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.
@@ -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)