dfc-kit 1.0.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.
- dfc_kit-1.0.0.dist-info/METADATA +184 -0
- dfc_kit-1.0.0.dist-info/RECORD +71 -0
- dfc_kit-1.0.0.dist-info/WHEEL +5 -0
- dfc_kit-1.0.0.dist-info/entry_points.txt +2 -0
- dfc_kit-1.0.0.dist-info/licenses/LICENSE +29 -0
- dfc_kit-1.0.0.dist-info/top_level.txt +1 -0
- dfckit/__init__.py +11 -0
- dfckit/_arrays.py +13 -0
- dfckit/_preprocessing.py +46 -0
- dfckit/_validation.py +87 -0
- dfckit/artifacts/__init__.py +36 -0
- dfckit/artifacts/_fields.py +67 -0
- dfckit/artifacts/_json.py +92 -0
- dfckit/artifacts/_numpy.py +55 -0
- dfckit/artifacts/models.py +624 -0
- dfckit/artifacts/state_alignment.py +106 -0
- dfckit/artifacts/state_results.py +353 -0
- dfckit/artifacts/state_scoring.py +509 -0
- dfckit/artifacts/state_stability.py +207 -0
- dfckit/cli.py +105 -0
- dfckit/commands/__init__.py +1 -0
- dfckit/commands/parser.py +354 -0
- dfckit/commands/reporting.py +146 -0
- dfckit/commands/source.py +245 -0
- dfckit/commands/stability.py +288 -0
- dfckit/commands/states.py +266 -0
- dfckit/connectivity/__init__.py +68 -0
- dfckit/connectivity/_edge_products.py +130 -0
- dfckit/connectivity/correlation.py +61 -0
- dfckit/connectivity/instantaneous.py +253 -0
- dfckit/connectivity/leida.py +204 -0
- dfckit/connectivity/lowrank.py +384 -0
- dfckit/connectivity/partition.py +291 -0
- dfckit/connectivity/windows.py +83 -0
- dfckit/data.py +229 -0
- dfckit/inference/__init__.py +55 -0
- dfckit/inference/endpoints.py +194 -0
- dfckit/inference/hc3.py +207 -0
- dfckit/inference/matching.py +300 -0
- dfckit/inference/multiple_testing.py +52 -0
- dfckit/inference/nbs.py +586 -0
- dfckit/inference/paired.py +157 -0
- dfckit/inference/state_metrics.py +277 -0
- dfckit/information/__init__.py +45 -0
- dfckit/information/_artifact.py +440 -0
- dfckit/information/estimators.py +536 -0
- dfckit/information/fixed.py +660 -0
- dfckit/information/summary.py +89 -0
- dfckit/io/__init__.py +23 -0
- dfckit/io/xcpd.py +470 -0
- dfckit/reference.py +350 -0
- dfckit/segments.py +44 -0
- dfckit/states/__init__.py +97 -0
- dfckit/states/alignment.py +364 -0
- dfckit/states/cap.py +62 -0
- dfckit/states/cross_validation.py +114 -0
- dfckit/states/data.py +387 -0
- dfckit/states/hmm.py +387 -0
- dfckit/states/interpretation.py +273 -0
- dfckit/states/kmeans.py +340 -0
- dfckit/states/metrics.py +94 -0
- dfckit/states/scoring.py +114 -0
- dfckit/states/selection.py +516 -0
- dfckit/states/stability.py +183 -0
- dfckit/states/streaming.py +859 -0
- dfckit/states/streaming_hmm.py +436 -0
- dfckit/storage/__init__.py +29 -0
- dfckit/storage/_statistics.py +80 -0
- dfckit/storage/builders.py +330 -0
- dfckit/storage/store.py +672 -0
- dfckit/storage/summary.py +140 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Portable persistence for state-number alignment mappings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from ..states.alignment import StateAlignment
|
|
10
|
+
from ._json import load_json_object
|
|
11
|
+
from ._numpy import write_numpy_artifact
|
|
12
|
+
|
|
13
|
+
FORMAT_NAME = "dfckit-state-alignment"
|
|
14
|
+
FORMAT_VERSION = 2
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def save_state_alignment(alignment: StateAlignment, path: str | Path) -> Path:
|
|
18
|
+
"""Write one alignment as strict JSON metadata and numeric arrays."""
|
|
19
|
+
if not isinstance(alignment, StateAlignment):
|
|
20
|
+
raise TypeError("alignment must be a StateAlignment object")
|
|
21
|
+
arrays = {
|
|
22
|
+
"candidate_to_reference": np.asarray(alignment.candidate_to_reference),
|
|
23
|
+
"matched_costs": np.asarray(alignment.matched_costs),
|
|
24
|
+
"cost_matrix": np.asarray(alignment.cost_matrix),
|
|
25
|
+
}
|
|
26
|
+
manifest = {
|
|
27
|
+
"format": FORMAT_NAME,
|
|
28
|
+
"format_version": FORMAT_VERSION,
|
|
29
|
+
"array_names": sorted(arrays),
|
|
30
|
+
"reference_seed": alignment.reference_seed,
|
|
31
|
+
"candidate_seed": alignment.candidate_seed,
|
|
32
|
+
"feature_keys": [list(key) for key in alignment.feature_keys],
|
|
33
|
+
"source_contract": alignment.source_contract,
|
|
34
|
+
"sample_interval_seconds": alignment.sample_interval_seconds,
|
|
35
|
+
"metric": alignment.metric,
|
|
36
|
+
}
|
|
37
|
+
return write_numpy_artifact(path, manifest, arrays, label="state-alignment")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_state_alignment(path: str | Path) -> StateAlignment:
|
|
41
|
+
"""Load and validate an alignment written by :func:`save_state_alignment`."""
|
|
42
|
+
root = Path(path)
|
|
43
|
+
if not root.is_dir():
|
|
44
|
+
raise FileNotFoundError(f"state-alignment artifact directory does not exist: {root}")
|
|
45
|
+
manifest_path = root / "manifest.json"
|
|
46
|
+
arrays_path = root / "arrays.npz"
|
|
47
|
+
if not manifest_path.is_file() or not arrays_path.is_file():
|
|
48
|
+
raise FileNotFoundError("state-alignment artifact requires manifest.json and arrays.npz")
|
|
49
|
+
manifest = load_json_object(manifest_path, context="state-alignment manifest")
|
|
50
|
+
expected_fields = {
|
|
51
|
+
"array_names",
|
|
52
|
+
"candidate_seed",
|
|
53
|
+
"feature_keys",
|
|
54
|
+
"format",
|
|
55
|
+
"format_version",
|
|
56
|
+
"metric",
|
|
57
|
+
"reference_seed",
|
|
58
|
+
"sample_interval_seconds",
|
|
59
|
+
"source_contract",
|
|
60
|
+
}
|
|
61
|
+
if not isinstance(manifest, dict) or set(manifest) != expected_fields:
|
|
62
|
+
raise ValueError("state-alignment manifest fields do not match the schema")
|
|
63
|
+
if manifest["format"] != FORMAT_NAME or manifest["format_version"] != FORMAT_VERSION:
|
|
64
|
+
raise ValueError("unsupported state-alignment artifact format or version")
|
|
65
|
+
expected_arrays = {
|
|
66
|
+
"candidate_to_reference",
|
|
67
|
+
"matched_costs",
|
|
68
|
+
"cost_matrix",
|
|
69
|
+
}
|
|
70
|
+
names = manifest["array_names"]
|
|
71
|
+
if not isinstance(names, list) or set(names) != expected_arrays or len(names) != 3:
|
|
72
|
+
raise ValueError("state-alignment array_names is invalid")
|
|
73
|
+
try:
|
|
74
|
+
with np.load(arrays_path, allow_pickle=False) as archive:
|
|
75
|
+
if set(archive.files) != expected_arrays:
|
|
76
|
+
raise ValueError("state-alignment arrays do not match the manifest")
|
|
77
|
+
arrays = {name: np.array(archive[name], copy=True) for name in archive.files}
|
|
78
|
+
except (OSError, ValueError) as error:
|
|
79
|
+
raise ValueError(f"cannot read state-alignment arrays: {error}") from error
|
|
80
|
+
if any(array.dtype.hasobject for array in arrays.values()):
|
|
81
|
+
raise ValueError("state-alignment arrays cannot contain objects")
|
|
82
|
+
keys = manifest["feature_keys"]
|
|
83
|
+
if not isinstance(keys, list):
|
|
84
|
+
raise TypeError("state-alignment feature_keys must be a list")
|
|
85
|
+
if any(
|
|
86
|
+
not isinstance(key, list)
|
|
87
|
+
or not key
|
|
88
|
+
or any(not isinstance(part, str) or not part for part in key)
|
|
89
|
+
for key in keys
|
|
90
|
+
):
|
|
91
|
+
raise ValueError("state-alignment feature_keys are invalid")
|
|
92
|
+
feature_keys = tuple(tuple(key) for key in keys)
|
|
93
|
+
return StateAlignment(
|
|
94
|
+
candidate_to_reference=arrays["candidate_to_reference"],
|
|
95
|
+
matched_costs=arrays["matched_costs"],
|
|
96
|
+
cost_matrix=arrays["cost_matrix"],
|
|
97
|
+
reference_seed=manifest["reference_seed"],
|
|
98
|
+
candidate_seed=manifest["candidate_seed"],
|
|
99
|
+
feature_keys=feature_keys,
|
|
100
|
+
source_contract=manifest["source_contract"],
|
|
101
|
+
sample_interval_seconds=manifest["sample_interval_seconds"],
|
|
102
|
+
metric=manifest["metric"],
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
__all__ = ["load_state_alignment", "save_state_alignment"]
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Portable, pickle-free persistence for decoded state sequences."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from numpy.typing import ArrayLike, NDArray
|
|
10
|
+
|
|
11
|
+
from .._arrays import readonly_copy as _readonly
|
|
12
|
+
from ..states.data import StateAssignments, StateLabelSequence
|
|
13
|
+
from ..states.metrics import summarize_state_assignments
|
|
14
|
+
from ._json import load_json_object, write_json_atomic
|
|
15
|
+
from ._numpy import write_numpy_artifact
|
|
16
|
+
|
|
17
|
+
FORMAT_NAME = "dfckit-state-predictions"
|
|
18
|
+
FORMAT_VERSION = 4
|
|
19
|
+
METRICS_FORMAT_NAME = "dfckit-state-metrics"
|
|
20
|
+
METRICS_FORMAT_VERSION = 1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class StatePredictions:
|
|
25
|
+
"""Decoded state assignments with optional Gaussian-HMM probabilities."""
|
|
26
|
+
|
|
27
|
+
assignments: StateAssignments
|
|
28
|
+
model_kind: str
|
|
29
|
+
model_seed: int
|
|
30
|
+
posterior_probabilities: tuple[ArrayLike, ...] | None = None
|
|
31
|
+
log_likelihood: float | None = None
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
if not isinstance(self.assignments, StateAssignments):
|
|
35
|
+
raise TypeError("assignments must be a StateAssignments object")
|
|
36
|
+
if self.model_kind not in {"kmeans-state", "gaussian-hmm-state"}:
|
|
37
|
+
raise ValueError("model_kind must be 'kmeans-state' or 'gaussian-hmm-state'")
|
|
38
|
+
if (
|
|
39
|
+
isinstance(self.model_seed, (bool, np.bool_))
|
|
40
|
+
or not isinstance(self.model_seed, (int, np.integer))
|
|
41
|
+
or self.model_seed < 0
|
|
42
|
+
):
|
|
43
|
+
raise ValueError("model_seed must be a non-negative integer")
|
|
44
|
+
object.__setattr__(self, "model_seed", int(self.model_seed))
|
|
45
|
+
if self.model_kind == "kmeans-state":
|
|
46
|
+
if self.posterior_probabilities is not None or self.log_likelihood is not None:
|
|
47
|
+
raise ValueError("KMeans predictions cannot contain posterior probabilities")
|
|
48
|
+
return
|
|
49
|
+
if self.posterior_probabilities is None or self.log_likelihood is None:
|
|
50
|
+
raise ValueError("Gaussian HMM predictions require posteriors and log likelihood")
|
|
51
|
+
posteriors = tuple(
|
|
52
|
+
_readonly(np.asarray(values, dtype=float))
|
|
53
|
+
for values in self.posterior_probabilities
|
|
54
|
+
)
|
|
55
|
+
if len(posteriors) != len(self.assignments.sequences):
|
|
56
|
+
raise ValueError("posterior sequence count does not match state assignments")
|
|
57
|
+
for posterior, sequence in zip(
|
|
58
|
+
posteriors,
|
|
59
|
+
self.assignments.sequences,
|
|
60
|
+
strict=True,
|
|
61
|
+
):
|
|
62
|
+
expected = (len(sequence.labels), self.assignments.n_states)
|
|
63
|
+
if posterior.shape != expected:
|
|
64
|
+
raise ValueError("posterior shape does not match sequence labels and states")
|
|
65
|
+
if (
|
|
66
|
+
not np.isfinite(posterior).all()
|
|
67
|
+
or np.any(posterior < 0.0)
|
|
68
|
+
or np.any(posterior > 1.0)
|
|
69
|
+
or not np.allclose(posterior.sum(axis=1), 1.0, rtol=0.0, atol=1e-7)
|
|
70
|
+
):
|
|
71
|
+
raise ValueError("posterior probabilities are invalid")
|
|
72
|
+
if not np.isfinite(self.log_likelihood):
|
|
73
|
+
raise ValueError("log_likelihood must be finite")
|
|
74
|
+
object.__setattr__(self, "posterior_probabilities", posteriors)
|
|
75
|
+
object.__setattr__(self, "log_likelihood", float(self.log_likelihood))
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def n_samples(self) -> int:
|
|
79
|
+
return sum(len(sequence.labels) for sequence in self.assignments.sequences)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def n_sequences(self) -> int:
|
|
83
|
+
return len(self.assignments.sequences)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def subjects(self) -> tuple[str, ...]:
|
|
87
|
+
return tuple(
|
|
88
|
+
dict.fromkeys(sequence.subject for sequence in self.assignments.sequences)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _sequence_metadata(assignments: StateAssignments) -> list[dict[str, object]]:
|
|
93
|
+
return [
|
|
94
|
+
{
|
|
95
|
+
"subject": sequence.subject,
|
|
96
|
+
"session": sequence.session,
|
|
97
|
+
"acquisition_id": sequence.acquisition_id,
|
|
98
|
+
"segment_id": sequence.segment_id,
|
|
99
|
+
"n_samples": len(sequence.labels),
|
|
100
|
+
}
|
|
101
|
+
for sequence in assignments.sequences
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def save_state_predictions(predictions: StatePredictions, path: str | Path) -> Path:
|
|
106
|
+
"""Write decoded labels, indices, and optional posteriors without pickle."""
|
|
107
|
+
if not isinstance(predictions, StatePredictions):
|
|
108
|
+
raise TypeError("predictions must be a StatePredictions object")
|
|
109
|
+
assignments = predictions.assignments
|
|
110
|
+
lengths = np.asarray(
|
|
111
|
+
[len(sequence.labels) for sequence in assignments.sequences],
|
|
112
|
+
dtype=np.int64,
|
|
113
|
+
)
|
|
114
|
+
offsets = np.concatenate((np.asarray([0], dtype=np.int64), np.cumsum(lengths)))
|
|
115
|
+
arrays: dict[str, NDArray] = {
|
|
116
|
+
"labels": np.concatenate([sequence.labels for sequence in assignments.sequences]),
|
|
117
|
+
"sample_start_indices": np.concatenate(
|
|
118
|
+
[sequence.sample_start_indices for sequence in assignments.sequences]
|
|
119
|
+
),
|
|
120
|
+
"sample_end_indices": np.concatenate(
|
|
121
|
+
[sequence.sample_end_indices for sequence in assignments.sequences]
|
|
122
|
+
),
|
|
123
|
+
"sequence_offsets": offsets,
|
|
124
|
+
}
|
|
125
|
+
if predictions.posterior_probabilities is not None:
|
|
126
|
+
arrays["posterior_probabilities"] = np.concatenate(
|
|
127
|
+
predictions.posterior_probabilities,
|
|
128
|
+
axis=0,
|
|
129
|
+
)
|
|
130
|
+
return write_numpy_artifact(
|
|
131
|
+
path,
|
|
132
|
+
{
|
|
133
|
+
"format": FORMAT_NAME,
|
|
134
|
+
"format_version": FORMAT_VERSION,
|
|
135
|
+
"model_kind": predictions.model_kind,
|
|
136
|
+
"model_seed": predictions.model_seed,
|
|
137
|
+
"array_names": sorted(arrays),
|
|
138
|
+
"n_states": assignments.n_states,
|
|
139
|
+
"source_contract": assignments.source_contract,
|
|
140
|
+
"sample_interval_seconds": assignments.sample_interval_seconds,
|
|
141
|
+
"log_likelihood": predictions.log_likelihood,
|
|
142
|
+
"sequences": _sequence_metadata(assignments),
|
|
143
|
+
},
|
|
144
|
+
arrays,
|
|
145
|
+
label="state-prediction",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _manifest(root: Path) -> tuple[dict[str, object], dict[str, NDArray]]:
|
|
150
|
+
if not root.is_dir():
|
|
151
|
+
raise FileNotFoundError(f"state-prediction artifact directory does not exist: {root}")
|
|
152
|
+
manifest_path = root / "manifest.json"
|
|
153
|
+
arrays_path = root / "arrays.npz"
|
|
154
|
+
if not manifest_path.is_file() or not arrays_path.is_file():
|
|
155
|
+
raise FileNotFoundError("state-prediction artifact requires manifest.json and arrays.npz")
|
|
156
|
+
manifest = load_json_object(manifest_path, context="state-prediction manifest")
|
|
157
|
+
expected = {
|
|
158
|
+
"array_names",
|
|
159
|
+
"format",
|
|
160
|
+
"format_version",
|
|
161
|
+
"log_likelihood",
|
|
162
|
+
"model_kind",
|
|
163
|
+
"model_seed",
|
|
164
|
+
"n_states",
|
|
165
|
+
"sample_interval_seconds",
|
|
166
|
+
"sequences",
|
|
167
|
+
"source_contract",
|
|
168
|
+
}
|
|
169
|
+
if not isinstance(manifest, dict) or set(manifest) != expected:
|
|
170
|
+
raise ValueError("state-prediction manifest fields do not match the schema")
|
|
171
|
+
if manifest["format"] != FORMAT_NAME or manifest["format_version"] != FORMAT_VERSION:
|
|
172
|
+
raise ValueError("unsupported state-prediction artifact format or version")
|
|
173
|
+
names = manifest["array_names"]
|
|
174
|
+
if (
|
|
175
|
+
not isinstance(names, list)
|
|
176
|
+
or any(not isinstance(name, str) or not name for name in names)
|
|
177
|
+
or len(set(names)) != len(names)
|
|
178
|
+
):
|
|
179
|
+
raise ValueError("state-prediction array_names is invalid")
|
|
180
|
+
try:
|
|
181
|
+
with np.load(arrays_path, allow_pickle=False) as archive:
|
|
182
|
+
if set(archive.files) != set(names):
|
|
183
|
+
raise ValueError("state-prediction arrays do not match the manifest")
|
|
184
|
+
arrays = {name: np.array(archive[name], copy=True) for name in archive.files}
|
|
185
|
+
except (OSError, ValueError) as error:
|
|
186
|
+
raise ValueError(f"cannot read state-prediction arrays: {error}") from error
|
|
187
|
+
if any(array.dtype.hasobject for array in arrays.values()):
|
|
188
|
+
raise ValueError("state-prediction arrays cannot contain objects")
|
|
189
|
+
return manifest, arrays
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _positive_integer(value: object, name: str, minimum: int = 1) -> int:
|
|
193
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
|
194
|
+
raise ValueError(f"state-prediction {name} must be an integer >= {minimum}")
|
|
195
|
+
return value
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def load_state_predictions(path: str | Path) -> StatePredictions:
|
|
199
|
+
"""Load and validate predictions written by :func:`save_state_predictions`."""
|
|
200
|
+
manifest, arrays = _manifest(Path(path))
|
|
201
|
+
model_kind = manifest["model_kind"]
|
|
202
|
+
model_seed = _positive_integer(manifest["model_seed"], "model_seed", minimum=0)
|
|
203
|
+
expected_arrays = {
|
|
204
|
+
"labels",
|
|
205
|
+
"sample_start_indices",
|
|
206
|
+
"sample_end_indices",
|
|
207
|
+
"sequence_offsets",
|
|
208
|
+
}
|
|
209
|
+
if model_kind == "gaussian-hmm-state":
|
|
210
|
+
expected_arrays.add("posterior_probabilities")
|
|
211
|
+
elif model_kind != "kmeans-state":
|
|
212
|
+
raise ValueError("state-prediction model_kind is invalid")
|
|
213
|
+
if set(arrays) != expected_arrays:
|
|
214
|
+
raise ValueError("state-prediction array set is invalid for its model kind")
|
|
215
|
+
n_states = _positive_integer(manifest["n_states"], "n_states", minimum=2)
|
|
216
|
+
source_contract = manifest["source_contract"]
|
|
217
|
+
if not isinstance(source_contract, str) or not source_contract.strip():
|
|
218
|
+
raise ValueError("state-prediction source_contract must be non-empty")
|
|
219
|
+
interval = manifest["sample_interval_seconds"]
|
|
220
|
+
if interval is not None and (
|
|
221
|
+
isinstance(interval, bool)
|
|
222
|
+
or not isinstance(interval, (int, float))
|
|
223
|
+
or not np.isfinite(interval)
|
|
224
|
+
or interval <= 0.0
|
|
225
|
+
):
|
|
226
|
+
raise ValueError("state-prediction sample interval is invalid")
|
|
227
|
+
records = manifest["sequences"]
|
|
228
|
+
if not isinstance(records, list) or not records:
|
|
229
|
+
raise ValueError("state-prediction sequences must be a non-empty list")
|
|
230
|
+
offsets = np.asarray(arrays["sequence_offsets"])
|
|
231
|
+
labels = np.asarray(arrays["labels"])
|
|
232
|
+
starts = np.asarray(arrays["sample_start_indices"])
|
|
233
|
+
ends = np.asarray(arrays["sample_end_indices"])
|
|
234
|
+
if (
|
|
235
|
+
offsets.dtype.kind not in "iu"
|
|
236
|
+
or offsets.shape != (len(records) + 1,)
|
|
237
|
+
or offsets[0] != 0
|
|
238
|
+
or np.any(np.diff(offsets) <= 0)
|
|
239
|
+
):
|
|
240
|
+
raise ValueError("state-prediction sequence offsets are invalid")
|
|
241
|
+
total = int(offsets[-1])
|
|
242
|
+
if (
|
|
243
|
+
labels.dtype.kind not in "iu"
|
|
244
|
+
or starts.dtype.kind not in "iu"
|
|
245
|
+
or ends.dtype.kind not in "iu"
|
|
246
|
+
or labels.shape != (total,)
|
|
247
|
+
or starts.shape != (total,)
|
|
248
|
+
or ends.shape != (total,)
|
|
249
|
+
):
|
|
250
|
+
raise ValueError("state-prediction label or index arrays are invalid")
|
|
251
|
+
sequences = []
|
|
252
|
+
expected_record_fields = {
|
|
253
|
+
"acquisition_id",
|
|
254
|
+
"n_samples",
|
|
255
|
+
"segment_id",
|
|
256
|
+
"session",
|
|
257
|
+
"subject",
|
|
258
|
+
}
|
|
259
|
+
for index, record in enumerate(records):
|
|
260
|
+
if not isinstance(record, dict) or set(record) != expected_record_fields:
|
|
261
|
+
raise ValueError("state-prediction sequence metadata is invalid")
|
|
262
|
+
start = int(offsets[index])
|
|
263
|
+
stop = int(offsets[index + 1])
|
|
264
|
+
if _positive_integer(record["n_samples"], "sequence n_samples") != stop - start:
|
|
265
|
+
raise ValueError("state-prediction sequence length does not match offsets")
|
|
266
|
+
sequences.append(
|
|
267
|
+
StateLabelSequence(
|
|
268
|
+
labels=labels[start:stop],
|
|
269
|
+
sample_start_indices=starts[start:stop],
|
|
270
|
+
sample_end_indices=ends[start:stop],
|
|
271
|
+
subject=record["subject"],
|
|
272
|
+
session=record["session"],
|
|
273
|
+
acquisition_id=record["acquisition_id"],
|
|
274
|
+
segment_id=record["segment_id"],
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
assignments = StateAssignments(
|
|
278
|
+
sequences=tuple(sequences),
|
|
279
|
+
n_states=n_states,
|
|
280
|
+
source_contract=source_contract,
|
|
281
|
+
sample_interval_seconds=None if interval is None else float(interval),
|
|
282
|
+
)
|
|
283
|
+
posterior = None
|
|
284
|
+
log_likelihood = manifest["log_likelihood"]
|
|
285
|
+
if model_kind == "gaussian-hmm-state":
|
|
286
|
+
pooled = np.asarray(arrays["posterior_probabilities"], dtype=float)
|
|
287
|
+
if pooled.shape != (total, n_states):
|
|
288
|
+
raise ValueError("state-prediction posterior array has an invalid shape")
|
|
289
|
+
posterior = tuple(
|
|
290
|
+
pooled[int(offsets[index]) : int(offsets[index + 1])]
|
|
291
|
+
for index in range(len(records))
|
|
292
|
+
)
|
|
293
|
+
return StatePredictions(
|
|
294
|
+
assignments=assignments,
|
|
295
|
+
model_kind=str(model_kind),
|
|
296
|
+
model_seed=model_seed,
|
|
297
|
+
posterior_probabilities=posterior,
|
|
298
|
+
log_likelihood=log_likelihood,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _nullable(values: NDArray) -> list:
|
|
303
|
+
array = np.asarray(values)
|
|
304
|
+
if array.ndim == 1:
|
|
305
|
+
return [None if not np.isfinite(value) else float(value) for value in array]
|
|
306
|
+
return [_nullable(row) for row in array]
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def write_state_metrics(predictions: StatePredictions, path: str | Path) -> Path:
|
|
310
|
+
"""Summarize one prediction artifact as strict machine-readable JSON."""
|
|
311
|
+
if not isinstance(predictions, StatePredictions):
|
|
312
|
+
raise TypeError("predictions must be a StatePredictions object")
|
|
313
|
+
runs = []
|
|
314
|
+
for metric in summarize_state_assignments(predictions.assignments):
|
|
315
|
+
runs.append(
|
|
316
|
+
{
|
|
317
|
+
"subject": metric.subject,
|
|
318
|
+
"session": metric.session,
|
|
319
|
+
"acquisition_id": metric.acquisition_id,
|
|
320
|
+
"n_samples": metric.n_samples,
|
|
321
|
+
"n_sequences": metric.n_sequences,
|
|
322
|
+
"n_possible_transitions": metric.n_possible_transitions,
|
|
323
|
+
"n_switches": metric.n_switches,
|
|
324
|
+
"switch_rate": (
|
|
325
|
+
None if not np.isfinite(metric.switch_rate) else metric.switch_rate
|
|
326
|
+
),
|
|
327
|
+
"occupancy": _nullable(metric.occupancy),
|
|
328
|
+
"mean_dwell_samples": _nullable(metric.mean_dwell_samples),
|
|
329
|
+
"mean_dwell_seconds": _nullable(metric.mean_dwell_seconds),
|
|
330
|
+
"transition_counts": np.asarray(metric.transition_counts).tolist(),
|
|
331
|
+
"transition_probabilities": _nullable(metric.transition_probabilities),
|
|
332
|
+
}
|
|
333
|
+
)
|
|
334
|
+
payload = {
|
|
335
|
+
"format": METRICS_FORMAT_NAME,
|
|
336
|
+
"format_version": METRICS_FORMAT_VERSION,
|
|
337
|
+
"model_kind": predictions.model_kind,
|
|
338
|
+
"model_seed": predictions.model_seed,
|
|
339
|
+
"n_states": predictions.assignments.n_states,
|
|
340
|
+
"source_contract": predictions.assignments.source_contract,
|
|
341
|
+
"sample_interval_seconds": predictions.assignments.sample_interval_seconds,
|
|
342
|
+
"n_runs": len(runs),
|
|
343
|
+
"runs": runs,
|
|
344
|
+
}
|
|
345
|
+
return write_json_atomic(path, payload)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
__all__ = [
|
|
349
|
+
"StatePredictions",
|
|
350
|
+
"load_state_predictions",
|
|
351
|
+
"save_state_predictions",
|
|
352
|
+
"write_state_metrics",
|
|
353
|
+
]
|