92. BERT: 양방향으로 읽는 모델
요약
BERT는 문맥을 양방향으로 이해하는 인코더 전용 트랜스포머 모델로, 마스크 언어 모델링(MLM)과 다음 문장 예측(NSP)을 통해 사전 학습됩니다. GPT가 다음 단어를 예측하는 생성에 특화된 것과 달리, BERT는 문장의 의미를 파악하는 이해 작업에 탁월하여 NLP 벤치마크의 표준으로 사용됩니다.
핵심 포인트
- BERT는 양방향 문맥(Bidirectional context)을 활용하여 텍스트 이해 성능이 뛰어남
- 마스크 언어 모델링(MLM)을 통해 문장 내 가려진 토큰을 예측하며 학습함
- 다음 문장 예측(NSP) 작업을 통해 문장 간의 관계를 학습함
- GPT는 디코더 전용(Decoder-only)으로 생성에, BERT는 인코더 전용(Encoder-only)으로 이해에 최적화됨
- HuggingFace를 활용하면 미세 조정(Fine-tuning)을 매우 간결하게 수행할 수 있음
GPT는 다음 단어를 예측함으로써 텍스트를 생성합니다. 이는 왼쪽에서 오른쪽으로 읽습니다. BERT는 이와 다른 방식을 취합니다. 문장에서 무작위로 단어를 마스킹 (Masking) 하고, 그것이 무엇인지 예측하려고 시도합니다. 이를 잘 수행하기 위해서, BERT는 모든 단어를 다른 모든 단어와의 관계 속에서 동시에 이해해야 합니다. 왼쪽과 오른쪽 문맥 (Context)이 모두 중요합니다. 이러한 양방향 이해 (Bidirectional understanding) 덕분에 BERT는 2018년 출시되었을 때 NLP 벤치마크를 장악했으며, 인코더 전용 트랜스포머 (Encoder-only transformers)가 여전히 이해 (Understanding) 작업의 표준으로 사용되는 이유이기도 합니다.
이곳에서 배우게 될 내용
- BERT를 GPT와 다르게 만드는 것
- 마스크 언어 모델링 (Masked Language Modeling): BERT가 학습하는 방식
- 다음 문장 예측 (Next Sentence Prediction): 두 번째 사전 학습 (Pretraining) 작업
- [CLS] 및 [SEP] 토큰과 그 역할
- 텍스트 분류를 위한 BERT 미세 조정 (Fine-tuning)
- 개체명 인식 (Named Entity Recognition)을 위한 미세 조정
- 질의응답 (Question Answering)을 위한 미세 조정
- HuggingFace를 사용하여 이 모든 것을 20줄 이내로 수행하기
BERT vs GPT: 핵심 차이점
둘 다 트랜스포머 (Transformer) 기반입니다. 아키텍처 (Architecture)도 유사합니다. 차이점은 어떻게 사전 학습 (Pretrained)되는지와 트랜스포머의 어느 부분을 사용하는지에 있습니다.
GPT (디코더 전용, Decoder-only):
- 인과적 마스킹 (Causal masking)을 통해 왼쪽에서 오른쪽으로 읽음
- 다음 토큰을 예측하도록 학습됨
- 생성 (Generation)에 탁월함
- 문맥 (Context): 왼쪽 측면만 사용 가능
BERT (인코더 전용, Encoder-only):
- 모든 토큰을 동시에 읽음
- 마스킹된 토큰 + 다음 문장을 예측하도록 학습됨
- 이해 (Understanding)에 탁월함
- 문맥 (Context): 왼쪽과 오른쪽 측면 모두 사용 가능
분류 (Classification) 작업에서는 BERT가 승리합니다. 생성 (Generation) 작업에서는 GPT가 승리합니다. 여러분이 실제로 구축하고자 하는 대부분의 NLP 애플리케이션에서 BERT는 시작점입니다.
BERT가 사전 학습된 방식
BERT는 거대한 코퍼스 (Corpus, BooksCorpus + English Wikipedia, 33억 단어)에서 두 가지 작업을 동시에 사전 학습 (Pretrained)했습니다.
작업 1: 마스크 언어 모델링 (Masked Language Modeling, MLM)
토큰의 15%가 무작위로 마스킹됩니다. 모델은 문맥 (Context)으로부터 원래의 토큰을 예측합니다.
입력 (Input): "The cat [MASK] on the [MASK]" 대상 (Target): "The cat sat on the mat"
선택된 15%의 토큰 중:
- 80%는 [MASK]로 교체됨
- 10%는 무작위 토큰 (Random token)으로 교체됨
- 10%는 변경되지 않고 그대로 유지됨
무작위 교체 및 유지 케이스는 모델이 오직 [MASK] 토큰만을 예측하는 법을 배우는 것을 방지합니다.
작업 2: 다음 문장 예측 (Next Sentence Prediction, NSP)
두 개의 문장이 주어집니다. 모델은 문장 B가 원래 텍스트에서 실제로 문장 A 다음에 오는지 예측합니다.
입력 (Input): [CLS] The cat sat on the mat. [SEP] It was a lazy afternoon. [SEP]
레이블 (Label): IsNext (1)
입력 (Input): [CLS] The cat sat on the mat. [SEP] The stock market crashed. [SEP]
레이블 (Label): NotNext (0)
NSP는 나중에 MLM보다 유용성이 낮다는 것이 밝혀졌으며 RoBERTa에서는 제외되었습니다. 하지만 이는 오리지널 BERT의 일부입니다.
BERT의 특수 토큰 (Special Tokens)
BERT는 반드시 알아야 할 세 가지 특수 토큰을 사용합니다:
- [CLS]: 분류 (Classification) 토큰. 항상 첫 번째 토큰입니다. 이 토큰의 최종 은닉 상태 (Final hidden state)는 분류 작업을 위한 문장 수준의 표현 (Sentence-level representation)으로 사용됩니다.
- [SEP]: 구분자 (Separator) 토큰. 문장의 끝을 표시하거나 쌍으로 된 두 문장을 구분합니다.
- [PAD]: 패딩 (Padding) 토큰. 배치 (Batch) 내의 모든 시퀀스 길이를 동일하게 만드는 데 사용됩니다.
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
text = "The cat sat on the mat."
tokens = tokenizer(text)
print(f"Input IDs: {tokens['input_ids']}")
print(f"Token type IDs: {tokens['token_type_ids']}")
print(f"Attention mask: {tokens['attention_mask']}")
print()
# 디코딩하여 무엇인지 확인
decoded = tokenizer.convert_ids_to_tokens(tokens['input_ids'])
print(f"Tokens: {decoded}")
출력 (Output):
Input IDs : [101, 1996, 4937, 2938, 2006, 1996, 13523, 1012, 102]
Token type IDs : [0, 0, 0, 0, 0, 0, 0, 0, 0]
Attention mask : [1, 1, 1, 1, 1, 1, 1, 1, 1]
Tokens : ['[CLS]', 'the', 'cat', 'sat', 'on', 'the', 'mat', '.', '[SEP]']
101은 [CLS]입니다. 102는 [SEP]입니다. 모든 BERT 입력은 [CLS]로 시작하여 [SEP]로 끝납니다.
101은 [CLS]입니다. 102는 [SEP]입니다. 모든 BERT 입력은 [CLS]로 시작하여 [SEP]로 끝납니다.
두 문장 텍스트 쌍(text_pair) = ("The cat sat on the mat." , "It was a lazy afternoon." )
tokens_pair = tokenizer (* text_pair )
decoded_pair = tokenizer.convert_ids_to_tokens( tokens_pair['input_ids'] )
print( f "Pair tokens: { decoded_pair } " )
print( f "Token types: { tokens_pair['token_type_ids'] } " )
출력:
Pair tokens : [ ' [CLS] ' , ' the ' , ' cat ' , ' sat ' , ' on ' , ' the ' , ' mat ' , ' . ' , ' [SEP] ' , ' it ' , ' was ' , ' a ' , ' lazy ' , ' afternoon ' , ' . ' , ' [SEP] ' ]
Token types : [ 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 1 , 1 , 1 , 1 , 1 , 1 , 1 ]
토큰 타입 0 = 첫 번째 문장. 토큰 타입 1 = 두 번째 문장. BERT는 이를 사용하여 둘을 구별합니다.
BERT 모델 변형:
bert-base-uncased: 12 레이어, 768 히든, 12 헤드, 110M 파라미터(params)
bert-large-uncased: 24 레이어, 1024 히든, 16 헤드, 340M 파라미터
bert-base-cased: base와 동일하지만 대소문자를 구분하는 토큰화(case-sensitive tokenization)를 사용합니다.
distilbert-base: 6 레이어, 66M 파라미터, BERT 성능의 97%, 속도는 60% 빠름
roberta-base: NSP가 없는 BERT로, 더 오래 훈련되어 성능이 더 좋습니다.
대부분의 작업에는 bert-base-uncased 또는 distilbert-base-uncased를 사용하십시오. 추가 용량이 필요할 때만 더 큰 모델을 사용하세요.
작업 1: 텍스트 분류 (Text Classification)
BERT의 가장 일반적인 사용 사례입니다. [CLS] 토큰 출력 위에 선형 레이어(linear layer)를 추가합니다.
from transformers import BertForSequenceClassification, BertTokenizer
from torch.utils.data import DataLoader, Dataset
import torch
import torch.nn as nn
from torch.optim import AdamW
from transformers import get_linear_schedule_with_warmup
간단한 감성 분석 데이터셋
texts = [ "This movie was absolutely fantastic!" , "I hated every minute of it." , "An incredible performance by the lead actor." , "Terrible writing, terrible acting." , "One of the best films I've seen this year." , "Complete waste of time and money." , "Beautifully crafted and deeply moving." , "Boring and predictable from start to finish."]
, ] labels = [ 1 , 0 , 1 , 0 , 1 , 0 , 1 , 0 ] # 1=positive, 0=negative # 토큰화 (Tokenize) tokenizer = BertTokenizer.from_pretrained('bert-base-uncased') class SentimentDataset(Dataset): def init(self, texts, labels, tokenizer, max_len=64): self.encodings = tokenizer(texts, truncation=True, padding=True, max_length=max_len, return_tensors='pt') self.labels = torch.tensor(labels) def len(self): return len(self.labels) def getitem(self, idx): return {'input_ids': self.encodings['input_ids'][idx], 'attention_mask': self.encodings['attention_mask'][idx], 'labels': self.labels[idx]} dataset = SentimentDataset(texts, labels, tokenizer) loader = DataLoader(dataset, batch_size=4, shuffle=True) # 분류 헤드(classification head)가 있는 사전 학습된 BERT 로드 model = BertForSequenceClassification.from_pretrained('bert-base-uncased', num_labels=2) device = 'cuda' if torch.cuda.is_available() else 'cpu' model = model.to(device) optimizer = AdamW(model.parameters(), lr=2e-5) scheduler = get_linear_schedule_with_warmup(optimizer, num_warmup_steps=0, num_training_steps=len(loader)*3) # 파인튜닝 (Fine-tune) print('Fine-tuning BERT for sentiment classification...') for epoch in range(3): model.train() total_loss = 0 for batch in loader: optimizer.zero_grad() input_ids = batch['input_ids'].to(device) attention_mask = batch['attention_mask'].to(device) labels = batch['labels'].to(device) outputs = model(input_ids=input_ids, attention_mask=attention_mask, labels=labels) loss = outputs.loss loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() total_loss += loss.item() print(f'Epoch {epoch + 1} : loss= {total_loss / len(loader):.4f}') # 새로운 예제에 대해 예측하기 model.
eval () new_texts = [ "I absolutely loved this film!" , "This was the worst movie I have ever seen." ] new_encoding = tokenizer ( new_texts , truncation = True , padding = True , max_length = 64 , return_tensors = 'pt' ).to(device) with torch.no_grad(): outputs = model(**new_encoding) preds = torch.argmax(outputs.logits, dim=1) for text, pred in zip(new_texts, preds): sentiment = "Positive" if pred == 1 else "Negative" print(f'"{text[:50]}..." -> {sentiment}") Output: Fine-tuning BERT for sentiment classification...
Epoch 1: loss=0.6834
Epoch 2: loss=0.4123
Epoch 3: loss=0.2187
'I absolutely loved this film!...' -> Positive
'This was the worst movie I have ever seen....' -> Negative
What Happens Inside During Fine-Tuning # Look at what BertForSequenceClassification adds from transformers import BertModel import torch.nn as nn class BertClassifier ( nn.Module ): def init ( self , n_classes , dropout = 0.3 ): super().init() self.bert = BertModel.from_pretrained('bert-base-uncased') self.dropout = nn.Dropout(dropout) self.classifier = nn.Linear(768, n_classes) # 768 = bert-base hidden size def forward ( self , input_ids , attention_mask ): outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask) # outputs.last_hidden_state: (batch, seq_len, 768) # outputs.pooler_output: (batch, 768) - the [CLS] token, passed through a linear+tanh cls_output = outputs.pooler_output # (batch, 768) cls_output = self.dropout(cls_output) logits = self.classifier(cls_output) # (batch, n_classes) return logits model_manual = BertClassifier(n_classes=2) # Check what's trainable vs frozen total = sum(p.numel() for p in model_manual.parameters()) trainable = sum(p.numel() for p in model_manual.parameters() if p.
requires_grad ) print ( f " Total parameters: { total : , } " ) print ( f " Trainable parameters: { trainable : , } " ) print () # 종종 BERT 레이어를 동결(freeze)하고 헤드만 학습시키는 경우가 있습니다. for param in model_manual . bert . parameters (): param . requires_grad = False frozen_trainable = sum ( p . numel () for p in model_manual . parameters () if p . requires_grad ) print ( f " Trainable (head only): { frozen_trainable : , } " ) print ( " (2-layer 분류기만 학습됨) " ) 출력: Total parameters: 109,484,546 Trainable parameters: 109,484,546 Trainable (head only): 1,538 (2-layer 분류기만 학습됨) 전체 BERT를 파인튜닝(fine-tune)하면 모든 109M 개의 매개변수가 업데이트됩니다. BERT를 동결하고 헤드만 학습시키면 1,538개의 매개변수만 업데이트됩니다. 동결하는 것이 더 빠르지만 일반적으로 정확도는 떨어집니다. 충분한 데이터가 있다면 모든 것을 파인튜닝하는 것이 더 나은 결과를 가져옵니다.
Task 2: 개체명 인식 (Named Entity Recognition, NER)
NER는 각 토큰을 분류합니다. 사람(Person), 조직(Organization), 장소(Location), 날짜(Date), 기타(Other)입니다. 이는 문장 수준이 아닌 토큰 수준의 분류 작업입니다.
from transformers import BertForTokenClassification , BertTokenizerFast # NER 레이블 label_list = [ ' O ' , ' B-PER ' , ' I-PER ' , ' B-ORG ' , ' I-ORG ' , ' B-LOC ' , ' I-LOC ' ] label2id = { l : i for i , l in enumerate ( label_list )} id2label = { i : l for i , l in enumerate ( label_list )} # NER 모델 로드 ner_model = BertForTokenClassification . from_pretrained ( ' bert-base-uncased ' , num_labels = len ( label_list ), id2label = id2label , label2id = label2id ) tokenizer_fast = BertTokenizerFast . from_pretrained ( ' bert-base-uncased ' ) # 예시: 단어 레이블을 서브워드 토큰에 맞추기 sentence = " Elon Musk founded Tesla in California. " words = sentence .
split() word_labels = [ ' B-PER ' , ' I-PER ' , ' O ' , ' B-ORG ' , ' O ' , ' B-LOC ' , ' O ' ] # subword(하위 단어) 처리를 위해 word_ids를 사용하여 토큰화 encoding = tokenizer_fast ( words , is_split_into_words = True , return_offsets_mapping = True , padding = True , truncation = True ) # 단어 수준의 레이블을 subword(하위 단어) 수준의 word_ids로 매핑 word_ids = encoding . word_ids () token_labels = [] prev_word_id = None for word_id in word_ids : if word_id is None : token_labels . append ( - 100 ) # 손실 함수(loss) 계산 시 [CLS] 및 [SEP] 무시 elif word_id != prev_word_id : token_labels . append ( label2id [ word_labels [ word_id ]]) # 첫 번째 subword(하위 단어) else : token_labels . append ( - 100 ) # 이후의 subwords(하위 단어)들은 무시 prev_word_id = word_id tokens = tokenizer_fast . convert_ids_to_tokens ( encoding [ ' input_ids ' ]) print ( " Token -> Label alignment: " ) for token , label_id in zip ( tokens , token_labels ): label = id2label . get ( label_id , ' IGN ' ) print ( f " { token : < 15 } { label } " ) Output: Token -> Label alignment: [CLS] IGN elon B-PER mu IGN ##sk IGN founded O tesla B-ORG in O california B-LOC . O [SEP] IGN "Elon"은 B-PER에 매핑됩니다. "mu"와 "##sk"("Musk"의 subwords(하위 단어))는 손실 함수에서 무시됩니다. 이것이 token-level(토큰 수준) 작업을 위해 subword tokenization(하위 단어 토큰화)을 처리하는 표준적인 방법입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기