rpx_benchmark.temporal_budget_sweep

Frame-budget sweep runner and degradation analysis for video tasks.

Runs a video-task pipeline at multiple frame budgets (e.g. {50, 100, 150, 250}) on the same clips and produces a degradation analysis that answers:

  • Temporal Cue Value (TCV): per model × metric, how much does the full clip improve over the smallest budget?
  • Area Under Degradation Curve (AUDC): integral of metric vs. budget, normalized — models with flat curves are budget-robust.
  • Critical Budget: smallest budget where metric is within tol of full-budget performance.
  • Paired test: Wilcoxon signed-rank across scenes at min vs. max budget.

Design note

The frame-budget ablation is a first-class diagnostic unique to RPX (no other benchmark offers it). It tests whether temporal cues actually help each model and at what granularity. The sweep is task-agnostic: any task that uses ~rpx_benchmark.video_loader.VideoDepthDataset with frame_budget + sampling benefits. Object tracking will plug in through the same harness.

The Φ MANOVA is computed only on the full-budget (canonical) run. Budget ablations are reported as a separate degradation analysis.

  1"""Frame-budget sweep runner and degradation analysis for video tasks.
  2
  3Runs a video-task pipeline at multiple frame budgets (e.g. {50, 100, 150,
  4250}) on the same clips and produces a degradation analysis that answers:
  5
  6* **Temporal Cue Value (TCV):** per model × metric, how much does the full
  7  clip improve over the smallest budget?
  8* **Area Under Degradation Curve (AUDC):** integral of metric vs. budget,
  9  normalized — models with flat curves are budget-robust.
 10* **Critical Budget:** smallest budget where metric is within ``tol`` of
 11  full-budget performance.
 12* **Paired test:** Wilcoxon signed-rank across scenes at min vs. max budget.
 13
 14Design note
 15-----------
 16The frame-budget ablation is a **first-class diagnostic** unique to RPX
 17(no other benchmark offers it). It tests whether temporal cues actually
 18help each model and at what granularity. The sweep is task-agnostic:
 19any task that uses :class:`~rpx_benchmark.video_loader.VideoDepthDataset`
 20with ``frame_budget`` + ``sampling`` benefits.  Object tracking will
 21plug in through the same harness.
 22
 23The Φ MANOVA is computed **only** on the full-budget (canonical) run.
 24Budget ablations are reported as a separate degradation analysis.
 25"""
 26
 27from __future__ import annotations
 28
 29from dataclasses import dataclass, field
 30from pathlib import Path
 31from typing import Dict, List, Optional, Sequence, Tuple
 32
 33import numpy as np
 34
 35from .logging_utils import get_logger
 36
 37log = get_logger(__name__)
 38
 39__all__ = [
 40    "BudgetSweepConfig",
 41    "DegradationResult",
 42    "run_budget_sweep",
 43    "degradation_analysis",
 44]
 45
 46#: Default frame budgets for the temporal resolution ablation.
 47DEFAULT_BUDGETS: Tuple[int, ...] = (50, 100, 150, 250)
 48
 49
 50# --------------------------------------------------------------------------- #
 51# Config
 52# --------------------------------------------------------------------------- #
 53
 54
 55@dataclass
 56class BudgetSweepConfig:
 57    """Configuration for a frame-budget sweep.
 58
 59    Parameters
 60    ----------
 61    budgets : sequence of int
 62        Frame budgets to evaluate. The largest budget is treated as
 63        the canonical "full" run for degradation analysis. Example:
 64        ``(50, 100, 150, 250)``.
 65    sampling : str
 66        Sampling mode for frame selection. ``"stride"`` (default) gives
 67        uniform-with-endpoint-anchored selection — equivalent to
 68        "what if your camera ran at a lower FPS". ``"fps_se3"``
 69        selects frames that maximise spatial coverage of the camera path.
 70    metric_keys : list of str or None
 71        If provided, restrict the degradation analysis to these metric
 72        keys. If ``None``, analyze all numeric columns in the cell logs.
 73    tol : float
 74        Tolerance for "critical budget" — the smallest budget where
 75        metric is within ``tol`` (relative) of full-budget performance.
 76        Default: 0.05 (5%).
 77    """
 78
 79    budgets: Sequence[int] = DEFAULT_BUDGETS
 80    sampling: str = "stride"
 81    metric_keys: Optional[List[str]] = None
 82    tol: float = 0.05
 83
 84
 85# --------------------------------------------------------------------------- #
 86# Degradation result
 87# --------------------------------------------------------------------------- #
 88
 89
 90@dataclass
 91class DegradationResult:
 92    """Per-model × per-metric degradation analysis across frame budgets.
 93
 94    Attributes
 95    ----------
 96    model : str
 97    metric : str
 98    direction : str
 99        ``"lower"`` or ``"higher"`` — whether lower/higher is better.
100    budgets : list[int]
101        Frame budgets (ascending).
102    values : list[float]
103        Mean metric value at each budget (over scenes/phases).
104    per_scene_values : dict[int, list[float]]
105        Per-scene metric values at each budget (for paired tests).
106    tcv : float
107        Temporal Cue Value = metric@max_budget - metric@min_budget
108        (sign-adjusted so positive = temporal cues help).
109    audc : float
110        Area Under Degradation Curve, normalized by metric@max_budget.
111    critical_budget : int or None
112        Smallest budget where metric is within ``tol`` of full-budget.
113        ``None`` if no budget meets the tolerance.
114    wilcoxon_p : float or None
115        p-value of Wilcoxon signed-rank test (min vs max budget).
116        ``None`` if insufficient data.
117    """
118
119    model: str = ""
120    metric: str = ""
121    direction: str = "lower"
122    budgets: List[int] = field(default_factory=list)
123    values: List[float] = field(default_factory=list)
124    per_scene_values: Dict[int, List[float]] = field(default_factory=dict)
125    tcv: float = float("nan")
126    audc: float = float("nan")
127    critical_budget: Optional[int] = None
128    wilcoxon_p: Optional[float] = None
129
130
131# --------------------------------------------------------------------------- #
132# Degradation analysis
133# --------------------------------------------------------------------------- #
134
135
136def degradation_analysis(
137    cells_by_budget: Dict[int, List[dict]],
138    metric_keys: Optional[List[str]] = None,
139    tol: float = 0.05,
140    model_name: str = "model",
141) -> List[DegradationResult]:
142    """Compute degradation statistics from cell-log dicts at each budget.
143
144    Parameters
145    ----------
146    cells_by_budget : dict[int, list[dict]]
147        Mapping from frame_budget → list of cell-log row dicts. Each
148        dict must contain the metric columns + ``scene`` (or
149        ``scene_id``) + ``phase`` for scene-level pairing.
150    metric_keys : list[str] or None
151        Metrics to analyze. ``None`` means auto-detect all float columns.
152    tol : float
153        Critical-budget tolerance (default 5%).
154    model_name : str
155        Model name for the result objects.
156
157    Returns
158    -------
159    list[DegradationResult]
160        One result per metric, sorted by metric name.
161    """
162    if not cells_by_budget:
163        return []
164
165    budgets_sorted = sorted(cells_by_budget.keys())
166    max_budget = budgets_sorted[-1]
167    min_budget = budgets_sorted[0]
168
169    # Auto-detect metric keys from the max-budget cells.
170    sample_row = cells_by_budget[max_budget][0] if cells_by_budget[max_budget] else {}
171    if metric_keys is None:
172        skip = {"frame_budget", "latency_ms", "scene", "scene_id", "phase",
173                "difficulty", "id", "model", "task"}
174        metric_keys = sorted(
175            k for k, v in sample_row.items()
176            if isinstance(v, (int, float)) and not isinstance(v, bool)
177            and k not in skip
178        )
179
180    # Determine metric direction from specs registry (best-effort).
181    from .metrics.specs import get_spec, has_spec
182
183    results = []
184    for mkey in metric_keys:
185        direction = "lower"
186        if has_spec(mkey):
187            direction = get_spec(mkey).direction
188
189        # Collect per-budget mean and per-scene values.
190        means = []
191        per_scene: Dict[int, List[float]] = {}
192        for b in budgets_sorted:
193            vals = [
194                row[mkey] for row in cells_by_budget[b]
195                if mkey in row and isinstance(row[mkey], (int, float))
196                and np.isfinite(row[mkey])
197            ]
198            means.append(float(np.mean(vals)) if vals else float("nan"))
199            per_scene[b] = vals
200
201        # TCV: sign-adjusted difference (positive = temporal cues help).
202        val_max = means[-1]  # metric @ max_budget
203        val_min = means[0]   # metric @ min_budget
204        if direction == "lower":
205            # Lower is better → if max_budget is lower, temporal cues help.
206            tcv = val_min - val_max  # positive when full clip is better
207        else:
208            # Higher is better → if max_budget is higher, temporal cues help.
209            tcv = val_max - val_min
210
211        # AUDC: trapezoidal integral, normalized by full-budget metric.
212        areas = []
213        for i in range(len(budgets_sorted) - 1):
214            db = budgets_sorted[i + 1] - budgets_sorted[i]
215            avg_val = (means[i] + means[i + 1]) / 2.0
216            areas.append(db * avg_val)
217        total_area = sum(areas)
218        budget_range = budgets_sorted[-1] - budgets_sorted[0]
219        if abs(val_max) > 1e-12 and budget_range > 0:
220            audc = total_area / (budget_range * abs(val_max))
221        else:
222            audc = float("nan")
223
224        # Critical budget: smallest where metric is within tol of full.
225        critical = None
226        for i, b in enumerate(budgets_sorted):
227            if abs(val_max) < 1e-12:
228                break
229            rel_diff = abs(means[i] - val_max) / abs(val_max)
230            if rel_diff <= tol:
231                critical = b
232                break
233
234        # Wilcoxon signed-rank test (min vs max budget, scene-paired).
235        wilcoxon_p = None
236        if len(per_scene[min_budget]) >= 5 and len(per_scene[max_budget]) >= 5:
237            # Pair by position (assumes same scene ordering).
238            n = min(len(per_scene[min_budget]), len(per_scene[max_budget]))
239            diffs = np.array(per_scene[max_budget][:n]) - np.array(per_scene[min_budget][:n])
240            diffs = diffs[np.isfinite(diffs)]
241            if len(diffs) >= 5 and np.any(diffs != 0):
242                try:
243                    from scipy.stats import wilcoxon  # type: ignore[import-untyped]
244
245                    _, wilcoxon_p = wilcoxon(diffs, alternative="two-sided")
246                    wilcoxon_p = float(wilcoxon_p)
247                except ImportError:
248                    log.debug("scipy not available — Wilcoxon test skipped")
249
250        results.append(DegradationResult(
251            model=model_name,
252            metric=mkey,
253            direction=direction,
254            budgets=budgets_sorted,
255            values=means,
256            per_scene_values=per_scene,
257            tcv=tcv,
258            audc=audc,
259            critical_budget=critical,
260            wilcoxon_p=wilcoxon_p,
261        ))
262
263    return sorted(results, key=lambda r: r.metric)
264
265
266# --------------------------------------------------------------------------- #
267# Sweep runner (orchestrates multiple pipeline calls)
268# --------------------------------------------------------------------------- #
269
270
271def run_budget_sweep(
272    *,
273    run_fn,
274    make_config_fn,
275    budgets: Sequence[int] = DEFAULT_BUDGETS,
276    sampling: str = "stride",
277    model_name: str = "model",
278    metric_keys: Optional[List[str]] = None,
279    tol: float = 0.05,
280) -> Tuple[Dict[int, List[dict]], List[DegradationResult]]:
281    """Run a video task at multiple frame budgets and analyze degradation.
282
283    Parameters
284    ----------
285    run_fn : callable
286        ``run_fn(config) -> (result, dr_report, paths)`` — the task
287        pipeline function (e.g. ``run_video_depth``).
288    make_config_fn : callable
289        ``make_config_fn(budget, sampling) -> config`` — builds a
290        run config with the given ``frame_budget`` and ``sampling``.
291    budgets, sampling, model_name, metric_keys, tol :
292        Forwarded to :func:`degradation_analysis`.
293
294    Returns
295    -------
296    cells_by_budget : dict[int, list[dict]]
297        Raw cell-log dicts per budget.
298    degradation : list[DegradationResult]
299        One per metric.
300    """
301    cells_by_budget: Dict[int, List[dict]] = {}
302
303    for budget in sorted(budgets):
304        log.info("budget sweep: running at frame_budget=%d, sampling=%s", budget, sampling)
305        cfg = make_config_fn(budget, sampling)
306        result, _, paths = run_fn(cfg)
307
308        # Read back the cell-log from disk.
309        cells_path = paths.get("cells")
310        if cells_path and Path(cells_path).exists():
311            try:
312                import pyarrow.parquet as pq  # type: ignore[import-untyped]
313
314                table = pq.read_table(str(cells_path))
315                cells = table.to_pylist()
316            except ImportError:
317                log.warning("pyarrow not available — reading cells from result.per_sample")
318                cells = list(getattr(result, "per_sample", []))
319        else:
320            cells = list(getattr(result, "per_sample", []))
321
322        cells_by_budget[budget] = cells
323        log.info("budget sweep: budget=%d → %d cells collected", budget, len(cells))
324
325    analysis = degradation_analysis(
326        cells_by_budget,
327        metric_keys=metric_keys,
328        tol=tol,
329        model_name=model_name,
330    )
331
332    return cells_by_budget, analysis
333
334
335# --------------------------------------------------------------------------- #
336# Formatting helpers
337# --------------------------------------------------------------------------- #
338
339
340def format_degradation_markdown(results: List[DegradationResult]) -> str:
341    """Format degradation analysis as a Markdown summary table."""
342    if not results:
343        return "_No degradation results._\n"
344
345    lines = [
346        "## Frame-Budget Degradation Analysis\n",
347        "| Metric | Direction | Budgets | Values | TCV | AUDC | Critical Budget | Wilcoxon p |",
348        "|--------|-----------|---------|--------|-----|------|-----------------|------------|",
349    ]
350    for r in results:
351        vals_str = " → ".join(f"{v:.4f}" for v in r.values)
352        budgets_str = ", ".join(str(b) for b in r.budgets)
353        tcv_str = f"{r.tcv:+.4f}" if np.isfinite(r.tcv) else "—"
354        audc_str = f"{r.audc:.3f}" if np.isfinite(r.audc) else "—"
355        crit_str = str(r.critical_budget) if r.critical_budget is not None else "—"
356        p_str = f"{r.wilcoxon_p:.4f}" if r.wilcoxon_p is not None else "—"
357        lines.append(
358            f"| `{r.metric}` | {r.direction} | {budgets_str} "
359            f"| {vals_str} | {tcv_str} | {audc_str} | {crit_str} | {p_str} |"
360        )
361
362    lines.append("")
363    lines.append("**TCV** = Temporal Cue Value (positive = temporal cues help). "
364                 "**AUDC** = Area Under Degradation Curve (normalized). "
365                 "**Critical Budget** = smallest budget within 5% of full-budget.")
366    lines.append("")
367    return "\n".join(lines)
@dataclass
class BudgetSweepConfig:
56@dataclass
57class BudgetSweepConfig:
58    """Configuration for a frame-budget sweep.
59
60    Parameters
61    ----------
62    budgets : sequence of int
63        Frame budgets to evaluate. The largest budget is treated as
64        the canonical "full" run for degradation analysis. Example:
65        ``(50, 100, 150, 250)``.
66    sampling : str
67        Sampling mode for frame selection. ``"stride"`` (default) gives
68        uniform-with-endpoint-anchored selection — equivalent to
69        "what if your camera ran at a lower FPS". ``"fps_se3"``
70        selects frames that maximise spatial coverage of the camera path.
71    metric_keys : list of str or None
72        If provided, restrict the degradation analysis to these metric
73        keys. If ``None``, analyze all numeric columns in the cell logs.
74    tol : float
75        Tolerance for "critical budget" — the smallest budget where
76        metric is within ``tol`` (relative) of full-budget performance.
77        Default: 0.05 (5%).
78    """
79
80    budgets: Sequence[int] = DEFAULT_BUDGETS
81    sampling: str = "stride"
82    metric_keys: Optional[List[str]] = None
83    tol: float = 0.05

