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