Перейти к содержимому
Ступень 11.3 Вероятность и теория информации

Статистика экспериментов: шум, доверительные интервалы, бутстрэп

Модель A набрала 71%, модель B — 73% на 500 вопросах: есть ли разница? Выборка и стандартная ошибка, ЦПТ на симуляции, доверительный интервал (откуда 1.96), бутстрэп, парное сравнение моделей, p-value без ритуала, множественные сравнения и размер eval-набора.

УРОКstage-1/1-3-probability/04-statistics-for-experiments.mdx2 чУрок 4 из 4
После урока вы сможете
  • Объяснить, почему точность на бенчмарке — случайная величина, и посчитать её стандартную ошибку и 95%-доверительный интервал
  • Показать симуляцией центральную предельную теорему и объяснить, откуда в формулах берётся множитель 1.96
  • Строить бутстрэп-интервалы для любой метрики и сравнивать две модели парным бутстрэпом на одних и тех же вопросах
  • Распознавать p-hacking и «проклятие победителя» и оценивать, сколько примеров нужно в eval-наборе

Вы сравниваете две модели на тестовом наборе из 500 вопросов (бенчмаркебенчмаркEN benchmark, урок 1.3.1). Модель A ответила верно на 71%, модель B — на 73%. Берём B? Через месяц вы поменяли промпт, и точность выросла с 60% до 64%. Это улучшение или случайность? Такие решения инженер по LLM принимает каждый день: выбирает модель, промпт, гиперпараметры, решает, работает ли новая идея.

Беда в том, что любой результат на тесте содержит шум. Возьмите другие 500 вопросов того же типа — и обе цифры сдвинутся на пару пунктов. Этот урок даёт инструменты, чтобы отличить реальную разницу от шума: стандартную ошибку, доверительный интервал, бутстрэп и парное сравнение. Сквозной пример построен по мотивам исследования Anthropic «Adding Error Bars to Evals» (2024): его главный тезис — у каждого числа в отчёте должен быть интервал. Всё проверяем симуляциями на NumPy, формулы — после интуиции. Эти инструменты понадобятся в модуле 7.3 (оценка моделей), при сравнении промптов на ступени 8 и в итоговом проекте.

Что вообще значит «точность модели 72%»? Здесь и дальше точность — это accuracyдоля правильных ответов (accuracy)EN accuracyПо-русски accuracy иногда тоже называют «точностью» — из-за этого её путают с precision. Безопаснее писать «accuracy»., доля верных ответов. (В уроке 2.2.3 появится ещё precision, которую тоже иногда переводят как «точность», — не путайте.) Представим все вопросы такого типа, какие только можно задать, — бесконечный запас. Это генеральная совокупностьгенеральная совокупностьEN populationВсе объекты, о которых хотим сделать вывод, — например, все возможные вопросы данного типа. Истинная точность модели — доля верных ответов на всей совокупности; её оценивают по выборке.. Доля вопросов из неё, на которые модель отвечает верно, — истинная точность pp. Её мы никогда не узнаем: все вопросы не задашь.

Бенчмарк из n=500n = 500 вопросов — выборкавыборкаEN sampleЧасть генеральной совокупности, которую мы реально наблюдаем: бенчмарк из n вопросов. Метрика на выборке — оценка истинной величины с шумом. из этой совокупности. Каждый ответ — испытание Бернулли из урока 1.3.1: 1 (верно) с вероятностью pp или 0. Измеренная точность p^\hat p («пэ с крышкой», урок 1.0.3) — среднее этих нулей и единиц, оценка истинной pp.

Почему p^≠p\hat p \neq p? Потому что выборка случайна: в наш набор могли попасть чуть более лёгкие или чуть более трудные вопросы. Есть и второй источник шума: если модель генерирует с температурой (урок 4.1.3), на тот же вопрос она отвечает то верно, то нет. Оба шума одинаково сдвигают p^\hat p.

Выборочное среднее — тоже случайная величина

Заголовок раздела «Выборочное среднее — тоже случайная величина»

Самый наглядный способ понять шум — притвориться, что мы знаем истину, и много раз повторить эксперимент. Пусть истинная точность p=0.72p = 0.72. Сгенерируем 10 000 «бенчмарков» по 500 вопросов и посмотрим, какие точности выйдут:

