Перейти к основному содержимому

Собеседование DS инженера в Авито: ML system design

· 47 мин. чтения

Сегодня мы разберём собеседование на позицию ML-инженера, в ходе которого кандидат Дима решал задачу построения системы автоматической модерации видеоконтента для маркетплейса. Интервью прошло в формате живого обсуждения: кандидат самостоятельно выстраивал бизнес-постановку, предлагал метрики, проектировал архитектуру пайплайна и рассуждал об эксплуатации модели. Интервьюер направлял диалог, задавал уточняющие вопросы и давал фидбэк в реальном времени.

Вопрос 1. Какие типы нарушений могут быть в видео (контакты, запрещённый контент и т.д.) и как приоритизировать их с точки зрения бизнес-метрик?

Таймкод: 00:10:57

Ответ собеседника: Правильный. Кандидат выделил основные типы нарушений: утечка контактов (номера телефонов, ссылки на мессенджеры), запрещённый контент (18+, запрещённые вещества по законодательству РФ), персональные данные (госномера). Объяснил приоритетность через бизнес-логику: Авито зарабатывает как посредник, поэтому отток клиентов через сторонние каналы связи — критичная проблема. Предложил ранжировать нарушения по влиянию на LTV пользователя. Также обсудил модальности видео: визуальная (картинки) и аудио, в которых могут быть разные типы нарушений.

Правильный ответ:

Типы нарушений в видео-контенте

1. Утечка контактов (Contact Leakage)

Это наиболее критичный тип нарушений для платформ-посредников (Авито, ЦИАН, Яндекс.Недвижимость). Включает:

  • Номера телефонов (визуальные и аудио)
  • Ссылки на мессенджеры (Telegram, WhatsApp, Viber)
  • Email-адреса
  • Ссылки на сторонние сайты и соцсети

Бизнес-обоснование приоритета: Платформа зарабатывает на комиссии за сделки. Если пользователи уходят в сторонние каналы связи, платформа теряет доход. Метрика LTV (Lifetime Value) пользователя напрямую зависит от того, остаётся ли он на платформе.

2. Запрещённый контент

  • Контент 18+ (порнография, сексуальные услуги)
  • Запрещённые вещества (наркотики, психотропные вещества)
  • Оружие и взрывчатые вещества
  • Контент, нарушающий законодательство РФ (экстремизм, разжигание ненависти)

3. Персональные данные

  • Государственные номера автомобилей
  • Паспортные данные
  • Адреса проживания
  • ФИО с фотографиями

4. Мошенничество

  • Фейковые объявления
  • Фишинговые ссылки
  • Поддельные документы

Модальности видео для анализа

Визуальная модальность:

  • Распознавание текста на кадрах (OCR)
  • Детекция объектов (оружие, наркотики)
  • Распознавание лиц
  • Детекция QR-кодов и штрихкодов

Аудио модальность:

  • Распознавание речи (ASR) с последующим анализом текста
  • Детекция ключевых слов (телефонные номера, названия мессенджеров)
  • Анализ тональности и интонации

Приоритизация нарушений

Приоритет определяется по формуле:

Priority = Business_Impact × Probability_of_Occurrence × Detection_Confidence

Где:

  • Business_Impact — влияние на ключевые метрики (LTV, GMV, конверсия)
  • Probability_of_Occurrence — частота встречаемости нарушения
  • Detection_Confidence — уверенность модели обнаружения

Ранжирование приоритетов:

  1. Высокий приоритет: Утечка контактов (прямой ущерб бизнесу)
  2. Высокий приоритет: Запрещённый контент (юридические риски)
  3. Средний приоритет: Мошенничество (репутационные риски)
  4. Низкий приоритет: Персональные данные (требует контекста)

Пример реализации приоритизации в Go:

package moderation

type ViolationType int

const (
ContactLeakage ViolationType = iota
ProhibitedContent
PersonalData
Fraud
)

type Violation struct {
Type ViolationType
Confidence float64
BusinessImpact float64
Timestamp int64
VideoID string
}

type PriorityCalculator struct {
weights map[ViolationType]float64
}

func NewPriorityCalculator() *PriorityCalculator {
return &PriorityCalculator{
weights: map[ViolationType]float64{
ContactLeakage: 1.0, // Максимальный вес
ProhibitedContent: 0.9,
Fraud: 0.7,
PersonalData: 0.5,
},
}
}

func (pc *PriorityCalculator) CalculatePriority(v Violation) float64 {
baseWeight := pc.weights[v.Type]
return baseWeight * v.Confidence * v.BusinessImpact
}

func (pc *PriorityCalculator) ShouldAutoReject(v Violation) bool {
priority := pc.CalculatePriority(v)

// Автоматический отказ для высокоприоритетных нарушений
switch v.Type {
case ContactLeakage, ProhibitedContent:
return priority > 0.8
case Fraud:
return priority > 0.9
default:
return false
}
}

Метрики для отслеживания эффективности:

  • Precision/Recall для каждого типа нарушений
  • False Positive Rate — доля ложных срабатываний
  • Time to Detection — время обнаружения нарушения
  • User Retention Rate — удержание пользователей после модерации
  • Revenue Impact — влияние на выручку

Вопрос 2. Какие технические ограничения у системы модерации видео (время обработки, доступные ресурсы, устойчивость сервиса)?

Таймкод: 00:21:57

Ответ собеседника: Правильный. Кандидат предложил следующие ограничения: наличие 4-8 GPU A100 для обучения и инференса, доступ к API LLM (бюджет ~$5000/мес) для генерации синтетических данных, достаточный CPU и оперативная память. Предложил схему обработки видео с тремя уровнями скоринга: автобан (>90-95% вероятности нарушения), ручная модерация (60-90%), автопубликация (<60%). Указал на необходимость информирования пользователя о статусе модерации через уведомления. Предварительно оценил время обработки 1-минутного видео в 30 секунд-5 минут. Указал на требование 95% устойчивости сервиса (availability).

Правильный ответ:

Вычислительные ресурсы

GPU-ресурсы для инференса:

Для обработки видео требуются GPU с достаточным объёмом видеопамяти:

  • NVIDIA A100 (40/80 GB) — оптимально для тяжёлых моделей
  • NVIDIA T4 (16 GB) — для лёгких моделей и OCR
  • NVIDIA L4 (24 GB) — баланс цены и производительности

Расчёт необходимого количества GPU:

GPU_count = (Avg_video_duration × Processing_time_per_second × QPS) / GPU_throughput

Пример: для обработки 1000 видео/час длительностью 1 минуту с временем обработки 2 минуты на видео:

GPU_count = (60 сек × 120 сек/сек × 0.28 QPS) / 1 = ~20 GPU

CPU и RAM:

  • CPU: 16-32 ядра на узел для препроцессинга видео
  • RAM: 64-128 GB для буферизации кадров
  • NVMe SSD: для быстрого доступа к видеофайлам

Временные ограничения

Целевые SLA:

МетрикаЗначениеОбоснование
P50 latency<30 секСреднее время ожидания пользователя
P95 latency<2 минДопустимое время для большинства
P99 latency<5 минГраница для длинных видео
Timeout>5 минПеревод в очередь ручной модерации

Декомпозиция времени обработки:

Total_time = Download + Decode + Visual_Analysis + Audio_Analysis + Aggregation + Decision
  • Download: 1-5 сек (зависит от размера файла)
  • Decode: 5-10 сек (извлечение ключевых кадров)
  • Visual Analysis: 10-60 сек (OCR, object detection)
  • Audio Analysis: 5-30 сек (ASR + text analysis)
  • Aggregation: 1-5 сек (объединение результатов)
  • Decision: <1 сек (принятие решения)

Архитектура устойчивости

Целевой Availability: 99.9% (три девятки)

Это означает не более 8.76 часов простоя в год.

Уровни скоринга и маршрутизации:

package moderation

type ModerationDecision int

const (
AutoReject ModerationDecision = iota
ManualReview
AutoApprove
)

type ScoringThresholds struct {
AutoRejectAbove float64 // >0.95
ManualReviewAbove float64 // >0.60
AutoApproveBelow float64 // <0.60
}

type VideoModerationResult struct {
VideoID string
VisualScore float64
AudioScore float64
CombinedScore float64
Decision ModerationDecision
Violations []Violation
ProcessingTime int64 // milliseconds
}

type ModerationEngine struct {
thresholds ScoringThresholds
notifier NotificationService
}

func NewModerationEngine() *ModerationEngine {
return &ModerationEngine{
thresholds: ScoringThresholds{
AutoRejectAbove: 0.95,
ManualReviewAbove: 0.60,
AutoApproveBelow: 0.60,
},
}
}

func (e *ModerationEngine) MakeDecision(result *VideoModerationResult) ModerationDecision {
// Комбинированный скор с весами
result.CombinedScore = result.VisualScore*0.6 + result.AudioScore*0.4

switch {
case result.CombinedScore >= e.thresholds.AutoRejectAbove:
result.Decision = AutoReject
e.notifyUser(result.VideoID, "rejected", "Обнаружено нарушение правил")
case result.CombinedScore >= e.thresholds.ManualReviewAbove:
result.Decision = ManualReview
e.notifyUser(result.VideoID, "pending", "Видео на модерации")
default:
result.Decision = AutoApprove
e.notifyUser(result.VideoID, "approved", "Видео опубликовано")
}

return result.Decision
}

func (e *ModerationEngine) notifyUser(videoID, status, message string) {
e.notifier.Send(videoID, status, message)
}

Обработка пиковых нагрузок

Стратегии для устойчивости:

  1. Graceful Degradation — при перегрузке отключение менее критичных моделей
  2. Circuit Breaker — автоматическое отключение упавших зависимостей
  3. Rate Limiting — ограничение нагрузки от одного пользователя
  4. Priority Queues — приоритизация VIP-пользователей и срочных задач
package resilience

import (
"sync"
"time"
)

type CircuitBreaker struct {
failures int
threshold int
lastFailure time.Time
resetTimeout time.Duration
mu sync.RWMutex
}

func NewCircuitBreaker(threshold int, resetTimeout time.Duration) *CircuitBreaker {
return &CircuitBreaker{
threshold: threshold,
resetTimeout: resetTimeout,
}
}

func (cb *CircuitBreaker) Call(fn func() error) error {
cb.mu.Lock()
defer cb.mu.Unlock()

if cb.isOpen() {
return ErrCircuitOpen
}

err := fn()
if err != nil {
cb.recordFailure()
return err
}

cb.reset()
return nil
}

func (cb *CircuitBreaker) isOpen() bool {
return cb.failures >= cb.threshold &&
time.Since(cb.lastFailure) < cb.resetTimeout
}

func (cb *CircuitBreaker) recordFailure() {
cb.failures++
cb.lastFailure = time.Now()
}

func (cb *CircuitBreaker) reset() {
cb.failures = 0
}

Мониторинг и алертинг

Ключевые метрики для мониторинга:

  • Latency percentiles (P50, P95, P99)
  • Error rate по типам ошибок
  • Queue depth — длина очереди на модерацию
  • GPU utilization — загрузка GPU
  • Throughput — количество обработанных видео в минуту

Бюджетные ограничения

РесурсСтоимость/месПримечание
8× A100~$15,000-20,000Облачный инстанс
LLM API~$5,000Генерация синтетических данных
Storage~$1,000S3/MinIO для видео
Monitoring~$500Grafana, Prometheus
Итого~$22,000-27,000

Вопрос 3. Какие онлайн-метрики использовать для оценки качества автомодерации (доля отменённых апелляций, точность ручной модерации, уровень пропущенных нарушений)? Что означает идеально работающая и плохо работающая модель?

Таймкод: 00:35:22

Ответ собеседника: Правильный. Кандидат предложил три ключевые метрики: 1) Доля отменённых апелляций после автобана — показывает качество автоматических блокировок (идеал: 0 отменённых апелляций = метрика 0). 2) Доля подтверждённых нарушений на ручной модерации — показывает насколько эффективно модель фильтрует контент для ручной проверки (идеал: высокая доля реальных нарушений среди отправленных на модерацию). 3) Доля нарушений в сэмпле пропущенного контента (quality assurance) — случайная выборка из пропущенных видео проверяется модераторами (идеал: 0 нарушений в сэмпле). Идеальная модель: автобан не блокирует хороший контент (0 апелляций), ручная модерация получает только реальные нарушения, в пропущенном контенте 0 нарушений. Плохая модель: обратная ситуация по всем трём метрикам.

