rpx_benchmark.data.esd_scoring

ESD scoring methods: turn the 27-feature table into a single difficulty score per (scene, phase) and an Easy/Medium/Hard tertile assignment.

Five scoring methods are exposed, all operating on the percentile-normalised feature matrix (per-feature rank/N, ∈ [0,1]):

  • mean_pn — uniform weighted mean (paper-stated method, primary default)
  • median_pn — robust to outlier features
  • max_pn — weakest-link: hardest single feature defines difficulty
  • pca_pc1 — first principal component score
  • kmeans3 — k-means clusters tier-ordered by centroid magnitude

Each method is a pure function that takes the percentile-normalised matrix (plus optional weights for mean_pn) and returns a (continuous_score, categorical_tier) pair.

Two weighting tiers are supported (paper §3.2):

  • uniform_v1 — wᵢ = 1/F. Always reproducible from data alone. The test-of-time baseline.
  • mi_v1 — weights derived from mutual information between features and model failure rates. Loaded from a JSON artifact (see load_mi_weights()). The headline "official" tier when calibration models are available.

Heavy deps (scikit-learn) are lazy-imported inside each function so the module imports cleanly on a minimal install — the mean_pn, median_pn, max_pn paths work without sklearn.

  1"""ESD scoring methods: turn the 27-feature table into a single difficulty
  2score per (scene, phase) and an Easy/Medium/Hard tertile assignment.
  3
  4Five scoring methods are exposed, all operating on the **percentile-normalised**
  5feature matrix (per-feature ``rank/N``, ∈ ``[0,1]``):
  6
  7- ``mean_pn``       — uniform weighted mean (paper-stated method, primary default)
  8- ``median_pn``     — robust to outlier features
  9- ``max_pn``        — weakest-link: hardest single feature defines difficulty
 10- ``pca_pc1``       — first principal component score
 11- ``kmeans3``       — k-means clusters tier-ordered by centroid magnitude
 12
 13Each method is a pure function that takes the percentile-normalised matrix
 14(plus optional weights for ``mean_pn``) and returns a ``(continuous_score,
 15categorical_tier)`` pair.
 16
 17Two weighting tiers are supported (paper §3.2):
 18
 19- ``uniform_v1`` — ``wᵢ = 1/F``. Always reproducible from data alone. The
 20  test-of-time baseline.
 21- ``mi_v1``     — weights derived from mutual information between features and
 22  model failure rates. Loaded from a JSON artifact (see :func:`load_mi_weights`).
 23  The headline "official" tier when calibration models are available.
 24
 25Heavy deps (``scikit-learn``) are lazy-imported inside each function so the
 26module imports cleanly on a minimal install — the ``mean_pn``, ``median_pn``,
 27``max_pn`` paths work without sklearn.
 28"""
 29
 30from __future__ import annotations
 31
 32import hashlib
 33import json
 34from dataclasses import dataclass, field
 35from pathlib import Path
 36from typing import Dict, List, Optional, Tuple
 37
 38import numpy as np
 39
 40from ..exceptions import ConfigError, DatasetError
 41from ..logging_utils import get_logger
 42
 43log = get_logger(__name__)
 44
 45
 46# Method names — kept as a tuple so callers can iterate stably.
 47SCORING_METHODS: Tuple[str, ...] = (
 48    "mean_pn",
 49    "median_pn",
 50    "max_pn",
 51    "pca_pc1",
 52    "kmeans3",
 53)
 54
 55# The default primary method. Documented in the paper as the v1 official score.
 56DEFAULT_PRIMARY: str = "mean_pn"
 57
 58# Tertile labels — kept as a constant so the order matches every output file.
 59TERTILE_LABELS: Tuple[str, str, str] = ("easy", "medium", "hard")
 60
 61# Confidence labels for per-row confidence based on weight-perturbation flip rate.
 62CONFIDENCE_LABELS: Tuple[str, str, str] = ("high", "medium", "low")
 63
 64# Feature → modality category mapping. Keys ordered by the paper appendix
 65# enumeration; values list the canonical FEATURE_NAMES that belong to each
 66# category. Used to compute the per-category sub-score vector that surfaces
 67# the multi-faceted nature of difficulty (data story §S4).
 68FEATURE_CATEGORIES: Dict[str, Tuple[str, ...]] = {
 69    "annotation_effort": ("iter_mean", "iter_max"),
 70    "scene_complexity": ("obj_mean", "obj_std", "obj_consist"),
 71    "occlusion": ("occ_mean", "occ_p90", "occ_heavy"),
 72    "depth_quality": ("depth_invalid", "depth_invalid_mask", "depth_std", "depth_std_mask"),
 73    "photometric_conflict": ("specular", "dark"),
 74    "image_quality": ("rgb_blur", "rgb_texture"),
 75    "object_size": ("mask_area_mean", "mask_area_std"),
 76    "temporal_stability": ("area_cv", "area_drop", "vis_instability"),
 77    "camera_motion": ("trans_mean", "trans_p90", "rot_mean", "rot_p90", "jerk"),
 78    "fisheye_stereo": (
 79        "fisheye_dark",
 80        "fisheye_bright",
 81        "fisheye_sharpness",
 82        "fisheye_corr",
 83        "fisheye_texture",
 84    ),
 85}
 86
 87
 88# --------------------------------------------------------------------------- #
 89# Provenance
 90# --------------------------------------------------------------------------- #
 91
 92
 93@dataclass
 94class ScoringProvenance:
 95    """Metadata stamped on every output file so a 2030 researcher can verify
 96    reproducibility without ambiguity."""
 97
 98    input_sha256: str
 99    feature_set_version: str
