Train ↔ Serve Consistency Audit Report — SoccerPredictAI¶
Date: 2026-04-24
Auditor: GitHub Copilot (Claude Sonnet 4.6)
Scope: Расхождения feature pipeline между training, batch inference и online serving
Method: анализ src/features/, src/pipelines/features.py, src/pipelines/inference.py, src/app/services/predict.py, src/app/tasks/predict.py, src/app/routers/predict.py
1. Feature Set Consistency¶
1.1 Инвентаризация feature set по точкам¶
| Точка | Источник | Метод получения фич |
|---|---|---|
Training (final_train) |
features.parquet + features_meta.parquet через select_model_features() |
Computed offline: feature_engineering DVC stage |
| Batch inference | Computed in-process: compute_all_match_features() → select_model_features() |
Re-computed в batch_inference DVC stage |
Online serving (POST /predict/) |
Request body features: dict |
Клиент предоставляет готовые фичи |
Feature columns¶
Training (final_train):
select_model_features(features_meta, side='diff', window_sizes=[1,3], include_elo=True)
→ diff_*_mean_w1, diff_*_mean_w3 (all/season/tournament/ha × 5 stats × 2 windows = 40)
+ diff_elo_pre (ELO)
+ coverage_w1, coverage_w3 per group × 4 = 8 coverage
+ sex (categorical)
Batch inference (compute_all_match_features):
Те же: select_model_features(features_meta, side='diff', window_sizes=[1,3], ...)
→ идентичный набор
Online serving (POST /predict/):
КЛИЕНТ предоставляет dict — нет server-side enforcement
Совпадение training ↔ batch_inference¶
✅ Полностью совпадает. Обе точки используют одни и те же:
- функции: build_team_match_table, add_rolling_features, to_match_level, compute_elo_ratings
- параметры: из одного params.yaml
- feature selector: select_model_features() с одинаковыми аргументами
Training ↔ online serving¶
⚠️ НЕТ SERVER-SIDE FEATURE COMPUTATION. Online POST /predict/ принимает уже готовые фичи от клиента. Это архитектурно задуманная конфигурация (задокументировано в PredictRequest docstring), но создаёт риск:
- Клиент может прислать неправильные фичи (неправильные window sizes, неправильные stats, устаревшие ELO)
- Нет schema validation кроме типов (dict of float/int/None)
- Нет feature completeness check
2. Preprocessing Logic¶
2.1 Feature engineering: Training vs Batch inference¶
| Компонент | Training | Batch inference | Консистентен |
|---|---|---|---|
build_team_match_table |
✅ | ✅ одна функция | ✅ |
add_rolling_features (4 группы) |
✅ | ✅ одна функция | ✅ |
to_match_level (home/away → diff) |
✅ | ✅ одна функция | ✅ |
compute_elo_ratings |
✅ | ✅ одна функция | ✅ |
| ELO params (k_factor, initial_rating, home_advantage) | params.yaml | params.yaml | ✅ |
| window_sizes | classification.window_sizes |
classification.window_sizes |
✅ |
| stats_cols | features.stats_cols |
features.stats_cols |
✅ |
2.2 Feature engineering: Training vs Batch inference (критическое различие)¶
⚠️ P1 — Исторический контекст в batch inference:
В feature_engineering (training): фичи для матча m используют только finished матчи до m.
В batch_inference (compute_all_match_features): фичи для future матча m используют все finished + все future матчи в объединённой таблице, отсортированной хронологически. Future матчи имеют zeroed outcome columns (_prepare_future_rows), но их startTimeUtc позиционирует их ПОСЛЕ finished матчей. shift(1) гарантирует что будущий матч не видит свой собственный результат (которого нет).
✅ Нет leakage. Но есть subtle asymmetry:
- При training: rolling stats для финального матча в training set вычисляются на основании предшествующих finished матчей
- При batch inference: rolling stats для future матча используют те же финальные finished матчи плюс любые more recent finished матчи, которые были добавлены после feature_engineering
→ batch_inference фичи fresher чем training фичи (используют более актуальный history). Это корректное поведение, но означает небольшой train/serve skew в coverage.
3. Encoding & Scaling¶
| Компонент | Training | Serving |
|---|---|---|
| StandardScaler (num) | ✅ в sklearn Pipeline | ✅ часть serialized model |
| SimpleImputer (median for num) | ✅ в sklearn Pipeline | ✅ часть serialized model |
| OneHotEncoder (cat cols: sex) | ✅ в sklearn Pipeline | ✅ часть serialized model |
| SimpleImputer (most_frequent for cat) | ✅ в sklearn Pipeline | ✅ часть serialized model |
✅ Все preprocessing включён в serialized sklearn Pipeline и загружается через MLflow pyfunc. Нет риска training/serving skew для encoding/scaling.
4. Порядок признаков¶
# select_model_features() возвращает features_meta["name"].tolist()
# Порядок определён порядком строк в features_meta.parquet
| Аспект | Статус |
|---|---|
| Порядок фиксирован через features_meta | ✅ |
| Один и тот же порядок в training и batch inference | ✅ (та же функция, тот же файл) |
| XGBoost не зависит от порядка | ✅ (tree-based) |
| Online serving: порядок диктует клиент | ⚠️ Нет enforcement |
5. Model Loading в Serving¶
# PredictionService.predict():
model = mlflow.pyfunc.load_model(f"models:/soccer_clf@champion")
result = model.predict(pd.DataFrame([features]))
- MLflow pyfunc оборачивает sklearn Pipeline → preprocessing применяется автоматически ✅
- Нет ручного preprocessing при serving ✅
- Модель загружается по alias
@champion— всегда актуальная registered версия ✅
6. Staleness Risk¶
| Сценарий | Риск |
|---|---|
batch_inference запущен, но match_features.parquet не загружен в MinIO |
FeatureLookupService подаст stale данные с предупреждением |
| Обновлена модель в registry, но Celery worker не перезапущен | Worker продолжает использовать старую модель (in-process singleton) |
| Future матч стал finished (прошёл), но batch_inference не перезапущен | Serving подаёт предсказания для уже сыгранных матчей |
7. Findings¶
| ID | Severity | Описание |
|---|---|---|
| TS-01 | P1 | POST /predict/ не вычисляет фичи server-side — клиент несёт ответственность за корректность feature set. Нет validation что переданные фичи соответствуют model schema |
| TS-02 | P1 | Нет механизма автоматического reload модели в Celery worker при смене champion — нужен ручной перезапуск |
| TS-03 | P2 | Batch inference fresher чем training features (использует more recent history) — небольшой systematic skew в coverage features |
| TS-04 | P2 | FeatureLookupService.get_features() удаляет NaN значения из dict — модель получит неполный feature dict, SimpleImputer закроет gaps, но это неявное поведение |
| TS-05 | P2 | Нет проверки что match_features.parquet актуален (не старее N часов) при serving — stale predictions без алертинга |