- BrainTools - https://www.braintools.ru -

A-A на страже: как статистический тест находит различие, которого нет

В прошлой статье [1] я разбирал спекулятивное декодирование — способ ускорить языковую модель. Метод обещает, что ускорение не изменит ответы: распределение текстов на выходе останется ровно таким же, как при обычной генерации. Гарантия следует из самого устройства метода, но её стоило проверить на практике.

Первая попытка дала обратный результат: статистический тест уверенно сообщал, что распределения различаются. Ошибка [2] сидела не в модели и не в ускорении, а в самом тесте. По одному выводу её было не разглядеть: числа выглядели нормально.

Эта статья о том, как статистический тест может показать различие там, где его нет, и как такую поломку отловить. Языковые модели тут ни при чём: такая же поломка случается и в A/B-тесте, в проверке после квантизации и в регрессионном тесте, который сравнивает распределение времени ответа до и после релиза.

Оптимизация, которая не должна ничего менять

Коротко напомню, как устроено спекулятивное декодирование: маленькая модель набрасывает предсказание нескольких токенов наперёд, а большая проверяет их уместность пачкой за один проход. Я пользовался методом EAGLE-3 [3], поэтому в выводе программ ниже спекулятивная генерация подписана как EAGLE.

Когда генерация жадная [4], проверка почти тривиальна: обе выдачи детерминированы, значит, их можно сравнить токен за токеном. Редкие несовпадения объясняются порядком суммирования в fp16, о чём подробнее было в прошлой статье [1].

Когда включается сэмплирование [5], такой ход не работает: выдачи случайны по построению и совпадать не обязаны. Сравнивать надо распределения, а их у меня нет. Есть две выборки по 500 продолжений одного и того же запроса, каждое длиной пять токенов: одна от обычной генерации, другая от спекулятивной.

Почему нельзя просто померить расстояние

Очевидный ход — взять расстояние между двумя эмпирическими распределениями. Я измерял полную вариацию: складывал модули разностей частот по всем исходам и делил сумму пополам. В выводе программ ниже она подписана как TV (от total variation).

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

Но это нулевое распределение надо откуда-то взять. Аналитическая формула тут неудобна: носитель заранее неизвестен, распределение исходов сильно неравномерное. Остаётся получить нулевое распределение из самих данных: много раз построить искусственные пары выборок, у которых различий заведомо нет, посмотреть на разброс расстояний и взять за порог 95-й процентиль.

Вот на этом шаге всё и ломается.

Пять способов построить нулевое распределение

Искусственные пары можно собрать по-разному, и все варианты выглядят одинаково разумно. Я взял пять и проверил каждый на задаче с известным ответом: обе выборки берутся из одного распределения, то есть правильный ответ всегда «различий нет». Исправный тест при уровне значимости 5% должен давать ложное срабатывание примерно в 5 прогонах из 100, а неудачный вариант выдаст себя отклонением от этой доли.

Условия такие: тысяча прогонов, по 500 наблюдений в выборке, носитель около 70 исходов. Объём выборки и носитель я подобрал близкими к реальным данным: там было по 500 продолжений обычной и спекулятивной генерации, и в каждой выборке встретилось 65 различных последовательностей из пяти токенов. В колонке «Порог» стоит 95-й процентиль нулевого распределения, построенного способом из той же строки; как и на рисунке ниже, это медиана по тысяче прогонов. Соседняя колонка сравнивает этот порог с эталонным, то есть с порогом перестановочного теста [6]. Почему эталоном я взял именно его, объясню чуть ниже.

Как построено нулевое распределение

Порог

К эталону

Ложных срабатываний

Перестановочный тест

0.2020

100%

5.0%

Бутстрэп из объединённого пула

0.1980

98%

8.5%

Бутстрэп из каждой выборки отдельно

0.2740

136%

0.0%

Бутстрэп из одной выборки

0.1920

95%

15.2%

Ресэмпл против исходной выборки

0.1341

66%

97.0%

Одно и то же расстояние на одних и тех же данных даёт от нуля до 97% ложных срабатываний. Меняется только источник искусственных выборок.

Чёрная линия — порог каждого способа, медиана по тысяче прогонов; оранжевый хвост за ней — ложные срабатывания

Чёрная линия — порог каждого способа, медиана по тысяче прогонов; оранжевый хвост за ней — ложные срабатывания

