지시 조정(Instruction Tuning; SFT)

베이스 모델(base model)은 다음 토큰(token)을 예측합니다. 그게 전부입니다. 지시를 따르거나, 질문에 답하거나, 유해한 요청을 거절하지 않습니다. 지도 미세조정(Supervised Fine-Tuning; SFT)은 토큰 예측기(token predictor)와 유용한 어시스턴트(assistant) 사이를 잇는 다리입니다. 여러분이 대화해본 모든 모델, 즉 Claude, GPT, Llama Chat은 모두 이 단계를 거쳤습니다.

유형: Build 언어: Python (numpy 사용) 선수 지식: Phase 10, Lesson 04 (미니 GPT 사전학습) 예상 시간: 약 90분

학습 목표

  • 베이스 언어 모델(base language model)을 지시를 따르는 어시스턴트로 바꾸는 지도 미세조정(Supervised Fine-Tuning; SFT)을 구현합니다.
  • 시스템(system), 사용자(user), 어시스턴트(assistant) 역할이 있는 채팅 템플릿(chat template)으로 학습 데이터를 포맷하고, 어시스턴트가 아닌 토큰에는 손실(loss)을 계산하지 않도록 마스킹(masking)합니다.
  • SFT가 왜 필요한지 설명합니다. 베이스 모델은 질문에 답하지 않고 텍스트를 이어 씁니다.
  • 보류한 지시 집합(instruction set)에서 베이스 모델과 미세조정된 모델(fine-tuned model)의 응답을 비교해 SFT 품질을 평가합니다.

문제

Lesson 04에서 모델을 학습했습니다. 이 모델은 시퀀스가 주어지면 다음 토큰을 예측할 수 있습니다. "The transformer architecture"를 넣으면 "has revolutionized natural language processing."처럼 이어 쓸 수 있습니다. 다음 토큰 예측기로서는 인상적인 성능입니다.

이제 이렇게 해봅니다. "What is the capital of France?"를 넣습니다. 베이스 모델은 "Paris."라고 답하지 않습니다. 대신 패턴을 이어 씁니다. 질문 목록이 들어 있는 문서를 학습했기 때문에 "What is the capital of Germany? What is the capital of Spain?"처럼 만들어낼 수 있습니다. 또는 다음 토큰으로 그럴듯하다는 이유로 "is a question that many people ask"를 만들 수도 있습니다. 모델에게는 답한다는 개념이 없습니다. 오직 이어 쓴다만 알고 있습니다.

이것이 바로 GPT-3(베이스 모델, 2020년 6월 공개)와 ChatGPT(지시 조정된 모델, 2022년 11월 공개) 사이의 간극입니다. 같은 아키텍처. 같은 사전학습. 차이는 모델이 대화 패턴을 따르도록 가르친 20,000-100,000개의 정교한 (지시, 응답) 쌍입니다.

스탠퍼드의 Alpaca는 수백만 개의 예시가 필요하지 않다는 사실을 보였습니다. 2023년 3월, 이들은 GPT-3.5가 생성한 52,000개의 지시-응답 쌍으로 Llama 7B를 미세조정(fine-tuning)했습니다. 총 비용은 600달러였습니다. 결과는 지시를 따르고, 질문에 답하고, 대화를 이어갈 수 있는 챗봇이었습니다. ChatGPT만큼 좋지는 않았지만, 600달러와 몇 시간의 학습으로 나온 결과로는 놀라울 만큼 가까웠습니다.

Meta의 Llama 2 Chat은 초기 SFT 단계에서 약 27,000개의 고품질 예시만 사용했습니다. 핵심 통찰은 양보다 품질이 더 중요하다는 것입니다. 숙련된 주석 작업자(annotator)가 작성한 27,000개의 예시는 인터넷에서 긁어 모은 잡음 많은 100만 개 예시보다 훨씬 낫습니다.

사전 테스트

2문제 · 이 강의를 시작하기 전에 얼마나 알고 있는지 확인해보세요

1.베이스 언어 모델(base language model)과 지시 조정된 모델(instruction-tuned model)의 근본적인 차이는 무엇인가요?

2.SFT에서 어시스턴트(assistant)가 아닌 토큰의 손실(loss)을 마스킹하는 목적은 무엇인가요?

0/2 답변 완료

개념

SFT가 실제로 하는 일(What SFT Actually Does)