Правильный ответ:

Онлайн-метрики качества модерации

1. Метрики автоматических блокировок

False Positive Rate (FPR) через апелляции:

Appeal_Overturn_Rate = Cancelled_Appeals / Total_Auto_Rejections

Идеальное значение: <2% (не более 2 отменённых апелляций на 100 автобанов)

package metrics

type ModerationMetrics struct {
TotalAutoRejects int64
TotalAppeals int64
OverturnedAppeals int64
ManualReviews int64
ConfirmedViolations int64
QASampleSize int64
QAViolationsFound int64
}

func (m *ModerationMetrics) AppealOverturnRate() float64 {
if m.TotalAutoRejects == 0 {
return 0
}
return float64(m.OverturnedAppeals) / float64(m.TotalAutoRejects)
}

func (m *ModerationMetrics) FalsePositiveRate() float64 {
return m.AppealOverturnRate()
}

Positive Predictive Value (PPV) для автобанов:

PPV = True_Positives / (True_Positives + False_Positives)
= (Total_Auto_Rejections - Overturned_Appeals) / Total_Auto_Rejections

Целевое значение: >98%

2. Метрики ручной модерации

Precision ручной модерации:

Manual_Precision = Confirmed_Violations / Total_Manual_Reviews

Эта метрика показывает, насколько эффективно модель фильтрует контент для ручной проверки. Если precision низкий — модераторы тратят время на проверку безопасного контента.

Целевое значение: >70% (минимум 7 из 10 видео на ручной модерации содержат реальные нарушения)

Распределение нагрузки на модераторов:

type ModeratorWorkload struct {
ReviewsPerHour float64
AvgReviewTime time.Duration
ViolationRate float64
Accuracy float64
}

func (mw *ModeratorWorkload) Efficiency() float64 {
// Эффективность = найденные нарушения / затраченное время
violationsPerMinute := mw.ViolationRate / mw.AvgReviewTime.Minutes()
return violationsPerMinute * mw.Accuracy
}

3. Метрики пропущенных нарушений (False Negative Rate)

QA Sampling для оценки пропусков:

type QASampler struct {
SampleRate float64 // 0.01 = 1% от всех одобренных видео
SampleSize int
ViolationsFound int
}

func (qa *QASampler) EstimateFalseNegativeRate() float64 {
if qa.SampleSize == 0 {
return 0
}
return float64(qa.ViolationsFound) / float64(qa.SampleSize)
}

func (qa *QASampler) EstimatedMissedViolations(totalApproved int64) int64 {
fnr := qa.EstimateFalseNegativeRate()
return int64(float64(totalApproved) * fnr)
}

Целевое значение FNR: <0.5% (не более 1 пропущенного нарушения на 200 одобренных видео)

Комплексная оценка качества

F1-Score системы модерации:

type ModerationQuality struct {
Precision float64 // PPV
Recall float64 // 1 - FNR
}

func (mq *ModerationQuality) F1Score() float64 {
if mq.Precision+mq.Recall == 0 {
return 0
}
return 2 * (mq.Precision * mq.Recall) / (mq.Precision + mq.Recall)
}

func (mq *ModerationQuality) Grade() string {
f1 := mq.F1Score()
switch {
case f1 >= 0.99:
return "Excellent"
case f1 >= 0.95:
return "Good"
case f1 >= 0.90:
return "Acceptable"
case f1 >= 0.80:
return "Needs Improvement"
default:
return "Critical"
}
}

Идеально работающая модель

Характеристики:

МетрикаЗначениеИнтерпретация
Appeal Overturn Rate0-1%Практически нет ложных блокировок
Manual Precision>90%Модераторы проверяют только подозрительный контент
False Negative Rate<0.1%Почти все нарушения обнаруживаются
F1-Score>0.99Баланс между precision и recall

Плохо работающая модель

Характеристики:

МетрикаЗначениеПоследствия
Appeal Overturn Rate>10%Массовые жалобы пользователей, отток
Manual Precision<30%Перегрузка модераторов, высокие затраты
False Negative Rate>5%Репутационные риски, юридическая ответственность
F1-Score<0.80Система не выполняет свою функцию

Дополнительные метрики для мониторинга

Временные метрики:

type LatencyMetrics struct {
P50 time.Duration
P95 time.Duration
P99 time.Duration
}

func (lm *LatencyMetrics) SLACompliant() bool {
return lm.P95 < 2*time.Minute && lm.P99 < 5*time.Minute
}

Бизнес-метрики:

  • User Retention Rate после модерации — удержание пользователей, чей контент был проверен
  • Time to Resolution — среднее время от загрузки до принятия решения
  • Cost per Review — стоимость модерации одного видео

Алерты и пороги

type AlertThresholds struct {
MaxAppealOverturnRate float64 // 0.05
MinManualPrecision float64 // 0.50
MaxFalseNegativeRate float64 // 0.02
MaxP95Latency time.Duration // 3 min
}

func (at *AlertThresholds) Check(metrics ModerationMetrics) []string {
var alerts []string

if metrics.AppealOverturnRate() > at.MaxAppealOverturnRate {
alerts = append(alerts, "HIGH: Appeal overturn rate exceeded threshold")
}

if metrics.EstimatedFNR() > at.MaxFalseNegativeRate {
alerts = append(alerts, "CRITICAL: False negative rate too high")
}

return alerts
}

Вопрос 4. Какие данные и сущности доступны для построения системы модерации видео (пользователь, объявление, медиафайлы)?

Таймкод: 00:49:26

Ответ собеседника: Правильный. Кандидат описал следующие сущности и признаки: Пользователь: user_id, дата регистрации (возраст аккаунта), список объявлений, тип аккаунта (бизнес/обычный). Объявление: ad_id, user_id (автор), description, title, список медиафайлов (видео, изображения), статус (на модерации/отклонено/активно). Медиаобъект (видео): media_id, ad_id, тип, хэш (для дедупликации). Также описал внутреннюю структуру видео: набор фреймов (кадров) через библиотеки обработки видео и аудиодорожку. Сосредоточился на метаданных, признаках пользователя и объявления, а также бинарным контенте видео.

Правильный ответ:

Основные сущности системы

1. Пользователь (User)

package models

import "time"

type User struct {
UserID string `json:"user_id" db:"user_id"`
Email string `json:"email" db:"email"`
Phone string `json:"phone" db:"phone"`
RegisteredAt time.Time `json:"registered_at" db:"registered_at"`
AccountType string `json:"account_type" db:"account_type"` // "personal", "business"
IsVerified bool `json:"is_verified" db:"is_verified"`
TrustScore float64 `json:"trust_score" db:"trust_score"`
}

type UserHistory struct {
UserID string `json:"user_id"`
TotalAds int `json:"total_ads"`
RejectedAds int `json:"rejected_ads"`
ViolationsCount int `json:"violations_count"`
LastViolationAt time.Time `json:"last_violation_at"`
AccountAgeDays int `json:"account_age_days"`
}

Признаки пользователя для модерации:

  • Account Age — возраст аккаунта в днях (новые аккаунты более подозрительны)
  • Trust Score — накопленный скор доверия на основе истории
  • Violation Rate — доля отклонённых объявлений
  • Verification Status — верифицирован ли пользователь
  • Account Type — бизнес-аккаунты обычно более надёжны

2. Объявление (Advertisement)

type Advertisement struct {
AdID string `json:"ad_id" db:"ad_id"`
UserID string `json:"user_id" db:"user_id"`
CategoryID string `json:"category_id" db:"category_id"`
Title string `json:"title" db:"title"`
Description string `json:"description" db:"description"`
Price float64 `json:"price" db:"price"`
Currency string `json:"currency" db:"currency"`
CityID string `json:"city_id" db:"city_id"`
Status AdStatus `json:"status" db:"status"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
MediaFiles []MediaFile `json:"media_files"`
}

type AdStatus string

const (
AdStatusDraft AdStatus = "draft"
AdStatusModeration AdStatus = "moderation"
AdStatusApproved AdStatus = "approved"
AdStatusRejected AdStatus = "rejected"
AdStatusActive AdStatus = "active"
AdStatusArchived AdStatus = "archived"
)

Признаки объявления:

  • Category — категория объявления (авто, недвижимость, услуги)
  • Text Content — заголовок и описание для текстового анализа
  • Price — цена (аномально низкие цены могут указывать на мошенничество)
  • Media Count — количество медиафайлов
  • Geolocation — город/регион размещения

3. Медиафайл (MediaFile)

type MediaFile struct {
MediaID string `json:"media_id" db:"media_id"`
AdID string `json:"ad_id" db:"ad_id"`
MediaType MediaType `json:"media_type" db:"media_type"`
FileURL string `json:"file_url" db:"file_url"`
FileHash string `json:"file_hash" db:"file_hash"` // SHA-256 для дедупликации
FileSize int64 `json:"file_size" db:"file_size"`
MimeType string `json:"mime_type" db:"mime_type"`
Width int `json:"width" db:"width"`
Height int `json:"height" db:"height"`
Duration time.Duration `json:"duration" db:"duration"` // для видео/аудио
CreatedAt time.Time `json:"created_at" db:"created_at"`
}

type MediaType string

const (
MediaTypeImage MediaType = "image"
MediaTypeVideo MediaType = "video"
MediaTypeAudio MediaType = "audio"
)

Внутренняя структура видео:

type VideoContent struct {
MediaID string `json:"media_id"`
Frames []VideoFrame `json:"frames"`
AudioTrack *AudioTrack `json:"audio_track"`
Metadata VideoMetadata `json:"metadata"`
}

type VideoFrame struct {
FrameIndex int `json:"frame_index"`
Timestamp float64 `json:"timestamp"` // секунда в видео
Width int `json:"width"`
Height int `json:"height"`
Format string `json:"format"` // "yuv420p", "rgb24"
Data []byte `json:"-"` // бинарные данные кадра
}

type AudioTrack struct {
SampleRate int `json:"sample_rate"`
Channels int `json:"channels"`
Duration float64 `json:"duration"`
Format string `json:"format"` // "aac", "mp3", "opus"
Data []byte `json:"-"`
}

type VideoMetadata struct {
Codec string `json:"codec"`
Bitrate int `json:"bitrate"`
FPS float64 `json:"fps"`
TotalFrames int `json:"total_frames"`
}

4. Результат модерации (ModerationResult)

type ModerationResult struct {
ResultID string `json:"result_id" db:"result_id"`
MediaID string `json:"media_id" db:"media_id"`
AdID string `json:"ad_id" db:"ad_id"`
Status ModerationStatus `json:"status" db:"status"`
Decision ModerationDecision `json:"decision" db:"decision"`
VisualScore float64 `json:"visual_score" db:"visual_score"`
AudioScore float64 `json:"audio_score" db:"audio_score"`
CombinedScore float64 `json:"combined_score" db:"combined_score"`
Violations []ViolationDetail `json:"violations" db:"violations"`
ModeratedBy string `json:"moderated_by" db:"moderated_by"` // "auto" или moderator_id
ModeratedAt time.Time `json:"moderated_at" db:"moderated_at"`
ProcessingMs int64 `json:"processing_ms" db:"processing_ms"`
}

type ViolationDetail struct {
Type ViolationType `json:"type"`
Confidence float64 `json:"confidence"`
Timestamp float64 `json:"timestamp"` // время в видео, где найдено нарушение
BBox *BoundingBox `json:"bbox,omitempty"` // координаты на кадре
Text string `json:"text,omitempty"` // распознанный текст
}

type BoundingBox struct {
X int `json:"x"`
Y `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
}

SQL-схема для хранения данных