import numpy as np
rng = np.random.default_rng(0)
p_true, n = 0.72, 500 # «истинная» точность и размер бенчмарка
# binomial(n, p) — число верных ответов из n (биномиальное распределение, урок 1.3.1)
accs = rng.binomial(n, p_true, size=10_000) / n # 10 000 бенчмарков: доля верных в каждом
print(accs[:6])
print(accs.mean().round(4), accs.std().round(4)) # центр и разброс
print(accs.min(), accs.max())
[0.746 0.718 0.72 0.704 0.694 0.724]
0.72 0.02
0.638 0.804

Одна и та же модель получила от 63.8% до 80.4%. Типичное отклонение от истины — 0.020, то есть два процентных пункта (п.п.: разница между 71% и 73% — это 2 п.п.). Распределение p^\hat p по повторным экспериментам — это колокол:

0.72 − 1.96·SE0.72 + 1.96·SEp = 0.720.640.680.720.760.80измеренная точность модели на случайном наборе из 500 вопросов95% «бенчмарков»между пунктирами
Одна и та же модель (истинная точность 72%) на 10 000 разных наборов по 500 вопросов. Столбцы — симуляция, кривая — нормальное распределение со средним 0.72 и стандартным отклонением SE = 0.020. Между пунктирами — 95% результатов.

Теперь пример из начала урока выглядит иначе. Разница между 71% и 73% — как раз одно типичное отклонение. Такие расхождения шум даёт постоянно, даже если модели одинаково сильны.

Разброс оценки называют стандартной ошибкойстандартная ошибкаEN standard errorСтандартное отклонение оценки метрики из-за конечного числа вопросов. Для доли — √(p(1−p)/n). (SE, standard error). Это стандартное отклонение p^\hat p по воображаемым повторениям эксперимента. Формулу вы уже видели в уроках 0.3.3 и 1.3.1: среднее nn независимых величин со стандартным отклонением σ\sigma имеет разброс σ/n\sigma / \sqrt{n}. У ответа 0/1 σ=p(1−p)\sigma = \sqrt{p(1-p)}, поэтому

SE=σn=p(1−p)n\mathrm{SE} = \frac{\sigma}{\sqrt{n}} = \sqrt{\frac{p(1-p)}{n}}

Читаем: «стандартная ошибка доли — корень из pp на 1−p1 - p, делённого на число вопросов». Проверка: 0.72⋅0.28/500=0.0201\sqrt{0.72 \cdot 0.28 / 500} = 0.0201 — ровно то, что дала симуляция. В жизни pp неизвестна, и в формулу подставляют p^\hat p: на больших nn это почти не меняет ответ.

correct = np.array([1] * 355 + [0] * 145) # ответы модели A: 355 верных из 500
p_hat = correct.mean()
se = np.sqrt(p_hat * (1 - p_hat) / len(correct))
print(p_hat, round(se, 4)) # 0.71 0.0203
# общая формула s / √n для любой метрики — например, оценки судьи от 1 до 10
print(round(correct.std(ddof=1) / np.sqrt(len(correct)), 4)) # 0.0203

Вторая строка — формула для любой метрики, которая усредняется по примерам: loss, длина ответа, баллы, которые ставит ответам модель-«судья» (урок 7.3.2). Берём стандартное отклонение по примерам ss и делим на n\sqrt{n}. Параметр ddof=1 делит сумму квадратов на n−1n - 1 вместо nn: так оценка разброса по выборке получается чуть честнее (на сотнях примеров разницы не видно).

Главный практический вывод — закон 1/n1/\sqrt{n} из урока 0.3.3: чтобы уменьшить шум вдвое, нужно вчетверо больше вопросов.

Почему гистограмма выше похожа на колокол нормального распределениянормальное (гауссовское) распределениеEN normal (Gaussian) distribution«Колокол» N(μ, σ²). Правило 68–95–99.7 — доля значений в пределах 1, 2 и 3 σ от среднего.? Это не совпадение, а центральная предельная теоремацентральная предельная теорема (ЦПТ)EN central limit theorem (CLT)Среднее многих независимых одинаково распределённых величин распределено почти нормально со стандартным отклонением σ/√n — каким бы ни было исходное распределение. Отсюда интервал ± 1.96·SE. (ЦПТ): среднее многих независимых величин распределено почти нормально, каким бы ни было распределение каждой из них. Условия: величины независимы, распределены одинаково и не имеют «бесконечного» разброса; приближение тем точнее, чем больше величин усредняем.

