17 KiB
17 KiB
SFT 개선 방안 심층 조사
프로젝트: 1B Korean LLM SFT (188k 샘플, 8×B200, FP8) 현재 구현: NEFTune, dynamic padding, gradient checkpointing, cosine LR, BF16+FP8 작성일: 2026-02-26
1. Curriculum Learning (교육과정 학습)
개념
쉬운 샘플에서 어려운 샘플 순서로 학습하여 수렴 속도와 최종 성능 향상.
구현 방법
방법 A: Perplexity 기반 정렬 (권장)
# scripts/compute_difficulty.py
import torch, json
from pathlib import Path
from tokenizers import Tokenizer
from model import LLM
def compute_sample_perplexity(model, tokenizer, data_path, output_path, device="cuda:0"):
"""현재 pretrain 모델로 각 샘플의 output perplexity 계산"""
model.eval()
results = []
with open(data_path) as f:
samples = [json.loads(line) for line in f]
with torch.no_grad():
for i, sample in enumerate(samples):
# conversation에서 assistant turn만 추출
messages = sample["messages"]
# 전체 시퀀스 토크나이즈
full_text = tokenizer.encode(
"".join(m["content"] for m in messages)
)
input_ids = torch.tensor([full_text.ids[:4096]], device=device)
logits = model(input_ids)
# response 토큰에 대한 CE loss = perplexity proxy
shift_logits = logits[:, :-1, :]
shift_labels = input_ids[:, 1:]
loss = torch.nn.functional.cross_entropy(
shift_logits.reshape(-1, shift_logits.size(-1)),
shift_labels.reshape(-1),
reduction='mean'
)
ppl = loss.exp().item()
results.append({"idx": i, "ppl": ppl})
if i % 1000 == 0:
print(f" {i}/{len(samples)} done")
# ppl 오름차순 정렬 = 쉬운 것부터
results.sort(key=lambda x: x["ppl"])
with open(output_path, "w") as f:
json.dump(results, f)
return results
방법 B: 길이 기반 (가장 간단)
- 짧은 응답 → 긴 응답 순서로 정렬
- SFTDataset에서
__getitem__시 정렬된 인덱스 사용
방법 C: IFD Score
- Cherry LLM 논문 (2024):
IFD = PPL(output|instruction) / PPL(output) - 높은 IFD = instruction이 output 생성을 잘 유도하지 못함 = 어려움
실제 효과
- Curriculum Learning for LLMs (Xu et al., 2024): SFT에서 MT-Bench +0.3~0.5점
- 효과 제한적 의견: Bengio et al.의 원 연구 이후 SFT에서의 효과는 mixed results
- 예상: ko_ifeval +1~2%, 반복률 변화 미미
평가
| 항목 | 값 |
|---|---|
| 예상 효과 | ko_ifeval +1~2% |
| 구현 복잡도 | 2/5 |
| 소요 시간 | PPL 계산 2~3시간 + 코드 수정 2시간 |
| 적용 가능 | ✅ DataLoader sampler 수정으로 가능 |
2. "Less is More" 전략 (LIMA, AlpaGasus)
핵심 논문
- LIMA (Zhou et al., 2023): 1000개 고품질 > 52k 저품질. 65B 모델에서 검증.
- AlpaGasus (Chen et al., 2023): GPT-4로 품질 점수 → 9k에서 3k 선별 → Alpaca 대비 우수
- DEITA (Liu et al., 2024): complexity + quality + diversity 3축 필터링
품질 점수 계산 방법 (외부 API 없이)
# scripts/quality_filter.py
import json, math, torch
import numpy as np
from collections import Counter
def compute_quality_scores(data_path, model, tokenizer, device="cuda:0"):
"""다차원 품질 점수 계산"""
with open(data_path) as f:
samples = [json.loads(line) for line in f]
scored = []
model.eval()
for i, sample in enumerate(samples):
msgs = sample["messages"]
# 1) 길이 점수: 너무 짧거나 너무 긴 건 감점
response = "".join(m["content"] for m in msgs if m["role"] == "assistant")
resp_len = len(response)
len_score = min(resp_len / 500, 1.0) * (1.0 if resp_len < 3000 else 3000 / resp_len)
# 2) 반복 감점: n-gram 반복률
tokens = list(response)
if len(tokens) > 10:
trigrams = [tuple(tokens[j:j+3]) for j in range(len(tokens)-2)]
unique_ratio = len(set(trigrams)) / len(trigrams)
else:
unique_ratio = 1.0
rep_score = unique_ratio
# 3) Perplexity 점수 (중간이 좋음 - 너무 낮으면 trivial, 너무 높으면 noise)
# 사전 계산된 ppl 사용
# 4) Instruction 복잡도: instruction 길이
instruction = "".join(m["content"] for m in msgs if m["role"] == "user")
inst_complexity = min(len(instruction) / 200, 1.0)
# 종합 점수
quality = 0.3 * len_score + 0.3 * rep_score + 0.2 * inst_complexity + 0.2
scored.append({"idx": i, "quality": quality, "sample": sample})
return scored
def select_top_k(scored, k):
"""상위 k개 선별"""
scored.sort(key=lambda x: x["quality"], reverse=True)
return scored[:k]
권장 샘플 수
- 188k 전체 → 50k~80k 권장 (상위 30~40%)
- 1B 모델 규모에서는 LIMA처럼 극단적 축소보다 moderate 필터링이 적합
- 근거: AlpaGasus는 ~30% 선별에서 최적, 1B는 65B보다 데이터 의존도 높음
평가
| 항목 | 값 |
|---|---|
| 예상 효과 | 반복률 -30 |
| 구현 복잡도 | 2/5 |
| 소요 시간 | 품질 계산 3~4시간, 필터링 코드 1시간 |
| 적용 가능 | ✅ 데이터 전처리 단계 |
3. Packing (Sequence Packing)
개념
짧은 시퀀스들을 하나의 max_seq_len에 패킹하여 padding 낭비 제거.
현 프로젝트 상황
- 이미
dynamic_collate_fn으로 batch-level dynamic padding 구현됨 - Packing은 그 이상: 여러 샘플을 하나의 시퀀스로 concatenate
주의사항: Cross-contamination
- 패킹된 서로 다른 샘플 간 attention이 흐르면 안 됨
- 해결: Flash Attention v2의
cu_seqlens파라미터 (varlen attention) - 또는 block diagonal attention mask
구현 방법
# data/packed_sft_dataset.py
class PackedSFTDataset:
"""여러 SFT 샘플을 하나의 시퀀스로 패킹"""
def __init__(self, samples, tokenizer, max_seq_len=4096):
self.packed = []
self.cu_seqlens = [] # Flash Attention varlen용
buffer_ids = []
buffer_labels = []
seq_lens = []
for sample in samples:
ids, labels = self._tokenize(sample, tokenizer)
if len(buffer_ids) + len(ids) > max_seq_len:
# 현재 버퍼 저장
if buffer_ids:
self._save_buffer(buffer_ids, buffer_labels, seq_lens, max_seq_len)
buffer_ids = ids
buffer_labels = labels
seq_lens = [len(ids)]
else:
buffer_ids.extend(ids)
buffer_labels.extend(labels)
seq_lens.append(len(ids))
if buffer_ids:
self._save_buffer(buffer_ids, buffer_labels, seq_lens, max_seq_len)
def _save_buffer(self, ids, labels, seq_lens, max_seq_len):
# Pad to max_seq_len
pad_len = max_seq_len - len(ids)
ids = ids + [0] * pad_len
labels = labels + [-1] * pad_len
# cu_seqlens for varlen flash attention
cu = [0]
for l in seq_lens:
cu.append(cu[-1] + l)
self.packed.append({
"input_ids": torch.tensor(ids),
"labels": torch.tensor(labels),
"cu_seqlens": torch.tensor(cu, dtype=torch.int32),
})
속도 개선 예상
- 현재 평균 시퀀스 길이가 max_seq_len(4096)보다 훨씬 짧다면 1.5~3× 속도 향상
- SFT 데이터 특성상 평균 ~1000 토큰이면 ~3× 효율 향상 예상
평가
| 항목 | 값 |
|---|---|
| 예상 효과 | 학습 속도 1.5~3×, 성능 변화 없거나 미미 |
| 구현 복잡도 | 3/5 (Flash Attention varlen 연동 필요) |
| 소요 시간 | 1~2일 |
| 적용 가능 | ⚠️ 모델의 attention 구현이 cu_seqlens 지원해야 함 |
4. Multi-task SFT (도메인별 Loss Weighting)
개념
데이터 소스별로 도메인을 분류하고, 도메인별 loss weight를 다르게 적용.
현재 데이터 소스 분석 (추정)
korean_safe_conv/raw/하위: hatespeech, square, evol, yitingxie, gamseong, koalpaca, conversation- 카테고리: 안전성, QA, 창작, 일반 대화
구현 방법
# 도메인 태그를 JSONL에 추가
# {"messages": [...], "domain": "qa"}
# {"messages": [...], "domain": "creative"}
# trainer에서 도메인별 loss weight
DOMAIN_WEIGHTS = {
"qa": 1.0,
"creative": 0.8,
"safety": 1.2,
"code": 1.0,
"math": 1.0,
"conversation": 0.6, # 일반 대화는 낮게
}
평가
| 항목 | 값 |
|---|---|
| 예상 효과 | 특정 벤치마크 +1~3% |
| 구현 복잡도 | 3/5 |
| 소요 시간 | 도메인 분류 0.5일 + 구현 0.5일 |
| 적용 가능 | ✅ loss 계산 시 weight 곱셈 |
5. Token-level Loss Weighting / Focal Loss
개념
모든 response 토큰에 동일 weight 대신, 모델이 예측하기 어려운 토큰에 더 높은 weight.
Focal Loss 구현
# train/focal_loss.py
import torch
import torch.nn.functional as F
def focal_cross_entropy(logits, targets, gamma=2.0, ignore_index=-1):
"""
Focal loss: down-weight easy tokens, up-weight hard tokens.
Lin et al., "Focal Loss for Dense Object Detection", ICCV 2017.
"""
# Standard CE
ce_loss = F.cross_entropy(
logits.reshape(-1, logits.size(-1)),
targets.reshape(-1),
ignore_index=ignore_index,
reduction='none'
)
# p_t = probability of correct class
log_pt = -ce_loss
pt = torch.exp(log_pt)
# Focal weight: (1 - p_t)^gamma
focal_weight = (1 - pt) ** gamma
loss = focal_weight * ce_loss
# Mask ignored tokens
mask = (targets.reshape(-1) != ignore_index)
loss = loss[mask].mean()
return loss
적용: trainer.py 수정
# trainer.py의 _compute_loss에서
# 기존: F.cross_entropy(logits, targets, ignore_index=-1)
# 변경: focal_cross_entropy(logits, targets, gamma=2.0, ignore_index=-1)
실제 효과
- SFT에서 focal loss 적용 논문은 제한적
- SelectIT (Liu et al., 2024): token-level selection으로 IFEval +2~4%
- gamma=2.0이 일반적이나 SFT에서는 gamma=1.0~1.5 권장 (너무 강하면 불안정)
평가
| 항목 | 값 |
|---|---|
| 예상 효과 | ko_ifeval +1~3%, 반복률 영향 미미 |
| 구현 복잡도 | 1/5 |
| 소요 시간 | 2시간 |
| 적용 가능 | ✅ loss 함수만 교체 |
6. Data Augmentation for Korean
방법 A: Self-Paraphrase
# 현재 모델(또는 더 큰 모델)로 response 재생성
# instruction은 유지, output만 다양화
# → 동일 instruction에 대한 N개 다른 응답 확보
방법 B: Back-translation
# 영어 고품질 데이터 (Alpaca, Dolly, OpenAssistant) → 한국어 번역
# 서버에서 실행 가능한 방법:
# 1) NLLB-200 3.3B (Meta): 오프라인 번역, 1GPU로 실행 가능
# 2) 한국어 특화 모델로 직접 번역 생성
# pip install transformers
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
model_name = "facebook/nllb-200-3.3B"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSeq2SeqLM.from_pretrained(model_name).to("cuda:7") # 여유 GPU 1개 사용
def translate_en_to_ko(text):
inputs = tokenizer(text, return_tensors="pt", max_length=1024, truncation=True).to("cuda:7")
outputs = model.generate(**inputs, forced_bos_token_id=tokenizer.lang_code_to_id["kor_Hang"], max_new_tokens=1024)
return tokenizer.decode(outputs[0], skip_special_tokens=True)
평가
| 항목 | 값 |
|---|---|
| 예상 효과 | 다양성 증가, ko_ifeval +2~4% (좋은 소스 데이터 시) |
| 구현 복잡도 | 3/5 |
| 소요 시간 | 번역 파이프라인 1일 + 번역 실행 1~2일 |
| 적용 가능 | ✅ 별도 GPU에서 병렬 실행 가능 |
7. 학습 안정성 개선
FP8 학습 주의사항
현재 설정: MXFP8 + BF16 기반. 주요 주의점:
-
Loss spike 방지
max_grad_norm: 1.0이미 적용됨 ✅- LR 2e-5는 보수적 ✅
- 추가: gradient norm 모니터링 + 자동 LR 감소
# 연속 3번 grad_norm > threshold면 lr 반감 if grad_norm > 5.0: spike_count += 1 if spike_count >= 3: for pg in optimizer.param_groups: pg['lr'] *= 0.5 -
Weight Decay
- 현재 0.01 (적절)
- SFT에서는 0.01~0.05 범위가 표준
-
Dropout
- 현재
dropout: 0.0— SFT에서는 0.05~0.1 추가 권장 - 과적합 방지에 직접적 효과
dropout: 0.05 # configs/korean_1b_sft.yaml - 현재
-
FP8 amax 설정
fp8_amax_history_len: 16+fp8_amax_compute_algo: "max"— 적절- MXFP8은 DelayedScaling보다 안정적
평가
| 항목 | 값 |
|---|---|
| 예상 효과 | 안정성 ↑, 과적합 -10 |
| 구현 복잡도 | 1/5 |
| 소요 시간 | 1시간 |
| 적용 가능 | ✅ config 변경만으로 가능 |
8. 평가 기반 데이터 선택 (Self-Play → ORPO)
파이프라인
1. 현재 SFT 모델로 각 instruction에 대해 N=4개 응답 생성
2. 자동 평가 (반복률, 길이, 일관성)로 best/worst 선정
3. (chosen, rejected) 페어 구성
4. ORPO/DPO 학습 (이미 train/orpo.py 존재!)
구체적 단계
# scripts/generate_self_play.py
def generate_candidates(model, tokenizer, instructions, n=4, temp=0.8):
pairs = []
for inst in instructions:
responses = []
for _ in range(n):
out = model.generate(inst, temperature=temp, max_new_tokens=1024)
score = auto_evaluate(out) # 반복률, 길이, coherence
responses.append((out, score))
responses.sort(key=lambda x: x[1], reverse=True)
pairs.append({
"instruction": inst,
"chosen": responses[0][0],
"rejected": responses[-1][0],
})
return pairs
평가
| 항목 | 값 |
|---|---|
| 예상 효과 | 반복률 -40 |
| 구현 복잡도 | 4/5 |
| 소요 시간 | 생성 1~2일 + ORPO 학습 0.5일 |
| 적용 가능 | ✅ orpo.py 이미 존재 |
종합 비교표
| 기법 | 예상 효과 (반복률) | 예상 효과 (ko_ifeval) | 구현 복잡도 | 소요 시간 | 우선순위 |
|---|---|---|---|---|---|
| 1. Curriculum Learning | - | +1~2% | 2/5 | 5시간 | 중기 |
| 2. Less is More | -30~50% | +3~5% | 2/5 | 5시간 | 즉시 |
| 3. Packing | (속도만) | (변화없음) | 3/5 | 1~2일 | 중기 |
| 4. Multi-task Weighting | - | +1~3% | 3/5 | 1일 | 중기 |
| 5. Focal Loss | - | +1~3% | 1/5 | 2시간 | 즉시 |
| 6. Data Augmentation | - | +2~4% | 3/5 | 2~3일 | 중기 |
| 7. 학습 안정성 (dropout) | -5~10% | - | 1/5 | 1시간 | 즉시 |
| 8. Self-Play → ORPO | -40~60% | +3~5% | 4/5 | 2~3일 | 중기 |
🚀 즉시 적용 Top 3
1위: "Less is More" 데이터 필터링
- 근거: LIMA, AlpaGasus 논문에서 일관되게 입증. 188k → 50~80k 필터링으로 저품질/반복적 샘플 제거
- 예상 효과: 반복률 -30
50%, ko_ifeval +35% - 소요: 5시간 (PPL 계산 + 필터링 스크립트)
- 리스크: 낮음 (최악의 경우 전체 데이터로 롤백)
2위: Focal Loss 적용
- 근거: 어려운 토큰에 집중 → instruction following 능력 향상. 구현 극히 간단
- 예상 효과: ko_ifeval +1~3%
- 소요: 2시간 (loss 함수 1개 추가)
- 리스크: 매우 낮음 (gamma 값만 조정하면 됨)
3위: Dropout 추가 (0.05)
- 근거: 현재 dropout=0.0으로 과적합 위험. SFT에서 light dropout은 표준
- 예상 효과: 과적합 감소, 반복률 -5~10%
- 소요: config 한 줄 변경
- 리스크: 없음
📅 중기 적용 Top 3
1위: Self-Play → ORPO (SFT 이후)
- 근거: SFT 완료 후 선호 학습은 반복률 감소에 가장 효과적. orpo.py 이미 구현됨
- 예상 효과: 반복률 -40
60%, ko_ifeval +35% - 소요: 2~3일 (생성 + 학습)
2위: Sequence Packing
- 근거: 학습 속도 1.5~3× 향상. 향후 반복 실험에 필수적
- 예상 효과: 학습 시간 대폭 단축
- 소요: 1~2일
3위: Curriculum Learning + Data Augmentation (Back-translation)
- 근거: 데이터 다양성과 학습 효율 동시 개선
- 예상 효과: ko_ifeval +2~4%
- 소요: 3~4일
권장 실행 순서
Phase 1 (즉시, SFT 전):
1. dropout: 0.05 설정
2. 데이터 품질 필터링 (188k → 60~80k)
3. Focal loss 적용 (gamma=1.5)
→ SFT 실행
Phase 2 (SFT 후):
4. Self-Play 데이터 생성
5. ORPO 학습
Phase 3 (다음 라운드):
6. Packing 구현 (반복 실험 가속)
7. Back-translation으로 데이터 확장
8. Curriculum learning 실험