지난 글에서 디코더를 두 장치로 관통해 봤다. 인과 마스크가 아직 만들지 않은 미래 토큰을 가렸고, Cross-Attention이 인코더가 만들어 둔 소스 표현을 끌어왔다. GPT처럼 왼쪽→오른쪽(단방향)으로 읽는 언어 모델은 앞쪽 장치 덕분에 자기 회귀 생성을 할 수 있다. 하지만 "나는 __을 좋아한다"에서 빈칸을 채울 때, 단어 앞뒤 맥락을 모두 볼 수 있다면 더 정확하지 않을까? 이 아이디어에서 출발한 것이 BERT(Bidirectional Encoder Representations from Transformers)다.
BERT는 2018년 10월 Google이 발표했고, 몇 달 만에 문장 분류·개체명 인식·질의응답 같은 이해 과제의 기록을 한꺼번에 갈아 치웠다. 이 글은 BERT가 무엇을 가리고 무엇을 맞히며 학습하는지, 그 규칙의 세부가 왜 그렇게 정해졌는지, 학습한 모델을 과제에 어떻게 붙이는지, 그리고 후속 모델들이 무엇을 버리고 무엇을 고쳤는지를 차례로 본다. 마지막에는 BERT가 왜 글을 쓰지 못하는지를 짚는다 — 그 답이 곧 다음 글에서 볼 GPT가 반대 방향으로 간 이유이기도 하다.
양방향 인코더
인코더만 쓰는 구조
BERT는 트랜스포머의 인코더만 쌓은 모델이다. 인코더에는 인과 마스크가 없으므로 모든 위치가 앞뒤의 모든 위치를 동시에 본다. 「은행에 돈을 맡겼다」와 「강 은행에 앉았다」에서 「은행」의 뜻은 뒤에 오는 말이 정하는데, 단방향 모델은 「은행」을 읽는 시점에 그 뒤를 볼 수 없다. BERT는 처음부터 문장 전체를 보고 각 토큰의 표현을 만든다.
그런데 양방향으로 보는 모델에게 GPT처럼 「다음 토큰을 맞혀라」를 시킬 수는 없다. 모든 위치가 뒤를 볼 수 있으니 답이 이미 입력에 보이기 때문이다. 그래서 BERT에게는 새 학습 과제가 필요했고, 그것이 다음 절의 MLM이다. 양방향이라는 구조와 빈칸 맞히기라는 과제는 따로 고른 두 가지가 아니라, 하나를 고르면 다른 하나가 따라오는 짝이다.
양방향 자체는 BERT가 처음이 아니었다. 같은 해 나온 ELMo는 왼쪽→오른쪽 LSTM과 오른쪽→왼쪽 LSTM을 따로 학습해 두 결과를 이어 붙였다. 각 방향은 끝까지 한쪽만 보고, 두 방향이 만나는 것은 마지막에 벡터를 이어 붙이는 순간 한 번뿐이다. BERT는 첫 층부터 모든 층에서 앞뒤를 함께 섞는다. 논문이 「깊은 양방향」이라는 말을 강조한 것은 이 차이 때문이다.
모델은 두 크기로 나왔다. BERT-Base가 12층·은닉 768·헤드 12로 파라미터 1억 1천만 개, BERT-Large가 24층·은닉 1,024·헤드 16으로 3억 4천만 개다. 학습 데이터는 BooksCorpus 8억 단어와 영어 위키백과 25억 단어였다.
입력 표현
BERT의 입력은 세 가지 임베딩을 더한 것이다.
입력 = Token Embedding + Segment Embedding + Position Embedding
토큰 임베딩은 WordPiece 토크나이저가 자른 조각 약 3만 종 각각에 벡터 하나를 준다. 세그먼트 임베딩은 입력이 문장 두 개로 이뤄졌을 때 각 토큰이 첫 문장(A) 소속인지 둘째 문장(B) 소속인지를 알리는 벡터 두 개다. 위치 임베딩은 0번부터 511번 자리마다 학습된 벡터 하나씩이다. 셋을 더하는 이유는 단순하다. 같은 크기의 벡터를 더하면 입력 길이도 차원도 늘지 않으면서, 모델이 학습 중에 세 정보를 알아서 풀어 쓰게 된다.
입력의 맨 앞에는 항상 [CLS]라는 특수 토큰이 붙고, 문장 끝마다 [SEP]이 붙는다. [CLS] 자리에는 원래 단어가 없지만, 12층을 지나는 동안 문장 전체를 어텐션으로 훑어 모은 표현이 이 자리에 쌓인다. 뒤에서 문장 단위 과제를 풀 때 이 자리의 벡터를 쓴다.
from transformers import BertTokenizer, BertModel
import torch
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertModel.from_pretrained('bert-base-uncased')
text = "I love transformers."
inputs = tokenizer(text, return_tensors='pt')
# {'input_ids': ..., 'attention_mask': ..., 'token_type_ids': ...}
with torch.no_grad():
outputs = model(**inputs)
last_hidden = outputs.last_hidden_state # (1, seq_len, 768)
cls_repr = outputs.pooler_output # (1, 768) — [CLS] 표현
코드의 token_type_ids가 세그먼트 임베딩의 번호, 곧 각 토큰이 A와 B 중 어디 소속인지다. attention_mask는 길이를 맞추려고 채운 패딩 자리를 어텐션에서 빼라는 표시다.
MLM
15% 선택
MLM(Masked Language Model)은 입력 토큰 일부를 가리고 원래 토큰을 맞히게 하는 과제다. BERT는 문장마다 토큰의 15%를 무작위로 골라 예측 대상으로 삼고, 손실은 그 15% 자리에서만 계산한다. 나머지 85%는 문맥으로만 쓰인다. 고르는 단위도 한 번 바뀌었다. 처음에는 WordPiece 조각 단위로 골랐는데, 그러면 「playing」이 「play + ##ing」로 쪼개졌을 때 「##ing」만 가려지고 「play」는 보이는 일이 생긴다. 앞 조각만 보면 뒤 조각이 뻔해서 과제가 너무 쉬워진다. Google은 나중에 한 낱말의 조각을 한꺼번에 가리는 방식으로 다시 학습한 모델을 내놓았고, 성능이 올랐다.
비율은 두 힘 사이에서 정해졌다. 너무 적게 가리면 한 문장에서 얻는 학습 신호가 적어 학습이 느리고, 너무 많이 가리면 빈칸을 채울 문맥이 부족해 과제가 지나치게 어려워진다. 15%는 원 논문이 잡은 값이고, 후속 연구에서는 모델이 클수록 더 많이 가려도 된다는 결과도 나왔다. 대가도 있다. 다음 토큰 예측은 모든 위치에서 손실을 걸지만 MLM은 15%에서만 거므로, 같은 텍스트를 읽고 얻는 신호가 그만큼 적다. 논문도 MLM이 왼쪽→오른쪽 모델보다 수렴이 조금 느리다고 적었다.
80/10/10 규칙
예측 대상으로 뽑힌 15%를 모두 [MASK]로 바꾸지는 않는다. 그중 80%만 [MASK]로 바꾸고, 10%는 어휘에서 무작위로 뽑은 다른 토큰으로 바꾸고, 나머지 10%는 원래 토큰을 그대로 둔다. 세 경우 모두 맞혀야 할 답은 원래 토큰이다.
전부 [MASK]로 두면 생기는 문제는 둘이다. 첫째, 파인튜닝과 실제 사용에서는 입력에 [MASK]가 나오지 않는다. 사전학습 내내 「[MASK]가 보이면 그 자리를 채운다」만 배운 모델은 [MASK]가 없는 입력에서 무엇을 해야 할지 확실히 배우지 못한다. 둘째, 모델이 요령을 피운다. [MASK] 자리만 예측하면 되니, 가려지지 않은 토큰의 표현은 대충 만들어도 손해가 없다. 그런데 실제로 쓸 때 필요한 것은 바로 그 가려지지 않은 토큰들의 좋은 표현이다.
무작위 치환 10%가 둘째 문제를 푼다. 멀쩡해 보이는 토큰 중에도 틀린 것이 섞여 있으니, 모델은 어느 자리가 바뀌었는지 모르는 채 모든 토큰의 표현을 문맥에 비춰 성실히 만들어야 한다. 그대로 두는 10%는 반대쪽 균형을 잡는다. 무작위 치환만 있으면 모델이 「보이는 토큰은 늘 의심하라」로 치우칠 수 있으니, 보이는 토큰이 정답인 경우도 섞어 관측된 단어를 믿는 쪽으로 표현을 끌어당긴다. 무작위 치환은 전체 토큰의 1.5%(15%의 10%)라서 언어 이해를 해칠 만큼 크지 않다는 것도 논문이 든 근거다. 논문의 비교 실험에서 전부 [MASK]로 바꾼 설정은 특히 파인튜닝 없이 표현만 뽑아 쓰는 방식에서 성능이 눈에 띄게 떨어졌다.
NSP
문장 쌍 판정
BERT의 두 번째 사전학습 과제는 NSP(Next Sentence Prediction)다. 문장 두 개를 [CLS] A [SEP] B [SEP] 꼴로 이어 붙이고, B가 원문에서 실제로 A 바로 다음에 온 문장인지 맞히게 한다. 절반은 실제로 이어진 쌍, 절반은 다른 문서에서 뽑은 무작위 쌍이다. 판정은 [CLS] 자리의 표현으로 한다.
의도는 분명했다. 질의응답이나 자연어 추론처럼 두 문장의 관계를 따지는 과제는 MLM만으로는 배우기 어렵다는 판단이었다. MLM은 한 문장 안의 빈칸을 채우는 일이라 문장과 문장 사이를 직접 다루지 않기 때문이다.
버려진 과제
이 과제는 오래 살아남지 못했다. 2019년 Facebook의 RoBERTa는 NSP를 빼고 대신 한 문서에서 연속된 문장들을 입력 길이를 채울 만큼 이어 붙여 학습했는데, 성능이 같거나 조금 나았다. RoBERTa는 입력을 짧은 문장 쌍으로 구성하면 성능이 떨어진다는 것도 함께 보여, BERT 논문에서 NSP의 효과처럼 보였던 것 일부가 사실은 입력 구성의 효과였을 수 있다고 봤다.
같은 해 Google의 ALBERT는 NSP가 쓸모없는 이유를 더 구체적으로 짚었다. 무작위 쌍은 대개 주제부터 다르기 때문에, 모델은 문장 사이의 흐름을 이해하지 않고 「두 문장의 주제가 같은가」만 봐도 쉽게 맞힌다. 주제 판별은 MLM이 이미 배우는 것이라 NSP가 새로 가르치는 것이 거의 없었다. ALBERT는 그 대신 SOP(Sentence Order Prediction)를 썼다. 같은 문서의 연속된 두 문장을 원래 순서대로 주거나 순서를 뒤집어 주고, 순서가 맞는지 맞히게 한다. 주제가 같으니 주제로는 풀 수 없고, 문장 사이의 논리적 흐름을 봐야만 풀린다. 쉬운 음성 예시로 만든 과제는 모델이 지름길을 찾는다는 교훈이 여기서 나왔다.
파인튜닝
네 가지 패턴
BERT의 강점은 사전학습한 모델 위에 작은 출력 층 하나만 올려 여러 과제를 풀 수 있다는 점이다. 과제의 모양에 따라 네 패턴으로 나뉜다.
첫째, 문장 하나를 분류하는 과제(감성 분석 등)는 [CLS] 자리 벡터에 분류 층을 붙인다. 둘째, 문장 두 개의 관계를 판정하는 과제(자연어 추론, 문장 유사도)는 두 문장을 [SEP]로 이어 한 입력으로 넣고 역시 [CLS] 벡터를 쓴다. 두 문장이 같은 입력 안에서 서로를 어텐션으로 보므로, 따로 인코딩해 비교하는 방식보다 관계를 정밀하게 잡는다. 셋째, 토큰마다 라벨을 붙이는 과제(개체명 인식)는 모든 토큰 자리의 벡터에 같은 분류 층을 붙인다. 낱말이 WordPiece 조각 여러 개로 쪼개졌으면 첫 조각의 예측만 쓴다. 넷째, 지문에서 답 구간을 찾는 추출형 질의응답은 질문과 지문을 이어 넣고, 각 토큰이 답의 시작일 점수와 끝일 점수를 따로 계산해 가장 그럴듯한 구간을 고른다.
넷 모두에서 새로 학습하는 파라미터는 출력 층 하나뿐이고, 나머지 1억 1천만 개는 사전학습에서 온 값에서 출발해 조금만 움직인다. 과제마다 모델을 처음부터 설계하던 이전 방식과 비교하면, 이 균일함 자체가 BERT가 가져온 가장 큰 변화였다.
한 가지 흔한 오해가 있다. [CLS] 벡터가 문장 전체를 담으니 그대로 문장 임베딩으로 써서 유사도 검색을 하면 되리라는 생각이다. 파인튜닝하지 않은 BERT의 [CLS] 벡터나 토큰 벡터 평균은 문장 유사도 과제에서 기대보다 한참 나쁘다. 2019년의 Sentence-BERT 논문은 이 값들이 오래된 GloVe 단어 벡터의 평균보다도 못하다는 것을 보였다. [CLS] 자리는 파인튜닝 과제가 요구하는 것을 담도록 다듬어져야 제구실을 하고, 검색용 문장 벡터가 필요하면 그 목적으로 따로 학습한 모델을 쓴다.
학습 설정
from transformers import BertForSequenceClassification
from torch.optim import AdamW
model = BertForSequenceClassification.from_pretrained(
'bert-base-uncased', num_labels=2
)
optimizer = AdamW(model.parameters(), lr=2e-5)
# 학습 루프 (단순화)
for batch in dataloader:
outputs = model(**batch)
loss = outputs.loss
loss.backward()
optimizer.step()
optimizer.zero_grad()
원 논문이 권한 범위는 학습률 5e-5·3e-5·2e-5 중 하나, 배치 16이나 32, 에폭 2~4다. 사전학습 때보다 훨씬 작은 학습률을 쓰는 이유는 사전학습에서 얻은 표현을 망가뜨리지 않고 조금만 옮기려는 것이다. 학습률이 크면 몇 백 걸음 만에 사전학습의 이득이 씻겨 나가 처음부터 학습한 모델과 다를 바 없어진다.
데이터가 작은 과제에서는 결과가 시드에 따라 크게 흔들린다. 논문도 작은 데이터셋에서 BERT-Large의 파인튜닝이 가끔 불안정해 여러 번 다시 시작해 가장 좋은 것을 골랐다고 적었다. 실무에서는 시드 서너 개로 돌려 평균과 편차를 함께 보고, 검증 점수가 가장 좋은 에폭에서 멈춘다. 학습 초반 몇 퍼센트 동안 학습률을 0에서 천천히 올리는 워밍업을 두는 것도 이 흔들림을 줄인다.
후속 모델
RoBERTa와 ALBERT
| 모델 | 레이어 | 은닉 | 헤드 | 파라미터 |
|---|---|---|---|---|
| BERT-Base | 12 | 768 | 12 | 110M |
| BERT-Large | 24 | 1024 | 16 | 340M |
| RoBERTa-Base | 12 | 768 | 12 | 125M |
| ALBERT-Base | 12 | 768 | 12 | 12M |
RoBERTa는 구조를 거의 그대로 두고 학습 방식만 고쳤다. NSP를 빼고, 데이터를 16 GB에서 160 GB로 늘리고, 배치를 키우고, 더 오래 학습했다. 그리고 마스킹 위치를 데이터 준비 때 한 번 정해 고정하던 것을, 문장을 볼 때마다 새로 뽑는 동적 마스킹으로 바꿨다. 같은 문장이 매번 다른 빈칸 문제가 되니 데이터를 여러 번 돌아도 새 문제를 푸는 셈이다. 파라미터가 1,500만 개 늘어난 것은 토크나이저를 5만 어휘의 바이트 단위 BPE로 바꿔 임베딩 행렬이 커졌기 때문이다. RoBERTa의 교훈은 BERT가 구조가 아니라 학습이 덜 된 상태였다는 것이다.
ALBERT는 반대로 파라미터를 줄이는 쪽이다. 3만 어휘 × 768차원이던 임베딩을 3만 × 128과 128 × 768 두 행렬로 쪼개 임베딩 파라미터를 크게 줄였고, 12개 층이 파라미터 한 벌을 돌려 쓰게 했다. 그래서 BERT-Base와 같은 모양인데 파라미터가 1,200만 개다. 다만 층을 공유해도 계산은 12번 하므로 추론 속도는 BERT-Base와 비슷하다. 메모리는 줄지만 빨라지지는 않는다는 점을 헷갈리면 안 된다.
2020년의 ELECTRA는 앞 절에서 본 MLM의 약점, 곧 15% 자리에서만 학습 신호를 얻는다는 점을 정면으로 고쳤다. 작은 MLM 모델이 가린 자리에 그럴듯한 토큰을 채워 넣으면, 본 모델은 모든 토큰에 대해 「이 토큰이 원래 것인가 바뀐 것인가」를 판정한다. 판정 문제는 모든 위치에 걸리므로 같은 계산으로 훨씬 많은 신호를 얻고, 논문은 같은 연산량에서 BERT보다 높은 점수를 보고했다. 한국어에서도 KoELECTRA가 이 방식으로 학습돼 널리 쓰였다.
한국어 BERT
영어 BERT를 한국어에 쓸 수는 없고, 104개 언어를 한 어휘에 담은 다국어 BERT는 한국어 몫이 작았다. 어휘 12만 개 가운데 한국어 조각이 적어서, 한국어 문장이 음절 하나하나로 쪼개지거나 흔한 어미가 낱말 경계를 무시하고 잘렸다. 토큰이 뜻의 단위와 어긋나면 모델은 그 어긋남을 되돌리는 일부터 배워야 한다.
한국어 BERT들은 이 토크나이저 문제부터 고쳤다. SKT의 KoBERT는 한국어 위키백과로 SentencePiece 어휘를 새로 만들었고, 서울대의 KR-BERT는 음절 단위와 자모 단위 어휘를 비교했다. 2021년의 KLUE는 한국어 이해 벤치마크와 함께 기준 모델을 내놓으면서, 먼저 형태소 분석기로 문장을 형태소 경계에서 자른 뒤 그 안에서 서브워드를 만드는 방식을 썼다. 「먹었다」가 「먹 + 었 + 다」처럼 뜻의 경계를 지키며 쪼개지도록 한 것이다. 조사와 어미가 붙는 교착어에서는 이 순서가 토큰 효율과 품질을 함께 올린다. KLUE 벤치마크는 주제 분류, 문장 유사도, 자연어 추론, 개체명 인식, 관계 추출, 의존 구문 분석, 기계 독해, 대화 상태 추적 여덟 과제로 이뤄져 있어, 한국어 인코더를 비교하는 공통 잣대 구실을 한다.
한계
생성
BERT는 글을 쓰지 못한다. 언어 생성은 앞에서부터 한 토큰씩 확률을 곱해 나가는 일인데, BERT는 그런 분해로 학습한 적이 없다. BERT가 배운 것은 「주변이 다 보일 때 이 빈칸에 무엇이 오나」이지 「여기까지 보고 다음에 무엇이 오나」가 아니다.
빈칸 맞히기를 되풀이해 억지로 문장을 만들 수는 있다. 전부 [MASK]로 채운 줄에서 가장 자신 있는 자리부터 하나씩 확정해 나가는 식이다. 그러나 이렇게 만든 문장은 각 자리를 따로 추측한 것이라 앞뒤가 잘 맞지 않고, 한 토큰을 확정할 때마다 문장 전체를 다시 계산해야 해서 느리다. 앞 토큰의 K·V를 쌓아 두고 재사용하는 캐시도 쓸 수 없다. 양방향으로 보는 대가가 생성에서 한꺼번에 청구되는 것이다. 이해와 생성 중 하나를 골라야 하는 이 갈림길에서, 같은 해 OpenAI는 반대쪽을 골랐다. 뒤를 보지 않는 대신 모든 위치에서 다음 토큰을 맞히게 해 학습 신호를 빈틈없이 얻고, 그 목표가 그대로 생성이 되는 쪽이다. 다음 글의 GPT가 그 선택이다.
512 토큰
BERT의 입력은 512 토큰에서 끝난다. 위치 임베딩이 0번부터 511번 자리까지 학습된 벡터 512개라서, 513번째 자리에 줄 벡터가 아예 없다. 게다가 사전학습의 90%는 길이 128로 하고 마지막 10%만 512로 했으므로, 긴 입력에서의 표현은 상대적으로 덜 다듬어져 있다.
긴 문서는 그래서 512 토큰짜리 창을 겹치게 밀며 여러 번 넣고 결과를 합친다. 질의응답이라면 창마다 답 구간을 찾아 점수가 가장 높은 것을 고른다. 창을 겹치게 두는 것은 답이 창 경계에 걸려 두 조각으로 잘리는 일을 막기 위해서다. 이 한계는 구조가 아니라 위치 표현 방식에서 오므로, 최근 인코더 모델들은 RoPE 같은 상대 위치 방식으로 바꿔 수천 토큰까지 늘렸다. 검색용 임베딩이나 대량 분류처럼 빠르고 싼 이해가 필요한 자리에 인코더 모델이 여전히 쓰이는 것도 이런 개선 덕분이다.
읽어주셔서 감사합니다. 😊

