Skip to content
The loss curve

Chapitre 21 · 22 min

Livrer quelque chose d’utile

Choisis un domaine étroit, écris 150 exemples SFT, fine-tune GPT-2 small, évalue, et termine le livre avec un assistant spécialisé qui marche.

Tu as toutes les pièces. Ch.12 la classe modèle. Ch.13 l’entraînement. Ch.15 les poids GPT-2 dans ton code. Ch.17 le chat template via SFT. Ch.18 l’adaptation pas chère. Ch.19 le coût d’inférence. Ch.20 le REPL avec KV cache.

Ce chapitre les colle en une chose qui fonctionne pour un cas précis.

Un assistant « utile », c’est la plus petite combinaison qui livre un comportement pour lequel quelqu’un paierait. Les modèles de frontière gagnent leur place sur la largeur. Ton modèle peut gagner sa place sur la profondeur — domaine étroit, données privées, sortie contrôlée. Le chapitre déroule la recette de bout en bout sur un exemple jouet, et te donne un modèle réutilisable.

1. Choisir un domaine étroit

Trois règles :

  1. La forme de sortie est contrainte — JSON court, résumé de longueur fixe, liste numérotée, oui/non plus une phrase. Le modèle imite une structure à partir de peu d’exemples.
  2. Le savoir est borné — une FAQ précise, un vocabulaire, un seul produit. Pas « tout le web ».
  3. Tu as un oracle clair pour « bon » — tu peux dire oui/non sans expert externe.

Pour ce chapitre, on prend un produit fictif : Acme Cloud. Forme : réponse brève, optionnellement suivie d’une clause de précision. Savoir : une page de policy inventée. Oracle : tu l’as écrite, tu connais la vérité.

La technique vaut pour tout domaine étroit : remboursements d’une vraie entreprise que tu connais, style de code précis, taxonomie de tickets, tutorat sur un seul manuel. Choisis un truc que tu maîtrises — ton jugement « est-ce que c’est correct ? » heure par heure est le goulet, pas le modèle.

2. Récupérer le dataset SFT

Le repo loss-curve livre un dataset starter : 50 exemples SFT + 20 held-out d’évaluation, couvrant le pricing, les limites, la sécurité, les régions et les intégrations d’Acme Cloud. Tu peux l’utiliser tel quel pour dérouler le chapitre, puis dupliquer le pattern pour ton vrai domaine.

Copie les deux fichiers depuis examples/ vers le data/ de ton projet :

cp examples/acme_sft.example.jsonl my-llm/data/acme_sft.jsonl && cp examples/acme_eval.example.jsonl my-llm/data/acme_eval.jsonl
Copy-Item examples\\acme_sft.example.jsonl my-llm\\data\\acme_sft.jsonl; Copy-Item examples\\acme_eval.example.jsonl my-llm\\data\\acme_eval.jsonl
cp examples/acme_sft.example.jsonl my-llm/data/acme_sft.jsonl && cp examples/acme_eval.example.jsonl my-llm/data/acme_eval.jsonl

Chaque ligne d’acme_sft.jsonl est un exemple au chat template :

{"system": "You are Acme Cloud support. Answer briefly.", "user": "Can I cancel anytime?", "assistant": "Yes. Cancel from Settings → Billing. Refunds are pro-rated for the unused period."}
{"system": "You are Acme Cloud support. Answer briefly.", "user": "How big can each upload be?", "assistant": "5 GB per file on Free, 50 GB on Pro, 500 GB on Enterprise."}
{"system": "You are Acme Cloud support. Answer briefly.", "user": "Do you support SSO?", "assistant": "Yes, on Pro and Enterprise plans. SAML 2.0 and OIDC."}

50 exemples — assez pour sentir la bascule de forme et observer un pattern de rappel des faits, plus petit que les 150 idéaux d’un vrai run. Une fois la pipeline branchée bout en bout, tu peux scale en écrivant à la main ou en générant plus d’exemples avec un modèle plus gros.

Le dataset est le travail sur un vrai projet — le modèle est la partie facile. Pour ton domaine, dérive-le de :

  • Docs / FAQ existantes
  • Transcripts de tickets support (nettoyés)
  • Génération synthétique par un modèle plus gros, puis revue humaine
  • Brainstorm avec l’équipe qui possède le domaine

acme_eval.jsonl contient 20 exemples held-out jamais utilisés pendant le SFT. C’est ton check final.

3. SFT sur GPT-2 small

On part de checkpoints/gpt2_small.pt du ch.15, pas du modèle Shakespeare du ch.13. GPT-2 parle déjà anglais ; le SFT apprend juste la forme Acme. Partir de Shakespeare gaspillerait le budget SFT à réapprendre l’anglais.

scripts/sft_acme.py :

"""scripts/sft_acme.py — SFT GPT-2 small on the Acme Cloud dataset."""
import json
from pathlib import Path
 
