feat: Filtre hospitalier pour éliminer les faux positifs

- Ajout config/hospital_stopwords.yml avec adresses/téléphones hôpitaux
- Ajout detectors/hospital_filter.py pour filtrer les FP
- Intégration dans anonymizer_core_refactored_onnx.py
- Test sur document: 40 -> 32 détections (-8 FP)
- Élimine: adresses hôpitaux, codes postaux CEDEX, épisodes dans noms de fichiers
This commit is contained in:
2026-03-02 11:21:48 +01:00
parent 70ff0b9e12
commit 6806aee587
10 changed files with 10478 additions and 6 deletions

View File

@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""
Filtre pour éliminer les faux positifs liés aux informations hospitalières.
Les informations d'établissements de santé (adresses, téléphones, etc.) sont publiques
et ne doivent pas être anonymisées.
"""
import re
import yaml
from pathlib import Path
from typing import List, Dict, Set
class HospitalFilter:
"""Filtre les détections qui correspondent à des informations hospitalières."""
def __init__(self, config_path: str = "config/hospital_stopwords.yml"):
"""
Initialise le filtre avec la configuration.
Args:
config_path: Chemin vers le fichier de configuration YAML
"""
self.config_path = Path(config_path)
self.hospital_addresses: Set[str] = set()
self.hospital_postal_codes: Set[str] = set()
self.hospital_cities: Set[str] = set()
self.hospital_phones: Set[str] = set()
self.hospital_phone_patterns: List[re.Pattern] = []
self.anatomical_terms: Set[str] = set()
self.episode_filename_patterns: List[re.Pattern] = []
self._load_config()
def _load_config(self):
"""Charge la configuration depuis le fichier YAML."""
if not self.config_path.exists():
print(f"⚠️ Configuration non trouvée: {self.config_path}")
return
with open(self.config_path, 'r', encoding='utf-8') as f:
config = yaml.safe_load(f)
# Charger les listes
self.hospital_addresses = set(
addr.upper() for addr in config.get('hospital_addresses', [])
)
self.hospital_postal_codes = set(
cp.upper() for cp in config.get('hospital_postal_codes', [])
)
self.hospital_cities = set(
city.upper() for city in config.get('hospital_cities', [])
)
self.hospital_phones = set(config.get('hospital_phones', []))
self.anatomical_terms = set(
term.upper() for term in config.get('anatomical_terms', [])
)
# Compiler les patterns regex
for pattern in config.get('hospital_phone_patterns', []):
self.hospital_phone_patterns.append(re.compile(pattern))
for pattern in config.get('episode_filename_patterns', []):
self.episode_filename_patterns.append(re.compile(pattern))
def is_hospital_address(self, text: str) -> bool:
"""Vérifie si le texte est une adresse d'hôpital."""
text_upper = text.upper().strip()
# Correspondance exacte
if text_upper in self.hospital_addresses:
return True
# Correspondance partielle (pour gérer les variations)
for hospital_addr in self.hospital_addresses:
if hospital_addr in text_upper or text_upper in hospital_addr:
return True
return False
def is_hospital_postal_code(self, text: str) -> bool:
"""Vérifie si le texte est un code postal d'hôpital."""
text_upper = text.upper().strip()
# Correspondance exacte
if text_upper in self.hospital_postal_codes:
return True
# Vérifier si contient "CEDEX" (indicateur d'établissement)
if "CEDEX" in text_upper:
return True
return False
def is_hospital_city(self, text: str) -> bool:
"""Vérifie si le texte est une ville d'hôpital."""
text_upper = text.upper().strip()
# Correspondance exacte
if text_upper in self.hospital_cities:
return True
# Vérifier si contient "CEDEX"
if "CEDEX" in text_upper:
return True
# Vérifier si c'est un terme anatomique
if text_upper in self.anatomical_terms:
return True
return False
def is_hospital_phone(self, text: str) -> bool:
"""Vérifie si le texte est un téléphone d'hôpital."""
text_clean = text.strip()
# Correspondance exacte
if text_clean in self.hospital_phones:
return True
# Vérifier les patterns
for pattern in self.hospital_phone_patterns:
if pattern.match(text_clean):
return True
return False
def is_episode_in_filename(self, text: str, filename: str = "") -> bool:
"""
Vérifie si le numéro d'épisode provient du nom de fichier.
Ces numéros apparaissent dans les métadonnées mais pas dans le contenu patient.
"""
if not filename:
return False
# Vérifier si le texte apparaît dans le nom de fichier
if text in filename:
return True
return False
def should_filter(self, pii_type: str, text: str, filename: str = "", page: int = -1) -> bool:
"""
Détermine si une détection doit être filtrée (= faux positif).
Args:
pii_type: Type de PII (ADRESSE, TEL, VILLE, etc.)
text: Texte détecté
filename: Nom du fichier source (optionnel)
page: Numéro de page (-1 = métadonnées)
Returns:
True si la détection doit être filtrée (faux positif)
"""
# Les détections en page -1 sont souvent des métadonnées
if page == -1:
# Les épisodes en métadonnées sont souvent des faux positifs
if pii_type == "EPISODE" and self.is_episode_in_filename(text, filename):
return True
# Filtrer par type
if pii_type == "ADRESSE":
return self.is_hospital_address(text)
elif pii_type == "CODE_POSTAL":
return self.is_hospital_postal_code(text)
elif pii_type == "VILLE":
return self.is_hospital_city(text)
elif pii_type == "TEL":
return self.is_hospital_phone(text)
elif pii_type == "EPISODE":
return self.is_episode_in_filename(text, filename)
return False
def filter_detections(self, detections: List[Dict], filename: str = "") -> List[Dict]:
"""
Filtre une liste de détections pour éliminer les faux positifs.
Args:
detections: Liste de détections (format: {'kind': ..., 'original': ..., 'page': ...})
filename: Nom du fichier source
Returns:
Liste de détections filtrées
"""
filtered = []
for det in detections:
pii_type = det.get('kind', '')
text = det.get('original', '')
page = det.get('page', -1)
if not self.should_filter(pii_type, text, filename, page):
filtered.append(det)
return filtered
# Test du filtre
if __name__ == "__main__":
filter = HospitalFilter()
# Tests
test_cases = [
("ADRESSE", "13, Avenue de l'Interne J", "", -1, True),
("ADRESSE", "22 LOT MENDI ALDE", "", -1, False),
("CODE_POSTAL", "64109 BAYONNE CEDEX", "", -1, True),
("CODE_POSTAL", "64130", "", -1, False),
("VILLE", "BAYONNE CEDEX", "", -1, True),
("VILLE", "CHERAUTE", "", -1, False),
("VILLE", "DROIT", "", -1, True), # Terme anatomique
("TEL", "05 59 44 35 35", "", -1, True),
("TEL", "0676085336", "", -1, False),
("EPISODE", "23202435", "trackare-14004105-23202435", -1, True),
("EPISODE", "23102610", "CRH_23102610", 0, False),
]
print("Tests du filtre hospitalier:")
print("=" * 80)
for pii_type, text, filename, page, expected in test_cases:
result = filter.should_filter(pii_type, text, filename, page)
status = "" if result == expected else ""
print(f"{status} {pii_type:15s} '{text:30s}' -> {result} (attendu: {expected})")