Проверим на величине, совсем не похожей на колокол, — длине ответов модели. Обычно ответы короткие, но иногда модель «задумывается» на тысячи токенов. Смоделируем это экспоненциальным распределением: маленькие значения у него самые частые, большие — редкие, но встречаются (rng.exponential(300) выдаёт такие числа со средним 300). Сравним отдельные ответы со средним по 50 ответам. У нормального распределения в пределах ±σ\pm\sigma лежит 68.3% значений, а выше m+2σm + 2\sigma — 2.3% (правило 68–95–99.7 из урока 1.3.1):

rng = np.random.default_rng(1)
lengths = rng.exponential(300, size=(100_000, 50)) # 100 000 экспериментов по 50 ответов
one = lengths[:, 0] # отдельные ответы
means = lengths.mean(axis=1) # средняя длина в каждом эксперименте
for name, x in [("один ответ", one), ("среднее по 50", means)]:
m, s = x.mean(), x.std()
inside = np.mean(np.abs(x - m) <= s) # доля в пределах ±σ
tail = np.mean(x > m + 2 * s) # доля в правом хвосте
print(f"{name:14} среднее {m:6.1f} σ {s:6.1f} в пределах ±σ: {inside:.3f} выше m + 2σ: {tail:.3f}")
один ответ среднее 299.5 σ 300.0 в пределах ±σ: 0.865 выше m + 2σ: 0.049
среднее по 50 среднее 300.1 σ 42.5 в пределах ±σ: 0.685 выше m + 2σ: 0.030

Отдельные длины совсем не нормальны: 86.5% вместо 68.3%, длинный правый хвост. А средние по 50 уже почти колокол (68.5%), и разброс сжался ровно в 50≈7.1\sqrt{50} \approx 7.1 раза: 300/7.1≈42.5300 / 7.1 \approx 42.5. Чем больше nn, тем ближе к колоколу.

Отличное наглядное объяснение ЦПТ — видео 3Blue1Brown «But what is the Central Limit Theorem?» (ссылка в источниках, с русскими автосубтитрами): первые 15 минут — откуда берётся колокол и корень из nn, с 24:50 — диапазон 95% и условия теоремы.

Шум мы измерили. Теперь превратим его в ответ на вопрос «где может быть истинная точность?». Раз p^\hat p почти нормально распределена вокруг pp со стандартным отклонением SE, то в 95% экспериментов p^\hat p отстоит от pp не дальше чем на 1.96 SE1.96\,\mathrm{SE}. Переворачиваем: интервал

p^±1.96⋅SE\hat p \pm 1.96 \cdot \mathrm{SE}

накрывает истинную pp в 95% экспериментов. Это 95-процентный доверительный интервалдоверительный интервалEN confidence intervalДиапазон вокруг измеренного значения метрики, построенный так, что при многократном повторении эксперимента он накрывает истинное значение с заданной частотой (обычно 95%). Для доли верных ответов ≈ p ± 1.96·√(p(1−p)/n).. Для модели A: 0.71±1.96⋅0.0203=0.71±0.0400.71 \pm 1.96 \cdot 0.0203 = 0.71 \pm 0.040, то есть от 67.0% до 75.0%. Для B: 0.73±0.0390.73 \pm 0.039, от 69.1% до 76.9%. Интервалы почти целиком перекрываются.

Откуда 1.96. Правило 68–95–99.7 говорит, что 95% значений нормального распределения лежат в пределах примерно ±2σ\pm 2\sigma. Точнее — в пределах ±1.96σ\pm 1.96\sigma. Проверим симуляцией:

z = np.random.default_rng(2).standard_normal(1_000_000) # миллион значений из N(0, 1)
print(np.quantile(np.abs(z), 0.95)) # 1.96026… — 95% значений по модулю меньше этого числа
# точное значение даёт scipy (по желанию): from scipy.stats import norm; norm.ppf(0.975) → 1.959963984540054

Для 90%-интервала множитель 1.645, для 99% — 2.576. Чем увереннее хотим быть, тем шире интервал.

Что значат эти 95%. Это свойство процедуры: если повторять эксперимент снова и снова и каждый раз строить интервал, 95% интервалов накроют истину. Проверим на 10 000 повторов и заодно посмотрим, когда формула врёт:

