홈강좌LAYA 결정 모델 24편 중 18편째
LAYA 쇼핑 검색 2 — 검색어 학습 데이터 만들기와 v1 학습: 단어를 세 묶음으로 나눈 이유
목차 · 3
17편에서 질문 여섯 개를 정했다. 이 편에서 학습 데이터를 만들고 v1 을 학습한다. 실제 서비스라면 검색 로그에 사람이 정답 필터를 붙이겠지만, 여기서는 6편처럼 합성 데이터를 직접 만든다.
01 표현을 세 묶음으로
같은 값을 가리키는 표현을 세 묶음으로 나눴다.
| 값 | train (처음 학습) | ops (운영에서 새로 들어옴) | unseen (한 번도 학습 안 함) |
|---|---|---|---|
| 패딩 | 패딩 · 롱패딩 | 숏패딩 · 푸퍼 | 다운 점퍼 · 오리털 점퍼 |
| 남성 | 남자 · 남성 · 남성용 | 남친 선물 · 남편 · 아빠 | 아들 · 오빠 선물로 · 할아버지 |
| 갈색 | 갈색 · 브라운 | 초코 | 밤색 · 고동색 |
| 가격 | 50만원 이하 · 30만원 안쪽 | 50만원까지 · ~30만 · 50이하 | 오십만 원 이하 · 예산 30만 |
| 겨울 | 겨울 · 겨울용 | 따뜻한 · 방한 | 한겨울 · 추운 날 입을 |
평가 세트는 세 묶음에서 70건씩, 모두 210건이다. 이렇게 나누면 한 숫자로 뭉뚱그리지 않고 따로 물을 수 있다.
- train 묶음: 배운 단어를 새 조합으로 썼을 때 맞히는가.
- ops 묶음: 운영에서 들어온 새 표현을 지속 학습으로 따라잡는가(20편).
- unseen 묶음: 한 번도 못 본 표현을 바탕 모델의 언어 지식으로 넘겨짚는가.
실제 검색어처럼 보이게 네 가지를 섞었다. 금액 뒤를 붙여 쓰기("50만원이하패딩", 15%), 부정("검정 말고 갈색", 10%), 곁들인 말("갈색 바지에 어울리는 니트", 상의의 12%), 오타("페딩"·"내이비", 10%)다.
그래도 합성은 합성이다. 실제 검색어에는 상표명, 사이즈, "ㅎㅎ" 같은 군말이 섞이고 값도 훨씬 많다. 이 데이터의 숫자는 방법끼리 비교하는 데만 쓰고, 내 서비스의 정확도로 읽지 않는다.
"""쇼핑몰 검색 문장 합성 데이터 — 문장 하나를 필터 여섯 칸(가격·분류·품목·겨울옷·성별·색)으로.
어휘를 셋으로 나눈다. 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']}")python make_shop.pyfirst 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 학습
laya-train --data shop-first-150.jsonl --base multilingual --out checkpoints/shop-v1 --eval shop-eval.jsonleval 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 까지 떨어지지는 않았다. 질문이 여섯이라 한 번에 다 외우지 못한 것이다.
=== 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 실습
make_shop.py에서typo를 부르는 줄의 비율(0.1)을 0 으로 바꿔 데이터를 다시 만들고 v1 을 학습해 본다. 평가에도 오타가 없어지니 숫자가 어떻게 바뀌는지 본다.- 내 쇼핑몰이라면 표의 세 묶음에 무엇을 넣을지 적어 본다. "운영에서 새로 들어올 표현" 은 지난달 검색 로그에서 상위 검색어를 보면 감이 온다.
ITEMS에 품목 하나(예: 플리스)를 더하면 질문의criteria도 바꿔야 한다. 어디를 같이 고쳐야 하는지 찾아본다.