[Перевод] 4 анти-паттерна pandas и способы борьбы с ними

Pandas — это мощная библиотека для анализа данных, API которой обладает широкими функциональными возможностями. Этот API позволяет решить любую задачу, связанную с обработкой данных, несколькими способами. Некоторые из подходов к решению задач лучше других. Часто бывает так, что пользователи pandas узнают о подходах, не отличающихся особой эффективностью, привыкают к ним и постоянно их применяют. Этот материал посвящён разбору четырёх анти-паттернов pandas и рассказу о приёмах работы, которые стоит использовать вместо них.

c91cf9a6872100a0f679d7665279d9fb.png

Автор черпал вдохновение из многих источников, ссылки на которые даны в статье. В частности — из замечательной книги Effective Pandas.

Данные, используемые в примерах

Здесь, для показа примеров удачных и неудачных способов работы в pandas, я пользуюсь набором данных Netflix с Kaggle. Он содержит сведения о почти 6000 сериалов и фильмов Netflix. Эти сведения разбиты на 15 характеристик, представленных данными различных типов.

import pandas as pd

# https://www.kaggle.com/datasets/victorsoeiro/netflix-tv-shows-and-movies
df = pd.read_csv("titles.csv")
print(df.sample(5, random_state=0))
print(df.shape)

# результаты:
#             id                  title   type                                        description  release_year age_certification  runtime                                             genres production_countries  seasons     imdb_id  imdb_score  imdb_votes  tmdb_popularity  tmdb_score
# 1519   ts38761        Another Miss Oh   SHOW  Two women working in the same industry with th...          2016             TV-MA       69          ['drama', 'fantasy', 'romance', 'comedy']               ['KR']      1.0   tt5679572         7.9      1769.0           22.672         8.2
# 4942  ts225657                Halston   SHOW  American fashion designer Halston skyrockets t...          2021             TV-MA       47                                          ['drama']               ['US']      1.0  tt10920514         7.5     14040.0           21.349         7.3
# 895    tm34646             Sisterakas  MOVIE  A man takes revenge on his sister by hiring he...          2012               NaN      110                                ['drama', 'comedy']               ['PH']      NaN   tt2590214         5.2       286.0            2.552         4.9
# 5426  ts301609  Love Is Blind: Brazil   SHOW  The dating experiment comes to Brazil as local...          2021             TV-MA       56                             ['romance', 'reality']               ['BR']      1.0  tt15018224         6.1       425.0            5.109         6.4
# 2033   ts56038         Dave Chappelle   SHOW  Comedy icon Dave Chappelle makes his triumphan...          2017               NaN       60                        ['comedy', 'documentation']               ['US']      1.0   tt6963504         8.7      2753.0            2.962         7.6
# (5806, 15)

Мы рассмотрим следующие анти-паттерны:

  1. Использование мутаций вместо цепочек методов.

  2. Применение циклов for при работе с объектами pandas DataFrame.

  3. Неоправданное использование .apply вместо np.select,  np.where и .isin.

  4. Использование неподходящих типов данных.

Анти-паттерн №1: использование мутаций вместо цепочек методов

Большинство тех, кто использует pandas на практике, сначала осваивают не самый удачный подход к обработке данных. Он предусматривает пошаговое изменение (мутацию) объектов DataFrame, выполняемое с помощью набора отдельных операций. Избыточные мутации объектов DataFrame могут приводить к проблемам. У этого есть несколько причин:

  1. Это приводит к расточительному использованию памяти из-за создания глобальных переменных (это особенно справедливо в тех случаях, когда на каждом шаге обработки данных создают новый объект DataFrame).

  2. Код получается громоздким, его тяжело читать.

  3. Этот код подвержен ошибкам. В частности, в блокнотах Jupyter, где может случиться так, что не будет строго выдерживаться порядок выполнения шагов обработки данных.

  4. При таком подходе часто возникают раздражающие и известные своей запутанностью уведомления SettingWithCopyWarning.

И, кстати, передача операции параметра inplace=True тут не помогает.

Вместо последовательных мутаций объектов DataFrame лучше будет трансформировать их, объединяя в цепочки методы pandas.

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

df = pd.read_csv("titles.csv")

# Мутация - ТАК ДЕЛАТЬ НЕ НАДО
df_bad = df.query("runtime > 30 & type == 'SHOW'")
df_bad["score"] = df_bad[["imdb_score", "tmdb_score"]].sum(axis=1)
df_bad = df_bad[["seasons", "score"]]
df_bad = df_bad.groupby("seasons").agg(["count", "mean"])
df_bad = df_bad.droplevel(axis=1, level=0)
df_bad = df_bad.query("count > 10")

# Цепочки методов - РЕКОМЕНДОВАНО
df_good = (df
    .query("runtime > 30 & type == 'SHOW'")
    .assign(score=lambda df_: df_[["imdb_score", "tmdb_score"]].sum(axis=1))
    [["seasons", "score"]]
    .groupby("seasons")
    .agg(["count", "mean"])
    .droplevel(axis=1, level=0)
    .query("count > 10")
)

# результаты:
#          count       mean
# seasons
# 1.0        835  13.064671
# 2.0        189  14.109524
# 3.0         83  14.618072
# 4.0         41  14.887805
# 5.0         38  15.242105
# 6.0         16  15.962500

При объединении методов в цепочку выполняется однопроходная трансформация DataFrame в соответствии с многошаговой процедурой. Это гарантирует полное и правильное применение каждого использованного метода pandas, а значит — снижает риск возникновения ошибок. Улучшается читабельность кода — каждая строка чётко представляет отдельную операцию. (Обратите внимание на то, что многие средства для форматирования Python-кода разрушают эту структуру; чтобы этого избежать — оформляйте фрагменты pandas-кода в виде блоков, ограниченных '#fmt: off' и '#fmt: on'). Объединение методов в цепочки покажется естественным для пользователей R, знакомых с оператором magrittr %>%.

Метод pandas .pipe

Иногда может понадобиться выполнить сложное преобразование данных, которое нельзя красиво реализовать, пользуясь готовыми методами, объединяемыми в цепочки. В подобных ситуациях можно прибегнуть к методу pandas .pipe. Он позволяет абстрагировать сложные трансформации объектов DataFrame в виде самостоятельно определённых функций.

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

df = pd.read_csv("titles.csv")

def split_prod_countries(df_):
    # разделить столбец `production_countries` (содержащий список строк,
    # представляющих страны) на три отдельных столбца с кодами отдельных стран
    dfc = pd.DataFrame(df_["production_countries"].apply(eval).to_list())
    dfc = dfc.iloc[:, :3]
    dfc.columns = ["prod_country1", "prod_country2", "prod_country3"]
    return df_.drop("production_countries", axis=1).join(dfc)

df_pipe = df.pipe(split_prod_countries)

print(df["production_countries"].sample(5, random_state=14))
# результаты:
# 3052    ['CA', 'JP', 'US']
# 1962                ['US']
# 2229                ['GB']
# 2151          ['KH', 'US']
# 3623                ['ES']

print(df_pipe.sample(5, random_state=14).iloc[:, -3:])
# результаты:
#      prod_country1 prod_country2 prod_country3
# 3052            CA            JP            US
# 1962            US          None          None
# 2229            GB          None          None
# 2151            KH            US          None
# 3623            ES          None          None

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

Вопросы об объединении методов в цепочки и об использовании .pipe

Как отлаживать код с методами pandas, объединёнными в цепочки?

Начать можно с простого комментирования интересующих вас строк (то есть — методов) в коде, описывающем цепочку. О более продвинутых способах отладки подробно написано в 35 главе книги Effective Pandas.

Как быть, если при использовании цепочки методов нужно заглянуть в объект DataFrame на одном из этапов его обработки?

Для этого можно, применив .pipe, добавить в нужное место цепочки нижеприведённую функцию, использующую IPython.display.display. Это позволит вывести DataFrame на промежуточном этапе обработки и при этом не помешать работе цепочки методов. Вот эта функция:

.pipe(lambda df_: display(df_) or df_)

Как, в процессе работы цепочки методов, сохранить на одном из этапов работы DataFrame в отдельной переменной?

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

Анти-паттерн №2: применение циклов for при работе с объектами pandas DataFrame

Если при работе с pandas используются циклы for — это признак плохого кода. От таких циклов надо избавляться. Это касается и встроенных методов-генераторов DataFrame.iterrows() и DataFrame.itertuples(). Вот две причины, в соответствии с которыми в pandas лучше не пользоваться циклами:

  1. При таком подходе код получается неоправданно длинным, неуклюжим и несовместимым с цепочками методов. Альтернативные подходы позволяют достичь того же результата путём применения более компактного кода и при этом поддерживают объединение методов в цепочки.

  2. Перебор отдельных строк — это медленно. Векторизованные операции обладают лучшей производительностью — особенно при обработке столбцов, содержащих данные числовых типов.

