feat(phase2): Gazetteers FINESS 102K établissements + fine-tuning CamemBERT-bio F1=89%

Gazetteers FINESS (data.gouv.fr open data):
- 102K numéros FINESS → détection par lookup exact dans _mask_admin_label + selective_rescan
- 122K noms d'établissements, 113K téléphones, 76K adresses (disponibles)
- Un nombre 9 chiffres matchant un vrai FINESS est masqué même sans label "FINESS"

Fine-tuning CamemBERT-bio (almanach/camembert-bio-base):
- Export silver annotations réécrit : alignement original↔pseudonymisé (difflib)
  → 6862 entités B- (vs 3344 avec l'ancien audit-only) sur 222K tokens
- Sliding windows (200 tokens, stride 100) pour documents longs
- WeightedNERTrainer avec class weights cappés (max 10x) + label smoothing
- Résultat: Precision=88.1%, Recall=89.8%, F1=88.9% (20 epochs, lr=1e-5)
- Modèle sauvegardé dans models/camembert-bio-deid/best (non commité)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 13:27:37 +01:00
parent 6e0e8c7312
commit 26b210607c
36 changed files with 447533 additions and 62915 deletions

View File

@@ -1,9 +1,10 @@
#!/usr/bin/env python3
"""
Export silver annotations — Génère des données d'entraînement BIO à partir du pipeline existant.
================================================================================================
Utilise le pipeline regex+NER+VLM actuel pour produire des annotations "silver standard"
sur les 706 OGC. Ces annotations servent de base pour fine-tuner CamemBERT-bio.
Export silver annotations — BIO via alignement texte original ↔ pseudonymisé.
=============================================================================
Aligne le texte extrait du PDF original avec le texte pseudonymisé (.pseudonymise.txt)
pour créer des annotations BIO fiables. Les placeholders [NOM], [TEL], etc. dans le
texte pseudonymisé indiquent exactement quels tokens ont été masqués.
Usage:
python scripts/export_silver_annotations.py [--limit N] [--out-dir DIR]
@@ -13,21 +14,15 @@ Format BIO: TOKEN\tLABEL (un token par ligne, lignes vides entre phrases)
"""
import sys
import re
import json
import difflib
import argparse
from pathlib import Path
from typing import List, Tuple
from typing import Dict, List, Tuple
sys.path.insert(0, str(Path(__file__).parent.parent))
# Regex pour détecter les placeholders et reconstruire l'alignement
PLACEHOLDER_RE = re.compile(
r"\[(NOM|TEL|EMAIL|NIR|IPP|DOSSIER|NDA|EPISODE|RPPS|DATE_NAISSANCE|"
r"ADRESSE|CODE_POSTAL|VILLE|MASK|FINESS|OGC|AGE|ETAB|IBAN)\]"
)
# Mapping placeholder → label BIO
PH_TO_BIO = {
PLACEHOLDER_TO_BIO: Dict[str, str] = {
"NOM": "PER",
"TEL": "TEL",
"EMAIL": "EMAIL",
@@ -41,78 +36,178 @@ PH_TO_BIO = {
"ADRESSE": "ADRESSE",
"CODE_POSTAL": "ZIP",
"VILLE": "VILLE",
"ETAB": "HOPITAL",
"FINESS": "HOPITAL",
"HOPITAL": "HOPITAL",
"MASK": "HOPITAL", # [MASK] = hôpital masqué par force_regex
"IBAN": "IBAN",
"AGE": "AGE",
"OGC": "NDA",
"MASK": "O", # MASK générique = pas d'annotation spécifique
}
RE_PLACEHOLDER = re.compile(r"^\[([A-Z_]+)\]$")
def text_to_bio(pseudonymised_text: str) -> List[Tuple[str, str]]:
"""Convertit un texte pseudonymisé en séquence BIO.
SRC = Path("/home/dom/Téléchargements/II-1 Ctrl_T2A_2025_CHCB_DocJustificatifs (1)")
AUDIT_DIR = SRC / "anonymise_audit_30"
Les tokens [PLACEHOLDER] deviennent B-TYPE / I-TYPE.
Les tokens normaux deviennent O.
def extract_original_text(pdf_path: Path) -> str:
"""Extrait le texte brut d'un PDF (même méthode que le pipeline)."""
import anonymizer_core_refactored_onnx as core
pages_text, _, _, _ = core.extract_text_with_fallback_ocr(pdf_path)
return "\f".join(pages_text)
def tokenize_text(text: str) -> List[str]:
"""Split en tokens whitespace, en nettoyant les caractères de contrôle."""
# Remplacer \f et \r par \n pour l'alignement
text = text.replace("\f", "\n").replace("\r", "")
tokens = []
for line in text.split("\n"):
line_toks = line.split()
if line_toks:
tokens.extend(line_toks)
return tokens
def align_and_annotate(original_text: str, pseudo_text: str) -> List[Tuple[str, str]]:
"""Aligne texte original et pseudonymisé pour créer les annotations BIO.
Utilise SequenceMatcher pour trouver les différences.
Quand le pseudo contient [PLACEHOLDER], les tokens originaux correspondants
reçoivent le label BIO approprié.
"""
orig_tokens = tokenize_text(original_text)
pseudo_tokens = tokenize_text(pseudo_text)
# Normaliser pour l'alignement (lowercase, sans accents pour meilleur matching)
def normalize(tok):
return tok.lower().strip(".,;:!?()[]{}\"'")
orig_norm = [normalize(t) for t in orig_tokens]
pseudo_norm = [normalize(t) for t in pseudo_tokens]
sm = difflib.SequenceMatcher(None, orig_norm, pseudo_norm, autojunk=False)
opcodes = sm.get_opcodes()
bio_tokens: List[Tuple[str, str]] = []
# Split le texte en segments : alternance texte normal / placeholder
parts = PLACEHOLDER_RE.split(pseudonymised_text)
# parts = [texte, label, texte, label, texte, ...]
for tag, i1, i2, j1, j2 in opcodes:
if tag == "equal":
# Tokens identiques → O
for t in orig_tokens[i1:i2]:
bio_tokens.append((t, "O"))
i = 0
while i < len(parts):
if i % 2 == 0:
# Texte normal
text_part = parts[i]
for word in text_part.split():
word = word.strip()
if word:
bio_tokens.append((word, "O"))
else:
# Label de placeholder
label = parts[i]
bio_label = PH_TO_BIO.get(label, "O")
if bio_label != "O":
# Le placeholder remplace un ou plusieurs tokens
bio_tokens.append((f"[{label}]", f"B-{bio_label}"))
elif tag == "replace":
# Analyser le côté pseudo : quels tokens sont des placeholders ?
pseudo_chunk = pseudo_tokens[j1:j2]
placeholder_labels = [] # (index_in_pseudo, bio_label) pour chaque placeholder
non_placeholder_norms = set()
for pi, pt in enumerate(pseudo_chunk):
m = RE_PLACEHOLDER.match(pt)
if m:
bio_label = PLACEHOLDER_TO_BIO.get(m.group(1))
if bio_label:
placeholder_labels.append((pi, bio_label))
else:
non_placeholder_norms.add(normalize(pt))
if not placeholder_labels:
# Pas de placeholder → O
for t in orig_tokens[i1:i2]:
bio_tokens.append((t, "O"))
elif len(placeholder_labels) == 1:
# Un seul placeholder : tous les tokens originaux (sauf ceux
# qui matchent un token non-placeholder du pseudo) prennent ce label
label = placeholder_labels[0][1]
first = True
for t in orig_tokens[i1:i2]:
if normalize(t) in non_placeholder_norms:
bio_tokens.append((t, "O"))
first = True
else:
prefix = "B-" if first else "I-"
bio_tokens.append((t, f"{prefix}{label}"))
first = False
else:
bio_tokens.append((f"[{label}]", "O"))
i += 1
# Plusieurs placeholders : distribuer les tokens originaux
# Stratégie : répartir proportionnellement, chaque groupe commence par B-
n_orig = i2 - i1
n_placeholders = len(placeholder_labels)
# Exclure d'abord les tokens qui matchent des non-placeholders
orig_assignments = []
for t in orig_tokens[i1:i2]:
if normalize(t) in non_placeholder_norms:
orig_assignments.append(("O", None))
else:
orig_assignments.append(("PII", None))
# Distribuer les tokens PII entre les placeholders
pii_indices = [k for k, (tp, _) in enumerate(orig_assignments) if tp == "PII"]
n_pii = len(pii_indices)
if n_pii > 0 and n_placeholders > 0:
chunk_size = max(1, n_pii // n_placeholders)
for pi_idx, (_, label) in enumerate(placeholder_labels):
start_pii = pi_idx * chunk_size
end_pii = (pi_idx + 1) * chunk_size if pi_idx < n_placeholders - 1 else n_pii
for k in range(start_pii, min(end_pii, n_pii)):
orig_assignments[pii_indices[k]] = ("PII", label)
# Générer les BIO tokens
prev_label = None
for k, (t, (tp, label)) in enumerate(zip(orig_tokens[i1:i2], orig_assignments)):
if tp == "O" or label is None:
bio_tokens.append((t, "O"))
prev_label = None
else:
prefix = "B-" if label != prev_label else "I-"
bio_tokens.append((t, f"{prefix}{label}"))
prev_label = label
elif tag == "delete":
# Tokens présents uniquement dans l'original → O
for t in orig_tokens[i1:i2]:
bio_tokens.append((t, "O"))
elif tag == "insert":
# Tokens ajoutés dans le pseudo (rare) → ignorer
pass
return bio_tokens
def export_document(pseudo_path: Path, out_dir: Path) -> int:
"""Exporte un fichier pseudonymisé en format BIO. Retourne le nombre de tokens."""
text = pseudo_path.read_text(encoding="utf-8", errors="replace")
def export_document(pdf_path: Path, pseudo_path: Path, out_dir: Path) -> Tuple[int, int]:
"""Exporte un document en format BIO. Retourne (nb_tokens, nb_entités)."""
# Extraire le texte original
original_text = extract_original_text(pdf_path)
if not original_text.strip():
return 0, 0
bio_tokens = text_to_bio(text)
if not bio_tokens:
return 0
# Lire le texte pseudonymisé
pseudo_text = pseudo_path.read_text(encoding="utf-8")
if not pseudo_text.strip():
return 0, 0
# Écrire en format CoNLL (TOKEN\tLABEL)
out_path = out_dir / pseudo_path.name.replace(".pseudonymise.txt", ".bio")
# Aligner et annoter
bio_tokens = align_and_annotate(original_text, pseudo_text)
# Écrire en format CoNLL
out_name = pdf_path.stem + ".bio"
out_path = out_dir / out_name
lines = []
for token, label in bio_tokens:
# Séparer les "phrases" par des lignes vides (heuristique: point final ou retour ligne)
# Séparer les phrases par des lignes vides (ponctuation finale)
if token in (".", "!", "?") and label == "O":
lines.append(f"{token}\t{label}")
lines.append("") # séparateur de phrase
lines.append("")
else:
lines.append(f"{token}\t{label}")
out_path.write_text("\n".join(lines), encoding="utf-8")
return len(bio_tokens)
n_ents = sum(1 for _, l in bio_tokens if l.startswith("B-"))
return len(bio_tokens), n_ents
def main():
parser = argparse.ArgumentParser(description="Export silver annotations BIO")
parser.add_argument("--input-dir", type=Path,
default=Path("/home/dom/Téléchargements/II-1 Ctrl_T2A_2025_CHCB_DocJustificatifs (1)/anonymise_audit_30"),
help="Répertoire contenant les .pseudonymise.txt")
parser = argparse.ArgumentParser(description="Export silver annotations BIO (alignement original ↔ pseudo)")
parser.add_argument("--out-dir", type=Path,
default=Path(__file__).parent.parent / "data" / "silver_annotations",
help="Répertoire de sortie")
@@ -121,23 +216,34 @@ def main():
args.out_dir.mkdir(parents=True, exist_ok=True)
pseudo_files = sorted(args.input_dir.glob("*.pseudonymise.txt"))
if args.limit > 0:
pseudo_files = pseudo_files[:args.limit]
# Trouver les paires PDF + pseudo
pseudo_files = sorted(AUDIT_DIR.glob("*.pseudonymise.txt"))
pairs = []
for pseudo_path in pseudo_files:
# Retrouver le PDF source
base_name = pseudo_path.name.replace(".pseudonymise.txt", ".pdf")
# Chercher dans les sous-dossiers OGC
found = list(SRC.glob(f"*/{base_name}"))
if found:
pairs.append((found[0], pseudo_path))
print(f"Export silver annotations: {len(pseudo_files)} fichiers → {args.out_dir}")
if args.limit > 0:
pairs = pairs[:args.limit]
print(f"Export silver annotations: {len(pairs)} documents → {args.out_dir}")
total_tokens = 0
total_entities = 0
for f in pseudo_files:
n = export_document(f, args.out_dir)
ent_count = sum(1 for line in (args.out_dir / f.name.replace(".pseudonymise.txt", ".bio")).read_text().splitlines()
if line and not line.endswith("\tO"))
total_tokens += n
total_entities += ent_count
print(f" {f.name}: {n} tokens, {ent_count} entités")
for pdf_path, pseudo_path in pairs:
try:
n_tok, n_ent = export_document(pdf_path, pseudo_path, args.out_dir)
total_tokens += n_tok
total_entities += n_ent
print(f" {pdf_path.name}: {n_tok} tokens, {n_ent} entités")
except Exception as e:
print(f" {pdf_path.name}: ERREUR {e}")
print(f"\nTotal: {total_tokens} tokens, {total_entities} entités annotées")
print(f"\nTotal: {total_tokens} tokens, {total_entities} entités B-")
print(f"Sortie: {args.out_dir}")