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:
2026-03-11 12:16:13 +01:00
parent c9572c383a
commit eb14cd219d
8 changed files with 1957 additions and 9 deletions

View File

@@ -156,6 +156,48 @@ _FINESS_ETAB_NAMES: set = set() # noms d'établissements (lowercase)
_FINESS_TELEPHONES: set = set() # téléphones 10 chiffres _FINESS_TELEPHONES: set = set() # téléphones 10 chiffres
_FINESS_VILLES: set = set() # villes FINESS (uppercase) _FINESS_VILLES: set = set() # villes FINESS (uppercase)
_FINESS_AC = None # Automate Aho-Corasick pour noms distinctifs _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: try:
import ahocorasick as _ahocorasick import ahocorasick as _ahocorasick
@@ -537,6 +579,8 @@ _MEDICAL_STOP_WORDS_SET = {
"digestif", "digestive", "digestives", "nutritive", "digestif", "digestive", "digestives", "nutritive",
# Abréviations soins trackare détectées comme NOM (batch 20 OGC) # Abréviations soins trackare détectées comme NOM (batch 20 OGC)
"soins", "lit", "jeun", "lever", "pose", "surv", "ggt", "vvp", "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", "verif", "crop", "evs", "maco", "pan", "cet", "trou", "nit", "nfs",
# Mots narratifs CRH capturés par fusion sidebar 2-colonnes # Mots narratifs CRH capturés par fusion sidebar 2-colonnes
"evolution", "évolution", "explorations", "fermeture", "allergie", "allergies", "evolution", "évolution", "explorations", "fermeture", "allergie", "allergies",
@@ -671,6 +715,11 @@ _MEDICAL_STOP_WORDS_SET = {
"probnp", "pro-bnp", "nt-probnp", "probnp", "pro-bnp", "nt-probnp",
"bpco", "colle", "gsc", "masse", "bpco", "colle", "gsc", "masse",
"selle", "selles", "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 # Enrichissement automatique avec les ~4000 noms de médicaments d'edsnlp
_MEDICAL_STOP_WORDS_SET.update(_load_edsnlp_drug_names()) _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. # Token nom composé : JEAN-PIERRE, CAZELLES-BOUDIER, etc.
_UC_COMPOUND = r"[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]{2,}(?:-[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]{2,})*" _UC_COMPOUND = r"[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]{2,}(?:-[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]{2,})*"
RE_EXTRACT_MME_MR = re.compile( 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*)?)?" 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*)?)?" _INITIAL_OPT = r"(?:[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]\.\s*(?:-?\s*[A-ZÉÈÀÙÂÊÎÔÛÄËÏÖÜÇ]\.\s*)?)?"
RE_EXTRACT_DR_DEST = re.compile( RE_EXTRACT_DR_DEST = re.compile(
@@ -772,6 +821,11 @@ RE_EXTRACT_OPERATEUR = re.compile(
+ _INITIAL_OPT + + _INITIAL_OPT +
rf"((?:{_UC_COMPOUND})(?:[ \t]+(?:{_UC_COMPOUND})){{0,2}})", 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 # Téléphone avec extension slash : 05.59.44.38.32/34
RE_TEL_SLASH = re.compile( RE_TEL_SLASH = re.compile(
r"(?<!\d)(?:\+33\s?|0)\d(?:[\s.\-]?\d){8}(?:/\d{1,4})(?!\d)" 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) # mais on log le fait qu'un match gazetteer a eu lieu)
audit.append(PiiHit(page_idx, "ETAB_FINESS", "gazetteer", PLACEHOLDERS["ETAB"])) 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.) # Services hospitaliers (service de Cardiologie, unité de soins palliatifs, etc.)
def _repl_service(m: re.Match) -> str: def _repl_service(m: re.Match) -> str:
full_match = m.group(0) 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) # Opérateur / Anesthésiste / Chirurgien + nom(s)
for m in RE_EXTRACT_OPERATEUR.finditer(full_text): for m in RE_EXTRACT_OPERATEUR.finditer(full_text):
_add_tokens_force_first(m.group(1)) _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 # Extraction des noms dans les listes virgulées après Dr/Docteur
# ex: "le Dr DUVAL, MACHELART, CHARLANNE, LAZARO, il a été proposé" # 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"), # Pour les noms composés avec tiret (ex: "LACLAU-LACROUTS"),
# ajouter aussi les parties individuelles pour capturer les occurrences standalone. # ajouter aussi les parties individuelles pour capturer les occurrences standalone.
# _apply_extracted_names traite le composé en premier (plus long) puis les parties. # _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} compound_names = {n for n in names if "-" in n}
for compound in compound_names: for compound in compound_names:
for part in compound.split("-"): for part in compound.split("-"):
part = part.strip() part = part.strip()
if len(part) >= 3 and part.lower() not in _MEDICAL_STOP_WORDS_SET: if len(part) >= 3:
names.add(part) names.add(part)
force_names.add(part)
return names, force_names 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) # Ne pas remplacer si le token fait partie d'un mot composé (tiret + lettre)
# Ex: "NOCENT-EJNAINI" → ne pas remplacer NOCENT seul # Ex: "NOCENT-EJNAINI" → ne pas remplacer NOCENT seul
# Mais "LACLAU-" (tiret de troncature) → remplacer # 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() > 0 and text[m.start() - 1] == "-":
if m.start() >= 2 and text[m.start() - 2].isalpha(): if m.start() >= 2 and text[m.start() - 2].isalpha():
continue 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() < len(text) and text[m.end()] == "-":
if m.end() + 1 < len(text) and text[m.end() + 1].isalpha(): if m.end() + 1 < len(text) and text[m.end() + 1].isalpha():
continue continue
@@ -2280,6 +2382,197 @@ def _mask_finess_establishments(text: str) -> str:
return "".join(result) 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 ----------------- # ----------------- Selective safety rescan -----------------
def selective_rescan(text: str, cfg: Dict[str, Any] | None = None) -> str: 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) # Établissements (gazetteer Aho-Corasick FINESS — 116K noms distinctifs)
if _FINESS_AC is not None: if _FINESS_AC is not None:
protected = _mask_finess_establishments(protected) 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 # Services hospitaliers
protected = RE_SERVICE.sub(PLACEHOLDERS["MASK"], protected) protected = RE_SERVICE.sub(PLACEHOLDERS["MASK"], protected)
# Lieu de naissance / Ville de résidence (accepte tout : villes, codes INSEE, minuscules) # 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
return raw.replace(span, PLACEHOLDERS["NOM"]) return raw.replace(span, PLACEHOLDERS["NOM"])
protected = RE_PERSON_CONTEXT.sub(_rescan_person, protected) 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) res = list(protected)
for start, end, payload in kept: for start, end, payload in kept:
res[start:end] = list(payload) res[start:end] = list(payload)
@@ -2772,6 +3089,26 @@ def process_pdf(
return m.group(0) return m.group(0)
final_text = _re_tel_partial.sub(_clean_tel_partial, final_text) 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) # 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.) # pour que la redaction PDF les cherche partout (sidebar répété, etc.)

