이미지 로딩 중...

AI 파인튜닝 실전 완벽 가이드 - 슬라이드 1/10
A

AI Generated

2025. 11. 9. · 2 Views

AI 파인튜닝 실전 완벽 가이드

실무에서 바로 활용 가능한 AI 파인튜닝 기법을 다룹니다. 데이터셋 준비부터 모델 학습, 평가, 배포까지 전체 파이프라인을 실습 코드와 함께 단계별로 설명합니다.


목차

  1. 파인튜닝_데이터셋_준비
  2. Tokenizer_설정과_데이터_전처리
  3. LoRA_설정으로_효율적_파인튜닝
  4. Training_Arguments_최적화
  5. Custom_Trainer_구현
  6. 학습_모니터링과_로깅
  7. 모델_평가와_검증
  8. 추론_파이프라인_구축
  9. 모델_최적화와_양자화

1. 파인튜닝_데이터셋_준비

시작하며

여러분이 GPT 모델을 회사의 특정 도메인에 맞게 파인튜닝하려고 할 때, 가장 먼저 막막한 것이 바로 데이터셋 준비입니다. "어떤 형식으로 만들어야 하지?", "몇 개나 필요하지?", "품질은 어떻게 보장하지?" 같은 질문들이 꼬리를 물죠.

실제로 파인튜닝의 성패는 80% 이상이 데이터 품질에 달려 있습니다. 아무리 좋은 모델과 학습 기법을 사용해도 데이터가 엉망이면 결과도 엉망입니다.

특히 instruction-following 태스크의 경우, 프롬프트-응답 쌍의 일관성과 다양성이 핵심입니다. 바로 이럴 때 필요한 것이 체계적인 데이터셋 포맷과 검증 파이프라인입니다.

표준화된 JSON 구조로 데이터를 관리하면 재사용성과 확장성이 크게 향상됩니다.

개요

간단히 말해서, 파인튜닝 데이터셋은 모델이 학습할 입력-출력 쌍의 집합입니다. 하지만 단순히 텍스트를 모아놓는 것이 아니라, 특정한 구조와 형식을 따라야 합니다.

HuggingFace의 Transformers 라이브러리는 주로 JSON Lines 형식(.jsonl)을 선호합니다. 각 줄이 하나의 학습 샘플을 나타내며, "instruction", "input", "output" 필드로 구성됩니다.

이 형식은 대용량 데이터셋도 효율적으로 처리할 수 있고, 스트리밍 학습도 가능하게 합니다. 데이터 품질 체크도 필수입니다.

중복 제거, 길이 필터링, 유해 콘텐츠 검사를 자동화해야 합니다. 기존에는 수작업으로 데이터를 검토했다면, 이제는 스크립트를 통해 수천 개의 샘플을 몇 초 만에 검증할 수 있습니다.

핵심 특징은 첫째, 일관된 스키마로 데이터 관리가 쉽고, 둘째, 프로그래밍 방식으로 품질 검증이 가능하며, 셋째, 버전 관리와 추적이 용이하다는 점입니다. 이러한 특징들이 대규모 프로젝트에서 협업을 가능하게 만듭니다.

코드 예제

import json
from typing import List, Dict
from pathlib import Path

def create_finetuning_dataset(samples: List[Dict], output_path: str):
    """파인튜닝용 데이터셋을 표준 형식으로 생성"""
    # 데이터 검증 및 전처리
    validated_samples = []
    for sample in samples:
        # 필수 필드 확인
        if not all(k in sample for k in ['instruction', 'output']):
            print(f"Skipping invalid sample: {sample}")
            continue

        # 길이 제한 (너무 짧거나 긴 샘플 제외)
        if len(sample['output']) < 10 or len(sample['output']) > 2048:
            continue

        # 표준 형식으로 변환
        formatted = {
            'instruction': sample['instruction'].strip(),
            'input': sample.get('input', '').strip(),
            'output': sample['output'].strip()
        }
        validated_samples.append(formatted)

    # JSONL 형식으로 저장
    Path(output_path).parent.mkdir(parents=True, exist_ok=True)
    with open(output_path, 'w', encoding='utf-8') as f:
        for sample in validated_samples:
            f.write(json.dumps(sample, ensure_ascii=False) + '\n')

    print(f"✅ Saved {len(validated_samples)} samples to {output_path}")
    return len(validated_samples)

# 실사용 예시
samples = [
    {
        'instruction': 'Python 코드를 설명해주세요',
        'input': 'def factorial(n): return 1 if n <= 1 else n * factorial(n-1)',
        'output': '이 코드는 재귀 방식으로 팩토리얼을 계산합니다. n이 1 이하면 1을 반환하고, 그렇지 않으면 n과 factorial(n-1)을 곱합니다.'
    }
]
create_finetuning_dataset(samples, 'data/train.jsonl')

설명

이것이 하는 일: 원시 데이터를 파인튜닝에 적합한 표준 형식으로 변환하고, 품질을 검증하며, 파일로 저장합니다. 첫 번째로, 함수는 리스트 형태의 원시 샘플들을 받아서 각각을 검증합니다.

필수 필드인 'instruction'과 'output'이 없는 샘플은 건너뜁니다. 이렇게 하는 이유는 학습 중 에러를 방지하기 위함입니다.

불완전한 데이터 하나가 전체 학습을 중단시킬 수 있기 때문입니다. 그 다음으로, 길이 필터링을 수행합니다.

10자 미만의 너무 짧은 응답이나 2048자를 초과하는 너무 긴 응답은 제외됩니다. 내부에서는 토큰 제한과 학습 효율성을 고려한 것입니다.

너무 짧으면 학습할 패턴이 부족하고, 너무 길면 메모리 문제가 발생합니다. 세 번째 단계에서는 표준 형식으로 변환합니다.

모든 텍스트 필드에 .strip()을 적용하여 불필요한 공백을 제거하고, 'input' 필드가 없으면 빈 문자열을 기본값으로 설정합니다. 이렇게 하면 다양한 소스의 데이터를 일관된 형식으로 통합할 수 있습니다.

마지막으로, JSONL 형식으로 파일에 저장합니다. 각 샘플이 한 줄씩 저장되며, ensure_ascii=False 옵션으로 한글이 깨지지 않게 합니다.

최종적으로 저장된 샘플 수를 반환하여 데이터 손실을 추적할 수 있습니다. 여러분이 이 코드를 사용하면 수천 개의 데이터를 몇 초 만에 검증하고 표준화할 수 있습니다.

데이터 품질 문제로 인한 학습 실패를 사전에 방지하고, 팀원들과 일관된 형식으로 협업할 수 있으며, 버전 관리 시스템으로 데이터 변경 이력을 추적할 수 있습니다.

실전 팁

💡 최소 100개 이상의 고품질 샘플을 준비하세요. 적은 데이터로는 overfitting 위험이 큽니다. 도메인별로 다르지만, 일반적으로 500-1000개 정도가 안정적인 결과를 냅니다.

💡 데이터 증강(augmentation)을 활용하세요. 같은 의미를 다른 표현으로 바꾸거나, GPT-4로 유사 샘플을 생성하면 데이터셋 크기를 효과적으로 늘릴 수 있습니다.

💡 train/validation/test를 8:1:1 비율로 분리하세요. 학습 중 과적합을 조기 발견하려면 검증 세트가 필수입니다. random.shuffle()로 섞은 후 분리하세요.

💡 데이터 버전을 관리하세요. 파일명에 날짜나 버전을 포함시키고(예: train_v1.2_20250109.jsonl), 각 버전의 특징을 README에 기록하면 실험 재현성이 높아집니다.

💡 중복 제거를 자동화하세요. 해시 함수로 완전 중복을 제거하고, 유사도 측정으로 거의 같은 샘플도 걸러내세요. 중복 데이터는 모델이 특정 패턴만 외우게 만듭니다.


2. Tokenizer_설정과_데이터_전처리

시작하며

여러분이 데이터셋을 준비했다면, 다음 단계는 모델이 이해할 수 있는 형태로 변환하는 것입니다. "텍스트를 어떻게 숫자로 바꾸지?", "특수 토큰은 어떻게 처리하지?" 같은 질문이 생깁니다.

이 과정이 잘못되면 학습은 되지만 성능이 형편없거나, 추론 시 이상한 출력이 나옵니다. 특히 instruction-following 모델의 경우, 프롬프트 템플릿 형식이 조금만 달라져도 모델이 제대로 작동하지 않습니다.

