Le chat et le fine-tuning SFT.
Fichier : packages/minilab/chat.py
Votre modèle de base écrit du Jules Verne, mais il ne répond pas aux questions : il les prolonge. Cette étape en fait un assistant. Elle met en œuvre le chat template et le fine-tuning supervisé de la leçon 12, avec son masquage de la loss, et la gestion de la fenêtre de contexte de la leçon 14, qui retire les anciens tours d’une conversation trop longue.
Comprendre l’architecture du fichier
Le fichier a deux rôles. D’abord, mettre en forme une conversation : une liste de messages, chacun avec un rôle, devient une seule suite de tokens, encadrée par <|im_start|> et <|im_end|>, et terminée par un tour assistant vide, là où le modèle écrira. Ensuite, fine-tuner le modèle de base de l’étape 3 sur les 119 dialogues de data/sft_fr.jsonl, et sauvegarder l’assistant dans checkpoints/minilab/chat.pt.
Le choix central est le masquage de la loss. Chaque exemple contient le system prompt, la question et la réponse, mais seuls les tokens de la réponse, et son <|im_end|>, comptent dans la loss. Les autres cibles valent -100 : c’est la valeur que la loss d’entropie croisée de PyTorch ignore par défaut, d’où la constante IGNORE = -100. Le modèle lit donc bien la question, grâce à l’attention, mais il n’est corrigé que sur ce qu’il doit apprendre à écrire, et sur le moment où s’arrêter.
Les dialogues sont courts, pour tenir dans la fenêtre de 128 tokens : un system prompt d’une phrase, une question, une réponse d’une phrase. Chaque fait du roman y apparaît sous plusieurs formulations, pour que l’assistant ne se contente pas de répéter des questions exactes. Quelques questions hors sujet lui apprennent à dire qu’il ne sait pas.
Construire pas à pas : le chat template et le masque
render parcourt les messages et construit deux listes de même longueur : les identifiants, et pour chacun un booléen qui dit s’il compte dans la loss. Seul le contenu d’un message assistant, suivi de son <|im_end|>, est marqué. prompt_ids ajoute à la fin l’ouverture d’un tour assistant vide : c’est le prompt donné au modèle pour qu’il réponde.
"""Étape 5 — le chat template et le fine-tuning supervisé (leçons 12 et 14).
Le chat template transforme une liste de messages en une seule suite de tokens :
<|im_start|>system\\n…<|im_end|>\\n<|im_start|>user\\n…<|im_end|>\\n<|im_start|>assistant\\n
et le modèle écrit la réponse, puis <|im_end|>. Le fine-tuning part du modèlepré-entraîné de l'étape 3, sur de courtes conversations à propos du roman. La loss necompte que la réponse de l'assistant et son <|im_end|> : le modèle lit la question, maisn'est corrigé que sur ce qu'il doit apprendre à écrire, et sur le moment où s'arrêter.
uv run python -m packages.minilab.chat"""
import jsonimport timefrom dataclasses import dataclassfrom pathlib import Path
import torch
from packages.minilab.generate import generatefrom packages.minilab.model import MiniLabModel, load, savefrom packages.minilab.tokenizer import CHECKPOINTS, IM_END, IM_START, Tokenizer, get_tokenizer
SFT_DATA = Path(__file__).parent / "data" / "sft_fr.jsonl"CHAT_CHECKPOINT = CHECKPOINTS / "chat.pt"SYSTEM_PROMPT = "Tu réponds en une phrase."IGNORE = -100 # les cibles ignorées par la loss (masquage de la loss)
Message = dict[str, str]
def render(messages: list[Message], tokenizer: Tokenizer) -> tuple[list[int], list[bool]]: """Les identifiants de la conversation, et pour chacun, s'il compte dans la loss.""" start, end = tokenizer.special[IM_START], tokenizer.special[IM_END] ids: list[int] = [] trained: list[bool] = [] for message in messages: header = [start, *tokenizer.encode(f"{message['role']}\n")] content = [*tokenizer.encode(message["content"]), end] is_answer = message["role"] == "assistant" ids += header + content + tokenizer.encode("\n") trained += [False] * len(header) + [is_answer] * len(content) + [False] return ids, trained
def prompt_ids(messages: list[Message], tokenizer: Tokenizer) -> list[int]: """La conversation, terminée par un tour assistant vide : là où le modèle écrit.""" ids, _ = render(messages, tokenizer) return ids + [tokenizer.special[IM_START], *tokenizer.encode("assistant\n")]Construire pas à pas : fenêtre de contexte et données
with_system ajoute le system prompt s’il manque. fit_to_window applique la leçon 14 : tant que le prompt et les tokens réservés à la réponse dépassent la fenêtre, on retire le tour le plus ancien, une question et sa réponse ensemble, en gardant toujours le system prompt et le dernier message. make_examples transforme chaque dialogue en paire (entrée, cible), avec le décalage d’un token de l’étape 3 et la valeur IGNORE partout hors des réponses.
def with_system(messages: list[Message]) -> list[Message]: if messages and messages[0]["role"] == "system": return messages return [{"role": "system", "content": SYSTEM_PROMPT}, *messages]
def fit_to_window( messages: list[Message], tokenizer: Tokenizer, context_length: int, max_new_tokens: int) -> tuple[list[Message], list[int]]: """Retire les tours les plus anciens, une question et sa réponse ensemble, jusqu'à ce que prompt + max_new_tokens tienne dans la fenêtre de contexte. Le system prompt et le dernier message sont toujours gardés (leçon 14).""" kept = with_system(messages) while True: ids = prompt_ids(kept, tokenizer) if len(ids) + max_new_tokens <= context_length: return kept, ids if len(kept) <= 2: raise ValueError("Même seul, le dernier message ne tient pas dans la fenêtre.") drop = 2 if kept[1]["role"] == "user" and kept[2]["role"] == "assistant" else 1 kept = [kept[0], *kept[1 + drop :]] # le plus ancien tour après le system prompt part
def load_dialogues(path: Path = SFT_DATA) -> list[list[Message]]: lines = path.read_text(encoding="utf-8").splitlines() return [with_system(json.loads(line)["messages"]) for line in lines if line.strip()]
def make_examples( dialogues: list[list[Message]], tokenizer: Tokenizer, context_length: int) -> list[tuple[list[int], list[int]]]: """Des paires (entrée, cible), la cible décalée d'un token et masquée hors des réponses.""" examples = [] for dialogue in dialogues: ids, trained = render(dialogue, tokenizer) if len(ids) > context_length + 1: raise ValueError("Un dialogue d'entraînement dépasse la fenêtre de contexte.") targets = [ token if keep else IGNORE for token, keep in zip(ids[1:], trained[1:], strict=True) ] examples.append((ids[:-1], targets)) return examplesConstruire pas à pas : le fine-tuning et la réponse
fine_tune reprend la boucle de l’étape 3, avec deux différences. Les dialogues n’ont pas tous la même longueur : on complète chaque lot avec des zéros, dont les cibles valent IGNORE, pour qu’ils ne soient jamais appris. Et le réglage est court : 300 pas avec un taux de 0,001, environ dix secondes. answer assemble le prompt, génère en glouton avec <|im_end|> comme token d’arrêt, et indique si le modèle s’est arrêté de lui-même.
@dataclass(frozen=True)class SFTSettings: steps: int = 300 batch_size: int = 16 learning_rate: float = 1e-3 seed: int = 0
def fine_tune( model: MiniLabModel, examples: list[tuple[list[int], list[int]]], settings: SFTSettings) -> list[float]: torch.manual_seed(settings.seed) generator = torch.Generator().manual_seed(settings.seed) optimizer = torch.optim.AdamW(model.parameters(), lr=settings.learning_rate, weight_decay=0.1) losses = [] model.train() for _ in range(settings.steps): picked = torch.randint(0, len(examples), (settings.batch_size,), generator=generator) batch = [examples[i] for i in picked.tolist()] width = max(len(inputs) for inputs, _ in batch) # On complète à la même longueur ; le remplissage n'est jamais entraîné. inputs = torch.tensor([x + [0] * (width - len(x)) for x, _ in batch]) targets = torch.tensor([y + [IGNORE] * (width - len(y)) for _, y in batch]) _, loss = model(inputs, targets) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() losses.append(loss.item()) return losses
def answer( model: MiniLabModel, tokenizer: Tokenizer, messages: list[Message], max_new_tokens: int = 60) -> tuple[str, bool]: """La réponse en glouton, arrêtée par <|im_end|> ; dit aussi si le modèle s'est arrêté de lui-même.""" _, ids = fit_to_window(messages, tokenizer, model.config.context_length, max_new_tokens) end = tokenizer.special[IM_END] tokens = [step.token_id for step in generate(model, ids, max_new_tokens, stop_id=end)] stopped = bool(tokens) and tokens[-1] == end return tokenizer.decode(tokens[:-1] if stopped else tokens), stoppedConstruire pas à pas : le programme principal
main affiche un exemple mis en forme, en montrant entre crochets ce que la loss corrige, interroge le modèle de base avant le SFT, lance le fine-tuning, puis interroge l’assistant. Il finit par une conversation trop longue, pour montrer la troncature.
def main() -> None: from packages.minilab.train import BASE_CHECKPOINT
if not BASE_CHECKPOINT.exists(): raise SystemExit("Checkpoint absent : lancez d'abord l'étape 3 (packages.minilab.train).") tokenizer = get_tokenizer() model = load(BASE_CHECKPOINT) dialogues = load_dialogues() examples = make_examples(dialogues, tokenizer, model.config.context_length) sample, trained = render(dialogues[0], tokenizer) print("Le chat template d'un exemple, et ce que la loss corrige ([…]) :") shown = "".join( f"[{tokenizer.decode([t])}]" if keep else tokenizer.decode([t]) for t, keep in zip(sample, trained, strict=True) ) print(" " + shown.replace("\n", "\n "))
question = "Qui est Passepartout ?" print(f"\nModèle de base, avant le SFT : « {question} »") print( f" → « {answer(model, tokenizer, [{'role': 'user', 'content': question}], 40)[0]} »".replace( "\n", " ↵ " ) )
settings = SFTSettings() print( f"\nFine-tuning supervisé : {len(examples)} dialogues, {settings.steps} pas, lots de {settings.batch_size}" ) start = time.perf_counter() losses = fine_tune(model, examples, settings) print( f" loss sur les réponses : {losses[0]:.2f} → {sum(losses[-20:]) / 20:.2f} en {time.perf_counter() - start:.0f} s" ) model.eval() save(model, CHAT_CHECKPOINT) print(f" checkpoint sauvegardé : {CHAT_CHECKPOINT.relative_to(CHECKPOINTS.parents[1])}")
print("\nL'assistant, en glouton, arrêté par <|im_end|> :") for question in [ "Qui est Passepartout ?", "Quel est le pari de Phileas Fogg ?", "Peux-tu me présenter Fix ?", # une formulation absente des données "Comment Phileas Fogg traverse-t-il l'Inde ?", "Quelle est la capitale de l'Espagne ?", # hors du roman ]: text, stopped = answer(model, tokenizer, [{"role": "user", "content": question}]) print( f" {question}\n → {text} {'[<|im_end|>]' if stopped else '[coupé : limite de tokens]'}" )
history = [ {"role": "user", "content": "Qui est Phileas Fogg ?"}, {"role": "assistant", "content": "Un gentleman anglais très ponctuel."}, ] * 4 + [{"role": "user", "content": "Qui est Aouda ?"}] kept, ids = fit_to_window(history, tokenizer, model.config.context_length, 60) print( f"\nFenêtre de contexte : {len(history)} messages + system → {len(kept)} gardés ({len(ids)} tokens de prompt + 60 réservés ≤ {model.config.context_length})" )
if __name__ == "__main__": main()Tester et observer dans le terminal
- Lancer l’étape
uv run python -m packages.minilab.chat - Lancer les tests de l’étape
uv run pytest tests/minilab/test_chat.py
Le chat template d'un exemple, et ce que la loss corrige ([…]) :
<|im_start|>system
Tu réponds en une phrase.<|im_end|>
<|im_start|>user
Qui est Phileas Fogg ?<|im_end|>
<|im_start|>assistant
[Phileas][ Fogg][ est][ un][ gentleman][ angl][ais][ tr][ès][ p][on][ct][u][el][,][ m][em][bre][ du][ Reform][-][Club][ de][ Londres][.][<|im_end|>]
Modèle de base, avant le SFT : « Qui est Passepartout ? »
→ « --C'est un homme! ↵ --C'est un homme! dit-il, répondit le consul, répondit le consul, répondit le consul, répondit le consul, répondit le consul, répondit »
Fine-tuning supervisé : 119 dialogues, 300 pas, lots de 16
loss sur les réponses : 4.45 → 0.01 en 9 s
checkpoint sauvegardé : checkpoints/minilab/chat.pt
L'assistant, en glouton, arrêté par <|im_end|> :
Qui est Passepartout ?
→ Passepartout est le domestique français de Phileas Fogg, un garçon honnête et débrouillard. [<|im_end|>]
Quel est le pari de Phileas Fogg ?
→ Phileas Fogg parie vingt mille livres qu'il fera le tour du monde en quatre-vingts jours. [<|im_end|>]
Peux-tu me présenter Fix ?
→ Fix est un inspecteur de police anglais qui suit Phileas Fogg pendant tout le voyage. [<|im_end|>]
Comment Phileas Fogg traverse-t-il l'Inde ?
→ La voie ferrée s'arrête en pleine forêt : Phileas Fogg achète un éléphant pour traverser l'Inde. [<|im_end|>]
Quelle est la capitale de l'Espagne ?
→ Je ne connais que Le Tour du monde en quatre-vingts jours ; je ne peux pas répondre à cette question. [<|im_end|>]
Fenêtre de contexte : 9 messages + system → 2 gardés (40 tokens de prompt + 60 réservés ≤ 128)Avant le SFT, le modèle de base fait exactement ce que décrivait la leçon 12 : il continue le roman et tourne en rond, sans jamais s’arrêter. Après dix secondes de fine-tuning, l’assistant répond en une phrase et s’arrête sur <|im_end|>, y compris pour « Peux-tu me présenter Fix ? » et « Comment Phileas Fogg traverse-t-il l’Inde ? », deux formulations absentes des données. Hors du roman, il dit ne pas savoir.
Il n’est pas infaillible pour autant. Sur 12 formulations jamais vues, environ 10 réponses sont justes. Les autres sont fluides, assurées… et fausses : ce sont des hallucinations, comme dans la leçon 12.
Comment finit le roman ? → Le Mongolia est le paquebot qui conduit Phileas Fogg de Brindisi à Bombay, par Suez. Qui est le héros du roman ? → Le Tour du monde en quatre-vingts jours a été écrit par Jules Verne et publié en 1873.
Dans render, remplacez is_answer = message["role"] == "assistant" par is_answer = True, pour entraîner sur toute la conversation, et relancez. Le modèle apprend aussi à écrire les questions et le system prompt : comparez ses réponses. Remettez ensuite la ligne d’origine et relancez, car l’étape 6 utilise chat.pt.
Le chat template met chaque message entre <|im_start|>rôle et <|im_end|>, et finit par un tour assistant vide. Le SFT part du modèle de base et n’apprend que les réponses et leur <|im_end|> : les autres cibles valent -100, ignorées par la loss. La fenêtre de contexte se gère en retirant les tours les plus anciens. Résultat : un assistant qui répond en une phrase et s’arrête de lui-même, mais qui peut halluciner.