Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@
*.jpeg
# Exceptions : on autorise les images de documentation
!docs/**/*.png
# Exceptions : les gabarits du site (logos Insee, pictogrammes, licences CC)
# ne sont pas des données élèves — sans ça, le site ne se construit plus.
!website/slides/_extensions/**/*.png
!website/slides/_extensions/**/*.jpg
!website/slides/_extensions/**/*.jpeg
!website/slides/img/**/*.png
!website/slides/img/**/*.jpg
!website/img/**/*.png

# ─── MLflow (suivi local, ne pas versionner) ───────────────────
mlflow.db
Expand Down
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,14 @@ 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
```

### Runs longs — utiliser screen ou nohup
Expand Down
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ notebooks = [
"jupyterlab>=4.6.1",
"matplotlib>=3.11.1",
]
# Rendu du site Quarto : les pages recalculent leurs figures au rendu, ce qui
# demande matplotlib et un noyau Jupyter (`uv sync --extra website`).
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.
# Le groupe `dev` est installé automatiquement par un simple `uv sync`.
Expand Down
168 changes: 168 additions & 0 deletions scripts/compute_ink_ratios.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"""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()
21 changes: 12 additions & 9 deletions src/evaluation_dictee/evaluation/multi_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@

from __future__ import annotations

from collections import Counter
from pathlib import Path

import numpy as np
import pandas as pd

from evaluation_dictee.evaluation.report import load_predictions
Expand Down Expand Up @@ -83,14 +83,17 @@ def agreement_per_item(df_multi: pd.DataFrame) -> pd.DataFrame:

out = df_multi.copy()

def _modal_and_count(row):
counts = Counter(row[c] for c in pred_cols)
modal, n_acc = counts.most_common(1)[0]
return pd.Series({"modal_pred": modal, "n_accord_modeles": n_acc})

modal_df = out[pred_cols].apply(_modal_and_count, axis=1)
out["modal_pred"] = modal_df["modal_pred"]
out["n_accord_modeles"] = modal_df["n_accord_modeles"].astype(int)
# Vote majoritaire vectorisé : `effectifs[:, i]` compte les runs qui prédisent
# la même chose que le run i, ligne par ligne. Le premier maximum gagne, ce qui
# départage les ex æquo par ordre d'apparition (comme Counter.most_common).
# Version vectorisée indispensable ici : un apply(axis=1) sur les ~290 000
# items d'un run complet coûte une minute, contre une fraction de seconde ici.
preds = out[pred_cols].to_numpy(dtype=object)
effectifs = np.column_stack([(preds == preds[:, [i]]).sum(axis=1) for i in range(n_modeles)])
lignes = np.arange(len(preds))
gagnant = effectifs.argmax(axis=1) if len(preds) else lignes
out["modal_pred"] = preds[lignes, gagnant]
out["n_accord_modeles"] = effectifs[lignes, gagnant].astype(int)
out["n_modeles"] = n_modeles
out["unanimite"] = out["n_accord_modeles"] == n_modeles

Expand Down
Loading
Loading