실제로 토큰화 오류로 수일간 학습한 모델을 버리는 경우가 많습니다. 바로 이럴 때 필요한 것이 체계적인 토크나이저 설정과 데이터 전처리 파이프라인입니다.

표준 템플릿을 사용하고 자동화하면 실수를 줄이고 일관성을 보장할 수 있습니다.

개요

간단히 말해서, 토크나이저는 텍스트를 모델이 처리할 수 있는 숫자(토큰 ID)로 변환하는 도구입니다. 하지만 단순 변환이 아니라 특수 토큰 추가, 길이 제한, 패딩 등 복잡한 처리를 수행합니다.

HuggingFace의 AutoTokenizer는 모델별로 최적화된 토큰화 방식을 자동으로 선택합니다. LLaMA 모델은 SentencePiece를, GPT는 BPE를 사용하는데, 이를 수동으로 설정할 필요가 없습니다.

중요한 것은 특수 토큰 설정과 프롬프트 템플릿입니다. Chat 모델의 경우 시스템 메시지, 유저 메시지, 어시스턴트 응답을 구분하는 특수 토큰이 있습니다.

기존에는 이를 수동으로 문자열 조합했다면, 이제는 chat_template을 사용하여 자동화할 수 있습니다. 핵심 특징은 첫째, 모델별 최적 토큰화 자동 적용, 둘째, 배치 처리로 대량 데이터 빠른 처리, 셋째, attention mask와 padding 자동 생성입니다.

이러한 특징들이 복잡한 전처리를 단 몇 줄의 코드로 해결하게 만듭니다.

코드 예제

from transformers import AutoTokenizer
from datasets import Dataset

def preprocess_function(examples, tokenizer, max_length=2048):
    """데이터셋을 모델 입력 형식으로 변환"""
    # 프롬프트 템플릿 적용
    prompts = []
    for inst, inp, out in zip(examples['instruction'],
                               examples['input'],
                               examples['output']):
        # Alpaca 스타일 프롬프트
        if inp.strip():
            prompt = f"### Instruction:\n{inst}\n\n### Input:\n{inp}\n\n### Response:\n{out}"
        else:
            prompt = f"### Instruction:\n{inst}\n\n### Response:\n{out}"
        prompts.append(prompt)

    # 토큰화 (자동 패딩 및 truncation)
    model_inputs = tokenizer(
        prompts,
        max_length=max_length,
        padding='max_length',
        truncation=True,
        return_tensors=None  # 리스트로 반환 (datasets 호환)
    )

    # Labels 설정 (입력과 동일, padding 토큰은 -100으로 마스킹)
    model_inputs['labels'] = [
        [(token_id if token_id != tokenizer.pad_token_id else -100)
         for token_id in input_ids]
        for input_ids in model_inputs['input_ids']
    ]

    return model_inputs

# 실사용
tokenizer = AutoTokenizer.from_pretrained('beomi/llama-2-ko-7b')
tokenizer.pad_token = tokenizer.eos_token  # 패딩 토큰 설정

# 데이터셋 로드 및 전처리
from datasets import load_dataset
dataset = load_dataset('json', data_files='data/train.jsonl')
tokenized_dataset = dataset.map(
    lambda x: preprocess_function(x, tokenizer),
    batched=True,
    remove_columns=['instruction', 'input', 'output']
)

설명

이것이 하는 일: 원시 텍스트 데이터를 모델이 학습할 수 있는 토큰 ID 배열로 변환하고, attention mask와 labels를 생성합니다. 첫 번째로, 프롬프트 템플릿을 적용합니다.

instruction과 input, output을 정해진 형식(Alpaca 스타일)으로 조합합니다. "### Instruction:", "### Response:" 같은 구분자를 사용하여 모델이 각 부분의 역할을 학습하도록 합니다.

왜 이렇게 하냐면, 일관된 형식이 모델의 instruction-following 능력을 크게 향상시키기 때문입니다. 그 다음으로, tokenizer를 호출하여 실제 토큰화를 수행합니다.

max_length=2048로 최대 길이를 제한하고, padding='max_length'로 모든 샘플을 같은 길이로 맞춥니다. 내부에서는 짧은 샘플에 pad_token을 추가하고, 긴 샘플은 잘라냅니다.

배치 학습에서는 모든 샘플의 길이가 같아야 하므로 이 과정이 필수입니다. 세 번째 단계에서 labels를 생성합니다.

Causal Language Modeling에서는 labels가 input_ids와 동일하지만, padding 부분은 -100으로 설정합니다. PyTorch의 CrossEntropyLoss는 -100을 무시하므로, padding 토큰이 손실 계산에 영향을 주지 않습니다.

이렇게 하지 않으면 모델이 의미 없는 padding 토큰까지 학습하려 해서 성능이 저하됩니다. 마지막으로, dataset.map()을 사용하여 전체 데이터셋에 일괄 적용합니다.

batched=True로 배치 처리를 활성화하면 수만 개의 샘플도 몇 분 만에 처리됩니다. remove_columns로 원본 텍스트 컬럼을 제거하여 메모리를 절약합니다.

여러분이 이 코드를 사용하면 복잡한 토큰화 과정을 자동화할 수 있습니다. 수동으로 토큰 ID를 다룰 필요 없이 텍스트만 제공하면 되고, 프롬프트 형식 오류를 방지하며, 대용량 데이터셋도 효율적으로 처리할 수 있습니다.

실전 팁

💡 pad_token을 반드시 설정하세요. LLaMA 같은 모델은 기본적으로 pad_token이 없어서 에러가 납니다. tokenizer.pad_token = tokenizer.eos_token으로 설정하면 해결됩니다.

💡 프롬프트 템플릿을 일관되게 유지하세요. 학습과 추론에서 같은 형식을 사용해야 합니다. 형식이 다르면 모델이 혼란스러워서 성능이 급격히 떨어집니다.

💡 max_length를 신중히 선택하세요. 너무 크면 메모리 부족, 너무 작으면 중요한 정보 손실입니다. 데이터의 길이 분포를 먼저 분석하고(예: 95 percentile), 그에 맞게 설정하세요.

💡 토큰화된 데이터를 디스크에 캐시하세요. save_to_disk()로 저장하면 다음번에는 몇 초 만에 로드됩니다. 수만 개 샘플을 매번 토큰화하는 것은 시간 낭비입니다.

💡 특수 토큰을 추가할 때는 resize_token_embeddings()를 호출하세요. 새 토큰을 추가하면 embedding 레이어 크기도 늘려야 합니다. 안 그러면 IndexError가 발생합니다.


3. LoRA_설정으로_효율적_파인튜닝

시작하며

여러분이 7B 파라미터 모델을 파인튜닝하려 할 때, GPU 메모리 부족 에러를 만나본 적 있나요? Full fine-tuning은 A100 80GB조차 모자랄 때가 많습니다.

더구나 모든 파라미터를 업데이트하면 학습 시간도 엄청나게 길어집니다. 이런 문제는 개인 연구자나 스타트업에게 큰 장벽입니다.

수백만 원짜리 GPU를 구매할 수도 없고, 클라우드 비용도 만만치 않습니다. 게다가 전체 모델을 저장하면 수십 GB의 체크포인트가 생성되어 관리가 어렵습니다.

바로 이럴 때 필요한 것이 LoRA(Low-Rank Adaptation)입니다. 모델의 극히 일부만 학습하면서도 full fine-tuning에 근접한 성능을 내고, 메모리와 시간을 10분의 1 이하로 줄입니다.

개요

간단히 말해서, LoRA는 모델의 특정 레이어에 작은 어댑터를 추가하여 그것만 학습하는 기법입니다. 원본 모델의 파라미터는 frozen 상태로 유지됩니다.

왜 이 개념이 필요한지는 자원 효율성 때문입니다. 7B 모델을 full fine-tuning하면 약 28GB의 GPU 메모리가 필요하지만, LoRA를 사용하면 12GB로 가능합니다.

예를 들어, 같은 RTX 4090 24GB로 full fine-tuning은 불가능하지만 LoRA는 여유롭게 돌아갑니다. 전통적인 방법과 비교하면, 기존에는 모든 70억 개의 파라미터를 업데이트했다면, 이제는 1-2%만 학습합니다.

