이미지 로딩 중...
AI Generated
2025. 11. 8. · 4 Views
LLM 구현 1편 - 언어 모델의 역사와 발전
현대 AI의 핵심인 대규모 언어 모델(LLM)이 어떻게 탄생하고 발전했는지 살펴봅니다. 초기 통계 기반 모델부터 GPT, BERT 같은 트랜스포머 기반 모델까지, 언어 모델의 역사를 코드와 함께 이해해봅니다.
목차
- N-gram 언어 모델 - 통계로 다음 단어 예측하기
- Word2Vec - 단어를 숫자 벡터로 변환하기
- RNN과 LSTM - 순서를 기억하는 언어 모델
- Attention 메커니즘 - 중요한 부분에 집중하기
- Transformer - Attention만으로 만든 혁신
- GPT - 생성형 언어 모델의 시작
- BERT - 양방향 문맥 이해의 혁신
- Tokenization - 텍스트를 숫자로 변환하는 기술
- Pre-training과 Fine-tuning - 전이 학습의 힘
- Scaling Laws - 모델이 커질수록 똑똑해진다
1. N-gram 언어 모델 - 통계로 다음 단어 예측하기
시작하며
여러분이 문장을 입력할 때 자동완성 기능을 사용해본 적 있나요? "오늘 날씨가"까지 입력하면 자동으로 "좋네요" 같은 단어가 추천되는 경험 말이죠.
이런 기능은 실제로 언어 모델의 가장 기본적인 형태에서 출발합니다. 과거에는 복잡한 딥러닝 없이도 단순한 통계만으로 다음에 올 단어를 예측했습니다.
바로 이럴 때 사용된 것이 N-gram 모델입니다. 문장에서 연속된 N개의 단어 패턴을 학습해서, 이전 단어들을 보고 다음 단어를 예측하는 방식이죠.
개요
간단히 말해서, N-gram 모델은 텍스트에서 연속된 N개의 단어가 함께 나타나는 빈도를 세어서 확률을 계산하는 통계 기반 모델입니다. 이 모델이 필요한 이유는 실무에서 검색 자동완성, 맞춤법 교정, 간단한 텍스트 생성 같은 작업을 빠르고 효율적으로 처리할 수 있기 때문입니다.
예를 들어, 이메일 작성 시 다음 단어를 제안하거나, 검색창에서 쿼리를 자동완성하는 경우에 매우 유용합니다. 기존에는 규칙 기반으로 단어를 하나하나 지정했다면, 이제는 데이터에서 자동으로 패턴을 학습할 수 있습니다.
N-gram의 핵심 특징은 첫째, 구현이 간단하고 빠르다는 점, 둘째, 메모리 효율적이라는 점, 셋째, 해석이 쉽다는 점입니다. 이러한 특징들이 초기 자연어 처리 시스템에서 N-gram이 널리 사용된 이유입니다.
코드 예제
from collections import defaultdict, Counter
class BigramModel:
def __init__(self):
# bigram: 2개 연속 단어의 빈도를 저장
self.bigrams = defaultdict(Counter)
def train(self, sentences):
# 문장들을 학습하여 bigram 빈도 계산
for sentence in sentences:
words = sentence.split()
for i in range(len(words) - 1):
# 현재 단어 다음에 오는 단어의 빈도 증가
self.bigrams[words[i]][words[i+1]] += 1
def predict_next(self, word):
# 주어진 단어 다음에 올 가장 확률 높은 단어 반환
if word in self.bigrams:
return self.bigrams[word].most_common(1)[0][0]
return None
# 사용 예시
model = BigramModel()
model.train(["오늘 날씨가 좋아요", "오늘 날씨가 흐려요", "날씨가 좋아요"])
print(model.predict_next("오늘")) # "날씨가" 출력
설명
이것이 하는 일: N-gram 모델은 대량의 텍스트를 분석하여 단어들의 연속 패턴을 학습하고, 이를 기반으로 다음에 올 단어를 확률적으로 예측합니다. 첫 번째로, train 메서드는 여러 문장을 입력받아 각 문장을 단어 단위로 분리합니다.
그리고 연속된 두 단어(bigram)를 찾아서 defaultdict(Counter) 구조에 저장합니다. 예를 들어 "오늘 날씨가"라는 패턴이 10번 나타나면, bigrams["오늘"]["날씨가"] = 10으로 저장되죠.
두 번째로, 학습이 완료되면 bigrams 딕셔너리에는 모든 단어 쌍의 빈도 정보가 담깁니다. "오늘" 다음에 "날씨가"가 8번, "점심"이 2번 나왔다면, "오늘" 다음에는 "날씨가"가 올 확률이 80%라는 것을 알 수 있습니다.
세 번째로, predict_next 메서드가 실행되면 주어진 단어 다음에 가장 많이 나타난 단어를 찾아 반환합니다. most_common(1)을 사용하여 가장 빈도가 높은 단어를 선택하는 것이죠.
여러분이 이 코드를 사용하면 간단한 텍스트 자동완성 시스템을 몇 줄의 코드로 구현할 수 있습니다. 실무에서는 검색 제안, 이메일 자동완성, 챗봇의 응답 생성 같은 곳에 활용할 수 있습니다.
또한 모델이 가볍고 빠르기 때문에 리소스가 제한된 환경에서도 효과적으로 동작합니다.
실전 팁
💡 실무에서는 Bigram(2-gram)보다 Trigram(3-gram)을 많이 사용합니다. 문맥을 더 많이 고려할 수 있어 예측 정확도가 높아지기 때문이죠.
💡 데이터가 부족할 때는 Smoothing 기법을 사용하세요. 한 번도 본 적 없는 단어 조합에도 작은 확률을 부여하여 예측이 실패하는 것을 방지할 수 있습니다.
💡 메모리 사용량을 줄이려면 빈도가 낮은 N-gram은 제거하세요. 1-2번만 나타난 패턴은 노이즈일 가능성이 높고, 실제 예측에도 거의 사용되지 않습니다.
💡 문장의 시작과 끝을 표시하는 특수 토큰(<START>, <END>)을 추가하면 문장 생성 품질이 향상됩니다. 문장의 자연스러운 시작과 종료를 학습할 수 있기 때문입니다.
💡 N-gram은 단어 단위뿐만 아니라 문자 단위로도 사용할 수 있습니다. 문자 단위 N-gram은 오타 교정이나 언어 감지에 특히 유용합니다.
2. Word2Vec - 단어를 숫자 벡터로 변환하기
시작하며
여러분이 컴퓨터에게 "왕"과 "여왕"이 비슷한 단어라고 어떻게 가르칠 수 있을까요? 더 나아가 "왕 - 남자 + 여자 = 여왕" 같은 의미 연산을 어떻게 가능하게 할 수 있을까요?
이런 문제는 딥러닝이 발전하면서 중요해졌습니다. 단순히 단어의 빈도를 세는 것을 넘어서, 단어의 "의미"를 수학적으로 표현할 방법이 필요했죠.
바로 이럴 때 등장한 것이 Word2Vec입니다. 2013년 구글에서 발표한 이 혁신적인 방법은 단어를 고차원 벡터 공간에 배치하여, 비슷한 의미의 단어들이 가까이 위치하도록 학습합니다.
개요
간단히 말해서, Word2Vec은 단어를 고정 길이의 실수 벡터로 변환하는 기법으로, 비슷한 문맥에서 사용되는 단어들은 비슷한 벡터 값을 갖도록 학습됩니다. 이 모델이 필요한 이유는 딥러닝 모델이 텍스트를 직접 처리할 수 없기 때문입니다.
신경망은 숫자만 이해하므로, 단어를 의미 있는 숫자 표현으로 변환해야 합니다. 예를 들어, 감성 분석 모델을 만들 때 "좋다", "훌륭하다", "최고"같은 긍정적 단어들이 비슷한 벡터를 가지면 모델이 더 잘 학습합니다.
기존의 One-hot 인코딩은 각 단어를 독립적인 벡터로 표현했다면, Word2Vec은 단어 간 유사도를 벡터 공간에서 거리로 표현할 수 있습니다. Word2Vec의 핵심 특징은 첫째, 의미적 유사도를 수치화할 수 있다는 점, 둘째, 벡터 연산으로 단어의 관계를 표현할 수 있다는 점, 셋째, 비교적 적은 데이터로도 좋은 성능을 낸다는 점입니다.
이러한 특징들이 Word2Vec을 현대 NLP의 기초 기술로 만들었습니다.
코드 예제
from gensim.models import Word2Vec
# 학습 데이터: 문장들의 리스트 (각 문장은 단어 리스트)
sentences = [
["왕", "은", "성", "에", "살아요"],
["여왕", "은", "성", "에", "살아요"],
["남자", "는", "왕", "이에요"],
["여자", "는", "여왕", "이에요"],
["고양이", "는", "동물", "이에요"],
["강아지", "는", "동물", "이에요"]
]
# Word2Vec 모델 학습: vector_size=100차원, window=5단어 문맥 고려
model = Word2Vec(sentences, vector_size=100, window=5, min_count=1, workers=4)
# 단어를 벡터로 변환
vector = model.wv["왕"] # 100차원 벡터 반환
# 유사한 단어 찾기
similar = model.wv.most_similar("왕", topn=3)
print(similar) # [('여왕', 0.95), ('성', 0.87), ...]
# 벡터 연산: 왕 - 남자 + 여자 = ?
result = model.wv.most_similar(positive=["왕", "여자"], negative=["남자"])
print(result) # 여왕이 나올 가능성 높음
설명
이것이 하는 일: Word2Vec은 대량의 텍스트를 학습하여 각 단어를 100~300차원의 벡터로 표현하고, 이 벡터들이 단어의 의미 관계를 반영하도록 최적화합니다. 첫 번째로, Word2Vec 모델은 문장들을 입력받아 각 단어 주변의 문맥 단어들을 분석합니다.
window=5는 앞뒤 5개 단어를 문맥으로 고려한다는 의미죠. "왕은 성에 살아요"에서 "성"의 문맥은 ["왕", "은", "에", "살아요"]가 됩니다.
이렇게 비슷한 문맥에 나타나는 단어들은 비슷한 의미를 가질 것이라는 가정에서 출발합니다. 두 번째로, 학습 과정에서 신경망은 중심 단어로부터 주변 단어를 예측하거나(Skip-gram), 주변 단어들로부터 중심 단어를 예측하는(CBOW) 방식으로 학습됩니다.
이 과정에서 각 단어의 벡터 값이 조금씩 조정되면서, "왕"과 "여왕"처럼 비슷한 문맥에서 사용되는 단어들은 벡터 공간에서 가까워집니다. 세 번째로, 학습이 완료되면 model.wv["왕"]으로 단어의 벡터를 얻을 수 있고, most_similar로 유사한 단어를 찾을 수 있습니다.
더 놀라운 것은 벡터 연산이 가능하다는 점인데, "왕 - 남자 + 여자"라는 연산을 하면 실제로 "여왕"에 가까운 벡터가 나옵니다. 여러분이 이 코드를 사용하면 검색 시스템에서 동의어 확장, 추천 시스템에서 유사 상품 찾기, 문서 분류에서 특징 추출 등 다양한 작업을 할 수 있습니다.
Word2Vec으로 만든 단어 벡터는 거의 모든 NLP 태스크의 입력으로 사용될 수 있어, 딥러닝 모델의 성능을 크게 향상시킵니다. 또한 사전학습된 Word2Vec 모델을 사용하면 자신만의 대규모 코퍼스가 없어도 고품질 단어 벡터를 얻을 수 있습니다.
실전 팁
💡 Skip-gram과 CBOW 중 선택할 때, 데이터가 적으면 Skip-gram을, 데이터가 많으면 CBOW를 사용하세요. Skip-gram은 희귀 단어에 강하지만 느리고, CBOW는 빠르지만 빈번한 단어에 유리합니다.
💡 vector_size는 보통 100~300 사이로 설정합니다. 너무 작으면 정보 손실이 크고, 너무 크면 과적합되기 쉽습니다. 데이터 크기에 따라 조정하세요.
💡 실무에서는 Google News 사전학습 모델이나 FastText를 사용하는 것이 좋습니다. 수백만 개의 문서로 학습된 모델은 자신의 작은 데이터셋보다 훨씬 좋은 벡터를 제공합니다.
💡 한국어의 경우 형태소 분석기(KoNLPy)로 전처리한 후 Word2Vec을 적용하세요. "먹었다", "먹는다", "먹고"를 모두 "먹다"로 통일하면 학습 효율이 높아집니다.
💡 단어 벡터의 품질을 평가할 때는 유사도 테스트와 analogy 테스트를 함께 사용하세요. "서울 - 한국 + 일본 = 도쿄" 같은 analogy가 잘 작동하는지 확인하면 벡터의 의미 표현력을 알 수 있습니다.
3. RNN과 LSTM - 순서를 기억하는 언어 모델
시작하며
여러분이 긴 문장을 읽을 때 앞부분의 내용을 기억하면서 뒷부분을 이해하죠? "철수는 오늘 회사에 갔다가 저녁에 친구를 만나서 영화를 봤다"라는 문장에서 "영화를 봤다"의 주체가 철수라는 걸 어떻게 알 수 있을까요?
이런 문제는 N-gram이나 Word2Vec만으로는 해결할 수 없습니다. 문장의 순서와 장기 의존성을 모델링할 방법이 필요했죠.
단순히 단어의 의미뿐만 아니라, 단어들의 순서와 흐름을 이해해야 했습니다. 바로 이럴 때 등장한 것이 순환 신경망(RNN)과 LSTM입니다.
RNN은 이전 시점의 정보를 기억하면서 순차적으로 데이터를 처리하고, LSTM은 RNN의 단점을 보완하여 긴 문맥도 효과적으로 기억할 수 있게 해줍니다.
개요
간단히 말해서, RNN은 이전 시점의 출력을 다음 시점의 입력으로 사용하는 순환 구조를 가진 신경망이고, LSTM은 게이트 메커니즘을 통해 장기 기억을 유지할 수 있도록 개선한 RNN의 변형입니다. 이 모델들이 필요한 이유는 텍스트, 시계열 데이터처럼 순서가 중요한 데이터를 처리하기 위해서입니다.
언어는 본질적으로 순차적이어서, 단어의 순서가 바뀌면 의미가 완전히 달라집니다. 예를 들어, 기계 번역, 문장 생성, 감성 분석 같은 작업에서 문맥을 이해하는 것이 핵심이죠.
기존의 일반 신경망은 각 입력을 독립적으로 처리했다면, RNN/LSTM은 이전 정보를 메모리에 저장하면서 순차적으로 처리할 수 있습니다. RNN/LSTM의 핵심 특징은 첫째, 가변 길이 시퀀스를 처리할 수 있다는 점, 둘째, 시간에 따른 패턴을 학습할 수 있다는 점, 셋째, 파라미터를 시간 축에서 공유하여 효율적이라는 점입니다.
특히 LSTM의 게이트 구조는 gradient vanishing 문제를 해결하여 실용적인 언어 모델을 가능하게 했습니다.
코드 예제
import torch
import torch.nn as nn
class LSTMLanguageModel(nn.Module):
def __init__(self, vocab_size, embedding_dim=128, hidden_dim=256):
super().__init__()
# 단어를 벡터로 변환하는 임베딩 레이어
self.embedding = nn.Embedding(vocab_size, embedding_dim)
# LSTM 레이어: 순서 정보를 기억하며 처리
self.lstm = nn.LSTM(embedding_dim, hidden_dim, num_layers=2, batch_first=True)
# 최종 출력: 다음 단어 예측
self.fc = nn.Linear(hidden_dim, vocab_size)
def forward(self, x, hidden=None):
# x: [batch_size, seq_len] 형태의 단어 인덱스
embedded = self.embedding(x) # [batch_size, seq_len, embedding_dim]
# LSTM에 입력하여 순차 처리, hidden state는 이전 문맥 저장
lstm_out, hidden = self.lstm(embedded, hidden) # [batch_size, seq_len, hidden_dim]
# 각 시점마다 다음 단어의 확률 분포 계산
output = self.fc(lstm_out) # [batch_size, seq_len, vocab_size]
return output, hidden
# 사용 예시
vocab_size = 10000
model = LSTMLanguageModel(vocab_size)
# 입력: 배치 크기 32, 시퀀스 길이 20
input_ids = torch.randint(0, vocab_size, (32, 20))
output, hidden = model(input_ids)
print(output.shape) # torch.Size([32, 20, 10000])
설명
이것이 하는 일: LSTM 언어 모델은 문장의 단어들을 순차적으로 입력받아 각 시점에서 다음에 올 단어를 예측하며, 이 과정에서 문장의 전체 문맥을 hidden state에 저장합니다. 첫 번째로, embedding 레이어가 각 단어 인덱스를 고차원 벡터로 변환합니다.
예를 들어 단어 ID 1234를 128차원의 실수 벡터로 바꿔주는 것이죠. 이 임베딩은 Word2Vec과 비슷하지만, 모델 학습 과정에서 함께 최적화되어 해당 태스크에 특화된 벡터를 만듭니다.
두 번째로, LSTM 레이어가 임베딩된 단어들을 시간 순서대로 처리합니다. LSTM의 핵심은 세 가지 게이트(input, forget, output gate)인데, 이들이 협력하여 어떤 정보를 기억하고, 잊고, 출력할지 결정합니다.
"철수는 오늘 회사에..."라는 긴 문장에서 주어 "철수"를 끝까지 기억할 수 있는 이유가 바로 이 메커니즘 덕분입니다. 세 번째로, LSTM의 출력은 fc 레이어를 거쳐 vocab_size 크기의 확률 분포로 변환됩니다.
각 시점에서 전체 어휘 중 다음에 올 가능성이 높은 단어들의 확률을 계산하는 것이죠. 가장 높은 확률을 가진 단어가 모델의 예측이 됩니다.
네 번째로, hidden 상태는 이전 시퀀스의 정보를 담고 있어서, 긴 텍스트를 여러 청크로 나눠 처리할 때도 문맥이 유지됩니다. 이는 실시간 텍스트 생성이나 스트리밍 처리에서 매우 유용합니다.
여러분이 이 코드를 사용하면 챗봇의 응답 생성, 자동 번역, 문장 자동완성, 코드 자동완성 등 다양한 시퀀스 생성 작업을 수행할 수 있습니다. LSTM은 시간 순서를 이해하기 때문에 시계열 예측(주가, 날씨)에도 활용됩니다.
또한 양방향 LSTM(Bi-LSTM)을 사용하면 앞뒤 문맥을 모두 고려하여 더욱 정확한 이해가 가능합니다.
실전 팁
💡 LSTM의 hidden_dim은 보통 256~512로 설정합니다. 너무 작으면 표현력이 부족하고, 너무 크면 과적합과 느린 학습 속도 문제가 발생합니다.
💡 긴 시퀀스를 처리할 때는 gradient clipping을 반드시 사용하세요. torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)으로 gradient 폭발을 방지할 수 있습니다.
💡 실무에서는 LSTM보다 GRU를 먼저 시도해보세요. GRU는 게이트가 2개로 더 간단하지만 성능은 비슷하고, 학습 속도가 20-30% 빠릅니다.
💡 양방향 LSTM을 사용하면 성능이 크게 향상됩니다. 문장 전체를 이미 알고 있는 경우(번역, 분류)에는 앞뒤 문맥을 모두 활용하는 것이 유리합니다.
💡 Dropout을 LSTM 레이어 사이에 추가하면 과적합을 방지할 수 있습니다. nn.LSTM(..., dropout=0.3)으로 간단히 적용 가능하며, 일반적으로 0.2~0.5 사이 값을 사용합니다.
4. Attention 메커니즘 - 중요한 부분에 집중하기
시작하며
여러분이 긴 문서를 읽을 때 모든 문장에 동일한 집중력을 기울이나요? 아니죠.
중요한 부분은 집중해서 읽고, 덜 중요한 부분은 가볍게 넘어갑니다. 예를 들어 "이 제품은 여러 기능이 있지만, 가장 중요한 것은 배터리 수명입니다"라는 문장에서 우리는 자연스럽게 "배터리 수명"에 주목하게 됩니다.
이런 선택적 집중은 인간 인지의 핵심 능력인데, 기계는 이를 어떻게 구현할 수 있을까요? LSTM은 모든 단어를 동등하게 처리하기 때문에, 정말 중요한 정보가 긴 시퀀스 속에서 희석되는 문제가 있었습니다.
바로 이럴 때 도입된 것이 Attention 메커니즘입니다. 2014년 기계 번역 문제를 해결하기 위해 제안된 이 혁신적인 아이디어는, 각 시점에서 입력의 어느 부분이 중요한지를 동적으로 계산하여 가중치를 부여합니다.
개요
간단히 말해서, Attention은 입력 시퀀스의 모든 위치를 고려하되, 현재 작업에 더 관련 있는 위치에 더 높은 가중치를 부여하는 메커니즘입니다. 이 메커니즘이 필요한 이유는 고정 길이 벡터의 한계를 극복하기 위해서입니다.
LSTM은 긴 문장을 하나의 고정 크기 벡터로 압축하는데, 이 과정에서 정보 손실이 발생합니다. 예를 들어, 영어를 한국어로 번역할 때 50단어 문장의 모든 정보를 256차원 벡터에 담는 것은 무리죠.
Attention은 필요할 때마다 원본 입력을 다시 참조하여 이 문제를 해결합니다. 기존의 Encoder-Decoder 구조는 인코더의 마지막 hidden state만 사용했다면, Attention은 모든 시점의 hidden state를 활용하여 문맥에 맞는 정보를 선택적으로 추출합니다.
Attention의 핵심 특징은 첫째, 입력의 모든 부분에 접근 가능하다는 점(병목 현상 해소), 둘째, 중요도를 동적으로 계산한다는 점(해석 가능성), 셋째, 병렬 처리가 가능하다는 점입니다. 이러한 특징들이 Attention을 현대 NLP의 핵심 구성 요소로 만들었고, 트랜스포머의 기반이 되었습니다.
코드 예제
import torch
import torch.nn as nn
import torch.nn.functional as F
class AttentionLayer(nn.Module):
def __init__(self, hidden_dim):
super().__init__()
# Query, Key, Value를 위한 선형 변환
self.query = nn.Linear(hidden_dim, hidden_dim)
self.key = nn.Linear(hidden_dim, hidden_dim)
self.value = nn.Linear(hidden_dim, hidden_dim)
self.scale = hidden_dim ** 0.5
def forward(self, x):
# x: [batch_size, seq_len, hidden_dim]
Q = self.query(x) # Query: 무엇을 찾고 싶은가
K = self.key(x) # Key: 각 위치가 가진 정보의 특성
V = self.value(x) # Value: 실제 정보 내용
# Q와 K의 유사도 계산 (scaled dot-product)
attention_scores = torch.matmul(Q, K.transpose(-2, -1)) / self.scale
# [batch_size, seq_len, seq_len]
# Softmax로 확률 분포 변환: 어디에 집중할지 결정
attention_weights = F.softmax(attention_scores, dim=-1)
# 가중치를 적용하여 정보 추출
output = torch.matmul(attention_weights, V)
return output, attention_weights
# 사용 예시
hidden_dim = 256
attention = AttentionLayer(hidden_dim)
x = torch.randn(32, 20, hidden_dim) # 배치 32, 길이 20
output, weights = attention(x)
print(f"Output: {output.shape}") # [32, 20, 256]
print(f"Attention weights: {weights.shape}") # [32, 20, 20]
설명
이것이 하는 일: Attention 메커니즘은 입력 시퀀스의 모든 위치 간 관계를 계산하여, 각 위치에서 다른 위치들의 정보를 얼마나 참조할지 결정합니다. 첫 번째로, 입력 x가 세 개의 선형 변환을 거쳐 Query(Q), Key(K), Value(V)로 변환됩니다.
이는 정보 검색에서 차용한 개념인데, Q는 "내가 찾는 정보", K는 "각 위치가 제공하는 정보의 특징", V는 "실제 정보 내용"을 나타냅니다. 예를 들어 "고양이가 매트 위에 앉았다"에서 "앉았다"(Query)는 "누가?"라는 질문을 던지고, "고양이가"(Key)가 매칭되어 해당 Value를 가져오는 식입니다.
두 번째로, Q와 K의 내적(dot product)을 계산하여 유사도 점수를 얻습니다. torch.matmul(Q, K.transpose(-2, -1))은 각 Query 위치와 모든 Key 위치 간의 유사도를 계산하여 [seq_len, seq_len] 행렬을 만듭니다.
이 행렬의 (i, j) 원소는 "i번째 위치가 j번째 위치를 얼마나 참조해야 하는가"를 나타냅니다. scale factor로 나누는 이유는 내적 값이 너무 커지면 softmax의 gradient가 소실되기 때문입니다.
세 번째로, softmax 함수가 유사도 점수를 확률 분포로 변환합니다. 각 행의 합이 1이 되도록 정규화하여, "이 위치에서는 30%는 단어 A를, 60%는 단어 B를 참조한다" 같은 해석이 가능해집니다.
이 확률 분포를 attention weights라고 합니다. 네 번째로, attention weights를 Value에 곱하여 최종 출력을 만듭니다.
이는 가중 평균과 같아서, 중요한 위치의 정보는 많이 반영되고 덜 중요한 위치는 적게 반영됩니다. 결과적으로 각 위치는 전체 시퀀스의 정보를 선택적으로 통합한 새로운 표현을 갖게 됩니다.
여러분이 이 코드를 사용하면 기계 번역의 정확도를 크게 향상시킬 수 있습니다. Attention weights를 시각화하면 모델이 어떤 단어에 집중했는지 볼 수 있어, 모델의 의사결정 과정을 이해하고 디버깅하는 데 도움이 됩니다.
또한 문서 요약, 질의응답, 이미지 캡셔닝 등 다양한 분야에서 Attention은 필수적인 구성 요소가 되었습니다.
실전 팁
💡 Scaled dot-product의 scale factor(√d)는 필수입니다. 생략하면 고차원에서 softmax가 극단적인 값으로 포화되어 학습이 불안정해집니다.
💡 Multi-head Attention을 사용하면 성능이 크게 향상됩니다. 여러 개의 Attention을 병렬로 실행하여 다양한 관점에서 정보를 추출할 수 있기 때문입니다.
💡 Attention weights를 시각화하여 모델이 제대로 학습하는지 확인하세요. 번역 모델이라면 source와 target의 대응 관계가 대각선 형태로 나타나야 정상입니다.
💡 긴 시퀀스에서는 메모리가 O(n²)로 증가합니다. 1000 단어 문장은 100만 개의 attention score를 계산해야 하므로, Sparse Attention이나 Linear Attention 같은 효율적인 변형을 고려하세요.
💡 Self-Attention(Q, K, V가 모두 같은 입력)과 Cross-Attention(Q는 다른 소스)을 구분하세요. 번역에서는 인코더-디코더 간 Cross-Attention이, 문장 이해에서는 Self-Attention이 핵심입니다.
5. Transformer - Attention만으로 만든 혁신
시작하며
여러분이 대규모 프로젝트를 진행할 때 순차적으로만 작업할 수 있다면 얼마나 답답할까요? LSTM은 바로 이런 문제를 가지고 있었습니다.
t번째 단어를 처리하려면 반드시 t-1번째 단어가 먼저 처리되어야 하기 때문에, 병렬 처리가 불가능했죠. 이런 제약은 GPU의 강력한 병렬 연산 능력을 제대로 활용하지 못하게 만들었습니다.
또한 아무리 Attention을 추가해도 RNN의 순차 처리 병목은 여전히 남아있었습니다. 바로 이럴 때 등장한 것이 2017년 "Attention Is All You Need" 논문의 Transformer입니다.
RNN을 완전히 제거하고 Attention만으로 모델을 구성한 이 혁신적인 아키텍처는, 병렬 처리가 가능하면서도 더 긴 문맥을 효과적으로 처리할 수 있었습니다.
개요
간단히 말해서, Transformer는 Self-Attention과 Feed-Forward 네트워크를 쌓아 올린 구조로, RNN 없이도 시퀀스를 처리할 수 있으며 완전한 병렬 학습이 가능한 모델입니다. 이 모델이 필요한 이유는 기존 RNN/LSTM의 한계를 극복하기 위해서입니다.
첫째, 순차 처리로 인한 느린 학습 속도, 둘째, 긴 시퀀스에서의 정보 손실, 셋째, GPU 병렬화의 어려움 등이 문제였죠. 예를 들어, 1000단어 문장을 처리할 때 LSTM은 1000번의 순차 연산이 필요하지만, Transformer는 모든 단어를 동시에 처리할 수 있습니다.
기존의 RNN 기반 모델은 시간 축으로 순차 처리했다면, Transformer는 전체 시퀀스를 한 번에 입력받아 Self-Attention으로 단어 간 관계를 파악합니다. Transformer의 핵심 특징은 첫째, 완전한 병렬 처리로 학습 속도가 10-100배 빠르다는 점, 둘째, Positional Encoding으로 단어 순서 정보를 보존한다는 점, 셋째, Multi-head Self-Attention으로 다양한 관점의 문맥을 동시에 학습한다는 점입니다.
이러한 특징들이 Transformer를 GPT, BERT, T5 등 현대 LLM의 기반 아키텍처로 만들었습니다.
코드 예제
import torch
import torch.nn as nn
import math
class TransformerBlock(nn.Module):
def __init__(self, d_model=512, num_heads=8, d_ff=2048, dropout=0.1):
super().__init__()
# Multi-head Self-Attention
self.attention = nn.MultiheadAttention(d_model, num_heads, dropout=dropout, batch_first=True)
# Position-wise Feed-Forward Network
self.feed_forward = nn.Sequential(
nn.Linear(d_model, d_ff),
nn.ReLU(),
nn.Dropout(dropout),
nn.Linear(d_ff, d_model)
)
# Layer Normalization (residual connection 전후)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x, mask=None):
# Self-Attention + Residual + Norm
attn_output, _ = self.attention(x, x, x, attn_mask=mask)
x = self.norm1(x + self.dropout(attn_output))
# Feed-Forward + Residual + Norm
ff_output = self.feed_forward(x)
x = self.norm2(x + self.dropout(ff_output))
return x
# 사용 예시
d_model = 512
transformer_block = TransformerBlock(d_model)
x = torch.randn(32, 50, d_model) # [batch, seq_len, d_model]
output = transformer_block(x)
print(output.shape) # torch.Size([32, 50, 512])
설명
이것이 하는 일: Transformer 블록은 입력 시퀀스의 모든 위치를 동시에 처리하며, Self-Attention으로 단어 간 관계를 파악하고 Feed-Forward로 각 위치의 표현을 변환합니다. 첫 번째로, Multi-head Attention이 입력 x를 Query, Key, Value로 변환하여 Self-Attention을 수행합니다.
num_heads=8이면 512차원을 8개의 64차원 헤드로 나누어, 각 헤드가 독립적으로 Attention을 계산합니다. 예를 들어 한 헤드는 문법적 관계를, 다른 헤드는 의미적 유사성을 학습하는 식으로 다양한 패턴을 포착할 수 있습니다.
batch_first=True는 입력 형태를 [batch, seq, feature]로 설정하는 옵션입니다. 두 번째로, Attention 출력에 Residual Connection과 Layer Normalization을 적용합니다.
x + self.dropout(attn_output)는 입력을 그대로 더해주는데, 이는 gradient 흐름을 개선하고 깊은 네트워크 학습을 가능하게 합니다. norm1은 각 샘플을 정규화하여 학습을 안정화시키죠.
Dropout은 과적합 방지를 위한 정규화 기법입니다. 세 번째로, Feed-Forward 네트워크가 각 위치의 표현을 독립적으로 변환합니다.
d_model → d_ff → d_model 구조로, 중간에 차원을 확장했다가 다시 축소합니다. d_ff는 보통 d_model의 4배(2048 = 512 × 4)로 설정하며, 이 확장된 공간에서 더 복잡한 변환을 학습합니다.
ReLU 활성화 함수가 비선형성을 추가하여 모델의 표현력을 높입니다. 네 번째로, 다시 한 번 Residual Connection과 Layer Normalization을 적용하여 최종 출력을 만듭니다.
이 과정을 거치면 각 단어는 자신의 원래 정보를 유지하면서, 문맥 정보와 변환된 특징을 추가로 획득합니다. 실제 Transformer는 이런 블록을 6개, 12개, 심지어 96개까지 쌓아서 매우 깊은 모델을 만듭니다.
여러분이 이 코드를 사용하면 현대적인 언어 모델의 기본 구조를 이해하고 직접 구현할 수 있습니다. GPT는 Decoder 블록만 쌓고, BERT는 Encoder 블록만 쌓고, T5는 둘 다 사용하는 식으로 다양한 변형이 가능합니다.
Transformer는 NLP뿐만 아니라 컴퓨터 비전(Vision Transformer), 음성 인식, 강화학습 등 거의 모든 분야로 확장되고 있습니다. 또한 학습된 Attention 패턴을 분석하면 모델이 언어의 문법 구조를 자동으로 학습했음을 확인할 수 있어, AI의 언어 이해 능력을 연구하는 데도 유용합니다.
실전 팁
💡 Positional Encoding을 반드시 추가하세요. Transformer는 순서 정보가 없으므로 sin/cos 함수나 학습 가능한 임베딩으로 위치를 인코딩해야 합니다.
💡 Warmup Learning Rate Scheduler를 사용하세요. 초반에 학습률을 천천히 올렸다가 내리는 전략이 Transformer 학습에 매우 효과적입니다. d_model ** -0.5 * min(step ** -0.5, step * warmup ** -1.5) 공식을 사용합니다.
💡 d_model은 512나 768처럼 num_heads로 나누어떨어지는 값을 사용하세요. 8개 헤드를 사용한다면 512 = 8 × 64로 깔끔하게 나눠집니다.
💡 메모리 부족 시 Gradient Checkpointing을 활용하세요. torch.utils.checkpoint로 중간 activation을 재계산하면 메모리를 절반 이상 줄일 수 있지만, 학습 시간이 20-30% 증가합니다.
💡 Pre-Layer Normalization(Norm을 Attention/FFN 전에 적용)이 Post-Layer Normalization보다 학습이 안정적입니다. 최신 모델들은 대부분 Pre-LN을 사용합니다.
6. GPT - 생성형 언어 모델의 시작
시작하며
여러분이 "오늘 날씨가"라고 입력하면 "좋네요", "흐려요" 같은 자연스러운 문장을 생성하는 AI를 본 적 있나요? 단순히 다음 단어를 예측하는 것을 넘어서, 수백 단어의 일관성 있는 문장을 생성하는 능력은 어떻게 가능할까요?
이런 생성 능력은 모델이 언어의 패턴을 깊이 이해해야만 가능합니다. 단순한 분류나 번역을 넘어서, "언어 자체"를 모델링하는 것이 핵심이었죠.
마치 사람이 글을 쓰듯이, 문맥을 이해하고 자연스러운 다음 문장을 만들어내야 합니다. 바로 이럴 때 등장한 것이 2018년 OpenAI의 GPT(Generative Pre-trained Transformer)입니다.
"다음 단어 예측"이라는 간단한 목표로 대규모 텍스트를 학습한 후, 다양한 태스크에 적용할 수 있는 범용 언어 모델을 만들었습니다.
개요
간단히 말해서, GPT는 Transformer의 Decoder 블록만 사용하여 왼쪽에서 오른쪽으로 순차적으로 다음 단어를 예측하도록 사전학습된 생성형 언어 모델입니다. 이 모델이 필요한 이유는 레이블이 없는 대량의 텍스트 데이터를 활용하기 위해서입니다.
기존 모델들은 특정 태스크마다 레이블링된 데이터가 필요했지만, GPT는 인터넷의 모든 텍스트로 학습할 수 있었죠. 예를 들어, 위키피디아, 책, 웹페이지 등 수십억 단어를 "다음 단어 맞추기" 방식으로 학습하여 언어의 일반적인 패턴을 익힙니다.
기존의 BERT가 양방향으로 문맥을 이해하는 인코더 모델이라면, GPT는 단방향으로 텍스트를 생성하는 디코더 모델입니다. GPT의 핵심 특징은 첫째, Pre-training + Fine-tuning 패러다임을 확립했다는 점, 둘째, Autoregressive 생성으로 무한히 긴 텍스트를 만들 수 있다는 점, 셋째, Zero-shot/Few-shot Learning이 가능하다는 점입니다.
특히 GPT-3부터는 학습 데이터에 없던 태스크도 몇 가지 예시만으로 수행할 수 있어, 인공지능의 새로운 패러다임을 열었습니다.
코드 예제
import torch
import torch.nn as nn
class GPTBlock(nn.Module):
def __init__(self, d_model=768, num_heads=12, d_ff=3072, dropout=0.1):
super().__init__()
self.attention = nn.MultiheadAttention(d_model, num_heads, dropout=dropout, batch_first=True)
self.feed_forward = nn.Sequential(
nn.Linear(d_model, d_ff),
nn.GELU(), # GPT는 ReLU 대신 GELU 사용
nn.Dropout(dropout),
nn.Linear(d_ff, d_model)
)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x, causal_mask):
# Causal Self-Attention: 미래 단어를 보지 못하도록 마스킹
attn_output, _ = self.attention(x, x, x, attn_mask=causal_mask, is_causal=True)
x = self.norm1(x + self.dropout(attn_output))
# Feed-Forward
ff_output = self.feed_forward(x)
x = self.norm2(x + self.dropout(ff_output))
return x
def create_causal_mask(seq_len):
# 상삼각 행렬: 미래 위치는 -inf로 마스킹
mask = torch.triu(torch.ones(seq_len, seq_len) * float('-inf'), diagonal=1)
return mask
# 사용 예시
d_model = 768
gpt_block = GPTBlock(d_model)
seq_len = 50
x = torch.randn(32, seq_len, d_model)
causal_mask = create_causal_mask(seq_len)
output = gpt_block(x, causal_mask)
print(output.shape) # torch.Size([32, 50, 768])
설명
이것이 하는 일: GPT는 주어진 문맥(이전 단어들)을 보고 다음에 올 가장 적절한 단어를 예측하며, 이 과정을 반복하여 문장, 문단, 심지어 책 전체를 생성할 수 있습니다. 첫 번째로, Causal Mask가 GPT의 핵심입니다.
torch.triu로 만든 상삼각 행렬은 대각선 위쪽을 -inf로 채워서, Attention 계산 시 미래 위치를 참조하지 못하게 합니다. 예를 들어 3번째 단어를 예측할 때는 1, 2번째 단어만 볼 수 있고, 4, 5번째는 볼 수 없죠.
이렇게 하면 모델이 "답을 미리 보는" 치팅을 하지 않고, 실제 생성 상황과 동일한 조건으로 학습합니다. 두 번째로, is_causal=True 플래그와 함께 Multi-head Attention이 실행됩니다.
이는 PyTorch에서 최적화된 Causal Attention을 사용하는 옵션으로, 메모리와 속도 면에서 효율적입니다. 각 단어는 자신보다 앞에 있는 모든 단어들의 정보를 가중 평균하여 새로운 표현을 만듭니다.
세 번째로, Feed-Forward 네트워크에서 GELU(Gaussian Error Linear Unit) 활성화 함수를 사용합니다. ReLU는 0 이하를 완전히 차단하지만, GELU는 부드러운 곡선으로 근사하여 gradient 흐름이 더 좋습니다.
d_ff=3072는 d_model=768의 4배로, GPT의 표준 비율입니다. 네 번째로, 학습 시에는 문장 전체를 입력하여 각 위치에서 다음 단어를 동시에 예측합니다(Teacher Forcing).
하지만 생성 시에는 한 단어씩 autoregressive하게 생성하죠. "안녕" → "안녕하세요" → "안녕하세요 저는" 식으로 이전 출력을 다시 입력으로 사용합니다.
여러분이 이 코드를 사용하면 ChatGPT 같은 대화형 AI의 기본 원리를 이해할 수 있습니다. GPT는 텍스트 생성뿐만 아니라 요약, 번역, 질의응답, 코드 생성 등 거의 모든 언어 태스크를 수행할 수 있습니다.
GPT-1은 12개 레이어, GPT-2는 48개, GPT-3는 96개 레이어를 쌓아 점점 더 복잡한 언어 패턴을 학습했습니다. 최근의 GPT-4나 Claude 같은 모델들은 수천억 개의 파라미터로 거의 인간 수준의 텍스트를 생성할 수 있게 되었죠.
실전 팁
💡 생성 시 Temperature 파라미터로 창의성을 조절하세요. Temperature=0.7은 안정적이고, 1.5는 창의적이지만 일관성이 떨어집니다. Softmax 전에 logits를 나누는 값입니다.
💡 Top-k sampling과 Top-p(nucleus) sampling을 조합하면 품질 좋은 텍스트를 생성할 수 있습니다. 확률이 낮은 이상한 단어들을 필터링하는 효과가 있습니다.
💡 실무에서는 Hugging Face의 사전학습 GPT-2를 사용하세요. transformers.GPT2LMHeadModel로 바로 로드할 수 있고, 자신의 데이터로 Fine-tuning이 가능합니다.
💡 긴 텍스트 생성 시 Repetition Penalty를 적용하면 같은 문장이 반복되는 것을 방지할 수 있습니다. 이미 생성된 토큰의 확률을 감소시키는 기법입니다.
💡 KV Cache를 사용하면 생성 속도가 10배 이상 빨라집니다. 이전 단계의 Key와 Value를 캐싱하여 매번 전체 시퀀스를 재계산하지 않아도 됩니다.
7. BERT - 양방향 문맥 이해의 혁신
시작하며
여러분이 "은행에 갔다"라는 문장을 읽을 때, "은행"이 금융기관인지 강가인지 어떻게 구분하나요? 뒤에 오는 "돈을 찾으러" 또는 "산책을 하러" 같은 단어를 보고 판단하죠.
즉, 앞뒤 문맥을 모두 고려해야 정확한 의미를 파악할 수 있습니다. GPT의 한계는 바로 여기에 있었습니다.
왼쪽에서 오른쪽으로만 읽기 때문에, "은행"을 처리할 때 아직 뒤에 나올 "돈을 찾으러"를 볼 수 없었죠. 생성 태스크에는 적합했지만, 문장의 의미를 깊이 이해하는 데는 한계가 있었습니다.
바로 이럴 때 등장한 것이 2018년 구글의 BERT(Bidirectional Encoder Representations from Transformers)입니다. 양방향으로 문맥을 읽어 단어의 의미를 정확히 파악하며, 문장 분류, 개체명 인식, 질의응답 같은 이해 태스크에서 혁신적인 성능을 보였습니다.
개요
간단히 말해서, BERT는 Transformer의 Encoder 블록만 사용하여 문장의 양방향 문맥을 동시에 고려하며, Masked Language Model 방식으로 사전학습된 이해형 언어 모델입니다. 이 모델이 필요한 이유는 텍스트 분류, 감성 분석, 질의응답, 개체명 인식 같은 이해 태스크에서 더 정확한 의미 파악이 필요하기 때문입니다.
GPT는 "다음 단어 예측"으로 학습하지만, BERT는 "가려진 단어 맞추기"로 학습하여 문맥의 양쪽을 모두 활용합니다. 예를 들어, "나는 [MASK]을 먹었다"에서 앞의 "나는"과 뒤의 "먹었다"를 보고 "밥", "사과" 등을 추론하는 식이죠.
기존의 GPT가 단방향 디코더라면, BERT는 양방향 인코더로 문장 전체를 한 번에 이해합니다. BERT의 핵심 특징은 첫째, Masked Language Model(MLM)로 양방향 문맥을 학습한다는 점, 둘째, Next Sentence Prediction(NSP)으로 문장 간 관계를 학습한다는 점, 셋째, Fine-tuning만으로 11개 NLP 벤치마크에서 SOTA를 달성했다는 점입니다.
이러한 특징들이 BERT를 검색 엔진(Google Search), 챗봇, 문서 분류 시스템 등에 널리 사용되게 만들었습니다.
코드 예제
import torch
import torch.nn as nn
import random
class BERTPretraining(nn.Module):
def __init__(self, vocab_size=30000, d_model=768, num_heads=12, num_layers=12):
super().__init__()
self.embedding = nn.Embedding(vocab_size, d_model)
self.position_embedding = nn.Embedding(512, d_model) # 최대 512 토큰
# Transformer Encoder 블록들
encoder_layer = nn.TransformerEncoderLayer(d_model, num_heads, dim_feedforward=3072, batch_first=True)
self.transformer = nn.TransformerEncoder(encoder_layer, num_layers)
# MLM 예측 헤드
self.mlm_head = nn.Linear(d_model, vocab_size)
def forward(self, input_ids):
# input_ids: [batch_size, seq_len]
seq_len = input_ids.size(1)
positions = torch.arange(seq_len, device=input_ids.device).unsqueeze(0)
# Token + Position 임베딩
x = self.embedding(input_ids) + self.position_embedding(positions)
# Transformer Encoder (양방향)
encoded = self.transformer(x)
# 각 토큰이 무엇인지 예측
predictions = self.mlm_head(encoded)
return predictions
def create_mlm_data(tokens, mask_prob=0.15, mask_token_id=103):
# 15% 토큰을 [MASK]로 변경
masked_tokens = tokens.clone()
labels = tokens.clone()
for i in range(len(tokens)):
if random.random() < mask_prob:
masked_tokens[i] = mask_token_id # [MASK] 토큰
else:
labels[i] = -100 # 손실 계산에서 무시
return masked_tokens, labels
설명
이것이 하는 일: BERT는 문장에서 일부 단어를 무작위로 가린 후, 앞뒤 문맥을 모두 활용하여 가려진 단어를 예측하며, 이 과정에서 깊은 양방향 언어 이해 능력을 학습합니다. 첫 번째로, create_mlm_data 함수가 학습 데이터를 만듭니다.
원본 문장에서 15% 토큰을 무작위로 선택하여 특수 토큰 [MASK]로 바꿉니다. 예를 들어 "나는 오늘 학교에 갔다"가 "나는 [MASK] 학교에 갔다"로 변환되는 식이죠.
나머지 85% 토큰의 label은 -100으로 설정하여 손실 계산에서 제외합니다. 이렇게 하면 모델은 가려진 단어만 예측하도록 집중합니다.
두 번째로, 입력 토큰이 두 가지 임베딩을 거칩니다. embedding은 각 단어의 의미를, position_embedding은 단어의 위치 정보를 인코딩합니다.
BERT는 Transformer처럼 순서 정보가 없으므로, 학습 가능한 Position Embedding을 더해줘야 "첫 번째 단어"와 "다섯 번째 단어"를 구분할 수 있습니다. 두 임베딩을 더한 결과가 Transformer의 입력이 됩니다.
세 번째로, TransformerEncoder가 문장을 처리합니다. GPT와 달리 Causal Mask가 없어서, 각 단어는 문장의 모든 단어를 자유롭게 참조할 수 있습니다.
"[MASK]"가 5번째 위치에 있다면, 14번째 단어(왼쪽 문맥)뿐만 아니라 610번째 단어(오른쪽 문맥)도 모두 보면서 예측합니다. 이것이 "양방향"의 의미입니다.
네 번째로, mlm_head가 각 위치의 인코딩 벡터를 받아 vocab_size 크기의 logits를 출력합니다. Softmax를 적용하면 각 단어가 정답일 확률이 나오고, Cross-Entropy Loss로 학습합니다.
가려진 위치에서만 손실을 계산하므로, 모델은 문맥으로부터 단어를 추론하는 능력을 키우게 됩니다. 여러분이 이 코드를 사용하면 검색 엔진의 문서 랭킹, 고객 리뷰 감성 분석, 이메일 자동 분류, 법률/의료 문서의 개체명 인식 등 다양한 실무 애플리케이션을 구축할 수 있습니다.
Google Search는 실제로 BERT를 사용하여 검색 쿼리의 의도를 더 정확히 이해합니다. 또한 BERT를 Sentence Embedding으로 사용하면 유사 문서 검색, 중복 질문 탐지, 문서 클러스터링 등에 활용할 수 있습니다.
Fine-tuning은 보통 수천~수만 개의 레이블 데이터만 있으면 충분하며, 며칠 내에 SOTA 성능을 달성할 수 있습니다.
실전 팁
💡 실무에서는 Hugging Face의 bert-base-uncased나 한국어용 klue/bert-base를 사용하세요. 수십 GB 텍스트로 사전학습된 모델을 바로 Fine-tuning할 수 있습니다.
💡 [CLS] 토큰의 출력을 문장 전체의 표현으로 사용합니다. 문장 분류 시 encoded[:, 0, :]을 추출하여 분류 레이어에 입력하면 됩니다.
💡 긴 문서는 Sliding Window로 처리하세요. BERT는 최대 512 토큰이므로, 1000 토큰 문서는 0-512, 256-768 식으로 겹치게 나눠서 처리한 후 결과를 평균내면 됩니다.
💡 Domain-specific 데이터로 추가 사전학습(Continue Pre-training)하면 성능이 향상됩니다. 의료, 법률, 금융 같은 전문 분야는 일반 BERT보다 도메인 BERT가 훨씬 좋습니다.
💡 DistilBERT나 ALBERT 같은 경량 모델을 고려하세요. 성능은 BERT의 95% 수준이지만 크기는 절반, 속도는 2배 빠르므로 실시간 서비스에 적합합니다.
8. Tokenization - 텍스트를 숫자로 변환하는 기술
시작하며
여러분이 "안녕하세요"를 컴퓨터에 입력하면, 컴퓨터는 이를 어떻게 이해할까요? 딥러닝 모델은 문자를 직접 처리할 수 없고, 숫자만 이해합니다.
따라서 텍스트를 숫자로 변환하는 과정이 필수적이죠. 이 변환 과정이 단순해 보이지만, 실제로는 매우 복잡합니다.
"먹었다", "먹는다", "먹고"를 각각 다른 단어로 처리할지, "먹-"이라는 어근으로 통합할지 결정해야 하고, "GPT-4"나 "don't" 같은 특수 케이스도 처리해야 합니다. 또한 "김치찌개"를 하나로 볼지, "김치"+"찌개"로 나눌지도 고민이 됩니다.
바로 이럴 때 필요한 것이 Tokenization 기술입니다. 특히 BPE(Byte Pair Encoding), WordPiece, SentencePiece 같은 서브워드(subword) 토크나이저는 단어와 문자의 장점을 결합하여, 효율적이면서도 유연한 텍스트 표현을 가능하게 합니다.
개요
간단히 말해서, Tokenization은 텍스트를 모델이 처리할 수 있는 작은 단위(토큰)로 분리하는 과정이며, 서브워드 토크나이저는 빈도에 기반하여 단어를 더 작은 의미 단위로 분해합니다. 이 기술이 필요한 이유는 어휘 크기와 표현력 사이의 균형을 맞추기 위해서입니다.
단어 단위 토크나이저는 어휘가 수백만 개로 커지고 미등록 단어(OOV) 문제가 심하며, 문자 단위는 시퀀스가 너무 길어져 학습이 비효율적입니다. 예를 들어, "unbelievable"을 ["un", "believ", "able"]로 나누면 어휘는 작게 유지하면서도 새로운 단어를 표현할 수 있습니다.
기존의 공백 기반 분리는 언어마다 규칙이 달랐다면, 서브워드 방식은 데이터에서 자동으로 최적의 분할을 학습합니다. Tokenization의 핵심 특징은 첫째, OOV 문제를 해결한다는 점(모든 단어를 서브워드 조합으로 표현), 둘째, 어휘 크기를 제어할 수 있다는 점(보통 30k~50k), 셋째, 다국어 처리가 가능하다는 점입니다.
GPT는 BPE를, BERT는 WordPiece를 사용하며, 이들은 현대 LLM의 표준 토크나이저가 되었습니다.
코드 예제
from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import Whitespace
# BPE 토크나이저 생성 및 학습
def train_bpe_tokenizer(texts, vocab_size=5000):
# BPE 모델 초기화
tokenizer = Tokenizer(BPE(unk_token="[UNK]"))
tokenizer.pre_tokenizer = Whitespace() # 공백으로 먼저 분리
# 트레이너 설정: vocab_size만큼 병합 수행
trainer = BpeTrainer(
vocab_size=vocab_size,
special_tokens=["[UNK]", "[CLS]", "[SEP]", "[PAD]", "[MASK]"]
)
# 텍스트 데이터로 학습
tokenizer.train_from_iterator(texts, trainer)
return tokenizer
# 사용 예시
texts = [
"안녕하세요 반갑습니다",
"안녕하세요 좋은 아침입니다",
"반갑습니다 만나서 반가워요",
"unbelievable performance"
]
tokenizer = train_bpe_tokenizer(texts, vocab_size=100)
# 인코딩: 텍스트 → 토큰 ID
output = tokenizer.encode("안녕하세요 오늘 날씨가 좋네요")
print(f"Tokens: {output.tokens}") # ['안녕', '하', '세요', ...]
print(f"IDs: {output.ids}") # [12, 34, 56, ...]
# 디코딩: 토큰 ID → 텍스트
decoded = tokenizer.decode(output.ids)
print(f"Decoded: {decoded}")
설명
이것이 하는 일: BPE 토크나이저는 대량의 텍스트에서 자주 함께 나타나는 문자나 서브워드를 찾아 병합하며, 이를 반복하여 지정된 어휘 크기에 도달할 때까지 어휘를 확장합니다. 첫 번째로, Whitespace() pre-tokenizer가 텍스트를 공백으로 1차 분리합니다.
"안녕하세요 반갑습니다"가 ["안녕하세요", "반갑습니다"]로 나뉘는 것이죠. 그 다음 각 단어를 문자 단위로 더 분해하여 초기 어휘를 만듭니다.
["안", "녕", "하", "세", "요", "반", "갑", "습", "니", "다"] 같은 식입니다. 두 번째로, BPE 알고리즘이 빈도를 계산하여 가장 많이 붙어 다니는 쌍을 찾습니다.
"안"과 "녕"이 항상 붙어 있다면 "안녕"으로 병합하고, "하"+"세"→"하세", "하세"+"요"→"하세요" 식으로 계속 병합합니다. vocab_size에 도달할 때까지 이 과정을 반복하여, 자주 나타나는 단어는 하나의 토큰으로, 희귀한 단어는 여러 서브워드 조합으로 표현됩니다.
세 번째로, 학습된 토크나이저로 새로운 텍스트를 인코딩합니다. encode 메서드는 텍스트를 토큰으로 분리한 후, 각 토큰을 어휘 사전에서 찾아 ID로 변환합니다.
만약 "오늘"이 학습 때 없었다면, "오"+"늘"로 분해하거나, 더 작은 단위로 나눕니다. 최악의 경우 문자 단위로 분해하므로, 어떤 텍스트도 표현 불가능한 경우는 없습니다.
네 번째로, 특수 토큰들이 어휘에 추가됩니다. [CLS]는 문장 시작, [SEP]는 문장 구분, [PAD]는 배치 처리 시 길이 맞추기, [MASK]는 BERT의 MLM에 사용됩니다.
[UNK]는 정말 알 수 없는 토큰을 나타내지만, 서브워드 방식에서는 거의 사용되지 않습니다. 여러분이 이 코드를 사용하면 자신만의 도메인 특화 토크나이저를 만들 수 있습니다.
의료, 법률, 프로그래밍 코드 등 특수 용어가 많은 분야에서는 일반 토크나이저보다 도메인 토크나이저가 훨씬 효율적입니다. 예를 들어 프로그래밍 코드용 토크나이저는 "print(", "def ", ")::" 같은 패턴을 하나의 토큰으로 학습하여 코드 이해 성능을 높입니다.
또한 토큰 수가 줄어들면 모델 입력 길이 제한 내에 더 많은 정보를 담을 수 있고, 처리 속도도 빨라집니다.
실전 팁
💡 vocab_size는 데이터 크기와 언어에 따라 조정하세요. 영어는 30k, 한국어는 32k, 다국어는 50k가 일반적입니다. 너무 작으면 긴 시퀀스가, 너무 크면 희소성 문제가 발생합니다.
💡 실무에서는 Hugging Face의 사전학습 토크나이저를 사용하세요. AutoTokenizer.from_pretrained("bert-base-uncased")로 바로 로드할 수 있고, 모델과 호환성이 보장됩니다.
💡 한국어는 형태소 분석기(KoNLPy)를 pre-tokenizer로 사용하면 성능이 향상됩니다. "먹었습니다"를 공백 분리하면 비효율적이지만, "먹/VV + 었/EP + 습니다/EF"로 분리하면 어근과 어미를 효과적으로 학습합니다.
💡 숫자와 URL은 별도로 처리하세요. "2023"을 ["2", "0", "2", "3"]으로 나누면 비효율적이므로, 정규식으로 전처리하거나 normalizer를 추가하는 것이 좋습니다.
💡 토큰 통계를 분석하여 어휘 품질을 검증하세요. 빈도 상위 토큰이 의미 있는 서브워드인지, 너무 많은 rare token이 생기지 않았는지 확인하면 문제를 조기에 발견할 수 있습니다.
9. Pre-training과 Fine-tuning - 전이 학습의 힘
시작하며
여러분이 새로운 프로그래밍 언어를 배울 때, 처음부터 모든 것을 배우나요? 아니죠.
이미 알고 있는 프로그래밍 개념(변수, 함수, 반복문)을 새 언어에 적용하면서 빠르게 배웁니다. 딥러닝도 마찬가지입니다.
처음부터 특정 태스크를 위해 모델을 학습하면 대량의 레이블 데이터가 필요하고, 학습 시간도 오래 걸립니다. 감성 분석을 위해 수백만 개의 레이블된 리뷰가 필요하고, 질의응답을 위해 수십만 개의 질문-답변 쌍이 필요하다면 현실적으로 불가능하죠.
바로 이럴 때 사용하는 것이 전이 학습(Transfer Learning) 패러다임입니다. 대규모 데이터로 언어의 일반적인 지식을 학습(Pre-training)한 후, 소량의 태스크별 데이터로 미세 조정(Fine-tuning)하여 특정 문제를 해결합니다.
GPT와 BERT의 성공은 바로 이 전략 덕분입니다.
개요
간단히 말해서, Pre-training은 대량의 레이블 없는 텍스트로 언어 모델을 학습하여 일반적인 언어 지식을 습득하는 과정이고, Fine-tuning은 소량의 레이블 데이터로 특정 태스크에 맞게 모델을 조정하는 과정입니다. 이 방법이 필요한 이유는 데이터 효율성과 성능 향상 때문입니다.
처음부터 학습(training from scratch)하면 수백만 개의 레이블 데이터가 필요하지만, Fine-tuning은 수천 개만으로도 SOTA 성능을 달성할 수 있습니다. 예를 들어, BERT를 감성 분석에 Fine-tuning하면 5000개 리뷰만으로도 95% 정확도를 얻을 수 있지만, 처음부터 학습하면 수백만 개가 필요합니다.
기존의 태스크별 모델은 각 문제마다 독립적으로 학습했다면, 전이 학습은 하나의 사전학습 모델을 여러 태스크에 재사용합니다. Pre-training과 Fine-tuning의 핵심 특징은 첫째, 레이블 없는 데이터를 활용한다는 점(위키피디아, 뉴스 등), 둘째, 적은 데이터로 높은 성능을 낸다는 점, 셋째, 학습 시간을 크게 단축한다는 점입니다.
이러한 특징들이 NLP를 "각 태스크마다 별도 모델"에서 "하나의 기반 모델 + 여러 Fine-tuning"으로 패러다임을 전환시켰습니다.
코드 예제
from transformers import BertForSequenceClassification, BertTokenizer, Trainer, TrainingArguments
from datasets import load_dataset
# 1. 사전학습된 BERT 모델 로드 (Pre-training은 이미 완료됨)
model_name = "bert-base-uncased"
tokenizer = BertTokenizer.from_pretrained(model_name)
model = BertForSequenceClassification.from_pretrained(model_name, num_labels=2)
# 2. Fine-tuning 데이터 준비 (예: 감성 분석)
dataset = load_dataset("imdb", split="train[:5000]") # 5000개만 사용
def tokenize_function(examples):
# 텍스트를 토큰 ID로 변환
return tokenizer(examples["text"], padding="max_length", truncation=True, max_length=512)
tokenized_dataset = dataset.map(tokenize_function, batched=True)
# 3. Fine-tuning 설정
training_args = TrainingArguments(
output_dir="./results",
num_train_epochs=3, # 보통 2-5 epoch이면 충분
per_device_train_batch_size=16,
learning_rate=2e-5, # Pre-training보다 작은 LR 사용
warmup_steps=500,
weight_decay=0.01,
)
# 4. Fine-tuning 실행
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_dataset,
)
trainer.train() # Fine-tuning 시작
# 5. 추론
text = "This movie is absolutely amazing!"
inputs = tokenizer(text, return_tensors="pt")
outputs = model(**inputs)
prediction = outputs.logits.argmax(-1).item()
print(f"Sentiment: {'Positive' if prediction == 1 else 'Negative'}")
설명
이것이 하는 일: 전이 학습은 수십억 단어로 사전학습된 모델의 언어 이해 능력을 활용하여, 특정 태스크에 필요한 세부 지식만 추가로 학습함으로써 효율적인 문제 해결을 가능하게 합니다. 첫 번째로, from_pretrained로 사전학습된 BERT 모델을 로드합니다.
이 모델은 이미 BookCorpus(800M 단어)와 Wikipedia(2500M 단어)로 MLM과 NSP 방식으로 수주일간 학습되었습니다. 12개 레이어, 110M 파라미터가 영어의 문법, 의미, 상식을 이미 학습한 상태죠.
num_labels=2는 감성 분석(긍정/부정)을 위한 분류 헤드를 추가합니다. 두 번째로, 태스크별 데이터를 준비합니다.
IMDB 영화 리뷰 데이터셋에서 5000개만 사용하는데, 이는 전체 데이터의 20%에 불과합니다. tokenize_function이 텍스트를 BERT가 이해하는 토큰 ID로 변환하고, 길이를 512로 맞춥니다.
Pre-training 때와 동일한 토크나이저를 사용해야 어휘가 일치합니다. 세 번째로, Fine-tuning hyperparameter를 설정합니다.
핵심은 learning_rate=2e-5로, Pre-training의 1e-4보다 훨씬 작습니다. 이는 이미 좋은 가중치를 가진 모델을 조금씩만 조정하겠다는 의미죠.
너무 큰 learning rate를 사용하면 사전학습된 지식이 망가질 수 있습니다(catastrophic forgetting). num_train_epochs=3도 중요한데, Pre-training은 수십 epoch 학습하지만 Fine-tuning은 2-5 epoch이면 충분합니다.
네 번째로, Trainer API가 학습 루프를 자동으로 처리합니다. Gradient 계산, Backpropagation, Optimizer 업데이트, Learning Rate Scheduling 등이 모두 포함됩니다.
내부적으로는 [CLS] 토큰의 출력을 분류 헤드에 입력하여 긍정/부정 확률을 계산하고, Cross-Entropy Loss로 학습합니다. 다섯 번째로, 학습된 모델로 추론을 수행합니다.
새로운 리뷰를 입력하면 토크나이저가 ID로 변환하고, 모델이 logits을 출력하며, argmax로 최종 클래스를 선택합니다. 놀라운 점은 단 5000개 데이터만으로 90% 이상의 정확도를 달성한다는 것입니다.
여러분이 이 코드를 사용하면 거의 모든 NLP 태스크를 빠르게 해결할 수 있습니다. 스팸 필터, 뉴스 카테고리 분류, 상품 리뷰 분석, 고객 문의 라우팅 등 비즈니스 문제에 바로 적용 가능합니다.
또한 도메인 특화 데이터로 추가 Pre-training(Continue Pre-training)을 하면 성능이 더 향상됩니다. 예를 들어 의료 분야라면 PubMed 논문으로 추가 Pre-training 후 질병 분류에 Fine-tuning하는 식이죠.
이 방법으로 적은 비용과 시간으로 전문가 수준의 AI 시스템을 구축할 수 있습니다.
실전 팁
💡 Fine-tuning 시 learning rate는 1e-5 ~ 5e-5 사이로 설정하세요. 너무 크면 사전학습 지식이 손상되고(catastrophic forgetting), 너무 작으면 학습이 느립니다.
💡 Layer-wise learning rate를 사용하면 성능이 향상됩니다. 하위 레이어(범용 특징)는 작은 LR로, 상위 레이어(태스크 특화)는 큰 LR로 학습하는 전략입니다.
💡 Early Stopping을 적용하여 과적합을 방지하세요. Validation loss가 3 epoch 동안 개선되지 않으면 학습을 중단하는 것이 일반적입니다.
💡 데이터가 정말 적다면(<1000개) Few-shot Learning이나 Prompt-based Learning을 고려하세요. GPT-3 스타일의 프롬프트로 Fine-tuning 없이도 작업을 수행할 수 있습니다.
💡 모델 크기와 데이터 크기를 매칭하세요. 데이터가 10k 미만이면 BERT-base, 100k 이상이면 BERT-large, 1M 이상이면 RoBERTa나 DeBERTa를 사용하는 것이 효율적입니다.
10. Scaling Laws - 모델이 커질수록 똑똑해진다
시작하며
여러분이 GPT-3(175B 파라미터)가 GPT-2(1.5B)보다 훨씬 똑똑하다는 것을 느껴본 적 있나요? 단순히 크기가 100배 커진 것뿐인데, 번역, 코딩, 추론 능력이 놀랍게 향상되었죠.
심지어 학습 데이터에 없던 새로운 태스크도 예시만 몇 개 보면 수행합니다. 이런 현상은 우연이 아닙니다.
OpenAI의 2020년 연구에 따르면, 모델 크기, 데이터 크기, 연산량이 증가할수록 성능이 예측 가능한 법칙에 따라 향상된다는 것이 밝혀졌습니다. 이를 "Scaling Laws"라고 부릅니다.
바로 이 발견이 GPT-3, GPT-4, PaLM, Claude 같은 초거대 모델들의 탄생을 이끌었습니다. "더 크게 만들면 더 좋아진다"는 단순하지만 강력한 원리가, AI 연구의 방향을 근본적으로 바꿔놓았죠.
개요
간단히 말해서, Scaling Laws는 모델 파라미터 수(N), 학습 데이터 크기(D), 연산량(C)이 증가할수록 모델의 손실(Loss)이 멱법칙(power law)에 따라 감소한다는 경험적 법칙입니다. 이 법칙이 중요한 이유는 AI 연구의 투자 방향을 결정하기 때문입니다.
새로운 알고리즘을 개발하는 대신, 단순히 모델과 데이터를 키우는 것만으로도 성능이 지속적으로 향상된다는 것을 보장하죠. 예를 들어, 파라미터를 10배 늘리면 Loss가 일정 비율로 줄어들고, 이는 다시 실제 태스크 성능 향상으로 이어집니다.
기존에는 모델 아키텍처 개선에 집중했다면, Scaling Laws 이후에는 "어떻게 더 큰 모델을 효율적으로 학습할까"가 핵심 과제가 되었습니다. Scaling Laws의 핵심 특징은 첫째, 예측 가능하다는 점(작은 실험으로 큰 모델 성능 추정), 둘째, 멱법칙을 따른다는 점(log-log 그래프에서 직선), 셋째, Emergent Abilities를 설명한다는 점입니다.
특히 일정 규모를 넘으면 갑자기 나타나는 능력(수학, 코딩, 추론)은 Scaling의 놀라운 효과를 보여줍니다.
코드 예제
import numpy as np
import matplotlib.pyplot as plt
# Scaling Law 수식: Loss = A / (N ** alpha)
# N: 파라미터 수, A: 상수, alpha: scaling exponent (보통 0.05~0.1)
def compute_loss(N, A=1.0, alpha=0.076):
"""
파라미터 수 N에 대한 예상 Loss 계산
alpha=0.076은 GPT 계열 모델의 실험값
"""
return A / (N ** alpha)
# 다양한 모델 크기에 대한 Loss 예측
model_sizes = np.logspace(6, 12, 50) # 1M ~ 1T 파라미터
losses = [compute_loss(N) for N in model_sizes]
# 시각화
plt.figure(figsize=(10, 6))
plt.loglog(model_sizes, losses, linewidth=2)
plt.xlabel("Model Parameters (N)", fontsize=12)
plt.ylabel("Loss", fontsize=12)
plt.title("Scaling Laws: Loss vs Model Size", fontsize=14)
plt.grid(True, alpha=0.3)
# 주요 모델들 표시
models = {
"GPT-2": 1.5e9,
"GPT-3": 1.75e11,
"GPT-4 (추정)": 1.7e12
}
for name, size in models.items():
loss = compute_loss(size)
plt.scatter(size, loss, s=100, zorder=5)
plt.annotate(name, (size, loss), xytext=(10, 10),
textcoords='offset points', fontsize=10)
plt.tight_layout()
plt.show()
# 예측: 10배 큰 모델의 성능 향상
N1 = 1e9 # 1B 모델
N2 = 1e10 # 10B 모델
improvement = compute_loss(N1) / compute_loss(N2)
print(f"10배 큰 모델의 Loss는 {improvement:.2f}배 개선됩니다")
설명
이것이 하는 일: Scaling Laws는 모델 개발 초기에 작은 실험을 통해 큰 모델의 최종 성능을 예측하고, 최적의 모델 크기와 데이터 크기 조합을 결정하는 데 사용됩니다. 첫 번째로, compute_loss 함수는 Scaling Law의 핵심 수식을 구현합니다.
Loss = A / (N ** alpha) 형태인데, 이는 파라미터 수가 증가하면 Loss가 멱법칙으로 감소한다는 것을 나타냅니다. alpha=0.076은 OpenAI의 GPT 계열 실험에서 얻은 실제 값으로, 이 값이 클수록 Scaling 효과가 큽니다.
즉, 같은 크기 증가로 더 큰 성능 향상을 얻을 수 있죠. 두 번째로, logspace로 1M부터 1T 파라미터까지 로그 스케일로 모델 크기를 생성합니다.
실제로 모델은 exponential하게 커지기 때문에(10M → 100M → 1B → 10B), 로그 스케일이 적합합니다. 각 크기에 대해 예상 Loss를 계산하여 그래프를 그리면, log-log 플롯에서 직선이 나타납니다.
이것이 멱법칙의 특징입니다. 세 번째로, GPT-2(1.5B), GPT-3(175B), GPT-4(1.7T 추정) 같은 실제 모델들을 그래프에 표시합니다.
이들이 거의 직선 위에 위치한다는 것을 볼 수 있는데, 이는 Scaling Law가 실제 모델의 성능을 잘 예측한다는 증거입니다. GPT-3가 GPT-2보다 100배 크고, 실제로 Loss도 예측대로 감소했죠.
네 번째로, 개선 비율을 계산합니다. 파라미터를 10배 늘리면 Loss가 약 1.22배 개선되는 것을 볼 수 있습니다.
이것이 작아 보일 수 있지만, Loss의 작은 감소가 실제 태스크 성능에서는 큰 차이를 만듭니다. 예를 들어 Loss 3.0 → 2.5 감소는 번역 BLEU 점수를 20 → 35로 향상시킬 수 있습니다.
다섯 번째로, 중요한 발견은 "Emergent Abilities"입니다. 일정 규모(보통 10B~100B) 이상에서 갑자기 나타나는 능력들이 있는데, 산술 연산, 코드 생성, 다단계 추론 같은 것들이죠.
Scaling Law는 평균 Loss만 예측하지만, 이런 질적 변화는 AI 발전의 가장 흥미로운 부분입니다. 여러분이 이 코드를 사용하면 자신의 모델 개발 로드맵을 계획할 수 있습니다.
예산이 정해져 있을 때, 100억 파라미터 모델을 1번 학습할지, 10억 모델을 10번 실험할지 결정하는 데 도움이 됩니다. 또한 Scaling Laws는 모델만이 아니라 데이터에도 적용됩니다.
Chinchilla 논문(2022)은 "모델과 데이터를 같이 키워야 최적"이라는 것을 밝혀, GPT-3가 사실 데이터가 부족했다는 것을 보였죠. 최신 연구는 Compute-optimal Scaling을 찾아, 주어진 연산 예산으로 최고 성능을 내는 조합을 연구합니다.
실전 팁
💡 작은 모델로 먼저 실험하여 Scaling 계수를 추정하세요. 10M, 100M, 1B 모델의 Loss를 측정하면 멱법칙을 피팅하여 10B 모델의 성능을 예측할 수 있습니다.
💡 Chinchilla Scaling Laws에 따르면 모델 크기 N에 대해 데이터는 약 20N 토큰이 필요합니다. 10B 모델이라면 200B 토큰으로 학습해야 최적입니다.
💡 연산 예산이 제한되어 있다면 작은 모델을 더 많은 데이터로 오래 학습하는 것이 효율적일 수 있습니다. GPT-3는 "over-sized, under-trained" 모델이었습니다.
💡 Emergent Abilities는 예측하기 어렵습니다. 특정 태스크가 언제 갑자기 가능해질지 모르므로, 중간 체크포인트를 저장하여 다양한 크기를 평가하세요.
💡 Inference 비용도 고려하세요. 100배 큰 모델은 성능이 좋지만 inference 비용도 100배입니다. Knowledge Distillation으로 큰 모델의 지식을 작은 모델로 압축하는 것도 좋은 전략입니다.