crosstab в Pandas — кросс-таблицы
; вместо запятой. Как корректно прочитать его через csv.reader?Содержание:
Что такое pd.crosstab
Когда в данных две категориальные колонки — сегмент пользователя и платформа, город и канал, продукт и оценка — первый вопрос аналитика почти всегда один: как они связаны между собой? Сколько premium-пользователей сидит на iOS? Где конверсия выше — в Москве или в Питере? Голый groupby тут даёт длинный «вертикальный» список из десятков строк, который глазами не прочитать. Хочется матрицу: одна категория по строкам, другая по столбцам, а в ячейках — число.
Ровно это и делает pd.crosstab(). Он строит таблицу сопряжённости (она же таблица частот, contingency table) — матрицу, где по строкам идут значения одной переменной, по столбцам — другой, а каждая ячейка показывает, сколько наблюдений попало в это пересечение. По умолчанию crosstab считает именно частоты: ему не нужно указывать агрегирующую функцию, он просто подсчитывает количество строк в каждой комбинации. Это его главное отличие от pivot_table и причина, по которой для быстрого EDA он короче и удобнее.
По смыслу crosstab — это сводная таблица из Excel или PIVOT в SQL, только в одну строку кода. На собеседовании на аналитика данных вопрос «как построить таблицу частот по двум категориям» встречается регулярно, потому что проверяет не синтаксис, а умение свернуть сырые данные в форму, по которой видно структуру. Ниже разберём базовый синтаксис, нормализацию в доли, итоги, агрегацию по значениям и отличия от соседних инструментов — всё на одном проверенном датасете.
Базовый синтаксис
Соберём небольшой DataFrame: сегмент пользователя, платформа и выручка. На нём прогоним все примеры статьи, чтобы выводы были сопоставимы.
import pandas as pd
df = pd.DataFrame({
'segment': ['premium', 'free', 'premium', 'free', 'trial', 'premium', 'free', 'trial'],
'platform': ['ios', 'android', 'ios', 'ios', 'android', 'android', 'android', 'ios'],
'revenue': [500, 0, 800, 0, 100, 600, 0, 50],
})
ct = pd.crosstab(df['segment'], df['platform'])
print(ct)platform android ios
segment
free 2 1
premium 1 2
trial 1 1Первый аргумент — то, что пойдёт по строкам (index), второй — по столбцам. crosstab принимает Series (колонки), а не имена строк, поэтому пишем df['segment'], а не 'segment'. Каждая ячейка — количество пользователей с такой комбинацией сегмент × платформа: например, free встречается дважды на Android и один раз на iOS. Результат — обычный DataFrame, его можно дальше сортировать, фильтровать, передавать в график.
Нормализация: доли вместо счётчиков
Сырые счётчики обманчивы. Если free-пользователей в датасете в сто раз больше, чем premium, абсолютные числа в каждой строке будут просто отражать размер сегмента, а не его поведение. Чтобы сравнивать сегменты честно, частоты переводят в доли параметром normalize.
# Доля внутри каждой строки (сегмента) — суммируется до 1.0 по строке
pd.crosstab(df['segment'], df['platform'], normalize='index').round(2)platform android ios
segment
free 0.67 0.33
premium 0.33 0.67
trial 0.50 0.50normalize='index' нормирует по строкам, каждая суммируется до 1.0: у premium доля Android равна 0.33, а iOS — 0.67, то есть две трети premium-пользователей сидят на iOS. Это уже инсайт, а не просто счётчик.
# Доля внутри каждого столбца (платформы)
pd.crosstab(df['segment'], df['platform'], normalize='columns').round(2)platform android ios
segment
free 0.50 0.25
premium 0.25 0.50
trial 0.25 0.25normalize='columns' нормирует по столбцам: половина всех Android-пользователей — это free. А normalize='all' делит на общее число наблюдений, и тогда сумма всех ячеек равна 1.0:
pd.crosstab(df['segment'], df['platform'], normalize='all').round(3)platform android ios
segment
free 0.250 0.125
premium 0.125 0.250
trial 0.125 0.125Чтобы получить проценты вместо долей, домножьте результат на 100: (pd.crosstab(df['segment'], df['platform'], normalize='index') * 100).round(1) даст для premium 33.3 и 66.7. Выбор оси нормализации зависит от вопроса: «какая платформа внутри сегмента» — это index, «какой сегмент внутри платформы» — это columns.
Маргиналы: строки и столбцы с итогами
Параметр margins=True добавляет итоговую строку и итоговый столбец — суммы по каждой оси и общий итог в углу. Имя итогов настраивается через margins_name (по умолчанию All).
pd.crosstab(df['segment'], df['platform'], margins=True, margins_name='Total')platform android ios Total
segment
free 2 1 3
premium 1 2 3
trial 1 1 2
Total 4 4 8Маргиналы удобны, когда нужно одновременно видеть и распределение по ячейкам, и общий объём каждого сегмента. margins работает и вместе с normalize, и вместе с агрегацией values/aggfunc — в последнем случае в итоговой строке окажется агрегат по всей группе, а не сумма ячеек, так что при aggfunc='mean' итог — это среднее по всему столбцу, а не среднее средних.
Агрегация значений: values и aggfunc
По умолчанию crosstab считает частоты, но если передать values (числовую колонку) и aggfunc (функцию агрегации), он начнёт сворачивать не количество строк, а сами значения. Так crosstab превращается в полноценную сводную таблицу.
# Средняя выручка по сегмент × платформа
pd.crosstab(
df['segment'],
df['platform'],
values=df['revenue'],
aggfunc='mean',
)platform android ios
segment
free 0.0 0.0
premium 600.0 650.0
trial 100.0 50.0aggfunc принимает строку ('mean', 'sum', 'count', 'median', 'max') или любую функцию, например np.std. Вместе с margins=True получаем итоги по выручке:
pd.crosstab(
df['segment'], df['platform'],
values=df['revenue'], aggfunc='sum', margins=True,
)platform android ios All
segment
free 0 0 0
premium 600 1300 1900
trial 100 50 150
All 700 1350 2050Важный нюанс: values и aggfunc всегда идут в паре. Передадите values без aggfunc (или наоборот) — pandas бросит ошибку. Если же оба не указаны, crosstab возвращается к подсчёту частот.
crosstab против pivot_table и groupby
Эти три инструмента решают пересекающиеся задачи, и на собеседовании любят спросить, чем они различаются. Короткий ответ: crosstab — частный случай pivot_table, заточенный под частоты, а groupby — самый низкоуровневый и гибкий. Следующие три вызова дают идентичный результат:
# crosstab — считает частоты по умолчанию
pd.crosstab(df['segment'], df['platform'])
# pivot_table — нужно явно попросить размер группы
df.pivot_table(index='segment', columns='platform', aggfunc='size', fill_value=0)
# groupby + unstack — собираем матрицу руками
df.groupby(['segment', 'platform']).size().unstack(fill_value=0)Все три печатают одну и ту же матрицу 3×2. Разница — в умолчаниях и эргономике. crosstab по умолчанию считает количество и принимает Series напрямую, поэтому для «таблицы частот двух категорий» он самый короткий. pivot_table по умолчанию считает mean, работает с именами колонок внутри DataFrame и сильнее в многомерной агрегации (несколько values, несколько aggfunc сразу). groupby ничего не сворачивает в матрицу сам — это делает .unstack(), — зато даёт полный контроль над цепочкой преобразований.
| Метод | Умолчание | Когда брать |
|---|---|---|
| crosstab | частоты (count) | таблица частот двух категорий, быстрый EDA, нормализация в доли |
| pivot_table | mean | агрегация по нескольким функциям/колонкам, сложные сводные |
| groupby + unstack | ничего | максимальная гибкость, нестандартные преобразования |
Практическое правило: для частотного анализа и долей — crosstab, для многомерной агрегации — pivot_table, для всего остального — groupby.
Практические кейсы аналитика
Конверсия по городу и каналу. Бинарная колонка converted (0/1) и aggfunc='mean' дают долю конверсии — среднее от нулей и единиц как раз и есть conversion rate.
conv = pd.DataFrame({
'channel': ['ads', 'seo', 'ads', 'seo', 'ads', 'seo', 'ads', 'seo'],
'city': ['msk', 'msk', 'spb', 'spb', 'msk', 'spb', 'msk', 'spb'],
'converted': [1, 0, 1, 1, 0, 1, 1, 0],
})
pd.crosstab(
conv['city'], conv['channel'],
values=conv['converted'], aggfunc='mean', margins=True,
).round(2)channel ads seo All
city
msk 0.67 0.00 0.50
spb 1.00 0.67 0.75
All 0.75 0.50 0.62Сразу видно: ads конвертит лучше seo в обоих городах, а Питер в целом конвертит выше Москвы. Это типовая задача атрибуции, в которую crosstab сворачивается одной строкой.
Доли распределения. Для процента, например, 5-звёздочных отзывов по каждому продукту берём normalize='index' и домножаем на 100: (pd.crosstab(df['product'], df['rating'], normalize='index') * 100).round(1). Каждая строка суммируется до 100% и показывает структуру оценок внутри продукта.
Heatmap по времени. crosstab из двух временных осей — готовый источник для теплокарты активности:
df['weekday'] = df['event_time'].dt.day_name()
df['hour'] = df['event_time'].dt.hour
ct = pd.crosstab(df['hour'], df['weekday'])
import seaborn as sns
sns.heatmap(ct, cmap='YlOrRd', annot=True, fmt='d')Тест хи-квадрат на crosstab
Таблица сопряжённости — это прямой вход для теста хи-квадрат на независимость двух категориальных переменных. Crosstab даёт наблюдаемые частоты, а scipy.stats.chi2_contingency проверяет, отличается ли распределение по ячейкам от того, что мы ждали бы при полной независимости категорий.
from scipy.stats import chi2_contingency
ct = pd.crosstab(df['segment'], df['platform'])
chi2, p_value, dof, expected = chi2_contingency(ct)
print(f'chi2 = {chi2:.2f}, p-value = {p_value:.4f}, dof = {dof}')chi2 = 0.67, p-value = 0.7165, dof = 2На нашем игрушечном датасете p-value около 0.72 — связи между сегментом и платформой нет (что логично, данных всего восемь строк). Правило интерпретации стандартное: если p-value < 0.05, связь между категориями статистически значима. Подробнее про сам критерий и его условия применимости — в гайде по тесту хи-квадрат.
Аналог в SQL
В SQL отдельной функции crosstab обычно нет (в PostgreSQL она прячется в расширении tablefunc), поэтому кросс-таблицу собирают вручную через условную агрегацию COUNT(CASE WHEN ...).
SELECT
segment,
COUNT(CASE WHEN platform = 'ios' THEN 1 END) AS ios,
COUNT(CASE WHEN platform = 'android' THEN 1 END) AS android,
COUNT(*) AS total
FROM users
GROUP BY segment;Каждый CASE WHEN — это отдельный столбец будущей матрицы, а GROUP BY segment — её строки. Минус подхода в том, что столбцы приходится перечислять руками: появилась новая платформа — правишь запрос. Crosstab же раскрывает все уникальные значения сам. Разбор того же приёма на стороне базы — в гайде по PIVOT и UNPIVOT в SQL.
Если хочется закрепить crosstab и соседние операции pandas на реальных задачах, проще всего гонять короткие тренировки: в тренажёре Карьерник собраны задачи по pandas в том же формате, что встречается на собеседованиях.
Частые ошибки
Анализ по сырым счётчикам. Самая частая ловушка — сравнивать сегменты по абсолютным числам, когда они сильно различаются по размеру. Если free-пользователей десять тысяч, а premium — сотня, raw counts покажут только разницу в объёме, а не в поведении. Почти всегда нужна нормализация: normalize='index' или normalize='columns' в зависимости от того, какую долю вы хотите видеть.
Слишком много категорий. Crosstab из ста городов на пятьдесят каналов — это пять тысяч ячеек, которые невозможно прочитать и которые ломают любую визуализацию. Перед построением сворачивайте мелкие категории в «Другие» (например, оставляя топ-10 по частоте), иначе таблица бесполезна и для глаз, и для хи-квадрата.
values без aggfunc и наоборот. Эти два параметра работают только в паре. Передадите один без другого — получите ValueError. Если нужны просто частоты, не указывайте ни тот, ни другой: подсчёт количества — поведение crosstab по умолчанию.
Забытый fill_value при отсутствующих комбинациях. Когда какой-то пары значений в данных нет, в ячейке окажется NaN (а при aggfunc — пропуск). Для последующих расчётов это мешает: pd.crosstab(...).fillna(0) вернёт нули вместо пропусков. В аналоге через pivot_table/groupby.unstack ту же роль играет аргумент fill_value=0.
Связанные темы
- pivot_table в Pandas
- groupby в Pandas
- value_counts в Pandas
- PIVOT и UNPIVOT в SQL
- Вопросы по pandas на собеседовании
FAQ
Чем crosstab отличается от pivot_table?
crosstab принимает Series (готовые колонки) и по умолчанию считает частоты — количество наблюдений в каждой ячейке. pivot_table работает с именами колонок внутри DataFrame и по умолчанию считает среднее (mean). Для частотного анализа двух категорий crosstab короче, для сложной многомерной агрегации удобнее pivot_table. Под капотом crosstab — это обёртка над pivot_table.
Как построить таблицу частот по двум категориям?
pd.crosstab(df['col1'], df['col2']). Первый аргумент идёт по строкам, второй — по столбцам, ячейки — это счётчики. Чтобы получить доли вместо счётчиков, добавьте normalize='index' (по строкам), 'columns' (по столбцам) или 'all' (от общего числа).
Как добавить проценты и итоги?
Для процентов используйте normalize и домножьте результат на 100: (pd.crosstab(a, b, normalize='index') * 100).round(1). Для итоговых строки и столбца добавьте margins=True; имя итогов задаётся через margins_name.
Можно ли сделать crosstab по трём переменным?
Да. Передайте список Series в index или columns: pd.crosstab([df['city'], df['segment']], df['platform']). Pandas построит таблицу с MultiIndex по строкам. Главное — не увлекаться: больше двух-трёх измерений делают таблицу нечитаемой.
Как проверить, связаны ли две категориальные переменные?
Постройте crosstab без нормализации (нужны наблюдаемые частоты) и прогоните chi2_contingency из scipy: chi2, p, dof, expected = chi2_contingency(ct). Если p-value меньше 0.05, связь между категориями статистически значима. Это классическая связка для проверки независимости.
Как сохранить crosstab в Excel?
ct.to_excel('crosstab.xlsx') — результат crosstab это обычный DataFrame, так что все методы экспорта pandas доступны. Для подсветки ячеек по величине: ct.style.background_gradient().to_excel('styled.xlsx').