feat(phase3): CamemBERT v3 + détection villes + initiales + texte espacé + docs réglementaires
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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"(?<!\d)(?:\+33\s?|0)\d(?:[\s.\-]?\d){8}(?:/\d{1,4})(?!\d)"
|
||||
@@ -1265,6 +1319,35 @@ def _mask_line_by_regex(line: str, audit: List[PiiHit], page_idx: int, cfg: Dict
|
||||
# mais on log le fait qu'un match gazetteer a eu lieu)
|
||||
audit.append(PiiHit(page_idx, "ETAB_FINESS", "gazetteer", PLACEHOLDERS["ETAB"]))
|
||||
|
||||
# Texte espacé d'en-tête : "C E N T R E H O S P I T A L I E R D E ..."
|
||||
# Les lettres majuscules séparées par des espaces échappent à toute détection normale
|
||||
_RE_SPACED_TEXT = re.compile(
|
||||
r'(?:[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]\s){4,}[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]'
|
||||
)
|
||||
_SPACED_ETAB_KEYWORDS = {
|
||||
"HOSPITALIER", "HOSPITALIERE", "HOSPITALIERES", "HOSPITALIERS",
|
||||
"CLINIQUE", "HOPITAL", "HÔPITAL", "POLYCLINIQUE",
|
||||
"CENTRE", "ETABLISSEMENT", "MAISON", "RESIDENCE",
|
||||
"EHPAD", "SSR", "USLD", "CHU", "CHRU",
|
||||
}
|
||||
for m_spaced in _RE_SPACED_TEXT.finditer(line):
|
||||
spaced_span = m_spaced.group(0)
|
||||
# Collapse les espaces : "C E N T R E" → "CENTRE"
|
||||
collapsed = spaced_span.replace(" ", "")
|
||||
# Vérifier si le texte collapsé contient un mot clé d'établissement
|
||||
collapsed_upper = collapsed.upper()
|
||||
if any(kw in collapsed_upper for kw in _SPACED_ETAB_KEYWORDS):
|
||||
audit.append(PiiHit(page_idx, "ETAB_SPACED", spaced_span, PLACEHOLDERS["ETAB"]))
|
||||
line = line.replace(spaced_span, PLACEHOLDERS["ETAB"], 1)
|
||||
|
||||
# Villes par gazetteer Aho-Corasick (INSEE + FINESS)
|
||||
if _VILLE_AC is None:
|
||||
_build_ville_ac()
|
||||
if _VILLE_AC is not None:
|
||||
line, ville_originals = _mask_ville_gazetteers(line)
|
||||
for vo in ville_originals:
|
||||
audit.append(PiiHit(page_idx, "VILLE_GAZ", vo, PLACEHOLDERS["VILLE"]))
|
||||
|
||||
# Services hospitaliers (service de Cardiologie, unité de soins palliatifs, etc.)
|
||||
def _repl_service(m: re.Match) -> 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.)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
258
docs/AIPD-anonymisation.md
Normal file
258
docs/AIPD-anonymisation.md
Normal file
@@ -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*
|
||||
77
docs/camembert-bio-deid-v3-specs.md
Normal file
77
docs/camembert-bio-deid-v3-specs.md
Normal file
@@ -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.
|
||||
235
docs/conformite-rgpd-ia-act.md
Normal file
235
docs/conformite-rgpd-ia-act.md
Normal file
@@ -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*
|
||||
254
evaluation/baseline_scores.json
Normal file
254
evaluation/baseline_scores.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
654
scripts/evaluate_quality.py
Normal file
654
scripts/evaluate_quality.py
Normal file
@@ -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"(?<!\d)(?:\+33\s?|0)\d(?:[ .\-]?\d){8}(?!\d)")
|
||||
RE_NIR = re.compile(
|
||||
r"\b[12]\s?\d{2}\s?(0[1-9]|1[0-2]|2[AB])\s?\d{2,3}\s?\d{3}\s?\d{3}\s?\d{2}\b"
|
||||
)
|
||||
RE_IBAN = re.compile(r"\b[A-Z]{2}\d{2}[\s]?(?:\d{4}[\s]?){4,7}\d{1,4}\b")
|
||||
RE_PLACEHOLDER = re.compile(r"\[[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ_]+\]")
|
||||
|
||||
# Termes médicaux qui ne doivent PAS être masqués (faux positifs connus)
|
||||
MEDICAL_FP_PATTERNS = {
|
||||
# [NOM] MÊME LIGNE suivi d'un terme médical → faux positif probable
|
||||
"ponction_lombaire": re.compile(r"\[NOM\][ \t]+lombaire", re.I),
|
||||
"hanche_context": re.compile(
|
||||
r"(?:de\s+la|de)\s+\[NOM\][ \t]+(?:profil|opérée|fémorale)", re.I
|
||||
),
|
||||
# IRM [NOM] sur la même ligne (pas cross-line)
|
||||
"IRM_NOM": re.compile(r"IRM[ \t]+\[NOM\](?![\s]*(?:médicale|cérébrale))", re.I),
|
||||
# [NOM] suivi de stade/type/lymphome = faux positif (pathologie masquée)
|
||||
"lymphome_context": re.compile(
|
||||
r"\[NOM\][ \t]*\.[ \t]*(?:stade|type|lymphome)", re.I
|
||||
),
|
||||
}
|
||||
|
||||
# Mots à ignorer dans la vérification INSEE (trop ambigus)
|
||||
NAME_IGNORE = {
|
||||
"CENTRE", "SERVICE", "COMPTE", "RENDU", "LETTRE", "SORTIE",
|
||||
"CONSULTATION", "ANESTHESISTE", "BACTERIO", "OBSERVATION",
|
||||
"HOSPITALIER", "CLINIQUE", "HOPITAL", "PHARMACIE", "TABLES",
|
||||
"FINESS", "EMAIL", "ADRESSE", "EPISODE", "ETABLISSEMENT",
|
||||
"NAISSANCE", "POSTAL", "DOSSIER", "RPPS", "GLOBAL",
|
||||
"TRACKARE", "BIOLOGIE", "MEDICALE", "CHIRURGIE", "MEDECINE",
|
||||
"URGENCES", "ANALYSE", "RESULTATS", "DIAGNOSTIC", "ANTECEDENT",
|
||||
"TRAITEMENT", "INTERVENTION", "OPERATOIRE", "RAPPORT",
|
||||
"PATIENT", "MONSIEUR", "MADAME", "DOCTEUR",
|
||||
"NORMAL", "POSITIF", "NEGATIF", "PRESENT", "ABSENT",
|
||||
# Instructions soins Trackare (aussi patronymes INSEE → faux positifs évaluateur)
|
||||
"LEVER", "COUCHER", "MANGER", "MARCHER", "SORTIR", "POSE",
|
||||
"GAUCHE", "DROITE", "ANTERIEUR", "POSTERIEUR",
|
||||
"JANVIER", "FEVRIER", "MARS", "AVRIL", "JUIN", "JUILLET",
|
||||
"AOUT", "SEPTEMBRE", "OCTOBRE", "NOVEMBRE", "DECEMBRE",
|
||||
"FRANCE", "BAYONNE", "BORDEAUX", "PARIS", "TOULOUSE",
|
||||
"SAINT", "SAINTE",
|
||||
}
|
||||
|
||||
# Titres/préfixes qui apparaissent dans les entrées NOM mais ne sont pas des PII
|
||||
TITLE_PREFIXES = {
|
||||
"Dr", "DR", "Pr", "PR", "M", "Mme", "MME", "Mlle", "MLLE",
|
||||
"Docteur", "DOCTEUR", "Professeur", "PROFESSEUR",
|
||||
"Monsieur", "MONSIEUR", "Madame", "MADAME",
|
||||
"Nom", "NOM", "Prénom", "PRENOM", "PRÉNOM",
|
||||
"Date", "DATE", "Adresse", "ADRESSE",
|
||||
"Née", "NEE", "Le", "LE", "La", "LA", "De", "DE", "Du", "DU",
|
||||
"Des", "DES", "Les", "LES", "Au", "AU", "Aux", "AUX",
|
||||
"Et", "ET", "Ou", "OU", "En", "EN",
|
||||
"Ute", # artefact OCR fréquent
|
||||
}
|
||||
|
||||
|
||||
def normalize_nfkd(s: str) -> 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()
|
||||
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user