Семестр 3 · Модуль 2 · Неделя 6

Группировка и агрегация

groupby · agg · pivot_table · merge/concat · cut/qcut · EDA

2 ч теории · ~90 минут активной работы: разогрев, 5 блоков мини-лекции, live-coding, разбор ошибки many-to-many, обобщение.

С прошлой лекции: студент сделал df.dropna() и удивлён — пропуски остались. Почему? Что он забыл? (Сегодня то же правило встретится в groupby.)

К концу лекции вы сможете: группировать, сводить, соединять, бинировать

  1. группировать через df.groupby('col') и применять .mean()/.sum()/.size()/.count();
  2. агрегировать по-разному через agg({'col': 'fun'}) и по нескольким колонкам;
  3. строить pivot_table и crosstab;
  4. соединять таблицы merge/join/concat и проверять .shape до/после;
  5. бинировать непрерывные признаки cut/qcut;
  6. собрать из приёмов недель 4–6 EDA-ноутбук проекта модуля.
Каждый пункт — из чек-листа самооценки семинара. Сегодня — финальный навык модуля: задавать данным вопросы.

groupby = SQL GROUP BY; pivot_table = формат отчётов

Какой вопрос о данных продаж вы зададите первым?

Средний возраст по классам «в лоб» — циклом: работает, но неудобно

titanic = pd.read_csv('titanic.csv') avg = {} for cls in titanic['Pclass'].unique(): avg[int(cls)] = round(titanic[titanic['Pclass'] == cls]['Age'].mean(), 1) avg {3: 26.8, 2: 34.7, 1: 26.5}

Работает, но: (1) ключи идут в порядке встречи в данных, а не по возрастанию; (2) на 50 классов цикл раздувается; (3) код описывает «как», а не «что».

Что неудобно в таком способе? (Через минуту — то же одной строкой.)

Данные недели: продажи интернет-магазина

import pandas as pd sales = pd.read_csv('sales.csv') sales['date'] = pd.to_datetime(sales['date']) sales['month'] = sales['date'].dt.month sales['revenue'] = sales['price'] * sales['qty'] sales.shape (60, 8)
sales.head(3) date category product price qty region month revenue 0 2024-07-22 Одежда Куртка 9000 5 Екатеринбург 7 45000 1 2024-11-26 Электроника Ноутбук 90000 2 Москва 11 180000 2 2024-02-14 Книги Python 1200 4 Екатеринбург 2 4800

Те же приёмы недель 4–5: загрузка, типы, арифметика колонок (price*qty — векторизация, а не apply). Данные чистые — можно задавать вопросы.

Арифметика колонок — это векторизация из недели 5. Почему важно не писать здесь цикл?

Первый вопрос: выручка по категориям — одна строка вместо цикла

sales.groupby('category')['revenue'].sum() category Книги 90200 Одежда 339500 Электроника 3146000 Name: revenue, dtype: int64

Одна команда: сгруппировать по category, взять колонку revenue, просуммировать. Ответ руководства: Электроника — 3 146 000.

На прошлом слайде то же делалось циклом. Что здесь короче и без ручного перебора?

split-apply-combine: разбить → применить → склеить

1 · Split — разбить2 · Apply — применить3 · Combine — склеить
Таблица режется на группы по значениям колонки:
Книги / Одежда / Электроника
К каждой группе — функция:
.sum(), .mean(), …
Результаты склеиваются обратно в таблицу

«df.groupby('Pclass')['Age'].mean() — одна команда, которая делает все три шага». Цикл со слайда 4 делал split + apply вручную — Pandas делает это за вас.

В цикле со слайда 4 какие из трёх шагов вы делали руками?

groupby возвращает GroupBy-объект: «ленив», нужна агрегация

grouped = sales.groupby('category') # это НЕ таблица, а GroupBy-объект len(grouped) # 3 — число групп 3
sales.groupby('category').mean() # вот теперь — результат (агрегация)

«Ленив»: запоминает разбиение, но считает только при агрегации (.sum(), .mean(), .size()). Без агрегации и без присваивания — та же потеря результата, что в неделе 5.

Что вернёт len(sales.groupby('category'))?

.size() vs .count(): число строк vs число непустых значений

tmp = pd.DataFrame({'cat': ['a', 'a', 'b'], 'val': [1, None, 3]}) tmp.groupby('cat').size() tmp.groupby('cat')['val'].count()

Разница: .size() — число строк в группе; .count() — число непустых значений по колонке. При пропусках они расходятся: у 'a' строк две, а count — 1 (NaN не считается).