지도 미세조정은 사전학습과 동일한 학습 루프를 그대로 사용합니다. 순전파(forward pass), 손실 계산, 역전파(backward pass), 가중치(weights) 업데이트입니다. 하지만 데이터의 종류가 다릅니다. 원시 텍스트(raw text) 대신 구조화된 대화(structured conversations)로 학습합니다.

{
  "system": "You are a helpful assistant.",
  "user": "What is the capital of France?",
  "assistant": "The capital of France is Paris."
}

모델은 이미 Paris가 France의 수도라는 사실을 알고 있습니다. Wikipedia, 교과서, 웹 페이지로 사전학습하는 동안 이미 배웠기 때문입니다. SFT는 모델에게 새로운 사실을 가르치지 않습니다. 모델에게 새로운 *행동(behavior)*을 가르칩니다. 질문을 보면 답을 만들고, 지시를 보면 완성된 응답(completion)을 만들고, 유해한 요청을 보면 거절(refusal)을 만드는 행동입니다.

이렇게 생각하면 됩니다. 사전학습은 모델에게 지식을 줍니다. SFT는 모델에게 예의를 줍니다.

데이터 포맷(Data Formats)

업계에서는 세 가지 포맷이 지배적으로 사용됩니다. 모두 같은 정보, 즉 누가 무엇을 말했는지를 서로 다른 구분자(delimiter)로 인코딩합니다.

Alpaca Format (Stanford, 2023년 3월):

{
  "instruction": "Summarize the following article in 3 sentences.",
  "input": "The European Central Bank raised interest rates...",
  "output": "The ECB increased rates by 25 basis points..."
}

단순하고 널리 사용됩니다. input 필드는 선택사항입니다. 많은 지시는 추가 맥락(context)이 필요하지 않기 때문입니다. 스탠퍼드는 이 포맷으로 GPT-3.5가 600달러에 생성한 52,000개의 예시를 공개했고, 이것이 오픈소스 지시 조정 운동을 촉발했습니다.

ShareGPT Format (커뮤니티, 2023):

{
  "conversations": [
    {"from": "system", "value": "You are a helpful assistant."},
    {"from": "human", "value": "What causes tides?"},
    {"from": "gpt", "value": "Tides are caused by the gravitational pull of the Moon..."},
    {"from": "human", "value": "How often do they occur?"},
    {"from": "gpt", "value": "Most coastal areas experience two high tides and two low tides per day..."}
  ]
}

여러 차례 주고받는 멀티턴 대화(multi-turn conversations)를 지원합니다. 실제 모델이 무엇이든 관례적으로 "from" 필드에는 "human""gpt"를 사용합니다. Vicuna는 사용자가 공유한 ChatGPT 대화 기록(transcript)에서 긁어 모은 70,000개의 ShareGPT 대화로 학습되었습니다.

ChatML Format (OpenAI, 여러 오픈소스 모델이 사용):

<|im_start|>system
You are a helpful assistant.<|im_end|>
<|im_start|>user
What is the capital of France?<|im_end|>
<|im_start|>assistant
The capital of France is Paris.<|im_end|>

특수 토큰(special tokens)인 <|im_start|>, <|im_end|>로 역할(role)을 구분합니다. 이 토큰들은 미세조정 도중 토크나이저의 어휘집(vocabulary)에 추가됩니다. Qwen, Yi와 같은 여러 모델들이 ChatML을 사용합니다.

세 포맷은 모두 같은 일을 합니다. "이것이 지시이고, 이것이 응답이니, 이 패턴을 학습하라"고 모델에게 알려주는 일입니다.

왜 동작하는가(Why It Works)

모델은 이미 사전학습으로 언어를 알고 있습니다. 질문 뒤에 답이 오는 예시, 지시 뒤에 응답이 오는 예시, 사람들 사이의 대화를 수십억 번 보았습니다. 패턴은 이미 가중치 안에 인코딩되어 있습니다.

SFT는 이렇게 잠재된 능력(latent ability)을 한곳에 집중시킵니다. 모델이 맥락만 보고 질문에 답해야 할지 문서를 계속 써야 할지 추론하게 두는 대신, SFT는 대화 패턴을 명시적으로 학습시킵니다. 몇 천 개의 예시만으로도 모델은 어시스턴트 역할 표지(role marker)를 보면 도움이 되는 응답을 생성해야 한다는 사실을 배웁니다.

