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

9.6 KiB
Raw Permalink Blame History

반복 퇴화(Repetition Degeneration) 근본 원인 분석 + Fix 보고서

날짜: 2026-02-26
모델: Korean 1B SFT (checkpoint-0005000)
문제: 생성 시 57% 3-gram 반복률, "### 답변:" 패턴 반복


1. 핵심 발견: 반복률 57%의 근본 원인

🔴 원인 #1 (가장 큰 원인): 프롬프트 포맷 불일치

학습 시 포맷 평가(sft_quick_check) 시 포맷
유저 턴 <|user|>\n{질문}\n<|assistant|>\n ### 질문: {질문}\n### 답변:
EOS </s> 없음

SFT 데이터는 <|user|> / <|assistant|> 포맷으로 학습했는데, 평가는 ### 질문: / ### 답변: 포맷으로 수행함.

이건 모델이 한 번도 본 적 없는 포맷이므로:

  • EOS를 언제 생성해야 하는지 모름 → 무한 생성
  • ### 답변: 패턴이 pretrain 코퍼스에 있었을 가능성 → 반복 루프 진입

🟡 원인 #2: 추론 파라미터 부재

  • repetition_penalty 미적용 (기본 1.0)
  • no_repeat_ngram_size 미적용
  • stop sequence 미구현

2. 실험 결과 (17개 파라미터 조합 × 2 포맷)

올바른 SFT 포맷 (<|user|> / <|assistant|>)

Config Avg 3-gram Rep 비고
baseline (T=0.8, no penalty) 5.4% 포맷만 맞춰도 57% → 5%로 급감!
rep_penalty=1.1 0.0% 완전 해결
rep_penalty=1.2 0.0%
no_repeat_3gram 0.0%
best_combo 0.0%
temp=0.5 (낮은 온도) 15.5% 오히려 악화
contrastive_a0.6_k4 35.4% 간이 구현은 효과 없음