Обе команды «считают». Какой вывод у каждой? (Вопрос «сколько записей» — это .size().)

Группировка по нескольким колонкам: ключ — пара (month, category)

sales.groupby(['month', 'category'])['revenue'].sum() month category 1 Книги 1500 Электроника 320000 2 Книги 12000 Одежда 1500 Электроника 120000 3 Одежда 17000 Электроника 540000 4 Книги 5400 Одежда 42000 Электроника 1050000 ... Name: revenue, dtype: int64

Показаны первые месяцы; остальные пропущены (…).

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

Как читать вывод: месяц 1 — по скольким категориям есть данные?

agg с dict: каждой колонке — своя функция агрегации

sales.groupby('category').agg({ 'price': 'mean', 'qty': 'sum', 'product': 'nunique', }) price qty product category Книги 1781.25 51 4 Одежда 5642.86 62 4 Электроника 45217.39 68 4

Каждой колонке — свою функцию: средняя цена, суммарное количество, число разных товаров (nunique = «число уникальных», отсюда «4 разных товара»). Словарь — это «заказ».

Зачем словарь, а не просто .mean()?

Именованные агрегации: короткая запись с новыми именами колонок

sales.groupby('category').agg( avg_price=('price', 'mean'), total_qty=('qty', 'sum'), ) avg_price total_qty category Книги 1781.250000 51 Одежда 5642.857143 62 Электроника 45217.391304 68

Тот же agg, но колонкам результата сразу даются осмысленные имена (avg_price, total_qty) — удобно для отчётов. Синтаксис — обзорно, детали на семинаре.

Два способа записи одного и того же. Какой удобнее, если колонок много?

pivot_table: «месяц × категория → выручка» — двумерная сводка

pd.pivot_table(sales, index='month', columns='category', values='revenue', aggfunc='sum') category Книги Одежда Электроника month 1 1500.0 NaN 320000.0 2 12000.0 1500.0 120000.0 3 NaN 17000.0 540000.0 4 5400.0 42000.0 1050000.0

Показаны первые 4 из 12 месяцев.

Вторая ось: columns='category' раскладывает категории по столбцам (у groupby ключ — только по строкам). NaN — «в этом месяце не было продаж категории», а не ошибка. Внимание: aggfunc по умолчанию mean — для выручки нужен 'sum'.

Чем pivot_table отличается от groupby(['month','category'])?

margins=True: итоговые строка и столбец — формат отчёта

pd.pivot_table(sales, index='month', columns='category', values='revenue', aggfunc='sum', margins=True, margins_name='Всего') category Книги Одежда Электроника Всего month 4 5400.0 42000.0 1050000.0 1097400 ... Всего 90200.0 339500.0 3146000.0 3575700

Показаны месяц 4 и итоговая строка «Всего».

Готовый формат отчёта: итоговая строка — сумма по категориям, итоговый столбец — сумма по месяцам. Ячейка («Всего», «Всего») — вся выручка: 3 575 700. Это ровно команда revenue_report(df) из семинара 6.5.

Как читать итоговую строку и итоговый столбец?

crosstab: частоты и доли по двум категориальным колонкам

pd.crosstab(sales['region'], sales['category']) category Книги Одежда Электроника region Екатеринбург 1 8 5 Казань 4 5 4 Москва 4 7 10 СПб 7 1 4
pd.crosstab(sales['region'], sales['category'], normalize='index') category Книги Одежда Электроника region Екатеринбург 0.071429 0.571429 0.357143 Казань 0.307692 0.384615 0.307692 Москва 0.190476 0.333333 0.476190 СПб 0.583333 0.083333 0.333333

Частоты — «группировка без колонки значений»: сколько раз встретилась пара. normalize='index' — доли по строкам (каждая строка в сумме 1): доля «Книг» выше всего в СПб (0.58).

Что покажет crosstab без normalize: абсолютные числа или доли?

merge по ключу: обогащение продаж округами (SQL JOIN)

regions = pd.read_csv('regions.csv') merged = pd.merge(sales, regions, on='region', how='left') merged.shape (60, 9)
merged.head(2) date category product price qty region month revenue district 0 2024-07-22 Одежда Куртка 9000 5 Екатеринбург 7 45000 Уральский 1 2024-11-26 Электроника Ноутбук 90000 2 Москва 11 180000 Центральный
merged['district'].isna().sum() # 0 — каждый регион есть в справочнике merged.groupby('district')['revenue'].sum() district Приволжский 471900 Северо-Западный 635900 Уральский 678300 Центральный 1789600 Name: revenue, dtype: int64

