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

12 KiB
Raw Permalink Blame History

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)으로 미리 패딩한다:

# 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

# truncation이 발생하면
response_ids = response_ids[:allowed_response]

response_ids"{output}</s>"를 인코딩한 것인데, 잘리면 마지막의 </s> 토큰이 제거된다. 해당 샘플은 EOS를 학습할 수 없다.

영향: truncated_count개의 샘플이 EOS 없이 학습됨. 긴 응답 → 잘림 → EOS 미학습 → 생성 시 끝없이 반복 생성의 직접적 원인.

수정:

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

# 각 샘플에 대해:
# 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 개선)

# 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 보존

# 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 추가

# 데이터 분리 스크립트
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)

# 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: 학습 데이터에서 반복 패턴 필터링

# 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.pydynamic_collate_fn: 이미 구현됨 (dataset 수정만 하면 작동)
  3. launch_sft.sh: val_data 추가, max_steps 10000

Phase 3: 학습 (8-12시간, 8×B200 기준)

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