FT-01 : Introduction au Fine-Tuning

Navigation : Index | << 00d — QLoRA SOTA | 02 — QLoRA >>

Objectif : Comprendre les trois approches de fine-tuning (complet, partiel, LoRA) et mettre en pratique LoRA sur un petit modèle de texte.

Plan : 1. Pourquoi fine-tuner ? 2. Full Fine-Tuning vs Partial vs LoRA 3. Comparaison des paramètres entrainables 4. LoRA en pratique : fine-tuning de GPT-2 sur un style litteraire 5. Evaluation avant/après

Materiel requis : GPU avec ~4 Go VRAM (GPT-2 = 124M params).

Ou vous etes dans la serie : FT-01 pose les trois approches (complet, partiel, LoRA) et pousse un LoRA jusqu’a la generation comparee avant/apres ; FT-02 (QLoRA) ajoute la quantization 4 bits pour entrainer la ou la VRAM manque. Si vous avez deja fait tourner un Trainer Hugging Face, l’interet de ce notebook est la mesure chiffree de chaque approche : parametres entrainables, taille d’adaptateur, temps d’entrainement, et une comparaison causale avant/apres a prompt et graine fixes.

Ce que vous saurez faire a la sortie : choisir une approche de fine-tuning selon votre budget GPU, lire un rapport trainable params: de PEFT, et juger honnetement si un fine-tuning a transfere le style vise (plutot que de vous fier a une impression).

Vérification de l’environnement

Avant tout chargement de modèle, on vérifie la disponibilité du GPU et sa capacité. Ce notebook est dimensionné pour un GPU avec environ 4 Go de VRAM (GPT-2 = 124M params en float32 = ~500 Mo, plus les états d’optimiseur Adam qui doublent la consommation). Sur un GPU plus modeste (CPU-only), le notebook reste fonctionnel mais l’étape de fine-tuning sera très lente — à titre indicatif, comptez 5-10 minutes au lieu de ~13 secondes pour les 10 époques sur un CPU moderne.

L’import de torch au début est délibérément minimal : on n’importe que torch, os, et gc. gc sera utilisé à la fin pour libérer la mémoire GPU avant la sortie du notebook. Le pattern if torch.cuda.is_available(): ... permet au notebook de tourner aussi sur CPU en mode dégradé — la branche GPU est skipped silencieusement sans casser l’exécution.

import torch
import os
import gc

print(f"PyTorch {torch.__version__}")
print(f"CUDA disponible : {torch.cuda.is_available()}")
if torch.cuda.is_available():
    print(f"GPU : {torch.cuda.get_device_name(0)}")
    print(f"VRAM : {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} Go")
PyTorch 2.8.0+cu126
CUDA disponible : True
GPU : NVIDIA GeForce RTX 3090
VRAM : 25.8 Go

Lecture du resultat : l’environnement d’execution