-- Пользователи
CREATE TABLE users (
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
phone VARCHAR(20),
registered_at TIMESTAMP NOT NULL DEFAULT NOW(),
account_type VARCHAR(20) NOT NULL DEFAULT 'personal',
is_verified BOOLEAN DEFAULT FALSE,
trust_score DECIMAL(5,4) DEFAULT 0.5,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Объявления
CREATE TABLE advertisements (
ad_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(user_id),
category_id VARCHAR(50) NOT NULL,
title VARCHAR(500) NOT NULL,
description TEXT,
price DECIMAL(15,2),
currency VARCHAR(3) DEFAULT 'RUB',
city_id VARCHAR(50),
status VARCHAR(20) NOT NULL DEFAULT 'draft',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Медиафайлы
CREATE TABLE media_files (
media_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ad_id UUID NOT NULL REFERENCES advertisements(ad_id),
media_type VARCHAR(10) NOT NULL,
file_url VARCHAR(1000) NOT NULL,
file_hash VARCHAR(64) NOT NULL,
file_size BIGINT,
mime_type VARCHAR(50),
width INTEGER,
height INTEGER,
duration_ms BIGINT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Индекс для дедупликации по хэшу
CREATE INDEX idx_media_hash ON media_files(file_hash);

-- Результаты модерации
CREATE TABLE moderation_results (
result_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
media_id UUID NOT NULL REFERENCES media_files(media_id),
ad_id UUID NOT NULL REFERENCES advertisements(ad_id),
status VARCHAR(20) NOT NULL,
decision VARCHAR(20) NOT NULL,
visual_score DECIMAL(5,4),
audio_score DECIMAL(5,4),
combined_score DECIMAL(5,4),
violations JSONB,
moderated_by VARCHAR(50) NOT NULL DEFAULT 'auto',
moderated_at TIMESTAMP NOT NULL DEFAULT NOW(),
processing_ms BIGINT
);

-- Индексы для быстрого поиска
CREATE INDEX idx_moderation_ad ON moderation_results(ad_id);
CREATE INDEX idx_moderation_status ON moderation_results(status);
CREATE INDEX idx_moderation_decision ON moderation_results(decision);
CREATE INDEX idx_moderation_date ON moderation_results(moderated_at);

-- История нарушений пользователя
CREATE MATERIALIZED VIEW user_violation_stats AS
SELECT
a.user_id,
COUNT(*) as total_ads,
COUNT(*) FILTER (WHERE mr.decision = 'auto_reject') as rejected_ads,
COUNT(*) FILTER (WHERE mr.decision = 'auto_reject')::FLOAT /
NULLIF(COUNT(*), 0) as violation_rate,
MAX(mr.moderated_at) as last_moderation_at
FROM advertisements a
JOIN moderation_results mr ON a.ad_id = mr.ad_id
GROUP BY a.user_id;

Граф связей между сущностями

User (1) ──→ (N) Advertisement (1) ──→ (N) MediaFile


(1) ModerationResult


(N) ViolationDetail

Дополнительные источники данных

  • Blacklist хэшей — база известных запрещённых видео
  • Whitelist пользователей — доверенные продавцы
  • Категорные правила — специфичные требования для каждой категории
  • Логи действий пользователя — частота публикаций, паттерны поведения

Вопрос 5. Как формализовать ML-задачу для модерации видео? Какой пайплайн обработки предложить (детекция текста, классификация изображений)? Какие оффлайн-метрики использовать?

Таймкод: 00:57:01

Ответ собеседника: Правильный. Кандидат предложил разделить задачу на два направления: 1) Детекция контактов: object detection для поиска текста на кадрах → OCR (распознавание текста) → NLP-классификатор для определения контактной информации (номера телефонов, QR-коды, призывы). 2) Запрещённый контент: сэмплинг кадров видео (разбиение на сцены или фиксированные интервалы, ~6-7 кадров на минуту) → извлечение эмбеддингов через CLIP (image-to-embedding) → классификационная голова (head) с бинарным выходом (есть/нет запрещённого контента). Для оффлайн-метрик предложил: Precision, Recall, F1-score, PR-AUC (важна для несбалансированных данных и ранжирования по вероятности). Обосновал выбор PR-AUC тем, что метрика хорошо ранжирует по вероятностям и позволяет гибко настраивать пороги для баланса между издержками и репутационными рисками.

Правильный ответ:

Формализация ML-задачи

Задача модерации видео — это мультимодальная бинарная классификация с элементами object detection и sequence labeling.

Математическая постановка:

Дано: V = {f₁, f₂, ..., fₙ} — набор кадров видео
A = {a₁, a₂, ..., aₘ} — аудиодорожка

Найти: y ∈ {0, 1} — бинарное решение (0 = безопасно, 1 = нарушение)
P(y=1|V, A) — вероятность нарушения

Мультиклассовая расширенная постановка:

y ∈ {safe, contact_leakage, prohibited_content, fraud, ...}
P(y=c|V, A) для каждого класса c

Архитектура пайплайна обработки

1. Пайплайн детекции контактов

package pipeline

type ContactDetectionPipeline struct {
frameSampler FrameSampler
textDetector TextDetector
ocrEngine OCREngine
contactClassifier ContactClassifier
}

func (p *ContactDetectionPipeline) Process(video VideoContent) (ContactResult, error) {
// Шаг 1: Сэмплинг кадров
frames := p.frameSampler.Sample(video, SampleConfig{
MaxFrames: 10,
Strategy: SceneBasedSampling,
MinSceneChange: 0.3,
})

// Шаг 2: Детекция текста на каждом кадре
var allDetections []TextDetection
for _, frame := range frames {
detections := p.textDetector.Detect(frame)
allDetections = append(allDetections, detections...)
}

// Шаг 3: OCR для распознавания текста
var recognizedTexts []RecognizedText
for _, det := range allDetections {
text, confidence := p.ocrEngine.Recognize(frame, det.BBox)
recognizedTexts = append(recognizedTexts, RecognizedText{
Text: text,
Confidence: confidence,
BBox: det.BBox,
Timestamp: frame.Timestamp,
})
}

// Шаг 4: Классификация контактной информации
result := p.contactClassifier.Classify(recognizedTexts)

return result, nil
}

2. Пайплайн детекции запрещённого контента

type ContentClassificationPipeline struct {
frameSampler FrameSampler
embedder ImageEmbedder
classifier ContentClassifier
aggregator FrameAggregator
}

func (p *ContentClassificationPipeline) Process(video VideoContent) (ContentResult, error) {
// Шаг 1: Сэмплинг кадров (6-7 кадров на минуту)
frames := p.frameSampler.Sample(video, SampleConfig{
FramesPerMinute: 6,
Strategy: UniformSampling,
})

// Шаг 2: Извлечение эмбеддингов через CLIP
var embeddings [][]float32
for _, frame := range frames {
embedding := p.embedder.Encode(frame)
embeddings = append(embeddings, embedding)
}

// Шаг 3: Классификация каждого кадра
var frameScores []float64
for _, emb := range embeddings {
score := p.classifier.Predict(emb)
frameScores = append(frameScores, score)
}

// Шаг 4: Агрегация скоров
result := p.aggregator.Aggregate(frameScores, AggregationStrategy{
Method: MaxPooling,
Threshold: 0.7,
})

return result, nil
}

3. Пайплайн аудиоанализа

type AudioAnalysisPipeline struct {
asrEngine ASREngine
textClassifier TextClassifier
keywordDetector KeywordDetector
}

func (p *AudioAnalysisPipeline) Process(audio AudioTrack) (AudioResult, error) {
// Шаг 1: Speech-to-Text
transcript, err := p.asrEngine.Transcribe(audio)
if err != nil {
return AudioResult{}, err
}

// Шаг 2: Классификация текста
textScore := p.textClassifier.Classify(transcript.FullText)

// Шаг 3: Детекция ключевых слов (телефоны, мессенджеры)
keywordMatches := p.keywordDetector.Find(transcript.Segments)

return AudioResult{
TextScore: textScore,
KeywordMatches: keywordMatches,
Transcript: transcript,
}, nil
}

Стратегии сэмплинга кадров

type SamplingStrategy int

const (
UniformSampling SamplingStrategy = iota
SceneBasedSampling
KeyframeSampling
)

type FrameSampler struct {
strategy SamplingStrategy
}

func (fs *FrameSampler) Sample(video VideoContent, config SampleConfig) []VideoFrame {
switch fs.strategy {
case UniformSampling:
return fs.uniformSample(video, config)
case SceneBasedSampling:
return fs.sceneBasedSample(video, config)
case KeyframeSampling:
return fs.keyframeSample(video, config)
default:
return fs.uniformSample(video, config)
}
}

func (fs *FrameSampler) sceneBasedSample(video VideoContent, config SampleConfig) []VideoFrame {
// Детекция смены сцен через сравнение гистограмм
var selectedFrames []VideoFrame
prevHistogram := computeHistogram(video.Frames[0])

for i := 1; i < len(video.Frames); i++ {
currHistogram := computeHistogram(video.Frames[i])
similarity := compareHistograms(prevHistogram, currHistogram)

if similarity < (1 - config.MinSceneChange) {
selectedFrames = append(selectedFrames, video.Frames[i])
prevHistogram = currHistogram
}

if len(selectedFrames) >= config.MaxFrames {
break
}
}

return selectedFrames
}

Оффлайн-метрики оценки качества

1. Основные метрики классификации

package metrics

type ClassificationMetrics struct {
TruePositives int
TrueNegatives int
FalsePositives int
FalseNegatives int
}

func (m *ClassificationMetrics) Precision() float64 {
denom := m.TruePositives + m.FalsePositives
if denom == 0 {
return 0
}
return float64(m.TruePositives) / float64(denom)
}

func (m *ClassificationMetrics) Recall() float64 {
denom := m.TruePositives + m.FalseNegatives
if denom == 0 {
return 0
}
return float64(m.TruePositives) / float64(denom)
}

func (m *ClassificationMetrics) F1Score() float64 {
p := m.Precision()
r := m.Recall()
if p+r == 0 {
return 0
}
return 2 * p * r / (p + r)
}

func (m *ClassificationMetrics) Specificity() float64 {
denom := m.TrueNegatives + m.FalsePositives
if denom == 0 {
return 0
}
return float64(m.TrueNegatives) / float64(denom)
}

2. PR-AUC (Precision-Recall Area Under Curve)

PR-AUC предпочтительнее ROC-AUC для несбалансированных данных (нарушения редки):

type PRAUCComputer struct {
thresholds []float64
}

func (pc *PRAUCComputer) Compute(scores []float64, labels []int) float64 {
var precisions, recalls []float64

for _, threshold := range pc.thresholds {
predicted := make([]int, len(scores))
for i, score := range scores {
if score >= threshold {
predicted[i] = 1
}
}

metrics := computeMetrics(predicted, labels)
precisions = append(precisions, metrics.Precision())
recalls = append(recalls, metrics.Recall())
}

// Trapezoidal rule for AUC
return trapezoidalAUC(recalls, precisions)
}

3. Метрики для разных типов нарушений

type PerClassMetrics struct {
ClassName string
Precision float64
Recall float64
F1 float64
Support int
}

func computePerClassMetrics(predictions []int, labels []int, classes []string) []PerClassMetrics {
var results []PerClassMetrics

for i, class := range classes {
tp, fp, fn := 0, 0, 0

for j := range predictions {
if predictions[j] == i && labels[j] == i {
tp++
} else if predictions[j] == i && labels[j] != i {
fp++
} else if predictions[j] != i && labels[j] == i {
fn++
}
}

precision := float64(tp) / float64(tp+fp)
recall := float64(tp) / float64(tp+fn)
f1 := 2 * precision * recall / (precision + recall)

results = append(results, PerClassMetrics{
ClassName: class,
Precision: precision,
Recall: recall,
F1: f1,
Support: tp + fn,
})
}

return results
}

Выбор порога на основе бизнес-требований

type ThresholdOptimizer struct {
targetPrecision float64
targetRecall float64
}

func (to *ThresholdOptimizer) FindOptimalThreshold(
scores []float64,
labels []int,
) float64 {
bestThreshold := 0.5
bestF1 := 0.0

for threshold := 0.1; threshold <= 0.99; threshold += 0.01 {
predicted := thresholdPredict(scores, threshold)
metrics := computeMetrics(predicted, labels)

// Приоритет: Recall >= target, затем максимизация Precision
if metrics.Recall() >= to.targetRecall && metrics.F1Score() > bestF1 {
bestF1 = metrics.F1Score()
bestThreshold = threshold
}
}

return bestThreshold
}

Рекомендуемые целевые значения метрик

МетрикаЦелевое значениеОбоснование
Precision>95%Минимизация ложных блокировок
Recall>99%Критично не пропускать нарушения
PR-AUC>0.98Хорошее ранжирование по вероятности
F1-Score>0.97Баланс precision/recall

Вопрос 6. Какие оффлайн-метрики классификации использовать и как выбрать порог вероятности для принятия решений? Что важнее — Precision или Recall?

Таймкод: 01:05:44

Ответ собеседника: Правильный. Кандидат предложил использовать Precision, Recall, F1-score и PR-AUC. Обосновал выбор PR-AUC вместо ROC-AUC несбалансированностью данных (на 10 000 контента может быть только 5 примеров запрещёнки). PR-AUC более устойчив к дисбалансу классов. Для автобана важнее Precision (чтобы не блокировать хороший контент), для ручной модерации и пропуска — Recall (чтобы не пропустить запрещённый контент). Глобально важнее Recall, а при фиксированном Recall максимизируется Precision. Предложил построить график Precision-Recall кривой и совместно с бизнесом выбрать оптимальный порог отсечения.

Правильный ответ:

Выбор метрик для несбалансированных данных

Почему PR-AUC вместо ROC-AUC:

В задаче модерации данные сильно несбалансированы: нарушения составляют 0.1-1% от всего контента. ROC-AUC в таких случаях может показывать оптимистичные значения из-за большого числа True Negatives.

package metrics

import (
"math"
"sort"
)

type MetricsComputer struct{}

// ROC-AUC — менее информативен при сильном дисбалансе
func (mc *MetricsComputer) ROCAUC(scores []float64, labels []int) float64 {
type pair struct {
score float64
label int
}

pairs := make([]pair, len(scores))
for i := range scores {
pairs[i] = pair{scores[i], labels[i]}
}

sort.Slice(pairs, func(i, j int) bool {
return pairs[i].score > pairs[j].score
})

tp, fp := 0, 0
tpPrev, fpPrev := 0, 0
auc := 0.0
totalPos := 0
for _, l := range labels {
totalPos += l
}
totalNeg := len(labels) - totalPos

for _, p := range pairs {
if p.label == 1 {
tp++
} else {
fp++
}
auc += float64(tp+tpPrev) * float64(fp-fpPrev) / 2.0
tpPrev, fpPrev = tp, fp
}

return auc / (float64(totalPos) * float64(totalNeg))
}

// PR-AUC — более информативен при дисбалансе
func (mc *MetricsComputer) PRAUC(scores []float64, labels []int) float64 {
type pair struct {
score float64
label int
}

pairs := make([]pair, len(scores))
for i := range scores {
pairs[i] = pair{scores[i], labels[i]}
}

sort.Slice(pairs, func(i, j int) bool {
return pairs[i].score > pairs[j].score
})

tp, fp := 0, 0
totalPos := 0
for _, l := range labels {
totalPos += l
}

var precisions, recalls []float64
prevPrecision := 1.0
prevRecall := 0.0
auc := 0.0

for _, p := range pairs {
if p.label == 1 {
tp++
} else {
fp++
}

precision := float64(tp) / float64(tp+fp)
recall := float64(tp) / float64(totalPos)

// Trapezoidal rule
auc += (recall - prevRecall) * (precision + prevPrecision) / 2.0

prevPrecision = precision
prevRecall = recall
}

return auc
}

Матрица ошибок и бизнес-интерпретация

Предсказано: нарушениеПредсказано: безопасно
Факт: нарушениеTrue Positive (TP)False Negative (FN) — ПРОПУЩЕНО
Факт: безопасноFalse Positive (FP) — ЛОЖНОЕ БЛОКИРОВАНИЕTrue Negative (TN)

Что важнее: Precision или Recall?

Ответ зависит от типа решения и стоимости ошибок:

type BusinessCost struct {
FalsePositiveCost float64 // Стоимость ложного блокирования
FalseNegativeCost float64 // Стоимость пропущенного нарушения
}

// Стоимость ошибок для разных типов нарушений
var violationCosts = map[ViolationType]BusinessCost{
ContactLeakage: {
FalsePositiveCost: 5.0, // Пользователь разочарован, может уйти
FalseNegativeCost: 50.0, // Утечка контакта — потеря дохода
},
ProhibitedContent: {
FalsePositiveCost: 10.0, // Серьёзное разочарование пользователя
FalseNegativeCost: 1000.0, // Юридическая ответственность, репутация
},
}

Приоритеты по уровням модерации:

УровеньПриоритетОбоснование
АвтобанPrecision > RecallНе блокировать хороший контент. Лучше отправить на ручную модерацию
Ручная модерацияRecall > PrecisionНе пропустить подозрительный контент в публикацию
ПропускRecall >>> PrecisionКритично не допустить нарушения в публичный доступ

Оптимизация порога принятия решения

type ThresholdOptimizer struct {
minRecall float64 // Минимально допустимый recall
maxFPR float64 // Максимально допустимый false positive rate
}

func (to *ThresholdOptimizer) FindOptimalThreshold(
scores []float64,
labels []int,
) (optimalThreshold float64, metrics ClassificationMetrics) {
bestThreshold := 0.5
bestPrecision := 0.0
var bestMetrics ClassificationMetrics

// Перебираем пороги с шагом 0.001
for threshold := 0.01; threshold < 1.0; threshold += 0.001 {
predicted := make([]int, len(scores))
for i, score := range scores {
if score >= threshold {
predicted[i] = 1
}
}

m := computeMetrics(predicted, labels)

// Условие: Recall >= минимального, FPR <= максимального
if m.Recall() >= to.minRecall && m.FPR() <= to.maxFPR {
if m.Precision() > bestPrecision {
bestPrecision = m.Precision()
bestThreshold = threshold
bestMetrics = m
}
}
}

return bestThreshold, bestMetrics
}

func (to *ThresholdOptimizer) FindThresholdForTargetRecall(
scores []float64,
labels []int,
targetRecall float64,
) float64 {
type pair struct {
score float64
label int
}

pairs := make([]pair, len(scores))
for i := range scores {
pairs[i] = pair{scores[i], labels[i]}
}

sort.Slice(pairs, func(i, j int) bool {
return pairs[i].score > pairs[j].score
})

totalPos := 0
for _, l := range labels {
totalPos += l
}

tpCount := 0
for _, p := range pairs {
if p.label == 1 {
tpCount++
}
currentRecall := float64(tpCount) / float64(totalPos)
if currentRecall >= targetRecall {
return p.score
}
}

return 0.0
}

Многороговая система для разных решений

type MultiThresholdSystem struct {
autoRejectThreshold float64 // Высокий порог для автобана
manualReviewThreshold float64 // Средний порог для ручной модерации
autoApproveThreshold float64 // Низкий порог для автопубликации
}

func NewMultiThresholdSystem() *MultiThresholdSystem {
return &MultiThresholdSystem{
autoRejectThreshold: 0.95, // Только очевидные нарушения
manualReviewThreshold: 0.60, // Подозрительный контент
autoApproveThreshold: 0.60, // Всё остальное
}
}

func (mts *MultiThresholdSystem) MakeDecision(score float64) ModerationDecision {
switch {
case score >= mts.autoRejectThreshold:
return AutoReject
case score >= mts.manualReviewThreshold:
return ManualReview
default:
return AutoApprove
}
}

// Калибровка порогов на валидационной выборке
func (mts *MultiThresholdSystem) Calibrate(
scores []float64,
labels []int,
targetAutoRejectRecall float64,
targetManualReviewRecall float64,
) {
// Порог автобана: обеспечиваем высокий precision (мин. ложных блокировок)
mts.autoRejectThreshold = findThresholdForPrecision(scores, labels, 0.98)

// Порог ручной модерации: обеспечиваем высокий recall (не пропускаем нарушения)
mts.manualReviewThreshold = findThresholdForTargetRecall(scores, labels, targetManualReviewRecall)
}

Рекомендуемые целевые значения

Тип нарушенияПорог автобанаПорог ручной модерацииЦелевой Recall
Утечка контактов0.950.7099%
Запрещённый контент0.900.6099.5%
Мошенничество0.950.7598%

Итоговый алгоритм выбора порога:

  1. Определить бизнес-стоимость ошибок FP и FN
  2. Задать минимально допустимый Recall (обычно 99%+)
  3. При фиксированном Recall максимизировать Precision
  4. Построить Precision-Recall кривую
  5. Выбрать рабочую точку совместно с бизнесом
  6. Регулярно перекалибровать порог на новых данных

Вопрос 7. Как собрать и подготовить данные для обучения модели классификации запрещённого контента? Какой баланс классов использовать и как сплитить данные?

Таймкод: 01:13:52

Ответ собеседника: Правильный. Кандидат предложил следующий план работы с данными: 1) Выгрузка исторических данных от команды ручной модерации (видео и картинки с размеченными нарушениями). 2) Проверка качества данных — создание команды или выделение человека для проверки случайной выборки из трейна. 3) Использование открытых датасетов для увеличения объёма данных. 4) Парсинг YouTube как дополнительный источник. 5) Баланс классов: стремиться к 50/50 для лучшего обучения модели, но учитывать реальный дисбаланс. При недостатке данных использовать focal loss. 6) Сплит данных: 80/20 с учётом user_id для предотвращения утечки данных. Учитывать временной сдвиг. Минимальный размер трейна: ~5000 сэмплов, теста: ~500-700 сэмплов.

