diff --git a/data/paranames/noms_famille_world.txt.gz b/data/paranames/noms_famille_world.txt.gz index df0b8a1..2853f91 100644 Binary files a/data/paranames/noms_famille_world.txt.gz and b/data/paranames/noms_famille_world.txt.gz differ diff --git a/data/paranames/prenoms_world.txt.gz b/data/paranames/prenoms_world.txt.gz index bbb92f1..b77101b 100644 Binary files a/data/paranames/prenoms_world.txt.gz and b/data/paranames/prenoms_world.txt.gz differ diff --git a/data/stopwords_manuels.txt b/data/stopwords_manuels.txt index 9621514..cb0cc6f 100644 --- a/data/stopwords_manuels.txt +++ b/data/stopwords_manuels.txt @@ -1321,3 +1321,353 @@ biogaran mylan teva zentiva + +# --- Mots-outils français (spaCy fr STOP_WORDS, filtrés des patronymes INSEE fréquents) — ajout 2026-06-03 --- +# Sûrs car aucun n'est un patronyme plausible. Réduit les FP au contexte 'low' (is_in_insee/paranames). +abord +afin +ah +ai +aie +ainsi +allaient +allons +alors +anterieur +anterieure +anterieures +antérieur +antérieure +antérieures +apres +as +attendu +au +aupres +auquel +aura +auraient +aurait +auront +autrement +autrui +auxquelles +auxquels +avaient +avais +avait +avoir +avons +ayant +basee +ce +cela +celle-ci +celle-la +celle-là +celles-ci +celles-la +celles-là +celui +celui-ci +celui-la +celui-là +cent +cependant +certaine +certaines +certains +ceux +ceux-ci +ceux-là +chacune +chaque +ci +cinquantaine +cinquante +cinquantième +cinquième +combien +compris +concernant +da +de +dedans +desquelles +desquels +dessous +deuxième +deuxièmement +devra +different +differente +differentes +differents +différent +différente +différentes +différents +directe +directement +dit +dite +dits +diverse +diverses +dix +dix-huit +dix-sept +dixième +doivent +dont +douze +douzième +du +duquel +effet +egalement +eh +elle-meme +elle-même +elles-memes +elles-mêmes +en +enfin +envers +environ +es +et +etaient +etais +etait +etant +etc +eu +eux +eux-mêmes +exactement +excepté +faisaient +feront +ha +hep +hi +ho +hormis +houp +huit +huitième +hé +il +ils +importe +je +jusqu +jusque +la +laisser +laquelle +le +lequel +lesquelles +lesquels +leur +longtemps +lors +lorsque +lui +lui-meme +lui-même +là +lès +ma +maint +malgre +malgré +me +memes +merci +mes +mienne +miennes +moi-meme +moi-même +moindres +mêmes +na +ne +neanmoins +neuvième +ni +notamment +notre +nous +nous-mêmes +nul +néanmoins +nôtre +nôtres +on +ont +onze +onzième +or +ou +ouias +ouste +ouvert +ouverte +ouverts +où +parfois +parle +parlent +parler +parmi +partant +pense +permet +peut +peuvent +peux +plutot +plutôt +possible +possibles +pourquoi +pourrais +pourrait +pouvait +prealable +precisement +première +premièrement +pres +procedant +proche +près +préalable +précisement +pu +puisque +quand +quant +quant-à-soi +quatorze +quatre-vingt +quatrième +quatrièmement +quel +quelconque +quelle +quelles +quels +quiconque +quinze +quoi +quoique +relative +relativement +rend +rendre +restant +reste +restent +revoici +revoila +revoilà +sa +sait +sauf +se +semblable +semblaient +semble +semblent +sent +sept +septième +seraient +serait +seront +seul +seule +seulement +seules +seuls +si +sien +sienne +siennes +siens +sinon +sixième +soi +soi-meme +soi-même +soit +soixante +sont +specifique +specifiques +spécifique +spécifiques +stop +suffisant +suffisante +suis +suit +suivante +suivantes +suivants +suivre +surtout +ta +te +tellement +telles +tels +tend +tenir +tente +tes +tien +tienne +tiennes +tiens +toi-meme +toi-même +toujours +toute +toutes +treize +trente +tres +troisième +troisièmement +tu +té +un +unes +uns +va +vingt +voici +voila +voilà +voir +vont +votres +vous +vous-mêmes +vu +vé +vôtre +vôtres +ça +ès +également +étaient +étais +était +étant diff --git a/scripts/build_paranames_gazetteer.py b/scripts/build_paranames_gazetteer.py index 20ea2c2..a0da263 100644 --- a/scripts/build_paranames_gazetteer.py +++ b/scripts/build_paranames_gazetteer.py @@ -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 diff --git a/scripts/validate_paranames.py b/scripts/validate_paranames.py new file mode 100644 index 0000000..dcdd7b2 --- /dev/null +++ b/scripts/validate_paranames.py @@ -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())