View File

@@ -3,13 +3,16 @@
""" """
CamemBERT-bio NER Manager — Inférence ONNX pour la désidentification clinique. 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 Modèle fine-tuné sur almanach/camembert-bio-base avec des annotations silver.
issues de 29 documents cliniques français (F1=89% sur validation).
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, Utilisé comme signal NER supplémentaire dans le pipeline d'anonymisation,
en complément d'EDS-Pseudo et GLiNER (vote majoritaire). 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 from __future__ import annotations
@@ -70,6 +73,10 @@ class CamembertNerManager:
def is_loaded(self) -> bool: def is_loaded(self) -> bool:
return self._loaded return self._loaded
@property
def version(self) -> str:
return getattr(self, "_version", "?")
def load(self) -> None: def load(self) -> None:
"""Charge le modèle ONNX et le tokenizer.""" """Charge le modèle ONNX et le tokenizer."""
if not _ORT_AVAILABLE: if not _ORT_AVAILABLE:
@@ -102,7 +109,23 @@ class CamembertNerManager:
# Tokenizer # Tokenizer
self._tokenizer = AutoTokenizer.from_pretrained(str(self._model_dir)) self._tokenizer = AutoTokenizer.from_pretrained(str(self._model_dir))
self._loaded = True 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: def unload(self) -> None:
self._session = None self._session = None

258
docs/AIPD-anonymisation.md Normal file
View 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*

View 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.

View 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*

View 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
View 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()

View File

@@ -13,9 +13,12 @@ Prérequis: pip install transformers datasets seqeval accelerate
Export ONNX post-training: python scripts/export_onnx.py Export ONNX post-training: python scripts/export_onnx.py
""" """
import sys import sys
import json
import subprocess
import argparse import argparse
import random import random
from pathlib import Path from pathlib import Path
from datetime import date
from typing import Dict, List, Tuple from typing import Dict, List, Tuple
from collections import Counter from collections import Counter
@@ -690,8 +693,115 @@ def main():
print(f" Precision: {results['eval_precision']:.4f}") print(f" Precision: {results['eval_precision']:.4f}")
print(f" Recall: {results['eval_recall']:.4f}") print(f" Recall: {results['eval_recall']:.4f}")
print(f" F1: {results['eval_f1']:.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__": if __name__ == "__main__":