《BERT》: 언어 이해 사전학습 패러다임의 확립

2026-01-31 · 1274 자 · 5 분

사전학습 패러다임의 확립, 실제 Python 코드 예시 포함

BERT의 목표는 더 깊은 Transformer를 만드는 것이 아니었습니다. NLP 과제가 오랫동안 조각나 있던 문제를 겨냥했습니다. 질의응답, 분류, sequence labeling마다 모델 형태와 데이터 인터페이스가 달랐고, 모델이 배운 언어 지식을 깔끔하게 재사용하기 어려웠습니다.

《BERT》의 핵심은 bidirectional Transformer만이 아닙니다. 언어 이해 과제를 pretrained representation의 재사용 문제로 통일했다는 데 있습니다. 먼저 범용 표현을 학습하고, 얇은 task layer로 각 문제에 맞춥니다. 이 전환이 구조 자체보다 더 중요했습니다.

0. 먼저 몇 가지 용어부터

대형 언어 모델의 학습 흐름이 낯설다면, 이 논문에서 가장 중요한 아래 용어만 먼저 잡아두면 된다:

  • Transformer: BERT가 사용하는 기본 아키텍처다. 문장의 좌우 문맥을 함께 보며 처리하는 기계라고 생각하면 된다.
  • pre-training / 사전 학습: 특정 작업부터 배우는 것이 아니라, 방대한 일반 텍스트에서 언어 자체를 먼저 배우는 단계다.
  • fine-tuning / 파인튜닝: 사전 학습으로 얻은 능력을 특정 작업에 맞게 조금 더 조정하는 단계다.
  • bidirectional / 양방향: 어떤 위치를 이해할 때 왼쪽만 보는 것이 아니라 오른쪽도 함께 본다는 뜻이다.
  • MLM / Masked Language Model: 일부 단어를 가리고, 주변 문맥만 보고 그 단어를 맞히게 하는 학습 방식이다.
  • NSP / Next Sentence Prediction: 두 문장이 실제로 이어지는 문장인지 판단하게 하는 학습 방식이다.

1. 문제

2018년, NLP는 어색한 현실에 놓여 있었다: 모든 태스크마다 전용 모델 구조를 따로 설계해야 했다. 질의응답에는 하나의 모델이 필요하고, 감성 분석에는 또 다른 모델이, 개체명 인식에는 또 다른 모델이 필요했다. 각 태스크의 레이블 데이터는 부족했고, 하나의 태스크에서 학습한 모델을 다른 태스크로 전이하기도 어려웠다.

이미 pre-training 시도는 있었다. ELMo는 양방향 LSTM을 사용해 문맥 표현을 학습했지만, pre-training된 피처를 태스크별 아키텍처에 “덧붙이는” 방식이었을 뿐 — 아키텍처 자체는 여전히 태스크 전용이었다. OpenAI GPT는 Transformer를 사용해 pre-training과 fine-tuning을 수행했지만, 왼쪽에서 오른쪽으로만 볼 수 있었다(단방향) — 각 토큰은 자신보다 앞에 있는 토큰만 참조할 수 있었고, 뒤에 있는 토큰은 볼 수 없었다.

논문은 단방향 언어 모델이 깊은 양방향 문맥을 필요로 하는 언어 이해 태스크에서 상당한 한계가 있다고 주장했다. 예를 들어:

“He picked up the _____ and started playing.”

왼쪽 문맥(“He picked up the”)만 보면 답은 무엇이든 될 수 있다. 하지만 오른쪽 문맥(“and started playing”)을 보면, 어떤 악기라는 것을 바로 알 수 있다. 많은 언어 이해 태스크에서 양방향 문맥은 본질적으로 더 유리하다.

2. 핵심 아이디어: 단어 일부를 가리고 모델이 맞추게 하기

BERT의 해법은 직관적이다: 양방향 언어 모델을 전통적인 방식으로 학습시킬 수 없으므로(각 단어가 간접적으로 “자기 자신을 볼” 수 있게 되므로), 학습 목표를 바꾼다.