Перестановочный тест — единственный точный. Если распределения совпадают, то неважно, какое наблюдение попало в первую выборку, а какое во вторую. Значит, объединяем обе, случайно делим пополам и считаем расстояние. Повторяем [7] сотни раз и получаем нулевое распределение без единого предположения о носителе.

Остальные четыре — это бутстрэп: новую выборку набирают с возвращением из уже имеющихся данных и называют ресэмплом. Идея кажется здравой: раз настоящее распределение недоступно, будем брать значения из наблюдений. Вопрос только в том, из какого именно эмпирического распределения их брать и с чем потом сравнивать, и от ответа зависит, даст тест 0% ложных срабатываний или 97%.

Ресэмпл против исходной выборки — ловушка, в которую я попал. Здесь ресэмпл сравнивается с исходной выборкой, а не со вторым ресэмплом. Звучит невинно, но нулевое распределение при этом описывает разброс одной выборки, а наблюдаемое расстояние измерено между двумя независимыми. Я померил и это: между двумя независимыми выборками расстояние примерно в полтора раза больше, чем между выборкой и её собственным ресэмплом. Порог выходит на треть ниже, чем нужно, и «значимым» становится почти всё: 97 ложных срабатываний из ста.

Бутстрэп из каждой выборки отдельно ошибается в обратную сторону. Каждая искусственная выборка набирается из своей исходной, так что наблюдаемое различие между исходными выборками переезжает в нулевое распределение. Порог задирается на треть, и тест не находит вообще ничего. Ноль ложных срабатываний кажется достоинством ровно до того дня, когда в системе появится настоящая регрессия: если она не слишком велика, этот тест её не заметит.

Бутстрэп из одной выборки — самый коварный, потому что ошибается незаметно. Здесь обе искусственные выборки набираются из первой исходной. Порог всего на 5% ниже эталонного, а ложных срабатываний втрое больше нормы. Пятипроцентный сдвиг порога на глаз не заметишь.

Бутстрэп из объединённого пула — самый аккуратный из четырёх, но и он неточен. Обе искусственные выборки набираются из объединённого пула, как и при перестановке. Отличие в том, что перестановка раскладывает каждое наблюдение ровно в одну из выборок, а здесь наблюдения берутся с возвращением: одни попадают в обе выборки или в одну по нескольку раз, а другие ни в одну не попадают. Порог отличается от эталона на два процента, а ложных срабатываний выходит 8.5% вместо 5%, то есть в 1.7 раза больше допустимого. Даже лучший из бутстрэпов здесь проигрывает точному тесту.

Скрипт, который считает эту таблицу [8], отрабатывает за пару минут на обычном ноутбуке. Если вы сами проверяете, изменилось ли что-то после правки, полезно так же прогнать свой тест много раз на данных, где различий заведомо нет.

Как выглядит сломанный тест

Он выглядит как исправный, в том-то и беда.

Сломанный тест не падает, не ругается и не выдаёт белиберду. Он печатает расстояние, порог и p-value и уверенно объявляет различие, причём все три числа лежат в правдоподобном диапазоне. И тут же находится подходящая причина. Спекулятивная генерация считает логиты [9] сразу для целого дерева токенов, набросанных маленькой моделью, а обычная считает их по одному токену за раз. Порядок суммирования в fp16 у них разный, значит, различие вполне может быть настоящим. Объяснение убедительное. Оно даже частично верное. Просто к полученному p-value оно отношения не имеет.

Отличить сломанный тест от исправного по его выводу нельзя в принципе. Оба возвращают одинаково оформленные числа, и правдоподобно объяснить можно любое из них.

A/A-тест: сравнение, в котором различий быть не должно

Самый простой приём поймать сломанный тест — контрольное сравнение, в котором различий по построению быть не должно.

В продуктовой аналитике этот приём называется A/A-тестом: две группы, между которыми заведомо нет различий, прогоняют через тот же конвейер, что и в исходном эксперименте. Проверяется весь конвейер, включая разбиение пользователей на группы и расчёт метрик. В моём случае проверяется только статистический тест. Я сравниваю между собой две независимые выборки обычной генерации, запущенной дважды с разными зёрнами, так что спекулятивного декодирования здесь нет вовсе. Любое различие, найденное в таком сравнении, будет на совести самого теста.

