Dans Finetuning vs RAG, j'expliquais pourquoi LoRA entraîne quelques millions de paramètres au lieu de plusieurs milliards. Ici, on passe au code : finetuner un modèle 7B en QLoRA (LoRA + quantification 4 bits) sur un seul GPU grand public, de l'installation jusqu'au test d'inférence.

Prérequis

Un GPU avec au moins 8 Go de VRAM pour un modèle 7B (un RTX 3060 12 Go ou mieux suffit largement grâce à la quantification 4 bits). Côté logiciel :

pip install transformers peft bitsandbytes trl accelerate datasets

1. Charger le modèle en 4 bits

BitsAndBytesConfig quantifie les poids du modèle de base au chargement — c'est ce qui fait tenir un 7B sur 8 Go au lieu de 28 Go en float32.

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig

model_id = "mistralai/Mistral-7B-v0.1"

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)

tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
    model_id,
    quantization_config=bnb_config,
    device_map="auto",
)

2. Préparer le dataset

Le format compte plus que le volume : quelques centaines d'exemples bien construits dans un format cohérent battent souvent un dataset énorme et bruité. Ici un format instruction/contexte/réponse simple :

from datasets import Dataset

exemples = [
    {
        "instruction": "Résume ce ticket support en une phrase.",
        "input": "Le client ne peut plus se connecter depuis la mise à jour de mardi, message 'session expirée' en boucle.",
        "output": "Connexion impossible après la mise à jour de mardi (boucle de session expirée).",
    },
    # ... quelques centaines d'exemples dans le même format
]

def format_prompt(ex):
    return {
        "text": f"### Instruction:\n{ex['instruction']}\n\n"
                f"### Contexte:\n{ex['input']}\n\n"
                f"### Réponse:\n{ex['output']}"
    }

dataset = Dataset.from_list(exemples).map(format_prompt)

3. Configurer l'adapter LoRA

prepare_model_for_kbit_training est facile à oublier — sans lui, les gradients ne remontent pas correctement à travers les poids quantifiés. target_modules liste les couches où injecter les matrices A/B ; se limiter à q_proj/v_proj marche mais couvrir aussi k_proj/o_proj donne de meilleurs résultats pour un coût mémoire marginal.

from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training

model = prepare_model_for_kbit_training(model)

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable params: ~8M || all params: ~7.25B || trainable%: ~0.1%

4. Lancer l'entraînement

from trl import SFTTrainer, SFTConfig

config = SFTConfig(
    output_dir="./mistral-support-lora",
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    num_train_epochs=3,
    learning_rate=2e-4,
    logging_steps=10,
    bf16=True,
    dataset_text_field="text",
)

trainer = SFTTrainer(model=model, train_dataset=dataset, args=config)
trainer.train()

Le learning_rate de 2e-4 est volontairement plus élevé qu'en finetuning complet (souvent 1e-5 à 5e-5) : avec si peu de paramètres entraînables, un taux trop bas n'apprend simplement rien en 3 epochs.

Estimer la VRAM nécessaire (QLoRA)

VRAM estimée : Estimation indicative (poids 4-bit + adapters + optimiseur). La longueur de séquence et la taille de batch font varier ce chiffre.

5. Fusionner et tester

Une fois l'entraînement fini, deux options : garder l'adapter séparé (léger, quelques dizaines de Mo, facile à swapper) ou le fusionner dans les poids du modèle pour un déploiement sans dépendance à peft à l'inférence.

merged = model.merge_and_unload()
merged.save_pretrained("./mistral-support-merged")
tokenizer.save_pretrained("./mistral-support-merged")

inputs = tokenizer(
    "### Instruction:\nRésume ce ticket...\n\n### Réponse:\n",
    return_tensors="pt",
).to(merged.device)
output = merged.generate(**inputs, max_new_tokens=80)
print(tokenizer.decode(output[0], skip_special_tokens=True))

Pièges fréquents

  • Dataset trop petit. En dessous de quelques centaines d'exemples, le modèle overfit dès la 2ᵉ epoch — surveiller la loss d'évaluation, pas seulement la loss d'entraînement.
  • target_modules incomplet. Se limiter à q_proj/v_proj fonctionne mais sous-performe légèrement par rapport à couvrir aussi k_proj/o_proj, pour un coût mémoire négligeable.
  • Utiliser LoRA pour injecter des faits. Comme vu dans Finetuning vs RAG, ce n'est pas ce que ça fait bien — LoRA excelle sur le format et le ton, pas sur la mémorisation de connaissances.
  • Se lancer alors qu'un prompt suffirait. Si le comportement voulu tient dans quelques exemples en few-shot prompting, tout ce pipeline n'apporte souvent rien de plus qu'un prompt bien construit.

FAQ

Faut-il un GPU cloud pour ce tutoriel ?

Non pour un 7B : un GPU grand public avec 8-12 Go de VRAM suffit grâce à la quantification 4 bits. Au-delà de 13B, un GPU cloud devient plus confortable.

Peut-on entraîner sans CUDA (Mac, CPU) ?

bitsandbytes dépend de CUDA — sur Mac, il faut passer par un backend MPS avec des contraintes différentes, ou louer un GPU cloud pour suivre ce tutoriel tel quel.

Combien de temps prend l'entraînement ?

Pour quelques centaines d'exemples et 3 epochs sur un 7B, compter de 20 à 60 minutes selon le GPU — très loin des dizaines d'heures d'un finetuning complet.