Представим, что нам надо использовать недавно созданный столбец prod_country1 для создания ещё одного столбца. Его содержимое указывает на то, входит ли страна из prod_country1 в топ-3/10/20 стран по частоте их появления. В следующем фрагменте кода показан нерациональный подход, где используется цикл for, а так же пример более чистого решения задачи, основанного на .apply.

df = pd.read_csv("titles.csv").pipe(split_production_countries)

# получение сведений о частоте появления стран
vcs = df["prod_country1"].value_counts()
top3 = vcs.index[:3]
top10 = vcs.index[:10]
top20 = vcs.index[:20]

# Цикл - ТАК ДЕЛАТЬ НЕ НАДО
vals = []
for ind, row in df.iterrows():
    country = row["prod_country1"]
    if country in top3:
        vals.append("top3")
    elif country in top10:
        vals.append("top10")
    elif country in top20:
        vals.append("top20")
    else:
        vals.append("other")
df["prod_country_rank"] = vals

# df[col].apply() - РЕКОМЕНДОВАНО
def get_prod_country_rank(country):
    if country in top3:
        return "top3"
    elif country in top10:
        return "top10"
    elif country in top20:
        return "top20"
    else:
        return "other"

df["prod_country_rank"] = df["prod_country1"].apply(get_prod_country_rank)
print(df.sample(5, random_state=14).iloc[:, -4:])
# результаты:
#      prod_country1 prod_country2 prod_country3 prod_country_rank
# 3052            CA            JP            US             top10
# 1962            US          None          None              top3
# 2229            GB          None          None              top3
# 2151            KH            US          None             other
# 3623            ES          None          None             top10

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

Во всех случаях, когда обрабатывать объекты DataFrame можно с помощью цикла for, .apply позволяет изящнее добиться тех же результатов.

Но .apply — это не векторизованная операция. В её недрах используется перебор строк DataFrame. В результате, если только вы не работаете со столбцами типа object (обычно — со строками), вместо .apply стоит использовать более производительные механизмы. Это ведёт нас к анти-паттерну №3…

Анти-паттерн №3: неоправданное использование .apply вместо np.select, np.where и .isin

Как только pandas-программист узнаёт про .apply, часто случается так, что он начинает применять эту конструкцию буквально повсюду. Это, само по себе, не всегда говорит о проблемах, так как .apply позволяет создавать понятный код, обладающий, на наборах данных умеренных размеров, адекватной производительностью.

Но при работе с большими наборами данных, при выполнении «тяжёлых» вычислений .apply может быть на порядки медленнее более эффективных, векторизованных механизмов. В этом материале хорошо показано преимущество подобных оптимизированных механизмов перед .apply.

Комбинация np.select и .isin

Есть один полезный паттерн — комбинация np.select и .isin, который можно использовать вместо .apply. Вот как выглядит его применение к нашей задаче об оценке стран.

df = pd.read_csv("titles.csv").pipe(split_production_countries)

def get_prod_country_rank(df_):
    vcs = df_["prod_country1"].value_counts()
    return np.select(
        condlist=(
            df_["prod_country1"].isin(vcs.index[:3]),
            df_["prod_country1"].isin(vcs.index[:10]),
            df_["prod_country1"].isin(vcs.index[:20]),
        ),
        choicelist=("top3", "top10", "top20"),
        default="other"
    )

df = df.assign(prod_country_rank=lambda df_: get_prod_country_rank(df_))

Тут нет такого же впечатляющего роста производительности, как в вышеупомянутой статье, так как мы работаем со столбцами типа object. Кроме того, большая часть вычислений связана с подсчётом значений, а это в обоих вариантах примеров выполняется одинаково. Но, несмотря на это, новый код примерно в 2 раза быстрее, чем тот, где используется .apply. Если же вычисление количества значений размещено внутри функции, используемой с .apply, то код оказывается в 500 раз медленнее, чем при совместном использовании np.select и .isin.

В дополнение к улучшению производительности, размещение вычисления количества значений в функции, где совместно используются np.select и .isin, позволяет выполнить эти трансформации в виде «цепочечной» операции без глобальных зависимостей. Обратите внимание на то, что список условий np.select ведёт себя так же, как блок if elif else, осуществляя выбор элемента на основании первого встреченного выражения, возвращающего True.

