Сообщить об ошибке.

Polars быстрая библиотека DataFrame для Python

Библиотека Polars - это невероятно быстрая библиотека DataFrame для управления структурированными данными. Ядро написано на Rust и доступно для Python.

Документация Polars: https://docs.pola.rs/user-guide/getting-started/

Ключевые особенности:

  • Написан с нуля на языке Rust, разработан без внешних зависимостей.
  • Первоклассная поддержка всех распространенных уровней хранения данных: локального, облачного хранилища и баз данных.
  • Интуитивно понятный API: пишите запросы так, как они были задуманы. Polars самостоятельно определит наиболее эффективный способ выполнения с помощью своего оптимизатора запросов.
  • потоковый API позволяет обрабатывать результаты, не требуя одновременного нахождения всех ваших данных в памяти.
  • использует мощность компьютера путем распределения рабочей нагрузки между доступными ядрами ЦП без какой-либо дополнительной настройки.

Установка библиотеки Polars в виртуальное окружение:

# создаем виртуальное окружение, если нет
$ python3 -m venv .venv --prompt VirtualEnv
# активируем виртуальное окружение 
$ source .venv/bin/activate
# ставим модуль `Polars`
(VirtualEnv):~$ python3 -m pip install -U polars

Используя приведенную выше команду, установится ядро ​​Polars. Однако в зависимости от варианта использования, можно установить дополнительные зависимости. Они сделаны необязательными, чтобы минимизировать занимаемую память.

Пример установки Polars с зависимостями:

(VirtualEnv):~$ python3 -m pip install -U 'polars[numpy,fsspec]'

Список доступных зависимостей Polars:

ЗависимостьОписание
allВсе дополнительные зависимости
pandasУстановит вместе с Pandas для преобразования данных в и из Pandas Dataframes/Series.
numpyУстановит с numpy для преобразования данных в массивы numpy и обратно.
pyarrowЧтение форматов данных с помощью PyArrow
fsspecПоддержка чтения из удаленных файловых систем
connectorxПоддержка чтения из баз данных SQL
xlsx2csvПоддержка чтения из файлов Excel
deltalakeПоддержка чтения из таблиц Delta Lake
plotПоддержка построения изображений Dataframes
timezoneПоддержка часового пояса необходима только в том случае, если используется Python 3.9 и/или используется Windows, в противном случае зависимости не будут установлены.

Содержание:


Различия в понятиях между Polars и pandas.

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

У Polars нет Index/MultiIndex

Библиотека pandas присваивает каждой строке метку с индексом. Polars не использует индекс, и каждая строка индексируется по ее целочисленной позиции в таблице.

Целью Polars является получение предсказуемых результатов и читаемых запросов, поэтому считается, что индекс не поможет достичь этой цели. Разработчики Polars считают, что семантика запроса не должна меняться в зависимости от состояния индекса или вызова reset_index.

В Polars DataFrame всегда будет двумерной таблицей с разнородными типами данных. Типы данных могут иметь вложенность, но сама таблица - нет. Такие операции, как повторная выборка, будут выполняться специализированными функциями или методами, которые действуют как "глаголы" в таблице, явно указывая столбцы, с которыми работает этот "глагол". Таким образом, отсутствие индексов делает вещи более простыми, более явными, более читабельными и менее подверженными ошибкам.

Обратите внимание, что "индексная" структура данных, известная в базах данных, будет использоваться Polars в качестве метода оптимизации.

Polars использует массивы Apache Arrow для представления данных в памяти, а pandas использует массивы NumPy.

Внимание! В данное время pandas тестирует представление данных в памяти с помощью Apache Arrow. С версии Pandas 3.0 этот способ будет доступен всем пользователям.

Polars представляет данные в памяти с помощью массивов Apache Arrow, а pandas (в данное время) представляет данные в памяти с помощью массивов NumPy. Apache Arrow - это новый стандарт столбчатой ​​аналитики в памяти, который может ускорить загрузку данных, сократить использование памяти и ускорить вычисления.

Polars может конвертировать данные в формат NumPy с помощью метода df.to_numpy().

У Polars больше поддержки параллельных операций, чем у pandas.