rng = np.random.default_rng(3)
for p_true, n in [(0.72, 500), (0.95, 30)]:
p_hat = rng.binomial(n, p_true, size=10_000) / n
se = np.sqrt(p_hat * (1 - p_hat) / n) # SE по данным: настоящей p мы «не знаем»
covered = (p_hat - 1.96 * se <= p_true) & (p_true <= p_hat + 1.96 * se)
print(p_true, n, covered.mean())
0.72 500 0.9468
0.95 30 0.7816
1234567891011121314151617181920истинная точность 0.720.640.680.720.760.80промах
20 «повторных бенчмарков» по 500 вопросов, у каждого свой интервал p̂ ± 1.96·SE. Девятнадцать накрывают истинные 72%, один промахнулся — ровно так и выглядит «95%».

На 500 вопросах покрытие честные 95%. А на 30 вопросах при точности 95% формула накрывает истину лишь в 78% случаев: модель часто решает все 30 задач, p^=1\hat p = 1, и формула выдаёт «интервал» нулевой ширины. ЦПТ требует, чтобы nn было достаточно большим, а pp — не у самого края. Для маленьких наборов (AIME — 30 задач) и точностей около 0 или 100% используйте бутстрэп или специальные интервалы (например, интервал Уилсона).

Для доли есть формула. А для медианы длины ответа, F1-меры (урок 2.2.3) или pass@k (урок 1.0.5) — нет. Здесь выручает бутстрэпбутстрэп (выборка с возвращением)EN bootstrap samplingОснова бэггинга (bagging) и случайного леса. (bootstrap, «вытянуть себя за шнурки»). Идея: генеральную совокупность мы не видим, но наша выборка — её лучшее приближение. Будем «повторять эксперимент» на ней самой:

  1. Выбираем из своих nn примеров nn штук с возвращением: один пример может попасть несколько раз, другой — ни разу.
  2. Считаем метрику на этом «новом бенчмарке».
  3. Повторяем тысячи раз; 2.5-й и 97.5-й перцентили (урок 0.3.3) полученных значений — границы 95%-интервала.
def bootstrap_ci(values, stat=np.mean, n_boot=10_000, seed=0):
rng = np.random.default_rng(seed)
values = np.asarray(values)
n = len(values)
stats = []
for _ in range(n_boot):
idx = rng.integers(0, n, size=n) # n случайных номеров с повторами: выборка с возвращением
stats.append(stat(values[idx])) # метрика на «новом бенчмарке»
return np.percentile(stats, [2.5, 97.5]) # между ними — средние 95% значений
print(bootstrap_ci(correct)) # [0.67 0.75] — почти как формула 0.71 ± 0.040
lengths = np.random.default_rng(4).exponential(300, size=200) # длины 200 ответов
print(np.median(lengths).round(1), bootstrap_ci(lengths, stat=np.median).round(1))
[0.67 0.75]
217.6 [187.5 265.8]

На доле бутстрэп совпал с формулой, а для медианы дал интервал там, где формулы у нас нет. Обратите внимание: в каждую бутстрэп-выборку не попадает около трети примеров — доля (1−1/n)n→1/e≈0.368(1 - 1/n)^n \to 1/e \approx 0.368 (число ee из урока 1.0.2). Тот же приём «выборки с возвращением» лежит в основе случайного леса (урок 2.3.1).

Вернёмся к вопросу из начала: A — 71%, B — 73%, по 500 вопросов.

Если бы модели решали разные наборы вопросов, оценки были бы независимы. Дисперсии независимых величин складываются (урок 1.3.1), поэтому SE разницы — SEA2+SEB2=0.02032+0.01992≈0.028\sqrt{\mathrm{SE}_A^2 + \mathrm{SE}_B^2} = \sqrt{0.0203^2 + 0.0199^2} \approx 0.028, а 95%-интервал разницы — 2±5.62 \pm 5.6 п.п. Ноль внутри с большим запасом. Вывод «B лучше» не обоснован.

Но модели решали одни и те же вопросы! А задачи бывают лёгкие (обе модели решают) и трудные (обе проваливают). Такие вопросы о разнице моделей ничего не говорят. Значение имеют только вопросы, где модели разошлись. В исследовании Anthropic корреляция ответов передовых моделей по вопросам была от 0.3 до 0.7 — общий шум очень велик, и парное сравнение его убирает. Пусть на наших 500 вопросах картина такая:

B верноB неверно
A верно34015
A неверно25120

