pylance things
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
"""
|
||||
Filename: intragroupstats.py
|
||||
Description: Logic for the Intra-Group Stats analysis window
|
||||
Note: Compliant with pylance strict type checking
|
||||
|
||||
Author: Tyler de Zeeuw
|
||||
License: GPL-3.0
|
||||
"""
|
||||
|
||||
# Built-in imports
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
# External library imports
|
||||
import pandas as pd
|
||||
from pandas import DataFrame
|
||||
|
||||
from mne import Annotations
|
||||
from mne.io.base import BaseRaw
|
||||
|
||||
from flares import run_roi_paired_contrast_analysis, run_roi_second_level_analysis, aggregate_channel_contrasts_to_roi
|
||||
from src.shared.flaresbasewidget import IntraGroupUIMixin, FlaresBaseWidget
|
||||
from src.shared.shareddata import APP_NAME
|
||||
|
||||
|
||||
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
||||
0: [
|
||||
{
|
||||
"key": "p_threshold",
|
||||
"label": "Significance threshold P-value (e.g. 0.05)",
|
||||
"default": "0.05",
|
||||
"type": float,
|
||||
},
|
||||
{
|
||||
"key": "min_subjects",
|
||||
"label": "Minimum number of participants to process",
|
||||
"default": "5",
|
||||
"type": int,
|
||||
},
|
||||
{
|
||||
"key": "correction_method",
|
||||
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
|
||||
"default": "fdr_bh",
|
||||
"type": str,
|
||||
},
|
||||
{
|
||||
"key": "target_chroma",
|
||||
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
|
||||
"default": "hbo",
|
||||
"type": str,
|
||||
},
|
||||
{
|
||||
"key": "graph_bounds",
|
||||
"label": "Graph Upper/Lower Limit",
|
||||
"default": "0.0",
|
||||
"type": float,
|
||||
}
|
||||
],
|
||||
1: [
|
||||
{
|
||||
"key": "p_threshold",
|
||||
"label": "Significance threshold P-value (e.g. 0.05)",
|
||||
"default": "0.05",
|
||||
"type": float,
|
||||
},
|
||||
{
|
||||
"key": "min_subjects",
|
||||
"label": "Minimum number of participants to process",
|
||||
"default": "5",
|
||||
"type": int,
|
||||
},
|
||||
{
|
||||
"key": "correction_method",
|
||||
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
|
||||
"default": "None",
|
||||
"type": str,
|
||||
},
|
||||
{
|
||||
"key": "target_chroma",
|
||||
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
|
||||
"default": "hbo",
|
||||
"type": str,
|
||||
},
|
||||
{
|
||||
"key": "roi_a",
|
||||
"label": "ROI A (e.g. contralateral region name from regions.json)",
|
||||
"default": [],
|
||||
"type": list,
|
||||
},
|
||||
{
|
||||
"key": "roi_b",
|
||||
"label": "ROI B (e.g. ipsilateral region name from regions.json)",
|
||||
"default": [],
|
||||
"type": list,
|
||||
}
|
||||
],
|
||||
2: [
|
||||
{
|
||||
"key": "p_value",
|
||||
"label": "Significance threshold P-value (e.g. 0.05)",
|
||||
"default": "0.05",
|
||||
"type": float,
|
||||
},
|
||||
{
|
||||
"key": "min_subjects",
|
||||
"label": "Minimum number of participants to process",
|
||||
"default": "5",
|
||||
"type": int,
|
||||
},
|
||||
{
|
||||
"key": "correction_method",
|
||||
"label": "Correction method to utilize. Valid values are 'fdr_bh', 'None'",
|
||||
"default": "fdr_bh",
|
||||
"type": str,
|
||||
},
|
||||
{
|
||||
"key": "target_chroma",
|
||||
"label": "Which chroma to target. Valid values are 'hbo', 'hbr'",
|
||||
"default": "hbo",
|
||||
"type": str,
|
||||
},
|
||||
{
|
||||
"key": "contrast_name",
|
||||
"label": "Name of the contrast to use",
|
||||
"default": [],
|
||||
"type": list,
|
||||
},
|
||||
{
|
||||
"key": "weighted",
|
||||
"label": "Use inverse-variance weighting to minimize noisy channels",
|
||||
"default": True,
|
||||
"type": bool,
|
||||
},
|
||||
{
|
||||
"key": "graph_bounds",
|
||||
"label": "Graph Upper/Lower Limit",
|
||||
"default": "0.0",
|
||||
"type": float,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
DESCRIPTION = """0. ROI vs. Zero (run_roi_second_level_analysis)
|
||||
\nTests whether one ROI's response during one condition reliably differs from zero across subjects - a one-sample t-test on each subject's ROI-averaged theta. A significant result means the region's signal during this condition is consistently non-zero across your sample, not just noise. It does not tell you whether the response is localized/specific to this region, or whether it reflects real neural activity versus systemic physiology (blood pressure, arousal) shared across the whole head during any active task - a single-condition "vs. zero" test can't distinguish those two explanations on its own.
|
||||
\nIf you expected significance here and didn't get it, likely causes include: the sample size is simply small relative to between-subject variability in true response magnitude or HRF shape (individual differences in timing/amplitude inflate the variance a t-test divides by); the ROI's channel composition differs slightly across subjects (missing channels get down-weighted or excluded from the inverse-variance average, diluting a real signal); FDR correction across many ROIs is suppressing a modest true effect that would clear an uncorrected threshold; or the condition itself may not reliably engage this region the way you assumed (worth checking the single-subject/individual-level results for this ROI before concluding the group effect isn't there).
|
||||
\n\n1. Paired ROI Contrast (run_roi_paired_contrast_analysis)
|
||||
\nFor one condition, subtracts each subject's ROI_A response from their ROI_B response, then tests whether that per-subject difference is reliably non-zero. A significant result is a genuine spatial contrast - the two regions respond differently from each other during this specific condition, with shared systemic noise partially cancelling in the subtraction. It says nothing about whether the condition produced meaningful activity at all (only a relative difference between two places), and its power depends entirely on ROI_A and ROI_B varying together across subjects - an assumption that isn't guaranteed.
|
||||
\nIf this test underperforms a plain ROI-vs-zero result, which can occur, the most likely explanation is that ROI_A and ROI_B's noise isn't well-correlated across your subjects. The math is variance(A - B) = variance(A) + variance(B) - 2·covariance(A,B): subtraction only helps when the shared/systemic component is large relative to independent noise in each region. If the two regions are picking up largely independent noise sources (motion artifact affecting one side more, different channel quality, etc.), subtracting adds variance rather than removing it, and can turn a detectable single-ROI effect into an underpowered paired one. Small sample size makes this worse, since the covariance itself is poorly estimated with few subjects.
|
||||
\n\n2. Joint Contrast, ROI-Aggregated (aggregate_channel_contrasts_to_roi + one-sample test)
|
||||
\nUses a contrast fit jointly within each subject's GLM (Condition A minus Condition B, estimated together), then aggregates that per-channel contrast to ROI level using inverse-variance weighting, and tests it against zero across subjects. A significant result means the two conditions produce reliably different responses at this ROI, with systemic noise largely cancelled at the model-fitting stage itself - the most statistically efficient of the three within-group methods, since the correlation between conditions is handled natively rather than inferred afterward. It does not tell you where the difference is localized on its own - for that, compare the sign/pattern across multiple ROIs: opposite signs across regions indicates a real, spatially-specific effect, while the same sign everywhere suggests diffuse/systemic noise rather than localized activity (as seen when comparing a real task-vs-task contrast against a task-vs-inert-marker contrast).
|
||||
\nIf this comes back non-significant despite expecting an effect, first check whether the two conditions are actually similar enough in their neural engagement of this ROI that a small or genuinely near-zero contrast is the correct answer - not every ROI should differentiate every pair of tasks, and a null result here can be the right result. Beyond that: FDR correction across every ROI in your regions file can suppress a real but modest contrast; the inverse-variance weighting can be destabilized if a few channels within the ROI have very noisy or near-zero t-statistics (their standard error estimate becomes huge or unstable); and - as always - small subject counts limit the achievable degrees of freedom regardless of how clean the underlying per-channel estimates are.
|
||||
\n\n
|
||||
\nWhy channels needed to be aggregated into ROIs: Testing every channel independently means paying a steep multiple-comparisons tax - with dozens of channels, FDR/Bonferroni correction demands very large effect sizes to call anything significant, and at small subject counts (n=5) essentially nothing survives even when a real, consistent effect exists. Collapsing channels into a handful of anatomically meaningful ROIs cuts the number of independent tests from a minimum of ~40 down to 2-8, which lets a genuinely present effect actually clear correction. It also matches the scientific question better: you have a hypothesis about regions (contralateral motor cortex, prefrontal cortex), not about individual source-detector pairs, so testing at the ROI level is testing the thing you actually believe in, using inverse-variance weighting so noisier channels contribute less to the region's combined estimate rather than diluting it equally.
|
||||
\nWhy some analyses needed contrasts instead of raw values: A single condition's GLM beta is only ever measured relative to the model's implicit intercept, and that intercept absorbs whatever's happening for the rest of the recording - including systemic physiology (blood pressure, arousal, general vascular reactivity) that rises during almost any active task, not just the one you care about. Testing a raw "vs. zero" value can't tell a real, localized neural response apart from that shared full-head noise. A contrast - either a within-subject spatial subtraction (ROI A minus ROI B) or a jointly-fit task contrast (Condition A minus Condition B, estimated together in one GLM) cancels out whatever's common to both halves of the subtraction, leaving something closer to the actual differential signal.
|
||||
\nWhy a minimum subject count is enforced: Every one of these tests is a t-test, and a t-test's ability to detect a real effect (its power) depends heavily on degrees of freedom - at n=5 (df=4), even a fairly large true effect can produce a middling p-value, and at n=2 (df=1) the test is barely meaningful at all regardless of the underlying data. The min_subjects floor exists to stop a channel or ROI from being silently tested (and potentially reported as significant or non-significant) on a sample too small for the resulting p-value to mean anything reliable - it's better to explicitly skip and flag an underpowered channel than to quietly produce a number that looks statistically legitimate but isn't backed by enough independent observations to trust."""
|
||||
|
||||
|
||||
class IntraGroupStatsWidget(IntraGroupUIMixin, FlaresBaseWidget):
|
||||
def __init__(
|
||||
self,
|
||||
haemo_dict: dict[str | Path, BaseRaw],
|
||||
cha_dict: dict[str, DataFrame],
|
||||
df_ind_dict: dict[str, DataFrame],
|
||||
design_matrix_dict: dict[str, DataFrame],
|
||||
contrast_results_dict: dict[str, dict[str, Any]],
|
||||
roi_channel_map_dict: dict[str, dict[str, str]],
|
||||
group_dict: dict[str, str],
|
||||
) -> None:
|
||||
|
||||
super().__init__("IntraGroupStats")
|
||||
self.setWindowTitle(f"Intra-Group Stats Viewer - {APP_NAME.upper()}")
|
||||
self.haemo_dict = haemo_dict
|
||||
self.cha_dict = cha_dict
|
||||
self.df_ind_dict = df_ind_dict
|
||||
self.design_matrix_dict = design_matrix_dict
|
||||
self.contrast_results_dict = contrast_results_dict
|
||||
self.roi_channel_map_dict = roi_channel_map_dict
|
||||
self.group_dict = group_dict
|
||||
|
||||
self.setup_intra_group_ui(["0 (ROI vs. Zero)", "1 (Paired ROI Contrast)", "2 (Joint Contrast, ROI-Aggregated)"], placeholder_text=DESCRIPTION)
|
||||
|
||||
|
||||
def process_request(self):
|
||||
request = self.get_common_request_data(PARAMETERIZED_INDEXES, self.df_ind_dict, self.contrast_results_dict)
|
||||
if request is None:
|
||||
return
|
||||
|
||||
(selected_event, selected_file_paths, selected_indexes, raw_params) = request
|
||||
|
||||
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
|
||||
|
||||
all_cha = DataFrame()
|
||||
for file_path in selected_file_paths:
|
||||
haemo_obj = self.haemo_dict.get(file_path)
|
||||
|
||||
if haemo_obj is None:
|
||||
continue
|
||||
|
||||
if selected_event:
|
||||
raw_annotations = getattr(haemo_obj, "annotations", None)
|
||||
|
||||
if raw_annotations is not None:
|
||||
annotations = cast(Annotations, raw_annotations)
|
||||
descriptions = cast(list[str], list(annotations.description))
|
||||
participant_events: set[str] = set(descriptions)
|
||||
else:
|
||||
participant_events: set[str] = set()
|
||||
|
||||
if selected_event not in participant_events:
|
||||
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
||||
continue
|
||||
|
||||
|
||||
|
||||
cha_df = self.cha_dict.get(file_path)
|
||||
if cha_df is not None:
|
||||
all_cha = pd.concat([all_cha, cha_df], ignore_index=True)
|
||||
|
||||
file_path = selected_file_paths[0]
|
||||
p_haemo = self.haemo_dict.get(file_path)
|
||||
|
||||
# Concatenate individual ROI stats (df_ind) for all chosen subjects
|
||||
df_group = DataFrame()
|
||||
if selected_file_paths:
|
||||
for file_path in selected_file_paths:
|
||||
df = self.df_ind_dict.get(file_path)
|
||||
if df is not None:
|
||||
df_group = pd.concat([df_group, df], ignore_index=True)
|
||||
|
||||
for idx in selected_indexes:
|
||||
if idx == 0:
|
||||
params = param_values.get(idx, {})
|
||||
p_threshold = params.get("p_threshold", 0.05)
|
||||
min_subjects = params.get("min_subjects", 5)
|
||||
correction_method = params.get("correction_method", "fdr_bh")
|
||||
target_chroma = params.get("target_chroma", "hbo")
|
||||
graph_bounds = params.get("graph_bounds", 0.0)
|
||||
|
||||
if correction_method == "None":
|
||||
correction_method = None
|
||||
|
||||
if df_group.empty:
|
||||
print("No ROI data (df_ind) found for selected participants.")
|
||||
continue
|
||||
|
||||
# Filter down to the selected experimental event/condition
|
||||
if selected_event:
|
||||
if 'Condition' in df_group.columns:
|
||||
df_filtered = df_group[df_group['Condition'] == selected_event]
|
||||
else:
|
||||
print("Warning: 'Condition' column not found in ROI data.")
|
||||
df_filtered = df_group
|
||||
else:
|
||||
df_filtered = df_group
|
||||
|
||||
if df_filtered.empty:
|
||||
print(f"No ROI data matches the condition '{selected_event}'.")
|
||||
continue
|
||||
|
||||
all_cha_filtered = DataFrame()
|
||||
if not all_cha.empty:
|
||||
if selected_event and 'Condition' in all_cha.columns:
|
||||
all_cha_filtered = all_cha[all_cha['Condition'] == selected_event]
|
||||
else:
|
||||
all_cha_filtered = all_cha
|
||||
|
||||
|
||||
run_roi_second_level_analysis(
|
||||
df_roi_all=df_filtered,
|
||||
df_cha_all=all_cha_filtered,
|
||||
raw_haemo=p_haemo,
|
||||
p_threshold=p_threshold,
|
||||
min_subjects=min_subjects,
|
||||
correction_method=correction_method,
|
||||
target_chroma=target_chroma,
|
||||
graph_bounds=graph_bounds if graph_bounds > 0.0 else None,
|
||||
)
|
||||
|
||||
elif idx == 1:
|
||||
params = param_values.get(idx, {})
|
||||
p_threshold = params.get("p_threshold", 0.05)
|
||||
min_subjects = params.get("min_subjects", 5)
|
||||
correction_method = params.get("correction_method", "None")
|
||||
target_chroma = params.get("target_chroma", "hbo")
|
||||
roi_a = params.get("roi_a", "").strip()
|
||||
roi_b = params.get("roi_b", "").strip()
|
||||
|
||||
if not selected_event:
|
||||
print("Paired ROI contrast requires a specific event/condition "
|
||||
"to be selected - pick one from the Event dropdown first.")
|
||||
continue
|
||||
|
||||
if df_group.empty:
|
||||
print("No ROI data (df_ind) found for selected participants.")
|
||||
continue
|
||||
|
||||
if correction_method == "None":
|
||||
correction_method = None
|
||||
|
||||
if not roi_a or not roi_b:
|
||||
print("Both ROI A and ROI B must be specified.")
|
||||
continue
|
||||
|
||||
print(min_subjects)
|
||||
run_roi_paired_contrast_analysis(
|
||||
df_roi_all=df_group,
|
||||
roi_pairs=(roi_a, roi_b),
|
||||
condition=selected_event,
|
||||
target_chroma=target_chroma,
|
||||
min_subjects=min_subjects,
|
||||
p_threshold=p_threshold,
|
||||
correction_method=correction_method,
|
||||
roi_a_label=roi_a,
|
||||
roi_b_label=roi_b,
|
||||
)
|
||||
|
||||
elif idx == 2:
|
||||
params = param_values.get(idx, {})
|
||||
p_threshold = params.get("p_threshold", 0.05)
|
||||
min_subjects = params.get("min_subjects", 5)
|
||||
correction_method = params.get("correction_method", "fdr_bh")
|
||||
target_chroma = params.get("target_chroma", "hbo")
|
||||
contrast_name = params.get("contrast_name", "")
|
||||
weighted = params.get("weighted", True)
|
||||
graph_bounds = params.get("graph_bounds", 0.0)
|
||||
|
||||
if not selected_event:
|
||||
print("Joint contrast ROI analysis requires a specific contrast "
|
||||
"to be selected from the Event dropdown first.")
|
||||
continue
|
||||
|
||||
if not contrast_name:
|
||||
print("Contrast name must be specified.")
|
||||
continue
|
||||
|
||||
|
||||
all_contrasts: list[DataFrame] = []
|
||||
for fp in selected_file_paths:
|
||||
condition_dfs = self.contrast_results_dict.get(fp)
|
||||
if condition_dfs is None:
|
||||
print(f" [MISSING] '{fp}' not found in contrast_results.")
|
||||
continue
|
||||
if contrast_name in condition_dfs:
|
||||
df = condition_dfs[contrast_name].copy()
|
||||
df["ID"] = fp
|
||||
df["contrast_name"] = contrast_name
|
||||
all_contrasts.append(df)
|
||||
else:
|
||||
print(f" [MISSING CONTRAST] '{contrast_name}' not "
|
||||
f"available for {self.participant_map.get(fp, fp)}.")
|
||||
|
||||
if not all_contrasts:
|
||||
print(f"No contrast data found for '{contrast_name}' "
|
||||
f"across selected participants.")
|
||||
continue
|
||||
|
||||
df_contrasts = pd.concat(all_contrasts, ignore_index=True)
|
||||
|
||||
selected_roi_maps = {
|
||||
fp: self.roi_channel_map_dict[fp]
|
||||
for fp in selected_file_paths
|
||||
if fp in self.roi_channel_map_dict
|
||||
}
|
||||
if not selected_roi_maps:
|
||||
print("No channel-to-ROI mapping available for selected participants.")
|
||||
continue
|
||||
|
||||
try:
|
||||
roi_theta = aggregate_channel_contrasts_to_roi(
|
||||
df_contrasts,
|
||||
roi_channel_maps=selected_roi_maps,
|
||||
weighted=weighted,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to aggregate contrasts to ROI: {e}")
|
||||
continue
|
||||
|
||||
if roi_theta.empty:
|
||||
print("No ROI-level contrast values could be computed "
|
||||
"(check regions.json channel names against this montage).")
|
||||
continue
|
||||
|
||||
# TODO: Come back to this
|
||||
# df_cha_all intentionally omitted (None): the topography
|
||||
# section of run_roi_second_level_analysis expects
|
||||
# single-condition Condition values in df_cha_all, which
|
||||
# doesn't semantically match a contrast name - skip it here
|
||||
# rather than pass mismatched data.
|
||||
run_roi_second_level_analysis(
|
||||
df_roi_all=roi_theta,
|
||||
df_cha_all=None,
|
||||
raw_haemo=p_haemo,
|
||||
p_threshold=p_threshold,
|
||||
min_subjects=min_subjects,
|
||||
correction_method=correction_method,
|
||||
target_chroma=target_chroma,
|
||||
graph_bounds=graph_bounds if graph_bounds > 0.0 else None,
|
||||
)
|
||||
|
||||
else:
|
||||
print(f"No method defined for index {idx}")
|
||||
Reference in New Issue
Block a user