Source code for ts2.models.single_session.stat_baseline.co_smoothing.pop_coupling

"""Rank-1 population coupling: ``r_i(t) = mean_i * p(t) ** gamma``.

``p(t)`` is a smoothed, mean-1-normalized population trace. Collapses to the mean rate
at ``gamma = 0``.
"""

import hashlib
import logging

import numpy as np
import torch
import torch.nn.functional as F
from scipy.ndimage import gaussian_filter1d

from ts2.ts2_dataset import IBLBrainWideBenchTS2

from ..base import LOG_EPS, StatBaseline

log = logging.getLogger(__name__)

SIGMA_GRID = [1.0, 1.5, 2.0, 2.5]  # Gaussian smoothing width
GAMMA_GRID = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]  # coupling exponent
FOLD_SALT = "pop_coupling_fold"  # unit-fold assignment, see _unit_folds


[docs] class PopCoupling(StatBaseline): """Population coupling. Args: sigma: Gaussian smoothing width (bins). ``None`` selects on val. gamma: coupling exponent. ``None`` selects on val. """ TASK = "co_smoothing" NAME = "pop_coupling" def __init__(self, sigma: float | None = None, gamma: float | None = None): super().__init__() self._sigma_arg = sigma self._gamma_arg = gamma self.sigma: float = sigma if sigma is not None else 1.5 self.gamma: float = gamma if gamma is not None else 1.0 self.register_buffer("smooth_kernel", self._gaussian_kernel(self.sigma), persistent=True)
[docs] def fit(self, train_dataset, val_dataset: IBLBrainWideBenchTS2): """Select sigma and gamma on val. Val feeds the full population, so a population sum including unit i would leak its own target into its prediction. Instead of leaving out only unit i, the trace leaves out a whole fold the size of the test hold-out, so selection reads the population the model reads at test; the folds tile the units, so every unit is still scored. """ sigmas = [self._sigma_arg] if self._sigma_arg is not None else SIGMA_GRID gammas = [self._gamma_arg] if self._gamma_arg is not None else GAMMA_GRID n_folds = min(max(2, round(1 / val_dataset.CO_SMOOTHING_HELD_OUT_RATIO)), self.N) folds = self._unit_folds(val_dataset, n_folds) # (N,) val_windows = self._windows(val_dataset) # (n_windows, T, N) val_counts = torch.from_numpy(val_windows) # (B, T, N) log_mean_t = self.log_mean # (N,) best = (-float("inf"), self.sigma, self.gamma) for sigma in sigmas: p = self._population_multiplier_np(val_windows, sigma, folds, n_folds) log_p = np.log(np.clip(p, LOG_EPS, None)).astype(np.float32) log_p_t = torch.from_numpy(log_p) # (B, T, N) for gamma in gammas: pred = log_mean_t.view(1, 1, -1) + gamma * log_p_t # (B, T, N) score = self._score(pred, val_counts) if score > best[0]: best = (score, sigma, gamma) self.val_score, self.sigma, self.gamma = best self.smooth_kernel = self._gaussian_kernel(self.sigma) log.info(f"{self.NAME}: selected sigma={self.sigma:.3g}, gamma={self.gamma:.3g}")
[docs] def predict(self, spikes: torch.Tensor, **_) -> torch.Tensor: # At test the held-out units are already removed from the input, so the # population sum is over the observed units only (no self-term). if self.gamma == 0.0: return self._log_mean_like(spikes) return self._log_mean_view(spikes) + self.gamma * self._log_pop_multiplier(spikes)
def _log_pop_multiplier(self, spikes: torch.Tensor) -> torch.Tensor: """Smoothed, per-window mean-1-normalized log population trace, (B, T, 1).""" pop = spikes.sum(dim=-1) # (B, T) pop_smooth = self._smooth_torch(pop) # (B, T) p = pop_smooth / pop_smooth.mean(dim=1, keepdim=True).clamp_min(LOG_EPS) return p.clamp_min(LOG_EPS).log().unsqueeze(-1) # (B, T, 1) def _smooth_torch(self, pop: torch.Tensor) -> torch.Tensor: """Gaussian smooth over time, edge='nearest' (replicate padding).""" kernel = self.smooth_kernel.to(pop.device, pop.dtype) # (1, 1, K) radius = (kernel.shape[-1] - 1) // 2 x = F.pad(pop.unsqueeze(1), (radius, radius), mode="replicate") # (B, 1, T) return F.conv1d(x, kernel).squeeze(1) # (B, T) @staticmethod def _gaussian_kernel(sigma: float, truncate: float = 4.0) -> torch.Tensor: """Normalized Gaussian as a (1, 1, K) conv weight. Follows scipy's radius convention so the torch forward and the numpy val sweep agree. """ radius = max(1, int(truncate * sigma + 0.5)) x = np.arange(-radius, radius + 1, dtype=np.float64) w = np.exp(-0.5 * (x / sigma) ** 2) w /= w.sum() return torch.from_numpy(w.astype(np.float32)).view(1, 1, -1) @staticmethod def _unit_folds(dataset: IBLBrainWideBenchTS2, n_folds: int) -> np.ndarray: """Fold index per unit (N,), in unit-table order. Keyed on ``sha256(salt:unit_id)``, so it survives a reordered unit table. """ ids = dataset.get_recording(dataset.recording_id).units.id digests = np.array( [ hashlib.sha256( f"{FOLD_SALT}:{u.decode() if isinstance(u, bytes) else u}".encode() ).hexdigest() for u in ids ] ) folds = np.empty(len(ids), dtype=np.int64) for f, idx in enumerate(np.array_split(np.argsort(digests), n_folds)): folds[idx] = f return folds @staticmethod def _population_multiplier_np( windows: np.ndarray, sigma: float, folds: np.ndarray, n_folds: int ) -> np.ndarray: """Population multiplier (n_windows, T, N). Entry (b, t, i) is the smoothed population at t over the units outside i's fold, mean-1 over time. Smoothing is linear, so the smoothed total is the sum of the per-unit smoothed traces and a fold's trace is that total minus the fold's own units. """ unit_smooth = gaussian_filter1d(windows, sigma=sigma, axis=1, mode="nearest") pop_smooth = unit_smooth.sum(axis=-1, keepdims=True) # (B, T, 1) onehot = np.zeros((folds.size, n_folds), dtype=unit_smooth.dtype) onehot[np.arange(folds.size), folds] = 1.0 trace = pop_smooth - unit_smooth @ onehot # (B, T, n_folds) denom = np.clip(trace.mean(axis=1, keepdims=True), LOG_EPS, None) # (B, 1, n_folds) return (trace / denom)[:, :, folds] # (B, T, N)