Модели разошлись всего на 40 вопросах: 25 решила только B, 15 — только A. Посчитаем разность по каждому вопросу di=bi−ai∈{−1,0,+1}d_i = b_i - a_i \in \{-1, 0, +1\} и её стандартную ошибку, а затем — парный бутстрэппарное сравнение (парный тест)EN paired testСравнение двух вариантов на одних и тех же примерах через разность по каждому примеру (например, парный бутстрэп). Убирает общий шум лёгких и трудных вопросов, поэтому точнее сравнения двух независимых оценок.: пересэмплируем вопросы, одни и те же для обеих моделей.

# ответы двух моделей на одни и те же 500 вопросов: 1 — верно, 0 — неверно
a = np.array([0] * 25 + [1] * 15 + [1] * 340 + [0] * 120) # модель A
b = np.array([1] * 25 + [0] * 15 + [1] * 340 + [0] * 120) # модель B
print(a.mean(), b.mean()) # 0.71 0.73
d = b - a # разница на каждом вопросе
se_paired = d.std(ddof=1) / np.sqrt(len(d))
print(d.mean(), round(se_paired, 4), round(1.96 * se_paired, 4)) # 0.02 0.0126 0.0248
rng = np.random.default_rng(0)
diffs = []
for _ in range(10_000):
idx = rng.integers(0, len(d), size=len(d)) # пересэмплируем ВОПРОСЫ — одни и те же для обеих моделей
diffs.append(b[idx].mean() - a[idx].mean())
diffs = np.array(diffs)
print(np.percentile(diffs, [2.5, 97.5]).round(3), np.mean(diffs <= 0).round(3))
0.71 0.73
0.02 0.0126 0.0248
[-0.004 0.046] 0.066

Парный интервал — 2±2.52 \pm 2.5 п.п. вместо ±5.6\pm 5.6: вдвое уже, и бутстрэп с формулой согласны. Но ноль всё равно внутри. Последнее число, 0.066, — доля бутстрэп-разниц, которые не больше нуля. Не читайте его как «вероятность того, что B не лучше A»: это лишь грубый аналог одностороннего p-value (о p-value — ниже). Честный ответ: по этим данным нельзя утверждать, что B лучше. Если бы при тех же 71% и 73% модели расходились только на 20 вопросах (15 против 5), парный интервал был бы 2±1.72 \pm 1.7 п.п. и ноль остался бы снаружи. Всё решают вопросы, на которых модели расходятся. Поэтому сохраняйте ответы по каждому вопросу, а не только итоговую цифру: без них парное сравнение невозможно.

Второй способ задать тот же вопрос. Предположим, что разницы нет (это называют нулевой гипотезойнулевая гипотезаEN null hypothesisПредположение «эффекта нет» (модели одинаковы), относительно которого считают p-value. Обозначается H₀.). Тогда на каждом из 40 «спорных» вопросов прав A или B — как монетка. Насколько часто монетка даёт перекос не меньше нашего, 25 против 15? Эта частота и есть p-valuep-value (p-значение)EN p-valueКак часто случай дал бы результат не менее экстремальный, чем наблюдаемый, если бы эффекта не было. Не вероятность того, что эффекта нет, и не размер эффекта. Порог «значимости» по соглашению — 0.05.:

rng = np.random.default_rng(0)
disc = d[d != 0] # 40 вопросов, где модели разошлись
observed = disc.sum() # 25 − 15 = 10
coins = rng.choice([-1, 1], size=(100_000, len(disc))) # «мир без разницы»: кто прав — решает монетка
fake = coins.sum(axis=1) # перекос в каждом воображаемом мире
print(observed, np.mean(np.abs(fake) >= abs(observed))) # 10 0.152

В 15% «миров без разницы» случай даёт такой же или больший перекос. Это обычное дело, а не повод праздновать. По соглашению результат называют статистически значимым, если p-value меньше 0.05; обычно это совпадает с тем, что 95%-интервал разницы не содержит ноль. Два частых заблуждения: p-value — не вероятность того, что модели одинаковы, и не размер разницы. Крошечное улучшение на миллионе примеров может иметь p = 0.0001 и при этом быть бесполезным. Смотрите на интервал: он показывает и есть ли эффект, и насколько он велик.

Допустим, вы проверили 20 идей — например, по очереди убирали части модели и смотрели, что изменится (такие эксперименты называют абляциямиабляционный эксперимент, абляцияEN ablation studyУбрать или заменить один компонент и сравнить с полной моделью, чтобы измерить вклад этого компонента.), — и ни одна на самом деле ничего не меняет. Каждая проверка на уровне 5% ложно «срабатывает» в 5% случаев. Вероятность, что сработает хотя бы одна из 20 независимых проверок, — 1−0.9520≈0.641 - 0.95^{20} \approx 0.64 (обратное событие «все 20 молчат», урок 1.0.5):