Правильный ответ:

Источники данных для обучения

1. Исторические данные модерации

package data

type DataCollector struct {
db *sql.DB
storage StorageClient
labelClient LabelingClient
}

func (dc *DataCollector) CollectHistoricalData(ctx context.Context, config CollectionConfig) (*Dataset, error) {
query := `
SELECT
mf.media_id,
mf.file_url,
mf.file_hash,
mr.decision,
mr.violations,
mr.moderated_by,
mr.moderated_at,
a.user_id,
a.category_id
FROM media_files mf
JOIN moderation_results mr ON mf.media_id = mr.media_id
JOIN advertisements a ON mf.ad_id = a.ad_id
WHERE mr.moderated_at BETWEEN $1 AND $2
AND mr.decision IN ('auto_reject', 'approved')
AND mr.moderated_by != 'auto' -- Только ручная модерация для качества
ORDER BY mr.moderated_at DESC
`

rows, err := db.QueryContext(ctx, query, config.StartDate, config.EndDate)
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
defer rows.Close()

var samples []Sample
for rows.Next() {
var s Sample
if err := rows.Scan(&s.MediaID, &s.FileURL, &s.FileHash, &s.Decision, &s.Violations, &s.ModeratedBy, &s.ModeratedAt, &s.UserID, &s.CategoryID); err != nil {
return nil, err
}
samples = append(samples, s)
}

return &Dataset{Samples: samples}, nil
}

2. Открытые датасеты

ДатасетТипРазмерПрименение
NSFW Data ScraperИзображения500K+Классификация 18+ контента
OpenImagesИзображения9MПредобучение
AudioSetАудио2MАудио классификация

3. Синтетическая генерация данных

type SyntheticDataGenerator struct {
llmClient LLMClient
}

func (sdg *SyntheticDataGenerator) GenerateNegativeSamples(count int) ([]Sample, error) {
// Генерация синтетических примеров контактной информации
prompts := []string{
"Generate phone numbers in various formats",
"Generate Telegram/WhatsApp contact patterns",
"Generate QR code descriptions",
}

var samples []Sample
for _, prompt := range prompts {
generated, err := sdg.llmClient.Generate(prompt, count/len(prompts))
if err != nil {
return nil, err
}
samples = append(samples, generated...)
}

return samples, nil
}

Проверка качества данных

type QualityChecker struct {
interAnnotatorThreshold float64 // Минимальное согласие аннотаторов
}