하지만 성능 차이는 5% 이내로 거의 없습니다. 이것이 LoRA의 마법입니다.

핵심 특징은 첫째, 메모리 사용량이 1/3 수준으로 감소, 둘째, 학습 속도가 2-3배 빨라짐, 셋째, 어댑터만 저장하면 되므로 모델 관리가 쉽다는 점입니다. 이러한 특징들이 개인 연구자도 대규모 모델을 파인튜닝할 수 있게 만듭니다.

코드 예제

from peft import LoraConfig, get_peft_model, TaskType
from transformers import AutoModelForCausalLM

def setup_lora_model(model_name, lora_r=8, lora_alpha=16, lora_dropout=0.05):
    """LoRA를 적용한 모델 생성"""
    # 베이스 모델 로드 (원본 가중치는 frozen)
    model = AutoModelForCausalLM.from_pretrained(
        model_name,
        load_in_8bit=True,  # 8-bit 양자화로 메모리 절약
        device_map='auto',   # 자동 GPU 할당
        trust_remote_code=True
    )

    # LoRA 설정
    lora_config = LoraConfig(
        r=lora_r,  # Low-rank 차원 (작을수록 파라미터 적음)
        lora_alpha=lora_alpha,  # 스케일링 팩터
        target_modules=['q_proj', 'k_proj', 'v_proj', 'o_proj'],  # 어텐션 레이어
        lora_dropout=lora_dropout,  # 정규화
        bias='none',  # bias는 학습 안 함
        task_type=TaskType.CAUSAL_LM  # 언어 모델링 태스크
    )

    # LoRA 어댑터 추가
    model = get_peft_model(model, lora_config)

    # 학습 가능 파라미터 출력
    trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
    total_params = sum(p.numel() for p in model.parameters())
    print(f"Trainable: {trainable_params:,} ({100*trainable_params/total_params:.2f}%)")

    return model

# 실사용
model = setup_lora_model('beomi/llama-2-ko-7b', lora_r=8)
# 출력 예시: Trainable: 8,388,608 (0.12%)

설명

이것이 하는 일: 대규모 언어 모델에 작은 어댑터를 추가하여 효율적으로 파인튜닝할 수 있게 준비합니다. 첫 번째로, 베이스 모델을 load_in_8bit=True로 로드합니다.

이것은 가중치를 8-bit로 양자화하여 메모리를 절반으로 줄입니다. 왜 이렇게 하냐면, LoRA와 결합하면 24GB GPU로도 13B 모델을 학습할 수 있기 때문입니다.

device_map='auto'는 모델을 여러 GPU나 CPU로 자동 분산시킵니다. 그 다음으로, LoraConfig를 정의합니다.

r=8은 low-rank 행렬의 차원입니다. 내부적으로는 원래 차원이 4096이라면, 4096 → 8 → 4096로 분해하여 파라미터를 크게 줄입니다.

lora_alpha=16은 학습률 조정에 사용되며, 보통 r의 2배로 설정합니다. target_modules는 어댑터를 추가할 레이어를 지정하는데, attention의 Q, K, V, O projection이 가장 효과적입니다.

세 번째 단계에서 get_peft_model()로 실제 어댑터를 모델에 추가합니다. 이 함수는 지정된 레이어들에 LoRA 행렬을 삽입하고, 원본 가중치는 frozen 시킵니다.

결과적으로 모델의 구조는 바뀌지만 학습 가능한 파라미터는 극소수만 추가됩니다. 마지막으로, 학습 가능한 파라미터 수를 출력하여 확인합니다.

7B 모델의 경우 보통 0.1-0.2%만 학습 가능하게 설정됩니다. 이것은 약 8-16M 파라미터에 불과하며, 이 적은 수로도 모델을 효과적으로 튜닝할 수 있습니다.

여러분이 이 코드를 사용하면 제한된 GPU로도 대규모 모델을 파인튜닝할 수 있습니다. RTX 3090 24GB로 7B 모델을 학습할 수 있고, 학습 시간이 절반으로 줄어들며, 어댑터만 저장하면 되므로 체크포인트 크기가 100MB 이하로 작아집니다.

실전 팁

💡 r 값은 8-64 사이에서 실험하세요. 8이면 빠르지만 복잡한 태스크는 성능이 부족할 수 있고, 64면 성능은 좋지만 느립니다. 보통 16이 좋은 시작점입니다.

💡 QLoRA(4-bit + LoRA)를 사용하면 메모리를 더 줄입니다. load_in_4bit=True로 설정하면 12GB GPU로도 13B 모델을 학습할 수 있습니다.

💡 target_modules를 MLP 레이어까지 확장하면 성능이 향상됩니다. ['q_proj', 'k_proj', 'v_proj', 'o_proj', 'gate_proj', 'up_proj', 'down_proj']처럼 설정하세요.

💡 lora_dropout=0.05로 정규화하세요. 작은 데이터셋(<1000 샘플)에서는 overfitting이 심하므로 dropout이 필수입니다.

💡 여러 LoRA 어댑터를 만들어 태스크별로 전환하세요. 베이스 모델 하나에 번역용, 요약용, 코드생성용 어댑터를 따로 학습하면 멀티태스크가 가능합니다.


4. Training_Arguments_최적화

시작하며

여러분이 모델과 데이터를 준비했다면, 이제 실제 학습을 시작할 차례입니다. 하지만 "learning rate은 얼마로?", "batch size는?", "몇 epoch 돌려야 하지?" 같은 질문에 막막함을 느낍니다.

이런 하이퍼파라미터 설정은 학습의 성패를 좌우합니다. learning rate이 너무 크면 학습이 발산하고, 너무 작으면 몇 시간을 돌려도 개선이 없습니다.

batch size가 작으면 불안정하고, 크면 메모리가 터집니다. 실제로 잘못된 설정으로 며칠간 학습한 결과를 버리는 경우가 흔합니다.

바로 이럴 때 필요한 것이 검증된 Training Arguments 템플릿과 최적화 전략입니다. 모델 크기와 데이터셋에 맞는 기본 설정을 사용하고, 학습 중 동적으로 조정하면 안정적인 결과를 얻을 수 있습니다.

개요

간단히 말해서, Training Arguments는 학습 과정의 모든 설정을 담은 객체입니다. learning rate, batch size, optimizer, scheduler, 로깅 등 수십 개의 옵션을 제어합니다.

HuggingFace의 TrainingArguments는 학습 안정화 기능들이 내장되어 있습니다. gradient accumulation으로 메모리 부족 문제를 해결하고, learning rate scheduler로 수렴을 개선하며, mixed precision training으로 속도를 2배 높입니다.

이런 기능들을 일일이 구현하려면 수백 줄의 코드가 필요합니다. 전통적인 PyTorch 학습 루프와 비교하면, 기존에는 optimizer, scheduler, gradient clipping, logging을 수동으로 작성했다면, 이제는 인자만 전달하면 자동으로 처리됩니다.

distributed training도 한 줄로 활성화됩니다. 핵심 특징은 첫째, fp16/bf16 자동 혼합 정밀도로 속도 향상, 둘째, gradient accumulation으로 큰 effective batch size 구현, 셋째, 자동 체크포인트 저장과 재개입니다.

이러한 특징들이 복잡한 학습 파이프라인을 단순화시킵니다.

코드 예제

from transformers import TrainingArguments, Trainer
import torch

def get_training_args(output_dir='./results', num_train_epochs=3):
    """최적화된 학습 설정 생성"""
    # 사용 가능한 GPU 메모리에 따라 batch size 조정
    gpu_memory_gb = torch.cuda.get_device_properties(0).total_memory / 1e9
    per_device_batch_size = 4 if gpu_memory_gb < 24 else 8

    training_args = TrainingArguments(
        output_dir=output_dir,
        num_train_epochs=num_train_epochs,
        per_device_train_batch_size=per_device_batch_size,
        per_device_eval_batch_size=per_device_batch_size,

        # Gradient accumulation (effective batch size 증가)
        gradient_accumulation_steps=4,  # effective = 4 * 4 = 16

        # Learning rate 설정
        learning_rate=2e-4,  # LoRA는 보통 1e-4 ~ 3e-4
        lr_scheduler_type='cosine',  # Cosine annealing
        warmup_ratio=0.03,  # 처음 3%는 warmup

        # 최적화 및 정규화
        optim='paged_adamw_8bit',  # 메모리 효율적 optimizer
        weight_decay=0.01,  # L2 regularization
        max_grad_norm=1.0,  # Gradient clipping

        # 혼합 정밀도 (A100/4090은 bf16, 나머지는 fp16)
        bf16=torch.cuda.is_bf16_supported(),
        fp16=not torch.cuda.is_bf16_supported(),

        # 로깅 및 저장
        logging_steps=10,
        save_strategy='steps',
        save_steps=100,
        save_total_limit=3,  # 최근 3개 체크포인트만 유지

        # 평가
        evaluation_strategy='steps',
        eval_steps=100,
        load_best_model_at_end=True,  # 최고 성능 모델 로드
        metric_for_best_model='eval_loss',

        # 기타
        report_to='tensorboard',  # TensorBoard 로깅
        seed=42,  # 재현성
    )

    return training_args

