이미지 로딩 중...
AI Generated
2025. 11. 8. · 3 Views
LLM 구현 9편 - 모델 학습과 Fine-tuning 완벽 가이드
대규모 언어 모델(LLM)의 핵심인 사전 학습부터 파인튜닝까지, 실무에서 직접 활용할 수 있는 학습 전략과 최적화 기법을 심층적으로 다룹니다. Pre-training, Fine-tuning, LoRA, RLHF 등 최신 기법들을 실제 코드와 함께 배워보세요.
목차
- Pre-training - 기반 모델 사전 학습
- Supervised Fine-tuning (SFT) - 지도 학습 기반 파인튜닝
- LoRA (Low-Rank Adaptation) - 효율적인 파인튜닝
- RLHF (Reinforcement Learning from Human Feedback) - 인간 피드백 기반 강화학습
- Gradient Accumulation - 효과적인 배치 크기 증가
- Mixed Precision Training - 혼합 정밀도 학습
- Learning Rate Scheduling - 학습률 스케줄링
- Regularization Techniques - 정규화 기법
- Checkpoint Management - 체크포인트 관리
- Data Collators and Efficient Batching - 데이터 콜레이터와 효율적 배칭
1. Pre-training - 기반 모델 사전 학습
시작하며
여러분이 대규모 언어 모델을 처음부터 학습시키려고 할 때, 어디서부터 시작해야 할지 막막하신가요? 수백억 개의 파라미터를 가진 모델을 효율적으로 학습시키는 것은 현대 AI의 가장 큰 도전 과제 중 하나입니다.
사전 학습(Pre-training)은 LLM이 언어의 패턴과 구조를 배우는 기초 단계입니다. 이 과정에서 모델은 방대한 텍스트 데이터를 통해 단어 간의 관계, 문법, 의미론적 지식을 습득합니다.
실제로 GPT, BERT, LLaMA 같은 모든 최신 언어 모델들은 수십억 개의 토큰으로 사전 학습됩니다. 이 과정을 이해하면 여러분만의 도메인 특화 모델을 만들거나, 기존 모델을 더 효과적으로 활용할 수 있습니다.
개요
간단히 말해서, 사전 학습은 모델이 대량의 비지도 학습 데이터로 언어의 일반적인 표현을 배우는 과정입니다. 이 과정이 필요한 이유는 모델이 특정 작업을 수행하기 전에 먼저 언어 자체를 이해해야 하기 때문입니다.
마치 아이가 특정 과목을 배우기 전에 먼저 언어를 배우는 것과 같습니다. 예를 들어, 의료 분야의 질의응답 시스템을 만들더라도 모델은 먼저 일반적인 언어 구조를 이해해야 합니다.
기존에는 작은 데이터셋으로 특정 태스크를 위해 모델을 학습했다면, 이제는 대규모 데이터로 범용적인 언어 이해 능력을 먼저 구축한 후 특정 태스크에 맞게 조정합니다. 사전 학습의 핵심 특징은 다음과 같습니다: (1) 비지도 학습 방식으로 레이블 없이 학습, (2) 다음 토큰 예측(Causal Language Modeling) 또는 마스크 언어 모델링(MLM) 사용, (3) 수십억~수조 개의 토큰 규모의 대용량 데이터 활용.
이러한 특징들이 모델에게 강력한 언어 이해 능력을 부여합니다.
코드 예제
import torch
from torch.utils.data import DataLoader
from transformers import GPT2Config, GPT2LMHeadModel, AdamW
# 모델 설정: 768차원, 12레이어, 12헤드의 GPT-2 스타일 모델
config = GPT2Config(
vocab_size=50257,
n_positions=1024,
n_embd=768,
n_layer=12,
n_head=12
)
model = GPT2LMHeadModel(config).cuda()
# 옵티마이저 설정 - AdamW 사용
optimizer = AdamW(model.parameters(), lr=5e-5, weight_decay=0.01)
# 학습 루프
for epoch in range(num_epochs):
for batch in train_dataloader:
# 입력 데이터를 GPU로 이동
input_ids = batch['input_ids'].cuda()
attention_mask = batch['attention_mask'].cuda()
# 다음 토큰 예측을 위한 포워드 패스
outputs = model(input_ids, attention_mask=attention_mask, labels=input_ids)
loss = outputs.loss
# 역전파 및 가중치 업데이트
loss.backward()
optimizer.step()
optimizer.zero_grad()
설명
이것이 하는 일: 위 코드는 GPT-2 스타일의 언어 모델을 처음부터 사전 학습시키는 기본 구조를 보여줍니다. Causal Language Modeling 방식으로 이전 토큰들을 보고 다음 토큰을 예측하도록 학습합니다.
첫 번째로, GPT2Config로 모델의 아키텍처를 정의합니다. n_embd=768은 각 토큰의 임베딩 차원을, n_layer=12는 트랜스포머 레이어 수를, n_head=12는 멀티헤드 어텐션의 헤드 개수를 의미합니다.
이 설정은 약 117M 파라미터의 모델을 만들며, 실무에서는 더 큰 모델(수십억 파라미터)을 사용합니다. 그 다음으로, 학습 루프에서 input_ids를 모델에 입력하고 동시에 labels로도 사용합니다.
이것이 핵심인데, 모델은 각 위치에서 다음 토큰을 예측하도록 학습되며, 내부적으로 Causal Mask가 적용되어 미래 토큰을 보지 못하게 합니다. outputs.loss는 자동으로 Cross-Entropy Loss를 계산합니다.
역전파 과정에서 loss.backward()로 그래디언트를 계산하고, optimizer.step()으로 가중치를 업데이트합니다. AdamW 옵티마이저는 weight decay를 적용하여 과적합을 방지하며, learning rate는 보통 학습 중에 스케줄링됩니다.
여러분이 이 코드를 사용하면 도메인 특화 언어 모델을 처음부터 구축할 수 있습니다. 실무에서는 (1) 혼합 정밀도 학습(FP16/BF16)으로 메모리 사용량 절반 감소, (2) Gradient Accumulation으로 효과적인 배치 크기 증가, (3) 분산 학습(DDP, FSDP)으로 여러 GPU 활용 등의 최적화 기법을 추가로 적용합니다.
실전 팁
💡 학습 데이터는 최소 수억 개의 토큰이 필요하며, 데이터 품질이 모델 성능을 좌우합니다. 중복 제거, 필터링, 다양성 확보가 핵심입니다.
💡 Learning Rate Warmup을 반드시 사용하세요. 처음 몇천 스텝 동안 0에서 목표 LR까지 선형 증가시키면 학습이 안정됩니다.
💡 Gradient Checkpointing을 활성화하면 메모리 사용량을 크게 줄일 수 있습니다. 속도는 약간 느려지지만 더 큰 모델이나 배치를 사용할 수 있습니다.
💡 Loss가 급격히 증가하면(Loss Spike) learning rate를 낮추고 이전 체크포인트에서 재시작하세요. 이는 수치적 불안정성의 신호입니다.
💡 Validation Perplexity를 모니터링하여 모델의 언어 이해 능력을 추적하세요. Perplexity가 낮을수록 더 좋은 언어 모델입니다.
2. Supervised Fine-tuning (SFT) - 지도 학습 기반 파인튜닝
시작하며
사전 학습된 모델을 가지고 있지만, 여러분의 특정 작업에는 잘 동작하지 않는 경험을 해보셨나요? 예를 들어, 범용 GPT 모델은 일반적인 대화는 잘하지만 의료 상담이나 법률 자문 같은 전문 분야에서는 부정확한 답변을 제공할 수 있습니다.
이런 문제는 모델이 특정 도메인의 패턴과 형식을 충분히 학습하지 못했기 때문입니다. 범용 모델은 "무엇이든 할 수 있지만 아무것도 완벽하지 않은" 상태입니다.
바로 이럴 때 필요한 것이 Supervised Fine-tuning(SFT)입니다. 고품질의 레이블된 데이터로 모델을 특정 작업에 맞게 조정하여, 원하는 형식과 스타일로 응답하도록 만듭니다.
개요
간단히 말해서, SFT는 사전 학습된 모델을 고품질의 입력-출력 쌍 데이터로 추가 학습시켜 특정 작업에 특화시키는 과정입니다. 이 과정이 필요한 이유는 사전 학습만으로는 특정 형식이나 스타일을 따르도록 할 수 없기 때문입니다.
예를 들어, 고객 서비스 챗봇을 만들 때는 친절하고 정중한 톤으로, 구조화된 형식으로 답변해야 하는데, 이런 특성은 SFT 과정에서 학습됩니다. 기존의 사전 학습이 "언어를 이해하는 법"을 배웠다면, SFT는 "특정 방식으로 대화하는 법"을 배웁니다.
마치 통역사가 일반적인 언어 능력 위에 전문 용어와 형식을 추가로 배우는 것과 같습니다. SFT의 핵심 특징은: (1) 고품질의 레이블된 데이터 사용 (보통 수천수만 개), (2) 작은 learning rate로 신중하게 학습 (보통 1e-51e-6), (3) 짧은 학습 기간 (몇 에폭).
이렇게 하면 사전 학습된 지식을 유지하면서도 새로운 패턴을 학습할 수 있습니다.
코드 예제
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments, Trainer
from datasets import load_dataset
# 사전 학습된 모델과 토크나이저 로드
model = AutoModelForCausalLM.from_pretrained("gpt2-medium").cuda()
tokenizer = AutoTokenizer.from_pretrained("gpt2-medium")
tokenizer.pad_token = tokenizer.eos_token
# 데이터 준비: 입력-출력 쌍을 "instruction: ... response: ..." 형식으로
def format_instruction(example):
text = f"instruction: {example['instruction']}\nresponse: {example['response']}"
return tokenizer(text, truncation=True, max_length=512, padding="max_length")
dataset = load_dataset("your_custom_dataset")
tokenized_dataset = dataset.map(format_instruction, remove_columns=dataset['train'].column_names)
# 파인튜닝 설정: 작은 LR과 짧은 학습
training_args = TrainingArguments(
output_dir="./sft-model",
num_train_epochs=3,
per_device_train_batch_size=4,
learning_rate=2e-5, # 사전 학습보다 10배 작은 LR
warmup_steps=100,
logging_steps=10,
save_strategy="epoch"
)
# Trainer로 파인튜닝 실행
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_dataset['train'],
eval_dataset=tokenized_dataset['validation']
)
trainer.train()
설명
이것이 하는 일: 위 코드는 사전 학습된 GPT-2 모델을 instruction-response 형식의 데이터로 파인튜닝하여, 지시사항에 따라 응답하는 모델로 변환합니다. 첫 번째로, from_pretrained로 사전 학습된 모델을 로드합니다.
이 모델은 이미 언어의 기본 구조를 알고 있으며, 우리는 여기에 특정 작업 패턴만 추가로 학습시킵니다. tokenizer.pad_token을 설정하는 것은 배치 처리 시 시퀀스 길이를 맞추기 위해 필수입니다.
그 다음으로, format_instruction 함수에서 데이터를 특정 형식으로 변환합니다. "instruction: ...
response: ..." 형식은 모델이 지시사항과 응답을 구분하도록 합니다. truncation과 padding을 적용하여 모든 샘플이 동일한 길이(512 토큰)를 갖도록 하며, 이는 효율적인 배치 처리를 가능하게 합니다.
TrainingArguments에서 learning_rate=2e-5는 매우 중요합니다. 사전 학습 시 사용한 learning rate(보통 1e-4~5e-5)보다 작은 값을 사용해야 기존 지식을 크게 손상시키지 않으면서 새로운 패턴을 학습할 수 있습니다.
num_train_epochs=3은 보통 충분하며, 너무 많이 학습하면 과적합(catastrophic forgetting)이 발생합니다. Trainer 클래스는 학습 루프, 그래디언트 업데이트, 로깅, 체크포인트 저장 등을 자동으로 처리합니다.
eval_dataset을 제공하면 각 에폭마다 검증 loss를 확인하여 과적합을 모니터링할 수 있습니다. 여러분이 이 코드를 사용하면 범용 언어 모델을 특정 도메인이나 작업에 특화된 모델로 변환할 수 있습니다.
실무에서는 (1) 데이터 품질이 가장 중요 - 잘못된 레이블은 모델을 망칩니다, (2) Early Stopping으로 과적합 방지, (3) 여러 checkpoint를 저장하고 검증 성능이 가장 좋은 것 선택 등의 전략을 사용합니다.
실전 팁
💡 데이터 품질 > 데이터 양입니다. 잘 작성된 1000개의 예시가 부실한 10000개보다 훨씬 효과적입니다. 각 샘플을 직접 검토하세요.
💡 Validation Loss가 증가하기 시작하면 즉시 학습을 중단하세요. 이는 과적합의 명확한 신호이며, 더 학습하면 일반화 능력이 떨어집니다.
💡 다양한 프롬프트 형식을 실험해보세요. "Q: ... A: ...", "instruction: ... response: ...", "### Human: ... ### Assistant: ..." 등 형식에 따라 성능이 달라질 수 있습니다.
💡 Gradient Accumulation을 사용하여 효과적인 배치 크기를 늘리세요. 작은 GPU에서도 큰 배치 효과를 얻을 수 있으며, 학습이 더 안정적입니다.
💡 모델의 응답을 주기적으로 샘플링하여 질적으로 평가하세요. Loss 값만으로는 실제 응답 품질을 알 수 없습니다.
3. LoRA (Low-Rank Adaptation) - 효율적인 파인튜닝
시작하며
수십억 개의 파라미터를 가진 LLM을 파인튜닝하려고 할 때, GPU 메모리 부족 에러를 겪어본 적 있나요? 예를 들어, 7B 파라미터 모델을 full fine-tuning하려면 최소 80GB 이상의 GPU 메모리가 필요하며, 이는 개인이나 작은 팀에게는 현실적이지 않습니다.
이런 문제는 모든 파라미터의 그래디언트를 계산하고 저장해야 하기 때문에 발생합니다. 또한 여러 작업을 위해 각각 전체 모델을 저장하면 스토리지 비용도 엄청나게 증가합니다.
바로 이럴 때 필요한 것이 LoRA(Low-Rank Adaptation)입니다. 전체 파라미터의 1% 미만만 학습시켜도 full fine-tuning과 비슷한 성능을 내면서, 메모리 사용량을 크게 줄입니다.
개요
간단히 말해서, LoRA는 모델의 가중치 행렬에 저랭크(low-rank) 분해된 작은 행렬을 추가하여, 원본 가중치는 고정하고 이 작은 행렬만 학습시키는 기법입니다. 이 기법이 필요한 이유는 대부분의 파인튜닝에서 모델의 변화가 실제로는 저차원 공간에서 일어나기 때문입니다.
즉, 모든 파라미터를 바꿀 필요 없이 중요한 방향으로만 조정하면 됩니다. 예를 들어, 4096x4096 크기의 가중치 행렬을 직접 업데이트하는 대신, 4096x8과 8x4096의 두 작은 행렬만 학습시킵니다.
기존에는 전체 모델을 복사하고 모든 파라미터를 업데이트했다면, LoRA는 원본 모델은 그대로 두고 작은 어댑터만 추가합니다. 이는 여러 작업에 대해 하나의 base 모델과 여러 개의 작은 어댑터를 사용할 수 있게 합니다.
LoRA의 핵심 특징은: (1) 학습 가능한 파라미터를 0.1%1%로 감소, (2) 메모리 사용량 34배 감소, (3) 추론 시 지연 시간 거의 없음, (4) 여러 LoRA 어댑터를 쉽게 교체 가능. 이러한 특징들이 대규모 모델의 실용적인 파인튜닝을 가능하게 합니다.
코드 예제
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import get_peft_model, LoraConfig, TaskType
# 기본 모델 로드
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf", device_map="auto")
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")
# LoRA 설정: rank=8, alpha=32 사용
lora_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
r=8, # Low-rank의 차원 (작을수록 파라미터 적음)
lora_alpha=32, # LoRA의 스케일링 팩터
lora_dropout=0.1, # 드롭아웃으로 과적합 방지
target_modules=["q_proj", "v_proj"], # 어텐션의 Q, V 행렬에만 적용
)
# LoRA 모델로 변환
model = get_peft_model(model, lora_config)
# 학습 가능한 파라미터 확인
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
total_params = sum(p.numel() for p in model.parameters())
print(f"Trainable: {trainable_params:,} / Total: {total_params:,} "
f"({100 * trainable_params / total_params:.2f}%)")
# 일반적인 학습 루프로 학습 (Trainer 또는 custom loop)
# ... 학습 코드 ...
# LoRA 어댑터만 저장 (수 MB)
model.save_pretrained("./lora-adapter")
설명
이것이 하는 일: 위 코드는 7B 파라미터의 Llama-2 모델에 LoRA를 적용하여, 전체 파라미터의 약 0.1%만 학습 가능하도록 만듭니다. 원본 모델의 가중치는 고정되고 작은 LoRA 행렬만 업데이트됩니다.
첫 번째로, LoraConfig에서 핵심 하이퍼파라미터를 설정합니다. r=8은 저랭크 분해의 rank로, 이 값이 작을수록 파라미터가 적지만 표현력도 감소합니다.
lora_alpha=32는 LoRA 업데이트의 스케일링 팩터로, 실제 업데이트는 (alpha/r) 비율로 스케일됩니다. 즉, 이 경우 32/8=4배로 증폭됩니다.
그 다음으로, target_modules에서 어느 레이어에 LoRA를 적용할지 선택합니다. 보통 어텐션 메커니즘의 query(q_proj)와 value(v_proj) 프로젝션에 적용하면 좋은 결과를 얻습니다.
모든 레이어에 적용할 수도 있지만(["q_proj", "v_proj", "k_proj", "o_proj"]), 더 많은 메모리를 사용합니다. get_peft_model이 실행되면, 선택된 레이어의 가중치 행렬 W에 대해 W' = W + BA 형태의 업데이트가 추가됩니다.
여기서 B는 (d x r), A는 (r x d) 크기이므로, 원본 (d x d) 대신 2dr 개의 파라미터만 학습됩니다. r=8, d=4096인 경우, 16M(원본) 대신 65K(LoRA) 파라미터만 사용합니다.
학습 후 save_pretrained는 LoRA 어댑터만 저장합니다(보통 수십 MB). 원본 모델은 그대로 두고 여러 작업에 대해 서로 다른 LoRA 어댑터를 빠르게 교체하여 사용할 수 있습니다.
여러분이 이 코드를 사용하면 제한된 GPU 자원으로도 대규모 모델을 파인튜닝할 수 있습니다. 실무에서는 (1) r 값을 4~64 범위에서 실험 (높을수록 성능 좋지만 메모리 많이 사용), (2) QLoRA로 4-bit 양자화와 결합하면 메모리 추가 절감, (3) 여러 작업을 위한 LoRA 어댑터를 모듈식으로 관리 등의 전략을 활용합니다.
실전 팁
💡 rank(r) 선택이 중요합니다. 간단한 작업은 r=48, 복잡한 작업은 r=1664를 사용하세요. 먼저 작은 값으로 시작해서 성능이 부족하면 증가시키세요.
💡 lora_alpha는 보통 r의 2~4배로 설정합니다. alpha/r 비율이 LoRA 업데이트의 영향력을 결정하므로, 이 비율로 조정하세요.
💡 QLoRA를 함께 사용하면 메모리를 더욱 절약할 수 있습니다. 4-bit 양자화된 모델에 LoRA를 적용하면 24GB GPU로도 65B 모델을 파인튜닝할 수 있습니다.
💡 여러 LoRA 어댑터를 동시에 로드하여 앙상블처럼 사용할 수 있습니다. 각 어댑터는 서로 다른 강점을 가질 수 있습니다.
💡 추론 시 merge_and_unload()로 LoRA 가중치를 원본 모델에 병합하면 추가 지연 없이 사용할 수 있습니다.
4. RLHF (Reinforcement Learning from Human Feedback) - 인간 피드백 기반 강화학습
시작하며
여러분이 파인튜닝한 모델이 기술적으로는 정확하지만, 사용자가 원하는 답변 스타일이나 안전성 기준을 충족하지 못하는 경험을 해보셨나요? 예를 들어, 모델이 유해한 콘텐츠를 생성하거나, 장황하게 설명하거나, 사용자의 의도를 잘못 이해하는 경우가 있습니다.
이런 문제는 단순히 정답 데이터로 학습하는 것만으로는 해결할 수 없습니다. "좋은 답변"의 기준은 정확성뿐만 아니라 유용성, 안전성, 톤, 길이 등 여러 주관적 요소를 포함하기 때문입니다.
바로 이럴 때 필요한 것이 RLHF(Reinforcement Learning from Human Feedback)입니다. 인간의 선호도를 학습한 보상 모델을 통해, 모델이 단순히 정확한 답변이 아닌 "더 나은" 답변을 생성하도록 유도합니다.
개요
간단히 말해서, RLHF는 (1) 인간이 여러 응답 중 선호하는 것을 선택하고, (2) 이 선호도를 예측하는 보상 모델을 학습시킨 후, (3) 강화학습으로 언어 모델이 높은 보상을 받는 응답을 생성하도록 최적화하는 3단계 과정입니다. 이 과정이 필요한 이유는 "좋은 답변"을 직접 작성하는 것보다 "어느 답변이 더 나은지" 비교하는 것이 훨씬 쉽고 확장 가능하기 때문입니다.
또한 안전성, 유용성, 정직함 같은 추상적인 목표를 달성할 수 있습니다. 예를 들어, ChatGPT는 RLHF를 통해 유해한 요청을 거부하고 도움이 되는 방식으로 답변하도록 학습되었습니다.
기존의 SFT가 "이렇게 답변해야 한다"를 직접 가르친다면, RLHF는 "이런 답변이 더 좋다"는 선호도를 학습합니다. 이는 모델이 더 창의적이고 다양한 방법으로 목표를 달성할 수 있게 합니다.
RLHF의 핵심 특징은: (1) Reward Model이 인간의 선호도를 수치화, (2) PPO(Proximal Policy Optimization) 같은 강화학습 알고리즘 사용, (3) KL Divergence 제약으로 원본 모델에서 크게 벗어나지 않도록 제어. 이러한 특징들이 GPT-4, Claude 같은 최신 대화형 AI의 핵심 기술입니다.
코드 예제
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from trl import PPOTrainer, PPOConfig, AutoModelForCausalLMWithValueHead
# SFT된 모델을 Value Head와 함께 로드
model = AutoModelForCausalLMWithValueHead.from_pretrained("sft-model")
ref_model = AutoModelForCausalLM.from_pretrained("sft-model") # KL 제약용 참조 모델
tokenizer = AutoTokenizer.from_pretrained("sft-model")
# 보상 모델 로드 (인간 선호도로 학습됨)
reward_model = AutoModelForCausalLM.from_pretrained("reward-model")
# PPO 설정
ppo_config = PPOConfig(
learning_rate=1.41e-5,
batch_size=16,
mini_batch_size=4,
kl_penalty="kl", # KL divergence 페널티 적용
init_kl_coef=0.2, # KL 제약 강도
)
ppo_trainer = PPOTrainer(ppo_config, model, ref_model, tokenizer)
# RLHF 학습 루프
for query_tensor in queries: # 프롬프트들
# 모델이 응답 생성
response_tensor = ppo_trainer.generate(query_tensor, max_length=128)
# 보상 모델로 응답의 품질 점수 계산
with torch.no_grad():
reward = reward_model(query_tensor, response_tensor)
# PPO로 정책 업데이트 (높은 보상을 받는 방향으로)
stats = ppo_trainer.step([query_tensor], [response_tensor], [reward])
설명
이것이 하는 일: 위 코드는 이미 SFT된 모델을 RLHF로 추가 학습시켜, 인간이 선호하는 응답을 생성하도록 만듭니다. PPO 알고리즘을 사용하여 보상을 최대화하면서도 원본 모델에서 크게 벗어나지 않도록 합니다.
첫 번째로, AutoModelForCausalLMWithValueHead는 일반 언어 모델에 Value Head를 추가합니다. Value Head는 각 상태(토큰 시퀀스)의 가치를 예측하는 작은 신경망으로, PPO 알고리즘에서 어드밴티지(advantage) 계산에 사용됩니다.
ref_model은 KL divergence 계약을 위한 참조 모델로, 학습 중 업데이트되지 않습니다. 그 다음으로, reward_model은 프롬프트와 응답을 받아 품질 점수를 반환합니다.
이 모델은 사전에 인간의 비교 데이터로 학습되었습니다. 예를 들어, "응답 A가 응답 B보다 낫다"는 수천~수만 개의 비교 데이터로 학습하여, 어떤 응답이 더 유용하고 안전한지 예측할 수 있습니다.
PPO 학습 루프에서는 먼저 현재 정책(모델)으로 응답을 생성하고, 보상 모델로 점수를 받습니다. 그런 다음 PPO 알고리즘이 이 보상을 최대화하도록 정책을 업데이트합니다.
중요한 점은 kl_penalty가 모델이 ref_model(원본 SFT 모델)에서 너무 멀리 벗어나는 것을 방지한다는 것입니다. 이는 모델이 보상을 최대화하려다 이상한 텍스트를 생성하는 "reward hacking"을 막습니다.
PPOTrainer.step()은 복잡한 PPO 알고리즘(Advantage 계산, Clipped Surrogate Objective, Value Function 학습 등)을 자동으로 처리합니다. 내부적으로는 여러 mini-batch에 걸쳐 정책을 업데이트하며, 각 업데이트마다 KL divergence를 모니터링합니다.
여러분이 이 코드를 사용하면 모델이 단순히 정확한 답변이 아닌, 인간이 선호하는 스타일과 안전성을 갖춘 답변을 생성하도록 만들 수 있습니다. 실무에서는 (1) 고품질의 선호도 데이터 수집이 가장 중요 (보통 수만 개 필요), (2) Reward Model이 잘못 학습되면 전체 RLHF가 실패하므로 충분히 검증, (3) KL 제약이 너무 강하면 학습이 안 되고 너무 약하면 붕괴하므로 세심한 튜닝 필요 등의 도전 과제가 있습니다.
실전 팁
💡 Reward Model을 먼저 철저히 검증하세요. 잘못된 보상 신호는 모델을 잘못된 방향으로 유도합니다. 다양한 응답에 대한 보상 점수를 샘플링하여 확인하세요.
💡 KL divergence 계수를 신중하게 조정하세요. 너무 높으면 모델이 거의 변하지 않고, 너무 낮으면 모델이 붕괴할 수 있습니다. 보통 0.1~0.5 범위에서 시작합니다.
💡 학습 중 생성된 응답을 주기적으로 샘플링하여 질적으로 평가하세요. 보상 점수가 높아져도 실제 응답 품질이 나빠질 수 있습니다(reward hacking).
💡 DPO(Direct Preference Optimization)를 대안으로 고려하세요. RLHF보다 간단하고 안정적이며, 별도의 보상 모델 없이 선호도 데이터로 직접 학습합니다.
💡 RLHF는 계산 비용이 매우 높습니다. 먼저 작은 모델로 파이프라인을 검증한 후 큰 모델로 스케일업하세요.
5. Gradient Accumulation - 효과적인 배치 크기 증가
시작하며
여러분이 큰 배치 크기로 학습하고 싶지만 GPU 메모리가 부족한 상황을 겪어본 적 있나요? 예를 들어, 연구 논문에서는 배치 크기 256으로 학습했는데, 여러분의 GPU는 배치 크기 4도 버거운 경우가 있습니다.
이런 문제는 큰 배치가 메모리에 동시에 올라가야 하기 때문입니다. 큰 배치는 학습을 안정화하고 더 나은 그래디언트 추정을 제공하지만, 하드웨어 한계로 인해 사용할 수 없는 경우가 많습니다.
바로 이럴 때 필요한 것이 Gradient Accumulation입니다. 작은 배치를 여러 번 처리하면서 그래디언트를 누적한 후, 한 번에 업데이트하여 큰 배치와 동일한 효과를 얻습니다.
개요
간단히 말해서, Gradient Accumulation은 여러 mini-batch의 그래디언트를 합산한 후 한 번에 가중치를 업데이트하여, 제한된 메모리로도 큰 effective batch size를 사용할 수 있게 하는 기법입니다. 이 기법이 필요한 이유는 큰 배치 크기가 학습에 여러 이점을 제공하기 때문입니다.
큰 배치는 그래디언트 추정의 분산을 줄이고, 학습을 안정화하며, 더 높은 learning rate를 사용할 수 있게 합니다. 예를 들어, BERT 같은 모델은 배치 크기 256~1024로 학습되는데, 이는 단일 GPU로는 불가능합니다.
기존에는 여러 GPU를 사용하거나 작은 배치로 타협해야 했다면, Gradient Accumulation은 단일 GPU에서도 큰 effective batch size를 달성할 수 있게 합니다. Gradient Accumulation의 핵심 특징은: (1) 메모리 사용량 증가 없이 effective batch size 증가, (2) 학습 속도는 약간 느려지지만 품질 향상, (3) 구현이 간단하고 모든 모델에 적용 가능.
이는 제한된 자원으로 고품질 학습을 가능하게 합니다.
코드 예제
import torch
from transformers import AutoModelForCausalLM, AdamW
model = AutoModelForCausalLM.from_pretrained("gpt2").cuda()
optimizer = AdamW(model.parameters(), lr=5e-5)
# 설정: 물리적 배치 4, accumulation 8번 = effective 배치 32
batch_size = 4
accumulation_steps = 8
effective_batch_size = batch_size * accumulation_steps
# 학습 루프
optimizer.zero_grad()
for step, batch in enumerate(train_dataloader):
# 포워드 패스
outputs = model(**batch)
loss = outputs.loss
# 그래디언트를 accumulation_steps로 나누어 평균 계산
loss = loss / accumulation_steps
# 역전파: 그래디언트 누적 (가중치는 업데이트하지 않음)
loss.backward()
# accumulation_steps마다 한 번씩 가중치 업데이트
if (step + 1) % accumulation_steps == 0:
# 그래디언트 클리핑으로 안정성 향상
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step() # 누적된 그래디언트로 가중치 업데이트
optimizer.zero_grad() # 그래디언트 초기화
print(f"Step {step}: Updated weights with effective batch {effective_batch_size}")
설명
이것이 하는 일: 위 코드는 배치 크기 4로 8번 누적하여 effective 배치 크기 32의 효과를 얻습니다. 메모리는 배치 4만큼만 사용하면서도 학습은 배치 32처럼 진행됩니다.
첫 번째로, loss를 accumulation_steps로 나누는 것이 매우 중요합니다. 이렇게 하지 않으면 그래디언트가 accumulation_steps배 커져서 학습이 불안정해집니다.
각 mini-batch의 그래디언트를 평균 내는 효과를 얻기 위해 loss를 미리 나눕니다. 그 다음으로, loss.backward()를 호출하면 그래디언트가 계산되어 각 파라미터의 .grad 속성에 누적됩니다.
PyTorch는 기본적으로 그래디언트를 누적하므로(+=), optimizer.zero_grad()를 호출하지 않는 한 계속 더해집니다. 이 특성을 활용하여 여러 번 backward()를 호출합니다.
accumulation_steps에 도달하면 실제 가중치 업데이트가 일어납니다. clip_grad_norm_은 그래디언트의 노름이 너무 커지는 것을 방지하여 학습을 안정화합니다.
optimizer.step()이 누적된 그래디언트를 사용하여 가중치를 업데이트하고, optimizer.zero_grad()가 다음 accumulation을 위해 그래디언트를 0으로 초기화합니다. 중요한 점은 forward pass와 backward pass는 작은 배치로 수행되므로 메모리는 적게 사용하지만, 가중치 업데이트는 누적된 그래디언트로 수행되므로 큰 배치와 동일한 효과를 얻는다는 것입니다.
유일한 차이점은 학습 속도가 약간 느려진다는 것입니다(여러 번의 forward/backward가 필요하므로). 여러분이 이 코드를 사용하면 제한된 GPU로도 대규모 배치 학습의 이점을 얻을 수 있습니다.
실무에서는 (1) Learning rate를 배치 크기에 비례하여 조정(Linear Scaling Rule), (2) Batch Normalization 대신 Layer Normalization 사용(BN은 accumulation과 잘 맞지 않음), (3) 에폭의 마지막에 남은 그래디언트 처리 등을 고려해야 합니다.
실전 팁
💡 Learning rate를 effective batch size에 맞게 조정하세요. 배치가 k배 커지면 LR도 k배 증가시키는 Linear Scaling Rule을 따르세요.
💡 에폭의 마지막에 accumulation이 완료되지 않은 그래디언트가 있을 수 있습니다. 학습 루프 끝에 남은 그래디언트로 한 번 더 업데이트하거나 버리는 로직을 추가하세요.
💡 Mixed Precision Training(FP16/BF16)과 함께 사용하면 메모리를 더욱 절약할 수 있습니다. 두 기법은 완벽하게 호환됩니다.
💡 너무 큰 accumulation_steps는 실제로 도움이 되지 않을 수 있습니다. 보통 4~32 범위에서 실험하며, 너무 크면 학습 속도만 느려집니다.
💡 분산 학습(DDP)과 함께 사용할 때는 각 GPU에서 독립적으로 accumulation이 일어나므로, 전체 effective batch size = GPUs × batch_size × accumulation_steps입니다.
6. Mixed Precision Training - 혼합 정밀도 학습
시작하며
여러분이 대규모 모델을 학습시킬 때 GPU 메모리가 부족하거나 학습 속도가 너무 느린 경험을 해보셨나요? 예를 들어, 1B 파라미터 모델을 FP32로 학습하면 4GB의 메모리가 필요하고, 학습 시간도 매우 오래 걸립니다.
이런 문제는 기본적으로 32비트 부동소수점(FP32)을 사용하기 때문입니다. 모든 계산과 저장에 높은 정밀도를 사용하면 메모리와 계산 시간이 크게 증가합니다.
바로 이럴 때 필요한 것이 Mixed Precision Training입니다. 16비트(FP16 또는 BF16)와 32비트를 혼합하여 사용함으로써, 메모리 사용량을 절반으로 줄이고 학습 속도를 2~3배 향상시키면서도 학습 품질을 유지합니다.
개요
간단히 말해서, Mixed Precision Training은 대부분의 연산을 16비트로 수행하여 속도와 메모리 효율을 높이되, 중요한 부분(가중치 업데이트, 손실 계산)은 32비트로 유지하여 수치적 안정성을 보장하는 학습 기법입니다. 이 기법이 필요한 이유는 최신 GPU(V100, A100, H100 등)가 FP16/BF16 연산에 특화된 Tensor Core를 제공하여, FP32보다 2~8배 빠른 처리 속도를 내기 때문입니다.
또한 메모리 사용량이 절반으로 줄어 더 큰 배치나 모델을 사용할 수 있습니다. 예를 들어, A100 GPU는 FP16에서 312 TFLOPS, FP32에서 19.5 TFLOPS로 16배 차이가 납니다.
기존에는 안전하게 FP32로만 학습했다면, 이제는 FP16/BF16로 대부분을 처리하고 필요한 부분만 FP32를 사용합니다. BF16(Brain Float 16)은 FP16보다 수치 범위가 넓어 최신 모델에서 선호됩니다.
Mixed Precision의 핵심 특징은: (1) 메모리 사용량 약 50% 감소, (2) 학습 속도 2~3배 향상, (3) Loss Scaling으로 작은 그래디언트 보존, (4) Master Weights를 FP32로 유지하여 정밀한 업데이트. 이러한 특징들이 대규모 모델 학습의 표준이 되었습니다.
코드 예제
import torch
from transformers import AutoModelForCausalLM, AdamW
from torch.cuda.amp import autocast, GradScaler
model = AutoModelForCausalLM.from_pretrained("gpt2").cuda()
optimizer = AdamW(model.parameters(), lr=5e-5)
# GradScaler: Loss Scaling으로 작은 그래디언트 보존
scaler = GradScaler()
# 학습 루프
for batch in train_dataloader:
optimizer.zero_grad()
# autocast: 이 블록 안에서 자동으로 FP16 사용
with autocast():
# 포워드 패스가 FP16으로 실행됨
outputs = model(**batch)
loss = outputs.loss
# Scaled loss로 역전파 (그래디언트 언더플로우 방지)
scaler.scale(loss).backward()
# 그래디언트 클리핑 (scaler 고려)
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
# 스케일된 그래디언트로 가중치 업데이트
scaler.step(optimizer)
# 다음 iteration을 위해 scaler 상태 업데이트
scaler.update()
설명
이것이 하는 일: 위 코드는 PyTorch의 자동 혼합 정밀도(AMP) 기능을 사용하여 FP16으로 학습하면서도 수치적 안정성을 유지합니다. autocast가 자동으로 연산을 FP16으로 변환하고, GradScaler가 작은 그래디언트를 보존합니다.
첫 번째로, autocast 컨텍스트 매니저 안에서 실행되는 모든 연산이 자동으로 FP16으로 캐스팅됩니다. PyTorch는 각 연산 타입에 대해 FP16이 안전한지 미리 정의된 규칙을 가지고 있어, 예를 들어 행렬 곱셈은 FP16으로, Softmax는 FP32로 실행합니다.
이는 수동으로 타입 캐스팅하는 것보다 안전하고 편리합니다. 그 다음으로, scaler.scale(loss).backward()가 Loss Scaling을 수행합니다.
FP16은 표현 범위가 좁아서(약 6e-8 이하) 작은 그래디언트가 0으로 언더플로우될 수 있습니다. Loss Scaling은 loss를 큰 수(예: 2^16)로 곱해서 역전파하고, 나중에 그래디언트를 다시 스케일 다운합니다.
이렇게 하면 작은 그래디언트도 보존됩니다. scaler.unscale_(optimizer) 단계에서 그래디언트가 원래 스케일로 복원됩니다.
그래디언트 클리핑은 반드시 unscale 후에 수행해야 정확한 노름으로 클리핑됩니다. 그렇지 않으면 스케일된 값으로 클리핑되어 의미가 없습니다.
scaler.step(optimizer)는 그래디언트에 inf나 nan이 있는지 확인하고, 있으면 업데이트를 건너뜁니다(수치적 오버플로우 발생). 없으면 정상적으로 가중치를 업데이트합니다.
scaler.update()는 내부 스케일 팩터를 조정하여, 오버플로우가 자주 발생하면 스케일을 낮추고 안정적이면 높입니다. 여러분이 이 코드를 사용하면 동일한 하드웨어로 더 큰 모델이나 배치를 사용하거나, 학습 시간을 크게 단축할 수 있습니다.
실무에서는 (1) BF16을 지원하는 GPU(A100, H100)에서는 BF16 사용 권장(Loss Scaling 불필요), (2) 일부 연산(Layer Norm, Softmax)은 자동으로 FP32 유지, (3) Gradient Accumulation과 완벽 호환 등을 고려합니다.
실전 팁
💡 최신 GPU(A100, H100)에서는 BF16을 사용하세요. BF16은 FP32와 동일한 지수 범위를 가져 Loss Scaling이 필요 없고 더 안정적입니다.
💡 Loss Scaling이 너무 자주 실패하면(inf/nan) learning rate를 낮추세요. 수치적 불안정성의 신호일 수 있습니다.
💡 혼합 정밀도는 Tensor Core를 사용하므로, 텐서 크기를 8의 배수로 맞추면 성능이 최적화됩니다(예: hidden_size=768).
💡 일부 연산(예: 큰 Softmax)은 FP16에서 불안정할 수 있습니다. 자동으로 FP32로 처리되지만, 이상한 loss 패턴이 보이면 특정 연산을 수동으로 FP32로 고정하세요.
💡 DeepSpeed나 FSDP 같은 분산 학습 프레임워크는 자체 혼합 정밀도 구현을 제공합니다. 이들을 사용할 때는 PyTorch AMP 대신 프레임워크의 기능을 사용하세요.
7. Learning Rate Scheduling - 학습률 스케줄링
시작하며
여러분이 모델을 학습시킬 때 초반에는 loss가 빠르게 감소하다가 나중에는 진동하거나 수렴하지 않는 경험을 해보셨나요? 예를 들어, 고정된 learning rate로 학습하면 초반에는 너무 느리게 학습되고, 후반에는 최적점 주변에서 진동만 하는 경우가 있습니다.
이런 문제는 학습의 각 단계마다 적절한 learning rate가 다르기 때문입니다. 초반에는 빠르게 좋은 방향을 찾아야 하고, 후반에는 세밀하게 최적점에 수렴해야 합니다.
바로 이럴 때 필요한 것이 Learning Rate Scheduling입니다. 학습 진행에 따라 learning rate를 동적으로 조정하여, 빠른 수렴과 안정적인 최적화를 동시에 달성합니다.
개요
간단히 말해서, Learning Rate Scheduling은 학습 과정에서 learning rate를 미리 정의된 규칙에 따라 변경하여, 학습 초기에는 빠르게 탐색하고 후기에는 세밀하게 조정하는 기법입니다. 이 기법이 필요한 이유는 고정된 learning rate로는 빠른 수렴과 안정성을 동시에 얻기 어렵기 때문입니다.
높은 LR은 빠르지만 불안정하고, 낮은 LR은 안정적이지만 느립니다. Scheduling은 이 두 가지 이점을 모두 활용합니다.
예를 들어, BERT는 Warmup + Linear Decay, GPT-3는 Cosine Annealing을 사용하여 학습 품질을 크게 향상시켰습니다. 기존에는 고정 LR로 학습하고 수동으로 조정했다면, 이제는 자동으로 LR이 조정되어 최적의 학습 곡선을 얻습니다.
Warmup은 초기 불안정성을 방지하고, Decay는 후반 수렴을 돕습니다. Learning Rate Scheduling의 핵심 패턴은: (1) Warmup - 처음 몇 스텝 동안 0에서 목표 LR로 증가, (2) Constant - 일정 기간 고정, (3) Decay - Cosine, Linear, Exponential 등으로 감소.
이러한 패턴들이 대부분의 최신 모델에서 표준으로 사용됩니다.
코드 예제
import torch
from transformers import AutoModelForCausalLM, AdamW, get_cosine_schedule_with_warmup
model = AutoModelForCausalLM.from_pretrained("gpt2").cuda()
optimizer = AdamW(model.parameters(), lr=5e-4) # Peak LR
# 총 학습 스텝 계산
num_epochs = 3
steps_per_epoch = len(train_dataloader)
total_steps = num_epochs * steps_per_epoch
warmup_steps = int(0.1 * total_steps) # 전체의 10%를 warmup
# Cosine Annealing with Warmup 스케줄러
scheduler = get_cosine_schedule_with_warmup(
optimizer,
num_warmup_steps=warmup_steps,
num_training_steps=total_steps
)
# 학습 루프
for epoch in range(num_epochs):
for step, batch in enumerate(train_dataloader):
optimizer.zero_grad()
outputs = model(**batch)
loss = outputs.loss
loss.backward()
optimizer.step()
scheduler.step() # 매 스텝마다 LR 업데이트
# 현재 LR 확인
current_lr = scheduler.get_last_lr()[0]
if step % 100 == 0:
print(f"Epoch {epoch}, Step {step}, LR: {current_lr:.2e}, Loss: {loss:.4f}")
설명
이것이 하는 일: 위 코드는 Cosine Annealing with Warmup 스케줄을 사용하여, 처음 10% 스텝 동안 LR을 0에서 5e-4로 선형 증가시킨 후, 코사인 곡선을 따라 0에 가까워지도록 감소시킵니다. 첫 번째로, warmup_steps를 전체 학습 스텝의 10%로 설정합니다.
Warmup 단계에서는 LR이 0에서 시작하여 선형적으로 peak LR(5e-4)까지 증가합니다. 이는 학습 초기의 큰 그래디언트로 인한 불안정성을 방지합니다.
특히 대규모 배치나 Adam 옵티마이저를 사용할 때 Warmup은 거의 필수적입니다. 그 다음으로, Warmup 이후에는 Cosine Annealing이 적용됩니다.
LR이 코사인 함수를 따라 부드럽게 감소하여 최종적으로 거의 0에 도달합니다. 수식은 lr = 0.5 * peak_lr * (1 + cos(π * progress))이며, progress는 0에서 1로 증가합니다.
Cosine은 Linear Decay보다 부드러워 학습이 더 안정적입니다. scheduler.step()은 매 optimizer.step() 직후 호출되어야 합니다.
이전에는 에폭마다 호출했지만, 현대적인 학습에서는 스텝마다 호출하여 더 세밀한 제어를 합니다. scheduler.get_last_lr()로 현재 LR을 확인하여 스케줄이 제대로 작동하는지 모니터링할 수 있습니다.
Cosine Annealing의 장점은 학습 후반에도 어느 정도 탐색이 가능하다는 것입니다. Linear Decay는 급격히 감소하지만, Cosine은 초반에는 천천히 감소하다가 후반에 빠르게 감소합니다.
이는 더 나은 로컬 미니마를 찾을 기회를 제공합니다. 여러분이 이 코드를 사용하면 학습이 더 빠르고 안정적으로 수렴하며, 최종 성능도 향상됩니다.
실무에서는 (1) Warmup 비율을 작업에 따라 조정(보통 5~10%), (2) Linear Decay도 시도(간단하고 효과적), (3) Cosine Annealing with Restarts로 여러 사이클 사용, (4) ReduceLROnPlateau로 검증 성능 기반 조정 등의 전략을 활용합니다.
실전 팁
💡 Warmup은 대부분의 경우 도움이 됩니다. 전체 스텝의 5~10%를 warmup으로 설정하세요. 배치가 크거나 Adam을 사용할 때 특히 중요합니다.
💡 Cosine vs Linear 선택: Cosine은 부드럽고 안정적이며, Linear는 간단하고 빠릅니다. 둘 다 시도해보고 검증 성능이 좋은 것을 선택하세요.
💡 scheduler.step()의 위치가 중요합니다. optimizer.step() 직후, 다음 iteration 전에 호출해야 정확한 스케줄을 따릅니다.
💡 학습 중 LR을 시각화하여 스케줄이 의도대로 작동하는지 확인하세요. 예상과 다른 패턴이 보이면 total_steps 계산을 확인하세요.
💡 Transfer Learning 시에는 기본 LR보다 10100배 작은 LR로 시작하고, warmup을 더 길게(2030%) 설정하여 사전 학습된 지식을 보존하세요.
8. Regularization Techniques - 정규화 기법
시작하며
여러분이 모델을 학습시킬 때 훈련 데이터에서는 성능이 좋지만 새로운 데이터에서는 성능이 크게 떨어지는 경험을 해보셨나요? 예를 들어, 훈련 loss는 거의 0에 가까운데 검증 loss는 계속 증가하는 과적합(overfitting) 현상이 발생합니다.
이런 문제는 모델이 훈련 데이터의 특정 패턴이나 노이즈까지 암기해버리기 때문입니다. 특히 대규모 모델은 수십억 개의 파라미터로 거의 모든 것을 외울 수 있어, 일반화 능력을 잃기 쉽습니다.
바로 이럴 때 필요한 것이 다양한 Regularization 기법입니다. Dropout, Weight Decay, Label Smoothing 등을 통해 모델이 과도하게 훈련 데이터에 맞춰지는 것을 방지하고, 새로운 데이터에도 잘 작동하도록 만듭니다.
개요
간단히 말해서, Regularization은 모델의 복잡도를 제한하거나 학습 과정에 노이즈를 추가하여, 훈련 데이터에 과적합되는 것을 방지하고 일반화 성능을 향상시키는 기법들의 총칭입니다. 이 기법들이 필요한 이유는 모델의 표현력과 일반화 능력 사이에 트레이드오프가 있기 때문입니다.
표현력이 높을수록 복잡한 패턴을 학습하지만, 노이즈까지 학습할 위험도 증가합니다. Regularization은 이 균형을 맞춥니다.
예를 들어, GPT-3는 175B 파라미터를 가지지만 Dropout과 Weight Decay 없이는 제대로 일반화되지 않습니다. 기존에는 모델 크기를 줄이거나 Early Stopping으로 과적합을 방지했다면, 이제는 큰 모델을 사용하면서도 적절한 Regularization으로 일반화 능력을 유지합니다.
주요 Regularization 기법은: (1) Dropout - 학습 중 랜덤하게 뉴런을 비활성화, (2) Weight Decay - 가중치의 L2 노름에 페널티, (3) Label Smoothing - 원-핫 레이블을 부드럽게 만들어 과신 방지. 이러한 기법들을 조합하여 강건한 모델을 만듭니다.
코드 예제
import torch
import torch.nn as nn
from transformers import AutoModelForCausalLM, AdamW
# Dropout이 포함된 모델 (기본적으로 포함됨)
model = AutoModelForCausalLM.from_pretrained(
"gpt2",
attn_pdrop=0.1, # 어텐션 드롭아웃
resid_pdrop=0.1, # Residual connection 드롭아웃
embd_pdrop=0.1 # 임베딩 드롭아웃
).cuda()
# Weight Decay를 포함한 옵티마이저
# Layer Norm과 Bias는 decay 제외
no_decay = ['bias', 'LayerNorm.weight']
optimizer_grouped_parameters = [
{
'params': [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay)],
'weight_decay': 0.01 # Weight Decay 적용
},
{
'params': [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay)],
'weight_decay': 0.0 # Weight Decay 미적용
}
]
optimizer = AdamW(optimizer_grouped_parameters, lr=5e-5)
# Label Smoothing을 포함한 Loss
class LabelSmoothingLoss(nn.Module):
def __init__(self, smoothing=0.1):
super().__init__()
self.smoothing = smoothing
def forward(self, logits, labels):
log_probs = torch.nn.functional.log_softmax(logits, dim=-1)
# 정답 확률을 (1-smoothing), 나머지를 smoothing/(vocab-1)로
nll_loss = -log_probs.gather(dim=-1, index=labels.unsqueeze(-1)).squeeze(-1)
smooth_loss = -log_probs.mean(dim=-1)
loss = (1 - self.smoothing) * nll_loss + self.smoothing * smooth_loss
return loss.mean()
criterion = LabelSmoothingLoss(smoothing=0.1)
설명
이것이 하는 일: 위 코드는 세 가지 주요 Regularization 기법을 결합하여 모델이 과적합되지 않도록 합니다. Dropout으로 학습 중 불확실성을 추가하고, Weight Decay로 가중치를 제한하며, Label Smoothing으로 과신을 방지합니다.
첫 번째로, Dropout은 학습 중 랜덤하게 일부 뉴런을 0으로 만듭니다. attn_pdrop=0.1은 어텐션 가중치의 10%를 랜덤하게 드롭합니다.
이는 모델이 특정 뉴런에 과도하게 의존하지 않도록 하여, 앙상블 효과를 냅니다. 중요한 점은 추론 시에는 Dropout이 비활성화되고 가중치가 스케일 조정된다는 것입니다(model.eval() 호출 시 자동).
그 다음으로, Weight Decay는 가중치의 L2 노름을 손실 함수에 추가합니다. 즉, loss = task_loss + 0.01 * ||W||^2 형태가 됩니다.
이는 가중치가 너무 커지는 것을 방지하여 모델이 더 간단한 함수를 학습하도록 유도합니다. optimizer_grouped_parameters에서 bias와 LayerNorm 파라미터는 weight decay를 적용하지 않는데, 이들은 정규화할 필요가 없기 때문입니다.
Label Smoothing은 원-핫 레이블(예: [0, 0, 1, 0])을 부드럽게 만듭니다(예: [0.025, 0.025, 0.925, 0.025]). smoothing=0.1이면 정답 클래스의 확률을 0.9로, 나머지를 0.1/(vocab_size-1)로 분배합니다.
이는 모델이 100% 확신하는 것을 방지하여, 더 보정된(calibrated) 확률을 출력하게 합니다. 세 기법의 조합이 강력합니다: Dropout은 모델 구조에, Weight Decay는 파라미터 크기에, Label Smoothing은 출력 분포에 제약을 가합니다.
각각 다른 측면에서 과적합을 방지하므로 함께 사용하면 시너지 효과가 있습니다. 여러분이 이 코드를 사용하면 큰 모델을 사용하면서도 검증 데이터에서 좋은 성능을 유지할 수 있습니다.
실무에서는 (1) Dropout 비율을 작업에 따라 조정(0.10.3), (2) Weight Decay는 AdamW에서 0.010.1, (3) Label Smoothing은 분류 작업에 특히 효과적, (4) Early Stopping과 함께 사용하여 최적 시점 선택 등의 전략을 활용합니다.
실전 팁
💡 Dropout 비율은 보통 0.1~0.3 범위에서 시작하세요. 너무 높으면 언더피팅, 너무 낮으면 효과가 없습니다. 검증 성능을 보며 조정하세요.
💡 Weight Decay는 학습 초기부터 적용하세요. 나중에 추가하면 효과가 적습니다. AdamW에서는 0.01이 일반적인 시작점입니다.
💡 Label Smoothing은 분류 작업(특히 대규모 어휘)에서 효과적이지만, 생성 작업에서는 신중하게 사용하세요. 창의성을 줄일 수 있습니다.
💡 추론 시 반드시 model.eval()을 호출하여 Dropout을 비활성화하세요. 학습 모드로 추론하면 결과가 일관성 없고 성능이 떨어집니다.
💡 과적합 여부를 판단하려면 훈련-검증 loss 간격을 모니터링하세요. 간격이 계속 벌어지면 Regularization을 강화하거나 Early Stopping을 고려하세요.
9. Checkpoint Management - 체크포인트 관리
시작하며
여러분이 며칠 동안 모델을 학습시키다가 갑자기 학습이 중단되거나, 최적의 모델을 찾지 못한 경험을 해보셨나요? 예를 들어, 학습이 50시간째 진행 중인데 전원 문제로 모든 것을 잃거나, 과적합으로 인해 마지막 모델이 최선이 아닌 경우가 있습니다.
이런 문제는 장시간 학습에서 예상치 못한 상황이 발생하거나, 검증 성능이 가장 좋은 시점을 놓치기 때문입니다. 특히 대규모 모델은 학습에 수일~수주가 걸리므로, 중간 상태를 저장하지 않으면 재앙적입니다.
바로 이럴 때 필요한 것이 체계적인 Checkpoint Management입니다. 학습 중 주기적으로 모델 상태를 저장하고, 여러 전략으로 최적의 체크포인트를 선택하여, 안전하고 효율적인 학습을 보장합니다.
개요
간단히 말해서, Checkpoint Management는 학습 중 모델의 가중치, 옵티마이저 상태, 학습 메타데이터를 주기적으로 저장하고, 필요 시 재개하거나 최적의 모델을 선택할 수 있도록 관리하는 시스템입니다. 이 시스템이 필요한 이유는 장시간 학습의 안정성과 효율성을 보장하기 위해서입니다.
하드웨어 장애, 메모리 오버플로우, 네트워크 문제 등으로 학습이 중단될 수 있으며, 과적합으로 인해 마지막 모델이 최선이 아닐 수 있습니다. 예를 들어, GPT-3 학습은 몇 주가 걸리며, 중간 체크포인트 없이는 불가능합니다.
기존에는 학습 끝에만 저장하거나 수동으로 관리했다면, 이제는 자동으로 주기적 저장, 최고 성능 모델 추적, 디스크 공간 관리 등이 이루어집니다. Checkpoint Management의 핵심 전략은: (1) 주기적 저장 - 에폭 또는 스텝마다, (2) 최고 성능 저장 - 검증 성능 기반, (3) 재개 기능 - 옵티마이저 상태 포함, (4) 디스크 관리 - 오래된 체크포인트 자동 삭제.
이러한 전략들이 안정적인 대규모 학습을 가능하게 합니다.
코드 예제
import torch
from transformers import AutoModelForCausalLM, AdamW, Trainer, TrainingArguments
model = AutoModelForCausalLM.from_pretrained("gpt2").cuda()
optimizer = AdamW(model.parameters(), lr=5e-5)
# 체크포인트 저장 전략 설정
training_args = TrainingArguments(
output_dir="./checkpoints",
# 저장 전략
save_strategy="steps", # "epoch" 또는 "steps"
save_steps=500, # 500 스텝마다 저장
save_total_limit=3, # 최대 3개만 유지 (디스크 관리)
# 최고 성능 모델 추적
load_best_model_at_end=True, # 학습 끝에 최고 모델 로드
metric_for_best_model="eval_loss", # 검증 loss 기준
greater_is_better=False, # loss는 낮을수록 좋음
# 평가 전략 (최고 모델 선택을 위해 필요)
evaluation_strategy="steps",
eval_steps=500, # 500 스텝마다 평가
# 재개를 위한 설정
resume_from_checkpoint=True, # 마지막 체크포인트에서 재개
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=train_dataset,
eval_dataset=eval_dataset,
)
# 학습 (자동으로 체크포인트 저장 및 관리)
trainer.train()
# 수동 체크포인트 저장 (필요 시)
checkpoint = {
'epoch': epoch,
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'loss': loss,
'step': global_step,
}
torch.save(checkpoint, f'checkpoint-{global_step}.pt')
# 체크포인트에서 재개
checkpoint = torch.load('checkpoint-5000.pt')
model.load_state_dict(checkpoint['model_state_dict'])
optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
start_epoch = checkpoint['epoch']
start_step = checkpoint['step']
설명
이것이 하는 일: 위 코드는 Hugging Face Trainer를 사용하여 자동으로 체크포인트를 관리하거나, PyTorch로 수동으로 저장/로드하는 두 가지 방법을 보여줍니다. 두 경우 모두 학습 재개와 최적 모델 선택이 가능합니다.
첫 번째로, TrainingArguments의 저장 전략을 이해해야 합니다. save_strategy="steps"와 save_steps=500은 매 500 스텝마다 체크포인트를 저장합니다.
save_total_limit=3은 최신 3개만 유지하고 오래된 것은 자동 삭제하여 디스크 공간을 관리합니다. 대규모 모델(수십 GB)은 빠르게 디스크를 채우므로 이 설정이 중요합니다.
그 다음으로, load_best_model_at_end=True는 학습이 끝난 후 검증 성능이 가장 좋았던 체크포인트를 자동으로 로드합니다. metric_for_best_model="eval_loss"는 검증 loss를 기준으로 하며, greater_is_better=False는 낮을수록 좋다는 의미입니다.
정확도(accuracy)를 사용한다면 True로 설정합니다. 수동 저장 방식에서는 model.state_dict()와 optimizer.state_dict()를 모두 저장하는 것이 핵심입니다.
model만 저장하면 재개 시 옵티마이저가 초기화되어 학습이 불안정해집니다. AdamW 같은 옵티마이저는 각 파라미터의 1차, 2차 모멘트를 추적하므로, 이 상태를 유지해야 합니다.
체크포인트 로드 시 load_state_dict()로 상태를 복원하고, start_epoch과 start_step으로 학습 루프를 재개 지점부터 시작합니다. scheduler를 사용하는 경우 scheduler 상태도 저장/로드해야 learning rate 스케줄이 정확히 유지됩니다.
여러분이 이 코드를 사용하면 장시간 학습을 안전하게 수행하고, 과적합 없이 최적의 모델을 얻을 수 있습니다. 실무에서는 (1) 클라우드 스토리지(S3, GCS)에 주기적으로 백업, (2) 각 체크포인트에 성능 메트릭을 메타데이터로 저장, (3) Git 커밋 해시를 저장하여 코드 버전 추적, (4) Exponential Moving Average(EMA) 가중치도 함께 저장 등의 고급 전략을 사용합니다.
실전 팁
💡 옵티마이저 상태를 반드시 저장하세요. 특히 Adam/AdamW는 모멘텀 정보가 중요하며, 이것 없이 재개하면 학습이 다시 불안정해집니다.
💡 디스크 공간을 모니터링하세요. 대규모 모델은 체크포인트 하나가 수십 GB이므로, save_total_limit을 적절히 설정하여 자동 관리하세요.
💡 클라우드 학습 시 체크포인트를 원격 스토리지(S3, GCS)에 자동 동기화하세요. 인스턴스 종료 시에도 안전합니다.
💡 최고 성능 모델을 별도로 저장하세요. save_total_limit으로 인해 삭제될 수 있으므로, 최고 모델은 다른 경로에 영구 보존하세요.
💡 체크포인트에 학습 메타데이터(config, 데이터셋 버전, Git 커밋)를 포함하세요. 나중에 재현하거나 디버깅할 때 필수적입니다.
10. Data Collators and Efficient Batching - 데이터 콜레이터와 효율적 배칭
시작하며
여러분이 언어 모델을 학습시킬 때 데이터 로딩이 GPU보다 느려서 GPU가 놀고 있는 경험을 해보셨나요? 예를 들어, 서로 다른 길이의 시퀀스를 배치로 묶을 때 모두 최대 길이로 패딩하면 메모리 낭비와 계산 낭비가 발생합니다.
이런 문제는 비효율적인 데이터 준비 방식 때문입니다. 정적 패딩은 짧은 시퀀스를 긴 시퀀스에 맞추기 위해 불필요한 토큰으로 채우고, 이는 메모리와 계산량을 낭비합니다.
바로 이럴 때 필요한 것이 Data Collator와 Dynamic Batching입니다. 각 배치마다 해당 배치의 최대 길이로만 패딩하고, 효율적으로 데이터를 준비하여 학습 속도를 크게 향상시킵니다.
개요
간단히 말해서, Data Collator는 개별 샘플들을 배치로 묶을 때 패딩, 마스킹, 특수 토큰 추가 등을 자동으로 처리하는 함수이며, Dynamic Batching은 각 배치마다 그 배치의 최대 길이로만 패딩하여 효율성을 높이는 기법입니다. 이 기법들이 필요한 이유는 언어 데이터의 길이가 매우 가변적이기 때문입니다.
어떤 문장은 10 토큰, 어떤 문장은 500 토큰일 수 있는데, 모두 500으로 패딩하면 대부분 불필요한 계산입니다. Dynamic Batching은 이 낭비를 줄입니다.
예를 들어, 평균 길이가 100인데 최대 길이가 512로 패딩하면 약 5배의 메모리와 계산 낭비가 발생합니다. 기존에는 전처리 단계에서 모든 샘플을 고정 길이로 패딩했다면, 이제는 배치 생성 시점에 동적으로 최소한의 패딩만 적용합니다.
Data Collator의 핵심 기능은: (1) Dynamic Padding - 배치별 최대 길이로 패딩, (2) Attention Mask 자동 생성, (3) Language Modeling을 위한 Labels 생성, (4) 멀티태스크를 위한 커스텀 처리. 이러한 기능들이 효율적이고 유연한 데이터 파이프라인을 만듭니다.
코드 예제
from transformers import AutoTokenizer, DataCollatorForLanguageModeling
from torch.utils.data import DataLoader
import torch
tokenizer = AutoTokenizer.from_pretrained("gpt2")
tokenizer.pad_token = tokenizer.eos_token
# 샘플 데이터 (서로 다른 길이)
texts = [
"This is a short text.",
"This is a much longer text with more tokens.",
"Medium length text here."
]
# 토크나이징 (패딩 없이)
tokenized = [tokenizer(text, truncation=True, max_length=512) for text in texts]
# Data Collator: 배치마다 동적으로 패딩
data_collator = DataCollatorForLanguageModeling(
tokenizer=tokenizer,
mlm=False, # Causal LM (GPT 스타일), True면 Masked LM (BERT 스타일)
mlm_probability=0.15 # MLM 사용 시 마스킹 확률
)
# DataLoader with collator
dataloader = DataLoader(
tokenized,
batch_size=2,
collate_fn=data_collator, # 배치 생성 시 collator 적용
shuffle=True
)
# 배치 확인
for batch in dataloader:
print(f"Input shape: {batch['input_ids'].shape}") # (batch_size, 배치 내 최대 길이)
print(f"Attention mask shape: {batch['attention_mask'].shape}")
print(f"Labels shape: {batch['labels'].shape}")
# 실제 길이 확인 (패딩 제외)
actual_lengths = batch['attention_mask'].sum(dim=1)
print(f"Actual lengths: {actual_lengths}")
break
설명
이것이 하는 일: 위 코드는 서로 다른 길이의 텍스트를 효율적으로 배치로 묶습니다. 전체 데이터셋의 최대 길이가 아닌 각 배치의 최대 길이로만 패딩하여, 불필요한 계산을 제거합니다.
첫 번째로, 토크나이징 단계에서는 패딩을 적용하지 않습니다. 각 샘플은 실제 길이 그대로 저장되며, 예를 들어 [5, 12, 8] 토큰 길이의 세 샘플이 있을 수 있습니다.
이는 메모리를 절약하고 데이터 로딩 시간을 줄입니다. 그 다음으로, DataCollatorForLanguageModeling이 배치 생성 시점에 작동합니다.
collate_fn으로 DataLoader에 전달되어, 각 배치를 만들 때마다 호출됩니다. 예를 들어, 배치에 [5, 12] 토큰 길이의 샘플이 있으면 12로 패딩하고, 다음 배치에 [8, 15]가 있으면 15로 패딩합니다.
전체를 512로 패딩하는 것보다 훨씬 효율적입니다. mlm=False는 Causal Language Modeling을 의미하며, labels는 input_ids를 한 토큰 오른쪽으로 시프트한 것이 됩니다.
즉, 각 위치에서 다음 토큰을 예측하도록 학습합니다. mlm=True면 랜덤하게 토큰을 마스킹([MASK])하고 원래 토큰을 예측하도록 합니다(BERT 스타일).
attention_mask는 자동으로 생성되어 실제 토큰은 1, 패딩은 0으로 표시합니다. 모델은 이를 사용하여 패딩 토큰을 무시하고, 실제 토큰에만 어텐션을 적용합니다.
이는 패딩으로 인한 학습 품질 저하를 방지합니다. 여러분이 이 코드를 사용하면 동일한 하드웨어로 더 큰 배치나 더 긴 시퀀스를 처리할 수 있습니다.
실무에서는 (1) 비슷한 길이끼리 묶는 Bucketing으로 추가 효율화, (2) num_workers > 0으로 멀티프로세스 데이터 로딩, (3) pin_memory=True로 GPU 전송 가속, (4) 커스텀 Data Collator로 복잡한 태스크 처리 등의 최적화를 적용합니다.
실전 팁
💡 Dynamic Batching과 함께 비슷한 길이끼리 묶는 Bucketing을 사용하세요. 배치 내 최대 길이를 더욱 줄여 효율성을 극대화합니다.
💡 num_workers를 적절히 설정하여 데이터 로딩을 병렬화하세요. 보통 CPU 코어 수의 절반~전체 정도가 적당합니다.
💡 pin_memory=True를 사용하면 CPU에서 GPU로 데이터 전송이 빨라집니다. 단, 시스템 메모리가 충분할 때만 사용하세요.
💡 커스텀 태스크를 위한 Data Collator를 작성할 때는 배치 생성 시점에 처리하세요. 전처리 단계에서 하면 유연성이 떨어집니다.
💡 매우 긴 시퀀스(1K+ 토큰)를 다룰 때는 Gradient Checkpointing과 함께 사용하여 메모리를 추가로 절약하세요.