hpce-dev / scripts /regression_snapshot.py
μ΄λ™ν˜„
[DOCS] μ „ λͺ¨λ“ˆ docstringΒ·type hint μ „μˆ˜ 보강
4819352
Raw
History Blame Contribute Delete
8.56 kB
from __future__ import annotations
"""
λ¦¬νŒ©ν† λ§ νšŒκ·€ μŠ€λƒ…μƒ· ν•˜λ‹ˆμŠ€ (Step A).
config-driven 수렴 λ¦¬νŒ©ν† λ§(Phase 3~4)의 μ•ˆμ „λ§.
"λ¦¬νŒ©ν† λ§ μ „ == ν›„"λ₯Ό κΈ°κ³„μ μœΌλ‘œ 증λͺ…ν•œλ‹€ β€” 3μ‹œλ‚˜λ¦¬μ˜€ 전체 intent score + feature dict 1:1 비ꡐ.
두 μŠ€λƒ…μƒ· νŒ¨λ°€λ¦¬:
1. scores : seed_dataset의 (쀑볡 제거된) survey_answers β†’ infer_batch β†’ {intent_id: score}.
build_batch_features(Index/Score) + rule_predict + model_predict 전체 경둜 컀버.
2. features : κ³ μ • ν•©μ„± 이벀트 μ‹œν€€μŠ€ β†’ engine.pattern_features/event_features dict.
pattern/event μΆ”μΆœκΈ°(μ—”ν‹°ν‹°β†’κ·Έλ£ΉΒ·ν”Œλž˜κ·Έ λ§΅) 컀버. (νƒ€μž„μŠ€νƒ¬ν”„λ₯˜ ν•„λ“œλŠ” 비ꡐ μ œμ™Έ)
μ‚¬μš©λ²•:
python scripts/regression_snapshot.py --save # ν˜„μž¬ λ™μž‘μ„ baseline으둜 μ €μž₯
python scripts/regression_snapshot.py --check # ν˜„μž¬ λ™μž‘ vs baseline (뢈일치 μ‹œ exit 1)
baseline: .documents/_snapshots/{scenario_id}.json (gitignore 경둜)
"""
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from core.engines import available_scenarios, get_engine, config # noqa: E402
from core.extractor import get_extractor # noqa: E402
from core.inference import infer_batch # noqa: E402
_SNAPSHOT_DIR = Path(__file__).parent.parent / ".documents" / "_snapshots"
# infer 결과의 비결정적/무관 ν•„λ“œ (μŠ€λƒ…μƒ· 비ꡐ μ œμ™Έ)
_VOLATILE_KEYS = {"last_event_at"}
_ROUND = 6
# ── μ •κ·œν™” (λΆ€λ™μ†Œμˆ˜Β·νƒ€μž… μ•ˆμ •ν™”) ───────────────────────────────
def _norm(v: object) -> object:
"""비ꡐ μ•ˆμ •ν™”: floatλŠ” _ROUND 자리 반올림, κ·Έ μ™Έ(bool 포함)λŠ” κ·ΈλŒ€λ‘œ."""
if isinstance(v, float):
return round(v, _ROUND)
if isinstance(v, bool):
return v
return v
def _norm_dict(d: dict) -> dict:
"""dictλ₯Ό ν‚€ μ •λ ¬Β·κ°’ μ •κ·œν™”ν•˜κ³  νœ˜λ°œμ„± ν‚€(_VOLATILE_KEYS)λŠ” μ œμ™Έ."""
return {k: _norm(v) for k, v in sorted(d.items()) if k not in _VOLATILE_KEYS}
# ── 1. scores μŠ€λƒ…μƒ· ────────────────────────────────────────────
def _unique_answers(scenario_id: str) -> list[dict]:
"""seed_dataset의 survey_answersλ₯Ό 쀑볡 μ œκ±°ν•˜μ—¬ 결정적 μˆœμ„œλ‘œ λ°˜ν™˜."""
path = Path(__file__).parent.parent / "scenarios" / scenario_id / "seed_dataset.json"
data = json.loads(path.read_text(encoding="utf-8"))
seen: dict[tuple, dict] = {}
for s in data["samples"]:
ans = s["survey_answers"]
key = tuple(sorted(ans.items()))
seen.setdefault(key, ans)
# λ‹΅λ³€ ν‚€ μ •λ ¬ λ¬Έμžμ—΄λ‘œ 결정적 μ •λ ¬
return [seen[k] for k in sorted(seen.keys())]
def _scores_snapshot(scenario_id: str) -> list[dict]:
"""쀑볡 제거된 survey_answersλ§ˆλ‹€ infer_batch β†’ {answers, intent별 final_score}."""
out = []
for ans in _unique_answers(scenario_id):
_, scores = infer_batch(ans, scenario_id)
out.append({
"answers": {k: ans[k] for k in sorted(ans)},
"scores": {s.intent_id: round(s.final_score, _ROUND) for s in scores},
})
return out
# ── 2. features μŠ€λƒ…μƒ· (pattern/event μΆ”μΆœκΈ° 컀버) ──────────────
def _synthetic_sequence(scenario_id: str) -> list[tuple[str, str]]:
"""behavior_signals의 entity 전체λ₯Ό (click, entity) 이벀트둜 β€” λ§€ν•‘ ν…Œμ΄λΈ” μ „μˆ˜ 컀버.
λ§ˆμ§€λ§‰ entityλ₯Ό ν•œ 번 더 λ°˜λ³΅ν•΄ repeated/dominant 집계도 자극."""
entities = sorted(config.get_behavior_signals(scenario_id).keys())
seq = [("click", e) for e in entities]
if entities:
seq.append(("click", entities[0])) # 반볡 1건
return seq
def _features_snapshot(scenario_id: str) -> dict:
"""ν•©μ„± 이벀트 μ‹œν€€μŠ€λ₯Ό μ£Όμž…ν•΄ empty/pattern/event Feature dictλ₯Ό μŠ€λƒ…μƒ·(νƒ€μž„μŠ€νƒ¬ν”„ μ œμ™Έ)."""
engine = get_engine(scenario_id)
ext = get_extractor()
session = f"__snapshot__{scenario_id}"
ext.reset(session)
for event_type, entity in _synthetic_sequence(scenario_id):
ext.add_event(session, event_type, entity) # occurred_at=now β†’ window λ‚΄
snap = {
"empty_pattern": _norm_dict(engine.empty_pattern_features()),
"empty_event": _norm_dict(engine.empty_event_features()),
"pattern": _norm_dict(engine.pattern_features(session)),
"event": _norm_dict(engine.event_features(session)),
}
ext.reset(session)
return snap
# ── μŠ€λƒ…μƒ· λΉŒλ“œ ─────────────────────────────────────────────────
def _build(scenario_id: str) -> dict:
"""ν•œ μ‹œλ‚˜λ¦¬μ˜€μ˜ 전체 μŠ€λƒ…μƒ·(scores + features) 생성."""
return {
"scenario_id": scenario_id,
"scores": _scores_snapshot(scenario_id),
"features": _features_snapshot(scenario_id),
}
# ── diff ────────────────────────────────────────────────────────
def _diff(old: dict, new: dict, scenario_id: str) -> list[str]:
"""baseline(old) vs ν˜„μž¬(new) μŠ€λƒ…μƒ· 비ꡐ β†’ 뢈일치 λ©”μ‹œμ§€ 리슀트(빈 리슀트면 무손상)."""
errs: list[str] = []
# features
for fam in ("empty_pattern", "empty_event", "pattern", "event"):
o, n = old["features"].get(fam, {}), new["features"].get(fam, {})
for k in sorted(set(o) | set(n)):
if o.get(k) != n.get(k):
errs.append(f"[{scenario_id}] features.{fam}.{k}: {o.get(k)} β†’ {n.get(k)}")
# scores (answers μ •λ ¬ 동일 κ°€μ •, 길이/μˆœμ„œ 검증)
o_cases, n_cases = old["scores"], new["scores"]
if len(o_cases) != len(n_cases):
errs.append(f"[{scenario_id}] scores μΌ€μ΄μŠ€ 수: {len(o_cases)} β†’ {len(n_cases)}")
for i, (oc, nc) in enumerate(zip(o_cases, n_cases)):
if oc["answers"] != nc["answers"]:
errs.append(f"[{scenario_id}] scores[{i}] answers 뢈일치")
continue
os_, ns_ = oc["scores"], nc["scores"]
for iid in sorted(set(os_) | set(ns_)):
if os_.get(iid) != ns_.get(iid):
errs.append(f"[{scenario_id}] scores[{i}].{iid}: {os_.get(iid)} β†’ {ns_.get(iid)}")
return errs
# ── main ────────────────────────────────────────────────────────
def main() -> None:
"""--save: baseline μ €μž₯ / --check: baseline λŒ€λΉ„ 검증(뢈일치 μ‹œ exit 1)."""
ap = argparse.ArgumentParser()
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--save", action="store_true", help="baseline μ €μž₯")
g.add_argument("--check", action="store_true", help="baseline λŒ€λΉ„ 검증")
ap.add_argument("--scenarios", nargs="*", default=None, help="λŒ€μƒ μ‹œλ‚˜λ¦¬μ˜€(κΈ°λ³Έ: 전체)")
args = ap.parse_args()
_SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
scenarios = args.scenarios or available_scenarios()
if args.save:
for sid in scenarios:
snap = _build(sid)
path = _SNAPSHOT_DIR / f"{sid}.json"
path.write_text(json.dumps(snap, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
print(f"saved {path} (scores={len(snap['scores'])} cases)")
return
# --check
all_errs: list[str] = []
for sid in scenarios:
path = _SNAPSHOT_DIR / f"{sid}.json"
if not path.exists():
print(f"❌ baseline μ—†μŒ: {path} (λ¨Όμ € --save)")
sys.exit(2)
old = json.loads(path.read_text(encoding="utf-8"))
new = _build(sid)
errs = _diff(old, new, sid)
if errs:
all_errs.extend(errs)
print(f"{'❌' if errs else 'βœ…'} {sid}: {len(errs)} diff (scores={len(new['scores'])} cases)")
if all_errs:
print("\n── 뢈일치 상세 (μ΅œλŒ€ 50건) ──")
for e in all_errs[:50]:
print(" " + e)
print(f"\n총 {len(all_errs)}건 뢈일치 β†’ νšŒκ·€ λ°œμƒ")
sys.exit(1)
print("\nβœ… 무손상 β€” μ „ μ‹œλ‚˜λ¦¬μ˜€ scoreΒ·feature 1:1 일치")
if __name__ == "__main__":
main()