Source code for nstat.decoding_algorithms

"""Neural decoding and state-space estimation algorithms.

This module mirrors MATLAB ``DecodingAlgorithms.m`` and bundles every
estimator the toolbox ships with into the :class:`DecodingAlgorithms`
static-method namespace.

Covered families (all entry points are ``@staticmethod``):

- **Point-process adaptive filters (PPAF)** — ``PPDecodeFilter``,
  ``PPDecodeFilterLinear``, ``PP_fixedIntervalSmoother``.
- **Point-process hybrid filters (PPHF)** — discrete + continuous
  hybrid recursions for mixed-mode decoding.
- **Kalman filtering / smoothing** — ``kalman_filter``,
  ``kalman_smoother``, ``kalman_smootherFromFiltered``,
  ``kalman_fixedIntervalSmoother``, plus their static-coefficient
  variants.
- **Expectation-maximisation** — ``PPSS_EMFB`` (state-space GLM EM),
  ``KF_EM``, ``PP_EM`` and ``PPLFP_EM`` (mixed point-process /
  continuous-observation EM; ``mPPCO_EM`` is its deprecated alias).

Most algorithms follow the derivations in the original 2012 paper (see
:doc:`/PaperOverview`).  Spike trains are :class:`~nstat.nspikeTrain.nspikeTrain`
objects; observations are ``CxN`` binary matrices; time is in **seconds**.
This is the largest single module in the package and is deliberately
kept flat to match the MATLAB layout.
"""
from __future__ import annotations

import logging
import warnings
from collections.abc import Sequence

import numpy as np

from .cif import CIF
from .errors import UnsupportedWorkflowError
from .nspikeTrain import nspikeTrain

# EM progress messages (KF_EM / KF_EStep / PP_EM) go to this
# logger at INFO level -- silent unless the caller configures logging, e.g.
# ``logging.basicConfig(level=logging.INFO)``.  MATLAB's DecodingAlgorithms.m
# prints nothing here, so these are Python-only diagnostics.
_logger = logging.getLogger(__name__)

# EM convergence hyperparameters shared by PPSS_EMFB, PPSS_EM, KF_EM, PP_EM and
# PPLFP_EM (same values as the literals they replace; none of these
# MATLAB-mirrored methods exposes them as arguments).
_EM_TOL_ABS = 1e-3  # absolute parameter-change tolerance (tolAbs)
_EM_TOL_REL = 1e-3  # relative tolerance (tolRel; PPSS_EMFB and PPSS_EM)
_EM_LL_TOL = 1e-3  # log-likelihood change tolerance (llTol)
_EM_MAX_ITER = 100  # maxIter for PPSS_EM, KF_EM, PP_EM, PPLFP_EM
_PPSS_EMFB_MAX_ITER = 2000  # PPSS_EMFB's own, larger maxIter


def _numba_kernels_module():
    """Return :mod:`nstat.extras._numba_kernels`, importing it on first use.

    The opt-in Numba accelerator is imported lazily -- at the first
    fast-path dispatch, not at ``import nstat`` -- so the core package does
    not depend on ``nstat.extras`` (or probe ``numba``) at import time.
    Callers read ``_numba_kernels._NUMBA_AVAILABLE`` from the returned
    module at call time, so tests can still monkeypatch that attribute to
    ``False`` to force the pure-Python parity path.
    """
    from .extras import _numba_kernels

    return _numba_kernels


def _as_observation_matrix(dN) -> np.ndarray:
    obs = np.asarray(dN, dtype=float)
    if obs.ndim == 1:
        obs = obs.reshape(1, -1)
    if obs.ndim != 2:
        raise ValueError("dN must be a CxN observation matrix")
    return obs


def _symmetrize(matrix: np.ndarray) -> np.ndarray:
    return 0.5 * (matrix + matrix.T)


# State-space GLM (PPSS) regularization. High-rate / bursting units, or sparse
# unit-impulse basis bins, drive the observed-information matrix to extreme
# conditioning (lambdaDelta up to e^30 ~ 1e13). The bare invert-then-eigh update
# then returns non-finite entries and ``np.linalg.eigh`` raises "Eigenvalues did
# not converge", aborting the EM. ``SSGLM_STATE_BOUND`` keeps the log-rate state
# physical (|x| <= 50 ⟺ rate within (e^-50, e^50)/delta), which stops a diverged
# trial from poisoning the next trial's prior; ``_robust_psd_inverse`` makes the
# covariance update nan-safe and guaranteed PSD without altering behaviour on
# well-conditioned problems (no non-finite entries, eigenvalues already >= eps).
SSGLM_STATE_BOUND = 50.0


def _robust_psd_inverse(precision: np.ndarray, dim: int, ridge: float = 1e-12) -> np.ndarray:
    """Invert a precision matrix and project to the nearest PSD matrix, robust to
    non-finite entries from extreme observed-information conditioning.

    Mirrors the original ``inv`` + eigenvalue-flooring update, but (a) scrubs
    non-finite entries, (b) symmetrizes, (c) falls back to ``pinv`` and finally a
    ridge-only covariance if the decomposition cannot converge. On well-conditioned
    inputs it returns the same PSD matrix the previous code did.
    """
    precision = np.where(np.isfinite(precision), precision, 0.0)
    precision = _symmetrize(precision) + ridge * np.eye(dim)
    try:
        cov = np.linalg.inv(precision)
    except np.linalg.LinAlgError:
        cov = np.linalg.pinv(precision)
    cov = _symmetrize(np.where(np.isfinite(cov), cov, 0.0))
    try:
        eigvals, eigvecs = np.linalg.eigh(cov)
    except np.linalg.LinAlgError:
        return np.eye(dim) * max(ridge, np.finfo(float).eps)
    eigvals = np.clip(eigvals, np.finfo(float).eps, None)
    return (eigvecs * eigvals) @ eigvecs.T


def _normalize_probabilities(values) -> np.ndarray:
    arr = np.asarray(values, dtype=float).reshape(-1)
    if arr.size == 0:
        raise ValueError("Probability vector cannot be empty")
    total = float(np.sum(arr))
    if not np.isfinite(total) or total <= 0:
        return np.full(arr.shape, 1.0 / float(arr.size), dtype=float)
    return arr / total


def _is_empty_value(value) -> bool:
    if value is None:
        return True
    if isinstance(value, (str, bytes)):
        return False
    if isinstance(value, np.ndarray):
        return value.size == 0
    if isinstance(value, Sequence):
        return len(value) == 0
    return False


def _infer_state_dim(A, beta, num_cells: int) -> int:
    arr = np.asarray(A, dtype=float)
    if arr.ndim >= 2:
        return int(arr.shape[0])
    beta_arr = np.asarray(beta, dtype=float)
    if beta_arr.ndim == 2:
        if beta_arr.shape[1] == num_cells:
            return int(beta_arr.shape[0])
        if beta_arr.shape[0] == num_cells:
            return int(beta_arr.shape[1])
    if beta_arr.ndim == 1:
        if num_cells == 1:
            return int(beta_arr.size)
        if beta_arr.size == num_cells:
            return 1
    return 1


def _as_state_matrix(matrix, dim: int) -> np.ndarray:
    arr = np.asarray(matrix, dtype=float)
    if arr.ndim == 0:
        return np.eye(dim, dtype=float) * float(arr)
    if arr.ndim == 1:
        if arr.size == 1:
            return np.eye(dim, dtype=float) * float(arr[0])
        raise ValueError("State-space matrices must be square matrices or scalars")
    if arr.ndim != 2 or arr.shape[0] != arr.shape[1]:
        raise ValueError("State-space matrices must be square")
    if arr.shape[0] != dim:
        raise ValueError("State-space matrix dimension mismatch")
    return arr.astype(float, copy=False)


def _select_time_matrix(matrix, time_index: int, dim: int) -> np.ndarray:
    arr = np.asarray(matrix, dtype=float)
    if arr.ndim <= 2:
        return _as_state_matrix(arr, dim)
    if arr.ndim == 3:
        return _as_state_matrix(arr[:, :, min(time_index, arr.shape[2] - 1)], dim)
    raise ValueError("Unsupported time-varying state-space matrix shape")


def _normalize_mu(mu, num_cells: int) -> np.ndarray:
    arr = np.asarray(mu, dtype=float).reshape(-1)
    if arr.size == 1 and num_cells > 1:
        arr = np.repeat(arr, num_cells)
    if arr.size != num_cells:
        raise ValueError("mu must contain one baseline term per observed cell")
    return arr


def _normalize_beta(beta, num_states: int, num_cells: int) -> np.ndarray:
    arr = np.asarray(beta, dtype=float)
    if arr.ndim == 0:
        if num_states != 1 or num_cells != 1:
            raise ValueError("scalar beta is only valid for the 1-state, 1-cell MATLAB surface")
        arr = arr.reshape(1, 1)
    elif arr.ndim == 1:
        if num_cells == 1 and arr.size == num_states:
            arr = arr.reshape(num_states, 1)
        elif arr.size == num_cells and num_states == 1:
            arr = arr.reshape(1, num_cells)
        else:
            raise ValueError("beta must be ns x C for MATLAB-facing decoding workflows")
    elif arr.ndim != 2:
        raise ValueError("beta must be a vector or 2D array")

    # When num_states != num_cells we can unambiguously detect the wrong
    # orientation and transpose.  When num_states == num_cells the shapes
    # are indistinguishable; trust the caller's orientation (ns × C).
    if num_states != num_cells and arr.shape == (num_cells, num_states):
        arr = arr.T
    if arr.shape != (num_states, num_cells):
        raise ValueError("beta must be ns x C after MATLAB-style normalization")
    return arr


def _normalize_gamma(gamma, num_windows: int, num_cells: int) -> np.ndarray:
    if num_windows == 0 or _is_empty_value(gamma):
        return np.zeros((num_windows, num_cells), dtype=float)

    arr = np.asarray(gamma, dtype=float)
    if arr.ndim == 0:
        return np.full((num_windows, num_cells), float(arr), dtype=float)
    if arr.ndim == 1:
        if arr.size == 1:
            return np.full((num_windows, num_cells), float(arr[0]), dtype=float)
        if arr.size == num_windows:
            return np.repeat(arr[:, None], num_cells, axis=1)
        if arr.size == num_cells:
            return np.repeat(arr[None, :], num_windows, axis=0)
        raise ValueError("gamma must align with windowTimes or number of cells")
    if arr.ndim != 2:
        raise ValueError("gamma must be scalar, vector, or 2D array")
    if arr.shape == (num_windows, 1) and num_cells > 1:
        # A shared numWindows x 1 column: MATLAB's drivers expand it with
        # if(size(gamma,2)==1 && C>1) gamma = repmat(gamma,1,C)
        # (PPAF.PPDecodeFilterLinear / PP_fixedIntervalSmoother, PPHF.
        # PPHybridFilterLinear -- repaired B2 -- and PPLFP).  It used to raise.
        return np.repeat(arr, num_cells, axis=1)
    # MATLAB reorients gamma only with ``if(size(gamma,2)~=C) gamma=gamma'; end``
    # (PPAF.PPDecodeFilterLinear, PPAF.PP_fixedIntervalSmoother,
    # PPLFP.PPLFP_fixedIntervalSmoother); PPAF.PPDecode_updateLinear,
    # PointProcessEM.PP_EStep, PPLFP.PPLFP_DecodeLinear and
    # PPHF.PPHybridFilterLinear never transpose it.  So a gamma that is already
    # numWindows x C -- including a square one (numWindows == C) -- is kept as
    # is; only a C x numWindows gamma is transposed.
    if arr.shape != (num_windows, num_cells) and arr.shape == (num_cells, num_windows):
        arr = arr.T
    if arr.shape != (num_windows, num_cells):
        raise ValueError("gamma must be numWindows x C after normalization")
    return arr


def _normalize_history_tensor(HkAll, num_steps: int, num_windows: int, num_cells: int) -> np.ndarray:
    if _is_empty_value(HkAll):
        return np.zeros((num_steps, num_windows, num_cells), dtype=float)

    arr = np.asarray(HkAll, dtype=float)
    # MATLAB stores a one-cell N x nW x 1 history as an N x nW matrix (it drops
    # the trailing singleton dimension), so accept that 2-D form when C == 1.
    if arr.ndim == 2 and num_cells == 1 and arr.shape == (num_steps, num_windows):
        return arr.reshape(num_steps, num_windows, 1)
    # The canonical (N, nW, C) layout is checked first so that it wins when
    # N == C makes another layout's shape identical to it.  (The previous
    # dict-keyed lookup let the colliding (C, nW, N) entry overwrite it and
    # silently swapped time and cells.)
    if arr.shape == (num_steps, num_windows, num_cells):
        return arr
    # Other layouts are accepted only when the shape identifies exactly one of
    # them.  A shape that fits several (e.g. MATLAB's permute(HkAll,[2 3 1]) =
    # (nW, C, N) when nW == C, which is also (C, nW, N)) cannot be read safely
    # -- MATLAB's PPAF and PPLFP update steps read such a square slice
    # differently -- so it is rejected instead of silently picking one.
    matches = [
        axes
        for shape, axes in (
            ((num_cells, num_steps, num_windows), (1, 2, 0)),
            ((num_cells, num_windows, num_steps), (2, 1, 0)),
            ((num_windows, num_cells, num_steps), (2, 0, 1)),
        )
        if arr.shape == shape
    ]
    if len(matches) == 1:
        return np.transpose(arr, matches[0])
    if len(matches) > 1:
        raise ValueError(
            f"HkAll of shape {arr.shape} is ambiguous for N={num_steps}, numWindows={num_windows}, "
            f"C={num_cells}: it fits more than one permuted layout.  Only the canonical "
            "(N, numWindows, C) layout is unambiguous; pass the history in that layout."
        )
    raise ValueError("HkAll must align with N x numWindows x C MATLAB-style history storage")


def _compute_history_terms(dN: np.ndarray, delta: float, windowTimes) -> np.ndarray:
    """Spike-history design tensor ``HkAll`` (N x numWindows x C) from ``windowTimes``.

    Reproduces what the MATLAB decoders build per cell with
    ``History(windowTimes,0,maxTime).computeHistory(nspikeTrain((find(dN(c,:)==1)-1)*delta)
    .resample(1/delta)).dataToMatrix`` (PPAF.m PPDecodeFilterLinear /
    PP_fixedIntervalSmoother; PPLFP.m PPLFP_EM builds it without the resample,
    which is identical at delta = 1 ms).  ``History`` filters the spike
    train with ones on taps ``ceil(t_i*sampleRate)+1 : ceil(t_(i+1)*sampleRate)``
    and delays the result one sample, so window ``i`` counts the spikes
    ``L`` bins back for ``ceil(t_i*sampleRate) + 1 <= L <= ceil(t_(i+1)*sampleRate)``
    (sampleRate = 1/delta) -- i.e. lags in ``(t_i, t_(i+1)]`` for edges on the
    sample grid.  The edges use MATLAB's own ``t * sampleRate`` product, so an
    edge such as 0.009 s at 1 kHz (9.000000000000002) rounds as it does in
    MATLAB.  Only bins with ``dN == 1`` are spikes, as in MATLAB's ``find``.
    """
    obs = _as_observation_matrix(dN)
    windows = np.asarray(windowTimes, dtype=float).reshape(-1)
    if windows.size <= 1:
        return np.zeros((obs.shape[1], 0, obs.shape[0]), dtype=float)

    num_cells, num_steps = obs.shape
    num_windows = windows.size - 1
    sample_rate = 1.0 / float(delta)
    out = np.zeros((num_steps, num_windows, num_cells), dtype=float)

    # counts[:, k] = number of spikes in bins 0 .. k-1, so the spikes in bins
    # a .. b are counts[:, b + 1] - counts[:, a].
    spikes = (obs == 1).astype(float)
    counts = np.zeros((num_cells, num_steps + 1), dtype=float)
    np.cumsum(spikes, axis=1, out=counts[:, 1:])
    steps = np.arange(num_steps)
    for window_index in range(num_windows):
        first_lag = int(np.ceil(float(windows[window_index]) * sample_rate)) + 1
        last_lag = int(np.ceil(float(windows[window_index + 1]) * sample_rate))
        if last_lag < first_lag:
            continue  # empty window (MATLAB's b(StartSample:NumSamples) is empty)
        if first_lag < 1:
            # MATLAB's b(StartSample:NumSamples)=1 fails on a non-positive
            # index; here it would sum the current and future bins.
            raise ValueError(
                f"windowTimes edge {float(windows[window_index])!r} reaches lag {first_lag} at "
                f"delta={float(delta)!r}: history windows must lie at or after one bin back "
                "(edges > -delta)"
            )
        # Bins n - last_lag .. n - first_lag, clipped to the observed record.
        upper = np.clip(steps - first_lag + 1, 0, num_steps)
        lower = np.clip(steps - last_lag, 0, num_steps)
        out[:, window_index, :] = (counts[:, upper] - counts[:, lower]).T
    return out


def _em_history_windows(gamma, windowTimes, delta, num_cells: int):
    """History coefficients and windows of ``PP_EM`` / ``PPLFP_EM`` (MATLAB B9).

    Mirrors the repaired MATLAB default rule (PointProcessEM.m PP_EM and
    PPLFP.m PPLFP_EM, fix/pp-em @ a457b54):

    * ``windowTimes`` empty and ``gamma`` empty or a scalar 0: no history
      (returns ``windowTimes = None``).
    * ``windowTimes`` empty otherwise: one window per history coefficient.
      ``numWindows = size(gamma, 1)`` -- a scalar is one shared window, a
      ``numWindows x C`` matrix one window per row, and a row whose length is
      not the number of cells is the shared coefficient list, made a column
      first -- and ``windowTimes = 0:deltaW:numWindows*deltaW`` (built
      element-exactly), ``deltaW = delta`` (0.001 if ``delta`` is None).
      (The former rule ``0:delta:(length(gamma)+1)*delta`` gave
      ``length(gamma) + 1`` windows for ``length(gamma)`` coefficients.)
    * A nonzero shared column (``numWindows x 1``, ``numWindows ==
      numel(windowTimes) - 1``, more than one cell) is expanded with
      ``repmat(gamma, 1, numCells)``.  An all-zero gamma is left as passed:
      0 means "no history coefficients" downstream (M-step, SE gamma block,
      IC parameter count).

    NumPy has no row/column distinction for 1-D arrays: a 1-D ``gamma`` is
    read as MATLAB's row, i.e. ``(1, C)`` when its length is the number of
    cells and the shared ``(numWindows, 1)`` column otherwise (with explicit
    ``windowTimes`` too, where MATLAB would reject a row).  A 2-D ``(1, n)``
    row is made a column only in the default-window branch, as in MATLAB.  A
    0-d scalar stays 0-d unless it is expanded.

    Returns ``(gamma, windowTimes)`` with ``gamma`` a float array.
    """
    from .core import _matlab_colon_exact

    g = np.zeros(0) if gamma is None else np.asarray(gamma, dtype=float)
    if g.ndim == 1:
        g = g.reshape(1, -1) if g.size == num_cells else g.reshape(-1, 1)
    elif g.ndim > 2:
        raise ValueError("gamma must be a scalar, vector or numWindows x C matrix")

    if _is_empty_value(windowTimes):
        if g.size == 0 or (g.size == 1 and float(g.reshape(-1)[0]) == 0.0):
            return g, None
        # MATLAB: if(isrow(gamma) && numel(gamma)~=size(dN,1)) gamma = gamma(:)
        # -- in this default-window branch only.  A 2-D (1, n) row (what
        # scipy.io.loadmat returns for a MATLAB row vector) with n != C is the
        # shared coefficient list.
        if g.ndim == 2 and g.shape[0] == 1 and g.size != num_cells:
            g = g.reshape(-1, 1)
        delta_w = 0.001 if delta is None else float(delta)
        num_windows = 1 if g.ndim == 0 else g.shape[0]
        windowTimes = _matlab_colon_exact(0.0, delta_w, num_windows * delta_w)
    windowTimes = np.asarray(windowTimes, dtype=float).reshape(-1)

    g2 = g.reshape(1, 1) if g.ndim == 0 else g
    if (g.size > 0 and g2.shape[1] == 1 and num_cells > 1 and g2.shape[0] == windowTimes.size - 1
            and np.any(g2 != 0)):
        g = np.tile(g2, (1, num_cells))
    return g, windowTimes


def _expand_shared_se_gamma(gammahat, windowTimes, num_cells: int):
    """Expand a shared history-coefficient column per cell (MATLAB F12).

    Mirrors the repaired MATLAB ``PP_ComputeParamStandardErrors`` /
    ``PPLFP_ComputeParamStandardErrors`` entry rule (fix/pp-em 1c051a9), the
    B9 rule of the EM drivers: if ``windowTimes`` is non-empty, ``gammahat``
    is one column (a scalar is one window), there is more than one cell,
    the column has ``numel(windowTimes) - 1`` rows and a nonzero entry, then
    ``gammahat = repmat(gammahat, 1, numCells)``.  An all-zero gamma is left
    as passed.  A 1-D array is read as in :func:`_em_history_windows` (a
    row when its length is the number of cells, else the shared column).
    """
    g = np.asarray(gammahat, dtype=float)
    if _is_empty_value(windowTimes) or num_cells <= 1 or g.size == 0:
        return g
    if g.ndim == 0:
        col = g.reshape(1, 1)
    elif g.ndim == 1:
        col = g.reshape(1, -1) if g.size == num_cells else g.reshape(-1, 1)
    else:
        col = g
    n_windows = np.asarray(windowTimes, dtype=float).reshape(-1).size - 1
    if col.ndim == 2 and col.shape[1] == 1 and col.shape[0] == n_windows and np.any(col != 0):
        return np.tile(col, (1, num_cells))
    return g


_MSTEP_METHODS = ("GLM", "NewtonRaphson")


class GLMPlugInWarning(UserWarning):
    """``MstepMethod='GLM'`` is a plug-in fit on the smoothed means.

    Mirrors MATLAB's ``nSTAT:EM:glmPlugIn`` warning (nSTAT PR #138, a
    maintainer decision): the GLM M-step regresses each cell's spikes
    on the smoothed state means ``x_K`` and ignores the smoothed covariance
    ``W_K``, which inflates ``beta`` and can drift; ``MstepMethod =
    'NewtonRaphson'`` (the default) maximises the expected complete-data
    log-likelihood over the smoothed posterior and is preferred.

    Raised once per top-level ``PP_EM`` / ``PPLFP_EM`` call (not once per
    internal M-step iteration) when ``MstepMethod='GLM'``; a direct
    ``PP_MStep`` / ``PPLFP_MStep`` call with ``MstepMethod='GLM'`` also warns
    exactly once.  ``mPPCO_EM`` / ``mPPCO_MStep`` forward to PPLFP and
    inherit this.  No numerical change: this is a warning only.
    """


def _glm_plugin_warning_message(routine: str) -> str:
    return (
        f"{routine}: MstepMethod='GLM' is a plug-in fit on the smoothed means "
        "(ignores W_K), which inflates beta and can drift; 'NewtonRaphson' "
        "(the default) is preferred."
    )


def _check_mstep_method(MstepMethod):
    """Resolve ``MstepMethod`` (``None`` -> ``'NewtonRaphson'``) and reject unknown values.

    MATLAB runs the Newton-Raphson M-step for any value other than ``'GLM'``;
    this port raises instead (a misspelt ``'GLM'`` silently ran Newton-Raphson).
    """
    if MstepMethod is None or (isinstance(MstepMethod, str) and MstepMethod == ""):
        return "NewtonRaphson"
    if not isinstance(MstepMethod, str) or MstepMethod not in _MSTEP_METHODS:
        raise ValueError(f"MstepMethod must be 'GLM' or 'NewtonRaphson' (got {MstepMethod!r})")
    return MstepMethod


def _em_glm_mstep(dN, x_K, fitType, muhat, betahat, gammahat, windowTimes, delta):
    """GLM M-step for (mu, beta, gamma) of ``PP_MStep`` / ``PPLFP_MStep``.

    Port of the repaired MATLAB GLM block (``PointProcessEM.PP_MStep`` /
    ``PPLFP.PPLFP_MStep`` with ``MstepMethod = 'GLM'``, fix/pp-em @ 8dbd0e4;
    the two MATLAB blocks are identical).  It regresses each cell's spikes on
    the smoothed means ``x_K`` (a plug-in fit that ignores ``W_K``) through
    the toolbox pipeline: ``Covariate`` -> ``Trial`` -> ``TrialConfig`` ->
    ``Analysis.RunAnalysisForAllNeurons`` (``'GLM'`` for poisson,
    ``'BNLRCG'`` otherwise) -> ``FitResSummary``.

    * Time base (C6 / R4c): ``time = (0:K-1)*delta``, spike trains with
      bin width ``delta`` and the Trial sample rate ``1/delta``.
    * History: self-history windows ``windowTimes`` unless gamma is all zero
      (MATLAB ``if(gammahat==0)``: no history term, gamma returned as passed).
      A nonzero (or empty) gamma with an empty ``windowTimes`` raises
      ``ValueError``, as MATLAB errors there.
    * Coefficients are read BY LABEL (F3: ``'constant'`` -> mu, ``'v<i>'`` ->
      beta row i; R4a: the window labels of the same ``History`` object the
      Trial uses -> gamma rows).  A coefficient that MATLAB's
      ``FitResSummary`` reports as NaN keeps its previous value: MATLAB's
      ``computePlotParams`` drops every coefficient whose standard error is
      not ``< 100`` (not identifiable, e.g. a history window separated by a
      refractory period), and so does this port -- the Python
      ``FitResSummary.getCoeffs`` returns the raw coefficients with their
      standard errors, so the filter is applied here.  A label absent from
      the fit (F1: no window estimable at all) also keeps the previous value.
      On a rank-deficient design (collinear smoothed means: one cell, or more
      states than cells, under an isotropic state model) ``Analysis.GLMFit``
      returns the dependent columns' coefficients as 0 with standard error 0,
      as MATLAB ``glmfit`` does, so those entries become 0.
    * Warnings raised during the fit are silenced inside a scoped
      ``warnings.catch_warnings()`` block (MATLAB ``warning('OFF')`` with
      the caller's state restored, C9); no global state changes.

    Returns ``(muhat_new, betahat_new, gammahat_new)``.
    """
    from .analysis import Analysis
    from .core import Covariate
    from .fit import FitResSummary
    from .history import History
    from ._spike_train_impl import nspikeTrain as _nst
    from ._trial_config_impl import ConfigCollection, TrialConfig
    from .trial import CovariateCollection, SpikeTrainCollection, Trial

    dN = np.atleast_2d(np.asarray(dN, dtype=float))
    x_K = np.atleast_2d(np.asarray(x_K, dtype=float))
    dx, K = x_K.shape
    numCells = dN.shape[0]
    delta = 0.001 if delta is None else float(delta)
    muhat_new = np.asarray(muhat, dtype=float).reshape(-1).copy()
    betahat_new = np.atleast_2d(np.asarray(betahat, dtype=float)).copy()
    gammahat_in = np.asarray(gammahat, dtype=float)
    # MATLAB if(gammahat==0): true when every entry is 0 (false for an empty gamma).
    gamma_is_zero = gammahat_in.size > 0 and bool(np.all(gammahat_in == 0))
    wt = None if _is_empty_value(windowTimes) else np.asarray(windowTimes, dtype=float).reshape(-1)
    if not gamma_is_zero and wt is None:
        # MATLAB fits no history then, and errors when it reads the window
        # labels: History([], ...).computeHistory(nst{1}).getCov(1) raises
        # MATLAB:nonLogicalConditional (CovColl.getCov), for a scalar, row,
        # column or matrix gamma alike (checked at fix/pp-em aa88a2b).  The EM
        # drivers never get here (an empty windowTimes defaults to one window
        # per coefficient, or gamma = 0 means no history).
        raise ValueError(
            "GLM M-step: nonzero (or empty) history coefficients gammahat need history windows "
            "(windowTimes is empty); pass windowTimes, or gammahat = 0 for no history")

    time = np.arange(K) * delta  # MATLAB (0:K-1)*delta
    labels = [f"v{i + 1}" for i in range(dx)]
    labels2 = ["vel", *labels]
    vel = Covariate(time, x_K.T, "vel", "time", "s", "m/s", labels)
    baseline = Covariate(time, np.ones((K, 1)), "Baseline", "time", "s", "", ["constant"])
    nst = [_nst(time[np.flatnonzero(dN[i, :] == 1)], "", binwidth=delta) for i in range(numCells)]
    trial = Trial(SpikeTrainCollection(nst), CovariateCollection([vel, baseline]))
    sampleRate = 1.0 / delta
    selfHist = None if gamma_is_zero else wt
    config = TrialConfig([["Baseline", "constant"], labels2], sampleRate, selfHist, None)
    config.setName("Baseline")
    algorithm = "GLM" if fitType == "poisson" else "BNLRCG"
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        results = Analysis.RunAnalysisForAllNeurons(trial, ConfigCollection([config]), 0, algorithm)
    summary = FitResSummary(results)

    coeffMat, coeffLabels, seMat = summary.getCoeffs()  # numCells x numLabels
    coeffMat = np.asarray(coeffMat, dtype=float).reshape(numCells, -1)
    seMat = np.asarray(seMat, dtype=float).reshape(numCells, -1)
    # MATLAB FitResSummary keeps a coefficient only when its se < 100 (NaN se
    # fails the test too); everything else is NaN, i.e. "keep previous".
    est = np.where(seMat < 100, coeffMat, np.nan)
    coeffLabels = list(coeffLabels)

    def _fitted(label):
        return est[:, coeffLabels.index(label)] if label in coeffLabels else None

    v = _fitted("constant")
    if v is not None:
        keep = ~np.isnan(v)
        muhat_new[keep] = v[keep]
    for i, label in enumerate(labels):
        v = _fitted(label)
        if v is not None:
            keep = ~np.isnan(v)
            betahat_new[i, keep] = v[keep]

    if gamma_is_zero:
        return muhat_new, betahat_new, gammahat_in.copy()
    if wt.size < 2:  # no complete window: nothing to map (gamma kept)
        return muhat_new, betahat_new, gammahat_in.copy()
    nWin = wt.size - 1
    winLabels = list(History(wt, float(time.min()), float(time.max())).computeHistory(nst[0]).getCov(0).dataLabels)
    gPrev = gammahat_in
    if gPrev.ndim == 1:  # NumPy 1-D: a row when its length is numCells, else the shared column
        gPrev = gPrev.reshape(1, -1) if gPrev.size == numCells else gPrev.reshape(-1, 1)
    if gPrev.size == 1:  # MATLAB isscalar(gPrev): gPrev*ones(nWin, numCells)
        histTemp = float(gPrev.reshape(-1)[0]) * np.ones((nWin, numCells))
    elif gPrev.shape[1] == 1:  # shared column: repmat(gPrev, 1, numCells)
        histTemp = np.tile(gPrev, (1, numCells))
    else:
        histTemp = np.array(gPrev, dtype=float, copy=True)
    for w in range(nWin):
        v = _fitted(winLabels[w])
        if v is not None:
            keep = ~np.isnan(v)
            histTemp[w, keep] = v[keep]
    if gammahat_in.ndim == 0 and histTemp.size == 1:
        histTemp = histTemp.reshape(())  # a 0-d coefficient comes back 0-d, as in the Newton-Raphson branch
    return muhat_new, betahat_new, histTemp


def _mc_state_draws(m, W, M, normal, *, non_pd="eig_floor"):
    """``M`` Monte Carlo draws from ``N(m, W)``, returned as a ``dx x M`` array.

    Private mirror of the repaired MATLAB helper ``mcStateDraws`` (F9;
    ``PointProcessEM.m`` / ``PPLFP.m``, fix/pp-em @ 8dbd0e4), used by every
    Monte Carlo state draw of ``PP_ComputeParamStandardErrors``,
    ``PP_MStep``, ``PPLFP_ComputeParamStandardErrors`` and ``PPLFP_MStep``.

    MATLAB drew ``m + chol(W)*z``; ``chol`` returns the UPPER factor ``R``
    with ``R'*R = W``, so the draws had covariance ``R*R'``, which is ``W``
    only for a diagonal ``W`` (or ``dx == 1``).  The repaired draw is
    ``m + R'*z``, covariance ``R'*R = W``.  Here ``R' = L =
    np.linalg.cholesky(W)`` (NumPy returns the lower factor), so the draw is
    ``m + L @ z``; this port used to mirror the defect as
    ``np.linalg.cholesky(W).T @ z``.  For a diagonal ``W`` both forms are
    bit-identical.

    ``normal(dx, M)`` returns the ``dx x M`` standard-normal matrix ``z`` --
    each caller passes its own generator, so the random stream is unchanged
    (MATLAB keeps ``normrnd(0,1,dx,M)`` too).

    ``non_pd`` selects the Python-only fallback each call site already had
    when ``W`` is not positive definite (MATLAB has none: its partial factor
    raises a dimension error).  ``"eig_floor"`` (the PP sites) factors
    ``W`` with its eigenvalues floored at ``1e-12``; ``"zero"`` (the PPLFP
    sites) returns ``m`` repeated ``M`` times (a zero factor).
    """
    m = np.asarray(m, dtype=float).reshape(-1)
    W = np.asarray(W, dtype=float).reshape(m.size, m.size)
    try:
        L = np.linalg.cholesky(W)
    except np.linalg.LinAlgError:
        if non_pd == "zero":
            L = np.zeros_like(W)
        elif non_pd == "eig_floor":
            eigv, eigvec = np.linalg.eigh(W)
            L = np.linalg.cholesky(eigvec @ np.diag(np.maximum(eigv, 1e-12)) @ eigvec.T)
        else:  # pragma: no cover - internal misuse
            raise ValueError(f"unknown non_pd fallback {non_pd!r}")
    z = np.asarray(normal(m.size, int(M)), dtype=float).reshape(m.size, int(M))
    return m[:, None] + L @ z


def _lambda_delta_from_state(
    x_state: np.ndarray,
    mu: np.ndarray,
    beta: np.ndarray,
    fitType: str,
    gamma: np.ndarray,
    HkAll: np.ndarray,
    time_index: int,
) -> np.ndarray:
    histterm = np.asarray(HkAll[time_index], dtype=float) if HkAll.size else np.zeros((0, mu.size), dtype=float)
    hist_effect = np.sum(gamma * histterm, axis=0) if histterm.size else np.zeros(mu.shape, dtype=float)
    lin_term = mu + beta.T @ x_state + hist_effect
    if fitType not in ("binomial", "poisson"):
        raise ValueError("fitType must be either 'poisson' or 'binomial'")
    return _matlab_lambda_delta(lin_term, fitType == "binomial")


def _matlab_lambda_delta(lin_term: np.ndarray, binomial: bool) -> np.ndarray:
    """lambda*delta of MATLAB ``PPAF.PPDecode_updateLinear``.

    ``exp(linTerm)`` (poisson) or ``exp(linTerm)./(1+exp(linTerm))`` (binomial),
    with no clipping, then every NaN / Inf entry set to 1 (the binomial form is
    Inf/Inf = NaN once exp overflows).  This port used to clip linTerm to
    [-20, 20], which changed the update wherever |linTerm| > 20 (e.g. a
    separated history coefficient in PP_EM) and has no MATLAB counterpart.
    """
    lin_term = np.asarray(lin_term, dtype=float)
    if lin_term.size and np.max(lin_term) < 709.0:
        # exp cannot overflow, so nothing is non-finite: the same values without
        # the per-call errstate / where (this runs once per decoder time step).
        exp_term = np.exp(lin_term)
        return exp_term / (1.0 + exp_term) if binomial else exp_term
    with np.errstate(over="ignore", invalid="ignore"):
        exp_term = np.exp(lin_term)
        lam = exp_term / (1.0 + exp_term) if binomial else exp_term
    return np.where(np.isfinite(lam), lam, 1.0)


def _matlab_float_semantics(func):
    """Run ``func`` with NumPy's overflow / divide / invalid warnings off.

    The EM routines mirror MATLAB's unguarded arithmetic (``exp`` overflowing
    to Inf, ``log(0) = -Inf``, ``0/0 = NaN``, a zero scalar Hessian giving
    +-Inf), which MATLAB evaluates silently; the non-finite results are then
    handled exactly where MATLAB handles them (the Newton steps' NaN checks,
    the EM drivers' non-finite log-likelihood stop).
    """
    import functools

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        with np.errstate(over="ignore", divide="ignore", invalid="ignore"):
            return func(*args, **kwargs)

    return wrapper


def _matlab_mldivide(H, g):
    """MATLAB ``H\\g`` for a Newton step (square ``H``, vector ``g``).

    The algorithm of MATLAB's mldivide for a full square matrix: elementwise
    division when ``H`` is 1 x 1; forward substitution when ``H`` is lower
    triangular (a diagonal ``H`` counts as lower), back substitution when it
    is upper triangular; otherwise LU with partial pivoting (the pivot is the
    first entry of largest magnitude) and the two triangular solves.  Every
    pivot is divided by, never multiplied by its reciprocal, and no zero is
    skipped, so
      * a denormal pivot (the Hessian entry of a history coefficient whose
        ``exp`` has nearly underflowed, as in a separated window) gives the
        finite step MATLAB takes: LAPACK's ``getrs`` / ``trsm``
        (``np.linalg.solve``, ``scipy.linalg.solve`` / ``lu_solve``) multiply
        by ``1/pivot``, which overflows to Inf for ``|pivot| < 5.6e-309``,
        and turned that step into -Inf;
      * an exactly singular ``H`` gives MATLAB's +-Inf / NaN (with MATLAB's
        "singular to working precision" warning, which is not reproduced)
        instead of raising; the callers then do what MATLAB does: a step
        with a NaN keeps the previous value, an infinite one is taken.
    Bit-identical to MATLAB R2025b's ``H\\g`` on the ``mldivide_*`` systems
    of ``em_drivers.mat`` (denormal, singular, ill-conditioned, triangular,
    Hessenberg, permuted triangular, symmetric definite of either sign) and
    on the Newton walk there; MATLAB solves the symmetric positive definite
    ones by LU too.  The Hessians are small (one cell's states or history
    windows), so the Python loop costs microseconds per step.
    """
    H = np.asarray(H, dtype=float)
    g = np.asarray(g, dtype=float)
    if H.size == 1:
        return np.divide(g, H.reshape(()))
    b = g.reshape(-1).copy()
    n = b.size
    with np.errstate(over="ignore", divide="ignore", invalid="ignore"):
        if not np.any(np.triu(H, 1)):  # lower triangular (or diagonal)
            for k in range(n):
                b[k] = b[k] / H[k, k]
                b[k + 1:] -= b[k] * H[k + 1:, k]
            return b.reshape(g.shape)
        if np.any(np.tril(H, -1)):  # general: LU with partial pivoting
            LU = H.copy()
            for k in range(n - 1):
                col = np.abs(LU[k:, k])
                # BLAS idamax: the first entry of largest magnitude; a NaN is
                # never larger than anything (it is chosen only in first place).
                if np.isnan(col[0]):
                    p = k
                else:
                    p = k + int(np.argmax(np.where(np.isnan(col), -np.inf, col)))
                if p != k:
                    LU[[k, p]] = LU[[p, k]]
                    b[[k, p]] = b[[p, k]]
                if LU[k, k] != 0:
                    LU[k + 1:, k] = LU[k + 1:, k] / LU[k, k]
                LU[k + 1:, k + 1:] -= np.outer(LU[k + 1:, k], LU[k, k + 1:])
            for k in range(n):  # unit lower triangular L
                b[k + 1:] -= b[k] * LU[k + 1:, k]
            H = LU
        for k in range(n - 1, -1, -1):  # upper triangular (H itself or U)
            b[k] = b[k] / H[k, k]
            b[:k] -= b[k] * H[:k, k]
    return b.reshape(g.shape)


def _matlab_mldivide_matrix(H, G):
    """MATLAB ``H\\G`` for a square ``H`` and a matrix (or vector) ``G``.

    MATLAB's mldivide factorizes ``H`` once and solves for every column of
    ``G`` with the same factorization; looping :func:`_matlab_mldivide`
    (which redoes the triangular/general detection and factorization per
    column) gives the identical result since the pivoting depends on ``H``
    alone, at the cost of some duplicated work -- the Hessians / covariance
    matrices this is used on are small (a handful of states or history
    windows), so that cost is microseconds.  Used where MATLAB's M-step /
    standard-error closed forms solve with a matrix right-hand side (for
    example ``Sxkm1xkm1\\Sxkxkm1`` by way of ``_matlab_mrdivide``, or
    ``Px0\\x0`` and ``inv(Px0)`` below, both of which have a vector or a
    square matrix on the right).
    """
    H = np.asarray(H, dtype=float)
    G = np.asarray(G, dtype=float)
    if G.ndim == 1:
        return _matlab_mldivide(H, G)
    return np.column_stack([_matlab_mldivide(H, G[:, j]) for j in range(G.shape[1])])


def _matlab_mrdivide(A, B):
    """MATLAB ``A/B`` (square ``B``) via the identity ``A/B = (B'\\A')'``."""
    A = np.asarray(A, dtype=float)
    B = np.asarray(B, dtype=float)
    return _matlab_mldivide_matrix(B.T, A.T).T


def _matlab_inv(A):
    """Approximation of MATLAB ``inv(A)`` via ``A \\ eye(n)``: the same
    LU/triangular detection as :func:`_matlab_mldivide`, so an exactly
    singular ``A`` gives +-Inf / NaN (with its unreproduced "singular to
    working precision" warning) instead of raising ``LinAlgError``.

    On a well-conditioned ``A`` this agrees with MATLAB's own ``inv`` to
    round-off (not verified bit-identical: likely a different LAPACK
    routine, ``getrs`` against the identity here vs. ``inv``'s own
    algorithm). On an EXACTLY singular ``A`` it is NOT even verified to
    agree with MATLAB's ``inv`` builtin, which can differ in the *sign
    pattern* of the resulting
    Inf entries from ``A \\ eye(n)``: checked directly against MATLAB
    R2026a, ``inv([1 1;1 1])`` gives all ``+Inf``, while
    ``[1 1;1 1] \\ eye(2)`` gives ``[Inf -Inf; -Inf Inf]`` -- evidently
    different internal algorithms (``inv`` likely uses LAPACK ``getri``
    on the LU factors directly, not ``getrs`` against the identity).
    Every call site here (``Qinv``, ``Px0inv`` in the EM standard-error
    routines) is not reached by any gold input (see
    ``parity/matlab_defects.yml``: ``em-closed-form-solves-reciprocal-pivot``),
    so this does not corrupt any currently-tested result; it reliably
    signals "singular, do not trust this value" (Inf/NaN rather than a
    raised exception) either way, which is what the EM drivers' own
    finite-log-likelihood stop depends on, but the exact Inf sign
    pattern should not be relied on as a MATLAB-bit-exact result.
    """
    A = np.asarray(A, dtype=float)
    n = A.shape[0]
    return _matlab_mldivide_matrix(A, np.eye(n))


def _matlab_nearest_spd(A) -> np.ndarray:
    """MATLAB ``nearestSPD`` (J. D'Errico; ``libraries/NearestSymmetricPositiveDefinite``),
    as the EM standard-error routines call it on the inverse observed information.

    Higham's projection ``(B + H)/2`` of ``B = (A + A')/2`` (``H`` the
    symmetric polar factor, from the SVD), then, while ``chol`` fails,
    ``Ahat += (-mineig*k^2 + eps(mineig)) * I`` for ``k = 1, 2, ...`` with
    ``mineig = min(eig(Ahat))``; a 1 x 1 ``A <= 0`` returns ``eps``.  The
    port used ``spacing(norm(A))`` for ``eps(mineig)`` and stopped after 100
    (PP) / 50 (PPLFP) passes.
    """
    A = np.asarray(A, dtype=float)
    r, c = A.shape
    if r != c:
        raise ValueError("A must be a square matrix.")
    if r == 1 and A[0, 0] <= 0:
        return np.array([[np.finfo(float).eps]])
    B = (A + A.T) / 2.0
    _, Sigma, Vt = np.linalg.svd(B)
    H = Vt.T @ np.diag(Sigma) @ Vt
    Ahat = (B + H) / 2.0
    Ahat = (Ahat + Ahat.T) / 2.0
    I = np.eye(r)
    k = 0
    while True:
        try:
            np.linalg.cholesky(Ahat)
            return Ahat
        except np.linalg.LinAlgError:
            pass
        k += 1
        mineig = float(np.min(np.linalg.eigvalsh(Ahat)))
        if not np.isfinite(mineig):  # MATLAB: eig of a non-finite matrix errors
            raise np.linalg.LinAlgError("nearestSPD: non-finite matrix")
        Ahat = Ahat + (-mineig * k ** 2 + np.spacing(abs(mineig))) * I


def _matlab_ztest_p(param, se):
    """Two-sided p-value of MATLAB ``ztest(param, 0, se)``: ``2*normcdf(-|param/se|)``.

    ``se = 0`` gives ``z = +-Inf`` and ``p = 0`` (``NaN`` for ``param = 0``),
    ``se = Inf`` gives ``p = 1`` and a NaN propagates.  The port computed
    ``2*(1 - normcdf(|z|))``, which underflows to 0 for ``|z| > 8.3`` (the
    gold p-values reach 1e-295), and returned 1 for ``se <= 0``.
    """
    from scipy.stats import norm  # lazy: keep scipy.stats out of `import nstat`

    with np.errstate(divide="ignore", invalid="ignore"):
        z = np.asarray(param, dtype=float) / np.asarray(se, dtype=float)
    return 2.0 * norm.cdf(-np.abs(z))


def _em_se_term_labels(groups) -> list[str]:
    """Names of the entries of an EM routine's stacked SE parameter vector.

    ``groups`` lists ``(name, n_terms, shape, layout)`` in stacking order;
    ``layout`` is ``"square"`` (a covariance-like or A-like matrix: ``n`` =
    rows*cols is row by row, ``n`` = rows its diagonal, ``n`` = 1 an
    isotropic value), ``"rowmajor"``, ``"cellmajor"`` (a states- or
    windows-by-cells matrix, one cell's column after another) or
    ``"vector"``.  Indices are zero-based, as in the returned arrays.
    """
    labels: list[str] = []
    for name, n, shape, layout in groups:
        if n <= 0:
            continue
        shape = tuple(int(v) for v in shape) + (1, 1)
        r, c = shape[0], shape[1]
        if layout == "vector" and n == r:
            labels += [f"{name}[{i}]" for i in range(r)]
        elif layout == "cellmajor" and n == r * c:
            labels += [f"{name}[{i}, {j}]" for j in range(c) for i in range(r)]
        elif layout in ("square", "rowmajor") and n == r * c:
            labels += [f"{name}[{i}, {j}]" for i in range(r) for j in range(c)]
        elif layout == "square" and n == r:
            labels += [f"{name}[{i}, {i}]" for i in range(r)]
        elif layout == "square" and n == 1:
            labels.append(f"{name} (isotropic)")
        else:
            labels += [f"{name} term {k}" for k in range(n)]
    return labels


def _em_singular_information_inverse(IObs, labels, routine):
    """Python-only inverse of an exactly singular observed information matrix.

    MATLAB's SE routines compute ``eye(n)/IObs``, which is Inf / NaN here, and
    ``nearestSPD`` of it then loops forever (in R2025b ``svd`` / ``eig`` of NaN
    return NaN and ``chol`` keeps failing), so MATLAB never returns.  This port
    returns, with these semantics:

    * the inverse is the pseudo-inverse of ``IObs`` (singular values at or
      below ``1e-15`` times the largest are dropped, NumPy's ``pinv``
      default);
    * a parameter is *not identifiable* when its unit vector has a component
      larger than ``sqrt(eps)`` in the null space of ``IObs`` (the dropped
      right singular vectors): the log-likelihood is flat along that
      direction, so the data determine neither the estimate nor its
      precision.  Its SE and p-value are NaN (the pseudo-inverse would give it
      an SE of about 1e-8 and p = 0).  The usual cause is a separated history
      window (no spike in it is followed by a spike), whose coefficient the
      Newton steps walk to the ``exp()`` underflow, where its information and
      score are exactly 0;
    * a ``RuntimeWarning`` names those parameters.

    Returns ``(invIObs, nonidentifiable)``, the latter a boolean mask over the
    stacked parameter vector (in ``labels`` order).
    """
    IObs = np.asarray(IObs, dtype=float)
    U, s, Vt = np.linalg.svd(IObs)
    keep = s > 1e-15 * (s.max() if s.size else 0.0)
    invIObs = (Vt[keep].T / s[keep]) @ U[:, keep].T
    weight = np.sqrt(np.sum(Vt[~keep] ** 2, axis=0))
    nonid = weight > np.sqrt(np.finfo(float).eps)
    names = [labels[i] if i < len(labels) else f"term {i}" for i in np.flatnonzero(nonid)]
    message = (f"{routine}: the observed information matrix is singular (MATLAB's SE pass never returns "
               "here); the standard errors come from its pseudo-inverse.")
    if names:
        message += (" Not identifiable from the data, SE and p-value set to NaN: " + ", ".join(names)
                    + ".  The log-likelihood is flat along them; a history coefficient is not identifiable "
                    "when no spike in its window is followed by a spike (a separated window).")
    warnings.warn(message, RuntimeWarning, stacklevel=2)
    return invIObs, nonid


def _em_project_covariance(invIObs, nonid, nearest_spd):
    """``nearestSPD`` projection of the inverse observed information (EM SE routines).

    Without non-identifiable parameters (``nonid`` is ``None`` or all
    ``False``) this is ``nearest_spd(invIObs)``, unchanged.  Otherwise only the
    identifiable block is projected, as in MATLAB (nSTAT PR #137, issue #136):
    ``nearestSPD`` does not return on a singular matrix (when ``chol`` fails
    while ``min(eig)`` is a tiny positive rounding value its shift
    ``-mineig*k^2 + eps(mineig)`` is negative), and the pseudo-inverse from
    :func:`_em_singular_information_inverse` is singular along the
    non-identifiable directions, whose SEs are reported as NaN anyway.  Their
    rows and columns are left as they are.
    """
    if nonid is None or not np.any(nonid):
        return nearest_spd(invIObs)
    keep = ~np.asarray(nonid, dtype=bool)
    out = np.array(invIObs, dtype=float, copy=True)
    if keep.any():
        out[np.ix_(keep, keep)] = nearest_spd(out[np.ix_(keep, keep)])
    return out


def _likelihood_from_lambda(observed: np.ndarray, lambda_delta: np.ndarray, fitType: str) -> float:
    lam = np.clip(np.asarray(lambda_delta, dtype=float).reshape(-1), 1e-9, 1.0 - 1e-9 if fitType == "binomial" else np.inf)
    obs = np.asarray(observed, dtype=float).reshape(-1)
    if fitType == "binomial":
        log_prob = np.sum(obs * np.log(lam) + (1.0 - obs) * np.log(1.0 - lam))
    else:
        log_prob = np.sum(obs * np.log(lam) - lam)
    return float(np.exp(np.clip(log_prob, -200.0, 50.0)))


def _normalize_model_sequence(values, n_models: int, factory):
    if _is_empty_value(values):
        return [factory(index) for index in range(n_models)]
    if isinstance(values, Sequence) and not isinstance(values, (str, bytes, np.ndarray)):
        out = list(values)
        if len(out) == n_models:
            return out
    return [values for _ in range(n_models)]


def _normalize_beta_models(beta, n_models: int, num_cells: int, state_dims: list[int]) -> list[np.ndarray]:
    if isinstance(beta, Sequence) and not isinstance(beta, (str, bytes, np.ndarray)):
        items = list(beta)
        if len(items) == n_models and any(np.asarray(item).ndim >= 1 for item in items):
            return [_normalize_beta(item, state_dims[index], num_cells) for index, item in enumerate(items)]
    return [_normalize_beta(beta, state_dims[index], num_cells) for index in range(n_models)]


def _normalize_mu_models(mu, n_models: int, num_cells: int) -> list[np.ndarray]:
    if isinstance(mu, Sequence) and not isinstance(mu, (str, bytes, np.ndarray)):
        items = list(mu)
        if len(items) == n_models and any(np.asarray(item).ndim >= 0 for item in items):
            return [_normalize_mu(item, num_cells) for item in items]
    return [_normalize_mu(mu, num_cells) for _ in range(n_models)]


def _normalize_cif_collection(lambdaCIFColl) -> list[CIF]:
    if isinstance(lambdaCIFColl, CIF):
        cifs = [lambdaCIFColl]
    elif isinstance(lambdaCIFColl, Sequence) and not isinstance(lambdaCIFColl, (str, bytes)):
        cifs = list(lambdaCIFColl)
    else:
        raise UnsupportedWorkflowError("PPDecodeFilter requires a CIF or sequence of CIF objects for the Python port")
    if not cifs:
        raise ValueError("lambdaCIFColl must contain at least one CIF object")
    for cif in cifs:
        if not isinstance(cif, CIF):
            raise UnsupportedWorkflowError("PPDecodeFilter only supports CIF objects in the Python port")
    return cifs


def _extract_linear_terms_from_cifs(lambdaCIFColl, num_states: int, num_cells: int):
    cifs = _normalize_cif_collection(lambdaCIFColl)

    if len(cifs) != num_cells:
        raise ValueError("Number of CIF objects must match the number of observed cells")

    mu_terms: list[float] = []
    beta_cols: list[np.ndarray] = []
    fit_types: set[str] = set()
    history_coeffs: list[np.ndarray] = []
    history_windows = None

    for cif in cifs:
        if not isinstance(cif, CIF):
            raise UnsupportedWorkflowError("PPDecodeFilter only supports CIF objects in the Python port")
        coeffs = np.asarray(cif.b, dtype=float).reshape(-1)
        if coeffs.size == num_states + 1:
            mu_terms.append(float(coeffs[0]))
            beta_cols.append(coeffs[1:])
        elif coeffs.size == num_states:
            mu_terms.append(0.0)
            beta_cols.append(coeffs)
        elif coeffs.size == 1:
            mu_terms.append(float(coeffs[0]))
            beta_cols.append(np.zeros(num_states, dtype=float))
        else:
            raise ValueError("CIF coefficient length is incompatible with the decoding state dimension")

        fit_types.add(str(cif.fitType))
        history_coeffs.append(np.asarray(cif.histCoeffs, dtype=float).reshape(-1))
        if getattr(cif, "history", None) is not None:
            windows = np.asarray(cif.history.windowTimes, dtype=float).reshape(-1)
            if history_windows is None:
                history_windows = windows
            elif not np.allclose(history_windows, windows):
                raise UnsupportedWorkflowError("All CIF history objects must share the same windowTimes")

    if len(fit_types) != 1:
        raise UnsupportedWorkflowError("Mixed fitType collections are not yet supported by PPDecodeFilter")

    max_hist = max((coeff.size for coeff in history_coeffs), default=0)
    if max_hist > 0:
        gamma = np.column_stack(
            [
                np.pad(coeff, (0, max_hist - coeff.size), mode="constant", constant_values=0.0)
                for coeff in history_coeffs
            ]
        )
        if history_windows is None:
            history_windows = np.arange(max_hist + 1, dtype=float)
    else:
        gamma = None

    beta = np.column_stack(beta_cols) if beta_cols else np.zeros((num_states, num_cells), dtype=float)
    return np.asarray(mu_terms, dtype=float), beta, fit_types.pop(), gamma, history_windows




def _nearestSPD(A: np.ndarray) -> np.ndarray:
    """Find the nearest symmetric positive-definite matrix to *A*.

    Uses the algorithm of Higham (1988) via polar decomposition plus
    eigenvalue clamping (not MATLAB's exact ``nearestSPD`` -- see
    :func:`_matlab_nearest_spd` for that).

    Python-only guard, used solely by ``KF_EM``'s whitening Cholesky
    fallback (``cholesky(_nearestSPD(Q0))`` / ``cholesky(_nearestSPD(R0))``
    when the caller's starting ``Q0``/``R0`` is not exactly positive
    definite): MATLAB's ``chol`` simply errors there, so this fallback has
    no MATLAB counterpart to mirror and is kept as the Python-only
    eps-clamped projection it always was, by design simpler and always-
    terminating -- unlike :func:`_matlab_nearest_spd`'s unbounded shift loop,
    which does not return on an exactly singular input (see
    :func:`_em_singular_information_inverse`, which exists precisely because
    that loop cannot be used there).  KF's own standard-error pass now uses
    :func:`DecodingAlgorithms._nearestSPD`
    (:func:`_matlab_nearest_spd`) instead of this helper, aligning with
    PP/PPLFP; see ``tests/test_review_characterization.py`` section 1.
    """
    B = 0.5 * (A + A.T)
    _, S, Vt = np.linalg.svd(B)
    H = Vt.T @ np.diag(S) @ Vt
    Ahat = 0.5 * (B + H)
    Ahat = 0.5 * (Ahat + Ahat.T)
    # Test for positive-definiteness; clamp eigenvalues if needed
    try:
        np.linalg.cholesky(Ahat)
        return Ahat
    except np.linalg.LinAlgError:
        pass
    eigvals, eigvecs = np.linalg.eigh(Ahat)
    eigvals = np.maximum(eigvals, np.finfo(float).eps)
    Ahat = eigvecs @ np.diag(eigvals) @ eigvecs.T
    Ahat = 0.5 * (Ahat + Ahat.T)
    return Ahat


def _warn_mppco_deprecated(old: str, new: str) -> None:
    """Emit MATLAB's ``nSTAT:deprecated:mPPCO`` message as a DeprecationWarning.

    Text mirrors the MATLAB ``DecodingAlgorithms.mPPCO_*`` shims verbatim;
    ``stacklevel=3`` attributes the warning to the caller of the alias.
    """
    warnings.warn(
        f"DecodingAlgorithms.{old} is deprecated; "
        f"use DecodingAlgorithms.{new} instead. "
        "See \u00a74.B.7 for the PPLFP derivation.",
        DeprecationWarning,
        stacklevel=3,
    )


[docs] class DecodingAlgorithms: """Static-method library for neural decoding and state-space estimation. Provides Kalman filtering/smoothing, point-process adaptive filtering (PPAF), hybrid discrete–continuous decoding, unscented Kalman filtering (UKF), state-space GLM EM algorithms (SSGLM), and mixed point-process / continuous-observation EM algorithms (``PPLFP_*``, with the deprecated ``mPPCO_*`` aliases). All methods are ``@staticmethod``; no instance is required. Method signatures follow the Matlab ``DecodingAlgorithms`` class as closely as possible. See Also -------- CIF : Conditional intensity function objects used by ``PPDecodeFilter``. Analysis : High-level fitting routines that call these decoders. """
[docs] @staticmethod def PPDecodeFilter(A, Q, Px0, dN, lambdaCIFColl, binwidth=0.001, x0=None, Pi0=None, yT=None, PiT=None, estimateTarget=0, Wconv=None): """Point-process adaptive filter using CIF object collection. Runs the full forward filter loop, evaluating CIF objects at each time step. When *yT* / *PiT* are supplied, delegates to the linear-parameter variant with goal-state estimation. Returns ``(x_p, W_p, x_u, W_u, xT, WT, MT, MT_cov)``. """ obs = _as_observation_matrix(dN) lambda_items = _normalize_cif_collection(lambdaCIFColl) num_cells, num_steps = obs.shape if len(lambda_items) != num_cells: raise ValueError("Number of CIF objects must match the number of observed cells") num_states = _infer_state_dim(A, np.array([0.0]), num_cells) uses_target_branch = not _is_empty_value(yT) or not _is_empty_value(PiT) or int(estimateTarget) != 0 if uses_target_branch: mu, beta, fitType, gamma, windowTimes = _extract_linear_terms_from_cifs(lambda_items, num_states, num_cells) initial_cov = Px0 if _is_empty_value(Pi0) else Pi0 return DecodingAlgorithms._ppdecode_filter_linear( A, Q, obs, mu, beta, fitType, binwidth, gamma, windowTimes, x0, initial_cov, yT, PiT, estimateTarget, Wconv, ) x0_vec = np.zeros(num_states, dtype=float) if _is_empty_value(x0) else np.asarray(x0, dtype=float).reshape(-1) if x0_vec.size != num_states: raise ValueError("x0 must match the decoding state dimension") # MATLAB PPDecodeFilter's standard branch initializes from Pi0, and # when Pi0 is omitted it falls back to zeros rather than using Px0. Pi0_mat = np.zeros((num_states, num_states), dtype=float) if _is_empty_value(Pi0) else _as_state_matrix(Pi0, num_states) x_p = np.zeros((num_states, num_steps + 1), dtype=float) x_u = np.zeros((num_states, num_steps), dtype=float) W_p = np.zeros((num_states, num_states, num_steps + 1), dtype=float) W_u = np.zeros((num_states, num_states, num_steps), dtype=float) A0 = _select_time_matrix(A, 0, num_states) Q0 = _select_time_matrix(Q, 0, num_states) x_p[:, 0], W_p[:, :, 0] = DecodingAlgorithms.PPDecode_predict(x0_vec, Pi0_mat, A0, Q0, Wconv) for time_index in range(num_steps): x_u[:, time_index], W_u[:, :, time_index], _ = DecodingAlgorithms.PPDecode_update( x_p[:, time_index], W_p[:, :, time_index], obs, lambda_items, binwidth, time_index, None, ) A_t = _select_time_matrix(A, time_index, num_states) Q_t = _select_time_matrix(Q, time_index, num_states) x_p[:, time_index + 1], W_p[:, :, time_index + 1] = DecodingAlgorithms.PPDecode_predict( x_u[:, time_index], W_u[:, :, time_index], A_t, Q_t, Wconv, ) empty_vec = np.array([], dtype=float) empty_cov = np.zeros((0, 0, 0), dtype=float) return x_p, W_p, x_u, W_u, empty_vec, empty_cov, empty_vec, empty_cov
[docs] @staticmethod def PPDecodeFilterLinear(*args, **kwargs): """Point-process adaptive filter using linear GLM parameters. Dispatches to ``_ppdecode_filter_linear`` when a ``fitType`` string is present, otherwise falls back to ``kalman_filter``. Matches the Matlab ``DecodingAlgorithms.PPDecodeFilterLinear`` signature. """ if len(args) >= 6 and isinstance(args[5], str): return DecodingAlgorithms._ppdecode_filter_linear(*args, **kwargs) if "fitType" in kwargs or "delta" in kwargs: return DecodingAlgorithms._ppdecode_filter_linear(*args, **kwargs) return DecodingAlgorithms.kalman_filter(*args, **kwargs)
[docs] @staticmethod def PP_fixedIntervalSmoother(A, Q, dN, lags, mu, beta, fitType="poisson", delta=0.001, gamma=None, windowTimes=None, x0=None, Pi0=None): """Point-process fixed-interval (fixed-lag) smoother. Runs ``PPDecode_updateLinear`` forward, then applies backward RTS-style smoothing at each step with the specified number of *lags*. Returns ``(x_pLag, W_pLag, x_uLag, W_uLag)``. """ obs = _as_observation_matrix(dN) num_cells, num_steps = obs.shape num_states = _infer_state_dim(A, beta, num_cells) mu_vec = _normalize_mu(mu, num_cells) beta_mat = _normalize_beta(beta, num_states, num_cells) x0_vec = np.zeros(num_states, dtype=float) if _is_empty_value(x0) else np.asarray(x0, dtype=float).reshape(-1) if x0_vec.size != num_states: raise ValueError("x0 must match the decoding state dimension") Pi0_mat = np.zeros((num_states, num_states), dtype=float) if _is_empty_value(Pi0) else _as_state_matrix(Pi0, num_states) if _is_empty_value(windowTimes): H_tensor = np.zeros((num_steps, 0, num_cells), dtype=float) gamma_mat = np.zeros((0, num_cells), dtype=float) else: H_tensor = _compute_history_terms(obs, float(delta), windowTimes) gamma_mat = _normalize_gamma(gamma, H_tensor.shape[1], num_cells) x_p = np.zeros((num_states, num_steps + 1), dtype=float) x_u = np.zeros((num_states, num_steps), dtype=float) W_p = np.zeros((num_states, num_states, num_steps + 1), dtype=float) W_u = np.zeros((num_states, num_states, num_steps), dtype=float) x_p[:, 0] = x0_vec W_p[:, :, 0] = Pi0_mat lag_count = max(int(lags), 1) num_states, num_steps = x_u.shape x_uLag = np.zeros_like(x_u) W_uLag = np.zeros_like(W_u) x_pLag = np.zeros_like(x_p) W_pLag = np.zeros_like(W_p) for n in range(num_steps): x_u[:, n], W_u[:, :, n], _ = DecodingAlgorithms.PPDecode_updateLinear( x_p[:, n], W_p[:, :, n], obs, mu_vec, beta_mat, fitType, gamma_mat, H_tensor, n, None, ) A_t = _select_time_matrix(A, n, num_states) Q_t = _select_time_matrix(Q, n, num_states) x_p[:, n + 1], W_p[:, :, n + 1] = DecodingAlgorithms.PPDecode_predict( x_u[:, n], W_u[:, :, n], A_t, Q_t, ) if n < lag_count: # MATLAB (PPAF.m:684-714) still runs its output block for the # first ``lags`` steps with x_K = W_K = 0: x_uLag/W_uLag stay # zero, and x_pLag(:,n+1) = x_K(:,lags-1) = 0 for lags > 1, but # with lags == 1 it is x_u(:,n) / W_u(:,:,n). if lag_count == 1: x_pLag[:, n + 1] = x_u[:, n] W_pLag[:, :, n + 1] = W_u[:, :, n] continue x_bank: list[np.ndarray] = [] w_bank: list[np.ndarray] = [] for k in range(1, lag_count + 1): idx = n - k A_k = _select_time_matrix(A, idx, num_states) gain = W_u[:, :, idx] @ A_k.T @ np.linalg.pinv(W_p[:, :, idx + 1]) target_x = x_u[:, idx + 1] if k == 1 else x_bank[k - 2] target_W = W_u[:, :, idx + 1] if k == 1 else w_bank[k - 2] x_k = x_u[:, idx] + gain @ (target_x - x_p[:, idx + 1]) W_k = W_u[:, :, idx] + gain @ (target_W - W_p[:, :, idx + 1]) @ gain.T W_k = _symmetrize(W_k) x_bank.append(x_k) w_bank.append(W_k) x_uLag[:, n] = x_bank[-1] W_uLag[:, :, n] = w_bank[-1] if lag_count > 1: x_pLag[:, n + 1] = x_bank[-2] W_pLag[:, :, n + 1] = w_bank[-1] else: x_pLag[:, n + 1] = x_u[:, n] W_pLag[:, :, n + 1] = W_u[:, :, n] return x_pLag, W_pLag, x_uLag, W_uLag
[docs] @staticmethod def PPDecode_predict(x_u, W_u, A, Q, Wconv=None): """Point-process adaptive filter predict step. Returns ``(x_p, W_p)`` — predicted state and covariance. """ x_vec = np.asarray(x_u, dtype=float).reshape(-1) dim = x_vec.size W_mat = _as_state_matrix(W_u, dim) A_mat = _as_state_matrix(A, dim) if Wconv is None or Wconv == []: Q_mat = _as_state_matrix(Q, dim) W_p = A_mat @ W_mat @ A_mat.T + Q_mat # Matlab: if rcond(W_p) < eps or NaN, fall back to W_u. # We mirror the singular/NaN guard cheaply: ``np.linalg.cond`` # (default 2-norm) calls SVD on every step and dominated the # profile (~21% of total time @ 30k calls). ``np.linalg.det`` # is a single LU and ~4x cheaper for small state matrices, # while capturing the same ``rcond ≈ 0`` signal: a near-zero # determinant relative to the matrix norm is exactly the # condition rcond detects. ``isfinite`` covers the NaN branch. if not np.all(np.isfinite(W_p)): W_p = W_mat.copy() else: det_W = np.linalg.det(W_p) # Use Frobenius norm as the normalising scale so the test is # invariant to the magnitude of W_p (matches rcond intent). scale = float(np.sqrt(np.sum(W_p * W_p))) ** dim if not np.isfinite(det_W) or (scale > 0 and abs(det_W) / scale < np.finfo(float).eps): W_p = W_mat.copy() W_p = _symmetrize(W_p) else: W_p = _symmetrize(_as_state_matrix(Wconv, dim)) x_p = A_mat @ x_vec return x_p, W_p
[docs] @staticmethod def PPDecode_update(x_p, W_p, dN, lambdaIn, binwidth=0.001, time_index=0, WuConv=None): """Point-process adaptive filter update step using CIF objects. Evaluates symbolic CIF gradients and Jacobians for the Newton-step posterior update. Returns ``(x_u, W_u, lambda_delta)``. """ x_vec = np.asarray(x_p, dtype=float).reshape(-1) W_mat = _as_state_matrix(W_p, x_vec.size) obs = _as_observation_matrix(dN) idx = max(0, min(int(time_index), obs.shape[1] - 1)) if isinstance(lambdaIn, CIF): lambda_items = [lambdaIn] elif isinstance(lambdaIn, Sequence) and not isinstance(lambdaIn, (str, bytes)): lambda_items = list(lambdaIn) else: raise ValueError("Lambda must be a cell of CIFs or a CIF") if not lambda_items: raise ValueError("Lambda must be a non-empty cell of CIFs or a CIF") lambda_delta = np.zeros((len(lambda_items), 1), dtype=float) sum_val_vec = np.zeros(x_vec.size, dtype=float) sum_val_mat = np.zeros((x_vec.size, x_vec.size), dtype=float) observed = obs[:, idx] for cell_index, cif in enumerate(lambda_items): # Fast path: history-free CIFs do not consume nst.minTime, # nst.maxTime, or binned spike counts — _history_values returns # zeros(0) regardless of nst when (history is None or histCoeffs # is empty). Rebuilding+resampling an nspikeTrain every step # cost O(idx) inside _build_sigrep, making the full forward # pass O(C*T^2); skipping it restores the O(C*T) complexity. if cif.historyMat.size == 0 and (cif.history is None or cif.histCoeffs.size == 0): lambda_delta[cell_index, 0] = float(cif.evalLambdaDelta(x_vec, idx, None)) sum_val_vec += observed[cell_index] * np.asarray(cif.evalGradientLog(x_vec, idx, None), dtype=float).reshape(-1) sum_val_vec -= np.asarray(cif.evalGradient(x_vec, idx, None), dtype=float).reshape(-1) sum_val_mat -= np.asarray(cif.evalJacobianLog(x_vec, idx, None), dtype=float) sum_val_mat += np.asarray(cif.evalJacobian(x_vec, idx, None), dtype=float) elif cif.historyMat.size == 0: # Edge case: History object attached but no spike train ever # set, so historyMat is empty yet histCoeffs is non-trivial. # The slow rebuild+resample produces real history values # from the observed prefix; preserve the prior numerics. observed_prefix = obs[cell_index, : idx + 1] spike_times = np.where(observed_prefix > 0.5)[0] * float(binwidth) nst = nspikeTrain(spike_times, makePlots=-1) nst.setMinTime(0.0) nst.setMaxTime(idx * float(binwidth)) nst = nst.resample(1.0 / float(binwidth)) lambda_delta[cell_index, 0] = float(cif.evalLambdaDelta(x_vec, idx, nst)) sum_val_vec += observed[cell_index] * np.asarray(cif.evalGradientLog(x_vec, idx, nst), dtype=float).reshape(-1) sum_val_vec -= np.asarray(cif.evalGradient(x_vec, idx, nst), dtype=float).reshape(-1) sum_val_mat -= np.asarray(cif.evalJacobianLog(x_vec, idx, nst), dtype=float) sum_val_mat += np.asarray(cif.evalJacobian(x_vec, idx, nst), dtype=float) else: lambda_delta[cell_index, 0] = float(cif.evalLambdaDelta(x_vec, idx)) sum_val_vec += observed[cell_index] * np.asarray(cif.evalGradientLog(x_vec, idx), dtype=float).reshape(-1) sum_val_vec -= np.asarray(cif.evalGradient(x_vec, idx), dtype=float).reshape(-1) sum_val_mat -= np.asarray(cif.evalJacobianLog(x_vec, idx), dtype=float) sum_val_mat += np.asarray(cif.evalJacobian(x_vec, idx), dtype=float) if _is_empty_value(WuConv): identity = np.eye(W_mat.shape[0], dtype=float) try: W_u = W_mat @ (identity - np.linalg.solve(identity + sum_val_mat @ W_mat, sum_val_mat @ W_mat)) except np.linalg.LinAlgError: W_u = W_mat.copy() W_u = _symmetrize(W_u) else: W_u = _symmetrize(_as_state_matrix(WuConv, x_vec.size)) x_u = x_vec + W_u @ sum_val_vec return x_u, W_u, lambda_delta
[docs] @staticmethod def PPDecode_updateLinear(x_p, W_p, dN, mu, beta, fitType="poisson", gamma=None, HkAll=None, time_index=0, WuConv=None): """Point-process adaptive filter update step using linear parameters. Uses ``mu``, ``beta``, and optional ``gamma`` history coefficients instead of CIF objects. Returns ``(x_u, W_u, lambda_delta)``. As MATLAB's ``PPDecode_updateLinear``, ``lambda_delta`` is ``exp(linTerm)`` (binomial ``exp./(1+exp)``), unclipped, with NaN / Inf entries set to 1 (this port clipped ``linTerm`` to [-20, 20]). """ x_vec = np.asarray(x_p, dtype=float).reshape(-1) W_mat = _as_state_matrix(W_p, x_vec.size) obs = _as_observation_matrix(dN) num_cells = obs.shape[0] mu_vec = _normalize_mu(mu, num_cells) beta_mat = _normalize_beta(beta, x_vec.size, num_cells) h_num_windows = 0 if _is_empty_value(HkAll) else np.asarray(HkAll).shape[1] H_tensor = _normalize_history_tensor(HkAll, obs.shape[1], h_num_windows, num_cells) num_windows = H_tensor.shape[1] gamma_mat = _normalize_gamma(gamma, num_windows, num_cells) lambda_delta = _lambda_delta_from_state(x_vec, mu_vec, beta_mat, str(fitType), gamma_mat, H_tensor, int(time_index)) observed = obs[:, int(time_index)] if str(fitType) == "binomial": factor = (observed - lambda_delta) * (1.0 - lambda_delta) temp_vec = (observed + (1.0 - 2.0 * lambda_delta)) * (1.0 - lambda_delta) * lambda_delta else: factor = observed - lambda_delta temp_vec = lambda_delta sum_val_vec = np.sum(beta_mat * factor[None, :], axis=1) sum_val_mat = (beta_mat * temp_vec[None, :]) @ beta_mat.T if _is_empty_value(WuConv): identity = np.eye(W_mat.shape[0], dtype=float) try: W_u = W_mat @ (identity - np.linalg.solve(identity + sum_val_mat @ W_mat, sum_val_mat @ W_mat)) except np.linalg.LinAlgError: W_u = W_mat.copy() W_u = _symmetrize(W_u) else: W_u = _symmetrize(_as_state_matrix(WuConv, x_vec.size)) x_u = x_vec + W_u @ sum_val_vec return x_u, W_u, lambda_delta.reshape(-1, 1)
@staticmethod def _ppdecode_filter_linear( A, Q, dN, mu, beta, fitType="poisson", delta=0.001, gamma=None, windowTimes=None, x0=None, Pi0=None, yT=None, PiT=None, estimateTarget=0, Wconv=None, ): obs = _as_observation_matrix(dN) num_cells, num_steps = obs.shape N = num_steps ns = _infer_state_dim(A, beta, num_cells) mu_vec = _normalize_mu(mu, num_cells) beta_mat = _normalize_beta(beta, ns, num_cells) x0_vec = np.zeros(ns, dtype=float) if _is_empty_value(x0) else np.asarray(x0, dtype=float).reshape(-1) if x0_vec.size != ns: raise ValueError("x0 must match the decoding state dimension") Pi0_mat = np.zeros((ns, ns), dtype=float) if _is_empty_value(Pi0) else _as_state_matrix(Pi0, ns) if _is_empty_value(windowTimes): H_tensor = np.zeros((N, 0, num_cells), dtype=float) gamma_mat = np.zeros((0, num_cells), dtype=float) else: H_tensor = _compute_history_terms(obs, float(delta), windowTimes) gamma_mat = _normalize_gamma(gamma, H_tensor.shape[1], num_cells) # ------------------------------------------------------------------ # Target estimation branch (Srinivasan et al. 2006) # ------------------------------------------------------------------ has_target = not _is_empty_value(yT) yT_vec = np.asarray(yT, dtype=float).reshape(-1) if has_target else np.array([], dtype=float) estimateTarget = int(estimateTarget) if has_target: PiT_mat = _as_state_matrix(PiT, ns) if not _is_empty_value(PiT) else np.zeros((ns, ns), dtype=float) # Backward information matrices (Srinivasan Eq. 2.16) PitT = np.zeros((ns, ns, N), dtype=float) QT = np.zeros((ns, ns, N), dtype=float) QN = _select_time_matrix(Q, N - 1, ns) if estimateTarget == 1: PitT[:, :, N - 1] = QN # Pi(T,T) = Q_T when PiT = 0 else: PitT[:, :, N - 1] = PiT_mat + QN # Backward transition matrices PhitT = np.zeros((ns, ns, N), dtype=float) PhitT[:, :, N - 1] = np.eye(ns, dtype=float) # phi(T,T) = I B = np.zeros((ns, ns, N), dtype=float) for n in range(N - 1, 0, -1): An = _select_time_matrix(A, n, ns) Qn = _select_time_matrix(Q, n, ns) invA = np.linalg.pinv(An) PhitT[:, :, n - 1] = invA @ PhitT[:, :, n] PitT[:, :, n - 1] = invA @ PitT[:, :, n] @ invA.T + Qn # Eq. 2.16 invPitT_n = np.linalg.pinv(PitT[:, :, n]) B[:, :, n] = An - (Qn @ invPitT_n) @ An # Eq. 2.21 QT[:, :, n] = Qn - (Qn @ invPitT_n) @ Qn.T A1 = _select_time_matrix(A, 0, ns) Q1 = _select_time_matrix(Q, 0, ns) invPitT_0 = np.linalg.pinv(PitT[:, :, 0]) B[:, :, 0] = A1 - (Q1 @ invPitT_0) @ A1 QT[:, :, 0] = Q1 - (Q1 @ invPitT_0) @ Q1.T if estimateTarget == 1: # Augmented state space [x_t; y_T] beta_aug = np.vstack([beta_mat, np.zeros((ns, num_cells), dtype=float)]) na = 2 * ns Amat = np.zeros((na, na, N), dtype=float) Qmat = np.zeros((na, na, N), dtype=float) for n in range(N): An = _select_time_matrix(A, n, ns) Qn = _select_time_matrix(Q, n, ns) psi = B[:, :, n] if n == N - 1: gammaMat = np.eye(ns, dtype=float) else: invPitT_n = np.linalg.pinv(PitT[:, :, n]) gammaMat = (Qn @ invPitT_n) @ PhitT[:, :, n] Amat[:ns, :ns, n] = psi Amat[:ns, ns:, n] = gammaMat Amat[ns:, ns:, n] = np.eye(ns, dtype=float) Qmat[:ns, :ns, n] = QT[:, :, n] # Augmented initial state x0_aug = np.concatenate([x0_vec, yT_vec]) x_p = np.zeros((na, N + 1), dtype=float) x_u = np.zeros((na, N), dtype=float) W_p = np.zeros((na, na, N + 1), dtype=float) W_u = np.zeros((na, na, N), dtype=float) x_p[:, 0] = Amat[:, :, 0] @ x0_aug W_p[:, :, 0] = Amat[:, :, 0] @ np.zeros((na, na), dtype=float) @ Amat[:, :, 0].T + Qmat[:, :, 0] for time_index in range(N): x_u[:, time_index], W_u[:, :, time_index], _ = DecodingAlgorithms.PPDecode_updateLinear( x_p[:, time_index], W_p[:, :, time_index], obs, mu_vec, beta_aug, fitType, gamma_mat, H_tensor, time_index, None, ) A_t = Amat[:, :, min(time_index, N - 1)] Q_t = Qmat[:, :, min(time_index, N - 1)] x_p[:, time_index + 1], W_p[:, :, time_index + 1] = DecodingAlgorithms.PPDecode_predict( x_u[:, time_index], W_u[:, :, time_index], A_t, Q_t, Wconv, ) # Decompose augmented state into state + target x_uT = x_u[ns:, :] W_uT = W_u[ns:, ns:, :] x_pT = x_p[ns:, :] W_pT = W_p[ns:, ns:, :] x_u = x_u[:ns, :] W_u = W_u[:ns, :ns, :] x_p = x_p[:ns, :] W_p = W_p[:ns, :ns, :] return x_p, W_p, x_u, W_u, x_pT, W_pT, x_uT, W_uT else: # Non-augmented target branch: use B and ft feedforward term Amat = B Qmat_arr = QT ft = np.zeros((ns, N), dtype=float) ut = np.zeros((ns, N), dtype=float) for n in range(N): An = _select_time_matrix(A, n, ns) Qn = _select_time_matrix(Q, n, ns) invPitT_n = np.linalg.pinv(PitT[:, :, n]) ft[:, n] = (Qn @ invPitT_n) @ PhitT[:, :, n] @ yT_vec x_p = np.zeros((ns, N + 1), dtype=float) x_u = np.zeros((ns, N), dtype=float) W_p = np.zeros((ns, ns, N + 1), dtype=float) W_u = np.zeros((ns, ns, N), dtype=float) # Fuse initial state with backward target information # (Srinivasan et al. 2006 — prior update step) if np.linalg.det(Pi0_mat) != 0: invPi0 = np.linalg.pinv(Pi0_mat) invPitT_0_fuse = np.linalg.pinv(PitT[:, :, 0]) Pi0New = np.linalg.pinv(invPi0 + invPitT_0_fuse) Pi0New = np.where(np.isnan(Pi0New), 0.0, Pi0New) x0New = Pi0New @ (invPi0 @ x0_vec + invPitT_0_fuse @ PhitT[:, :, 0] @ yT_vec) x0_vec = x0New Pi0_mat = Pi0New # Initial predict with target correction # NOTE: MATLAB uses n=N (leftover from ft loop) for the initial # PPDecode_predict call, so Amat(:,:,min(N,N)) = B(:,:,N) and # Qmat(:,:,min(N)) = QT(:,:,N). We replicate this for parity. invPitT_0 = np.linalg.pinv(PitT[:, :, 0]) invA1 = np.linalg.pinv(A1) invPhi0T = np.linalg.pinv(invA1 @ PhitT[:, :, 0]) ut[:, 0] = (Q1 @ invPitT_0) @ PhitT[:, :, 0] @ (yT_vec - invPhi0T @ x0_vec) x_p[:, 0], W_p[:, :, 0] = DecodingAlgorithms.PPDecode_predict( x0_vec, Pi0_mat, Amat[:, :, N - 1], Qmat_arr[:, :, N - 1], ) x_p[:, 0] += ut[:, 0] W_p[:, :, 0] += (Q1 @ invPitT_0) @ A1 @ Pi0_mat @ A1.T @ (Q1 @ invPitT_0).T for time_index in range(N): x_u[:, time_index], W_u[:, :, time_index], _ = DecodingAlgorithms.PPDecode_updateLinear( x_p[:, time_index], W_p[:, :, time_index], obs, mu_vec, beta_mat, fitType, gamma_mat, H_tensor, time_index, None, ) if time_index < N: An = _select_time_matrix(A, time_index, ns) Qn = _select_time_matrix(Q, time_index, ns) invPitT_n1 = np.linalg.pinv(PitT[:, :, time_index]) invPhitm1T = np.linalg.pinv(PhitT[:, :, time_index]) ut[:, time_index] = (Qn @ invPitT_n1) @ PhitT[:, :, time_index] @ ( yT_vec - invPhitm1T @ x_u[:, time_index] ) # MATLAB PPDecode_predict in non-augmented target branch # uses Amat(:,:,min(size(A,3),n)) and Qmat(:,:,min(size(Qmat,3))). # size(A,3) = number of A pages (1 if time-invariant), so # min(1,n) = 1 → always B[:,:,0]. # min(size(Qmat,3)) = min(N) = N → always QT[:,:,N-1]. A_dim3 = A.shape[2] if A.ndim == 3 else 1 A_t = Amat[:, :, min(A_dim3 - 1, time_index)] Q_t = Qmat_arr[:, :, N - 1] x_p[:, time_index], W_p[:, :, time_index] = DecodingAlgorithms.PPDecode_predict( x_u[:, time_index], W_u[:, :, time_index], A_t, Q_t, ) x_p[:, time_index] += ut[:, time_index] W_p[:, :, time_index] += (Qn @ invPitT_n1) @ An @ W_u[:, :, time_index] @ An.T @ (Qn @ invPitT_n1).T empty_vec = np.array([], dtype=float) empty_cov = np.zeros((0, 0, 0), dtype=float) return x_p, W_p, x_u, W_u, empty_vec, empty_cov, empty_vec, empty_cov # ------------------------------------------------------------------ # Standard filter (no target) # ------------------------------------------------------------------ # ---- Numba fast-path (opt-in via nstat-toolbox[numba]) ---------- # Eligible only when A/Q are time-invariant, no Wconv override is # active, and the gold-fixture-proven branch is selected. The # pure-Python loop below is the parity baseline; the Numba kernel # mirrors it line-for-line with explicit float64 arithmetic. A_raw_pre = np.asarray(A, dtype=float) Q_raw_pre = np.asarray(Q, dtype=float) _Wconv_inactive_pre = ( Wconv is None or (isinstance(Wconv, (list, tuple)) and len(Wconv) == 0) ) if A_raw_pre.ndim != 3 and Q_raw_pre.ndim != 3 and _Wconv_inactive_pre: try: # Obtained only for eligible calls, inside the ``try``: any # failure (import, decoration, kernel) falls back to the # pure-Python path below. _numba_kernels = _numba_kernels_module() if not _numba_kernels._NUMBA_AVAILABLE: raise RuntimeError("numba fast path unavailable") A_static_jit = _as_state_matrix(A_raw_pre, ns) Q_static_jit = _as_state_matrix(Q_raw_pre, ns) x_p_j, W_p_j, x_u_j, W_u_j = _numba_kernels.ppdecode_linear_loop( A_static_jit, Q_static_jit, beta_mat, mu_vec, gamma_mat, H_tensor, obs, x0_vec, Pi0_mat, str(fitType) == "binomial", H_tensor.shape[1] > 0, ) empty_vec = np.array([], dtype=float) empty_cov = np.zeros((0, 0, 0), dtype=float) return x_p_j, W_p_j, x_u_j, W_u_j, empty_vec, empty_cov, empty_vec, empty_cov except Exception: # pragma: no cover - JIT typing or LinAlg fallback # Fall back to pure-Python on any kernel error so parity # tests + edge-case shapes stay valid. pass x_p = np.zeros((ns, N + 1), dtype=float) x_u = np.zeros((ns, N), dtype=float) W_p = np.zeros((ns, ns, N + 1), dtype=float) W_u = np.zeros((ns, ns, N), dtype=float) A0 = _select_time_matrix(A, 0, ns) Q0 = _select_time_matrix(Q, 0, ns) x_p[:, 0], W_p[:, :, 0] = DecodingAlgorithms.PPDecode_predict(x0_vec, Pi0_mat, A0, Q0, Wconv) # ----- Inlined fast-path for the standard (no-target) loop. ------ # The public ``PPDecode_updateLinear`` / ``PPDecode_predict`` wrappers # re-normalise their inputs every call (O(C+ns^2) of pure Python # dispatch). Inside this loop the shapes are already canonical, so # we inline the math directly and skip the wrappers, the per-step # ``np.linalg.cond`` SVD, and the redundant ``np.asarray``/``reshape`` # calls. The semantics MUST match ``PPDecode_updateLinear`` exactly; # gold fixtures (`tests/test_matlab_gold_fixtures.py`) cover both # ``binomial`` and ``poisson`` branches end-to-end. is_binomial = (str(fitType) == "binomial") A_raw = np.asarray(A, dtype=float) Q_raw = np.asarray(Q, dtype=float) A_is_3d = A_raw.ndim == 3 Q_is_3d = Q_raw.ndim == 3 # For non-time-varying A/Q, normalise once to (ns, ns) to skip the # per-step ``_as_state_matrix`` wrapper inside the loop. A_static = None if A_is_3d else _as_state_matrix(A_raw, ns) Q_static = None if Q_is_3d else _as_state_matrix(Q_raw, ns) Wconv_inactive = (Wconv is None or (isinstance(Wconv, (list, tuple)) and len(Wconv) == 0)) ident = np.eye(ns, dtype=float) eps_floor = np.finfo(float).eps h_num_windows_loc = H_tensor.shape[1] for time_index in range(N): # ---- inlined PPDecode_updateLinear (standard fast path) ----- x_vec_t = x_p[:, time_index] W_mat_t = W_p[:, :, time_index] # lambda * delta from current state vector if h_num_windows_loc: hist_effect = np.sum(gamma_mat * H_tensor[time_index], axis=0) lin_term = mu_vec + beta_mat.T @ x_vec_t + hist_effect else: lin_term = mu_vec + beta_mat.T @ x_vec_t # MATLAB's rule, no clipping (see _matlab_lambda_delta). lambda_delta = _matlab_lambda_delta(lin_term, is_binomial) observed = obs[:, time_index] if is_binomial: one_minus_l = 1.0 - lambda_delta factor = (observed - lambda_delta) * one_minus_l temp_vec = (observed + (1.0 - 2.0 * lambda_delta)) * one_minus_l * lambda_delta else: factor = observed - lambda_delta temp_vec = lambda_delta sum_val_vec = np.sum(beta_mat * factor, axis=1) sum_val_mat = (beta_mat * temp_vec) @ beta_mat.T # Cache ``sum_val_mat @ W_mat_t`` — used as both the RHS and # part of the LHS of the inner ``solve`` (`PPDecode_updateLinear` # called it twice with separate dispatch, doubling matmul work). SmW = sum_val_mat @ W_mat_t try: W_u_t = W_mat_t @ (ident - np.linalg.solve(ident + SmW, SmW)) except np.linalg.LinAlgError: W_u_t = W_mat_t.copy() # symmetrize W_u_t = 0.5 * (W_u_t + W_u_t.T) x_u_t = x_vec_t + W_u_t @ sum_val_vec x_u[:, time_index] = x_u_t W_u[:, :, time_index] = W_u_t # ---- inlined PPDecode_predict ------------------------------ A_t = A_static if A_static is not None else _as_state_matrix(A_raw[:, :, min(time_index, A_raw.shape[2] - 1)], ns) if Wconv_inactive: Q_t = Q_static if Q_static is not None else _as_state_matrix(Q_raw[:, :, min(time_index, Q_raw.shape[2] - 1)], ns) W_pred = A_t @ W_u_t @ A_t.T + Q_t # MATLAB does ``if rcond(W_p) < eps or NaN, W_p = W_u``. # The check exists to guard against an ill-conditioned # predicted covariance — but the upstream public # ``PPDecode_predict`` retains the explicit guard, so # callers that pass adversarial inputs still get it. In # the standard fast-path loop W_u is freshly computed and # symmetrised, and Q is parametrised in user code as # positive-definite; the only way W_pred goes singular # here is if it became non-finite, which we detect with # a single ``np.isnan`` check on its (0,0) element (the # full ``isfinite`` reduction was 7% of total time per # iter-54 profile). Cheap and sufficient. if not np.isfinite(W_pred[0, 0]): W_pred = W_u_t.copy() W_p[:, :, time_index + 1] = 0.5 * (W_pred + W_pred.T) else: W_p[:, :, time_index + 1] = _symmetrize(_as_state_matrix(Wconv, ns)) x_p[:, time_index + 1] = A_t @ x_u_t empty_vec = np.array([], dtype=float) empty_cov = np.zeros((0, 0, 0), dtype=float) return x_p, W_p, x_u, W_u, empty_vec, empty_cov, empty_vec, empty_cov
[docs] @staticmethod def PPHybridFilterLinear( A, Q, p_ij, Mu0, dN, mu, beta, fitType="poisson", binwidth=0.001, gamma=None, windowTimes=None, x0=None, Pi0=None, yT=None, PiT=None, estimateTarget=0, MinClassificationError=0, ): """Hybrid point-process filter with discrete-mode switching (linear parameters). Combines multiple linear state-space models (one per discrete mode) with a Markov transition matrix *p_ij*. At each time step, per-mode PPAF updates are merged using posterior mode probabilities. Returns ``(x_p, W_p, x_u, W_u, xT, WT, S_est, X_est, W_est)``. """ obs = _as_observation_matrix(dN) A_models = list(A) if isinstance(A, Sequence) and not isinstance(A, np.ndarray) else [A] Q_models = list(Q) if isinstance(Q, Sequence) and not isinstance(Q, np.ndarray) else [Q] n_models = len(A_models) if len(Q_models) != n_models: raise ValueError("A and Q must define the same number of hybrid models") num_cells, num_steps = obs.shape state_dims = [_infer_state_dim(A_models[index], beta, num_cells) for index in range(n_models)] max_dim = max(state_dims) mu_models = _normalize_mu_models(mu, n_models, num_cells) beta_models = _normalize_beta_models(beta, n_models, num_cells, state_dims) x0_models_raw = _normalize_model_sequence(x0, n_models, lambda index: np.zeros(state_dims[index], dtype=float)) Pi0_models_raw = _normalize_model_sequence(Pi0, n_models, lambda index: np.zeros((state_dims[index], state_dims[index]), dtype=float)) x0_models = [np.asarray(x0_models_raw[index], dtype=float).reshape(-1) for index in range(n_models)] Pi0_models = [_as_state_matrix(Pi0_models_raw[index], state_dims[index]) for index in range(n_models)] transition = np.asarray(p_ij, dtype=float) if transition.shape != (n_models, n_models): raise ValueError("p_ij must be an nModels x nModels transition matrix") row_sums = np.sum(transition, axis=1) if not np.allclose(row_sums, np.ones(n_models), atol=1e-8): raise ValueError("State Transition probability matrix must sum to 1 along each row") if _is_empty_value(Mu0): model_probs0 = np.full(n_models, 1.0 / float(n_models), dtype=float) else: model_probs0 = _normalize_probabilities(Mu0) if model_probs0.size != n_models: raise ValueError("Mu0 must contain one probability per hybrid model") if _is_empty_value(windowTimes): H_tensor = np.zeros((num_steps, 0, num_cells), dtype=float) gamma_mat = np.zeros((0, num_cells), dtype=float) else: H_tensor = _compute_history_terms(obs, float(binwidth), windowTimes) gamma_mat = _normalize_gamma(gamma, H_tensor.shape[1], num_cells) # ------------------------------------------------------------------ # Goal-directed target branch (Srinivasan et al. 2006) # ------------------------------------------------------------------ estimateTarget = int(estimateTarget) # Normalize yT, PiT as per-model lists if _is_empty_value(yT): yT_models = [None] * n_models elif isinstance(yT, (list, tuple)) and not isinstance(yT, np.ndarray): yT_models = [ np.asarray(y, dtype=float).reshape(-1) if not _is_empty_value(y) else None for y in yT ] else: yT_vec = np.asarray(yT, dtype=float).reshape(-1) yT_models = [yT_vec] * n_models if _is_empty_value(PiT): PiT_models = [None] * n_models elif isinstance(PiT, (list, tuple)) and not isinstance(PiT, np.ndarray): PiT_models = [ _as_state_matrix(p, state_dims[i]) if not _is_empty_value(p) else None for i, p in enumerate(PiT) ] else: PiT_models = [_as_state_matrix(PiT, state_dims[i]) for i in range(n_models)] _has_target = [yT_models[s] is not None for s in range(n_models)] _any_target = any(_has_target) if estimateTarget == 1 and _any_target: raise NotImplementedError( "Augmented state-space target estimation (estimateTarget=1) is not yet " "supported for PPHybridFilterLinear. Use estimateTarget=0 with fixed target, " "or use PPDecodeFilterLinear which supports both modes." ) # Backward information filter for each target model PhitT_m = [None] * n_models PitT_m = [None] * n_models B_m = [None] * n_models QT_m = [None] * n_models for s in range(n_models): if not _has_target[s]: continue dim = state_dims[s] PiT_s = PiT_models[s] if PiT_models[s] is not None else np.zeros((dim, dim), dtype=float) PitT = np.zeros((dim, dim, num_steps), dtype=float) PhitT = np.zeros((dim, dim, num_steps), dtype=float) B_arr = np.zeros((dim, dim, num_steps), dtype=float) QT_arr = np.zeros((dim, dim, num_steps), dtype=float) QN = _select_time_matrix(Q_models[s], num_steps - 1, dim) PitT[:, :, num_steps - 1] = PiT_s + QN PhitT[:, :, num_steps - 1] = np.eye(dim, dtype=float) for n in range(num_steps - 1, 0, -1): An = _select_time_matrix(A_models[s], n, dim) Qn = _select_time_matrix(Q_models[s], n, dim) invA = np.linalg.pinv(An) PhitT[:, :, n - 1] = invA @ PhitT[:, :, n] PitT[:, :, n - 1] = invA @ PitT[:, :, n] @ invA.T + Qn invPitT_n = np.linalg.pinv(PitT[:, :, n]) B_arr[:, :, n] = An - (Qn @ invPitT_n) @ An QT_arr[:, :, n] = Qn - (Qn @ invPitT_n) @ Qn.T A1 = _select_time_matrix(A_models[s], 0, dim) Q1 = _select_time_matrix(Q_models[s], 0, dim) invPitT_0 = np.linalg.pinv(PitT[:, :, 0]) B_arr[:, :, 0] = A1 - (Q1 @ invPitT_0) @ A1 QT_arr[:, :, 0] = Q1 - (Q1 @ invPitT_0) @ Q1.T PhitT_m[s] = PhitT PitT_m[s] = PitT B_m[s] = B_arr QT_m[s] = QT_arr X = np.zeros((max_dim, num_steps), dtype=float) W = np.zeros((max_dim, max_dim, num_steps), dtype=float) X_s = [np.zeros((max_dim, num_steps), dtype=float) for _ in range(n_models)] W_s = [np.zeros((max_dim, max_dim, num_steps), dtype=float) for _ in range(n_models)] X_u = [np.zeros((state_dims[index], num_steps), dtype=float) for index in range(n_models)] W_u = [np.zeros((state_dims[index], state_dims[index], num_steps), dtype=float) for index in range(n_models)] X_p = [np.zeros((state_dims[index], num_steps), dtype=float) for index in range(n_models)] W_p = [np.zeros((state_dims[index], state_dims[index], num_steps), dtype=float) for index in range(n_models)] MU_u = np.zeros((n_models, num_steps), dtype=float) pNGivenS = np.zeros((n_models, num_steps), dtype=float) S_est = np.zeros(num_steps, dtype=int) # Fuse initial state prior with terminal constraint for each model # (Srinivasan et al. Eq. 2.23). Matches PPDecodeFilterLinear and # the corrected MATLAB PPHybridFilterLinear. Without this step the # goal-directed filter starts from the raw prior, never incorporating # target information into x0/Pi0. for s in range(n_models): if _has_target[s] and estimateTarget == 0: dim = state_dims[s] Pi0s = Pi0_models[s] x0s = x0_models[s] det_Pi0 = np.linalg.det(Pi0s) if det_Pi0 != 0.0: invPi0s = np.linalg.pinv(Pi0s) invPitTs = np.linalg.pinv(PitT_m[s][:, :, 0]) Pi0New = np.linalg.pinv(invPi0s + invPitTs) Pi0New = np.where(np.isnan(Pi0New), 0.0, Pi0New) x0New = Pi0New @ (invPi0s @ x0s + invPitTs @ PhitT_m[s][:, :, 0] @ yT_models[s]) x0_models[s] = x0New Pi0_models[s] = Pi0New fit_type = str(fitType) for time_index in range(num_steps): if time_index == 0: MU_p = transition.T @ model_probs0 prev_probs = model_probs0 else: # Audit M22: previous-step model probabilities. Original # Python port used MU_u[:, time_index] (current step, # always 0); MATLAB DA.m uses the previous step. See # AUDIT_REPORT.md. MU_p = transition.T @ MU_u[:, time_index - 1] prev_probs = MU_u[:, time_index - 1] p_ij_s = transition * prev_probs[:, None] column_norm = np.sum(p_ij_s, axis=0, keepdims=True) column_norm[column_norm == 0.0] = 1.0 p_ij_s = p_ij_s / column_norm for target_model in range(n_models): mixed_state = np.zeros(max_dim, dtype=float) for source_model in range(n_models): dim_i = state_dims[source_model] source_state = x0_models[source_model] if time_index == 0 else X_u[source_model][:, time_index - 1] mixed_state[:dim_i] += source_state * p_ij_s[source_model, target_model] X_s[target_model][:, time_index] = mixed_state mixed_cov = np.zeros((max_dim, max_dim), dtype=float) for source_model in range(n_models): dim_i = state_dims[source_model] source_state = x0_models[source_model] if time_index == 0 else X_u[source_model][:, time_index - 1] source_cov = Pi0_models[source_model] if time_index == 0 else W_u[source_model][:, :, time_index - 1] diff = source_state - mixed_state[:dim_i] mixed_cov[:dim_i, :dim_i] += ( source_cov + np.outer(diff, diff) ) * p_ij_s[source_model, target_model] W_s[target_model][:, :, time_index] = _symmetrize(mixed_cov) likelihoods = np.zeros(n_models, dtype=float) for model_index in range(n_models): dim = state_dims[model_index] if _has_target[model_index]: # Use B(:,:,0) and QT(:,:,-1) — matches the original # Srinivasan et al. implementation where the prediction # uses the initial modified dynamics B_1 with the terminal # modified noise covariance QT_N for consistent goal # correction across all time steps. A_t = B_m[model_index][:, :, 0] Q_t = QT_m[model_index][:, :, -1] else: A_t = _select_time_matrix(A_models[model_index], time_index, dim) Q_t = _select_time_matrix(Q_models[model_index], time_index, dim) pred_x, pred_W = DecodingAlgorithms.PPDecode_predict( X_s[model_index][:dim, time_index], W_s[model_index][:dim, :dim, time_index], A_t, Q_t, ) # Goal-directed offset for fixed target (Srinivasan Eq. 2.21) if _has_target[model_index] and estimateTarget == 0: Qn_orig = _select_time_matrix(Q_models[model_index], time_index, dim) invPitT_n = np.linalg.pinv(PitT_m[model_index][:, :, time_index]) if time_index > 0: invPhitm1 = np.linalg.pinv(PhitT_m[model_index][:, :, time_index]) ut = (Qn_orig @ invPitT_n) @ PhitT_m[model_index][:, :, time_index] @ ( yT_models[model_index] - invPhitm1 @ X_s[model_index][:dim, time_index] ) else: A1_orig = _select_time_matrix(A_models[model_index], 0, dim) invA1 = np.linalg.pinv(A1_orig) invPhi0 = np.linalg.pinv(invA1 @ PhitT_m[model_index][:, :, 0]) ut = (Qn_orig @ invPitT_n) @ PhitT_m[model_index][:, :, time_index] @ ( yT_models[model_index] - invPhi0 @ X_s[model_index][:dim, time_index] ) pred_x = pred_x + ut An_orig = _select_time_matrix(A_models[model_index], time_index, dim) pred_W = pred_W + ( (Qn_orig @ invPitT_n) @ An_orig
[docs] @ W_s[model_index][:dim, :dim, time_index] @ An_orig.T @ (Qn_orig @ invPitT_n).T ) upd_x, upd_W, lambda_delta = DecodingAlgorithms.PPDecode_updateLinear( pred_x, pred_W, obs, mu_models[model_index], beta_models[model_index], fit_type, gamma_mat, H_tensor, time_index, None, ) X_p[model_index][:, time_index] = pred_x W_p[model_index][:, :, time_index] = pred_W X_u[model_index][:, time_index] = upd_x W_u[model_index][:, :, time_index] = upd_W # Likelihood (Step 5): Laplace approximation # p(n|s) = sqrt(det(W_u)/det(W_p)) * prod(exp(dN*log(lambda) - lambda)) # Use log-determinants for numerical stability with high-dimensional # covariance matrices whose determinants can be extremely small (e.g. # 1e-46 for 6-D states with tiny eigenvalues). MATLAB computes # sqrt(det(W_u))/sqrt(det(W_p)) directly without any clamping. sign_u, logdet_u = np.linalg.slogdet(upd_W) sign_p, logdet_p = np.linalg.slogdet(pred_W) lambda_flat = lambda_delta.reshape(-1) log_spike = np.sum( obs[:, time_index] * np.log(np.where(lambda_flat > 0, lambda_flat, 1e-300)) - lambda_flat ) if sign_u > 0 and sign_p > 0: log_likelihood = 0.5 * (logdet_u - logdet_p) + log_spike likelihoods[model_index] = float(np.exp(log_likelihood)) else: likelihoods[model_index] = 0.0 finite_likelihoods = likelihoods.copy() finite_likelihoods[~np.isfinite(finite_likelihoods)] = 0.0 pNGivenS[:, time_index] = finite_likelihoods norm = np.sum(pNGivenS[:, time_index]) if norm != 0.0 and np.isfinite(norm): pNGivenS[:, time_index] /= norm elif time_index > 0: pNGivenS[:, time_index] = pNGivenS[:, time_index] else: pNGivenS[:, time_index] = np.full(n_models, 0.5 if n_models == 2 else 1.0 / float(n_models), dtype=float) posterior = MU_p * pNGivenS[:, time_index] posterior_norm = np.sum(posterior) if posterior_norm != 0.0 and np.isfinite(posterior_norm): MU_u[:, time_index] = posterior / posterior_norm elif time_index > 0: MU_u[:, time_index] = MU_u[:, time_index] else: MU_u[:, time_index] = model_probs0 best_model = int(np.argmax(MU_u[:, time_index])) S_est[time_index] = best_model + 1 if MinClassificationError: chosen = best_model dim = state_dims[chosen] X[:dim, time_index] = X_u[chosen][:, time_index] W[:dim, :dim, time_index] = W_u[chosen][:, :, time_index] continue mixed_global_state = np.zeros(max_dim, dtype=float) for model_index in range(n_models): dim = state_dims[model_index] mixed_global_state[:dim] += MU_u[model_index, time_index] * X_u[model_index][:, time_index] X[:, time_index] = mixed_global_state mixed_global_cov = np.zeros((max_dim, max_dim), dtype=float) for model_index in range(n_models): dim = state_dims[model_index] diff = X_u[model_index][:, time_index] - mixed_global_state[:dim] mixed_global_cov[:dim, :dim] += MU_u[model_index, time_index] * ( W_u[model_index][:, :, time_index] + np.outer(diff, diff) ) W[:, :, time_index] = _symmetrize(mixed_global_cov) return S_est, X, W, MU_u, X_s, W_s, pNGivenS
@staticmethod def PPHybridFilter(A, Q, p_ij, Mu0, dN, lambdaCIFColl, binwidth=0.001, x0=None, Pi0=None, yT=None, PiT=None, estimateTarget=0, MinClassificationError=0): """Hybrid point-process filter with CIF-object evaluation. Unlike :meth:`PPHybridFilterLinear` which takes pre-extracted linear parameters (mu, beta, gamma), this method evaluates CIF objects directly via their ``evalLambdaDelta`` / ``evalGradient*`` / ``evalJacobian*`` methods. This supports nonlinear conditional intensity specifications. Falls back to the linear path when the target-estimation branch is active (``yT`` / ``PiT`` / ``estimateTarget`` supplied), matching Matlab behaviour. """ del yT, PiT, estimateTarget # reserved for future target-estimation branch obs = _as_observation_matrix(dN) lambda_items = _normalize_cif_collection(lambdaCIFColl) A_models = list(A) if isinstance(A, Sequence) and not isinstance(A, np.ndarray) else [A] Q_models = list(Q) if isinstance(Q, Sequence) and not isinstance(Q, np.ndarray) else [Q] n_models = len(A_models) if len(Q_models) != n_models: raise ValueError("A and Q must define the same number of hybrid models") num_cells, num_steps = obs.shape if len(lambda_items) != num_cells: raise ValueError("Number of CIF objects must match the number of observed cells") state_dims = [_infer_state_dim(A_models[index], np.array([0.0]), num_cells) for index in range(n_models)] max_dim = max(state_dims) x0_models_raw = _normalize_model_sequence(x0, n_models, lambda index: np.zeros(state_dims[index], dtype=float)) Pi0_models_raw = _normalize_model_sequence(Pi0, n_models, lambda index: np.zeros((state_dims[index], state_dims[index]), dtype=float)) x0_models = [np.asarray(x0_models_raw[index], dtype=float).reshape(-1) for index in range(n_models)] Pi0_models = [_as_state_matrix(Pi0_models_raw[index], state_dims[index]) for index in range(n_models)] transition = np.asarray(p_ij, dtype=float) if transition.shape != (n_models, n_models): raise ValueError("p_ij must be an nModels x nModels transition matrix") row_sums = np.sum(transition, axis=1) if not np.allclose(row_sums, np.ones(n_models), atol=1e-8): raise ValueError("State Transition probability matrix must sum to 1 along each row") if _is_empty_value(Mu0): model_probs0 = np.full(n_models, 1.0 / float(n_models), dtype=float) else: model_probs0 = _normalize_probabilities(Mu0) if model_probs0.size != n_models: raise ValueError("Mu0 must contain one probability per hybrid model") X = np.zeros((max_dim, num_steps), dtype=float) W = np.zeros((max_dim, max_dim, num_steps), dtype=float) X_s = [np.zeros((max_dim, num_steps), dtype=float) for _ in range(n_models)] W_s = [np.zeros((max_dim, max_dim, num_steps), dtype=float) for _ in range(n_models)] X_u = [np.zeros((state_dims[index], num_steps), dtype=float) for index in range(n_models)] W_u = [np.zeros((state_dims[index], state_dims[index], num_steps), dtype=float) for index in range(n_models)] X_p = [np.zeros((state_dims[index], num_steps), dtype=float) for index in range(n_models)] W_p = [np.zeros((state_dims[index], state_dims[index], num_steps), dtype=float) for index in range(n_models)] MU_u = np.zeros((n_models, num_steps), dtype=float) pNGivenS = np.zeros((n_models, num_steps), dtype=float) S_est = np.zeros(num_steps, dtype=int) for time_index in range(num_steps): if time_index == 0: MU_p = transition.T @ model_probs0 prev_probs = model_probs0 else: # Audit M22: previous-step model probabilities. Original # Python port used MU_u[:, time_index] (current step, # always 0); MATLAB DA.m uses the previous step. See # AUDIT_REPORT.md. MU_p = transition.T @ MU_u[:, time_index - 1] prev_probs = MU_u[:, time_index - 1] p_ij_s = transition * prev_probs[:, None] column_norm = np.sum(p_ij_s, axis=0, keepdims=True) column_norm[column_norm == 0.0] = 1.0 p_ij_s = p_ij_s / column_norm for target_model in range(n_models): mixed_state = np.zeros(max_dim, dtype=float) for source_model in range(n_models): dim_i = state_dims[source_model] source_state = x0_models[source_model] if time_index == 0 else X_u[source_model][:, time_index - 1] mixed_state[:dim_i] += source_state * p_ij_s[source_model, target_model] X_s[target_model][:, time_index] = mixed_state mixed_cov = np.zeros((max_dim, max_dim), dtype=float) for source_model in range(n_models): dim_i = state_dims[source_model] source_state = x0_models[source_model] if time_index == 0 else X_u[source_model][:, time_index - 1] source_cov = Pi0_models[source_model] if time_index == 0 else W_u[source_model][:, :, time_index - 1] diff = source_state - mixed_state[:dim_i] mixed_cov[:dim_i, :dim_i] += ( source_cov + np.outer(diff, diff) ) * p_ij_s[source_model, target_model] W_s[target_model][:, :, time_index] = _symmetrize(mixed_cov) likelihoods = np.zeros(n_models, dtype=float) for model_index in range(n_models): dim = state_dims[model_index] A_t = _select_time_matrix(A_models[model_index], time_index, dim) Q_t = _select_time_matrix(Q_models[model_index], time_index, dim) pred_x, pred_W = DecodingAlgorithms.PPDecode_predict( X_s[model_index][:dim, time_index], W_s[model_index][:dim, :dim, time_index], A_t, Q_t, ) # Use CIF-based (nonlinear) update instead of linear upd_x, upd_W, lambda_delta = DecodingAlgorithms.PPDecode_update( pred_x, pred_W, obs, lambda_items, binwidth, time_index, None, ) X_p[model_index][:, time_index] = pred_x W_p[model_index][:, :, time_index] = pred_W X_u[model_index][:, time_index] = upd_x W_u[model_index][:, :, time_index] = upd_W # Likelihood (Step 5): Laplace approximation # p(n|s) = sqrt(det(W_u)/det(W_p)) * prod(exp(dN*log(lambda) - lambda)) # Use log-determinants for numerical stability with high-dimensional # covariance matrices whose determinants can be extremely small (e.g. # 1e-46 for 6-D states with tiny eigenvalues). MATLAB computes # sqrt(det(W_u))/sqrt(det(W_p)) directly without any clamping. sign_u, logdet_u = np.linalg.slogdet(upd_W) sign_p, logdet_p = np.linalg.slogdet(pred_W) lambda_flat = lambda_delta.reshape(-1) log_spike = np.sum( obs[:, time_index] * np.log(np.where(lambda_flat > 0, lambda_flat, 1e-300)) - lambda_flat ) if sign_u > 0 and sign_p > 0: log_likelihood = 0.5 * (logdet_u - logdet_p) + log_spike likelihoods[model_index] = float(np.exp(log_likelihood)) else: likelihoods[model_index] = 0.0 finite_likelihoods = likelihoods.copy() finite_likelihoods[~np.isfinite(finite_likelihoods)] = 0.0 pNGivenS[:, time_index] = finite_likelihoods norm = np.sum(pNGivenS[:, time_index]) if norm != 0.0 and np.isfinite(norm): pNGivenS[:, time_index] /= norm elif time_index > 0: pNGivenS[:, time_index] = pNGivenS[:, time_index] else: pNGivenS[:, time_index] = np.full(n_models, 0.5 if n_models == 2 else 1.0 / float(n_models), dtype=float) posterior = MU_p * pNGivenS[:, time_index] posterior_norm = np.sum(posterior) if posterior_norm != 0.0 and np.isfinite(posterior_norm): MU_u[:, time_index] = posterior / posterior_norm elif time_index > 0: MU_u[:, time_index] = MU_u[:, time_index] else: MU_u[:, time_index] = model_probs0 best_model = int(np.argmax(MU_u[:, time_index])) S_est[time_index] = best_model + 1 if MinClassificationError: chosen = best_model dim = state_dims[chosen] X[:dim, time_index] = X_u[chosen][:, time_index] W[:dim, :dim, time_index] = W_u[chosen][:, :, time_index] continue mixed_global_state = np.zeros(max_dim, dtype=float) for model_index in range(n_models): dim = state_dims[model_index] mixed_global_state[:dim] += MU_u[model_index, time_index] * X_u[model_index][:, time_index] X[:, time_index] = mixed_global_state mixed_global_cov = np.zeros((max_dim, max_dim), dtype=float) for model_index in range(n_models): dim = state_dims[model_index] diff = X_u[model_index][:, time_index] - mixed_global_state[:dim] mixed_global_cov[:dim, :dim] += MU_u[model_index, time_index] * ( W_u[model_index][:, :, time_index] + np.outer(diff, diff) ) W[:, :, time_index] = _symmetrize(mixed_global_cov) return S_est, X, W, MU_u, X_s, W_s, pNGivenS
# ------------------------------------------------------------------ # Unscented Kalman Filter (UKF) # Ported from Matlab DecodingAlgorithms.m # ------------------------------------------------------------------
[docs] @staticmethod def ukf(fstate, x: np.ndarray, P: np.ndarray, hmeas, z: np.ndarray, Q: np.ndarray, R: np.ndarray): """Unscented Kalman Filter for nonlinear systems. One-step update matching Matlab ``DecodingAlgorithms.ukf``. System model (additive noise):: x_{k+1} = fstate(x_k) + w_k, w ~ N(0, Q) z_k = hmeas(x_k) + v_k, v ~ N(0, R) Parameters ---------- fstate : callable (L,) -> (L,) x : (L,) prior state estimate P : (L, L) prior covariance hmeas : callable (L,) -> (m,) z : (m,) measurement Q : (L, L) process noise covariance R : (m, m) measurement noise covariance Returns ------- x : (L,) posterior state estimate P : (L, L) posterior covariance """ x = np.asarray(x, dtype=float).reshape(-1) z = np.asarray(z, dtype=float).reshape(-1) P = np.asarray(P, dtype=float) Q = np.asarray(Q, dtype=float) R = np.asarray(R, dtype=float) if R.ndim == 0: R = R.reshape(1, 1) elif R.ndim == 1: R = np.diag(R) L = len(x) m = len(z) alpha = 1e-3 ki = 0.0 beta = 2.0 lam = alpha ** 2 * (L + ki) - L c = L + lam Wm = np.full(2 * L + 1, 0.5 / c) Wm[0] = lam / c Wc = Wm.copy() Wc[0] += (1 - alpha ** 2 + beta) c_sqrt = np.sqrt(c) X = DecodingAlgorithms.ukf_sigmas(x, P, c_sqrt) x1, X1, P1, X2 = DecodingAlgorithms.ukf_ut(fstate, X, Wm, Wc, L, Q) z1, Z1, P2, Z2 = DecodingAlgorithms.ukf_ut(hmeas, X1, Wm, Wc, m, R) P12 = X2 @ np.diag(Wc) @ Z2.T # Case C: K = P12 @ inv(P2) → solve via P2.T @ K.T = P12.T. # Avoids the explicit inverse, more stable for ill-conditioned P2. K = np.linalg.solve(P2.T, P12.T).T x_out = x1 + K @ (z - z1) P_out = P1 - K @ P12.T return x_out, P_out
[docs] @staticmethod def ukf_ut(f, X: np.ndarray, Wm: np.ndarray, Wc: np.ndarray, n: int, R: np.ndarray): """Unscented transformation. Parameters ---------- f : callable mapping (L,) -> (n,) X : (L, 2L+1) sigma points Wm, Wc : (2L+1,) weights n : output dimensionality R : (n, n) additive covariance Returns ------- y : (n,) transformed mean Y : (n, 2L+1) transformed sigma points P : (n, n) transformed covariance Y1 : (n, 2L+1) deviations """ Lpts = X.shape[1] y = np.zeros(n) Y = np.zeros((n, Lpts)) for k in range(Lpts): Y[:, k] = np.asarray(f(X[:, k]), dtype=float).reshape(-1)[:n] y += Wm[k] * Y[:, k] Y1 = Y - y[:, None] P = Y1 @ np.diag(Wc) @ Y1.T + np.asarray(R, dtype=float).reshape(n, n) return y, Y, P, Y1
[docs] @staticmethod def ukf_sigmas(x: np.ndarray, P: np.ndarray, c: float) -> np.ndarray: """Generate sigma points around reference point *x*. Parameters ---------- x : (L,) state vector P : (L, L) covariance c : scaling coefficient Returns ------- X : (L, 2L+1) sigma-point matrix """ x = np.asarray(x, dtype=float).reshape(-1) P = np.asarray(P, dtype=float) A = c * np.linalg.cholesky(P) # (L, L) L = len(x) Y = np.tile(x[:, None], (1, L)) X = np.column_stack([x[:, None], Y + A, Y - A]) return X
# ------------------------------------------------------------------
[docs] @staticmethod def linear_decode(spike_counts: np.ndarray, stimulus: np.ndarray) -> dict[str, np.ndarray]: """Ordinary-least-squares linear decoder (spike counts → stimulus). Returns a dict with keys ``'coefficients'``, ``'decoded'``, ``'residual'``, and ``'ci'`` (95 % confidence band). """ x = np.asarray(spike_counts, dtype=float) y = np.asarray(stimulus, dtype=float).reshape(-1) if x.ndim == 1: x = x[:, None] if x.shape[0] != y.shape[0]: raise ValueError("spike_counts and stimulus must align") x_aug = np.column_stack([np.ones(x.shape[0]), x]) beta, *_ = np.linalg.lstsq(x_aug, y, rcond=None) y_hat = x_aug @ beta resid = y - y_hat sigma = float(np.std(resid)) ci = np.column_stack([y_hat - 1.96 * sigma, y_hat + 1.96 * sigma]) return {"coefficients": beta, "decoded": y_hat, "residual": resid, "ci": ci}
[docs] @staticmethod def kalman_filter( A=None, C=None, Pv=None, Pw=None, Px0=None, x0=None, y=None, GnConv=None, *, observations=None, transition=None, observation_matrix=None, q_cov=None, r_cov=None, p0=None, ): """Discrete-time Kalman filter. Accepts **both** the MATLAB-compatible signature:: kalman_filter(A, C, Pv, Pw, Px0, x0, y, GnConv) and the Pythonic keyword signature:: kalman_filter(observations=y, transition=A, observation_matrix=C, q_cov=Pv, r_cov=Pw, x0=x0, p0=Px0) When MATLAB-style positional args are provided, delegates to ``_kalman_filter_matlab``. Otherwise runs a simple Pythonic Kalman filter returning ``dict(state=..., cov=...)``. Returns ------- dict with keys: ``state`` : (N, Dx) — updated (posterior) state estimates. ``cov`` : (N, Dx, Dx) — updated covariances. """ # Detect MATLAB-style positional call: kalman_filter(A, C, Pv, Pw, Px0, x0, y, GnConv) # MATLAB call has 7-8 positional args where y (arg 7) is the observation matrix. # Pythonic call has 7 args where the first is observations (2D time-series). # Distinguish by checking: in MATLAB style, A is square (Dx,Dx) and y is (Dy,N); # in Pythonic style, the first arg ('A' slot) is observations (N,Dy). _all_positional = A is not None and C is not None and Pv is not None and Pw is not None if _all_positional and y is not None and GnConv is None: # 7 positional args: could be MATLAB (A,C,Pv,Pw,Px0,x0,y) or Pythonic (obs,A,C,Q,R,x0,p0) # MATLAB: A is square state matrix, y is observation time-series # Pythonic: first arg (A slot) IS the observation time-series a_arr = np.asarray(A, dtype=float) y_arr = np.asarray(y, dtype=float) # If A is 2D square and y has more rows than A, it's MATLAB style if a_arr.ndim == 2 and a_arr.shape[0] == a_arr.shape[1] and y_arr.ndim >= 2 and y_arr.shape[1] > a_arr.shape[0]: return DecodingAlgorithms._kalman_filter_matlab(A, C, Pv, Pw, Px0, x0, y, GnConv) elif _all_positional and y is not None and GnConv is not None: # 8 positional args: must be MATLAB (A,C,Pv,Pw,Px0,x0,y,GnConv) return DecodingAlgorithms._kalman_filter_matlab(A, C, Pv, Pw, Px0, x0, y, GnConv) # Pythonic positional call: kalman_filter(observations, transition, obs_matrix, q, r, x0, p0) # When 7 positional args are given and it's NOT MATLAB-style, treat as: # A=observations, C=transition, Pv=obs_matrix, Pw=q_cov, Px0=r_cov, x0=x0, y=p0 if observations is not None: # Keyword call: use keyword values obs = observations a = transition if transition is not None else A h = observation_matrix if observation_matrix is not None else C q = q_cov if q_cov is not None else Pv r = r_cov if r_cov is not None else Pw x0_vec = x0 p0_mat = p0 if p0 is not None else Px0 else: # Positional Pythonic call: A=obs, C=A, Pv=C, Pw=Q, Px0=R, x0=x0, y=p0 obs = A a = C h = Pv q = Pw r = Px0 x0_vec = x0 p0_mat = y obs = np.asarray(obs, dtype=float) a = np.asarray(a, dtype=float) h = np.asarray(h, dtype=float) q = np.asarray(q, dtype=float) r = np.asarray(r, dtype=float) x_prev = np.asarray(x0_vec, dtype=float).reshape(-1) p_prev = np.asarray(p0_mat, dtype=float) n_t = obs.shape[0] n_x = x_prev.shape[0] xs = np.zeros((n_t, n_x), dtype=float) ps = np.zeros((n_t, n_x, n_x), dtype=float) for t in range(n_t): x_pred = a @ x_prev p_pred = a @ p_prev @ a.T + q innovation = obs[t] - h @ x_pred s_cov = h @ p_pred @ h.T + r k_gain = p_pred @ h.T @ np.linalg.pinv(s_cov) x_post = x_pred + k_gain @ innovation p_post = _symmetrize((np.eye(n_x) - k_gain @ h) @ p_pred) xs[t] = x_post ps[t] = p_post x_prev = x_post p_prev = p_post return {"state": xs, "cov": ps}
@staticmethod def _kalman_filter_matlab(A, C, Pv, Pw, Px0, x0, y, GnConv=None): """Discrete-time Kalman filter matching the Matlab API (internal). Implements the DT Kalman filter for the system:: x(:, n+1) = A(:,:,n) x(:, n) + v(:, n) y(:, n) = C(:,:,n) x(:, n) + w(:, n) where ``Pv(:,:,n)``, ``Pw(:,:,n)`` are the covariances of v(n) and w(n), and ``Px0`` is the initial state covariance. Supports **time-varying** system matrices when supplied as 3-D arrays (e.g. ``A.shape == (Dx, Dx, N)``). Time-invariant (2-D) matrices are broadcast automatically. Parameters ---------- A : (Dx, Dx) or (Dx, Dx, N) — state transition. C : (Dy, Dx) or (Dy, Dx, N) — observation matrix. Pv : (Dx, Dx) or (Dx, Dx, N) — process noise covariance. Pw : (Dy, Dy) or (Dy, Dy, N) — observation noise covariance. Px0 : (Dx, Dx) — initial error covariance. x0 : (Dx,) — initial state estimate. y : (Dy, N) or (N, Dy) — observations (auto-detected layout). GnConv : array or None, optional Pre-converged Kalman gain. When ``None``, gain convergence is auto-detected during filtering. Returns ------- x_p : (Dx, N+1) — predicted states (``x_p[:, 0] == x0``). Pe_p : (Dx, Dx, N+1) — predicted covariances. x_u : (Dx, N) — updated states. Pe_u : (Dx, Dx, N) — updated covariances. Gn : (Dx, Dy, N) — Kalman gain history. GnConvIter : int or None — iteration at which gain converged. """ A = np.asarray(A, dtype=float) C = np.asarray(C, dtype=float) Pv = np.asarray(Pv, dtype=float) Pw = np.asarray(Pw, dtype=float) Px0 = np.asarray(Px0, dtype=float) x0_vec = np.asarray(x0, dtype=float).reshape(-1) y = np.asarray(y, dtype=float) if y.ndim == 1: y = y[None, :] Dx = A.shape[0] Dy = C.shape[0] # Auto-detect layout: Matlab expects (Dy, N) state-major. # If y is (N, Dy) time-major, transpose. if y.shape[0] != Dy and y.shape[1] == Dy: y = y.T N = y.shape[1] def _sel(M, n): """Select time-varying slice M[:,:,n] or broadcast M[:,:] if 2-D.""" if M.ndim == 3: return M[:, :, min(n, M.shape[2] - 1)] return M # ---- Numba fast-path (opt-in via nstat-toolbox[numba]) ---------- # Eligible for the most common case: time-invariant A/C/Pv/Pw # AND no pre-converged gain override. Mirrors the Python loop # below line-for-line; gold-fixture-verified bit-equivalence. _gnconv_inactive = GnConv is None or _is_empty_value(GnConv) if ( _gnconv_inactive and A.ndim == 2 and C.ndim == 2 and Pv.ndim == 2 and Pw.ndim == 2 ): try: # Obtained only for eligible calls, inside the ``try``: any # failure (import, decoration, kernel) falls back to the # pure-Python path below. _numba_kernels = _numba_kernels_module() if not _numba_kernels._NUMBA_AVAILABLE: raise RuntimeError("numba fast path unavailable") return _numba_kernels.kalman_filter_loop( A, C, Pv, Pw, Px0, x0_vec, y, ) except Exception: # pragma: no cover - JIT typing fallback pass x_p = np.zeros((Dx, N + 1), dtype=float) Pe_p = np.zeros((Dx, Dx, N + 1), dtype=float) x_u = np.zeros((Dx, N), dtype=float) Pe_u = np.zeros((Dx, Dx, N), dtype=float) Gn = np.zeros((Dx, Dy, N), dtype=float) x_p[:, 0] = x0_vec Pe_p[:, :, 0] = Px0 GnConvIter = None _GnConv = None if GnConv is not None and not _is_empty_value(GnConv): _GnConv = np.asarray(GnConv, dtype=float) for n in range(N): An = _sel(A, n) Cn = _sel(C, n) Pvn = _sel(Pv, n) Pwn = _sel(Pw, n) # --- Update --- if _GnConv is not None: G = _GnConv else: S = Cn @ Pe_p[:, :, n] @ Cn.T + Pwn G = Pe_p[:, :, n] @ Cn.T @ np.linalg.solve(S, np.eye(Dy)) x_u[:, n] = x_p[:, n] + G @ (y[:, n] - Cn @ x_p[:, n]) # CAREFUL: inlined _symmetrize at three call sites below # (iter 56 perf: saves ~3 function-call frames per kalman step; # symmetrize is the identity 0.5*(M + M.T) for our case). Pu = Pe_p[:, :, n] - G @ Cn @ Pe_p[:, :, n] Pu = 0.5 * (Pu + Pu.T) Pe_u[:, :, n] = Pu Gn[:, :, n] = G # --- Predict --- if _GnConv is not None: Pp = Pu else: Pp = An @ Pu @ An.T + Pvn Pe_p[:, :, n + 1] = 0.5 * (Pp + Pp.T) x_p[:, n + 1] = An @ x_u[:, n] # --- Gain convergence detection --- if n > 0 and _GnConv is None: diffGn = np.abs(Gn[:, :, n] - Gn[:, :, n - 1]) if np.max(diffGn) < 1e-6: _GnConv = Gn[:, :, n] GnConvIter = n return x_p, Pe_p, x_u, Pe_u, Gn, GnConvIter # NOTE: ``kalman_fixedIntervalSmoother`` is declared here in MATLAB order # (immediately after ``kalman_filter``) but its real implementation is the # later definition further down in this class. Python lets the later # ``def`` overwrite this one; the parity scanner records the *first* # occurrence so the canonical MATLAB-mirrored order is preserved. @staticmethod def kalman_fixedIntervalSmoother(A, C, Pv, Pw, Px0, x0, y, lags): # noqa: F811 """Kalman fixed-interval (fixed-lag) smoother via augmented state. See the canonical implementation below; this forward declaration exists purely to mirror MATLAB ``DecodingAlgorithms.m`` method ordering for the parity scanner. """ return DecodingAlgorithms.kalman_fixedIntervalSmoother( # pragma: no cover - shadowed A, C, Pv, Pw, Px0, x0, y, lags )
[docs] @staticmethod def kalman_predict(x_u, Pe_u, A, Pv, GnConv=None): """Kalman filter predict step: ``x_p = A x_u``, ``Pe_p = A Pe A' + Pv``. When the converged-gain ``GnConv`` is provided the predict-step covariance is taken from the previously-filtered ``Pe_u`` (which is the steady-state covariance under the converged gain) rather than recomputed. Audit finding H1 (Agent 5): the prior implementation used ``GnConv`` itself as the covariance — but ``GnConv`` is a gain matrix (shape ``Dx x Dy``), not a covariance (``Dx x Dx``). The ``_kalman_filter_matlab`` path at lines 611-614 had this right; the standalone ``kalman_predict`` diverged. Matches MATLAB ``KalmanFilter.m:91`` (``Pe_p = Pe_u`` when ``Gn_conv`` set). """ x_vec = np.asarray(x_u, dtype=float).reshape(-1) dim = x_vec.size A_mat = _as_state_matrix(A, dim) Pe_mat = _as_state_matrix(Pe_u, dim) if _is_empty_value(GnConv): Pv_mat = _as_state_matrix(Pv, dim) Pe_p = _symmetrize(A_mat @ Pe_mat @ A_mat.T + Pv_mat) else: Pe_p = _symmetrize(Pe_mat) x_p = A_mat @ x_vec return x_p, Pe_p
[docs] @staticmethod def kalman_update(x_p, Pe_p, C, Pw, y, GnConv=None): """Kalman filter update step: incorporate observation *y* and return ``(x_u, Pe_u, G)``.""" x_vec = np.asarray(x_p, dtype=float).reshape(-1) dim = x_vec.size C_mat = np.asarray(C, dtype=float) if C_mat.ndim == 1: C_mat = C_mat.reshape(1, -1) if C_mat.shape[1] != dim: raise ValueError("C must have one column per state dimension") Pe_mat = _as_state_matrix(Pe_p, dim) y_vec = np.asarray(y, dtype=float).reshape(-1) if _is_empty_value(GnConv): Pw_mat = _as_state_matrix(Pw, y_vec.size) innovation = y_vec - C_mat @ x_vec S_cov = _symmetrize(C_mat @ Pe_mat @ C_mat.T + Pw_mat) G = Pe_mat @ C_mat.T @ np.linalg.pinv(S_cov) x_u = x_vec + G @ innovation Pe_u = _symmetrize((np.eye(dim, dtype=float) - G @ C_mat) @ Pe_mat) else: G = np.asarray(GnConv, dtype=float) innovation = y_vec - C_mat @ x_vec x_u = x_vec + G @ innovation Pe_u = _symmetrize((np.eye(dim, dtype=float) - G @ C_mat) @ Pe_mat) return x_u, Pe_u, G
@staticmethod def _state_history_time_major(x, P): x_arr = np.asarray(x, dtype=float) P_arr = np.asarray(P, dtype=float) if P_arr.ndim != 3: raise ValueError("Covariance history must be 3D") transposed = False if x_arr.ndim == 1: x_arr = x_arr[:, None] if x_arr.shape[0] == P_arr.shape[0]: return x_arr, P_arr, transposed if x_arr.shape[1] == P_arr.shape[0]: return x_arr.T, P_arr, True raise ValueError("State history shape does not align with covariance history")
[docs] @staticmethod def kalman_smootherFromFiltered(A, x_p, Pe_p, x_u, Pe_u): """RTS backward smoother from precomputed filter estimates. Returns ``(x_N, P_N, Ln)`` — smoothed states, covariances, and backward Kalman gains. """ x_p_tm, Pe_p_tm, predicted_transposed = DecodingAlgorithms._state_history_time_major(x_p, Pe_p) x_u_tm, Pe_u_tm, updated_transposed = DecodingAlgorithms._state_history_time_major(x_u, Pe_u) if predicted_transposed != updated_transposed: raise ValueError("Predicted and updated state histories must share an orientation") n_t, n_x = x_u_tm.shape x_N = x_u_tm.copy() P_N = Pe_u_tm.copy() Ln = np.zeros((max(n_t - 1, 0), n_x, n_x), dtype=float) for t in range(n_t - 2, -1, -1): A_t = _select_time_matrix(A, t, n_x) gain = Pe_u_tm[t] @ A_t.T @ np.linalg.pinv(Pe_p_tm[t + 1]) Ln[t] = gain x_N[t] = x_u_tm[t] + gain @ (x_N[t + 1] - x_p_tm[t + 1]) P_N[t] = _symmetrize(Pe_u_tm[t] + gain @ (P_N[t + 1] - Pe_p_tm[t + 1]) @ gain.T) if updated_transposed: return x_N.T, P_N, Ln return x_N, P_N, Ln
[docs] @staticmethod def kalman_smoother(A, C, Pv, Pw, Px0, x0, y): """Run a Kalman filter followed by an RTS smoother. Returns ``(x_N, P_N, Ln, x_p, Pe_p, x_u, Pe_u)``. """ observations = np.asarray(y, dtype=float) if observations.ndim == 1: observations = observations[:, None] x_prev = np.asarray(x0, dtype=float).reshape(-1) Pe_prev = _as_state_matrix(Px0, x_prev.size) n_t = observations.shape[0] n_x = x_prev.size x_p = np.zeros((n_t, n_x), dtype=float) Pe_p = np.zeros((n_t, n_x, n_x), dtype=float) x_u = np.zeros((n_t, n_x), dtype=float) Pe_u = np.zeros((n_t, n_x, n_x), dtype=float) for t in range(n_t): x_p[t], Pe_p[t] = DecodingAlgorithms.kalman_predict(x_prev, Pe_prev, A, Pv) x_u[t], Pe_u[t], _ = DecodingAlgorithms.kalman_update(x_p[t], Pe_p[t], C, Pw, observations[t]) x_prev = x_u[t] Pe_prev = Pe_u[t] x_N, P_N, Ln = DecodingAlgorithms.kalman_smootherFromFiltered(A, x_p, Pe_p, x_u, Pe_u) return x_N, P_N, Ln, x_p, Pe_p, x_u, Pe_u
[docs] @staticmethod def kalman_fixedIntervalSmoother(A, C, Pv, Pw, Px0, x0, y, lags): """Kalman fixed-interval (fixed-lag) smoother via augmented state. Matches the Matlab implementation: builds an augmented state of dimension ``(1 + lags) * n_x`` and runs ``kalman_filter`` on the augmented system. The lagged portion of the augmented state gives the exact smoothed estimate at lag *lags*. Parameters ---------- A, C, Pv, Pw : arrays System matrices (may be time-varying 3-D arrays). Px0 : (Dx, Dx) — initial covariance. x0 : (Dx,) — initial state. y : (N, Dy) or (Dy, N) — observations (auto-detected layout). lags : int — number of smoothing lags. Returns ------- x_pLag : (N+1, Dx) — predicted states at the lagged component (time-major). Pe_pLag : (N+1, Dx, Dx) — predicted covariances at the lagged component. x_uLag : (N, Dx) — updated states at the lagged component. Pe_uLag : (N, Dx, Dx) — updated covariances at the lagged component. """ A = np.asarray(A, dtype=float) C = np.asarray(C, dtype=float) Pv = np.asarray(Pv, dtype=float) Pw = np.asarray(Pw, dtype=float) Px0 = np.asarray(Px0, dtype=float) x0_vec = np.asarray(x0, dtype=float).reshape(-1) y = np.asarray(y, dtype=float) if y.ndim == 1: y = y[None, :] nStates = A.shape[0] nObs = C.shape[0] # Auto-detect layout: convert to state-major (Dy, N) for kalman_filter if y.shape[0] != nObs and y.shape[1] == nObs: y = y.T N = y.shape[1] lags = max(int(lags), 1) aug_dim = (lags + 1) * nStates def _sel(M, n): if M.ndim == 3: return M[:, :, min(n, M.shape[2] - 1)] return M # Build augmented time-varying matrices Alag = np.zeros((aug_dim, aug_dim, N), dtype=float) Pvlag = np.zeros((aug_dim, aug_dim, N), dtype=float) Clag = np.zeros((nObs, aug_dim, N), dtype=float) Pwlag = np.zeros((nObs, nObs, N), dtype=float) for n in range(N): offset = 0 for i in range(lags + 1): sl = slice(offset, offset + nStates) if i == 0: Alag[sl, sl, n] = _sel(A, n) Pvlag[sl, sl, n] = _sel(Pv, n) Clag[:, sl, n] = _sel(C, n) Pwlag[:, :, n] = _sel(Pw, n) else: prev_sl = slice(offset - nStates, offset) Alag[sl, prev_sl, n] = np.eye(nStates) offset += nStates # Augmented initial state and covariance x0lag = np.zeros(aug_dim, dtype=float) x0lag[:nStates] = x0_vec Px0lag = np.zeros((aug_dim, aug_dim), dtype=float) Px0lag[:nStates, :nStates] = Px0 # Run Kalman filter on augmented system (internal Matlab API) x_p, Pe_p, x_u, Pe_u, _, _ = DecodingAlgorithms._kalman_filter_matlab( Alag, Clag, Pvlag, Pwlag, Px0lag, x0lag, y ) # Extract the lagged portion — state-major lag_sl = slice(lags * nStates, (lags + 1) * nStates) # x_p is (aug_dim, N+1), x_u is (aug_dim, N) # Return N time steps (drop initial prediction at column 0) for # backward compatibility with the Python API where both predicted # and updated arrays have the same N rows. x_pLag_sm = x_p[lag_sl, 1:] # (Dx, N) Pe_pLag_sm = Pe_p[lag_sl, lag_sl, 1:] # (Dx, Dx, N) -- uses numpy advanced slicing on dim 3 so extract via loop x_uLag_sm = x_u[lag_sl, :] # (Dx, N) Pe_uLag_sm = Pe_u[lag_sl, lag_sl, :] # (Dx, Dx, N) # Correct the Pe slicing (nested slice on 3-D doesn't work as expected) Pe_pLag_sm = Pe_p[lags * nStates:(lags + 1) * nStates, lags * nStates:(lags + 1) * nStates, 1:] Pe_uLag_sm = Pe_u[lags * nStates:(lags + 1) * nStates, lags * nStates:(lags + 1) * nStates, :] # Return time-major to match kalman_smoother convention: (N, Dx) x_pLag = x_pLag_sm.T Pe_pLag = np.transpose(Pe_pLag_sm, (2, 0, 1)) x_uLag = x_uLag_sm.T Pe_uLag = np.transpose(Pe_uLag_sm, (2, 0, 1)) return x_pLag, Pe_pLag, x_uLag, Pe_uLag
# State-Space GLM (SSGLM) via EM Forward-Backward # Ported from Matlab DecodingAlgorithms.m (PPSS_EMFB and helpers) # ------------------------------------------------------------------ @staticmethod def _ssglm_build_basis(numBasis, minTime, maxTime, delta): """Build unit-impulse basis matrix for SSGLM.""" from .trial import SpikeTrainCollection sampleRate = 1.0 / delta basisWidth = (maxTime - minTime) / numBasis basis_cov = SpikeTrainCollection.generateUnitImpulseBasis( basisWidth, minTime, maxTime, sampleRate ) return np.asarray(basis_cov.data, dtype=float) @staticmethod def _ssglm_build_history(dN, windowTimes, delta): """Build history design matrices for each trial from spike observations.""" from .history import History K, N = dN.shape minTime = 0.0 maxTime = (N - 1) * delta if windowTimes is not None and len(windowTimes) > 0: histObj = History(windowTimes, minTime, maxTime) HkAll = [] for k in range(K): spike_indices = np.where(dN[k, :] > 0.5)[0] spike_times = spike_indices.astype(float) * delta nst = nspikeTrain(spike_times, makePlots=-1) nst.setMinTime(minTime) nst.setMaxTime(maxTime) hist_cov = histObj._compute_single_history(nst) HkAll.append(np.asarray(hist_cov.data, dtype=float)) return HkAll else: return [np.zeros((N, 0), dtype=float) for _ in range(K)]
[docs] @staticmethod def PPSS_EMFB(A, Q0, x0, dN, fitType, delta, gamma0, windowTimes, numBasis, neuronName=None): """EM Forward-Backward algorithm for state-space GLM. Wraps PPSS_EM in a forward-backward-forward cycle for improved convergence. Parameters ---------- A : (R, R) state transition matrix (typically identity for random walk) Q0 : (R,) initial state noise variance x0 : (R,) initial state coefficients dN : (K, N) binary spike observations (K trials, N time bins) fitType : 'poisson' or 'binomial' delta : time bin width in seconds gamma0 : (J,) initial history coefficients windowTimes : array of history window boundaries numBasis : number of basis functions R neuronName : identifier for the neuron (for labeling) Returns ------- xKFinal : (R, K) estimated state trajectories WKFinal : (R, R, K) estimated state covariances WkuFinal : (R, R, K, K) cross-trial covariances Qhat : (R,) estimated state noise variance gammahat : (J,) estimated history coefficients fitResults : FitResult object with goodness-of-fit diagnostics stimulus : (R, K) estimated stimulus effect stimCIs : (R, K, 2) stimulus confidence intervals logll : float, log-likelihood at convergence QhatAll : parameter history gammahatAll : parameter history nIter : total EM iterations """ K, N = dN.shape fitType = str(fitType or "poisson").lower() Q0_vec = np.asarray(Q0, dtype=float).reshape(-1) if Q0_vec.size == numBasis * numBasis: Q0_vec = np.diag(Q0_vec.reshape(numBasis, numBasis)) gamma0_vec = np.asarray(gamma0, dtype=float).reshape(-1) if gamma0 is not None else np.array([], dtype=float) Qhat_cur = Q0_vec.copy() gammahat_cur = gamma0_vec.copy() xK0 = np.asarray(x0, dtype=float).reshape(-1) # Build history matrices HkAll = DecodingAlgorithms._ssglm_build_history(dN, windowTimes, delta) HkAllR = list(reversed(HkAll)) tolAbs = _EM_TOL_ABS tolRel = _EM_TOL_REL llTol = _EM_LL_TOL maxIter = _PPSS_EMFB_MAX_ITER Qhat_history = [Qhat_cur.copy()] gammahat_history = [gammahat_cur.copy()] logll_list = [] stoppingCriteria = False cnt = 0 xK = None WK = None Wku = None while not stoppingCriteria and cnt < maxIter: # Forward EM xK, WK, Wku, Qnew, gnew, ll, _, _, _, negLL = DecodingAlgorithms.PPSS_EM( A, Qhat_cur, xK0, dN, fitType, delta, gammahat_cur, windowTimes, numBasis, HkAll ) if not negLL: # Backward EM (reversed trial order) xKR, _, _, QnewR, gnewR, _, _, _, _, negLLR = DecodingAlgorithms.PPSS_EM( A, Qnew, xK[:, -1], np.flipud(dN), fitType, delta, gnew, windowTimes, numBasis, HkAllR ) if not negLLR: # Forward EM again with backward-updated parameters # Matlab: PPSS_EM(A, QhatR(:,cnt+1), xKR(:,end), dN, ...) # Use backward EM's final state as initial state for forward pass xK2, WK2, Wku2, Qnew2, gnew2, ll2, _, _, _, negLL2 = DecodingAlgorithms.PPSS_EM( A, QnewR, xKR[:, -1], dN, fitType, delta, gnewR, windowTimes, numBasis, HkAll ) if not negLL2: xK = xK2 WK = WK2 Wku = Wku2 Qnew = Qnew2 gnew = gnew2 ll = ll2 Qhat_cur = Qnew gammahat_cur = gnew Qhat_history.append(Qnew.copy()) gammahat_history.append(gnew.copy()) logll_list.append(ll) xK0 = xK[:, 0] # Check convergence if cnt > 0: dLikelihood = logll_list[cnt] - logll_list[cnt - 1] else: dLikelihood = np.inf if len(Qhat_history) >= 2: Q_prev = Qhat_history[-2] Q_cur = Qhat_history[-1] dQvals = np.abs(np.sqrt(np.maximum(Q_cur, 0)) - np.sqrt(np.maximum(Q_prev, 0))) g_prev = gammahat_history[-2] g_cur = gammahat_history[-1] dGamma = np.abs(g_cur - g_prev) if g_cur.size > 0 else np.array([0.0]) dMax = max(float(np.max(dQvals)), float(np.max(dGamma))) Q_denom = np.sqrt(np.maximum(Q_prev, 1e-30)) dQRel = float(np.max(np.abs(dQvals / Q_denom))) if g_prev.size > 0 and np.any(g_prev != 0): g_denom = np.maximum(np.abs(g_prev), 1e-30) dGammaRel = float(np.max(np.abs(dGamma / g_denom))) dMaxRel = max(dQRel, dGammaRel) else: dMaxRel = dQRel if dMax < tolAbs and dMaxRel < tolRel: stoppingCriteria = True if abs(dLikelihood) < llTol or dLikelihood < 0: stoppingCriteria = True cnt += 1 # Select best iteration logll_arr = np.array(logll_list) if logll_arr.size > 0: maxLLIndex = int(np.argmax(logll_arr)) else: maxLLIndex = 0 xKFinal = xK WKFinal = WK WkuFinal = Wku Qhat = Qhat_history[min(maxLLIndex + 1, len(Qhat_history) - 1)] gammahat = gammahat_history[min(maxLLIndex + 1, len(gammahat_history) - 1)] logll = float(logll_arr[maxLLIndex]) if logll_arr.size > 0 else -np.inf QhatAll = np.column_stack(Qhat_history) if Qhat_history else Q0_vec.reshape(-1, 1) gammahatAll = np.vstack(gammahat_history) if gammahat_history and gammahat_history[0].size > 0 else np.array([[]]) R = numBasis x0Final = xK[:, 0] if xK is not None else np.zeros(R) SumXkTermsFinal = np.diag(Qhat) * K McInfo = 100 McCI = 3000 # Observed log-likelihood logllobs = logll + R * K * np.log(2 * np.pi) + K / 2.0 * np.log( max(float(np.prod(np.maximum(Qhat, np.finfo(float).eps))), np.finfo(float).eps) ) + 0.5 * float(np.trace(np.linalg.pinv(np.diag(Qhat)) @ SumXkTermsFinal)) nIter = cnt # Information matrix and result packaging InfoMat = DecodingAlgorithms.estimateInfoMat( fitType, dN, HkAll, A, x0Final, xKFinal, WKFinal, WkuFinal, Qhat, gammahat, windowTimes, SumXkTermsFinal, delta, McInfo ) fitResults = DecodingAlgorithms.prepareEMResults( fitType, neuronName, dN, HkAll, xKFinal, WKFinal, Qhat, gammahat, windowTimes, delta, InfoMat, logllobs ) stimCIs, stimulus = DecodingAlgorithms._ComputeStimulusCIs_MC( fitType, xKFinal, WkuFinal, delta, McCI ) return (xKFinal, WKFinal, WkuFinal, Qhat, gammahat, fitResults, stimulus, stimCIs, logll, QhatAll, gammahatAll, nIter)
[docs] @staticmethod def PPSS_EM(A, Q0, x0, dN, fitType, delta, gamma0, windowTimes, numBasis, HkAll): """Inner EM loop for state-space GLM. Parameters ---------- A : (R, R) state transition Q0 : (R,) initial state noise variance x0 : (R,) initial state dN : (K, N) observations fitType : 'poisson' or 'binomial' delta : time bin width gamma0 : (J,) initial history coefficients windowTimes : history window boundaries numBasis : number of basis functions HkAll : precomputed history matrices Returns ------- xKFinal, WKFinal, WkuFinal, Qhat, gammahat, logll, QhatAll, gammahatAll, nIter, negLL """ if numBasis is None: numBasis = 20 if delta is None or delta == 0: delta = 0.001 fitType = str(fitType or "poisson").lower() Q0_vec = np.asarray(Q0, dtype=float).reshape(-1) if Q0_vec.size == numBasis * numBasis: Q0_vec = np.diag(Q0_vec.reshape(numBasis, numBasis)) gamma0_vec = np.asarray(gamma0, dtype=float).reshape(-1) if gamma0 is not None else np.array([], dtype=float) tolAbs = _EM_TOL_ABS tolRel = _EM_TOL_REL llTol = _EM_LL_TOL maxIter = _EM_MAX_ITER numToKeep = 10 # Circular buffer storage Qhat = np.zeros((Q0_vec.size, numToKeep), dtype=float) Qhat[:, 0] = Q0_vec gammahat = np.zeros((numToKeep, gamma0_vec.size), dtype=float) gammahat[0, :] = gamma0_vec xK_buf = [None] * numToKeep WK_buf = [None] * numToKeep Wku_buf = [None] * numToKeep x0hat = np.asarray(x0, dtype=float).reshape(-1) logll_list = [] dLikelihood = [np.inf] negLL = False stoppingCriteria = False cnt = 0 while not stoppingCriteria and cnt < maxIter: si = cnt % numToKeep si_p1 = (cnt + 1) % numToKeep si_m1 = (cnt - 1) % numToKeep xK_cur, WK_cur, Wku_cur, ll, SumXkTerms, _ = DecodingAlgorithms.PPSS_EStep( A, Qhat[:, si], x0hat, dN, HkAll, fitType, delta, gammahat[si, :], numBasis ) xK_buf[si] = xK_cur WK_buf[si] = WK_cur Wku_buf[si] = Wku_cur logll_list.append(ll) Qnew, gnew = DecodingAlgorithms.PPSS_MStep( dN, HkAll, fitType, xK_cur, WK_cur, gammahat[si, :], delta, SumXkTerms, windowTimes ) Qhat[:, si_p1] = Qnew gammahat[si_p1, :] = gnew if cnt == 0: dLikelihood.append(np.inf) else: dLikelihood.append(logll_list[cnt] - logll_list[cnt - 1]) # Check convergence if cnt > 0: dQvals = np.abs(np.sqrt(np.maximum(Qhat[:, si], 0)) - np.sqrt(np.maximum(Qhat[:, si_m1], 0))) dGamma = np.abs(gammahat[si, :] - gammahat[si_m1, :]) dMax = max(np.max(dQvals), np.max(dGamma)) if dGamma.size > 0 else float(np.max(dQvals)) Q_prev = np.sqrt(np.maximum(Qhat[:, si_m1], 1e-30)) dQRel = float(np.max(np.abs(dQvals / Q_prev))) if dGamma.size > 0: g_prev = np.maximum(np.abs(gammahat[si_m1, :]), 1e-30) dGammaRel = float(np.max(np.abs(dGamma / g_prev))) dMaxRel = max(dQRel, dGammaRel) else: dMaxRel = dQRel if dMax < tolAbs and dMaxRel < tolRel: stoppingCriteria = True negLL = False if abs(dLikelihood[-1]) < llTol or dLikelihood[-1] < 0: stoppingCriteria = True negLL = True cnt += 1 # Select best iteration by log-likelihood logll_arr = np.array(logll_list) if logll_arr.size > 0: maxLLIndex = int(np.argmax(logll_arr)) else: maxLLIndex = 0 maxLLIndMod = maxLLIndex % numToKeep nIter = cnt xKFinal = xK_buf[maxLLIndMod] if xK_buf[maxLLIndMod] is not None else np.zeros((numBasis, dN.shape[0])) WKFinal = WK_buf[maxLLIndMod] if WK_buf[maxLLIndMod] is not None else np.zeros((numBasis, numBasis, dN.shape[0])) WkuFinal = Wku_buf[maxLLIndMod] if Wku_buf[maxLLIndMod] is not None else np.zeros((numBasis, numBasis, dN.shape[0], dN.shape[0])) QhatFinal = Qhat[:, maxLLIndMod] gammahatFinal = gammahat[maxLLIndMod, :] logllFinal = float(logll_arr[maxLLIndex]) if logll_arr.size > 0 else -np.inf QhatAll = Qhat[:, : min(cnt + 1, numToKeep)] gammahatAll = gammahat[: min(cnt + 1, numToKeep), :] return xKFinal, WKFinal, WkuFinal, QhatFinal, gammahatFinal, logllFinal, QhatAll, gammahatAll, nIter, negLL
[docs] @staticmethod def PPSS_EStep(A, Q, x0, dN, HkAll, fitType, delta, gamma, numBasis): """E-step: Forward Kalman filter + backward RTS smoother + cross-covariance. Parameters ---------- A : (R, R) state transition matrix Q : (R,) or (R, R) state noise covariance (diagonal vector or matrix) x0 : (R,) initial state dN : (K, N) binary spike observations (K trials, N time bins) HkAll : list of K arrays, each (N, J) history design matrices fitType : 'poisson' or 'binomial' delta : time bin width gamma : (J,) history coefficients numBasis : number of basis functions R Returns ------- x_K, W_K, Wku, logll, sumXkTerms, sumPPll """ K, N = dN.shape minTime = 0.0 maxTime = (N - 1) * delta basisMat = DecodingAlgorithms._ssglm_build_basis(numBasis, minTime, maxTime, delta) # Ensure basisMat has N rows matching dN columns if basisMat.shape[0] != N: basisMat = basisMat[:N, :] if basisMat.shape[0] > N else np.vstack( [basisMat, np.zeros((N - basisMat.shape[0], basisMat.shape[1]))] ) Q_diag = np.asarray(Q, dtype=float).reshape(-1) if Q_diag.size == numBasis * numBasis: Q_mat = Q_diag.reshape(numBasis, numBasis) Q_diag = np.diag(Q_mat) Q_mat = np.diag(Q_diag) A_mat = np.asarray(A, dtype=float) if A_mat.ndim < 2: A_mat = np.eye(numBasis, dtype=float) * A_mat x0_vec = np.asarray(x0, dtype=float).reshape(-1) gamma_vec = np.asarray(gamma, dtype=float).reshape(-1) R = numBasis fitType = str(fitType).lower() # Forward Kalman filter x_p = np.zeros((R, K), dtype=float) x_u = np.zeros((R, K), dtype=float) W_p = np.zeros((R, R, K), dtype=float) W_u = np.zeros((R, R, K), dtype=float) for k in range(K): if k == 0: x_p[:, k] = A_mat @ x0_vec W_p[:, :, k] = Q_mat.copy() else: x_p[:, k] = A_mat @ x_u[:, k - 1] W_p[:, :, k] = A_mat @ W_u[:, :, k - 1] @ A_mat.T + Q_mat Hk = HkAll[k] stimK = basisMat @ x_p[:, k] if fitType == "poisson": histEffect = np.exp(np.clip(Hk @ gamma_vec, -30, 30)) if gamma_vec.size > 0 and Hk.shape[1] > 0 else np.ones(N) stimEffect = np.exp(np.clip(stimK, -30, 30)) lambdaDelta = stimEffect * histEffect GradLogLD = basisMat # (N, R) GradLD = basisMat * lambdaDelta[:, None] # (N, R) sumValVec = GradLogLD.T @ dN[k, :] - np.diag(GradLD.T @ basisMat) sumValMat = GradLD.T @ basisMat elif fitType == "binomial": Hk = HkAll[k] stimK = basisMat @ x_p[:, k] linpred = stimK + (Hk @ gamma_vec if gamma_vec.size > 0 and Hk.shape[1] > 0 else 0.0) linpred = np.clip(linpred, -30, 30) lambdaDelta = 1.0 / (1.0 + np.exp(-linpred)) GradLogLD = basisMat * (1.0 - lambdaDelta)[:, None] JacobianLogLD = basisMat * (lambdaDelta * (-1.0 + lambdaDelta))[:, None] GradLD = basisMat * (lambdaDelta * (1.0 - lambdaDelta))[:, None] # Sigmoid third-derivative factor: lambdaDelta'' / lambdaDelta' # equals (1 - 2*lambdaDelta), NOT (1 - 2*lambdaDelta**2). # The squared form was a port typo — every other binomial # site in this file (lines 533, 603 of MATLAB DecodingAlgorithms.m; # Python lines 2681, 2773, 3291) uses the linear form. JacobianLD = basisMat * (lambdaDelta * (1.0 - lambdaDelta) * (1.0 - 2.0 * lambdaDelta))[:, None] sumValVec = GradLogLD.T @ dN[k, :] - np.diag(GradLD.T @ basisMat) sumValMat = -np.diag(JacobianLogLD.T @ dN[k, :]) + JacobianLD.T @ basisMat else: raise ValueError(f"Unsupported fitType: {fitType}") # Kalman update (regularized: see _robust_psd_inverse / SSGLM_STATE_BOUND). # The posterior precision invW_u can become ill-conditioned / non-finite # for high-rate or sparse bins; invert robustly and project to PSD so the # EM cannot abort on an eigh non-convergence. W_p_inv = np.linalg.inv(W_p[:, :, k] + 1e-12 * np.eye(R)) invW_u = W_p_inv + sumValMat W_u[:, :, k] = _robust_psd_inverse(invW_u, R) # Keep the log-rate state physical so a diverged trial cannot poison the # next trial's prior (real states sit near log(rate*delta), ~[-12, 5]). x_u[:, k] = np.clip( x_p[:, k] + W_u[:, :, k] @ sumValVec, -SSGLM_STATE_BOUND, SSGLM_STATE_BOUND, ) # Backward RTS smoother x_K = np.zeros((R, K), dtype=float) W_K = np.zeros((R, R, K), dtype=float) Lk = np.zeros((R, R, K), dtype=float) x_K[:, K - 1] = x_u[:, K - 1] W_K[:, :, K - 1] = W_u[:, :, K - 1] for k in range(K - 2, -1, -1): Lk[:, :, k] = W_u[:, :, k] @ A_mat.T @ np.linalg.inv(W_p[:, :, k + 1] + 1e-12 * np.eye(R)) x_K[:, k] = x_u[:, k] + Lk[:, :, k] @ (x_K[:, k + 1] - x_p[:, k + 1]) W_K[:, :, k] = W_u[:, :, k] + Lk[:, :, k] @ (W_K[:, :, k + 1] - W_p[:, :, k + 1]) @ Lk[:, :, k].T W_K[:, :, k] = 0.5 * (W_K[:, :, k] + W_K[:, :, k].T) # Cross-trial covariance Wku (R, R, K, K) Wku = np.zeros((R, R, K, K), dtype=float) for k in range(K): Wku[:, :, k, k] = W_K[:, :, k] # Dk depends on k only, not on u: build it in one backward pass rather # than re-inverting W_p inside the u-loop (K-1 inversions instead of # K(K-1)/2; bit-identical output). Dk = np.zeros((R, R, K), dtype=float) for k in range(K - 2, -1, -1): Dk[:, :, k] = W_u[:, :, k] @ A_mat.T @ np.linalg.inv(W_p[:, :, k + 1] + 1e-12 * np.eye(R)) for u in range(K - 1, 0, -1): for k in range(u - 1, -1, -1): Wku[:, :, k, u] = Dk[:, :, k] @ Wku[:, :, k + 1, u] Wku[:, :, u, k] = Wku[:, :, k, u] # Sufficient statistics for M-step Sxkxkp1 = np.zeros((R, R), dtype=float) Sxkp1xkp1 = np.zeros((R, R), dtype=float) Sxkxk = np.zeros((R, R), dtype=float) for k in range(K - 1): Sxkxkp1 += Wku[:, :, k, k + 1] + np.outer(x_K[:, k], x_K[:, k + 1]) Sxkp1xkp1 += W_K[:, :, k + 1] + np.outer(x_K[:, k + 1], x_K[:, k + 1]) Sxkxk += W_K[:, :, k] + np.outer(x_K[:, k], x_K[:, k]) sumXkTerms = ( Sxkp1xkp1 - A_mat @ Sxkxkp1 - Sxkxkp1.T @ A_mat.T + A_mat @ Sxkxk @ A_mat.T + W_K[:, :, 0] + np.outer(x_K[:, 0], x_K[:, 0]) - A_mat @ np.outer(x0_vec, x_K[:, 0]) - np.outer(x_K[:, 0], x0_vec) @ A_mat.T + A_mat @ np.outer(x0_vec, x0_vec) @ A_mat.T ) # Point process log-likelihood sumPPll = 0.0 for k in range(K): Hk = HkAll[k] Wk = basisMat @ np.diag(W_K[:, :, k]) stimK = basisMat @ x_K[:, k] if fitType == "poisson": hist_term = Hk @ gamma_vec if gamma_vec.size > 0 and Hk.shape[1] > 0 else np.zeros(N) histEffect = np.exp(np.clip(hist_term, -30, 30)) stimK_clipped = np.clip(stimK, -30, 30) stimEffect = np.exp(stimK_clipped) + np.exp(stimK_clipped) / 2.0 * Wk ExplambdaDelta = stimEffect * histEffect ExplogLD = stimK + hist_term sumPPll += float(np.sum(dN[k, :] * ExplogLD - ExplambdaDelta)) elif fitType == "binomial": hist_term = Hk @ gamma_vec if gamma_vec.size > 0 and Hk.shape[1] > 0 else np.zeros(N) linpred = np.clip(stimK + hist_term, -30, 30) lambdaDelta = 1.0 / (1.0 + np.exp(-linpred)) ExplambdaDelta = lambdaDelta + Wk * (lambdaDelta * (1.0 - lambdaDelta) * (1.0 - 2.0 * lambdaDelta)) / 2.0 ExplogLD = linpred - np.log(1.0 + np.exp(linpred)) - Wk * lambdaDelta * (1.0 - lambdaDelta) * 0.5 sumPPll += float(np.sum(dN[k, :] * ExplogLD - ExplambdaDelta)) det_Q = float(np.prod(np.maximum(Q_diag, np.finfo(float).eps))) logll = ( -R * K * np.log(2.0 * np.pi) - K / 2.0 * np.log(det_Q) + sumPPll - 0.5 * float(np.trace(np.linalg.pinv(Q_mat) @ sumXkTerms)) ) return x_K, W_K, Wku, logll, sumXkTerms, sumPPll
[docs] @staticmethod def PPSS_MStep(dN, HkAll, fitType, x_K, W_K, gamma, delta, sumXkTerms, windowTimes): """M-step: Update Q via closed form, gamma via Newton-Raphson. Parameters ---------- dN : (K, N) HkAll : list of K arrays (N, J) fitType : 'poisson' or 'binomial' x_K : (R, K) smoothed states W_K : (R, R, K) smoothed covariances gamma : (J,) current history coefficients delta : time bin width sumXkTerms : (R, R) sufficient statistics from E-step windowTimes : array of history window boundaries Returns ------- Qhat : (R,) updated state noise variance (diagonal) gamma_new : (J,) updated history coefficients """ K, N = dN.shape R = x_K.shape[0] fitType = str(fitType).lower() # Q update (closed form) sumQ = np.diag(np.diag(sumXkTerms)) Qhat = sumQ / K eigvals, eigvecs = np.linalg.eigh(Qhat) eigvals = np.maximum(eigvals, 1e-8) Qhat = eigvecs @ np.diag(eigvals) @ eigvecs.T Qhat = np.diag(Qhat) # Return as vector # Build basis matrix for gamma update minTime = 0.0 maxTime = (N - 1) * delta basisMat = DecodingAlgorithms._ssglm_build_basis(R, minTime, maxTime, delta) if basisMat.shape[0] != N: basisMat = basisMat[:N, :] if basisMat.shape[0] > N else np.vstack( [basisMat, np.zeros((N - basisMat.shape[0], basisMat.shape[1]))] ) gamma_vec = np.asarray(gamma, dtype=float).reshape(-1) gamma_new = gamma_vec.copy() J = gamma_new.size # Newton-Raphson for gamma (history coefficients) if windowTimes is not None and len(windowTimes) > 0 and J > 0 and np.any(gamma_new != 0): converged = False max_iter = 300 for iteration in range(max_iter): gradQ = np.zeros(J, dtype=float) jacQ = np.zeros((J, J), dtype=float) for k in range(K): Hk = HkAll[k] if Hk.shape[1] == 0: continue Wk = basisMat @ np.diag(W_K[:, :, k]) stimK = basisMat @ x_K[:, k] if fitType == "poisson": hist_term = np.clip(gamma_new @ Hk.T, -30, 30) histEffect = np.exp(hist_term) stimK_clipped = np.clip(stimK, -30, 30) stimEffect = np.exp(stimK_clipped) + np.exp(stimK_clipped) / 2.0 * Wk lambdaDelta = stimEffect * histEffect gradQ += Hk.T @ dN[k, :] - Hk.T @ lambdaDelta jacQ -= (Hk * lambdaDelta[:, None]).T @ Hk elif fitType == "binomial": linpred = np.clip(stimK + Hk @ gamma_new, -30, 30) lambdaDelta = 1.0 / (1.0 + np.exp(-linpred)) histEffect = np.exp(np.clip(gamma_new @ Hk.T, -30, 30)) stimEffect = np.exp(np.clip(stimK, -30, 30)) C = stimEffect * histEffect M = np.where(C > 1e-30, 1.0 / C, 1e30) ExpLambdaDelta = lambdaDelta + Wk * (lambdaDelta * (1.0 - lambdaDelta) * (1.0 - 2.0 * lambdaDelta)) / 2.0 ExpLDSquaredTimesInvExp = lambdaDelta ** 2 * M ExpLDCubedTimesInvExpSquared = ( lambdaDelta ** 3 * M ** 2 + Wk / 2.0 * (3.0 * M ** 4 * lambdaDelta ** 3 + 12.0 * lambdaDelta ** 3 * M ** 3 - 12.0 * M ** 4 * lambdaDelta ** 4) ) gradQ += (Hk * (1.0 - ExpLambdaDelta)[:, None]).T @ dN[k, :] \ - (Hk * (ExpLDSquaredTimesInvExp / np.maximum(lambdaDelta, 1e-30))[:, None]).T @ lambdaDelta jacQ -= (Hk * (ExpLDSquaredTimesInvExp * dN[k, :])[:, None]).T @ Hk \ + (Hk * ExpLDSquaredTimesInvExp[:, None]).T @ Hk \ + (Hk * (2.0 * ExpLDCubedTimesInvExpSquared)[:, None]).T @ Hk # Newton-Raphson update try: gamma_temp = gamma_new - np.linalg.pinv(jacQ) @ gradQ except np.linalg.LinAlgError: gamma_temp = gamma_new if np.any(np.isnan(gamma_temp)): gamma_temp = gamma_new mabsDiff = float(np.max(np.abs(gamma_temp - gamma_new))) gamma_new = gamma_temp if mabsDiff < 1e-2: converged = True break # Clamp gamma — Matlab SSGLM.m:596-597. Symmetric clamp prevents # exp(gamma * H) underflow in subsequent E-step (large-magnitude # gamma → 0 or inf after exponentiation). gamma_new[gamma_new > 1e2] = 1e1 gamma_new[gamma_new < -1e2] = -1e1 return Qhat, gamma_new
@staticmethod def _ComputeStimulusCIs_MC(fitType, xK, Wku, delta, Mc=3000, alphaVal=0.05): """Monte Carlo confidence intervals for SSGLM stimulus estimate. Uses Cholesky decomposition of the cross-trial covariance to generate draws of the state trajectory, then computes empirical CIs. """ fitType = str(fitType).lower() numBasis, K = xK.shape CIs = np.zeros((numBasis, K, 2), dtype=float) for r in range(numBasis): WkuTemp = Wku[r, r, :, :] # (K, K) cross-trial covariance for basis r try: chol_m = np.linalg.cholesky(WkuTemp + 1e-10 * np.eye(K)) except np.linalg.LinAlgError: eigvals, eigvecs = np.linalg.eigh(WkuTemp) eigvals = np.maximum(eigvals, 1e-10) chol_m = eigvecs @ np.diag(np.sqrt(eigvals)) stimulusDraw = np.zeros((Mc, K), dtype=float) for c in range(Mc): z = np.random.randn(K) xKDraw = xK[r, :] + chol_m.T @ z if fitType == "poisson": stimulusDraw[c, :] = np.exp(np.clip(xKDraw, -30, 30)) / delta elif fitType == "binomial": xKDraw_clip = np.clip(xKDraw, -30, 30) stimulusDraw[c, :] = (np.exp(xKDraw_clip) / (1.0 + np.exp(xKDraw_clip))) / delta else: stimulusDraw[c, :] = xKDraw / delta for k in range(K): CIs[r, k, 0] = float(np.percentile(stimulusDraw[:, k], 100.0 * alphaVal / 2.0)) CIs[r, k, 1] = float(np.percentile(stimulusDraw[:, k], 100.0 * (1.0 - alphaVal / 2.0))) if fitType == "poisson": stimulus = np.exp(np.clip(xK, -30, 30)) / delta elif fitType == "binomial": xK_clip = np.clip(xK, -30, 30) stimulus = (np.exp(xK_clip) / (1.0 + np.exp(xK_clip))) / delta else: stimulus = xK / delta return CIs, stimulus
[docs] @staticmethod def prepareEMResults(fitType, neuronNumber, dN, HkAll, xK, WK, Q, gamma, windowTimes, delta, informationMatrix, logll): """Package SSGLM EM results into a FitResult object.""" from .core import Covariate from .fit import FitResult from .history import History from .trial import ( ConfigCollection, SpikeTrainCollection, TrialConfig, ) from .analysis import Analysis fitType = str(fitType).lower() numBasis, K = xK.shape R = numBasis N = dN.shape[1] minTime = 0.0 maxTime = (N - 1) * delta sampleRate = 1.0 / delta gamma_vec = np.asarray(gamma, dtype=float).reshape(-1) # Build basis matrix basisMat = DecodingAlgorithms._ssglm_build_basis(numBasis, minTime, maxTime, delta) if basisMat.shape[0] != N: basisMat = basisMat[:N, :] if basisMat.shape[0] > N else np.vstack( [basisMat, np.zeros((N - basisMat.shape[0], basisMat.shape[1]))] ) # Standard errors from information matrix try: SE = np.sqrt(np.abs(np.diag(np.linalg.inv(informationMatrix)))) except np.linalg.LinAlgError: SE = np.zeros(informationMatrix.shape[0], dtype=float) # Build per-trial standard errors xKbeta = xK.T.reshape(-1) # (K*R,) seXK = np.zeros(K * R, dtype=float) for k in range(K): seXK[k * R:(k + 1) * R] = np.sqrt(np.maximum(np.diag(WK[:, :, k]), 0.0)) # Neuron name if neuronNumber is None: name = "N01" elif isinstance(neuronNumber, (int, float)): n = int(neuronNumber) name = f"N{n:02d}" if 0 < n < 10 else f"N{n}" else: name = str(neuronNumber) # Create spike trains from dN nst_list = [] for k in range(K): spike_indices = np.where(dN[k, :] > 0.5)[0] spike_times = spike_indices.astype(float) * delta nst_k = nspikeTrain(spike_times, name=name, makePlots=-1) nst_k.setMinTime(minTime) nst_k.setMaxTime(maxTime) nst_list.append(nst_k) nCopy = SpikeTrainCollection(nst_list) nCopy = nCopy.toSpikeTrain() # Compute lambda (conditional intensity) lambdaData = [] otherLabels = [] cnt = 0 for k in range(K): Hk = HkAll[k] stimK = basisMat @ xK[:, k] if fitType == "poisson": hist_term = gamma_vec @ Hk.T if gamma_vec.size > 0 and Hk.shape[1] > 0 else np.zeros(N) histEffect = np.exp(np.clip(hist_term, -30, 30)) stimEffect = np.exp(np.clip(stimK, -30, 30)) lambdaDelta = histEffect * stimEffect / delta elif fitType == "binomial": linpred = np.clip(stimK + (Hk @ gamma_vec if gamma_vec.size > 0 and Hk.shape[1] > 0 else 0.0), -30, 30) hist_term = np.clip(gamma_vec @ Hk.T, -30, 30) if gamma_vec.size > 0 and Hk.shape[1] > 0 else np.zeros(N) histEffect = np.exp(hist_term) stimEffect = np.exp(np.clip(stimK, -30, 30)) C = histEffect * stimEffect lambdaDelta = C / (1.0 + C) / delta else: lambdaDelta = np.zeros(N) lambdaData.append(lambdaDelta) for r in range(R): label = f"b{r + 1:02d}_{{{k + 1}}}" if r + 1 < 10 else f"b{r + 1}_{{{k + 1}}}" otherLabels.append(label) cnt += 1 lambdaData = np.concatenate(lambdaData) lambdaTime = np.arange(len(lambdaData)) * delta + minTime nCopy.setMaxTime(float(np.max(lambdaTime))) nCopy.setMinTime(float(np.min(lambdaTime))) # Covariance labels covarianceLabels = [f"Q{r + 1:02d}" if r + 1 < 10 else f"Q{r + 1}" for r in range(R)] # History labels histLabels = [] if windowTimes is not None and len(windowTimes) > 0: wt = np.asarray(windowTimes, dtype=float) for i in range(len(wt) - 1): histLabels.append(f"[{wt[i]:.3g},{wt[i + 1]:.3g}]") allLabels = otherLabels + covarianceLabels + histLabels # History objects if windowTimes is not None and len(windowTimes) > 0: histObj = [History(windowTimes, minTime, maxTime)] else: histObj = [None] # Trial configuration numBasisStr = str(numBasis) numHistStr = str(len(windowTimes) - 1) if windowTimes is not None and len(windowTimes) > 1 else "0" if histObj[0] is not None: cfg_name = f"SSGLM(N_{{b}}={numBasisStr})+Hist(N_{{h}}={numHistStr})" else: cfg_name = f"SSGLM(N_{{b}}={numBasisStr})" tc = TrialConfig([allLabels], sampleRate, histObj, []) tc.setName(cfg_name) configColl = ConfigCollection([tc]) # Lambda covariate lambda_cov = Covariate( lambdaTime, lambdaData, r"\Lambda(t)", "time", "s", "Hz", [r"\lambda_{1}"] ) # Model selection criteria AIC = 2.0 * len(allLabels) - 2.0 * logll BIC = -2.0 * logll + len(allLabels) * np.log(max(len(lambdaData), 1)) dev = -2.0 * logll # Stats structure statsStruct = { "beta": np.concatenate([xKbeta, np.asarray(Q, dtype=float).reshape(-1), gamma_vec]), "se": np.concatenate([seXK, SE]), } # Coefficients b = [statsStruct["beta"]] stats = [statsStruct] distrib = [fitType] # Spike trains for FitResult spikeTraining = [nst.nstCopy() for nst in nst_list] for st in spikeTraining: st.setName(name) XvalData = [None] XvalTime = [None] numHist = [len(windowTimes) - 1] if windowTimes is not None and len(windowTimes) > 1 else [0] ensHistObj = [None] fitResults = FitResult( nCopy, [allLabels], numHist, histObj, ensHistObj, lambda_cov, b, dev, stats, AIC, BIC, logll, configColl, XvalData, XvalTime, distrib ) # Goodness-of-fit (silent) try: Analysis.KSPlot(fitResults, DTCorrection=1, makePlot=0) except Exception: pass try: Analysis.plotInvGausTrans(fitResults, makePlot=0) except Exception: pass try: Analysis.plotFitResidual(fitResults, makePlot=0) except Exception: pass return fitResults
[docs] @staticmethod def ComputeStimulusCIs(fitType, xK, Wku, delta, Mc=None, alphaVal=0.05): """Confidence intervals for stimulus estimate. When ``Wku`` is a 4-D array ``(numBasis, numBasis, K, K)`` (SSGLM cross-trial covariance), uses Monte Carlo sampling matching Matlab's ``DecodingAlgorithms.ComputeStimulusCIs``. When ``Wku`` is a 3-D array ``(N, Dx, Dx)`` (e.g. smoother output), falls back to z-score Gaussian CIs with inverse-link transform. Parameters ---------- fitType : str ``'poisson'`` or ``'binomial'``. xK : array Smoothed state estimates. Shape ``(numBasis, K)`` for SSGLM or ``(N, Dx)`` for smoother output. Wku : array Covariance. Shape ``(numBasis, numBasis, K, K)`` for SSGLM or ``(N, Dx, Dx)`` for smoother output. delta : float Time-step size in seconds. Mc : int, optional Number of Monte Carlo draws (default 3000). Ignored when using z-score fallback. alphaVal : float, optional Significance level for two-sided CIs (default 0.05). Returns ------- CIs : array, shape ``(..., 2)`` Lower and upper confidence bounds. stimulus : array Point estimate (inverse-link of xK, divided by delta for MC mode). """ Wku_arr = np.asarray(Wku, dtype=float) # SSGLM cross-trial path: 4-D covariance (numBasis, numBasis, K, K) if Wku_arr.ndim == 4: if Mc is None: Mc = 3000 return DecodingAlgorithms._ComputeStimulusCIs_MC( fitType, xK, Wku, delta, Mc=Mc, alphaVal=alphaVal ) # Fallback: 3-D covariance (N, Dx, Dx) from smoother — z-score CIs x_tm, W_tm, transposed = DecodingAlgorithms._state_history_time_major(xK, Wku) variances = np.clip(np.diagonal(W_tm, axis1=1, axis2=2), 0.0, None) from scipy.stats import norm # lazy: keep scipy.stats out of `import nstat` z = float(norm.ppf(1.0 - float(alphaVal) / 2.0)) lower = x_tm - z * np.sqrt(variances) upper = x_tm + z * np.sqrt(variances) fit_type = str(fitType).lower() if fit_type == "poisson": stimulus = np.exp(np.clip(x_tm, -20.0, 20.0)) ci_lower = np.exp(np.clip(lower, -20.0, 20.0)) ci_upper = np.exp(np.clip(upper, -20.0, 20.0)) elif fit_type == "binomial": stimulus = 1.0 / (1.0 + np.exp(-np.clip(x_tm, -20.0, 20.0))) ci_lower = 1.0 / (1.0 + np.exp(-np.clip(lower, -20.0, 20.0))) ci_upper = 1.0 / (1.0 + np.exp(-np.clip(upper, -20.0, 20.0))) else: stimulus = x_tm ci_lower = lower ci_upper = upper ci = np.stack([ci_lower, ci_upper], axis=-1) if transposed: return np.transpose(ci, (1, 0, 2)), stimulus.T return ci, stimulus
[docs] @staticmethod def estimateInfoMat(fitType, dN, HkAll, A, x0, xK, WK, Wku, Q, gamma, windowTimes, SumXkTerms, delta, Mc=500): """Observed information matrix via Louis' identity with Monte Carlo. Computes I_obs = I_complete - I_missing where I_missing is estimated by MC sampling from the smoothing distribution. """ fitType = str(fitType).lower() K, N = dN.shape gamma_vec = np.asarray(gamma, dtype=float).reshape(-1) J = gamma_vec.size if (windowTimes is not None and len(windowTimes) > 0) else 0 Q_vec = np.asarray(Q, dtype=float).reshape(-1) R = Q_vec.size Q_mat = np.diag(Q_vec) numBasis = R # Build basis matrix minTime = 0.0 maxTime = (N - 1) * delta basisMat = DecodingAlgorithms._ssglm_build_basis(numBasis, minTime, maxTime, delta) if basisMat.shape[0] != N: basisMat = basisMat[:N, :] if basisMat.shape[0] > N else np.vstack( [basisMat, np.zeros((N - basisMat.shape[0], basisMat.shape[1]))] ) # Complete data information matrix Ic = np.zeros((R + J, R + J), dtype=float) Q_mat_safe = np.diag(np.maximum(Q_vec, np.finfo(float).eps)) Q2 = Q_mat_safe @ Q_mat_safe Q3 = Q2 @ Q_mat_safe Ic[:R, :R] = K / 2.0 * np.linalg.inv(Q2) + np.linalg.inv(Q3) @ SumXkTerms # History portion of information matrix jacQ = np.zeros((J, J), dtype=float) if J > 0 else np.zeros((0, 0)) if fitType == "poisson" and J > 0: for k in range(K): Hk = HkAll[k] if Hk.shape[1] == 0: continue Wk = basisMat @ np.diag(WK[:, :, k]) stimK = basisMat @ xK[:, k] stimK_clip = np.clip(stimK, -30, 30) hist_term = np.clip(gamma_vec @ Hk.T, -30, 30) histEffect = np.exp(hist_term) stimEffect = np.exp(stimK_clip) + np.exp(stimK_clip) / 2.0 * Wk lambdaDelta = stimEffect * histEffect jacQ -= (Hk * lambdaDelta[:, None]).T @ Hk elif fitType == "binomial" and J > 0: for k in range(K): Hk = HkAll[k] if Hk.shape[1] == 0: continue Wk = basisMat @ np.diag(WK[:, :, k]) stimK = basisMat @ xK[:, k] linpred = np.clip(stimK + Hk @ gamma_vec, -30, 30) histEffect = np.exp(np.clip(gamma_vec @ Hk.T, -30, 30)) stimEffect = np.exp(np.clip(stimK, -30, 30)) C = stimEffect * histEffect M = np.where(C > 1e-30, 1.0 / C, 1e30) lambdaDelta = 1.0 / (1.0 + np.exp(-linpred)) ExpLDSquaredTimesInvExp = lambdaDelta ** 2 * M ExpLDCubedTimesInvExpSquared = ( lambdaDelta ** 3 * M ** 2 + Wk / 2.0 * (3.0 * M ** 4 * lambdaDelta ** 3 + 12.0 * lambdaDelta ** 3 * M ** 3 - 12.0 * M ** 4 * lambdaDelta ** 4) ) jacQ -= (Hk * (ExpLDSquaredTimesInvExp * dN[k, :])[:, None]).T @ Hk \ + (Hk * ExpLDSquaredTimesInvExp[:, None]).T @ Hk \ + (Hk * (2.0 * ExpLDCubedTimesInvExpSquared)[:, None]).T @ Hk Ic[:R, :R] = K * np.linalg.inv(2.0 * Q2) + np.linalg.inv(Q3) @ SumXkTerms if J > 0: Ic[R:R + J, R:R + J] = -jacQ # MC estimation of missing information xKDraw = np.zeros((numBasis, K, Mc), dtype=float) for r in range(numBasis): WkuTemp = Wku[r, r, :, :] try: chol_m = np.linalg.cholesky(WkuTemp + 1e-10 * np.eye(K)) except np.linalg.LinAlgError: eigvals, eigvecs = np.linalg.eigh(WkuTemp) eigvals = np.maximum(eigvals, 1e-10) chol_m = eigvecs @ np.diag(np.sqrt(eigvals)) for c in range(Mc): z = np.random.randn(K) xKDraw[r, :, c] = xK[r, :] + chol_m.T @ z ImMC = np.zeros((R + J, R + J), dtype=float) A_mat = np.asarray(A, dtype=float) if A_mat.ndim < 2: A_mat = np.eye(R) * A_mat x0_vec = np.asarray(x0, dtype=float).reshape(-1) Q_inv = np.linalg.inv(Q_mat_safe) for c in range(Mc): gradQGammahat = np.zeros(J, dtype=float) if J > 0 else np.array([], dtype=float) gradQQhat = np.zeros(R, dtype=float) for k in range(K): Hk = HkAll[k] stimK = basisMat @ xKDraw[:, k, c] if fitType == "poisson": hist_term = np.clip(gamma_vec @ Hk.T, -30, 30) if J > 0 and Hk.shape[1] > 0 else np.zeros(N) histEffect = np.exp(hist_term) stimK_clip = np.clip(stimK, -30, 30) stimEffect = np.exp(stimK_clip) lambdaDelta = stimEffect * histEffect if J > 0 and Hk.shape[1] > 0: gradQGammahat += Hk.T @ dN[k, :] - Hk.T @ lambdaDelta elif fitType == "binomial": Wk = basisMat @ np.diag(WK[:, :, k]) linpred = np.clip(stimK + (Hk @ gamma_vec if J > 0 and Hk.shape[1] > 0 else 0.0), -30, 30) histEffect = np.exp(np.clip(gamma_vec @ Hk.T, -30, 30)) if J > 0 and Hk.shape[1] > 0 else np.ones(N) stimEffect = np.exp(np.clip(stimK, -30, 30)) C = stimEffect * histEffect M = np.where(C > 1e-30, 1.0 / C, 1e30) lambdaDelta = 1.0 / (1.0 + np.exp(-linpred)) ExpLambdaDelta = lambdaDelta + Wk * (lambdaDelta * (1.0 - lambdaDelta) * (1.0 - 2.0 * lambdaDelta)) / 2.0 ExpLDSquaredTimesInvExp = lambdaDelta ** 2 * M if J > 0 and Hk.shape[1] > 0: gradQGammahat += (Hk * (1.0 - ExpLambdaDelta)[:, None]).T @ dN[k, :] \ - (Hk * (ExpLDSquaredTimesInvExp / np.maximum(lambdaDelta, 1e-30))[:, None]).T @ lambdaDelta if k == 0: diff = xKDraw[:, k, c] - A_mat @ x0_vec else: diff = xKDraw[:, k, c] - A_mat @ xKDraw[:, k - 1, c] gradQQhat += diff * diff gradQQhat_scaled = 0.5 * Q_inv @ gradQQhat - np.diag(K / 2.0 * np.linalg.inv(Q2)) ImMC[:R, :R] += np.outer(gradQQhat_scaled, gradQQhat_scaled) if J > 0: ImMC[R:R + J, R:R + J] += np.diag(gradQGammahat ** 2) Im = ImMC / Mc InfoMatrix = Ic - Im return InfoMatrix
# ------------------------------------------------------------------ # Kalman Filter EM (KF_EM) family # Ported from Matlab DecodingAlgorithms.m lines 3295-4586 # ------------------------------------------------------------------
[docs] @staticmethod def computeSpikeRateCIs( xK, Wku, dN, t0: float, tf: float, fitType: str = "poisson", delta: float = 0.001, gamma=None, windowTimes=None, Mc: int = 500, alphaVal: float = 0.05, ): """Monte Carlo spike-rate confidence intervals. Computes the average firing rate over ``[t0, tf]`` for each trial by drawing ``Mc`` samples from the smoothing distribution and evaluating the conditional intensity. Parameters ---------- xK : array, shape (numBasis, K) Smoothed state estimates (basis coefficients × trials). Wku : array, shape (numBasis, numBasis, K, K) or compatible Smoothed state covariance. dN : array, shape (C, N) Observation (spike indicator) matrix. t0, tf : float Time window over which to compute the average rate. fitType : str ``'poisson'`` or ``'binomial'``. delta : float Time-step size in seconds. gamma : array or None History-effect coefficients. windowTimes : array or None History window boundaries. Mc : int Number of Monte Carlo draws. alphaVal : float Significance level for CIs (one-sided). Returns ------- spikeRateSig : Covariate Mean spike rate per trial with attached ConfidenceInterval. ProbMat : ndarray, shape (K, K) ``ProbMat[k, m]`` = P(rate_m > rate_k) estimated from MC draws. sigMat : ndarray, shape (K, K) Binary significance matrix at level ``1 - alphaVal``. """ from .confidence_interval import ConfidenceInterval from .core import Covariate from .history import History from .nspikeTrain import nspikeTrain from .trial import SpikeTrainCollection xK = np.asarray(xK, dtype=float) dN = np.asarray(dN, dtype=float) if dN.ndim == 1: dN = dN.reshape(1, -1) numBasis, K = xK.shape minTime = 0.0 maxTime = (dN.shape[1] - 1) * delta # Build unit-impulse basis matrix basisWidth = (maxTime - minTime) / numBasis sampleRate = 1.0 / delta unitPulseBasis = SpikeTrainCollection.generateUnitImpulseBasis( basisWidth, minTime, maxTime, sampleRate ) basisMat = unitPulseBasis.data # shape (T, numBasis) # Build history matrices if windowTimes provided Hk = {} if windowTimes is not None and len(windowTimes) > 0: histObj = History(windowTimes, minTime, maxTime) for k in range(K): spike_idx = np.flatnonzero(dN[k, :] == 1) spike_times = (spike_idx) * delta nst_k = nspikeTrain(spike_times) nst_k.setMinTime(minTime) nst_k.setMaxTime(maxTime) hist_cov = histObj.computeHistory(nst_k) Hk[k] = hist_cov.dataToMatrix() else: for k in range(K): Hk[k] = 0.0 gamma = 0.0 if gamma is None: gamma = 0.0 gamma = np.asarray(gamma, dtype=float) # Monte Carlo draws from smoothing distribution Wku = np.asarray(Wku, dtype=float) xKDraw = np.zeros((numBasis, K, Mc), dtype=float) for r in range(numBasis): WkuTemp = Wku[r, r, :, :].squeeze() if Wku.ndim == 4 else Wku[r, r] WkuTemp = np.atleast_2d(WkuTemp) if WkuTemp.shape[0] != K: WkuTemp = np.diag(np.full(K, float(WkuTemp.flat[0]))) try: chol_m = np.linalg.cholesky(WkuTemp) except np.linalg.LinAlgError: eigvals = np.linalg.eigvalsh(WkuTemp) WkuTemp += np.eye(K) * (abs(min(eigvals.min(), 0.0)) + 1e-10) chol_m = np.linalg.cholesky(WkuTemp) for c in range(Mc): z = np.random.randn(K) xKDraw[r, :, c] = xK[r, :] + chol_m.T @ z # Compute lambda for each MC draw and each trial time_vec = np.arange(minTime, maxTime + delta, delta) T = basisMat.shape[0] fit_type = str(fitType).lower() spikeRate = np.zeros((Mc, K), dtype=float) for c in range(Mc): for k in range(K): stimK = basisMat @ xKDraw[:, k, c] if fit_type == "poisson": histEffect = np.exp(gamma @ Hk[k].T).ravel() if not np.isscalar(Hk[k]) else np.ones(T) stimEffect = np.exp(np.clip(stimK, -20.0, 20.0)) lambdaDelta_kc = stimEffect * histEffect[:T] elif fit_type == "binomial": if np.isscalar(Hk[k]): eta = stimK else: eta = stimK + (gamma @ Hk[k].T).ravel()[:T] eta = np.clip(eta, -20.0, 20.0) lambdaDelta_kc = np.exp(eta) / (1.0 + np.exp(eta)) else: lambdaDelta_kc = np.exp(np.clip(stimK, -20.0, 20.0)) # Integrate via cumulative trapezoid rate_per_sec = lambdaDelta_kc / delta time_k = time_vec[:len(rate_per_sec)] cum_integral = np.zeros(len(rate_per_sec)) cum_integral[1:] = np.cumsum(rate_per_sec[:-1] * delta + 0.5 * np.diff(rate_per_sec) * delta) # Interpolate integral at t0 and tf val_t0 = np.interp(t0, time_k, cum_integral) val_tf = np.interp(tf, time_k, cum_integral) spikeRate[c, k] = (1.0 / (tf - t0)) * (val_tf - val_t0) # Compute CIs from ECDF (one-sided) CIs = np.zeros((K, 2), dtype=float) for k in range(K): sorted_rates = np.sort(spikeRate[:, k]) ecdf = np.arange(1, Mc + 1, dtype=float) / float(Mc) lower_idx = np.flatnonzero(ecdf < alphaVal) upper_idx = np.flatnonzero(ecdf > (1.0 - alphaVal)) CIs[k, 0] = sorted_rates[lower_idx[-1]] if lower_idx.size else sorted_rates[0] CIs[k, 1] = sorted_rates[upper_idx[0]] if upper_idx.size else sorted_rates[-1] trial_axis = np.arange(1, K + 1, dtype=float) mean_rate = np.mean(spikeRate, axis=0) spikeRateSig = Covariate( trial_axis, mean_rate, f"({tf:g}-{t0:g})^{{-1}} * \\Lambda({tf:g}-{t0:g})", "Trial", "k", "Hz", ) ciSpikeRate = ConfidenceInterval( trial_axis, CIs, "CI_{spikeRate}", "Trial", "k", "Hz" ) spikeRateSig.setConfInterval(ciSpikeRate) # Pairwise probability matrix ProbMat = np.zeros((K, K), dtype=float) for k in range(K): for m in range(k + 1, K): ProbMat[k, m] = np.sum(spikeRate[:, m] > spikeRate[:, k]) / float(Mc) sigMat = (ProbMat > (1.0 - alphaVal)).astype(float) return spikeRateSig, ProbMat, sigMat
[docs] @staticmethod def computeSpikeRateDiffCIs( xK, Wku, dN, time1, time2, fitType: str = "poisson", delta: float = 0.001, gamma=None, windowTimes=None, Mc: int = 500, alphaVal: float = 0.05, ): """Monte Carlo CIs for the difference in spike rates between two time windows. Computes the difference of average firing rates ``rate(time1) - rate(time2)`` for each trial by drawing ``Mc`` samples from the smoothing distribution. Parameters ---------- xK : array, shape (numBasis, K) Smoothed state estimates (basis coefficients × trials). Wku : array, shape (numBasis, numBasis, K, K) or compatible Smoothed state covariance. dN : array, shape (C, N) Observation (spike indicator) matrix. time1 : array-like, length 2 ``[t0_1, tf_1]`` — first time window. time2 : array-like, length 2 ``[t0_2, tf_2]`` — second time window. fitType : str ``'poisson'`` or ``'binomial'``. delta : float Time-step size in seconds. gamma : array or None History-effect coefficients. windowTimes : array or None History window boundaries. Mc : int Number of Monte Carlo draws. alphaVal : float Significance level for CIs (one-sided). Returns ------- spikeRateSig : Covariate Mean spike-rate difference per trial with attached CI. ProbMat : ndarray, shape (K, K) ``ProbMat[k, m]`` = P(diff_m > diff_k) from MC draws. sigMat : ndarray, shape (K, K) Binary significance matrix at level ``1 - alphaVal``. """ from .confidence_interval import ConfidenceInterval from .core import Covariate from .history import History from .nspikeTrain import nspikeTrain from .trial import SpikeTrainCollection xK = np.asarray(xK, dtype=float) dN = np.asarray(dN, dtype=float) if dN.ndim == 1: dN = dN.reshape(1, -1) numBasis, K = xK.shape minTime = 0.0 maxTime = (dN.shape[1] - 1) * delta time1 = np.asarray(time1, dtype=float).ravel() time2 = np.asarray(time2, dtype=float).ravel() # Build unit-impulse basis matrix basisWidth = (maxTime - minTime) / numBasis sampleRate = 1.0 / delta unitPulseBasis = SpikeTrainCollection.generateUnitImpulseBasis( basisWidth, minTime, maxTime, sampleRate ) basisMat = unitPulseBasis.data # Build history matrices if windowTimes provided Hk = {} if windowTimes is not None and len(windowTimes) > 0: histObj = History(windowTimes, minTime, maxTime) for k in range(K): spike_idx = np.flatnonzero(dN[k, :] == 1) spike_times = (spike_idx) * delta nst_k = nspikeTrain(spike_times) nst_k.setMinTime(minTime) nst_k.setMaxTime(maxTime) hist_cov = histObj.computeHistory(nst_k) Hk[k] = hist_cov.dataToMatrix() else: for k in range(K): Hk[k] = 0.0 gamma = 0.0 if gamma is None: gamma = 0.0 gamma = np.asarray(gamma, dtype=float) # Monte Carlo draws from smoothing distribution Wku = np.asarray(Wku, dtype=float) xKDraw = np.zeros((numBasis, K, Mc), dtype=float) for r in range(numBasis): WkuTemp = Wku[r, r, :, :].squeeze() if Wku.ndim == 4 else Wku[r, r] WkuTemp = np.atleast_2d(WkuTemp) if WkuTemp.shape[0] != K: WkuTemp = np.diag(np.full(K, float(WkuTemp.flat[0]))) try: chol_m = np.linalg.cholesky(WkuTemp) except np.linalg.LinAlgError: eigvals = np.linalg.eigvalsh(WkuTemp) WkuTemp += np.eye(K) * (abs(min(eigvals.min(), 0.0)) + 1e-10) chol_m = np.linalg.cholesky(WkuTemp) for c in range(Mc): z = np.random.randn(K) xKDraw[r, :, c] = xK[r, :] + chol_m.T @ z # Compute lambda and spike-rate difference for each MC draw time_vec = np.arange(minTime, maxTime + delta, delta) T = basisMat.shape[0] fit_type = str(fitType).lower() spikeRate = np.zeros((Mc, K), dtype=float) for c in range(Mc): for k in range(K): stimK = basisMat @ xKDraw[:, k, c] if fit_type == "poisson": histEffect = np.exp(gamma @ Hk[k].T).ravel() if not np.isscalar(Hk[k]) else np.ones(T) stimEffect = np.exp(np.clip(stimK, -20.0, 20.0)) lambdaDelta_kc = stimEffect * histEffect[:T] elif fit_type == "binomial": if np.isscalar(Hk[k]): eta = stimK else: eta = stimK + (gamma @ Hk[k].T).ravel()[:T] eta = np.clip(eta, -20.0, 20.0) lambdaDelta_kc = np.exp(eta) / (1.0 + np.exp(eta)) else: lambdaDelta_kc = np.exp(np.clip(stimK, -20.0, 20.0)) # Integrate via cumulative sum rate_per_sec = lambdaDelta_kc / delta time_k = time_vec[:len(rate_per_sec)] cum_integral = np.zeros(len(rate_per_sec)) cum_integral[1:] = np.cumsum(rate_per_sec[:-1] * delta + 0.5 * np.diff(rate_per_sec) * delta) # Rate for time window 1 t0_1, tf_1 = float(min(time1)), float(max(time1)) val_t0_1 = np.interp(t0_1, time_k, cum_integral) val_tf_1 = np.interp(tf_1, time_k, cum_integral) rate1 = (1.0 / (tf_1 - t0_1)) * (val_tf_1 - val_t0_1) # Rate for time window 2 t0_2, tf_2 = float(min(time2)), float(max(time2)) val_t0_2 = np.interp(t0_2, time_k, cum_integral) val_tf_2 = np.interp(tf_2, time_k, cum_integral) rate2 = (1.0 / (tf_2 - t0_2)) * (val_tf_2 - val_t0_2) spikeRate[c, k] = rate1 - rate2 # Compute CIs from ECDF (one-sided) CIs = np.zeros((K, 2), dtype=float) for k in range(K): sorted_rates = np.sort(spikeRate[:, k]) ecdf = np.arange(1, Mc + 1, dtype=float) / float(Mc) lower_idx = np.flatnonzero(ecdf < alphaVal) upper_idx = np.flatnonzero(ecdf > (1.0 - alphaVal)) CIs[k, 0] = sorted_rates[lower_idx[-1]] if lower_idx.size else sorted_rates[0] CIs[k, 1] = sorted_rates[upper_idx[0]] if upper_idx.size else sorted_rates[-1] trial_axis = np.arange(1, K + 1, dtype=float) mean_rate = np.mean(spikeRate, axis=0) label = ( r"(t_{1f}-t_{1o})^{-1} \Lambda(t_{1f}-t_{1o})" r" - (t_{2f}-t_{2o})^{-1} \Lambda(t_{2f}-t_{2o})" ) spikeRateSig = Covariate(trial_axis, mean_rate, label, "Trial", "k", "Hz") ciSpikeRate = ConfidenceInterval( trial_axis, CIs, "CI_{spikeRate}", "Trial", "k", "Hz" ) spikeRateSig.setConfInterval(ciSpikeRate) # Pairwise probability matrix ProbMat = np.zeros((K, K), dtype=float) for k in range(K): for m in range(k + 1, K): ProbMat[k, m] = np.sum(spikeRate[:, m] > spikeRate[:, k]) / float(Mc) sigMat = (ProbMat > (1.0 - alphaVal)).astype(float) return spikeRateSig, ProbMat, sigMat
[docs] @staticmethod def KF_EMCreateConstraints( EstimateA=1, AhatDiag=0, QhatDiag=1, QhatIsotropic=0, RhatDiag=1, RhatIsotropic=0, Estimatex0=1, EstimatePx0=1, Px0Isotropic=0, mcIter=1000, EnableIkeda=0, ): """Return a dict of EM constraint flags for :meth:`KF_EM`. Parameters ---------- EstimateA : int Whether to estimate the state transition matrix *A*. AhatDiag : int Constrain *A* to be diagonal. QhatDiag : int Constrain *Q* to be diagonal. QhatIsotropic : int Constrain *Q* to be isotropic (scalar times identity). Only active when *QhatDiag* is also true. RhatDiag : int Constrain *R* to be diagonal. RhatIsotropic : int Constrain *R* to be isotropic. Only active when *RhatDiag* is also true. Estimatex0 : int Whether to estimate the initial state *x0*. EstimatePx0 : int Whether to estimate the initial covariance *Px0*. Px0Isotropic : int Constrain *Px0* to be isotropic. Only active when *EstimatePx0* is true. mcIter : int Number of Monte Carlo iterations for standard-error estimation via the observed information matrix. EnableIkeda : int Enable Ikeda acceleration in the EM loop. Returns ------- dict Constraint dictionary consumed by :meth:`KF_EM`, :meth:`KF_MStep`, and :meth:`KF_ComputeParamStandardErrors`. Notes ----- Docs only; mirrors nSTAT PR #138 (``fix/kf-em`` @ ``584ec96``). ``QhatDiag=1`` / ``RhatDiag=1`` impose diagonal structure on ``Qhat`` / ``Rhat`` in the frame ``KF_EM`` internally whitens by the STARTING covariances ``Q0`` / ``R0`` (``Tq = inv(chol(Q0, 'lower'))``, ``Tr = inv(chol(R0, 'lower'))``; see the G1 whitening fix), not in the caller's own state / observation coordinates. For a diagonal ``Q0`` / ``R0`` the two frames coincide, so this is invisible. For a non-diagonal ``Q0`` / ``R0``, "``Qhat``/``Rhat`` is diagonal" means ``Tq @ Qhat @ Tq.T`` / ``Tr @ Rhat @ Tr.T`` is diagonal; the returned ``Qhat`` / ``Rhat`` (mapped back to the caller's coordinates) generally is not. """ C = {} C["EstimateA"] = int(EstimateA) C["AhatDiag"] = int(AhatDiag) C["QhatDiag"] = int(QhatDiag) # QhatIsotropic only valid if QhatDiag is true C["QhatIsotropic"] = 1 if (QhatDiag and QhatIsotropic) else 0 C["RhatDiag"] = int(RhatDiag) # RhatIsotropic only valid if RhatDiag is true C["RhatIsotropic"] = 1 if (RhatDiag and RhatIsotropic) else 0 C["Estimatex0"] = int(Estimatex0) C["EstimatePx0"] = int(EstimatePx0) # Px0Isotropic only valid if EstimatePx0 is true C["Px0Isotropic"] = 1 if (EstimatePx0 and Px0Isotropic) else 0 C["mcIter"] = int(mcIter) C["EnableIkeda"] = int(EnableIkeda) return C
# ---- internal Kalman filter matching Matlab (A, C, Q, R, Px0, x0, y) ---- @staticmethod def _kf_filter_stateMajor(A, C, Q, R, Px0, x0, y): """Run a Kalman filter with Matlab-compatible state-major layout. Parameters ---------- A : (Dx, Dx) state transition C : (Dy, Dx) observation matrix Q : (Dx, Dx) process noise covariance R : (Dy, Dy) observation noise covariance Px0 : (Dx, Dx) initial state covariance x0 : (Dx,) or (Dx, 1) initial state y : (Dy, K) observations (state-major, each column is one time step) Returns ------- x_p : (Dx, K+1) predicted states (x_p[:, 0] == x0) Pe_p : (Dx, Dx, K+1) predicted covariances x_u : (Dx, K) updated states Pe_u : (Dx, Dx, K) updated covariances """ A = np.asarray(A, dtype=float) C = np.asarray(C, dtype=float) Q = np.asarray(Q, dtype=float) R = np.asarray(R, dtype=float) Px0 = np.asarray(Px0, dtype=float) x0 = np.asarray(x0, dtype=float).reshape(-1) y = np.asarray(y, dtype=float) Dx = A.shape[0] K = y.shape[1] x_p = np.zeros((Dx, K + 1), dtype=float) Pe_p = np.zeros((Dx, Dx, K + 1), dtype=float) x_u = np.zeros((Dx, K), dtype=float) Pe_u = np.zeros((Dx, Dx, K), dtype=float) x_p[:, 0] = x0 Pe_p[:, :, 0] = Px0 for n in range(K): # Update S = C @ Pe_p[:, :, n] @ C.T + R Gn = Pe_p[:, :, n] @ C.T @ np.linalg.pinv(S) x_u[:, n] = x_p[:, n] + Gn @ (y[:, n] - C @ x_p[:, n]) Pe_u[:, :, n] = Pe_p[:, :, n] - Gn @ C @ Pe_p[:, :, n] # Predict x_p[:, n + 1] = A @ x_u[:, n] Pe_p[:, :, n + 1] = A @ Pe_u[:, :, n] @ A.T + Q return x_p, Pe_p, x_u, Pe_u @staticmethod def _kf_smootherFromFiltered_stateMajor(A, x_p, Pe_p, x_u, Pe_u): """RTS smoother with Matlab-compatible state-major layout. Parameters ---------- A : (Dx, Dx) transition matrix x_p : (Dx, K+1) predicted states Pe_p : (Dx, Dx, K+1) predicted covariances x_u : (Dx, K) updated states Pe_u : (Dx, Dx, K) updated covariances Returns ------- x_K : (Dx, K) smoothed states W_K : (Dx, Dx, K) smoothed covariances Lk : (Dx, Dx, K-1) smoother gains """ K = x_u.shape[1] Dx = x_u.shape[0] x_K = np.copy(x_u) W_K = np.copy(Pe_u) Lk = np.zeros((Dx, Dx, max(K - 1, 0)), dtype=float) for t in range(K - 2, -1, -1): gain = Pe_u[:, :, t] @ A.T @ np.linalg.pinv(Pe_p[:, :, t + 1]) Lk[:, :, t] = gain x_K[:, t] = x_u[:, t] + gain @ (x_K[:, t + 1] - x_p[:, t + 1]) W_K[:, :, t] = _symmetrize( Pe_u[:, :, t] + gain @ (W_K[:, :, t + 1] - Pe_p[:, :, t + 1]) @ gain.T ) return x_K, W_K, Lk # NOTE: ``KF_EM`` is forward-declared here in MATLAB order # (between ``KF_EMCreateConstraints`` and ``KF_ComputeParamStandardErrors``). # The canonical implementation appears further below; the later ``def`` # overrides this stub, while the parity scanner sees the MATLAB-mirrored # position from this forward declaration. @staticmethod def KF_EM(*args, **kwargs): # noqa: F811 """Kalman Filter EM algorithm with Cholesky-scaled system. Forward declaration; see the canonical implementation below. """ return DecodingAlgorithms.KF_EM(*args, **kwargs) # pragma: no cover - shadowed
[docs] @staticmethod def KF_ComputeParamStandardErrors( y, xKFinal, WKFinal, Ahat, Qhat, Chat, Rhat, alphahat, x0hat, Px0hat, ExpectationSumsFinal, KFEM_Constraints=None, ): """Compute standard errors via the observed information matrix. Uses the complete information matrix and a Monte Carlo estimate of the missing information matrix, following McLachlan and Krishnan Eq. 4.7: ``Io(theta; y) = Ic(theta; y) - Im(theta; y)``. Parameters ---------- y : (Dy, K) observations xKFinal : (Dx, K) smoothed states WKFinal : (Dx, Dx, K) smoothed covariances Ahat, Qhat, Chat, Rhat : estimated model matrices alphahat : (Dy, 1) observation offset x0hat : (Dx,) initial state Px0hat : (Dx, Dx) initial covariance ExpectationSumsFinal : dict from :meth:`KF_EStep` KFEM_Constraints : dict from :meth:`KF_EMCreateConstraints` Returns ------- SE : dict of standard-error matrices/vectors for each parameter Pvals : dict of p-value matrices/vectors for each parameter """ if KFEM_Constraints is None: KFEM_Constraints = DecodingAlgorithms.KF_EMCreateConstraints() Ahat = np.asarray(Ahat, dtype=float) Qhat = np.asarray(Qhat, dtype=float) Chat = np.asarray(Chat, dtype=float) Rhat = np.asarray(Rhat, dtype=float) alphahat = np.asarray(alphahat, dtype=float).reshape(-1, 1) x0hat = np.asarray(x0hat, dtype=float).reshape(-1) Px0hat = np.asarray(Px0hat, dtype=float) y = np.asarray(y, dtype=float) xKFinal = np.asarray(xKFinal, dtype=float) WKFinal = np.asarray(WKFinal, dtype=float) dy, N = y.shape dx = xKFinal.shape[0] K = N # ---------------------------------------------------------------- # Complete Information Matrices # ---------------------------------------------------------------- # --- IAComp: information for A --- n1_A, n2_A = Ahat.shape el_A = np.eye(n1_A) em_A = np.eye(n2_A) if KFEM_Constraints["AhatDiag"]: nA = n1_A IAComp = np.zeros((nA, nA), dtype=float) cnt = 0 for l in range(n1_A): m = l # diagonal only # termMat = inv(Q) * el(:,l)*em(:,m)' * Sxkm1xkm1 .* I termMat = np.linalg.solve(Qhat, np.outer(el_A[:, l], em_A[:, m])) @ ( ExpectationSumsFinal["Sxkm1xkm1"] * np.eye(n1_A, n2_A) ) IAComp[:, cnt] = np.diag(termMat) cnt += 1 else: nA = Ahat.size IAComp = np.zeros((nA, nA), dtype=float) cnt = 0 Qinv = np.linalg.inv(Qhat) for l in range(n1_A): for m in range(n2_A): termMat = Qinv @ np.outer(el_A[:, l], em_A[:, m]) @ ExpectationSumsFinal["Sxkm1xkm1"] termvec = termMat.T.ravel() IAComp[:, cnt] = termvec cnt += 1 # --- ICComp: information for C --- n1_C, n2_C = Chat.shape el_C = np.eye(n1_C) em_C = np.eye(n2_C) nC = Chat.size ICComp = np.zeros((nC, nC), dtype=float) cnt = 0 Rinv = np.linalg.inv(Rhat) for l in range(n1_C): for m in range(n2_C): termMat = Rinv @ np.outer(el_C[:, l], em_C[:, m]) @ ExpectationSumsFinal["Sxkxk"] termvec = termMat.T.ravel() ICComp[:, cnt] = termvec cnt += 1 # --- IRComp: information for R --- n1_R, n2_R = Rhat.shape el_R = np.eye(n1_R) em_R = np.eye(n2_R) if KFEM_Constraints["RhatDiag"]: if KFEM_Constraints["RhatIsotropic"]: IRComp = np.array([[0.5 * N * dy * Rhat[0, 0] ** (-2)]]) nR = 1 else: nR = n1_R IRComp = np.zeros((nR, nR), dtype=float) cnt = 0 for l in range(n1_R): m = l termMat = (N / 2.0) * np.linalg.solve(Rhat, np.outer(em_R[:, m], el_R[:, l])) @ np.linalg.inv(Rhat) IRComp[:, cnt] = np.diag(termMat) cnt += 1 else: nR = Rhat.size IRComp = np.zeros((nR, nR), dtype=float) cnt = 0 for l in range(n1_R): for m in range(n2_R): termMat = (N / 2.0) * np.linalg.solve(Rhat, np.outer(em_R[:, m], el_R[:, l])) @ np.linalg.inv(Rhat) termvec = termMat.T.ravel() IRComp[:, cnt] = termvec cnt += 1 # --- IQComp: information for Q --- n1_Q, n2_Q = Qhat.shape el_Q = np.eye(n1_Q) em_Q = np.eye(n2_Q) if KFEM_Constraints["QhatDiag"]: if KFEM_Constraints["QhatIsotropic"]: IQComp = np.array([[0.5 * N * dx * Qhat[0, 0] ** (-2)]]) nQ = 1 else: nQ = n1_Q IQComp = np.zeros((nQ, nQ), dtype=float) cnt = 0 for l in range(n1_Q): m = l termMat = (N / 2.0) * np.linalg.solve(Qhat, np.outer(em_Q[:, m], el_Q[:, l])) @ np.linalg.inv(Qhat) IQComp[:, cnt] = np.diag(termMat) cnt += 1 else: nQ = Qhat.size IQComp = np.zeros((nQ, nQ), dtype=float) cnt = 0 for l in range(n1_Q): for m in range(n2_Q): termMat = (N / 2.0) * np.linalg.solve(Qhat, np.outer(em_Q[:, m], el_Q[:, l])) @ np.linalg.inv(Qhat) termvec = termMat.T.ravel() IQComp[:, cnt] = termvec cnt += 1 # --- ISComp: information for Px0 --- if KFEM_Constraints["EstimatePx0"]: if KFEM_Constraints["Px0Isotropic"]: ISComp = np.array([[0.5 * dx * Px0hat[0, 0] ** (-2)]]) nS = 1 else: nS = Px0hat.shape[0] ISComp = np.zeros((nS, nS), dtype=float) el_S = np.eye(nS) em_S = np.eye(nS) cnt = 0 for l in range(nS): m = l termMat = 0.5 * np.linalg.solve(Px0hat, np.outer(em_S[:, m], el_S[:, l])) @ np.linalg.inv(Px0hat) ISComp[:, cnt] = np.diag(termMat) cnt += 1 else: nS = 0 # --- Ix0Comp: information for x0 --- if KFEM_Constraints["Estimatex0"]: Ix0Comp = np.linalg.inv(Px0hat) + Ahat.T @ np.linalg.solve(Qhat, Ahat) nx0 = Ix0Comp.shape[0] else: nx0 = 0 # --- IAlphaComp --- IAlphaComp = N * np.linalg.inv(Rhat) nAlpha = IAlphaComp.shape[0] # Block sizes # n1=A, n2=Q, n3=C, n4=R, n5=Px0, n6=x0, n7=alpha if KFEM_Constraints["EstimateA"]: n1 = IAComp.shape[0] else: n1 = 0 n2 = IQComp.shape[0] n3 = ICComp.shape[0] n4 = IRComp.shape[0] n5 = nS n6 = nx0 n7 = nAlpha nTerms = n1 + n2 + n3 + n4 + n5 + n6 + n7 # Assemble block-diagonal complete information matrix IComp = np.zeros((nTerms, nTerms), dtype=float) if KFEM_Constraints["EstimateA"]: IComp[:n1, :n1] = IAComp off = n1 IComp[off:off + n2, off:off + n2] = IQComp off = n1 + n2 IComp[off:off + n3, off:off + n3] = ICComp off = n1 + n2 + n3 IComp[off:off + n4, off:off + n4] = IRComp off = n1 + n2 + n3 + n4 if KFEM_Constraints["EstimatePx0"]: IComp[off:off + n5, off:off + n5] = ISComp off = n1 + n2 + n3 + n4 + n5 if KFEM_Constraints["Estimatex0"]: IComp[off:off + n6, off:off + n6] = Ix0Comp off = n1 + n2 + n3 + n4 + n5 + n6 IComp[off:off + n7, off:off + n7] = IAlphaComp # ---------------------------------------------------------------- # Missing Information Matrix (Monte Carlo) # ---------------------------------------------------------------- Mc = KFEM_Constraints["mcIter"] xKDraw = np.zeros((dx, N, Mc), dtype=float) # FIX (KF track M, item C1 / F9, mirrors nSTAT PR #138 @ 178917c): # draws were m + chol(W)*z; MATLAB's chol returns the UPPER factor R # (R'*R = W), so R*z has covariance R*R' != W for any non-diagonal W. # Routed through the shared _mc_state_draws helper (x = m + L@z, L # the lower Cholesky factor; same z stream; bit-identical for a # diagonal W), matching PP_ComputeParamStandardErrors / # PPLFP_ComputeParamStandardErrors's identical F9 fix. for n in range(N): xKDraw[:, n, :] = _mc_state_draws(xKFinal[:, n], WKFinal[:, :, n], Mc, np.random.randn) if KFEM_Constraints["EstimatePx0"] or KFEM_Constraints["Estimatex0"]: x0Draw = _mc_state_draws(x0hat, Px0hat, Mc, np.random.randn) else: x0Draw = np.tile(x0hat[:, None], (1, Mc)) IMc = np.zeros((nTerms, nTerms, Mc), dtype=float) alpha_flat = alphahat.ravel() for c in range(Mc): x_K_c = xKDraw[:, :, c] x_0_c = x0Draw[:, c] Dx_c = x_K_c.shape[0] Dy_c = y.shape[0] Sxkm1xk_c = np.zeros((Dx_c, Dx_c)) Sxkm1xkm1_c = np.zeros((Dx_c, Dx_c)) Sxkxk_c = np.zeros((Dx_c, Dx_c)) Sykyk_c = np.zeros((Dy_c, Dy_c)) Sxkyk_c = np.zeros((Dx_c, Dy_c)) for k in range(K): if k == 0: Sxkm1xk_c += np.outer(x_0_c, x_K_c[:, k]) Sxkm1xkm1_c += np.outer(x_0_c, x_0_c) else: Sxkm1xk_c += np.outer(x_K_c[:, k - 1], x_K_c[:, k]) Sxkm1xkm1_c += np.outer(x_K_c[:, k - 1], x_K_c[:, k - 1]) Sxkxk_c += np.outer(x_K_c[:, k], x_K_c[:, k]) yk_centered = y[:, k] - alpha_flat Sykyk_c += np.outer(yk_centered, yk_centered) Sxkyk_c += np.outer(x_K_c[:, k], yk_centered) Sxkxk_c = _symmetrize(Sxkxk_c) Sykyk_c = _symmetrize(Sykyk_c) sumXkTerms_c = Sxkxk_c - Ahat @ Sxkm1xk_c - Sxkm1xk_c.T @ Ahat.T + Ahat @ Sxkm1xkm1_c @ Ahat.T sumYkTerms_c = Sykyk_c - Chat @ Sxkyk_c - Sxkyk_c.T @ Chat.T + Chat @ Sxkxk_c @ Chat.T Sxkxkm1_c = Sxkm1xk_c.T Sykxk_c = Sxkyk_c.T sumXkTerms_c = _symmetrize(sumXkTerms_c) sumYkTerms_c = _symmetrize(sumYkTerms_c) # Score for A if KFEM_Constraints["EstimateA"]: ScorA = np.linalg.solve(Qhat, Sxkxkm1_c - Ahat @ Sxkm1xkm1_c) if KFEM_Constraints["AhatDiag"]: ScoreAMc = np.diag(ScorA) else: ScoreAMc = ScorA.T.ravel() else: ScoreAMc = np.array([], dtype=float) # Score for C ScorC = np.linalg.solve(Rhat, Sykxk_c - Chat @ Sxkxk_c) ScoreCMc = ScorC.T.ravel() # Score for Q Qinv_c = np.linalg.inv(Qhat) I_Q = np.eye(Qhat.shape[0]) if KFEM_Constraints["QhatDiag"]: if KFEM_Constraints["QhatIsotropic"]: ScoreQ = -0.5 * (K * Dx_c * Qhat[0, 0] ** (-1) - Qhat[0, 0] ** (-2) * np.trace(sumXkTerms_c)) ScoreQMc = np.atleast_1d(ScoreQ) else: ScoreQ = -0.5 * np.linalg.solve(Qhat, K * I_Q - np.linalg.solve(Qhat, sumXkTerms_c).T) ScoreQMc = np.diag(ScoreQ) else: ScoreQ = -0.5 * np.linalg.solve(Qhat, K * I_Q - np.linalg.solve(Qhat, sumXkTerms_c).T) ScoreQMc = ScoreQ.T.ravel() # Score for alpha ScoreAlphaMc = np.sum( np.linalg.solve(Rhat, y - Chat @ x_K_c - alpha_flat[:, None] @ np.ones((1, N))), axis=1, ) # Score for R I_R = np.eye(Rhat.shape[0]) if KFEM_Constraints["RhatDiag"]: if KFEM_Constraints["RhatIsotropic"]: ScoreR = -0.5 * (K * Dy_c * Rhat[0, 0] ** (-1) - Rhat[0, 0] ** (-2) * np.trace(sumYkTerms_c)) ScoreRMc = np.atleast_1d(ScoreR) else: ScoreR = -0.5 * np.linalg.solve(Rhat, K * I_R - np.linalg.solve(Rhat, sumYkTerms_c).T) ScoreRMc = np.diag(ScoreR) else: ScoreR = -0.5 * np.linalg.solve(Rhat, K * I_R - np.linalg.solve(Rhat, sumYkTerms_c).T) ScoreRMc = ScoreR.T.ravel() # Score for Px0 diff_x0 = x_0_c - x0hat if KFEM_Constraints["Px0Isotropic"]: ScoreSMc = np.atleast_1d( -0.5 * (Dx_c * Px0hat[0, 0] ** (-1) - Px0hat[0, 0] ** (-2) * np.trace(np.outer(diff_x0, diff_x0))) ) else: ScorS = -0.5 * np.linalg.solve( Px0hat, np.eye(Px0hat.shape[0]) - np.linalg.solve(Px0hat, np.outer(diff_x0, diff_x0)).T, ) ScoreSMc = np.diag(ScorS) # Score for x0 Scorx0 = -np.linalg.solve(Px0hat, diff_x0) + Ahat.T @ np.linalg.solve(Qhat, x_K_c[:, 0] - Ahat @ x_0_c) Scorex0Mc = Scorx0.ravel() # Assemble score vector ScoreVec = ScoreAMc if KFEM_Constraints["EstimateA"] else np.array([], dtype=float) ScoreVec = np.concatenate([ScoreVec, ScoreQMc, ScoreCMc, ScoreRMc]) if KFEM_Constraints["EstimatePx0"]: ScoreVec = np.concatenate([ScoreVec, ScoreSMc]) if KFEM_Constraints["Estimatex0"]: ScoreVec = np.concatenate([ScoreVec, Scorex0Mc]) ScoreVec = np.concatenate([ScoreVec, ScoreAlphaMc]) IMc[:, :, c] = np.outer(ScoreVec, ScoreVec) # Observed information = Complete - Missing IMissing = np.mean(IMc, axis=2) IObs = IComp - IMissing # FIX (KF track M, item C6 / #136, mirrors nSTAT PR #138 @ c7d177e): # an exactly singular IObs (a parameter the data cannot identify) # made eye(size(IObs))/IObs Inf/NaN, and nearestSPD's loop never # returns on a NaN matrix, so this hung forever whenever SEs were # requested. Mirrors PP_ComputeParamStandardErrors's identical #136 # fix: the regular inverse when IObs is nonsingular (bit-identical # to the old pinv/nearestSPD path), otherwise the pseudo-inverse # with the non-identifiable directions flagged (SE/p-value NaN). nonid = None try: invIObs = np.linalg.inv(IObs) except np.linalg.LinAlgError: labels = _em_se_term_labels([ ("A", n1, Ahat.shape, "square"), ("Q", n2, Qhat.shape, "square"), ("C", n3, Chat.shape, "square"), ("R", n4, Rhat.shape, "square"), ("Px0", n5, Px0hat.shape, "square"), ("x0", n6, x0hat.shape, "vector"), ("alpha", n7, alphahat.shape, "vector")]) invIObs, nonid = _em_singular_information_inverse(IObs, labels, "KF_ComputeParamStandardErrors") invIObs = _em_project_covariance(invIObs, nonid, DecodingAlgorithms._nearestSPD) VarVec = np.diag(invIObs) SEVec = np.sqrt(VarVec) # MATLAB sqrt(VarVec); nonnegative after nearestSPD if nonid is not None: SEVec[nonid] = np.nan # not identifiable: SE (and so the p-value) NaN # Unpack SE vector off = 0 SEAterms = SEVec[off:off + n1]; off += n1 SEQterms = SEVec[off:off + n2]; off += n2 SECterms = SEVec[off:off + n3]; off += n3 SERterms = SEVec[off:off + n4]; off += n4 SEPx0terms = SEVec[off:off + n5]; off += n5 SEx0terms = SEVec[off:off + n6]; off += n6 SEAlphaterms = SEVec[off:off + n7] # Reshape SEs into matrices matching parameter shapes SE = {} if KFEM_Constraints["EstimateA"]: if KFEM_Constraints["AhatDiag"]: SE["A"] = np.diag(SEAterms) else: SE["A"] = SEAterms.reshape(Ahat.shape[1], Ahat.shape[0]).T SE["Q"] = np.diag(SEQterms) if KFEM_Constraints["QhatDiag"] else SEQterms.reshape(Qhat.shape[1], Qhat.shape[0]).T SE["C"] = SECterms.reshape(Chat.shape[1], Chat.shape[0]).T SE["R"] = np.diag(SERterms) if KFEM_Constraints["RhatDiag"] else SERterms.reshape(Rhat.shape[1], Rhat.shape[0]).T SE["alpha"] = SEAlphaterms.reshape(alphahat.shape) if KFEM_Constraints["EstimatePx0"]: SE["Px0"] = np.diag(SEPx0terms) if KFEM_Constraints["Estimatex0"]: SE["x0"] = SEx0terms # Compute p-values via z-tests. # ITEM 3 (align KF's SE conventions with MATLAB, see # tests/test_review_characterization.py): KF used to go through the # module-level ``_ztest_pvalue``/``_nearestSPD`` pair (se <= 0 -> p=1; # Higham + eps clamp), unlike PP/PPLFP's staticmethod pair, which # mirrors MATLAB's ``ztest``/``nearestSPD`` exactly (nSTAT PR #135 EM # pass). Now uses ``DecodingAlgorithms._ztest_pvalue`` throughout, so # a non-identifiable parameter's NaN SE (set above by the C6 fix) # propagates to a NaN p-value, as MATLAB's ztest(NaN, 0, NaN) does. _zt = DecodingAlgorithms._ztest_pvalue Pvals = {} if KFEM_Constraints["EstimateA"]: if KFEM_Constraints["AhatDiag"]: pA = np.diag(_zt(np.diag(Ahat), np.diag(SE["A"]))) else: pA = _zt(Ahat.ravel(), SE["A"].ravel()).reshape(Ahat.shape) Pvals["A"] = pA # C p-values Pvals["C"] = _zt(Chat.ravel(), SE["C"].ravel()).reshape(Chat.shape) # R p-values if KFEM_Constraints["RhatDiag"]: if KFEM_Constraints["RhatIsotropic"]: pR = np.diag(_zt(np.atleast_1d(Rhat[0, 0]), np.atleast_1d(SE["R"][0, 0]))) else: pR = np.diag(_zt(np.diag(Rhat), np.diag(SE["R"]))) else: pR = _zt(Rhat.ravel(), SE["R"].ravel()).reshape(Rhat.shape) Pvals["R"] = pR # Q p-values if KFEM_Constraints["QhatDiag"]: if KFEM_Constraints["QhatIsotropic"]: pQ = np.diag(_zt(np.atleast_1d(Qhat[0, 0]), np.atleast_1d(SE["Q"][0, 0]))) else: pQ = np.diag(_zt(np.diag(Qhat), np.diag(SE["Q"]))) else: pQ = _zt(Qhat.ravel(), SE["Q"].ravel()).reshape(Qhat.shape) Pvals["Q"] = pQ # Px0 p-values if KFEM_Constraints["EstimatePx0"]: if KFEM_Constraints["Px0Isotropic"]: pPx0 = np.diag(_zt(np.atleast_1d(Px0hat[0, 0]), np.atleast_1d(SE["Px0"][0, 0]))) else: pPx0 = np.diag(_zt(np.diag(Px0hat), np.diag(SE["Px0"]))) Pvals["Px0"] = pPx0 # alpha p-values Pvals["alpha"] = _zt(alphahat.ravel(), SE["alpha"].ravel()) # x0 p-values if KFEM_Constraints["Estimatex0"]: Pvals["x0"] = _zt(x0hat, SE["x0"]) return SE, Pvals
[docs] @staticmethod def KF_EM( y, Ahat0, Qhat0, Chat0, Rhat0, alphahat0, x0=None, Px0=None, KFEM_Constraints=None, ): """Kalman Filter EM algorithm with Cholesky-scaled system. Estimates the parameters of a linear-Gaussian state-space model:: x_{k+1} = A x_k + v_k, v ~ N(0, Q) y_k = C x_k + alpha + w_k, w ~ N(0, R) using the Expectation-Maximisation algorithm (E-step: KF + RTS smoother, M-step: closed-form updates). Optionally applies Ikeda acceleration. Parameters ---------- y : (Dy, K) observation matrix (each column is one time step) Ahat0 : (Dx, Dx) initial state transition Qhat0 : (Dx, Dx) initial process noise covariance Chat0 : (Dy, Dx) initial observation matrix Rhat0 : (Dy, Dy) initial observation noise covariance alphahat0 : (Dy, 1) initial observation offset x0 : (Dx,) initial state (default zeros) Px0 : (Dx, Dx) initial state covariance (default 1e-10 * I) KFEM_Constraints : dict from :meth:`KF_EMCreateConstraints` Returns ------- xKFinal : (Dx, K) smoothed state estimates WKFinal : (Dx, Dx, K) smoothed covariances Ahat, Qhat, Chat, Rhat : estimated model matrices alphahat : (Dy, 1) estimated observation offset x0hat : (Dx,) estimated initial state Px0hat : (Dx, Dx) estimated initial covariance IC : dict of information criteria (AIC, AICc, BIC, llobs, llcomp) SE : dict of standard errors (or empty dict if not computed) Pvals : dict of p-values (or empty dict if not computed) nIter : int — number of EM iterations Notes ----- Docs only; mirrors nSTAT PR #138 (``fix/kf-em`` @ ``584ec96`` / ``dd7c7d5``). EM stops at the FIRST decrease of the log-likelihood, not at convergence; see :meth:`PP_EM`'s identical note for the general rule. Unlike PP_EM / PPLFP_EM (whose Monte Carlo M-step makes the stopping iteration random run to run), ``KF_EM``'s E-step and M-step are both closed-form and deterministic, so its stopping iteration is NOT random for a fixed problem -- though it still depends on the starting parameters ``Ahat0``/``Qhat0``/.../``Px0``. """ Ahat0 = np.asarray(Ahat0, dtype=float) Qhat0 = np.asarray(Qhat0, dtype=float) Chat0 = np.asarray(Chat0, dtype=float) Rhat0 = np.asarray(Rhat0, dtype=float) alphahat0 = np.asarray(alphahat0, dtype=float).reshape(-1, 1) y = np.asarray(y, dtype=float) numStates = Ahat0.shape[0] if KFEM_Constraints is None: KFEM_Constraints = DecodingAlgorithms.KF_EMCreateConstraints() if Px0 is None: Px0 = 1e-10 * np.eye(numStates) else: Px0 = np.asarray(Px0, dtype=float) if x0 is None: x0 = np.zeros(numStates) else: x0 = np.asarray(x0, dtype=float).reshape(-1) tolAbs = _EM_TOL_ABS llTol = _EM_LL_TOL maxIter = _EM_MAX_ITER numToKeep = 10 # Save originals for un-scaling later A0 = Ahat0.copy() Q0 = Qhat0.copy() C0 = Chat0.copy() R0 = Rhat0.copy() alpha0 = alphahat0.copy() yOrig = y.copy() # Circular buffers (indexed by storeInd) Ahat_buf = [None] * numToKeep Qhat_buf = [None] * numToKeep Chat_buf = [None] * numToKeep Rhat_buf = [None] * numToKeep x0hat_buf = [None] * numToKeep Px0hat_buf = [None] * numToKeep alphahat_buf = [None] * numToKeep x_K_buf = [None] * numToKeep W_K_buf = [None] * numToKeep ExpSums_buf = [None] * numToKeep # Initialize slot 0 Ahat_buf[0] = A0.copy() Qhat_buf[0] = Q0.copy() Chat_buf[0] = C0.copy() Rhat_buf[0] = R0.copy() x0hat_buf[0] = x0.copy() Px0hat_buf[0] = Px0.copy() alphahat_buf[0] = alpha0.copy() # Scale the system via Cholesky transforms. # FIX (KF track M, item C3 / G1, mirrors nSTAT PR #138 @ 6ba92cb): # MATLAB's chol(Q) returns the UPPER factor R (R'*R = Q), so # Tq = inv(R) whitens exactly only for a diagonal Q0 (Tq*Q0*Tq' != I # in general). Use the LOWER factor L (Q0 = L*L'; np.linalg.cholesky # already returns the lower factor, so no transpose), so # Tq = inv(L) gives Tq*Q0*Tq' = I exactly. For diagonal Q0/R0, L = R # and nothing changes (bit-identical). # Matlab (repaired): Tq = eye(size(Q))/(chol(Q,'lower')); # Tr = eye(size(R))/(chol(R,'lower')) scaledSystem = True if scaledSystem: try: cholQ = np.linalg.cholesky(Qhat_buf[0]) # lower Cholesky except np.linalg.LinAlgError: cholQ = np.linalg.cholesky(_nearestSPD(Qhat_buf[0])) try: cholR = np.linalg.cholesky(Rhat_buf[0]) # lower Cholesky except np.linalg.LinAlgError: cholR = np.linalg.cholesky(_nearestSPD(Rhat_buf[0])) Tq = np.linalg.solve(cholQ, np.eye(numStates)) Tr = np.linalg.solve(cholR, np.eye(y.shape[0])) Ahat_buf[0] = Tq @ Ahat_buf[0] @ np.linalg.solve(Tq, np.eye(numStates)) Chat_buf[0] = Tr @ Chat_buf[0] @ np.linalg.solve(Tq, np.eye(numStates)) Qhat_buf[0] = Tq @ Qhat_buf[0] @ Tq.T Rhat_buf[0] = Tr @ Rhat_buf[0] @ Tr.T y = Tr @ y x0hat_buf[0] = Tq @ x0 Px0hat_buf[0] = Tq @ Px0 @ Tq.T alphahat_buf[0] = Tr @ alphahat_buf[0] cnt = 0 # 0-based iteration counter ll_list = [] dLikelihood = [np.inf, np.inf] IkedaAcc = KFEM_Constraints["EnableIkeda"] stoppingCriteria = False _logger.info(" Kalman Filter/Gaussian Observation EM Algorithm ") while not stoppingCriteria and cnt < maxIter: storeInd = cnt % numToKeep storeIndP1 = (cnt + 1) % numToKeep storeIndM1 = (cnt - 1) % numToKeep _logger.info("-" * 100) _logger.info("Iteration #%s", cnt + 1) _logger.info("-" * 100) # E-step x_K_buf[storeInd], W_K_buf[storeInd], ll_val, ExpSums_buf[storeInd] = ( DecodingAlgorithms.KF_EStep( Ahat_buf[storeInd], Qhat_buf[storeInd], Chat_buf[storeInd], Rhat_buf[storeInd], y, alphahat_buf[storeInd], x0hat_buf[storeInd], Px0hat_buf[storeInd], ) ) ll_list.append(ll_val) # M-step ( Ahat_buf[storeIndP1], Qhat_buf[storeIndP1], Chat_buf[storeIndP1], Rhat_buf[storeIndP1], alphahat_buf[storeIndP1], x0hat_buf[storeIndP1], Px0hat_buf[storeIndP1], ) = DecodingAlgorithms.KF_MStep( y, x_K_buf[storeInd], x0hat_buf[storeInd], Px0hat_buf[storeInd], ExpSums_buf[storeInd], KFEM_Constraints, ) # Ikeda acceleration if IkedaAcc: _logger.info("****Ikeda Acceleration Step****") K_obs = x_K_buf[storeInd].shape[1] mean_y = ( Chat_buf[storeIndP1] @ x_K_buf[storeInd] + alphahat_buf[storeIndP1] @ np.ones((1, K_obs)) ) ykNew = np.random.multivariate_normal( np.zeros(Rhat_buf[storeIndP1].shape[0]), Rhat_buf[storeIndP1], size=K_obs, ).T + mean_y x_KNew, W_KNew, llNew, ExpSumsNew = DecodingAlgorithms.KF_EStep( Ahat_buf[storeInd], Qhat_buf[storeInd], Chat_buf[storeInd], Rhat_buf[storeInd], ykNew, alphahat_buf[storeInd], x0, Px0, ) ( AhatNew, QhatNew, ChatNew, RhatNew, alphahatNew, x0new, Px0new, ) = DecodingAlgorithms.KF_MStep( ykNew, x_KNew, x0hat_buf[storeInd], Px0hat_buf[storeInd], ExpSumsNew, KFEM_Constraints, ) Ahat_buf[storeIndP1] = 2 * Ahat_buf[storeIndP1] - AhatNew Qhat_buf[storeIndP1] = 2 * Qhat_buf[storeIndP1] - QhatNew Qhat_buf[storeIndP1] = _symmetrize(Qhat_buf[storeIndP1]) Chat_buf[storeIndP1] = 2 * Chat_buf[storeIndP1] - ChatNew Rhat_buf[storeIndP1] = 2 * Rhat_buf[storeIndP1] - RhatNew Rhat_buf[storeIndP1] = _symmetrize(Rhat_buf[storeIndP1]) alphahat_buf[storeIndP1] = 2 * alphahat_buf[storeIndP1] - alphahatNew # Override A if not estimating if not KFEM_Constraints["EstimateA"]: Ahat_buf[storeIndP1] = Ahat_buf[storeInd] # Likelihood change if cnt == 0: dLikelihood_val = np.inf else: dLikelihood_val = ll_list[cnt] - ll_list[cnt - 1] # Convergence check: max parameter change if cnt == 0: dMax = np.inf else: prev = storeIndM1 dQvals = np.max(np.abs( np.sqrt(np.abs(np.diag(Qhat_buf[storeInd]))) - np.sqrt(np.abs(np.diag(Qhat_buf[prev]))) )) dRvals = np.max(np.abs( np.sqrt(np.abs(np.diag(Rhat_buf[storeInd]))) - np.sqrt(np.abs(np.diag(Rhat_buf[prev]))) )) dAvals = np.max(np.abs(Ahat_buf[storeInd] - Ahat_buf[prev])) dCvals = np.max(np.abs(Chat_buf[storeInd] - Chat_buf[prev])) dAlphavals = np.max(np.abs(alphahat_buf[storeInd] - alphahat_buf[prev])) dMax = max(dQvals, dRvals, dAvals, dCvals, dAlphavals) if cnt == 0: _logger.info("Max Parameter Change: N/A") else: _logger.info("Max Parameter Change: %s", dMax) cnt += 1 if dMax < tolAbs: stoppingCriteria = True _logger.info(" EM converged at iteration# %s b/c change in params was within criteria", cnt) if abs(dLikelihood_val) < llTol or dLikelihood_val < 0: stoppingCriteria = True _logger.info(" EM stopped at iteration# %s b/c change in likelihood was negative", cnt) _logger.info("-" * 100) # Select best iteration by max log-likelihood ll_arr = np.array(ll_list) maxLLIndex = int(np.argmax(ll_arr)) maxLLIndMod = maxLLIndex % numToKeep nIter = cnt xKFinal = x_K_buf[maxLLIndMod] WKFinal = W_K_buf[maxLLIndMod] Ahat_final = Ahat_buf[maxLLIndMod] Qhat_final = Qhat_buf[maxLLIndMod] Chat_final = Chat_buf[maxLLIndMod] Rhat_final = Rhat_buf[maxLLIndMod] alphahat_final = alphahat_buf[maxLLIndMod] x0hat_final = x0hat_buf[maxLLIndMod] Px0hat_final = Px0hat_buf[maxLLIndMod] ll_best = ll_list[maxLLIndex] ExpectationSumsFinal = ExpSums_buf[maxLLIndMod] # Un-scale the system. # FIX (KF track M, item C3 / G1, mirrors #138 @ 6ba92cb): lower # Cholesky factor; see the scale-in block above for why. if scaledSystem: # Reconstruct Tq, Tr from original Q0, R0 try: cholQ0 = np.linalg.cholesky(Q0) except np.linalg.LinAlgError: cholQ0 = np.linalg.cholesky(_nearestSPD(Q0)) try: cholR0 = np.linalg.cholesky(R0) except np.linalg.LinAlgError: cholR0 = np.linalg.cholesky(_nearestSPD(R0)) Tq = np.linalg.solve(cholQ0, np.eye(numStates)) Tr = np.linalg.solve(cholR0, np.eye(y.shape[0])) # Matlab: Ahat = Tq\Ahat*Tq Tq_inv = np.linalg.inv(Tq) Tr_inv = np.linalg.inv(Tr) Ahat_final = Tq_inv @ Ahat_final @ Tq Qhat_final = Tq_inv @ Qhat_final @ Tq_inv.T Chat_final = Tr_inv @ Chat_final @ Tq Rhat_final = Tr_inv @ Rhat_final @ Tr_inv.T alphahat_final = Tr_inv @ alphahat_final x0hat_final = Tq_inv @ x0hat_final Px0hat_final = Tq_inv @ Px0hat_final @ Tq_inv.T # FIX (KF track M, item C4, mirrors #138 @ 6ba92cb): xKFinal, # WKFinal, ll_best and ExpectationSumsFinal were left on the # internal Tq/Tr-scaled system (the loop's buffers above), while # Ahat/Qhat/Chat/Rhat/alphahat/x0hat/Px0hat are now on the # original scale, and y (scaled at setup) was never restored. # The SE call below and the IC formula further down then mixed # scaled sums/y with original-scale estimates -- the same # defect class as PP_EM/PPLFP_EM's pre-F8/F10 SE and IC bugs. # Recompute the E-step once, from the unscaled parameters and # the original y: the Kalman filter/RTS smoother is exactly # equivariant under this linear change of variables, so this # reproduces (rather than approximates) the original-coordinate # xKFinal, WKFinal, ll_best and ExpectationSumsFinal, all # mutually consistent on one scale. xKFinal, WKFinal, ll_best, ExpectationSumsFinal = DecodingAlgorithms.KF_EStep( Ahat_final, Qhat_final, Chat_final, Rhat_final, yOrig, alphahat_final, x0hat_final, Px0hat_final, ) # Compute standard errors SE, Pvals = DecodingAlgorithms.KF_ComputeParamStandardErrors( yOrig, xKFinal, WKFinal, Ahat_final, Qhat_final, Chat_final, Rhat_final, alphahat_final, x0hat_final, Px0hat_final, ExpectationSumsFinal, KFEM_Constraints, ) # Compute information criteria # Count number of estimated parameters (matches Matlab lines 3600-3640) if KFEM_Constraints["EstimateA"] and KFEM_Constraints["AhatDiag"]: np1 = Ahat_final.shape[0] elif KFEM_Constraints["EstimateA"] and not KFEM_Constraints["AhatDiag"]: np1 = Ahat_final.size else: np1 = 0 if KFEM_Constraints["QhatDiag"] and KFEM_Constraints["QhatIsotropic"]: np2 = 1 elif KFEM_Constraints["QhatDiag"] and not KFEM_Constraints["QhatIsotropic"]: np2 = Qhat_final.shape[0] else: np2 = Qhat_final.size np3 = Chat_final.size if KFEM_Constraints["RhatDiag"] and KFEM_Constraints["RhatIsotropic"]: np4 = 1 elif KFEM_Constraints["RhatDiag"] and not KFEM_Constraints["RhatIsotropic"]: # FIX (KF track M, item C5 / F11, mirrors #138 @ dcf03eb): this # branch tested Q's flags instead of R's, so R's own parameter # count (and therefore IC.nTerms/AIC/AICc/BIC) was wrong whenever # QhatDiag/QhatIsotropic differed from RhatDiag=1/RhatIsotropic=0. np4 = Rhat_final.shape[0] else: np4 = Rhat_final.size if KFEM_Constraints["EstimatePx0"] and KFEM_Constraints["Px0Isotropic"]: np5 = 1 elif KFEM_Constraints["EstimatePx0"] and not KFEM_Constraints["Px0Isotropic"]: np5 = Px0hat_final.shape[0] else: np5 = 0 np6 = x0hat_final.shape[0] if KFEM_Constraints["Estimatex0"] else 0 np7 = alphahat_final.shape[0] nTerms_ic = np1 + np2 + np3 + np4 + np5 + np6 + np7 K_steps = yOrig.shape[1] Dx = Ahat_final.shape[1] sumXkTerms_final = ExpectationSumsFinal["sumXkTerms"] # Matlab: llobs = ll + Dx*K/2*log(2*pi) + K/2*log(det(Qhat)) # + 1/2*trace(Qhat\sumXkTerms) + Dx/2*log(2*pi) # + 1/2*log(det(Px0hat)) + 1/2*Dx _, logdet_Q_final = np.linalg.slogdet(Qhat_final) _, logdet_Px0_final = np.linalg.slogdet(Px0hat_final) llobs = ( ll_best + Dx * K_steps / 2.0 * np.log(2.0 * np.pi) + K_steps / 2.0 * logdet_Q_final + 0.5 * np.trace(np.linalg.solve(Qhat_final, sumXkTerms_final)) + Dx / 2.0 * np.log(2.0 * np.pi) + 0.5 * logdet_Px0_final + 0.5 * Dx ) AIC = 2.0 * nTerms_ic - 2.0 * llobs AICc = AIC + 2.0 * nTerms_ic * (nTerms_ic + 1) / max(K_steps - nTerms_ic - 1, 1) BIC = -2.0 * llobs + nTerms_ic * np.log(K_steps) IC = { "AIC": float(AIC), "AICc": float(AICc), "BIC": float(BIC), "llobs": float(llobs), "llcomp": float(ll_best), } return ( xKFinal, WKFinal, Ahat_final, Qhat_final, Chat_final, Rhat_final, alphahat_final, x0hat_final, Px0hat_final, IC, SE, Pvals, nIter, )
[docs] @staticmethod def KF_EStep(A, Q, C, R, y, alpha, x0, Px0): """E-step for the Kalman Filter EM algorithm. Runs the forward Kalman filter followed by the backward RTS smoother and computes sufficient statistics (expectation sums) for the M-step. Parameters ---------- A : (Dx, Dx) state transition matrix Q : (Dx, Dx) process noise covariance C : (Dy, Dx) observation matrix R : (Dy, Dy) observation noise covariance y : (Dy, K) observations (state-major) alpha : (Dy, 1) or (Dy,) observation offset x0 : (Dx,) initial state Px0 : (Dx, Dx) initial state covariance Returns ------- x_K : (Dx, K) smoothed states W_K : (Dx, Dx, K) smoothed covariances logll : float — complete-data log-likelihood ExpectationSums : dict of sufficient statistics """ A = np.asarray(A, dtype=float) Q = np.asarray(Q, dtype=float) C = np.asarray(C, dtype=float) R = np.asarray(R, dtype=float) y = np.asarray(y, dtype=float) alpha = np.asarray(alpha, dtype=float).reshape(-1, 1) x0 = np.asarray(x0, dtype=float).reshape(-1) Px0 = np.asarray(Px0, dtype=float) Dx = A.shape[1] Dy = C.shape[0] K = y.shape[1] # Forward filter with offset subtracted: y - alpha*ones(1,K) y_centered = y - alpha @ np.ones((1, K)) x_p, Pe_p, x_u, Pe_u = DecodingAlgorithms._kf_filter_stateMajor( A, C, Q, R, Px0, x0, y_centered ) # Backward RTS smoother x_K, W_K, Lk = DecodingAlgorithms._kf_smootherFromFiltered_stateMajor( A, x_p, Pe_p, x_u, Pe_u ) # Best estimates of initial states given the data # Matlab: W1G0 = A*Px0*A' + Q W1G0 = A @ Px0 @ A.T + Q L0 = Px0 @ A.T @ np.linalg.pinv(W1G0) # Ex0Gy = x0 + L0*(x_K(:,1) - x_p(:,1)) Ex0Gy = x0 + L0 @ (x_K[:, 0] - x_p[:, 0]) # Px0Gy = Px0 + L0*(inv(W_K(:,:,1)) - inv(W1G0))*L0' Px0Gy = Px0 + L0 @ ( np.linalg.pinv(W_K[:, :, 0]) - np.linalg.pinv(W1G0) ) @ L0.T Px0Gy = _symmetrize(Px0Gy) # Cross-covariance terms Wku(:,:,k,u) from de Jong and MacKinnon 1988 # Only compute the elements actually needed for the sums: # Wku(:,:,k,k) = W_K(:,:,k) and off-diagonal lags (k, k+1) # Matlab: Dk(:,:,k) = W_u(:,:,k)*A'/(W_p(:,:,k+1)) # Wku(:,:,k,u) = Dk(:,:,k)*Wku(:,:,k+1,u) # We only need Wku(:,:,k-1,k) for the expectation sums. Wku_lag1 = np.zeros((Dx, Dx, K), dtype=float) # Wku_lag1[:,:,k] = Wku(:,:,k-1,k) for k in range(K - 1, 0, -1): # Dk = Pe_u[:,:,k-1] * A' / Pe_p[:,:,k] Dk = Pe_u[:, :, k - 1] @ A.T @ np.linalg.pinv(Pe_p[:, :, k]) if k == K - 1: # Wku(:,:,k-1,k) = Dk * W_K(:,:,k) Wku_lag1[:, :, k] = Dk @ W_K[:, :, k] else: # Wku(:,:,k-1,k) = Dk * Wku(:,:,k,k) = Dk * W_K(:,:,k) Wku_lag1[:, :, k] = Dk @ W_K[:, :, k] # Also need Wku(:,:,0,0) = W_K(:,:,0) and Px0*A'/W_p(:,:,0) for k==0 # Matlab: Sxkm1xk at k==1: Px0*A'/W_p(:,:,1)*Wku(:,:,1,1) # Note: Matlab 1-indexed, W_p(:,:,1) is our Pe_p[:,:,0] # But the Matlab filter stores x_p(:,1)=x0, Pe_p(:,:,1)=Px0 # and W_p(:,:,1) after the first predict is actually Pe_p(:,:,1) in Matlab = Pe_p[:,:,0] here # Actually let me re-read: Matlab's filter has Pe_p(:,:,1)=Px0 and the first # iteration does update then predict, so Pe_p(:,:,2) = A*Pe_u(:,:,1)*A'+Q. # In our _kf_filter_stateMajor, Pe_p[:,:,0]=Px0 and Pe_p[:,:,1]=A*Pe_u[:,:,0]*A'+Q # So Matlab's W_p(:,:,1) = Pe_p(:,:,1) in Matlab = our Pe_p[:,:,0] = Px0 # Sufficient statistics (expectation sums) Sxkm1xk = np.zeros((Dx, Dx), dtype=float) Sxkm1xkm1 = np.zeros((Dx, Dx), dtype=float) Sxkxk = np.zeros((Dx, Dx), dtype=float) Sykyk = np.zeros((Dy, Dy), dtype=float) Sxkyk = np.zeros((Dx, Dy), dtype=float) for k in range(K): if k == 0: # Matlab: Sxkm1xk = Sxkm1xk + Px0*A'/W_p(:,:,1)*Wku(:,:,1,1) # W_p(:,:,1) in Matlab is Pe_p[:,:,0] = Px0 here # Wku(:,:,1,1) = W_K(:,:,1) in Matlab = W_K[:,:,0] here Sxkm1xk += Px0 @ A.T @ np.linalg.pinv(Pe_p[:, :, 0]) @ W_K[:, :, 0] Sxkm1xkm1 += Px0 + np.outer(x0, x0) else: # Wku(:,:,k-1,k) is Wku_lag1[:,:,k] Sxkm1xk += Wku_lag1[:, :, k] + np.outer(x_K[:, k - 1], x_K[:, k]) Sxkm1xkm1 += W_K[:, :, k - 1] + np.outer(x_K[:, k - 1], x_K[:, k - 1]) Sxkxk += W_K[:, :, k] + np.outer(x_K[:, k], x_K[:, k]) Sykyk += np.outer(y[:, k] - alpha.ravel(), y[:, k] - alpha.ravel()) Sxkyk += np.outer(x_K[:, k], y[:, k] - alpha.ravel()) Sxkxk = _symmetrize(Sxkxk) Sykyk = _symmetrize(Sykyk) sumXkTerms = Sxkxk - A @ Sxkm1xk - Sxkm1xk.T @ A.T + A @ Sxkm1xkm1 @ A.T sumYkTerms = Sykyk - C @ Sxkyk - Sxkyk.T @ C.T + C @ Sxkxk @ C.T Sxkxkm1 = Sxkm1xk.T sumXkTerms = _symmetrize(sumXkTerms) sumYkTerms = _symmetrize(sumYkTerms) # Complete-data log-likelihood # Matlab: logll = -Dx*K/2*log(2*pi) - K/2*log(det(Q)) # - Dy*K/2*log(2*pi) - K/2*log(det(R)) # - Dx/2*log(2*pi) - 1/2*log(det(Px0)) # - 1/2*trace(inv(Q)*sumXkTerms) - 1/2*trace(inv(R)*sumYkTerms) # - Dx/2 sign_Q, logdet_Q = np.linalg.slogdet(Q) sign_R, logdet_R = np.linalg.slogdet(R) sign_P, logdet_P = np.linalg.slogdet(Px0) logll = ( -Dx * K / 2.0 * np.log(2.0 * np.pi) - K / 2.0 * logdet_Q - Dy * K / 2.0 * np.log(2.0 * np.pi) - K / 2.0 * logdet_R - Dx / 2.0 * np.log(2.0 * np.pi) - 0.5 * logdet_P - 0.5 * np.trace(np.linalg.solve(Q, sumXkTerms)) - 0.5 * np.trace(np.linalg.solve(R, sumYkTerms)) - Dx / 2.0 ) logll = float(logll) _logger.info("logll: %s", logll) ExpectationSums = { "Sxkm1xkm1": Sxkm1xkm1, "Sxkm1xk": Sxkm1xk, "Sxkxkm1": Sxkxkm1, "Sxkxk": Sxkxk, "Sxkyk": Sxkyk, "Sykyk": Sykyk, "sumXkTerms": sumXkTerms, "sumYkTerms": sumYkTerms, "Sx0": Ex0Gy, "Sx0x0": Px0Gy + np.outer(Ex0Gy, Ex0Gy), } return x_K, W_K, logll, ExpectationSums
[docs] @staticmethod def KF_MStep(y, x_K, x0, Px0, ExpectationSums, KFEM_Constraints=None): """M-step for the Kalman Filter EM algorithm. Updates all state-space model parameters given the sufficient statistics from :meth:`KF_EStep`. Parameters ---------- y : (Dy, K) observations x_K : (Dx, K) smoothed states from E-step x0 : (Dx,) current initial state estimate Px0 : (Dx, Dx) current initial covariance estimate ExpectationSums : dict from :meth:`KF_EStep` KFEM_Constraints : dict from :meth:`KF_EMCreateConstraints`, or *None* Returns ------- Ahat, Qhat, Chat, Rhat, alphahat, x0hat, Px0hat """ if KFEM_Constraints is None: KFEM_Constraints = DecodingAlgorithms.KF_EMCreateConstraints() Sxkm1xkm1 = ExpectationSums["Sxkm1xkm1"] Sxkxkm1 = ExpectationSums["Sxkxkm1"] Sxkxk = ExpectationSums["Sxkxk"] Sxkyk = ExpectationSums["Sxkyk"] sumXkTerms = ExpectationSums["sumXkTerms"] sumYkTerms = ExpectationSums["sumYkTerms"] Sx0 = ExpectationSums["Sx0"] Sx0x0 = ExpectationSums["Sx0x0"] y = np.asarray(y, dtype=float) x_K = np.asarray(x_K, dtype=float) x0 = np.asarray(x0, dtype=float).reshape(-1) Px0 = np.asarray(Px0, dtype=float) N, K = x_K.shape # N = Dx (num states), K = num time steps # Ahat if KFEM_Constraints["AhatDiag"]: I_N = np.eye(N) Ahat = (Sxkxkm1 * I_N) @ np.linalg.pinv(Sxkm1xkm1 * I_N) else: Ahat = Sxkxkm1 @ np.linalg.pinv(Sxkm1xkm1) # Chat = Sxkyk' / Sxkxk (Matlab: Chat = Sxkyk'/Sxkxk) Chat = Sxkyk.T @ np.linalg.pinv(Sxkxk) # alphahat = sum(y - Chat*x_K, 2) / K alphahat = np.sum(y - Chat @ x_K, axis=1, keepdims=True) / K # Qhat if KFEM_Constraints["QhatDiag"]: if KFEM_Constraints["QhatIsotropic"]: Qhat = (1.0 / (N * K)) * np.trace(sumXkTerms) * np.eye(N) else: I_N = np.eye(N) Qhat = (1.0 / K) * (sumXkTerms * I_N) Qhat = _symmetrize(Qhat) else: Qhat = (1.0 / K) * sumXkTerms Qhat = _symmetrize(Qhat) # Rhat dy = sumYkTerms.shape[0] if KFEM_Constraints["RhatDiag"]: if KFEM_Constraints["RhatIsotropic"]: I_dy = np.eye(dy) Rhat = (1.0 / (dy * K)) * np.trace(sumYkTerms) * I_dy else: I_dy = np.eye(dy) Rhat = (1.0 / K) * (sumYkTerms * I_dy) Rhat = _symmetrize(Rhat) else: Rhat = (1.0 / K) * sumYkTerms Rhat = _symmetrize(Rhat) # x0hat — uses the newly computed Ahat and Qhat if KFEM_Constraints["Estimatex0"]: # Matlab: x0hat = (inv(Px0)+Ahat'/Qhat*Ahat)\(Ahat'/Qhat*x_K(:,1)+Px0\x0) Px0_inv = np.linalg.pinv(Px0) AQ = np.linalg.solve(Qhat, Ahat) # Qhat\Ahat lhs = Px0_inv + Ahat.T @ AQ rhs = Ahat.T @ np.linalg.solve(Qhat, x_K[:, 0]) + np.linalg.solve(Px0, x0) x0hat = np.linalg.solve(lhs, rhs) else: x0hat = x0.copy() # Px0hat if KFEM_Constraints["EstimatePx0"]: if KFEM_Constraints["Px0Isotropic"]: diff = x0hat - x0 Px0hat = (np.trace(np.outer(diff, diff)) / (N * K)) * np.eye(N) else: I_N = np.eye(N) diff = x0hat - x0 Px0hat = ( np.outer(x0hat, x0hat) - np.outer(x0, x0hat) - np.outer(x0hat, x0) + np.outer(x0, x0) ) * I_N Px0hat = _symmetrize(Px0hat) eigvals, eigvecs = np.linalg.eigh(Px0hat) if np.min(eigvals) < np.finfo(float).eps: eigvals[eigvals == np.min(eigvals)] = np.finfo(float).eps Px0hat = eigvecs @ np.diag(eigvals) @ eigvecs.T else: Px0hat = Px0.copy() return Ahat, Qhat, Chat, Rhat, alphahat, x0hat, Px0hat
# ------------------------------------------------------------------ # PPLFP family — deprecated shims (MATLAB order: fixedIntervalSmoother, # DecodeLinear, Decode_predict, Decode_update, EMCreateConstraints, # ComputeParamStandardErrors, EM, EStep, MStep). # # The canonical implementations live in :mod:`nstat.decoding.PPLFP`. # These wrappers preserve the historical ``DecodingAlgorithms.PPLFP_*`` # entry points while call sites migrate to the new import path. # ------------------------------------------------------------------
[docs] @staticmethod def PPLFP_fixedIntervalSmoother(*args, **kwargs): """[DEPRECATED] Use nstat.decoding.PPLFP.PPLFP_fixedIntervalSmoother instead.""" from nstat.decoding.PPLFP import PPLFP return getattr(PPLFP, "PPLFP_fixedIntervalSmoother")(*args, **kwargs)
[docs] @staticmethod def PPLFP_DecodeLinear(*args, **kwargs): """[DEPRECATED] Use nstat.decoding.PPLFP.PPLFP_DecodeLinear instead.""" from nstat.decoding.PPLFP import PPLFP return getattr(PPLFP, "PPLFP_DecodeLinear")(*args, **kwargs)
[docs] @staticmethod def PPLFP_Decode_predict(*args, **kwargs): """[DEPRECATED] Use nstat.decoding.PPLFP.PPLFP_Decode_predict instead.""" from nstat.decoding.PPLFP import PPLFP return getattr(PPLFP, "PPLFP_Decode_predict")(*args, **kwargs)
[docs] @staticmethod def PPLFP_Decode_update(*args, **kwargs): """[DEPRECATED] Use nstat.decoding.PPLFP.PPLFP_Decode_update instead.""" from nstat.decoding.PPLFP import PPLFP return getattr(PPLFP, "PPLFP_Decode_update")(*args, **kwargs)
[docs] @staticmethod def PPLFP_EMCreateConstraints(*args, **kwargs): """[DEPRECATED] Use nstat.decoding.PPLFP.PPLFP_EMCreateConstraints instead.""" from nstat.decoding.PPLFP import PPLFP return getattr(PPLFP, "PPLFP_EMCreateConstraints")(*args, **kwargs)
[docs] @staticmethod def PPLFP_ComputeParamStandardErrors(*args, **kwargs): """[DEPRECATED] Use nstat.decoding.PPLFP.PPLFP_ComputeParamStandardErrors instead.""" from nstat.decoding.PPLFP import PPLFP return getattr(PPLFP, "PPLFP_ComputeParamStandardErrors")(*args, **kwargs)
[docs] @staticmethod def PPLFP_EM(*args, **kwargs): """[DEPRECATED] Use nstat.decoding.PPLFP.PPLFP_EM instead.""" from nstat.decoding.PPLFP import PPLFP return getattr(PPLFP, "PPLFP_EM")(*args, **kwargs)
[docs] @staticmethod def PPLFP_EStep(*args, **kwargs): """[DEPRECATED] Use nstat.decoding.PPLFP.PPLFP_EStep instead.""" from nstat.decoding.PPLFP import PPLFP return getattr(PPLFP, "PPLFP_EStep")(*args, **kwargs)
[docs] @staticmethod def PPLFP_MStep(*args, **kwargs): """[DEPRECATED] Use nstat.decoding.PPLFP.PPLFP_MStep instead.""" from nstat.decoding.PPLFP import PPLFP return getattr(PPLFP, "PPLFP_MStep")(*args, **kwargs)
# ------------------------------------------------------------------ # PP_EM family: Point-Process state-space EM (without basis functions) # ------------------------------------------------------------------
[docs] @staticmethod def PP_EMCreateConstraints( EstimateA=1, AhatDiag=0, QhatDiag=1, QhatIsotropic=0, Estimatex0=0, EstimatePx0=0, Px0Isotropic=0, mcIter=1000, EnableIkeda=0, ): """Build a constraints dict for PP_EM. Matlab: ``PointProcessEM.PP_EMCreateConstraints``. Defaults follow the repaired MATLAB (fix/pp-em round 2): ``EstimateA=1, AhatDiag=0, QhatDiag=1, QhatIsotropic=0, Estimatex0=0, EstimatePx0=0, Px0Isotropic=0, mcIter=1000, EnableIkeda=0``. x0 and Px0 are NOT estimated by default (they were 1 and 1): the Px0 M-step, ``Px0hat = (x0hat - x0)(x0hat - x0)' .* I``, is a single-sample estimate that collapses to ~0 after one iteration, which drives ``-1/2 log det(Px0)`` and hence the E-step log-likelihood to +Inf and stops EM after ~2 iterations (MATLAB report C2). Pass ``Estimatex0=1, EstimatePx0=1`` explicitly for the old behaviour. Parameters ---------- EstimateA : int Whether to estimate the state transition matrix A. AhatDiag : int Constrain A to be diagonal. QhatDiag : int Constrain Q to be diagonal. QhatIsotropic : int Constrain Q to be isotropic (scalar * I). Estimatex0 : int Whether to estimate the initial state x0 (default 0; see above). EstimatePx0 : int Whether to estimate the initial state covariance Px0 (default 0). Px0Isotropic : int Constrain Px0 to be isotropic. mcIter : int Number of Monte Carlo iterations for standard error estimation. EnableIkeda : int Enable Ikeda acceleration. Not ported: ``PP_EM`` raises ``NotImplementedError`` when it is 1 (MATLAB runs an Ikeda step, history-free models only). Returns ------- dict Constraints dictionary with all fields. Notes ----- Docs only; mirrors nSTAT PR #138 (``fix/kf-em`` @ ``584ec96``). ``QhatDiag=1`` imposes diagonal structure on ``Qhat`` in the frame ``PP_EM`` internally whitens by the STARTING covariance ``Q0`` (``Tq = inv(chol(Q0, 'lower'))``; see G1), not in the caller's own state coordinates. For a diagonal ``Q0`` the two frames coincide, so this is invisible. For a non-diagonal ``Q0``, "``Qhat`` is diagonal" means ``Tq @ Qhat @ Tq.T`` is diagonal; the returned ``Qhat`` (mapped back to the caller's coordinates) generally is not. """ C = {} C["EstimateA"] = int(EstimateA) C["AhatDiag"] = int(AhatDiag) C["QhatDiag"] = int(QhatDiag) C["QhatIsotropic"] = 1 if (QhatDiag and QhatIsotropic) else 0 C["Estimatex0"] = int(Estimatex0) C["EstimatePx0"] = int(EstimatePx0) C["Px0Isotropic"] = 1 if (EstimatePx0 and Px0Isotropic) else 0 C["mcIter"] = int(mcIter) C["EnableIkeda"] = int(EnableIkeda) return C
@staticmethod def _nearestSPD(A): """MATLAB ``nearestSPD`` (Higham 1988 projection, then the ``-mineig*k^2 + eps(mineig)`` diagonal shift until ``chol`` succeeds); see :func:`_matlab_nearest_spd`. """ return _matlab_nearest_spd(A) @staticmethod def _ztest_pvalue(param, se): """Two-sided z-test p-value for H0: param == 0, as MATLAB ``ztest(param, 0, se)`` (see :func:`_matlab_ztest_p`). Returns an ndarray (0-d for scalar inputs).""" return np.asarray(_matlab_ztest_p(param, se))
[docs] @staticmethod @_matlab_float_semantics def PP_ComputeParamStandardErrors( dN, xKFinal, WKFinal, Ahat, Qhat, x0hat, Px0hat, ExpectationSumsFinal, fitType, muhat, betahat, gammahat, windowTimes, HkAll, PPEM_Constraints=None, ): """Compute standard errors via the observed information matrix. Uses a Monte-Carlo approximation of the missing information matrix (McLachlan & Krishnan, Eq. 4.7), with ``mcIter`` draws from NumPy's global stream (seed with ``np.random.seed`` or :func:`nstat.extras.matlab_rng.seeded_global_rng`). As in MATLAB (nSTAT PR #135): the constraints are honoured, the intensities are unclipped, the inverse observed information is projected with MATLAB's ``nearestSPD`` and the p-values are MATLAB ``ztest``'s ``2*normcdf(-|z|)``. An exactly singular observed information (MATLAB's SE pass never returns there) is a Python-only extension: the SEs come from its pseudo-inverse, every parameter with a component in its null space -- not identifiable from the data, typically the coefficient of a separated history window at the ``exp()`` underflow -- gets SE and p-value NaN, and a ``RuntimeWarning`` names those parameters. Parameters ---------- dN : (C, N) spike observations xKFinal : (dx, N) smoothed states WKFinal : (dx, dx, N) smoothed state covariances Ahat : (dx, dx) estimated state transition Qhat : (dx, dx) estimated state noise covariance x0hat : (dx,) estimated initial state Px0hat : (dx, dx) estimated initial state covariance ExpectationSumsFinal : dict with sufficient statistics fitType : 'poisson' or 'binomial' muhat : (C,) estimated baseline rates betahat : (dx, C) estimated stimulus coefficients gammahat : (nW, C) or scalar estimated history coefficients windowTimes : history window boundaries or None HkAll : (N, nW, C) history design tensor PPEM_Constraints : dict from PP_EMCreateConstraints Returns ------- SE : dict of standard errors for each parameter group Pvals : dict of p-values for each parameter group nTerms : int, total number of estimated parameters """ if PPEM_Constraints is None: PPEM_Constraints = DecodingAlgorithms.PP_EMCreateConstraints() Ahat = np.atleast_2d(Ahat) Qhat = np.atleast_2d(Qhat) Px0hat = np.atleast_2d(Px0hat) x0hat = np.asarray(x0hat, dtype=float).reshape(-1) muhat = np.asarray(muhat, dtype=float).reshape(-1) betahat = np.atleast_2d(betahat) gammahat = np.asarray(gammahat, dtype=float) dN = np.atleast_2d(dN) dx = Ahat.shape[0] N = xKFinal.shape[1] K = N numCells = betahat.shape[1] fitType = str(fitType).lower() # MATLAB F12 (fix/pp-em 1c051a9): a shared history-coefficient column # (numWindows x 1, a scalar for one window) with several cells is # expanded to numWindows x numCells, the rule PP_EM applies before it # calls this routine (B9). The gamma blocks below are per cell, so a # scalar counted C gamma parameters while returning a 1-D SE.gamma, and # a column failed on gammahat[:, c] for c > 0. gammahat = _expand_shared_se_gamma(gammahat, windowTimes, numCells) # MATLAB stores a one-cell N x nW x 1 history as an N x nW matrix and its # HkAll(k,:,c) slices read it as such (B6: single-cell history SEs). # Restore the cell axis; otherwise the ndim == 3 tests below silently # dropped the history from every block. HkAll = np.asarray(HkAll, dtype=float) if numCells == 1 and HkAll.ndim == 2: HkAll = HkAll.reshape(HkAll.shape[0], HkAll.shape[1], 1) # ---- Complete Information Matrices ---- # A information if PPEM_Constraints["EstimateA"]: n1_A, n2_A = Ahat.shape # The A (and full-Q) parameters are ordered row by row, as in # MATLAB (loop l over rows, m over columns; termvec = # reshape(termMat', 1, n) and ScoreAMc = reshape(ScorA', n, 1) are # row-major; SE.A = reshape(SEAterms, ncol, nrow)'). This port # filled each column column-major (termMat.T.ravel()), so the A # block was a row permutation of the Fisher information Q^-1 (x) # Sxkm1xkm1 -- not symmetric for dx >= 2 -- and its scores and # SE.A were column-major. if PPEM_Constraints["AhatDiag"]: IAComp = np.zeros((n1_A, n1_A)) for l in range(n1_A): el = np.zeros((n1_A, 1)) el[l, 0] = 1.0 em = np.zeros((n2_A, 1)) em[l, 0] = 1.0 # MATLAB (PointProcessEM.m ~166): Qhat\el*em'*S.*eye # -- one mldivide, not inv(Qhat). left = _matlab_mldivide_matrix(Qhat, el) termMat = (left @ em.T @ ExpectationSumsFinal["Sxkm1xkm1"]) * np.eye(n1_A) IAComp[:, l] = np.diag(termMat) else: nA = Ahat.size IAComp = np.zeros((nA, nA)) cnt = 0 for l in range(n1_A): el = np.zeros((n1_A, 1)) el[l, 0] = 1.0 for m in range(n2_A): em = np.zeros((n2_A, 1)) em[m, 0] = 1.0 # MATLAB (PointProcessEM.m ~176): inv(Qhat)*el*em'*S -- # the non-diagonal branch IS a literal inv() call. termMat = _matlab_inv(Qhat) @ (el @ em.T) @ ExpectationSumsFinal["Sxkm1xkm1"] IAComp[:, cnt] = termMat.ravel() cnt += 1 else: IAComp = np.zeros((0, 0)) # Q information # MATLAB (PointProcessEM.m ~201,213): N/2*((Qhat)\em*el'/(Qhat)) -- # one mldivide, one mrdivide per element, not a Qinv sandwich. n1_Q, n2_Q = Qhat.shape if PPEM_Constraints["QhatDiag"]: if PPEM_Constraints["QhatIsotropic"]: IQComp = np.array([[0.5 * N * dx * Qhat[0, 0] ** (-2)]]) else: IQComp = np.zeros((n1_Q, n1_Q)) cnt = 0 for l in range(n1_Q): el = np.zeros((n1_Q, 1)) el[l, 0] = 1.0 left = _matlab_mldivide_matrix(Qhat, el) * (N / 2.0) termMat = _matlab_mrdivide(left @ el.T, Qhat) IQComp[:, cnt] = np.diag(termMat) cnt += 1 else: nQ = Qhat.size IQComp = np.zeros((nQ, nQ)) cnt = 0 for l in range(n1_Q): el = np.zeros((n1_Q, 1)) el[l, 0] = 1.0 for m in range(n2_Q): em = np.zeros((n2_Q, 1)) em[m, 0] = 1.0 left = _matlab_mldivide_matrix(Qhat, em) * (N / 2.0) termMat = _matlab_mrdivide(left @ el.T, Qhat) IQComp[:, cnt] = termMat.ravel() # row-major, as MATLAB (see the A block) cnt += 1 # Px0 information # MATLAB (PointProcessEM.m 237): 0.5*((Px0hat)\em*el'/(Px0hat)). if PPEM_Constraints["EstimatePx0"]: if PPEM_Constraints["Px0Isotropic"]: ISComp = np.array([[0.5 * dx * Px0hat[0, 0] ** (-2)]]) else: n1_S, n2_S = Px0hat.shape ISComp = np.zeros((n1_S, n1_S)) cnt = 0 for l in range(n1_S): el = np.zeros((n1_S, 1)) el[l, 0] = 1.0 left = _matlab_mldivide_matrix(Px0hat, el) * 0.5 termMat = _matlab_mrdivide(left @ el.T, Px0hat) ISComp[:, cnt] = np.diag(termMat) cnt += 1 else: ISComp = np.zeros((0, 0)) # x0 information # MATLAB (+nstat/+decoding/PointProcessEM.m:247): # Ix0Comp = eye(size(Px0hat))/Px0hat + (Ahat'/Qhat)*Ahat; # literally mrdivide (eye/Px0hat, Ahat'/Qhat), not inv(...) -- see # _matlab_mrdivide's docstring and em-closed-form-solves-reciprocal-pivot # for why this is not necessarily bit-identical to the inv() form # through the hand-rolled LU. if PPEM_Constraints["Estimatex0"]: Ix0Comp = ( _matlab_mrdivide(np.eye(Px0hat.shape[0]), Px0hat) + _matlab_mrdivide(Ahat.T, Qhat) @ Ahat ) else: Ix0Comp = np.zeros((0, 0)) # Monte Carlo draws for expectation approximation: x_k ~ N(xKFinal_k, # WKFinal_k) through _mc_state_draws (MATLAB mcStateDraws, F9: the # draw was chol(W)*z, covariance R*R' instead of W). McExp = PPEM_Constraints["mcIter"] xKDrawExp = np.zeros((dx, K, McExp)) for k in range(K): xKDrawExp[:, k, :] = _mc_state_draws(xKFinal[:, k], WKFinal[:, :, k], McExp, np.random.randn) # Beta information (Hessian approximation via MC) IBetaComp = np.zeros((dx * numCells, dx * numCells)) # xkPerm: (dx, McExp, K) xkPerm = np.transpose(xKDrawExp, (0, 2, 1)) for c in range(numCells): HessianTerm = np.zeros((dx, dx, K)) for k in range(K): Hk = HkAll[k, :, c] if HkAll.ndim == 3 else np.zeros(0) xk = xkPerm[:, :, k] # (dx, McExp) gammaC = np.ravel(gammahat) if gammahat.size == 1 else gammahat[:, c] gammaC = np.atleast_1d(gammaC) Hk_vec = np.atleast_1d(Hk) hist_term = float(gammaC @ Hk_vec) if Hk_vec.size == gammaC.size and gammaC.size > 0 else 0.0 terms = muhat[c] + betahat[:, c] @ xk + hist_term if fitType == "poisson": ld = np.exp(terms) HessianTerm[:, :, k] = -1.0 / McExp * (ld[None, :] * xk) @ xk.T else: # binomial ld = np.exp(terms) / (1.0 + np.exp(terms)) ExplambdaDeltaXkXk = 1.0 / McExp * (ld[None, :] * xk) @ xk.T ExplambdaDeltaSqXkXkT = 1.0 / McExp * (ld[None, :] ** 2 * xk) @ xk.T ExplambdaDeltaCubeXkXkT = 1.0 / McExp * (ld[None, :] ** 3 * xk) @ xk.T # For log L = dN*log(p) - p with logistic p the beta Hessian is # -p(1-p)(1+dN-2p) x x' = (-(dN+1)p + (dN+3)p^2 - 2p^3) x x', the # form the mu and gamma blocks below use (MATLAB # PP_ComputeParamStandardErrors, repaired). The former # E[p] + E[p^2] - 2E[p^3] made IBetaComp negative definite. HessianTerm[:, :, k] = (-(dN[c, k] + 1) * ExplambdaDeltaXkXk + (dN[c, k] + 3) * ExplambdaDeltaSqXkXkT - 2 * ExplambdaDeltaCubeXkXkT) startInd = dx * c endInd = dx * (c + 1) IBetaComp[startInd:endInd, startInd:endInd] = -np.sum(HessianTerm, axis=2) # Mu information IMuComp = np.zeros((numCells, numCells)) for c in range(numCells): HessianTerm = 0.0 for k in range(K): Hk_full = HkAll[:, :, c] if HkAll.ndim == 3 else np.zeros((K, 0)) Hk_vec = Hk_full[k, :] if Hk_full.ndim == 2 and Hk_full.shape[0] > k else np.zeros(0) xk = xkPerm[:, :, k] gammaC = np.ravel(gammahat) if gammahat.size == 1 else gammahat[:, c] gammaC = np.atleast_1d(gammaC) Hk_vec = np.atleast_1d(Hk_vec) hist_term = float(gammaC @ Hk_vec) if Hk_vec.size == gammaC.size and gammaC.size > 0 else 0.0 terms = muhat[c] + betahat[:, c] @ xk + hist_term if fitType == "poisson": ld = np.exp(terms) HessianTerm -= 1.0 / McExp * np.sum(ld) else: ld = np.exp(terms) / (1.0 + np.exp(terms)) ExplambdaDelta = 1.0 / McExp * np.sum(ld) ExplambdaDeltaSq = 1.0 / McExp * np.sum(ld ** 2) ExplambdaDeltaCubed = 1.0 / McExp * np.sum(ld ** 3) # d/dmu of the score (dN-p)(1-p) is -(dN+1)p + (dN+3)p^2 - 2p^3; the # cubic coefficient was -3 (MATLAB, repaired, uses -2 as here and # in PP_MStep's mu Newton step). HessianTerm += -(dN[c, k] + 1) * ExplambdaDelta + (dN[c, k] + 3) * ExplambdaDeltaSq - 2 * ExplambdaDeltaCubed IMuComp[c, c] = -HessianTerm # Gamma information gammahat_flat = gammahat.ravel() has_gamma = gammahat_flat.size > 1 or (gammahat_flat.size == 1 and gammahat_flat[0] != 0) if windowTimes is not None and len(windowTimes) > 0 and has_gamma: nHist = HkAll.shape[1] if HkAll.ndim == 3 else 0 IGammaComp = np.zeros((nHist * numCells, nHist * numCells)) for c in range(numCells): HessianTerm = np.zeros((nHist, nHist)) Hk_full = HkAll[:, :, c] if HkAll.ndim == 3 else np.zeros((K, nHist)) for k in range(K): Hk_vec = Hk_full[k, :] xk = xkPerm[:, :, k] gammaC = np.ravel(gammahat) if gammahat.size == 1 else gammahat[:, c] gammaC = np.atleast_1d(gammaC) hist_term = float(gammaC @ Hk_vec) if Hk_vec.size == gammaC.size else 0.0 terms = muhat[c] + betahat[:, c] @ xk + hist_term if fitType == "poisson": ld = np.exp(terms) ExplambdaDelta = 1.0 / McExp * np.sum(ld) HessianTerm -= np.outer(Hk_vec, Hk_vec) * ExplambdaDelta else: ld = np.exp(terms) / (1.0 + np.exp(terms)) ExplambdaDelta = 1.0 / McExp * np.sum(ld) ExplambdaDeltaSq = 1.0 / McExp * np.sum(ld ** 2) # Third moment of the binomial Monte Carlo estimate. The # original Python port mirrored MATLAB's ``ld.^2`` here # because that was MATLAB's behavior at the time, but # MATLAB nSTAT v1.4.0 audit point 1.3 fixed the variable # to ``ld.^3`` (it was always the cube — the "Cubed" # in the variable name). Cross-referenced 2026-05-21. ExplambdaDeltaCubed = 1.0 / McExp * np.sum(ld ** 3) HessianTerm += (-ExplambdaDelta * (dN[c, k] + 1) + ExplambdaDeltaSq * (dN[c, k] + 3) - 2 * ExplambdaDeltaCubed) * np.outer(Hk_vec, Hk_vec) startInd = nHist * c endInd = nHist * (c + 1) IGammaComp[startInd:endInd, startInd:endInd] = -HessianTerm else: IGammaComp = np.zeros((0, 0)) # Assemble complete information matrix n1 = IAComp.shape[0] if PPEM_Constraints["EstimateA"] else 0 n2 = IQComp.shape[0] n3 = ISComp.shape[0] if PPEM_Constraints["EstimatePx0"] else 0 n4 = Ix0Comp.shape[0] if PPEM_Constraints["Estimatex0"] else 0 n5 = IMuComp.shape[0] n6 = IBetaComp.shape[0] n7 = IGammaComp.shape[0] if has_gamma else 0 nTerms = n1 + n2 + n3 + n4 + n5 + n6 + n7 IComp = np.zeros((nTerms, nTerms)) off = 0 if PPEM_Constraints["EstimateA"] and n1 > 0: IComp[off:off + n1, off:off + n1] = IAComp off = n1 IComp[off:off + n2, off:off + n2] = IQComp off = n1 + n2 if PPEM_Constraints["EstimatePx0"] and n3 > 0: IComp[off:off + n3, off:off + n3] = ISComp off = n1 + n2 + n3 if PPEM_Constraints["Estimatex0"] and n4 > 0: IComp[off:off + n4, off:off + n4] = Ix0Comp off = n1 + n2 + n3 + n4 IComp[off:off + n5, off:off + n5] = IMuComp off = n1 + n2 + n3 + n4 + n5 IComp[off:off + n6, off:off + n6] = IBetaComp off = n1 + n2 + n3 + n4 + n5 + n6 if n7 > 0: IComp[off:off + n7, off:off + n7] = IGammaComp # ---- Missing Information Matrix (Monte Carlo) ---- # Draws through _mc_state_draws (MATLAB mcStateDraws, F9). # (MATLAB evaluates every ld above as exp(terms) or # exp(terms)./(1+exp(terms)) with no clipping; this port clipped terms # to +-30, which changed the information wherever |terms| > 30, e.g. a # separated history coefficient.) Mc = PPEM_Constraints["mcIter"] xKDraw = np.zeros((dx, N, Mc)) for n_idx in range(N): xKDraw[:, n_idx, :] = _mc_state_draws(xKFinal[:, n_idx], WKFinal[:, :, n_idx], Mc, np.random.randn) if PPEM_Constraints["EstimatePx0"] or PPEM_Constraints["Estimatex0"]: x0Draw = _mc_state_draws(x0hat, Px0hat, Mc, np.random.randn) else: x0Draw = np.tile(x0hat[:, None], (1, Mc)) IMc = np.zeros((nTerms, nTerms, Mc)) for c_mc in range(Mc): x_K = xKDraw[:, :, c_mc] x_0 = x0Draw[:, c_mc] Dx = x_K.shape[0] Sxkm1xk = np.zeros((Dx, Dx)) Sxkm1xkm1 = np.zeros((Dx, Dx)) Sxkxk = np.zeros((Dx, Dx)) for k in range(K): if k == 0: Sxkm1xk += np.outer(x_0, x_K[:, k]) Sxkm1xkm1 += np.outer(x_0, x_0) else: Sxkm1xk += np.outer(x_K[:, k - 1], x_K[:, k]) Sxkm1xkm1 += np.outer(x_K[:, k - 1], x_K[:, k - 1]) Sxkxk += np.outer(x_K[:, k], x_K[:, k]) Sxkxk = 0.5 * (Sxkxk + Sxkxk.T) sumXkTerms_mc = Sxkxk - Ahat @ Sxkm1xk - Sxkm1xk.T @ Ahat.T + Ahat @ Sxkm1xkm1 @ Ahat.T Sxkxkm1 = Sxkm1xk.T sumXkTerms_mc = 0.5 * (sumXkTerms_mc + sumXkTerms_mc.T) # Score for A if PPEM_Constraints["EstimateA"]: ScorA = _matlab_mldivide_matrix(Qhat, Sxkxkm1 - Ahat @ Sxkm1xkm1) if PPEM_Constraints["AhatDiag"]: ScoreAMc = np.diag(ScorA) else: ScoreAMc = ScorA.ravel() # row-major: MATLAB reshape(ScorA', n, 1) else: ScoreAMc = np.array([]) # Score for Q if PPEM_Constraints["QhatDiag"]: if PPEM_Constraints["QhatIsotropic"]: ScoreQ = -0.5 * (K * Dx * Qhat[0, 0] ** (-1) - Qhat[0, 0] ** (-2) * np.trace(sumXkTerms_mc)) ScoreQMc = np.atleast_1d(ScoreQ) else: ScoreQ = -0.5 * _matlab_mldivide_matrix(Qhat, K * np.eye(dx) - _matlab_mldivide_matrix(Qhat, sumXkTerms_mc).T) ScoreQMc = np.diag(ScoreQ) else: ScoreQ = -0.5 * _matlab_mldivide_matrix(Qhat, K * np.eye(dx) - _matlab_mldivide_matrix(Qhat, sumXkTerms_mc).T) ScoreQMc = ScoreQ.ravel() # row-major: MATLAB reshape(ScoreQ', n, 1) # Score for Px0 if PPEM_Constraints["Px0Isotropic"]: diff = x_0 - x0hat ScoreSMc = np.atleast_1d(-0.5 * (Dx * Px0hat[0, 0] ** (-1) - Px0hat[0, 0] ** (-2) * np.dot(diff, diff))) else: diff = x_0 - x0hat ScorS = -0.5 * _matlab_mldivide_matrix(Px0hat, np.eye(dx) - _matlab_mldivide_matrix(Px0hat, np.outer(diff, diff)).T) ScoreSMc = np.diag(ScorS) # Score for x0 # MATLAB (PointProcessEM.m:722): Scorx0 = (-Px0hat\(x_0-x0hat)) # + Ahat'/Qhat*(x_K(:,1)-Ahat*x_0); -- Ahat'/Qhat is mrdivide, # not Ahat.T @ inv(Qhat). Scorx0 = -_matlab_mldivide_matrix(Px0hat, x_0 - x0hat) + _matlab_mrdivide(Ahat.T, Qhat) @ (x_K[:, 0] - Ahat @ x_0) Scorex0Mc = Scorx0.ravel() # Cell scores ScoreMuMc = np.zeros(numCells) ScoreBetaMc = np.array([], dtype=float) ScoreGammaMc = np.array([], dtype=float) for nc in range(numCells): Hk_full = HkAll[:, :, nc] if HkAll.ndim == 3 else np.zeros((K, 0)) nHist_c = Hk_full.shape[1] gammaC = np.ravel(gammahat) if gammahat.size == 1 else gammahat[:, nc] gammaC = np.atleast_1d(gammaC) hist_terms = Hk_full @ gammaC if gammaC.size == nHist_c and nHist_c > 0 else np.zeros(K) terms = muhat[nc] + betahat[:, nc] @ x_K + hist_terms if fitType == "poisson": ld = np.exp(terms) ScoreMuMc[nc] = np.sum(dN[nc, :] - ld) ScoreBetaMc = np.concatenate([ScoreBetaMc, np.sum((dN[nc, :] - ld)[None, :] * x_K, axis=1)]) if nHist_c > 0: ScoreGammaMc = np.concatenate([ScoreGammaMc, np.sum((dN[nc, :] - ld)[None, :] * Hk_full.T, axis=1)]) else: # binomial ld = np.exp(terms) / (1.0 + np.exp(terms)) ScoreMuMc[nc] = np.sum(dN[nc, :] - (dN[nc, :] + 1) * ld + ld ** 2) ScoreBetaMc = np.concatenate([ScoreBetaMc, np.sum((dN[nc, :] * (1 - ld) - ld * (1 - ld))[None, :] * x_K, axis=1)]) if nHist_c > 0: ScoreGammaMc = np.concatenate([ScoreGammaMc, np.sum((dN[nc, :] - (dN[nc, :] + 1) * ld + ld ** 2)[None, :] * Hk_full.T, axis=1)]) # Assemble score vector ScoreVec = np.concatenate([ScoreAMc, ScoreQMc]) if PPEM_Constraints["EstimatePx0"]: ScoreVec = np.concatenate([ScoreVec, ScoreSMc]) if PPEM_Constraints["Estimatex0"]: ScoreVec = np.concatenate([ScoreVec, Scorex0Mc]) ScoreVec = np.concatenate([ScoreVec, ScoreMuMc, ScoreBetaMc]) if has_gamma and ScoreGammaMc.size > 0: ScoreVec = np.concatenate([ScoreVec, ScoreGammaMc]) IMc[:, :, c_mc] = np.outer(ScoreVec, ScoreVec) IMissing = np.mean(IMc, axis=2) IObs = IComp - IMissing nonid = None try: invIObs = np.linalg.inv(IObs) except np.linalg.LinAlgError: # Python-only: an exactly singular IObs. MATLAB's eye/IObs warns and # returns Inf / NaN; in R2025b svd and eig of NaN return NaN and # chol keeps failing (p > 0), so nearestSPD's loop never ends and # MATLAB never returns. The pseudo-inverse, with NaN SEs for the # parameters in its null space (a warning names them). labels = _em_se_term_labels([ ("A", n1, Ahat.shape, "square"), ("Q", n2, Qhat.shape, "square"), ("Px0", n3, Px0hat.shape, "square"), ("x0", n4, x0hat.shape, "vector"), ("mu", n5, muhat.shape, "vector"), ("beta", n6, betahat.shape, "cellmajor"), ("gamma", n7, gammahat.shape if gammahat.ndim == 2 else (gammahat.size, 1), "cellmajor")]) invIObs, nonid = _em_singular_information_inverse(IObs, labels, "PP_ComputeParamStandardErrors") invIObs = _em_project_covariance(invIObs, nonid, DecodingAlgorithms._nearestSPD) VarVec = np.diag(invIObs) SEVec = np.sqrt(VarVec) # MATLAB sqrt(VarVec); positive after nearestSPD if nonid is not None: SEVec[nonid] = np.nan # not identifiable: SE (and so the p-value) NaN # Unpack SE vector off = 0 SEAterms = SEVec[off:off + n1]; off += n1 SEQterms = SEVec[off:off + n2]; off += n2 SEPx0terms = SEVec[off:off + n3]; off += n3 SEx0terms = SEVec[off:off + n4]; off += n4 SEMuTerms = SEVec[off:off + n5]; off += n5 SEBetaTerms = SEVec[off:off + n6]; off += n6 SEGammaTerms = SEVec[off:off + n7] SE = {} Pvals = {} # A if PPEM_Constraints["EstimateA"]: if PPEM_Constraints["AhatDiag"]: SEA = np.diag(SEAterms) pA = np.diag(DecodingAlgorithms._ztest_pvalue(np.diag(Ahat), np.diag(SEA))) else: # Row-major, as the parameters are ordered: MATLAB # reshape(SEAterms, ncol, nrow)'. SEA = SEAterms.reshape(Ahat.shape) pA = DecodingAlgorithms._ztest_pvalue(Ahat.ravel(), SEA.ravel()).reshape(Ahat.shape) SE["A"] = SEA Pvals["A"] = pA # Q if PPEM_Constraints["QhatDiag"]: SEQ = np.diag(SEQterms) if PPEM_Constraints["QhatIsotropic"]: pQ = np.diag(DecodingAlgorithms._ztest_pvalue(np.atleast_1d(Qhat[0, 0]), np.atleast_1d(SEQ[0, 0]))) else: pQ = np.diag(DecodingAlgorithms._ztest_pvalue(np.diag(Qhat), np.diag(SEQ))) else: SEQ = SEQterms.reshape(Qhat.shape) # row-major: MATLAB reshape(SEQterms, ncol, nrow)' pQ = DecodingAlgorithms._ztest_pvalue(Qhat.ravel(), SEQ.ravel()).reshape(Qhat.shape) SE["Q"] = SEQ Pvals["Q"] = pQ # Px0 if PPEM_Constraints["EstimatePx0"]: SES = np.diag(SEPx0terms) if PPEM_Constraints["Px0Isotropic"]: pPx0 = np.diag(DecodingAlgorithms._ztest_pvalue(np.atleast_1d(Px0hat[0, 0]), np.atleast_1d(SES[0, 0]))) else: pPx0 = np.diag(DecodingAlgorithms._ztest_pvalue(np.diag(Px0hat), np.diag(SES))) SE["Px0"] = SES Pvals["Px0"] = pPx0 # x0 if PPEM_Constraints["Estimatex0"]: SEx0 = SEx0terms pX0 = DecodingAlgorithms._ztest_pvalue(x0hat, SEx0) SE["x0"] = SEx0 Pvals["x0"] = pX0 # Mu SEMu = SEMuTerms pMu = DecodingAlgorithms._ztest_pvalue(muhat, SEMu) SE["mu"] = SEMu Pvals["mu"] = pMu # Beta. SEBetaTerms is stacked cell by cell (dx entries per cell), so the # row-major reshape to (C, dx) followed by .T puts cell c's block in column # c -- MATLAB's repaired reshape(SEBetaTerms, dx, C). (MATLAB's former # reshape(..., C, dx)' scrambled it; this port never did.) SEBeta = SEBetaTerms.reshape(betahat.shape[1], betahat.shape[0]).T pBeta = DecodingAlgorithms._ztest_pvalue(betahat.ravel(), SEBeta.ravel()).reshape(betahat.shape) SE["beta"] = SEBeta Pvals["beta"] = pBeta # Gamma: same cell-by-cell layout (nW entries per cell) -> # reshape(SEGammaTerms, nW, C). The p-value pairs each gamma(w, c) with # SEGamma(w, c), as MATLAB's ztest over reshape(gammahat) / # reshape(SEGamma) does. (It used to pair gammahat.ravel() -- window-major -- # with the cell-major SEGammaTerms, scrambling Pvals.gamma whenever nW > 1 # and C > 1.) if has_gamma and n7 > 0: if gammahat.ndim == 2: SEGamma = SEGammaTerms.reshape(gammahat.shape[1], gammahat.shape[0]).T pGamma = DecodingAlgorithms._ztest_pvalue(gammahat.ravel(), SEGamma.ravel()).reshape(gammahat.shape) else: SEGamma = SEGammaTerms pGamma = DecodingAlgorithms._ztest_pvalue(gammahat.ravel(), SEGammaTerms) SE["gamma"] = SEGamma Pvals["gamma"] = pGamma return SE, Pvals, nTerms
[docs] @staticmethod @_matlab_float_semantics def PP_EM( dN, Ahat0, Qhat0, mu, beta, fitType="poisson", delta=0.001, gamma=None, windowTimes=None, x0=None, Px0=None, PPEM_Constraints=None, MstepMethod="NewtonRaphson", ): """Full Point-Process state-space EM algorithm. Estimates state-space model parameters (A, Q, mu, beta, gamma) via EM for point-process observations. Unlike PPSS_EM, this operates on raw spike observations with explicit beta/mu/gamma parameters (no basis functions). Matlab: ``PointProcessEM.PP_EM`` (repaired, fix/pp-em). Defaults: ``fitType='poisson'``, ``delta=0.001``, no history (``gamma`` empty or 0 with ``windowTimes`` empty), ``x0 = 0``, ``Px0 = 1e-9 I``, ``PPEM_Constraints = PP_EMCreateConstraints()`` (x0 / Px0 not estimated: that estimator collapses Px0 and stops EM after ~2 iterations) and ``MstepMethod='NewtonRaphson'`` -- the Monte-Carlo Newton-Raphson M-step maximises the expected complete-data log-likelihood over the smoothed state posterior. (The alternative 'GLM' M-step is a plug-in regression on the smoothed means that ignores W_K, inflates beta and drifts; it is still available by passing it explicitly. Any other MstepMethod raises ValueError.) With ``gamma`` given and ``windowTimes`` empty there is one history window per coefficient, ``windowTimes = 0:delta:size(gamma,1)*delta``, and a nonzero shared ``numWindows x 1`` gamma is expanded to every cell. History is built on the ``delta`` grid. EM stops before the M-step if the E-step log-likelihood is not finite and returns the best finite iterate. ``EnableIkeda = 1`` (MATLAB's Ikeda acceleration step) is not ported and raises ``NotImplementedError``; a ``Qhat0`` that is not positive definite raises ``LinAlgError`` (MATLAB's ``chol`` errors). The Monte Carlo of the M-steps and the SE pass draws from NumPy's global stream, so ``np.random.seed(s)`` (or :func:`nstat.extras.matlab_rng.seeded_global_rng`) makes a fit reproducible; MATLAB's randn stream is not reproduced (the end-to-end gold ``em_drivers.mat`` is compared within the measured Monte Carlo spread). ``SE`` / ``Pvals`` are always computed (MATLAB computes them only when more than 10 outputs are requested). The Monte Carlo SE pass dominates the run time at the default ``mcIter = 1000``: measured 4.5 s of 6.2 s for N = 800 bins, C = 4 cells, 3 history windows (0.5 s of 2.1 s at ``mcIter = 100``); pass a smaller ``mcIter`` in ``PPEM_Constraints`` to reduce it. Note (docs only; mirrors nSTAT PR #138, ``fix/kf-em`` @ ``584ec96``): EM stops at the FIRST decrease of the log-likelihood (``dLikelihood < 0``), not at convergence. Under the Newton-Raphson M-step's Monte Carlo expectation, successive iterations' log-likelihoods are themselves noisy, so the iteration at which that first decrease happens -- and hence ``nIter``, and which iterate ``xKFinal``/``Ahat``/.../``IC`` come from -- is random (the same problem, run repeatedly, was observed to stop anywhere from iteration 6 to 11 on one problem). This is a property of the stopping rule, not a bug; a smaller ``mcIter`` increases the noise and the variability. :meth:`KF_EM`'s closed-form E/M steps make its stopping iteration deterministic for a fixed problem, though it still depends on the starting parameters; see its docstring. Parameters ---------- dN : (C, N) binary spike observations Ahat0 : (dx, dx) initial state transition matrix Qhat0 : (dx, dx) initial state noise covariance mu : (C,) initial baseline log-rates beta : (dx, C) initial stimulus coefficients fitType : 'poisson' or 'binomial' delta : float, time bin width gamma : (nW, C) or scalar, initial history coefficients windowTimes : history window boundaries x0 : (dx,) initial state (default zeros) Px0 : (dx, dx) initial state covariance PPEM_Constraints : dict from PP_EMCreateConstraints MstepMethod : 'NewtonRaphson' or 'GLM' (passed to PP_MStep with ``delta``) Returns ------- xKFinal, WKFinal, Ahat, Qhat, muhat, betahat, gammahat, x0hat, Px0hat, IC, SE, Pvals, nIter """ Ahat0 = np.atleast_2d(Ahat0).astype(float) Qhat0 = np.atleast_2d(Qhat0).astype(float) numStates = Ahat0.shape[0] dN = np.atleast_2d(dN).astype(float) if PPEM_Constraints is None: PPEM_Constraints = DecodingAlgorithms.PP_EMCreateConstraints() if PPEM_Constraints.get("EnableIkeda", 0): # MATLAB's PP_EM runs an Ikeda acceleration step after each M-step # (re-simulated spikes, a second E-/M-step, 2*theta - thetaNew) # when EnableIkeda = 1 -- for a model without history only; with # history it errors (IkedaHistNotImplemented). It is not ported: # this port used to ignore the option silently. raise NotImplementedError( "PP_EM: PPEM_Constraints['EnableIkeda'] = 1 (Ikeda acceleration) is not ported; " "pass EnableIkeda=0 (the default)") if Px0 is None: Px0 = 1e-9 * np.eye(numStates) else: Px0 = np.atleast_2d(Px0).astype(float) if x0 is None: x0 = np.zeros(numStates) else: x0 = np.asarray(x0, dtype=float).reshape(-1) K_cells = dN.shape[0] N_time = dN.shape[1] # Default history windows and shared-gamma expansion (MATLAB B9; see # _em_history_windows). deltaW is taken before delta is defaulted, as # in MATLAB (both give 0.001 for an empty delta). if delta is None or delta == 0: # (delta == 0 -> 1 ms is a Python extension) delta = None gamma, windowTimes = _em_history_windows(gamma, windowTimes, delta, K_cells) windowTimes = [] if windowTimes is None else windowTimes if delta is None: delta = 0.001 mu = np.asarray(mu, dtype=float).reshape(-1) beta = np.atleast_2d(beta).astype(float) # History on the delta grid (MATLAB, repaired C6): PP_EM builds each # cell's train as nspikeTrain(t, '', delta), so window [t_i, t_(i+1)] # counts the spikes ceil(t_i*fs)+1 .. ceil(t_(i+1)*fs) bins back, # fs = 1/delta -- exactly _compute_history_terms. The former 1 kHz nspikeTrain gave # a (2N-1)-row history at delta = 2 ms (ValueError in the first E-step). if len(windowTimes) > 0: HkAll = _compute_history_terms(dN, delta, windowTimes) else: HkAll = np.zeros((N_time, 0, K_cells)) gamma = np.zeros(1) gamma[0] = 0.0 # EM setup tolAbs = _EM_TOL_ABS llTol = _EM_LL_TOL maxIter = _EM_MAX_ITER numToKeep = 10 # Circular buffer storage A_buf = [None] * numToKeep Q_buf = [None] * numToKeep x0_buf = [None] * numToKeep Px0_buf = [None] * numToKeep mu_buf = [None] * numToKeep beta_buf = [None] * numToKeep gamma_buf = [None] * numToKeep x_K_buf = [None] * numToKeep W_K_buf = [None] * numToKeep ExpSums_buf = [None] * numToKeep # Scaled system initialization A0 = Ahat0.copy() Q0 = Qhat0.copy() A_buf[0] = A0.copy() Q_buf[0] = Q0.copy() x0_buf[0] = x0.copy() Px0_buf[0] = Px0.copy() mu_buf[0] = mu.copy() beta_buf[0] = beta.copy() gamma_buf[0] = gamma.copy() # Apply scaling. Tq = inv(chol(Q0, 'lower')), so Tq*Q0*Tq' = I (MATLAB # G1, repaired after fix/pp-em 8dbd0e4). It was inv(chol(Q0)) with the # UPPER factor R (R'R = Q0): then Tq*Q0*Tq' != I for a non-diagonal # Q0, the diagonal / isotropic Q constraints acted on a mixed # parameterisation, and with the default QhatDiag = 1 the first # M-step lowered the log-likelihood and EM returned the initial # parameters. Identical for a diagonal Q0. # A Qhat0 that is not positive definite raises (MATLAB's chol errors); # this port silently ran the EM unwhitened (Tq = I). Tq = np.linalg.solve(np.linalg.cholesky(Q_buf[0]), np.eye(numStates)) TqInv = np.linalg.inv(Tq) A_buf[0] = Tq @ A_buf[0] @ TqInv Q_buf[0] = Tq @ Q_buf[0] @ Tq.T x0_buf[0] = Tq @ x0 Px0_buf[0] = Tq @ Px0 @ Tq.T beta_buf[0] = np.linalg.solve(Tq.T, beta_buf[0]) ll_list = [] dLikelihood = [np.inf] stoppingCriteria = False cnt = 0 # FIX (M3, mirrors nSTAT PR #138 @ 3f91924): warn once per PP_EM # call (not once per internal M-step iteration) when MstepMethod is # 'GLM'; PP_MStep's own identical warning is suppressed for the # loop's duration via _warn_glm=False above. if MstepMethod == "GLM": warnings.warn(_glm_plugin_warning_message("PP_EM"), GLMPlugInWarning, stacklevel=2) _logger.info(" Point-Process Observation EM Algorithm ") while not stoppingCriteria and cnt < maxIter: si = cnt % numToKeep si_p1 = (cnt + 1) % numToKeep si_m1 = (cnt - 1) % numToKeep _logger.info("-" * 80) _logger.info("Iteration #%s", cnt + 1) _logger.info("-" * 80) # E-step x_K_cur, W_K_cur, ll, ExpSums = DecodingAlgorithms.PP_EStep( A_buf[si], Q_buf[si], dN, mu_buf[si], beta_buf[si], fitType, gamma_buf[si], HkAll, x0_buf[si], Px0_buf[si] ) x_K_buf[si] = x_K_cur W_K_buf[si] = W_K_cur ExpSums_buf[si] = ExpSums ll_list.append(ll) # MATLAB (repaired, PP_EM bug 8): stop before the M-step when the # E-step log-likelihood is not a finite real number (a degenerate # iterate, e.g. a collapsed Px0hat or a diverged filter). The best # finite iterate is selected below. NaN comparisons are False, so # the likelihood stopping rule alone never caught a NaN. if not np.isfinite(ll): _logger.info( " EM stopped at iteration# %s b/c the E-step log-likelihood was not a finite " "real number (%s)", cnt + 1, ll, ) break # M-step Anew, Qnew, munew, bnew, gnew, x0new, Px0new = DecodingAlgorithms.PP_MStep( dN, x_K_cur, W_K_cur, x0_buf[si], Px0_buf[si], ExpSums, fitType, mu_buf[si], beta_buf[si], gamma_buf[si], windowTimes, HkAll, PPEM_Constraints, MstepMethod, delta, _warn_glm=False, ) A_buf[si_p1] = Anew Q_buf[si_p1] = Qnew mu_buf[si_p1] = munew beta_buf[si_p1] = bnew gamma_buf[si_p1] = gnew x0_buf[si_p1] = x0new Px0_buf[si_p1] = Px0new if not PPEM_Constraints["EstimateA"]: A_buf[si_p1] = A_buf[si] # Convergence check if cnt == 0: dLikelihood.append(np.inf) dMax = np.inf else: dLikelihood.append(ll_list[cnt] - ll_list[cnt - 1]) # MATLAB max(max(abs(sqrt(Qhat{k}) - sqrt(Qhat{k-1})))): the # elementwise sqrt is complex for a negative (off-diagonal) entry. # This port took sqrt(|Q|), which differs when an entry changes sign. dQvals = float(np.max(np.abs(np.sqrt(Q_buf[si].astype(complex)) - np.sqrt(Q_buf[si_m1].astype(complex))))) dAvals = float(np.max(np.abs(A_buf[si] - A_buf[si_m1]))) dMuvals = float(np.max(np.abs(mu_buf[si] - mu_buf[si_m1]))) dBetavals = float(np.max(np.abs(beta_buf[si] - beta_buf[si_m1]))) gam_cur = gamma_buf[si].ravel() if gamma_buf[si] is not None else np.zeros(1) gam_prev = gamma_buf[si_m1].ravel() if gamma_buf[si_m1] is not None else np.zeros(1) dGammavals = float(np.max(np.abs(gam_cur[:min(len(gam_cur), len(gam_prev))] - gam_prev[:min(len(gam_cur), len(gam_prev))]))) if gam_cur.size > 0 else 0.0 dMax = max(dQvals, dAvals, dMuvals, dBetavals, dGammavals) if cnt == 0: _logger.info("Max Parameter Change: N/A") else: _logger.info("Max Parameter Change: %.6f", dMax) cnt += 1 if dMax < tolAbs: stoppingCriteria = True _logger.info(" EM converged at iteration# %s b/c change in params was within criteria", cnt) if abs(dLikelihood[-1]) < llTol or dLikelihood[-1] < 0: stoppingCriteria = True _logger.info(" EM stopped at iteration# %s b/c change in likelihood was negative", cnt) _logger.info("-" * 80) # Select the best FINITE iteration (MATLAB, repaired: non-finite / # complex logll -> NaN, then the first maximum; no finite logll -> # iteration 1). np.argmax alone returned the first NaN, or a +Inf. ll_arr = np.asarray(ll_list, dtype=float) finite = np.isfinite(ll_arr) if np.any(finite): maxLLIndex = int(np.argmax(np.where(finite, ll_arr, -np.inf))) else: maxLLIndex = 0 maxLLIndMod = maxLLIndex % numToKeep nIter = cnt xKFinal = x_K_buf[maxLLIndMod] if x_K_buf[maxLLIndMod] is not None else np.zeros((numStates, N_time)) WKFinal = W_K_buf[maxLLIndMod] if W_K_buf[maxLLIndMod] is not None else np.zeros((numStates, numStates, N_time)) Ahat = A_buf[maxLLIndMod] if A_buf[maxLLIndMod] is not None else A0 Qhat = Q_buf[maxLLIndMod] if Q_buf[maxLLIndMod] is not None else Q0 muhat = mu_buf[maxLLIndMod] if mu_buf[maxLLIndMod] is not None else mu betahat = beta_buf[maxLLIndMod] if beta_buf[maxLLIndMod] is not None else beta gammahat = gamma_buf[maxLLIndMod] if gamma_buf[maxLLIndMod] is not None else gamma x0hat = x0_buf[maxLLIndMod] if x0_buf[maxLLIndMod] is not None else x0 Px0hat = Px0_buf[maxLLIndMod] if Px0_buf[maxLLIndMod] is not None else Px0 ExpSumsFinal = ExpSums_buf[maxLLIndMod] if ExpSums_buf[maxLLIndMod] is not None else {} # Unscale system (the same Tq = inv(chol(Q0, 'lower')) as above) Tq_unscale = np.linalg.solve(np.linalg.cholesky(Q0), np.eye(numStates)) TqInv_unscale = np.linalg.inv(Tq_unscale) Ahat = TqInv_unscale @ Ahat @ Tq_unscale Qhat = TqInv_unscale @ Qhat @ TqInv_unscale.T xKFinal = TqInv_unscale @ xKFinal x0hat = TqInv_unscale @ x0hat Px0hat = TqInv_unscale @ Px0hat @ TqInv_unscale.T if WKFinal.ndim == 3: for kk in range(WKFinal.shape[2]): WKFinal[:, :, kk] = TqInv_unscale @ WKFinal[:, :, kk] @ TqInv_unscale.T betahat = (betahat.T @ Tq_unscale).T # Compute standard errors. MATLAB computes them (when SE/Pvals are # requested) without a guard, so a failure raises; the former blanket # ``except Exception: pass`` silently returned SE = Pvals = {}. SE = {} Pvals = {} if ExpSumsFinal: # MATLAB F8 (fix/pp-em bac99f9): the expectation sums come from the # E-step of the SCALED system (x_s = Tq x) while every estimate # passed here is back on the original scale; the SE routine reads # ES.Sxkm1xkm1 (A information Q^-1 (x) Sxkm1xkm1) with the unscaled # Qhat, so SE.A was off by the Tq factor whenever Q0 != I. Map the # sum it reads back: Sxkm1xkm1 = (Tq\S_s)/Tq'. (A copy: the # information criteria below read the buffer's own sums.) ESforSE = dict(ExpSumsFinal) ESforSE["Sxkm1xkm1"] = TqInv_unscale @ np.asarray(ExpSumsFinal["Sxkm1xkm1"]) @ TqInv_unscale.T SE, Pvals, _ = DecodingAlgorithms.PP_ComputeParamStandardErrors( dN, xKFinal, WKFinal, Ahat, Qhat, x0hat, Px0hat, ESforSE, fitType, muhat, betahat, gammahat, windowTimes, HkAll, PPEM_Constraints ) # Information criteria K_total = xKFinal.shape[1] Dx = Ahat.shape[1] # Count parameters if PPEM_Constraints["EstimateA"] and PPEM_Constraints["AhatDiag"]: n1_ic = Ahat.shape[0] elif PPEM_Constraints["EstimateA"]: n1_ic = Ahat.size else: n1_ic = 0 if PPEM_Constraints["QhatDiag"] and PPEM_Constraints["QhatIsotropic"]: n2_ic = 1 elif PPEM_Constraints["QhatDiag"]: n2_ic = Qhat.shape[0] else: n2_ic = Qhat.size if PPEM_Constraints["EstimatePx0"] and PPEM_Constraints["Px0Isotropic"]: n3_ic = 1 elif PPEM_Constraints["EstimatePx0"]: n3_ic = Px0hat.shape[0] else: n3_ic = 0 n4_ic = x0hat.size if PPEM_Constraints["Estimatex0"] else 0 n5_ic = muhat.size n6_ic = betahat.size gammahat_flat = gammahat.ravel() if gammahat_flat.size == 1 and gammahat_flat[0] == 0: n7_ic = 0 else: n7_ic = gammahat.size nTerms_ic = n1_ic + n2_ic + n3_ic + n4_ic + n5_ic + n6_ic + n7_ic sumXkTerms_ic = ExpSumsFinal.get("sumXkTerms", np.zeros((Dx, Dx))) ll_best = ll_list[maxLLIndex] if ll_list else 0.0 # MATLAB F10 (fix/pp-em 8843a94): ll_best (the best E-step's expected # complete-data log-likelihood) and its sumXkTerms belong to the SCALED # system x_s = Tq x, while Qhat and Px0hat are back on the original # scale, so llobs mixed scales and AIC / BIC depended on the units of # x. Map both to the original scale: S = (Tq\S_s)/Tq', and each of the # K+1 state log-densities (x_0 .. x_K) gains log|det Tq|. llobs is then # the E-step's observation term sumPPll, and IC.llcomp the expected # complete-data log-likelihood PP_EStep returns in the original # coordinates at the returned estimates. sumXkTerms_ic = TqInv_unscale @ np.asarray(sumXkTerms_ic, dtype=float) @ TqInv_unscale.T llcomp = ll_best + (K_total + 1) * np.log(np.abs(np.linalg.det(Tq_unscale))) # MATLAB log(det(Qhat)), log(det(Px0hat)) and 2n(n+1)/(K-n-1): no # floors or guards (this port floored the determinants at the smallest # normal double and the AICc denominator at 1). det_Q = np.linalg.det(Qhat) det_Px0 = np.linalg.det(Px0hat) llobs = (llcomp + Dx * K_total / 2.0 * np.log(2.0 * np.pi) + K_total / 2.0 * np.log(det_Q) + 0.5 * np.trace(np.linalg.solve(Qhat, sumXkTerms_ic)) + Dx / 2.0 * np.log(2.0 * np.pi) + 0.5 * np.log(det_Px0) + 0.5 * Dx) AIC = 2 * nTerms_ic - 2 * llobs AICc = AIC + np.divide(2.0 * nTerms_ic * (nTerms_ic + 1), np.float64(K_total - nTerms_ic - 1)) BIC = -2 * llobs + nTerms_ic * np.log(K_total) IC = { "AIC": AIC, "AICc": AICc, "BIC": BIC, "llobs": llobs, "llcomp": llcomp, } return (xKFinal, WKFinal, Ahat, Qhat, muhat, betahat, gammahat, x0hat, Px0hat, IC, SE, Pvals, nIter)
[docs] @staticmethod @_matlab_float_semantics def PP_EStep(A, Q, dN, mu, beta, fitType, gamma, HkAll, x0, Px0): """E-step for PP EM: forward filter + RTS smoother + cross-covariance. Parameters ---------- A : (dx, dx) state transition matrix Q : (dx, dx) state noise covariance dN : (C, N) binary spike observations mu : (C,) baseline log-rate beta : (dx, C) stimulus coefficients fitType : 'poisson' or 'binomial' gamma : (nW, C) or scalar history coefficients HkAll : (N, nW, C) history design tensor x0 : (dx,) initial state Px0 : (dx, dx) initial state covariance Returns ------- x_K : (dx, N) smoothed states W_K : (dx, dx, N) smoothed covariances logll : float, log-likelihood ExpectationSums : dict of sufficient statistics Notes ----- As in MATLAB the intensity is the unclipped ``exp(terms)`` (binomial ``exp./(1+exp)``) and ``log(det(Q))`` / ``log(det(Px0))`` are unfloored: a non-positive determinant gives a non-finite ``logll``, on which ``PP_EM`` stops before the M-step. """ A = np.atleast_2d(A).astype(float) Q = np.atleast_2d(Q).astype(float) dN = np.atleast_2d(dN).astype(float) mu = np.asarray(mu, dtype=float).reshape(-1) beta = np.atleast_2d(beta).astype(float) gamma = np.asarray(gamma, dtype=float) x0 = np.asarray(x0, dtype=float).reshape(-1) Px0 = np.atleast_2d(Px0).astype(float) fitType = str(fitType).lower() numCells, K = dN.shape Dx = A.shape[1] # MATLAB stores a one-cell N x nW x 1 history as an N x nW matrix. # Restore the cell axis so the log-likelihood below (which indexes # HkAll[k, :, :]) sees the history, as the filter step already does. if numCells == 1 and np.ndim(HkAll) == 2: HkAll = np.asarray(HkAll, dtype=float).reshape(np.shape(HkAll)[0], np.shape(HkAll)[1], 1) # A shared numWindows x 1 gamma column is repeated for every cell, as # _normalize_gamma does for the filter step below (MATLAB's drivers # repmat it before the E-step); without this the log-likelihood below # would read the column as one cell's coefficients. if (gamma.ndim == 2 and gamma.shape[1] == 1 and numCells > 1 and np.ndim(HkAll) == 3 and gamma.shape[0] == np.shape(HkAll)[1]): gamma = np.repeat(gamma, numCells, axis=1) # Forward filter x_p = np.zeros((Dx, K + 1)) x_u = np.zeros((Dx, K)) W_p = np.zeros((Dx, Dx, K + 1)) W_u = np.zeros((Dx, Dx, K)) x_p[:, 0] = A @ x0 W_p[:, :, 0] = A @ Px0 @ A.T + Q # MATLAB passes HkPerm = permute(HkAll,[2 3 1]) (nW x C x N) and the # 1-based k, because its PPDecode_updateLinear reads # HkAll(:,:,time_index) and dN(:,time_index). The Python # PPDecode_updateLinear takes the canonical (N, nW, C) history tensor # (it infers nW from axis 1 and slices time on axis 0) and a 0-based # time index, so the faithful translation is HkAll as-is and k. for k in range(K): x_u[:, k], W_u[:, :, k], _ = DecodingAlgorithms.PPDecode_updateLinear( x_p[:, k], W_p[:, :, k], dN, mu, beta, fitType, gamma, HkAll, k, None ) A_k = A[:, :, min(k, A.shape[2] - 1)] if A.ndim == 3 else A Q_k = Q[:, :, min(k, Q.shape[2] - 1)] if Q.ndim == 3 else Q x_p[:, k + 1], W_p[:, :, k + 1] = DecodingAlgorithms.PPDecode_predict( x_u[:, k], W_u[:, :, k], A_k, Q_k ) # RTS smoother using kalman_smootherFromFiltered # Convert state-major (dx, K+1/K) to time-major (K+1/K, dx) for # the smoother, which uses _state_history_time_major internally. x_p_tm = x_p.T # (K+1, dx) W_p_tm = np.transpose(W_p, (2, 0, 1)) # (K+1, dx, dx) x_u_tm = x_u.T # (K, dx) W_u_tm = np.transpose(W_u, (2, 0, 1)) # (K, dx, dx) x_K_tm, W_K_tm, Lk = DecodingAlgorithms.kalman_smootherFromFiltered( A, x_p_tm, W_p_tm, x_u_tm, W_u_tm ) # Convert back to state-major: x_K (dx, K), W_K (dx, dx, K) x_K = x_K_tm.T if x_K_tm.ndim == 2 else x_K_tm W_K = np.transpose(W_K_tm, (1, 2, 0)) if W_K_tm.ndim == 3 else W_K_tm numStates = x_K.shape[0] # Cross-covariance Wku Wku = np.zeros((numStates, numStates, K, K)) for k in range(K): Wku[:, :, k, k] = W_K[:, :, k] # W_u and W_p remain in state-major (dx, dx, K) format W_u_sm = W_u W_p_sm = W_p Dk = np.zeros((numStates, numStates, K)) for u in range(K - 1, 0, -1): k = u - 1 Dk[:, :, k] = W_u_sm[:, :, k] @ A.T @ np.linalg.inv(W_p_sm[:, :, k + 1]) Wku[:, :, k, u] = Dk[:, :, k] @ Wku[:, :, k + 1, u] Wku[:, :, u, k] = Wku[:, :, k, u].T # Sufficient statistics Sxkm1xk = np.zeros((Dx, Dx)) Sxkm1xkm1 = np.zeros((Dx, Dx)) Sxkxk = np.zeros((Dx, Dx)) for k in range(K): if k == 0: Sxkm1xk += Px0 @ A.T @ np.linalg.inv(W_p_sm[:, :, 0]) @ Wku[:, :, 0, 0] Sxkm1xkm1 += Px0 + np.outer(x0, x0) else: Sxkm1xk += Wku[:, :, k - 1, k] + np.outer(x_K[:, k - 1], x_K[:, k]) Sxkm1xkm1 += Wku[:, :, k - 1, k - 1] + np.outer(x_K[:, k - 1], x_K[:, k - 1]) Sxkxk += Wku[:, :, k, k] + np.outer(x_K[:, k], x_K[:, k]) Sxkxk = 0.5 * (Sxkxk + Sxkxk.T) sumXkTerms = Sxkxk - A @ Sxkm1xk - Sxkm1xk.T @ A.T + A @ Sxkm1xkm1 @ A.T Sxkxkm1 = Sxkm1xk.T # Point process log-likelihood sumPPll = 0.0 if fitType == "poisson": for k in range(K): if HkAll.ndim == 3: # (nW, C). MATLAB (repaired, C3) orients this slice by its # COLUMNS: if(size(Hk,2)~=numCells) Hk=Hk'. The former # rows test also fired for nW == C and transposed a square # slice, pairing gamma(w,c) with H(c,w) in the logll only. Hk = HkAll[k, :, :] if Hk.shape[1] != numCells: Hk = Hk.T else: Hk = np.zeros((0, numCells)) xk = x_K[:, k] gammaC = np.tile(gamma, numCells) if gamma.ndim == 0 or gamma.size == 1 else gamma gammaC = np.atleast_2d(gammaC) if gammaC.shape[0] == 1 and gammaC.shape[1] == 1: gammaC = np.full((max(Hk.shape[0], 1), numCells), float(gamma.ravel()[0]) if gamma.size > 0 else 0.0) if Hk.ndim == 2 and Hk.shape[0] > 0 and gammaC.shape[0] == Hk.shape[0]: hist_diag = np.diag(gammaC.T @ Hk) if Hk.shape[0] > 0 else np.zeros(numCells) else: hist_diag = np.zeros(numCells) terms = mu + beta.T @ xk + hist_diag Wk = W_K[:, :, k] ld = np.exp(terms) # MATLAB: no clip (this port clipped terms to +-30) bt = beta ExplambdaDelta = ld + 0.5 * (ld * np.diag(bt.T @ Wk @ bt)) ExplogLD = terms sumPPll += float(np.sum(dN[:, k] * ExplogLD - ExplambdaDelta)) elif fitType == "binomial": for k in range(K): if HkAll.ndim == 3: Hk = HkAll[k, :, :] # (nW, C); column-based orientation as above if Hk.shape[1] != numCells: Hk = Hk.T else: Hk = np.zeros((0, numCells)) xk = x_K[:, k] gammaC = np.tile(gamma, numCells) if gamma.ndim == 0 or gamma.size == 1 else gamma gammaC = np.atleast_2d(gammaC) if gammaC.shape[0] == 1 and gammaC.shape[1] == 1: gammaC = np.full((max(Hk.shape[0], 1), numCells), float(gamma.ravel()[0]) if gamma.size > 0 else 0.0) if Hk.ndim == 2 and Hk.shape[0] > 0 and gammaC.shape[0] == Hk.shape[0]: hist_diag = np.diag(gammaC.T @ Hk) if Hk.shape[0] > 0 else np.zeros(numCells) else: hist_diag = np.zeros(numCells) terms = mu + beta.T @ xk + hist_diag Wk = W_K[:, :, k] # MATLAB: ld = exp(terms)./(1+exp(terms)), no clip (NaN once exp # overflows, which PP_EM's non-finite log-likelihood stop catches). ld = np.exp(terms) / (1.0 + np.exp(terms)) bt = beta btWbt_diag = np.diag(bt.T @ Wk @ bt) ExplambdaDelta = ld + 0.5 * (ld * (1 - ld) * (1 - 2 * ld)) * btWbt_diag # MATLAB: ExplogLD = log(ld) + ... (no floor; the former # log(max(ld, 1e-30)) floor had no MATLAB counterpart). ExplogLD = np.log(ld) + 0.5 * (-ld * (1 - ld)) * btWbt_diag sumPPll += float(np.sum(dN[:, k] * ExplogLD - ExplambdaDelta)) # MATLAB log(det(Q)), log(det(Px0)): no floor. A non-positive # determinant gives -Inf (MATLAB: -Inf) or NaN (MATLAB: a complex # logll); either stops PP_EM before the M-step. This port floored the # determinants at the smallest normal double, which kept a collapsed # Px0 (EstimatePx0 = 1) iterating where MATLAB stops. logll = ( -Dx * K / 2.0 * np.log(2.0 * np.pi) - K / 2.0 * np.log(np.linalg.det(Q)) - Dx / 2.0 * np.log(2.0 * np.pi) - 0.5 * np.log(np.linalg.det(Px0)) + sumPPll - 0.5 * np.trace(np.linalg.solve(Q, sumXkTerms)) - Dx / 2.0 ) ExpectationSums = { "Sxkm1xkm1": Sxkm1xkm1, "Sxkm1xk": Sxkm1xk, "Sxkxkm1": Sxkxkm1, "Sxkxk": Sxkxk, "sumXkTerms": sumXkTerms, "sumPPll": sumPPll, } return x_K, W_K, logll, ExpectationSums
[docs] @staticmethod @_matlab_float_semantics def PP_MStep( dN, x_K, W_K, x0, Px0, ExpectationSums, fitType, muhat, betahat, gammahat, windowTimes, HkAll, PPEM_Constraints=None, MstepMethod="NewtonRaphson", delta=0.001, *, _warn_glm=True, ): """M-step for PP EM: update all model parameters. Matlab: ``PointProcessEM.PP_MStep`` (repaired, fix/pp-em @ 8dbd0e4). A, Q, x0 and Px0 have closed-form updates; (mu, beta, gamma) are updated by the Monte-Carlo Newton-Raphson step (``MstepMethod = 'NewtonRaphson'``, the default) or by a GLM fit on the smoothed means (``'GLM'``). Any other ``MstepMethod`` raises ``ValueError`` (MATLAB runs Newton-Raphson for it). As in MATLAB (nSTAT PR #135, ``aa88a2b``): the closed-form updates have no ridges or eigenvalue floors (a single-sample ``Px0hat`` can collapse to 0); each Newton loop runs at most 99 steps on the unclipped ``exp(terms)`` (binomial ``exp./(1+exp)``), each step is MATLAB's ``H\\g`` (``g/H`` for one coefficient, MATLAB's LU dividing by the pivots for several: a denormal pivot, as on a separated history window near the ``exp()`` underflow, gives MATLAB's finite step); as in MATLAB, a step with a NaN keeps the previous value and an infinite one is taken (an exactly singular Hessian gives either, e.g. ``-[1 1; 1 1]\\[1; 2] = [-Inf; Inf]``). The ``McExp = 50`` draws come from NumPy's global stream (seed with ``np.random.seed`` or :func:`nstat.extras.matlab_rng.seeded_global_rng`). Parameters ---------- dN : (C, N) spike observations x_K : (dx, N) smoothed states W_K : (dx, dx, N) smoothed covariances x0 : (dx,) current initial state estimate Px0 : (dx, dx) current initial covariance estimate ExpectationSums : dict from E-step fitType : 'poisson' or 'binomial' muhat : (C,) current baseline rates betahat : (dx, C) current stimulus coefficients gammahat : scalar or (nW, C) current history coefficients windowTimes : history window boundaries or None HkAll : (N, nW, C) history tensor PPEM_Constraints : dict from PP_EMCreateConstraints (default ``PP_EMCreateConstraints()``: x0 / Px0 not estimated) MstepMethod : 'NewtonRaphson' (default, as in the repaired MATLAB) or 'GLM'. 'GLM' regresses each cell's spikes on the smoothed means ``x_K`` (a plug-in fit that ignores ``W_K``; see :func:`_em_glm_mstep`), as MATLAB's GLM M-step does. delta : float, default 0.001 Seconds per bin (MATLAB's optional 15th input): the GLM M-step's time base. ``PP_EM`` passes its ``delta``. Returns ------- Ahat, Qhat, muhat_new, betahat_new, gammahat_new, x0hat, Px0hat """ MstepMethod = _check_mstep_method(MstepMethod) if delta is None: delta = 0.001 if PPEM_Constraints is None: PPEM_Constraints = DecodingAlgorithms.PP_EMCreateConstraints() Sxkm1xkm1 = ExpectationSums["Sxkm1xkm1"] Sxkxkm1 = ExpectationSums["Sxkxkm1"] sumXkTerms = ExpectationSums["sumXkTerms"] dx, K = x_K.shape numCells = dN.shape[0] fitType = str(fitType).lower() x0 = np.asarray(x0, dtype=float).reshape(-1) Px0 = np.atleast_2d(Px0).astype(float) muhat = np.asarray(muhat, dtype=float).reshape(-1) betahat = np.atleast_2d(betahat).astype(float) gammahat = np.asarray(gammahat, dtype=float) # --- A update --- # MATLAB: Ahat = (Sxkxkm1.*I)/(Sxkm1xkm1.*I) or Sxkxkm1/Sxkm1xkm1, and # the Q, x0 and Px0 updates below, have no ridges or eigenvalue floors. # This port added 1e-12*I to Sxkm1xkm1 (and to Px0 and Qhat in the x0 # update) and floored the eigenvalues of Qhat and Px0hat at 1e-10; the # Px0 floor is what kept EM iterating after the single-sample Px0 # estimate collapsed (EstimatePx0 = 1), where MATLAB stops on the # non-finite log-likelihood. I_dx = np.eye(dx) if PPEM_Constraints["AhatDiag"]: Ahat = _matlab_mrdivide(Sxkxkm1 * I_dx, Sxkm1xkm1 * I_dx) else: Ahat = _matlab_mrdivide(Sxkxkm1, Sxkm1xkm1) # --- Q update --- if PPEM_Constraints["QhatDiag"]: if PPEM_Constraints["QhatIsotropic"]: Qhat = (1.0 / (dx * K)) * np.trace(sumXkTerms) * I_dx else: Qhat = (1.0 / K) * (sumXkTerms * I_dx) Qhat = 0.5 * (Qhat + Qhat.T) else: Qhat = (1.0 / K) * sumXkTerms Qhat = 0.5 * (Qhat + Qhat.T) # --- x0 update: MATLAB (inv(Px0)+Ahat'/Qhat*Ahat)\(Ahat'/Qhat*x_K(:,1)+Px0\x0) --- if PPEM_Constraints["Estimatex0"]: AtQinv = _matlab_mrdivide(Ahat.T, Qhat) # Ahat'/Qhat x0hat = _matlab_mldivide_matrix( _matlab_inv(Px0) + AtQinv @ Ahat, AtQinv @ x_K[:, 0] + _matlab_mldivide_matrix(Px0, x0), ) else: x0hat = x0.copy() # --- Px0 update: MATLAB's expanded form of (x0hat - x0)(x0hat - x0)' --- if PPEM_Constraints["EstimatePx0"]: xh = x0hat.reshape(dx, 1) xo = x0.reshape(dx, 1) outer = xh @ xh.T - xo @ xh.T - xh @ xo.T + xo @ xo.T if PPEM_Constraints["Px0Isotropic"]: Px0hat = (np.trace(outer) / (dx * K)) * I_dx else: Px0hat = outer * I_dx Px0hat = (Px0hat + Px0hat.T) / 2.0 else: Px0hat = Px0.copy() betahat_new = betahat.copy() gammahat_new = gammahat.copy() if gammahat.ndim > 0 else np.atleast_1d(gammahat).copy() muhat_new = muhat.copy() if MstepMethod == "GLM": # FIX (M3, mirrors nSTAT PR #138 @ 3f91924): warn once per # top-level call (not once per PP_EM iteration). PP_EM passes # _warn_glm=False and issues its own single warning before the # loop instead, so its internal PP_MStep calls do not re-warn # every iteration; a direct PP_MStep(..., 'GLM') call (the # default _warn_glm=True) still warns exactly once. if _warn_glm: warnings.warn(_glm_plugin_warning_message("PP_MStep"), GLMPlugInWarning, stacklevel=2) # MATLAB's GLM M-step (repaired: written to the returned variables, # coefficients mapped by label, keep-previous for unestimable ones, # delta time base, scoped warnings) -- see _em_glm_mstep. muhat_new, betahat_new, gammahat_new = _em_glm_mstep( dN, x_K, fitType, muhat, betahat, gammahat, windowTimes, delta) return Ahat, Qhat, muhat_new, betahat_new, gammahat_new, x0hat, Px0hat # --- Newton-Raphson for beta, mu, gamma --- # Every ld below is MATLAB's exp(terms) or exp(terms)./(1+exp(terms)), # unclipped (this port clipped terms to +-30): a separated history # coefficient keeps walking as in MATLAB, and a binomial overflow gives a # NaN Hessian that keeps the previous value, as MATLAB's isnan check does. McExp = 50 xKDrawExp = np.zeros((dx, K, McExp)) diffTol = 1e-5 # Draws through _mc_state_draws (MATLAB mcStateDraws, F9). for k in range(K): xKDrawExp[:, k, :] = _mc_state_draws(x_K[:, k], W_K[:, :, k], McExp, np.random.randn) # xkPerm: (dx, McExp, K) xkPerm = np.transpose(xKDrawExp, (0, 2, 1)) # --- Beta Newton-Raphson --- for c in range(numCells): converged = False maxIter_nr = 100 # MATLAB: iter = 1; while(~converged && iter < maxIter) -- at most # maxIter - 1 = 99 Newton steps (this ran range(100), one more). for iteration in range(maxIter_nr - 1): HessianTerm = np.zeros((dx, dx)) GradTerm = np.zeros(dx) for k in range(K): Hk_full = HkAll[:, :, c] if HkAll.ndim == 3 else np.zeros((K, 0)) Hk_vec = Hk_full[k, :] if Hk_full.ndim == 2 and Hk_full.shape[0] > k else np.zeros(0) xk = xkPerm[:, :, k] # (dx, McExp) gammaC = np.ravel(gammahat) if gammahat.size == 1 else gammahat[:, c] gammaC = np.atleast_1d(gammaC) Hk_vec = np.atleast_1d(Hk_vec) hist_term = float(gammaC @ Hk_vec) if Hk_vec.size == gammaC.size and gammaC.size > 0 else 0.0 terms = muhat[c] + betahat_new[:, c] @ xk + hist_term if fitType == "poisson": ld = np.exp(terms) ExpLambdaXk = (1.0 / McExp) * np.sum(ld[None, :] * xk, axis=1) ExpLambdaXkXkT = (1.0 / McExp) * (ld[None, :] * xk) @ xk.T GradTerm += dN[c, k] * x_K[:, k] - ExpLambdaXk HessianTerm -= ExpLambdaXkXkT else: # binomial ld = np.exp(terms) / (1.0 + np.exp(terms)) ExplambdaDeltaXkXk = (1.0 / McExp) * (ld[None, :] * xk) @ xk.T ExplambdaDeltaSqXkXkT = (1.0 / McExp) * ((ld ** 2)[None, :] * xk) @ xk.T ExplambdaDeltaCubeXkXkT = (1.0 / McExp) * ((ld ** 3)[None, :] * xk) @ xk.T ExpLambdaXk = (1.0 / McExp) * np.sum(ld[None, :] * xk, axis=1) ExpLambdaSquaredXk = (1.0 / McExp) * np.sum((ld ** 2)[None, :] * xk, axis=1) GradTerm += dN[c, k] * x_K[:, k] - (dN[c, k] + 1) * ExpLambdaXk + ExpLambdaSquaredXk # Jacobian of GradTerm: (-(dN+1)p + (dN+3)p^2 - 2p^3) x x' (the # form the mu and gamma steps below use; MATLAB PP_MStep, # repaired). The former E[p] + E[p^2] - 2E[p^3] is positive # definite, so the Newton step moved downhill and diverged. HessianTerm += (-(dN[c, k] + 1) * ExplambdaDeltaXkXk + (dN[c, k] + 3) * ExplambdaDeltaSqXkXkT - 2 * ExplambdaDeltaCubeXkXkT) if np.any(np.isnan(HessianTerm)) or np.any(np.isinf(HessianTerm)): betahat_newTemp = betahat_new[:, c] else: betahat_newTemp = betahat_new[:, c] - _matlab_mldivide(HessianTerm, GradTerm) if np.any(np.isnan(betahat_newTemp)): betahat_newTemp = betahat_new[:, c] mabsDiff = float(np.max(np.abs(betahat_newTemp - betahat_new[:, c]))) if mabsDiff < diffTol: converged = True betahat_new[:, c] = betahat_newTemp if converged: break # --- Mu Newton-Raphson --- for c in range(numCells): converged = False maxIter_nr = 100 for iteration in range(maxIter_nr - 1): # MATLAB: at most maxIter - 1 steps (see beta) HessianTerm = 0.0 GradTerm = 0.0 for k in range(K): Hk_full = HkAll[:, :, c] if HkAll.ndim == 3 else np.zeros((K, 0)) Hk_vec = Hk_full[k, :] if Hk_full.ndim == 2 and Hk_full.shape[0] > k else np.zeros(0) xk = xkPerm[:, :, k] gammaC = np.ravel(gammahat) if gammahat.size == 1 else gammahat[:, c] gammaC = np.atleast_1d(gammaC) Hk_vec = np.atleast_1d(Hk_vec) hist_term = float(gammaC @ Hk_vec) if Hk_vec.size == gammaC.size and gammaC.size > 0 else 0.0 terms = muhat_new[c] + betahat[:, c] @ xk + hist_term if fitType == "poisson": ld = np.exp(terms) ExpLambdaDelta = (1.0 / McExp) * np.sum(ld) GradTerm += dN[c, k] - ExpLambdaDelta HessianTerm -= ExpLambdaDelta else: # binomial ld = np.exp(terms) / (1.0 + np.exp(terms)) ExpLambdaDelta = (1.0 / McExp) * np.sum(ld) ExpLambdaDeltaSq = (1.0 / McExp) * np.sum(ld ** 2) ExpLambdaDeltaCubed = (1.0 / McExp) * np.sum(ld ** 3) GradTerm += dN[c, k] - (dN[c, k] + 1) * ExpLambdaDelta + ExpLambdaDeltaSq HessianTerm += -(dN[c, k] + 1) * ExpLambdaDelta + (dN[c, k] + 3) * ExpLambdaDeltaSq - 2 * ExpLambdaDeltaCubed if np.isnan(HessianTerm) or np.isinf(HessianTerm): muhat_newTemp = muhat_new[c] else: # MATLAB HessianTerm\GradTerm: a zero Hessian gives +-Inf, which # MATLAB accepts, or NaN (0/0), which keeps the previous value. # This port also kept it for |HessianTerm| < 1e-30. muhat_newTemp = muhat_new[c] - np.divide(GradTerm, HessianTerm) if np.isnan(muhat_newTemp): muhat_newTemp = muhat_new[c] mabsDiff = abs(muhat_newTemp - muhat_new[c]) if mabsDiff < diffTol: converged = True muhat_new[c] = muhat_newTemp if converged: break # --- Gamma Newton-Raphson --- # MATLAB: if(~isempty(windowTimes) && any(any(gammahat_new~=0))) -- an # all-zero gamma means "no history coefficients" and is left at zero (as # PPLFP_MStep does). The former size-based test estimated gamma from an # all-zero init whenever windowTimes was non-empty. gammahat_flat = gammahat_new.ravel() has_gamma = (windowTimes is not None and len(windowTimes) > 0 and bool(np.any(gammahat_flat != 0))) if has_gamma and gammahat_new.ndim >= 1: nGamma = gammahat_new.shape[0] if gammahat_new.ndim == 1 else gammahat_new.shape[0] for c in range(numCells): converged = False maxIter_nr = 100 gammaC = np.ravel(gammahat_new) if gammahat_new.size == 1 else gammahat_new[:, c] if gammahat_new.ndim == 2 else gammahat_new gammaC = np.atleast_1d(gammaC).copy() for iteration in range(maxIter_nr - 1): # MATLAB: at most maxIter - 1 steps (see beta) HessianTerm = np.zeros((nGamma, nGamma)) GradTerm = np.zeros(nGamma) for k in range(K): Hk_full = HkAll[:, :, c] if HkAll.ndim == 3 else np.zeros((K, 0)) Hk_vec = Hk_full[k, :] if Hk_full.ndim == 2 and Hk_full.shape[0] > k else np.zeros(0) Hk_vec = np.atleast_1d(Hk_vec) xk = xkPerm[:, :, k] hist_term = float(gammaC @ Hk_vec) if Hk_vec.size == gammaC.size and gammaC.size > 0 else 0.0 terms = muhat[c] + betahat[:, c] @ xk + hist_term if fitType == "poisson": ld = np.exp(terms) ExpLambdaDelta = (1.0 / McExp) * np.sum(ld) GradTerm += (dN[c, k] - ExpLambdaDelta) * Hk_vec HessianTerm -= ExpLambdaDelta * np.outer(Hk_vec, Hk_vec) else: # binomial ld = np.exp(terms) / (1.0 + np.exp(terms)) ExpLambdaDelta = (1.0 / McExp) * np.sum(ld) ExpLambdaDeltaSq = (1.0 / McExp) * np.sum(ld ** 2) ExpLambdaDeltaCubed = (1.0 / McExp) * np.sum(ld ** 3) GradTerm += (dN[c, k] - (dN[c, k] + 1) * ExpLambdaDelta + ExpLambdaDeltaSq) * Hk_vec HessianTerm += (-(dN[c, k] + 1) * ExpLambdaDelta + (dN[c, k] + 3) * ExpLambdaDeltaSq - 2 * ExpLambdaDeltaCubed) * np.outer(Hk_vec, Hk_vec) if np.any(np.isnan(HessianTerm)) or np.any(np.isinf(HessianTerm)): gammahat_newTemp = gammaC else: gammahat_newTemp = gammaC - _matlab_mldivide(HessianTerm, GradTerm) if np.any(np.isnan(gammahat_newTemp)): gammahat_newTemp = gammaC mabsDiff = float(np.max(np.abs(gammahat_newTemp - gammaC))) if mabsDiff < diffTol: converged = True gammaC = gammahat_newTemp if converged: break if gammahat_new.ndim == 2: gammahat_new[:, c] = gammaC else: gammahat_new = gammaC.reshape(gammahat_new.shape) if gammahat.ndim == 0: # A scalar history coefficient comes back as a scalar (it is worked # on as a 1-element vector above), as PPLFP_MStep returns it. gammahat_new = gammahat_new.reshape(()) return Ahat, Qhat, muhat_new, betahat_new, gammahat_new, x0hat, Px0hat
# mPPCO family -- mixed Point-Process & Continuous Observation # ------------------------------------------------------------------
[docs] @staticmethod def mPPCO_fixedIntervalSmoother(A, Q, C, R, y, alpha, dN, lags, mu, beta, fitType, delta=0.001, gamma=None, windowTimes=None, x0=None, Px0=None, HkAll=None): """[DEPRECATED] Alias of :meth:`PPLFP_fixedIntervalSmoother` (fixed-interval lag smoother). Matlab: ``DecodingAlgorithms.mPPCO_fixedIntervalSmoother`` is a deprecation shim that warns ``nSTAT:deprecated:mPPCO`` and forwards ``varargin{:}`` to ``DecodingAlgorithms.PPLFP_fixedIntervalSmoother`` (the mPPCO family was renamed PPLFP). This mirror emits a :class:`DeprecationWarning` with MATLAB's message and forwards every argument positionally to :meth:`PPLFP_fixedIntervalSmoother`, so it returns exactly what ``PPLFP_fixedIntervalSmoother`` returns: ``(x_pLag, W_pLag, x_uLag, W_uLag)``. Use :meth:`PPLFP_fixedIntervalSmoother` (or ``nstat.decoding.PPLFP.PPLFP_fixedIntervalSmoother``) instead. """ _warn_mppco_deprecated("mPPCO_fixedIntervalSmoother", "PPLFP_fixedIntervalSmoother") return DecodingAlgorithms.PPLFP_fixedIntervalSmoother( A, Q, C, R, y, alpha, dN, lags, mu, beta, fitType, delta, gamma, windowTimes, x0, Px0, HkAll, )
[docs] @staticmethod def mPPCODecodeLinear(A, Q, C, R, y, alpha, dN, mu, beta, fitType='poisson', delta=0.001, gamma=None, windowTimes=None, x0=None, Px0=None, HkAll=None): """[DEPRECATED] Alias of :meth:`PPLFP_DecodeLinear` (the PPLFP forward filter). Matlab: ``DecodingAlgorithms.mPPCODecodeLinear`` is a deprecation shim that warns ``nSTAT:deprecated:mPPCO`` and forwards ``varargin{:}`` to ``DecodingAlgorithms.PPLFP_DecodeLinear`` (the mPPCO family was renamed PPLFP). This mirror emits a :class:`DeprecationWarning` with MATLAB's message and forwards every argument positionally to :meth:`PPLFP_DecodeLinear`, so it returns exactly what ``PPLFP_DecodeLinear`` returns: ``(x_p, W_p, x_u, W_u)``. The legacy Python signature defaults ``fitType`` to ``'poisson'``, which ``PPLFP_DecodeLinear`` (and MATLAB) do not; the signature is frozen, so that default is forwarded as-is. ``delta=0.001`` equals ``PPLFP_DecodeLinear``'s ``None`` default, which it resolves to ``0.001``. Use :meth:`PPLFP_DecodeLinear` (or ``nstat.decoding.PPLFP.PPLFP_DecodeLinear``) instead. """ _warn_mppco_deprecated("mPPCODecodeLinear", "PPLFP_DecodeLinear") return DecodingAlgorithms.PPLFP_DecodeLinear( A, Q, C, R, y, alpha, dN, mu, beta, fitType, delta, gamma, windowTimes, x0, Px0, HkAll, )
[docs] @staticmethod def mPPCODecode_predict(x_u, W_u, A, Q): """[DEPRECATED] Alias of :meth:`PPLFP_Decode_predict` (the PPLFP predict step). Matlab: ``DecodingAlgorithms.mPPCODecode_predict`` is a deprecation shim that warns ``nSTAT:deprecated:mPPCO`` and forwards ``varargin{:}`` to ``DecodingAlgorithms.PPLFP_Decode_predict`` (the mPPCO family was renamed PPLFP). This mirror emits a :class:`DeprecationWarning` with MATLAB's message and forwards every argument positionally to :meth:`PPLFP_Decode_predict`, so it returns exactly what ``PPLFP_Decode_predict`` returns: ``(x_p, W_p)`` with ``x_p = A @ x_u`` (an ``(ns, 1)`` column ``x_u`` now gives an ``(ns, 1)`` ``x_p``, as in MATLAB, where the old body flattened it). Use :meth:`PPLFP_Decode_predict` (or ``nstat.decoding.PPLFP.PPLFP_Decode_predict``) instead. """ _warn_mppco_deprecated("mPPCODecode_predict", "PPLFP_Decode_predict") return DecodingAlgorithms.PPLFP_Decode_predict(x_u, W_u, A, Q)
[docs] @staticmethod def mPPCODecode_update(x_p, W_p, C, R, y, alpha, dN, mu, beta, fitType='poisson', gamma=None, HkAll=None, time_index=0, WuConv=None): """[DEPRECATED] Alias of :meth:`PPLFP_Decode_update` (the PPLFP measurement update). Matlab: ``DecodingAlgorithms.mPPCODecode_update`` (lines 4855-4944) is a deprecation shim that warns ``nSTAT:deprecated:mPPCO`` and forwards ``varargin{:}`` to ``DecodingAlgorithms.PPLFP_Decode_update`` (the mPPCO family was renamed PPLFP). This mirror emits a :class:`DeprecationWarning` with MATLAB's message and forwards to :meth:`PPLFP_Decode_update`, so it returns exactly what ``PPLFP_Decode_update`` returns: ``(x_u, W_u, lambdaDeltaMat)``. Unlike the other ``mPPCO_*`` aliases, this one's *documented* MATLAB contract takes ``HkAll`` permuted as ``(numWindows, numCells, N)`` (time on the 3rd axis) -- the layout this alias's own standalone body used before this fix, and the layout callers of this frozen API signature still pass. ``PPLFP_Decode_update`` takes the canonical ``(N, numWindows, numCells)`` layout, so ``HkAll`` is transposed (``np.transpose(HkAll, (2, 0, 1))``) before forwarding; every other argument forwards unchanged. Use :meth:`PPLFP_Decode_update` (or ``nstat.decoding.PPLFP.PPLFP_Decode_update``, with the canonical ``(N, numWindows, numCells)`` ``HkAll``) instead. """ _warn_mppco_deprecated("mPPCODecode_update", "PPLFP_Decode_update") if HkAll is None or _is_empty_value(HkAll): HkAll_canonical = HkAll else: HkAll_arr = np.asarray(HkAll, dtype=float) if HkAll_arr.ndim == 2: # MATLAB drops the trailing singleton cell axis for a # one-cell history (this alias's permuted (numWindows, # numCells, N) convention collapses to (numWindows, N)); # restore it before transposing, rather than letting # np.transpose(..., (2, 0, 1)) raise on a 2-D input. HkAll_arr = HkAll_arr[:, np.newaxis, :] HkAll_canonical = np.transpose(HkAll_arr, (2, 0, 1)) return DecodingAlgorithms.PPLFP_Decode_update( x_p, W_p, C, R, y, alpha, dN, mu, beta, fitType, gamma, HkAll_canonical, time_index, WuConv, )
[docs] @staticmethod def mPPCO_EMCreateConstraints(EstimateA=1, AhatDiag=0, QhatDiag=1, QhatIsotropic=0, RhatDiag=1, RhatIsotropic=0, Estimatex0=0, EstimatePx0=0, Px0Isotropic=0, mcIter=1000, EnableIkeda=0): """[DEPRECATED] Alias of :meth:`PPLFP_EMCreateConstraints` (EM constraints builder). Matlab: ``DecodingAlgorithms.mPPCO_EMCreateConstraints`` is a deprecation shim that warns ``nSTAT:deprecated:mPPCO`` and forwards ``varargin{:}`` to ``DecodingAlgorithms.PPLFP_EMCreateConstraints`` (the mPPCO family was renamed PPLFP). This mirror emits a :class:`DeprecationWarning` with MATLAB's message and forwards every argument positionally to :meth:`PPLFP_EMCreateConstraints`, so it returns exactly what ``PPLFP_EMCreateConstraints`` returns: the constraints ``dict``. Its defaults are ``PPLFP_EMCreateConstraints``'s (MATLAB forwards ``varargin``, so the alias inherits them): ``Estimatex0=0`` and ``EstimatePx0=0`` since the repaired MATLAB (B8). Use :meth:`PPLFP_EMCreateConstraints` (or ``nstat.decoding.PPLFP.PPLFP_EMCreateConstraints``) instead. """ _warn_mppco_deprecated("mPPCO_EMCreateConstraints", "PPLFP_EMCreateConstraints") return DecodingAlgorithms.PPLFP_EMCreateConstraints( EstimateA, AhatDiag, QhatDiag, QhatIsotropic, RhatDiag, RhatIsotropic, Estimatex0, EstimatePx0, Px0Isotropic, mcIter, EnableIkeda, )
[docs] @staticmethod def mPPCO_ComputeParamStandardErrors(y, dN, xKFinal, WKFinal, Ahat, Qhat, Chat, Rhat, alphahat, x0hat, Px0hat, ExpectationSumsFinal, fitType, muhat, betahat, gammahat, windowTimes, HkAll, mPPCOEM_Constraints=None): """[DEPRECATED] Alias of :meth:`PPLFP_ComputeParamStandardErrors` (parameter standard errors). Matlab: ``DecodingAlgorithms.mPPCO_ComputeParamStandardErrors`` is a deprecation shim that warns ``nSTAT:deprecated:mPPCO`` and forwards ``varargin{:}`` to ``DecodingAlgorithms.PPLFP_ComputeParamStandardErrors`` (the mPPCO family was renamed PPLFP). This mirror emits a :class:`DeprecationWarning` with MATLAB's message and forwards every argument positionally to :meth:`PPLFP_ComputeParamStandardErrors`, so it returns exactly what ``PPLFP_ComputeParamStandardErrors`` returns: ``(SE, Pvals, nTerms)``. ``mPPCOEM_Constraints`` is the legacy name of ``PPLFP_EM_Constraints``; it is passed positionally. Use :meth:`PPLFP_ComputeParamStandardErrors` (or ``nstat.decoding.PPLFP.PPLFP_ComputeParamStandardErrors``) instead. """ _warn_mppco_deprecated("mPPCO_ComputeParamStandardErrors", "PPLFP_ComputeParamStandardErrors") return DecodingAlgorithms.PPLFP_ComputeParamStandardErrors( y, dN, xKFinal, WKFinal, Ahat, Qhat, Chat, Rhat, alphahat, x0hat, Px0hat, ExpectationSumsFinal, fitType, muhat, betahat, gammahat, windowTimes, HkAll, mPPCOEM_Constraints, )
[docs] @staticmethod def mPPCO_EM(y, dN, Ahat0, Qhat0, Chat0, Rhat0, alphahat0, mu, beta, fitType='poisson', delta=0.001, gamma=None, windowTimes=None, x0=None, Px0=None, mPPCOEM_Constraints=None, MstepMethod='NewtonRaphson'): """[DEPRECATED] Alias of :meth:`PPLFP_EM` (the full EM driver). Matlab: ``DecodingAlgorithms.mPPCO_EM`` is a deprecation shim that warns ``nSTAT:deprecated:mPPCO`` and forwards ``varargin{:}`` to ``DecodingAlgorithms.PPLFP_EM`` (the mPPCO family was renamed PPLFP). This mirror emits a :class:`DeprecationWarning` with MATLAB's message and forwards every argument positionally to :meth:`PPLFP_EM`, so it returns exactly what ``PPLFP_EM`` returns: ``(xKFinal, WKFinal, Ahat, Qhat, Chat, Rhat, alphahat, muhat, betahat, gammahat, x0hat, Px0hat, IC, SE, Pvals)``. ``mPPCOEM_Constraints`` is the legacy name of ``PPLFP_EM_Constraints``; it is passed positionally. ``MstepMethod`` defaults to ``PPLFP_EM``'s ``'NewtonRaphson'`` (the repaired MATLAB's, B8, which the MATLAB alias inherits through ``varargin``). Use :meth:`PPLFP_EM` (or ``nstat.decoding.PPLFP.PPLFP_EM``) instead. """ _warn_mppco_deprecated("mPPCO_EM", "PPLFP_EM") return DecodingAlgorithms.PPLFP_EM( y, dN, Ahat0, Qhat0, Chat0, Rhat0, alphahat0, mu, beta, fitType, delta, gamma, windowTimes, x0, Px0, mPPCOEM_Constraints, MstepMethod, )
[docs] @staticmethod def mPPCO_EStep(A, Q, C, R, y, alpha, dN, mu, beta, fitType='poisson', delta=0.001, gamma=None, HkAll=None, x0=None, Px0=None): """[DEPRECATED] Alias of :meth:`PPLFP_EStep` (the EM expectation step). Matlab: ``DecodingAlgorithms.mPPCO_EStep`` is a deprecation shim that warns ``nSTAT:deprecated:mPPCO`` and forwards ``varargin{:}`` to ``DecodingAlgorithms.PPLFP_EStep`` (the mPPCO family was renamed PPLFP). This mirror emits a :class:`DeprecationWarning` with MATLAB's message and forwards every argument positionally to :meth:`PPLFP_EStep`, so it returns exactly what ``PPLFP_EStep`` returns: ``(x_K, W_K, logll, ExpectationSums)``. Use :meth:`PPLFP_EStep` (or ``nstat.decoding.PPLFP.PPLFP_EStep``) instead. """ _warn_mppco_deprecated("mPPCO_EStep", "PPLFP_EStep") return DecodingAlgorithms.PPLFP_EStep( A, Q, C, R, y, alpha, dN, mu, beta, fitType, delta, gamma, HkAll, x0, Px0, )
[docs] @staticmethod def mPPCO_MStep(dN, y, x_K, W_K, x0, Px0, ExpectationSums, fitType='poisson', muhat=None, betahat=None, gammahat=None, windowTimes=None, HkAll=None, mPPCOEM_Constraints=None, MstepMethod='NewtonRaphson', delta=0.001): """[DEPRECATED] Alias of :meth:`PPLFP_MStep` (the EM maximisation step). Matlab: ``DecodingAlgorithms.mPPCO_MStep`` is a deprecation shim that warns ``nSTAT:deprecated:mPPCO`` and forwards ``varargin{:}`` to ``DecodingAlgorithms.PPLFP_MStep`` (the mPPCO family was renamed PPLFP). This mirror emits a :class:`DeprecationWarning` with MATLAB's message and forwards every argument positionally to :meth:`PPLFP_MStep`, so it returns exactly what ``PPLFP_MStep`` returns: ``(Ahat, Qhat, Chat, Rhat, alphahat, muhat_new, betahat_new, gammahat_new, x0hat, Px0hat)``. ``mPPCOEM_Constraints`` is the legacy name of ``PPLFP_EM_Constraints``; it is passed positionally. Unlike the other aliases, the legacy Python signature gives ``fitType`` / ``muhat`` / ``betahat`` / ``gammahat`` / ``windowTimes`` / ``HkAll`` defaults that ``PPLFP_MStep`` (and MATLAB) do not have; the signature is frozen, so those defaults are forwarded as-is. Supply all six: omitting them is unsupported (MATLAB raises, here ``PPLFP_MStep`` receives ``None``). ``MstepMethod`` defaults to ``'NewtonRaphson'``, ``PPLFP_MStep``'s default since the repaired MATLAB (B8), which the MATLAB alias inherits through ``varargin``. The trailing ``delta`` (default 0.001) is ``PPLFP_MStep``'s optional 16th input of the repaired MATLAB (R4c), which the MATLAB alias also forwards through ``varargin``. Use :meth:`PPLFP_MStep` (or ``nstat.decoding.PPLFP.PPLFP_MStep``) instead. """ _warn_mppco_deprecated("mPPCO_MStep", "PPLFP_MStep") return DecodingAlgorithms.PPLFP_MStep( dN, y, x_K, W_K, x0, Px0, ExpectationSums, fitType, muhat, betahat, gammahat, windowTimes, HkAll, mPPCOEM_Constraints, MstepMethod, delta, )
# PPLFP family relocated above (between KF_MStep and PP_EMCreateConstraints) # to match MATLAB DecodingAlgorithms.m method ordering. # Module-level aliases for backward compatibility PP_fixedIntervalSmoother = DecodingAlgorithms.PP_fixedIntervalSmoother PPDecodeFilter = DecodingAlgorithms.PPDecodeFilter PPDecodeFilterLinear = DecodingAlgorithms.PPDecodeFilterLinear PPDecode_predict = DecodingAlgorithms.PPDecode_predict PPDecode_update = DecodingAlgorithms.PPDecode_update PPDecode_updateLinear = DecodingAlgorithms.PPDecode_updateLinear PPHybridFilter = DecodingAlgorithms.PPHybridFilter PPHybridFilterLinear = DecodingAlgorithms.PPHybridFilterLinear PPSS_EM = DecodingAlgorithms.PPSS_EM PPSS_EMFB = DecodingAlgorithms.PPSS_EMFB PPSS_EStep = DecodingAlgorithms.PPSS_EStep PPSS_MStep = DecodingAlgorithms.PPSS_MStep kalman_filter = DecodingAlgorithms.kalman_filter kalman_predict = DecodingAlgorithms.kalman_predict kalman_update = DecodingAlgorithms.kalman_update kalman_fixedIntervalSmoother = DecodingAlgorithms.kalman_fixedIntervalSmoother kalman_smootherFromFiltered = DecodingAlgorithms.kalman_smootherFromFiltered kalman_smoother = DecodingAlgorithms.kalman_smoother ComputeStimulusCIs = DecodingAlgorithms.ComputeStimulusCIs computeSpikeRateCIs = DecodingAlgorithms.computeSpikeRateCIs computeSpikeRateDiffCIs = DecodingAlgorithms.computeSpikeRateDiffCIs ukf = DecodingAlgorithms.ukf ukf_ut = DecodingAlgorithms.ukf_ut ukf_sigmas = DecodingAlgorithms.ukf_sigmas KF_EM = DecodingAlgorithms.KF_EM KF_EMCreateConstraints = DecodingAlgorithms.KF_EMCreateConstraints KF_EStep = DecodingAlgorithms.KF_EStep KF_MStep = DecodingAlgorithms.KF_MStep KF_ComputeParamStandardErrors = DecodingAlgorithms.KF_ComputeParamStandardErrors PP_EM = DecodingAlgorithms.PP_EM PP_EMCreateConstraints = DecodingAlgorithms.PP_EMCreateConstraints PP_ComputeParamStandardErrors = DecodingAlgorithms.PP_ComputeParamStandardErrors PP_EStep = DecodingAlgorithms.PP_EStep PP_MStep = DecodingAlgorithms.PP_MStep mPPCODecode_predict = DecodingAlgorithms.mPPCODecode_predict mPPCODecode_update = DecodingAlgorithms.mPPCODecode_update mPPCODecodeLinear = DecodingAlgorithms.mPPCODecodeLinear mPPCO_fixedIntervalSmoother = DecodingAlgorithms.mPPCO_fixedIntervalSmoother mPPCO_EMCreateConstraints = DecodingAlgorithms.mPPCO_EMCreateConstraints mPPCO_ComputeParamStandardErrors = DecodingAlgorithms.mPPCO_ComputeParamStandardErrors mPPCO_EM = DecodingAlgorithms.mPPCO_EM mPPCO_EStep = DecodingAlgorithms.mPPCO_EStep mPPCO_MStep = DecodingAlgorithms.mPPCO_MStep __all__ = [ "ComputeStimulusCIs", "DecodingAlgorithms", "computeSpikeRateCIs", "computeSpikeRateDiffCIs", "KF_ComputeParamStandardErrors", "KF_EM", "KF_EMCreateConstraints", "KF_EStep", "KF_MStep", "PP_ComputeParamStandardErrors", "PP_EM", "PP_EMCreateConstraints", "PP_EStep", "PP_MStep", "PPDecodeFilter", "PPDecodeFilterLinear", "PPDecode_predict", "PPDecode_update", "PPDecode_updateLinear", "PPHybridFilter", "PPHybridFilterLinear", "PPSS_EM", "PPSS_EMFB", "PPSS_EStep", "PPSS_MStep", "PP_fixedIntervalSmoother", "kalman_filter", "kalman_fixedIntervalSmoother", "kalman_predict", "kalman_smoother", "kalman_smootherFromFiltered", "kalman_update", "mPPCODecode_predict", "mPPCODecode_update", "mPPCODecodeLinear", "mPPCO_ComputeParamStandardErrors", "mPPCO_EM", "mPPCO_EMCreateConstraints", "mPPCO_EStep", "mPPCO_MStep", "mPPCO_fixedIntervalSmoother", "ukf", "ukf_sigmas", "ukf_ut", ]