Test association with behavioural variable such as reaction time#

This notebook runs permutation tests to see whether there is a statistically significant relationship between the phase angle at which a target stimulus was presented and the response was given by the participant. The analysis is done for each participant separately, and the results are visualised in polar plots.

[1]:
import pickle
import numpy as np
from pathlib import Path
from pyriodic.permutation import permutation_test_phase_modulation
from pyriodic.viz import CircPlot
from pyriodic import Circular
import matplotlib.pyplot as plt

[2]:
# defining some constants
num_bins = 6
stat = "median"
filter_outliers = True

subj_ids = ["0001", "0002", "0003", "0012", "0013", "0014", "0016", "0018", "0019",
            "0020", "0021", "0022", "0023", "0024", "0026", "0027", "0028", "0029",
            "0030", "0031"]
data_path = Path("../../data/respiration/intermediate")
sfreq = 300  # Hz


Single level analysis for all participants#

[ ]:
# defining some helper functions
def binned_stats(phase_angles, var, n_bins=10, stat="mean"):
    """
    Calculate binned statistics for response times.

    Parameters:
    rt (array-like): Response times.
    n_bins (int): Number of bins to use.
    stat (str): Statistic to calculate ('mean' or 'median').

    Returns:
    bin_centers (array): Centers of the bins.
    avg_response_times (array): Average response times in each bin.
    std_response_times (array): Standard deviation of response times in each bin.
    """
    bin_edges = np.linspace(0, 2 * np.pi, n_bins + 1)
    bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
    avg_response_times = np.zeros(n_bins+1)
    std_response_times = np.zeros(n_bins+1)

    for i in range(n_bins):
        bin_mask = (phase_angles >= bin_edges[i]) & (phase_angles < bin_edges[i + 1])
        if stat == "mean":
            avg_response_times[i] = np.mean(var[bin_mask]) if np.any(bin_mask) else np.nan
        elif stat == "median":
            avg_response_times[i] = np.median(var[bin_mask]) if np.any(bin_mask) else np.nan
        else:
            raise ValueError("stat must be 'mean' or 'median'")
        # Ensure the last two dots are connected
        std_response_times[i] = np.std(var[bin_mask]) if np.any(bin_mask) else np.nan

    # Ensure the last two dots are connected
    avg_response_times[-1] = avg_response_times[0]
    std_response_times[-1] = std_response_times[0]
    bin_centers = np.concatenate((bin_centers, [bin_centers[0]]))

    return bin_centers, avg_response_times, std_response_times



Association between reaction time and phase angle at the time of target presentation#

[4]:
n_rows = len(subj_ids) // 4
n_cols = 4

fig, axes = plt.subplots(n_rows, n_cols, figsize=(n_rows * 6, n_cols * 6), subplot_kw={"projection": "polar"})

for subj_id, ax in zip(subj_ids, axes.flatten()):

    file_path = data_path / f"participant_{subj_id}_preproc.pkl"

    with open(file_path, 'rb') as f:
        data = pickle.load(f)

    circ = data["circ"]
    rejected_indices = np.array(data["rejected_indices"])
    event_samples = data["event_samples"]
    event_samples = event_samples[~np.isin(event_samples, rejected_indices)]
    labels = circ.labels

    # FIND ALL THE TARGET EVENTS AND CALCULATE THEIR RESPONSE TIMES BY THE EVENT SAMPLES
    response_times = []
    idx_target = [i for i, label in enumerate(labels) if "target" in label]

    for idx in idx_target:
        target_sample = event_samples[idx]
        # Check that the label of the next event is "response"
        if "response" not in labels[idx + 1]:
            response_times.append(np.nan)
            continue
        response_sample = event_samples[idx + 1]
        resp_time = (response_sample - target_sample) / sfreq  # convert to seconds
        response_times.append(resp_time)

    targets = circ["target"].data
    targets = targets[~np.isnan(response_times)]
    response_times = np.array(response_times)[~np.isnan(response_times)]

    if filter_outliers:
        # Filter out outliers by standard deviation
        upper_bound = np.mean(response_times) + 5 * np.std(response_times)
        lower_bound = np.mean(response_times) - 5 * np.std(response_times)

        # Filter out outliers
        filtered_indices = np.where((response_times >= lower_bound) & (response_times <= upper_bound))
        targets = targets[filtered_indices]
        response_times = response_times[filtered_indices]


    # PERFORM PERMUTATION TEST ON THE DATA
    try:
        obs, p = permutation_test_phase_modulation(
            targets, response_times, n_null=1000, verbose=False, n_bins=num_bins
        )
    except ValueError:
        obs, p = None, None

    # PLOTTING
    circ_tmp = Circular(targets)
    plot = CircPlot(circ_tmp, ax=ax, group_by_labels=False)
    plot.add_points(
        y=response_times,
        s=10,
        alpha=0.5,
        marker='o',
        color="forestgreen",
        label=None
    )

    plot.add_hline(
        y=np.mean(response_times) if stat == "mean" else np.median(response_times),
        label=f"{stat} response time",
        color="black",
        alpha=0.7,
        linestyle="--"
    )

    # AVERAGE RESPONSE TIME IN BINS + STD
    bin_centers, avg_response_times, std_response_times = binned_stats(circ_tmp.data, response_times, n_bins=num_bins, stat=stat)


    plot.add_polar_line(
        angles=bin_centers,
        values=avg_response_times,
        errors=std_response_times,
        color='orange',
        label='Binned RT',
        marker='o',
        linestyle='-'
    )
    # Customize the plot
    ax.set_yticks(np.arange(0, np.max(response_times) + 0.5, 0.5))
    ax.set_yticklabels([f"{i:.1f}" for i in np.arange(0, np.max(response_times) + 0.5, 0.5)])

    try:
        ax.set_title(f"Participant {subj_id}, p={p.round(3)}", fontsize=12)
    except AttributeError:
        ax.set_title(f"Participant {subj_id}", fontsize=12)


    ax.yaxis.grid(True)

