hetest / train.py
jkkim
Add classifier/anonymization/learning pipeline + Genesis design + Lucide icons
8485d6e
Raw
History Blame Contribute Delete
13.6 kB
"""ํ•™์Šต ํŠธ๋ฆฌ๊ฑฐ + Gradient ๊ณ„์‚ฐ (PoC ๋‹จ์ˆœํ™” ๋ฒ„์ „)
=================================================
SPEC ยง1 ๊ธฐ๋Šฅ 6 โ€” *"ํ•™์Šต ์กฐ๊ฑด ๋„๋‹ฌ ์‹œ gradient ์ƒ์„ฑ"* ์˜ ๋ฐ๋ชจ/๊ฒ€์ฆ์šฉ ๊ตฌํ˜„.
์„ค๊ณ„ ์˜๋„
---------
- ํ˜„์žฌ ๋ถ„๋ฅ˜๊ธฐ `rule-v1` ์€ **์„ ํ˜• ์ ์ˆ˜ ๋ชจ๋ธ**: ``score = ฮฃ w_e ยท x_e``
(x_e = entity ๋˜๋Š” keyword ์˜ ๋ณธ๋ฌธ ๋‚ด ๊ฐœ์ˆ˜)
- "ํ•™์Šต" ์˜ ๊ฐ€์žฅ ๋‹จ์ˆœํ•˜๊ณ  ์˜๋ฏธ์žˆ๋Š” ํ˜•ํƒœ = **์„ ํ˜• ํšŒ๊ท€์˜ 1-step gradient descent**.
- ์‚ฌ์šฉ์ž๊ฐ€ ๋ถ€์—ฌํ•œ ๋“ฑ๊ธ‰์„ *๋ชฉํ‘œ ์ ์ˆ˜* ๋กœ ๋งคํ•‘ํ•œ ๋’ค, ์ž”์ฐจ ร— feature ๋กœ ๊ฐ€์ค‘์น˜ ์—…๋ฐ์ดํŠธ.
- ๊ฒฐ๊ณผ = **proposed weight deltas** + ์ƒˆ ๊ฐ€์ค‘์น˜ + ์ ์šฉ ์ „ํ›„ SSE / ์ •ํ™•๋„.
- ์ด ๊ฒฐ๊ณผ๋ฅผ **๊ทธ๋Œ€๋กœ ENTITY_WEIGHTS / GRADE_KEYWORDS ์— ๋ฐ˜์˜ํ•˜๋ฉด ๋ชจ๋ธ ํ•ซ์Šค์™‘ ์™„๋ฃŒ**.
ํ•™์Šต ์กฐ๊ฑด (SPEC ยง9 ์˜ ๋‹จ์ˆœํ™”)
-----------------------------
PoC ๋ฐ๋ชจ ํŽธ์˜๋ฅผ ์œ„ํ•ด ์ž„๊ณ„๊ฐ’์„ ๋‚ฎ์ท„๋‹ค โ€” ์‹œ์—ฐ ์‹œ 5~10 ๊ฑด์ด๋ฉด ๊ตฌ๋™ ๊ฐ€๋Šฅ:
- ๋ˆ„์  ๋ ˆ์ด๋ธ” โ‰ฅ 10
- ๋“ฑ๊ธ‰๋ณ„ ์ตœ์†Œ โ‰ฅ 3 (C, S, O ๊ฐ๊ฐ)
- ๊ฐญ(gap > 0) โ‰ฅ 3
- ๋ชจ๋‘ ๋งŒ์กฑํ•˜๋ฉด ready=True
Gradient ์ •์˜ (์„ ํ˜• ํšŒ๊ท€)
-------------------------
๊ฐ ๊ฒฐ์ •์— ๋Œ€ํ•ด:
target = TARGET_SCORE[user_grade] # Oโ†’1.0, Sโ†’3.5, Cโ†’6.5 (๊ฐ ๋“ฑ๊ธ‰ ๋ฐด๋“œ ์ค‘์•™)
pred = ฮฃ w_f ยท x_f # ํ˜„์žฌ ๋ถ„๋ฅ˜๊ธฐ๊ฐ€ ๋ถ€์—ฌํ•œ ์ ์ˆ˜
resid = target - pred
ฮ”w_f += ฮท ยท resid ยท x_f / (1 + ฮฃ x_f)
ํ•™์Šต ๊ฐ€์ค‘์น˜ (SPEC ยง10.2): sample_weight = 1 + 1.5 ยท gap
โ†’ ๊ฐญ ํฐ ์ƒ˜ํ”Œ์ผ์ˆ˜๋ก grad ์— ๋” ํฌ๊ฒŒ ๊ธฐ์—ฌ. ๋ฐ์ดํ„ฐ ์ ์€ PoC ์—์„œ ํ•ต์‹ฌ.
ฮท (ํ•™์Šต๋ฅ ) = 0.05 (๊ธฐ๋ณธ). ํฐ deltas ๋ˆ„์  ๋ฐฉ์ง€๋ฅผ ์œ„ํ•œ max_step clip.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Iterable
import classifier # ENTITY_WEIGHTS / GRADE_KEYWORDS ์˜ *์›๋ณธ* (in-memory) ์ฐธ์กฐ
# ---------------------------------------------------------------------------
# ํ•™์Šต ํŠธ๋ฆฌ๊ฑฐ ์กฐ๊ฑด (PoC ๋ฐ๋ชจ์šฉ โ€” SPEC ยง9 ์˜ ์™„ํ™” ๋ฒ„์ „)
# ---------------------------------------------------------------------------
MIN_LABELED = 10
MIN_PER_GRADE = 3
MIN_GAP_DECISIONS = 3
# Gradient hyperparameters
LEARNING_RATE = 0.05
MAX_DELTA = 0.5 # |ฮ”w| ์ƒํ•œ โ€” ํ•œ ๋ผ์šด๋“œ์—์„œ ๊ฐ€์ค‘์น˜๊ฐ€ ๋„ˆ๋ฌด ํ”๋“ค๋ฆฌ์ง€ ์•Š๊ฒŒ
# ๋“ฑ๊ธ‰ โ†’ ๋ชฉํ‘œ ์ ์ˆ˜ (rule-v1 ์˜ ์ž„๊ณ„๊ฐ’ 5.0/2.0 ๊ธฐ์ค€)
TARGET_SCORE = {"O": 1.0, "S": 3.5, "C": 6.5}
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
# ---------------------------------------------------------------------------
# ํ•™์Šต ํŠธ๋ฆฌ๊ฑฐ ์กฐ๊ฑด ํ‰๊ฐ€
# ---------------------------------------------------------------------------
def evaluate_readiness(decisions: list[dict]) -> dict:
"""์ €์žฅ๋œ ๊ฒฐ์ • ๋ฆฌ์ŠคํŠธ โ†’ ํ•™์Šต ๊ฐ€๋Šฅ ์—ฌ๋ถ€ + ์‚ฌ์œ ."""
n = len(decisions)
per = {"C": 0, "S": 0, "O": 0}
gap_pos = 0
for d in decisions:
u = d.get("user_grade")
if u in per:
per[u] += 1
if int(d.get("gap", 0)) > 0:
gap_pos += 1
checks = [
{"key": "labeled", "label": "๋ˆ„์  ๋ ˆ์ด๋ธ”",
"value": n, "threshold": MIN_LABELED,
"ok": n >= MIN_LABELED},
{"key": "per_grade_C", "label": "C ๋“ฑ๊ธ‰ ๋ˆ„์ ",
"value": per["C"], "threshold": MIN_PER_GRADE,
"ok": per["C"] >= MIN_PER_GRADE},
{"key": "per_grade_S", "label": "S ๋“ฑ๊ธ‰ ๋ˆ„์ ",
"value": per["S"], "threshold": MIN_PER_GRADE,
"ok": per["S"] >= MIN_PER_GRADE},
{"key": "per_grade_O", "label": "O ๋“ฑ๊ธ‰ ๋ˆ„์ ",
"value": per["O"], "threshold": MIN_PER_GRADE,
"ok": per["O"] >= MIN_PER_GRADE},
{"key": "gap", "label": "์‚ฌ์šฉ์ž-AI ๊ฐญ(>0)",
"value": gap_pos, "threshold": MIN_GAP_DECISIONS,
"ok": gap_pos >= MIN_GAP_DECISIONS},
]
return {
"ready": all(c["ok"] for c in checks),
"checks": checks,
"totals": {"labeled": n, "per_grade": per, "gap_positive": gap_pos},
}
# ---------------------------------------------------------------------------
# Feature ์ถ”์ถœ โ€” decision 1๊ฑด โ†’ {feature_name: count}
# ---------------------------------------------------------------------------
def _features_of(decision: dict) -> dict[str, int]:
"""์ €์žฅ๋œ reasons ์—์„œ feature counts ๋ณต์›.
`reasons` ๋Š” ๋ถ„๋ฅ˜ ์‹œ์  ์Šค๋ƒ…์ƒท์ด๊ณ  ๊ทธ ์ž์ฒด๊ฐ€ (entity_type or keyword_label, count)
๋ฆฌ์ŠคํŠธ๋ผ ๊ทธ๋Œ€๋กœ features ๋กœ ์‚ฌ์šฉ. (entity_type ๊ณผ keyword ๋ผ๋ฒจ์ด ์ถฉ๋Œํ•˜์ง€ ์•Š๋„๋ก
keyword ๋Š” 'kw:' prefix ๋ฅผ ๋ถ™์ธ ๋ณ„๋„ namespace ๋กœ ๋‹ค๋ฃฌ๋‹ค.)
"""
feats: dict[str, int] = {}
for r in decision.get("reasons") or []:
kind = r.get("kind")
label = r.get("label")
if not label:
continue
if kind == "keyword":
# keyword ๋Š” cap ์ ์šฉ๋œ counted ์‚ฌ์šฉ (์—†์œผ๋ฉด count)
cnt = int(r.get("counted") or r.get("count") or 0)
feats[f"kw:{label}"] = feats.get(f"kw:{label}", 0) + cnt
else:
cnt = int(r.get("count") or 0)
feats[label] = feats.get(label, 0) + cnt
return feats
def _current_weight(feature: str) -> float:
"""ENTITY_WEIGHTS / GRADE_KEYWORDS ์—์„œ ํ˜„์žฌ ๊ฐ€์ค‘์น˜ ์กฐํšŒ."""
if feature.startswith("kw:"):
label = feature[3:]
for _kw, w, lbl in classifier.GRADE_KEYWORDS:
if lbl == label:
return float(w)
return 0.0
return classifier.ENTITY_WEIGHTS.get(feature, classifier.DEFAULT_ENTITY_WEIGHT)
def _predict_score(feats: dict[str, int]) -> float:
return sum(_current_weight(f) * c for f, c in feats.items())
# ---------------------------------------------------------------------------
# Training step (1 epoch, ์ „์ฒด ๋ฐฐ์น˜)
# ---------------------------------------------------------------------------
def train_one_round(
decisions: list[dict],
learning_rate: float = LEARNING_RATE,
max_delta: float = MAX_DELTA,
) -> dict:
"""์ „์ฒด ๊ฒฐ์ •์— ๋Œ€ํ•ด ํ‰๊ท  gradient ๋ฅผ ๊ณ„์‚ฐํ•˜์—ฌ ๊ฐ€์ค‘์น˜ ์—…๋ฐ์ดํŠธ ์ œ์•ˆ.
Returns:
{
decisions_count, accuracy_before, accuracy_after,
sse_before, sse_after,
weight_deltas: {feature: signed_delta},
new_weights: {feature: new_w},
per_decision: [{id, target, pred_before, pred_after, residual}, ...]
}
"""
if not decisions:
return {"decisions_count": 0, "weight_deltas": {}, "new_weights": {}}
# 1) ๋ชจ๋“  feature ์ˆ˜์ง‘ + ๊ธฐ์กด ๊ฐ€์ค‘์น˜ ์Šค๋ƒ…์ƒท
all_feats: set[str] = set()
for d in decisions:
all_feats.update(_features_of(d).keys())
base_w: dict[str, float] = {f: _current_weight(f) for f in all_feats}
grad_sum: dict[str, float] = {f: 0.0 for f in all_feats}
weight_sum = 0.0
sse_before = 0.0
correct_before = 0
per_decision = []
# 2) ๋ˆ„์  grad ๊ณ„์‚ฐ
for d in decisions:
feats = _features_of(d)
target = TARGET_SCORE[d["user_grade"]]
pred = sum(base_w[f] * c for f, c in feats.items())
resid = target - pred
sw = 1.0 + 1.5 * int(d.get("gap", 0)) # SPEC ยง10.2
weight_sum += sw
denom = 1.0 + sum(feats.values())
for f, c in feats.items():
grad_sum[f] += sw * resid * c / denom
sse_before += (target - pred) ** 2
if d["ai_grade"] == d["user_grade"]:
correct_before += 1
per_decision.append({
"id": d.get("id"),
"target": round(target, 2),
"pred_before": round(pred, 3),
"residual": round(resid, 3),
"sample_weight": round(sw, 2),
"user_grade": d["user_grade"],
"ai_grade": d["ai_grade"],
})
# 3) ํ‰๊ท  gradient โ†’ delta (clip)
deltas: dict[str, float] = {}
new_weights: dict[str, float] = {}
for f, g in grad_sum.items():
d_w = learning_rate * (g / max(weight_sum, 1e-9))
d_w = max(-max_delta, min(max_delta, d_w))
new_w = max(0.0, base_w[f] + d_w) # ์Œ์ˆ˜ ๊ฐ€์ค‘์น˜ ๋ฐฉ์ง€
deltas[f] = round(d_w, 4)
new_weights[f] = round(new_w, 4)
# 4) After ํ‰๊ฐ€
sse_after = 0.0
correct_after = 0
for i, d in enumerate(decisions):
feats = _features_of(d)
target = TARGET_SCORE[d["user_grade"]]
pred_after = sum(new_weights.get(f, base_w.get(f, 0.0)) * c for f, c in feats.items())
sse_after += (target - pred_after) ** 2
# ์ƒˆ ๊ฐ€์ค‘์น˜๋กœ ๋‹ค์‹œ ๋“ฑ๊ธ‰ ์‚ฐ์ •ํ•ด ์ •ํ™•๋„ ์ธก์ •
new_grade = (
"C" if pred_after >= classifier.C_THRESHOLD else
"S" if pred_after >= classifier.S_THRESHOLD else
"O"
)
if new_grade == d["user_grade"]:
correct_after += 1
per_decision[i]["pred_after"] = round(pred_after, 3)
per_decision[i]["new_grade"] = new_grade
n = len(decisions)
return {
"decisions_count": n,
"accuracy_before": round(correct_before / n, 3),
"accuracy_after": round(correct_after / n, 3),
"sse_before": round(sse_before, 3),
"sse_after": round(sse_after, 3),
"weight_deltas": deltas,
"new_weights": new_weights,
"per_decision": per_decision,
"hyperparams": {
"learning_rate": learning_rate,
"max_delta": max_delta,
"target_score": TARGET_SCORE,
"loss": "MSE on linear score",
"objective": "ฮฃ sample_weight ยท (target - ฮฃ wยทx)ยฒ",
},
"started_at": _now(),
"finished_at": _now(),
"status": "completed",
}
# ---------------------------------------------------------------------------
# ํ•ซ์Šค์™‘ โ€” ๊ฒฐ๊ณผ๋ฅผ in-memory ENTITY_WEIGHTS / GRADE_KEYWORDS ์— ๋ฐ˜์˜
# ---------------------------------------------------------------------------
def apply_new_weights(
new_weights: dict[str, float],
*,
training_run_id: int | None = None,
decisions_count: int | None = None,
accuracy: float | None = None,
sse: float | None = None,
notes: str | None = None,
) -> dict:
"""SPEC ยง1 ๊ธฐ๋Šฅ 10 โ€” "๋ชจ๋ธ ํ•ซ์Šค์™‘". ๋‹ค์Œ ๋ถ„์„๋ถ€ํ„ฐ ์ƒˆ ๊ฐ€์ค‘์น˜ ์ ์šฉ.
ํ”„๋กœ์„ธ์Šค ์žฌ์‹œ์ž‘ ์‹œ:
- in-memory ENTITY_WEIGHTS / GRADE_KEYWORDS ์ž์ฒด๋Š” ์›๋ณต๋˜์ง€๋งŒ,
- storage.model_versions ํ…Œ์ด๋ธ”์— ์Šค๋ƒ…์ƒท์ด ๋ณด์กด๋˜๋ฏ€๋กœ
๋ถ€ํŒ… ์‹œ active ๋ฒ„์ „์„ ๋‹ค์‹œ ๋กœ๋“œํ•˜๋ฉด ๋ณต์› ๊ฐ€๋Šฅ.
"""
import storage # ์ˆœํ™˜ import ๋ฐฉ์ง€
applied_entities: dict[str, list[float]] = {}
applied_keywords: dict[str, list[float]] = {}
for f, new_w in new_weights.items():
if f.startswith("kw:"):
label = f[3:]
for i, (kw, old_w, lbl) in enumerate(classifier.GRADE_KEYWORDS):
if lbl == label:
classifier.GRADE_KEYWORDS[i] = (kw, float(new_w), lbl)
applied_keywords[label] = [round(old_w, 4), round(new_w, 4),
round(new_w - old_w, 4)]
break
else:
old_w = classifier.ENTITY_WEIGHTS.get(f, classifier.DEFAULT_ENTITY_WEIGHT)
classifier.ENTITY_WEIGHTS[f] = float(new_w)
applied_entities[f] = [round(old_w, 4), round(new_w, 4),
round(new_w - old_w, 4)]
# ---- ์ƒˆ ๋ฒ„์ „ ๋ผ๋ฒจ ๋ถ€์—ฌ ----
parent = classifier.active_version()
new_version = storage.next_version_label(base="rule-v1")
classifier.set_active_version(new_version)
# ---- ๋ชจ๋ธ ๋ฒ„์ „ ๊ธฐ๋ก ----
diff = {
"entities_changed": applied_entities,
"keywords_changed": applied_keywords,
}
storage.insert_model_version({
"version": new_version,
"parent_version": parent,
"training_run_id": training_run_id,
"decisions_count": decisions_count,
"accuracy": accuracy,
"sse": sse,
"weights": dict(classifier.ENTITY_WEIGHTS),
"keywords": [(kw, w, lbl) for kw, w, lbl in classifier.GRADE_KEYWORDS],
"diff": diff,
"notes": notes or "linear-regression 1-step gradient hot-swap",
"is_active": True,
})
return {
"new_version": new_version,
"parent_version": parent,
"entities_changed": applied_entities,
"keywords_changed": applied_keywords,
"ENTITY_WEIGHTS_now": dict(classifier.ENTITY_WEIGHTS),
"GRADE_KEYWORDS_now": [(kw, w, lbl) for kw, w, lbl in classifier.GRADE_KEYWORDS],
}
def bootstrap_initial_version() -> None:
"""์•ฑ ๋ถ€ํŒ… ์‹œ 1ํšŒ ํ˜ธ์ถœ โ€” ๋ฒ ์ด์Šค 'rule-v1' ๊ฐ€ model_versions ์— ์—†์œผ๋ฉด ๋“ฑ๋ก.
์ด๋ ‡๊ฒŒ ํ•ด๋‘๋ฉด ํ•™์Šต/์ด๋ ฅ ํƒญ์˜ ๋ฒ„์ „ ์ด๋ ฅ ํ‘œ๊ฐ€ ํ•ญ์ƒ ์ตœ์†Œ 1ํ–‰์€ ๊ฐ€์ง„๋‹ค.
"""
import storage
if storage.active_model_version():
return
storage.insert_model_version({
"version": classifier.CLASSIFIER_VERSION,
"parent_version": None,
"training_run_id": None,
"decisions_count": 0,
"accuracy": None,
"sse": None,
"weights": dict(classifier.ENTITY_WEIGHTS),
"keywords": [(kw, w, lbl) for kw, w, lbl in classifier.GRADE_KEYWORDS],
"diff": {},
"notes": "initial baseline weights (hand-tuned)",
"is_active": True,
})