Notebook 1. Whole Brain Analysis
# Work from this notebook's folder (content/), also when the notebook is executed from the project root
# (myst build --execute, NeuroLibre), so the local modules (helpers, stats_test, ...) and ../data/ resolve.
import os
import sys
if os.path.isdir("content") and not os.path.isfile("helpers.py"):
os.chdir("content")
if os.getcwd() not in sys.path:
sys.path.insert(0, os.getcwd())import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np
from scipy.spatial import distance
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
# Setting text svg exports as exports
plt.rcParams['svg.fonttype'] = 'none'
# rpy2 configuration
#import rpy2.robjects as ro
from rpy2.robjects.packages import importr
#from rpy2.robjects import pandas2ri
#from rpy2.robjects.conversion import localconverter
try:
effectsize = importr('effectsize')
marginaleffects = importr('marginaleffects')
car = importr('car')
print("✅ All R dependencies are loaded and ready!")
except Exception as e:
print(f"❌ Missing R package: {e}")✅ All R dependencies are loaded and ready!
# Import auxiliary functions bundled in this notebook
import ancova_assumptions as aa
import stats_test as st
import helpers
The next flag creates static figures that were used as a base for paper static version. Saved in static folder. If you want to create them again, set this variable to True. Otherwise, set it to False.
# Static (matplotlib/seaborn) figure versions for the Word/PDF/LaTeX exports and the journal figures.
# Written to static/figures/ only when STATIC_FIGURE_FORMATS is set (pixi tasks static-figures-png/-pdf/-svg,
# export-docx, export-tex). A plain `pixi run run-notebooks` does not write them.
create_Static_figures = bool(helpers.STATIC_FIGURE_FORMATS)
# This color pallet matches plotly figures with seaborn defaults.
color_palette = ["#4C72B0", "#DD8452", "#55A868", "#C44E52", "#8172B2", "#937860", "#DA8BC3", "#8C8C8C", "#CCB974", "#64B5CD"]Load data¶
df = pd.read_csv(helpers.data_path("hyades_wholeBrain_ROIs.csv"))# Exclusion list obtained from QC the segemntation and registration of the anatomical scans. Participants with a QC level of 0 were excluded from the analyses.
exclusion_list = pd.read_csv(helpers.data_path("exclusion_list.csv"))
print(exclusion_list)
exclusion_list = exclusion_list["participant_id"].to_list() participant_id reason
0 sub-021 HippUnfold segmentation with a small leak arti...
1 sub-024 HippUnfold segmentation slightly failed: SRLM ...
2 sub-035 HippUnfold segmentation failed with an artifac...
3 sub-045 HippUnfold segmentation slightly failed with s...
4 sub-049 HippUnfold segmentation failed with subiculum ...
5 sub-051 HippUnfold segmentation struggled with multipl...
6 sub-057 HippUnfold segmentation failed with small leak...
Wrangling¶
# rename column from dataframe (just for stylec onsitency with paper)
df = df.rename(columns={'D_dki': 'MD'})
# normalized_volume is the ROI volume as a fraction of total intracranial volume (TIV); express it as % of TIV
# (same units as the hippocampal subfield volumes; F statistics, effect sizes and p-values are unaffected)
df['normalized_volume'] = df['normalized_volume'] * 100# Drop subjects with other diagnosis than dementia
nonDemGroups = ['PDD','PSP','VCI','PD','Conc','aMCI','TBD']
df = df.query("group != @nonDemGroups")# Drop subjects in the exclusion list
df = df.query("participant_id != @exclusion_list")# Define the desired order of categories
group_order = ['HC', 'LBD', 'FTD', 'AD']
# Convert to ordered categorical type (the data files code healthy controls as 'Control'; the paper uses HC)
df['group'] = df['group'].replace({'Control': 'HC'})
df['group'] = pd.Categorical(df['group'], categories=group_order, ordered=True)
# harmonize color palette
group_colors = dict(zip(group_order, color_palette))# Drop "unkown" label structre
df = df.query("structure != 'unknown'")Extract features to study¶
metadata = ["participant_id", "group", "age",'sex',"structure"]
#metrics = ["FA","D_uFA", "uFA", "Ki", "Vol_mm3_norm", "MTsat"]
metrics = ["FA","MD", "uFA", "Ki", "MTsat", "normalized_volume"]
df_brain_grouped = df[metadata + metrics]# Define the order of the structures for plotting
structure_order = ["Parietal", "Frontal", "Temporal", "Occipital", "Cingulate", "Insula", "Hippocampus", "Amygdala"];Mahallanobis distance by metric¶
def mahalanobis_distance_byMetrics(df, group2compare = "HC", metrics=None):
"""
Calculates the Mahalanobis distance of each subject to the distribution of the 'group2compare' group
for each unique structure, using the columns in the metrics list.
Parameters
----------
df : pd.DataFrame
DataFrame containing participant data.
group2compare : str
Column name for the group to compare with.
metrics : str
List of metrics to use to calculate the distance.
Returns
-------
pd.DataFrame
DataFrame with original data and the Mahalanobis distance for each structure to the group2compare group.
"""
group_col='group'
structure_col='structure'
results = []
for structure in df[structure_col].unique():
df_struct = df[df[structure_col] == structure].copy()
group2compare_df = df_struct[df_struct[group_col] == group2compare][metrics]
if len(group2compare_df) < 2:
continue # Skip if not enough subjects for covariance
group2compare_mean = group2compare_df.mean().values
group2compare_cov = np.cov(group2compare_df, rowvar=False)
group2compare_cov_inv = np.linalg.pinv(group2compare_cov)
def compute_md(row):
return distance.mahalanobis(row[metrics], group2compare_mean, group2compare_cov_inv)
df_struct['mahalanobis_distance'] = df_struct.apply(compute_md, axis=1)
results.append(df_struct)
return pd.concat(results, ignore_index=True)
df_brain_grouped_mahallanobis = mahalanobis_distance_byMetrics(df_brain_grouped, group2compare = "HC", metrics=metrics)fig = px.box(
df_brain_grouped_mahallanobis,
x='structure',
y='mahalanobis_distance',
color='group',
category_orders={
'structure': structure_order,
'group': group_order,
},
color_discrete_map=group_colors,
labels={
'structure': 'ROI',
'mahalanobis_distance': 'Mahalanobis distance',
'group': 'Group'
},
)
# fig.update_layout(
# width=2000,
# height=600,
# legend=dict(
# orientation='v',
# yanchor='middle', y=0.5,
# xanchor='left', x=1.02,
# ),
# boxmode='group',
# )
figLoading...
if create_Static_figures:
helpers.static_theme()
fig, ax = plt.subplots(figsize=(helpers.TEXT_WIDTH_IN, 2.8))
sns.boxplot(data=df_brain_grouped_mahallanobis, x='structure', y="mahalanobis_distance", hue="group",
order=structure_order, palette=group_colors, ax=ax)
sns.move_legend(ax, 'upper left', bbox_to_anchor=(1, 1), title="Group", frameon=False)
ax.set_xlabel("ROI")
ax.set_ylabel("Mahalanobis distance")
sns.despine(fig)
fig.tight_layout()
helpers.save_static_figure(fig, "fig_brainRois_MhD_boxplots")
plt.close(fig)
sns.set_theme()Plotting maha vs age¶
df_brain_grouped_mahallanobis_melted = pd.melt(df_brain_grouped_mahallanobis, id_vars=['participant_id','group','age','sex','structure'], value_vars='mahalanobis_distance',
var_name='metric', value_name='value')
#df_brain_grouped_mahallanobis_meltedfig = px.scatter(
df_brain_grouped_mahallanobis_melted,
x='age',
y='value',
color='group',
facet_col='structure',
facet_col_wrap=2,
category_orders={
'structure': structure_order,
'group': group_order,
},
color_discrete_map=group_colors,
trendline='ols',
labels={
'age': 'Age',
'value': 'Value',
'group': 'Group',
},
)
# sharey=False equivalent: let each facet have its own y-axis range
fig.update_yaxes(matches=None, showticklabels=True)
# px.scatter + trendline creates a duplicate legend entry per group
# (one for points, one for the "...,trendline" line) — collapse to one
seen = set()
for trace in fig.data:
base_name = trace.name.split(',')[0]
trace.legendgroup = base_name
if 'trendline' in trace.name:
trace.showlegend = False
elif base_name in seen:
trace.showlegend = False
else:
trace.name = base_name
seen.add(base_name)
# clean up facet titles ("structure=Hippocampus" -> "Hippocampus")
fig.for_each_annotation(lambda a: a.update(text=a.text.split('=')[-1]))
fig.update_layout(
width=1100,
height=1600,
legend=dict(
orientation='v',
yanchor='middle', y=0.5,
xanchor='left', x=1.02,
),
)
figLoading...
if create_Static_figures:
helpers.static_theme()
# FacetGrid + regplot (same as lmplot) so the bootstrapped confidence bands can use a fixed seed
p = sns.FacetGrid(df_brain_grouped_mahallanobis_melted, hue='group', palette=group_colors, col="structure",
col_wrap=4, col_order=structure_order, sharey=False, height=1.55, aspect=1.0)
p.map_dataframe(sns.regplot, x='age', y='value', seed=0, scatter_kws={"s": 6})
p.add_legend(title="Group")
p.set_titles("{col_name}")
p.set_axis_labels("Age (years)", "Mahalanobis distance")
helpers.save_static_figure(p.figure, "fig_wholeBrain_ageMhD")
plt.close(p.figure)
sns.set_theme()Visualization of metrics by region¶
qMRI_metrics = ["FA", "MD", "uFA", "Ki", "MTsat", "normalized_volume"]
default_metric = "uFA"
default_idx = qMRI_metrics.index(default_metric)
fig = go.Figure()
traces_per_metric = len(group_order)
for i, metric in enumerate(qMRI_metrics):
if metric == 'MTsat':
data_plot = df_brain_grouped[df_brain_grouped["MTsat"].notna()]
else:
data_plot = df_brain_grouped
for group in group_order:
data_group = data_plot[data_plot["group"] == group]
fig.add_trace(
go.Box(
x=data_group["structure"],
y=data_group[metric],
name=group,
legendgroup=group,
offsetgroup=group,
marker_color=group_colors[group],
visible=(i == default_idx),
showlegend=(i == default_idx),
)
)
fig.update_xaxes(
categoryorder='array',
categoryarray=structure_order,
#tickangle=-90,
)
buttons = []
n_total = len(qMRI_metrics) * traces_per_metric
for i, metric in enumerate(qMRI_metrics):
visible_mask = [False] * n_total
start = i * traces_per_metric
for j in range(traces_per_metric):
visible_mask[start + j] = True
buttons.append(dict(
label=st.metric_name(metric),
method='update',
args=[
{'visible': visible_mask, 'showlegend': visible_mask},
{'yaxis': {'title': st.metric_axis_label(metric)}, 'title': f"Boxplot — {st.metric_name(metric)}"},
],
))
fig.update_layout(
width=750,
height=500,
boxmode='group',
boxgap=0.15,
boxgroupgap=0.0,
title=f"Boxplot — {st.metric_name(default_metric)}",
yaxis_title=st.metric_axis_label(default_metric),
yaxis_automargin=True,
margin=dict(l=20, r=100, t=80, b=60),
legend=dict(
orientation='v',
yanchor='middle', y=0.5,
xanchor='left', x=1.02,
),
updatemenus=[dict(
type='dropdown',
buttons=buttons,
direction='down',
x=.7, xanchor='left',
y=1.15, yanchor='top',
showactive=True,
active=default_idx,
)],
)
figLoading...
if create_Static_figures:
from matplotlib.patches import Patch
helpers.static_theme()
# A: quantitative MRI metrics; B: normalized volume, split into larger and smaller ROIs (own y scales)
fig = plt.figure(figsize=(helpers.TEXT_WIDTH_IN, 7.0))
subfig_a, subfig_b = fig.subfigures(2, 1, height_ratios=[3, 1.25])
axes = subfig_a.subplots(nrows=3, ncols=2).flatten()
qMRI_metrics = ["FA", "MD", "uFA", "Ki", "MTsat"]
for i, metric in enumerate(qMRI_metrics):
data_plot = df_brain_grouped[df_brain_grouped["MTsat"].notna()] if metric == 'MTsat' else df_brain_grouped
sns.boxplot(data=data_plot, x="structure", y=metric, hue="group", palette=group_colors, ax=axes[i],
order=structure_order)
axes[i].set_title(st.metric_axis_label(metric))
axes[i].set_ylabel("")
axes[i].set_xlabel("")
# ROI names only on the lowest panel of each column (MTsat, Ki)
if metric in ("MTsat", "Ki"):
axes[i].tick_params(axis='x', rotation=45)
else:
axes[i].tick_params(axis='x', labelbottom=False)
axes[i].get_legend().remove()
# sixth slot: legend
handles = [Patch(facecolor=group_colors[g], edgecolor="0.25", label=g) for g in group_order]
axes[5].axis("off")
axes[5].legend(handles=handles, title="Group", loc="center", frameon=False)
metric = "normalized_volume"
structures_plot = [['Parietal', 'Frontal', 'Temporal', 'Occipital'], ['Insula', 'Cingulate', 'Hippocampus', 'Amygdala']]
axes_b = subfig_b.subplots(nrows=1, ncols=2)
for ax, structures in zip(axes_b, structures_plot):
data_plot = df_brain_grouped.query("structure in @structures")
sns.boxplot(data=data_plot, x="structure", y=metric, hue="group", palette=group_colors, ax=ax,
order=structures, legend=False)
ax.set_xlabel("")
ax.set_ylabel("")
axes_b[0].set_ylabel(st.metric_axis_label(metric))
for subfig, letter in ((subfig_a, "A"), (subfig_b, "B")):
subfig.text(0, 1, letter, fontsize=10, fontweight="bold", va="top", ha="left")
sns.despine(fig)
subfig_a.subplots_adjust(left=0.08, right=0.97, top=0.95, bottom=0.12, hspace=0.35, wspace=0.18)
subfig_b.subplots_adjust(left=0.08, right=0.97, top=0.88, bottom=0.15, wspace=0.18)
helpers.save_static_figure(fig, "fig_brainRois_metrics_boxplots")
plt.close(fig)
sns.set_theme()Stadistics¶
# Define general variables
structures = df_brain_grouped["structure"].unique()
mc_alpha = 0.05Maha¶
# MhD transformation to stabilize variance in data for simple stats
df_brain_grouped_mahallanobis['mahalanobis_distance_WilHil'] = df_brain_grouped_mahallanobis['mahalanobis_distance'].pow(1/3)
df_brain_grouped_mahallanobis['mahalanobis_distance_log'] = np.log(df_brain_grouped_mahallanobis['mahalanobis_distance'])#summary_ancovaAssumptions, raw = aa.ancova_assumption_tests(df_brain_grouped_mahallanobis, ['mahalanobis_distance'], structures, covar='age + sex')
#summary_ancovaAssumptions, raw = aa.ancova_assumption_tests(df_brain_grouped_mahallanobis, ['mahalanobis_distance_WilHil'], structures, covar='age + sex')
summary_ancovaAssumptions, raw = aa.ancova_assumption_tests(df_brain_grouped_mahallanobis, ['mahalanobis_distance_log'], structures, covar='age + sex')
display(summary_ancovaAssumptions[summary_ancovaAssumptions["flag_violated"] == True])Total results collected: 48 rows across 8 structure/metric combinations
Total results collected: 8
Total results collected: 16
Total assumption checks run: 40 | Violations flagged: 1
Loading...
sns.reset_defaults()
plt.figure(figsize=(18, 8))
sns.set_theme(style="white", font_scale=2)
plt.rcParams['svg.fonttype'] = 'none'
ax = sns.boxplot(data=df_brain_grouped_mahallanobis, x='structure', y="mahalanobis_distance_log", hue="group", order=structure_order)
sns.move_legend(ax, 'center left' , bbox_to_anchor=(1, 0.5))
# Code to adjust layout and show the plot
ax.set_xticks(ax.get_xticks())
ax.set_xticklabels(ax.get_xticklabels(), rotation=90)
plt.tight_layout()
#plt.savefig('../static/fig_boxplotMahaMetrics.svg')
plt.show()
# Run ANCOVA test for each structure and MhD, controlling for age + sex
df_brain_grouped_ancova = st.ancova_tests(df_brain_grouped_mahallanobis, ['mahalanobis_distance_log'], structures, summary_ancovaAssumptions, covar='age + sex')
# Perform multiple-comparison correction on the group variable results of the ANCOVA tests
df_brain_grouped_ancova_Pcorrected = st.post_ancova_correctMultipleComparison(df_brain_grouped_ancova, mc_method='fdr_by', mc_alpha=mc_alpha)Total results collected: 32
cols = ["structure", "metric", "F value", "Df_residual",
"Omega2_partial", "CI_low.y", "pvals_adj", "level",
"violated_terms"
]
df_sig_maha = (
df_brain_grouped_ancova_Pcorrected
#.query("pvals_adj < @mc_alpha")
[cols]
.rename(columns={
"F value": "F(3, df_err)",
"Df_residual": "df_err",
"Omega2_partial": "ω²ₚ",
"CI_low.y": "95% CI lower",
"pvals_adj": "p_adj",
"violated_terms": "Interaction included"
})
.sort_values(["structure", "metric"])
#.round(4)
.copy()
).reset_index(drop=True)
df_sig_maha["Interaction included"] = df_sig_maha["Interaction included"].replace("", "—")
st.display_table(st.format_table(df_sig_maha.sort_values(by='ω²ₚ', ascending=False), two_decimals=["F(3, df_err)", "ω²ₚ", "95% CI lower"], p_values=["p_adj"]))Loading...
ROIs¶
# Run ANCOVA test for each structure and metric, controlling for age + sex
df_brain_grouped_ancova = st.ancova_tests(df_brain_grouped, metrics, structures, summary_ancovaAssumptions, covar='age + sex')
# Perform multiple-comparison correction on the group variable results of the ANCOVA tests
df_brain_grouped_ancova_Pcorrected = st.post_ancova_correctMultipleComparison(df_brain_grouped_ancova, mc_method='fdr_by', mc_alpha=mc_alpha)Total results collected: 192
Check homogeneity of regression slopes to asses Ancova assumpsions
summary_ancovaAssumptions, raw = aa.ancova_assumption_tests(df_brain_grouped, metrics, structures, covar='age + sex')Total results collected: 288 rows across 48 structure/metric combinations
Total results collected: 48
Total results collected: 96
Total assumption checks run: 240 | Violations flagged: 20
assumption_names = {
"homogeneity_of_slopes": "Homogeneity of regression slopes",
"normality_of_residuals": "Normality of residuals",
"homogeneity_of_variance": "Homogeneity of variance",
}
test_names = {
"group:age": "group × age interaction",
"group:sex": "group × sex interaction",
"shapiro_wilk": "Shapiro–Wilk",
"group": "Levene",
}
df_violations = (
summary_ancovaAssumptions
.query("flag_violated == True")
.drop(columns="flag_violated")
.assign(test_name=lambda d: d["test_name"].replace(assumption_names),
term=lambda d: d["term"].replace(test_names))
.reset_index(drop=True)
)
st.display_table(st.format_table(df_violations, p_values=["p_value"]))Loading...
# Uncomment to visually inspect the normality assumption for violated cases
#fig = aa.plot_normality_diagnostics(df, raw['normality'])
#fig# Run ANCOVA test for each structure and metric, controlling for age + sex
df_brain_grouped_ancova = st.ancova_tests(df_brain_grouped_mahallanobis, metrics, structures, summary_ancovaAssumptions, covar='age + sex')
# Perform multiple-comparison correction on the group variable results of the ANCOVA tests
df_brain_grouped_ancova_Pcorrected = st.post_ancova_correctMultipleComparison(df_brain_grouped_ancova, mc_method='fdr_by', mc_alpha=mc_alpha)Total results collected: 206
cols = ["structure", "metric", "F value", "Df_residual",
"Omega2_partial", "CI_low.y", "pvals_adj", "level",
"violated_terms"
]
df_sig = (
df_brain_grouped_ancova_Pcorrected
.query("pvals_adj < @mc_alpha")
[cols]
.rename(columns={
"F value": "F(3, df_err)",
"Df_residual": "df_err",
"Omega2_partial": "ω²ₚ",
"CI_low.y": "95% CI lower",
"pvals_adj": "p_adj",
"violated_terms": "Interaction included"
})
.sort_values(["structure", "metric"])
).reset_index(drop=True)
df_sig["Interaction included"] = df_sig["Interaction included"].replace("", "—")
st.display_table(st.format_table(df_sig, two_decimals=["F(3, df_err)", "ω²ₚ", "95% CI lower"], p_values=["p_adj"]))Loading...
df_brain_grouped_ancova_posthoc = st.posthoc_eem_tests(df_brain_grouped, df_brain_grouped_ancova_Pcorrected, summary_ancovaAssumptions, mc_alpha=0.05, covar = 'age + sex')cols = [
"structure", "metric", "contrast",
"df", "t_value",
"hedges_g", "hedges_g_low", "hedges_g_high",
"p_holm", "level"
]
df_brain_grouped_ancova_posthoc_sig = (
df_brain_grouped_ancova_posthoc
.query("p_holm < @mc_alpha")
[cols]
.rename(columns={
"df": "df_err",
"t_value": "t",
"hedges_g": "g",
"hedges_g_low": "g CI low",
"hedges_g_high": "g CI high",
"p_holm": "p (Holm)"
})
.sort_values(["structure", "metric", "contrast"])
.reset_index(drop=True)
)
st.display_table(st.format_table(df_brain_grouped_ancova_posthoc_sig, two_decimals=["t", "g", "g CI low", "g CI high"], p_values=["p (Holm)"]))Loading...
pivot_order = ['AD - HC', 'FTD - HC', 'LBD - HC', 'AD - LBD', 'FTD - LBD', 'AD - FTD']
pivot = (
df_brain_grouped_ancova_posthoc_sig
.pivot_table(index=['structure', 'metric'],
columns='contrast',
values="g",
aggfunc='first')
.reindex(columns=pivot_order)
)
# contrasts that did not survive Holm correction stay NaN: drawn as blank cells, not as g = 0
# flatten the multiindex row labels into a single string per row for display
row_labels = [f"{s} — {st.metric_name(m)}" for s, m in pivot.index]
fig = px.imshow(
pivot.values,
x=pivot.columns.tolist(),
y=row_labels,
color_continuous_scale='RdBu_r',
color_continuous_midpoint=0,
aspect='auto',
labels=dict(color="Hedges' g"),
)
fig.update_traces(
text=[["" if np.isnan(v) else f"{v:.2f}" for v in row] for row in pivot.values],
texttemplate="%{text}",
hovertemplate="%{y}<br>%{x}<br>Hedges' g = %{z:.2f}<extra></extra>",
)
fig.update_layout(
plot_bgcolor="white",
title="Pairwise group differences",
width=700,
height=900,
margin=dict(l=200, r=60, t=60, b=60), # extra left margin for long row labels
)
figLoading...
if create_Static_figures:
helpers.static_theme(style="white")
pivot = (
df_brain_grouped_ancova_posthoc_sig
.pivot_table(index=['structure', 'metric'], columns='contrast', values="g", aggfunc='first')
.reindex(columns=pivot_order)
)
pivot.index = [f"{s} — {st.metric_name(m)}" for s, m in pivot.index]
fig, ax = plt.subplots(figsize=(helpers.TEXT_WIDTH_IN, 5.2))
# contrasts that did not survive Holm correction (NaN) are left blank, as in the interactive figure
sns.heatmap(pivot, mask=pivot.isna(), annot=True, fmt='.2f', annot_kws={"size": 7}, center=0, cmap='RdBu_r',
linewidths=0.5, linecolor="white", ax=ax, cbar_kws={'label': "Hedges' g"})
ax.set_xlabel("Contrast")
ax.set_ylabel("ROI — metric")
ax.tick_params(length=0)
fig.tight_layout()
helpers.save_static_figure(fig, "fig_posthocResults_significant")
plt.close(fig)
sns.set_theme()