hyunsu blog

홈강좌LAYA 결정 모델 24편 중 11편째

LAYA 지속 학습 3 — 새 판은 기준을 넘을 때만 올린다

목차 · 6
  1. 01기준 셋
  2. 02판정 코드
  3. 03v1 → v2: 통과
  4. 04v1 → v2b: 막힘
  5. 05기록
  6. 06실습 — 내 서비스의 기준 정하기

새로 학습한 판을 운영에 올릴지 사람이 감으로 정하지 않는다. 같은 평가 데이터로 지금 판(current)과 새 판(candidate) 을 재고, 정해 둔 기준을 모두 넘을 때만 올린다. 이 편에서 그 판정을 코드로 만든다. 10편의 v2 는 통과했고, 이어 학습한 v2b 는 막혔다.

01 기준 셋

  1. 전체 정확도가 0.02 이상 오른다. 10편에서 본 대로 같은 학습도 판마다 몇 점씩 흔들린다. 그보다 작은 차이는 우연일 수 있으니, 바꾸는 수고를 들일 만큼 오를 때만 올린다.
  2. 어느 팀도 0.05 넘게 떨어지지 않는다. 전체 평균이 올라도 한 팀이 무너지면 그 팀의 고객은 서비스가 나빠진 것이다. 평균이 가리는 퇴보를 잡는다.
  3. ECE 가 0.25 이하다. 확신이 크게 어긋나면 9편의 "확신이 낮으면 사람에게" 가 듣지 않는다.

숫자는 이 강좌의 데이터에 맞춰 정한 것이다. 내 서비스에서는 "틀리면 얼마나 손해인가" 로 정한다(아래 실습).

02 판정 코드

python
"""지금 쓰는 판(current)과 새 판(candidate)을 같은 평가 세트로 재고, 기준을 넘을 때만 승격한다.

기준 — 셋 다 통과해야 한다:
  1. 전체 정확도가 0.02 이상 오른다
  2. 어느 팀도 정확도가 0.05 넘게 떨어지지 않는다(전체 평균에 가려진 퇴보를 잡는다)
  3. ECE(확신과 실제의 차이)가 0.25 이하
결과는 data/registry.jsonl 에 한 줄씩 남긴다 — 무엇을 왜 올렸고 왜 막았는지.
"""
import json
import sys
import time
from datetime import date

import numpy as np
import laya
from laya.common import ece_score

current_path, candidate_path = sys.argv[1], sys.argv[2]
cases = [json.loads(line) for line in open("tickets-eval.jsonl", encoding="utf-8")]


def measure(path):
    agent = laya.load(path)
    correct, conf, per_team = [], [], {}
    for case in cases:
        answer = agent.predict(case["state"], case["questions"])["answers"]["team"]
        ok = answer["choice"] == case["expected"]["team"]
        correct.append(ok)
        conf.append(answer["probabilities"][answer["choice"]])
        per_team.setdefault(case["expected"]["team"], []).append(ok)
    correct = np.array(correct, dtype=float)
    return {
        "accuracy": round(float(correct.mean()), 3),
        "ece": round(float(ece_score(np.array(conf), correct, bins=10)), 3),
        "teams": {t: round(sum(v) / len(v), 3) for t, v in sorted(per_team.items())},
    }


cur, cand = measure(current_path), measure(candidate_path)
checks = {
    "accuracy +0.02": cand["accuracy"] - cur["accuracy"] >= 0.02,
    "no team -0.05": all(cand["teams"][t] - cur["teams"][t] >= -0.05 for t in cur["teams"]),
    "ECE <= 0.25": cand["ece"] <= 0.25,
}
decision = "PROMOTE" if all(checks.values()) else "REJECT"

print(f"{'':<12}{'current':>10}{'candidate':>11}")
print(f"{'accuracy':<12}{cur['accuracy']:>10.3f}{cand['accuracy']:>11.3f}")
for team in cur["teams"]:
    print(f"  {team:<10}{cur['teams'][team]:>10.3f}{cand['teams'][team]:>11.3f}")
print(f"{'ECE':<12}{cur['ece']:>10.3f}{cand['ece']:>11.3f}")
for name, ok in checks.items():
    print(f"[{'pass' if ok else 'FAIL'}] {name}")
print(f"decision: {decision} {candidate_path}")

record = {"date": str(date.today()), "current": current_path, "candidate": candidate_path,
          "decision": decision, "checks": checks, "current_metrics": cur, "candidate_metrics": cand}
with open("data/registry.jsonl", "a", encoding="utf-8") as f:
    f.write(json.dumps(record, ensure_ascii=False) + "\n")

