3장. Attention 과 Transformer, GPT 직접 구현
실습 1 — 글자 토크나이저와 임베딩
가상 쇼핑몰의 고객센터 대화 글 cs_dialogs.txt 로 글자 토크나이저를 만들고, 글을 번호로 바꾼 뒤 임베딩과 위치 임베딩을 지나게 해 봅니다. 연습용으로 지어낸 대화 1,500개입니다. 실습 3, 4의 미니 GPT 가 이 글로 배웁니다.
1단계. 글 읽기
# 대화 글 전체를 문자열 하나로 읽는다
with open("cs_dialogs.txt", encoding="utf-8") as f:
text = f.read()
print("글자 수:", len(text))
print(text[:120]) # 앞부분만 본다글자 수: 80312
고객: 후드티 사이즈가 안 맞아요.
상담원: 교환 신청을 해 주시면 새 사이즈로 다시 보내 드립니다.
고객: 티셔츠 쿠폰을 쓸 수 있나요?
상담원: 티셔츠는 할인 쿠폰을 함께 쓸 수 있습니다.
고객: 그릇 세트가 망이 단계의 함수입니다.
| 코드 | 쉬운 뜻 |
|---|---|
open("cs_dialogs.txt", encoding="utf-8") | 한글이 든 글 파일을 UTF-8 방식으로 연다. Windows 에서는 이 인자를 빼면 글자가 깨질 수 있다. |
f.read() | 파일 전체를 문자열 하나로 읽는다. 대화 1,500개가 줄바꿈과 함께 한 덩어리가 된다. |
len(text) | 문자열의 글자 수를 센다. 빈칸과 줄바꿈도 한 글자로 센다. |
text[:120] | 앞의 120글자만 잘라 본다. 글이 어떻게 생겼는지 훑어보는 용도다. |
2단계. 어휘와 토크나이저 만들기
# 글에 나오는 글자를 모아 정렬한 것이 어휘다
chars = sorted(set(text))
print("어휘 크기:", len(chars))
print(chars[:30]) # 앞의 30글자만 본다(맨 앞의 '\n' 은 줄바꿈, ' ' 는 빈칸)
stoi = {c: i for i, c in enumerate(chars)} # 글자 → 번호
itos = {i: c for c, i in stoi.items()} # 번호 → 글자
def encode(s):
"""글을 번호 목록으로 바꾼다."""
return [stoi[c] for c in s]
def decode(ids):
"""번호 목록을 글로 되돌린다."""
return "".join(itos[i] for i in ids)
ids = encode("고객: 이불 배송이 언제 오나요?")
print(ids)
print(decode(ids))어휘 크기: 124
['\n', ' ', '.', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', '?', '가', '개', '객', '고', '교', '그', '금', '기', '께', '꿔', '나', '날', '남', '내', '는']
[18, 17, 13, 1, 87, 58, 1, 54, 67, 87, 1, 77, 93, 1, 79, 25, 81, 14]
고객: 이불 배송이 언제 오나요?이 단계의 함수입니다.
| 코드 | 쉬운 뜻 |
|---|---|
set(text) | 글에 나오는 글자를 겹치지 않게 모은다. 같은 글자가 여러 번 나와도 하나만 남는다. |
sorted(...) | 모은 글자를 차례대로 줄 세워 리스트로 만든다. 이 리스트가 어휘이고, 정렬해 두면 실행할 때마다 번호가 같다. |
stoi | 글자를 열쇠로, 번호를 값으로 둔 사전이다. string to integer 의 줄임말이다. |
itos | 번호를 열쇠로, 글자를 값으로 둔 사전이다. stoi 를 거꾸로 뒤집은 것이다. |
encode(s) | 글을 글자마다 stoi 에서 찾아 번호 목록으로 바꾼다. 토크나이저의 "자르고 번호 붙이기"다. |
decode(ids) | 번호마다 itos 에서 글자를 찾아 이어 붙인다. encode 의 반대 방향이다. |
두 사전과 두 함수를 합친 것이 글자 토크나이저입니다. 따로 라이브러리를 쓰지 않아도 "글자 → 번호 → 글자"가 되면 토크나이저입니다.
3단계. 글 전체를 텐서로 바꾸고 나누기
import torch
# 글 전체를 번호 텐서 하나로 바꾼다. 글자 하나가 번호 하나다
data = torch.tensor(encode(text))
print(data.shape, data.dtype)
# 앞 90% 를 학습용, 뒤 10% 를 검증용으로. 글은 순서가 있어서 섞지 않고 자른다
n = int(len(data) * 0.9)
train_data, val_data = data[:n], data[n:]
print("학습용:", len(train_data), "검증용:", len(val_data))torch.Size([80312]) torch.int64
학습용: 72280 검증용: 8032이 단계의 함수입니다.
| 코드 | 쉬운 뜻 |
|---|---|
torch.tensor(encode(text)) | 번호 목록을 PyTorch 텐서로 바꾼다. 정수 목록을 넣으면 정수 텐서가 된다. |
data.shape | 텐서의 모양이다. 출력 torch.Size([80312]) 는 번호가 한 줄로 80,312개 있다는 뜻이다. |
data.dtype | 텐서 안 값의 자료형이다. torch.int64 는 64비트 정수이고 torch.long 과 같은 것이다. |
int(len(data) * 0.9) | 앞에서 몇 번째까지를 학습용으로 쓸지 정한다. 소수를 버려 정수 위치로 만든다. |
data[:n], data[n:] | 텐서를 리스트처럼 잘라 앞부분과 뒷부분으로 나눈다. 섞지 않으므로 글의 순서가 그대로 남는다. |
임베딩 층은 번호를 표의 줄 번호로 쓰기 때문에 정수 텐서(torch.long)만 받습니다. 그래서 글을 실수가 아닌 정수 텐서로 바꿔 둡니다.
4단계. 임베딩과 위치 임베딩 지나기
from torch import nn
torch.manual_seed(42)
V, EMB, BLOCK = len(chars), 64, 64 # 어휘 크기, 좌표 수, 한 번에 보는 글자 수
tok_emb = nn.Embedding(V, EMB) # 글자마다 좌표 64개
pos_emb = nn.Embedding(BLOCK, EMB) # 자리(0~63번째)마다 좌표 64개
chunk = data[:BLOCK].view(1, -1) # 앞의 64글자를 (배치 1, 64글자) 모양으로
x = tok_emb(chunk) + pos_emb(torch.arange(BLOCK)) # 글자의 뜻 + 자리 정보
print("입력:", chunk.shape, "→ 임베딩:", x.shape)
print("임베딩 표 둘의 파라미터 수:", tok_emb.weight.numel() + pos_emb.weight.numel())입력: torch.Size([1, 64]) → 임베딩: torch.Size([1, 64, 64])
임베딩 표 둘의 파라미터 수: 12032이 단계의 함수입니다.
| 코드 | 쉬운 뜻 |
|---|---|
from torch import nn | 신경망 층을 모아 둔 torch.nn 을 nn 이라는 짧은 이름으로 불러온다. |
torch.manual_seed(42) | 2장에서 본 씨앗 고정이다. 임베딩 표의 처음 난수가 실행마다 같아진다. |
nn.Embedding(V, EMB) | 줄이 V 개, 칸이 EMB 개인 표를 만든다. 번호를 넣으면 그 번호의 줄(좌표 64개)을 꺼내 준다. 표의 값은 학습으로 바뀌는 가중치다. |
nn.Embedding(BLOCK, EMB) | 같은 모양의 표를 자리 번호용으로 하나 더 만든다. 0번째부터 63번째 자리마다 좌표 64개가 있다. |
data[:BLOCK].view(1, -1) | 앞의 64글자를 꺼내 모양을 (1, 64)로 바꾼다. -1 은 "나머지는 알아서 맞춰라"는 뜻이다. 앞의 1은 묶음(배치) 하나라는 뜻이다. |
torch.arange(BLOCK) | 0부터 63까지의 정수 텐서를 만든다. 자리 번호로 쓴다. |
tok_emb(chunk) + pos_emb(...) | 글자의 좌표와 자리의 좌표를 칸마다 더한다. 모양 (1, 64, 64)와 (64, 64)가 브로드캐스팅으로 맞춰진다. |
.weight.numel() | .weight 는 임베딩 표 자체이고, numel() 은 그 안의 값 개수다. 124 × 64 + 64 × 64 = 12,032개다. |
임베딩 결과의 모양 (1, 64, 64)는 (배치, 글자 수, 좌표 수)로 읽습니다. 앞의 64는 글자 수, 뒤의 64는 좌표 수라서 숫자가 같아도 뜻이 다릅니다.
이 실습이 뜻하는 것
이 실습은 고객센터 대화 글을 모델이 받을 수 있는 숫자로 바꾸는 준비 과정입니다. 한글을 못 읽는 외국인 친구에게 책을 보낸다고 생각해 봅니다. 책을 펼쳐 보고(1단계), 나오는 글자마다 번호표를 만들고(2단계), 책 전체를 번호로 옮겨 적어 공부용과 시험용으로 나눈 뒤(3단계), 번호마다 "글자의 성격 카드"와 "몇 번째 자리인지 카드"를 붙입니다(4단계). 여기서 번호표가 토크나이저, 성격 카드가 임베딩, 자리 카드가 위치 임베딩입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 글 읽기 | 글자 수 80312 | 대화 1,500개가 빈칸과 줄바꿈까지 합쳐 80,312글자인 문자열 하나가 되었습니다 |
| 2. 어휘 만들기 | 어휘 크기 124 | 글 전체에 나오는 서로 다른 글자는 124가지뿐입니다. 같은 문장 틀로 지어낸 대화라 글자 종류가 적습니다 |
| 2. encode | [18, 17, 13, 1, 87, …] | "고"는 18번, "객"은 17번, ":"는 13번, 빈칸은 1번입니다. "이불"과 "배송이"의 "이"는 둘 다 87번입니다. 18글자가 번호 18개가 됩니다 |
| 2. decode | 고객: 이불 배송이 언제 오나요? | 번호를 되돌리면 원래 문장이 그대로 나옵니다. 토크나이저가 바르게 만들어졌다는 확인입니다 |
| 3. 텐서로 | torch.Size([80312]) torch.int64 | 80,312글자가 정수 80,312개짜리 한 줄이 되었습니다. 임베딩 표는 번호를 줄 번호로 쓰므로 정수여야 합니다 |
| 3. 나누기 | 학습용 72280, 검증용 8032 | 앞 90%로 배우고 뒤 10%로 시험합니다. 글은 순서에 뜻이 있어서 섞지 않고 앞뒤로 잘랐습니다 |
| 4. 임베딩 | [1, 64] → [1, 64, 64] | 64글자 묶음 하나가 "글자마다 숫자 64개"로 바뀌었습니다. 앞의 64는 글자 수, 뒤의 64는 좌표 수입니다 |
| 4. 파라미터 수 | 12032 | 글자 표 124 × 64 = 7,936개에 자리 표 64 × 64 = 4,096개를 더한 값입니다. 모두 학습으로 바뀔 숫자입니다 |
한 줄로 줄이면, 글(80,312글자)을 번호(124가지)로, 다시 좌표(글자마다 64개)로 바꾸는 실습입니다. 마지막의 (1, 64, 64) 모양 숫자 덩어리가 실습 3의 Transformer 블록에 들어가는 입력입니다. 지금의 임베딩 값은 난수라서 아직 뜻이 없고, 비슷하게 쓰이는 글자의 좌표가 가까워지는 것은 실습 4에서 학습한 뒤입니다.
코드 설명
- 글 전체는 80,312글자이고, 서로 다른 글자는 124가지입니다. 대화 1,500개가 같은 문장 틀로 지어져 있어 글자 종류가 적습니다. 어휘의 맨 앞은 줄바꿈(
'\n')과 빈칸, 문장부호와 숫자이고, 그 뒤로 한글이 가나다 순으로 이어집니다. encode와decode는 서로 반대 방향입니다. 번호로 바꿨다가 되돌리면 원래 글이 그대로 나와야 토크나이저가 바르게 만들어진 것입니다.- 1장, 2장의 데이터는 섞어서 나눴지만 글은 순서가 뜻을 가지므로 앞뒤로 잘라 나눴습니다. 검증용은 학습 중에 처음 보는 글에서의 손실을 재는 데 씁니다.
pos_emb(torch.arange(BLOCK))의 모양은 (64, 64)이고tok_emb(chunk)는 (1, 64, 64)입니다. 모양이 달라도 더할 수 있는 것은 PyTorch 가 앞의 배치 축을 알아서 늘려 맞추기 때문입니다(브로드캐스팅).