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

Работаете с офлайн A/B-тестами в ресторанах? Тогда вы знаете, как шумят метрики: трафик скачет, дисперсия зашкаливает, а эффект тонет в данных.
Я, Елена Малая, и это моя третья статья об офлайн-тестах (первая здесь: "Офлайн А/Б тесты в ресторанах фастфуда"). Моя задача — анализировать данные ресторанов (меньше 1000 точек, наблюдения — ресторан-день), где рандомизация невозможна, а мэтчинг — пока единственный вариант. Сегодня разберём, как линеаризация помогает снизить дисперсию для метрик вроде среднего чека (ср. чек = выручка/чеки) и почему в офлайне она требует особой осторожности.
Введение. Линеаризация: не волшебство, а инженерия
Если вы запускали A/B-тест в офлайне — например, на точках продаж, в киосках или ресторанах — то знаете, как это бывает: метрика шумит, дисперсия прыгает, сигнал тонет. И тогда вспоминаешь: «А может, линеаризацию?»
Линеаризация — это способ уменьшить шум, сделав метрику стабильнее. Особенно полезно, если метрика составная (например, средний чек = выручка / чеки), и одна из её частей подвержена флуктуациям.
В теории всё красиво: вычитаем влияние фона (скажем, трафика), получаем более чистую картину, тест становится чувствительнее. Но офлайн не онлайн. Мало наблюдений, точки сильно различаются, эффекты — слабые. И если действовать по наитию, можно только навредить: линеаризация, вместо того чтобы помогать, начнёт затирать эффект, который мы ищем.
Эта статья — о том, как на практике приручить линеаризацию в оффлайн A/B. Почему стандартный подход не работает, как не стереть сигнал вместе с шумом и в каких случаях лучше остановиться.
Вспомним, что такое линеаризация
Суть линеаризации проста: если у нас есть целевая метрика, зависящая от какой-то базовой величины (например, ср.чек = выручка / checks), мы можем переписать её, убрав влияние фона — скажем, трафика. Это должно сократить дисперсию и усилить сигнал. На практике формула выглядит примерно так:
Где Ki — среднее значение среднего чека по ресторану или группе.
Почему это особенно актуально в офлайне
В онлайне можно работать с миллионами пользователей и гладкими распределениями. В офлайне — всё наоборот: мало наблюдений, высокая гетерогенность по точкам, скачки трафика, влияние погоды и праздников, а иногда и просто случай, что в один день в ресторан зашёл автобус туристов. Это всё мешает ловить эффект.
Линеаризация здесь кажется особенно соблазнительной: если трафик нестабилен, давайте "вычтем его влияние".
Интуитивный пример
Представим два ресторана. В обоих — рост прибыли, но в одном из-за маркетинга, а в другом — просто больше людей пришло. Если мы вычтем влияние количества чеков (трафика), то останется только «чистый» эффект. В теории, после линеаризации:
дисперсия метрики должна упасть,
сигнал должен стать яснее,
-
мощность теста — выше.
Звучит прекрасно!
Как всё может пойти не по плану, почему и что с этим делать
Иногда линеаризация «по учебнику» не просто не помогает — она ухудшает результат. Дисперсия может действительно снижаться, но при этом:

MDE остаётся прежним (или даже может сильно увеличится),
Мощность падает — вплоть до экстремально низких значений,
А если применить ещё и CUPED, всё окончательно «размазывается».
Это может выглядеть парадоксально, особенно если смотришь только на вариацию по группам. Но на деле есть несколько частых ловушек.
Наивный подход: «доначислили формулу — и всё»
В первую попытку:
Взяли стандартную формулу: (Выручка - K * checks) / checks
Вычислили K по всей группе (или по тесту)
В некоторых случаях получили рост дисперсии, в других — потерю эффекта.

