Accelerated functional network mapping¶
In this tutorial, you will learn how to use accelerated functional network mapping (AFNM) to compute parcel-level lesion network scores.
What you'll learn:
- Prepare a parcellated group-average FC matrix from a functional connectome
- Run the accelerated functional network mapping analysis
- Interpret and visualize parcel-level LNM scores
Setup¶
Get the tutorial data.
# Get tutorial data
!lacuna tutorial /tmp/tutorial_bids --force
Setting up tutorial data at: /tmp/tutorial_bids ✓ Tutorial data copied to: /tmp/tutorial_bids The tutorial dataset includes: - 3 synthetic subjects (sub-01, sub-02, sub-03) - Binary lesion masks in MNI152NLin6Asym space - BIDS-style structure
Background¶
Classic functional network mapping (FNM) correlates each voxel's timeseries with the lesion timeseries across all subjects in a normative connectome. While powerful, this is computationally intensive and requires loading the full voxelwise connectome.
Accelerated functional network mapping replaces the voxelwise sweep with a single matrix multiplication at the parcel level:
$$\mathbf{m} \times \mathbf{C}$$
where m is a (1 x N) lesion-to-parcel weight vector and C is a (N x N) group-average parcellated functional connectivity matrix. This yields parcel-level scores directly, matching the aggregated results of voxel-level FNM while running in a fraction of the time.
This approach was formalized by van den Heuvel et al. (2026, Nat Neurosci).
Lesion weighting schemes¶
AFNM supports three weighting schemes for the lesion-to-parcel vector m that can be specified with --lesion-weighting:
| Scheme | Description |
|---|---|
fractional (default) |
Each touched parcel gets weight 1/n_touched |
binary |
1 if touched, 0 otherwise |
voxel_count |
Fraction of parcel voxels covered by the lesion |
Fetch functional connectome¶
AFNM requires the same connectome as standard fLNM. Here we fetch a small test version of the GSP1000 dataset. Before fetching, obtain API key from Harvard Dataverse.
!lacuna fetch gsp1000 \
--output-dir /tmp/gsp1000_data \
--api-key $DATAVERSE_API_KEY \
--test-mode \
--skip-checksum \
--no-keep-original
Fetching GSP1000 functional connectome... Output: /tmp/gsp1000_data Mode: TEST (1 tarball only) Warning: Checksum verification disabled ✓ GSP1000 fetch complete! Files: 1 Duration: 0.0s Output: /tmp/gsp1000_data/processed
Prepare the parcellated FC matrix¶
Before running AFNM, we need to reduce the voxelwise connectome into a parcellated group-average connectivity matrix. This is done once per parcellation atlas and can be reused for all subjects.
lacuna parcellate reads the HDF5 connectome batches, extracts parcel-mean timeseries per subject, computes Pearson correlations, applies Fisher z-transform averaging across subjects, and back-transforms to produce a single N x N FC matrix.
!lacuna parcellate \
--connectome-path /tmp/gsp1000_data/processed/ \
--modality functional \
--parcel-atlases schaefer2018tian2020parcels1054networks7 \
--output /tmp/parcellated/ \
--overwrite
Inspect the output.
!ls /tmp/parcellated/
method-parcellate_atlas-schaefer2018tian2020parcels1054networks7_desc-fcgroupr_connmatrix.json method-parcellate_atlas-schaefer2018tian2020parcels1054networks7_desc-fcgroupr_connmatrix.tsv method-parcellate_atlas-schaefer2018tian2020parcels1054networks7_desc-fcgroupz_connmatrix.json method-parcellate_atlas-schaefer2018tian2020parcels1054networks7_desc-fcgroupz_connmatrix.tsv
The output is a BIDS-style TSV + JSON sidecar. The TSV contains the N x N group-FC matrix with region labels as row and column headers.
import pandas as pd
fc_matrix = pd.read_csv(
"/tmp/parcellated/method-parcellate_atlas-schaefer2018tian2020parcels1054networks7_desc-fcgroupz_connmatrix.tsv",
sep="\t",
index_col=0,
)
Visualize the matrix
import matplotlib.pyplot as plt
plt.figure(figsize=(10, 8))
im = plt.imshow(fc_matrix, aspect='auto', cmap='viridis')
plt.colorbar(im, label="Connectivity (Fisher z)")
plt.title("Functional connectivity matrix (Schaefer2018Tian2020Parcels1054Networks7)")
plt.xlabel("Region")
plt.ylabel("Region")
im.set_clim(-0.5, 0.5)
plt.tight_layout()
plt.show()
Run accelerated functional network mapping¶
Now run lacuna run afnm pointing at the parcellated matrix. The analysis:
- Loads the lesion mask for each subject
- Resamples the atlas to the mask and computes a weight vector m (default: fractional weighting, where each touched parcel gets weight 1/n_touched)
- Multiplies m x C to obtain a parcel-level LNM score vector
!lacuna run afnm \
/tmp/tutorial_bids/ \
/tmp/outputs_afnm/ \
--matrix-path /tmp/parcellated/method-parcellate_atlas-schaefer2018tian2020parcels1054networks7_desc-fcgroupz_connmatrix.tsv \
--parcel-atlases schaefer2018tian2020parcels1054networks7 \
--participant-label 01 \
--mask-space MNI152NLin6Asym \
--lesion-weighting fractional
Inspect the output files.
!ls /tmp/outputs_afnm/sub-01/ses-01/anat/
sub-01_ses-01_label-acuteinfarct_method-afnm_atlas-schaefer2018tian2020parcels1054networks7_desc-afnmap_parcelstats.json sub-01_ses-01_label-acuteinfarct_method-afnm_atlas-schaefer2018tian2020parcels1054networks7_desc-afnmap_parcelstats.tsv
Visualize parcel-level scores¶
import warnings
import pandas as pd
import pyvista as pv
import yabplot as yab
warnings.simplefilter("ignore", pv.PyVistaDeprecationWarning)
pv.set_jupyter_backend('static')
pv.global_theme.notebook = True
pv.start_xvfb()
file_path = "/tmp/outputs_afnm/sub-01/ses-01/anat/sub-01_ses-01_label-acuteinfarct_method-afnm_atlas-schaefer2018tian2020parcels1054networks7_desc-afnmap_parcelstats.tsv"
df = pd.read_csv(file_path, sep="\t", index_col=0).iloc[:1000, :1000]
values = df.values.flatten()
yab.plot_cortical(
data=values,
atlas='schaefer1000',
views=['left_lateral', 'left_medial', 'right_lateral', 'right_medial'],
figsize=(800, 300),
vminmax=(-0.5, 0.5),
cmap="RdBu_r",
display_type="static",
)
Run on multiple subjects¶
AFNM also supports batch processing. Omit --participant-label to process all subjects in the BIDS dataset. Clone sub-01 of the tutorial dataset to demonstrate that.
import shutil
from pathlib import Path
tutorial_data = Path("/tmp/tutorial_bids")
tutorial_data_multiple = Path("/tmp/tutorial_bids_multiple")
src_id = "01"
src_file = tutorial_data / f"sub-{src_id}" / "ses-01" / "anat" / f"sub-{src_id}_ses-01_space-MNI152NLin6Asym_label-acuteinfarct_mask.nii.gz"
for i in range(0, 20):
sub_id = f"{i+1:02d}"
target_dir = tutorial_data_multiple / f"sub-{sub_id}" / "ses-01" / "anat"
target_file = target_dir / f"sub-{sub_id}_ses-01_space-MNI152NLin6Asym_label-acuteinfarct_mask.nii.gz"
target_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(src_file, target_file)
!lacuna run afnm \
/tmp/tutorial_bids_multiple/ \
/tmp/outputs_afnm_multiple/ \
--matrix-path /tmp/parcellated/method-parcellate_atlas-schaefer2018tian2020parcels1054networks7_desc-fcgroupz_connmatrix.tsv \
--parcel-atlases schaefer2018tian2020parcels1054networks7 \
--mask-space MNI152NLin6Asym
Use lacuna collect to aggregate parcel-level results across subjects into a single group-level TSV.
!lacuna collect \
/tmp/outputs_afnm_multiple/
Collecting output types: 0%| | 0/1 [00:00<?, ?type/s] Reading ses-01_label-acuteinfarct_method-afnm_atlas-schaefer2018tian2020parcel Reading ses-01_label-acuteinfarct_method-afnm_atlas-schaefer2018tian2020parcel Reading ses-01_label-acuteinfarct_method-afnm_atlas-schaefer2018tian2020parcel Reading ses-01_label-acuteinfarct_method-afnm_atlas-schaefer2018tian2020parcel Reading ses-01_label-acuteinfarct_method-afnm_atlas-schaefer2018tian2020parcel Reading ses-01_label-acuteinfarct_method-afnm_atlas-schaefer2018tian2020parcel Reading ses-01_label-acuteinfarct_method-afnm_atlas-schaefer2018tian2020parcel Collecting output types: 100%|██████████████████| 1/1 [00:00<00:00, 1.32type/s]
!ls /tmp/outputs_afnm_multiple/group_*
/tmp/outputs_afnm_multiple/group_ses-01_label-acuteinfarct_method-afnm_atlas-schaefer2018tian2020parcels1054networks7_desc-afnmap_parcelstats.json /tmp/outputs_afnm_multiple/group_ses-01_label-acuteinfarct_method-afnm_atlas-schaefer2018tian2020parcels1054networks7_desc-afnmap_parcelstats.tsv
group_df = pd.read_csv(
sorted(Path("/tmp/outputs_afnm_multiple/").glob("group_*afnm*parcelstats.tsv"))[0],
sep="\t",
)
print(f"Subjects: {len(group_df)}, Regions: {len(group_df.columns) - 3}")
group_df.head()
Subjects: 20, Regions: 1054
| participant_id | session_id | label | 7Networks_LH_Vis_1 | 7Networks_LH_Vis_2 | 7Networks_LH_Vis_3 | 7Networks_LH_Vis_4 | 7Networks_LH_Vis_5 | 7Networks_LH_Vis_6 | 7Networks_LH_Vis_7 | ... | CAU-DA-lh | CAU-body-lh | CAU-tail-lh | lAMY-lh | mAMY-lh | THA-DP-lh | NAc-shell-lh | NAc-core-lh | pGP-lh | aGP-lh | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1 | 1 | acuteinfarct | 0.107631 | 0.151329 | 0.204577 | 0.252164 | 0.18644 | 0.166947 | 0.045375 | ... | -0.02187 | -0.035619 | -0.048258 | -0.014021 | -0.000934 | 0.150208 | 0.030955 | 0.02343 | 0.064789 | 0.023715 |
| 1 | 2 | 1 | acuteinfarct | 0.107631 | 0.151329 | 0.204577 | 0.252164 | 0.18644 | 0.166947 | 0.045375 | ... | -0.02187 | -0.035619 | -0.048258 | -0.014021 | -0.000934 | 0.150208 | 0.030955 | 0.02343 | 0.064789 | 0.023715 |
| 2 | 3 | 1 | acuteinfarct | 0.107631 | 0.151329 | 0.204577 | 0.252164 | 0.18644 | 0.166947 | 0.045375 | ... | -0.02187 | -0.035619 | -0.048258 | -0.014021 | -0.000934 | 0.150208 | 0.030955 | 0.02343 | 0.064789 | 0.023715 |
| 3 | 4 | 1 | acuteinfarct | 0.107631 | 0.151329 | 0.204577 | 0.252164 | 0.18644 | 0.166947 | 0.045375 | ... | -0.02187 | -0.035619 | -0.048258 | -0.014021 | -0.000934 | 0.150208 | 0.030955 | 0.02343 | 0.064789 | 0.023715 |
| 4 | 5 | 1 | acuteinfarct | 0.107631 | 0.151329 | 0.204577 | 0.252164 | 0.18644 | 0.166947 | 0.045375 | ... | -0.02187 | -0.035619 | -0.048258 | -0.014021 | -0.000934 | 0.150208 | 0.030955 | 0.02343 | 0.064789 | 0.023715 |
5 rows × 1057 columns
Shortcut: use preprocessed connectome matrix¶
While Lacuna supports flexible use of different parcellation atlases for AFNM, a shortcut is available if you are working with the Schaefer1000×7 cortical atlas combined with the Tian54 subcortical atlas.
In this case, you can directly use the precomputed functional connectome provided by by van den Heuvel et al..
Download the matrix and convert it into a format compatible with Lacuna.
!wget -q -O /tmp/GSP1000_FCz_Schaefer1000Melbourne54.mat \
"https://raw.githubusercontent.com/dutchconnectomelab/lesionnetworkmapping/main/data/normative_connectome/GSP1000_FCz_Schaefer1000Melbourne54.mat"
Note that the parcel ordering in the precomputed matrix is Tian → Schaefer. Hence, we will reorder the matrix.
import numpy as np
from scipy.io import loadmat
from lacuna.assets.parcellations import load_parcellation
C_path = "/tmp/GSP1000_FCz_Schaefer1000Melbourne54.mat"
C = loadmat(C_path)["FC"]
parcellation = load_parcellation("schaefer2018tian2020parcels1054networks7")
labels = list(parcellation.labels.values())
n_tian = 54
idx = np.r_[n_tian:C.shape[0], :n_tian]
C_reordered = C[np.ix_(idx, idx)]
C_reordered_df = pd.DataFrame(C_reordered, index=labels, columns=labels)
C_reordered_df.to_csv("/tmp/precomputed_atlas-schaefer2018tian2020parcels1054networks7_desc-fcgroupz_connmatrix.tsv", sep="\t", index=True, header=True)
Visualize the precomputed matrix.
import matplotlib.pyplot as plt
plt.figure(figsize=(10, 8))
im = plt.imshow(C_reordered, aspect='auto', cmap='viridis')
plt.colorbar(im, label="Connectivity (Fisher z)")
plt.title("Precomputed functional connectivity matrix (Schaefer2018Tian2020Parcels1054Networks7)")
plt.xlabel("Region")
plt.ylabel("Region")
im.set_clim(-0.5, 0.5)
plt.tight_layout()
plt.show()
!lacuna run afnm \
/tmp/tutorial_bids/ \
/tmp/outputs_afnm_precomputed/ \
--matrix-path /tmp/precomputed_atlas-schaefer2018tian2020parcels1054networks7_desc-fcgroupz_connmatrix.tsv \
--parcel-atlases schaefer2018tian2020parcels1054networks7 \
--participant-label 01 \
--mask-space MNI152NLin6Asym \
--lesion-weighting fractional
!ls /tmp/outputs_afnm_precomputed/sub-01/ses-01/anat/
sub-01_ses-01_label-acuteinfarct_method-afnm_atlas-schaefer2018tian2020parcels1054networks7_desc-afnmap_parcelstats.json sub-01_ses-01_label-acuteinfarct_method-afnm_atlas-schaefer2018tian2020parcels1054networks7_desc-afnmap_parcelstats.tsv
Compare against classic functional network mapping¶
!lacuna run fnm \
/tmp/tutorial_bids/ \
/tmp/outputs_fnm/ \
--connectome-path /tmp/gsp1000_data/processed/ \
--participant-label 01 \
--mask-space MNI152NLin6Asym \
--parcel-atlases schaefer2018tian2020parcels1054networks7
from scipy.stats import spearmanr
afnm_map = pd.read_csv("/tmp/outputs_afnm/sub-01/ses-01/anat/sub-01_ses-01_label-acuteinfarct_method-afnm_atlas-schaefer2018tian2020parcels1054networks7_desc-afnmap_parcelstats.tsv", sep="\t", index_col=0).values
afnm_precomputed_map = pd.read_csv("/tmp/outputs_afnm_precomputed/sub-01/ses-01/anat/sub-01_ses-01_label-acuteinfarct_method-afnm_atlas-schaefer2018tian2020parcels1054networks7_desc-afnmap_parcelstats.tsv", sep="\t", index_col=0).values
fnm_map = pd.read_csv("/tmp/outputs_fnm/sub-01/ses-01/anat/sub-01_ses-01_label-acuteinfarct_method-fnm_atlas-schaefer2018tian2020parcels1054networks7_desc-zmap_parcelstats.tsv", sep="\t", index_col=0).values
print("Classic FNM vs Accelerated FNM Spearman correlation:", round(spearmanr(fnm_map.flatten(), afnm_map.flatten())[0], 2))
print("Classic FNM vs Accelerated FNM (precomputed) Spearman correlation:", round(spearmanr(fnm_map.flatten(), afnm_precomputed_map.flatten())[0], 2))
print("Accelerated FNM vs Accelerated FNM (precomputed) Spearman correlation:", round(spearmanr(afnm_map.flatten(), afnm_precomputed_map.flatten())[0], 2))
Classic FNM vs Accelerated FNM Spearman correlation: 0.85 Classic FNM vs Accelerated FNM (precomputed) Spearman correlation: 0.85 Accelerated FNM vs Accelerated FNM (precomputed) Spearman correlation: 0.94
import matplotlib.pyplot as plt
df = pd.DataFrame({
"Classic FNM": fnm_map.flatten(),
"Accelerated FNM": afnm_map.flatten(),
"Accelerated FNM (precomputed)": afnm_precomputed_map.flatten(),
})
plt.figure(figsize=(12, 5))
plt.subplot(1, 3, 1)
plt.scatter(df["Classic FNM"], df["Accelerated FNM"], alpha=0.5)
plt.xlabel("Classic FNM")
plt.ylabel("Accelerated FNM")
plt.title("Classic FNM vs AFNM")
plt.subplot(1, 3, 2)
plt.scatter(df["Classic FNM"], df["Accelerated FNM (precomputed)"], alpha=0.5)
plt.xlabel("Classic FNM")
plt.ylabel("Accelerated FNM (precomputed)")
plt.title("Classic FNM vs AFNM (precomputed)")
plt.subplot(1, 3, 3)
plt.scatter(df["Accelerated FNM"], df["Accelerated FNM (precomputed)"], alpha=0.5)
plt.xlabel("Accelerated FNM")
plt.ylabel("Accelerated FNM (precomputed)")
plt.title("AFNM vs AFNM (precomputed)")
plt.tight_layout()
plt.show()