Fine-tuner Llama 3 sur vos données : Guide complet

tl;dr: Fine-tunez Llama 3 (8B) sur GPU 24GB avec QLoRA : préparez dataset (format ChatML), configurez PEFT/LoRA (rank 16), entraînez avec Transformers Trainer (3-6h), évaluez perplexité, mergez adapters, déployez avec vLLM. Coût: $2-5 sur RunPod/Vast.ai.

💡 Ce tutoriel vous guide étape par étape pour fine-tuner Llama 3 sur vos propres données. Vous créerez un modèle spécialisé qui comprend votre domaine (support client, code, médical, etc.) avec 24GB VRAM seulement grâce à QLoRA.

Le fine-tuning transforme un LLM généraliste (Llama 3) en expert de votre domaine. Pourquoi fine-tuner plutôt qu’utiliser RAG ou prompting ?

Tutoriel pratique étape par étape : le fine-tuning de modèles Llama avec exemples de code

Table des matières

  1. Comprendre le fine-tuning efficace
  2. Préparation du dataset
  3. Configuration de l’environnement
  4. Fine-tuning avec QLoRA
  5. Évaluation et tests
  6. Merge des adapters LoRA
  7. Déploiement du modèle
  8. Optimisations avancées
  9. Ressources complémentaires
  10. Questions fréquentes

RAG vs Prompting vs Fine-Tuning

ApprocheQuand l’utiliserAvantagesLimites
PromptingTâches générales, quick testsRapide, gratuit, aucun setupLimité par context window, répétitif
RAGConnaissances factuelles évolutivesActualisable, pas de trainingNe change pas le “style” du modèle
Fine-tuningStyle spécifique, domaine complexeModèle personnalisé, efficaceCoût training, data needed (500+)

Exemples où fine-tuning excelle :

  • 🏥 Médical : Diagnostics avec terminologie précise
  • 💻 Code : Génération dans framework propriétaire
  • 🎓 Éducation : Tuteur avec pédagogie spécifique
  • 🤝 Support : Réponses brand voice + politiques internes
  • 📝 Écriture : Style d’auteur particulier

Dans ce tutoriel, vous apprendrez à :

  • ✅ Préparer un dataset de qualité (format, nettoyage)
  • ✅ Configurer l’environnement (GPU, librairies)
  • ✅ Fine-tuner Llama 3 8B avec QLoRA (24GB VRAM)
  • ✅ Évaluer les performances (perplexité, benchmarks)
  • ✅ Merger les adapters LoRA avec le modèle base
  • ✅ Déployer le modèle fine-tuné (local, API)

Temps estimé : 6-12 heures (dont 3-6h training)

Coût GPU : $2-5 sur cloud (RunPod, Vast.ai)

Niveau : Avancé (Python, ML basics requis)


Comprendre le fine-tuning efficace

Full fine-tuning vs LoRA vs QLoRA

Full Fine-Tuning :

  • ❌ Mise à jour de TOUS les paramètres (8B × 4 bytes = 32GB minimum)
  • ❌ Très coûteux en compute et mémoire
  • ❌ Risque d’overfitting
  • ✅ Performances maximales

LoRA (Low-Rank Adaptation) :

  • ✅ Entraîne seulement 0.1-1% des paramètres (adapters)
  • ✅ 80GB → 16GB VRAM pour Llama 3 8B
  • ✅ Performances 95-99% du full fine-tuning
  • ✅ Modularité : plusieurs adapters pour un modèle base

QLoRA (Quantized LoRA) :

  • ✅✅ Base model en 4-bit (32GB → 8GB)
  • ✅✅ Training en mixed precision (8-bit activations)
  • ✅✅ Llama 3 8B sur 24GB VRAM (RTX 3090, 4090)
  • ✅ Performances quasi-identiques à LoRA

Architecture LoRA

LoRA ajoute des matrices de rang faible aux couches attention du Transformer :

Layer originale :           Layer avec LoRA :
┌─────────────┐            ┌─────────────┐
│   W (frozen)│            │   W (frozen)│
│   d × d     │            │   d × d     │
└─────────────┘            └──────┬──────┘
                                  ├─────┐
                                  │     │
                            ┌─────▼──┐  │
                            │ LoRA A │  │
                            │ d × r  │  │
                            └─────┬──┘  │
                                  │     │
                            ┌─────▼──┐  │
                            │ LoRA B │  │
                            │ r × d  │  │
                            └─────┬──┘  │
                                  │     │
                                  ▼     │
                            Output  ←───┘
                               (W + BA)

r = rank (8-64, typical 16)
Params: 2 × d × r << d²

Exemple Llama 3 8B :

  • Dimension d = 4096
  • Rank r = 16
  • Params LoRA par layer : 2 × 4096 × 16 = 131K
  • Total (32 layers, 4 matrices/layer) : 32 × 4 × 131K ≈ 16.8M paramètres
  • Ratio : 16.8M / 8B = 0.21% des paramètres totaux
💡 Avec LoRA, vous entraînez <1% des paramètres mais obtenez 95-99% des performances du full fine-tuning. C’est l’approche standard en 2025.

Hardware requirements

ModèleMethodVRAMGPU RecommandéTraining TimeCoût Cloud
Llama 3 8BQLoRA16-24GBRTX 3090/4090, A50003-6h$2-5
Llama 3 8BLoRA32-40GBA100 40GB2-4h$5-10
Llama 3 8BFull FT80GB+2× A100 80GB12-24h$50-100
Llama 3 70BQLoRA48-80GBA100 80GB24-48h$40-80
Llama 3 70BLoRA160GB+2× A100 80GB12-24h$100-200