# 실사용
args = get_training_args(output_dir='./llama2-ko-finetuned')

설명

이것이 하는 일: 모델 학습에 필요한 모든 하이퍼파라미터를 안정적이고 효율적인 값으로 설정합니다. 첫 번째로, GPU 메모리를 자동 감지하여 batch size를 조정합니다.

24GB 미만이면 4, 이상이면 8로 설정하여 OOM(Out Of Memory)을 방지합니다. 왜 이렇게 하냐면, 하드코딩된 batch size는 다른 환경에서 실행할 때 문제를 일으키기 때문입니다.

그 다음으로, gradient_accumulation_steps=4로 설정하여 effective batch size를 늘립니다. 내부에서는 4번의 forward-backward를 수행한 후 한 번만 optimizer.step()을 호출합니다.

이렇게 하면 메모리는 batch_size=4만 사용하면서도 batch_size=16의 학습 안정성을 얻습니다. 세 번째 단계에서 learning rate과 scheduler를 설정합니다.

LoRA는 full fine-tuning보다 높은 learning rate(2e-4)를 사용합니다. cosine scheduler는 학습 후반부에 lr을 0으로 감소시켜 fine-grained한 수렴을 돕습니다.

warmup_ratio=0.03은 처음 3% step 동안 lr을 점진적으로 올려 초기 불안정성을 줄입니다. 네 번째로, 혼합 정밀도를 자동 선택합니다.

A100이나 RTX 4090은 bf16을 지원하여 더 안정적이고, 구형 GPU는 fp16을 사용합니다. 혼합 정밀도는 메모리를 절반으로 줄이고 속도를 2배 높이면서도 성능 손실이 거의 없습니다.

마지막으로, 로깅과 체크포인트 전략을 설정합니다. 100 step마다 저장하고 평가하며, save_total_limit=3으로 디스크 공간을 절약합니다.

load_best_model_at_end=True는 학습 종료 시 가장 낮은 eval_loss를 가진 모델을 로드합니다. 여러분이 이 코드를 사용하면 복잡한 하이퍼파라미터 튜닝 없이 안정적인 결과를 얻을 수 있습니다.

대부분의 태스크에서 즉시 사용 가능하고, 학습 중단 시 자동 재개가 가능하며, TensorBoard로 학습 과정을 시각화할 수 있습니다.

실전 팁

💡 작은 데이터셋(<1000)에서는 epoch을 늘리세요. num_train_epochs=10 정도로 설정하되, early stopping으로 overfitting을 막으세요.

💡 learning rate을 실험하세요. 2e-4로 시작해서 loss가 발산하면 1e-4로, 너무 느리면 3e-4로 조정합니다. learning rate finder를 사용하면 최적값을 자동으로 찾을 수 있습니다.

💡 gradient_checkpointing=True를 추가하면 메모리를 더 절약합니다. 속도는 약간 느려지지만, OOM이 발생하는 상황에서 필수입니다.

💡 save_steps를 데이터셋 크기에 맞게 조정하세요. 작은 데이터셋은 50 step마다, 큰 데이터셋은 500 step마다 저장하여 디스크 I/O를 줄이세요.

💡 WandB를 사용하면 더 강력한 모니터링이 가능합니다. report_to='wandb'로 설정하고 wandb.init()를 호출하면 웹에서 실시간으로 학습을 추적할 수 있습니다.


5. Custom_Trainer_구현

시작하며

여러분이 기본 Trainer를 사용하다가 "특정 metric을 추가하고 싶은데", "학습 중 특별한 처리가 필요한데" 같은 상황을 만나면 어떻게 하시나요? 기본 Trainer는 강력하지만 모든 케이스를 커버하지는 못합니다.

실제 프로젝트에서는 custom loss function, 특수한 평가 지표, 학습 중 데이터 증강 등이 필요한 경우가 많습니다. 특히 도메인 특화 태스크(의료, 법률 등)에서는 표준 perplexity만으로는 성능을 제대로 측정할 수 없습니다.

바로 이럴 때 필요한 것이 Trainer 클래스를 상속한 Custom Trainer입니다. 핵심 메서드 몇 개만 오버라이드하면 완전한 제어가 가능하면서도 기본 기능은 모두 유지됩니다.

개요

간단히 말해서, Custom Trainer는 기본 Trainer를 상속받아 특정 동작을 변경하거나 추가하는 클래스입니다. 학습 루프의 핵심은 그대로 두고 원하는 부분만 커스터마이즈합니다.

왜 이 개념이 필요한지는 유연성 때문입니다. compute_loss()를 오버라이드하면 focal loss, contrastive loss 같은 특수 손실 함수를 사용할 수 있습니다.

evaluate()를 수정하면 BLEU, ROUGE 같은 생성 품질 지표를 추가할 수 있습니다. 예를 들어, 번역 모델을 학습할 때는 perplexity뿐 아니라 BLEU score도 함께 추적해야 합니다.

전통적인 PyTorch 학습 루프와 비교하면, 기존에는 전체 루프를 처음부터 작성했다면, 이제는 필요한 부분만 메서드 하나로 구현합니다. distributed training, gradient accumulation, mixed precision 같은 복잡한 기능은 자동으로 유지됩니다.

핵심 특징은 첫째, 선택적 커스터마이징으로 복잡도 최소화, 둘째, 기존 Trainer의 모든 기능 재사용, 셋째, 다양한 hook 포인트 제공입니다. 이러한 특징들이 복잡한 요구사항도 깔끔하게 구현할 수 있게 만듭니다.

코드 예제

from transformers import Trainer
import torch.nn.functional as F
from sklearn.metrics import accuracy_score
import numpy as np

class CustomTrainer(Trainer):
    """커스텀 손실 함수와 평가 지표를 사용하는 Trainer"""

    def compute_loss(self, model, inputs, return_outputs=False):
        """Label smoothing을 적용한 손실 계산"""
        labels = inputs.pop('labels')

        # Forward pass
        outputs = model(**inputs)
        logits = outputs.logits

        # Label smoothing (과적합 방지)
        loss = F.cross_entropy(
            logits.view(-1, logits.size(-1)),
            labels.view(-1),
            ignore_index=-100,
            label_smoothing=0.1  # 10% smoothing
        )

        return (loss, outputs) if return_outputs else loss

    def evaluate(self, eval_dataset=None, **kwargs):
        """기본 평가에 추가 메트릭 포함"""
        # 기본 평가 수행
        output = super().evaluate(eval_dataset, **kwargs)

        # 추가 정보 로깅
        print(f"\n📊 Evaluation Results:")
        print(f"  Loss: {output['eval_loss']:.4f}")
        print(f"  Perplexity: {np.exp(output['eval_loss']):.2f}")

        return output

    def log(self, logs):
        """로깅 시 추가 정보 출력"""
        # Learning rate 추가
        if 'learning_rate' in self.lr_scheduler.__dict__:
            logs['lr'] = self.lr_scheduler.get_last_lr()[0]

        # 기본 로깅 수행
        super().log(logs)

        # 콘솔에 깔끔하게 출력
        if 'loss' in logs:
            print(f"Step {self.state.global_step}: "
                  f"loss={logs['loss']:.4f}, "
                  f"lr={logs.get('lr', 0):.2e}")

# 실사용
trainer = CustomTrainer(
    model=model,
    args=training_args,
    train_dataset=tokenized_dataset['train'],
    eval_dataset=tokenized_dataset.get('validation'),
    tokenizer=tokenizer,
)

# 학습 시작
trainer.train()

설명

