텍스트용 CNN과 RNN (CNNs and RNNs for Text)
합성곱(Convolution)은 n-그램(n-gram)을 학습합니다. 순환(Recurrence)은 기억합니다. 둘 다 어텐션(Attention)에 의해 대체되었지만, 제약이 있는 하드웨어(hardware)에서는 여전히 중요합니다.
유형: Build
언어: Python
선수 강의: Phase 3 · 11 (PyTorch Intro), Phase 5 · 03 (Word Embeddings), Phase 4 · 02 (Convolutions from Scratch)
예상 시간: 약 75분
학습 목표
- 텍스트용 CNN(TextCNN)이 단어 임베딩(word embedding) 시퀀스 위에서 학습 가능한 n-그램 검출기(learnable n-gram detector)처럼 동작하는 방식을 이해합니다.
- 순환 신경망(RNN), 장단기 기억(LSTM), 게이트 순환 유닛(GRU)의 은닉 상태(hidden state)와 게이팅(gating)이 해결하려는 문제를 설명합니다.
- PyTorch로 TextCNN과 LSTM 분류기(classifier)의 핵심 구조를 작성합니다.
- 어텐션(attention)이 왜 필요해졌는지, RNN 계열의 병목(bottleneck)을 설명합니다.
문제
TF-IDF와 Word2Vec은 단어 순서(word order)를 무시하는 평탄한 벡터(flat vector)를 만들었습니다. 그 위에 만든 분류기는 dog bites man과 man bites dog를 구분하지 못합니다. 단어 순서가 신호인 경우가 분명히 존재합니다.
트랜스포머(Transformer)가 등장하기 전에는 두 가지 아키텍처(architecture) 계열이 이 간극을 메웠습니다.
텍스트용 합성곱 신경망(Convolutional nets for text; TextCNN). 단어 임베딩 시퀀스 위에 1차원 합성곱(1D convolution)을 적용합니다. 너비(width) 3인 필터(filter)는 학습 가능한 트라이그램(trigram) 검출기 역할을 합니다. 즉, 세 단어에 걸쳐 점수를 출력합니다. 너비를 (2, 3, 4, 5)처럼 다양하게 쌓아 여러 규모(multi-scale)의 패턴을 잡아내고, 최대 풀링(max-pool)으로 고정된 크기의 표현(fixed-size representation)을 만듭니다. 구조가 평평하고, 병렬화가 가능하며, 빠르게 동작합니다.
순환 신경망(Recurrent nets; RNN, LSTM, GRU). 토큰(token)을 하나씩 처리하며 은닉 상태에 정보를 누적해 전달합니다. 순차적이고, 메모리를 가지며, 입력 길이를 유연하게 다룹니다. 2014년부터 2017년까지 시퀀스 모델링(sequence modeling)을 지배했고, 그 뒤에 어텐션이 등장했습니다.
이번 강의에서는 두 가지를 모두 직접 만들고, 어텐션을 부르게 된 한계까지 짚어 봅니다.
개념

