rpx_benchmark.phi
State-Transition Robustness Φ via repeated-measures MANOVA.
Implements paper §3.2 from raw H (between-phase) and E (within-phase) SSCP
matrices. We do not depend on statsmodels; only numpy (core) and
scipy.stats.f (in the [analysis] extras) are required.
What this module provides
standardise()— z-score each metric column independently across all3N(scene, phase) cells (paper §3.2).compute_phi_oneway()— one-way repeated-measures MANOVA on the phase factor. Returns Wilks-based and Pillai-based Φ plus an F-approximation p-value.compute_phi_per_transition()— pairwise Hotelling T² on within-scene paired differences for each of (Clu→Int, Int→Cln, Clu→Cln). Returnsη² = T² / (T² + N − 1)andΦ = 1 − η²per transition.phi_interpretation()— Cohen anchor lookup (negligible / small / medium / large) for a given Φ value.
Conventions
All array shapes use the convention (N_scenes, n_phases, K_metrics).
Lower-is-better metrics should be negated before standardisation by
the caller (matching paper §3.2 — sign convention for the MANOVA, separate
from the direction-aware bounds policy used by JEDI).
1"""State-Transition Robustness Φ via repeated-measures MANOVA. 2 3Implements paper §3.2 from raw H (between-phase) and E (within-phase) SSCP 4matrices. We do not depend on ``statsmodels``; only ``numpy`` (core) and 5``scipy.stats.f`` (in the ``[analysis]`` extras) are required. 6 7What this module provides 8------------------------- 9 10- :func:`standardise` — z-score each metric column independently across all 11 ``3N`` (scene, phase) cells (paper §3.2). 12- :func:`compute_phi_oneway` — one-way repeated-measures MANOVA on the phase 13 factor. Returns Wilks-based and Pillai-based Φ plus an F-approximation 14 p-value. 15- :func:`compute_phi_per_transition` — pairwise Hotelling T² on within-scene 16 paired differences for each of (Clu→Int, Int→Cln, Clu→Cln). Returns 17 ``η² = T² / (T² + N − 1)`` and ``Φ = 1 − η²`` per transition. 18- :func:`phi_interpretation` — Cohen anchor lookup (negligible / small / 19 medium / large) for a given Φ value. 20 21Conventions 22----------- 23 24All array shapes use the convention ``(N_scenes, n_phases, K_metrics)``. 25Lower-is-better metrics should be **negated before standardisation** by 26the caller (matching paper §3.2 — sign convention for the MANOVA, separate 27from the direction-aware bounds policy used by JEDI). 28""" 29 30from __future__ import annotations 31 32from dataclasses import dataclass, field 33from typing import Any, Dict, List, Optional, Sequence, Tuple 34 35import numpy as np 36 37from .exceptions import MetricError 38from .logging_utils import get_logger 39 40log = get_logger(__name__) 41 42 43# --------------------------------------------------------------------------- # 44# Standardisation 45# --------------------------------------------------------------------------- # 46 47 48def standardise(x: np.ndarray) -> np.ndarray: 49 """Z-score each metric column across all (scene, phase) cells. 50 51 Parameters 52 ---------- 53 x : ndarray, shape ``(N, P, K)`` 54 Raw metric values: ``N`` scenes, ``P`` phases, ``K`` metrics. 55 56 Returns 57 ------- 58 ndarray, shape ``(N, P, K)`` 59 Each metric column normalised to zero mean / unit variance across 60 the ``N*P`` cells. Constant columns are mapped to zeros (so they 61 contribute nothing to the MANOVA test). 62 63 Notes 64 ----- 65 Paper §3.2: standardisation is performed *across the 3N (scene, phase) 66 pairs within a task*, so the K metrics enter the MANOVA on a common 67 scale and the test is invariant to the units of the underlying raw 68 scores. 69 """ 70 if x.ndim != 3: 71 raise MetricError( 72 f"standardise expects shape (N, P, K), got {x.shape}", 73 ) 74 N, P, K = x.shape 75 flat = x.reshape(N * P, K).astype(np.float64) 76 mu = flat.mean(axis=0) 77 sigma = flat.std(axis=0, ddof=0) 78 # Constant columns: variance 0; zero out instead of div-by-zero. 79 safe = np.where(sigma > 0, sigma, 1.0) 80 z = (flat - mu) / safe 81 z[:, sigma == 0] = 0.0 82 return z.reshape(N, P, K) 83 84 85# --------------------------------------------------------------------------- # 86# Collinearity diagnostics 87# --------------------------------------------------------------------------- # 88# 89# Λ = |E| / |H + E| is numerically unstable when E is ill-conditioned, and 90# ill-conditioned E almost always means two K-vector metrics are near-linear 91# combinations of each other (paper §3.2 warns against this but does not 92# gate the computation). We surface a warning at moderate condition numbers 93# and refuse the run at severe ones — with an actionable hint pointing at 94# the worst-correlated metric pair if names are available. 95 96#: Warn when cond(E) exceeds this — Λ may be unstable but the result is usable. 97COLLINEARITY_WARN_COND: float = 30.0 98 99#: Refuse when cond(E) exceeds this — Λ is numerically unreliable. 100COLLINEARITY_FAIL_COND: float = 1000.0 101 102 103def _worst_correlated_pair( 104 z: np.ndarray, 105 metric_names: Optional[Sequence[str]], 106) -> str: 107 """Return a short human-readable hint identifying the most collinear 108 pair of metrics in the standardised K-vector. 109 110 Falls back to an anonymous "column i vs column j" message when 111 ``metric_names`` is None. 112 """ 113 N, P, K = z.shape 114 flat = z.reshape(N * P, K) 115 if K < 2 or flat.shape[0] < 2: 116 return "" 117 with np.errstate(invalid="ignore"): 118 C = np.corrcoef(flat, rowvar=False) 119 # np.corrcoef returns NaN when a column is constant; treat those as 0. 120 C = np.where(np.isfinite(C), C, 0.0) 121 np.fill_diagonal(C, 0.0) 122 i, j = np.unravel_index(int(np.argmax(np.abs(C))), C.shape) 123 r = float(C[i, j]) 124 if metric_names is not None and len(metric_names) == K: 125 return f"worst pair: ({metric_names[int(i)]!r}, {metric_names[int(j)]!r}) with |r|={abs(r):.3f}" 126 return f"worst pair: (column {i}, column {j}) with |r|={abs(r):.3f}" 127 128 129# --------------------------------------------------------------------------- # 130# One-way repeated-measures MANOVA → Φ 131# --------------------------------------------------------------------------- # 132 133 134@dataclass 135class PhiOneway: 136 """Result of the one-way phase MANOVA. 137 138 Attributes 139 ---------- 140 phi_wilks : float 141 :math:`\\Phi = 1 - (1 - \\Lambda^{1/s})` from Wilks' Λ. 142 phi_pillai : float 143 :math:`\\Phi = 1 - V/s` from Pillai's trace V. 144 p_value : float 145 F-approximation p-value (Wilks → Rao's F). Treats per-test p as 146 descriptive; multiple-comparison correction is the caller's 147 responsibility (paper §3.2, Holm-Bonferroni within (model, task)). 148 n_eff : int 149 Number of scenes that contributed (complete-case; scenes with 150 missing predictions in any phase are dropped upstream). 151 K : int 152 Number of metric dimensions. 153 s : int 154 Rank of the hypothesis matrix, ``min(K, df_effect)``. 155 wilks_lambda : float 156 The raw Λ statistic, in case callers want to recompute. 157 pillai_V : float 158 The raw Pillai trace, same reason. 159 """ 160 161 phi_wilks: float 162 phi_pillai: float 163 p_value: float 164 n_eff: int 165 K: int 166 s: int 167 wilks_lambda: float 168 pillai_V: float 169 170 def to_dict(self) -> Dict[str, object]: 171 return { 172 "phi_wilks": self.phi_wilks, 173 "phi_pillai": self.phi_pillai, 174 "phi_conservative": self.phi_conservative, 175 "p_value": self.p_value, 176 "n_eff": self.n_eff, 177 "K": self.K, 178 "s": self.s, 179 "wilks_lambda": self.wilks_lambda, 180 "pillai_V": self.pillai_V, 181 } 182 183 @property 184 def phi_conservative(self) -> float: 185 """The smaller (more conservative) of Wilks/Pillai Φ. 186 187 Per paper §3.2: *"main results report the more conservative 188 estimate, with the full comparison in Appendix C."* 189 """ 190 return min(self.phi_wilks, self.phi_pillai) 191 192 193def _h_e_matrices_oneway(z: np.ndarray) -> Tuple[np.ndarray, np.ndarray, int]: 194 """Build H (between-phase) and E (within-phase) SSCP matrices. 195 196 Repeated-measures structure: scenes are subjects; phase is the 197 within-subjects factor with P levels. 198 199 Parameters 200 ---------- 201 z : ndarray, shape ``(N, P, K)`` 202 Standardised metric values per (scene, phase). 203 204 Returns 205 ------- 206 H : ndarray, shape ``(K, K)`` 207 E : ndarray, shape ``(K, K)`` 208 df_effect : int 209 ``P - 1`` for the phase main effect. 210 """ 211 N, P, K = z.shape 212 213 # Per-phase mean across scenes (shape (P, K)) and overall mean (shape (K,)). 214 phase_means = z.mean(axis=0) 215 grand_mean = z.reshape(N * P, K).mean(axis=0) 216 217 # H: between-phase SSCP, weighted by per-cell count N. 218 diffs_h = phase_means - grand_mean # (P, K) 219 H = N * (diffs_h.T @ diffs_h) 220 221 # E: within-phase SSCP — sum over scenes & phases of (cell - phase_mean) outer. 222 centered = z - phase_means[np.newaxis, :, :] # (N, P, K) 223 flat = centered.reshape(N * P, K) 224 E = flat.T @ flat 225 226 return H, E, P - 1 227 228 229def _rao_f_from_wilks(Lambda: float, K: int, q: int, N: int) -> Tuple[float, int, float]: 230 """Rao's F-approximation for Wilks' Λ in a one-way design. 231 232 Returns (F, df1, df2). With P=3 phases (q=2) and any K≥2 this gives an 233 exact F distribution; for q=1 or K=1 it reduces to a Hotelling/F test 234 exactly. 235 """ 236 # p = K, q = df_effect, N_obs = N*P observations but for repeated-measures 237 # the relevant sample is N subjects; we follow the standard transformation 238 # used in textbooks (Tabachnick & Fidell ch. 7) with v = N - 1 - (K + q + 1)/2. 239 p = K 240 v = N - 1 - (p + q + 1) / 2.0 241 242 pq2 = p * p + q * q 243 if pq2 == 5: 244 t = 1.0 245 else: 246 denom = pq2 - 5.0 247 if denom <= 0: 248 t = 1.0 249 else: 250 t = np.sqrt((p * p * q * q - 4.0) / denom) 251 252 df1 = int(p * q) 253 df2 = int(round(v * t - (p * q - 2) / 2.0)) 254 if df2 <= 0: 255 df2 = max(df1, 1) 256 257 if Lambda <= 0.0: 258 F = float("inf") 259 else: 260 Lambda_t = Lambda ** (1.0 / t) 261 F = ((1.0 - Lambda_t) / Lambda_t) * (df2 / df1) 262 263 return F, df1, df2 264 265 266def compute_phi_oneway( 267 z: np.ndarray, 268 *, 269 metric_names: Optional[Sequence[str]] = None, 270) -> PhiOneway: 271 """One-way repeated-measures MANOVA on the phase factor. 272 273 Parameters 274 ---------- 275 z : ndarray, shape ``(N, P, K)`` 276 Standardised metric values. Use :func:`standardise` first; do 277 not pass raw scores. 278 metric_names : sequence of str, optional 279 Names of the K metrics in column order. When provided, the 280 collinearity guard's error messages / warnings identify the 281 offending metric pair by name; otherwise it falls back to 282 column indices. 283 284 Returns 285 ------- 286 PhiOneway 287 Wilks-based Φ, Pillai-based Φ, F-approximation p-value, and the 288 raw test statistics. 289 290 Notes 291 ----- 292 Paper §3.2 Eq. 2: :math:`\\Phi = 1 - \\eta^2_{phase}`, with 293 :math:`\\eta^2_{phase} = 1 - \\Lambda^{1/s}` (Wilks) or 294 :math:`\\eta^2_{phase} = V / s` (Pillai), where 295 :math:`s = \\min(K, df_{eff})` and :math:`df_{eff} = P - 1`. 296 297 The collinearity guard proactively rejects K-vectors whose within- 298 phase SSCP is numerically ill-conditioned (``cond(E) > 1000``) — a 299 signature of near-duplicate metrics that would otherwise silently 300 destabilise Λ. A softer warning fires at ``cond(E) > 30``. 301 """ 302 if z.ndim != 3: 303 raise MetricError( 304 f"compute_phi_oneway expects shape (N, P, K), got {z.shape}", 305 ) 306 N, P, K = z.shape 307 if N < 2: 308 raise MetricError( 309 f"compute_phi_oneway needs at least 2 scenes, got N={N}", 310 hint="Drop scenes with missing predictions earlier (complete-case).", 311 ) 312 if P < 2: 313 raise MetricError( 314 f"compute_phi_oneway needs at least 2 phases, got P={P}", 315 ) 316 if metric_names is not None and len(metric_names) != K: 317 raise MetricError( 318 f"metric_names has {len(metric_names)} entries, expected K={K}", 319 ) 320 321 H, E, df_effect = _h_e_matrices_oneway(z) 322 s = min(K, df_effect) 323 324 # Collinearity guard — cond(E) diagnoses near-duplicate K-vector metrics 325 # before the log-determinant path silently produces a garbage Λ. 326 if K >= 2: 327 cond_E = float(np.linalg.cond(E)) 328 if not np.isfinite(cond_E) or cond_E > COLLINEARITY_FAIL_COND: 329 hint = _worst_correlated_pair(z, metric_names) 330 raise MetricError( 331 f"Within-phase SSCP E is near-singular " 332 f"(cond(E) = {cond_E:.2e} > {COLLINEARITY_FAIL_COND:.0e})", 333 hint=( 334 "Two or more K-vector metrics are near-linear combinations " 335 "of each other; drop one before running MANOVA. " 336 + hint 337 ).strip(), 338 ) 339 if cond_E > COLLINEARITY_WARN_COND: 340 pair = _worst_correlated_pair(z, metric_names) 341 log.warning( 342 "compute_phi_oneway: cond(E) = %.1f (threshold %.0f) — " 343 "moderate collinearity may destabilise Λ. %s", 344 cond_E, COLLINEARITY_WARN_COND, pair, 345 ) 346 347 # Wilks' Λ = |E| / |H + E|. 348 sign_e, logdet_e = np.linalg.slogdet(E) 349 sign_he, logdet_he = np.linalg.slogdet(H + E) 350 if sign_e <= 0 or sign_he <= 0: 351 raise MetricError( 352 "Wilks' Λ requires positive-definite E and (H+E); got singular matrices", 353 hint=( 354 "This usually means K > N (more metrics than scenes) or perfectly " 355 "collinear metrics. Drop redundant metrics or increase scene count." 356 ), 357 ) 358 Lambda = float(np.exp(logdet_e - logdet_he)) 359 eta2_wilks = 1.0 - Lambda ** (1.0 / s) 360 phi_wilks = 1.0 - eta2_wilks 361 362 # Pillai's trace V = tr( H (H + E)^{-1} ). 363 HE_inv = np.linalg.solve(H + E, H) 364 V = float(np.trace(HE_inv)) 365 eta2_pillai = V / s 366 phi_pillai = 1.0 - eta2_pillai 367 368 # F-approximation p-value via Wilks → Rao's F. 369 try: 370 from scipy.stats import f as f_dist # lazy: scipy is an [analysis] extra 371 except ImportError as e: 372 raise MetricError( 373 "scipy is required for Φ p-values", 374 hint="pip install 'rpx-benchmark[analysis]'", 375 ) from e 376 F, df1, df2 = _rao_f_from_wilks(Lambda, K, df_effect, N) 377 p_value = float(1.0 - f_dist.cdf(F, df1, df2)) if np.isfinite(F) else 0.0 378 379 return PhiOneway( 380 phi_wilks=float(np.clip(phi_wilks, 0.0, 1.0)), 381 phi_pillai=float(np.clip(phi_pillai, 0.0, 1.0)), 382 p_value=p_value, 383 n_eff=N, 384 K=K, 385 s=s, 386 wilks_lambda=Lambda, 387 pillai_V=V, 388 ) 389 390 391# --------------------------------------------------------------------------- # 392# Bootstrap confidence interval for Φ 393# --------------------------------------------------------------------------- # 394# 395# The one-shot Rao's-F p-value from ``compute_phi_oneway`` says whether the 396# phase effect is significant; it does not say how tight the Φ estimate is. 397# A scene-level nonparametric bootstrap answers that: resample scenes (rows 398# of the cube) with replacement, recompute Φ per bootstrap, then report the 399# percentile CI. This is the standard reviewer-defensible way to communicate 400# uncertainty around a small-sample MANOVA effect size. 401 402 403@dataclass 404class PhiBootstrapCI: 405 """Bootstrap distribution of Φ over resampled scene sets. 406 407 Attributes 408 ---------- 409 phi_wilks_mean, phi_pillai_mean : float 410 Mean of the bootstrap Φ estimates (point estimate, robust to the 411 original single-fit result). 412 phi_wilks_ci : tuple[float, float] 413 phi_pillai_ci : tuple[float, float] 414 Percentile confidence intervals ``(low, high)`` at the requested 415 confidence level. 416 phi_wilks_se, phi_pillai_se : float 417 Bootstrap standard error (standard deviation of the resampled Φ). 418 n_boot : int 419 Number of bootstrap resamples requested. 420 n_success : int 421 Number of resamples that yielded a valid Φ (non-singular E, no 422 cond(E) failure). Resamples that fail are skipped and counted 423 towards ``n_failed``. 424 n_failed : int 425 ci_level : float 426 Confidence level used (e.g. 0.95). 427 """ 428 429 phi_wilks_mean: float 430 phi_pillai_mean: float 431 phi_wilks_ci: Tuple[float, float] 432 phi_pillai_ci: Tuple[float, float] 433 phi_wilks_se: float 434 phi_pillai_se: float 435 n_boot: int 436 n_success: int 437 n_failed: int 438 ci_level: float 439 440 def to_dict(self) -> Dict[str, object]: 441 return { 442 "phi_wilks_mean": self.phi_wilks_mean, 443 "phi_pillai_mean": self.phi_pillai_mean, 444 "phi_wilks_ci_low": self.phi_wilks_ci[0], 445 "phi_wilks_ci_high": self.phi_wilks_ci[1], 446 "phi_pillai_ci_low": self.phi_pillai_ci[0], 447 "phi_pillai_ci_high": self.phi_pillai_ci[1], 448 "phi_wilks_se": self.phi_wilks_se, 449 "phi_pillai_se": self.phi_pillai_se, 450 "n_boot": self.n_boot, 451 "n_success": self.n_success, 452 "n_failed": self.n_failed, 453 "ci_level": self.ci_level, 454 } 455 456 457def bootstrap_phi_oneway( 458 z: np.ndarray, 459 *, 460 n_boot: int = 1000, 461 ci_level: float = 0.95, 462 seed: Optional[int] = None, 463 metric_names: Optional[Sequence[str]] = None, 464 min_success_frac: float = 0.5, 465) -> PhiBootstrapCI: 466 """Scene-level nonparametric bootstrap of one-way Φ. 467 468 Parameters 469 ---------- 470 z : ndarray, shape ``(N, P, K)`` 471 Standardised metric values (use :func:`standardise` first). 472 Bootstrap resamples along the scene axis with replacement; the 473 (phase, metric) coupling within each scene is preserved. 474 n_boot : int 475 Number of bootstrap resamples. Defaults to 1000, which is 476 adequate for percentile CIs at the 95% level. 477 ci_level : float 478 Confidence level in ``(0, 1)``. Defaults to 0.95. 479 seed : int, optional 480 Reproducible seed for the resampler. ``None`` uses fresh entropy. 481 metric_names : sequence of str, optional 482 Forwarded to :func:`compute_phi_oneway` on every resample; used 483 only for collinearity-guard hints if a resample fails. 484 min_success_frac : float 485 Raise :class:`MetricError` if fewer than this fraction of 486 resamples yield a valid Φ. Default 0.5 — protects against pipes 487 where standardisation upstream produced degenerate columns. 488 489 Returns 490 ------- 491 PhiBootstrapCI 492 """ 493 if z.ndim != 3: 494 raise MetricError(f"bootstrap_phi_oneway expects (N, P, K), got {z.shape}") 495 N, P, K = z.shape 496 if N < 2: 497 raise MetricError( 498 f"bootstrap_phi_oneway needs at least 2 scenes, got N={N}", 499 ) 500 if not (0.0 < ci_level < 1.0): 501 raise MetricError(f"ci_level must be in (0, 1), got {ci_level}") 502 if n_boot < 50: 503 raise MetricError( 504 f"n_boot={n_boot} is too small for a stable percentile CI; use ≥ 50", 505 ) 506 507 rng = np.random.default_rng(seed) 508 wilks_samples: List[float] = [] 509 pillai_samples: List[float] = [] 510 n_failed = 0 511 512 for _ in range(n_boot): 513 idx = rng.integers(0, N, size=N) 514 z_boot = z[idx] 515 try: 516 r = compute_phi_oneway(z_boot, metric_names=metric_names) 517 except MetricError: 518 n_failed += 1 519 continue 520 wilks_samples.append(r.phi_wilks) 521 pillai_samples.append(r.phi_pillai) 522 523 n_success = len(wilks_samples) 524 if n_success < int(min_success_frac * n_boot): 525 raise MetricError( 526 f"bootstrap failed on {n_failed}/{n_boot} resamples " 527 f"(only {n_success} succeeded)", 528 hint=( 529 "The K-vector is likely marginally-conditioned — many " 530 "scene resamples yield singular E. Reduce K or increase " 531 "the scene pool before bootstrapping." 532 ), 533 ) 534 535 wilks_arr = np.array(wilks_samples, dtype=np.float64) 536 pillai_arr = np.array(pillai_samples, dtype=np.float64) 537 alpha = 1.0 - ci_level 538 lo_q, hi_q = 100.0 * (alpha / 2.0), 100.0 * (1.0 - alpha / 2.0) 539 540 return PhiBootstrapCI( 541 phi_wilks_mean=float(wilks_arr.mean()), 542 phi_pillai_mean=float(pillai_arr.mean()), 543 phi_wilks_ci=( 544 float(np.percentile(wilks_arr, lo_q)), 545 float(np.percentile(wilks_arr, hi_q)), 546 ), 547 phi_pillai_ci=( 548 float(np.percentile(pillai_arr, lo_q)), 549 float(np.percentile(pillai_arr, hi_q)), 550 ), 551 phi_wilks_se=float(wilks_arr.std(ddof=1)) if n_success > 1 else 0.0, 552 phi_pillai_se=float(pillai_arr.std(ddof=1)) if n_success > 1 else 0.0, 553 n_boot=n_boot, 554 n_success=n_success, 555 n_failed=n_failed, 556 ci_level=ci_level, 557 ) 558 559 560# --------------------------------------------------------------------------- # 561# Pairwise Hotelling T² per transition 562# --------------------------------------------------------------------------- # 563 564 565@dataclass 566class PhiTransition: 567 """Result of one pairwise Hotelling T² test. 568 569 Attributes 570 ---------- 571 label : str 572 Human-readable transition label (e.g. ``"0->1"``). 573 phi : float 574 :math:`\\Phi_{i \\to j} = 1 - T^2 / (T^2 + N - 1)`. 575 eta2 : float 576 :math:`\\eta^2 = T^2 / (T^2 + N - 1)`. 577 T2 : float 578 The Hotelling :math:`T^2` statistic on within-scene paired 579 differences. 580 p_value : float 581 F-converted p-value. 582 p_holm : Optional[float] 583 Holm-Bonferroni-adjusted p (set by 584 :func:`compute_phi_per_transition` when multiple transitions 585 are tested jointly). 586 n_eff : int 587 Number of scenes contributing to the paired test. 588 """ 589 590 label: str 591 phi: float 592 eta2: float 593 T2: float 594 p_value: float 595 p_holm: Optional[float] 596 n_eff: int 597 598 def to_dict(self) -> Dict[str, object]: 599 return { 600 "label": self.label, 601 "phi": self.phi, 602 "eta2": self.eta2, 603 "T2": self.T2, 604 "p_value": self.p_value, 605 "p_holm": self.p_holm, 606 "n_eff": self.n_eff, 607 } 608 609 610def _hotelling_one_sample(d: np.ndarray) -> Tuple[float, float, float, int, int]: 611 """One-sample Hotelling T² for paired differences. 612 613 Parameters 614 ---------- 615 d : ndarray, shape ``(N, K)`` 616 Paired-difference vectors (one row per scene). 617 618 Returns 619 ------- 620 T2 : float 621 ``T^2 = N · d̄' S_d^{-1} d̄``. 622 p_value : float 623 F-converted, ``F = ((N-K) / ((N-1) K)) · T^2 ~ F(K, N-K)``. 624 eta2 : float 625 ``T^2 / (T^2 + N - 1)``. 626 df1, df2 : int 627 """ 628 if d.ndim != 2: 629 raise MetricError(f"paired differences must be 2D, got {d.shape}") 630 N, K = d.shape 631 if N <= K: 632 raise MetricError( 633 f"Hotelling T² requires N > K, got N={N}, K={K}", 634 hint="Drop metrics or increase scene count.", 635 ) 636 d_bar = d.mean(axis=0) 637 S_d = np.cov(d, rowvar=False, ddof=1) 638 try: 639 S_inv = np.linalg.inv(S_d) 640 except np.linalg.LinAlgError as e: 641 raise MetricError( 642 "Within-difference covariance matrix is singular", 643 hint="Drop collinear metrics before computing Φ_per_transition.", 644 ) from e 645 T2 = float(N * d_bar @ S_inv @ d_bar) 646 647 # F-distribution conversion. 648 df1 = K 649 df2 = N - K 650 F = ((N - K) / ((N - 1) * K)) * T2 651 652 try: 653 from scipy.stats import f as f_dist 654 except ImportError as e: 655 raise MetricError( 656 "scipy is required for Hotelling T² p-values", 657 hint="pip install 'rpx-benchmark[analysis]'", 658 ) from e 659 p_value = float(1.0 - f_dist.cdf(F, df1, df2)) 660 661 eta2 = T2 / (T2 + N - 1) 662 return T2, p_value, eta2, df1, df2 663 664 665def _holm_bonferroni(p_values: list[float]) -> list[float]: 666 """Holm-Bonferroni step-down adjustment. 667 668 Parameters 669 ---------- 670 p_values : list of float 671 Raw p-values (any order). 672 673 Returns 674 ------- 675 list of float 676 Adjusted p-values in the same order as input. Monotonically 677 non-decreasing under the Holm rule and clipped to ``[0, 1]``. 678 """ 679 m = len(p_values) 680 order = np.argsort(p_values) 681 adjusted = np.empty(m, dtype=np.float64) 682 running_max = 0.0 683 for rank_i, idx in enumerate(order): 684 adj = (m - rank_i) * p_values[idx] 685 running_max = max(running_max, adj) 686 adjusted[idx] = min(running_max, 1.0) 687 return adjusted.tolist() 688 689 690def compute_phi_per_transition( 691 z: np.ndarray, 692 *, 693 transitions: Tuple[Tuple[int, int], ...] = ((0, 1), (1, 2), (0, 2)), 694 apply_holm: bool = True, 695) -> Dict[str, PhiTransition]: 696 """Pairwise Hotelling T² → Φ for each phase transition. 697 698 Parameters 699 ---------- 700 z : ndarray, shape ``(N, P, K)`` 701 Standardised metric values. 702 transitions : tuple of (p1, p2) 703 Phase-index pairs to test. Defaults to all three: Clu→Int, 704 Int→Cln, Clu→Cln. 705 apply_holm : bool 706 Apply Holm-Bonferroni correction across the requested 707 transitions (paper §3.2: family-wise control for the 3 pairwise 708 transitions per (model, task)). 709 710 Returns 711 ------- 712 dict 713 ``{"0->1": PhiTransition, "1->2": ..., "0->2": ...}``. 714 """ 715 if z.ndim != 3: 716 raise MetricError(f"expects (N, P, K), got {z.shape}") 717 N, P, _ = z.shape 718 719 raw_results: Dict[str, PhiTransition] = {} 720 raw_ps: list[float] = [] 721 keys: list[str] = [] 722 for p1, p2 in transitions: 723 if not (0 <= p1 < P) or not (0 <= p2 < P): 724 raise MetricError( 725 f"transition ({p1}, {p2}) out of range for P={P}", 726 ) 727 if p1 == p2: 728 raise MetricError(f"degenerate transition ({p1}, {p1})") 729 d = z[:, p2, :] - z[:, p1, :] 730 T2, p_raw, eta2, _, _ = _hotelling_one_sample(d) 731 phi = 1.0 - eta2 732 key = f"{p1}->{p2}" 733 raw_results[key] = PhiTransition( 734 label=key, 735 phi=float(np.clip(phi, 0.0, 1.0)), 736 eta2=float(np.clip(eta2, 0.0, 1.0)), 737 T2=T2, 738 p_value=p_raw, 739 p_holm=None, 740 n_eff=N, 741 ) 742 raw_ps.append(p_raw) 743 keys.append(key) 744 745 if apply_holm and raw_ps: 746 adj = _holm_bonferroni(raw_ps) 747 for k, p_h in zip(keys, adj, strict=True): 748 r = raw_results[k] 749 raw_results[k] = PhiTransition( 750 label=r.label, 751 phi=r.phi, 752 eta2=r.eta2, 753 T2=r.T2, 754 p_value=r.p_value, 755 p_holm=p_h, 756 n_eff=r.n_eff, 757 ) 758 759 return raw_results 760 761 762# --------------------------------------------------------------------------- # 763# Interpretation rubric (Cohen anchors) 764# --------------------------------------------------------------------------- # 765 766 767def phi_interpretation(phi: float) -> str: 768 """Map a Φ value to a Cohen partial-η² effect-size label. 769 770 Thresholds (paper §3.2): 771 772 - ``η² < 0.01`` → Φ > 0.99 → "negligible" 773 - ``0.01 ≤ η² < 0.06`` → 0.94 < Φ ≤ 0.99 → "small" 774 - ``0.06 ≤ η² < 0.14`` → 0.86 < Φ ≤ 0.94 → "medium" 775 - ``η² ≥ 0.14`` → Φ ≤ 0.86 → "large" 776 """ 777 if not (0.0 <= phi <= 1.0): 778 raise MetricError(f"Φ must be in [0, 1], got {phi}") 779 if phi > 0.99: 780 return "negligible" 781 if phi > 0.94: 782 return "small" 783 if phi > 0.86: 784 return "medium" 785 return "large" 786 787 788# --------------------------------------------------------------------------- # 789# Two-way mixed-design MANOVA (paper §3.2) 790# --------------------------------------------------------------------------- # 791# 792# Phase is within-subjects (each scene appears in all P phases), difficulty 793# tier is between-subjects (each scene belongs to exactly one tier). The 794# three effects we report: 795# 796# - Phase main effect: does the per-scene phase profile differ 797# from a flat one, pooled across tiers? 798# - Difficulty main effect: do tier-averaged scene means differ? 799# - Phase × difficulty interaction: does the phase profile differ across 800# tiers? (The headline number paper §3.2 801# calls η²_{phase×diff}.) 802# 803# Standard recipe: build orthonormal phase contrasts C (P × (P−1)); for each 804# scene, form the contrast vector c_s = C^T x_s ∈ R^{(P−1) × K}, flatten to 805# R^{(P−1)K}. Then: 806# 807# - Phase main effect ≈ one-sample Hotelling on {c_s} vs. zero. 808# - Phase × diff ≈ one-way between-groups MANOVA on {c_s} by tier. 809# - Diff main effect ≈ one-way between-groups MANOVA on 810# {m_s = mean_p x_{s,p}} by tier. 811# 812# This is the standard reduction used in repeated-measures multivariate 813# analysis (Tabachnick & Fidell, ch. 8). It's not the only formulation 814# possible but it's clean and the effect-size η² values match what the 815# paper §3.2 asks for. 816 817 818@dataclass 819class PhiMixedDesign: 820 """Result of the two-way mixed-design MANOVA. 821 822 Attributes 823 ---------- 824 phi_per_tier : Dict[str, PhiOneway] 825 Per-tier one-way Φ (computed by restricting the cell tensor to 826 each tier and running :func:`compute_phi_oneway`). 827 Paper notation: $\\Phi_\\text{E}, \\Phi_\\text{M}, \\Phi_\\text{H}$. 828 eta2_phase : float 829 Main-effect partial $\\eta^2$ for the phase factor, pooled 830 across tiers. 831 eta2_diff : float 832 Main-effect partial $\\eta^2$ for the difficulty factor. 833 eta2_interaction : float 834 Phase × difficulty interaction partial $\\eta^2$. The headline 835 "do harder scenes amplify the phase effect?" number. 836 phi_phase, phi_diff, phi_interaction : float 837 Complements of the η² values, ∈ [0, 1]. 838 p_phase, p_diff, p_interaction : float 839 F-approximation p-values. 840 K : int 841 Metric dimension carried into the contrast space. 842 n_scenes : int 843 Number of scenes that contributed. 844 tier_counts : Dict[str, int] 845 Per-tier scene counts. 846 notes : List[str] 847 """ 848 849 phi_per_tier: Dict[str, PhiOneway] 850 eta2_phase: float 851 eta2_diff: float 852 eta2_interaction: float 853 phi_phase: float 854 phi_diff: float 855 phi_interaction: float 856 p_phase: float 857 p_diff: float 858 p_interaction: float 859 K: int 860 n_scenes: int 861 tier_counts: Dict[str, int] 862 notes: List[str] = field(default_factory=list) 863 864 def to_dict(self) -> Dict[str, object]: 865 return { 866 "phi_per_tier": {t: r.to_dict() for t, r in self.phi_per_tier.items()}, 867 "eta2_phase": self.eta2_phase, 868 "eta2_diff": self.eta2_diff, 869 "eta2_interaction": self.eta2_interaction, 870 "phi_phase": self.phi_phase, 871 "phi_diff": self.phi_diff, 872 "phi_interaction": self.phi_interaction, 873 "p_phase": self.p_phase, 874 "p_diff": self.p_diff, 875 "p_interaction": self.p_interaction, 876 "K": self.K, 877 "n_scenes": self.n_scenes, 878 "tier_counts": dict(self.tier_counts), 879 "notes": list(self.notes), 880 } 881 882 883def _orthonormal_phase_contrasts(P: int) -> np.ndarray: 884 """Return a $P \\times (P-1)$ matrix whose columns are an 885 orthonormal basis for the subspace orthogonal to $(1,\\dots,1)$. 886 887 Built via QR of the centering matrix; numerically stable for any 888 reasonable P. Caller does not need to know the specific basis --- 889 Λ, V, and η² are invariant under orthonormal change of basis on 890 the contrast subspace. 891 """ 892 if P < 2: 893 raise MetricError(f"need P>=2 phases for contrasts, got {P}") 894 centering = np.eye(P) - np.ones((P, P)) / P 895 Q, _ = np.linalg.qr(centering[:, : P - 1]) 896 return Q # shape (P, P-1) 897 898 899def _one_way_between_groups( 900 X: np.ndarray, 901 group_labels: Sequence[Any], 902) -> Tuple[np.ndarray, np.ndarray, int, int]: 903 """Build H, E matrices for a one-way between-groups MANOVA. 904 905 Parameters 906 ---------- 907 X : (N, D) ndarray 908 Per-subject observation vectors. 909 group_labels : length-N sequence 910 Group assignment per row. 911 912 Returns 913 ------- 914 H : (D, D) between-groups SSCP. 915 E : (D, D) within-groups (pooled) SSCP. 916 df_h : int 917 Hypothesis degrees of freedom: G - 1. 918 G : int 919 Number of distinct groups. 920 """ 921 N, D = X.shape 922 if N != len(group_labels): 923 raise MetricError( 924 f"length mismatch: X has {N} rows, group_labels has {len(group_labels)}" 925 ) 926 unique = sorted(set(group_labels), key=str) 927 G = len(unique) 928 if G < 2: 929 raise MetricError( 930 f"between-groups MANOVA needs >=2 groups, got {G}", 931 hint="The interaction term is undefined with a single tier.", 932 ) 933 934 grand = X.mean(axis=0) 935 H = np.zeros((D, D), dtype=np.float64) 936 E = np.zeros((D, D), dtype=np.float64) 937 for g in unique: 938 mask = np.array([gl == g for gl in group_labels], dtype=bool) 939 n_g = int(mask.sum()) 940 if n_g < 1: 941 continue 942 X_g = X[mask] 943 mean_g = X_g.mean(axis=0) 944 diff_g = (mean_g - grand)[:, None] 945 H += n_g * (diff_g @ diff_g.T) 946 centered = X_g - mean_g 947 E += centered.T @ centered 948 949 return H, E, G - 1, G 950 951 952def _wilks_eta_squared_from_he( 953 H: np.ndarray, E: np.ndarray, K: int, df_h: int, n_obs: int 954) -> Tuple[float, float, float]: 955 """Convert (H, E, df_h, n_obs) to (eta²_wilks, F, p_value).""" 956 sign_e, logdet_e = np.linalg.slogdet(E) 957 sign_he, logdet_he = np.linalg.slogdet(H + E) 958 if sign_e <= 0 or sign_he <= 0: 959 raise MetricError( 960 "Wilks Λ requires positive-definite E and (H+E); got singular matrices", 961 hint=( 962 "Drop collinear metrics, increase scene count, or merge " 963 "tiers — the input is too rank-deficient for the test." 964 ), 965 ) 966 Lambda = float(np.exp(logdet_e - logdet_he)) 967 s = max(1, min(K, df_h)) 968 eta2 = 1.0 - Lambda ** (1.0 / s) 969 F, df1, df2 = _rao_f_from_wilks(Lambda, K, df_h, n_obs) 970 971 try: 972 from scipy.stats import f as f_dist 973 except ImportError as e: 974 raise MetricError( 975 "scipy required for F-approximation p-values", 976 hint="pip install 'rpx-benchmark[analysis]'", 977 ) from e 978 979 p_value = float(1.0 - f_dist.cdf(F, df1, df2)) if np.isfinite(F) else 0.0 980 return float(np.clip(eta2, 0.0, 1.0)), F, p_value 981 982 983def compute_phi_mixed_design( 984 z: np.ndarray, 985 tier_by_scene: Sequence[str], 986) -> PhiMixedDesign: 987 """Two-way mixed-design MANOVA: phase within, tier between. 988 989 Parameters 990 ---------- 991 z : ndarray, shape ``(N, P, K)`` 992 Standardised metric values (use :func:`standardise` first). 993 tier_by_scene : length-N sequence 994 Tier label per scene (e.g. ``"easy"``, ``"medium"``, ``"hard"``). 995 Must align with the scene axis of ``z``. 996 997 Returns 998 ------- 999 PhiMixedDesign 1000 """ 1001 if z.ndim != 3: 1002 raise MetricError(f"compute_phi_mixed_design expects (N, P, K), got {z.shape}") 1003 N, P, K = z.shape 1004 if N != len(tier_by_scene): 1005 raise MetricError( 1006 f"tier_by_scene length {len(tier_by_scene)} != N={N}", 1007 ) 1008 if P < 2: 1009 raise MetricError(f"need P>=2 phases, got {P}") 1010 if N < 4: 1011 raise MetricError( 1012 f"need at least 4 scenes for a meaningful mixed design, got {N}", 1013 ) 1014 1015 notes: List[str] = [] 1016 1017 # ---- Per-tier Φ (just restrict compute_phi_oneway to each subset) ---- 1018 tier_counts: Dict[str, int] = {} 1019 phi_per_tier: Dict[str, PhiOneway] = {} 1020 tier_indices: Dict[str, List[int]] = {} 1021 for i, t in enumerate(tier_by_scene): 1022 tier_indices.setdefault(str(t), []).append(i) 1023 for tier, indices in tier_indices.items(): 1024 tier_counts[tier] = len(indices) 1025 if len(indices) < 2: 1026 notes.append(f"tier {tier!r}: only {len(indices)} scene(s); skipping per-tier Φ") 1027 continue 1028 try: 1029 phi_per_tier[tier] = compute_phi_oneway(z[indices]) 1030 except MetricError as e: 1031 notes.append(f"tier {tier!r}: compute_phi_oneway failed ({e})") 1032 1033 # ---- Build contrast vectors c_s ∈ R^{(P-1)*K} per scene ---- 1034 C = _orthonormal_phase_contrasts(P) # (P, P-1) 1035 # c_s = C^T x_s gives (P-1, K); flatten to ((P-1)*K,) 1036 c = np.einsum("ip,npk->nik", C.T, z) # (N, P-1, K) 1037 D_c = (P - 1) * K 1038 c_flat = c.reshape(N, D_c) 1039 1040 # ---- Phase main effect: one-sample Hotelling on c_s vs zero, pooled 1041 # over all scenes. The pool may include tier-mean shifts; we therefore 1042 # center within-tier before testing the grand mean, which is the 1043 # standard within-subjects-only main-effect formulation. 1044 # H_phase = N * c̄_centered c̄_centered^T, E_phase = within-tier SSCP. 1045 eta2_phase = phi_phase = p_phase = 0.0 1046 try: 1047 unique_tiers = sorted(set(str(t) for t in tier_by_scene), key=str) 1048 H_phase = np.zeros((D_c, D_c)) 1049 E_phase = np.zeros((D_c, D_c)) 1050 tier_centered_means_weighted_sum = np.zeros(D_c) 1051 n_total = 0 1052 for tier in unique_tiers: 1053 mask = np.array([str(t) == tier for t in tier_by_scene], dtype=bool) 1054 c_g = c_flat[mask] 1055 if c_g.shape[0] == 0: 1056 continue 1057 mean_g = c_g.mean(axis=0) 1058 tier_centered_means_weighted_sum += c_g.shape[0] * mean_g 1059 n_total += c_g.shape[0] 1060 centered_g = c_g - mean_g 1061 E_phase += centered_g.T @ centered_g 1062 grand_c = tier_centered_means_weighted_sum / max(1, n_total) 1063 # H_phase tests grand_c == 0 (subject-pooled phase profile is flat). 1064 # With df_h = 1, we get a within-subjects T² style test. 1065 H_phase = n_total * np.outer(grand_c, grand_c) 1066 eta2_phase, _F_phase, p_phase = _wilks_eta_squared_from_he( 1067 H_phase, E_phase, K=D_c, df_h=1, n_obs=n_total 1068 ) 1069 phi_phase = float(np.clip(1.0 - eta2_phase, 0.0, 1.0)) 1070 except MetricError as e: 1071 notes.append(f"phase main effect: {e}") 1072 1073 # ---- Phase × difficulty interaction: one-way between-groups MANOVA 1074 # on the contrast vectors {c_s} with tier as the grouping factor. 1075 eta2_int = phi_int = p_int = 0.0 1076 try: 1077 H_int, E_int, df_h_int, _G = _one_way_between_groups(c_flat, list(map(str, tier_by_scene))) 1078 eta2_int, _F_int, p_int = _wilks_eta_squared_from_he( 1079 H_int, E_int, K=D_c, df_h=df_h_int, n_obs=N 1080 ) 1081 phi_int = float(np.clip(1.0 - eta2_int, 0.0, 1.0)) 1082 except MetricError as e: 1083 notes.append(f"phase × difficulty interaction: {e}") 1084 1085 # ---- Difficulty main effect: one-way between-groups MANOVA on 1086 # phase-averaged scene means. 1087 eta2_diff = phi_diff = p_diff = 0.0 1088 try: 1089 m_s = z.mean(axis=1) # (N, K) 1090 H_d, E_d, df_h_d, _G2 = _one_way_between_groups(m_s, list(map(str, tier_by_scene))) 1091 eta2_diff, _F_d, p_diff = _wilks_eta_squared_from_he( 1092 H_d, E_d, K=K, df_h=df_h_d, n_obs=N 1093 ) 1094 phi_diff = float(np.clip(1.0 - eta2_diff, 0.0, 1.0)) 1095 except MetricError as e: 1096 notes.append(f"difficulty main effect: {e}") 1097 1098 return PhiMixedDesign( 1099 phi_per_tier=phi_per_tier, 1100 eta2_phase=eta2_phase, 1101 eta2_diff=eta2_diff, 1102 eta2_interaction=eta2_int, 1103 phi_phase=phi_phase, 1104 phi_diff=phi_diff, 1105 phi_interaction=phi_int, 1106 p_phase=p_phase, 1107 p_diff=p_diff, 1108 p_interaction=p_int, 1109 K=K, 1110 n_scenes=N, 1111 tier_counts=tier_counts, 1112 notes=notes, 1113 ) 1114 1115 1116__all__ = [ 1117 "COLLINEARITY_FAIL_COND", 1118 "COLLINEARITY_WARN_COND", 1119 "PhiBootstrapCI", 1120 "PhiMixedDesign", 1121 "PhiOneway", 1122 "PhiTransition", 1123 "bootstrap_phi_oneway", 1124 "compute_phi_mixed_design", 1125 "compute_phi_oneway", 1126 "compute_phi_per_transition", 1127 "phi_interpretation", 1128 "standardise", 1129]
404@dataclass 405class PhiBootstrapCI: 406 """Bootstrap distribution of Φ over resampled scene sets. 407 408 Attributes 409 ---------- 410 phi_wilks_mean, phi_pillai_mean : float 411 Mean of the bootstrap Φ estimates (point estimate, robust to the 412 original single-fit result). 413 phi_wilks_ci : tuple[float, float] 414 phi_pillai_ci : tuple[float, float] 415 Percentile confidence intervals ``(low, high)`` at the requested 416 confidence level. 417 phi_wilks_se, phi_pillai_se : float 418 Bootstrap standard error (standard deviation of the resampled Φ). 419 n_boot : int 420 Number of bootstrap resamples requested. 421 n_success : int 422 Number of resamples that yielded a valid Φ (non-singular E, no 423 cond(E) failure). Resamples that fail are skipped and counted 424 towards ``n_failed``. 425 n_failed : int 426 ci_level : float 427 Confidence level used (e.g. 0.95). 428 """ 429 430 phi_wilks_mean: float 431 phi_pillai_mean: float 432 phi_wilks_ci: Tuple[float, float] 433 phi_pillai_ci: Tuple[float, float] 434 phi_wilks_se: float 435 phi_pillai_se: float 436 n_boot: int 437 n_success: int 438 n_failed: int 439 ci_level: float 440 441 def to_dict(self) -> Dict[str, object]: 442 return { 443 "phi_wilks_mean": self.phi_wilks_mean, 444 "phi_pillai_mean": self.phi_pillai_mean, 445 "phi_wilks_ci_low": self.phi_wilks_ci[0], 446 "phi_wilks_ci_high": self.phi_wilks_ci[1], 447 "phi_pillai_ci_low": self.phi_pillai_ci[0], 448 "phi_pillai_ci_high": self.phi_pillai_ci[1], 449 "phi_wilks_se": self.phi_wilks_se, 450 "phi_pillai_se": self.phi_pillai_se, 451 "n_boot": self.n_boot, 452 "n_success": self.n_success, 453 "n_failed": self.n_failed, 454 "ci_level": self.ci_level, 455 }
Bootstrap distribution of Φ over resampled scene sets.
Attributes
phi_wilks_mean, phi_pillai_mean : float
Mean of the bootstrap Φ estimates (point estimate, robust to the
original single-fit result).
phi_wilks_ci : tuple[float, float]
phi_pillai_ci : tuple[float, float]
Percentile confidence intervals (low, high) at the requested
confidence level.
phi_wilks_se, phi_pillai_se : float
Bootstrap standard error (standard deviation of the resampled Φ).
n_boot : int
Number of bootstrap resamples requested.
n_success : int
Number of resamples that yielded a valid Φ (non-singular E, no
cond(E) failure). Resamples that fail are skipped and counted
towards n_failed.
n_failed : int
ci_level : float
Confidence level used (e.g. 0.95).
441 def to_dict(self) -> Dict[str, object]: 442 return { 443 "phi_wilks_mean": self.phi_wilks_mean, 444 "phi_pillai_mean": self.phi_pillai_mean, 445 "phi_wilks_ci_low": self.phi_wilks_ci[0], 446 "phi_wilks_ci_high": self.phi_wilks_ci[1], 447 "phi_pillai_ci_low": self.phi_pillai_ci[0], 448 "phi_pillai_ci_high": self.phi_pillai_ci[1], 449 "phi_wilks_se": self.phi_wilks_se, 450 "phi_pillai_se": self.phi_pillai_se, 451 "n_boot": self.n_boot, 452 "n_success": self.n_success, 453 "n_failed": self.n_failed, 454 "ci_level": self.ci_level, 455 }
819@dataclass 820class PhiMixedDesign: 821 """Result of the two-way mixed-design MANOVA. 822 823 Attributes 824 ---------- 825 phi_per_tier : Dict[str, PhiOneway] 826 Per-tier one-way Φ (computed by restricting the cell tensor to 827 each tier and running :func:`compute_phi_oneway`). 828 Paper notation: $\\Phi_\\text{E}, \\Phi_\\text{M}, \\Phi_\\text{H}$. 829 eta2_phase : float 830 Main-effect partial $\\eta^2$ for the phase factor, pooled 831 across tiers. 832 eta2_diff : float 833 Main-effect partial $\\eta^2$ for the difficulty factor. 834 eta2_interaction : float 835 Phase × difficulty interaction partial $\\eta^2$. The headline 836 "do harder scenes amplify the phase effect?" number. 837 phi_phase, phi_diff, phi_interaction : float 838 Complements of the η² values, ∈ [0, 1]. 839 p_phase, p_diff, p_interaction : float 840 F-approximation p-values. 841 K : int 842 Metric dimension carried into the contrast space. 843 n_scenes : int 844 Number of scenes that contributed. 845 tier_counts : Dict[str, int] 846 Per-tier scene counts. 847 notes : List[str] 848 """ 849 850 phi_per_tier: Dict[str, PhiOneway] 851 eta2_phase: float 852 eta2_diff: float 853 eta2_interaction: float 854 phi_phase: float 855 phi_diff: float 856 phi_interaction: float 857 p_phase: float 858 p_diff: float 859 p_interaction: float 860 K: int 861 n_scenes: int 862 tier_counts: Dict[str, int] 863 notes: List[str] = field(default_factory=list) 864 865 def to_dict(self) -> Dict[str, object]: 866 return { 867 "phi_per_tier": {t: r.to_dict() for t, r in self.phi_per_tier.items()}, 868 "eta2_phase": self.eta2_phase, 869 "eta2_diff": self.eta2_diff, 870 "eta2_interaction": self.eta2_interaction, 871 "phi_phase": self.phi_phase, 872 "phi_diff": self.phi_diff, 873 "phi_interaction": self.phi_interaction, 874 "p_phase": self.p_phase, 875 "p_diff": self.p_diff, 876 "p_interaction": self.p_interaction, 877 "K": self.K, 878 "n_scenes": self.n_scenes, 879 "tier_counts": dict(self.tier_counts), 880 "notes": list(self.notes), 881 }
Result of the two-way mixed-design MANOVA.
Attributes
phi_per_tier : Dict[str, PhiOneway]
Per-tier one-way Φ (computed by restricting the cell tensor to
each tier and running compute_phi_oneway()).
Paper notation: $\Phi_\text{E}, \Phi_\text{M}, \Phi_\text{H}$.
eta2_phase : float
Main-effect partial $\eta^2$ for the phase factor, pooled
across tiers.
eta2_diff : float
Main-effect partial $\eta^2$ for the difficulty factor.
eta2_interaction : float
Phase × difficulty interaction partial $\eta^2$. The headline
"do harder scenes amplify the phase effect?" number.
phi_phase, phi_diff, phi_interaction : float
Complements of the η² values, ∈ [0, 1].
p_phase, p_diff, p_interaction : float
F-approximation p-values.
K : int
Metric dimension carried into the contrast space.
n_scenes : int
Number of scenes that contributed.
tier_counts : Dict[str, int]
Per-tier scene counts.
notes : List[str]
865 def to_dict(self) -> Dict[str, object]: 866 return { 867 "phi_per_tier": {t: r.to_dict() for t, r in self.phi_per_tier.items()}, 868 "eta2_phase": self.eta2_phase, 869 "eta2_diff": self.eta2_diff, 870 "eta2_interaction": self.eta2_interaction, 871 "phi_phase": self.phi_phase, 872 "phi_diff": self.phi_diff, 873 "phi_interaction": self.phi_interaction, 874 "p_phase": self.p_phase, 875 "p_diff": self.p_diff, 876 "p_interaction": self.p_interaction, 877 "K": self.K, 878 "n_scenes": self.n_scenes, 879 "tier_counts": dict(self.tier_counts), 880 "notes": list(self.notes), 881 }
135@dataclass 136class PhiOneway: 137 """Result of the one-way phase MANOVA. 138 139 Attributes 140 ---------- 141 phi_wilks : float 142 :math:`\\Phi = 1 - (1 - \\Lambda^{1/s})` from Wilks' Λ. 143 phi_pillai : float 144 :math:`\\Phi = 1 - V/s` from Pillai's trace V. 145 p_value : float 146 F-approximation p-value (Wilks → Rao's F). Treats per-test p as 147 descriptive; multiple-comparison correction is the caller's 148 responsibility (paper §3.2, Holm-Bonferroni within (model, task)). 149 n_eff : int 150 Number of scenes that contributed (complete-case; scenes with 151 missing predictions in any phase are dropped upstream). 152 K : int 153 Number of metric dimensions. 154 s : int 155 Rank of the hypothesis matrix, ``min(K, df_effect)``. 156 wilks_lambda : float 157 The raw Λ statistic, in case callers want to recompute. 158 pillai_V : float 159 The raw Pillai trace, same reason. 160 """ 161 162 phi_wilks: float 163 phi_pillai: float 164 p_value: float 165 n_eff: int 166 K: int 167 s: int 168 wilks_lambda: float 169 pillai_V: float 170 171 def to_dict(self) -> Dict[str, object]: 172 return { 173 "phi_wilks": self.phi_wilks, 174 "phi_pillai": self.phi_pillai, 175 "phi_conservative": self.phi_conservative, 176 "p_value": self.p_value, 177 "n_eff": self.n_eff, 178 "K": self.K, 179 "s": self.s, 180 "wilks_lambda": self.wilks_lambda, 181 "pillai_V": self.pillai_V, 182 } 183 184 @property 185 def phi_conservative(self) -> float: 186 """The smaller (more conservative) of Wilks/Pillai Φ. 187 188 Per paper §3.2: *"main results report the more conservative 189 estimate, with the full comparison in Appendix C."* 190 """ 191 return min(self.phi_wilks, self.phi_pillai)
Result of the one-way phase MANOVA.
Attributes
phi_wilks : float
\( \Phi = 1 - (1 - \Lambda^{1/s}) \) from Wilks' Λ.
phi_pillai : float
\( \Phi = 1 - V/s \) from Pillai's trace V.
p_value : float
F-approximation p-value (Wilks → Rao's F). Treats per-test p as
descriptive; multiple-comparison correction is the caller's
responsibility (paper §3.2, Holm-Bonferroni within (model, task)).
n_eff : int
Number of scenes that contributed (complete-case; scenes with
missing predictions in any phase are dropped upstream).
K : int
Number of metric dimensions.
s : int
Rank of the hypothesis matrix, min(K, df_effect).
wilks_lambda : float
The raw Λ statistic, in case callers want to recompute.
pillai_V : float
The raw Pillai trace, same reason.
171 def to_dict(self) -> Dict[str, object]: 172 return { 173 "phi_wilks": self.phi_wilks, 174 "phi_pillai": self.phi_pillai, 175 "phi_conservative": self.phi_conservative, 176 "p_value": self.p_value, 177 "n_eff": self.n_eff, 178 "K": self.K, 179 "s": self.s, 180 "wilks_lambda": self.wilks_lambda, 181 "pillai_V": self.pillai_V, 182 }
184 @property 185 def phi_conservative(self) -> float: 186 """The smaller (more conservative) of Wilks/Pillai Φ. 187 188 Per paper §3.2: *"main results report the more conservative 189 estimate, with the full comparison in Appendix C."* 190 """ 191 return min(self.phi_wilks, self.phi_pillai)
The smaller (more conservative) of Wilks/Pillai Φ.
Per paper §3.2: "main results report the more conservative estimate, with the full comparison in Appendix C."
566@dataclass 567class PhiTransition: 568 """Result of one pairwise Hotelling T² test. 569 570 Attributes 571 ---------- 572 label : str 573 Human-readable transition label (e.g. ``"0->1"``). 574 phi : float 575 :math:`\\Phi_{i \\to j} = 1 - T^2 / (T^2 + N - 1)`. 576 eta2 : float 577 :math:`\\eta^2 = T^2 / (T^2 + N - 1)`. 578 T2 : float 579 The Hotelling :math:`T^2` statistic on within-scene paired 580 differences. 581 p_value : float 582 F-converted p-value. 583 p_holm : Optional[float] 584 Holm-Bonferroni-adjusted p (set by 585 :func:`compute_phi_per_transition` when multiple transitions 586 are tested jointly). 587 n_eff : int 588 Number of scenes contributing to the paired test. 589 """ 590 591 label: str 592 phi: float 593 eta2: float 594 T2: float 595 p_value: float 596 p_holm: Optional[float] 597 n_eff: int 598 599 def to_dict(self) -> Dict[str, object]: 600 return { 601 "label": self.label, 602 "phi": self.phi, 603 "eta2": self.eta2, 604 "T2": self.T2, 605 "p_value": self.p_value, 606 "p_holm": self.p_holm, 607 "n_eff": self.n_eff, 608 }
Result of one pairwise Hotelling T² test.
Attributes
label : str
Human-readable transition label (e.g. "0->1").
phi : float
\( \Phi_{i \to j} = 1 - T^2 / (T^2 + N - 1) \).
eta2 : float
\( \eta^2 = T^2 / (T^2 + N - 1) \).
T2 : float
The Hotelling \( T^2 \) statistic on within-scene paired
differences.
p_value : float
F-converted p-value.
p_holm : Optional[float]
Holm-Bonferroni-adjusted p (set by
compute_phi_per_transition() when multiple transitions
are tested jointly).
n_eff : int
Number of scenes contributing to the paired test.
458def bootstrap_phi_oneway( 459 z: np.ndarray, 460 *, 461 n_boot: int = 1000, 462 ci_level: float = 0.95, 463 seed: Optional[int] = None, 464 metric_names: Optional[Sequence[str]] = None, 465 min_success_frac: float = 0.5, 466) -> PhiBootstrapCI: 467 """Scene-level nonparametric bootstrap of one-way Φ. 468 469 Parameters 470 ---------- 471 z : ndarray, shape ``(N, P, K)`` 472 Standardised metric values (use :func:`standardise` first). 473 Bootstrap resamples along the scene axis with replacement; the 474 (phase, metric) coupling within each scene is preserved. 475 n_boot : int 476 Number of bootstrap resamples. Defaults to 1000, which is 477 adequate for percentile CIs at the 95% level. 478 ci_level : float 479 Confidence level in ``(0, 1)``. Defaults to 0.95. 480 seed : int, optional 481 Reproducible seed for the resampler. ``None`` uses fresh entropy. 482 metric_names : sequence of str, optional 483 Forwarded to :func:`compute_phi_oneway` on every resample; used 484 only for collinearity-guard hints if a resample fails. 485 min_success_frac : float 486 Raise :class:`MetricError` if fewer than this fraction of 487 resamples yield a valid Φ. Default 0.5 — protects against pipes 488 where standardisation upstream produced degenerate columns. 489 490 Returns 491 ------- 492 PhiBootstrapCI 493 """ 494 if z.ndim != 3: 495 raise MetricError(f"bootstrap_phi_oneway expects (N, P, K), got {z.shape}") 496 N, P, K = z.shape 497 if N < 2: 498 raise MetricError( 499 f"bootstrap_phi_oneway needs at least 2 scenes, got N={N}", 500 ) 501 if not (0.0 < ci_level < 1.0): 502 raise MetricError(f"ci_level must be in (0, 1), got {ci_level}") 503 if n_boot < 50: 504 raise MetricError( 505 f"n_boot={n_boot} is too small for a stable percentile CI; use ≥ 50", 506 ) 507 508 rng = np.random.default_rng(seed) 509 wilks_samples: List[float] = [] 510 pillai_samples: List[float] = [] 511 n_failed = 0 512 513 for _ in range(n_boot): 514 idx = rng.integers(0, N, size=N) 515 z_boot = z[idx] 516 try: 517 r = compute_phi_oneway(z_boot, metric_names=metric_names) 518 except MetricError: 519 n_failed += 1 520 continue 521 wilks_samples.append(r.phi_wilks) 522 pillai_samples.append(r.phi_pillai) 523 524 n_success = len(wilks_samples) 525 if n_success < int(min_success_frac * n_boot): 526 raise MetricError( 527 f"bootstrap failed on {n_failed}/{n_boot} resamples " 528 f"(only {n_success} succeeded)", 529 hint=( 530 "The K-vector is likely marginally-conditioned — many " 531 "scene resamples yield singular E. Reduce K or increase " 532 "the scene pool before bootstrapping." 533 ), 534 ) 535 536 wilks_arr = np.array(wilks_samples, dtype=np.float64) 537 pillai_arr = np.array(pillai_samples, dtype=np.float64) 538 alpha = 1.0 - ci_level 539 lo_q, hi_q = 100.0 * (alpha / 2.0), 100.0 * (1.0 - alpha / 2.0) 540 541 return PhiBootstrapCI( 542 phi_wilks_mean=float(wilks_arr.mean()), 543 phi_pillai_mean=float(pillai_arr.mean()), 544 phi_wilks_ci=( 545 float(np.percentile(wilks_arr, lo_q)), 546 float(np.percentile(wilks_arr, hi_q)), 547 ), 548 phi_pillai_ci=( 549 float(np.percentile(pillai_arr, lo_q)), 550 float(np.percentile(pillai_arr, hi_q)), 551 ), 552 phi_wilks_se=float(wilks_arr.std(ddof=1)) if n_success > 1 else 0.0, 553 phi_pillai_se=float(pillai_arr.std(ddof=1)) if n_success > 1 else 0.0, 554 n_boot=n_boot, 555 n_success=n_success, 556 n_failed=n_failed, 557 ci_level=ci_level, 558 )
Scene-level nonparametric bootstrap of one-way Φ.
Parameters
z : ndarray, shape (N, P, K)
Standardised metric values (use standardise() first).
Bootstrap resamples along the scene axis with replacement; the
(phase, metric) coupling within each scene is preserved.
n_boot : int
Number of bootstrap resamples. Defaults to 1000, which is
adequate for percentile CIs at the 95% level.
ci_level : float
Confidence level in (0, 1). Defaults to 0.95.
seed : int, optional
Reproducible seed for the resampler. None uses fresh entropy.
metric_names : sequence of str, optional
Forwarded to compute_phi_oneway() on every resample; used
only for collinearity-guard hints if a resample fails.
min_success_frac : float
Raise MetricError if fewer than this fraction of
resamples yield a valid Φ. Default 0.5 — protects against pipes
where standardisation upstream produced degenerate columns.
Returns
PhiBootstrapCI
984def compute_phi_mixed_design( 985 z: np.ndarray, 986 tier_by_scene: Sequence[str], 987) -> PhiMixedDesign: 988 """Two-way mixed-design MANOVA: phase within, tier between. 989 990 Parameters 991 ---------- 992 z : ndarray, shape ``(N, P, K)`` 993 Standardised metric values (use :func:`standardise` first). 994 tier_by_scene : length-N sequence 995 Tier label per scene (e.g. ``"easy"``, ``"medium"``, ``"hard"``). 996 Must align with the scene axis of ``z``. 997 998 Returns 999 ------- 1000 PhiMixedDesign 1001 """ 1002 if z.ndim != 3: 1003 raise MetricError(f"compute_phi_mixed_design expects (N, P, K), got {z.shape}") 1004 N, P, K = z.shape 1005 if N != len(tier_by_scene): 1006 raise MetricError( 1007 f"tier_by_scene length {len(tier_by_scene)} != N={N}", 1008 ) 1009 if P < 2: 1010 raise MetricError(f"need P>=2 phases, got {P}") 1011 if N < 4: 1012 raise MetricError( 1013 f"need at least 4 scenes for a meaningful mixed design, got {N}", 1014 ) 1015 1016 notes: List[str] = [] 1017 1018 # ---- Per-tier Φ (just restrict compute_phi_oneway to each subset) ---- 1019 tier_counts: Dict[str, int] = {} 1020 phi_per_tier: Dict[str, PhiOneway] = {} 1021 tier_indices: Dict[str, List[int]] = {} 1022 for i, t in enumerate(tier_by_scene): 1023 tier_indices.setdefault(str(t), []).append(i) 1024 for tier, indices in tier_indices.items(): 1025 tier_counts[tier] = len(indices) 1026 if len(indices) < 2: 1027 notes.append(f"tier {tier!r}: only {len(indices)} scene(s); skipping per-tier Φ") 1028 continue 1029 try: 1030 phi_per_tier[tier] = compute_phi_oneway(z[indices]) 1031 except MetricError as e: 1032 notes.append(f"tier {tier!r}: compute_phi_oneway failed ({e})") 1033 1034 # ---- Build contrast vectors c_s ∈ R^{(P-1)*K} per scene ---- 1035 C = _orthonormal_phase_contrasts(P) # (P, P-1) 1036 # c_s = C^T x_s gives (P-1, K); flatten to ((P-1)*K,) 1037 c = np.einsum("ip,npk->nik", C.T, z) # (N, P-1, K) 1038 D_c = (P - 1) * K 1039 c_flat = c.reshape(N, D_c) 1040 1041 # ---- Phase main effect: one-sample Hotelling on c_s vs zero, pooled 1042 # over all scenes. The pool may include tier-mean shifts; we therefore 1043 # center within-tier before testing the grand mean, which is the 1044 # standard within-subjects-only main-effect formulation. 1045 # H_phase = N * c̄_centered c̄_centered^T, E_phase = within-tier SSCP. 1046 eta2_phase = phi_phase = p_phase = 0.0 1047 try: 1048 unique_tiers = sorted(set(str(t) for t in tier_by_scene), key=str) 1049 H_phase = np.zeros((D_c, D_c)) 1050 E_phase = np.zeros((D_c, D_c)) 1051 tier_centered_means_weighted_sum = np.zeros(D_c) 1052 n_total = 0 1053 for tier in unique_tiers: 1054 mask = np.array([str(t) == tier for t in tier_by_scene], dtype=bool) 1055 c_g = c_flat[mask] 1056 if c_g.shape[0] == 0: 1057 continue 1058 mean_g = c_g.mean(axis=0) 1059 tier_centered_means_weighted_sum += c_g.shape[0] * mean_g 1060 n_total += c_g.shape[0] 1061 centered_g = c_g - mean_g 1062 E_phase += centered_g.T @ centered_g 1063 grand_c = tier_centered_means_weighted_sum / max(1, n_total) 1064 # H_phase tests grand_c == 0 (subject-pooled phase profile is flat). 1065 # With df_h = 1, we get a within-subjects T² style test. 1066 H_phase = n_total * np.outer(grand_c, grand_c) 1067 eta2_phase, _F_phase, p_phase = _wilks_eta_squared_from_he( 1068 H_phase, E_phase, K=D_c, df_h=1, n_obs=n_total 1069 ) 1070 phi_phase = float(np.clip(1.0 - eta2_phase, 0.0, 1.0)) 1071 except MetricError as e: 1072 notes.append(f"phase main effect: {e}") 1073 1074 # ---- Phase × difficulty interaction: one-way between-groups MANOVA 1075 # on the contrast vectors {c_s} with tier as the grouping factor. 1076 eta2_int = phi_int = p_int = 0.0 1077 try: 1078 H_int, E_int, df_h_int, _G = _one_way_between_groups(c_flat, list(map(str, tier_by_scene))) 1079 eta2_int, _F_int, p_int = _wilks_eta_squared_from_he( 1080 H_int, E_int, K=D_c, df_h=df_h_int, n_obs=N 1081 ) 1082 phi_int = float(np.clip(1.0 - eta2_int, 0.0, 1.0)) 1083 except MetricError as e: 1084 notes.append(f"phase × difficulty interaction: {e}") 1085 1086 # ---- Difficulty main effect: one-way between-groups MANOVA on 1087 # phase-averaged scene means. 1088 eta2_diff = phi_diff = p_diff = 0.0 1089 try: 1090 m_s = z.mean(axis=1) # (N, K) 1091 H_d, E_d, df_h_d, _G2 = _one_way_between_groups(m_s, list(map(str, tier_by_scene))) 1092 eta2_diff, _F_d, p_diff = _wilks_eta_squared_from_he( 1093 H_d, E_d, K=K, df_h=df_h_d, n_obs=N 1094 ) 1095 phi_diff = float(np.clip(1.0 - eta2_diff, 0.0, 1.0)) 1096 except MetricError as e: 1097 notes.append(f"difficulty main effect: {e}") 1098 1099 return PhiMixedDesign( 1100 phi_per_tier=phi_per_tier, 1101 eta2_phase=eta2_phase, 1102 eta2_diff=eta2_diff, 1103 eta2_interaction=eta2_int, 1104 phi_phase=phi_phase, 1105 phi_diff=phi_diff, 1106 phi_interaction=phi_int, 1107 p_phase=p_phase, 1108 p_diff=p_diff, 1109 p_interaction=p_int, 1110 K=K, 1111 n_scenes=N, 1112 tier_counts=tier_counts, 1113 notes=notes, 1114 )
Two-way mixed-design MANOVA: phase within, tier between.
Parameters
z : ndarray, shape (N, P, K)
Standardised metric values (use standardise() first).
tier_by_scene : length-N sequence
Tier label per scene (e.g. "easy", "medium", "hard").
Must align with the scene axis of z.
Returns
PhiMixedDesign
267def compute_phi_oneway( 268 z: np.ndarray, 269 *, 270 metric_names: Optional[Sequence[str]] = None, 271) -> PhiOneway: 272 """One-way repeated-measures MANOVA on the phase factor. 273 274 Parameters 275 ---------- 276 z : ndarray, shape ``(N, P, K)`` 277 Standardised metric values. Use :func:`standardise` first; do 278 not pass raw scores. 279 metric_names : sequence of str, optional 280 Names of the K metrics in column order. When provided, the 281 collinearity guard's error messages / warnings identify the 282 offending metric pair by name; otherwise it falls back to 283 column indices. 284 285 Returns 286 ------- 287 PhiOneway 288 Wilks-based Φ, Pillai-based Φ, F-approximation p-value, and the 289 raw test statistics. 290 291 Notes 292 ----- 293 Paper §3.2 Eq. 2: :math:`\\Phi = 1 - \\eta^2_{phase}`, with 294 :math:`\\eta^2_{phase} = 1 - \\Lambda^{1/s}` (Wilks) or 295 :math:`\\eta^2_{phase} = V / s` (Pillai), where 296 :math:`s = \\min(K, df_{eff})` and :math:`df_{eff} = P - 1`. 297 298 The collinearity guard proactively rejects K-vectors whose within- 299 phase SSCP is numerically ill-conditioned (``cond(E) > 1000``) — a 300 signature of near-duplicate metrics that would otherwise silently 301 destabilise Λ. A softer warning fires at ``cond(E) > 30``. 302 """ 303 if z.ndim != 3: 304 raise MetricError( 305 f"compute_phi_oneway expects shape (N, P, K), got {z.shape}", 306 ) 307 N, P, K = z.shape 308 if N < 2: 309 raise MetricError( 310 f"compute_phi_oneway needs at least 2 scenes, got N={N}", 311 hint="Drop scenes with missing predictions earlier (complete-case).", 312 ) 313 if P < 2: 314 raise MetricError( 315 f"compute_phi_oneway needs at least 2 phases, got P={P}", 316 ) 317 if metric_names is not None and len(metric_names) != K: 318 raise MetricError( 319 f"metric_names has {len(metric_names)} entries, expected K={K}", 320 ) 321 322 H, E, df_effect = _h_e_matrices_oneway(z) 323 s = min(K, df_effect) 324 325 # Collinearity guard — cond(E) diagnoses near-duplicate K-vector metrics 326 # before the log-determinant path silently produces a garbage Λ. 327 if K >= 2: 328 cond_E = float(np.linalg.cond(E)) 329 if not np.isfinite(cond_E) or cond_E > COLLINEARITY_FAIL_COND: 330 hint = _worst_correlated_pair(z, metric_names) 331 raise MetricError( 332 f"Within-phase SSCP E is near-singular " 333 f"(cond(E) = {cond_E:.2e} > {COLLINEARITY_FAIL_COND:.0e})", 334 hint=( 335 "Two or more K-vector metrics are near-linear combinations " 336 "of each other; drop one before running MANOVA. " 337 + hint 338 ).strip(), 339 ) 340 if cond_E > COLLINEARITY_WARN_COND: 341 pair = _worst_correlated_pair(z, metric_names) 342 log.warning( 343 "compute_phi_oneway: cond(E) = %.1f (threshold %.0f) — " 344 "moderate collinearity may destabilise Λ. %s", 345 cond_E, COLLINEARITY_WARN_COND, pair, 346 ) 347 348 # Wilks' Λ = |E| / |H + E|. 349 sign_e, logdet_e = np.linalg.slogdet(E) 350 sign_he, logdet_he = np.linalg.slogdet(H + E) 351 if sign_e <= 0 or sign_he <= 0: 352 raise MetricError( 353 "Wilks' Λ requires positive-definite E and (H+E); got singular matrices", 354 hint=( 355 "This usually means K > N (more metrics than scenes) or perfectly " 356 "collinear metrics. Drop redundant metrics or increase scene count." 357 ), 358 ) 359 Lambda = float(np.exp(logdet_e - logdet_he)) 360 eta2_wilks = 1.0 - Lambda ** (1.0 / s) 361 phi_wilks = 1.0 - eta2_wilks 362 363 # Pillai's trace V = tr( H (H + E)^{-1} ). 364 HE_inv = np.linalg.solve(H + E, H) 365 V = float(np.trace(HE_inv)) 366 eta2_pillai = V / s 367 phi_pillai = 1.0 - eta2_pillai 368 369 # F-approximation p-value via Wilks → Rao's F. 370 try: 371 from scipy.stats import f as f_dist # lazy: scipy is an [analysis] extra 372 except ImportError as e: 373 raise MetricError( 374 "scipy is required for Φ p-values", 375 hint="pip install 'rpx-benchmark[analysis]'", 376 ) from e 377 F, df1, df2 = _rao_f_from_wilks(Lambda, K, df_effect, N) 378 p_value = float(1.0 - f_dist.cdf(F, df1, df2)) if np.isfinite(F) else 0.0 379 380 return PhiOneway( 381 phi_wilks=float(np.clip(phi_wilks, 0.0, 1.0)), 382 phi_pillai=float(np.clip(phi_pillai, 0.0, 1.0)), 383 p_value=p_value, 384 n_eff=N, 385 K=K, 386 s=s, 387 wilks_lambda=Lambda, 388 pillai_V=V, 389 )
One-way repeated-measures MANOVA on the phase factor.
Parameters
z : ndarray, shape (N, P, K)
Standardised metric values. Use standardise() first; do
not pass raw scores.
metric_names : sequence of str, optional
Names of the K metrics in column order. When provided, the
collinearity guard's error messages / warnings identify the
offending metric pair by name; otherwise it falls back to
column indices.
Returns
PhiOneway Wilks-based Φ, Pillai-based Φ, F-approximation p-value, and the raw test statistics.
Notes
Paper §3.2 Eq. 2: \( \Phi = 1 - \eta^2_{phase} \), with \( \eta^2_{phase} = 1 - \Lambda^{1/s} \) (Wilks) or \( \eta^2_{phase} = V / s \) (Pillai), where \( s = \min(K, df_{eff}) \) and \( df_{eff} = P - 1 \).
The collinearity guard proactively rejects K-vectors whose within-
phase SSCP is numerically ill-conditioned (cond(E) > 1000) — a
signature of near-duplicate metrics that would otherwise silently
destabilise Λ. A softer warning fires at cond(E) > 30.
691def compute_phi_per_transition( 692 z: np.ndarray, 693 *, 694 transitions: Tuple[Tuple[int, int], ...] = ((0, 1), (1, 2), (0, 2)), 695 apply_holm: bool = True, 696) -> Dict[str, PhiTransition]: 697 """Pairwise Hotelling T² → Φ for each phase transition. 698 699 Parameters 700 ---------- 701 z : ndarray, shape ``(N, P, K)`` 702 Standardised metric values. 703 transitions : tuple of (p1, p2) 704 Phase-index pairs to test. Defaults to all three: Clu→Int, 705 Int→Cln, Clu→Cln. 706 apply_holm : bool 707 Apply Holm-Bonferroni correction across the requested 708 transitions (paper §3.2: family-wise control for the 3 pairwise 709 transitions per (model, task)). 710 711 Returns 712 ------- 713 dict 714 ``{"0->1": PhiTransition, "1->2": ..., "0->2": ...}``. 715 """ 716 if z.ndim != 3: 717 raise MetricError(f"expects (N, P, K), got {z.shape}") 718 N, P, _ = z.shape 719 720 raw_results: Dict[str, PhiTransition] = {} 721 raw_ps: list[float] = [] 722 keys: list[str] = [] 723 for p1, p2 in transitions: 724 if not (0 <= p1 < P) or not (0 <= p2 < P): 725 raise MetricError( 726 f"transition ({p1}, {p2}) out of range for P={P}", 727 ) 728 if p1 == p2: 729 raise MetricError(f"degenerate transition ({p1}, {p1})") 730 d = z[:, p2, :] - z[:, p1, :] 731 T2, p_raw, eta2, _, _ = _hotelling_one_sample(d) 732 phi = 1.0 - eta2 733 key = f"{p1}->{p2}" 734 raw_results[key] = PhiTransition( 735 label=key, 736 phi=float(np.clip(phi, 0.0, 1.0)), 737 eta2=float(np.clip(eta2, 0.0, 1.0)), 738 T2=T2, 739 p_value=p_raw, 740 p_holm=None, 741 n_eff=N, 742 ) 743 raw_ps.append(p_raw) 744 keys.append(key) 745 746 if apply_holm and raw_ps: 747 adj = _holm_bonferroni(raw_ps) 748 for k, p_h in zip(keys, adj, strict=True): 749 r = raw_results[k] 750 raw_results[k] = PhiTransition( 751 label=r.label, 752 phi=r.phi, 753 eta2=r.eta2, 754 T2=r.T2, 755 p_value=r.p_value, 756 p_holm=p_h, 757 n_eff=r.n_eff, 758 ) 759 760 return raw_results
Pairwise Hotelling T² → Φ for each phase transition.
Parameters
z : ndarray, shape (N, P, K)
Standardised metric values.
transitions : tuple of (p1, p2)
Phase-index pairs to test. Defaults to all three: Clu→Int,
Int→Cln, Clu→Cln.
apply_holm : bool
Apply Holm-Bonferroni correction across the requested
transitions (paper §3.2: family-wise control for the 3 pairwise
transitions per (model, task)).
Returns
dict
{"0->1": PhiTransition, "1->2": ..., "0->2": ...}.
768def phi_interpretation(phi: float) -> str: 769 """Map a Φ value to a Cohen partial-η² effect-size label. 770 771 Thresholds (paper §3.2): 772 773 - ``η² < 0.01`` → Φ > 0.99 → "negligible" 774 - ``0.01 ≤ η² < 0.06`` → 0.94 < Φ ≤ 0.99 → "small" 775 - ``0.06 ≤ η² < 0.14`` → 0.86 < Φ ≤ 0.94 → "medium" 776 - ``η² ≥ 0.14`` → Φ ≤ 0.86 → "large" 777 """ 778 if not (0.0 <= phi <= 1.0): 779 raise MetricError(f"Φ must be in [0, 1], got {phi}") 780 if phi > 0.99: 781 return "negligible" 782 if phi > 0.94: 783 return "small" 784 if phi > 0.86: 785 return "medium" 786 return "large"
Map a Φ value to a Cohen partial-η² effect-size label.
Thresholds (paper §3.2):
η² < 0.01→ Φ > 0.99 → "negligible"0.01 ≤ η² < 0.06→ 0.94 < Φ ≤ 0.99 → "small"0.06 ≤ η² < 0.14→ 0.86 < Φ ≤ 0.94 → "medium"η² ≥ 0.14→ Φ ≤ 0.86 → "large"
49def standardise(x: np.ndarray) -> np.ndarray: 50 """Z-score each metric column across all (scene, phase) cells. 51 52 Parameters 53 ---------- 54 x : ndarray, shape ``(N, P, K)`` 55 Raw metric values: ``N`` scenes, ``P`` phases, ``K`` metrics. 56 57 Returns 58 ------- 59 ndarray, shape ``(N, P, K)`` 60 Each metric column normalised to zero mean / unit variance across 61 the ``N*P`` cells. Constant columns are mapped to zeros (so they 62 contribute nothing to the MANOVA test). 63 64 Notes 65 ----- 66 Paper §3.2: standardisation is performed *across the 3N (scene, phase) 67 pairs within a task*, so the K metrics enter the MANOVA on a common 68 scale and the test is invariant to the units of the underlying raw 69 scores. 70 """ 71 if x.ndim != 3: 72 raise MetricError( 73 f"standardise expects shape (N, P, K), got {x.shape}", 74 ) 75 N, P, K = x.shape 76 flat = x.reshape(N * P, K).astype(np.float64) 77 mu = flat.mean(axis=0) 78 sigma = flat.std(axis=0, ddof=0) 79 # Constant columns: variance 0; zero out instead of div-by-zero. 80 safe = np.where(sigma > 0, sigma, 1.0) 81 z = (flat - mu) / safe 82 z[:, sigma == 0] = 0.0 83 return z.reshape(N, P, K)
Z-score each metric column across all (scene, phase) cells.
Parameters
x : ndarray, shape (N, P, K)
Raw metric values: N scenes, P phases, K metrics.
Returns
ndarray, shape (N, P, K)
Each metric column normalised to zero mean / unit variance across
the N*P cells. Constant columns are mapped to zeros (so they
contribute nothing to the MANOVA test).
Notes
Paper §3.2: standardisation is performed across the 3N (scene, phase) pairs within a task, so the K metrics enter the MANOVA on a common scale and the test is invariant to the units of the underlying raw scores.