Learning Platform
Урок 09.02 · 26 мин
Начальный
pandasfilteringgroupbymergepivotjoins
SQL GROUP BY: модель группировки и связь с pandas groupby Хеш-функции: что происходит внутри groupby и merge

Что вы делаете руками каждый день

В прошлом уроке мы прочитали данные и привели типы в порядок. Дальше начинается рутина 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. Это

vectorized
, ничего быстрого вы не сделаете руками.

Комбинируем условия. Внимание: в 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, но читабельнее на длинных условиях.

TIP

Не злоупотребляйте 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-ю по порядку строку.

loc vs iloc на нестандартном индексе

После set_index('user_id') метки строк — id пользователей, позиции — порядковые номера.

DataFrameindex=user_id
user_id=101amount=100
user_id=205amount=250
user_id=312amount=80
df.loc[205]amount=250
по метке user_id
df.iloc[1]amount=250
по позиции 1

Правило: для бизнес-логики (фильтры, выборки по ключу) — loc. Для механических вещей (первая строка, последние 10) — iloc.

WARNING

В pandas 2.x старая ловушка SettingWithCopyWarning («ты меняешь копию, а не оригинал») встречается реже — большинство операций возвращают view-or-copy более предсказуемо. Если столкнулись с предупреждением — используйте .loc[...] = ... напрямую, без цепочек типа df[df["x"] > 0]["y"] = 1.

groupby — split-apply-combine

Главная команда DE после фильтра — групповая агрегация. Парадигма pandas —

split-apply-combine
:

  1. Split: делим DataFrame на группы по значениям колонки.
  2. Apply: применяем агрегатную функцию к каждой группе.
  3. 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

Этот синтаксис —

named aggregation
— самый чистый: имена результирующих колонок задаются явно. Вместо мультииндекса в колонках вы получаете обычные имена 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")
Виды merge: что попадает в результат

Прямая аналогия с SQL JOIN: разница в том, какие строки сохраняются.

innerпересечение
только matchиз обеих
наименьший
leftвсе из левой
праваяNaN если нет
самый частый
rightвсе из правой
леваяNaN если нет
редко нужен
outerвсё из обеих
недостающееNaN
full outer

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-агрегатов.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4