Об np.where

В случаях, когда выбирать нужно лишь из двух вариантов, можно прибегнуть к альтернативе — к методу np.where. Например, представьте, что мы знаем о том, что в значения оценок IMDB вкралась ошибка, и нам надо вычесть 1 из всех оценок сериалов и фильмов, вышедших после 2016 года. Сделать это можно так:

df = df.assign(
    adjusted_score=lambda df_: np.where(
        df_["release_year"] > 2016, df_["imdb_score"] - 1, df_["imdb_score"]
    )
)

Применение метода .apply, если только речь не идёт о работе со столбцами типа object, будет менее производительным, чем использование других подходов. Вместо .apply можно воспользоваться np.select,  np.where и .isin, что позволит производить эффективные, векторизованные преобразования данных.

По непонятной причине pd.where работает немного не так, как np.where. Но этот метод может пригодиться в том случае, если нужно оставить в столбце все значения, соответствующие некоему условию, заменив при этом всё остальное (например — оставить значения, входящие в топ-5, а всё остальное установить в значение 'other').

Анти-паттерн №4: использование неподходящих типов данных

Подбор наиболее адекватных типов данных для столбцов объектов DataFrame позволяет улучшить производительность программ и снизить потребление памяти. Работая с большими наборами данных, можно привести значения типов float64 и int64, используемых по умолчанию, к их более компактным эквивалентам, таким, как float16 и int8. Применив такое преобразование к столбцам, в которых оно не вызовет потери данных, можно добиться значительных улучшений.

Но самое страшное несовпадение типов, от которого стоит чистить pandas-код — это использование строк, а не pandas.Categorical, для описания категориальных признаков. Предположим, имеется столбец с категориальными данными, содержащий небольшое количество отличающихся значений. Если преобразовать тип этого столбца с используемого по умолчанию object, к типу category, это часто способно привести к примерно 100-кратному улучшению использования памяти, и к 10-кратному росту скорости вычислений. В следующем примере показано выполнение подобного преобразования с использованием методов, объединённых в цепочку.

df = pd.read_csv("titles.csv")

df = df.assign(
    age_certification=lambda df_: df_["age_certification"].astype("category")
)

Если ограничения, связанные с производительностью и/или с памятью, причиняют вам неудобства при работе с большими наборами данных — подумайте об оптимизации типов данных в ваших DataFrame. Особенно это касается преобразования типов object к типу categorial в применении к тем столбцам, для которых это имеет смысл.

Здесь можно найти сведения о преимуществах использования оптимизированных типов данных.

Итоги

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

import pandas as pd
import numpy as np

df = pd.read_csv("titles.csv")

def split_prod_countries(df_):
    dfc = pd.DataFrame(df_["production_countries"].apply(eval).to_list())
    dfc = dfc.iloc[:, :3]
    dfc.columns = ["prod_country1", "prod_country2", "prod_country3"]
    return df_.drop("production_countries", axis=1).join(dfc)

def get_prod_country_rank(df_):
    vcs = df_["prod_country1"].value_counts()
    return np.select(
        condlist=(
            df_["prod_country1"].isin(vcs.index[:3]),
            df_["prod_country1"].isin(vcs.index[:10]),
            df_["prod_country1"].isin(vcs.index[:20]),
        ),
        choicelist=("top3", "top10", "top20"),
        default="other",
    )

def get_adjusted_score(df_):
    return np.where(
        df_["release_year"] > 2016, df_["imdb_score"] - 1, df_["imdb_score"]
    )

(df
    .query("runtime > 30 & type == 'SHOW'")
    .pipe(split_prod_countries)
    .assign(
        imdb_score=lambda df_: get_adjusted_score(df_),
        score=lambda df_: df_[["imdb_score", "tmdb_score"]].sum(axis=1),
        prod_country_rank=lambda df_: get_prod_country_rank(df_),
        rank=lambda df_: df_["prod_country_rank"].astype("category")
    )
    [["rank", "score"]]
    .groupby("rank")
    .agg(["count", "mean"])
    .droplevel(axis=1, level=0)
    .sort_values("mean", ascending=False)
)

#результаты:
#        count       mean
# rank                   
# top10     37  15.232432
# other   1104  12.824819
# top3      78  12.624359
# top20     20  10.775000

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

О, а приходите к нам работать?

© Habrahabr.ru