diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml new file mode 100644 index 0000000..503d188 --- /dev/null +++ b/.github/workflows/site.yml @@ -0,0 +1,150 @@ +name: Site + +# Construit le site Quarto (`website/`) et le publie sur GitHub Pages. +# +# Les pages « Résultats » et « Écarts » RE-EXÉCUTENT leur code Python au rendu : +# elles relisent les prédictions exportées sur le S3 du SSP Cloud (le cache +# `website/_freeze/` n'est volontairement pas versionné, cf. `.gitignore`). +# Le runner a donc besoin des identifiants MinIO, fournis en secrets du dépôt : +# +# AWS_ACCESS_KEY_ID ┐ jetons MinIO du SSP Cloud +# AWS_SECRET_ACCESS_KEY │ (page « Mon compte » → « Connexion au stockage ») +# AWS_SESSION_TOKEN ┘ ⚠ ils EXPIRENT (7 jours max) : à renouveler. +# +# Optionnels, avec valeurs par défaut ci-dessous : +# AWS_S3_ENDPOINT (variable), S3_PREDICTIONS_PREFIX (secret ou variable) +# +# Sans identifiants valides le site se construit quand même : les pages +# concernées affichent « — » et une note d'incident. Le déploiement n'échoue pas. + +on: + push: + branches: [main] + paths: + - "website/**" + - "src/**" + - "configs/**" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/site.yml" + workflow_dispatch: # publication manuelle (ex. après renouvellement des jetons) + +# Droits nécessaires au déploiement par artefact (pas de branche gh-pages). +permissions: + contents: read + pages: write + id-token: write + +# Une seule publication à la fois ; on ne coupe pas un déploiement en cours. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + construire: + runs-on: ubuntu-latest + # Environnement commun aux étapes de vérification et de rendu (GitHub + # Actions ne prend pas en charge les ancres YAML : on factorise ici). + env: + # ── Accès au S3 du SSP Cloud (MinIO) ──────────────────────────────── + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + # Vide avec un compte de service (pas de jeton de session) : botocore + # normalise alors la variable en `None`, c'est sans effet. + AWS_SESSION_TOKEN: ${{ secrets.AWS_SESSION_TOKEN }} + AWS_DEFAULT_REGION: us-east-1 + # `fsspec`/`s3fs` lisent l'endpoint dans l'environnement. `AWS_S3_ENDPOINT` + # seul ne suffit PAS : botocore l'ignore et route vers s3.amazonaws.com. + # C'est `AWS_ENDPOINT_URL` qui dirige vers MinIO (vérifié sur ce projet). + AWS_S3_ENDPOINT: ${{ vars.AWS_S3_ENDPOINT || 'minio.lab.sspcloud.fr' }} + AWS_ENDPOINT_URL: https://${{ vars.AWS_S3_ENDPOINT || 'minio.lab.sspcloud.fr' }} + FSSPEC_S3_ENDPOINT_URL: https://${{ vars.AWS_S3_ENDPOINT || 'minio.lab.sspcloud.fr' }} + # ── Paramètres des pages de résultats (cf. website/_analyse.py) ───── + # Valeur explicite : une variable vide écraserait le défaut du module. + S3_PREDICTIONS_PREFIX: ${{ vars.S3_PREDICTIONS_PREFIX || 's3://projet-production-ecrits-depp/predictions' }} + steps: + - uses: actions/checkout@v4 + + # Bascule Pages en mode « GitHub Actions » (déploiement par artefact). + # Sans ça, si la source du dépôt est restée sur « Deploy from a branch », + # le job de déploiement boucle sur `deployment_in_progress` puis échoue : + # il attend un build de branche qui n'arrive jamais. + - name: Configurer Pages + uses: actions/configure-pages@v5 + + - name: Installer Quarto + uses: quarto-dev/quarto-actions/setup@v2 + with: + version: "1.10.18" # même version qu'en local, pour un rendu identique + + - name: Installer uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + + - name: Installer Python et dépendances + run: | + uv python install 3.11 + # `--extra website` apporte matplotlib et le noyau Jupyter dont le + # rendu a besoin ; le paquet du projet fournit les calculs. + uv sync --extra website + + # `_analyse.py` capture toute erreur S3 pour garder les pages rendables : + # un échec d'accès ne se voit alors que par des « — » sur le site publié. + # Cette étape fait remonter l'erreur. Non bloquante : mieux vaut republier + # un site incomplet que ne rien publier. + # + # Le résultat est écrit dans `$GITHUB_STEP_SUMMARY`, donc affiché sur la + # page de résumé du run : inutile d'aller déplier les logs du job. + - name: Vérifier l'accès au S3 + continue-on-error: true + # Seules les LONGUEURS des secrets sont affichées, jamais leur valeur. + run: | + { + echo "clé : ${#AWS_ACCESS_KEY_ID} caractères" + echo "secret : ${#AWS_SECRET_ACCESS_KEY} caractères" + echo "jeton : ${#AWS_SESSION_TOKEN} caractères" + echo "endpoint : ${AWS_ENDPOINT_URL}" + echo "préfixe : ${S3_PREDICTIONS_PREFIX}" + uv run python -c " + import os, fsspec + prefixe = os.environ['S3_PREDICTIONS_PREFIX'] + fs = fsspec.filesystem('s3') + print('endpoint résolu :', fs.s3.meta.endpoint_url) + for entree in fs.ls(prefixe, detail=False): + print(' ', entree) + " 2>&1 + } > "${RUNNER_TEMP}/diagnostic-s3.txt" || true + cat "${RUNNER_TEMP}/diagnostic-s3.txt" + { + echo "## Diagnostic S3" + echo '```' + cat "${RUNNER_TEMP}/diagnostic-s3.txt" + echo '```' + } >> "${GITHUB_STEP_SUMMARY}" + + - name: Rendre le site + # Rendu depuis la RACINE du dépôt : `_analyse.py` remonte jusqu'au + # dossier contenant `src/evaluation_dictee` pour importer le paquet et + # résoudre `configs/grille_dictee_2015.json`. + run: uv run quarto render website + + - name: Préparer l'artefact Pages + uses: actions/upload-pages-artifact@v3 + with: + path: website/_site + + deployer: + needs: construire + runs-on: ubuntu-latest + # Un déploiement courant prend moins d'une minute, mais la toute première + # publication d'un site (provision CDN + certificat TLS) peut dépasser + # 8 minutes : on garde de la marge pour ne pas tuer un déploiement valide. + timeout-minutes: 20 + environment: + name: github-pages + url: ${{ steps.deploiement.outputs.page_url }} + steps: + - name: Déployer sur GitHub Pages + id: deploiement + uses: actions/deploy-pages@v4 diff --git a/CLAUDE.md b/CLAUDE.md index f6374d5..81aaf88 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,10 +161,6 @@ uv run mypy src # typage # Lancer un benchmark à partir d'une config uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml -# Densité d'encre de chaque copie (détection des copies vierges, sans appel modèle). -# Documente `data.blank_ink_threshold` et alimente la page « Écarts » du site. -uv run scripts/compute_ink_ratios.py --config configs/scoring/dictee_end2end.yaml --export - # Rendre le site Quarto (les pages recalculent leurs figures au rendu) uv sync --extra website uv run quarto render website @@ -196,13 +192,30 @@ screen -ls # lister les session **Option 2 — nohup.** Sans interface interactive, log dans un fichier. ```bash +ps -ef | grep run_benchmark | grep -v grep # AVANT tout : rien ne doit tourner mkdir -p logs -nohup uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml \ - > logs/dictee_REFERENCE.log 2>&1 & -echo $! > logs/dictee_REFERENCE.pid # noter le PID pour arrêter plus tard -tail -f logs/dictee_REFERENCE.log # suivre le log en direct + +# Un log DISTINCT par run : deux runs qui partagent un log sont illisibles après coup. +nohup uv run scripts/run_benchmark.py --config configs/scoring/dictee_end2end.yaml \ + > logs/dictee_end2end.log 2>&1 & +nohup uv run scripts/run_benchmark.py --config configs/scoring/dictee_two_stage.yaml \ + > logs/dictee_two_stage.log 2>&1 & + +tail -f logs/dictee_end2end.log # suivre en direct +head -20 logs/dictee_end2end.log | grep -i reprise # la reprise a-t-elle pris ? + +# Arrêter : PAS `kill $(cat *.pid)`. `nohup uv run …` crée un wrapper `uv run` ET un +# `python3 scripts/run_benchmark.py` ; `$!` ne capture que le wrapper, dont le kill +# laisserait l'enfant orphelin continuer d'écrire (cause de runs concurrents observée). +pkill -f "run_benchmark.py --config configs/scoring/dictee_end2end.yaml" ``` +> **Un seul run par fichier de sortie.** Deux runs qui appendent le même JSONL +> dupliquent les copies et faussent les métriques. Un verrou `flock` sur +> `.lock` fait échouer le second dès le démarrage. Si un run annonce +> `0 déjà faites` alors qu'un checkpoint existe, l'arrêter : le nom du fichier de +> sortie ne correspond pas au checkpoint (modèle ou `name` différent). + ### Checkpointing et reprise après crash Depuis la refonte du benchmark, **chaque copie est écrite sur disque @@ -210,11 +223,17 @@ immédiatement** (`flush + fsync`). Effets : - Un crash à mi-run (API down, kernel tué, …) ne perd que la copie en cours. - Relancer la même commande **reprend automatiquement** où on s'était arrêté : - les copies déjà présentes dans `_predictions.jsonl` sont sautées. + les copies déjà présentes dans `__predictions.jsonl` sont sautées. - Les copies qui lèvent une exception API sont loggées dans - `_failed_copies.txt` et le run continue sur les suivantes. Elles seront + `__failed_copies.txt` et le run continue sur les suivantes. Elles seront retentées au prochain lancement. -- Pour repartir de zéro, supprimer `_predictions.jsonl` (ou changer +- Une copie dont **aucun** item n'a pu être parsé (code `?` partout : réponse vide, + JSON cassé) n'est PAS considérée comme faite. `pipeline/purge.preparer_reprise` + la retire du JSONL au démarrage du run — sauvegarde en `.bak` — et le run + la refait. Sans ce garde-fou, la reprise fige les échecs : c'est ce qui a laissé + 84 copies entièrement `?` dans le run two_stage du 5 août 2026, alors que leur + cause avait été corrigée entre les deux lancements. +- Pour repartir de zéro, supprimer `__predictions.jsonl` (ou changer `config.name`). ## 10. Pour un⋅e débutant⋅e diff --git a/README.md b/README.md index 78e503b..d2896af 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,13 @@ l'aide de modèles multimodaux open weight, et **comparer rigoureusement** le codage automatique à celui d'un correcteur expert. Collaboration **DEPP × SSP Lab (INSEE)**. -> Contexte complet et décisions : voir **[CLAUDE.md](CLAUDE.md)** et les -> **[décisions](docs/decisions.md)** dans [`docs/`](docs/). - --- ## Ce que fait le projet, en bref 1. **Charge** les imagettes de dictée (TIFF 1 bit, depuis S3) et les codes de l'annotateur expert (gold standard). -2. **Demande à un modèle multimodal** (gemma4 sur llm.lab) de coder chaque mot de +2. **Demande à un modèle multimodal** (gemma4 ou gwen3.6 sur llm.lab) de coder chaque mot de la dictée — correct / erreur / absent — directement à partir de l'image et du texte de référence, sans étape d'OCR séparée. 3. **Compare** les codes du modèle à ceux de l'expert. @@ -23,10 +20,9 @@ automatique à celui d'un correcteur expert. Collaboration **DEPP × SSP Lab (IN La tâche cible est la **grille simplifiée** : `1` correct / `9` erreur / `0` absent (voir [docs/decisions.md](docs/decisions.md), décision D2). -### Deux approches d'évaluation comparables +### ✅ Deux approches d'évaluation comparables -Le projet implémente **deux architectures** derrière la même interface `Scorer`, -donc évaluées par le même code de métriques (comparaison rigoureuse) : +Le projet implémente **deux architectures** derrière la même interface `Scorer` : - **`end_to_end` (approche 2)** : un VLM lit l'image ET code en une seule passe. Approche par défaut. Config : `configs/scoring/dictee_REFERENCE.yaml`. @@ -34,19 +30,19 @@ donc évaluées par le même code de métriques (comparaison rigoureuse) : texte brut, fautes comprises) ; étape 2 = codage du texte transcrit (sans image, éventuellement par un modèle texte plus léger via `model_stage2`). Isole lecture et jugement. Config : `configs/scoring/dictee_REFERENCE.yaml`. L'approche se choisit - via le champ `approach` du YAML. + via le champ `approach` du YAML ou par les paramètres `--model_name` et + `--model_stage2-name`. -### Évaluation dédiée de la transcription (HTR) sur Scoledit +### 📄 Évaluation dédiée de la transcription (HTR) sur Scoledit Indépendamment du codage, on peut mesurer la **fidélité de lecture** d'un modèle sur l'écriture manuscrite d'enfants via le corpus **Scoledit** (transcriptions de -référence humaines, fautes préservées). Métriques CER/WER (bruts et normalisés). -Cela permet de comparer les modèles sur la seule lecture et de distinguer les -erreurs de lecture de celles de jugement. Config : `configs/htr/htr_REFERENCE.yaml`, -script : `scripts/run_htr_benchmark.py`, analyse : -`notebooks/05_analyse_transcription_htr.ipynb`. +référence humaines, fautes préservées). Cela permet de comparer les modèles sur la +seule lecture et de distinguer les erreurs de lecture de celles de jugement. +Config : `configs/htr/htr_REFERENCE.yaml`, script : `scripts/run_htr_benchmark.py`, +analyse : `notebooks/05_analyse_transcription_htr.ipynb`. -### Fine-tuning HTR (phase ultérieure) +### 🔨 Fine-tuning HTR (phase ultérieure) Pour améliorer la fidélité de lecture sur l'écriture d'enfants, on peut **spécialiser** un VLM par fine-tuning **QLoRA** (LoRA en 4 bits) sur le corpus @@ -55,7 +51,7 @@ sortie est un adaptateur léger (~50-200 Mo) qui se charge par-dessus le modèle base. Nécessite un **GPU H100**. Suivi via **MLflow** (et non Langfuse). Config : `configs/finetune/finetune_REFERENCE.yaml`, script : `scripts/finetune_htr_scoledit.py`. -### Documentation pédagogique (site Quarto) +### 📖 Documentation pédagogique (site Quarto) Un **site Quarto** (dossier [`website/`](website/)) présente le projet pour un public statisticien novice en IA : architecture, résultats des deux approches, @@ -65,10 +61,12 @@ explication détaillée des métriques d'évaluation, et fine-tuning. quarto preview website # aperçu local avec rechargement à chaud quarto render website # génère le site statique dans website/_site/ ``` +> Contexte complet et décisions : voir **[CLAUDE.md](CLAUDE.md)** et les +> **[décisions](docs/decisions.md)** dans [`docs/`](docs/). --- -## Démarrage rapide (SSP Cloud / VSCode) +## 🔧 Démarrage rapide (SSP Cloud / VSCode) ### 1. Installer @@ -153,13 +151,19 @@ messages=[{'role':'user','content':'Dis bonjour'}], max_tokens=10).choices[0].me ### 3. Lancer un benchmark -**Commande de base** (test rapide, terminal foreground) : +#### **Pour lancer une évalutaion des copies** : ```bash -uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml +uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml [--model-name gwen3-6-35b-moe] [--model-stage2-name qwen3-6-35b-moe] ``` -Cela produit `data/processed/dictee_REFERENCE_predictions.jsonl` -(une ligne par item × copie) et journalise tout dans Langfuse : une **session** +Cela produit `data/processed/dictee_REFERENCE__predictions.jsonl` — le nom +du fichier est **toujours** suffixé par le modèle de l'étape 1 (plus celui de +l'étape 2 s'il diffère), que le modèle vienne du YAML ou de `--model-name`, pour +que deux modèles n'écrasent jamais le même checkpoint. Chaque ligne porte en +outre les champs `model` et `model_stage2`. + +Le fichier contient une ligne par item × copie, et tout est journalisé dans +Langfuse : une **session** par run, une **trace** par copie (entrée/sortie + score d'accord), les appels LLM en générations imbriquées, et les métriques agrégées du run en Scores et metadata. @@ -172,13 +176,13 @@ reprend automatiquement où il s'était arrêté : si un run est interrompu et il saute les copies déjà traitées. Voir « Runs longs » pour les détails. **Vitesse : évaluation en parallèle.** Les copies sont évaluées concurremment -(le endpoint vLLM batche les requêtes) — réglé par `concurrency` dans le YAML +(le endpoint vLLM batche les requêtes), réglé par `concurrency` dans le YAML (défaut **8**). C'est le principal levier de temps mural : passer de 1 à N copies en parallèle divise d'autant la durée tant que le serveur suit. Monter (16, 32…) si l'endpoint tient, redescendre en cas de timeouts / erreurs 429. Seul le scoring est parallélisé : l'écriture du JSONL et la reprise restent inchangées. -**Pour l'évaluation de la transcription (HTR)** sur Scoledit : +#### **Pour l'évaluation de la transcription (HTR)** sur Scoledit : ```bash uv run scripts/run_htr_benchmark.py --config configs/htr/htr_REFERENCE.yaml @@ -189,14 +193,16 @@ le CER/WER moyens. Analyse dans `notebooks/05_analyse_transcription_htr.ipynb`. La transcription est elle aussi **parallélisée** (champ `concurrency` du YAML HTR, défaut 8) ; l'ordre des échantillons en sortie est préservé. -**Exporter les prédictions vers S3.** Le pipeline écrit en local (append + fsync -par copie, pour la reprise sur crash). Une fois un run terminé, on pousse le JSONL -vers le répertoire `predictions/` du bucket S3, afin de **relancer les notebooks et -le site Quarto sans réexécuter le pipeline**. Rien n'est commité dans Git. +#### **Exporter les prédictions vers S3.** + +Le pipeline écrit en local (append + fsync par copie, pour la reprise sur crash). +Une fois un run terminé, on pousse le JSONL vers le répertoire `predictions/` du bucket +S3, afin de **relancer les notebooks et le site Quarto sans réexécuter le pipeline**. +Rien n'est commité dans Git. ```bash # scoring — end_to_end OU two_stage (même format, c'est le `name` qui distingue) : -uv run scripts/export_predictions.py --config configs/scoring/dictee_REFERENCE.yaml +uv run scripts/export_predictions.py --run-name dictee_two_stage_gemma4-26b-moe # type d'approche + nom du modèle # transcription HTR seule : uv run scripts/export_predictions.py --config configs/htr/htr_REFERENCE.yaml --htr @@ -205,10 +211,10 @@ uv run scripts/export_predictions.py --config configs/htr/htr_REFERENCE.yaml --h eval-ecrit export configs/scoring/dictee_REFERENCE.yaml ``` -Destination : `$S3_PREDICTIONS_PREFIX/_predictions.jsonl` (défaut +Destination : `$S3_PREDICTIONS_PREFIX/__predictions.jsonl` (défaut `s3://projet-production-ecrits-depp/predictions`, surchargeable par `--dest-prefix`). -**Pour le fine-tuning** d'un modèle de transcription (nécessite un GPU H100) : +#### **Pour le fine-tuning** d'un modèle de transcription (nécessite un GPU H100) : ```bash uv run scripts/finetune_htr_scoledit.py --config configs/finetune/finetune_REFERENCE.yaml ``` @@ -254,9 +260,17 @@ checkpointing sauvera les prédictions déjà écrites, mais pas la copie en cou > du SSP Cloud (`sudo apt-get install tmux` échoue avec « No installation > candidate »). Utiliser `screen` (Option A) ou `nohup` (Option B). -### Avant tout : créer le dossier logs +### Avant tout : vérifier qu'aucun run ne tourne, et créer le dossier logs + +Deux runs sur le même fichier de sortie dupliquent les copies et faussent les +métriques. Le verrou `.lock` fait désormais échouer le second dès le +démarrage, mais autant le constater avant de lancer — quelle que soit l'option +choisie ci-dessous : ```bash +ps -ef | grep run_benchmark | grep -v grep # doit ne rien renvoyer +screen -ls # ni session détachée oubliée + # À faire une seule fois (nohup échoue si le dossier n'existe pas) : mkdir -p logs ``` @@ -279,51 +293,89 @@ uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml ### Option B — nohup (toujours disponible, sans interface interactive) +Lancer — **un fichier de log distinct par run**, sinon les sorties de deux runs +s'entrelacent dans le même fichier et deviennent illisibles après coup : + ```bash -mkdir -p logs # créer le dossier si pas encore fait +mkdir -p logs -nohup uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml \ - > logs/dictee_REFERENCE.log 2>&1 & -echo $! > logs/dictee_REFERENCE.pid # noter le PID pour arrêter plus tard +nohup uv run scripts/run_benchmark.py --config configs/scoring/dictee_end2end.yaml \ + > logs/dictee_end2end.log 2>&1 & +nohup uv run scripts/run_benchmark.py --config configs/scoring/dictee_two_stage.yaml \ + > logs/dictee_two_stage.log 2>&1 & +``` + +Suivre et contrôler : + +```bash # Suivre le log en direct : -tail -f logs/dictee_REFERENCE.log +tail -f logs/dictee_end2end.log + +# Vérifier que le run tourne (affiche le wrapper `uv run` ET le python) : +ps -ef | grep run_benchmark | grep -v grep + +# Arrêter proprement (le checkpointing conserve tout sauf la copie en cours) : +pkill -f "run_benchmark.py --config configs/scoring/dictee_end2end.yaml" +``` + +> **Ne pas piloter le run par un fichier `.pid`.** `nohup uv run … &` crée DEUX +> process : le wrapper `uv run` et le vrai `python3 scripts/run_benchmark.py`. +> `echo $!` ne capture que le wrapper ; un `kill` sur ce seul PID laisse +> l'enfant Python vivant en orphelin, qui continue d'écrire. C'est ainsi que +> plusieurs runs concurrents se sont retrouvés sur le même JSONL. `pkill -f` +> sur le motif de la config cible les deux d'un coup. -# Vérifier que le process tourne : -ps -p $(cat logs/dictee_REFERENCE.pid) +**Vérifier que la reprise a bien pris.** Les premières lignes du log doivent +annoncer le nombre de copies déjà faites : -# Arrêter proprement (le checkpointing sauvegardera l'état) : -kill $(cat logs/dictee_REFERENCE.pid) +```bash +head -20 logs/dictee_end2end.log | grep -i "reprise\|copies au total" ``` +Si un run annonce `0 déjà faites` alors qu'un checkpoint existe, l'arrêter : le +nom du fichier de sortie ne correspond pas au checkpoint (modèle différent, ou +`name` modifié dans le YAML), et le run repartirait de zéro. + ### Surveillance de l'avancement Pendant un run long, dans une **autre** fenêtre ou onglet, ces commandes donnent un signal de vie plus fiable que la barre de progression : ```bash +# Retrouver le fichier du run en cours (le nom porte le modèle) : +ls -lt data/processed/*_predictions.jsonl | head + # Compter les copies déjà traitées dans le JSONL (une copie = ~83 lignes) : -wc -l data/processed/dictee_REFERENCE_predictions.jsonl +wc -l data/processed/dictee_REFERENCE_qwen3-6-35b-moe_predictions.jsonl # Suivre le compteur en direct (mise à jour toutes les 5 s) : -watch -n 5 "wc -l data/processed/dictee_REFERENCE_predictions.jsonl" +watch -n 5 "wc -l data/processed/dictee_REFERENCE_qwen3-6-35b-moe_predictions.jsonl" # Lister les copies en échec (à retenter au prochain lancement) : -cat data/processed/dictee_REFERENCE_failed_copies.txt +cat data/processed/dictee_REFERENCE_qwen3-6-35b-moe_failed_copies.txt ``` +> **Un seul run par fichier de sortie.** Un second run visant le même fichier +> s'arrête aussitôt sur le verrou `.lock`. Avant de relancer, vérifier +> qu'aucun run ne tourne déjà : `ps -ef | grep run_benchmark`. + ### Reprise après crash — mode d'emploi Le benchmark écrit sur disque après CHAQUE copie évaluée (avec `flush + fsync`). Conséquences pratiques : - **Crash ou déconnexion** : relance exactement la même commande. Les copies - déjà présentes dans `_predictions.jsonl` sont automatiquement sautées, - et le run reprend à la copie suivante. + déjà présentes dans `__predictions.jsonl` sont automatiquement + sautées, et le run reprend à la copie suivante. Relancer avec une commande + *différente* (par ex. en ajoutant `--model-name` alors que le premier + lancement lisait le modèle du YAML) viserait, avant correction, un autre + fichier et repartirait de zéro : le nommage est désormais identique dans les + deux cas. - **Erreurs API sur des copies isolées** : elles sont loggées dans - `_failed_copies.txt`, la copie fautive est sautée mais le run continue. + `__failed_copies.txt`, la copie fautive est sautée mais le run continue. Au prochain lancement, ces copies seront retentées. -- **Repartir de zéro** : supprimer `_predictions.jsonl` (ou changer +- **Repartir de zéro** : supprimer `__predictions.jsonl` (ou changer `config.name` dans le YAML). --- @@ -452,6 +504,7 @@ uv run scripts/run_htr_benchmark.py --config configs/htr/htr_REFERENCE.yaml uv run scripts/finetune_htr_scoledit.py --config configs/finetune/finetune_REFERENCE.yaml # ─────────── Runs longs (session détachable) ─────────── +ps -ef | grep run_benchmark | grep -v grep # AVANT tout : rien ne doit tourner mkdir -p logs # toujours créer d'abord # Option A : screen (recommandé sur Onyxia, généralement disponible) @@ -459,12 +512,22 @@ which screen && screen -S dictee # puis Ctrl+A D pour détacher # screen -r dictee pour rattacher # Option B : nohup (toujours dispo, sans interface interactive) -nohup uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml \ - > logs/dictee_REFERENCE.log 2>&1 & +# Un log DISTINCT par run, sinon les sorties s'entrelacent. +nohup uv run scripts/run_benchmark.py --config configs/scoring/dictee_end2end.yaml \ + > logs/dictee_end2end.log 2>&1 & +nohup uv run scripts/run_benchmark.py --config configs/scoring/dictee_two_stage.yaml \ + > logs/dictee_two_stage.log 2>&1 & # ─────────── Surveillance d'un run en cours ─────────── -tail -f logs/dictee_REFERENCE.log -watch -n 5 "wc -l data/processed/dictee_REFERENCE_predictions.jsonl" +tail -f logs/dictee_end2end.log +head -20 logs/dictee_end2end.log | grep -i "reprise" # la reprise a-t-elle pris ? +ls -lt data/processed/*_predictions.jsonl | head # retrouver le fichier du run +watch -n 5 "wc -l data/processed/dictee_end2end_qwen3-6-35b-moe_predictions.jsonl" + +# ─────────── Arrêter un run ─────────── +# PAS `kill $(cat *.pid)` : `nohup uv run …` crée un wrapper + un python, et `$!` +# ne capture que le wrapper — l'enfant survivrait et continuerait d'écrire. +pkill -f "run_benchmark.py --config configs/scoring/dictee_end2end.yaml" # ─────────── Analyse des résultats ─────────── uv sync --extra notebooks # une seule fois (JupyterLab + matplotlib) diff --git a/configs/README.md b/configs/README.md index 685f622..1d8b9be 100644 --- a/configs/README.md +++ b/configs/README.md @@ -39,6 +39,43 @@ uv run scripts/finetune_htr_scoledit.py --config configs/finetune/finetune_REFER > nombre de copies (mettre `5`–`20` pour un test, `null` pour tout le corpus) et > **`model.name`** est le seul champ à changer pour tester un autre modèle. +> **Comparer deux modèles sans dupliquer les YAML** : `run_benchmark.py` (et la +> commande `eval-ecrit benchmark`) acceptent `--model-name` (étape 1 / unique +> étape en end_to_end) et `--model-stage2-name` (étape 2, two_stage +> uniquement) pour surcharger `model.name` / `model_stage2.name` sans éditer le +> fichier. Les noms passés doivent correspondre EXACTEMENT à ceux servis sur +> [llm.lab.sspcloud.fr](https://llm.lab.sspcloud.fr). +> +> Le fichier de sortie est **toujours** suffixé par le(s) modèle(s) utilisé(s) — +> `data/processed/__predictions.jsonl` — que le modèle vienne du +> YAML ou d'une surcharge CLI, pour ne jamais écraser le checkpoint d'un autre +> modèle. Le suffixe n'est ajouté qu'une fois (cf. `run_output_name`), et le +> modèle de l'étape 2 n'y figure que s'il diffère de celui de l'étape 1. Le nom +> du modèle est en outre inscrit dans **chaque ligne** du JSONL (champs `model` +> et `model_stage2`), donc l'information survit à une fusion ou à un renommage. +> +> Exemple : comparer `gemma4-26b-moe` à `qwen3.6-35b-moe` sur les deux +> approches (4 fichiers de prédictions) : +> ```bash +> # qwen3-6-35b-moe (modèle inscrit dans les YAML, rien à surcharger) +> uv run scripts/run_benchmark.py --config configs/scoring/dictee_end2end.yaml +> uv run scripts/run_benchmark.py --config configs/scoring/dictee_two_stage.yaml +> +> # gemma4-26b-moe (surcharge du modèle) +> uv run scripts/run_benchmark.py --config configs/scoring/dictee_end2end.yaml \ +> --model-name gemma4-26b-moe +> uv run scripts/run_benchmark.py --config configs/scoring/dictee_two_stage.yaml \ +> --model-name gemma4-26b-moe --model-stage2-name gemma4-26b-moe +> ``` +> → `dictee_end2end_qwen3-6-35b-moe_predictions.jsonl`, +> `dictee_two_stage_qwen3-6-35b-moe_predictions.jsonl`, +> `dictee_end2end_gemma4-26b-moe_predictions.jsonl`, +> `dictee_two_stage_gemma4-26b-moe_predictions.jsonl`. +> +> **Un seul run à la fois par fichier de sortie** : un second run visant le même +> fichier échoue immédiatement sur le verrou `.lock`. Deux runs qui +> appendent le même JSONL dupliquent les copies et faussent les métriques. + --- ## Famille 1 — Scoring dictée (`scoring/`) diff --git a/configs/scoring/dictee_REFERENCE.yaml b/configs/scoring/dictee_REFERENCE.yaml index 9b20820..98f8d83 100644 --- a/configs/scoring/dictee_REFERENCE.yaml +++ b/configs/scoring/dictee_REFERENCE.yaml @@ -15,8 +15,9 @@ # ═══════════════════════════════════════════════════════════════════════════ # Nom UNIQUE du run. Sert à la fois de nom de session Langfuse et de préfixe des -# fichiers de sortie (data/processed/_predictions.jsonl). Deux runs de même -# nom partagent le checkpoint : changer le nom pour repartir de zéro. +# fichiers de sortie, qui sont TOUJOURS suffixés par le(s) modèle(s) utilisé(s) : +# data/processed/__predictions.jsonl. Deux runs de même nom ET même +# modèle partagent le checkpoint ; changer le nom (ou le modèle) repart de zéro. # Convention : dictee__ (ex. dictee_gemma4_zeroshot). name: dictee_REFERENCE diff --git a/configs/scoring/dictee_end2end.yaml b/configs/scoring/dictee_end2end.yaml index 98d4428..f2704e8 100644 --- a/configs/scoring/dictee_end2end.yaml +++ b/configs/scoring/dictee_end2end.yaml @@ -6,17 +6,17 @@ # chaque paramètre (les champs omis reprennent leur valeur par défaut). # # Lancer : uv run scripts/run_benchmark.py --config configs/scoring/dictee_end2end.yaml -# Sortie : data/processed/dictee_end2end_predictions.jsonl (approach="end_to_end") +# Sortie : data/processed/dictee_end2end__predictions.jsonl (approach="end_to_end") # ═══════════════════════════════════════════════════════════════════════════ name: dictee_end2end seed: 42 -concurrency: 8 +concurrency: 12 approach: end_to_end model: - name: gemma4-26b-moe + name: qwen3-6-35b-moe #ou gemma4-26b-moe kind: vlm temperature: 0.0 max_tokens: 8192 diff --git a/configs/scoring/dictee_two_stage.yaml b/configs/scoring/dictee_two_stage.yaml index 86d24f2..cb1c72e 100644 --- a/configs/scoring/dictee_two_stage.yaml +++ b/configs/scoring/dictee_two_stage.yaml @@ -7,18 +7,19 @@ # chaque paramètre (les champs omis reprennent leur valeur par défaut). # # Lancer : uv run scripts/run_benchmark.py --config configs/scoring/dictee_two_stage.yaml -# Sortie : data/processed/dictee_two_stage_predictions.jsonl (approach="two_stage") +# Sortie : data/processed/dictee_two_stage__predictions.jsonl +# (approach="two_stage" ; suffixe = modèle étape 1, + modèle étape 2 s'il diffère) # ═══════════════════════════════════════════════════════════════════════════ name: dictee_two_stage seed: 42 -concurrency: 8 +concurrency: 12 approach: two_stage # Étape 1 = transcription (doit être multimodal pour lire l'image). model: - name: gemma4-26b-moe + name: qwen3-6-35b-moe # gemma4-26b-moe kind: vlm temperature: 0.0 max_tokens: 8192 @@ -29,10 +30,13 @@ model: # Étape 2 = codage textuel (modèle texte seul, obligatoire en two_stage). model_stage2: - name: gemma4-26b-moe + name: qwen3-6-35b-moe # ou gemma4-26b-moe kind: llm temperature: 0.0 max_tokens: 8192 + structured_output: true + disable_thinking: true + max_retries: 2 data: corpus: dictee diff --git a/pyproject.toml b/pyproject.toml index 8d792e0..d1b63a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,12 @@ dependencies = [ "httpx>=0.27", # Métriques "scikit-learn>=1.4", + # Export du rapport HTML (`evaluation/html_report.py`) : ce module de `src/` + # les importe au niveau module, ce sont donc des dépendances du paquet et + # non des extras. Elles n'arrivaient jusqu'ici qu'en transitif via + # JupyterLab, ce qui cassait `uv sync` seul (et donc la CI). + "nbformat>=5.10", + "nbconvert>=7.16", # Suivi d'expériences (traces LLM, prompts, scores) "langfuse>=4,<5", # Confort @@ -49,7 +55,6 @@ website = [ "ipykernel>=6.29", "matplotlib>=3.11.1", "nbclient>=0.10", - "nbformat>=5.10", ] # Outillage de développement (lint, typage, tests) — NON publié avec le paquet. diff --git a/scripts/compute_ink_ratios.py b/scripts/compute_ink_ratios.py deleted file mode 100644 index fc14957..0000000 --- a/scripts/compute_ink_ratios.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Mesure la densité d'encre de chaque copie et exporte la distribution. - -À quoi ça sert : le benchmark déclare une copie VIERGE quand sa densité d'encre -passe sous `data.blank_ink_threshold`, et code alors tous ses items « 0 » sans -appeler le modèle. Ce script calcule la même mesure pour **tout le corpus**, sans -appel modèle ni GPU, afin de : - -- documenter le seuil (le site trace la distribution obtenue, voir - `website/ecarts.qmd`) ; -- rejuger ce seuil en quelques minutes, sans relancer un benchmark de 30 h. - -La sortie ne contient **aucune donnée d'élève** : un identifiant de copie et un -scalaire par ligne. Elle vit malgré tout dans `data/processed/` (ignoré par Git) -et sur S3, comme les prédictions. - -Usage : - # Depuis le YAML d'un run (reprend son corpus et son seuil) : - uv run scripts/compute_ink_ratios.py --config configs/scoring/dictee_end2end.yaml - - # Ou en désignant directement les données : - uv run scripts/compute_ink_ratios.py \ - --images-path s3://projet-production-ecrits-depp/dictee_2015/ \ - --labels-path s3://projet-production-ecrits-depp/resultat_dictee_2015.csv - - # Puis pousser sur S3 pour que le site le relise : - uv run scripts/compute_ink_ratios.py --config configs/... --export -""" - -from __future__ import annotations - -import argparse -import csv -from concurrent.futures import ThreadPoolExecutor -from pathlib import Path - -import fsspec - -from evaluation_dictee.config import ExperimentConfig, Secrets, load_config -from evaluation_dictee.data.loaders import Copy, ink_ratio, load_dataset, load_image -from evaluation_dictee.utils.logging import get_logger - -logger = get_logger(__name__) - -#: Nom du fichier produit, dérivé du corpus (et non du run) : la densité d'encre -#: ne dépend ni du modèle ni de l'approche. -NOM_FICHIER = "{corpus}_ink_ratios.csv" - - -def _mesurer(copy: Copy) -> tuple[str, float | None]: - """Densité d'encre d'une copie ; None si l'image est illisible.""" - try: - return copy.copy_id, ink_ratio(load_image(copy.image_path)) - except Exception as exc: # noqa: BLE001 — une image cassée ne doit pas tout arrêter - logger.warning("%s illisible (%s) : ignorée.", copy.copy_id, type(exc).__name__) - return copy.copy_id, None - - -def mesurer_corpus(copies: list[Copy], workers: int = 16) -> list[tuple[str, float]]: - """Mesure la densité d'encre de toutes les copies, en parallèle. - - Args: - copies: copies à mesurer. - workers: nombre de threads (le coût est dominé par les entrées/sorties S3). - - Returns: - La liste des couples (copy_id, densité), triée par identifiant, privée des - copies dont l'image n'a pas pu être lue. - """ - with ThreadPoolExecutor(max_workers=workers) as pool: - resultats = list(pool.map(_mesurer, copies)) - return sorted((cid, r) for cid, r in resultats if r is not None) - - -def ecrire_csv(mesures: list[tuple[str, float]], chemin: str | Path, seuil: float) -> Path: - """Écrit les mesures en CSV (`copy_id;ink_ratio;blank`). - - Args: - mesures: couples (copy_id, densité d'encre). - chemin: fichier de destination. - seuil: seuil au-dessous duquel la copie est marquée vierge. - - Returns: - Le chemin écrit. - """ - sortie = Path(chemin) - sortie.parent.mkdir(parents=True, exist_ok=True) - with sortie.open("w", encoding="utf-8", newline="") as f: - writer = csv.writer(f, delimiter=";") - writer.writerow(["copy_id", "ink_ratio", "blank"]) - for copy_id, densite in mesures: - writer.writerow([copy_id, f"{densite:.6f}", int(densite < seuil)]) - return sortie - - -def _resoudre_source(args: argparse.Namespace) -> tuple[str, str, str, float, int | None]: - """Résout (images, labels, corpus, seuil, limite) depuis le YAML ou les options.""" - if args.config: - config: ExperimentConfig = load_config(args.config) - return ( - config.data.images_path, - config.data.labels_path, - config.data.corpus, - config.data.blank_ink_threshold, - args.limit if args.limit is not None else config.data.limit, - ) - if not (args.images_path and args.labels_path): - raise SystemExit("Sans --config, --images-path ET --labels-path sont requis.") - return (args.images_path, args.labels_path, args.corpus, args.threshold, args.limit) - - -def main() -> None: - """Mesure la densité d'encre du corpus, écrit le CSV et l'exporte si demandé.""" - parser = argparse.ArgumentParser( - description="Mesure la densité d'encre de chaque copie (détection des copies vierges)." - ) - parser.add_argument("--config", help="YAML d'un run : corpus, chemins et seuil en sont lus.") - parser.add_argument("--images-path", help="Dossier des images (local ou s3://).") - parser.add_argument("--labels-path", help="CSV des codes experts (local ou s3://).") - parser.add_argument("--corpus", default="dictee", help="Nom du corpus. [défaut : dictee]") - parser.add_argument( - "--threshold", - type=float, - default=0.025, - help="Seuil de copie vierge, si absent du YAML. [défaut : 0.025]", - ) - parser.add_argument("--limit", type=int, default=None, help="Limiter le nombre de copies.") - parser.add_argument("--workers", type=int, default=16, help="Threads de lecture. [défaut : 16]") - parser.add_argument( - "--output-dir", - default="data/processed", - help="Dossier local de sortie. [défaut : data/processed]", - ) - parser.add_argument( - "--export", - action="store_true", - help="Pousse aussi le CSV sur S3, où le site Quarto le relit.", - ) - args = parser.parse_args() - - images, labels, corpus, seuil, limite = _resoudre_source(args) - copies = load_dataset(images, labels, limit=limite) - logger.info("%d copies à mesurer (seuil de copie vierge : %.4f).", len(copies), seuil) - - mesures = mesurer_corpus(copies, workers=args.workers) - n_vierges = sum(1 for _, densite in mesures if densite < seuil) - logger.info( - "%d copies mesurées, dont %d vierges (%.2f %%).", - len(mesures), - n_vierges, - n_vierges / len(mesures) * 100 if mesures else 0.0, - ) - - nom = NOM_FICHIER.format(corpus=corpus) - local = ecrire_csv(mesures, Path(args.output_dir) / nom, seuil) - logger.info("Écrit : %s", local) - - if args.export: - dest = Secrets().s3_predictions_prefix.rstrip("/") + "/" + nom - with ( - local.open("rb") as src, - fsspec.open(dest, "wb") as dst, - ): - dst.write(src.read()) - logger.info("OK — disponible sur S3 : %s", dest) - - -if __name__ == "__main__": - main() diff --git a/scripts/export_predictions.py b/scripts/export_predictions.py index 31d777e..6f98b99 100644 --- a/scripts/export_predictions.py +++ b/scripts/export_predictions.py @@ -19,21 +19,13 @@ import argparse -import yaml - from evaluation_dictee.config import Secrets from evaluation_dictee.utils.logging import get_logger -from evaluation_dictee.utils.s3_export import export_run +from evaluation_dictee.utils.s3_export import export_run, resolve_run_name logger = get_logger(__name__) -def _run_name_from_config(config_path: str) -> str: - """Lit le champ `name` d'un YAML de run (scoring ou HTR).""" - with open(config_path, encoding="utf-8") as f: - return str(yaml.safe_load(f)["name"]) - - def main() -> None: """Résout le nom du run et exporte son fichier de prédictions vers S3.""" parser = argparse.ArgumentParser( @@ -59,7 +51,7 @@ def main() -> None: ) args = parser.parse_args() - run_name = args.run_name or _run_name_from_config(args.config) + run_name = args.run_name or resolve_run_name(args.config, htr=args.htr) dest_prefix = args.dest_prefix or Secrets().s3_predictions_prefix dest = export_run(run_name, dest_prefix, source_dir=args.source_dir, htr=args.htr) diff --git a/scripts/run_benchmark.py b/scripts/run_benchmark.py index b6351f7..2847e1e 100644 --- a/scripts/run_benchmark.py +++ b/scripts/run_benchmark.py @@ -9,7 +9,7 @@ from langfuse import get_client -from evaluation_dictee.config import Secrets, load_config +from evaluation_dictee.config import Secrets, load_config, override_model_names from evaluation_dictee.data.grid import load_grid from evaluation_dictee.evaluation.calibration import referral_curve from evaluation_dictee.models.factory import build_scorer @@ -24,11 +24,38 @@ def main() -> None: """Lance le benchmark et affiche métriques et calibration.""" parser = argparse.ArgumentParser(description="Lance un benchmark d'évaluation.") parser.add_argument("--config", required=True, help="Chemin du fichier YAML.") + parser.add_argument( + "--model-name", + default=None, + help=( + "Surcharge model.name (étape 1 / unique étape en end_to_end). " + "Doit correspondre exactement au nom servi sur llm.lab." + ), + ) + parser.add_argument( + "--model-stage2-name", + default=None, + help=( + "Surcharge model_stage2.name (étape 2, codage textuel). " + "Uniquement valide en approche two_stage." + ), + ) args = parser.parse_args() config = load_config(args.config) + config = override_model_names(config, args.model_name, args.model_stage2_name) secrets = Secrets() + if config.model_stage2 is not None: + logger.info( + "Run : %s | modèles : %s (étape 1), %s (étape 2)", + config.name, + config.model.name, + config.model_stage2.name, + ) + else: + logger.info("Run : %s | modèle : %s", config.name, config.model.name) + grid = load_grid(config.data.grid_path) scorer = build_scorer( config=config, diff --git a/src/evaluation_dictee/cli.py b/src/evaluation_dictee/cli.py index 6065b72..4805725 100644 --- a/src/evaluation_dictee/cli.py +++ b/src/evaluation_dictee/cli.py @@ -6,17 +6,16 @@ from __future__ import annotations import typer -import yaml from langfuse import get_client from rich.console import Console from rich.table import Table -from evaluation_dictee.config import Secrets, load_config +from evaluation_dictee.config import Secrets, load_config, override_model_names from evaluation_dictee.data.grid import load_grid from evaluation_dictee.evaluation.metrics import ScoringMetrics from evaluation_dictee.models.factory import build_scorer from evaluation_dictee.pipeline.benchmark import run_benchmark -from evaluation_dictee.utils.s3_export import export_run +from evaluation_dictee.utils.s3_export import export_run, resolve_run_name from evaluation_dictee.utils.tracking import experiment_run, log_metrics app = typer.Typer(help="Évaluation automatique de la production d'écrit (DEPP × SSP Lab).") @@ -24,7 +23,26 @@ @app.command() -def benchmark(config_path: str) -> None: +def benchmark( + config_path: str, + model_name: str | None = typer.Option( + None, + "--model-name", + "-m", + help=( + "Surcharge model.name (étape 1 / unique étape en end_to_end). " + "Doit correspondre exactement au nom servi sur llm.lab." + ), + ), + model_stage2_name: str | None = typer.Option( + None, + "--model-stage2-name", + help=( + "Surcharge model_stage2.name (étape 2, codage textuel). " + "Uniquement valide en approche two_stage." + ), + ), +) -> None: """Lance un benchmark à partir d'un fichier de configuration YAML. Charge la config et les secrets, construit le scorer, exécute le run (tracé @@ -32,12 +50,21 @@ def benchmark(config_path: str) -> None: Args: config_path: Chemin du fichier YAML de configuration du run. + model_name: Surcharge du modèle d'étape 1 (voir `override_model_names`). + model_stage2_name: Surcharge du modèle d'étape 2 (two_stage uniquement). """ config = load_config(config_path) + config = override_model_names(config, model_name, model_stage2_name) secrets = Secrets() console.print(f"[bold]Run :[/bold] {config.name}") - console.print(f"Modèle : {config.model.name} | Méthode : {config.prompt.method}") + if config.model_stage2 is not None: + console.print( + f"Modèle étape 1 : {config.model.name} | étape 2 : {config.model_stage2.name} | " + f"Méthode : {config.prompt.method}" + ) + else: + console.print(f"Modèle : {config.model.name} | Méthode : {config.prompt.method}") scorer = build_scorer( config=config, @@ -67,7 +94,7 @@ def benchmark(config_path: str) -> None: @app.command() def export( config_path: str | None = typer.Argument( - None, help="YAML du run (le nom est lu dans le champ `name`)." + None, help="YAML du run (le nom de fichier inclut le modèle)." ), run_name: str | None = typer.Option( None, "--run-name", help="Nom du run (alternative à config_path)." @@ -87,7 +114,8 @@ def export( Fournir SOIT un YAML de run, SOIT `--run-name`. Args: - config_path: Chemin du YAML du run (le nom est lu dans `name`). + config_path: Chemin du YAML du run (le préfixe des fichiers est résolu + par `resolve_run_name`, modèle inclus). run_name: Nom du run, alternative au YAML. htr: Exporte `_htr_predictions.jsonl` (transcription seule). source_dir: Dossier local des prédictions. @@ -97,8 +125,9 @@ def export( raise typer.BadParameter("Fournir un YAML de run ou --run-name.") if run_name is None: - with open(config_path, encoding="utf-8") as f: - run_name = str(yaml.safe_load(f)["name"]) + # Le fichier de scoring est suffixé par le modèle : lire le seul champ `name` + # du YAML viserait un fichier inexistant (cf. `resolve_run_name`). + run_name = resolve_run_name(str(config_path), htr=htr) prefix = dest_prefix or Secrets().s3_predictions_prefix dest = export_run(run_name, prefix, source_dir=source_dir, htr=htr) diff --git a/src/evaluation_dictee/config.py b/src/evaluation_dictee/config.py index 1e73504..b36640a 100644 --- a/src/evaluation_dictee/config.py +++ b/src/evaluation_dictee/config.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from pathlib import Path from typing import Literal @@ -116,6 +117,103 @@ def load_config(path: str | Path) -> ExperimentConfig: return ExperimentConfig.model_validate(raw) +def _slugify_model_name(name: str) -> str: + """Convertit un nom de modèle en fragment de nom de fichier sûr, sans perte. + + Ne remplace QUE les caractères réellement invalides dans un nom de fichier + (`/`, espaces, `:`…) : `.` et `-` sont conservés tels quels et NE SONT PAS + interchangés, pour que deux noms de modèle différents (ex. un nom fautif + avec un point vs. le nom réel avec un tiret) ne produisent jamais le même + suffixe — et donc n'écrasent jamais le même fichier de sortie. + """ + return re.sub(r"[^a-zA-Z0-9_.-]+", "-", name).strip("-.") + + +def run_output_name(config: ExperimentConfig) -> str: + """Nom de base des fichiers de sortie d'un run : `name` + modèle(s), sans doublon. + + Source unique de vérité pour nommer `<...>_predictions.jsonl` et + `<...>_failed_copies.txt`. Le(s) modèle(s) figurent TOUJOURS dans le nom, que + le run soit lancé via le YAML seul ou via les surcharges `--model-name` / + `--model-stage2-name` : deux modèles n'écrasent donc jamais le même fichier. + + Idempotent : si `config.name` porte déjà le suffixe de modèle (cas d'un + lancement CLI, où `override_model_names` a déjà renommé le run), il n'est pas + ajouté une seconde fois. C'est ce qui garantit qu'un même run écrit dans le + même fichier — et retrouve donc son checkpoint de reprise — quel que soit son + mode de lancement. + + Args: + config: Configuration de l'expérience. + + Returns: + Le préfixe des fichiers de sortie du run. + """ + parts = [_slugify_model_name(config.model.name)] + if config.model_stage2 is not None: + slug_stage2 = _slugify_model_name(config.model_stage2.name) + if slug_stage2 not in parts: + parts.append(slug_stage2) + suffix = "_".join(parts) + + if config.name == suffix or config.name.endswith(f"_{suffix}"): + return config.name + return f"{config.name}_{suffix}" + + +def override_model_names( + config: ExperimentConfig, + model_name: str | None = None, + model_stage2_name: str | None = None, +) -> ExperimentConfig: + """Surcharge le(s) nom(s) de modèle d'une config, sans dupliquer le YAML. + + Les noms doivent correspondre EXACTEMENT à ceux servis sur llm.lab. Dès qu'un + nom est surchargé, le `name` du run (donc le fichier de sortie + `data/processed/_predictions.jsonl`) est suffixé par le(s) modèle(s) + utilisé(s), pour ne jamais écraser le checkpoint d'un autre modèle. + + Args: + config: Configuration chargée depuis le YAML. + model_name: Nom de modèle pour l'étape 1 (unique étape en end_to_end, + transcription en two_stage). None = ne pas surcharger. + model_stage2_name: Nom de modèle pour l'étape 2 (codage textuel, + two_stage uniquement). None = ne pas surcharger. + + Returns: + Une nouvelle config avec les noms de modèle et le `name` mis à jour. + + Raises: + ValueError: Si `model_stage2_name` est fourni alors que la config n'a + pas de bloc `model_stage2` (approche `end_to_end`). + """ + if model_name is None and model_stage2_name is None: + return config + + current_stage2 = config.model_stage2 + if model_stage2_name is not None and current_stage2 is None: + raise ValueError( + "--model-stage2-name n'a de sens qu'en approche two_stage " + "(la config chargée n'a pas de bloc `model_stage2`)." + ) + + updates: dict[str, object] = {} + suffix_parts = [] + + if model_name is not None: + updates["model"] = config.model.model_copy(update={"name": model_name}) + suffix_parts.append(_slugify_model_name(model_name)) + + if model_stage2_name is not None and current_stage2 is not None: + updates["model_stage2"] = current_stage2.model_copy(update={"name": model_stage2_name}) + stage2_slug = _slugify_model_name(model_stage2_name) + if stage2_slug not in suffix_parts: + suffix_parts.append(stage2_slug) + + updates["name"] = f"{config.name}_{'_'.join(suffix_parts)}" + return config.model_copy(update=updates) + + # ───────────────────────────────────────────────────────────────────────────── # Configuration pour le pipeline HTR (transcription seule, corpus Scoledit) # ───────────────────────────────────────────────────────────────────────────── diff --git a/src/evaluation_dictee/evaluation/report.py b/src/evaluation_dictee/evaluation/report.py index fab35b4..e7afc2e 100644 --- a/src/evaluation_dictee/evaluation/report.py +++ b/src/evaluation_dictee/evaluation/report.py @@ -27,8 +27,12 @@ def load_predictions(predictions_path: str | Path) -> pd.DataFrame: Args: predictions_path: chemin local ou S3 du JSONL (une prédiction par ligne). + Déduplique sur (copy_id, item_id) en gardant la DERNIÈRE occurrence : un fichier + écrit par deux runs concurrents contient la même copie plusieurs fois, ce qui + pèserait double dans toutes les métriques calculées en aval. + Returns: - Un DataFrame, une ligne par item, avec les colonnes du JSONL. + Un DataFrame, une ligne par (copy_id, item_id), avec les colonnes du JSONL. """ records = [] with fsspec.open(str(predictions_path), "rt", encoding="utf-8") as f: @@ -36,7 +40,10 @@ def load_predictions(predictions_path: str | Path) -> pd.DataFrame: line = line.strip() if line: records.append(json.loads(line)) - return pd.DataFrame(records) + df = pd.DataFrame(records) + if not df.empty and {"copy_id", "item_id"}.issubset(df.columns): + df = df.drop_duplicates(subset=["copy_id", "item_id"], keep="last").reset_index(drop=True) + return df def per_item_metrics(df: pd.DataFrame, level: float = 0.95) -> pd.DataFrame: diff --git a/src/evaluation_dictee/evaluation/statistics.py b/src/evaluation_dictee/evaluation/statistics.py index 8239fff..359cdea 100644 --- a/src/evaluation_dictee/evaluation/statistics.py +++ b/src/evaluation_dictee/evaluation/statistics.py @@ -54,7 +54,7 @@ def wilson_interval( n: nombre total d'observations. level: niveau de confiance (0.90, 0.95 ou 0.99 ; sinon 0.95 par défaut). deff: design effect (voir `design_effect`). L'intervalle est alors calculé - sur l'effectif effectif `n / deff`, ce qui l'élargit d'un facteur + sur l'effectif équivalent `n / deff`, ce qui l'élargit d'un facteur √deff. Laisser à 1.0 quand les observations sont indépendantes. Returns: @@ -71,7 +71,7 @@ def wilson_interval( raise ValueError("deff doit être >= 1 (1.0 = observations indépendantes).") z = _Z.get(level, 1.96) p = successes / n - n_eff = n / deff # effectif effectif : ce que les données pèsent réellement + n_eff = n / deff # effectif équivalent : ce que les données pèsent réellement denom = 1 + z**2 / n_eff centre = (p + z**2 / (2 * n_eff)) / denom half = (z / denom) * math.sqrt(p * (1 - p) / n_eff + z**2 / (4 * n_eff**2)) @@ -231,13 +231,15 @@ def design_effect(indicator: pd.Series | np.ndarray, clusters: pd.Series | np.nd partout) : la moyenne d'une indicatrice sur N items porte donc moins d'information que N observations indépendantes. Le design effect chiffre cette perte — `deff = 1 + (m - 1) x ICC`, où m est la taille moyenne de grappe et - l'ICC la part de variance qui vient des différences *entre* copies. Diviser N - par ce facteur donne l'effectif effectif, ce qui élargit l'IC de √deff. + l'ICC le rapport de la variance *entre* copies à la variance totale. Diviser N + par ce facteur donne l'effectif équivalent (le nombre d'observations + indépendantes portant la même information), ce qui élargit l'IC de √deff. **Le design effect appartient à une indicatrice, pas à un jeu de données** : sur - ce corpus, celle de l'erreur donne ≈ 10, celle de l'accord ≈ 7,5, celle du - rappel ≈ 4. Passer l'indicatrice concernée est donc obligatoire, sous peine de - corriger avec le mauvais facteur. Corollaire : une statistique calculée à raison + ce corpus, celle de l'erreur donne 10 à 16 selon le run, celle de l'accord 5 à + 12, celle du rappel 3 à 5. Passer l'indicatrice concernée est donc obligatoire, + sous peine de corriger avec le mauvais facteur. Corollaire : une statistique + calculée à raison d'**une observation par grappe** (la prévalence d'un item, mesurée une fois par copie) n'a aucune corrélation intra-grappe à corriger — son deff vaut 1 et cette fonction n'a pas lieu d'être appelée. diff --git a/src/evaluation_dictee/models/base.py b/src/evaluation_dictee/models/base.py index e7e5a5d..ad337ad 100644 --- a/src/evaluation_dictee/models/base.py +++ b/src/evaluation_dictee/models/base.py @@ -7,6 +7,13 @@ from evaluation_dictee.data.loaders import Copy +#: Code posé quand la réponse du modèle n'a pu être ni parsée ni alignée sur l'item +#: (réponse vide, JSON cassé, item absent de la réponse). Ce n'est PAS un code de la +#: grille : il marque un échec technique, à distinguer d'un désaccord de jugement. +#: Défini ici parce que c'est l'interface `Scorer` qui l'émet — les deux scorers, le +#: ré-alignement et le pipeline s'y réfèrent. +CODE_NON_PARSE = "?" + @dataclass class ItemPrediction: diff --git a/src/evaluation_dictee/models/two_stage.py b/src/evaluation_dictee/models/two_stage.py index 68bbe26..f782e66 100644 --- a/src/evaluation_dictee/models/two_stage.py +++ b/src/evaluation_dictee/models/two_stage.py @@ -7,16 +7,18 @@ import json import re -from typing import cast +from typing import Any, cast -from openai import OpenAI +# Client instrumenté Langfuse (comme VLMScorer) : sans lui, les appels des deux étapes +# n'apparaissent pas comme « générations » sous la trace de la copie. +from langfuse.openai import OpenAI from openai.types.chat import ChatCompletionMessageParam from evaluation_dictee.config import ModelConfig, PromptConfig from evaluation_dictee.data.grid import GridItem from evaluation_dictee.data.loaders import Copy, load_image -from evaluation_dictee.models.base import CopyPrediction, ItemPrediction, Scorer -from evaluation_dictee.models.vlm import _image_to_data_url +from evaluation_dictee.models.base import CODE_NON_PARSE, CopyPrediction, ItemPrediction, Scorer +from evaluation_dictee.models.vlm import _image_to_data_url, _items_json_schema from evaluation_dictee.pipeline.alignment import best_realignment, needs_realignment from evaluation_dictee.pipeline.prompts import ( attach_image, @@ -27,6 +29,41 @@ logger = get_logger(__name__) +# Schéma de la réponse attendue à l'étape 1 (transcription seule). +_TRANSCRIPTION_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"transcription": {"type": "string"}}, + "required": ["transcription"], +} + + +def _request_kwargs( + model_config: ModelConfig, schema: dict[str, Any], schema_name: str +) -> dict[str, Any]: + """Options d'appel communes aux deux étapes : décodage contraint et coupure du . + + Ces options étaient appliquées à l'end-to-end (`VLMScorer`) mais pas ici, d'où les + nombreux « JSON non extractible » à l'étape 2 : sans `response_format`, le modèle + produit du JSON libre (souvent tronqué ou précédé d'un bloc de raisonnement). + + Args: + model_config: Configuration du modèle de l'étape concernée. + schema: Schéma JSON de la réponse attendue. + schema_name: Nom du schéma transmis à l'API. + + Returns: + Les kwargs à passer à `chat.completions.create` (éventuellement vides). + """ + kwargs: dict[str, Any] = {} + if model_config.structured_output: + kwargs["response_format"] = { + "type": "json_schema", + "json_schema": {"name": schema_name, "schema": schema}, + } + if model_config.disable_thinking: + kwargs["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}} + return kwargs + def _extract_items_from_content(content: str) -> list[dict]: """Extrait la liste d'items d'une réponse modèle, du parsing strict au plus permissif. @@ -141,6 +178,9 @@ def _transcribe(self, copy: Copy) -> tuple[str, int]: _image_to_data_url(image), ), ) + request_kwargs = _request_kwargs( + self.model_config, _TRANSCRIPTION_SCHEMA, "transcription_dictee" + ) for attempt in range(self.model_config.max_retries + 1): temp = self.model_config.temperature + (0.3 if attempt > 0 else 0.0) response = self.client.chat.completions.create( @@ -148,6 +188,7 @@ def _transcribe(self, copy: Copy) -> tuple[str, int]: temperature=temp, max_tokens=self.model_config.max_tokens, messages=messages, + **request_kwargs, ) content = response.choices[0].message.content or "{}" transcription = self._parse_transcription(content) @@ -198,6 +239,11 @@ def _code_text(self, copy: Copy, reference_text: str, transcription: str) -> Cop scheme=self.scheme, ), ) + # chain_of_thought=False : le prompt de l'étape 2 ne demande pas de champ + # `comparaison`, inutile de le rendre obligatoire dans le schéma. + request_kwargs = _request_kwargs( + self.model_config_stage2, _items_json_schema(chain_of_thought=False), "codage_dictee" + ) for attempt in range(self.model_config_stage2.max_retries + 1): temp = self.model_config_stage2.temperature + (0.3 if attempt > 0 else 0.0) response = self.client.chat.completions.create( @@ -205,6 +251,7 @@ def _code_text(self, copy: Copy, reference_text: str, transcription: str) -> Cop temperature=temp, max_tokens=self.model_config_stage2.max_tokens, messages=messages, + **request_kwargs, ) content = response.choices[0].message.content or "" if _extract_items_from_content(content): @@ -239,7 +286,7 @@ def _parse_coding(self, copy: Copy, content: str) -> CopyPrediction: content[:200], ) - codes_seq = [str(it.get("code", "?")).strip() for it in raw_items] + codes_seq = [str(it.get("code", CODE_NON_PARSE)).strip() for it in raw_items] trans_seq = [it.get("transcription") for it in raw_items] conf_seq = [it.get("confidence") for it in raw_items] @@ -265,12 +312,12 @@ def _parse_coding(self, copy: Copy, content: str) -> CopyPrediction: for item_id in copy.item_ids: entry = by_id.get(item_id) if entry is None: - items.append(ItemPrediction(item_id=item_id, code="?", confidence=0.0)) + items.append(ItemPrediction(item_id=item_id, code=CODE_NON_PARSE, confidence=0.0)) else: items.append( ItemPrediction( item_id=item_id, - code=str(entry.get("code", "?")).strip(), + code=str(entry.get("code", CODE_NON_PARSE)).strip(), confidence=entry.get("confidence"), transcription=entry.get("transcription"), comparaison=entry.get("comparaison"), @@ -295,7 +342,8 @@ def score_copy(self, copy: Copy, reference_text: str | None) -> CopyPrediction: if not transcription.strip(): items_vides = [ - ItemPrediction(item_id=i, code="?", confidence=0.0) for i in copy.item_ids + ItemPrediction(item_id=i, code=CODE_NON_PARSE, confidence=0.0) + for i in copy.item_ids ] return CopyPrediction( copy_id=copy.copy_id, diff --git a/src/evaluation_dictee/models/vlm.py b/src/evaluation_dictee/models/vlm.py index e5c7139..001e0c8 100644 --- a/src/evaluation_dictee/models/vlm.py +++ b/src/evaluation_dictee/models/vlm.py @@ -14,7 +14,7 @@ from evaluation_dictee.config import ModelConfig, PromptConfig from evaluation_dictee.data.grid import GridItem from evaluation_dictee.data.loaders import Copy, load_image -from evaluation_dictee.models.base import CopyPrediction, ItemPrediction, Scorer +from evaluation_dictee.models.base import CODE_NON_PARSE, CopyPrediction, ItemPrediction, Scorer from evaluation_dictee.pipeline.alignment import best_realignment, needs_realignment from evaluation_dictee.pipeline.prompts import ( PROMPT_DICTATION, @@ -186,7 +186,7 @@ def _parse_response(self, copy: Copy, content: str) -> CopyPrediction: raw_items = [] # Séquences dans l'ordre renvoyé par le modèle (avant ré-alignement éventuel). - codes_seq = [str(it.get("code", "?")).strip() for it in raw_items] + codes_seq = [str(it.get("code", CODE_NON_PARSE)).strip() for it in raw_items] trans_seq = [it.get("transcription") for it in raw_items] conf_seq = [it.get("confidence") for it in raw_items] @@ -194,7 +194,8 @@ def _parse_response(self, copy: Copy, content: str) -> CopyPrediction: n_trans_utiles = sum(1 for t in trans_seq if t and str(t).strip()) if not raw_items or n_trans_utiles == 0: items_vides = [ - ItemPrediction(item_id=i, code="?", confidence=0.0) for i in copy.item_ids + ItemPrediction(item_id=i, code=CODE_NON_PARSE, confidence=0.0) + for i in copy.item_ids ] return CopyPrediction(copy_id=copy.copy_id, items=items_vides, transcribed=False) @@ -221,12 +222,12 @@ def _parse_response(self, copy: Copy, content: str) -> CopyPrediction: for item_id in copy.item_ids: entry = by_id.get(item_id) if entry is None: - items.append(ItemPrediction(item_id=item_id, code="?", confidence=0.0)) + items.append(ItemPrediction(item_id=item_id, code=CODE_NON_PARSE, confidence=0.0)) else: items.append( ItemPrediction( item_id=item_id, - code=str(entry.get("code", "?")).strip(), + code=str(entry.get("code", CODE_NON_PARSE)).strip(), confidence=entry.get("confidence"), transcription=entry.get("transcription"), comparaison=entry.get("comparaison"), diff --git a/src/evaluation_dictee/pipeline/alignment.py b/src/evaluation_dictee/pipeline/alignment.py index c268ae5..002a5b5 100644 --- a/src/evaluation_dictee/pipeline/alignment.py +++ b/src/evaluation_dictee/pipeline/alignment.py @@ -9,6 +9,8 @@ import unicodedata from dataclasses import dataclass +from evaluation_dictee.models.base import CODE_NON_PARSE + @dataclass class AlignedPrediction: @@ -148,7 +150,9 @@ def realign( aligned[i - 1] = AlignedPrediction("0", None, 0.0, realigned=True) i -= 1 - return [a if a is not None else AlignedPrediction("?", None, 0.0, True) for a in aligned] + return [ + a if a is not None else AlignedPrediction(CODE_NON_PARSE, None, 0.0, True) for a in aligned + ] def _alignment_quality(expected_words: list[str], aligned: list[AlignedPrediction]) -> float: @@ -223,7 +227,9 @@ def realign_anchored( else: result[ie] = AlignedPrediction("0", None, 0.0, realigned=True) - return [a if a is not None else AlignedPrediction("?", None, 0.0, True) for a in result] + return [ + a if a is not None else AlignedPrediction(CODE_NON_PARSE, None, 0.0, True) for a in result + ] def best_realignment( diff --git a/src/evaluation_dictee/pipeline/benchmark.py b/src/evaluation_dictee/pipeline/benchmark.py index d8788e7..3940633 100644 --- a/src/evaluation_dictee/pipeline/benchmark.py +++ b/src/evaluation_dictee/pipeline/benchmark.py @@ -6,20 +6,29 @@ from __future__ import annotations import contextvars +import fcntl import json import os +from collections.abc import Iterator from concurrent.futures import ThreadPoolExecutor, as_completed +from contextlib import contextmanager from dataclasses import dataclass, field from pathlib import Path from tqdm import tqdm -from evaluation_dictee.config import ExperimentConfig +from evaluation_dictee.config import ExperimentConfig, run_output_name from evaluation_dictee.data import reference from evaluation_dictee.data.grid import load_grid from evaluation_dictee.data.loaders import Copy, ink_ratio, load_dataset, load_image from evaluation_dictee.evaluation.metrics import ScoringMetrics, compute_scoring_metrics -from evaluation_dictee.models.base import CopyPrediction, ItemPrediction, Scorer +from evaluation_dictee.models.base import ( + CODE_NON_PARSE, + CopyPrediction, + ItemPrediction, + Scorer, +) +from evaluation_dictee.pipeline.purge import preparer_reprise from evaluation_dictee.utils.logging import get_logger from evaluation_dictee.utils.tracking import copy_trace @@ -53,30 +62,44 @@ class _CopyOutcome: blank: bool = False # copie vierge auto-codée "0" (status "ok") -def _load_processed_copy_ids(predictions_path: Path) -> set[str]: - """Renvoie les copy_id déjà présents dans un fichier de prédictions (pour reprendre un run). +@contextmanager +def _single_writer(out_path: Path) -> Iterator[None]: + """Interdit deux runs concurrents sur le même fichier de prédictions. + + Deux process qui appendent le même JSONL se dupliquent le travail et écrivent + les mêmes copies deux fois (métriques calculées sur des doublons), voire des + lignes entrelacées si une écriture dépasse la taille d'écriture atomique du + noyau. Le verrou est un `flock` sur `.lock` : le noyau le relâche tout + seul si le process meurt, donc un crash ne laisse pas de verrou fantôme. Args: - predictions_path: chemin du fichier JSONL de prédictions. + out_path: Chemin du fichier de prédictions à protéger. - Returns: - L'ensemble des copy_id déjà traités (vide si le fichier n'existe pas). + Yields: + Rien ; le verrou est tenu pendant toute la durée du bloc. + + Raises: + RuntimeError: Si un autre process écrit déjà dans ce fichier. """ - if not predictions_path.exists(): - return set() - processed: set[str] = set() - with open(predictions_path, encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line: - continue - try: - rec = json.loads(line) - processed.add(rec["copy_id"]) - except (json.JSONDecodeError, KeyError): - # Ligne tronquée par un crash : ignorée. - continue - return processed + lock_path = out_path.parent / f"{out_path.name}.lock" + with open(lock_path, "w", encoding="utf-8") as lock_file: + try: + fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as exc: + raise RuntimeError( + f"Un autre run écrit déjà dans {out_path.name} (verrou {lock_path.name}).\n" + "Deux runs sur le même fichier dupliquent les copies et faussent les " + "métriques. Vérifier les process en cours :\n" + " ps -ef | grep run_benchmark\n" + "Pour lancer un run concurrent volontairement, changer `name` dans le " + "YAML ou passer --model-name (le fichier de sortie sera distinct)." + ) from exc + lock_file.write(str(os.getpid())) + lock_file.flush() + try: + yield + finally: + fcntl.flock(lock_file, fcntl.LOCK_UN) def run_benchmark( @@ -134,10 +157,16 @@ def run_benchmark( output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) - out_path = output_dir / f"{config.name}_predictions.jsonl" - failed_path = output_dir / f"{config.name}_failed_copies.txt" - - processed = _load_processed_copy_ids(out_path) + # `run_output_name` slugifie le(s) nom(s) de modèle et n'ajoute le suffixe qu'une + # fois : un nom type "Qwen/Qwen2.5-VL-7B" ne casse pas le chemin, et un lancement + # avec --model-name ne produit plus un suffixe dupliqué (donc pas de checkpoint orphelin). + run_name = run_output_name(config) + out_path = output_dir / f"{run_name}_predictions.jsonl" + failed_path = output_dir / f"{run_name}_failed_copies.txt" + + # Retire du fichier les copies dont aucun item n'est exploitable avant de + # décider quoi sauter : sinon la reprise fige les échecs (cf. pipeline/purge). + processed = preparer_reprise(out_path) if processed: logger.info( "Reprise détectée : %d copies déjà traitées dans %s. On saute ces copies.", @@ -161,6 +190,12 @@ def run_benchmark( failed_copies: list[tuple[str, str]] = [] # (copy_id, message d'erreur) blank_copies: list[str] = [] # copies vierges auto-codées "0" + # Modèle(s) inscrits dans CHAQUE ligne du JSONL, et pas seulement dans le nom du + # fichier : c'est la seule façon de savoir quel modèle a produit une prédiction + # après une fusion, un renommage ou un export S3. `model_stage2` vaut None en end_to_end. + model_name = config.model.name + model_stage2_name = config.model_stage2.name if config.model_stage2 is not None else None + def _score_one(copy: Copy) -> _CopyOutcome: """Score une copie et prépare ses lignes JSONL (exécuté dans un thread worker). @@ -218,7 +253,7 @@ def _score_one(copy: Copy) -> _CopyOutcome: for item_id, expert_code in zip(copy.item_ids, copy.expert_codes, strict=True): pred = pred_by_id.get(item_id) true_code = reference.normalize(expert_code, scheme) - pred_code = reference.normalize(pred.code, scheme) if pred else "?" + pred_code = reference.normalize(pred.code, scheme) if pred else CODE_NON_PARSE records.append( { "copy_id": copy.copy_id, @@ -230,6 +265,8 @@ def _score_one(copy: Copy) -> _CopyOutcome: "comparaison": pred.comparaison if pred else None, "raw_transcription": prediction.raw_transcription, "approach": config.approach, + "model": model_name, + "model_stage2": model_stage2_name, "blank": is_blank, "ink_ratio": densite_encre, } @@ -275,8 +312,10 @@ def _consume(outcome: _CopyOutcome, f_pred: object) -> None: f_pred.flush() # type: ignore[attr-defined] os.fsync(f_pred.fileno()) # type: ignore[attr-defined] - # Mode APPEND : conserve les copies déjà traitées lors d'une reprise. - with open(out_path, "a", encoding="utf-8") as f_pred: + # Mode APPEND : conserve les copies déjà traitées lors d'une reprise. Le verrou est + # pris AVANT la première écriture : un second run sur le même fichier échoue tout de + # suite (LOCK_NB) au lieu d'y dupliquer des copies. + with _single_writer(out_path), open(out_path, "a", encoding="utf-8") as f_pred: desc = f"Évaluation ({config.name}, {workers} en parallèle)" if workers <= 1: # Chemin séquentiel (comportement historique), sans thread ni contexte copié. @@ -313,6 +352,12 @@ def _consume(outcome: _CopyOutcome, f_pred: object) -> None: confidences: list[float | None] = [] item_ids: list[str] = [] copy_ids: list[str] = [] + # Déduplication par (copy_id, item_id), la DERNIÈRE occurrence gagne. Un fichier + # écrit par deux runs concurrents (ou repris après un crash à mi-copie) contient la + # même copie plusieurs fois ; sans cette passe, chaque item dupliqué pèse double + # dans l'accord brut et le kappa. + par_cle: dict[tuple[str, str], dict] = {} + n_valides = 0 with open(out_path, encoding="utf-8") as f: for line in f: line = line.strip() @@ -322,16 +367,30 @@ def _consume(outcome: _CopyOutcome, f_pred: object) -> None: rec = json.loads(line) except json.JSONDecodeError: continue - y_true.append(rec["y_true"]) - y_pred.append(rec["y_pred"]) - confidences.append(rec.get("confidence")) - item_ids.append(rec["item_id"]) - copy_ids.append(rec["copy_id"]) + n_valides += 1 + par_cle[(rec["copy_id"], rec["item_id"])] = rec + + n_lignes_dupliquees = n_valides - len(par_cle) + if n_lignes_dupliquees > 0: + logger.warning( + "%d ligne(s) dupliquée(s) dans %s (même copy_id + item_id) : ignorées pour " + "les métriques, seule la dernière occurrence compte. Cause probable : deux " + "runs concurrents sur le même fichier avant l'ajout du verrou.", + n_lignes_dupliquees, + out_path.name, + ) + + for rec in par_cle.values(): + y_true.append(rec["y_true"]) + y_pred.append(rec["y_pred"]) + confidences.append(rec.get("confidence")) + item_ids.append(rec["item_id"]) + copy_ids.append(rec["copy_id"]) # Garde-fou : après normalisation, un code hors de l'alphabet attendu signale une # incohérence (prétraitement expert oublié, prompt non aligné sur le schéma). attendus = reference.allowed_codes(scheme) codes_vus = set(y_true) | set(y_pred) - intrus = codes_vus - attendus - {"?"} # "?" = réponse modèle non parsée, traité à part + intrus = codes_vus - attendus - {CODE_NON_PARSE} # échec de parsing, traité à part if intrus: logger.warning( "Codes hors du schéma '%s' (attendu %s) détectés : %s. " diff --git a/src/evaluation_dictee/pipeline/purge.py b/src/evaluation_dictee/pipeline/purge.py new file mode 100644 index 0000000..fc40877 --- /dev/null +++ b/src/evaluation_dictee/pipeline/purge.py @@ -0,0 +1,164 @@ +"""Préparation de la reprise d'un run : retrait des copies non exploitables. + +Un item codé `CODE_NON_PARSE` n'a pas de valeur d'évaluation : il signale que la +réponse du modèle n'a pu être ni parsée ni alignée (réponse vide, JSON cassé, item +absent de la réponse). Il compte pourtant en désaccord dans toutes les métriques, +et dégrade donc le modèle pour une raison purement technique. + +Le risque vient de la reprise : le benchmark saute les copies déjà présentes dans le +JSONL, sans regarder leur contenu. Une copie ratée y reste figée, même après +correction de la cause — c'est ce qui est arrivé au run two_stage du 5 août 2026, où +84 copies sont restées entièrement non parsées alors que les options d'appel de +l'étape 2 avaient été corrigées entre-temps : la relance sautait exactement les +copies à refaire. + +`preparer_reprise` est donc appelée au démarrage de chaque run (`run_benchmark`) : +elle retire du fichier les copies sans aucun code exploitable et renvoie les copies +réellement acquises. Aucun point d'entrée CLI : ce nettoyage n'a de sens que dans le +lancement du pipeline, dont il conditionne la liste des copies à traiter. +""" + +from __future__ import annotations + +import json +import os +import shutil +from collections import Counter +from pathlib import Path + +from evaluation_dictee.models.base import CODE_NON_PARSE +from evaluation_dictee.utils.logging import get_logger + +logger = get_logger(__name__) + +#: Part d'items non parsés à partir de laquelle une copie est retirée du fichier. +#: 1.0 = seulement les copies dont AUCUN item n'est exploitable. Une valeur plus +#: basse refait aussi les copies partiellement ratées, au prix de recoder des items +#: valides — et, à chaque relance, de refaire les copies dont le modèle omet +#: régulièrement un item. +SEUIL_PURGE = 1.0 + + +def compter_non_parses(chemin: str | Path) -> tuple[Counter[str], Counter[str]]: + """Compte, par copie, les items présents et les items non parsés. + + Args: + chemin: fichier JSONL de prédictions. + + Returns: + Le couple (items par copie, items non parsés par copie). Les lignes + illisibles — dernière ligne tronquée par un crash, par exemple — sont + ignorées. + """ + n_items: Counter[str] = Counter() + n_non_parses: Counter[str] = Counter() + with open(chemin, encoding="utf-8") as f: + for ligne in f: + ligne = ligne.strip() + if not ligne: + continue + try: + rec = json.loads(ligne) + copy_id = rec["copy_id"] + except (json.JSONDecodeError, KeyError): + continue + n_items[copy_id] += 1 + if str(rec.get("y_pred")) == CODE_NON_PARSE: + n_non_parses[copy_id] += 1 + return n_items, n_non_parses + + +def copies_a_purger( + n_items: Counter[str], n_non_parses: Counter[str], seuil: float = SEUIL_PURGE +) -> set[str]: + """Copies dont la part d'items non parsés atteint le seuil. + + Args: + n_items: nombre d'items par copie. + n_non_parses: nombre d'items non parsés par copie. + seuil: part minimale d'items non parsés pour retirer la copie. Une copie + sans aucun item non parsé n'est jamais retenue, même à seuil nul. + + Returns: + L'ensemble des copy_id à retirer du fichier. + """ + return { + copy_id + for copy_id, total in n_items.items() + if n_non_parses.get(copy_id, 0) and n_non_parses[copy_id] / total >= seuil + } + + +def purger(chemin: str | Path, a_purger: set[str]) -> int: + """Réécrit le fichier de prédictions sans les copies indiquées. + + Le fichier d'origine est d'abord copié en `.bak`, et la réécriture passe par un + temporaire suivi d'un `os.replace` : le fichier de prédictions n'est jamais + partiel, même si le process meurt en cours de route. C'est le même niveau de + garantie que l'écriture incrémentale du benchmark. + + Args: + chemin: fichier JSONL de prédictions. + a_purger: copy_id à retirer. + + Returns: + Le nombre de lignes supprimées. + """ + chemin = Path(chemin) + shutil.copy2(chemin, chemin.with_suffix(chemin.suffix + ".bak")) + temporaire = chemin.with_suffix(chemin.suffix + ".tmp") + supprimees = 0 + with ( + open(chemin, encoding="utf-8") as source, + open(temporaire, "w", encoding="utf-8") as cible, + ): + for ligne in source: + nue = ligne.strip() + if nue: + try: + if json.loads(nue)["copy_id"] in a_purger: + supprimees += 1 + continue + except (json.JSONDecodeError, KeyError): + pass + cible.write(ligne) + cible.flush() + os.fsync(cible.fileno()) + os.replace(temporaire, chemin) + return supprimees + + +def preparer_reprise(chemin: str | Path, seuil: float = SEUIL_PURGE) -> set[str]: + """Nettoie le fichier de prédictions et renvoie les copies déjà acquises. + + Appelée au démarrage d'un run. Sans fichier (premier lancement), ne fait rien. + Sans copie ratée — le cas normal —, ne réécrit rien : le fichier n'est que relu. + + Args: + chemin: fichier JSONL de prédictions du run. + seuil: part d'items non parsés à partir de laquelle refaire une copie. + + Returns: + Les copy_id à considérer comme traités, donc à sauter. + """ + chemin = Path(chemin) + if not chemin.is_file(): + return set() + + n_items, n_non_parses = compter_non_parses(chemin) + a_purger = copies_a_purger(n_items, n_non_parses, seuil) + if not a_purger: + return set(n_items) + + items_perdus = sum(n_non_parses[c] for c in a_purger) + logger.warning( + "%d copies déjà présentes n'ont aucun code exploitable (%d items « %s ») : " + "elles sont retirées du fichier et vont être refaites. Sauvegarde : %s.bak", + len(a_purger), + items_perdus, + CODE_NON_PARSE, + chemin.name, + ) + supprimees = purger(chemin, a_purger) + logger.info("%d lignes retirées de %s.", supprimees, chemin.name) + return set(n_items) - a_purger diff --git a/src/evaluation_dictee/utils/s3_export.py b/src/evaluation_dictee/utils/s3_export.py index 73ca030..ffa27b1 100644 --- a/src/evaluation_dictee/utils/s3_export.py +++ b/src/evaluation_dictee/utils/s3_export.py @@ -19,7 +19,9 @@ from pathlib import Path import fsspec +import yaml +from evaluation_dictee.config import load_config, run_output_name from evaluation_dictee.utils.logging import get_logger logger = get_logger(__name__) @@ -30,6 +32,27 @@ HTR_SUFFIX = "_htr_predictions.jsonl" +def resolve_run_name(config_path: str | Path, htr: bool = False) -> str: + """Résout le préfixe des fichiers de sortie d'un run à partir de son YAML. + + Pour le scoring, ce préfixe inclut le(s) nom(s) de modèle (`run_output_name`) : + se contenter du champ `name` désignerait un fichier inexistant, puisque le + benchmark suffixe ses sorties par le modèle. Le pipeline HTR, lui, nomme + encore ses sorties d'après le seul champ `name`. + + Args: + config_path: Chemin du YAML du run. + htr: True pour un run HTR (nommage par `name` seul). + + Returns: + Le préfixe des fichiers de sortie du run. + """ + if htr: + with open(config_path, encoding="utf-8") as f: + return str(yaml.safe_load(f)["name"]) + return run_output_name(load_config(config_path)) + + def _join_s3(prefix: str, name: str) -> str: """Concatène un préfixe (S3 ou local) et un nom de fichier (un seul slash).""" return prefix.rstrip("/") + "/" + name diff --git a/src/evaluation_dictee/utils/tracking.py b/src/evaluation_dictee/utils/tracking.py index 1840715..2c5bb2e 100644 --- a/src/evaluation_dictee/utils/tracking.py +++ b/src/evaluation_dictee/utils/tracking.py @@ -34,10 +34,15 @@ def _run_metadata(config: ExperimentConfig) -> dict[str, str]: config: Configuration de l'expérience à décrire. Returns: - Dictionnaire {modèle, méthode, schéma, n_few_shot, corpus} en chaînes. + Dictionnaire {modèle, modèle étape 2, approche, méthode, schéma, n_few_shot, + corpus} en chaînes. `model_stage2` vaut "-" en end_to_end. """ return { "model": config.model.name, + # Sans ce champ, deux runs two_stage ne différant que par le modèle de codage + # sont indiscernables dans Langfuse. + "model_stage2": config.model_stage2.name if config.model_stage2 is not None else "-", + "approach": config.approach, "method": config.prompt.method, "scheme": config.grid.scheme, "n_few_shot": str(config.prompt.n_few_shot), @@ -52,16 +57,23 @@ def _run_tags(config: ExperimentConfig) -> list[str]: config: Configuration de l'expérience. Returns: - Liste de tags (méthode, corpus, `scheme:…`, `model:…`, `run:`). + Liste de tags (méthode, corpus, `scheme:…`, `model:…`, éventuellement + `model_stage2:…`, `run:`). """ - return [ + tags = [ config.prompt.method, config.data.corpus, f"scheme:{config.grid.scheme}", f"model:{config.model.name}", - # session_id unique par lancement : ce tag regroupe les lancements d'une config - f"run:{config.name}", ] + # Tag distinct seulement si l'étape 2 utilise un autre modèle : évite un tag + # redondant sur les runs two_stage mono-modèle, tout en rendant filtrables les + # runs qui croisent deux modèles. + if config.model_stage2 is not None and config.model_stage2.name != config.model.name: + tags.append(f"model_stage2:{config.model_stage2.name}") + # session_id unique par lancement : ce tag regroupe les lancements d'une config + tags.append(f"run:{config.name}") + return tags def _user_id() -> str | None: diff --git a/tests/test_benchmark_concurrency.py b/tests/test_benchmark_concurrency.py index f9cb31d..50da714 100644 --- a/tests/test_benchmark_concurrency.py +++ b/tests/test_benchmark_concurrency.py @@ -15,7 +15,12 @@ from evaluation_dictee.config import ExperimentConfig from evaluation_dictee.data.loaders import Copy -from evaluation_dictee.models.base import CopyPrediction, ItemPrediction, Scorer +from evaluation_dictee.models.base import ( + CODE_NON_PARSE, + CopyPrediction, + ItemPrediction, + Scorer, +) from evaluation_dictee.pipeline import benchmark as bench @@ -113,8 +118,8 @@ def test_concurrent_matches_sequential(patched, monkeypatch, tmp_path: Path) -> bench.run_benchmark(_config(12), FakeScorer(), output_dir=seq_dir, concurrency=1) bench.run_benchmark(_config(12), FakeScorer(), output_dir=par_dir, concurrency=8) - assert _read_lines(seq_dir / "test_run_predictions.jsonl") == _read_lines( - par_dir / "test_run_predictions.jsonl" + assert _read_lines(seq_dir / "test_run_fake_predictions.jsonl") == _read_lines( + par_dir / "test_run_fake_predictions.jsonl" ) @@ -138,9 +143,10 @@ def test_failures_and_non_transcribed(patched, monkeypatch, tmp_path: Path) -> N result = bench.run_benchmark(_config(6), scorer, output_dir=tmp_path, concurrency=4) assert "c002.png" in result.non_transcribed - assert (tmp_path / "test_run_failed_copies.txt").exists() + assert (tmp_path / "test_run_fake_failed_copies.txt").exists() written = { - json.loads(line)["copy_id"] for line in _read_lines(tmp_path / "test_run_predictions.jsonl") + json.loads(line)["copy_id"] + for line in _read_lines(tmp_path / "test_run_fake_predictions.jsonl") } assert "c001.png" not in written # échec → non écrit assert "c002.png" not in written # non transcrite → non écrit @@ -160,7 +166,7 @@ def test_copie_vierge_auto_codee_zero(patched, monkeypatch, tmp_path: Path) -> N assert result.blank_copies == ["c001.png"] assert "c001.png" not in scorer.scored # aucune inférence sur une copie vierge - recs = [json.loads(line) for line in _read_lines(tmp_path / "test_run_predictions.jsonl")] + recs = [json.loads(line) for line in _read_lines(tmp_path / "test_run_fake_predictions.jsonl")] vierge = [r for r in recs if r["copy_id"] == "c001.png"] assert len(vierge) == 3 assert all(r["y_pred"] == "0" for r in vierge) @@ -171,11 +177,115 @@ def test_copie_vierge_auto_codee_zero(patched, monkeypatch, tmp_path: Path) -> N assert all(r["blank"] is False for r in non_vierge) +def test_nom_fichier_inclut_toujours_le_modele(patched, monkeypatch, tmp_path: Path) -> None: + """Le fichier de sortie porte le modèle, et le suffixe n'est ajouté qu'une fois. + + Régression : `benchmark` ajoutait `model.name` à un `name` que + `override_model_names` avait déjà suffixé, d'où deux fichiers distincts pour un + même run selon son mode de lancement (et donc un checkpoint jamais retrouvé). + """ + copies = _copies(2) + monkeypatch.setattr(bench, "load_dataset", lambda **_k: copies) + + # Cas 1 : modèle lu dans le YAML (`name` non suffixé). + bench.run_benchmark(_config(2), FakeScorer(), output_dir=tmp_path, concurrency=1) + assert (tmp_path / "test_run_fake_predictions.jsonl").exists() + + # Cas 2 : lancement CLI, où `name` porte déjà le suffixe. Même fichier attendu. + deja_suffixe = _config(2).model_copy(update={"name": "test_run_fake"}) + bench.run_benchmark(deja_suffixe, FakeScorer(), output_dir=tmp_path, concurrency=1) + + produits = sorted(p.name for p in tmp_path.glob("*_predictions.jsonl")) + assert produits == ["test_run_fake_predictions.jsonl"] + + +def test_nom_fichier_slugifie_le_modele(patched, monkeypatch, tmp_path: Path) -> None: + """Un nom de modèle contenant « / » ne crée pas de sous-dossier fantôme.""" + monkeypatch.setattr(bench, "load_dataset", lambda **_k: _copies(1)) + config = _config(1).model_copy( + update={"model": _config(1).model.model_copy(update={"name": "Qwen/Qwen2.5-VL-7B"})} + ) + + bench.run_benchmark(config, FakeScorer(), output_dir=tmp_path, concurrency=1) + + assert (tmp_path / "test_run_Qwen-Qwen2.5-VL-7B_predictions.jsonl").exists() + + +def test_modele_inscrit_dans_chaque_ligne(patched, monkeypatch, tmp_path: Path) -> None: + """Chaque ligne du JSONL porte le modèle, y compris celui de l'étape 2.""" + monkeypatch.setattr(bench, "load_dataset", lambda **_k: _copies(2)) + config = ExperimentConfig.model_validate( + { + "name": "test_run", + "approach": "two_stage", + "model": {"name": "vlm-etape1"}, + "model_stage2": {"name": "llm-etape2", "kind": "llm"}, + "data": {"images_path": "x", "labels_path": "y"}, + } + ) + + bench.run_benchmark(config, FakeScorer(), output_dir=tmp_path, concurrency=1) + + out = tmp_path / "test_run_vlm-etape1_llm-etape2_predictions.jsonl" + recs = [json.loads(line) for line in _read_lines(out)] + assert recs + assert all(r["model"] == "vlm-etape1" for r in recs) + assert all(r["model_stage2"] == "llm-etape2" for r in recs) + + +def test_model_stage2_absent_en_end_to_end(patched, monkeypatch, tmp_path: Path) -> None: + """En end_to_end, `model_stage2` est explicitement None (et non absent).""" + monkeypatch.setattr(bench, "load_dataset", lambda **_k: _copies(1)) + + bench.run_benchmark(_config(1), FakeScorer(), output_dir=tmp_path, concurrency=1) + + recs = [json.loads(line) for line in _read_lines(tmp_path / "test_run_fake_predictions.jsonl")] + assert all(r["model"] == "fake" and r["model_stage2"] is None for r in recs) + + +def test_verrou_bloque_un_second_run_sur_le_meme_fichier(tmp_path: Path) -> None: + """Deux runs visant le même JSONL : le second échoue au lieu d'y dupliquer des copies.""" + out = tmp_path / "test_run_fake_predictions.jsonl" + + # Les contextes sont entrés de gauche à droite : la seconde prise de verrou lève, + # et `pytest.raises`, déjà actif, l'intercepte. + with ( + bench._single_writer(out), + pytest.raises(RuntimeError, match="Un autre run écrit déjà"), + bench._single_writer(out), + ): + pass + + # Verrou relâché à la sortie du bloc : un run suivant repasse. + with bench._single_writer(out): + pass + + +def test_lignes_dupliquees_exclues_des_metriques(patched, monkeypatch, tmp_path: Path) -> None: + """Une copie écrite deux fois (runs concurrents) ne pèse qu'une fois dans les métriques.""" + copies = _copies(5) + monkeypatch.setattr(bench, "load_dataset", lambda **_k: copies) + + # c000 pré-écrite EN DOUBLE, comme l'aurait fait un second run concurrent. + out = tmp_path / "test_run_fake_predictions.jsonl" + doublons = [ + json.dumps({"copy_id": "c000.png", "item_id": f"i0_{k}", "y_true": "1", "y_pred": "1"}) + for k in (1, 2, 3) + ] * 2 + out.write_text("\n".join(doublons) + "\n", encoding="utf-8") + + result = bench.run_benchmark(_config(5), FakeScorer(), output_dir=tmp_path, concurrency=1) + + # 5 copies × 3 items = 15 items distincts, malgré les 6 lignes écrites pour c000. + assert result.metrics.n_items == 15 + assert len(result.y_true) == 15 + + def test_resume_skips_processed(patched, monkeypatch, tmp_path: Path) -> None: """Une copie déjà présente dans le JSONL n'est pas re-scorée.""" copies = _copies(5) monkeypatch.setattr(bench, "load_dataset", lambda **_k: copies) - out = tmp_path / "test_run_predictions.jsonl" + out = tmp_path / "test_run_fake_predictions.jsonl" out.write_text( json.dumps({"copy_id": "c000.png", "item_id": "i0_1", "y_true": "1", "y_pred": "1"}) + "\n", encoding="utf-8", @@ -186,3 +296,38 @@ def test_resume_skips_processed(patched, monkeypatch, tmp_path: Path) -> None: assert "c000.png" not in scorer.scored # sautée à la reprise assert len(scorer.scored) == 4 + + +def test_reprise_refait_les_copies_non_exploitables(patched, monkeypatch, tmp_path: Path) -> None: + """Bout en bout : un run relancé recode les copies dont aucun item n'était parsé. + + Le premier run écrit tout ; on abîme ensuite une copie comme l'aurait fait un + échec d'appel (tous les items non parsés). Le second run doit la refaire — et + elle seule. + """ + copies = _copies(4) + monkeypatch.setattr(bench, "load_dataset", lambda **_k: copies) + out = tmp_path / "test_run_fake_predictions.jsonl" + + bench.run_benchmark(_config(4), FakeScorer(), output_dir=tmp_path, concurrency=2) + + abimee = "c002.png" + lignes = [] + for ligne in out.read_text(encoding="utf-8").splitlines(): + rec = json.loads(ligne) + if rec["copy_id"] == abimee: + rec["y_pred"] = CODE_NON_PARSE + lignes.append(json.dumps(rec)) + out.write_text("\n".join(lignes) + "\n", encoding="utf-8") + + scorer = FakeScorer() + bench.run_benchmark(_config(4), scorer, output_dir=tmp_path, concurrency=2) + + assert scorer.scored == [abimee] # seule la copie abîmée est recodée + recodee = [ + json.loads(li) + for li in out.read_text(encoding="utf-8").splitlines() + if json.loads(li)["copy_id"] == abimee + ] + assert len(recodee) == 3 # les anciennes lignes ont été retirées, pas dupliquées + assert all(r["y_pred"] != CODE_NON_PARSE for r in recodee) diff --git a/tests/test_benchmark_resume.py b/tests/test_benchmark_resume.py index 3f2c5fd..de835b7 100644 --- a/tests/test_benchmark_resume.py +++ b/tests/test_benchmark_resume.py @@ -1,50 +1,131 @@ -"""Tests du checkpointing incrémental et de la reprise du benchmark.""" +"""Tests de la reprise du benchmark : copies déjà acquises et copies à refaire. + +La reprise est préparée par `pipeline/purge.preparer_reprise`, qui retire du fichier +les copies sans aucun code exploitable avant de renvoyer celles à sauter. +""" import json from pathlib import Path -from evaluation_dictee.pipeline.benchmark import _load_processed_copy_ids +import pytest + +from evaluation_dictee.models.base import CODE_NON_PARSE +from evaluation_dictee.pipeline.purge import preparer_reprise + + +def _ecrire(path: Path, lignes: list[dict]) -> None: + """Écrit un JSONL de prédictions à partir d'enregistrements bruts.""" + with open(path, "w", encoding="utf-8") as f: + for rec in lignes: + f.write(json.dumps(rec) + "\n") -def test_load_processed_from_empty(tmp_path: Path) -> None: - """Fichier absent = aucune copie déjà traitée.""" - assert _load_processed_copy_ids(tmp_path / "nope.jsonl") == set() +def test_fichier_absent(tmp_path: Path) -> None: + """Fichier absent = aucune copie déjà traitée (premier lancement).""" + assert preparer_reprise(tmp_path / "nope.jsonl") == set() -def test_load_processed_extracts_copy_ids(tmp_path: Path) -> None: +def test_extrait_les_copy_ids(tmp_path: Path) -> None: """Lit correctement les copy_id depuis un JSONL existant.""" path = tmp_path / "p.jsonl" - with open(path, "w", encoding="utf-8") as f: - f.write(json.dumps({"copy_id": "c1.png", "item_id": "i1"}) + "\n") - f.write(json.dumps({"copy_id": "c1.png", "item_id": "i2"}) + "\n") - f.write(json.dumps({"copy_id": "c2.png", "item_id": "i1"}) + "\n") - assert _load_processed_copy_ids(path) == {"c1.png", "c2.png"} + _ecrire( + path, + [ + {"copy_id": "c1.png", "item_id": "i1", "y_pred": "1"}, + {"copy_id": "c1.png", "item_id": "i2", "y_pred": "9"}, + {"copy_id": "c2.png", "item_id": "i1", "y_pred": "1"}, + ], + ) + assert preparer_reprise(path) == {"c1.png", "c2.png"} -def test_load_processed_ignores_truncated_last_line(tmp_path: Path) -> None: +def test_ignore_la_derniere_ligne_tronquee(tmp_path: Path) -> None: """Une ligne tronquée par un crash à mi-écriture ne casse pas la reprise.""" path = tmp_path / "p.jsonl" with open(path, "w", encoding="utf-8") as f: - f.write(json.dumps({"copy_id": "c1.png", "item_id": "i1"}) + "\n") - # Ligne tronquée (le crash a coupé au milieu du json) - f.write('{"copy_id": "c2.png", "item_id":') - assert _load_processed_copy_ids(path) == {"c1.png"} + f.write(json.dumps({"copy_id": "c1.png", "item_id": "i1", "y_pred": "1"}) + "\n") + f.write('{"copy_id": "c2.png", "item_id":') # coupée par le crash + assert preparer_reprise(path) == {"c1.png"} -def test_load_processed_ignores_empty_lines(tmp_path: Path) -> None: +def test_ignore_les_lignes_vides(tmp_path: Path) -> None: path = tmp_path / "p.jsonl" with open(path, "w", encoding="utf-8") as f: f.write("\n") - f.write(json.dumps({"copy_id": "c1.png", "item_id": "i1"}) + "\n") + f.write(json.dumps({"copy_id": "c1.png", "item_id": "i1", "y_pred": "1"}) + "\n") f.write("\n") - assert _load_processed_copy_ids(path) == {"c1.png"} + assert preparer_reprise(path) == {"c1.png"} -def test_load_processed_skips_records_without_copy_id(tmp_path: Path) -> None: +def test_ignore_les_enregistrements_sans_copy_id(tmp_path: Path) -> None: """Ligne JSON valide mais sans copy_id : ignorée silencieusement.""" path = tmp_path / "p.jsonl" - with open(path, "w", encoding="utf-8") as f: - f.write(json.dumps({"copy_id": "c1.png", "item_id": "i1"}) + "\n") - f.write(json.dumps({"unrelated": "junk"}) + "\n") # pas de copy_id - f.write(json.dumps({"copy_id": "c2.png", "item_id": "i1"}) + "\n") - assert _load_processed_copy_ids(path) == {"c1.png", "c2.png"} + _ecrire( + path, + [ + {"copy_id": "c1.png", "item_id": "i1", "y_pred": "1"}, + {"unrelated": "junk"}, + {"copy_id": "c2.png", "item_id": "i1", "y_pred": "1"}, + ], + ) + assert preparer_reprise(path) == {"c1.png", "c2.png"} + + +def test_copie_entierement_non_parsee_est_retiree_et_refaite(tmp_path: Path) -> None: + """Une copie 100 % non parsée n'a rien produit : à refaire, et retirée du fichier.""" + path = tmp_path / "p.jsonl" + _ecrire( + path, + [ + {"copy_id": "ok.png", "item_id": "i1", "y_pred": "1"}, + {"copy_id": "ok.png", "item_id": "i2", "y_pred": "9"}, + {"copy_id": "ratee.png", "item_id": "i1", "y_pred": CODE_NON_PARSE}, + {"copy_id": "ratee.png", "item_id": "i2", "y_pred": CODE_NON_PARSE}, + ], + ) + assert preparer_reprise(path) == {"ok.png"} + + restants = {json.loads(li)["copy_id"] for li in path.read_text(encoding="utf-8").splitlines()} + assert restants == {"ok.png"} # les lignes de la copie ratée ont disparu + assert path.with_suffix(".jsonl.bak").exists() # sauvegarde avant réécriture + assert not path.with_suffix(".jsonl.tmp").exists() # temporaire renommé + + +def test_copie_partiellement_non_parsee_est_conservee(tmp_path: Path) -> None: + """Un item non parsé isolé n'invalide pas la copie. + + La refaire à chaque relance serait sans fin : un modèle qui omet régulièrement + un item ferait boucler indéfiniment le même run. + """ + path = tmp_path / "p.jsonl" + _ecrire( + path, + [ + {"copy_id": "c.png", "item_id": "i1", "y_pred": "1"}, + {"copy_id": "c.png", "item_id": "i2", "y_pred": CODE_NON_PARSE}, + ], + ) + assert preparer_reprise(path) == {"c.png"} + assert not path.with_suffix(".jsonl.bak").exists() # rien à purger, rien à réécrire + + +def test_seuil_permet_de_refaire_les_copies_partielles(tmp_path: Path) -> None: + """Un seuil plus bas refait aussi les copies partiellement ratées.""" + path = tmp_path / "p.jsonl" + _ecrire( + path, + [ + {"copy_id": "saine.png", "item_id": "i1", "y_pred": "1"}, + {"copy_id": "partielle.png", "item_id": "i1", "y_pred": "1"}, + {"copy_id": "partielle.png", "item_id": "i2", "y_pred": CODE_NON_PARSE}, + ], + ) + assert preparer_reprise(path, seuil=0.0) == {"saine.png"} + + +@pytest.mark.parametrize("seuil", [0.0, 0.5, 1.0]) +def test_copie_saine_jamais_retiree(tmp_path: Path, seuil: float) -> None: + """Quel que soit le seuil, une copie sans item non parsé est conservée.""" + path = tmp_path / "p.jsonl" + _ecrire(path, [{"copy_id": "c.png", "item_id": "i1", "y_pred": "1"}]) + assert preparer_reprise(path, seuil=seuil) == {"c.png"} diff --git a/tests/test_config.py b/tests/test_config.py index 86a7bd2..33be481 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,7 +2,9 @@ from pathlib import Path -from evaluation_dictee.config import load_config +import pytest + +from evaluation_dictee.config import load_config, override_model_names, run_output_name def test_charge_config_exemple() -> None: @@ -13,3 +15,91 @@ def test_charge_config_exemple() -> None: assert config.grid.scheme == "simplifiee" assert config.prompt.method == "C" assert config.prompt.read_final_state is True + + +def test_override_model_names_sans_argument_ne_change_rien() -> None: + config = load_config(Path("configs/scoring/dictee_end2end.yaml")) + assert override_model_names(config) is config + + +def test_override_model_names_end_to_end_suffixe_le_nom() -> None: + config = load_config(Path("configs/scoring/dictee_end2end.yaml")) + modele_yaml = config.model.name + updated = override_model_names(config, model_name="qwen3.6-35b-moe") + + assert updated.model.name == "qwen3.6-35b-moe" + # Le point est CONSERVÉ par `_slugify_model_name` : un nom fautif avec un point + # et le nom réel avec un tiret doivent produire deux suffixes distincts, donc + # deux fichiers de sortie distincts. Ne pas « corriger » en tiret. + assert updated.name == "dictee_end2end_qwen3.6-35b-moe" + # La config d'origine n'est pas mutée (on ne fige pas le modèle du YAML ici, + # il change au fil des expériences). + assert config.model.name == modele_yaml + assert config.name == "dictee_end2end" + + +def test_override_model_names_two_stage_les_deux_etapes() -> None: + config = load_config(Path("configs/scoring/dictee_two_stage.yaml")) + updated = override_model_names( + config, model_name="qwen3.6-35b-moe", model_stage2_name="qwen3.6-35b-moe" + ) + + assert updated.model.name == "qwen3.6-35b-moe" + assert updated.model_stage2.name == "qwen3.6-35b-moe" + # Même modèle aux deux étapes ⇒ un seul suffixe (pas de doublon). + assert updated.name == "dictee_two_stage_qwen3.6-35b-moe" + + +def test_override_model_names_deux_modeles_differents() -> None: + config = load_config(Path("configs/scoring/dictee_two_stage.yaml")) + updated = override_model_names( + config, model_name="qwen3.6-35b-moe", model_stage2_name="gemma4-26b-moe" + ) + + assert updated.name == "dictee_two_stage_qwen3.6-35b-moe_gemma4-26b-moe" + + +def test_run_output_name_ajoute_le_modele_depuis_le_yaml() -> None: + """Sans surcharge CLI, le modèle du YAML figure quand même dans le nom de sortie.""" + config = load_config(Path("configs/scoring/dictee_end2end.yaml")) + assert run_output_name(config) == f"dictee_end2end_{config.model.name}" + + +def test_run_output_name_est_idempotent() -> None: + """Un `name` déjà suffixé (lancement CLI) ne reçoit pas un second suffixe. + + C'est la garantie qu'un run lancé via le YAML et le même run lancé via + --model-name écrivent dans le MÊME fichier, donc partagent leur checkpoint. + """ + config = load_config(Path("configs/scoring/dictee_end2end.yaml")) + via_yaml = run_output_name(config) + via_cli = run_output_name(override_model_names(config, model_name=config.model.name)) + assert via_yaml == via_cli + + +def test_run_output_name_two_stage_deux_modeles_differents() -> None: + """Deux modèles différents ⇒ deux fichiers distincts (pas de checkpoint mélangé).""" + config = load_config(Path("configs/scoring/dictee_two_stage.yaml")) + croise = override_model_names( + config, model_name="qwen3.6-35b-moe", model_stage2_name="gemma4-26b-moe" + ) + mono = override_model_names(config, model_name="qwen3.6-35b-moe") + + assert run_output_name(croise) == "dictee_two_stage_qwen3.6-35b-moe_gemma4-26b-moe" + assert run_output_name(mono) != run_output_name(croise) + + +def test_run_output_name_slugifie_les_caracteres_interdits() -> None: + """Un nom de modèle avec « / » ne doit pas produire un chemin à sous-dossier.""" + config = load_config(Path("configs/scoring/dictee_end2end.yaml")) + updated = config.model_copy( + update={"model": config.model.model_copy(update={"name": "Qwen/Qwen2.5-VL-7B-Instruct"})} + ) + assert "/" not in run_output_name(updated) + assert run_output_name(updated) == "dictee_end2end_Qwen-Qwen2.5-VL-7B-Instruct" + + +def test_override_model_stage2_sans_bloc_leve_une_erreur() -> None: + config = load_config(Path("configs/scoring/dictee_end2end.yaml")) + with pytest.raises(ValueError, match="model_stage2"): + override_model_names(config, model_stage2_name="qwen3.6-35b-moe") diff --git a/uv.lock b/uv.lock index 3cc1291..096fe18 100644 --- a/uv.lock +++ b/uv.lock @@ -1388,6 +1388,8 @@ source = { editable = "." } dependencies = [ { name = "httpx" }, { name = "langfuse" }, + { name = "nbconvert" }, + { name = "nbformat" }, { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, { name = "openai" }, @@ -1418,7 +1420,6 @@ website = [ { name = "ipykernel" }, { name = "matplotlib" }, { name = "nbclient" }, - { name = "nbformat" }, ] [package.dev-dependencies] @@ -1440,7 +1441,8 @@ requires-dist = [ { name = "matplotlib", marker = "extra == 'notebooks'", specifier = ">=3.11.1" }, { name = "matplotlib", marker = "extra == 'website'", specifier = ">=3.11.1" }, { name = "nbclient", marker = "extra == 'website'", specifier = ">=0.10" }, - { name = "nbformat", marker = "extra == 'website'", specifier = ">=5.10" }, + { name = "nbconvert", specifier = ">=7.16" }, + { name = "nbformat", specifier = ">=5.10" }, { name = "numpy", specifier = ">=1.26" }, { name = "openai", specifier = ">=1.30" }, { name = "pandas", specifier = ">=2.2" }, diff --git a/website/_analyse.py b/website/_analyse.py index 9daf6c8..9470a77 100644 --- a/website/_analyse.py +++ b/website/_analyse.py @@ -74,16 +74,45 @@ #: hors ligne : `export S3_PREDICTIONS_PREFIX=/chemin/vers/predictions`. PREFIX = os.environ.get("S3_PREDICTIONS_PREFIX", "s3://projet-production-ecrits-depp/predictions") -#: Runs comparés côte à côte, dans l'ordre d'affichage : libellé → nom de run. -#: Surchargeables pour comparer d'autres runs sans toucher aux pages. -RUNS: dict[str, str] = { - "end-to-end": os.environ.get("RESULTATS_RUN_END_TO_END", "dictee_end2end"), - "two-stage": os.environ.get("RESULTATS_RUN_TWO_STAGE", "dictee_two_stage"), + +def _liste_env(variable: str, defaut: list[str]) -> list[str]: + """Lit une liste séparée par des virgules dans l'environnement.""" + brut = os.environ.get(variable, "") + valeurs = [v.strip() for v in brut.split(",") if v.strip()] + return valeurs or defaut + + +#: Approches comparées, dans l'ordre d'affichage : libellé → champ `name` du YAML. +APPROCHES: dict[str, str] = { + "end-to-end": "dictee_end2end", + "two-stage": "dictee_two_stage", } -#: Run servant de référence quand une analyse en exige un seul (classement des -#: pires copies, courbe de renvoi). Par défaut l'approche privilégiée du projet. -RUN_REFERENCE = os.environ.get("RESULTATS_RUN_REFERENCE", "end-to-end") +#: Modèles comparés, dans l'ordre d'affichage. Le benchmark suffixe TOUJOURS ses +#: sorties par le nom du modèle (`__predictions.jsonl`, cf. +#: `config.run_output_name`) : le site nomme donc le modèle pour retrouver le +#: fichier. Un modèle non exporté est simplement omis, avec une note — on ne +#: substitue JAMAIS un autre modèle, ce qui fausserait la comparaison. +MODELES: list[str] = _liste_env("RESULTATS_MODELES", ["gemma4-26b-moe", "qwen3-6-35b-moe"]) + +#: Modèle utilisé par les pages qui n'ont pas d'axe « modèle » (page « Écarts », +#: dont le détail copie par copie serait illisible multiplié par les modèles). +MODELE_REFERENCE = os.environ.get("RESULTATS_MODELE_REFERENCE", MODELES[0]) + +#: Approche de référence quand une analyse exige un run unique (classement des +#: pires copies, tri des items). Par défaut l'approche privilégiée du projet. +APPROCHE_REFERENCE = os.environ.get("RESULTATS_APPROCHE_REFERENCE", "end-to-end") + + +def libelle_run(approche: str, modele: str) -> str: + """Libellé d'un run croisant une approche et un modèle (clé des dictionnaires).""" + return f"{approche} · {modele}" + + +#: Run servant de référence quand une analyse en exige un seul. +RUN_REFERENCE = os.environ.get( + "RESULTATS_RUN_REFERENCE", libelle_run(APPROCHE_REFERENCE, MODELE_REFERENCE) +) #: Chemin de la grille de codage (mots attendus, ordre de la dictée). GRID_PATH = os.environ.get("RESULTATS_GRID_PATH", "configs/grille_dictee_2015.json") @@ -117,10 +146,6 @@ #: Colonne portant la densité d'encre mesurée, quand elle est disponible. COLONNE_ENCRE = "ink_ratio" -#: Fichier de distribution des densités d'encre, produit par -#: `scripts/compute_ink_ratios.py` et cherché à côté des prédictions. -FICHIER_ENCRE = os.environ.get("RESULTATS_FICHIER_ENCRE", "dictee_ink_ratios.csv") - # ── Palette ─────────────────────────────────────────────────────────────────── C_EXPERT = "#1f4e79" C_MODELE = "#c44536" @@ -148,6 +173,35 @@ def init_matplotlib() -> None: ) +def grille_axes(n: int, largeur: float = 7.5, hauteur: float = 7.0, ncols: int = 2): + """Grille de sous-graphiques à `n` panneaux, sur au plus `ncols` colonnes. + + Les figures « un panneau par run » deviennent illisibles alignées sur une + seule ligne dès qu'on croise les approches et les modèles : au-delà de deux + panneaux, on passe à la ligne plutôt que d'écraser chaque panneau. + + Args: + n: nombre de panneaux utiles. + largeur: largeur d'un panneau, en pouces. + hauteur: hauteur d'un panneau, en pouces. + ncols: nombre maximal de colonnes. + + Returns: + Le couple (figure, liste des `n` axes), les axes en trop étant masqués. + """ + import matplotlib.pyplot as plt + + ncols = max(1, min(ncols, n)) + nrows = math.ceil(n / ncols) + fig, axes = plt.subplots( + nrows, ncols, figsize=(largeur * ncols, hauteur * nrows), squeeze=False + ) + plats = [ax for ligne in axes for ax in ligne] + for ax in plats[n:]: + ax.set_visible(False) + return fig, plats[:n] + + # ── Formatage ───────────────────────────────────────────────────────────────── def _absent(x: object) -> bool: """Vrai si la valeur est manquante (None ou NaN) et doit s'afficher « — ».""" @@ -208,6 +262,26 @@ def table_markdown(entetes: list[str], lignes: list[list[str]], legende: str = " return "\n".join(out) +# Ancre de la définition de chaque métrique sur la page « Évaluation & métriques ». +# Les tableaux de résultats n'affichent donc plus le sens de lecture ni la méthode +# d'intervalle : ils y renvoient, pour rester lisibles. +ANCRES_METRIQUES: dict[str, str] = { + "Accord brut": "accord-brut", + "Kappa de Cohen": "kappa", + "Rappel des erreurs (sensibilité)": "rappel", + "Précision sur les erreurs": "precision", + "Taux de sur-correction": "sur-correction", + "Taux de sur-détection": "sur-detection", + "ECE (calibration)": "ece", +} + + +def lien_metrique(metrique: str, page: str = "evaluation.qmd") -> str: + """Nom de métrique transformé en lien vers sa définition (inchangé si inconnue).""" + ancre = ANCRES_METRIQUES.get(metrique) + return f"[{metrique}]({page}#{ancre})" if ancre else metrique + + def bloc_notes() -> str: """Callout replié listant les incidents de chargement (vide si aucun).""" if not NOTES: @@ -228,6 +302,8 @@ class Run: label: str nom: str + approche: str + modele: str couleur: str df: pd.DataFrame copies: pd.DataFrame = field(repr=False) @@ -244,39 +320,167 @@ def n_items(self) -> int: return len(self.df) +#: Suffixes des fichiers exportés (cf. `utils/s3_export.py`). Le suffixe HTR se +#: termine par celui du scoring : ne jamais tester l'un sans écarter l'autre. +SUFFIXE_SCORING = "_predictions.jsonl" +SUFFIXE_HTR = "_htr_predictions.jsonl" + + def _chemin(nom_run: str) -> str: """URI du JSONL de prédictions d'un run.""" - return PREFIX.rstrip("/") + "/" + nom_run + "_predictions.jsonl" + return PREFIX.rstrip("/") + "/" + nom_run + SUFFIXE_SCORING + + +#: Nom des runs de scoring exportés à côté de `PREFIX`, listés une seule fois. +_EXPORTES: list[str] | None = None + + +def _noms_exportes() -> list[str]: + """Noms des runs de scoring exportés à côté de `PREFIX`, triés. + + On liste le RÉPERTOIRE, jamais un motif `_*` : sur S3, un glob par + préfixe fait mettre en cache par s3fs une vue *partielle* du répertoire, + après quoi les autres fichiers deviennent invisibles — y compris pour + `load_predictions`, qui échouerait alors sur un fichier bien présent. + + Returns: + Les noms de runs (suffixe de modèle compris, `_predictions.jsonl` ôté), + hors runs HTR. Liste vide si le préfixe est injoignable. + """ + global _EXPORTES + if _EXPORTES is None: + import fsspec + + try: + fs, _, _ = fsspec.get_fs_token_paths(PREFIX) + entrees = [str(e) for e in fs.ls(PREFIX.rstrip("/"), detail=False)] + except Exception: # noqa: BLE001 — la page doit rester rendable + entrees = [] + _EXPORTES = sorted( + Path(e).name.removesuffix(SUFFIXE_SCORING) + for e in entrees + if e.endswith(SUFFIXE_SCORING) and not e.endswith(SUFFIXE_HTR) + ) + return _EXPORTES -def charger_runs() -> dict[str, Run]: - """Charge tous les runs de `RUNS` et calcule leurs agrégats par item et copie. +def _run_du_modele(base: str, modele: str) -> str | None: + """Nom du run exporté qui croise une approche et un modèle, None s'il manque. + + Deux formes sont acceptées, toutes deux produites par `run_output_name` : + `_` (un seul modèle) et `__` + (two_stage dont l'étape 2 utilise un autre modèle). Le modèle demandé est + donc toujours celui de l'ÉTAPE 1. + + Args: + base: champ `name` du run (ex. `dictee_end2end`). + modele: nom du modèle tel que servi sur llm.lab. Returns: - Les runs chargés, indexés par libellé, dans l'ordre de `RUNS`. Un run - absent ou illisible est omis et l'incident consigné dans `NOTES`. + Le nom du run exporté, ou None si aucun ne correspond. + """ + attendu = f"{base}_{modele}" + candidats = [n for n in _noms_exportes() if n == attendu or n.startswith(f"{attendu}_")] + return candidats[0] if candidats else None + + +def _modele_du_run(df: pd.DataFrame, nom: str, base: str) -> str: + """Modèle(s) d'un run, lu dans les prédictions ou, à défaut, dans son nom. + + Les runs récents estampillent chaque ligne d'un `model` (et d'un + `model_stage2` en two_stage) : c'est la source la plus fiable, et la seule + qui distingue les deux étapes. Les runs plus anciens ne portent pas ces + colonnes — on retombe alors sur le suffixe du nom de fichier, posé par + `config.run_output_name`. + + Args: + df: prédictions du run. + nom: nom du run (préfixe des fichiers de sortie). + base: champ `name` du run, à ôter du nom pour isoler le suffixe. + + Returns: + Le modèle, sous la forme `<étape 1>` ou `<étape 1> → <étape 2>`. + """ + if "model" in df.columns and df["model"].notna().any(): + etape1 = str(df["model"].dropna().iloc[0]) + etape2 = "" + if "model_stage2" in df.columns and df["model_stage2"].notna().any(): + etape2 = str(df["model_stage2"].dropna().iloc[0]) + return etape1 if etape2 in ("", etape1) else f"{etape1} → {etape2}" + if nom.startswith(f"{base}_"): + return nom[len(base) + 1 :].replace("_", " → ") + return "—" + + +def runs_attendus(modeles: list[str] | None = None) -> dict[str, str]: + """Runs à afficher : libellé (approche × modèle) → nom de run attendu. + + Args: + modeles: modèles à croiser avec les approches. [défaut : `MODELES`] + + Returns: + Le dictionnaire des runs attendus, modèles en boucle interne pour que + les deux approches d'un même modèle restent voisines à l'affichage. + """ + return { + libelle_run(approche, modele): f"{base}_{modele}" + for approche, base in APPROCHES.items() + for modele in (modeles if modeles is not None else MODELES) + } + + +def charger_runs(modeles: list[str] | None = None) -> dict[str, Run]: + """Charge les runs croisant chaque approche et chaque modèle demandé. + + Un modèle non exporté est **omis**, jamais remplacé par un autre : la page + compare les modèles entre eux, une substitution silencieuse y attribuerait + les chiffres d'un modèle à un autre. + + Args: + modeles: modèles à charger. [défaut : `MODELES`, tous comparés] + + Returns: + Les runs chargés, indexés par libellé `approche · modèle`. Un run absent + ou illisible est omis et l'incident consigné dans `NOTES`. """ if not PAQUET_OK: return {} + demandes = modeles if modeles is not None else MODELES runs: dict[str, Run] = {} - for i, (label, nom) in enumerate(RUNS.items()): - if not nom: - continue - try: - df = load_predictions(_chemin(nom)) - except Exception as exc: # noqa: BLE001 — la page doit rester rendable - NOTES.append(f"`{nom}` indisponible ({type(exc).__name__}) : colonne non calculée.") - continue - if df.empty: - NOTES.append(f"`{nom}` est vide : colonne non calculée.") - continue - runs[label] = Run( - label=label, - nom=nom, - couleur=COULEURS_RUN[i % len(COULEURS_RUN)], - df=df, - copies=per_copy_metrics(df), - items=per_item_metrics(df), + manquants: list[str] = [] + i = 0 + for approche, base in APPROCHES.items(): + for modele in demandes: + label = libelle_run(approche, modele) + nom = _run_du_modele(base, modele) + if nom is None: + manquants.append(f"`{base}_{modele}`") + continue + try: + df = load_predictions(_chemin(nom)) + except Exception as exc: # noqa: BLE001 — la page doit rester rendable + NOTES.append(f"`{nom}` illisible ({type(exc).__name__}) : run omis.") + continue + if df.empty: + NOTES.append(f"`{nom}` est vide : run omis.") + continue + runs[label] = Run( + label=label, + nom=nom, + approche=approche, + modele=_modele_du_run(df, nom, base), + couleur=COULEURS_RUN[i % len(COULEURS_RUN)], + df=df, + copies=per_copy_metrics(df), + items=per_item_metrics(df), + ) + i += 1 + if manquants: + NOTES.append( + f"Non exporté(s), donc absent(s) des comparaisons : {', '.join(manquants)}. " + "Lancer le run puis `uv run scripts/export_predictions.py --config … " + "--model-name `. Runs disponibles sous " + f"`{PREFIX}` : {', '.join(f'`{n}`' for n in _noms_exportes()) or 'aucun'}." ) if not runs: NOTES.append( @@ -286,6 +490,45 @@ def charger_runs() -> dict[str, Run]: return runs +def restreindre_corpus_commun(runs: dict[str, Run]) -> tuple[dict[str, Run], int]: + """Restreint tous les runs aux copies qu'ils ont TOUS traitées. + + Comparer deux modèles sur des corpus différents (run inachevé, copies + abandonnées à la transcription) confond l'effet du modèle avec celui de la + composition de l'échantillon : les copies difficiles ne se répartissent pas + au hasard. Cette restriction est la seule façon de lire un écart entre + modèles comme un écart de qualité. + + Args: + runs: runs chargés par `charger_runs`. + + Returns: + Le couple (runs restreints aux copies communes, nombre de ces copies). + Les runs sont renvoyés tels quels si le corpus est déjà commun. + """ + if not runs: + return {}, 0 + commun: set[str] = set.intersection(*(set(r.df["copy_id"].unique()) for r in runs.values())) + if not commun: + return {}, 0 + if all(r.n_copies == len(commun) for r in runs.values()): + return runs, len(commun) + restreints: dict[str, Run] = {} + for label, r in runs.items(): + df = r.df[r.df["copy_id"].isin(commun)].reset_index(drop=True) + restreints[label] = Run( + label=r.label, + nom=r.nom, + approche=r.approche, + modele=r.modele, + couleur=r.couleur, + df=df, + copies=per_copy_metrics(df), + items=per_item_metrics(df), + ) + return restreints, len(commun) + + def charger_grille() -> tuple[list[GridItem], dict[str, str], dict[str, int]]: """Charge la grille de codage : items ordonnés, mot attendu et rang par item. @@ -675,20 +918,18 @@ def seuil_encre() -> float: def charger_densites_encre(runs: dict[str, Run]) -> tuple[pd.DataFrame | None, str]: """Densité d'encre par copie, et provenance de la mesure. - Deux sources, par ordre de préférence : - - 1. la colonne `ink_ratio` du JSONL, écrite par le benchmark en même temps - qu'il applique le seuil — c'est la mesure qui a réellement décidé ; - 2. le CSV produit par `scripts/compute_ink_ratios.py`, qui mesure le corpus - sans appel modèle (utile avant d'avoir relancé le benchmark). + Source unique : la colonne `ink_ratio` du JSONL, que le benchmark écrit en même + temps qu'il applique le seuil de copie vierge — c'est donc exactement la mesure + qui a décidé. La mesure vit dans le pipeline et nulle part ailleurs : il n'existe + pas de fichier de densités produit à côté, qui pourrait diverger de lui. Args: runs: runs chargés par `charger_runs`. Returns: Le couple (DataFrame indexé par copy_id avec la colonne `ink_ratio`, - libellé de provenance). Le DataFrame vaut None si aucune source n'est - disponible ; l'incident est alors consigné dans `NOTES`. + libellé de provenance). Le DataFrame vaut None si aucun run ne porte la + colonne ; l'incident est alors consigné dans `NOTES`. """ for label, run in runs.items(): if COLONNE_ENCRE in run.df.columns: @@ -696,25 +937,12 @@ def charger_densites_encre(runs: dict[str, Run]) -> tuple[pd.DataFrame | None, s if len(serie): return serie.to_frame(COLONNE_ENCRE), f"prédictions du run {label}" - # Import local : `fsspec` arrive avec s3fs, mais la page doit rester rendable - # même dans un environnement où le paquet du projet n'est pas installé. - chemin = PREFIX.rstrip("/") + "/" + FICHIER_ENCRE - try: - import fsspec - - with fsspec.open(chemin, "rt", encoding="utf-8") as f: - mesures = pd.read_csv(f, sep=";") - except Exception as exc: # noqa: BLE001 — la page doit rester rendable - NOTES.append( - f"Distribution des densités d'encre indisponible ({type(exc).__name__}) : " - f"section omise. La produire avec `uv run scripts/compute_ink_ratios.py " - f"--config configs/scoring/dictee_end2end.yaml --export`." - ) - return None, "" - if COLONNE_ENCRE not in mesures.columns or "copy_id" not in mesures.columns: - NOTES.append(f"`{FICHIER_ENCRE}` n'a pas les colonnes attendues : section omise.") - return None, "" - return mesures.set_index("copy_id")[[COLONNE_ENCRE]], f"`{FICHIER_ENCRE}`" + NOTES.append( + f"Aucun run ne porte la colonne `{COLONNE_ENCRE}` : distribution des densités " + "d'encre omise. Ces runs sont antérieurs à la mesure de densité — relancer le " + "benchmark et réexporter pour l'obtenir." + ) + return None, "" def marqueur_vierge_disponible(runs: dict[str, Run]) -> bool: diff --git a/website/_quarto.yml b/website/_quarto.yml index b133958..e8fb97f 100644 --- a/website/_quarto.yml +++ b/website/_quarto.yml @@ -3,6 +3,13 @@ project: output-dir: _site render: - "*.qmd" + # Écarts IA – humain : la page détaille copie par copie la transcription + # produite par le modèle et le codage expert. Ce sont des productions + # écrites de mineurs, et ce site est public — la page n'est donc pas + # publiée. Elle reste rendable dans l'environnement sécurisé du SSP Cloud + # en retirant cette ligne. Voir aussi la note « Données sensibles » de + # `index.qmd` et le §6 de `CLAUDE.md`. + - "!ecarts.qmd" - "cards/*.qmd" - "slides/*.qmd" resources: @@ -28,12 +35,10 @@ website: file: initialisation.qmd - text: "Architecture" file: architecture.qmd - - text: "Résultats" - file: resultats.qmd - - text: "Écarts IA – humain" - file: ecarts.qmd - text: "Évaluation & métriques" file: evaluation.qmd + - text: "Résultats" + file: resultats.qmd - text: "Fine-tuning" file: finetuning.qmd - text: "Présentations" diff --git a/website/architecture.qmd b/website/architecture.qmd index 513ea3e..0d61c4d 100644 --- a/website/architecture.qmd +++ b/website/architecture.qmd @@ -1,6 +1,6 @@ --- title: "Architecture du projet" -subtitle: "De l'imagett de la copie à l'évaluation, étape par étape" +subtitle: "De l'imagette de la copie à l'évaluation, étape par étape" --- ## Vue d'ensemble @@ -40,6 +40,176 @@ n'impliquerait de toucher ni les métriques, ni le suivi, ni les notebooks. ![Schéma d'appel au LLM](/img/call_llm.png) +## Liste des commandes à exécuter pour lancer l'évaluation des copies + +Toutes les tâches suivent le même schéma : **un script, un fichier YAML**. Rien +n'est à passer en ligne de commande sauf le chemin de la config — et, pour le +scoring, le nom du modèle si l'on veut le surcharger sans éditer le YAML. + +| Tâche | Commande | Sortie | +|---|---|---| +| Évaluation **end-to-end** | `run_benchmark.py --config configs/scoring/dictee_end2end.yaml` | `data/processed/dictee_end2end__predictions.jsonl` | +| Évaluation **deux étapes** | `run_benchmark.py --config configs/scoring/dictee_two_stage.yaml` | `data/processed/dictee_two_stage__predictions.jsonl` | +| **Transcription HTR** seule | `run_htr_benchmark.py --config configs/htr/htr_REFERENCE.yaml` | `data/processed/htr_REFERENCE_htr_predictions.jsonl` | +| **Export S3** | `export_predictions.py --config ` | `$S3_PREDICTIONS_PREFIX/__predictions.jsonl` | +| **Fine-tuning** HTR | `finetune_htr_scoledit.py --config configs/finetune/finetune_REFERENCE.yaml` | `checkpoints//` (adaptateur LoRA) | + +### 0. Préalable, une fois par service + +```bash +cd ~/work/evaluation_dictee +uv sync # installe tout, groupe dev inclus +mkdir -p logs # les lancements nohup en ont besoin +``` + +Les accès S3 sont injectés par Onyxia ; le token `llm.lab` vient du Vault ou du +fichier `.env`. Vérifier que les deux répondent avant de lancer un run long : + +```bash +uv run python -c "from evaluation_dictee.data.loaders import load_labels; \ +print(len(load_labels('s3://projet-production-ecrits-depp/resultat_dictee_2015.csv')), 'copies')" +``` + +### 1. Évaluation des copies — end-to-end (approche 2) + +Un VLM lit l'image **et** code en une passe. C'est l'approche par défaut. + +```bash +# Modèle inscrit dans le YAML : +uv run scripts/run_benchmark.py --config configs/scoring/dictee_end2end.yaml + +# Ou en surchargeant le modèle, sans éditer le YAML : +uv run scripts/run_benchmark.py --config configs/scoring/dictee_end2end.yaml \ + --model-name gemma4-26b-moe +``` + +### 2. Évaluation des copies — deux étapes (approche 1) + +Étape 1 : un VLM transcrit l'image. Étape 2 : un modèle texte code la +transcription. `--model-stage2-name` ne vaut que pour cette approche. + +```bash +# Même modèle aux deux étapes (valeurs du YAML) : +uv run scripts/run_benchmark.py --config configs/scoring/dictee_two_stage.yaml + +# Croiser deux modèles : lecture par l'un, jugement par l'autre. +uv run scripts/run_benchmark.py --config configs/scoring/dictee_two_stage.yaml \ + --model-name qwen3-6-35b-moe --model-stage2-name gemma4-26b-moe +``` + +::: {.callout-important} +## Le nom du fichier de sortie porte toujours le(s) modèle(s) + +`dictee_two_stage_qwen3-6-35b-moe_predictions.jsonl`, et +`…_qwen3-6-35b-moe_gemma4-26b-moe_…` quand les deux étapes diffèrent. Deux +modèles n'écrasent donc jamais le même checkpoint. Le modèle est aussi inscrit +dans **chaque ligne** du JSONL (champs `model` et `model_stage2`), donc +l'information survit à une fusion, un renommage ou un export. + +Corollaire : **un seul run à la fois par fichier de sortie.** Un second run +visant le même fichier échoue aussitôt sur le verrou `.lock`. Deux runs +qui écrivent le même JSONL dupliquent les copies et faussent les métriques. +::: + +### 3. Transcription HTR seule (corpus Scoledit) + +Mesure la **fidélité de lecture** (CER/WER) indépendamment du codage. Le modèle +se change uniquement dans le YAML : ce script n'accepte pas `--model-name`. + +```bash +uv run scripts/run_htr_benchmark.py --config configs/htr/htr_REFERENCE.yaml +``` + +::: {.callout-warning} +Contrairement au scoring, la sortie HTR est nommée d'après le seul champ `name` +et le fichier est **écrasé** à chaque lancement (pas de reprise). Évaluer deux +modèles impose donc de changer `name` dans le YAML entre les deux runs, sinon le +second efface le premier. +::: + +### 4. Export des prédictions sur S3 + +Le pipeline écrit en local (append + `fsync`, pour la reprise) ; l'export vers S3 +se fait **une fois le run terminé**, pour que notebooks et site Quarto se +relancent sans réexécuter le pipeline. + +```bash +# Scoring — le nom de fichier, modèle inclus, est résolu depuis le YAML : +uv run scripts/export_predictions.py --config configs/scoring/dictee_end2end.yaml + +# Transcription HTR : +uv run scripts/export_predictions.py --config configs/htr/htr_REFERENCE.yaml --htr + +# Par nom de run explicite (utile pour un fichier renommé à la main) : +uv run scripts/export_predictions.py --run-name dictee_end2end_qwen3-6-35b-moe + +# Équivalent via la CLI installée : +eval-ecrit export configs/scoring/dictee_end2end.yaml +``` + +Destination par défaut : `$S3_PREDICTIONS_PREFIX` +(`s3://projet-production-ecrits-depp/predictions`), surchargeable par +`--dest-prefix`. Aucune donnée d'élève n'est jamais commitée dans Git. + +### 5. Fine-tuning HTR (QLoRA) — nécessite un GPU H100 + +Spécialise un VLM sur l'écriture d'enfants (Scoledit, CP→CM2). Sortie : un +adaptateur LoRA léger, à charger par-dessus le modèle de base. Le suivi passe par +**MLflow**, et non Langfuse. + +```bash +uv run scripts/finetune_htr_scoledit.py --config configs/finetune/finetune_REFERENCE.yaml +``` + +Pour un essai de bout en bout à coût réduit, mettre `data.limit: 100` dans le +YAML avant de lancer l'entraînement complet. + +### 6. Annexe — densité d'encre (détection des copies vierges) + +Aucune commande à lancer : le benchmark mesure la densité d'encre de chaque copie +au moment où il la traite — c'est ainsi qu'il décide si elle est vierge — et écrit +la valeur dans la colonne `ink_ratio` du JSONL. Le seuil `data.blank_ink_threshold` +et la distribution qui le justifie (page « Écarts » du site) se lisent donc +directement dans les prédictions exportées, sans mesure parallèle qui pourrait +diverger de celle du pipeline. + +### Lancer un run long sans le perdre + +Un benchmark complet (3469 copies) dure de longues heures : **jamais dans le +terminal du navigateur sans protection**, une mise en veille ou un onglet fermé +tuerait le process. + +```bash +# 1. Vérifier qu'aucun run ne tourne déjà (le verrou le bloquerait, autant le voir avant) : +ps -ef | grep run_benchmark | grep -v grep +screen -ls + +# 2. Lancer, avec UN LOG DISTINCT PAR RUN : +nohup uv run scripts/run_benchmark.py --config configs/scoring/dictee_end2end.yaml \ + > logs/dictee_end2end.log 2>&1 & +nohup uv run scripts/run_benchmark.py --config configs/scoring/dictee_two_stage.yaml \ + > logs/dictee_two_stage.log 2>&1 & + +# 3. Vérifier que la reprise a bien pris (sinon le run repart de zéro) : +head -20 logs/dictee_end2end.log | grep -i "reprise\|copies au total" + +# 4. Suivre : +tail -f logs/dictee_end2end.log +watch -n 5 "wc -l data/processed/dictee_end2end_qwen3-6-35b-moe_predictions.jsonl" + +# 5. Arrêter (le checkpointing conserve tout sauf la copie en cours) : +pkill -f "run_benchmark.py --config configs/scoring/dictee_end2end.yaml" +``` + +::: {.callout-note} +## Ne pas piloter un run par un fichier `.pid` + +`nohup uv run … &` crée **deux** process : le wrapper `uv run` et le vrai +`python3 scripts/run_benchmark.py`. `echo $!` ne capture que le wrapper ; un +`kill` sur ce seul PID laisse l'enfant Python vivant en orphelin, qui continue +d'écrire dans le JSONL. `pkill -f` sur le motif de la config cible les deux. +::: + ## Le déroulé d'un run Le module `pipeline/benchmark.py` orchestre chaque run : diff --git a/website/ecarts.qmd b/website/ecarts.qmd index 2941c48..6e3c928 100644 --- a/website/ecarts.qmd +++ b/website/ecarts.qmd @@ -23,7 +23,11 @@ A.init_matplotlib() #: Nombre de copies détaillées. N_PIRES = 20 -RUNS = A.charger_runs() +# Un seul modèle sur cette page : le détail copie par copie (statistiques, +# transcription, deux codages en vis-à-vis) multiplié par chaque modèle donnerait +# une page illisible et lourde de plusieurs mégaoctets. La comparaison entre +# modèles vit sur la page « Résultats » ; ici on garde le modèle de référence. +RUNS = A.charger_runs(modeles=[A.MODELE_REFERENCE]) ITEMS, MOTS, RANGS = A.charger_grille() REF = A.RUN_REFERENCE if A.RUN_REFERENCE in RUNS else (next(iter(RUNS)) if RUNS else "") @@ -31,6 +35,9 @@ REF = A.RUN_REFERENCE if A.RUN_REFERENCE in RUNS else (next(iter(RUNS)) if RUNS # l'élève n'a rien écrit, ce qui ne dit rien de la qualité du codage. Le critère # est celui du pipeline (densité d'encre), relu depuis le marqueur `blank` du # JSONL. Elles sont récapitulées dans leur propre section, plus bas. +# Page mono-modèle : le libellé de run porte le modèle, redondant ici. On affiche +# donc la seule approche, tout en gardant les libellés complets comme clés. +APPROCHE = {label: r.approche for label, r in RUNS.items()} BRUTS = {label: A.classement_ecarts(r.df) for label, r in RUNS.items()} VIERGES, CLASSEMENTS = {}, {} for label, classement in BRUTS.items(): @@ -83,6 +90,16 @@ if not RUNS: print("Vérifier `S3_PREDICTIONS_PREFIX` et l'export des prédictions.") print(":::") print(A.bloc_notes()) +else: + # Le modèle fait partie du nom du run exporté : on l'affiche, sinon rien ne + # dit sur quelles prédictions portent les écarts détaillés plus bas. + detail = " ; ".join(f"**{r.approche}** ({A.milliers(r.n_copies)} copies)" for r in RUNS.values()) + modele = next(iter(RUNS.values())).modele + print('::: {.callout-note appearance="simple"}') + print(f"Cette page porte sur le **modèle `{modele}`** — {detail}.") + print("La comparaison entre modèles est sur la page") + print("[Résultats](resultats.qmd#comparaison-des-modeles-a-corpus-commun).") + print(":::") ``` ## Comment les copies sont classées @@ -150,7 +167,7 @@ if RUNS and MARQUEUR_OK: for label, vierges in VIERGES.items(): lignes.append( [ - f"Approche {label}", + f"Approche {APPROCHE[label]}", A.milliers(len(vierges)), A.pts(len(vierges) / len(BRUTS[label]) * 100, 2), A.pts(vierges["pct_desaccord"].mean()) if not vierges.empty else "—", @@ -182,13 +199,14 @@ if RUNS and MARQUEUR_OK: if ENCRE is None: print('::: {.callout-note appearance="simple"}') print( - "La distribution des densités d'encre n'est pas disponible : ni les " - "prédictions ni le corpus mesuré ne la portent. La produire en quelques " - "minutes, sans appel modèle ni GPU :\n" + "La distribution des densités d'encre n'est pas disponible : les runs " + "exportés ne portent pas la colonne `ink_ratio`, que le benchmark écrit " + "pourtant pour chaque copie. Ils sont donc antérieurs à cette mesure — " + "relancer le benchmark, puis réexporter :\n" ) print("```bash") - print("uv run scripts/compute_ink_ratios.py \\") - print(" --config configs/scoring/dictee_end2end.yaml --export") + print("uv run scripts/run_benchmark.py --config configs/scoring/dictee_end2end.yaml") + print("uv run scripts/export_predictions.py --config configs/scoring/dictee_end2end.yaml") print("```") print(":::") else: @@ -347,7 +365,7 @@ Deux conséquences pratiques : if RUNS and MARQUEUR_OK and any(not v.empty for v in VIERGES.values()): print("::: {.panel-tabset}") for label, vierges in VIERGES.items(): - print(f"\n## Approche {label}\n") + print(f"\n## Approche {APPROCHE[label]}\n") if vierges.empty: print("*Aucune copie vierge détectée dans ce run.*\n") continue @@ -368,7 +386,7 @@ if RUNS and MARQUEUR_OK and any(not v.empty for v in VIERGES.values()): "Erreurs vues par l'expert", ], lignes, - f"Copies vierges — approche {label}", + f"Copies vierges — approche {APPROCHE[label]}", ) ) print("\n:::") @@ -392,7 +410,7 @@ if RUNS: pires = classement.head(N_PIRES) lignes.append( [ - f"Approche {label}", + f"Approche {APPROCHE[label]}", A.milliers(len(classement)), A.pts(classement["pct_desaccord"].mean()), A.pts(classement["pct_desaccord"].median()), @@ -416,7 +434,7 @@ if RUNS: part = premier.head(N_PIRES)["n_desaccords"].sum() / premier["n_desaccords"].sum() * 100 print( f"\n**Lecture** : les {N_PIRES} pires copies ne portent que " - f"{A.pts(part)} des désaccords totaux de l'approche {REF}. Les écarts ne " + f"{A.pts(part)} des désaccords totaux de l'approche {APPROCHE.get(REF, REF)}. Les écarts ne " "sont donc **pas concentrés sur une poignée de copies aberrantes** : ils " "sont diffus sur tout le corpus. Corriger ces cas extrêmes ne réglerait " "pas le problème de fond — mais ils restent le meilleur endroit pour " @@ -436,7 +454,7 @@ if RUNS: n_ex_aequo = int((classement["pct_desaccord"] >= maxi - 1e-9).sum()) if n_ex_aequo > N_PIRES: alertes.append( - f"**Approche {label}** — {n_ex_aequo} copies sont à égalité au " + f"**Approche {APPROCHE[label]}** — {n_ex_aequo} copies sont à égalité au " f"désaccord maximal ({A.pts(maxi)}) : les {N_PIRES} retenues sont " "les premières par identifiant, un choix arbitraire parmi elles." ) @@ -458,7 +476,7 @@ if RUNS: couleur = RUNS[label].couleur ax1.hist( classement["pct_desaccord"], bins=40, color=couleur, alpha=0.55, - edgecolor="white", linewidth=0.3, label=f"Approche {label}", + edgecolor="white", linewidth=0.3, label=f"Approche {APPROCHE[label]}", ) seuil = classement.head(N_PIRES)["pct_desaccord"].min() ax1.axvspan(seuil, 100, color=couleur, alpha=0.10) @@ -472,7 +490,7 @@ if RUNS: for label, classement in CLASSEMENTS.items(): cumul = classement["n_desaccords"].cumsum() / classement["n_desaccords"].sum() * 100 part_copies = np.arange(1, len(classement) + 1) / len(classement) * 100 - ax2.plot(part_copies, cumul, color=RUNS[label].couleur, lw=2.2, label=f"Approche {label}") + ax2.plot(part_copies, cumul, color=RUNS[label].couleur, lw=2.2, label=f"Approche {APPROCHE[label]}") ax2.plot([0, 100], [0, 100], color="grey", ls="--", lw=1, label="répartition uniforme") ax2.set_xlabel("% de copies, des plus divergentes aux plus fidèles") ax2.set_ylabel("% cumulé des désaccords") @@ -490,7 +508,7 @@ if RUNS: if RUNS: print("::: {.panel-tabset}") for label, classement in CLASSEMENTS.items(): - print(f"\n## Approche {label}\n") + print(f"\n## Approche {APPROCHE[label]}\n") lignes = [] for rang, (copy_id, ligne) in enumerate(classement.head(N_PIRES).iterrows(), start=1): lignes.append( @@ -513,7 +531,7 @@ if RUNS: "Items incodables (expert)", ], lignes, - f"Les {N_PIRES} copies les plus divergentes — approche {label}", + f"Les {N_PIRES} copies les plus divergentes — approche {APPROCHE[label]}", ) ) print("\n:::") @@ -530,7 +548,7 @@ if len(RUNS) > 1 and REF: for label in autres } detail = " ; ".join( - f"**{n} copies sur {N_PIRES}** en commun avec l'approche {label}" + f"**{n} copies sur {N_PIRES}** en commun avec l'approche {APPROCHE[label]}" for label, n in communs.items() ) print( @@ -609,7 +627,7 @@ if RUNS and ITEMS: manquantes = {label: n for label, n in absentes.items() if n} if manquantes: detail = " ; ".join( - f"**{n} des {N_PIRES}** pour l'approche {label}" for label, n in manquantes.items() + f"**{n} des {N_PIRES}** pour l'approche {APPROCHE[label]}" for label, n in manquantes.items() ) print('::: {.callout-warning appearance="simple"}') print( @@ -650,7 +668,7 @@ if RUNS and ITEMS and REF: if sub.empty: # Absence explicite : ce run n'a produit aucun codage pour la copie. lignes_stats.append( - [f"Approche {label}", *["—"] * 4, "*copie écartée par ce run*"] + [f"Approche {APPROCHE[label]}", *["—"] * 4, "*copie écartée par ce run*"] ) continue sub = sub.set_index("item_id") @@ -666,7 +684,7 @@ if RUNS and ITEMS and REF: blocs[label] = (codes_modele, lectures, texte, provenance) lignes_stats.append( [ - f"Approche {label}", + f"Approche {APPROCHE[label]}", A.pct((len(sub) - n_dis) / len(sub)), f"{n_dis} / {len(sub)}", str(int((sub["y_true"] != "1").sum())), @@ -687,7 +705,7 @@ if RUNS and ITEMS and REF: # ── Transcription lue par le modèle ────────────────────────────────── for label, (_, _, texte, provenance) in blocs.items(): print( - f"\n**Transcription par le modèle — approche {label}** " + f"\n**Transcription par le modèle — approche {APPROCHE[label]}** " f"({provenance})\n" ) if texte: @@ -701,7 +719,7 @@ if RUNS and ITEMS and REF: print(A.codage_expert_html(ITEMS, codes_expert, codes_ref_modele)) for label, (codes_modele, lectures, _, _) in blocs.items(): print( - f"\n**Codage par le modèle — approche {label}** " + f"\n**Codage par le modèle — approche {APPROCHE[label]}** " "(vert = accord avec l'expert, rouge = désaccord)\n" ) print(A.codage_modele_html(ITEMS, codes_expert, codes_modele, lectures)) diff --git a/website/evaluation.qmd b/website/evaluation.qmd index c2ad448..73f90fc 100644 --- a/website/evaluation.qmd +++ b/website/evaluation.qmd @@ -1,43 +1,87 @@ --- -title: "Évaluation de la prédiction" -subtitle: "Comment assurer la fiabilité de la prédiction ?" +title: "Évaluation & métriques" +subtitle: "Ce qu'on mesure, comment on le calcule, comment lire les intervalles" --- +Cette page définit **toutes** les métriques employées dans le projet et la façon +dont elles sont calculées. Les valeurs mesurées, elles, sont sur la page +[Résultats](resultats.qmd), qui renvoie ici section par section. + Évaluer, ici, ce n'est pas seulement « le modèle a-t-il raison ». C'est répondre à trois questions distinctes : 1. **Le code prédit est-il fiable ?** (le modèle attribue-t-il les bons codes ?) 2. **La lecture est-elle fidèle ?** (le modèle lit-il correctement l'écriture ?) -3. **La indicateur de confiance est-il exploitable ?** (peut-on se fier au score de confiance - pour trier ce qu'on automatise et ce qu'on renvoie à un humain ?) +3. **L'indicateur de confiance est-il exploitable ?** (peut-on se fier au score de + confiance pour trier ce qu'on automatise et ce qu'on renvoie à un humain ?) -Pour chaque question, des métriques différentes seront utilisées. +À chaque question correspond une famille de métriques, plus une question +transversale, comment entourer ces chiffres d'un **intervalle de confiance** +honnête, sachant que les 83 items d'une copie ne sont pas indépendants. --- -## 1. Fiabilité du code prédit +## 1. Fiabilité du code prédit {#codage} On compare, item par item, le code du modèle au code de l'**expert humain**, qui sert de **vérité de terrain**. -### Accord brut +### Notations et convention d'erreur {#convention-erreur} + +Pour un item donné, notons $y$ le code de l'expert et $\hat{y}$ celui du modèle. +Les codes de la grille simplifiée sont `1` (mot correct), `9` (faute +d'orthographe) et `0` (mot absent). + +Deux lectures des mêmes données coexistent, et les confondre est la principale +source de malentendu : + +- la lecture **multi-classes** compare les codes tels quels : c'est celle de + l'accord brut, du kappa et de la matrice de confusion ; +- la lecture **binaire** ne retient que « le mot est-il fauté ? », par la + convention **erreur $\Leftrightarrow$ code $\neq$ `1`** : c'est celle du + rappel, de la précision, de la sur-correction et de la sur-détection. + +La lecture binaire se résume à quatre effectifs : + +| | Modèle : erreur ($\hat{y} \neq 1$) | Modèle : correct ($\hat{y} = 1$) | +|---|---|---| +| **Expert : erreur** ($y \neq 1$) | $\text{VP}$ — faute détectée | $\text{FN}$ — **sur-correction** | +| **Expert : correct** ($y = 1$) | $\text{FP}$ — **sur-détection** | $\text{VN}$ — accord sur un mot juste | + +: Les quatre cas de la lecture binaire {.striped} -La proportion d'items où le modèle et l'expert donnent le **même** code. +Les deux erreurs de la diagonale montante portent un nom dans le projet parce +qu'elles n'ont pas le même coût : $\text{FN}$ laisse passer une faute, +$\text{FP}$ sanctionne un élève à tort. -$$\text{accord brut} = \frac{\text{nombre d'items identiques}}{\text{nombre total d'items}}$$ +### Accord brut {#accord-brut} -Métrique simple à comprendre, mais **peu fiable** lorsqu'une catégorie domine. Si 90 % des mots -sont corrects, un modèle qui répond « correct » partout obtient 90 % d'accord sans -rien comprendre. +La proportion d'items où le modèle et l'expert donnent le **même** code, sur +l'ensemble des codes de la grille (lecture multi-classes) : -### Kappa de Cohen +$$\text{accord brut} = \frac{\text{nombre d'items où } \hat{y} = y}{\text{nombre total d'items}}$$ + +Métrique simple à comprendre, mais **peu fiable** lorsqu'une catégorie domine. Si +90 % des mots sont corrects, un modèle qui répond « correct » partout obtient +90 % d'accord sans rien comprendre. *Plus haut = mieux.* + +### Kappa de Cohen {#kappa} Le **kappa** ($\kappa$) corrige l'accord brut de ce qu'on obtiendrait **par pur -hasard**. C'est la métrique de référence du projet. +hasard**, en tenant compte des fréquences marginales des codes. C'est la métrique +de référence du projet. $$\kappa = \frac{p_o - p_e}{1 - p_e}$$ -où $p_o$ est l'accord observé et $p_e$ l'accord attendu par hasard. +où $p_o$ est l'accord observé (l'accord brut ci-dessus) et $p_e$ l'accord attendu +si le modèle et l'expert tiraient leurs codes au hasard en respectant chacun sa +propre distribution : + +$$p_e = \sum_{c} p_{\cdot c} \, p_{c \cdot}$$ + +$p_{\cdot c}$ et $p_{c \cdot}$ étant les fréquences du code $c$ chez le modèle et +chez l'expert. Un $\kappa$ nul signifie « pas mieux que le hasard », 1 un accord +parfait. *Plus haut = mieux.* | Valeur de κ | Interprétation courante | |:-----------:|-------------------------| @@ -47,57 +91,124 @@ où $p_o$ est l'accord observé et $p_e$ l'accord attendu par hasard. | 0,60 – 0,80 | accord substantiel | | > 0,80 | accord presque parfait | -::: {.callout-tip appearance="simple"} -**Repère du projet.** Sur le test pilote, la grille simplifiée atteint κ ≈ 0,88 — -un accord presque parfait. C'est le niveau à retrouver (ou dépasser) à grande échelle. +Le kappa a une particularité qui a des conséquences sur son intervalle de +confiance : ce n'est pas la moyenne d'une variable binaire, mais un **rapport** +entre deux quantités elles-mêmes estimées. D'où le +[bootstrap par grappes](#bootstrap-kappa). + +### Rappel des erreurs (sensibilité) {#rappel} + +Parmi les fautes que l'élève a réellement commises, la part que le modèle +retrouve : + +$$\text{rappel} = \frac{\text{VP}}{\text{VP} + \text{FN}}$$ + +*Plus haut = moins de fautes ratées.* + +### Précision sur les erreurs {#precision} + +Parmi les fautes que le modèle signale, la part qui en est vraiment : + +$$\text{précision} = \frac{\text{VP}}{\text{VP} + \text{FP}}$$ + +*Plus haut = moins de fausses alertes.* + +### Taux de sur-correction {#sur-correction} + +La part des fautes de l'élève que le modèle **valide à tort** : il « corrige » +silencieusement la copie, et la faute qu'on cherchait à détecter disparaît. + +$$\text{sur-correction} = \frac{\text{FN}}{\text{VP} + \text{FN}} = 1 - \text{rappel}$$ + +*Plus bas = mieux.* + +::: {.callout-warning appearance="simple"} +Ce taux est, par construction, le complément exact du rappel : les deux disent la +même chose dans deux sens. Il est affiché parce qu'il nomme le risque +opérationnel, pas parce qu'il apporte une information de plus. + +Attention également : la fonction `scoring_summary` du paquet calcule un +`overcorrection_rate` rapporté à **tous** les items, et non aux seules erreurs de +l'expert. Les deux définitions sont légitimes, mais elles ne donnent pas le même +nombre — celle du site est celle indiquée ci-dessus. ::: -### Matrice de confusion +### Taux de sur-détection {#sur-detection} + +La part des mots corrects que le modèle **sanctionne à tort** : -Un tableau qui croise les codes de l'expert (lignes) et ceux du modèle (colonnes). -La diagonale, ce sont les accords ; **hors diagonale**, ce sont les erreurs, et -surtout **leur nature**. +$$\text{sur-détection} = \frac{\text{FP}}{\text{FP} + \text{VN}}$$ -Elle répond à des questions comme : « le modèle confond-il -plutôt *correct* et *erreur*, ou *erreur* et *absent* ? ». +*Plus bas = mieux.* C'est le risque symétrique du précédent, et celui qui pèse le +plus lourd sur l'acceptabilité d'un codage automatique. -### F1 pondéré +### F1 pondéré {#f1} -Une synthèse de la **précision** (quand le modèle dit « erreur », a-t-il raison ?) -et du **rappel** (parmi les vraies erreurs, combien le modèle en attrape-t-il ?). -Le « pondéré » tient compte du fait que les catégories sont déséquilibrées. +Une synthèse de la précision et du rappel, par leur moyenne harmonique : + +$$F_1 = 2 \times \frac{\text{précision} \times \text{rappel}}{\text{précision} + \text{rappel}}$$ + +Le « pondéré » calcule ce $F_1$ pour chaque code puis en fait la moyenne pondérée +par l'effectif de chaque code, ce qui tient compte du déséquilibre des catégories. ::: {.callout-note appearance="simple"} **Les deux erreurs n'ont pas le même coût.** Rater une faute (mauvais **rappel**) -et signaler une faute qui n'existe pas (mauvaise **précision**) n'ont pas les mêmes -conséquences pour la DEPP. On regarde donc les deux séparément, pas seulement leur -moyenne. +et signaler une faute qui n'existe pas (mauvaise **précision**) n'ont pas les +mêmes conséquences pour la DEPP. On regarde donc les deux séparément, et le $F_1$ +ne sert que de résumé : une moyenne masque exactement l'arbitrage qui intéresse +le commanditaire. ::: +### Matrice de confusion {#matrice-de-confusion} + +Un tableau qui croise les codes de l'expert (lignes) et ceux du modèle +(colonnes). La diagonale, ce sont les accords ; **hors diagonale**, les erreurs +et surtout **leur nature**. Elle répond à des questions comme : « le modèle +confond-il plutôt *correct* et *erreur*, ou *erreur* et *absent* ? ». + +La version détaillée, une ligne par transition +`code expert → code modèle`, est la +[décomposition des désaccords](resultats.qmd#decomposition-des-desaccords) de la +page Résultats. + --- -## 2. Fidélité de la lecture (HTR) +## 2. Fidélité de la lecture (HTR) {#htr} Ici on compare la **transcription** produite par le modèle à une transcription de **référence humaine**. Les deux préservent les fautes de l'élève : on mesure la **lecture**, pas l'orthographe. -### CER — taux d'erreur au niveau caractère +### CER — taux d'erreur au niveau caractère {#cer} *Character Error Rate.* Le nombre minimal de corrections (insérer, supprimer, -remplacer un caractère) pour transformer la transcription du modèle en la référence, -divisé par la longueur de la référence. +remplacer un caractère) pour transformer la transcription du modèle en la +référence — la **distance de Levenshtein** — divisé par la longueur de la +référence : + +$$\text{CER} = \frac{i + s + d}{N_{\text{car}}}$$ + +où $i$, $s$ et $d$ sont les nombres d'insertions, substitutions et suppressions +de caractères, et $N_{\text{car}}$ le nombre de caractères de la référence. -$$\text{CER} = \frac{\text{insertions} + \text{suppressions} + \text{substitutions (caractères)}}{\text{nombre de caractères de la référence}}$$ +**0 = transcription parfaite.** 0,10 signifie « environ un caractère sur dix à +corriger ». La métrique peut dépasser 1 si le modèle produit beaucoup plus de +texte que la référence. -**0 = transcription parfaite.** 0,10 signifie « environ un caractère sur dix à corriger ». +### WER — taux d'erreur au niveau mot {#wer} -### WER — taux d'erreur au niveau mot +*Word Error Rate.* La même distance d'édition, comptée en **mots** entiers : -*Word Error Rate.* La même idée, mais en comptant les **mots** entiers. Plus sévère -que le CER : un seul caractère faux rend tout le mot faux. +$$\text{WER} = \frac{i_{\text{mots}} + s_{\text{mots}} + d_{\text{mots}}}{N_{\text{mots}}}$$ -### Variantes normalisées +Plus sévère que le CER : un seul caractère faux rend tout le mot faux. + +Les deux taux sont agrégés en **micro-moyenne** sur le corpus, c'est-à-dire +pondérés par la longueur de chaque référence — une copie longue pèse plus qu'une +copie courte. C'est le taux d'erreur du corpus vu comme un seul long texte, et +non la moyenne des taux par copie. + +### Variantes normalisées {#normalisees} On calcule aussi CER et WER après **normalisation** (minuscules, sans accents ni ponctuation). Comparer la version brute et la version normalisée permet de @@ -105,7 +216,7 @@ ponctuation). Comparer la version brute et la version normalisée permet de d'accent — particulièrement utile ici, car la binarisation des scans rend les accents difficiles à lire. -### Taux de sur-correction +### Sur-correction de lecture {#sur-correction-lecture} ::: {.callout-important appearance="simple"} **La métrique critique pour l'écriture d'enfants.** La sur-correction, c'est quand @@ -115,58 +226,328 @@ on perd précisément la faute qu'on voulait détecter. Un bon transcripteur doi reproduire les fautes, pas les gommer. ::: +À ne pas confondre avec la [sur-correction du codage](#sur-correction) : la +première porte sur le texte transcrit, la seconde sur le code attribué. Les deux +mesurent le même biais à deux étapes différentes du pipeline. + --- -## 3. Fiabilité de la confiance +## 3. Fiabilité de la confiance et calibration {#calibration} Chaque code prédit s'accompagne d'un **score de confiance**. L'objectif final du projet est d'**automatiser les items sûrs** et de **renvoyer les items douteux** à un correcteur humain. Pour cela, encore faut-il que la confiance soit *fiable*. -### Diagramme de fiabilité +### Qu'est-ce qu'un score calibré ? {#definition-calibration} + +Un score de confiance est **calibré** si sa valeur se lit comme une probabilité +d'avoir raison : parmi tous les items annoncés à 80 % de confiance, environ 80 % +doivent effectivement être justes. + +$$\mathbb{P}\left(\hat{y} = y \;\middle|\; \text{confiance} = c\right) = c \quad \text{pour tout } c$$ + +Deux propriétés distinctes, souvent confondues, et qui ne servent pas au même +usage : -On regroupe les items par tranche de confiance (0–10 %, 10–20 %, …) et, dans chaque -tranche, on compare la **confiance annoncée** à l'**accord réellement observé**. +- la **calibration** — le niveau du score est juste. C'est elle qui permet de + traduire un seuil en promesse chiffrée : « au-dessus de 0,9, je garantis moins + de 10 % d'erreur » ; +- la **discrimination** (ou pouvoir de tri) — le score **classe** correctement les + items, les faux ayant des scores plus bas que les justes, même si le niveau est + décalé. C'est elle, et elle seule, qui rend une + [courbe de renvoi](#courbe-de-renvoi) utile. -Un modèle **bien calibré** est sur la diagonale : quand il annonce 80 % de -confiance, il a effectivement raison ~80 % du temps. S'il est au-dessus de sa -performance réelle, il est **trop sûr de lui** (dangereux : on automatiserait des -erreurs). +Un score mal calibré mais bien discriminant reste exploitable : il suffit de le +**recalibrer**. Un score bien calibré en moyenne mais sans pouvoir de tri est +inutilisable pour décider item par item. -### ECE — erreur de calibration attendue +### Diagramme de fiabilité {#diagramme-de-fiabilite} -*Expected Calibration Error.* Un unique chiffre qui résume le diagramme : l'écart -moyen (pondéré par le nombre d'items) entre confiance annoncée et accord observé. +C'est la vérification graphique de la calibration. On découpe l'intervalle +$[0, 1]$ en $B = 10$ tranches de largeur égale, on y range les items selon leur +confiance annoncée, et dans chaque tranche $b$ on compare deux quantités : + +- la **confiance moyenne annoncée** $\overline{c}_b$ ; +- l'**accord réellement observé** $\text{acc}_b$, la part d'items justes de la + tranche. + +Un modèle bien calibré est sur la diagonale. Au-dessus de sa performance réelle, +il est **trop sûr de lui** (dangereux : on automatiserait des erreurs) ; en +dessous, il est trop modeste (on renverrait à l'humain des items qu'il traitait +bien). + +### ECE — erreur de calibration attendue {#ece} + +*Expected Calibration Error.* Le résumé du diagramme en un seul chiffre : l'écart +absolu moyen entre confiance annoncée et accord observé, pondéré par l'effectif +de chaque tranche. + +$$\text{ECE} = \sum_{b=1}^{B} \frac{n_b}{N} \left| \text{acc}_b - \overline{c}_b \right|$$ + +où $n_b$ est l'effectif de la tranche $b$ et $N$ le nombre total d'items munis +d'une confiance (ceux qui n'en ont pas sont exclus du calcul). - **0 = parfaitement calibré.** - **> ~0,10** : le score de confiance n'est pas fiable tel quel et doit être **recalibré** avant de servir de seuil de décision. -### Courbe de renvoi humain — le livrable décisionnel +::: {.callout-warning appearance="simple"} +**L'ECE ne détecte pas tout.** C'est une moyenne d'écarts absolus : elle ne dit +rien du pouvoir de tri. Un modèle qui annonce **la même** confiance partout — +disons 1,0 — obtient un ECE égal à son taux d'erreur, sans qu'aucune information +n'ait été mesurée : le score est constant, donc inutile pour trier, et pourtant +l'ECE paraît « seulement » médiocre. C'est exactement ce qui se produit sur nos +runs (voir la [page Résultats](resultats.qmd#synthese-codage-de-la-dictee)), et +c'est la raison pour laquelle le projet construit un autre signal de confiance. +::: + +### Recalibrer un score {#recalibration} + +Quand la discrimination est là mais le niveau faux, deux méthodes usuelles : + +- le **passage à l'échelle par température** (*temperature scaling*) : on divise + les logits du modèle par un scalaire $T$ ajusté sur un échantillon de + validation. Une seule constante à estimer, l'ordre des items est préservé + intégralement ; +- la **régression isotonique** : on ajuste une fonction monotone quelconque du + score annoncé vers la probabilité observée. Plus souple, donc plus gourmande en + données de validation. + +Dans les deux cas, la recalibration s'ajuste sur des données **distinctes** de +celles où l'on mesure ensuite l'ECE, sinon la calibration mesurée est optimiste. +Aucune des deux ne peut rattraper un score constant : il n'y a rien à réordonner. + +### Confiance par consensus inter-modèles {#consensus} + +Le signal de confiance retenu par le projet ne vient pas du modèle lui-même mais +du **désaccord entre plusieurs runs**. Pour chaque item, on compare les codes +prédits par tous les runs disponibles ; le **code majoritaire** l'emporte et l'on +retient le nombre de runs qui s'accordent. Au niveau de la copie : -C'est **la** sortie qui guide la décision opérationnelle. Pour chaque **seuil** de -confiance, on mesure deux quantités : +$$\text{score de confiance} = \frac{\text{nombre d'items où tous les runs s'accordent}}{\text{nombre d'items de la copie}}$$ -- le **taux de renvoi humain** : la part d'items renvoyés à un correcteur (ceux dont - la confiance est sous le seuil) ; -- le **taux d'erreur résiduel** : le taux d'erreur qui subsiste sur les items que - le modèle a **auto-validés** (au-dessus du seuil). +L'intuition est qu'un item sur lequel des architectures et des modèles +différents convergent est probablement bien codé, alors qu'un item qui les divise +est un candidat naturel à la relecture humaine. Contrairement à la confiance +auto-déclarée, ce score varie réellement d'un item à l'autre — il **discrimine**, +donc il est utilisable comme critère de tri. + +### Courbe de renvoi humain — le livrable décisionnel {#courbe-de-renvoi} + +C'est **la** sortie qui guide la décision opérationnelle. On trie les copies par +score de confiance et, pour chaque **seuil** $\tau$, on mesure : + +$$\text{taux de renvoi}(\tau) = \frac{\text{nombre de copies dont le score} < \tau}{\text{nombre total de copies}}$$ + +$$\text{accord retenu}(\tau) = \text{accord modèle-expert sur les items des copies conservées}$$ + +Le **taux d'erreur résiduel** est le complément de l'accord retenu : le taux +d'erreur qui subsiste sur ce qu'on a auto-validé. ```{mermaid} flowchart LR - A["Tous les items codés
+ confiance"] --> B{"confiance ≥ seuil ?"} + A["Toutes les copies codées
+ score de confiance"] --> B{"score ≥ τ ?"} B -->|oui| C["Auto-validé
(on mesure l'erreur résiduelle)"] B -->|non| D["Renvoyé à un humain"] ``` -Faire varier le seuil dessine un compromis : **plus on renvoie à l'humain, plus +Faire varier $\tau$ dessine un compromis : **plus on renvoie à l'humain, plus l'erreur résiduelle baisse**. La DEPP peut ainsi choisir un point de fonctionnement, par exemple : « je tolère 2 % d'erreur résiduelle — combien de copies dois-je alors relire à la main ? » --- -## 4. Toujours comparer à l'humain +## 4. Intervalles de confiance et structure en grappes {#intervalles} + +Toutes les métriques ci-dessus sont des estimations, donc assorties d'un +intervalle de confiance à 95 %. Le calculer naïvement donnerait ici des +intervalles **faux**, et cette section explique pourquoi. + +### Le problème {#probleme-grappes} + +Un intervalle de confiance se resserre à mesure qu'on accumule des observations +**indépendantes**. Or les 83 items d'une même copie ne le sont pas : si un élève +se trompe sur un mot, il a de bonnes chances de se tromper sur le suivant. Son +niveau est commun à ses 83 items. + +Conséquence : les ≈ 290 000 items du corpus ne portent pas l'information de +290 000 observations indépendantes. Traiter chaque item comme indépendant +produirait des intervalles trop étroits : on annoncerait une précision qu'on n'a +pas. + +C'est ce qu'on appelle une **structure en grappes** (*clustering*) : les +observations arrivent par paquets — ici la copie — au sein desquels elles se +ressemblent. + +### Intervalle de Wilson {#wilson} + +Pour une proportion $\hat{p}$ observée sur $n$ observations, le projet utilise +l'intervalle de **Wilson** plutôt que l'approximation normale usuelle +$\hat{p} \pm z\sqrt{\hat{p}(1-\hat{p})/n}$, qui se comporte mal quand $\hat{p}$ +approche 0 ou 1 — le cas de plusieurs de nos taux : + +$$\text{IC}_{95\%} = \frac{\hat{p} + \dfrac{z^2}{2n}}{1 + \dfrac{z^2}{n}} \; \pm \; \frac{z}{1 + \dfrac{z^2}{n}} \sqrt{\dfrac{\hat{p}(1-\hat{p})}{n} + \dfrac{z^2}{4n^2}}$$ + +avec $z = 1{,}96$. L'intervalle reste toujours dans $[0, 1]$, et la valeur +ponctuelle affichée demeure $\hat{p}$ — seul l'intervalle est corrigé. + +### Design effect de Kish {#design-effect} + +Le **design effect** chiffre la perte d'information due au regroupement : + +$$\text{deff} = 1 + (m - 1) \times \text{ICC}$$ + +où $m$ est le nombre d'observations par copie et l'**ICC** (*intra-class +correlation*) mesure à quel point les items d'une même copie se ressemblent. + +L'ICC se lit sur une décomposition de la variance de l'indicatrice en deux parts : + +- la variance **entre copies** est celle des moyennes par copie. Pour + l'indicatrice d'erreur, c'est l'écart de niveau entre élèves : une copie à 40 + fautes et une copie à 3 fautes. Elle est d'autant plus grande que les copies + diffèrent les unes des autres ; +- la variance **à l'intérieur d'une copie** est celle qui reste une fois le + niveau de l'élève retiré : sur une même copie, certains items sont ratés et + d'autres réussis. + +$$\text{ICC} = \frac{\text{variance entre copies}}{\text{variance entre copies} + \text{variance à l'intérieur d'une copie}}$$ + +Un ICC nul signifie que les copies se valent toutes et que connaître un item +n'apprend rien sur les autres items de la même copie : ils sont de fait +indépendants. Un ICC de 1 signifie que tous les items d'une copie disent la même +chose, donc qu'une copie entière ne vaut qu'une seule observation. + +On divise alors l'effectif par le design effect pour obtenir l'**effectif +équivalent** : + +$$n_{\text{eq}} = \frac{n}{\text{deff}}$$ + +c'est-à-dire le nombre d'observations indépendantes qui porteraient la même +information. C'est ce $n_{\text{eq}}$ qui remplace $n$ dans la formule de Wilson +ci-dessus, ce qui élargit l'intervalle d'un facteur $\sqrt{\text{deff}}$. + +### Un design effect par métrique, pas un pour tout le corpus {#deff-par-metrique} + +Le design effect appartient à une **indicatrice** précise, pas à un jeu de +données, parce que les deux termes de la formule changent d'une métrique à +l'autre : + +- ***m* change** : toutes les métriques ne sont pas calculées sur les 83 items de + la copie. Le rappel ne porte que sur les items que l'expert a codés en erreur, + soit une vingtaine par copie ; +- **l'ICC change** : l'erreur se regroupe beaucoup plus par copie que l'accord. + Un élève faible se trompe partout, alors qu'un modèle peut être d'accord avec + l'expert sur une copie faible comme sur une copie forte. + +```{python} +#| echo: false +#| warning: false +# Illustration mesurée sur les données réelles : un seul run suffit, on ne charge +# donc pas les quatre du tableau de résultats (≈ 290 000 lignes chacun). +import sys + +sys.path.insert(0, ".") + +import _analyse as A + +RUNS_DEFF = A.charger_runs(modeles=[A.MODELE_REFERENCE]) if A.PAQUET_OK else {} +REF_DEFF = ( + A.RUN_REFERENCE + if A.RUN_REFERENCE in RUNS_DEFF + else (next(iter(RUNS_DEFF)) if RUNS_DEFF else "") +) +``` + +```{python} +#| echo: false +#| output: asis +# Valeurs recalculées à chaque rendu : elles dépendent du run, et un tableau figé +# se périmerait au premier nouveau modèle. L'ICC se déduit du deff par +# ICC = (deff - 1) / (m - 1), inversion exacte de la formule de Kish. +if REF_DEFF: + d = RUNS_DEFF[REF_DEFF].df + grappes = d["copy_id"] + err_exp, err_mod = d["y_true"] != "1", d["y_pred"] != "1" + cas = [ + ("Accord", d["y_true"] == d["y_pred"], grappes, "tous les items"), + ("Erreur détectée", err_mod, grappes, "tous les items"), + ("Rappel", err_mod[err_exp], grappes[err_exp], "items en erreur chez l'expert"), + ] + lignes_deff = [] + for nom, ind, gr, champ in cas: + m = ind.groupby(gr).size().mean() + deff_cas = A.design_effect(ind, gr) + lignes_deff.append( + [ + f"{nom} ({champ})", + f"{m:.0f}", + f"{(deff_cas - 1) / (m - 1):.3f}", + f"{deff_cas:.1f}", + f"{A.milliers(len(ind))} → {A.milliers(round(len(ind) / deff_cas))}", + ] + ) + print( + A.table_markdown( + ["Indicatrice", "*m*", "ICC", "deff", "Effectif → équivalent"], + lignes_deff, + f"Trois indicatrices, trois design effects — run **{REF_DEFF}**", + ) + ) +else: + print('::: {.callout-note appearance="simple"}') + print("Illustration chiffrée indisponible : aucun run de prédictions n'a pu être") + print("chargé (voir la [page Résultats](resultats.qmd) pour le détail).") + print(":::") +``` + +Le rappel illustre bien que les deux termes jouent en sens contraire : son ICC est +le plus élevé des trois, mais comme il ne dispose que d'une vingtaine +d'observations par copie au lieu de 83, son design effect est le plus faible. + +Il n'existe donc pas un design effect « du corpus ». Chaque métrique est corrigée +par **le sien**, et le facteur varie aussi d'un run à l'autre : sur l'accord brut, +il va de 5 à 12 selon l'approche et le modèle. + +### Le cas du kappa : bootstrap par grappes {#bootstrap-kappa} + +Le [kappa](#kappa) n'est pas la moyenne d'une indicatrice binaire mais un +**rapport** entre deux quantités elles-mêmes estimées : il n'existe aucune +indicatrice dont on pourrait calculer l'ICC pour alimenter la formule de Kish. +Emprunter le design effect de l'accord, qui est la proportion la plus proche, +élargit son intervalle d'environ 50 % de trop. + +Son intervalle vient donc d'un **bootstrap par grappes**, qui contourne le +problème au lieu de le modéliser : + +1. on tire au hasard autant de copies que le corpus en compte, **avec remise** — + une copie peut sortir deux fois, une autre pas du tout ; +2. on recalcule le kappa sur l'échantillon ainsi obtenu ; +3. on répète 1 000 fois, et l'intervalle est donné par les centiles 2,5 et 97,5 + de la distribution des kappas obtenus. + +Tirer des **copies entières** conserve intact le lien entre les items d'un même +élève, sans avoir à le résumer par un ICC : la dispersion observée d'un tirage à +l'autre intègre déjà la structure en grappes. C'est précisément ce que le design +effect cherche à approcher par une formule. + +Le procédé est peu coûteux : la matrice de confusion de chaque copie est calculée +une seule fois, un tirage se réduisant à sommer celles des copies tirées. + +### Où la correction ne s'applique pas {#sans-correction} + +À la [prévalence par item](resultats.qmd#prevalence-derreur-par-item) : chaque +item n'y est observé qu'une fois par copie, donc une fois par élève. Ces +observations sont indépendantes, leur design effect vaut 1, et l'intervalle de +Wilson non corrigé est le bon. + +La règle générale : la correction s'applique aux métriques **agrégées sur les 83 +items d'une copie**, pas à celles calculées à raison d'**une observation par +copie**. + +--- + +## 5. Toujours comparer à l'humain {#reference-humaine} Une dernière exigence transversale : la performance de l'IA se lit **relativement à la variabilité entre correcteurs humains**. Deux experts qui codent la même copie ne @@ -175,14 +556,35 @@ niveau d'accord *inter-humains* fait déjà « aussi bien qu'un humain ». On si systématiquement les métriques du modèle par rapport à ce plancher humain (estimé via des modèles d'accord inter-codeurs, type Dawid & Skene). ---- +Concrètement, un $\kappa$ de 0,60 ne se lit pas « 60 % du chemin vers la +perfection » mais « les deux tiers du chemin vers l'accord que deux humains +obtiennent entre eux ». -## Récapitulatif +--- -| Question | Métriques | Sens | -|----------|-----------|------| -| Le codage est-il fiable ? | Accord brut, **kappa**, matrice de confusion, F1 | plus haut = mieux | -| La lecture est-elle fidèle ? | **CER**, **WER** (+ normalisés), **sur-correction** | plus bas = mieux | -| La confiance est-elle exploitable ? | Diagramme de fiabilité, **ECE**, **courbe de renvoi** | ECE bas ; courbe = arbitrage | +## Récapitulatif {#recapitulatif} + +| Métrique | Ce qu'elle mesure | Sens | Correction d'IC | +|---|---|:--:|---| +| [Accord brut](#accord-brut) | part d'items codés à l'identique | plus haut | [design effect](#design-effect) | +| [Kappa de Cohen](#kappa) | accord corrigé du hasard | plus haut | [bootstrap](#bootstrap-kappa) | +| [Rappel des erreurs](#rappel) | fautes de l'élève retrouvées | plus haut | [design effect](#design-effect) | +| [Précision sur les erreurs](#precision) | fautes signalées qui en sont | plus haut | [design effect](#design-effect) | +| [Sur-correction](#sur-correction) | fautes validées à tort ($1 -$ rappel) | plus bas | [design effect](#design-effect) | +| [Sur-détection](#sur-detection) | mots corrects sanctionnés à tort | plus bas | [design effect](#design-effect) | +| [F1 pondéré](#f1) | résumé précision / rappel | plus haut | — | +| [CER](#cer) / [WER](#wer) | fidélité de la transcription | plus bas | — | +| [ECE](#ece) | écart confiance / justesse | plus bas | — | +| [Score de consensus](#consensus) | part d'items où les runs s'accordent | plus haut | — | +| [Courbe de renvoi](#courbe-de-renvoi) | charge humaine vs erreur résiduelle | arbitrage | [Wilson](#wilson) | +| [Prévalence par item](#sans-correction) | difficulté de chaque item | comparaison | aucune (deff = 1) | + +: Toutes les métriques du projet, leur sens de lecture et le traitement de leur intervalle {.striped .hover} + +| Question | Métriques | +|----------|-----------| +| Le codage est-il fiable ? | accord brut, **kappa**, matrice de confusion, rappel, précision, sur-correction, sur-détection, F1 | +| La lecture est-elle fidèle ? | **CER**, **WER** (+ normalisés), sur-correction de lecture | +| La confiance est-elle exploitable ? | diagramme de fiabilité, **ECE**, score de consensus, **courbe de renvoi** | : Les trois familles de métriques {.striped .hover} diff --git a/website/index.qmd b/website/index.qmd index 948f052..bd50dad 100644 --- a/website/index.qmd +++ b/website/index.qmd @@ -20,7 +20,7 @@ coûteuse (5 à 10 minutes par copie), et donc difficile à passer à l'échelle **La question du projet** : est-ce que l'IA peut être un nouvel outil permettant d'accélérer l'évaluation des copies et être cfomplémentaire à la correction manuelle faite par les professeurs ? -## La tâche, concrètement +## La tâche Chaque **item** (un mot ou un signe de ponctuation) écrit par l'élève reçoit un **code**. Dans un premier temps, la cible principale du projet est une **grille simplifiée** à trois modalités : @@ -73,9 +73,8 @@ Ces deux approches sont détaillées dans la page **[Résultats](resultats.qmd)* | Page | Ce que vous y trouverez | |------|-------------------------| | **[Architecture](architecture.qmd)** | Comment le projet est organisé : du scan à la métrique, brique par brique. | +| **[Évaluation & métriques](evaluation.qmd)** | Chaque métrique définie et sa formule : ce qu'elle mesure, son sens de lecture, et comment son intervalle de confiance est calculé. **À lire avant les résultats.** | | **[Résultats](resultats.qmd)** | Les deux approches en détail, les tableaux de comparaison et l'analyse complète du benchmark. | -| **[Écarts IA – humain](ecarts.qmd)** | Les 20 copies où le modèle et l'annotateur expert divergent le plus, décortiquées item par item. | -| **[Évaluation & métriques](evaluation.qmd)** | Chaque métrique expliquée : ce qu'elle mesure et pourquoi. | | **[Fine-tuning](finetuning.qmd)** | Spécialiser un modèle à l'écriture d'enfants. | ::: {.callout-important appearance="simple"} diff --git a/website/resultats.qmd b/website/resultats.qmd index c75b153..87cb77a 100644 --- a/website/resultats.qmd +++ b/website/resultats.qmd @@ -1,6 +1,6 @@ --- -title: "Résultats des deux approches" -subtitle: "Deux méthodes d'évaluation de copies, une seule grille de métriques" +title: "Résultats" +subtitle: "Deux approches, plusieurs modèles, une seule grille de métriques" --- ```{python} @@ -33,16 +33,17 @@ REF = A.RUN_REFERENCE if A.RUN_REFERENCE in RUNS else (next(iter(RUNS)) if RUNS #| echo: false #| output: asis if RUNS: - effectifs = " ; ".join( - f"**{label}** (`{r.nom}`) : {A.milliers(r.n_copies)} copies × " - f"{r.df['item_id'].nunique()} items" + effectifs = "\n".join( + f"- **{label}** — modèle `{r.modele}` — {A.milliers(r.n_copies)} copies × " + f"{r.df['item_id'].nunique()} items (`{r.nom}`)" for label, r in RUNS.items() ) print('::: {.callout-note appearance="simple"}') print("**Tous les chiffres et figures de cette page sont recalculés au rendu**") print("à partir des prédictions exportées (voir la commande d'export en bas de") - print("page). Les deux approches sont affichées côte à côte partout où c'est") - print(f"possible.\n\nRuns comparés — {effectifs}.") + print("page). Chaque **approche** est croisée avec chaque **modèle** : les") + print("colonnes et les courbes se lisent donc `approche · modèle`.") + print(f"\nRuns comparés :\n\n{effectifs}") note = A.note_codes_non_standard(RUNS) if note: print(f"\n{note}") @@ -54,7 +55,7 @@ else: print(":::") ``` -## Les deux approches en détail +## From image to text : les deux approches en détail Le projet compare deux architectures pour passer de l'image au code. Toutes deux respectent la même interface et sont donc évaluées par le même code de métriques : @@ -68,93 +69,55 @@ On sépare explicitement la **lecture** et le **jugement** : **exactement tel que l'élève l'a écrit**, fautes comprises. Il ne connaît ni le texte de référence ni le découpage en items : on mesure la **lecture pour elle-même**. -- **Étape 2 (codage)** : un modèle reçoit cette transcription **et** le texte de - référence, puis code chaque item. Il ne voit pas l'image. Ce peut être un modèle +- **Étape 2 (codage)** : un modèle reçoit cette transcription, le texte de + référence, la grille de notation et la liste des consigne sà appliquer, + puis code chaque item. Il ne voit pas l'image. Ce peut être un modèle de texte plus léger (champ `model_stage2`). -| Avantages | Limites | -|-----------|---------| -| La transcription intermédiaire peut être analysée (on voit ce que le modèle a lu). | Deux étapes = **deux sources d'erreur** qui se cumulent : l'erreur de transcription et d'évaluation | -| Les erreurs de **lecture** et de **jugement** sont découplées. | Deux appels au modèle = coût et latence plus élevés. | -| L'étape 2 peut utiliser un modèle texte moins cher au token. | | - ### Approche 2 — End-to-end (une seule passe) -Un unique modèle multimodal (*VLM*) reçoit l'image, le texte de référence et la -grille, et produit **directement** le code de chaque item. - - -| Avantages | Limites | -|-----------|---------| -| **Un seul appel** : plus simple, plus rapide, moins cher. | La lecture est « fondue » dans le codage : moins facile à diagnostiquer. | -| Le modèle voit l'image **et** la référence en même temps. | Risque de **sur-correction** (le modèle « corrige » silencieusement l'élève). | +Un unique modèle multimodal (*VLM*) reçoit l'image, le texte de référence, la +grille de notation de chaque item et la liste des consignes à appliquer, et produit **directement** le code de chaque item. ::: {.callout-note appearance="simple"} -C'est l'approche **par défaut** du projet (la méthode « C » : image + référence + -grille → codes). +C'est l'approche **par défaut** du projet (image + texte de référence + +grille + consignes → codes). ::: -## Synthèse — codage de la dictée {#synthese-codage-de-la-dictee} - -Comparaison des deux approches sur les mêmes copies, avec **intervalles de -confiance à 95 %** corrigés de la structure en grappes des données. Le paragraphe -suivant explique pourquoi cette correction est indispensable ici, et ce qu'elle -change. - -::: {.callout-note collapse="true" title="Pourquoi corriger les intervalles du « design effect de Kish » ?"} -### Le problème - -Un intervalle de confiance se resserre à mesure qu'on accumule des observations -**indépendantes**. Or les 83 items d'une même copie ne le sont pas : si un élève -se trompe sur un mot, il a de bonnes chances de se tromper sur le suivant. Son -niveau est commun à ses 83 items. - -Conséquence : les ≈ 290 000 items du corpus ne portent pas l'information de -290 000 observations indépendantes. Traiter chaque item comme indépendant -produirait des intervalles trop étroits — on annoncerait une précision qu'on n'a -pas. - -### La correction - -Le **design effect de Kish** chiffre cette perte d'information : +### Ce qui les distingue -$$\text{deff} = 1 + (m - 1)\times \text{ICC}$$ +Une ligne par critère de décision, à lire horizontalement. -où *m* est le nombre d'items par copie (83) et l'**ICC** (*intra-class -correlation*) la part de la variance qui vient des différences **entre** copies -plutôt que de la variabilité **à l'intérieur** d'une copie. Un ICC nul signifie -qu'une copie n'apprend rien sur elle-même et que les items sont de fait -indépendants ; un ICC de 1 signifie qu'une copie entière ne vaut qu'une seule -observation. +| | Approche *two-stage* | Approche *end-to-end* | +|---|---|---| +| **Appels au modèle** par copie | 2 (lire, puis coder) | 1 | +| **Coût et latence** | Plus élevés, mais l'étape 2 peut tourner sur un modèle texte moins cher au token | Plus faibles | +| **Ce que voit le modèle qui code** | La transcription et le texte de référence, pas l'image | L'image et le texte de référence | +| **Sources d'erreur** | Deux, qui se cumulent : une mauvaise lecture se propage au codage sans pouvoir être rattrapée | Une seule, mais indissociable du codage | +| **Diagnostic d'un désaccord** avec l'expert | Possible : la transcription dit si la faute vient de la lecture ou du jugement | Limité : on n'observe que le code final | +| **Risque propre** | La transcription perd l'information graphique (ratures, lettres ambiguës, accents douteux) | Sur-correction : le modèle code ce que l'élève *aurait dû* écrire | -On divise alors l'effectif par ce facteur pour obtenir l'**effectif effectif** — -le nombre d'observations indépendantes qui porterait autant d'information — et -c'est sur lui qu'on calcule l'intervalle. Celui-ci s'élargit d'un facteur √deff. +Séparer la lecture du jugement produit deux effets opposés, et c'est la même +décision de conception qui les cause : on gagne la possibilité de diagnostiquer +(la transcription est lisible), on perd la possibilité de rattraper (l'étape 2 ne +voit plus l'image, donc elle code une lecture erronée sans le savoir). Ce n'est +pas une contradiction mais un arbitrage que les chiffres tranchent : +[la synthèse](#synthese-codage-de-la-dictee) montre l'end-to-end devant sur les +deux modèles testés. -### Un design effect par métrique, pas un pour tout le corpus - -Point important, et facile à rater : le design effect appartient à une -**indicatrice** précise, pas à un jeu de données. Sur ce corpus, l'indicatrice -d'erreur donne ≈ 10, celle d'accord ≈ 7,5, celle du rappel ≈ 4. Chaque ligne du -tableau ci-dessous est donc corrigée par **son propre** design effect, affiché en -colonne. - -### Le cas du kappa - -Le kappa n'est la moyenne d'aucune indicatrice : lui appliquer le design effect -d'une proportion voisine le sur-corrige d'environ 50 %. Son intervalle vient donc -d'un **bootstrap par grappes** — on retire au hasard des copies entières, avec -remise, et on observe la dispersion du kappa. Cette méthode ne suppose rien sur la -structure de corrélation. Elle est ici exacte et rapide : la matrice de confusion -de chaque copie est calculée une fois, un tirage se réduisant à sommer celles des -copies tirées. +## Synthèse — codage de la dictée {#synthese-codage-de-la-dictee} -### Où la correction ne s'applique pas +Une colonne par couple **approche × modèle**. Le nom de chaque métrique renvoie à +sa définition et à sa formule sur la page +[Évaluation & métriques](evaluation.qmd), qui donne aussi le **sens de lecture** +(faut-il viser haut ou bas ?). -À la [prévalence par item](#prevalence-derreur-par-item) : chaque item n'y est -observé qu'une fois par copie, donc une fois par élève. Ces observations sont -indépendantes et leur design effect vaut 1. -::: +Les **intervalles de confiance à 95 %** entre crochets sont corrigés de la +**structure en grappes** des données : les observations ne sont pas 290 000 items +isolés mais 83 items × 3 469 copies, et les items d'une même copie se ressemblent +puisqu'ils ont été écrits par le même élève. Sans cette correction, ils seraient +deux à trois fois trop étroits — voir +[intervalles de confiance et structure en grappes](evaluation.qmd#intervalles). ```{python} #| echo: false @@ -174,19 +137,13 @@ if RUNS: ) for label in RUNS ] - # Design effect appliqué : identique aux deux approches à un cheveu près, - # on affiche celui du run de référence (« bootstrap » pour le kappa). - deff = syntheses[REF].loc[metrique, "deff"] if REF else float("nan") - correction = "bootstrap" if pd.isna(deff) else f"÷ {deff:.1f}" - lignes.append([metrique, *cellules, correction, ligne["lecture"]]) + lignes.append([A.lien_metrique(metrique), *cellules]) # ECE : calibration du score de confiance, non dérivable de la synthèse ci-dessus. from evaluation_dictee.evaluation.results_summary import scoring_summary eces = [A.num(scoring_summary(r.df, run=r.nom).ece) for r in RUNS.values()] - lignes.append( - ["ECE (calibration)", *eces, "—", "écart confiance/justesse : plus **bas** = mieux"] - ) + lignes.append([A.lien_metrique("ECE (calibration)"), *eces]) # Couverture : une approche qui écarte des copies n'est pas comparable à # effectif égal. Le two-stage abandonne celles qu'il ne sait pas transcrire. @@ -199,29 +156,15 @@ if RUNS: f"({A.pts(COUVERTURE.loc[label, 'pct_ecartees'], 2)})" for label in RUNS ], - "—", - "copies sans codage produit : plus **bas** = mieux", ] ) print( A.table_markdown( - [ - "Métrique", - *(f"Approche {label}" for label in RUNS), - "Effectif divisé par", - "Lecture", - ], + ["Métrique", *(label for label in RUNS)], lignes, - "Codage de la dictée : comparaison des deux approches, IC 95 % entre crochets", + "Codage de la dictée : une colonne par approche × modèle, IC 95 % entre crochets", ) ) - print( - "\nLa colonne « effectif divisé par » donne le design effect " - "appliqué à chaque métrique : un facteur de 7,5 signifie que les ≈ 290 000 " - "items pèsent autant que 39 000 observations indépendantes, et que " - "l'intervalle est √7,5 ≈ 2,7 fois plus large qu'un calcul naïf. " - "Voir l'encadré ci-dessus." - ) ``` ```{python} @@ -235,34 +178,168 @@ if RUNS and (COUVERTURE["n_ecartees"] > 0).any(): ) print('::: {.callout-warning appearance="simple"}') print( - f"**Les deux approches ne couvrent pas le même corpus** : {detail}. " - "L'approche two-stage abandonne les copies dont l'étape 1 ne produit " - "aucune transcription ; l'end-to-end code toujours quelque chose, y " - "compris sur des copies blanches (voir la page " - "[Écarts IA – humain](ecarts.qmd)). Les métriques ci-dessus sont donc " - "calculées sur des ensembles légèrement différents — l'écart est faible " - "en volume, mais il porte précisément sur les copies les plus difficiles." + f"**Les runs ne couvrent pas le même corpus** : {detail}. Trois causes " + "possibles : un run **encore en cours** (les copies restantes ne sont pas " + "encore codées), l'approche **two-stage** qui abandonne les copies dont " + "l'étape 1 ne produit aucune transcription, ou une copie en échec d'appel " + "API. Les colonnes ci-dessus portent donc sur des ensembles de copies " + "différents : elles ne sont **pas directement comparables entre elles**, " + "car les copies manquantes ne sont pas un sous-échantillon au hasard. " + "La [comparaison à corpus commun](#comparaison-des-modeles-a-corpus-commun) " + "ci-dessous est la lecture à privilégier tant que cet écart subsiste." ) print(":::") ``` ::: {.callout-important appearance="simple"} -**Ce que dit ce tableau.** Les deux approches se trompent différemment. -L'**end-to-end** est plus prudent : il valide beaucoup d'items et rate donc une -grande part des erreurs de l'élève (sur-correction élevée). Le **two-stage** -détecte davantage d'erreurs mais en invente aussi beaucoup (précision plus -faible, sur-détection élevée). Le kappa, qui corrige l'accord du hasard, reste -loin de la variabilité inter-codeurs humains : à ce -stade, **aucune des deux approches n'est utilisable sans relecture humaine**. +**Ce que dit ce tableau.** + +*Sur l'architecture*, les deux approches se trompent différemment. L'**end-to-end** +est plus prudent : il valide beaucoup d'items et rate donc une grande part des +erreurs de l'élève (sur-correction élevée). Le **two-stage** détecte davantage +d'erreurs mais en invente aussi beaucoup (précision plus faible, sur-détection +élevée). À modèle égal, l'end-to-end garde le meilleur kappa. + +*Sur le modèle*, `qwen3-6-35b-moe` devance `gemma4-26b-moe` sur les **deux** +architectures. Mais le kappa cache deux profils d'erreur opposés, et c'est ce qui +doit guider le choix : qwen déclenche très peu de fausses alertes (précision 85,9 %, +sur-détection 2,6 % en end-to-end) au prix d'un rappel plus faible — il laisse +passer plus de fautes ; gemma détecte davantage de fautes mais en signale plus à +tort. Sanctionner un élève à tort et laisser passer une faute n'ont pas le même +coût pour la DEPP : c'est cet arbitrage, et non le kappa seul, qui tranche. + +Dans tous les cas, le kappa reste loin de la variabilité inter-codeurs humains +(0,879 sur le test pilote) : à ce stade, **aucune configuration n'est utilisable +sans relecture humaine**. ::: ::: {.callout-note appearance="simple" collapse="true" title="Pourquoi l'ECE est peu informatif ici"} -Les modèles renvoient un score de confiance quasi constant à 1,0 : l'ECE se -réduit alors au taux d'erreur et ne mesure plus rien d'utile. C'est précisément -pourquoi le projet construit un score de confiance **par désaccord -inter-modèles** (voir plus bas), bien plus prédictif. +Les modèles renvoient un score de confiance quasi constant à 1,0 : l'[ECE](evaluation.qmd#ece) +se réduit alors au taux d'erreur et ne mesure plus rien d'utile — un score +constant ne **discrimine** rien, et aucune +[recalibration](evaluation.qmd#recalibration) n'y changerait quoi que ce soit. +C'est précisément pourquoi le projet construit un score de confiance +[par désaccord inter-modèles](evaluation.qmd#consensus), bien plus prédictif +(voir plus bas). ::: +## Comparaison des modèles à corpus commun {#comparaison-des-modeles-a-corpus-commun} + +**Question** : à approche fixée, quel modèle code le mieux ? Y répondre exige de +comparer les modèles sur **les mêmes copies**. Sinon l'écart mesuré mélange deux +choses : la qualité du modèle, et la composition de l'échantillon que chaque run a +traité — et cette composition n'est pas neutre, puisque les copies manquantes sont +en général les plus difficiles (échec de transcription) ou simplement les +dernières de la file (run inachevé). + +Quand les runs ne couvrent pas le même corpus, les métriques sont donc recalculées +sur l'**intersection** des copies traitées ; quand ils le couvrent — le cas dès que +tous les runs sont allés au bout —, la synthèse ci-dessus est déjà comparable telle +quelle et n'est pas répétée ici. + +```{python} +#| echo: false +#| output: asis +# `restreindre_corpus_commun` renvoie les runs tels quels si le corpus est déjà +# commun : on ne réaffiche alors pas un tableau identique à celui du dessus, on se +# contente de le dire et on passe au classement. +if len(RUNS) >= 2: + COMMUNS, N_COMMUN = A.restreindre_corpus_commun(RUNS) +else: + COMMUNS, N_COMMUN = {}, 0 +CORPUS_DEJA_COMMUN = bool(COMMUNS) and all(r.n_copies == N_COMMUN for r in RUNS.values()) + +if not COMMUNS: + print('::: {.callout-note appearance="simple"}') + print("Cette comparaison demande **au moins deux runs** portant sur des copies") + print("communes. Exporter un second run (autre modèle, autre approche) pour") + print("l'activer.") + print(":::") +else: + syntheses_c = {label: A.synthese_globale(r.df) for label, r in COMMUNS.items()} + +if COMMUNS and CORPUS_DEJA_COMMUN: + print('::: {.callout-note appearance="simple"}') + print( + f"Les {len(RUNS)} runs portent tous sur les **mêmes " + f"{A.milliers(N_COMMUN)} copies** : aucune restriction n'est nécessaire, et " + "les colonnes de [la synthèse](#synthese-codage-de-la-dictee) sont " + "directement comparables entre elles. Le classement ci-dessous en découle." + ) + print(":::") +elif COMMUNS: + premiere_c = next(iter(syntheses_c.values())) + lignes = [] + for metrique, ligne in premiere_c.iterrows(): + fmt = A.formateur_unite(ligne["unite"]) + lignes.append( + [ + A.lien_metrique(metrique), + *[ + A.ic( + syntheses_c[label].loc[metrique, "valeur"], + syntheses_c[label].loc[metrique, "bas"], + syntheses_c[label].loc[metrique, "haut"], + fmt, + ) + for label in COMMUNS + ], + ] + ) + print( + A.table_markdown( + ["Métrique", *COMMUNS], + lignes, + f"Métriques recalculées sur les {A.milliers(N_COMMUN)} copies communes " + "à tous les runs, IC 95 % entre crochets", + ) + ) + print( + f"\nRestriction à **{A.milliers(N_COMMUN)} copies** sur " + f"{A.milliers(max(r.n_copies for r in RUNS.values()))} du corpus complet — " + "les chiffres diffèrent donc de la synthèse ci-dessus, qui exploite pour " + "chaque run toutes les copies dont il dispose." + ) +``` + +```{python} +#| echo: false +#| output: asis +# Classement explicite : à approche fixée, quel modèle a le meilleur kappa sur le +# corpus commun ? Le kappa est retenu comme critère parce qu'il corrige l'accord +# du hasard, contrairement à l'accord brut que la prévalence gonfle. +if COMMUNS and len(A.MODELES) >= 2: + lignes = [] + for approche in A.APPROCHES: + pairs = [(r.modele, syntheses_c[lab]) for lab, r in COMMUNS.items() if r.approche == approche] + if len(pairs) < 2: + continue + kappas = [(mod, float(s.loc["Kappa de Cohen", "valeur"])) for mod, s in pairs] + kappas.sort(key=lambda kv: kv[1], reverse=True) + (meilleur, k_max), (dernier, k_min) = kappas[0], kappas[-1] + lignes.append( + [ + approche, + " · ".join(f"`{mod}` {A.num(k)}" for mod, k in kappas), + f"**`{meilleur}`**", + f"{k_max - k_min:+.3f}", + ] + ) + if lignes: + print( + A.table_markdown( + ["Approche", "Kappa par modèle", "Meilleur modèle", "Écart"], + lignes, + "Classement des modèles par kappa, à approche fixée et corpus commun", + ) + ) + print( + "\nUn écart de kappa n'est concluant que s'il dépasse la largeur " + "des intervalles de confiance de la synthèse : les lire avant de " + "trancher." + ) +``` + ## Zoom sur la transcription (HTR) **HTR** = *Handwritten Text Recognition*, la reconnaissance d'écriture manuscrite. @@ -273,13 +350,14 @@ modèles sur la seule capacité à lire. Le point délicat, propre à l'écriture d'enfants : on veut une transcription **fidèle**, c'est-à-dire qui **préserve les fautes**. Un modèle qui « corrige » spontanément l'orthographe est un mauvais transcripteur ici, même s'il produit un -français correct — c'est le biais de **sur-correction**, surveillé de près (voir -la page [Évaluation](evaluation.qmd)). +français correct — c'est le biais de +[sur-correction de lecture](evaluation.qmd#sur-correction-lecture), surveillé de +près. L'évaluation de la transcription se fait sur le corpus **Scoledit**, qui fournit des transcriptions de référence **humaines** (fautes préservées), du CP au CM2. -On mesure la qualité de lecture par le **CER** et le **WER** (définis dans la page -[Évaluation](evaluation.qmd)). +On mesure la qualité de lecture par le [CER](evaluation.qmd#cer) et le +[WER](evaluation.qmd#wer), agrégés en micro-moyenne sur le corpus. ```{python} #| echo: false @@ -341,7 +419,7 @@ Les principales questions sur l'évaluation du modèles sont : Les sections qui suivent reprennent l'analyse du notebook `notebooks/03_analyse_resultats.ipynb`. -## Prévalence d'erreur par item +## Prévalence d'erreur par item {#prevalence-derreur-par-item} **Objectif DEPP** : - 1. calculer le nombre d'erreur par copie. @@ -352,10 +430,8 @@ signifie que le modèle **sous-estime** la difficulté de cet item. Les intervalles de confiance sont des intervalles de Wilson **sans correction de clustering**, et c'est volontaire : pour un item donné, il y a exactement *une* -observation par copie, chacune venant d'un élève différent. Ces observations sont -donc indépendantes et il n'y a rien à corriger. La correction s'applique aux -métriques agrégées sur les 83 items d'une copie — voir -[la synthèse](#synthese-codage-de-la-dictee) ci-dessus. +observation par copie, chacune venant d'un élève différent — voir +[où la correction ne s'applique pas](evaluation.qmd#sans-correction). ```{python} #| echo: false @@ -363,8 +439,8 @@ métriques agrégées sur les 83 items d'une copie — voir #| fig-cap: "Prévalence d'erreur par item — expert (axe horizontal) vs modèle (axe vertical). Les traits fins sont les intervalles de Wilson à 95 %." PREVALENCES = {} if RUNS: - fig, axes = plt.subplots(1, len(RUNS), figsize=(7.5 * len(RUNS), 7), squeeze=False) - for ax, (label, r) in zip(axes[0], RUNS.items()): + fig, axes = A.grille_axes(len(RUNS), largeur=7.5, hauteur=7.0) + for ax, (label, r) in zip(axes, RUNS.items()): prev = A.prevalence_par_item(r.df, MOTS, RANGS) PREVALENCES[label] = prev x, y = prev["pct_expert"].values, prev["pct_modele"].values @@ -406,7 +482,7 @@ if RUNS: ) ax.set_xlabel("Prévalence d'erreur selon l'expert (%)") ax.set_ylabel("Prévalence d'erreur selon le modèle (%)") - ax.set_title(f"Approche {label}", fontweight="bold") + ax.set_title(label, fontweight="bold") ax.legend(loc="lower right", fontsize=8) plt.tight_layout() plt.show() @@ -422,7 +498,7 @@ if PREVALENCES: pente, _, r_pearson, *_ = scistats.linregress(x, y) lignes.append( [ - f"Approche {label}", + label, f"{np.corrcoef(x, y)[0, 1]:+.3f}", f"{scistats.spearmanr(x, y).correlation:+.3f}", f"{pente:.3f}", @@ -452,7 +528,7 @@ Les items sur lesquels le modèle s'écarte le plus de l'expert : if PREVALENCES: print("::: {.panel-tabset}") for label, prev in PREVALENCES.items(): - print(f"\n## Approche {label}\n") + print(f"\n## {label}\n") pires = prev.reindex(prev["ecart"].abs().sort_values(ascending=False).index).head(15) lignes = [ [ @@ -468,7 +544,7 @@ if PREVALENCES: A.table_markdown( ["Rang", "Mot attendu", "Erreur — expert", "Erreur — modèle", "Écart"], lignes, - f"15 items les plus divergents — approche {label}", + f"15 items les plus divergents — {label}", ) ) print("\n:::") @@ -516,7 +592,7 @@ if RUNS: ) ax.set_xlabel("Nombre par copie") ax.set_ylabel("Nombre de copies") - ax.set_title(f"{titre}\napproche {label}", fontsize=9, fontweight="bold") + ax.set_title(f"{titre}\n{label}", fontsize=9, fontweight="bold") ax.legend(fontsize=7.5) plt.tight_layout() plt.show() @@ -613,8 +689,8 @@ le niveau absolu est biaisé. #| fig-cap: "Nombre d'erreurs par copie : modèle vs expert. Chaque point est une copie." CORRELATIONS = {} if RUNS: - fig, axes = plt.subplots(1, len(RUNS), figsize=(6.8 * len(RUNS), 6.6), squeeze=False) - for ax, (label, r) in zip(axes[0], RUNS.items()): + fig, axes = A.grille_axes(len(RUNS), largeur=6.8, hauteur=6.6) + for ax, (label, r) in zip(axes, RUNS.items()): x = r.copies["n_erreurs_expert"].values y = r.copies["n_erreurs_modele"].values maxv = max(x.max(), y.max()) * 1.05 @@ -635,7 +711,7 @@ if RUNS: } ax.set_xlabel("Nombre d'erreurs selon l'expert (proxy du niveau de l'élève)") ax.set_ylabel("Nombre d'erreurs selon le modèle") - ax.set_title(f"Approche {label}", fontweight="bold") + ax.set_title(label, fontweight="bold") ax.legend(loc="upper left", fontsize=8) plt.tight_layout() plt.show() @@ -647,7 +723,7 @@ if RUNS: if CORRELATIONS: lignes = [ [ - f"Approche {label}", + label, f"{c['pearson']:+.3f}", f"{c['spearman']:+.3f}", f"{c['kendall']:+.3f}", @@ -715,10 +791,18 @@ if RUNS: ## Consensus inter-modèles -**Motivation** : quand deux approches indépendantes convergent, la prédiction est -fiable ; quand elles divergent, c'est un signal d'incertitude qui appelle un +**Motivation** : quand plusieurs runs indépendants convergent, la prédiction est +fiable ; quand ils divergent, c'est un signal d'incertitude qui appelle un humain. Le désaccord inter-modèles est un **bien meilleur prédicteur de -confiance** que le score annoncé par un modèle unique. +confiance** que le score annoncé par un modèle unique, qui ne varie presque pas — +voir [score de consensus inter-modèles](evaluation.qmd#consensus) pour sa +définition. + +Le consensus est calculé sur **tous les runs chargés** — approches et modèles +confondus, c'est-à-dire sur les items communs à tous. Croiser deux modèles ET +deux approches donne un signal plus riche qu'un seul de ces axes : deux +architectures différentes servies par deux modèles différents se trompent moins +souvent *de la même façon*. ```{python} #| echo: false @@ -738,14 +822,14 @@ if len(RUNS) >= 2: if DF_ACCORD is None: print('::: {.callout-note appearance="simple"}') print("Cette section demande **au moins deux runs** portant sur les mêmes copies.") - print("Lancer un second benchmark (autre approche, autre modèle ou autre prompt)") + print("Lancer un second benchmark (autre modèle, autre approche ou autre prompt)") print("puis l'exporter pour l'activer.") print(":::") else: print( - f"Sur les {A.milliers(len(DF_ACCORD))} items communs aux deux approches, " + f"Sur les {A.milliers(len(DF_ACCORD))} items communs aux {len(RUNS)} runs comparés, " f"**{A.pct(DF_ACCORD['unanimite'].mean())} font l'objet d'un accord** " - "entre les modèles. Le score de confiance moyen par copie (part d'items " + "entre les runs. Le score de confiance moyen par copie (part d'items " f"sur lesquels les modèles s'accordent) vaut " f"{A.pts(CONF_COPIES['score_confiance'].mean())}." ) @@ -801,7 +885,7 @@ if DF_ACCORD is not None: ) ) print( - "\n**Lecture** : quand les deux approches s'accordent (dernière ligne), " + "\n**Lecture** : quand tous les runs s'accordent (dernière ligne), " "l'accord avec l'expert est nettement plus élevé. Le désaccord " "inter-modèles est donc un signal exploitable pour orienter la relecture " "humaine — c'est ce qu'exploite la section suivante." @@ -812,8 +896,9 @@ if DF_ACCORD is not None: **Question opérationnelle** : combien de copies faut-il renvoyer en correction humaine pour atteindre un accord cible sur les copies conservées ? On trie les -copies par score de confiance (part d'items où les modèles s'accordent) et on -renvoie celles sous le seuil. +copies par score de confiance et on renvoie celles sous le seuil — la +construction de la courbe est détaillée dans +[courbe de renvoi humain](evaluation.qmd#courbe-de-renvoi). ```{python} #| echo: false @@ -827,7 +912,7 @@ if DF_ACCORD is not None: COURBES_RENVOI[label] = courbe xs = courbe["pct_copies_renvoyees"].values ys = courbe["pct_accord_retenues"].values - ax.plot(xs, ys, color=r.couleur, lw=2.5, marker="o", ms=4.5, label=f"Approche {label}") + ax.plot(xs, ys, color=r.couleur, lw=2.5, marker="o", ms=4.5, label=label) ax.fill_between(xs, courbe["accord_lo"], courbe["accord_hi"], alpha=0.15, color=r.couleur) ax.axhline(ys[0], color=r.couleur, ls=":", lw=1, alpha=0.7) for seuil in (20, 50): @@ -856,7 +941,7 @@ if DF_ACCORD is not None: if COURBES_RENVOI: print("::: {.panel-tabset}") for label, courbe in COURBES_RENVOI.items(): - print(f"\n## Approche {label}\n") + print(f"\n## {label}\n") cible = courbe[courbe["seuil_confiance"].isin([50, 60, 70, 80, 90, 95, 100])] lignes = [ [ @@ -876,18 +961,21 @@ if COURBES_RENVOI: ["Seuil de confiance τ", "Copies renvoyées", "Accord sur les copies conservées", "Copies conservées"], lignes, - f"Seuils de renvoi — approche {label}", + f"Seuils de renvoi — {label}", ) ) print("\n:::") ``` -## Décomposition des désaccords +## Décomposition des désaccords {#decomposition-des-desaccords} -Chaque désaccord n'a pas les mêmes conséquences. **Sur-correction** -(expert : erreur → modèle : correct) veut dire qu'une faute de l'élève est -passée inaperçue. **Sur-détection** (expert : correct → modèle : erreur) veut -dire qu'on sanctionne un élève à tort. +Chaque désaccord n'a pas les mêmes conséquences : +[sur-correction](evaluation.qmd#sur-correction) (expert : erreur → modèle : +correct) veut dire qu'une faute de l'élève est passée inaperçue ; +[sur-détection](evaluation.qmd#sur-detection) (expert : correct → modèle : +erreur) veut dire qu'on sanctionne un élève à tort. Le tableau détaille chaque +transition `code expert → code modèle`, soit la +[matrice de confusion](evaluation.qmd#matrice-de-confusion) mise à plat. ```{python} #| echo: false @@ -914,7 +1002,7 @@ if RUNS: lignes.append([f"`{transition}`", *cellules]) print( A.table_markdown( - ["Transition expert → modèle", *(f"Approche {label}" for label in RUNS)], + ["Transition expert → modèle", *(label for label in RUNS)], lignes, "Décomposition des désaccords : effectif et part parmi les désaccords", ) @@ -923,8 +1011,8 @@ if RUNS: ## Accord par item -Accord entre modèle et expert pour chacun des 83 items, avec intervalle de -Wilson à 95 %. Les items sont triés par accord croissant selon l'approche de +Accord entre modèle et expert pour chacun des 83 items, avec +[intervalle de Wilson](evaluation.qmd#wilson) à 95 %. Les items sont triés par accord croissant selon l'approche de référence : ceux du haut du graphique sont les plus problématiques. ```{python} @@ -946,7 +1034,7 @@ if RUNS: ] ax.errorbar( valeurs, y + decalage, xerr=err, fmt="o", ms=4, lw=0, - elinewidth=0.9, capsize=1.8, color=r.couleur, alpha=0.9, label=f"Approche {label}", + elinewidth=0.9, capsize=1.8, color=r.couleur, alpha=0.9, label=label, ) ax.set_yticks(y) ax.set_yticklabels([A.libelle_item(i, MOTS, RANGS) for i in ordre], fontsize=7) @@ -954,7 +1042,7 @@ if RUNS: ax.axvline(85, color=A.C_PB, ls=":", lw=1) ax.axvline(95, color=A.C_MOYEN, ls=":", lw=1) ax.set_xlabel("Accord avec l'expert (%)") - ax.set_title(f"Accord par item — items triés selon l'approche {REF}", fontweight="bold") + ax.set_title(f"Accord par item — items triés selon {REF}", fontweight="bold") ax.legend(loc="lower right", fontsize=8.5) plt.show() ``` @@ -990,7 +1078,7 @@ if RUNS: ) ax2.plot( tendance["x"], tendance["y"] * 100, color=r.couleur, lw=2.2, - marker="o", ms=5, label=f"Approche {label}", + marker="o", ms=5, label=label, ) ax2.set_xlabel("% d'items en erreur sur la copie, selon l'expert") ax2.set_ylabel("Accord modèle-expert (%)") @@ -1002,7 +1090,9 @@ if RUNS: Les copies sur lesquelles le modèle s'écarte le plus de l'expert sont détaillées une par une — statistiques, transcription et codages comparés — dans la page -**[Écarts IA – humain](ecarts.qmd)**. +« Écarts IA – humain ». Elle porte sur des productions écrites de mineurs et +n'est donc pas publiée : la rendre depuis le SSP Cloud, en retirant l'exclusion +de `ecarts.qmd` dans `website/_quarto.yml`. ```{python} #| echo: false @@ -1028,9 +1118,6 @@ uv run scripts/export_predictions.py --config configs/scoring/dictee_two_stage.y uv run scripts/run_htr_benchmark.py --config configs/htr/htr_REFERENCE.yaml uv run scripts/export_predictions.py --config configs/htr/htr_REFERENCE.yaml --htr -# Densité d'encre du corpus : documente le seuil de détection des copies vierges -# (quelques minutes, aucun appel modèle). Alimente la page « Écarts IA – humain ». -uv run scripts/compute_ink_ratios.py --config configs/scoring/dictee_end2end.yaml --export ``` **3. Rendre le site.** Le rendu exige `matplotlib` et le noyau Jupyter, réunis @@ -1041,19 +1128,35 @@ uv sync --extra website uv run quarto render website # ou : uv run quarto preview website ``` -**4. Choisir les runs affichés** via des variables d'environnement (par défaut -`dictee_end2end` et `dictee_two_stage`) : +**4. Choisir les modèles comparés** via des variables d'environnement. Le nom du +modèle fait partie du nom de fichier exporté +(`__predictions.jsonl`) : le site croise donc chaque approche avec +chaque modèle listé dans `RESULTATS_MODELES`. ```bash -export RESULTATS_RUN_END_TO_END=dictee_end2end -export RESULTATS_RUN_TWO_STAGE=dictee_two_stage -export RESULTATS_RUN_REFERENCE="end-to-end" # run de référence des classements +# Modèles comparés, séparés par des virgules, dans l'ordre d'affichage : +export RESULTATS_MODELES="gemma4-26b-moe,qwen3-6-35b-moe" +# Modèle des pages sans axe « modèle » (page Écarts) : +export RESULTATS_MODELE_REFERENCE=gemma4-26b-moe +export RESULTATS_APPROCHE_REFERENCE="end-to-end" # approche de référence export RESULTATS_RUN_HTR_BASE=htr_gemma4_base export RESULTATS_RUN_HTR_FINETUNE=htr_gemma4_finetune # Rendu hors ligne, depuis un dossier local de prédictions : -export S3_PREDICTIONS_PREFIX=/chemin/vers/predictions +export S3_PREDICTIONS_PREFIX=data/processed ``` +Un modèle demandé mais non exporté est **omis**, jamais remplacé par un autre : +substituer un modèle à un autre fausserait précisément la comparaison que cette +page produit. Les runs manquants sont listés dans l'encadré de fin de page, avec +l'inventaire de ce qui est réellement exporté. + +::: {.callout-tip appearance="simple"} +Le rendu est mis en cache (`execute: freeze: auto`) : une page n'est réexécutée +que si son `.qmd` change. Après un **nouvel export** de prédictions, forcer le +recalcul avec `rm -rf website/_freeze`, sinon le site réaffiche les chiffres du +rendu précédent. +::: + L'exploration interactive (au-delà de ce que le site affiche) se fait dans les **notebooks** : @@ -1071,4 +1174,5 @@ Au-delà des chiffres bruts, le projet vise trois décisions : améliore-t-il assez la lecture pour justifier son coût) ? 3. **Où placer le seuil** de renvoi vers un correcteur humain, pour garantir un taux d'erreur résiduel acceptable tout en automatisant le maximum de copies - (voir la **courbe de renvoi** ci-dessus et la page [Évaluation](evaluation.qmd)). + (voir la courbe de renvoi ci-dessus et sa + [définition](evaluation.qmd#courbe-de-renvoi)).