- BrainTools - https://www.braintools.ru -
Новая логика [1] распределения заказов по курьерам выиграла A/B с запасом: время доставки минус восемь процентов, интервал узкий, p‑value с тремя нулями.
Раскатали на сто, подождали две недели — время доставки осталось ровно там, где было. Логирование в порядке, сплит честный, пользователи делились случайно.
Так ломается обычный тест в системах, где варианты дерутся за один ресурс.
Курьер, доставшийся тестовой группе, отобран у контрольной, и разница надувается с обеих сторон сразу.
Лечится это рандомизацией не по пользователям, а по времени:
весь город час живёт на новом алгоритме;
следующий час на старом.
Схема называется switchback и стоит дорого — вместо миллиона независимых пользователей у вас остаётся полторы тысячи часовых ячеек.
Ниже — как проверить, что перетекание между группами у вас действительно есть; как выбрать длину окна, чтобы эффект не протекал через границу; почему наивный подсчёт по такому эксперименту даёт шесть ложных срабатываний из десяти и что с этим делать; как посчитать мощность заранее и где switchback не поможет вообще.
Обычный тест держится на условии, что назначение одного пользователя не меняет исход другого.
Пока это выполняется, разница средних адекватно оценивает эффект полной раскатки.
Ломается условие там, где обе группы тянут один ресурс.
Курьеров в городе конечное число, и более агрессивная диспетчеризация удерживает курьера возле зоны высокого спроса — того самого курьера, который иначе поехал бы на заказ из контроля.
Заказ тестовой группы обслужен быстрее, заказ контрольной медленнее, разница набегает с обеих сторон сразу.
Оценка ловит сумму настоящего улучшения и перераспределения между группами.
Сколько там второго, зависит от того, насколько жёстко упёрт ресурс.
Когда ёмкость фиксирована наглухо, эффект полной раскатки нулевой, а тест всё равно покажет уверенную разницу: он меряет, как хорошо новый алгоритм отбирает курьеров у старого.
В отчёте это не выглядит подозрительно.
Тот же механизм включается:
в ранжировании с общим инвентарём;
в ценообразовании с перетекающим спросом;
в промо с единым бюджетом;
в модерации с одной очередью на всех.
Поймать перетекание можно до всякого switchback и без новой инфраструктуры.
Запустите один и тот же вариант на пяти процентах трафика, потом на двадцати, потом на пятидесяти.
Если единицы независимы, оценка эффекта от доли не зависит, меняется только ширина интервала.
Ползёт вслед за размером группы — интерференция есть, и знак сползания подскажет, в какую сторону врал прошлый тест.
Switchback меняет единицу рандомизации.
Случайно назначается не пользователь, а пара «регион и временное окно»:
Екатеринбург с 14:00 до 15:00 живёт на новом алгоритме целиком, с 15:00 до 16:00 на старом.
Конкуренция между вариантами пропадает по построению.
В один момент в одном рынке работает только один вариант, отбирать курьеров не у кого, и вы получаете ответ на тот вопрос, который вас волнует: что будет, если включить это всем.
Счёт за это выставляют сразу.
Две недели по 24 окна в пяти городах дают 1680 ячеек, и это ваш размер выборки — хоть миллион заказов внутри, хоть десять.
Точность падает примерно на порядок против пользовательского сплита, поэтому решение «берём switchback» принимают не при анализе, а когда планируют квартал.
Самая частая ошибка [2] выглядит так:
выгрузка на уровне заказов;
привычная регрессия;
узкий интервал.
Заказы внутри ячейки не независимы.
Их обслужил один алгоритм, в один час, при одной погоде, тем же пулом курьеров, на том же остатке склада. Ошибки внутри ячейки скоррелированы, а обычная формула стандартной ошибки исходит из обратного.
Насколько сильно она промахнётся, считается до эксперимента. Дисперсия оценки раздувается в раз, где
— среднее число заказов в ячейке,
— внутриклассовая корреляция метрики. Шестьсот заказов в ячейке и корреляция всего 0,01 дают множитель около семи, то есть стандартная ошибка занижена в два с половиной раза.
При = 0,02 занижение уже в три с половиной, порог значимости фактически сдвинут с 1,96 до примерно 0,55 сигмы, и под нулевой гипотезой такой тест отвергает её почти в шести случаях из десяти.
Фиксится кластерными робастными ошибками с кластеризацией по единице рандомизации:
import statsmodels.formula.api as smf
# orders: одна строка на заказ
# cell: идентификатор ячейки регион×окно — единица рандомизации
# treat: 0/1, назначение этой ячейки
model = smf.ols("delivery_time ~ treat + C(region) + C(hour) + C(dow)", data=orders)
res = model.fit(cov_type="cluster", cov_kwds={"groups": orders["cell"]})
print(res.params["treat"], res.bse["treat"], res.pvalues["treat"])
Точечная оценка не сдвинется ни на копейку, изменится только ширина интервала — обычно втрое, иногда вчетверо.
Считать по заказам с кластеризацией лучше, чем сравнивать средние по ячейкам: насыщенные ячейки получают больший вес, и можно подмешать ковариаты.
С уровнем кластеризации есть засада.
Ячейка подходит, пока шоки локальны.
Когда по всем городам одновременно прилетает релиз, футбольный матч или сбой у эквайринга, ошибки коррелируют поперёк регионов в одну и ту же минуту, и кластеризация по ячейке снова занижает разброс.
Тогда кластеризуют:
по временному окну целиком;
или считают двустороннюю кластеризацию, по региону и по времени сразу.
Сама кластерная поправка тоже работает не всегда.
Ей нужно много кластеров, ходовое правило называет сорок с лишним, и оно разваливается, когда ячейки сильно разного размера.
Про десяток ячеек разговора нет вовсе: тест отвергает нулевую гипотезу кратно чаще номинала.
Выручает дикий кластерный бутстрап со случайными знаками при остатках ячеек, ему много кластеров не требуется.
В switchback есть приём, который бьёт всё перечисленное, и работает он потому, что расписание переключений придумали вы сами.
Вы знаете, какие расписания могли выпасть и с какой вероятностью.
Распределение оценки под нулевой гипотезой можно не приближать формулами, а построить перебором:
сгенерировать тысячи расписаний тем же генератором, которым назначался эксперимент;
пересчитать на фактических данных статистику;
посмотреть, в какой хвост попадает наблюдённое значение.
def randomization_p(cells, stat_fn, sampler, n_draws=5000, rng=None):
"""sampler() возвращает то же расписание, которым назначался эксперимент."""
rng = rng or np.random.default_rng(0)
observed = stat_fn(cells["metric"].values, cells["treat"].values)
null = np.empty(n_draws)
for i in range(n_draws):
null[i] = stat_fn(cells["metric"].values, sampler(rng))
return (np.abs(null) >= abs(observed)).mean()
Генератор внутри обязан быть тем же самым, что и на запуске, со всеми блоками и балансировками.
Подмените его равномерной монеткой там, где была блочная схема, и p‑value поедет.
Зато при выполненном условии тест точен при любом числе ячеек и переживает и сорок ячеек, и двенадцать.
Короткие окна дают много ячеек и хорошую точность, но система не успевает перейти в новое состояние. Курьеры, которых новая логика за прошлый час подтянула к центру, физически остаются там и в следующий час, когда алгоритм уже старый.
Эффект протекает через границу и размывает разницу. Сколько окон живёт такое последействие, называют порядком переноса.
Длинные окна перенос гасят, зато ячеек остаётся мало, интервал растёт, а эксперимент растягивается настолько, что за его время меняется рынок.
Порядок переноса меряют, а не угадывают:
cells = cells.sort_values(["region", "window_start"])
for lag in (1, 2, 3):
cells[f"treat_lag{lag}"] = cells.groupby("region")["treat"].shift(lag)
d = cells.dropna()
res = smf.ols(
"metric ~ treat + treat_lag1 + treat_lag2 + treat_lag3 + C(region) + C(hour)",
data=d,
).fit(cov_type="cluster", cov_kwds={"groups": d["cell"]})
Значимый коэффициент при первом лаге говорит, что одно окно последействие не покрывает.
Дотянулось до второго и третьего — окно надо удлинять кратно.
Все лаги пустые — у вас есть право на короткие окна и приличную точность.
Промахнуться можно в обе стороны, но цена разная.
Недооценили порядок переноса — получили смещённую оценку, причём по интервалу этого не видно.
Переоценили — потеряли часть мощности, но остались честны.
Против переноса работает и выбрасывание разогрева:
первые минуты после переключения исключают из расчёта метрики;
хотя вариант честно работает всё окно.
На длинных блоках приём хороший, на коротких съедает половину данных.
Для доставки и такси можно начинать с окон от получаса до двух часов.
Схема «час через час» выглядит адекватной и намертво привязывает назначение ко времени суток. Если период чередования делит суточный цикл, тест систематически попадает на пики, а контроль в провалы, и вы меряете разницу между часами.
Назначение должно быть случайным и сбалансированным одновременно, поэтому берут блочную рандомизацию:
import numpy as np
def assign_day(n_windows: int, rng: np.random.Generator) -> np.ndarray:
"""Половина окон дня — тест, порядок случайный."""
half = n_windows // 2
slots = np.array([1] * half + [0] * (n_windows - half))
rng.shuffle(slots)
return slots
rng = np.random.default_rng(20260914)
schedule = {(region, day): assign_day(24, rng) for region in regions for day in days}
Этот же генератор потом уйдёт в рандомизационный тест, так что держите его в одной функции. Баланс проверьте ещё и по часам отдельно: на коротком эксперименте тест легко наберёт больше вечерних окон, чем контроль, и видно это из одной сводной таблицы долей.
Если параллельно крутятся другие switchback, границы окон у них лучше разъехать случайным сдвигом.
Калькулятор мощности, накормленный числом заказов, выдаёт обнадёживающие числа, не имеющие отношения к делу. Минимальный детектируемый эффект зависит от числа ячеек и от разброса метрики между ячейками.
from scipy import stats
def mde(history_cell_metric, n_cells, alpha=0.05, power=0.8):
"""history_cell_metric: метрика по историческим ячейкам того же размера."""
sigma = history_cell_metric.std(ddof=1)
se = sigma * np.sqrt(4 / n_cells) # деление ячеек пополам
return (stats.norm.ppf(1 - alpha / 2) + stats.norm.ppf(power)) * se
Число, которое выпадет, часто оказывается больше того эффекта, ради которого всё затевалось, и это тот момент, когда эксперимент дешевле не начинать.
На тех же исторических данных прогоните A/A‑повтор:
назначьте фиктивное расписание сотню раз;
посчитайте конвейер анализа целиком;
убедитесь, что доля значимых результатов держится около пяти процентов.
Мощность растёт от трёх вещей:
больше регионов и больше дней;
третья интереснее — метрика ячейки неплохо предсказывается тем, что происходило в этом же регионе в этот же час неделю назад, и такой предиктор в регрессии сужает интервал на десятки процентов.
Считается он только по данным до запуска, иначе впитает эффект и утащит оценку.
Нарезать окна мельче ради количества ячеек не надо.
Вместе с числом ячеек растёт и перенос через границы, и разброс метрики между ними, так что выигрыш выйдет сильно меньше корня из двух, а смещение точно придёт.
Больше всего мешают медленные эффекты. Программа лояльности, дообученная рекомендательная модель, репутация продавца раскачиваются неделями, значит и окно переключения должно быть длиннее недели. Посчитайте, сколько ячеек наберётся за квартал, и вопрос закроется сам.
Мешает заметность воздействия. Диспетчеризацию можно дёргать хоть каждые полчаса, пользователь о ней не подозревает. Цену дёргать нельзя: скачки видны, в поддержку летят обращения, а самые внимательные запоминают, в какой час было дешевле, и начинают ждать этот час. Цены тестируют географией.
Тише всего убивает необратимость. Switchback исходит из того, что после переключения обратно система возвращается туда, где была, а в жизни кэш прогрелся, модель дообучилась на трафике теста, пейсинг бюджета перестроился, и к третьему дню контрольные окна контрольными быть перестали. Проверяется несложно: посмотрите, ползёт ли метрика контрольных ячеек от начала эксперимента к концу.
Отдельно стоят границы регионов. Если курьеры и спрос свободно переливаются между соседними зонами, эти зоны не независимые рынки, и интерференция возвращается через географию.
Не сошлось хотя бы по одному пункту — не натягивайте.
Switchback стоит дорого:
единиц рандомизации на порядок меньше;
план приходится зафиксировать до старта и держаться его.
Оправдывает эту цену одно обстоятельство — варианты делят общий ресурс, и разбивка по пользователям даёт смещённую оценку.
Там, где интерференции нет, обычный сплит выигрывает по всем пунктам.
Половина ложных находок приходит со стороны данных.
Выгрузка на миллион строк выглядит убедительно, по ней хочется посчитать привычное сравнение средних, а реальных наблюдений полторы тысячи, и это временные ячейки.
Ошибки кластеризуются по ячейке, а когда ячеек меньше сорока, асимптотику меняют на рандомизационный тест.

Если вы запускаете эксперименты с данными и хотите принимать решения на основе надёжных результатов, важно понимать не только как посчитать эффект, но и как проверить корректность самого эксперимента.
Разберитесь, как управлять ML‑экспериментами, контролировать качество данных и находить причины, из‑за которых выводы могут оказаться ошибочными. Это поможет строить более точные процессы анализа и увереннее принимать решения на основе данных.
Приходите на бесплатные открытые уроки:
29 сентября в 20:00. «MLFlow — контроль над ML‑экспериментами». Записаться. [3]
30 сентября в 20:00. «Качество данных: риски и ответственность». Записаться. [4]
Больше бесплатных уроков сентября смотрите в дайджесте. [5]
Автор: badcasedaily1
Источник [6]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/35865
URLs in this post:
[1] логика: http://www.braintools.ru/article/7640
[2] ошибка: http://www.braintools.ru/article/4192
[3] Записаться.: https://otus.pw/mle9/
[4] Записаться.: https://otus.pw/fI2D/
[5] в дайджесте.: https://otus.pw/57PJ/
[6] Источник: https://habr.com/ru/companies/otus/articles/1081848/?utm_campaign=1081848&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.