@yibeichan/claude-skills 1.0.2
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.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/cli.js +272 -0
- package/install.py +240 -0
- package/package.json +44 -0
- package/skills/bidsapp-nidm-standards/SKILL.md +202 -0
- package/skills/bidsapp-nidm-standards/references/babs_config.md +20 -0
- package/skills/bidsapp-nidm-standards/references/cli_arguments.md +76 -0
- package/skills/bidsapp-nidm-standards/references/container_patterns.md +53 -0
- package/skills/bidsapp-nidm-standards/references/nidm_integration.md +403 -0
- package/skills/bidsapp-nidm-standards/references/repo_structure.md +121 -0
- package/skills/bidsapp-nidm-standards/references/testing_patterns.md +82 -0
- package/skills/dicom2fmriprep/SKILL.md +377 -0
- package/skills/dicom2fmriprep/evals/evals.json +26 -0
- package/skills/dicom2fmriprep/references/babs-details.md +407 -0
- package/skills/dicom2fmriprep/references/fmriprep-details.md +250 -0
- package/skills/dicom2fmriprep/references/heudiconv-details.md +243 -0
- package/skills/fmri-ssm/SKILL.md +317 -0
- package/skills/fmri-ssm/references/code_templates.md +1570 -0
- package/skills/fmri-ssm/references/downstream_analysis.md +680 -0
- package/skills/fmri-ssm/references/group_inference.md +608 -0
- package/skills/fmri-ssm/references/hrf_modeling.md +447 -0
- package/skills/fmri-ssm/references/model_catalog.md +436 -0
- package/skills/fmri-ssm/references/paradigm_guide.md +406 -0
- package/skills/fmri-ssm/references/preprocessing.md +614 -0
- package/skills/fmri-ssm.zip +0 -0
- package/skills/neuroimaging-qc/SKILL.md +203 -0
- package/skills/neuroimaging-qc/references/eeg_qc.md +400 -0
- package/skills/neuroimaging-qc/references/fmri_qc.md +343 -0
- package/skills/neuroimaging-qc/references/fnirs_qc.md +430 -0
- package/skills/neuroimaging-qc/references/structural_qc.md +454 -0
- package/skills/neuroimaging-qc/scripts/parse_fmriprep_confounds.py +153 -0
- package/skills/neuroimaging-qc/scripts/parse_mriqc.py +114 -0
- package/skills/neuroimaging-qc/scripts/qc_report.py +295 -0
- package/skills/scientific-writer/SKILL.md +202 -0
- package/skills/scientific-writer/references/citation_styles.md +163 -0
- package/skills/scientific-writer/references/field_conventions.md +245 -0
- package/skills/scientific-writer/references/figures_tables.md +225 -0
- package/skills/scientific-writer/references/reporting_guidelines.md +225 -0
- package/skills.json +54 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
# Group-Level Inference for SSMs in fMRI
|
|
2
|
+
|
|
3
|
+
## Table of Contents
|
|
4
|
+
1. [When Single-Subject vs. Group Models](#when-to-use)
|
|
5
|
+
2. [Approach 1: Concatenation (Group HMM)](#concatenation)
|
|
6
|
+
3. [Approach 2: Two-Stage (Fit per Subject, Aggregate)](#two-stage)
|
|
7
|
+
4. [Approach 3: Hierarchical Bayesian Models](#hierarchical)
|
|
8
|
+
5. [State Alignment Across Subjects](#alignment)
|
|
9
|
+
6. [Comparing Groups (Clinical, Behavioral)](#group-comparison)
|
|
10
|
+
7. [Deep Data vs. Wide Data Strategies](#deep-vs-wide)
|
|
11
|
+
8. [Statistical Testing on SSM-Derived Metrics](#stats)
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. When Single-Subject vs. Group Models {#when-to-use}
|
|
16
|
+
|
|
17
|
+
**Single-subject models (fit each subject independently):**
|
|
18
|
+
- Deep data: many runs or long scans per subject (>30 min total per subject)
|
|
19
|
+
- When individual differences in state structure are the focus
|
|
20
|
+
- When subjects may have genuinely different numbers of states
|
|
21
|
+
- Precision medicine / individual-level inference
|
|
22
|
+
|
|
23
|
+
**Group models (pool across subjects):**
|
|
24
|
+
- Shallow data: short scans, few runs per subject (<15 min total)
|
|
25
|
+
- When you want to define common states shared across subjects
|
|
26
|
+
- When group-level transition dynamics are the primary question
|
|
27
|
+
- Standard clinical comparison designs (patients vs. controls)
|
|
28
|
+
|
|
29
|
+
**Hybrid: group-defined states, subject-specific dynamics**
|
|
30
|
+
- Define states from group model, then estimate subject-specific transition matrices
|
|
31
|
+
- Good balance for most studies
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 2. Approach 1: Concatenation (Group HMM) {#concatenation}
|
|
36
|
+
|
|
37
|
+
The simplest group approach: concatenate all subjects' data and fit a single HMM.
|
|
38
|
+
The model learns states shared across all subjects.
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
"""Group HMM via concatenation."""
|
|
42
|
+
import numpy as np
|
|
43
|
+
from hmmlearn import hmm
|
|
44
|
+
|
|
45
|
+
def fit_group_hmm_concat(subject_data, K, covariance_type='full',
|
|
46
|
+
n_restarts=50, n_iter=200):
|
|
47
|
+
"""Fit a single HMM on concatenated multi-subject data.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
subject_data : dict
|
|
52
|
+
{subject_id: list_of_run_arrays} where each array is (T, n_features)
|
|
53
|
+
K : int
|
|
54
|
+
Number of states
|
|
55
|
+
|
|
56
|
+
Returns
|
|
57
|
+
-------
|
|
58
|
+
group_model : fitted HMM
|
|
59
|
+
subject_states : dict of {subject_id: list_of_state_arrays}
|
|
60
|
+
"""
|
|
61
|
+
# Concatenate all runs from all subjects
|
|
62
|
+
all_data = []
|
|
63
|
+
all_lengths = []
|
|
64
|
+
subject_run_map = [] # track which segment belongs to which subject/run
|
|
65
|
+
|
|
66
|
+
for sub_id, runs in subject_data.items():
|
|
67
|
+
for run_idx, run_data in enumerate(runs):
|
|
68
|
+
all_data.append(run_data)
|
|
69
|
+
all_lengths.append(run_data.shape[0])
|
|
70
|
+
subject_run_map.append((sub_id, run_idx))
|
|
71
|
+
|
|
72
|
+
data_concat = np.vstack(all_data)
|
|
73
|
+
print(f"Total data: {data_concat.shape[0]} TRs from {len(subject_data)} subjects")
|
|
74
|
+
|
|
75
|
+
# Z-score globally (or per-subject — see note below)
|
|
76
|
+
data_concat = (data_concat - data_concat.mean(axis=0)) / data_concat.std(axis=0)
|
|
77
|
+
|
|
78
|
+
# Fit group HMM (inline; see code_templates.md for the full fit_gaussian_hmm helper)
|
|
79
|
+
from hmmlearn import hmm
|
|
80
|
+
from sklearn.cluster import KMeans
|
|
81
|
+
best_model = None
|
|
82
|
+
best_score = -np.inf
|
|
83
|
+
for restart in range(n_restarts):
|
|
84
|
+
model = hmm.GaussianHMM(
|
|
85
|
+
n_components=K, covariance_type=covariance_type,
|
|
86
|
+
n_iter=n_iter, tol=1e-4, random_state=42 + restart,
|
|
87
|
+
)
|
|
88
|
+
if restart == 0:
|
|
89
|
+
km = KMeans(n_clusters=K, random_state=42, n_init=10).fit(data_concat)
|
|
90
|
+
model.means_init = km.cluster_centers_
|
|
91
|
+
try:
|
|
92
|
+
model.fit(data_concat, lengths=all_lengths)
|
|
93
|
+
score = model.score(data_concat, lengths=all_lengths)
|
|
94
|
+
if score > best_score:
|
|
95
|
+
best_score = score
|
|
96
|
+
best_model = model
|
|
97
|
+
except Exception:
|
|
98
|
+
continue
|
|
99
|
+
group_model = best_model
|
|
100
|
+
|
|
101
|
+
# Decode per subject
|
|
102
|
+
states = group_model.predict(data_concat, all_lengths)
|
|
103
|
+
subject_states = {}
|
|
104
|
+
offset = 0
|
|
105
|
+
for (sub_id, run_idx), length in zip(subject_run_map, all_lengths):
|
|
106
|
+
if sub_id not in subject_states:
|
|
107
|
+
subject_states[sub_id] = []
|
|
108
|
+
subject_states[sub_id].append(states[offset:offset + length])
|
|
109
|
+
offset += length
|
|
110
|
+
|
|
111
|
+
return group_model, subject_states
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# IMPORTANT: Z-scoring considerations
|
|
115
|
+
# Option A: Global z-score (above) — treats all subjects as coming from same distribution
|
|
116
|
+
# Good when subjects are similar (same scanner, similar demographics)
|
|
117
|
+
# Bad when there are systematic between-subject differences in signal level
|
|
118
|
+
#
|
|
119
|
+
# Option B: Per-subject z-score — normalize each subject's data independently
|
|
120
|
+
# Good when between-subject variability in signal level is a nuisance
|
|
121
|
+
# Bad when between-subject differences in mean activation are meaningful
|
|
122
|
+
#
|
|
123
|
+
# Recommendation: Per-subject z-scoring is usually safer for group HMMs
|
|
124
|
+
def zscore_per_subject(subject_data):
|
|
125
|
+
"""Z-score each subject's data independently."""
|
|
126
|
+
for sub_id in subject_data:
|
|
127
|
+
all_runs = np.vstack(subject_data[sub_id])
|
|
128
|
+
mu = all_runs.mean(axis=0)
|
|
129
|
+
sigma = all_runs.std(axis=0)
|
|
130
|
+
sigma[sigma < 1e-6] = 1.0 # avoid division by zero
|
|
131
|
+
subject_data[sub_id] = [(run - mu) / sigma for run in subject_data[sub_id]]
|
|
132
|
+
return subject_data
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Limitations of concatenation:**
|
|
136
|
+
- Assumes all subjects share the same state definitions AND transition dynamics
|
|
137
|
+
- Subjects with more data have more influence on state definitions
|
|
138
|
+
- Cannot capture individual differences in state structure
|
|
139
|
+
- Large datasets: concatenating 100+ subjects creates very large matrices
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## 3. Approach 2: Two-Stage (Fit per Subject, Aggregate) {#two-stage}
|
|
144
|
+
|
|
145
|
+
Fit individual subject models, then align states across subjects and aggregate statistics.
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
"""Two-stage group analysis: fit per-subject, then align and aggregate."""
|
|
149
|
+
import numpy as np
|
|
150
|
+
from scipy.optimize import linear_sum_assignment
|
|
151
|
+
from scipy.spatial.distance import cdist
|
|
152
|
+
|
|
153
|
+
def fit_per_subject(subject_data, K, **hmm_kwargs):
|
|
154
|
+
"""Stage 1: Fit HMM to each subject independently."""
|
|
155
|
+
subject_models = {}
|
|
156
|
+
subject_states = {}
|
|
157
|
+
|
|
158
|
+
for sub_id, runs in subject_data.items():
|
|
159
|
+
data_concat = np.vstack(runs)
|
|
160
|
+
lengths = [r.shape[0] for r in runs]
|
|
161
|
+
|
|
162
|
+
# Z-score within subject
|
|
163
|
+
data_concat = (data_concat - data_concat.mean(0)) / data_concat.std(0)
|
|
164
|
+
|
|
165
|
+
model, score, _ = fit_gaussian_hmm(
|
|
166
|
+
data_concat, lengths, K, **hmm_kwargs
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
subject_models[sub_id] = model
|
|
170
|
+
states = model.predict(data_concat, lengths)
|
|
171
|
+
|
|
172
|
+
# Split states back into runs
|
|
173
|
+
subject_states[sub_id] = []
|
|
174
|
+
offset = 0
|
|
175
|
+
for length in lengths:
|
|
176
|
+
subject_states[sub_id].append(states[offset:offset + length])
|
|
177
|
+
offset += length
|
|
178
|
+
|
|
179
|
+
print(f"Subject {sub_id}: LL={score:.1f}")
|
|
180
|
+
|
|
181
|
+
return subject_models, subject_states
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def align_subjects_to_reference(subject_models, subject_data, reference_sub=None):
|
|
185
|
+
"""Stage 2: Align state labels across subjects using Hungarian algorithm.
|
|
186
|
+
|
|
187
|
+
Parameters
|
|
188
|
+
----------
|
|
189
|
+
subject_models : dict of {sub_id: fitted_model}
|
|
190
|
+
subject_data : dict of {sub_id: list_of_run_arrays}
|
|
191
|
+
Raw data per subject (needed to score models when reference_sub is None)
|
|
192
|
+
reference_sub : str or None
|
|
193
|
+
Subject to use as reference. If None, use the subject with highest LL.
|
|
194
|
+
"""
|
|
195
|
+
sub_ids = list(subject_models.keys())
|
|
196
|
+
|
|
197
|
+
if reference_sub is None:
|
|
198
|
+
# Use subject with best model fit as reference
|
|
199
|
+
scores = {s: subject_models[s].score(
|
|
200
|
+
np.vstack(subject_data[s]),
|
|
201
|
+
[r.shape[0] for r in subject_data[s]]
|
|
202
|
+
) for s in sub_ids}
|
|
203
|
+
reference_sub = max(scores, key=scores.get)
|
|
204
|
+
|
|
205
|
+
ref_means = subject_models[reference_sub].means_
|
|
206
|
+
alignments = {reference_sub: {k: k for k in range(ref_means.shape[0])}}
|
|
207
|
+
|
|
208
|
+
for sub_id in sub_ids:
|
|
209
|
+
if sub_id == reference_sub:
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
target_means = subject_models[sub_id].means_
|
|
213
|
+
cost = cdist(ref_means, target_means, metric='correlation')
|
|
214
|
+
row_ind, col_ind = linear_sum_assignment(cost)
|
|
215
|
+
|
|
216
|
+
alignments[sub_id] = {col: row for row, col in zip(row_ind, col_ind)}
|
|
217
|
+
|
|
218
|
+
# Report alignment quality
|
|
219
|
+
match_cost = cost[row_ind, col_ind].mean()
|
|
220
|
+
print(f"Subject {sub_id}: mean alignment cost = {match_cost:.3f}")
|
|
221
|
+
|
|
222
|
+
return alignments, reference_sub
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def aggregate_metrics(subject_states, subject_models, alignments, tr):
|
|
226
|
+
"""Stage 3: Compute group-level metrics from aligned subject results.
|
|
227
|
+
|
|
228
|
+
Returns per-subject metrics that can be used for group statistics.
|
|
229
|
+
"""
|
|
230
|
+
K = subject_models[list(subject_models.keys())[0]].n_components
|
|
231
|
+
|
|
232
|
+
metrics = {}
|
|
233
|
+
for sub_id in subject_states:
|
|
234
|
+
alignment = alignments[sub_id]
|
|
235
|
+
|
|
236
|
+
# Re-label states using alignment
|
|
237
|
+
aligned_states = []
|
|
238
|
+
for run_states in subject_states[sub_id]:
|
|
239
|
+
aligned = np.array([alignment[s] for s in run_states])
|
|
240
|
+
aligned_states.append(aligned)
|
|
241
|
+
all_states = np.concatenate(aligned_states)
|
|
242
|
+
|
|
243
|
+
# Fractional occupancy
|
|
244
|
+
frac_occ = np.array([(all_states == k).sum() / len(all_states) for k in range(K)])
|
|
245
|
+
|
|
246
|
+
# Mean dwell time per state
|
|
247
|
+
mean_dwell = {}
|
|
248
|
+
for k in range(K):
|
|
249
|
+
dwells = []
|
|
250
|
+
for run_states in aligned_states:
|
|
251
|
+
current = 0
|
|
252
|
+
for t in range(1, len(run_states)):
|
|
253
|
+
if run_states[t] == run_states[t-1] == k:
|
|
254
|
+
current += 1
|
|
255
|
+
elif run_states[t-1] == k:
|
|
256
|
+
dwells.append((current + 1) * tr)
|
|
257
|
+
current = 0
|
|
258
|
+
else:
|
|
259
|
+
current = 0
|
|
260
|
+
mean_dwell[k] = np.mean(dwells) if dwells else 0
|
|
261
|
+
|
|
262
|
+
# Transition rates (number of transitions per minute)
|
|
263
|
+
total_time = len(all_states) * tr / 60 # minutes
|
|
264
|
+
n_transitions = np.sum(np.diff(all_states) != 0)
|
|
265
|
+
transition_rate = n_transitions / total_time
|
|
266
|
+
|
|
267
|
+
metrics[sub_id] = {
|
|
268
|
+
'fractional_occupancy': frac_occ,
|
|
269
|
+
'mean_dwell_time': mean_dwell,
|
|
270
|
+
'transition_rate': transition_rate,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return metrics
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## 4. Approach 3: Hierarchical Bayesian Models {#hierarchical}
|
|
279
|
+
|
|
280
|
+
The most principled approach: subject parameters are drawn from group-level priors.
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
"""Hierarchical HMM using pyhsmm (Bayesian approach).
|
|
284
|
+
|
|
285
|
+
This learns both group-level state definitions and subject-specific variations.
|
|
286
|
+
The Dirichlet prior on transitions links subjects together.
|
|
287
|
+
"""
|
|
288
|
+
import pyhsmm
|
|
289
|
+
import pyhsmm.basic.distributions as distributions
|
|
290
|
+
|
|
291
|
+
def fit_hierarchical_hmm(subject_data, K, alpha_a0=1.0, alpha_b0=1.0):
|
|
292
|
+
"""Fit hierarchical Bayesian HMM with shared state definitions.
|
|
293
|
+
|
|
294
|
+
Uses MCMC sampling — slower but provides uncertainty estimates.
|
|
295
|
+
"""
|
|
296
|
+
n_features = subject_data[list(subject_data.keys())[0]][0].shape[1]
|
|
297
|
+
|
|
298
|
+
obs_distns = [
|
|
299
|
+
distributions.Gaussian(
|
|
300
|
+
mu_0=np.zeros(n_features),
|
|
301
|
+
sigma_0=np.eye(n_features),
|
|
302
|
+
kappa_0=0.1,
|
|
303
|
+
nu_0=n_features + 2,
|
|
304
|
+
)
|
|
305
|
+
for _ in range(K)
|
|
306
|
+
]
|
|
307
|
+
|
|
308
|
+
model = pyhsmm.models.WeakLimitStickyHDPHMM(
|
|
309
|
+
kappa=50, # stickiness
|
|
310
|
+
alpha_a_0=alpha_a0,
|
|
311
|
+
alpha_b_0=alpha_b0,
|
|
312
|
+
gamma_a_0=1.0,
|
|
313
|
+
gamma_b_0=1.0,
|
|
314
|
+
init_state_concentration=1.0,
|
|
315
|
+
obs_distns=obs_distns,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# Add each subject's data as a separate sequence
|
|
319
|
+
for sub_id, runs in subject_data.items():
|
|
320
|
+
for run_data in runs:
|
|
321
|
+
model.add_data(run_data)
|
|
322
|
+
|
|
323
|
+
# MCMC sampling
|
|
324
|
+
n_samples = 500
|
|
325
|
+
n_burnin = 200
|
|
326
|
+
|
|
327
|
+
for i in range(n_samples):
|
|
328
|
+
model.resample_model()
|
|
329
|
+
if i >= n_burnin and i % 10 == 0:
|
|
330
|
+
print(f"Sample {i}: {model.num_states()} active states")
|
|
331
|
+
|
|
332
|
+
return model
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
**For a more scalable hierarchical approach, consider `glhmm`:**
|
|
336
|
+
|
|
337
|
+
```python
|
|
338
|
+
"""Hierarchical HMM using glhmm (Vidaurre's library).
|
|
339
|
+
|
|
340
|
+
Supports group-level inference with subject-specific transition matrices.
|
|
341
|
+
"""
|
|
342
|
+
from glhmm import glhmm # import the class directly (not 'from glhmm import glhmm as gl')
|
|
343
|
+
from glhmm import preproc
|
|
344
|
+
|
|
345
|
+
def fit_group_glhmm(subject_data, K):
|
|
346
|
+
"""Fit group-level HMM allowing subject-specific transitions."""
|
|
347
|
+
|
|
348
|
+
# Prepare data
|
|
349
|
+
all_data = []
|
|
350
|
+
T_list = []
|
|
351
|
+
for sub_id in sorted(subject_data.keys()):
|
|
352
|
+
for run in subject_data[sub_id]:
|
|
353
|
+
all_data.append(run)
|
|
354
|
+
T_list.append(run.shape[0])
|
|
355
|
+
|
|
356
|
+
data_concat = np.vstack(all_data)
|
|
357
|
+
indices = preproc.build_indices(T_list)
|
|
358
|
+
|
|
359
|
+
model = glhmm(
|
|
360
|
+
K=K,
|
|
361
|
+
covtype='full',
|
|
362
|
+
model_mean='state',
|
|
363
|
+
model_beta='no',
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
model.train(data_concat, indices=indices, maxiter=200)
|
|
367
|
+
|
|
368
|
+
return model
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
---
|
|
372
|
+
|
|
373
|
+
## 5. State Alignment Across Subjects {#alignment}
|
|
374
|
+
|
|
375
|
+
When fitting per-subject models, states are arbitrarily labeled. Alignment is needed.
|
|
376
|
+
|
|
377
|
+
**Hungarian algorithm** (shown above): optimal 1-to-1 assignment based on state similarity.
|
|
378
|
+
Works when all subjects have the same K and similar states.
|
|
379
|
+
|
|
380
|
+
**K-means on state parameters:** Cluster all subjects' state means into K clusters. Each
|
|
381
|
+
cluster defines a group state, and subjects' states are assigned to the nearest cluster.
|
|
382
|
+
|
|
383
|
+
**Correlation-based alignment:** Compute spatial correlation between each subject's state
|
|
384
|
+
means and a reference set. Assign based on maximum correlation.
|
|
385
|
+
|
|
386
|
+
**When alignment fails:** If subjects genuinely have different state structures (different K
|
|
387
|
+
or qualitatively different states), forced alignment is inappropriate. Strategies:
|
|
388
|
+
|
|
389
|
+
1. **Correlation-based alignment with a quality threshold.** Compute the max correlation
|
|
390
|
+
between each subject's state and the reference. If the best match is below r = 0.5,
|
|
391
|
+
flag that subject's state as "unaligned" and exclude it from group averages for that state.
|
|
392
|
+
|
|
393
|
+
2. **Reduce K.** Often alignment failure signals over-fitting. Try K-1 or K-2; finer
|
|
394
|
+
distinctions may be subject-specific and unreliable at the group level.
|
|
395
|
+
|
|
396
|
+
3. **K-means on pooled state means.** Cluster all subjects' K state means together into K
|
|
397
|
+
clusters (or use the silhouette score to find the optimal group K). Each cluster is a
|
|
398
|
+
group state; subjects whose state mean falls in a cluster are "aligned" to that state.
|
|
399
|
+
|
|
400
|
+
4. **Treat as separate analyses.** When populations genuinely differ (e.g., controls vs.
|
|
401
|
+
patients with vastly altered dynamics), fit separate group models rather than forcing
|
|
402
|
+
alignment. Compare models by BIC or by the interpretability of aligned vs. separate fits.
|
|
403
|
+
|
|
404
|
+
5. **HDP-HMM alignment.** If using HDP-HMM (where K varies by subject), align only the
|
|
405
|
+
shared "active" states using the mode K across subjects. For subjects with extra states,
|
|
406
|
+
treat the extras as subject-specific and exclude from group comparisons. Cap the group K
|
|
407
|
+
at the minimum K seen across subjects to avoid extrapolation.
|
|
408
|
+
|
|
409
|
+
---
|
|
410
|
+
|
|
411
|
+
## 6. Comparing Groups {#group-comparison}
|
|
412
|
+
|
|
413
|
+
### SSM-derived metrics for group comparison
|
|
414
|
+
|
|
415
|
+
| Metric | Description | Statistical test |
|
|
416
|
+
|--------|-------------|-----------------|
|
|
417
|
+
| Fractional occupancy | Proportion of time in each state | t-test or Wilcoxon per state |
|
|
418
|
+
| Mean dwell time | Average duration of state visits | t-test or Wilcoxon per state |
|
|
419
|
+
| Transition probability | A[i,j] matrix | Permutation test on matrix elements |
|
|
420
|
+
| Transition rate | Total transitions per minute | t-test |
|
|
421
|
+
| State-specific FC | Covariance matrix per state | Network-based statistic, NBS |
|
|
422
|
+
| Switching entropy | Entropy of the transition matrix | t-test |
|
|
423
|
+
|
|
424
|
+
### Example: patient vs. control comparison
|
|
425
|
+
|
|
426
|
+
```python
|
|
427
|
+
"""Compare SSM metrics between two groups."""
|
|
428
|
+
from scipy import stats
|
|
429
|
+
|
|
430
|
+
def compare_groups(metrics_group1, metrics_group2, K, alpha=0.05,
|
|
431
|
+
correction='fdr'):
|
|
432
|
+
"""Compare SSM-derived metrics between two groups.
|
|
433
|
+
|
|
434
|
+
Parameters
|
|
435
|
+
----------
|
|
436
|
+
metrics_group1, metrics_group2 : list of dicts
|
|
437
|
+
Each dict has 'fractional_occupancy', 'mean_dwell_time', 'transition_rate'
|
|
438
|
+
K : int
|
|
439
|
+
Number of states
|
|
440
|
+
correction : str
|
|
441
|
+
'bonferroni' or 'fdr' for multiple comparison correction
|
|
442
|
+
"""
|
|
443
|
+
results = {}
|
|
444
|
+
p_values = []
|
|
445
|
+
|
|
446
|
+
# Fractional occupancy per state
|
|
447
|
+
for k in range(K):
|
|
448
|
+
occ1 = [m['fractional_occupancy'][k] for m in metrics_group1]
|
|
449
|
+
occ2 = [m['fractional_occupancy'][k] for m in metrics_group2]
|
|
450
|
+
|
|
451
|
+
stat, p = stats.mannwhitneyu(occ1, occ2, alternative='two-sided')
|
|
452
|
+
results[f'frac_occ_state{k}'] = {
|
|
453
|
+
'group1_mean': np.mean(occ1), 'group2_mean': np.mean(occ2),
|
|
454
|
+
'U': stat, 'p': p
|
|
455
|
+
}
|
|
456
|
+
p_values.append(p)
|
|
457
|
+
|
|
458
|
+
# Dwell time per state
|
|
459
|
+
for k in range(K):
|
|
460
|
+
dwell1 = [m['mean_dwell_time'][k] for m in metrics_group1]
|
|
461
|
+
dwell2 = [m['mean_dwell_time'][k] for m in metrics_group2]
|
|
462
|
+
|
|
463
|
+
stat, p = stats.mannwhitneyu(dwell1, dwell2, alternative='two-sided')
|
|
464
|
+
results[f'dwell_state{k}'] = {
|
|
465
|
+
'group1_mean': np.mean(dwell1), 'group2_mean': np.mean(dwell2),
|
|
466
|
+
'U': stat, 'p': p
|
|
467
|
+
}
|
|
468
|
+
p_values.append(p)
|
|
469
|
+
|
|
470
|
+
# Transition rate
|
|
471
|
+
rate1 = [m['transition_rate'] for m in metrics_group1]
|
|
472
|
+
rate2 = [m['transition_rate'] for m in metrics_group2]
|
|
473
|
+
stat, p = stats.mannwhitneyu(rate1, rate2, alternative='two-sided')
|
|
474
|
+
results['transition_rate'] = {
|
|
475
|
+
'group1_mean': np.mean(rate1), 'group2_mean': np.mean(rate2),
|
|
476
|
+
'U': stat, 'p': p
|
|
477
|
+
}
|
|
478
|
+
p_values.append(p)
|
|
479
|
+
|
|
480
|
+
# Multiple comparison correction
|
|
481
|
+
from statsmodels.stats.multitest import multipletests
|
|
482
|
+
reject, p_corrected, _, _ = multipletests(p_values, method='fdr_bh')
|
|
483
|
+
|
|
484
|
+
# Attach corrected p-values
|
|
485
|
+
for i, key in enumerate(results):
|
|
486
|
+
results[key]['p_corrected'] = p_corrected[i]
|
|
487
|
+
results[key]['significant'] = reject[i]
|
|
488
|
+
|
|
489
|
+
return results
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
---
|
|
493
|
+
|
|
494
|
+
## 7. Deep Data vs. Wide Data Strategies {#deep-vs-wide}
|
|
495
|
+
|
|
496
|
+
### Deep data (few subjects, many runs/long scans)
|
|
497
|
+
- Example: 5 subjects, each with 10 hours of scanning (Midnight Scan Club, MyConnectome)
|
|
498
|
+
- Strategy: fit rich per-subject models (HMM-MAR, rSLDS, many states)
|
|
499
|
+
- Cross-validate within-subject (train on 80% of runs, test on 20%)
|
|
500
|
+
- Compare subjects qualitatively — each subject gets a full characterization
|
|
501
|
+
- Can detect rare or idiosyncratic states that group models would miss
|
|
502
|
+
|
|
503
|
+
### Wide data (many subjects, short scans)
|
|
504
|
+
- Example: 1000 subjects from UK Biobank, each with 6 min of resting-state
|
|
505
|
+
- Strategy: group-level HMM or concatenation approach with simple model (Gaussian HMM)
|
|
506
|
+
- K=4-8 is typical — not enough per-subject data for more
|
|
507
|
+
- Power is in between-subject comparisons, not within-subject dynamics
|
|
508
|
+
- Use diagonal covariance or low-dimensional features to keep parameter count manageable
|
|
509
|
+
|
|
510
|
+
### Mixed strategies
|
|
511
|
+
- Fit group model on all subjects to define states
|
|
512
|
+
- Then, for each subject, fix state definitions and estimate only the transition matrix
|
|
513
|
+
and initial state distribution (fewer parameters, estimable from short scans)
|
|
514
|
+
- This gives subject-specific dynamics with group-defined states
|
|
515
|
+
|
|
516
|
+
```python
|
|
517
|
+
def refit_transitions_per_subject(group_model, subject_data):
|
|
518
|
+
"""Fix emission parameters from group model, refit transitions per subject.
|
|
519
|
+
|
|
520
|
+
This is the hybrid approach: group-defined states, subject-specific dynamics.
|
|
521
|
+
"""
|
|
522
|
+
from hmmlearn import hmm
|
|
523
|
+
|
|
524
|
+
K = group_model.n_components
|
|
525
|
+
subject_transitions = {}
|
|
526
|
+
|
|
527
|
+
for sub_id, runs in subject_data.items():
|
|
528
|
+
data = np.vstack(runs)
|
|
529
|
+
lengths = [r.shape[0] for r in runs]
|
|
530
|
+
|
|
531
|
+
# Create new model with fixed emissions
|
|
532
|
+
sub_model = hmm.GaussianHMM(
|
|
533
|
+
n_components=K,
|
|
534
|
+
covariance_type=group_model.covariance_type,
|
|
535
|
+
n_iter=100,
|
|
536
|
+
params='st', # only update startprob and transmat
|
|
537
|
+
init_params='', # don't reinitialize anything
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
# Copy fixed emissions from group model
|
|
541
|
+
sub_model.means_ = group_model.means_.copy()
|
|
542
|
+
sub_model.covars_ = group_model.covars_.copy()
|
|
543
|
+
sub_model.startprob_ = group_model.startprob_.copy()
|
|
544
|
+
sub_model.transmat_ = group_model.transmat_.copy()
|
|
545
|
+
|
|
546
|
+
sub_model.fit(data, lengths=lengths)
|
|
547
|
+
subject_transitions[sub_id] = sub_model.transmat_.copy()
|
|
548
|
+
|
|
549
|
+
return subject_transitions
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
---
|
|
553
|
+
|
|
554
|
+
## 8. Statistical Testing on SSM-Derived Metrics {#stats}
|
|
555
|
+
|
|
556
|
+
### Permutation testing (recommended for SSM-derived statistics)
|
|
557
|
+
|
|
558
|
+
SSM-derived metrics (dwell times, transition probabilities) often violate assumptions of
|
|
559
|
+
parametric tests. Permutation testing is distribution-free and appropriate.
|
|
560
|
+
|
|
561
|
+
```python
|
|
562
|
+
def permutation_test_groups(metric_group1, metric_group2, n_permutations=10000,
|
|
563
|
+
random_state=42):
|
|
564
|
+
"""Two-sample permutation test.
|
|
565
|
+
|
|
566
|
+
Parameters
|
|
567
|
+
----------
|
|
568
|
+
metric_group1, metric_group2 : array-like
|
|
569
|
+
Per-subject metric values
|
|
570
|
+
|
|
571
|
+
Returns
|
|
572
|
+
-------
|
|
573
|
+
observed_diff : float
|
|
574
|
+
p_value : float (two-sided)
|
|
575
|
+
"""
|
|
576
|
+
rng = np.random.RandomState(random_state)
|
|
577
|
+
|
|
578
|
+
g1 = np.array(metric_group1)
|
|
579
|
+
g2 = np.array(metric_group2)
|
|
580
|
+
all_data = np.concatenate([g1, g2])
|
|
581
|
+
n1 = len(g1)
|
|
582
|
+
|
|
583
|
+
observed_diff = g1.mean() - g2.mean()
|
|
584
|
+
|
|
585
|
+
perm_diffs = np.zeros(n_permutations)
|
|
586
|
+
for i in range(n_permutations):
|
|
587
|
+
perm = rng.permutation(all_data)
|
|
588
|
+
perm_diffs[i] = perm[:n1].mean() - perm[n1:].mean()
|
|
589
|
+
|
|
590
|
+
p_value = np.mean(np.abs(perm_diffs) >= np.abs(observed_diff))
|
|
591
|
+
|
|
592
|
+
return observed_diff, p_value
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
### Effect sizes
|
|
596
|
+
|
|
597
|
+
Always report effect sizes alongside p-values:
|
|
598
|
+
- Cohen's d for mean differences
|
|
599
|
+
- Eta-squared for ANOVA-style comparisons
|
|
600
|
+
- For transition matrices: Frobenius norm of the difference matrix
|
|
601
|
+
|
|
602
|
+
### Confounds in group comparisons
|
|
603
|
+
|
|
604
|
+
When comparing clinical groups, control for:
|
|
605
|
+
- Head motion (mean FD) — correlates with many SSM metrics
|
|
606
|
+
- Scan length (if variable) — affects estimation quality
|
|
607
|
+
- Age, sex — standard demographic confounds
|
|
608
|
+
- Scanner/site (for multi-site studies) — use as covariate or ComBat harmonization
|