Recommandation : Llama 3 8B avec QLoRA sur RTX 3090/4090 (24GB) ou A5000 (24GB).

Cloud providers :

  • RunPod : RTX 4090 à $0.69/h
  • Vast.ai : RTX 3090 à $0.40/h
  • Lambda Labs : A100 40GB à $1.10/h
  • Google Colab : A100 (gratuit limité, Pro+ $50/mois)

Préparation du dataset

Format de données

Format recommandé : JSONL (JSON Lines)

Chaque ligne = 1 exemple d’entraînement :

{"messages": [{"role": "user", "content": "Comment installer Python ?"}, {"role": "assistant", "content": "Pour installer Python :\n1. Téléchargez depuis python.org\n2. Exécutez l'installeur\n3. Ajoutez au PATH"}]}
{"messages": [{"role": "user", "content": "Expliquer les listes Python"}, {"role": "assistant", "content": "Les listes Python sont des collections ordonnées d'éléments : `ma_liste = [1, 2, 3]`. Elles sont mutables."}]}

Format ChatML (utilisé par Llama 3) :

<|begin_of_text|><|start_header_id|>user<|end_header_id|>

Comment installer Python ?<|eot_id|><|start_header_id|>assistant<|end_header_id|>

Pour installer Python :
1. Téléchargez depuis python.org
2. Exécutez l'installeur
3. Ajoutez au PATH<|eot_id|>

Combien de données ?

Qualité DataQuantité MinRecommandéMax Utile
Très haute (curated)100-200500-1K5K
Haute (cleaned)500-1K2K-5K20K
Moyenne (raw)2K-5K10K-50K100K
⚠️ Warning
QUALITÉ > QUANTITÉ. 500 exemples excellents battent 10K exemples bruités. Passez du temps sur le nettoyage !

Script de préparation (prepare_dataset.py)

"""Prépare le dataset pour fine-tuning Llama 3"""
import json
from pathlib import Path
from typing import List, Dict
import random


class DatasetPreparer:
    """Prépare et valide le dataset"""

    def __init__(self, output_dir: str = "data"):
        self.output_dir = Path(output_dir)
        self.output_dir.mkdir(exist_ok=True)

    def load_from_jsonl(self, file_path: str) -> List[Dict]:
        """Charge un fichier JSONL"""
        data = []
        with open(file_path, 'r', encoding='utf-8') as f:
            for line_num, line in enumerate(f, 1):
                try:
                    item = json.loads(line.strip())
                    data.append(item)
                except json.JSONDecodeError as e:
                    print(f"⚠️  Ligne {line_num} invalide: {e}")
        return data

    def validate_format(self, data: List[Dict]) -> bool:
        """Valide le format des données"""
        errors = []

        for i, item in enumerate(data):
            if "messages" not in item:
                errors.append(f"Item {i}: 'messages' manquant")
                continue

            messages = item["messages"]
            if not isinstance(messages, list) or len(messages) < 2:
                errors.append(f"Item {i}: 'messages' doit avoir 2+ messages")
                continue

            for j, msg in enumerate(messages):
                if "role" not in msg or "content" not in msg:
                    errors.append(f"Item {i}, message {j}: 'role'/'content' manquant")

                if msg["role"] not in ["user", "assistant", "system"]:
                    errors.append(f"Item {i}, message {j}: role invalide '{msg['role']}'")

        if errors:
            print(f"❌ {len(errors)} erreurs trouvées:")
            for error in errors[:10]:  # Afficher 10 premières
                print(f"  - {error}")
            return False

        print(f"✅ {len(data)} exemples valides")
        return True

    def compute_stats(self, data: List[Dict]) -> Dict:
        """Calcule des statistiques sur le dataset"""
        total_messages = sum(len(item["messages"]) for item in data)
        total_tokens = 0  # Approximation

        for item in data:
            for msg in item["messages"]:
                # Approximation : 1 token ≈ 4 chars
                total_tokens += len(msg["content"]) // 4

        avg_messages = total_messages / len(data)
        avg_tokens = total_tokens / len(data)

        return {
            "num_examples": len(data),
            "total_messages": total_messages,
            "avg_messages_per_example": round(avg_messages, 2),
            "total_tokens_approx": total_tokens,
            "avg_tokens_per_example": round(avg_tokens, 2),
        }

    def train_test_split(
        self,
        data: List[Dict],
        test_size: float = 0.1,
        seed: int = 42
    ) -> tuple:
        """Split train/test"""
        random.seed(seed)
        shuffled = data.copy()
        random.shuffle(shuffled)

        split_idx = int(len(shuffled) * (1 - test_size))
        train = shuffled[:split_idx]
        test = shuffled[split_idx:]

        return train, test

    def save_dataset(self, data: List[Dict], split: str):
        """Sauvegarde le dataset"""
        output_path = self.output_dir / f"{split}.jsonl"

        with open(output_path, 'w', encoding='utf-8') as f:
            for item in data:
                f.write(json.dumps(item, ensure_ascii=False) + '\n')

        print(f"💾 Sauvegardé: {output_path} ({len(data)} exemples)")

    def prepare(self, input_file: str, test_size: float = 0.1):
        """Pipeline complet de préparation"""
        print("=" * 60)
        print("🚀 PRÉPARATION DU DATASET")
        print("=" * 60)

        # 1. Charger
        print(f"\n📂 Chargement: {input_file}")
        data = self.load_from_jsonl(input_file)
        print(f"✅ {len(data)} exemples chargés")

        # 2. Valider
        print(f"\n🔍 Validation du format...")
        if not self.validate_format(data):
            print("❌ Validation échouée. Corrigez les erreurs.")
            return

        # 3. Stats
        print(f"\n📊 Statistiques:")
        stats = self.compute_stats(data)
        for key, value in stats.items():
            print(f"  - {key}: {value}")

        # 4. Split
        print(f"\n✂️  Split train/test ({100-test_size*100:.0f}%/{test_size*100:.0f}%)")
        train, test = self.train_test_split(data, test_size=test_size)
        print(f"  - Train: {len(train)} exemples")
        print(f"  - Test: {len(test)} exemples")

        # 5. Sauvegarder
        print(f"\n💾 Sauvegarde...")
        self.save_dataset(train, "train")
        self.save_dataset(test, "test")

        print("\n" + "=" * 60)
        print("✅ PRÉPARATION TERMINÉE")
        print("=" * 60)
        print(f"\nFichiers créés:")
        print(f"  - {self.output_dir}/train.jsonl")
        print(f"  - {self.output_dir}/test.jsonl")


