[Перевод] 4 анти-паттерна pandas и способы борьбы с ними
Pandas — это мощная библиотека для анализа данных, API которой обладает широкими функциональными возможностями. Этот API позволяет решить любую задачу, связанную с обработкой данных, несколькими способами. Некоторые из подходов к решению задач лучше других. Часто бывает так, что пользователи pandas узнают о подходах, не отличающихся особой эффективностью, привыкают к ним и постоянно их применяют. Этот материал посвящён разбору четырёх анти-паттернов pandas и рассказу о приёмах работы, которые стоит использовать вместо них.
Автор черпал вдохновение из многих источников, ссылки на которые даны в статье. В частности — из замечательной книги 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)
Мы рассмотрим следующие анти-паттерны:
Использование мутаций вместо цепочек методов.
Применение циклов
for
при работе с объектами pandasDataFrame
.Неоправданное использование
.apply
вместоnp.select
,np.where
и.isin
.Использование неподходящих типов данных.
Анти-паттерн №1: использование мутаций вместо цепочек методов
Большинство тех, кто использует pandas на практике, сначала осваивают не самый удачный подход к обработке данных. Он предусматривает пошаговое изменение (мутацию) объектов DataFrame
, выполняемое с помощью набора отдельных операций. Избыточные мутации объектов DataFrame
могут приводить к проблемам. У этого есть несколько причин:
Это приводит к расточительному использованию памяти из-за создания глобальных переменных (это особенно справедливо в тех случаях, когда на каждом шаге обработки данных создают новый объект
DataFrame
).Код получается громоздким, его тяжело читать.
Этот код подвержен ошибкам. В частности, в блокнотах Jupyter, где может случиться так, что не будет строго выдерживаться порядок выполнения шагов обработки данных.
При таком подходе часто возникают раздражающие и известные своей запутанностью уведомления
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 лучше не пользоваться циклами:
При таком подходе код получается неоправданно длинным, неуклюжим и несовместимым с цепочками методов. Альтернативные подходы позволяют достичь того же результата путём применения более компактного кода и при этом поддерживают объединение методов в цепочки.
Перебор отдельных строк — это медленно. Векторизованные операции обладают лучшей производительностью — особенно при обработке столбцов, содержащих данные числовых типов.
Представим, что нам надо использовать недавно созданный столбец 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-код таким способом — рекомендую вам его попробовать!
О, а приходите к нам работать?