Почему так могло случиться и как это решить
1. Метрика-донор участвует в эффекте
Проблема: Если наш тест влияет на Выручку напрямую (например, стимулирует upsell или меняет прайсинг), а мы используем Выручку как донор в линеаризации, то мы буквально вычитаем сам эффект, который хотели измерить.
И наоборот: если тест влияет на средний чек, а Выручка в формуле линеаризации сильно зависит от среднего чека — результат аналогичный. Эффект «затирается».
Решение: Проверьте, влияет ли тест на донор (выручку, чеки). Если да — ищите другую метрику. Выбор донора осуществляется не только из компонент целевой метрики (например, Выручка или checks), но и из коррелирующих, но внешних показателей, не вовлечённых в эффект. Можно взять пред тестовый ср.чек, историческую выручку, прогнозное значение, трафик по времени и т.д.
Главное — чтобы метрика:
Была стабильна
Не изменялась самим тестом
Коррелировала с целевой
2. Неправильная корреляция — плохо
Слишком сильная (r → 1): Донор становится «заменителем» метрики, и эффект стирается.
Слишком слабая (r < 0.5): Линеаризация добавляет шум.
Решение: Целевая корреляция между ср. чеком и донором — 0.5–0.9. Проверьте corr(ср.чек, выручка) и corr(ср.чек, чеки) по ресторанам. Корреляцию, как и коэффициент K_i, следует проверять на пред-тестовом периоде. Это гарантирует, что само воздействие теста не исказит эту взаимосвязь.
3. Переобучение на пре-период
Проблема: Если пре-период короткий или нестабилен (например, праздники), Ki подстраивается под шум.
Решение: Используйте более длинный пре-период или стабилизируйте Ki, рассчитывая его по группам ресторанов с похожими характеристиками. И помните, что K_i нужно считать только по данным пре-периода
4. Глобальный Ki
Проблема: Единый Ki для всех ресторанов игнорирует их гетерогенность.
Решение: Рассчитывайте Ki отдельно для каждого ресторана, кластера или пары ресторанов. В нашем случае это снизило дисперсию на ~40% и восстановило чувствительность теста.

