MLOps + Процессы
~45 мин

🏢 ML в продакшене: от бизнес-задачи до мониторинга

Полный цикл ML-проекта в компании. 10 этапов, конкретные инструменты, 3 сквозных примера.

mlopsprocessesproductionscruma/b testing

Зачем этот гайд

Ты умеешь обучать модели. Знаешь, что такое F1-score и ROC-AUC. Может, даже побеждал на Kaggle. Но в компании тебя ждёт неприятный сюрприз: 80% работы ML-инженера — это НЕ обучение моделей. Это процессы, инфраструктура, командная работа, A/B тесты и бесконечный мониторинг.

Этот гайд — про то, как ML-проект живёт в реальной компании. 10 этапов от "PM пришёл с идеей" до "модель крутится в проде и ты спишь спокойно". С конкретным кодом, инструментами и тремя сквозными примерами.

3 сквозных примера

🔤 NLP модерация — автоматическая фильтрация токсичных комментариев (основной, подробный). 📦 RecSys — персонализация каталога товаров. 📸 CV OCR — распознавание документов. NLP-пример разбираем до мельчайших деталей. RecSys и CV — кратко, чтобы показать разницу между доменами.
  • 💼 Бизнес-задача → 🔍 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-у

• Какой объём комментариев в день? → ~50K • Есть ли ручная разметка? → Модераторы ставят toxic/ok в PostgreSQL • Допустимый уровень ошибок? → Лучше пропустить toxic, чем заблокировать нормальный • SLA по latency? → Комментарий проверяется до публикации, < 500ms • Текущая стоимость модерации? → 3 модератора × $2000/мес = $6000/мес

После встречи создаёшь 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: Персонализация каталога

PM: "Конверсия из каталога упала с 3.2% до 2.1%. Пользователи не находят релевантные товары." Смотришь воронку в Amplitude: провал на шаге каталог→карточка. Текущая "персонализация" — ручные подборки мерчандайзера. Логи кликов в ClickHouse. Epic: [ML] Персонализация каталога, RICE: Reach=100%, Impact=High.

📸 CV OCR: Распознавание документов

