342 lines
12 KiB
Markdown
342 lines
12 KiB
Markdown
# SFT 학습 파이프라인 근본 재설계 보고서
|
||
|
||
**작성일**: 2026-02-26
|
||
**대상 프로젝트**: `/PROJECT/0325120031_A/ghong/taketimes/llm-bang/`
|
||
**현재 상태**: 반복 퇴화 57%, EOS 생성 불안정
|
||
|
||
---
|
||
|
||
## 1. 현재 구현의 근본적 문제점
|
||
|
||
### 🔴 Critical #1: Dynamic Padding이 작동하지 않음 (가장 큰 성능 낭비)
|
||
|
||
**파일**: `data/sft_dataset.py` L139-146, `train/sft.py` L198-230
|
||
|
||
`SFTDataset.__init__`에서 모든 샘플을 `max_seq_len`(4096)으로 **미리 패딩**한다:
|
||
|
||
```python
|
||
# sft_dataset.py L139-141
|
||
input_ids = torch.full(
|
||
(max_seq_len,), fill_value=pad_token_id, dtype=torch.long
|
||
)
|
||
```
|
||
|
||
그런데 `dynamic_collate_fn`은 배치 내 최대 길이에 맞춰 패딩하도록 설계됐지만, 이미 모든 텐서가 `max_seq_len` 길이이므로 **raw_max = max_seq_len 항상 고정**. Dynamic padding이 사실상 무효화된 상태.
|
||
|
||
**영향**: 평균 시퀀스 길이가 ~500 토큰이라면, 매 스텝마다 ~3600개의 패딩 토큰을 불필요하게 처리. **학습 속도 ~3-8x 저하**, GPU FLOPs 낭비.
|
||
|
||
**수정**: `__getitem__`에서 패딩하지 말고, 실제 길이의 텐서를 반환. `dynamic_collate_fn`이 배치별로 패딩하도록.
|
||
|
||
### 🔴 Critical #2: 트렁케이션 시 EOS 토큰 손실
|
||
|
||
**파일**: `data/sft_dataset.py` L130-134
|
||
|
||
```python
|
||
# truncation이 발생하면
|
||
response_ids = response_ids[:allowed_response]
|
||
```
|
||
|
||
`response_ids`는 `"{output}</s>"`를 인코딩한 것인데, 잘리면 마지막의 `</s>` 토큰이 제거된다. 해당 샘플은 EOS를 학습할 수 없다.
|
||
|
||
**영향**: `truncated_count`개의 샘플이 EOS 없이 학습됨. 긴 응답 → 잘림 → EOS 미학습 → 생성 시 끝없이 반복 생성의 직접적 원인.
|
||
|
||
**수정**:
|
||
```python
|
||
response_ids = response_ids[:allowed_response - 1] + [self.eos_token_id]
|
||
```
|
||
|
||
### 🟡 Important #3: Label Shift 로직 미묘한 버그 가능성
|
||
|
||
**파일**: `data/sft_dataset.py` L148-157, `train/trainer.py` L263-274
|
||
|
||
현재 구현:
|
||
- `labels[resp_start-1 : resp_start-1+len(response_ids)] = response_ids`
|
||
- `_compute_loss`에서 **별도의 shift 없이** `cross_entropy(logits[i], labels[i])` 수행
|
||
|
||
이 패턴은 "logits[i]가 position i+1을 예측한다"는 가정 하에 올바르지만, **마지막 response 토큰 이후 position에 대한 예측이 학습되지 않는다**. 즉, EOS를 출력한 후 다음 토큰을 패딩(또는 아무것도 아닌 것)으로 예측하도록 학습하지 않아서, EOS 이후에도 계속 토큰을 생성하는 경향이 있을 수 있다.
|
||
|
||
**권장**: response_ids의 마지막 토큰(EOS) 다음 position의 label도 EOS로 설정하여 "EOS 이후에도 EOS"를 학습시킴.
|
||
|
||
### 🟡 Important #4: Validation Split 없음
|
||
|
||
**파일**: `scripts/launch_sft.sh` — `--val_data` 인자 미전달
|
||
|
||
Trainer에 validation 루프가 구현돼 있고 (`trainer.py` L200-220), best checkpoint 저장도 되지만, 실제로 val_data를 전달하지 않아 과적합 모니터링이 불가능.
|
||
|
||
### 🟡 Important #5: 학습 에포크 부족
|
||
|
||
- Effective batch = 4 × 2 × 8 = **64 seqs/step**
|
||
- 5000 steps × 64 = **320,000 sample-steps**
|
||
- 159k 샘플 → **~2 epochs**
|
||
|
||
SFT는 보통 **3-5 epochs**가 적절. 2 epochs는 데이터를 충분히 학습하지 못함.
|
||
|
||
### 🟢 Minor #6: Warmup 비율
|
||
|
||
150/5000 = 3%. SFT에서는 적절한 범위 (3-10%).
|
||
|
||
### 🟢 Minor #7: NEFTune 사용 중
|
||
|
||
`noise_alpha=10.0`으로 NEFTune 적용 중. 이건 좋은 설정. 다만 반복 퇴화가 심한 경우 효과가 제한적.
|
||
|
||
---
|
||
|
||
## 2. 업계 최고 수준 SFT 구현과의 비교
|
||
|
||
### LLaMA-Factory
|
||
- **completion_only_loss**: prompt 토큰 masking + response만 학습 (현재 구현과 동일)
|
||
- **EOS 보장**: 트렁케이션 시 반드시 EOS 토큰 유지
|
||
- **Packing**: 여러 짧은 샘플을 하나의 시퀀스에 패킹하여 GPU 활용률 극대화
|
||
- **데이터 필터링**: 너무 짧거나 품질 낮은 샘플 자동 제거
|
||
|
||
### TRL SFTTrainer
|
||
- `packing=True`: 여러 샘플을 하나의 시퀀스에 패킹 (패딩 낭비 제거)
|
||
- `DataCollatorForCompletionOnlyLM`: response 토큰만 학습하는 collator
|
||
- **max_seq_length에 맞춰 동적 패딩** (배치별)
|
||
|
||
### Axolotl
|
||
- **Sample packing**: 긴 시퀀스 내에 여러 대화를 패킹
|
||
- **Sequence length warmup**: 짧은 시퀀스부터 시작해서 점진적으로 길이 증가
|
||
- **Repetition penalty during training**: 학습 중 반복 패널티 적용 옵션
|
||
|
||
### OpenInstruct (Allen AI)
|
||
- **데이터 품질 우선**: 데이터 필터링 파이프라인 강조
|
||
- **길이 기반 정규화**: 긴 응답에 대한 loss normalization
|
||
- **다단계 학습**: 일반 SFT → 도메인 SFT → DPO/RLHF
|
||
|
||
### 반복 퇴화 방지를 위한 업계 공통 패턴
|
||
1. **EOS 토큰 확실한 학습**: 모든 샘플에서 EOS가 label에 포함되도록 보장
|
||
2. **Repetition penalty loss**: 동일 n-gram 반복 시 추가 페널티
|
||
3. **Length normalization**: 긴 응답의 loss가 과도하게 커지지 않도록 정규화
|
||
4. **데이터 품질 필터링**: 반복이 있는 학습 데이터 자체를 제거
|
||
5. **KL divergence regularization**: base model과의 KL divergence 제약
|
||
|
||
---
|
||
|
||
## 3. Curriculum Learning 방법론
|
||
|
||
### IFD (Instruction Following Difficulty) Score
|
||
```python
|
||
# 각 샘플에 대해:
|
||
# 1. base model로 response의 perplexity 계산
|
||
# 2. instruction 없이 response만의 perplexity 계산
|
||
# 3. IFD = conditional_ppl / unconditional_ppl
|
||
# IFD가 낮을수록 "쉬운" 샘플
|
||
|
||
def compute_ifd(model, tokenizer, instruction, response):
|
||
cond_ppl = compute_perplexity(model, instruction + response)
|
||
uncond_ppl = compute_perplexity(model, response)
|
||
return cond_ppl / uncond_ppl
|
||
```
|
||
|
||
### KL-Divergence 기반 데이터 선택
|
||
- Base model 대비 응답 분포가 크게 다른 샘플 = 어려운 샘플
|
||
- 처음에는 KL이 작은 (쉬운) 샘플, 나중에 큰 샘플
|
||
|
||
### 실용적 접근: 길이 기반 Curriculum
|
||
- Phase 1 (epoch 1): 응답 길이 < 256 토큰 샘플만
|
||
- Phase 2 (epoch 2): 응답 길이 < 1024 토큰
|
||
- Phase 3 (epoch 3+): 전체 데이터
|
||
|
||
---
|
||
|
||
## 4. 즉시 적용 가능한 수정 Top 5
|
||
|
||
### Fix #1: Dynamic Padding 실제 작동하도록 수정 (성능 3-8x 개선)
|
||
|
||
```python
|
||
# data/sft_dataset.py — __getitem__ 수정
|
||
# 기존: 고정 max_seq_len 패딩된 텐서 반환
|
||
# 수정: 실제 길이의 텐서만 반환
|
||
|
||
class SFTDataset(Dataset):
|
||
def __init__(self, ...):
|
||
# ...기존 코드...
|
||
# samples 저장 시 패딩 제거
|
||
self.samples: list[tuple[torch.Tensor, torch.Tensor]] = []
|
||
|
||
for prompt_text, response_text in raw_samples:
|
||
# ...기존 인코딩 코드...
|
||
|
||
seq_len = len(full_ids)
|
||
# 패딩 없이 실제 길이만 저장
|
||
input_ids = torch.tensor(full_ids, dtype=torch.long)
|
||
|
||
labels = torch.full((seq_len,), fill_value=-1, dtype=torch.long)
|
||
resp_start = len(prompt_ids)
|
||
resp_label_start = max(0, resp_start - 1)
|
||
resp_label_end = resp_label_start + len(response_ids)
|
||
labels[resp_label_start:resp_label_end] = torch.tensor(
|
||
response_ids, dtype=torch.long
|
||
)
|
||
|
||
self.samples.append((input_ids, labels))
|
||
```
|
||
|
||
### Fix #2: 트렁케이션 시 EOS 보존
|
||
|
||
```python
|
||
# data/sft_dataset.py L130-134 수정
|
||
# 기존:
|
||
# response_ids = response_ids[:allowed_response]
|
||
# 수정:
|
||
if len(full_ids) > max_seq_len:
|
||
truncated_count += 1
|
||
allowed_response = max_seq_len - len(prompt_ids)
|
||
if allowed_response <= 1:
|
||
skipped_too_long += 1
|
||
continue
|
||
# EOS를 반드시 마지막에 유지
|
||
response_ids = response_ids[:allowed_response - 1] + [self.eos_token_id]
|
||
full_ids = prompt_ids + response_ids
|
||
```
|
||
|
||
### Fix #3: Validation Split 추가
|
||
|
||
```bash
|
||
# 데이터 분리 스크립트
|
||
python -c "
|
||
import json, random
|
||
random.seed(42)
|
||
with open('data/sft/train.jsonl') as f:
|
||
lines = f.readlines()
|
||
random.shuffle(lines)
|
||
split = int(len(lines) * 0.9)
|
||
with open('data/sft/train_split.jsonl', 'w') as f:
|
||
f.writelines(lines[:split])
|
||
with open('data/sft/val_split.jsonl', 'w') as f:
|
||
f.writelines(lines[split:])
|
||
print(f'Train: {split}, Val: {len(lines)-split}')
|
||
"
|
||
|
||
# launch_sft.sh에 추가:
|
||
# --val_data data/sft/val_split.jsonl
|
||
```
|
||
|
||
### Fix #4: Max Steps 증가 (3-5 epochs)
|
||
|
||
```bash
|
||
# launch_sft.sh 수정
|
||
# 159k samples / 64 effective_batch ≈ 2484 steps/epoch
|
||
# 4 epochs → ~10,000 steps
|
||
MAX_STEPS=10000
|
||
WARMUP_STEPS=300 # 3% of 10000
|
||
```
|
||
|
||
### Fix #5: 학습 데이터에서 반복 패턴 필터링
|
||
|
||
```python
|
||
# data/filter_repetitive.py
|
||
import json, re
|
||
|
||
def has_repetition(text, n=3, threshold=0.3):
|
||
"""n-gram 반복 비율이 threshold 이상이면 True"""
|
||
words = text.split()
|
||
if len(words) < n * 2:
|
||
return False
|
||
ngrams = [tuple(words[i:i+n]) for i in range(len(words) - n + 1)]
|
||
unique_ratio = len(set(ngrams)) / len(ngrams)
|
||
return unique_ratio < (1 - threshold)
|
||
|
||
filtered = 0
|
||
with open('data/sft/train.jsonl') as fin, \
|
||
open('data/sft/train_clean.jsonl', 'w') as fout:
|
||
for line in fin:
|
||
obj = json.loads(line)
|
||
text = obj.get('output', '') or ''
|
||
if 'conversations' in obj:
|
||
text = ' '.join(t['content'] for t in obj['conversations']
|
||
if t['role'] == 'assistant')
|
||
if not has_repetition(text):
|
||
fout.write(line)
|
||
else:
|
||
filtered += 1
|
||
|
||
print(f"Filtered {filtered} repetitive samples")
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 근본적 재학습 시나리오
|
||
|
||
### Phase 1: 데이터 준비 (1-2시간)
|
||
|
||
1. 반복 패턴이 있는 데이터 필터링
|
||
2. Train/Val split (90/10)
|
||
3. 데이터 통계 확인 (길이 분포, 카테고리 분포)
|
||
|
||
### Phase 2: 코드 수정 (1-2시간)
|
||
|
||
1. ✅ `sft_dataset.py`: 패딩 제거, EOS 보존, 가변 길이 반환
|
||
2. ✅ `sft.py`의 `dynamic_collate_fn`: 이미 구현됨 (dataset 수정만 하면 작동)
|
||
3. ✅ `launch_sft.sh`: val_data 추가, max_steps 10000
|
||
|
||
### Phase 3: 학습 (8-12시간, 8×B200 기준)
|
||
|
||
```bash
|
||
# 수정된 launch_sft.sh
|
||
MAX_STEPS=10000
|
||
BATCH_SIZE=4
|
||
GRAD_ACCUM=2
|
||
LR="2.0e-5"
|
||
WARMUP_STEPS=300
|
||
|
||
torchrun --nproc_per_node=8 train/sft.py \
|
||
--base_checkpoint checkpoints/korean_1b_fp8_run1/checkpoint-0034000 \
|
||
--sft_data data/sft/train_clean.jsonl \
|
||
--val_data data/sft/val_split.jsonl \
|
||
--max_steps 10000 \
|
||
--batch_size 4 \
|
||
--grad_accum 2 \
|
||
--lr 2.0e-5 \
|
||
--warmup_steps 300 \
|
||
--use_fp8
|
||
```
|
||
|
||
### Phase 4: 평가 (1시간)
|
||
|
||
1. Val loss 모니터링 (TensorBoard)
|
||
2. 반복 퇴화율 측정 (기존 eval 스크립트)
|
||
3. Best checkpoint 선택 (val_loss 기준)
|
||
|
||
### 예상 소요시간
|
||
- Dynamic padding 수정 후 학습 속도: **3-5x 향상** (4096→~600 avg 기준)
|
||
- 10000 steps: 기존 대비 비슷하거나 더 빠를 수 있음
|
||
- 총 12-16시간 (데이터 준비 ~ 평가 완료)
|
||
|
||
---
|
||
|
||
## 6. 예상 개선 효과
|
||
|
||
| 수정 항목 | 현재 | 예상 개선 | 근거 |
|
||
|-----------|------|----------|------|
|
||
| Dynamic padding | 4096 고정 | 학습속도 3-8x↑ | 평균 ~600 토큰 시 패딩 85% 감소 |
|
||
| EOS 보존 | 트렁케이션 시 손실 | 반복퇴화 57%→~20% | EOS 학습 누락이 반복의 직접 원인 |
|
||
| 데이터 필터링 | 반복 데이터 포함 | 반복퇴화 → ~10% | 반복 학습데이터 제거 |
|
||
| Val split | 없음 | 과적합 조기 감지 | best checkpoint 선택 가능 |
|
||
| Epoch 증가 | ~2 epochs | 수렴 개선 | 3-5 epochs이 SFT 표준 |
|
||
| **종합** | **반복 57%** | **반복 <10%** | 위 수정 모두 적용 시 |
|
||
|
||
---
|
||
|
||
## 7. 권장 아키텍처 (장기)
|
||
|
||
현재 직접 구현한 SFT 파이프라인을 **TRL SFTTrainer** 또는 **LLaMA-Factory**로 교체 권장:
|
||
|
||
1. **Packing 지원**: 패딩 낭비 완전 제거
|
||
2. **검증된 EOS 처리**: 수백 개 프로젝트에서 검증
|
||
3. **DPO/RLHF 파이프라인 연계**: SFT 후 alignment 학습으로 자연스럽게 이행
|
||
4. **Sample packing + Flash Attention**: 최적화된 메모리/속도
|
||
|
||
단, 현재 커스텀 모델(`LLM` 클래스)과의 호환성 확인 필요. HuggingFace 포맷으로 변환 후 사용하면 바로 적용 가능.
|
||
|
||
---
|
||
|
||
## 요약: 즉시 해야 할 일 (우선순위)
|
||
|
||
1. 🔴 **sft_dataset.py**: 패딩 제거 + EOS 보존 (30분)
|
||
2. 🔴 **데이터 필터링**: 반복 패턴 있는 샘플 제거 (30분)
|
||
3. 🟡 **Val split 생성 + launch_sft.sh 수정** (15분)
|
||
4. 🟡 **Max steps 10000으로 증가** (설정만 변경)
|
||
5. 🟢 **재학습 시작** (수정 완료 후)
|