Collaboration DEPP × SSP Lab (Insee), destinée aux équipes qui évaluent l'opportunité d'assister la correction humaine par l'IA.
Documentation — contexte, décisions méthodologiques et conventions : CLAUDE.md ·
docs/decisions.md· site Quarto
Observer la possibilité et la fiabilité d'un codage automatique des items d'une dictée manuscrite, recueillie dans le cadre des évaluations CEDRE.
Deux pipelines sont disponibles pour la prédiction des codes d'évaluation d'une copie scannée, comparées sur les mêmes métriques (dont accord brut et kappa de cohen) :
| Approche | Principe | Intérêt |
|---|---|---|
| end2end (défaut) | un modèle multimodal (VLM) lit l'image et attribue les codes en une seule passe | plus simple, aucune perte d'information entre deux étapes |
| two step | la copie est d'abord transcrite en texte par un modèle HTR, puis ce texte est codé par un modèle plus léger (LLM Texte) | isole les erreurs de lecture de celles de jugement |
Le choix se fait dans la configuration de l'expérience (voir Configuration).
- Évaluation HTR seule sur le corpus Scoledit (transcriptions humaines, fautes d'élèves préservées) : mesure la fidélité de lecture indépendamment du codage, et permet donc de savoir si un désaccord vient d'une mauvaise lecture ou d'un mauvais jugement.
- Fine-tuning QLoRA d'un VLM sur Scoledit (CP→CM2) pour spécialiser la lecture de l'écriture manuscrite d'élèves : produit un adaptateur léger (~50-200 Mo) et demande un GPU H100.
| Version | Note | |
|---|---|---|
| Python | ≥ 3.11 | installé par uv si absent |
| uv | ≥ 0.4 | seul gestionnaire d'environnement du projet (pas de pip install ni de conda) |
| Accès SSP Cloud | — | S3 (MinIO), llm.lab, Langfuse ; voir Configuration |
quarto |
≥ 1.10 | uniquement pour rendre le site |
| GPU H100 | — | uniquement pour le fine-tuning |
uv n'est pas installé ? curl -LsSf https://astral.sh/uv/install.sh | sh
(déjà présent sur les services vscode-python du SSP Cloud).
git clone https://github.com/InseeFrLab/evaluation_dictee.git
cd evaluation_dictee
uv sync # dépendances + groupe dev + mode éditable (imports evaluation_dictee)Extras optionnels, à la demande :
uv sync --extra notebooks # JupyterLab + matplotlib (analyse des résultats)
uv sync --extra website # noyau Jupyter + matplotlib (rendu Quarto)
uv sync --extra gpu # torch, transformers, vllm (machines GPU seulement)Une fois les accès configurés (section suivante) :
uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml --limit 5Cinq copies sont codées et comparées à l'expert en quelques minutes. Résultat :
data/processed/<name>_<modele>_predictions.jsonl, une ligne par item × copie, et
les métriques d'accord affichées en fin de run.
Le nom du fichier porte toujours 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: deux modèles n'écrasent jamais le même checkpoint et peuvent tourner en parallèle.
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.
Les données sont produites et détenues par la DEPP, qui les met à disposition du SSP Lab pour ce projet. Elles ne sont couvertes ni par la licence de ce dépôt (MIT, qui ne porte que sur le code) ni par aucune licence ouverte : tout réemploi hors de ce cadre suppose l'accord de la DEPP.
Aucune donnée n'est versionnée dans ce dépôt, et ce n'est pas négociable : les copies sont des écritures d'élèves mineurs; elles ne quittent jamais le SSP Cloud.
| Jeu | Emplacement | Contenu |
|---|---|---|
| Dictée CM2 2015 - imagettes | s3://projet-production-ecrits-depp/dictee_2015/ |
~3 469 copies scannées |
| Dictée CM2 2015 - codes experts | s3://projet-production-ecrits-depp/resultat_dictee_2015.csv |
gold standard, 83 items par copie |
| Scoledit (HTR) | s3://projet-production-ecrits-depp/scoledit/{scans,annotation}/<niveau>/ |
transcriptions humaines CP→CM2, fautes préservées |
| Grille de codage | configs/grille_dictee_2015.json |
versionné : mot attendu + fautes connues, par item |
| Prédictions exportées | $S3_PREDICTIONS_PREFIX (défaut …/predictions) |
sorties de run, relues par les notebooks et le site |
Les chemins d'entrée vivent dans le YAML de chaque expérience, pas dans .env :
un run reste ainsi reproductible à partir de sa seule config. L'accès S3 est
transparent sur Onyxia (identifiants injectés), un chemin s3://… se lit comme un
chemin local.
- Les fichiers
.pngfournis sont en réalité des TIFF bi-level 1 bit (compression G4, 1594×2044).src/evaluation_dictee/data/loaders.pynormalise le format en entrée : ne pas court-circuiter ce chargeur. - La binarisation 1 bit détruit les nuances de gris, ce qui rend les accents difficiles à lire : c'est une cause connue d'une part des désaccords modèle/expert.
- Les annotations sont des codes, pas des transcriptions mot à mot. Fine-tuner un HTR demande un ground truth de transcription séparé (d'où Scoledit).
Tout passe par des variables d'environnement : aucun secret ne doit être ajouté dans le code ni
dans les YAML. Liste complète et commentée des variables d'environnement : .env.example.
| Clé | Requis pour |
|---|---|
LLM_BASE_URL, LLM_API_KEY |
tout run |
LANGFUSE_* (URL + clés publique et secrète) |
traçage des runs |
S3_PREDICTIONS_PREFIX |
export des prédictions |
MLFLOW_TRACKING_URI |
fine-tuning |
- Sur le SSP Cloud : il faut stocker ces valeurs dans le Vault Onyxia
(
Mon compte→Vault), puis référencer le secret au lancement du service : Onyxia les injecte dans l'environnement. Les identifiants S3 sont injectés automatiquement, il n'y a rien à configurer pour le stockage. - En local :
cp .env.example .env, puis compléter. Ce fichier n'est jamais commité sur GitHub.
Un fichier dans configs/ décrit une expérience reproductible, rangé par famille
(scoring/, htr/, finetune/). Partir de
configs/scoring/dictee_REFERENCE.yaml,
exhaustivement commenté ; détails dans configs/README.md.
| Champ | Rôle |
|---|---|
approach |
end_to_end ou two_stage |
model.name |
modèle servi par llm.lab (ex. gemma4-26b-moe) |
concurrency |
copies évaluées en parallèle (défaut 8) —> principal levier de temps mural |
data.limit |
nombre de copies ; null = tout le corpus |
grid.scheme |
simplifiee (1/9/0) ou complete (1/3/4/5/9/0) |
evaluation_dictee/
├── src/evaluation_dictee/ ← le paquet : config, data, models, pipeline, evaluation, transcription, utils
├── scripts/ ← points d'entrée Python (un run = un process)
├── launchers/ ← lanceurs shell des runs longs (voir launchers/README.md)
├── configs/ ← une expérience = un YAML (scoring/, htr/, finetune/) + la grille de codage
├── notebooks/ ← 03 analyse, 04 diagnostic, 05 transcription HTR
├── website/ ← site Quarto (archi, résultats, métriques, fine-tuning)
├── tests/ ← 155 tests unitaires (pytest)
└── docs/ ← décisions méthodologiques, grille de codage, schéma du pipeline
Détail des modules et des scripts
src/evaluation_dictee/
├── config.py ← configs validées (Pydantic) + secrets
├── data/ ← chargement images (S3, TIFF 1 bit), grille, labels
├── models/ ← interface Scorer, scorers end-to-end et two-stage
├── pipeline/ ← prompts, évaluation + checkpointing, ré-alignement (Needleman-Wunsch)
├── evaluation/ ← métriques, statistiques, calibration, diagnostics, rapports HTML
├── transcription/ ← pipeline HTR indépendant (Scoledit) : loader, CER/WER, diffs
└── utils/ ← logging, suivi Langfuse, export S3
scripts/
├── run_benchmark.py ← scoring dictée (les deux approches)
├── run_htr_benchmark.py ← évaluation HTR Scoledit
├── export_predictions.py ← export d'un run terminé vers S3
└── finetune_htr_scoledit.py ← fine-tuning QLoRA (GPU H100)
launchers/
├── launch_eval.sh ← run complet : détache du terminal, surveille, relance, exporte le run
└── install_assistant.sh ← installe Claude Code / openCode, branchés sur llm.lab
.claude/skills/launch/ ← skill Claude Code ┐ n'appellent que launch_eval.sh,
.opencode/command/ ← commande openCode ┘ aucune logique dupliquée
Un bras est une variante de prompt testée contre la référence : chain-of-thought, comptage des items, exemples de fautes… Comparer des bras est une question distincte de « comment se comporte ce run », et aucun des deux autres supports n'y répond :
| Support | Ce qu'il porte | Ce qu'il ne porte pas |
|---|---|---|
| Langfuse | chaque run isolément : accord, kappa, sous-détection, biais sur le taux de faute, kappa mots/ponctuation, options actives en tags | l'écart contre une référence, et son intervalle de confiance |
| Le site Quarto | ce qui est retenu, pour l'extérieur (DEPP, partenaires) | les expérimentations abandonnées, qui l'encombreraient |
rapport_bras.py |
la comparaison des variantes entre elles, en interne | rien de destiné à être publié |
uv run scripts/rapport_bras.py # tous les bras, tous les modèles
uv run scripts/rapport_bras.py --modeles qwen3-8-27b # un seul modèle
uv run scripts/rapport_bras.py --bras référence=dictee_end2end exemples=dictee_end2end_exemplesLe rapport sort dans data/processed/rapport_bras.html, autonome et hors de Git. Il
contient quatre sections : l'écart de kappa de chaque bras contre la référence du même
modèle avec son intervalle de confiance ; l'accord et le kappa item par item ; le taux de
faute attribué aux élèves face à celui mesuré par l'expert ; et la ventilation
mots/ponctuation. Les copies vierges et illisibles sont écartées (décision D8), et
toutes les séries sont restreintes aux copies communes — comparer deux bras sur des
corpus différents confondrait l'effet du bras avec celui de la composition de
l'échantillon.
Où vivent les résultats d'un bras. Un run absent en local est cherché sur S3, à
DEUX emplacements : predictions/ (runs de référence, corpus complet) puis
predictions/experimentations/ (bras testés sur un échantillon). Cette séparation
n'est pas cosmétique : le site liste predictions/ sans y descendre, donc un bras posé
dans experimentations/ n'apparaît jamais comme un faux modèle sur le site, par
construction. scripts/export_predictions.py choisit automatiquement la bonne
destination, en comparant l'effectif réellement présent dans le run au corpus complet
(pas data.limit du YAML, qui ne reflète pas un --limit passé en ligne de commande —
c'est pourtant ainsi que tous les bras de ce projet ont été lancés). Un run n'alimente
donc le site que s'il a été mené sur le corpus complet, peu importe qu'il soit passé
par launchers/launch_eval.sh ou par run_benchmark.py directement : c'est la taille
du run qui compte, pas la façon dont il a été lancé.
Publier un bras comme approche du site (le montrer à côté d'end-to-end / two-stage,
pour un modèle donné) est en revanche manuel, pas automatique : ajouter une ligne
dans MODELES_PROMUS (website/_analyse.py), après avoir vérifié que le run complet
est bien exporté à la racine de predictions/. Manuel à dessein — le meilleur prompt
diffère selon le modèle (décision D10), rien à déduire d'un nom de fichier. Détails et
exemple : docs/decisions.md (D10) et le docstring de MODELES_PROMUS.
Ce qui doit survivre, ce sont les chiffres, pas le HTML : ils sont enregistrés à chaque run dans Langfuse. Le rapport, lui, se régénère en une commande.
Un benchmark complet (3 469 copies × ~30 s) prend ~15 h : il ne doit jamais dépendre de l'onglet du navigateur.
Il ne faut pas lancer les copies à la main, mais passer par le lanceur, qui est le point d'entrée unique. En effet, le lanceur détache le run du terminal (celui-ci continue donc de tourner après la fermeture de la session ou de l'onglet), le surveille, puis relance les copies en échec avant d'exporter le résultat vers S3.
launchers/launch_eval.sh --config configs/scoring/dictee_end2end.yaml # lancer
launchers/launch_eval.sh --config configs/scoring/dictee_end2end.yaml --status # avancement + heure de fin
launchers/launch_eval.sh --config configs/scoring/dictee_end2end.yaml --stop # arrêt propre
launchers/launch_eval.sh --help # toutes les optionsToujours préciser le run concerné (option
--config) pour toute action spécifique sur un run (avec les options--statusetstoppar exemple)
Options courantes :
--model-name <modèle>(surcharge le YAML),--limit N(test rapide, sans export S3),--passes N(nombre de relances des copies en échec, défaut 3),--foreground(débogage).
Sortie de
--status, avec un débit mesuré sur la passe en cours :Run dictee_end2end_qwen3-6-35b-moe ✔ en cours : 10489 01:33 uv run scripts/run_benchmark.py --config … copies 412/3469 (11%) débit 464.5 copies/h (depuis 0h 53min) fin estimée 2026-09-06 09:12 (dans 6h 34min)Les logs sont horodatés, un par lancement :
logs/<run>_<horodatage>.log, aveclogs/<run>.latest.logqui pointe vers le dernier. Détails des vérifications effectuées au démarrage :launchers/README.md.
Le benchmark écrit sur disque après chaque copie (flush + fsync).
Après une interruption, relancer la même commande saute les copies déjà faites, seule la copie
en cours est perdue. Pour repartir de zéro, supprimer le .jsonl (ou changer
config.name).
Ne jamais arrêter un run par son PID.
uv run …lance DEUX processus : leuvvisible en tête deps, et lepython3qui fait réellement le travail.Tuer le premier ne tue pas le second : le run paraît arrêté alors qu'il continue d'écrire dans le JSONL, et un nouveau lancement viendrait s'y ajouter en double.
-> Utiliser
--stop, qui arrête les deux ou l'instruction :pkill -f "run_benchmark.py --config <la config>".
Un seul run par fichier de sortie. Un second run visant le même fichier s'arrête sur le verrou
<sortie>.lock: deux runs qui appendent le même JSONL dupliquent les copies et faussent les métriques.
Les assistants ne font qu'appeler le lanceur, sans dupliquer la moindre logique.
| Assistant | Fichier | Comment |
|---|---|---|
| Claude Code | .claude/skills/launch/ |
/launch, ou « lance l'évaluation complète » |
| openCode | .opencode/command/launch.md |
/launch, /launch status, /launch stop |
| Un autre (Cursor, Codex…) | — | lui faire lire launchers/README.md, ou lancer le .sh soi-même |
Pas encore d'assistant installé ? launchers/install_assistant.sh l'installe et le
branche sur llm.lab plutôt que sur une API payante.
Le pipeline complet, de l'environnement vierge aux figures publiées.
1. Environnement et accès : Installation puis Configuration de l'environnement. Pousser une fois les prompts à utiliser et les coûts dans Langfuse pour garder une trace des évolutions :
# --env-file .env : Langfuse lit ses clés dans os.environ, que .env n'alimente pas seul.
uv run --env-file .env add-langfuse-prompt # prompts d'évaluation
uv run --env-file .env add-langfuse-models # coût théorique /1M tokens2. Vérifier que tout répond : S3 (voir Données), le modèle, les tests :
uv run python -c "from openai import OpenAI; from evaluation_dictee.config import Secrets; \
s=Secrets(); c=OpenAI(base_url=s.llm_base_url, api_key=s.llm_api_key); \
print(c.chat.completions.create(model='gemma4-26b-moe', \
messages=[{'role':'user','content':'Dis bonjour'}], max_tokens=10).choices[0].message.content)"
uv run pytest -q3. Relancer les évaluations : une config = une expérience ; les runs complets passent par le lanceur.
launchers/launch_eval.sh --config configs/scoring/dictee_end2end.yaml # ~30 h
launchers/launch_eval.sh --config configs/scoring/dictee_two_stage.yaml # ~30 h
uv run scripts/run_htr_benchmark.py --config configs/htr/htr_REFERENCE.yaml4. Exporter vers S3 : le pipeline écrit en local (append + fsync, pour la reprise) ;
l'export permet de rejouer notebooks et site sans relancer le pipeline.
launch_eval.sh le fait seul ; à la main :
uv run scripts/export_predictions.py --run-name dictee_two_stage_gemma4-26b-moe
uv run scripts/export_predictions.py --config configs/htr/htr_REFERENCE.yaml --htr5. Régénérer les analyses : changer la variable RUN_NAME en tête de notebook
suffit pour analyser un autre run.
uv sync --extra notebooks && uv run jupyter lab| Notebook | Ce qu'il fait | Prérequis |
|---|---|---|
03_analyse_resultats.ipynb |
métriques globales, prévalence par item (IC bootstrap), distributions, corrélation modèle vs expert, seuils critiques, export HTML DEPP | un run de scoring terminé |
04_diagnostic.ipynb |
copies triées par désaccord, HTML des N pires, HTML d'une copie précise (scan + transcription + comparaison expert/modèle) | un run de scoring terminé |
05_analyse_transcription_htr.ipynb |
CER/WER, distribution, HTML des N pires transcriptions et de N aléatoires | un run HTR terminé |
Le rapport pour la DEPP se génère depuis la section 9 du notebook 03 :
data/processed/rapport_depp_<RUN>.html, autonome (assets inlinés), prêt à envoyer.
6. Republier le site — les pages « Résultats » et « Écarts » relisent les prédictions exportées sur S3 au moment du rendu.
uv sync --extra website
quarto preview website # aperçu local (rechargement à chaud)
quarto render website # génère website/_site/La publication sur GitHub Pages est automatique à chaque push sur main
(.github/workflows/site.yml).
- Partir de
mainà jour, créer une branche :git switch -c <type>-<sujet>(ex.feat-scorer-multimodal,docs-nettoyage-readme). - Développer, puis vérifier localement avant de pousser (voir ci-dessous).
- Ouvrir une pull request vers
main. La CI rejoue lint, formatage, typage (non bloquant) et tests.
Messages de commit en français, format type: description — feat:, fix:,
docs:, refactor:, test:.
uv run ruff format src tests scripts # formatage
uv run ruff check src tests scripts # lint
uv run mypy src # typage
uv run pytest # toute la suite (155 tests)
uv run pytest tests/test_alignment.py -v # un fichier
uv run pytest -k "chain_of_thought" # par motifDétaillées dans CLAUDE.md § 8. L'essentiel :
- Python ≥ 3.11, dépendances gérées avec uv exclusivement.
- Annoter les fonctions publiques ; docstrings en français, style Google, court.
- Pas de chemin en dur : tout passe par
configs/*.yamletconfig.py. - Pas de secret dans le code : variables d'environnement uniquement.
- Reproductibilité : une expérience = une config versionnée + un run tracé dans Langfuse. Fixer les graines aléatoires.
- Les
notebooks/servent à explorer : dès qu'un bout de code devient réutilisable, le déplacer danssrc/.
Lire CLAUDE.md, puis suivre le fil de scripts/run_benchmark.py : il lit
une config, charge les données, appelle un modèle, calcule les métriques. En cas de
doute sur une décision méthodologique, la réponse est dans CLAUDE.md ou
docs/decisions.md.
Le code de ce dépôt est sous licence MIT — voir LICENSE.
Copyright (c) 2026 Insee et DEPP (Ministère de l'Éducation nationale).
Les données ne sont pas couvertes par cette licence. Produites et détenues par la DEPP, elles ne sont pas versionnées ici et restent soumises au cadre d'accès de la DEPP et du SSP Cloud — voir Données.
# ─────────── Installation & configuration (une seule fois) ───────────
uv sync # environnement Python (dev inclus)
cp .env.example .env && $EDITOR .env # hors Onyxia seulement (sinon : Vault)
uv run --env-file .env add-langfuse-prompt # pousse les prompts d'évaluation
uv run --env-file .env add-langfuse-models # coût théorique /1M tokens
launchers/install_assistant.sh --check # (option) assistant de code : tester l'endpoint
launchers/install_assistant.sh --endpoint "$LLM_BASE_URL" # installer + brancher sur llm.lab
# ─────────── Runs ───────────
# Run COMPLET (~3469 copies) — la voie normale. Répéter --config (et --model-name)
# à l'identique sur les trois commandes : elles identifient le run.
CFG=configs/scoring/dictee_end2end.yaml
launchers/launch_eval.sh --config $CFG # lancer
launchers/launch_eval.sh --config $CFG --status # avancement + heure de fin estimée
launchers/launch_eval.sh --config $CFG --stop # arrêt propre
launchers/launch_eval.sh --config $CFG --limit 5 # test rapide de bout en bout, sans export S3
uv run scripts/run_benchmark.py --config configs/scoring/dictee_REFERENCE.yaml # un passage, au premier plan
uv run scripts/run_htr_benchmark.py --config configs/htr/htr_REFERENCE.yaml # transcription seule
uv run scripts/finetune_htr_scoledit.py --config configs/finetune/finetune_REFERENCE.yaml # GPU H100
# ─────────── Suivre un run en cours ───────────
tail -f logs/<run>.latest.log # log du dernier lancement
cat data/processed/<run>_failed_copies.txt # copies en échec du dernier passage
# ─────────── Export des prédictions vers S3 ───────────
uv run scripts/export_predictions.py --run-name dictee_two_stage_gemma4-26b-moe
uv run scripts/export_predictions.py --config configs/htr/htr_REFERENCE.yaml --htr
eval-ecrit export configs/scoring/dictee_REFERENCE.yaml # équivalent via la CLI installée
# Destination : $S3_PREDICTIONS_PREFIX/<name>_<modele>_predictions.jsonl
# (défaut s3://projet-production-ecrits-depp/predictions, surchargeable par --dest-prefix)
# ─────────── Analyse & documentation ───────────
uv sync --extra notebooks && uv run jupyter lab # notebooks 03 / 04 / 05
quarto preview website # aperçu local (rechargement à chaud)
quarto render website # génère website/_site/