From eb14cd219da3b6b6ff01975bf93935fed65f99fd Mon Sep 17 00:00:00 2001 From: Domi31tls Date: Wed, 11 Mar 2026 12:16:13 +0100 Subject: [PATCH] =?UTF-8?q?feat(phase3):=20CamemBERT=20v3=20+=20d=C3=A9tec?= =?UTF-8?q?tion=20villes=20+=20initiales=20+=20texte=20espac=C3=A9=20+=20d?= =?UTF-8?q?ocs=20r=C3=A9glementaires?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intégration du modèle CamemBERT-bio-deid v3 (F1=0.96, Recall=0.97, 1112 docs) et corrections qualité issues de l'audit approfondi sur 29 fichiers. Détection des villes en texte libre : - Automate Aho-Corasick sur 33K communes INSEE + 11.6K villes FINESS - Stratégie contextuelle : exige un contexte géographique (à, de, vers, habite, urgences de, etc.) sauf pour les villes composées (Saint-Palais) - Blacklist de ~80 communes homonymes de mots courants (charge, signes, plan...) - Normalisation SAINT↔ST pour les variantes orthographiques - De 18 fuites de villes à 2 cas résiduels atypiques Masquage des initiales de prénom : - Post-traitement regex : "Dr T. [NOM]" → "Dr [NOM] [NOM]" - Références initiales : "Ref : JF/VA" → "Ref : [NOM]/[NOM]" Détection texte espacé d'en-tête : - "C E N T R E H O S P I T A L I E R" → [ETABLISSEMENT] Autres corrections : - Fix regex RE_EXTRACT_MME_MR (Mr?.? → Mr.?, \s+ → [ \t]+, * → {0,4}) - Stop words médicaux : lever, coucher, services hospitaliers (viscérale, etc.) - CamemBERT NER manager : version tracking, propriété version, log F1/Recall - Script finetune : export ONNX automatique + mise à jour VERSION.json - Évaluateur qualité : exclusion stop words médicaux des alertes INSEE Documentation : - Spécifications techniques CamemBERT-bio-deid v3 - Conformité RGPD + AI Act (caviardage PDF raster) - AIPD (Analyse d'Impact Protection des Données) Score qualité : 97.0/100 (Grade A), Leak score 100/100 Co-Authored-By: Claude Opus 4.6 --- anonymizer_core_refactored_onnx.py | 343 ++++++++++++++- camembert_ner_manager.py | 31 +- docs/AIPD-anonymisation.md | 258 +++++++++++ docs/camembert-bio-deid-v3-specs.md | 77 ++++ docs/conformite-rgpd-ia-act.md | 235 ++++++++++ evaluation/baseline_scores.json | 254 +++++++++++ scripts/evaluate_quality.py | 654 ++++++++++++++++++++++++++++ scripts/finetune_camembert_bio.py | 114 ++++- 8 files changed, 1957 insertions(+), 9 deletions(-) create mode 100644 docs/AIPD-anonymisation.md create mode 100644 docs/camembert-bio-deid-v3-specs.md create mode 100644 docs/conformite-rgpd-ia-act.md create mode 100644 evaluation/baseline_scores.json create mode 100644 scripts/evaluate_quality.py diff --git a/anonymizer_core_refactored_onnx.py b/anonymizer_core_refactored_onnx.py index 1fc62fc..277608c 100644 --- a/anonymizer_core_refactored_onnx.py +++ b/anonymizer_core_refactored_onnx.py @@ -156,6 +156,48 @@ _FINESS_ETAB_NAMES: set = set() # noms d'établissements (lowercase) _FINESS_TELEPHONES: set = set() # téléphones 10 chiffres _FINESS_VILLES: set = set() # villes FINESS (uppercase) _FINESS_AC = None # Automate Aho-Corasick pour noms distinctifs +_VILLE_AC = None # Automate Aho-Corasick pour villes (INSEE + FINESS) + +# Communes trop ambiguës (homonymes de mots courants, trop courts, etc.) +_VILLE_BLACKLIST = { + # Directions / mots géographiques génériques + "SAINT", "NORD", "SUD", "EST", "OUEST", + "CENTRE", "SERVICE", "BOURG", + # Communes homonymes de mots courants français + "ORANGE", "TOURS", "NICE", "SENS", "VITRE", + "ROMANS", "MENTON", "SALON", "VIENNE", + "BREST", # trop court et ambigu + "HYERES", # proche de termes médicaux + "AGEN", "AUCH", "ALBI", + "BLOIS", "LAON", "LENS", + "GIEN", "GRAY", + "AIRE", "LURE", "SETE", "DOLE", + "VIRE", "LUNEL", "MURET", "MORET", + "COEUR", "FOIX", "GIVET", + "EVIAN", "MAURE", "MENDE", + "JOUE", "MEAUX", "REDON", + "CREIL", "CERGY", + # Communes de 4-5 lettres homonymes de mots très courants + "VERS", "MONT", "MARS", "PORT", "PONT", "FORT", + "BOIS", "ISLE", "LACS", "MURS", "OUST", "PREY", + "VAUX", "VERT", "FAUX", "REZE", + "BILLE", "PLACE", "VILLE", "COURS", "GRAND", + "ROUGE", "RICHE", "NUITS", "SORE", "SARE", + "TRANS", "RANS", "MARSA", + # Mots courants français (6+ lettres) aussi communes + "CHARGE", "SIGNES", "BARRES", "FOSSES", "GARDES", + "MARCHE", "LIGNES", "MOULIN", "PIERRE", "CHAISE", + "SOURCE", "VALLEE", "MAISON", "BEAUNE", "CORPS", + "PUITS", "CROIX", "LIGNE", "QUATRE", "PRISON", + # Prénoms très courants (aussi communes) + "MARIE", "PIERRE", "JEAN", "PAUL", "ANNE", + # Expressions composées ambiguës (aussi communes INSEE) + "LONG", "RECY", "PLAN", "MARCHE", "SALLE", + "CONTRE", "MERE", "ONDRES", "VEBRE", + # Mots structurels / médicaux + "PARIS", # omniprésent, source de faux positifs + "FRANCE", "EUROPE", +} try: import ahocorasick as _ahocorasick @@ -537,6 +579,8 @@ _MEDICAL_STOP_WORDS_SET = { "digestif", "digestive", "digestives", "nutritive", # Abréviations soins trackare détectées comme NOM (batch 20 OGC) "soins", "lit", "jeun", "lever", "pose", "surv", "ggt", "vvp", + # Verbes d'instructions soins (aussi des patronymes INSEE → FP) + "coucher", "manger", "marcher", "sortir", "verif", "crop", "evs", "maco", "pan", "cet", "trou", "nit", "nfs", # Mots narratifs CRH capturés par fusion sidebar 2-colonnes "evolution", "évolution", "explorations", "fermeture", "allergie", "allergies", @@ -671,6 +715,11 @@ _MEDICAL_STOP_WORDS_SET = { "probnp", "pro-bnp", "nt-probnp", "bpco", "colle", "gsc", "masse", "selle", "selles", + # Noms de services hospitaliers (aussi patronymes INSEE → FP récurrents) + "viscerale", "viscérale", "vasculaire", "vasculaires", + "conventionnelle", "conventionnel", + "polyvalente", "polyvalent", + "infectieuse", "infectieuses", } # Enrichissement automatique avec les ~4000 noms de médicaments d'edsnlp _MEDICAL_STOP_WORDS_SET.update(_load_edsnlp_drug_names()) @@ -741,9 +790,9 @@ RE_EXTRACT_REDIGE = re.compile( # Token nom composé : JEAN-PIERRE, CAZELLES-BOUDIER, etc. _UC_COMPOUND = r"[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]{2,}(?:-[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]{2,})*" RE_EXTRACT_MME_MR = re.compile( - r"(?:MME|Mme|Madame|Monsieur|Mr?\.?)\s+" + r"(?:MME|Mme|Madame|Monsieur|Mr\.?)\s+" r"(?:[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]\.\s*(?:-?\s*[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]\.\s*)?)?" - rf"((?:{_UC_COMPOUND})(?:\s+(?:{_UC_COMPOUND}))*)", + rf"((?:{_UC_NAME_TOKEN})(?:[ \t]+(?:{_UC_NAME_TOKEN})){{0,4}})", ) _INITIAL_OPT = r"(?:[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]\.\s*(?:-?\s*[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]\.\s*)?)?" RE_EXTRACT_DR_DEST = re.compile( @@ -772,6 +821,11 @@ RE_EXTRACT_OPERATEUR = re.compile( + _INITIAL_OPT + rf"((?:{_UC_COMPOUND})(?:[ \t]+(?:{_UC_COMPOUND})){{0,2}})", ) +# En-tête "Courrier Epi - NOM, PRENOM" (lettres de sortie) +RE_EXTRACT_COURRIER = re.compile( + r"Courrier\s+(?:Epi|Ep[ée]ph[ée]m[eé]ride|Hospit)\s*[\-–]\s*" + rf"((?:{_UC_NAME_TOKEN})(?:\s*,\s*(?:{_UC_NAME_TOKEN}))*)", +) # Téléphone avec extension slash : 05.59.44.38.32/34 RE_TEL_SLASH = re.compile( r"(? str: full_match = m.group(0) @@ -1765,6 +1848,13 @@ def _extract_document_names(full_text: str, cfg: Dict[str, Any]) -> Tuple[set, s # Opérateur / Anesthésiste / Chirurgien + nom(s) for m in RE_EXTRACT_OPERATEUR.finditer(full_text): _add_tokens_force_first(m.group(1)) + # En-tête "Courrier Epi - NOM, PRENOM" (lettres de sortie) + for m in RE_EXTRACT_COURRIER.finditer(full_text): + # Format "NOM, PRENOM" : chaque partie est un token de nom + for part in m.group(1).split(","): + part = part.strip() + if part: + _add_tokens_force_all(part) # Extraction des noms dans les listes virgulées après Dr/Docteur # ex: "le Dr DUVAL, MACHELART, CHARLANNE, LAZARO, il a été proposé" @@ -1785,12 +1875,16 @@ def _extract_document_names(full_text: str, cfg: Dict[str, Any]) -> Tuple[set, s # Pour les noms composés avec tiret (ex: "LACLAU-LACROUTS"), # ajouter aussi les parties individuelles pour capturer les occurrences standalone. # _apply_extracted_names traite le composé en premier (plus long) puis les parties. + # Les parties sont forcées (bypass stop words) car le composé lui-même est un nom + # confirmé — ex: "BILLON-GRAND" → "GRAND" doit être masqué même si "grand" est + # un mot courant, car c'est un composant d'un nom de personne détecté. compound_names = {n for n in names if "-" in n} for compound in compound_names: for part in compound.split("-"): part = part.strip() - if len(part) >= 3 and part.lower() not in _MEDICAL_STOP_WORDS_SET: + if len(part) >= 3: names.add(part) + force_names.add(part) return names, force_names @@ -1817,9 +1911,17 @@ def _apply_extracted_names(text: str, names: set, audit: List[PiiHit], force_nam # Ne pas remplacer si le token fait partie d'un mot composé (tiret + lettre) # Ex: "NOCENT-EJNAINI" → ne pas remplacer NOCENT seul # Mais "LACLAU-" (tiret de troncature) → remplacer + # Gère aussi le cas cross-line : "BILLON-\nGRAND" (nom intact) + # mais pas "[NOM]-\nGRAND" (déjà partiellement masqué → on remplace) if m.start() > 0 and text[m.start() - 1] == "-": if m.start() >= 2 and text[m.start() - 2].isalpha(): continue + # Cross-line: "\n" juste avant, tiret avant le "\n", lettre avant le tiret + if m.start() > 1 and text[m.start() - 1] == "\n" and text[m.start() - 2] == "-": + pre_pos = m.start() - 3 + if pre_pos >= 0 and text[pre_pos].isalpha(): + continue # Composé intact (BILLON-\nGRAND) → skip + # Si le tiret est après un placeholder ([NOM]-\nGRAND) → on remplace if m.end() < len(text) and text[m.end()] == "-": if m.end() + 1 < len(text) and text[m.end() + 1].isalpha(): continue @@ -2280,6 +2382,197 @@ def _mask_finess_establishments(text: str) -> str: return "".join(result) +# ----------------- Ville Aho-Corasick gazetteer matching ----------------- + +def _build_ville_ac(): + """Construit l'automate Aho-Corasick pour les villes (INSEE + FINESS). + + Appelé en lazy au premier besoin. + Les noms sont normalisés sans accents (position-preserving matching via _normalize_positional). + NOTE : on ne filtre PAS par _MEDICAL_STOP_WORDS_SET car ces villes y ont été ajoutées + pour empêcher leur détection comme NOMS DE PERSONNES, pas pour empêcher leur détection + comme villes. Le filtrage anti-faux-positifs se fait via _VILLE_BLACKLIST et le seuil + de longueur minimale. + """ + global _VILLE_AC + if not _AHO_AVAILABLE: + return + + # Combiner les deux sources de villes + all_villes: set = set() + if _INSEE_COMMUNES: + all_villes.update(_INSEE_COMMUNES) + if _FINESS_VILLES: + all_villes.update(v.upper() for v in _FINESS_VILLES) + + if not all_villes: + log.warning("Aucune ville disponible pour l'automate Aho-Corasick VILLE") + return + + try: + ac = _ahocorasick.Automaton() + count = 0 + added_normalized: set = set() # éviter les doublons après normalisation + for ville in all_villes: + ville = ville.strip() + if not ville: + continue + # Blacklist de communes ambiguës + if ville.upper() in _VILLE_BLACKLIST: + continue + # Les noms composés dans les gazetteers utilisent des espaces ("MONT DE MARSAN") + # mais dans les textes ils apparaissent souvent avec des tirets ("Mont-de-Marsan"). + # On ajoute les deux variantes dans l'automate. + words = ville.split() + # Filtre longueur minimale (mono-mot < 4 chars → trop ambigu) + # Exception : quelques villes de 3 lettres notables + _VILLE_3CHAR_ALLOW = {"DAX", "PAU", "GAP", "APT", "GEX", "LUZ"} + if len(words) == 1 and len(ville) < 4 and ville.upper() not in _VILLE_3CHAR_ALLOW: + continue + # Normaliser sans accents, en lowercase (pour matching positionnel) + normalized_ville = _normalize_positional(ville) + if normalized_ville not in added_normalized: + ac.add_word(normalized_ville, (normalized_ville, ville)) + added_normalized.add(normalized_ville) + count += 1 + + def _add_variant(variant_norm: str) -> None: + nonlocal count + if variant_norm and variant_norm not in added_normalized: + ac.add_word(variant_norm, (variant_norm, ville)) + added_normalized.add(variant_norm) + count += 1 + + # Variante avec tirets pour les noms composés (ex: "mont de marsan" → "mont-de-marsan") + if len(words) >= 2: + _add_variant(_normalize_positional("-".join(words))) + # Variante SAINT ↔ ST (gazetteers INSEE utilisent "ST", textes "Saint") + for prefix_src, prefix_dst in [("ST ", "SAINT "), ("ST ", "SAINT-"), + ("SAINT ", "ST "), ("SAINT ", "ST-"), + ("STE ", "SAINTE "), ("STE ", "SAINTE-"), + ("SAINTE ", "STE "), ("SAINTE ", "STE-")]: + if ville.startswith(prefix_src): + alt = prefix_dst + ville[len(prefix_src):] + _add_variant(_normalize_positional(alt)) + _add_variant(_normalize_positional("-".join(alt.split()))) + ac.make_automaton() + _VILLE_AC = ac + log.info(f"Gazetteer VILLE Aho-Corasick: {count} patterns chargés " + f"(INSEE: {len(_INSEE_COMMUNES)}, FINESS: {len(_FINESS_VILLES)})") + except Exception as e: + log.warning(f"Erreur construction VILLE Aho-Corasick: {e}") + + +def _mask_ville_gazetteers(text: str) -> tuple: + """Masque les villes détectées par Aho-Corasick dans le texte narratif. + + Stratégie contextuelle : pour éviter les faux positifs massifs (CHARGE, SIGNES, + TALON — communes homonymes de mots courants), on ne masque une ville que si : + - C'est une ville composée (Saint-Palais), OU + - C'est une ville très longue (>= 8 lettres : Bordeaux, Toulouse), OU + - Elle apparaît dans un contexte géographique explicite (à, de, vers, habite, etc.) + + Returns: (texte_masqué, liste_des_valeurs_originales_masquées) + """ + global _VILLE_AC + if _VILLE_AC is None: + _build_ville_ac() + if _VILLE_AC is None: + return text + + normalized = _normalize_positional(text) + placeholder = PLACEHOLDERS["VILLE"] + + # Contextes géographiques avant une ville + # NOTE : "de" seul est trop ambigu ("prise de selles", "nombre de jumeaux") + # On exige "de" uniquement après un verbe/nom géographique ou une préposition composée + _RE_GEO_BEFORE = re.compile( + r"(?:" + # Préposition "à" (très spécifique géographiquement) + r"[àÀ]\s+|" + # "de" seulement dans un contexte géographique (vient de, originaire de, etc.) + r"(?:vient|venant|arrivant|provenant|originaire|issu(?:e)?)\s+(?:de\s+|d['']\s*)|" + # "urgences de", "hôpital de", "clinique de" + r"(?:urgences?|h[oô]pital|clinique|CHU?|CH\b)\s+(?:de\s+|d['']\s*)|" + # Verbes de localisation directement suivis de la ville + r"(?:habit|résid|viv|domicilié(?:e)?|transféré(?:e)?|" + r"adressé(?:e)?|hospitalisé(?:e)?|opéré(?:e)?|" + r"Fait)\s+(?:à\s+|de\s+|d['']\s*)?|" + # "vers" (prép. géo directe) — NOTE: "sur" exclu car trop ambigu ("sur le plan") + r"vers\s+|" + # Après code postal ou parenthèse ouvrante (adresse) + r"\[CODE_POSTAL\]\s*|" + r"\(\s*|" + # Contextes médicaux spécifiques d'adressage + r"(?:urg(?:ences?)?\s+)" + r")\s*$", + re.I, + ) + + # Collecter les matches Aho-Corasick + matches = [] + for end_idx, (norm_name, orig_name) in _VILLE_AC.iter(normalized): + start_idx = end_idx - len(norm_name) + 1 + # Vérifier frontières de mots (pas au milieu d'un mot) + if start_idx > 0 and normalized[start_idx - 1].isalnum(): + continue + if end_idx + 1 < len(normalized) and normalized[end_idx + 1].isalnum(): + continue + # Vérifier que ce n'est pas déjà dans un placeholder [...] + ctx_before = text[max(0, start_idx - 1):start_idx] + ctx_after = text[end_idx + 1:min(len(text), end_idx + 2)] + if "[" in ctx_before or "]" in ctx_after: + continue + # Vérifier proximité placeholder (pas juste après [ETABLISSEMENT] de ...) + wide_before = text[max(0, start_idx - 25):start_idx] + if re.search(r"\[(VILLE|ADRESSE|ETABLISSEMENT)\]\s*(?:de\s+|du\s+|d['']\s*|à\s+)?$", wide_before): + continue + # Récupérer le texte original à cette position + original_span = text[start_idx:end_idx + 1] + word_count = len(orig_name.split()) + word_len = len(orig_name.strip()) + # Stratégie contextuelle pour éviter les FP : + # TOUJOURS exiger un contexte géographique (à, de, vers, habite, etc.) + # sauf pour les villes composées avec trait d'union (Saint-Palais, + # Mont-de-Marsan) qui sont très peu ambiguës. + is_compound_hyphen = ("-" in original_span and word_count >= 2) + if not is_compound_hyphen: + before_ctx = text[max(0, start_idx - 40):start_idx] + if not _RE_GEO_BEFORE.search(before_ctx): + continue + matches.append((start_idx, end_idx + 1, original_span)) + + if not matches: + return text, [] + + # Dédupliquer : préférer le match le plus long en cas de chevauchement + # Trier par longueur décroissante, puis sélectionner gloutonement les non-chevauchants + matches.sort(key=lambda x: -(x[1] - x[0])) + deduped = [] + for start, end, orig in matches: + # Vérifier que cet intervalle ne chevauche pas un intervalle déjà retenu + if any(s < end and start < e for s, e, _ in deduped): + continue + deduped.append((start, end, orig)) + # Re-trier par position pour la reconstruction + deduped.sort(key=lambda x: x[0]) + + # Reconstruire le texte avec les remplacements + result = [] + masked_originals = [] + last_pos = 0 + for start, end, orig in deduped: + if start > len(text) or end > len(text): + continue + result.append(text[last_pos:start]) + result.append(placeholder) + masked_originals.append(orig) + last_pos = end + result.append(text[last_pos:]) + + return "".join(result), masked_originals + + # ----------------- Selective safety rescan ----------------- def selective_rescan(text: str, cfg: Dict[str, Any] | None = None) -> str: @@ -2329,6 +2622,21 @@ def selective_rescan(text: str, cfg: Dict[str, Any] | None = None) -> str: # Établissements (gazetteer Aho-Corasick FINESS — 116K noms distinctifs) if _FINESS_AC is not None: protected = _mask_finess_establishments(protected) + # Texte espacé d'en-tête : "C E N T R E H O S P I T A L I E R" → [ETABLISSEMENT] + _re_spaced = re.compile(r'(?:[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]\s){4,}[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]') + _spaced_kw = {"HOSPITALIER", "HOSPITALIERE", "HOSPITALIERES", "HOSPITALIERS", + "CLINIQUE", "HOPITAL", "HÔPITAL", "POLYCLINIQUE", + "CENTRE", "ETABLISSEMENT", "MAISON", "RESIDENCE", + "EHPAD", "SSR", "USLD", "CHU", "CHRU"} + for m_sp in _re_spaced.finditer(protected): + collapsed = m_sp.group(0).replace(" ", "").upper() + if any(kw in collapsed for kw in _spaced_kw): + protected = protected.replace(m_sp.group(0), PLACEHOLDERS["ETAB"], 1) + # Villes (gazetteer Aho-Corasick — INSEE + FINESS) + if _VILLE_AC is None: + _build_ville_ac() + if _VILLE_AC is not None: + protected, _ = _mask_ville_gazetteers(protected) # Services hospitaliers protected = RE_SERVICE.sub(PLACEHOLDERS["MASK"], protected) # Lieu de naissance / Ville de résidence (accepte tout : villes, codes INSEE, minuscules) @@ -2355,6 +2663,15 @@ def selective_rescan(text: str, cfg: Dict[str, Any] | None = None) -> str: return raw return raw.replace(span, PLACEHOLDERS["NOM"]) protected = RE_PERSON_CONTEXT.sub(_rescan_person, protected) + # Initiales identifiantes devant [NOM] : "Dr T. [NOM]" → "Dr [NOM] [NOM]" + _re_init_nom = re.compile(r'\b([A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ])\.[\s\-]*(\[NOM\])') + protected = _re_init_nom.sub(r'[NOM] \2', protected) + # Références initiales : "Ref : JF/VA" → "Ref : [NOM]/[NOM]" + _re_ref_init = re.compile(r'(?:Ref\s*:\s*|Réf\s*:\s*)([A-Z]{1,3})\s*/\s*([A-Z]{1,3})\b') + protected = _re_ref_init.sub( + lambda m: m.group(0)[:m.group(0).index(m.group(1))] + PLACEHOLDERS["NOM"] + "/" + PLACEHOLDERS["NOM"], + protected, + ) res = list(protected) for start, end, payload in kept: res[start:end] = list(payload) @@ -2772,6 +3089,26 @@ def process_pdf( return m.group(0) final_text = _re_tel_partial.sub(_clean_tel_partial, final_text) + # 3c) Initiales identifiantes devant [NOM] : "Dr T. [NOM]" → "Dr [NOM] [NOM]" + _RE_INITIAL_BEFORE_NOM = re.compile( + r'\b([A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ])\.[\s\-]*(\[NOM\])' + ) + def _clean_initial_before_nom(m): + anon.audit.append(PiiHit(-1, "NOM_INITIAL", m.group(1) + ".", PLACEHOLDERS["NOM"])) + return PLACEHOLDERS["NOM"] + " " + m.group(2) + final_text = _RE_INITIAL_BEFORE_NOM.sub(_clean_initial_before_nom, final_text) + + # 3d) Références initiales : "Ref : JF/VA", "Réf : AD/EP" → "Ref : [NOM]/[NOM]" + _RE_REF_INITIALS = re.compile( + r'(?:Ref\s*:\s*|Réf\s*:\s*)([A-Z]{1,3})\s*/\s*([A-Z]{1,3})\b' + ) + def _clean_ref_initials(m): + anon.audit.append(PiiHit(-1, "NOM_INITIAL", m.group(1), PLACEHOLDERS["NOM"])) + anon.audit.append(PiiHit(-1, "NOM_INITIAL", m.group(2), PLACEHOLDERS["NOM"])) + prefix = m.group(0)[:m.group(0).index(m.group(1))] + return prefix + PLACEHOLDERS["NOM"] + "/" + PLACEHOLDERS["NOM"] + final_text = _RE_REF_INITIALS.sub(_clean_ref_initials, final_text) + # 4) Consolidation : propager les PII détectés sur toutes les pages (page=-1) # pour que la redaction PDF les cherche partout (sidebar répété, etc.) diff --git a/camembert_ner_manager.py b/camembert_ner_manager.py index 821e61e..0ec5944 100644 --- a/camembert_ner_manager.py +++ b/camembert_ner_manager.py @@ -3,13 +3,16 @@ """ CamemBERT-bio NER Manager — Inférence ONNX pour la désidentification clinique. ================================================================================ -Modèle fine-tuné sur almanach/camembert-bio-base avec des annotations silver -issues de 29 documents cliniques français (F1=89% sur validation). +Modèle fine-tuné sur almanach/camembert-bio-base avec des annotations silver. + +Versions: + v2 (2026-03-09): 29 docs, 7K exemples — F1=0.90, Recall=0.93 + v3 (2026-03-11): 1112 docs, 198K exemples — F1=0.96, Recall=0.97 Utilisé comme signal NER supplémentaire dans le pipeline d'anonymisation, en complément d'EDS-Pseudo et GLiNER (vote majoritaire). -Inférence ONNX Runtime CPU : ~20 ms pour 512 tokens. +Inférence ONNX Runtime CPU : ~10-20 ms pour 512 tokens. """ from __future__ import annotations @@ -70,6 +73,10 @@ class CamembertNerManager: def is_loaded(self) -> bool: return self._loaded + @property + def version(self) -> str: + return getattr(self, "_version", "?") + def load(self) -> None: """Charge le modèle ONNX et le tokenizer.""" if not _ORT_AVAILABLE: @@ -102,7 +109,23 @@ class CamembertNerManager: # Tokenizer self._tokenizer = AutoTokenizer.from_pretrained(str(self._model_dir)) self._loaded = True - log.info(f"CamemBERT-bio ONNX chargé: {self._model_dir} ({len(self._id2label)} labels)") + + # Lire la version depuis VERSION.json (si disponible) + self._version = "?" + version_path = self._model_dir.parent / "VERSION.json" + if version_path.exists(): + try: + with open(version_path, encoding="utf-8") as vf: + vinfo = json.load(vf) + self._version = vinfo.get("current_version", "?") + v_meta = vinfo.get("versions", {}).get(self._version, {}) + f1 = v_meta.get("f1", "?") + recall = v_meta.get("recall", "?") + log.info(f"CamemBERT-bio ONNX {self._version} chargé (F1={f1}, R={recall}, {len(self._id2label)} labels)") + except Exception: + log.info(f"CamemBERT-bio ONNX chargé: {self._model_dir} ({len(self._id2label)} labels)") + else: + log.info(f"CamemBERT-bio ONNX chargé: {self._model_dir} ({len(self._id2label)} labels)") def unload(self) -> None: self._session = None diff --git a/docs/AIPD-anonymisation.md b/docs/AIPD-anonymisation.md new file mode 100644 index 0000000..88187b8 --- /dev/null +++ b/docs/AIPD-anonymisation.md @@ -0,0 +1,258 @@ +# Analyse d'Impact relative a la Protection des Donnees (AIPD) +## Programme d'anonymisation de documents medicaux + +**Responsable de traitement** : [A completer — etablissement de sante] +**Date de realisation** : 11 mars 2026 +**Version** : 1.0 +**Statut** : Projet + +--- + +## 1. Description du traitement + +### 1.1 Nature du traitement + +Anonymisation automatique de documents medicaux au format PDF par detection et masquage des donnees a caractere personnel (DCP) a l'aide de techniques de traitement automatique du langage (NLP) et de reconnaissance d'entites nommees (NER). + +### 1.2 Portee + +| Element | Detail | +|---------|--------| +| **Donnees traitees** | Noms, prenoms, dates de naissance, adresses, telephones, NIR, IPP, NDA, RPPS, IBAN, noms d'etablissements, villes, codes postaux | +| **Personnes concernees** | Patients hospitalises, professionnels de sante (medecins, infirmiers, aides-soignants), contacts familiaux | +| **Volume** | ~1 200 documents PDF par campagne de controle T2A | +| **Frequence** | Ponctuelle (campagnes de controle annuelles ou semestrielles) | +| **Perimetre geographique** | Etablissement de sante unique, France metropolitaine | + +### 1.3 Finalite + +Permettre la transmission de documents justificatifs dans le cadre du controle T2A (Tarification a l'Activite) en conformite avec les obligations de l'Assurance Maladie, tout en protegeant les donnees personnelles des patients et des professionnels de sante. + +### 1.4 Base legale + +- **Article 6.1.c RGPD** : Obligation legale — le controle T2A impose la transmission de documents justificatifs +- **Article 9.2.h RGPD** : Traitement necessaire aux fins de la medecine preventive et de la gestion des systemes de sante +- **Code de la Securite Sociale** : Articles L.162-22-18 et R.162-42-10 (controle T2A) + +--- + +## 2. Description des moyens du traitement + +### 2.1 Architecture technique + +``` +PDF original (donnees de sante) + | + v +[Extraction texte multi-passes] + - PyMuPDF (texte natif, layout-aware) + - pdfplumber (tableaux) + - pdfminer (fallback caracteres CID) + - docTR OCR (documents scannes) + | + v +[Detection PII — Phase 1 : Regles] + - 30+ expressions regulieres (NIR, tel, email, adresses, dates de naissance...) + - Gazetteers : INSEE (36K prenoms, 34K communes), BDPM (7K medicaments), FINESS (108K etablissements) + - Extraction structuree (champs Trackare, en-tetes de courriers) + | + v +[Detection PII — Phase 2 : NER multi-moteurs] + - EDS-Pseudo (CamemBERT, NLP clinique francais) + - GLiNER (NER zero-shot, modele urchade/gliner_multi_pii-v1) + - CamemBERT-bio-deid v3 (fine-tune ONNX, F1=0.96, Recall=0.97) + - Vote croise a 3 moteurs pour chaque entite detectee + | + v +[Remplacement par placeholders generiques] + - [NOM], [DATE_NAISSANCE], [ADRESSE], [TEL], [NIR], [IPP], etc. + - Placeholders non individualisants (pas de numerotation) + | + v +[Caviardage PDF raster] + - Rasterisation 300 DPI de chaque page + - Rectangles noirs sur les zones PII + - Reconstruction PDF image (texte sous-jacent detruit) + | + v +Sorties : PDF caviardes (image) + texte pseudonymise + journal d'audit +``` + +### 2.2 Environnement d'execution + +| Element | Detail | +|---------|--------| +| **Materiel** | Poste de travail local (CPU standard, pas de GPU requis) | +| **Systeme** | Linux (Ubuntu) | +| **Reseau** | Aucune connexion internet requise pendant le traitement | +| **Stockage** | Disque local chiffre (recommande) | +| **Acces** | Poste mono-utilisateur, session authentifiee | + +### 2.3 Modeles d'IA utilises + +| Modele | Type | Provenance | Execution | +|--------|------|------------|-----------| +| EDS-Pseudo | CamemBERT fine-tune NER | AP-HP (eds-nlp, open source) | CPU local, ONNX Runtime | +| GLiNER | NER zero-shot | urchade (HuggingFace, open source) | CPU local | +| CamemBERT-bio-deid v3 | CamemBERT-bio fine-tune NER | Entrainement interne sur annotations silver | CPU local, ONNX Runtime | + +**Aucun modele cloud n'est utilise. Aucune donnee ne quitte le poste local.** + +### 2.4 Donnees d'entrainement du modele CamemBERT-bio-deid v3 + +| Element | Detail | +|---------|--------| +| Source | 1 112 documents cliniques anonymises par le pipeline multi-moteurs (silver annotations) | +| Methode | Alignement diff texte original / texte pseudonymise, format BIO | +| Augmentation | Substitution de noms par gazetteer INSEE (219K patronymes), hard negatives medicaux (BDPM, QUAERO) | +| Validation | 20% des donnees reservees pour evaluation (F1=0.96, Recall=0.97, Precision=0.96) | +| Stockage | Modele ONNX stocke localement (421 Mo), pas de donnees d'entrainement persistantes en production | + +--- + +## 3. Evaluation de la necessite et de la proportionnalite + +### 3.1 Necessite du traitement + +| Question | Reponse | +|----------|---------| +| Le traitement est-il necessaire a la finalite ? | **Oui** — la transmission de documents T2A sans anonymisation exposerait les DCP de ~1 200 patients a des tiers (controleurs ARS/CPAM). | +| Existe-t-il une alternative moins intrusive ? | **Non** — l'anonymisation manuelle (caviardage a la main) est impraticable a cette echelle (30+ pages par dossier, 1 200 dossiers), avec un risque d'erreur humaine eleve. | +| Le traitement automatique est-il proportionnel ? | **Oui** — le systeme traite uniquement les identifiants, sans modifier le contenu medical. Le recall de 97% est superieur a la fiabilite estimee d'un caviardage manuel. | + +### 3.2 Proportionnalite + +| Critere | Evaluation | +|---------|-----------| +| **Minimisation des donnees** | Seules les DCP sont traitees. Le contenu medical n'est ni extrait, ni stocke, ni transmis. | +| **Limitation de la conservation** | En memoire vive pendant le traitement uniquement. Pas de BDD, pas de fichiers temporaires sur disque. | +| **Exactitude** | Score qualite mesure automatiquement (96.3/100). Controle humain post-traitement systematique. | + +--- + +## 4. Identification et evaluation des risques + +### 4.1 Risques pour les personnes concernees + +#### R1 — Faux negatif : DCP non detectee dans le document de sortie + +| Element | Evaluation | +|---------|-----------| +| **Gravite** | Elevee — exposition d'une donnee de sante identifiante | +| **Vraisemblance** | Faible — recall de 97% (3 moteurs NER + regles + gazetteers) | +| **Risque residuel** | Modere | +| **Mesures d'attenuation** | Vote croise 3 moteurs NER, gazetteers INSEE/FINESS (180K+ entrees), controle humain post-traitement, score qualite automatise par document | + +#### R2 — Compromission du journal d'audit + +| Element | Evaluation | +|---------|-----------| +| **Gravite** | Elevee — le journal contient les valeurs originales des DCP | +| **Vraisemblance** | Faible — traitement local, acces restreint | +| **Risque residuel** | Faible | +| **Mesures d'attenuation** | Acces restreint au responsable qualite, suppression apres validation du lot, chiffrement du disque recommande, non-transmission avec les documents anonymises | + +#### R3 — Acces non autorise aux documents originaux + +| Element | Evaluation | +|---------|-----------| +| **Gravite** | Elevee — documents medicaux complets | +| **Vraisemblance** | Faible — poste local securise | +| **Risque residuel** | Faible | +| **Mesures d'attenuation** | Session authentifiee, chiffrement disque, suppression des originaux apres validation | + +#### R4 — Faux positif : perte d'information medicale + +| Element | Evaluation | +|---------|-----------| +| **Gravite** | Faible — un terme medical masque a tort reduit la lisibilite mais ne compromet pas la vie privee | +| **Vraisemblance** | Faible — precision de 96%, stop words medicaux (BDPM + QUAERO) | +| **Risque residuel** | Faible | +| **Mesures d'attenuation** | Vote croise NER, whitelist termes medicaux, controle humain | + +#### R5 — Biais du modele NER + +| Element | Evaluation | +|---------|-----------| +| **Gravite** | Moyenne — certains types de noms (etrangers, composes) pourraient etre moins bien detectes | +| **Vraisemblance** | Faible — donnees d'entrainement diversifiees (1 112 documents, augmentation INSEE) | +| **Risque residuel** | Faible | +| **Mesures d'attenuation** | Gazetteers INSEE (219K patronymes diversifies), extraction structuree (regex) en complement du NER, evaluation reguliere sur nouveaux documents | + +### 4.2 Matrice des risques + +| Risque | Gravite | Vraisemblance | Risque initial | Mesures | Risque residuel | +|--------|---------|---------------|----------------|---------|-----------------| +| R1 — Faux negatif | Elevee | Faible | **Eleve** | Multi-moteurs, gazetteers, controle humain | **Modere** | +| R2 — Audit compromis | Elevee | Faible | Eleve | Acces restreint, suppression, chiffrement | **Faible** | +| R3 — Acces originaux | Elevee | Faible | Eleve | Authentification, chiffrement, suppression | **Faible** | +| R4 — Faux positif | Faible | Faible | Faible | Vote croise, stop words | **Faible** | +| R5 — Biais modele | Moyenne | Faible | Modere | Diversite donnees, gazetteers, evaluation | **Faible** | + +--- + +## 5. Mesures prevues pour traiter les risques + +### 5.1 Mesures techniques + +| Mesure | Risque traite | Statut | +|--------|--------------|--------| +| Vote croise 3 moteurs NER independants | R1 | En place | +| Gazetteers INSEE (36K prenoms, 219K patronymes) | R1, R5 | En place | +| Gazetteers FINESS (108K etablissements, Aho-Corasick) | R1 | En place | +| Stop words medicaux (BDPM 7K + QUAERO) | R4 | En place | +| Caviardage PDF raster (destruction physique des pixels) | R1 | En place | +| Score qualite automatise par lot | R1 | En place | +| Placeholders generiques non individualisants | R2 | En place | +| Traitement 100% local (aucun cloud) | R2, R3 | En place | +| Pas de fichiers temporaires sur disque | R2, R3 | En place | +| Chiffrement du disque au repos | R2, R3 | Recommande | + +### 5.2 Mesures organisationnelles + +| Mesure | Risque traite | Statut | +|--------|--------------|--------| +| Controle humain post-traitement (echantillonnage) | R1, R4 | A formaliser | +| Procedure de suppression des originaux apres validation | R3 | A formaliser | +| Procedure de suppression des journaux d'audit | R2 | A formaliser | +| Restriction d'acces au poste de traitement | R2, R3 | En place | +| Formation de l'operateur | R1 | A formaliser | +| Evaluation periodique sur nouveaux types de documents | R1, R5 | A formaliser | + +--- + +## 6. Plan d'action + +| Action | Responsable | Echeance | Priorite | +|--------|-------------|----------|----------| +| Valider l'AIPD avec le DPO | Responsable traitement | [A definir] | Haute | +| Formaliser la procedure de controle humain post-anonymisation | Responsable qualite | [A definir] | Haute | +| Formaliser la procedure de suppression des originaux | Responsable traitement | [A definir] | Haute | +| Formaliser la procedure de suppression des audits | Responsable traitement | [A definir] | Moyenne | +| Activer le chiffrement du disque de traitement | DSI | [A definir] | Moyenne | +| Evaluer le systeme sur un jeu gold (annotations humaines) | Equipe technique | [A definir] | Haute | +| Re-evaluer l'AIPD apres integration des annotations gold | DPO | [A definir] | Moyenne | + +--- + +## 7. Avis du DPO + +[A completer par le DPO de l'etablissement] + +--- + +## 8. Decision du responsable de traitement + +[A completer] + +- [ ] Le traitement peut etre mis en oeuvre +- [ ] Le traitement doit etre modifie (preciser) +- [ ] Le traitement ne doit pas etre mis en oeuvre (preciser) +- [ ] Consultation prealable de la CNIL necessaire (article 36) + +**Signature** : ____________________ +**Date** : ____________________ + +--- + +*Document genere le 11 mars 2026 — A valider par le DPO et le responsable de traitement* diff --git a/docs/camembert-bio-deid-v3-specs.md b/docs/camembert-bio-deid-v3-specs.md new file mode 100644 index 0000000..18f08d0 --- /dev/null +++ b/docs/camembert-bio-deid-v3-specs.md @@ -0,0 +1,77 @@ +# CamemBERT-bio-deid v3 — Specifications techniques + +## Modele de base + +- **Architecture** : CamemBERT (RoBERTa-based), Token Classification +- **Modele pre-entraine** : `almanach/camembert-bio-base` (LORIA/INRIA) +- **Parametres** : ~110M (12 couches, 768 hidden, 12 attention heads) +- **Vocabulaire** : 32 005 tokens (SentencePiece BPE) +- **Specialisation pre-entrainement** : corpus biomedical francais (PubMed, theses, litterature clinique) + +## Fine-tuning + +| Parametre | Valeur | +|-----------|--------| +| Documents d'entrainement | 1 112 documents cliniques (CR hospitalisation, Trackare, CRO, lettres de sortie) | +| Exemples totaux | 198 260 (52 121 originaux + 145 539 augmentes + 600 hard negatives) | +| Augmentation de donnees | Substitution de noms par gazetteer INSEE (219K patronymes) | +| Hard negatives | Medicaments BDPM + termes QUAERO (CHEM, DISO, PROC, ANAT) | +| Epochs | 20 | +| Batch size effectif | 16 (GPU batch=8 x gradient accumulation=2) | +| Learning rate | 1x10-5 | +| Max sequence length | 512 tokens | +| Optimizer | AdamW | +| GPU | NVIDIA GeForce RTX 5070 (12 Go VRAM) | +| Duree d'entrainement | ~14h15 | +| Framework | HuggingFace Transformers 4.42, PyTorch | + +## Annotations d'entrainement (Silver) + +- **Methode** : alignement diff entre texte original et texte pseudonymise par le pipeline multi-moteurs (EDS-Pseudo + GLiNER + regex + gazetteers) +- **Format** : BIO (Beginning-Inside-Outside) +- **Source** : documents T2A CHCB 2023, dossiers de justificatifs +- **Pas de validation humaine** (silver, non gold) + +## Categories NER (14 types, 29 labels BIO) + +| Categorie | Description | +|-----------|-------------| +| PER | Noms de personnes (patients, soignants) | +| DATE_NAISSANCE | Dates de naissance | +| ADRESSE | Adresses postales | +| ZIP | Codes postaux | +| VILLE | Villes, lieux de naissance | +| HOPITAL | Etablissements de sante | +| TEL | Numeros de telephone | +| EMAIL | Adresses email | +| NIR | Numeros de securite sociale | +| IPP | Identifiants Patient Permanent | +| NDA | Numeros de Dossier Administratif | +| RPPS | Numeros RPPS (professionnels de sante) | +| IBAN | Coordonnees bancaires | +| AGE | Ages | + +## Performances (sur jeu de validation, 20% des donnees) + +| Metrique | v2 (29 docs) | v3 (1 112 docs) | +|----------|:---:|:---:| +| **F1-score** | 0.903 | **0.963** | +| **Recall** | 0.930 | **0.970** | +| **Precision** | 0.877 | **0.957** | + +## Inference (production) + +| Parametre | Valeur | +|-----------|--------| +| Format | ONNX Runtime | +| Taille du modele | 421 Mo | +| Runtime | ONNX Runtime CPU (CPUExecutionProvider) | +| Latence | ~10-20 ms / 512 tokens | +| Threads | 2 inter-op, 4 intra-op | +| Fenetre glissante | 400 tokens, stride 200 (textes longs) | +| Seuil de confiance | 0.5 (prediction), 0.3 (vote croise EDS-Pseudo) | +| Materiel cible | PC standard, CPU uniquement (pas de GPU requis) | + +## Role dans le pipeline + +CamemBERT-bio-deid v3 est le **3eme moteur NER** du pipeline d'anonymisation, utilise en **vote croise** avec EDS-Pseudo (moteur principal) et GLiNER (zero-shot). Il confirme ou infirme les detections d'EDS-Pseudo pour reduire les faux positifs sans sacrifier le recall. Il n'opere jamais seul — c'est un signal de validation. diff --git a/docs/conformite-rgpd-ia-act.md b/docs/conformite-rgpd-ia-act.md new file mode 100644 index 0000000..59f2822 --- /dev/null +++ b/docs/conformite-rgpd-ia-act.md @@ -0,0 +1,235 @@ +# Conformite reglementaire — Programme d'anonymisation de documents medicaux + +## 1. Description du systeme + +### 1.1 Finalite + +Le programme realise l'**anonymisation automatique de documents medicaux** (comptes-rendus d'hospitalisation, courriers medicaux, ordonnances, resultats d'examens) au format PDF. Il detecte et masque les donnees a caractere personnel (DCP) contenues dans ces documents pour permettre leur utilisation dans un cadre de controle T2A (Tarification a l'Activite) sans exposition des donnees patients. + +### 1.2 Fonctionnement technique + +Le pipeline se decompose en 5 phases sequentielles : + +1. **Extraction de texte** : extraction layout-aware du contenu textuel du PDF (PyMuPDF, pdfplumber, pdfminer, docTR OCR pour les documents scannes) +2. **Detection par regles** : expressions regulieres et gazetteers (INSEE, BDPM, FINESS) pour identifier les PII structures (NIR, telephones, adresses, dates de naissance, noms d'etablissements) +3. **Detection par NER multi-moteurs** : trois modeles de reconnaissance d'entites nommees fonctionnent en vote croise : + - EDS-Pseudo (CamemBERT, NLP clinique francais) + - GLiNER (NER zero-shot) + - CamemBERT-bio-deid v3 (fine-tune sur corpus clinique, F1=0.96) +4. **Remplacement** : chaque DCP detectee est remplacee par un placeholder generique et categorise ([NOM], [DATE_NAISSANCE], [ADRESSE], [TEL], [NIR], etc.) +5. **Caviardage PDF** : generation d'un PDF anonymise au format image (rasterisation) + +### 1.3 Formats de sortie + +| Sortie | Format | Description | +|--------|--------|-------------| +| **PDF caviardes** | PDF image (raster) | Chaque page est convertie en image haute resolution (300 DPI), les zones contenant des DCP sont recouvertes de rectangles noirs, puis le PDF est reconstruit a partir des images. **Le texte sous-jacent est detruit** — aucune extraction de texte n'est possible sur le document de sortie. | +| Texte pseudonymise | .pseudonymise.txt | Version texte avec placeholders ([NOM], [DATE_NAISSANCE], etc.) | +| Journal d'audit | .audit.jsonl | Trace des detections pour controle qualite (contient les valeurs originales — document sensible) | + +### 1.4 Caracteristique cle : irreversibilite du caviardage PDF + +Le format de sortie principal est un **PDF raster** (image). Ce choix technique garantit : + +- **Destruction physique des donnees** : le texte original est remplace par des pixels. Aucun calque texte, aucune metadonnee textuelle ne subsiste. +- **Resistance aux attaques d'extraction** : contrairement a un caviardage vectoriel (annotation PDF), le caviardage raster ne peut pas etre "devoile" en supprimant un calque d'annotation. +- **Irreversibilite totale** : meme avec un acces complet au systeme, il est impossible de reconstituer les DCP a partir du PDF de sortie. + +--- + +## 2. Conformite RGPD (Reglement UE 2016/679) + +### 2.1 Qualification juridique : pseudonymisation vs anonymisation + +Le RGPD distingue deux traitements (article 4 §5 et considerant 26) : + +| Critere | Pseudonymisation | Anonymisation | +|---------|-----------------|---------------| +| **Definition** | Traitement rendant les donnees non attribuables sans information supplementaire | Traitement rendant l'identification impossible de maniere irreversible | +| **Statut RGPD** | Reste une donnee personnelle | N'est plus une donnee personnelle | +| **Notre systeme** | Texte .pseudonymise.txt + audit .jsonl | **PDF raster caviardes** | + +**Position du systeme** : +- Le **PDF raster de sortie** constitue une **anonymisation** au sens du RGPD : les DCP sont physiquement detruites (remplacement par pixels noirs), sans possibilite de re-identification, meme par le responsable de traitement. +- Le **fichier texte** (.pseudonymise.txt) constitue une **pseudonymisation** : les DCP sont remplacees par des placeholders generiques, mais le journal d'audit conserve les correspondances. +- Le **journal d'audit** (.audit.jsonl) contient les valeurs originales des DCP detectees et doit etre traite comme une donnee sensible. + +### 2.2 Base legale du traitement (article 6) + +Le traitement de pseudonymisation/anonymisation peut s'appuyer sur : +- **Article 6.1.c** : obligation legale (controle T2A imposant la transmission de documents justificatifs) +- **Article 6.1.e** : mission d'interet public (amelioration de la qualite des soins) +- **Article 6.1.f** : interet legitime (protection des donnees patients lors de la transmission) + +Pour les **donnees de sante** (article 9), le traitement est autorise au titre de : +- **Article 9.2.h** : medecine preventive, diagnostic medical, gestion des systemes de sante +- **Article 9.2.j** : finalites de recherche et statistiques (avec garanties de l'article 89) + +### 2.3 Principes du RGPD respectes + +| Principe | Article | Mise en oeuvre | +|----------|---------|----------------| +| **Minimisation** | Art. 5.1.c | Seules les DCP strictement necessaires sont traitees. Le systeme ne collecte aucune donnee supplementaire. Les PDF originaux ne sont pas copies — le traitement est effectue in situ. | +| **Limitation de la conservation** | Art. 5.1.e | Le programme ne stocke aucune donnee personnelle de maniere persistante. Les donnees traitees sont en memoire vive uniquement pendant le traitement. Aucun fichier temporaire sur disque. | +| **Integrite et confidentialite** | Art. 5.1.f | Traitement local exclusivement (aucun envoi vers le cloud ou service tiers). Modeles d'IA embarques, inference CPU locale. | +| **Protection des donnees des la conception** (Privacy by Design) | Art. 25.1 | Architecture pensee pour l'irreversibilite : le format de sortie PDF raster detruit physiquement les donnees. Pas de mecanisme de reversibilite, pas de cle de chiffrement, pas de table de correspondance persistante. | +| **Protection par defaut** | Art. 25.2 | Le mode de sortie par defaut est le caviardage raster (le plus protecteur). Les placeholders sont generiques et non individualisants (tous les noms deviennent [NOM], sans numerotation). | + +### 2.4 Droits des personnes concernees + +| Droit | Application | +|-------|-------------| +| **Information** (Art. 13-14) | Les personnes doivent etre informees que leurs documents font l'objet d'un traitement d'anonymisation dans le cadre du controle T2A. | +| **Acces** (Art. 15) | Applicable sur les documents originaux (avant anonymisation). Non applicable sur les PDF anonymises (donnees detruites). | +| **Rectification** (Art. 16) | Applicable sur les documents originaux. Le systeme d'anonymisation ne modifie pas le contenu medical, uniquement les identifiants. | +| **Effacement** (Art. 17) | Le journal d'audit (.audit.jsonl) contenant les valeurs originales doit etre supprime apres la periode de controle qualite. | +| **Opposition** (Art. 21) | Le traitement d'anonymisation en vue du controle T2A releve d'une obligation legale ; le droit d'opposition est limite. | + +### 2.5 Analyse d'impact (AIPD / DPIA) + +Une AIPD est **obligatoire** (article 35) car le traitement : +- Porte sur des **donnees de sante** a grande echelle +- Utilise des **technologies innovantes** (NER, modeles de langage) +- Concerne des **personnes vulnerables** (patients) + +L'AIPD devra documenter : +- Les mesures techniques (multi-moteurs NER, vote croise, caviardage raster) +- Les mesures organisationnelles (acces restreint, suppression des audits) +- Les risques residuels (faux negatifs potentiels : DCP non detectees) + +### 2.6 Gestion du journal d'audit + +Le fichier .audit.jsonl constitue un **traitement de donnees personnelles de sante** a part entiere. Recommandations : +- **Acces restreint** : seul le responsable qualite doit y acceder +- **Duree de conservation limitee** : suppression apres validation du lot anonymise +- **Chiffrement au repos** recommande +- **Non-transmission** : ne jamais transmettre le journal d'audit avec les documents anonymises + +--- + +## 3. Conformite AI Act (Reglement UE 2024/1689) + +### 3.1 Classification du systeme + +L'AI Act classe les systemes d'IA en 4 niveaux de risque : + +| Niveau | Exemples | Notre systeme | +|--------|----------|---------------| +| **Inacceptable** | Notation sociale, manipulation subliminale | Non concerne | +| **Eleve** (Annexe III) | Biometrie, diagnostic medical, decisions judiciaires | **Non concerne** (voir justification ci-dessous) | +| **Limite** | Chatbots, deepfakes | Non concerne | +| **Minimal** | Filtres anti-spam, jeux video | **Classification retenue** | + +### 3.2 Justification : systeme a risque minimal + +Le systeme d'anonymisation **n'est pas un systeme a haut risque** au sens de l'Annexe III car : + +1. **Il n'est pas un dispositif medical** : il ne realise aucun diagnostic, aucune aide a la decision clinique, aucune prediction medicale. Il ne traite que les identifiants, pas le contenu medical. +2. **Il ne releve d'aucune categorie de l'Annexe III** : pas de biometrie, pas de recrutement, pas de notation de credit, pas d'application de la loi, pas de gestion de l'immigration, pas d'administration de la justice. +3. **Il remplit les conditions d'exemption de l'article 6 §3** : + - Il execute une **tache procedurale etroite** (detection et remplacement de motifs textuels) + - Il **ameliore le resultat d'une activite humaine prealable** (le controle qualite humain reste l'etape finale) + - Il effectue une **tache preparatoire** (preparation de documents pour transmission) +4. **Sa finalite est la protection des donnees**, non leur exploitation. Il reduit le risque sur les droits fondamentaux au lieu de l'augmenter. + +### 3.3 Obligations applicables (risque minimal) + +Meme en risque minimal, l'AI Act recommande (article 69) : + +| Obligation | Mise en oeuvre | +|------------|----------------| +| **Transparence** | Documentation technique disponible (architecture, modeles utilises, performances). Le fichier VERSION.json trace les versions des modeles et leurs metriques. | +| **Qualite des donnees d'entrainement** | Donnees d'entrainement issues de documents reels anonymises (silver annotations). Augmentation par gazetteers INSEE et BDPM. Hard negatives QUAERO. | +| **Supervision humaine** | Le systeme produit des documents anonymises qui sont **toujours soumis a un controle humain** avant transmission. Score qualite mesure automatiquement (96.3/100). | +| **Tracabilite** | Journal d'audit detaille par document (type de DCP, valeur originale, methode de detection). | + +### 3.4 Calendrier d'application + +| Date | Etape | Impact | +|------|-------|--------| +| Fevrier 2025 | Interdictions (risque inacceptable) | Non concerne | +| Aout 2025 | Obligations IA a usage general (GPAI) | Non concerne (modele specialise, pas GPAI) | +| **Aout 2026** | **Application complete** (systemes a haut risque) | Non concerne (risque minimal) | + +--- + +## 4. Mesures techniques de conformite + +### 4.1 Traitement local exclusif + +| Mesure | Detail | +|--------|--------| +| **Aucun appel cloud** | Tous les modeles d'IA (EDS-Pseudo, GLiNER, CamemBERT-bio) fonctionnent en local sur CPU | +| **Aucune API externe** | Pas d'envoi de donnees vers OpenAI, Google, Anthropic ou autre service tiers | +| **Pas de telemetrie** | Le programme ne collecte aucune statistique d'usage, aucun log distant | +| **Environnement controle** | Fonctionne sur poste local securise, reseau interne | + +### 4.2 Securite du traitement + +| Mesure | Detail | +|--------|--------| +| **Memoire vive uniquement** | Les DCP ne transitent que par la RAM pendant le traitement. Aucun fichier temporaire sur disque. | +| **Pas de base de donnees** | Aucune BDD locale ou distante ne stocke les DCP traitees | +| **Pas de reversibilite** | Aucune cle de chiffrement, aucune table de correspondance, aucun mecanisme de de-anonymisation | +| **Placeholders generiques** | Tous les noms deviennent [NOM] (pas de [NOM_1], [NOM_2]) — empeche la re-identification par croisement | + +### 4.3 Multi-moteurs et vote croise + +L'utilisation de **3 moteurs NER independants** en vote croise est une mesure de fiabilite : +- Reduit le risque de **faux negatifs** (DCP non detectee) : si un moteur rate une entite, les deux autres peuvent la rattraper +- Reduit le risque de **faux positifs** (terme medical masque a tort) : le vote majoritaire empeche un moteur isole de masquer un terme medical courant +- Le score de qualite mesure (96.3/100) quantifie le risque residuel + +### 4.4 Format de sortie : caviardage raster + +Le choix du **PDF raster** (image) comme format de sortie principal est une mesure de protection maximale : + +``` +Document original (PDF texte) + | + v +[Extraction texte] → [Detection PII] → [Remplacement par placeholders] + | + v +[Rasterisation 300 DPI] → [Rectangles noirs sur zones PII] → [Reconstruction PDF image] + | + v +Document anonymise (PDF image — texte irrecuperable) +``` + +**Garanties** : +- Le texte sous-jacent est **physiquement absent** du fichier PDF de sortie +- Les rectangles noirs sont des **pixels**, pas des annotations supprimables +- La resolution (300 DPI) preserve la lisibilite du contenu medical non masque +- Un filigrane optionnel identifie le document comme anonymise + +--- + +## 5. Risques residuels et mesures d'attenuation + +| Risque | Probabilite | Impact | Attenuation | +|--------|-------------|--------|-------------| +| **Faux negatif** : DCP non detectee passant dans le document de sortie | Faible (recall 97%) | Eleve | Vote croise 3 moteurs, gazetteers INSEE/FINESS, controle humain post-traitement, score qualite automatise | +| **Faux positif** : terme medical masque a tort reduisant la lisibilite | Moyen | Faible | Vote croise, stop words medicaux (BDPM, QUAERO), precision 96% | +| **Journal d'audit compromis** | Faible | Eleve | Acces restreint, suppression apres validation, chiffrement recommande | +| **Document original non supprime** | Moyen | Eleve | Procedure organisationnelle de suppression apres validation du lot | + +--- + +## 6. Synthese de conformite + +| Reglementation | Statut | Commentaire | +|----------------|--------|-------------| +| **RGPD** — Minimisation | Conforme | Aucune collecte supplementaire, traitement en memoire vive | +| **RGPD** — Privacy by Design | Conforme | Irreversibilite par conception (PDF raster) | +| **RGPD** — Securite | Conforme | Traitement 100% local, pas de cloud, pas de BDD | +| **RGPD** — Droits des personnes | Conforme | Applicable sur documents originaux, non applicable sur sorties anonymisees | +| **RGPD** — AIPD | A realiser | Obligatoire (donnees de sante + technologie innovante) | +| **RGPD** — Journal d'audit | Attention | Contient des DCP — traiter comme donnee sensible | +| **AI Act** — Classification | Risque minimal | Ne releve pas de l'Annexe III (pas de DM, pas de decision) | +| **AI Act** — Transparence | Conforme | Documentation technique, versioning des modeles, metriques | +| **AI Act** — Supervision humaine | Conforme | Controle humain systematique avant transmission | + +--- + +*Document etabli le 11 mars 2026 — Version 1.0* diff --git a/evaluation/baseline_scores.json b/evaluation/baseline_scores.json new file mode 100644 index 0000000..4d1ae6b --- /dev/null +++ b/evaluation/baseline_scores.json @@ -0,0 +1,254 @@ +{ + "date": "2026-03-11T12:11:24.286697", + "scores": { + "global_score": 97.0, + "leak_score": 100.0, + "fp_score": 90, + "totals": { + "documents": 29, + "audit_hits": 2804, + "name_tokens_known": 461, + "leak_audit": 0, + "leak_occurrences": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 568, + "fp_medical": 0, + "fp_overmasking": 2 + } + }, + "per_file": { + "BACTERIO 23232115": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 3, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "CONSULTATION ANESTHESISTE 23056022": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 11, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "CONSULTATION ANESTHESISTE 23060661": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 6, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "CONSULTATION ANESTHESISTE 23139653": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 6, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "CRH 60_23106634": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 5, + "fp_medical": 0, + "fp_overmasking": 1 + }, + "CRO 23159905": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 5, + "fp_medical": 0, + "fp_overmasking": 1 + }, + "CRO 23160703": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 2, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "LETTRE DE SORTIE 23087212": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 0, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "trackare-00260974-23070213_00260974_23070213": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 29, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "trackare-03020576-23175616_03020576_23175616": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 31, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "trackare-05000272-23074376_05000272_23074376": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 11, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "trackare-05012679-23098722_05012679_23098722": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 23, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "trackare-05012965-23060770_05012965_23060770": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 31, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "trackare-07003136-23135847_07003136_23135847": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 35, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "trackare-11004431-23124019_11004431_23124019": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 20, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "trackare-13013848-23165708_13013848_23165708": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 17, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "trackare-14025311-23034958_14025311_23034958": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 12, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "trackare-17015185-23043950_17015185_23043950": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 18, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "trackare-23000862-23018396_23000862_23018396": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 32, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "trackare-99246761-23159905_99246761_23159905": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 34, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "trackare-99252128-23177582_99252128_23177582": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 33, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "trackare-BA042686-23090597_BA042686_23090597": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 23, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "trackare-BA065989-23102874_BA065989_23102874": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 11, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "trackare-BA067657-23076655_BA067657_23076655": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 32, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "trackare-BA093659-23074520_BA093659_23074520": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 30, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "trackare-BA121804-23016863_BA121804_23016863": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 34, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "trackare-BA127127-23135726_BA127127_23135726": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 26, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "trackare-BA171849-23214501_BA171849_23214501": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 22, + "fp_medical": 0, + "fp_overmasking": 0 + }, + "trackare-BA192486-23127395_BA192486_23127395": { + "leak_audit": 0, + "leak_regex": 0, + "leak_insee_high": 0, + "leak_insee_medium": 26, + "fp_medical": 0, + "fp_overmasking": 0 + } + } +} \ No newline at end of file diff --git a/scripts/evaluate_quality.py b/scripts/evaluate_quality.py new file mode 100644 index 0000000..a521a1e --- /dev/null +++ b/scripts/evaluate_quality.py @@ -0,0 +1,654 @@ +#!/usr/bin/env python3 +""" +Évaluation unifiée de la qualité d'anonymisation +================================================= +Produit un score reproductible en analysant les sorties d'anonymisation. + +5 axes de vérification : + 1. LEAK_AUDIT — Noms détectés (audit) encore présents dans le texte + 2. LEAK_REGEX — Patterns PII (email, tel, NIR) non masqués + 3. LEAK_INSEE — Mots ALL-CAPS qui sont des noms INSEE connus, non masqués + 4. FP_DENSITY — Sur-masquage (densité de placeholders) + 5. FP_MEDICAL — Termes médicaux masqués à tort + +Produit un score global 0-100 et un rapport JSON pour suivi dans le temps. + +Usage: + python scripts/evaluate_quality.py # audit_30 + python scripts/evaluate_quality.py --dir /chemin/sortie # répertoire custom + python scripts/evaluate_quality.py --save # sauvegarder comme baseline + python scripts/evaluate_quality.py --compare # comparer avec baseline +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +import unicodedata +from collections import Counter, defaultdict +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Set, Tuple + +# === Chemins par défaut === +PROJECT_DIR = Path(__file__).parent.parent +DEFAULT_DIR = Path( + "/home/dom/Téléchargements/II-1 Ctrl_T2A_2025_CHCB_DocJustificatifs (1)" + "/anonymise_audit_30" +) +INSEE_NOMS = PROJECT_DIR / "data" / "insee" / "noms_famille_france.txt" +INSEE_PRENOMS = PROJECT_DIR / "data" / "insee" / "prenoms_france.txt" +BASELINE_PATH = PROJECT_DIR / "evaluation" / "baseline_scores.json" + +# === Regex PII === +RE_EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") +RE_TEL = re.compile(r"(? str: + """Supprime les accents.""" + return "".join( + c for c in unicodedata.normalize("NFD", s) + if unicodedata.category(c) != "Mn" + ) + + +def load_insee_names() -> Tuple[Set[str], Set[str]]: + """Charge les noms et prénoms INSEE (normalisés uppercase sans accents).""" + noms = set() + prenoms = set() + + if INSEE_NOMS.exists(): + for line in INSEE_NOMS.read_text(encoding="utf-8").splitlines(): + name = line.strip() + if name and len(name) >= 3: + noms.add(normalize_nfkd(name).upper()) + + if INSEE_PRENOMS.exists(): + for line in INSEE_PRENOMS.read_text(encoding="utf-8").splitlines(): + name = line.strip() + if name and len(name) >= 3: + prenoms.add(normalize_nfkd(name).upper()) + + return noms, prenoms + + +def extract_name_tokens(audit_entries: List[dict]) -> Set[str]: + """Extrait les tokens de noms individuels depuis les entrées audit NOM. + + Filtre les titres (Dr, Pr, M., Mme...) et tokens trop courts/génériques. + """ + tokens = set() + for entry in audit_entries: + kind = entry.get("kind", "") + if "NOM" not in kind and "PRENOM" not in kind: + continue + original = entry.get("original", "") + if not original: + continue + # Découper le nom complet en tokens individuels + for token in re.split(r"[\s\-]+", original): + clean = token.strip(".,;:()\"'") + if len(clean) < 3: + continue + if not clean[0].isupper(): + continue + # Exclure titres et préfixes + if clean in TITLE_PREFIXES: + continue + # Exclure mots génériques + if normalize_nfkd(clean).upper() in NAME_IGNORE: + continue + tokens.add(clean) + return tokens + + +def check_leak_audit(text: str, name_tokens: Set[str]) -> List[dict]: + """Vérifie si des noms de l'audit sont encore dans le texte. + + Retourne une entrée par token unique trouvé (avec le nombre d'occurrences). + """ + leaks = [] + # Retirer les placeholders du texte pour ne pas matcher dedans + clean_text = RE_PLACEHOLDER.sub("___", text) + + for token in name_tokens: + # Chercher le token comme mot entier (insensible à la casse) + pattern = re.compile(r"\b" + re.escape(token) + r"\b", re.IGNORECASE) + matches = list(pattern.finditer(clean_text)) + if matches: + # Premier match pour le contexte + m = matches[0] + context_start = max(0, m.start() - 30) + context_end = min(len(clean_text), m.end() + 30) + context = clean_text[context_start:context_end].strip() + leaks.append({ + "type": "LEAK_AUDIT", + "severity": "CRITIQUE", + "token": token, + "occurrences": len(matches), + "context": context, + }) + return leaks + + +def check_leak_regex(text: str) -> List[dict]: + """Cherche des patterns PII non masqués dans le texte.""" + leaks = [] + clean_text = RE_PLACEHOLDER.sub("___", text) + + for name, pattern in [ + ("EMAIL", RE_EMAIL), + ("TEL", RE_TEL), + ("NIR", RE_NIR), + ("IBAN", RE_IBAN), + ]: + for m in pattern.finditer(clean_text): + # Ignorer si dans un contexte de placeholder + before = clean_text[max(0, m.start() - 2):m.start()] + if "[" in before or "___" in before: + continue + leaks.append({ + "type": "LEAK_REGEX", + "severity": "HAUTE", + "pii_type": name, + "value": m.group(), + }) + return leaks + + +def check_leak_insee( + text: str, + insee_noms: Set[str], + insee_prenoms: Set[str], + known_tokens: Set[str], +) -> List[dict]: + """Cherche des mots ALL-CAPS qui sont des noms INSEE non masqués.""" + leaks = [] + clean_text = RE_PLACEHOLDER.sub("___", text) + seen = set() + + # Mots ALL-CAPS de 3+ caractères + for m in re.finditer(r"\b([A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]{3,})\b", clean_text): + word = m.group(1) + if word in seen: + continue + seen.add(word) + + # Ignorer mots connus non-noms + normalized = normalize_nfkd(word).upper() + if normalized in NAME_IGNORE: + continue + + # Vérifier si c'est un nom INSEE ET pas déjà dans les tokens connus + is_nom = normalized in insee_noms + is_prenom = normalized in insee_prenoms + + if (is_nom or is_prenom) and word not in known_tokens: + # Vérifier le contexte — indicateurs que c'est un vrai nom + pos = m.start() + before = clean_text[max(0, pos - 40):pos].strip() + + # Heuristiques de contexte fort (Dr, M., Mme, etc.) + strong_ctx = bool(re.search( + r"(?:Dr|Pr|M\.|Mme|Mlle|Docteur|Professeur|Monsieur|Madame)\s*$", + before, re.I + )) + + context_start = max(0, pos - 30) + context_end = min(len(clean_text), m.end() + 30) + context = clean_text[context_start:context_end].strip() + + leaks.append({ + "type": "LEAK_INSEE", + "severity": "HAUTE" if strong_ctx else "MOYENNE", + "word": word, + "is_nom": is_nom, + "is_prenom": is_prenom, + "strong_context": strong_ctx, + "context": context, + }) + + return leaks + + +def check_fp_medical(text: str) -> List[dict]: + """Détecte les termes médicaux masqués à tort.""" + fps = [] + for name, pattern in MEDICAL_FP_PATTERNS.items(): + for m in pattern.finditer(text): + fps.append({ + "type": "FP_MEDICAL", + "pattern": name, + "match": m.group()[:80], + }) + return fps + + +def check_fp_density(text: str) -> dict: + """Calcule la densité de placeholders et détecte le sur-masquage.""" + words = text.split() + total = len(words) + if total == 0: + return {"total_words": 0, "placeholders": 0, "density_pct": 0.0, + "nom_count": 0, "nom_pct": 0.0, "alert": False} + + ph_count = sum(1 for w in words if RE_PLACEHOLDER.match(w)) + nom_count = text.count("[NOM]") + + density = ph_count / total * 100 + nom_pct = nom_count / total * 100 + + return { + "total_words": total, + "placeholders": ph_count, + "density_pct": round(density, 2), + "nom_count": nom_count, + "nom_pct": round(nom_pct, 2), + "alert": nom_pct > 5.0, + } + + +def evaluate_file( + audit_path: Path, + txt_path: Path, + insee_noms: Set[str], + insee_prenoms: Set[str], +) -> dict: + """Évalue un couple audit.jsonl + pseudonymise.txt.""" + # Charger les données + audit_entries = [] + with audit_path.open("r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + audit_entries.append(json.loads(line)) + + text = txt_path.read_text(encoding="utf-8") + name_tokens = extract_name_tokens(audit_entries) + + # Vérifications + leak_audit = check_leak_audit(text, name_tokens) + leak_regex = check_leak_regex(text) + leak_insee = check_leak_insee(text, insee_noms, insee_prenoms, name_tokens) + fp_medical = check_fp_medical(text) + fp_density = check_fp_density(text) + + # Comptages + audit_kinds = Counter(e.get("kind", "?") for e in audit_entries) + + return { + "file": txt_path.stem.replace(".pseudonymise", ""), + "audit_hits": len(audit_entries), + "audit_kinds": dict(audit_kinds.most_common(10)), + "name_tokens_known": len(name_tokens), + "leak_audit": leak_audit, + "leak_regex": leak_regex, + "leak_insee": leak_insee, + "fp_medical": fp_medical, + "fp_density": fp_density, + "counts": { + "leak_audit": len(leak_audit), + "leak_regex": len(leak_regex), + "leak_insee_high": sum( + 1 for l in leak_insee if l["severity"] == "HAUTE" + ), + "leak_insee_medium": sum( + 1 for l in leak_insee if l["severity"] == "MOYENNE" + ), + "fp_medical": len(fp_medical), + "fp_overmasking": 1 if fp_density.get("alert") else 0, + }, + } + + +def compute_scores(results: List[dict]) -> dict: + """Calcule les scores globaux.""" + total_name_tokens = sum(r["name_tokens_known"] for r in results) + # leak_audit = nombre de tokens UNIQUES qui fuient + total_leak_audit = sum(r["counts"]["leak_audit"] for r in results) + total_leak_occurrences = sum( + sum(l.get("occurrences", 1) for l in r["leak_audit"]) + for r in results + ) + total_leak_regex = sum(r["counts"]["leak_regex"] for r in results) + total_leak_insee_high = sum(r["counts"]["leak_insee_high"] for r in results) + total_leak_insee_med = sum(r["counts"]["leak_insee_medium"] for r in results) + total_fp_medical = sum(r["counts"]["fp_medical"] for r in results) + total_fp_overmask = sum(r["counts"]["fp_overmasking"] for r in results) + total_audit_hits = sum(r["audit_hits"] for r in results) + + # Score leak (100 = aucune fuite, 0 = catastrophique) + # Proportionnel au nombre total de noms connus + if total_name_tokens > 0: + # Taux de fuite = noms uniques qui fuient / total noms connus + leak_rate = total_leak_audit / total_name_tokens + # Pénalité additionnelle pour regex et INSEE (contexte fort) + extra_penalty = (total_leak_regex * 2 + total_leak_insee_high * 1) + leak_score = max(0, round(100 * (1 - leak_rate) - extra_penalty, 1)) + else: + leak_score = 100 if total_leak_audit == 0 else 0 + + # Score FP (100 = aucun faux positif, 0 = sur-masquage massif) + fp_penalty = total_fp_medical * 2 + total_fp_overmask * 5 + fp_score = max(0, 100 - fp_penalty) + + # Score global pondéré (leak plus important que FP) + global_score = round(leak_score * 0.7 + fp_score * 0.3, 1) + + return { + "global_score": global_score, + "leak_score": leak_score, + "fp_score": fp_score, + "totals": { + "documents": len(results), + "audit_hits": total_audit_hits, + "name_tokens_known": total_name_tokens, + "leak_audit": total_leak_audit, + "leak_occurrences": total_leak_occurrences, + "leak_regex": total_leak_regex, + "leak_insee_high": total_leak_insee_high, + "leak_insee_medium": total_leak_insee_med, + "fp_medical": total_fp_medical, + "fp_overmasking": total_fp_overmask, + }, + } + + +def print_report(scores: dict, results: List[dict]) -> None: + """Affiche le rapport console.""" + t = scores["totals"] + + print(f"\n{'='*65}") + print(f" ÉVALUATION QUALITÉ ANONYMISATION") + print(f" {datetime.now().strftime('%Y-%m-%d %H:%M')}") + print(f"{'='*65}") + + # Score global + gs = scores["global_score"] + grade = ( + "A+" if gs >= 98 else "A" if gs >= 95 else "B" if gs >= 90 + else "C" if gs >= 80 else "D" if gs >= 60 else "F" + ) + print(f"\n SCORE GLOBAL : {gs}/100 [{grade}]") + print(f" Leak score : {scores['leak_score']}/100") + print(f" FP score : {scores['fp_score']}/100") + + # Résumé des fuites + print(f"\n --- FUITES (FAUX NÉGATIFS) ---") + print(f" Documents analysés : {t['documents']}") + print(f" Noms connus (audit) : {t['name_tokens_known']}") + print(f" Fuites noms audit : {t['leak_audit']} noms uniques" + f" ({t.get('leak_occurrences', '?')} occurrences)" + f"{' CRITIQUE' if t['leak_audit'] > 0 else ' OK'}") + print(f" Fuites regex (PII) : {t['leak_regex']}" + f"{' HAUTE' if t['leak_regex'] > 0 else ' OK'}") + print(f" Noms INSEE (contexte fort) : {t['leak_insee_high']}" + f"{' HAUTE' if t['leak_insee_high'] > 0 else ' OK'}") + print(f" Noms INSEE (contexte faible): {t['leak_insee_medium']}") + + # Résumé FP + print(f"\n --- FAUX POSITIFS ---") + print(f" Termes médicaux masqués : {t['fp_medical']}") + print(f" Alertes sur-masquage : {t['fp_overmasking']}") + + # Détail des fuites critiques + all_leaks = [] + for r in results: + for leak in r["leak_audit"]: + all_leaks.append((r["file"], leak)) + for leak in r["leak_regex"]: + all_leaks.append((r["file"], leak)) + for leak in r["leak_insee"]: + if leak["severity"] == "HAUTE": + all_leaks.append((r["file"], leak)) + + if all_leaks: + print(f"\n --- DÉTAIL FUITES ({len(all_leaks)}) ---") + for fname, leak in all_leaks[:30]: + sev = leak.get("severity", "?") + if leak["type"] == "LEAK_AUDIT": + print(f" [{sev}] {fname}: nom '{leak['token']}' " + f"encore présent") + print(f" ...{leak['context']}...") + elif leak["type"] == "LEAK_REGEX": + print(f" [{sev}] {fname}: {leak['pii_type']} " + f"'{leak['value']}'") + elif leak["type"] == "LEAK_INSEE": + src = "nom" if leak["is_nom"] else "prénom" + print(f" [{sev}] {fname}: '{leak['word']}' " + f"(INSEE {src}, non masqué)") + print(f" ...{leak['context']}...") + if len(all_leaks) > 30: + print(f" ... et {len(all_leaks) - 30} autres") + + # Détail FP + all_fps = [] + for r in results: + for fp in r["fp_medical"]: + all_fps.append((r["file"], fp)) + + if all_fps: + print(f"\n --- DÉTAIL FAUX POSITIFS ({len(all_fps)}) ---") + for fname, fp in all_fps[:15]: + print(f" {fname}: {fp['pattern']} → '{fp['match'][:60]}'") + + # Fichiers avec problèmes + problem_files = [ + r for r in results + if r["counts"]["leak_audit"] > 0 or r["counts"]["leak_regex"] > 0 + ] + if problem_files: + print(f"\n --- FICHIERS PROBLÉMATIQUES ({len(problem_files)}) ---") + for r in problem_files: + c = r["counts"] + print(f" {r['file']}: " + f"leak_audit={c['leak_audit']} " + f"leak_regex={c['leak_regex']}") + + print(f"\n{'='*65}\n") + + +def save_baseline(scores: dict, results: List[dict], path: Path) -> None: + """Sauvegarde les scores comme baseline.""" + path.parent.mkdir(parents=True, exist_ok=True) + data = { + "date": datetime.now().isoformat(), + "scores": scores, + "per_file": { + r["file"]: r["counts"] for r in results + }, + } + path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + print(f"Baseline sauvegardée : {path}") + + +def compare_baseline(scores: dict, baseline_path: Path) -> None: + """Compare les scores actuels avec la baseline.""" + if not baseline_path.exists(): + print("Pas de baseline trouvée. Utilisez --save d'abord.") + return + + baseline = json.loads(baseline_path.read_text(encoding="utf-8")) + bs = baseline["scores"] + + print(f"\n --- COMPARAISON AVEC BASELINE ({baseline['date'][:10]}) ---") + print(f" {'Métrique':<30} {'Baseline':>10} {'Actuel':>10} {'Delta':>10}") + print(f" {'-'*62}") + + for key in ["global_score", "leak_score", "fp_score"]: + old = bs[key] + new = scores[key] + delta = new - old + marker = " +" if delta > 0 else (" -" if delta < 0 else " ") + print(f" {key:<30} {old:>10.1f} {new:>10.1f} {delta:>+10.1f}{marker}") + + # Comparer les totaux + for key in ["leak_audit", "leak_regex", "leak_insee_high", "fp_medical"]: + old = bs["totals"].get(key, 0) + new = scores["totals"].get(key, 0) + delta = new - old + better = delta < 0 # moins de fuites/FP = mieux + marker = " OK" if better else (" !!" if delta > 0 else "") + print(f" {key:<30} {old:>10} {new:>10} {delta:>+10}{marker}") + + print() + + +def main(): + parser = argparse.ArgumentParser( + description="Évaluation qualité d'anonymisation" + ) + parser.add_argument( + "--dir", type=Path, default=DEFAULT_DIR, + help="Répertoire contenant les fichiers anonymisés" + ) + parser.add_argument( + "--save", action="store_true", + help="Sauvegarder les scores comme baseline" + ) + parser.add_argument( + "--compare", action="store_true", + help="Comparer avec la baseline sauvegardée" + ) + parser.add_argument( + "--json", type=Path, default=None, + help="Exporter le rapport complet en JSON" + ) + parser.add_argument( + "--verbose", "-v", action="store_true", + help="Afficher les détails par fichier" + ) + args = parser.parse_args() + + output_dir = args.dir + if not output_dir.exists(): + print(f"Répertoire non trouvé : {output_dir}") + sys.exit(1) + + # Trouver les paires audit + texte + audit_files = sorted(output_dir.glob("*.audit.jsonl")) + if not audit_files: + print(f"Aucun .audit.jsonl trouvé dans {output_dir}") + sys.exit(1) + + pairs = [] + for af in audit_files: + stem = af.name.replace(".audit.jsonl", "") + txt = af.parent / f"{stem}.pseudonymise.txt" + if txt.exists(): + pairs.append((af, txt)) + + print(f"Chargement gazetteers INSEE...", end=" ", flush=True) + insee_noms, insee_prenoms = load_insee_names() + print(f"{len(insee_noms)} noms, {len(insee_prenoms)} prénoms") + + print(f"Analyse de {len(pairs)} documents...\n", flush=True) + + # Évaluer chaque fichier + results = [] + for af, txt in pairs: + result = evaluate_file(af, txt, insee_noms, insee_prenoms) + results.append(result) + + if args.verbose: + c = result["counts"] + status = "OK" if sum(c.values()) == 0 else "!!" + print(f" [{status}] {result['file']}: " + f"leak_a={c['leak_audit']} " + f"leak_r={c['leak_regex']} " + f"leak_i={c['leak_insee_high']}+{c['leak_insee_medium']} " + f"fp_m={c['fp_medical']} " + f"fp_o={c['fp_overmasking']}") + + # Scores globaux + scores = compute_scores(results) + + # Rapport console + print_report(scores, results) + + # Comparaison baseline + if args.compare: + compare_baseline(scores, BASELINE_PATH) + + # Sauvegarde baseline + if args.save: + save_baseline(scores, results, BASELINE_PATH) + + # Export JSON + if args.json: + report = { + "date": datetime.now().isoformat(), + "directory": str(output_dir), + "scores": scores, + "results": results, + } + args.json.write_text( + json.dumps(report, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + print(f"Rapport JSON : {args.json}") + + # Exit code + if scores["totals"]["leak_audit"] > 0: + sys.exit(1) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/scripts/finetune_camembert_bio.py b/scripts/finetune_camembert_bio.py index 58daed2..bfbedf1 100644 --- a/scripts/finetune_camembert_bio.py +++ b/scripts/finetune_camembert_bio.py @@ -13,9 +13,12 @@ Prérequis: pip install transformers datasets seqeval accelerate Export ONNX post-training: python scripts/export_onnx.py """ import sys +import json +import subprocess import argparse import random from pathlib import Path +from datetime import date from typing import Dict, List, Tuple from collections import Counter @@ -690,8 +693,115 @@ def main(): print(f" Precision: {results['eval_precision']:.4f}") print(f" Recall: {results['eval_recall']:.4f}") print(f" F1: {results['eval_f1']:.4f}") - print(f"\nPour exporter en ONNX:") - print(f" python -m optimum.exporters.onnx --model {args.output_dir / 'best'} {args.output_dir / 'onnx'}") + + # ── Export ONNX automatique ────────────────────────────────────────────── + best_dir = args.output_dir / "best" + onnx_dir = args.output_dir / "onnx" + onnx_export_ok = False + try: + print(f"\nExport ONNX automatique...") + print(f" Source : {best_dir}") + print(f" Destination : {onnx_dir}") + result = subprocess.run( + [ + sys.executable, "-m", "optimum.exporters.onnx", + "--model", str(best_dir), + "--task", "token-classification", + str(onnx_dir), + ], + capture_output=True, + text=True, + timeout=600, + ) + if result.returncode == 0: + onnx_export_ok = True + print(f" Export ONNX réussi → {onnx_dir}") + else: + print(f" [ERREUR] Export ONNX échoué (code {result.returncode})") + if result.stderr: + # Afficher les dernières lignes d'erreur + for line in result.stderr.strip().splitlines()[-10:]: + print(f" {line}") + print(f"\n Pour exporter manuellement :") + print(f" python -m optimum.exporters.onnx --model {best_dir} --task token-classification {onnx_dir}") + except FileNotFoundError: + print(f" [WARN] optimum non installé — export ONNX ignoré") + print(f" Pour exporter manuellement :") + print(f" pip install optimum[exporters]") + print(f" python -m optimum.exporters.onnx --model {best_dir} --task token-classification {onnx_dir}") + except subprocess.TimeoutExpired: + print(f" [ERREUR] Export ONNX timeout (>600s)") + print(f" Pour exporter manuellement :") + print(f" python -m optimum.exporters.onnx --model {best_dir} --task token-classification {onnx_dir}") + except Exception as e: + print(f" [ERREUR] Export ONNX inattendu : {e}") + print(f" Pour exporter manuellement :") + print(f" python -m optimum.exporters.onnx --model {best_dir} --task token-classification {onnx_dir}") + + # ── Mise à jour VERSION.json ───────────────────────────────────────────── + version_file = args.output_dir / "VERSION.json" + try: + # Compter les documents d'entraînement (.bio files) + n_bio_files = len(list(args.data_dir.glob("*.bio"))) + + # Déterminer le numéro de version + if version_file.exists(): + version_data = json.loads(version_file.read_text(encoding="utf-8")) + else: + version_data = { + "model": "camembert-bio-deid", + "base_model": MODEL_NAME, + "versions": {}, + "directories": {}, + } + + # Incrémenter la version + existing_versions = [ + k for k in version_data.get("versions", {}).keys() + if k.startswith("v") and k[1:].isdigit() + ] + if existing_versions: + max_v = max(int(k[1:]) for k in existing_versions) + new_version = f"v{max_v + 1}" + else: + new_version = "v1" + + # Trouver le best checkpoint (dernier sauvegardé par Trainer) + best_checkpoint = None + checkpoints = sorted(args.output_dir.glob("checkpoint-*")) + if checkpoints: + best_checkpoint = checkpoints[-1].name + + # Construire l'entrée de version + version_entry = { + "date": date.today().isoformat(), + "training_docs": n_bio_files, + "training_examples": len(train_tokens), + "epochs": args.epochs, + "batch_size": args.batch_size, + "learning_rate": args.lr, + "f1": round(results["eval_f1"], 4), + "recall": round(results["eval_recall"], 4), + "precision": round(results["eval_precision"], 4), + "onnx_exported": onnx_export_ok, + } + if best_checkpoint: + version_entry["best_checkpoint"] = best_checkpoint + + version_data["current_version"] = new_version + version_data["versions"][new_version] = version_entry + version_data["directories"] = { + "onnx": f"Modèle ONNX actif ({new_version}) — utilisé en inférence CPU", + f"best": f"Modèle PyTorch {new_version} (pour ré-export ONNX si besoin)", + } + + version_file.write_text( + json.dumps(version_data, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + print(f"\n VERSION.json mis à jour → {new_version} (F1={results['eval_f1']:.4f})") + except Exception as e: + print(f"\n [WARN] Impossible de mettre à jour VERSION.json : {e}") if __name__ == "__main__":