100    scoring_version: str  # "uniform_v1" | "mi_v1"
101    primary_method: str  # one of SCORING_METHODS
102    weights: Dict[str, float]  # {feature_name: weight} for primary scoring
103    feature_names: List[str]  # the 27 feature names, in canonical order
104    n_entries: int
105    generated_at_utc: str
106    script_version: str = "unknown"  # caller fills with `git rev-parse HEAD`
107    extra: Dict[str, object] = field(default_factory=dict)
108
109    def to_dict(self) -> Dict[str, object]:
110        return {
111            "input_sha256": self.input_sha256,
112            "feature_set_version": self.feature_set_version,
113            "scoring_version": self.scoring_version,
114            "primary_method": self.primary_method,
115            "weights": self.weights,
116            "feature_names": self.feature_names,
117            "n_entries": self.n_entries,
118            "generated_at_utc": self.generated_at_utc,
119            "script_version": self.script_version,
120            "extra": self.extra,
121        }
122
123
124def sha256_of_file(path: Path) -> str:
125    """SHA-256 hex digest of a file's bytes — used to anchor provenance."""
126    h = hashlib.sha256()
127    with Path(path).open("rb") as f:
128        for chunk in iter(lambda: f.read(64 * 1024), b""):
129            h.update(chunk)
130    return h.hexdigest()
131
132
133# --------------------------------------------------------------------------- #
134# Weights
135# --------------------------------------------------------------------------- #
136
137
138def uniform_weights(feature_names: List[str]) -> Dict[str, float]:
139    """``wᵢ = 1/F`` — the always-reproducible baseline.
140
141    Note: this is the ``uniform_v1`` tier. The methodology's headline scoring
142    is ``effort_stratified_v1`` (see :func:`effort_stratified_weights`) which
143    actually honours the methodology's name; ``uniform_v1`` is kept as a
144    "no-prior" reference point for the sensitivity analysis.
145    """
146    if not feature_names:
147        raise ConfigError(
148            "uniform_weights requires a non-empty feature list",
149            hint="check that FEATURE_NAMES is populated.",
150        )
151    w = 1.0 / len(feature_names)
152    return {name: w for name in feature_names}
153
154
155# Features that anchor the Effort-Stratified Difficulty (ESD) score: mask
156# refinement iteration counts. The methodology is *named* after these — the
157# ``effort_stratified_v1`` weighting reflects that name in the actual score.
158EFFORT_FEATURES: Tuple[str, ...] = ("iter_mean", "iter_max")
159
160# Default convex-combination weight on the effort block. α=0.25 means
161# "effort gets a modest boost" without dominating: each iter feature
162# contributes 12.5% of the score (4× the 3% each other feature gets),
163# but the 25 perception features still collectively drive 75% of the score.
164DEFAULT_EFFORT_ALPHA: float = 0.25
165
166
167def effort_stratified_weights(
168    feature_names: List[str],
169    *,
170    alpha: float = DEFAULT_EFFORT_ALPHA,
171    effort_features: Tuple[str, ...] = EFFORT_FEATURES,
172) -> Dict[str, float]:
173    """Effort-anchored weights — the headline ``effort_stratified_v1`` scoring.
174
175    The methodology is named *Effort-Stratified Difficulty*; this weighting
176    actually puts effort at the centre. The convex combination
177
178        RPX-DS = α · effort_score + (1 − α) · perception_score
179
180    expands to per-feature weights:
181
182        iter_mean = iter_max = α / |effort_features|
183        other features       = (1 − α) / (F − |effort_features|)
184
185    With ``alpha = 0.25`` (default) and 31 features (2 effort + 29 other),
186    iter features each carry 12.5 % of the score (25 % combined); each of the
187    29 perception features carries ~2.6 % (75 % combined). At ``alpha = 0`` the
188    weighting reduces to uniform over the perception features only; at
189    ``alpha = 1`` the score depends only on annotation effort.
190    """
191    if not feature_names:
192        raise ConfigError(
193            "effort_stratified_weights requires a non-empty feature list",
194            hint="check that FEATURE_NAMES is populated.",
195        )
196    if not (0.0 <= alpha <= 1.0):
197        raise ConfigError(
198            f"alpha must be in [0, 1], got {alpha}",
199            hint="alpha controls the convex combination of effort vs perception.",
200        )
201
202    effort = [n for n in feature_names if n in effort_features]
203    other = [n for n in feature_names if n not in effort_features]
204    if not effort:
205        raise ConfigError(
206            f"no effort features found in feature list (looked for {effort_features})",
207            hint="check that FEATURE_NAMES includes iter_mean / iter_max.",
208        )
209    if not other:
210        # Degenerate edge case: every feature is an effort feature → fall back
211        # to uniform within the effort block so weights still sum to 1.
212        return uniform_weights(feature_names)
213
214    w_effort = alpha / len(effort)
215    w_other = (1.0 - alpha) / len(other)
216    weights = {n: (w_effort if n in effort_features else w_other) for n in feature_names}
217    # Sanity: weights sum to 1 (modulo floating-point).
218    total = sum(weights.values())
219    assert abs(total - 1.0) < 1e-9, f"weights sum to {total}, expected 1.0"
220    return weights
221
222
223def load_mi_weights(
224    path: Path,
225    feature_names: List[str],
226) -> Tuple[Dict[str, float], Dict[str, object]]:
227    """Load mutual-information-derived weights from a JSON artifact.
228
229    The artifact contract::
230
231        {
232          "weights": { "<feature_name>": <float>, ... },
233          "calibration_model_set": [ "model_id", ... ],
234          "metric_used": "MI",
235          "notes": "..."
236        }
237
238    Weights are renormalised to sum to 1.0 over the requested ``feature_names``.
239    Features missing from the artifact get weight 0.0; features in the artifact
240    but not in ``feature_names`` are silently dropped (forward-compat: future
241    feature additions don't break old MI files).
242    """
243    path = Path(path)
244    if not path.is_file():
245        raise DatasetError(
246            f"MI weights file not found: {path}",
247            hint="pass --weights-mi to point at the calibration artifact.",
248        )
249    payload = json.loads(path.read_text())
250    raw = payload.get("weights")
251    if not isinstance(raw, dict):
252        raise DatasetError(
253            f"MI weights file missing 'weights' dict: {path}",
254            hint="artifact must look like {'weights': {feature: float, ...}, ...}",
255        )
256
257    aligned = {name: float(raw.get(name, 0.0)) for name in feature_names}
258    total = sum(aligned.values())
259    if total <= 0:
260        raise DatasetError(
261            f"MI weights sum to {total} after alignment with feature list",
262            hint="check that weights file uses the same feature names as the build.",
263        )
264    aligned = {k: v / total for k, v in aligned.items()}
265
266    metadata = {k: v for k, v in payload.items() if k != "weights"}
267    return aligned, metadata
268
269
270# --------------------------------------------------------------------------- #
271# Percentile-normalisation
272# --------------------------------------------------------------------------- #
273
274
275def percentile_normalize(matrix: np.ndarray) -> np.ndarray:
276    """Per-column rank divided by N (matches paper §3.2 ``f̃ᵢ``).
277
278    ``matrix`` shape ``(N, F)``. Output same shape, values in ``[0, 1]``.
279    Constant columns (zero variance) are mapped to all-0.5 — informationless
280    but non-degenerate; downstream scoring handles them gracefully.
281
282    NaN values (e.g., fisheye features for scenes without fisheye data) are
283    ranked only among non-NaN entries, then set to 0.5 (midpoint) in the
284    output. This prevents missing-modality scenes from being systematically
285    biased toward low difficulty.
286    """
287    if matrix.ndim != 2:
288        raise ConfigError(
289            f"percentile_normalize expects 2D array, got shape {matrix.shape}",
290            hint="reshape to (n_rows, n_features) before calling.",
291        )
292    n_rows, n_cols = matrix.shape
293    out = np.empty_like(matrix, dtype=np.float64)
294    for j in range(n_cols):
295        col = matrix[:, j]
296        valid_mask = ~np.isnan(col)
297        valid = col[valid_mask]
298        n_valid = valid.size
299
300        if n_valid == 0 or np.unique(valid).size <= 1:
301            out[:, j] = 0.5  # all-NaN or constant → uninformative midpoint
302            continue
303
304        # Rank only the non-NaN entries.
305        order = np.argsort(valid, kind="mergesort")
306        ranks = np.empty(n_valid, dtype=np.float64)
307        ranks[order] = np.arange(1, n_valid + 1, dtype=np.float64)
308        # Average ranks for tied values.
309        for v in np.unique(valid):
310            idx = np.where(valid == v)[0]
311            if idx.size > 1:
312                ranks[idx] = ranks[idx].mean()
313
314        # Write ranked values back; NaN entries get 0.5 (neutral midpoint).
315        out[:, j] = 0.5
316        out[valid_mask, j] = ranks / n_valid
317    return out
318
319
320# --------------------------------------------------------------------------- #
321# Tertile cut
322# --------------------------------------------------------------------------- #
323
324
325def tertile_cut(scores: np.ndarray) -> np.ndarray:
326    """Bottom 33% / middle 33% / top 34% → 'easy' / 'medium' / 'hard'.
327
328    Uses rank-based percentiles so ties never collapse a tertile. If the score
329    has fewer than 3 unique values everything degenerates to 'medium' (caller
330    can decide what to do).
331    """
332    n = scores.size
333    if n == 0:
334        return np.array([], dtype=object)
335    if np.unique(scores).size < 3:
336        log.warning("tertile_cut: fewer than 3 unique scores; defaulting all to 'medium'")
337        return np.array([TERTILE_LABELS[1]] * n, dtype=object)
338
339    ranks = np.argsort(np.argsort(scores, kind="mergesort")) + 1
340    p = ranks / n
341    out = np.empty(n, dtype=object)
342    out[p <= 1.0 / 3.0] = TERTILE_LABELS[0]
343    out[(p > 1.0 / 3.0) & (p <= 2.0 / 3)] = TERTILE_LABELS[1]
344    out[p > 2.0 / 3.0] = TERTILE_LABELS[2]
345    return out
346
347
348def percentile_rank(scores: np.ndarray) -> np.ndarray:
349    """Per-row percentile rank in ``[0, 1]`` based on ``argsort``."""
350    n = scores.size
351    if n == 0:
352        return np.array([], dtype=np.float64)
353    ranks = np.argsort(np.argsort(scores, kind="mergesort")) + 1
354    return ranks.astype(np.float64) / n
355
356
357# --------------------------------------------------------------------------- #
358# Scoring methods (each returns continuous score; tertile_cut applied separately)
359# --------------------------------------------------------------------------- #
360
361
362def score_mean_pn(pn: np.ndarray, weights: np.ndarray) -> np.ndarray:
363    """Weighted mean of percentile-normalised features. Default primary method.
364
365    With ``weights = 1/F`` this is the uniform RPX-DS in paper §3.2. With
366    MI-derived weights it becomes ``mi_v1``.
367    """
368    if pn.shape[1] != weights.size:
369        raise ConfigError(
370            f"weights size {weights.size} does not match feature count {pn.shape[1]}",
371            hint="weights must align with the feature columns of pn.",
372        )
373    return pn @ weights
374
375
376def score_median_pn(pn: np.ndarray) -> np.ndarray:
377    """Per-row median over features. Robust to outlier features."""
378    return np.median(pn, axis=1)
379
380
381def score_max_pn(pn: np.ndarray) -> np.ndarray:
382    """Per-row max over features. Weakest-link: hardest single feature wins."""
383    return pn.max(axis=1)
384
385
386def score_pca_pc1(pn: np.ndarray) -> np.ndarray:
387    """First principal component score of the centred percentile matrix.
388
389    PC1 is sign-flipped if needed so that higher score correlates with higher
390    mean_pn — keeps the "Hard = high score" convention consistent across methods.
391    """
392    try:
393        from sklearn.decomposition import PCA  # noqa: PLC0415
394    except ImportError as e:
395        raise ConfigError(
396            "score_pca_pc1 requires scikit-learn",
397            hint="install with: pip install 'rpx-benchmark[analysis]'",
398        ) from e
399    pca = PCA(n_components=1)
400    pc1 = pca.fit_transform(pn - pn.mean(axis=0)).ravel()
401    if np.corrcoef(pc1, pn.mean(axis=1))[0, 1] < 0:
402        pc1 = -pc1
403    return pc1
404
405
406def score_kmeans3(pn: np.ndarray, *, seed: int = 0) -> np.ndarray:
407    """K-means cluster id (k=3) on the first 5 PCs of the percentile matrix.
408
409    Returned as a *continuous* score: each row's value is the centroid's
410    rank by mean_pn-magnitude (0 → easiest cluster, 2 → hardest). The
411    ``tertile_cut`` then collapses these onto ``easy/medium/hard``.
412    """
413    try:
414        from sklearn.cluster import KMeans  # noqa: PLC0415
415        from sklearn.decomposition import PCA  # noqa: PLC0415
416    except ImportError as e:
417        raise ConfigError(
418            "score_kmeans3 requires scikit-learn",
419            hint="install with: pip install 'rpx-benchmark[analysis]'",
420        ) from e
421    n_rows = pn.shape[0]
422    if n_rows < 3:
423        log.warning("score_kmeans3: <3 rows; degenerate clustering, returning zeros")
424        return np.zeros(n_rows, dtype=np.float64)
425
426    n_pcs = min(5, pn.shape[1], n_rows)
427    proj = PCA(n_components=n_pcs).fit_transform(pn - pn.mean(axis=0))
428    km = KMeans(n_clusters=3, n_init=10, random_state=seed).fit(proj)
429    labels = km.labels_
430
431    # Order clusters by their mean of mean_pn so cluster 0 = easiest.
432    mean_pn = pn.mean(axis=1)
433    cluster_means = {c: float(mean_pn[labels == c].mean()) for c in np.unique(labels)}
434    sorted_clusters = sorted(cluster_means, key=cluster_means.get)
435    rank_of = {c: i for i, c in enumerate(sorted_clusters)}
436    return np.asarray([rank_of[c] for c in labels], dtype=np.float64)
437
438
439# --------------------------------------------------------------------------- #
440# Locality Preserving Projections (LPP) — He & Niyogi, NeurIPS 2003
441# --------------------------------------------------------------------------- #
442
443
444def lpp_embedding(
445    X: np.ndarray,
446    *,
447    n_components: int = 2,
448    n_neighbors: int = 10,
449    heat_t: Optional[float] = None,
450) -> np.ndarray:
451    """Locality Preserving Projections — linear projection that preserves
452    local neighbourhood structure on the data manifold.
453
454    Implements the generalised-eigenvalue formulation from
455    He & Niyogi (NeurIPS 2003): solve ``X^T L X v = λ X^T D X v`` where
456    ``L = D − W`` is the graph Laplacian over a kNN affinity matrix with
457    heat-kernel weights.
458
459    Parameters
460    ----------
461    X            : ``(n, d)`` data matrix
462    n_components : output dimensionality
463    n_neighbors  : k for the kNN affinity graph (excluding self)
464    heat_t       : heat-kernel bandwidth; defaults to median squared
465                   inter-neighbour distance
466
467    Returns
468    -------
469    ``(n, n_components)`` linear embedding ``Y = X @ W``.
470
471    Notes
472    -----
473    LPP is the linear approximation of Laplacian Eigenmaps. Compared to
474    sklearn's ``SpectralEmbedding`` (the non-linear version), LPP gives a
475    deterministic projection that can be applied to held-out data — useful
476    for analyses that need the projection matrix itself, not just the
477    embedding of the training points.
478    """
479    try:
480        from scipy.linalg import eigh  # noqa: PLC0415
481        from sklearn.neighbors import NearestNeighbors  # noqa: PLC0415
482    except ImportError as e:
483        raise ConfigError(
484            "lpp_embedding requires scikit-learn and scipy",
485            hint="install with: pip install 'rpx-benchmark[analysis]'",
486        ) from e
487
488    if X.ndim != 2:
489        raise ConfigError(
490            f"lpp_embedding expects 2D array, got shape {X.shape}",
491            hint="reshape to (n_rows, n_features) before calling.",
492        )
493    n, d = X.shape
494    if n_neighbors >= n:
495        raise ConfigError(
496            f"n_neighbors ({n_neighbors}) must be < n_samples ({n})",
497            hint="reduce n_neighbors or supply a larger dataset.",
498        )
499
500    # Step 1: kNN graph (k+1 to drop self).
501    nn = NearestNeighbors(n_neighbors=n_neighbors + 1).fit(X)
502    distances, indices = nn.kneighbors(X)
503    distances = distances[:, 1:]  # drop self
504    indices = indices[:, 1:]
505
506    # Step 2: heat-kernel weights, symmetrised affinity matrix W.
507    if heat_t is None:
508        heat_t = float(np.median(distances**2))
509        if heat_t <= 0:
510            heat_t = 1.0  # degenerate fallback (e.g., duplicated points)
511
512    W = np.zeros((n, n), dtype=np.float64)
513    for i in range(n):
514        for j_idx, j in enumerate(indices[i]):
515            w = float(np.exp(-(distances[i, j_idx] ** 2) / heat_t))
516            # Symmetrise: take max so the affinity is consistent under
517            # asymmetric kNN.
518            W[i, j] = max(W[i, j], w)
519            W[j, i] = max(W[j, i], w)
520
521    # Step 3: Laplacian.
522    deg = W.sum(axis=1)
523    D = np.diag(deg)
524    L = D - W
525
526    # Step 4: solve generalised eigenvalue problem X^T L X v = λ X^T D X v.
527    A = X.T @ L @ X
528    B = X.T @ D @ X
529    # Tikhonov regularisation on B to handle rank-deficient cases.
530    B = B + 1e-9 * np.eye(d)
531    eigvals, eigvecs = eigh(A, B)
532
533    # Take the n_components smallest non-zero eigenvalues (they correspond
534    # to directions that minimise local distortion).
535    proj = eigvecs[:, :n_components]
536    return X @ proj
537
538
539# --------------------------------------------------------------------------- #
540# Driver — run all methods at once
541# --------------------------------------------------------------------------- #
542
543
544def all_methods(
545    pn: np.ndarray,
546    weights: np.ndarray,
547    *,
548    seed: int = 0,
549) -> Dict[str, np.ndarray]:
550    """Compute every method's continuous score on the same percentile matrix.
551
552    Returns ``{method_name: 1D score array}``. Methods that need scikit-learn
553    are skipped with a warning if the import fails — the caller can then ship
554    the available subset.
555    """
556    out: Dict[str, np.ndarray] = {}
557    out["mean_pn"] = score_mean_pn(pn, weights)
558    out["median_pn"] = score_median_pn(pn)
559    out["max_pn"] = score_max_pn(pn)
560    try:
561        out["pca_pc1"] = score_pca_pc1(pn)
562    except ConfigError as e:
563        log.warning("skipping pca_pc1: %s", e)
564    try:
565        out["kmeans3"] = score_kmeans3(pn, seed=seed)
566    except ConfigError as e:
567        log.warning("skipping kmeans3: %s", e)
568    return out
569
570
571# --------------------------------------------------------------------------- #
572# Structured-metric helpers (data story §S4 + §S5 + consensus)
573# --------------------------------------------------------------------------- #
574
575# Per-row tier-flip rate thresholds for the confidence label. A pair flipping
576# tiers in <10% of weight perturbations is "high" confidence; 10-50% medium;
577# >50% low.
578DEFAULT_CONFIDENCE_HIGH_MAX: float = 0.10
579DEFAULT_CONFIDENCE_MEDIUM_MAX: float = 0.50
580
581
582def compute_per_category_scores(
583    pn: np.ndarray,
584    feature_names: List[str],
585    categories: Optional[Dict[str, Tuple[str, ...]]] = None,
586) -> Dict[str, np.ndarray]:
587    """Per-row mean of percentile-normalised features within each category.
588
589    Returns ``{category_name: 1D float array of length N}``.
590    """
591    cats = categories if categories is not None else FEATURE_CATEGORIES
592    name_to_idx = {n: i for i, n in enumerate(feature_names)}
593    out: Dict[str, np.ndarray] = {}
594    for cat, feats in cats.items():
595        idx = [name_to_idx[f] for f in feats if f in name_to_idx]
596        if not idx:
597            log.warning("category %s has no features in the matrix; skipping", cat)
598            continue
599        out[cat] = pn[:, idx].mean(axis=1)
600    return out
601
602
603def compute_consensus_tier(
604    method_tiers: Dict[str, np.ndarray],
605    *,
606    exclude_methods: Optional[Tuple[str, ...]] = None,
607) -> np.ndarray:
608    """Majority-vote tier across multiple methods' tier assignments.
609
610    Each method's tier array must have the same length. ``exclude_methods``
611    drops degenerate methods (e.g. DBSCAN on data with no density modes)
612    from the vote. Ties are broken by the canonical TERTILE_LABELS order
613    (easy < medium < hard) — i.e. ties favour the easier label, the
614    conservative choice for downstream evaluation.
615    """
616    from collections import Counter
617
618    excluded = set(exclude_methods or ())
619    voting = {m: t for m, t in method_tiers.items() if m not in excluded}
620    if not voting:
621        raise ConfigError(
622            "compute_consensus_tier received no methods to vote on",
623            hint="check that exclude_methods does not exhaust all methods.",
624        )
625    n = next(iter(voting.values())).shape[0]
626    out = np.empty(n, dtype=object)
627    for i in range(n):
628        votes = Counter(voting[m][i] for m in voting)
629        max_count = max(votes.values())
630        winners = [t for t, c in votes.items() if c == max_count]
631        if len(winners) == 1:
632            out[i] = winners[0]
633        else:
634            for t in TERTILE_LABELS:
635                if t in winners:
636                    out[i] = t
637                    break
638            else:
639                out[i] = winners[0]  # fallback (e.g. degenerate label)
640    return out
641
642
643def aggregate_to_scene_splits(
644    scene_ids: List[str],
645    scores: np.ndarray,
646    *,
647    phases: Optional[np.ndarray] = None,
648    phase_tiers: Optional[np.ndarray] = None,
649) -> Dict[str, object]:
650    """Roll per-(scene, phase) scores up to per-scene splits, preserving
651    per-phase tier detail for downstream analysis.
652
653    Each scene's representative score is the **mean of its phase scores**;
654    scenes are sorted by that score and cut into ⌊N/3⌋ Easy + ⌊N/3⌋ Medium
655    + (remainder) Hard. With N=100 the cut is the canonical 33/33/34;
656    with N=99 it becomes 33/33/33.
657
658    Output shape::
659
660        {
661          "splits": {
662            "easy":   [scene_id, …],   # alphabetically sorted in-tier
663            "medium": [...],
664            "hard":   [...]
665          },
666          "scene_detail": {
667            "scene_id": {
668              "scene_tier":  "easy" | "medium" | "hard",
669              "scene_score": <mean of phase scores>,
670              "phase_tiers": ["easy", "medium", "hard"]   # length-3 if phase
671                                                          # info is supplied,
672                                                          # in capture-phase
673                                                          # order (0,1,2)
674              "phase_scores": [0.30, 0.45, 0.28]          # same indexing
675            },
676            ...
677          }
678        }
679
680    The split lists are the consumer-facing deliverable; ``scene_detail``
681    answers "what did each phase look like for this scene?" inline,
682    without requiring readers to cross-reference a separate per-phase
683    difficulty table. Per-phase info is included only when ``phases``
684    (and optionally ``phase_tiers``) are passed.
685    """
686    if len(scene_ids) != scores.size:
687        raise ConfigError(
688            f"length mismatch: {len(scene_ids)} scene_ids vs {scores.size} scores",
689            hint="pass arrays aligned 1:1 across (scene, phase) entries.",
690        )
691    if phases is not None and len(phases) != scores.size:
692        raise ConfigError(
693            f"length mismatch: {len(phases)} phases vs {scores.size} scores",
694            hint="phases must align 1:1 with scene_ids and scores.",
695        )
696    if phase_tiers is not None and len(phase_tiers) != scores.size:
697        raise ConfigError(
698            f"length mismatch: {len(phase_tiers)} phase_tiers vs {scores.size} scores",
699            hint="phase_tiers must align 1:1 with scene_ids and scores.",
700        )
701
702    from collections import defaultdict
703
704    # Aggregate per-scene: mean score plus per-phase detail.
705    per_scene: Dict[str, Dict[int, Tuple[float, Optional[str]]]] = defaultdict(dict)
706    for i, sid in enumerate(scene_ids):
707        ph = int(phases[i]) if phases is not None else i
708        tier_here = str(phase_tiers[i]) if phase_tiers is not None else None
709        per_scene[sid][ph] = (float(scores[i]), tier_here)
710
711    per_scene_mean = {
712        sid: float(np.mean([v[0] for v in info.values()])) for sid, info in per_scene.items()
713    }
714
715    # Sort scenes ascending by mean score; tertile-cut.
716    ordered = sorted(per_scene_mean, key=per_scene_mean.get)
717    n = len(ordered)
718    n_easy = n // 3
719    n_med = n // 3
720    easy = sorted(ordered[:n_easy])
721    medium = sorted(ordered[n_easy : n_easy + n_med])
722    hard = sorted(ordered[n_easy + n_med :])
723    splits = {"easy": easy, "medium": medium, "hard": hard}
724
725    # Reverse map for "which tier does this scene belong to".
726    scene_to_tier = {
727        **{s: "easy" for s in easy},
728        **{s: "medium" for s in medium},
729        **{s: "hard" for s in hard},
730    }
731
732    scene_detail: Dict[str, Dict[str, object]] = {}
733    for sid, info in per_scene.items():
734        # Sort phases by index so output is deterministic (capture order).
735        ordered_phases = sorted(info)
736        phase_scores = [info[p][0] for p in ordered_phases]
737        phase_tiers_out = [info[p][1] for p in ordered_phases if info[p][1] is not None]
738        detail: Dict[str, object] = {
739            "scene_tier": scene_to_tier[sid],
740            "scene_score": per_scene_mean[sid],
741            "phase_scores": phase_scores,
742        }
743        if phase_tiers_out:
744            detail["phase_tiers"] = phase_tiers_out
745        if phases is not None:
746            detail["phase_indices"] = ordered_phases
747        scene_detail[sid] = detail
748
749    return {"splits": splits, "scene_detail": scene_detail}
750
751
752def compute_confidence(
753    pn: np.ndarray,
754    *,
755    n_perturb: int = 1000,
756    seed: int = 0,
757    high_max: float = DEFAULT_CONFIDENCE_HIGH_MAX,
758    medium_max: float = DEFAULT_CONFIDENCE_MEDIUM_MAX,
759) -> Tuple[np.ndarray, np.ndarray]:
760    """Per-row confidence in the uniform mean_pn tier under weight perturbation.
761
762    For each Dirichlet$(\\mathbf{1})$-sampled weight vector, recompute the
763    tertile and check whether each row's tier changed. A row whose tier flips
764    in <``high_max`` of perturbations is "high" confidence; <``medium_max``
765    is "medium"; otherwise "low".
766
767    Returns ``(flip_rate, label)`` arrays, both of length N.
768    """
769    rng = np.random.default_rng(seed)
770    F = pn.shape[1]
771    base_score = pn.mean(axis=1)
772    base_tier = tertile_cut(base_score)
773
774    flips = np.zeros(pn.shape[0], dtype=np.int32)
775    for _ in range(n_perturb):
776        w = rng.dirichlet(np.ones(F))
777        flips += (tertile_cut(pn @ w) != base_tier).astype(np.int32)
778    flip_rate = flips.astype(np.float64) / float(n_perturb)
779
780    label = np.empty(pn.shape[0], dtype=object)
781    label[flip_rate < high_max] = "high"
782    label[(flip_rate >= high_max) & (flip_rate < medium_max)] = "medium"
783    label[flip_rate >= medium_max] = "low"
784    return flip_rate, label
log = <Logger rpx_benchmark.data.esd_scoring (WARNING)>
SCORING_METHODS: Tuple[str, ...] = ('mean_pn', 'median_pn', 'max_pn', 'pca_pc1', 'kmeans3')
DEFAULT_PRIMARY: str = 'mean_pn'
TERTILE_LABELS: Tuple[str, str, str] = ('easy', 'medium', 'hard')
CONFIDENCE_LABELS: Tuple[str, str, str] = ('high', 'medium', 'low')
FEATURE_CATEGORIES: Dict[str, Tuple[str, ...]] = {'annotation_effort': ('iter_mean', 'iter_max'), 'scene_complexity': ('obj_mean', 'obj_std', 'obj_consist'), 'occlusion': ('occ_mean', 'occ_p90', 'occ_heavy'), 'depth_quality': ('depth_invalid', 'depth_invalid_mask', 'depth_std', 'depth_std_mask'), 'photometric_conflict': ('specular', 'dark'), 'image_quality': ('rgb_blur', 'rgb_texture'), 'object_size': ('mask_area_mean', 'mask_area_std'), 'temporal_stability': ('area_cv', 'area_drop', 'vis_instability'), 'camera_motion': ('trans_mean', 'trans_p90', 'rot_mean', 'rot_p90', 'jerk'), 'fisheye_stereo': ('fisheye_dark', 'fisheye_bright', 'fisheye_sharpness', 'fisheye_corr', 'fisheye_texture')}
@dataclass
class ScoringProvenance:
 94@dataclass
 95class ScoringProvenance:
 96    """Metadata stamped on every output file so a 2030 researcher can verify
 97    reproducibility without ambiguity."""
 98
 99    input_sha256: str
