| """ |
| train.py — Fine-tuning de DistilCamemBERT sur le dataset Allociné (avis ciné). |
| =============================================================================== |
| |
| CE QUE FAIT CE SCRIPT, EN UNE PHRASE : |
| Il prend un modèle qui "sait déjà le français" (DistilCamemBERT) et lui |
| apprend une tâche précise : décider si un avis de film est positif ou |
| négatif. |
| |
| ───────────────────────────────────────────────────────────────────────────── |
| CONCEPT CLÉ N°1 — LE TRANSFER LEARNING |
| ───────────────────────────────────────────────────────────────────────────── |
| Entraîner un modèle de langue "from scratch" (à partir de zéro) demanderait |
| des dizaines de Go de texte et des semaines de GPU. À la place : |
| |
| 1. Quelqu'un (l'équipe CamemBERT) a PRÉ-ENTRAÎNÉ un gros modèle sur |
| 138 Go de texte français, avec une tâche générique : deviner des mots |
| masqués dans des phrases ("Le chat mange la <MASK>"). En faisant ça |
| des milliards de fois, le modèle a appris la grammaire, le vocabulaire |
| et le sens des mots français. |
| 2. Nous, on RÉCUPÈRE ce modèle déjà intelligent, on lui ajoute une petite |
| "tête de classification" (une couche de neurones à 2 sorties : |
| négatif / positif), et on ajuste le tout sur NOS données (des avis de |
| films étiquetés). C'est le FINE-TUNING. |
| |
| Résultat : en ~20 minutes et avec seulement 8 000 exemples, on obtient |
| ~94 % de réussite. From scratch, il faudrait des millions d'exemples. |
| |
| ───────────────────────────────────────────────────────────────────────────── |
| CONCEPT CLÉ N°2 — LA DÉCISION ARCHITECT (le cœur du sujet) |
| ───────────────────────────────────────────────────────────────────────────── |
| Deux façons de faire du transfer learning, TOUTES DEUX implémentées ici : |
| |
| Stratégie A — FINE-TUNING COMPLET (par défaut) |
| On ajuste TOUS les poids du modèle (les 68 millions), avec un |
| learning rate très faible pour ne pas casser ce qu'il sait déjà. |
| -> Meilleure performance (~0.94 F1) : le modèle adapte même sa |
| compréhension du français au vocabulaire des critiques de ciné. |
| |
| Stratégie B — BACKBONE GELÉ (option --freeze-backbone) |
| On "gèle" le corps du modèle (ses poids ne bougent plus du tout) et |
| on n'entraîne QUE la petite tête de classification (~600 000 |
| paramètres, soit 1 % du total). Le modèle gelé sert juste |
| d'extracteur de features (de représentations numériques du texte). |
| -> ~3x plus rapide à entraîner, mais moins bon (~0.85-0.89 F1) car |
| les représentations génériques ne sont pas adaptées à la tâche. |
| |
| Pour trancher : lancer les deux, comparer les training_metrics.json. |
| |
| ───────────────────────────────────────────────────────────────────────────── |
| COMMENT LANCER (depuis la racine du projet) : |
| # Stratégie A — fine-tuning complet (choix final du projet) : |
| python scripts/train.py |
| |
| # Stratégie B — backbone gelé, pour la comparaison : |
| python scripts/train.py --freeze-backbone --learning-rate 1e-3 |
| |
| # Version rapide pour vérifier que tout marche, sur CPU (~10 min) : |
| python scripts/train.py --max-train 2000 --max-val 500 --max-test 500 --epochs 1 |
| |
| Sur Google Colab (GPU gratuit, recommandé) : |
| !pip install -q transformers datasets accelerate scikit-learn |
| !python scripts/train.py |
| |
| SORTIE : le modèle entraîné est sauvegardé dans model/sentiment_model/ |
| """ |
|
|
| |
| import argparse |
| import json |
| import os |
| from pathlib import Path |
|
|
| PROJECT_ROOT = Path(__file__).resolve().parent.parent |
| HF_CACHE_DIR = PROJECT_ROOT / "hf_cache" |
| os.environ.setdefault("HF_HOME", str(HF_CACHE_DIR)) |
| os.environ.setdefault("HUGGINGFACE_HUB_CACHE", str(HF_CACHE_DIR / "hub")) |
| os.environ.setdefault("TRANSFORMERS_CACHE", str(HF_CACHE_DIR / "transformers")) |
|
|
| import numpy as np |
| import torch |
| from datasets import load_dataset |
| from sklearn.metrics import ( |
| accuracy_score, |
| confusion_matrix, |
| precision_recall_fscore_support, |
| ) |
| from transformers import ( |
| AutoModelForSequenceClassification, |
| AutoTokenizer, |
| DataCollatorWithPadding, |
| Trainer, |
| TrainingArguments, |
| ) |
|
|
| |
|
|
| |
| |
| |
| |
| MODEL_NAME = "cmarkea/distilcamembert-base" |
|
|
| |
| |
| |
| |
| ID2LABEL = {0: "négatif", 1: "positif"} |
| LABEL2ID = {"négatif": 0, "positif": 1} |
|
|
| |
| |
| |
| |
| |
| |
| MAX_LENGTH = 256 |
|
|
| |
| |
| |
| SEED = 42 |
|
|
| |
| |
| |
| OUTPUT_DIR = PROJECT_ROOT / "model" / "sentiment_model" |
| CHECKPOINTS_DIR = PROJECT_ROOT / "checkpoints" |
|
|
|
|
| |
| |
| |
| def parse_args() -> argparse.Namespace: |
| """Définit les options ajustables sans toucher au code. |
| |
| Exemple : `python scripts/train.py --epochs 3 --batch-size 8` |
| argparse lit ces options et les rend disponibles dans `args.epochs`, etc. |
| """ |
| parser = argparse.ArgumentParser( |
| description="Fine-tuning DistilCamemBERT sur Allociné" |
| ) |
| parser.add_argument("--max-train", type=int, default=8000, |
| help="Nombre d'avis pour l'entraînement (max 160 000)") |
| parser.add_argument("--max-val", type=int, default=2000, |
| help="Nombre d'avis pour la validation (max 20 000)") |
| parser.add_argument("--max-test", type=int, default=2000, |
| help="Nombre d'avis pour l'évaluation finale (max 20 000)") |
| parser.add_argument("--epochs", type=int, default=2, |
| help="Une ÉPOQUE = un passage complet sur tout le " |
| "dataset d'entraînement. 2 suffisent en fine-tuning : " |
| "au-delà, le modèle commence à mémoriser (overfitting)") |
| parser.add_argument("--batch-size", type=int, default=16, |
| help="Nombre d'avis traités EN MÊME TEMPS à chaque pas. " |
| "Plus grand = plus rapide mais plus de mémoire GPU. " |
| "Réduire à 8 si erreur 'out of memory'") |
| parser.add_argument("--learning-rate", type=float, default=2e-5, |
| help="Taille des pas d'apprentissage. 2e-5 (=0.00002) pour " |
| "le fine-tuning complet ; monter à ~1e-3 avec " |
| "--freeze-backbone (la tête part de zéro, elle " |
| "peut apprendre plus vite sans rien casser)") |
| parser.add_argument("--freeze-backbone", action="store_true", |
| help="Stratégie B : gèle DistilCamemBERT et n'entraîne " |
| "que la tête de classification") |
| return parser.parse_args() |
|
|
|
|
| |
| |
| |
| def load_allocine(): |
| """Charge le dataset Allociné (avis de cinéma) et vérifie sa structure. |
| |
| POURQUOI ALLOCINÉ ? |
| - Dataset de référence du sentiment en français : ~200 000 avis de films |
| réels écrits par des internautes. |
| - Les labels viennent des NOTES laissées par les utilisateurs (étoiles), |
| pas d'une annotation manuelle -> fiables et peu bruités. |
| - Classes équilibrées ~50/50 -> pas besoin de techniques de rééquilibrage. |
| - 3 splits DÉJÀ séparés -> aucun risque de "fuite de données" (un même |
| avis qui serait à la fois dans le train et dans le test fausserait |
| l'évaluation) : |
| train : 160 000 avis (pour apprendre) |
| validation : 20 000 avis (pour surveiller l'apprentissage) |
| test : 20 000 avis (pour la note finale, jamais vu avant) |
| |
| Chaque exemple a 2 colonnes : "review" (le texte) et "label" (0 ou 1). |
| """ |
| print("=" * 70) |
| print("ÉTAPE 1/5 — Chargement du dataset Allociné") |
| print("=" * 70) |
| |
| |
| dataset = load_dataset("allocine") |
|
|
| |
| |
| expected_columns = {"review", "label"} |
| actual_columns = set(dataset["train"].column_names) |
| if not expected_columns.issubset(actual_columns): |
| raise ValueError( |
| f"Colonnes attendues : {expected_columns}, trouvées : {actual_columns}" |
| ) |
|
|
| |
| print(f"\nSplits disponibles : {list(dataset.keys())}") |
| for split_name, split_data in dataset.items(): |
| labels = split_data["label"] |
| n_positive = sum(labels) |
| n_total = len(labels) |
| print(f" {split_name:<12} : {n_total:>7} avis " |
| f"({n_positive / n_total:.1%} positifs — dataset équilibré)") |
|
|
| |
| print("\nExemple d'avis POSITIF (label=1) :") |
| positive_example = next(ex for ex in dataset["train"] if ex["label"] == 1) |
| print(f" « {positive_example['review'][:150]}... »") |
| print("Exemple d'avis NÉGATIF (label=0) :") |
| negative_example = next(ex for ex in dataset["train"] if ex["label"] == 0) |
| print(f" « {negative_example['review'][:150]}... »") |
|
|
| return dataset |
|
|
|
|
| |
| |
| |
| def prepare_datasets(dataset, tokenizer, max_train: int, max_val: int, max_test: int): |
| """Réduit la taille des splits puis convertit les textes en nombres. |
| |
| POURQUOI SOUS-ÉCHANTILLONNER (8 000 avis au lieu de 160 000) ? |
| Grâce au transfer learning, le modèle connaît déjà le français : il n'a |
| besoin que d'apprendre la frontière positif/négatif. La performance |
| sature vite : ~94 % de F1 avec 8k exemples. Les 160k complets |
| n'apporteraient que 1-2 points de plus... pour 20x plus de calcul. |
| C'est un arbitrage coût/bénéfice assumé (et réglable via --max-train). |
| """ |
| print("\n" + "=" * 70) |
| print("ÉTAPE 2/5 — Sous-échantillonnage et tokenisation") |
| print("=" * 70) |
|
|
| |
| |
| |
| |
| train_data = dataset["train"].shuffle(seed=SEED).select(range(max_train)) |
| val_data = dataset["validation"].shuffle(seed=SEED).select(range(max_val)) |
| test_data = dataset["test"].shuffle(seed=SEED).select(range(max_test)) |
| print(f"Train : {len(train_data)} | Validation : {len(val_data)} " |
| f"| Test : {len(test_data)}") |
|
|
| def tokenize_batch(batch): |
| """Convertit un lot de textes en identifiants de tokens. |
| |
| ─── CONCEPT : LA TOKENISATION ─────────────────────────────────────── |
| Un réseau de neurones ne comprend que des NOMBRES. La tokenisation |
| fait la conversion texte -> nombres en 2 temps : |
| |
| 1. DÉCOUPAGE en sous-mots (algorithme SentencePiece) : |
| "Ce film est génial" -> ["▁Ce", "▁film", "▁est", "▁génial"] |
| Pourquoi des SOUS-mots et pas des mots entiers ? Pour gérer les |
| mots inconnus : "inregardable" n'est pas dans le vocabulaire, |
| mais "in" + "regard" + "able" oui. Aucun mot n'est jamais |
| vraiment "inconnu". (Le ▁ marque un début de mot.) |
| |
| 2. CONVERSION de chaque sous-mot en son numéro dans le vocabulaire |
| (32 000 entrées) : |
| ["▁Ce", "▁film", "▁est", "▁génial"] -> [149, 1621, 30, 11197] |
| |
| Le tokenizer ajoute aussi 2 tokens spéciaux : <s> au début (c'est |
| SA représentation finale que la tête de classification utilisera |
| pour décider) et </s> à la fin. |
| |
| truncation=True : coupe à MAX_LENGTH tokens si l'avis est trop long. |
| Le PADDING (compléter les avis courts pour égaliser les longueurs) |
| n'est PAS fait ici mais plus tard, batch par batch — voir |
| DataCollatorWithPadding plus bas. |
| ───────────────────────────────────────────────────────────────────── |
| """ |
| return tokenizer(batch["review"], truncation=True, max_length=MAX_LENGTH) |
|
|
| |
| |
| |
| |
| |
| train_data = train_data.map(tokenize_batch, batched=True, remove_columns=["review"]) |
| val_data = val_data.map(tokenize_batch, batched=True, remove_columns=["review"]) |
| test_data = test_data.map(tokenize_batch, batched=True, remove_columns=["review"]) |
|
|
| |
| sample = train_data[0] |
| print(f"\nExemple tokenisé : {len(sample['input_ids'])} tokens") |
| print(f" input_ids (10 premiers) : {sample['input_ids'][:10]}") |
| print(f" décodés : {tokenizer.convert_ids_to_tokens(sample['input_ids'][:10])}") |
|
|
| return train_data, val_data, test_data |
|
|
|
|
| |
| |
| |
| def apply_training_strategy(model, freeze_backbone: bool) -> str: |
| """Gèle (ou non) le backbone selon la stratégie choisie. |
| |
| COMMENT ON "GÈLE" UN MODÈLE ? |
| Chaque poids (paramètre) du modèle a un attribut `requires_grad` : |
| - True -> PyTorch calcule son gradient et l'optimiseur le modifie |
| à chaque pas d'apprentissage (le poids "apprend"). |
| - False -> le poids est ignoré par l'entraînement : il reste figé. |
| |
| Ici on met requires_grad=False sur tout le CORPS du modèle |
| (model.base_model = embeddings + les 6 couches d'attention). |
| La tête de classification (model.classifier), elle, n'en fait pas |
| partie : elle reste entraînable. Comme elle vient d'être initialisée |
| aléatoirement, c'est de toute façon elle qui doit apprendre. |
| """ |
| if freeze_backbone: |
| for param in model.base_model.parameters(): |
| param.requires_grad = False |
| strategy = "head-only (backbone gelé)" |
| else: |
| strategy = "fine-tuning complet" |
|
|
| |
| |
| total_params = sum(p.numel() for p in model.parameters()) |
| trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad) |
| print(f"\nStratégie : {strategy}") |
| print(f" Paramètres totaux : {total_params / 1e6:>6.1f}M") |
| print(f" Paramètres entraînables : {trainable_params / 1e6:>6.1f}M " |
| f"({trainable_params / total_params:.1%})") |
|
|
| return strategy |
|
|
|
|
| |
| |
| |
| def compute_metrics(eval_pred): |
| """Calcule les 4 métriques classiques de classification. |
| |
| Cette fonction est appelée AUTOMATIQUEMENT par le Trainer à chaque |
| évaluation. Elle reçoit : |
| logits : les scores bruts du modèle, tableau (n_exemples, 2) |
| -> pour chaque avis : [score_négatif, score_positif] |
| labels : les vraies réponses, tableau (n_exemples,) |
| |
| ─── CONCEPT : LES 4 MÉTRIQUES ────────────────────────────────────────── |
| En notant : VP = vrais positifs (prédit positif, c'était positif) |
| FP = faux positifs (prédit positif, c'était négatif) |
| FN = faux négatifs (prédit négatif, c'était positif) |
| |
| accuracy = % de bonnes réponses au total. |
| Suffisante ici car les classes sont équilibrées 50/50. |
| precision = VP / (VP + FP) |
| "Quand le modèle dit positif, a-t-il raison ?" |
| recall = VP / (VP + FN) |
| "Parmi les vrais positifs, combien en a-t-il trouvés ?" |
| f1 = moyenne harmonique de precision et recall. |
| Résume les deux en un seul chiffre ; c'est notre métrique |
| de référence pour choisir le meilleur checkpoint. |
| ───────────────────────────────────────────────────────────────────────── |
| """ |
| logits, labels = eval_pred |
| |
| |
| predictions = np.argmax(logits, axis=-1) |
| precision, recall, f1, _ = precision_recall_fscore_support( |
| labels, predictions, average="binary" |
| ) |
| return { |
| "accuracy": accuracy_score(labels, predictions), |
| "precision": precision, |
| "recall": recall, |
| "f1": f1, |
| } |
|
|
|
|
| def print_confusion_matrix(labels, predictions): |
| """Affiche la matrice de confusion : OÙ le modèle se trompe-t-il ? |
| |
| prédit négatif prédit positif |
| vrai négatif [ vrais négatifs ][ faux positifs ] |
| vrai positif [ faux négatifs ][ vrais positifs] |
| |
| La diagonale = les bonnes réponses. Hors diagonale = les erreurs. |
| Si une case d'erreur est beaucoup plus grosse que l'autre, le modèle a |
| un biais (ex : il prédit trop facilement "positif"). |
| """ |
| matrix = confusion_matrix(labels, predictions) |
| print("\nMatrice de confusion (test) :") |
| print(f"{'':>16} | {'prédit négatif':>15} | {'prédit positif':>15}") |
| print("-" * 52) |
| print(f"{'vrai négatif':>16} | {matrix[0][0]:>15} | {matrix[0][1]:>15}") |
| print(f"{'vrai positif':>16} | {matrix[1][0]:>15} | {matrix[1][1]:>15}") |
|
|
|
|
| |
| |
| |
| def main(): |
| args = parse_args() |
|
|
| |
| |
| use_gpu = torch.cuda.is_available() |
| device_name = torch.cuda.get_device_name(0) if use_gpu else "CPU" |
| print(f"Matériel utilisé : {device_name}") |
| if not use_gpu: |
| print("(Pas de GPU détecté — pensez à Google Colab, ou utilisez " |
| "--freeze-backbone / --max-train réduit pour aller plus vite.)") |
|
|
| |
| |
| if args.freeze_backbone and args.learning_rate < 1e-4: |
| print(f"\nAttention : --freeze-backbone avec un LR de {args.learning_rate} " |
| "est très lent à converger.\nLa tête part de zéro, elle supporte " |
| "un LR bien plus grand : recommandé --learning-rate 1e-3") |
|
|
| |
| dataset = load_allocine() |
|
|
| |
| |
| |
| |
| tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME) |
| train_data, val_data, test_data = prepare_datasets( |
| dataset, tokenizer, args.max_train, args.max_val, args.max_test |
| ) |
|
|
| |
| print("\n" + "=" * 70) |
| print("ÉTAPE 3/5 — Chargement du modèle + stratégie d'entraînement") |
| print("=" * 70) |
| |
| |
| |
| |
| |
| |
| model = AutoModelForSequenceClassification.from_pretrained( |
| MODEL_NAME, |
| num_labels=2, |
| id2label=ID2LABEL, |
| label2id=LABEL2ID, |
| ) |
|
|
| |
| strategy = apply_training_strategy(model, args.freeze_backbone) |
|
|
| |
| print("\n" + "=" * 70) |
| print("ÉTAPE 4/5 — Entraînement") |
| print("=" * 70) |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| training_args = TrainingArguments( |
| |
| output_dir=str(CHECKPOINTS_DIR), |
|
|
| |
| num_train_epochs=args.epochs, |
|
|
| |
| |
| per_device_train_batch_size=args.batch_size, |
| per_device_eval_batch_size=args.batch_size * 2, |
|
|
| |
| |
| |
| |
| |
| learning_rate=args.learning_rate, |
|
|
| |
| |
| |
| weight_decay=0.01, |
|
|
| |
| |
| |
| |
| warmup_ratio=0.1, |
|
|
| |
| |
| eval_strategy="epoch", |
| save_strategy="epoch", |
|
|
| |
| |
| |
| load_best_model_at_end=True, |
| metric_for_best_model="f1", |
|
|
| |
| |
| |
| fp16=use_gpu, |
|
|
| |
| |
| logging_steps=50, |
|
|
| |
| report_to="none", |
|
|
| seed=SEED, |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| data_collator = DataCollatorWithPadding(tokenizer=tokenizer) |
|
|
| |
| |
| |
| trainer = Trainer( |
| model=model, |
| args=training_args, |
| train_dataset=train_data, |
| eval_dataset=val_data, |
| data_collator=data_collator, |
| compute_metrics=compute_metrics, |
| ) |
|
|
| |
| train_result = trainer.train() |
| train_runtime = train_result.metrics["train_runtime"] |
| print(f"\nEntraînement terminé en {train_runtime:.0f} s") |
|
|
| |
| |
| |
| |
| |
| print("\n" + "=" * 70) |
| print("ÉTAPE 5/5 — Évaluation finale sur le set de test") |
| print("=" * 70) |
| test_output = trainer.predict(test_data) |
| test_metrics = test_output.metrics |
| test_predictions = np.argmax(test_output.predictions, axis=-1) |
|
|
| print("\nMétriques sur le set de TEST :") |
| for name in ("accuracy", "precision", "recall", "f1"): |
| print(f" {name:<10} : {test_metrics[f'test_{name}']:.4f}") |
| print_confusion_matrix(test_output.label_ids, test_predictions) |
|
|
| |
| OUTPUT_DIR.mkdir(parents=True, exist_ok=True) |
| |
| |
| trainer.save_model(str(OUTPUT_DIR)) |
| |
| |
| tokenizer.save_pretrained(str(OUTPUT_DIR)) |
|
|
| |
| |
| |
| metrics_summary = { |
| "model": MODEL_NAME, |
| "strategy": strategy, |
| "train_size": args.max_train, |
| "val_size": args.max_val, |
| "test_size": args.max_test, |
| "epochs": args.epochs, |
| "batch_size": args.batch_size, |
| "learning_rate": args.learning_rate, |
| "train_runtime_seconds": round(train_runtime, 1), |
| "test_accuracy": round(test_metrics["test_accuracy"], 4), |
| "test_precision": round(test_metrics["test_precision"], 4), |
| "test_recall": round(test_metrics["test_recall"], 4), |
| "test_f1": round(test_metrics["test_f1"], 4), |
| } |
| metrics_path = OUTPUT_DIR / "training_metrics.json" |
| with open(metrics_path, "w", encoding="utf-8") as f: |
| |
| json.dump(metrics_summary, f, indent=2, ensure_ascii=False) |
|
|
| print(f"\nModèle sauvegardé dans : {OUTPUT_DIR}") |
| print(f"Métriques sauvegardées : {metrics_path}") |
| print("\nÉtape suivante : tester avec python scripts/predict.py \"Votre avis ici\"") |
|
|
|
|
| |
| |
| if __name__ == "__main__": |
| main() |
|
|