이것이 하는 일: 기본 Trainer의 핵심 메서드를 오버라이드하여 학습과 평가 과정을 커스터마이징합니다. 첫 번째로, compute_loss()를 재정의하여 label smoothing을 적용합니다.

label smoothing은 정답 레이블에 100% 확률을 주는 대신 90%를 주고 나머지 10%를 다른 클래스에 분산시킵니다. 왜 이렇게 하냐면, 모델이 너무 확신하는 것을 방지하여 일반화 성능을 높이기 때문입니다.

특히 작은 데이터셋에서 overfitting을 크게 줄여줍니다. 그 다음으로, evaluate() 메서드를 확장합니다.

super().evaluate()로 기본 평가를 먼저 수행하고, 그 결과에 추가 정보를 더합니다. 내부에서는 eval_loss뿐 아니라 perplexity(exp(loss))도 계산하여 출력합니다.

Perplexity는 언어 모델의 표준 지표로, "모델이 다음 단어를 예측할 때 평균 몇 개의 선택지 중에서 고민하는가"를 나타냅니다. 세 번째 단계에서 log() 메서드를 커스터마이즈합니다.

learning rate을 로그에 추가하여 scheduler가 제대로 작동하는지 확인할 수 있게 합니다. super().log()로 기본 로깅을 수행한 후, 콘솔에 깔끔하게 포매팅된 출력을 추가합니다.

이렇게 하면 TensorBoard 로그와 콘솔 출력을 동시에 얻을 수 있습니다. 마지막으로, 이 Custom Trainer를 기본 Trainer와 똑같이 사용합니다.

train(), evaluate(), predict() 같은 모든 메서드가 정상 작동하며, 내부적으로는 우리가 오버라이드한 메서드들이 호출됩니다. 여러분이 이 코드를 사용하면 특수한 학습 요구사항을 쉽게 구현할 수 있습니다.

Focal loss, triplet loss 같은 고급 손실 함수를 추가하거나, BLEU, ROUGE 같은 생성 지표를 평가에 포함시키거나, 학습 중 샘플 생성으로 품질을 실시간 확인할 수 있습니다.

실전 팁

💡 on_epoch_end() 콜백을 구현하여 epoch마다 샘플 생성을 수행하세요. 모델이 실제로 얼마나 개선되는지 정성적으로 확인할 수 있습니다.

💡 compute_metrics 인자를 전달하여 평가 지표를 추가하세요. evaluate_bleu(), evaluate_rouge() 같은 함수를 작성하고 Trainer에 전달하면 자동으로 계산됩니다.

💡 gradient를 직접 조작해야 한다면 training_step()을 오버라이드하세요. gradient clipping, gradient noise 같은 고급 기법을 구현할 수 있습니다.

💡 early stopping을 추가하려면 EarlyStoppingCallback을 사용하세요. callbacks=[EarlyStoppingCallback(patience=3)]으로 설정하면 3번 연속 개선 없을 때 자동 종료됩니다.

💡 DDP(분산 학습) 환경에서는 self.is_world_process_zero()로 마스터 프로세스만 로깅하게 하세요. 안 그러면 같은 메시지가 GPU 수만큼 중복 출력됩니다.


6. 학습_모니터링과_로깅

시작하며

여러분이 몇 시간씩 모델을 학습시키면서 "제대로 학습되고 있는 건가?", "loss가 왜 튀지?", "언제 멈춰야 하지?" 같은 의문이 들어본 적 있나요? 터미널에 숫자만 주르륵 흘러가면 상황 파악이 어렵습니다.

이런 문제는 디버깅을 매우 어렵게 만듭니다. 학습이 끝나고 나서야 문제를 발견하면 이미 늦습니다.

learning rate이 너무 높았는지, gradient가 explode했는지, data가 잘못됐는지 알 방법이 없습니다. 실제로 제대로 된 모니터링 없이 학습하는 것은 눈 감고 운전하는 것과 같습니다.

바로 이럴 때 필요한 것이 TensorBoard와 WandB 같은 시각화 도구입니다. 실시간으로 loss 곡선을 보고, learning rate 변화를 추적하며, 여러 실험을 비교할 수 있습니다.

개요

간단히 말해서, 학습 모니터링은 학습 중 발생하는 모든 메트릭을 기록하고 시각화하는 과정입니다. loss, learning rate, gradient norm, GPU 사용률 등을 실시간으로 추적합니다.

왜 이 개념이 필요한지는 빠른 피드백 때문입니다. TensorBoard를 열면 loss 그래프가 실시간으로 업데이트되어 학습 진행 상황을 즉시 확인할 수 있습니다.

WandB는 여기에 더해 하이퍼파라미터 비교, 시스템 메트릭 추적, 팀 협업 기능을 제공합니다. 예를 들어, learning rate을 3개 다르게 실험하면서 loss 곡선을 동시에 비교할 수 있습니다.

전통적인 방법과 비교하면, 기존에는 print()로 터미널에 출력하고 수동으로 엑셀에 기록했다면, 이제는 자동으로 로그되고 아름다운 그래프로 시각화됩니다. 과거 실험 기록도 영구 보존됩니다.

핵심 특징은 첫째, 실시간 그래프로 직관적 파악, 둘째, 자동 시스템 메트릭(GPU, CPU, 메모리) 추적, 셋째, 실험 비교와 협업 기능입니다. 이러한 특징들이 반복적인 실험과 튜닝을 효율적으로 만듭니다.

코드 예제

import wandb
from transformers import TrainerCallback
import torch

class DetailedLoggingCallback(TrainerCallback):
    """상세한 학습 정보를 로깅하는 커스텀 콜백"""

    def on_train_begin(self, args, state, control, **kwargs):
        """학습 시작 시 WandB 초기화"""
        wandb.init(
            project='llama2-ko-finetuning',
            name=f'lora_r{args.lora_r}_lr{args.learning_rate}',
            config={
                'model': args.model_name_or_path,
                'lora_r': 8,
                'learning_rate': args.learning_rate,
                'batch_size': args.per_device_train_batch_size,
                'epochs': args.num_train_epochs,
            }
        )

    def on_log(self, args, state, control, logs=None, **kwargs):
        """로그 발생 시 추가 정보 기록"""
        if logs:
            # Gradient norm 계산
            model = kwargs['model']
            total_norm = 0
            for p in model.parameters():
                if p.grad is not None:
                    param_norm = p.grad.data.norm(2)
                    total_norm += param_norm.item() ** 2
            total_norm = total_norm ** 0.5

            # WandB에 로깅
            wandb.log({
                'train/loss': logs.get('loss', 0),
                'train/learning_rate': logs.get('learning_rate', 0),
                'train/gradient_norm': total_norm,
                'train/epoch': logs.get('epoch', 0),
                'system/gpu_memory_allocated': torch.cuda.memory_allocated() / 1e9,
                'system/gpu_memory_reserved': torch.cuda.memory_reserved() / 1e9,
            }, step=state.global_step)

    def on_evaluate(self, args, state, control, metrics=None, **kwargs):
        """평가 시 결과 기록"""
        if metrics:
            wandb.log({
                'eval/loss': metrics.get('eval_loss', 0),
                'eval/perplexity': np.exp(metrics.get('eval_loss', 0)),
            }, step=state.global_step)

# 실사용: Trainer에 콜백 추가
from transformers import Trainer

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=tokenized_dataset['train'],
    eval_dataset=tokenized_dataset.get('validation'),
    callbacks=[DetailedLoggingCallback()],  # 커스텀 콜백 추가
)

# TensorBoard 시작 (별도 터미널)
# tensorboard --logdir ./results/runs

설명

이것이 하는 일: 학습 중 발생하는 다양한 메트릭을 자동으로 수집하고, WandB와 TensorBoard에 실시간으로 전송하여 시각화합니다. 첫 번째로, on_train_begin()에서 WandB를 초기화합니다.

project명과 run명을 설정하여 나중에 찾기 쉽게 하고, config에 모든 하이퍼파라미터를 기록합니다. 왜 이렇게 하냐면, 실험이 수십 개가 되면 어떤 설정으로 돌렸는지 기억하기 어렵기 때문입니다.

WandB는 이를 자동으로 추적하고 검색 가능하게 만듭니다. 그 다음으로, on_log()에서 매 로깅 스텝마다 상세 정보를 기록합니다.

