iducationducation
IndexArticlesFormationsProfilOutilsBibliothech
N°014 — 2026
Navigation
01Index02Articles03Formations04Profil05Outils06Bibliothech
N°014 — 2026
iducationducation
IndexArticlesFormationsProfilOutilsBibliothech
N°014 — 2026
Navigation
01Index02Articles03Formations04Profil05Outils06Bibliothech
N°014 — 2026
Formations
Python · Intermédiaire

Entraîner son IA en local

Créer chez soi une IA experte d'un domaine : choisir le matériel et le LLM de base, préparer les données, construire un RAG, fine-tuner en QLoRA, évaluer et déployer avec Ollama.

OllamaUnslothHugging FaceChromaDBQLoRAGGUF
01Pré-entraînement, fine-tuning, RAG : ce qu'on peut vraiment faire chez soi02Matériel et environnement de travail03Choisir le LLM de départ04Préparer les données : sources, nettoyage et formats05Construire le RAG local06Fine-tuner le modèle en QLoRA07Évaluer : savoir si l'IA répond vraiment bien08Exporter, déployer avec Ollama et faire évoluer l'IA
Chapitre 6·60 min

Fine-tuner le modèle en QLoRA

C'est le moment où l'on modifie réellement le modèle. Avec QLoRA, un modèle de 8 milliards de paramètres se spécialise sur une carte de 12 Go en une à deux heures. Ce chapitre explique ce qui se passe pendant l'entraînement, prépare des données adaptées au RAG, lance l'entraînement avec Unsloth et apprend à lire les courbes pour savoir quand s'arrêter.

LoRA et QLoRA : entraîner 1 % du modèle

