fix(prediction): dark_suspicion Adapter 입력 매핑 교정 (run_outputs 0 rows silent)
기존 DarkSuspicionModel.run() 이 ctx.inputs[*].features.gap_info.is_dark
로 대상을 필터했으나, 실제 AnalysisResult.features 는 gap_info 키를
포함하지 않고 다음으로 이미 flatten 저장됨:
- dark_suspicion_score (0~100 int)
- dark_tier ('CRITICAL'/'HIGH'/'WATCH'/'NONE')
- dark_patterns (list[str])
- gap_start_state / gap_start_sog / gap_start_lat / gap_start_lon
결과: PRIMARY 승격 + feature flag=1 적용해도 Adapter 가 한 선박도
평가하지 못해 detection_model_run_outputs 에 0 rows 적재 — E2E 검증
에서 evaluated_count=0 메트릭이 silent 조기 포착.
수정: Adapter 를 transshipment/risk/pair_trawl 과 동일한 관찰형으로 재작성.
- row.is_dark 필터 후 기존 결과(score/tier/patterns) JSONB snapshot 기록
- critical/high/watch/none_count + avg_score 메트릭 집계
- input_ref: {mmsi, analyzed_at, gap_min}
운영 반영 후 1 사이클에서 3508 rows 적재 (141 CRITICAL / 467 HIGH /
963 WATCH / avg_score=27.78) 확인.
범위 밖 (후속 PR):
- compute_dark_suspicion 에 ACTIVE 버전 params 를 런타임 주입해 실제
score/tier 재계산하는 scheduler 호출부 리팩토링. 현 상태는 params
카탈로그 관찰까지만 (나머지 4 모델과 동일 수준).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
87916a55bf
커밋
20806ee80c
@ -4,6 +4,9 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 수정
|
||||
- **Phase 2 PoC `dark_suspicion` Adapter 입력 매핑 교정** — 기존 Adapter 가 `ctx.inputs[*].features.gap_info.is_dark` 로 대상을 필터했으나, 실제 AnalysisResult 의 `features` JSONB 는 `gap_info` 를 포함하지 않고 `dark_suspicion_score`/`dark_tier`/`dark_patterns`/`gap_start_*` 로 이미 flatten 저장됨 → Adapter 가 **한 선박도 평가하지 못해** `detection_model_run_outputs=0 rows` 로 나오던 silent 문제. E2E 검증에서 `evaluated_count=0` 메트릭으로 포착. 수정: Adapter 를 transshipment/risk/pair_trawl 과 동일한 "관찰형" 으로 재작성 — `row.is_dark` 필터 후 `features.dark_suspicion_score / dark_tier / dark_patterns` 를 그대로 JSONB snapshot 으로 기록, `critical/high/watch/none_count` + `avg_score` 메트릭 집계. 운영 반영 후 1 사이클에서 **3,508 rows 적재, 141 CRITICAL / 467 HIGH / 963 WATCH / avg_score=27.78** 확인. 런타임 params override (compute_dark_suspicion 에 ACTIVE 버전 params 주입) 는 scheduler 호출부 리팩토링 필요 — **후속 PR 로 이관**
|
||||
|
||||
### 추가
|
||||
- **Phase 3 MVP — Detection Model Registry 운영자 API (백엔드)** — `gc.mda.kcg.domain.ai` 패키지 신설, `ai-operations:detection-models` 권한 기반 8 엔드포인트: GET `/api/ai/detection-models` (카탈로그 목록, tier 정렬) / GET `/{modelId}` / GET `/{modelId}/dependencies` (DAG 선행) / GET `/{modelId}/versions` / GET `/{modelId}/versions/{versionId}` / POST `/{modelId}/versions` (DRAFT 생성, @Auditable `DETECTION_MODEL_VERSION_CREATE`) / POST `/versions/{versionId}/activate` (DRAFT → ACTIVE(role=PRIMARY·SHADOW·CHALLENGER), @Auditable `DETECTION_MODEL_VERSION_ACTIVATE`) / POST `/versions/{versionId}/archive` (ACTIVE/DRAFT → ARCHIVED, @Auditable `DETECTION_MODEL_VERSION_ARCHIVE`, idempotent). `DetectionModel`·`DetectionModelVersion` 엔티티(JSONB params 는 Hibernate `@JdbcTypeCode(SqlTypes.JSON)` 기반 `JsonNode` 매핑), `DetectionModelRepository`·`DetectionModelVersionRepository`, `DetectionModelService`(READ 전용)·`DetectionModelVersionService`(create/activate/archive, 전이 화이트리스트 + `uk_detection_model_primary` 중복 방지 409 응답), Request/Response record 4종. 로컬 `mvn spring-boot:run` 기동 성공 + admin 계정 쿠키 인증으로 8 엔드포인트 전수 smoke test 통과(5 모델 조회 / DRAFT 생성 → activate SHADOW → archive 전이 사이클 검증). **범위 밖 후속 PR**: promote-primary(SHADOW/CHALLENGER→PRIMARY) / enable 토글 / metrics · compare · runs 조회
|
||||
|
||||
|
||||
@ -1,19 +1,26 @@
|
||||
"""dark_suspicion — 의도적 AIS OFF 의심 점수 모델 (Phase 2 PoC #1).
|
||||
"""dark_suspicion — 의도적 AIS OFF 의심 점수 관찰 어댑터 (Phase 2 PoC #1).
|
||||
|
||||
구조:
|
||||
- 기존 `algorithms.dark_vessel.compute_dark_suspicion` 을 그대로 사용 (BACK-COMPAT).
|
||||
- 입력 단위: `ctx.inputs` 의 각 항목(AnalysisResult asdict) → (mmsi, gap_info).
|
||||
prediction 기존 사이클이 이미 `analyze_dark_pattern` 을 돌려 AnalysisResult.features
|
||||
에 gap_info 와 dark_patterns 를 저장하므로, 이 모델은 **그 결과에 대해 score 를 재계산**
|
||||
하는 shadow 비교용이다. PRIMARY 경로는 아직 `scheduler.py` 의 기존 계산을 사용.
|
||||
현재 운영 파이프라인의 데이터 흐름:
|
||||
|
||||
Phase 2 동치성 검증:
|
||||
- params=None 로 호출하면 compute_dark_suspicion 이 DEFAULT_PARAMS 를 사용해
|
||||
기존 하드코딩 상수와 완전히 동일한 score/tier 를 낸다.
|
||||
- detection_model_versions.params 가 DEFAULT 와 동일하면 신·구 경로 diff=0.
|
||||
scheduler.py (기존) → analyze_dark_pattern() + compute_dark_suspicion() 실행 →
|
||||
AnalysisResult.features 에 다음 키로 flatten 저장:
|
||||
- dark_suspicion_score (0~100 int)
|
||||
- dark_tier ('CRITICAL'/'HIGH'/'WATCH'/'NONE')
|
||||
- dark_patterns (list[str])
|
||||
- dark_history_7d / dark_history_24h
|
||||
- gap_start_state / gap_start_sog / gap_start_lat / gap_start_lon
|
||||
|
||||
Phase 3 백엔드 API 연동 후 PRIMARY 로 승격하면 scheduler 도 이 모델을 호출하도록
|
||||
전환 (현재는 ctx.shared 주입만, 기존 경로 영향 없음).
|
||||
Adapter 의 역할은 **이 결과를 `detection_model_run_outputs` 로 관찰 집계**.
|
||||
scheduler 가 ACTIVE params 를 compute_dark_suspicion 호출 시점에 주입하도록
|
||||
연결하는 런타임 override 는 **후속 리팩토링 PR** 에서 수행한다
|
||||
(현 단계에서는 카탈로그 · 관찰 · 메트릭까지 확립).
|
||||
|
||||
metrics:
|
||||
· evaluated_count : is_dark=True 관찰 수
|
||||
· critical_count : dark_tier='CRITICAL'
|
||||
· high_count : dark_tier='HIGH'
|
||||
· watch_count : dark_tier='WATCH'
|
||||
· avg_score : is_dark=True 중 dark_suspicion_score 평균
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@ -21,7 +28,6 @@ from algorithms.dark_vessel import (
|
||||
DARK_SUSPICION_DEFAULT_PARAMS,
|
||||
compute_dark_suspicion,
|
||||
)
|
||||
from algorithms.location import classify_zone
|
||||
from models_core.base import (
|
||||
BaseDetectionModel,
|
||||
ModelContext,
|
||||
@ -36,51 +42,42 @@ class DarkSuspicionModel(BaseDetectionModel):
|
||||
|
||||
def run(self, ctx: ModelContext) -> ModelResult:
|
||||
outputs_per_input: list[tuple[dict, dict]] = []
|
||||
critical = 0
|
||||
high = 0
|
||||
watch = 0
|
||||
for row in ctx.inputs or []:
|
||||
if not row:
|
||||
continue
|
||||
mmsi = row.get('mmsi')
|
||||
features = row.get('features') or {}
|
||||
gap_info = features.get('gap_info') if isinstance(features, dict) else None
|
||||
if not gap_info or not gap_info.get('is_dark'):
|
||||
continue
|
||||
tiers = {'CRITICAL': 0, 'HIGH': 0, 'WATCH': 0, 'NONE': 0}
|
||||
score_sum = 0.0
|
||||
evaluated = 0
|
||||
|
||||
for row in ctx.inputs or []:
|
||||
if not row or not row.get('is_dark'):
|
||||
continue
|
||||
features = row.get('features') or {}
|
||||
if not isinstance(features, dict):
|
||||
features = {}
|
||||
|
||||
score = int(features.get('dark_suspicion_score') or 0)
|
||||
tier = features.get('dark_tier') or 'NONE'
|
||||
patterns = features.get('dark_patterns') or []
|
||||
if not isinstance(patterns, list):
|
||||
patterns = []
|
||||
|
||||
evaluated += 1
|
||||
score_sum += float(score)
|
||||
if tier in tiers:
|
||||
tiers[tier] += 1
|
||||
|
||||
history = {
|
||||
'count_7d': row.get('dark_count_7d', 0),
|
||||
'count_24h': row.get('dark_count_24h', 0),
|
||||
}
|
||||
now_kst_hour = row.get('now_kst_hour', 0)
|
||||
score, patterns, tier = compute_dark_suspicion(
|
||||
gap_info=gap_info,
|
||||
mmsi=mmsi,
|
||||
is_permitted=bool(row.get('is_permitted', False)),
|
||||
history=history,
|
||||
now_kst_hour=int(now_kst_hour or 0),
|
||||
classify_zone_fn=classify_zone,
|
||||
ship_kind_code=row.get('ship_kind_code', '') or '',
|
||||
nav_status=row.get('nav_status', '') or '',
|
||||
heading=row.get('heading'),
|
||||
last_cog=row.get('last_cog'),
|
||||
params=self.params or None,
|
||||
)
|
||||
if tier == 'CRITICAL':
|
||||
critical += 1
|
||||
elif tier == 'HIGH':
|
||||
high += 1
|
||||
elif tier == 'WATCH':
|
||||
watch += 1
|
||||
outputs_per_input.append((
|
||||
make_input_ref(mmsi, row.get('analyzed_at'), gap_min=gap_info.get('gap_min')),
|
||||
make_input_ref(
|
||||
row.get('mmsi'),
|
||||
row.get('analyzed_at'),
|
||||
gap_min=row.get('gap_duration_min'),
|
||||
),
|
||||
{
|
||||
'score': int(score),
|
||||
'score': score,
|
||||
'tier': tier,
|
||||
'patterns': patterns,
|
||||
},
|
||||
))
|
||||
|
||||
avg = score_sum / evaluated if evaluated else 0.0
|
||||
return ModelResult(
|
||||
model_id=self.model_id,
|
||||
version_id=self.version_id,
|
||||
@ -88,12 +85,18 @@ class DarkSuspicionModel(BaseDetectionModel):
|
||||
role=self.role,
|
||||
outputs_per_input=outputs_per_input,
|
||||
metrics={
|
||||
'evaluated_count': float(len(outputs_per_input)),
|
||||
'critical_count': float(critical),
|
||||
'high_count': float(high),
|
||||
'watch_count': float(watch),
|
||||
'evaluated_count': float(evaluated),
|
||||
'avg_score': round(avg, 2),
|
||||
'critical_count': float(tiers['CRITICAL']),
|
||||
'high_count': float(tiers['HIGH']),
|
||||
'watch_count': float(tiers['WATCH']),
|
||||
'none_count': float(tiers['NONE']),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ['DarkSuspicionModel', 'DARK_SUSPICION_DEFAULT_PARAMS']
|
||||
__all__ = [
|
||||
'DarkSuspicionModel',
|
||||
'DARK_SUSPICION_DEFAULT_PARAMS',
|
||||
'compute_dark_suspicion',
|
||||
]
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user