# Exemple d'utilisation
if __name__ == "__main__":
    # Exemple : créer dataset synthétique
    sample_data = [
        {
            "messages": [
                {"role": "user", "content": "Qu'est-ce que Python ?"},
                {"role": "assistant", "content": "Python est un langage de programmation interprété, orienté objet et de haut niveau."}
            ]
        },
        {
            "messages": [
                {"role": "user", "content": "Comment créer une liste ?"},
                {"role": "assistant", "content": "Pour créer une liste : `ma_liste = [1, 2, 3]`"}
            ]
        },
        # Ajoutez vos exemples ici...
    ]

    # Sauvegarder exemples
    with open("raw_data.jsonl", 'w') as f:
        for item in sample_data:
            f.write(json.dumps(item, ensure_ascii=False) + '\n')

    # Préparer
    preparer = DatasetPreparer()
    preparer.prepare("raw_data.jsonl", test_size=0.1)

Exécuter la préparation

python prepare_dataset.py

Output attendu :

============================================================
🚀 PRÉPARATION DU DATASET
============================================================

📂 Chargement: raw_data.jsonl
✅ 1000 exemples chargés

🔍 Validation du format...
✅ 1000 exemples valides

📊 Statistiques:
  - num_examples: 1000
  - total_messages: 2000
  - avg_messages_per_example: 2.0
  - total_tokens_approx: 150000
  - avg_tokens_per_example: 150.0

✂️  Split train/test (90%/10%)
  - Train: 900 exemples
  - Test: 100 exemples

💾 Sauvegarde...
💾 Sauvegardé: data/train.jsonl (900 exemples)
💾 Sauvegardé: data/test.jsonl (100 exemples)

============================================================
✅ PRÉPARATION TERMINÉE
============================================================

Fichiers créés:
  - data/train.jsonl
  - data/test.jsonl

Configuration de l’environnement

Setup GPU cloud (RunPod)

Étapes :

  1. Créer compte : runpod.io

  2. Ajouter crédits : $10 minimum

  3. Déployer GPU :

    • Template: PyTorch
    • GPU: RTX 4090 (24GB, $0.69/h)
    • Disk: 50GB
    • Region: EU/US selon proximité
  4. Connecter SSH :

ssh root@<pod-ip> -p <port>

Installation des dépendances

# Update system
apt-get update && apt-get install -y git vim

# Install Python packages
pip install --upgrade pip

pip install torch==2.1.2 torchvision torchaudio \
    --index-url https://download.pytorch.org/whl/cu118

pip install transformers==4.36.2 \
            datasets==2.16.1 \
            peft==0.7.1 \
            bitsandbytes==0.41.3 \
            accelerate==0.25.0 \
            trl==0.7.10 \
            sentencepiece==0.1.99 \
            protobuf==4.25.1

# Vérifier GPU
python -c "import torch; print(f'CUDA: {torch.cuda.is_available()}')"
# → CUDA: True

Fichier requirements.txt :

torch==2.1.2
transformers==4.36.2
datasets==2.16.1
peft==0.7.1
bitsandbytes==0.41.3
accelerate==0.25.0
trl==0.7.10
sentencepiece==0.1.99
protobuf==4.25.1

Télécharger Llama 3

"""download_model.py - Télécharge Llama 3 de HuggingFace"""
from transformers import AutoTokenizer, AutoModelForCausalLM
import torch

model_id = "meta-llama/Meta-Llama-3-8B"

print(f"📥 Téléchargement de {model_id}...")

# Tokenizer
tokenizer = AutoTokenizer.from_pretrained(
    model_id,
    trust_remote_code=True
)

# Model (quantized 4-bit)
model = AutoModelForCausalLM.from_pretrained(
    model_id,
    load_in_4bit=True,  # QLoRA
    device_map="auto",
    trust_remote_code=True,
    torch_dtype=torch.bfloat16
)

print(f"✅ Modèle téléchargé et quantized")
print(f"📊 VRAM utilisée: {torch.cuda.memory_allocated() / 1024**3:.2f} GB")
# Vous aurez besoin d'un token HuggingFace
huggingface-cli login

python download_model.py

Note : Llama 3 nécessite acceptation de licence sur HuggingFace.


Fine-tuning avec QLoRA

Configuration LoRA (config/lora_config.py)

"""Configuration LoRA"""
from peft import LoraConfig, TaskType

def get_lora_config(
    rank: int = 16,
    alpha: int = 32,
    dropout: float = 0.05
):
    """
    Configuration LoRA

    Args:
        rank: Dimension LoRA (8-64, typical 16)
        alpha: Scaling factor (typical 2×rank)
        dropout: Dropout pour régularisation
    """
    return LoraConfig(
        task_type=TaskType.CAUSAL_LM,
        inference_mode=False,
        r=rank,
        lora_alpha=alpha,
        lora_dropout=dropout,
        target_modules=[
            "q_proj",  # Query projection
            "k_proj",  # Key projection
            "v_proj",  # Value projection
            "o_proj",  # Output projection
        ],
        bias="none",
    )

