노트북:
13-02. lstm_naver_classification.ipynb(코드 셀 73개)
예상 시간: 약 25분 · 짝꿍 글:4.5_LSTM으로_네이버_영화_리뷰_분류하기.md
⏱ 공지대로 세션 시작 직후 학습까지 돌려 두고 설명을 시작한다.
이 노트북은 4.1~4.4를 전부 이어 붙인 것이다. 리뷰 한 줄이 이렇게 흘러간다.
"아 더빙.. 진짜 짜증나네요 목소리" ← 원문
→ "아 더빙 진짜 짜증나네요 목소리" ← 정제: 한글과 공백만 남김 (셀 19)
→ ['아','더','빙','진짜','짜증','나','네요','목소리'] ← 토큰화 (4.3) (셀 26)
→ [342, 1050, 787, 13, 199, 8, 64, 627] ← 정수 인코딩: 단어 집합 번호 (셀 42)
→ [342, 1050, ..., 627, 0, 0, ..., 0] ← 패딩: 길이 30으로 맞춤 (셀 50)
shape (배치 32, 30)
→ nn.Embedding → (32, 30, 100) ← 임베딩 (4.4)
→ nn.LSTM → hidden (1, 32, 128) ← LSTM (4.2), 마지막 은닉 상태만
→ nn.Linear → (32, 2) ← 부정/긍정 점수 → CrossEntropyLoss
(정수 값은 예시다.) 이 그림만 머리에 있으면 73개 셀이 전부 이 중 어딘가에 속한다.
1. 시작하자마자 "모두 실행"을 누르자. 학습 셀(64)은 앞의 전처리 셀이 전부 실행돼야 돈다. 런타임을 GPU로 바꾸고 바로 시작한다. 직접 돌려 보니 토큰화 약 25초, CPU 학습은 에폭당 1분 안팎이었다. 대신 검증·테스트가 느리다(아래 5번).
2. Mecab 설치(셀 0)가 막히면 대안이 있다. 셀 0은 셸 스크립트로 Mecab을 설치하는데, 환경에 따라 오래 걸리거나 실패할 수 있다. 막히면 셀 0과 셀 1의 Mecab 부분을 이렇게 바꾼다.
!pip install python-mecab-ko
from mecab import MeCab
mecab = MeCab() # 셀 25의 mecab = Mecab() 대신
직접 확인한 결과는 이렇다.
3. 셀 23: 테스트 데이터 정제가 조용히 건너뛰어질 수 있다. 셀 23의 str.replace에는 regex=True가 없다. 노트북을 만들 때의 pandas에서는 기본값이 정규식이었다. 저장된 출력에 FutureWarning: The default value of regex will change가 찍혀 있는 게 그 흔적이다. pandas 2.0부터는 기본값이 일반 문자열이라서, [^ㄱ-ㅎㅏ-ㅣ가-힣 ]라는 글자 그대로를 찾다가 아무것도 못 바꾼다. 실제 데이터로 확인했다.
| 남은 테스트 샘플 수 | 문장부호 | |
|---|---|---|
| 노트북 그대로 (최신 pandas) | 49,157 | 남아 있음 (뭐야 이 평점들은....) |
regex=True 추가 | 48,852 (노트북 출력과 같음) | 제거됨 |
→ 셀 23의 두 str.replace에 , regex=True를 붙이자.
4. 셀 20: pandas 3에서는 빈 리뷰가 안 걸러진다. train_data['document'].replace('', np.nan, inplace=True)처럼 열을 꺼낸 뒤 inplace로 바꾸는 코드는 pandas 3에서 원본에 반영되지 않는다(경고만 뜬다). 직접 돌려 보니 걸러져야 할 빈 리뷰 789개가 그대로 남았다. 코랩의 pandas가 2.x면 경고와 함께 동작하지만, 안전하게 고쳐 두자.
train_data['document'] = train_data['document'].replace('', np.nan) # inplace 대신 대입
셀 23의 test_data['document'].replace('', np.nan, inplace=True)도 같다. 코랩 버전은 pd.__version__으로 확인한다.
5. 검증·테스트 로더의 batch_size=1(셀 57)이 느리다. 검증 29,079개와 테스트 48,852개를 한 개씩 모델에 넣는다. 에폭마다 검증이 학습보다 오래 걸릴 수 있다. 시간이 빠듯하면 valid_dataloader의 batch_size를 512 정도로 바꾸자. 정확도 계산 방식은 배치 크기와 상관없게 짜여 있어서 결과가 같다. 손실 평균만 아주 조금 달라진다.
6. 사소한 것.
learning_rate = 0.01은 쓰이지 않는다. 셀 61이 lr=0.001을 직접 쓴다.num_epochs가 셀 58(5)과 셀 59(10)에서 두 번 정해진다. 실제로는 10이 쓰인다.| 파트 | 셀 | 한 줄 메시지 | 시간 |
|---|---|---|---|
| 1. 설치·데이터 로드 | 0~7 | 리뷰 15만 + 5만, 라벨 0/1 | 1분 |
| 2. 정제 | 8~23 | 중복·결측 제거, 한글만 남기기 | 3분 |
| 3. 토큰화·불용어 | 24~28 | Mecab으로 형태소 → 조사 빼기 (4.3) | 2분 |
| 4. 데이터 나누기 | 29~31 | 훈련 8 : 검증 2, 비율 유지 | 1분 |
| 5. 단어 집합 | 32~40 | 빈도순 번호, 희귀 단어 버리기 | 3분 |
| 6. 정수 인코딩 | 41~45 | 단어 → 번호, 모르면 <UNK> | 2분 |
| 7. 패딩 | 46~51 | 길이 30으로 자르고 0으로 채움 | 2분 |
| 8. 텐서·데이터로더 | 52~58 | 2.3의 DataLoader 그대로 | 1분 |
| 9. 모델 | 56, 59~60 | Embedding → LSTM → Linear (4.4 + 4.2) | 4분 |
| 10. 학습 루프 | 61~64 | 2장부터 쓰던 루프 + 체크포인트 | 3분 |
| 11. 평가·예측 | 65~72 | 테스트 84.7%, 새 문장 예측 | 2분 |
| 토론 | — | 뒤 패딩 + 마지막 은닉 상태 | 시간 남으면 |
읽는 법: 🗣 = 그대로 말해도 되는 문장, ❓ = 나올 만한 질문과 답
urllib.request.urlretrieve("https://raw.githubusercontent.com/e9t/nsmc/master/ratings_train.txt", ...)
train_data = pd.read_table('ratings_train.txt') # 탭으로 구분된 파일
test_data = pd.read_table('ratings_test.txt')
id, document(리뷰 본문), label(0 = 부정, 1 = 긍정).pickle은 쓰이지 않는다.train_data['document'].nunique() # 146,182 — 리뷰가 15만인데 고유값은 14만 6천
train_data.drop_duplicates(subset=['document'], inplace=True) # 같은 리뷰 제거 → 146,183
train_data = train_data.dropna(how='any') # 본문이 비어 있는(NaN) 1개 제거 → 146,182
train_data['document'] = train_data['document'].str.replace("[^ㄱ-ㅎㅏ-ㅣ가-힣 ]", "", regex=True)
[^...] = "대괄호 안에 없는 것". ㄱ-ㅎ(자음), ㅏ-ㅣ(모음), 가-힣(완성형 한글), 공백만 남기고 다 지운다. 영어, 숫자, 문장부호, 이모티콘이 빠진다.[^a-zA-Z ])로 먼저 보여준 것이다.ㅋㅋ!!나 10점 같은 것)를 NaN으로 바꾼다. 셀 22에서 지운다 → 145,393개. (⚠️ 발표 전 체크 4번)🗣 "테스트 데이터도 훈련 데이터와 똑같이 처리해야 해요. 훈련 때 문장부호를 다 지웠는데 테스트에 남아 있으면, 모델이 처음 보는 토큰이 잔뜩 들어가게 됩니다. 그래서 셀 23에 regex=True가 빠진 게 생각보다 중요해요."
stopwords = ['도', '는', '다', '의', '가', '이', '은', '한', '에', '하', '고', '을', '를', ...]
mecab = Mecab()
X_train = []
for sentence in tqdm(train_data['document']):
tokenized_sentence = mecab.morphs(sentence) # 형태소 토큰화 (4.3)
stopwords_removed_sentence = [w for w in tokenized_sentence if not w in stopwords] # 불용어 제거
X_train.append(stopwords_removed_sentence)
mecab.morphs() → 4.3의 okt.morphs()와 같은 역할이다. 리뷰 14만 5천 개가 약 10초 걸린다.['너무', '재', '밓었다그래서보는것을추천한다']. 원문의 오타(재밓었다)와 띄어쓰기 없음 때문에 형태소 분석이 실패해서, 뒷부분이 통째로 한 토큰이 됐다. 이런 토큰은 거의 한 번만 나와서 5부에서 버려진다. 진짜 데이터는 지저분하다.y_train = np.array(train_data['label'])
X_train, X_valid, y_train, y_valid = train_test_split(X_train, y_train, test_size=0.2, random_state=0, stratify=y_train)
stratify=y_train → 나눈 뒤에도 부정/긍정 비율이 같게 유지한다. 셀 31에서 훈련·검증 모두 부정 50.24%로 같다.random_state=0 → 매번 같은 방식으로 나눈다(재현성).word_counts = Counter(word_list) # 단어별 등장 횟수 → 45,296종
vocab = sorted(word_counts, key=word_counts.get, reverse=True) # 많이 나온 순서로 정렬
# 셀 35: 등장 2번 이하인 희귀 단어 26,105개 → 단어 종류의 57.6%, 전체 등장의 2.3%
vocab = vocab[:vocab_size] # 상위 19,191개만 남김
word_to_index = {'<PAD>': 0, '<UNK>': 1}
for index, word in enumerate(vocab):
word_to_index[word] = index + 2 # 빈도 1위 '영화' → 2
영화 → 2(1위).<PAD>=0, <UNK>=1. 4.4 노트북과 번호가 반대라는 점에 주의하자.vocab_size = 19,191 + 2 = 19,193 → 임베딩 테이블의 행 수가 된다.🗣 "단어 종류의 절반 넘게가 두 번 이하로만 나오는 단어예요. 그런데 다 합쳐도 전체 등장의 2%밖에 안 돼서, 과감하게 버리고 UNK로 처리합니다."
def texts_to_sequences(tokenized_X_data, word_to_index):
encoded_X_data = []
for sent in tokenized_X_data:
index_sequences = []
for word in sent:
try:
index_sequences.append(word_to_index[word]) # 단어 → 번호
except KeyError:
index_sequences.append(word_to_index['<UNK>']) # 없는 단어 → 1
encoded_X_data.append(index_sequences)
return encoded_X_data
try/except와 완전히 같은 구조다.index_to_word)을 만들어 되돌려 본다. 원래 토큰과 같으면 인코딩이 맞게 된 것이다.# 셀 46: 최대 길이 74, 평균 12.3
max_len = 30 # 셀 48: 길이 30 이하가 92.5%
def pad_sequences(sentences, max_len):
features = np.zeros((len(sentences), max_len), dtype=int) # 전부 0(<PAD>)으로 시작
for index, sentence in enumerate(sentences):
if len(sentence) != 0:
features[index, :len(sentence)] = np.array(sentence)[:max_len] # 앞에서부터 채움
return features
(배치, 시점) 텐서로 묶인다. 그래서 길이를 30으로 맞춘다.np.zeros(...) → 0(<PAD>)으로 채운 판을 만든다.features[index, :len(sentence)] = ...[:max_len] →(116314, 30), 검증 (29079, 30), 테스트 (48852, 30).if len(sentence) != 0 → 토큰이 하나도 없는 리뷰는 전부 0인 줄이 된다.❓ 왜 30? 30이면 92.5%의 리뷰가 잘리지 않는다. 74로 하면 하나도 안 잘리지만, 대부분이 PAD인 계산을 하게 된다. 길이와 손실 사이의 타협이다.
encoded_train = torch.tensor(padded_X_train).to(torch.int64) # 정수형 (임베딩 입력이니까)
train_dataset = torch.utils.data.TensorDataset(encoded_train, train_label_tensor)
train_dataloader = torch.utils.data.DataLoader(train_dataset, shuffle=True, batch_size=32)
TensorDataset + DataLoader 그대로다..to(torch.int64) → 임베딩 층의 입력은 정수(Long) 여야 한다(4.4).(32, 30) 정수 + (32,) 라벨. 배치 수는 116,314 / 32 → 3,635(셀 58).batch_size=1이다. (⚠️ 발표 전 체크 5번)class TextClassifier(nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_dim, output_dim):
super(TextClassifier, self).__init__()
self.embedding = nn.Embedding(vocab_size, embedding_dim) # 4.4
self.lstm = nn.LSTM(embedding_dim, hidden_dim, batch_first=True) # 4.2
self.fc = nn.Linear(hidden_dim, output_dim) # 2장
def forward(self, x):
embedded = self.embedding(x)
lstm_out, (hidden, cell) = self.lstm(embedded) # LSTM은 튜플로 받는다 (4.2)
last_hidden = hidden.squeeze(0)
logits = self.fc(last_hidden)
return logits
하이퍼파라미터(셀 59): embedding_dim=100, hidden_dim=128, output_dim=2
shape 따라가기 (배치 32 기준)
| 단계 | 코드 | shape | 근거 |
|---|---|---|---|
| 입력 | x | (32, 30) | 패딩된 정수 |
| 임베딩 | self.embedding(x) | (32, 30, 100) | 4.4: 정수 → 100차원 벡터 |
| LSTM 모든 시점 | lstm_out | (32, 30, 128) | 4.1: outputs. 쓰지 않는다 |
| LSTM 마지막 은닉 | hidden | (1, 32, 128) | 4.1: 맨 앞은 층 수 |
| 층 차원 제거 | hidden.squeeze(0) | (32, 128) | |
| 출력 | self.fc(...) | (32, 2) | 부정/긍정 점수(로짓) |
squeeze(0) → 0번 축의 크기 1을 없앤다. (1, 32, 128) → (32, 128). 층이 하나라서 가능하다. 여러 층이면 hidden[-1]을 쓴다.CrossEntropyLoss(2.4 소프트맥스 회귀)로 2개 클래스를 분류한다. 시그모이드 1칸으로 해도 된다.파라미터 수 (직접 셈)
| 층 | 계산 | 개수 | 비율 |
|---|---|---|---|
| Embedding | 19,193 × 100 | 1,919,300 | 94% |
| LSTM | 4 × (128×100 + 128×128 + 128 + 128) | 117,760 | 6% |
| Linear | 128 × 2 + 2 | 258 | 0.01% |
| 합계 | 2,037,318 |
🗣 "모델 코드는 딱 세 층이에요. 4.4의 임베딩이 정수를 벡터로 바꾸고, 4.2의 LSTM이 리뷰를 끝까지 읽고, 마지막 은닉 상태 하나를 Linear가 긍정·부정 점수로 바꿉니다. 파라미터는 94%가 임베딩 표예요."
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
for epoch in range(num_epochs): # 10
model.train()
for batch_X, batch_y in train_dataloader:
batch_X, batch_y = batch_X.to(device), batch_y.to(device)
logits = model(batch_X) # 순전파
loss = criterion(logits, batch_y) # 손실
optimizer.zero_grad() # 기울기 초기화
loss.backward() # 역전파
optimizer.step() # 가중치 갱신
val_loss, val_accuracy = evaluate(model, valid_dataloader, criterion, device)
if val_loss < best_val_loss: # 검증 손실이 가장 낮을 때만
torch.save(model.state_dict(), 'best_model_checkpoint.pth') # 저장
calculate_accuracy → argmax로 큰 점수 쪽을 예측으로 보고, 맞은 비율을 센다.evaluate → model.eval() + torch.no_grad(). 평가할 때는 기울기를 안 만든다.저장된 학습 로그 (GPU)
| 에폭 | 훈련 손실 | 훈련 정확도 | 검증 손실 | 검증 정확도 | 저장 |
|---|---|---|---|---|---|
| 1 | 0.476 | 74.5% | 0.367 | 83.4% | ✔ |
| 2 | 0.320 | 86.1% | 0.342 | 84.8% | ✔ |
| 3 | 0.259 | 89.3% | 0.346 | 85.1% | |
| 5 | 0.155 | 94.2% | 0.417 | 84.6% | |
| 10 | 0.050 | 98.3% | 0.679 | 84.0% |
🗣 "학습 루프는 2장부터 쓰던 그 다섯 줄이에요. 로그를 보면 2 에폭부터 훈련 정확도만 오르고 검증 손실은 커지죠. 전형적인 과적합이라, 검증 손실이 제일 낮았던 2 에폭 모델을 저장해서 씁니다."
❓ 그럼 2 에폭만 돌리면 되잖아? 결과적으로는 그렇다. 하지만 몇 에폭이 최적인지는 돌려 봐야 안다. 그래서 넉넉히 돌리고 최적 시점을 저장한다. 검증 손실이 몇 번 연속 안 좋아지면 멈추는 조기 종료(early stopping) 를 쓰면 시간을 아낄 수 있다.
model.load_state_dict(torch.load('best_model_checkpoint.pth')) # 2 에폭 모델 불러오기
evaluate(model, valid_dataloader, ...) # 검증 84.83%
evaluate(model, test_dataloader, ...) # 테스트 84.72%
def predict(text, model, word_to_index, index_to_tag):
model.eval()
tokens = mecab.morphs(text) # 토큰화
tokens = [word for word in tokens if not word in stopwords] # 불용어 제거
token_indices = [word_to_index.get(token, 1) for token in tokens] # 정수 인코딩 (없으면 1=UNK)
input_tensor = torch.tensor([token_indices], dtype=torch.long).to(device) # (1, 길이)
with torch.no_grad():
logits = model(input_tensor)
return index_to_tag[torch.argmax(logits, dim=1).item()]
word_to_index.get(token, 1) → 6부의 try/except를 한 줄로 쓴 것이다.이 영화 개꿀잼 ㅋㅋㅋ → 긍정, 이딴게 영화냐 ㅉㅉ → 부정 등.ㅋㅋㅋ는 한글 자모라 남지만, 영어나 숫자가 섞이면 훈련 때와 다른 토큰이 들어간다.| 1 에폭 검증 | 2 에폭 검증 | |
|---|---|---|
| 뒤 패딩 (노트북) | 83.4% | 84.9% |
| 앞 패딩 | 84.0% | 85.0% |
차이는 작다. 1회 실행이라 우연일 수도 있다. "LSTM이 PAD를 무시하는 법을 꽤 잘 배운다"는 쪽에 가깝다.
nn.Embedding(vocab_size, embedding_dim, padding_idx=0) → PAD를 0 벡터로 고정한다(4.4).pack_padded_sequence → 진짜 길이까지만 LSTM이 읽게 한다.🗣 "이 모델은 짧은 리뷰를 읽고 나서 빈칸을 십여 개 더 읽은 다음의 기억으로 판단해요. 그래도 잘 되는 건, LSTM이 빈칸에서는 기억을 건드리지 않는 법을 배우기 때문이에요. 지난주 게이트 이야기가 여기서 실제로 쓰이는 셈입니다."
🗣 "오늘 4.1부터 4.4까지 본 게 이 노트북 하나에 다 들어 있어요. 리뷰를 정제하고, Mecab으로 토큰화하고, 단어 집합 번호로 바꾸고, 길이 30으로 패딩합니다. 그다음 임베딩, LSTM, Linear를 거쳐 긍정·부정을 고르고, 테스트에서 84.7%가 나왔어요. 2 에폭부터는 과적합이라 체크포인트로 가장 좋았던 모델을 썼습니다."
shape 치트시트
| 단계 | shape |
|---|---|
| 패딩된 입력 | (배치, 30) 정수 |
| 임베딩 후 | (배치, 30, 100) |
LSTM lstm_out | (배치, 30, 128) — 안 씀 |
LSTM hidden | (1, 배치, 128) |
hidden.squeeze(0) | (배치, 128) |
| fc 출력 (로짓) | (배치, 2) |
| 라벨 | (배치,) 0 또는 1 |
고쳐 두면 좋은 셀 요약
| 셀 | 고칠 것 | 이유 |
|---|---|---|
| 0, 1, 25 | (막히면) python-mecab-ko | Mecab 설치 실패 대비 |
| 20, 23 | x = x.replace('', np.nan) | pandas 3에서 inplace가 안 먹음 |
| 23 | str.replace(..., regex=True) | pandas 2+에서 정제가 건너뛰어짐 |
| 57 | 검증 로더 batch_size=512 | 평가 시간 단축 |