Fine-tuner Llama 3 sur vos données : Guide complet
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 ?

Table des matières
- Comprendre le fine-tuning efficace
- Préparation du dataset
- Configuration de l’environnement
- Fine-tuning avec QLoRA
- Évaluation et tests
- Merge des adapters LoRA
- Déploiement du modèle
- Optimisations avancées
- Ressources complémentaires
- Questions fréquentes
RAG vs Prompting vs Fine-Tuning
| Approche | Quand l’utiliser | Avantages | Limites |
|---|---|---|---|
| Prompting | Tâches générales, quick tests | Rapide, gratuit, aucun setup | Limité par context window, répétitif |
| RAG | Connaissances factuelles évolutives | Actualisable, pas de training | Ne change pas le “style” du modèle |
| Fine-tuning | Style spécifique, domaine complexe | Modèle personnalisé, efficace | Coû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
Hardware requirements
| Modèle | Method | VRAM | GPU Recommandé | Training Time | Coût Cloud |
|---|---|---|---|---|---|
| Llama 3 8B | QLoRA | 16-24GB | RTX 3090/4090, A5000 | 3-6h | $2-5 |
| Llama 3 8B | LoRA | 32-40GB | A100 40GB | 2-4h | $5-10 |
| Llama 3 8B | Full FT | 80GB+ | 2× A100 80GB | 12-24h | $50-100 |
| Llama 3 70B | QLoRA | 48-80GB | A100 80GB | 24-48h | $40-80 |
| Llama 3 70B | LoRA | 160GB+ | 2× A100 80GB | 12-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é Data | Quantité Min | Recommandé | Max Utile |
|---|---|---|---|
| Très haute (curated) | 100-200 | 500-1K | 5K |
| Haute (cleaned) | 500-1K | 2K-5K | 20K |
| Moyenne (raw) | 2K-5K | 10K-50K | 100K |
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 :
Créer compte : runpod.io
Ajouter crédits : $10 minimum
Déployer GPU :
- Template: PyTorch
- GPU: RTX 4090 (24GB, $0.69/h)
- Disk: 50GB
- Region: EU/US selon proximité
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 optimallora_alpha: Scaling, mettre à2 × ranktarget_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")
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)
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.
- 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."
- Créer modèle Ollama :
ollama create llama3-custom -f Modelfile
- 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é :
- Upload vers HuggingFace Hub :
huggingface-cli login
# Push model
cd llama3-merged
huggingface-cli upload votre-username/llama3-custom .
Créer Inference Endpoint :
- Aller sur https://huggingface.co/inference-endpoints
- New Endpoint → votre modèle
- GPU : T4 (16GB) ou A10G (24GB)
- Auto-scaling : min 0, max 3
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
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
Optimal LoRA rank search
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")
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é
Prochain tutoriel : Déployer votre modèle fine-tuné sur HuggingFace Inference Endpoints avec autoscaling !
À suivre : Tutoriel 3 - Déploiement HuggingFace