rpx_benchmark.paper_depth_analysis

Paper-level statistical analysis for the six-metric RPX D1-F cell log.

  1"""Paper-level statistical analysis for the six-metric RPX D1-F cell log."""
  2
  3from __future__ import annotations
  4
  5import csv
  6import hashlib
  7import json
  8import math
  9from pathlib import Path
 10from typing import Any, Mapping, Sequence
 11
 12import numpy as np
 13
 14from .cell_log import merge_cells, write_cells
 15from .exceptions import ConfigError, DatasetError
 16from .metrics.depth_paper import D1_CALIBRATION, PAPER_METRIC_KEYS
 17
 18PHASES = ("clutter", "interaction", "clean")
 19DIFFICULTIES = ("easy", "medium", "hard")
 20METRIC_DIRECTIONS = {
 21    "absrel": "lower",
 22    "rmse": "lower",
 23    "silog": "lower",
 24    "delta1": "higher",
 25    "irmse": "lower",
 26    "fscore_5cm": "higher",
 27}
 28_PHASE_ALIASES = {
 29    "0": "clutter",
 30    "clu": "clutter",
 31    "clutter": "clutter",
 32    "1": "interaction",
 33    "int": "interaction",
 34    "interaction": "interaction",
 35    "2": "clean",
 36    "cln": "clean",
 37    "clean": "clean",
 38}
 39
 40
 41def _phase_contrasts() -> np.ndarray:
 42    return np.array(
 43        [
 44            [-1.0 / math.sqrt(2.0), -1.0 / math.sqrt(6.0)],
 45            [1.0 / math.sqrt(2.0), -1.0 / math.sqrt(6.0)],
 46            [0.0, 2.0 / math.sqrt(6.0)],
 47        ]
 48    )
 49
 50
 51def _residual_ssp(y: np.ndarray, design: np.ndarray) -> tuple[np.ndarray, int]:
 52    rank = int(np.linalg.matrix_rank(design))
 53    df = len(y) - rank
 54    if df <= 0:
 55        raise ConfigError("MANOVA design has no residual degrees of freedom.")
 56    coefficients, *_ = np.linalg.lstsq(design, y, rcond=None)
 57    residual = y - design @ coefficients
 58    return residual.T @ residual, df
 59
 60
 61def _effect_summary(h: np.ndarray, e: np.ndarray, *, df_h: int, df_e: int) -> dict[str, float]:
 62    from scipy.stats import f as f_distribution
 63
 64    h = (h + h.T) / 2.0
 65    e = (e + e.T) / 2.0
 66    response_dim = e.shape[0]
 67    effect_s = min(response_dim, df_h)
 68
 69    # An exactly phase-invariant cube has H=0 and E=0 after subject effects.
 70    # This is a well-defined null effect (Phi=1), even though E has no inverse.
 71    tolerance = np.finfo(np.float64).eps * max(1.0, float(np.linalg.norm(e))) * 1_000
 72    if float(np.linalg.norm(h)) <= tolerance:
 73        p = float(response_dim)
 74        q = float(df_h)
 75        numerator = p * p * q * q - 4.0
 76        denominator = p * p + q * q - 5.0
 77        rao_t = math.sqrt(numerator / denominator) if numerator > 0 and denominator > 0 else 1.0
 78        df1 = response_dim * df_h
 79        df2 = rao_t * (df_e - (response_dim - df_h + 1.0) / 2.0) - (df1 - 2.0) / 2.0
 80        if df2 <= 0:
 81            raise ConfigError("Rao F approximation produced non-positive denominator df.")
 82        return {
 83            "wilks_lambda": 1.0,
 84            "rao_f": 0.0,
 85            "df1": float(df1),
 86            "df2": float(df2),
 87            "p_value": 1.0,
 88            "partial_eta_squared": 0.0,
 89            "phi_wilks": 1.0,
 90            "pillai_trace": 0.0,
 91            "phi_pillai": 1.0,
 92            "pillai_minus_wilks_phi": 0.0,
 93        }
 94    try:
 95        chol = np.linalg.cholesky(e)
 96    except np.linalg.LinAlgError as exc:
 97        raise ConfigError(
 98            "MANOVA residual SSP matrix is singular.",
 99            hint="Check for constant/redundant metrics or incomplete scene-phase cells.",
100        ) from exc
101    transformed = np.linalg.solve(chol, h)
102    transformed = np.linalg.solve(chol, transformed.T).T
103    eigenvalues = np.linalg.eigvalsh((transformed + transformed.T) / 2.0)
104    eigenvalues = np.clip(eigenvalues, 0.0, None)
105    wilks = float(np.prod(1.0 / (1.0 + eigenvalues)))
106    pillai = float(np.sum(eigenvalues / (1.0 + eigenvalues)))
107    phi_wilks = float(wilks ** (1.0 / effect_s))
108    eta_wilks = float(1.0 - phi_wilks)
109    phi_pillai = float(1.0 - pillai / effect_s)
110
111    # Rao's F approximation for Wilks' lambda.
112    p = float(response_dim)
113    q = float(df_h)
114    numerator = p * p * q * q - 4.0
115    denominator = p * p + q * q - 5.0
116    rao_t = math.sqrt(numerator / denominator) if numerator > 0 and denominator > 0 else 1.0
117    df1 = int(response_dim * df_h)
118    df2 = rao_t * (df_e - (response_dim - df_h + 1.0) / 2.0) - (df1 - 2.0) / 2.0
119    if df2 <= 0:
120        raise ConfigError("Rao F approximation produced non-positive denominator df.")
121    lambda_root = wilks ** (1.0 / rao_t)
122    f_value = 0.0 if lambda_root >= 1.0 else ((1.0 - lambda_root) / lambda_root) * (df2 / df1)
123    p_value = float(f_distribution.sf(f_value, df1, df2))
124    return {
125        "wilks_lambda": wilks,
126        "rao_f": float(f_value),
127        "df1": float(df1),
128        "df2": float(df2),
129        "p_value": p_value,
130        "partial_eta_squared": eta_wilks,
131        "phi_wilks": phi_wilks,
132        "pillai_trace": pillai,
133        "phi_pillai": phi_pillai,
134        "pillai_minus_wilks_phi": float(phi_pillai - phi_wilks),
135    }
136
137
138def _nested_effect(
139    y: np.ndarray, reduced: np.ndarray, full: np.ndarray, *, df_h: int
140) -> dict[str, float]:
141    e_reduced, _ = _residual_ssp(y, reduced)
142    e_full, df_e = _residual_ssp(y, full)
143    h = e_reduced - e_full
144    return _effect_summary(h, e_full, df_h=df_h, df_e=df_e)
145
146
147def _repeated_phase_test(cube: np.ndarray) -> dict[str, float]:
148    n_scenes, n_phases, n_metrics = cube.shape
149    if n_phases != 3 or n_metrics != len(PAPER_METRIC_KEYS):
150        raise ConfigError(f"Unexpected MANOVA cube shape: {cube.shape}")
151    y = cube.reshape(n_scenes * n_phases, n_metrics)
152    subjects = np.repeat(np.eye(n_scenes), n_phases, axis=0)
153    phase = np.tile(_phase_contrasts(), (n_scenes, 1))
154    return _nested_effect(y, subjects, np.column_stack((subjects, phase)), df_h=2)
155
156
157def _hotelling_transition(a: np.ndarray, b: np.ndarray) -> dict[str, float]:
158    from scipy.stats import f as f_distribution
159
160    diff = b - a
161    n, k = diff.shape
162    if n <= k:
163        raise ConfigError(f"Hotelling T2 requires scenes > metrics; got {n} <= {k}.")
164    mean = np.mean(diff, axis=0)
165    if np.allclose(diff, 0.0, rtol=0.0, atol=1e-12):
166        return {
167            "hotelling_t_squared": 0.0,
168            "f_value": 0.0,
169            "df1": float(k),
170            "df2": float(n - k),
171            "p_value_raw": 1.0,
172            "wilks_lambda": 1.0,
173            "phi": 1.0,
174        }
175    covariance = np.cov(diff, rowvar=False, ddof=1)
176    try:
177        solved = np.linalg.solve(covariance, mean)
178    except np.linalg.LinAlgError as exc:
179        raise ConfigError("Hotelling covariance matrix is singular.") from exc
180    t_squared = float(n * mean @ solved)
181    f_value = float((n - k) * t_squared / (k * (n - 1)))
182    p_value = float(f_distribution.sf(f_value, k, n - k))
183    wilks = float(1.0 / (1.0 + t_squared / (n - 1)))
184    return {
185        "hotelling_t_squared": t_squared,
186        "f_value": f_value,
187        "df1": float(k),
188        "df2": float(n - k),
189        "p_value_raw": p_value,
190        "wilks_lambda": wilks,
191        "phi": wilks,
192    }
193
194
195def _holm_adjust(values: Sequence[float]) -> list[float]:
196    order = np.argsort(values)
197    adjusted = np.empty(len(values), dtype=np.float64)
198    running = 0.0
199    for rank, index in enumerate(order):
200        candidate = min(1.0, (len(values) - rank) * float(values[index]))
201        running = max(running, candidate)
202        adjusted[index] = running
203    return adjusted.tolist()
204
205
206def _interaction_test(cube: np.ndarray, difficulties: Sequence[str]) -> dict[str, float]:
207    n_scenes, n_phases, n_metrics = cube.shape
208    y = cube.reshape(n_scenes * n_phases, n_metrics)
209    subjects = np.repeat(np.eye(n_scenes), n_phases, axis=0)
210    phase = np.tile(_phase_contrasts(), (n_scenes, 1))
211    difficulty_index = np.array([DIFFICULTIES.index(value) for value in difficulties])
212    difficulty = _phase_contrasts()[difficulty_index]
213    difficulty_long = np.repeat(difficulty, n_phases, axis=0)
214    interaction = (phase[:, :, None] * difficulty_long[:, None, :]).reshape(len(y), 4)
215    reduced = np.column_stack((subjects, phase))
216    full = np.column_stack((reduced, interaction))
217    return _nested_effect(y, reduced, full, df_h=4)
218
219
220def _load_jedi_bounds(path: Path | None) -> tuple[dict[str, Any] | None, str | None]:
221    if path is None:
222        return None, None
223    raw = path.read_bytes()
224    payload = json.loads(raw)
225    if not payload.get("version") or not payload.get("provenance"):
226        raise ConfigError("JEDI bounds require non-empty version and provenance.")
227    epsilon = payload.get("epsilon")
228    if not isinstance(epsilon, (int, float)) or not 0.0 < float(epsilon) <= 1.0:
229        raise ConfigError("JEDI epsilon must be numeric in (0, 1].")
230    metrics = payload.get("metrics")
231    if not isinstance(metrics, dict) or set(metrics) != set(PAPER_METRIC_KEYS):
232        raise ConfigError(f"JEDI bounds must define exactly: {list(PAPER_METRIC_KEYS)}")
233    for key in PAPER_METRIC_KEYS:
234        spec = metrics[key]
235        if spec.get("direction") != METRIC_DIRECTIONS[key]:
236            raise ConfigError(f"JEDI direction mismatch for {key}.")
237        if not all(isinstance(spec.get(field), (int, float)) for field in ("best", "worst")):
238            raise ConfigError(f"JEDI {key} requires numeric best and worst bounds.")
239        best = float(spec["best"])
240        worst = float(spec["worst"])
241        if not np.isfinite([best, worst]).all():
242            raise ConfigError(f"JEDI {key} bounds must be finite.")
243        if (spec["direction"] == "lower" and best >= worst) or (
244            spec["direction"] == "higher" and best <= worst
245        ):
246            raise ConfigError(f"JEDI {key} best/worst bounds contradict its direction.")
247    return payload, hashlib.sha256(raw).hexdigest()
248
249
250def _jedi_score(values: Mapping[str, float], bounds: Mapping[str, Any]) -> dict[str, Any]:
251    epsilon = float(bounds["epsilon"])
252    desirabilities: dict[str, float] = {}
253    for key in PAPER_METRIC_KEYS:
254        spec = bounds["metrics"][key]
255        value = float(values[key])
256        desirability = (value - float(spec["worst"])) / (float(spec["best"]) - float(spec["worst"]))
257        desirabilities[key] = float(np.clip(desirability, 0.0, 1.0))
258    score = float(
259        np.exp(np.mean(np.log([max(value, epsilon) for value in desirabilities.values()])))
260    )
261    return {"score": score, "desirabilities": desirabilities}
262
263
264def analyze_depth_cells(
265    paths: Sequence[str | Path],
266    *,
267    jedi_bounds_path: str | Path | None = None,
268    expected_scenes: int = 100,
269    provenance: Mapping[str, Any] | None = None,
270) -> tuple[dict[str, Any], list[dict[str, Any]]]:
271    """Validate, merge and analyze three RPX D1-F split cell logs."""
272    rows = merge_cells(Path(path) for path in paths)
273    rows = [
274        row
275        for row in rows
276        if row.get("task") == "monocular_depth" and int(row.get("frame_budget") or 0) == 0
277    ]
278    if not rows:
279        raise DatasetError("No monocular_depth cells found in the supplied logs.")
280    model_names = {str(row.get("model_name")) for row in rows}
281    if len(model_names) != 1 or model_names == {"None"} or model_names == {""}:
282        raise DatasetError(f"Paper analysis requires exactly one model; got {sorted(model_names)}")
283
284    by_key: dict[tuple[str, str], dict[str, Any]] = {}
285    for row in rows:
286        scene = str(row.get("scene_id") or "")
287        phase_raw = str(row.get("phase") or "").lower()
288        phase = _PHASE_ALIASES.get(phase_raw)
289        if not scene or phase is None:
290            raise DatasetError(f"Invalid scene/phase cell: {scene!r}/{phase_raw!r}")
291        key = (scene, phase)
292        if key in by_key:
293            raise DatasetError(f"Duplicate scene-phase cell: {key}")
294        normalized = dict(row)
295        normalized["phase"] = phase
296        by_key[key] = normalized
297
298    scenes = sorted({scene for scene, _phase in by_key})
299    if len(scenes) != expected_scenes:
300        raise DatasetError(f"Expected {expected_scenes} scenes; found {len(scenes)}.")
301    if len(by_key) != expected_scenes * 3:
302        raise DatasetError(f"Expected {expected_scenes * 3} cells; found {len(by_key)}.")
303
304    raw_cube = np.empty((len(scenes), 3, len(PAPER_METRIC_KEYS)), dtype=np.float64)
305    difficulties: list[str] = []
306    normalized_rows: list[dict[str, Any]] = []
307    for scene_index, scene in enumerate(scenes):
308        scene_difficulties = set()
309        for phase_index, phase in enumerate(PHASES):
310            if (scene, phase) not in by_key:
311                raise DatasetError(f"Missing scene-phase cell: {scene}/{phase}")
312            row = by_key[(scene, phase)]
313            difficulty = str(row.get("difficulty") or "").lower()
314            if difficulty not in DIFFICULTIES:
315                raise DatasetError(f"Invalid difficulty for {scene}/{phase}: {difficulty!r}")
316            scene_difficulties.add(difficulty)
317            for metric_index, metric in enumerate(PAPER_METRIC_KEYS):
318                value = row.get(f"metric:{metric}")
319                if not isinstance(value, (int, float)) or not np.isfinite(value):
320                    raise DatasetError(f"Missing/non-finite {metric} for {scene}/{phase}.")
321                raw_cube[scene_index, phase_index, metric_index] = float(value)
322            normalized_rows.append(row)
323        if len(scene_difficulties) != 1:
324            raise DatasetError(f"Difficulty changes across phases for scene {scene}.")
325        difficulties.append(scene_difficulties.pop())
326
327    oriented = raw_cube.copy()
328    for index, metric in enumerate(PAPER_METRIC_KEYS):
329        if METRIC_DIRECTIONS[metric] == "lower":
330            oriented[:, :, index] *= -1.0
331    flat = oriented.reshape(-1, len(PAPER_METRIC_KEYS))
332    std = np.std(flat, axis=0, ddof=0)
333    if np.any(std <= 0) or not np.isfinite(std).all():
334        raise DatasetError("Paper analysis requires six non-constant finite metrics.")
335    standardized = ((flat - np.mean(flat, axis=0)) / std).reshape(oriented.shape)
336
337    overall = _repeated_phase_test(standardized)
338    transitions: list[dict[str, Any]] = []
339    for label, first, second in (
340        ("clutter_to_interaction", 0, 1),
341        ("interaction_to_clean", 1, 2),
342        ("clutter_to_clean", 0, 2),
343    ):
344        transitions.append(
345            {
346                "transition": label,
347                **_hotelling_transition(standardized[:, first, :], standardized[:, second, :]),
348            }
349        )
350    adjusted = _holm_adjust([item["p_value_raw"] for item in transitions])
351    for item, value in zip(transitions, adjusted, strict=True):
352        item["p_value_holm"] = value
353
354    per_tier: dict[str, Any] = {}
355    difficulty_array = np.asarray(difficulties)
356    for tier in DIFFICULTIES:
357        subset = standardized[difficulty_array == tier]
358        per_tier[tier] = {"n_scenes": int(len(subset)), **_repeated_phase_test(subset)}
359
360    phase_means = {
361        phase: {
362            metric: float(np.mean(raw_cube[:, phase_index, metric_index]))
363            for metric_index, metric in enumerate(PAPER_METRIC_KEYS)
364        }
365        for phase_index, phase in enumerate(PHASES)
366    }
367    overall_means = {
368        metric: float(np.mean(raw_cube[:, :, metric_index]))
369        for metric_index, metric in enumerate(PAPER_METRIC_KEYS)
370    }
371
372    bounds_path = Path(jedi_bounds_path) if jedi_bounds_path else None
373    bounds, bounds_sha = _load_jedi_bounds(bounds_path)
374    if bounds is None:
375        jedi: dict[str, Any] = {"status": "bounds_missing"}
376    else:
377        jedi = {
378            "status": "computed",
379            "bounds_version": bounds["version"],
380            "bounds_sha256": bounds_sha,
381            "overall": _jedi_score(overall_means, bounds),
382            "per_phase": {
383                phase: _jedi_score(values, bounds) for phase, values in phase_means.items()
384            },
385        }
386
387    analysis = {
388        "schema_version": "rpx-d1f-paper-analysis-v1",
389        "model_name": next(iter(model_names)),
390        "metrics": list(PAPER_METRIC_KEYS),
391        "metric_directions": METRIC_DIRECTIONS,
392        "n_scenes": len(scenes),
393        "n_cells": len(by_key),
394        "calibration": D1_CALIBRATION.to_dict(),
395        "overall_phase_manova": overall,
396        "transitions": transitions,
397        "per_tier": per_tier,
398        "phase_by_difficulty": _interaction_test(standardized, difficulties),
399        "metric_means": {"overall": overall_means, "per_phase": phase_means},
400        "jedi": jedi,
401        "jedi_status": jedi["status"],
402        "provenance": dict(provenance or {}),
403    }
404    return analysis, normalized_rows
405
406
407def write_depth_analysis(
408    analysis: Mapping[str, Any],
409    cells: Sequence[Mapping[str, Any]],
410    output_dir: str | Path,
411) -> dict[str, Path]:
412    out = Path(output_dir)
413    out.mkdir(parents=True, exist_ok=True)
414    combined = out / "combined_cells.parquet"
415    write_cells(cells, combined, format="parquet")
416    json_path = out / "paper_analysis.json"
417    json_path.write_text(json.dumps(analysis, indent=2, sort_keys=True) + "\n")
418
419    table_path = out / "paper_table.csv"
420    flat: dict[str, Any] = {
421        "model_name": analysis["model_name"],
422        "phi": analysis["overall_phase_manova"]["phi_wilks"],
423        "wilks_lambda": analysis["overall_phase_manova"]["wilks_lambda"],
424        "rao_f": analysis["overall_phase_manova"]["rao_f"],
425        "p_value": analysis["overall_phase_manova"]["p_value"],
426        "partial_eta_squared": analysis["overall_phase_manova"]["partial_eta_squared"],
427        "phi_pillai": analysis["overall_phase_manova"]["phi_pillai"],
428        "wilks_pillai_gap": analysis["overall_phase_manova"]["pillai_minus_wilks_phi"],
429        "jedi_status": analysis["jedi"]["status"],
430    }
431    if analysis["jedi"]["status"] == "computed":
432        flat["jedi"] = analysis["jedi"]["overall"]["score"]
433    for phase, values in analysis["metric_means"]["per_phase"].items():
434        for metric, value in values.items():
435            flat[f"{metric}_{phase}"] = value
436    for item in analysis["transitions"]:
437        flat[f"phi_{item['transition']}"] = item["phi"]
438        flat[f"hotelling_t2_{item['transition']}"] = item["hotelling_t_squared"]
439        flat[f"p_raw_{item['transition']}"] = item["p_value_raw"]
440        flat[f"p_holm_{item['transition']}"] = item["p_value_holm"]
441    for tier, values in analysis["per_tier"].items():
442        for key in ("n_scenes", "wilks_lambda", "phi_wilks", "p_value", "partial_eta_squared"):
443            flat[f"{key}_{tier}"] = values[key]
444    for key in ("wilks_lambda", "rao_f", "p_value", "partial_eta_squared"):
445        flat[f"phase_by_difficulty_{key}"] = analysis["phase_by_difficulty"][key]
446    with table_path.open("w", newline="") as handle:
447        writer = csv.DictWriter(handle, fieldnames=list(flat))
448        writer.writeheader()
449        writer.writerow(flat)
450
451    markdown_path = out / "paper_analysis.md"
452    overall = analysis["overall_phase_manova"]
453    lines = [
454        "# RPX D1-F Paper Analysis",
455        "",
456        f"- Model: `{analysis['model_name']}`",
457        f"- Complete cells: {analysis['n_cells']} ({analysis['n_scenes']} scenes x 3 phases)",
458        f"- Wilks Lambda: {overall['wilks_lambda']:.6g}",
459        f"- Phi: {overall['phi_wilks']:.6g}",
460        f"- Rao F({overall['df1']:.0f}, {overall['df2']:.3f}): "
461        f"{overall['rao_f']:.6g}; p={overall['p_value']:.6g}",
462        f"- Partial eta squared: {overall['partial_eta_squared']:.6g}",
463        f"- Pillai Phi: {overall['phi_pillai']:.6g}; gap={overall['pillai_minus_wilks_phi']:.6g}",
464        f"- JEDI: `{analysis['jedi']['status']}`",
465        "",
466        "## Phase transitions",
467        "",
468        "| Transition | Hotelling T2 | Wilks Phi | Raw p | Holm p |",
469        "|---|---:|---:|---:|---:|",
470    ]
471    for item in analysis["transitions"]:
472        lines.append(
473            f"| {item['transition']} | {item['hotelling_t_squared']:.6g} | "
474            f"{item['phi']:.6g} | {item['p_value_raw']:.6g} | "
475            f"{item['p_value_holm']:.6g} |"
476        )
477    lines.extend(
478        [
479            "",
480            "## Difficulty tiers",
481            "",
482            "| Tier | Scenes | Wilks Lambda | Phi | p | Partial eta squared |",
483            "|---|---:|---:|---:|---:|---:|",
484        ]
485    )
486    for tier in DIFFICULTIES:
487        values = analysis["per_tier"][tier]
488        lines.append(
489            f"| {tier} | {values['n_scenes']} | {values['wilks_lambda']:.6g} | "
490            f"{values['phi_wilks']:.6g} | {values['p_value']:.6g} | "
491            f"{values['partial_eta_squared']:.6g} |"
492        )
493    interaction = analysis["phase_by_difficulty"]
494    lines.extend(
495        [
496            "",
497            "## Phase by difficulty",
498            "",
499            f"Wilks Lambda={interaction['wilks_lambda']:.6g}; "
500            f"Rao F({interaction['df1']:.0f}, {interaction['df2']:.3f})="
501            f"{interaction['rao_f']:.6g}; p={interaction['p_value']:.6g}; "
502            f"partial eta squared={interaction['partial_eta_squared']:.6g}.",
503            "",
504            "## Phase means",
505            "",
506            "| Phase | " + " | ".join(PAPER_METRIC_KEYS) + " |",
507            "|---|" + "---:|" * len(PAPER_METRIC_KEYS),
508        ]
509    )
510    for phase in PHASES:
511        values = analysis["metric_means"]["per_phase"][phase]
512        lines.append(
513            "| "
514            + phase
515            + " | "
516            + " | ".join(f"{values[key]:.6g}" for key in PAPER_METRIC_KEYS)
517            + " |"
518        )
519    markdown_path.write_text("\n".join(lines) + "\n")
520    return {
521        "combined_cells": combined,
522        "analysis_json": json_path,
523        "analysis_markdown": markdown_path,
524        "paper_table": table_path,
525    }
PHASES = ('clutter', 'interaction', 'clean')
DIFFICULTIES = ('easy', 'medium', 'hard')
METRIC_DIRECTIONS = {'absrel': 'lower', 'rmse': 'lower', 'silog': 'lower', 'delta1': 'higher', 'irmse': 'lower', 'fscore_5cm': 'higher'}
def analyze_depth_cells( paths: Sequence[str | pathlib.Path], *, jedi_bounds_path: str | pathlib.Path | None = None, expected_scenes: int = 100, provenance: Optional[Mapping[str, Any]] = None) -> tuple[dict[str, typing.Any], list[dict[str, typing.Any]]]:
265def analyze_depth_cells(
266    paths: Sequence[str | Path],
267    *,
268    jedi_bounds_path: str | Path | None = None,
269    expected_scenes: int = 100,
270    provenance: Mapping[str, Any] | None = None,
271) -> tuple[dict[str, Any], list[dict[str, Any]]]:
272    """Validate, merge and analyze three RPX D1-F split cell logs."""
273    rows = merge_cells(Path(path) for path in paths)
274    rows = [
275        row
276        for row in rows
277        if row.get("task") == "monocular_depth" and int(row.get("frame_budget") or 0) == 0
278    ]
279    if not rows:
280        raise DatasetError("No monocular_depth cells found in the supplied logs.")
281    model_names = {str(row.get("model_name")) for row in rows}
282    if len(model_names) != 1 or model_names == {"None"} or model_names == {""}:
283        raise DatasetError(f"Paper analysis requires exactly one model; got {sorted(model_names)}")
284
285    by_key: dict[tuple[str, str], dict[str, Any]] = {}
286    for row in rows:
287        scene = str(row.get("scene_id") or "")
288        phase_raw = str(row.get("phase") or "").lower()
289        phase = _PHASE_ALIASES.get(phase_raw)
290        if not scene or phase is None:
291            raise DatasetError(f"Invalid scene/phase cell: {scene!r}/{phase_raw!r}")
292        key = (scene, phase)
293        if key in by_key:
294            raise DatasetError(f"Duplicate scene-phase cell: {key}")
295        normalized = dict(row)
296        normalized["phase"] = phase
297        by_key[key] = normalized
298
299    scenes = sorted({scene for scene, _phase in by_key})
300    if len(scenes) != expected_scenes:
301        raise DatasetError(f"Expected {expected_scenes} scenes; found {len(scenes)}.")
302    if len(by_key) != expected_scenes * 3:
303        raise DatasetError(f"Expected {expected_scenes * 3} cells; found {len(by_key)}.")
304
305    raw_cube = np.empty((len(scenes), 3, len(PAPER_METRIC_KEYS)), dtype=np.float64)
306    difficulties: list[str] = []
307    normalized_rows: list[dict[str, Any]] = []
308    for scene_index, scene in enumerate(scenes):
309        scene_difficulties = set()
310        for phase_index, phase in enumerate(PHASES):
311            if (scene, phase) not in by_key:
312                raise DatasetError(f"Missing scene-phase cell: {scene}/{phase}")
313            row = by_key[(scene, phase)]
314            difficulty = str(row.get("difficulty") or "").lower()
315            if difficulty not in DIFFICULTIES:
316                raise DatasetError(f"Invalid difficulty for {scene}/{phase}: {difficulty!r}")
317            scene_difficulties.add(difficulty)
318            for metric_index, metric in enumerate(PAPER_METRIC_KEYS):
319                value = row.get(f"metric:{metric}")
320                if not isinstance(value, (int, float)) or not np.isfinite(value):
321                    raise DatasetError(f"Missing/non-finite {metric} for {scene}/{phase}.")
322                raw_cube[scene_index, phase_index, metric_index] = float(value)
323            normalized_rows.append(row)
324        if len(scene_difficulties) != 1:
325            raise DatasetError(f"Difficulty changes across phases for scene {scene}.")
326        difficulties.append(scene_difficulties.pop())
327
328    oriented = raw_cube.copy()
329    for index, metric in enumerate(PAPER_METRIC_KEYS):
330        if METRIC_DIRECTIONS[metric] == "lower":
331            oriented[:, :, index] *= -1.0
332    flat = oriented.reshape(-1, len(PAPER_METRIC_KEYS))
333    std = np.std(flat, axis=0, ddof=0)
334    if np.any(std <= 0) or not np.isfinite(std).all():
335        raise DatasetError("Paper analysis requires six non-constant finite metrics.")
336    standardized = ((flat - np.mean(flat, axis=0)) / std).reshape(oriented.shape)
337
338    overall = _repeated_phase_test(standardized)
339    transitions: list[dict[str, Any]] = []
340    for label, first, second in (
341        ("clutter_to_interaction", 0, 1),
342        ("interaction_to_clean", 1, 2),
343        ("clutter_to_clean", 0, 2),
344    ):
345        transitions.append(
346            {
347                "transition": label,
348                **_hotelling_transition(standardized[:, first, :], standardized[:, second, :]),
349            }
350        )
351    adjusted = _holm_adjust([item["p_value_raw"] for item in transitions])
352    for item, value in zip(transitions, adjusted, strict=True):
353        item["p_value_holm"] = value
354
355    per_tier: dict[str, Any] = {}
356    difficulty_array = np.asarray(difficulties)
357    for tier in DIFFICULTIES:
358        subset = standardized[difficulty_array == tier]
359        per_tier[tier] = {"n_scenes": int(len(subset)), **_repeated_phase_test(subset)}
360
361    phase_means = {
362        phase: {
363            metric: float(np.mean(raw_cube[:, phase_index, metric_index]))
364            for metric_index, metric in enumerate(PAPER_METRIC_KEYS)
365        }
366        for phase_index, phase in enumerate(PHASES)
367    }
368    overall_means = {
369        metric: float(np.mean(raw_cube[:, :, metric_index]))
370        for metric_index, metric in enumerate(PAPER_METRIC_KEYS)
371    }
372
373    bounds_path = Path(jedi_bounds_path) if jedi_bounds_path else None
374    bounds, bounds_sha = _load_jedi_bounds(bounds_path)
375    if bounds is None:
376        jedi: dict[str, Any] = {"status": "bounds_missing"}
377    else:
378        jedi = {
379            "status": "computed",
380            "bounds_version": bounds["version"],
381            "bounds_sha256": bounds_sha,
382            "overall": _jedi_score(overall_means, bounds),
383            "per_phase": {
384                phase: _jedi_score(values, bounds) for phase, values in phase_means.items()
385            },
386        }
387
388    analysis = {
389        "schema_version": "rpx-d1f-paper-analysis-v1",
390        "model_name": next(iter(model_names)),
391        "metrics": list(PAPER_METRIC_KEYS),
392        "metric_directions": METRIC_DIRECTIONS,
393        "n_scenes": len(scenes),
394        "n_cells": len(by_key),
395        "calibration": D1_CALIBRATION.to_dict(),
396        "overall_phase_manova": overall,
397        "transitions": transitions,
398        "per_tier": per_tier,
399        "phase_by_difficulty": _interaction_test(standardized, difficulties),
400        "metric_means": {"overall": overall_means, "per_phase": phase_means},
401        "jedi": jedi,
402        "jedi_status": jedi["status"],
403        "provenance": dict(provenance or {}),
404    }
405    return analysis, normalized_rows

