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.
Files changed (38) hide show
  1. {cccpm-0.3.2 → cccpm-0.4.0}/PKG-INFO +1 -1
  2. {cccpm-0.3.2 → cccpm-0.4.0}/pyproject.toml +1 -1
  3. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/constants.py +12 -0
  4. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/cpm_analysis.py +49 -5
  5. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/edge_selection.py +0 -67
  6. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/data_loader.py +11 -0
  7. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/html_report.py +3 -0
  8. cccpm-0.4.0/src/cccpm/reporting/plots/stats_figures.py +88 -0
  9. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/section_builders.py +96 -9
  10. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/templates/report.html.j2 +60 -13
  11. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/templates/styles.css +10 -0
  12. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/results_manager.py +251 -75
  13. {cccpm-0.3.2 → cccpm-0.4.0}/LICENSE +0 -0
  14. {cccpm-0.3.2 → cccpm-0.4.0}/README.md +0 -0
  15. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/__init__.py +0 -0
  16. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/inner_fold.py +0 -0
  17. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/logging.py +0 -0
  18. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/models/__init__.py +0 -0
  19. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/models/linear_model.py +0 -0
  20. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/models/nonlinear_models.py +0 -0
  21. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/__init__.py +0 -0
  22. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/assets/CCCPM.png +0 -0
  23. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/plots/__init__.py +0 -0
  24. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/plots/brain_figures.py +0 -0
  25. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/plots/chord_v2.py +0 -0
  26. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/plots/connectome_utils.py +0 -0
  27. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/plots/cpm_chord_plot.py +0 -0
  28. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/plots/figure_style.py +0 -0
  29. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/plots/plots.py +0 -0
  30. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/plots/utils.py +0 -0
  31. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/reporting_utils.py +0 -0
  32. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/reporting/table_builders.py +0 -0
  33. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/scoring.py +0 -0
  34. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/simulation/__init__.py +0 -0
  35. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/simulation/simulate_multivariate.py +0 -0
  36. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/simulation/simulate_sem.py +0 -0
  37. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/simulation/simulate_simple.py +0 -0
  38. {cccpm-0.3.2 → cccpm-0.4.0}/src/cccpm/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cccpm
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Confound-Corrected Connectome-based Predictive Modeling Python Package
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "cccpm"
3
- version = "0.3.2"
3
+ version = "0.4.0"
4
4
  description = "Confound-Corrected Connectome-based Predictive Modeling Python Package"
