10 корисних порад обробки даних у Pandas
Привіт! Мене звати Дмитро Самчук, я Machine Learning Engineer, в компанії Levi9 займаюся розробкою проєктів, в основі яких лежать ML і Data Science-технології.
За 5 років у комерційній розробці мені доводилося працювати з різними бібліотеками для аналізу даних, зокрема і з Pandas. У цій статті я ділюся своїм топ-10 речей, які, на мою думку, корисно знати, працюючи з цією бібліотекою.
Спирався суто на власний досвід, тож деякі спостереження суттєвіші за інші, а щось вам може бути вже відомо. Сподіваюся, що кожен знайде для себе принаймні одне відкриття.

Категоріальний тип даних
За замовчуванням, колонки, що мають обмежену кількість варіантів (категорії) зчитуються як тип object. З точки зору використання пам’яті це не зовсім те, чого хотілося б — бажано мати індекс такої колонки та замість реальних значень «під капотом» використовувати лише посилання на об’єкт.
На щастя, такий тип даних існує у pandas, а саме CategoricalDtype.
Розглянемо приклад. Нещодавно я готував датасет тріплетів для metric learning. Враховуючи певні внутрішні причини, вирішив зробити великий датафрейм шляхів до картинок, кожний рядок якого мав 3 колонки: anchor, positive, negative.
Вхідний датафрейм виглядає так:
+----------+------------------------+ | class | filename | +----------+------------------------+ | Bathroom | Bathroom\bath_1.jpg | | Bathroom | Bathroom\bath_100.jpg | | Bathroom | Bathroom\bath_1003.jpg | | Bathroom | Bathroom\bath_1004.jpg | | Bathroom | Bathroom\bath_1005.jpg | +----------+------------------------+
Бажаний вигляд:
+------------------------+------------------------+----------------------------+ | anchor | positive | negative | +------------------------+------------------------+----------------------------+ | Bathroom\bath_1.jpg | Bathroom\bath_100.jpg | Dinning\din_540.jpg | | Bathroom\bath_100.jpg | Bathroom\bath_1003.jpg | Dinning\din_1593.jpg | | Bathroom\bath_1003.jpg | Bathroom\bath_1004.jpg | Bedroom\bed_329.jpg | | Bathroom\bath_1004.jpg | Bathroom\bath_1005.jpg | Livingroom\living_1030.jpg | | Bathroom\bath_1005.jpg | Bathroom\bath_1007.jpg | Bedroom\bed_1240.jpg | +------------------------+------------------------+----------------------------+
Значення колонки `filename` дублюватиметься часто, тому має сенс зробити її категоріальною.
Для прикладу завантажимо вже перетворений датасет і подивимося на різницю в пам’яті (з використанням категоріального типу та без):
# with categories triplets.info(memory_usage="deep") # Column Non-Null Count Dtype # --- ------ -------------- ----- # 0 anchor 525000 non-null category # 1 positive 525000 non-null category # 2 negative 525000 non-null category # dtypes: category(3) # memory usage: 4.6 MB # without categories triplets_raw.info(memory_usage="deep") # Column Non-Null Count Dtype # --- ------ -------------- ----- # 0 anchor 525000 non-null object # 1 positive 525000 non-null object # 2 negative 525000 non-null object # dtypes: object(3) # memory usage: 118.1 MB
Як бачимо, різниця суттєва. При цьому вона нелінійно збільшуватиметься зі зростанням кількості дублювань.
Розгортання колонок в рядки
Поглянемо на такий датасет зі змагання Kaggle. А саме на файл ‘census_starter.csv’, що виглядає наступним чином:
+-------+-------------+-------------+-------------+-------------+-------------+ | cfips | pct_bb_2017 | pct_bb_2018 | pct_bb_2019 | pct_bb_2020 | pct_bb_2021 | +-------+-------------+-------------+-------------+-------------+-------------+ | 1001 | 76.6 | 78.9 | 80.6 | 82.7 | 85.5 | | 1003 | 74.5 | 78.1 | 81.8 | 85.1 | 87.9 | | 1005 | 57.2 | 60.4 | 60.5 | 64.6 | 64.6 | | 1007 | 62.0 | 66.1 | 69.2 | 76.1 | 74.6 | | 1009 | 65.8 | 68.5 | 73.0 | 79.6 | 81.0 | | 1011 | 49.4 | 58.9 | 60.1 | 60.6 | 59.4 | | 1013 | 58.2 | 62.1 | 64.6 | 73.6 | 76.3 | | 1015 | 71.0 | 73.0 | 75.1 | 79.8 | 81.6 | | 1017 | 62.8 | 66.5 | 69.4 | 74.5 | 77.1 | | 1019 | 67.5 | 68.6 | 70.7 | 75.0 | 76.7 | | 1021 | 56.6 | 61.8 | 68.7 | 74.6 | 78.3 | +-------+-------------+-------------+-------------+-------------+-------------+
Очевидно, що ці колонки на практиці не застосовуються, адже набагато краще було б мати одну колонку «year» та «pct_bb», і по 5 рядків з відповідними значеннями, як показано в таблиці нижче:
+-------+------+--------+ | cfips | year | pct_bb | +-------+------+--------+ | 1001 | 2017 | 76.6 | | 1001 | 2018 | 78.9 | | 1001 | 2019 | 80.6 | | 1001 | 2020 | 82.7 | | 1001 | 2021 | 85.5 | | 1003 | 2017 | 74.5 | | 1003 | 2018 | 78.1 | | 1003 | 2019 | 81.8 | | 1003 | 2020 | 85.1 | | 1003 | 2021 | 87.9 | +-------+------+--------+
Цього результату можна досягти різними шляхами, але найчастіше використовується метод melt().
cols = sorted([col for col in original_df.columns \
if col.startswith("pct_bb")])
df = original_df[(["cfips"] + cols)]
df = df.melt(id_vars="cfips",
value_vars=cols,
var_name="year",
value_name=feature).sort_values(by=["cfips", "year"])
Подібний функціонал досягається також за допомогою wide_to_long().
apply() повільний, але виходу немає
Імовірно, вам вже відомо, що цим шляхом краще не йти, бо він буквально ітерує кожен рядок і викликає вказаний метод. Але доволі часто альтернатив не лишається — тоді можна скористатися пакетами як swifter або pandarallel, який паралелізує цей процес.
""" Swifter way """ import pandas as pd import swifter def target_function(row): return row * 10 def traditional_way(data): data['out'] = data['in'].apply(target_function) def swifter_way(data): data['out'] = data['in'].swifter.apply(target_function)
""" Pandarallel way """ import pandas as pd from pandarallel import pandarallel def target_function(row): return row * 10 def traditional_way(data): data['out'] = data['in'].apply(target_function) def pandarallel_way(data): pandarallel.initialize() data['out'] = data['in'].parallel_apply(target_function)
Звісно, якщо у вас є кластер та ви зацікавлені в дистрибутивному обчисленні дійсно великих датасетів, краще скористатися dask або pyspark.
Пусті значення, int, Int64
Тут дуже коротко: стандартний int тип не може мати пустих значень. Будьте уважні, бо за замовчуванням це буде float.
Якщо у вашій схемі передбачаються пусті значення в int полі, використовуйте Int64. В такому разі замість конвертування у float ви отримаєте стабільні цілі числа та pandas.NA.
В мене був випадок, коли роками одна колонка індексувалась як int (ніколи не мала пустих значень), як раптом одного дня через помилку бекенду пусті значення там таки з’явились.
Зберігати в csv чи parquet?
На це питання відповідь однозначна: якщо нічого не заважає, то parquet. Він зберігає типи даних і вам не потрібно більше передавати dtypes при читанні.
Крім того, файл займає дуже мало місця на диску. До прикладу, я зберіг однаковий датасет у форматах csv (no compression), zip, gzip та parquet. Це особливо важливо, коли у вас великі масиви даних у хмарі і ви сплачуєте за обсяг спожитого дискового простору.
+------------------------+---------+ | file | size | +------------------------+---------+ | triplets_525k.csv | 38.4 MB | | triplets_525k.csv.gzip | 4.3 MB | | triplets_525k.csv.zip | 4.5 MB | | triplets_525k.parquet | 1.9 MB | +------------------------+---------+
Однак, для його читання вам необхідні додаткові пакети, як pyarrow або fastparquet. До того ж не можна просто так взяти та передати аргумент nrows, щоб вичитати перші N рядків. Це робиться трохи складніше, — наприклад, як показано тут.
При цьому хочу зауважити, що мені достеменно невідомі усі обмеження використання паркет-файлів. Запрошую ділитись думками в коментарях!
Відносні value_counts()
Певний час я шукав відносні частоти дещо складнішим способом — явним групуванням, підрахунком і діленням на загальну кількість.
Проте, якщо звернути увагу на аргументи цього методу, то можна вчинити набагато простіше, водночас вирішуючи, чи брати до уваги пусті значення.
df = pd.DataFrame({"a": [1, 2, None], "b": [4., 5.1, 14.02]})
df["a"] = df["a"].astype("Int64")
print(df.info())
print(df["a"].value_counts(normalize=True, dropna=False),
df["a"].value_counts(normalize=True, dropna=True), sep="\n\n")
# # Column Non-Null Count Dtype
#--- ------ -------------- -----
# 0 a 2 non-null Int64
# 1 b 3 non-null float64
#1 0.333333
#2 0.333333
#<NA> 0.333333
#Name: a, dtype: Float64
#1 0.5
#2 0.5
#Name: a, dtype: Float64
Modin для експериментів
Можливо, цей пункт варто було поставити на перше місце. Ця бібліотека досі популярна, особливо тим, що потребує змінити лише один рядок у вашому скрипті/ ноутбуці, а саме імпорт бібліотеки pandas на modin.pandas.
Я наразі не користуюсь цим рішенням у «проді», тому що там, як мінімум, є pyspark. Але для локальних експериментів чи аналізу даних, як на мене, це дуже хороша ідея.
Тут документація з використання.
Іменовані групи та метод extract
Доволі часто можна зустріти складні, але структуровані строки, звідки вам треба виділити частини та зробити з них окремі стовпці.
Розглянемо приклад з адресами:
addresses_structured = pd.Series([ "Україна, Полтавська обл, м. Полтава, вул. Полтавська, 0", 'Україна, Київська обл, м. Вишневе, вул. Вишнівська, 1', 'Україна, Львівська обл, м. Львів, вул. Львівська, 999']) regex_structured = ( r"(?P<country>[А-Яа-яїі]+),\s" r"(?P<region>[А-Яа-яїі\']+(?:\sобл\.*)),\s" r"(?P<city>(?:[смт]+\.\s*)[А-Яа-яїі\']+),\s" r"(?P<street>(?:вул+\.\s*)[А-Яа-яїі\']+),\s" r"(?P<street_num>\d+)") addresses_structured.str.extract(regex_structured)
Маємо такий результат:
+---------+----------------+------------+-----------------+------------+ | country | region | city | street | street_num | +---------+----------------+------------+-----------------+------------+ | Україна | Полтавська обл | м. Полтава | вул. Полтавська | 0 | | Україна | Київська обл | м. Вишневе | вул. Вишнівська | 1 | | Україна | Львівська обл | м. Львів | вул. Львівська | 999 | +---------+----------------+------------+-----------------+------------+
Читання з буфера обміну
Наступний трюк не стільки важливий, скільки веселий. Я його використовував декілька разів, коли цікаво було додати до аналізу табличку з pdf-файлу. Типовим рішенням було б скопіювати все необхідне, вставити в табличний редактор, експортувати в csv і прочитати його у Pandas.
Однак, є простіше рішення. А саме: pd.read_from_clipboard(). Просто копіюєте потрібні дані та запускаєте — на кшталт такого:
df_cb = pd.read_clipboard(header=None) df_cb.head()
Якщо таблиця з групуваннями, можна читати буфер у різні змінні і потім скласти в одне ціле.
Рекомендую просто запам’ятати, що така можливість існує. До речі, так само можна і експортувати в буфер відповідно методом to_clipboard(). Власне так я і вставляв таблиці в цю статтю.
Інакший спосіб «розпаковувати» теги у мультілейбл стиль
Скажімо, є у нас такий датасет, що доволі типово:
+---+---+----------------+ | a | b | cat | +---+---+----------------+ | 1 | 4 | ['foo', 'bar'] | | 2 | 5 | ['foo'] | | 3 | 6 | ['qux'] | +---+---+----------------+
Очевидно, аби тренувати якусь модель або просто аналізувати дані, нам потрібно зробити з нього щось таке:
+---+---+---------+---------+---------+ | a | b | cat_bar | cat_foo | cat_qux | +---+---+---------+---------+---------+ | 1 | 4 | 1 | 1 | 0 | | 2 | 5 | 0 | 1 | 0 | | 3 | 6 | 0 | 0 | 1 | +---+---+---------+---------+---------+
Мені відомо про 3 різні шляхи досягнення цього результату, а саме:
- ітеративно зробити з кожного масиву
pd.Series; - векторизований спосіб;
- використати
sklearn.preprocessing.MultiLabelBinarizer().
Я наведу ці 3 приклади й подивимося, як швидко вони працюють.
import pandas as pd
from sklearn.preprocessing import MultiLabelBinarizer
df = pd.DataFrame({"a": [1, 2, 3],
"b": [4, 5, 6],
"category": [["foo", "bar"], ["foo"], ["qux"]]})
# збільшимо кількість рядків в датафреймі
df = pd.concat([df]*10000, ignore_index=True)
def dummies_series_apply(df):
return df.join(df['category'].apply(pd.Series)
.stack()
.str.get_dummies()
.groupby(level=0)
.sum())
.drop("category", axis=1)
def dummies_vectorized(df):
return pd.get_dummies(df.explode("category"), prefix="cat")
.groupby(["a", "b"])
.sum()
.reset_index()
def sklearn_mlb(df):
mlb = MultiLabelBinarizer()
return df.join(pd.DataFrame(mlb.fit_transform(df['category']),
columns=mlb.classes_))
.drop("category", axis=1)
%timeit dummies_series_apply(df.copy())
# 3.27 s ± 27.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit dummies_vectorized(df.copy())
# 13.3 ms ± 246 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit sklearn_mlb(df.copy())
# 16 ms ± 345 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
Як бачимо, перший метод, що доволі розповсюджений серед відповідей на stackoverflow, дає неймовірно повільний результат. Я вже опускаю те, що в цьому випадку заміряти час виконання більш як 1 цикл ноутбук навіть не став.
Натомість 2 інші варіанти цілком прийнятні.
Висновок
Сподіваюся, кожен читач знайде для себе щось нове у цій підбірці. Особливої уваги, мабуть, заслуговує висновок: варто менше використовувати apply(), а надавати перевагу векторизованим операціям.
Також, слід пам’ятати, що є цікавіші альтернативи зберігання датасетів у csv. І не забувайте про категоріальний тип, адже це зекономить вам багато пам’яті.
4 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів