Shanraq.org Shanraq.org
Группировки и агрегаты: посчитать по категориям и по месяцам
IT

Python: от данных до своей сводки Урок 29 из 56

Группировки и агрегаты: посчитать по категориям и по месяцам

Двадцать восьмой урок курса по Python. `groupby` — это цикл, написанный вопросом: разрезать таблицу по ключу, посчитать каждый кусок, собрать обратно. Разбираем `agg` со своими именами столбцов, два ключа, ключ, которого нет в таблице, `transform` для доли и группы, которые теряются молча.

Зачем это нужно

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

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

Если вы помните GROUP BY из двадцать второго урока — это он и есть, только над таблицей в памяти вашей программы.

Сразу целиком

Файл gruppy.py. Двенадцать чеков за два месяца и семь способов их сложить.

"""Урок 28: группировки и агрегаты.

Двенадцать чеков за два месяца. Считаем по категориям, по городам и по месяцам —
и ни одного цикла.
"""

import pandas as pd

rows = [
    ("2026-01-03", "Костанай", "еда", 4200),
    ("2026-01-05", "Костанай", "топливо", 18500),
    ("2026-01-07", "Рудный", "еда", 2600),
    ("2026-01-09", "Костанай", "связь", 4990),
    ("2026-01-15", "Костанай", "аренда", 95000),
    ("2026-01-18", "Рудный", "топливо", 16200),
    ("2026-01-24", "Костанай", "еда", 3100),
    ("2026-02-02", "Рудный", "еда", 2800),
    ("2026-02-06", "Костанай", "топливо", 19100),
    ("2026-02-11", "Костанай", "связь", 4990),
    ("2026-02-15", "Костанай", "аренда", 95000),
    ("2026-02-20", "Рудный", "аренда", 62000),
]
df = pd.DataFrame(rows, columns=["day", "city", "kind", "amount"])
df["day"] = pd.to_datetime(df["day"])

print("== сумма по категориям")
print(df.groupby("kind")["amount"].sum().sort_values(ascending=False))

print()
print("== три числа сразу")
print(df.groupby("kind")["amount"].agg(["count", "sum", "mean"]).round(0))

print()
print("== свои имена столбцов")
report = df.groupby("kind").agg(
    чеков=("amount", "count"),
    всего=("amount", "sum"),
    средний=("amount", "mean"),
).round(0)
print(report.sort_values("всего", ascending=False))

print()
print("== два ключа")
by_two = df.groupby(["city", "kind"])["amount"].sum()
print(by_two)
print("подписи:", type(by_two.index).__name__)

print()
print("== обратно в плоскую таблицу")
print(by_two.reset_index().head(3))

print()
print("== по месяцам: ключ, которого нет в таблице")
by_month = df.groupby(df["day"].dt.to_period("M"))["amount"].agg(["count", "sum"])
print(by_month)

print()
print("== count считает непустые значения, size — строки")
with_gap = df.copy()
with_gap.loc[6, "amount"] = None
print("count:", with_gap.groupby("kind")["amount"].count().to_dict())
print("size: ", with_gap.groupby("kind").size().to_dict())

print()
print("== доля категории внутри города")
df["доля"] = (df["amount"] / df.groupby("city")["amount"].transform("sum") * 100).round(1)
print(df.loc[df["city"] == "Рудный", ["kind", "amount", "доля"]].to_string(index=False))

Выводит:

== сумма по категориям
kind
аренда     252000
топливо     53800
еда         12700
связь        9980
Name: amount, dtype: int64

== три числа сразу
         count     sum     mean
kind                           
аренда       3  252000  84000.0
еда          4   12700   3175.0
связь        2    9980   4990.0
топливо      3   53800  17933.0

== свои имена столбцов
         чеков   всего  средний
kind                           
аренда       3  252000  84000.0
топливо      3   53800  17933.0
еда          4   12700   3175.0
связь        2    9980   4990.0

== два ключа
city      kind   
Костанай  аренда     190000
          еда          7300
          связь        9980
          топливо     37600
Рудный    аренда      62000
          еда          5400
          топливо     16200
Name: amount, dtype: int64
подписи: MultiIndex

== обратно в плоскую таблицу
       city    kind  amount
0  Костанай  аренда  190000
1  Костанай     еда    7300
2  Костанай   связь    9980

== по месяцам: ключ, которого нет в таблице
         count     sum
day                   
2026-01      7  144590
2026-02      5  183890

== count считает непустые значения, size — строки
count: {'аренда': 3, 'еда': 3, 'связь': 2, 'топливо': 3}
size:  {'аренда': 3, 'еда': 4, 'связь': 2, 'топливо': 3}

