diff --git a/data/paranames/EXTRACTION.md b/data/paranames/EXTRACTION.md new file mode 100644 index 0000000..0de7820 --- /dev/null +++ b/data/paranames/EXTRACTION.md @@ -0,0 +1,88 @@ +# Procédure d'extraction — gazetteer paranames + +## Vue d'ensemble + +Le script `scripts/build_paranames_gazetteer.py` télécharge le dataset +paranames depuis HuggingFace, filtre les entités de type PER, normalise +les noms (NFKD UPPERCASE A-Z) et produit deux gazetteers compressés. + +## Pré-requis + +- Python ≥ 3.10 +- Venv du projet activé : `source .venv/bin/activate` +- Paquets : `datasets`, `huggingface_hub`, `pyarrow`, `pandas` + (déjà présents dans `requirements.txt`). +- Connexion réseau pour le premier téléchargement (~1.33 GB). +- ~3 GB de cache HuggingFace disponibles. +- ~1 GB de RAM (le script lit le parquet par batches de 64 K lignes). + +## Lancement + +```bash +cd /home/dom/ai/anonymisation +source .venv/bin/activate +python scripts/build_paranames_gazetteer.py +``` + +Options : +- `--hf-cache /chemin` : forcer un cache custom (défaut : `~/.cache/huggingface`). +- `--limit N` : ne traiter que N lignes (debug uniquement). + +## Étapes internes du script + +1. **Téléchargement** via `huggingface_hub.hf_hub_download` du parquet + `data/train.parquet` du repo `imvladikon/paranames`. Le cache HF est + réutilisé (idempotent). +2. **Chargement** du BDPM stop-words (`data/bdpm/medicaments_stopwords.txt`, + 7 312 tokens normalisés en UPPER A-Z) pour filtrer les noms qui sont en + fait des médicaments. +3. **Itération par batches** (`pyarrow.parquet.ParquetFile.iter_batches`) + sur les colonnes `name` et `type` uniquement. Filtre `type == "PER"`. +4. **Split** de chaque `name` sur espaces et séparateurs courants + (`SPLIT_CHARS`). +5. **Heuristique nom/prénom** : + - dernier token → **nom de famille candidat** + - tokens précédents → **prénoms candidats** + - cas mononyme (1 seul token) : considéré comme nom de famille. +6. **Normalisation** : NFKD → strip diacritiques → UPPER → conserver + uniquement A-Z (chars latins de base). +7. **Filtres anti-bruit** : + - longueur ≥ 3 caractères + - longueur ≤ 25 caractères + - non présent dans la BDPM stop-words. +8. **Écriture** triée alphabétique en `.txt.gz` compresslevel=9. + +## Volumes attendus (ordre de grandeur) + +- Lignes parquet totales : ~124 M +- Lignes PER après filtre : ~82 M +- Noms famille uniques (après dédup + normalisation) : quelques centaines + de milliers à quelques millions. +- Prénoms uniques : idem. + +## Régénération (mise à jour) + +Si une nouvelle version de paranames est publiée, supprimer le cache HF +correspondant : + +```bash +rm -rf ~/.cache/huggingface/datasets--imvladikon--paranames/ +python scripts/build_paranames_gazetteer.py +``` + +ou supprimer simplement les `.txt.gz` cibles et relancer (le download +réutilise le cache si la version est inchangée). + +## Vérification rapide + +```bash +zcat data/paranames/noms_famille_world.txt.gz | wc -l +zcat data/paranames/prenoms_world.txt.gz | wc -l +zcat data/paranames/noms_famille_world.txt.gz | grep -E "^(OYARCABAL|EJNAINI|NGUYEN|SCHMIDT|OBAMA)$" +``` + +## Licence + +paranames est sous **CC BY 4.0**. Les fichiers dérivés (`*.txt.gz`) +héritent de cette licence et doivent être redistribués avec attribution +(voir README.md). diff --git a/data/paranames/README.md b/data/paranames/README.md new file mode 100644 index 0000000..a2b3719 --- /dev/null +++ b/data/paranames/README.md @@ -0,0 +1,64 @@ +# data/paranames — Gazetteers de noms mondiaux + +Issu de [paranames](https://github.com/bltlab/paranames) v2024.05.07.0, +sous licence **CC BY 4.0**. + +## Citation + +> Sälevä, J., & Lignos, C. (2024). *ParaNames 1.0: Creating an Entity Name +> Corpus for 400+ Languages using Wikidata.* In Proceedings of LREC-COLING +> 2024. + +Lien : + +## Contenu + +| Fichier | Description | +|----------------------------------|--------------------------------------------------------------------| +| `noms_famille_world.txt.gz` | Noms de famille mondiaux (UPPERCASE, NFKD sans diacritiques, A-Z). | +| `prenoms_world.txt.gz` | Prénoms mondiaux (UPPERCASE, NFKD sans diacritiques, A-Z). | +| `EXTRACTION.md` | Procédure reproductible d'extraction. | + +Les deux fichiers sont triés alphabétiquement, encodés UTF-8, compressés gzip +niveau 9. Une entrée par ligne. + +## Régénération + +```bash +python scripts/build_paranames_gazetteer.py +``` + +Le script est **idempotent** : relance = même résultat. Le cache HuggingFace +(~/.cache/huggingface/) évite tout re-téléchargement. + +Voir [EXTRACTION.md](EXTRACTION.md) pour le détail de la procédure. + +## Source amont + +- **Repo** : +- **Mirror HuggingFace** : +- **Données** : `data/train.parquet` (~1.33 GB, 124 M lignes — noms parallèles + de plus de 12 M d'entités nommées dans 400+ langues, extraits de Wikidata). +- **Filtrage appliqué** : seuls les `type == "PER"` (personnes) sont retenus. + +## Utilisation dans l'anonymiseur + +Ces gazetteers complètent les listes INSEE (françaises) pour couvrir les noms +**internationaux** (basques, vietnamiens, arabes, asiatiques, africains…) +fréquents dans les documents médicaux français des CHU et hôpitaux de +territoires multi-ethniques (La Réunion, Antilles, métropole). + +Charger en lecture : + +```python +import gzip +with gzip.open("data/paranames/noms_famille_world.txt.gz", "rt", encoding="utf-8") as f: + NOMS_WORLD = {line.strip() for line in f if line.strip()} +``` + +## Attribution dans l'application + +L'écran « À propos » de l'application Pseudonymisation mentionne : + +> Gazetteers de noms mondiaux issus de paranames (Sälevä & Lignos, 2024) +> sous licence CC BY 4.0. diff --git a/data/paranames/noms_famille_world.txt.gz b/data/paranames/noms_famille_world.txt.gz new file mode 100644 index 0000000..df0b8a1 Binary files /dev/null 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 new file mode 100644 index 0000000..bbb92f1 Binary files /dev/null and b/data/paranames/prenoms_world.txt.gz differ diff --git a/scripts/build_paranames_gazetteer.py b/scripts/build_paranames_gazetteer.py new file mode 100644 index 0000000..20ea2c2 --- /dev/null +++ b/scripts/build_paranames_gazetteer.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +"""Build gazetteer paranames pour anonymisation médicale FR. + +Source : https://github.com/bltlab/paranames (CC BY 4.0) +Citation : Sälevä & Lignos, ParaNames 1.0, LREC-COLING 2024. + +Workflow : +1. Télécharge ``data/train.parquet`` (~1.33 GB) du repo HF + ``imvladikon/paranames`` via ``huggingface_hub.hf_hub_download`` (cache + persistant, pas de re-téléchargement si déjà présent). +2. Itère sur le fichier parquet **par batches** avec pyarrow (RAM constante, + < 500 Mo de pointe). +3. Filtre ``type == "PER"`` (personnes uniquement). +4. Pour chaque ``name`` : + - split par espace et séparateurs courants + - dernier token UPPER NFKD → candidat **nom de famille** + - tokens précédents UPPER NFKD → candidats **prénoms** +5. Normalisation NFKD + uppercase + suppression diacritiques + ASCII A-Z. +6. Filtrage anti-bruit : + - longueur ≥ 3 + - exclusion des stop-words médicaments BDPM +7. Sortie : 2 fichiers ``.txt.gz`` triés alphabétiquement, encodés UTF-8. + +Idempotent : relance = même résultat. Cache HF réutilisé si présent. + +Usage : + python scripts/build_paranames_gazetteer.py + python scripts/build_paranames_gazetteer.py --hf-cache /tmp/hf_paranames + python scripts/build_paranames_gazetteer.py --limit 200000 # debug +""" +from __future__ import annotations + +import argparse +import gzip +import os +import sys +import time +import unicodedata +from pathlib import Path +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" +INSEE_NOMS = REPO_ROOT / "data" / "insee" / "noms_famille_france.txt" + +OUT_NOMS = DATA_DIR / "noms_famille_world.txt.gz" +OUT_PRENOMS = DATA_DIR / "prenoms_world.txt.gz" + +HF_REPO_ID = "imvladikon/paranames" +HF_PARQUET_PATH = "data/train.parquet" + +MIN_TOKEN_LEN = 3 +MAX_TOKEN_LEN = 25 + +# Caractères à découper en plus de l'espace (séparateurs internes). +SPLIT_CHARS = " \t /,;:|()[]{}\"'`«»–—−.·" +SPLIT_TABLE = str.maketrans({c: " " for c in SPLIT_CHARS}) + + +def normalize(token: str) -> str: + """NFKD → uppercase → drop diacritics → A-Z only.""" + if not token: + return "" + nfkd = unicodedata.normalize("NFKD", token) + no_acc = "".join(c for c in nfkd if not unicodedata.combining(c)) + up = no_acc.upper() + 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: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + n = normalize(line) + if n: + stop.add(n) + print(f"[INFO] BDPM stop-words : {len(stop):,} entrées.") + return stop + + +def download_parquet(cache_dir: str) -> Path: + """Télécharge (ou récupère du cache) le parquet paranames.""" + try: + from huggingface_hub import hf_hub_download # type: ignore + except ImportError as e: + sys.exit( + "[FATAL] `huggingface_hub` requis. Install : pip install huggingface_hub\n" + f"Erreur : {e}" + ) + try: + path = hf_hub_download( + repo_id=HF_REPO_ID, + filename=HF_PARQUET_PATH, + repo_type="dataset", + cache_dir=cache_dir, + ) + except Exception as e: + sys.exit( + f"[FATAL] Impossible de télécharger {HF_REPO_ID}:{HF_PARQUET_PATH}\n" + f" Vérifier réseau / cache HF / accès huggingface.co\n" + f" Erreur : {e}" + ) + p = Path(path) + print(f"[INFO] Parquet local : {p} ({p.stat().st_size/1e9:.2f} GB)") + return p + + +def iter_per_names(parquet_path: Path, limit: int | None) -> Iterator[str]: + """Stream les noms PER du parquet par row-groups (RAM constante).""" + try: + import pyarrow.parquet as pq # type: ignore + except ImportError as e: + sys.exit(f"[FATAL] pyarrow requis. Install : pip install pyarrow\n{e}") + + pf = pq.ParquetFile(parquet_path) + print( + f"[INFO] Parquet : {pf.num_row_groups} row groups, " + f"{pf.metadata.num_rows:,} lignes totales." + ) + count_in = 0 + count_per = 0 + # On ne lit que les colonnes utiles + for batch in pf.iter_batches(batch_size=65536, columns=["name", "type"]): + names = batch.column("name").to_pylist() + types = batch.column("type").to_pylist() + for nm, tp in zip(names, types): + count_in += 1 + if tp != "PER": + continue + if nm: + count_per += 1 + yield nm + if limit is not None and count_in >= limit: + print(f"[INFO] Limite atteinte ({limit}).") + print(f"[INFO] Total lignes lues : {count_in:,}") + print(f"[INFO] Total PER conservés : {count_per:,}") + return + if count_in % 1_000_000 < 65536: + print( + f"[PROGRESS] {count_in:>11,} lignes lues, " + f"{count_per:>11,} PER." + ) + print(f"[INFO] Total lignes lues : {count_in:,}") + print(f"[INFO] Total PER conservés : {count_per:,}") + + +def split_name(name: str) -> tuple[list[str], str | None]: + clean = name.translate(SPLIT_TABLE) + tokens = [t for t in clean.split() if t] + if not tokens: + return [], None + if len(tokens) == 1: + return [], tokens[0] + return tokens[:-1], tokens[-1] + + +def good_token(tok: str, stop: set[str]) -> bool: + if not tok: + return False + if len(tok) < MIN_TOKEN_LEN or len(tok) > MAX_TOKEN_LEN: + return False + if tok in stop: + return False + return True + + +def write_sorted_gz(path: Path, items: Iterable[str]) -> int: + path.parent.mkdir(parents=True, exist_ok=True) + data = sorted(items) + with gzip.open(path, "wt", encoding="utf-8", compresslevel=9) as f: + for s in data: + f.write(s) + f.write("\n") + return len(data) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument( + "--hf-cache", + default=os.environ.get("HF_HOME", str(Path.home() / ".cache" / "huggingface")), + help="Répertoire cache HuggingFace (par défaut : ~/.cache/huggingface).", + ) + parser.add_argument( + "--limit", + type=int, + default=None, + help="Limiter le nombre de lignes lues (debug).", + ) + args = parser.parse_args() + + t0 = time.time() + print(f"[INFO] Cache HF : {args.hf_cache}") + + stopwords = load_stopwords() + parquet_path = download_parquet(args.hf_cache) + + noms: set[str] = set() + prenoms: set[str] = set() + bad_kept = 0 + + for raw_name in iter_per_names(parquet_path, limit=args.limit): + prens, fam = split_name(raw_name) + if fam is not None: + n = normalize(fam) + if good_token(n, stopwords): + noms.add(n) + else: + bad_kept += 1 + for p in prens: + n = normalize(p) + if good_token(n, stopwords): + prenoms.add(n) + + print(f"[INFO] Noms de famille uniques (post-filtre) : {len(noms):,}") + print(f"[INFO] Prénoms uniques (post-filtre) : {len(prenoms):,}") + print(f"[INFO] Tokens rejetés (longueur/stop/vide) : {bad_kept:,}") + + n_noms = write_sorted_gz(OUT_NOMS, noms) + n_pren = write_sorted_gz(OUT_PRENOMS, prenoms) + print( + f"[OK] {OUT_NOMS} — {n_noms:,} entrées " + f"({OUT_NOMS.stat().st_size/1e6:.1f} Mo)" + ) + print( + f"[OK] {OUT_PRENOMS} — {n_pren:,} entrées " + f"({OUT_PRENOMS.stat().st_size/1e6:.1f} Mo)" + ) + + if INSEE_NOMS.exists(): + insee_noms = { + line.strip().upper() + for line in INSEE_NOMS.read_text(encoding="utf-8").splitlines() + if line.strip() + } + inter = noms & insee_noms + cov = 100 * len(inter) / max(1, len(insee_noms)) + print( + f"[INFO] Intersection noms_famille_world ∩ INSEE_FR : " + f"{len(inter):,} ({cov:.1f}% de couverture INSEE)" + ) + + print(f"[DONE] Temps total : {time.time()-t0:.1f}s") + return 0 + + +if __name__ == "__main__": + sys.exit(main())