100    feature_set_version: str
101    scoring_version: str  # "uniform_v1" | "mi_v1"
102    primary_method: str  # one of SCORING_METHODS
103    weights: Dict[str, float]  # {feature_name: weight} for primary scoring
104    feature_names: List[str]  # the 27 feature names, in canonical order
105    n_entries: int
106    generated_at_utc: str
107    script_version: str = "unknown"  # caller fills with `git rev-parse HEAD`
108    extra: Dict[str, object] = field(default_factory=dict)
109
110    def to_dict(self) -> Dict[str, object]:
111        return {
112            "input_sha256": self.input_sha256,
113            "feature_set_version": self.feature_set_version,
114            "scoring_version": self.scoring_version,
115            "primary_method": self.primary_method,
116            "weights": self.weights,
117            "feature_names": self.feature_names,
118            "n_entries": self.n_entries,
119            "generated_at_utc": self.generated_at_utc,
120            "script_version": self.script_version,
121            "extra": self.extra,
122        }

Metadata stamped on every output file so a 2030 researcher can verify reproducibility without ambiguity.

ScoringProvenance( input_sha256: str, feature_set_version: str, scoring_version: str, primary_method: str, weights: Dict[str, float], feature_names: List[str], n_entries: int, generated_at_utc: str, script_version: str = 'unknown', extra: Dict[str, object] = <factory>)
input_sha256: str
feature_set_version: str
scoring_version: str
primary_method: str
weights: Dict[str, float]
feature_names: List[str]
n_entries: int
generated_at_utc: str
script_version: str = 'unknown'
extra: Dict[str, object]
def to_dict(self) -> Dict[str, object]:
110    def to_dict(self) -> Dict[str, object]:
111        return {
112            "input_sha256": self.input_sha256,
113            "feature_set_version": self.feature_set_version,
114            "scoring_version": self.scoring_version,
115            "primary_method": self.primary_method,
116            "weights": self.weights,
117            "feature_names": self.feature_names,
118            "n_entries": self.n_entries,
119            "generated_at_utc": self.generated_at_utc,
120            "script_version": self.script_version,
121            "extra": self.extra,
122        }
def sha256_of_file(path: pathlib.Path) -> str:
125def sha256_of_file(path: Path) -> str:
126    """SHA-256 hex digest of a file's bytes — used to anchor provenance."""
127    h = hashlib.sha256()
128    with Path(path).open("rb") as f:
129        for chunk in iter(lambda: f.read(64 * 1024), b""):
130            h.update(chunk)
131    return h.hexdigest()