내부에서는 모든 파라미터의 gradient norm을 계산하여 gradient exploding/vanishing을 모니터링합니다. GPU 메모리 사용량도 추적하여 OOM 위험을 조기 발견할 수 있습니다.

이 정보들은 wandb.log()를 통해 서버로 전송되고 실시간 그래프로 표시됩니다. 세 번째 단계에서 on_evaluate()를 구현하여 평가 결과를 기록합니다.

eval_loss뿐 아니라 perplexity도 계산하여 로깅합니다. step=state.global_step으로 x축을 통일하면 train loss와 eval loss를 같은 그래프에 겹쳐서 볼 수 있습니다.

마지막으로, 이 콜백을 Trainer의 callbacks 리스트에 추가합니다. Trainer는 학습 중 적절한 시점에 각 콜백 메서드를 자동으로 호출합니다.

여러 콜백을 동시에 사용할 수도 있습니다. 여러분이 이 코드를 사용하면 학습을 완전히 투명하게 추적할 수 있습니다.

브라우저에서 실시간 그래프를 보며 문제를 즉시 발견하고, 과거 실험과 현재 실험을 쉽게 비교하며, 팀원들과 결과를 공유할 수 있습니다.

실전 팁

💡 wandb.watch(model)로 모델 가중치 분포를 추적하세요. 특정 레이어가 제대로 학습되는지, dead neuron이 있는지 확인할 수 있습니다.

💡 Sweep 기능으로 하이퍼파라미터 탐색을 자동화하세요. learning rate, batch size 등을 grid search하고 최적 조합을 자동으로 찾습니다.

💡 샘플 생성 결과도 로깅하세요. wandb.Table()로 프롬프트와 생성 텍스트를 표로 기록하면 정성적 품질을 추적할 수 있습니다.

💡 알림 기능을 설정하세요. WandB는 학습 완료나 에러 발생 시 이메일/슬랙으로 알림을 보낼 수 있습니다. 긴 학습을 시작하고 자리를 비울 때 유용합니다.

💡 TensorBoard와 WandB를 동시에 사용하세요. report_to=['tensorboard', 'wandb']로 설정하면 두 도구의 장점을 모두 활용할 수 있습니다.


7. 모델_평가와_검증

시작하며

여러분이 학습을 완료하고 "이 모델이 얼마나 좋은 건가?", "실전에 쓸 만한가?" 같은 질문을 던질 때입니다. loss가 낮다고 좋은 모델은 아닙니다.

실제 사용 환경에서의 성능이 중요합니다. 이런 평가 없이 모델을 배포하면 큰 문제가 발생합니다.

학습 데이터에만 과적합되어 새로운 입력에는 엉뚱한 답을 내거나, 특정 유형의 질문에만 잘 대답하고 나머지는 못하는 경우가 많습니다. 실제로 production에서 문제가 발견되면 이미 비즈니스 영향이 발생한 후입니다.

바로 이럴 때 필요한 것이 체계적인 평가 파이프라인입니다. 정량적 지표(BLEU, ROUGE, perplexity)와 정성적 평가(실제 생성 결과 검토)를 결합하여 모델의 진짜 능력을 측정합니다.

개요

간단히 말해서, 모델 평가는 학습된 모델이 실제 태스크를 얼마나 잘 수행하는지 측정하는 과정입니다. 단순히 loss뿐 아니라 태스크 특화 지표를 사용합니다.

왜 이 개념이 필요한지는 실전 성능 예측 때문입니다. 번역 모델은 BLEU score로, 요약 모델은 ROUGE score로, 분류 모델은 F1 score로 평가해야 실제 품질을 알 수 있습니다.

perplexity가 낮아도 BLEU가 낮으면 번역 품질이 나쁜 것입니다. 예를 들어, 챗봇 모델은 일관성, 유용성, 안전성 같은 다차원 평가가 필요합니다.

전통적인 방법과 비교하면, 기존에는 수작업으로 몇 개 샘플을 뽑아서 검토했다면, 이제는 수백 개의 테스트 케이스를 자동으로 평가하고 통계를 냅니다. A/B 테스트도 자동화할 수 있습니다.

핵심 특징은 첫째, 태스크별 표준 지표 자동 계산, 둘째, 대규모 테스트 세트로 신뢰성 있는 평가, 셋째, 정량과 정성 평가의 결합입니다. 이러한 특징들이 모델의 강점과 약점을 명확히 파악하게 만듭니다.

코드 예제

from datasets import load_metric
from tqdm import tqdm
import numpy as np

def evaluate_generation_model(model, tokenizer, test_dataset, num_samples=100):
    """생성 모델을 다양한 지표로 평가"""
    # 메트릭 로드
    bleu_metric = load_metric('bleu')
    rouge_metric = load_metric('rouge')

    model.eval()
    predictions = []
    references = []

    # 샘플별 생성 및 평가
    print(f"🔍 Evaluating {num_samples} samples...")
    for i, sample in enumerate(tqdm(test_dataset[:num_samples])):
        # 프롬프트 생성
        prompt = f"### Instruction:\n{sample['instruction']}\n\n### Response:\n"
        inputs = tokenizer(prompt, return_tensors='pt').to(model.device)

        # 생성 (beam search)
        with torch.no_grad():
            outputs = model.generate(
                **inputs,
                max_new_tokens=256,
                num_beams=4,  # Beam search
                temperature=0.7,
                do_sample=True,
                top_p=0.9,
                repetition_penalty=1.2,
            )

        # 디코딩
        generated_text = tokenizer.decode(outputs[0], skip_special_tokens=True)
        generated_text = generated_text.split('### Response:\n')[-1].strip()

        predictions.append(generated_text)
        references.append(sample['output'])

        # 첫 5개는 출력하여 정성 평가
        if i < 5:
            print(f"\n--- Sample {i+1} ---")
            print(f"Input: {sample['instruction'][:100]}...")
            print(f"Expected: {sample['output'][:100]}...")
            print(f"Generated: {generated_text[:100]}...")

    # BLEU 계산 (n-gram 일치도)
    bleu_results = bleu_metric.compute(
        predictions=[[p] for p in predictions],
        references=[[r] for r in references]
    )

    # ROUGE 계산 (요약 품질)
    rouge_results = rouge_metric.compute(
        predictions=predictions,
        references=references
    )

    # 결과 출력
    print(f"\n📊 Evaluation Results:")
    print(f"  BLEU-4: {bleu_results['bleu']:.4f}")
    print(f"  ROUGE-1: {rouge_results['rouge1'].mid.fmeasure:.4f}")
    print(f"  ROUGE-2: {rouge_results['rouge2'].mid.fmeasure:.4f}")
    print(f"  ROUGE-L: {rouge_results['rougeL'].mid.fmeasure:.4f}")

    return {
        'bleu': bleu_results['bleu'],
        'rouge1': rouge_results['rouge1'].mid.fmeasure,
        'rouge2': rouge_results['rouge2'].mid.fmeasure,
        'rougeL': rouge_results['rougeL'].mid.fmeasure,
    }

# 실사용
metrics = evaluate_generation_model(model, tokenizer, test_dataset, num_samples=100)

설명

이것이 하는 일: 학습된 모델로 테스트 데이터에 대해 텍스트를 생성하고, 표준 지표로 품질을 측정합니다. 첫 번째로, HuggingFace의 load_metric()으로 평가 지표를 로드합니다.

BLEU는 기계번역 품질을, ROUGE는 요약 품질을 측정하는 표준 지표입니다. 왜 이렇게 하냐면, 이 지표들은 학계와 산업계에서 널리 인정받아 다른 연구와 비교 가능하기 때문입니다.

그 다음으로, 각 테스트 샘플에 대해 모델이 텍스트를 생성합니다. 내부에서는 num_beams=4로 beam search를 사용하여 여러 후보 중 가장 좋은 것을 선택합니다.

temperature=0.7과 top_p=0.9는 생성 다양성을 조절하며, repetition_penalty=1.2는 반복을 방지합니다. 이 파라미터들을 조정하면 생성 스타일이 크게 달라집니다.

세 번째 단계에서 생성된 텍스트를 디코딩하고 정제합니다. "### Response:" 이후 부분만 추출하여 프롬프트 템플릿을 제거합니다.

첫 5개 샘플은 콘솔에 출력하여 사람이 직접 품질을 판단할 수 있게 합니다. 정량 지표가 높아도 실제로 이상한 답을 생성할 수 있으므로 정성 평가가 필수입니다.