Работает это потому, что сломанный тест не разбирает, что ему подсунули. Последняя строка таблицы выше как раз и есть такой контроль для моего первого теста: обе выборки там из одного распределения, и тест кричит о различии в 97 прогонах из ста. Значит, и на контроле он почти наверняка найдёт различие, как и в основном сравнении.

Здесь нужна оговорка, без которой приём кажется сильнее, чем на самом деле. Один прогон A/A-теста ловит только грубые поломки. Мой тест попался лишь потому, что врал почти всегда. Вариант, ошибающийся в 15.2% прогонов, проскочит одиночный A/A-тест в 85 случаях из ста, а вариант с 8.5% — в 92. Тест, который врёт реже, так не поймать: для него нужен второй приём, многократный прогон самого теста на данных с заранее известным ответом, как в таблице выше.

Вот как выглядели оба сравнения, когда я уже исправил тест:

КОНТРОЛЬ — обычная против обычной (тест обязан НЕ найти разницы):
  совместное распределение 5 токенов: TV=0.1440, порог=0.1940, p=0.865  тест исправен

ТЕСТ — EAGLE против обычной:
  совместное распределение 5 токенов: TV=0.2060, порог=0.2060, p=0.053
  ВЕРДИКТ: распределения неразличимы

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

И обратите внимание [10] на второе сравнение: совместный тест по всем пяти токенам выдал p = 0.053 при уровне значимости 0.05. Формально различие незначимо, фактически результат стоит на самой границе, и запаса не остаётся.

Вторая ловушка: множественные сравнения

Первая ловушка была в том, как построено нулевое распределение. Вторая — сколько раз я этот тест применил.

Совместный тест различий не нашёл. Но я считал ещё и по отдельным позициям:

  поз 0: EAGLE p=0.033 !! | контроль p=0.827 | различных токенов 10
  поз 1: EAGLE p=0.528 ok | контроль p=0.410 | различных токенов 3
  поз 2: EAGLE p=0.003 !! | контроль p=0.192 | различных токенов 12
  поз 3: EAGLE p=0.498 ok | контроль p=0.482 | различных токенов 11
  поз 4: EAGLE p=0.118 ok | контроль p=0.507 | различных токенов 16

Две позиции из пяти значимы, причём у одной из них запас приличный. Тут велик соблазн заявить о серьёзной находке, тем более что контроль на тех же позициях чистый.

Начинать надо не с p-value, а с числа сравнений. На вопрос, изменил ли метод распределение, отвечают пять сравнений, по одному на позицию. Контрольные сравнения нужны для другого: они проверяют, исправен ли сам тест. При уровне значимости 5% среди пяти сравнений хотя бы одно ложное срабатывание выпадает почти в каждом четвёртом случае, так что одну значимую позицию легко списать на множественные сравнения [11].

С двумя сложнее: две значимые позиции из пяти выпадают примерно в двух случаях из ста, а значение p = 0.003 или меньше хотя бы в одном из пяти сравнений встречается примерно в полутора случаях из ста. Позицию 2 на множественные сравнения не списать.

А вот с позицией 0 нашлось кое-что получше любых оценок вероятности.

Что закрыло вопрос

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

Позиция 0 — это первый новый токен, и выбирает его сама большая модель: спекулятивное декодирование на него никак не влияет и включается только со следующей позиции. Значит, распределение для этой позиции можно не оценивать по выборке, а прочитать напрямую. Я поставил перехват на функцию, которая тянет случайный токен, и записал тензор вероятностей, по которому обе генерации разыгрывают свой первый токен.

размер словаря: 151936
TV между распределениями, из которых берётся первый токен: 0.00000
максимальная разница вероятности одного токена: 0.00000
бит-в-бит одинаковы: True

       токен   обычная     EAGLE   разница
     'apple'   0.33350   0.33350  +0.00000
      'book'   0.31812   0.31812  +0.00000
       'dog'   0.15259   0.15259  +0.00000

Сто пятьдесят тысяч чисел совпали бит в бит — это тот самый результат, на который опирается вывод прошлой статьи [1]. Различия на позиции 0 нет и быть не может, а тест всё равно выдал там p = 0.033.

Статистический тест отвечает на вопрос «правдоподобно ли такое различие при таком объёме данных, если распределения совпадают». Он не отвечает на вопрос «есть ли различие». Если ответ на второй вопрос можно получить напрямую, надо измерять. Для позиции 0 вся возня со статистикой свелась к полутора десяткам строк кода с перехватом, и они решили дело надёжнее любого p-value.

