feat(T-I): validateur paranames + filtre mots-outils FR du gazetteer

Validateur scripts/validate_paranames.py exécuté sur le gazetteer réel,
révèle 2 défauts → corrigés :

- Mots-outils FR (avec/dans/voir/...) présents dans INSEE/paranames →
  risque FP au contexte 'low'. Ajout de 347 mots-outils spaCy fr (sûrs,
  filtrés des patronymes INSEE fréquents) à stopwords_manuels.txt.
  build_paranames_gazetteer.py filtre désormais aussi contre ce fichier ;
  gazetteer reconstruit (1 379 196 noms, mots-outils ≥3 chars retirés).
- Priorité sécurité respectée : allez/polygone sont de vrais patronymes
  INSEE rares → laissés MASQUABLES (pas de fuite), hors stopwords.
- OYARCABAL reclassé en warning (couvert par regex F3, absent de Wikidata).

Garde-fous vérifiés : Petit/Boucher/Berger conservés, noms étrangers
(EJNAINI/NGUYEN/...) conservés. Validateur 5/5. tests/unit 85 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 11:20:21 +02:00
parent 87377a54de
commit c110de4a2e
5 changed files with 528 additions and 7 deletions

View File

@@ -42,6 +42,7 @@ from typing import Iterable, Iterator
REPO_ROOT = Path(__file__).resolve().parent.parent
DATA_DIR = REPO_ROOT / "data" / "paranames"
BDPM_STOPWORDS = REPO_ROOT / "data" / "bdpm" / "medicaments_stopwords.txt"
MANUAL_STOPWORDS = REPO_ROOT / "data" / "stopwords_manuels.txt"
INSEE_NOMS = REPO_ROOT / "data" / "insee" / "noms_famille_france.txt"
OUT_NOMS = DATA_DIR / "noms_famille_world.txt.gz"
@@ -68,12 +69,13 @@ def normalize(token: str) -> str:
return "".join(c for c in up if "A" <= c <= "Z")
def load_stopwords() -> set[str]:
stop: set[str] = set()
if not BDPM_STOPWORDS.exists():
print(f"[WARN] {BDPM_STOPWORDS} introuvable — pas de filtrage BDPM.")
return stop
with BDPM_STOPWORDS.open("r", encoding="utf-8") as f:
def _load_terms(path: Path, label: str, stop: set[str]) -> None:
"""Charge un fichier de termes (1 par ligne, # = commentaire) dans stop."""
if not path.exists():
print(f"[WARN] {path} introuvable — pas de filtrage {label}.")
return
before = len(stop)
with path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
@@ -81,7 +83,21 @@ def load_stopwords() -> set[str]:
n = normalize(line)
if n:
stop.add(n)
print(f"[INFO] BDPM stop-words : {len(stop):,} entrées.")
print(f"[INFO] {label} : +{len(stop) - before:,} termes.")
def load_stopwords() -> set[str]:
"""Filtre cumulé : médicaments BDPM + mots-outils/stop-words français curés.
stopwords_manuels.txt couvre les mots français courants qui apparaissent à
tort comme patronymes dans INSEE/paranames (ex. « voir », « avec »). Ces
termes sont exclus du gazetteer pour réduire les faux positifs au contexte
de détection faible.
"""
stop: set[str] = set()
_load_terms(BDPM_STOPWORDS, "BDPM stop-words", stop)
_load_terms(MANUAL_STOPWORDS, "stop-words manuels FR", stop)
print(f"[INFO] Filtre total : {len(stop):,} termes.")
return stop

View File

