cccpm 0.3.0__tar.gz → 0.3.2__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 (41) hide show
  1. {cccpm-0.3.0 → cccpm-0.3.2}/PKG-INFO +3 -2
  2. {cccpm-0.3.0 → cccpm-0.3.2}/pyproject.toml +3 -2
  3. cccpm-0.3.2/src/cccpm/reporting/html_report.py +156 -0
  4. cccpm-0.3.2/src/cccpm/reporting/plots/brain_figures.py +320 -0
  5. cccpm-0.3.2/src/cccpm/reporting/plots/connectome_utils.py +170 -0
  6. cccpm-0.3.2/src/cccpm/reporting/plots/figure_style.py +95 -0
  7. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/reporting/plots/plots.py +160 -68
  8. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/reporting/reporting_utils.py +45 -0
  9. cccpm-0.3.2/src/cccpm/reporting/section_builders.py +539 -0
  10. cccpm-0.3.2/src/cccpm/reporting/templates/report.html.j2 +376 -0
  11. cccpm-0.3.2/src/cccpm/reporting/templates/styles.css +444 -0
  12. cccpm-0.3.2/src/cccpm/simulation/simulate_sem.py +573 -0
  13. cccpm-0.3.0/src/cccpm/reporting/html_report.py +0 -153
  14. cccpm-0.3.0/src/cccpm/reporting/page_generators.py +0 -640
  15. cccpm-0.3.0/src/cccpm/simulation/mediator_simulation.py +0 -34
  16. cccpm-0.3.0/src/cccpm/simulation/simulate_sem.py +0 -319
  17. {cccpm-0.3.0 → cccpm-0.3.2}/LICENSE +0 -0
  18. {cccpm-0.3.0 → cccpm-0.3.2}/README.md +0 -0
  19. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/__init__.py +0 -0
  20. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/constants.py +0 -0
  21. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/cpm_analysis.py +0 -0
  22. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/edge_selection.py +0 -0
  23. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/inner_fold.py +0 -0
  24. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/logging.py +0 -0
  25. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/models/__init__.py +0 -0
  26. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/models/linear_model.py +0 -0
  27. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/models/nonlinear_models.py +0 -0
  28. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/reporting/__init__.py +0 -0
  29. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/reporting/assets/CCCPM.png +0 -0
  30. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/reporting/data_loader.py +0 -0
  31. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/reporting/plots/__init__.py +0 -0
  32. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/reporting/plots/chord_v2.py +0 -0
  33. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/reporting/plots/cpm_chord_plot.py +0 -0
  34. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/reporting/plots/utils.py +0 -0
  35. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/reporting/table_builders.py +0 -0
  36. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/results_manager.py +0 -0
  37. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/scoring.py +0 -0
  38. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/simulation/__init__.py +0 -0
  39. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/simulation/simulate_multivariate.py +0 -0
  40. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/simulation/simulate_simple.py +0 -0
  41. {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cccpm
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Confound-Corrected Connectome-based Predictive Modeling Python Package
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -19,8 +19,8 @@ Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Programming Language :: Python :: 3.13
20
20
  Classifier: Programming Language :: Python :: 3.14
21
21
  Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
22
- Requires-Dist: arakawa
23
22
  Requires-Dist: bleach
23
+ Requires-Dist: jinja2
24
24
  Requires-Dist: matplotlib
25
25
  Requires-Dist: netplotbrain
26
26
  Requires-Dist: networkx
@@ -28,6 +28,7 @@ Requires-Dist: nilearn
28
28
  Requires-Dist: numpy
29
29
  Requires-Dist: pandas
30
30
  Requires-Dist: plotly
31
+ Requires-Dist: pycirclize
31
32
  Requires-Dist: pygam
32
33
  Requires-Dist: scikit-image
33
34
  Requires-Dist: scikit-learn (>=1.6)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "cccpm"
3
- version = "0.3.0"
3
+ version = "0.3.2"
4
4
  description = "Confound-Corrected Connectome-based Predictive Modeling Python Package"
5
5
  authors = [
6
6
  "Nils Winter <nils.r.winter@uni-muenster.de>",
@@ -52,7 +52,8 @@ statsmodels = "*"
52
52
  nilearn = "*"
53
53
  matplotlib = "*"
54
54
  seaborn = "*"
55
- arakawa = "*"
55
+ jinja2 = "*"
56
+ pycirclize = "*"
56
57
  tqdm = "*"
57
58
  pygam = "*"
58
59
 
@@ -0,0 +1,156 @@
1
+ """
2
+ HTML Report Generator for Confound-Corrected CPM Analysis.
3
+
4
+ Builds a self-contained single-page HTML report using a Jinja2 template.
5
+ The public interface (HTMLReporter / generate_html_report) is unchanged.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime
11
+ import os
12
+ from pathlib import Path
13
+
14
+ import matplotlib
15
+ matplotlib.use("Agg")
16
+ import matplotlib.pyplot as plt
17
+
18
+ import jinja2
19
+
20
+ import cccpm
21
+ from cccpm.reporting.data_loader import ReportDataLoader
22
+ from cccpm.reporting.reporting_utils import embed_image_base64
23
+ from cccpm.reporting.section_builders import (
24
+ build_brain_plots_context,
25
+ build_data_context,
26
+ build_hero_context,
27
+ build_hyperparameters_context,
28
+ build_network_strengths_context,
29
+ build_performance_context,
30
+ build_stable_edges_context,
31
+ )
32
+
33
+
34
+ class HTMLReporter:
35
+ """
36
+ Generate a self-contained HTML report from CPM analysis results.
37
+
38
+ The report is a single scrolling page with a sticky sidebar; it embeds
39
+ all figures as base64 data URIs so it is fully portable and works offline.
40
+
41
+ Args:
42
+ results_directory: Path to the directory containing CPM results.
43
+ atlas_labels: Optional path to a CSV file with atlas region labels
44
+ (enables brain plots and labelled edge tables).
45
+ """
46
+
47
+ def __init__(self, results_directory: str, atlas_labels: str | None = None):
48
+ self.results_directory = results_directory
49
+ self.plots_dir = os.path.join(results_directory, "plots")
50
+ os.makedirs(self.plots_dir, exist_ok=True)
51
+
52
+ self.data_loader = ReportDataLoader(results_directory, atlas_labels)
53
+ self.X_names, self.y_name, self.covariates_names = (
54
+ self.data_loader.load_variable_names()
55
+ )
56
+ self.atlas_labels = self.data_loader.atlas_labels
57
+ self.task_type = self.data_loader.load_task_type()
58
+ self._load_data()
59
+
60
+ def _load_data(self) -> None:
61
+ self.df, self.df_mean = self.data_loader.load_cv_results()
62
+ self.df_predictions = self.data_loader.load_predictions()
63
+ self.df_p_values = self.data_loader.load_p_values()
64
+ self.df_permutations = self.data_loader.load_permutations()
65
+ self.df_network_strengths = self.data_loader.load_network_strengths()
66
+
67
+ def generate_html_report(self) -> None:
68
+ """Render the HTML report and write it to ``results_directory/report.html``."""
69
+ summary_df, scatter_path, _ = self.data_loader.load_data_insights()
70
+ edge_stability, edge_stability_sig = self.data_loader.load_edge_stability()
71
+
72
+ # ── Build template context ──────────────────────────────────────────
73
+ ctx: dict = {
74
+ "y_name": self.y_name,
75
+ "logo_img": self._logo_html(),
76
+ }
77
+
78
+ ctx.update(build_hero_context(
79
+ df_mean=self.df_mean,
80
+ df_p_values=self.df_p_values,
81
+ df_predictions=self.df_predictions,
82
+ summary_df=summary_df,
83
+ edge_stability=edge_stability,
84
+ results_directory=self.results_directory,
85
+ plots_dir=self.plots_dir,
86
+ y_name=self.y_name,
87
+ task_type=self.task_type,
88
+ version=cccpm.__version__,
89
+ run_date=datetime.date.today().isoformat(),
90
+ ))
91
+
92
+ ctx.update(build_data_context(summary_df, scatter_path, self.results_directory))
93
+
94
+ ctx.update(build_hyperparameters_context(self.df))
95
+
96
+ ctx.update(build_performance_context(
97
+ df_full=self.df,
98
+ df_mean=self.df_mean,
99
+ df_p_values=self.df_p_values,
100
+ y_name=self.y_name,
101
+ task_type=self.task_type,
102
+ plots_dir=self.plots_dir,
103
+ ))
104
+
105
+ ctx.update(build_network_strengths_context(
106
+ df_network_strengths=self.df_network_strengths,
107
+ y_name=self.y_name,
108
+ plots_dir=self.plots_dir,
109
+ ))
110
+
111
+ ctx.update(build_brain_plots_context(
112
+ results_directory=self.results_directory,
113
+ plots_dir=self.plots_dir,
114
+ atlas_labels=self.atlas_labels,
115
+ edge_stability=edge_stability,
116
+ edge_significance=edge_stability_sig,
117
+ ))
118
+
119
+ ctx.update(build_stable_edges_context(
120
+ edge_stability=edge_stability,
121
+ edge_stability_significance=edge_stability_sig,
122
+ atlas_labels=self.atlas_labels,
123
+ ))
124
+
125
+ # ── Render ─────────────────────────────────────────────────────────
126
+ html = self._render(ctx)
127
+
128
+ out_path = os.path.join(self.results_directory, "report.html")
129
+ Path(out_path).write_text(html, encoding="utf-8")
130
+
131
+ plt.close("all")
132
+
133
+ # ── Private helpers ────────────────────────────────────────────────────
134
+
135
+ def _logo_html(self) -> str:
136
+ logo_path = Path(__file__).parent / "assets" / "CCCPM.png"
137
+ if logo_path.exists():
138
+ return embed_image_base64(str(logo_path))
139
+ return ""
140
+
141
+ def _render(self, ctx: dict) -> str:
142
+ template_dir = Path(__file__).parent / "templates"
143
+ env = jinja2.Environment(
144
+ loader=jinja2.FileSystemLoader(str(template_dir)),
145
+ autoescape=jinja2.select_autoescape(["html"]),
146
+ )
147
+ # Disable autoescape for HTML fragment variables (figures, tables)
148
+ env.autoescape = False
149
+
150
+ template = env.get_template("report.html.j2")
151
+
152
+ # Inline the stylesheet
153
+ css_path = template_dir / "styles.css"
154
+ ctx["styles"] = css_path.read_text(encoding="utf-8")
155
+
156
+ return template.render(**ctx)
@@ -0,0 +1,320 @@
1
+ """
2
+ Neuroscience figures for the CCCPM HTML report.
3
+
4
+ Each function takes a node-level connectome matrix (and, where needed, an atlas
5
+ DataFrame) and returns the path to a saved SVG figure. The functions are kept
6
+ self-contained — input = numpy matrix / atlas, output = matplotlib Figure — so
7
+ they can later be moved into the shared ``wwu-mmll/brainplots`` toolbox.
8
+
9
+ Atlas convention (matches brainchord): one row per node, columns include
10
+ ``region``, ``network``, ``hemisphere``, ``x``, ``y``, ``z``. Only ``network``
11
+ is required for the network-summary and chord figures.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+
18
+ import matplotlib.pyplot as plt
19
+ import numpy as np
20
+ import pandas as pd
21
+
22
+ from cccpm.reporting.plots.connectome_utils import (
23
+ aggregate_matrix_by_group,
24
+ get_network_color,
25
+ )
26
+ from cccpm.reporting.plots.figure_style import (
27
+ DIVERGING_CMAP,
28
+ NEG,
29
+ POS,
30
+ SQUARE,
31
+ apply_nature_style,
32
+ save_figure,
33
+ )
34
+
35
+
36
+ # ── 1. Connectivity matrix ─────────────────────────────────────────────────────
37
+
38
+ def connectivity_matrix(
39
+ matrix: np.ndarray,
40
+ results_folder: str,
41
+ atlas: pd.DataFrame | None = None,
42
+ *,
43
+ name: str = "connectivity_matrix",
44
+ ) -> str:
45
+ """
46
+ Node×node heatmap of (signed) edge stability.
47
+
48
+ Red = positive-network edges, blue = negative-network edges. If *atlas* has
49
+ a ``network`` column, nodes are ordered by network and dividers are drawn,
50
+ so within/between-network structure is visible (the classic CPM matrix view).
51
+ """
52
+ apply_nature_style()
53
+ mat = np.asarray(matrix, dtype=float)
54
+ n = mat.shape[0]
55
+
56
+ order = np.arange(n)
57
+ boundaries: list[int] = []
58
+ tick_pos: list[float] = []
59
+ tick_lab: list[str] = []
60
+ if atlas is not None and "network" in atlas.columns and len(atlas) == n:
61
+ nets = atlas["network"].to_numpy()
62
+ order = np.argsort(nets, kind="stable")
63
+ nets_sorted = nets[order]
64
+ mat = mat[np.ix_(order, order)]
65
+ # network block boundaries + centered labels
66
+ start = 0
67
+ for i in range(1, n + 1):
68
+ if i == n or nets_sorted[i] != nets_sorted[start]:
69
+ boundaries.append(i)
70
+ tick_pos.append((start + i) / 2)
71
+ tick_lab.append(str(nets_sorted[start]))
72
+ start = i
73
+
74
+ vmax = np.abs(mat).max() or 1.0
75
+ fig, ax = plt.subplots(figsize=SQUARE)
76
+ # rasterize the cell grid so the SVG stays small for large atlases
77
+ # (200-1000 ROIs) while axes/labels remain crisp vectors.
78
+ im = ax.imshow(mat, cmap=DIVERGING_CMAP, vmin=-vmax, vmax=vmax,
79
+ interpolation="nearest", rasterized=True)
80
+
81
+ if boundaries:
82
+ for b in boundaries[:-1]:
83
+ ax.axhline(b - 0.5, color="#444", linewidth=0.4)
84
+ ax.axvline(b - 0.5, color="#444", linewidth=0.4)
85
+ ax.set_xticks(tick_pos)
86
+ ax.set_xticklabels(tick_lab, rotation=90, fontsize=5)
87
+ ax.set_yticks(tick_pos)
88
+ ax.set_yticklabels(tick_lab, fontsize=5)
89
+ else:
90
+ ax.set_xlabel("node")
91
+ ax.set_ylabel("node")
92
+ ax.set_xticks([])
93
+ ax.set_yticks([])
94
+
95
+ cbar = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
96
+ cbar.ax.tick_params(labelsize=5)
97
+ cbar.set_label("stability (signed)", fontsize=6)
98
+ fig.tight_layout(pad=0.3)
99
+ return save_figure(fig, os.path.join(results_folder, name))
100
+
101
+
102
+ # ── 2. Network-summary matrix ──────────────────────────────────────────────────
103
+
104
+ def network_summary_matrix(
105
+ matrix: np.ndarray,
106
+ atlas: pd.DataFrame,
107
+ results_folder: str,
108
+ *,
109
+ groupby: str = "network",
110
+ name: str = "network_summary_matrix",
111
+ ) -> str | None:
112
+ """
113
+ Region×region matrix: edges aggregated by canonical network.
114
+
115
+ Returns ``None`` if the atlas lacks the *groupby* column (caller shows a
116
+ graceful "needs network labels" message).
117
+ """
118
+ if atlas is None or groupby not in atlas.columns or len(atlas) != matrix.shape[0]:
119
+ return None
120
+
121
+ apply_nature_style()
122
+ groups = atlas[groupby].astype(str).tolist()
123
+ region_df = aggregate_matrix_by_group(np.asarray(matrix, dtype=float), groups, signed=True)
124
+
125
+ vmax = np.abs(region_df.values).max() or 1.0
126
+ fig, ax = plt.subplots(figsize=SQUARE)
127
+ im = ax.imshow(region_df.values, cmap=DIVERGING_CMAP, vmin=-vmax, vmax=vmax,
128
+ rasterized=True)
129
+
130
+ ax.set_xticks(range(len(region_df.columns)))
131
+ ax.set_xticklabels(region_df.columns, rotation=90, fontsize=6)
132
+ ax.set_yticks(range(len(region_df.index)))
133
+ ax.set_yticklabels(region_df.index, fontsize=6)
134
+
135
+ # annotate cells
136
+ for i in range(region_df.shape[0]):
137
+ for j in range(region_df.shape[1]):
138
+ v = region_df.values[i, j]
139
+ if abs(v) > 1e-9:
140
+ ax.text(j, i, f"{v:.2f}", ha="center", va="center",
141
+ fontsize=5, color="#222")
142
+
143
+ cbar = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
144
+ cbar.ax.tick_params(labelsize=5)
145
+ cbar.set_label("mean stability (signed)", fontsize=6)
146
+ fig.tight_layout(pad=0.3)
147
+ return save_figure(fig, os.path.join(results_folder, name))
148
+
149
+
150
+ # ── 3. Chord diagram ───────────────────────────────────────────────────────────
151
+
152
+ def chord_diagram(
153
+ matrix: np.ndarray,
154
+ atlas: pd.DataFrame,
155
+ results_folder: str,
156
+ *,
157
+ groupby: str = "network",
158
+ name: str = "chord_diagram",
159
+ link_alpha: float = 0.55,
160
+ ) -> str | None:
161
+ """
162
+ Network-level chord diagram (pycirclize). Sectors = canonical networks,
163
+ link colour = sign (red positive, blue negative), arc width = |stability|.
164
+
165
+ Returns ``None`` if the atlas lacks the *groupby* column or pycirclize is
166
+ unavailable.
167
+ """
168
+ if atlas is None or groupby not in atlas.columns or len(atlas) != matrix.shape[0]:
169
+ return None
170
+ try:
171
+ from pycirclize import Circos
172
+ except Exception:
173
+ return None
174
+
175
+ groups = atlas[groupby].astype(str).tolist()
176
+ region_df = aggregate_matrix_by_group(np.asarray(matrix, dtype=float), groups, signed=True)
177
+
178
+ # drop empty sectors
179
+ active = region_df.abs().sum(axis=1) > 1e-9
180
+ region_df = region_df.loc[active, active]
181
+ if region_df.empty or region_df.shape[0] < 2:
182
+ return None
183
+
184
+ abs_df = region_df.abs()
185
+ sector_colors = {r: get_network_color(r) for r in region_df.index}
186
+
187
+ def _link_kws_handler(r1: str, r2: str) -> dict:
188
+ try:
189
+ val = region_df.loc[r1, r2]
190
+ except KeyError:
191
+ val = 0.0
192
+ color = POS if val >= 0 else NEG
193
+ return dict(fc=color, alpha=link_alpha, zorder=1.0)
194
+
195
+ circos = Circos.chord_diagram(
196
+ abs_df,
197
+ space=min(3.0, 360 / max(len(region_df), 1) * 0.8),
198
+ cmap=sector_colors,
199
+ label_kws=dict(size=7, r=110, orientation="vertical"),
200
+ link_kws=dict(alpha=link_alpha, zorder=1.0),
201
+ link_kws_handler=_link_kws_handler,
202
+ )
203
+ fig = circos.plotfig(figsize=SQUARE, dpi=150)
204
+ fig.patch.set_facecolor("white")
205
+ return save_figure(fig, os.path.join(results_folder, name), formats=("svg",))
206
+
207
+
208
+ # ── 4. Node-degree / hub plot ──────────────────────────────────────────────────
209
+
210
+ def node_degree_plot(
211
+ matrix: np.ndarray,
212
+ results_folder: str,
213
+ atlas: pd.DataFrame | None = None,
214
+ *,
215
+ top_k: int = 15,
216
+ name: str = "node_degree",
217
+ ) -> str:
218
+ """
219
+ Horizontal bar of the most-connected nodes (hubs): number of stable edges
220
+ per node, split into positive (red) and negative (blue) contributions.
221
+ """
222
+ apply_nature_style()
223
+ mat = np.asarray(matrix, dtype=float)
224
+ n = mat.shape[0]
225
+
226
+ pos_deg = (mat > 1e-9).sum(axis=1)
227
+ neg_deg = (mat < -1e-9).sum(axis=1)
228
+ total = pos_deg + neg_deg
229
+
230
+ if atlas is not None and "region" in atlas.columns and len(atlas) == n:
231
+ labels = atlas["region"].astype(str).to_numpy()
232
+ else:
233
+ labels = np.array([f"node {i}" for i in range(n)])
234
+
235
+ order = np.argsort(total)[::-1][:top_k]
236
+ order = order[total[order] > 0]
237
+ if len(order) == 0:
238
+ order = np.argsort(total)[::-1][:top_k]
239
+
240
+ y = np.arange(len(order))[::-1]
241
+ fig, ax = plt.subplots(figsize=(SQUARE[0], SQUARE[1]))
242
+ ax.barh(y, pos_deg[order], color=POS, label="positive")
243
+ ax.barh(y, neg_deg[order], left=pos_deg[order], color=NEG, label="negative")
244
+ ax.set_yticks(y)
245
+ ax.set_yticklabels(labels[order], fontsize=5)
246
+ ax.set_xlabel("number of stable edges")
247
+ # Legend above the plot so it never covers bars.
248
+ ax.legend(fontsize=6, frameon=False, ncol=2,
249
+ loc="lower right", bbox_to_anchor=(1.0, 1.0))
250
+ sns_despine(ax)
251
+ fig.tight_layout(pad=0.3)
252
+ return save_figure(fig, os.path.join(results_folder, name))
253
+
254
+
255
+ def sns_despine(ax) -> None:
256
+ """Local despine to avoid importing seaborn just for this."""
257
+ for spine in ("top", "right"):
258
+ ax.spines[spine].set_visible(False)
259
+
260
+
261
+ # ── 5. Glass brain (netplotbrain) ──────────────────────────────────────────────
262
+
263
+ def glass_brain(
264
+ matrix: np.ndarray,
265
+ atlas: pd.DataFrame,
266
+ results_folder: str,
267
+ *,
268
+ edge_color: str = POS,
269
+ name: str = "glass_brain",
270
+ ) -> str | None:
271
+ """
272
+ Anatomical glass-brain rendering of a set of edges via netplotbrain.
273
+
274
+ *matrix* is a node×node matrix whose non-zero off-diagonal entries are the
275
+ edges to draw. The atlas must provide node coordinates (``x``, ``y``, ``z``).
276
+ Returns the PNG path, or ``None`` if there is nothing to draw / no coordinates
277
+ / netplotbrain is unavailable.
278
+
279
+ This supersedes the file-based ``cpm_chord_plot.plot_netplotbrain`` (which
280
+ expected ``sig_stability_*`` .npy files the pipeline no longer writes): the
281
+ edges are passed in directly so it works from the stability matrices.
282
+ """
283
+ if atlas is None or not {"x", "y", "z"}.issubset(atlas.columns):
284
+ return None
285
+ mat = np.asarray(matrix, dtype=float)
286
+ if mat.shape[0] != len(atlas):
287
+ return None
288
+
289
+ rows, cols = np.triu_indices(mat.shape[0], k=1)
290
+ mask = np.abs(mat[rows, cols]) > 1e-9
291
+ i_idx, j_idx, weights = rows[mask], cols[mask], mat[rows, cols][mask]
292
+ if len(i_idx) == 0:
293
+ return None
294
+
295
+ try:
296
+ import netplotbrain
297
+ except Exception:
298
+ return None
299
+
300
+ edges_df = pd.DataFrame({"i": i_idx, "j": j_idx, "weights": weights})
301
+ try:
302
+ fig, _ = netplotbrain.plot(
303
+ template="MNI152NLin2009cAsym",
304
+ template_style="glass",
305
+ nodes=atlas,
306
+ edges=edges_df,
307
+ view=["LSR"],
308
+ highlight_edges=True,
309
+ highlight_nodes=None,
310
+ node_type="circles",
311
+ edge_color=edge_color,
312
+ node_color="#332f2c",
313
+ )
314
+ except Exception:
315
+ return None
316
+
317
+ out = os.path.join(results_folder, f"{name}.png")
318
+ fig.savefig(out, dpi=200, bbox_inches="tight", facecolor="white")
319
+ plt.close(fig)
320
+ return out
@@ -0,0 +1,170 @@
1
+ """
2
+ Connectome / atlas utilities for the brain-figure module.
3
+
4
+ These helpers are deliberately self-contained (they operate on plain numpy
5
+ matrices and a pandas atlas table) so the whole brain-figure layer can later be
6
+ lifted into the shared ``wwu-mmll/brainplots`` toolbox with minimal change.
7
+
8
+ The network colour scheme and ``upper_tri_to_matrix`` are adapted from that
9
+ toolbox's ``brainchord`` package (Juri Howe) to keep the two consistent.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import matplotlib.colors as mcolors
15
+ import matplotlib.pyplot as plt
16
+ import numpy as np
17
+ import pandas as pd
18
+
19
+
20
+ # ── Network colours (Yeo-7 / Schaefer / HCP inspired) ─────────────────────────
21
+ # Adapted from brainchord.atlas.DEFAULT_NETWORK_COLORS.
22
+ DEFAULT_NETWORK_COLORS: dict[str, str] = {
23
+ "Vis": "#781286",
24
+ "SomMot": "#4682b4",
25
+ "DorsAttn": "#00760e",
26
+ "SalVentAttn": "#c43afa",
27
+ "Limbic": "#dcf8a4",
28
+ "Cont": "#e69422",
29
+ "Default": "#cd3e4e",
30
+ "Subcortical": "#7b1fa2",
31
+ "Thalamus": "#6a1b9a",
32
+ "Cerebellum": "#1565c0",
33
+ "Brainstem": "#c2185b",
34
+ "Prefrontal": "#d32f2f",
35
+ "Frontal": "#e53935",
36
+ "Motor": "#c62828",
37
+ "Premotor": "#b71c1c",
38
+ "Insula": "#f9a825",
39
+ "Parietal": "#388e3c",
40
+ "Temporal": "#43a047",
41
+ "Occipital": "#00897b",
42
+ "Cingulate": "#0277bd",
43
+ }
44
+
45
+ _FALLBACK_CYCLE = list(plt.cm.tab20.colors)
46
+ _fallback_map: dict[str, str] = {}
47
+
48
+
49
+ def get_network_color(name: str, custom: dict[str, str] | None = None) -> str:
50
+ """Return a hex colour for a network/region *name* (exact → fuzzy → fallback)."""
51
+ lookup = {**DEFAULT_NETWORK_COLORS, **(custom or {})}
52
+ if name in lookup:
53
+ return lookup[name]
54
+ lower = str(name).lower()
55
+ for key, color in lookup.items():
56
+ if key.lower() in lower or lower in key.lower():
57
+ return color
58
+ if name not in _fallback_map:
59
+ idx = len(_fallback_map) % len(_FALLBACK_CYCLE)
60
+ _fallback_map[name] = mcolors.to_hex(_FALLBACK_CYCLE[idx])
61
+ return _fallback_map[name]
62
+
63
+
64
+ # ── Matrix helpers ────────────────────────────────────────────────────────────
65
+
66
+ def upper_tri_to_matrix(n_nodes: int, edge_values: np.ndarray) -> np.ndarray:
67
+ """Reconstruct a symmetric ``n_nodes × n_nodes`` matrix from an upper-tri vector."""
68
+ mat = np.zeros((n_nodes, n_nodes))
69
+ rows, cols = np.triu_indices(n_nodes, k=1)
70
+ mat[rows, cols] = edge_values
71
+ mat[cols, rows] = edge_values
72
+ return mat
73
+
74
+
75
+ def signed_stability_matrix(edge_stability: np.ndarray) -> np.ndarray:
76
+ """
77
+ Collapse a ``[n_nodes, n_nodes, 2(, runs)]`` stability array into one signed
78
+ matrix: positive-network stability as ``+value``, negative as ``-value``.
79
+
80
+ An edge belongs to at most one network, so the two layers do not overlap.
81
+ """
82
+ arr = np.asarray(edge_stability, dtype=float)
83
+ # squeeze any trailing run dimension(s) down to [n, n, 2]
84
+ while arr.ndim > 3:
85
+ arr = arr[..., 0]
86
+ pos = arr[:, :, 0]
87
+ neg = arr[:, :, 1]
88
+ return pos - neg
89
+
90
+
91
+ def significant_edge_matrices(
92
+ edge_stability: np.ndarray,
93
+ edge_significance: np.ndarray | None = None,
94
+ *,
95
+ alpha: float = 0.05,
96
+ fallback_to_stable: bool = True,
97
+ ) -> tuple[np.ndarray, np.ndarray]:
98
+ """
99
+ Build binary positive/negative node×node matrices of the edges to highlight
100
+ on the glass brain.
101
+
102
+ An edge is kept if it was selected (stability > 0) and, when significance is
103
+ available, its stability p-value is below *alpha*. If significance filtering
104
+ leaves a network empty but stable edges exist, fall back to all stable edges
105
+ (so the figure still renders — e.g. with few permutations the p-values can
106
+ sit just above the threshold). Returns ``(positive_matrix, negative_matrix)``.
107
+ """
108
+ stab = np.asarray(edge_stability, dtype=float)
109
+ while stab.ndim > 3:
110
+ stab = stab[..., 0] # drop trailing run dim(s) -> [n, n, 2]
111
+
112
+ sig = None
113
+ if edge_significance is not None:
114
+ sig = np.asarray(edge_significance, dtype=float)
115
+ while sig.ndim > 3:
116
+ sig = sig[..., 0]
117
+
118
+ out = []
119
+ for layer in (0, 1):
120
+ stable = stab[:, :, layer] > 1e-9
121
+ keep = stable
122
+ if sig is not None:
123
+ keep = stable & (sig[:, :, layer] < alpha)
124
+ if fallback_to_stable and not keep.any() and stable.any():
125
+ keep = stable
126
+ out.append(np.where(keep, 1.0, 0.0))
127
+ return out[0], out[1]
128
+
129
+
130
+ def aggregate_matrix_by_group(
131
+ matrix: np.ndarray,
132
+ groups: list[str],
133
+ *,
134
+ signed: bool = True,
135
+ ) -> pd.DataFrame:
136
+ """
137
+ Aggregate a node×node *matrix* into a region×region DataFrame by averaging
138
+ over the off-diagonal node pairs that fall in each (group_i, group_j) block.
139
+
140
+ Parameters
141
+ ----------
142
+ matrix : np.ndarray, shape (n_nodes, n_nodes)
143
+ Symmetric node-level matrix (e.g. signed stability).
144
+ groups : list[str], length n_nodes
145
+ Group/network label for each node.
146
+ signed : bool
147
+ If False, aggregate absolute values.
148
+ """
149
+ n_nodes = matrix.shape[0]
150
+ if len(groups) != n_nodes:
151
+ raise ValueError(f"groups has {len(groups)} entries but matrix has {n_nodes} nodes.")
152
+
153
+ vals = matrix if signed else np.abs(matrix)
154
+ unique = list(dict.fromkeys(groups))
155
+ idx = {g: i for i, g in enumerate(unique)}
156
+ k = len(unique)
157
+
158
+ sums = np.zeros((k, k))
159
+ counts = np.zeros((k, k), dtype=int)
160
+ rows, cols = np.triu_indices(n_nodes, k=1)
161
+ for r, c in zip(rows, cols):
162
+ gi, gj = idx[groups[r]], idx[groups[c]]
163
+ sums[gi, gj] += vals[r, c]
164
+ sums[gj, gi] += vals[r, c]
165
+ counts[gi, gj] += 1
166
+ counts[gj, gi] += 1
167
+
168
+ with np.errstate(invalid="ignore"):
169
+ mean = np.where(counts > 0, sums / counts, 0.0)
170
+ return pd.DataFrame(mean, index=unique, columns=unique)