== доля категории внутри города
   kind  amount  доля
    еда    2600   3.1
топливо   16200  19.4
    еда    2800   3.3
 аренда   62000  74.2

Разбор

Разрезать, посчитать, собрать

df.groupby("kind")["amount"].sum() делает три вещи: режет таблицу на куски по значению kind, считает сумму в каждом, складывает куски обратно в один ряд. Ключ становится подписью — тем самым индексом из двадцать пятого урока, — поэтому ответ можно сразу сортировать, фильтровать и складывать с другим таким же рядом.

Порядок групп по умолчанию — по возрастанию ключа, а не по величине ответа. Если нужен рейтинг, его просят отдельно: .sort_values(ascending=False).

Несколько чисел сразу и свои имена

agg(["count", "sum", "mean"]) даёт три столбца вместо одного. Имена у них — имена функций, и в отчёте они выглядят как черновик.

Именованная форма читается как определение отчёта:

df.groupby("kind").agg(
    чеков=("amount", "count"),
    всего=("amount", "sum"),
    средний=("amount", "mean"),
)

Слева — имя столбца в ответе, справа — пара «по какому столбцу считать» и «чем считать». В одном agg можно смешивать разные столбцы: дороже=("amount", "max"), первый_день=("day", "min").

count считает непустые значения, size — все строки

Разница не косметическая. count считает значения, которые есть, — любые: числа, строки, даты; пропуски он пропускает. size считает строки группы целиком. Расхождение между ними и есть число пропусков в том столбце, который вы посчитали. В примере после одного стёртого значения у «еды» count даёт 3, а size — 4.

Отсюда же и разница в обращении: count спрашивают у столбца (groupby(...)["amount"].count()), а size — у группы целиком, ей столбец не нужен.

Это первый из двух способов молча получить неверный ответ. Второй хуже.

Группы, которые теряются

groupby по умолчанию выбрасывает строки, где ключ пустой. Сумма по группам тогда не сходится с общей суммой, и ничто об этом не говорит:

df["amount"].sum()                        → 1000
df.groupby("kind")["amount"].sum().sum()  → 800

Лечится аргументом dropna=False: пропуск становится отдельной группой. Привычка, которая стоит того, чтобы её завести: после группировки сверять итог с итогом по всей таблице. Если они разошлись — вы уже знаете, где искать.

Два ключа и подписи в два этажа

groupby(["city", "kind"]) даёт MultiIndex: подпись строки состоит из пары. Это удобно смотреть и неудобно передавать дальше, поэтому чаще всего сразу пишут .reset_index() — и получают обычную плоскую таблицу с двумя столбцами-ключами.

Ключ, которого нет в таблице

Группировать можно не только по столбцу, но и по любому ряду той же длины. Отсюда «по месяцам»: df["day"].dt.to_period("M") превращает дату в месяц, и по нему же группируем — столбца «месяц» в таблице при этом может и не быть.

to_period("M") даёт не строку "2026-01", а период: он сортируется как время, а не как текст, и умеет складываться с числом месяцев. Для группировки по дням есть dt.date, по годам — dt.year, по дням недели — dt.day_name().

transform: ответ группы рядом с каждой строкой

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

df["amount"] / df.groupby("city")["amount"].transform("sum") * 100

У каждой строки в знаменателе стоит сумма её собственного города. Тем же приёмом считают отклонение от среднего по группе и место внутри группы (rank).

Карта урока

Карта урока: разрезать, посчитать, собрать

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Что делает groupby и во что превращается ключ в ответе?
  2. Чем count отличается от size и что означает разница между ними?
  3. Чем transform отличается от agg и когда нужен именно он?

Разминка

Три коротких шага перед заданием: предсказать, дописать, починить. Ответы — в конце урока, но сначала ответьте сами.

1. Предскажите. Что напечатает эта программа?

import pandas as pd

df = pd.DataFrame({"kind": ["еда", "еда", "связь"], "amount": [1200, 800, 4990]})
print(df.groupby("kind")["amount"].sum().to_dict())
print(df.groupby("kind")["amount"].agg(["count", "max"]).to_string())

2. Заполните пропуск. Вместо ... назовите столбец «всего» и посчитайте в нём сумму.

# именованный агрегат: имя слева, пара «столбец, чем считать» справа
import pandas as pd

df = pd.DataFrame({"kind": ["еда", "еда", "связь"], "amount": [1200, 800, 4990]})
print(df.groupby("kind").agg(...).to_string())

