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.
- {cccpm-0.3.0 → cccpm-0.3.2}/PKG-INFO +3 -2
- {cccpm-0.3.0 → cccpm-0.3.2}/pyproject.toml +3 -2
- cccpm-0.3.2/src/cccpm/reporting/html_report.py +156 -0
- cccpm-0.3.2/src/cccpm/reporting/plots/brain_figures.py +320 -0
- cccpm-0.3.2/src/cccpm/reporting/plots/connectome_utils.py +170 -0
- cccpm-0.3.2/src/cccpm/reporting/plots/figure_style.py +95 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/reporting/plots/plots.py +160 -68
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/reporting/reporting_utils.py +45 -0
- cccpm-0.3.2/src/cccpm/reporting/section_builders.py +539 -0
- cccpm-0.3.2/src/cccpm/reporting/templates/report.html.j2 +376 -0
- cccpm-0.3.2/src/cccpm/reporting/templates/styles.css +444 -0
- cccpm-0.3.2/src/cccpm/simulation/simulate_sem.py +573 -0
- cccpm-0.3.0/src/cccpm/reporting/html_report.py +0 -153
- cccpm-0.3.0/src/cccpm/reporting/page_generators.py +0 -640
- cccpm-0.3.0/src/cccpm/simulation/mediator_simulation.py +0 -34
- cccpm-0.3.0/src/cccpm/simulation/simulate_sem.py +0 -319
- {cccpm-0.3.0 → cccpm-0.3.2}/LICENSE +0 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/README.md +0 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/__init__.py +0 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/constants.py +0 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/cpm_analysis.py +0 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/edge_selection.py +0 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/inner_fold.py +0 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/logging.py +0 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/models/__init__.py +0 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/models/linear_model.py +0 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/models/nonlinear_models.py +0 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/reporting/__init__.py +0 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/reporting/assets/CCCPM.png +0 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/reporting/data_loader.py +0 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/reporting/plots/__init__.py +0 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/reporting/plots/chord_v2.py +0 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/reporting/plots/cpm_chord_plot.py +0 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/reporting/plots/utils.py +0 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/reporting/table_builders.py +0 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/results_manager.py +0 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/scoring.py +0 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/simulation/__init__.py +0 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/simulation/simulate_multivariate.py +0 -0
- {cccpm-0.3.0 → cccpm-0.3.2}/src/cccpm/simulation/simulate_simple.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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)
|