Собеседование DS инженера в Авито: ML system design
Сегодня мы разберём собеседование на позицию 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 — уверенность модели обнаружения
Ранжирование приоритетов:
- Высокий приоритет: Утечка контактов (прямой ущерб бизнесу)
- Высокий приоритет: Запрещённый контент (юридические риски)
- Средний приоритет: Мошенничество (репутационные риски)
- Низкий приоритет: Персональные данные (требует контекста)
Пример реализации приоритизации в 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)
}
Обработка пиковых нагрузок
Стратегии для устойчивости:
- Graceful Degradation — при перегрузке отключение менее критичных моделей
- Circuit Breaker — автоматическое отключение упавших зависимостей
- Rate Limiting — ограничение нагрузки от одного пользователя
- 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,000 | S3/MinIO для видео |
| Monitoring | ~$500 | Grafana, 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 Rate | 0-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.95 | 0.70 | 99% |
| Запрещённый контент | 0.90 | 0.60 | 99.5% |
| Мошенничество | 0.95 | 0.75 | 98% |
Итоговый алгоритм выбора порога:
- Определить бизнес-стоимость ошибок FP и FN
- Задать минимально допустимый Recall (обычно 99%+)
- При фиксированном Recall максимизировать Precision
- Построить Precision-Recall кривую
- Выбрать рабочую точку совместно с бизнесом
- Регулярно перекалибровать порог на новых данных
Вопрос 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;
Рекомендуемые размеры выборок:
| Сплит | Минимальный размер | Оптимальный размер | Соотношение |
|---|---|---|---|
| Train | 5,000 | 50,000+ | 80% |
| Validation | 500 | 5,000+ | 10% |
| Test | 500 | 5,000+ | 10% |
Требования к качеству данных:
- Inter-annotator agreement > 0.8 (Cohen's Kappa)
- Дубликаты < 1% (по file_hash)
- Временной диапазон — не более 6 месяцев для актуальности
- Покрытие категорий — все основные категории объявлений представлены
- Баланс аннотаторов — каждый аннотатор разметил > 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()
Итоговый процесс подготовки данных:
- Сбор исторических данных ручной модерации
- Фильтрация по качеству (только ручная модерация, актуальные данные)
- Проверка inter-annotator agreement
- Удаление дубликатов по file_hash
- Аугментация миноритарного класса (oversampling)
- Сплит с группировкой по user_id
- Сохранение метаданных о сплите для воспроизводимости
Вопрос 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)
}
Сравнение подходов:
| Подход | Latency | Cost | Quality | Scalability |
|---|---|---|---|---|
| CLIP + классификатор | 10-30 сек | $0.01-0.05 | Хорошее | Высокая |
| GPT-4V | 30-120 сек | $0.05-0.20 | Отличное | Средняя |
| LLaVA | 20-60 сек | $0.02-0.10 | Хорошее | Средняя |
| Gemini | 20-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-50K | 5K-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 тестирование",
"План итераций (новые фичи, переобучение)",
},
},
},
}
}
Ключевые принципов рассказа:
- От общего к частному — сначала бизнес-проблема, потом технические детали
- Метрики в первую очередь — всегда привязывай решения к измеримым целям
- Trade-offs — показывай понимание компромиссов (precision vs recall, latency vs quality)
- Практичность — предлагай реалистичные решения с учётом ресурсов
- Итеративность — показывай, что решение можно улучшать постепенно