Аналог SQL LEFT JOIN: how='left' сохраняет все 60 строк (плюс колонка district) — .shape 60 → 60. Теперь можно группировать по округам.

Первая проверка после merge: .shape и isna().sum() по новой колонке. Зачем?

how='inner/left/right/outer': какие строки остаются

howЧто остаётся
innerтолько строки с ключом в обеих таблицах
leftвсе строки A + данные B, где есть ключ
rightвсе строки B + данные A, где есть ключ
outerвсе строки обеих таблиц; недостающее — NaN

«Чьи строки я не имею права потерять»: для обогащения продаж справочником — left (все продажи). inner — только пересечение; outer — всё с NaN там, где нет пары.

Какой how, если нужно оставить ВСЕ продажи, даже без региона в справочнике?

many-to-many merge размножает строки: 3×2 → 4

a = pd.DataFrame({'region': ['Москва', 'Москва', 'СПб'], 'sale': [1, 2, 3]}) b = pd.DataFrame({'region': ['Москва', 'Москва'], 'district': ['ЦФО', 'ЦФО-2']}) pd.merge(a, b, on='region')
В a — 3 строки, в b — 2. Сколько строк вернёт merge?
region sale district 0 Москва 1 ЦФО 1 Москва 1 ЦФО-2 2 Москва 2 ЦФО 3 Москва 2 ЦФО-2

Размножение: ключ «Москва» есть в обеих таблицах по 2 раза → каждая пара склеивается: 2×2=4 строки вместо 3. Это самый коварный баг соединений — он «не падает», а просто искажает данные.

Проверяй .shape до/после и дубликаты ключей ДО merge

Мантра: «не уверен, что в таблице — выведи .shape и .head()».

Как проверить, что в regions нет дубликатов, до merge?

concat: склейка по осям; февраль + март = 9 строк

feb = sales[sales['month'] == 2] mar = sales[sales['month'] == 3] len(feb), len(mar) # (5, 4) (5, 4)
c = pd.concat([feb, mar], ignore_index=True) len(c) # 5 + 4 = 9 c.index[:3] Index([0, 1, 2], dtype='int64')

Складывает друг под другом (axis=0) или рядом (axis=1) — без сопоставления ключей. ignore_index=True перенумеровывает индекс, иначе индексы частей «наслаиваются».

merge соединяет по ключу, concat — по позициям. Что вернёт concat таблиц с разными колонками?

merge vs concat vs join: по ключу / по осям / по индексу

ИнструментЧто делаетКлюч сопоставленияКогда
mergeдобавляет колонки из другой таблицыпо колонке/колонкам (on)SQL JOIN, обогащение
joinто же, но по индексуindexmerge по индексу
concatскладывает таблицы друг под другом / рядомбез ключа (по осям)склейка выгрузок

Критерий выбора: есть общий ключ → merge/join; нет ключа, просто две выгрузки → concat. Путаница merge vs concat — ошибка №4 недели 6.

Вы «соединяете по ключу» — какой инструмент нужен?

cut: равные интервалы — частоты крайне неравны

pd.cut(sales['revenue'], 4).value_counts() revenue (1051.5, 113625.0] 51 (225750.0, 337875.0] 4 (113625.0, 225750.0] 3 (337875.0, 450000.0] 2 Name: count, dtype: int64

4 интервала РАВНОЙ ШИРИНЫ. Из-за скошенного распределения (много мелких чеков, мало крупных) в первый интервал попало 51 покупка, в последний — 2. Группы «неуравновешены», но это честно отражает реальность. Интервалы (a, b]: левая граница открыта, правая включена.

В каком сегменте может оказаться 1 покупка при чеках 100–1000 руб. — cut или qcut?

qcut: равные частоты — в каждой группе ~одинаково наблюдений

pd.qcut(sales['revenue'], 4).value_counts() revenue (1499.999, 7200.0] 16 (7200.0, 16000.0] 15 (46250.0, 450000.0] 15 (16000.0, 46250.0] 14 Name: count, dtype: int64

Делит на КВАнтили: в каждом сегменте примерно равное число наблюдений (16/15/15/14). Границы — по данным, а не по ширине. Это «равные группы»; cut — «равные интервалы». Небольшие отклонения — норма из-за совпадений значений.

