hyunsu blog

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

LAYA 쇼핑 검색 4 — 필터 칩을 고친 검색어로 지속 학습: 0.322 에서 0.532 로

목차 · 7
  1. 01정답 모으기 — 필터 칩을 고친 검색어
  2. 02방법 넷 — 세 번씩
  3. 03v2 학습과 승격 판정
  4. 04예문 셋 다시
  5. 05v2 의 확신 문턱
  6. 06이 사례의 결과 가능성
  7. 07실습

19편의 v1 은 운영에서 새로 들어오는 단어(ops 묶음)에서 0.114 였다. 이 편에서 운영 기록으로 v2 를 만든다. 문의 분류 때(9~11편)와 같은 순서이고, 정답을 모으는 방법만 다르다. 이번에는 사용자가 필터 칩을 고친 기록이 정답이다.

01 정답 모으기 — 필터 칩을 고친 검색어

검색 결과 위에 v1 이 건 필터가 칩으로 보인다고 하자("패딩 ✕", "갈색 ✕"). 필터가 틀리면 사용자 일부는 칩을 지우거나 바꾼다. 고친 뒤의 필터가 그 검색어의 정답이다. 사람이 따로 라벨을 붙이지 않아도 정답이 쌓인다.

다만 이 기록에는 치우침이 있다. v1 이 틀린 검색어만 담기고, 틀려도 그냥 떠난 사용자의 검색어는 빠진다. 여기서는 사용자 60% 가 칩을 고친다고 흉내 냈다.

python
"""운영 흉내 — 검색어마다 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}")
bash
python collect.py checkpoints/shop-v1 0.6
text
incoming: 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 흔들리는 것을 봤다. 이번에는 처음부터 씨앗 셋으로 돌린다.

python
"""이어 가는 방법 넷을 씨앗만 바꿔 세 번씩 학습해 같은 평가 세트로 잰다(맥 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)
bash
python repeat.py
text
approach                          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.v2 만드는 방법별 — 씨앗 세 번
0.20.30.40.50.60.7v1 다시v1 + 고친 것 이어처음+고친 것 다시450건 전부 라벨v1 다시: 0.310v1 다시: 0.329v1 다시: 0.329v1 + 고친 것 이어: 0.486v1 + 고친 것 이어: 0.438v1 + 고친 것 이어: 0.443처음+고친 것 다시: 0.567처음+고친 것 다시: 0.548처음+고친 것 다시: 0.481450건 전부 라벨: 0.600450건 전부 라벨: 0.600450건 전부 라벨: 0.586v1 다시: 0.323v1 + 고친 것 이어: 0.456처음+고친 것 다시: 0.532450건 전부 라벨: 0.595평균 0.323평균 0.456평균 0.532평균 0.5950.20.30.40.50.60.7v1 다시v1 + 고친 것 이어처음+고친 것 다시450건 전부 라벨v1 다시: 0.310v1 다시: 0.329v1 다시: 0.329v1 + 고친 것 이어: 0.486v1 + 고친 것 이어: 0.438v1 + 고친 것 이어: 0.443처음+고친 것 다시: 0.567처음+고친 것 다시: 0.548처음+고친 것 다시: 0.481450건 전부 라벨: 0.600450건 전부 라벨: 0.600450건 전부 라벨: 0.586v1 다시: 0.323v1 + 고친 것 이어: 0.456처음+고친 것 다시: 0.532450건 전부 라벨: 0.595평균 0.323평균 0.456평균 0.532평균 0.595
  • 씨앗 1·2·3
  • 평균
  • 최소~최대

검색어 210건의 완전 일치. 넷째는 사람이 300건 모두에 정답을 붙였다고 가정한 비교용