Polars использует сильную поддержку параллелизма в Rust для параллельного выполнения множества операций. Хотя некоторые операции в pandas являются многопоточными, ядро ​​библиотеки является однопоточным, и для распараллеливания операций необходимо использовать дополнительную библиотеку, такую ​​как Dask.

Polars может "лениво" оценивать запросы и применять оптимизацию запросов.

Оперативная оценка - это когда код оценивается сразу после запуска. Отложенная оценка - это когда выполнение строки кода означает, что базовая логика добавляется в план запроса, а не оценивается.

Polars поддерживает оперативную оценку и отложенную оценку, в то время как pandas поддерживает только оперативную оценку. Режим отложенной оценки эффективен, поскольку Polars выполняет автоматическую оптимизацию запроса, когда проверяет план запроса и ищет способы ускорить выполнение запроса или уменьшить использование памяти.

Dask также поддерживает отложенную оценку при создании плана запроса. Однако Dask не выполняет оптимизацию запроса в плане запроса.

Ключевые синтаксические различия

Внимание! Во всех примерах демонстрируется абстрактный код.

Пользователям, пришедшим из pandas, как правило, нужно знать одну вещь... Что polars != pandas

Если код Polars выглядит так, как будто это может быть код pandas, он может работать, но, скорее всего, работает медленнее, чем следовало бы.

Далее пройдемся по некоторому типичному коду pandas и посмотрим, как мы могли бы переписать его в Polars.

Выбор данных

Так как в Polars нет индекса, в Polars нет метода .loc или iloc - и в Polars также нет SettingWithCopyWarning.

Лучший способ выбрать данные в Polars - это использовать expression API. Например, если необходимо выбрать столбец в pandas, то можно выполнить одно из следующих действий:

df['a']
df.loc[:,'a']

но в Polars необходимо использовали метод df.select():

df.select('a')

Если нужно выбрать строки на основе значений, то в Polars использует метод фильтрации df.filter():

df.filter(pl.col('a') < 10)

Как отмечено в разделе о выражениях ниже, Polars могут выполнять операции в df.select() и df.filter() параллельно, и могут выполнять оптимизацию запросов по полному набору критериев выбора данных.

Режим отложенной оценки

Работа в режиме отложенной оценки проста и должна использоваться по умолчанию в Polars, т.к. отложенный режим позволяет Polars выполнять оптимизацию запросов.

Можно работать в отложенном режиме, либо используя неявно отложенную функцию (такую как pl.scan_csv()), либо явно используя отложенный метод.

Возьмем следующий простой пример, в котором считывает CSV-файл с диска и выполняет операцию df.group_by(). Просто сгруппируем по одному из столбцов идентификатора (id1), а затем суммировать по столбцу значения (v1). В pandas это было бы:

df = pd.read_csv(csv_file, usecols=['id1','v1'])
grouped_df = df.loc[:,['id1','v1']].groupby('id1').sum('v1')

В Polars можно создать этот запрос в отложенном режиме с оптимизацией запроса и оценить его, заменив функцию pandas pd.read_csv() на неявно отложенную функцию Polars pl.scan_csv():

df = pl.scan_csv(csv_file)
grouped_df = df.group_by('id1').agg(pl.col('v1').sum()).collect()

Polars оптимизирует этот запрос, определяя, что только столбцы id1 и v1 являются релевантными, и поэтому будет считывать только эти столбцы из CSV. Вызывая метод .collect в конце второй строки, даем указание Polars внимательно оценить запрос.

Если нужно запустить этот запрос в режиме ожидания, то можно просто заменить pl.scan_csv на pl.read_csv в коде Polars.

Альтернативный способ получить доступ к lazy API - это вызвать метод .lazy для фрейма данных, который уже создан в памяти.

q3 = pl.DataFrame({"foo": ["a", "b", "c"], "bar": [0, 1, 2]}).lazy()

Преобразования с использованием выражений

Типичный скрипт pandas состоит из нескольких преобразований данных, которые выполняются последовательно. В Polars эти преобразования могут выполняться параллельно с использованием выражений.

Добавление столбцов

Допустим есть фрейм данных df со столбцом под названием value . Необходимо добавить два новых столбца, столбец с именем tenXValue, где столбец значений умножается на 10, и столбец с именем hunderdXValue, где столбец значений умножается на 100.

В pandas это будет:

df.assign(
    tenXValue=lambda df_: df_.value * 10,
    hundredXValue=lambda df_: df_.value * 100
)

Эти назначения столбцов выполняются последовательно.

В Polars добавляются столбцы с помощью метода df.with_columns():

df.with_columns(
    tenXValue=pl.col("value") * 10,
    hundredXValue=pl.col("value") * 100,
)

Эти назначения столбцов выполняются параллельно.

Добавление столбца на основе условия

В данном случае используем фрейм данных df со столбцами a, b и c. Необходимо повторно назначить значения в столбце a на основе условия. Когда значение в столбце c равно 2, заменить значение в a значением из b .

В pandas это будет:

df.assign(a=lambda df_: df_.a.where(df_.c != 2, df_.b))

в то время как в Polars это будет:

df.with_columns(
    pl.when(pl.col("c") == 2)
    .then(pl.col("b"))
    .otherwise(pl.col("a")).alias("a")
)

Polars может вычислять каждую ветвь if-then-иначе параллельно. Это ценно, когда вычисление ветвей становится более затратным.

Фильтрация данных

Нужно отфильтровать фрейм данных df с данными о жилье на основе некоторых критериев.

В pandas фильтруется фрейм данных, передавая логические выражения в метод запроса df.query():

df.query("m2_living > 2500 and price < 300000")

или путем прямой логической индексации:

df[(df["m2_living"] > 2500) & (df["price"] < 300000)]

В Polars вызывается метод фильтра df.filter():

df.filter(
    (pl.col("m2_living") > 2500) & (pl.col("price") < 300000)
)

Оптимизатор запросов в Polars также может определить, что пишется несколько фильтров отдельно и объединяет их в один фильтр в оптимизированном плане.

Групповые операции и преобразования с данными

Документация pandas демонстрирует операцию над группой с помощью преобразования. Например, есть dataframe, и необходимо добавить новый столбец, показывающий количество строк в каждой группе.

В pandas:

df = pd.DataFrame({
    "c": [1, 1, 1, 2, 2, 2, 2],
    "type": ["m", "n", "o", "m", "m", "n", "n"],
})

df["size"] = df.groupby("c")["type"].transform(len)

# ВЫВОД
#    c type size
# 0  1    m    3
# 1  1    n    3
# 2  1    o    3
# 3  2    m    4
# 4  2    m    4
# 5  2    n    4
# 6  2    n    4

Здесь pandas выполняет группировку по 'c', берет столбец 'type', вычисляет длину группы, а затем присоединяет результат обратно к исходному фрейму данных.

В Polars то же самое может быть достигнуто с помощью оконных функций:

df.with_columns(
    pl.col("type").count().over("c").alias("size")
)

# ВЫВОД
# shape: (7, 3)
# ┌─────┬──────┬──────┐
# │ c   ┆ type ┆ size │
# │ --- ┆ ---  ┆ ---  │
# │ i64 ┆ str  ┆ u32  │
# ╞═════╪══════╪══════╡
# │ 1   ┆ m    ┆ 3    │
# │ 1   ┆ n    ┆ 3    │
# │ 1   ┆ o    ┆ 3    │
# │ 2   ┆ m    ┆ 4    │
# │ 2   ┆ m    ┆ 4    │
# │ 2   ┆ n    ┆ 4    │
# │ 2   ┆ n    ┆ 4    │
# └─────┴──────┴──────┘

Так как можно сохранить всю операцию в одном выражении, то можно комбинировать несколько оконных функций и даже объединять разные группы!

Polars будут кэшировать оконные выражения, которые применяются к одной и той же группе, поэтому хранить их в одном with_columns удобно и оптимально. В следующем примере рассмотрим случай, когда дважды вычисляем групповую статистику по 'c':

df.with_columns(
    pl.col("c").count().over("c").alias("size"),
    pl.col("c").sum().over("type").alias("sum"),
    pl.col("type").reverse().over("c").alias("reverse_type")
)

