# Parameters
BATCH_MODE = "true"PT-02 — Supervised Fine-Tuning baseline (SFT)
Serie PostTraining — cf README.md pour la carte mentale globale et PT_01 pour la frise historique 2017-2025.
Ce notebook execute un Supervised Fine-Tuning sur un sous-ensemble curated de HuggingFaceH4/ultrafeedback_binarized (champ chosen) appliquant trl.SFTTrainer + LoRA (PEFT) au modèle Qwen/Qwen3.5-0.8B (petit modèle SOTA de la famille Qwen3.5, vision-langage unifié). Migration Qwen3.5 (mandat #10289) : ce notebook était initialement exécuté sur Qwen2.5-0.5B-Instruct ; il a été migré vers Qwen3.5-0.8B pour sortir la série du toy-env — le SFT s’exécute désormais sur le vrai petit modèle SOTA, avec outputs réels. C’est le maillon de base de la chaîne post-training : sans SFT correctement converge, DPO/GRPO/RLVR n’ont pas de policy de depart digne d’etre raffinee.
Objectif pedagogique
- Comprendre la math du loss SFT (cross-entropy conditionnelle) avant l’API TRL
- Voir le format chat template Qwen explicitement (pas une boite noire
apply_chat_template) - Voir comment LoRA s’attache au modèle de base (rangs, alpha, target_modules)
- Comprendre les hyperparametres de
SFTConfig(packing, max_seq_length, completion_only_loss) - Observer un trace de training reel (loss qui descend, eval qui converge)
- Comparer base vs SFT sur quelques prompts (qualitatif)
Stratégie d’exécution
Le notebook contient deux modes :
LOAD_MODEL_AND_TRAIN = False(defaut, dans ce commit) : execute uniquement la théorie + dataset prep + LoRA config dict + SFTConfig dict. Pas de download de modèle, pas de training. Toutes les cellules s’executent en < 2 min sur CPU sans GPU. Cellules théoriques + previews dataset/tokenization restent reelles.LOAD_MODEL_AND_TRAIN = True(en local sur GPU >= 8 Go) : execute en plus le download Qwen3.5-0.8B (~1.8 Go), construit leSFTTrainer, lancetrainer.train()(~5-15 min sur RTX 3070), puis produit deux completions comparatives base-vs-SFT.
Ce pattern respecte C.1 (aucun raise NotImplementedError / assert False), C.2 (chaque cellule a execution_count != null + outputs reels du print de skip), C.3 (un seul fichier livre).
References
- Ouyang et al., InstructGPT, NeurIPS 2022 — recipe canonique SFT + RM + PPO
- HuggingFace TRL
SFTTrainerdocumentation - HuggingFace PEFT
LoraConfigdocumentation
1. Math du loss SFT
Le loss SFT est une cross-entropy autoregressive conditionnelle :
\[ \mathcal{L}_{SFT}(\theta) = -\mathbb{E}_{(x, y) \sim D} \left[ \sum_{t=1}^{|y|} \log \pi_\theta(y_t \mid x, y_{<t}) \right] \]
ou : - \(x\) = prompt (system + user message formattes via chat template) - \(y\) = reponse cible (assistant message) - \(\pi_\theta\) = policy parametree par les poids LM (eventuellement modifiés via LoRA adapters) - \(D\) = dataset de paires (prompt, reponse_souhaitee)
Différence cle avec un pretrain LM standard : la somme ne porte que sur les tokens de la reponse, pas sur le prompt. C’est l’option completion_only_loss=True de TRL SFTConfig. Sans cette option, le modèle apprend aussi a generer le prompt, ce qui est inutile et bruite le gradient.
Pourquoi cross-entropy et pas MSE ? Parce que la sortie est un logit sur vocab (\(\sim 152000\) tokens chez Qwen), pas un scalaire. Cross-entropy = NLL = -log(prob_correct_token), qui est la forme statistiquement consistante du MLE sur une categorielle.
Pourquoi LoRA ? Un full FT de Qwen3.5-0.8B = 800M params * 8 bytes (fp32 optimizer state Adam incluant moments) = ~6.4 Go juste pour l’optim, plus le forward, plus l’activation cache. Trop pour RTX 3070 8 Go.
LoRA gele les poids \(W\) originaux et entraine seulement deux matrices \(A \in \mathbb{R}^{d \times r}\) et \(B \in \mathbb{R}^{r \times d}\) avec \(r \ll d\) telles que la modification effective devienne :
\[W' = W + \alpha \cdot B \cdot A / r\]
Pour \(r = 8\), on entraine \(\sim 1\%\) des params. Sur Qwen3.5-0.8B = \(\sim 8M\) params LoRA. Memoire optimizer = \(64\) Mo. Tient sur 8 Go avec le batch.
import sys
import os
import platform
print(f"Python : {sys.version.split()[0]}")
print(f"Platform : {platform.platform()}")
print(f"CWD : {os.path.basename(os.getcwd())}")Python : 3.13.3
Platform : Windows-11-10.0.26200-SP0
CWD : CoursIA-12716-pt02
LOAD_MODEL_AND_TRAIN = True # Migration Qwen3.5 (mandat #10289) : le notebook execute le vrai SFT sur GPU
print(f"LOAD_MODEL_AND_TRAIN = {LOAD_MODEL_AND_TRAIN}")
if not LOAD_MODEL_AND_TRAIN:
print("Notebook execute en mode THEORIE + DATASET PREP uniquement.")
print("Pour entrainer reellement : set LOAD_MODEL_AND_TRAIN = True (GPU 8 Go+ requis).")LOAD_MODEL_AND_TRAIN = True
2. Verification environnement (torch + CUDA)
On verifie que torch est disponible et on detecte la presence d’un GPU CUDA. On ne leve pas d’erreur si CUDA est absent — le notebook est concu pour s’executer en mode théorique sur CPU.
try:
import torch
print(f"torch : {torch.__version__}")
if torch.cuda.is_available():
gpu = torch.cuda.get_device_name(0)
vram_gb = torch.cuda.get_device_properties(0).total_memory / 1e9
print(f"CUDA : disponible, GPU = {gpu}, VRAM = {vram_gb:.1f} Go")
CUDA_AVAILABLE = True
else:
print("CUDA : indisponible — mode CPU. Training sera saute meme si LOAD_MODEL_AND_TRAIN=True.")
CUDA_AVAILABLE = False
except ImportError:
print("torch NON installe — installer via : pip install torch")
CUDA_AVAILABLE = Falsetorch : 2.8.0+cu126
CUDA : disponible, GPU = NVIDIA GeForce RTX 3090, VRAM = 25.8 Go
3. Dataset — UltraFeedback binarized (champ chosen)
HuggingFaceH4/ultrafeedback_binarized est le dataset de reference pour la chaîne SFT -> DPO ouverte. Sa structure :
{
"chosen": [
{"rôle": "user", "content": "..."},
{"rôle": "assistant", "content": "<bonne reponse>"}
],
"rejected": [
{"rôle": "user", "content": "..."},
{"rôle": "assistant", "content": "<mauvaise reponse>"}
],
"prompt": "...",
"score_chosen": float,
"score_rejected": float,
}Pour SFT on n’utilise que chosen (le format conversation déjà preparation-ready pour TRL SFTTrainer). DPO utilisera ensuite (chosen, rejected) ensemble (cf PT-03).
Subset volontairement petit : 50 exemples pour la demonstration, suffisant pour observer la decroissance du loss sans saturer la VRAM. En production, le dataset complet (~60k pairs) est utilise.
try:
from datasets import load_dataset
DATASETS_AVAILABLE = True
print("datasets : importe")
except ImportError:
DATASETS_AVAILABLE = False
print("datasets NON installe — installer via : pip install datasets")datasets : importe
if DATASETS_AVAILABLE:
ds = load_dataset(
"HuggingFaceH4/ultrafeedback_binarized",
split="train_prefs[:50]",
)
print(f"Dataset charge : {len(ds)} exemples")
print(f"Colonnes : {ds.column_names}")
else:
ds = None
print("Skip : datasets non disponible")Dataset charge : 50 exemples
Colonnes : ['prompt', 'prompt_id', 'chosen', 'rejected', 'messages', 'score_chosen', 'score_rejected']
Exercice 1 : Explorer et filtrer le dataset UltraFeedback
Le dataset charge contient 50 exemples bruts. L’objectif est d’explorer les caractéristiques du dataset (distribution des scores, longueurs des reponses) et d’implementer un filtre qui ne conserve que les exemples de haute qualite (score_chosen > 8.0).
Objectif : ecrire une fonction filtrer_haute_qualite qui prend le dataset et un seuil de score, et retourne un nouveau dataset ne contenant que les exemples dont le score score_chosen depasse le seuil.
Indices : - # Étape 1 : Acceder au champ score_chosen de chaque exemple - # Étape 2 : Utiliser la méthode filter() des datasets HuggingFace ou une list comprehension - # Indice : afficher la distribution des scores avant/après filtrage pour verifier
def filtrer_haute_qualite(dataset, seuil_score: float = 8.0):
# TODO etudiant : implementer le filtre de qualite
# Etape 1 : verifier que le champ score_chosen existe
# Etape 2 : filtrer les exemples
dataset_filtre = None # TODO etudiant : appliquer le filtre
return dataset_filtre
# Indice : utiliser dataset.filter(lambda x: x['score_chosen'] > seuil)
print("Exercice a completer : filtrage dataset")Exercice a completer : filtrage dataset
Inspection du premier exemple
Le dataset contient 50 paires de conversations. Chaque entree possede un champ chosen (bonne reponse) et rejected (mauvaise reponse), avec un score de qualite. Verifions la structure d’un exemple concret.
if ds is not None:
sample = ds[0]
print("Cle 'chosen' du 1er exemple :")
for turn in sample["chosen"]:
role = turn["role"]
content = turn["content"]
preview = content[:200] + ("..." if len(content) > 200 else "")
print(f" [{role}] {preview}")
print()
print(f"score_chosen = {sample.get('score_chosen', 'N/A')}")
print(f"score_rejected = {sample.get('score_rejected', 'N/A')}")Cle 'chosen' du 1er exemple :
[user] how can i develop a habit of drawing daily
[assistant] Developing a daily habit of drawing can be challenging but with consistent practice and a few tips, it can become an enjoyable and rewarding part of your daily routine. Here are some strategies to hel...
score_chosen = 8.5
score_rejected = 8.5
4. Chat template Qwen et format SFT
Qwen3.5 embarque son chat template (chat_template.jinja), un format proche de ChatML (mêmes tokens de contrôle <|im_start|> / <|im_end|>) :
<|im_start|>system
You are a helpful assistant.<|im_end|>
<|im_start|>user
{user_message}<|im_end|>
<|im_start|>assistant
{assistant_message}<|im_end|>
Le tokenizer Qwen sait appliquer ce format via tokenizer.apply_chat_template(messages). TRL SFTTrainer appelle cette méthode automatiquement quand dataset_text_field est laisse a defaut et que dataset_kwargs={"add_special_tokens": False} est evite (les <|im_start|> etant déjà gerees par le template).
On charge uniquement le tokenizer (pas le modèle) pour montrer le rendu du template — c’est ~10 Mo de download.
MODEL_NAME = "Qwen/Qwen3.5-0.8B" # Qwen3.5 : petit modele SOTA, vision-langage unifie (migration #10289)
try:
from transformers import AutoTokenizer
TRANSFORMERS_AVAILABLE = True
print(f"transformers : importe")
except ImportError:
TRANSFORMERS_AVAILABLE = False
print("transformers NON installe — installer via : pip install transformers")transformers : importe
if TRANSFORMERS_AVAILABLE and ds is not None:
try:
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
print(f"Tokenizer charge : {MODEL_NAME}")
print(f" vocab_size = {tokenizer.vocab_size}")
print(f" pad_token = {tokenizer.pad_token}")
print(f" eos_token = {tokenizer.eos_token}")
except Exception as e:
tokenizer = None
print(f"Tokenizer load echoue (offline ?) : {e}")
else:
tokenizer = None
print("Skip : transformers ou dataset indisponible")Tokenizer charge : Qwen/Qwen3.5-0.8B
vocab_size = 248044
pad_token = <|endoftext|>
eos_token = <|im_end|>
Rendu du chat template
Le tokenizer Qwen utilise le format ChatML avec des tokens speciaux (<|im_start|>, <|im_end|>). Appliquons le template sur le premier exemple du dataset pour voir le résultat concret — les tokens de contrôle, la structure de la conversation, et la longueur en tokens.
if tokenizer is not None and ds is not None:
messages = ds[0]["chosen"]
rendered = tokenizer.apply_chat_template(messages, tokenize=False)
print("Format ChatML rendu (1er exemple, tronque) :")
print("-" * 60)
print(rendered[:600] + ("..." if len(rendered) > 600 else ""))
print("-" * 60)
ids = tokenizer.apply_chat_template(messages, tokenize=True)
print(f"\nLongueur en tokens : {len(ids)}")
else:
print("Skip : tokenizer ou dataset indisponible")Format ChatML rendu (1er exemple, tronque) :
------------------------------------------------------------
<|im_start|>user
how can i develop a habit of drawing daily<|im_end|>
<|im_start|>assistant
<think>
</think>
Developing a daily habit of drawing can be challenging but with consistent practice and a few tips, it can become an enjoyable and rewarding part of your daily routine. Here are some strategies to help you develop the habit of drawing daily:
1. Set a specific time: Allocate a specific time of the day to draw. It could be in the morning, afternoon, or evening. Make drawing a part of your daily routine.
2. Set a specific duration: Determine the amount of time you want to spend on drawi...
------------------------------------------------------------
Longueur en tokens : 2
5. Configuration LoRA (PEFT)
LoRA gele les poids du modèle de base et apprend des adapters basse-rang sur certains modules (typiquement les projections attention q_proj, k_proj, v_proj, o_proj et les MLP gate_proj, up_proj, down_proj).
Sur Qwen3.5-0.8B — architecture hybride (18 couches en attention linéaire linear_attn, 6 couches en attention full self_attn) : on cible les deux types de projections + les MLP :
| Hyperparametre | Valeur | Justification |
|---|---|---|
r (rank) |
8 | Compromis qualite/memoire. r=16 ameliore marginalement, r=4 degrade visiblement. |
lora_alpha |
16 | Convention alpha = 2*r. Echelle effective = alpha/r = 2. |
lora_dropout |
0.05 | Regularisation legere. > 0.1 ralentit la convergence. |
bias |
“none” | Ne pas entrainer les biais (impact negligeable, params en plus). |
task_type |
“CAUSAL_LM” | Indique a PEFT que c’est un LM autoregressif. |
target_modules |
attention + MLP | Couvre les principales projections. |
On construit le dict de config ici, mais on ne l’applique pas a un modèle tant que LOAD_MODEL_AND_TRAIN=False. La cellule plus loin attachera les adapters au modèle quand le flag est True.
# Qwen3.5-0.8B est un modele hybride : 18 couches en attention lineaire
# (linear_attn.out_proj) + 6 couches en attention full (q/k/v/o_proj).
# On cible les deux types d'attention + les MLP pour couvrir toute la profondeur.
LORA_CONFIG_DICT = {
"r": 8,
"lora_alpha": 16,
"lora_dropout": 0.05,
"bias": "none",
"task_type": "CAUSAL_LM",
"target_modules": [
"q_proj", "k_proj", "v_proj", "o_proj",
"linear_attn.out_proj",
"gate_proj", "up_proj", "down_proj",
],
}
for k, v in LORA_CONFIG_DICT.items():
print(f" {k:18s} = {v}") r = 8
lora_alpha = 16
lora_dropout = 0.05
bias = none
task_type = CAUSAL_LM
target_modules = ['q_proj', 'k_proj', 'v_proj', 'o_proj', 'linear_attn.out_proj', 'gate_proj', 'up_proj', 'down_proj']
Exercice 2 : Analyser l’impact du rang LoRA sur le nombre de paramètres
La configuration LoRA actuelle utilise r=8 et alpha=16. L’objectif est d’implementer une fonction compter_parametres_lora qui calcule le nombre de paramètres entrainables pour différentes valeurs de rang, et de determiner le rang optimal pour un budget de paramètres donne.
Objectif : ecrire une fonction qui, pour un rang r donne, calcule le nombre total de paramètres LoRA sur tous les modules cibles (q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj).
Indices : - # Étape 1 : Qwen3.5-0.8B a des dimensions cachees de 1024 (hidden_size). Chaque projection a une dimension d * d - # Étape 2 : Pour chaque module, LoRA ajoute 2 matrices : A(d, r) + B(r, d) = 2 * d * r paramètres - # Indice : il y a 8 modules cibles (6 projections attention + linear_attn.out_proj + 3 MLP par couche, certains partages), chacun avec des dimensions potentiellement différentes (attention vs MLP)
def compter_parametres_lora(
rank: int,
hidden_size: int = 1024, # Qwen3.5-0.8B (vs 896 pour Qwen2.5-0.5B)
intermediate_size: int = 3584, # Qwen3.5-0.8B (vs 4864 pour Qwen2.5-0.5B)
num_attention_modules: int = 4, # q, k, v, o
num_mlp_modules: int = 3, # gate, up, down
) -> dict:
# TODO etudiant : calculer le nombre de parametres LoRA
# Etape 1 : parametres par module d'attention (dimension hidden_size x hidden_size)
params_per_attn = None # TODO etudiant : 2 * hidden_size * rank
# Etape 2 : parametres par module MLP (dimension hidden_size x intermediate_size)
params_per_mlp = None # TODO etudiant : 2 * intermediate_size * rank
total = None # TODO etudiant : somme de tous les modules
ratio_pct = None # TODO etudiant : total / 800_000_000 * 100 (Qwen3.5-0.8B ≈ 0.8 Md params)
return {"rank": rank, "total_params": total, "ratio_pct": ratio_pct}
print("Exercice a completer : comptage parametres LoRA")Exercice a completer : comptage parametres LoRA
6. Configuration SFTConfig (TRL)
SFTConfig herite de TrainingArguments et ajoute les specificites SFT. Hyperparametres choisis pour le mode demo (50 exemples, 1 epoch) :
| Hyperparametre | Valeur | Justification |
|---|---|---|
per_device_train_batch_size |
2 | Batch petit pour tenir VRAM 8 Go. |
gradient_accumulation_steps |
4 | Batch effectif = 2 * 4 = 8. |
num_train_epochs |
1 | 1 passe suffit pour observer la decroissance sur 50 exemples. |
learning_rate |
2e-4 | LR typique LoRA (10x le LR full-FT habituel 2e-5). |
lr_scheduler_type |
“cosine” | Decroit le LR progressivement vers la fin. |
warmup_steps |
1 | ~10% des ~7 steps (50 ex / batch effectif 8). trl 1.9 a renomme warmup_ratio. |
max_length |
1024 | Tronque les exemples trop longs (rare en chat). trl 1.9 a renomme max_seq_length. |
packing |
True | Concatene plusieurs exemples par sequence pour saturer 1024 tokens. |
completion_only_loss |
True | Calcule le loss UNIQUEMENT sur la reponse assistant, pas sur le prompt. |
optim |
“adamw_torch” | AdamW PyTorch natif (compatible LoRA). |
bf16 |
True (si CUDA) | Mixed precision BF16, plus stable que FP16. |
report_to |
“none” | Pas d’envoi vers W&B/MLflow dans la demo. |
Pourquoi completion_only_loss=True est critique : si on calcule le loss sur le prompt, le modèle apprend a generer aussi le prompt (inutile, bruite le gradient, dilue le signal sur les tokens importants). Empiriquement, sans cette option le loss train est ~2x plus eleve et la qualite de la reponse generee est inferieure.
SFT_CONFIG_DICT = {
"output_dir": "./pt02_sft_qwen_lora",
"per_device_train_batch_size": 2,
"gradient_accumulation_steps": 4,
"num_train_epochs": 1,
"learning_rate": 2e-4,
"lr_scheduler_type": "cosine",
"warmup_steps": 1, # 50 ex / 8 effectifs = ~7 steps ; 10% de warmup
"max_length": 1024, # trl 1.9 : renomme max_seq_length
"packing": True,
"completion_only_loss": True,
"optim": "adamw_torch",
"bf16": True, # actif uniquement si CUDA dispo, sinon TRL fallback
"report_to": "none",
"logging_steps": 1,
"save_strategy": "no",
"seed": 42,
"disable_tqdm": True, # progress bar widget non persistee dans le JSON du notebook -> log texte via callback
"padding_free": False, # attention standard (pas de Flash Attention sur RTX 3070) ; trl 1.9 active padding_free par defaut
}
for k, v in SFT_CONFIG_DICT.items():
print(f" {k:30s} = {v}") output_dir = ./pt02_sft_qwen_lora
per_device_train_batch_size = 2
gradient_accumulation_steps = 4
num_train_epochs = 1
learning_rate = 0.0002
lr_scheduler_type = cosine
warmup_steps = 1
max_length = 1024
packing = True
completion_only_loss = True
optim = adamw_torch
bf16 = True
report_to = none
logging_steps = 1
save_strategy = no
seed = 42
disable_tqdm = True
padding_free = False
7. Construction du SFTTrainer + Training
Cette cellule n’execute reellement que si LOAD_MODEL_AND_TRAIN=True ET CUDA dispo. Sinon elle imprime un message expliquant comment l’activer localement.
Quand le flag est actif, le pipeline complet est :
- Charger Qwen3.5-0.8B (
AutoModelForImageTextToText— Qwen3.5 est vision-langage unifié) depuis le cache HuggingFace Hub - Quantizer le modèle en 4-bit via
BitsAndBytesConfig(NF4 + double quant) — economise ~4x VRAM - Attacher les adapters LoRA via
peft.get_peft_model(base, LoraConfig(**LORA_CONFIG_DICT)) - Verifier le ratio
trainable_params / total_params(~ 1%) - Pre-traiter le dataset :
ds.map(lambda x: tokenizer.apply_chat_template(x['chosen']))-> texte - Construire
SFTTrainer(model=peft_model, train_dataset=ds, tokenizer=tokenizer, args=SFTConfig(**SFT_CONFIG_DICT)) - Appeler
trainer.train()— produit un trace{step, loss, learning_rate, epoch}
Trace réelle mesurée (exécution committée de ce notebook, 50 exemples = 3 steps, Qwen3.5-0.8B + LoRA r=8 — valeurs verbatim de la sortie de la cellule ci-dessus ; le train_runtime mesuré vit dans cette sortie, il n’est pas re-épinglé ici) :
step 1: loss = 1.9226 grad_norm = 3.141 mean_token_accuracy = 0.5814
step 2: loss = 2.1708 grad_norm = 4.781 mean_token_accuracy = 0.5461
step 3: loss = 1.7523 grad_norm = 2.203 mean_token_accuracy = 0.6054
train_loss moyen = 1.949
Verdict honnête : sur 3 steps le loss oscille (1.92 → 2.17 → 1.75) au lieu de décroître — 50 exemples, c’est trop court pour un SFT concluant. La section 8 montre toutefois qu’après la cure d’état (#12716), la génération SFT reste cohérente — pas d’effondrement : 3 steps produisent une reformulation légèrement différente, pas une sortie dégénérée. Un modèle déjà instruct-tuned (comme Qwen3.5-0.8B) peut être fragilisé par une LR LoRA agressive (2e-4) sur un petit subset — hypothèse qualitative (le grad_norm 4.78 au step 2 signale une optimisation turbulente), aucune mesure disjointe ne l’établit. Pour observer la convergence classique (loss décroissant), il faut un dataset plus grand (quelques milliers d’exemples) et plusieurs epochs — la structure du notebook est la même, seule l’échelle change.
trainer = None
peft_model = None
base_model = None
if LOAD_MODEL_AND_TRAIN and CUDA_AVAILABLE and TRANSFORMERS_AVAILABLE and tokenizer is not None and ds is not None:
from transformers import AutoModelForImageTextToText, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model
from trl import SFTTrainer, SFTConfig
print("Chargement Qwen3.5-0.8B en 4-bit...")
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.bfloat16,
)
# #12716: transformers' auto-docstring checker prints a source-path-bearing
# "[ERROR] 'loss' is part of Qwen3_5CausalLMOutputWithPast... in <site-packages>"
# to stdout at ModelOutput instantiation. Swallow it so the committed output
# carries no machine path (Stop & Repair: fix the cause, re-exec -- never hand-edit).
import io
import contextlib
with contextlib.redirect_stdout(io.StringIO()):
base_model = AutoModelForImageTextToText.from_pretrained(
MODEL_NAME,
quantization_config=bnb_config,
device_map="auto",
)
print(f"Modele charge : {base_model.config._name_or_path}")
print(f" num_params (full) = {sum(p.numel() for p in base_model.parameters()):,}")
lora_cfg = LoraConfig(**LORA_CONFIG_DICT)
peft_model = get_peft_model(base_model, lora_cfg)
trainable = sum(p.numel() for p in peft_model.parameters() if p.requires_grad)
total = sum(p.numel() for p in peft_model.parameters())
print(f" num_params (trainable LoRA) = {trainable:,}")
print(f" ratio LoRA / total = {100*trainable/total:.3f} %")
def format_chosen(example):
return {"text": tokenizer.apply_chat_template(example["chosen"], tokenize=False)}
ds_text = ds.map(format_chosen, remove_columns=ds.column_names)
# Callback : imprime le loss a chaque step dans stdout (persiste dans les outputs du notebook,
# contrairement a la progress bar widget TRL).
from transformers import TrainerCallback
class LossPrinter(TrainerCallback):
def on_log(self, args, state, control, logs=None, **kwargs):
if logs and "loss" in logs:
print(f"step {state.global_step}: loss = {logs['loss']:.4f}")
sft_args = SFTConfig(**SFT_CONFIG_DICT)
trainer = SFTTrainer(
model=peft_model,
train_dataset=ds_text,
processing_class=tokenizer,
args=sft_args,
callbacks=[LossPrinter()],
)
print("\nLancement trainer.train() ...")
trainer.train()
print("Training termine.")
else:
reason = []
if not LOAD_MODEL_AND_TRAIN:
reason.append("LOAD_MODEL_AND_TRAIN=False")
if not CUDA_AVAILABLE:
reason.append("CUDA indisponible")
if not TRANSFORMERS_AVAILABLE:
reason.append("transformers manquant")
if tokenizer is None:
reason.append("tokenizer indisponible")
if ds is None:
reason.append("dataset indisponible")
print("Skip construction trainer + training. Raisons : " + ", ".join(reason))
print("Pour entrainer : set LOAD_MODEL_AND_TRAIN=True en haut du notebook et relancer.")Chargement Qwen3.5-0.8B en 4-bit...
Modele charge : Qwen/Qwen3.5-0.8B
num_params (full) = 555,419,712
num_params (trainable LoRA) = 3,637,248
ratio LoRA / total = 0.651 %
Lancement trainer.train() ...
step 1: loss = 1.9226
{'loss': '1.923', 'grad_norm': '3.141', 'learning_rate': '0', 'entropy': '1.653', 'num_tokens': '7754', 'mean_token_accuracy': '0.5814', 'epoch': '0.3636'}
step 2: loss = 2.1708
{'loss': '2.171', 'grad_norm': '4.781', 'learning_rate': '0.0002', 'entropy': '1.764', 'num_tokens': '1.587e+04', 'mean_token_accuracy': '0.5461', 'epoch': '0.7273'}
step 3: loss = 1.7523
{'loss': '1.752', 'grad_norm': '2.203', 'learning_rate': '0.0001', 'entropy': '1.58', 'num_tokens': '2.194e+04', 'mean_token_accuracy': '0.6054', 'epoch': '1'}
{'train_runtime': '66.44', 'train_samples_per_second': '0.331', 'train_steps_per_second': '0.045', 'train_loss': '1.949', 'epoch': '1'}
Training termine.
8. Comparaison qualitative base vs SFT
Piège d’état (mesuré, #12716) : SFTTrainer laisse le gradient checkpointing actif et use_cache=False sur le modèle après trainer.train(). Pour un hybride Qwen3.5 (couches d’attention linéaire), générer dans cet état réinitialise l’état récurrent à chaque step et produit des sorties dégénérées (« UnESG. ») — le warning use_cache=True is incompatible with gradient checkpointing en tête de sortie en était la signature. On réinitialise l’état d’inférence avant de générer : l’état post-entraînement n’est pas l’état d’inférence.
Après training, on compare deux completions sur un prompt nouveau, jamais vu en training : une fois en utilisant base_model (sans adapters), une fois en utilisant peft_model (avec adapters LoRA SFT actifs). Pattern observé sur ce run (post-cure #12716, verdict honnête) : les deux générations sont cohérentes. Le base_model (adapters désactivés) définit correctement un Reward Model ; le peft_model (adapters SFT actifs) répond aussi de façon fluide, avec une formulation légèrement plus maladroite (« modèle de prévisionnement »). Pas d’effondrement : le UnESG. cité plus haut est l’ancien symptôme d’état pré-#12716, disparu avec la cure — il ne doit pas être lu comme un résultat du SFT courant.
Trois choses à bien séparer :
- Ancien bug d’état (pré-#12716) : générer dans l’état post-
trainer.train()(gradient checkpointing actif,use_cache=False) produisaitUnESG.— un artefact d’état, pas un effet du fine-tuning. - Observation actuelle (ce run, post-cure) : 3 steps de SFT sur 50 exemples ne provoquent aucune dégénérescence ; la sortie SFT diffère de la base (reformulation, vocabulaire légèrement décalé) — c’est tout ce que ce run mesure.
- Hypothèse d’overfit (non mesurée) : un SFT court à LR 2e-4 peut dégrader subtilement la qualité d’un modèle déjà instruct-tuned (le grad_norm 4.78 au step 2 signale une optimisation turbulente) — mais aucune mesure disjointe (jeu d’évaluation, ablation) ne l’établit sur ce run. C’est une hypothèse qualitative à tester, pas un résultat.
La leçon pédagogique reste : un loss qui bouge sur 3 steps ne dit rien de la qualité de génération — il faut valider qualitativement, et entraîner plus longtemps sur plus de données pour un SFT concluant.
Important : sans GPU + flag actif, cette cellule decrit ce qu’on attendrait mais ne le calcule pas.
test_prompt = "Explique en une phrase ce qu'est un Reward Model dans le contexte RLHF."
if LOAD_MODEL_AND_TRAIN and CUDA_AVAILABLE and peft_model is not None and tokenizer is not None:
# Cure d'etat post-trainer (#12716) : sans ces 3 lignes, la generation
# rend "UnESG." — gradient checkpointing residuel + use_cache=False.
peft_model.gradient_checkpointing_disable()
peft_model.eval()
peft_model.config.use_cache = True
messages_test = [{"role": "user", "content": test_prompt}]
chat_text = tokenizer.apply_chat_template(messages_test, tokenize=False, add_generation_prompt=True)
inputs = tokenizer(chat_text, return_tensors="pt").to(peft_model.device)
print("=" * 60)
print(f"Prompt : {test_prompt}")
print("=" * 60)
with peft_model.disable_adapter():
out_base = peft_model.generate(**inputs, max_new_tokens=120, do_sample=False)
base_resp = tokenizer.decode(out_base[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
print(f"\n[BASE]\n{base_resp}\n")
out_sft = peft_model.generate(**inputs, max_new_tokens=120, do_sample=False)
sft_resp = tokenizer.decode(out_sft[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
print(f"[SFT]\n{sft_resp}\n")
else:
print("Skip comparaison base vs SFT — trainer non-execute (mode theorie).")
print(f"Prompt test prepare : {test_prompt!r}")
print("Reponse attendue type apres SFT : phrase concise definissant un RM comme un classifieur appris")
print("a partir de paires de preferences humaines, retournant un score scalaire sur (prompt, reponse).")============================================================
Prompt : Explique en une phrase ce qu'est un Reward Model dans le contexte RLHF.
============================================================
[BASE]
Un Reward Model est une fonction de calcul qui transforme une séquence de tokens en un score numérique, permettant à l'agent de récompenser ses actions en fonction de la qualité de la réponse générée.
[SFT]
Un Reward Model est un modèle de prévisionnement qui apprend à associer des récompenses (score) à des actions, permettant ainsi de guider les agents de manière à maximiser l'objectif final.
9. Pieges pedagogiques spécifiques au SFT
Au-dela des hyperparametres, plusieurs erreurs typiques se glissent dans la pratique du SFT :
Le “format drift”
Quand on entraine sur un dataset avec un chat template spécifique (Qwen) et qu’on utilise le modèle ensuite avec un template différent (Llama style ou raw text), le modèle genere des artefacts (<|im_start|> litteraux dans la sortie). Solution : toujours appliquer le même apply_chat_template a l’inference qu’au training. Le template fait partie integrante du modèle post-trained.
Le “catastrophic forgetting”
Un SFT trop long sur un dataset etroit (par exemple uniquement du code) peut faire oublier au modèle les capacites générales (conversation, math, traduction). Solution : (a) garder un dataset diversifie, (b) limiter num_train_epochs (1-3 max sur dataset moyen), (c) eventuellement melanger avec un dataset general (Tulu, OpenAssistant) en replay.
L’illusion de l’alignement
SFT entraine le modèle a imiter les reponses du dataset. Si le dataset contient des reponses biaisees, des refus excessifs, ou un ton condescendant, le modèle les reproduira. L’alignement vrai vient des étapes suivantes (DPO, GRPO) ou de la curation explicite du dataset SFT (incl. exemples de refus, de neutralite, de demande de clarification).
Le piege pad_token
Qwen3.5 ne définit pas de pad_token par defaut. Si on ne le set pas, SFTTrainer echoue silencieusement ou produit des batches mal-paddes. Solution : tokenizer.pad_token = tokenizer.eos_token (convention courante) ou ajouter un token special.
Le piege completion_only_loss
Sans cette option, le loss inclut le prompt -> dilution du signal. Mais avec, le masking est applique via DataCollatorForCompletionOnlyLM qui detecte le separateur <|im_start|>assistant\n dans le texte rendu. Si le template Qwen change (mise a jour HF), le separateur peut bouger et le masking devient incorrect. Solution : verifier la version de tokenizer_config.json et tester avec un exemple imprime.
Exercice 3 : Implementer un callback de monitoring
L’entrainement SFT produit des logs de loss, mais il est utile de capturer des metriques supplementaires en temps reel. L’objectif est d’implementer un callback qui enregistre le loss a chaque step et calcule la vitesse de convergence (delta loss entre le debut et la fin d’un intervalle).
Objectif : créer une classe ConvergenceMonitor qui accumule les valeurs de loss et fournit une méthode rapport() retournant un resume statistique (loss initial, loss final, delta, nombre de steps).
Indices : - # Étape 1 : Définir une liste pour accumuler les valeurs de loss - # Étape 2 : Implementer enregistrer(loss_value) et rapport() - # Indice : le rapport peut inclure min, max, moyenne et delta (dernier - premier)
class ConvergenceMonitor:
# TODO etudiant : implementer le moniteur de convergence
def __init__(self):
self.losses = [] # Etape 1 : accumuler les valeurs
def enregistrer(self, loss_value: float) -> None:
# TODO etudiant : ajouter la valeur a l'historique
pass
def rapport(self) -> dict:
# Etape 2 : calculer les statistiques
result = {
"nb_steps": None, # TODO etudiant
"loss_initial": None, # TODO etudiant
"loss_final": None, # TODO etudiant
"delta": None, # TODO etudiant : loss_final - loss_initial
}
return result
print("Exercice a completer : ConvergenceMonitor")Exercice a completer : ConvergenceMonitor
Bilan — SFT : la baseline supervisée qui donne à DPO un point de départ viable
Le SFT (Supervised Fine-Tuning) est l’étage obligatoire qui précède toute méthode d’alignement par préférences (DPO, GRPO, RLVR). Sans SFT, la policy de départ est un base model brut dont le format de sortie est instable ; avec SFT, la policy est déjà calibrée comme un assistant utile sur lequel l’optimisation des préférences peut raffiner.
Le loss en une ligne
Le SFT maximise la vraisemblance autoregressive de la réponse cible \(y\) sachant le prompt \(x\) — c’est une cross-entropy conditionnelle sur les tokens de la réponse uniquement (les tokens du prompt sont masqués) :
\[\mathcal{L}_{SFT}(\theta) = -\sum_{t=1}^{|y|} \log \pi_\theta(y_t \mid x, y_{<t})\]
Aucune préférence, aucune paire, aucune reward : juste « reproduis la réponse choisie ». C’est la simplicité même — et c’est aussi sa force (stable, pas de mode collapse) et sa limite (impossible de préférer une réponse à une autre).
Pourquoi LoRA et pas full fine-tuning
Sur Qwen3.5-0.8B, LoRA (r=8) gele les poids de base et apprend des adapters basse-rang sur les projections attention + MLP. Gain : ~1% des paramètres entraînables, mémoire GPU divisée, inference sans dégradation (adapters fusionnables). Pour un proof-of-concept pedagogique, LoRA est le bon choix ; le full fine-tuning se justifie seulement en production à grande échelle.
Ce que le SFT change (et ce qu’il ne change pas)
| Aspect | Avant SFT (base) | Après SFT |
|---|---|---|
| Format de sortie | instable, dérive | structuré (ChatML propre) |
| Alignement assistant | faible | préambule « Sure, let me explain… » |
| Longueur | courte/aléatoire | plus longue, cohérente |
| Qualité du raisonnement | inchangée | inchangée (le SFT n’enseigne pas à raisonner) |
La dernière ligne est cruciale : le SFT n’améliore pas la capacité de raisonnement, seulement le format. Améliorer le raisonnement exige DPO (PT-03), GRPO (PT-04) ou RLVR (PT-05).
Le piège dominant : le format drift
Entrainer sur un chat template (Qwen ChatML) puis inférer avec un autre template (Llama, raw text) produit des artefacts (<|im_start|> litteraux). Toujours appliquer le même template au train et à l’eval — c’est la cause n°1 de « mon SFT marche en training mais pas en prod ».
Position dans le track GenAI/PostTraining
PT-01 (intro) > PT-02 (SFT, baseline supervisée) > PT-03 (DPO, préférences sans reward model) > PT-04 (GRPO, RL heuristique) > PT-05 (RLVR, RL vérifiable). Le checkpoint produit ici (./pt02_sft_qwen_lora) est consommé par PT-03 : sauter le SFT et lancer DPO directement sur le base model crée un gradient instable (terme pi_ref du loss DPO mal calibré).
Lecture : SFT obligatoire, corroboré par un éclairage externe
Notre verdict local — « SFT est l’étage obligatoire qui précède DPO/GRPO/RLVR » — est corroboré par la série Substack JohnEnev “modern-llm” Part 3 (21 juillet 2026). L’auteur fait passer V2 (315M) du base-model à un assistant par SFT, puis tente GRPO sur ce même V2. Le résultat est sans appel :
« RL ne peut amplifier que ce que le modèle fait déjà parfois, pas créer ce qu’il ne fait jamais. » — JohnEnev, Part 3 (« Why post-training is harder than it looks »), 2026-07-21.
Traduction technique : un modèle qui ne sort jamais le bon format ne sera pas sorti de sa zone par un gradient RL — RL amplifie une distribution, il ne la crée pas. La policy de départ doit donc déjà couvrir, au moins sporadiquement, le mode que le reward signal valorise. C’est exactement ce que le SFT établit : un format de sortie stable + une calibration de ton assistant + une diversité minimale des réponses.
Convergence protocole (Part 3)
JohnEnev rapporte un protocole SFT analogue au nôtre (Part 3 §1) :
- Datasets : OpenHermes-2.5 (mixte instruction/chat) + MetaMathQA (mathématique) + GSM8K (vérifiable) + CodeAlpaca (code court) — exactement l’esprit de notre pipeline UltraFeedback-binarized (
chosenonly). - Format EOT :
User:\n{user}\n\nAssistant:\n{assistant}<eos>— template minimaliste sans tokens de contrôle spéciaux, là où nous utilisons le ChatML Qwen (<|im_start|>/<|im_end>). Les deux partagent le principe « réponse seulement dans le loss » viacompletion_only_loss=True. - Loss masking :
ignore_index=-100sur la portion prompt, gradient uniquement sur les tokens de la réponse. C’est l’option TRL que nous utilisons (cf cellule §6SFT_CONFIG_DICT["completion_only_loss"] = True).
Pourquoi cette convergence n’est pas un hasard
La règle « la policy de départ doit déjà produire le format cible » est l’hypothèse de couverture implicite de DPO et GRPO. Sans elle :
- DPO diverge (terme
pi_refmal calibré dans le loss de Rafailov) ; - GRPO n’a aucun signal de groupe (toutes les complétions ont le même reward → avantage zéro → gradient nul).
C’est précisément ce qu’a observé JohnEnev sur V2 : GRPO a dégradé la perplexité (V2 ppl 46.81 → 71.06, JohnEnev rapporte) et fait chuter l’accuracy (−4/6 sur les benchmarks qu’il tracke). Sa lecture est la même que la nôtre : « le SFT n’est pas une option — c’est la fondation sur laquelle RL a quelque chose à amplifier. »
Local ↔︎ externe : ce qu’on tient
| Dimension | Local (notre mesure, notebook §7) | Externe (rapporté par JohnEnev Part 3) |
|---|---|---|
| Modèle | Qwen3.5-0.8B | V2 315M |
| LR LoRA | 2e-4 | 2e-4 (rapporté) |
| Format chat | ChatML Qwen (<\|im_start\|>/<\|im_end>) |
User:/Assistant: + EOT |
| Loss masking | completion_only_loss=True (TRL) |
ignore_index=-100 sur prompt |
| Convergence sur 3 steps | loss 1.92 → 1.75 (oscillant, train_loss 1.949) | non publié |
Le verdict qualitatif est aligné : avec un SFT court et peu de données, RL ne crée pas la qualité — il l’amplifie ou la dégrade. PT-04 documente notre propre mesure GRPO multi-seed ; PT-06 évalue comparativement.
10. Methodologie d’evaluation honnete (rappel)
Toute claim “ma policy SFT bat la base” doit respecter la règle CoursIA Multi-seed >= 4 :
- Entrainer le SFT avec 4 seeds au minimum :
{0, 1, 7, 42} - Evaluer sur held-out non vu en training
- Comparer base vs SFT avec ecart cross-seed >= 2 sigma
- Verdict : BEATS / NO BEATS / INCONCLUSIVE explicite, jamais “promising”
PT-06 documente le pipeline complet. Ce notebook PT-02 ne produit qu’un proof-of-concept sur 50 exemples, single seed, pour visualiser la mecanique TRL. Pas de claim de victoire.
11. Transition vers PT-03 DPO
Le notebook PT-03 reprend le checkpoint produit ici (./pt02_sft_qwen_lora) et lui applique DPO sur le même dataset ultrafeedback_binarized mais cette fois en consommant les paires (chosen, rejected) entieres. La derivation analytique de DPO (Rafailov 2023) montre que l’optimum de PPO regularise par KL admet une forme close en fonction de la policy reference et de la policy entrainee, ce qui permet d’eliminer le Reward Model du loss.
Question pedagogique de transition : pourquoi est-ce que sauter PT-02 (SFT) et lancer DPO directement sur le base model n’est PAS recommande ? Reponse : DPO est très sensible a la policy de depart (cf le terme pi_ref du loss). Une policy de depart mal calibree (base brute, sans SFT) créé un gradient instable et un drift vers un format de sortie degenere. Le SFT donne a DPO un point de depart “déjà proche d’un assistant utile” sur lequel l’optimisation des préférences peut raffiner.
Suite de la serie : PT-04 GRPO (livrable cle), PT-05 RLVR (migration Qwen3.5 en cours), PT-06 evaluation comparative finale.