🤖 Ошибка в проде или как одна строка сломала всё (ML edition)
Недавно в ML-сервисе (он ранжировал товары в каталоге) в продакшн попала вот такая правка:
# хотели заменить устаревший scaler
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X_train)
И всё — выдача внезапно «поехала».
В чём подвох?
В проде модель ждала данные, нормализованные так же, как при обучении.
Но fit_transform обучил новый StandardScaler прямо на боевых данных.
В результате каждый запрос нормализовался «по-своему», и модель выдавала рандом.
Одна строка превратила рекомендательную систему в рандомизатор. Почему? StandardScaler.fit_transform() вычисляет среднее (mean) и стандартное отклонение (std) по предоставленным данным и сразу применяет их ((x - mean) / std).
На обучении он посчитал mean_train и std_train на трейне и преобразовал данные. Модель научилась работать с признаками, имеющими такое распределение (~N(0,1)). На проде в каждом запросе (или батче) он заново вычислял mean_prod и std_prod на лету для одного-единственного запроса и применял их. Для одного объекта mean = x1, std = 0. Попытка поделить на ноль (или получить огромные числа, если std выставлялся в какое-то минимальное значение для избежания деления на ноль). Для небольшого батча нормализация происходила по странным, нестабильным параметрам. Модель получала на вход числа, которые по масштабу и распределению были совершенно не похожи на те, на которых она училась. Ее предсказания становились бессмысленными.
Именно поэтому ML-прод = код + данные + артефакты препроцессинга.
Как нашли:
Метрики CTR резко упали (тревогу поднимают аналитики)
В логах модели значения признаков стали «плясать».
git bisect вывел на единственную строку.
Фикс:
# должно быть строго transform с сохранённым scaler из обучения
scaler = load("scaler.joblib")
X_scaled = scaler.transform(X_request)
Выводы:
Никогда не вызывай fit/fit_transform на продовых данных.
Все препроцессоры должны сохраняться вместе с моделью (joblib/pickle/onnx).
Смоук-тесты для ML — это не только «API 200 ОК», но и проверка распределений фичей.
Весь препроцессинг (кодирование, скалирование, импутация) должен быть инкапсулирован в единый объект, который обучается только один раз и затем переиспользуется. Лучший способ — использовать sklearn.pipeline.Pipeline.
P.S. Ошибка не ограничивается StandardScaler. Любой объект с состоянием, обученным на трейне, должен быть сохранён. Для прод-ML best practices тык.
Недавно в ML-сервисе (он ранжировал товары в каталоге) в продакшн попала вот такая правка:
# хотели заменить устаревший scaler
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X_train)
И всё — выдача внезапно «поехала».
В чём подвох?
В проде модель ждала данные, нормализованные так же, как при обучении.
Но fit_transform обучил новый StandardScaler прямо на боевых данных.
В результате каждый запрос нормализовался «по-своему», и модель выдавала рандом.
Одна строка превратила рекомендательную систему в рандомизатор. Почему? StandardScaler.fit_transform() вычисляет среднее (mean) и стандартное отклонение (std) по предоставленным данным и сразу применяет их ((x - mean) / std).
На обучении он посчитал mean_train и std_train на трейне и преобразовал данные. Модель научилась работать с признаками, имеющими такое распределение (~N(0,1)). На проде в каждом запросе (или батче) он заново вычислял mean_prod и std_prod на лету для одного-единственного запроса и применял их. Для одного объекта mean = x1, std = 0. Попытка поделить на ноль (или получить огромные числа, если std выставлялся в какое-то минимальное значение для избежания деления на ноль). Для небольшого батча нормализация происходила по странным, нестабильным параметрам. Модель получала на вход числа, которые по масштабу и распределению были совершенно не похожи на те, на которых она училась. Ее предсказания становились бессмысленными.
Именно поэтому ML-прод = код + данные + артефакты препроцессинга.
Как нашли:
Метрики CTR резко упали (тревогу поднимают аналитики)
В логах модели значения признаков стали «плясать».
git bisect вывел на единственную строку.
Фикс:
# должно быть строго transform с сохранённым scaler из обучения
scaler = load("scaler.joblib")
X_scaled = scaler.transform(X_request)
Выводы:
Никогда не вызывай fit/fit_transform на продовых данных.
Все препроцессоры должны сохраняться вместе с моделью (joblib/pickle/onnx).
Смоук-тесты для ML — это не только «API 200 ОК», но и проверка распределений фичей.
Весь препроцессинг (кодирование, скалирование, импутация) должен быть инкапсулирован в единый объект, который обучается только один раз и затем переиспользуется. Лучший способ — использовать sklearn.pipeline.Pipeline.
P.S. Ошибка не ограничивается StandardScaler. Любой объект с состоянием, обученным на трейне, должен быть сохранён. Для прод-ML best practices тык.