График показывающий распределение ср. чека до и после линеаризации показывает сужение дисперсии.
5. Применение CUPED
CUPED использует ковариаты из пре-периода, и, если они коррелируют с донором линеаризации, это может привести к избыточной корректировке. Это происходит потому, что и линеаризация, и CUPED пытаются снизить дисперсию за счёт ковариат. Если их ковариаты (донор и предтестовая метрика) сильно коррелируют, возникает риск "двойной коррекции", которая искажает эффект.
Практика: Код для линеаризации
Вот пример кода на Python для линеаризации ср. чека (gpc). Данные: GP — выручка, checks — количество чеков, rest_id — ID ресторана, group — тест/контроль.
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
# Оставляем нужные столбцы
columns_to_keep = ['day_id', 'rest_id', 'group', 'period', 'GP', 'checks']
df_l = df_all[columns_to_keep].copy()
# Удаляем строки с нулевыми чеками
df_l = df_l[df_l['checks'] > 0]
# Считаем исходную GPC
df_l['original_gpc'] = df_l['GP'] / df_l['checks']
# Создаем датафрейм ТОЛЬКО с данными пре-периода для расчета K_i
df_pre_period = df_l[df_l['period'] == 'pre_period'].copy()
# Считаем K_i по ресторану (без разделения на группы)
totals_per_rest = df_pre_period.groupby(['rest_id']).agg(
total_gp=('GP', 'sum'),
total_checks=('checks', 'sum')
).reset_index()
# считается тотал по ресторану чтобы не сглаживать различия между группами
totals_per_rest['K_i'] = totals_per_rest['total_gp'] / totals_per_rest['total_checks']
# Объединяем с основным датафреймом
df_l = df_l.merge(totals_per_rest[['rest_id', 'K_i']],
on=['rest_id'],
how='left')
# Линейризуем метрику
df_l['linearized_gpc'] = (df_l['GP'] - (df_l['K_i'] * df_l['checks'])) / df_l['checks']
# Итоговый датафрейм
linearized_df = df_l[['day_id', 'rest_id', 'group', 'period', 'linearized_gpc']]
# Проверка результата
print("\nПервые строки с линейризованной метрикой:")
print(df_l[['day_id', 'rest_id', 'group', 'GP', 'checks', 'K_i', 'linearized_gpc']].head())
# Сравнение метрик
metrics_comparison = df_l.groupby(['rest_id', 'group']).agg(
orig_mean=('original_gpc', 'mean'),
orig_std=('original_gpc', 'std'),
lin_mean=('linearized_gpc', 'mean'),
lin_std=('linearized_gpc', 'std')
).reset_index()
print("\nСравнение метрик:")
print(metrics_comparison)
# Проверка дисперсии
for group in df_l['group'].unique():
orig_var = df_l[df_l['group'] == group]['original_gpc'].var()
lin_var = df_l[df_l['group'] == group]['linearized_gpc'].var()
print(f"\nДисперсия для группы {group}:")
print(f"Исходная: {orig_var:.2f}")
print(f"Линейризованная: {lin_var:.2f}")
print(f"Изменение: {((lin_var - orig_var) / orig_var * 100):.2f}%")
# Разница между группами
test_control_diff = linearized_df.groupby(['rest_id', 'group'])['linearized_gpc'].mean().unstack()
test_control_diff['diff'] = test_control_diff['test'] - test_control_diff['control']
print("\nРазница между тестовой и контрольной группами:")
print(test_control_diff)
# Сравнение эффекта
effect_comparison = df_l.groupby(['rest_id', 'group'])['original_gpc'].mean().unstack()
effect_comparison['orig_diff'] = effect_comparison['test'] - effect_comparison['control']
effect_comparison = effect_comparison.join(test_control_diff[['diff']].rename(columns={'diff': 'lin_diff'}))
print("\nСравнение эффекта (orig_diff vs lin_diff):")
print(effect_comparison)
# Визуализация
plt.figure(figsize=(12, 6))
plt.subplot(1, 2, 1)
sns.histplot(data=df_l, x='original_gpc', hue='group', bins=30, kde=True)
plt.title('Исходная GPC')
plt.subplot(1, 2, 2)
sns.histplot(data=df_l, x='linearized_gpc', hue='group', bins=30, kde=True)
plt.title('Линейризованная GPC')
plt.tight_layout()
plt.show()
Линеаризация в пост-анализе
Можно ли использовать линеаризованную метрику, если она не была основной? Да, если:
Линеаризация валидирована на пре-периоде (снижение дисперсии, сохранение эффекта).
Эффект в сырой метрике тонет в шуме, а линеаризация его выявляет.
Ошибки I и II рода под контролем. Важно: мощность теста с новой линеаризованной метрикой не снизилась. О практике расчета мощности я писала в своей предыдущей статье (https://habr.com/ru/articles/902918/ )
Если линеаризация вводится постфактум без валидации, она годится только как иллюстрация, а не основа для выводов.
✅ Чек-лист перед применением линеаризации

Перед тем как применять линеаризацию, важно пройтись по этому списку:
1. Метрика дробная (например, ср.чек = GP/checks).
2. Есть коррелирующий донор (выручка, трафик), не затронутый тестом.
3. Корреляция умеренная (0.5–0.9).
4. Линеаризация валидирована на пре-периоде (снижение дисперсии, сохранение эффекта).
Снижается дисперсия
Эффект сохраняется по направлению и масштабу
Ошибка I рода остаётся под контролем
5. Проверена мощность с новой метрикой
Если она ниже, чем у исходной, линеаризацию лучше не использовать
Заключение
Линеаризация — тонкий инструмент. В офлайне, где трафик скачет, а рестораны различаются, простые формулы из онлайна не работают. Снижение дисперсии — не самоцель: если мощность падает или эффект теряется, метод становится ловушкой. Но с правильным донором и валидацией линеаризация помогает поймать слабый сигнал в шуме. Проверяйте допущения, тестируйте на пре-периоде и не бойтесь отказаться от линеаризации, если она не помогает.
Линеаризация — не must-have, а опциональный инструмент. И как любой инструмент, он работает только в умелых руках.
? Книги и статьи:
· «Trustworthy Online Controlled Experiments», Ron Kohavi — классика про AB-тесты, онлайн и офлайн."
· Купер: «Линеаризация: зачем и как укрощать ratio-метрики в A/B-тестах», статья на habr
· «Метод гармонической линеаризации средствами Python», статья на habr
· X5Tech: «А/Б тестирование с CUPED: детальный разбор», статья на habr