Drift Monitoring — M1 (read) + M2 (mutation)
Theme G (#1154) → G-1 GAP #1213 의 backend 골격. 가스공사 RFP SFR-009 "정확도·안전성 + 시계열 알림" 대응.
Scope
| 영역 | M1 PoC (#1217) | M2 (본 PR) | M3 / 후속 |
|---|---|---|---|
| 드리프트 엔진 | SciPy KS test (수치형) | (동일 — service 변경 없음) | Evidently AI (PSI + KS + chi-square) |
| DB 테이블 | drift_report ✅ | (동일 — Alembic 없음, baseline 정책) | deleted_at 컬럼 + override audit 테이블 |
| API | GET list + GET detail | POST /compute + PUT + DELETE ✅ | timeseries + per-feature |
| UI | 없음 | 없음 | /admin/drift 대시보드 + iframe |
| Slack 알림 | 없음 | 없음 (notified_at 컬럼은 미사용) | breach 시 발송 + notified_at 갱신 |
| Batch 통합 | 없음 | 없음 | ml/batch_predict_asset.py drift_check=True |
| MLflow REST + Iceberg fetch | 없음 | stub (empty df → ok row) | 실 데이터 fetch |
M2 에 포함되지 않은 것 (M3 작업)
- ❌ Evidently AI 의존성 — 이미지 사이즈 부담 + wire 안정화 우선
- ❌ MLflow REST 의 reference run artifact 다운로드 + Iceberg current window query — 본 PR 의
compute_and_save는 빈 DataFrame stub 사용 - ❌ Slack / Email 알림 발송 +
notified_at갱신 - ❌ UI 컴포넌트 (
/admin/drift페이지, 시계열 차트, iframe) - ❌ Dagster batch asset 통합 (Epic #1083 M2 와 묶음)
- ❌ Per-feature 시계열 (
GET /drift/timeseries) - ❌
deleted_at기반 진짜 soft delete tombstone (M2 = hard delete)
구성 요소
1. ORM 테이블 — drift_report
-- apps/api/src/gend_api/db/models/drift.py (ADR-002 baseline, no Alembic)
CREATE TABLE drift_report (
id UUID PRIMARY KEY,
workspace_id UUID NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
ml_batch_run_id UUID NULL REFERENCES ml_batch_run(id) ON DELETE SET NULL,
manual_trigger BOOLEAN NOT NULL DEFAULT FALSE,
model_name VARCHAR(255) NOT NULL,
model_version VARCHAR(32),
reference_run_id VARCHAR(64),
current_data_window_start TIMESTAMPTZ,
current_data_window_end TIMESTAMPTZ,
rows_evaluated BIGINT,
drift_score DOUBLE PRECISION NOT NULL,
drift_status VARCHAR(32) NOT NULL CHECK (drift_status IN ('ok','warn','breach')),
drifted_features JSONB NOT NULL DEFAULT '{}',
notified_at TIMESTAMPTZ,
artifact_uri TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_drift_report_model ON drift_report(model_name, created_at);
workspace_id 는 NULLABLE — Epic #1018 M1 backfill 단계 (ml_batch 패턴 동일). 후속 M3 PR 에서 NOT NULL 전환.
2. 드리프트 계산 — services/drift_service.py
from gend_api.services.drift_service import DriftService
service = DriftService()
report = service.compute_drift(
reference=training_df,
current=batch_df,
threshold=0.3, # 또는 MLBatchJob.drift_threshold 에서 read
)
# report.drift_score: float in [0, 1]
# report.drift_status: 'ok' / 'warn' / 'breach'
# report.drifted_features: {col: p_value, ...}
알고리즘 (PoC):
- 두 DataFrame 의 수치형 컬럼 교집합 추출 (
select_dtypes(include='number')). - 각 컬럼에 대해
scipy.stats.ks_2samp호출 → p-value. - p < 0.05 인 컬럼을
drifted_features맵에 기록. drift_score = flagged_count / comparable_count(comparable=0 시 0.0).drift_status:score == 0→ok0 < score ≤ threshold→warnscore > threshold→breach
수치형이 아닌 컬럼은 silent skip — chi-square 는 M2.
3. API
# Read (M1)
GET /api/v1/models/{model_name}/drift # 리스트 (workspace fence + pagination)
GET /api/v1/models/{model_name}/drift/{report_id} # 단건 (model_name 불일치 → 404)
# Mutation (M2)
POST /api/v1/models/{model_name}/drift/compute # 수동 trigger (viewer+)
PUT /api/v1/models/{model_name}/drift/{report_id} # status override (admin only)
DELETE /api/v1/models/{model_name}/drift/{report_id} # soft delete (admin only, 409 if notified)
리스트 응답 shape — apps/api/src/gend_api/models/drift.py DriftReportListResponse (items: list[DriftReportRead] + total: int):
{
"items": [
{
"id": "uuid",
"workspace_id": "uuid",
"ml_batch_run_id": null,
"manual_trigger": true,
"model_name": "credit_risk",
"model_version": "1",
"reference_run_id": "ref-run-abc",
"drift_score": 0.5,
"drift_status": "breach",
"drifted_features": {"age": 0.001, "income": 0.02},
"notified_at": null,
"artifact_uri": null,
"created_at": "2026-05-28T00:00:00Z"
}
],
"total": 1
}
권한: GET 은 require_viewer (admin / analyst / viewer). Workspace fence 는 routers/ml_batch.py 패턴과 동일 — caller-tenant 행 + legacy NULL 행만 가시.
4. Mutation API (M2)
POST /api/v1/models/{name}/drift/compute — 수동 trigger
권한: require_viewer (모든 인증 사용자). 결과 행은 caller 의 workspace 로 fence.
curl -X POST "https://gend.genon.ai/api/v1/models/credit_risk/drift/compute" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"reference_run_id": "ref-run-abc",
"current_data_window_start": "2026-05-01T00:00:00Z",
"current_data_window_end": "2026-05-08T00:00:00Z",
"threshold": 0.3,
"model_version": "3"
}'
# 201 Created → DriftReportRead body
응답 body 는 GET 단건과 동일한 DriftReportRead shape. manual_trigger=true 가 자동 set 되며, ml_batch_run_id=null (parent batch 없음).
- pipelines 측 (Dagster
ml_batch_predict_asset):drift_check=True인 ml_batch_job 실행 시pipelines/gend_pipelines/ml/drift.py:compute_ks_drift로 실 데이터 KS 계산 +drift_reportrow INSERT. baseline 은 현 batch 의 50% split (앞쪽 reference / 뒤쪽 current) — PoC. 다음 follow-up 에서 MLflow training run artifact 의 dataframe 으로 reference 교체 예정. - apps/api 측 (
/drift/computerouter): M2 PoC 의 빈 DataFrame stub 그대로. 별도 PR 에서 service.compute_and_save 의 fetch 로직 도입. - cross-PR 정합:
tests/test_drift_module.py::test_default_drift_threshold_matches_apps_api가 양쪽DEFAULT_DRIFT_THRESHOLDliteral 정합 강제.
검증 (Pydantic):
reference_run_id— required, 1–64 자current_data_window_start/end— required ISO 8601 datetimethreshold—[0.0, 1.0]범위 (out → 422)model_version— optional, max 32 자
PUT /api/v1/models/{name}/drift/{report_id} — admin status override
권한: require_admin (admin only). False-positive 표시 등 수동 재분류.
curl -X PUT "https://gend.genon.ai/api/v1/models/credit_risk/drift/${REPORT_ID}" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"drift_status": "warn",
"override_reason": "upstream data quality blip — verified false positive"
}'
# 200 OK → DriftReportRead body (updated drift_status)
정책:
drift_status는 enumok | warn | breach만 허용 (다른 값 → 422)override_reason은 필수 (3–500 자) — 빈 override 를 막아 paper trail 강제- override 액션은
gend.audit로거에drift_status_override구조화 라인 +AuditMiddleware의 JSON request-body capture 양쪽에 기록 (M3 OpenSearch ingestion 까지 보존) - Workspace fence: admin 은 모든 워크스페이스 행 override 가능 (
ml_batch와 동일 정책). M3 에서 strict tenant 분리 검토. - 본 PR 에서 override_reason 컬럼은 추가하지 않음 (Alembic baseline 정책) — audit 로그가 단일 진실 공급원.
DELETE /api/v1/models/{name}/drift/{report_id} — soft delete
권한: require_admin. 알림이 발송된 행은 보존.
curl -X DELETE "https://gend.genon.ai/api/v1/models/credit_risk/drift/${REPORT_ID}" \
-H "Authorization: Bearer ${ADMIN_TOKEN}"
# 204 No Content (성공)
# 404 — 행이 없거나 model_name 불일치
# 409 — notified_at != NULL (M3 알림 발송 후) → "Cannot delete a drift report that has already triggered a notification"
정책:
- M2 에서는 hard DELETE (Alembic baseline 정책상
deleted_at컬럼 추가 불가). M3 에서 tombstone update 로 전환. notified_at IS NOT NULL인 행은 보존: breach 알림과 근거 데이터 cross-reference 가능해야 한다.- 액션은
gend.audit로거에drift_report_soft_delete라인으로 기록.
회귀 가드
tests/test_drift_service.py(16 tests) — KS test, 분류기 boundary, threshold validation, M2create_report/update_status/soft_delete/compute_and_save.tests/test_drift_router.py(31 tests) — 권한 fence, 404/403/422 분기, trailing slash, M2 POST/PUT/DELETE 권한·workspace·enum·409 notified 가드.tests/test_no_drift_threshold_hardcoded.py—threshold=<float>리터럴 차단 (allowed: drift_service 모듈 상수 + ORM server_default + 테스트 fixture).
M3 마일스톤 — 후속 작업
issue #1213 의 나머지 acceptance criteria:
- MLflow REST + Iceberg fetch —
DriftService.compute_and_save의 stub 분기를 실 데이터 페치로 swap. 시그니처는 변경 없음 (M2 PR 의 docstring 영역만 교체). - Evidently AI 통합 —
DriftService.compute_drift의 SciPy 분기를 EvidentlyReport호출로 swap. DB / wire shape 동일 → service-layer-only 교체. - Slack 알림 —
services/notification/provider 사용, breach 시notified_at갱신. - UI
/admin/drift—components/admin/Drift/DriftDashboard.tsx+ iframe Evidently HTML. - Batch asset 통합 — Epic #1083 M2 의
ml/batch_predict_asset.py가 끝나는 시점에drift_service.compute_and_save()호출. GET /drift/timeseries— UI 차트용 score 히스토리.deleted_at컬럼 + override audit 테이블 — Alembic migration 동반 PR.