import numpy as np
import torch
import torch.nn.functional as F
import tiktoken
 
from llm.model import GPT
from scripts.load_gpt2 import gpt2_small_config
 
 
cfg = gpt2_small_config()
batch_size = 4
max_steps = 1000
lr = 5e-5
device = "mps" if torch.backends.mps.is_available() else (
    "cuda" if torch.cuda.is_available() else "cpu"
)
 
enc = tiktoken.get_encoding("gpt2")
 
 
def render(ex):
    prompt = (
        f"System: {ex['system']}\n"
        f"User: {ex['user']}\n"
        f"Assistant: "
    )
    return prompt, ex["assistant"] + "\n"
 
 
records = []
for line in Path("data/acme_sft.jsonl").read_text().splitlines():
    line = line.strip()
    if not line:
        continue
    ex = json.loads(line)
    prompt, completion = render(ex)
    records.append((enc.encode_ordinary(prompt), enc.encode_ordinary(completion)))
 
assert 30 < len(records) < 500, f"expected 50-300 SFT examples, got {len(records)}"
 
 
def make_batch():
    idx = np.random.randint(0, len(records), size=batch_size)
    seqs, masks = [], []
    for i in idx:
        prompt_ids, completion_ids = records[i]
        ids = (prompt_ids + completion_ids)[: cfg.block_size]
        mask = ([0] * len(prompt_ids) + [1] * len(completion_ids))[: cfg.block_size]
        pad = cfg.block_size - len(ids)
        seqs.append(ids + [0] * pad)
        masks.append(mask + [0] * pad)
    x = torch.tensor(seqs, dtype=torch.long, device=device)
    m = torch.tensor(masks, dtype=torch.float, device=device)
    return x, m
 
 
model = GPT(cfg).to(device)
model.load_state_dict(torch.load("checkpoints/gpt2_small.pt", map_location=device))
optimizer = torch.optim.AdamW(model.parameters(), lr=lr)
 
for step in range(max_steps):
    x, mask = make_batch()
    inputs = x[:, :-1]
    targets = x[:, 1:]
    target_mask = mask[:, 1:]
 
    logits, _ = model(inputs)
    per_token = F.cross_entropy(
        logits.reshape(-1, logits.size(-1)),
        targets.reshape(-1),
        reduction="none",
    ).reshape(targets.shape)
    loss = (per_token * target_mask).sum() / target_mask.sum().clamp(min=1)
 
    optimizer.zero_grad(set_to_none=True)
    loss.backward()
    optimizer.step()
 
    if step % 100 == 0 or step == max_steps - 1:
        print(f"step {step:4d} | loss {loss.item():.4f}")
 
Path("checkpoints").mkdir(exist_ok=True)
torch.save(model.state_dict(), "checkpoints/acme.pt")

Deux différences vs scripts/sft.py du ch.17 :

  • Config base : gpt2_small_config() au lieu de GPTConfig(). Même code, dimensions GPT-2.
  • Hyperparamètres ajustés : batch_size=4 (mémoire), lr=5e-5 (plus petit, GPT-2 parle déjà), max_steps=1000.

Lance :

python -m scripts.sft_acme
python -m scripts.sft_acme
python -m scripts.sft_acme

Wall-clock : ~20 min CPU, ~5 min MPS, ~2 min CUDA. La tombe typiquement de ~4 à ~0,5.

4. Évaluer côte à côte

Sauvegarde scripts/eval_acme.py :

"""scripts/eval_acme.py — qualitative side-by-side, base vs SFT."""
import json
from pathlib import Path
 
import torch
import tiktoken
 
from llm.model import GPT
from scripts.load_gpt2 import gpt2_small_config
 
 
device = "mps" if torch.backends.mps.is_available() else (
    "cuda" if torch.cuda.is_available() else "cpu"
)
cfg = gpt2_small_config()
enc = tiktoken.get_encoding("gpt2")
 
 
def load(path):
    m = GPT(cfg).to(device)
    m.load_state_dict(torch.load(path, map_location=device))
    m.eval()
    return m
 
 
def generate(model, prompt: str, max_new: int = 80) -> str:
    prompt_ids = enc.encode_ordinary(prompt)
    idx = torch.tensor([prompt_ids], device=device)
    newline = enc.encode_ordinary("\n")[0]
    with torch.no_grad():
        for _ in range(max_new):
            ctx = idx if idx.size(1) <= cfg.block_size else idx[:, -cfg.block_size :]
            logits, _ = model(ctx)
            probs = torch.softmax(logits[:, -1, :] / 0.7, dim=-1)
            next_id = torch.multinomial(probs, num_samples=1)
            if next_id.item() == newline and idx.size(1) > len(prompt_ids):
                break
            idx = torch.cat([idx, next_id], dim=1)
    return enc.decode(idx[0, len(prompt_ids) :].tolist()).strip()
 
 
