355 lines
20 KiB
Python
355 lines
20 KiB
Python
"""
|
|
Filename: intergroupstats.py
|
|
Description: Logic for the Inter-Group Stats analysis window
|
|
Note: Compliant with pylance strict type checking
|
|
|
|
Author: Tyler de Zeeuw
|
|
License: GPL-3.0
|
|
"""
|
|
|
|
# Built-in imports
|
|
from typing import Any, cast
|
|
|
|
# External library imports
|
|
import pandas as pd
|
|
from pandas import DataFrame
|
|
|
|
from mne.io.base import BaseRaw
|
|
|
|
from flares import run_inter_group_contrast_analysis, run_inter_group_laterality_analysis, run_inter_group_second_level_analysis
|
|
from src.shared.flaresbasewidget import InterGroupUIMixin, 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": "3",
|
|
"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": "threshold_topo",
|
|
"label": "threshold_topo: TBD",
|
|
"default": False,
|
|
"type": bool,
|
|
}
|
|
],
|
|
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": "3",
|
|
"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": "3",
|
|
"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,
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
DESCRIPTION = """0. Raw ROI Comparison (run_inter_group_second_level_analysis)
|
|
\nCompares one ROI's raw response magnitude between two independent groups (e.g. control vs. target) for a given condition, using Welch's t-test. A significant result means the two populations differ in this ROI's response magnitude for this condition. It does not tell you whether that difference is a real, localized, task-specific effect or a generic between-population difference - different overall vascular reactivity, arousal, or skull/scalp optical properties can produce the exact same statistical signature, and two independently recruited groups (especially patients vs. healthy controls) are considerably more likely to differ this way than two subsets of one study population.
|
|
\nIf you expected a group difference and didn't find one, the most common cause is within-group heterogeneity swallowing a real between-group difference - a "target" population (e.g. a clinical group) is often more variable than a tightly-screened control group, and that added within-group variance directly weakens a between-group t-test even if the group means truly differ. Small per-group sample sizes compound this. It's also possible the true difference between your groups isn't in raw magnitude at all, but in spatial specificity or task-differentiation - which is exactly why the laterality and contrast-comparison methods exist alongside this one; a null result here doesn't rule those out.
|
|
\n\n1. Laterality Comparison (run_inter_group_laterality_analysis)
|
|
\nComputes each subject's own contralateral-minus-ipsilateral laterality index first, then compares those indices between the two groups with Welch's t-test. A significant result means the degree of spatial specificity/lateralization differs between the two populations - a claim about lateralization itself, harder to explain away as a generic population confound since person-level differences in overall reactivity largely cancel before the group comparison happens. It says nothing about overall response magnitude between groups (a group could have identical laterality but very different raw amplitude), and it only uses subjects who have both the contra and ipsi ROI valid, so it can lose subjects the raw-ROI comparison would have kept.
|
|
\nNon-significance here has two likely sources, and it's worth distinguishing them. First, the same covariance issue from the within-group paired test applies across a whole group: if contra/ipsi responses aren't well-correlated within subjects, the laterality index itself is noisier than either ROI alone, and that added noise now has to clear a between-group test on top of it - a double power cost at small N. Second, and more informative if true: the groups may genuinely have similar lateralization but differ in overall magnitude instead, in which case this test correctly returns null while method 4 (raw comparison) should be the one to look at.
|
|
\n\n2. Contrast Comparison (run_inter_group_contrast_analysis)
|
|
\nCompares a jointly-fit task contrast (e.g. Task A minus Task B, estimated together within each subject's GLM), aggregated to ROI level, between two independent groups. A significant result means one group differentiates between the two tasks more or less than the other does, at this specific ROI - with systemic noise cancelled at the model-fitting stage, the same benefit that makes the within-group version of this method the strongest of that trio. As with the within-group version, it does not by itself say where a difference is localized unless you compare sign/pattern across multiple ROIs - opposite-signed group differences across regions point to something spatially specific, same-signed differences everywhere point to a diffuse/non-specific group difference (e.g. one group simply has stronger contrast responses across the whole head).
|
|
\nIf this comes back non-significant despite an expected group difference, check first whether the underlying single-subject contrast estimates are noisy for either group - small per-group N means the joint contrast's precision depends on the same limited subject count as everything else, and a noisy input propagates all the way through the ROI aggregation. It's also possible for a real, localized sub-regional effect to get washed out by ROI averaging itself: if only part of an ROI's channels actually show the group difference while others don't, the inverse-variance-weighted average can dilute it toward null - in that case, a finer-grained ROI definition (splitting the region further) may recover the effect that a coarser ROI averaged away. Finally, FDR correction across every ROI tested reduces power exactly as it does everywhere else in this framework - a real but modest effect can fail to survive correction even when the raw p-value would have looked convincing on its own.
|
|
\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 InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget):
|
|
|
|
def __init__(
|
|
self,
|
|
haemo_dict: dict[str, 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__("InterGroupStats")
|
|
self.setWindowTitle(f"Inter-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_inter_group_ui(["0 (Raw ROI Comparison)", "1 (Laterality Comparison)", "2 (Contrast Comparison)",], 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, file_paths_a, file_paths_b, _, selected_indexes, raw_params) = request
|
|
|
|
param_values = cast(dict[int | str, dict[str, Any]], raw_params)
|
|
|
|
valid_dfs = [df for df in self.df_ind_dict.values() if not df.empty]
|
|
if valid_dfs:
|
|
df_ind_combined = pd.concat(valid_dfs, ignore_index=True)
|
|
else:
|
|
df_ind_combined = pd.DataFrame()
|
|
|
|
valid_chas = [df for df in self.cha_dict.values() if not df.empty]
|
|
cha_combined = pd.concat(valid_chas, ignore_index=True) if valid_chas else pd.DataFrame()
|
|
|
|
sample_path = file_paths_a[0]
|
|
p_haemo = self.haemo_dict.get(sample_path)
|
|
|
|
# Visualizations
|
|
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", 3)
|
|
correction_method = params.get("correction_method", "fdr_bh")
|
|
target_chroma = params.get("target_chroma", "hbo")
|
|
threshold_topo = params.get("threshold_topo", False)
|
|
|
|
selected_roi_maps = {
|
|
fp: self.roi_channel_map_dict[fp]
|
|
for fp in (file_paths_a + file_paths_b)
|
|
if fp in self.roi_channel_map_dict
|
|
}
|
|
|
|
run_inter_group_second_level_analysis(
|
|
df_roi_all=df_ind_combined, # Individual stats dataframe
|
|
file_paths_a=file_paths_a,
|
|
file_paths_b=file_paths_b,
|
|
group_a_name=self.group_a_dropdown.currentText(),
|
|
group_b_name=self.group_b_dropdown.currentText(),
|
|
df_cha_all=cha_combined,
|
|
raw_haemo=p_haemo,
|
|
p_threshold=p_threshold,
|
|
min_subjects=min_subjects,
|
|
correction_method=correction_method,
|
|
target_chroma=target_chroma,
|
|
selected_event=selected_event,
|
|
roi_channel_maps=selected_roi_maps,
|
|
threshold_topo=threshold_topo # Shows the raw difference map (Unthresholded)
|
|
)
|
|
elif idx == 1:
|
|
if not selected_event:
|
|
print("Laterality comparison requires a specific event/condition "
|
|
"to be selected first.")
|
|
continue
|
|
|
|
params = param_values.get(idx, {})
|
|
p_threshold = params.get("p_threshold", 0.05)
|
|
min_subjects = params.get("min_subjects", 3)
|
|
correction_method = params.get("correction_method", "None")
|
|
target_chroma = params.get("target_chroma", "hbo")
|
|
roi_a: str = params.get("roi_a", "").strip()
|
|
roi_b: str = params.get("roi_b", "").strip()
|
|
|
|
if not roi_a or not roi_b:
|
|
print("Both a contralateral and ipsilateral ROI name must be specified.")
|
|
continue
|
|
|
|
if correction_method == "None":
|
|
correction_method = None
|
|
|
|
# Build each group's dataframe directly from the dict using
|
|
# the file-path lists as keys - no ID cleaning/matching needed.
|
|
def _build_group_df(
|
|
file_paths: list[str],
|
|
dict_source: dict[str, DataFrame]
|
|
) -> DataFrame:
|
|
|
|
valid_dfs = [
|
|
dict_source[fp] for fp in file_paths
|
|
if fp in dict_source and not dict_source[fp].empty
|
|
]
|
|
|
|
return pd.concat(valid_dfs, ignore_index=True) if valid_dfs else pd.DataFrame()
|
|
|
|
|
|
df_roi_a = _build_group_df(file_paths_a, self.df_ind_dict)
|
|
df_roi_b = _build_group_df(file_paths_b, self.df_ind_dict)
|
|
|
|
if df_roi_a.empty or df_roi_b.empty:
|
|
print("No ROI data (df_ind) found for one or both groups.")
|
|
continue
|
|
|
|
run_inter_group_laterality_analysis(
|
|
df_roi_all_a=df_roi_a,
|
|
df_roi_all_b=df_roi_b,
|
|
roi_pairs=(roi_a, roi_b),
|
|
condition=selected_event,
|
|
group_a_name=self.group_a_dropdown.currentText(),
|
|
group_b_name=self.group_b_dropdown.currentText(),
|
|
target_chroma=target_chroma,
|
|
min_subjects=min_subjects,
|
|
p_threshold=p_threshold,
|
|
correction_method=correction_method,
|
|
roi_contra_label=roi_a,
|
|
roi_ipsi_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", 3)
|
|
correction_method = params.get("correction_method", "fdr_bh")
|
|
target_chroma = params.get("target_chroma", "hbo")
|
|
contrast_name = params.get("contrast_name", "")
|
|
|
|
if not contrast_name:
|
|
print("A contrast name must be specified.")
|
|
continue
|
|
|
|
# Build each group's channel-level contrast dataframe
|
|
# directly from contrast_results_dict, keyed by file path -
|
|
# same dict-key approach as the laterality patch, avoids
|
|
# any ID-string matching.
|
|
def _build_group_contrast_df(
|
|
file_paths: list[str],
|
|
contrast_dict: dict[str, dict[str, pd.DataFrame]],
|
|
name: str,
|
|
) -> pd.DataFrame:
|
|
|
|
all_rows: list[DataFrame] = []
|
|
for fp in file_paths:
|
|
condition_dfs = contrast_dict.get(fp)
|
|
if condition_dfs is None:
|
|
print(f" [MISSING] '{fp}' not found in contrast_results.")
|
|
continue
|
|
if name in condition_dfs:
|
|
df = condition_dfs[name].copy()
|
|
df["ID"] = fp
|
|
df["contrast_name"] = name
|
|
all_rows.append(df)
|
|
else:
|
|
print(f" [MISSING CONTRAST] '{name}' not available for '{fp}'.")
|
|
return pd.concat(all_rows, ignore_index=True) if all_rows else pd.DataFrame()
|
|
|
|
df_contrasts_a = _build_group_contrast_df(file_paths_a, self.contrast_results_dict, contrast_name)
|
|
df_contrasts_b = _build_group_contrast_df(file_paths_b, self.contrast_results_dict, contrast_name)
|
|
|
|
if df_contrasts_a.empty or df_contrasts_b.empty:
|
|
print("No contrast data found for one or both groups.")
|
|
continue
|
|
|
|
roi_maps_a = {
|
|
fp: self.roi_channel_map_dict[fp]
|
|
for fp in file_paths_a
|
|
if fp in self.roi_channel_map_dict
|
|
}
|
|
roi_maps_b = {
|
|
fp: self.roi_channel_map_dict[fp]
|
|
for fp in file_paths_b
|
|
if fp in self.roi_channel_map_dict
|
|
}
|
|
if not roi_maps_a or not roi_maps_b:
|
|
print("No channel-to-ROI mapping available for one or both groups.")
|
|
continue
|
|
|
|
run_inter_group_contrast_analysis(
|
|
df_contrasts_a=df_contrasts_a,
|
|
df_contrasts_b=df_contrasts_b,
|
|
contrast_name=contrast_name,
|
|
roi_channel_maps_a=roi_maps_a,
|
|
roi_channel_maps_b=roi_maps_b,
|
|
group_a_name=self.group_a_dropdown.currentText(),
|
|
group_b_name=self.group_b_dropdown.currentText(),
|
|
target_chroma=target_chroma,
|
|
min_subjects=min_subjects,
|
|
p_threshold=p_threshold,
|
|
correction_method=correction_method,
|
|
)
|
|
|
|
else:
|
|
print("no") |