5
5
  authors = [
6
6
  "Nils Winter <nils.r.winter@uni-muenster.de>",
@@ -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(self.results_directory, self.logger)
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
- current_edges = self.edge_selection.fit_transform(
392
- X=X_train, y=y_train[:, run_id].reshape(-1, 1), covariates=cov_train
393
- ).return_selected_edges()
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
- max_rows: int = 50,
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
- Tables are capped at *max_rows* edges per network (already sorted by
509
- significance) — without a stable-edge threshold a run can produce hundreds
510
- of edges, which would make the report metres long. A note reports the total.
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
- "edge_max_rows": max_rows,
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
- total = len(df)
533
- html = df.head(max_rows).to_html(classes="data-table", border=0)
534
- ctx[f"edge_table_{network}"] = html
535
- ctx[f"edge_count_{network}"] = total
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
- <em>significance</em> is the permutation p-value for that stability. These are the
277
- edges you would report and interpret anatomically.
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
- {% if edge_count_positive > edge_max_rows %}
285
- <p class="caption">Showing the top {{ edge_max_rows }} of {{ edge_count_positive }} edges (by significance).</p>
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
- {% if edge_count_negative > edge_max_rows %}
294
- <p class="caption">Showing the top {{ edge_max_rows }} of {{ edge_count_negative }} edges (by significance).</p>
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, matrix_to_vector_tensor_version, matrix_to_vector_numpy, vector_to_matrix_numpy
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
- use_fdr = True
475
- if use_fdr:
476
- calculate_p_values_edges = PermutationManager.calculate_p_values_edges_fdr
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
- calculate_p_values_edges = PermutationManager.calculate_p_values_edges_max_value
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 calculate_p_values_edges_max_value(true_stability, permutation_stability):
491
- """
492
- Calculate empirical p-values for each edge in a connectivity matrix using the
493
- max-value method from permutation testing.
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
- For each permutation, the maximum value across all edges is taken to construct
496
- a max-null distribution. Each true edge value is then compared to this distribution
497
- to compute a p-value, which controls the family-wise error rate (FWER).
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 (n_regions, n_regions)
502
- Symmetric matrix containing the observed stability scores for each edge.
503
-
504
- permutation_stability : ndarray of shape (n_permutations, n_regions, n_regions)
505
- Array containing stability scores from each permutation run. Each entry is
506
- a symmetric matrix of the same shape as `true_stability`.
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 (n_regions, n_regions)
511
- Symmetric matrix of empirical p-values for each edge, calculated by comparing
512
- the true stability values to the max null distribution. The p-values reflect
513
- the probability of observing a value as extreme or more extreme under the null.
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
- # n_permutations
517
- n_permutations = permutation_stability.shape[0]
518
-
519
- triu_indices = np.triu_indices_from(true_stability, k=1)
520
-
521
- # Extract only the upper triangle for each permutation (ignores symmetric redundancy and diagonal)
522
- max_null = np.max(permutation_stability[:, triu_indices[0], triu_indices[1]], axis=1)
523
-
524
- # Compute significance p-values per edge, comparing against max null distribution
525
- sig_stability = np.ones_like(true_stability)
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
- # For only upper triangle (to avoid redundant computation)
528
- for i, j in zip(*triu_indices):
529
- true_val = true_stability[i, j]
530
- p = (np.sum(max_null >= true_val) + 1) / (n_permutations + 1)
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
- sig_stability[i, j] = p
533
- sig_stability[j, i] = p # symmetric
534
- return sig_stability
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 calculate_p_values_edges_fdr(true_stability, permutation_stability):
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
- Calculate FDR-corrected p-values for each edge in a connectivity matrix using
540
- permutation-based empirical p-values and the Benjamini–Yekutieli procedure.
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
- For each edge, an empirical p-value is calculated by comparing the true
543
- stability score to the distribution of permuted scores at the same edge.
544
- The Benjamini–Yekutieli (BY) method is then applied to correct for multiple
545
- comparisons, controlling the false discovery rate (FDR).
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 (n_regions, n_regions)
550
- Symmetric matrix containing the observed stability scores for each edge.
551
-
552
- permutation_stability : ndarray of shape (n_permutations, n_regions, n_regions)
553
- Array containing stability scores from each permutation run. Each entry is
554
- a symmetric matrix of the same shape as `true_stability`.
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 (n_regions, n_regions)
559
- Symmetric matrix of FDR-corrected p-values for each edge, calculated by first
560
- computing empirical p-values and then applying the Benjamini–Yekutieli correction
561
- to control the expected false discovery rate across all edges.
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
- n_permutations = permutation_stability.shape[-1]
564
- permutation_stability_flattenened = matrix_to_vector_numpy(permutation_stability, dim=0)
565
- true_stability_flattenened = matrix_to_vector_numpy(true_stability, dim=0)
566
-
567
- # Compute empirical p-values for each edge. Use the standard
568
- # (count + 1) / (n_permutations + 1) form so p-values are valid in (0, 1].
569
- condition_count = (true_stability_flattenened < permutation_stability_flattenened).sum(axis=-1)
570
- p_vals = (condition_count + 1) / (n_permutations + 1)
571
-
572
- # Apply Benjamini-Yekutieli correction
573
- p_vals_corrected = np.full_like(p_vals, np.nan)
574
- _, p_vals_corrected[:, 0], _, _ = multipletests(p_vals[:, 0], alpha=0.05, method='fdr_by')
575
- _, p_vals_corrected[:, 1], _, _ = multipletests(p_vals[:, 1], alpha=0.05, method='fdr_by')
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
- return vector_to_matrix_numpy(p_vals_corrected, dim=0)
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