Head of Operations: "20 операторов вбивают данные из сканов — это bottleneck." Типы: паспорта РФ (80%), загранпаспорта (15%), права (5%). Есть эталон: операторский ввод как ground truth. Epic: [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        623
import 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-discovery

Feasibility Report (вывод)

Данные: 183K размеченных комментариев, 8.3% toxic, Cohen's kappa = 0.78. Baselines: Regex Precision=0.71/Recall=0.31, TF-IDF+LR F1=0.73, AUC=0.891. Вывод: ML feasible. TF-IDF+LR уже лучше regex в 2× по recall. Трансформеры (ruBERT-tiny) предположительно дадут F1 > 0.85. Двигаемся дальше → Design Doc.

📦 RecSys Discovery

ClickHouse: 847M кликов, 2.1M пользователей, 98K товаров. Long tail: топ-1% товаров = 45% кликов. Baseline: Popular Items CTR=1.8%. ALS (implicit): Recall@10=0.12. Вывод: персонализация даёт прирост, neural ranking может дать ещё +15%.

📸 CV OCR Discovery

S3: 3.2M сканов, 2.1 TB. Распределение: 82% паспорта РФ, 13% загранпаспорта, 5% права. Baseline: Tesseract CER=12% — много ошибок на рукописных полях. Вывод: TrOCR fine-tuned предположительно CER < 3%.
  • 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 (ключевое)

Approach: Stage 1 — ALS для Candidate Generation (200 кандидатов/user, batch daily). Stage 2 — Two-Tower NN для Ranking (online, latency < 50ms). Feature Store: Redis (user features) + S3 batch (item features). Alternatives: Content-based only (cold start ok, но low personalization), Full neural online (latency too high) → Hybrid ✅.

📸 CV OCR Design Doc (ключевое)

Approach: Preprocessing (OpenCV: deskew + denoise) → TrOCR-base fine-tuned → Post-processing (regex validation по формату). Confidence < 0.8 → manual review queue. Dependencies: GPU для inference (batch), 20K labeled docs из операторского ввода.
  • 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: 2

Planning 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

Sprint 1: Spark ETL (ClickHouse → user_item_matrix, 5 SP) + offline eval framework (3 SP). Sprint 2: ALS candidates + Two-Tower ranking → offline eval. Sprint 3: Feature Store (Redis) + serving + A/B setup. Sprint 4: A/B rollout + monitoring.

📸 CV OCR Sprint Plan

Sprint 1: OpenCV preprocessing pipeline (3 SP) + ground truth alignment (5 SP) + Tesseract baseline. Sprint 2: Fine-tune TrOCR + per-document-type evaluation. Sprint 3: Airflow batch inference + monitoring.
  • 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: Разработка

ALS (implicit lib) для candidate generation → Recall@20=0.15. Two-Tower model (PyTorch): user tower + item tower → cosine similarity. Feature Store: user features в Redis (обновление ежедневно Airflow DAG), item features — batch S3. W&B для tracking.

📸 CV OCR: Разработка

OpenCV preprocessing (deskew, denoise, binarize). Fine-tune TrOCR-base на 15K crops паспортных полей. Результаты CER: Tesseract 12% → TrOCR base 5.2% → TrOCR finetuned 2.8%. Active learning: low confidence → manual review → retrain.
  • 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 approved

ML Lead комментирует PR:

Review комментарии

📝 preprocessing.py L42: "clean_text() не обрабатывает l33t speak ('1ди0т'). Для v1 ок, но заведи TODO." 📝 toxic_classifier.py L78: "Threshold 0.5 — лучше калибровать по PR-curve на val set. Добавь find_optimal_threshold()." 📝 train.yaml L15: "max_length: 128 — проверял распределение длин? Может потерять контекст." ✅ prepare_data.py: "Stratified split — хорошо."
# Добавляешь по результатам ревью: оптимизация 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.889

CI проходит (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

PR включает ALS candidate generation, Two-Tower ranking, feature extraction. Data Engineer ревьюит Spark ETL, Backend Lead — serving latency. Ключевой тест: test_ranking_order() — top-1 item should have highest score. CI: offline eval на holdout → Recall@20 > 0.12 gate.

📸 CV OCR Code Review

PR включает OpenCV preprocessing, TrOCR inference wrapper, regex validators per field. Фокус ревью: edge cases (повёрнутые, блики, рукописный текст). Ключевой тест: 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

10% treatment, primary: CTR. Используют interleaving (Team Draft) вместо классического A/B — честнее для ранжирования. Результат через 2 недели: CTR +28% (p=0.008), Revenue +12%, Session length без изменений → Rollout.

📸 CV OCR A/B

Shadow mode вместо классического A/B: оба пути работают параллельно (ML + оператор), сравниваем результаты. Пользователь видит только оператора. Через 2 недели: agreement 97.2%, ML CER 2.8% → переключаем.
  • 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 Деплой

Hybrid serving: Batch — Airflow DAG генерирует ALS-кандидатов каждый час → S3 → Redis. Online — FastAPI + ONNX для Two-Tower reranking, 5 replicas в K8s. Кандидаты (200/user) из Redis → reranking → top 20.

📸 CV OCR Деплой

Batch serving через Airflow: каждый час обрабатывает новые сканы из S3. Preprocessing → TrOCR inference (GPU pod) → post-processing → validation → DB. Confidence < 0.8 → manual review queue. Throughput: 150 docs/min на 1× A100.
  • 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 пример

11:00 — Slack #ml-alerts: "Model drift! Prediction PSI = 0.31". Block rate скакнул с 11% до 22%. Причина: backend добавил emoji reactions в text field. Модель видит "[emoji:heart] отличная статья" и путается. Фикс: 1) Включаешь fallback (regex-only) через env var. 2) Пишешь backend-команде. 3) Создаёшь тикет на retrain с новым форматом. Урок: Процесс "backend changes → notify ML team" отсутствовал. Добавляем в ретро.

📦 RecSys мониторинг

Специфика: Coverage (> 30% каталога показывается), Diversity (попарное расстояние), Novelty, Cold Start метрики. Drift: распределение кликов по категориям (сезонность!). Алерт: "CTR < 2.0% for 24h" → model staleness.

📸 CV OCR мониторинг

Специфика: CER per document type, Confidence distribution, Rejection rate (< 20%), Processing throughput. Алерт: "Rejection rate > 20%" — возможно новый тип документов. "CER > 5%" — модель деградировала.
  • 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 спринта

Цикл повторяется

Design Doc v2 → Planning → Training → Review → A/B → Deploy → Monitor → Retro → v3... ML-проект — это не waterfall. Это бесконечный цикл улучшений. Каждая итерация делает модель лучше, а процесс — зрелее.

📦 RecSys: Итерации

v1: ALS + Two-Tower → CTR +28%. v2: + user behavioral features → CTR +8%. v3: + multi-armed bandit для exploration → coverage +40%. v4: + real-time features (last 5 clicks) → CTR +5%. Retrain: ежедневно (candidates), еженедельно (ranking).

📸 CV OCR: Итерации

v1: TrOCR finetuned → CER 2.8%. v2: + Active Learning → CER 2.1%. v3: + новые типы документов (СНИЛС, ИНН) → unified model. v4: + layout detection (Detectron2) → structured extraction. Retrain: при накоплении 1000+ новых размеченных документов.

🎯 Уровни зрелости 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. Модель улучшается без участия человека

Где ты сейчас?

Если ты на Level 0 — это нормально. Все с этого начинали. Цель этого гайда — показать, как выглядит Level 2-3, чтобы ты понимал, куда двигаться. Не пытайся прыгнуть с 0 на 3 за неделю — это путь в burnout.

🏁 Итого: 3 сквозных примера

🔤 NLP модерация контента

Задача: "Пользователи жалуются на токсичные комментарии, ручная модерация не справляется" • Discovery: 183K размеченных комментариев, baseline regex Recall=0.31 • Design Doc: ruBERT-tiny, F1 ≥ 0.85, latency < 200ms • Планирование: 3 спринта, 6 недель, 10 SP / sprint • Разработка: 4 эксперимента, лучший F1=0.881 (ruBERT + user features) • Code Review: PR, threshold optimization, 24 теста, 87% coverage • A/B: 5% трафика 2 недели, жалобы -35%, p=0.003 • Деплой: FastAPI + ONNX Runtime, Docker, K8s canary rollout • Мониторинг: Grafana 4 уровня, Evidently drift detection, Airflow DAG • Итерация: автоматический retrain при drift, v2 с multi-label

📦 RecSys — персонализация каталога

Задача: "Конверсия из каталога упала с 3.2% до 2.1%" • Discovery: 2M пользователей, 98K товаров, baseline Popular Items CTR=1.8% • Design Doc: Two-Tower model, CTR > 2.8%, latency < 50ms • Планирование: 4 спринта, 8 недель • Разработка: ALS candidates + Two-Tower ranking, Feature Store (Redis) • A/B: interleaving, CTR +28%, Revenue +12% • Деплой: batch precompute + online reranking • Мониторинг: Coverage, Diversity, CTR per segment • Итерация: ежедневный retrain, v2-v4 с behavioral features и MAB

📸 CV — OCR документов

Задача: "Ручной ввод данных из 10K сканов/день, ошибки 5%" • Discovery: 3.2M сканов, baseline Tesseract CER=12% • Design Doc: TrOCR finetuned, CER < 3%, throughput > 100 docs/min • Планирование: 3 спринта, 6 недель • Разработка: OpenCV preprocessing + TrOCR fine-tune, CER 2.8% • A/B: shadow mode (ML + оператор параллельно), agreement 97.2% • Деплой: batch Airflow, GPU inference • Мониторинг: CER per doc type, confidence, rejection rate • Итерация: Active Learning, v2-v4 с новыми типами документов

Что дальше

Этот гайд — карта территории. Теперь ты знаешь, как выглядит полный цикл ML-проекта в компании. Но карта — не территория. Чтобы реально научиться — нужно пройти этот путь самому. Начни с малого: возьми свой pet-project и проведи его через все 10 этапов.

  • Углубись в конкретные этапы: A/B тестирование, мониторинг, CI/CD — каждая тема заслуживает отдельного погружения
  • Изучи MLOps роадмап — там детальные материалы по каждому инструменту
  • Попрактикуйся на реальном проекте — обучи модель, задеплой через Docker, настрой мониторинг
  • Подготовься к собеседованиям — System Design вопросы про ML pipeline — один из самых частых топиков

Хочешь проработать каждый этап? 🚀

Изучай ML System Design с персональной программой. Реальные кейсы, интерактивные материалы.

Начать обучение