Configuration for a frame-budget sweep.

Parameters

budgets : sequence of int Frame budgets to evaluate. The largest budget is treated as the canonical "full" run for degradation analysis. Example: (50, 100, 150, 250). sampling : str Sampling mode for frame selection. "stride" (default) gives uniform-with-endpoint-anchored selection — equivalent to "what if your camera ran at a lower FPS". "fps_se3" selects frames that maximise spatial coverage of the camera path. metric_keys : list of str or None If provided, restrict the degradation analysis to these metric keys. If None, analyze all numeric columns in the cell logs. tol : float Tolerance for "critical budget" — the smallest budget where metric is within tol (relative) of full-budget performance. Default: 0.05 (5%).

BudgetSweepConfig( budgets: Sequence[int] = (50, 100, 150, 250), sampling: str = 'stride', metric_keys: Optional[List[str]] = None, tol: float = 0.05)
budgets: Sequence[int] = (50, 100, 150, 250)
sampling: str = 'stride'
metric_keys: Optional[List[str]] = None
tol: float = 0.05
@dataclass
class DegradationResult:
 91@dataclass
 92class DegradationResult:
 93    """Per-model × per-metric degradation analysis across frame budgets.
 94
 95    Attributes
 96    ----------
 97    model : str
 98    metric : str
 99    direction : str
100        ``"lower"`` or ``"higher"`` — whether lower/higher is better.
101    budgets : list[int]
102        Frame budgets (ascending).
103    values : list[float]
104        Mean metric value at each budget (over scenes/phases).
105    per_scene_values : dict[int, list[float]]
106        Per-scene metric values at each budget (for paired tests).
107    tcv : float
108        Temporal Cue Value = metric@max_budget - metric@min_budget
109        (sign-adjusted so positive = temporal cues help).
110    audc : float
111        Area Under Degradation Curve, normalized by metric@max_budget.
112    critical_budget : int or None
113        Smallest budget where metric is within ``tol`` of full-budget.
114        ``None`` if no budget meets the tolerance.
115    wilcoxon_p : float or None
116        p-value of Wilcoxon signed-rank test (min vs max budget).
117        ``None`` if insufficient data.
118    """
119
120    model: str = ""
121    metric: str = ""
122    direction: str = "lower"
123    budgets: List[int] = field(default_factory=list)
124    values: List[float] = field(default_factory=list)
125    per_scene_values: Dict[int, List[float]] = field(default_factory=dict)
126    tcv: float = float("nan")
127    audc: float = float("nan")
128    critical_budget: Optional[int] = None
129    wilcoxon_p: Optional[float] = None

