Source code for blinklinmult.compare.clusters

"""The unit of resampling: what counts as one independent observation.

This module exists because getting this wrong is the most common way a model
comparison overstates its own certainty, and it fails silently -- the arithmetic
is identical, only the interval is wrong, and it is wrong in the flattering
direction.

**A recording is one observation. A window is not.** Two 15-frame windows cut
from the same recording share a subject, a camera, an illuminant, an eyelid
shape and a blink rate. They are close to duplicates. Resampling them
independently pretends the corpus holds far more information than it does:
measured on RN30, 7312 windows come from **35 recordings**, and treating the
windows as independent shrank the confidence interval on a model difference by
**5.9x** -- turning an inconclusive result into a significant one.

The same argument applies one level down. The left and right eye of one subject
are two samples, but they are not independent: they blink together. So both eyes
land in the same cluster, which :func:`cluster_of` enforces by construction.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import numpy as np

if TYPE_CHECKING:
    from collections.abc import Sequence

SAMPLE_SEPARATOR = "|"
"""Separator inside a sample id, matching ``data.schema.build_sample_id``."""


[docs] class ClusterError(ValueError): """Raised when clusters cannot be determined from the given identifiers."""
[docs] def cluster_of(sample_id: str) -> str: """Return the cluster a sample belongs to. Sample ids are ``video_id|frame_group|eye_side`` (see :func:`~blinklinmult.data.schema.build_sample_id`), so the cluster is the leading component. **Both eyes of one subject map to the same cluster**: they blink together, so resampling them independently would be the window-level error one level down. Args: sample_id (str): An id built by :func:`~blinklinmult.data.schema.build_sample_id`, or a bare recording name, which is already its own cluster. Returns: str: The cluster key. Raises: ClusterError: If the id is empty, which would silently pool everything into one nameless cluster. """ if not sample_id: raise ClusterError("An empty sample id has no cluster.") return sample_id.split(SAMPLE_SEPARATOR, 1)[0]
[docs] def clusters_from_signals(recordings: Sequence[str]) -> list[str]: """Reduce recording keys to their sorted, unique clusters. Args: recordings (Sequence[str]): Keys from :func:`~blinklinmult.train.callbacks.load_signals`. Read the archive through that function rather than :func:`numpy.load`: it checks which target the ``truth`` arrays hold, and reading a ``blink_presence`` archive as ``eye_state`` once fabricated 200 045 phantom false positives. Returns: list[str]: Unique clusters, sorted so a run is reproducible. Raises: ClusterError: If no recordings were given, since there is nothing to resample. """ if not recordings: raise ClusterError("No recordings, so there is nothing to resample.") return sorted({cluster_of(name) for name in recordings})
[docs] def resample( n_clusters: int, rounds: int, seed: int, ) -> np.ndarray: """Draw bootstrap resamples of cluster indices. One array for every model to share. Drawing once and evaluating every model on the same draw is what makes the comparison **paired**: a resample heavy on hard recordings drags all models down together, so the difference between them is stable where their absolute scores are not. At n=20-35 clusters that between-recording variance dominates, and an unpaired interval would be too wide to resolve anything. Args: n_clusters (int): How many clusters the corpus has. rounds (int): Bootstrap replicates. seed (int): Seed, recorded in the output so a result can be reproduced. Returns: np.ndarray: ``(rounds, n_clusters)`` indices, drawn with replacement. Raises: ClusterError: If either argument is not positive. """ if n_clusters < 1: raise ClusterError(f"Need at least one cluster to resample, got {n_clusters}.") if rounds < 1: raise ClusterError(f"Need at least one round, got {rounds}.") generator = np.random.default_rng(seed) return generator.integers(0, n_clusters, size=(rounds, n_clusters))