마지막으로, 모든 예측과 정답을 수집하여 BLEU와 ROUGE를 계산합니다. BLEU-4는 4-gram 일치도를, ROUGE-1/2/L은 unigram/bigram/longest common subsequence 일치도를 측정합니다.

이 값들이 높을수록 모델이 정답과 유사한 텍스트를 생성한다는 의미입니다. 여러분이 이 코드를 사용하면 모델의 실전 성능을 객관적으로 측정할 수 있습니다.

여러 모델을 비교하거나, 하이퍼파라미터 변경의 영향을 정량화하거나, 배포 전 품질 기준을 설정할 수 있습니다.

실전 팁

💡 테스트 세트를 학습/검증 세트와 완전히 분리하세요. 데이터 유출이 있으면 평가 결과가 의미 없어집니다. 시간 순으로 분리하거나 다른 소스에서 가져오세요.

💡 다양한 generation 파라미터를 실험하세요. temperature=[0.5, 0.7, 1.0], top_p=[0.9, 0.95], num_beams=[1, 4, 8]을 조합하여 최적 설정을 찾으세요.

💡 Human evaluation을 추가하세요. 무작위로 50개를 뽑아 사람이 1-5점으로 평가하면 자동 지표로 놓치는 문제를 발견할 수 있습니다. 특히 창의성, 일관성 같은 측면은 자동 측정이 어렵습니다.

💡 오류 분석을 수행하세요. 낮은 점수를 받은 샘플들을 모아서 패턴을 찾으면 모델의 약점을 파악할 수 있습니다. 특정 유형의 질문에 약하다면 그 데이터를 추가로 수집하세요.

💡 베이스라인과 비교하세요. 파인튜닝 전 모델이나 GPT-3.5 같은 강력한 모델과 비교하여 개선 정도를 측정하세요. 절대 점수보다 상대적 개선이 중요합니다.


8. 추론_파이프라인_구축

시작하며

여러분이 훌륭한 모델을 학습했다면, 이제 실제로 사용할 차례입니다. "어떻게 API로 만들지?", "여러 요청을 동시에 처리하려면?", "응답 속도는 어떻게 높이지?" 같은 질문이 생깁니다.

학습 코드를 그대로 추론에 사용하면 비효율적입니다. 배치 처리, 캐싱, 모델 최적화 같은 기법 없이는 느리고 비용이 많이 듭니다.

실제로 production에서는 초당 수백 건의 요청을 처리해야 하는데, 간단한 generate() 호출로는 불가능합니다. 바로 이럴 때 필요한 것이 최적화된 추론 파이프라인입니다.

모델을 효율적으로 로드하고, 배치 처리로 throughput을 높이며, FastAPI로 RESTful API를 제공합니다.

개요

간단히 말해서, 추론 파이프라인은 학습된 모델을 실제 서비스에서 사용할 수 있도록 최적화하고 API로 감싸는 시스템입니다. 단순 예측뿐 아니라 전처리, 후처리, 에러 핸들링을 포함합니다.

왜 이 개념이 필요한지는 production 요구사항 때문입니다. 연구 환경에서는 한 번에 하나씩 느리게 처리해도 되지만, 실제 서비스는 동시에 수십 개 요청을 처리해야 합니다.

HuggingFace의 pipeline API와 FastAPI를 결합하면 몇 줄로 확장 가능한 API 서버를 만들 수 있습니다. 예를 들어, 챗봇 서비스는 수백 명의 유저가 동시에 질문할 수 있어야 합니다.

전통적인 방법과 비교하면, 기존에는 Flask로 간단한 endpoint를 만들고 매번 모델을 로드했다면, 이제는 모델을 메모리에 한 번만 로드하고 재사용하며, 비동기 처리로 동시성을 높입니다. 응답 시간이 10배 이상 빨라집니다.

핵심 특징은 첫째, 모델 로딩 최적화로 빠른 시작, 둘째, 배치 처리로 높은 throughput, 셋째, RESTful API로 쉬운 통합입니다. 이러한 특징들이 연구 모델을 실제 제품으로 전환하게 만듭니다.

코드 예제

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
from typing import List

# FastAPI 앱 생성
app = FastAPI(title='Fine-tuned LLaMA API')

# 전역 변수로 모델 로드 (한 번만)
MODEL_PATH = './llama2-ko-finetuned'
tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH)
model = AutoModelForCausalLM.from_pretrained(
    MODEL_PATH,
    load_in_8bit=True,
    device_map='auto',
)
model.eval()

# 요청 스키마
class GenerationRequest(BaseModel):
    instruction: str
    input: str = ''
    max_tokens: int = 256
    temperature: float = 0.7
    top_p: float = 0.9

class GenerationResponse(BaseModel):
    generated_text: str
    tokens_generated: int

@app.post('/generate', response_model=GenerationResponse)
async def generate(request: GenerationRequest):
    """텍스트 생성 API"""
    try:
        # 프롬프트 구성
        if request.input:
            prompt = f"### Instruction:\n{request.instruction}\n\n### Input:\n{request.input}\n\n### Response:\n"
        else:
            prompt = f"### Instruction:\n{request.instruction}\n\n### Response:\n"

        # 토큰화
        inputs = tokenizer(prompt, return_tensors='pt').to(model.device)

        # 생성
        with torch.no_grad():
            outputs = model.generate(
                **inputs,
                max_new_tokens=request.max_tokens,
                temperature=request.temperature,
                top_p=request.top_p,
                do_sample=True,
                pad_token_id=tokenizer.eos_token_id,
            )

        # 디코딩
        generated_text = tokenizer.decode(outputs[0], skip_special_tokens=True)
        generated_text = generated_text.split('### Response:\n')[-1].strip()

        return GenerationResponse(
            generated_text=generated_text,
            tokens_generated=len(outputs[0]) - len(inputs['input_ids'][0])
        )

    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.get('/health')
async def health_check():
    """헬스 체크 엔드포인트"""
    return {'status': 'healthy', 'model': MODEL_PATH}

# 실행: uvicorn inference_api:app --host 0.0.0.0 --port 8000

설명

이것이 하는 일: 학습된 모델을 HTTP API 서버로 감싸서 외부 애플리케이션이 쉽게 사용할 수 있게 합니다. 첫 번째로, FastAPI 앱을 생성하고 모델을 전역 변수로 로드합니다.

서버가 시작될 때 단 한 번만 로드되며, 이후 모든 요청이 같은 모델 인스턴스를 재사용합니다. 왜 이렇게 하냐면, 모델 로딩은 수십 초가 걸리므로 매 요청마다 로드하면 불가능하기 때문입니다.

load_in_8bit=True로 메모리 사용을 줄입니다. 그 다음으로, Pydantic 모델로 요청/응답 스키마를 정의합니다.

내부에서 FastAPI가 자동으로 타입 검증, JSON 직렬화, OpenAPI 문서 생성을 처리합니다. 잘못된 타입이 전달되면 자동으로 400 에러를 반환하므로 수동 검증이 필요 없습니다.

세 번째 단계에서 /generate 엔드포인트를 구현합니다. async def로 정의하여 비동기 처리를 활성화하고, 내부적으로는 학습 때와 같은 프롬프트 템플릿을 사용합니다.

torch.no_grad()로 gradient 계산을 비활성화하여 메모리와 속도를 개선합니다. 생성 파라미터는 요청에서 받아 유연성을 제공합니다.

마지막으로, 에러 핸들링과 헬스 체크를 추가합니다. try-except로 예외를 잡아 500 에러로 변환하고, /health 엔드포인트로 서버 상태를 확인할 수 있게 합니다.

로드 밸런서나 쿠버네티스가 이를 사용하여 서버가 살아있는지 확인합니다. 여러분이 이 코드를 사용하면 모델을 즉시 API로 제공할 수 있습니다.

프론트엔드 앱이나 다른 마이크로서비스에서 HTTP로 호출하면 되고, FastAPI의 자동 문서(/docs)로 테스트도 쉬우며, 수평 확장으로 트래픽을 처리할 수 있습니다.

실전 팁

💡 배치 처리를 구현하여 throughput을 높이세요. 여러 요청을 모아서 한 번에 처리하면 GPU 활용률이 올라갑니다. 단, latency와 throughput의 트레이드오프를 고려하세요.

