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.
Files changed (71) hide show
  1. dfc_kit-1.0.0.dist-info/METADATA +184 -0
  2. dfc_kit-1.0.0.dist-info/RECORD +71 -0
  3. dfc_kit-1.0.0.dist-info/WHEEL +5 -0
  4. dfc_kit-1.0.0.dist-info/entry_points.txt +2 -0
  5. dfc_kit-1.0.0.dist-info/licenses/LICENSE +29 -0
  6. dfc_kit-1.0.0.dist-info/top_level.txt +1 -0
  7. dfckit/__init__.py +11 -0
  8. dfckit/_arrays.py +13 -0
  9. dfckit/_preprocessing.py +46 -0
  10. dfckit/_validation.py +87 -0
  11. dfckit/artifacts/__init__.py +36 -0
  12. dfckit/artifacts/_fields.py +67 -0
  13. dfckit/artifacts/_json.py +92 -0
  14. dfckit/artifacts/_numpy.py +55 -0
  15. dfckit/artifacts/models.py +624 -0
  16. dfckit/artifacts/state_alignment.py +106 -0
  17. dfckit/artifacts/state_results.py +353 -0
  18. dfckit/artifacts/state_scoring.py +509 -0
  19. dfckit/artifacts/state_stability.py +207 -0
  20. dfckit/cli.py +105 -0
  21. dfckit/commands/__init__.py +1 -0
  22. dfckit/commands/parser.py +354 -0
  23. dfckit/commands/reporting.py +146 -0
  24. dfckit/commands/source.py +245 -0
  25. dfckit/commands/stability.py +288 -0
  26. dfckit/commands/states.py +266 -0
  27. dfckit/connectivity/__init__.py +68 -0
  28. dfckit/connectivity/_edge_products.py +130 -0
  29. dfckit/connectivity/correlation.py +61 -0
  30. dfckit/connectivity/instantaneous.py +253 -0
  31. dfckit/connectivity/leida.py +204 -0
  32. dfckit/connectivity/lowrank.py +384 -0
  33. dfckit/connectivity/partition.py +291 -0
  34. dfckit/connectivity/windows.py +83 -0
  35. dfckit/data.py +229 -0
  36. dfckit/inference/__init__.py +55 -0
  37. dfckit/inference/endpoints.py +194 -0
  38. dfckit/inference/hc3.py +207 -0
  39. dfckit/inference/matching.py +300 -0
  40. dfckit/inference/multiple_testing.py +52 -0
  41. dfckit/inference/nbs.py +586 -0
  42. dfckit/inference/paired.py +157 -0
  43. dfckit/inference/state_metrics.py +277 -0
  44. dfckit/information/__init__.py +45 -0
  45. dfckit/information/_artifact.py +440 -0
  46. dfckit/information/estimators.py +536 -0
  47. dfckit/information/fixed.py +660 -0
  48. dfckit/information/summary.py +89 -0
  49. dfckit/io/__init__.py +23 -0
  50. dfckit/io/xcpd.py +470 -0
  51. dfckit/reference.py +350 -0
  52. dfckit/segments.py +44 -0
  53. dfckit/states/__init__.py +97 -0
  54. dfckit/states/alignment.py +364 -0
  55. dfckit/states/cap.py +62 -0
  56. dfckit/states/cross_validation.py +114 -0
  57. dfckit/states/data.py +387 -0
  58. dfckit/states/hmm.py +387 -0
  59. dfckit/states/interpretation.py +273 -0
  60. dfckit/states/kmeans.py +340 -0
  61. dfckit/states/metrics.py +94 -0
  62. dfckit/states/scoring.py +114 -0
  63. dfckit/states/selection.py +516 -0
  64. dfckit/states/stability.py +183 -0
  65. dfckit/states/streaming.py +859 -0
  66. dfckit/states/streaming_hmm.py +436 -0
  67. dfckit/storage/__init__.py +29 -0
  68. dfckit/storage/_statistics.py +80 -0
  69. dfckit/storage/builders.py +330 -0
  70. dfckit/storage/store.py +672 -0
  71. dfckit/storage/summary.py +140 -0
