직접 해 보기: 작은 언어 모델로 디코딩 전략 비교하기
바이그램 모델로 탐욕 · 빔 · 온도 · top-k · top-p · min-p를 직접 돌려 비교하는 실습
먼저 알아야 할 것
다루는 내용
문장 12개로 만든 단어 바이그램 언어 모델에서 디코딩 전략만 바꿔 200문장씩 생성하고 distinct-2 · 반복률 · 자연스러움 · 학습 문장 복사율로 비교하는 넘파이 실습입니다. 실제 실행 결과로 온도의 다양성-품질 교환, 평평한 꼬리에서 top-p의 한계, min-p의 꼬리 자르기를 확인합니다.
해설
직접 해 보기: 작은 언어 모델로 디코딩 전략 비교하기
디코딩 전략 수업에서 손으로 계산한 탐욕 · 빔 · 온도 · top-k · top-p · min-p를 직접 코드로 돌려 봅니다. 문장 12개로 단어 바이그램 언어 모델을 만들고, 같은 모델에서 전략만 바꿔 200문장씩 생성한 뒤 다양성 · 반복 · 자연스러움 · 학습 문장 베끼기 네 지표로 비교합니다. 넘파이만 있으면 노트북에서 1초 안에 돌아갑니다.
| 항목 | 내용 |
|---|---|
| 참고 출처 | Hugging Face 블로그 「How to generate text」 · Generation strategies 문서 · 핵 샘플링(arXiv 1904.09751) |
| 트랙 · 수준 | NLP·트랜스포머 · 중급 (실습) |
| 준비물 | 파이썬 3.10 이상, numpy |
| 걸리는 시간 | 읽고 실행 30분, 응용 과제 1시간 |
| 실행 확인 | 2026-10-22, Python 3.14 · numpy 2.5에서 아래 코드를 그대로 실행해 결과 표를 얻었습니다 |
들어가기 전에
- 디코딩 전략: 탐욕 · 빔 · 온도 · top-k · top-p — 각 전략의 정의와 손계산.
- 언어 모델의 기본: 다음 토큰 예측 · 퍼플렉서티 · 샘플링 — 개수를 세어 만드는 n-그램 언어 모델과 평활화.
한눈에 보기
- 개수 세기만으로 만든 바이그램 모델로 "다음 단어 확률 분포"를 직접 만듭니다.
- 탐욕 · 빔 탐색 · 온도 · top-k · top-p · min-p를 각각 20줄 안팎으로 구현합니다.
- distinct-2(다양성), 반복률, 자연스러움, 학습 문장 복사율로 전략을 비교합니다.
- 분포의 긴 꼬리 가 왜 문제이고, top-p와 min-p가 꼬리를 다르게 다루는 이유를 결과로 확인합니다.
1. 실습 구조
flowchart LR
A[문장 12개] --> B[단어로 나누기<br/>시작 · 끝 토큰 붙이기]
B --> C[바이그램 개수 세기<br/>+ 더하기-k 평활화]
C --> D[다음 단어 확률표 P]
D --> E[전략별 생성<br/>200문장씩]
E --> F[지표 4개로 비교]왜 신경망이 아니라 바이그램인가. 디코딩 전략은 "확률 분포를 받아 토큰을 고르는 규칙"이라 모델 종류와 상관없이 똑같이 적용됩니다. 바이그램은 분포를 눈으로 확인할 수 있을 만큼 작아서, 전략이 분포의 어느 부분을 자르는지 직접 볼 수 있습니다. 실제 LLM에서는 logits[idx[prev]] 자리에 모델 출력 로짓이 들어갈 뿐입니다.
2. 단계별 설명
① 데이터와 어휘. 문장마다 앞에 <s>(시작), 뒤에 </s>(끝)를 붙입니다. 어휘는 40개 단어입니다.
② 바이그램 확률. "앞 단어 다음에 가 나온 횟수"를 셉니다. 한 번도 안 나온 쌍도 확률이 0이 되지 않도록 모든 칸에 작은 값 을 더합니다(더하기-k 평활화).
| 기호 | 뜻 |
|---|---|
| 학습 문장에서 바로 뒤에 가 나온 횟수 | |
| 평활화 값(여기서는 0.01) | |
| 분모 | 그 행의 합. 확률 합이 1이 되게 합니다 |
숫자로 확인(코드로 출력한 값). '본관' 다음은 학습 데이터에서 '삼'만 한 번 나왔습니다. 그래도 평활화 때문에 확률은 '삼' 0.727, 나머지 38개 단어(시작 토큰 제외)가 각각 약 0.007입니다. 꼬리 38개를 합치면 0.273, 즉 27%의 확률이 한 번도 본 적 없는 이어짐 에 가 있습니다. 이것이 '긴 꼬리'입니다. '회의는' 다음은 '본관 · 오전 · 오후'가 각각 0.298로 동률입니다.
③ 고르기 규칙. pick 함수 하나에 전략을 모았습니다. 온도로 나누고 → top-k · top-p · min-p로 자르고 → 재정규화한 뒤 → 샘플링합니다. 탐욕은 가장 큰 값을 고르고, 빔 탐색은 beam 함수에서 상위 3개 갈래를 유지하며 길이로 나눈 로그 확률 로 순위를 매깁니다.
④ 지표.
- distinct-2: 생성된 모든 단어 쌍 가운데 서로 다른 쌍의 비율. 높을수록 다양합니다.
- 반복률: 한 문장 안에서 같은 단어가 다시 나온 비율의 평균.
- 자연스러움: 문장의 모든 이웃 단어 쌍이 학습 데이터에 실제로 있던 쌍인 문장의 비율. 이 작은 실험에서 '말이 되는 정도'를 대신하는 거친 지표입니다(해설용으로 만든 지표).
- 학습 문장 복사: 생성 문장이 학습 문장 하나와 똑같은 비율. 높으면 외운 것을 그대로 내놓는 셈입니다.
3. 전체 코드
decoding_lab.py 로 저장하고 python decoding_lab.py 로 실행합니다. 윈도에서 한글이 깨지면 python -X utf8 decoding_lab.py 로 실행합니다.
# 작은 단어 바이그램 언어 모델로 디코딩 전략 비교하기 (numpy만 사용)
import numpy as np
from collections import Counter
corpus = """
오늘 회의는 오후 세 시에 시작합니다
오늘 회의는 본관 삼 층에서 시작합니다
내일 회의는 오전 열 시에 시작합니다
오늘 점심은 회사 근처 식당에서 먹습니다
내일 점심은 팀원들과 함께 먹습니다
오늘 보고서는 오후 다섯 시까지 제출합니다
내일 보고서는 오전 중에 제출합니다
팀장님은 오늘 회의에서 보고서를 검토합니다
팀원들은 내일 회의에서 결과를 발표합니다
우리는 오후 세 시에 커피를 마십니다
우리는 회의 전에 커피를 마십니다
오늘 발표는 회의 중에 진행합니다
""".strip().split("\n")
# 1) 토큰화와 어휘
sents = [["<s>"] + s.split() + ["</s>"] for s in corpus]
vocab = sorted({w for s in sents for w in s})
idx = {w: i for i, w in enumerate(vocab)}
V = len(vocab)
# 2) 바이그램 개수 세기 + 더하기-k 평활화 -> 로그 확률(로짓 역할)
k = 0.01
C = np.full((V, V), k)
for s in sents:
for a, b in zip(s, s[1:]):
C[idx[a], idx[b]] += 1
C[:, idx["<s>"]] = 0 # 문장 시작 토큰은 다음 토큰으로 나오지 않게
P = C / C.sum(1, keepdims=True)
logits = np.log(P + 1e-12)
def softmax(x):
e = np.exp(x - x.max()); return e / e.sum()
# 3) 다음 토큰 고르기 규칙들
def pick(prev, rng, mode, T=1.0, top_k=None, top_p=None, min_p=None):
u = logits[idx[prev]].copy()
if mode == "greedy":
return vocab[int(np.argmax(u))]
p = softmax(u / T)
if top_k is not None:
keep = np.argsort(-p)[:top_k]
m = np.zeros_like(p); m[keep] = p[keep]; p = m / m.sum()
if top_p is not None:
order = np.argsort(-p); c = np.cumsum(p[order])
n = int(np.searchsorted(c, top_p) + 1)
m = np.zeros_like(p); m[order[:n]] = p[order[:n]]; p = m / m.sum()
if min_p is not None:
m = np.where(p >= min_p * p.max(), p, 0.0); p = m / m.sum()
return vocab[rng.choice(V, p=p)]
def generate(start, rng, max_len=12, **kw):
out = [start]
while len(out) < max_len:
nxt = pick(out[-1], rng, **kw)
if nxt == "</s>": break
out.append(nxt)
return out
def beam(start, b=3, max_len=12):
beams = [([start], 0.0, False)]
for _ in range(max_len):
cand = []
for seq, lp, done in beams:
if done: cand.append((seq, lp, True)); continue
for j in np.argsort(-logits[idx[seq[-1]]])[:b]:
w = vocab[j]
cand.append((seq + ([] if w == "</s>" else [w]), lp + logits[idx[seq[-1]], j], w == "</s>"))
beams = sorted(cand, key=lambda t: t[1] / len(t[0]), reverse=True)[:b] # 길이로 나눈 점수
if all(d for _, _, d in beams): break
return beams[0][0]
# 4) 평가 지표: 다양성(distinct-2)과 반복률
def distinct2(samples):
bg = [tuple(s[i:i+2]) for s in samples for i in range(len(s) - 1)]
return len(set(bg)) / max(1, len(bg))
def repeat_rate(samples):
r = [1 - len(set(s)) / len(s) for s in samples]
return float(np.mean(r))
def seen_rate(samples): # 생성 문장이 학습 문장과 똑같은 비율
S = {" ".join(s[1:-1]) for s in sents}
return float(np.mean([" ".join(s) in S for s in samples]))
SEEN = {(a, b) for s in sents for a, b in zip(s, s[1:])}
def valid_rate(samples): # 모든 이웃 단어 쌍이 학습 데이터에 있던 쌍인 비율(말이 되는 정도의 대용)
return float(np.mean([all((a, b) in SEEN for a, b in zip(s, s[1:])) for s in samples]))
if __name__ == "__main__":
print("어휘 크기:", V)
print("탐욕 :", " ".join(generate("오늘", None, mode="greedy")))
print("빔(b=3) :", " ".join(beam("오늘")))
settings = [
("온도 0.3", dict(mode="sample", T=0.3)),
("온도 1.0", dict(mode="sample", T=1.0)),
("온도 2.0", dict(mode="sample", T=2.0)),
("top-k 2", dict(mode="sample", top_k=2)),
("top-p 0.9", dict(mode="sample", top_p=0.9)),
("min-p 0.1", dict(mode="sample", min_p=0.1)),
]
print(f"{'설정':<10}{'distinct-2':>11}{'반복률':>8}{'자연스러움':>8}{'학습문장 복사':>10} 예시")
for name, kw in settings:
rng = np.random.default_rng(0)
samples = [generate("오늘", rng, **kw) for _ in range(200)]
print(f"{name:<10}{distinct2(samples):>11.3f}{repeat_rate(samples):>8.3f}{valid_rate(samples):>8.2f}{seen_rate(samples):>10.2f} {' '.join(samples[0])}")4. 실행 결과 (2026-10-22 실제 출력)
어휘 크기: 40
탐욕 : 오늘 회의는 본관 삼 층에서 시작합니다
빔(b=3) : 오늘 회의는 본관 삼 층에서 시작합니다
설정 distinct-2 반복률 자연스러움 학습문장 복사 예시
온도 0.3 0.044 0.000 1.00 0.56 오늘 회의는 본관 삼 층에서 시작합니다
온도 1.0 0.232 0.028 0.34 0.12 오늘 회의는 본관 다섯 결과를 삼 층에서 시작합니다
온도 2.0 0.492 0.092 0.03 0.00 오늘 층에서 본관 검토합니다
top-k 2 0.023 0.000 1.00 0.36 오늘 회의는 본관 삼 층에서 시작합니다
top-p 0.9 0.132 0.008 0.65 0.17 오늘 회의는 본관 다섯 결과를 발표합니다 시까지 제출합니다
min-p 0.1 0.047 0.000 1.00 0.30 오늘 회의는 본관 삼 층에서 시작합니다5. 결과 읽기
탐욕과 빔이 같은 문장을 냈습니다. 바이그램은 바로 앞 단어만 보므로 갈래가 적고, 이 작은 데이터에서는 빔이 더 나은 길을 찾을 여지가 없었습니다. 또 '회의는' 다음 세 후보가 동률이라, 탐욕은 어휘 순서상 먼저 오는 '본관'을 골랐습니다. 실제 모델에서도 탐욕은 "동률이나 근소한 차이에서 늘 같은 쪽"을 고르기 때문에 다양성이 0입니다.
온도는 다양성과 자연스러움을 맞바꿉니다. 온도 0.3은 자연스러움 1.00이지만 distinct-2가 0.044이고 절반 넘게(0.56) 학습 문장을 그대로 베꼈습니다. 온도 2.0은 다양성이 0.492로 가장 높지만 자연스러움이 0.03으로 거의 모든 문장이 말이 안 됩니다. 온도 1.0은 그 중간입니다.
top-p 0.9가 꼬리를 못 잘랐습니다. '본관' 다음처럼 1등이 0.727이고 나머지가 0.007씩 고르게 깔린 분포에서는 누적 0.9를 채우려면 꼬리 단어를 25개나 더 넣어야 합니다(코드로 세면 후보 26개). 그래서 top-p 0.9의 자연스러움은 0.65에 그쳤습니다. 핵 샘플링이 "분포 모양에 맞춰 후보 수를 바꾼다"는 장점이 있지만, 꼬리가 넓고 평평하면 그 꼬리를 대부분 포함하게 된다 는 것을 보여 줍니다.
min-p 0.1과 top-k 2는 꼬리를 잘랐습니다. min-p는 "1등 확률의 10% 미만"을 버리므로 0.007짜리 꼬리를 모두 버립니다. 자연스러움 1.00을 지키면서도 학습 문장 복사율은 0.30으로 온도 0.3(0.56)보다 낮습니다. 즉 학습 문장에 없던 새롭지만 말이 되는 조합 을 더 많이 만들었습니다. top-k 2도 자연스럽지만 후보가 늘 2개라 다양성이 가장 낮았습니다.
주의. 이 결과는 문장 12개짜리 장난감 모델의 것입니다. 실제 LLM은 분포 모양이 훨씬 다양해서 상대적 순위가 달라질 수 있습니다. 여기서 얻을 것은 숫자 자체가 아니라 "각 전략이 분포의 어느 부분을 자르는가"라는 감각입니다.
6. 응용 과제
- 평활화를 바꿔 보기.
k = 0.1로 올리고 다시 실행하세요. 무엇이 달라질까요?- 풀이 방향: 꼬리 확률이 커져 온도 1.0과 top-p 0.9의 자연스러움이 각각 0.34 → 0.04, 0.65 → 0.04로 떨어집니다(같은 시드로 실행한 값). min-p 0.1은 1.00을 유지합니다. 모델이 "모르는 이어짐"에 확률을 많이 줄수록 자르기 전략이 중요해진다는 뜻입니다.
- top-p를 0.7로.
top_p=0.7설정을 추가해 보세요.- 풀이 방향: '본관' 다음에는 '삼'(0.727) 하나만 남아 꼬리가 잘립니다. '회의는' 다음은 세 후보 누적이 0.298 → 0.596 → 0.894라 세 개가 모두 남습니다. 문턱을 더 낮추면(예: 0.5) 동률 후보 중 일부가 잘리기 시작합니다.
- 반복 벌점 구현.
pick에 이미 나온 단어의 로짓을 깎는 코드를 넣어 보세요. CTRL 논문 방식은 생성된 토큰의 점수를 (예: 1.2)로 나누는 것입니다. 이 실험의 로짓은 로그 확률이라 모두 음수이므로, 나누면 오히려 커집니다. 음수 로짓은 를 곱해야 낮아진다는 점을 확인하세요.- 풀이 방향:
u[seen] = np.where(u[seen] < 0, u[seen] * theta, u[seen] / theta)처럼 부호에 따라 다르게 처리합니다. Hugging Face 구현도 부호에 따라 곱하거나 나누는 방식으로 처리합니다.
- 풀이 방향:
- 진짜 LLM으로 옮기기(선택).
transformers가 설치된 환경이라면model.generate(..., do_sample=True, temperature=..., top_p=...)로 같은 비교를 해 보세요. 이 실습 환경에는 무거운 패키지를 설치하지 않았으므로 이 단계는 실행해 보지 않았습니다.
스스로 점검
- 이 실험에서 온도 0.3이 '자연스럽지만 쓸모없는' 이유는?
- 답: 거의 학습 문장을 그대로 반복해(복사율 0.56, distinct-2 0.044) 새로운 문장을 만들지 못하기 때문입니다.
- top-p 0.9가 '본관' 다음에서 꼬리를 거의 다 포함한 이유는?
- 답: 1등이 0.727이라 나머지 0.173을 채우려면 0.007짜리 꼬리 단어를 25개나 더해야 했기 때문입니다(후보 26개).
- min-p가 꼬리를 잘 자른 이유를 한 문장으로?
- 답: 문턱이 1등 확률에 비례해(0.1×0.727≈0.073) 정해지므로, 1등보다 훨씬 작은 꼬리 단어들이 한꺼번에 걸러집니다.
용어 정리
| 용어 | 쉬운 뜻 |
|---|---|
| 바이그램 모델 | 바로 앞 단어 하나만 보고 다음 단어 확률을 정하는 모델 |
| 더하기-k 평활화 | 안 나온 쌍에도 작은 개수 를 더해 확률 0을 없애는 방법 |
| 긴 꼬리 | 각각은 작지만 합치면 큰, 확률 낮은 후보들의 묶음 |
| distinct-2 | 생성 글에서 서로 다른 두 단어 쌍의 비율(다양성 지표) |
| 반복률 | 한 문장 안에서 같은 단어가 되풀이되는 정도 |
| 시드(seed) | 난수 생성을 같은 순서로 재현하게 하는 시작값 |
더 깊이 보려면
- 디코딩 전략: 탐욕 · 빔 · 온도 · top-k · top-p — 이 실습의 이론
- 언어 모델은 비지도 멀티태스크 학습자다 (GPT-2) — 큰 모델에서 샘플링 생성의 예
- Hugging Face 블로그 「How to generate text」 —
transformers로 같은 전략을 쓰는 법
다음에 볼 것
- LLM 사전학습: 데이터 · 목표 · 학습 과정 한눈에 — 장난감 12문장이 아니라 수조 토큰으로 분포를 배우는 과정
- 언어 모델은 퓨샷 학습자다 (GPT-3) — 분포를 아주 잘 배운 모델이 할 수 있게 된 일
라이선스
본문과 코드는 KAITABASE가 이 실습을 위해 직접 작성했습니다. 예문 12개도 직접 만든 것입니다. 참고 자료 Hugging Face 문서는 Apache 2.0으로 공개되어 있습니다.
출처
- Hugging Face, How to generate text: https://huggingface.co/blog/how-to-generate
- Hugging Face, Text generation strategies: https://huggingface.co/docs/transformers/generation_strategies
- Holtzman 외, The Curious Case of Neural Text Degeneration (2019): https://arxiv.org/abs/1904.09751
- Keskar 외, CTRL (2019): https://arxiv.org/abs/1909.05858
AI 원리부터 이해하기 · NLP·트랜스포머 모듈
NLP·트랜스포머 모듈 학습
이 글은 NLP·트랜스포머 모듈의 학습 자료입니다. 전체 23개 자료 중 18번째입니다.
출처·원문
원문 보기요약만