func (qc *QualityChecker) CheckAnnotationQuality(samples []Sample) QualityReport {
// Inter-annotator agreement
agreement := qc.computeCohenKappa(samples)

// Проверка консистентентности
consistency := qc.checkConsistency(samples)

// Поиск дубликатов
duplicates := qc.findDuplicates(samples)

QualityReport{
TotalSamples: len(samples),
Agreement: agreement,
Consistency: consistency,
DuplicateCount: len(duplicates),
RecommendedAction: qc.recommend(agreement, consistency),
}
}

func (qc *QualityChecker) computeCohenKappa(samples []Sample) float64 {
// Cohen's Kappa для измерения согласия между аннотаторами
// κ > 0.8 — отличное согласие
// κ > 0.6 — хорошее согласие
// κ < 0.6 — требуется переразметка
...
}

Баланс классов

type ClassBalancer struct {
strategy BalancingStrategy
targetRatio float64
}

type BalancingStrategy int

const (
Oversampling BalancingStrategy = iota
Undersampling
SMOTE
ClassWeights
)

func (cb *ClassBalancer) Balance(dataset *Dataset) (*Dataset, error) {
posSamples := filterByLabel(dataset, 1)
negSamples := filterByLabel(dataset, 0)

posCount := len(posSamples)
negCount := len(negSamples)

ratio := float64(posCount) / float64(negCount)

switch {
case ratio < 0.1:
// Сильный дисбаланс — используем oversampling + class weights
return cb.combineStrategies(dataset, posSamples, negSamples)
case ratio < 0.3:
// Умеренный дисбаланс — oversampling
return cb.oversample(posSamples, negCount)
default:
// Баланс приемлем — используем class weights
return cb.applyClassWeights(dataset, posCount, negCount)
}
}

func (cb *ClassBalancer) computeClassWeights(posCount, negCount int) (posWeight, negWeight float64) {
total := float64(posCount + negCount)
posWeight = total / (2 * float64(posCount))
negWeight = total / (2 * float64(negCount))
return
}

Стратегия балансировки для разных этапов:

ЭтапСтратегияОбоснование
ОбучениеOversampling + Class weightsМодель лучше учится на сбалансированных данных
ВалидацияЕстественное распределениеОценка в реальных условиях
ТестированиеЕстественное распределениеФинальная оценка качества

Сплит данных

type DataSplitter struct {
testRatio float64
valRatio float64
stratifyBy string
groupBy string // user_id для предотвращения утечки
temporalSplit bool
}

func (ds *DataSplitter) Split(dataset *Dataset) (train, val, test *Dataset) {
if ds.temporalSplit {
return ds.temporalSplit(dataset)
}

if ds.groupBy != "" {
return ds.groupSplit(dataset)
}

return ds.stratifiedSplit(dataset)
}

func (ds *DataSplitter) groupSplit(dataset *Dataset) (train, val, test *Dataset) {
// Группировка по user_id — все объявления одного пользователя в одном сплите
userGroups := groupSamplesBy(dataset, ds.groupBy)

// Перемешивание групп
shuffle(userGroups)

totalGroups := len(userGroups)
testSize := int(float64(totalGroups) * ds.testRatio)
valSize := int(float64(totalGroups) * ds.valRatio)

testGroups := userGroups[:testSize]
valGroups := userGroups[testSize : testSize+valSize]
trainGroups := userGroups[testSize+valSize:]

return mergeGroups(trainGroups), mergeGroups(valGroups), mergeGroups(testGroups)
}

func (ds *DataSplitter) temporalSplit(dataset *Dataset) (train, val, test *Dataset) {
// Временной сплит: старые данные для обучения, новые для тестирования
sorted := sortByTimestamp(dataset)

total := len(sorted.Samples)
testSize := int(float64(total) * ds.testRatio)
valSize := int(float64(total) * ds.valRatio)

test.Samples = sorted.Samples[:testSize]
val.Samples = sorted.Samples[testSize : testSize+valSize]
train.Samples = sorted.Samples[testSize+valSize:]

return
}

SQL для создания сплитов:

-- Создание сплитов с группировкой по user_id
WITH user_splits AS (
SELECT
user_id,
NTILE(10) OVER (ORDER BY user_id) as bucket
FROM (SELECT DISTINCT user_id FROM advertisements) u
),
split_assignment AS (
SELECT
mf.media_id,
mr.decision,
us.bucket,
CASE
WHEN us.bucket <= 7 THEN 'train'
WHEN us.bucket <= 8 THEN 'validation'
ELSE 'test'
END as split
FROM media_files mf
JOIN moderation_results mr ON mf.media_id = mr.media_id
JOIN advertisements a ON mf.ad_id = a.ad_id
JOIN user_splits us ON a.user_id = us.user_id
WHERE mr.moderated_by != 'auto'
)
SELECT
split,
decision,
COUNT(*) as count
FROM split_assignment
GROUP BY split, decision
ORDER BY split, decision;

Рекомендуемые размеры выборок:

СплитМинимальный размерОптимальный размерСоотношение
Train5,00050,000+80%
Validation5005,000+10%
Test5005,000+10%

Требования к качеству данных:

  1. Inter-annotator agreement > 0.8 (Cohen's Kappa)
  2. Дубликаты < 1% (по file_hash)
  3. Временной диапазон — не более 6 месяцев для актуальности
  4. Покрытие категорий — все основные категории объявлений представлены
  5. Баланс аннотаторов — каждый аннотатор разметил > 100 примеров

Focal Loss для несбалансированных данных:

import torch
import torch.nn as nn
import torch.nn.functional as F

class FocalLoss(nn.Module):
def __init__(self, alpha=0.25, gamma=2.0):
super().__init__()
self.alpha = alpha
self.gamma = gamma

def forward(self, inputs, targets):
BCE_loss = F.binary_cross_entropy_with_logits(inputs, targets, reduction='none')
pt = torch.exp(-BCE_loss)
focal_loss = self.alpha * (1 - pt) ** self.gamma * BCE_loss
return focal_loss.mean()

Итоговый процесс подготовки данных:

  1. Сбор исторических данных ручной модерации
  2. Фильтрация по качеству (только ручная модерация, актуальные данные)
  3. Проверка inter-annotator agreement
  4. Удаление дубликатов по file_hash
  5. Аугментация миноритарного класса (oversampling)
  6. Сплит с группировкой по user_id
  7. Сохранение метаданных о сплите для воспроизводимости

Вопрос 8. Как выглядит архитектура пайплайна инференса модели модерации видео на проде?

Таймкод: 01:25:23

Ответ собеседника: Правильный. Кандидат описал следующий пайплайн: 1) Пользователь загружает видео в сервис модерации. 2) Видео разбивается на кадры с помощью сервиса сегментации. 3) Список кадров подаётся в модель для получения эмбеддингов. 4) Классификационная голова выдаёт скоры для каждого кадра. 5) Агрегация скоров — max pooling. 6) Итоговый скор поступает в роутер: <0.4 — автопубликация, 0.4–0.95 — ручная модерация, >0.95 — автобан.

Правильный ответ:

Архитектура пайплайна инференса

Общая схема системы:

┌─────────────┐ ┌──────────────┐ ┌─────────────┐ ┌──────────────┐
│ Upload │────▶│ Preprocess │────▶│ ML Models │────▶│ Router │
│ Service │ │ Pipeline │ │ Ensemble │ │ Decision │
└─────────────┘ └──────────────┘ └─────────────┘ └──────────────┘
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌─────────────┐ ┌──────────────┐
│ Frame │ │ Visual │ │ Auto │
│ Extraction │ │ Scoring │ │ Publish │
└──────────────┘ └─────────────┘ ├──────────────┤
│ │ │ Manual │
▼ ▼ │ Review │
┌──────────────┐ ┌─────────────┐ ├──────────────┤
│ Audio │ │ Audio │ │ Auto │
│ Extraction │ │ Scoring │ │ Reject │
└──────────────┘ └─────────────┘ └──────────────┘

Реализация на Go:

package inference

import (
"context"
"sync"
"time"
)

// ModerationPipeline — основной пайплайн модерации
type ModerationPipeline struct {
preprocessor *Preprocessor
visualModel *VisualModel
audioModel *AudioModel
contactDetector *ContactDetector
router *DecisionRouter
notifier *Notifier
metrics *MetricsCollector
}

type ModerationRequest struct {
VideoID string
AdID string
UserID string
FileURL string
FileHash string
Timestamp time.Time
}

type ModerationResponse struct {
VideoID string
Decision ModerationDecision
VisualScore float64
AudioScore float64
ContactScore float64
CombinedScore float64
Violations []Violation
ProcessingTime time.Duration
}

func (p *ModerationPipeline) Process(ctx context.Context, req ModerationRequest) (*ModerationResponse, error) {
startTime := time.Now()

// Шаг 1: Предобработка видео
preprocessed, err := p.preprocessor.Process(ctx, req)
if err != nil {
return nil, fmt.Errorf("preprocessing failed: %w", err)
}

// Шаг 2: Параллельный анализ визуальной и аудио модальностей
var wg sync.WaitGroup
var visualResult *VisualResult
var audioResult *AudioResult
var contactResult *ContactResult
var visualErr, audioErr, contactErr error

wg.Add(3)

go func() {
defer wg.Done()
visualResult, visualErr = p.visualModel.Analyze(ctx, preprocessed.Frames)
}()

go func() {
defer wg.Done()
audioResult, audioErr = p.audioModel.Analyze(ctx, preprocessed.Audio)
}()

go func() {
defer wg.Done()
contactResult, contactErr = p.contactDetector.Detect(ctx, preprocessed.Frames, preprocessed.Audio)
}()

wg.Wait()

// Проверка ошибок
if visualErr != nil {
return nil, fmt.Errorf("visual analysis failed: %w", visualErr)
}
if audioErr != nil {
return nil, fmt.Errorf("audio analysis failed: %w", audioErr)
}
if contactErr != nil {
return nil, fmt.Errorf("contact detection failed: %w", contactErr)
}

// Шаг 3: Комбинирование скоров
combinedScore := p.combineScores(visualResult.Score, audioResult.Score, contactResult.Score)

// Шаг 4: Принятие решения
decision := p.router.Decide(DecisionInput{
CombinedScore: combinedScore,
VisualScore: visualResult.Score,
AudioScore: audioResult.Score,
ContactScore: contactResult.Score,
Violations: mergeViolations(visualResult.Violations, audioResult.Violations, contactResult.Violations),
})

response := &ModerationResponse{
VideoID: req.VideoID,
Decision: decision,
VisualScore: visualResult.Score,
AudioScore: audioResult.Score,
ContactScore: contactResult.Score,
CombinedScore: combinedScore,
Violations: mergeViolations(visualResult.Violations, audioResult.Violations, contactResult.Violations),
ProcessingTime: time.Since(startTime),
}

// Шаг 5: Уведомление пользователя
p.notifier.Notify(ctx, req.UserID, response)

// Шаг 6: Сбор метрик
p.metrics.Record(response)

return response, nil
}

func (p *ModerationPipeline) combineScores(visual, audio, contact float64) float64 {
// Взвешенная комбинация скоров
// Контактная информация имеет наивысший приоритет
weights := map[string]float64{
"visual": 0.4,
"audio": 0.3,
"contact": 0.3,
}

return visual*weights["visual"] + audio*weights["audio"] + contact*weights["contact"]
}

Предобработка видео:

package preprocessing

type Preprocessor struct {
frameExtractor *FrameExtractor
audioExtractor *AudioExtractor
videoDecoder *VideoDecoder
}

type PreprocessedVideo struct {
Frames []Frame
Audio *AudioTrack
Metadata *VideoMetadata
}

func (p *Preprocessor) Process(ctx context.Context, req ModerationRequest) (*PreprocessedVideo, error) {
// Шаг 1: Скачивание видео
videoPath, err := p.downloadVideo(ctx, req.FileURL)
if err != nil {
return nil, fmt.Errorf("download failed: %w", err)
}

// Шаг 2: Извлечение метаданных
metadata, err := p.videoDecoder.GetMetadata(videoPath)
if err != nil {
return nil, fmt.Errorf("metadata extraction failed: %w", err)
}

// Шаг 3: Извлечение кадров
frames, err := p.frameExtractor.Extract(ctx, videoPath, ExtractionConfig{
MaxFrames: 10,
Strategy: SceneBasedSampling,
MinSceneChange: 0.3,
TargetWidth: 224,
TargetHeight: 224,
})
if err != nil {
return nil, fmt.Errorf("frame extraction failed: %w", err)
}

// Шаг 4: Извлечение аудио
audio, err := p.audioExtractor.Extract(ctx, videoPath, AudioConfig{
SampleRate: 16000,
Format: "wav",
})
if err != nil {
return nil, fmt.Errorf("audio extraction failed: %w", err)
}

return &PreprocessedVideo{
Frames: frames,
Audio: audio,
Metadata: metadata,
}, nil
}

