feat: rééquilibrage dataset LoRA — raisonnement DIM vs mémorisation

Passe de 95/3/2 (lookups/raisonnement/règles) à ~31/49/20.
Dataset cible ~16K exemples denses (vs 66K de lookups avant).

Modifiés :
- 03_convert_cache.py : cache complet 1840 entrées (actuel + backup)
- 04_build_dataset.py : subsampling agressif (CIM-10 1.5K, CCAM 1.5K,
  CoCoA 2K) + sélection intelligente priorisant le raisonnement
- 12_generate_pipeline_examples.py : 3 templates (court + long + CPAM),
  cache actuel, cible ~2800 exemples

Créés :
- 13_generate_fascicule_reasoning.py : parsing 10 fascicules ATIH,
  génération Q&A raisonnement via Claude Opus 4.6 (~450 exemples)
- 14_generate_negative_examples.py : 1000 exemples négatifs
  (symptômes/DP, redondances sémantiques, DAS non significatifs)
- 15_generate_discrimination.py : 800 exercices de discrimination
  entre codes siblings CIM-10 via Claude Opus 4.6
- 16_parse_guide_metho.py : extraction Guide Méthodologique MCO 2026,
  Q&A directes + raisonnement via Claude Opus 4.6 (~500 exemples)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-16 19:42:33 +01:00
commit 06100df236
21 changed files with 6106 additions and 0 deletions

View File