3. Почините. Сумма по группам не сходится с общей суммой на двести. Ничего не пропало — просто одна группа не попала в ответ.

# 1000 против 800: где ещё двести
import pandas as pd

df = pd.DataFrame({"kind": ["еда", None, "еда", "связь"], "amount": [100, 200, 300, 400]})
print("всего:", int(df["amount"].sum()))
print("по группам:", int(df.groupby("kind")["amount"].sum().sum()))

Задание

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

Ожидаемый вывод:

  месяц    kind  чеков  всего  доля
2026-01  аренда      1  95000  65.7
2026-01 топливо      2  34700  24.0
2026-01     еда      3   9900   6.8
2026-01   связь      1   4990   3.5
2026-02  аренда      2 157000  85.4
2026-02 топливо      1  19100  10.4
2026-02   связь      1   4990   2.7
2026-02     еда      1   2800   1.5

итог по месяцам: {'2026-01': 144590, '2026-02': 183890}
месяц с наибольшими расходами: 2026-02

Готово, когда: вывод совпадает построчно; доля считается от суммы своего месяца, а не от суммы за оба; столбцы названы в agg, а не переименованы после; месяц получен из даты, а не вырезан из строки; месяц с наибольшими расходами найден через idxmax.

На своих данных. Сгруппируйте свою таблицу по любому полю и посчитайте три числа. Потом сверьте: сумма по группам равна сумме по всей таблице? Если нет — посмотрите, нет ли пропусков в ключе.

По желанию.

  • Сгруппируйте по дню недели (dt.day_name()) и посмотрите, в какой день расходы больше.
  • Добавьте в agg столбец из другого поля: первый_день=("day", "min").
  • Посчитайте через transform("mean") отклонение каждого чека от среднего по его категории.

Куда это встанет в проекте

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

Ради этой формы всё и затевалось: отчёт превращается в один вопрос вместо цикла по странам. groupby("country").agg(...) даёт годы, пропуски, среднее и максимум, а год максимума приходит из idxmax внутри группировки — подпись наибольшего значения это и есть та строка, а строка знает свой год.

Долги. Страны в отчёте называются кодами KAZ, UZB, RUS — так их отдаёт банк. Названия лежат в том же ответе, но чтобы поставить их рядом, нужна вторая таблица и соединение по ключу. Это следующий урок.

Ответы

Показать ответы

На вопросы

  1. Режет таблицу на куски по ключу, считает каждый кусок отдельно и собирает ответы обратно в таблицу. Ключ становится подписью строки — индексом, — поэтому ответ можно сортировать и складывать с другими рядами по тем же подписям.
  2. count считает значения, которые есть, — числа, строки, даты, что угодно; size считает все строки группы. Их разница и есть число пропусков в том столбце, который вы посчитали.
  3. agg сжимает группу в одно число, transform возвращает столько же значений, сколько строк, повторяя групповое число для каждой строки своей группы. Он нужен, когда групповой ответ должен встать рядом с исходными строками: доля, отклонение от среднего, место внутри группы.

К разминке

  1. Группировка по категории даёт сумму по каждой, а agg со списком — два столбца с именами функций.
{'еда': 2000, 'связь': 4990}
       count   max
kind              
еда        2  1200
связь      1  4990
  1. всего=("amount", "sum"). Имя столбца слева от знака равенства, пара «по чему считать, чем считать» — справа.
import pandas as pd

df = pd.DataFrame({"kind": ["еда", "еда", "связь"], "amount": [1200, 800, 4990]})
print(df.groupby("kind").agg(всего=("amount", "sum")).to_string())
       всего
kind        
еда     2000
связь   4990
  1. groupby("kind", dropna=False). Строка с пустым ключом по умолчанию не попадает ни в одну группу, и двести уходят молча.
import pandas as pd

df = pd.DataFrame({"kind": ["еда", None, "еда", "связь"], "amount": [100, 200, 300, 400]})
print("всего:", int(df["amount"].sum()))
print("по группам:", int(df.groupby("kind", dropna=False)["amount"].sum().sum()))
всего: 1000
по группам: 1000

К заданию

Доля считается через transform("sum") по месяцу: у каждой строки в знаменателе должна стоять сумма её собственного месяца, а не общая. Если поделить на report["всего"].sum(), доли сложатся в сто процентов по всему отчёту, а не по каждому месяцу — и это будет другой ответ на другой вопрос.

Сортировка — по двум ключам сразу: sort_values(["месяц", "всего"], ascending=[True, False]), потому что месяцы идут по порядку, а суммы внутри месяца — от большей к меньшей.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

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

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.