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,509 @@
|
|
|
1
|
+
"""Strict JSON persistence for held-out state-model scores."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import MappingProxyType
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from .._validation import (
|
|
14
|
+
validated_nonnegative_integer as _nonnegative_integer,
|
|
15
|
+
)
|
|
16
|
+
from .._validation import (
|
|
17
|
+
validated_positive_integer as _positive_integer,
|
|
18
|
+
)
|
|
19
|
+
from ..states.hmm import GaussianHMMStateModel
|
|
20
|
+
from ..states.kmeans import KMeansStateModel
|
|
21
|
+
from ..states.scoring import RunGaussianHMMScore, RunKMeansScore, RunStateModelScore
|
|
22
|
+
from ._json import load_json_object, write_json_atomic
|
|
23
|
+
|
|
24
|
+
FORMAT_NAME = "dfckit-state-model-scores"
|
|
25
|
+
FORMAT_VERSION = 3
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _state_model_specification(
|
|
29
|
+
model: KMeansStateModel | GaussianHMMStateModel,
|
|
30
|
+
) -> dict[str, object]:
|
|
31
|
+
"""Return the complete score-comparison specification of a fitted model."""
|
|
32
|
+
if isinstance(model, KMeansStateModel):
|
|
33
|
+
return {
|
|
34
|
+
"algorithm": model.algorithm,
|
|
35
|
+
"batch_size": model.batch_size,
|
|
36
|
+
"implementation": model.implementation,
|
|
37
|
+
"init_sample_size": model.init_sample_size,
|
|
38
|
+
"max_iter": model.max_iter,
|
|
39
|
+
"n_init": model.n_init,
|
|
40
|
+
"n_pca_components": model.n_pca_components,
|
|
41
|
+
"pca_batch_size": model.pca_batch_size,
|
|
42
|
+
"reassignment_ratio": model.reassignment_ratio,
|
|
43
|
+
"standardize_features": model.standardize_features,
|
|
44
|
+
}
|
|
45
|
+
if isinstance(model, GaussianHMMStateModel):
|
|
46
|
+
return {
|
|
47
|
+
"covariance_type": model.covariance_type,
|
|
48
|
+
"implementation": model.implementation,
|
|
49
|
+
"minimum_sequence_length": model.minimum_sequence_length,
|
|
50
|
+
"n_init": model.n_init,
|
|
51
|
+
"n_iter": model.n_iter,
|
|
52
|
+
"n_pca_components": model.n_pca_components,
|
|
53
|
+
"pca_batch_size": model.pca_batch_size,
|
|
54
|
+
"tol": model.tol,
|
|
55
|
+
}
|
|
56
|
+
raise TypeError("state-model specification requires KMeans or Gaussian HMM")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _run_boundaries(
|
|
60
|
+
report: StateModelScoreReport,
|
|
61
|
+
) -> tuple[tuple[object, ...], ...]:
|
|
62
|
+
"""Return acquisition identities used to compare score collections."""
|
|
63
|
+
return tuple(
|
|
64
|
+
(
|
|
65
|
+
score.subject,
|
|
66
|
+
score.session,
|
|
67
|
+
score.acquisition_id,
|
|
68
|
+
score.n_samples,
|
|
69
|
+
score.n_sequences,
|
|
70
|
+
)
|
|
71
|
+
for score in report.scores
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
_KMEANS_SPECIFICATION_FIELDS = {
|
|
75
|
+
"algorithm",
|
|
76
|
+
"batch_size",
|
|
77
|
+
"implementation",
|
|
78
|
+
"init_sample_size",
|
|
79
|
+
"max_iter",
|
|
80
|
+
"n_init",
|
|
81
|
+
"n_pca_components",
|
|
82
|
+
"pca_batch_size",
|
|
83
|
+
"reassignment_ratio",
|
|
84
|
+
"standardize_features",
|
|
85
|
+
}
|
|
86
|
+
_HMM_SPECIFICATION_FIELDS = {
|
|
87
|
+
"covariance_type",
|
|
88
|
+
"implementation",
|
|
89
|
+
"minimum_sequence_length",
|
|
90
|
+
"n_init",
|
|
91
|
+
"n_iter",
|
|
92
|
+
"n_pca_components",
|
|
93
|
+
"pca_batch_size",
|
|
94
|
+
"tol",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _sample_interval(value: float | None) -> float | None:
|
|
99
|
+
if value is None:
|
|
100
|
+
return None
|
|
101
|
+
output = float(value)
|
|
102
|
+
if not np.isfinite(output) or output <= 0.0:
|
|
103
|
+
raise ValueError("sample_interval_seconds must be finite and positive")
|
|
104
|
+
return output
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _model_specification(
|
|
108
|
+
value: Mapping[str, object],
|
|
109
|
+
model_kind: str,
|
|
110
|
+
) -> dict[str, object]:
|
|
111
|
+
if not isinstance(value, Mapping):
|
|
112
|
+
raise TypeError("model_specification must be a mapping")
|
|
113
|
+
output = dict(value)
|
|
114
|
+
if model_kind == "kmeans-state":
|
|
115
|
+
output.setdefault("n_pca_components", None)
|
|
116
|
+
output.setdefault("pca_batch_size", None)
|
|
117
|
+
expected = (
|
|
118
|
+
_KMEANS_SPECIFICATION_FIELDS
|
|
119
|
+
if model_kind == "kmeans-state"
|
|
120
|
+
else _HMM_SPECIFICATION_FIELDS
|
|
121
|
+
)
|
|
122
|
+
if set(output) != expected:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
"model_specification fields do not match model_kind; "
|
|
125
|
+
f"missing={sorted(expected - set(output))}, "
|
|
126
|
+
f"unexpected={sorted(set(output) - expected)}"
|
|
127
|
+
)
|
|
128
|
+
try:
|
|
129
|
+
json.dumps(output, allow_nan=False, sort_keys=True)
|
|
130
|
+
except (TypeError, ValueError) as error:
|
|
131
|
+
raise ValueError("model_specification must contain finite JSON values") from error
|
|
132
|
+
if not isinstance(output["implementation"], str) or not str(
|
|
133
|
+
output["implementation"]
|
|
134
|
+
).strip():
|
|
135
|
+
raise ValueError("model_specification implementation must be non-empty")
|
|
136
|
+
if model_kind == "kmeans-state":
|
|
137
|
+
if output["algorithm"] not in {"lloyd", "minibatch"}:
|
|
138
|
+
raise ValueError("KMeans model_specification algorithm is invalid")
|
|
139
|
+
if not isinstance(output["standardize_features"], bool):
|
|
140
|
+
raise TypeError("KMeans model_specification standardize_features must be boolean")
|
|
141
|
+
for name in ("n_init", "max_iter"):
|
|
142
|
+
_positive_integer(output[name], f"model_specification {name}")
|
|
143
|
+
for name in (
|
|
144
|
+
"batch_size",
|
|
145
|
+
"init_sample_size",
|
|
146
|
+
"n_pca_components",
|
|
147
|
+
"pca_batch_size",
|
|
148
|
+
):
|
|
149
|
+
if output[name] is not None:
|
|
150
|
+
_positive_integer(output[name], f"model_specification {name}")
|
|
151
|
+
ratio = output["reassignment_ratio"]
|
|
152
|
+
if ratio is not None and (
|
|
153
|
+
isinstance(ratio, bool)
|
|
154
|
+
or not isinstance(ratio, (int, float))
|
|
155
|
+
or not np.isfinite(ratio)
|
|
156
|
+
or not 0.0 <= ratio <= 1.0
|
|
157
|
+
):
|
|
158
|
+
raise ValueError("KMeans model_specification reassignment_ratio is invalid")
|
|
159
|
+
else:
|
|
160
|
+
if output["covariance_type"] not in {"diag", "full"}:
|
|
161
|
+
raise ValueError("HMM model_specification covariance_type is invalid")
|
|
162
|
+
for name in ("n_init", "n_iter", "n_pca_components", "minimum_sequence_length"):
|
|
163
|
+
_positive_integer(output[name], f"model_specification {name}")
|
|
164
|
+
if output["pca_batch_size"] is not None:
|
|
165
|
+
_positive_integer(
|
|
166
|
+
output["pca_batch_size"],
|
|
167
|
+
"model_specification pca_batch_size",
|
|
168
|
+
)
|
|
169
|
+
tolerance = output["tol"]
|
|
170
|
+
if (
|
|
171
|
+
isinstance(tolerance, bool)
|
|
172
|
+
or not isinstance(tolerance, (int, float))
|
|
173
|
+
or not np.isfinite(tolerance)
|
|
174
|
+
or tolerance <= 0.0
|
|
175
|
+
):
|
|
176
|
+
raise ValueError("HMM model_specification tol must be finite and positive")
|
|
177
|
+
return output
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass(frozen=True)
|
|
181
|
+
class StateModelScoreReport:
|
|
182
|
+
"""Validated in-memory representation of one score artifact."""
|
|
183
|
+
|
|
184
|
+
scores: tuple[RunStateModelScore, ...]
|
|
185
|
+
format_version: int
|
|
186
|
+
model_kind: str
|
|
187
|
+
model_seed: int
|
|
188
|
+
n_states: int
|
|
189
|
+
fit_subjects: tuple[str, ...]
|
|
190
|
+
source_contract: str
|
|
191
|
+
sample_interval_seconds: float | None
|
|
192
|
+
minimum_sequence_length: int
|
|
193
|
+
omitted_short_sequence_count: int
|
|
194
|
+
allow_fit_subjects: bool
|
|
195
|
+
model_specification: Mapping[str, object]
|
|
196
|
+
|
|
197
|
+
def __post_init__(self) -> None:
|
|
198
|
+
object.__setattr__(self, "scores", tuple(self.scores))
|
|
199
|
+
specification = _model_specification(self.model_specification, self.model_kind)
|
|
200
|
+
object.__setattr__(
|
|
201
|
+
self,
|
|
202
|
+
"model_specification",
|
|
203
|
+
MappingProxyType(specification),
|
|
204
|
+
)
|
|
205
|
+
canonical = _state_model_scores_payload(
|
|
206
|
+
self.scores,
|
|
207
|
+
model_kind=self.model_kind,
|
|
208
|
+
model_seed=self.model_seed,
|
|
209
|
+
n_states=self.n_states,
|
|
210
|
+
fit_subjects=self.fit_subjects,
|
|
211
|
+
source_contract=self.source_contract,
|
|
212
|
+
sample_interval_seconds=self.sample_interval_seconds,
|
|
213
|
+
minimum_sequence_length=self.minimum_sequence_length,
|
|
214
|
+
omitted_short_sequence_count=self.omitted_short_sequence_count,
|
|
215
|
+
allow_fit_subjects=self.allow_fit_subjects,
|
|
216
|
+
model_specification=self.model_specification,
|
|
217
|
+
)
|
|
218
|
+
if canonical["format_version"] != self.format_version:
|
|
219
|
+
raise ValueError("state-model score format version and provenance disagree")
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def subjects(self) -> tuple[str, ...]:
|
|
223
|
+
return tuple(dict.fromkeys(score.subject for score in self.scores))
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def n_runs(self) -> int:
|
|
227
|
+
return len(self.scores)
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def n_sequences(self) -> int:
|
|
231
|
+
return sum(score.n_sequences for score in self.scores)
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def n_samples(self) -> int:
|
|
235
|
+
return sum(score.n_samples for score in self.scores)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _state_model_scores_payload(
|
|
239
|
+
scores: Sequence[RunStateModelScore],
|
|
240
|
+
*,
|
|
241
|
+
model_kind: str,
|
|
242
|
+
model_seed: int,
|
|
243
|
+
n_states: int,
|
|
244
|
+
fit_subjects: Sequence[str],
|
|
245
|
+
source_contract: str,
|
|
246
|
+
sample_interval_seconds: float | None,
|
|
247
|
+
minimum_sequence_length: int,
|
|
248
|
+
omitted_short_sequence_count: int,
|
|
249
|
+
allow_fit_subjects: bool,
|
|
250
|
+
model_specification: Mapping[str, object],
|
|
251
|
+
) -> dict[str, object]:
|
|
252
|
+
"""Build a finite held-out scoring payload."""
|
|
253
|
+
scores = tuple(scores)
|
|
254
|
+
if not scores:
|
|
255
|
+
raise ValueError("state-model scoring requires at least one run")
|
|
256
|
+
if model_kind not in {"kmeans-state", "gaussian-hmm-state"}:
|
|
257
|
+
raise ValueError("state-model scoring model_kind is invalid")
|
|
258
|
+
expected_type = RunKMeansScore if model_kind == "kmeans-state" else RunGaussianHMMScore
|
|
259
|
+
if any(not isinstance(score, expected_type) for score in scores):
|
|
260
|
+
raise TypeError("state-model score type does not match model_kind")
|
|
261
|
+
if not isinstance(allow_fit_subjects, (bool, np.bool_)):
|
|
262
|
+
raise TypeError("allow_fit_subjects must be boolean")
|
|
263
|
+
if not isinstance(source_contract, str) or not source_contract.strip():
|
|
264
|
+
raise ValueError("state-model scoring source_contract must be non-empty")
|
|
265
|
+
fit_subjects = tuple(fit_subjects)
|
|
266
|
+
if (
|
|
267
|
+
not fit_subjects
|
|
268
|
+
or any(not isinstance(subject, str) or not subject.strip() for subject in fit_subjects)
|
|
269
|
+
or len(set(fit_subjects)) != len(fit_subjects)
|
|
270
|
+
):
|
|
271
|
+
raise ValueError("fit_subjects must contain unique non-empty labels")
|
|
272
|
+
identities = tuple(
|
|
273
|
+
(score.subject, score.session, score.acquisition_id) for score in scores
|
|
274
|
+
)
|
|
275
|
+
if len(set(identities)) != len(identities):
|
|
276
|
+
raise ValueError("state-model scores contain duplicate run identities")
|
|
277
|
+
score_subjects = tuple(dict.fromkeys(score.subject for score in scores))
|
|
278
|
+
overlap = sorted(set(fit_subjects).intersection(score_subjects))
|
|
279
|
+
if overlap and not allow_fit_subjects:
|
|
280
|
+
raise ValueError(
|
|
281
|
+
"state-model score fit and evaluation subjects overlap without permission: "
|
|
282
|
+
f"{overlap}"
|
|
283
|
+
)
|
|
284
|
+
specification = _model_specification(model_specification, model_kind)
|
|
285
|
+
if (
|
|
286
|
+
model_kind == "gaussian-hmm-state"
|
|
287
|
+
and specification["minimum_sequence_length"] != minimum_sequence_length
|
|
288
|
+
):
|
|
289
|
+
raise ValueError(
|
|
290
|
+
"HMM model_specification minimum_sequence_length disagrees with scoring"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
runs: list[dict[str, object]] = []
|
|
294
|
+
if model_kind == "kmeans-state":
|
|
295
|
+
total = sum(score.total_squared_distance for score in scores)
|
|
296
|
+
n_samples = sum(score.n_samples for score in scores)
|
|
297
|
+
metric_name = "mean_squared_distance"
|
|
298
|
+
direction = "lower is better on an identical held-out cohort"
|
|
299
|
+
summary = {
|
|
300
|
+
"total_squared_distance": total,
|
|
301
|
+
"mean_squared_distance": total / n_samples,
|
|
302
|
+
}
|
|
303
|
+
for score in scores:
|
|
304
|
+
assert isinstance(score, RunKMeansScore)
|
|
305
|
+
runs.append(
|
|
306
|
+
{
|
|
307
|
+
"subject": score.subject,
|
|
308
|
+
"session": score.session,
|
|
309
|
+
"acquisition_id": score.acquisition_id,
|
|
310
|
+
"n_samples": score.n_samples,
|
|
311
|
+
"n_sequences": score.n_sequences,
|
|
312
|
+
"total_squared_distance": score.total_squared_distance,
|
|
313
|
+
"mean_squared_distance": score.mean_squared_distance,
|
|
314
|
+
}
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
total = sum(score.log_likelihood for score in scores)
|
|
318
|
+
n_samples = sum(score.n_samples for score in scores)
|
|
319
|
+
metric_name = "log_likelihood_per_sample"
|
|
320
|
+
direction = "higher is better on an identical held-out cohort"
|
|
321
|
+
summary = {
|
|
322
|
+
"log_likelihood": total,
|
|
323
|
+
"log_likelihood_per_sample": total / n_samples,
|
|
324
|
+
}
|
|
325
|
+
for score in scores:
|
|
326
|
+
assert isinstance(score, RunGaussianHMMScore)
|
|
327
|
+
runs.append(
|
|
328
|
+
{
|
|
329
|
+
"subject": score.subject,
|
|
330
|
+
"session": score.session,
|
|
331
|
+
"acquisition_id": score.acquisition_id,
|
|
332
|
+
"n_samples": score.n_samples,
|
|
333
|
+
"n_sequences": score.n_sequences,
|
|
334
|
+
"log_likelihood": score.log_likelihood,
|
|
335
|
+
"log_likelihood_per_sample": score.log_likelihood_per_sample,
|
|
336
|
+
}
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
payload = {
|
|
340
|
+
"format": FORMAT_NAME,
|
|
341
|
+
"format_version": FORMAT_VERSION,
|
|
342
|
+
"model_kind": model_kind,
|
|
343
|
+
"model_seed": _nonnegative_integer(model_seed, "model_seed"),
|
|
344
|
+
"n_states": _positive_integer(n_states, "n_states"),
|
|
345
|
+
"selection_metric": metric_name,
|
|
346
|
+
"selection_direction": direction,
|
|
347
|
+
"fit_subjects": list(fit_subjects),
|
|
348
|
+
"source_contract": source_contract,
|
|
349
|
+
"sample_interval_seconds": _sample_interval(sample_interval_seconds),
|
|
350
|
+
"minimum_sequence_length": _positive_integer(
|
|
351
|
+
minimum_sequence_length,
|
|
352
|
+
"minimum_sequence_length",
|
|
353
|
+
),
|
|
354
|
+
"omitted_short_sequence_count": _nonnegative_integer(
|
|
355
|
+
omitted_short_sequence_count,
|
|
356
|
+
"omitted_short_sequence_count",
|
|
357
|
+
),
|
|
358
|
+
"allow_fit_subjects": bool(allow_fit_subjects),
|
|
359
|
+
"n_runs": len(scores),
|
|
360
|
+
"n_sequences": sum(score.n_sequences for score in scores),
|
|
361
|
+
"n_samples": sum(score.n_samples for score in scores),
|
|
362
|
+
"subjects": list(score_subjects),
|
|
363
|
+
"summary": summary,
|
|
364
|
+
"runs": runs,
|
|
365
|
+
"model_specification": specification,
|
|
366
|
+
}
|
|
367
|
+
return payload
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def load_state_model_scores(path: str | Path) -> StateModelScoreReport:
|
|
371
|
+
"""Load and canonically validate a state-model score artifact."""
|
|
372
|
+
source = Path(path)
|
|
373
|
+
raw = load_json_object(source, context="state-model score artifact")
|
|
374
|
+
version = raw.get("format_version")
|
|
375
|
+
if (
|
|
376
|
+
raw.get("format") != FORMAT_NAME
|
|
377
|
+
or isinstance(version, bool)
|
|
378
|
+
or not isinstance(version, int)
|
|
379
|
+
or version != FORMAT_VERSION
|
|
380
|
+
):
|
|
381
|
+
raise ValueError("unsupported state-model score artifact format or version")
|
|
382
|
+
expected_fields = {
|
|
383
|
+
"allow_fit_subjects",
|
|
384
|
+
"fit_subjects",
|
|
385
|
+
"format",
|
|
386
|
+
"format_version",
|
|
387
|
+
"minimum_sequence_length",
|
|
388
|
+
"model_kind",
|
|
389
|
+
"model_seed",
|
|
390
|
+
"model_specification",
|
|
391
|
+
"n_runs",
|
|
392
|
+
"n_samples",
|
|
393
|
+
"n_sequences",
|
|
394
|
+
"n_states",
|
|
395
|
+
"omitted_short_sequence_count",
|
|
396
|
+
"runs",
|
|
397
|
+
"sample_interval_seconds",
|
|
398
|
+
"selection_direction",
|
|
399
|
+
"selection_metric",
|
|
400
|
+
"source_contract",
|
|
401
|
+
"subjects",
|
|
402
|
+
"summary",
|
|
403
|
+
}
|
|
404
|
+
if set(raw) != expected_fields:
|
|
405
|
+
raise ValueError(
|
|
406
|
+
"state-model score artifact fields do not match its version; "
|
|
407
|
+
f"missing={sorted(expected_fields - set(raw))}, "
|
|
408
|
+
f"unexpected={sorted(set(raw) - expected_fields)}"
|
|
409
|
+
)
|
|
410
|
+
model_kind = raw["model_kind"]
|
|
411
|
+
if model_kind not in {"kmeans-state", "gaussian-hmm-state"}:
|
|
412
|
+
raise ValueError("state-model score artifact model_kind is invalid")
|
|
413
|
+
run_fields = {
|
|
414
|
+
"acquisition_id",
|
|
415
|
+
"mean_squared_distance",
|
|
416
|
+
"n_samples",
|
|
417
|
+
"n_sequences",
|
|
418
|
+
"session",
|
|
419
|
+
"subject",
|
|
420
|
+
"total_squared_distance",
|
|
421
|
+
} if model_kind == "kmeans-state" else {
|
|
422
|
+
"acquisition_id",
|
|
423
|
+
"log_likelihood",
|
|
424
|
+
"log_likelihood_per_sample",
|
|
425
|
+
"n_samples",
|
|
426
|
+
"n_sequences",
|
|
427
|
+
"session",
|
|
428
|
+
"subject",
|
|
429
|
+
}
|
|
430
|
+
records = raw["runs"]
|
|
431
|
+
if not isinstance(records, list) or not records:
|
|
432
|
+
raise ValueError("state-model score artifact runs must be a non-empty array")
|
|
433
|
+
scores: list[RunStateModelScore] = []
|
|
434
|
+
for record in records:
|
|
435
|
+
if not isinstance(record, dict) or set(record) != run_fields:
|
|
436
|
+
raise ValueError("state-model score run fields do not match model_kind")
|
|
437
|
+
common = {
|
|
438
|
+
"subject": record["subject"],
|
|
439
|
+
"session": record["session"],
|
|
440
|
+
"acquisition_id": record["acquisition_id"],
|
|
441
|
+
"n_samples": record["n_samples"],
|
|
442
|
+
"n_sequences": record["n_sequences"],
|
|
443
|
+
}
|
|
444
|
+
if model_kind == "kmeans-state":
|
|
445
|
+
score = RunKMeansScore(
|
|
446
|
+
**common,
|
|
447
|
+
total_squared_distance=record["total_squared_distance"],
|
|
448
|
+
mean_squared_distance=record["mean_squared_distance"],
|
|
449
|
+
)
|
|
450
|
+
else:
|
|
451
|
+
score = RunGaussianHMMScore(
|
|
452
|
+
**common,
|
|
453
|
+
log_likelihood=record["log_likelihood"],
|
|
454
|
+
log_likelihood_per_sample=record["log_likelihood_per_sample"],
|
|
455
|
+
)
|
|
456
|
+
scores.append(score)
|
|
457
|
+
fit_subjects = raw["fit_subjects"]
|
|
458
|
+
if not isinstance(fit_subjects, list):
|
|
459
|
+
raise TypeError("state-model score fit_subjects must be an array")
|
|
460
|
+
report = StateModelScoreReport(
|
|
461
|
+
scores=tuple(scores),
|
|
462
|
+
format_version=version,
|
|
463
|
+
model_kind=model_kind,
|
|
464
|
+
model_seed=raw["model_seed"],
|
|
465
|
+
n_states=raw["n_states"],
|
|
466
|
+
fit_subjects=tuple(fit_subjects),
|
|
467
|
+
source_contract=raw["source_contract"],
|
|
468
|
+
sample_interval_seconds=raw["sample_interval_seconds"],
|
|
469
|
+
minimum_sequence_length=raw["minimum_sequence_length"],
|
|
470
|
+
omitted_short_sequence_count=raw["omitted_short_sequence_count"],
|
|
471
|
+
allow_fit_subjects=raw["allow_fit_subjects"],
|
|
472
|
+
model_specification=raw["model_specification"],
|
|
473
|
+
)
|
|
474
|
+
canonical = _state_model_scores_payload(
|
|
475
|
+
report.scores,
|
|
476
|
+
model_kind=report.model_kind,
|
|
477
|
+
model_seed=report.model_seed,
|
|
478
|
+
n_states=report.n_states,
|
|
479
|
+
fit_subjects=report.fit_subjects,
|
|
480
|
+
source_contract=report.source_contract,
|
|
481
|
+
sample_interval_seconds=report.sample_interval_seconds,
|
|
482
|
+
minimum_sequence_length=report.minimum_sequence_length,
|
|
483
|
+
omitted_short_sequence_count=report.omitted_short_sequence_count,
|
|
484
|
+
allow_fit_subjects=report.allow_fit_subjects,
|
|
485
|
+
model_specification=report.model_specification,
|
|
486
|
+
)
|
|
487
|
+
if raw != canonical:
|
|
488
|
+
raise ValueError("state-model score artifact summary or metadata is inconsistent")
|
|
489
|
+
return report
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def write_state_model_scores(
|
|
493
|
+
scores: Sequence[RunStateModelScore],
|
|
494
|
+
path: str | Path,
|
|
495
|
+
**metadata: object,
|
|
496
|
+
) -> Path:
|
|
497
|
+
"""Atomically write held-out state-model scores without JSON NaN values."""
|
|
498
|
+
target = Path(path)
|
|
499
|
+
payload = _state_model_scores_payload(scores, **metadata)
|
|
500
|
+
return write_json_atomic(target, payload)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
__all__ = [
|
|
504
|
+
"FORMAT_NAME",
|
|
505
|
+
"FORMAT_VERSION",
|
|
506
|
+
"StateModelScoreReport",
|
|
507
|
+
"load_state_model_scores",
|
|
508
|
+
"write_state_model_scores",
|
|
509
|
+
]
|