feat(phase2): Intégration CamemBERT-bio ONNX comme 3e signal NER (vote triple)

- camembert_ner_manager.py : inférence ONNX CPU (~10ms), predict/predict_long/validate_eds_entities
- Vote triple NER : EDS-Pseudo (confiance) + GLiNER (zero-shot) + CamemBERT-bio (fine-tuné F1=89%)
- CamemBERT-bio peut sauver un vrai nom à basse confiance EDS (camembert_confirmed=True)
- CamemBERT-bio confirme le rejet des FP médicaux (Paracétamol, Tramadol → False)
- Intégré dans process_pdf via paramètre camembert_manager
- run_batch_30_audit.py mis à jour pour charger le modèle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 13:42:56 +01:00
parent 26b210607c
commit 19e089ea38
3 changed files with 326 additions and 10 deletions

View File

@@ -1943,20 +1943,21 @@ def _mask_with_eds_pseudo(text: str, ents: List[Dict[str, Any]], cfg: Dict[str,
# Vérifier si c'est un médicament connu
if w.lower() in _MEDICATION_WHITELIST:
continue
# Chantier 3+4 : Confiance NER + vote croisé GLiNER + gazetteers INSEE
# Chantier 3+4+5 : Confiance NER + vote croisé GLiNER + CamemBERT-bio + gazetteers INSEE
# Sécurité d'abord : haute confiance NER → toujours masquer
# GLiNER peut rejeter SEULEMENT si confiance NER basse
gliner_vote = e.get("gliner_confirmed") # True=PII, False=médical, None=neutre
# GLiNER/CamemBERT peuvent rejeter SEULEMENT si confiance NER basse
gliner_vote = e.get("gliner_confirmed") # True=PII, False=médical, None=neutre
camembert_vote = e.get("camembert_confirmed") # True=PII confirmé, False=non détecté, None=neutre
if label in ("NOM", "PRENOM"):
score = e.get("score", 1.0)
# Gazetteer INSEE : prénom connu = renforcement confiance (ne pas filtrer)
is_known_prenom = w.lower() in _INSEE_PRENOMS
if isinstance(score, float) and score < 0.70 and not is_known_prenom:
# Basse confiance NER + pas un prénom connu : GLiNER peut trancher
if gliner_vote is False:
continue # NER pas sûr + GLiNER dit "médical" → skip
if score < 0.30:
continue # Très basse confiance → skip même sans GLiNER
# Basse confiance NER + pas un prénom connu
if gliner_vote is False and camembert_vote is not True:
continue # GLiNER dit "médical" + CamemBERT ne confirme pas → skip
if score < 0.30 and camembert_vote is not True:
continue # Très basse confiance + CamemBERT ne confirme pas → skip
# Chantier 2 : Safe patterns contextuels (Philter-style)
# Token suivi/précédé de dosages ou formes pharma → jamais un nom de personne
pos = text.find(w)
@@ -1994,7 +1995,8 @@ def _mask_with_eds_pseudo(text: str, ents: List[Dict[str, Any]], cfg: Dict[str,
def apply_eds_pseudo_on_narrative(text_out: str, cfg: Dict[str, Any], manager: "EdsPseudoManager",
gliner_mgr: Any = None) -> Tuple[str, List[PiiHit]]:
gliner_mgr: Any = None,
camembert_mgr: Any = None) -> Tuple[str, List[PiiHit]]:
"""Applique EDS-Pseudo sur le narratif avec validation croisée GLiNER optionnelle."""
if manager is None or not manager.is_loaded():
return text_out, []
@@ -2021,6 +2023,10 @@ def apply_eds_pseudo_on_narrative(text_out: str, cfg: Dict[str, Any], manager: "
if gliner_mgr is not None and hasattr(gliner_mgr, 'validate_entities') and gliner_mgr.is_loaded():
for i, (para, ents) in enumerate(zip(paras, ents_per_para)):
ents_per_para[i] = gliner_mgr.validate_entities(para, ents, threshold=0.4)
# Chantier 5 : Validation croisée CamemBERT-bio (vote NER fine-tuné)
if camembert_mgr is not None and hasattr(camembert_mgr, 'validate_eds_entities') and camembert_mgr.is_loaded():
for i, (para, ents) in enumerate(zip(paras, ents_per_para)):
ents_per_para[i] = camembert_mgr.validate_eds_entities(para, ents, threshold=0.3)
buf = []
for para, ents in zip(paras, ents_per_para):
masked = _mask_with_eds_pseudo(para, ents, cfg, hits)
@@ -2465,6 +2471,7 @@ def process_pdf(
ogc_label: Optional[str] = None,
vlm_manager=None,
gliner_manager=None,
camembert_manager=None,
) -> Dict[str, str]:
out_dir.mkdir(parents=True, exist_ok=True)
cfg = load_dictionaries(config_path)
@@ -2487,7 +2494,7 @@ def process_pdf(
if use_hf and ner_manager is not None and ner_manager.is_loaded():
# Détecter le type de manager et appeler la bonne fonction
if EdsPseudoManager is not None and isinstance(ner_manager, EdsPseudoManager):
final_text, hf_hits = apply_eds_pseudo_on_narrative(final_text, cfg, ner_manager, gliner_mgr=gliner_manager)
final_text, hf_hits = apply_eds_pseudo_on_narrative(final_text, cfg, ner_manager, gliner_mgr=gliner_manager, camembert_mgr=camembert_manager)
else:
final_text, hf_hits = apply_hf_ner_on_narrative(final_text, cfg, ner_manager, ner_thresholds)
anon.audit.extend(hf_hits)