jkkim commited on
Commit
464bc42
·
1 Parent(s): fb947cb

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 학습 클라이언트 시뮬 + 서버 시뮬\" 완료.

Files changed (6) hide show
  1. app.py +24 -0
  2. app_lite.py +24 -0
  3. he_fl.py +157 -1
  4. ner_filter.py +29 -2
  5. scripts/cleanup_decisions_reasons.py +179 -0
  6. templates/index.html +173 -0
app.py CHANGED
@@ -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"))
app_lite.py CHANGED
@@ -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"))
he_fl.py CHANGED
@@ -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.list_decisions(limit=1000)),
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
+ }
ner_filter.py CHANGED
@@ -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
- # LOCATION 은 보통 시/도/구/동/리 같은 행정 단위 끝남 엄격하게
96
- return True, None
 
 
 
 
 
 
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]:
scripts/cleanup_decisions_reasons.py ADDED
@@ -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()
templates/index.html CHANGED
@@ -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'),