rng = np.random.default_rng(5)
n, p, k, runs = 500, 0.70, 20, 5000
a = rng.binomial(n, p, size=(runs, k)) / n # базовая модель, k сравнений в каждом из runs «исследований»
b = rng.binomial(n, p, size=(runs, k)) / n # «улучшения», которые на деле ничего не меняют
se = np.sqrt(a * (1 - a) / n + b * (1 - b) / n)
signif = np.abs(b - a) > 1.96 * se # какие сравнения выглядят «значимыми»
print(signif.mean().round(3), signif.any(axis=1).mean().round(3)) # 0.051 0.644

Каждое сравнение ошибается в 5% случаев, но в 64% «исследований» найдётся хотя бы одно «открытие». Это проблема множественных сравнениймножественные сравненияEN multiple comparisonsЧем больше проверок, тем больше ложных «открытий»: среди 20 бесполезных идей хотя бы одна окажется «значимой» на уровне 5% с вероятностью ≈ 64%. Лечат заранее зафиксированным протоколом, перепроверкой и поправками (Бонферрони).. Её злая версия — p-hacking: перебирать промпты, seed, подмножества бенчмарка и способы считать метрику, пока что-нибудь не станет «значимым», и сообщить только это.

Родственный эффект — проклятие победителя. Возьмите 10 промптов, которые на самом деле одинаково хороши (точность 70%), и проверьте каждый на 100 вопросах. Лучший в среднем покажет около 77%:

rng = np.random.default_rng(6)
scores = rng.binomial(100, 0.7, size=(100_000, 10)) / 100 # 10 одинаковых промптов × 100 вопросов, 100 000 раз
print(scores.max(axis=1).mean().round(3)) # 0.769 — «победитель» лучше истины на 7 п.п.

Как защититься:

  • Заранее решите, что и как сравниваете: набор, метрику, число seed.
  • Выбирайте лучший вариант на одном наборе (валидационном), а итоговое число сообщайте на другом, который не трогали при выборе (тестовом, урок 2.1.1).
  • Перепроверяйте «победителя» на свежих данных и других seed. Если эффект настоящий, он повторится. Как сравнивать обучения по 3–5 seed с честным интервалом — в уроке 11.1.3.
  • Если сравнений mm, грубая поправка Бонферрони — требовать p-value меньше 0.05/m0.05 / m.

Из формулы интервала можно найти размер набора. Хотим полуширину интервала hh (например, ±2\pm 2 п.п.):

h=1.96p(1−p)n⟹n=1.962⋅p(1−p)h2h = 1.96\sqrt{\frac{p(1-p)}{n}} \quad\Longrightarrow\quad n = \frac{1.96^2 \cdot p(1-p)}{h^2}

Если pp заранее неизвестна, берут худший случай p=0.5p = 0.5, где p(1−p)p(1-p) максимально:

Полуширина hh±10 п.п.±5 п.п.±3 п.п.±2 п.п.±1 п.п.
Нужно вопросов nn97385106824019604

Для грубой проверки «не сломали ли мы модель» хватит сотни примеров (±10 п.п.). Чтобы различать изменения в 1–2 п.п., нужны тысячи — или парное сравнение. Для него вместо p(1−p)p(1-p) подставляют дисперсию парной разности; для ответов 0/1 она примерно равна доле вопросов, на которых модели расходятся.

Последний шаг — мощностьмощность экспериментаEN statistical powerВероятность, что эксперимент обнаружит эффект, если он действительно есть. Обычно планируют 80%; для этого считают нужный размер выборки до эксперимента.: вероятность, что эксперимент заметит разницу, если она действительно есть. Чтобы настоящая разница δ\delta оказывалась «значимой» в 80% повторений, к 1.96 добавляют запас 0.84: n≈(1.96+0.84)2⋅sd2/δ2n \approx (1.96 + 0.84)^2 \cdot s_d^2 / \delta^2. Пример: модели расходятся на 8% вопросов (sd2≈0.08s_d^2 \approx 0.08), хотим надёжно замечать δ=2\delta = 2 п.п.: n≈7.84⋅0.08/0.0004≈1570n \approx 7.84 \cdot 0.08 / 0.0004 \approx 1570 вопросов. На 500 вопросах из нашего примера такую разницу мы чаще пропустим, чем заметим. Считайте мощность до эксперимента — это одна из пяти рекомендаций статьи Anthropic. Та же формула задаёт размер A/B-теста в продукте: чтобы заметить рост доли заказавших в ленте маркетплейса с 4% до 4.08%, нужно около 950 000 пользователей в каждой группе (урок 11.2.3).

