Время прочтения: 6 мин.

Да кто такой этот ваш Polars?!

Polars – это open-source библиотека для работы с данными, очень похожая на Pandas. Только новее и быстрее. За два года существования Polars набрал популярность благодаря своей молниеносной производительности. Это обусловлено тем, что он написан на Rust и использует в своей основе Apache Arrow, который ускоряет загрузку данных и вычисления, уменьшает использование памяти.

Самая интересная, на мой взгляд, особенность Polars – возможность работы в двух режимах:

— Eager mode (нетерпеливый режим) – операции выполняются немедленно, результат выполнения доступен в памяти. Для каждой операции требуется выделить фрейм данных, что не очень хорошо для памяти.

— Lazy mode (ленивый режим) – при запуске кода операции не выполняются сразу же, а добавляются в план запроса. Polars автоматически оптимизирует запрос, то есть считывает последовательность действий и ищет способы ускорить запрос и уменьшить использование памяти. В lazy mode есть возможность параллельного выполнения.

Pandas, в отличие от Polars, работает только в eager mode. Это означает, что он менее производительный и использует память не самым оптимальным образом.

Polars != Pandas

Работать в Polars после Pandas будет легко, потому что синтаксис у этих библиотек практически одинаковый. Но есть небольшие различия, которые я рассмотрю далее.

Импортирую Pandas и Polars.

import pandas as pd
import polars as pl

Для демонстрации возможностей библиотек был взят набор данных, содержащий информацию о продаже автомобилей. Датасет состоит из 500 000 строк и 17 столбцов, ссылка на датасет тут.

Считываю данные из csv файла с помощью Pandas…

df_pd = pd.read_csv('cars.csv', encoding='utf-8')
df_pd.head(5)

…и с помощью Polars:

df_pl = pl.read_csv('cars.csv', 
		     encoding='utf8',
		     dtypes={'engineDisplacement': str})
df_pl.head(5)

Сразу можно отметить два отличия DataFrame в Polars:

— тип данных каждого столбца указывается под его заголовком

— нет индексов у строк

Вопреки отсутствию индексации в Polars, к данным всё-таки можно обращаться по индексам в квадратных скобках вместо привычной для Pandas функции iloc.

Синтаксис для Pandas:

df_pd.iloc[15]

df_pd.iloc[10:15]

На мой взгляд, синтаксис для Polars проще:

df_pl[15, :]
df_pl[10:15, :]

Несмотря на то, что можно обращаться к данным по индексу, этот функционал в Polars считается антипаттерном и, возможно, будет удалён в будущих версиях библиотеки. А в lazy mode он вообще не предусмотрен. Отсутствие индексации, по мнению создателей Polars, значительно упрощает работу с данными.

Лучший способ обращения к данным в Polars – это использование выражений с select (для выбора столбцов) и filter (для выбора строк).

Выбираю определенные столбцы из DataFrame.

Синтаксис для Polars:

df_pl.select(['brand', 'color', 'mileage', 'price']).head(5)

Синтаксис для Pandas:

df_pd[['brand', 'color', 'mileage', 'price']].head(5)

В select() можно указывать не только названия столбцов, но и выражения, а также назначать алиасы.

Синтаксис для Polars:

df_pl.select([
    pl.col('price').max().alias('price_max'),
    pl.col('brand').n_unique().alias('brand_names_count'),
    pl.col('mileage').min().alias('mileage_min')
])

Аналогичная операция в Pandas требует создания нового DataFrame, что сложнее по синтаксису и занимает дополнительную память.

df_pd_select = pd.DataFrame(columns=['price_max', 'brand_names_count',
			              'mileage_min'])
df_pd_select['price_max'] = [df_pd['price'].max()]
df_pd_select['brand_names_count'] = [df_pd['brand'].nunique()]
df_pd_select['mileage_min'] = [df_pd['mileage'].min()]
df_pd_select

Выбираю строки, в которых содержится информация о продаже автомобилей Toyota, которые были выпущены после 2015 года, со стоимостью от 4 млн рублей.

Синтаксис для Polars:

df_pl.filter((pl.col('brand') == 'Toyota') 
	      & (pl.col('year') > 2015) 
	      & (pl.col('price') >= 4000000)).sort('price').limit(3)

Синтаксис для Pandas:

df_pd.loc[(df_pd['brand'] == 'Toyota') 
	  & (df_pd['year'] > 2015) 
	  & (df_pd['price'] >= 4000000)].sort_values('price').head(3)

Сила в лени

Если быть точными, скорость в лени. Как уже говорилось, в Polars есть lazy mode. Рассмотрю, насколько быстро происходит обработка данных в этом режиме.

Из набора данных необходимо выбрать чёрные Mercedes-Benz Седан, выпущенные после 2010 года.

Для того, чтобы переключиться в lazy mode, необходимо у DataFrame вызвать метод lazy(). И он превратится… в LazyFrame. Далее можно выполнять необходимые операции, но они не будут запущены, пока не будет вызван метод collect().

q = (
    df_pl.lazy()
    .drop_nulls()
    .filter(
        (pl.col('brand') == 'Mercedes-Benz')
        & (pl.col('color') == 'Черный')
        & (pl.col('bodyType') == 'Седан')
        & (pl.col('year') > 2010))
    .sort(['year', 'price'])
    .limit(5)
)
%time q.collect()

Выполню этот же запрос без преобразования изначального DataFrame в LazyFrame. Можно заметить, что выполнился он медленнее.

%time df_pl.drop_nulls()
      .filter(
	  (pl.col('brand') == 'Mercedes-Benz') 
          & (pl.col('color') == 'Черный') 
          & (pl.col('bodyType') == 'Седан') 
          & (pl.col('year') > 2010))
      .sort(['year', 'price'])
      .limit(5)

Создатели библиотеки рекомендуют пользоваться преимуществами lazy mode и считывать файлы сразу в LazyFrame, заменив метод read_csv() на scan_csv().

lazy_df_pl = pl.scan_csv('cars.csv', 
			   encoding='utf8', 
			   dtypes={'engineDisplacement': str})
lazy_df_pl

В таком случае данные не будут загружены в память полностью, Polars просканирует их и выдаст объект LazyFrame. Извлечь данные из LazyFrame можно, применив метод fetch(n), который покажет n строк.

lazy_df_pl.fetch(5)

Выполню запрос, который был приведен выше, к полученному LazyFrame. Можно отметить, что несмотря на свою «ленивость», он выполнялся дольше, чем предыдущие эксперименты.

q1 = (
lazy_df_pl.drop_nulls()
    .filter(
        (pl.col('brand') == 'Mercedes-Benz')
        & (pl.col('color') == 'Черный')
        & (pl.col('bodyType') == 'Седан')
        & (pl.col('year') > 2010))
    .sort(['year', 'price'])
    .limit(5)
    )
%time q1.collect()

Pandas аналогичную операцию выполняет значительно медленнее, чем Polars в любом режиме:

%time df_pd_merc = df_pd.dropna()
    	.loc[(df_pd['brand'] == 'Mercedes-Benz') 
         & (df_pd['color'] == 'Черный') 
         & (df_pd['bodyType'] == 'Седан') 
         & (df_pd['year'] > 2010)]
    .sort_values(['year', 'price'])
df_pd_merc.head(5)

Итак, сделаю выводы. Отсутствие индексации в Polars никак не влияет на получение данных из DataFrame. А главным отличием и преимуществом Polars является lazy mode, который выдает хорошие показатели производительности. Его можно использовать для более быстрой работы с тяжёлыми файлами. Надеюсь, моя публикация вдохновит вас попробовать Polars в своих задачах.