cccpm 0.3.2__tar.gz → 0.4.0__tar.gz
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.
- {cccpm-0.3.2 → cccpm-0.4.0}/PKG-INFO +1 -1
- {cccpm-0.3.2 → cccpm-0.4.0}/pyproject.toml +1 -1
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/constants.py +12 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/cpm_analysis.py +49 -5
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/edge_selection.py +0 -67
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/data_loader.py +11 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/html_report.py +3 -0
- cccpm-0.4.0/src/cccpm/reporting/plots/stats_figures.py +88 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/section_builders.py +96 -9
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/templates/report.html.j2 +60 -13
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/templates/styles.css +10 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/results_manager.py +251 -75
- {cccpm-0.3.2 → cccpm-0.4.0}/LICENSE +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/README.md +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/__init__.py +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/inner_fold.py +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/logging.py +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/models/__init__.py +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/models/linear_model.py +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/models/nonlinear_models.py +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/__init__.py +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/assets/CCCPM.png +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/plots/__init__.py +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/plots/brain_figures.py +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/plots/chord_v2.py +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/plots/connectome_utils.py +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/plots/cpm_chord_plot.py +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/plots/figure_style.py +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/plots/plots.py +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/plots/utils.py +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/reporting_utils.py +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/table_builders.py +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/scoring.py +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/simulation/__init__.py +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/simulation/simulate_multivariate.py +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/simulation/simulate_sem.py +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/simulation/simulate_simple.py +0 -0
- {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/utils.py +0 -0
|
@@ -14,6 +14,18 @@ class Networks(IntEnum):
|
|
|
14
14
|
both = 2
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
class EdgeSignificance(str, Enum):
|
|
18
|
+
"""Method for testing edge-stability significance under permutation.
|
|
19
|
+
|
|
20
|
+
``nbs`` — Network-Based Statistic: connected-component test controlling
|
|
21
|
+
FWER via a permutation max-component null (subnetwork-level).
|
|
22
|
+
``tfce`` — Threshold-Free Cluster Enhancement: per-edge FWER control with
|
|
23
|
+
no arbitrary primary threshold.
|
|
24
|
+
"""
|
|
25
|
+
nbs = "nbs"
|
|
26
|
+
tfce = "tfce"
|
|
27
|
+
|
|
28
|
+
|
|
17
29
|
class Models(IntEnum):
|
|
18
30
|
"""Model types in CPM analysis."""
|
|
19
31
|
connectome = 0
|
|
@@ -45,6 +45,9 @@ class CPMAnalysis:
|
|
|
45
45
|
impute_missing_values: bool = True,
|
|
46
46
|
calculate_residuals: bool = False,
|
|
47
47
|
n_permutations: int = 0,
|
|
48
|
+
edge_significance_method: str = "nbs",
|
|
49
|
+
nbs_threshold: float = 0.5,
|
|
50
|
+
nbs_component_stat: str = "extent",
|
|
48
51
|
atlas_labels: str = None,
|
|
49
52
|
device: str = 'cpu',
|
|
50
53
|
random_state: int = 42):
|
|
@@ -85,6 +88,19 @@ class CPMAnalysis:
|
|
|
85
88
|
n_permutations: int, default=0
|
|
86
89
|
Number of label permutations for significance testing. ``0`` disables
|
|
87
90
|
permutation testing; use 1000+ for publishable p-values.
|
|
91
|
+
edge_significance_method: str, default='nbs'
|
|
92
|
+
How edge-stability significance is established from the permutations.
|
|
93
|
+
``'nbs'`` uses the Network-Based Statistic (connected-component test,
|
|
94
|
+
subnetwork-level FWER control); ``'tfce'`` uses network Threshold-Free
|
|
95
|
+
Cluster Enhancement (per-edge FWER control, no primary threshold).
|
|
96
|
+
nbs_threshold: float, default=0.5
|
|
97
|
+
Stability threshold (``>=``) for NBS component forming. Because
|
|
98
|
+
stability is discrete over the outer folds, ``0.5`` keeps edges
|
|
99
|
+
selected in a majority of folds. Ignored when method is ``'tfce'``.
|
|
100
|
+
nbs_component_stat: str, default='extent'
|
|
101
|
+
NBS component statistic: ``'extent'`` (number of edges, classic NBS)
|
|
102
|
+
or ``'intensity'`` (summed supra-threshold stability). Ignored when
|
|
103
|
+
method is ``'tfce'``.
|
|
88
104
|
atlas_labels: str, default=None
|
|
89
105
|
Path to a CSV file with atlas region labels (columns ``x``, ``y``,
|
|
90
106
|
``z``, ``region``) used for the brain plots in the report.
|
|
@@ -119,6 +135,9 @@ class CPMAnalysis:
|
|
|
119
135
|
self.impute_missing_values = impute_missing_values
|
|
120
136
|
self.calculate_residuals = calculate_residuals
|
|
121
137
|
self.n_permutations = n_permutations
|
|
138
|
+
self.edge_significance_method = edge_significance_method
|
|
139
|
+
self.nbs_threshold = nbs_threshold
|
|
140
|
+
self.nbs_component_stat = nbs_component_stat
|
|
122
141
|
|
|
123
142
|
if device.lower() == 'gpu' or device.lower() == 'cuda':
|
|
124
143
|
if torch.cuda.is_available():
|
|
@@ -245,7 +264,11 @@ class CPMAnalysis:
|
|
|
245
264
|
self.logger.info(f"Running {self.n_permutations} permutations.")
|
|
246
265
|
y_perms = self._create_permuted_y(y)
|
|
247
266
|
self._single_run(X=X, y=y_perms, covariates=covariates, perm_run=True)
|
|
248
|
-
PermutationManager.calculate_permutation_results(
|
|
267
|
+
PermutationManager.calculate_permutation_results(
|
|
268
|
+
self.results_directory, self.logger,
|
|
269
|
+
method=self.edge_significance_method,
|
|
270
|
+
nbs_threshold=self.nbs_threshold,
|
|
271
|
+
nbs_component_stat=self.nbs_component_stat)
|
|
249
272
|
|
|
250
273
|
self.logger.info("=" * 50)
|
|
251
274
|
self.logger.info("Estimation completed.")
|
|
@@ -385,12 +408,33 @@ class CPMAnalysis:
|
|
|
385
408
|
if self.select_stable_edges:
|
|
386
409
|
return select_stable_edges(stability_edges, self.stability_threshold)
|
|
387
410
|
|
|
388
|
-
edges = torch.zeros(X_train.shape[1], len(Networks) - 1, len(best_params)
|
|
411
|
+
edges = torch.zeros(X_train.shape[1], len(Networks) - 1, len(best_params),
|
|
412
|
+
device=self.device)
|
|
413
|
+
|
|
414
|
+
# The edge statistics (r, p) depend only on the data and the fixed
|
|
415
|
+
# correlation statistic — not on the per-run selector params — so compute
|
|
416
|
+
# them for every run in a single batched pass (over all target columns)
|
|
417
|
+
# instead of recomputing them once per run. This is the expensive step;
|
|
418
|
+
# only the cheap thresholding below can vary per run (e.g. when inner CV
|
|
419
|
+
# selects different params for different runs).
|
|
420
|
+
#
|
|
421
|
+
# INVARIANT: this single shared pass is correct only because the edge
|
|
422
|
+
# statistic itself is fixed across runs (it is a scalar on
|
|
423
|
+
# UnivariateEdgeSelection, never part of the tunable param grid — only
|
|
424
|
+
# the p-threshold / correction are). If the statistic (e.g. pearson vs
|
|
425
|
+
# spearman) ever becomes a per-run hyperparameter, r/p can no longer be
|
|
426
|
+
# shared and must be computed once per distinct statistic in play.
|
|
427
|
+
r_edges, p_edges = self.edge_selection.edge_statistic.fit_transform(
|
|
428
|
+
X=X_train, y=y_train, covariates=cov_train, device=self.device)
|
|
429
|
+
|
|
389
430
|
for run_id, params in enumerate(best_params):
|
|
390
431
|
self.edge_selection.set_params(**params)
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
432
|
+
# Feed this run's precomputed r/p column into the (unchanged)
|
|
433
|
+
# selection path, keeping any per-run multiple-comparison correction
|
|
434
|
+
# applied within a single run's family of edges, exactly as before.
|
|
435
|
+
self.edge_selection.r_edges = r_edges[:, [run_id]]
|
|
436
|
+
self.edge_selection.p_edges = p_edges[:, [run_id]]
|
|
437
|
+
current_edges = self.edge_selection.return_selected_edges()
|
|
394
438
|
edges[:, :, run_id] = current_edges.squeeze()
|
|
395
439
|
|
|
396
440
|
return edges
|
|
@@ -1,72 +1,10 @@
|
|
|
1
|
-
import pandas as pd
|
|
2
1
|
import numpy as np
|
|
3
|
-
from scipy.stats import ttest_1samp, t, rankdata
|
|
4
2
|
from typing import Union
|
|
5
3
|
|
|
6
4
|
import torch
|
|
7
5
|
|
|
8
6
|
from sklearn.base import BaseEstimator
|
|
9
7
|
from sklearn.model_selection import ParameterGrid
|
|
10
|
-
import statsmodels.stats.multitest as multitest
|
|
11
|
-
from warnings import filterwarnings
|
|
12
|
-
|
|
13
|
-
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def one_sample_t_test(matrix, population_mean):
|
|
18
|
-
# Calculate the mean and standard deviation along the rows
|
|
19
|
-
sample_means = np.mean(matrix, axis=0)
|
|
20
|
-
sample_stds = np.std(matrix, axis=0, ddof=1)
|
|
21
|
-
n = matrix.shape[1] # Number of samples in each row
|
|
22
|
-
|
|
23
|
-
# Calculate the t-statistics
|
|
24
|
-
filterwarnings('ignore', category=RuntimeWarning)
|
|
25
|
-
t_stats = (sample_means - population_mean) / (sample_stds / np.sqrt(n))
|
|
26
|
-
|
|
27
|
-
# Calculate the p-values using the t-distribution survival function
|
|
28
|
-
p_values = 2 * t.sf(np.abs(t_stats), df=n - 1)
|
|
29
|
-
|
|
30
|
-
return t_stats, p_values
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def compute_t_and_p_values(correlations, df):
|
|
34
|
-
# Calculate t-statistics
|
|
35
|
-
t_stats = correlations * np.sqrt(df / (1 - correlations ** 2))
|
|
36
|
-
# Calculate p-values
|
|
37
|
-
p_values = 2 * t.sf(np.abs(t_stats), df=df)
|
|
38
|
-
return t_stats, p_values
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def compute_correlation_and_pvalues(x, Y, rank=False):
|
|
42
|
-
n = len(x)
|
|
43
|
-
|
|
44
|
-
if rank:
|
|
45
|
-
x = rankdata(x)
|
|
46
|
-
Y = rankdata(Y, axis=0)
|
|
47
|
-
|
|
48
|
-
# Mean-centering
|
|
49
|
-
x_centered = x - np.mean(x)
|
|
50
|
-
Y_centered = Y - np.mean(Y, axis=0)
|
|
51
|
-
|
|
52
|
-
# Correlation calculation
|
|
53
|
-
corr_numerator = np.dot(Y_centered.T, x_centered)
|
|
54
|
-
corr_denominator = (np.sqrt(np.sum(Y_centered ** 2, axis=0)) * np.sqrt(np.sum(x_centered ** 2)))
|
|
55
|
-
|
|
56
|
-
correlations = corr_numerator / corr_denominator
|
|
57
|
-
|
|
58
|
-
# Calculate t-statistics and p-values
|
|
59
|
-
_, p_values = compute_t_and_p_values(correlations, n - 2)
|
|
60
|
-
|
|
61
|
-
return correlations, p_values
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def pearson_correlation_with_pvalues(x, Y):
|
|
65
|
-
return compute_correlation_and_pvalues(x, Y, rank=False)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def spearman_correlation_with_pvalues(x, Y):
|
|
69
|
-
return compute_correlation_and_pvalues(x, Y, rank=True)
|
|
70
8
|
|
|
71
9
|
|
|
72
10
|
def torch_rankdata(data, dim=-1):
|
|
@@ -370,11 +308,6 @@ class EdgeStatistic(BaseEstimator):
|
|
|
370
308
|
device):
|
|
371
309
|
r_edges, p_edges = (torch.zeros((X.shape[1], y.shape[1]), device=device),
|
|
372
310
|
torch.ones((X.shape[1], y.shape[1]), device=device))
|
|
373
|
-
#if self.t_test_filter:
|
|
374
|
-
# _, p_values = one_sample_t_test(X, 0)
|
|
375
|
-
# valid_edges = p_values < 0.05
|
|
376
|
-
#else:
|
|
377
|
-
# valid_edges = np.bool(np.ones(X.shape[1]))
|
|
378
311
|
|
|
379
312
|
# 1. Convert to GPU Tensors immediately
|
|
380
313
|
X = torch.as_tensor(X, device=device, dtype=torch.float32)
|
|
@@ -162,6 +162,17 @@ class ReportDataLoader:
|
|
|
162
162
|
|
|
163
163
|
return edge_stability, edge_stability_significance
|
|
164
164
|
|
|
165
|
+
def load_edge_significance_meta(self) -> Optional[dict]:
|
|
166
|
+
"""Load the edge-significance diagnostics (method, null distributions,
|
|
167
|
+
components) written by the permutation step, or ``None`` if absent."""
|
|
168
|
+
import json
|
|
169
|
+
|
|
170
|
+
path = os.path.join(self.results_directory, "stability_edges_significance_meta.json")
|
|
171
|
+
if os.path.exists(path):
|
|
172
|
+
with open(path) as f:
|
|
173
|
+
return json.load(f)
|
|
174
|
+
return None
|
|
175
|
+
|
|
165
176
|
def load_data_insights(self) -> Tuple[Optional[pd.DataFrame], str, str]:
|
|
166
177
|
"""
|
|
167
178
|
Load data insight files (summary stats and scatter matrix).
|
|
@@ -68,6 +68,7 @@ class HTMLReporter:
|
|
|
68
68
|
"""Render the HTML report and write it to ``results_directory/report.html``."""
|
|
69
69
|
summary_df, scatter_path, _ = self.data_loader.load_data_insights()
|
|
70
70
|
edge_stability, edge_stability_sig = self.data_loader.load_edge_stability()
|
|
71
|
+
edge_significance_meta = self.data_loader.load_edge_significance_meta()
|
|
71
72
|
|
|
72
73
|
# ── Build template context ──────────────────────────────────────────
|
|
73
74
|
ctx: dict = {
|
|
@@ -120,6 +121,8 @@ class HTMLReporter:
|
|
|
120
121
|
edge_stability=edge_stability,
|
|
121
122
|
edge_stability_significance=edge_stability_sig,
|
|
122
123
|
atlas_labels=self.atlas_labels,
|
|
124
|
+
significance_meta=edge_significance_meta,
|
|
125
|
+
plots_dir=self.plots_dir,
|
|
123
126
|
))
|
|
124
127
|
|
|
125
128
|
# ── Render ─────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Statistics figures for the CCCPM HTML report.
|
|
3
|
+
|
|
4
|
+
Currently: the permutation null-distribution plot for edge-stability
|
|
5
|
+
significance (NBS max-component or TFCE max-TFCE null), with the critical
|
|
6
|
+
value and the observed statistic marked. Self-contained (input = numpy /
|
|
7
|
+
plain values, output = SVG path) to match the other report-figure modules.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from typing import Sequence
|
|
14
|
+
|
|
15
|
+
import matplotlib.pyplot as plt
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from cccpm.reporting.plots.figure_style import (
|
|
19
|
+
COLOR_MAP,
|
|
20
|
+
PANEL,
|
|
21
|
+
apply_nature_style,
|
|
22
|
+
figure_path,
|
|
23
|
+
save_figure,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def null_distribution_plot(
|
|
28
|
+
max_null: Sequence[float],
|
|
29
|
+
results_folder: str,
|
|
30
|
+
name: str,
|
|
31
|
+
*,
|
|
32
|
+
network: str = "positive",
|
|
33
|
+
observed: float | None = None,
|
|
34
|
+
critical_value: float | None = None,
|
|
35
|
+
statistic_label: str = "Max statistic",
|
|
36
|
+
) -> str:
|
|
37
|
+
"""
|
|
38
|
+
Plot the permutation max-statistic null distribution for one network.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
max_null : sequence of float
|
|
43
|
+
The per-permutation maximum statistic (max component size for NBS, max
|
|
44
|
+
TFCE score for TFCE) — i.e. the FWER null distribution.
|
|
45
|
+
results_folder : str
|
|
46
|
+
Directory to write the figure into.
|
|
47
|
+
name : str
|
|
48
|
+
Extension-less figure name.
|
|
49
|
+
network : {'positive', 'negative'}
|
|
50
|
+
Selects the accent colour.
|
|
51
|
+
observed : float, optional
|
|
52
|
+
The observed statistic to mark (largest true component / max TFCE).
|
|
53
|
+
critical_value : float, optional
|
|
54
|
+
The (1 - alpha) quantile of the null; drawn as the significance cutoff.
|
|
55
|
+
statistic_label : str
|
|
56
|
+
X-axis label describing the statistic.
|
|
57
|
+
|
|
58
|
+
Returns
|
|
59
|
+
-------
|
|
60
|
+
str
|
|
61
|
+
Path to the saved SVG.
|
|
62
|
+
"""
|
|
63
|
+
apply_nature_style()
|
|
64
|
+
color = COLOR_MAP.get(network, COLOR_MAP["both"])
|
|
65
|
+
null = np.asarray(list(max_null), dtype=float)
|
|
66
|
+
|
|
67
|
+
fig, ax = plt.subplots(figsize=PANEL)
|
|
68
|
+
if null.size:
|
|
69
|
+
n_bins = int(min(40, max(10, np.unique(null).size)))
|
|
70
|
+
ax.hist(null, bins=n_bins, color=color, alpha=0.55,
|
|
71
|
+
edgecolor="white", linewidth=0.3)
|
|
72
|
+
|
|
73
|
+
if critical_value is not None:
|
|
74
|
+
ax.axvline(critical_value, color="#555555", linestyle="--", linewidth=0.9,
|
|
75
|
+
label=f"critical value ({critical_value:.3g})")
|
|
76
|
+
if observed is not None:
|
|
77
|
+
ax.axvline(observed, color=color, linestyle="-", linewidth=1.3,
|
|
78
|
+
label=f"observed ({observed:.3g})")
|
|
79
|
+
|
|
80
|
+
ax.set_xlabel(statistic_label)
|
|
81
|
+
ax.set_ylabel("Permutations")
|
|
82
|
+
if critical_value is not None or observed is not None:
|
|
83
|
+
ax.legend(frameon=False, loc="upper right")
|
|
84
|
+
|
|
85
|
+
path_no_ext = figure_path(results_folder, name)
|
|
86
|
+
out = save_figure(fig, path_no_ext)
|
|
87
|
+
plt.close(fig)
|
|
88
|
+
return out
|
|
@@ -496,18 +496,61 @@ def build_brain_plots_context(
|
|
|
496
496
|
# 6. Stable Edges section
|
|
497
497
|
# ---------------------------------------------------------------------------
|
|
498
498
|
|
|
499
|
+
def _method_summary(meta: Optional[dict]) -> dict:
|
|
500
|
+
"""Human-readable summary of the edge-significance method + per-network
|
|
501
|
+
headline diagnostics, for the Stable Edges section header."""
|
|
502
|
+
if not meta:
|
|
503
|
+
return {}
|
|
504
|
+
method = str(meta.get("method", "")).lower()
|
|
505
|
+
alpha = meta.get("alpha", 0.05)
|
|
506
|
+
n_perms = meta.get("n_permutations", 0)
|
|
507
|
+
if method == "nbs":
|
|
508
|
+
label = (f"Network-Based Statistic (component statistic: "
|
|
509
|
+
f"{meta.get('component_stat', 'extent')}, stability threshold "
|
|
510
|
+
f"≥ {meta.get('threshold', 0.5):g})")
|
|
511
|
+
elif method == "tfce":
|
|
512
|
+
label = (f"Network TFCE (E={meta.get('E', 0.5):g}, H={meta.get('H', 2.0):g}, "
|
|
513
|
+
f"dh={meta.get('dh', 0.1):g})")
|
|
514
|
+
else:
|
|
515
|
+
label = method.upper()
|
|
516
|
+
|
|
517
|
+
lines = {}
|
|
518
|
+
for network in ("positive", "negative"):
|
|
519
|
+
nm = (meta.get("networks") or {}).get(network)
|
|
520
|
+
if not nm:
|
|
521
|
+
continue
|
|
522
|
+
if method == "nbs":
|
|
523
|
+
lines[network] = (
|
|
524
|
+
f"largest component: {nm.get('largest_component_edges', 0)} edges; "
|
|
525
|
+
f"{nm.get('n_significant_components', 0)} significant subnetwork(s)")
|
|
526
|
+
elif method == "tfce":
|
|
527
|
+
lines[network] = f"{nm.get('n_significant_edges', 0)} significant edge(s)"
|
|
528
|
+
return {
|
|
529
|
+
"edge_method": method,
|
|
530
|
+
"edge_method_label": label,
|
|
531
|
+
"edge_method_alpha": alpha,
|
|
532
|
+
"edge_method_n_permutations": n_perms,
|
|
533
|
+
"edge_method_lines": lines,
|
|
534
|
+
"edge_is_subnetwork": method == "nbs",
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
|
|
499
538
|
def build_stable_edges_context(
|
|
500
539
|
edge_stability: Optional[np.ndarray],
|
|
501
540
|
edge_stability_significance: Optional[np.ndarray],
|
|
502
541
|
atlas_labels: Optional[pd.DataFrame],
|
|
503
|
-
|
|
542
|
+
significance_meta: Optional[dict] = None,
|
|
543
|
+
plots_dir: Optional[str] = None,
|
|
544
|
+
alpha: float = 0.05,
|
|
504
545
|
) -> dict:
|
|
505
546
|
"""
|
|
506
547
|
Build context for the Stable Edges section.
|
|
507
548
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
549
|
+
Displays *all* significant edges (p < *alpha*) per network — no cap — and
|
|
550
|
+
provides a downloadable CSV of every selected edge with its stability and
|
|
551
|
+
significance. When permutation diagnostics are available (see
|
|
552
|
+
:meth:`ReportDataLoader.load_edge_significance_meta`) the method is named
|
|
553
|
+
and a null-distribution figure is rendered per network.
|
|
511
554
|
"""
|
|
512
555
|
if edge_stability is None or edge_stability_significance is None:
|
|
513
556
|
return {
|
|
@@ -520,8 +563,14 @@ def build_stable_edges_context(
|
|
|
520
563
|
"has_edge_data": True,
|
|
521
564
|
"edge_table_positive": "", "edge_table_negative": "",
|
|
522
565
|
"edge_count_positive": 0, "edge_count_negative": 0,
|
|
523
|
-
"
|
|
566
|
+
"edge_total_positive": 0, "edge_total_negative": 0,
|
|
567
|
+
"edge_alpha": alpha,
|
|
568
|
+
"null_plot_positive": "", "null_plot_negative": "",
|
|
569
|
+
"edge_csv_data_uri": "",
|
|
524
570
|
}
|
|
571
|
+
ctx.update(_method_summary(significance_meta))
|
|
572
|
+
|
|
573
|
+
all_rows = []
|
|
525
574
|
try:
|
|
526
575
|
for i, network in enumerate(["positive", "negative"]):
|
|
527
576
|
edges = {
|
|
@@ -529,10 +578,48 @@ def build_stable_edges_context(
|
|
|
529
578
|
"stability_significance": edge_stability_significance[:, :, i],
|
|
530
579
|
}
|
|
531
580
|
df = create_edge_stability_table(edges, atlas_labels)
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
581
|
+
# A real table has the (Region A, Region B) MultiIndex; the empty
|
|
582
|
+
# placeholder does not.
|
|
583
|
+
is_real = list(df.index.names) == ["Region A", "Region B"]
|
|
584
|
+
if not is_real:
|
|
585
|
+
continue
|
|
586
|
+
|
|
587
|
+
flat = df.reset_index()
|
|
588
|
+
flat.insert(0, "Network", network)
|
|
589
|
+
all_rows.append(flat)
|
|
590
|
+
|
|
591
|
+
ctx[f"edge_total_{network}"] = len(df)
|
|
592
|
+
sig_df = df[df["Stability Significance"] < alpha]
|
|
593
|
+
ctx[f"edge_count_{network}"] = len(sig_df)
|
|
594
|
+
if len(sig_df):
|
|
595
|
+
ctx[f"edge_table_{network}"] = sig_df.to_html(
|
|
596
|
+
classes="data-table", border=0)
|
|
597
|
+
|
|
598
|
+
# Null-distribution figure.
|
|
599
|
+
nm = (significance_meta or {}).get("networks", {}).get(network)
|
|
600
|
+
if nm and plots_dir and nm.get("max_null"):
|
|
601
|
+
from cccpm.reporting.plots.stats_figures import null_distribution_plot
|
|
602
|
+
|
|
603
|
+
if significance_meta.get("method") == "nbs":
|
|
604
|
+
comps = nm.get("components") or []
|
|
605
|
+
observed = max((c["statistic"] for c in comps), default=None)
|
|
606
|
+
else:
|
|
607
|
+
observed = nm.get("observed_max")
|
|
608
|
+
path = null_distribution_plot(
|
|
609
|
+
nm["max_null"], plots_dir, f"null_dist_{network}",
|
|
610
|
+
network=network, observed=observed,
|
|
611
|
+
critical_value=nm.get("critical_value"),
|
|
612
|
+
statistic_label=significance_meta.get("statistic_label", "Max statistic"),
|
|
613
|
+
)
|
|
614
|
+
ctx[f"null_plot_{network}"] = _svg_to_html(path)
|
|
615
|
+
|
|
616
|
+
if all_rows:
|
|
617
|
+
full = pd.concat(all_rows, ignore_index=True)
|
|
618
|
+
import base64
|
|
619
|
+
|
|
620
|
+
csv_bytes = full.to_csv(index=False).encode("utf-8")
|
|
621
|
+
ctx["edge_csv_data_uri"] = (
|
|
622
|
+
"data:text/csv;base64," + base64.b64encode(csv_bytes).decode("ascii"))
|
|
536
623
|
except Exception:
|
|
537
624
|
pass
|
|
538
625
|
|
|
@@ -272,30 +272,77 @@
|
|
|
272
272
|
<div class="info">
|
|
273
273
|
<p>
|
|
274
274
|
The individual connections (region A — region B) that most reliably drive the
|
|
275
|
-
prediction. <em>Stability</em> is how often an edge was selected across folds
|
|
276
|
-
|
|
277
|
-
|
|
275
|
+
prediction. <em>Stability</em> is how often an edge was selected across folds.
|
|
276
|
+
{% if edge_method == "nbs" %}
|
|
277
|
+
Significance is assessed at the <strong>subnetwork</strong> level with the
|
|
278
|
+
Network-Based Statistic: connected components of stable edges are tested against
|
|
279
|
+
a permutation null of the largest component, so every edge in a significant
|
|
280
|
+
subnetwork shares that subnetwork's p-value. A significant result means
|
|
281
|
+
"this connected subnetwork is selected more consistently than chance" — not a
|
|
282
|
+
claim about any single edge in isolation.
|
|
283
|
+
{% elif edge_method == "tfce" %}
|
|
284
|
+
Significance is a per-edge family-wise-error-corrected p-value from network
|
|
285
|
+
Threshold-Free Cluster Enhancement (no arbitrary cluster-forming threshold).
|
|
286
|
+
{% else %}
|
|
287
|
+
<em>Significance</em> is the permutation p-value for that stability.
|
|
288
|
+
{% endif %}
|
|
278
289
|
</p>
|
|
279
290
|
</div>
|
|
291
|
+
|
|
292
|
+
{% if edge_method_label %}
|
|
293
|
+
<div class="info">
|
|
294
|
+
<p>
|
|
295
|
+
<strong>Significance method:</strong> {{ edge_method_label }},
|
|
296
|
+
{{ edge_method_n_permutations }} permutations, α = {{ edge_method_alpha }}.
|
|
297
|
+
{% if edge_method_lines.positive %}<br><strong>Positive network:</strong> {{ edge_method_lines.positive }}.{% endif %}
|
|
298
|
+
{% if edge_method_lines.negative %}<br><strong>Negative network:</strong> {{ edge_method_lines.negative }}.{% endif %}
|
|
299
|
+
</p>
|
|
300
|
+
</div>
|
|
301
|
+
{% endif %}
|
|
302
|
+
|
|
303
|
+
{% if null_plot_positive or null_plot_negative %}
|
|
304
|
+
<div class="figure-row">
|
|
305
|
+
{% if null_plot_positive %}
|
|
306
|
+
<div style="flex:1 1 320px">{{ null_plot_positive }}
|
|
307
|
+
<p class="caption">Permutation null of the max statistic (positive network); dashed = critical value, solid = observed.</p>
|
|
308
|
+
</div>
|
|
309
|
+
{% endif %}
|
|
310
|
+
{% if null_plot_negative %}
|
|
311
|
+
<div style="flex:1 1 320px">{{ null_plot_negative }}
|
|
312
|
+
<p class="caption">Permutation null of the max statistic (negative network); dashed = critical value, solid = observed.</p>
|
|
313
|
+
</div>
|
|
314
|
+
{% endif %}
|
|
315
|
+
</div>
|
|
316
|
+
{% endif %}
|
|
317
|
+
|
|
318
|
+
{% if edge_csv_data_uri %}
|
|
319
|
+
<p class="caption">
|
|
320
|
+
<a class="download-link" download="edge_significance.csv" href="{{ edge_csv_data_uri }}">
|
|
321
|
+
⭳ Download all edges (CSV)
|
|
322
|
+
</a>
|
|
323
|
+
— every selected edge with its network, stability and significance.
|
|
324
|
+
</p>
|
|
325
|
+
{% endif %}
|
|
326
|
+
|
|
280
327
|
<div class="figure-row">
|
|
281
|
-
{% if edge_table_positive %}
|
|
282
328
|
<div style="flex:1 1 400px">
|
|
283
329
|
<h3>Positive Network</h3>
|
|
284
|
-
{
|
|
285
|
-
|
|
286
|
-
{% endif %}
|
|
330
|
+
<p class="caption">{{ edge_count_positive }} significant of {{ edge_total_positive }} selected edges (α = {{ edge_alpha }}).</p>
|
|
331
|
+
{% if edge_table_positive %}
|
|
287
332
|
<div class="table-scroll table-tall">{{ edge_table_positive }}</div>
|
|
333
|
+
{% else %}
|
|
334
|
+
<p class="caption">No significant edges in this network.</p>
|
|
335
|
+
{% endif %}
|
|
288
336
|
</div>
|
|
289
|
-
{% endif %}
|
|
290
|
-
{% if edge_table_negative %}
|
|
291
337
|
<div style="flex:1 1 400px">
|
|
292
338
|
<h3>Negative Network</h3>
|
|
293
|
-
{
|
|
294
|
-
|
|
295
|
-
{% endif %}
|
|
339
|
+
<p class="caption">{{ edge_count_negative }} significant of {{ edge_total_negative }} selected edges (α = {{ edge_alpha }}).</p>
|
|
340
|
+
{% if edge_table_negative %}
|
|
296
341
|
<div class="table-scroll table-tall">{{ edge_table_negative }}</div>
|
|
342
|
+
{% else %}
|
|
343
|
+
<p class="caption">No significant edges in this network.</p>
|
|
344
|
+
{% endif %}
|
|
297
345
|
</div>
|
|
298
|
-
{% endif %}
|
|
299
346
|
</div>
|
|
300
347
|
{% endif %}
|
|
301
348
|
</section>
|
|
@@ -377,6 +377,16 @@ figcaption, .caption {
|
|
|
377
377
|
.figure-row { display: flex; gap: var(--sp-5); flex-wrap: wrap; margin: var(--sp-4) 0; }
|
|
378
378
|
.figure-row > .figure-wrap { flex: 1 1 300px; }
|
|
379
379
|
|
|
380
|
+
a.download-link {
|
|
381
|
+
display: inline-block;
|
|
382
|
+
padding: var(--sp-1) var(--sp-3);
|
|
383
|
+
border: 1px solid var(--accent-strong);
|
|
384
|
+
border-radius: 4px;
|
|
385
|
+
font-size: var(--fs-xs);
|
|
386
|
+
font-weight: 600;
|
|
387
|
+
}
|
|
388
|
+
a.download-link:hover { background: var(--accent-strong); color: #fff; }
|
|
389
|
+
|
|
380
390
|
/* Uniform card grid for figures */
|
|
381
391
|
.figure-grid {
|
|
382
392
|
display: grid;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import json
|
|
2
3
|
import pandas as pd
|
|
3
4
|
|
|
4
5
|
from typing import Union
|
|
@@ -8,9 +9,10 @@ import torch
|
|
|
8
9
|
|
|
9
10
|
from glob import glob
|
|
10
11
|
|
|
12
|
+
import networkx as nx
|
|
13
|
+
|
|
11
14
|
from cccpm.constants import Networks, Models, Metrics, TaskType, get_metrics_for_task
|
|
12
|
-
from cccpm.utils import vector_to_matrix_tensor_version
|
|
13
|
-
from statsmodels.stats.multitest import multipletests
|
|
15
|
+
from cccpm.utils import vector_to_matrix_tensor_version
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
class ResultsManager:
|
|
@@ -454,11 +456,24 @@ class PermutationManager:
|
|
|
454
456
|
return pd.Series(result_dict)
|
|
455
457
|
|
|
456
458
|
@staticmethod
|
|
457
|
-
def calculate_permutation_results(results_directory, logger
|
|
459
|
+
def calculate_permutation_results(results_directory, logger, method="nbs",
|
|
460
|
+
nbs_threshold=0.5, nbs_component_stat="extent"):
|
|
458
461
|
"""
|
|
459
462
|
Calculate and save the permutation test results.
|
|
460
463
|
|
|
464
|
+
Model-level metric p-values are always computed. Edge-stability
|
|
465
|
+
significance is established at the *subnetwork* level via a
|
|
466
|
+
Network-Based Statistic (``method='nbs'``) or, threshold-free, via
|
|
467
|
+
network TFCE (``method='tfce'``); both control the family-wise error
|
|
468
|
+
rate through a permutation max-statistic and write a per-edge p-value
|
|
469
|
+
matrix (edges belonging to a significant subnetwork carry that
|
|
470
|
+
subnetwork's p-value) to ``stability_edges_significance.npy``.
|
|
471
|
+
|
|
461
472
|
:param results_directory: Directory where the results are saved.
|
|
473
|
+
:param logger: Logger for progress messages.
|
|
474
|
+
:param method: Edge-significance method, ``'nbs'`` (default) or ``'tfce'``.
|
|
475
|
+
:param nbs_threshold: Stability threshold for NBS component forming.
|
|
476
|
+
:param nbs_component_stat: NBS component statistic, ``'extent'`` or ``'intensity'``.
|
|
462
477
|
"""
|
|
463
478
|
true_results = ResultsManager.load_cv_results(results_directory)
|
|
464
479
|
|
|
@@ -471,15 +486,21 @@ class PermutationManager:
|
|
|
471
486
|
p_values = PermutationManager.calculate_p_values(true_results, perm_results)
|
|
472
487
|
p_values.to_csv(os.path.join(results_directory, 'p_values.csv'))
|
|
473
488
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
489
|
+
if method == "nbs":
|
|
490
|
+
stability_significance, sig_meta = PermutationManager.calculate_p_values_edges_nbs(
|
|
491
|
+
true_edge_stability, perm_edge_stability,
|
|
492
|
+
threshold=nbs_threshold, component_stat=nbs_component_stat,
|
|
493
|
+
return_diagnostics=True)
|
|
494
|
+
elif method == "tfce":
|
|
495
|
+
stability_significance, sig_meta = PermutationManager.calculate_p_values_edges_tfce(
|
|
496
|
+
true_edge_stability, perm_edge_stability, return_diagnostics=True)
|
|
477
497
|
else:
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
stability_significance = calculate_p_values_edges(true_edge_stability, perm_edge_stability)
|
|
498
|
+
raise ValueError(
|
|
499
|
+
f"Unknown edge-significance method '{method}'. Use 'nbs' or 'tfce'.")
|
|
481
500
|
|
|
482
501
|
np.save(os.path.join(results_directory, 'stability_edges_significance.npy'), stability_significance)
|
|
502
|
+
with open(os.path.join(results_directory, 'stability_edges_significance_meta.json'), 'w') as f:
|
|
503
|
+
json.dump(sig_meta, f)
|
|
483
504
|
|
|
484
505
|
logger.debug("Saving significance of edge stability.")
|
|
485
506
|
logger.info("Permutation test results")
|
|
@@ -487,91 +508,246 @@ class PermutationManager:
|
|
|
487
508
|
return
|
|
488
509
|
|
|
489
510
|
@staticmethod
|
|
490
|
-
def
|
|
491
|
-
"""
|
|
492
|
-
|
|
493
|
-
|
|
511
|
+
def _layer_arrays(true_stability, permutation_stability, layer):
|
|
512
|
+
"""Extract the observed ``[n_nodes, n_nodes]`` matrix and the
|
|
513
|
+
``[n_nodes, n_nodes, n_perms]`` null stack for one network *layer*
|
|
514
|
+
(``0`` = positive, ``1`` = negative) from the stored stability arrays,
|
|
515
|
+
which have shape ``[n_nodes, n_nodes, 2, runs]``."""
|
|
516
|
+
true_layer = true_stability[:, :, layer, 0]
|
|
517
|
+
perm_layer = permutation_stability[:, :, layer, :]
|
|
518
|
+
return true_layer, perm_layer
|
|
494
519
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
520
|
+
@staticmethod
|
|
521
|
+
def _connected_components(supra):
|
|
522
|
+
"""Yield the edge lists of the connected components (each with at least
|
|
523
|
+
one edge) of a symmetric boolean adjacency matrix, using its upper
|
|
524
|
+
triangle only. Isolated nodes are skipped."""
|
|
525
|
+
graph = nx.from_numpy_array(np.triu(supra, k=1))
|
|
526
|
+
for nodes in nx.connected_components(graph):
|
|
527
|
+
if len(nodes) < 2:
|
|
528
|
+
continue
|
|
529
|
+
edges = list(graph.subgraph(nodes).edges())
|
|
530
|
+
if edges:
|
|
531
|
+
yield edges
|
|
532
|
+
|
|
533
|
+
@staticmethod
|
|
534
|
+
def calculate_p_values_edges_nbs(true_stability, permutation_stability,
|
|
535
|
+
threshold=0.5, component_stat="extent",
|
|
536
|
+
alpha=0.05, return_diagnostics=False):
|
|
537
|
+
"""
|
|
538
|
+
Network-Based Statistic (Zalesky et al., 2010) for edge-stability
|
|
539
|
+
significance.
|
|
540
|
+
|
|
541
|
+
Edges whose stability meets ``threshold`` form a graph; its connected
|
|
542
|
+
components are the candidate subnetworks. A permutation null of the
|
|
543
|
+
**largest component statistic** controls the family-wise error rate, so
|
|
544
|
+
an observed component is significant if it is larger/stronger than the
|
|
545
|
+
biggest component seen in (almost) any permutation. Each component's
|
|
546
|
+
p-value is broadcast onto all of its member edges; every other edge is
|
|
547
|
+
assigned ``p = 1``.
|
|
548
|
+
|
|
549
|
+
Inference is at the *subnetwork* level: a significant result licenses
|
|
550
|
+
"this connected subnetwork is selected more consistently than chance",
|
|
551
|
+
not per-edge claims.
|
|
552
|
+
|
|
553
|
+
Note on discreteness: stability over ``K`` outer folds takes only the
|
|
554
|
+
values ``{0, 1/K, ..., 1}``, so ``threshold=0.5`` keeps edges selected
|
|
555
|
+
in a majority of folds and the effective thresholding is coarse for few
|
|
556
|
+
folds. A continuous edge statistic (deferred) would sharpen this.
|
|
498
557
|
|
|
499
558
|
Parameters
|
|
500
559
|
----------
|
|
501
|
-
true_stability : ndarray of shape (
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
560
|
+
true_stability : ndarray of shape (n_nodes, n_nodes, 2, 1)
|
|
561
|
+
Observed edge stability; dim 2 is the positive/negative network.
|
|
562
|
+
permutation_stability : ndarray of shape (n_nodes, n_nodes, 2, n_perms)
|
|
563
|
+
Edge stability from each permutation run.
|
|
564
|
+
threshold : float, default=0.5
|
|
565
|
+
Stability threshold (``>=``) for component forming.
|
|
566
|
+
component_stat : {'extent', 'intensity'}, default='extent'
|
|
567
|
+
``'extent'`` = number of edges in the component (classic NBS);
|
|
568
|
+
``'intensity'`` = sum of ``(stability - threshold)`` over its edges.
|
|
569
|
+
alpha : float, default=0.05
|
|
570
|
+
Significance level recorded in the diagnostics.
|
|
571
|
+
return_diagnostics : bool, default=False
|
|
572
|
+
If ``True``, also return a JSON-serialisable diagnostics dict with
|
|
573
|
+
the per-network max-component null distribution, the observed
|
|
574
|
+
components (size / statistic / p-value) and the largest component.
|
|
507
575
|
|
|
508
576
|
Returns
|
|
509
577
|
-------
|
|
510
|
-
sig_stability : ndarray of shape (
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
Family-wise error is controlled via the max-statistic method.
|
|
578
|
+
sig_stability : ndarray of shape (n_nodes, n_nodes, 2)
|
|
579
|
+
Per-edge p-values (member edges carry their component's p-value).
|
|
580
|
+
diagnostics : dict, optional
|
|
581
|
+
Returned only when ``return_diagnostics=True``.
|
|
515
582
|
"""
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
583
|
+
true_stability = np.asarray(true_stability, dtype=float)
|
|
584
|
+
permutation_stability = np.asarray(permutation_stability, dtype=float)
|
|
585
|
+
n_nodes = true_stability.shape[0]
|
|
586
|
+
n_perms = permutation_stability.shape[-1]
|
|
587
|
+
sig = np.ones((n_nodes, n_nodes, 2))
|
|
588
|
+
|
|
589
|
+
if component_stat not in ("extent", "intensity"):
|
|
590
|
+
raise ValueError(
|
|
591
|
+
f"Unknown component_stat '{component_stat}'. Use 'extent' or 'intensity'.")
|
|
592
|
+
|
|
593
|
+
def components_with_stats(mat):
|
|
594
|
+
out = []
|
|
595
|
+
for edges in PermutationManager._connected_components(mat >= threshold):
|
|
596
|
+
if component_stat == "extent":
|
|
597
|
+
stat = float(len(edges))
|
|
598
|
+
else: # intensity
|
|
599
|
+
stat = float(sum(mat[i, j] - threshold for i, j in edges))
|
|
600
|
+
out.append((edges, stat))
|
|
601
|
+
return out
|
|
602
|
+
|
|
603
|
+
meta = {
|
|
604
|
+
"method": "nbs",
|
|
605
|
+
"threshold": float(threshold),
|
|
606
|
+
"component_stat": component_stat,
|
|
607
|
+
"n_permutations": int(n_perms),
|
|
608
|
+
"alpha": float(alpha),
|
|
609
|
+
"statistic_label": ("Component size (edges)" if component_stat == "extent"
|
|
610
|
+
else "Component intensity"),
|
|
611
|
+
"networks": {},
|
|
612
|
+
}
|
|
526
613
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
614
|
+
for layer in (Networks.positive, Networks.negative):
|
|
615
|
+
true_layer, perm_layer = PermutationManager._layer_arrays(
|
|
616
|
+
true_stability, permutation_stability, layer)
|
|
617
|
+
|
|
618
|
+
# Null distribution of the largest component statistic.
|
|
619
|
+
max_null = np.zeros(n_perms)
|
|
620
|
+
for p in range(n_perms):
|
|
621
|
+
comps = components_with_stats(perm_layer[:, :, p])
|
|
622
|
+
max_null[p] = max((s for _, s in comps), default=0.0)
|
|
623
|
+
|
|
624
|
+
# Observed components -> component p-value on every member edge.
|
|
625
|
+
components = []
|
|
626
|
+
for edges, stat in components_with_stats(true_layer):
|
|
627
|
+
p_value = (np.sum(max_null >= stat) + 1) / (n_perms + 1)
|
|
628
|
+
nodes = set()
|
|
629
|
+
for i, j in edges:
|
|
630
|
+
nodes.update((i, j))
|
|
631
|
+
sig[i, j, layer] = p_value
|
|
632
|
+
sig[j, i, layer] = p_value
|
|
633
|
+
components.append({
|
|
634
|
+
"n_edges": int(len(edges)),
|
|
635
|
+
"n_nodes": int(len(nodes)),
|
|
636
|
+
"statistic": float(stat),
|
|
637
|
+
"p_value": float(p_value),
|
|
638
|
+
"significant": bool(p_value < alpha),
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
components.sort(key=lambda c: c["statistic"], reverse=True)
|
|
642
|
+
name = "positive" if layer == Networks.positive else "negative"
|
|
643
|
+
meta["networks"][name] = {
|
|
644
|
+
"max_null": [float(v) for v in max_null],
|
|
645
|
+
"critical_value": float(np.quantile(max_null, 1.0 - alpha)) if n_perms else 0.0,
|
|
646
|
+
"components": components,
|
|
647
|
+
"largest_component_edges": max((c["n_edges"] for c in components), default=0),
|
|
648
|
+
"n_significant_components": int(sum(c["significant"] for c in components)),
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if return_diagnostics:
|
|
652
|
+
return sig, meta
|
|
653
|
+
return sig
|
|
531
654
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
655
|
+
@staticmethod
|
|
656
|
+
def _tfce_map(layer_matrix, heights, E, H, dh):
|
|
657
|
+
"""Threshold-Free Cluster Enhancement score per edge for one network
|
|
658
|
+
layer: integrate ``extent(component)^E * h^H * dh`` over the threshold
|
|
659
|
+
sweep *heights* (extent = number of edges in the component the edge
|
|
660
|
+
belongs to at height ``h``)."""
|
|
661
|
+
n_nodes = layer_matrix.shape[0]
|
|
662
|
+
tfce = np.zeros((n_nodes, n_nodes))
|
|
663
|
+
for h in heights:
|
|
664
|
+
for edges in PermutationManager._connected_components(layer_matrix >= h):
|
|
665
|
+
contrib = (len(edges) ** E) * (h ** H) * dh
|
|
666
|
+
for i, j in edges:
|
|
667
|
+
tfce[i, j] += contrib
|
|
668
|
+
tfce[j, i] += contrib
|
|
669
|
+
return tfce
|
|
535
670
|
|
|
536
671
|
@staticmethod
|
|
537
|
-
def
|
|
672
|
+
def calculate_p_values_edges_tfce(true_stability, permutation_stability,
|
|
673
|
+
E=0.5, H=2.0, dh=0.1, alpha=0.05,
|
|
674
|
+
return_diagnostics=False):
|
|
538
675
|
"""
|
|
539
|
-
|
|
540
|
-
|
|
676
|
+
Threshold-Free Cluster Enhancement (Smith & Nichols, 2009) adapted to
|
|
677
|
+
networks, for per-edge stability significance without an arbitrary
|
|
678
|
+
primary threshold.
|
|
541
679
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
680
|
+
Each edge's TFCE score integrates the support of the components it
|
|
681
|
+
belongs to across a sweep of stability thresholds. A permutation
|
|
682
|
+
**max-TFCE** null across edges controls the family-wise error rate, so
|
|
683
|
+
this yields genuine per-edge FWER-corrected p-values (unlike NBS, which
|
|
684
|
+
is subnetwork-level).
|
|
546
685
|
|
|
547
686
|
Parameters
|
|
548
687
|
----------
|
|
549
|
-
true_stability : ndarray of shape (
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
688
|
+
true_stability : ndarray of shape (n_nodes, n_nodes, 2, 1)
|
|
689
|
+
Observed edge stability; dim 2 is the positive/negative network.
|
|
690
|
+
permutation_stability : ndarray of shape (n_nodes, n_nodes, 2, n_perms)
|
|
691
|
+
Edge stability from each permutation run.
|
|
692
|
+
E, H : float
|
|
693
|
+
TFCE extent/height exponents (field-standard defaults 0.5 / 2.0).
|
|
694
|
+
dh : float, default=0.1
|
|
695
|
+
Step of the stability-threshold sweep over ``(0, 1]``.
|
|
696
|
+
alpha : float, default=0.05
|
|
697
|
+
Significance level recorded in the diagnostics.
|
|
698
|
+
return_diagnostics : bool, default=False
|
|
699
|
+
If ``True``, also return a JSON-serialisable diagnostics dict with
|
|
700
|
+
the per-network max-TFCE null distribution and observed maximum.
|
|
555
701
|
|
|
556
702
|
Returns
|
|
557
703
|
-------
|
|
558
|
-
sig_stability : ndarray of shape (
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
704
|
+
sig_stability : ndarray of shape (n_nodes, n_nodes, 2)
|
|
705
|
+
Per-edge FWER-corrected p-values.
|
|
706
|
+
diagnostics : dict, optional
|
|
707
|
+
Returned only when ``return_diagnostics=True``.
|
|
562
708
|
"""
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
709
|
+
true_stability = np.asarray(true_stability, dtype=float)
|
|
710
|
+
permutation_stability = np.asarray(permutation_stability, dtype=float)
|
|
711
|
+
n_nodes = true_stability.shape[0]
|
|
712
|
+
n_perms = permutation_stability.shape[-1]
|
|
713
|
+
heights = np.arange(dh, 1.0 + dh / 2, dh)
|
|
714
|
+
triu = np.triu_indices(n_nodes, k=1)
|
|
715
|
+
sig = np.ones((n_nodes, n_nodes, 2))
|
|
716
|
+
|
|
717
|
+
meta = {
|
|
718
|
+
"method": "tfce",
|
|
719
|
+
"E": float(E), "H": float(H), "dh": float(dh),
|
|
720
|
+
"n_permutations": int(n_perms),
|
|
721
|
+
"alpha": float(alpha),
|
|
722
|
+
"statistic_label": "Max TFCE score",
|
|
723
|
+
"networks": {},
|
|
724
|
+
}
|
|
576
725
|
|
|
577
|
-
|
|
726
|
+
for layer in (Networks.positive, Networks.negative):
|
|
727
|
+
true_layer, perm_layer = PermutationManager._layer_arrays(
|
|
728
|
+
true_stability, permutation_stability, layer)
|
|
729
|
+
|
|
730
|
+
true_tfce = PermutationManager._tfce_map(true_layer, heights, E, H, dh)
|
|
731
|
+
max_null = np.zeros(n_perms)
|
|
732
|
+
for p in range(n_perms):
|
|
733
|
+
perm_tfce = PermutationManager._tfce_map(perm_layer[:, :, p], heights, E, H, dh)
|
|
734
|
+
max_null[p] = perm_tfce[triu].max(initial=0.0)
|
|
735
|
+
|
|
736
|
+
for i, j in zip(*triu):
|
|
737
|
+
score = true_tfce[i, j]
|
|
738
|
+
if score > 0:
|
|
739
|
+
p_value = (np.sum(max_null >= score) + 1) / (n_perms + 1)
|
|
740
|
+
sig[i, j, layer] = p_value
|
|
741
|
+
sig[j, i, layer] = p_value
|
|
742
|
+
|
|
743
|
+
name = "positive" if layer == Networks.positive else "negative"
|
|
744
|
+
meta["networks"][name] = {
|
|
745
|
+
"max_null": [float(v) for v in max_null],
|
|
746
|
+
"critical_value": float(np.quantile(max_null, 1.0 - alpha)) if n_perms else 0.0,
|
|
747
|
+
"observed_max": float(true_tfce[triu].max(initial=0.0)),
|
|
748
|
+
"n_significant_edges": int(np.sum(sig[:, :, layer][triu] < alpha)),
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if return_diagnostics:
|
|
752
|
+
return sig, meta
|
|
753
|
+
return sig
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|