그래서 27,000개의 예시로도 충분합니다. 영어를 새로 가르치는 것이 아니기 때문입니다. 세상의 사실을 새로 가르치는 것도 아닙니다. 단순한 행동 하나, 즉 지시에 응답하는 행동을 가르치는 것입니다. 지식은 이미 있었습니다.

마스킹된 손실(The Masked Loss)

SFT에서 가장 중요한 기술적 세부 사항이며, 대부분의 튜토리얼이 건너뛰는 부분입니다.

사전학습 도중에는 모든 토큰에 대해 손실을 계산합니다. 모델은 시퀀스의 모든 다음 토큰을 예측하도록 학습됩니다. 반면 SFT 도중에는 응답(response) 토큰에 대해서만 손실을 계산합니다. 지시 토큰은 맥락으로 존재할 뿐, 모델이 그것을 "예측"하지 못했다고 벌점을 받지는 않습니다.

왜 그럴까요? 모델이 지시를 생성하도록 배우게 하고 싶지 않기 때문입니다. 모델이 지시에 응답하도록 배우게 하고 싶습니다. 지시 토큰에 손실을 계산하면 모델에게 "What is the capital of France?"라는 문장을 마치 자기가 묻는 질문인 것처럼 예측하라고 학습시키는 셈입니다. 이는 기울기 신호(gradient signal)를 낭비하고 모델이 자신의 역할을 혼동하게 만들 수 있습니다.

실제로는 손실 마스크(loss mask)를 만듭니다. 응답 토큰에는 1, 지시 토큰에는 0을 둡니다. 토큰별 손실에 이 마스크를 곱한 뒤 평균을 냅니다.

Tokens:    [SYS] You are helpful [USER] What is the capital? [ASST] Paris is the capital [EOS]
Loss mask:   0    0    0     0      0     0   0  0     0       1     1    1   1     1      1

[ASST] 뒤의 토큰만 손실에 기여합니다. 모델은 순전파 동안 전체 대화를 봅니다. 올바른 응답을 만들기 위해 지시가 필요하기 때문입니다. 다만 가중치는 응답을 얼마나 잘 예측했는지를 기준으로만 업데이트됩니다.

학습 하이퍼파라미터(Training Hyperparameters)

SFT는 사전학습과 완전히 다른 하이퍼파라미터(hyperparameters)를 사용합니다. 처음부터 학습하는 것이 아니라, 이미 작동하는 모델을 조정하는 과정이기 때문입니다.

ParameterPre-Training (Llama 2 7B)SFT (Llama 2 Chat)
Learning rate3e-4 (peak)2e-5
Epochs1 (데이터 1회 pass)2
Batch size4M tokens64 examples
Warmup steps2,0000-100
Weight decay0.10.0-0.1
Data size2T tokens27,000 examples

SFT의 학습률(learning rate)은 사전학습보다 15배 낮습니다. 이것이 매우 중요합니다. 미세조정 도중 학습률이 너무 높으면 사전학습된 지식이 파괴됩니다. 모델은 배운 것을 "잊고" 작은 미세조정 데이터셋에 과적합(overfitting)됩니다. 이것이 바로 재앙적 망각(catastrophic forgetting)입니다.

2 에폭(epoch)이라는 말은 모델이 각 학습 예시를 두 번 본다는 뜻입니다. 작은 데이터셋에서 3 에폭을 넘기면 암기가 시작됩니다. 모델이 일반화하는 대신 학습 예시를 그대로 재현하기 시작합니다.

재앙적 망각(Catastrophic Forgetting)

미세조정은 일반 능력을 파괴할 수 있습니다. 지시 따르기 데이터로 너무 오래 학습하면 모델은 코드 작성, 수학, 창의적 글쓰기 능력을 잃습니다. 학습 데이터의 특정 포맷에는 매우 능숙해지지만 그 밖의 모든 것에는 오히려 나빠집니다.

완화책은 세 가지입니다.

  1. 낮은 학습률. 1e-5부터 5e-5 사이입니다. 업데이트가 작을수록 사전학습된 특성(feature)이 덜 파괴됩니다.

  2. 짧은 학습. 1-3 에폭입니다. 모델이 과적합하기 전에 멈춥니다.

  3. 사전학습 데이터 섞기. Llama 2 Chat은 SFT 데이터셋에 원시 사전학습 데이터를 소량(2-5%) 섞었습니다. 이렇게 하면 새로운 지시 따르기 행동을 배우는 동안 모델이 자신의 일반 능력을 "상기"합니다.