Per-model × per-metric degradation analysis across frame budgets.

Attributes

model : str metric : str direction : str "lower" or "higher" — whether lower/higher is better. budgets : list[int] Frame budgets (ascending). values : list[float] Mean metric value at each budget (over scenes/phases). per_scene_values : dict[int, list[float]] Per-scene metric values at each budget (for paired tests). tcv : float Temporal Cue Value = metric@max_budget - metric@min_budget (sign-adjusted so positive = temporal cues help). audc : float Area Under Degradation Curve, normalized by metric@max_budget. critical_budget : int or None Smallest budget where metric is within tol of full-budget. None if no budget meets the tolerance. wilcoxon_p : float or None p-value of Wilcoxon signed-rank test (min vs max budget). None if insufficient data.

DegradationResult( model: str = '', metric: str = '', direction: str = 'lower', budgets: List[int] = <factory>, values: List[float] = <factory>, per_scene_values: Dict[int, List[float]] = <factory>, tcv: float = nan, audc: float = nan, critical_budget: Optional[int] = None, wilcoxon_p: Optional[float] = None)
model: str = ''
metric: str = ''
direction: str = 'lower'
budgets: List[int]
values: List[float]
per_scene_values: Dict[int, List[float]]
tcv: float = nan
audc: float = nan
critical_budget: Optional[int] = None
wilcoxon_p: Optional[float] = None
def run_budget_sweep( *, run_fn, make_config_fn, budgets: Sequence[int] = (50, 100, 150, 250), sampling: str = 'stride', model_name: str = 'model', metric_keys: Optional[List[str]] = None, tol: float = 0.05) -> Tuple[Dict[int, List[dict]], List[DegradationResult]]:
272def run_budget_sweep(
273    *,
274    run_fn,
275    make_config_fn,
276    budgets: Sequence[int] = DEFAULT_BUDGETS,
277    sampling: str = "stride",
278    model_name: str = "model",
279    metric_keys: Optional[List[str]] = None,
280    tol: float = 0.05,
281) -> Tuple[Dict[int, List[dict]], List[DegradationResult]]:
282    """Run a video task at multiple frame budgets and analyze degradation.
283
284    Parameters
285    ----------
286    run_fn : callable
287        ``run_fn(config) -> (result, dr_report, paths)`` — the task
288        pipeline function (e.g. ``run_video_depth``).
289    make_config_fn : callable
290        ``make_config_fn(budget, sampling) -> config`` — builds a
291        run config with the given ``frame_budget`` and ``sampling``.
292    budgets, sampling, model_name, metric_keys, tol :
293        Forwarded to :func:`degradation_analysis`.
294
295    Returns
296    -------
297    cells_by_budget : dict[int, list[dict]]
298        Raw cell-log dicts per budget.
299    degradation : list[DegradationResult]
300        One per metric.
301    """
302    cells_by_budget: Dict[int, List[dict]] = {}
303
304    for budget in sorted(budgets):
305        log.info("budget sweep: running at frame_budget=%d, sampling=%s", budget, sampling)
306        cfg = make_config_fn(budget, sampling)
307        result, _, paths = run_fn(cfg)
308
309        # Read back the cell-log from disk.
310        cells_path = paths.get("cells")
311        if cells_path and Path(cells_path).exists():
312            try:
313                import pyarrow.parquet as pq  # type: ignore[import-untyped]
314
315                table = pq.read_table(str(cells_path))
316                cells = table.to_pylist()
317            except ImportError:
318                log.warning("pyarrow not available — reading cells from result.per_sample")
319                cells = list(getattr(result, "per_sample", []))
320        else:
321            cells = list(getattr(result, "per_sample", []))
322
323        cells_by_budget[budget] = cells
324        log.info("budget sweep: budget=%d → %d cells collected", budget, len(cells))
325
326    analysis = degradation_analysis(
327        cells_by_budget,
328        metric_keys=metric_keys,
329        tol=tol,
330        model_name=model_name,
331    )
332
333    return cells_by_budget, analysis

