🏢 ML в продакшене: от бизнес-задачи до мониторинга
Полный цикл ML-проекта в компании. 10 этапов, конкретные инструменты, 3 сквозных примера.
Зачем этот гайд
Ты умеешь обучать модели. Знаешь, что такое F1-score и ROC-AUC. Может, даже побеждал на Kaggle. Но в компании тебя ждёт неприятный сюрприз: 80% работы ML-инженера — это НЕ обучение моделей. Это процессы, инфраструктура, командная работа, A/B тесты и бесконечный мониторинг.
Этот гайд — про то, как ML-проект живёт в реальной компании. 10 этапов от "PM пришёл с идеей" до "модель крутится в проде и ты спишь спокойно". С конкретным кодом, инструментами и тремя сквозными примерами.
3 сквозных примера
- 💼 Бизнес-задача → 🔍 Discovery → 📋 Design Doc → 📊 Планирование → 🔧 Разработка
- 👀 Code Review → 🧪 A/B тест → 🚀 Деплой → 📈 Мониторинг → 🔄 Итерация
Загрузка интерактивного виджета...
💼 Блок 1: Бизнес-задача
ML-задача никогда не начинается с "давайте обучим нейронку". Она начинается с бизнес-проблемы: PM приходит с болью, а ты должен понять — это вообще ML-задача или хватит трёх строк regex? Спойлер: в половине случаев хватит regex.
На этом этапе твоя задача — задать правильные вопросы. Какой объём данных? Есть ли разметка? Какой SLA по latency? Сколько стоит ручной процесс? Если не задашь — потратишь 2 месяца на модель, которая никому не нужна.
- Откуда берётся задача: Product Owner/PM приходит с бизнес-проблемой, а не с техническим решением
- Когда правила → ML: когда правила перестают масштабироваться (100 regex-ов и всё равно Recall 30%)
- Stakeholders: PM, Tech Lead, DS Lead, бизнес-заказчик
- Приоритизация: Impact vs Effort matrix, RICE scoring (Reach × Impact × Confidence / Effort)
- Результат этапа: Epic в Jira, чёткая формулировка проблемы, stakeholders согласованы
🎬 День из жизни: NLP модерация
PM показывает дашборд: жалобы на токсичные комментарии выросли на 40% за месяц. 3 модератора обрабатывают 2000 комментариев в день — очередь растёт. Стоимость: $6000/мес. Твоя задача — задать правильные вопросы.
-- Первым делом — понять масштаб проблемы
SELECT
date_trunc('week', created_at) AS week,
count(*) AS total_comments,
sum(CASE WHEN has_complaint THEN 1 ELSE 0 END) AS complaints,
round(100.0 * sum(CASE WHEN has_complaint THEN 1 ELSE 0 END) / count(*), 2) AS complaint_rate
FROM moderation.comments
WHERE created_at >= now() - interval '3 months'
GROUP BY 1
ORDER BY 1;
-- Видим: complaint_rate вырос с 0.8% до 1.3% → тренд подтверждёнВопросы PM-у
После встречи создаёшь Epic в Jira и делаешь quick research: Jigsaw competition на Kaggle (BERT-based, F1 ~0.95 на английском), Google Perspective API (не кастомизируется), ruBERT pretrained на toxicity. Фиксируешь всё в Notion.
# Jira Epic
title: "[ML] Автоматическая модерация комментариев"
priority: High
labels: [ml, nlp, moderation]
description: |
toxic/ok.
-: 50%, 70%.
: 6000/ .📦 RecSys: Персонализация каталога
[ML] Персонализация каталога, RICE: Reach=100%, Impact=High.📸 CV OCR: Распознавание документов
[ML] OCR документов, привязка к OKR "Снижение OpEx на 40%".- Jira / Linear — трекинг задач, Epic → Stories → Tasks
- Confluence / Notion — документация, Research Notes
- RICE scoring — приоритизация: Reach × Impact × Confidence / Effort
- Miro — визуализация процессов, brainstorming
🔍 Блок 2: Discovery
Discovery — это фаза "а оно вообще работает?". Тут ты получаешь доступы, лезешь в данные, считаешь baseline и пишешь Feasibility Report. Если baseline (TF-IDF + LogReg) даёт 85% accuracy — задача решаема ML. Если regex даёт 95% — забудь про нейронки, иди пить кофе.
Длительность: 3-10 дней. Главное правило — не начинать строить pipeline, пока не доказал, что ML тут вообще нужен.
🎬 День из жизни: Discovery NLP модерации
День 1: получаешь доступы к DWH, настраиваешь окружение.
# Получаешь доступ к DWH
psql -h dwh.company.com -U ml_analyst -d production
-c "SELECT count(*) FROM moderation.comments;"
# → 1,247,832
# Настраиваешь ML-окружение
git clone git@github.com:company/ml-models.git && cd ml-models
git checkout -b research/moderation-discovery
# Зависимости
pyenv install 3.11.7 && pyenv local 3.11.7
poetry init
poetry add pandas numpy matplotlib seaborn scikit-learn jupyter sqlalchemy psycopg2-binary
poetry installДни 2-3: EDA — выгружаешь данные и ищешь паттерны.
SELECT
c.comment_id,
c.text,
c.user_id,
c.created_at,
m.is_toxic,
m.toxic_category, -- insult, threat, spam, etc.
m.moderator_id,
u.account_age_days,
u.total_comments,
u.total_reports
FROM moderation.comments c
JOIN moderation.labels m ON c.comment_id = m.comment_id
LEFT JOIN users.profiles u ON c.user_id = u.user_id
WHERE c.created_at >= '2024-01-01'
AND m.moderated_at IS NOT NULL
ORDER BY c.created_at DESC;
-- Результат: ~180K строкimport pandas as pd
from sqlalchemy import create_engine
engine = create_engine('postgresql://ml_analyst:***@localhost:5432/production')
df = pd.read_sql(query, engine)
df.to_parquet('data/moderation_raw.parquet')
print(f"Shape: {df.shape}") # (183421, 11)
# Базовые статистики
print(df['is_toxic'].value_counts(normalize=True))
# False 0.917
# True 0.083 ← 8.3% toxic — imbalanced!
print(df['toxic_category'].value_counts())
# insult 8234
# spam 3912
# threat 1456
# hate_speech 987
# other 623import seaborn as sns
import matplotlib.pyplot as plt
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
# Распределение длин текстов
df['text_length'] = df['text'].str.len()
sns.histplot(df['text_length'], bins=50, ax=axes[0, 0])
axes[0, 0].set_title('Распределение длин комментариев')
# Toxic vs Non-toxic длины
sns.boxplot(x='is_toxic', y='text_length', data=df, ax=axes[0, 1])
axes[0, 1].set_title('Длина текста: toxic vs ok')
# Динамика по времени
df.set_index('created_at').resample('W')['is_toxic'].mean().plot(ax=axes[1, 0])
axes[1, 0].set_title('Доля toxic по неделям')
# Категории
df[df['is_toxic']]['toxic_category'].value_counts().plot.bar(ax=axes[1, 1])
axes[1, 1].set_title('Категории токсичности')
plt.tight_layout()
plt.savefig('data/eda_overview.png', dpi=150)Проверяешь качество разметки: дубликаты, inter-annotator agreement (Cohen's kappa = 0.78 — хорошо), NULL-ы в текстах.
Дни 4-5: строишь baseline модели.
import re
from sklearn.metrics import classification_report
# Baseline 0: Regex + стоп-слова
toxic_patterns = [
r'\bидиот\b', r'\bдурак\b', r'\bубью\b', r'\bспам\b',
r'\bдебил\b', r'\bтупой\b', # ~100 паттернов
]
toxic_regex = re.compile('|'.join(toxic_patterns), re.IGNORECASE)
df['pred_regex'] = df['text'].apply(lambda x: bool(toxic_regex.search(x)))
print(classification_report(df['is_toxic'], df['pred_regex']))
# precision recall f1-score
# toxic 0.71 0.31 0.43 ← Recall ужасный!from sklearn.model_selection import train_test_split
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, roc_auc_score
# Baseline 1: TF-IDF + LogReg
X_train, X_test, y_train, y_test = train_test_split(
df['text'], df['is_toxic'], test_size=0.2,
random_state=42, stratify=df['is_toxic']
)
tfidf = TfidfVectorizer(max_features=30000, ngram_range=(1, 2))
X_train_tfidf = tfidf.fit_transform(X_train)
X_test_tfidf = tfidf.transform(X_test)
model = LogisticRegression(class_weight='balanced', max_iter=1000)
model.fit(X_train_tfidf, y_train)
y_pred = model.predict(X_test_tfidf)
y_proba = model.predict_proba(X_test_tfidf)[:, 1]
print(classification_report(y_test, y_pred))
# precision recall f1-score
# toxic 0.82 0.65 0.73
print(f"ROC-AUC: {roc_auc_score(y_test, y_proba):.3f}")
# ROC-AUC: 0.891Коммитишь ноутбуки и создаёшь Feasibility Report.
git add experiments/moderation/
git commit -m "feat(moderation): EDA + baselines (regex F1=0.43, tfidf+lr F1=0.73)"
git push origin research/moderation-discoveryFeasibility Report (вывод)
📦 RecSys Discovery
📸 CV OCR Discovery
- Jupyter + pandas — EDA, визуализации, baseline эксперименты
- SQL к DWH — выгрузка и анализ данных
- scikit-learn — быстрые baseline модели (LogReg, RF)
- Confluence / Notion — Feasibility Report
- Git — версионирование ноутбуков и кода
📋 Блок 3: Design Doc
Design Doc — это документ на 3-5 страниц, который отвечает на три вопроса: ЧТО делаем, КАК и ПОЧЕМУ именно так. Если ты пропускаешь Design Doc и идёшь сразу кодить — поздравляю, ты переделаешь архитектуру на третьей неделе. Проверено.
Design Doc ревьюят ML Lead, Tech Lead и PM. Каждый проверяет своё: ML Lead — подход и метрики, Tech Lead — инфраструктуру и SLA, PM — бизнес-метрики и timeline. Без трёх ✅ LGTM — дальше не идёшь.
- Problem Statement — бизнес-проблема в одном абзаце
- Success Metrics — офлайн (F1, AUC) + онлайн (жалобы, конверсия) + guardrail (latency)
- Proposed Approach — модель, фичи, данные, архитектура
- Alternatives Considered — почему не правила, почему не GPT-4 API
- Risks & Mitigations — data drift, bias, latency spikes
- Timeline — оценка в спринтах
- Dependencies — GPU, Feature Store, доступы
🎬 День из жизни: Design Doc для NLP модерации
# ML Design Doc: Auto-Moderation System
**Author:** @your-name
**Reviewers:** @ml-lead, @tech-lead, @pm
**Status:** Draft Approved
## 1. Problem Statement
+40% .
(3 , 2000 /) .
human-in-the-loop edge cases.
## 2. Success Metrics
### Offline
| Metric | Target | Rationale |
|--------|--------|-----------|
| F1 (toxic) | 0.85 | Baseline TF-IDF+LR = 0.73, |
| Precision | 0.90 | FP = UX |
| Latency p95 | < | |
### Online (A/B)
| Metric | Target |
|--------|--------|
| | -50% |
| False Positive Rate | < 5% |
| | -70% |
### Guardrail
- > 15% ( engagement)
- Latency p99 < ## 3. Proposed Approach
### Model
- **Primary:** ruBERT-tiny2 ( params) fine-tuned
- **Fallback:** TF-IDF + LogReg ( BERT )
### Serving Architecture
User Comment API Gateway Moderation Service (FastAPI)
ruBERT-tiny2 (ONNX Runtime)
score > 0.85 auto-block
0.5 < score < 0.85 moderator queue
score < 0.5 auto-approve
## 4. Alternatives Considered
| Approach | Decision | Why |
|----------|----------|-----|
| Regex + - | | Recall 0.31 |
| GPT-4 API | | 0.03/req /day = 1500/day |
| Perspective API | | , |
| ruBERT-tiny2 ft | | quality/speed/cost |
## 5. Risks
| Risk | Mitigation |
|------|-----------|
| Data drift ( ) | Monthly retrain + PSI |
| Adversarial (l33t speak) | Text normalization pipeline |
| Bias () | , fairness |
## 6. Timeline
- Sprint 1: Data pipeline + training (2 )
- Sprint 2: Evaluation + serving (2 )
- Sprint 3: A/B + monitoring + rollout (2 )Отправляешь на ревью. ML Lead просит обосновать threshold 0.85, Tech Lead предлагает Triton вместо чистого FastAPI, PM согласовывает бизнес-метрики. Вносишь правки → три ✅ → Status: Approved.
📦 RecSys Design Doc (ключевое)
📸 CV OCR Design Doc (ключевое)
- Google Docs / Notion / Confluence — сам документ
- Шаблон ML Design Doc — стандартизация по компании
- Miro — визуализация архитектуры
📊 Блок 4: Планирование
Тут Design Doc превращается в задачи. Scrum или Kanban — зависит от фазы: research лучше ложится на Kanban (поток, без жёстких дедлайнов), production — на Scrum (спринты, story points, чёткие цели). На практике большинство ML-команд используют гибрид.
Главная ловушка ML-планирования: ты не знаешь, сколько займёт "обучить модель". Может 2 дня, может 2 недели. Решение — timeboxing: "3 дня на эксперименты с CatBoost, если не работает — пробуем другой подход". Не уходи в rabbit hole.
- Scrum: 2-недельные спринты, Planning Poker, velocity tracking
- Kanban: непрерывный поток, WIP limits, подходит для research
- Timeboxing: исследование ≠ детерминированная задача, ставь таймеры
- Story Points: Fibonacci (1/2/3/5/8/13), Planning Poker для оценки
- Spike tasks: research-задачи с фиксированным временем (2 дня на исследование подхода X)
🎬 День из жизни: Sprint Planning
Sprint Planning, 2 часа. ML Lead открывает Miro, команда декомпозирует Design Doc на задачи. Каждая задача — карточка с чёткими Acceptance Criteria.
# Sprint 1 — Data & Baseline (2 недели)
ML-101:
title: "ETL pipeline для модерационных данных"
story_points: 3
description: |
DWH parquet S3,
Airflow DAG, scheduled daily
acceptance_criteria: "DAG работает, данные обновляются ежедневно"
assignee: "@you"
ML-102:
title: "Data quality checks"
story_points: 2
description: |
Great Expectations suite moderation.comments
Checks: schema, null rate < 0.1%, text length > 0
assignee: "@you"
ML-103:
title: "Baseline TF-IDF + LogReg"
story_points: 3
description: |
baseline, MLflow
acceptance_criteria: "classification_report в MLflow, модель в registry"
assignee: "@you"
ML-104:
title: "Setup MLflow tracking"
story_points: 2
description: |
MLflow tracking URI, experiment, artifact store (S3)
assignee: "@you"# Sprint 2 — Model Experiments (2 недели)
ML-201:
title: "Fine-tune ruBERT-tiny"
story_points: 5
description: "Fine-tune на наших данных, залогировать в MLflow"
ML-202:
title: "Feature engineering (user history)"
story_points: 3
description: "account_age, total_reports, comment_length → дополнительные фичи"
ML-203:
title: "Hyperparameter tuning + model selection"
story_points: 3
description: "Grid search lr, epochs, class weights. Выбор лучшей модели."
ML-204:
title: "Offline evaluation report"
story_points: 2
description: "classification_report + confusion matrix + error analysis"
# Sprint 3 — Production (2 недели)
ML-301:
title: "FastAPI serving endpoint"
story_points: 3
ML-302:
title: "A/B test infrastructure + canary deploy"
story_points: 3
ML-303:
title: "Monitoring dashboards (Grafana)"
story_points: 2Planning Poker: ты показываешь 3 за ETL pipeline, коллега — 5 (думает нужна инкрементальная загрузка). Обсуждаете: "для MVP хватит full refresh раз в день" → сходитесь на 3. Итого Sprint 1: 10 SP, velocity команды 12 SP/sprint — ок, с запасом.
Daily Standup (каждое утро, 15 минут). Формат: вчера / сегодня / блокеры.
# Типичный стендап ML-инженера
: ETL DAG (ML-101), staging
4 .
: Great Expectations (ML-102).
.
: baseline MLflow, F1=0.73.
: Sprint 2, fine-tune ruBERT.
: GPU- infra,
@devops INFRA-342📦 RecSys Sprint Plan
📸 CV OCR Sprint Plan
- Jira / Linear — борда задач, sprint management
- Planning Poker — оценка story points
- Miro — декомпозиция, визуализация
- Confluence — Sprint Goals, capacity planning
🔧 Блок 5: Разработка
Самый весёлый этап — ты наконец-то пишешь код. Но не как на Kaggle. Тут каждый эксперимент логируется в MLflow, данные версионируются через DVC, конфиги — через Hydra. Если ты обучил модель без логирования — ты её не обучил.
Правило номер один: не уходи в rabbit hole. Timeboxing — твой лучший друг. Если за 3 дня CatBoost не даёт прирост — пробуй другой подход, а не крутишь гиперпараметры до посинения.
- Experiment tracking: MLflow / W&B — каждый run с параметрами, метриками, артефактами
- Data versioning: DVC + S3 — чтобы знать, на каких данных обучена какая модель
- Config management: Hydra — один конфиг, разные эксперименты через override
- Feature branches:
feature/toxicity-model-v1→ PR → merge в main - Timeboxing: 3 дня на подход, не работает — pivot
🎬 День из жизни: Разработка NLP модерации
День 1: настраиваешь MLflow и Hydra конфиг.
# configs/moderation/train.yaml (Hydra)
model:
name: "cointegrated/rubert-tiny2"
max_length: 128
num_labels: 2
training:
lr: 2e-5
epochs: 5
batch_size: 32
warmup_ratio: 0.1
weight_decay: 0.01
class_weight: "balanced"
data:
train_path: "s3://ml-data/moderation/train.parquet"
val_path: "s3://ml-data/moderation/val.parquet"
test_path: "s3://ml-data/moderation/test.parquet"
mlflow:
tracking_uri: "http://mlflow.internal:5000"
experiment_name: "moderation-toxic-classifier"# src/data/prepare_moderation_data.py
import pandas as pd
from sklearn.model_selection import train_test_split
def prepare_data(input_path: str, output_dir: str, seed: int = 42):
df = pd.read_parquet(input_path)
# Чистка
df = df.dropna(subset=['text'])
df = df[df['text'].str.len() >= 3]
df = df.drop_duplicates(subset=['comment_id'])
# Препроцессинг
df['text_clean'] = (
df['text']
.str.lower()
.str.replace(r'http\S+', '[URL]', regex=True)
.str.replace(r'@\w+', '[USER]', regex=True)
.str.strip()
)
# Stratified split
train, temp = train_test_split(
df, test_size=0.3, stratify=df['is_toxic'], random_state=seed
)
val, test = train_test_split(
temp, test_size=0.5, stratify=temp['is_toxic'], random_state=seed
)
print(f"Train: {len(train)} (toxic: {train['is_toxic'].mean():.3f})")
print(f"Val: {len(val)} (toxic: {val['is_toxic'].mean():.3f})")
print(f"Test: {len(test)} (toxic: {test['is_toxic'].mean():.3f})")
train.to_parquet(f"{output_dir}/train.parquet")
val.to_parquet(f"{output_dir}/val.parquet")
test.to_parquet(f"{output_dir}/test.parquet")Версионируешь данные через DVC — чтобы через полгода точно знать, на каких данных обучена модель v1.
dvc init
dvc add data/moderation/
dvc push # → S3
git add data/moderation/.dvc data/moderation/.gitignore
git commit -m "feat(data): versioned moderation dataset v1 (183K samples)"Дни 2-3: эксперименты с BERT. Запускаешь на GPU-сервере.
# src/train.py — тренировочный скрипт
import hydra
import mlflow
import numpy as np
from transformers import (
AutoTokenizer,
AutoModelForSequenceClassification,
Trainer,
TrainingArguments,
)
from sklearn.metrics import f1_score, precision_score, recall_score
@hydra.main(config_path="../configs/moderation", config_name="train")
def train(cfg):
mlflow.set_tracking_uri(cfg.mlflow.tracking_uri)
mlflow.set_experiment(cfg.mlflow.experiment_name)
with mlflow.start_run(run_name=f"{cfg.model.name}_lr{cfg.training.lr}"):
mlflow.log_params({
"model": cfg.model.name,
"lr": cfg.training.lr,
"epochs": cfg.training.epochs,
"batch_size": cfg.training.batch_size,
})
tokenizer = AutoTokenizer.from_pretrained(cfg.model.name)
model = AutoModelForSequenceClassification.from_pretrained(
cfg.model.name, num_labels=cfg.model.num_labels
)
train_dataset = ModerationDataset(
cfg.data.train_path, tokenizer, cfg.model.max_length
)
val_dataset = ModerationDataset(
cfg.data.val_path, tokenizer, cfg.model.max_length
)
def compute_metrics(eval_pred):
logits, labels = eval_pred
preds = np.argmax(logits, axis=-1)
return {
"f1_toxic": f1_score(labels, preds, pos_label=1),
"precision_toxic": precision_score(labels, preds, pos_label=1),
"recall_toxic": recall_score(labels, preds, pos_label=1),
}
training_args = TrainingArguments(
output_dir="./outputs/moderation",
num_train_epochs=cfg.training.epochs,
per_device_train_batch_size=cfg.training.batch_size,
learning_rate=cfg.training.lr,
warmup_ratio=cfg.training.warmup_ratio,
eval_strategy="epoch",
save_strategy="epoch",
load_best_model_at_end=True,
metric_for_best_model="f1_toxic",
fp16=True,
report_to="mlflow",
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=train_dataset,
eval_dataset=val_dataset,
compute_metrics=compute_metrics,
)
trainer.train()
# Evaluate on test set
test_dataset = ModerationDataset(
cfg.data.test_path, tokenizer, cfg.model.max_length
)
test_results = trainer.evaluate(test_dataset)
mlflow.log_metrics({
f"test_{k}": v for k, v in test_results.items()
})
trainer.save_model("./outputs/moderation/best_model")
mlflow.pytorch.log_model(model, "model")
if __name__ == "__main__":
train()Запускаешь серию экспериментов, переопределяя параметры через Hydra CLI.
# Эксперимент 1: базовый
poetry run python src/train.py
# Эксперимент 2: другой learning rate
poetry run python src/train.py training.lr=3e-5 training.epochs=5
# Эксперимент 3: с class weights
poetry run python src/train.py training.lr=3e-5 training.class_weight=balanced
# Эксперимент 4: с user features
poetry run python src/train.py --config configs/moderation/train_with_features.yamlСмотришь сравнение в MLflow UI:
MLflow: moderation-toxic-classifier
Run Name lr epochs F1 Prec Recall
rubert_lr2e-5 2e-5 3 0.811 0.867 0.762
rubert_lr3e-5 3e-5 5 0.842 0.881 0.806
rubert_balanced 3e-5 5 0.862 0.852 0.872
rubert+features 3e-5 5 0.881 0.903 0.860 best!
День 4: error analysis — смотришь, где модель ошибается.
# error_analysis.py — анализ ошибок
import pandas as pd
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay
test_df = pd.read_parquet("data/moderation/test.parquet")
test_df['pred'] = predictions
test_df['pred_proba'] = probabilities
# False Negatives — пропущенные toxic
fn = test_df[(test_df['is_toxic'] == True) & (test_df['pred'] == False)]
print(f"False Negatives: {len(fn)}")
# Паттерн: модель пропускает завуалированную токсичность
# "ты такой умный, просто гений 🙄" — сарказм
# False Positives — нормальные заблокированы
fp = test_df[(test_df['is_toxic'] == False) & (test_df['pred'] == True)]
print(f"False Positives: {len(fp)}")
# Паттерн: цитирование токсичности
# "он написал мне 'ты идиот', это ненормально"
# Confusion matrix
cm = confusion_matrix(test_df['is_toxic'], test_df['pred'])
ConfusionMatrixDisplay(cm, display_labels=['ok', 'toxic']).plot()# Регистрируешь лучшую модель в MLflow Model Registry
mlflow models register --model-uri "runs:/<run_id>/model"
--name "moderation-toxic-classifier"
mlflow models transition --name "moderation-toxic-classifier"
--version 1 --stage Staging
git add src/ configs/ experiments/moderation/
git commit -m "feat(moderation): ruBERT+features F1=0.881, registered in MLflow"
git push origin feature/moderation-model-v1📦 RecSys: Разработка
📸 CV OCR: Разработка
- MLflow — experiment tracking, model registry, artifact store
- DVC — data versioning (данные в S3, метаданные в Git)
- Hydra — config management, CLI overrides
- W&B (Weights & Biases) — альтернатива MLflow с красивым UI
- PyTorch / HuggingFace Transformers — fine-tuning моделей
👀 Блок 6: Code Review + Testing
Ты написал модель, метрики огонь, всё работает на твоём ноутбуке. Теперь нужно, чтобы это работало у коллег. И через полгода. Code Review в ML — это не только "красиво ли написан код", но и "нет ли data leakage?", "воспроизводим ли результат?", "где тесты?".
ML-код особенный: баги тут часто не ломают программу, а тихо портят метрики. Data leakage — king of silent bugs. Поэтому тесты для ML — это не только unit tests на функции, но и data validation, model quality gates и smoke tests.
- Data leakage — фичи из будущего, утечка таргета → модель "знает" ответ
- Reproducibility — random seed, pinned dependencies, version всего
- Пирамида тестов ML: unit → integration → data validation → model quality → smoke
- Git flow: GitHub Flow (main + feature branches) для команд 3-10 человек
- Pre-commit hooks: black, ruff, mypy — автоматически при каждом коммите
🎬 День из жизни: PR и Code Review
Пишешь тесты: unit tests на preprocessing, integration tests на pipeline, model quality tests.
# tests/test_preprocessing.py
import pytest
from src.data.preprocessing import clean_text
class TestCleanText:
def test_url_replacement(self):
assert clean_text("check https://example.com pls") == "check [URL] pls"
def test_mention_replacement(self):
assert clean_text("@user123 привет") == "[USER] привет"
def test_empty_string(self):
assert clean_text("") == ""
def test_unicode_handling(self):
assert clean_text("тест 🔥 эмодзи") == "тест 🔥 эмодзи"
def test_lowercase(self):
assert clean_text("КАПС ЛОК") == "капс лок"# tests/test_model.py
import pytest
import time
from src.model.toxic_classifier import ToxicClassifier
class TestToxicClassifier:
@pytest.fixture
def model(self):
return ToxicClassifier.from_pretrained("outputs/moderation/best_model")
def test_predict_returns_valid_proba(self, model):
result = model.predict("тест")
assert 0 <= result['toxic_probability'] <= 1
def test_predict_batch(self, model):
texts = ["привет", "ты идиот", "хорошая статья"]
results = model.predict_batch(texts)
assert len(results) == 3
def test_obviously_toxic(self, model):
result = model.predict("ты полный идиот, убирайся")
assert result['toxic_probability'] > 0.7
def test_obviously_ok(self, model):
result = model.predict("спасибо за помощь, отличная статья")
assert result['toxic_probability'] < 0.3
def test_latency(self, model):
start = time.time()
for _ in range(100):
model.predict("тестовый комментарий для проверки")
avg_ms = (time.time() - start) / 100 * 1000
assert avg_ms < 50, f"Avg latency {avg_ms:.1f}ms > 50ms SLA"# Запуск тестов
poetry run pytest tests/ -v --cov=src --cov-report=html
# ==================== 24 passed in 8.3s ====================
# Coverage: 87%Настраиваешь pre-commit hooks и создаёшь PR.
# .pre-commit-config.yaml
repos:
- repo: https://github.com/psf/black
rev: 23.12.0
hooks:
- id: black
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.9
hooks:
- id: ruff
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.8.0
hooks:
- id: mypy
additional_dependencies: [types-requests, pandas-stubs]## PR: Toxic Comment Classifier v1
### Summary
ruBERT-tiny2 + user features .
### Metrics
| Metric | Target | Achieved |
|--------|--------|----------|
| F1 (toxic) | 0.85 | **0.881** |
| Precision | 0.90 | **0.903** |
| Latency p95 | < | **** |
### MLflow
Experiment: moderation-toxic-classifier
Best run: rubert_features_lr3e-5
### Checklist
- [x] Unit tests passing (24 tests, 87% coverage)
- [x] Metrics above threshold
- [x] No data leakage (checked temporal split)
- [x] Model registered in MLflow
- [x] Design Doc approvedML Lead комментирует PR:
Review комментарии
# Добавляешь по результатам ревью: оптимизация threshold
from sklearn.metrics import precision_recall_curve
import numpy as np
def find_optimal_threshold(
y_true, y_proba, min_precision: float = 0.90
) -> float:
"""Find threshold maximizing F1 while keeping precision >= min_precision."""
precisions, recalls, thresholds = precision_recall_curve(y_true, y_proba)
f1_scores = 2 * (precisions * recalls) / (precisions + recalls + 1e-8)
valid = precisions[:-1] >= min_precision
if not valid.any():
return 0.5
best_idx = np.argmax(f1_scores[:-1] * valid)
return float(thresholds[best_idx])
# Результат: optimal_threshold = 0.62 → F1 вырос до 0.889CI проходит (GitHub Actions: lint + tests + model quality gate), ML Lead ставит ✅ → Squash & Merge.
# .github/workflows/ml-ci.yml
name: ML CI
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install poetry && poetry install
- run: poetry run pytest tests/ -v --tb=short
- run: poetry run ruff check src/
- run: poetry run mypy src/ --ignore-missing-imports
model-quality:
runs-on: [self-hosted, gpu]
steps:
- uses: actions/checkout@v4
- run: poetry install
- run: |
poetry run python src/evaluate.py
--model outputs/moderation/best_model
--test-data data/moderation/test.parquet
--min-f1 0.85 --min-precision 0.90📦 RecSys Code Review
test_ranking_order() — top-1 item should have highest score. CI: offline eval на holdout → Recall@20 > 0.12 gate.📸 CV OCR Code Review
test_cer_per_field() — CER < 3% per field type. CI: batch eval на 1000 документов.- GitHub / GitLab — PR review, branch protection rules
- GitHub Actions — CI: lint, tests, model quality gates
- pytest — unit + integration tests
- Great Expectations / Pandera — data validation tests
- pre-commit — автоформатирование и линтинг
🧪 Блок 7: A/B тест
Офлайн метрики — это хорошо, но единственный способ проверить влияние на бизнес — запустить A/B тест на живых пользователях. Offline F1=0.88 не гарантирует, что жалобы упадут. Может, пользователям нравится токсичность (шутка… почти).
Классическая ошибка новичка: "О, на второй день уже значимый результат, выкатываем!" Нет. Жди минимум неделю, а лучше две. Novelty effect, дневные/недельные циклы, Simpson's paradox — всё это может тебя обмануть.
- Сплитование: hash(user_id + experiment) % 100 — детерминированное, один пользователь = одна группа
- Стратегии: Shadow mode → Canary (1-5%) → Partial (10-50%) → Full rollout
- Метрики A/B: Primary (бизнес), Secondary (модельные), Guardrail (не должна деградировать)
- Significance: p-value < 0.05, power analysis для минимального размера выборки
- MDE: Minimum Detectable Effect — какой эффект хочешь увидеть (10%? 20%? 50%?)
🎬 День из жизни: A/B тест NLP модерации
День 1: настраиваешь сплитование и логирование.
# src/serving/experiment.py — детерминированное сплитование
import hashlib
def get_experiment_group(
user_id: str, experiment_name: str = "moderation_v1"
) -> str:
"""Один и тот же user всегда в одной группе."""
hash_input = f"{user_id}:{experiment_name}"
hash_value = int(hashlib.md5(hash_input.encode()).hexdigest(), 16)
bucket = hash_value % 100
if bucket < 5: # 0-4: 5% treatment
return "treatment" # ML модель
else: # 5-99: 95% control
return "control" # ручная модерация (status quo)# configs/experiments/moderation_v1.yaml
experiment:
name: "moderation_v1"
status: "running"
start_date: "2024-09-15"
planned_end_date: "2024-09-29" # 2 недели
traffic_split:
treatment: 5 # %
control: 95
metrics:
primary: "complaint_rate"
secondary: ["latency_p95", "auto_block_rate"]
guardrail: ["false_positive_rate", "engagement_rate"]
thresholds:
min_sample_size: 10000
significance_level: 0.05
min_effect_size: 0.15# src/serving/app.py — A/B модерация
from fastapi import FastAPI
from prometheus_client import Counter, Histogram
app = FastAPI()
moderation_requests = Counter(
'moderation_requests_total',
'Total moderation requests',
['group', 'decision']
)
moderation_latency = Histogram(
'moderation_latency_seconds',
'Moderation latency',
['group']
)
@app.post("/moderate")
async def moderate_comment(request: ModerationRequest):
group = get_experiment_group(request.user_id)
with moderation_latency.labels(group=group).time():
if group == "treatment":
result = ml_model.predict(request.text)
if result['toxic_probability'] > 0.62:
decision = "block"
elif result['toxic_probability'] > 0.3:
decision = "review"
else:
decision = "approve"
else:
decision = "review" # всё идёт к модераторам
moderation_requests.labels(group=group, decision=decision).inc()
# Логируем в ClickHouse для офлайн-анализа
log_to_clickhouse({
'comment_id': request.comment_id,
'user_id': request.user_id,
'group': group,
'decision': decision,
'model_score': result.get('toxic_probability'),
})
return {"decision": decision, "group": group}Дни 3-14: мониторишь эксперимент. Каждый день — 5 минут на дашборд.
# scripts/ab_analysis.py — ежедневный анализ A/B
import pandas as pd
from statsmodels.stats.proportion import proportions_ztest
# Данные из ClickHouse
# control: 142000 комментариев, 1847 жалоб (1.30%)
# treatment: 7500 комментариев, 63 жалобы (0.84%)
stat, pvalue = proportions_ztest(
[63, 1847], # complaints
[7500, 142000], # total
alternative='smaller'
)
print(f"p-value: {pvalue:.4f}") # Day 7: p=0.021, Day 14: p=0.003
effect = 1 - (63 / 7500) / (1847 / 142000)
print(f"Effect: {effect:.1%}") # -35% complaintsДень 14: принятие решения. Показываешь результаты PM и ML Lead.
# A/B Results: moderation_v1 (2 weeks)
| Metric | Control | Treatment | | p-value |
|-----------------|---------|-----------|-------|---------|
| Complaint rate | 1.30% | 0.84% | -35% | 0.003 |
| FP rate | N/A | 3.2% | <5% | |
| Latency p95 | | | + | < |
| Block rate | 0% | 11.4% | <15% | |
Decision: ROLLOUT to 100% 📦 RecSys A/B
📸 CV OCR A/B
- scipy.stats / statsmodels — Z-test, t-test, p-value
- Prometheus + Grafana — real-time мониторинг эксперимента
- ClickHouse — хранение логов эксперимента для офлайн-анализа
- Split.io / LaunchDarkly — feature flags, traffic splitting
- Custom A/B platform — у крупных компаний своя инфраструктура
🚀 Блок 8: Деплой
Модель обучена, A/B выиграл, теперь нужно выкатить на 100% трафика и не сломать прод. Деплой ML-модели — это Docker + K8s + CI/CD + ONNX оптимизация + canary rollout. Если ты просто делаешь scp model.pkl server:/app/ — мы не друзья.
Три паттерна serving: online (FastAPI, latency < 100ms), batch (Airflow, раз в час/день) и streaming (Kafka + Flink). Выбор зависит от SLA: модерация — online, рекомендации — batch precompute + online reranking, OCR — batch.
- Online serving: FastAPI / Triton / TF Serving, latency < 100ms
- Batch serving: Airflow / Spark, обновление раз в час/день
- Streaming: Kafka + Flink, near real-time
- ONNX export: 3× ускорение inference (PyTorch → ONNX Runtime)
- Canary rollout: 1% → 10% → 50% → 100%, мгновенный rollback при ошибках
🎬 День из жизни: Деплой NLP модерации
День 1: ONNX export и Docker.
# scripts/export_onnx.py — экспорт в ONNX для быстрого inference
import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification
model = AutoModelForSequenceClassification.from_pretrained(
"outputs/moderation/best_model"
)
tokenizer = AutoTokenizer.from_pretrained(
"outputs/moderation/best_model"
)
dummy_input = tokenizer(
"тестовый комментарий",
return_tensors="pt",
max_length=128,
padding="max_length",
truncation=True,
)
torch.onnx.export(
model,
(dummy_input['input_ids'], dummy_input['attention_mask']),
"outputs/moderation/model.onnx",
input_names=['input_ids', 'attention_mask'],
output_names=['logits'],
dynamic_axes={
'input_ids': {0: 'batch', 1: 'seq'},
'attention_mask': {0: 'batch', 1: 'seq'},
'logits': {0: 'batch'},
},
opset_version=14,
)
# Сравнение latency:
# PyTorch: avg 38ms
# ONNX Runtime: avg 12ms → 3x faster! ✅# Dockerfile
FROM python:3.11-slim AS base
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends
libgomp1 && rm -rf /var/lib/apt/lists/*
COPY pyproject.toml poetry.lock ./
RUN pip install poetry &&
poetry config virtualenvs.create false &&
poetry install --only main --no-interaction
COPY src/ src/
COPY configs/ configs/
COPY outputs/moderation/model.onnx /models/moderation/model.onnx
COPY outputs/moderation/tokenizer/ /models/moderation/tokenizer/
HEALTHCHECK --interval= --timeout=
CMD curl -f http://localhost:8000/health || exit 1
EXPOSE 8000
CMD ["uvicorn", "src.serving.app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]# src/serving/app.py — production FastAPI
from fastapi import FastAPI
from pydantic import BaseModel
import onnxruntime as ort
from transformers import AutoTokenizer
import numpy as np
from prometheus_client import make_asgi_app, Histogram, Counter
import time
app = FastAPI(title="Moderation Service", version="1.0.0")
metrics_app = make_asgi_app()
app.mount("/metrics", metrics_app)
LATENCY = Histogram('moderation_predict_seconds', 'Prediction latency')
PREDICTIONS = Counter(
'moderation_predictions_total', 'Total predictions', ['decision']
)
tokenizer = AutoTokenizer.from_pretrained("/models/moderation/tokenizer")
ort_session = ort.InferenceSession(
"/models/moderation/model.onnx",
providers=['CPUExecutionProvider'],
)
THRESHOLD = 0.62
class ModerationRequest(BaseModel):
text: str
comment_id: str | None = None
user_id: str | None = None
class ModerationResponse(BaseModel):
toxic_probability: float
decision: str
latency_ms: float
@app.post("/predict", response_model=ModerationResponse)
async def predict(request: ModerationRequest):
start = time.time()
inputs = tokenizer(
request.text, return_tensors="np",
max_length=128, padding="max_length", truncation=True,
)
logits = ort_session.run(None, {
'input_ids': inputs['input_ids'],
'attention_mask': inputs['attention_mask'],
})[0]
probs = np.exp(logits) / np.exp(logits).sum(axis=-1, keepdims=True)
toxic_prob = float(probs[0][1])
if toxic_prob > THRESHOLD:
decision = "block"
elif toxic_prob > 0.3:
decision = "review"
else:
decision = "approve"
latency_ms = (time.time() - start) * 1000
LATENCY.observe(latency_ms / 1000)
PREDICTIONS.labels(decision=decision).inc()
return ModerationResponse(
toxic_probability=toxic_prob,
decision=decision,
latency_ms=round(latency_ms, 1),
)
@app.get("/health")
async def health():
return {"status": "ok", "model": "loaded"}# Build и тест
docker build -t moderation-service:v1.0.0 .
docker run -p 8000:8000 moderation-service:v1.0.0
# Smoke tests
curl -X POST http://localhost:8000/predict
-H "Content-Type: application/json"
-d '{"text": "отличная статья, спасибо!"}'
# {"toxic_probability": 0.023, "decision": "approve", "latency_ms": 11.3}
curl -X POST http://localhost:8000/predict
-d '{"text": "ты полный дебил, убирайся отсюда"}'
# {"toxic_probability": 0.947, "decision": "block", "latency_ms": 12.1}
# Push to registry
docker tag moderation-service:v1.0.0 registry.company.com/ml/moderation-service:v1.0.0
docker push registry.company.com/ml/moderation-service:v1.0.0День 2: Kubernetes deployment и canary rollout.
# k8s/moderation-service.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: moderation-service
namespace: ml
spec:
replicas: 3
selector:
matchLabels:
app: moderation-service
template:
metadata:
labels:
app: moderation-service
version: v1.0.0
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8000"
prometheus.io/path: "/metrics"
spec:
containers:
- name: moderation
image: registry.company.com/ml/moderation-service:v1.0.0
ports:
- containerPort: 8000
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1000m"
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 30# Canary rollout
# Шаг 1: Deploy canary (1 pod, 10% трафика)
kubectl apply -f k8s/moderation-service-canary.yaml
# Шаг 2: Мониторинг 30 минут
# Grafana: latency p95 = 14ms, error rate = 0% ✅
# Шаг 3: Увеличиваем до 50%
kubectl patch virtualservice moderation -p
{"spec":{"http":[{"route":[
{"destination":{"host":"moderation-canary"},"weight":50},
{"destination":{"host":"moderation-stable"},"weight":50}
]}]}}
# Шаг 4: Мониторинг 1 час → всё ОК → 100%
kubectl apply -f k8s/moderation-service.yaml
kubectl delete -f k8s/moderation-service-canary.yaml# .github/workflows/ml-deploy.yml — CI/CD для следующих релизов
name: ML Deploy
on:
push:
branches: [main]
paths: ['src/serving/**', 'configs/**', 'Dockerfile']
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests
run: poetry run pytest tests/ -v
- name: Model quality gate
run: poetry run python src/evaluate.py --min-f1 0.85
- name: Build & Push Docker
run: |
docker build -t registry.company.com/ml/moderation-service:{{ github.sha }} .
docker push registry.company.com/ml/moderation-service:{{ github.sha }}
- name: Deploy to staging
run: |
kubectl set image deployment/moderation-service
moderation=registry.company.com/ml/moderation-service:{{ github.sha }}
--namespace ml-staging
- name: Smoke test staging
run: |
sleep 30
curl -f http://moderation.staging.internal/health📦 RecSys Деплой
📸 CV OCR Деплой
- Docker — упаковка сервиса + модели в образ
- Kubernetes — оркестрация, авто-скейлинг, self-healing
- ONNX Runtime — ускорение inference (3× vs PyTorch)
- ArgoCD — GitOps деплой, rollback одной кнопкой
- Triton Inference Server — высокопроизводительный serving (GPU batching)
- GitHub Actions — CI/CD pipeline: тесты → build → deploy
📈 Блок 9: Мониторинг
Задеплоил модель и пошёл пить кофе? Не-а. ML-модели деградируют. Данные дрифтуют, пользователи меняют поведение, backend-команда меняет формат данных и не предупреждает. Без мониторинга ты узнаешь о проблеме из Slack #support через неделю.
Четыре уровня мониторинга: инфраструктура (CPU, latency, errors), данные (распределения, NULL rate, schema), модель (prediction distribution, confidence, drift), бизнес (CTR, жалобы, revenue). Все четыре — обязательны.
- Infrastructure: CPU, GPU, memory, latency p95/p99, error rate
- Data: распределение фичей, NULL rate, schema changes, volume
- Model: prediction distribution, confidence, staleness, PSI
- Business: CTR, конверсия, revenue, user satisfaction, жалобы
- PSI (Population Stability Index): < 0.1 OK, 0.1-0.25 мониторим, > 0.25 алерт
- KS-test: для числовых фичей, p-value < 0.05 → drift detected
🎬 День из жизни: Мониторинг NLP модерации
Сразу после деплоя настраиваешь Grafana дашборд и drift detection.
# Grafana Dashboard: "Moderation ML Monitoring"
Row 1: Infrastructure
Panel 1: Request rate rate(moderation_predictions_total[])
Panel 2: Latency p50/p95/p99
Panel 3: Error rate ()
Panel 4: Pod memory/CPU
Row 2: Model
Panel 5: Prediction distribution (histogram of toxic_probability)
Panel 6: Decision breakdown (pie: approve/block/review)
Panel 7: Avg confidence per decision bucket
Panel 8: Predictions per minute
Row 3: Business
Panel 9: Complaint rate (rolling )
Panel 10: False positive rate (appeals / blocked)
Panel 11: Moderator queue size
Panel 12: Block rate (guardrail: < 15%)# src/monitoring/drift_detector.py — запускается ежедневно через Airflow
from evidently.report import Report
from evidently.metric_preset import DataDriftPreset, DataQualityPreset
from evidently.metrics import ColumnDriftMetric
import pandas as pd
def check_drift(reference_path: str, current_path: str):
"""Сравнивает reference (training data) vs current (last 24h)."""
reference = pd.read_parquet(reference_path)
current = pd.read_parquet(current_path)
report = Report(metrics=[
DataDriftPreset(),
DataQualityPreset(),
ColumnDriftMetric(column_name="text_length"),
ColumnDriftMetric(column_name="toxic_probability"),
])
report.run(reference_data=reference, current_data=current)
result = report.as_dict()
text_psi = result['metrics'][2]['result']['drift_score']
pred_psi = result['metrics'][3]['result']['drift_score']
if text_psi > 0.2:
send_slack_alert(
"#ml-alerts",
f"⚠️ Data drift! Text length PSI = {text_psi:.3f} (threshold: 0.2)"
)
if pred_psi > 0.25:
send_slack_alert(
"#ml-alerts",
f"🚨 Model drift! Prediction PSI = {pred_psi:.3f}. "
f"Model may need retraining."
)
report.save_html(f"reports/drift_{datetime.today().isoformat()}.html")
return {"text_psi": text_psi, "pred_psi": pred_psi}# dags/moderation_monitoring.py — Airflow DAG
from airflow import DAG
from airflow.operators.python import PythonOperator
from datetime import datetime, timedelta
dag = DAG(
'moderation_monitoring',
schedule_interval='0 8 * * *', # каждый день в 8:00
catchup=False,
)
extract_predictions = PythonOperator(
task_id='extract_predictions',
python_callable=extract_yesterdays_predictions,
dag=dag,
)
check_data_drift = PythonOperator(
task_id='check_drift',
python_callable=check_drift,
op_kwargs={
'reference_path': 's3://ml-data/moderation/reference.parquet',
'current_path': '/tmp/yesterday_predictions.parquet',
},
dag=dag,
)
check_model_quality = PythonOperator(
task_id='check_quality',
python_callable=check_quality_on_fresh_labels,
dag=dag,
)
extract_predictions >> check_data_drift >> check_model_quality# Grafana Alert Rules
alerts:
- name: ModerationLatencyHigh
expr: histogram_quantile(0.95, rate(moderation_predict_seconds_bucket[])) > 0.1
for:
severity: warning
summary: "Moderation latency p95 > 100ms"
- name: ModerationErrorRate
expr: rate(http_responses_total{status=~"5..", service="moderation"}[]) > 0.01
for:
severity: critical
summary: "Moderation error rate > 1%"
- name: ModerationBlockRateHigh
expr: >
sum(rate(moderation_predictions_total{decision="block"}[])) /
sum(rate(moderation_predictions_total[])) > 0.15
for:
severity: warning
summary: "Block rate > 15% — guardrail violated!"Типичное утро через 2 месяца: открываешь дашборд за 10 минут.
09:00 Grafana "Moderation ML Monitoring"
Latency p95:
Error rate: 0.001%
Block rate: 11.3% (< 15% guardrail)
Complaint rate: 0.8% (was 1.3% before ML)
Text length PSI: 0.18 (close to 0.2 threshold)
: Stories ( )
Expected drift, model degradation
Slack: PSI - Stories.
training set retrain.🚨 Incident пример
📦 RecSys мониторинг
📸 CV OCR мониторинг
- Prometheus + Grafana — метрики инфраструктуры и модели
- Evidently AI — drift detection, data quality reports
- Whylabs — автоматический мониторинг ML-моделей (SaaS)
- Airflow — scheduled drift checks и quality monitoring
- PagerDuty / Slack alerts — уведомления при инцидентах
🔄 Блок 10: Итерация
ML-проект не заканчивается деплоем. Он заканчивается… никогда. Модель деградирует, бизнес-требования меняются, появляются новые данные. Вся прелесть ML в том, что ты можешь бесконечно улучшать метрики. Вся проблема — в том же самом.
Sprint Retrospective — твой лучший друг. Что пошло хорошо? Что плохо? Что улучшить? Из ретро рождаются action items: автоматизация retrain, процесс "backend notify ML", dedicated GPU pool.
- Sprint Retrospective: 😊 Went Well / 😐 Could Improve / 💡 Action Items
- Retraining triggers: data drift, новые данные, бизнес-требования, деградация метрик
- Champion-Challenger: новая модель автоматически сравнивается со старой (shadow A/B)
- Зрелость ML-команды: Manual → ML pipeline → CI/CD for ML → Continuous Training
🎬 День из жизни: Retrospective и Retrain
Sprint Retrospective (1 час, конец Sprint 3). Miro-доска с 3 колонками.
Sprint Retrospective: Moderation v1
Went Well:
- A/B -35%
- ONNX export inference
- MLflow tracking
- Design Doc
Could Improve:
- GPU training 2 ( infra )
- Threshold optimization code review,
- Data drift Stories Design Doc
- ,
Action Items:
- [ML Lead] Dedicated GPU pool ML-
- [You] retrain pipeline (Airflow DAG)
- [You] Stories training data v1.1
- [PM] : backend changes notify ML team Через месяц: автоматический retrain pipeline.
# dags/moderation_retrain.py — автоматический retrain
from airflow import DAG
from airflow.operators.python import PythonOperator, BranchPythonOperator
dag = DAG(
'moderation_retrain',
schedule_interval='0 2 * * 0', # каждое воскресенье в 2:00
catchup=False,
)
def should_retrain(**context):
"""Проверяет нужен ли retrain."""
drift_score = get_latest_drift_score()
new_labels_count = count_new_labels_since_last_train()
if drift_score > 0.2 or new_labels_count > 5000:
return 'retrain_model'
return 'skip_retrain'
def retrain_model(**context):
"""Перетренировка на свежих данных."""
import subprocess
# 1. Выгружаем свежие данные
subprocess.run([
"python", "src/data/prepare_moderation_data.py",
"--output", "/tmp/moderation_fresh/",
"--since", (datetime.now() - timedelta(days=90)).isoformat(),
], check=True)
# 2. Тренируем
result = subprocess.run([
"python", "src/train.py",
"--config", "configs/moderation/retrain.yaml",
], capture_output=True, text=True, check=True)
# 3. Quality gate
new_f1 = extract_f1_from_output(result.stdout)
current_f1 = get_production_model_f1()
if new_f1 < current_f1 - 0.02:
raise ValueError(
f"New model F1 ({new_f1}) worse than current ({current_f1})"
)
if new_f1 < 0.85:
raise ValueError(f"New model F1 ({new_f1}) below threshold 0.85")
register_model_as_challenger(new_f1)
return {"new_f1": new_f1, "current_f1": current_f1}
def deploy_challenger(**context):
"""Shadow deploy: challenger делает предсказания без трафика."""
ti = context['ti']
result = ti.xcom_pull(task_ids='retrain_model')
deploy_shadow_model(
model_name="moderation-toxic-classifier",
version="challenger",
)
send_slack_notification(
"#ml-team",
f"🔄 Model retrained! F1: {result['new_f1']:.3f} "
f"(was {result['current_f1']:.3f}). "
f"Shadow deployed, promoting in 48h if metrics ok."
)
check = BranchPythonOperator(
task_id='check_retrain_need', python_callable=should_retrain, dag=dag,
)
retrain = PythonOperator(
task_id='retrain_model', python_callable=retrain_model, dag=dag,
)
deploy = PythonOperator(
task_id='deploy_challenger', python_callable=deploy_challenger, dag=dag,
)
skip = PythonOperator(
task_id='skip_retrain', python_callable=lambda: None, dag=dag,
)
check >> [retrain, skip]
retrain >> deployЧерез 3 месяца: v2 модель с multi-label классификацией.
# Moderation v2: Design Doc
## What Changed Since v1
- ( ), Stories
-
- PM multi-label (insult, threat, spam )
## Proposed Changes
1. Multi-label classification 5
2. ruBERT-base tiny (latency : < )
3. Hard negative mining /
4. Threshold per category
## Expected Improvement
- F1: 0.88 0.92
- Recall on sarcasm: 0.45 0.75
## Timeline: 2 спринтаЦикл повторяется
📦 RecSys: Итерации
📸 CV OCR: Итерации
🎯 Уровни зрелости ML-команды
Не все компании проходят все 10 этапов одинаково. Зрелость ML-процессов — это спектр. Большинство начинают с Level 0 (Jupyter → прод вручную) и постепенно растут.
- Level 0 — Manual process: Jupyter → scp model.pkl → prod. Ручной деплой, нет мониторинга, молитвы вместо тестов
- Level 1 — ML pipeline: автоматический training pipeline, но ручной deploy. MLflow для tracking, DVC для данных
- Level 2 — CI/CD for ML: автоматический retrain + deploy + monitoring. Quality gates в CI, canary rollout
- Level 3 — Continuous Training: авто-retrain при drift, shadow A/B, champion-challenger. Модель улучшается без участия человека
Где ты сейчас?
🏁 Итого: 3 сквозных примера
🔤 NLP модерация контента
📦 RecSys — персонализация каталога
📸 CV — OCR документов
Что дальше
Этот гайд — карта территории. Теперь ты знаешь, как выглядит полный цикл ML-проекта в компании. Но карта — не территория. Чтобы реально научиться — нужно пройти этот путь самому. Начни с малого: возьми свой pet-project и проведи его через все 10 этапов.
- Углубись в конкретные этапы: A/B тестирование, мониторинг, CI/CD — каждая тема заслуживает отдельного погружения
- Изучи MLOps роадмап — там детальные материалы по каждому инструменту
- Попрактикуйся на реальном проекте — обучи модель, задеплой через Docker, настрой мониторинг
- Подготовься к собеседованиям — System Design вопросы про ML pipeline — один из самых частых топиков
Полезные ресурсы
MLOps: Machine Learning Operations
Обзор MLOps практик и инструментов
Full Stack Deep Learning (2022)
Полный курс от модели до продакшена
MLOps Zoomcamp — DataTalksClub
Бесплатный курс по MLOps с практическими заданиями
MLflow Tracking — Experiment Tracking
Официальная документация MLflow по experiment tracking
ML Monitoring and Observability — Evidently AI
Полный гайд по мониторингу ML-моделей в продакшене
Stanford CS 329S: ML Systems Design
Лекции Chip Huyen про дизайн ML-систем
Made With ML — MLOps Course
Практический курс: от разработки до деплоя ML-систем
MLOps Course — Made With ML
Практический курс: от разработки до деплоя ML-моделей
Google PAIR: ML Fairness & A/B Testing
Интерактивные объяснения A/B тестирования и fairness в ML
Rules of ML — Google
43 правила ML-инженерии от Google — обязательное чтение
Designing ML Systems — Chip Huyen
Книга про проектирование ML-систем (O'Reilly)
How Netflix Uses ML — InfoQ
ML pipeline в Netflix: рекомендации, A/B тесты, мониторинг
Хочешь проработать каждый этап? 🚀
Изучай ML System Design с персональной программой. Реальные кейсы, интерактивные материалы.
Начать обучение