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,146 @@
1
+ """Command handlers that summarize, interpret, or infer from existing artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from ..artifacts import load_state_model_scores
8
+ from ..inference.endpoints import (
9
+ _infer_paired_endpoints_file,
10
+ _write_paired_endpoint_inference,
11
+ )
12
+ from ..inference.state_metrics import (
13
+ _infer_paired_state_metrics_file,
14
+ _write_paired_state_inference,
15
+ )
16
+ from ..information.summary import summarize_information_artifact, write_information_summary
17
+ from ..states import compare_state_model_scores
18
+ from ..states.interpretation import (
19
+ describe_state_artifacts,
20
+ write_state_description,
21
+ )
22
+ from ..states.selection import _write_state_count_comparison
23
+ from ..storage.summary import _summarize_store_file, _write_store_summary
24
+
25
+
26
+ def describe_states(namespace: argparse.Namespace) -> dict[str, object]:
27
+ """Describe named features that distinguish fitted state patterns."""
28
+ payload = describe_state_artifacts(
29
+ namespace.store,
30
+ namespace.model,
31
+ top_features=namespace.top_features,
32
+ network_map_path=namespace.network_map,
33
+ )
34
+ output = write_state_description(payload, namespace.output)
35
+ return {
36
+ "output": str(output),
37
+ "model_kind": payload["model_kind"],
38
+ "feature_type": payload["feature_type"],
39
+ "n_states": payload["n_states"],
40
+ "n_features": payload["n_features"],
41
+ "network_map_applied": payload["network_map_applied"],
42
+ }
43
+
44
+
45
+ def infer_state_metrics(namespace: argparse.Namespace) -> dict[str, object]:
46
+ """Run paired inference over selected state metrics."""
47
+ payload = _infer_paired_state_metrics_file(
48
+ namespace.metrics,
49
+ condition_a=namespace.condition_a,
50
+ condition_b=namespace.condition_b,
51
+ metrics=tuple(namespace.metric),
52
+ fdr_family=namespace.fdr_family,
53
+ alpha=namespace.alpha,
54
+ n_permutations=namespace.permutations,
55
+ n_bootstrap=namespace.bootstrap,
56
+ seed=namespace.seed,
57
+ exact=namespace.exact,
58
+ )
59
+ output = _write_paired_state_inference(payload, namespace.output)
60
+ counts = {
61
+ status: sum(result["result_status"] == status for result in payload["results"])
62
+ for status in ("positive", "negative", "not_testable")
63
+ }
64
+ return {
65
+ "output": str(output),
66
+ "contrast": payload["contrast"],
67
+ "fdr_family": payload["fdr_family"],
68
+ "n_endpoints": payload["n_endpoints"],
69
+ "n_tested": payload["n_tested"],
70
+ "status_counts": counts,
71
+ }
72
+
73
+
74
+ def summarize_store(namespace: argparse.Namespace) -> dict[str, object]:
75
+ """Write acquisition-level feature statistics from a FeatureStore."""
76
+ statistics = tuple(namespace.statistic or ("mean",))
77
+ payload = _summarize_store_file(namespace.store, statistics)
78
+ output = _write_store_summary(payload, namespace.output)
79
+ return {
80
+ "output": str(output),
81
+ "source_contract": payload["source_contract"],
82
+ "feature_type": payload["feature_type"],
83
+ "n_features": payload["n_features"],
84
+ "n_acquisitions": payload["n_acquisitions"],
85
+ "statistics": payload["statistics"],
86
+ }
87
+
88
+
89
+ def infer_endpoints(namespace: argparse.Namespace) -> dict[str, object]:
90
+ """Run paired inference over named acquisition-level endpoints."""
91
+ payload = _infer_paired_endpoints_file(
92
+ namespace.endpoints,
93
+ condition_a=namespace.condition_a,
94
+ condition_b=namespace.condition_b,
95
+ fdr_family=namespace.fdr_family,
96
+ alpha=namespace.alpha,
97
+ n_permutations=namespace.permutations,
98
+ n_bootstrap=namespace.bootstrap,
99
+ seed=namespace.seed,
100
+ exact=namespace.exact,
101
+ )
102
+ output = _write_paired_endpoint_inference(payload, namespace.output)
103
+ return {
104
+ "output": str(output),
105
+ "contrast": payload["contrast"],
106
+ "fdr_family": payload["fdr_family"],
107
+ "n_endpoints": payload["n_endpoints"],
108
+ "n_tested": payload["n_tested"],
109
+ }
110
+
111
+
112
+ def summarize_information(namespace: argparse.Namespace) -> dict[str, object]:
113
+ """Extract acquisition-level endpoints from a fixed-information artifact."""
114
+ payload = summarize_information_artifact(namespace.artifact)
115
+ output = write_information_summary(payload, namespace.output)
116
+ return {
117
+ "output": str(output),
118
+ "lengths": payload["lengths"],
119
+ "groups": payload["groups"],
120
+ "n_endpoints": len(payload["rows"]),
121
+ }
122
+
123
+
124
+ def compare_state_counts(namespace: argparse.Namespace) -> dict[str, object]:
125
+ """Aggregate complete held-out score grids into a state-count comparison."""
126
+ if namespace.output.exists() or namespace.output.is_symlink():
127
+ raise FileExistsError(
128
+ f"state-count comparison output path already exists: {namespace.output}"
129
+ )
130
+ reports = tuple(load_state_model_scores(path) for path in namespace.score)
131
+ comparison = compare_state_model_scores(
132
+ reports,
133
+ )
134
+ output = _write_state_count_comparison(comparison, namespace.output)
135
+ return {
136
+ "output": str(output),
137
+ "model_kind": comparison.model_kind,
138
+ "n_folds": comparison.selection.n_folds,
139
+ "n_candidates": comparison.selection.n_candidates,
140
+ "candidate_n_states": comparison.selection.candidate_n_states.tolist(),
141
+ "n_seeds": len(comparison.model_seeds),
142
+ "model_seeds": list(comparison.model_seeds),
143
+ "n_subjects": len(comparison.subjects),
144
+ "best_n_states": comparison.selection.best_n_states,
145
+ "one_standard_error_n_states": comparison.selection.one_standard_error_n_states,
146
+ }
@@ -0,0 +1,245 @@
1
+ """Command handlers that turn XCP-D outputs into analysis artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+
10
+ from ..connectivity import ETS, MTD, LEiDA, SlidingWindowFC
11
+ from ..data import TimeSeriesDataset
12
+ from ..information import (
13
+ compute_fixed_information,
14
+ load_fixed_window_schedule,
15
+ load_information_groups,
16
+ save_fixed_information,
17
+ )
18
+ from ..io import discover_xcpd_runs, load_xcpd_dataset
19
+ from ..storage import (
20
+ write_cap_store,
21
+ write_instantaneous_edge_store,
22
+ write_leida_store,
23
+ write_window_fc_store,
24
+ )
25
+
26
+
27
+ def load_roi_selection(path: Path | None) -> dict[str, tuple[str, ...]] | None:
28
+ """Load and validate an atlas-to-ROI selection mapping."""
29
+ if path is None:
30
+ return None
31
+ try:
32
+ raw = json.loads(path.read_text(encoding="utf-8"))
33
+ except (OSError, json.JSONDecodeError) as error:
34
+ raise ValueError(f"cannot read ROI selection JSON {path}: {error}") from error
35
+ if not isinstance(raw, dict) or not raw:
36
+ raise ValueError("ROI selection must be a non-empty JSON object")
37
+ output: dict[str, tuple[str, ...]] = {}
38
+ for atlas, names in raw.items():
39
+ if not isinstance(atlas, str) or not atlas.strip():
40
+ raise ValueError("ROI selection atlas names must be non-empty strings")
41
+ if isinstance(names, str) or not isinstance(names, Sequence) or not names:
42
+ raise ValueError(f"ROI selection for {atlas!r} must be a non-empty array")
43
+ selected = tuple(str(name) for name in names)
44
+ if any(not name.strip() for name in selected) or len(set(selected)) != len(selected):
45
+ raise ValueError(f"ROI selection for {atlas!r} must contain unique non-empty names")
46
+ output[atlas] = selected
47
+ return output
48
+
49
+
50
+ def xcpd_filters(
51
+ namespace: argparse.Namespace,
52
+ *,
53
+ subject: str | None = None,
54
+ ) -> dict[str, object]:
55
+ """Build the discovery filters shared by XCP-D commands."""
56
+ return {
57
+ "atlases": tuple(namespace.atlas),
58
+ "subject": subject,
59
+ "session": namespace.session,
60
+ "task": namespace.task,
61
+ "space": namespace.space,
62
+ }
63
+
64
+
65
+ def selected_xcpd_subjects(namespace: argparse.Namespace) -> tuple[str | None, ...]:
66
+ """Normalize an optional repeated ``--subject`` selection."""
67
+ subjects = namespace.subject
68
+ if subjects is None:
69
+ return (None,)
70
+ values = tuple(str(subject).strip().removeprefix("sub-") for subject in subjects)
71
+ if any(not subject for subject in values):
72
+ raise ValueError("--subject values must be non-empty")
73
+ normalized = tuple(f"sub-{subject}" for subject in values)
74
+ unique = tuple(dict.fromkeys(normalized))
75
+ if len(unique) != len(subjects):
76
+ raise ValueError("--subject values must be unique")
77
+ return unique
78
+
79
+
80
+ def discover_selected_xcpd_runs(namespace: argparse.Namespace):
81
+ """Discover runs for the selected subject set."""
82
+ discovered = []
83
+ for subject in selected_xcpd_subjects(namespace):
84
+ discovered.extend(
85
+ discover_xcpd_runs(namespace.root, **xcpd_filters(namespace, subject=subject))
86
+ )
87
+ return tuple(discovered)
88
+
89
+
90
+ def inspect_xcpd(namespace: argparse.Namespace) -> dict[str, object]:
91
+ """Describe matching XCP-D acquisitions without loading their time series."""
92
+ files = discover_selected_xcpd_runs(namespace)
93
+ acquisitions = []
94
+ for item in files:
95
+ stem = item.outliers.name.removesuffix("_outliers.tsv")
96
+ entities = {
97
+ token.split("-", 1)[0]: token.split("-", 1)[1]
98
+ for token in stem.split("_")
99
+ if "-" in token
100
+ }
101
+ acquisitions.append(
102
+ {
103
+ "acquisition_id": stem,
104
+ "subject": entities.get("sub"),
105
+ "session": entities.get("ses"),
106
+ "task": entities.get("task"),
107
+ "atlases": [atlas.atlas for atlas in item.atlases],
108
+ "files": {
109
+ atlas.atlas: {
110
+ "timeseries": str(atlas.timeseries),
111
+ "coverage": str(atlas.coverage),
112
+ }
113
+ for atlas in item.atlases
114
+ },
115
+ "outliers": str(item.outliers),
116
+ "motion": None if item.motion is None else str(item.motion),
117
+ }
118
+ )
119
+ return {"n_acquisitions": len(acquisitions), "acquisitions": acquisitions}
120
+
121
+
122
+ def _load_selected_dataset(namespace: argparse.Namespace) -> TimeSeriesDataset:
123
+ selection = load_roi_selection(namespace.roi_selection)
124
+ datasets = tuple(
125
+ load_xcpd_dataset(
126
+ namespace.root,
127
+ roi_names=selection,
128
+ minimum_coverage=namespace.minimum_coverage,
129
+ tr=namespace.tr,
130
+ **xcpd_filters(namespace, subject=subject),
131
+ )
132
+ for subject in selected_xcpd_subjects(namespace)
133
+ )
134
+ return TimeSeriesDataset(tuple(run for item in datasets for run in item.runs))
135
+
136
+
137
+ def build_store(namespace: argparse.Namespace) -> dict[str, object]:
138
+ """Build a disk-backed feature store from selected XCP-D runs."""
139
+ dataset = _load_selected_dataset(namespace)
140
+ if namespace.method == "window-fc":
141
+ estimator = SlidingWindowFC(
142
+ length=namespace.window_length,
143
+ step=namespace.window_step,
144
+ taper=namespace.taper,
145
+ )
146
+ store = write_window_fc_store(
147
+ namespace.output,
148
+ dataset.runs,
149
+ estimator,
150
+ chunk_size=namespace.chunk_size,
151
+ dtype=namespace.dtype,
152
+ )
153
+ elif namespace.method == "cap":
154
+ store = write_cap_store(
155
+ namespace.output,
156
+ dataset.runs,
157
+ chunk_size=namespace.chunk_size,
158
+ dtype=namespace.dtype,
159
+ )
160
+ elif namespace.method == "leida":
161
+ store = write_leida_store(
162
+ namespace.output,
163
+ dataset.runs,
164
+ LEiDA(minimum_segment_length=namespace.minimum_segment_length),
165
+ chunk_size=namespace.chunk_size,
166
+ dtype=namespace.dtype,
167
+ )
168
+ elif namespace.method == "ets":
169
+ store = write_instantaneous_edge_store(
170
+ namespace.output,
171
+ dataset.runs,
172
+ ETS(),
173
+ chunk_size=namespace.chunk_size,
174
+ dtype=namespace.dtype,
175
+ )
176
+ else:
177
+ store = write_instantaneous_edge_store(
178
+ namespace.output,
179
+ dataset.runs,
180
+ MTD(),
181
+ chunk_size=namespace.chunk_size,
182
+ dtype=namespace.dtype,
183
+ )
184
+ return {
185
+ "method": namespace.method,
186
+ "output": str(store.root),
187
+ "n_runs": dataset.n_runs,
188
+ "subjects": list(dataset.subjects),
189
+ "acquisition_ids": [run.acquisition_id for run in dataset.runs],
190
+ "n_sequences": store.n_sequences,
191
+ "n_samples": store.n_samples,
192
+ "n_chunks": store.n_chunks,
193
+ "n_features": store.n_features,
194
+ "format_version": store.format_version,
195
+ }
196
+
197
+
198
+ def fixed_information(namespace: argparse.Namespace) -> dict[str, object]:
199
+ """Compute and persist fixed-length MI and CMI endpoints."""
200
+ if namespace.jobs < 1:
201
+ raise ValueError("--jobs must be at least 1")
202
+ dataset = _load_selected_dataset(namespace)
203
+ groups = load_information_groups(namespace.information_groups)
204
+ schedule = (
205
+ None
206
+ if namespace.window_schedule is None
207
+ else load_fixed_window_schedule(namespace.window_schedule)
208
+ )
209
+ artifact = compute_fixed_information(
210
+ dataset,
211
+ groups,
212
+ lengths=namespace.length,
213
+ draws=namespace.draws,
214
+ sample_seed=namespace.sample_seed,
215
+ schedule=schedule,
216
+ schedule_source=(
217
+ None if namespace.window_schedule is None else str(namespace.window_schedule)
218
+ ),
219
+ k=namespace.k,
220
+ jitter=namespace.jitter,
221
+ jitter_seed=namespace.jitter_seed,
222
+ standardize=namespace.standardize,
223
+ jobs=namespace.jobs,
224
+ )
225
+ output = save_fixed_information(artifact, namespace.output)
226
+ return {
227
+ "acquisition_ids": [item.acquisition_id for item in artifact.acquisitions],
228
+ "conditioning_rois": (
229
+ [] if artifact.groups.conditioning is None else list(artifact.groups.conditioning)
230
+ ),
231
+ "draws_per_length": artifact.draws_per_length,
232
+ "format_version": artifact.format_version,
233
+ "has_cmi": artifact.has_cmi,
234
+ "left_rois": list(artifact.groups.left),
235
+ "lengths": list(artifact.lengths),
236
+ "method": "fixed-information",
237
+ "n_draws": artifact.n_draws,
238
+ "n_runs": len(artifact.acquisitions),
239
+ "n_cells": artifact.n_cells,
240
+ "jobs": namespace.jobs,
241
+ "output": str(output),
242
+ "right_rois": list(artifact.groups.right),
243
+ "schedule_mode": artifact.schedule_mode,
244
+ "subjects": list(dataset.subjects),
245
+ }
@@ -0,0 +1,288 @@
1
+ """Command handlers for state alignment and repeated-fit stability."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from ..artifacts import (
8
+ StatePredictions,
9
+ load_fitted_model,
10
+ load_state_predictions,
11
+ save_fitted_model,
12
+ save_state_alignment,
13
+ save_state_predictions,
14
+ write_state_stability,
15
+ )
16
+ from ..states.alignment import (
17
+ align_gaussian_hmm_emissions,
18
+ align_kmeans_centroids,
19
+ apply_gaussian_hmm_alignment,
20
+ apply_state_alignment,
21
+ relabel_gaussian_hmm_model,
22
+ relabel_kmeans_model,
23
+ )
24
+ from ..states.data import StateAssignments
25
+ from ..states.hmm import GaussianHMMStateModel, GaussianHMMStateResult
26
+ from ..states.kmeans import KMeansStateModel
27
+ from ..states.stability import summarize_state_stability
28
+ from ..states.streaming import predict_kmeans_store
29
+ from ..states.streaming_hmm import predict_gaussian_hmm_store
30
+ from ..storage import FeatureStore
31
+ from .states import selected_store_subjects
32
+
33
+
34
+ def _requested_alignment_metric(
35
+ namespace: argparse.Namespace,
36
+ model: KMeansStateModel | GaussianHMMStateModel,
37
+ ) -> str:
38
+ requested = getattr(namespace, "alignment_metric", "auto")
39
+ if requested == "auto":
40
+ return "pearson" if model.source_contract.startswith("cap:") else "euclidean"
41
+ return str(requested)
42
+
43
+
44
+ def align_states(namespace: argparse.Namespace) -> dict[str, object]:
45
+ """Align a candidate model and optional predictions to a reference model."""
46
+ reference = load_fitted_model(namespace.reference_model)
47
+ candidate = load_fitted_model(namespace.candidate_model)
48
+ if type(reference) is not type(candidate):
49
+ raise TypeError("reference and candidate models must use the same state-model family")
50
+ alignment_metric = _requested_alignment_metric(namespace, reference)
51
+ if isinstance(reference, KMeansStateModel):
52
+ alignment = align_kmeans_centroids(
53
+ reference,
54
+ candidate,
55
+ metric=alignment_metric,
56
+ )
57
+ aligned_model = relabel_kmeans_model(candidate, alignment)
58
+ model_kind = "kmeans-state"
59
+ elif isinstance(reference, GaussianHMMStateModel):
60
+ alignment = align_gaussian_hmm_emissions(
61
+ reference,
62
+ candidate,
63
+ metric=alignment_metric,
64
+ )
65
+ aligned_model = relabel_gaussian_hmm_model(candidate, alignment)
66
+ model_kind = "gaussian-hmm-state"
67
+ else:
68
+ raise TypeError("align-states requires KMeans or Gaussian HMM model artifacts")
69
+
70
+ prediction_output = namespace.predictions_output
71
+ if (namespace.predictions is None) != (prediction_output is None):
72
+ raise ValueError("--predictions and --predictions-output must be provided together")
73
+ aligned_predictions = None
74
+ if namespace.predictions is not None:
75
+ predictions = load_state_predictions(namespace.predictions)
76
+ if predictions.model_kind != model_kind:
77
+ raise ValueError("prediction artifact and candidate model use different model families")
78
+ if predictions.model_seed != candidate.seed:
79
+ raise ValueError("prediction artifact seed does not match the candidate model")
80
+ if model_kind == "kmeans-state":
81
+ aligned_assignments = apply_state_alignment(predictions.assignments, alignment)
82
+ aligned_predictions = StatePredictions(
83
+ assignments=aligned_assignments,
84
+ model_kind=model_kind,
85
+ model_seed=candidate.seed,
86
+ )
87
+ else:
88
+ if predictions.posterior_probabilities is None or predictions.log_likelihood is None:
89
+ raise ValueError("HMM prediction artifact is missing posterior information")
90
+ result = GaussianHMMStateResult(
91
+ assignments=predictions.assignments,
92
+ posterior_probabilities=predictions.posterior_probabilities,
93
+ log_likelihood=predictions.log_likelihood,
94
+ )
95
+ aligned_result = apply_gaussian_hmm_alignment(result, alignment)
96
+ aligned_predictions = StatePredictions(
97
+ assignments=aligned_result.assignments,
98
+ model_kind=model_kind,
99
+ model_seed=candidate.seed,
100
+ posterior_probabilities=aligned_result.posterior_probabilities,
101
+ log_likelihood=aligned_result.log_likelihood,
102
+ )
103
+
104
+ output_paths = [namespace.output_model, namespace.alignment_output]
105
+ if aligned_predictions is not None:
106
+ output_paths.append(prediction_output)
107
+ normalized_outputs = [path.resolve(strict=False) for path in output_paths]
108
+ if len(set(normalized_outputs)) != len(normalized_outputs):
109
+ raise ValueError("align-states output paths must be distinct")
110
+ if any(path.exists() or path.is_symlink() for path in output_paths):
111
+ raise FileExistsError("one or more align-states output paths already exist")
112
+ alignment_path = save_state_alignment(alignment, namespace.alignment_output)
113
+ model_path = save_fitted_model(aligned_model, namespace.output_model)
114
+ prediction_path = None
115
+ if aligned_predictions is not None:
116
+ prediction_path = save_state_predictions(aligned_predictions, prediction_output)
117
+ return {
118
+ "model_kind": model_kind,
119
+ "reference_seed": alignment.reference_seed,
120
+ "candidate_seed": alignment.candidate_seed,
121
+ "candidate_to_reference": alignment.candidate_to_reference.tolist(),
122
+ "alignment_metric": alignment.metric,
123
+ "matched_costs": alignment.matched_costs.tolist(),
124
+ "output_model": str(model_path),
125
+ "alignment_output": str(alignment_path),
126
+ "predictions_output": None if prediction_path is None else str(prediction_path),
127
+ }
128
+
129
+
130
+ def _repeated_fit_contract(
131
+ model: KMeansStateModel | GaussianHMMStateModel,
132
+ ) -> tuple[object, ...]:
133
+ common = (
134
+ type(model),
135
+ model.feature_keys,
136
+ model.source_contract,
137
+ model.sample_interval_seconds,
138
+ model.n_states,
139
+ model.fit_subjects,
140
+ model.fit_sample_count,
141
+ model.implementation,
142
+ )
143
+ if isinstance(model, KMeansStateModel):
144
+ return common + (
145
+ model.n_init,
146
+ model.max_iter,
147
+ model.algorithm,
148
+ model.standardize_features,
149
+ model.batch_size,
150
+ model.reassignment_ratio,
151
+ model.init_sample_size,
152
+ )
153
+ return common + (
154
+ model.fit_sequence_count,
155
+ model.omitted_short_sequence_count,
156
+ model.n_pca_components,
157
+ model.covariance_type,
158
+ model.n_init,
159
+ model.n_iter,
160
+ model.tol,
161
+ model.minimum_sequence_length,
162
+ model.pca_batch_size,
163
+ )
164
+
165
+
166
+ def _require_complete_repeated_fit_provenance(
167
+ model: KMeansStateModel | GaussianHMMStateModel,
168
+ ) -> None:
169
+ if (
170
+ isinstance(model, KMeansStateModel)
171
+ and model.algorithm == "minibatch"
172
+ and "out-of-core" in model.implementation
173
+ and model.init_sample_size is None
174
+ ):
175
+ raise ValueError("out-of-core KMeans stability requires a recorded init_sample_size")
176
+ if (
177
+ isinstance(model, GaussianHMMStateModel)
178
+ and "IncrementalPCA" in model.implementation
179
+ and model.pca_batch_size is None
180
+ ):
181
+ raise ValueError("out-of-core HMM stability requires a recorded pca_batch_size")
182
+
183
+ def _decode_assignments(
184
+ model: KMeansStateModel | GaussianHMMStateModel,
185
+ store: FeatureStore,
186
+ *,
187
+ subjects: tuple[str, ...] | None,
188
+ allow_fit_subjects: bool,
189
+ ) -> StateAssignments:
190
+ if isinstance(model, KMeansStateModel):
191
+ return predict_kmeans_store(
192
+ model,
193
+ store,
194
+ subjects=subjects,
195
+ allow_fit_subjects=allow_fit_subjects,
196
+ )
197
+ return predict_gaussian_hmm_store(
198
+ model,
199
+ store,
200
+ subjects=subjects,
201
+ allow_fit_subjects=allow_fit_subjects,
202
+ ).assignments
203
+
204
+
205
+ def summarize_stability(namespace: argparse.Namespace) -> dict[str, object]:
206
+ """Align repeated state fits and summarize their run-level agreement."""
207
+ if namespace.output.exists() or namespace.output.is_symlink():
208
+ raise FileExistsError(f"state-stability output path already exists: {namespace.output}")
209
+ store = FeatureStore.open(namespace.store)
210
+ subjects = selected_store_subjects(store, namespace.subject)
211
+ reference = load_fitted_model(namespace.reference_model)
212
+ candidates = tuple(load_fitted_model(path) for path in namespace.candidate_model)
213
+ if not isinstance(reference, (KMeansStateModel, GaussianHMMStateModel)):
214
+ raise TypeError("summarize-stability requires a KMeans or Gaussian HMM reference")
215
+ if any(type(candidate) is not type(reference) for candidate in candidates):
216
+ raise TypeError("all stability models must use the same state-model family")
217
+ for model in (reference, *candidates):
218
+ _require_complete_repeated_fit_provenance(model)
219
+ if any(candidate.fit_subjects != reference.fit_subjects for candidate in candidates):
220
+ raise ValueError("stability models must use the same training subjects")
221
+ if any(candidate.fit_sample_count != reference.fit_sample_count for candidate in candidates):
222
+ raise ValueError("stability models must use the same fitted sample count")
223
+ reference_contract = _repeated_fit_contract(reference)
224
+ if any(_repeated_fit_contract(candidate) != reference_contract for candidate in candidates):
225
+ raise ValueError(
226
+ "stability models must share training subjects, fitted samples, and hyperparameters"
227
+ )
228
+ seeds = tuple(model.seed for model in (reference, *candidates))
229
+ if len(set(seeds)) != len(seeds):
230
+ raise ValueError("stability models must use distinct seeds")
231
+
232
+ reference_assignments = _decode_assignments(
233
+ reference,
234
+ store,
235
+ subjects=subjects,
236
+ allow_fit_subjects=namespace.allow_fit_subjects,
237
+ )
238
+ aligned_assignments = [reference_assignments]
239
+ alignments = []
240
+ alignment_metric = _requested_alignment_metric(namespace, reference)
241
+ for candidate in candidates:
242
+ candidate_assignments = _decode_assignments(
243
+ candidate,
244
+ store,
245
+ subjects=subjects,
246
+ allow_fit_subjects=namespace.allow_fit_subjects,
247
+ )
248
+ if isinstance(reference, KMeansStateModel):
249
+ alignment = align_kmeans_centroids(
250
+ reference,
251
+ candidate,
252
+ metric=alignment_metric,
253
+ )
254
+ else:
255
+ alignment = align_gaussian_hmm_emissions(
256
+ reference,
257
+ candidate,
258
+ metric=alignment_metric,
259
+ )
260
+ alignments.append(alignment)
261
+ aligned_assignments.append(apply_state_alignment(candidate_assignments, alignment))
262
+ stability = summarize_state_stability(aligned_assignments)
263
+ model_kind = "kmeans-state" if isinstance(reference, KMeansStateModel) else "gaussian-hmm-state"
264
+ output = write_state_stability(
265
+ stability,
266
+ namespace.output,
267
+ model_kind=model_kind,
268
+ reference_seed=reference.seed,
269
+ candidate_seeds=tuple(candidate.seed for candidate in candidates),
270
+ alignments=tuple(alignments),
271
+ source_contract=store.source_contract,
272
+ sample_interval_seconds=store.sample_interval_seconds,
273
+ allow_fit_subjects=namespace.allow_fit_subjects,
274
+ )
275
+ return {
276
+ "output": str(output),
277
+ "model_kind": model_kind,
278
+ "reference_seed": reference.seed,
279
+ "candidate_seeds": [candidate.seed for candidate in candidates],
280
+ "subjects": list(dict.fromkeys(run.subject for run in stability)),
281
+ "n_fits": len(aligned_assignments),
282
+ "n_states": reference.n_states,
283
+ "n_runs": len(stability),
284
+ "n_sequences": sum(run.n_sequences for run in stability),
285
+ "n_samples": sum(run.n_samples for run in stability),
286
+ "allow_fit_subjects": bool(namespace.allow_fit_subjects),
287
+ "alignment_metric": alignment_metric,
288
+ }