판정 결과는 통과·실패와 상관없이 data/registry.jsonl 에 한 줄씩 남는다. 언제, 무엇을, 무엇과 비교해, 왜 올리거나 막았는지가 기록으로 남아야 나중에 "이 판은 왜 올렸지?" 에 답할 수 있다.

03 v1 → v2: 통과

bash
python promote.py checkpoints/v1 checkpoints/v2
text
               current  candidate
accuracy         0.750      0.815
  account        0.800      0.889
  billing        0.746      0.825
  delivery       0.600      0.622
  technical      0.851      0.915
ECE              0.207      0.136
[pass] accuracy +0.02
[pass] no team -0.05
[pass] ECE <= 0.25
decision: PROMOTE checkpoints/v2

전체 0.750 → 0.815(+0.065), 네 팀 모두 같거나 올랐고, ECE 도 0.207 → 0.136 으로 좋아졌다. 셋 다 통과해 PROMOTE 다. 배송팀(0.600 → 0.622)은 거의 그대로지만 떨어지지 않았으니 막지 않는다.

04 v1 → v2b: 막힘

10편에서 v1 에 누적 데이터를 이어 학습한 v2b 를 같은 기준에 넣었다.

bash
python promote.py checkpoints/v1 checkpoints/v2b
text
               current  candidate
accuracy         0.750      0.735
  account        0.800      0.800
  billing        0.746      0.905
  delivery       0.600      0.711
  technical      0.851      0.468
ECE              0.207      0.237
[FAIL] accuracy +0.02
[FAIL] no team -0.05
[pass] ECE <= 0.25
decision: REJECT checkpoints/v2b
그림 1.팀별 정확도 — v1 · v2 · v2b
0.00.20.40.60.81.0accountbillingdeliverytechnicalaccount · v1 (지금): 0.800account · v2 (통과): 0.889account · v2b (막힘): 0.800billing · v1 (지금): 0.746billing · v2 (통과): 0.825billing · v2b (막힘): 0.905delivery · v1 (지금): 0.600delivery · v2 (통과): 0.622delivery · v2b (막힘): 0.711technical · v1 (지금): 0.851technical · v2 (통과): 0.915technical · v2b (막힘): 0.4680.8000.8890.8000.7460.8250.9050.6000.6220.7110.8510.9150.4680.00.20.40.60.81.0accountbillingdeliverytechnicalaccount · v1 (지금): 0.800account · v2 (통과): 0.889account · v2b (막힘): 0.800billing · v1 (지금): 0.746billing · v2 (통과): 0.825billing · v2b (막힘): 0.905delivery · v1 (지금): 0.600delivery · v2 (통과): 0.622delivery · v2b (막힘): 0.711technical · v1 (지금): 0.851technical · v2 (통과): 0.915technical · v2b (막힘): 0.4680.8000.8890.8000.7460.8250.9050.6000.6220.7110.8510.9150.468
  • v1 (지금)
  • v2 (통과)
  • v2b (막힘)

v2b 는 결제·배송이 올랐지만 기술팀이 0.851 → 0.468 로 무너졌다

숫자로 보기
항목v1 (지금)v2 (통과)v2b (막힘)
account0.8000.8890.800
billing0.7460.8250.905
delivery0.6000.6220.711
technical0.8510.9150.468

결제팀(0.905)·배송팀(0.711)은 v1 보다 좋다. 하지만 기술팀이 0.851 → 0.468 로 무너졌고, 그 탓에 전체도 0.750 → 0.735 로 떨어졌다. 기준 둘이 실패해 REJECT 다. 결제팀 숫자만 보고 "좋아졌다" 고 올렸다면, 기술 문의의 절반이 결제팀으로 가는 판이 운영에 나갔을 것이다.

05 기록

bash
tail -n 2 data/registry.jsonl

한 줄에 날짜·지금 판·새 판·판정·기준별 통과 여부·두 판의 팀별 숫자가 들어 있다. 이 파일이 쌓이면 "언제부터 어느 팀이 좋아졌는지" 를 판 순서대로 볼 수 있다.

06 실습 — 내 서비스의 기준 정하기

물을 것기준으로
틀리면 손해가 큰 팀(선택지)은?그 팀만 "떨어지지 않기" 를 더 엄하게(-0.02 등)
판을 바꾸는 데 드는 수고는?수고가 크면 전체 상승 기준을 높게
사람에게 넘기는 규칙을 쓰나?쓰면 ECE 기준을 둔다
평가 데이터는 몇 건인가?200건이면 1건이 0.005 다. 기준이 너무 촘촘하면 우연에 흔들린다

판정을 통과한 판만 다음 편부터의 배포로 넘어간다. 그래도 평가 데이터는 실제 운영과 다를 수 있으니, 15편에서 실제 문의로 한 번 더 비교한 뒤 바꾼다.