Source code for pyvisor.analysis.reliability.measures

# ╔══════════════════════════════════════════════════════════════════╗
# ║  GameThogram — reliability.measures                              ║
# ║  « derived behavioural measures per clip »                       ║
# ╠══════════════════════════════════════════════════════════════════╣
# ║  Courtship Index, bout counts and durations, and latency to      ║
# ║  first occurrence — the scalar readouts fed to ICC and           ║
# ║  Bland–Altman across clips.                                      ║
# ╚══════════════════════════════════════════════════════════════════╝
"""Per-clip behavioural measures derived from a frame raster."""
from __future__ import annotations

from dataclasses import dataclass

import numpy as np


[docs] @dataclass(frozen=True) class BoutStatistics: n_bouts: int total_frames: int mean_duration_s: float sd_duration_s: float
[docs] def proportion_active(raster: np.ndarray) -> float: """Fraction of frames in which the behaviour is active. With a 1-D behaviour vector this is that behaviour's index; pass a 2-D (frames × behaviours) slice and the row-wise ``any`` to obtain a Courtship Index over a *set* of courtship behaviours. """ a = np.asarray(raster).astype(bool) if a.ndim == 2: a = a.any(axis=1) if a.size == 0: raise ValueError("empty raster") return float(np.mean(a))
[docs] def courtship_index(raster: np.ndarray, behaviour_columns: np.ndarray) -> float: """Courtship Index: proportion of frames courting. Args: raster: (frames × behaviours) 0-1 matrix. behaviour_columns: Indices of the columns that count as courtship (e.g. orienting, tapping, wing extension, licking, attempted copulation). """ matrix = np.asarray(raster).astype(bool) if matrix.ndim != 2: raise ValueError("raster must be 2-D (frames × behaviours)") courting = matrix[:, list(behaviour_columns)].any(axis=1) return float(np.mean(courting))
def _runs_of_true(vector: np.ndarray) -> list[tuple[int, int]]: """Return (start, stop_exclusive) frame indices of each True run.""" a = np.asarray(vector).astype(bool) padded = np.concatenate([[False], a, [False]]) edges = np.diff(padded.astype(int)) starts = np.where(edges == 1)[0] stops = np.where(edges == -1)[0] return list(zip(starts.tolist(), stops.tolist()))
[docs] def bout_statistics(behaviour_vector: np.ndarray, fps: float) -> BoutStatistics: """Count and time the contiguous bouts of one behaviour.""" if fps <= 0: raise ValueError("fps must be positive") runs = _runs_of_true(behaviour_vector) durations_frames = np.array([stop - start for start, stop in runs], dtype=float) durations_s = durations_frames / fps n = len(runs) return BoutStatistics( n_bouts=n, total_frames=int(durations_frames.sum()), mean_duration_s=float(durations_s.mean()) if n else 0.0, sd_duration_s=float(durations_s.std(ddof=1)) if n > 1 else 0.0, )
[docs] def latency_to_first(behaviour_vector: np.ndarray, fps: float) -> float: """Seconds until the behaviour first becomes active. Returns ``nan`` if the behaviour never occurs. """ if fps <= 0: raise ValueError("fps must be positive") a = np.asarray(behaviour_vector).astype(bool) hits = np.where(a)[0] if hits.size == 0: return float("nan") return float(hits[0] / fps)
[docs] def onset_frames(behaviour_vector: np.ndarray) -> np.ndarray: """Frame indices at which the behaviour switches from off to on.""" a = np.asarray(behaviour_vector).astype(bool) padded = np.concatenate([[False], a]) return np.where(np.diff(padded.astype(int)) == 1)[0]