Files
frankenstallm/source/eval/sft_redesign_report.md
ModelHub XC d4abdb70fa 初始化项目,由ModelHub XC社区提供模型
Model: pathcosmos/frankenstallm
Source: Original Platform
2026-07-14 04:21:16 +08:00

342 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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. 🟢 **재학습 시작** (수정 완료 )