statespacecheck 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- statespacecheck/__init__.py +53 -0
- statespacecheck/_validation.py +189 -0
- statespacecheck/_version.py +34 -0
- statespacecheck/highest_density.py +138 -0
- statespacecheck/periods.py +567 -0
- statespacecheck/predictive_checks.py +426 -0
- statespacecheck/state_consistency.py +296 -0
- statespacecheck/viz.py +137 -0
- statespacecheck-0.1.0.dist-info/METADATA +373 -0
- statespacecheck-0.1.0.dist-info/RECORD +12 -0
- statespacecheck-0.1.0.dist-info/WHEEL +4 -0
- statespacecheck-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""State space model goodness of fit diagnostics for neuroscience.
|
|
2
|
+
|
|
3
|
+
This package provides tools to assess the consistency between state
|
|
4
|
+
distributions and their component likelihood distributions in Bayesian
|
|
5
|
+
state space models.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from statespacecheck._validation import DistributionArray
|
|
9
|
+
from statespacecheck.highest_density import DEFAULT_COVERAGE, highest_density_region
|
|
10
|
+
from statespacecheck.periods import (
|
|
11
|
+
aggregate_over_period,
|
|
12
|
+
combine_flags,
|
|
13
|
+
find_low_overlap_intervals,
|
|
14
|
+
flag_extreme_kl,
|
|
15
|
+
flag_extreme_pvalues,
|
|
16
|
+
flag_low_overlap,
|
|
17
|
+
)
|
|
18
|
+
from statespacecheck.predictive_checks import (
|
|
19
|
+
log_predictive_density,
|
|
20
|
+
predictive_density,
|
|
21
|
+
predictive_pvalue,
|
|
22
|
+
)
|
|
23
|
+
from statespacecheck.state_consistency import (
|
|
24
|
+
hpd_overlap,
|
|
25
|
+
kl_divergence,
|
|
26
|
+
)
|
|
27
|
+
from statespacecheck.viz import plot_diagnostics
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"highest_density_region",
|
|
31
|
+
"kl_divergence",
|
|
32
|
+
"hpd_overlap",
|
|
33
|
+
"predictive_density",
|
|
34
|
+
"log_predictive_density",
|
|
35
|
+
"aggregate_over_period",
|
|
36
|
+
"predictive_pvalue",
|
|
37
|
+
"DEFAULT_COVERAGE",
|
|
38
|
+
"DistributionArray",
|
|
39
|
+
"find_low_overlap_intervals",
|
|
40
|
+
"flag_extreme_kl",
|
|
41
|
+
"flag_extreme_pvalues",
|
|
42
|
+
"flag_low_overlap",
|
|
43
|
+
"combine_flags",
|
|
44
|
+
"plot_diagnostics",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
from ._version import __version__
|
|
49
|
+
except ImportError:
|
|
50
|
+
# Fallback for development installs
|
|
51
|
+
from importlib.metadata import version
|
|
52
|
+
|
|
53
|
+
__version__ = version("statespacecheck")
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Validation utilities for distributions and parameters."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from numpy.typing import NDArray
|
|
5
|
+
|
|
6
|
+
# Type aliases for distribution arrays
|
|
7
|
+
DistributionArray = NDArray[np.floating]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_coverage(coverage: float) -> None:
|
|
11
|
+
"""Validate that coverage is in the valid range (0, 1).
|
|
12
|
+
|
|
13
|
+
Parameters
|
|
14
|
+
----------
|
|
15
|
+
coverage : float
|
|
16
|
+
Coverage value to validate
|
|
17
|
+
|
|
18
|
+
Raises
|
|
19
|
+
------
|
|
20
|
+
ValueError
|
|
21
|
+
If coverage is not in (0, 1)
|
|
22
|
+
"""
|
|
23
|
+
if not (0.0 < coverage < 1.0):
|
|
24
|
+
raise ValueError(
|
|
25
|
+
f"coverage must be in (0, 1), got {coverage}. "
|
|
26
|
+
f"Coverage represents the probability mass of the highest density region "
|
|
27
|
+
f"and must be a value between 0 and 1 (exclusive). "
|
|
28
|
+
f"For example, use 0.95 for a 95% credible region."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_distribution(
|
|
33
|
+
distribution: DistributionArray,
|
|
34
|
+
name: str = "distribution",
|
|
35
|
+
min_ndim: int = 1,
|
|
36
|
+
allow_nan: bool = True,
|
|
37
|
+
) -> DistributionArray:
|
|
38
|
+
"""Validate and clean distribution array.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
distribution : np.ndarray
|
|
43
|
+
Distribution to validate
|
|
44
|
+
name : str
|
|
45
|
+
Name for error messages
|
|
46
|
+
min_ndim : int
|
|
47
|
+
Minimum number of dimensions required
|
|
48
|
+
allow_nan : bool
|
|
49
|
+
Whether to allow NaN values (converted to 0 if True)
|
|
50
|
+
|
|
51
|
+
Returns
|
|
52
|
+
-------
|
|
53
|
+
clean : np.ndarray
|
|
54
|
+
Original shape array with NaN/inf converted to 0 if allow_nan=True
|
|
55
|
+
|
|
56
|
+
Raises
|
|
57
|
+
------
|
|
58
|
+
ValueError
|
|
59
|
+
If validation fails
|
|
60
|
+
"""
|
|
61
|
+
arr = np.asarray(distribution, dtype=float)
|
|
62
|
+
|
|
63
|
+
if arr.ndim < min_ndim:
|
|
64
|
+
if min_ndim == 1:
|
|
65
|
+
expected_shape = "(n_time,)"
|
|
66
|
+
elif min_ndim == 2:
|
|
67
|
+
expected_shape = "(n_time, n_position)"
|
|
68
|
+
else:
|
|
69
|
+
expected_shape = f"{min_ndim}D"
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"{name} must be at least {min_ndim}D with shape {expected_shape}, "
|
|
72
|
+
f"got shape {arr.shape}. "
|
|
73
|
+
f"State space diagnostics require time-series data where the first "
|
|
74
|
+
f"dimension is time. "
|
|
75
|
+
f"For 1D spatial data use shape (n_time, n_position_bins), "
|
|
76
|
+
f"for 2D spatial data use shape (n_time, n_x_bins, n_y_bins). "
|
|
77
|
+
f"Did you forget to add the time dimension?"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Handle non-finite values
|
|
81
|
+
clean: DistributionArray
|
|
82
|
+
if allow_nan:
|
|
83
|
+
# Use standard NumPy idiom: convert NaN/inf to 0
|
|
84
|
+
clean = np.nan_to_num(arr, nan=0.0, posinf=0.0, neginf=0.0)
|
|
85
|
+
else:
|
|
86
|
+
clean = arr.copy()
|
|
87
|
+
if not np.all(np.isfinite(clean)):
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"{name} contains non-finite values (NaN or inf). "
|
|
90
|
+
f"Probability distributions must have finite values. "
|
|
91
|
+
f"If you have invalid spatial bins (e.g., inaccessible locations), "
|
|
92
|
+
f"consider setting them to 0 instead of NaN, or ensure "
|
|
93
|
+
f"allow_nan=True in the validation."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Check for negative values
|
|
97
|
+
finite_mask = np.isfinite(arr)
|
|
98
|
+
if np.any(clean[finite_mask] < 0):
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"{name} must be non-negative (probability or weight). "
|
|
101
|
+
f"Found negative values in the distribution. "
|
|
102
|
+
f"Probability distributions and weights must be >= 0. "
|
|
103
|
+
f"Check your data for errors or ensure proper normalization."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return clean
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def flatten_time_spatial(arr: DistributionArray) -> DistributionArray:
|
|
110
|
+
"""Flatten array to (n_time, n_spatial) shape.
|
|
111
|
+
|
|
112
|
+
Parameters
|
|
113
|
+
----------
|
|
114
|
+
arr : np.ndarray, shape (n_time, ...)
|
|
115
|
+
Array where ... represents arbitrary spatial dimensions.
|
|
116
|
+
|
|
117
|
+
Returns
|
|
118
|
+
-------
|
|
119
|
+
flat : np.ndarray, shape (n_time, n_spatial)
|
|
120
|
+
Flattened array.
|
|
121
|
+
"""
|
|
122
|
+
n_time = arr.shape[0]
|
|
123
|
+
# Use numpy's automatic dimension calculation with -1
|
|
124
|
+
return arr.reshape(n_time, -1)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def validate_paired_distributions(
|
|
128
|
+
dist1: DistributionArray,
|
|
129
|
+
dist2: DistributionArray,
|
|
130
|
+
name1: str = "state_dist",
|
|
131
|
+
name2: str = "likelihood",
|
|
132
|
+
min_ndim: int = 2,
|
|
133
|
+
) -> tuple[DistributionArray, DistributionArray]:
|
|
134
|
+
"""Validate two distributions have matching shapes.
|
|
135
|
+
|
|
136
|
+
Parameters
|
|
137
|
+
----------
|
|
138
|
+
dist1 : np.ndarray
|
|
139
|
+
First distribution
|
|
140
|
+
dist2 : np.ndarray
|
|
141
|
+
Second distribution
|
|
142
|
+
name1 : str
|
|
143
|
+
Name for first distribution (error messages)
|
|
144
|
+
name2 : str
|
|
145
|
+
Name for second distribution (error messages)
|
|
146
|
+
min_ndim : int
|
|
147
|
+
Minimum number of dimensions required
|
|
148
|
+
|
|
149
|
+
Returns
|
|
150
|
+
-------
|
|
151
|
+
clean1 : np.ndarray
|
|
152
|
+
First distribution, cleaned
|
|
153
|
+
clean2 : np.ndarray
|
|
154
|
+
Second distribution, cleaned
|
|
155
|
+
|
|
156
|
+
Raises
|
|
157
|
+
------
|
|
158
|
+
ValueError
|
|
159
|
+
If shapes don't match or validation fails
|
|
160
|
+
"""
|
|
161
|
+
clean1 = validate_distribution(dist1, name1, min_ndim=min_ndim)
|
|
162
|
+
clean2 = validate_distribution(dist2, name2, min_ndim=min_ndim)
|
|
163
|
+
|
|
164
|
+
if clean1.shape != clean2.shape:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
f"{name1} and {name2} must have same shape, got {clean1.shape} vs {clean2.shape}. "
|
|
167
|
+
f"Both distributions must cover the same time points and spatial bins. "
|
|
168
|
+
f"Common causes: different spatial discretization, mismatched time periods, "
|
|
169
|
+
f"or one distribution missing time/spatial dimensions. "
|
|
170
|
+
f"Ensure both arrays use consistent binning and time indexing."
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return clean1, clean2
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def get_spatial_axes(arr: DistributionArray) -> tuple[int, ...]:
|
|
177
|
+
"""Get tuple of spatial dimension axes (all except time axis 0).
|
|
178
|
+
|
|
179
|
+
Parameters
|
|
180
|
+
----------
|
|
181
|
+
arr : np.ndarray, shape (n_time, ...)
|
|
182
|
+
Array where ... are spatial dimensions.
|
|
183
|
+
|
|
184
|
+
Returns
|
|
185
|
+
-------
|
|
186
|
+
spatial_axes : tuple[int, ...]
|
|
187
|
+
Tuple of axis indices for spatial dimensions.
|
|
188
|
+
"""
|
|
189
|
+
return tuple(range(1, arr.ndim))
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '0.1.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Functions for computing highest density regions."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from numpy.typing import NDArray
|
|
5
|
+
|
|
6
|
+
from ._validation import (
|
|
7
|
+
DistributionArray,
|
|
8
|
+
flatten_time_spatial,
|
|
9
|
+
validate_coverage,
|
|
10
|
+
validate_distribution,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
# Default coverage probability for highest density regions
|
|
14
|
+
DEFAULT_COVERAGE = 0.95
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def highest_density_region(
|
|
18
|
+
distribution: DistributionArray, *, coverage: float = DEFAULT_COVERAGE
|
|
19
|
+
) -> NDArray[np.bool_]:
|
|
20
|
+
"""Compute boolean mask indicating highest density region membership.
|
|
21
|
+
|
|
22
|
+
Vectorized HPD mask for arrays shaped (n_time, *spatial). For each time t,
|
|
23
|
+
includes all bins with value >= threshold_t, where threshold_t is chosen so
|
|
24
|
+
cumulative mass >= coverage * total_t.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
distribution : np.ndarray, shape (n_time, ...)
|
|
29
|
+
Probability distributions over position at each time point where
|
|
30
|
+
... represents arbitrary spatial dimensions.
|
|
31
|
+
coverage : float, optional
|
|
32
|
+
Desired coverage probability for the highest density region. Must be between 0 and 1.
|
|
33
|
+
Default is 0.95 for 95% coverage.
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
isin_hd : np.ndarray, shape (n_time, ...)
|
|
38
|
+
Boolean mask indicating which positions are in the highest density region at each
|
|
39
|
+
time point, matching input shape.
|
|
40
|
+
|
|
41
|
+
Raises
|
|
42
|
+
------
|
|
43
|
+
ValueError
|
|
44
|
+
If coverage is not in the range (0, 1).
|
|
45
|
+
|
|
46
|
+
Examples
|
|
47
|
+
--------
|
|
48
|
+
>>> import numpy as np
|
|
49
|
+
>>> from statespacecheck import highest_density_region
|
|
50
|
+
>>> # Simple 1D example with peaked distribution
|
|
51
|
+
>>> distribution = np.array([[0.1, 0.6, 0.3], [0.2, 0.5, 0.3]])
|
|
52
|
+
>>> region = highest_density_region(distribution, coverage=0.9)
|
|
53
|
+
>>> region.shape
|
|
54
|
+
(2, 3)
|
|
55
|
+
>>> region.dtype
|
|
56
|
+
dtype('bool')
|
|
57
|
+
|
|
58
|
+
See Also
|
|
59
|
+
--------
|
|
60
|
+
hpd_overlap : Compute overlap between HPD regions of two distributions
|
|
61
|
+
kl_divergence : Measure information divergence between distributions
|
|
62
|
+
|
|
63
|
+
Notes
|
|
64
|
+
-----
|
|
65
|
+
- NaNs are ignored (treated as 0 mass).
|
|
66
|
+
- If total mass at time t <= 0 or not finite, returns all-False for that t.
|
|
67
|
+
- Works in unnormalized space to avoid numerical issues.
|
|
68
|
+
- Fully vectorized with no Python loops for efficiency.
|
|
69
|
+
- Uses `>=` threshold: all bins with value equal to cutoff are included.
|
|
70
|
+
- Due to ties, actual coverage may slightly exceed requested coverage.
|
|
71
|
+
- This ensures consistent behavior across equivalent distributions.
|
|
72
|
+
|
|
73
|
+
References
|
|
74
|
+
----------
|
|
75
|
+
.. [1] https://stats.stackexchange.com/questions/240749/how-to-find-95-credible-interval
|
|
76
|
+
|
|
77
|
+
"""
|
|
78
|
+
validate_coverage(coverage)
|
|
79
|
+
|
|
80
|
+
# Use centralized validation: handles NaN/inf → 0, checks non-negativity, validates dimensions
|
|
81
|
+
clean = validate_distribution(
|
|
82
|
+
distribution,
|
|
83
|
+
name="distribution",
|
|
84
|
+
min_ndim=2, # Require at least (n_time, n_spatial)
|
|
85
|
+
allow_nan=True,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Flatten to (n_time, n_spatial) for vectorized operations
|
|
89
|
+
flat = flatten_time_spatial(clean)
|
|
90
|
+
|
|
91
|
+
n_time = clean.shape[0]
|
|
92
|
+
n_spatial = flat.shape[1]
|
|
93
|
+
|
|
94
|
+
# Compute total mass and target mass for each time point
|
|
95
|
+
# Shape: (n_time,)
|
|
96
|
+
totals = flat.sum(axis=1)
|
|
97
|
+
target = coverage * totals
|
|
98
|
+
|
|
99
|
+
# Identify rows with no mass -> empty HPD (all False)
|
|
100
|
+
empty = ~np.isfinite(totals) | (totals <= 0)
|
|
101
|
+
|
|
102
|
+
# Sort each row descending (vectorized)
|
|
103
|
+
# Shape: (n_time, n_spatial)
|
|
104
|
+
flat_sorted = np.sort(flat, axis=1)[:, ::-1]
|
|
105
|
+
|
|
106
|
+
# Row-wise cumulative sums
|
|
107
|
+
# Shape: (n_time, n_spatial)
|
|
108
|
+
csum = np.cumsum(flat_sorted, axis=1)
|
|
109
|
+
|
|
110
|
+
# Find the first index where cumulative >= target (per row)
|
|
111
|
+
# Shape: (n_time, n_spatial) boolean
|
|
112
|
+
ge = csum >= target[:, None]
|
|
113
|
+
|
|
114
|
+
# Check if each row has at least one True value
|
|
115
|
+
# Shape: (n_time,)
|
|
116
|
+
has_true = ge.any(axis=1)
|
|
117
|
+
|
|
118
|
+
# argmax gives first True index; if none True, returns 0 (we fix below)
|
|
119
|
+
# Shape: (n_time,)
|
|
120
|
+
idx = ge.argmax(axis=1)
|
|
121
|
+
|
|
122
|
+
# If a row never reaches target but has positive mass (rare numeric case),
|
|
123
|
+
# choose the last index. If it's truly empty, handle later.
|
|
124
|
+
idx = np.where(has_true, idx, n_spatial - 1)
|
|
125
|
+
|
|
126
|
+
# Per-row cutoff (unnormalized)
|
|
127
|
+
# Shape: (n_time,)
|
|
128
|
+
cutoff = np.take_along_axis(flat_sorted, idx[:, None], axis=1).squeeze(1)
|
|
129
|
+
|
|
130
|
+
# Empty rows -> set cutoff to +inf so mask is all False
|
|
131
|
+
cutoff = np.where(empty, np.inf, cutoff)
|
|
132
|
+
|
|
133
|
+
# Broadcast cutoff back to spatial shape and build mask
|
|
134
|
+
# Use the **clean** array for the comparison to keep behavior consistent
|
|
135
|
+
# Broadcasting: reshape cutoff from (n_time,) to (n_time, 1, 1, ...) to match spatial dims
|
|
136
|
+
# Using tuple unpacking for clarity
|
|
137
|
+
broadcast_shape = (n_time,) + (1,) * (clean.ndim - 1)
|
|
138
|
+
return clean >= cutoff.reshape(broadcast_shape)
|