SHA-256 hex digest of a file's bytes — used to anchor provenance.

def uniform_weights(feature_names: List[str]) -> Dict[str, float]:
139def uniform_weights(feature_names: List[str]) -> Dict[str, float]:
140    """``wᵢ = 1/F`` — the always-reproducible baseline.
141
142    Note: this is the ``uniform_v1`` tier. The methodology's headline scoring
143    is ``effort_stratified_v1`` (see :func:`effort_stratified_weights`) which
144    actually honours the methodology's name; ``uniform_v1`` is kept as a
145    "no-prior" reference point for the sensitivity analysis.
146    """
147    if not feature_names:
148        raise ConfigError(
149            "uniform_weights requires a non-empty feature list",
150            hint="check that FEATURE_NAMES is populated.",
151        )
152    w = 1.0 / len(feature_names)
153    return {name: w for name in feature_names}

wᵢ = 1/F — the always-reproducible baseline.

Note: this is the uniform_v1 tier. The methodology's headline scoring is effort_stratified_v1 (see effort_stratified_weights()) which actually honours the methodology's name; uniform_v1 is kept as a "no-prior" reference point for the sensitivity analysis.

EFFORT_FEATURES: Tuple[str, ...] = ('iter_mean', 'iter_max')
DEFAULT_EFFORT_ALPHA: float = 0.25
def effort_stratified_weights( feature_names: List[str], *, alpha: float = 0.25, effort_features: Tuple[str, ...] = ('iter_mean', 'iter_max')) -> Dict[str, float]:
168def effort_stratified_weights(
169    feature_names: List[str],
170    *,
171    alpha: float = DEFAULT_EFFORT_ALPHA,
172    effort_features: Tuple[str, ...] = EFFORT_FEATURES,
173) -> Dict[str, float]:
174    """Effort-anchored weights — the headline ``effort_stratified_v1`` scoring.
175
176    The methodology is named *Effort-Stratified Difficulty*; this weighting
177    actually puts effort at the centre. The convex combination
178
179        RPX-DS = α · effort_score + (1 − α) · perception_score
180
181    expands to per-feature weights:
182
183        iter_mean = iter_max = α / |effort_features|
184        other features       = (1 − α) / (F − |effort_features|)
185
186    With ``alpha = 0.25`` (default) and 31 features (2 effort + 29 other),
187    iter features each carry 12.5 % of the score (25 % combined); each of the
188    29 perception features carries ~2.6 % (75 % combined). At ``alpha = 0`` the
189    weighting reduces to uniform over the perception features only; at
190    ``alpha = 1`` the score depends only on annotation effort.
191    """
192    if not feature_names:
193        raise ConfigError(
194            "effort_stratified_weights requires a non-empty feature list",
195            hint="check that FEATURE_NAMES is populated.",
196        )
197    if not (0.0 <= alpha <= 1.0):
198        raise ConfigError(
199            f"alpha must be in [0, 1], got {alpha}",
200            hint="alpha controls the convex combination of effort vs perception.",
201        )
202
203    effort = [n for n in feature_names if n in effort_features]
204    other = [n for n in feature_names if n not in effort_features]
205    if not effort:
206        raise ConfigError(
207            f"no effort features found in feature list (looked for {effort_features})",
208            hint="check that FEATURE_NAMES includes iter_mean / iter_max.",
209        )
210    if not other:
211        # Degenerate edge case: every feature is an effort feature → fall back
212        # to uniform within the effort block so weights still sum to 1.
213        return uniform_weights(feature_names)
214
215    w_effort = alpha / len(effort)
216    w_other = (1.0 - alpha) / len(other)
217    weights = {n: (w_effort if n in effort_features else w_other) for n in feature_names}
218    # Sanity: weights sum to 1 (modulo floating-point).
219    total = sum(weights.values())
220    assert abs(total - 1.0) < 1e-9, f"weights sum to {total}, expected 1.0"
221    return weights