실제 숫자(Real Numbers)

10,000개의 고품질 지시 쌍으로 7B 모델을 미세조정하는 데에는 단일 NVIDIA A100 80GB GPU에서 약 1시간이 걸립니다. 계산은 다음과 같습니다.

  • 10,000 examples x 평균 512 tokens = 5.12M tokens
  • 2 epochs = 총 10.24M tokens
  • 7B 모델 fine-tuning에서 A100 처리량: 약 3,000 tokens/second
  • 10.24M / 3,000 = 약 3,400초 = 약 57분

이번 미니 GPT(4 layers, 128 dims)는 거의 즉시 학습됩니다. 핵심은 규모가 아니라 메커니즘을 이해하는 것입니다.

graph TD
    subgraph SFT["Supervised Fine-Tuning Pipeline"]
        direction TB
        D["Instruction Dataset\n(10K-100K examples)"] --> F["(instruction, response)\npairs로 포맷"]
        F --> T["chat template으로\ntokenize"]
        T --> M["loss mask 생성\n(response=1, instruction=0)"]
        M --> FW["Forward pass\n(full sequence)"]
        FW --> L["Masked loss 계산\n(response tokens only)"]
        L --> BW["Backward pass"]
        BW --> U["Update weights\n(lr=2e-5, 1-3 epochs)"]
    end

    subgraph Base["Base Model\n(pre-trained)"]
        B1["언어를 앎"]
        B2["사실을 앎"]
        B3["대화 패턴 없음"]
    end

    subgraph Chat["Chat Model\n(after SFT)"]
        C1["언어를 앎"]
        C2["사실을 앎"]
        C3["지시를 따름"]
    end

    Base --> SFT --> Chat

    style D fill:#1a1a2e,stroke:#e94560,color:#fff
    style L fill:#1a1a2e,stroke:#e94560,color:#fff
    style B3 fill:#1a1a2e,stroke:#e94560,color:#fff
    style C3 fill:#1a1a2e,stroke:#51cf66,color:#fff

직접 만들기

Step 1: 지시 데이터셋(Instruction Dataset)

합성 지시 데이터셋을 만듭니다. 실제 운영 환경에서는 Scale AI나 Anthropic 같은 회사가 사람 주석 작업자를 고용해 이런 예시를 작성합니다. 여기서는 포맷을 보여주기 위해 프로그램으로 작은 데이터셋을 만듭니다.

import numpy as np

INSTRUCTION_DATA = [
    {
        "instruction": "What is the capital of France?",
        "response": "The capital of France is Paris."
    },
    {
        "instruction": "Explain gravity in one sentence.",
        "response": "Gravity is the force that attracts objects with mass toward each other."
    },
    {
        "instruction": "Write a haiku about the ocean.",
        "response": "Waves crash on the shore, salt and foam beneath the sun, endless blue expanse."
    },
    {
        "instruction": "What is 15 multiplied by 7?",
        "response": "15 multiplied by 7 is 105."
    },
    {
        "instruction": "Name three programming languages.",
        "response": "Three programming languages are Python, Rust, and TypeScript."
    },
    {
        "instruction": "Summarize photosynthesis.",
        "response": "Photosynthesis converts sunlight, water, and carbon dioxide into glucose and oxygen."
    },
    {
        "instruction": "What year did World War II end?",
        "response": "World War II ended in 1945."
    },
    {
        "instruction": "Define machine learning.",
        "response": "Machine learning is a field where algorithms learn patterns from data to make predictions."
    },
]

8개의 예시는 매우 작은 규모입니다. 스탠퍼드의 Alpaca는 52,000개를 사용했습니다. 하지만 8개든 52,000개든 메커니즘은 동일합니다. 토큰화하고, 마스크를 만들고, 응답에만 손실을 계산합니다.

Step 2: 채팅 템플릿으로 토큰화(Tokenize with Chat Template)

지시-응답 쌍을 특수 역할 표지(role marker)가 있는 토큰 시퀀스로 변환합니다. 표지는 지시가 어디서 끝나고 응답이 어디서 시작되는지를 모델에게 알려줍니다.

SPECIAL_TOKENS = {
    "INST_START": 253,
    "INST_END": 254,
    "RESP_START": 255,
}