Тот же revenue, те же 4 группы — почему частоты почти равные, в отличие от cut?

cut vs qcut: границы заданы бизнесом vs равные группы

Вопросcut (равные интервалы)qcut (равные частоты)
Границызаданы заранее / равной шириныпо квантилям (по данным)
Частоты группмогут сильно различаться (51 vs 2)примерно равные (16/15/15/14)
Когда«до 1000», «до 5000» — границы продиктованы бизнесом«разбить на 4 равные группы» для сравнения

Спросите себя: «что для меня важнее — ширина интервала или число наблюдений?» В проекте оба варианта допустимы, но выбор надо обосновать.

Чек от 100 до 1000 руб.: когда уместен cut?

value_counts(normalize=) — категориальные доли в EDA

sales['category'].value_counts(normalize=True) category Электроника 0.383333 Одежда 0.350000 Книги 0.266667 Name: proportion, dtype: float64

Доли категорий: Электроника — 38% всех покупок. Вместе с выручкой по категориям (слайд 6) — полный портрет: электроника дорогая, и её много.

Доля покупок — 38%, доля выручки — 88% (3 146 000 / 3 575 700). О чём это говорит?

Собираем EDA: топ-5 продуктов через nlargest

sales.groupby('product')['revenue'].sum().nlargest(5) product Ноутбук 1980000 Смартфон 750000 Монитор 280000 Куртка 153000 Наушники 136000 Name: revenue, dtype: int64

«Векторное мышление»: то, что на 1–2 курсе делалось циклом по строкам, здесь — одна цепочка методов. nlargest не сортирует всё, а берёт топ сразу. Это готовая ячейка проекта модуля.

Чем nlargest(5) идиоматичнее, чем sort_values().head(5)?

Выручка по категориям и месяцам — одним выражением

sales.groupby(['month', 'category'])['revenue'].sum().unstack() category Книги Одежда Электроника month 1 1500.0 NaN 320000.0 2 12000.0 1500.0 120000.0 3 NaN 17000.0 540000.0 4 5400.0 42000.0 1050000.0

Показаны первые 4 из 12 месяцев.

Двухключевой groupby + .unstack() — внутренний ключ превращается в колонки: та же сводка, что у pivot_table. Недели 4–6 сошлись в один инструмент.

Один результат тремя способами: groupby с двумя ключами, .unstack(), pivot_table.

Типичные ошибки недели 6

  1. Забывают присвоить: df.groupby(...) без агрегации/присваивания — группа не «применилась» (правило недели 5).
  2. Мутация в цикле: df.groupby(...).agg(...) в цикле копит предупреждения — группировка пишется векторизованно.
  3. Merge с дубликатами ключей — внезапно разросшийся DataFrame (many-to-many); проверяй .shape и дубликаты.
  4. Путаница merge vs concat — по ключу vs по позициям.
  5. Непонимание, зачем agg с dict, когда колонки агрегируются по-разному.
После df.groupby('category').agg({'price':'mean'}) колонка qty пропала. Что объяснить?

Проект модуля: полный EDA-цикл, критерии, сдача

Три лекции — три этапа проекта. Сегодня вы овладели последним инструментом для него.

Стоп-вопросы: четыре вопроса недели (письменно, 2 минуты)

  1. Чем merge отличается от concat — и когда что использовать?
  2. Как посчитать выручку по категориям и месяцам одним выражением?
  3. Что делает agg({'price': 'mean', 'qty': 'sum'})?
  4. Чем cut отличается от qcut?

Ответьте письменно за 2 минуты — это мини-самооценка перед семинаром.

Сложно с вопросом 2? Вернёмся к слайдам 10 / 13 / 27.

Семинар и ДЗ недели 6

Откройте семинар-6.md и sales.csv. Проект — до конца недели.

Сегодня вы научились: шесть навыков + one-minute paper

  1. Группировать и агрегировать: groupby(...).mean()/.sum()/.size()/.count().
  2. Применять agg({'col': 'fun'}) — разные функции разным колонкам.
  3. Строить pivot_table (с margins) и crosstab (с normalize).
  4. Соединять merge/join/concat и защищаться от many-to-many проверкой .shape и дубликатов ключей.
  5. Бинировать cut/qcut и объяснять разницу.
  6. Собирать из этого EDA-ноутбук проекта модуля.

One-minute paper: главное, что вы поняли, и один оставшийся вопрос.

Модуль закрыт — вы прошли весь цикл EDA. На семинаре соберёте отчёт «месяц × категория».