type FrameExtractor struct {
ffmpegPath string
}

func (fe *FrameExtractor) Extract(ctx context.Context, videoPath string, config ExtractionConfig) ([]Frame, error) {
// Используем FFmpeg для извлечения кадров
// ffmpeg -i input.mp4 -vf "select=gt(scene\,0.3)" -vsync vfr frame_%04d.png

cmd := exec.CommandContext(ctx, fe.ffmpegPath,
"-i", videoPath,
"-vf", fmt.Sprintf("select=gt(scene\\,%f)", config.MinSceneChange),
"-vsync", "vfr",
"-frames:v", fmt.Sprintf("%d", config.MaxFrames),
filepath.Join(outputDir, "frame_%04d.png"),
)

if err := cmd.Run(); err != nil {
return nil, err
}

return loadFrames(outputDir), nil
}

ML модели для инференса:

package models

type VisualModel struct {
embedder *ImageEmbedder
classifier *ContentClassifier
}

type VisualResult struct {
Score float64
Violations []Violation
Embeddings [][]float32
}

func (m *VisualModel) Analyze(ctx context.Context, frames []Frame) (*VisualResult, error) {
var maxScore float64
var violations []Violation

for i, frame := range frames {
// Получение эмбеддинга через CLIP
embedding, err := m.embedder.Encode(ctx, frame)
if err != nil {
return nil, fmt.Errorf("embedding failed for frame %d: %w", i, err)
}

// Классификация
score, err := m.classifier.Predict(ctx, embedding)
if err != nil {
return nil, fmt.Errorf("classification failed for frame %d: %w", i, err)
}

// Max pooling — берём максимальный скор
if score > maxScore {
maxScore = score
}

// Если скор высокий, добавляем информацию о нарушении
if score > 0.5 {
violations = append(violations, Violation{
Type: ProhibitedContent,
Confidence: score,
Timestamp: frame.Timestamp,
FrameIndex: i,
})
}
}

return &VisualResult{
Score: maxScore,
Violations: violations,
}, nil
}

type AudioModel struct {
asrEngine *ASREngine
textClassifier *TextClassifier
}

type AudioResult struct {
Score float64
Violations []Violation
Transcript string
}

func (m *AudioModel) Analyze(ctx context.Context, audio *AudioTrack) (*AudioResult, error) {
// Speech-to-Text
transcript, err := m.asrEngine.Transcribe(ctx, audio)
if err != nil {
return nil, fmt.Errorf("transcription failed: %w", err)
}

// Классификация текста
score, err := m.textClassifier.Classify(ctx, transcript.Text)
if err != nil {
return nil, fmt.Errorf("text classification failed: %w", err)
}

violations := []Violation{}
if score > 0.5 {
violations = append(violations, Violation{
Type: ProhibitedContent,
Confidence: score,
Text: transcript.Text,
})
}

return &AudioResult{
Score: score,
Violations: violations,
Transcript: transcript.Text,
}, nil
}

Роутер решений:

package router

type DecisionRouter struct {
thresholds ThresholdConfig
userScorer *UserScorer
}

type ThresholdConfig struct {
AutoRejectAbove float64
ManualReviewAbove float64
}

type DecisionInput struct {
CombinedScore float64
VisualScore float64
AudioScore float64
ContactScore float64
Violations []Violation
UserTrust float64
}

func (r *DecisionRouter) Decide(input DecisionInput) ModerationDecision {
// Учитываем trust score пользователя
adjustedScore := r.adjustForUserTrust(input.CombinedScore, input.UserTrust)

// Контактная информация — всегда высокий приоритет
if input.ContactScore > 0.8 {
return AutoReject
}

switch {
case adjustedScore >= r.thresholds.AutoRejectAbove:
return AutoReject
case adjustedScore >= r.thresholds.ManualReviewAbove:
return ManualReview
default:
return AutoApprove
}
}

func (r *DecisionRouter) adjustForUserTrust(score, trustScore float64) float64 {
// Для доверенных пользователей повышаем порог автобана
trustAdjustment := (trustScore - 0.5) * 0.1
return score - trustAdjustment
}

Инфраструктура для инференса:

package infrastructure

type GPUPool struct {
workers chan *GPUWorker
}

type GPUWorker struct {
id int
model Model
memUsed int64
memTotal int64
}

func (p *GPUPool) Acquire(ctx context.Context) (*GPUWorker, error) {
select {
case worker := <-p.workers:
return worker, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}

func (p *GPUPool) Release(worker *GPUWorker) {
p.workers <- worker
}

type InferenceServer struct {
gpuPool *GPUPool
pipeline *ModerationPipeline
queue *TaskQueue
maxWorkers int
}

func (s *InferenceServer) Start(ctx context.Context) error {
for i := 0; i < s.maxWorkers; i++ {
go s.worker(ctx, i)
}
return nil
}

func (s *InferenceServer) worker(ctx context.Context, id int) {
for {
select {
case <-ctx.Done():
return
case task := <-s.queue.Tasks():
gpuWorker, err := s.gpuPool.Acquire(ctx)
if err != nil {
task.ResultChan() <- Result{Error: err}
continue
}

result, err := s.pipeline.Process(ctx, task.Request())
s.gpuPool.Release(gpuWorker)

task.ResultChan() <- Result{
Response: result,
Error: err,
}
}
}
}

Обработка ошибок и fallback:

func (p *ModerationPipeline) ProcessWithFallback(ctx context.Context, req ModerationRequest) (*ModerationResponse, error) {
result, err := p.Process(ctx, req)

if err != nil {
// Логируем ошибку
log.Error().Err(err).Str("video_id", req.VideoID).Msg("moderation failed")

// Fallback: отправляем на ручную модерацию
return &ModerationResponse{
VideoID: req.VideoID,
Decision: ManualReview,
Violations: []Violation{{
Type: ProcessingError,
Text: err.Error(),
}},
}, nil
}

return result, nil
}

Мониторинг и метрики:

type MetricsCollector struct {
processingTime *prometheus.HistogramVec
decisionCounter *prometheus.CounterVec
errorCounter *prometheus.CounterVec
}

func (m *MetricsCollector) Record(response *ModerationResponse) {
m.processingTime.WithLabelValues(
response.Decision.String(),
).Observe(response.ProcessingTime.Seconds())

m.decisionCounter.WithLabelValues(
response.Decision.String(),
).Inc()
}

Ключевые характеристики пайплайна:

КомпонентВремя обработкиПараллелизм
Предобработка5-15 секCPU-bound
Визуальный анализ10-60 секGPU-bound
Аудио анализ5-30 секGPU-bound
Детекция контактов5-20 секGPU-bound
Роутер<1 секCPU-bound
Итого30 сек - 2 мин

Вопрос 9. Как мониторить модель в продакшене? Какие технические метрики и бизнес-метрики отслеживать? Как реагировать на деградацию качества?

Таймкод: 01:34:04

Ответ собеседника: Правильный. Кандидат предложил следующий план мониторинга: 1) Мониторинг распределения вероятностей модели для обнаружения дрифта. 2) Обновление датасета через ошибки модели. 3) Алерты на ключевые бизнес-метрики. 4) Технические метрики: latency, uptime, стоимость обработки.

Правильный ответ:

Архитектура мониторинга

Общая схема системы мониторинга:

┌─────────────────────────────────────────────────────────────────┐
│ Monitoring System │
├─────────────────┬───────────────────┬───────────────────────────┤
│ Technical │ ML Metrics │ Business Metrics │
│ Metrics │ │ │
│ - Latency │ - Score drift │ - Appeal rate │
│ - Throughput │ - Distribution │ - Manual review rate │
│ - Error rate │ - Feature drift │ - Missed violations │
│ - GPU usage │ - Prediction conf │ - User retention │
└─────────────────┴───────────────────┴───────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ Alerting System │
│ - PagerDuty / Telegram для критических │
│ - Email для предупреждений │
│ - Dashboard для визуализации │
└─────────────────────────────────────────────────────────────────┘

Технические метрики

package monitoring

import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)

type TechnicalMetrics struct {
// Latency метрики
processingDuration *prometheus.HistogramVec

// Throughput метрики
requestsTotal *prometheus.CounterVec
requestsInProgress prometheus.Gauge

// Error метрики
errorsTotal *prometheus.CounterVec

// Resource метрики
gpuUtilization prometheus.Gauge
gpuMemoryUsage prometheus.Gauge
cpuUsage prometheus.Gauge
memoryUsage prometheus.Gauge

// Queue метрики
queueDepth prometheus.Gauge
queueWaitTime prometheus.Histogram
}

func NewTechnicalMetrics() *TechnicalMetrics {
return &TechnicalMetrics{
processingDuration: promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "moderation_processing_duration_seconds",
Help: "Time spent processing video moderation",
Buckets: prometheus.ExponentialBuckets(0.1, 2, 15), // 0.1s to ~3000s
},
[]string{"model_type", "decision"},
),

requestsTotal: promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "moderation_requests_total",
Help: "Total number of moderation requests",
},
[]string{"status", "model_type"},
),

requestsInProgress: promauto.NewGauge(
prometheus.GaugeOpts{
Name: "moderation_requests_in_progress",
Help: "Number of moderation requests currently being processed",
},
),

errorsTotal: promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "moderation_errors_total",
Help: "Total number of moderation errors",
},
[]string{"error_type"},
),

gpuUtilization: promauto.NewGauge(
prometheus.GaugeOpts{
Name: "moderation_gpu_utilization_percent",
Help: "GPU utilization percentage",
},
),

gpuMemoryUsage: promauto.NewGauge(
prometheus.GaugeOpts{
Name: "moderation_gpu_memory_bytes",
Help: "GPU memory usage in bytes",
},
),

queueDepth: promauto.NewGauge(
prometheus.GaugeOpts{
Name: "moderation_queue_depth",
Help: "Number of videos waiting in queue",
},
),
}
}

func (m *TechnicalMetrics) RecordProcessing(modelType string, decision string, duration float64) {
m.processingDuration.WithLabelValues(modelType, decision).Observe(duration)
}

func (m *TechnicalMetrics) RecordRequest(status, modelType string) {
m.requestsTotal.WithLabelValues(status, modelType).Inc()
}

func (m *TechnicalMetrics) RecordError(errorType string) {
m.errorsTotal.WithLabelValues(errorType).Inc()
}

func (m *TechnicalMetrics) UpdateGPUUtilization(utilization float64) {
m.gpuUtilization.Set(utilization)
}

func (m *TechnicalMetrics) UpdateQueueDepth(depth float64) {
m.queueDepth.Set(depth)
}

ML метрики мониторинга

package monitoring

import (
"math"
)

type MLMetrics struct {
// Distribution tracking
scoreDistribution *DistributionTracker
featureDriftDetector *FeatureDriftDetector

// Prediction confidence
confidenceHistogram prometheus.Histogram

// Model performance (estimated)
estimatedPrecision prometheus.Gauge
estimatedRecall prometheus.Gauge
}

type DistributionTracker struct {
referenceDistribution []float64
windowSize int
currentWindow []float64
}

func (dt *DistributionTracker) AddScore(score float64) {
dt.currentWindow = append(dt.currentWindow, score)
if len(dt.currentWindow) > dt.windowSize {
dt.currentWindow = dt.currentWindow[1:]
}
}

func (dt *DistributionTracker) ComputeKLDivergence() float64 {
// KL-дивергенция между референсным и текущим распределением
currentDist := estimateDistribution(dt.currentWindow)
referenceDist := dt.referenceDistribution

kl := 0.0
for i := range currentDist {
if currentDist[i] > 0 && referenceDist[i] > 0 {
kl += currentDist[i] * math.Log(currentDist[i]/referenceDist[i])
}
}

return kl
}

