hyunsu blog

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

LAYA 쇼핑 검색 2 — 검색어 학습 데이터 만들기와 v1 학습: 단어를 세 묶음으로 나눈 이유

목차 · 3
  1. 01표현을 세 묶음으로
  2. 02v1 학습
  3. 03실습

17편에서 질문 여섯 개를 정했다. 이 편에서 학습 데이터를 만들고 v1 을 학습한다. 실제 서비스라면 검색 로그에 사람이 정답 필터를 붙이겠지만, 여기서는 6편처럼 합성 데이터를 직접 만든다.

01 표현을 세 묶음으로

같은 값을 가리키는 표현을 세 묶음으로 나눴다.

값train (처음 학습)ops (운영에서 새로 들어옴)unseen (한 번도 학습 안 함)
패딩패딩 · 롱패딩숏패딩 · 푸퍼다운 점퍼 · 오리털 점퍼
남성남자 · 남성 · 남성용남친 선물 · 남편 · 아빠아들 · 오빠 선물로 · 할아버지
갈색갈색 · 브라운초코밤색 · 고동색
가격50만원 이하 · 30만원 안쪽50만원까지 · ~30만 · 50이하오십만 원 이하 · 예산 30만
겨울겨울 · 겨울용따뜻한 · 방한한겨울 · 추운 날 입을

평가 세트는 세 묶음에서 70건씩, 모두 210건이다. 이렇게 나누면 한 숫자로 뭉뚱그리지 않고 따로 물을 수 있다.

  • train 묶음: 배운 단어를 새 조합으로 썼을 때 맞히는가.
  • ops 묶음: 운영에서 들어온 새 표현을 지속 학습으로 따라잡는가(20편).
  • unseen 묶음: 한 번도 못 본 표현을 바탕 모델의 언어 지식으로 넘겨짚는가.

실제 검색어처럼 보이게 네 가지를 섞었다. 금액 뒤를 붙여 쓰기("50만원이하패딩", 15%), 부정("검정 말고 갈색", 10%), 곁들인 말("갈색 바지에 어울리는 니트", 상의의 12%), 오타("페딩"·"내이비", 10%)다.

그래도 합성은 합성이다. 실제 검색어에는 상표명, 사이즈, "ㅎㅎ" 같은 군말이 섞이고 값도 훨씬 많다. 이 데이터의 숫자는 방법끼리 비교하는 데만 쓰고, 내 서비스의 정확도로 읽지 않는다.

python
"""쇼핑몰 검색 문장 합성 데이터 — 문장 하나를 필터 여섯 칸(가격·분류·품목·겨울옷·성별·색)으로.

어휘를 셋으로 나눈다. train(처음 학습) · ops(운영에서 새로 들어오는 표현) · unseen(학습에 한 번도 안 나오는 표현).
평가 세트는 세 묶음을 같은 수로 섞어, 지속 학습이 어느 묶음에 도움이 되는지 따로 잰다.
"""
import json
import random

QUESTIONS = {
    "price": {"type": "choice", "instructions": "검색어의 가격 조건에 맞는 가격 구간은? 구간 경계와 다르면 그 금액을 넘는 가장 작은 구간",
              "criteria": {"under10": "10만원 이하", "under30": "30만원 이하", "under50": "50만원 이하",
                           "under100": "100만원 이하", "none": "가격 조건 없음"}},
    "category": {"type": "choice", "instructions": "찾는 상품의 대분류는?",
                 "criteria": {"top": "상의·아우터", "bottom": "하의", "shoes": "신발", "bag": "가방", "none": "분류를 알 수 없음"}},
    "item": {"type": "choice", "instructions": "찾는 상품의 품목은?",
             "criteria": {"padding": "패딩", "coat": "코트", "knit": "니트", "tshirt": "티셔츠", "jeans": "청바지",
                          "slacks": "슬랙스", "sneakers": "운동화", "boots": "부츠", "bag": "가방", "none": "품목 언급 없음"}},
    "winter": {"type": "noul", "instructions": "겨울옷(패딩·코트·니트·부츠, 또는 겨울에 쓸 상품)을 찾는가?"},
    "gender": {"type": "choice", "instructions": "누가 쓸 상품인가?",
               "criteria": {"men": "남성", "women": "여성", "unknown": "알 수 없음"}},
    "color": {"type": "choice", "instructions": "원하는 색은?",
              "criteria": {"brown": "갈색", "black": "검정", "white": "흰색", "gray": "회색", "navy": "남색",
                           "beige": "베이지", "none": "색 언급 없음"}},
}