Effort-anchored weights — the headline effort_stratified_v1 scoring.

The methodology is named Effort-Stratified Difficulty; this weighting actually puts effort at the centre. The convex combination

RPX-DS = α · effort_score + (1 − α) · perception_score

expands to per-feature weights:

iter_mean = iter_max = α / |effort_features|
other features       = (1 − α) / (F − |effort_features|)

With alpha = 0.25 (default) and 31 features (2 effort + 29 other), iter features each carry 12.5 % of the score (25 % combined); each of the 29 perception features carries ~2.6 % (75 % combined). At alpha = 0 the weighting reduces to uniform over the perception features only; at alpha = 1 the score depends only on annotation effort.

def load_mi_weights( path: pathlib.Path, feature_names: List[str]) -> Tuple[Dict[str, float], Dict[str, object]]:
224def load_mi_weights(
225    path: Path,
226    feature_names: List[str],
227) -> Tuple[Dict[str, float], Dict[str, object]]:
228    """Load mutual-information-derived weights from a JSON artifact.
229
230    The artifact contract::
231
232        {
233          "weights": { "<feature_name>": <float>, ... },
234          "calibration_model_set": [ "model_id", ... ],
235          "metric_used": "MI",
236          "notes": "..."
237        }
238
239    Weights are renormalised to sum to 1.0 over the requested ``feature_names``.
240    Features missing from the artifact get weight 0.0; features in the artifact
241    but not in ``feature_names`` are silently dropped (forward-compat: future
242    feature additions don't break old MI files).
243    """
244    path = Path(path)
245    if not path.is_file():
246        raise DatasetError(
247            f"MI weights file not found: {path}",
248            hint="pass --weights-mi to point at the calibration artifact.",
249        )
250    payload = json.loads(path.read_text())
251    raw = payload.get("weights")
252    if not isinstance(raw, dict):
253        raise DatasetError(
254            f"MI weights file missing 'weights' dict: {path}",
255            hint="artifact must look like {'weights': {feature: float, ...}, ...}",
256        )
257
258    aligned = {name: float(raw.get(name, 0.0)) for name in feature_names}
259    total = sum(aligned.values())
260    if total <= 0:
261        raise DatasetError(
262            f"MI weights sum to {total} after alignment with feature list",
263            hint="check that weights file uses the same feature names as the build.",
264        )
265    aligned = {k: v / total for k, v in aligned.items()}
266
267    metadata = {k: v for k, v in payload.items() if k != "weights"}
268    return aligned, metadata

Load mutual-information-derived weights from a JSON artifact.

The artifact contract::

{
  "weights": { "<feature_name>": <float>, ... },
  "calibration_model_set": [ "model_id", ... ],
  "metric_used": "MI",
  "notes": "..."
}