Masked Language Model (MLM): 입력 토큰의 15%를 무작위로 마스킹한다 — 구체적으로는 특수 [MASK] 토큰으로 대체한다 — 그런 다음 모델이 문맥으로부터 마스킹된 단어를 예측하게 한다. 이 아이디어는 심리학의 Cloze 테스트(1953년 Taylor가 제안)에서 유래한 것으로, 위의 빈칸 채우기 문제와 같은 원리다.

마스킹 후 모델은 왼쪽과 오른쪽 문맥을 모두 사용해야 예측할 수 있으므로, 양방향 이해가 자연스럽게 나타난다.

그러나 선택된 모든 토큰을 [MASK]로 대체하면 문제가 생긴다: [MASK]는 fine-tuning 중에는 나타나지 않으므로, pre-training과 fine-tuning 사이에 불일치가 발생한다. 논문의 해법: 선택된 15%의 토큰 중, 80%는 [MASK]로 대체하고, 10%는 임의의 토큰으로 대체하며, 10%는 그대로 둔다. 이렇게 하면 모델은 단순히 “[MASK]가 보이니 예측해야 한다”에 의존할 수 없고, 모든 위치에서 이해를 유지해야 한다.

Python
import random
from typing import Optional, Sequence
def mask_tokens(
tokens: Sequence[str],
mask_prob: float = 0.15,
vocab: Optional[Sequence[str]] = None,
) -> tuple[list[str], list[int], list[str]]:
if vocab is None:
vocab = tokens
masked = list(tokens)
positions: list[int] = []
labels: list[str] = []
for i, token in enumerate(tokens):
if random.random() < mask_prob:
positions.append(i)
labels.append(token)
r = random.random()
if r < 0.8:
masked[i] = "[MASK]"
elif r < 0.9:
masked[i] = random.choice(vocab)
return masked, positions, labels

3. 두 번째 Pre-training 태스크: Next Sentence Prediction

많은 NLP 태스크(질의응답, 자연어 추론 등)는 두 문장 간의 관계를 이해해야 하지만, 언어 모델은 그런 관계를 직접 모델링하지 않는다.

논문은 두 번째 pre-training 태스크를 추가했다: Next Sentence Prediction (NSP). 모델에 두 문장 A와 B가 주어지고 — 50%의 확률로 B는 A 다음에 실제로 오는 문장이며, 50%의 확률로 B는 코퍼스에서 무작위로 추출된 문장이다. 모델은 B가 실제로 A 다음에 오는 문장인지 판단해야 한다.

태스크 설계 자체는 단순하지만, 논문의 ablation 실험(구성 요소를 하나씩 제거하며 영향을 관찰하는 방법)에서 NSP를 제거하면 질의응답과 자연어 추론 태스크의 성능이 눈에 띄게 하락하는 것으로 나타났다. 다만, 이후 연구(RoBERTa 등)에서는 NSP의 필요성에 대해 다른 결론에 도달했다.

Python
from dataclasses import dataclass
@dataclass
class PretrainingExample:
tokens: list[str]
segment_ids: list[int]
masked_positions: list[int]
masked_labels: list[str]
is_next: bool

4. 모델 아키텍처

BERT의 아키텍처는 새로운 발명이 아니다. 단순히 Transformer의 인코더 부분을 레이어별로 쌓은 것이다.

논문은 두 가지 크기를 명시한다:

  • BERT_BASE: 12 레이어, hidden size 768, 12 attention head, 110M 파라미터
  • BERT_LARGE: 24 레이어, hidden size 1024, 16 attention head, 340M 파라미터

BERT_BASE는 OpenAI GPT와 대략 같은 파라미터 수를 가지므로 직접 비교가 가능하다. 둘 사이의 가장 핵심적인 차이는 딱 하나다: GPT는 단방향 attention을 사용하고(각 토큰은 왼쪽 토큰만 볼 수 있음), BERT는 양방향 attention을 사용한다(각 토큰이 모든 위치를 볼 수 있음).

