Одно имя метрики, разные эксперименты: почему состав выборки надо хешировать
Рядом с метрикой принято хранить версию модели и версию данных. Этого мало. Между двумя запусками может смениться правило, по которому объекты попадают в оценку, и тогда одно и то же имя метрики описывает два разных эксперимента.
У меня это вылезло на прогнозе загруженности парковок. Часть датчиков молчит: вместо занятости они круглосуточно отдают одно и то же число. Такие парковки надо выкидывать и из обучения, и из замера, иначе модель учится предсказывать константу, а метрика выходит красивее заслуженного.
В скрипте обучения лежал список исключений: 39 идентификаторов, заведённых руками в 2025 году. Через год я сверил его с реальностью.
в списке : 39
реально мусорных сейчас : 39
пересечение : 9
Размер совпал. Состав сменился на три четверти. Тридцать парковок исключались зря, тридцать других попадали в обучение, хотя не должны были.
Два способа, которыми расходится состав
Первый очевиден: правило записано перечислением. Список идентификаторов это снимок на один день, а данные живут дальше. Часть датчиков починили, часть парковок город завёл позже, и в список они не попали просто потому, что на момент его написания не существовали.
Второй способ не требует вообще никаких действий.
Правило может быть записано условием и всё равно давать разный ответ в разные дни. У меня критерий такой: «парковка не отдала ни одного свободного места за 30 дней». Текст условия неизменен, код неизменен, а состав ползёт сам, потому что ползёт календарь.
Я поймал это на второй день после того, как написал проверку. Вчера под условие попадало 39 парковок, сегодня 40. Разницу дала одна, у которой последнее свободное место пришло ровно 31 день назад. Никто ничего не трогал, порог просто истёк.
Что именно записывать
Напрашивается хранить текст правила. Это не работает: в обоих случаях выше текст совпадал, а результат нет.
Записывать надо, какие именно объекты дошли до оценки. Хранить их списком накладно, поэтому я складываю рядом хеш.
import hashlib
def fingerprint(object_ids, rule: str) -> dict:
ids = sorted(int(i) for i in object_ids)
return {
'rule': rule,
'n': len(ids),
'sha256': hashlib.sha256(','.join(map(str, ids)).encode()).hexdigest()[:16],
}
Три поля. Текст правила нужен человеку, чтобы понять, что имелось в виду. Размер ловит грубые расхождения глазом. Хеш ловит тонкие, когда размер совпал, а состав нет.
Тот самый случай с двумя списками по 39:
правило из кода 2025 года n=39 sha256=7f0c85daeed23adb
то же правило, пересчитанное n=40 sha256=e01745cc0f28e9d8
Сравнивать размеры бесполезно, они почти равны. Хеши расходятся сразу.
Считать по факту, а не по фильтру
Отпечаток надо снимать не с тех объектов, которые прошли условие, а с тех, которые реально дошли до расчёта метрики.
Разница тонкая, но существенная. В моём замере под условие «датчик живой» подходят 170 парковок, а в метрику попали 167. Три отвалились внутри самого расчёта: по ним не хватило истории для окна прогноза. Если хешировать результат фильтра, отпечаток опишет намерение, а не то, что посчиталось.
Поэтому накопление идёт по ходу цикла.
scored = {'live': [], 'silent': []}
for object_id in ids:
df = load_history(object_id)
if len(df) < required_window:
continue # не попал в метрику, значит не попадёт и в отпечаток
...
scored[group].append(object_id)
cohorts = {
'live': fingerprint(scored['live'], 'NOT feed_silent'),
'silent': fingerprint(scored['silent'], 'feed_silent'),
}
Что показал следующий прогон
Я перезапустил тот же скрипт на следующий день. Веса модели те же, код тот же, данных прибавилось на сутки.
вчера сегодня
live n=168 MAE 8.80 → n=167 MAE 8.79 sha256=d14e294f57dd149f
silent n=39 MAE 1.18 → n=40 MAE 1.07 sha256=e01745cc0f28e9d8
MAE сдвинулась на сотую. Без отпечатка это читается как «метрика стабильна, прогон воспроизвёлся». С отпечатком видно, что выборка другая: одна парковка переехала из живых в молчащие, и сравнивать эти два числа напрямую нельзя.
Разница копеечная, и именно поэтому пример хороший. Копеечную разницу никто не пойдёт расследовать. Она ляжет в таблицу результатов как подтверждение, что всё в порядке.
Чего это не решает
Отпечаток фиксирует состав и ничего не говорит о том, что внутри объектов. Если у половины выборки обновилась история за те же сутки, идентификаторы совпадут, хеш совпадёт, а данные будут другие. Для этого нужен отдельный отпечаток самих данных, и считается он дороже.
Ещё отпечаток не делает замеры сравнимыми, он только показывает, что они несравнимы. Чтобы сравнивать честно, надо либо зафиксировать состав на момент базового замера и гонять последующие на нём же, либо раскладывать изменение метрики на вклад модели и вклад состава. Второе требует прогона старой модели на новой выборке и новой на старой, то есть четырёх замеров вместо двух.
Хеш я обрезаю до 16 символов, чтобы читать его глазами в логе. Сравнивать полную сумму незачем: задача у него одна, показать, что два прогона с одинаковым именем метрики это разные прогоны.
Данные лежат открыто: 4,6 млн получасовых замеров загруженности парковок, CC BY 4.0. Признак молчащего датчика и дата последнего свободного места там есть, так что описанное воспроизводится.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.