Weights are renormalised to sum to 1.0 over the requested feature_names. Features missing from the artifact get weight 0.0; features in the artifact but not in feature_names are silently dropped (forward-compat: future feature additions don't break old MI files).

def percentile_normalize(matrix: numpy.ndarray) -> numpy.ndarray:
276def percentile_normalize(matrix: np.ndarray) -> np.ndarray:
277    """Per-column rank divided by N (matches paper §3.2 ``f̃ᵢ``).
278
279    ``matrix`` shape ``(N, F)``. Output same shape, values in ``[0, 1]``.
280    Constant columns (zero variance) are mapped to all-0.5 — informationless
281    but non-degenerate; downstream scoring handles them gracefully.
282
283    NaN values (e.g., fisheye features for scenes without fisheye data) are
284    ranked only among non-NaN entries, then set to 0.5 (midpoint) in the
285    output. This prevents missing-modality scenes from being systematically
286    biased toward low difficulty.
287    """
288    if matrix.ndim != 2:
289        raise ConfigError(
290            f"percentile_normalize expects 2D array, got shape {matrix.shape}",
291            hint="reshape to (n_rows, n_features) before calling.",
292        )
293    n_rows, n_cols = matrix.shape
294    out = np.empty_like(matrix, dtype=np.float64)
295    for j in range(n_cols):
296        col = matrix[:, j]
297        valid_mask = ~np.isnan(col)
298        valid = col[valid_mask]
299        n_valid = valid.size
300
301        if n_valid == 0 or np.unique(valid).size <= 1:
302            out[:, j] = 0.5  # all-NaN or constant → uninformative midpoint
303            continue
304
305        # Rank only the non-NaN entries.
306        order = np.argsort(valid, kind="mergesort")
307        ranks = np.empty(n_valid, dtype=np.float64)
308        ranks[order] = np.arange(1, n_valid + 1, dtype=np.float64)
309        # Average ranks for tied values.
310        for v in np.unique(valid):
311            idx = np.where(valid == v)[0]
312            if idx.size > 1:
313                ranks[idx] = ranks[idx].mean()
314
315        # Write ranked values back; NaN entries get 0.5 (neutral midpoint).
316        out[:, j] = 0.5
317        out[valid_mask, j] = ranks / n_valid
318    return out

Per-column rank divided by N (matches paper §3.2 f̃ᵢ).

matrix shape (N, F). Output same shape, values in [0, 1]. Constant columns (zero variance) are mapped to all-0.5 — informationless but non-degenerate; downstream scoring handles them gracefully.

NaN values (e.g., fisheye features for scenes without fisheye data) are ranked only among non-NaN entries, then set to 0.5 (midpoint) in the output. This prevents missing-modality scenes from being systematically biased toward low difficulty.

def tertile_cut(scores: numpy.ndarray) -> numpy.ndarray:
326def tertile_cut(scores: np.ndarray) -> np.ndarray:
327    """Bottom 33% / middle 33% / top 34% → 'easy' / 'medium' / 'hard'.
328
329    Uses rank-based percentiles so ties never collapse a tertile. If the score
330    has fewer than 3 unique values everything degenerates to 'medium' (caller
331    can decide what to do).
332    """
333    n = scores.size
334    if n == 0:
335        return np.array([], dtype=object)
336    if np.unique(scores).size < 3:
337        log.warning("tertile_cut: fewer than 3 unique scores; defaulting all to 'medium'")
338        return np.array([TERTILE_LABELS[1]] * n, dtype=object)
339
340    ranks = np.argsort(np.argsort(scores, kind="mergesort")) + 1
341    p = ranks / n
342    out = np.empty(n, dtype=object)
343    out[p <= 1.0 / 3.0] = TERTILE_LABELS[0]
344    out[(p > 1.0 / 3.0) & (p <= 2.0 / 3)] = TERTILE_LABELS[1]
345    out[p > 2.0 / 3.0] = TERTILE_LABELS[2]
346    return out

Bottom 33% / middle 33% / top 34% → 'easy' / 'medium' / 'hard'.

Uses rank-based percentiles so ties never collapse a tertile. If the score has fewer than 3 unique values everything degenerates to 'medium' (caller can decide what to do).

def percentile_rank(scores: numpy.ndarray) -> numpy.ndarray:
349def percentile_rank(scores: np.ndarray) -> np.ndarray:
350    """Per-row percentile rank in ``[0, 1]`` based on ``argsort``."""
351    n = scores.size
352    if n == 0:
353        return np.array([], dtype=np.float64)
354    ranks = np.argsort(np.argsort(scores, kind="mergesort")) + 1
355    return ranks.astype(np.float64) / n

Per-row percentile rank in [0, 1] based on argsort.

def score_mean_pn(pn: numpy.ndarray, weights: numpy.ndarray) -> numpy.ndarray:
363def score_mean_pn(pn: np.ndarray, weights: np.ndarray) -> np.ndarray:
364    """Weighted mean of percentile-normalised features. Default primary method.
365
366    With ``weights = 1/F`` this is the uniform RPX-DS in paper §3.2. With
367    MI-derived weights it becomes ``mi_v1``.
368    """
369    if pn.shape[1] != weights.size:
370        raise ConfigError(
371            f"weights size {weights.size} does not match feature count {pn.shape[1]}",
372            hint="weights must align with the feature columns of pn.",
373        )
374    return pn @ weights

Weighted mean of percentile-normalised features. Default primary method.

With weights = 1/F this is the uniform RPX-DS in paper §3.2. With MI-derived weights it becomes mi_v1.

def score_median_pn(pn: numpy.ndarray) -> numpy.ndarray:
377def score_median_pn(pn: np.ndarray) -> np.ndarray:
378    """Per-row median over features. Robust to outlier features."""
379    return np.median(pn, axis=1)

Per-row median over features. Robust to outlier features.

def score_max_pn(pn: numpy.ndarray) -> numpy.ndarray:
382def score_max_pn(pn: np.ndarray) -> np.ndarray:
383    """Per-row max over features. Weakest-link: hardest single feature wins."""
384    return pn.max(axis=1)

Per-row max over features. Weakest-link: hardest single feature wins.

def score_pca_pc1(pn: numpy.ndarray) -> numpy.ndarray:
387def score_pca_pc1(pn: np.ndarray) -> np.ndarray:
388    """First principal component score of the centred percentile matrix.
389
390    PC1 is sign-flipped if needed so that higher score correlates with higher
391    mean_pn — keeps the "Hard = high score" convention consistent across methods.
392    """
393    try:
394        from sklearn.decomposition import PCA  # noqa: PLC0415
395    except ImportError as e:
396        raise ConfigError(
397            "score_pca_pc1 requires scikit-learn",
398            hint="install with: pip install 'rpx-benchmark[analysis]'",
399        ) from e
400    pca = PCA(n_components=1)
401    pc1 = pca.fit_transform(pn - pn.mean(axis=0)).ravel()
402    if np.corrcoef(pc1, pn.mean(axis=1))[0, 1] < 0:
403        pc1 = -pc1
404    return pc1

First principal component score of the centred percentile matrix.

PC1 is sign-flipped if needed so that higher score correlates with higher mean_pn — keeps the "Hard = high score" convention consistent across methods.

def score_kmeans3(pn: numpy.ndarray, *, seed: int = 0) -> numpy.ndarray:
407def score_kmeans3(pn: np.ndarray, *, seed: int = 0) -> np.ndarray:
408    """K-means cluster id (k=3) on the first 5 PCs of the percentile matrix.
409
410    Returned as a *continuous* score: each row's value is the centroid's
411    rank by mean_pn-magnitude (0 → easiest cluster, 2 → hardest). The
412    ``tertile_cut`` then collapses these onto ``easy/medium/hard``.
413    """
414    try:
415        from sklearn.cluster import KMeans  # noqa: PLC0415
416        from sklearn.decomposition import PCA  # noqa: PLC0415
417    except ImportError as e:
418        raise ConfigError(
419            "score_kmeans3 requires scikit-learn",
420            hint="install with: pip install 'rpx-benchmark[analysis]'",
421        ) from e
422    n_rows = pn.shape[0]
423    if n_rows < 3:
424        log.warning("score_kmeans3: <3 rows; degenerate clustering, returning zeros")
425        return np.zeros(n_rows, dtype=np.float64)
426
427    n_pcs = min(5, pn.shape[1], n_rows)
428    proj = PCA(n_components=n_pcs).fit_transform(pn - pn.mean(axis=0))
429    km = KMeans(n_clusters=3, n_init=10, random_state=seed).fit(proj)
430    labels = km.labels_
431
432    # Order clusters by their mean of mean_pn so cluster 0 = easiest.
433    mean_pn = pn.mean(axis=1)
434    cluster_means = {c: float(mean_pn[labels == c].mean()) for c in np.unique(labels)}
435    sorted_clusters = sorted(cluster_means, key=cluster_means.get)
436    rank_of = {c: i for i, c in enumerate(sorted_clusters)}
437    return np.asarray([rank_of[c] for c in labels], dtype=np.float64)

K-means cluster id (k=3) on the first 5 PCs of the percentile matrix.

Returned as a continuous score: each row's value is the centroid's rank by mean_pn-magnitude (0 → easiest cluster, 2 → hardest). The tertile_cut then collapses these onto easy/medium/hard.

def lpp_embedding( X: numpy.ndarray, *, n_components: int = 2, n_neighbors: int = 10, heat_t: Optional[float] = None) -> numpy.ndarray:
445def lpp_embedding(
446    X: np.ndarray,
447    *,
448    n_components: int = 2,
449    n_neighbors: int = 10,
450    heat_t: Optional[float] = None,
451) -> np.ndarray:
452    """Locality Preserving Projections — linear projection that preserves
453    local neighbourhood structure on the data manifold.
454
455    Implements the generalised-eigenvalue formulation from
456    He & Niyogi (NeurIPS 2003): solve ``X^T L X v = λ X^T D X v`` where
457    ``L = D − W`` is the graph Laplacian over a kNN affinity matrix with
458    heat-kernel weights.
459
460    Parameters
461    ----------
462    X            : ``(n, d)`` data matrix
463    n_components : output dimensionality
464    n_neighbors  : k for the kNN affinity graph (excluding self)
465    heat_t       : heat-kernel bandwidth; defaults to median squared
466                   inter-neighbour distance
467
468    Returns
469    -------
470    ``(n, n_components)`` linear embedding ``Y = X @ W``.
471
472    Notes
473    -----
474    LPP is the linear approximation of Laplacian Eigenmaps. Compared to
475    sklearn's ``SpectralEmbedding`` (the non-linear version), LPP gives a
476    deterministic projection that can be applied to held-out data — useful
477    for analyses that need the projection matrix itself, not just the
478    embedding of the training points.
479    """
480    try:
481        from scipy.linalg import eigh  # noqa: PLC0415
482        from sklearn.neighbors import NearestNeighbors  # noqa: PLC0415
483    except ImportError as e:
484        raise ConfigError(
485            "lpp_embedding requires scikit-learn and scipy",
486            hint="install with: pip install 'rpx-benchmark[analysis]'",
487        ) from e
488
489    if X.ndim != 2:
490        raise ConfigError(
491            f"lpp_embedding expects 2D array, got shape {X.shape}",
492            hint="reshape to (n_rows, n_features) before calling.",
493        )
494    n, d = X.shape
495    if n_neighbors >= n:
496        raise ConfigError(
497            f"n_neighbors ({n_neighbors}) must be < n_samples ({n})",
498            hint="reduce n_neighbors or supply a larger dataset.",
499        )
500
501    # Step 1: kNN graph (k+1 to drop self).
502    nn = NearestNeighbors(n_neighbors=n_neighbors + 1).fit(X)
503    distances, indices = nn.kneighbors(X)
504    distances = distances[:, 1:]  # drop self
505    indices = indices[:, 1:]
506
507    # Step 2: heat-kernel weights, symmetrised affinity matrix W.
508    if heat_t is None:
509        heat_t = float(np.median(distances**2))
510        if heat_t <= 0:
511            heat_t = 1.0  # degenerate fallback (e.g., duplicated points)
512
513    W = np.zeros((n, n), dtype=np.float64)
514    for i in range(n):
515        for j_idx, j in enumerate(indices[i]):
516            w = float(np.exp(-(distances[i, j_idx] ** 2) / heat_t))
517            # Symmetrise: take max so the affinity is consistent under
518            # asymmetric kNN.
519            W[i, j] = max(W[i, j], w)
520            W[j, i] = max(W[j, i], w)
521
522    # Step 3: Laplacian.
523    deg = W.sum(axis=1)
524    D = np.diag(deg)
525    L = D - W
526
527    # Step 4: solve generalised eigenvalue problem X^T L X v = λ X^T D X v.
528    A = X.T @ L @ X
529    B = X.T @ D @ X
530    # Tikhonov regularisation on B to handle rank-deficient cases.
531    B = B + 1e-9 * np.eye(d)
532    eigvals, eigvecs = eigh(A, B)
533
534    # Take the n_components smallest non-zero eigenvalues (they correspond
535    # to directions that minimise local distortion).
536    proj = eigvecs[:, :n_components]
537    return X @ proj

Locality Preserving Projections — linear projection that preserves local neighbourhood structure on the data manifold.

Implements the generalised-eigenvalue formulation from He & Niyogi (NeurIPS 2003): solve X^T L X v = λ X^T D X v where L = D − W is the graph Laplacian over a kNN affinity matrix with heat-kernel weights.

Parameters

X : (n, d) data matrix n_components : output dimensionality n_neighbors : k for the kNN affinity graph (excluding self) heat_t : heat-kernel bandwidth; defaults to median squared inter-neighbour distance

Returns

(n, n_components) linear embedding Y = X @ W.

Notes

LPP is the linear approximation of Laplacian Eigenmaps. Compared to sklearn's SpectralEmbedding (the non-linear version), LPP gives a deterministic projection that can be applied to held-out data — useful for analyses that need the projection matrix itself, not just the embedding of the training points.

def all_methods( pn: numpy.ndarray, weights: numpy.ndarray, *, seed: int = 0) -> Dict[str, numpy.ndarray]:
545def all_methods(
546    pn: np.ndarray,
547    weights: np.ndarray,
548    *,
549    seed: int = 0,
550) -> Dict[str, np.ndarray]:
551    """Compute every method's continuous score on the same percentile matrix.
552
553    Returns ``{method_name: 1D score array}``. Methods that need scikit-learn
554    are skipped with a warning if the import fails — the caller can then ship
555    the available subset.
556    """
557    out: Dict[str, np.ndarray] = {}
558    out["mean_pn"] = score_mean_pn(pn, weights)
559    out["median_pn"] = score_median_pn(pn)
560    out["max_pn"] = score_max_pn(pn)
561    try:
562        out["pca_pc1"] = score_pca_pc1(pn)
563    except ConfigError as e:
564        log.warning("skipping pca_pc1: %s", e)
565    try:
566        out["kmeans3"] = score_kmeans3(pn, seed=seed)
567    except ConfigError as e:
568        log.warning("skipping kmeans3: %s", e)
569    return out

Compute every method's continuous score on the same percentile matrix.

Returns {method_name: 1D score array}. Methods that need scikit-learn are skipped with a warning if the import fails — the caller can then ship the available subset.

DEFAULT_CONFIDENCE_HIGH_MAX: float = 0.1
DEFAULT_CONFIDENCE_MEDIUM_MAX: float = 0.5
def compute_per_category_scores( pn: numpy.ndarray, feature_names: List[str], categories: Optional[Dict[str, Tuple[str, ...]]] = None) -> Dict[str, numpy.ndarray]:
583def compute_per_category_scores(
584    pn: np.ndarray,
585    feature_names: List[str],
586    categories: Optional[Dict[str, Tuple[str, ...]]] = None,
587) -> Dict[str, np.ndarray]:
588    """Per-row mean of percentile-normalised features within each category.
589
590    Returns ``{category_name: 1D float array of length N}``.
591    """
592    cats = categories if categories is not None else FEATURE_CATEGORIES
593    name_to_idx = {n: i for i, n in enumerate(feature_names)}
594    out: Dict[str, np.ndarray] = {}
595    for cat, feats in cats.items():
596        idx = [name_to_idx[f] for f in feats if f in name_to_idx]
597        if not idx:
598            log.warning("category %s has no features in the matrix; skipping", cat)
599            continue
600        out[cat] = pn[:, idx].mean(axis=1)
601    return out

Per-row mean of percentile-normalised features within each category.

Returns {category_name: 1D float array of length N}.

def compute_consensus_tier( method_tiers: Dict[str, numpy.ndarray], *, exclude_methods: Optional[Tuple[str, ...]] = None) -> numpy.ndarray:
604def compute_consensus_tier(
605    method_tiers: Dict[str, np.ndarray],
606    *,
607    exclude_methods: Optional[Tuple[str, ...]] = None,
608) -> np.ndarray:
609    """Majority-vote tier across multiple methods' tier assignments.
610
611    Each method's tier array must have the same length. ``exclude_methods``
612    drops degenerate methods (e.g. DBSCAN on data with no density modes)
613    from the vote. Ties are broken by the canonical TERTILE_LABELS order
614    (easy < medium < hard) — i.e. ties favour the easier label, the
615    conservative choice for downstream evaluation.
616    """
617    from collections import Counter
618
619    excluded = set(exclude_methods or ())
620    voting = {m: t for m, t in method_tiers.items() if m not in excluded}
621    if not voting:
622        raise ConfigError(
623            "compute_consensus_tier received no methods to vote on",
624            hint="check that exclude_methods does not exhaust all methods.",
625        )
626    n = next(iter(voting.values())).shape[0]
627    out = np.empty(n, dtype=object)
628    for i in range(n):
629        votes = Counter(voting[m][i] for m in voting)
630        max_count = max(votes.values())
631        winners = [t for t, c in votes.items() if c == max_count]
632        if len(winners) == 1:
633            out[i] = winners[0]
634        else:
635            for t in TERTILE_LABELS:
636                if t in winners:
637                    out[i] = t
638                    break
639            else:
640                out[i] = winners[0]  # fallback (e.g. degenerate label)
641    return out

Majority-vote tier across multiple methods' tier assignments.

Each method's tier array must have the same length. exclude_methods drops degenerate methods (e.g. DBSCAN on data with no density modes) from the vote. Ties are broken by the canonical TERTILE_LABELS order (easy < medium < hard) — i.e. ties favour the easier label, the conservative choice for downstream evaluation.

def aggregate_to_scene_splits( scene_ids: List[str], scores: numpy.ndarray, *, phases: Optional[numpy.ndarray] = None, phase_tiers: Optional[numpy.ndarray] = None) -> Dict[str, object]:
644def aggregate_to_scene_splits(
645    scene_ids: List[str],
646    scores: np.ndarray,
647    *,
648    phases: Optional[np.ndarray] = None,
649    phase_tiers: Optional[np.ndarray] = None,
650) -> Dict[str, object]:
651    """Roll per-(scene, phase) scores up to per-scene splits, preserving
652    per-phase tier detail for downstream analysis.
653
654    Each scene's representative score is the **mean of its phase scores**;
655    scenes are sorted by that score and cut into ⌊N/3⌋ Easy + ⌊N/3⌋ Medium
656    + (remainder) Hard. With N=100 the cut is the canonical 33/33/34;
657    with N=99 it becomes 33/33/33.
658
659    Output shape::
660
661        {
662          "splits": {
663            "easy":   [scene_id, …],   # alphabetically sorted in-tier
664            "medium": [...],
665            "hard":   [...]
666          },
667          "scene_detail": {
668            "scene_id": {
669              "scene_tier":  "easy" | "medium" | "hard",
670              "scene_score": <mean of phase scores>,
671              "phase_tiers": ["easy", "medium", "hard"]   # length-3 if phase
672                                                          # info is supplied,
673                                                          # in capture-phase
674                                                          # order (0,1,2)
675              "phase_scores": [0.30, 0.45, 0.28]          # same indexing
676            },
677            ...
678          }
679        }
680
681    The split lists are the consumer-facing deliverable; ``scene_detail``
682    answers "what did each phase look like for this scene?" inline,
683    without requiring readers to cross-reference a separate per-phase
684    difficulty table. Per-phase info is included only when ``phases``
685    (and optionally ``phase_tiers``) are passed.
686    """
687    if len(scene_ids) != scores.size:
688        raise ConfigError(
689            f"length mismatch: {len(scene_ids)} scene_ids vs {scores.size} scores",
690            hint="pass arrays aligned 1:1 across (scene, phase) entries.",
691        )
692    if phases is not None and len(phases) != scores.size:
693        raise ConfigError(
694            f"length mismatch: {len(phases)} phases vs {scores.size} scores",
695            hint="phases must align 1:1 with scene_ids and scores.",
696        )
697    if phase_tiers is not None and len(phase_tiers) != scores.size:
698        raise ConfigError(
699            f"length mismatch: {len(phase_tiers)} phase_tiers vs {scores.size} scores",
700            hint="phase_tiers must align 1:1 with scene_ids and scores.",
701        )
702
703    from collections import defaultdict
704
705    # Aggregate per-scene: mean score plus per-phase detail.
706    per_scene: Dict[str, Dict[int, Tuple[float, Optional[str]]]] = defaultdict(dict)
707    for i, sid in enumerate(scene_ids):
708        ph = int(phases[i]) if phases is not None else i
709        tier_here = str(phase_tiers[i]) if phase_tiers is not None else None
710        per_scene[sid][ph] = (float(scores[i]), tier_here)
711
712    per_scene_mean = {
713        sid: float(np.mean([v[0] for v in info.values()])) for sid, info in per_scene.items()
714    }
715
716    # Sort scenes ascending by mean score; tertile-cut.
717    ordered = sorted(per_scene_mean, key=per_scene_mean.get)
718    n = len(ordered)
719    n_easy = n // 3
720    n_med = n // 3
721    easy = sorted(ordered[:n_easy])
722    medium = sorted(ordered[n_easy : n_easy + n_med])
723    hard = sorted(ordered[n_easy + n_med :])
724    splits = {"easy": easy, "medium": medium, "hard": hard}
725
726    # Reverse map for "which tier does this scene belong to".
727    scene_to_tier = {
728        **{s: "easy" for s in easy},
729        **{s: "medium" for s in medium},
730        **{s: "hard" for s in hard},
731    }
732
733    scene_detail: Dict[str, Dict[str, object]] = {}
734    for sid, info in per_scene.items():
735        # Sort phases by index so output is deterministic (capture order).
736        ordered_phases = sorted(info)
737        phase_scores = [info[p][0] for p in ordered_phases]
738        phase_tiers_out = [info[p][1] for p in ordered_phases if info[p][1] is not None]
739        detail: Dict[str, object] = {
740            "scene_tier": scene_to_tier[sid],
741            "scene_score": per_scene_mean[sid],
742            "phase_scores": phase_scores,
743        }
744        if phase_tiers_out:
745            detail["phase_tiers"] = phase_tiers_out
746        if phases is not None:
747            detail["phase_indices"] = ordered_phases
748        scene_detail[sid] = detail
749
750    return {"splits": splits, "scene_detail": scene_detail}

Roll per-(scene, phase) scores up to per-scene splits, preserving per-phase tier detail for downstream analysis.

Each scene's representative score is the mean of its phase scores; scenes are sorted by that score and cut into ⌊N/3⌋ Easy + ⌊N/3⌋ Medium

  • (remainder) Hard. With N=100 the cut is the canonical 33/33/34; with N=99 it becomes 33/33/33.

Output shape::

{
  "splits": {
    "easy":   [scene_id, …],   # alphabetically sorted in-tier
    "medium": [...],
    "hard":   [...]
  },
  "scene_detail": {
    "scene_id": {
      "scene_tier":  "easy" | "medium" | "hard",
      "scene_score": <mean of phase scores>,
      "phase_tiers": ["easy", "medium", "hard"]   # length-3 if phase
                                                  # info is supplied,
                                                  # in capture-phase
                                                  # order (0,1,2)
      "phase_scores": [0.30, 0.45, 0.28]          # same indexing
    },
    ...
  }
}

The split lists are the consumer-facing deliverable; scene_detail answers "what did each phase look like for this scene?" inline, without requiring readers to cross-reference a separate per-phase difficulty table. Per-phase info is included only when phases (and optionally phase_tiers) are passed.

def compute_confidence( pn: numpy.ndarray, *, n_perturb: int = 1000, seed: int = 0, high_max: float = 0.1, medium_max: float = 0.5) -> Tuple[numpy.ndarray, numpy.ndarray]:
753def compute_confidence(
754    pn: np.ndarray,
755    *,
756    n_perturb: int = 1000,
757    seed: int = 0,
758    high_max: float = DEFAULT_CONFIDENCE_HIGH_MAX,
759    medium_max: float = DEFAULT_CONFIDENCE_MEDIUM_MAX,
760) -> Tuple[np.ndarray, np.ndarray]:
761    """Per-row confidence in the uniform mean_pn tier under weight perturbation.
762
763    For each Dirichlet$(\\mathbf{1})$-sampled weight vector, recompute the
764    tertile and check whether each row's tier changed. A row whose tier flips
765    in <``high_max`` of perturbations is "high" confidence; <``medium_max``
766    is "medium"; otherwise "low".
767
768    Returns ``(flip_rate, label)`` arrays, both of length N.
769    """
770    rng = np.random.default_rng(seed)
771    F = pn.shape[1]
772    base_score = pn.mean(axis=1)
773    base_tier = tertile_cut(base_score)
774
775    flips = np.zeros(pn.shape[0], dtype=np.int32)
776    for _ in range(n_perturb):
777        w = rng.dirichlet(np.ones(F))
778        flips += (tertile_cut(pn @ w) != base_tier).astype(np.int32)
779    flip_rate = flips.astype(np.float64) / float(n_perturb)
780
781    label = np.empty(pn.shape[0], dtype=object)
782    label[flip_rate < high_max] = "high"
783    label[(flip_rate >= high_max) & (flip_rate < medium_max)] = "medium"
784    label[flip_rate >= medium_max] = "low"
785    return flip_rate, label

Per-row confidence in the uniform mean_pn tier under weight perturbation.

For each Dirichlet$(\mathbf{1})$-sampled weight vector, recompute the tertile and check whether each row's tier changed. A row whose tier flips in <high_max of perturbations is "high" confidence; <medium_max is "medium"; otherwise "low".

Returns (flip_rate, label) arrays, both of length N.