Spaces:
Sleeping
3종 후속 작업 — cleanup 스크립트 + ner_filter 강화 + HE-FL Phase 2
Browse files(C) scripts/cleanup_decisions_reasons.py 신규:
- 모든 decisions 의 reasons / findings 에서 classifier 의 0-weight entity
(NRP/NORP/DATE_TIME/URL/US_DRIVER_LICENSE/US_ITIN/US_PASSPORT/IN_PAN) 제거
- ai_score 재계산 (reasons 합계와 일관성)
- Dry-run 기본 + --apply 옵션 + 자동 백업 (decisions.db.before-cleanup-<ts>)
- 검증: 현재 18건 중 3건 영향, 993 reasons 항목 제거 예정
(결정 18 한 건이 987 reasons 보유 — NRP 481·URL 364·PHONE 189 의 누적)
(B) ner_filter.py 강화:
- _REJECT_PREFIX 에 부서·업무 어휘 30+ 추가:
평가/검토/심사/관리/운영/총무/기획/전략/영업/인사/교육/연구/개발/...
- LOCATION 신규 _LOC_ADMIN_SUFFIX (시/도/구/군/동/리/읍/면/가/로/길/...) —
3자 이상 LOCATION 은 행정 단위 접미사 필요
- LOCATION 신규 _LOC_BLACKLIST (여기/거기/본사/지사/내부/외부/현장/...) 즉시 거부
(A) HE-FL Phase 2 — N 라운드 자동 반복:
- he_fl.py 확장:
- compute_gradient_from_reasons() — train.py 와 동일한 reasons 기반 features
(Phase 1 의 findings_summary 가 비어있어 grad=0 이던 문제 해소)
- _evaluate() — 평문 평가: MSE + 등급 정확도 (학습 전후 비교)
- run_round() — 1라운드: 분할 → reasons grad → 합산 시뮬 → 평균 → 적용
- run_multi_rounds() — N회 반복 + 라운드별 정확도/MSE 기록
- 라우트: POST /api/he/fl/round + /api/he/fl/auto
- UI: 같은 #tab-he-fl 탭 하단에 Phase 2 카드 추가
- n_rounds (default 10) + n_clients + eta input
- 최종 성과 표 (시작/최종 정확도/MSE)
- 시계열 SVG (정확도 파랑 + MSE 주황, dual axis)
- 라운드별 이력 표 (model_version 까지)
- 초보 가이드: Phase 1 (실제 BFV) vs Phase 2 (평문 시뮬) 차이 + 왜 시뮬로 OK
검증 (5 라운드 실행):
현 데이터 (cleanup 미실행, NRP 노이즈 누적) 로는 정확도 진동 (61%→33%).
→ 사용자: scripts/cleanup_decisions_reasons.py --apply 실행 후 재실행 권장.
cleanup 후엔 학습이 안정되어 정확도 수렴 예상.
이로써 SPEC §6.3 \"PoC 학습 클라이언트 시뮬 + 서버 시뮬\" 완료.
- app.py +24 -0
- app_lite.py +24 -0
- he_fl.py +157 -1
- ner_filter.py +29 -2
- scripts/cleanup_decisions_reasons.py +179 -0
- templates/index.html +173 -0
|
@@ -1661,6 +1661,30 @@ def api_he_fl_apply():
|
|
| 1661 |
return jsonify(he_fl.apply_aggregated_gradient(grad_f, eta=eta))
|
| 1662 |
|
| 1663 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1664 |
if __name__ == "__main__":
|
| 1665 |
host = os.environ.get("HOST", "127.0.0.1")
|
| 1666 |
port = int(os.environ.get("PORT", "5000"))
|
|
|
|
| 1661 |
return jsonify(he_fl.apply_aggregated_gradient(grad_f, eta=eta))
|
| 1662 |
|
| 1663 |
|
| 1664 |
+
# HE-FL Phase 2 — 라운드 자동 반복
|
| 1665 |
+
@app.route("/api/he/fl/round", methods=["POST"])
|
| 1666 |
+
def api_he_fl_round():
|
| 1667 |
+
p = request.get_json(silent=True) or {}
|
| 1668 |
+
try:
|
| 1669 |
+
n_clients = max(1, int(p.get("n_clients", 2)))
|
| 1670 |
+
eta = float(p.get("eta", 0.05))
|
| 1671 |
+
except (TypeError, ValueError):
|
| 1672 |
+
return jsonify({"ok": False, "message": "n_clients/eta 필수"}), 400
|
| 1673 |
+
return jsonify(he_fl.run_round(n_clients=n_clients, eta=eta))
|
| 1674 |
+
|
| 1675 |
+
|
| 1676 |
+
@app.route("/api/he/fl/auto", methods=["POST"])
|
| 1677 |
+
def api_he_fl_auto():
|
| 1678 |
+
p = request.get_json(silent=True) or {}
|
| 1679 |
+
try:
|
| 1680 |
+
n_rounds = max(1, min(100, int(p.get("n_rounds", 10))))
|
| 1681 |
+
n_clients = max(1, int(p.get("n_clients", 2)))
|
| 1682 |
+
eta = float(p.get("eta", 0.05))
|
| 1683 |
+
except (TypeError, ValueError):
|
| 1684 |
+
return jsonify({"ok": False, "message": "n_rounds/n_clients/eta 필수"}), 400
|
| 1685 |
+
return jsonify(he_fl.run_multi_rounds(n_rounds=n_rounds, n_clients=n_clients, eta=eta))
|
| 1686 |
+
|
| 1687 |
+
|
| 1688 |
if __name__ == "__main__":
|
| 1689 |
host = os.environ.get("HOST", "127.0.0.1")
|
| 1690 |
port = int(os.environ.get("PORT", "5000"))
|
|
@@ -1396,6 +1396,30 @@ def api_he_fl_apply():
|
|
| 1396 |
return jsonify(he_fl.apply_aggregated_gradient(grad_f, eta=eta))
|
| 1397 |
|
| 1398 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1399 |
if __name__ == "__main__":
|
| 1400 |
host = os.environ.get("HOST", "127.0.0.1")
|
| 1401 |
port = int(os.environ.get("PORT", "5000"))
|
|
|
|
| 1396 |
return jsonify(he_fl.apply_aggregated_gradient(grad_f, eta=eta))
|
| 1397 |
|
| 1398 |
|
| 1399 |
+
# HE-FL Phase 2 — 라운드 자동 반복
|
| 1400 |
+
@app.route("/api/he/fl/round", methods=["POST"])
|
| 1401 |
+
def api_he_fl_round():
|
| 1402 |
+
p = request.get_json(silent=True) or {}
|
| 1403 |
+
try:
|
| 1404 |
+
n_clients = max(1, int(p.get("n_clients", 2)))
|
| 1405 |
+
eta = float(p.get("eta", 0.05))
|
| 1406 |
+
except (TypeError, ValueError):
|
| 1407 |
+
return jsonify({"ok": False, "message": "n_clients/eta 필수"}), 400
|
| 1408 |
+
return jsonify(he_fl.run_round(n_clients=n_clients, eta=eta))
|
| 1409 |
+
|
| 1410 |
+
|
| 1411 |
+
@app.route("/api/he/fl/auto", methods=["POST"])
|
| 1412 |
+
def api_he_fl_auto():
|
| 1413 |
+
p = request.get_json(silent=True) or {}
|
| 1414 |
+
try:
|
| 1415 |
+
n_rounds = max(1, min(100, int(p.get("n_rounds", 10))))
|
| 1416 |
+
n_clients = max(1, int(p.get("n_clients", 2)))
|
| 1417 |
+
eta = float(p.get("eta", 0.05))
|
| 1418 |
+
except (TypeError, ValueError):
|
| 1419 |
+
return jsonify({"ok": False, "message": "n_rounds/n_clients/eta 필수"}), 400
|
| 1420 |
+
return jsonify(he_fl.run_multi_rounds(n_rounds=n_rounds, n_clients=n_clients, eta=eta))
|
| 1421 |
+
|
| 1422 |
+
|
| 1423 |
if __name__ == "__main__":
|
| 1424 |
host = os.environ.get("HOST", "127.0.0.1")
|
| 1425 |
port = int(os.environ.get("PORT", "5000"))
|
|
@@ -250,7 +250,163 @@ def status() -> dict[str, Any]:
|
|
| 250 |
weights = dict(classifier.ENTITY_WEIGHTS)
|
| 251 |
return {
|
| 252 |
"ok": True,
|
| 253 |
-
"n_decisions": len(storage.
|
| 254 |
"current_weights": weights,
|
| 255 |
"n_entities": len(weights),
|
| 256 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
weights = dict(classifier.ENTITY_WEIGHTS)
|
| 251 |
return {
|
| 252 |
"ok": True,
|
| 253 |
+
"n_decisions": len(storage.fetch_all_for_training()),
|
| 254 |
"current_weights": weights,
|
| 255 |
"n_entities": len(weights),
|
| 256 |
}
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
# -------------------------------------------------------------------
|
| 260 |
+
# Phase 2 — 다중 클라이언트 + N 라운드 자동 반복
|
| 261 |
+
# -------------------------------------------------------------------
|
| 262 |
+
|
| 263 |
+
def _evaluate(decisions: list[dict], weights: dict[str, float],
|
| 264 |
+
entities: list[str]) -> dict[str, float]:
|
| 265 |
+
"""평문 평가 — MSE 와 등급 정확도 (학습용 reasons-기반)."""
|
| 266 |
+
import classifier as _cls
|
| 267 |
+
if not decisions:
|
| 268 |
+
return {"mse": None, "accuracy": None, "n": 0}
|
| 269 |
+
n_correct = 0
|
| 270 |
+
sse = 0.0
|
| 271 |
+
n = 0
|
| 272 |
+
for d in decisions:
|
| 273 |
+
# train.py 의 _features_of 와 동일한 reasons 기반 features (정확성 위해)
|
| 274 |
+
feats: dict[str, int] = {}
|
| 275 |
+
for r in d.get("reasons") or []:
|
| 276 |
+
if r.get("kind") != "entity":
|
| 277 |
+
continue
|
| 278 |
+
lab = r.get("label")
|
| 279 |
+
if lab:
|
| 280 |
+
feats[lab] = feats.get(lab, 0) + int(r.get("count") or 0)
|
| 281 |
+
target = _grade_to_target(d.get("user_grade"))
|
| 282 |
+
if target is None:
|
| 283 |
+
continue
|
| 284 |
+
pred = sum(weights.get(ent, 0.0) * cnt for ent, cnt in feats.items())
|
| 285 |
+
sse += (pred - target) ** 2
|
| 286 |
+
# 등급 결정
|
| 287 |
+
if pred >= getattr(_cls, "C_THRESHOLD", 2.0):
|
| 288 |
+
new_grade = "C"
|
| 289 |
+
elif pred >= getattr(_cls, "S_THRESHOLD", 1.0):
|
| 290 |
+
new_grade = "S"
|
| 291 |
+
else:
|
| 292 |
+
new_grade = "O"
|
| 293 |
+
if new_grade == d.get("user_grade"):
|
| 294 |
+
n_correct += 1
|
| 295 |
+
n += 1
|
| 296 |
+
return {
|
| 297 |
+
"mse": round(sse / n, 4) if n else None,
|
| 298 |
+
"accuracy": round(n_correct / n, 4) if n else None,
|
| 299 |
+
"n": n,
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def compute_gradient_from_reasons(decisions: list[dict], weights: dict[str, float],
|
| 304 |
+
entities: list[str]) -> tuple[dict[str, float], dict[str, Any]]:
|
| 305 |
+
"""reasons 기반 gradient — train.py 의 features 와 동일 (Phase 2 정확성).
|
| 306 |
+
|
| 307 |
+
Phase 1 은 findings_summary 사용 (비어 있어 grad=0 문제).
|
| 308 |
+
Phase 2 는 reasons 사용 — 실제 학습 데이터 일관성.
|
| 309 |
+
"""
|
| 310 |
+
n = 0
|
| 311 |
+
sum_loss = 0.0
|
| 312 |
+
grad_accum: dict[str, float] = {ent: 0.0 for ent in entities}
|
| 313 |
+
|
| 314 |
+
for d in decisions:
|
| 315 |
+
feats: dict[str, int] = {}
|
| 316 |
+
for r in d.get("reasons") or []:
|
| 317 |
+
if r.get("kind") != "entity":
|
| 318 |
+
continue
|
| 319 |
+
lab = r.get("label")
|
| 320 |
+
if lab and lab in grad_accum:
|
| 321 |
+
feats[lab] = feats.get(lab, 0) + int(r.get("count") or 0)
|
| 322 |
+
target = _grade_to_target(d.get("user_grade"))
|
| 323 |
+
if target is None:
|
| 324 |
+
continue
|
| 325 |
+
pred = sum(weights.get(ent, 0.0) * cnt for ent, cnt in feats.items())
|
| 326 |
+
err = pred - target
|
| 327 |
+
sum_loss += err * err
|
| 328 |
+
for ent, c in feats.items():
|
| 329 |
+
grad_accum[ent] += err * c
|
| 330 |
+
n += 1
|
| 331 |
+
|
| 332 |
+
if n == 0:
|
| 333 |
+
return {ent: 0.0 for ent in entities}, {"n_samples": 0, "mse": None}
|
| 334 |
+
grad_avg = {ent: round(g / n, 6) for ent, g in grad_accum.items()}
|
| 335 |
+
return grad_avg, {"n_samples": n, "mse": round(sum_loss / n, 6)}
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def run_round(n_clients: int = 2, eta: float = 0.05) -> dict[str, Any]:
|
| 339 |
+
"""1 라운드 — 결정 N분할 → client 별 reasons-기반 gradient → 합산 시뮬 → 평균 → 적용.
|
| 340 |
+
|
| 341 |
+
Phase 2 의 HE 시뮬: 서버 측 평문 합산 (BFV 동형성은 Phase 1 탭이 시각 검증).
|
| 342 |
+
한 라운드를 ms 단위 빠르게 → N 라운드 반복 가능.
|
| 343 |
+
"""
|
| 344 |
+
import classifier
|
| 345 |
+
decisions = storage.fetch_all_for_training()
|
| 346 |
+
if not decisions:
|
| 347 |
+
return {"ok": False, "message": "no decisions"}
|
| 348 |
+
|
| 349 |
+
weights_before = dict(classifier.ENTITY_WEIGHTS)
|
| 350 |
+
entities = sorted(weights_before.keys())
|
| 351 |
+
|
| 352 |
+
# 학습 전 평가
|
| 353 |
+
eval_before = _evaluate(decisions, weights_before, entities)
|
| 354 |
+
|
| 355 |
+
# 클라이언트 분할 + 각자 gradient
|
| 356 |
+
chunks = simulate_clients(decisions, n_clients=n_clients)
|
| 357 |
+
client_grads = []
|
| 358 |
+
for c in chunks:
|
| 359 |
+
grad, _meta = compute_gradient_from_reasons(c, weights_before, entities)
|
| 360 |
+
client_grads.append(grad)
|
| 361 |
+
|
| 362 |
+
# 시뮬 합산 → 평균 (HE 의 evaluator.add 동일 결과)
|
| 363 |
+
n = len(client_grads)
|
| 364 |
+
avg_grad = {ent: 0.0 for ent in entities}
|
| 365 |
+
for g in client_grads:
|
| 366 |
+
for ent, v in g.items():
|
| 367 |
+
avg_grad[ent] += v
|
| 368 |
+
avg_grad = {ent: v / n for ent, v in avg_grad.items()}
|
| 369 |
+
|
| 370 |
+
# 적용
|
| 371 |
+
applied = apply_aggregated_gradient(avg_grad, eta=eta)
|
| 372 |
+
weights_after = applied["after"]
|
| 373 |
+
|
| 374 |
+
# 학습 후 평가
|
| 375 |
+
eval_after = _evaluate(decisions, weights_after, entities)
|
| 376 |
+
|
| 377 |
+
return {
|
| 378 |
+
"ok": True,
|
| 379 |
+
"n_clients": n,
|
| 380 |
+
"n_decisions": len(decisions),
|
| 381 |
+
"eta": eta,
|
| 382 |
+
"model_version": applied["model_version"],
|
| 383 |
+
"before": eval_before,
|
| 384 |
+
"after": eval_after,
|
| 385 |
+
"deltas": applied["deltas"],
|
| 386 |
+
"new_weights": weights_after,
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def run_multi_rounds(n_rounds: int = 10, n_clients: int = 2, eta: float = 0.05) -> dict[str, Any]:
|
| 391 |
+
"""N 라운드 자동 반복. 라운드별 정확도/SSE 기록."""
|
| 392 |
+
history = []
|
| 393 |
+
for i in range(max(1, n_rounds)):
|
| 394 |
+
r = run_round(n_clients=n_clients, eta=eta)
|
| 395 |
+
if not r.get("ok"):
|
| 396 |
+
return r
|
| 397 |
+
history.append({
|
| 398 |
+
"round": i + 1,
|
| 399 |
+
"accuracy_before": r["before"]["accuracy"],
|
| 400 |
+
"accuracy_after": r["after"]["accuracy"],
|
| 401 |
+
"mse_before": r["before"]["mse"],
|
| 402 |
+
"mse_after": r["after"]["mse"],
|
| 403 |
+
"model_version": r["model_version"],
|
| 404 |
+
"max_abs_delta": max((abs(v) for v in r["deltas"].values()), default=0.0),
|
| 405 |
+
})
|
| 406 |
+
return {
|
| 407 |
+
"ok": True,
|
| 408 |
+
"n_rounds": len(history),
|
| 409 |
+
"n_clients": n_clients,
|
| 410 |
+
"eta": eta,
|
| 411 |
+
"history": history,
|
| 412 |
+
}
|
|
@@ -42,6 +42,13 @@ _REJECT_PREFIX = (
|
|
| 42 |
"매뉴얼", "자료", "회의록", "성과", "일정",
|
| 43 |
"받을", "넣을", "쓸", "할", "갈", "올",
|
| 44 |
"안녕", "감사", "환영",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
)
|
| 46 |
|
| 47 |
# 흔한 한국 성씨 (상위 ~30개로 인구 80% 커버)
|
|
@@ -79,6 +86,18 @@ def _is_likely_kr_person(text: str) -> tuple[bool, str | None]:
|
|
| 79 |
return True, None
|
| 80 |
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
def _is_likely_kr_location(text: str) -> tuple[bool, str | None]:
|
| 83 |
if not text or len(text) < 2:
|
| 84 |
return False, "too_short"
|
|
@@ -88,12 +107,20 @@ def _is_likely_kr_location(text: str) -> tuple[bool, str | None]:
|
|
| 88 |
return False, "contains_special_chars"
|
| 89 |
if not _is_pure_hangul(text):
|
| 90 |
return False, "non_hangul"
|
|
|
|
|
|
|
| 91 |
if text.startswith(_REJECT_PREFIX):
|
| 92 |
return False, "prefix_blacklist"
|
| 93 |
if text.endswith(_REJECT_SUFFIX):
|
| 94 |
return False, "suffix_blacklist"
|
| 95 |
-
#
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
|
| 99 |
def _is_likely_org(text: str) -> tuple[bool, str | None]:
|
|
|
|
| 42 |
"매뉴얼", "자료", "회의록", "성과", "일정",
|
| 43 |
"받을", "넣을", "쓸", "할", "갈", "올",
|
| 44 |
"안녕", "감사", "환영",
|
| 45 |
+
# 추가 — 부서·업무·평가 어휘 (회의록·검토 문서에 흔함)
|
| 46 |
+
"평가", "검토", "심사", "관리", "운영", "총무", "기획", "전략",
|
| 47 |
+
"영업", "마케팅", "구매", "인사", "교육", "연구", "개발", "생산",
|
| 48 |
+
"품질", "물류", "재무", "회계", "법무", "감사", "보안", "안전",
|
| 49 |
+
"건의", "보고", "제안", "검사", "조치", "결과", "활동", "처리",
|
| 50 |
+
"참고", "참석", "참여", "협의", "공유", "지원", "요청", "수신",
|
| 51 |
+
"송신", "전달", "교부", "발송", "수령", "접수", "처분", "통보",
|
| 52 |
)
|
| 53 |
|
| 54 |
# 흔한 한국 성씨 (상위 ~30개로 인구 80% 커버)
|
|
|
|
| 86 |
return True, None
|
| 87 |
|
| 88 |
|
| 89 |
+
# LOCATION 은 보통 행정 단위 접미사로 끝남 (긍정 신호)
|
| 90 |
+
_LOC_ADMIN_SUFFIX = (
|
| 91 |
+
"시", "도", "구", "군", "동", "리", "읍", "면", "가",
|
| 92 |
+
"로", "길", "역", "공항", "항구", "원", "공원",
|
| 93 |
+
)
|
| 94 |
+
# LOCATION 으로 흔히 잘못 잡히는 일반 명사
|
| 95 |
+
_LOC_BLACKLIST = {
|
| 96 |
+
"여기", "거기", "저기", "이곳", "그곳", "저곳",
|
| 97 |
+
"본사", "지사", "본부", "지점", "사무실", "회의실",
|
| 98 |
+
"현장", "내부", "외부", "전국", "전체", "각종",
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
def _is_likely_kr_location(text: str) -> tuple[bool, str | None]:
|
| 102 |
if not text or len(text) < 2:
|
| 103 |
return False, "too_short"
|
|
|
|
| 107 |
return False, "contains_special_chars"
|
| 108 |
if not _is_pure_hangul(text):
|
| 109 |
return False, "non_hangul"
|
| 110 |
+
if text in _LOC_BLACKLIST:
|
| 111 |
+
return False, "location_blacklist"
|
| 112 |
if text.startswith(_REJECT_PREFIX):
|
| 113 |
return False, "prefix_blacklist"
|
| 114 |
if text.endswith(_REJECT_SUFFIX):
|
| 115 |
return False, "suffix_blacklist"
|
| 116 |
+
# 행정 단위 접미사가 있으면 강한 신호 — 통과
|
| 117 |
+
if text.endswith(_LOC_ADMIN_SUFFIX):
|
| 118 |
+
return True, None
|
| 119 |
+
# 단일 단어 (2~3자) 인데 행정 접미사 없으면 의심 — 일반 한국 지명 (예: 부산) 만 허용
|
| 120 |
+
# PoC: 2자는 통과, 3자 이상은 행정 단위 필요
|
| 121 |
+
if len(text) <= 2:
|
| 122 |
+
return True, None
|
| 123 |
+
return False, "no_admin_suffix"
|
| 124 |
|
| 125 |
|
| 126 |
def _is_likely_org(text: str) -> tuple[bool, str | None]:
|
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""decisions.reasons / findings 마이그레이션 — 0-weight entity 제거.
|
| 2 |
+
|
| 3 |
+
배경
|
| 4 |
+
----
|
| 5 |
+
이전 분류에서 NRP, DATE_TIME, URL, US_DRIVER_LICENSE 등이 reasons 에 다수 누적되어
|
| 6 |
+
학습을 dominate 했다 (NRP 481회 등장). 분류 오탐 fix (가중치 0 + 정규식 강화 +
|
| 7 |
+
후처리 필터) 적용 후에도 과거 decisions 의 reasons 에는 이 entity 가 그대로 남아 있음.
|
| 8 |
+
|
| 9 |
+
본 스크립트는:
|
| 10 |
+
1) 모든 decisions 의 reasons / findings 에서 NOISE_ENTITIES 제거
|
| 11 |
+
2) ai_score 도 재계산 (reasons 합계와 일관성 보장)
|
| 12 |
+
3) Dry-run 기본 — 실제 적용은 --apply 필요
|
| 13 |
+
4) 적용 전 자동 백업 (decisions.db → decisions.db.before-cleanup-<ts>)
|
| 14 |
+
|
| 15 |
+
실행
|
| 16 |
+
----
|
| 17 |
+
.venv/bin/python scripts/cleanup_decisions_reasons.py # dry-run
|
| 18 |
+
.venv/bin/python scripts/cleanup_decisions_reasons.py --apply # 실제 적용
|
| 19 |
+
|
| 20 |
+
옵션
|
| 21 |
+
----
|
| 22 |
+
--noise-list NRP NORP DATE_TIME URL US_DRIVER_LICENSE ... # 직접 지정
|
| 23 |
+
--keep-low-weight # 가중치 0 인 entity 만 제거 (default — classifier 기준 자동)
|
| 24 |
+
"""
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import argparse
|
| 28 |
+
import json
|
| 29 |
+
import shutil
|
| 30 |
+
import sys
|
| 31 |
+
import time
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
|
| 34 |
+
# 프로젝트 루트 import 경로
|
| 35 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 36 |
+
|
| 37 |
+
import storage # noqa: E402
|
| 38 |
+
import classifier # noqa: E402
|
| 39 |
+
|
| 40 |
+
# classifier.ENTITY_WEIGHTS 에서 0 인 entity 자동 추출 (대책 commit 후의 명시 0 목록)
|
| 41 |
+
def get_zero_weight_entities() -> set[str]:
|
| 42 |
+
return {ent for ent, w in classifier.ENTITY_WEIGHTS.items() if w == 0.0}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def cleanup_reasons(reasons: list[dict], noise: set[str]) -> tuple[list[dict], int]:
|
| 46 |
+
"""reasons 에서 label ∈ noise 인 항목 제거. 반환: (clean, removed_count)."""
|
| 47 |
+
if not isinstance(reasons, list):
|
| 48 |
+
return reasons, 0
|
| 49 |
+
out = []
|
| 50 |
+
removed = 0
|
| 51 |
+
for r in reasons:
|
| 52 |
+
lab = r.get("label")
|
| 53 |
+
kind = r.get("kind")
|
| 54 |
+
if kind == "entity" and lab in noise:
|
| 55 |
+
removed += int(r.get("count") or 1)
|
| 56 |
+
continue
|
| 57 |
+
out.append(r)
|
| 58 |
+
return out, removed
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def cleanup_findings(findings: list[dict], noise: set[str]) -> tuple[list[dict], int]:
|
| 62 |
+
"""findings 에서 entity_type ∈ noise 인 항목 제거."""
|
| 63 |
+
if not isinstance(findings, list):
|
| 64 |
+
return findings, 0
|
| 65 |
+
out = []
|
| 66 |
+
removed = 0
|
| 67 |
+
for f in findings:
|
| 68 |
+
if f.get("entity_type") in noise:
|
| 69 |
+
removed += 1
|
| 70 |
+
continue
|
| 71 |
+
out.append(f)
|
| 72 |
+
return out, removed
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def recompute_ai_score(reasons: list[dict]) -> float:
|
| 76 |
+
"""reasons 합산 (contribution 또는 weight*count)."""
|
| 77 |
+
total = 0.0
|
| 78 |
+
for r in reasons:
|
| 79 |
+
contrib = r.get("contribution")
|
| 80 |
+
if contrib is not None:
|
| 81 |
+
total += float(contrib)
|
| 82 |
+
else:
|
| 83 |
+
try:
|
| 84 |
+
total += float(r.get("weight") or 0.0) * int(r.get("count") or 0)
|
| 85 |
+
except (TypeError, ValueError):
|
| 86 |
+
pass
|
| 87 |
+
return round(total, 4)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def main():
|
| 91 |
+
p = argparse.ArgumentParser(description=__doc__)
|
| 92 |
+
p.add_argument("--apply", action="store_true",
|
| 93 |
+
help="실제 DB 수정 (default: dry-run)")
|
| 94 |
+
p.add_argument("--noise-list", nargs="*", default=None,
|
| 95 |
+
help="강제로 제거할 entity 목록 (default: classifier 의 0-weight)")
|
| 96 |
+
args = p.parse_args()
|
| 97 |
+
|
| 98 |
+
if args.noise_list:
|
| 99 |
+
noise = set(args.noise_list)
|
| 100 |
+
print(f"강제 noise 목록 사용: {sorted(noise)}")
|
| 101 |
+
else:
|
| 102 |
+
noise = get_zero_weight_entities()
|
| 103 |
+
print(f"classifier 의 0-weight entity 자동 추출: {sorted(noise)}")
|
| 104 |
+
|
| 105 |
+
if not noise:
|
| 106 |
+
print("제거할 entity 없음. 종료.")
|
| 107 |
+
return
|
| 108 |
+
|
| 109 |
+
decisions = storage.fetch_all_for_training()
|
| 110 |
+
print(f"\n총 결정: {len(decisions)}건 분석 시작.\n")
|
| 111 |
+
|
| 112 |
+
total_removed_reasons = 0
|
| 113 |
+
total_removed_findings = 0
|
| 114 |
+
affected = 0
|
| 115 |
+
plans = []
|
| 116 |
+
|
| 117 |
+
for d in decisions:
|
| 118 |
+
reasons_clean, rrem = cleanup_reasons(d.get("reasons") or [], noise)
|
| 119 |
+
findings_clean, frem = cleanup_findings(d.get("findings") or [], noise)
|
| 120 |
+
if rrem == 0 and frem == 0:
|
| 121 |
+
continue
|
| 122 |
+
new_score = recompute_ai_score(reasons_clean)
|
| 123 |
+
affected += 1
|
| 124 |
+
total_removed_reasons += rrem
|
| 125 |
+
total_removed_findings += frem
|
| 126 |
+
plans.append({
|
| 127 |
+
"id": d["id"],
|
| 128 |
+
"ai_grade": d.get("ai_grade"),
|
| 129 |
+
"user_grade": d.get("user_grade"),
|
| 130 |
+
"ai_score_old": d.get("ai_score"),
|
| 131 |
+
"ai_score_new": new_score,
|
| 132 |
+
"removed_reasons": rrem,
|
| 133 |
+
"removed_findings": frem,
|
| 134 |
+
"reasons_clean": reasons_clean,
|
| 135 |
+
"findings_clean": findings_clean,
|
| 136 |
+
})
|
| 137 |
+
|
| 138 |
+
print(f"영향 결정: {affected}건 / 전체 {len(decisions)}건")
|
| 139 |
+
print(f"제거할 reasons 항목 누적: {total_removed_reasons}건")
|
| 140 |
+
print(f"제거할 findings 항목 누적: {total_removed_findings}건")
|
| 141 |
+
print()
|
| 142 |
+
|
| 143 |
+
if plans[:5]:
|
| 144 |
+
print("샘플 변경 (상�� 5건):")
|
| 145 |
+
for p in plans[:5]:
|
| 146 |
+
print(f" id={p['id']:3d} ai={p['ai_grade']}→user={p['user_grade']} "
|
| 147 |
+
f"score {p['ai_score_old']} → {p['ai_score_new']} "
|
| 148 |
+
f"(-{p['removed_reasons']} reasons, -{p['removed_findings']} findings)")
|
| 149 |
+
|
| 150 |
+
if not args.apply:
|
| 151 |
+
print("\n[DRY-RUN] 변경 미적용. --apply 추가하면 실제 DB update.")
|
| 152 |
+
return
|
| 153 |
+
|
| 154 |
+
# 백업
|
| 155 |
+
db_path = Path(storage.DB_PATH) if hasattr(storage, "DB_PATH") else Path("decisions.db")
|
| 156 |
+
ts = time.strftime("%Y%m%d-%H%M%S")
|
| 157 |
+
backup = db_path.with_suffix(db_path.suffix + f".before-cleanup-{ts}")
|
| 158 |
+
if db_path.exists():
|
| 159 |
+
shutil.copy2(db_path, backup)
|
| 160 |
+
print(f"\n✅ 백업 생성: {backup}")
|
| 161 |
+
|
| 162 |
+
# 실제 UPDATE
|
| 163 |
+
with storage.connect() as c:
|
| 164 |
+
for p in plans:
|
| 165 |
+
c.execute(
|
| 166 |
+
"UPDATE decisions SET reasons_json=?, findings_json=?, ai_score=? WHERE id=?",
|
| 167 |
+
(
|
| 168 |
+
json.dumps(p["reasons_clean"], ensure_ascii=False),
|
| 169 |
+
json.dumps(p["findings_clean"], ensure_ascii=False),
|
| 170 |
+
p["ai_score_new"],
|
| 171 |
+
p["id"],
|
| 172 |
+
),
|
| 173 |
+
)
|
| 174 |
+
print(f"✅ {affected}건 결정 update 완료.")
|
| 175 |
+
print(f"\n다음 단계: 학습/이력 탭에서 \"새로고침\" 후 \"🚀 학습 라운드 실행\" → 깨끗한 데이터로 학습.")
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
if __name__ == "__main__":
|
| 179 |
+
main()
|
|
@@ -2596,6 +2596,81 @@ AWS Key: AKIAIOSFODNN7EXAMPLE
|
|
| 2596 |
<p class="ser-size">평문 합산 결과와 HE 복호화 결과 일치 확인 + 변경된 entity 별 Δw 시각화.</p>
|
| 2597 |
<div id="hefl-step6-body"><div class="empty">실행 후 표시</div></div>
|
| 2598 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2599 |
</div>
|
| 2600 |
|
| 2601 |
<!-- =================== TAB : 신뢰도 보정 (Platt — SPEC §2.4 (2)) =================== -->
|
|
@@ -6165,6 +6240,104 @@ document.querySelector('.tab-btn[data-tab="calib"]')?.addEventListener('click',
|
|
| 6165 |
});
|
| 6166 |
|
| 6167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6168 |
/* ========================= Drift Detection (SPEC §2.4 (4)) ========================= */
|
| 6169 |
const driftUI = {
|
| 6170 |
window: document.getElementById('drift-window'),
|
|
|
|
| 2596 |
<p class="ser-size">평문 합산 결과와 HE 복호화 결과 일치 확인 + 변경된 entity 별 Δw 시각화.</p>
|
| 2597 |
<div id="hefl-step6-body"><div class="empty">실행 후 표시</div></div>
|
| 2598 |
</div>
|
| 2599 |
+
|
| 2600 |
+
<!-- ====== Phase 2 — N 라운드 자동 반복 ====== -->
|
| 2601 |
+
<div class="card" style="border-left:4px solid #10B981; background:rgba(16,185,129,.04);">
|
| 2602 |
+
<h2><span class="ico" data-lucide="rotate-ccw"></span>Phase 2 — N 라운드 자동 반복 (서버 시뮬)</h2>
|
| 2603 |
+
<p class="lead">Phase 1 의 1 라운드를 N 회 자동 반복합니다. 라운드별 정확도·MSE 변화를 시계열로 시각화하여 수렴 여부 확인. <b>HE 보호는 Phase 1 가 BFV 동형성으로 검증</b>; Phase 2 는 서버 측 평문 시뮬레이션으로 빠르게 N 라운드 돌림.</p>
|
| 2604 |
+
|
| 2605 |
+
<details style="margin-bottom:14px;">
|
| 2606 |
+
<summary style="cursor:pointer; font-weight:600;">📖 초보 가이드 — Phase 2 가 Phase 1 과 다른 점</summary>
|
| 2607 |
+
<div style="padding:12px; background:#F4F4F8; border-radius:6px; margin-top:8px; font-size:13.5px; line-height:1.7;">
|
| 2608 |
+
<p><b>Phase 1</b> (위 6단계) — 실제 BFV 암호화·합산·복호화. 한 라운드 ~5초 (key gen 등 포함).</p>
|
| 2609 |
+
<p><b>Phase 2</b> (이 섹션) — 같은 흐름의 <b>평문 시뮬레이션</b>. HE 의 효과는 Phase 1 이 보여줬으니, 이제 라운드 반복 수렴만 보는 단계. 한 라운드 ~10ms.</p>
|
| 2610 |
+
<p><b>핵심 통찰:</b> HE 의 동형성으로 평문 합산 결과 = HE 복호화 결과 (Phase 1 검증). 따라서 Phase 2 의 평문 시뮬도 production 의 HE 결과와 같음.</p>
|
| 2611 |
+
<p><b>data 소스:</b> reasons 기반 (train.py 와 동일). 결정 이력의 noise (NRP 등) 영향은 노이즈 fix commit 이후 가중치 0 으로 차단됨.</p>
|
| 2612 |
+
</div>
|
| 2613 |
+
</details>
|
| 2614 |
+
|
| 2615 |
+
<div class="grid" style="margin-bottom:10px;">
|
| 2616 |
+
<div class="col">
|
| 2617 |
+
<label style="display:block; font-weight:600; margin-bottom:4px;">라운드 수 (n_rounds)</label>
|
| 2618 |
+
<input type="number" id="hefl2-rounds" min="1" max="100" value="10" style="width:90px;">
|
| 2619 |
+
</div>
|
| 2620 |
+
<div class="col">
|
| 2621 |
+
<label style="display:block; font-weight:600; margin-bottom:4px;">클라이언트 수</label>
|
| 2622 |
+
<input type="number" id="hefl2-nclients" min="1" max="5" value="2" style="width:90px;">
|
| 2623 |
+
</div>
|
| 2624 |
+
<div class="col">
|
| 2625 |
+
<label style="display:block; font-weight:600; margin-bottom:4px;">η (learning rate)</label>
|
| 2626 |
+
<input type="number" id="hefl2-eta" min="0.001" max="0.5" step="0.005" value="0.02" style="width:90px;">
|
| 2627 |
+
</div>
|
| 2628 |
+
</div>
|
| 2629 |
+
<div style="display:flex; gap:8px; align-items:center; margin-bottom:14px;">
|
| 2630 |
+
<button id="hefl2-run"><span class="ico" data-lucide="zap"></span>N 라운드 자동 실행</button>
|
| 2631 |
+
<span id="hefl2-status" class="ser-size">대기 중</span>
|
| 2632 |
+
</div>
|
| 2633 |
+
|
| 2634 |
+
<div id="hefl2-result" style="display:none;">
|
| 2635 |
+
<div class="grid">
|
| 2636 |
+
<div class="col">
|
| 2637 |
+
<div class="card" style="background:white; box-shadow:none; margin-bottom:0;">
|
| 2638 |
+
<h3 style="font-size:13px; margin:0 0 6px;">최종 성과</h3>
|
| 2639 |
+
<table style="width:100%; font-size:13px;">
|
| 2640 |
+
<tr><td class="ser-size">시작 정확도</td><td class="num"><code id="hefl2-acc-start">—</code></td></tr>
|
| 2641 |
+
<tr><td class="ser-size">최종 정확도</td><td class="num"><code id="hefl2-acc-end">—</code></td></tr>
|
| 2642 |
+
<tr><td class="ser-size">시작 MSE</td><td class="num"><code id="hefl2-mse-start">—</code></td></tr>
|
| 2643 |
+
<tr><td class="ser-size">최종 MSE</td><td class="num"><code id="hefl2-mse-end">—</code></td></tr>
|
| 2644 |
+
<tr><td class="ser-size">총 라운드</td><td class="num"><code id="hefl2-rounds-done">—</code></td></tr>
|
| 2645 |
+
</table>
|
| 2646 |
+
</div>
|
| 2647 |
+
</div>
|
| 2648 |
+
<div class="col" style="flex:2;">
|
| 2649 |
+
<div class="card" style="background:white; box-shadow:none; margin-bottom:0;">
|
| 2650 |
+
<h3 style="font-size:13px; margin:0 0 6px;">시계열 — 정확도(파랑) · MSE(주황)</h3>
|
| 2651 |
+
<svg id="hefl2-chart" width="700" height="240" style="max-width:100%; border:1px solid var(--line); border-radius:4px; background:white;">
|
| 2652 |
+
<text x="350" y="120" text-anchor="middle" fill="#9C9C9C" font-size="13">실행 후 표시</text>
|
| 2653 |
+
</svg>
|
| 2654 |
+
</div>
|
| 2655 |
+
</div>
|
| 2656 |
+
</div>
|
| 2657 |
+
<div class="card" style="background:white; box-shadow:none; margin-top:10px;">
|
| 2658 |
+
<h3 style="font-size:13px; margin:0 0 6px;">라운드 이력</h3>
|
| 2659 |
+
<div style="max-height:260px; overflow:auto; border:1px solid var(--line); border-radius:4px;">
|
| 2660 |
+
<table style="width:100%; font-size:12.5px;">
|
| 2661 |
+
<thead style="background:#F4F4F8; position:sticky; top:0;"><tr>
|
| 2662 |
+
<th style="padding:4px 8px;">#</th>
|
| 2663 |
+
<th class="num">accuracy (before→after)</th>
|
| 2664 |
+
<th class="num">MSE (before→after)</th>
|
| 2665 |
+
<th class="num">max|Δw|</th>
|
| 2666 |
+
<th>model_version</th>
|
| 2667 |
+
</tr></thead>
|
| 2668 |
+
<tbody id="hefl2-history"></tbody>
|
| 2669 |
+
</table>
|
| 2670 |
+
</div>
|
| 2671 |
+
</div>
|
| 2672 |
+
</div>
|
| 2673 |
+
</div>
|
| 2674 |
</div>
|
| 2675 |
|
| 2676 |
<!-- =================== TAB : 신뢰도 보정 (Platt — SPEC §2.4 (2)) =================== -->
|
|
|
|
| 6240 |
});
|
| 6241 |
|
| 6242 |
|
| 6243 |
+
/* ========================= HE-FL Phase 2 — N 라운드 자동 ========================= */
|
| 6244 |
+
const hefl2UI = {
|
| 6245 |
+
rounds: document.getElementById('hefl2-rounds'),
|
| 6246 |
+
nClients: document.getElementById('hefl2-nclients'),
|
| 6247 |
+
eta: document.getElementById('hefl2-eta'),
|
| 6248 |
+
run: document.getElementById('hefl2-run'),
|
| 6249 |
+
status: document.getElementById('hefl2-status'),
|
| 6250 |
+
result: document.getElementById('hefl2-result'),
|
| 6251 |
+
accStart: document.getElementById('hefl2-acc-start'),
|
| 6252 |
+
accEnd: document.getElementById('hefl2-acc-end'),
|
| 6253 |
+
mseStart: document.getElementById('hefl2-mse-start'),
|
| 6254 |
+
mseEnd: document.getElementById('hefl2-mse-end'),
|
| 6255 |
+
roundsDone:document.getElementById('hefl2-rounds-done'),
|
| 6256 |
+
chart: document.getElementById('hefl2-chart'),
|
| 6257 |
+
history: document.getElementById('hefl2-history'),
|
| 6258 |
+
};
|
| 6259 |
+
|
| 6260 |
+
function _hefl2Pct(v) { return v == null ? '—' : (v * 100).toFixed(1) + '%'; }
|
| 6261 |
+
function _hefl2Num(v, d=4) { return v == null ? '—' : Number(v).toFixed(d); }
|
| 6262 |
+
|
| 6263 |
+
function _hefl2DrawChart(history) {
|
| 6264 |
+
const svg = hefl2UI.chart;
|
| 6265 |
+
const W = 700, H = 240, m = {top:20,right:60,bottom:30,left:50};
|
| 6266 |
+
const gW = W - m.left - m.right, gH = H - m.top - m.bottom;
|
| 6267 |
+
if (!history || !history.length) {
|
| 6268 |
+
svg.innerHTML = `<text x="${W/2}" y="${H/2}" text-anchor="middle" fill="#9C9C9C" font-size="13">데이터 없음</text>`;
|
| 6269 |
+
return;
|
| 6270 |
+
}
|
| 6271 |
+
const n = history.length;
|
| 6272 |
+
const xAt = i => m.left + (n === 1 ? gW/2 : (i / (n - 1)) * gW);
|
| 6273 |
+
const yAcc = v => m.top + (1 - (v ?? 0)) * gH;
|
| 6274 |
+
const maxMse = Math.max(0.5, ...history.map(h => h.mse_after ?? 0));
|
| 6275 |
+
const yMse = v => m.top + (1 - ((v ?? 0) / maxMse)) * gH;
|
| 6276 |
+
const parts = [];
|
| 6277 |
+
parts.push(`<rect x="${m.left}" y="${m.top}" width="${gW}" height="${gH}" fill="white" stroke="#E8E8EC"/>`);
|
| 6278 |
+
for (let i = 0; i <= 5; i++) {
|
| 6279 |
+
const y = m.top + (i/5)*gH;
|
| 6280 |
+
parts.push(`<line x1="${m.left}" y1="${y}" x2="${m.left+gW}" y2="${y}" stroke="#F0F0F2" stroke-width="0.5"/>`);
|
| 6281 |
+
parts.push(`<text x="${m.left-6}" y="${y+3}" text-anchor="end" font-size="10" fill="#9C9C9C">${(1-i/5).toFixed(1)}</text>`);
|
| 6282 |
+
parts.push(`<text x="${m.left+gW+6}" y="${y+3}" font-size="10" fill="#92400E">${(maxMse*(1-i/5)).toFixed(2)}</text>`);
|
| 6283 |
+
}
|
| 6284 |
+
// accuracy line (after)
|
| 6285 |
+
const accPath = history.map((h,i) => `${i===0?'M':'L'} ${xAt(i)} ${yAcc(h.accuracy_after)}`).join(' ');
|
| 6286 |
+
parts.push(`<path d="${accPath}" stroke="#6366F1" stroke-width="2" fill="none"/>`);
|
| 6287 |
+
// mse line
|
| 6288 |
+
const msePath = history.map((h,i) => `${i===0?'M':'L'} ${xAt(i)} ${yMse(h.mse_after)}`).join(' ');
|
| 6289 |
+
parts.push(`<path d="${msePath}" stroke="#F59E0B" stroke-width="2" fill="none"/>`);
|
| 6290 |
+
// points
|
| 6291 |
+
for (let i = 0; i < n; i++) {
|
| 6292 |
+
parts.push(`<circle cx="${xAt(i)}" cy="${yAcc(history[i].accuracy_after)}" r="3" fill="#6366F1"/>`);
|
| 6293 |
+
parts.push(`<circle cx="${xAt(i)}" cy="${yMse(history[i].mse_after)}" r="3" fill="#F59E0B"/>`);
|
| 6294 |
+
}
|
| 6295 |
+
parts.push(`<text x="${m.left + gW/2}" y="${H-8}" text-anchor="middle" font-size="11" fill="#525252">round 1 → ${n}</text>`);
|
| 6296 |
+
parts.push(`<text x="${m.left-36}" y="${m.top-6}" font-size="10" fill="#6366F1">정확도</text>`);
|
| 6297 |
+
parts.push(`<text x="${m.left+gW+6}" y="${m.top-6}" font-size="10" fill="#92400E">MSE</text>`);
|
| 6298 |
+
svg.innerHTML = parts.join('');
|
| 6299 |
+
}
|
| 6300 |
+
|
| 6301 |
+
hefl2UI.run.addEventListener('click', async () => {
|
| 6302 |
+
const n_rounds = parseInt(hefl2UI.rounds.value) || 10;
|
| 6303 |
+
const n_clients = parseInt(hefl2UI.nClients.value) || 2;
|
| 6304 |
+
const eta = parseFloat(hefl2UI.eta.value) || 0.02;
|
| 6305 |
+
hefl2UI.run.disabled = true;
|
| 6306 |
+
hefl2UI.status.textContent = `${n_rounds} 라운드 실행 중...`;
|
| 6307 |
+
try {
|
| 6308 |
+
const r = await fetch('/api/he/fl/auto', {
|
| 6309 |
+
method: 'POST', headers: {'Content-Type': 'application/json'},
|
| 6310 |
+
body: JSON.stringify({ n_rounds, n_clients, eta }),
|
| 6311 |
+
});
|
| 6312 |
+
const j = await r.json();
|
| 6313 |
+
if (!j.ok) {
|
| 6314 |
+
hefl2UI.status.textContent = '❌ ' + (j.message || '실패');
|
| 6315 |
+
return;
|
| 6316 |
+
}
|
| 6317 |
+
const h = j.history;
|
| 6318 |
+
hefl2UI.result.style.display = '';
|
| 6319 |
+
hefl2UI.accStart.textContent = _hefl2Pct(h[0]?.accuracy_before);
|
| 6320 |
+
hefl2UI.accEnd.textContent = _hefl2Pct(h[h.length-1]?.accuracy_after);
|
| 6321 |
+
hefl2UI.mseStart.textContent = _hefl2Num(h[0]?.mse_before);
|
| 6322 |
+
hefl2UI.mseEnd.textContent = _hefl2Num(h[h.length-1]?.mse_after);
|
| 6323 |
+
hefl2UI.roundsDone.textContent = h.length;
|
| 6324 |
+
_hefl2DrawChart(h);
|
| 6325 |
+
hefl2UI.history.innerHTML = h.map(row => `<tr>
|
| 6326 |
+
<td style="padding:3px 8px;">${row.round}</td>
|
| 6327 |
+
<td class="num">${_hefl2Pct(row.accuracy_before)} → <b>${_hefl2Pct(row.accuracy_after)}</b></td>
|
| 6328 |
+
<td class="num">${_hefl2Num(row.mse_before, 3)} → <b>${_hefl2Num(row.mse_after, 3)}</b></td>
|
| 6329 |
+
<td class="num">${_hefl2Num(row.max_abs_delta, 4)}</td>
|
| 6330 |
+
<td><code style="font-size:11px;">${row.model_version}</code></td>
|
| 6331 |
+
</tr>`).join('');
|
| 6332 |
+
hefl2UI.status.textContent = `✅ 완료 — ${h.length} 라운드, 정확도 ${_hefl2Pct(h[0]?.accuracy_before)} → ${_hefl2Pct(h[h.length-1]?.accuracy_after)}`;
|
| 6333 |
+
} catch (e) {
|
| 6334 |
+
hefl2UI.status.textContent = '❌ ' + e.message;
|
| 6335 |
+
} finally {
|
| 6336 |
+
hefl2UI.run.disabled = false;
|
| 6337 |
+
}
|
| 6338 |
+
});
|
| 6339 |
+
|
| 6340 |
+
|
| 6341 |
/* ========================= Drift Detection (SPEC §2.4 (4)) ========================= */
|
| 6342 |
const driftUI = {
|
| 6343 |
window: document.getElementById('drift-window'),
|