# ВЫВОД
# shape: (7, 5)
# ┌─────┬──────┬──────┬─────┬──────────────┐
# │ c   ┆ type ┆ size ┆ sum ┆ reverse_type │
# │ --- ┆ ---  ┆ ---  ┆ --- ┆ ---          │
# │ i64 ┆ str  ┆ u32  ┆ i64 ┆ str          │
# ╞═════╪══════╪══════╪═════╪══════════════╡
# │ 1   ┆ m    ┆ 3    ┆ 5   ┆ o            │
# │ 1   ┆ n    ┆ 3    ┆ 5   ┆ n            │
# │ 1   ┆ o    ┆ 3    ┆ 1   ┆ m            │
# │ 2   ┆ m    ┆ 4    ┆ 5   ┆ n            │
# │ 2   ┆ m    ┆ 4    ┆ 5   ┆ n            │
# │ 2   ┆ n    ┆ 4    ┆ 5   ┆ m            │
# │ 2   ┆ n    ┆ 4    ┆ 5   ┆ m            │
# └─────┴──────┴──────┴─────┴──────────────┘

Отсутствующие данные

Pandas использует значения NaN и/или None для указания пропущенных значений в зависимости от dtype столбца. Кроме того, поведение в pandas варьируется в зависимости от того, используются dtypes по умолчанию или необязательные массивы с нулевым значением. В Polars отсутствующим данным соответствует нулевое значение для всех типов данных.

Для столбцов с плавающей запятой Polars разрешает использовать значения NaN. Эти значения NaN считаются не отсутствующими данными, а специальным значением с плавающей запятой.

В pandas целочисленный столбец с пропущенными значениями преобразуется в столбец с плавающей точкой со значениями NaN для пропущенных значений (если не используются необязательные целочисленные dtypes с нулевым значением). В Polars любые пропущенные значения в целочисленном столбце являются просто нулевыми значениями, и столбец остается целочисленным столбцом.

Цепочки вызовов методов/функций

Обычным использованием в pandas является использование канала для применения некоторой функции к фрейму данных. Копирование этого стиля кодирования в Polars является однотипным и приводит к неоптимальным планам запросов.

Приведенный ниже фрагмент показывает общую закономерность у pandas.

def add_foo(df: pd.DataFrame) -> pd.DataFrame:
    df["foo"] = ...
    return df

def add_bar(df: pd.DataFrame) -> pd.DataFrame:
    df["bar"] = ...
    return df


def add_ham(df: pd.DataFrame) -> pd.DataFrame:
    df["ham"] = ...
    return df

(df
 .pipe(add_foo)
 .pipe(add_bar)
 .pipe(add_ham)
)

Если сделать это в polars, то создается 3 контекста with_columns, которые заставят Polars запускать 3 канала последовательно, используя нулевой параллелизм.

Способ получить аналогичные абстракции в polars - это создать функции, создающие выражения. Приведенный ниже фрагмент создает 3 выражения, которые выполняются в одном контексте и, следовательно, могут выполняться параллельно.

def get_foo(input_column: str) -> pl.Expr:
    return pl.col(input_column).some_computation().alias("foo")

def get_bar(input_column: str) -> pl.Expr:
    return pl.col(input_column).some_computation().alias("bar")

def get_ham(input_column: str) -> pl.Expr:
    return pl.col(input_column).some_computation().alias("ham")

# This single context will run all 3 expressions in parallel
df.with_columns(
    get_ham("col_a"),
    get_bar("col_b"),
    get_foo("col_c"),
)

Если в функциях необходима схема, генерирующая выражения, то можно использовать один канал:

from collections import OrderedDict

def get_foo(input_column: str, schema: OrderedDict) -> pl.Expr:
    if "some_col" in schema:
        # branch_a
        ...
    else:
        # branch b
        ...

def get_bar(input_column: str, schema: OrderedDict) -> pl.Expr:
    if "some_col" in schema:
        # branch_a
        ...
    else:
        # branch b
        ...

def get_ham(input_column: str) -> pl.Expr:
    return pl.col(input_column).some_computation().alias("ham")

# Use pipe (just once) to get hold of the schema of the LazyFrame.
lf.pipe(lambda lf: lf.with_columns(
    get_ham("col_a"),
    get_bar("col_b", lf.schema),
    get_foo("col_c", lf.schema),
)

Еще одним преимуществом написания функций, возвращающих выражения, является то, что эти функции являются составными, т.к. выражения могут быть объединены в цепочки и частично применены, что приводит к гораздо большей гибкости в дизайне.