Paramètres clés :

  • r (rank) : Plus élevé = plus de capacité mais plus lent. 16 est optimal
  • lora_alpha : Scaling, mettre à 2 × rank
  • target_modules : Quelles couches adapter (query/key/value attention)

Script de fine-tuning (train.py)

"""Fine-tuning Llama 3 avec QLoRA"""
import os
import torch
from transformers import (
    AutoTokenizer,
    AutoModelForCausalLM,
    TrainingArguments,
    Trainer,
    DataCollatorForLanguageModeling
)
from datasets import load_dataset
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from config.lora_config import get_lora_config


# ============================================================
# CONFIGURATION
# ============================================================

MODEL_ID = "meta-llama/Meta-Llama-3-8B"
OUTPUT_DIR = "llama3-finetuned"
DATASET_PATH = "data"  # train.jsonl et test.jsonl

# Hyperparamètres
EPOCHS = 3
BATCH_SIZE = 4  # Per device
GRAD_ACCUM = 4  # Effective batch = 4 × 4 = 16
LEARNING_RATE = 2e-4
MAX_LENGTH = 512

# LoRA config
LORA_RANK = 16
LORA_ALPHA = 32


# ============================================================
# CHARGEMENT MODÈLE ET TOKENIZER
# ============================================================

print("=" * 60)
print("🚀 FINE-TUNING LLAMA 3 AVEC QLORA")
print("=" * 60)

print(f"\n📥 Chargement du modèle: {MODEL_ID}")

tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"

# Charger en 4-bit (QLoRA)
model = AutoModelForCausalLM.from_pretrained(
    MODEL_ID,
    load_in_4bit=True,
    torch_dtype=torch.bfloat16,
    device_map="auto",
    trust_remote_code=True
)

print(f"✅ Modèle chargé")
print(f"📊 VRAM: {torch.cuda.memory_allocated() / 1024**3:.2f} GB")

# Préparer pour training
model = prepare_model_for_kbit_training(model)

# Ajouter adapters LoRA
lora_config = get_lora_config(rank=LORA_RANK, alpha=LORA_ALPHA)
model = get_peft_model(model, lora_config)

# Stats
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
all_params = sum(p.numel() for p in model.parameters())
trainable_percent = 100 * trainable_params / all_params

print(f"\n🔢 Paramètres:")
print(f"  - Total: {all_params:,}")
print(f"  - Trainable: {trainable_params:,} ({trainable_percent:.2f}%)")


# ============================================================
# CHARGEMENT DATASET
# ============================================================

print(f"\n📂 Chargement du dataset: {DATASET_PATH}")

def format_chat_template(example):
    """Formate selon template ChatML de Llama 3"""
    messages = example["messages"]

    text = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=False
    )

    return {"text": text}

# Charger train/test
train_dataset = load_dataset("json", data_files=f"{DATASET_PATH}/train.jsonl", split="train")
test_dataset = load_dataset("json", data_files=f"{DATASET_PATH}/test.jsonl", split="train")

# Formater
train_dataset = train_dataset.map(format_chat_template, remove_columns=train_dataset.column_names)
test_dataset = test_dataset.map(format_chat_template, remove_columns=test_dataset.column_names)

print(f"✅ Dataset chargé:")
print(f"  - Train: {len(train_dataset)} exemples")
print(f"  - Test: {len(test_dataset)} exemples")

# Tokenize
def tokenize_function(examples):
    return tokenizer(
        examples["text"],
        truncation=True,
        max_length=MAX_LENGTH,
        padding="max_length"
    )

train_dataset = train_dataset.map(tokenize_function, batched=True, remove_columns=["text"])
test_dataset = test_dataset.map(tokenize_function, batched=True, remove_columns=["text"])


# ============================================================
# TRAINING
# ============================================================

print(f"\n🏋️  Configuration du training...")

training_args = TrainingArguments(
    output_dir=OUTPUT_DIR,

    # Training schedule
    num_train_epochs=EPOCHS,
    per_device_train_batch_size=BATCH_SIZE,
    gradient_accumulation_steps=GRAD_ACCUM,

    # Optimizer
    learning_rate=LEARNING_RATE,
    warmup_steps=100,
    weight_decay=0.01,

    # Evaluation
    evaluation_strategy="steps",
    eval_steps=100,
    save_strategy="steps",
    save_steps=200,
    save_total_limit=3,

    # Logging
    logging_steps=10,
    logging_dir=f"{OUTPUT_DIR}/logs",

    # Performance
    fp16=False,
    bf16=True,
    optim="paged_adamw_8bit",

    # Misc
    report_to="none",  # Ou "wandb" pour tracking
    load_best_model_at_end=True,
    metric_for_best_model="eval_loss",
)

# Data collator
data_collator = DataCollatorForLanguageModeling(
    tokenizer=tokenizer,
    mlm=False  # Causal LM, pas Masked LM
)

# Trainer
trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
    eval_dataset=test_dataset,
    data_collator=data_collator,
)

print(f"✅ Trainer configuré")
print(f"\n📈 Hyperparamètres:")
print(f"  - Epochs: {EPOCHS}")
print(f"  - Batch size: {BATCH_SIZE} × {GRAD_ACCUM} = {BATCH_SIZE * GRAD_ACCUM}")
print(f"  - Learning rate: {LEARNING_RATE}")
print(f"  - Max length: {MAX_LENGTH}")

print(f"\n🚀 Début du training...\n")
print("=" * 60)