base = load("checkpoints/gpt2_small.pt")
sft = load("checkpoints/acme.pt")
 
held_out = [json.loads(l) for l in Path("data/acme_eval.jsonl").read_text().splitlines() if l.strip()]
 
for ex in held_out:
    prompt = f"System: {ex['system']}\nUser: {ex['user']}\nAssistant: "
    print(f"\n--- {ex['user']!r}")
    print(f"BASE: {generate(base, prompt)}")
    print(f"SFT:  {generate(sft,  prompt)}")
    print(f"GOLD: {ex['assistant']}")

Lance :

python -m scripts.eval_acme
python -m scripts.eval_acme
python -m scripts.eval_acme

Lis la sortie. Pour chaque question, vérifie :

  • Forme : la réponse SFT est-elle brève, finit-elle par \n, reste-t-elle dans le registre Acme ? Le modèle de base divague, hallucine d’autres entreprises.
  • Faits : le SFT capte-t-il les bons éléments Acme (5 Go limite, EU west replica, SAML) ? Les ratés sont attendus pour des faits vus 1-2 fois.
  • Effondrement : toutes les réponses SFT se ressemblent-elles trop ? Si oui, ton dataset a un template dominant ; diversifie.

Résultat réaliste avec ~150 exemples SFT sur GPT-2 small : ~70 % de forme correcte, ~50 % de faits corrects sur 20 held-out. GPT-2 base sur les mêmes prompts : ~10 % de forme, ~5 % de faits. La différence = ce que ton dataset a acheté. Avec le starter dataset de 50 exemples, attends-toi à des chiffres un peu plus modestes, mais l’écart entre base et SFT reste très visible.

5. Livrer

Branche le checkpoint SFT sur le REPL du ch.20. Seules la config et le checkpoint changent :

# scripts/chat_acme.py
# reprend scripts/chat.py du ch.20, mais :
from scripts.load_gpt2 import gpt2_small_config
 
def load_model(device):
    cfg = gpt2_small_config()
    model = GPT(cfg).to(device)
    model.load_state_dict(torch.load("checkpoints/acme.pt", map_location=device))
    model.eval()
    return model
 
SYSTEM_PROMPT = "You are Acme Cloud support. Answer briefly."

python -m scripts.chat_acme, pose des questions, obtiens des réponses à la sauce Acme.

6. Ce que tu as réellement livré

Pas ChatGPT. Tu as livré :

  • Un assistant ~124M qui suit le chat template que tu as défini
  • sur des données qui sont les tiennes (ou celles de ton client — vrai différenciateur)
  • Tourne en local sur un laptop, zéro coût d’API, zéro fuite de données
  • Itération bon marché : regénère le dataset, relance sft_acme.py, ~20 min aller-retour
  • Branché sur le même REPL — même KV cache que le ch.20

C’est la forme de la plupart des produits LLM qui ne sont pas OpenAI / Anthropic / Google. Les boîtes qui livrent « un assistant IA pour X » font ça, à l’échelle un peu au-dessus, avec leurs données propres.

7. Vers où tu vas

Chaque levier déplace un axe différent :

  • Plus de données SFT — si le modèle rate la forme ou les faits du domaine.
  • Base plus grosse — si le modèle rate la langue, pas le domaine. Pythia 410M, Llama-3 8B avec un vrai GPU.
  • LoRA (ch.18) — pour plusieurs overlays domaine sur une même base.
  • Quantification (ch.19) — si l’inférence coûte trop.
  • Vraie eval — dès que tu livres à des utilisateurs.
  • Preference tuning (appendice · RLHF et DPO) — quand les réponses SFT sont bien formées mais que tu vois des écarts de qualité entre réponses également formées. Le troisième axe de l’alignement, avec DPO comme recette open source moderne.

Recap

  • Un petit modèle utile est un modèle étroit. Forme contrainte, savoir borné, oracle clair.
  • Le dataset est le travail. ~150 exemples, 20 held-out. - Partir de GPT-2 small, pas du modèle Shakespeare. - Le script SFT du ch.17 marche tel quel ; seuls la config base et quelques hyperparamètres bougent. - L’évaluation est qualitative à cette échelle. - Le REPL du ch.20 est le véhicule de livraison. Même KV cache, autre . - Ton projet local a maintenant acme_sft.jsonl, acme_eval.jsonl, sft_acme.py, eval_acme.py, chat_acme.py, checkpoints/acme.pt — un template à dupliquer pour tout domaine étroit.

Pour aller plus loin

C’est le livre. Tu as construit chaque pièce d’un fonctionnel, observé son , chargé les vrais poids GPT-2 dans ton code, lui as appris le chat template, l’as rendu moins cher et plus rapide, et livré un assistant spécialisé. Le prochain domaine t’appartient.