ICT-12e — Valeur de l’information pour l’animat incarné : EVPI / EVSI (#13569)
Sous-série ICT (trajectoires intégrées, Epic #4588), strate 3 — la jambe de l’animat décisionnel. Suite du Cran A: ICT-12 (champs de valence, modèle interne payant ou ruineux), ICT-12c (prégnance/valence incarnée), ICT-12d (inhibition de Laborit). Voir #4588, #13569.
Ce que ce notebook est. Une greffe : ICT pose un problème de décision d’animat — faut-il observer avant d’agir, et à quel coût ? — et laisse les organes natifs du dépôt calculer. La valeur de l’information (EVPI/EVSI, mesure howardienne) existe déjà trois fois dans le dépôt, dans deux moteurs :
Le double portage Infer.NET / PyMC est un atout, pas une redondance : il donne un contrôle croisé gratuit. Un EVSI calculé par les deux moteurs et qui diverge est un bug qu’un seul moteur n’aurait jamais montré. Ici, ICT ne ré-implémente rien : il appelle l’interface canonique ict.voi (tranche 1/3) et consomme le comparateur cross-engine voi/ (tranche 3/3) qui fait tourner les deux moteurs sur le même contrat JSON et journalise l’accord/désaccord.
Statut épistémique — Sans verdict à ce jour : aucune ligne de la matrice de dissociations ne concerne ce notebook ; son statut épistémique sera porté par la matrice le cas échéant.
# Setup — même convention que les notebooks ICT voisins : le package `ict` est dans le cwd.import warningswarnings.filterwarnings("ignore", category=UserWarning)import sys, osimport numpy as npsys.path.insert(0, os.path.abspath("."))from ict.voi import ( DecisionProblem, optimal_action_without_info, evpi, evsi, evsi_net, observation_is_worthwhile, animat_decision_summary,)print("Interface canonique `ict.voi` chargée :", [ f for f in ("DecisionProblem", "evpi", "evsi", "evsi_net","observation_is_worthwhile", "animat_decision_summary")if f inglobals()])
La greffe — ICT pose le problème, les organes calculent
Un animat incarné (ICT-12c/12d) doit agir dans un monde à deux états (\(\theta \in \{\text{pétrole}, \text{pas pétrole}\}\), prior \(0.3/0.7\)) et choisit entre deux actions (\(\text{forer}\), \(\text{vendre}\)). L’utilité \(U[\theta, a]\) est la sienne. Avant d’agir, l’animat peut observer un signal imparfait (une mesure sismique) qui lui coûte \(c\). La question de la greffe :
Combien l’observation vaut-elle, et vaut-elle son coût ?
C’est exactement l’EVPI/EVSI. ICT ne re-dérive pas la formule — il appelle l’organe canonique qui la porte déjà. La structure est celle de DecPyMC-5 §forage : le même problème, présenté depuis le point de vue de l’animat.
# Problème de décision de l'animat — états, actions, utilité, prior.# Convention : utility[etat, action] (shape n_states x n_actions), prior somme a 1.pb_animat = DecisionProblem( states=("petrole", "pas_petrole"), prior=(0.3, 0.7), actions=("forer", "vendre"), utility=[ [1_500_000.0, 200_000.0], [-500_000.0, 200_000.0], ],)# Sans observation : l'animat choisit l'action de meilleure esperance.eu_no, best_no = optimal_action_without_info(pb_animat)print(f"EU sans info : {eu_no:>12,.0f}")print(f"Action optimale: {best_no}")# EVPI (information parfaite) — borne superieure de toute observation.e_evpi = evpi(pb_animat)print(f"EVPI : {e_evpi:>12,.0f}")# Observation sismique imparfaite : L[etat, signal] = P(signal | etat).# 90% de detection si petrole, 80% de rejet si pas petrole.L_seismic = np.array([ [0.90, 0.10], [0.20, 0.80],])e_evsi = evsi(pb_animat, L_seismic)print(f"EVSI (sismique) : {e_evsi:>12,.0f} (<= EVPI, toujours)")# Cout d'observation explicite et non nul — l'animat observe ssi rendement > 0.cout_seismic =60_000.0e_net = evsi_net(pb_animat, L_seismic, cout_seismic)observe = observation_is_worthwhile(pb_animat, L_seismic, cout_seismic)print(f"EVSI_net(cout={cout_seismic:,.0f}) : {e_net:>12,.0f}")print(f"Observation rentable ? : {observe}")# Interface d'appel principale : un seul appel, toutes les grandeurs.resume = animat_decision_summary(pb_animat, L_seismic, cout_seismic)print()print("animat_decision_summary ->", {k: (round(v) ifisinstance(v, (int, float)) else v)for k, v in resume.items()})
L’EVPI (\(390\,000\)) dit : un oracle qui révèle l’état ajouterait \(390\,000\) à l’espérance. L’EVSI (\(253\,000\)) dit : le test sismique imparfait capture une grande partie de cette valeur (il est informatif). Mais l’observation coûte \(60\,000\) — l’EVSI net (\(193\,000\)) est ce que l’animat gagne réellement : comme il est \(> 0\), l’animat observe. C’est le prix de l’information mis à l’épreuve, pas supposé.
Contrôle 1 — observation non informative (EVSI = 0)
La règle SOTA (Prong B, problème non-trivial) exige de prouver que le problème n’est pas dégénéré. Le premier piège serait de présenter l’EVSI comme « de la valeur ajoutée » alors qu’un senseur qui n’apprend rien doit donner EVSI = 0. Voici le contrôle : une vraisemblance indépendante de l’état — le signal ne porte aucune information sur \(\theta\).
# Controle 1 : vraisemblance non informative (le signal est independant de l'etat).# Chaque ligne = P(signal | etat) ; si les deux lignes sont identiques, le signal# n'apprend rien. L'EVSI DOIT etre nul (0) et l'animat ne doit PAS observer.L_non_informatif = np.array([[0.50, 0.50], [0.50, 0.50]])e_evsi_non = evsi(pb_animat, L_non_informatif)e_net_non = evsi_net(pb_animat, L_non_informatif, cout_seismic)print(f"EVSI (non-informatif) : {e_evsi_non:,.6f}")print(f"EVSI_net(cout={cout_seismic:,.0f}) : {e_net_non:,.0f} (negatif : observern'est PAS rentable)")# L'assertion dit ce qui doit etre vrai : EVSI exactement nul, decision d'abstention.assertabs(e_evsi_non) <1e-9, f"EVSI non-informatif DOIT etre 0, recu {e_evsi_non}"assertnot observation_is_worthwhile(pb_animat, L_non_informatif, cout_seismic), "Un senseur non-informatif ne doit jamais etre vendu comme rentable"print("OK : le senseur non-informatif vaut 0, l'animat n'observe pas.")
EVSI (non-informatif) : 0.000000
EVSI_net(cout=60,000) : -60,000 (negatif : observern'est PAS rentable)
OK : le senseur non-informatif vaut 0, l'animat n'observe pas.
Contrôle 2 — observation dont le coût dépasse sa valeur
Le second piège serait de présenter une observation toujours rentable. Mais l’EVSI est une borne : dès que le coût franchit l’EVSI, l’observation cesse d’être une bonne affaire. Il existe un coût break-even\(c^* = \text{EVSI}\). Vérifions-le.
# Controle 2 : cout au-dessus de l'EVSI -> l'observation ne vaut plus son prix.# L'EVSI brut est 253000. A cout = 300000 > EVSI, l'animat doit s'abstenir.for c in (60_000.0, 253_000.0, 300_000.0, 400_000.0): net = evsi_net(pb_animat, L_seismic, c) obs = observation_is_worthwhile(pb_animat, L_seismic, c)print(f" cout={c:>10,.0f} EVSI_net={net:>12,.0f} observe={obs}")assertnot observation_is_worthwhile(pb_animat, L_seismic, 400_000.0), "Au-dessus du break-even l'observation doit etre refusee"print("OK : franchir le break-even (cout=EVSI) fait basculer la decision.")
cout= 60,000 EVSI_net= 193,000 observe=True
cout= 253,000 EVSI_net= 0 observe=False
cout= 300,000 EVSI_net= -47,000 observe=False
cout= 400,000 EVSI_net= -147,000 observe=False
OK : franchir le break-even (cout=EVSI) fait basculer la decision.
Lecture du tableau, ligne par ligne. À coût \(60\,000\) : EVSI net \(193\,000\), observer (True). À coût \(253\,000\) : EVSI net \(0\)exactement, ne plus observer (False). À \(300\,000\) : \(-47\,000\). À \(400\,000\) : \(-147\,000\). La deuxième ligne réalise la frontière théorique à l’unité près : \(c^* = \text{EVSI} = 253\,000\) est précisément le coût où la valeur brute du test et son prix s’annulent – la sortie tranche l’indifférence en False, et les deux lignes suivantes confirment que tout dépassement rend le net strictement négatif. Le tableau entier est la courbe EVSI-net-en-fonction-du-coût échantillonnée autour de son unique zéro – strictement décroissante, et le seuil de décision bascule exactement au croisement.
Contrôle 3 — le cas parapluie canonique (EVPI de référence)
Pour ancrer la mesure, on rejoue le cas canonique de DecPyMC-5 §parapluie et de DecInfer-06. Son EVPI vaut \(3{,}5\) (unités arbitraires) — la référence que les tests du dépôt ict/tests/test_voi.py vérifient déjà. L’animat sensoriel (proprioception imparfaite \(85/75\)) en est une déclinaison : la mesure sensorielle ne capte qu’une fraction de l’information parfaite.
# Cas canonique : parapluie (EVPI = 3.5, reference deja testee dans ict/tests/test_voi.py).pb_parapluie = DecisionProblem( states=("pluie", "soleil"), prior=(0.3, 0.7), actions=("parapluie", "pas_parapluie"), utility=[ [0.0, -50.0], # pluie [-5.0, 0.0], # soleil ],)evpi_para = evpi(pb_parapluie)print(f"EVPI parapluie : {evpi_para:.3f} (attendu 3.5)")assertabs(evpi_para -3.5) <1e-9, f"EVPI parapluie DOIT valoir 3.5, recu {evpi_para}"# Senseur proprioceptif imparfait 85% sensi / 75% speci (l'animat de ICT-12c).L_proprio = np.array([[0.85, 0.15], [0.25, 0.75]])e_proprio = evsi(pb_parapluie, L_proprio)ratio = e_proprio / evpi_paraprint(f"EVSI proprio(85/75) : {e_proprio:.3f} ratio={ratio*100:.1f}% de l'EVPI")# Discrimination : nettement distinct de 0% (bruit) et de 100% (oracle).L_oracle = np.eye(2)L_bruit = np.ones((2, 2)) *0.5print(f" senseur parfait (oracle) : EVSI = {evsi(pb_parapluie, L_oracle):.3f} = EVPI")print(f" senseur uniforme (bruit) : EVSI = {evsi(pb_parapluie, L_bruit):.6f} = 0")assert0.05< ratio <0.20, f"ratio {ratio:.3f} hors plage [5%, 20%]"print("OK : discrimination nette — le proprioceptif vit entre bruit (0) et oracle (1).")
EVPI parapluie : 3.500 (attendu 3.5)
EVSI proprio(85/75) : 0.375 ratio=10.7% de l'EVPI
senseur parfait (oracle) : EVSI = 3.500 = EVPI
senseur uniforme (bruit) : EVSI = 0.000000 = 0
OK : discrimination nette — le proprioceptif vit entre bruit (0) et oracle (1).
Lecture de la sortie, chiffre par chiffre. EVPI parapluie \(= 3{,}500\) pour \(3{,}5\) attendu : l’implémentation reproduit la référence canonique à l’affichage près. Autour d’elle, la hiérarchie des senseurs : l’uniforme (bruit pur) capte \(0{,}000000\) – aucune information, aucune valeur ; l’oracle capte $3{,}500 = $ EVPI – l’information parfaite réalise exactement le plafond, par définition ; et le senseur proprioceptif \(85/75\) vit entre les deux : EVSI \(= 0{,}375\), soit \(10{,}7\,\%\) de l’EVPI. Ce dernier nombre est la leçon : un capteur imparfait réel ne capte ni tout ni rien – un dixième de la valeur d’oracle est déjà une grandeur mesurable et rationnellement exploitable, et la hiérarchie bruit \(<\) capteur \(<\) oracle borne par construction ce que n’importe quel senseur intermédiaire peut atteindre.
Contrôle croisé — Infer.NET × PyMC sur le même contrat JSON
La valeur mesurée ci-dessus est close-form (analytique). Mais l’acceptance [#13569] demande un contrôle croisé : la même décision passée dans les deux moteurs (Infer.NET et PyMC), avec un écart rapporté, jamais lissé. Le comparateur voi/run_comparison.py le fait déjà : un contrat JSON commun, deux adaptateurs indépendants (InferNetVoi pour Infer.NET, pymc_voi pour PyMC), qui exécutent les moteurs réels et journalisent l’accord/désaccord.
Le notebook consomme cet organe — il n’en ré-implémente pas une once. On lit l’artefact de preuve déjà committé sur main (comparison.json), puis on invoque le comparateur pour régénérer un verdict frais.
# Lecture de la sortie committée du comparateur cross-engine (voi/comparison.json).# Cet artefact est la preuve que Infer.NET et PyMC s'accordent sur le contrat JSON.import jsoncomparison_path = os.path.join("..", "..", "Probas", "DecisionTheory", "voi", "comparison.json")withopen(comparison_path, encoding="utf-8") as fh: comparison = json.load(fh)print("Contrat JSON : tolerance", comparison["tolerance"])print()for pb_name, pb in comparison["problems"].items(): acc = pb["agree"] and pb["controls_ok"]print(f"[{pb_name}] accord={pb['agree']} controls={pb['controls_ok']} -> "f"{'ACCORD + CONTROLES PASS'if acc else'DIVERGENCE OU CONTROLE FAIL'}")for row in pb["rows"]:if row["field"] in ("evsi_brute", "evsi_nette", "evpi", "eu_no_info", "decision"):print(f" {row['field']:<12} infer_net={row['infer_net']:>12} "f"pymc={row['pymc']:>12}{row['verdict']}")# Le forage-petrolier est discriminant (EVSI_nette > 0 et < EVPI) ;# le forage-non-informatif est le controle negatif (EVSI = 0). Les deux ACCORDENT.assert comparison["problems"]["forage-petrolier"]["agree"]assert comparison["problems"]["forage-non-informatif"]["agree"]print("OK : l'artefact committe confirme l'accord Infer.NET x PyMC.")
Lecture du contrat croisé, ligne par ligne. Le contrat fixe les tolérances : \(0{,}01\) en probabilité, \(20\,000\) en utilité. Scénario non-informatif : les cinq lignes tombent d’accord à l’identique – EVSI nette \(-60\,000\) des deux côtés, décision agir_sans_test identique. Scénario forage : le seul écart du tableau apparaît sur l’EVSI brute, \(253\,000\) (close-form Infer.NET) contre \(252\,796\) (échantillonnage PyMC) – \(204\) d’écart, deux ordres de grandeur sous la tolérance, et l’écart typique du bruit d’échantillonnage MCMC. La décision, elle, ne bouge pas : observer dans les deux moteurs. Deux moteurs probabilistes indépendants – inférence exacte côté .NET, échantillonnage côté Python – convergent donc sur le même acte, qui est la seule quantité dont l’animat a besoin.
Option — régénérer le verdict frais (fait tourner les deux moteurs)
La lecture de comparison.json consomme l’artefact. Pour produire un verdict frais, on invoque le comparateur lui-même — il rebuild le projet Infer.NET, échantillonne le modèle PyMC (200 000 tirages) et réécrit comparison.json et les sorties out_*.json (gitignorés). Coût : ~1 min. Ceci n’est pas une ré-implémentation : c’est l’appel de l’organe du dépôt.
# Invocation de l'organe natif : le comparateur cross-engine.# (rebuild dotnet + PyMC 200k draws — ~1 min si l'env est complet ; sorties gitignorees)import subprocessvoi_dir = os.path.abspath(os.path.join("..", "..", "Probas", "DecisionTheory", "voi"))proc = subprocess.run( [sys.executable, os.path.join(voi_dir, "run_comparison.py"), voi_dir], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=240,)print(proc.stdout[-1800:] if proc.stdout.strip() else"(sortie vide)")if proc.returncode !=0:print("[stderr]", proc.stderr[-500:])print(f"\n[retour comparateur: {proc.returncode}]")
Synthèse — la valeur de l’information pour l’animat, en trois grandeurs
Grandeur
Valeur (forage)
Sens pour l’animat
EVPI
\(390\,000\)
Plafond : ce que vaudrait un oracle parfait
EVSI
\(253\,000\)
Ce que vaut le vrai test imparfait
EVSI net (\(c{=}60\,000\))
\(193\,000\)
Ce que l’animat gagne après avoir payé
Trois contrôles (colonne gauche) bornent la lecture : 1. Non-informatif → EVSI \(= 0\) : la valeur est mesurée, pas prêtée à l’observation. 2. Coût break-even → EVSI net change de signe au franchissement de \(c^*\) : le prix de l’information est explicite et non nul. 3. Parapluie canonique → EVPI \(= 3{,}5\) : la référence du dépôt est atteinte, l’animat sensoriel (proprioceptif \(85/75\)) vit à \(10{,}7\%\) de l’information parfaite — distinct du bruit (\(0\%\)) comme de l’oracle (\(100\%\)).
Et le contrôle croisé : Infer.NET et PyMC, les deux moteurs natifs du dépôt, s’accordent sur le même contrat JSON — l’écart éventuel serait rapporté, pas lissé. C’est ce que la greffe [#13569] rend possible : ICT pose le problème, les organes calculent.
Exercices
Exercice 1 — Senseur sismique personnalisé
Choisissez \((\text{sensi}, \text{spec})\) pour un nouveau senseur sismique (\(\text{sensi} \in [0.5, 0.99]\), \(\text{spec} \in [0.5, 0.99]\)) et calculez son EVSI sur le problème de forage. Précisez s’il est plus ou moins rentable que le senseur \(90/80\) du texte, et à quel coût il deviendrait un mauvais achat.
Exercice 2 — Coût break-even
Trouvez le coût \(c^*\) exact qui annule l’EVSI net de votre senseur (dichotomie ou recherche linéaire). Vérifiez que \(c^* = \text{EVSI}\). Expliquez ce que l’animat doit faire pour \(c\) juste au-dessus et juste en dessous.
Exercice 3 — Contre-exemple non-informatif en paramètre libre
La vraisemblance non informative est \(L = [[a, 1-a], [a, 1-a]]\) avec \(a = 0{,}5\). Montrez que l’EVSI reste nul pour tout\(a \in (0, 1)\) — un senseur dont les deux lignes sont égales n’apprend jamais rien, quelle que soit sa « confiance ».
# Exercice 1 — squelette. Choisir sensi/speci et calculer l'EVSI sur le forage.# L_perso = np.array([[sensi, 1-sensi], [1-speci, speci]]) puis evsi(pb_animat, L_perso)sensi =None# TODO etudiant : entre 0.5 et 0.99speci =None# TODO etudiant : entre 0.5 et 0.99if sensi isNoneor speci isNone:print("Exercice 1 a completer : choisir (sensi, speci)")else: L_perso = np.array([[sensi, 1- sensi], [1- speci, speci]]) e = evsi(pb_animat, L_perso)print(f"EVSI(sensi={sensi}, speci={speci}) = {e:,.0f}")# Exercice 2 — squelette. Trouver c* qui annule EVSI_net.# c_etoile = None # TODO etudiant : evsi_net(pb_animat, L_perso, c) == 0# if c_etoile is not None:# print(f"c* (break-even) = {c_etoile:,.0f} (= EVSI = {e:,.0f})")print("Exercice 2 a completer : determiner le cout break-even.")
Exercice 1 a completer : choisir (sensi, speci)
Exercice 2 a completer : determiner le cout break-even.
Limites & prolongements
Portée : monde à 2 états × 2 actions (contrat binaire du cross-engine), fidèle aux notebooks sources DecInfer-06 / DecPyMC-5. Les extensions multi-états sortent du contrat JSON actuel.
Les organes font le calcul : ict.voi porte la forme close-form (tranche 1/3), voi/ porte le comparateur cross-engine (tranche 3/3). Ce notebook ne re-dérive ni Bayes ni Howard — il consomme.
Prolongement : le témoin de l’animat actif (coût endogène, choix du senseur sous contrainte de budget) est un cran ultérieur de la strate 3; la greffe 4 (#13570) branchera le bac à sable institutionnel sur cette valeur de l’information.