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 :

Organe Moteur Calcul
DecInfer-06 Infer.NET bayésien déterministe
DecPyMC-5 PyMC bayésien + MCMC
Actuariat-05 PyMC cas appliqué (souscription)

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.

Navigation : Index | << ICT-12d | ICT-13 >>

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 warnings
warnings.filterwarnings("ignore", category=UserWarning)

import sys, os
import numpy as np

sys.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 in globals()
])
Interface canonique `ict.voi` chargée : ['DecisionProblem', 'evpi', 'evsi', 'evsi_net', 'observation_is_worthwhile', 'animat_decision_summary']

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.0
e_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) if isinstance(v, (int, float)) else v)
                                     for k, v in resume.items()})
EU sans info   :      200,000
Action optimale: vendre
EVPI           :      390,000
EVSI (sismique) :      253,000  (<= EVPI, toujours)
EVSI_net(cout=60,000) :      193,000
Observation rentable ? : True

animat_decision_summary -> {'eu_no_info': 200000, 'best_no_info': 'vendre', 'evpi': 390000, 'evsi': 253000, 'evsi_net': 193000, 'observe': 1}

Lecture — ce que l’animat apprend

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.
assert abs(e_evsi_non) < 1e-9, f"EVSI non-informatif DOIT etre 0, recu {e_evsi_non}"
assert not 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}")

assert not 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)")
assert abs(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_para
print(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.5
print(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")
assert 0.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 json
comparison_path = os.path.join("..", "..", "Probas", "DecisionTheory", "voi", "comparison.json")
with open(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.")
Contrat JSON : tolerance {'probability_abs': 0.01, 'utility_abs': 20000.0}

[forage-non-informatif] accord=True controls=True  ->  ACCORD + CONTROLES PASS
    eu_no_info   infer_net=   200000.00  pymc=   200000.00  accord
    evpi         infer_net=   390000.00  pymc=   390000.00  accord
    evsi_brute   infer_net=        0.00  pymc=        0.00  accord
    evsi_nette   infer_net=   -60000.00  pymc=   -60000.00  accord
    decision     infer_net=agir_sans_test  pymc=agir_sans_test  accord
[forage-petrolier] accord=True controls=True  ->  ACCORD + CONTROLES PASS
    eu_no_info   infer_net=   200000.00  pymc=   200000.00  accord
    evpi         infer_net=   390000.00  pymc=   390000.00  accord
    evsi_brute   infer_net=   253000.00  pymc=   252796.00  accord
    evsi_nette   infer_net=   193000.00  pymc=   192796.00  accord
    decision     infer_net=    observer  pymc=    observer  accord
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 subprocess
voi_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}]")
                     0.300000           0.298788  accord
P(pas_petrole|positif)                 0.700000           0.701212  accord
P(petrole|negatif)                     0.300000           0.300107  accord
P(pas_petrole|negatif)                 0.700000           0.699893  accord
P(positif)                             0.500000           0.498095  accord
P(negatif)                             0.500000           0.501905  accord
[infer-net] negatif |EVSI_brute| <= tolerance: PASS (brute=0.00)
[pymc] negatif |EVSI_brute| <= tolerance: PASS (brute=0.00)

=== forage-petrolier ===
champ                                 infer-net               pymc  verdict
eu_no_info                            200000.00          200000.00  accord
evpi                                  390000.00          390000.00  accord
evsi_brute                            253000.00          252796.00  accord
evsi_nette                            193000.00          192796.00  accord
action_no_info                           vendre             vendre  accord
decision                               observer           observer  accord
P(petrole|positif)                     0.658537           0.659102  accord
P(pas_petrole|positif)                 0.341463           0.340898  accord
P(petrole|negatif)                     0.050847           0.050636  accord
P(pas_petrole|negatif)                 0.949153           0.949364  accord
P(positif)                             0.410000           0.408920  accord
P(negatif)                             0.590000           0.591080  accord
[infer-net] discriminant 0 < EVSI_nette < EVPI: PASS (nette=193000 evpi=390000)
[pymc] discriminant 0 < EVSI_nette < EVPI: PASS (nette=192796 evpi=390000)

VERDICT: ACCORD + CONTROLES PASS (tolerance utility_abs=20000, probability_abs=0.01)


[retour comparateur: 0]

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.99
speci = None  # TODO etudiant : entre 0.5 et 0.99
if sensi is None or speci is None:
    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.
Retour au sommet