# 값마다 [train, ops, unseen] 표현 목록.
ITEMS = {
    "padding": (["패딩", "롱패딩"], ["숏패딩", "푸퍼"], ["다운 점퍼", "오리털 점퍼"]),
    "coat": (["코트"], ["울코트", "롱코트"], ["더블 코트", "캐시미어 코트"]),
    "knit": (["니트", "스웨터"], ["니트티", "꽈배기 니트"], ["터틀넥", "폴라티"]),
    "tshirt": (["티셔츠", "반팔티"], ["반팔", "긴팔티"], ["라운드티", "무지티"]),
    "jeans": (["청바지"], ["데님 팬츠"], ["진 바지", "생지 데님"]),
    "slacks": (["슬랙스"], ["정장 바지"], ["기지 바지"]),
    "sneakers": (["운동화", "스니커즈"], ["러닝화"], ["단화", "캔버스화"]),
    "boots": (["부츠"], ["첼시 부츠", "워커"], ["털부츠", "롱부츠"]),
    "bag": (["가방", "백팩"], ["크로스백", "숄더백"], ["토트백", "에코백"]),
}
CATEGORY_OF = {"padding": "top", "coat": "top", "knit": "top", "tshirt": "top", "jeans": "bottom",
               "slacks": "bottom", "sneakers": "shoes", "boots": "shoes", "bag": "bag"}
WINTER_ITEMS = {"padding", "coat", "knit", "boots"}       # 회사 규칙: 이 품목은 겨울옷
CATEGORY_WORDS = {   # 품목 없이 분류만 말할 때
    "top": (["아우터", "상의"], ["겉옷"], ["윗옷"]),
    "bottom": (["바지", "하의"], ["팬츠"], ["아랫도리"]),
    "shoes": (["신발"], ["슈즈"], ["신을 거"]),
}
GENDERS = {
    "men": (["남자", "남성", "남성용"], ["남친 선물", "남편", "아빠"], ["아들", "오빠 선물로", "할아버지"]),
    "women": (["여자", "여성", "여성용"], ["여친 선물", "아내", "엄마"], ["딸", "언니 선물로", "할머니"]),
}
COLORS = {
    "brown": (["갈색", "브라운"], ["초코"], ["밤색", "고동색"]),
    "black": (["검정", "블랙"], ["검은"], ["까만", "흑색"]),
    "white": (["흰색", "화이트"], ["하얀"], ["백색", "순백"]),
    "gray": (["회색", "그레이"], ["차콜"], ["잿빛", "쥐색"]),
    "navy": (["남색", "네이비"], ["곤색"], ["감색"]),
    "beige": (["베이지"], ["샌드"], ["모래색"]),
}
WINTER_WORDS = (["겨울", "겨울용"], ["따뜻한", "방한"], ["한겨울", "추운 날 입을"])
SUMMER_WORDS = (["여름"], ["시원한"], ["한여름"])
PRICE_FORMS = (["{n}만원 이하", "{n}만 원 이하로", "{n}만원 안쪽", "{n}만원 미만"],
               ["{n}만원 아래", "{n}만원까지", "{n}이하", "~{n}만"],
               ["{k}만 원 이하", "{n}만원 넘지 않는", "예산 {n}만"])
AMOUNTS = ([10, 30, 50, 100, 20, 40], [10, 30, 50, 100, 15, 70], [10, 30, 50, 100, 25, 80])
KOREAN = {10: "십", 15: "십오", 20: "이십", 25: "이십오", 30: "삼십", 40: "사십", 50: "오십", 70: "칠십", 80: "팔십", 100: "백"}
TAILS = (["", " 추천", " 찾아요", " 보여줘"], ["", " 있나요", " 추천 좀", " 상품중"], ["", " 사고 싶어요", " 골라 주세요"])
POOLS = {"train": 0, "ops": 1, "unseen": 2}