@@ -0,0 +1,55 @@
1
+ """Shared writer for directory artifacts containing JSON and NumPy arrays."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import tempfile
8
+ from collections.abc import Callable, Mapping
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ from numpy.typing import NDArray
13
+
14
+ from ._json import write_json_atomic
15
+
16
+
17
+ def write_numpy_artifact(
18
+ target: str | Path,
19
+ manifest: Mapping[str, object],
20
+ arrays: Mapping[str, NDArray],
21
+ *,
22
+ label: str,
23
+ validate: Callable[[Path], object] | None = None,
24
+ ) -> Path:
25
+ """Atomically create a new ``manifest.json`` plus ``arrays.npz`` directory.
26
+
27
+ Format-specific loaders remain responsible for schema validation. ``validate``
28
+ is an optional round-trip check run against the temporary directory before it
29
+ becomes visible at ``target``.
30
+ """
31
+ destination = Path(target)
32
+ if destination.exists() or destination.is_symlink():
33
+ raise FileExistsError(f"{label} artifact path already exists: {destination}")
34
+ destination.parent.mkdir(parents=True, exist_ok=True)
35
+ normalized = {name: np.asarray(value) for name, value in arrays.items()}
36
+ if not normalized or any(not isinstance(name, str) or not name for name in normalized):
37
+ raise ValueError(f"{label} artifact arrays must have non-empty names")
38
+ if any(array.dtype.hasobject or not np.isfinite(array).all() for array in normalized.values()):
39
+ raise ValueError(f"{label} artifact arrays must be finite numeric arrays")
40
+ temporary = Path(tempfile.mkdtemp(prefix=f".{destination.name}.tmp-", dir=destination.parent))
41
+ try:
42
+ np.savez(temporary / "arrays.npz", **normalized)
43
+ write_json_atomic(temporary / "manifest.json", dict(manifest))
44
+ if validate is not None:
45
+ validate(temporary)
46
+ if destination.exists() or destination.is_symlink():
47
+ raise FileExistsError(f"{label} artifact path already exists: {destination}")
48
+ os.rename(temporary, destination)
49
+ except BaseException:
50
+ shutil.rmtree(temporary, ignore_errors=True)
51
+ raise
52
+ return destination
53
+
54
+
55
+ __all__ = ["write_numpy_artifact"]
@@ -0,0 +1,624 @@
1
+ """Portable, pickle-free persistence for fitted dfc-kit models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import TypeAlias
8
+
9
+ import numpy as np
10
+ from numpy.typing import NDArray
11
+
12
+ from ..states.hmm import GaussianHMMStateModel
13
+ from ..states.kmeans import KMeansStateModel
14
+ from ..states.streaming import StreamingPCAModel
15
+ from ._json import load_json_object
16
+ from ._numpy import write_numpy_artifact
17
+
18
+ FORMAT_NAME = "dfckit-fitted-model"
19
+ FORMAT_VERSION = 4
20
+
21
+ FittedModel: TypeAlias = KMeansStateModel | GaussianHMMStateModel | StreamingPCAModel
22
+
23
+ _KMEANS_ARRAYS = {
24
+ "centers",
25
+ "clustering_centers",
26
+ "feature_mean",
27
+ "feature_scale",
28
+ "pca_components",
29
+ "pca_explained_variance_ratio",
30
+ "pca_mean",
31
+ "standardized_centers",
32
+ }
33
+ _KMEANS_METADATA = {
34
+ "algorithm",
35
+ "batch_size",
36
+ "feature_keys",
37
+ "fit_sample_count",
38
+ "fit_subjects",
39
+ "implementation",
40
+ "init_sample_size",
41
+ "inertia",
42
+ "iterations",
43
+ "max_iter",
44
+ "n_init",
45
+ "n_pca_components",
46
+ "pca_batch_size",
47
+ "n_states",
48
+ "reassignment_ratio",
49
+ "sample_interval_seconds",
50
+ "seed",
51
+ "source_contract",
52
+ "standardize_features",
53
+ }
54
+ _PCA_ARRAYS = {
55
+ "explained_variance_ratio",
56
+ "feature_mean",
57
+ "feature_scale",
58
+ "pca_components",
59
+ "pca_mean",
60
+ }
61
+ _PCA_METADATA = {
62
+ "batch_size",
63
+ "feature_keys",
64
+ "fit_sample_count",
65
+ "fit_sequence_count",
66
+ "fit_subjects",
67
+ "implementation",
68
+ "n_components",
69
+ "sample_interval_seconds",
70
+ "source_contract",
71
+ "standardize_features",
72
+ }
73
+ _HMM_REQUIRED_ARRAYS = {
74
+ "emission_means",
75
+ "feature_mean",
76
+ "feature_scale",
77
+ "initialization_log_likelihoods",
78
+ "pca_components",
79
+ "pca_explained_variance_ratio",
80
+ "pca_mean",
81
+ "reduced_covariances",
82
+ "reduced_means",
83
+ "start_probabilities",
84
+ "transition_matrix",
85
+ }
86
+ _HMM_METADATA = {
87
+ "converged",
88
+ "covariance_type",
89
+ "feature_keys",
90
+ "fit_sample_count",
91
+ "fit_sequence_count",
92
+ "fit_subjects",
93
+ "implementation",
94
+ "initialization_seeds",
95
+ "iterations",
96
+ "log_likelihood",
97
+ "minimum_sequence_length",
98
+ "n_init",
99
+ "n_iter",
100
+ "n_pca_components",
101
+ "n_states",
102
+ "omitted_short_sequence_count",
103
+ "pca_batch_size",
104
+ "sample_interval_seconds",
105
+ "seed",
106
+ "selected_initialization",
107
+ "source_contract",
108
+ "tol",
109
+ }
110
+
111
+
112
+ def _readonly_float(value: object, name: str) -> NDArray[np.float64]:
113
+ array = np.array(value, dtype=np.float64, copy=True)
114
+ if not np.isfinite(array).all():
115
+ raise ValueError(f"model array {name!r} contains non-finite values")
116
+ array.setflags(write=False)
117
+ return array
118
+
119
+
120
+ def _metadata_fields(metadata: object, expected: set[str]) -> dict[str, object]:
121
+ if not isinstance(metadata, dict):
122
+ raise TypeError("model artifact metadata must be a JSON object")
123
+ observed = set(metadata)
124
+ if observed != expected:
125
+ missing = sorted(expected - observed)
126
+ unexpected = sorted(observed - expected)
127
+ raise ValueError(
128
+ "model artifact metadata fields do not match the schema; "
129
+ f"missing={missing}, unexpected={unexpected}"
130
+ )
131
+ return metadata
132
+
133
+
134
+ def _integer(metadata: Mapping[str, object], name: str, *, minimum: int = 0) -> int:
135
+ value = metadata[name]
136
+ if isinstance(value, bool) or not isinstance(value, int):
137
+ raise TypeError(f"model metadata {name!r} must be an integer")
138
+ if value < minimum:
139
+ raise ValueError(f"model metadata {name!r} must be at least {minimum}")
140
+ return value
141
+
142
+
143
+ def _finite_float(metadata: Mapping[str, object], name: str, *, positive: bool = False) -> float:
144
+ value = metadata[name]
145
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
146
+ raise TypeError(f"model metadata {name!r} must be numeric")
147
+ output = float(value)
148
+ if not np.isfinite(output) or (positive and output <= 0.0):
149
+ qualifier = "finite and positive" if positive else "finite"
150
+ raise ValueError(f"model metadata {name!r} must be {qualifier}")
151
+ return output
152
+
153
+
154
+ def _optional_float(metadata: Mapping[str, object], name: str) -> float | None:
155
+ return None if metadata[name] is None else _finite_float(metadata, name)
156
+
157
+
158
+ def _optional_integer(
159
+ metadata: Mapping[str, object],
160
+ name: str,
161
+ *,
162
+ minimum: int = 0,
163
+ ) -> int | None:
164
+ return None if metadata[name] is None else _integer(metadata, name, minimum=minimum)
165
+
166
+
167
+ def _boolean(metadata: Mapping[str, object], name: str) -> bool:
168
+ value = metadata[name]
169
+ if not isinstance(value, bool):
170
+ raise TypeError(f"model metadata {name!r} must be boolean")
171
+ return value
172
+
173
+
174
+ def _string(metadata: Mapping[str, object], name: str) -> str:
175
+ value = metadata[name]
176
+ if not isinstance(value, str) or not value.strip():
177
+ raise ValueError(f"model metadata {name!r} must be a non-empty string")
178
+ return value
179
+
180
+
181
+ def _string_tuple(metadata: Mapping[str, object], name: str) -> tuple[str, ...]:
182
+ value = metadata[name]
183
+ if not isinstance(value, list) or not value:
184
+ raise TypeError(f"model metadata {name!r} must be a non-empty JSON list")
185
+ output = tuple(value)
186
+ if any(not isinstance(item, str) or not item.strip() for item in output):
187
+ raise ValueError(f"model metadata {name!r} contains an invalid string")
188
+ if len(set(output)) != len(output):
189
+ raise ValueError(f"model metadata {name!r} contains duplicates")
190
+ return output
191
+
192
+
193
+ def _integer_tuple(metadata: Mapping[str, object], name: str) -> tuple[int, ...]:
194
+ value = metadata[name]
195
+ if not isinstance(value, list) or not value:
196
+ raise TypeError(f"model metadata {name!r} must be a non-empty JSON list")
197
+ if any(isinstance(item, bool) or not isinstance(item, int) or item < 0 for item in value):
198
+ raise ValueError(f"model metadata {name!r} contains an invalid integer")
199
+ return tuple(value)
200
+
201
+
202
+ def _feature_keys(metadata: Mapping[str, object]) -> tuple[tuple[str, ...], ...]:
203
+ value = metadata["feature_keys"]
204
+ if not isinstance(value, list) or not value:
205
+ raise TypeError("model metadata 'feature_keys' must be a non-empty JSON list")
206
+ output = []
207
+ for key in value:
208
+ if not isinstance(key, list) or not key or any(not isinstance(part, str) for part in key):
209
+ raise ValueError("model metadata 'feature_keys' contains an invalid key")
210
+ output.append(tuple(key))
211
+ result = tuple(output)
212
+ if len(set(result)) != len(result):
213
+ raise ValueError("model metadata 'feature_keys' contains duplicates")
214
+ return result
215
+
216
+
217
+ def _sample_interval(metadata: Mapping[str, object]) -> float | None:
218
+ value = _optional_float(metadata, "sample_interval_seconds")
219
+ if value is not None and value <= 0.0:
220
+ raise ValueError("model sample interval must be positive")
221
+ return value
222
+
223
+
224
+ def _array_payload(model: FittedModel) -> tuple[str, dict[str, object], dict[str, NDArray]]:
225
+ if not isinstance(model, (KMeansStateModel, GaussianHMMStateModel, StreamingPCAModel)):
226
+ raise TypeError(f"unsupported fitted model type: {type(model).__name__}")
227
+ common = {
228
+ "feature_keys": [list(key) for key in model.feature_keys],
229
+ "source_contract": str(model.source_contract),
230
+ "sample_interval_seconds": model.sample_interval_seconds,
231
+ "fit_subjects": list(model.fit_subjects),
232
+ "fit_sample_count": int(model.fit_sample_count),
233
+ "implementation": str(model.implementation),
234
+ }
235
+ if isinstance(model, KMeansStateModel):
236
+ metadata = {
237
+ **common,
238
+ "n_states": int(model.n_states),
239
+ "seed": int(model.seed),
240
+ "n_init": int(model.n_init),
241
+ "max_iter": int(model.max_iter),
242
+ "algorithm": str(model.algorithm),
243
+ "standardize_features": bool(model.standardize_features),
244
+ "n_pca_components": (
245
+ None
246
+ if model.n_pca_components is None
247
+ else int(model.n_pca_components)
248
+ ),
249
+ "pca_batch_size": (
250
+ None if model.pca_batch_size is None else int(model.pca_batch_size)
251
+ ),
252
+ "batch_size": None if model.batch_size is None else int(model.batch_size),
253
+ "reassignment_ratio": (
254
+ None if model.reassignment_ratio is None else float(model.reassignment_ratio)
255
+ ),
256
+ "init_sample_size": (
257
+ None if model.init_sample_size is None else int(model.init_sample_size)
258
+ ),
259
+ "iterations": int(model.iterations),
260
+ "inertia": float(model.inertia),
261
+ }
262
+ arrays = {name: np.asarray(getattr(model, name)) for name in _KMEANS_ARRAYS}
263
+ return "kmeans-state", metadata, arrays
264
+ if isinstance(model, GaussianHMMStateModel):
265
+ metadata = {
266
+ **common,
267
+ "n_states": int(model.n_states),
268
+ "n_pca_components": int(model.n_pca_components),
269
+ "covariance_type": str(model.covariance_type),
270
+ "seed": int(model.seed),
271
+ "n_init": int(model.n_init),
272
+ "n_iter": int(model.n_iter),
273
+ "tol": float(model.tol),
274
+ "minimum_sequence_length": int(model.minimum_sequence_length),
275
+ "pca_batch_size": (
276
+ None if model.pca_batch_size is None else int(model.pca_batch_size)
277
+ ),
278
+ "selected_initialization": int(model.selected_initialization),
279
+ "initialization_seeds": [int(value) for value in model.initialization_seeds],
280
+ "iterations": int(model.iterations),
281
+ "converged": bool(model.converged),
282
+ "log_likelihood": float(model.log_likelihood),
283
+ "fit_sequence_count": int(model.fit_sequence_count),
284
+ "omitted_short_sequence_count": int(model.omitted_short_sequence_count),
285
+ }
286
+ arrays = {
287
+ name: np.asarray(getattr(model, name)) for name in _HMM_REQUIRED_ARRAYS
288
+ }
289
+ if model.emission_covariances is not None:
290
+ arrays["emission_covariances"] = np.asarray(model.emission_covariances)
291
+ return "gaussian-hmm-state", metadata, arrays
292
+ if isinstance(model, StreamingPCAModel):
293
+ metadata = {
294
+ **common,
295
+ "n_components": int(model.n_components),
296
+ "standardize_features": bool(model.standardize_features),
297
+ "batch_size": int(model.batch_size),
298
+ "fit_sequence_count": int(model.fit_sequence_count),
299
+ }
300
+ arrays = {name: np.asarray(getattr(model, name)) for name in _PCA_ARRAYS}
301
+ return "streaming-pca", metadata, arrays
302
+ raise TypeError(f"unsupported fitted model type: {type(model).__name__}")
303
+
304
+
305
+ def save_fitted_model(model: FittedModel, path: str | Path) -> Path:
306
+ """Write one fitted model as JSON metadata plus pickle-free NumPy arrays."""
307
+ kind, metadata, arrays = _array_payload(model)
308
+ return write_numpy_artifact(
309
+ path,
310
+ {
311
+ "format": FORMAT_NAME,
312
+ "format_version": FORMAT_VERSION,
313
+ "model_kind": kind,
314
+ "array_names": sorted(arrays),
315
+ "metadata": metadata,
316
+ },
317
+ arrays,
318
+ label="model",
319
+ )
320
+
321
+
322
+ def _load_manifest(root: Path) -> tuple[str, dict[str, object], dict[str, NDArray[np.float64]]]:
323
+ if not root.is_dir():
324
+ raise FileNotFoundError(f"model artifact directory does not exist: {root}")
325
+ manifest_path = root / "manifest.json"
326
+ arrays_path = root / "arrays.npz"
327
+ if not manifest_path.is_file() or not arrays_path.is_file():
328
+ raise FileNotFoundError("model artifact requires manifest.json and arrays.npz")
329
+ manifest = load_json_object(manifest_path, context="model artifact manifest")
330
+ expected_fields = {"array_names", "format", "format_version", "metadata", "model_kind"}
331
+ if set(manifest) != expected_fields:
332
+ raise ValueError("model artifact manifest fields do not match the schema")
333
+ if manifest["format"] != FORMAT_NAME:
334
+ raise ValueError("not a dfc-kit fitted-model artifact")
335
+ version = manifest["format_version"]
336
+ if (
337
+ isinstance(version, bool)
338
+ or not isinstance(version, int)
339
+ or version != FORMAT_VERSION
340
+ ):
341
+ raise ValueError(f"unsupported model artifact format version: {manifest['format_version']!r}")
342
+ kind = manifest["model_kind"]
343
+ if kind not in {"kmeans-state", "gaussian-hmm-state", "streaming-pca"}:
344
+ raise ValueError(f"unsupported fitted model kind: {kind!r}")
345
+ names = manifest["array_names"]
346
+ if (
347
+ not isinstance(names, list)
348
+ or any(not isinstance(name, str) or not name for name in names)
349
+ or len(set(names)) != len(names)
350
+ ):
351
+ raise ValueError("model artifact array_names is invalid")
352
+ try:
353
+ with np.load(arrays_path, allow_pickle=False) as archive:
354
+ if set(archive.files) != set(names):
355
+ raise ValueError("model artifact array archive does not match its manifest")
356
+ arrays = {name: _readonly_float(archive[name], name) for name in names}
357
+ except (OSError, ValueError) as error:
358
+ if isinstance(error, ValueError) and str(error).startswith("model artifact"):
359
+ raise
360
+ raise ValueError(f"cannot read model artifact arrays: {error}") from error
361
+ metadata = manifest["metadata"]
362
+ if not isinstance(metadata, dict):
363
+ raise TypeError("model artifact metadata must be a JSON object")
364
+ return kind, metadata, arrays
365
+
366
+
367
+ def _require_arrays(
368
+ arrays: Mapping[str, NDArray[np.float64]],
369
+ expected: set[str],
370
+ ) -> None:
371
+ observed = set(arrays)
372
+ if observed != expected:
373
+ raise ValueError(
374
+ "model artifact arrays do not match the model schema; "
375
+ f"missing={sorted(expected - observed)}, unexpected={sorted(observed - expected)}"
376
+ )
377
+
378
+
379
+ def _load_kmeans(
380
+ metadata_value: object,
381
+ arrays: dict[str, NDArray[np.float64]],
382
+ ) -> KMeansStateModel:
383
+ metadata = _metadata_fields(metadata_value, _KMEANS_METADATA)
384
+ _require_arrays(arrays, _KMEANS_ARRAYS)
385
+ keys = _feature_keys(metadata)
386
+ n_states = _integer(metadata, "n_states", minimum=2)
387
+ n_pca_components = _optional_integer(metadata, "n_pca_components", minimum=1)
388
+ cluster_width = len(keys) if n_pca_components is None else n_pca_components
389
+ shape = (n_states, len(keys))
390
+ if arrays["centers"].shape != shape or arrays["standardized_centers"].shape != shape:
391
+ raise ValueError("KMeans artifact centers have an invalid shape")
392
+ if arrays["clustering_centers"].shape != (n_states, cluster_width):
393
+ raise ValueError("KMeans artifact clustering_centers have an invalid shape")
394
+ for name in ("feature_mean", "feature_scale"):
395
+ if arrays[name].shape != (len(keys),):
396
+ raise ValueError(f"KMeans artifact {name} has an invalid shape")
397
+ if np.any(arrays["feature_scale"] <= 0.0):
398
+ raise ValueError("KMeans artifact feature_scale must be positive")
399
+ expected_pca_shapes = (
400
+ ((0, len(keys)), (0,))
401
+ if n_pca_components is None
402
+ else ((n_pca_components, len(keys)), (n_pca_components,))
403
+ )
404
+ if (
405
+ arrays["pca_mean"].shape != (len(keys),)
406
+ or arrays["pca_components"].shape != expected_pca_shapes[0]
407
+ or arrays["pca_explained_variance_ratio"].shape != expected_pca_shapes[1]
408
+ ):
409
+ raise ValueError("KMeans artifact PCA arrays have invalid shapes")
410
+ algorithm = _string(metadata, "algorithm")
411
+ if algorithm not in {"lloyd", "minibatch"}:
412
+ raise ValueError("KMeans artifact algorithm is invalid")
413
+ batch_size = _optional_integer(metadata, "batch_size", minimum=1)
414
+ reassignment_ratio = _optional_float(metadata, "reassignment_ratio")
415
+ init_sample_size = _optional_integer(metadata, "init_sample_size", minimum=1)
416
+ if algorithm == "lloyd" and (batch_size is not None or reassignment_ratio is not None):
417
+ raise ValueError("Lloyd KMeans artifact cannot contain mini-batch parameters")
418
+ if algorithm == "lloyd" and init_sample_size is not None:
419
+ raise ValueError("Lloyd KMeans artifact cannot contain init_sample_size")
420
+ if algorithm == "minibatch" and (batch_size is None or reassignment_ratio is None):
421
+ raise ValueError("MiniBatchKMeans artifact requires batch parameters")
422
+ if reassignment_ratio is not None and not 0.0 <= reassignment_ratio <= 1.0:
423
+ raise ValueError("KMeans artifact reassignment_ratio must be within [0, 1]")
424
+ inertia = _finite_float(metadata, "inertia")
425
+ if inertia < 0.0:
426
+ raise ValueError("KMeans artifact inertia must be non-negative")
427
+ fit_sample_count = _integer(metadata, "fit_sample_count", minimum=n_states)
428
+ return KMeansStateModel(
429
+ centers=arrays["centers"],
430
+ standardized_centers=arrays["standardized_centers"],
431
+ clustering_centers=arrays["clustering_centers"],
432
+ feature_mean=arrays["feature_mean"],
433
+ feature_scale=arrays["feature_scale"],
434
+ pca_mean=arrays["pca_mean"],
435
+ pca_components=arrays["pca_components"],
436
+ pca_explained_variance_ratio=arrays["pca_explained_variance_ratio"],
437
+ feature_keys=keys,
438
+ source_contract=_string(metadata, "source_contract"),
439
+ sample_interval_seconds=_sample_interval(metadata),
440
+ n_states=n_states,
441
+ seed=_integer(metadata, "seed"),
442
+ n_init=_integer(metadata, "n_init", minimum=1),
443
+ max_iter=_integer(metadata, "max_iter", minimum=1),
444
+ algorithm=algorithm,
445
+ standardize_features=_boolean(metadata, "standardize_features"),
446
+ n_pca_components=n_pca_components,
447
+ pca_batch_size=_optional_integer(metadata, "pca_batch_size", minimum=1),
448
+ batch_size=batch_size,
449
+ reassignment_ratio=reassignment_ratio,
450
+ init_sample_size=init_sample_size,
451
+ iterations=_integer(metadata, "iterations", minimum=1),
452
+ inertia=inertia,
453
+ fit_subjects=_string_tuple(metadata, "fit_subjects"),
454
+ fit_sample_count=fit_sample_count,
455
+ implementation=_string(metadata, "implementation"),
456
+ )
457
+
458
+
459
+ def _load_pca(
460
+ metadata_value: object,
461
+ arrays: dict[str, NDArray[np.float64]],
462
+ ) -> StreamingPCAModel:
463
+ metadata = _metadata_fields(metadata_value, _PCA_METADATA)
464
+ _require_arrays(arrays, _PCA_ARRAYS)
465
+ keys = _feature_keys(metadata)
466
+ n_components = _integer(metadata, "n_components", minimum=1)
467
+ if n_components > len(keys):
468
+ raise ValueError("Streaming PCA component count exceeds its feature count")
469
+ if np.any(arrays["feature_scale"] <= 0.0):
470
+ raise ValueError("Streaming PCA artifact feature_scale must be positive")
471
+ explained = arrays["explained_variance_ratio"]
472
+ if np.any(explained < 0.0) or explained.sum() > 1.0 + 1e-7:
473
+ raise ValueError("Streaming PCA explained variance ratios are invalid")
474
+ return StreamingPCAModel(
475
+ feature_mean=arrays["feature_mean"],
476
+ feature_scale=arrays["feature_scale"],
477
+ pca_mean=arrays["pca_mean"],
478
+ pca_components=arrays["pca_components"],
479
+ explained_variance_ratio=arrays["explained_variance_ratio"],
480
+ feature_keys=keys,
481
+ source_contract=_string(metadata, "source_contract"),
482
+ sample_interval_seconds=_sample_interval(metadata),
483
+ n_components=n_components,
484
+ standardize_features=_boolean(metadata, "standardize_features"),
485
+ batch_size=_integer(metadata, "batch_size", minimum=1),
486
+ fit_subjects=_string_tuple(metadata, "fit_subjects"),
487
+ fit_sample_count=_integer(metadata, "fit_sample_count", minimum=1),
488
+ fit_sequence_count=_integer(metadata, "fit_sequence_count", minimum=1),
489
+ implementation=_string(metadata, "implementation"),
490
+ )
491
+
492
+
493
+ def _load_hmm(
494
+ metadata_value: object,
495
+ arrays: dict[str, NDArray[np.float64]],
496
+ ) -> GaussianHMMStateModel:
497
+ metadata = _metadata_fields(metadata_value, _HMM_METADATA)
498
+ expected = set(_HMM_REQUIRED_ARRAYS)
499
+ if "emission_covariances" in arrays:
500
+ expected.add("emission_covariances")
501
+ _require_arrays(arrays, expected)
502
+ keys = _feature_keys(metadata)
503
+ n_features = len(keys)
504
+ n_states = _integer(metadata, "n_states", minimum=2)
505
+ n_components = _integer(metadata, "n_pca_components", minimum=1)
506
+ if n_components > n_features:
507
+ raise ValueError("Gaussian HMM PCA component count exceeds its feature count")
508
+ n_init = _integer(metadata, "n_init", minimum=1)
509
+ shapes = {
510
+ "start_probabilities": (n_states,),
511
+ "transition_matrix": (n_states, n_states),
512
+ "reduced_means": (n_states, n_components),
513
+ "reduced_covariances": (n_states, n_components, n_components),
514
+ "emission_means": (n_states, n_features),
515
+ "feature_mean": (n_features,),
516
+ "feature_scale": (n_features,),
517
+ "pca_mean": (n_features,),
518
+ "pca_components": (n_components, n_features),
519
+ "pca_explained_variance_ratio": (n_components,),
520
+ "initialization_log_likelihoods": (n_init,),
521
+ }
522
+ if "emission_covariances" in arrays:
523
+ shapes["emission_covariances"] = (n_states, n_features, n_features)
524
+ for name, shape in shapes.items():
525
+ if arrays[name].shape != shape:
526
+ raise ValueError(f"Gaussian HMM artifact {name} has an invalid shape")
527
+ start = arrays["start_probabilities"]
528
+ transition = arrays["transition_matrix"]
529
+ if np.any(start < 0.0) or not np.isclose(start.sum(), 1.0, atol=1e-7):
530
+ raise ValueError("Gaussian HMM artifact start probabilities are invalid")
531
+ if np.any(transition < 0.0) or not np.allclose(
532
+ transition.sum(axis=1), 1.0, atol=1e-7
533
+ ):
534
+ raise ValueError("Gaussian HMM artifact transition matrix is invalid")
535
+ if np.any(arrays["feature_scale"] <= 0.0):
536
+ raise ValueError("Gaussian HMM artifact feature_scale must be positive")
537
+ explained = arrays["pca_explained_variance_ratio"]
538
+ if np.any(explained < 0.0) or explained.sum() > 1.0 + 1e-7:
539
+ raise ValueError("Gaussian HMM PCA explained variance ratios are invalid")
540
+ covariance_type = _string(metadata, "covariance_type")
541
+ if covariance_type not in {"diag", "full"}:
542
+ raise ValueError("Gaussian HMM artifact covariance_type is invalid")
543
+ reduced_covariances = arrays["reduced_covariances"]
544
+ if not np.allclose(
545
+ reduced_covariances,
546
+ np.swapaxes(reduced_covariances, 1, 2),
547
+ rtol=0.0,
548
+ atol=1e-10,
549
+ ):
550
+ raise ValueError("Gaussian HMM reduced covariances must be symmetric")
551
+ reduced_diagonal = np.diagonal(reduced_covariances, axis1=1, axis2=2)
552
+ if np.any(reduced_diagonal <= 0.0):
553
+ raise ValueError("Gaussian HMM reduced covariance diagonal must be positive")
554
+ if covariance_type == "diag":
555
+ diagonal_matrices = np.zeros_like(reduced_covariances)
556
+ diagonal_indices = np.arange(n_components)
557
+ diagonal_matrices[:, diagonal_indices, diagonal_indices] = reduced_diagonal
558
+ if not np.allclose(reduced_covariances, diagonal_matrices, rtol=0.0, atol=1e-10):
559
+ raise ValueError("diagonal Gaussian HMM covariances contain off-diagonal values")
560
+ elif np.any(np.linalg.eigvalsh(reduced_covariances) <= 0.0):
561
+ raise ValueError("full Gaussian HMM reduced covariances must be positive definite")
562
+ emission_covariances = arrays.get("emission_covariances")
563
+ if emission_covariances is not None and (
564
+ not np.allclose(
565
+ emission_covariances,
566
+ np.swapaxes(emission_covariances, 1, 2),
567
+ rtol=0.0,
568
+ atol=1e-9,
569
+ )
570
+ or np.any(np.diagonal(emission_covariances, axis1=1, axis2=2) < -1e-10)
571
+ ):
572
+ raise ValueError("Gaussian HMM emission covariances are invalid")
573
+ initialization_seeds = _integer_tuple(metadata, "initialization_seeds")
574
+ if len(initialization_seeds) != n_init:
575
+ raise ValueError("Gaussian HMM initialization seed count does not match n_init")
576
+ selected = _integer(metadata, "selected_initialization")
577
+ if selected >= n_init:
578
+ raise ValueError("Gaussian HMM selected initialization is out of range")
579
+ return GaussianHMMStateModel(
580
+ start_probabilities=start,
581
+ transition_matrix=transition,
582
+ reduced_means=arrays["reduced_means"],
583
+ reduced_covariances=reduced_covariances,
584
+ emission_means=arrays["emission_means"],
585
+ emission_covariances=emission_covariances,
586
+ feature_mean=arrays["feature_mean"],
587
+ feature_scale=arrays["feature_scale"],
588
+ pca_mean=arrays["pca_mean"],
589
+ pca_components=arrays["pca_components"],
590
+ pca_explained_variance_ratio=arrays["pca_explained_variance_ratio"],
591
+ feature_keys=keys,
592
+ source_contract=_string(metadata, "source_contract"),
593
+ sample_interval_seconds=_sample_interval(metadata),
594
+ n_states=n_states,
595
+ n_pca_components=n_components,
596
+ covariance_type=covariance_type,
597
+ seed=_integer(metadata, "seed"),
598
+ n_init=n_init,
599
+ n_iter=_integer(metadata, "n_iter", minimum=1),
600
+ tol=_finite_float(metadata, "tol", positive=True),
601
+ minimum_sequence_length=_integer(metadata, "minimum_sequence_length", minimum=1),
602
+ pca_batch_size=_optional_integer(metadata, "pca_batch_size", minimum=1),
603
+ selected_initialization=selected,
604
+ initialization_seeds=initialization_seeds,
605
+ initialization_log_likelihoods=arrays["initialization_log_likelihoods"],
606
+ iterations=_integer(metadata, "iterations", minimum=1),
607
+ converged=_boolean(metadata, "converged"),
608
+ log_likelihood=_finite_float(metadata, "log_likelihood"),
609
+ fit_subjects=_string_tuple(metadata, "fit_subjects"),
610
+ fit_sample_count=_integer(metadata, "fit_sample_count", minimum=n_states),
611
+ fit_sequence_count=_integer(metadata, "fit_sequence_count", minimum=1),
612
+ omitted_short_sequence_count=_integer(metadata, "omitted_short_sequence_count"),
613
+ implementation=_string(metadata, "implementation"),
614
+ )
615
+
616
+
617
+ def load_fitted_model(path: str | Path) -> FittedModel:
618
+ """Load and validate one model written by :func:`save_fitted_model`."""
619
+ kind, metadata, arrays = _load_manifest(Path(path))
620
+ if kind == "kmeans-state":
621
+ return _load_kmeans(metadata, arrays)
622
+ if kind == "streaming-pca":
623
+ return _load_pca(metadata, arrays)
624
+ return _load_hmm(metadata, arrays)