Objectif : adapter un modèle vision-langage (VLM) avec LoRA/QLoRA en ciblant exclusivement son décodeur de langage. La tâche est volontairement bornée — décrire une image synthétique contenant une forme colorée selon un contrat de sortie strict — pour que l’effet du fine-tuning soit mesurable : nous comparons le modèle de base et le modèle adapté sur une métrique de conformité de format et sur la justesse champ par champ, sur des images jamais vues à l’entraînement.
Prérequis : - FT-01 (panorama du fine-tuning, LoRA sur GPT-2) - FT-02 (quantization 4-bit NF4, QLoRA) - FT-03 (SFT instruction-following, boucle entraînement/évaluation) - Notions de PyTorch et Transformers
Plan du notebook : 1. Modèles vision-langage et Qwen3.5-0.8B 2. La tâche image→texte bornée : contrat de sortie et dataset synthétique 3. Mesure de référence du modèle de base 4. LoRA ciblé sur le décodeur 5. Entraînement QLoRA 6. Évaluation comparative : base vs LoRA 7. Pourquoi le décodeur seulement ? 8. Nettoyage de la mémoire GPU 9. Exercices 10. Résumé
Matériel requis : GPU avec ~8 Go de VRAM (le corps du modèle 0,8 Md est chargé en 4-bit, mais la tour de vision, les activations et les adaptateurs s’ajoutent au budget).
Durée estimée : 45-60 minutes
Parcours : nous chargeons d’abord le modèle multimodal et son processor (section 1), construisons un dataset d’images synthétiques entièrement déterministe — dessinées par PIL, donc reproductibles au pixel près sans aucun téléchargement de données (section 2), mesurons le modèle de base sur un contrat qu’il n’a jamais vu (section 3), greffons des adaptateurs LoRA sur les seules couches du décodeur (section 4), entraînons (section 5), puis comparons chiffres en main base contre LoRA sur les mêmes images held-out (section 6). La section 7 justifie le choix « décodeur seulement » ; trois exercices prolongent : une forme nouvelle, un champ de contrat en plus, et le ciblage de la vision elle-même.
import warnings# Filtres poses AVANT les autres imports (issue #11725) : ils evitent que les# avertissements de dependances (barres de progression, cache HuggingFace)# n'encombrent — et ne fuient — dans les sorties du notebook.warnings.filterwarnings("ignore", message="IProgress not found.*")warnings.filterwarnings("ignore", message="Unable to fetch remote file.*")warnings.filterwarnings("ignore", message="Could not find a config file.*")import osimport gcimport jsonimport timeimport randomimport torchfrom PIL import Image, ImageDraw# Reproducibilite : graine fixee pour le dataset synthetique et les tirages PyTorchSEED =42random.seed(SEED)torch.manual_seed(SEED)print(f"PyTorch {torch.__version__}")print(f"CUDA : {torch.cuda.is_available()}")if torch.cuda.is_available():print(f"GPU : {torch.cuda.get_device_name(0)}") props = torch.cuda.get_device_properties(0)print(f"VRAM : {props.total_memory /1e9:.1f} Go")
PyTorch 2.13.0+cu126
CUDA : True
GPU : NVIDIA GeForce RTX 3070 Laptop GPU
VRAM : 8.6 Go
1. Modèles vision-langage et Qwen3.5-0.8B
Un modèle vision-langage (VLM) juxtapose deux organes : une tour de vision — un ViT qui découpe l’image en patchs et les encode en vecteurs — et un décodeur de langage qui génère du texte en s’appuyant sur ces vecteurs comme contexte. Entre les deux, un merger projette les patchs visuels dans l’espace d’embedding du décodeur : après lui, une image n’est plus qu’une séquence de « tokens visuels » parmi les tokens textuels.
Qwen3.5-0.8B illustre en miniature deux tendances des architectures récentes :
Hybridation de l’attention : toutes les couches du décodeur ne sont pas identiques. Les couches d’attention linéaire (Gated DeltaNet, module linear_attn) ont un coût mémoire constant en longueur de séquence ; les autres gardent l’attention complète (softmax classique, modules q_proj/k_proj/v_proj/o_proj). Les tokens d’image, nombreux, rendent ce compromis précieux.
Multimodalité native : le checkpoint embarque tour de vision ET décodeur ; la classe AutoModelForImageTextToText charge l’ensemble (Qwen3_5ForConditionalGeneration en interne).
Nous le chargeons comme en FT-02/FT-03 : corps quantifié 4-bit NF4 (QLoRA), calculs en bfloat16, double quantization des constantes. La différence nouvelle est le processor : pour un VLM, il fait deux travaux à la fois — il tokenise le texte ET prépare les pixels (redimensionnement, normalisation, découpage en patchs). Une image ne passe jamais directement dans le modèle : elle passe par le processor, qui produit les pixel_values et insère dans la séquence le nombre exact de tokens visuels correspondant à l’image.
Le point d’API à retenir : le texte donné au processor doit contenir le marqueur d’image <|vision_start|><|image_pad|><|vision_end|>. Le processor remplace ensuite <|image_pad|> par autant de tokens d’image que l’image compte de patchs — oublier ce marqueur, c’est soumettre une image que le modèle ne regardera jamais.
# Charger le modele vision-langage Qwen3.5-0.8B en 4-bit (QLoRA, comme FT-02/FT-03)from transformers import ( AutoModelForImageTextToText, AutoTokenizer, AutoProcessor, BitsAndBytesConfig,)MODEL_NAME ="Qwen/Qwen3.5-0.8B"bnb_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True,)print(f"Chargement de {MODEL_NAME} en 4-bit NF4...")tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)if tokenizer.pad_token isNone: tokenizer.pad_token = tokenizer.eos_tokentokenizer.padding_side ="right"# transformers 5.15 : la validation de docstring du module qwen3_vl imprime des# [ERROR] via un print inconditionnel (pas un warning, non filtrable) qui embarque# le chemin d'installation du venv -- on redirige stdout le temps de cet import (#11725).import contextlib, iowith contextlib.redirect_stdout(io.StringIO()): processor = AutoProcessor.from_pretrained(MODEL_NAME)# le processor embarque sa propre instance du tokenizer : memes regles de paddingif processor.tokenizer.pad_token isNone: processor.tokenizer.pad_token = processor.tokenizer.eos_tokenprocessor.tokenizer.padding_side ="right"model_base = AutoModelForImageTextToText.from_pretrained( MODEL_NAME, quantization_config=bnb_config, device_map="auto",)nb_params =sum(p.numel() for p in model_base.parameters())print(f"Modele charge. Parametres : {nb_params:,}")print(f"Tour de vision presente : {hasattr(model_base, 'model') andhasattr(model_base.model, 'visual')}")if torch.cuda.is_available():print(f"VRAM utilisee : {torch.cuda.memory_allocated() /1e6:.0f} Mo")
Chargement de Qwen/Qwen3.5-0.8B en 4-bit NF4...
Modele charge. Parametres : 555,419,712
Tour de vision presente : True
VRAM utilisee : 832 Mo
Lecture du chargement. La sortie affiche le total de paramètres du checkpoint — 555 419 712 ici, couvrant tour de vision et décodeur réunis, le corps étant stocké en 4-bit pour 832 Mo de VRAM. Deux points de contrôle : la ligne « tour de vision présente : True » confirme que AutoModelForImageTextToText a bien instancié l’architecture multimodale complète (et non le seul décodeur ForCausalLM, qui existe aussi pour ce checkpoint) ; la VRAM mesurée juste après chargement donne la référence à partir de laquelle les sections suivantes (adaptateurs, entraînement, génération) seront comparées. Le processor, lui, ne consomme pas de VRAM — mais c’est lui qui fixe combien de tokens une image « coûtera » dans la séquence, donc indirectement la longueur des lots d’entraînement.
2. La tâche image→texte bornée : contrat de sortie et dataset synthétique
La question pédagogique du notebook est étroite, et volontairement telle : que faut-il modifier dans un VLM pour qu’il honore un format de sortie arbitraire sur une tâche visuelle simple ? La réponse explorée ici : pas la vision — le décodeur.
La tâche : chaque image 128×128 contient exactement une forme colorée sur fond blanc. Le modèle doit répondre selon le contrat strict :
Trois formes, quatre couleurs — 12 combinaisons possibles. Nous générons :
24 images d’entraînement (2 par combinaison), avec un léger jitter de taille et de position pour éviter qu’une disposition d’image précise n’apparaisse deux fois à l’identique ;
6 images de test held-out — des combinaisons tirées parmi les 12, jamais vues à l’entraînement, dessinées avec un autre grain aléatoire. C’est cette séparation qui donnera son sens à l’évaluation : mesurer la généralisation du contrat, pas sa mémorisation image par image.
Deux choix de conception à noter. D’abord, le dataset est synthétique et déterministe : dessiné par PIL avec un générateur aléatoire local (random.Random(graine)), il se reproduit au pixel près d’une session à l’autre — pas de téléchargement de données, pas de variation cachée, la graine suffit. Ensuite, le jitter est borné (positions dans une zone centrale, tailles dans un intervalle) : la tâche reste visuellement triviale pour la tour de vision. C’est délibéré — nous voulons que tout écart de mesure soit attribuable au contrat de langage, pas à la difficulté perceptive.
# Dataset synthetique — une forme coloree par image 128x128, dessinee par PILFORMES = ["disque", "carre", "triangle"]COULEURS = {"rouge": (214, 40, 40),"vert": (40, 160, 60),"bleu": (40, 70, 214),"jaune": (230, 210, 40),}FOND = (250, 250, 250)TAILLE_IMAGE =128def dessiner_forme(forme, couleur_rgb, taille, cx, cy):"""Dessine une forme centree en (cx, cy) sur un canvas blanc 128x128.""" image = Image.new("RGB", (TAILLE_IMAGE, TAILLE_IMAGE), FOND) dessin = ImageDraw.Draw(image) demi = taille //2if forme =="disque": dessin.ellipse([cx - demi, cy - demi, cx + demi, cy + demi], fill=couleur_rgb)elif forme =="carre": dessin.rectangle([cx - demi, cy - demi, cx + demi, cy + demi], fill=couleur_rgb)elif forme =="triangle": sommets = [(cx, cy - demi), (cx - demi, cy + demi), (cx + demi, cy + demi)] dessin.polygon(sommets, fill=couleur_rgb)return imagedef generer_ensemble(nb_par_combinaison, graine):"""Genere les 12 combinaisons forme x couleur, avec jitter borne, de facon deterministe. Le generateur random.Random local ne touche pas l'etat aleatoire global : deux appels avec la meme graine produisent les memes images au pixel pres. """ gen = random.Random(graine) exemples = []for forme in FORMES:for couleur in COULEURS:for _ inrange(nb_par_combinaison): taille = gen.randint(28, 44) cx = gen.randint(48, 80) cy = gen.randint(48, 80) image = dessiner_forme(forme, COULEURS[couleur], taille, cx, cy) exemples.append({"image": image, "forme": forme, "couleur": couleur}) gen.shuffle(exemples)return exemplestrain_brut = generer_ensemble(nb_par_combinaison=2, graine=SEED) # 12 x 2 = 24candidats_test = generer_ensemble(nb_par_combinaison=1, graine=SEED +1000) # 12 candidatstest_exemples = candidats_test[::2] # 6 held-outprint(f"Train : {len(train_brut)} images ({len(FORMES)} formes x {len(COULEURS)} couleurs x 2)")print(f"Test held-out : {len(test_exemples)} images")print("\nCombinaisons de test :")for ex in test_exemples:print(f" {ex['forme']:<10}{ex['couleur']}")
Train : 24 images (3 formes x 4 couleurs x 2)
Test held-out : 6 images
Combinaisons de test :
disque jaune
carre jaune
disque bleu
carre rouge
disque rouge
triangle vert
Lecture du dataset. La sortie confirme la structure voulue : 24 images d’entraînement couvrant uniformément les 12 combinaisons (2 chacune), et 6 images de test listées avec leur étiquette. Vérifiez dans la liste que les combinaisons de test recouvrent plusieurs formes et plusieurs couleurs — 3 disques, 2 carrés, 1 triangle sur 4 couleurs distinctes : c’est la condition pour que l’accuracy par champ (section 3) puisse distinguer un modèle qui devine toujours « disque » d’un modèle qui regarde vraiment l’image. Remarquez aussi ce que le jitter ne fait pas : il varie taille et position dans des bornes étroites, donc deux images d’une même combinaison diffèrent, mais restent perceptivement identiques — l’apprentissage portera sur l’association forme/couleur, pas sur la mémorisation de pixels exacts.
# Le contrat de sortie : paire (prompt multimodal, reponse attendue)PROMPT_MODELE = ("### Human: <|vision_start|><|image_pad|><|vision_end|> Decrivez l'image.\n### Assistant:")def construire_reponse(forme, couleur):"""La reponse attendue : le contrat de format que le LoRA doit installer."""returnf"[IMG] forme={forme} couleur={couleur} [FIN]"train_exemples = [ {"image": ex["image"],"texte": f"{PROMPT_MODELE}{construire_reponse(ex['forme'], ex['couleur'])}", }for ex in train_brut]print("Exemple complet (placeholder d'image non encore expande) :")print("-"*60)print(train_exemples[0]["texte"])print("-"*60)# Longueur de la part texte : le placeholder compte pour 1 jeton ici,# le processor l'expandra au moment du lottailles = [len(processor.tokenizer(t["texte"])["input_ids"]) for t in train_exemples]print(f"\n{len(train_exemples)} paires formatees")print(f"Longueur texte : min {min(tailles)}, max {max(tailles)}, moy {sum(tailles) /len(tailles):.0f} jetons")
Exemple complet (placeholder d'image non encore expande) :
------------------------------------------------------------
### Human: <|vision_start|><|image_pad|><|vision_end|> Decrivez l'image.
### Assistant: [IMG] forme=carre couleur=rouge [FIN]
------------------------------------------------------------
24 paires formatees
Longueur texte : min 29, max 31, moy 30 jetons
Lecture du formatage. L’exemple affiché montre le squelette complet d’un exemple d’entraînement : le prompt ### Human: contient le marqueur d’image encadré de <|vision_start|>/<|vision_end|> suivi de la consigne, et la cible après ### Assistant: porte le contrat [IMG] forme=… couleur=… [FIN]. Les longueurs affichées — 29 à 31 tokens, moyenne 30 — ne couvrent que la part textuelle : au moment où le processor prépare un lot, le marqueur <|image_pad|> sera remplacé par le nombre exact de tokens visuels de l’image (les patchs après fusion), allongeant d’autant la séquence réelle. C’est pour cela qu’un VLM « consomme » tant de contexte pour si peu de mots : la part visuelle domine la séquence effective (mesurée à la section 5). Comme en FT-03, ce format (### Human: / ### Assistant:) est un contrat entre entraînement et inférence — identique des deux côtés.
3. Mesure de référence du modèle de base
Avant tout fine-tuning, il faut un point de comparaison honnête. Nous soumettons au modèle de base les 6 images held-out avec exactement le prompt qui servira à l’entraînement, puis nous mesurons trois indicateurs :
respecte_format — la conformité au contrat : la réponse commence par [IMG] et contient [FIN]. C’est la métrique centrale du notebook : elle mesure un comportement de langage (suivre un format), pas une connaissance.
accuracy_forme — le champ forme= extrait de la réponse est-il le bon ?
accuracy_couleur — le champ couleur= est-il le bon ?
L’extraction est volontairement fruste (découpage sur les espaces, lecture des préfixes forme=/couleur=) : si le modèle n’émet pas les champs, l’extraction rend None et le champ est compté faux — aucune indulgence. Ce protocole étant écrit une fois et appliqué tel quel aux deux modèles (section 6), la comparaison base vs LoRA ne dépend d’aucun réglage caché.
Que prédire pour un modèle de base ? Rien ne l’a jamais exposé à ce contrat arbitraire : il continuera vraisemblablement le prompt en prose libre, ou recyclera des formats vus au pré-entraînement. La mesure dira précisément à quelle distance il est du contrat — c’est cette distance que le LoRA devra combler.
# Fonctions d'evaluation : generation multimodale + metriques de conformitedef generer_vlm(model, image, prompt, max_new_tokens=40):"""Genere la suite du prompt pour une image donnee (decodage glouton, deterministe).""" entrees = processor(text=prompt, images=image, return_tensors="pt").to(model.device)with torch.no_grad(): sortie = model.generate(**entrees, max_new_tokens=max_new_tokens, do_sample=False, pad_token_id=processor.tokenizer.pad_token_id, )return processor.tokenizer.decode( sortie[0][entrees["input_ids"].shape[1]:], skip_special_tokens=True, ).strip()def extraire_champs(texte):"""Extrait les valeurs des champs forme=... et couleur=... (None si absents).""" forme, couleur =None, Nonefor mot in texte.split():if mot.startswith("forme="): forme = mot.split("=", 1)[1]elif mot.startswith("couleur="): couleur = mot.split("=", 1)[1]return forme, couleurdef respecte_format(texte):"""Conformite au contrat : demarre par [IMG] et contient [FIN]."""return texte.startswith("[IMG]") and"[FIN]"in textedef evaluer_modele(model, exemples, nom="modele"):"""Soumet chaque image au modele, mesure conformite et justesse champ par champ.""" lignes = []for ex in exemples: sortie = generer_vlm(model, ex["image"], PROMPT_MODELE) forme_pred, couleur_pred = extraire_champs(sortie) lignes.append({"verite": f"{ex['forme']}/{ex['couleur']}","sortie": sortie,"respecte": respecte_format(sortie),"forme_ok": forme_pred == ex["forme"],"couleur_ok": couleur_pred == ex["couleur"], }) n =len(lignes) metriques = {"respecte_format": sum(l["respecte"] for l in lignes) / n,"accuracy_forme": sum(l["forme_ok"] for l in lignes) / n,"accuracy_couleur": sum(l["couleur_ok"] for l in lignes) / n, }print(f"--- Evaluation : {nom} ({n} images held-out) ---")print(f"{'Verite':<18}{'Conforme':<10}{'Forme':<7}{'Couleur':<9}Sortie (60 car.)")for l in lignes: extrait = l["sortie"][:60].replace("\n", " ")print(f"{l['verite']:<18}{str(l['respecte']):<10}{str(l['forme_ok']):<7}{str(l['couleur_ok']):<9}{extrait}")print(f"\nConformite de format : {metriques['respecte_format']:.2f}")print(f"Accuracy forme : {metriques['accuracy_forme']:.2f}")print(f"Accuracy couleur : {metriques['accuracy_couleur']:.2f}")return metriques, lignes
3a. Passage du modèle de base sur les images held-out
Chaque image est soumise seule (batch de 1, décodage glouton pour que la mesure soit reproductible). La table affiche la vérité terrain, les trois verdicts booléens et un extrait de la sortie brute — lisez l’extrait avant les métriques : c’est lui qui montre comment le modèle échoue (prose libre ? champs déformés ? boucle ?), les nombres ne donnent que l’amplitude.
# Mesure de reference : modele de base (avant tout fine-tuning)metriques_base, lignes_base = evaluer_modele(model_base, test_exemples, nom="modele de base")
--- Evaluation : modele de base (6 images held-out) ---
Verite Conforme Forme Couleur Sortie (60 car.)
disque/jaune False False False <think> </think> L'image présente une simple composition g
carre/jaune False False False <think> </think> D'après l'image, il s'agit d'une image si
disque/bleu False False False <think> </think> L'image présente un cercle bleu pur sur u
carre/rouge False False False <think> </think> D'après l'image, il s'agit d'une image si
disque/rouge False False False <think> </think> La image présente un cercle rouge centré
triangle/vert False False False <think> </think> L'image montre un triangle vert simple et
Conformite de format : 0.00
Accuracy forme : 0.00
Accuracy couleur : 0.00
Lecture de la mesure de référence. Conformité 0.00, accuracy forme 0.00, accuracy couleur 0.00 — le plancher, comme attendu. Mais lisez les extraits bruts : c’est le résultat le plus instructif du notebook. Le modèle de base voit parfaitement les images — « un cercle bleu pur », « un triangle vert simple », « un cercle rouge centré » : la description en prose contient la bonne forme ET la bonne couleur à chaque fois. Le régime d’échec n’est donc pas perceptuel : la tour de vision fonctionne, le décodeur sait nommer ce qu’il voit. Ce qui manque est uniquement le contrat — aucun forme=…, aucun [IMG], aucune balise. La colonne Couleur affiche 0.00 alors que la couleur est correcte en prose : la métrique ne récompense que le champ formaté, pas la connaissance. C’est précisément ce que le LoRA sur le décodeur va installer, sans toucher à la vision.
4. LoRA ciblé sur le décodeur
Le choix structurant du notebook : nous adaptons exclusivement le décodeur de langage. La LoraConfig ci-dessous cible trois familles de modules, toutes côté langage :
Famille
Modules
Rôle
Attention complète
q_proj, k_proj, v_proj, o_proj
couches softmax du décodeur hybride
Attention linéaire
linear_attn.out_proj
couches Gated DeltaNet (coût constant en longueur)
MLP
gate_proj, up_proj, down_proj
projections feed-forward de chaque couche
C’est la même liste que FT-03, adaptée à l’architecture hybride de Qwen3.5 : le rang r=16 fixe la capacité des adaptateurs, lora_alpha=32 leur échelle (ratio 2:1 usuel), le dropout 0,05 régularise un entraînement court. La tour de vision, elle, reste gelée — le pourquoi fait l’objet de la section 7 ; l’extension (cibler aussi le merger visuel) est l’exercice 3.
Comme en FT-03, prepare_model_for_kbit_training prépare le corps 4-bit à recevoir des gradients (gel fin, normes en float32, cache désactivé pour le gradient checkpointing), puis get_peft_model greffe les adaptateurs. La sortie de print_trainable_parameters() est le chiffre à retenir : la part de paramètres réellement entraînés dans le modèle multimodal complet.
# LoRA sur le decodeur : preparer le corps 4-bit puis greffer les adaptateursfrom peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training, TaskTypemodel_base = prepare_model_for_kbit_training(model_base)lora_config = LoraConfig( task_type=TaskType.CAUSAL_LM, r=16, # rang des matrices LoRA lora_alpha=32, # facteur d'echelle (ratio 2:1 usuel) lora_dropout=0.05, # regularisation pour un entrainement court target_modules=["q_proj", "k_proj", "v_proj", "o_proj", # attention complete"linear_attn.out_proj", # attention lineaire (Gated DeltaNet)"gate_proj", "up_proj", "down_proj", # MLP ], bias="none",)model_sft = get_peft_model(model_base, lora_config)model_sft.print_trainable_parameters()if torch.cuda.is_available():print(f"\nVRAM avant entrainement : {torch.cuda.memory_allocated() /1e6:.0f} Mo")
trainable params: 7,274,496 || all params: 860,260,416 || trainable%: 0.8456
VRAM avant entrainement : 1385 Mo
Lecture de la configuration LoRA.print_trainable_parameters() affiche 7 274 496 paramètres entraînables sur 860 260 416 au total — 0,8456 %. À titre de comparaison, FT-03 reporte exactement les mêmes nombres ; les deux outputs ne montrent aucune croissance, et LoRA cible les modules nommés ci-dessous (le périmètre exact de la composition du dénominateur total — décodeur + éventuelles autres composantes du modèle — n’est pas établi par cette seule comparaison). La cible LoRA — q/k/v/o_proj, linear_attn.out_proj, gate/up/down_proj — est strictement côté langage ; peft n’ajoute aucune matrice d’adaptateur sur model.visual.* dans cette configuration. La VRAM avant entraînement (1385 Mo) donne le point de départ mémoire : les adaptateurs et leurs états d’optimiseur s’ajouteront à elle, mais le corps 4-bit ne bougera pas — c’est l’économie QLoRA. Vérifiez enfin dans la sortie de PEFT les modules réellement ciblés : q/k/v/o_proj pour l’attention complète, linear_attn.out_proj pour l’attention linéaire, gate/up/down_proj pour les MLP — les deux familles d’attention de l’architecture hybride Qwen3.5.
5. Entraînement QLoRA
Le pipeline reprend la mécanique du FT-03 avec une différence de taille : le collator doit passer par le processor, car chaque exemple mêle une image et du texte. C’est lui qui, lot par lot, tokenise les textes, prépare les pixels, remplace le marqueur <|image_pad|> par le nombre exact de tokens visuels, padde à la longueur du lot — et fabrique les labels : les positions de padding sont masquées à −100 (hors loss), ainsi que les tokens d’image eux-mêmes (le modèle n’a pas à apprendre à émettre des tokens visuels, seulement la réponse texte).
Hyperparamètres clés : - per_device_train_batch_size=1 + gradient_accumulation_steps=8 — les séquences contiennent les tokens d’image : le batch effectif de 8 est reconstitué par accumulation pour tenir dans 8 Go de VRAM - num_train_epochs=5 — 24 exemples × 5 époques ≈ 15 pas d’optimisation, un budget délibérément minuscule - learning_rate=2e-4 — taux standard LoRA, ~100× celui d’un pré-entraînement, sûr car seuls les adaptateurs bougent - warmup_steps=3 — l’échauffement se compte en pas (transformers 5.x a retiré warmup_ratio) - bf16=True + gradient_checkpointing=True — précision large et activations recalculées : le duo qui fait tenir l’entraînement d’un VLM sur une carte consumer
# Dataset HuggingFace + collator multimodal (le processor traite chaque lot image+texte)from datasets import Datasetfrom transformers import TrainingArguments, Trainertrain_dataset = Dataset.from_list(train_exemples)print(f"Dataset pret : {len(train_dataset)} exemples, colonnes : {train_dataset.column_names}")def collate_fn(exemples):"""Applique le processor aux (image, texte) du lot et fabrique les labels masques.""" lots = processor( text=[e["texte"] for e in exemples], images=[e["image"] for e in exemples], padding=True, return_tensors="pt", ) labels = lots["input_ids"].clone()# padding hors loss... labels[labels == processor.tokenizer.pad_token_id] =-100# ...et jetons d'image hors loss : le modele apprend la reponse, pas les tokens visuels labels[labels == processor.image_token_id] =-100 lots["labels"] = labelsreturn lots# Verifier le collator sur un mini-lot de 2 exemplesapercu = collate_fn([train_exemples[0], train_exemples[1]])print(f"Forme input_ids du lot : {apercu['input_ids'].shape}")print(f"Positions hors loss (labels == -100) : {(apercu['labels'] ==-100).sum().item()} jetons")
Dataset pret : 24 exemples, colonnes : ['image', 'texte']
Forme input_ids du lot : torch.Size([2, 94])
Positions hors loss (labels == -100) : 128 jetons
Lecture de la préparation. Le mini-lot affiche une vraie longueur de 94 tokens pour 2 exemples — comparez aux longueurs purement textuelles de la section 2 (29-31 tokens par exemple) : l’écart, ce sont les tokens visuels insérés par le processor à la place du marqueur <|image_pad|>, ~35 patches par image après fusion. Les 128 positions masquées (labels à −100) cumulent le padding d’alignement et ces tokens d’image : la loss ne portera que sur le texte utile — le modèle n’apprend ni à émettre des tokens visuels, ni à prédire le padding. Le Dataset.from_list stocke les images PIL (encodage Arrow) et les restitue en objets image au tirage — le collator est le seul endroit où le processor intervient pendant l’entraînement.
Debut de l'entrainement QLoRA (24 exemples, 5 epoques)...
[15/15 04:47, Epoch 5/5]
Step
Training Loss
5
4.597577
10
0.634687
15
0.070737
Entrainement termine en 308.4s (5.1 min)
Perte finale : 1.7677
VRAM apres entrainement : 1452 Mo
Lecture de l’entraînement. Trois mesures sortent de cette cellule. La durée : 308.4 s (5.1 min) pour 24 exemples × 5 époques ≈ 15 pas d’optimisation, chacun cumulant 8 micro-lots. La loss finale : 1.7677 — le contrat est court et répétitif, la descente est nette dès les premiers pas ; mais la leçon du FT-03 reste entière : une loss basse sur un mini-dataset mesure la mémorisation locale, pas la généralisation, et ne dit rien de la conformité sur les images held-out. La VRAM : 1452 Mo après entraînement (contre 1385 avant) — l’écart de ~67 Mo correspond aux états optimiseur des adaptateurs et aux activations du gradient checkpointing. Le verdict utile se lira à la section 6, sur des images jamais vues.
6. Évaluation comparative : base vs LoRA
Le moment de vérité : nous reprenons à l’identique le protocole de la section 3 — mêmes 6 images held-out, même prompt, même fonction d’évaluation, même décodage glouton — sur le modèle affiné. Le tableau croise les trois métriques mesurées avant entraînement (stockées dans metriques_base) avec leurs valeurs après entraînement, et affiche le delta signé ; deux sorties brutes côte à côte montrent le changement de régime textuel. Rien n’est reconstruit à la main : chaque nombre du tableau vient des variables mesurées dans les cellules précédentes.
# Evaluation comparative : modele de base vs modele LoRA (memes images, meme protocole)model_sft.eval()metriques_lora, lignes_lora = evaluer_modele(model_sft, test_exemples, nom="modele LoRA")print()print("="*66)print("COMPARATIF : MODELE DE BASE vs MODELE LORA (6 images held-out)")print("="*66)print(f"{'Metrique':<22}{'Base':>12}{'LoRA':>12}{'Delta':>12}")print("-"*66)for cle in ("respecte_format", "accuracy_forme", "accuracy_couleur"): delta = metriques_lora[cle] - metriques_base[cle]print(f"{cle:<22}{metriques_base[cle]:>12.2f}{metriques_lora[cle]:>12.2f}{delta:>+12.2f}")print("\nSorties brutes cote a cote (2 premieres images) :")for i inrange(min(2, len(lignes_lora))):print(f"\nImage {i +1} — verite : {lignes_lora[i]['verite']}")print(f" Base : {lignes_base[i]['sortie'][:110]}")print(f" LoRA : {lignes_lora[i]['sortie'][:110]}")
--- Evaluation : modele LoRA (6 images held-out) ---
Verite Conforme Forme Couleur Sortie (60 car.)
disque/jaune True True True [IMG] forme=disque couleur=jaune [FIN]
carre/jaune True True True [IMG] forme=carre couleur=jaune [FIN]
disque/bleu True True True [IMG] forme=disque couleur=bleu [FIN]
carre/rouge True True True [IMG] forme=carre couleur=rouge [FIN]
disque/rouge True True True [IMG] forme=disque couleur=rouge [FIN]
triangle/vert True True True [IMG] forme=triangle couleur=vert [FIN]
Conformite de format : 1.00
Accuracy forme : 1.00
Accuracy couleur : 1.00
==================================================================
COMPARATIF : MODELE DE BASE vs MODELE LORA (6 images held-out)
==================================================================
Metrique Base LoRA Delta
------------------------------------------------------------------
respecte_format 0.00 1.00 +1.00
accuracy_forme 0.00 1.00 +1.00
accuracy_couleur 0.00 1.00 +1.00
Sorties brutes cote a cote (2 premieres images) :
Image 1 — verite : disque/jaune
Base : <think>
</think>
L'image présente une simple composition graphique :
* **Contenu central :** Il y a un ce
LoRA : [IMG] forme=disque couleur=jaune [FIN]
Image 2 — verite : carre/jaune
Base : <think>
</think>
D'après l'image, il s'agit d'une image simplifiée et abstraite.
Voici une décomposition de
LoRA : [IMG] forme=carre couleur=jaune [FIN]
Lecture du comparatif. Le tableau parle de lui-même : conformité 0.00 → 1.00, accuracy forme 0.00 → 1.00, accuracy couleur 0.00 → 1.00 — 6/6 images held-out parfaitement décrites, forme et couleur exactes, contrat respecté de bout en bout. Trois choses à remarquer. (1) Conformité et justesse montent ensemble : le cas redouté (conformité maximale, accuracy médiocre — le squelette appris sans lire l’image) n’a pas eu lieu ; la loss a bien forcé le contenu des champs. (2) La vision gelée a suffi : la tour de vision et le merger, intouchés, extrayaient déjà les bons traits (la section 3 le montrait en prose) — le LoRA a seulement appris au décodeur à les lire dans le format du contrat. (3) La généralisation est réelle : les 6 images de test ne sont pas dans les 24 d’entraînement (jitter de position/taille différent) — le contrat transfère à des pixels jamais vus, comme le format balise du FT-03 transférait à des reformulations.
7. Pourquoi le décodeur seulement ?
La question mérite sa section, car elle généralise au-delà de ce TP : où faut-il placer ses adaptateurs quand on fine-tune un VLM ? Notre réponse ici — le décodeur — tient en trois arguments.
1. Le comportement à installer est un comportement de langage. Le contrat [IMG] forme=… couleur=… [FIN] n’exige rien de nouveau côté perception : distinguer un disque rouge d’un carré bleu est une tâche que la tour de vision, gelée, résout déjà largement. Ce qui manque au modèle, c’est la mise en forme : émettre les délimiteurs, les noms de champs, l’ordre, la fermeture [FIN]. Or tout cela vit dans le décodeur — c’est lui qui produit la séquence de tokens. Adapter les projections d’attention et de MLP du décodeur donne à LoRA exactement les degrés de liberté qu’il faut pour réorganiser le langage de sortie.
2. Geler la vision préserve un extracteur de traits déjà compétent. La tour de vision a été pré-entraînée sur des millions d’images ; la recalibrer sur 24 images synthétiques de formes primaires serait au mieux inutile, au pire destructeur — le sur-apprentissage d’un extracteur massif sur un dataset minuscule est la recette classique de la dégradation. Le gel est ici une protection.
3. Le budget suit la décision. Chaque module ciblé ajoute ses matrices LoRA (rang × dimensions). Cibler le décodeur seul garde l’empreinte et l’entraînement dans les bornes d’une carte 8 Go ; ajouter la vision grossit les adaptateurs pour un gain qui, sur une tâche aussi simple perceptuellement, doit être démontré plutôt que présumé.
La frontière n’est pas dogmatique pour autant : quand la tâche exige une discrimination visuelle que le merger encode mal (nuances fines, positions, relations spatiales), cibler les projections visuelles — le merger model.visual.merger (linear_fc1, linear_fc2) — devient l’extension naturelle. C’est précisément l’exercice 3 : mesurer ce que ce ciblage ajoute (ou non) en paramètres et en métriques.
8. Nettoyage de la mémoire GPU
Nous libérons le modèle et ses adaptateurs avant la section exercices — l’exercice 3 recharge un modèle frais, et une carte 8 Go n’accueillera pas deux copies. La mesure avant/après quantifie ce que pèsent corps 4-bit + adaptateurs + cache d’allocation.
Les trois exercices prolongent chacun un fil du notebook : (1) la richesse du contrat — une forme nouvelle exige-t-elle plus de données pour la même conformité ? ; (2) l’élargissement du contrat — un champ position= teste si le modèle lit l’image, pas seulement le format ; (3) le périmètre du LoRA — cibler aussi la vision et mesurer ce que ça change vraiment. Chaque exercice réutilise les cellules précédentes ; rechargez un modèle frais comme en section 1 si vous avez exécuté le nettoyage de la section 8.
Exercice 1 : une quatrième forme — l’étoile
Ajoutez une forme etoile au dataset (10 sommets en alternant rayon externe et rayon interne pour ImageDraw.polygon), régénérez entraînement et test, réentraînez avec les mêmes hyperparamètres, mesurez à nouveau la conformité.
Questions : avec combien d’exemples par combinaison la conformité maximale était-elle atteinte à 3 formes — et ce nombre suffit-il à 4 formes ? La conformité au format reste-t-elle acquise plus vite que la justesse du nouveau champ ?
# Exercice 1 : ajouter la forme "etoile" au dataset et re-entrainer# TODO etudiant : completez dessiner_etoile, regenerez le dataset avec 4 formes,# relancez les cellules des sections 2 a 6, puis relevez la conformite obtenue.def dessiner_etoile(cx, cy, rayon_ext, rayon_int):"""Retourne les 10 sommets d'une etoile centree en (cx, cy)."""# Indice : bouclez sur 10 sommets en alternant rayon_ext et rayon_int,# angle progressant de pi/5 par sommet (depart en haut : -pi/2),# puis dessinez la liste avec draw.polygon(...) dans dessiner_forme.passFORMES_ETENDUES = ["disque", "carre", "triangle"] # TODO etudiant : ajoutez "etoile"exemples_par_combinaison =None# TODO etudiant : combien d'exemples pour la conformite ?print("Exercice a completer : etoile dessinee, dataset regenere, conformite relevee")
Exercice a completer : etoile dessinee, dataset regenere, conformite relevee
Exercice 2 : étendre le contrat au champ position
Générez des formes positionnées dans la moitié haute ou basse de l’image, étendez la réponse attendue en [IMG] forme=… couleur=… position=<haut|bas> [FIN], ajoutez accuracy_position à l’évaluation (même motif que accuracy_forme), réentraînez et comparez.
Question : la conformité du nouveau champ vient-elle aussi vite que celle du format — et l’accuracy position du modèle LoRA rejoint-elle celle des autres champs, ou la localisation spatiale résiste-t-elle davantage ?
# Exercice 2 : etendre le contrat au champ position=<haut|bas># TODO etudiant : binarisez cy (cy < 64 -> "haut", sinon "bas") dans generer_ensemble,# etendez construire_reponse(), ajoutez accuracy_position a evaluer_modele()# (meme motif que accuracy_forme) ; re-entrainez et comparez avant/apres.resultats_position = {"accuracy_position_base": None, # TODO etudiant : mesure apres re-evaluation du modele de base"accuracy_position_lora": None, # TODO etudiant : mesure apres re-entrainement}# Indice : position et couleur varient independamment — verifiez que vos images# de test couvrent les deux positions, sinon l'accuracy position ne mesurera rien.print("Exercice a completer : contrat etendu, metrique ajoutee, comparaison faite")
Exercice a completer : contrat etendu, metrique ajoutee, comparaison faite
Exercice 3 : cibler aussi la vision — le merger dans target_modules
Rechargez un modèle 4-bit frais et construisez une LoraConfig identique à celle de la section 4 plus les modules du merger visuel (visual.merger.linear_fc1, visual.merger.linear_fc2). Comparez trois choses avec le run décodeur-seul : le nombre de paramètres entraînables (print_trainable_parameters), la VRAM d’entraînement, et les métriques finales sur les mêmes 6 images.
Question : sur une tâche où la vision était déjà compétente, le surcoût de paramètres achète-t-il un gain de conformité ou d’accuracy mesurable — ou le décodeur seul était-il déjà le bon périmètre ?
# Exercice 3 : cibler aussi la vision — ajouter visual.merger aux target_modules# TODO etudiant : rechargez un modele 4-bit frais, construisez la LoraConfig avec# les modules du merger en plus, re-entrainez, comparez params / VRAM / metriques.config_vision =None# TODO etudiant : LoraConfig identique + ["visual.merger.linear_fc1", "visual.merger.linear_fc2"]# Indice : apres la section 8 le GPU est libre — rechargez comme en section 1# (meme BitsAndBytesConfig), puis prepare_model_for_kbit_training, get_peft_model,# et relevez print_trainable_parameters() et les trois metriques held-out.print("Exercice a completer : run decoder+vision livre et compare au run decoder-seul")
Exercice a completer : run decoder+vision livre et compare au run decoder-seul
10. Résumé du FT-06
Concept
Détail
Modèle vision-langage
Tour de vision (ViT) + merger + décodeur — chargé via AutoModelForImageTextToText (555 M params), préparé via AutoProcessor
Tâche bornée
12 combinaisons forme × couleur sur images PIL déterministes — 24 train / 6 held-out
Contrat de sortie
[IMG] forme=… couleur=… [FIN] — un comportement de langage, mesurable
Métriques
respecte_format (conformité), accuracy_forme, accuracy_couleur — même protocole avant/après
LoRA décodeur-seul
7,3 M paramètres (0,85 %) sur attention hybride + MLP ; tour de vision et merger gelés
Entraînement QLoRA
Corps 4-bit NF4, bf16, gradient checkpointing — 308.4 s (5.1 min), loss 1.7677, VRAM ≤ 1452 Mo
Résultat
Conformité 0.00 → 1.00, forme 0.00 → 1.00, couleur 0.00 → 1.00 sur 6 held-out — le base voyait déjà (prose correcte) mais ignorait le contrat
Bilan du TP : le modèle de base décrivait chaque image correctement en prose (« un cercle bleu pur », « un triangle vert simple ») — la défaillance était purement contractuelle, pas perceptuelle. En gelant la vision et en adaptant 0,85 % des paramètres du décodeur, le LoRA a installé le contrat et la lecture structurée des traits visuels : 6/6 images jamais vues décrites exactement au format. La leçon architecturale : quand la tâche est un comportement de langage conditionné par la perception, adapter le décodeur suffit — la perception pré-entraînée est déjà bonne.
Prochaines étapes : les exercices étendent le contrat (4e forme, champ position) puis testent la frontière de cette conclusion (cibler aussi le merger — l’exercice 3 mesure ce que la vision adaptée ajoute quand elle a déjà tout ce qu’il faut).