BERT 뉴스 분류 #2 Max length 최적화, 분류 헤드 확장

BERT 뉴스 분류 #2 Max length 최적화, 분류 헤드 확장 #

#2026-03-01


#1 데이터 요약

데이터: AG News (뉴스 기사 데이터셋)
  - 학습: 120,000건 (클래스별 30,000건 균등)
  - 테스트: 7,600건

라벨:
  0 → World (세계)
  1 → Sports (스포츠)
  2 → Business (경제)
  3 → Sci/Tech (과학/기술)

문제 유형: 4클래스 분류

데이터가 균등하다 (클래스별 같은 수).

#

#2 BERT 파인튜닝

BERT는 이미 위키피디아와 BookCorpus라는 방대한 텍스트로 사전 학습되어 있다. 수십억 개의 단어를 읽으면서 영어의 문법, 단어 간의 의미 관계, 문맥에 따른 의미 변화를 이미 체득한 상태다. 하지만 “이 뉴스가 스포츠인지 경제인지"를 판단하는 법은 배운 적이 없다.파인튜닝은 이 이미 똑똑한 모델을 우리 문제에 맞게 살짝 조정하는 것이다. 비유하자면 영어를 유창하게 하는 사람에게 뉴스 편집 업무를 가르치는 것과 같다. 영어 자체를 처음부터 가르칠 필요는 없다. “경제 기사는 보통 이런 단어들이 나오고, 스포츠 기사는 저런 단어들이 나온다"는 정도만 가르치면 된다.

사전학습 BERT: 영어 이해 능력은 있지만 뉴스 분류는 모름
                    ↓ 파인튜닝 (AG News 데이터로 학습)
파인튜닝 BERT: 영어 이해 + 뉴스 카테고리 분류 가능

#

#3 max_length 최적화

BERT는 최대 512개 토큰을 처리할 수 있다. 아무 생각 없이 max_length=512로 설정하면 작동은 한다. 하지만 이게 엄청난 낭비를 만든다.

실제 AG News 기사들의 토큰 길이를 분석해보면, 평균이 95개이고 95번째 퍼센타일이 180이다. 기사 대부분이 200 토큰도 안 된다는 뜻이다. 512로 설정하면 평균적으로 417개의 [PAD] 토큰이 뒤에 붙는다. 실제 내용이 95개이고 빈칸이 417개인 셈이다.

lengths = [len(tokenizer.encode(text)) for text in train_texts]
print(f"평균:    {np.mean(lengths):.0f}")
print(f"95%ile: {np.percentile(lengths, 95):.0f}")
print(f"99%ile: {np.percentile(lengths, 99):.0f}")
평균:    95
95%ile: 180
99%ile: 287

“attention_mask가 패딩을 무시하게 해주지 않느냐"고 물을 수 있다. 맞다, attention_mask 덕분에 패딩 토큰의 가중치는 0이 된다. 하지만 Self-Attention의 행렬 계산 자체는 여전히 512×512 크기로 수행된다. attention_mask는 계산 결과에서 패딩의 영향력을 지우는 것이지, 계산 자체를 건너뛰는 게 아니다. 512×512 = 262,144번의 연산 중 실제로 의미 있는 건 95×95 = 9,025번뿐인데, 나머지 25만 번은 그냥 버려지는 것이다.

# 왜 과도한 패딩이 문제인가?
max_length=512 설정 시, 길이 95짜리 기사 처리:
[wall][st][...][black][SEP][PAD][PAD][PAD]...[PAD]  (512개)
 ←────────── 실제 내용 ─────────→← 빈칸 416개 →

Self-Attention은 512×512 행렬을 계산:
512 × 512 = 262,144 연산

max_length=192 설정 시:
192 × 192 = 36,864 연산  ← 7배 이상 적음
def preprocess_function(examples):
    return tokenizer(
        examples["text"],
        truncation=True,
        padding=False,      # ← 고정 패딩 제거
        max_length=192,     # ← 512 → 192
    )