@@ -0,0 +1,477 @@
#!/usr/bin/env python3
"""
Parse les référentiels ATIH/PMSI pour générer des exemples ChatML.
Sources :
1. Annexe-4 CMA V11e — niveaux de sévérité officiels (tabulaire)
2. Racines GHM V11e — caractéristiques des racines (tabulaire)
3. Fascicules de codage — règles par spécialité (texte libre)
4. Instruction DGOS contrôle T2A — priorités de contrôle
Déduplique automatiquement avec les données CoCoA existantes (sévérité CMA).
Usage :
python scripts/11_parse_referentiels.py
"""
import json
import re
from pathlib import Path
import pdfplumber
BASE = Path(__file__).resolve().parent.parent
T2A = BASE.parent / "t2a"
REF_DIR = T2A / "data" / "referentiels"
OUTPUT = BASE / "data" / "processed"
OUTPUT.mkdir(parents=True, exist_ok=True)
SYSTEM_PROMPT = (
"Tu es un médecin DIM expert en codage CIM-10 pour le PMSI français. "
"Tu t'appuies sur les référentiels ATIH officiels."
)
SYSTEM_PROMPT_GHM = (
"Tu es un médecin DIM expert en groupage GHM/GHS pour le PMSI français. "
"Tu connais les règles de classification des GHM version 11e."
)
SYSTEM_PROMPT_CONTROLE = (
"Tu es un médecin DIM expert en contrôle T2A. "
"Tu connais les instructions DGOS et les règles de contrôle externe."
)
def _chatml(system: str, user: str, assistant: str, source: str = "") -> dict:
d = {
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
{"role": "assistant", "content": assistant},
]
}
if source:
d["source"] = source
return d
# ─── 1. Annexe-4 CMA ────────────────────────────────────────────────────────
def parse_annexe4_cma() -> list[dict]:
"""Parse l'Annexe-4 : Diagnostics classés CMA avec niveaux de sévérité."""
pdf_path = list(REF_DIR.glob("*Annexe-4*CMA*.pdf"))
if not pdf_path:
print(" Annexe-4 CMA non trouvée, skip")
return []
pdf_path = pdf_path[0]
# Charger les sévérités CoCoA existantes pour dédupliquer
cocoa_path = BASE / "data" / "processed" / "cocoa_entries_debug.json"
cocoa_severities = set()
if cocoa_path.exists():
with open(cocoa_path) as f:
cocoa = json.load(f)
for code, entry in cocoa.items():
if entry.get("severity"):
cocoa_severities.add(code)
entries = []
# Pattern: code (A00.0) suivi d'un niveau (2-4) suivi d'un libellé
pattern = re.compile(r"^([A-Z]\d{2}(?:\.\d{1,2})?)\s+([234])\s+(.+)$")
with pdfplumber.open(str(pdf_path)) as pdf:
for page in pdf.pages:
text = page.extract_text() or ""
for line in text.split("\n"):
line = line.strip()
m = pattern.match(line)
if m:
code, niveau, libelle = m.group(1), int(m.group(2)), m.group(3).strip()
entries.append({"code": code, "niveau": niveau, "libelle": libelle})
print(f" Annexe-4 CMA : {len(entries)} entrées extraites")
# Dédupliquer avec CoCoA
new_entries = [e for e in entries if e["code"] not in cocoa_severities]
dupes = len(entries) - len(new_entries)
print(f" Doublons CoCoA : {dupes}, nouvelles : {len(new_entries)}")
# Générer les exemples ChatML
examples = []
# Type 1 : Quel niveau CMA ?
for e in entries: # Garder tous pour renforcer, même les doublons
examples.append(_chatml(
SYSTEM_PROMPT,
f"Quel est le niveau de sévérité CMA du code {e['code']} ({e['libelle']}) ?",
f"Le code {e['code']} ({e['libelle']}) est classé CMA de niveau {e['niveau']}. "
f"{'Ce diagnostic est considéré comme une complication ou morbidité associée majeure.' if e['niveau'] >= 3 else 'Ce diagnostic est une complication ou morbidité associée significative.'}",
source="annexe4_cma"
))
# Type 2 : Est-ce une CMA ? (discrimination)
# Quelques exemples de codes NON-CMA pour contraste
non_cma_codes = set()
cma_codes = {e["code"] for e in entries}
for e in entries:
# Codes voisins qui ne sont pas CMA
base = e["code"][:3]
for suffix in range(10):
candidate = f"{base}.{suffix}"
if candidate not in cma_codes and candidate not in non_cma_codes:
non_cma_codes.add(candidate)
if len(non_cma_codes) >= 500:
break
return examples
# ─── 2. Racines GHM ──────────────────────────────────────────────────────────
def parse_racines_ghm() -> list[dict]:
"""Parse les Racines GHM V11e (tableau de caractéristiques)."""
pdf_path = list(REF_DIR.glob("*Racines_GHM*.pdf"))
if not pdf_path:
print(" Racines GHM non trouvé, skip")
return []
pdf_path = pdf_path[0]
entries = []
# Pattern racine GHM : 2 chiffres + lettre + 2 chiffres (ex: 01C02, 05K06)
pattern = re.compile(r"^(\d{2}[A-Z]\d{2})\s+(.+)$")
with pdfplumber.open(str(pdf_path)) as pdf:
for page in pdf.pages[3:]: # Skip pages de couverture
text = page.extract_text() or ""
for line in text.split("\n"):
line = line.strip()
m = pattern.match(line)
if m:
racine = m.group(1)
reste = m.group(2).strip()
entries.append({"racine": racine, "description": reste})
print(f" Racines GHM : {len(entries)} racines extraites")
# Aussi extraire les tables complètes par page pour du contexte riche
examples = []
with pdfplumber.open(str(pdf_path)) as pdf:
# Extraire les tables
for page in pdf.pages[3:]:
tables = page.extract_tables()
for table in tables:
if not table or len(table) < 2:
continue
headers = table[0] if table[0] else []
for row in table[1:]:
if not row or not row[0]:
continue
racine = str(row[0]).strip()
if not re.match(r"^\d{2}[A-Z]\d{2}$", racine):
continue
# Construire la description depuis les colonnes
desc_parts = [str(c).strip() for c in row[1:] if c and str(c).strip()]
if not desc_parts:
continue
full_desc = " | ".join(desc_parts)
examples.append(_chatml(
SYSTEM_PROMPT_GHM,
f"Quelles sont les caractéristiques de la racine GHM {racine} ?",
f"La racine GHM {racine} : {full_desc}",
source="racines_ghm"
))
# Si pas de tables parsées, utiliser les entrées textuelles
if not examples:
for e in entries:
examples.append(_chatml(
SYSTEM_PROMPT_GHM,
f"Quelles sont les caractéristiques de la racine GHM {e['racine']} ?",
f"La racine GHM {e['racine']} : {e['description']}",
source="racines_ghm"
))
return examples
# ─── 3. Arbre de décision GHM ─────────────────────────────────────────────────
def parse_arbre_ghm() -> list[dict]:
"""Parse l'arbre de décision GHM — extrait les règles par page/bloc."""
pdf_path = list(REF_DIR.glob("*Arbre_decision_GHM*.pdf"))
if not pdf_path:
print(" Arbre GHM non trouvé, skip")
return []
pdf_path = pdf_path[0]
examples = []
# Le PDF est un arbre graphique : chaque page contient des nœuds de décision
# avec des codes GHM (ex: 01K03, 28Z18) et des conditions (listes diagnostiques/actes)
ghm_pattern = re.compile(r"\b(\d{2}[A-Z]\d{2})\b")
condition_pattern = re.compile(r"\(([A-Z]-\d{3})\)") # Ex: (A-198), (D-064)
# Grouper les pages par CMD (les 2 premiers chiffres du GHM)
cmd_pages = {}
with pdfplumber.open(str(pdf_path)) as pdf:
for page in pdf.pages[7:]: # Skip couverture, symboles, légende
text = page.extract_text() or ""
if not text.strip() or len(text.strip()) < 30:
continue
# Nettoyer : supprimer les numéros de page isolés en début de texte
lines = text.strip().split("\n")
lines = [l for l in lines if not re.match(r"^\s*\d{1,3}\s*$", l.strip())]
text = "\n".join(lines)
# Trouver les codes GHM sur cette page
ghm_codes = ghm_pattern.findall(text)
if not ghm_codes:
continue
# Déterminer la CMD (premiers 2 chiffres)
cmd_num = ghm_codes[0][:2]
if cmd_num not in cmd_pages:
cmd_pages[cmd_num] = []
cmd_pages[cmd_num].append(text.strip())
# Générer un exemple par CMD
for cmd_num in sorted(cmd_pages.keys()):
pages_text = cmd_pages[cmd_num]
full_text = "\n\n".join(pages_text)
# Extraire tous les GHM et conditions
ghm_codes = sorted(set(ghm_pattern.findall(full_text)))
conditions = sorted(set(condition_pattern.findall(full_text)))
ghm_str = ", ".join(ghm_codes[:15])
body = full_text[:2000]
examples.append(_chatml(
SYSTEM_PROMPT_GHM,
f"Quelles sont les règles de l'arbre de décision GHM pour la CMD {cmd_num} ?",
f"CMD {cmd_num} — Arbre de décision GHM V11e :\n"
f"Racines GHM concernées : {ghm_str}\n\n{body}",
source="arbre_ghm"
))
print(f" Arbre GHM : {len(examples)} CMDs extraites")
return examples
# ─── 4. Fascicules de codage ──────────────────────────────────────────────────
def parse_fascicule(pdf_path: Path, topic: str) -> list[dict]:
"""Parse un fascicule de codage — extrait les sections de règles."""
examples = []
with pdfplumber.open(str(pdf_path)) as pdf:
full_text = ""
for page in pdf.pages:
text = page.extract_text() or ""
full_text += text + "\n"
if not full_text.strip():
return []
# Nettoyer : supprimer les lignes de table des matières (avec ........)
clean_lines = []
for line in full_text.split("\n"):
if "...." in line or "TABLE DES MATIERES" in line:
continue
# Supprimer les numéros de page isolés
if re.match(r"^\s*\d{1,3}\s*$", line.strip()):
continue
clean_lines.append(line)
full_text = "\n".join(clean_lines)
# Découper en sections par les titres (lignes commençant par un chiffre romain ou "Consignes")
sections = []
current_title = topic
current_body = []
for line in full_text.split("\n"):
line_stripped = line.strip()
# Détecter les titres de section
is_title = False
# Sections principales : I., II., III., IV.
if re.match(r"^[IVX]+\.\s+", line_stripped):
is_title = True
# Sous-sections : II.1., III.2., IV.3.
elif re.match(r"^[IVX]+\.\d+\.?\s+", line_stripped):
is_title = True
# Titres descriptifs courants dans les fascicules
elif re.match(r"^(Consignes|Règles|Les pièges|Le codage|Codage|Comment coder|Principes|Exemples?|Cas particulier|Remarque)", line_stripped, re.IGNORECASE):
is_title = True
elif re.match(r"^(Créé le|Mis à jour|Modifié le|TABLE DES MATIERES|FASCICULE DE CODAGE)", line_stripped):
continue # Skip dates et entêtes
if is_title and current_body:
body = "\n".join(current_body).strip()
if len(body) > 100:
sections.append((current_title, body))
current_title = line_stripped
current_body = []
else:
current_body.append(line)
# Dernière section
if current_body:
body = "\n".join(current_body).strip()
if len(body) > 100:
sections.append((current_title, body))
# Générer les exemples ChatML
for title, body in sections:
# Nettoyer
body = re.sub(r"\n{3,}", "\n\n", body)
body = body[:2000] # Limiter la taille
# Extraire les codes CIM-10 mentionnés pour enrichir la question
codes = re.findall(r"[A-Z]\d{2}(?:\.\d{1,2})?", body)
codes_unique = sorted(set(codes))[:5]
codes_str = f" (codes : {', '.join(codes_unique)})" if codes_unique else ""
# Type 1 : Règle de codage
examples.append(_chatml(
SYSTEM_PROMPT,
f"Quelles sont les règles de codage PMSI pour {title}{codes_str} ?",
f"Selon le fascicule de codage ATIH « {topic} » :\n\n{body}",
source=f"fascicule_{topic.lower().replace(' ', '_')[:30]}"
))
# Type 2 : Question pratique à partir du contenu
# Chercher les patterns "code X pour Y" ou "on codera X"
coding_rules = re.findall(
r"(?:on codera?|se code|coder?|est codé[e]?|codé[e]?\s+(?:avec|par|en))\s+([A-Z]\d{2}(?:\.\d{1,2})?)\s*(.*?)(?:\.|$)",
body, re.IGNORECASE
)
for code, context in coding_rules[:5]:
context = context.strip()[:200]
# Filtrer les contextes trop courts ou non informatifs
if len(context) < 15:
continue
examples.append(_chatml(
SYSTEM_PROMPT,
f"Comment coder en CIM-10 : {context} ?",
f"Selon les règles ATIH ({topic}), cette situation se code {code}. {context}",
source=f"fascicule_{topic.lower().replace(' ', '_')[:30]}"
))
return examples
def parse_all_fascicules() -> list[dict]:
"""Parse tous les fascicules disponibles."""
fascicules = {
"Fascicule_01_Generalites": "Généralités du codage PMSI",
"Fascicule_02_Maladies_digestives": "Maladies de l'appareil digestif",
"Fascicule_03_Tumeurs": "Tumeurs",
"Fascicule_04_Metabolisme": "Métabolisme",
"Fascicule_05_Gyneco_Obstetrique": "Gynécologie et Obstétrique",
"Fascicule_06_Neonatalogie": "Néonatalogie",
"Fascicule_07_Evolutions_2010": "Évolutions 2010",
"Fascicule_08_Maladies_infectieuses": "Maladies infectieuses",
"Fascicule_09_AVC": "Accidents vasculaires cérébraux",
"Fascicule_10_SCA_Coronariens": "Syndromes coronariens aigus",
}
all_examples = []
for filename_part, topic in fascicules.items():
pdf_files = list(REF_DIR.glob(f"*{filename_part}*"))
if not pdf_files:
print(f" {topic} : non trouvé, skip")
continue
examples = parse_fascicule(pdf_files[0], topic)
print(f" {topic} : {len(examples)} exemples")
all_examples.extend(examples)
return all_examples
# ─── 5. Instruction DGOS ──────────────────────────────────────────────────────
def parse_instruction_dgos() -> list[dict]:
"""Parse l'instruction DGOS contrôle T2A."""
pdf_path = list(REF_DIR.glob("*Instruction_DGOS*.pdf"))
if not pdf_path:
print(" Instruction DGOS non trouvée, skip")
return []
pdf_path = pdf_path[0]
with pdfplumber.open(str(pdf_path)) as pdf:
full_text = ""
for page in pdf.pages:
text = page.extract_text() or ""
full_text += text + "\n"
# Découper en sections thématiques
examples = []
sections = re.split(r"\n(?=\d+\.\s+|\d+\.\d+\s+|Annexe)", full_text)
for section in sections:
section = section.strip()
if len(section) < 100:
continue
# Titre = première ligne
lines = section.split("\n")
title = lines[0].strip()
body = "\n".join(lines[1:]).strip()[:2000]
if body:
examples.append(_chatml(
SYSTEM_PROMPT_CONTROLE,
f"Que dit l'instruction DGOS 2025 sur : {title} ?",
f"Selon l'instruction DGOS/FIP1/DSS/1A/2025/141 relative aux contrôles T2A :\n\n{body}",
source="instruction_dgos"
))
print(f" Instruction DGOS : {len(examples)} sections")
return examples
# ─── Main ─────────────────────────────────────────────────────────────────────
def main():
print("=" * 60)
print("Parsing des référentiels ATIH/PMSI")
print("=" * 60)
all_examples = []
# 1. Annexe-4 CMA
print("\n1. Annexe-4 CMA")
all_examples.extend(parse_annexe4_cma())
# 2. Racines GHM
print("\n2. Racines GHM")
all_examples.extend(parse_racines_ghm())
# 3. Arbre de décision GHM
print("\n3. Arbre de décision GHM")
all_examples.extend(parse_arbre_ghm())
# 4. Fascicules
print("\n4. Fascicules de codage")
all_examples.extend(parse_all_fascicules())
# 5. Instruction DGOS
print("\n5. Instruction DGOS")
all_examples.extend(parse_instruction_dgos())
# Sauvegarder
output_path = OUTPUT / "referentiels_chatml.jsonl"
with open(output_path, "w") as f:
for ex in all_examples:
f.write(json.dumps(ex, ensure_ascii=False) + "\n")
size_mo = output_path.stat().st_size / 1024 / 1024
print(f"\n{'=' * 60}")
print(f"Total : {len(all_examples)} exemples")
print(f"Sauvegardé : {output_path} ({size_mo:.1f} Mo)")
print(f"{'=' * 60}")
if __name__ == "__main__":
main()