Что вы делаете руками каждый день
В прошлом уроке мы прочитали данные и привели типы в порядок. Дальше начинается рутина DE: «дай мне всех клиентов из Москвы за прошлый месяц с суммой больше тысячи», «склей выгрузку заказов с справочником продуктов», «посчитай выручку по дням и сравни с прошлой неделей». Всё это в pandas делается тремя примитивами: фильтрация, группировка, джоин. Они напрямую соответствуют SQL: WHERE, GROUP BY, JOIN.
В этом уроке — минимум, который покрывает 80% запросов в реальной работе.
Boolean indexing — фильтр через маску
Базовый способ фильтрации: вы строите Series из True/False той же длины, что и DataFrame, и pandas возвращает строки, где True.
import pandas as pd
df = pd.DataFrame({
"user_id": [1, 2, 3, 4, 5],
"country": ["RU", "US", "RU", "DE", "US"],
"amount": [100, 250, 80, 999, 1500],
})
# простая маска
big = df[df["amount"] > 200]
print(big)
# user_id country amount
# 1 2 US 250
# 3 4 DE 999
# 4 5 US 1500
Внутри df["amount"] > 200 — это Series [False, True, False, True, True]. df[mask] отбирает строки, где маска True. Это
Комбинируем условия. Внимание: в pandas используется &, |, ~ (битовые операторы), а не and/or/not. И каждое условие в скобках:
# AND
mask = (df["amount"] > 100) & (df["country"] == "US")
df[mask]
# OR
mask = (df["country"] == "RU") | (df["amount"] > 1000)
df[mask]
# NOT
df[~(df["country"] == "RU")] # всё кроме RU
Почему & а не and. and в Python — это короткое замыкание, работает с двумя одиночными bool, а не с массивом. Вы получите ValueError: ambiguous truth value. & — это NumPy/Arrow-операция на двух Series-масках, возвращающая Series-маску.
Полезные методы для масок
# принадлежность списку
df[df["country"].isin(["RU", "US"])]
# вхождение в диапазон
df[df["amount"].between(100, 500, inclusive="both")]
# поиск подстроки в текстовой колонке
df[df["product"].str.contains("organic", case=False, na=False)]
# null/not-null
df[df["amount"].notna()]
df[df["email"].isna()]
# проверка по началу строки
df[df["url"].str.startswith("https://")]
na=False в str.contains — обязательно для колонок с пропусками. Без него NaN в строке вернёт NaN-маску, и df[mask] упадёт. Лучше явно: NaN не подходит под фильтр — мы её и не берём.
query — синтаксис ближе к SQL
Длинные масочные выражения становятся нечитабельными. Альтернатива — df.query("..."):
df.query("amount > 100 and country == 'US'")
# с переменной
threshold = 500
df.query("amount > @threshold")
# isin
df.query("country in ['RU', 'US']")
@variable внутри строки query — это ссылка на переменную из внешней области. Под капотом query парсит выражение в Python-AST и применяет его. Чуть медленнее boolean indexing, но читабельнее на длинных условиях.
Не злоупотребляйте query для всего подряд. Простые фильтры через boolean indexing — короче и быстрее. Query берите, когда условие длинное, читабельность важнее скорости, или нужно динамически собирать строку фильтра.
loc и iloc — два способа адресации
df.loc — доступ по меткам (значениям index и имён колонок). df.iloc — по позициям (целочисленные 0..N).
# loc: метки строк (index), имена колонок
df.loc[0, "amount"] # значение в строке с index=0, колонке amount
df.loc[0:2, ["amount", "country"]] # строки 0,1,2 (включая 2!) и две колонки
df.loc[df["amount"] > 100, "country"] # маска для строк, колонка country
# iloc: целочисленные позиции
df.iloc[0, 2] # первая строка, третья колонка
df.iloc[0:2, [2, 3]] # первые две строки, колонки 2 и 3
df.iloc[-1] # последняя строка
Различия не очевидны на дефолтном RangeIndex. Они проявляются, когда index — не 0..N. Например, после df.set_index("user_id") метки — это id, а позиции — всё ещё 0..N. df.loc[42] возьмёт пользователя с id=42, df.iloc[42] — 43-ю по порядку строку.
После set_index('user_id') метки строк — id пользователей, позиции — порядковые номера.
Правило: для бизнес-логики (фильтры, выборки по ключу) — loc. Для механических вещей (первая строка, последние 10) — iloc.
В pandas 2.x старая ловушка SettingWithCopyWarning («ты меняешь копию, а не оригинал») встречается реже — большинство операций возвращают view-or-copy более предсказуемо. Если столкнулись с предупреждением — используйте .loc[...] = ... напрямую, без цепочек типа df[df["x"] > 0]["y"] = 1.
groupby — split-apply-combine
Главная команда DE после фильтра — групповая агрегация. Парадигма pandas —
- Split: делим DataFrame на группы по значениям колонки.
- Apply: применяем агрегатную функцию к каждой группе.
- Combine: склеиваем результаты в новый DataFrame/Series.
df = pd.DataFrame({
"country": ["RU", "US", "RU", "DE", "US", "RU"],
"amount": [100, 250, 80, 999, 1500, 200],
})
# одна агрегация
df.groupby("country")["amount"].sum()
# country
# DE 999
# RU 380
# US 1750
# Name: amount, dtype: int64
# несколько агрегаций
df.groupby("country")["amount"].agg(["sum", "mean", "count"])
# sum mean count
# country
# DE 999 999.000000 1
# RU 380 126.666667 3
# US 1750 875.000000 2
Многоколонная группировка и разные агрегации на разные колонки
В реальной выгрузке часто нужно: «по стране и валюте, сумма по amount и количество уникальных user_id».
df = pd.DataFrame({
"country": ["RU", "RU", "US", "US", "RU"],
"currency": ["RUB", "USD", "USD", "USD", "RUB"],
"amount": [100, 200, 300, 400, 50],
"user_id": [1, 2, 3, 3, 1],
})
df.groupby(["country", "currency"]).agg(
total_amount=("amount", "sum"),
avg_amount=("amount", "mean"),
unique_users=("user_id", "nunique"),
)
# total_amount avg_amount unique_users
# country currency
# RU RUB 150 75.0 1
# RU USD 200 200.0 1
# US USD 700 350.0 1
Этот синтаксис —
total_amount, avg_amount.
Часто используемые агрегатные функции: sum, mean, median, min, max, count (не-null), size (всё), nunique (число уникальных), first, last, std.
transform — агрегат рядом с каждой строкой
Иногда нужна сумма по группе в каждой строке, чтобы посчитать долю.
df["country_total"] = df.groupby("country")["amount"].transform("sum")
df["share"] = df["amount"] / df["country_total"]
transform возвращает Series той же длины, что исходный DataFrame, заполненный соответствующим групповым агрегатом. Это broadcast — обратное к агрегации.
pivot vs pivot_table
pivot — превращает «длинную» форму в «широкую»: одну колонку в набор колонок.
df = pd.DataFrame({
"date": ["2025-01-01", "2025-01-01", "2025-01-02", "2025-01-02"],
"country": ["RU", "US", "RU", "US"],
"amount": [100, 200, 150, 250],
})
# pivot — простой переворот, требует уникальности (date, country)
df.pivot(index="date", columns="country", values="amount")
# country RU US
# date
# 2025-01-01 100 200
# 2025-01-02 150 250
Что если у вас несколько строк для одной пары (date, country)? pivot упадёт. Нужен pivot_table с агрегатом:
df.pivot_table(
index="date",
columns="country",
values="amount",
aggfunc="sum",
fill_value=0,
)
Правило: pivot — когда данные уже агрегированы и уникальны. pivot_table — когда есть дубликаты, и вы хотите их свернуть.
merge — джойны как в SQL
Самая частая операция склейки таблиц. df.merge(other, ...) соответствует SQL JOIN:
orders = pd.DataFrame({
"order_id": [1, 2, 3, 4],
"user_id": [101, 102, 101, 999],
"amount": [100, 250, 80, 50],
})
users = pd.DataFrame({
"user_id": [101, 102, 103],
"name": ["Anna", "Bob", "Cathy"],
"country": ["RU", "US", "DE"],
})
# inner join — только те, у кого есть match
orders.merge(users, on="user_id", how="inner")
# order_id user_id amount name country
# 1 101 100 Anna RU
# 2 102 250 Bob US
# 3 101 80 Anna RU
# left join — все orders, юзеры подставляются где есть
orders.merge(users, on="user_id", how="left")
# (order 4 с user_id=999 остаётся, name/country = NaN)
# right join — все users, orders где есть
orders.merge(users, on="user_id", how="right")
# outer (full) join — все из обеих таблиц
orders.merge(users, on="user_id", how="outer")
Прямая аналогия с SQL JOIN: разница в том, какие строки сохраняются.
90% случаев — how="left": «возьми мою таблицу заказов и докинь к ней имена пользователей». Если у заказа нет пользователя в справочнике — оставь заказ, NaN в name.
Подводные камни merge
Разные имена ключевых колонок:
# orders.user_id, users.id
orders.merge(users, left_on="user_id", right_on="id")
Колонки с одинаковыми именами (кроме ключа):
# в обеих таблицах есть колонка "country" — pandas добавит суффиксы
orders.merge(users, on="user_id", suffixes=("_order", "_user"))
# country_order, country_user
Дубликаты в правой таблице удваивают строки:
# если users содержит user_id=101 дважды (баг в данных),
# каждый заказ юзера 101 удвоится в результате
orders.merge(users, on="user_id", validate="many_to_one")
# validate упадёт, если ожидание не выполняется
validate — параметр, который явно требует структуру отношения: "many_to_one" (на левой стороне могут быть повторы, на правой — нет), "one_to_one", "one_to_many". Хорошая привычка — указывать всегда, ловит ошибки в данных рано.
concat — UNION ALL
Если merge — это JOIN, то concat — это UNION ALL. Склеить два DataFrame по строкам:
df_jan = pd.read_csv("jan.csv")
df_feb = pd.read_csv("feb.csv")
# вертикальная конкатенация (по строкам)
all_data = pd.concat([df_jan, df_feb], ignore_index=True)
# горизонтальная (по колонкам — редкий случай)
combined = pd.concat([df_features, df_labels], axis=1)
ignore_index=True пересчитывает index 0..N. Без этого index будет 0..N для jan, потом снова 0..N для feb — дублирующиеся метки, неудобно.
DE-кейс: денормализация через merge
Классическая задача. У вас есть три таблицы:
orders = pd.DataFrame({
"order_id": [1, 2, 3],
"user_id": [101, 102, 101],
"product_id": ["A", "B", "A"],
"quantity": [2, 1, 3],
})
users = pd.DataFrame({
"user_id": [101, 102, 103],
"name": ["Anna", "Bob", "Cathy"],
"country": ["RU", "US", "DE"],
})
products = pd.DataFrame({
"product_id": ["A", "B", "C"],
"name": ["Apple", "Banana", "Cherry"],
"price": [50, 30, 80],
})
Денормализовать в одну широкую таблицу для отчёта:
report = (
orders
.merge(users, on="user_id", how="left", suffixes=("", "_user"))
.merge(products, on="product_id", how="left", suffixes=("", "_product"))
.assign(total_amount=lambda d: d["quantity"] * d["price"])
[["order_id", "name_user", "country", "name_product", "quantity", "total_amount"]]
)
.assign добавляет вычислённую колонку. lambda d: ... — потому что нам нужен текущий DataFrame после двух merge. [[...]] в конце — выбор интересующих колонок. Чейнинг через . читается сверху вниз как SQL pipeline.
Этот же запрос на SQL выглядел бы:
SELECT
o.order_id,
u.name AS name_user,
u.country,
p.name AS name_product,
o.quantity,
o.quantity * p.price AS total_amount
FROM orders o
LEFT JOIN users u ON o.user_id = u.user_id
LEFT JOIN products p ON o.product_id = p.product_id;
Один в один — это та же логика, два разных синтаксиса. Если вы уверенно делаете JOIN в SQL, вы уже делаете merge в pandas — нужно только привыкнуть к именам параметров.
Упражнение
Возьмите три DataFrame:
import pandas as pd
orders = pd.DataFrame({
"order_id": [1, 2, 3, 4, 5],
"user_id": [101, 102, 101, 103, 102],
"product_id": ["A", "B", "C", "A", "B"],
"quantity": [2, 1, 3, 1, 2],
"created": pd.to_datetime(["2025-01-01", "2025-01-02", "2025-01-02", "2025-01-03", "2025-01-03"]),
})
users = pd.DataFrame({
"user_id": [101, 102, 103],
"country": ["RU", "US", "DE"],
})
products = pd.DataFrame({
"product_id": ["A", "B", "C"],
"price": [100, 50, 200],
})
Напишите функцию country_revenue(orders, users, products) -> pd.DataFrame, которая возвращает выручку по стране:
- Колонки результата:
country,total_revenue(суммаquantity * priceпо стране). - Сортировка: по
total_revenueубывающая.
Критерии приёмки:
- Используется
mergeсhow="left"от orders. - Используется
groupby("country")с агрегацией. - Используется
validate="many_to_one"при merge на справочники. - Для тестовых данных получается:
- RU: 800 (заказы 1 и 3: 2100 + 3200 = 200 + 600)
- US: 150 (заказы 2 и 5: 50 + 2*50)
- DE: 100 (заказ 4: 1*100)
В следующем уроке мы научимся работать с датами и временными окнами — для отчётов по дням, неделям и rolling-агрегатов.