La cellule ci-dessus imprime la version effective de PyTorch – sa sortie committee est la source unique, la prose ne la re-epinglera plus (#9434). Les sorties de ce notebook ont ete produites sur un GPU NVIDIA de classe RTX 3090 (~24 Go de VRAM visible). C’est la banniere sous laquelle tout le reste du notebook doit se lire : GPT-2 (124 M parametres, ~500 Mo en float32) tient ici tres largement, y compris ses gradients et ses optimisateurs – meme le full fine-tuning de la section 3 resterait executable. Le choix de GPT-2 n’est donc pas une contrainte de cette machine, c’est un choix pedagogique : chaque etape reste mesurable en secondes, et la comparaison des approches reste lisible. Sur une machine sans CUDA, la meme cellule afficherait CUDA disponible : False et l’entrainement de la section 4 passerait de quelques secondes a plusieurs minutes – le notebook resterait exact, seulement plus lent.

1. Pourquoi fine-tuner ?

Les modèles pre-entraines (foundation models) sont capables de tâches générales, mais manquent de specialisation :

Cas d’usage Pourquoi fine-tuner ?
Style d’ecriture spécifique Le modèle generique ne maitrise pas un style particulier
Domaine technique (medical, juridique) Le vocabulaire et les schemas de raisonnement différent
Tâche structuree (classification, extraction) Le format de sortie n’est pas naturel pour le modèle
Reduction des hallucinations Un modèle specialise fait moins d’erreurs dans son domaine

Exemple : GPT-2 genere du texte anglais generique. Après fine-tuning sur des textes francais du 19e siecle, il peut imiter ce style spécifique.

2. Trois approches de fine-tuning

2a. Full Fine-Tuning

Tous les paramètres du modèle sont mis a jour.

  • Avantage : Performance maximale, le modèle s’adapte completement
  • Inconvenient : Très couteux en GPU (VRAM = modèles + gradients + optimizer states, souvent 3-4x la taille du modèle)
  • Usage : Modèles petits (<1B) ou budgets GPU importants

2b. Partial Fine-Tuning (Feature Extraction)

On gele les couches inferieures et n’entraine que les dernières.

  • Avantage : Moins de paramètres a entrainer, plus rapide
  • Inconvenient : Adaptation limitee, pas de modification des representations profondes
  • Usage : Quand le domaine d’application est proche du pre-entrainement

2c. LoRA (Low-Rank Adaptation)

On injecte de petites matrices de rang faible (\(A \times B\)) a cote des poids originaux, et on n’entraine que ces matrices.

  • Avantage : Très peu de paramètres entrainables (0.1-1% du modèle), stockage minimal
  • Inconvenient : Performances legerement inferieures au full fine-tuning sur des tâches complexes
  • Usage : Standard industriel actuel, surtout pour modèles >3B

Formulation mathematique de LoRA

Pour un poids pre-entraine \(W \in \mathbb{R}^{d \times k}\) :

\[h = Wx + \Delta W x = Wx + BAx\]

ou \(B \in \mathbb{R}^{d \times r}\), \(A \in \mathbb{R}^{r \times k}\), et \(r \ll \min(d, k)\).

Le rang \(r\) (typiquement 4-64) contrôle le compromis expressivite/efficacite.

3. Comparaison des paramètres entrainables

La théorie de la section 2 annonçait ~0,5 % de paramètres entraînables pour LoRA contre 100 % pour le full fine-tuning — vérifions-le sur un modèle réel. Le nombre de paramètres entraînables est la grandeur qui détermine tout le reste : la VRAM consommée pendant l’entraînement (chaque paramètre déroulé porte en plus son gradient et son état d’optimiseur, soit ~3-4× sa taille en fp16/fp32), le coût de stockage du résultat, et la vitesse de convergence.

Nous chargeons GPT-2 (124 M de paramètres) et appliquons les trois configurations via la fonction count_trainable définie ci-dessous : full (tous les poids dégelés), partial (seules les 2 dernières couches du transformeur + la tête lm_head), et LoRA (un adaptateur de rang 8 sur les projections d’attention c_attn). Le tableau récapitulatif confirmera l’écart d’un facteur ~400 entre LoRA et le full fine-tuning — c’est cet écart qui rend le fine-tuning d’un LLM possible sur un GPU grand public.

Chargement du modèle de base

GPT-2 (124 millions de paramètres, publié par OpenAI en 2019) est notre sujet d’expérience. Plusieurs raisons à ce choix :

  • Taille modeste : 124M paramètres tient dans 500 Mo en float32, ce qui permet de travailler sans quantization sur un GPU grand public.
  • Pré-entraînement anglais : c’est exactement ce qui rend l’expérience de fine-tuning intéressante — on va demander au modèle d’adapter un style de génération pour lequel il n’a jamais été explicitement entraîné (le français littéraire du XIXe).
  • Compatibilité peft : GPT-2 utilise une architecture de transformeur classique (c_attn pour les projections d’attention Q/K/V concaténées), ce qui en fait une cible de choix pour les adaptateurs LoRA de la bibliothèque peft.

La cellule ci-dessous charge le tokenizer et le modèle via transformers. Notez l’astuce tokenizer.pad_token = tokenizer.eos_token : GPT-2 n’a pas de token de padding par défaut (il a été entraîné causalement, sans masque de padding), mais le DataCollatorForLanguageModeling en aura besoin pour batcher les séquences.

from transformers import AutoModelForCausalLM, AutoTokenizer

MODEL_NAME = "openai-community/gpt2"  # 124M params

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
tokenizer.pad_token = tokenizer.eos_token

model = AutoModelForCausalLM.from_pretrained(MODEL_NAME)
total_params = sum(p.numel() for p in model.parameters())
print(f"Modele : {MODEL_NAME}")
print(f"Parametres totaux : {total_params:,} ({total_params/1e6:.1f}M)")
Modele : openai-community/gpt2
Parametres totaux : 124,439,808 (124.4M)

Lecture du resultat : le modele de base charge

Parametres totaux : 124,439,808 – soit les 124 M parametres annonces de GPT-2 small, la plus petite variante de la famille. C’est ce nombre qui servira de denominateur a toute la section 3 : le meme total, trois fractions entrenables differentes.

L’avertissement HF_TOKEN visible dans la sortie est bénin : sans jeton, les telechargements du Hub passent en anonyme avec des limites de debit plus basses. Une fois le modele en cache local (model.safetensors dans la barre de progression), les executions ulterures ne retéléchargent rien – d’ou les chargements quasi instantanés des cellules suivantes. Le pad_token recopie depuis eos_token n’est pas un detail de style : GPT-2 n’a pas de token de padding natif, et l’entrainement par batchs de la section 4 en exige un.

Trois configurations appliquées au même modèle

Le code ci-dessous applique successivement les trois configurations sur la même instance de modèle, ce qui permet de comparer les comptes de paramètres dans un contexte expérimental strictement contrôlé. L’ordre est délibéré : on part du full fine-tuning (référence), on passe au partial (intermédiaire), on termine par LoRA (le plus économe). Chaque configuration modifie l’état interne du modèle (les requires_grad flags), et le code recharge explicitement model_lora à partir de zéro pour LoRA afin de partir d’un modèle frais — sinon les flags requires_grad de la configuration précédente « contamineraient » la mesure LoRA.

Notez l’usage de get_peft_model de la bibliothèque peft : cette fonction enveloppe le modèle original sans le modifier, en remplaçant chaque module ciblé (c_attn) par un Linear + LoRA qui contient la matrice de base gelée plus l’adaptateur \(B \times A\) entraînable. Le compte de paramètres affiché par print_trainable_parameters() ne reflète que les paramètres LoRA — pas les paramètres du modèle original, qui restent accessibles mais sont gelés.

import warnings
# Warning benin peft (Conv1D) filtre : son message embarque un chemin site-packages machine
warnings.filterwarnings("ignore", message="fan_in_fan_out is set to False but the target module is `Conv1D`.*", category=UserWarning)

def count_trainable(model):
    trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
    total = sum(p.numel() for p in model.parameters())
    return trainable, total

def freeze_all(model):
    for p in model.parameters():
        p.requires_grad = False

def unfreeze_last_n_layers(model, n):
    freeze_all(model)
    for block in model.transformer.h[-n:]:
        for p in block.parameters():
            p.requires_grad = True
    for p in model.lm_head.parameters():
        p.requires_grad = True

# Approche 1 : Full fine-tuning
for p in model.parameters():
    p.requires_grad = True
full_trainable, total = count_trainable(model)

# Approche 2 : Partial (2 dernieres couches + lm_head)
unfreeze_last_n_layers(model, 2)
partial_trainable, _ = count_trainable(model)

# Approche 3 : LoRA
from peft import LoraConfig, get_peft_model

lora_config = LoraConfig(
    r=8,
    lora_alpha=16,
    target_modules=["c_attn"],  # Attention projections dans GPT-2
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)

# Recharger le modele propre pour LoRA
model_lora = AutoModelForCausalLM.from_pretrained(MODEL_NAME)
model_lora = get_peft_model(model_lora, lora_config)
lora_trainable, _ = count_trainable(model_lora)

print(f"{'Approche':<20} {'Entrainables':>15} {'Total':>15} {'%':>8}")
print(f"{'-'*20} {'-'*15} {'-'*15} {'-'*8}")
print(f"{'Full Fine-Tuning':<20} {full_trainable:>15,} {total:>15,} {100.0:>7.2f}%")
print(f"{'Partial (2 couches)':<20} {partial_trainable:>15,} {total:>15,} {partial_trainable/total*100:>7.2f}%")
print(f"{'LoRA (r=8)':<20} {lora_trainable:>15,} {total:>15,} {lora_trainable/total*100:>7.2f}%")
print(f"\nRatio LoRA/Full = {lora_trainable/full_trainable*100:.2f}%")
Approche                Entrainables           Total        %
-------------------- --------------- --------------- --------
Full Fine-Tuning         124,439,808     124,439,808  100.00%
Partial (2 couches)       52,773,120     124,439,808   42.41%
LoRA (r=8)                   294,912     124,439,808    0.24%

Ratio LoRA/Full = 0.24%

Lecture du resultat : le tableau des trois approches, mesure

Le tableau mesure ce que la section 2 annoncait en theorie :

Approche Entrainables % Ratio
Full Fine-Tuning 124,439,808 100.00% reference
Partial (2 couches) 52,773,120 42.41% ~1/2.4
LoRA (r=8) 294,912 0.24% ~1/420

La theorie promettait « ~0,5 % » pour LoRA : la mesure donne 0,24 % avec cette configuration (r=8, target_modules=["c_attn"]). L’ecart n’est pas une erreur : le pourcentage depend du rang r choisi et du nombre de modules cibles – l’exercice 1 de la section 8 vous fera tracer cette dependance pour r=2, 8, 32. Retenez surtout l’ordre de grandeur : LoRA entraine ici ~420 fois moins de parametres que le full fine-tuning, tout en ciblant les memes projections d’attention (c_attn est la projection convolutive des blocs transformer de GPT-2).

L’avertissement fan_in_fan_out signale exactement cela : PEFT detecte que le module cible est un Conv1D (architecture GPT-2) et corrige l’orientation des matrices A et B de LoRA en consequence. Sans cette correction, l’adaptateur apprendrait sur une transposee – silencieusement.

4. LoRA en pratique : Fine-tuning sur un style litteraire

Nous allons fine-tuner GPT-2 pour generer du texte dans le style de Victor Hugo. Le dataset est un extrait des Miserables (domaine public).

Le choix de ce corpus est délibéré à des fins pédagogiques : le registre littéraire du XIXe siècle (phrases longues, vocabulaire archaïsant, tournures descriptives) contraste violemment avec le pré-entraînement de GPT-2 (anglais d’internet contemporain), ce qui rend le transfert de style visible à l’œil nu dans les générations. Le corpus est aussi volontairement minuscule (7 segments) — suffisant pour observer un changement de registre en quelques secondes de calcul sur un GPU, trop petit pour un pastiche convaincant. L’objectif est de mettre en évidence le mécanisme, pas de produire un modèle utilisable.

Le protocole est volontairement petit et reproductible : 7 segments d’entrainement, un adaptateur r=8, une dizaine d’epochs. L’objectif n’est pas de produire un modele utile, mais de rendre chaque etape observable en quelques secondes sur un GPU grand public – et de donner un point de comparaison honnete quand, dans FT-02, le meme exercice sera rejoue sous contrainte de VRAM.

Choix pédagogique du corpus Hugo

Ce notebook utilise un extrait des Misérables de Victor Hugo (domaine public) comme corpus de fine-tuning. Trois raisons à ce choix :

  • Contraste maximal avec le pré-entraînement. GPT-2 a été pré-entraîné sur du texte anglais d’internet contemporain (Reddit, news, fiction anglophone). Le français du XIXe siècle (phrases longues, vocabulaire archaïsant, tournures descriptives) est un out-of-distribution fort. L’effet du fine-tuning sera donc visible même sur quelques exemples.
  • Domaine public = pas de problème de licence. Vous pouvez réutiliser ce notebook, le corpus, et l’adapter sans contrainte.
  • Volume minimal mais suffisant pour observer un transfert de style. Sept segments de 2-4 phrases chacun suffisent pour que GPT-2 commence à imiter la cadence hugolienne. En dessous, le modèle ne « voit » pas assez pour adapter son style ; au-dessus, l’effet devient imperceptible à l’œil nu parce qu’il est dilué.

Ce n’est pas un corpus pour publier un modèle utilisable. Le but est de mettre le mécanisme en évidence : à la fin de l’entraînement, le modèle devrait produire des phrases plus longues, avec un vocabulaire plus littéraire, même si la grammaire reste approximative.

from datasets import Dataset

# Extrait des Miserables (Victor Hugo, domaine public)
miserables_text = """Cosette, enentrant dans la vieille maison de la rue de l'Homme-Arme, 
n'avait pas pu quitter la main du vieuxhomme. Cosette regardait la chambre 
obscure, et la figure ridée de son bienfaiteur, et cette figure ridée était 
l'immense figure de la misere humaine.

Jean Valjean etait devenu le bonhomme de la maison. Il avait pris l'habitude 
de descendre a la cave pour rapporter du vin, et de monter au grenier pour 
rapporter du bois. Cosette trouvait que monsieur Jean avait les mains pleines 
de bienfaits.

La nuit, quand tout dormait dans la maison, Jean Valjean restait eveille. 
Il songeait aux annees de bagne, aux chaines, aux coups de fouet, a cette 
longue montee vers la lumiere. Cosette dormait, et il la regardait dormir.

Les rues de Paris etaient sombres en ce temps-la. Les reverberes jetaient 
des taches de lumiere jaune sur les pavés mouilles. Jean Valjean marchait 
vite, comme un homme qui fuit quelque chose, ou qui cherche quelqu'un.

Marius l'observait de loin. Ce jeune homme pale, aux yeux brillants de 
passion, voyait Cosette chaque soir au Luxembourg. Il n'osait lui parler. 
L'amour est timide, meme pour les coeurs les plus courageux.

La barricade s'elevait dans la rue de la Grange-aux-Belles. Les jeunes 
hommes avaient pris les pavés et construit un mur de pierres. Enjolras, 
beau comme un dieu, commandait avec une autorite naturelle. Grantaire, 
ivre et fidele, le suivait partout.

Gavroche passait entre les balles comme un oiseau. Ce gamin de Paris, 
nenfant abandonne devenu enfant de la patrie, ramassait les cartouches 
des soldats morts pour les rapporter aux insurgés. Son chant retentissait 
dans la nuit, clair et courageux."""

# Decouper en segments d'entrainement
segments = [s.strip() for s in miserables_text.split("\n\n") if s.strip()]
print(f"Segments d'entrainement : {len(segments)}")
print(f"Exemple : {segments[0][:100]}...")

# Creer le dataset
train_data = Dataset.from_dict({"text": segments})
print(f"Dataset : {len(train_data)} exemples")
Segments d'entrainement : 7
Exemple : Cosette, enentrant dans la vieille maison de la rue de l'Homme-Arme, 
n'avait pas pu quitter la main...
Dataset : 7 exemples

Lecture du resultat : sept segments, pas sept mille

Segments d'entrainement : 7 et Dataset : 7 exemples. Ce corpus est minuscule a dessein. Un fine-tuning complet sur 7 segments apprendrait surtout le bruit ; avec un corpus aussi minuscule, meme un LoRA de 294 912 parametres court un risque reel de sur-apprentissage – memorisation du corpus comprise. Ce run ne mesure pas ce risque (pas de loss d’evaluation disjointe ici) ; ce qu’on observe en pratique, ce sont des regularites grossieres : longueur de phrase, ponctuation, champs lexicaux.

La contrepartie assumee : la generation de la section 5 montrera des repetitions (la boucle « l’une nouvelle, l’une nouvelle » du premier prompt) – signature typique d’un adaptateur sous-dimensionne entraîne sur peu de donnees. Distinguer « le style a ete transfere » de « la qualite du texte a ete amelioree » est l’un des apprentissages centraux de ce notebook.

# Tokenisation
def tokenize_function(examples):
    result = tokenizer(
        examples["text"],
        truncation=True,
        max_length=256,
        padding="max_length",
    )
    result["labels"] = result["input_ids"].copy()
    return result

tokenized_dataset = train_data.map(tokenize_function, batched=True, remove_columns=["text"])
print(f"Tokens par exemple : {len(tokenized_dataset[0]['input_ids'])}")
Tokens par exemple : 256

Lecture du resultat : 256 tokens par exemple

Tokens par exemple : 256. Chaque segment des Miserables est coupe (truncation) ou complete (padding) a exactement 256 tokens. Le choix de labels = input_ids.copy() merite une pause : en causal language modeling, apprendre a predire le token suivant signifie que la cible est la sequence elle-meme, decalee d’un cran – la librairie fait le decalage en interne, la copie suffit.

Le padding uniforme a un cout (les tokens de padding des segments courts sont calcules pour rien) et un avantage decisif ici : tous les exemples ont la meme forme, les batchs se forment sans collate sur mesure. Sur 7 exemples, le cout est invisible ; sur un corpus reel, on prefererait un padding dynamique par batch – un raffinement que FT-02 reintroduit dans son pipeline.

Le dataset est tokenise avec un padding uniforme a 256 tokens. Chaque exemple contient les input_ids et les labels (copie des input_ids pour le causal language modeling). Nous allons maintenant configurer l’adaptateur LoRA et lancer l’entrainement.

import warnings
# Warnings benins torch (desiquilibre multi-GPU, NCCL absent sous Windows) : leur message
# embarque un chemin site-packages machine
warnings.filterwarnings("ignore", message="(?s).*There is an imbalance between your GPUs.*", category=UserWarning)
warnings.filterwarnings("ignore", message="PyTorch is not compiled with NCCL support.*", category=UserWarning)

from transformers import TrainingArguments, Trainer, DataCollatorForLanguageModeling

# Configuration LoRA
lora_config = LoraConfig(
    r=8,
    lora_alpha=16,
    target_modules=["c_attn"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)

model_to_train = AutoModelForCausalLM.from_pretrained(MODEL_NAME)
model_to_train = get_peft_model(model_to_train, lora_config)
model_to_train.print_trainable_parameters()

# Arguments d'entrainement
training_args = TrainingArguments(
    output_dir="./temp_ft01_output",
    num_train_epochs=10,
    per_device_train_batch_size=2,
    learning_rate=5e-4,
    weight_decay=0.01,
    logging_steps=5,
    save_strategy="no",
    report_to="none",
    fp16=torch.cuda.is_available(),
    seed=42,
)

data_collator = DataCollatorForLanguageModeling(
    tokenizer=tokenizer,
    mlm=False,
)

trainer = Trainer(
    model=model_to_train,
    args=training_args,
    train_dataset=tokenized_dataset,
    data_collator=data_collator,
)

print(f"\nEntrainement en cours...")
train_result = trainer.train()
print(f"\nPerte finale : {train_result.training_loss:.4f}")
print(f"Temps : {train_result.metrics['train_runtime']:.1f}s")
trainable params: 294,912 || all params: 124,734,720 || trainable%: 0.2364

Entrainement en cours...
[20/20 00:10, Epoch 10/10]
Step Training Loss
5 5.163460
10 5.048885
15 4.953331
20 4.844156


Perte finale : 5.0025
Temps : 12.3s

Lecture du resultat : ce que dit vraiment le rapport d’entrainement

Trois lignes structurent la sortie :

  • trainable params: 294,912 || all params: 124,734,720 || trainable%: 0.2364 – PEFT confirme la mesure de la section 3 (leger denominateur different : 124,7 M car il ajoute au total les 294 912 parametres de l’adaptateur). Seuls ces 0,24 % recoivent des gradients ; le reste du modele est gele.
  • Perte finale : 5.0025 – sur 7 exemples, 10 epochs. Ce chiffre n’est pas une performance a etaler : il confirme surtout que l’optimisation a tourne sans diverger. Ce sera la valeur de reference de l’exercice 3.
  • Temps : ... – sur RTX 3090 (runtime machine-dep), dix epochs sur GPT-2 en LoRA tiennent en une dizaine de secondes. C’est l’argument operationnel de LoRA : le meme budget en full fine-tuning devrait aussi porter les gradients et les etats d’optimiseur des 124 M parametres.

Les avertissements (GPU imbalance, NCCL, loss_type=None) sont ceux d’une station multi-GPU heterogene – ils documentent l’environnement, rien de l’entraînement lui-meme. La discipline de lecture : un warning s’explique avant de s’elimine.

5. Evaluation avant / après

Le seul moyen honnête de mesurer l’effet d’un fine-tuning est une comparaison causale avant/après : même modèle de base, même prompt, même graine aléatoire — seul l’adaptateur LoRA diffère. Tout écart de style entre les deux générations est alors attribuable au fine-tuning, pas au hasard de l’échantillonnage.

Nous rechargeons le GPT-2 original (sans adaptateur) et nous émettons le même prompt des deux côtés. Le critère est qualitatif mais précis : le modèle original produit un français générique et souvent incohérent (GPT-2 n’a été pré-entraîné qu’en anglais), tandis que le modèle affiné devrait adopter le registre des Misérables — phrases plus longues, tournures littéraires, vocabulaire du XIXe siècle. Sur un dataset aussi petit (7 segments d’entraînement), l’effet est partiel : attendez-vous à un changement de registre perceptible, pas à un pastiche parfait de Victor Hugo.

Pourquoi une évaluation causale avant/après

L’évaluation naïve — prendre un seul prompt et comparer une génération du modèle original à une génération du modèle fine-tuné — est piégeuse. Elle confond deux effets : le changement de style induit par le fine-tuning, et le hasard de l’échantillonnage (top_p, temperature). Avec temperature=0.8 et do_sample=True, deux appels successifs au même modèle sur le même prompt produisent des sorties différentes. Si on ne compare qu’une seule génération de chaque côté, on ne sait pas si l’écart observé est dû au fine-tuning ou au RNG.

La bonne pratique, appliquée ici : garder le prompt identique et les paramètres de génération identiques (temperature=0.8, top_p=0.9, max_new_tokens=80), et ne faire varier qu’un seul facteur entre les deux côtés : la présence ou l’absence de l’adaptateur LoRA. Une réserve honnête toutefois : la génération est échantillonnée (do_sample=True) et generate_text ne re-fixe pas la graine avant chaque appel — chaque côté est donc un tirage unique, pas une moyenne. L’écart observé est indicatif ; pour une isolation causale stricte, re-fixez torch.manual_seed(...) immédiatement avant chacun des deux appels.

Ce qu’on attend, ce qu’on n’attend pas — le cadre avant de lire les générations

Ce notebook fine-tune sur 7 exemples. Il faut donc lire les deux générations de la section 5 avec le bon niveau d’exigence :

On attend (le mécanisme) On n’attend pas (la maîtrise)
que le format du corpus soit appris : phrases courtes et cadencées, ponctuation régulière, champ lexical resserré (rue, Paris, lune) qu’un pastiche convaincant de Victor Hugo émerge
que l’anglais du pré-entraînement recule au profit du français que la grammaire, les accords ou le sens soient corrects
qu’un artefact de sous-dimensionnement apparaisse (la répétition en boucle « l’une nouvelle ») qu’un texte fluide, varié et grammaticalement propre soit produit

Le critère de réussite atteignable à cette échelle est donc un critère de FORMAT, pas de contenu. Ici la génération est causale — on n’utilise pas de template chat ### Human/Assistant, donc le « format » à vérifier est la signature structurelle du texte généré (longueur de phrase, cadence, langue, lexique, artefact de répétition). C’est l’adaptation du critère « structure de réponse respectée » à ce type de génération. Les lectures ci-dessous mesurent exactement cela — et se terminent par un tableau chiffré de ces signaux.

# Modele original (recharger)
model_original = AutoModelForCausalLM.from_pretrained(MODEL_NAME)
model_original.eval()

def generate_text(model, prompt, max_new_tokens=80, temperature=0.8):
    inputs = tokenizer(prompt, return_tensors="pt")
    if torch.cuda.is_available():
        inputs = {k: v.to(model.device) for k, v in inputs.items()}
    with torch.no_grad():
        outputs = model.generate(
            **inputs,
            max_new_tokens=max_new_tokens,
            temperature=temperature,
            do_sample=True,
            top_p=0.9,
            pad_token_id=tokenizer.eos_token_id,
        )
    return tokenizer.decode(outputs[0], skip_special_tokens=True)

prompt = "Dans les rues sombres de Paris,"

print("=" * 60)
print("GENERATION ORIGINALE (GPT-2 pre-entraine)")
print("=" * 60)
original_output = generate_text(model_original, prompt)
print(original_output)

print("\n" + "=" * 60)
print("GENERATION APRES LoRA (fine-tune style Hugo)")
print("=" * 60)
model_to_train.eval()
finetuned_output = generate_text(model_to_train, prompt)
print(finetuned_output)
============================================================
GENERATION ORIGINALE (GPT-2 pre-entraine)
============================================================
Dans les rues sombres de Paris, l'amour d'un seul et de l'accérieur en France, l'un peut-être la monde en France, l'ambassador sombre, l'équipage, l'éclaration des consommateurs en France, l'équipage, l'équipage d'une développ

============================================================
GENERATION APRES LoRA (fine-tune style Hugo)
============================================================
Dans les rues sombres de Paris, les rues à la nouvelle à la lune.

Paris, l'une nouvelle, l'une nouvelle, l'une nouvelle, l'une nouvelle.

Pendant, l'une nouvelle, l'une nouvelle, l'une nouvelle.

Lecture du resultat : premiere generation – le style, pas la qualite

Cote original : « l’amour d’un seul et de l’accérieur en France, l’un peut-etre la monde en France, l’ambassador sombre… » – du francais approximatif, des accords inexistants, un effet salade de mots. GPT-2 small multilingual est un modele 124 M : son francais de surface est fragile.

Cote fine-tune : « les rues à la nouvelle à la lune. Paris, l’une nouvelle, l’une nouvelle, l’une nouvelle, l’une nouvelle. » – la syntaxe reste approximative, mais trois choses ont change : les phrases sont courtes, la ponctuation cadence le texte (une phrase par ligne), et le vocabulaire s’est resserre sur le champ du corpus (rues, Paris, lune) – au prix d’une repetition en boucle typique d’un adaptateur sous-dimensionne. C’est le transfert de style attendu d’un petit LoRA : il deplace la distribution vers les regularites du corpus sans ameliorer la grammaire de base. Juger un fine-tuning, c’est apprendre a lire cette difference pour ce qu’elle est.

# Second prompt pour comparer
prompt2 = "Jean Valjean marchait dans la nuit"

print("=" * 60)
print(f"Prompt : \"{prompt2}\"")
print("=" * 60)

print("\nORIGINAL :")
print(generate_text(model_original, prompt2))

print("\nFINE-TUNE :")
print(generate_text(model_to_train, prompt2))
============================================================
Prompt : "Jean Valjean marchait dans la nuit"
============================================================

ORIGINAL :
Jean Valjean marchait dans la nuit (I want you to see)

It's the moment when the truth is revealed.

(A) Vigour de vivre, je vous répondent qu'on avait, avec le journée à ce soir à l'hôtel, je vous vous avez pas avait.

(B) Vig

FINE-TUNE :
Jean Valjean marchait dans la nuit à la vie est réponse une réputation des fois en France, cette doute leur leur des mains, qui la comme de vie de la cette sommage.

The question was:

What were the effects of the French revolution on France's culture?

What did the French revolution have on French culture?

Le critère de format, mesuré sur les deux générations

Le cadre ci-dessus promettait un critère de format, pas de contenu. Mesurons-le sur les sorties réelles des deux prompts, avant vs après LoRA :

Signal de format Original (pré-entraîné) Après LoRA (style Hugo) Ce que ça dit
Longueur de phrase phrases-salade enchaînées (« l’amour d’un seul et de l’accérieur en France, l’un peut-être la monde ») phrases courtes et détachées (« les rues à la nouvelle à la lune. ») le rythme du corpus est capté
Cadence / ponctuation virgules en rafale, pas de découpage net phrases courtes et détachées, une par ligne (1ᵉʳ prompt) la structure du corpus est approchée
Langue bascule en anglais en plein prompt français (« (I want you to see) ») français dominant, l’anglais en recul net — un résidu anglophone affleure en fin de 2ᵉ prompt le lexique du corpus déplace celui du pré-entraînement sans l’évacuer entièrement
Lexique du corpus champ large, hors corpus (« ambassador », « consommateurs ») resserré sur le corpus (« rue », « Paris », « lune », « vie ») le champ lexical cible émerge
Artefact de sous-dimensionnement absent répétition en boucle « l’une nouvelle » (1ᵉʳ prompt), « leur leur » (2ᵉ prompt) signature d’un adaptateur sous-dimensionné sur 7 exemples

Ce que l’échantillon committé montre : côté fine-tune, quatre signaux de format sont réunis (phrases courtes, cadence, lexique resserré, répétition d’artefact) et la langue reste dominante en français avec un résidu anglophone ; la maîtrise, elle, n’est pas au rendez-vous (grammaire, accords, sens — « cette doute leur leur des mains »). Ces observations sont compatibles avec un transfert de format par l’adaptateur, sans l’isoler causalement — un tirage unique par côté, cf. la réserve de la section 5. Les deux colonnes répondent à la question posée par le cadrage : à 7 exemples, on exige le format, pas la maîtrise — et on le lit sur ces cinq signaux — quatre nets, la langue en recul marqué sans être totale. C’est ce qui distingue un fine-tuning jugé (par un critère checkable) d’un fine-tuning ressenti (par impression).

Lecture du resultat : l’anglais recule tres nettement – sans disparaitre tout a fait

Ce deuxieme prompt donne la comparaison la plus parlante du notebook :

  • ORIGINAL : « Jean Valjean marchait dans la nuit (I want you to see) It’s the moment when the truth is revealed. » – le modele bascule en anglais au milieu d’un prompt francais, et enchaîne des restes de dialogues anglophones (A) Vigour de vivre....
  • FINE-TUNE : « …à la vie est réponse une réputation des fois en France, cette doute leur leur des mains, qui la comme de vie de la cette sommage. » – imparfait, mais le corps narratif reste en francais, avec une cadence de phrase longue typique du corpus Hugo ; la generation se termine toutefois par un residu anglophone (« The question was: What were the effects of the French revolution… »).

Le prompt est identique et les parametres de generation sont ceux de la fonction generate_text ; chaque cote reste neanmoins un tirage echantillonne unique (la graine n’est pas re-fixee avant chaque appel – cf. la reserve de la section 5). Dans ce tirage commite, le corps de la generation fine-tune reste en francais la ou l’original basculait immediatement en anglais, avec un residu anglophone en finale : le contraste est indicatif, faute de re-echantillonnage controle – un tirage par cote, pas une moyenne ; seul un protocole multi-tirages a graine fixee permettrait d’attribuer l’ecart a l’adaptateur LoRA de 1,18 Mo.

6. Taille des adaptateurs LoRA

C’est ici que l’avantage de LoRA devient opérationnel, pas seulement théorique. Les matrices de bas rang apprises (\(B \times A\), rang \(r=8\)) ne pèsent qu’une fraction de pourcent du modèle complet : ~1,2 Mo contre ~498 Mo pour GPT-2 entier. Le code ci-dessous sauvegarde l’adaptateur seul et mesure la taille réelle du fichier .safetensors produit, à comparer avec la taille calculée du modèle complet en fp32.

La conséquence pour le déploiement est décisive : un seul modèle de base de 498 Mo chargé en VRAM, plus N adaptateurs de ~1 Mo chacun sur disque, permet de servir N tâches ou N styles distincts en interchangeant l’adaptateur à la volée — au lieu de maintenir N copies de 498 Mo en mémoire. C’est ce qui fait de LoRA le standard industriel pour servir plusieurs spécialisations d’un même grand modèle derrière une seule instance servie.

import tempfile

# Sauvegarder l'adaptateur LoRA
adapter_dir = os.path.join(tempfile.gettempdir(), "ft01_lora_adapter")
model_to_train.save_pretrained(adapter_dir)

# Taille totale du modele original vs adaptateur
adapter_size = sum(
    os.path.getsize(os.path.join(adapter_dir, f))
    for f in os.listdir(adapter_dir)
    if f.endswith(".safetensors") or f.endswith(".bin")
)

model_size_mb = total_params * 4 / 1e6  # float32
adapter_size_mb = adapter_size / 1e6

print(f"Modele complet (GPT-2) : {model_size_mb:.1f} Mo")
print(f"Adaptateur LoRA seul  : {adapter_size_mb:.2f} Mo")
print(f"Ratio adaptateur/modele : {adapter_size_mb/model_size_mb*100:.2f}%")
print(f"\nPour deployer : il suffit du modele de base + adaptateur ({adapter_size_mb:.1f} Mo)")
Modele complet (GPT-2) : 497.8 Mo
Adaptateur LoRA seul  : 1.18 Mo
Ratio adaptateur/modele : 0.24%

Pour deployer : il suffit du modele de base + adaptateur (1.2 Mo)

Lecture du resultat : 1,18 Mo contre 497,8 Mo

Modele complet (GPT-2) : 497.8 Mo – les 124 439 808 parametres en float32. Adaptateur LoRA seul : 1.18 Mo – les matrices A et B de rang 8 apprises, soit un ratio de 0,24 % coherant avec la fraction entrenable mesuree en section 3 (ce n’est pas un hasard : en LoRA, la taille de l’adaptateur est proportionnelle au nombre de parametres entrenables).

L’implication operationnelle est dans la derniere ligne : pour deployer, il suffit de distribuer le modele de base (telechargeable une fois, partageable entre tous vos adaptateurs) plus un fichier de 1,18 Mo par style appris. Dix styles Hugo, Zola, Sand… = dix adaptateurs de ~1 Mo sur un seul GPT-2. C’est le modele de distribution que rendent possible les adaptateurs – et que FT-02 poussera encore en quantizant le modele de base lui-meme.

7. Nettoyage memoire GPU

del model_original, model_to_train, trainer
gc.collect()
if torch.cuda.is_available():
    torch.cuda.empty_cache()
    print(f"VRAM libre : {torch.cuda.mem_get_info()[0] / 1e9:.1f} Go")
else:
    print("Nettoyage termine")
VRAM libre : 24.4 Go

Lecture du resultat : la memoire rendue

VRAM libre : 24.4 Go apres suppression des references et empty_cache(). Sur les ~25,8 Go vus en section 1, l’entrainement LoRA complet n’avait donc occupe qu’une fraction visible de la carte. Cette cellule n’est pas de la decoration : dans un notebook suivant, le premier reflexe est de rendre la VRAM avant de charger le prochain modele – sinon l’OutOfMemoryError arrive a la cellule d’apres, tres loin de sa vraie cause.

8. Exercices

Appliquez les concepts de ce notebook.

Chaque exercice reutilise une cellule du corps du notebook comme point d’appui : l’exercice 1 reprend le tableau de la section 3 (les 294 912 parametres de r=8 ne sont qu’un point de la courbe), l’exercice 2 remplace le corpus de la section 4, l’exercice 3 fait varier le learning rate autour de la perte finale mesuree en section 4. Les stubs s’executent sans erreur : ils affichent un message d’attente et rendent la main.

Préparation des exercices

Les trois exercices de cette section sont progressifs : ils commencent par faire varier un seul hyperparamètre en gardant le reste du pipeline inchangé (rangs LoRA), puis élargissent à un changement de corpus (style littéraire), et terminent par une exploration plus risquée mais très pédagogique (learning rate). Gardez à l’esprit la contrainte de ressources : un entraînement de 10 époques sur 7 segments avec GPT-2 prend une dizaine de secondes sur un RTX 3090. Vous pouvez donc itérer rapidement — l’idée est d’observer des tendances, pas de publier un résultat.

Pour chaque exercice, mesurez avant de conclure : comptez les paramètres entraînables, affichez la perte finale, et surtout comparez visuellement deux générations plutôt que de vous fier à un chiffre de perte. La perte est un proxy nécessaire mais notoirement insuffisant pour évaluer un modèle de langage : un modèle peut avoir une perte basse et générer du texte incohérent, et inversement.

# Exercice 1 : Exploration du rang LoRA
# TODO etudiant : Creez 3 configurations LoRA avec r=2, r=8, r=32
# et comparez le nombre de parametres entrainables pour chacune.
# Quel rang donne le meilleur ratio performance/taille ?

print("Exercice a completer : comparez les rangs LoRA r=2, 8, 32")
Exercice a completer : comparez les rangs LoRA r=2, 8, 32
# Exercice 2 : Fine-tuning sur un autre style
# TODO etudiant : Remplacez le dataset des Miserables par un extrait
# d'un autre auteur (ex: Baudelaire, Verlaine) et relancez l'entrainement.
# Observez comment le style de generation change.

print("Exercice a completer : fine-tunez sur un style different")
Exercice a completer : fine-tunez sur un style different

Exercice 3 : Hyperparametres d’entrainement

Le learning rate est l’hyperparametre le plus sensible du fine-tuning. Un LR trop faible = convergence lente, un LR trop eleve = divergence et degradation du modèle. Testez différentes valeurs pour trouver le point optimal.

Point de reference mesure : avec la configuration du corps du notebook (10 epochs, batch 2), la perte finale mesuree est de 5.0025 (12,3 s sur la machine d’execution, valeur machine-dependante) – c’est la valeur a battre (ou a degrader volontairement) en faisant varier le learning rate. Un learning_rate trop eleve fait typiquement diverger la perte au-dela de cette reference des les premiers steps, un taux trop faible la laisse stagner sans l’atteindre.

# Exercice 3 : Impact du learning rate
# TODO etudiant : Entrainnez avec learning_rate = 1e-5, 5e-4, 1e-3.
# Observez la perte finale et la qualite de generation.
# Que se passe-t-il avec un learning rate trop eleve ?

print("Exercice a completer : comparez les learning rates")
Exercice a completer : comparez les learning rates

Resume

Approche Params entrainables VRAM requise Stockage adaptateur
Full Fine-Tuning 100 % (124 439 808) 3-4x taille modèle Modèle complet
Partial (2 couches) 42,41 % (52 773 120) ~1.5x taille modèle Couches modifiees
LoRA (r=8) 0,24 % (294 912) ~1.1x taille modèle 1,18 Mo

Points cles : - LoRA decompose la mise a jour en matrices de bas rang \(B \times A\) ou \(r \ll d, k\) - Seuls 0,24 % des paramètres sont entraines (mesure section 3 ; trainable%: 0.2364 cote PEFT) ; ce notebook n’execute pas de full fine-tuning de comparaison, aucune parité de qualité n’est donc mesurée ici - Les adaptateurs sont portables : un seul modèle de base + N adaptateurs pour N tâches - Le rang \(r\) contrôle le compromis expressivite/efficacite

Prochaines étapes (FT-02) : LoRA sur un modèle plus grand avec quantization 4-bit (QLoRA).

Ce que ce notebook a mesure, pas seulement annonce : 124 439 808 parametres totaux pour GPT-2 ; 294 912 parametres entrainables en LoRA r=8 (0,24 %, trainable%: 0.2364 cote PEFT) ; un adaptateur de 1,18 Mo contre 497,8 Mo pour le modele complet ; et, dans ce tirage commite, une generation apres fine-tuning majoritairement en francais – l’anglais du pre-entrainement y recule sans etre evacue totalement – la ou le modele original basculait en anglais : contraste indicatif faute de re-echantillonnage controle (cf. la reserve de la section 5). La suite logique est FT-02 : quantizer le modele de base en 4 bits (QLoRA) pour entrainer ces memes adaptateurs sur une fraction de la VRAM.

Retour au sommet