홈강좌LAYA 결정 모델 24편 중 20편째
LAYA 쇼핑 검색 4 — 필터 칩을 고친 검색어로 지속 학습: 0.322 에서 0.532 로
19편의 v1 은 운영에서 새로 들어오는 단어(ops 묶음)에서 0.114 였다. 이 편에서 운영 기록으로 v2 를 만든다. 문의 분류 때(9~11편)와 같은 순서이고, 정답을 모으는 방법만 다르다. 이번에는 사용자가 필터 칩을 고친 기록이 정답이다.
01 정답 모으기 — 필터 칩을 고친 검색어
검색 결과 위에 v1 이 건 필터가 칩으로 보인다고 하자("패딩 ✕", "갈색 ✕"). 필터가 틀리면 사용자 일부는 칩을 지우거나 바꾼다. 고친 뒤의 필터가 그 검색어의 정답이다. 사람이 따로 라벨을 붙이지 않아도 정답이 쌓인다.
다만 이 기록에는 치우침이 있다. v1 이 틀린 검색어만 담기고, 틀려도 그냥 떠난 사용자의 검색어는 빠진다. 여기서는 사용자 60% 가 칩을 고친다고 흉내 냈다.
"""운영 흉내 — 검색어마다 v1 이 만든 필터를 보여 주고, 사용자가 필터 칩을 고친 검색어를 정답으로 모은다.
사용자 행동은 흉내다. 필터가 틀렸으면 FIX_RATE 만큼은 칩을 고치고(고친 결과 = 정답), 나머지는 그냥 떠난다.
맞았으면 아무도 고치지 않는다 — 그래서 고친 기록에는 v1 이 틀린 검색어만 담긴다.
python collect.py checkpoints/shop-v1 0.6
"""
import json
import random
import sys
from collections import Counter
import laya
FIELDS = ["price", "category", "item", "winter", "gender", "color"]
model, fix_rate = sys.argv[1], float(sys.argv[2])
rng = random.Random(7)
agent = laya.load(model)
incoming = [json.loads(line) for line in open("shop-incoming-300.jsonl", encoding="utf-8")]
first = [json.loads(line) for line in open("shop-first-150.jsonl", encoding="utf-8")]
fixed, left, right, fields = [], 0, 0, Counter()
for case in incoming:
a = agent.predict(case["state"], case["questions"])["answers"]
got = {f: (a[f]["noul"] >= 0.5 if a[f]["type"] == "noul" else a[f]["choice"]) for f in FIELDS}
wrong = [f for f in FIELDS if got[f] != case["expected"][f]]
if not wrong:
right += 1
elif rng.random() < fix_rate:
fixed.append(case)
fields.update(wrong)
else:
left += 1
pools = Counter(c["pool"] for c in incoming)
print(f"incoming: {len(incoming)} (ops words {pools['ops']} · train words {pools['train']})")
print(f"filters all right: {right} ({right / len(incoming):.0%})")
print(f"wrong: {len(fixed) + left} — user fixed chips {len(fixed)}, left without fixing {left}")
print("fields fixed:", ", ".join(f"{f} {fields[f]}" for f in FIELDS))
for name, rows in [("data/shop-feedback.jsonl", fixed), ("data/shop-train-v2.jsonl", first + fixed),
("data/shop-train-all.jsonl", first + incoming)]:
with open(name, "w", encoding="utf-8") as f:
for r in rows:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
print(f"saved {len(rows):>3} rows -> {name}")python collect.py checkpoints/shop-v1 0.6incoming: 300 (ops words 205 · train words 95)
filters all right: 92 (31%)
wrong: 208 — user fixed chips 133, left without fixing 75
fields fixed: price 41, category 58, item 65, winter 30, gender 21, color 72
saved 133 rows -> data/shop-feedback.jsonl
saved 283 rows -> data/shop-train-v2.jsonl
saved 450 rows -> data/shop-train-all.jsonl- 운영 검색어 300건 가운데 필터가 모두 맞은 것은 31% 다. 새 단어(ops)가 든 검색어가 205건이라 낮다.
- 틀린 208건 중 133건이 고쳐져 정답이 됐다. 9편에서 사람이 300건 중 90건을 손으로 봤던 것과 비교하면, 사람 손을 쓰지 않고 더 많이 모였다.
- 가장 많이 고친 칸은 색(72)과 품목(65)이다. 새 단어가 가장 많은 칸이다.
- 파일을 셋 남긴다. 고친 것만(133), 처음 150건 + 고친 것(283), 그리고 비교용으로 300건 모두에 사람이 정답을 붙였다면(450)이다.
02 방법 넷 — 세 번씩
10편에서 한 번 돌린 숫자가 최대 0.2 흔들리는 것을 봤다. 이번에는 처음부터 씨앗 셋으로 돌린다.
"""이어 가는 방법 넷을 씨앗만 바꿔 세 번씩 학습해 같은 평가 세트로 잰다(맥 GPU 학습은 한 번 숫자가 흔들린다)."""
import json
import shutil
import statistics
import subprocess
import laya
FIELDS = ["price", "category", "item", "winter", "gender", "color"]
APPROACHES = {
"v1 again (base + first 150)": ("shop-first-150.jsonl", "multilingual"),
"continue v1 + fixed only": ("data/shop-feedback.jsonl", "checkpoints/shop-v1"),
"retrain base + first + fixed": ("data/shop-train-v2.jsonl", "multilingual"),
"retrain base + all 450 labeled": ("data/shop-train-all.jsonl", "multilingual"),
}
SEEDS = [1, 2, 3]
cases = [json.loads(line) for line in open("shop-eval.jsonl", encoding="utf-8")]
def exact_by_pool(path):
agent = laya.load(path)
by = {"all": [], "train": [], "ops": [], "unseen": []}
for c in cases:
a = agent.predict(c["state"], c["questions"])["answers"]
ok = all((a[f]["noul"] >= 0.5 if a[f]["type"] == "noul" else a[f]["choice"]) == c["expected"][f] for f in FIELDS)
by["all"].append(ok)
by[c["pool"]].append(ok)
return {k: sum(v) / len(v) for k, v in by.items()}
print(f"{'approach':<32}{'seed 1':>8}{'seed 2':>8}{'seed 3':>8}{'mean':>8}{'range':>8}{'train':>8}{'ops':>8}{'unseen':>8}")
for name, (data, base) in APPROACHES.items():
runs = []
for seed in SEEDS:
out = f"checkpoints/shop-repeat-{seed}"
subprocess.run(["laya-train", "--data", data, "--base", base, "--out", out, "--seed", str(seed)],
check=True, capture_output=True)
runs.append(exact_by_pool(out))
shutil.rmtree(out)
alls = [r["all"] for r in runs]
pools = "".join(f"{statistics.mean(r[p] for r in runs):>8.3f}" for p in ("train", "ops", "unseen"))
cells = "".join(f"{s:>8.3f}" for s in alls)
print(f"{name:<32}{cells}{statistics.mean(alls):>8.3f}{max(alls) - min(alls):>8.3f}{pools}", flush=True)python repeat.pyapproach seed 1 seed 2 seed 3 mean range train ops unseen
v1 again (base + first 150) 0.310 0.329 0.329 0.322 0.019 0.819 0.129 0.019
continue v1 + fixed only 0.486 0.438 0.443 0.456 0.048 0.752 0.533 0.081
retrain base + first + fixed 0.567 0.548 0.481 0.532 0.086 0.895 0.624 0.076
retrain base + all 450 labeled 0.600 0.600 0.586 0.595 0.014 0.919 0.795 0.071- 씨앗 1·2·3
- 평균
- 최소~최대
검색어 210건의 완전 일치. 넷째는 사람이 300건 모두에 정답을 붙였다고 가정한 비교용
숫자로 보기
| 항목 | 측정값 | 평균 |
|---|---|---|
| v1 다시 | 0.310, 0.329, 0.329 | 0.323 |
| v1 + 고친 것 이어 | 0.486, 0.438, 0.443 | 0.456 |
| 처음+고친 것 다시 | 0.567, 0.548, 0.481 | 0.532 |
| 450건 전부 라벨 | 0.600, 0.600, 0.586 | 0.595 |
- 지속 학습은 효과가 컸다. 처음부터 다시 학습한 쪽이 평균 0.532 로, v1(0.322)보다 0.21 높다. 가장 나쁜 판(0.481)도 v1 의 가장 좋은 판(0.329)보다 한참 위다. 흔들림(0.09)보다 차이가 훨씬 크다.
- ops 단어가 0.129 → 0.624 로 올랐다. 고친 검색어에 그 단어들이 들어 있었기 때문이다.
- 이어 학습(0.456)은 이번에도 처음부터 다시보다 낮다. ops 는 올랐지만 train 단어가 0.819 → 0.752 로 내려갔다. 새 것을 배우며 옛것을 잊는 것이다(10편과 같은 모양).
- unseen 단어는 0.019 → 0.076. 거의 그대로다. 고친 기록에 없는 단어는 지속 학습으로 오지 않는다.
- 450건 전부 라벨(0.595) 과 고친 것만 모은 쪽(0.532)의 차이는 0.063 이다. 사람이 300건을 다 보는 대신 사용자의 칩 고침만으로 그 차이의 대부분을 메웠다.
03 v2 학습과 승격 판정
처음부터 다시 하는 방법으로 v2 를 학습하고, 11편처럼 기준을 정해 판정한다.
laya-train --data data/shop-train-v2.jsonl --base multilingual --out checkpoints/shop-v2 --eval shop-eval.jsonleval items 1260 from shop-eval.jsonl (skipped {}, 0 overlapping training/calibration items)
train items 1529, calibration items 169, eval items 1260 (shop-eval.jsonl), skipped {}, device mps
optimizer budget: 1529 train item(s), effective batch up to 64 (micro_batch 8 x grad_accum 8), 96 optimizer updates over 4 epoch(s)=== Evaluation (before vs after fine-tuning on 1260 held-out items from shop-eval.jsonl) ===
Metric Before After Delta
-----------------------------------------------------------------
Accuracy 0.4857 0.8563 +0.3706
Loss 1.5558 0.5511 -1.0047
ECE 0.1940 0.0786 -0.1154
Brier (multi-class) 0.7368 0.2358 -0.5010
Brier (top-1) 0.2975 0.1049 -0.1926
Mean Confidence 0.6769 0.9315 +0.2546
-----------------------------------------------------------------
Training complete! Model saved to checkpoints/shop-v2학습 항목이 1529 로 v1(810)의 두 배쯤이다. 질문별 정확도는 0.856, ECE 는 0.079 로 v1(0.207)보다 확신과 실제가 가까워졌다.
"""지금 쓰는 판(current)과 새 판(candidate)을 같은 검색어 평가 세트로 재고, 기준을 넘을 때만 승격한다.
기준 — 셋 다 통과해야 한다:
1. 완전 일치(여섯 칸 모두 맞음)가 0.03 이상 오른다
2. 어느 칸도 0.03 넘게 떨어지지 않는다(평균에 가려진 퇴보를 잡는다)
3. 처음 학습한 단어(train words)의 완전 일치가 0.03 넘게 떨어지지 않는다(새 것을 배우다 옛것을 잊는지)
결과는 data/shop-registry.jsonl 에 한 줄씩 남긴다.
"""
import json
import sys
from datetime import date
import laya
FIELDS = ["price", "category", "item", "winter", "gender", "color"]
current_path, candidate_path = sys.argv[1], sys.argv[2]
cases = [json.loads(line) for line in open("shop-eval.jsonl", encoding="utf-8")]
def measure(path):
agent = laya.load(path)
preds = []
for c in cases:
a = agent.predict(c["state"], c["questions"])["answers"]
preds.append({f: (a[f]["noul"] >= 0.5 if a[f]["type"] == "noul" else a[f]["choice"]) for f in FIELDS})
exact = [all(p[f] == c["expected"][f] for f in FIELDS) for c, p in zip(cases, preds)]
train = [e for c, e in zip(cases, exact) if c["pool"] == "train"]
return {"exact": round(sum(exact) / len(exact), 3), "train words": round(sum(train) / len(train), 3),
"fields": {f: round(sum(p[f] == c["expected"][f] for c, p in zip(cases, preds)) / len(cases), 3)
for f in FIELDS}}
cur, cand = measure(current_path), measure(candidate_path)
checks = {
"exact +0.03": cand["exact"] - cur["exact"] >= 0.03,
"no field -0.03": all(cand["fields"][f] - cur["fields"][f] >= -0.03 for f in FIELDS),
"train words -0.03": cand["train words"] - cur["train words"] >= -0.03,
}
decision = "PROMOTE" if all(checks.values()) else "REJECT"
print(f"{'':<14}{'current':>10}{'candidate':>11}")
print(f"{'exact match':<14}{cur['exact']:>10.3f}{cand['exact']:>11.3f}")
print(f"{' train words':<14}{cur['train words']:>10.3f}{cand['train words']:>11.3f}")
for f in FIELDS:
print(f" {f:<12}{cur['fields'][f]:>10.3f}{cand['fields'][f]:>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/shop-registry.jsonl", "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")python promote.py checkpoints/shop-v1 checkpoints/shop-v2 current candidate
exact match 0.305 0.510
train words 0.800 0.929
price 0.800 0.933
category 0.671 0.838
item 0.638 0.833
winter 0.852 0.867
gender 0.743 0.952
color 0.605 0.710
[pass] exact +0.03
[pass] no field -0.03
[pass] train words -0.03
decision: PROMOTE checkpoints/shop-v2기준을 이렇게 정했다. 완전 일치가 0.03 넘게 오르고, 어느 칸도 0.03 넘게 떨어지지 않고, 처음 학습한 단어(train words)의 완전 일치가 0.03 넘게 떨어지지 않을 것. 셋째 기준은 위에서 본 "옛것을 잊는" 판을 막으려고 넣었다. v2 는 셋 다 통과했다. 완전 일치 0.305 → 0.510, 성별 칸이 0.743 → 0.952 로 가장 많이 올랐다.
04 예문 셋 다시
python first_query.py checkpoints/shop-v2검색어: 50만원이하 패딩 상품중 갈색
price under50 확신 0.99
category top 확신 0.99
item padding 확신 0.98
winter 0.96 확신 0.96
gender unknown 확신 1.00
color brown 확신 0.98
filters: {"price_under50": true, "category_top": true, "item_padding": true, "winter": true, "gender": null, "color_brown": true}
검색어: 아빠 겨울 부츠 브라운으로
price none 확신 0.99
category shoes 확신 0.99
item boots 확신 0.98
winter 0.95 확신 0.95
gender men 확신 1.00
color brown 확신 0.98
filters: {"price": null, "category_shoes": true, "item_boots": true, "winter": true, "gender_men": true, "color_brown": true}
검색어: 언니 선물로 십만 원 이하 밤색 터틀넥
price under30 확신 0.95
category top 확신 0.98
item tshirt 확신 0.71
winter 0.24 확신 0.76
gender men 확신 0.83
color navy 확신 0.95
filters: {"price_under30": true, "category_top": true, "item_tshirt": true, "winter": false, "gender_men": true, "color_navy": true}- 첫 예문은 v1 과 같다.
- 둘째 예문은 이제 "아빠" 를 남성(
gender_men)으로 읽는다. 운영에서 고친 검색어에 "아빠" 가 있었다. - 셋째 예문이 나빠졌다. v1 은 대부분을 비워 두었는데, v2 는 "밤색" 을 남색(0.95), "언니" 를 남성(0.83), "터틀넥" 을 티셔츠(0.71)로 자신 있게 걸었다. 이대로면 결과가 0건이다.
05 v2 의 확신 문턱
19편의 threshold.py 를 v2 로 다시 돌렸다.
python threshold.py checkpoints/shop-v2queries 210 · filters the queries ask for 868
threshold applied wrong wrong % queries hit unseen hit
0.5 782 87 11.1% 67 53
0.7 743 63 8.5% 53 44
0.9 691 35 5.1% 31 27
0.99 160 0 0.0% 0 00.9 에서 v2 는 필터를 691개 건다(v1 506개). 틀린 필터는 35개로 비슷하다. 맞는 필터가 크게 늘고 틀린 필터는 그대로이니, 검색 결과는 좋아졌다. 그런데 틀린 필터가 걸린 31건 중 27건이 unseen 단어다. v1 때(18건)보다 몰렸다. 지속 학습은 아는 단어의 확신을 올렸고, 모르는 단어에서도 함께 자신 있어졌다. 셋째 예문이 그 모습이다.
06 이 사례의 결과 가능성
| 결과 | 이번에 본 것 |
|---|---|
| 좋아짐 | 처음부터 다시 학습 — 평균 0.322 → 0.532, ops 단어 0.129 → 0.624 |
| 좋아지다 잊음 | v1 에 이어 학습 — ops 는 오르고 train 단어는 0.819 → 0.752 |
| 그대로 | unseen 단어 — 0.019 → 0.076 |
| 더 위험해짐 | 처음 보는 단어에 자신 있게 틀림 — 0.9 에서 틀린 필터의 대부분 |
| 천장 | 사람이 다 붙여도 0.595 — 남은 것은 unseen 단어와 오타 |
정리하면 지속 학습은 운영에서 실제로 들어오는 단어를 따라잡는 데는 잘 듣는다. 16편의 권장 순서(정확한 정답 모으기 → 처음부터 다시 학습 → 고정 평가셋으로 승격 판정)가 이 사례에서도 맞았다. 그러나 아무도 쓰지 않은 단어는 기다려도 오지 않고, 그런 단어에서 틀리는 것이 점점 자신 있어진다. 이 약점을 다른 방법으로 메울 수 있는지 다음 편에서 본다.
07 실습
collect.py의 고치는 비율을 0.6 에서 0.3 으로 낮춰 v2 를 다시 만든다. 칩을 고치는 사용자가 적으면 얼마나 느려지는지 본다.- 칩을 고친 검색어만이 아니라 "필터를 그대로 두고 상품을 누른 검색어" 도 정답으로 넣으면 무엇이 좋아지고 무엇이 위험한지 적어 본다. v1 이 틀렸는데 사용자가 그냥 누른 경우가 섞인다.
promote.py에 넷째 기준 "unseen 단어에서 0.9 이상 확신으로 틀린 필터가 늘지 않을 것" 을 더해 본다. v2 가 통과하는지 본다.