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,396 @@
#!/usr/bin/env python3
"""
Génère des exemples négatifs : enseigner au modèle quand NE PAS coder.
3 types d'exemples :
a) Codes rejetés (500) — symptômes couverts par le DP
b) Redondances sémantiques (200) — paires dominé/dominant
c) DAS non significatifs (300) — antécédents sans ressources consommées
Sources :
- Cache Ollama (textes diagnostics réels)
- Règles PMSI (SEMANTIC_REDUNDANCIES, symptômes R00-R99)
- Templates + CIM-10 FHIR
Produit : data/processed/negative_chatml.jsonl
Usage :
python scripts/14_generate_negative_examples.py
"""
import json
import random
from pathlib import Path
random.seed(42)
BASE = Path(__file__).resolve().parent.parent
T2A = BASE.parent / "t2a"
RAW = BASE / "data" / "raw"
OUTPUT = BASE / "data" / "processed" / "negative_chatml.jsonl"
OUTPUT.parent.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 sais quand un diagnostic ne doit PAS être codé."
)
def make_chatml(user: str, assistant: str) -> dict:
return {
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user},
{"role": "assistant", "content": assistant},
]
}
def load_cim10_dict() -> dict[str, str]:
"""Charge le dictionnaire CIM-10."""
d = {}
for path in [T2A / "data" / "cim10_dict.json", T2A / "data" / "cim10_supplements.json"]:
if path.exists():
d.update(json.loads(path.read_text()))
return d
def load_fhir_concepts() -> dict[str, dict]:
"""Charge les concepts FHIR indexés par code."""
fhir_path = RAW / "smt_cim10_fhir.json"
if not fhir_path.exists():
return {}
data = json.loads(fhir_path.read_text())
by_code = {}
for c in data.get("concept", []):
by_code[c["code"]] = c
return by_code
# ─── Type A : Symptômes couverts par le DP ────────────────────────────────────
# Symptômes R00-R99 fréquemment codés à tort comme DAS quand le DP les explique
SYMPTOM_DP_PAIRS = [
# (symptôme_code, symptôme_label, dp_code, dp_label, explication)
("R50.9", "Fièvre, sans précision", "A41.9", "Sepsis, sans précision",
"La fièvre est un symptôme cardinal du sepsis. Elle est couverte par le DP A41.9."),
("R50.9", "Fièvre, sans précision", "J18.9", "Pneumonie, sans précision",
"La fièvre est un symptôme habituel de la pneumonie. Le DP J18.9 la couvre."),
("R50.9", "Fièvre, sans précision", "N10", "Néphrite tubulo-interstitielle aiguë",
"La fièvre accompagne habituellement la pyélonéphrite aiguë (N10)."),
("R06.0", "Dyspnée", "J44.1", "BPCO avec exacerbation aiguë",
"La dyspnée est le symptôme principal de l'exacerbation de BPCO."),
("R06.0", "Dyspnée", "I50.9", "Insuffisance cardiaque, sans précision",
"La dyspnée est un symptôme majeur de l'insuffisance cardiaque."),
("R06.0", "Dyspnée", "J96.0", "Insuffisance respiratoire aiguë",
"La dyspnée est couverte par le DP d'insuffisance respiratoire aiguë."),
("R07.4", "Douleur thoracique, sans précision", "I21.9", "IDM aigu, sans précision",
"La douleur thoracique est le symptôme principal de l'IDM."),
("R07.4", "Douleur thoracique, sans précision", "I20.0", "Angor instable",
"La douleur thoracique est le symptôme cardinal de l'angor instable."),
("R10.4", "Douleur abdominale, sans précision", "K80.1", "Lithiase vésiculaire avec cholécystite",
"La douleur abdominale est couverte par le DP de cholécystite."),
("R10.4", "Douleur abdominale, sans précision", "K35.8", "Appendicite aiguë, autre et sans précision",
"La douleur abdominale est le symptôme principal de l'appendicite."),
("R11.0", "Nausées", "K29.1", "Gastrite aiguë",
"Les nausées sont un symptôme courant de la gastrite, couvert par le DP."),
("R11.2", "Nausées avec vomissements, sans précision", "K56.6", "Occlusion intestinale, autre et sans précision",
"Les vomissements sont un signe cardinal de l'occlusion intestinale."),
("R00.0", "Tachycardie, sans précision", "A41.9", "Sepsis, sans précision",
"La tachycardie est un critère diagnostique du sepsis."),
("R00.0", "Tachycardie, sans précision", "I48.9", "Fibrillation auriculaire",
"La tachycardie est un symptôme de la FA, couverte par le DP."),
("R41.0", "Désorientation, sans précision", "F05.9", "Delirium, sans précision",
"La désorientation est un symptôme constitutif du delirium."),
("R40.0", "Somnolence", "S06.9", "Lésion traumatique intracrânienne, sans précision",
"La somnolence est un signe d'atteinte neurologique dans le trauma crânien."),
("R42", "Étourdissements et éblouissements", "H81.1", "Vertige paroxystique bénin",
"Les étourdissements sont le symptôme principal du VPPB."),
("R51", "Céphalée", "G43.9", "Migraine, sans précision",
"La céphalée est le symptôme principal de la migraine."),
("R63.0", "Anorexie", "C16.9", "Tumeur maligne de l'estomac",
"L'anorexie est un symptôme fréquent du cancer gastrique, couvert par le DP."),
("R53", "Malaise et fatigue", "D64.9", "Anémie, sans précision",
"La fatigue est un symptôme courant de l'anémie."),
("R31", "Hématurie, sans précision", "N20.0", "Calcul du rein",
"L'hématurie est un symptôme classique de la lithiase rénale."),
("R73.0", "Hyperglycémie SAI", "E11.9", "Diabète de type 2",
"L'hyperglycémie est un signe du diabète de type 2, couvert par le DP."),
("R60.0", "Oedème localisé", "I50.0", "Insuffisance cardiaque congestive",
"L'oedème est un signe de l'ICC, couvert par le DP."),
("R09.2", "Arrêt respiratoire", "J96.0", "Insuffisance respiratoire aiguë",
"L'arrêt respiratoire est la forme extrême de l'insuffisance respiratoire aiguë."),
("R57.0", "Choc cardiogénique", "I21.9", "IDM aigu",
"Le choc cardiogénique comme complication de l'IDM peut se coder comme DAS (mobilise des ressources), mais s'il est le tableau initial il est couvert par le DP."),
]
def generate_symptom_dp_examples(cim10_dict: dict, target: int = 500) -> list[dict]:
"""Générer des exemples de symptômes couverts par le DP."""
examples = []
# Templates de variation
user_templates = [
"Code ce diagnostic : {symptom_label}\nTYPE : DAS\nCONTEXTE : DP = {dp_code} ({dp_label})",
"Le patient est hospitalisé pour {dp_label} (DP : {dp_code}). Faut-il coder {symptom_label} ({symptom_code}) en DAS ?",
"DAS candidat : {symptom_code} ({symptom_label})\nDP du séjour : {dp_code} ({dp_label})\nCe DAS est-il pertinent ?",
]
assistant_template_null = json.dumps({
"code": None,
"confidence": "high",
"justification": "{explanation} Règle PMSI : ne pas coder un symptôme (R00-R99) si le diagnostic qui l'explique est déjà codé comme DP."
}, ensure_ascii=False)
# Générer depuis les paires prédéfinies (avec variations de templates)
for sym_code, sym_label, dp_code, dp_label, expl in SYMPTOM_DP_PAIRS:
for tmpl in user_templates:
user = tmpl.format(
symptom_code=sym_code, symptom_label=sym_label,
dp_code=dp_code, dp_label=dp_label
)
assistant = assistant_template_null.replace("{explanation}", expl)
examples.append(make_chatml(user, assistant))
# Générer des variations supplémentaires pour atteindre la cible
# Utiliser tous les codes R du dictionnaire CIM-10
r_codes = [(c, l) for c, l in cim10_dict.items() if c.startswith("R") and len(c) >= 4]
non_r_codes = [(c, l) for c, l in cim10_dict.items()
if not c.startswith("R") and not c.startswith("Z")
and c[0].isalpha() and len(c) >= 4 and len(l) > 5]
while len(examples) < target and r_codes and non_r_codes:
sym_code, sym_label = random.choice(r_codes)
dp_code, dp_label = random.choice(non_r_codes)
tmpl = random.choice(user_templates)
user = tmpl.format(
symptom_code=sym_code, symptom_label=sym_label,
dp_code=dp_code, dp_label=dp_label
)
expl = f"{sym_label} est un symptôme (code R) potentiellement couvert par le DP {dp_code} ({dp_label})."
assistant = json.dumps({
"code": None,
"confidence": "medium",
"justification": f"{expl} Règle PMSI : ne pas coder un symptôme en DAS s'il est expliqué par le DP. Vérifier si le symptôme a nécessité une prise en charge spécifique supplémentaire."
}, ensure_ascii=False)
examples.append(make_chatml(user, assistant))
random.shuffle(examples)
return examples[:target]
# ─── Type B : Redondances sémantiques ─────────────────────────────────────────
SEMANTIC_REDUNDANCIES = [
# (dominated_prefix, dominant_prefixes, explanation)
("I10", ["I11", "I12", "I13"],
"I10 (HTA essentielle) est redondant quand I11/I12/I13 est présent. Le code hypertensif spécifique inclut la composante HTA."),
("N30", ["N39"],
"N30 (cystite) est redondant quand N39.0 (infection urinaire) est présent. L'infection urinaire couvre la cystite."),
("J18", ["J15", "J16"],
"J18 (pneumonie SAI) est redondant quand J15/J16 (pneumonie spécifique) est présent. Le code spécifique prime."),
("E11.9", ["E11.0", "E11.1", "E11.2", "E11.3", "E11.4", "E11.5", "E11.6", "E11.7"],
"E11.9 (diabète type 2 SAI) est redondant si un sous-code E11.x spécifiant une complication est présent."),
("I25.9", ["I25.1", "I25.2", "I25.5"],
"I25.9 (cardiopathie ischémique chronique SAI) est redondant si un sous-code I25.x plus spécifique est présent."),
("N18.9", ["N18.1", "N18.2", "N18.3", "N18.4", "N18.5"],
"N18.9 (IRC SAI) est redondant si un stade N18.x spécifique est présent."),
("J44.9", ["J44.0", "J44.1"],
"J44.9 (BPCO SAI) est redondant si J44.0 (BPCO avec infection) ou J44.1 (BPCO avec exacerbation) est présent."),
("K21.9", ["K21.0"],
"K21.9 (RGO SAI) est redondant si K21.0 (RGO avec œsophagite) est présent."),
]
def generate_redundancy_examples(cim10_dict: dict, target: int = 200) -> list[dict]:
"""Générer des exemples de redondances sémantiques."""
examples = []
user_templates = [
"DAS candidats : {dominated} ({dom_label}), {dominant} ({sup_label})\nLesquels garder ?",
"Le codage inclut {dominated} et {dominant}. Y a-t-il une redondance ?",
"Vérification de codage :\n- DAS1 : {dominated} ({dom_label})\n- DAS2 : {dominant} ({sup_label})\nCes deux DAS sont-ils tous les deux pertinents ?",
]
for dominated_prefix, dominant_prefixes, explanation in SEMANTIC_REDUNDANCIES:
# Trouver des codes réels pour chaque préfixe
dom_codes = [(c, l) for c, l in cim10_dict.items() if c.startswith(dominated_prefix) and len(l) > 3]
sup_codes = [(c, l) for c, l in cim10_dict.items()
if any(c.startswith(dp) for dp in dominant_prefixes) and len(l) > 3]
if not dom_codes or not sup_codes:
continue
for _ in range(target // len(SEMANTIC_REDUNDANCIES) + 1):
dom_code, dom_label = random.choice(dom_codes)
sup_code, sup_label = random.choice(sup_codes)
tmpl = random.choice(user_templates)
user = tmpl.format(
dominated=dom_code, dom_label=dom_label,
dominant=sup_code, sup_label=sup_label
)
assistant = json.dumps({
"garder": [sup_code],
"retirer": [dom_code],
"justification": explanation
}, ensure_ascii=False)
examples.append(make_chatml(user, assistant))
random.shuffle(examples)
return examples[:target]
# ─── Type C : DAS non significatifs ───────────────────────────────────────────
# Diagnostics fréquemment mentionnés dans les antécédents mais sans ressources
NON_SIGNIFICANT_DAS = [
("J30.1", "Rhinite allergique due au pollen", "rhinite allergique mentionnée dans les antécédents"),
("J45.9", "Asthme, sans précision", "asthme stable mentionné dans les antécédents"),
("M54.5", "Lombalgie basse", "lombalgie chronique mentionnée dans les antécédents"),
("K21.0", "RGO avec œsophagite", "RGO mentionné dans les antécédents"),
("H52.1", "Myopie", "myopie mentionnée dans les antécédents"),
("E78.0", "Hypercholestérolémie pure", "hypercholestérolémie dans les antécédents, traitement habituel"),
("E03.9", "Hypothyroïdie, sans précision", "hypothyroïdie sous Lévothyrox dans les antécédents"),
("F32.0", "Épisode dépressif léger", "dépression traitée mentionnée dans les antécédents"),
("G47.3", "Apnée du sommeil", "SAOS appareillé mentionné dans les antécédents"),
("M81.9", "Ostéoporose sans fracture", "ostéoporose connue dans les antécédents"),
("H40.1", "Glaucome primaire à angle ouvert", "glaucome traité dans les antécédents"),
("I84.1", "Hémorroïdes internes avec complication", "hémorroïdes mentionnées dans les antécédents"),
("K58.9", "Syndrome du côlon irritable", "SCI mentionné dans les antécédents"),
("L40.0", "Psoriasis vulgaire", "psoriasis stable dans les antécédents"),
("N40", "Hyperplasie de la prostate", "HBP traitée dans les antécédents"),
("E66.9", "Obésité, sans précision", "obésité mentionnée, pas de prise en charge spécifique"),
("Z87.1", "Antécédents personnels de maladies de l'appareil digestif", "antécédent de chirurgie digestive ancienne"),
("Z86.7", "Antécédents personnels de maladies de l'appareil circulatoire", "antécédent d'AVC il y a 5 ans"),
("Z92.1", "Antécédents de traitement anticoagulant au long cours", "patient sous AVK au long cours"),
("F17.2", "Dépendance au tabac", "tabagisme actif mais pas de sevrage pendant le séjour"),
]
def generate_non_significant_examples(target: int = 300) -> list[dict]:
"""Générer des exemples de DAS non significatifs (antécédents sans ressources)."""
examples = []
user_templates = [
"Le patient a {context}. Faut-il le coder en DAS ?",
"Antécédent : {code} ({label}). Le patient est hospitalisé pour une autre raison. Ce diagnostic doit-il être codé comme DAS ?",
"Le dossier mentionne : {context}. Est-ce un DAS pertinent pour le séjour ?\nDP : {dp_code} ({dp_label})",
"Lors du codage du séjour (DP : {dp_code}), le dossier fait état de {context}. Coder {code} en DAS ?",
]
dp_examples = [
("K80.1", "Lithiase vésiculaire avec cholécystite"),
("S72.0", "Fracture du col du fémur"),
("I63.9", "Infarctus cérébral, sans précision"),
("J18.1", "Pneumonie lobaire, sans précision"),
("I21.9", "IDM aigu, sans précision"),
("C34.9", "Tumeur maligne des bronches ou du poumon"),
("K35.8", "Appendicite aiguë"),
("N20.0", "Calcul du rein"),
("G45.9", "AIT, sans précision"),
("A41.9", "Sepsis, sans précision"),
("C18.9", "Tumeur maligne du côlon"),
("I48.9", "Fibrillation auriculaire"),
]
# Générer en croisant chaque DAS avec plusieurs DPs
# On veut ~240 négatifs pour avoir assez de marge avec les positifs
combos_per_das = max(3, (target * 3 // 4) // len(NON_SIGNIFICANT_DAS) + 1)
for code, label, context in NON_SIGNIFICANT_DAS:
selected_dps = random.sample(dp_examples, min(combos_per_das, len(dp_examples)))
for dp_code, dp_label in selected_dps:
tmpl = random.choice(user_templates)
user = tmpl.format(
code=code, label=label, context=context,
dp_code=dp_code, dp_label=dp_label
)
assistant = json.dumps({
"coder": False,
"code": code,
"justification": f"Un DAS ne doit être codé que s'il a nécessité des ressources supplémentaires pendant le séjour (examens, traitements, surveillance spécifique). {label} mentionné uniquement dans les antécédents, sans prise en charge spécifique durant le séjour, ne justifie pas un DAS."
}, ensure_ascii=False)
examples.append(make_chatml(user, assistant))
# Ajouter des exemples positifs (contraste) — DAS qui DOIVENT être codés
positive_das = [
("E11.6", "Diabète de type 2 avec complications", "diabète déséquilibré ayant nécessité une adaptation thérapeutique",
"Le diabète a mobilisé des ressources supplémentaires (adaptation insuline, surveillance glycémique renforcée)."),
("I10", "HTA essentielle", "HTA sévère ayant nécessité un traitement IV pendant le séjour",
"L'HTA a nécessité un traitement intraveineux spécifique, justifiant le codage en DAS."),
("N18.4", "IRC stade 4", "IRC ayant nécessité une adaptation posologique de tous les médicaments",
"L'IRC a mobilisé des ressources (adaptation posologique, surveillance créatinine quotidienne)."),
("E87.1", "Hypo-osmolalité et hyponatrémie", "hyponatrémie sévère découverte pendant le séjour",
"L'hyponatrémie a nécessité un bilan étiologique et un traitement spécifique."),
("J96.0", "Insuffisance respiratoire aiguë", "détresse respiratoire ayant nécessité une oxygénothérapie",
"L'insuffisance respiratoire a mobilisé des ressources (oxygénothérapie, surveillance SpO2, GDS)."),
("N17.9", "Insuffisance rénale aiguë", "IRA survenue pendant le séjour ayant nécessité une surveillance biologique quotidienne",
"L'IRA a nécessité des bilans répétés et une adaptation thérapeutique, justifiant le DAS."),
("D62", "Anémie posthémorragique aiguë", "anémie aiguë ayant nécessité une transfusion de 2 CGR",
"L'anémie a mobilisé des ressources (transfusion, surveillance post-transfusionnelle)."),
("E87.6", "Hypokaliémie", "hypokaliémie sévère découverte en biologie nécessitant une supplémentation IV",
"L'hypokaliémie a nécessité un traitement spécifique IV, justifiant le DAS."),
]
combos_per_pos = max(3, (target // 4) // len(positive_das) + 1)
for code, label, context, justification in positive_das:
selected_dps = random.sample(dp_examples, min(combos_per_pos, len(dp_examples)))
for dp_code, dp_label in selected_dps:
user = f"Le patient est hospitalisé pour {dp_label} (DP : {dp_code}). Il présente aussi : {context}. Faut-il coder {code} ({label}) en DAS ?"
assistant = json.dumps({
"coder": True,
"code": code,
"justification": justification
}, ensure_ascii=False)
examples.append(make_chatml(user, assistant))
random.shuffle(examples)
return examples[:target]
def main():
print("=" * 60)
print("Génération d'exemples négatifs (quand NE PAS coder)")
print("=" * 60)
print("\nChargement du dictionnaire CIM-10...")
cim10_dict = load_cim10_dict()
print(f" {len(cim10_dict)} codes")
all_examples = []
# Type A : Symptômes couverts par le DP
print("\nType A : Symptômes couverts par le DP...")
symptom_examples = generate_symptom_dp_examples(cim10_dict, target=500)
print(f"{len(symptom_examples)} exemples")
all_examples.extend(symptom_examples)
# Type B : Redondances sémantiques
print("\nType B : Redondances sémantiques...")
redundancy_examples = generate_redundancy_examples(cim10_dict, target=200)
print(f"{len(redundancy_examples)} exemples")
all_examples.extend(redundancy_examples)
# Type C : DAS non significatifs
print("\nType C : DAS non significatifs...")
non_sig_examples = generate_non_significant_examples(target=300)
print(f"{len(non_sig_examples)} exemples")
all_examples.extend(non_sig_examples)
# Mélanger et sauvegarder
random.shuffle(all_examples)
with open(OUTPUT, "w") as f:
for ex in all_examples:
f.write(json.dumps(ex, ensure_ascii=False) + "\n")
print(f"\n{'='*60}")
print(f"Total : {len(all_examples)} exemples → {OUTPUT}")
print(f" Type A (symptômes/DP) : {len(symptom_examples)}")
print(f" Type B (redondances) : {len(redundancy_examples)}")
print(f" Type C (non signif.) : {len(non_sig_examples)}")
print(f"Taille : {OUTPUT.stat().st_size / 1024:.0f} Ko")
if __name__ == "__main__":
main()