func (dt *DistributionTracker) DetectDrift(threshold float64) bool {
klDiv := dt.ComputeKLDivergence()
return klDiv > threshold
}

type FeatureDriftDetector struct {
featureStats map[string]*FeatureStat
}

type FeatureStat struct {
Mean float64
Std float64
Count int64
}

func (fdd *FeatureDriftDetector) Update(featureName string, value float64) {
stat, exists := fdd.featureStats[featureName]
if !exists {
fdd.featureStats[featureName] = &FeatureStat{
Mean: value,
Std: 0,
Count: 1,
}
return
}

// Online update of mean and variance
stat.Count++
oldMean := stat.Mean
stat.Mean += (value - stat.Mean) / float64(stat.Count)
stat.Std = math.Sqrt(
stat.Std*stat.Std + (value-oldMean)*(value-stat.Mean),
)
}

func (fdd *FeatureDriftDetector) DetectFeatureDrift(featureName string, value float64, threshold float64) bool {
stat, exists := fdd.featureStats[featureName]
if !exists {
return false
}

// Z-score тест
zScore := math.Abs(value-stat.Mean) / stat.Std
return zScore > threshold
}

Бизнес-метрики

package monitoring

type BusinessMetrics struct {
// Appeal metrics
appealRate prometheus.Gauge
overturnRate prometheus.Gauge

// Manual review metrics
manualReviewRate prometheus.Gauge
manualPrecision prometheus.Gauge

// Quality assurance metrics
qaViolationRate prometheus.Gauge

// User impact metrics
userRetentionRate prometheus.Gauge
avgProcessingTime prometheus.Gauge

// Cost metrics
costPerVideo prometheus.Gauge
costPerHour prometheus.Gauge
}

func NewBusinessMetrics() *BusinessMetrics {
return &BusinessMetrics{
appealRate: promauto.NewGauge(
prometheus.GaugeOpts{
Name: "moderation_appeal_rate",
Help: "Rate of appeals after auto-rejection",
},
),

overturnRate: promauto.NewGauge(
prometheus.GaugeOpts{
Name: "moderation_appeal_overturn_rate",
Help: "Rate of overturned appeals (false positives)",
},
),

manualReviewRate: promauto.NewGauge(
prometheus.GaugeOpts{
Name: "moderation_manual_review_rate",
Help: "Percentage of videos sent to manual review",
},
),

manualPrecision: promauto.NewGauge(
prometheus.GaugeOpts{
Name: "moderation_manual_precision",
Help: "Precision of manual review (confirmed violations / total reviews)",
},
),

qaViolationRate: promauto.NewGauge(
prometheus.GaugeOpts{
Name: "moderation_qa_violation_rate",
Help: "Violation rate in QA sample of approved content",
},
),

costPerVideo: promauto.NewGauge(
prometheus.GaugeOpts{
Name: "moderation_cost_per_video_usd",
Help: "Cost per video processed in USD",
},
),
}
}

Система алертов

package alerting

type AlertSeverity int

const (
SeverityInfo AlertSeverity = iota
SeverityWarning
SeverityCritical
)

type Alert struct {
Name string
Severity AlertSeverity
Message string
Timestamp time.Time
Metrics map[string]float6th
Thresholds map[string]float64
}

type AlertManager struct {
rules []AlertRule
notifiers []Notifier
alertHistory []Alert
}

type AlertRule struct {
Name string
MetricName string
Condition string // "gt", "lt", "eq"
Threshold float64
Duration time.Duration
Severity AlertSeverity
Description string
}

func NewAlertManager() *AlertManager {
return &AlertManager{
rules: []AlertRule{
{
Name: "high_appeal_rate",
MetricName: "moderation_appeal_overturn_rate",
Condition: "gt",
Threshold: 0.05, // 5%
Duration: 1 * time.Hour,
Severity: SeverityCritical,
Description: "Appeal overturn rate exceeds 5%",
},
{
Name: "high_manual_review_rate",
MetricName: "moderation_manual_review_rate",
Condition: "gt",
Threshold: 0.30, // 30%
Duration: 30 * time.Minute,
Severity: SeverityWarning,
Description: "Manual review rate exceeds 30%",
},
{
Name: "low_manual_precision",
MetricName: "moderation_manual_precision",
Condition: "lt",
Threshold: 0.50, // 50%
Duration: 1 * time.Hour,
Severity: SeverityWarning,
Description: "Manual review precision below 50%",
},
{
Name: "high_qa_violation_rate",
MetricName: "moderation_qa_violation_rate",
Condition: "gt",
Threshold: 0.01, // 1%
Duration: 2 * time.Hour,
Severity: SeverityCritical,
Description: "QA violation rate exceeds 1%",
},
{
Name: "high_latency_p95",
MetricName: "moderation_processing_duration_seconds_p95",
Condition: "gt",
Threshold: 120, // 2 minutes
Duration: 15 * time.Minute,
Severity: SeverityWarning,
Description: "P95 latency exceeds 2 minutes",
},
{
Name: "score_distribution_drift",
MetricName: "moderation_score_kl_divergence",
Condition: "gt",
Threshold: 0.1,
Duration: 4 * time.Hour,
Severity: SeverityWarning,
Description: "Score distribution drift detected",
},
{
Name: "service_unavailable",
MetricName: "moderation_requests_success_rate",
Condition: "lt",
Threshold: 0.95, // 95%
Duration: 5 * time.Minute,
Severity: SeverityCritical,
Description: "Service availability below 95%",
},
},
notifiers: []Notifier{
NewPagerDutyNotifier(),
NewTelegramNotifier(),
NewEmailNotifier(),
},
}
}

func (am *AlertManager) Evaluate(metrics map[string]float64) []Alert {
var triggeredAlerts []Alert

for _, rule := range am.rules {
value, exists := metrics[rule.MetricName]
if !exists {
continue
}

triggered := false
switch rule.Condition {
case "gt":
triggered = value > rule.Threshold
case "lt":
triggered = value < rule.Threshold
case "eq":
triggered = value == rule.Threshold
}

if triggered {
alert := Alert{
Name: rule.Name,
Severity: rule.Severity,
Message: rule.Description,
Timestamp: time.Now(),
Metrics: map[string]float64{rule.MetricName: value},
Thresholds: map[string]float64{"threshold": rule.Threshold},
}
triggeredAlerts = append(triggeredAlerts, alert)
}
}

return triggeredAlerts
}

func (am *AlertManager) Notify(alerts []Alert) {
for _, alert := range alerts {
for _, notifier := range am.notifiers {
if notifier.ShouldNotify(alert.Severity) {
notifier.Notify(alert)
}
}
}
}

Реагирование на деградацию

package degradation

type DegradationType int

const (
DataDrift DegradationType = iota
ConceptDrift
InfrastructureIssue
ModelBug
)

type DegradationHandler struct {
alertManager *alerting.AlertManager
modelManager *ModelManager
fallbackEnabled bool
}

func (dh *DegradationHandler) Handle(ctx context.Context, degradation DegradationType, metrics map[string]float64) error {
switch degradation {
case DataDrift:
return dh.handleDataDrift(ctx, metrics)
case ConceptDrift:
return dh.handleConceptDrift(ctx, metrics)
case InfrastructureIssue:
return dh.handleInfrastructureIssue(ctx)
case ModelBug:
return dh.handleModelBug(ctx)
default:
return fmt.Errorf("unknown degradation type: %v", degradation)
}
}

func (dh *DegradationHandler) handleDataDrift(ctx context.Context, metrics map[string]float64) error {
// Плавная деградация из-за дрифта данных
log.Warn().Msg("Data drift detected, triggering model retraining")

// Шаг 1: Включить fallback на более консеративные пороги
dh.modelManager.AdjustThresholds(ThresholdAdjustment{
AutoRejectMultiplier: 0.9, // Снизить порог автобана
ManualReviewMultiplier: 1.1, // Повысить порог ручной модерации
})

// Шаг 2: Запустить переобучение модели
go dh.modelManager.TriggerRetraining(RetrainingConfig{
UseRecentData: true,
LookbackDays: 30,
})

return nil
}

func (dh *DegradationHandler) handleConceptDrift(ctx context.Context, metrics map[string]float64) error {
// Новые паттерны нарушений
log.Warn().Msg("Concept drift detected")

// Шаг 1: Увеличить выборку для QA
dh.modelManager.InQASampleRate(2.0) // Удвоить размер выборки

// Шаг 2: Запустить анализ новых паттернов
go dh.modelManager.AnalyzeNewPatterns()

return nil
}

func (dh *DegradationHandler) handleInfrastructureIssue(ctx context.Context) error {
// Резкое падение — техническая проблема
log.Error().Msg("Infrastructure issue detected")

// Шаг 1: Переключить всю нагрузку на ручную модерацию
dh.fallbackEnabled = true
dh.modelManager.SetFallbackMode(FallbackMode{
Decision: ManualReview,
Reason: "Infrastructure issue",
})

// Шаг 2: Оповестиить on-call инженера
dh.alertManager.Notify([]alerting.Alert{{
Name: "critical_infrastructure_issue",
Severity: alerting.SeverityCritical,
Message: "Infrastructure issue detected, fallback to manual review",
}})

return nil
}

func (dh *DegradationHandler) handleModelBug(ctx context.Context) error {
// Баг в модели после деплоя
log.Error().Msg("Model bug detected")

// Шаг 1: Откатить модель на предыдущую версию
dh.modelManager.Rollback()

// Шаг 2: Уведомить команду
dh.alertManager.Notify([]alerting.Alert{{
Name: "model_bug_detected",
Severity: alerting.SeverityCritical,
Message: "Model rolled back due to bug",
}})

return nil
}

Дашборд мониторинга (Grafana)

{
"dashboard": {
"title": "Video Moderation Monitoring",
"panels": [
{
"title": "Processing Latency",
"targets": [
{
"expr": "histogram_quantile(0.50, rate(moderation_processing_duration_seconds_bucket[5m]))",
"legendFormat": "P50"
},
{
"expr": "histogram_quantile(0.95, rate(moderation_processing_duration_seconds_bucket[5m]))",
"legendFormat": "P95"
},
{
"expr": "histogram_quantile(0.99, rate(moderation_processing_duration_seconds_bucket[5m]))",
"legendFormat": "P99"
}
]
},
{
"title": "Decision Distribution",
"targets": [
{
"expr": "rate(moderation_requests_total[5m])",
"legendFormat": "{{decision}}"
}
]
},
{
"title": "Appeal Overturn Rate",
"targets": [
{
"expr": "moderation_appeal_overturn_rate",
"legendFormat": "Overturn Rate"
}
],
"alert": {
"conditions": [
{
"evaluator": {"type": "gt", "params": [0.05]},
"operator": {"type": "and"},
"reducer": {"type": "avg"}
}
]
}
},
{
"title": "Score Distribution Drift (KL Divergence)",
"targets": [
{
"expr": "moderation_score_kl_divergence",
"legendFormat": "KL Divergence"
}
]
},
{
"title": "GPU Utilization",
"targets": [
{
"expr": "moderation_gpu_utilization_percent",
"legendFormat": "GPU {{gpu_id}}"
}
]
},
{
"title": "Cost per Video",
"targets": [
{
"expr": "moderation_cost_per_video_usd",
"legendFormat": "USD per video"
}
]
}
]
}
}

Матрица реагирования на инциденты:

ИнцидентПризнакиДействияSLA
Дрифт данныхKL-div > 0.1, плавный рост appeal rateКорректировка порогов + переобучение4 часа
Новые паттерныРост QA violation rateУвеличение QA выборки + анализ2 часа
Инфраструктурный сбойLatency > 5 min, Error rate > 5%Fallback на ручную модерацию15 минут
Баг моделиРезкий рост FP/FNОткат модели30 минут

Вопрос 10. Обсуждение вопросов от зрителей: использование LLM для мультимодальной модерации видео/аудио, генерация синтетики для обучения модели, уточнение DAU и выбор между ручной и автоматической модерацией, размещение видео на внешних хостингах, оценка доверительных интервалов через бутстрап и оптимальная последовательность рассказа на собеседовании.