# Add a single legend for the entire figure at the top center
handles, labels = ax.get_legend_handles_labels()
fig.legend(handles, labels, loc='lower center')
fig.suptitle("RT by target phase angle", fontsize=20)

plt.tight_layout()
plt.show()

../_images/tutorials_07_association_with_behavioural_variable_6_0.png

Association between reaction time and phase angle at the time of response#

[5]:
fig, axes = plt.subplots(n_rows, n_cols, figsize=(n_rows * 6, n_cols * 6), subplot_kw={"projection": "polar"})

for subj_id, ax in zip(subj_ids, axes.flatten()):

    file_path = data_path / f"participant_{subj_id}_preproc.pkl"

    with open(file_path, 'rb') as f:
        data = pickle.load(f)

    circ = data["circ"]
    rejected_indices = np.array(data["rejected_indices"])
    event_samples = data["event_samples"]
    event_samples = event_samples[~np.isin(event_samples, rejected_indices)]
    labels = circ.labels

    # FIND ALL THE TARGET EVENTS AND CALCULATE THEIR RESPONSE TIMES BY THE EVENT SAMPLES
    idx_target = [i for i, label in enumerate(labels) if "target" in label]
    response_phase_angles = []
    response_times = []

    for idx in idx_target:
        target_sample = event_samples[idx]
        # Check that the label of the next event is "response"
        if "response" not in labels[idx + 1]:
            continue
        response_sample = event_samples[idx + 1]
        resp_time = (response_sample - target_sample) / sfreq  # convert to seconds
        response_times.append(resp_time)
        response_phase_angles.append(circ.data[idx + 1])


    response_times = np.array(response_times)
    response_phase_angles = np.array(response_phase_angles)
    if filter_outliers:
        # Filter out outliers by standard deviation
        upper_bound = np.mean(response_times) + 3 * np.std(response_times)
        lower_bound = np.mean(response_times) - 3 * np.std(response_times)

        # Filter out outliers
        filtered_indices = np.where((response_times >= lower_bound) & (response_times <= upper_bound))
        response_phase_angles = response_phase_angles[filtered_indices]
        response_times = response_times[filtered_indices]


    # PLOTTING
    circ_tmp = Circular(response_phase_angles)
    plot = CircPlot(circ_tmp, ax=ax, group_by_labels=False)

    plot.add_points(
        y=response_times,
        s=10,
        alpha=0.5,
        marker='o',
        color="forestgreen",
        label=None
    )

    plot.add_hline(
        y=np.mean(response_times) if stat == "mean" else np.median(response_times),
        label=f"{stat} response time",
        color="black",
        alpha=0.7,
        linestyle="--"
    )

    # AVERAGE RESPONSE TIME IN BINS + STD
    bin_centers, avg_response_times, std_response_times = binned_stats(circ_tmp.data, response_times, n_bins=num_bins, stat=stat)


    plot.add_polar_line(
        angles=bin_centers,
        values=avg_response_times,
        errors=std_response_times,
        color='orange',
        label='Binned RT',
        marker='o',
        linestyle='-'
    )
    # Customize the plot
    ax.set_yticks(np.arange(0, np.max(response_times) + 0.5, 0.5))
    ax.set_yticklabels([f"{i:.1f}" for i in np.arange(0, np.max(response_times) + 0.5, 0.5)])

    try:
        ax.set_title(f"Participant {subj_id}, p={p.round(3)}", fontsize=12)
    except AttributeError:
        ax.set_title(f"Participant {subj_id}", fontsize=12)


    ax.yaxis.grid(True)

# Add a single legend for the entire figure at the top center
handles, labels = ax.get_legend_handles_labels()
fig.legend(handles, labels, loc='lower center')


fig.suptitle("RT by response phase angle", fontsize=20)
plt.tight_layout()
plt.show()

../_images/tutorials_07_association_with_behavioural_variable_8_0.png