"""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",
]