statespacecheck
Local goodness-of-fit diagnostics for state space models: find the observations, down to individual spikes, where a decoder disagrees with the data.
A state space model decodes a latent state (for example an animal's position) by
combining a prediction from the past with the evidence in each new observation.
statespacecheck asks, for every observation, whether the two agree: does the
observation fall where the model's one-step prediction expected it? Global scores
such as the likelihood of a whole session cannot say when a model fails; these
diagnostics can, so a misfit can be traced to a period, a behavior, or a part of the
model.
The package implements the methods of the paper Local goodness-of-fit measures for neural decoding (Zeng, Comrie, Frank, Eden and Denovellis). Its analysis code and an interactive website are at statespacecheck-paper and https://edeno.github.io/statespacecheck-paper/.
Installation
pip install statespacecheck
Quick start
Given a decoder's one-step predictive distribution, each unit's firing rate at each position, and which unit fired in which time bin, compute three diagnostics for every spike and flag the poorly fit ones. Here a simulated decoder predicts the animal's position correctly in the first half of the recording and the mirror-image position in the second half:
import numpy as np
import statespacecheck as ssc
rng = np.random.default_rng(0)
position = np.linspace(0, 100, 51) # position bins (cm)
n_time, n_units, dt = 2000, 20, 0.02 # time bins, units, bin width (s)
# Place fields: firing rate (Hz) of each unit at each position, (n_bins, n_units)
centers = np.linspace(0, 100, n_units)
place_fields = 0.1 + 20 * np.exp(-0.5 * ((position[:, None] - centers) / 8) ** 2)
# The animal runs back and forth; spikes follow the place fields
true_position = 50 + 45 * np.sin(np.arange(n_time) * dt * 0.6)
true_bin = np.abs(position[:, None] - true_position).argmin(axis=0)
spike_counts = rng.poisson(place_fields[true_bin] * dt) # (n_time, n_units)
time_bin, unit = np.nonzero(spike_counts)
n_spikes = spike_counts[time_bin, unit]
time_ind, unit = np.repeat(time_bin, n_spikes), np.repeat(unit, n_spikes) # one per spike
# A decoder's one-step predictive distribution, (n_time, n_bins): it tracks the
# animal in the first half and predicts the mirror-image position in the second
predicted = np.where(np.arange(n_time) < n_time // 2, true_position, 100 - true_position)
predictive = np.exp(-0.5 * ((position - predicted[:, None]) / 5) ** 2)
predictive /= predictive.sum(axis=1, keepdims=True)
# Diagnostics for every spike; thresholds from a baseline period; flags
diagnostics = ssc.event_diagnostics(predictive, place_fields, time_ind, unit)
baseline = time_ind < n_time // 4
hpd_threshold = ssc.baseline_threshold(diagnostics.hpd_overlap[baseline], 0.01)
flags = ssc.flag_events(diagnostics, hpd_overlap_threshold=hpd_threshold)
print(
f"HPD overlap flagged at or below {hpd_threshold:.2f} (the baseline's 1st "
f"percentile): {flags.hpd_overlap[baseline].mean():.1%} of baseline spikes"
)
misfit = time_ind >= n_time // 2
for name, flagged in [
("HPD overlap", flags.hpd_overlap),
("p-value", flags.predictive_pvalue),
]:
print(
f"{name:12s} flagged: {flagged[~misfit].mean():.0%} of spikes before, "
f"{flagged[misfit].mean():.0%} during the misfit"
)
HPD overlap flagged at or below 0.00 (the baseline's 1st percentile): 2.4% of baseline spikes
HPD overlap flagged: 2% of spikes before, 81% during the misfit
p-value flagged: 2% of spikes before, 85% during the misfit
More than 1% of baseline spikes have no overlap at all, so the 1st percentile is 0 and the inclusive rule flags all of them: 2.4% of the baseline, not 1%. That fraction describes the baseline; it is not a false-alarm rate to expect elsewhere.
The per-event tutorial walks through this workflow with a real filter, and the decoder guide shows how to get these inputs from your own decoder.
What your decoder provides
| Argument | Shape | What it is |
|---|---|---|
predictive |
(n_time, n_bins) or (n_time, n_x, n_y) |
The one-step predictive distribution p(x_t | y_1:t-1) in each time bin |
mark_intensities |
(n_bins, n_units) or (n_x, n_y, n_units) |
Each unit's firing rate at each position (its place field) |
event_time_ind |
(n_spikes,) |
The time bin of each spike (an integer index) |
event_marks |
(n_spikes,) |
The unit of each spike (an integer index) |
Every array is on the same grid of states, with bins of equal size: the diagnostics compare distributions over a discretized state space. A grid decoder already works this way. For a model whose distributions are continuous, such as a Kalman filter, evaluate the prediction and each observation's likelihood on a grid first; the Kalman filter tutorial shows how, and how to check that the grid is fine enough. The same diagnostics apply to any state space model, not only neural decoders.
Common pitfalls:
- Place fields stored one unit per row,
(n_units, n_bins): passplace_fields.T. - Positions outside the track marked NaN: the per-event functions need finite values, so keep only the valid bins in both arrays.
- Switching models (for example continuous and fragmented dynamics): sum the predictive distribution over the discrete states first, so every model is compared on the same position grid.
- Spike times:
event_time_indholds bin indices; convert times withnp.digitize(spike_times, time_bin_edges) - 1.
Reading the results
| Diagnostic | Measures | Poor fit when | The paper's rule |
|---|---|---|---|
| HPD overlap | Overlap of the 95% highest-density regions of the prediction and the spike's likelihood | Low | At or below the 1st percentile of a baseline period (baseline_threshold) in the simulation; at or below 0.05 in the real data, which had no baseline period |
| Predictive p-value | How unexpected the unit that fired is, given the prediction | Low | At or below 0.05 |
| KL divergence | How different the two distributions are | High | At or above the 99th percentile of a baseline period in the simulation; no cutoff in the real data. A reference: it is also large when a broad prediction is consistent with a precise spike |
The diagnostics measure consistency, not similarity: a spike is consistent with the prediction when it falls where the prediction put probability, even if the prediction is much broader. Passing a diagnostic (a high HPD overlap, a large p-value) means the spike is consistent with the prediction in that respect, not that the model is right: a prediction too broad to be useful passes both, a rare unit can get small p-values under a correct model, and counts, timing and the proportions of units across spikes are not checked. See Interpreting the diagnostics.
The paper's quantities in the package
| Paper | Function |
|---|---|
| Normalized single-event likelihood | event_likelihood |
| HPD overlap (Szymkiewicz–Simpson overlap of 95% HPD regions) | hpd_overlap, highest_density_region |
| KL divergence D(predictive ‖ likelihood) | kl_divergence |
| Predictive distribution over units | predictive_mark_probabilities |
| Event-weighted predictive distribution | event_weighted_predictive |
| Rank-based predictive p-value (exact sum over units) | mark_predictive_pvalue |
| Rank-based predictive p-value by Monte Carlo (continuous marks) | monte_carlo_mark_pvalue |
| All three diagnostics for every spike | event_diagnostics |
| All three diagnostics for every spike, continuous marks (clusterless) | clusterless_event_diagnostics |
| Thresholds from a baseline period; flagging | baseline_threshold, flag_events |
The package also has tools the paper does not use: time-bin versions of the diagnostics
for a whole-bin likelihood, run-based flagging of time series (statespacecheck.periods),
a generic Monte Carlo predictive check (predictive_pvalue), and plot_diagnostics. The
API reference marks which is which.
Continuous marks
With clusterless decoding, each spike's mark is a vector of waveform features rather
than a unit, so the predictive p-value cannot be summed over marks.
monte_carlo_mark_pvalue estimates it by simulation, as the paper describes: it draws a
state from the event-weighted predictive distribution, draws a mark for a spike at that
state, and ranks the observed mark's predictive density among the replicates'. It needs
the observation model as a MarkModel: the log of its joint mark intensity (in log
space, because densities of marks with many features underflow), a sampler of marks,
and the total event rate at each state. The paper's Figure 2 example, with a 1-D mark:
import numpy as np
from scipy.stats import norm
import statespacecheck as ssc
position = np.linspace(0, 100, 200) # state grid (cm)
predictive = norm.pdf(position, 35, 8)[np.newaxis] # one spike's prediction, (1, n_bins)
sigma = 12.0 # spread of a spike's 1-D mark around the position
def log_mark_intensity(marks): # log lambda(x, y) of marks (n, 1) at every position
return norm.logpdf(marks, position, sigma) # (n, n_bins)
def sample_marks(bins, rng): # one mark for a spike at each position bin
return rng.normal(position[bins], sigma)[:, np.newaxis]
model = ssc.MarkModel(
log_intensity=log_mark_intensity,
sample=sample_marks,
ground_intensity=np.ones_like(position), # the same total rate everywhere
)
check = ssc.monte_carlo_mark_pvalue(
predictive,
model,
np.array([[60.0]]), # the observed mark
n_samples=5000,
rng=0,
)
print(check.pvalue.round(2))
# All three diagnostics for every spike of a recording
spikes = ssc.clusterless_event_diagnostics(
norm.pdf(position, [[35.0], [60.0]], 8), # predictive of 2 time bins, (n_time, n_bins)
model,
event_time_ind=np.array([0, 0, 1]), # each spike's time bin
event_marks=np.array([[60.0], [36.0], [58.0]]), # each spike's mark
rng=0,
)
print(spikes.predictive_pvalue.round(2), spikes.hpd_overlap.round(2))
[0.08]
[0.08 0.95 0.87] [0.46 1. 1. ]
The observed mark lies in the tail of the predictive mark distribution: the Monte Carlo
p-value is about 0.08 (numerical integration gives 0.083), above the paper's 0.05 cutoff.
clusterless_event_diagnostics computes HPD overlap, KL divergence and this p-value for
every spike, as event_diagnostics does for sorted units. The first spike repeats the
example above (mark 60 under a prediction centred at 35): its single-event likelihood
has the lowest HPD overlap with the prediction (0.46), and its p-value is again about
0.08. Results are reproducible for a fixed seed and batch_size. The
clusterless tutorial
decodes a simulated clusterless recording and diagnoses a misspecified mark model.
Documentation
https://edeno.github.io/statespacecheck: tutorials, interpreting the diagnostics, using the package with your decoder, and the API reference.
Citation
If you use this package in your research, please cite it; CITATION.cff in
the repository records the version and release date:
@software{statespacecheck,
title={statespacecheck: Goodness-of-fit diagnostics for state space models},
author={Denovellis, Eric and Zeng, Sirui and Eden, Uri T.},
url={https://github.com/edeno/statespacecheck},
doi={10.5281/zenodo.22999988}
}
Releases are archived on Zenodo. The DOI above, 10.5281/zenodo.22999988, covers all versions and resolves to the latest; to cite a specific version, use its DOI from the Zenodo record (version 0.3.0 is 10.5281/zenodo.22999989).
Please also cite the companion paper for the methods: Local goodness-of-fit measures for neural decoding (Zeng, Comrie, Frank, Eden and Denovellis); its analysis code is at https://github.com/edeno/statespacecheck-paper.
Contributing and license
Contributions are welcome; see CONTRIBUTING.md. MIT License.
Metadata
Release files for statespacecheck 0.3.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| statespacecheck-0.3.1.tar.gz | 3.3 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| statespacecheck-0.3.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 3.3 MB
Release files / statespacecheck-0.3.1.tar.gz
| Download URL | statespacecheck-0.3.1.tar.gz |
|---|---|
| Size | 3.3 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5cd901563ac936855accf10ac85100b0827a8b99355807114c1608fe69450d12
|
|
BLAKE2b-256 checksum How to use checksums |
d420c79cc27fcacfd3f04683a98592731eac65bdf2d1e5c9f30313be484d412e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency logRelease files / statespacecheck-0.3.1-py3-none-any.whl
| Download URL | statespacecheck-0.3.1-py3-none-any.whl |
|---|---|
| Size | 60.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
def4e9e3b4d74212089bf2255b27951a3aa99ee6b0c500d23bcfb92104a35b1c
|
|
BLAKE2b-256 checksum How to use checksums |
9170fc651e95a4d47d42ef9c81bf11df39decd63fbbe9cfc5ced742f01f7a91f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.
Transparency log