tokenized_dataset = dataset.map(preprocess_function, batched=True)

max_length=192로 줄이면 192×192 = 36,864번의 연산만 하면 된다. 512 대비 7분의 1 수준이다. 95%의 기사가 완전히 보존되고, 나머지 5%만 끝이 약간 잘린다. 뉴스 분류에서 기사 앞부분만으로도 카테고리를 충분히 판단할 수 있으니, 이건 합리적인 트레이드오프다.

여기서 한 단계 더 최적화가 있다. Dynamic Padding이다. max_length=192로 고정 패딩을 하면, 40 토큰짜리 짧은 기사도 192로 늘어난다. 하지만 어떤 배치에 담긴 기사들이 모두 100 토큰 이하라면, 그 배치는 100까지만 패딩하면 충분하다.

data_collator = DataCollatorWithPadding(tokenizer=tokenizer)
# 배치를 만들 때마다 그 배치의 최장 시퀀스 길이로 동적으로 패딩

DataCollatorWithPadding이 바로 이 일을 한다. 배치를 구성할 때 그 배치 안에서 가장 긴 시퀀스의 길이에 맞춰 패딩한다. 배치마다 패딩 길이가 달라지므로 “동적(Dynamic)” 패딩이다. 고정 패딩에서 평균 120개의 빈칸이 붙었다면, 동적 패딩에서는 평균 83개 정도로 줄어든다. 작은 차이 같지만 12만 건을 처리하면 누적 효과가 크다.

고정 패딩:  배치 내 최장 = 180, 최단 = 40
  → 모든 샘플을 192로 패딩  (평균 패딩 120)

Dynamic:   배치 내 최장 = 180
  → 모든 샘플을 180으로 패딩  (평균 패딩 83, ← 더 효율적)

이 최적화의 본질은 이전 챕터들에서 반복해서 본 원칙과 같다. DNA 분석에서 101bp 서열에는 큰 모델을, 21bp 서열에는 작은 모델을 썼다. 데이터의 실제 크기에 맞게 모델의 입력 크기를 조절하는 것이다. 필요 이상으로 큰 입력을 넣으면 계산 낭비와 과적합 위험만 늘어난다.

#

#4 분류 헤드 구조 확장

기본 BERT의 분류 헤드는 극단적으로 단순하다. [CLS] 토큰에서 나온 768차원 벡터에 Dropout을 한 번 걸고, Linear(768 → 4)로 바로 4개 클래스 logit을 만든다. 한 방에 768차원에서 4차원으로 뛰어내리는 것이다.

[CLS] 벡터 (768차원)
    ↓ Dropout(0.1)
    ↓ Linear(768 → 4)
logits (4차원)

이게 왜 문제가 될 수 있는가? 768차원 공간에서 4개 카테고리의 경계가 하나의 직선(정확히는 초평면)으로 깔끔하게 나뉘면 문제가 없다. 하지만 현실의 뉴스 기사는 그렇게 단순하지 않다. “Tech company wins lawsuit"은 기술 뉴스일까 경제 뉴스일까? “Olympic committee announces budget cuts"는 스포츠일까 경제일까? 카테고리 사이의 경계가 직선이 아니라 구불구불한 곡선일 수 있다. 단일 Linear 레이어는 직선 경계만 그을 수 있으므로, 이런 복잡한 경계를 표현하지 못한다.

class EnhancedClassifier(nn.Module):
    def __init__(self, hidden_size=768, num_labels=4, dropout_rate=0.3):
        super().__init__()
        self.dropout1 = nn.Dropout(dropout_rate)      # 0.1 → 0.3
        self.dense1 = nn.Linear(hidden_size, 256)     # 768 → 256
        self.activation = nn.GELU()                   # ReLU 대신 GELU
        self.dropout2 = nn.Dropout(dropout_rate)
        self.dense2 = nn.Linear(256, num_labels)      # 256 → 4

    def forward(self, cls_output):
        x = self.dropout1(cls_output)
        x = self.dense1(x)
        x = self.activation(x)
        x = self.dropout2(x)
        return self.dense2(x)