💡 vLLM이나 TGI(Text Generation Inference)를 사용하면 추론 속도가 5-10배 빨라집니다. continuous batching, PagedAttention 같은 최적화가 내장되어 있습니다.

💡 Redis로 결과를 캐싱하세요. 같은 질문이 자주 들어오면 모델 실행 없이 캐시에서 바로 반환하여 비용을 절감합니다.

💡 Rate limiting을 추가하여 남용을 방지하세요. slowapi 같은 라이브러리로 IP당 분당 요청 수를 제한할 수 있습니다.

💡 Prometheus와 Grafana로 API 메트릭을 모니터링하세요. 요청 수, 응답 시간, 에러율을 추적하여 문제를 조기 발견하세요.


9. 모델_최적화와_양자화

시작하며

여러분이 모델을 배포하려 할 때 "GPU 비용이 너무 비싼데", "모바일에서는 어떻게 돌리지?", "응답이 너무 느린데" 같은 문제를 만납니다. 7B 모델은 28GB 메모리를 차지하고, 생성 속도도 느립니다.

이런 문제는 비즈니스 확장을 막습니다. GPU 서버 비용이 월 수백만 원씩 나가면 서비스 운영이 어렵습니다.

사용자가 답을 기다리다 이탈하면 UX가 나빠집니다. 실제로 많은 스타트업이 모델 서빙 비용 때문에 사업성이 나오지 않습니다.

바로 이럴 때 필요한 것이 모델 양자화와 최적화입니다. 가중치를 16-bit에서 8-bit, 심지어 4-bit로 줄여 메모리를 1/4로 줄이고, pruning과 distillation으로 속도를 높입니다.

개요

간단히 말해서, 모델 최적화는 성능을 크게 희생하지 않으면서 모델을 작고 빠르게 만드는 기술입니다. 양자화, pruning, knowledge distillation 같은 여러 기법이 있습니다.

왜 이 개념이 필요한지는 경제성과 사용성 때문입니다. GPTQ나 GGML로 4-bit 양자화하면 메모리가 1/4로 줄어듭니다.

7B 모델이 28GB에서 7GB로 줄어들어 RTX 3090 하나로 돌아갑니다. bitsandbytes 라이브러리는 이를 자동화하여 몇 줄의 코드로 적용 가능합니다.

예를 들어, LLaMA 7B를 4-bit로 양자화하면 스마트폰에서도 실행할 수 있습니다. 전통적인 방법과 비교하면, 기존에는 큰 모델은 큰 GPU가 필수였다면, 이제는 양자화로 소형 GPU나 심지어 CPU로도 실행합니다.

성능 손실은 5% 이내로 미미합니다. 핵심 특징은 첫째, 메모리 사용량을 1/2~1/4로 감소, 둘째, 추론 속도 1.5-2배 향상, 셋째, 배포 비용 대폭 절감입니다.

이러한 특징들이 대규모 모델의 대중화를 가능하게 만듭니다.

코드 예제

from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
import torch

def load_quantized_model(model_path, quantization='8bit'):
    """양자화된 모델 로드 (메모리 절약)"""

    if quantization == '8bit':
        # 8-bit 양자화 (메모리 1/2, 성능 손실 거의 없음)
        quantization_config = BitsAndBytesConfig(
            load_in_8bit=True,
            llm_int8_threshold=6.0,  # Outlier detection threshold
        )
    elif quantization == '4bit':
        # 4-bit 양자화 (메모리 1/4, 약간의 성능 손실)
        quantization_config = BitsAndBytesConfig(
            load_in_4bit=True,
            bnb_4bit_compute_dtype=torch.float16,  # 계산은 fp16
            bnb_4bit_use_double_quant=True,  # Double quantization
            bnb_4bit_quant_type='nf4',  # NormalFloat4
        )
    else:
        quantization_config = None

    # 모델 로드
    model = AutoModelForCausalLM.from_pretrained(
        model_path,
        quantization_config=quantization_config,
        device_map='auto',
        trust_remote_code=True,
    )

    tokenizer = AutoTokenizer.from_pretrained(model_path)

    # 메모리 사용량 출력
    if torch.cuda.is_available():
        memory_mb = torch.cuda.memory_allocated() / 1e6
        print(f"💾 GPU Memory: {memory_mb:.0f} MB")

    return model, tokenizer

def optimize_for_inference(model):
    """추론 최적화 적용"""
    # Gradient 비활성화
    model.eval()
    for param in model.parameters():
        param.requires_grad = False

    # Torch compile (PyTorch 2.0+, 속도 2배 향상)
    if hasattr(torch, 'compile'):
        model = torch.compile(model, mode='reduce-overhead')
        print("✅ Torch compile enabled")

    # KV cache 최적화
    model.config.use_cache = True

    return model

# 실사용
# 8-bit 양자화 (권장: 성능 거의 유지)
model_8bit, tokenizer = load_quantized_model('./llama2-ko-finetuned', '8bit')
model_8bit = optimize_for_inference(model_8bit)

# 4-bit 양자화 (메모리 극한 절약)
model_4bit, _ = load_quantized_model('./llama2-ko-finetuned', '4bit')

설명

이것이 하는 일: 모델의 가중치를 낮은 정밀도로 변환하여 메모리와 계산량을 줄이고, 추론 최적화를 적용합니다. 첫 번째로, BitsAndBytesConfig로 양자화 설정을 정의합니다.

8-bit는 가장 안정적이고 성능 손실이 거의 없으며, 4-bit는 메모리를 극한으로 줄이지만 약간의 품질 저하가 있습니다. 왜 이렇게 하냐면, 신경망의 가중치는 대부분 -1~1 범위에 분포하므로 32-bit 정밀도가 불필요하기 때문입니다.

bnb_4bit_use_double_quant=True는 양자화 상수까지 다시 양자화하여 메모리를 추가로 절약합니다. 그 다음으로, AutoModelForCausalLM.from_pretrained()에 양자화 설정을 전달합니다.

내부에서는 모델을 로드하면서 동시에 각 레이어의 가중치를 양자화합니다. device_map='auto'는 모델이 GPU 메모리에 들어가지 않으면 자동으로 CPU로 오프로드합니다.

양자화 덕분에 대부분 GPU에 올라갑니다. 세 번째 단계에서 optimize_for_inference()로 추가 최적화를 적용합니다.

requires_grad=False로 gradient 계산을 완전히 비활성화하여 메모리를 절약하고, torch.compile()로 모델을 JIT 컴파일하여 속도를 2배 높입니다. use_cache=True는 attention의 key-value를 캐싱하여 디코딩 속도를 크게 개선합니다.

마지막으로, GPU 메모리 사용량을 출력하여 양자화 효과를 확인합니다. 7B 모델이 full precision에서 28GB였다면, 8-bit는 14GB, 4-bit는 7GB로 줄어듭니다.

이것은 실제로 측정 가능한 극적인 개선입니다. 여러분이 이 코드를 사용하면 제한된 자원으로도 대규모 모델을 실행할 수 있습니다.

A100 대신 RTX 4090을 써서 비용을 1/10로 줄이거나, 여러 모델을 하나의 GPU에 올려 서버 대수를 줄이거나, 심지어 라즈베리파이 같은 엣지 디바이스에서도 실행할 수 있습니다.

실전 팁

💡 GPTQ나 AWQ로 미리 양자화된 모델을 사용하세요. TheBloke 같은 커뮤니티에서 제공하는 양자화 모델을 다운받으면 즉시 사용 가능합니다.

💡 GGML/llama.cpp로 CPU 추론을 최적화하세요. GPU 없이도 맥북이나 데스크탑 CPU로 빠르게 실행할 수 있습니다.

💡 Flash Attention을 사용하면 메모리와 속도를 동시에 개선합니다. pip install flash-attn 후 model.config.attn_implementation='flash_attention_2'로 활성화하세요.

💡 Dynamic quantization을 고려하세요. 가중치만 양자화하는 static과 달리, activation도 양자화하여 더 빠릅니다. torch.quantization.quantize_dynamic()로 적용합니다.

💡 양자화 전후 성능을 비교하세요. 같은 테스트 세트로 BLEU나 ROUGE를 측정하여 품질 저하가 허용 범위 내인지 확인하세요. 보통 8-bit는 거의 차이 없고, 4-bit는 5% 이내입니다.


#Python#FineTuning#HuggingFace#ModelTraining#AIOptimization#AI

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.