숫자로 보기
항목측정값평균
v1 다시0.310, 0.329, 0.3290.323
v1 + 고친 것 이어0.486, 0.438, 0.4430.456
처음+고친 것 다시0.567, 0.548, 0.4810.532
450건 전부 라벨0.600, 0.600, 0.5860.595
그림 2.단어 묶음별 완전 일치 — 세 번 평균
0.00.20.40.60.81.0train 단어ops 단어unseen 단어train 단어 · v1 다시: 0.819train 단어 · v1 + 고친 것 이어: 0.752train 단어 · 처음+고친 것 다시: 0.895ops 단어 · v1 다시: 0.129ops 단어 · v1 + 고친 것 이어: 0.533ops 단어 · 처음+고친 것 다시: 0.624unseen 단어 · v1 다시: 0.019unseen 단어 · v1 + 고친 것 이어: 0.081unseen 단어 · 처음+고친 것 다시: 0.0760.8190.7520.8950.1290.5330.6240.0190.0810.0760.00.20.40.60.81.0train 단어ops 단어unseen 단어train 단어 · v1 다시: 0.819train 단어 · v1 + 고친 것 이어: 0.752train 단어 · 처음+고친 것 다시: 0.895ops 단어 · v1 다시: 0.129ops 단어 · v1 + 고친 것 이어: 0.533ops 단어 · 처음+고친 것 다시: 0.624unseen 단어 · v1 다시: 0.019unseen 단어 · v1 + 고친 것 이어: 0.081unseen 단어 · 처음+고친 것 다시: 0.0760.8190.7520.8950.1290.5330.6240.0190.0810.076
  • v1 다시
  • v1 + 고친 것 이어
  • 처음+고친 것 다시

묶음마다 검색어 70건

숫자로 보기
항목v1 다시v1 + 고친 것 이어처음+고친 것 다시
train 단어0.8190.7520.895
ops 단어0.1290.5330.624
unseen 단어0.0190.0810.076
  • 지속 학습은 효과가 컸다. 처음부터 다시 학습한 쪽이 평균 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편처럼 기준을 정해 판정한다.

bash
laya-train --data data/shop-train-v2.jsonl --base multilingual --out checkpoints/shop-v2 --eval shop-eval.jsonl
text
eval 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)
text
=== 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)보다 확신과 실제가 가까워졌다.

python
"""지금 쓰는 판(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")
bash
python promote.py checkpoints/shop-v1 checkpoints/shop-v2
text
                 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 예문 셋 다시

bash
python first_query.py checkpoints/shop-v2
text
검색어: 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 로 다시 돌렸다.

bash
python threshold.py checkpoints/shop-v2
text
queries 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            0
그림 3.확신 0.9 에서 — v1 과 v2
개0100200300400500600700걸린 필터틀린 필터틀린 필터 걸린 검색어걸린 필터 · v1: 506걸린 필터 · v2: 691틀린 필터 · v1: 33틀린 필터 · v2: 35틀린 필터 걸린 검색어 · v1: 31틀린 필터 걸린 검색어 · v2: 31506691333531310200400600개걸린 필터틀린 필터틀린 필터 걸린 검색어걸린 필터 · v1: 506걸린 필터 · v2: 691틀린 필터 · v1: 33틀린 필터 · v2: 35틀린 필터 걸린 검색어 · v1: 31틀린 필터 걸린 검색어 · v2: 3150669133353131
  • v1
  • v2

검색어 210건이 요구한 필터는 868개

숫자로 보기
항목v1v2
걸린 필터506691
틀린 필터3335
틀린 필터 걸린 검색어3131

0.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 실습

  1. collect.py 의 고치는 비율을 0.6 에서 0.3 으로 낮춰 v2 를 다시 만든다. 칩을 고치는 사용자가 적으면 얼마나 느려지는지 본다.
  2. 칩을 고친 검색어만이 아니라 "필터를 그대로 두고 상품을 누른 검색어" 도 정답으로 넣으면 무엇이 좋아지고 무엇이 위험한지 적어 본다. v1 이 틀렸는데 사용자가 그냥 누른 경우가 섞인다.
  3. promote.py 에 넷째 기준 "unseen 단어에서 0.9 이상 확신으로 틀린 필터가 늘지 않을 것" 을 더해 본다. v2 가 통과하는지 본다.