EnhancedClassifier는 중간 단계를 하나 추가한다. 768 → 256 → 4 구조다. 768차원 벡터가 먼저 256차원으로 압축되면서 비선형 활성화 함수(GELU)를 통과하고, 그 256차원 표현에서 4개 클래스로 분류한다.

이 중간 레이어가 존재하는 것만으로도 모델의 표현력이 크게 달라진다. 첫 번째 Linear가 768차원 공간을 256차원으로 사영(projection)하면서 정보를 재조합하고, GELU가 비선형성을 도입해서 구불구불한 경계를 그을 수 있게 만들어준다. 그 다음 두 번째 Linear가 이 재조합된 표현을 보고 최종 분류를 한다. 망막증 모델에서 GlobalMaxPooling 뒤에 바로 Dense(5)로 가지 않고 Dense(1024, relu) → Dense(5)로 중간 단계를 둔 것과 같은 설계 원리다.

왜 768 → 256 → 4인가?
- 768 → 4로 한 번에 가면 각 출력 뉴런이 768개의 입력을 모두 직접 봐야 한다. 중간에 256차원을 두면 768개의 특징이 먼저 256개의 중간 표현으로 압축된 뒤, 그 압축된 표현으로 클래스를 판단한다. 단계적으로 추상화하는 것이다.
- 망막증 모델에서 GlobalMaxPooling → Dense(1024, relu) → Dense(5) 구조를 쓴 것과 같은 판단이다. 바로 출력으로 뛰어내리지 않고 중간 표현을 하나 더 만든다.

GELU라는 활성화 함수를 쓴 이유도 흥미롭다. 이전 챕터들에서는 ReLU를 썼다. ReLU는 입력이 양수면 그대로 통과시키고, 음수면 완전히 0으로 만든다. 이분법적이다. GELU는 다르다. 음수 입력도 확률적으로 일부 통과시킨다. 완전히 죽이지 않고 약간의 정보를 흘려보내는 것이다. BERT 본체가 내부적으로 GELU를 사용하므로, 분류 헤드도 GELU를 쓰면 모델 전체가 일관된 활성화 패턴을 유지한다.

왜 GELU인가?
- ReLU:  f(x) = max(0, x)       ← x < 0이면 완전히 0
- GELU:  f(x) = x · Φ(x)       ← x < 0이어도 확률적으로 일부 통과

Dropout을 0.1에서 0.3으로 올린 것은 분류 헤드의 특수한 상황을 반영한다. BERT 본체는 1억 개가 넘는 파라미터가 이미 사전학습으로 잘 조정되어 있지만, 분류 헤드는 완전히 랜덤한 상태에서 시작한다. 사전학습의 보호막이 없는 셈이다. 이런 상황에서 과적합이 일어나기 쉬우므로 더 강한 정규화가 필요하다. DNA 분석에서 짧은 서열(21bp)에 0.3, 긴 서열(101bp)에 0.5를 쓴 것처럼, Dropout 비율은 과적합 위험도에 맞춰 조절하는 것이다.

왜 Dropout을 0.1 → 0.3으로 올렸나?
- DNA 분석에서 서열이 짧을 때(21bp) 0.3, 길 때(101bp) 0.5를 쓴 것처럼 
— Dropout 비율은 과적합 위험도에 맞춰 조절한다. AG News는 12만 건으로 충분한 데이터지만, 분류 헤드는 랜덤 초기화에서 시작하므로 처음부터 강한 정규화가 필요하다. 0.3은 BERT 파인튜닝에서 헤드 레이어에 흔히 쓰이는 값이다.

#