Fine-tuning LLM : guide pratique LoRA
Guide pratique du fine-tuning LLM avec LoRA : code qui tourne, préparation des données, évaluation, et quand préférer le RAG.

Ce guide prend un modèle de base et lui apprend un comportement qu’il n’a pas par défaut, avec LoRA sur un seul GPU. Vous récupérez le code réel : mise en forme des données, un entraînement PEFT avec Hugging Face, l’évaluation et l’inférence. À la fin, vous reproduisez tout sur votre propre dataset.
La plupart des équipes qui pensent avoir besoin de fine-tuning ont en fait besoin de récupération. Le fine-tuning change la façon dont un modèle se comporte ; il n’ajoute pas de façon fiable des faits que vous pourriez aller chercher. Tranchez bien cet arbitrage et le reste est mécanique.
Fine-tuning ou RAG : comment choisir
Le fine-tuning est le bon outil quand le problème est le comportement : vous voulez chaque réponse dans un schéma JSON strict, un ton maison cohérent, une étiquette de classification, ou un dialecte métier que le modèle de base écorche. La connaissance est déjà latente ; vous modelez la sortie.
LoRA : un petit adaptateur de rang faible à côté des poids gelés
Pour trancher vite :
- Si un fait nouveau le mois prochain impose un réentraînement, vous voulez du RAG.
- Si vous ne savez pas décrire la sortie attendue sous forme de règle mais que vous pouvez en montrer 500 exemples, vous voulez du fine-tuning.
- S’il vous faut à la fois des faits corrects et un format de sortie rigide, faites du RAG pour les faits et du fine-tuning pour le format. Les deux se combinent proprement.
Les deux ne sont pas rivaux. Une bonne partie des demandes de fine-tuning qui atterrissent dans la file d’une équipe ML sont en réalité des problèmes de récupération déguisés en fine-tuning : on demande au modèle de connaître un fait plutôt que de se comporter d’une certaine manière. Faites du fine-tuning sur des faits et vous obtenez un modèle qui en invente de nouveaux, calqués pile sur la forme de vos données d’entraînement.
Ce que fait LoRA, en un paragraphe
Le fine-tuning complet met à jour tous les poids du modèle. Pour un modèle 7B en fp16, cela représente environ 14 Go rien que pour les poids, plus l’état de l’optimiseur et les gradients, ce qui pousse les besoins mémoire réels au-delà de 60 à 80 Go. LoRA gèle les poids d’origine et entraîne à la place deux petites matrices de faible rang par couche ciblée, si bien que vous mettez à jour bien moins de 1 % des paramètres. Cela fait tenir un fine-tuning de 7B sur un seul GPU de 16 à 24 Go, et le résultat est un petit adaptateur (quelques dizaines de Mo) que vous chargez par-dessus le modèle de base. Quantifier d’abord la base gelée en 4 bits (QLoRA) abaisse encore le plancher, assez bas pour un Colab T4 gratuit sur les plus petits modèles.
Étape 1 : environnement et dépendances
Figez les versions. Les API de PEFT et de TRL bougent d’une release à l’autre (TRL en particulier a franchi une frontière de version majeure, de 0.x à 1.x, avec des changements sur l’interface de SFTTrainer) et une stack désaccordée est la raison la plus courante pour laquelle un snippet copié échoue. Les versions ci-dessous sont à jour en juin 2026 ; vérifiez sur PyPI avant de figer.
pip install "transformers>=4.51.0" \
"peft==0.19.0" \
"trl==1.5.1" \
"datasets>=3.3.0" \
"accelerate>=1.4.0" \
"bitsandbytes==0.49.2"
bitsandbytes fournit la quantification 4 bits pour QLoRA et exige un GPU CUDA. Sur Apple Silicon, passez-le et faites tourner LoRA sans quantification ; le backend MPS gère les petits modèles. Vérifiez que votre GPU est bien visible avant d’aller plus loin :
import torch
print(torch.cuda.is_available(), torch.cuda.get_device_name(0) if torch.cuda.is_available() else "CPU/MPS")
Sortie attendue sur une machine CUDA :
True NVIDIA GeForce RTX 4090
Étape 2 : préparer le dataset
C’est ici que les projets se gagnent ou se perdent. Le modèle apprend exactement le format que vous lui donnez, vos erreurs comprises, donc le dataset doit ressembler à ce que vous voulez obtenir à l’inférence.
Nous allons faire du fine-tuning d’instruction following : prompt d’entrée, réponse attendue. Le plus propre est de mettre en forme chaque exemple avec le chat template du modèle de base lui-même, pour que l’entraînement et l’inférence utilisent un formatage identique. Ici, on prend une petite tranche d’un dataset d’instructions public ; remplacez-la par votre propre JSONL avec les champs instruction et response.
from datasets import load_dataset
from transformers import AutoTokenizer
model_id = "mistralai/Mistral-7B-v0.3"
tokenizer = AutoTokenizer.from_pretrained(model_id)
tokenizer.pad_token = tokenizer.eos_token # Mistral has no pad token by default
raw = load_dataset("databricks/databricks-dolly-15k", split="train[:2000]")
def to_chat(example):
user = example["instruction"]
if example.get("context"):
user += f"\n\nContext:\n{example['context']}"
messages = [
{"role": "user", "content": user},
{"role": "assistant", "content": example["response"]},
]
text = tokenizer.apply_chat_template(messages, tokenize=False)
return {"text": text}
dataset = raw.map(to_chat, remove_columns=raw.column_names)
dataset = dataset.train_test_split(test_size=0.1, seed=42)
print(dataset["train"][0]["text"][:300])
Quelques règles qui comptent plus que le choix du modèle :
- Quelques centaines d’exemples propres et cohérents valent mieux que des dizaines de milliers d’exemples bruités. Lisez un échantillon au hasard à la main avant d’entraîner ; vous y trouverez des prompts dupliqués et des réponses tronquées.
- Réservez un vrai split de test (le
test_size=0.1ci-dessus). Vous ne pouvez pas juger un fine-tuning sur la seule loss d’entraînement. - Gardez une distribution honnête. Si 95 % des exemples d’entraînement sont des réponses courtes, le modèle rechignera à en écrire de longues.
Étape 3 : charger le modèle en 4 bits
Chargez le modèle de base quantifié en 4 bits pour qu’il tienne en mémoire, puis laissez PEFT y rattacher les couches LoRA entraînables par-dessus.
import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig, prepare_model_for_kbit_training, get_peft_model
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
model = AutoModelForCausalLM.from_pretrained(
model_id,
quantization_config=bnb_config,
device_map="auto",
)
model = prepare_model_for_kbit_training(model)
lora_config = LoraConfig(
r=16,
lora_alpha=32,
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
Sortie attendue :
trainable params: 13,631,488 || all params: 7,261,655,040 || trainable%: 0.1877
Ces 0,19 %, c’est tout l’enjeu. Le r (rang) contrôle la capacité de l’adaptateur : 8 à 16 suffisent pour la plupart des tâches ; ne montez à 32-64 que quand le comportement est réellement complexe. Par convention, lora_alpha vaut le double du rang. target_modules couvre ici les projections d’attention, ce qui est l’ensemble de départ standard ; ajouter les projections du MLP (gate_proj, up_proj, down_proj) augmente la capacité au prix de plus de mémoire.
Étape 4 : entraîner avec le SFTTrainer de TRL
Le SFTTrainer de TRL enveloppe le Trainer de Hugging Face avec des réglages par défaut sensés pour le supervised fine-tuning. Il gère la tokenisation du champ text et la boucle d’entraînement.
from trl import SFTTrainer, SFTConfig
training_args = SFTConfig(
output_dir="./mistral-lora-out",
num_train_epochs=2,
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # effective batch size = 16
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
eval_strategy="steps",
eval_steps=50,
save_steps=50,
bf16=True,
max_seq_length=1024,
dataset_text_field="text",
report_to="none",
)
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=dataset["train"],
eval_dataset=dataset["test"],
)
trainer.train()
trainer.save_model("./mistral-lora-adapter")
Ce que font les arguments clés :
learning_rate=2e-4est environ dix fois plus élevé que pour un fine-tuning complet. LoRA tolère et réclame un taux plus grand parce que très peu de paramètres bougent.gradient_accumulation_stepssimule un batch plus grand quand le GPU ne peut pas en tenir un. La taille de batch effective vautper_device_batch_size × accumulation × num_gpus.bf16=Trues’aligne sur le dtype de calcul défini dans la config de quantification. Utilisez plutôtfp16=Truesur les GPU anciens sans support bfloat16 (avant Ampere).eval_stepspasse le jeu réservé périodiquement, pour repérer le surapprentissage en temps réel.
Vous devriez voir la loss d’entraînement et la loss d’évaluation descendre toutes les deux. Quand la loss d’évaluation plafonne ou remonte alors que la loss d’entraînement continue de baisser, le modèle apprend par cœur. Arrêtez là.
{'loss': 1.842, 'epoch': 0.16}
{'loss': 1.401, 'epoch': 0.32}
{'eval_loss': 1.387, 'epoch': 0.40}
{'loss': 1.298, 'epoch': 0.48}
Si vous tombez sur une erreur CUDA out-of-memory, descendez per_device_train_batch_size à 1 et montez gradient_accumulation_steps pour garder la même taille de batch effective, ou réduisez max_seq_length. Les deux baissent la mémoire sans changer grand-chose au calcul.
Étape 5 : évaluer au-delà de la courbe de loss
La loss vous dit que le modèle s’ajuste ; elle ne vous dit pas qu’il est bon. Pour un fine-tuning de comportement, le test honnête est qualitatif et hors échantillon : faites tourner l’adaptateur sur des prompts qu’il n’a jamais vus et lisez les sorties.
from peft import PeftModel
base = AutoModelForCausalLM.from_pretrained(
model_id, quantization_config=bnb_config, device_map="auto"
)
model = PeftModel.from_pretrained(base, "./mistral-lora-adapter")
model.eval()
def generate(instruction):
messages = [{"role": "user", "content": instruction}]
prompt = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
out = model.generate(
**inputs, max_new_tokens=256, temperature=0.7,
do_sample=True, pad_token_id=tokenizer.eos_token_id,
)
return tokenizer.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
print(generate("Summarize the benefits of low-rank adaptation in two sentences."))
Trois vérifications à mener avant de faire confiance au résultat :
- Des prompts hors échantillon uniquement. Réutiliser les prompts d’entraînement mesure la mémorisation, pas la généralisation. Gardez un jeu que le modèle n’a jamais touché.
- Comparez au modèle de base sur les mêmes prompts. Si le fine-tuning n’est pas nettement meilleur sur votre comportement cible, c’est le dataset ou le rang qu’il faut retravailler, pas le nombre d’epochs.
- Surveillez les régressions. Forcer le fine-tuning sur une tâche peut entamer la capacité générale (oubli catastrophique). Testez quelques prompts hors sujet et vérifiez que le modèle n’est pas devenu plus bête ailleurs.
Pour les tâches à réponse mesurable (classification, extraction, sortie structurée), calculez une vraie métrique sur le split de test : accuracy, F1, ou exact-match sur le schéma JSON. Un chiffre que vous suivez d’un run à l’autre vaut mieux que de juger à l’œil.
Étape 6 : fusionner et livrer
Vous pouvez servir l’adaptateur par-dessus le modèle de base à l’exécution, ce qui garde la base partagée entre plusieurs adaptateurs. Ou fusionner l’adaptateur dans les poids pour obtenir un seul modèle autonome, plus simple à déployer et légèrement plus rapide à l’inférence.
from peft import PeftModel
from transformers import AutoModelForCausalLM
base = AutoModelForCausalLM.from_pretrained(model_id, torch_dtype="bfloat16")
merged = PeftModel.from_pretrained(base, "./mistral-lora-adapter")
merged = merged.merge_and_unload()
merged.save_pretrained("./mistral-merged")
tokenizer.save_pretrained("./mistral-merged")
Fusionnez en fp16/bf16, jamais en 4 bits ; fusionner dans des poids quantifiés dégrade la qualité. Chargez la base en pleine précision pour l’étape de fusion, même si vous avez entraîné en 4 bits. Le dossier fusionné se branche directement dans une stack de service comme vLLM ou TGI.
Les pièges qui coûtent le plus de temps
Une courte liste des échecs qui reviennent encore et encore :
- Le surapprentissage sur un petit dataset. Deux epochs suffisent souvent. Si la loss d’évaluation remonte, vous avez entraîné trop longtemps ; baissez les epochs ou ajoutez du
lora_dropout. - Entraînez et inférez avec exactement le même chat template ; un format désaccordé à l’inférence fait passer un bon fine-tuning pour cassé.
- Les mauvais
target_modules. Les noms diffèrent selon les architectures. Pour Llama et Mistral, les projections d’attention sontq_proj/k_proj/v_proj/o_proj; pour les autres familles, inspectezmodel.named_modules()et ciblez les couches linéaires. - Un run LoRA de 2 000 exemples sur un modèle 7B finit en bien moins d’une heure sur un seul A100 loué, pour quelques dollars au tarif spot. Le fine-tuning complet du même modèle réclame de la mémoire multi-GPU et coûte un ordre de grandeur de plus. Commencez par LoRA ; ne sortez le fine-tuning complet que lorsque LoRA plafonne, preuve à l’appui.
- Le pad token du tokenizer. Beaucoup de modèles de base sortent sans. Régler
pad_token = eos_tokenest le correctif standard ; l’oublier déclenche une erreur de padding en plein run.
Conclusion
La boucle de travail est petite : trancher fine-tuning ou RAG, mettre vos données au format exact de l’inférence, attacher LoRA, entraîner deux epochs en surveillant la loss d’évaluation, puis juger sur des prompts hors échantillon plutôt que sur la courbe de loss. Tout le reste, c’est régler le rang, le learning rate et les target modules autour de cette colonne vertébrale.
Commencez par le plus petit dataset viable et le rang le plus bas qui marche, puis ajoutez de la capacité seulement quand l’évaluation dit que vous en avez besoin. Si le vrai problème se révèle être des faits plutôt qu’un comportement, routez-le vers la récupération et gardez le fine-tuning pour ce qu’il fait bien.
À retenir, en un coup d’œil
FAQ
Fine-tuning ou RAG : par lequel commencer ?
Le RAG, dans la plupart des cas. Si l’objectif est que le modèle sache quelque chose (docs à jour, données internes, tout ce qui change), la récupération est moins chère, actualisable et citable. Faites du fine-tuning quand l’objectif est que le modèle se comporte d’une certaine façon (format, ton, tâche), ce que vous savez montrer en exemples mais pas injecter facilement sous forme de documents.
De combien de données ai-je besoin ?
Pour un fine-tuning de comportement avec LoRA, quelques centaines à quelques milliers d’exemples de qualité, c’est la fourchette habituelle. Passé un seuil, nettoyer et dédupliquer vos données rapporte plus qu’en ajouter. Mille exemples cohérents valent mieux que dix mille bruités, et des données bruitées enseignent activement de mauvaises habitudes.
Combien coûte le fine-tuning d’un modèle 7B ?
Avec QLoRA sur un seul A100 loué ou une 4090, un run de quelques milliers d’exemples revient généralement à quelques dollars et tient en moins d’une heure. Le coût dépend de la taille du dataset multipliée par les epochs, pas du nom du modèle. Le fine-tuning complet d’un modèle 7B coûte à peu près un ordre de grandeur de plus, parce qu’il réclame de la mémoire multi-GPU.
LoRA ou fine-tuning complet ?
LoRA pour presque tout. Il tient sur un seul GPU, produit un petit adaptateur interchangeable, et égale le fine-tuning complet sur la plupart des tâches en aval. Ne choisissez le fine-tuning complet que lorsque vous avez mesuré LoRA en deçà sur un comportement complexe et que vous avez le budget multi-GPU pour suivre.
Mon modèle fine-tuné est devenu moins bon sur les tâches générales. Pourquoi ?
L’oubli catastrophique. Entraîner durement sur une tâche étroite peut éroder la capacité générale. Atténuez-le avec moins d’epochs, un learning rate plus bas, un rang LoRA plus bas, ou en réinjectant quelques exemples généralistes dans le jeu d’entraînement pour que le modèle ne se spécialise pas au point de perdre ses compétences de base.
Photo : cottonbro studio / Pexels