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:
229
detectors/hospital_filter.py
Normal file
229
detectors/hospital_filter.py
Normal 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})")
|
||||
Reference in New Issue
Block a user