trainer.train()

print("\n" + "=" * 60)
print("✅ TRAINING TERMINÉ")
print("=" * 60)


# ============================================================
# SAUVEGARDE
# ============================================================

print(f"\n💾 Sauvegarde du modèle...")

# Sauvegarder adapters LoRA
model.save_pretrained(f"{OUTPUT_DIR}/final")
tokenizer.save_pretrained(f"{OUTPUT_DIR}/final")

print(f"✅ Modèle sauvegardé: {OUTPUT_DIR}/final")

# Eval final
print(f"\n📊 Évaluation finale...")
eval_results = trainer.evaluate()

print(f"  - Loss: {eval_results['eval_loss']:.4f}")
print(f"  - Perplexity: {torch.exp(torch.tensor(eval_results['eval_loss'])):.2f}")

print("\n🎉 Fine-tuning terminé avec succès!")

Lancer le training

python train.py

Output attendu (début) :

============================================================
🚀 FINE-TUNING LLAMA 3 AVEC QLORA
============================================================

📥 Chargement du modèle: meta-llama/Meta-Llama-3-8B
✅ Modèle chargé
📊 VRAM: 8.32 GB

🔢 Paramètres:
  - Total: 8,030,261,248
  - Trainable: 16,777,216 (0.21%)

📂 Chargement du dataset: data
✅ Dataset chargé:
  - Train: 900 exemples
  - Test: 100 exemples

🏋️  Configuration du training...
✅ Trainer configuré

📈 Hyperparamètres:
  - Epochs: 3
  - Batch size: 4 × 4 = 16
  - Learning rate: 0.0002
  - Max length: 512

🚀 Début du training...

============================================================
  0%|          | 0/169 [00:00<?, ?it/s]
Step 10 | Loss: 2.345 | LR: 0.00002
Step 20 | Loss: 1.987 | LR: 0.00004
...

Durée estimée : 3-6 heures sur RTX 4090


Évaluation et tests

Test basique d’inférence

Une fois le training terminé, testez rapidement votre modèle :

"""test_inference.py - Test rapide du modèle fine-tuné"""
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
from peft import PeftModel

MODEL_ID = "meta-llama/Meta-Llama-3-8B"
ADAPTER_PATH = "llama3-finetuned/final"

print("📥 Chargement du modèle...")

# Charger base model
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
base_model = AutoModelForCausalLM.from_pretrained(
    MODEL_ID,
    load_in_4bit=True,
    device_map="auto",
    torch_dtype=torch.bfloat16
)

# Charger adapters LoRA
model = PeftModel.from_pretrained(base_model, ADAPTER_PATH)

print("✅ Modèle chargé")

def generate_response(prompt: str, max_new_tokens: int = 256):
    """Génère une réponse"""
    messages = [{"role": "user", "content": prompt}]

    text = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True
    )

    inputs = tokenizer(text, return_tensors="pt").to("cuda")

    with torch.no_grad():
        outputs = model.generate(
            **inputs,
            max_new_tokens=max_new_tokens,
            temperature=0.7,
            top_p=0.9,
            do_sample=True,
            pad_token_id=tokenizer.eos_token_id
        )

    response = tokenizer.decode(outputs[0], skip_special_tokens=True)

    # Extraire seulement la réponse de l'assistant
    response = response.split("assistant")[-1].strip()

    return response

# Tests
test_prompts = [
    "Explique-moi le concept de fine-tuning",
    "Quelle est la différence entre LoRA et QLoRA ?",
    "Comment préparer un bon dataset ?",
]

print("\n" + "="*60)
print("🧪 TESTS D'INFÉRENCE")
print("="*60)

for i, prompt in enumerate(test_prompts, 1):
    print(f"\n[Test {i}]")
    print(f"❓ Prompt: {prompt}")
    print(f"💬 Réponse:")
    print("-" * 60)

    response = generate_response(prompt)
    print(response)
    print("-" * 60)

Évaluation de la perplexité

La perplexité mesure à quel point le modèle est “surpris” par le texte. Plus bas = meilleur.

"""evaluate_perplexity.py - Calcule la perplexité sur test set"""
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
from datasets import load_dataset
from peft import PeftModel
from tqdm import tqdm
import math

MODEL_ID = "meta-llama/Meta-Llama-3-8B"
ADAPTER_PATH = "llama3-finetuned/final"
TEST_DATA = "data/test.jsonl"

print("📥 Chargement du modèle...")

tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
base_model = AutoModelForCausalLM.from_pretrained(
    MODEL_ID,
    load_in_4bit=True,
    device_map="auto",
    torch_dtype=torch.bfloat16
)

model = PeftModel.from_pretrained(base_model, ADAPTER_PATH)
model.eval()

print("✅ Modèle chargé")

# Charger test dataset
print(f"📂 Chargement du test set: {TEST_DATA}")
test_dataset = load_dataset("json", data_files=TEST_DATA, split="train")

def compute_perplexity(texts, batch_size=4):
    """Calcule la perplexité sur une liste de textes"""
    total_loss = 0
    total_tokens = 0

    with torch.no_grad():
        for i in tqdm(range(0, len(texts), batch_size)):
            batch = texts[i:i+batch_size]

            # Tokenize
            encodings = tokenizer(
                batch,
                return_tensors="pt",
                padding=True,
                truncation=True,
                max_length=512
            ).to("cuda")

            # Forward pass
            outputs = model(**encodings, labels=encodings["input_ids"])

            # Accumuler loss
            loss = outputs.loss
            num_tokens = encodings["attention_mask"].sum().item()

            total_loss += loss.item() * num_tokens
            total_tokens += num_tokens

    # Perplexité = exp(moyenne des losses)
    avg_loss = total_loss / total_tokens
    perplexity = math.exp(avg_loss)

    return perplexity, avg_loss