잘못된 포맷 (### 질문: / ### 답변:)

Config Avg 3-gram Rep 비고
baseline 35.3% 포맷 불일치로 심각
rep_penalty=1.1 1.9% 거의 해결되지만 품질 저하
rep_penalty=1.2 0.0% 반복은 없지만 출력 품질 나쁨 (깨진 텍스트)
temp=0.5 57.5% ← 보고된 57% 수치와 일치!
contrastive_a0.4_k6 60.9% 최악

핵심 인사이트

  1. 포맷 수정만으로 반복률 57% → 5%로 감소 (10배 개선)
  2. rep_penalty=1.1 추가하면 5% → 0% (완전 해결)
  3. 잘못된 포맷에서 rep_penalty로 반복을 억제하면 품질이 심각하게 저하 (깨진 텍스트 출력)
  4. 낮은 temperature(0.5)가 오히려 반복을 악화시킴 (확률 분포가 뾰족해져서 같은 토큰에 빠짐)

3. 즉시 적용 가능한 최적 파라미터 조합

추천 설정 (즉시 적용)

# eval/generate.py 또는 추론 코드에 적용
generation_config = {
    # 1. 올바른 프롬프트 포맷 사용 (가장 중요!)
    "prompt_template": "<|user|>\n{question}\n<|assistant|>\n",
    
    # 2. 생성 파라미터
    "temperature": 0.8,
    "top_p": 0.9,
    "top_k": 50,
    "repetition_penalty": 1.1,       # 최소한의 반복 억제
    "no_repeat_ngram_size": 3,        # 3-gram 반복 완전 차단 (보험)
    "max_new_tokens": 512,
    
    # 3. EOS token
    "eos_token_id": 2,                # </s> token ID
}

4. Stop Sequence 구현 코드

토큰 ID 매핑

'</s>'          → [2]                          (단일 토큰, 이미 EOS로 사용)
'### 답변:'     → [493, 2894, 2894, 10663, 36] (5 토큰)
'### 질문:'     → [493, 2894, 2894, 6326, 36]  (5 토큰)
'<|user|>'      → [473, 2526, 887, 201, 2526, 927] (6 토큰)
'<|assistant|>' → [473, 2526, 29273, 16, 24232, 2526, 927] (7 토큰)

구현 방법 (generate.py 수정)

# eval/generate.py의 generate() 함수에 추가

def generate(..., stop_strings=None, repetition_penalty=1.0, no_repeat_ngram_size=0):
    # ... 기존 코드 ...
    
    # Stop sequence token IDs 사전 계산
    stop_seqs = []
    if stop_strings:
        for s in stop_strings:
            stop_seqs.append(tokenizer.encode(s).ids)
    
    new_token_ids = []
    
    for _ in range(max_new_tokens):
        logits_all, _ = model(generated_ids)
        logits = logits_all[:, -1, :].clone()
        
        # --- Repetition penalty ---
        if repetition_penalty != 1.0:
            for token_id in set(generated_ids[0].tolist()):
                if logits[0, token_id] > 0:
                    logits[0, token_id] /= repetition_penalty
                else:
                    logits[0, token_id] *= repetition_penalty
        
        # --- No-repeat n-gram blocking ---
        if no_repeat_ngram_size > 0:
            all_ids = generated_ids[0].tolist()
            if len(all_ids) >= no_repeat_ngram_size:
                last_ngram = tuple(all_ids[-(no_repeat_ngram_size - 1):])
                for i in range(len(all_ids) - no_repeat_ngram_size + 1):
                    if tuple(all_ids[i:i + no_repeat_ngram_size - 1]) == last_ngram:
                        logits[0, all_ids[i + no_repeat_ngram_size - 1]] = float("-inf")
        
        # ... 기존 sampling 코드 ...
        
        new_token_ids.append(next_token_id.item())
        
        # --- Stop sequence 확인 ---
        for seq in stop_seqs:
            if len(new_token_ids) >= len(seq) and new_token_ids[-len(seq):] == seq:
                new_token_ids = new_token_ids[:-len(seq)]  # stop seq 제거
                return tokenizer.decode(new_token_ids)
        
        # EOS
        if eos_token_id is not None and next_token_id.item() == eos_token_id:
            break

5. 학습 레벨 근본 Fix

5.1 현재 학습 코드 진단

잘 되어 있는 것:

  • SFTDataset에서 labels=-1로 prompt 토큰 마스킹 → response 토큰에만 loss 계산
  • </s> EOS 토큰이 response 끝에 추가됨
  • ignore_index=-1CrossEntropyLoss 사용

⚠️ 개선 필요:

  • 반복 억제 학습 전략 없음 (Unlikelihood Training 등)
  • 데이터 품질 필터링 없음 (반복적인 응답 포함 가능)

5.2 Unlikelihood Training 추가 (중기)

# train/trainer.py의 _compute_loss() 수정

def _compute_loss(self, logits, targets, input_ids=None, alpha_ul=0.5):
    """Loss = CE_loss + alpha * Unlikelihood_loss"""
    B, S, V = logits.shape
    
    # 기존 CE loss
    ce_loss = F.cross_entropy(
        logits.reshape(B * S, V),
        targets.reshape(B * S),
        ignore_index=-1,
    )
    
    if input_ids is None or alpha_ul == 0:
        return ce_loss
    
    # Unlikelihood loss: 이전에 나온 토큰의 확률을 낮추는 loss
    probs = F.softmax(logits, dim=-1)  # [B, S, V]
    
    # 각 position에서 "이전에 이미 나온 토큰" 집합 구성
    # Negative candidates = 이전 context에서 나온 토큰들
    ul_loss = torch.tensor(0.0, device=logits.device)
    count = 0
    
    for b in range(B):
        seen_tokens = set()
        for s in range(S):
            if targets[b, s] == -1:
                seen_tokens.add(input_ids[b, s].item())
                continue
            
            # 현재 position에서 seen tokens의 확률
            for tok in seen_tokens:
                # Unlikelihood: maximize log(1 - p(tok))
                p = probs[b, s, tok]
                ul_loss -= torch.log(torch.clamp(1 - p, min=1e-8))
                count += 1
            
            seen_tokens.add(targets[b, s].item())
    
    if count > 0:
        ul_loss = ul_loss / count
    
    return ce_loss + alpha_ul * ul_loss

5.3 데이터 증강: 반복 필터링 (중기)

# data/filter_repetitive.py
def filter_repetitive_samples(jsonl_path, output_path, max_3gram_rep=0.15):
    """반복률이 높은 샘플 제거"""
    import json
    
    kept, removed = 0, 0
    with open(jsonl_path) as fin, open(output_path, 'w') as fout:
        for line in fin:
            obj = json.loads(line)
            output = obj.get('output', '')
            
            # 3-gram 반복률 계산
            tokens = output.split()
            if len(tokens) >= 3:
                ngrams = [tuple(tokens[i:i+3]) for i in range(len(tokens)-2)]
                rep = 1 - len(set(ngrams)) / len(ngrams) if ngrams else 0
                if rep > max_3gram_rep:
                    removed += 1
                    continue
            
            fout.write(line)
            kept += 1
    
    print(f"Kept: {kept}, Removed: {removed}")

6. 로드맵

🟢 단기 (즉시, 코드 수정만)

  1. 평가 프롬프트 포맷을 <|user|> / <|assistant|>로 수정 — 이것만으로 57% → 5%
  2. repetition_penalty=1.1 추가 — 5% → 0%
  3. no_repeat_ngram_size=3 추가 — 보험
  4. sft_quick_check 스크립트의 프롬프트 템플릿 수정

🟡 중기 (1-2주)

  1. 데이터 품질 필터링 (반복적 응답 제거)
  2. Unlikelihood Training loss 추가 (alpha=0.1~0.5)
  3. EOS 생성 학습 강화: response 끝 EOS에 weight 1.5x 부여
  4. 다양한 temperature에서의 robustness 테스트

🔵 장기 (1개월+)

  1. HuggingFace transformers 호환 모델 변환 → generate() 내장 기능 활용
  2. RLHF/DPO: 반복 없는 응답을 preferred로 학습
  3. Contrastive Search (진짜 hidden-state 기반) 구현
  4. 모델 크기별 반복 퇴화 비교 분석

7. sft_quick_check 수정 예시

기존 (잘못됨):

prompt = "### 질문: 한국의 수도는 어디인가요?\n### 답변:"

수정 (올바름):

prompt = "<|user|>\n한국의 수도는 어디인가요?\n<|assistant|>\n"

부록: 실험 전체 결과

테스트 스크립트: eval/test_generation_params.py
결과 JSON: eval/repetition_param_search_results.json

총 17개 파라미터 조합 × 2개 포맷 (sft_format, wrong_format) = 34 실험 수행. 모든 실험은 checkpoint-0005000 기준, 5개 프롬프트, max_new_tokens=200.