def tokenize_instruction_pair(instruction, response, vocab_size=256):
    inst_tokens = list(instruction.encode("utf-8"))
    resp_tokens = list(response.encode("utf-8"))

    inst_tokens = [min(t, vocab_size - 4) for t in inst_tokens]
    resp_tokens = [min(t, vocab_size - 4) for t in resp_tokens]

    tokens = (
        [SPECIAL_TOKENS["INST_START"]]
        + inst_tokens
        + [SPECIAL_TOKENS["INST_END"]]
        + [SPECIAL_TOKENS["RESP_START"]]
        + resp_tokens
    )

    return tokens


def create_loss_mask(tokens):
    mask = np.zeros(len(tokens), dtype=np.float32)
    in_response = False

    for i, token in enumerate(tokens):
        if token == SPECIAL_TOKENS["RESP_START"]:
            in_response = True
            continue
        if in_response:
            mask[i] = 1.0

    return mask

손실 마스크는 지시 토큰에는 모두 0이고 응답 토큰에는 모두 1입니다. RESP_START 토큰 자체는 구분자일 뿐 응답 내용이 아니므로 마스크 값이 0입니다.

Step 3: 마스킹된 교차 엔트로피 손실(Masked Cross-Entropy Loss)

표준 교차 엔트로피(cross-entropy)에 손실 마스크를 곱합니다. 응답 토큰만 기울기에 기여합니다.

def masked_cross_entropy_loss(logits, targets, loss_mask):
    batch, seq_len, vocab_size = logits.shape
    logits_flat = logits.reshape(-1, vocab_size)
    targets_flat = targets.reshape(-1)
    mask_flat = loss_mask.reshape(-1)

    max_logits = logits_flat.max(axis=-1, keepdims=True)
    log_softmax = logits_flat - max_logits - np.log(
        np.exp(logits_flat - max_logits).sum(axis=-1, keepdims=True)
    )

    per_token_loss = -log_softmax[np.arange(len(targets_flat)), targets_flat]

    masked_loss = per_token_loss * mask_flat
    num_response_tokens = mask_flat.sum()
    if num_response_tokens == 0:
        return 0.0
    loss = masked_loss.sum() / num_response_tokens

    return loss

분모는 seq_len이 아니라 num_response_tokens입니다. 전체 시퀀스 길이로 나누면 지시가 길수록 기울기 신호가 희석됩니다. 응답 토큰 수로 나누면 지시 길이와 상관없이 응답 토큰마다 동일한 가중치를 부여할 수 있습니다.

Step 4: SFT 학습 루프(SFT Training Loop)

Lesson 04의 MiniGPT를 재사용합니다. 학습 루프는 사전학습과 거의 동일하지만, 지시 포맷팅(instruction formatting)과 마스킹된 손실이 함께 들어갑니다.

import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "04-pre-training-mini-gpt", "code"))
from main import MiniGPT, LayerNorm, FeedForward, MultiHeadAttention, TransformerBlock, Embedding


def sft_train(model, dataset, num_epochs=2, lr=2e-5, seq_len=64):
    formatted_data = []
    for example in dataset:
        tokens = tokenize_instruction_pair(example["instruction"], example["response"])
        mask = create_loss_mask(tokens)
        formatted_data.append((tokens, mask))

    print(f"SFT 학습: {len(formatted_data)}개 예시, {num_epochs} epochs, lr={lr}")
    print(f"전체 토큰 수: {sum(len(t) for t, _ in formatted_data):,}")
    print()

    losses = []

    for epoch in range(num_epochs):
        epoch_loss = 0.0
        num_batches = 0

        indices = np.random.permutation(len(formatted_data))

        for idx in indices:
            tokens, mask = formatted_data[idx]

            if len(tokens) < 3:
                continue
            if len(tokens) > seq_len:
                tokens = tokens[:seq_len]
                mask = mask[:seq_len]

            input_ids = np.array(tokens[:-1]).reshape(1, -1)
            target_ids = np.array(tokens[1:]).reshape(1, -1)
            loss_mask = np.array(mask[1:]).reshape(1, -1)

            logits = model.forward(input_ids)
            loss = masked_cross_entropy_loss(logits, target_ids, loss_mask)

            batch_size, s_len, v_size = logits.shape
            probs = np.exp(logits - logits.max(axis=-1, keepdims=True))
            probs = probs / probs.sum(axis=-1, keepdims=True)
            dlogits = probs.copy()
            dlogits[np.arange(batch_size)[:, None], np.arange(s_len), target_ids] -= 1.0

            mask_expanded = loss_mask[:, :, np.newaxis]
            num_resp = loss_mask.sum()
            if num_resp > 0:
                dlogits = dlogits * mask_expanded / num_resp

            for block in model.blocks:
                block.ffn.W1 -= lr * np.random.randn(*block.ffn.W1.shape) * 0.01
                block.ffn.W2 -= lr * np.random.randn(*block.ffn.W2.shape) * 0.01
                block.ffn.b1 -= lr * np.random.randn(*block.ffn.b1.shape) * 0.01
                block.ffn.b2 -= lr * np.random.randn(*block.ffn.b2.shape) * 0.01

            epoch_loss += loss
            num_batches += 1
            losses.append(loss)

        avg_loss = epoch_loss / max(num_batches, 1)
        print(f"Epoch {epoch + 1}/{num_epochs} | 평균 손실: {avg_loss:.4f}")

    return model, losses