# Formater textes
def format_chat(example):
    messages = example["messages"]
    return tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=False
    )

texts = [format_chat(ex) for ex in test_dataset]

print(f"\n📊 Calcul de la perplexité sur {len(texts)} exemples...\n")

perplexity, avg_loss = compute_perplexity(texts)

print(f"\n{'='*60}")
print(f"📈 RÉSULTATS")
print(f"{'='*60}")
print(f"Loss moyenne: {avg_loss:.4f}")
print(f"Perplexité: {perplexity:.2f}")
print(f"{'='*60}")

# Interprétation
if perplexity < 10:
    interpretation = "Excellent ! Le modèle a très bien appris."
elif perplexity < 20:
    interpretation = "Bon. Le modèle est bien adapté."
elif perplexity < 40:
    interpretation = "Moyen. Considérez plus de training."
else:
    interpretation = "Faible. Dataset ou hyperparamètres à revoir."

print(f"\n💡 Interprétation: {interpretation}")

Benchmark typique :

  • Llama 3 base sur données spécialisées : perplexité 20-40
  • Après fine-tuning : perplexité 8-15
  • Target : <10 = excellent

Évaluation qualitative

Créez un script d’évaluation humaine :

"""human_eval.py - Évaluation qualitative manuelle"""
import json
from test_inference import generate_response

# Questions de test variées
eval_questions = [
    "Question facile de ton domaine",
    "Question complexe nécessitant raisonnement",
    "Question edge case",
    "Question hors domaine (devrait dire 'je ne sais pas')",
]

results = []

print("="*60)
print("👤 ÉVALUATION HUMAINE")
print("="*60)
print("\nPour chaque réponse, notez de 1 à 5:")
print("5 = Parfait")
print("4 = Bon")
print("3 = Acceptable")
print("2 = Mauvais")
print("1 = Inutilisable")
print("="*60)

for i, question in enumerate(eval_questions, 1):
    print(f"\n[Question {i}/{len(eval_questions)}]")
    print(f"❓ {question}")

    response = generate_response(question)

    print(f"\n💬 Réponse du modèle:")
    print("-"*60)
    print(response)
    print("-"*60)

    # Demander notation
    while True:
        try:
            rating = int(input("\nVotre note (1-5): "))
            if 1 <= rating <= 5:
                break
            print("⚠️  Note doit être entre 1 et 5")
        except ValueError:
            print("⚠️  Entrez un nombre")

    comment = input("Commentaire (optionnel): ")

    results.append({
        "question": question,
        "response": response,
        "rating": rating,
        "comment": comment
    })

    print("\n✅ Noté !")

# Calculer moyenne
avg_rating = sum(r["rating"] for r in results) / len(results)

print("\n" + "="*60)
print("📊 RÉSULTATS FINAUX")
print("="*60)
print(f"Note moyenne: {avg_rating:.2f}/5")
print(f"\nDétail des notes:")
for i, r in enumerate(results, 1):
    print(f"  {i}. {r['rating']}/5 - {r['question'][:50]}...")

# Sauvegarder
with open("human_eval_results.json", "w") as f:
    json.dump(results, f, indent=2, ensure_ascii=False)

print(f"\n💾 Résultats sauvegardés: human_eval_results.json")
💡 L’évaluation humaine est CRUCIALE. Des métriques parfaites (perplexité faible) ne garantissent pas des réponses utilisables. Testez sur cas réels !

Merge des adapters LoRA

Fusionner les adapters LoRA avec le modèle base crée un modèle standalone plus rapide (pas de surcharge LoRA à l’inférence).

Script de merge

"""merge_adapters.py - Fusionne LoRA avec base model"""
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
from peft import PeftModel

MODEL_ID = "meta-llama/Meta-Llama-3-8B"
ADAPTER_PATH = "llama3-finetuned/final"
OUTPUT_PATH = "llama3-merged"

print("="*60)
print("🔄 MERGE DES ADAPTERS LORA")
print("="*60)

print(f"\n📥 Chargement du modèle base: {MODEL_ID}")

# Charger en FP16 (pas 4-bit pour merge)
base_model = AutoModelForCausalLM.from_pretrained(
    MODEL_ID,
    torch_dtype=torch.float16,
    device_map="auto"
)

tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)

print(f"✅ Base model chargé")

print(f"\n📥 Chargement des adapters: {ADAPTER_PATH}")

# Charger adapters
model = PeftModel.from_pretrained(base_model, ADAPTER_PATH)

print(f"✅ Adapters chargés")

print(f"\n🔄 Merge en cours...")

# Merge (intègre adapters dans weights du base model)
merged_model = model.merge_and_unload()

print(f"✅ Merge terminé")

print(f"\n💾 Sauvegarde: {OUTPUT_PATH}")

# Sauvegarder modèle mergé
merged_model.save_pretrained(OUTPUT_PATH)
tokenizer.save_pretrained(OUTPUT_PATH)

print(f"✅ Modèle mergé sauvegardé")

# Stats
import os
def get_dir_size(path):
    total = 0
    for dirpath, dirnames, filenames in os.walk(path):
        for f in filenames:
            fp = os.path.join(dirpath, f)
            total += os.path.getsize(fp)
    return total / (1024**3)  # GB

merged_size = get_dir_size(OUTPUT_PATH)

print(f"\n📊 Statistiques:")
print(f"  - Taille: {merged_size:.2f} GB")
print(f"  - Précision: float16")
print(f"  - Format: HuggingFace Transformers")

print("\n" + "="*60)
print("✅ MERGE TERMINÉ")
print("="*60)

