cortical-motor-engram-decoding / 01_first_level_glm / GLM.py
GLM.py
Raw
#!/usr/bin/env python
# coding: utf-8


# # GLM

# In[ ]:


"""
First-level surface GLM analysis 

This script fits a surface-based GLM for each subject/run using nilearn,
restricting analysis to trials without behavioral errors. Sequence identity
is modeled at the pair level, and nuisance regressors are included as confounds.

Outputs:
- Z-maps for each sequence × pair contrast (fsaverage5 surface)

Important:
- 

Author: Theo Marins
"""

import os
import numpy as np
import pandas as pd

from nilearn.glm.first_level import FirstLevelModel
from nilearn.surface import PolyData, SurfaceImage
from nilearn.datasets import load_fsaverage, load_fsaverage_data
from nilearn.plotting import plot_surf_stat_map, show
from pathlib import Path

# ============================================================================
# Paths and parameters
# ============================================================================

# Base directories 
BASE_DIR = Path("/PATH/TO/PROJECT")
RAW_DIR = BASE_DIR / "raw"
DERIV_DIR = RAW_DIR / "derivatives"
OUT_DIR = BASE_DIR / "z_scores"

# Make output dir if needed
OUT_DIR.mkdir(parents=True, exist_ok=True)


TR = 1.0

subjects = [f"{i:02d}" for i in range(1, 27)]
sessions = ["01"]
runs = [f"{i:02d}" for i in range(1, 9)]

fsaverage = load_fsaverage()
fsaverage_data = load_fsaverage_data(data_type="sulcal")

# ============================================================================
# Sequence remapping (group-specific counterbalancing)
# ============================================================================

GROUP2_SUBJECTS = [
    "02", "04", "07", "09", "11", "13",
    "15", "17", "19", "21", "23", "25"
]

def remap_seq_num(seq_num, subject):
    """
    Swap sequence labels (1–6 ↔ 7–12) for group 2 subjects.
    """
    if subject in GROUP2_SUBJECTS:
        if 1 <= seq_num <= 6:
            return seq_num + 6
        elif 7 <= seq_num <= 12:
            return seq_num - 6
    return seq_num

# ============================================================================
# Main loop
# ============================================================================

for subject in subjects:
    try:
        for ses in sessions:
            print(f"\nStarting sub-{subject}, ses-{ses}")

            for run in runs:
                print(f"  Processing run-{run}")

                # ------------------------------------------------------------------
                # Load surface fMRI data
                # ------------------------------------------------------------------
                fmri_img_L = (
                    f"{DERIV_DIR}/sub-{subject}/ses-{ses}/func/"
                    f"sub-{subject}_ses-{ses}_task-motorseq_run-{run}_hemi-l_space-fsaverage5_bold.func.gii"
                )
                fmri_img_R = (
                    f"{DERIV_DIR}/sub-{subject}/ses-{ses}/func/"
                    f"sub-{subject}_ses-{ses}_task-motorseq_run-{run}_hemi-r_space-fsaverage5_bold.func.gii"
                )

                surface_image = SurfaceImage(
                    mesh=fsaverage["pial"],
                    data=PolyData(left=fmri_img_L, right=fmri_img_R)
                )

                # ------------------------------------------------------------------
                # Load and prepare events
                # ------------------------------------------------------------------
                events_file = (
                    f"{RAW_DIR}/sub-{subject}/ses-{ses}/func/"
                    f"sub-{subject}_ses-{ses}_task-motorseq_run-{run}_events.tsv"
                )
                events = pd.read_table(events_file)

                events["duration"] = 3.5  # fixed duration (Berlot, personal comm.)
                events["sequence_type"] = events["sequence_type"].replace(
                    {1: "trained", 2: "untrained"}
                )

                events["sequence_num"] = events["sequence_num"].astype(int)
                events["sequence_num"] = events["sequence_num"].apply(
                    lambda x: remap_seq_num(x, subject)
                )

                events["sequence"] = events["sequence_num"].astype(str)

                # Pair repetitions (2 trials per pair)
                events["pair"] = (events.groupby("sequence").cumcount() // 2) + 1
                events["condition"] = events["sequence"] + "_pair" + events["pair"].astype(str)

                # ------------------------------------------------------------------
                # Remove error trials (pair-wise exclusion)
                # ------------------------------------------------------------------
                valid_conditions = (
                    events.groupby("condition")["is_error"].sum()
                )
                valid_conditions = valid_conditions[valid_conditions == 0].index

                events_no_error = events[events["condition"].isin(valid_conditions)]

                events_glm = (
                    events_no_error[["onset", "duration", "condition"]]
                    .rename(columns={"condition": "trial_type"})
                    .replace(np.nan, 0)
                )

                # ------------------------------------------------------------------
                # Load confounds
                # ------------------------------------------------------------------
                confounds_file = (
                    f"{DERIV_DIR}/sub-{subject}/ses-{ses}/func/"
                    f"sub-{subject}_ses-{ses}_task-motorseq_run-{run}_desc-confounds_timeseries.tsv"
                )
                confounds = pd.read_csv(confounds_file, sep="\t")

                confounds_glm = confounds[
                    [
                        "white_matter", "global_signal", "framewise_displacement",
                        "trans_x", "trans_y", "trans_z",
                        "rot_x", "rot_y", "rot_z",
                    ]
                ].replace(np.nan, 0)

                # ------------------------------------------------------------------
                # Fit surface GLM
                # ------------------------------------------------------------------
                glm = FirstLevelModel(
                    t_r=TR,
                    hrf_model="spm",
                ).fit(
                    run_imgs=surface_image,
                    events=events_glm,
                    confounds=confounds_glm,
                )

                # ------------------------------------------------------------------
                # Define contrasts (task regressors only)
                # ------------------------------------------------------------------
                design_matrix = glm.design_matrices_[0]

                task_columns = [
                    col for col in design_matrix.columns
                    if not col.startswith(
                        ("white_matter", "global_signal", "framewise", "trans_", "rot_")
                    )
                    and col != "constant"
                ]

                # ------------------------------------------------------------------
                # Compute and visualize contrasts
                # ------------------------------------------------------------------
                for contrast_id in task_columns:
                    contrast_vector = np.zeros(len(design_matrix.columns))
                    contrast_vector[design_matrix.columns.get_loc(contrast_id)] = 1

                    z_map = glm.compute_contrast(
                        contrast_vector,
                        output_type="z_score"
                    )
                    out_file = (
                        OUT_DIR / f"sub-{subject}_ses-{ses}_run-{run}_contrast-{contrast_id}_zmap.func.gii"
                    )
                    z_map.to_filename(out_file)
                    print(f"    Saved z-map for contrast '{contrast_id}' to {out_file}")

    except Exception as e:
        print(f"Error computing contrasts for sub-{subject}: {e}")