개요
지난 글까지 PyTorch로 모델을 만들고 학습시키는 데까지 왔다. 그런데 지금까지 다룬 데이터는 전부 이미 숫자였다.
텍스트는 다르다. 모델에 넣기 전에 글자를 숫자로 바꾸는 단계가 하나 더 필요하다.
"오늘 학교에서 공부했다"
↓ 토큰화 — 조각으로 쪼갠다
["오늘", "학교", "에서", "공부", "했다"]
↓ 사전 조회 — 번호를 붙인다
[1023, 887, 45, 2311, 908]
↓ 임베딩 — 벡터로 바꾼다
[[0.2, -1.4, ...], [0.5, 0.3, ...], ...]
↓
모델 입력
이번 글은 이 세 단계를 코드로 따라간다. 개념은 SSAFY 시리즈에서 다뤘으니 여기서는 실제로 어떻게 돌리는지에 집중한다.
1. 토큰화 — 어떤 크기로 쪼갤 것인가
토큰화는 문장을 조각으로 나누는 작업이다. 문제는 어떤 크기로 나눌 것인가다.
방법 1 — 단어 단위
text = "I'm a student!"
print(text.split()) # ["I'm", 'a', 'student!']
간단해 보이는데 문제가 바로 보인다. student!에 느낌표가 붙어 있고, I'm은 하나로 묶여 있다. 정규식으로 더 잘게 쪼갤 수는 있지만 근본적인 문제 두 개가 남는다.
- 사전이 끝없이 커진다 — “학교”, “학교에”, “학교에서”, “학교를”이 전부 다른 단어가 된다
- 처음 보는 단어를 전혀 모른다 — 사전에 없으면 통째로 “모르는 단어”가 된다
특히 한국어는 조사가 붙어서 같은 단어의 변형이 수십 가지다. 단어 단위로는 감당이 안 된다.
방법 2 — 글자 단위
print(list("학교에서")) # ['학', '교', '에', '서']
사전이 아주 작아진다. 한글 음절은 몇천 개면 끝이다. 모르는 단어도 없다.
대신 의미 단위가 부서진다. “학”과 “교”를 따로 보면 아무 뜻이 없다. 모델이 “학+교=학교”를 처음부터 배워야 해서 학습이 훨씬 어려워진다.
방법 3 — 서브워드 (현대의 정답)
둘의 중간을 택한다.
자주 쓰는 말 → 통째로 하나의 토큰
드물게 쓰는 말 → 조각으로 쪼갬
"학교에서" → ["학교", "에서"]
"양자컴퓨터" → ["양자", "컴", "퓨터"]
자주 나오는 “학교”는 통째로 두고, 드문 “양자컴퓨터”는 쪼갠다. 사전 크기도 잡히고, 모르는 단어도 조각으로 표현할 수 있다.
지금 쓰는 모델들은 거의 다 이 방식이다.
2. BPE — 어떤 덩어리를 사전에 넣을지 데이터가 정한다
그럼 어떤 말을 통째로 두고 어떤 말을 쪼갤지는 누가 정하나? 사람이 정하지 않는다. 데이터가 정한다.
대표적인 알고리즘이 BPE(Byte Pair Encoding)인데, 원리가 놀랄 만큼 단순하다.
가장 자주 붙어 나오는 두 조각을 하나로 합친다. 이걸 반복한다.
단계별로 따라가기
처음엔 모든 단어를 글자 단위로 쪼개 놓는다.
말뭉치 = {
'b a n a n a </w>': 5, # 이 형태가 5번 등장
'b a n d </w>': 3,
'b a t </w>': 2,
}
1회차 — 인접한 쌍의 빈도를 전부 센다. 직접 세어보면 이렇게 나온다.
| 쌍 | 어디서 나오나 | 합계 |
|---|---|---|
| (a, n) | banana에 2번×5 + band에 1번×3 | 13 |
| (n, a) | banana에 2번×5 | 10 |
| (b, a) | banana 5 + band 3 + bat 2 | 10 |
| (n, d) | band에 1번×3 | 3 |
| (a, t) | bat에 1번×2 | 2 |
1등은 ('a','n')으로 13회다. 이걸 하나로 합친다.
{'b an an a </w>': 5, 'b an d </w>': 3, 'b a t </w>': 2}
banana하나에an이 두 번 들어 있다는 걸 놓치기 쉽다. 단어 개수가 아니라 쌍의 등장 횟수 × 그 단어의 빈도로 세야 한다.
2회차 — 다시 세고, 또 합친다. 이번엔 ('b','an')이다.
{'ban an a </w>': 5, 'ban d </w>': 3, 'b a t </w>': 2}
이걸 사전 크기가 목표에 닿을 때까지 반복한다. 그러면 ban, an 같은 자주 쓰이는 덩어리가 자동으로 사전에 등록된다.
반 친구들 별명을 만드는 것과 같다. 처음엔 한 글자씩 부르다가, “지”와 “훈”이 항상 붙어 다니는 걸 발견하면 아예 “지훈”을 하나의 이름으로 등록한다. 이걸 반복하면 자주 쓰는 이름들이 자연스럽게 하나의 단위가 된다.
직접 구현해보기
원리를 확인하려고 20줄로 짜봤다.
import collections
def 쌍_빈도_세기(말뭉치):
쌍_빈도 = collections.defaultdict(int)
for 단어, 빈도 in 말뭉치.items():
조각들 = 단어.split()
for i in range(len(조각들) - 1):
쌍_빈도[(조각들[i], 조각들[i+1])] += 빈도
return 쌍_빈도
def 쌍_합치기(합칠쌍, 말뭉치):
찾을것 = " ".join(합칠쌍) # ('a','n') → "a n"
바꿀것 = "".join(합칠쌍) # ('a','n') → "an"
return {단어.replace(찾을것, 바꿀것): 빈도
for 단어, 빈도 in 말뭉치.items()}
for 회차 in range(10):
쌍_빈도 = 쌍_빈도_세기(말뭉치)
if not 쌍_빈도:
break
최다_쌍 = max(쌍_빈도, key=쌍_빈도.get)
print(f"{회차+1}회차: {최다_쌍} (빈도 {쌍_빈도[최다_쌍]})")
말뭉치 = 쌍_합치기(최다_쌍, 말뭉치)
문법 두 개를 짚고 간다.
max(쌍_빈도, key=쌍_빈도.get)은 딕셔너리에서 “값이 가장 큰 것의 키”를 찾는 문법이다. key=를 빼면 키 자체를 사전순으로 비교해버려서 엉뚱한 결과가 나온다. 나는 이걸 빼먹고 왜 이상한 쌍이 뽑히나 한참 봤다.
defaultdict(int)는 없는 키에 접근하면 자동으로 0부터 시작한다. 일반 딕셔너리였다면 쌍_빈도[키] += 빈도에서 KeyError가 난다.
실전에서는 라이브러리를 쓴다
직접 구현은 원리 확인용이고, 실제로는 tokenizers를 쓴다.
from tokenizers import BertWordPieceTokenizer
tokenizer = BertWordPieceTokenizer(lowercase=False, strip_accents=False)
tokenizer.train(
files="말뭉치.txt",
vocab_size=30000,
min_frequency=2,
limit_alphabet=6000,
special_tokens=["[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"],
wordpieces_prefix="##",
)
| 파라미터 | 뜻 |
|---|---|
vocab_size=30000 |
사전 크기 |
min_frequency=2 |
2번 이상 나온 것만 사전에 (오타 걸러내기) |
limit_alphabet=6000 |
기본 글자 종류 상한 (한글·한자는 글자가 많아서 제한이 필요) |
wordpieces_prefix="##" |
이어붙는 조각의 표시 |
특수 토큰
["[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"]
| 토큰 | 역할 |
|---|---|
[PAD] |
길이를 맞추기 위한 빈자리 |
[UNK] |
정말 알 수 없는 것 (서브워드를 쓰면 거의 안 나온다) |
[CLS] |
문장 시작 표시. 문장 전체를 대표하는 자리로도 쓰인다 |
[SEP] |
문장 구분자 |
[MASK] |
가려서 맞히게 하는 자리 |
[PAD]가 왜 필요한지가 처음엔 이해가 안 됐는데, 배치를 생각하면 당연했다.
3단어 문장과 10단어 문장을 같은 배치에 넣으려면 길이가 같아야 한다. 짧은 쪽을
[PAD]로 채워서 맞추는 것이다. 노트에 줄을 맞추려고 빈칸을 채우는 것과 같다.
[MASK]는 지난 SSAFY 시리즈에서 본 BERT의 빈칸 맞히기에 쓰이는 그 토큰이다.
써보기
text = "I'm a student!"
encoded = tokenizer.encode(text)
print("토큰:", encoded.tokens) # ['I', "'", 'm', 'a', 'stu', '##dent', '!']
print("ID :", encoded.ids) # [412, 11, 79, 25, 3021, 8890, 5]
print("복원:", tokenizer.decode(encoded.ids)) # "I'm a student!"
##dent의 ##가 “앞 조각에 이어 붙는다”는 표시다. 그래서 디코딩할 때 공백 없이 붙여 복원된다.
encode와decode는 짝이다. 디코딩해서 원문이 제대로 복원되는지 확인해보는 건 토크나이저가 제대로 학습됐는지 검증하는 좋은 방법이다.
3. 임베딩 — 번호를 벡터로
토큰화가 끝나면 [412, 11, 79, ...] 같은 번호 목록이 나온다. 그런데 이 번호를 그대로 모델에 넣으면 안 된다.
번호는 크기 정보를 갖기 때문이다.
"학교" = 887번
"의자" = 1774번
모델이 보기엔 1774가 887의 두 배다. 그런데 의자가 학교의 두 배일 리가 없다. 아무 의미 없는 순서에 크기 관계가 생겨버린다.
원-핫으로 바꾸면 이 문제는 없어지지만, 사전이 3만 개면 3만 칸짜리 벡터가 필요하고 그중 하나만 1이다. 낭비가 심하고 단어 사이의 유사도도 못 잰다.
그래서 작은 차원에 실수를 꽉 채운 벡터를 쓴다. 이게 임베딩이다.
nn.Embedding은 사실 그냥 표다
import torch
import torch.nn as nn
임베딩 = nn.Embedding(num_embeddings=30000, embedding_dim=768)
print(임베딩.weight.shape) # torch.Size([30000, 768])
-
num_embeddings=30000— 사전에 토큰이 3만 개 (표의 행 개수) -
embedding_dim=768— 각 토큰을 768개 숫자로 (표의 열 개수)
토큰ID = torch.tensor([887], dtype=torch.long)
벡터 = 임베딩(토큰ID)
print(벡터.shape) # torch.Size([1, 768])
nn.Embedding이 하는 일은 “표에서 해당 줄을 찾아 꺼내는 것”뿐이다. 887번이 들어오면 887번째 줄을 그대로 돌려준다. 사전에서 단어를 찾아보는 것과 똑같다.
처음엔 뭔가 대단한 계산을 하는 줄 알았는데, 조회 테이블이라는 걸 알고 나니 허무하면서도 명확해졌다.
⚠️ 입력은 반드시
long(정수)이어야 한다. “몇 번째 줄을 꺼낼지”를 가리키는 번호이기 때문이다.float을 넣으면 에러가 난다.
그런데 이 표도 학습된다
여기가 핵심이다.
nn.Embedding을 처음 만들면 표의 값은 전부 무작위다. 즉 아직 아무 의미가 없다.
임베딩 = nn.Embedding(30000, 768) # 이 순간의 값은 그냥 난수
그런데 이 표의 값도 학습되는 파라미터다. model.parameters()에 포함되고, optimizer.step()에서 같이 갱신된다.
모델이 문장을 수없이 읽으면서 “학교”와 “교실”이 비슷한 문맥에 자주 나온다는 걸 발견하면, 두 벡터를 점점 비슷한 방향으로 조정한다.
학습이 끝나면 비슷한 뜻의 단어들이 가까이 모여 있다.
우리가 “이 단어들은 비슷해”라고 알려준 적이 없다. 모델이 문장을 읽으면서 스스로 정리한 결과다. SSAFY 시리즈에서 다룬 분포 가설이 코드에서는 이렇게 구현된다.
학습된 임베딩 가져다 쓰기
직접 학습시키려면 방대한 텍스트와 시간이 필요하다. 이미 학습된 걸 가져다 쓰는 게 보통이다.
from transformers import BertModel, BertTokenizer
tokenizer = BertTokenizer.from_pretrained("bert-base-multilingual-cased")
model = BertModel.from_pretrained("bert-base-multilingual-cased")
inputs = tokenizer("오늘 학교에서 공부했다", return_tensors="pt")
outputs = model(**inputs)
print(outputs.last_hidden_state.shape) # torch.Size([1, 토큰수, 768])
from_pretrained() 한 줄이면 남이 수개월 학습시킨 결과를 그대로 받아온다. 처음 이걸 돌려보고 좀 허탈했다. 앞에서 손으로 만든 게 몇 줄로 끝나버리니까.
그래도 직접 만들어보고 나서 쓰는 것과 그냥 쓰는 건 다르다. [PAD]가 왜 필요한지, 왜 long이어야 하는지, 왜 결과 모양이 (배치, 토큰수, 768)인지가 전부 설명되기 때문이다.
**inputs는 딕셔너리를 키워드 인자로 풀어서 넘기는 파이썬 문법이다. tokenizer가 input_ids, attention_mask 등을 담은 딕셔너리를 주는데, 그걸 하나씩 적는 대신 한 번에 넘기는 것이다.
4. 여기서 라이브러리 지도를 다시 보면
첫 글에서 그린 층 구조를 다시 떠올려 보자.
| 층 | 이 시리즈에서 다룬 것 |
|---|---|
| NumPy | 모든 것의 바닥 (지난 시리즈) |
| pandas · seaborn | 표 다루기, 정제 |
| scikit-learn | 분할·스케일링·평가·PCA |
| PyTorch | 텐서·Autograd·모델·학습 루프 |
| transformers | 사전학습 모델 가져다 쓰기 |
아래에서 위로 올라오면서 각 층이 무엇을 대신해주는지가 보인다.
- scikit-learn은 머신러닝의 반복 작업을 대신해준다
- PyTorch는 미분과 GPU 관리를 대신해준다
- transformers는 학습 자체를 대신해준다
위로 갈수록 편해지는데, 편해진 만큼 안에서 무슨 일이 벌어지는지 안 보인다. 그래서 아래층을 아는 게 결국 도움이 된다. 에러는 항상 아래층에서 나기 때문이다.
시리즈를 마치며
이 시리즈를 정리하면서 가장 크게 남은 건 이거였다.
라이브러리를 외우는 게 아니라, 그 라이브러리가 무엇을 대신해주고 있는지를 아는 것.
optimizer.step() 한 줄이 무슨 일을 하는지 알면 순서를 헷갈릴 일이 없다. nn.Embedding이 조회 테이블이라는 걸 알면 왜 입력이 long이어야 하는지 외울 필요가 없다. 원리를 알면 외울 게 줄어든다.
처음에는 반대로 접근했다. 함수 목록을 정리하고, 파라미터를 외우고, 예제를 복사했다. 그렇게 쌓은 건 다음 주에 남아 있지 않았다.
그리고 하나 더. 이 시리즈를 쓰면서 내가 쓰던 코드에서 오류를 두 개 발견했다.
- 교차검증에 미리 스케일링한 데이터를 넘기고 있었다 — 데이터 유출
-
.index를 리스트인 줄 알고+로 이어 붙이고 있었다
둘 다 에러가 안 나서 몰랐던 것들이다. 설명하려고 다시 들여다보지 않았으면 계속 그렇게 썼을 것이다.
남에게 설명하려고 정리하면 내가 모르는 게 드러난다. 이 시리즈에서 얻은 것 중에 이게 제일 컸다.
다시 지도 앞에서
첫 글에서 라이브러리를 층으로 그렸는데, 다 쓰고 나서 보니 그 그림에 한 줄을 더 붙이고 싶어졌다.
위층으로 갈수록 → 편해진다 · 대신 안이 안 보인다
아래층으로 갈수록 → 번거롭다 · 대신 무슨 일이 벌어지는지 보인다
from_pretrained() 한 줄이면 남이 수개월 학습시킨 모델이 손에 들어온다. 편하다. 그런데 거기서 에러가 나면 결국 아래층으로 내려가야 한다. 텐서 모양이 안 맞거나, 타입이 다르거나, 장치가 어긋난다.
그래서 결론은 이렇다. 위층을 쓰되 아래층을 알아야 한다. 위층은 평소에 시간을 아껴주고, 아래층은 문제가 생겼을 때 나를 구해준다.
익숙한 개념과 견줘 보면
이번 시리즈를 정리하면서 기존에 알던 것과 겹쳐 보였던 지점들이다.
| AI 라이브러리 | 익숙한 개념 |
|---|---|
sklearn의 fit / transform
|
설정을 한 번 읽어두고 계속 재사용하는 초기화 패턴 |
| 데이터 유출 | 테스트 코드가 프로덕션 DB를 건드리는 것 — 통과했다고 안심하면 안 되는 통과 |
| Pipeline | 순서를 지켜야 하는 절차를 하나로 묶어 강제하는 것 |
텐서의 .device
|
서로 다른 서버에 있는 데이터를 그냥 합치려는 것 |
| Autograd | 실행 로그를 남겨두고 나중에 거꾸로 되짚는 것 |
optimizer.zero_grad() |
요청마다 상태를 초기화하지 않으면 직전 요청 값이 새는 문제 |
nn.Embedding |
조회 테이블 — 다만 그 테이블 값 자체가 학습된다 |
from_pretrained() |
잘 만들어진 라이브러리를 가져다 쓰는 것 |
특히 zero_grad()를 빼먹었을 때의 증상이 익숙했다. 요청 사이에 상태를 안 지워서 앞 사용자 데이터가 다음 사람에게 보이는 종류의 버그와 성격이 같다. 에러는 안 나는데 값이 조용히 오염된다는 것까지.
이 시리즈에서 만난 함정들이 대부분 “에러 없이 조용히 틀리는” 유형이었는데, 돌아보면 개발하면서 제일 오래 걸렸던 버그들도 늘 그런 종류였다.
정리
- 텍스트는 토큰화 → 번호 → 임베딩 세 단계를 거쳐 모델에 들어간다
- 토큰화 방식 세 가지
- 단어 단위 — 사전이 폭발하고 모르는 단어에 취약
- 글자 단위 — 사전은 작지만 의미가 부서진다
- 서브워드 — 자주 쓰는 건 통째로, 드문 건 조각으로. 현대의 정답
- BPE = 가장 자주 붙어 나오는 쌍을 합치기를 반복. 덩어리를 데이터가 정한다
- 실전에서는
tokenizers라이브러리.[PAD]는 배치 길이를 맞추기 위한 빈자리 -
nn.Embedding은 조회 테이블이다. 번호를 받아 그 줄을 꺼내줄 뿐- 입력은 반드시
long - 표의 값도 학습되는 파라미터 — 처음엔 난수, 학습 후엔 비슷한 단어끼리 모인다
- 입력은 반드시
-
transformers의from_pretrained()로 학습된 결과를 그대로 받아올 수 있다 - 라이브러리는 무엇을 대신해주는지를 알고 써야 한다. 에러는 항상 아래층에서 난다
다음 글에서
여기까지가 지금까지 정리한 범위다. 실습이 더 쌓이는 대로 이어서 추가할 예정이다.
한줄 평
- 세상에 존재하시는 여러 천재분들의 발상에 오늘도 감탄하며 마무리했습니다.