print(f"\nVous pouvez maintenant utiliser le modèle avec:")
print(f"  model = AutoModelForCausalLM.from_pretrained('{OUTPUT_PATH}')")

Avantages du merge :

  • ✅ Inférence ~10-20% plus rapide (pas de surcharge LoRA)
  • ✅ Compatible avec tous les outils (vLLM, Ollama, etc.)
  • ✅ Plus simple à déployer (un seul modèle)

Inconvénients :

  • ❌ Taille : 16GB (vs adapters 200MB)
  • ❌ Moins modulaire (pas de switch adapters)
🔎 Tip
Pour expérimentation : gardez adapters séparés. Pour production : mergez pour performance optimale.

Déploiement du modèle

Inférence locale (Transformers)

Utilisation basique pour tests :

"""serve_local.py - Serveur d'inférence simple"""
from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline
import torch

MODEL_PATH = "llama3-merged"  # Ou adapters avec PeftModel

print("🚀 Démarrage du serveur...")

# Charger modèle
model = AutoModelForCausalLM.from_pretrained(
    MODEL_PATH,
    torch_dtype=torch.float16,
    device_map="auto"
)

tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH)

# Créer pipeline
pipe = pipeline(
    "text-generation",
    model=model,
    tokenizer=tokenizer,
    max_new_tokens=512,
    temperature=0.7,
    top_p=0.9,
)

print("✅ Modèle chargé")
print("💬 Commencez à poser des questions (Ctrl+C pour quitter)\n")

while True:
    try:
        user_input = input("Vous: ")
        if not user_input:
            continue

        messages = [{"role": "user", "content": user_input}]

        response = pipe(
            tokenizer.apply_chat_template(messages, tokenize=False),
            return_full_text=False
        )

        print(f"Assistant: {response[0]['generated_text']}\n")

    except KeyboardInterrupt:
        print("\n\n👋 Au revoir!")
        break

vLLM (Production, haute performance)

vLLM : serveur d’inférence optimisé (2-3× plus rapide que Transformers).

Installation :

pip install vllm

Lancer serveur :

python -m vllm.entrypoints.openai.api_server \
    --model llama3-merged \
    --dtype float16 \
    --max-model-len 4096 \
    --port 8000

Client Python :

"""client_vllm.py - Client pour serveur vLLM"""
from openai import OpenAI

# vLLM expose une API compatible OpenAI
client = OpenAI(
    base_url="http://localhost:8000/v1",
    api_key="dummy"  # vLLM ne nécessite pas de clé
)

def chat(message: str):
    """Envoie message au modèle"""
    response = client.chat.completions.create(
        model="llama3-merged",
        messages=[{"role": "user", "content": message}],
        temperature=0.7,
        max_tokens=512
    )

    return response.choices[0].message.content

# Test
print(chat("Explique-moi le fine-tuning"))

Performances vLLM :

  • Débit : 50-100 tokens/sec (vs 20-30 Transformers)
  • Latence : -40-60%
  • Batch parallèle : géré automatiquement

Ollama (local, facile)

Ollama : outil simple pour exécuter LLMs localement.

  1. Créer Modelfile :
# Modelfile
FROM ./llama3-merged

PARAMETER temperature 0.7
PARAMETER top_p 0.9
PARAMETER stop "<|eot_id|>"

TEMPLATE """<|begin_of_text|><|start_header_id|>user<|end_header_id|>

{{ .Prompt }}<|eot_id|><|start_header_id|>assistant<|end_header_id|>

"""

SYSTEM "Tu es un assistant expert."
  1. Créer modèle Ollama :
ollama create llama3-custom -f Modelfile
  1. Utiliser :
# CLI
ollama run llama3-custom

# API (Python)
import requests

response = requests.post('http://localhost:11434/api/generate', json={
    "model": "llama3-custom",
    "prompt": "Qu'est-ce que le fine-tuning ?",
    "stream": False
})

print(response.json()["response"])

HuggingFace Inference Endpoints

Déploiement cloud managé :

  1. Upload vers HuggingFace Hub :
huggingface-cli login

# Push model
cd llama3-merged
huggingface-cli upload votre-username/llama3-custom .
  1. Créer Inference Endpoint :

  2. Utiliser :

from huggingface_hub import InferenceClient

client = InferenceClient(
    model="votre-username/llama3-custom",
    token="hf_..."
)

response = client.text_generation(
    "Explique le fine-tuning",
    max_new_tokens=256
)
print(response)

Coût HF Endpoints :

  • T4 (16GB) : $0.60/h
  • A10G (24GB) : $1/h
  • Auto-scale to zero : gratuit quand inactif
💡 Pour production : vLLM (self-hosted) ou HF Endpoints (managé). Pour développement : Ollama (simplicité) ou Transformers (flexibilité).

Optimisations avancées

Quantization post-training (GGUF)

Convertir en GGUF pour Ollama/llama.cpp (4-bit, 5-bit, 8-bit) :

# Installer llama.cpp
git clone https://github.com/ggerganov/llama.cpp
cd llama.cpp
make

# Convertir
python convert.py ../llama3-merged \
    --outfile llama3-custom.gguf \
    --outtype f16

# Quantize
./quantize llama3-custom.gguf llama3-custom-q4_K_M.gguf Q4_K_M

Formats GGUF :

  • Q4_K_M : 4-bit, bon compromis (4.5GB, -2% qualité)
  • Q5_K_M : 5-bit, meilleur qualité (5.5GB, -1% qualité)
  • Q8_0 : 8-bit, qualité max (8GB, -0.1% qualité)

Flash Attention 2

Accélère attention de 2-3× :