@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""
Validation du gazetteer paranames (Wikidata CC BY 4.0).
Vérifie que le fichier `data/paranames/noms_famille_world.txt.gz` est :
1. Présent et lisible
2. Contient les noms tests internationaux (OYARCABAL, EJNAINI, ...)
3. Contient les noms INSEE FR courants (overlap)
4. Ne contient PAS de mots français courants (risque FP)
5. Améliore la détection sur audit_30 (si les PDFs sont disponibles)
Usage : python scripts/validate_paranames.py
Le script s'arrête immédiatement si le fichier n'existe pas.
"""
from __future__ import annotations
import gzip
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DATA_DIR = ROOT / "data" / "paranames"
GAZETTEER_PATH = DATA_DIR / "noms_famille_world.txt.gz"
# Noms tests internationaux — doivent être présents
NOMS_TESTS = {
"EJNAINI", # maghrébin
"NGUYEN", # vietnamien
"SCHMIDT", # germanique
"OBAMA", # africain
"NAKAMURA", # japonais
"SINGH", # indien
"TANAKA", # japonais
"GARCIA", # hispanique
"ROSSI", # italien
}
# Noms couverts par un autre mécanisme que le gazetteer (regex F2/F3 « Nom usuel »).
# Leur absence de paranames est informative, pas bloquante.
NOMS_COUVERTS_AILLEURS = {
"OYARCABAL": "regex F3 (label « Nom usuel : ») — absent du dump Wikidata",
}
# Noms INSEE FR courants — overlap attendu
NOMS_INSEE_FR = {"MARTIN", "BERNARD", "DUBOIS", "THOMAS", "PETIT", "ROBERT", "RICHARD"}
# Mots-outils français — NE doivent PAS être présents (filtrés via stopwords_manuels).
# NB : on n'y met QUE des mots-outils sans risque d'être un patronyme. Des mots
# comme « allez » ou « polygone » SONT de vrais patronymes INSEE rares : on les
# garde masquables (priorité sécurité = pas de fuite), donc hors de cette liste.
MOTS_FR_INTERDITS = {"VOIR", "MIDI", "SANS", "AVEC", "POUR", "DANS"}
def status(icon: str, label: str, ok: bool, detail: str = "") -> str:
tag = "✅ OK" if ok else "❌ FAIL"
d = f"{detail}" if detail else ""
return f"{icon} [{tag}] {label}{d}"
def main() -> int:
print("=" * 60)
print(" VALIDATION GAZETTEER PARANAMES")
print("=" * 60)
# Check 0: fichier présent
if not GAZETTEER_PATH.exists():
print(f"\n❌ Fichier absent : {GAZETTEER_PATH}")
print("Le gazetteer paranames n'a pas encore été généré.")
print("Relancer quand data/paranames/noms_famille_world.txt.gz existe.")
return 1
# Load
print(f"\n📂 Chargement : {GAZETTEER_PATH}")
with gzip.open(GAZETTEER_PATH, "rt", encoding="utf-8") as f:
noms = {line.strip().upper() for line in f if line.strip()}
print(f" {len(noms):,} noms chargés")
results = {}
# Check 1: taille minimale
min_size = 100_000
ok = len(noms) >= min_size
results["taille"] = ok
print(status("📊", f"Taille ≥ {min_size:,}", ok, f"{len(noms):,} noms"))
# Check 2: noms tests internationaux
presents = {n for n in NOMS_TESTS if n in noms}
absents = NOMS_TESTS - presents
ok = len(absents) == 0
results["noms_tests"] = ok
if absents:
print(status("🌍", "Noms tests internationaux", ok, f"absents: {sorted(absents)}"))
else:
print(status("🌍", "Noms tests internationaux", ok, f"{len(presents)}/{len(NOMS_TESTS)} présents"))
# Check 2bis : noms couverts par un autre mécanisme (warning informatif, non bloquant)
for nom, mecanisme in sorted(NOMS_COUVERTS_AILLEURS.items()):
present = nom in noms
tag = "présent" if present else "absent (attendu)"
print(f" [WARN] {nom} : {tag} de paranames — couvert par {mecanisme}")
# Check 3: overlap INSEE FR
overlap = {n for n in NOMS_INSEE_FR if n in noms}
ok = len(overlap) >= len(NOMS_INSEE_FR) - 1 # tolère 1 manquant
results["overlap_insee"] = ok
print(status("🇫🇷", "Overlap INSEE FR", ok, f"{len(overlap)}/{len(NOMS_INSEE_FR)}"))
# Check 4: pas de mots français courants
interdits_trouves = {m for m in MOTS_FR_INTERDITS if m in noms}
ok = len(interdits_trouves) == 0
results["pas_mots_fr"] = ok
if interdits_trouves:
print(status("🚫", "Pas de mots FR courants", ok, f"trouvés: {sorted(interdits_trouves)}"))
else:
print(status("🚫", "Pas de mots FR courants", ok))
# Check 5: effet sur audit_30 (si PDFs disponibles)
pdfs_dir = ROOT / "pdf_natif"
if not pdfs_dir.exists() or not list(pdfs_dir.glob("*.pdf")):
results["audit30"] = None # N/A
print(status("📄", "Test audit_30", True, "PDFs non disponibles — SKIP"))
else:
# Quick check: essayer de charger le gazetteer via le core
try:
sys.path.insert(0, str(ROOT))
from anonymizer_core_refactored_onnx import _load_paranames_noms, _PARANAMES_NOMS_SET
loaded = _load_paranames_noms()
ok = len(loaded) > 0
results["audit30"] = ok
print(status("⚙️", "Chargement core OK", ok, f"{len(loaded):,} noms dans le core"))
# Vérifier que les noms gazetteer attendus sont reconnus
for test_nom in ["EJNAINI", "NGUYEN"]:
in_core = test_nom.upper() in loaded
print(status(f" {test_nom}", "Reconnu dans core", in_core))
except Exception as e:
results["audit30"] = False
print(status("⚙️", "Chargement core", False, str(e)))
# Summary
print("\n" + "=" * 60)
passed = sum(1 for v in results.values() if v is True)
failed = sum(1 for v in results.values() if v is False)
skipped = sum(1 for v in results.values() if v is None)
print(f" {passed} passed, {failed} failed, {skipped} skipped")
if failed > 0:
print(" ⚠️ Gazetteer nécessite corrections avant utilisation.")
return 1
print(" ✅ Gazetteer paranames validé.")
return 0
if __name__ == "__main__":
sys.exit(main())