Таймкод: 01:55:23

Ответ собеседника: Правильный. По LLM: кандидат отметил зависимость от размера модели и доступных ресурсов/таймингов. По синтетике: подходит для холодного старта, но на постоянной основе может привести к деградации из-за расхождения распределений. По DAU: согласился, что нужно уточнять, и при маленьком DAU ручная модерация может быть оптимальнее. По внешним хостингам: интервьюер пояснил зависимость от сторонних правил. По бутстрапу: кандидат подтвердил, что это хороший метод для оценки доверительных интервалов.

Правильный ответ:

LLM для мультимодальной модерации

Архитектура с использованием LLM:

package llm

type LLMModerationPipeline struct {
visionEncoder *VisionEncoder
audioEncoder *AudioEncoder
llmClient LLMClient
promptBuilder *PromptBuilder
}

type ModerationPrompt struct {
VisualDescription string
AudioTranscript string
Metadata map[string]string
}

func (p *LLMModerationPipeline) Analyze(ctx context.Context, video VideoContent) (*LLMResult, error) {
// Шаг 1: Получение визуального описания
visualDesc, err := p.visionEncoder.Describe(ctx, video.Frames)
if err != nil {
return nil, fmt.Errorf("vision encoding failed: %w", err)
}

// Шаг 2: Получение аудиотранскрипта
audioTranscript, err := p.audioEncoder.Transcribe(ctx, video.Audio)
if err != nil {
return nil, fmt.Errorf("audio encoding failed: %w", err)
}

// Шаг 3: Формирование промпта
prompt := p.promptBuilder.Build(ModerationPrompt{
VisualDescription: visualDesc,
AudioTranscript: audioTranscript,
Metadata: map[string]string{
"category": video.Category,
"duration": fmt.Sprintf("%d seconds", video.Duration),
},
})

// Шаг 4: Запрос к LLM
response, err := p.llmClient.Complete(ctx, prompt)
if err != nil {
return nil, fmt.Errorf("LLM request failed: %w", err)
}

// Шаг 5: Парсинг результата
return p.parseResponse(response)
}

Сравнение подходов:

ПодходLatencyCostQualityScalability
CLIP + классификатор10-30 сек$0.01-0.05ХорошееВысокая
GPT-4V30-120 сек$0.05-0.20ОтличноеСредняя
LLaVA20-60 сек$0.02-0.10ХорошееСредняя
Gemini20-90 сек$0.03-0.15ОтличноеСредняя

Рекомендация: Для продакшена лучше использовать специализированные модели (CLIP + классификатор) из-за скорости и стоимости. LLM — для сложных случаев и ручной модерации.

Генерация синтетических данных

package synthetic

type SyntheticDataGenerator struct {
llmClient LLMClient
imageGenClient ImageGenClient
audioGenClient AudioGenClient
}

type SyntheticConfig struct {
Count int
ViolationTypes []ViolationType
RealismLevel float64 // 0.0 - 1.0
DiversityFactor float64
}

func (sdg *SyntheticDataGenerator) Generate(config SyntheticConfig) (*SyntheticDataset, error) {
var samples []SyntheticSample

for _, vType := range config.ViolationTypes {
// Генерация текстовых описаний нарушений
descriptions, err := sdg.generateDescriptions(vType, config.Count)
if err != nil {
return nil, err
}

// Генерация соответствующих изображений/видео
for _, desc := range descriptions {
image, err := sdg.imageGenClient.Generate(desc.ImagePrompt)
if err != nil {
continue
}

audio, err := sdg.audioGenClient.Generate(desc.AudioPrompt)
if err != nil {
continue
}

samples = append(samples, SyntheticSample{
Image: image,
Audio: audio,
Description: desc,
Label: vType,
IsSynthetic: true,
})
}
}

return &SyntheticDataset{Samples: samples}, nil
}

Проблемы синтетических данных:

ПроблемаОписаниеРешение
Distribution shiftСинтетика отличается от реальных данныхДоменная адаптация, fine-tuning на реальных
Mode collapseОграниченное разнообразиеУвеличение diversity factor
Annotation noiseНеточные меткиФильтрация через ручную проверку
Concept driftМодель переобучается на синтетикуОграничение доли синтетики в трейне

Рекомендуемое соотношение:

type DatasetMix struct {
RealSamples int
SyntheticSamples int
}

func optimalMix(totalBudget int) DatasetMix {
// Максимум 30% синтетики для предотвращения деградации
maxSynthetic := int(float64(totalBudget) * 0.3)

return DatasetMix{
RealSamples: totalBudget - maxSynthetic,
SyntheticSamples: maxSynthetic,
}
}

Выбор между ручной и автоматической модерацией

Формула принятия решения:

package decision

type ModerationStrategy int

const (
ManualOnly ModerationStrategy = iota
AutomaticWithManualFallback
FullAutomatic
)

type StrategySelector struct {
dau int
avgVideoDuration time.Duration
violationRate float64
manualCost float64 // Cost per manual review
autoCost float64 // Cost per automatic review
gpuBudget float64
}

func (ss *StrategySelector) SelectStrategy() ModerationStrategy {
// Расчёт нагрузки
dailyVideos := ss.dau * 5 // Предположим 5 видео на пользователя в день
dailyProcessingMinutes := dailyVideos * int(ss.avgVideoDuration.Minutes())

// Расчёт стоимости
manualTotalCost := float64(dailyVideos) * ss.manualCost
autoTotalCost := float64(dailyVideos)*ss.autoCost +
float64(dailyProcessingMinutes)*0.01 // GPU cost

// Пороги принятия решения
switch {
case dailyVideos < 100:
// Малый DAU — ручная модерация дешевле
return ManualOnly

case dailyVideos < 10000 && ss.gpuBudget < manualTotalCost:
// Средний DAU — автоматическая с fallback
return AutomaticWithManualFallback

default:
// Большой DAU — полная автоматизация
return FullAutomatic
}
}

func (ss *StrategySelector) EstimateROI() ROIAnalysis {
manualCost := float64(ss.dau) * ss.manualCost * 30 // Monthly
autoCost := float64(ss.dau) * ss.autoCost * 30 + ss.gpuBudget

return ROIAnalysis{
MonthlyManualCost: manualCost,
MonthlyAutoCost: autoCost,
MonthlySavings: manualCost - autoCost,
PaybackPeriod: ss.gpuBudget / (manualCost - autoCost), // months
}
}

Таблица принятия решения по DAU:

DAUВидео/деньРекомендацияОбоснование
< 1K< 5KРучная модерацияGPU дороже модераторов
1K-50K5K-250KАвтоматическая + fallbackОптимальный баланс
> 50K> 250KПолная автоматизацияМасштаб оправдывает инвестиции

Размещение видео на внешних хостингах

package external

type ExternalHostingConfig struct {
Provider string // "youtube", "vimeo", "s3"
APIKey string
Bucket string
Region string
}

type ExternalHostingManager struct {
providers map[string]HostingProvider
}

func (ehm *ExternalHostingManager) Upload(ctx context.Context, video VideoContent, config ExternalHostingConfig) (string, error) {
provider, ok := ehm.providers[config.Provider]
if !ok {
return "", fmt.Errorf("unknown provider: %s", config.Provider)
}

// Загрузка на внешний хостинг
url, err := provider.Upload(ctx, video, config)
if err != nil {
return "", err
}

return url, nil
}

// Проблемы внешних хостингов:
// 1. Зависимость от правил сторонней платформы
// 2. Риск блокировки аккаунта
// 3. Ограничения на размер и формат
// 4. Дополнительные затраты на API

Оценка доверительных интервалов через бутстрап

package statistics

import (
"math/rand"
"sort"
)

type BootstrapEstimator struct {
numBootstrap int
confidence float64
}

func NewBootstrapEstimator(numBootstrap int, confidence float64) *BootstrapEstimator {
return &BootstrapEstimator{
numBootstrap: numBootstrap,
confidence: confidence,
}
}

// EstimateConfidenceInterval — оценка доверительного интервала для метрики
func (be *BootstrapEstimator) EstimateConfidenceInterval(
data []float64,
metricFunc func([]float64) float64,
) (lower, upper float64) {
bootstrapMetrics := make([]float64, be.numBootstrap)

for i := 0; i < be.numBootstrap; i++ {
// Случайная выборка с возвращением
sample := bootstrapSample(data, len(data))
bootstrapMetrics[i] = metricFunc(sample)
}

sort.Float64s(bootstrapMetrics)

alpha := 1 - be.confidence
lowerIdx := int(float64(be.numBootstrap) * alpha / 2)
upperIdx := int(float64(be.numBootstrap) * (1 - alpha/2))

return bootstrapMetrics[lowerIdx], bootstrapMetrics[upperIdx]
}

func bootstrapSample(data []float64, size int) []float64 {
sample := make([]float64, size)
for i := 0; i < size; i++ {
idx := rand.Intn(len(data))
sample[i] = data[idx]
}
return sample
}

// Пример использования для оценки precision
func EstimatePrecisionInterval(
predictions []int,
labels []int,
confidence float64,
) (float64, float64) {
be := NewBootstrapEstimator(1000, confidence)

data := make([]float64, len(predictions))
for i := range predictions {
data[i] = float64(predictions[i])
}

metricFunc := func(sample []float64) float64 {
// Расчёт precision для подвыборки
tp, fp := 0, 0
for _, pred := range sample {
if pred == 1 {
tp++
} else {
fp++
}
}
if tp+fp == 0 {
return 0
}
return float64(tp) / float64(tp+fp)
}

return be.EstimateConfidenceInterval(data, metricFunc)
}

Оптимальная структура рассказа на собеседовании

package interview

type InterviewStructure struct {
Phases []Phase
}

type Phase struct {
Name string
Duration time.Duration
KeyPoints []string
}

func OptimalInterviewStructure() InterviewStructure {
return InterviewStructure{
Phases: []Phase{
{
Name: "Проблема и контекст",
Duration: 5 * time.Minute,
KeyPoints: []string{
"Бизнес-проблема (утечка контактов, запрещённый контент)",
"Масштаб (DAU, объём видео, нагрузка)",
"Текущее решение (ручная модерация)",
"Боли (стоимость, время, качество)",
},
},
{
Name: "Метрики успеха",
Duration: 3 * time.Minute,
KeyPoints: []string{
"Бизнес-метрики (LTV, GMV, конверсия)",
"ML-метрики (Precision, Recall, F1)",
"Технические метрики (latency, throughput)",
"Целевые значения и обоснование",
},
},
{
Name: "Данные",
Duration: 5 * time.Minute,
KeyPoints: []string{
"Источники данных (история модерации, открытые датасеты)",
"Сущности (User, Ad, MediaFile, ModerationResult)",
"Признаки (trust score, account age, category)",
"Баланс классов и стратегия сплита",
},
},
{
Name: "ML-решение",
Duration: 7 * time.Minute,
KeyPoints: []string{
"Декомпозиция задачи (визуальная + аудио модальности)",
"Архитектура моделей (CLIP + классификатор)",
"Пайплайн обработки (фреймы → эмбеддинги → скоры → агрегация)",
"Пороги принятия решений (автобан, ручная модерация, автопубликация)",
},
},
{
Name: "Инфраструктура",
Duration: 5 * time.Minute,
KeyPoints: []string{
"GPU-ресурсы (A100, T4, количество)",
"Очереди и параллелизм",
"Graceful degradation и fallback",
"Мониторинг и алерты",
},
},
{
Name: "Оценка и улучшения",
Duration: 5 * time.Minute,
KeyPoints: []string{
"Оффлайн-метрики (PR-AUC, F1)",
"Онлайн-метрики (appeal rate, manual precision)",
"A/B тестирование",
"План итераций (новые фичи, переобучение)",
},
},
},
}
}

Ключевые принципов рассказа:

  1. От общего к частному — сначала бизнес-проблема, потом технические детали
  2. Метрики в первую очередь — всегда привязывай решения к измеримым целям
  3. Trade-offs — показывай понимание компромиссов (precision vs recall, latency vs quality)
  4. Практичность — предлагай реалистичные решения с учётом ресурсов
  5. Итеративность — показывай, что решение можно улучшать постепенно