Validate, merge and analyze three RPX D1-F split cell logs.

def write_depth_analysis( analysis: Mapping[str, Any], cells: Sequence[Mapping[str, Any]], output_dir: str | pathlib.Path) -> dict[str, pathlib.Path]:
408def write_depth_analysis(
409    analysis: Mapping[str, Any],
410    cells: Sequence[Mapping[str, Any]],
411    output_dir: str | Path,
412) -> dict[str, Path]:
413    out = Path(output_dir)
414    out.mkdir(parents=True, exist_ok=True)
415    combined = out / "combined_cells.parquet"
416    write_cells(cells, combined, format="parquet")
417    json_path = out / "paper_analysis.json"
418    json_path.write_text(json.dumps(analysis, indent=2, sort_keys=True) + "\n")
419
420    table_path = out / "paper_table.csv"
421    flat: dict[str, Any] = {
422        "model_name": analysis["model_name"],
423        "phi": analysis["overall_phase_manova"]["phi_wilks"],
424        "wilks_lambda": analysis["overall_phase_manova"]["wilks_lambda"],
425        "rao_f": analysis["overall_phase_manova"]["rao_f"],
426        "p_value": analysis["overall_phase_manova"]["p_value"],
427        "partial_eta_squared": analysis["overall_phase_manova"]["partial_eta_squared"],
428        "phi_pillai": analysis["overall_phase_manova"]["phi_pillai"],
429        "wilks_pillai_gap": analysis["overall_phase_manova"]["pillai_minus_wilks_phi"],
430        "jedi_status": analysis["jedi"]["status"],
431    }
432    if analysis["jedi"]["status"] == "computed":
433        flat["jedi"] = analysis["jedi"]["overall"]["score"]
434    for phase, values in analysis["metric_means"]["per_phase"].items():
435        for metric, value in values.items():
436            flat[f"{metric}_{phase}"] = value
437    for item in analysis["transitions"]:
438        flat[f"phi_{item['transition']}"] = item["phi"]
439        flat[f"hotelling_t2_{item['transition']}"] = item["hotelling_t_squared"]
440        flat[f"p_raw_{item['transition']}"] = item["p_value_raw"]
441        flat[f"p_holm_{item['transition']}"] = item["p_value_holm"]
442    for tier, values in analysis["per_tier"].items():
443        for key in ("n_scenes", "wilks_lambda", "phi_wilks", "p_value", "partial_eta_squared"):
444            flat[f"{key}_{tier}"] = values[key]
445    for key in ("wilks_lambda", "rao_f", "p_value", "partial_eta_squared"):
446        flat[f"phase_by_difficulty_{key}"] = analysis["phase_by_difficulty"][key]
447    with table_path.open("w", newline="") as handle:
448        writer = csv.DictWriter(handle, fieldnames=list(flat))
449        writer.writeheader()
450        writer.writerow(flat)
451
452    markdown_path = out / "paper_analysis.md"
453    overall = analysis["overall_phase_manova"]
454    lines = [
455        "# RPX D1-F Paper Analysis",
456        "",
457        f"- Model: `{analysis['model_name']}`",
458        f"- Complete cells: {analysis['n_cells']} ({analysis['n_scenes']} scenes x 3 phases)",
459        f"- Wilks Lambda: {overall['wilks_lambda']:.6g}",
460        f"- Phi: {overall['phi_wilks']:.6g}",
461        f"- Rao F({overall['df1']:.0f}, {overall['df2']:.3f}): "
462        f"{overall['rao_f']:.6g}; p={overall['p_value']:.6g}",
463        f"- Partial eta squared: {overall['partial_eta_squared']:.6g}",
464        f"- Pillai Phi: {overall['phi_pillai']:.6g}; gap={overall['pillai_minus_wilks_phi']:.6g}",
465        f"- JEDI: `{analysis['jedi']['status']}`",
466        "",
467        "## Phase transitions",
468        "",
469        "| Transition | Hotelling T2 | Wilks Phi | Raw p | Holm p |",
470        "|---|---:|---:|---:|---:|",
471    ]
472    for item in analysis["transitions"]:
473        lines.append(
474            f"| {item['transition']} | {item['hotelling_t_squared']:.6g} | "
475            f"{item['phi']:.6g} | {item['p_value_raw']:.6g} | "
476            f"{item['p_value_holm']:.6g} |"
477        )
478    lines.extend(
479        [
480            "",
481            "## Difficulty tiers",
482            "",
483            "| Tier | Scenes | Wilks Lambda | Phi | p | Partial eta squared |",
484            "|---|---:|---:|---:|---:|---:|",
485        ]
486    )
487    for tier in DIFFICULTIES:
488        values = analysis["per_tier"][tier]
489        lines.append(
490            f"| {tier} | {values['n_scenes']} | {values['wilks_lambda']:.6g} | "
491            f"{values['phi_wilks']:.6g} | {values['p_value']:.6g} | "
492            f"{values['partial_eta_squared']:.6g} |"
493        )
494    interaction = analysis["phase_by_difficulty"]
495    lines.extend(
496        [
497            "",
498            "## Phase by difficulty",
499            "",
500            f"Wilks Lambda={interaction['wilks_lambda']:.6g}; "
501            f"Rao F({interaction['df1']:.0f}, {interaction['df2']:.3f})="
502            f"{interaction['rao_f']:.6g}; p={interaction['p_value']:.6g}; "
503            f"partial eta squared={interaction['partial_eta_squared']:.6g}.",
504            "",
505            "## Phase means",
506            "",
507            "| Phase | " + " | ".join(PAPER_METRIC_KEYS) + " |",
508            "|---|" + "---:|" * len(PAPER_METRIC_KEYS),
509        ]
510    )
511    for phase in PHASES:
512        values = analysis["metric_means"]["per_phase"][phase]
513        lines.append(
514            "| "
515            + phase
516            + " | "
517            + " | ".join(f"{values[key]:.6g}" for key in PAPER_METRIC_KEYS)
518            + " |"
519        )
520    markdown_path.write_text("\n".join(lines) + "\n")
521    return {
522        "combined_cells": combined,
523        "analysis_json": json_path,
524        "analysis_markdown": markdown_path,
525        "paper_table": table_path,
526    }