입력 표현은 세 가지 구성 요소의 합이다:

  • Token Embedding: WordPiece 토크나이제이션, 30,000 어휘
  • Segment Embedding: 토큰이 문장 A에 속하는지 문장 B에 속하는지 표시
  • Position Embedding: 모델에 각 토큰의 위치를 알려줌 (BERT는 사인파가 아닌 학습된 position embedding을 사용)

모든 입력 시퀀스는 특수 [CLS] 토큰으로 시작하며, 이 토큰의 최종 레이어 hidden state가 문장 수준 분류(예: NSP, 감성 분석)에 사용된다. 두 문장은 [SEP]로 구분한다.

Python
import torch
from torch import nn
class BertEmbeddings(nn.Module):
def __init__(
self,
vocab_size: int,
hidden_size: int,
max_positions: int,
type_vocab_size: int = 2,
dropout: float = 0.1,
) -> None:
super().__init__()
self.token_embedding = nn.Embedding(vocab_size, hidden_size)
self.segment_embedding = nn.Embedding(type_vocab_size, hidden_size)
self.position_embedding = nn.Embedding(max_positions, hidden_size)
self.layer_norm = nn.LayerNorm(hidden_size, eps=1e-12)
self.dropout = nn.Dropout(dropout)
def forward(
self,
token_ids: torch.Tensor,
segment_ids: torch.Tensor,
) -> torch.Tensor:
position_ids = torch.arange(token_ids.size(1), device=token_ids.device).unsqueeze(0)
position_ids = position_ids.expand_as(token_ids)
embeddings = (
self.token_embedding(token_ids)
+ self.segment_embedding(segment_ids)
+ self.position_embedding(position_ids)
)
embeddings = self.layer_norm(embeddings)
return self.dropout(embeddings)
class BertModel(nn.Module):
def __init__(
self,
vocab_size: int,
hidden_size: int = 768,
max_positions: int = 512,
num_layers: int = 12,
num_heads: int = 12,
dropout: float = 0.1,
) -> None:
super().__init__()
self.embeddings = BertEmbeddings(vocab_size, hidden_size, max_positions, dropout=dropout)
encoder_layer = nn.TransformerEncoderLayer(
d_model=hidden_size,
nhead=num_heads,
dim_feedforward=4 * hidden_size,
dropout=dropout,
activation="gelu",
batch_first=True,
)
self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers)
def forward(
self,
token_ids: torch.Tensor,
segment_ids: torch.Tensor,
) -> torch.Tensor:
hidden = self.embeddings(token_ids, segment_ids)
return self.encoder(hidden)

5. Fine-tuning: 하나의 모델로 모든 태스크를

BERT의 가장 우아한 점은 fine-tuning의 단순함이다. Pre-training이 완료되면, 다운스트림 태스크가 무엇이든 절차는 거의 동일하다: BERT 위에 태스크별 출력 레이어 하나를 추가한 뒤, 소량의 레이블 데이터로 전체 파라미터를 fine-tuning한다.

  • 텍스트 분류 (감성 분석, 자연어 추론): [CLS] 위치의 출력 벡터를 선형 분류기에 입력
  • 질의응답 (주어진 문단에서 답의 범위 찾기): 각 토큰의 출력 벡터에 두 개의 선형 변환을 적용하여 답의 시작과 끝 위치를 예측
  • 시퀀스 레이블링 (개체명 인식): 각 토큰의 출력 벡터에 분류기를 부착하여 토큰별로 레이블을 예측

Pre-training에는 며칠이 걸릴 수 있지만, fine-tuning은 보통 수 분에서 수 시간이면 된다(대부분의 태스크가 TPU 한 대에서 1시간 이내). 이 효율성 차이가 “pre-train + fine-tune” 패러다임의 핵심 매력이다.

6. 실험 결과

논문은 11개의 NLP 태스크에서 실험을 수행했으며, 모든 태스크에서 새로운 기록을 세웠다.

