import numpy as npimport pandas as pdimport torchimport torch.nn as nnimport torch.nn.functional as Fimport matplotlib.pyplot as plttorch.manual_seed(42)corpus = ["딥러닝은 데이터로 규칙을 배운다","기계학습은 데이터로 규칙을 배운다","딥러닝은 이미지를 잘 분류한다","자동차는 도로를 달린다",]for s in corpus:print(s)
딥러닝은 데이터로 규칙을 배운다
기계학습은 데이터로 규칙을 배운다
딥러닝은 이미지를 잘 분류한다
자동차는 도로를 달린다
1-1. 무엇을 한 단위로 셀 것인가
def by_space(s): return s.split()def by_char(s): returnlist(s.replace(' ', ''))rows = []for name, fn in [('공백 단위', by_space), ('글자 단위', by_char)]: toks = [t for s in corpus for t in fn(s)] rows.append({'방식': name, '전체 토큰 수': len(toks), '어휘 크기': len(set(toks))})print(pd.DataFrame(rows).to_string(index=False))print('\n공백 단위:', by_space(corpus[0]))print('글자 단위:', by_char(corpus[0]))
방식 전체 토큰 수 어휘 크기
공백 단위 15 11
글자 단위 52 32
공백 단위: ['딥러닝은', '데이터로', '규칙을', '배운다']
글자 단위: ['딥', '러', '닝', '은', '데', '이', '터', '로', '규', '칙', '을', '배', '운', '다']
두 방식의 맞바꿈
공백 단위: 시퀀스는 짧지만 어휘가 폭발한다. “배운다 / 배웠다 / 배우고”가 전부 다른 단어다.
글자 단위: 어휘는 작지만 시퀀스가 길어지고, 글자 하나에는 의미가 거의 없다.
그래서 실제로는 그 사이 — 서브워드를 쓴다.
1-2. BPE를 직접 구현 — Ch10의 손계산 재현
장난감 문자열 aaabdaaabac 에 병합을 3번 적용한다.
from collections import Counterdef most_frequent_pair(tokens, order):"""최다 빈도 쌍. 동률이면 어휘에 먼저 들어온 기호를 우선한다.""" pairs = Counter(zip(tokens[:-1], tokens[1:]))ifnot pairs:returnNone, 0 best =max(pairs.items(), key=lambda kv: (kv[1], -order[kv[0][0]], -order[kv[0][1]]))return bestdef merge(tokens, pair, new_symbol): out, i = [], 0while i <len(tokens):if i <len(tokens)-1and (tokens[i], tokens[i+1]) == pair: out.append(new_symbol); i +=2else: out.append(tokens[i]); i +=1return outtokens =list('aaabdaaabac')order = {c: i for i, c inenumerate(sorted(set(tokens)))} # 어휘 진입 순서rows = [{'단계': 0, '토큰열': ' '.join(tokens), '토큰 수': len(tokens),'최다 빈도 쌍': f'{most_frequent_pair(tokens, order)[0]} — {most_frequent_pair(tokens, order)[1]}회','병합': ''}]for step, sym inenumerate(['Z', 'Y', 'X'], start=1): pair, cnt = most_frequent_pair(tokens, order) tokens = merge(tokens, pair, sym) order[sym] =len(order) # 새 기호는 맨 뒤에 추가 nxt, ncnt = most_frequent_pair(tokens, order) rows.append({'단계': step, '토큰열': ' '.join(tokens), '토큰 수': len(tokens),'최다 빈도 쌍': f'{nxt} — {ncnt}회','병합': f'{"".join(pair)} → {sym}'})print(pd.DataFrame(rows).to_string(index=False))print('\n어휘 사전:', list(order.keys()))
단계 토큰열 토큰 수 최다 빈도 쌍 병합
0 a a a b d a a a b a c 11 ('a', 'a') — 4회
1 Z a b d Z a b a c 9 ('a', 'b') — 2회 aa → Z
2 Z Y d Z Y a c 7 ('Z', 'Y') — 2회 ab → Y
3 X d X a c 5 ('a', 'c') — 1회 ZY → X
어휘 사전: ['a', 'b', 'c', 'd', 'Z', 'Y', 'X']
토큰 수는 11 → 5로 줄고, 어휘는 4개 → 7개로 늘었다. 자주 나오는 조각을 하나의 토큰으로 승격시키는 것이 BPE의 전부다.
단계 1에서 (Z, a) 와 (a, b) 가 둘 다 2회로 동률이다. 동률일 때 무엇을 고를지는 미리 정해 두어야 결과가 재현된다 — 여기서는 어휘에 먼저 들어온 기호를 우선하는 규칙을 썼다. 실제 토크나이저도 이런 규칙을 학습 시점에 고정해 두고 파일로 저장한다.
1-3. 실제 토크나이저
from transformers import AutoTokenizertok = AutoTokenizer.from_pretrained('klue/bert-base')print('토크나이저 :', type(tok).__name__)print('어휘 크기 :', tok.vocab_size)print('특수 토큰 :', {'[PAD]': tok.pad_token_id, '[UNK]': tok.unk_token_id,'[CLS]': tok.cls_token_id, '[SEP]': tok.sep_token_id})
토크나이저 : BertTokenizerFast
어휘 크기 : 32000
특수 토큰 : {'[PAD]': 0, '[UNK]': 1, '[CLS]': 2, '[SEP]': 3}
for s in ['딥러닝은 즐겁다', '기계학습은 어렵다', 'Deep learning is fun', '췩췩폭폭']:print(f'{s:22s} → {tok.tokenize(s)}')
앞뒤에 [CLS], [SEP] 가 자동으로 붙었다. 모델마다 요구하는 특수 토큰이 다르므로 토크나이저와 모델은 반드시 짝을 맞춰야 한다.
2. 어휘 사전 만들기
토크나이저가 하는 일을 작은 규모로 직접 해 본다.
tokens_all =sorted({t for s in corpus for t in by_space(s)})stoi = {'[PAD]': 0, '[UNK]': 1}for t in tokens_all: stoi[t] =len(stoi)itos = {i: s for s, i in stoi.items()}print('어휘 크기 V :', len(stoi))print(stoi)
def encode(s):return [stoi.get(t, stoi['[UNK]']) for t in by_space(s)]def decode(ids):return' '.join(itos[i] for i in ids)ids = encode(corpus[0])print('원문 :', corpus[0])print('인덱스 :', ids)print('복원 :', decode(ids))print('\n처음 보는 단어:', encode('로봇은 걷는다'), '← 둘 다 [UNK](1)')
원문 : 딥러닝은 데이터로 규칙을 배운다
인덱스 : [7, 5, 2, 8]
복원 : 딥러닝은 데이터로 규칙을 배운다
처음 보는 단어: [1, 1] ← 둘 다 [UNK](1)
3. 원-핫으로는 왜 안 되는가
V =len(stoi)def onehot(i): v = torch.zeros(V); v[i] =1.0;return va, b, c = onehot(stoi['딥러닝은']), onehot(stoi['기계학습은']), onehot(stoi['자동차는'])print('딥러닝은 · 기계학습은 =', float(a @ b))print('딥러닝은 · 자동차는 =', float(a @ c))print('딥러닝은 · 딥러닝은 =', float(a @ a))
서로 다른 단어는 무조건 내적이 0이다. “딥러닝”과 “기계학습”이 가까운지, “자동차”와 먼지를 원-핫은 표현할 방법이 없다.
rows = []for V_ in [len(stoi), 5_000, 32_000, 50_257]: rows.append({'어휘 V': f'{V_:,}', '원-핫 한 단어의 크기': f'{V_:,} 차원','0이 아닌 값': 1, '메모리(float32)': f'{V_*4/1024:.1f} KB'})print(pd.DataFrame(rows).to_string(index=False))
어휘 V 원-핫 한 단어의 크기 0이 아닌 값 메모리(float32)
13 13 차원 1 0.1 KB
5,000 5,000 차원 1 19.5 KB
32,000 32,000 차원 1 125.0 KB
50,257 50,257 차원 1 196.3 KB
4. 임베딩 — 단어를 좌표로
4-1. nn.Embedding 은 표에서 행을 꺼내는 것
d =8emb = nn.Embedding(num_embeddings=V, embedding_dim=d)print('가중치 shape :', tuple(emb.weight.shape), ' = (V, d)')print('파라미터 수 :', sum(p.numel() for p in emb.parameters()), ' = V x d =', V*d)print('편향이 있는가:', any('bias'in n for n, _ in emb.named_parameters()))
가중치 shape : (13, 8) = (V, d)
파라미터 수 : 104 = V x d = 104
편향이 있는가: False
임베딩에는 편향이 없다
\[\text{임베딩 파라미터 수} = V \times d\]
완전연결층과 달리 곱셈이 아니라 “표에서 행을 꺼내는” 연산이기 때문이다.
i = stoi['딥러닝은']print('emb(i) :', emb(torch.tensor(i)).detach().numpy().round(4))print('weight[i] :', emb.weight[i].detach().numpy().round(4))print('같은가 :', torch.allclose(emb(torch.tensor(i)), emb.weight[i]))
vecs = {'고양이': torch.tensor([3., 4.]), '강아지': torch.tensor([4., 3.]),'호랑이': torch.tensor([6., 8.]), '자동차': torch.tensor([4., -3.])}rows = []for w1, w2 in [('고양이','강아지'), ('고양이','호랑이'), ('강아지','호랑이'), ('고양이','자동차'), ('강아지','자동차')]: a, b = vecs[w1], vecs[w2] dot =float(a @ b) na, nb =float(a.norm()), float(b.norm()) rows.append({'쌍': f'{w1} · {w2}', '내적': dot, '|a|': round(na, 2), '|b|': round(nb, 2),'코사인': round(dot/(na*nb), 4)})print(pd.DataFrame(rows).to_string(index=False))
쌍 내적 |a| |b| 코사인
고양이 · 강아지 24.0 5.0 5.0 0.96
고양이 · 호랑이 50.0 5.0 10.0 1.00
강아지 · 호랑이 48.0 5.0 10.0 0.96
고양이 · 자동차 0.0 5.0 5.0 0.00
강아지 · 자동차 7.0 5.0 5.0 0.28
a, b = vecs['고양이'], vecs['강아지']print('직접 계산 :', round(float(a @ b) / (float(a.norm()) *float(b.norm())), 6))print('F.cosine_similarity:', round(float(F.cosine_similarity(a[None], b[None])), 6))
for row_ids, row_mask inzip(enc['input_ids'], enc['attention_mask']): toks = tok.convert_ids_to_tokens(row_ids)print([f'{t}({m})'for t, m inzip(toks, row_mask.tolist())])
# 수업 시간 안에 끝나도록 일부만 사용한다train_df = train_raw.sample(20000, random_state=42)test_df = test_raw.sample(5000, random_state=42)for i inrange(3): r = train_df.iloc[i]print(f'[{r["label"]}] {r["document"][:60]}')
[1] 원본이 최고
[1] 스릴감과 훈훈함이 있는 영화.
[1] 굉장히 저평가되는 영화중 하나라고 생각함
토큰화 2.1초
입력 (20000, 48) 마스크 (20000, 48) 정답 (20000,)
실제 토큰 길이 분포: 20.7 토큰 평균
truncation=True, max_length=48 — 긴 리뷰는 잘라 낸다. 텐서가 직사각형이어야 하고, 길이를 늘리면 계산량이 늘기 때문이다. 무엇을 버리는지 알고 정해야 하는 하이퍼파라미터다.
6-5-3. 모형 — 임베딩 + 마스크 평균 + 분류기
class SentimentNet(nn.Module):def__init__(self, vocab_size, d=64, n_classes=2):super().__init__()self.emb = nn.Embedding(vocab_size, d, padding_idx=tok.pad_token_id)self.fc = nn.Linear(d, n_classes)def forward(self, ids, mask): X =self.emb(ids) # (B, T) → (B, T, d) v = masked_mean(X, mask) # (B, T, d) → (B, d)returnself.fc(v) # (B, d) → (B, 2)torch.manual_seed(42)model = SentimentNet(tok.vocab_size)print(model)print('\n파라미터 :', f'{sum(p.numel() for p in model.parameters()):,}')print('그중 임베딩:', f'{tok.vocab_size *64:,}', ' ← 대부분이 임베딩이다')
SentimentNet(
(emb): Embedding(32000, 64, padding_idx=0)
(fc): Linear(in_features=64, out_features=2, bias=True)
)
파라미터 : 2,048,130
그중 임베딩: 2,048,000 ← 대부분이 임베딩이다
6-5-4. 학습 — 3주차의 그 루프
train_loader = DataLoader(TensorDataset(Xtr, Mtr, ytr), batch_size=128, shuffle=True)optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)criterion = nn.CrossEntropyLoss()def evaluate(): model.eval()with torch.no_grad(): out = model(Xte, Mte)return criterion(out, yte).item(), (out.argmax(1) == yte).float().mean().item()hist = {'loss': [], 'acc': []}t0 = time.time()for ep inrange(6): model.train()for ids, m, y in train_loader: optimizer.zero_grad() loss = criterion(model(ids, m), y) loss.backward() optimizer.step() l, acc = evaluate() hist['loss'].append(l); hist['acc'].append(acc)print(f'epoch {ep} test loss {l:.4f} test acc {acc:.4f}')print(f'\n학습 시간 {time.time()-t0:.0f}초')
epoch 0 test loss 0.6285 test acc 0.6730
epoch 1 test loss 0.5571 test acc 0.7252
epoch 2 test loss 0.5075 test acc 0.7614
epoch 3 test loss 0.4788 test acc 0.7776
epoch 4 test loss 0.4632 test acc 0.7892
epoch 5 test loss 0.4558 test acc 0.7946
학습 시간 5초
def predict(sentences): e = tok(sentences, padding=True, truncation=True, max_length=MAX_LEN, return_tensors='pt') model.eval()with torch.no_grad(): p = model(e['input_ids'], e['attention_mask']).softmax(1)for s_, prob inzip(sentences, p): tag ='긍정'if prob[1] >0.5else'부정'print(f'{tag} ({prob[1]:.3f}) {s_}')predict(['연기도 좋고 스토리도 탄탄하다','시간이 아깝다 최악','기대했는데 그냥 그랬다','눈물이 났다 정말 감동적이었다',])
긍정 (0.802) 연기도 좋고 스토리도 탄탄하다
부정 (0.000) 시간이 아깝다 최악
부정 (0.055) 기대했는데 그냥 그랬다
긍정 (0.982) 눈물이 났다 정말 감동적이었다
이 모형이 못 하는 것
단어 순서를 전혀 보지 않는다. 마스크 평균은 토큰을 그냥 다 더해 나눌 뿐이라 “재미없지 않다”와 “재미있지 않다”를 구별할 방법이 없다.
\[\text{평균은 순서를 지운다}\]
다음 주에 토큰끼리 서로를 보게 만드는 어텐션으로 이 한계를 넘는다.
직접 해보기 ③ — 임베딩 차원을 바꿔 보기
d 를 16, 64, 256 으로 바꿔 학습시키고 파라미터 수와 정확도를 비교하시오.
def train_once(d, epochs=6): torch.manual_seed(42) m = SentimentNet(tok.vocab_size, d=d) ... # ← 위 6-5-4의 루프를 옮겨 오세요returnsum(p.numel() for p in m.parameters()), accfor d in [16, 64, 256]: ...
정답 코드 보기
def train_once(d, epochs=6): torch.manual_seed(42) m = SentimentNet(tok.vocab_size, d=d) opt = torch.optim.Adam(m.parameters(), lr=1e-3)for ep inrange(epochs): m.train()for ids, mk, y in train_loader: opt.zero_grad(); criterion(m(ids, mk), y).backward(); opt.step() m.eval()with torch.no_grad(): acc = (m(Xte, Mte).argmax(1) == yte).float().mean().item()returnsum(p.numel() for p in m.parameters()), accrows = []for d in [16, 64, 256]: n, acc = train_once(d) rows.append({'임베딩 차원 d': d, '파라미터': f'{n:,}', '테스트 정확도': round(acc, 4)})print(pd.DataFrame(rows).to_string(index=False))
임베딩 차원 d 파라미터 테스트 정확도
16 512,034 0.7540
64 2,048,130 0.7946
256 8,192,514 0.8130
sents = ['오늘은 춥다', '내일은 조금 더 따뜻할 것 같다']e = tok(sents, padding=True, return_tensors='pt')em = nn.Embedding(tok.vocab_size, 32, padding_idx=0)print('input_ids :', tuple(e['input_ids'].shape))print('임베딩 후 :', tuple(em(e['input_ids']).shape))print('마스크 합 :', e['attention_mask'].sum(1).tolist())print()print('임베딩 파라미터:', f'{sum(p.numel() for p in em.parameters()):,}',f'= {tok.vocab_size} x 32')print('첫 문장 토큰 :', tok.convert_ids_to_tokens(e['input_ids'][0]))
input_ids : (2, 11)
임베딩 후 : (2, 11, 32)
마스크 합 : [6, 11]
임베딩 파라미터: 1,024,000 = 32000 x 32
첫 문장 토큰 : ['[CLS]', '오늘', '##은', '춥', '##다', '[SEP]', '[PAD]', '[PAD]', '[PAD]', '[PAD]', '[PAD]']