scikit-learn после деплоя: как проверить имена и порядок признаков
В ноутбуке модель оттока получает число заказов за месяц и давность последней покупки. В API те же значения приходят из JSON. Ответ не падает с ошибкой, однако прогнозы резко меняются: при сборке массива разработчик поставил давность покупки перед числом заказов. Количество признаков осталось равным двум, поэтому проверка размерности пропустила подмену смысла.
Такое расхождение стоит искать до переобучения модели. Для одной и той же записи сравните входы непосредственно перед predict: имена, порядок, типы, единицы измерения и результат преобразований. Совпадение исходного JSON с таблицей ещё не доказывает, что модель получила одинаковую матрицу.
Сохраните схему вместе с моделью
Зафиксируйте список входных признаков в нужном порядке. Не стройте его из произвольного обхода словаря запроса. Если модель обучалась на DataFrame, сохраняйте имена до самой границы inference. В scikit-learn это позволяет обнаружить часть несовпадений, которые массив без названий уже не объяснит.
Отдельно определите политику лишних колонок. В примере ниже вход должен содержать ровно признаки модели. Идентификатор клиента хранится в конверте запроса и не попадает в эту таблицу. Такой контракт делает неожиданное изменение входа видимой ошибкой. Если API принимает больше данных, явно выделяйте из конверта объект признаков перед проверкой.
Воспроизведите проверку до predict
Пример требует pandas, NumPy и scikit-learn. Он обучает небольшой Pipeline, переставляет входные колонки и проверяет, что подготовка восстанавливает исходный порядок. StandardScaler обучается вместе с классификатором; при предсказании используются его сохранённые параметры.
import numpy as np
import pandas as pd
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
FEATURES = ["orders_30d", "days_since_order"]
train = pd.DataFrame({
"orders_30d": [0, 1, 1, 3, 4, 6],
"days_since_order": [60, 40, 30, 10, 5, 1]
})
y = [1, 1, 1, 0, 0, 0]
model = make_pipeline(StandardScaler(), LogisticRegression())
model.fit(train[FEATURES], y)
def prepare(frame):
if frame.columns.duplicated().any():
raise ValueError("Duplicate feature name")
missing = set(FEATURES) - set(frame.columns)
extra = set(frame.columns) - set(FEATURES)
if missing or extra:
raise ValueError(f"Missing: {missing}; extra: {extra}")
ordered = frame.loc[:, FEATURES]
if any(not pd.api.types.is_numeric_dtype(t) or
pd.api.types.is_bool_dtype(t) for t in ordered.dtypes):
raise ValueError("Numeric features required")
if not np.isfinite(ordered.to_numpy(dtype=float)).all():
raise ValueError("Finite values required")
return ordered
request = pd.DataFrame({"days_since_order": [20], "orders_30d": [2]})
reference = pd.DataFrame({"orders_30d": [2], "days_since_order": [20]})
actual = model.predict_proba(prepare(request))
assert np.allclose(actual, model.predict_proba(reference))
for bad in (
request.drop(columns="orders_30d"),
request.assign(orders_30d="2"),
request.assign(orders_30d=np.inf),
request.assign(debug=1),
pd.DataFrame([[2, 3]], columns=["orders_30d", "orders_30d"]),
):
try:
prepare(bad)
except ValueError:
continue
raise AssertionError("Invalid input accepted")
print("order restored; 5 invalid inputs rejected")
Скрипт напечатает order restored; 5 invalid inputs rejected. Первая проверка сравнивает реальные вероятности обученной модели для двух представлений одной записи. Последующие случаи проверяют отсутствие признака, строковое число, бесконечность, лишнее поле и повторяющееся имя. Они должны завершиться отказом до вызова predict.
Строка "2" здесь отклоняется намеренно. Для публичного API можно разрешить явное преобразование числовых строк, но правило должно быть единым и проверенным. Молчаливое преобразование всего входа опасно: оно может скрыть изменение формата источника. Аналогично пропуски допускают только там, где заранее определена обработка пропусков внутри сохранённого pipeline.
Где поставить эту функцию в сервисе
В обработчике inference сначала разберите запрос и отделите служебные поля. Затем сформируйте DataFrame, вызовите prepare и передайте результат в загруженный Pipeline. Ошибку контракта верните как ошибку входа, а не как прогноз с запасным нулём. Для batch-инференса сохраните идентификаторы строк отдельно, чтобы после отказа можно было найти исходные записи.
Сохранение одного классификатора без preprocessing создаёт ещё один путь расхождения. Например, в ноутбуке признаки стандартизовали по обучающей выборке, а в сервисе забыли это сделать либо вычислили среднее по текущему пакету. Переносите обученные преобразования вместе с estimator и вызывайте transform/predict, а не fit на запросах.
Порядок колонок не защитит от изменения единиц. Значение «дней с покупки» может внезапно стать числом часов и всё равно пройти числовой контракт. Для таких полей добавьте бизнес-проверки диапазонов и небольшой набор эталонных входов. Сравнивайте преобразованные признаки и прогнозы между notebook, batch-скриптом и API на одном сохранённом артефакте.
Как удержать исправление после следующего релиза
Включите переставленные колонки и некорректные входы из примера в тесты сервиса. Отдельно проверяйте версию набора признаков рядом с версией модели. Если новый релиз требует третий признак, запрос старого формата должен получить понятную ошибку или пройти через явный совместимый адаптер.
В платном курсе ML Engineer / Data Scientist: табличные модели в продакшн вы соберёте inference pipeline, проверите его unit-тестами и перенесёте модель в batch-инференс и FastAPI-сервис. Дальше в программе — контейнеризация и мониторинг. Откройте программу курса, чтобы отработать весь путь от обучения до одинаковых входов в разных способах запуска.