def bucket(n):
    """구간 경계와 다른 금액은 그 금액을 넘는 가장 작은 구간으로(넘치는 상품은 검색엔진이 가격으로 거른다)."""
    return next(f"under{b}" for b in (10, 30, 50, 100) if n <= b)


def has_final(word):
    code = ord(word[-1]) - 0xAC00
    return 0 <= code <= 11171 and code % 28 not in (0, 8)   # 받침 없음·ㄹ 받침이면 "로"


def typo(word, rng):
    """흔한 오타 하나 — ㅐ↔ㅔ 를 바꾸거나, 없으면 낱말 가운데에 띄어쓰기를 넣는다."""
    chars = list(word)
    spots = [i for i, ch in enumerate(chars) if 0 <= ord(ch) - 0xAC00 <= 11171 and (ord(ch) - 0xAC00) // 28 % 21 in (1, 5)]
    if spots:
        i = rng.choice(spots)
        code = ord(chars[i]) - 0xAC00
        vowel = (code // 28) % 21
        chars[i] = chr(ord(chars[i]) + (4 if vowel == 1 else -4) * 28)
        return "".join(chars)
    if len(word) >= 3 and " " not in word:
        i = rng.randrange(1, len(word) - 1)
        return word[:i] + " " + word[i:]
    return word


def other(table, value, p, rng):
    return rng.choice(table[rng.choice([k for k in table if k != value])][p])


def make_one(pool, rng):
    p = POOLS[pool]
    label = {"price": "none", "category": "none", "item": "none", "winter": False, "gender": "unknown", "color": "none"}
    parts = {}
    if rng.random() < 0.65:
        n = rng.choice(AMOUNTS[p])
        parts["price"] = rng.choice(PRICE_FORMS[p]).format(n=n, k=KOREAN[n])
        label["price"] = bucket(n)
    r = rng.random()
    if r < 0.75:
        item = rng.choice(list(ITEMS))
        parts["item"] = rng.choice(ITEMS[item][p])
        label.update(item=item, category=CATEGORY_OF[item], winter=item in WINTER_ITEMS)
    elif r < 0.9:
        cat = rng.choice(list(CATEGORY_WORDS))
        parts["item"] = rng.choice(CATEGORY_WORDS[cat][p])
        label["category"] = cat
    if rng.random() < 0.6:
        g = rng.choice(list(GENDERS))
        parts["gender"] = rng.choice(GENDERS[g][p])
        label["gender"] = g
    if rng.random() < 0.65:
        c = rng.choice(list(COLORS))
        parts["color"] = rng.choice(COLORS[c][p])
        label["color"] = c
    s = rng.random()
    if s < 0.2 and label["item"] not in ("tshirt",):
        parts["season"] = rng.choice(WINTER_WORDS[p])
        label["winter"] = True
    elif s < 0.27 and label["item"] in ("tshirt", "sneakers", "bag", "none"):
        parts["season"] = rng.choice(SUMMER_WORDS[p])
    if len(parts) < 2:
        return None
    # 실제 검색어처럼: 부정("검정 말고 갈색") · 곁들인 말("갈색 바지에 어울리는") · 오타.
    if "color" in parts and rng.random() < 0.1:
        parts["color"] = f"{other(COLORS, label['color'], p, rng)} 말고 {parts['color']}"
    if label["item"] != "none" and rng.random() < 0.08:
        parts["item"] = f"{other(ITEMS, label['item'], p, rng)} 말고 {parts['item']}"
    if label["category"] == "top" and label["item"] != "none" and rng.random() < 0.12:
        side = rng.choice(ITEMS[rng.choice(["jeans", "slacks"])][p])
        parts["context"] = f"{rng.choice(list(COLORS.values()))[p][0]} {side}에 어울리는"
    for key in ("item", "color"):
        if key in parts and rng.random() < 0.1:
            words = parts[key].split(" ")
            words[-1] = typo(words[-1], rng)
            parts[key] = " ".join(words)
    # 말하는 순서: 대개 [가격] [성별] [계절] [색] [품목], 가끔 "품목 중에 색" · "색으로 된".
    head = [parts[k] for k in ("price", "gender", "season") if k in parts]
    rng.shuffle(head)
    color, item = parts.get("color"), parts.get("item")
    if color and item and rng.random() < 0.25:
        tail_words = [f"{item} 중에 {color}"]
    elif color and item and rng.random() < 0.2:
        tail_words = [f"{color}{'으로' if has_final(color) else '로'} 된 {item}"]
    else:
        tail_words = [w for w in (color, item) if w]
    words = head + ([parts["context"]] if "context" in parts else []) + tail_words
    text = " ".join(words)
    if "price" in parts and rng.random() < 0.15:           # "50만원이하패딩" 처럼 붙여 쓰기
        text = text.replace(parts["price"] + " ", parts["price"], 1)
    return text + rng.choice(TAILS[p]), label


def make(pools, n, seed, exclude=()):
    """pools 는 [(pool, 비율)]. 같은 문장은 한 번만, exclude 에 있는 문장은 뺀다."""
    rng = random.Random(seed)
    rows, seen = [], set(exclude)
    names, weights = zip(*pools)
    while len(rows) < n:
        pool = rng.choices(names, weights)[0]
        made = make_one(pool, rng)
        if not made or made[0] in seen:
            continue
        seen.add(made[0])
        rows.append({"state": made[0], "questions": QUESTIONS, "expected": made[1], "pool": pool})
    return rows


def write(name, rows):
    with open(name, "w", encoding="utf-8") as f:
        for r in rows:
            f.write(json.dumps(r, ensure_ascii=False) + "\n")


if __name__ == "__main__":
    evals = []
    for i, pool in enumerate(POOLS):
        evals += make([(pool, 1)], 70, seed=10 + i, exclude={r["state"] for r in evals})
    seen = {r["state"] for r in evals}
    first = make([("train", 1)], 150, seed=1, exclude=seen)
    seen |= {r["state"] for r in first}
    # 운영에서 들어올 검색어: 새 표현 70% + 익숙한 표현 30%.
    incoming = make([("ops", 0.7), ("train", 0.3)], 300, seed=3, exclude=seen)
    write("shop-first-150.jsonl", first)
    write("shop-incoming-300.jsonl", incoming)
    write("shop-eval.jsonl", evals)
    print(f"first {len(first)} · incoming {len(incoming)} · eval {len(evals)} (train/ops/unseen 70씩)")
    for key in ("price", "item", "gender", "color"):
        counts = {}
        for r in first:
            counts[r["expected"][key]] = counts.get(r["expected"][key], 0) + 1
        print(f"  {key:<8}", " ".join(f"{k} {v}" for k, v in sorted(counts.items(), key=lambda kv: -kv[1])))
    print("eval samples:")
    for r in evals[::35]:
        print(f"  [{r['pool']}] {r['state']}")
bash
python make_shop.py
text
first 150 · incoming 300 · eval 210 (train/ops/unseen 70씩)
  price    none 57 under30 30 under50 26 under100 20 under10 17
  item     none 46 boots 15 jeans 14 tshirt 14 knit 14 sneakers 13 padding 12 bag 9 slacks 9 coat 4
  gender   men 55 women 48 unknown 47
  color    none 41 black 20 navy 19 white 19 beige 18 gray 17 brown 16
eval samples:
  [train] 20만원 이하 내이비 부츠 찾아요
  [train] 10만원 안쪽청바지
  [ops] ~100만 시원한 곤색 반팔 있나요
  [ops] 시원한 ~70만 곤색 데님 팬츠에 어울리는 곤섹 긴팔티 추천 좀
  [unseen] 언니 선물로 폴라티 중에 잿빛
  [unseen] 10만원 넘지 않는 딸 토트백 사고 싶어요
  • 처음 150건(shop-first-150.jsonl)은 train 묶음만 쓴다. 사람이 정답을 붙인 첫 데이터 역할이다.
  • 운영 300건(shop-incoming-300.jsonl)은 ops 70% · train 30% 다. 20편에서 v1 을 운영에 올린 뒤 들어오는 검색어로 쓴다.
  • 줄마다 pool 칸에 어느 묶음인지 적었다. 평가 때 묶음별로 나누려는 것이고, laya-train 은 모르는 칸을 무시한다.
  • 품목 분포가 고르지 않다(부츠 15 · 코트 4). 실제 데이터도 그렇다. 코트처럼 적은 값은 결과에서 따로 본다.

02 v1 학습

bash
laya-train --data shop-first-150.jsonl --base multilingual --out checkpoints/shop-v1 --eval shop-eval.jsonl
text
eval items 1260 from shop-eval.jsonl (skipped {}, 0 overlapping training/calibration items)
train items 810, calibration items 90, eval items 1260 (shop-eval.jsonl), skipped {}, device mps
optimizer budget: 810 train item(s), effective batch up to 64 (micro_batch 8 x grad_accum 8), 52 optimizer updates over 4 epoch(s)
epoch 1/4 step 100 loss 0.2087
epoch 1/4 mean loss 0.4847
epoch 2/4 step 100 loss 0.1722
epoch 2/4 mean loss 0.1414
epoch 3/4 step 100 loss -0.0686
epoch 3/4 mean loss 0.1514
epoch 4/4 step 100 loss -0.0253
epoch 4/4 mean loss 0.1495
  • 810 항목: 검색어 150건 × 질문 6개 = 900 에서 보정용 90 을 뺀 수다. 문의 분류 때(150건 × 질문 2개)보다 항목이 세 배다. 같은 150건이라도 질문이 많으면 모델이 배울 것도 많다.
  • 52 업데이트: 4에폭 동안 가중치를 52번 고쳤다. 7편의 20번보다 많다.
  • 손실은 1에폭 0.48 에서 0.15 로 내려갔다. 7편처럼 0 까지 떨어지지는 않았다. 질문이 여섯이라 한 번에 다 외우지 못한 것이다.
text
=== Evaluation (before vs after fine-tuning on 1260 held-out items from shop-eval.jsonl) ===
Metric                 Before         After          Delta         
-----------------------------------------------------------------
Accuracy               0.4857         0.7183         +0.2326       
Loss                   1.5558         1.3935         -0.1623       
ECE                    0.1940         0.2072         +0.0132       
Brier (multi-class)    0.7368         0.4829         -0.2539       
Brier (top-1)          0.2975         0.2211         -0.0764       
Mean Confidence        0.6769         0.9243         +0.2474       
-----------------------------------------------------------------

Training complete! Model saved to checkpoints/shop-v1

평가 세트 210건 × 6 = 1260 항목에서, 질문 하나하나의 정확도가 0.486 에서 0.718 로 올랐다. 이것은 필터 칸 하나하나의 평균이다. 검색에서 중요한 것은 여섯 칸이 모두 맞아서 필터가 통째로 맞는 비율이고, 그 숫자는 이것보다 훨씬 낮다. 다음 편에서 잰다.

평균 확신(0.924)이 정확도(0.718)보다 높다. 모델이 실제보다 자신 있어 한다는 뜻이고, 17편의 확신 문턱 0.7 이 생각만큼 거르지 못할 수 있다는 신호다.

03 실습

  1. make_shop.py 에서 typo 를 부르는 줄의 비율(0.1)을 0 으로 바꿔 데이터를 다시 만들고 v1 을 학습해 본다. 평가에도 오타가 없어지니 숫자가 어떻게 바뀌는지 본다.
  2. 내 쇼핑몰이라면 표의 세 묶음에 무엇을 넣을지 적어 본다. "운영에서 새로 들어올 표현" 은 지난달 검색 로그에서 상위 검색어를 보면 감이 온다.
  3. ITEMS 에 품목 하나(예: 플리스)를 더하면 질문의 criteria 도 바꿔야 한다. 어디를 같이 고쳐야 하는지 찾아본다.