import numpy as np
from scipy import stats
rng = np.random.default_rng(42)
print("Setup OK - module stats Oversight R12, numpy + scipy.stats")Setup OK - module stats Oversight R12, numpy + scipy.stats
Ce notebook consolide les notebooks anterieurs du meme sub-grain :
| PR | Notebook | Contenu | Section R12 |
|---|---|---|---|
| 1 (#17092) | Oversight-Scaling-Laws-Oversight.ipynb |
Mesure empirique : Nim (jeu a information incomplete) | §2 (base experimentale) |
| 2 (#17099) | Oversight-Scaling-Laws-Analytics.ipynb |
NSO close-form + double-ReLU L-BFGS-B + AIC | §3 (formalisation) |
| 3 (#17108) | Oversight-Scaling-Laws-Wargames.ipynb |
Simulation 3 roles Defender/Attacker/Judge | §5 (Wargames) |
| 4 (ce notebook) | Oversight-Scaling-Laws-Statistics.ipynb |
Tests statistiques + calibration + meta-analyse | §3 + §4 (calibration) |
Sources :
Auto-contenu : numpy + scipy.stats uniquement, pas de GPU, 100% reproductible (seed = 42).
Plan :
PR 1 a mesure l’oversight par un jeu Nim ; PR 2 a formalise par NSO close-form ; PR 3 a simule en Wargames.
Question de calibration : pour des donnees synthetiques dont on connait la vraie valeur des parametres, est-ce que les estimateurs des PRs 1-3 retournent ces valeurs ?
On genere des donnees selon un modele controle et on compare :
D_elo (connu) vs D_elo estime par la formule NSO inverse de PR 2p_success (connu) vs p_success mesure par la simulation Wargames de PR 3n* (connu) vs n* predit par NSOdef D_elo_from_data(wins, losses):
"""Conversion Elo : D_elo = -400 * log10(1/winrate - 1). winrate = wins / (wins+losses).
Convention R12 : D_elo > 0 si le 'fort' (wins > losses) gagne.
Pour wins=60, losses=40 : winrate=0.6 -> D_elo = -400*log10(1/0.6 - 1) = +70.4.
"""
if wins == 0:
return -np.inf # le fort perd toutes les parties, D_elo -> -inf
if losses == 0:
return np.inf # le fort gagne toutes les parties, D_elo -> +inf
winrate = wins / (wins + losses)
return -400 * np.log10(1 / winrate - 1)
def n_star_closeform(D_elo, q):
"""NSO close-form R12 : n* = log(q) / log(1 - D_elo/400).
Renvoie NaN hors domaine :
- q hors [0.5, 1) : probabilite cible invalide
- D_elo <= 0 : Attacker dominant (n* <= 0 par construction, on signale)
- D_elo >= 400 : Defender sature (log(<=0) indefini)
- log(ratio) == 0 : singularite
Interpretation des NaN : NSO trivial (Defender dominant -> n* < 1 -> un seul
niveau de Judge suffit). Voir §4 du notebook pour l'analyse des regimes.
"""
if q <= 0.5 or q >= 1:
return np.nan
if D_elo >= 400:
return float("nan") # Defender sature
ratio = 1 - D_elo / 400
if ratio <= 0:
return float("nan")
if ratio >= 1: # D_elo <= 0
return float("nan") # Attacker dominant -> n* <= 0 -> NSO trivial
val = np.log(q) / np.log(ratio)
if not np.isfinite(val):
return float("nan")
return val
# Donnees de controle : Elo 200 vs 150 (D = 50), 100 parties simulees
true_D = 50
n_games = 100
# Probabilite que le fort gagne selon Elo : p = 1 / (1 + 10^(-D/400))
p_strong = 1 / (1 + 10**(-true_D / 400))
wins = sum(rng.random() < p_strong for _ in range(n_games))
losses = n_games - wins
est_D = D_elo_from_data(wins, losses)
print(f"Vrai D_elo = {true_D}")
print(f"Estime D_elo = {est_D:.1f} (sur {n_games} parties, {wins} victoires du fort)")
print(f"winrate observe = {wins/n_games:.3f} (attendu = {p_strong:.3f})")
print(f"n* predit pour q=0.8 : {n_star_closeform(true_D, 0.8):.3f}")
print(f"n* predit pour q=0.95 : {n_star_closeform(true_D, 0.95):.3f}")Vrai D_elo = 50
Estime D_elo = 70.4 (sur 100 parties, 60 victoires du fort)
winrate observe = 0.600 (attendu = 0.571)
n* predit pour q=0.8 : 1.671
n* predit pour q=0.95 : 0.384
Le vrai écart vaut D_elo = 50, mais 100 parties ne suffisent pas à le voir : 60 victoires du fort (winrate observé 0.600 contre 0.571 attendu) donnent une estimation de 70.4. La conversion passe par log10(1/winrate - 1), dont la dérivée explose quand le winrate s’éloigne de 0.5 : ±2 points de winrate se paient ici +20 points d’Elo. La conséquence se lit sur n* : 1.671 pour q=0.8, 0.384 pour q=0.95 — l’écart de qualité exigé change le nombre de niveaux de Judge requis d’un facteur 4, donc toute erreur d’estimation sur D_elo se propage directement dans le dimensionnement de l’oversight. Les NaN renvoyés par n_star_closeform ne sont pas des bugs : ils délimitent les régimes où la question posée est triviale (cf. section 4 pour celle où elle ne l’est pas).
PR 2 predit un n* en fonction de D_elo et du seuil de qualite q. PR 3 simule un scenario Wargames et mesure p_success selon (K, d_strength).
Test : les predictions NSO de PR 2 sont-elles compatibles avec les observations Wargames de PR 3 ?
Methode : pour un meme scenario (meme K, meme qualite q), le pont Elo est plug-in — D_elo est derive du winrate Defenseur observe par la simulation, via l’inverse calibre en section 1 (D_elo_from_data) — puis on compare - p_success predit par NSO (PR 2) = 1 - (1 - q)^(1/n*) - p_success observe par simulation Wargames (PR 3)
Si les deux sont dans la meme IC95, NSO est conserve par Wargames. Sinon, on cherche le regime de desaccord.
# Scenario test : K = 10, d_strength = 0.0 (Defender transparent, regime trivial)
# PR 3 attend : p_success ~ 1.0 des la 1ere question
# PR 2 predit : si Defender est transparent, D_elo est tres negatif, donc n* est tres petit
K = 10
q = 0.95 # qualite cible
d_strength = 0.0
# Simulation PR 3 (repliquee en inline)
class Defender:
def __init__(self, secret, K, d_strength, rng):
self.secret = secret; self.K = K; self.d_strength = d_strength; self.rng = rng
def answer(self, subset):
truth = self.secret in subset
if self.rng.random() < self.d_strength:
return not truth
return truth
class BayesianAttacker:
def __init__(self, K, rng):
self.K = K; self.rng = rng; self.posterior = np.ones(K) / K
def best_guess(self):
return int(np.argmax(self.posterior))
def update(self, subset, answer):
likelihood = np.array([1.0 if ((s in subset) == answer) else 0.0 for s in range(self.K)])
self.posterior *= likelihood
if self.posterior.sum() == 0:
self.posterior = np.ones(self.K) / self.K
else:
self.posterior /= self.posterior.sum()
def ask_question(self, posterior):
sorted_idx = np.argsort(posterior)[::-1]
cumulative = 0; half = posterior.sum() / 2; split = 1
for i, idx in enumerate(sorted_idx):
cumulative += posterior[idx]
if cumulative >= half:
split = i + 1; break
return set(sorted_idx[:split].tolist())
def run_episode(K, d_strength, n_q, rng):
secret = int(rng.integers(0, K))
d = Defender(secret, K, d_strength, rng); a = BayesianAttacker(K, rng)
for q in range(n_q):
s = a.ask_question(a.posterior); ans = d.answer(s); a.update(s, ans)
if a.posterior.max() > 0.99: break
return a.best_guess() == secret, q + 1
n_eps = 500
results = [run_episode(K, d_strength, 20, rng) for _ in range(n_eps)]
p_obs = sum(s for s, _ in results) / n_eps
avg_q = np.mean([q for _, q in results])
# Pont Elo plug-in : D_elo derive du winrate DEFENSEUR observe sur ces memes
# episodes, via l'inverse calibre en cellule 3 (D_elo_from_data, fort = Defender).
# Remplace l'ancienne heuristique -400*log10(K*(1-d)+d) : son argument valait
# K*(1-d)+d >= 1 pour toute configuration (K >= 1, d dans [0,1]), donc D_elo <= 0
# partout et NSO trivial dans tous les scenarios — elle ne pouvait jamais tester D > 0.
succes = sum(s for s, _ in results)
echecs = n_eps - succes
D_pred = D_elo_from_data(echecs, succes)
n_star = n_star_closeform(D_pred, q)
if np.isfinite(n_star) and n_star > 0:
p_pred_nso = 1 - (1 - q) ** (1 / n_star)
else:
# n_star <= 0 ou NaN : regime trivial (1 niveau suffit / attacker dominant)
p_pred_nso = 1.0
print(f"Scenario : K={K}, d_strength={d_strength}, q_qualite={q}")
print(f"NSO PR 2 : D_elo={D_pred:+.1f} (plug-in winrate defenseur), n*={n_star:.3f}, p_success predit={p_pred_nso:.3f}")
print(f"Wargames PR 3 : p_success observe={p_obs:.3f}, avg_questions={avg_q:.1f} (sur {n_eps} episodes)")
print(f"Ecart NSO-Wargames : {abs(p_pred_nso - p_obs):.3f}")Scenario : K=10, d_strength=0.0, q_qualite=0.95
NSO PR 2 : D_elo=-inf (plug-in winrate defenseur), n*=nan, p_success predit=1.000
Wargames PR 3 : p_success observe=1.000, avg_questions=3.4 (sur 500 episodes)
Ecart NSO-Wargames : 0.000
Avec un Defender qui ne se trompe jamais (d_strength = 0.0), les deux camps disent la même chose : NSO prédit p_success = 1.000, la simulation Wargames observe 1.000 sur 500 épisodes, écart nul. L’information intéressante est le coût : 3.4 questions en moyenne pour isoler le secret parmi K=10. C’est la signature de l’attaquant bayésien de la cellule — chaque question coupe la masse postérieure en deux environ, et log2(10) ≈ 3.32 : la borne informationnelle est atteinte. Retenir ce chiffre comme référence : quand d_strength passera à 0.5 en section 4, c’est le seul levier qui restera à l’attaquant.
Les estimations ponctuelles de p_success et n* ont de l’incertitude liee a l’echantillonnage. On la quantifie par bootstrap : reechantillonnage B fois des observations et calcul des IC95.
Methode :
def bootstrap_ci(observations, stat_func, B=1000, alpha=0.05):
"""IC95 par bootstrap sur observations (liste de scalaires)."""
n = len(observations)
boot_stats = []
for _ in range(B):
sample = [observations[rng.integers(0, n)] for _ in range(n)]
boot_stats.append(stat_func(sample))
lo = np.percentile(boot_stats, 100 * alpha / 2)
hi = np.percentile(boot_stats, 100 * (1 - alpha / 2))
return np.mean(boot_stats), lo, hi
# Bootstrap sur les resultats Wargames precedents (cellule 5)
boot_results = [1.0 if s else 0.0 for s, _ in results]
boot_q = [float(q) for _, q in results]
mean_p, lo_p, hi_p = bootstrap_ci(boot_results, np.mean)
mean_q, lo_q, hi_q = bootstrap_ci(boot_q, np.mean)
print(f"Bootstrap p_success sur {n_eps} observations (B=1000) :")
print(f" Moyenne = {mean_p:.3f}, IC95 = [{lo_p:.3f}, {hi_p:.3f}]")
print(f"Bootstrap avg_questions :")
print(f" Moyenne = {mean_q:.2f}, IC95 = [{lo_q:.2f}, {hi_q:.2f}]")
print(f"Conclusion : Wargames converge en {mean_q:.1f} questions (precision de {hi_q-lo_q:.1f} questions)")Bootstrap p_success sur 500 observations (B=1000) :
Moyenne = 1.000, IC95 = [1.000, 1.000]
Bootstrap avg_questions :
Moyenne = 3.39, IC95 = [3.35, 3.44]
Conclusion : Wargames converge en 3.4 questions (precision de 0.1 questions)
Deux bootstrap, deux leçons. Sur p_success, l’IC95 vaut [1.000, 1.000] : les 500 épisodes sont tous des succès, la distribution bootstrap est un point masse — le percentile bootstrap ne peut pas dire « p est exactement 1 », seulement « aucun tirage observé ne contredit 1 ». C’est la limite connue du percentile bootstrap aux bornes de l’espace paramétrique (un IC binomial exact type Clopper-Pearson donnerait [0.9926, 1.0]). Sur avg_questions en revanche, l’IC [3.35, 3.44] est informatif : la précision de ±0.05 question sur une moyenne de 3.39 avec n=500 fixe le budget d’épisodes qu’il faudrait pour distinguer deux stratégies de questionnement séparées de 0.1 question.
On compare maintenant sur plusieurs scenarios pour identifier le regime de desaccord.
On balaye 3 niveaux de K (petit / moyen / grand) et 2 niveaux de defense (transparent d_strength=0 / mensonges d_strength=0.5), soit 6 scenarios au total. Pour chaque scenario, on calcule :
p_pred_nso : prediction par NSO (PR 2)p_obs_wargames : observation par simulation (PR 3)|ecart| : ecart absoluHypothese H0 : accord parfait — la moyenne des ecarts absolus est nulle. Alternative H1 : NSO sous-estime ou surestime systematiquement.
scenarios = [
(4, 0.0), (4, 0.5),
(10, 0.0), (10, 0.5),
(25, 0.0), (25, 0.5),
]
print(f"{'K':>5} {'d_str':>6} {'w_att':>6} {'D_elo':>8} {'n*':>6} {'p_NSO':>8} {'p_obs':>8} {'ecart':>8}")
print('-' * 68)
ecarts = []
for K_s, d_s in scenarios:
obs = [run_episode(K_s, d_s, 20, rng) for _ in range(200)]
succ = sum(s for s, _ in obs)
p_obs = succ / len(obs)
# Pont plug-in (cf. cellule 5) : D_elo du winrate DEFENSEUR observe,
# via l'inverse calibre en cellule 3 — couvre D > 0, contrairement a
# l'ancienne heuristique qui bornait D_elo <= 0 pour toute configuration.
D_s = D_elo_from_data(len(obs) - succ, succ)
n_s = n_star_closeform(D_s, 0.95)
if np.isfinite(n_s) and n_s > 0:
p_nso = 1 - (1 - 0.95) ** (1 / n_s)
else:
# n_s <= 0 ou NaN : regime trivial (attacker dominant, ou Defender sature)
p_nso = 1.0
ecart = abs(p_nso - p_obs)
ecarts.append(ecart)
print(f'{K_s:>5} {d_s:>6.2f} {p_obs:>6.3f} {D_s:>+8.1f} {n_s:>6.2f} {p_nso:>8.3f} {p_obs:>8.3f} {ecart:>8.3f}')
# Test statistique sur les ecarts : H0 = accord parfait (moyenne des |ecarts| = 0)
t_stat, p_value = stats.ttest_1samp(ecarts, 0)
print(f'\nTest t sur les ecarts : t={t_stat:.3f}, p={p_value:.3f}')
n_grands = sum(1 for e in ecarts if e > 0.1)
if p_value > 0.05:
print(f'H0 non rejetee au seuil 0.05 (p={p_value:.3f}, n={len(ecarts)})')
print(f'Mais {n_grands}/{len(ecarts)} scenarios ont un ecart > 0.10 : le test manque de puissance, l accord n est pas etabli')
else:
print('H0 rejetee : desaccord systematique entre la prediction NSO et la simulation Wargames') K d_str w_att D_elo n* p_NSO p_obs ecart
--------------------------------------------------------------------
4 0.00 1.000 -inf nan 1.000 1.000 0.000
4 0.50 0.230 +209.9 0.07 1.000 0.230 0.770
10 0.00 1.000 -inf nan 1.000 1.000 0.000
10 0.50 0.080 +424.3 nan 1.000 0.080 0.920
25 0.00 1.000 -inf nan 1.000 1.000 0.000
25 0.50 0.040 +552.1 nan 1.000 0.040 0.960
Test t sur les ecarts : t=2.217, p=0.077
H0 non rejetee au seuil 0.05 (p=0.077, n=6)
Mais 3/6 scenarios ont un ecart > 0.10 : le test manque de puissance, l accord n est pas etabli
La table de méta-analyse se lit en deux blocs. Aux trois scénarios transparents (d_strength = 0.0), prédiction et observation coïncident (écart 0.000) — c’est la section 2 répliquée. Aux trois scénarios adverses (d_strength = 0.5), l’écart vaut 0.770 à 0.960 : NSO annonce p ≈ 1.000 quand la simulation observe 0.230, 0.080, 0.040. La cause se lit dans la colonne D_elo : +209.9, +424.3, +552.1 — or le close-form n* n’est défini que pour D_elo < 400 (saturation du Defender). Le plug-in, faute de réponse, décrète p_nso = 1.000 : le modèle répond « trivial » précisément dans le régime le plus dur.
Le test t qui clôt la cellule mérite un regard critique : t = 2.217, p = 0.077, « H0 non rejetée » — conclure à l’accord serait une erreur. Les 6 écarts valent {0, 0.77, 0, 0.92, 0, 0.96} : trois zéros exacts et trois valeurs massives. Avec n = 6, la puissance du test est effondrée, et l’hypothèse de normalité est contredite par cette distribution à deux pôles. Le test ne valide pas le modèle : il mesure surtout son incapacité à séparer 0 de 0.9 sur six points. La conclusion honnête est celle du corps du notebook : le close-form NSO est fiable en régime transparent, et sans réponse en régime adverse — c’est un plafond de modèle documenté, pas un désaccord statistique.
La section 1 a montré qu’une estimation ponctuelle de D_elo sur 100 parties (70.4 pour un vrai 50) peut être largement décalée. Quantifiez cette incertitude.
rng.D_elo_from_data(sum(sample), len(sample) - sum(sample)).Indice : bootstrap_ci(observations, stat_func) existe déjà (section 3) — il suffit d’une stat_func adaptée.
# Exercice 1 : IC95 bootstrap de D_elo sur 100 parties
# Etape 1 : simuler 100 parties (winrate attendu 0.571, D_elo vrai = 50)
# Etape 2 : B=1000 resamples -> D_elo a chaque tirage
# Etape 3 : IC95 percentile + moyenne bootstrap ; le vrai 50 est-il couvert ?
# TODO etudiant
resultat_ic95 = None # TODO etudiant : (moyenne_bootstrap, lo, hi)
print("Exercice 1 a completer : IC95 bootstrap de D_elo sur 100 parties")Exercice 1 a completer : IC95 bootstrap de D_elo sur 100 parties
La méta-analyse conclut « H0 non rejetée » avec p = 0.077 sur 6 écarts dont trois valent ~0.9. Fabrichez le contre-exemple.
Indice : stats.ttest_1samp sur chaque sous-liste ; pour l’étape 3, une monte-carlo rapide (tirer n écarts {0, 0.9} équilibrés, compter les rejets) suffit.
# Exercice 2 : puissance du test t sur les ecarts de la meta-analyse
# Etape 1 : test t sur les 3 ecarts adverses (d_strength = 0.5) seuls
# Etape 2 : test t sur les 3 ecarts transparents seuls
# Etape 3 : monte-carlo -- combien de scenarios pour detecter l'ecart ~0.88 ?
# TODO etudiant
p_adverse = None # TODO etudiant : p-value du bloc adverse
n_requis = None # TODO etudiant : nombre de scenarios pour puissance ~0.8
print("Exercice 2 a completer : puissance du test sur les ecarts")Exercice 2 a completer : puissance du test sur les ecarts
La table de la section 4 montre le close-form muet dès D_elo ≥ 400. Localisez la frontière.
run_episode).n_star_closeform renvoie un nombre ou NaN.Indice : la frontière cherchée est celle où le winrate observé du Defender passe sous ~10 % (winrate → 0.1 donne D_elo → +400 par la formule Elo).
# Exercice 3 : frontiere de saturation D_elo >= 400 en fonction de d_strength
# Etape 1 : scenarios (K=10, d_strength in {0.1, 0.25, 0.4}), 200 episodes chacun
# Etape 2 : p_obs, D_elo estime, n* (nombre ou NaN) par scenario
# Etape 3 : transition progressive ou brutale ? d_strength qui sature K=10 ?
# TODO etudiant
frontiere = None # TODO etudiant : d_strength ou D_elo franchit 400 pour K=10
print("Exercice 3 a completer : frontiere de saturation du close-form")Exercice 3 a completer : frontiere de saturation du close-form
n* = log(q)/log(1 - D/400) n’est definie que pour 0 < D < 400. Avec le pont plug-in, (K=4, d=0.5) tombe dans ce domaine (D = +209.9, n* = 0.07 < 1), mais (K>=10, d=0.5) sature l’echelle Elo (D >= 400 -> n* indefini) : le comportement NSO y reste hors de portee de la formule, et la comparaison a p_NSO = 1.0 y est un fallback, pas une prediction.Apres cette PR 4, le sub-grain est complet :