Углубление: многомерное нормальное распределение (понадобится в VAE и диффузии)

До сих пор колокол был одномерным. Часто нужно сэмплировать сразу вектор чисел. Самый простой случай — dd независимых величин N(0,1)\mathcal{N}(0, 1): это многомерное нормальное распределение N(0,I)\mathcal{N}(0, I) (II — единичная матрица). Именно такой шум добавляют к картинкам в диффузионных моделях (урок 9.1.2) и из него сэмплируют скрытый вектор в VAE (урок 9.1.3). Сдвиг и масштаб дают x=μ+σ⊙εx = \mu + \sigma \odot \varepsilon, где ε∼N(0,I)\varepsilon \sim \mathcal{N}(0, I).

Координаты могут быть и связаны — как ответы двух моделей на одни и те же вопросы. Связь описывает ковариация Cov[X,Y]=E[(X−EX)(Y−EY)]\mathrm{Cov}[X, Y] = \mathbb{E}[(X - \mathbb{E}X)(Y - \mathbb{E}Y)]: больше нуля, если XX и YY обычно отклоняются в одну сторону. Дисперсии и ковариации собирают в матрицу ковариаций Σ\Sigma, и распределение пишут N(μ,Σ)\mathcal{N}(\mu, \Sigma):

rng = np.random.default_rng(7)
cov = np.array([[1.0, 0.8],
[0.8, 1.0]]) # дисперсии 1, ковариация 0.8: координаты «ходят вместе»
x = rng.multivariate_normal([0.0, 0.0], cov, size=100_000)
print(np.cov(x.T).round(2)) # оценка матрицы по сэмплам ≈ исходная
print(np.var(x[:, 0] - x[:, 1]).round(2)) # 0.4 = 1 + 1 − 2·0.8

Последняя строка объясняет парное сравнение одной формулой: Var[X−Y]=Var[X]+Var[Y]−2 Cov[X,Y]\mathrm{Var}[X - Y] = \mathrm{Var}[X] + \mathrm{Var}[Y] - 2\,\mathrm{Cov}[X, Y]. Чем сильнее связаны оценки двух моделей, тем меньше шум их разности.

flowchart TD
A["Есть ответы двух вариантов<br/>на одних и тех же вопросах?"] -->|да| B["Парная разность d = b − a<br/>парный бутстрэп"]
A -->|нет| C["Интервал разницы<br/>√(SE_A² + SE_B²)"]
B --> D{"95%-интервал<br/>содержит 0?"}
C --> D
D -->|да| E["«Значимой разницы<br/>не обнаружено»"]
D -->|нет| F["Разница есть, её размер — интервал.<br/>Сравнений было много? Перепроверьте"]
Шпаргалка: как сравнить два варианта модели или промпта

Реализуйте инструменты урока: стандартную ошибку, интервал точности, бутстрэп для любой метрики, парный бутстрэп и расчёт размера набора. Тесты проверят их на данных урока, а одна симуляция — что ваш интервал действительно накрывает истину примерно в 95% повторов.

Шум, интервалы и парный бутстрэпnumpy · нужен интернет8 тестов
import math

import numpy as np


def standard_error(p, n):
    """Стандартная ошибка доли верных ответов: sqrt(p · (1 − p) / n).

    Пример: standard_error(0.72, 500) ≈ 0.0201.
    """
    # TODO
    ...


def accuracy_ci(correct, z=1.96):
    """correct — массив (или список) из 0 и 1: верно ли модель ответила на каждый вопрос.

    Верните кортеж (p_hat, lo, hi): точность p_hat = доля единиц и границы интервала p_hat ± z · SE,
    где SE считается по p_hat и числу вопросов.
    Пример: 355 единиц из 500 → (0.71, ≈0.6702, ≈0.7498).
    """
    # TODO
    ...