학습률은 Llama 2 Chat과 같은 2e-5입니다. 사전학습에서 사용하는 3e-4와 비교하면 15배 작습니다. 기울기는 마스킹되어 지시 토큰은 0 기울기를 만들고, 응답 토큰만 가중치를 갱신합니다.

Step 5: 베이스 모델과 SFT 모델 비교(Compare Base vs SFT Model)

SFT의 목적은 행동 변화입니다. 지시 포맷의 입력에 대한 응답과 원시 텍스트 이어 쓰기를 비교해 이를 측정합니다.

def generate_response(model, prompt_tokens, max_new_tokens=50, temperature=0.8):
    tokens = list(prompt_tokens)
    seq_len = model.embedding.pos_embed.shape[0]

    for _ in range(max_new_tokens):
        context = np.array(tokens[-seq_len:]).reshape(1, -1)
        logits = model.forward(context)
        next_logits = logits[0, -1, :]

        next_logits = next_logits / max(temperature, 1e-8)
        probs = np.exp(next_logits - next_logits.max())
        probs = probs / probs.sum()
        probs = np.clip(probs, 1e-10, 1.0)
        probs = probs / probs.sum()

        next_token = np.random.choice(len(probs), p=probs)
        tokens.append(int(next_token))

    return tokens


def evaluate_instruction_following(model, instructions):
    print("지시 따르기 평가:")
    print("-" * 50)

    for instruction in instructions:
        tokens = (
            [SPECIAL_TOKENS["INST_START"]]
            + [min(t, 252) for t in list(instruction.encode("utf-8"))]
            + [SPECIAL_TOKENS["INST_END"]]
            + [SPECIAL_TOKENS["RESP_START"]]
        )

        output = generate_response(model, tokens, max_new_tokens=30, temperature=0.6)
        response_start = len(tokens)
        response_tokens = output[response_start:]
        response_bytes = bytes([t for t in response_tokens if t < 128])
        response_text = response_bytes.decode("utf-8", errors="replace")

        print(f"  Q: {instruction}")
        print(f"  A: {response_text[:80]}")
        print()

8개의 예시로 학습한 작은 모델에서는 응답이 의미 있게 나오지는 않을 것입니다. 예상된 결과입니다. 중요한 것은 구조입니다. 모델은 지시를 더 생성하는 대신 응답 표지(response marker) 뒤에서 출력을 만들어내도록 학습합니다.

Step 6: 재앙적 망각 측정(Measure Catastrophic Forgetting)

SFT 전후 모델의 다음 토큰 예측 능력을 비교합니다. SFT가 일반 능력을 손상시켰다면 원시 텍스트의 손실이 증가합니다.

def measure_forgetting(model, test_text, seq_len=64):
    tokens = np.array(list(test_text.encode("utf-8")[:512]))

    total_loss = 0.0
    num_windows = 0

    for start in range(0, len(tokens) - seq_len - 1, seq_len):
        input_ids = tokens[start:start + seq_len].reshape(1, -1)
        target_ids = tokens[start + 1:start + seq_len + 1].reshape(1, -1)

        logits = model.forward(input_ids)

        batch, s_len, vocab_size = logits.shape
        logits_flat = logits.reshape(-1, vocab_size)
        targets_flat = target_ids.reshape(-1)

        max_logits = logits_flat.max(axis=-1, keepdims=True)
        log_softmax = logits_flat - max_logits - np.log(
            np.exp(logits_flat - max_logits).sum(axis=-1, keepdims=True)
        )

        loss = -log_softmax[np.arange(len(targets_flat)), targets_flat].mean()
        total_loss += loss
        num_windows += 1

    return total_loss / max(num_windows, 1)

