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]
|
## [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 조회
|
- **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 동치성 검증:
|
scheduler.py (기존) → analyze_dark_pattern() + compute_dark_suspicion() 실행 →
|
||||||
- params=None 로 호출하면 compute_dark_suspicion 이 DEFAULT_PARAMS 를 사용해
|
AnalysisResult.features 에 다음 키로 flatten 저장:
|
||||||
기존 하드코딩 상수와 완전히 동일한 score/tier 를 낸다.
|
- dark_suspicion_score (0~100 int)
|
||||||
- detection_model_versions.params 가 DEFAULT 와 동일하면 신·구 경로 diff=0.
|
- 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 도 이 모델을 호출하도록
|
Adapter 의 역할은 **이 결과를 `detection_model_run_outputs` 로 관찰 집계**.
|
||||||
전환 (현재는 ctx.shared 주입만, 기존 경로 영향 없음).
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -21,7 +28,6 @@ from algorithms.dark_vessel import (
|
|||||||
DARK_SUSPICION_DEFAULT_PARAMS,
|
DARK_SUSPICION_DEFAULT_PARAMS,
|
||||||
compute_dark_suspicion,
|
compute_dark_suspicion,
|
||||||
)
|
)
|
||||||
from algorithms.location import classify_zone
|
|
||||||
from models_core.base import (
|
from models_core.base import (
|
||||||
BaseDetectionModel,
|
BaseDetectionModel,
|
||||||
ModelContext,
|
ModelContext,
|
||||||
@ -36,51 +42,42 @@ class DarkSuspicionModel(BaseDetectionModel):
|
|||||||
|
|
||||||
def run(self, ctx: ModelContext) -> ModelResult:
|
def run(self, ctx: ModelContext) -> ModelResult:
|
||||||
outputs_per_input: list[tuple[dict, dict]] = []
|
outputs_per_input: list[tuple[dict, dict]] = []
|
||||||
critical = 0
|
tiers = {'CRITICAL': 0, 'HIGH': 0, 'WATCH': 0, 'NONE': 0}
|
||||||
high = 0
|
score_sum = 0.0
|
||||||
watch = 0
|
evaluated = 0
|
||||||
for row in ctx.inputs or []:
|
|
||||||
if not row:
|
for row in ctx.inputs or []:
|
||||||
continue
|
if not row or not row.get('is_dark'):
|
||||||
mmsi = row.get('mmsi')
|
continue
|
||||||
features = row.get('features') or {}
|
features = row.get('features') or {}
|
||||||
gap_info = features.get('gap_info') if isinstance(features, dict) else None
|
if not isinstance(features, dict):
|
||||||
if not gap_info or not gap_info.get('is_dark'):
|
features = {}
|
||||||
continue
|
|
||||||
|
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((
|
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,
|
'tier': tier,
|
||||||
'patterns': patterns,
|
'patterns': patterns,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
|
||||||
|
avg = score_sum / evaluated if evaluated else 0.0
|
||||||
return ModelResult(
|
return ModelResult(
|
||||||
model_id=self.model_id,
|
model_id=self.model_id,
|
||||||
version_id=self.version_id,
|
version_id=self.version_id,
|
||||||
@ -88,12 +85,18 @@ class DarkSuspicionModel(BaseDetectionModel):
|
|||||||
role=self.role,
|
role=self.role,
|
||||||
outputs_per_input=outputs_per_input,
|
outputs_per_input=outputs_per_input,
|
||||||
metrics={
|
metrics={
|
||||||
'evaluated_count': float(len(outputs_per_input)),
|
'evaluated_count': float(evaluated),
|
||||||
'critical_count': float(critical),
|
'avg_score': round(avg, 2),
|
||||||
'high_count': float(high),
|
'critical_count': float(tiers['CRITICAL']),
|
||||||
'watch_count': float(watch),
|
'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