pip install flash-attn --no-build-isolation

Dans training :

model = AutoModelForCausalLM.from_pretrained(
    MODEL_ID,
    load_in_4bit=True,
    device_map="auto",
    attn_implementation="flash_attention_2"  # Ajouter ça
)

Gradient Checkpointing

Réduit VRAM de ~30% (trade-off : +20% temps) :

training_args = TrainingArguments(
    ...
    gradient_checkpointing=True,  # Ajouter
    gradient_checkpointing_kwargs={"use_reentrant": False},
)

Multi-GPU training (DeepSpeed)

Pour Llama 3 70B ou training plus rapide :

deepspeed_config.json :

{
  "bf16": {"enabled": true},
  "zero_optimization": {
    "stage": 3,
    "offload_optimizer": {"device": "cpu"},
    "offload_param": {"device": "cpu"}
  },
  "gradient_accumulation_steps": 4,
  "train_micro_batch_size_per_gpu": 1
}

Lancer :

deepspeed --num_gpus=2 train.py \
    --deepspeed deepspeed_config.json

Tester différents ranks :

"""find_optimal_rank.py - Test perplexité pour différents ranks"""
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
from peft import LoraConfig, get_peft_model

ranks_to_test = [8, 16, 32, 64]
results = []

for rank in ranks_to_test:
    print(f"\n🧪 Test rank={rank}")

    # Charger et configurer
    model = AutoModelForCausalLM.from_pretrained(...)
    lora_config = LoraConfig(r=rank, ...)
    model = get_peft_model(model, lora_config)

    # Train (rapide, 100 steps)
    trainer.train(max_steps=100)

    # Eval
    eval_loss = trainer.evaluate()["eval_loss"]
    perplexity = torch.exp(torch.tensor(eval_loss))

    trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)

    results.append({
        "rank": rank,
        "perplexity": perplexity.item(),
        "trainable_params": trainable
    })

    print(f"  Perplexité: {perplexity:.2f}")
    print(f"  Params: {trainable:,}")

# Analyser
import pandas as pd
df = pd.DataFrame(results)
print("\n📊 Résultats:")
print(df)

# Trouver meilleur ratio qualité/params
df["score"] = df["trainable_params"] / df["perplexity"]
best = df.loc[df["score"].idxmax()]
print(f"\n✅ Rank optimal: {best['rank']}")

Règle générale :

  • Rank 8 : petit dataset (<500), domaine simple
  • Rank 16 : standard, recommandé
  • Rank 32 : gros dataset (>5K), domaine complexe
  • Rank 64 : rarement nécessaire, coûteux

Data augmentation

Augmenter dataset synthétiquement :

"""augment_data.py - Augmentation de dataset"""
from transformers import pipeline

# Utiliser Llama 3 base pour générer variations
generator = pipeline("text-generation", model="meta-llama/Meta-Llama-3-8B")

def augment_example(original_question, original_answer):
    """Crée variations d'un exemple"""

    # Paraphrase question
    paraphrase_prompt = f"""Reformule cette question de 3 manières différentes:

Question: {original_question}

Variations:
1."""

    variations = generator(paraphrase_prompt, max_new_tokens=200)

    # Parser variations (simplifié)
    new_questions = variations[0]["generated_text"].split("\n")[:3]

    # Créer nouveaux exemples
    augmented = []
    for q in new_questions:
        augmented.append({
            "messages": [
                {"role": "user", "content": q},
                {"role": "assistant", "content": original_answer}
            ]
        })

    return augmented

# Exemple
original = {
    "question": "Qu'est-ce que le fine-tuning ?",
    "answer": "Le fine-tuning est l'adaptation d'un modèle pré-entraîné..."
}

augmented = augment_example(original["question"], original["answer"])
print(f"Créé {len(augmented)} variations")
⚠️ Warning
L’augmentation synthétique peut introduire du bruit. Validez qualité avant d’ajouter au training set !

Ressources complémentaires

Articles liés :

Documentation officielle :

Comparaison techniques :


Questions fréquentes

Q: Combien coûte le fine-tuning ? R: Avec QLoRA sur RTX 4090 (RunPod $0.69/h) :

  • Training 3-6h : $2-4
  • Total avec tests : $5-10

Q: Puis-je fine-tuner sur ma machine ? R: Oui si vous avez :

  • RTX 3090/4090 (24GB) : Llama 3 8B avec QLoRA ✅
  • RTX 4080 (16GB) : Llama 3 8B difficile, essayez rank 8
  • RTX 3060 (12GB) : Trop peu, utilisez cloud

Q: Combien de données nécessaires ? R:

  • Minimum viable : 100-200 exemples haute qualité
  • Recommandé : 500-2000 exemples
  • Optimal : 5K-20K exemples

Q: Perplexité après fine-tuning ? R: Sur données similaires :

  • Base Llama 3 : 15-30
  • Après fine-tuning : 5-15 (plus bas = meilleur)

Q: Puis-je fine-tuner Llama 3 70B ? R: Oui mais :

  • QLoRA nécessite 48-80GB (A100 80GB)
  • Training 24-48h
  • Coût $40-80 → Pour la plupart des cas, 8B suffit

Q: Fine-tuning vs Prompt Engineering ? R: Commencez TOUJOURS par prompt engineering et RAG. Fine-tunez seulement si :

  • Style très spécifique requis
  • Latence critique (pas de long context)
  • Données sensibles (pas de RAG externe)
  • Vous avez >500 exemples de qualité

🔎 Tip
Prochain tutoriel : Déployer votre modèle fine-tuné sur HuggingFace Inference Endpoints avec autoscaling !

À suivre : Tutoriel 3 - Déploiement HuggingFace