def bootstrap_ci(values, stat=np.mean, n_boot=2000, alpha=0.05, seed=0):
    """Перцентильный бутстрэп-интервал для метрики stat (любая функция массива → число).

    1) rng = np.random.default_rng(seed);
    2) n_boot раз: выберите n случайных индексов с возвращением (rng.integers(0, n, size=n)),
       посчитайте stat на values[idx];
    3) верните (lo, hi) — перцентили 100·alpha/2 и 100·(1 − alpha/2) полученных значений.
    """
    # TODO
    ...


def paired_bootstrap(a, b, n_boot=5000, alpha=0.05, seed=0):
    """a, b — ответы (0/1) двух моделей на ОДНИ И ТЕ ЖЕ вопросы (одинаковой длины).

    Пересэмплируйте вопросы с возвращением — одни и те же индексы для a и b — и каждый раз считайте
    a[idx].mean() − b[idx].mean(). Верните кортеж из четырёх чисел:
      (diff, lo, hi, p_not_better)
    diff — разница mean(a) − mean(b) на исходных данных; lo, hi — перцентили 100·alpha/2 и 100·(1 − alpha/2);
    p_not_better — доля бутстрэп-разниц, которые меньше или равны 0 (повторы, где «a не лучше b»).
    Это не вероятность того, что a не лучше b, а лишь грубый аналог одностороннего p-value.
    """
    # TODO
    ...


def samples_needed(half_width, p=0.5, z=1.96):
    """Сколько вопросов нужно, чтобы полуширина интервала z · sqrt(p(1 − p) / n) была ≤ half_width.

    Верните целое число, округлив вверх. Пример: samples_needed(0.01) → 9604.
    """
    # TODO: выразите n из формулы полуширины
    ...


# Проверьте себя кнопкой «Запустить»:
correct = np.array([1] * 355 + [0] * 145)
print(standard_error(0.72, 500))            # ≈ 0.0201
print(accuracy_ci(correct))                 # ≈ (0.71, 0.6702, 0.7498)
print(bootstrap_ci(correct))                # ≈ (0.67, 0.75)
a = np.array([0] * 25 + [1] * 15 + [1] * 340 + [0] * 120)
b = np.array([1] * 25 + [0] * 15 + [1] * 340 + [0] * 120)
print(paired_bootstrap(b, a))               # ≈ (0.02, −0.004, 0.044, 0.068): ноль внутри интервала
print(samples_needed(0.01))                 # 9604
Проверь себя6 вопросов
1 Модель ответила верно на 80% из 400 вопросов. Чему равна полуширина 95%-доверительного интервала (в долях, не в процентах)? Округлите до трёх знаков.
2 Во сколько раз нужно увеличить eval-набор, чтобы доверительный интервал стал вдвое уже?
3 Две версии промпта проверили на одних и тех же 300 вопросах. Какой способ сравнения правильный?
4 Парный бутстрэп дал 95%-интервал разницы [−0.5,+3.1][-0.5, +3.1] п.п. Какие выводы верны?Можно выбрать несколько ответов
5 Вы проверили 10 бесполезных идей, каждую на уровне 5%, проверки независимы. Какова вероятность хотя бы одной ложной «находки»? Округлите до двух знаков.
6 Что такое p-value в парном сравнении двух моделей?
  • Бенчмарк — выборка из генеральной совокупности вопросов, и точность p^\hat p на нём — случайная величина. Её разброс — стандартная ошибка p(1−p)/n\sqrt{p(1-p)/n} (в общем случае s/ns/\sqrt{n}); вдвое меньше шум — вчетверо больше вопросов.
  • ЦПТ: среднее многих независимых величин почти нормально. Отсюда интервал p^±1.96 SE\hat p \pm 1.96\,\mathrm{SE}, который накрывает истину в 95% экспериментов. На маленьких наборах и у краёв (0% или 100%) формула врёт — берите бутстрэп.
  • Бутстрэп — пересэмплирование своих данных с возвращением; даёт интервал для любой метрики.
  • Модели сравнивают парно, на одних и тех же вопросах: всё решают вопросы, где они расходятся. Значимость — это «95%-интервал разницы не содержит 0», а p-value — частота такого перекоса в «мире без разницы».
  • Много сравнений рождают ложные открытия и проклятие победителя: фиксируйте протокол заранее и перепроверяйте на свежих данных. Размер набора и мощность считайте до эксперимента.

Дальше: модуль 1.4 «Оптимизация» — градиентный спуск: как модель находит параметры, которые минимизируют cross-entropy, и почему шум мини-батча (тот самый σ/B\sigma/\sqrt{B}) ему даже помогает.