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,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
+ ]