Где кончается прямое измерение

Дальше позиции 0 прямое измерение не работает: распределение следующего токена зависит от того, какой токен уже выпал, а в каждом продолжении он свой. Сравнивать там нечего, объекты разные.

Поэтому позиция 2 с её p = 0.003 так и осталась загадкой. На множественные сравнения её не списать, прямо померить нельзя, а по построению метода при точной арифметике распределения должны совпадать. Правдоподобная версия — порядок суммирования в fp16, тот же самый, который разводит выдачи в жадном режиме. Этим же объяснением я выше оправдывал сломанный тест, только теперь оно относится к p-value исправного. Доказать это мне нечем, так и записываю: одна позиция не объяснена.

И совместный тест дал p = 0.053, то есть гипотезу о совпадении распределений я не отверг, но и не доказал. Выдавать пограничный результат за подтверждение гарантии я не буду.

Что я из этого вынес

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

Для себя я сформулировал четыре правила:

  1. Прогонять A/A-тест раньше основного сравнения. Это сравнение, в котором различий быть не должно: две выборки, собранные до изменения, или два запуска одной и той же системы с разными зёрнами. Пока он не пройден, на основной результат смотреть рано.

  2. Проверять сам тест на данных с известным ответом. Сгенерировать данные, где различий заведомо нет, и посмотреть, как часто тест их находит. Должно выходить примерно столько, сколько заложено в уровень значимости. Это полчаса работы, и окупается она с первой же пойманной ошибки.

  3. Считать число сравнений. При пяти сравнениях на уровне значимости 5% хотя бы одно «открытие» появляется почти в каждом четвёртом случае, даже если реальных различий нет. Но эффект множественных сравнений объясняет не всё: если p меньше уровня значимости, делённого на число сравнений [12], этим эффектом его уже не объяснить.

  4. Измерять напрямую, если до ответа можно дотянуться. Тест нужен там, где прямого доступа нет. Там, где он есть, тест только добавляет способов ошибиться.

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

Ссылки

Автор: dmagog

Источник [14]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/36186

URLs in this post:

[1] прошлой статье: https://habr.com/ru/articles/1082452/

[2] Ошибка: http://www.braintools.ru/article/4192

[3] EAGLE-3: https://arxiv.org/abs/2503.01840

[4] генерация жадная: https://huggingface.co/docs/transformers/generation_strategies#greedy-search

[5] сэмплирование: https://huggingface.co/docs/transformers/generation_strategies#sampling

[6] перестановочного теста: https://en.wikipedia.org/wiki/Permutation_test

[7] Повторяем: http://www.braintools.ru/article/4012

[8] Скрипт, который считает эту таблицу: https://github.com/dmagog/eagle3-speculative-decoding/blob/main/tools/null_band.py

[9] логиты: https://developers.google.com/machine-learning/glossary#logits

[10] внимание: http://www.braintools.ru/article/7595

[11] множественные сравнения: https://ru.wikipedia.org/wiki/%D0%9F%D0%BE%D0%BF%D1%80%D0%B0%D0%B2%D0%BA%D0%B0_%D0%BD%D0%B0_%D0%BC%D0%BD%D0%BE%D0%B6%D0%B5%D1%81%D1%82%D0%B2%D0%B5%D0%BD%D0%BD%D1%83%D1%8E_%D0%BF%D1%80%D0%BE%D0%B2%D0%B5%D1%80%D0%BA%D1%83_%D0%B3%D0%B8%D0%BF%D0%BE%D1%82%D0%B5%D0%B7

[12] уровня значимости, делённого на число сравнений: https://ru.wikipedia.org/wiki/%D0%9F%D0%BE%D0%BF%D1%80%D0%B0%D0%B2%D0%BA%D0%B0_%D0%91%D0%BE%D0%BD%D1%84%D0%B5%D1%80%D1%80%D0%BE%D0%BD%D0%B8

[13] Ноутбук с полным прогоном: https://www.kaggle.com/code/georgymamarin/eagle-3-qwen3-1-7b-speculative-decoding

[14] Источник: https://habr.com/ru/articles/1087872/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1087872

www.BrainTools.ru

Rambler's Top100