Un fine-tuning complet modifie tous les poids : pour un 8B, il faudrait plus de 60 Go de VRAM (poids, gradients, états de l'optimiseur). Impossible sur une carte grand public.

LoRA (Low-Rank Adaptation) gèle les poids d'origine et ajoute à certaines couches deux petites matrices entraînables. Leur produit représente la modification apportée au modèle. On entraîne ainsi 0,5 à 2 % des paramètres, et le résultat est un adaptateur de quelques dizaines de Mo.

QLoRA va plus loin : le modèle gelé est chargé en 4 bits, les adaptateurs restent en 16 bits. La mémoire nécessaire chute d'un facteur 3 à 4, pour une qualité très proche d'un LoRA classique.

Poids d'origine (gelés, 4 bits)  W   ─────────┐
                                              ├──►  sortie = W·x + (B·A)·x
Adaptateur LoRA (entraîné, 16 bits)  B·A  ────┘
     A : d × r      B : r × d      r = rang, typiquement 8 à 64

Quels hyperparamètres

ParamètreRôleValeur de départ
r (rang)capacité de l'adaptateur16 ; 32-64 pour beaucoup de données
lora_alphaforce de l'adaptationégal à r ou 2 × r
target_modulescouches modifiéestoutes les projections d'attention et du MLP
learning_ratetaille des pas d'apprentissage2e-4 ; 1e-4 si le modèle se dégrade
num_train_epochspassages sur les données1 à 3
batch effectifexemples par mise à jour8 à 16 (batch_size × gradient_accumulation)
max_seq_lengthlongueur maximale d'un exemple2048 ; 4096 avec des passages RAG

Ne cherchez pas les réglages parfaits au premier essai. Ces valeurs donnent un bon résultat dans la grande majorité des cas ; ce sont les données qui feront la différence.

Préparer des données « RAG-aware »

Si l'assistant final utilise le RAG, entraînez-le dans les mêmes conditions : chaque exemple contient des passages récupérés, et la réponse s'appuie dessus. Le modèle apprend alors précisément la compétence dont il aura besoin : lire des documents, extraire la bonne information, ignorer les passages hors sujet, et avouer quand la réponse n'y est pas.

Cette idée vient de la méthode RAFT (Retrieval-Augmented Fine-Tuning). On mélange :

  • 80 % d'exemples où le passage source est présent parmi des passages « distracteurs »
  • 20 % d'exemples où le passage source a été retiré : la réponse attendue dit alors honnêtement que les documents ne couvrent pas la question
rag/construire_train_rag.py
import json
import random
from pathlib import Path
 
from recherche import rechercher
 
random.seed(42)
passages = {p["id"]: p for p in map(json.loads, Path("data/chunks.jsonl").read_text(encoding="utf-8").splitlines())}
 
SYSTEME = """Tu es un assistant expert du potager. Tu réponds en français, de façon précise, pratique et concise.
Appuie-toi sur les documents fournis. S'ils ne contiennent pas la réponse, dis-le clairement et n'invente rien."""
 
SANS_SOURCE = ("Mes documents ne couvrent pas précisément ce point, je préfère ne pas te donner "
               "une information approximative. {piste}")
 
def construire(ex: dict) -> dict:
    question = ex["messages"][1]["content"]
    reponse = ex["messages"][2]["content"]
    source = passages[ex["source_id"]]
 
    distracteurs = [r for r in rechercher(question, k=6) if r["texte"] != source["texte"]][:4]
    docs = [{"source": d["source"], "texte": d["texte"]} for d in distracteurs]
 
    if random.random() < 0.8:
        docs.insert(random.randint(0, len(docs)), {"source": source["source"], "texte": source["texte"]})
        reponse_finale = f"{reponse}\n\nSource : [{source['source']}]"
    else:
        reponse_finale = SANS_SOURCE.format(
            piste="Si tu me précises ton contexte (région, variété, période), je peux chercher plus précisément."
        )
 
    contexte = "\n\n".join(f"[{d['source']}]\n{d['texte']}" for d in docs)
    return {"messages": [
        {"role": "system", "content": SYSTEME},
        {"role": "user", "content": f"<documents>\n{contexte}\n</documents>\n\nQuestion : {question}"},
        {"role": "assistant", "content": reponse_finale},
    ]}
 
for nom in ["train", "val"]:
    exemples = [json.loads(l) for l in Path(f"data/{nom}.jsonl").read_text(encoding="utf-8").splitlines() if l.strip()]
    sortie = [construire(e) if "source_id" in e else e for e in exemples]   # exemples manuels gardés tels quels
    Path(f"data/{nom}_rag.jsonl").write_text(
        "\n".join(json.dumps(s, ensure_ascii=False) for s in sortie), encoding="utf-8"
    )
    print(nom, len(sortie))

Les exemples écrits à la main (refus hors sujet, demandes de précision) n'ont pas de source_id et passent sans modification.

Utilisez exactement le même prompt système et le même format de documents que dans assistant.py. Un modèle entraîné sur un format et utilisé avec un autre perd une bonne partie du bénéfice.

Le script d'entraînement

Dans l'environnement train/ créé au chapitre 2 :

train/entrainer.py
from datasets import load_dataset
from trl import SFTConfig, SFTTrainer
from unsloth import FastLanguageModel, is_bfloat16_supported
from unsloth.chat_templates import train_on_responses_only
 
MODELE = "unsloth/Qwen3-8B"
MAX_LEN = 4096
 
# 1. Charger le modèle en 4 bits
model, tokenizer = FastLanguageModel.from_pretrained(
    model_name=MODELE,
    max_seq_length=MAX_LEN,
    load_in_4bit=True,
)
 
# 2. Ajouter les adaptateurs LoRA
model = FastLanguageModel.get_peft_model(
    model,
    r=16,
    lora_alpha=16,
    lora_dropout=0,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
    use_gradient_checkpointing="unsloth",
    random_state=42,
)
 
# 3. Charger et formater les données avec le chat template du modèle
def formater(lot):
    return {"text": [
        tokenizer.apply_chat_template(msgs, tokenize=False, add_generation_prompt=False)
        for msgs in lot["messages"]
    ]}
 
train = load_dataset("json", data_files="../data/train_rag.jsonl", split="train").map(formater, batched=True)
val = load_dataset("json", data_files="../data/val_rag.jsonl", split="train").map(formater, batched=True)
 
print(train[0]["text"])   # TOUJOURS vérifier un exemple formaté avant d'entraîner
 
# 4. Configurer l'entraînement
trainer = SFTTrainer(
    model=model,
    processing_class=tokenizer,
    train_dataset=train,
    eval_dataset=val,
    args=SFTConfig(
        dataset_text_field="text",
        per_device_train_batch_size=2,
        gradient_accumulation_steps=4,
        num_train_epochs=2,
        learning_rate=2e-4,
        lr_scheduler_type="cosine",
        warmup_steps=10,
        optim="adamw_8bit",
        weight_decay=0.01,
        bf16=is_bfloat16_supported(),
        fp16=not is_bfloat16_supported(),
        logging_steps=10,
        eval_strategy="steps",
        eval_steps=50,
        save_strategy="steps",
        save_steps=50,
        save_total_limit=3,
        output_dir="../models/checkpoints",
        report_to="none",
        seed=42,
    ),
)
 
# 5. N'apprendre que sur les réponses de l'assistant
trainer = train_on_responses_only(
    trainer,
    instruction_part="<|im_start|>user\n",
    response_part="<|im_start|>assistant\n",
)
 
# 6. Entraîner et sauvegarder l'adaptateur
trainer.train()
model.save_pretrained("../models/potager-lora")
tokenizer.save_pretrained("../models/potager-lora")
cd train
uv run entrainer.py

Les bibliothèques d'entraînement évoluent très vite et certains noms de paramètres changent d'une version à l'autre. Si un argument est refusé, comparez avec les notebooks officiels d'Unsloth pour votre modèle : ils sont tenus à jour à chaque nouvelle sortie.

Pourquoi train_on_responses_only

Par défaut, le modèle apprend à prédire tout le texte, y compris les passages et les questions. Ce n'est pas ce qu'on veut : il doit apprendre à produire les réponses, pas à réciter les documents. train_on_responses_only masque tout sauf les réponses de l'assistant dans le calcul de l'erreur.

Les balises instruction_part et response_part dépendent du chat template. Celles ci-dessus sont celles de Qwen ; pour Llama 3, ce seraient <|start_header_id|>user<|end_header_id|>\n\n et <|start_header_id|>assistant<|end_header_id|>\n\n. Le print(train[0]["text"]) vous montre les balises exactes à utiliser.

Lire les courbes d'apprentissage

Pendant l'entraînement, deux valeurs s'affichent :

  • training loss : l'erreur sur les données d'entraînement
  • eval loss : l'erreur sur les données de validation, jamais vues
Step   Training Loss   Validation Loss
  50        1.284           1.201
 100        0.962           1.018
 150        0.871           0.975
 200        0.803           0.969   ← la validation stagne
 250        0.694           0.991   ← la validation remonte : surapprentissage
 300        0.581           1.043

Interprétation :

SituationDiagnosticAction
les deux baissentapprentissage saincontinuer
train baisse, validation stagneon atteint la limite utiles'arrêter, garder ce checkpoint
train baisse, validation remontesurapprentissage : le modèle réciterevenir au meilleur checkpoint, moins d'époques
train ne baisse pastaux d'apprentissage trop faible ou données mal formatéesvérifier le formatage, augmenter le learning rate
train tombe près de 0mémorisation pureréduire époques, rang ou learning rate

Les checkpoints sauvegardés tous les 50 pas permettent de revenir au meilleur point. Dans l'exemple, c'est le pas 200.

Tester l'adaptateur immédiatement

Avant toute conversion, un test rapide dans Python :

train/tester.py
from unsloth import FastLanguageModel
 
model, tokenizer = FastLanguageModel.from_pretrained("../models/potager-lora", max_seq_length=4096, load_in_4bit=True)
FastLanguageModel.for_inference(model)
 
messages = [
    {"role": "system", "content": "Tu es un assistant expert du potager. Tu réponds en français, de façon précise, pratique et concise.\nAppuie-toi sur les documents fournis. S'ils ne contiennent pas la réponse, dis-le clairement et n'invente rien."},
    {"role": "user", "content": "<documents>\n</documents>\n\nQuestion : Comment réparer l'embrayage de ma voiture ?"},
]
entrees = tokenizer.apply_chat_template(messages, add_generation_prompt=True, return_tensors="pt").to("cuda")
sortie = model.generate(input_ids=entrees, max_new_tokens=300, temperature=0.2, do_sample=True)
print(tokenizer.decode(sortie[0][entrees.shape[1]:], skip_special_tokens=True))

Testez une question du domaine, une question hors sujet et une question dont la réponse n'est pas dans les documents. Trois questions suffisent à repérer un entraînement raté (réponses en boucle, mauvaise langue, balises parasites).

Les pièges classiques

L'oubli catastrophique. Un fine-tuning trop agressif fait perdre au modèle des capacités générales : il écrit moins bien, raisonne moins bien, ou ne sait plus parler d'autre chose. Signes : réponses répétitives, formulations figées. Remèdes : learning rate plus bas, moins d'époques, et mélanger 10 à 20 % d'exemples de conversation générale dans le jeu d'entraînement.

Le modèle qui répète toujours la même structure. Vos exemples synthétiques se ressemblent trop. Diversifiez la consigne de génération, ou utilisez plusieurs modèles générateurs.

CUDA out of memory. Dans l'ordre : réduire per_device_train_batch_size à 1 (en augmentant gradient_accumulation_steps pour garder le même batch effectif), réduire MAX_LEN, passer à un modèle plus petit.

Le template d'entraînement différent du template de service. Déjà évoqué au chapitre 3, et on le vérifiera au chapitre 8. C'est la première chose à regarder quand un modèle excellent dans Python devient mauvais dans Ollama.

Pour aller plus loin : l'alignement par préférences

Une fois le SFT (supervised fine-tuning) terminé, on peut affiner encore avec DPO (Direct Preference Optimization) : pour une même question, on montre au modèle une bonne et une mauvaise réponse. Il apprend à préférer la première. C'est très efficace pour corriger des défauts précis repérés à l'évaluation : trop verbeux, trop sûr de lui, mauvais format. TRL fournit un DPOTrainer qui s'utilise comme le SFTTrainer. Je ne le recommande qu'une fois le reste stabilisé.


QLoRA rend le fine-tuning accessible sur une carte de joueur, mais ce n'est pas l'entraînement qui fait la qualité : ce sont des données au bon format, identiques aux conditions réelles d'utilisation, et la discipline de s'arrêter quand la courbe de validation remonte. La seule façon de savoir si le modèle est meilleur, c'est de le mesurer : c'est l'objet du chapitre suivant.

Précédent
Construire le RAG local
Suivant
Évaluer : savoir si l'IA répond vraiment bien

Développeur fullstack passionné. J'apprends en construisant et je documente tout — front, back, outils. Le code s'apprend mieux en public.

Naviguer

IndexTous les articlesFormationsProfilOutilsBibliothech

Ailleurs

GitHub RSS

Newsletter

Les articles, libs et découvertes. Une fois par semaine, pas plus.

© 2026 William LoreeConçu & codé à la main