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

Работаете с офлайн A/B-тестами в ресторанах? Тогда вы знаете, как шумят метрики: трафик скачет, дисперсия зашкаливает, а эффект тонет в данных.

Я, Елена Малая, и это моя третья статья об офлайн-тестах (первая здесь: "Офлайн А/Б тесты в ресторанах фастфуда"). Моя задача — анализировать данные ресторанов (меньше 1000 точек, наблюдения — ресторан-день), где рандомизация невозможна, а мэтчинг — пока единственный вариант. Сегодня разберём, как линеаризация помогает снизить дисперсию для метрик вроде среднего чека (ср. чек = выручка/чеки) и почему в офлайне она требует особой осторожности.

Введение. Линеаризация: не волшебство, а инженерия

Если вы запускали A/B-тест в офлайне — например, на точках продаж, в киосках или ресторанах — то знаете, как это бывает: метрика шумит, дисперсия прыгает, сигнал тонет. И тогда вспоминаешь: «А может, линеаризацию?»

Линеаризация — это способ уменьшить шум, сделав метрику стабильнее. Особенно полезно, если метрика составная (например, средний чек = выручка / чеки), и одна из её частей подвержена флуктуациям.

В теории всё красиво: вычитаем влияние фона (скажем, трафика), получаем более чистую картину, тест становится чувствительнее. Но офлайн не онлайн. Мало наблюдений, точки сильно различаются, эффекты — слабые. И если действовать по наитию, можно только навредить: линеаризация, вместо того чтобы помогать, начнёт затирать эффект, который мы ищем.

Эта статья — о том, как на практике приручить линеаризацию в оффлайн A/B. Почему стандартный подход не работает, как не стереть сигнал вместе с шумом и в каких случаях лучше остановиться.

Вспомним, что такое линеаризация

Суть линеаризации проста: если у нас есть целевая метрика, зависящая от какой-то базовой величины (например, ср.чек = выручка / checks), мы можем переписать её, убрав влияние фона — скажем, трафика. Это должно сократить дисперсию и усилить сигнал. На практике формула выглядит примерно так:

\text{Линеризованная метрика ср.чек} = \frac{\text{Выручка} - K_i \times \text{checks}}{\text{checks}}

Где Ki — среднее значение среднего чека по ресторану или группе.

Почему это особенно актуально в офлайне

В онлайне можно работать с миллионами пользователей и гладкими распределениями. В офлайне — всё наоборот: мало наблюдений, высокая гетерогенность по точкам, скачки трафика, влияние погоды и праздников, а иногда и просто случай, что в один день в ресторан зашёл автобус туристов. Это всё мешает ловить эффект.

Линеаризация здесь кажется особенно соблазнительной: если трафик нестабилен, давайте "вычтем его влияние".

Интуитивный пример

Представим два ресторана. В обоих — рост прибыли, но в одном из-за маркетинга, а в другом — просто больше людей пришло. Если мы вычтем влияние количества чеков (трафика), то останется только «чистый» эффект. В теории, после линеаризации:

  • дисперсия метрики должна упасть,

  • сигнал должен стать яснее,

  • мощность теста — выше.

    Звучит прекрасно!

Как всё может пойти не по плану, почему и что с этим делать

Иногда линеаризация «по учебнику» не просто не помогает — она ухудшает результат. Дисперсия может действительно снижаться, но при этом:

  • MDE остаётся прежним (или даже может сильно увеличится),

  • Мощность падает — вплоть до экстремально низких значений,

  • А если применить ещё и CUPED, всё окончательно «размазывается».

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

Наивный подход: «доначислили формулу — и всё»

В первую попытку:

  • Взяли стандартную формулу: (Выручка - K * checks) / checks

  • Вычислили K по всей группе (или по тесту)

  • В некоторых случаях получили рост дисперсии, в других — потерю эффекта.

Почему так могло случиться и как это решить

1.     Метрика-донор участвует в эффекте

Проблема: Если наш тест влияет на Выручку напрямую (например, стимулирует upsell или меняет прайсинг), а мы используем Выручку как донор в линеаризации, то мы буквально вычитаем сам эффект, который хотели измерить.

И наоборот: если тест влияет на средний чек, а Выручка в формуле линеаризации сильно зависит от среднего чека — результат аналогичный. Эффект «затирается».

Решение: Проверьте, влияет ли тест на донор (выручку, чеки). Если да — ищите другую метрику. Выбор донора осуществляется не только из компонент целевой метрики (например, Выручка или checks), но и из коррелирующих, но внешних показателей, не вовлечённых в эффект. Можно взять пред тестовый ср.чек, историческую выручку, прогнозное значение, трафик по времени и т.д.

Главное — чтобы метрика:

  1. Была стабильна

  2. Не изменялась самим тестом

  3. Коррелировала с целевой

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

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