GLUE 벤치마크 (General Language Understanding Evaluation, 8개 하위 태스크):

  • BERT_LARGE 평균 80.5%, 이전 최고(OpenAI GPT) 대비 7.7 퍼센트 포인트 향상
  • 가장 큰 하위 태스크인 MNLI에서 4.6% 향상

SQuAD v1.1 (독해 질의응답, Test F1):

  • BERT_LARGE 단일 모델 + TriviaQA 데이터: F1 91.8, 인간 성능(91.2) 초과
  • BERT_LARGE 앙상블 + TriviaQA 데이터: F1 93.2

SQuAD v2.0 (답할 수 없는 질문 포함):

  • F1 83.1, 이전 최고 시스템 대비 5.1 포인트 향상

SWAG (상식 추론):

  • 정확도 86.3%, OpenAI GPT 대비 8.3 포인트 향상

논문은 또한 모델 크기에 대한 ablation 실험을 수행했으며, 중요한 결론을 도출했다: 더 큰 모델이 모든 태스크에서 더 좋은 성능을 보였으며, 레이블 데이터가 매우 적은 태스크(최소 3,600개)에서도 마찬가지였다. 이는 작은 데이터셋에서 큰 모델이 과적합(학습 데이터를 암기하여 새로운 데이터에서 성능이 저하되는 현상)할 것이라는 당시의 직관에 반하는 결과로, pre-training된 지식이 이 위험을 효과적으로 완화한다는 것을 시사했다.

7. 학습 상세

Pre-training 데이터: BooksCorpus (8억 단어) + 영어 Wikipedia (25억 단어), 텍스트 본문만 사용하고 목록, 표, 헤더는 제외. 논문은 장거리 문맥 관계를 포착하기 위해 문장 수준으로 셔플된 코퍼스가 아닌 문서 수준 코퍼스를 사용하는 것의 중요성을 강조했다.

토크나이제이션: WordPiece, 어휘 크기 30,000. WordPiece는 흔하지 않은 단어를 더 작은 서브워드 단위로 분할한다 — 예를 들어, “playing”은 “play” + “##ing”으로 분할될 수 있다.

옵티마이저: Adam, 학습률 1e-4, 처음 10,000 스텝에 걸쳐 선형 warmup 후 선형 감쇠. 배치 크기 256 시퀀스, 최대 시퀀스 길이 512.

하드웨어: BERT_BASE는 Cloud TPU 4대(TPU 칩 16개)에서 4일간 학습. BERT_LARGE는 Cloud TPU 16대(TPU 칩 64개)에서 4일간 학습.

Dropout: 모든 레이어에서 0.1. 활성화 함수는 원래 Transformer의 ReLU 대신 GELU (Gaussian Error Linear Unit)를 사용.

8. 이 논문이 바꾼 질문

BERT의 핵심 문장은 이것입니다. 언어 이해는 먼저 재사용 가능한 표현으로 학습되고, 그다음 구체적인 과제에 전달될 수 있다.

핵심은 Transformer encoder만도 아니고 bidirectional만도 아닙니다. MLM과 NSP로 unlabeled text를 pretraining signal로 만들고, classification, question answering, sequence labeling이 같은 입력 형식과 representation base를 공유하게 한 것입니다.

이것은 NLP의 엔지니어링 단위를 바꿨습니다. BERT 이전에는 각 과제가 별도 프로젝트처럼 보였습니다. BERT 이후 많은 과제는 같은 pretrained representation 위의 얇은 adaptation layer가 됐습니다. 모델은 각 task dataset에서 언어를 다시 배우지 않습니다. 먼저 일반 언어 기반을 갖고, 그 위에서 task boundary를 배웁니다.

다음에 understanding model을 볼 때는 bidirectional인지에서 멈추지 않는 편이 좋습니다. 그 representation이 재사용 가능한가? task 차이는 모델 본체 안에 들어가는가, 아니면 얇은 인터페이스로 압축되는가?


논문 읽기 시리즈

끝 · 읽어주셔서 감사합니다

댓글