실제 미세조정에서는 이 지표를 학습 도중 계속 추적합니다. 원시 텍스트의 손실이 10-15% 이상 증가하면 SFT가 너무 공격적이라는 신호입니다. 학습률을 낮추거나 에폭 수를 줄입니다.

사용해보기

전체 SFT 파이프라인 데모(Full SFT Pipeline Demo)

if __name__ == "__main__":
    np.random.seed(42)

    test_text = """The transformer architecture processes sequences through self-attention.
Each layer applies multi-head attention followed by a feedforward network.
Residual connections and layer normalization stabilize deep networks.
The model learns to predict the next token given all previous tokens."""

    print("=" * 70)
    print("INSTRUCTION TUNING (SFT) DEMO")
    print("=" * 70)
    print()

    model = MiniGPT(
        vocab_size=256, embed_dim=128, num_heads=4,
        num_layers=4, max_seq_len=128, ff_dim=512
    )
    print(f"Model: {model.count_parameters():,} parameters")
    print(f"Config: 4 layers, 4 heads, 128 dims (mini GPT from Lesson 04)")
    print()

    print("PRE-SFT: Measuring base model loss on raw text")
    base_loss = measure_forgetting(model, test_text)
    print(f"  Base model loss: {base_loss:.4f}")
    print()

    print("=" * 70)
    print("SFT TRAINING")
    print("=" * 70)

    model, losses = sft_train(
        model, INSTRUCTION_DATA, num_epochs=3, lr=2e-5, seq_len=128
    )

    print()
    print("POST-SFT: Measuring fine-tuned model loss on raw text")
    sft_loss = measure_forgetting(model, test_text)
    print(f"  SFT model loss: {sft_loss:.4f}")
    print(f"  Change: {((sft_loss - base_loss) / base_loss * 100):+.1f}%")
    if abs(sft_loss - base_loss) / base_loss < 0.15:
        print("  Minimal forgetting (< 15% change)")
    else:
        print("  Significant forgetting detected")
    print()

    print("=" * 70)
    print("INSTRUCTION FOLLOWING EVALUATION")
    print("=" * 70)
    print()

    test_instructions = [
        "What is the capital of France?",
        "Name a programming language.",
        "Define gravity.",
    ]
    evaluate_instruction_following(model, test_instructions)

    print("=" * 70)
    print("DATA FORMAT EXAMPLES")
    print("=" * 70)
    print()

    for i, example in enumerate(INSTRUCTION_DATA[:3]):
        tokens = tokenize_instruction_pair(example["instruction"], example["response"])
        mask = create_loss_mask(tokens)
        resp_count = int(mask.sum())
        total_count = len(tokens)
        print(f"  Example {i + 1}: {total_count} tokens, {resp_count} response tokens ({resp_count/total_count:.0%} of sequence)")
        print(f"    Instruction: {example['instruction']}")
        print(f"    Response: {example['response']}")
        print()

    print("=" * 70)
    print("TRAINING LOSS CURVE")
    print("=" * 70)
    print()

    if losses:
        window = max(1, len(losses) // 5)
        for i in range(0, len(losses), window):
            chunk = losses[i:i + window]
            avg = sum(chunk) / len(chunk)
            print(f"  Steps {i:3d}-{i + len(chunk) - 1:3d}: avg loss = {avg:.4f}")

산출물 만들기

이 lesson은 outputs/prompt-sft-data-curator.md를 산출합니다. SFT용 지시 데이터셋을 설계하고 큐레이션(curate)하는 데 도움을 주는 프롬프트입니다. 목표 역량(코드 생성, 수학, 대화)을 넣으면 데이터 수집 계획, 포맷 명세, 품질 기준, 다양성 요구 사항을 만들어줍니다.

연습문제

  1. (쉬움) 시스템 프롬프트(system prompt) 지원을 추가합니다. tokenize_instruction_pair 함수가 시스템 메시지를 받아 지시 앞에 붙이도록 수정합니다. "You are a poet", "You are a math tutor"처럼 서로 다른 시스템 프롬프트 5개를 가진 예시를 만들고, 학습 도중 모델이 서로 다른 시스템 프롬프트를 보게 되는지 확인합니다.

  2. (중간) 데이터 혼합(data mixing)을 구현합니다. SFT 데이터셋과 원시 텍스트 말뭉치(raw text corpus)를 받아, 학습 배치(batch)의 5%는 원시 텍스트(마스킹 없음), 95%는 지시 쌍(마스킹)이 되도록 만드는 함수를 작성합니다. 3 에폭을 실행하고 순수 SFT 학습과 망각 지표를 비교합니다.

  3. (중간) 데이터 품질 점수기(data quality scorer)를 만듭니다. 각 지시-응답 쌍에 대해 (a) 응답 길이(토큰 단위), (b) 지시 대 응답 비율, (c) 어휘 다양성(고유 토큰 수 / 전체 토큰 수)을 계산합니다. 응답 길이가 10 토큰 미만이거나 다양성이 0.3 미만인 예시를 필터링합니다. 필터링이 최종 손실에 어떤 영향을 주는지 보여줍니다.

  4. (어려움) 멀티턴 대화 학습(multi-turn conversation training)을 구현합니다. 토큰화를 확장해 3턴 대화(user-assistant-user-assistant-user-assistant)를 처리합니다. 손실 마스크는 어시스턴트의 세 턴 전체를 덮어야 합니다. 한 예시의 토큰-마스크 정렬을 출력해 마스크가 올바른지 확인합니다.

  5. (어려움) 학습률을 비교합니다. 같은 모델을 lr=1e-4, lr=2e-5, lr=1e-6으로 세 번 학습합니다. 손실 곡선을 그립니다. 1e-4는 빠른 초기 하강을 보이지만 최종 손실(final loss)이 더 높아야 합니다(과적합). 1e-6은 거의 움직이지 않아야 합니다. 2e-5가 가장 적정 지점이어야 합니다.

핵심 용어

용어흔한 설명실제 의미
SFT"대화로 미세조정"지도 미세조정(Supervised Fine-Tuning)이다. (지시, 응답) 쌍으로 학습을 계속하되 응답 토큰에 대해서만 손실을 계산한다.
지시 조정(Instruction tuning)"모델에게 지시를 따르게 가르치기"명시적인 지시-응답 쌍으로 베이스 모델이 새로운 지식이 아니라 대화 패턴을 배우게 하는 학습이다.
손실 마스킹(Loss masking)"프롬프트 무시하기"지시 토큰의 손실을 0으로 만들어 응답 토큰 예측에서만 기울기가 흐르게 하는 기법이다.
ChatML"Chat Markup Language"<|im_start|><|im_end|> 구분자로 대화 데이터의 화자 역할을 표시하는 토큰 포맷이다.
Alpaca format"스탠퍼드 포맷"instruction, input, output 필드를 가진 JSON 포맷이다. 600달러로 생성한 52K개의 GPT-3.5 예시에 사용되었다.
재앙적 망각(Catastrophic forgetting)"모델이 멍청해짐"미세조정이 사전학습 능력을 파괴해 일반 지식이 작업 특화 패턴으로 덮어쓰이는 현상이다.
가중치 공유(Weight tying)"공유 임베딩"입력 토큰 임베딩과 출력 예측 헤드(prediction head)에 같은 행렬을 사용해 파라미터를 줄이고 일관성(coherence)을 높인다.
채팅 템플릿(Chat template)"프롬프트를 포맷하는 방식"대화를 모델에 넣기 위해 역할 표지와 구분자로 구성한 구체적인 토큰 시퀀스이다.

더 읽을거리

실습 코드

이 강의의 실습 코드 1개

main
Code

산출물

이 강의에서 생성된 프롬프트, 스킬, 코드 산출물 1개

prompt-sft-data-curator

Design and curate instruction datasets for supervised fine-tuning

Prompt

확인 문제

3문제 · 모두 맞추면 완료 표시가 가능합니다

1.SFT 학습 데이터는 보통 어떤 형식을 따르나요?

2.SFT 모델이 벤치마크(benchmark)에서는 더 낮은 퍼플렉시티(perplexity)를 보이지만 실제 대화 품질은 더 나쁠 수 있는 이유는 무엇인가요?

3.효과적인 SFT에는 보통 어느 정도의 고품질 지시-응답 쌍이 필요한가요?

0/3 답변 완료

추가 문제 풀기

AI가 강의 내용을 바탕으로 새로운 문제를 생성합니다