groupby и split-apply-combine

groupby — это не одна операция, а схема из трёх шагов: разбить данные на группы, применить функцию к каждой, собрать результат.

split-apply-combine — модель группировки: split делит строки на группы по ключу, apply применяет функцию к каждой группе, combine склеивает результаты в одну таблицу.

Идея split-apply-combine

Почти любой вопрос вида «посчитать что-то в разрезе чего-то» — это группировка. «Средний чек по городам», «сумма продаж по месяцам», «число заказов на клиента» — все они укладываются в три шага:

  1. Split — разбить строки на группы по значению ключа (город, месяц, клиент).
  2. Apply — внутри каждой группы посчитать агрегат (сумму, среднее, количество).
  3. Combine — собрать по одному результату на группу в итоговую таблицу, где ключ группы становится индексом.

В pandas всё это пишется в одну строку:

df.groupby("город")["чек"].mean()
#            ^split^   ^что^  ^apply^   → combine происходит автоматически

Реализуем split-apply-combine на чистом Python

Лучший способ понять groupby — собрать его руками. Это ровно то, что pandas делает внутри, только на C и для целых массивов:

orders = [
    {"город": "Москва", "чек": 1200},
    {"город": "Сочи",   "чек": 800},
    {"город": "Москва", "чек": 1800},
    {"город": "Сочи",   "чек": 1000},
    {"город": "Москва", "чек": 600},
]

# SPLIT: раскладываем строки по группам (ключ → список значений)
groups = {}
for o in orders:
    groups.setdefault(o["город"], []).append(o["чек"])

# APPLY + COMBINE: для каждой группы считаем средний чек
for city in sorted(groups):
    cheks = groups[city]
    print(city, "->", sum(cheks) / len(cheks))

Вывод:

Москва -> 1200.0
Сочи -> 900.0

Словарь groups — это шаг split: ключ города → список чеков. Цикл с делением — apply (среднее в каждой группе). Печать по одной строке на город — combine. df.groupby("город")["чек"].mean() делает то же самое и возвращает Series, где индекс — города, а значения — средние чеки.

Ленивость: groupby ничего не считает сразу

Важная деталь: df.groupby("город") сам по себе не вычисляет ничего — он возвращает объект GroupBy, который лишь «знает», как разбить данные. Реальная работа происходит, только когда вы вызываете агрегат (.mean(), .sum(), .agg(...)). Это позволяет переиспользовать одну группировку для разных подсчётов:

g = df.groupby("город")     # ленивый объект, вычислений нет
g["чек"].mean()             # вот теперь считается среднее
g["чек"].sum()              # и здесь — сумма, по той же разбивке
g.size()                    # число строк в каждой группе

Итерация по группам

Объект GroupBy можно перебрать в цикле — он отдаёт пары «ключ группы, под-DataFrame». Это полезно для отладки или нестандартной обработки (но для агрегатов всегда предпочитайте встроенные методы — они быстрее):

for city, group_df in df.groupby("город"):
    print(city, len(group_df))   # имя группы и сколько в ней строк

Частые агрегаты и группировка по нескольким ключам

df.groupby("город")["чек"].sum()       # сумма
df.groupby("город")["чек"].count()     # число НЕпустых значений
df.groupby("город").size()             # число строк (включая пустые)
df.groupby("город")["чек"].agg(["min", "max", "mean"])  # несколько сразу
df.groupby(["город", "месяц"])["чек"].sum()  # по двум ключам → MultiIndex

Группировка по нескольким ключам даёт иерархический индекс (MultiIndex) — каждая уникальная пара (город, месяц) становится отдельной группой. Различие count() (не считает NaN) и size() (считает все строки) — частый источник расхождений в отчётах.

as_index и reset_index: ключ как индекс или столбец

По умолчанию ключ группировки уходит в индекс результата. Это удобно для дальнейшего выравнивания, но неудобно для выгрузки в файл или график, где хочется обычный столбец. Два способа вернуть его столбцом:

df.groupby("город", as_index=False)["чек"].sum()  # сразу столбцом
df.groupby("город")["чек"].sum().reset_index()    # превратить индекс в столбец после

Оба дают одинаковый плоский результат. Выбор стиля — дело вкуса, но важно осознавать, где у вас ключ: в индексе или в столбце. От этого зависит, как обращаться к результату дальше.

Прямая аналогия с SQL GROUP BY

Кто знает SQL, освоит groupby мгновенно: это тот же GROUP BY. Сравним на живой SQLite-песочнице:

CREATE TABLE orders (city TEXT, chek INTEGER);
INSERT INTO orders VALUES
  ('Москва', 1200), ('Сочи', 800), ('Москва', 1800),
  ('Сочи', 1000), ('Москва', 600);

SELECT city, AVG(chek) AS avg_chek, COUNT(*) AS n
FROM orders
GROUP BY city
ORDER BY city;

Вывод:

Москва|1200.0|3
Сочи|900.0|2

Соответствие почти дословное: GROUP BY citydf.groupby("город"), AVG(chek)["чек"].mean(), COUNT(*).size(), ORDER BY ≈ сортировка индекса. pandas даёт ровно те же средние (1200 и 900), что и наш ручной Python выше — модель split-apply-combine универсальна.

Где pandas гибче SQL? В том, что между «применить» и «собрать» вы не ограничены набором встроенных агрегатов. Можно подсунуть любую функцию, в том числе свою, вернуть несколько значений на группу, или вообще не агрегировать, а преобразовать каждую группу (об этом — следующий урок про transform). SQL для такого требует оконных функций и подзапросов, а в pandas это один вызов. Обратная сторона — pandas работает в памяти одной машины, тогда как СУБД считает группировку по терабайтам на диске. Поэтому на практике часто комбинируют: тяжёлую первичную агрегацию делают в SQL, а тонкую доводку — в pandas.

Подводные камни

  • count() против size(). count() игнорирует NaN по столбцу, size() считает все строки группы. В отчётах это разные числа.
  • Ключ группировки уходит в индекс. Результат имеет город индексом, а не столбцом; чтобы вернуть его столбцом, добавьте reset_index() или as_index=False.
  • NaN в ключе группировки. Строки с пропуском в ключе по умолчанию выпадают из группировки — это легко не заметить. Управляется параметром dropna=False.
  • Не итерируйте ради агрегатов. Цикл for ... in groupby читаем, но медленный; встроенные .sum()/.agg() на порядки быстрее.

Лучшие практики

  • Формулируйте задачу как «что считаем в разрезе чего» — и она почти всегда ложится на groupby.
  • Сохраняйте ленивый объект g = df.groupby(...), если нужно несколько разных агрегатов по той же разбивке.
  • Помните про reset_index()/as_index=False, когда ключ нужен как обычный столбец.
  • Если знаете SQL — переносите интуицию GROUP BY напрямую.

Итог

  • groupby = split (разбить) + apply (посчитать) + combine (собрать).
  • Объект GroupBy ленив: считает только при вызове агрегата.
  • Ключ группировки становится индексом результата.
  • Это прямой аналог SQL GROUP BY.
Проверьте себя
1. Что произойдёт при вызове df.groupby('город') без последующего агрегата?
AСразу посчитается среднее по всем столбцам
BВернётся ленивый объект GroupBy, который пока ничего не вычисляет
CБудет ошибка
DDataFrame отсортируется по городу
2. Чем отличаются count() и size() для группы?
AОни идентичны
Bcount() считает непустые значения по столбцу, size() — все строки группы включая NaN
Csize() считает только уникальные значения
Dcount() работает только с числами
3. Каким шагам split-apply-combine соответствует SQL-запрос с GROUP BY city и AVG(chek)?
AТолько apply
BGROUP BY — split, AVG — apply, формирование результирующих строк — combine
CGROUP BY — combine, AVG — split
DЭто разные модели и не сопоставимы
Поддержать проект