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,266 @@
1
+ """Command handlers for fitting, decoding, summarizing, and scoring state models."""
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_predictions,
13
+ write_state_metrics,
14
+ write_state_model_scores,
15
+ )
16
+ from ..artifacts.state_scoring import _state_model_specification
17
+ from ..states.hmm import GaussianHMMStateModel
18
+ from ..states.kmeans import KMeansStateModel
19
+ from ..states.metrics import summarize_state_assignments
20
+ from ..states.streaming import (
21
+ fit_kmeans_store_materialized,
22
+ fit_minibatch_kmeans_store,
23
+ predict_kmeans_store,
24
+ score_kmeans_store,
25
+ )
26
+ from ..states.streaming_hmm import (
27
+ fit_gaussian_hmm_store,
28
+ predict_gaussian_hmm_store,
29
+ score_gaussian_hmm_store,
30
+ )
31
+ from ..storage import FeatureStore
32
+
33
+
34
+ def fit_states(namespace: argparse.Namespace) -> dict[str, object]:
35
+ """Fit one state model from a disk-backed feature store."""
36
+ store = FeatureStore.open(namespace.store)
37
+ if namespace.output.exists() or namespace.output.is_symlink():
38
+ raise FileExistsError(f"model artifact path already exists: {namespace.output}")
39
+ subjects = None if namespace.subject is None else tuple(namespace.subject)
40
+ if subjects is not None:
41
+ missing = sorted(set(subjects) - set(store.subjects))
42
+ if missing:
43
+ raise ValueError(f"requested fit subjects are absent from the store: {missing}")
44
+
45
+ if namespace.method == "kmeans":
46
+ if namespace.fitting_mode == "streaming":
47
+ if namespace.algorithm != "minibatch":
48
+ raise ValueError("streaming KMeans requires --algorithm minibatch")
49
+ fit = fit_minibatch_kmeans_store(
50
+ store,
51
+ n_states=namespace.n_states,
52
+ seed=namespace.seed,
53
+ n_init=10 if namespace.n_init is None else namespace.n_init,
54
+ max_iter=namespace.max_iter,
55
+ batch_size=namespace.batch_size,
56
+ standardize_features=namespace.standardize_features,
57
+ reassignment_ratio=namespace.reassignment_ratio,
58
+ init_sample_size=namespace.init_sample_size,
59
+ n_pca_components=namespace.n_pca_components,
60
+ pca_batch_size=namespace.pca_batch_size,
61
+ subjects=subjects,
62
+ )
63
+ else:
64
+ if namespace.init_sample_size is not None:
65
+ raise ValueError("materialized KMeans does not use --init-sample-size")
66
+ fit = fit_kmeans_store_materialized(
67
+ store,
68
+ n_states=namespace.n_states,
69
+ seed=namespace.seed,
70
+ n_init=10 if namespace.n_init is None else namespace.n_init,
71
+ max_iter=namespace.max_iter,
72
+ algorithm=namespace.algorithm,
73
+ batch_size=namespace.batch_size,
74
+ standardize_features=namespace.standardize_features,
75
+ reassignment_ratio=namespace.reassignment_ratio,
76
+ n_pca_components=namespace.n_pca_components,
77
+ subjects=subjects,
78
+ )
79
+ model = fit.model
80
+ fit_sequence_count = len(fit.assignments.sequences)
81
+ artifact = save_fitted_model(model, namespace.output)
82
+ return {
83
+ "model_kind": "kmeans-state",
84
+ "method": namespace.method,
85
+ "fitting_mode": namespace.fitting_mode,
86
+ "output": str(artifact),
87
+ "fit_subjects": list(model.fit_subjects),
88
+ "fit_sample_count": model.fit_sample_count,
89
+ "fit_sequence_count": fit_sequence_count,
90
+ "n_states": model.n_states,
91
+ "seed": model.seed,
92
+ "converged": None,
93
+ "log_likelihood": None,
94
+ "inertia": model.inertia,
95
+ "init_sample_size": model.init_sample_size,
96
+ "n_pca_components": model.n_pca_components,
97
+ "pca_batch_size": model.pca_batch_size,
98
+ }
99
+
100
+ if namespace.n_pca_components is None:
101
+ raise ValueError("--n-pca-components is required when --method hmm")
102
+ fit = fit_gaussian_hmm_store(
103
+ store,
104
+ n_states=namespace.n_states,
105
+ seed=namespace.seed,
106
+ n_pca_components=namespace.n_pca_components,
107
+ covariance_type=namespace.covariance_type,
108
+ n_init=1 if namespace.n_init is None else namespace.n_init,
109
+ n_iter=namespace.n_iter,
110
+ tol=namespace.tol,
111
+ pca_batch_size=namespace.pca_batch_size,
112
+ subjects=subjects,
113
+ minimum_sequence_length=namespace.minimum_sequence_length,
114
+ )
115
+ model = fit.model
116
+ artifact = save_fitted_model(model, namespace.output)
117
+ return {
118
+ "model_kind": "gaussian-hmm-state",
119
+ "method": namespace.method,
120
+ "output": str(artifact),
121
+ "fit_subjects": list(model.fit_subjects),
122
+ "fit_sample_count": model.fit_sample_count,
123
+ "fit_sequence_count": model.fit_sequence_count,
124
+ "n_states": model.n_states,
125
+ "seed": model.seed,
126
+ "converged": model.converged,
127
+ "log_likelihood": model.log_likelihood,
128
+ "n_pca_components": model.n_pca_components,
129
+ "omitted_short_sequence_count": model.omitted_short_sequence_count,
130
+ "pca_batch_size": model.pca_batch_size,
131
+ }
132
+
133
+
134
+ def selected_store_subjects(
135
+ store: FeatureStore,
136
+ requested: list[str] | None,
137
+ ) -> tuple[str, ...] | None:
138
+ """Validate and retain an optional store-subject selection."""
139
+ if requested is None:
140
+ return None
141
+ missing = sorted(set(requested) - set(store.subjects))
142
+ if missing:
143
+ raise ValueError(f"requested prediction subjects are absent from the store: {missing}")
144
+ return tuple(requested)
145
+
146
+
147
+ def predict_states(namespace: argparse.Namespace) -> dict[str, object]:
148
+ """Decode a feature store with a fitted state model."""
149
+ store = FeatureStore.open(namespace.store)
150
+ model = load_fitted_model(namespace.model)
151
+ subjects = selected_store_subjects(store, namespace.subject)
152
+ if isinstance(model, KMeansStateModel):
153
+ assignments = predict_kmeans_store(
154
+ model,
155
+ store,
156
+ subjects=subjects,
157
+ allow_fit_subjects=namespace.allow_fit_subjects,
158
+ )
159
+ predictions = StatePredictions(
160
+ assignments=assignments,
161
+ model_kind="kmeans-state",
162
+ model_seed=model.seed,
163
+ )
164
+ elif isinstance(model, GaussianHMMStateModel):
165
+ result = predict_gaussian_hmm_store(
166
+ model,
167
+ store,
168
+ subjects=subjects,
169
+ allow_fit_subjects=namespace.allow_fit_subjects,
170
+ )
171
+ predictions = StatePredictions(
172
+ assignments=result.assignments,
173
+ model_kind="gaussian-hmm-state",
174
+ model_seed=model.seed,
175
+ posterior_probabilities=result.posterior_probabilities,
176
+ log_likelihood=result.log_likelihood,
177
+ )
178
+ else:
179
+ raise TypeError("predict-states requires a KMeans or Gaussian HMM model artifact")
180
+ artifact = save_state_predictions(predictions, namespace.output)
181
+ return {
182
+ "model_kind": predictions.model_kind,
183
+ "output": str(artifact),
184
+ "subjects": list(predictions.subjects),
185
+ "n_sequences": predictions.n_sequences,
186
+ "n_samples": predictions.n_samples,
187
+ "allow_fit_subjects": bool(namespace.allow_fit_subjects),
188
+ "log_likelihood": predictions.log_likelihood,
189
+ }
190
+
191
+
192
+ def summarize_states(namespace: argparse.Namespace) -> dict[str, object]:
193
+ """Write acquisition-level state metrics."""
194
+ predictions = load_state_predictions(namespace.predictions)
195
+ output = write_state_metrics(predictions, namespace.output)
196
+ metrics = summarize_state_assignments(predictions.assignments)
197
+ return {
198
+ "output": str(output),
199
+ "model_kind": predictions.model_kind,
200
+ "n_states": predictions.assignments.n_states,
201
+ "n_runs": len(metrics),
202
+ "n_sequences": predictions.n_sequences,
203
+ "n_samples": predictions.n_samples,
204
+ "subjects": list(predictions.subjects),
205
+ }
206
+
207
+
208
+ def score_states(namespace: argparse.Namespace) -> dict[str, object]:
209
+ """Score a fitted state model on selected acquisitions."""
210
+ if namespace.output.exists() or namespace.output.is_symlink():
211
+ raise FileExistsError(f"state-model score output path already exists: {namespace.output}")
212
+ store = FeatureStore.open(namespace.store)
213
+ model = load_fitted_model(namespace.model)
214
+ subjects = selected_store_subjects(store, namespace.subject)
215
+ if isinstance(model, KMeansStateModel):
216
+ scores = score_kmeans_store(
217
+ model,
218
+ store,
219
+ subjects=subjects,
220
+ allow_fit_subjects=namespace.allow_fit_subjects,
221
+ )
222
+ model_kind = "kmeans-state"
223
+ minimum_sequence_length = 1
224
+ elif isinstance(model, GaussianHMMStateModel):
225
+ scores = score_gaussian_hmm_store(
226
+ model,
227
+ store,
228
+ subjects=subjects,
229
+ allow_fit_subjects=namespace.allow_fit_subjects,
230
+ )
231
+ model_kind = "gaussian-hmm-state"
232
+ minimum_sequence_length = model.minimum_sequence_length
233
+ else:
234
+ raise TypeError("score-states requires a KMeans or Gaussian HMM model artifact")
235
+ scored_subjects = tuple(dict.fromkeys(score.subject for score in scores))
236
+ selected_subject_set = set(scored_subjects)
237
+ omitted_short_sequence_count = sum(
238
+ identity[0] in selected_subject_set and count < minimum_sequence_length
239
+ for identity, count in store.sequence_sample_counts
240
+ )
241
+ output = write_state_model_scores(
242
+ scores,
243
+ namespace.output,
244
+ model_kind=model_kind,
245
+ model_seed=model.seed,
246
+ n_states=model.n_states,
247
+ fit_subjects=model.fit_subjects,
248
+ source_contract=store.source_contract,
249
+ sample_interval_seconds=store.sample_interval_seconds,
250
+ minimum_sequence_length=minimum_sequence_length,
251
+ omitted_short_sequence_count=omitted_short_sequence_count,
252
+ allow_fit_subjects=namespace.allow_fit_subjects,
253
+ model_specification=_state_model_specification(model),
254
+ )
255
+ return {
256
+ "output": str(output),
257
+ "model_kind": model_kind,
258
+ "n_states": model.n_states,
259
+ "subjects": list(scored_subjects),
260
+ "n_runs": len(scores),
261
+ "n_sequences": sum(score.n_sequences for score in scores),
262
+ "n_samples": sum(score.n_samples for score in scores),
263
+ "minimum_sequence_length": minimum_sequence_length,
264
+ "omitted_short_sequence_count": omitted_short_sequence_count,
265
+ "allow_fit_subjects": bool(namespace.allow_fit_subjects),
266
+ }
@@ -0,0 +1,68 @@
1
+ """Connectivity estimators and graph summaries of connectivity matrices."""
2
+
3
+ from .correlation import edge_index, fisher_z_edges, weighted_correlation
4
+ from .instantaneous import (
5
+ ETS,
6
+ MTD,
7
+ InstantaneousEdgeResult,
8
+ )
9
+ from .leida import (
10
+ LEiDA,
11
+ LEiDAResult,
12
+ )
13
+ from .lowrank import (
14
+ LowRankCovariance,
15
+ LowRankCovarianceResult,
16
+ StandardizedPCA,
17
+ bidirectional_heldout_r2,
18
+ effective_rank,
19
+ eigen_concentration,
20
+ fit_standardized_pca,
21
+ heldout_reconstruction_r2,
22
+ mean_projector_basis,
23
+ subspace_distance,
24
+ subspace_similarity,
25
+ )
26
+ from .partition import (
27
+ FixedPartitionGraph,
28
+ FixedPartitionGraphResult,
29
+ PartitionEdgeSummary,
30
+ achieved_density,
31
+ fixed_partition_modularity,
32
+ participation_coefficient,
33
+ partition_edge_summary,
34
+ positive_proportional_adjacency,
35
+ )
36
+ from .windows import SlidingWindowFC, WindowFCResult
37
+
38
+ __all__ = [
39
+ "ETS",
40
+ "MTD",
41
+ "FixedPartitionGraph",
42
+ "FixedPartitionGraphResult",
43
+ "InstantaneousEdgeResult",
44
+ "LEiDA",
45
+ "LEiDAResult",
46
+ "LowRankCovariance",
47
+ "LowRankCovarianceResult",
48
+ "PartitionEdgeSummary",
49
+ "SlidingWindowFC",
50
+ "StandardizedPCA",
51
+ "WindowFCResult",
52
+ "achieved_density",
53
+ "bidirectional_heldout_r2",
54
+ "edge_index",
55
+ "effective_rank",
56
+ "eigen_concentration",
57
+ "fisher_z_edges",
58
+ "fit_standardized_pca",
59
+ "fixed_partition_modularity",
60
+ "heldout_reconstruction_r2",
61
+ "mean_projector_basis",
62
+ "participation_coefficient",
63
+ "partition_edge_summary",
64
+ "positive_proportional_adjacency",
65
+ "subspace_distance",
66
+ "subspace_similarity",
67
+ "weighted_correlation",
68
+ ]
@@ -0,0 +1,130 @@
1
+ """Shared instantaneous edge-product kernels.
2
+
3
+ ETS and MTD differ in how they construct their ROI samples. Once those
4
+ samples have been constructed, both methods use the same upper-triangular
5
+ outer-product operation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterable
11
+
12
+ import numpy as np
13
+ from numpy.typing import ArrayLike, NDArray
14
+
15
+ from .._arrays import readonly_copy as _readonly
16
+ from .._validation import validated_roi_indices
17
+ from .correlation import edge_index
18
+
19
+
20
+ def _validated_samples(samples: ArrayLike) -> NDArray[np.float64]:
21
+ values = np.asarray(samples, dtype=float)
22
+ if values.ndim != 2 or values.shape[1] < 2:
23
+ raise ValueError("samples must be a frames-by-ROI array")
24
+ if not np.isfinite(values).all():
25
+ raise ValueError("samples contain non-finite values")
26
+ return values
27
+
28
+
29
+ def edge_products(samples: ArrayLike) -> NDArray[np.float64]:
30
+ """Return all upper-triangular instantaneous ROI edge products.
31
+
32
+ The rows may be segment-standardized signals (ETS) or standardized
33
+ temporal derivatives (MTD); the kernel intentionally has no method-specific
34
+ temporal semantics.
35
+ """
36
+ values = _validated_samples(samples)
37
+ left, right = edge_index(values.shape[1])
38
+ products = values[:, left] * values[:, right]
39
+ if not np.isfinite(products).all():
40
+ raise ValueError("edge products contain non-finite values")
41
+ return _readonly(products)
42
+
43
+
44
+ def edge_rss(samples: ArrayLike) -> NDArray[np.float64]:
45
+ """Compute all-edge root-sum-square amplitude without materializing edges."""
46
+ values = _validated_samples(samples)
47
+ squared = values * values
48
+ squared_rss = ((squared.sum(axis=1) ** 2 - (squared * squared).sum(axis=1)) / 2).clip(min=0.0)
49
+ return _readonly(np.sqrt(squared_rss))
50
+
51
+
52
+ def cross_block_products(
53
+ samples: ArrayLike,
54
+ left: Iterable[int],
55
+ right: Iterable[int],
56
+ ) -> NDArray[np.float64]:
57
+ """Return the mean product across two disjoint ROI blocks per row."""
58
+ values = _validated_samples(samples)
59
+ left_nodes = validated_roi_indices(left, n_rois=values.shape[1], label="left block", minimum=1)
60
+ right_nodes = validated_roi_indices(
61
+ right, n_rois=values.shape[1], label="right block", minimum=1
62
+ )
63
+ overlap = sorted(set(left_nodes.tolist()).intersection(right_nodes.tolist()))
64
+ if overlap:
65
+ raise ValueError(f"cross-block products require disjoint blocks; overlap={overlap}")
66
+ products = (values[:, left_nodes, None] * values[:, None, right_nodes]).mean(axis=(1, 2))
67
+ return _readonly(products)
68
+
69
+
70
+ def within_block_products(
71
+ samples: ArrayLike,
72
+ nodes: Iterable[int],
73
+ ) -> NDArray[np.float64]:
74
+ """Return the off-diagonal mean product within one ROI block per row."""
75
+ values = _validated_samples(samples)
76
+ selected = validated_roi_indices(nodes, n_rois=values.shape[1], label="within block", minimum=2)
77
+ left, right = np.triu_indices(len(selected), k=1)
78
+ products = (values[:, selected[left]] * values[:, selected[right]]).mean(axis=1)
79
+ return _readonly(products)
80
+
81
+
82
+ def _edge_block_mask(
83
+ result: object,
84
+ left_nodes: NDArray[np.int64],
85
+ right_nodes: NDArray[np.int64],
86
+ ) -> NDArray[np.bool_]:
87
+ edge_i = np.asarray(result.edge_i, dtype=np.int64)
88
+ edge_j = np.asarray(result.edge_j, dtype=np.int64)
89
+ return np.isin(edge_i, left_nodes) & np.isin(edge_j, right_nodes) | (
90
+ np.isin(edge_i, right_nodes) & np.isin(edge_j, left_nodes)
91
+ )
92
+
93
+
94
+ def cross_block_edge_summary(
95
+ result: object,
96
+ left: Iterable[int],
97
+ right: Iterable[int],
98
+ ) -> NDArray[np.float64]:
99
+ """Summarize cross-block products from a common edge result."""
100
+ n_rois = len(result.roi_names)
101
+ left_nodes = validated_roi_indices(left, n_rois=n_rois, label="left block", minimum=1)
102
+ right_nodes = validated_roi_indices(right, n_rois=n_rois, label="right block", minimum=1)
103
+ overlap = sorted(set(left_nodes.tolist()).intersection(right_nodes.tolist()))
104
+ if overlap:
105
+ raise ValueError(f"cross-block products require disjoint blocks; overlap={overlap}")
106
+ if result.features is not None:
107
+ selected = _edge_block_mask(result, left_nodes, right_nodes)
108
+ features = np.asarray(result.features, dtype=float)
109
+ if not np.any(selected):
110
+ raise ValueError("cross-block product summary selected no edges")
111
+ return _readonly(features[:, selected].mean(axis=1))
112
+ raise ValueError("cross-block summary requires materialized edge features")
113
+
114
+
115
+ def within_block_edge_summary(
116
+ result: object,
117
+ nodes: Iterable[int],
118
+ ) -> NDArray[np.float64]:
119
+ """Summarize within-block products from a common edge result."""
120
+ n_rois = len(result.roi_names)
121
+ selected_nodes = validated_roi_indices(nodes, n_rois=n_rois, label="within block", minimum=2)
122
+ if result.features is not None:
123
+ edge_i = np.asarray(result.edge_i, dtype=np.int64)
124
+ edge_j = np.asarray(result.edge_j, dtype=np.int64)
125
+ selected = np.isin(edge_i, selected_nodes) & np.isin(edge_j, selected_nodes)
126
+ features = np.asarray(result.features, dtype=float)
127
+ if not np.any(selected):
128
+ raise ValueError("within-block product summary selected no edges")
129
+ return _readonly(features[:, selected].mean(axis=1))
130
+ raise ValueError("within-block summary requires materialized edge features")
@@ -0,0 +1,61 @@
1
+ """Correlation matrices and edge-vector utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ from numpy.typing import ArrayLike, NDArray
7
+
8
+
9
+ def edge_index(n_rois: int) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
10
+ if n_rois < 2:
11
+ raise ValueError("at least two ROIs are required")
12
+ left, right = np.triu_indices(n_rois, k=1)
13
+ return left.astype(np.int64), right.astype(np.int64)
14
+
15
+
16
+ def weighted_correlation(
17
+ values: ArrayLike,
18
+ weights: ArrayLike | None = None,
19
+ ) -> NDArray[np.float64]:
20
+ """Compute a finite weighted Pearson correlation matrix."""
21
+ data = np.asarray(values, dtype=float)
22
+ if data.ndim != 2 or data.shape[0] < 2 or data.shape[1] < 2:
23
+ raise ValueError("values must have at least two frames and two ROIs")
24
+ if not np.isfinite(data).all():
25
+ raise ValueError("values contain non-finite samples")
26
+
27
+ if weights is None:
28
+ weight = np.ones(data.shape[0], dtype=float)
29
+ else:
30
+ weight = np.asarray(weights, dtype=float)
31
+ if weight.ndim != 1 or len(weight) != len(data):
32
+ raise ValueError("weights must be one-dimensional and match the frame count")
33
+ if not np.isfinite(weight).all() or np.any(weight < 0) or weight.sum() <= 0:
34
+ raise ValueError("weights must be finite, non-negative, and have positive sum")
35
+
36
+ mean = np.average(data, axis=0, weights=weight)
37
+ centered = data - mean
38
+ covariance = (centered * weight[:, None]).T @ centered / weight.sum()
39
+ variance = np.diag(covariance)
40
+ if np.any(~np.isfinite(variance)) or np.any(variance <= 1e-12):
41
+ raise ValueError("correlation is undefined for a constant or invalid ROI")
42
+ scale = np.sqrt(variance)
43
+ correlation = covariance / np.outer(scale, scale)
44
+ np.fill_diagonal(correlation, 1.0)
45
+ if not np.isfinite(correlation).all():
46
+ raise ValueError("correlation matrix contains non-finite values")
47
+ return np.clip(correlation, -1.0, 1.0)
48
+
49
+
50
+ def fisher_z_edges(
51
+ correlation: ArrayLike,
52
+ ) -> tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.int64]]:
53
+ """Return finite upper-triangular Fisher-z edges and their node indices."""
54
+ matrix = np.asarray(correlation, dtype=float)
55
+ if matrix.ndim != 2 or matrix.shape[0] != matrix.shape[1]:
56
+ raise ValueError("correlation must be a square matrix")
57
+ if not np.isfinite(matrix).all():
58
+ raise ValueError("correlation contains non-finite values")
59
+ left, right = edge_index(matrix.shape[0])
60
+ edges = np.arctanh(np.clip(matrix[left, right], -0.999999, 0.999999))
61
+ return edges, left, right