TextCNN (Kim, 2014). 토큰이 임베딩으로 변환됩니다. 너비 k의 1차원 합성곱이 연속된 k-그램 임베딩 위로 필터를 미끄러뜨리며 특징 지도(feature map)를 만듭니다. 그 특징 지도 위에서 전역 최대 풀링(global max-pooling)이 가장 강한 활성값을 골라냅니다. 여러 필터 너비의 최대 풀링 결과를 이어 붙인(concatenate) 뒤 분류기 헤드(classifier head)에 넣습니다.
작동 원리는 다음과 같습니다. 필터 하나가 학습 가능한 n-그램입니다. 최대 풀링은 위치에 불변(position-invariant)이므로, "not good"이라는 표현이 리뷰의 앞에 있든 중간에 있든 같은 특징이 켜집니다. 필터 너비를 세 가지 쓰고 각 너비당 필터를 100개 두면 학습된 n-그램 검출기 300개를 얻게 됩니다. 시간축의 순차 의존성이 없어 학습이 병렬화됩니다.
RNN. 각 시점(time step) t에서 은닉 상태가 h_t = f(W * x_t + U * h_{t-1} + b)로 갱신됩니다. W, U, b는 시점 전체에 걸쳐 공유합니다. 시점 T의 은닉 상태는 그 앞쪽 전체 시퀀스의 요약입니다. 분류에는 h_1 ... h_T 위로 풀링(최대, 평균, 또는 마지막)을 걸어 사용합니다.
기본 형태의 RNN은 그래디언트 소실(vanishing gradient) 문제를 겪습니다. LSTM(Long Short-Term Memory) 은 무엇을 잊고, 무엇을 저장하고, 무엇을 내보낼지 결정하는 게이트(gate)를 더해 긴 시퀀스에서도 그래디언트가 안정적으로 흐르도록 합니다. GRU(Gated Recurrent Unit) 는 LSTM을 두 개의 게이트로 단순화한 구조이며, 파라미터(parameter)는 적지만 비슷한 성능을 냅니다.
양방향 RNN(Bidirectional RNN) 은 정방향 RNN과 역방향 RNN을 함께 실행해 두 은닉 상태를 이어 붙입니다. 각 토큰의 표현이 왼쪽 문맥과 오른쪽 문맥을 모두 보기 때문에, 태깅(tagging) 작업에서는 사실상 필수입니다.
직접 만들기
Step 1: PyTorch로 TextCNN 만들기
import torch
import torch.nn as nn
import torch.nn.functional as F
class TextCNN(nn.Module):
def __init__(self, vocab_size, embed_dim, n_classes, filter_widths=(2, 3, 4), n_filters=64, dropout=0.3):
super().__init__()
self.embed = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
self.convs = nn.ModuleList([
nn.Conv1d(embed_dim, n_filters, kernel_size=k)
for k in filter_widths
])
self.dropout = nn.Dropout(dropout)
self.fc = nn.Linear(n_filters * len(filter_widths), n_classes)
def forward(self, token_ids):
x = self.embed(token_ids).transpose(1, 2)
pooled = []
for conv in self.convs:
c = F.relu(conv(x))
p = F.max_pool1d(c, c.size(2)).squeeze(2)
pooled.append(p)
h = torch.cat(pooled, dim=1)
return self.fc(self.dropout(h))
transpose(1, 2)는 [batch, seq_len, embed_dim]을 [batch, embed_dim, seq_len]으로 바꿉니다. nn.Conv1d가 가운데 축을 채널(channel)로 보기 때문입니다. 풀링을 거친 출력은 입력 길이와 무관하게 고정 크기로 떨어집니다.
Step 2: LSTM 분류기
class LSTMClassifier(nn.Module):
def __init__(self, vocab_size, embed_dim, hidden_dim, n_classes, bidirectional=True, dropout=0.3):
super().__init__()
self.embed = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
self.lstm = nn.LSTM(embed_dim, hidden_dim, batch_first=True, bidirectional=bidirectional)
factor = 2 if bidirectional else 1
self.dropout = nn.Dropout(dropout)
self.fc = nn.Linear(hidden_dim * factor, n_classes)
def forward(self, token_ids):
x = self.embed(token_ids)
out, _ = self.lstm(x)
pooled = out.max(dim=1).values
return self.fc(self.dropout(pooled))
분류에서는 마지막 은닉 상태만 가져오는 대신 시퀀스 전체 위로 최대 풀링을 거는 방식이 보통 더 잘 동작합니다. 긴 시퀀스에서는 끝부분 정보가 마지막 상태를 지나치게 지배할 수 있기 때문입니다.
Step 3: 그래디언트 소실 시뮬레이션(직관 잡기)
게이트가 없는 기본 RNN은 멀리 떨어진 의존 관계를 잘 학습하지 못합니다. 시퀀스 어디든 토큰 A가 등장했는지 맞히는 작은 작업(toy task)을 생각해 봅니다. A가 1번째 위치에 있고 시퀀스 길이가 100 토큰이라면, 손실의 그래디언트는 순환 가중치(recurrent weight)를 99번 곱해 거슬러 올라가야 합니다. 가중치가 1보다 작으면 그래디언트가 사라지고, 1보다 크면 폭발합니다.
def vanishing_gradient_sim(seq_len, recurrent_weight=0.9):
import math
return math.pow(recurrent_weight, seq_len)
LSTM은 셀 상태(cell state)를 두어 이 문제를 해결합니다. 셀 상태는 덧셈 위주의 연산을 통해 네트워크 전체를 가로지르며 흐릅니다(망각 게이트가 곱셈으로 조절하기는 하지만, 그래디언트는 마치 "고속도로(highway)" 같은 경로를 따라 안정적으로 전달됩니다). GRU도 더 적은 파라미터로 비슷한 효과를 냅니다. 두 구조 모두 100단계 이상의 시퀀스에서 안정적인 학습을 가능하게 합니다.
Step 4: 그래도 충분하지 않았던 이유
LSTM까지 등장한 뒤에도 세 가지 문제는 그대로 남아 있었습니다.
- 순차 처리 병목(Sequential bottleneck). 길이 1000인 시퀀스에서 RNN을 학습하려면 1000개의 시점을 직렬로 순방향/역방향으로 통과시켜야 합니다. 시간축 방향으로는 병렬화할 수 없습니다.
- 인코더-디코더(encoder-decoder)의 고정 크기 문맥 벡터(fixed-size context vector). 디코더(decoder)는 인코더(encoder)의 마지막 은닉 상태 하나만 보고 동작하므로, 입력이 길면 세부 정보가 사라집니다. 이 부분은 Lesson 09에서 직접 다룹니다.
- 장거리 의존성의 정확도 한계(distant-dependency accuracy ceiling). LSTM이 기본 RNN보다는 낫지만, 200단계 이상을 건너 특정 정보를 그대로 전달하는 데에는 한계가 있습니다.
어텐션은 이 세 가지 문제를 한꺼번에 해결했습니다. 트랜스포머는 아예 순환 구조 자체를 버렸습니다. Lesson 10이 그 전환점입니다.
사용해보기
PyTorch의 nn.LSTM, nn.GRU, nn.Conv1d는 그대로 프로덕션에 투입할 수 있는 수준입니다. 학습 코드도 표준적인 형태로 작성합니다.
Hugging Face는 입력 층으로 그대로 끼워 넣을 수 있는 사전학습(pretrained) 임베딩을 제공합니다.
from transformers import AutoModel
encoder = AutoModel.from_pretrained("bert-base-uncased")
for param in encoder.parameters():
param.requires_grad = False
class BertCNN(nn.Module):
def __init__(self, n_classes, filter_widths=(2, 3, 4), n_filters=64):
super().__init__()
self.encoder = encoder
self.convs = nn.ModuleList([nn.Conv1d(768, n_filters, kernel_size=k) for k in filter_widths])
self.fc = nn.Linear(n_filters * len(filter_widths), n_classes)
def forward(self, input_ids, attention_mask):
with torch.no_grad():
out = self.encoder(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state
x = out.transpose(1, 2)
pooled = [F.max_pool1d(F.relu(conv(x)), kernel_size=conv(x).size(2)).squeeze(2) for conv in self.convs]
return self.fc(torch.cat(pooled, dim=1))
제약 조건에 맞을 때 골라 쓰는 체크리스트입니다.
- 엣지(Edge)/온디바이스(on-device) 추론. GloVe 임베딩을 결합한 TextCNN은 트랜스포머보다 10~100배 더 작습니다. 배포 대상이 휴대폰이라면 이 스택이 정답에 가깝습니다.
- 스트리밍(Streaming)/온라인(online) 분류. RNN은 토큰을 한 번에 하나씩 처리할 수 있지만, 트랜스포머는 시퀀스 전체가 필요합니다. 실시간으로 들어오는 텍스트에는 여전히 LSTM이 유리합니다.
- 베이스라인용 초소형 모델(tiny model). 새로운 작업에서 빠르게 반복 실험할 때 좋습니다. CPU에서도 TextCNN은 5분이면 학습이 끝납니다.
- 데이터가 적은 시퀀스 라벨링(sequence labeling). BiLSTM-CRF(lesson 06)는 라벨이 1k~10k 문장 수준일 때 여전히 프로덕션급 개체명 인식(Named Entity Recognition; NER) 아키텍처입니다.
이 외에는 대부분 트랜스포머로 갑니다.
산출물 만들기
outputs/prompt-text-encoder-picker.md에 저장합니다.
---
name: text-encoder-picker
description: Pick a text encoder architecture for a given constraint set.
phase: 5
lesson: 08
---
Given constraints (task, data volume, latency budget, deploy target, compute budget), output:
Guide the student in Korean.
1. Encoder architecture: TextCNN, BiLSTM, BiLSTM-CRF, transformer fine-tune, or "use a pretrained transformer as a frozen encoder + small head".
2. Embedding input: random init, GloVe / fastText frozen, or contextualized transformer embeddings.
3. Training recipe in 5 lines: optimizer, learning rate, batch size, epochs, regularization.
4. One monitoring signal. For RNN/CNN models: attention mechanism absence means they miss long-range deps; check per-length accuracy. For transformers: fine-tuning collapse if LR too high; check train loss.
Refuse to recommend fine-tuning a transformer when data is under ~500 labeled examples without showing that a TextCNN / BiLSTM baseline has plateaued. Flag edge deployment as needing architecture-before-everything.
연습문제
- 쉬움. 3-클래스(3-class) 작은 데이터셋을 직접 만들어 TextCNN을 학습합니다. 필터 너비 (2, 3, 4)를 함께 쓰는 쪽이 단일 너비 (3)보다 평균 F1에서 우위를 보이는지 확인합니다.
- 중간. LSTM 분류기에 최대 풀링(max-pool), 평균 풀링(mean-pool), 마지막 상태 풀링(last-state pooling)을 모두 구현해 작은 데이터셋에서 비교합니다. 어떤 풀링이 이겼는지, 왜 그렇게 됐는지 가설을 정리합니다.
- 어려움. Lesson 06과 결합해 BiLSTM-CRF 개체명 인식기를 만듭니다. CoNLL-2003에서 학습한 뒤, lesson 06의 CRF 단독 베이스라인 및 BERT 미세조정과 비교해 학습 시간, 메모리, F1을 보고합니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 텍스트용 CNN(TextCNN) | 텍스트용 CNN | 단어 임베딩 위의 1차원 합성곱과 전역 최대 풀링을 쌓은 구조. Kim (2014). |
| 순환 신경망(RNN; Recurrent Neural Network) | 순환 네트워크 | 매 시점마다 은닉 상태를 갱신하는 구조: h_t = f(W x_t + U h_{t-1}). |
| 장단기 기억(LSTM; Long Short-Term Memory) | 게이트 달린 RNN | 입력/망각/출력 게이트와 셀 상태를 더해 긴 시퀀스에서도 안정적으로 학습되는 구조. |
| 게이트 순환 유닛(GRU; Gated Recurrent Unit) | 더 단순한 LSTM | 게이트가 세 개에서 두 개로 줄었지만 비슷한 정확도를 더 적은 파라미터로 달성. |
| 양방향(Bidirectional) | 양쪽 방향 | 정방향 RNN과 역방향 RNN을 이어 붙여 모든 토큰이 양쪽 문맥을 함께 보도록 함. |
| 그래디언트 소실(Vanishing gradient) | 학습 신호가 죽음 | 기본 RNN에서 1 미만 가중치가 반복 곱해지면서 초기 시점의 그래디언트가 사실상 0이 되는 현상. |
더 읽을거리