Run a video task at multiple frame budgets and analyze degradation.

Parameters

run_fn : callable run_fn(config) -> (result, dr_report, paths) — the task pipeline function (e.g. run_video_depth). make_config_fn : callable make_config_fn(budget, sampling) -> config — builds a run config with the given frame_budget and sampling. budgets, sampling, model_name, metric_keys, tol : Forwarded to degradation_analysis().

Returns

cells_by_budget : dict[int, list[dict]] Raw cell-log dicts per budget. degradation : list[DegradationResult] One per metric.

def degradation_analysis( cells_by_budget: Dict[int, List[dict]], metric_keys: Optional[List[str]] = None, tol: float = 0.05, model_name: str = 'model') -> List[DegradationResult]:
137def degradation_analysis(
138    cells_by_budget: Dict[int, List[dict]],
139    metric_keys: Optional[List[str]] = None,
140    tol: float = 0.05,
141    model_name: str = "model",
142) -> List[DegradationResult]:
143    """Compute degradation statistics from cell-log dicts at each budget.
144
145    Parameters
146    ----------
147    cells_by_budget : dict[int, list[dict]]
148        Mapping from frame_budget → list of cell-log row dicts. Each
149        dict must contain the metric columns + ``scene`` (or
150        ``scene_id``) + ``phase`` for scene-level pairing.
151    metric_keys : list[str] or None
152        Metrics to analyze. ``None`` means auto-detect all float columns.
153    tol : float
154        Critical-budget tolerance (default 5%).
155    model_name : str
156        Model name for the result objects.
157
158    Returns
159    -------
160    list[DegradationResult]
161        One result per metric, sorted by metric name.
162    """
163    if not cells_by_budget:
164        return []
165
166    budgets_sorted = sorted(cells_by_budget.keys())
167    max_budget = budgets_sorted[-1]
168    min_budget = budgets_sorted[0]
169
170    # Auto-detect metric keys from the max-budget cells.
171    sample_row = cells_by_budget[max_budget][0] if cells_by_budget[max_budget] else {}
172    if metric_keys is None:
173        skip = {"frame_budget", "latency_ms", "scene", "scene_id", "phase",
174                "difficulty", "id", "model", "task"}
175        metric_keys = sorted(
176            k for k, v in sample_row.items()
177            if isinstance(v, (int, float)) and not isinstance(v, bool)
178            and k not in skip
179        )
180
181    # Determine metric direction from specs registry (best-effort).
182    from .metrics.specs import get_spec, has_spec
183
184    results = []
185    for mkey in metric_keys:
186        direction = "lower"
187        if has_spec(mkey):
188            direction = get_spec(mkey).direction
189
190        # Collect per-budget mean and per-scene values.
191        means = []
192        per_scene: Dict[int, List[float]] = {}
193        for b in budgets_sorted:
194            vals = [
195                row[mkey] for row in cells_by_budget[b]
196                if mkey in row and isinstance(row[mkey], (int, float))
197                and np.isfinite(row[mkey])
198            ]
199            means.append(float(np.mean(vals)) if vals else float("nan"))
200            per_scene[b] = vals
201
202        # TCV: sign-adjusted difference (positive = temporal cues help).
203        val_max = means[-1]  # metric @ max_budget
204        val_min = means[0]   # metric @ min_budget
205        if direction == "lower":
206            # Lower is better → if max_budget is lower, temporal cues help.
207            tcv = val_min - val_max  # positive when full clip is better
208        else:
209            # Higher is better → if max_budget is higher, temporal cues help.
210            tcv = val_max - val_min
211
212        # AUDC: trapezoidal integral, normalized by full-budget metric.
213        areas = []
214        for i in range(len(budgets_sorted) - 1):
215            db = budgets_sorted[i + 1] - budgets_sorted[i]
216            avg_val = (means[i] + means[i + 1]) / 2.0
217            areas.append(db * avg_val)
218        total_area = sum(areas)
219        budget_range = budgets_sorted[-1] - budgets_sorted[0]
220        if abs(val_max) > 1e-12 and budget_range > 0:
221            audc = total_area / (budget_range * abs(val_max))
222        else:
223            audc = float("nan")
224
225        # Critical budget: smallest where metric is within tol of full.
226        critical = None
227        for i, b in enumerate(budgets_sorted):
228            if abs(val_max) < 1e-12:
229                break
230            rel_diff = abs(means[i] - val_max) / abs(val_max)
231            if rel_diff <= tol:
232                critical = b
233                break
234
235        # Wilcoxon signed-rank test (min vs max budget, scene-paired).
236        wilcoxon_p = None
237        if len(per_scene[min_budget]) >= 5 and len(per_scene[max_budget]) >= 5:
238            # Pair by position (assumes same scene ordering).
239            n = min(len(per_scene[min_budget]), len(per_scene[max_budget]))
240            diffs = np.array(per_scene[max_budget][:n]) - np.array(per_scene[min_budget][:n])
241            diffs = diffs[np.isfinite(diffs)]
242            if len(diffs) >= 5 and np.any(diffs != 0):
243                try:
244                    from scipy.stats import wilcoxon  # type: ignore[import-untyped]
245
246                    _, wilcoxon_p = wilcoxon(diffs, alternative="two-sided")
247                    wilcoxon_p = float(wilcoxon_p)
248                except ImportError:
249                    log.debug("scipy not available — Wilcoxon test skipped")
250
251        results.append(DegradationResult(
252            model=model_name,
253            metric=mkey,
254            direction=direction,
255            budgets=budgets_sorted,
256            values=means,
257            per_scene_values=per_scene,
258            tcv=tcv,
259            audc=audc,
260            critical_budget=critical,
261            wilcoxon_p=wilcoxon_p,
262        ))
263
264    return sorted(results, key=lambda r: r.metric)

Compute degradation statistics from cell-log dicts at each budget.

Parameters

cells_by_budget : dict[int, list[dict]] Mapping from frame_budget → list of cell-log row dicts. Each dict must contain the metric columns + scene (or scene_id) + phase for scene-level pairing. metric_keys : list[str] or None Metrics to analyze. None means auto-detect all float columns. tol : float Critical-budget tolerance (default 5%). model_name : str Model name for the result objects.

Returns

list[DegradationResult] One result per metric, sorted by metric name.