이번 주 학습 내용
지난주에 토큰화와 TF-IDF까지 훑으면서 "단어 순서와 문맥을 반영하지 못한다"는 한계를 확인했는데, 이번 주는 정확히 그 지점부터 시작했다. 앞부분은 전처리 마무리였다. 어간 추출과 표제어 추출, 정규표현식, 정수 인코딩·패딩·원핫 인코딩까지 이어지는 파이프라인을 한 줄로 꿰고, 서브워드 토크나이저를 직접 학습시켜봤다. 그다음이 워드 임베딩이었고, 여기서부터 순서를 다루는 모델인 RNN·LSTM·GRU로 넘어가 감성분석과 주가예측을 돌렸다. 후반부는 텍스트 분류, 언어 모델, seq2seq 번역, 그리고 마지막 이틀은 어텐션과 트랜스포머였다. 한 주 만에 "단어를 숫자로 바꾸는 법"에서 "요즘 모델들이 쓰는 구조"까지 온 셈이라 진도가 상당히 빨랐다.
01. 어간 추출과 표제어 추출, 그리고 정규표현식
같은 단어의 여러 형태를 하나로 모으는 두 가지 방법을 비교했다. 어간 추출(stemming)은 규칙적으로 단어 뒤를 잘라내는 방식이라 빠르지만 결과가 실제 단어가 아닐 수 있고, 표제어 추출(lemmatization)은 사전과 품사 정보를 이용해 기본형으로 되돌린다. 같은 문장에 두 방식을 나란히 적용해보니 차이가 확실했다.
PorterStemmer는swiftly를swiftli,easily를easili로 만들어버렸다. 사람 눈에는 이상해 보여도 같은 어원의 단어끼리 형태가 통일되기만 하면 되는 거라 목적 자체가 다르다는 걸 알았다WordNetLemmatizer에pos='v'를 주니ran→run,were→be로 제대로 돌아갔다. 대신 품사를 잘못 지정하면 변환이 안 되거나 엉뚱한 결과가 나와서, 표제어 추출은 품사 태깅과 세트로 써야 한다는 게 핵심이었다
stemmed = [stemmer.stem(token) for token in tokens]
lemmatized = [lemmatizer.lemmatize(token, pos='v') for token in tokens]정규표현식은 정제 단계에서 노이즈를 걸러내는 도구로 배웠다. . * + ? {n,m} [] ^ $ 같은 기호들을 하나씩 re.search로 확인해보고, re.sub('[^a-zA-Z]', '', text)로 영문자만 남기거나 [0-9]+-[0-9]+-[0-9]+로 전화번호를 통째로 뽑아내는 식으로 써봤다. RegexpTokenizer(r'\w+')처럼 토크나이저에 정규식을 넘겨서 원하는 규칙대로 토큰을 쪼갤 수도 있었다.
02. 정수 인코딩 · 패딩 · 원핫 인코딩
토큰을 모델에 넣으려면 결국 숫자여야 한다. 등장 빈도 순으로 단어에 인덱스를 매기고(정수 인코딩), 길이를 맞추고(패딩), 필요하면 0/1 벡터로 펼치는(원핫 인코딩) 순서를 직접 구현해본 뒤 케라스 Tokenizer로 같은 걸 다시 해봤다.
- 인덱스를 1번부터 시작하는 이유가 있었다. 0번은 패딩 토큰 자리로 비워두는 관례라서,
nn.Embedding(..., padding_idx=0)처럼 나중에 모델에서 "이 자리는 의미 없는 값"이라고 알려주는 데 쓰인다 - 단어 사전을 상위 N개로 자르고 나머지를
OOV로 처리하는 이유도 이해했다. 희귀 단어까지 다 넣으면 임베딩 테이블만 커지고 노이즈가 늘어서 오히려 일반화에 불리하다고 한다. 다만 상위 15개만 남기고 인코딩해보니 문장 대부분이 OOV 인덱스로 채워져서, 자르는 기준을 잘못 잡으면 정보가 통째로 날아간다는 것도 눈으로 확인했다 - 패딩은
post(뒤에 채움)와pre(앞에 채움)를 고를 수 있고,maxlen을 넘는 문장은 잘라낸다. 문장 길이 분포를 히스토그램으로 보고maxlen을 정하는 습관이 필요하다는 얘기가 인상적이었다 — 너무 크게 잡으면 의미 없는 0만 잔뜩 계산하게 된다
padded = pad_sequences(sequences, maxlen=10, padding='post', truncating='post')
one_hot_encoded = to_categorical(padded) # (문장 수, maxlen, vocab_size)원핫 인코딩까지 해보고 나니 왜 다들 임베딩을 쓰는지가 명확해졌다. 단어 하나를 사전 크기만큼의 벡터로 표현하니 차원이 순식간에 커지는데, 그 큰 벡터에 담긴 정보는 "몇 번 단어인가" 하나뿐이라 단어 사이의 의미 관계는 전혀 표현되지 않는다.
03. 워드 클라우드로 본 스팸 문자
전처리 결과를 눈으로 확인하는 용도로 워드 클라우드를 만들어봤다. 스팸 문자 3만 건 정도를 Okt.nouns()로 명사만 추출하고, Counter로 빈도를 센 뒤 불용어를 걸러서 WordCloud에 넘겼다. 한글은 폰트 경로를 지정해줘야 글자가 깨지지 않았고, 마스크 이미지를 넣으면 그 모양대로 단어가 배치됐다.
- "광고", "고객", "할인", "무료", "수신" 같은 단어가 크게 잡히는 걸 보면서, 빈도만 세도 데이터의 성격이 꽤 드러난다는 걸 느꼈다
- 다만 크기가 큰 단어가 곧 중요한 단어는 아니라는 주의사항이 기억에 남는다. 워드 클라우드는 어디까지나 탐색용이고, 긍/부정이나 문맥은 따로 봐야 한다
04. 서브워드 토크나이저 직접 학습시키기
지난주에 개념만 봤던 서브워드 토큰화를 이번엔 직접 학습시켰다. 네이버 영화 리뷰 15만 건을 텍스트 파일로 뽑아서 SentencePiece와 BertWordPieceTokenizer 두 방식으로 각각 vocab 1만 개짜리 사전을 만들고, 같은 문장을 인코딩·디코딩해서 비교해봤다.
cmd = f'--input=naver_review.txt --model_prefix=naver_review --vocab_size=10000'
spt.SentencePieceTrainer.Train(cmd)같은 문장 "걸작은 몇안되고 졸작들만 넘쳐난다."를 두 방식으로 쪼갠 결과가 미묘하게 달랐다.
SentencePiece : ['▁걸작', '은', '▁몇', '안되고', '▁졸작', '들만', '▁넘', '쳐', '난다', '.']
WordPiece : ['걸작', '##은', '몇', '##안되고', '졸작', '##들만', '넘쳐', '##난다', '.']- SentencePiece는 공백을
▁기호로 바꿔서 토큰에 포함시키기 때문에 띄어쓰기 정보가 그대로 보존되고, 디코딩할 때 원문이 정확히 복원됐다. WordPiece는 이어지는 조각에##을 붙이는 방식이라 접근이 다른데 복원 결과는 둘 다 동일했다 - 미리 형태소 분석을 하지 않고 원문을 그대로 넣었는데도 "걸작", "졸작"처럼 의미 단위가 잘 잡히는 게 신기했다. 한국어에서 형태소 분석기 없이 처리할 수 있는 길이 있다는 게 서브워드 방식의 큰 장점으로 느껴졌다
05. 워드 임베딩 — Word2Vec과 FastText
드디어 지난주 목표로 적어뒀던 "TF-IDF의 한계를 보완하는 임베딩"에 도달했다. Word2Vec은 주변 단어로 중심 단어를 맞히거나(CBOW) 중심 단어로 주변 단어를 맞히는(Skip-gram) 얕은 신경망을 학습시키고, 그 과정에서 만들어진 가중치 행렬을 단어 벡터로 쓰는 방식이었다. "정답 라벨을 사람이 붙이지 않고 문장 자체에서 만들어낸다"는 점이 인상적이었다.
- 구글이 뉴스 데이터로 학습해둔 300차원 벡터(300만 단어)를 불러와서
similarity('this', 'is')는 0.41,similarity('post', 'book')은 0.06처럼 단어 쌍의 유사도를 바로 계산해봤다. 사람이 규칙을 정해준 적이 없는데도 관계가 숫자로 나온다는 게 원핫 인코딩과의 결정적인 차이였다 - FastText는 단어를 문자 n-gram 조각으로 쪼개서 학습하기 때문에, 사전에 없는 단어도 조각들의 조합으로 벡터를 만들어낼 수 있다. TED 강연 자막으로 직접 학습시킨 뒤
ultramicrosafe처럼 존재하지 않는 단어를 넣어봤는데 정말 벡터가 나왔다 - 다만 부작용도 같이 보였다.
most_similar('man')의 결과가 woman,batman,hoffman,fireman처럼 "글자가 비슷한 단어"로 채워졌다. 서브워드를 보기 때문에 철자가 겹치면 의미가 달라도 가깝게 나온다는 걸 확인했다
PyTorch에서는 nn.Embedding이 이 역할을 한다. 정수 인덱스를 넣으면 저차원 실수 벡터로 바꿔주는 학습 가능한 룩업 테이블인데, 여기에 미리 학습된 벡터를 넣어주는 것도 해봤다.
self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0)
self.embedding.weight = nn.Parameter(torch.tensor(embedding_matrix, dtype=torch.float))
# self.embedding.weight.requires_grad = False # 고정할지 함께 학습할지 선택06. 순서를 기억하는 모델 — RNN · LSTM · GRU
텍스트가 순차 데이터라는 관점에서 RNN을 배웠다. 이전 시점의 은닉 상태를 현재 입력과 함께 받아 새 은닉 상태를 만들고, 그걸 다음 시점에 넘기는 구조라 "지금까지 읽은 내용을 요약해서 들고 다니는" 느낌이었다. 문제는 시퀀스가 길어질수록 앞쪽 정보가 희미해지는 것(장기 의존성 문제)이었고, 이를 보완한 게 LSTM과 GRU였다.
- LSTM은 셀 상태라는 별도의 기억 통로를 두고, 망각·입력·출력 세 개의 게이트로 무엇을 지우고 무엇을 새로 담을지 조절한다. GRU는 셀 상태 없이 은닉 상태 하나만 쓰고 게이트도 업데이트·리셋 두 개뿐이라 더 가볍다
- PyTorch에서
nn.RNN/LSTM/GRU는 모두(batch, seq_len, feature)를 받아 전체 시점 출력과 마지막 은닉 상태를 함께 돌려준다. 분류처럼 문장 하나에 결과 하나가 필요한 경우엔 마지막 은닉 상태만 꺼내서Linear에 넣으면 된다는 패턴이 반복해서 나왔다 - 양방향(bidirectional)을 켜면 앞→뒤와 뒤→앞 두 방향의 은닉 상태가 나오는데, 이 둘을
torch.cat으로 이어붙여서 다음 층에 넘긴다. 그래서Linear의 입력 크기가hidden_dim * 2가 된다
forward_pass = hidden[-2]
backward_pass = hidden[-1]
hidden_merged = torch.cat((forward_pass, backward_pass), dim=1)
output = self.fc(hidden_merged)IMDB 영화 리뷰로 감성분석을 돌려봤는데, 원핫 인코딩 + 단순 RNN 조합은 테스트 정확도 69.4%에 그쳤고 임베딩 + 양방향 GRU로 바꾸니 70.6%로 조금 올랐다. 두 경우 모두 단어 사전을 상위 300개로 제한한 탓에 성능 자체가 낮게 나온 건데, 모델 구조보다 입력 데이터의 정보량이 먼저라는 걸 숫자로 본 셈이었다.
07. 한국어 리뷰 감성분석과 주가 예측
네이버 영화 리뷰(NSMC) 5만 건으로 한국어 감성분석을 했다. 한글과 공백만 남기고 Okt.morphs(stem=True)로 형태소를 뽑은 뒤 불용어를 제거하고, 사전 크기 2만 · maxlen=15로 맞춰서 양방향 LSTM에 넣었다. 테스트 정확도는 79.6%가 나왔다.
- 직접 문장을 넣어보는 함수까지 만들어서 확인했는데 "오랜만에 감동을 느꼈다~"는 긍정, "감독이 생각이 있나?"는 부정으로 잘 잡았다. 반면 "개구리다"나 "강아지"처럼 감성과 무관한 단어를 넣으면 그냥 부정으로 밀어버렸다. 학습 데이터에 없는 맥락은 판단 근거가 없으니 당연한 결과인데, 모델이 "모르겠다"고 말하지 못한다는 점이 눈에 들어왔다
- 지난주에 아쉬웠던 형태소 분석 오분석은 여기서도 보였다. "찐한 여운과 함께..."가 "찌다 여운 하다"로 분석되는 식이었는데,
stem=True로 원형 복원을 하다 보니 생기는 부작용이었다
같은 LSTM을 텍스트가 아닌 시계열에도 적용해봤다. 네이버 종가 3000일치를 MinMaxScaler로 정규화하고, 20일 구간을 입력으로 다음 날 종가를 맞히는 방식이다. 여기서 Conv1d를 LSTM 앞에 붙이는 구조를 처음 써봤다.
def forward(self, x): # (batch, seq_len, input_size)
x = x.permute(0, 2, 1) # Conv1d는 (batch, channel, seq_len)을 요구
x = self.conv1d(x)
x = x.permute(0, 2, 1)
_, (hidden, _) = self.lstm(x)
output = self.fc2(self.relu(self.fc1(hidden[-1])))
return output- Conv1d가 시퀀스에서 짧은 구간의 패턴을 먼저 뽑아주고, 그 결과를 LSTM이 시간 순서대로 읽는 구조였다. 이미지에서 쓰던 합성곱을 1차원으로 눕히면 시계열에도 쓸 수 있다는 게 새로웠다
- 예측 그래프가 실제 값을 거의 따라가는 것처럼 보였지만, 자세히 보니 "전날 값을 거의 그대로 따라가는" 형태에 가까웠다. 그래프가 예뻐 보인다고 예측이 잘 되는 건 아니라는 걸 염두에 둬야 할 것 같다
08. 텍스트 분류 — 나이브 베이즈부터 사전학습 임베딩까지
나이브 베이즈는 베이즈 정리에 "모든 단어가 서로 독립"이라는 과감한 가정을 얹은 분류기다. 문서에서 각 단어가 특정 클래스에서 나올 확률을 곱해(실제로는 로그로 더해) 가장 높은 클래스를 고르는 방식인데, 20뉴스그룹 데이터에 TF-IDF와 함께 쓰니 20개 클래스 분류에서 정확도 87.8%가 나왔다. 딥러닝 없이 이 정도라는 게 놀라웠다.
log_prob = class_log_prior[class_idx]
for i, count in enumerate(x_vec):
if count > 0:
log_prob += count * feature_log_prob[class_idx][i]사이킷런이 내부에서 하는 계산을 직접 짜서 predict_proba 결과와 맞춰봤는데, 확률을 곱하지 않고 로그로 더하는 이유가 확 와닿았다. 단어가 수백 개면 작은 확률이 계속 곱해져서 값이 0으로 내려앉기 때문이다.
같은 데이터를 LSTM 분류기로도 돌렸다. 임베딩을 랜덤 초기화했을 때는 정확도 83%였는데, 앞에서 학습해둔 FastText 벡터를 임베딩 초기값으로 넣어주자 92%로 올랐다.
self.embedding.weight.data.copy_(torch.from_numpy(embedding_matrix))
self.embedding.weight.requires_grad = True # 초기값만 빌려오고 함께 미세조정마지막으로 다중 라벨 분류도 해봤다. 영화 줄거리에 장르를 여러 개 붙이는 문제인데, 라벨이 배타적이지 않다는 게 핵심이었다. MultiLabelBinarizer로 라벨을 0/1 벡터로 바꾸고, 손실 함수도 CrossEntropyLoss가 아니라 BCEWithLogitsLoss를 써서 각 장르를 독립적인 이진 판단으로 다뤘다. 여기에 BERT로 뽑은 문장 표현을 입력으로 쓰니 "탐정이 살인 사건을 수사한다" 같은 문장에서 Crime·Mystery를 0.95 확률로 잡아냈다.
09. 언어 모델 — N-gram과 퍼플렉서티
"다음에 올 단어를 맞히는" 언어 모델의 가장 단순한 형태인 N-gram부터 시작했다. 바이그램은 직전 한 단어만 보고 다음 단어의 확률을 세는 방식이라, 빈도만 나눠도 P(맑다 | 날씨가) = 1.000 같은 조건부 확률이 바로 나왔다.
모델의 성능은 퍼플렉서티(perplexity)로 측정했다. 낮을수록 좋고, 대략 "모델이 다음 단어를 고를 때 몇 개 중에서 헷갈리는가"로 해석하면 된다는 설명이 직관적이었다. 실제로 학습 문장을 그대로 넣으면 1.0에 가까운 값이 나왔는데, 학습에 없던 조합을 넣자 값이 460만까지 튀었다(아래 트러블슈팅 참고).
이어서 신경망으로 언어 모델을 만드는 NNLM도 봤다. 앞 두 단어의 임베딩을 이어붙여 은닉층을 거쳐 전체 사전 크기만큼의 확률을 출력하는 구조인데, N-gram과 달리 학습에 없던 조합도 임베딩 공간의 유사성을 통해 그럴듯한 확률을 낼 수 있다는 게 차이였다. 마지막으로 바이그램 확률을 이용해 문장을 생성하는 함수도 만들어봤는데, 학습 문장이 워낙 적어서 몇 개 문장이 계속 반복되는 결과가 나왔다.
10. Seq2Seq — 영어를 한국어로
입력과 출력의 길이가 다른 문제를 다루는 인코더-디코더 구조를 배웠다. 인코더 LSTM이 영어 문장을 읽어 마지막 은닉 상태와 셀 상태로 압축하고, 디코더가 그 상태를 초기값으로 받아 한국어를 한 단어씩 생성하는 방식이다. 영어-한국어 문장쌍 5,890개로 번역 모델을 만들어봤다.
- 학습할 때는 티처 포싱(teacher forcing)을 썼다. 디코더 입력에
<sos> 난 널 사랑해, 정답에난 널 사랑해 <eos>를 넣어서 한 칸씩 밀어놓는 방식인데, 모델이 이전 시점에서 틀린 단어를 뱉어도 다음 시점에는 정답을 입력으로 받게 되니 학습이 빠르게 수렴한다 - 그런데 추론할 때는 정답을 줄 수 없다. 그래서
<sos>하나로 시작해 예측한 단어를 다음 입력으로 다시 넣는 별도의 추론용 모델을 따로 만들어야 했다. 학습용 모델과 추론용 모델이 가중치는 공유하되 구조가 다르다는 점이 처음엔 헷갈렸다 - 영어 입력은
pre패딩, 한국어 출력은post패딩을 쓴 이유도 배웠다. 인코더는 마지막 은닉 상태를 쓰니까 문장이 0으로 끝나면 안 되고, 디코더는 앞에서부터 생성하니 시점이 앞에 정렬되는 게 자연스럽다
결과는 솔직히 처참했다. "My hair is black."을 넣으니 "내 내 내 검은색 좀"이 나왔다. 단어는 대충 관련 있는 걸 골랐지만 문장이 되지 않았다. 데이터가 6천 문장뿐이고 인코더가 문장 전체를 고정 크기 벡터 하나에 밀어넣는 구조라는 한계가 겹친 결과였는데, 바로 이 지점이 어텐션이 등장한 이유라는 게 자연스럽게 이어졌다.
비교용으로 사전학습 번역 모델(facebook/nllb-200-distilled-600M)을 불러와서 같은 작업을 시켜봤더니 "she is learning Python Programming"을 "그녀는 파이썬 프로그래밍을 배우고 있습니다."로 매끄럽게 번역했다. 몇 줄 안 되는 코드로 이 정도가 나오는 걸 보고 사전학습 모델의 위력을 실감했다.
11. 어텐션과 트랜스포머
마지막 이틀은 어텐션이었다. 개념은 "지금 만들 단어와 관련 있는 입력 위치에 더 집중한다"인데, 실제 계산은 생각보다 단순했다. 입력에서 Query·Key·Value 세 벡터를 만들고, Q와 K를 내적해 관련도 점수를 낸 뒤 소프트맥스로 가중치를 만들고, 그 가중치로 V를 가중합하는 게 전부였다.
attn_scores = torch.matmul(Q, K.transpose(-2, -1))
attn_scores /= Q.size(-1) ** 0.5 # 차원이 커질수록 값이 커지는 걸 완화
attn_weights = F.softmax(attn_scores, dim=1)
output = torch.matmul(attn_weights, V)- 멀티헤드 어텐션은 이 계산을 여러 개로 쪼개서 병렬로 돌리는 것이었다. 임베딩 차원을 헤드 수로 나눠
view와transpose로 모양만 바꾸면 되고, 계산이 끝나면 다시 이어붙인다. 한 문장 안에서도 관계는 여러 종류라서 어떤 헤드는 시간·장소 단서를, 다른 헤드는 주변 단어 의미를 보게 하는 식으로 나눠 본다는 설명이 납득이 갔다 - RNN처럼 순차적으로 도는 게 아니라 행렬곱 중심이라 GPU에서 병렬 처리가 잘 된다는 점이 트랜스포머의 결정적인 장점이었다
그리고 트랜스포머를 논문 구조대로 직접 조립해봤다. Positional Encoding, Multi-Head Attention, Feed Forward, Layer Normalization을 각각 클래스로 만들고 잔차 연결로 이어서 인코더 층·디코더 층을 쌓았다. d_model=512, 헤드 8개, 층 6개로 만들었더니 파라미터가 4천만 개 넘게 나왔고, (64, 128) 크기 입력을 넣으니 (64, 128, 10000) 형태의 출력이 제대로 나왔다.
- 순서 정보가 없는 구조라서 Positional Encoding으로 위치를 사인·코사인 값으로 만들어 임베딩에 더해준다는 게 핵심이었다. 어텐션은 모든 위치를 동시에 보기 때문에 위치를 알려주지 않으면 단어 순서를 아예 모른다
- 구조에 따라 쓰임이 갈린다는 정리가 유용했다. Encoder-only(BERT)는 문장 이해와 분류, Decoder-only(GPT류)는 텍스트 생성, Encoder-Decoder(T5, NLLB)는 번역·요약에 쓰인다
- 다만 마스킹은 인자로
e_mask,d_mask만 받아두고 실제 적용은 하지 못한 채 넘어갔다. 패딩 위치를 무시하고 디코더가 미래 토큰을 못 보게 막는 부분이 빠져 있어서 아직 절반짜리 구현이다
마지막으로 허깅페이스 pipeline으로 사전학습 모델도 써봤다. KoELECTRA 기반 감성분석 모델은 "대작같은 졸작"을 0.999 확률로 부정이라고 판단했고, Qwen2.5로는 "오늘 학원에서 수업을 듣다가"를 이어서 문장을 생성해봤다. 직접 만든 모델과 비교하니 격차가 꽤 컸다.
KPT 회고
Keep
진도가 빨랐는데도 흐름이 끊기지 않았던 건, 매번 "이전 방식의 한계 → 그걸 해결하는 새 방법"이라는 순서로 이어졌기 때문이었다. 원핫의 한계에서 임베딩으로, TF-IDF의 순서 무시에서 RNN으로, RNN의 장기 의존성 문제에서 LSTM으로, seq2seq의 고정 벡터 한계에서 어텐션으로 넘어가는 과정이 자연스러웠다. 새 기법을 볼 때마다 "이게 뭘 해결하려고 나온 건지"를 먼저 확인하는 습관을 계속 가져가고 싶다.
사이킷런이나 케라스가 대신 해주는 계산을 몇 번 직접 짜본 것도 좋았다. 나이브 베이즈의 로그 확률 합, 어텐션의 QKV 계산, 정수 인코딩과 패딩 함수를 손으로 구현해보니 라이브러리 함수의 인자 하나하나가 무슨 뜻인지 훨씬 잘 읽혔다.
Problem
직접 만든 모델들의 성능이 전반적으로 낮았다. IMDB 감성분석 69~70%, 직접 만든 seq2seq 번역은 문장이 되지 않는 수준이었다. 원인이 데이터 양인지, 하이퍼파라미터인지, 구조 자체의 한계인지 구분하지 못한 채 "원래 이 정도인가 보다" 하고 넘어간 경우가 많았다. 사전 크기를 300으로 잡은 게 결정적이었다는 걸 나중에야 눈치챘다.
트랜스포머를 코드로 조립하긴 했지만 마스킹을 구현하지 못했고, 실제 데이터로 학습시켜보지도 못했다. 랜덤 텐서를 넣어서 출력 shape만 확인하고 끝냈기 때문에 "돌아간다"는 것 외에는 검증한 게 없다.
Try
비워둔 마스킹부터 채워 넣고, 작은 번역 데이터로라도 직접 만든 트랜스포머를 실제로 학습시켜서 이번 주 seq2seq 결과와 비교해보고 싶다. 성능이 안 나올 때 데이터·하이퍼파라미터·구조 중 무엇이 원인인지 하나씩 바꿔가며 확인하는 습관도 같이 들여야겠다. IMDB는 사전 크기만 바꿔서 다시 돌려보면 바로 확인할 수 있을 것 같다.
새롭게 발견한 것
사전학습 임베딩을 초기값으로 넣는 것만으로 정확도가 9%p 올랐다. 20뉴스그룹 3개 카테고리 분류에서 같은 LSTM 구조인데 임베딩을 랜덤으로 초기화하면 83%, 앞서 TED 자막으로 학습해둔 FastText 벡터를 넣으면 92%였다. 모델 구조를 건드린 게 아니라 시작 지점만 바꿨는데 이만큼 차이가 난다는 게 인상적이었다. 데이터가 적을수록 "이미 학습된 것을 빌려오는" 전략이 유효하다는 걸 숫자로 봤다.
FastText가 모르는 단어의 벡터를 만들어내는 대신 치르는 대가가 있다. 존재하지 않는 ultramicrosafe도 벡터가 나오는 건 신기했는데, 대신 man의 유사 단어로 batman, hoffman, fireman이 올라왔다. 단어를 문자 조각으로 보기 때문에 철자가 겹치면 의미가 전혀 달라도 가깝게 나온다. 미등록 단어에 강한 것과 철자에 휘둘리는 것이 같은 원리의 양면이라는 게 재밌었다.
티처 포싱 때문에 학습용 모델과 추론용 모델을 따로 만들어야 한다. 학습할 때는 정답을 한 칸씩 밀어서 디코더에 통째로 넣지만, 추론할 때는 정답이 없으니 <sos> 하나로 시작해 자기 출력을 다시 입력으로 넣는 루프를 돌려야 한다. 가중치는 그대로 공유하면서 입력 구조만 다른 모델을 하나 더 정의한다는 발상이 처음엔 낯설었는데, 학습과 추론의 조건이 다르면 그럴 수밖에 없다는 걸 코드로 확인했다.
어텐션은 결국 행렬곱 세 번이었다. "문맥을 파악한다"는 설명 때문에 복잡한 무언가일 거라 생각했는데, Q·K 내적 → 소프트맥스 → V 가중합이 전부였다. 순차적으로 돌 필요가 없어서 GPU에서 병렬로 처리되고, 그래서 RNN보다 긴 문장을 빠르게 학습할 수 있다는 것까지 이어서 이해가 됐다.
트러블슈팅
문제 : 퍼플렉서티가 460만, 100억까지 튀어올랐다
바이그램 언어 모델의 퍼플렉서티를 계산했는데, 학습 문장을 그대로 넣으면 1.0 근처로 정상이었지만 조금만 다른 문장을 넣으면 값이 폭발했다.
오늘은 날씨가 좋다. Perplexity: 1.0
오늘은 날씨가 맑다. Perplexity: 4641588.83
오늘은 사람이 많다. Perplexity: 10000000000.00원인은 학습 데이터에 없는 바이그램이었다. 학습 문장에 "날씨가 맑다"라는 조합이 한 번도 없으니 빈도가 0이고, 확률이 0이 되면 log2(0)이 정의되지 않는다. 그래서 코드에서 아주 작은 값으로 대체하고 있었다.
prob = bigram_freq.get(bigram, 0) / unigram_freq.get(w1, 1)
if prob == 0:
prob = 1e-10 # 0 대신 넣은 값이 그대로 퍼플렉서티에 반영된다결국 1e-10이라는 임의의 값이 로그로 들어가면서 전체 값을 끌어올린 것이었다. 처음엔 계산식이 틀린 줄 알았는데, 계산은 맞고 모델이 "본 적 없는 조합"을 표현할 방법이 없다는 게 진짜 문제였다. 이걸 다루려고 나온 게 라플라스 스무딩처럼 모든 조합에 아주 작은 빈도를 미리 더해두는 기법이고, 나이브 베이즈에서 봤던 alpha 값이 정확히 같은 역할이라는 걸 뒤늦게 연결했다. 단순 카운트 기반 모델의 근본적인 한계를 숫자로 확인한 셈이었다.
문제 : 다중 라벨 분류에서 예측 결과가 전부 0이 나왔다
영화 줄거리로 장르를 예측하는 모델에서 "An alien spaceship lands in the middle of a war."를 넣었더니 어떤 장르도 선택되지 않았다.
y_pred: [[0 0 0 0 0 0 0]]확률을 직접 찍어보니 이유가 보였다.
[[0.386 0.171 0.444 0.171 0.444 0.386 0.202]]가장 높은 값이 0.444라서 기본 임계값 0.5를 아무것도 넘지 못한 것이었다. 다중 분류에서는 소프트맥스로 확률의 합이 1이 되니 항상 하나는 뽑히지만, 다중 라벨은 각 라벨이 독립적인 이진 판단이라 전부 0.5 아래로 깔리는 게 가능하다는 걸 몰랐다. 학습 데이터가 5건뿐이라 모델이 어느 쪽으로도 확신하지 못한 상태였다.
y_pred_proba = clf.predict_proba(X_test)
y_pred = (y_pred_proba >= 0.3).astype(int) # 임계값을 직접 조정
y_pred_label = mlb.inverse_transform(y_pred)임계값을 0.3으로 낮추니 Action·Sci-Fi가 제대로 잡혔다. 다중 라벨 문제에서는 임계값 자체가 조정해야 할 하이퍼파라미터라는 걸 배웠다. 실제로 BERT 임베딩을 쓴 모델에서는 확률이 0.8 근처까지 올라와서 0.5로도 잘 동작했는데, 같은 문제라도 표현이 좋아지면 임계값 고민이 줄어든다는 것도 같이 확인했다.
다음 주 목표
- 트랜스포머 구현에서 비워둔 마스킹(패딩 마스크·룩어헤드 마스크) 채워 넣고 실제 데이터로 학습시켜보기
- IMDB 감성분석을 사전 크기만 300에서 2만으로 바꿔 다시 돌려보고, 성능 차이가 데이터에서 온 게 맞는지 확인하기
- 사전학습 모델을 그냥 불러다 쓰는 데서 그치지 말고, 내 데이터로 파인튜닝하는 방법 알아보기
아직 절반짜리 구현이 몇 개 남아 있지만, 이번 주에 지나온 길만큼은 확실히 손에 익었다.