개요
지난 Python - < 9 >에서 글자를 숫자로 바꾸는 데까지 왔다.
토큰화로 문장을 조각내고, 사전에서 번호를 찾고, 임베딩으로 벡터를 만드는 세 단계였다.
그런데 거기서 글이 끝났다. 숫자로 바꿔놓고 정작 그 숫자를 모델에 넣어본 적이 없다..
이번 글은 그 다음이다. 사전을 직접 학습시켜 만들고, 그 벡터를 RNN에 흘려보내고, Encoder-Decoder를 붙이고, Attention을 손으로 짠다. 그리고 방향을 한 번 뒤집는다 — 모델을 써서 데이터를 만들어내는 쪽으로.
이번 글이 답할 질문 글자를 숫자로 바꾼 다음엔 뭘 하나? 그리고 학습시킬 데이터 자체가 없으면 어떻게 하나?
실습 자료는 SSAFY 교육 컨텐츠라 원본을 그대로 싣지 않는다. 개념만 가져와 코드와 예제는 전부 새로 짰다. 실습에서 쓴 영화 리뷰 말뭉치 대신 게임 채팅 로그를, 영화 추천 대신 몬스터 도감을 예로 쓴다.
1. 토크나이저는 가져오는 게 아니라 만든다 — 사전을 직접 학습시킨다
< 9 >에서는 이미 만들어진 토크나이저를 불러다 썼다. 이번엔 빈 토크나이저에 말뭉치를 먹여 사전을 만든다.
말뭉치는 그냥 텍스트 파일 하나면 된다. 한 줄에 문장 하나씩.
from tokenizers import BertWordPieceTokenizer
# 아직 아무것도 모르는 빈 토크나이저 — vocab이 0개다
tokenizer = BertWordPieceTokenizer(
lowercase=False, # 영어 대소문자를 살린다
strip_accents=False, # 한국어엔 café 같은 악센트가 없다
)
tokenizer.train(
files="guild_chat.txt",
vocab_size=30000, # 사전을 몇 칸까지 만들 것인가
min_frequency=2, # 딱 한 번 나온 말은 안 담는다
limit_alphabet=6000, # 학습 전에 미리 넣어둘 글자 수 상한
special_tokens=["[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"],
wordpieces_prefix="##",
)
파라미터가 다섯 개인데 의미가 겹치지 않는다.
| 파라미터 | 무엇을 정하나 | 크게 잡으면 | 작게 잡으면 |
|---|---|---|---|
vocab_size |
사전 칸 수 | 단어를 통째로 담아 조각이 덜 난다 | 잘게 쪼개져 문장이 길어진다 |
min_frequency |
사전에 넣을 최소 등장 횟수 | 오타·고유명사가 걸러진다 | 한 번 나온 말까지 다 담긴다 |
limit_alphabet |
시작 글자 수 상한 | 희귀 한자·기호까지 산다 | 모르는 글자가 UNK가 된다 |
특수 토큰 다섯 개도 역할이 다 다르다. [PAD]는 길이를 맞추려고 채우는 빈칸, [UNK]는 사전에 없는 말이 왔을 때 대신 들어가는 자리, [CLS]는 문장 맨 앞에 붙는 표시, [SEP]는 문장 끝·문장 사이 구분, [MASK]는 빈칸 맞추기 학습용이다.
학습이 끝나면 바로 써볼 수 있다.
encoded = tokenizer.encode("길드원 모집합니다!")
print(encoded.tokens) # ['[CLS]', '길드', '##원', '모집', '##합니다', '!', '[SEP]']
print(encoded.ids) # [2, 812, 45, 1190, 233, 5, 3]
##이 붙은 건 앞 조각에 이어붙는 조각이라는 표시다. 길드 + ##원 이 붙어서 “길드원”이 된다.
사전에 “길드원”이 통째로 없어도 두 조각으로 표현이 되니까 처음 보는 단어가 통째로 버려지지 않는다.
만들어진 번호를 벡터로 바꾸는 건 < 9 >에서 본 그대로다.
import torch
import torch.nn as nn
embedding = nn.Embedding(num_embeddings=30000, embedding_dim=768)
token_id = tokenizer.token_to_id("길드")
vector = embedding(torch.tensor([token_id]))
print(vector.shape) # torch.Size([1, 768])
토크나이저는 모델의 부속이 아니라 말뭉치가 결정하는 것이다. 게임 채팅으로 학습시킨 사전은 논문으로 학습시킨 사전과 다른 조각을 낸다.
2. RNN — 상태 하나를 계속 물려준다
이제 저 벡터를 모델에 넣는다. 첫 번째로 볼 건 RNN이다.
RNN은 문장을 앞에서부터 한 토큰씩 읽는다. 읽으면서 지금까지 읽은 내용을 숫자 뭉치 하나에 눌러 담고, 그걸 다음 토큰을 읽을 때 같이 넣는다. 이 눌러 담긴 숫자 뭉치를 은닉 상태(hidden state) 라고 부른다.
rnn = nn.RNN(
input_size=768, # 들어오는 임베딩 차원
hidden_size=1024, # 눌러 담을 숫자 뭉치의 크기
num_layers=1,
bidirectional=False,
)
hidden_states, h_n = rnn(input_embeds)
여기서 제일 헷갈렸다. 출력이 두 개인데 이름도 비슷하고 차원도 비슷해서 뭐가 뭔지 계속 뒤집혔다..
| 이름 | 차원 | 무엇인가 |
|---|---|---|
hidden_states |
(L, 1024) |
토큰마다 하나씩, 전부 |
h_n |
(D * num_layers, 1024) |
다 읽고 난 마지막 것 |
L은 토큰 개수다. 토큰이 5개면 hidden_states는 5줄, h_n은 1줄이다.
그래서 이 둘은 이런 관계가 된다.
# 마지막 한 줄만 떼어내면 h_n 과 같다
torch.equal(hidden_states[-1].unsqueeze(0), h_n) # True
unsqueeze(0)이 붙는 이유가 처음엔 안 보였다. hidden_states[-1]은 (1024,) 인 1차원인데 h_n은 (1, 1024) 인 2차원이라 모양이 안 맞아서 비교가 안 된다. 앞에 축을 하나 끼워 넣어서 맞춰준 것이다.
차원이 헷갈릴 때 규칙 하나만 잡아두면 편했다.
L이 들어간 자리는 시퀀스 방향,num_layers가 들어간 자리는 깊이 방향이다. 둘 다 앞자리에 오니까, 지금 보는 게 시간인지 층인지부터 확인하면 된다.
더 쉬운 비유로
은닉 상태 — 지금까지 읽은 문장 전체를 숫자 1024개로 눌러 담은 것 hidden_states — 한 토큰 읽을 때마다 찍어둔 스냅샷 전부 h_n — 다 읽고 났을 때의 마지막 스냅샷 하나
예전에 TCP 멀티플레이 만들 때 protobuf로 패킷 직렬화를 했었다. 플레이어 좌표·체력·상태를 바이트 배열에 순서대로 눌러 담아 보내고, 받는 쪽에서 같은 순서로 풀어내는 방식이었다. 여러 값을 하나로 눌러 담는다는 점에서 은닉 상태와 하는 일이 똑같다.
다른 점이 하나 있는데, 이게 중요하다. 직렬화는 되돌릴 수 있다. 눌러 담은 바이트를 풀면 원래 좌표와 체력이 그대로 나온다. 은닉 상태는 되돌릴 수 없다. 문장 열 개를 숫자 1024개에 욱여넣었으니 뭔가는 반드시 뭉개진다. 그것도 앞쪽부터 뭉개진다. 새로 읽은 토큰이 계속 덮어쓰니까 처음에 읽은 말이 제일 먼저 흐려진다.
LSTM — 통로를 하나 더 단다
이걸 손보려고 나온 게 LSTM이다. RNN에 장기 기억용 통로(cell state)를 하나 더 달아서, 급하게 안 덮어써도 되는 정보를 따로 들고 간다.
lstm = nn.LSTM(input_size=768, hidden_size=1024, num_layers=1)
hidden_states, (h_n, c_n) = lstm(input_embeds) # 반환값이 셋으로 늘었다!
바뀌는 건 반환값 하나 늘어나는 것뿐이다. 나머지 코드는 손댈 게 없다.
3. Encoder-Decoder — 눌러 담았다가 다시 푼다
은닉 상태를 얻었으면 그걸로 뭘 하나. 번역을 해본다.
구조는 두 덩어리다.
-
Encoder — 한국어 문장을 읽어서
h_n하나로 눌러 담는다. 이때의h_n을 문맥 벡터(context vector) 라고 부른다 - Decoder — 그 문맥 벡터를 받아서 영어 토큰을 하나씩 뱉는다
Encoder는 앞에서 본 게 전부다. 임베딩하고 RNN 통과시키면 끝이다.
class RNNEncoder(nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_size):
super().__init__()
self.word_embeddings = nn.Embedding(vocab_size, embedding_dim)
self.rnn = nn.RNN(input_size=embedding_dim, hidden_size=hidden_size)
def forward(self, input_ids):
input_embeds = self.word_embeddings(input_ids)
hidden_states, h_n = self.rnn(input_embeds)
return hidden_states, h_n
Decoder — 몇 개를 뱉을지 모른다
문제는 Decoder다. 여기가 두 번째로 막힌 곳이었다.
Encoder는 입력 길이를 이미 아니까 한 번에 돌리면 되는데, Decoder는 몇 개를 뱉어야 하는지 모른다. 그래서 루프를 직접 돌려야 한다.
def forward(self, init_hidden_state, max_len=10):
logits = []
input_token = self.input_token # [CLS] 로 시작
h_n = init_hidden_state # encoder가 넘겨준 문맥 벡터
for _ in range(max_len):
embedded = self.word_embeddings(input_token)
_, h_n = self.rnn(embedded, h_n)
logit = self.fully_connected_layer(h_n).squeeze() # (vocab_size,)
output_token = logit.argmax(dim=-1) # 제일 점수 높은 번호
logits.append(logit)
if output_token.item() == self.end_token_id: # [SEP] 나오면 끝
break
input_token = output_token.unsqueeze(0) # 방금 뱉은 걸 다음 입력으로
루프를 코드로 보면 별거 아닌데, 말로 풀면 이렇다.
- 시작 신호(
[CLS])를 하나 넣는다 - 은닉 상태를 갱신하고,
nn.Linear로 사전 3만 칸에 대한 점수를 뽑는다 - 점수가 제일 높은 칸을 고른다 — 이게 이번에 뱉을 토큰이다
- 방금 뱉은 토큰을 다음 입력으로 되먹인다
- 끝 신호(
[SEP])가 나오면 멈추고, 아니면max_len까지 반복
4번이 핵심이었다. 정답을 미리 넣어주는 게 아니라 자기가 뱉은 걸 자기가 다시 먹는다. 그래서 앞에서 한 번 틀리면 뒤가 전부 어긋난다.
Encoder와 Decoder를 붙이면 Seq2Seq이 된다.
class RNNSeq2Seq(nn.Module):
def forward(self, input_ids):
_, context_vector = self.encoder(input_ids)
return self.decoder(context_vector)
학습 안 한 모델이 뱉는 것
그런데 돌려보면 출력이 완전한 쓰레기로 나온다. 처음엔 내가 뭘 잘못 짰나 싶었다.
아니었다. 학습을 안 시켰으니 당연한 것이다. nn.Embedding도 nn.RNN도 nn.Linear도 전부 난수로 초기화된 상태다. 난수를 통과시켰으니 난수가 나온다. 이 단계에서 확인할 건 결과물이 아니라 차원이 안 터지고 끝까지 흐르는가다.
LSTM으로 바꿔도 구조는 그대로다. c_n을 같이 넘기는 것만 늘어난다.
| 항목 | RNN 버전 | LSTM 버전 |
|---|---|---|
| Encoder 반환 | hidden_states, h_n |
hidden_states, (h_n, c_n) |
| Decoder에 넘기는 것 | h_n |
h_n, c_n
|
| 루프 안에서 갱신 | h_n |
h_n, c_n
|
| 나머지 코드 | — | 손댈 게 없다 |
구조를 짜는 것과 성능을 내는 것은 완전히 다른 일이다. 여기서 확인할 건 텐서가 끝까지 흐르느냐지 번역이 되느냐가 아니다.
4. Attention — 마지막 한 칸만 보지 않는다
Seq2Seq에는 티가 확 나는 약점이 있다. Decoder가 받는 건 h_n 하나뿐이라는 것.
문장이 서른 토큰이든 세 토큰이든 똑같이 숫자 1024개로 눌러서 넘긴다. 길어질수록 앞쪽 내용이 뭉개진다는 얘기를 위에서 했는데, 그게 여기서 그대로 터진다.
Attention은 발상을 뒤집는다. h_n 하나만 주지 말고 hidden_states 전부를 주자. 대신 매 스텝마다 “지금은 그중 어디를 봐야 하나”를 계산해서 골라 본다.
Luong Attention은 네 단계다. 세 번째로 막혔던 곳이 여기고, .T를 어디에 붙이는지가 계속 헷갈렸다..
지금 디코더가 들고 있는 상태를 $h_t$, 인코더가 남긴 전체 은닉 상태를 $E$ 라고 하면 이렇게 된다.
$$ a_t = \mathrm{softmax} \left( E (W_a h_t)^\top \right) $$
$$ c_t = a_t^\top E $$
수식만 보면 안 와닿아서 차원을 같이 적어놓고 봤더니 그제야 풀렸다.
| 단계 | 코드 | 나오는 차원 | 뜻 |
|---|---|---|---|
| ① 투영 | Wa_ht = self.W_a(h_t) |
(1, 1024) |
질문을 하나 만든다 |
| ② 내적 | encoder_outputs @ Wa_ht.T |
(L, 1) |
칸마다 점수가 하나씩 |
| ③ softmax | F.softmax(score, dim=-1) |
(L, 1) |
점수를 비중으로, 합이 1 |
| ④ 가중합 | weights.T @ encoder_outputs |
(1, 1024) |
볼 것만 모아 담는다 |
class LuongAttention(nn.Module):
def __init__(self, hidden_size):
super().__init__()
self.W_a = nn.Linear(hidden_size, hidden_size, bias=False)
def forward(self, h_t, encoder_outputs):
Wa_ht = self.W_a(h_t) # (1, H)
attention_score = encoder_outputs @ Wa_ht.T # (L, H) @ (H, 1) -> (L, 1)
attention_weights = F.softmax(attention_score, dim=-1)
context_vector = attention_weights.T @ encoder_outputs # (1, L) @ (L, H) -> (1, H)
return context_vector, attention_weights
.T가 붙는 자리는 곱셈이 성립하려면 붙을 수밖에 없는 자리였다. 앞 행렬의 뒷자리와 뒤 행렬의 앞자리가 같아야 곱이 되니까, 안 맞으면 뒤집는 것 말고 방법이 없다. 외울 게 아니라 차원을 적어보면 답이 나온다.
여기서 나온 context_vector를 원래 h_t와 이어붙여서 다음 토큰을 예측한다. 매 스텝마다 문맥을 새로 만들어 쓰는 것이라 문장이 길어져도 앞쪽이 안 죽는다.
Attention은
h_n하나로 눌러 담던 걸 매번 다시 만들어 쓰는 것으로 바꾼 것이다. 압축을 잘하는 대신 압축을 덜 하는 쪽을 골랐다.
5. 라이브러리로 넘어가면 — 직접 짠 것과의 거리
여기까지 손으로 짠 걸 라이브러리로 하면 네 줄이다.
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
model_name = "Helsinki-NLP/opus-mt-ko-en"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
그리고 이번엔 말이 되는 문장이 나온다. 구조가 더 좋아서가 아니라 학습이 되어 있어서다. 3장에서 쓰레기가 나온 이유가 여기서 반대로 증명된다.
print(model)을 찍어보면 안에 encoder와 decoder가 그대로 들어 있는 게 보인다. 내가 짠 것과 이름까지 같다. 손으로 짜본 게 헛수고가 아니었다는 걸 여기서 알았다!
요즘 모델은 이 구조를 셋 중 하나로 쓴다.
| 구조 | 대표 모델 | 하는 일 | 요즘 쓰임 |
|---|---|---|---|
| Encoder only | BERT | 문장을 읽고 이해만 한다 | 검색·분류, RAG의 문서 임베딩 |
| Decoder only | GPT | 다음 토큰을 계속 이어 쓴다 | 대화·번역·생성, 지금 대부분 |
| Encoder-Decoder | T5, opus-mt | 읽고 나서 새로 쓴다 | 번역 전용 외엔 잘 안 씀 |
Encoder only 쪽은 빈칸 맞추기로 성격이 드러난다.
from transformers import AutoTokenizer, AutoModelForMaskedLM
# 한국어를 맞추려면 한국어로 학습된 BERT를 써야 한다
model_name = "klue/bert-base"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForMaskedLM.from_pretrained(model_name)
sentence = f"나는 길드전에 {tokenizer.mask_token} 했다."
outputs = model(**tokenizer(sentence, return_tensors="pt"))
여기서 한 번 걸렸다. bert-base-cased처럼 영어로 학습된 모델에 한국어를 넣으면 사전에 없는 글자라 죄다 [UNK]가 되고 엉뚱한 답이 나온다. 1장에서 “말뭉치가 사전을 결정한다”고 한 게 그대로 돌아온다.
[MASK] 자리에 뭐가 들어갈지 점수를 매기는 게 전부다. 뒷말을 이어 쓰지 못한다. 앞뒤를 다 보고 가운데를 맞추는 데 특화돼서, 문서를 벡터로 바꿔 검색하는 쪽에 쓴다.
Decoder only는 반대다. 앞만 보고 뒤를 계속 이어 쓴다. ChatGPT 계열이 전부 이쪽이다.
구조가 용도를 정한다. 가운데를 맞추게 학습한 모델에게 뒷말을 이어 쓰라고 하면 못 한다.
6. 데이터가 없으면 만든다 — 합성 데이터와 LLM as Judge
여기서 방향이 뒤집힌다. 지금까지는 데이터로 모델을 만들었는데, 이번엔 모델로 데이터를 만든다.
먼저 API 키부터. 키를 코드에 직접 적으면 GitHub에 그대로 올라간다.
import os
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv(".env") # .env 는 반드시 .gitignore 에 넣는다
# GMS(Gen AI Management System)는 SSAFY 내부의 AI 통합 시스템 이름이다.
client = OpenAI(api_key=os.getenv("GMS_KEY"), base_url="...")
base_url만 바꾸면 같은 코드로 다른 회사 모델을 부를 수 있다. 인터페이스가 표준화돼 있어서 코드는 그대로 두고 주소만 갈아끼우면 된다.
프롬프팅 세 가지
같은 요청을 세 가지 방식으로 던지면 답이 다르게 나온다.
| 기법 | 방식 | 장점 | 단점 |
|---|---|---|---|
| Zero-shot | 예시 없이 그냥 시킨다 | 짧고 싸다 | 형식이 매번 다르다 |
| Few-shot | 예시 두세 개를 먼저 보여준다 | 형식이 고정된다 | 입력이 길어져 비싸진다 |
| Chain-of-Thought | 단계를 나눠 생각하게 시킨다 | 논리가 촘촘해진다 | 답이 길고 느려진다 |
Few-shot은 이런 식이다. 답을 알려주는 게 아니라 답의 생김새를 보여주는 것이다.
few_shot_prompt = f"""다음은 몬스터 도감 작성 예시다.
이름: 늪지 두꺼비
등급: 하급
설명: 습지대에서 무리 지어 다니며, 혀로 원거리 공격을 한다.
이름: 서리 늑대
등급: 중급
설명: 눈보라 속에서 이동 속도가 오르고, 울음소리로 동료를 부른다.
이름: {name}
등급:"""
구조화된 출력
그런데 위 결과는 그냥 글이라 코드에서 쓰기 불편하다. JSON으로 뱉으라고 시켜놓고 잘라내는 방식을 쓴다.
def json_parsing(output_text: str) -> dict:
try:
if "```json" in output_text:
head = output_text.index("```json") + len("```json")
output_text = output_text[head:].strip()
output_text = output_text[:output_text.index("```")].strip()
return json.loads(output_text)
except (json.JSONDecodeError, ValueError) as e:
print(f"JSON 파싱 오류: {e}")
return None
솔직히 이 방식이 좀 허술해 보였다. 모델이 코드블록을 안 붙이거나 앞에 설명을 달면 그냥 깨진다. 그래서 None을 리턴하고 넘어가는 처리가 반드시 필요하다. 실패를 전제로 짜야 하는 코드다.
채점까지 모델에게 맡긴다
데이터를 100개 만들면 그중엔 엉망인 것도 섞인다. 사람이 하나씩 보기엔 많다. 그래서 다른 모델에게 채점을 시킨다. 이걸 LLM as Judge라고 부른다.
지켜야 할 게 세 개 있는데, 세 개 다 이유가 분명하다.
- 채점자는 생성자와 다른 모델로 — 같은 모델이면 자기 답에 후한 점수를 준다
- 점수만 받지 말고 이유도 같이 — 근거를 쓰게 하면 점수가 더 정확해진다
-
채점할 땐
temperature=0— 같은 답을 두 번 채점했는데 점수가 다르면 채점이 아니다
temperature가 생성과 채점에서 정반대로 쓰인다는 게 눈에 띄었다. 만들 땐 다양해야 하니 높이고, 채점할 땐 일관돼야 하니 0으로 내린다.
criteria_list = [
"요청 충실도: 요청한 등급에 맞는 몬스터인지, 필수 항목이 다 있는지",
"설명 구체성: 설명이 구체적이고 게임에 바로 쓸 만한지",
"정보 일관성: 등급과 설명의 강함 정도가 서로 안 어긋나는지",
]
for data in synthetic_data:
scores = [evaluate_with_llm(data, c)["score"] for c in criteria_list]
avg = sum(scores) / len(scores)
기준을 하나로 뭉뚱그리지 않고 세 개로 쪼개서 따로 받는 게 포인트였다. 뭉뚱그리면 “대체로 좋음 4점” 같은 게 나오는데, 쪼개면 어느 항목이 무너졌는지가 보인다.
데이터가 없으면 만들면 된다 — 대신 만든 걸 검증하는 단계까지가 한 세트다. 검증 없이 만든 합성 데이터는 그냥 노이즈다.
정리
-
토크나이저는 학습해서 만든다.
vocab_size·min_frequency·limit_alphabet이 사전의 성격을 정하고, 말뭉치가 바뀌면 나오는 조각도 바뀐다 -
RNN 출력 두 개:
hidden_states는 토큰마다 전부(L, H),h_n은 마지막 하나(D * num_layers, H). 문맥 벡터로 넘기는 건h_n이다 -
은닉 상태는 손실 압축이다. 직렬화와 달리 되돌릴 수 없고, 문장이 길어지면 앞쪽부터 뭉개진다. LSTM은
cell state를 하나 더 달아 이걸 늦춘다 -
Decoder는 자기가 뱉은 걸 자기가 먹는다.
max_len까지 루프를 돌고[SEP]이 나오면 멈춘다. 앞에서 틀리면 뒤가 전부 어긋난다 - 학습 안 한 모델이 이상한 출력을 내는 건 정상이다. 구조 단계에서 볼 건 성능이 아니라 차원이 끝까지 흐르는가다
-
Attention은
h_n하나 대신hidden_states전부를 쓴다. 투영 → 내적 → softmax → 가중합 네 단계고,.T위치는 외우는 게 아니라 차원을 적어보면 정해진다 - 구조가 용도를 정한다. Encoder only는 읽기, Decoder only는 이어 쓰기, Encoder-Decoder는 번역 전용으로 밀려났다
-
합성 데이터는 생성과 검증이 한 세트다. 생성은
temperature를 올려 다양하게, 채점은 다른 모델로temperature=0에서 기준을 쪼개서
참고 자료
- PyTorch nn.RNN 문서
- PyTorch nn.LSTM 문서
- HuggingFace Tokenizers
- Effective Approaches to Attention-based Neural Machine Translation (Luong, 2015)
한줄 평
- 데이터가 없으면 모델로 만들면 된다는 게 이번 편에서 제일 크게 남았다 -> 대신 만든 걸 채점하는 단계까지 붙여야 데이터지, 안 붙이면 그냥 쓰레기다!