crosstab в Pandas — кросс-таблицы

Проверь себя · 1/3разбор после ответа
CSV-экспорт из системы разделён точкой с запятой ; вместо запятой. Как корректно прочитать его через 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.50

normalize='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.25

normalize='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.0

aggfunc принимает строку ('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.

Закрепи Python для аналитика
200+ задач по pandas, numpy и работе с данными — с разборами
Тренировать Python в Telegram

Практические кейсы аналитика

Конверсия по городу и каналу. Бинарная колонка 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.

Связанные темы

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').