2812 lines
121 KiB
Python
2812 lines
121 KiB
Python
"""
|
|
Filename: __init__.py
|
|
Description: machine-learning-pack plugin
|
|
|
|
Author: Tyler de Zeeuw
|
|
License: GPL-3.0
|
|
"""
|
|
|
|
import base64
|
|
import io
|
|
import json
|
|
import os
|
|
import time
|
|
from itertools import combinations
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
from sklearn.metrics import accuracy_score, balanced_accuracy_score
|
|
|
|
import matplotlib
|
|
matplotlib.use("Agg")
|
|
import matplotlib.pyplot as plt
|
|
|
|
from PySide6.QtGui import QAction
|
|
|
|
from PySide6.QtCore import Qt, QThread, Signal
|
|
from PySide6.QtWidgets import ( QApplication, QCheckBox, QComboBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit,
|
|
QMainWindow, QMenu, QMessageBox, QPushButton, QScrollArea, QTabWidget, QTableWidget, QTableWidgetItem, QTextEdit, QVBoxLayout, QWidget,)
|
|
from scipy import stats
|
|
|
|
from sklearn.pipeline import Pipeline
|
|
from sklearn.preprocessing import StandardScaler
|
|
from sklearn.feature_selection import SelectKBest, f_classif
|
|
from sklearn.linear_model import LogisticRegression
|
|
from sklearn.svm import SVC
|
|
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
|
|
from sklearn.model_selection import LeaveOneGroupOut, GridSearchCV, StratifiedKFold
|
|
from sklearn.metrics import accuracy_score, balanced_accuracy_score
|
|
|
|
|
|
|
|
# Metric Registry mapping short keys to CSV column suffixes
|
|
METRIC_REGISTRY = {
|
|
'Peak_Amp': 'Peak_Amp',
|
|
'TTP': 'Time_to_Peak',
|
|
'AUC': 'AUC',
|
|
'Rising_Slope': 'Rising_Slope',
|
|
'Recovery_Slope': 'Recovery_Slope',
|
|
'FWHM': 'FWHM',
|
|
'Onset_Latency': 'Onset_Latency',
|
|
'P2P_Amp': 'Peak_to_Peak_Amp',
|
|
'Signal_Std': 'Signal_Std',
|
|
'RMS': 'RMS'
|
|
}
|
|
|
|
# Neutral, factual definitions of each metric (used for user-facing explanations)
|
|
METRIC_DESCRIPTIONS = {
|
|
'Peak_Amp': "the maximum amplitude reached during the hemodynamic response",
|
|
'TTP': "how long it took the signal to reach its peak amplitude",
|
|
'AUC': "the total area under the response curve (overall response magnitude over time)",
|
|
'Rising_Slope': "how steeply the signal rises on its way up to the peak",
|
|
'Recovery_Slope': "how steeply the signal declines from its peak back toward baseline",
|
|
'FWHM': "the width of the response at half its maximum height (how long the response stays elevated)",
|
|
'Onset_Latency': "how long after stimulus onset the response begins to rise",
|
|
'P2P_Amp': "the difference between the highest and lowest points of the response",
|
|
'Signal_Std': "the overall variability (standard deviation) of the signal",
|
|
'RMS': "the root-mean-square magnitude of the signal, a measure of overall signal energy",
|
|
}
|
|
|
|
# --- Modeling Settings ---
|
|
K_FEATURES = 5
|
|
MODEL_RANDOM_STATE = 42
|
|
MODEL_C = 0.5
|
|
TUNE_HYPERPARAMETERS = True
|
|
CANDIDATE_C_VALUES = [5.0, 10.0, 50.0]
|
|
INNER_CV_FOLDS = 3
|
|
|
|
CLASSIFIER_FACTORIES = {
|
|
'L1_Logistic': lambda: LogisticRegression(
|
|
l1_ratio=1.0, solver='liblinear', max_iter=2000, C=MODEL_C, random_state=MODEL_RANDOM_STATE,
|
|
class_weight='balanced',
|
|
),
|
|
'Linear_SVM': lambda: SVC(
|
|
kernel='linear', C=MODEL_C, random_state=MODEL_RANDOM_STATE,
|
|
class_weight='balanced',
|
|
),
|
|
'LDA': lambda: LinearDiscriminantAnalysis(solver='lsqr', shrinkage='auto'),
|
|
}
|
|
|
|
|
|
CONFIG_FILE = "splitter_config.json"
|
|
DEFAULT_CSV_PATH = ""
|
|
SWEEP_RESULTS_CSV = "metric_sweep_leaderboard.csv"
|
|
ALL_METRIC_KEYS = tuple(METRIC_REGISTRY.keys())
|
|
|
|
|
|
def sigmoid(x):
|
|
return 1.0 / (1.0 + np.exp(-np.clip(x, -50, 50)))
|
|
|
|
|
|
def _fmt_confidence(conf):
|
|
"""
|
|
Standard 1-decimal formatting rounds anything above 99.95% to a flat '100.0%', which
|
|
hides the actual precision the sigmoid produced. Switch to higher precision near the
|
|
extremes so 'why is this 100%' has a real number to look at instead of a rounded one.
|
|
"""
|
|
if conf >= 99.95 or conf <= 0.05:
|
|
return f"{conf:.4f}%"
|
|
return f"{conf:.1f}%"
|
|
|
|
|
|
def _fmt_val(v):
|
|
"""Format small/large feature values readably; tiny magnitudes fall back to scientific notation."""
|
|
if v == 0:
|
|
return "0"
|
|
if abs(v) < 1e-3:
|
|
return f"{v:.3e}"
|
|
return f"{v:.4f}"
|
|
|
|
|
|
def _split_feature_name(feat_name):
|
|
"""
|
|
Splits a raw column name like 'FIR_Reach_ROI_Somatosensory_S1_BA40_Part_2_Recovery_Slope'
|
|
into (readable_roi_label, metric_key). Falls back gracefully if no metric suffix matches.
|
|
"""
|
|
metric_map = {}
|
|
for k, v in METRIC_REGISTRY.items():
|
|
metric_map[k] = k
|
|
metric_map[v] = k
|
|
sorted_patterns = sorted(metric_map.keys(), key=len, reverse=True)
|
|
|
|
matched_key = None
|
|
matched_pattern = None
|
|
for pattern in sorted_patterns:
|
|
if feat_name.endswith(f"_{pattern}"):
|
|
matched_key = metric_map[pattern]
|
|
matched_pattern = pattern
|
|
break
|
|
|
|
if matched_pattern:
|
|
stem = feat_name[: -(len(matched_pattern) + 1)]
|
|
else:
|
|
stem = feat_name
|
|
|
|
parts = stem.split('_')
|
|
# Drop leading FIR_<condition>_ prefix if present
|
|
if len(parts) >= 2 and parts[0] == 'FIR':
|
|
parts = parts[2:]
|
|
|
|
roi_label = " ".join(parts) if parts else stem
|
|
return roi_label, matched_key
|
|
|
|
|
|
def _render_feature_distribution_png(class_a_values, class_b_values, subject_val,
|
|
class_a_mean, class_b_mean, actual_label,
|
|
metric_label, roi_label, z_score):
|
|
"""
|
|
Renders a single feature's training-set distribution for both classes, with this test
|
|
subject's value overlaid on top, as a base64-encoded PNG. This is deliberately not a bar
|
|
chart: individual training subjects are shown as points so you can see spread and overlap,
|
|
not just a single average, and the subject's true-class row is tinted green so it's obvious
|
|
at a glance how far off they were from where they were "supposed" to land.
|
|
"""
|
|
rng = np.random.default_rng(0)
|
|
fig, ax = plt.subplots(figsize=(7.0, 2.5), dpi=130)
|
|
|
|
row_a_y, row_b_y = 1.0, 0.0
|
|
band_h = 0.42
|
|
|
|
correct_y = row_a_y if actual_label == "Class_A" else row_b_y
|
|
incorrect_y = row_b_y if actual_label == "Class_A" else row_a_y
|
|
ax.axhspan(correct_y - band_h, correct_y + band_h, color="#2e7d32", alpha=0.08, zorder=0)
|
|
ax.axhspan(incorrect_y - band_h, incorrect_y + band_h, color="#c62828", alpha=0.05, zorder=0)
|
|
|
|
if len(class_a_values) > 0:
|
|
ja = rng.uniform(-0.18, 0.18, size=len(class_a_values))
|
|
ax.scatter(class_a_values, row_a_y + ja, s=26, color="#1f77b4", alpha=0.6, zorder=3)
|
|
if len(class_b_values) > 0:
|
|
jb = rng.uniform(-0.18, 0.18, size=len(class_b_values))
|
|
ax.scatter(class_b_values, row_b_y + jb, s=26, color="#ff7f0e", alpha=0.6, zorder=3)
|
|
|
|
ax.axvline(class_a_mean, color="#1f77b4", linestyle="--", linewidth=1.3, zorder=2)
|
|
ax.axvline(class_b_mean, color="#ff7f0e", linestyle="--", linewidth=1.3, zorder=2)
|
|
|
|
ax.axvline(subject_val, color="black", linewidth=1.6, zorder=4)
|
|
ax.scatter([subject_val], [row_a_y], marker="*", s=260, color="gold", edgecolor="black", linewidth=0.8, zorder=5)
|
|
ax.scatter([subject_val], [row_b_y], marker="*", s=260, color="gold", edgecolor="black", linewidth=0.8, zorder=5)
|
|
|
|
ymin, ymax = row_b_y - band_h - 0.18, row_a_y + band_h + 0.4
|
|
ax.set_ylim(ymin, ymax)
|
|
|
|
a_true = actual_label == "Class_A"
|
|
b_true = actual_label == "Class_B"
|
|
a_label = "Class_A" + (" \u2190 true class" if a_true else "")
|
|
b_label = "Class_B" + (" \u2190 true class" if b_true else "")
|
|
ax.set_yticks([row_a_y, row_b_y])
|
|
ax.set_yticklabels([a_label, b_label], fontsize=9)
|
|
ax.get_yticklabels()[0].set_color("#2e7d32" if a_true else "#333333")
|
|
ax.get_yticklabels()[1].set_color("#2e7d32" if b_true else "#333333")
|
|
|
|
ax.text(class_a_mean, ymax - 0.06, f"A mean\n{_fmt_val(class_a_mean)}", color="#1f77b4",
|
|
fontsize=7.5, ha="center", va="top")
|
|
ax.text(class_b_mean, ymax - 0.06, f"B mean\n{_fmt_val(class_b_mean)}", color="#ff7f0e",
|
|
fontsize=7.5, ha="center", va="top")
|
|
ax.text(subject_val, ymin + 0.03, f"this subject: {_fmt_val(subject_val)} (z={z_score:+.2f} SD)",
|
|
color="black", fontsize=7.5, ha="center", va="bottom", fontweight="bold")
|
|
|
|
ax.set_title(f"{metric_label} \u2014 {roi_label}", fontsize=9.5, loc="left")
|
|
ax.set_xlabel("Raw feature value \u2014 each dot is one training subject", fontsize=8)
|
|
for spine in ("top", "right", "left"):
|
|
ax.spines[spine].set_visible(False)
|
|
ax.tick_params(axis="x", labelsize=8)
|
|
fig.tight_layout()
|
|
|
|
buf = io.BytesIO()
|
|
fig.savefig(buf, format="png")
|
|
plt.close(fig)
|
|
return base64.b64encode(buf.getvalue()).decode("ascii")
|
|
|
|
|
|
class PermutationTestWorker(QThread):
|
|
log_signal = Signal(str)
|
|
finished_signal = Signal(dict)
|
|
error_signal = Signal(str)
|
|
|
|
def __init__(self, X_raw, y, groups, feature_names, feature_channels, quality_list,
|
|
model_name, k_features, scoring='accuracy', n_permutations=200):
|
|
super().__init__()
|
|
self.X_raw = X_raw
|
|
self.y = y
|
|
self.groups = groups
|
|
self.feature_names = feature_names
|
|
self.feature_channels = feature_channels
|
|
self.quality_list = quality_list
|
|
self.model_name = model_name
|
|
self.k_features = k_features
|
|
self.scoring = scoring
|
|
self.n_permutations = n_permutations
|
|
|
|
def run(self):
|
|
try:
|
|
self.log_signal.emit(
|
|
f"Running permutation test for {self.model_name} "
|
|
f"({self.n_permutations} label shuffles, scoring={self.scoring})..."
|
|
)
|
|
|
|
def progress_cb(done, total):
|
|
if done % 20 == 0 or done == total:
|
|
self.log_signal.emit(f" Permutation {done}/{total}")
|
|
|
|
result = run_permutation_test(
|
|
X_raw=self.X_raw, y=self.y, groups=self.groups,
|
|
feature_names=self.feature_names, feature_channels=self.feature_channels,
|
|
quality_list=self.quality_list,
|
|
classifier_factories={self.model_name: CLASSIFIER_FACTORIES[self.model_name]},
|
|
k_features=self.k_features, tune_hyperparameters=False, scoring=self.scoring,
|
|
n_permutations=self.n_permutations, progress_callback=progress_cb,
|
|
)
|
|
self.log_signal.emit("Permutation test complete.")
|
|
self.finished_signal.emit(result[self.model_name])
|
|
except Exception as e:
|
|
self.error_signal.emit(str(e))
|
|
|
|
|
|
class PairedPermutationWorker(QThread):
|
|
log_signal = Signal(str)
|
|
finished_signal = Signal(dict)
|
|
error_signal = Signal(str)
|
|
|
|
def __init__(self, X_raw, y, pair_ids, feature_names, feature_channels, quality_list,
|
|
model_name, k_features, scoring='accuracy', n_permutations=200):
|
|
super().__init__()
|
|
self.X_raw = X_raw
|
|
self.y = y
|
|
self.pair_ids = pair_ids
|
|
self.feature_names = feature_names
|
|
self.feature_channels = feature_channels
|
|
self.quality_list = quality_list
|
|
self.model_name = model_name
|
|
self.k_features = k_features
|
|
self.scoring = scoring
|
|
self.n_permutations = n_permutations
|
|
|
|
def run(self):
|
|
try:
|
|
n_pairs = len(np.unique(self.pair_ids))
|
|
self.log_signal.emit(
|
|
f"Running paired permutation test for {self.model_name} "
|
|
f"({n_pairs} subject pairs, {self.n_permutations} sign-flip shuffles, "
|
|
f"scoring={self.scoring})..."
|
|
)
|
|
|
|
def progress_cb(done, total):
|
|
if done % 20 == 0 or done == total:
|
|
self.log_signal.emit(f" Permutation {done}/{total}")
|
|
|
|
result = run_paired_permutation_test(
|
|
X_raw=self.X_raw, y=self.y, pair_ids=self.pair_ids,
|
|
feature_names=self.feature_names, feature_channels=self.feature_channels,
|
|
quality_list=self.quality_list,
|
|
classifier_factories={self.model_name: CLASSIFIER_FACTORIES[self.model_name]},
|
|
k_features=self.k_features, tune_hyperparameters=False, scoring=self.scoring,
|
|
n_permutations=self.n_permutations, progress_callback=progress_cb,
|
|
)
|
|
self.log_signal.emit("Paired permutation test complete.")
|
|
self.finished_signal.emit(result[self.model_name])
|
|
except Exception as e:
|
|
self.error_signal.emit(str(e))
|
|
|
|
|
|
class PairedDiagnosticsWorker(QThread):
|
|
log_signal = Signal(str)
|
|
finished_signal = Signal(dict)
|
|
error_signal = Signal(str)
|
|
|
|
def __init__(self, X_raw, y, pair_ids, feature_names, feature_channels, quality_list,
|
|
model_name, k_features, scoring='accuracy', n_bootstrap=500):
|
|
super().__init__()
|
|
self.X_raw = X_raw
|
|
self.y = y
|
|
self.pair_ids = pair_ids
|
|
self.feature_names = feature_names
|
|
self.feature_channels = feature_channels
|
|
self.quality_list = quality_list
|
|
self.model_name = model_name
|
|
self.k_features = k_features
|
|
self.scoring = scoring
|
|
self.n_bootstrap = n_bootstrap
|
|
|
|
def run(self):
|
|
try:
|
|
n_pairs = len(np.unique(self.pair_ids))
|
|
self.log_signal.emit(
|
|
f"Running paired diagnostics for {self.model_name} "
|
|
f"({n_pairs} pairs, {self.n_bootstrap} bootstrap resamples, "
|
|
f"scoring={self.scoring})..."
|
|
)
|
|
|
|
def progress_cb(done, total):
|
|
if done % 50 == 0 or done == total:
|
|
self.log_signal.emit(f" Bootstrap {done}/{total}")
|
|
|
|
result = paired_accuracy_diagnostics(
|
|
X_raw=self.X_raw, y=self.y, pair_ids=self.pair_ids,
|
|
feature_names=self.feature_names, feature_channels=self.feature_channels,
|
|
quality_list=self.quality_list,
|
|
classifier_factory=CLASSIFIER_FACTORIES[self.model_name],
|
|
k_features=self.k_features, n_bootstrap=self.n_bootstrap, scoring=self.scoring,
|
|
progress_callback=progress_cb,
|
|
)
|
|
self.log_signal.emit("Paired diagnostics complete.")
|
|
self.finished_signal.emit(result)
|
|
except Exception as e:
|
|
self.error_signal.emit(str(e))
|
|
|
|
|
|
class MaxStatWorker(QThread):
|
|
log_signal = Signal(str)
|
|
finished_signal = Signal(dict)
|
|
error_signal = Signal(str)
|
|
|
|
def __init__(self, X_raw, y, groups, feature_combos, combo_labels, feature_names,
|
|
model_name, k_features, scoring='accuracy', n_permutations=100):
|
|
super().__init__()
|
|
self.X_raw = X_raw
|
|
self.y = y
|
|
self.groups = groups
|
|
self.feature_combos = feature_combos
|
|
self.combo_labels = combo_labels
|
|
self.feature_names = feature_names
|
|
self.model_name = model_name
|
|
self.k_features = k_features
|
|
self.scoring = scoring
|
|
self.n_permutations = n_permutations
|
|
|
|
def run(self):
|
|
try:
|
|
self.log_signal.emit(
|
|
f"Running max-stat permutation test: {len(self.feature_combos)} combos x "
|
|
f"{self.n_permutations} shuffles (this can take a while)..."
|
|
)
|
|
|
|
def progress_cb(done, total):
|
|
if done % 10 == 0 or done == total:
|
|
self.log_signal.emit(f" Permutation {done}/{total}")
|
|
|
|
result = run_max_stat_permutation_test(
|
|
X_raw=self.X_raw, y=self.y, groups=self.groups,
|
|
feature_combos=self.feature_combos, feature_names=self.feature_names,
|
|
classifier_factories={self.model_name: CLASSIFIER_FACTORIES[self.model_name]},
|
|
k_features=self.k_features, tune_hyperparameters=False, scoring=self.scoring,
|
|
n_permutations=self.n_permutations, progress_callback=progress_cb,
|
|
)
|
|
result['combo_labels'] = self.combo_labels
|
|
self.log_signal.emit("Max-stat permutation test complete.")
|
|
self.finished_signal.emit(result)
|
|
except Exception as e:
|
|
self.error_signal.emit(str(e))
|
|
|
|
|
|
class MetricSweepWorker(QThread):
|
|
log_signal = Signal(str)
|
|
finished_signal = Signal(pd.DataFrame)
|
|
error_signal = Signal(str)
|
|
|
|
def __init__(self, df, subject_col, target_condition, train_label_map, test_label_map,
|
|
data_root=None, scoring='accuracy'):
|
|
super().__init__()
|
|
self.df = df
|
|
self.subject_col = subject_col
|
|
self.target_condition = target_condition
|
|
self.train_label_map = train_label_map
|
|
self.test_label_map = test_label_map
|
|
self.data_root = data_root
|
|
self.scoring = scoring
|
|
|
|
def run(self):
|
|
try:
|
|
self.log_signal.emit("Initializing metric-combination sweep from memory...")
|
|
|
|
extractor = FNIRSClassificationPipeline(
|
|
training_subjects=list(self.train_label_map.keys()),
|
|
data_root=self.data_root,
|
|
target_condition=self.target_condition,
|
|
selected_metrics=ALL_METRIC_KEYS
|
|
)
|
|
extractor.load_from_dataframe(self.df, self.subject_col, self.train_label_map)
|
|
|
|
unseen_extractor = FNIRSClassificationPipeline(
|
|
training_subjects=list(self.test_label_map.keys()),
|
|
data_root=self.data_root,
|
|
target_condition=self.target_condition,
|
|
selected_metrics=ALL_METRIC_KEYS
|
|
)
|
|
unseen_extractor.load_from_dataframe(self.df, self.subject_col, self.test_label_map)
|
|
|
|
feature_keys = build_metric_keys(extractor.feature_names)
|
|
unseen_keys = build_metric_keys(unseen_extractor.feature_names)
|
|
|
|
singles = list(combinations(ALL_METRIC_KEYS, 1))
|
|
pairs = list(combinations(ALL_METRIC_KEYS, 2))
|
|
all_combos = singles + pairs
|
|
|
|
self.log_signal.emit(f"Running Stage 1 & 2 ({len(all_combos)} runs, scoring={self.scoring})...")
|
|
results = []
|
|
|
|
for idx, combo in enumerate(all_combos, 1):
|
|
res = self._eval_combo(
|
|
combo, extractor, unseen_extractor, feature_keys, unseen_keys
|
|
)
|
|
results.append(res)
|
|
if idx % 10 == 0 or idx == len(all_combos):
|
|
self.log_signal.emit(f" Completed {idx}/{len(all_combos)} combos")
|
|
|
|
ranked = self._rank_metrics(results, ALL_METRIC_KEYS)
|
|
top_5 = [m for m, _ in ranked[:5]]
|
|
self.log_signal.emit(f"Top 5 metrics: {', '.join(top_5)}")
|
|
|
|
stage3_combos = []
|
|
for size in (3, 4, 5):
|
|
stage3_combos.extend(list(combinations(top_5, size)))
|
|
|
|
self.log_signal.emit(f"Running Stage 3 ({len(stage3_combos)} runs)...")
|
|
for combo in stage3_combos:
|
|
res = self._eval_combo(
|
|
combo, extractor, unseen_extractor, feature_keys, unseen_keys
|
|
)
|
|
results.append(res)
|
|
|
|
res_df = pd.DataFrame(results).sort_values(
|
|
['avg_test_acc', 'avg_train_acc'], ascending=False
|
|
).reset_index(drop=True)
|
|
res_df.to_csv(SWEEP_RESULTS_CSV, index=False)
|
|
|
|
self.log_signal.emit("Sweep complete!")
|
|
self.finished_signal.emit(res_df)
|
|
|
|
except Exception as e:
|
|
self.error_signal.emit(str(e))
|
|
|
|
def _eval_combo(self, keys, ex, uex, f_keys, u_keys):
|
|
col_idx = np.where(np.isin(f_keys, keys))[0]
|
|
unseen_col_idx = np.where(np.isin(u_keys, keys))[0]
|
|
|
|
X_sub = ex.X_raw[:, col_idx]
|
|
X_unseen_sub = uex.X_raw[:, unseen_col_idx]
|
|
|
|
model_accs, fitted_pipes, _ = run_loso_cv(
|
|
X_raw=X_sub, y=ex.y, groups=ex.groups,
|
|
feature_names=[ex.feature_names[i] for i in col_idx],
|
|
feature_channels=[ex.feature_channels[i] for i in col_idx],
|
|
quality_list=ex.quality_list,
|
|
classifier_factories=CLASSIFIER_FACTORIES,
|
|
k_features=min(K_FEATURES, X_sub.shape[1]),
|
|
verbose=False,
|
|
tune_hyperparameters=TUNE_HYPERPARAMETERS,
|
|
scoring=self.scoring,
|
|
)
|
|
|
|
score_fn = accuracy_score if self.scoring == 'accuracy' else balanced_accuracy_score
|
|
|
|
unseen_accs = {}
|
|
if len(uex.y) > 0 and X_unseen_sub.shape[1] > 0:
|
|
for name, pipe in fitted_pipes.items():
|
|
unseen_accs[name] = float(score_fn(uex.y, pipe.predict(X_unseen_sub)))
|
|
else:
|
|
unseen_accs = {name: 0.0 for name in fitted_pipes}
|
|
|
|
return {
|
|
'combo_label': "+".join(keys),
|
|
'size': len(keys),
|
|
'avg_train_acc': float(np.mean(list(model_accs.values()))),
|
|
'avg_test_acc': float(np.mean(list(unseen_accs.values()))),
|
|
'L1_Logistic_train': model_accs.get('L1_Logistic', 0),
|
|
'Linear_SVM_train': model_accs.get('Linear_SVM', 0),
|
|
'LDA_train': model_accs.get('LDA', 0),
|
|
'L1_Logistic_test': unseen_accs.get('L1_Logistic', 0),
|
|
'Linear_SVM_test': unseen_accs.get('Linear_SVM', 0),
|
|
'LDA_test': unseen_accs.get('LDA', 0),
|
|
}
|
|
|
|
def _rank_metrics(self, results, all_keys):
|
|
scores = {m: [] for m in all_keys}
|
|
for r in results:
|
|
for m in r['combo_label'].split('+'):
|
|
scores[m].append(r['avg_train_acc'])
|
|
return sorted([(m, np.mean(v) if v else 0) for m, v in scores.items()], key=lambda x: x[1], reverse=True)
|
|
|
|
|
|
class DataSplitterGUI(QWidget):
|
|
|
|
def __init__(self, parent: QWidget | None = None) -> None:
|
|
super().__init__(parent)
|
|
self.setWindowTitle("fNIRS Data Splitter & Model Inspector")
|
|
self.resize(1200, 850)
|
|
|
|
self.df = None
|
|
self.subject_col = None
|
|
self.all_subjects = []
|
|
self.sweep_thread = None
|
|
self.sweep_results_df = None
|
|
self.test_combos = {}
|
|
|
|
# Leaderboard sort state
|
|
self.leaderboard_sort_col = None
|
|
self.leaderboard_sort_asc = True
|
|
self.sweep_results_df = None
|
|
|
|
self.breakdown_sort_col = None
|
|
self.breakdown_sort_asc = True
|
|
self.breakdown_df = None
|
|
|
|
self.perm_thread = None
|
|
self.current_train_pipe = None
|
|
self.current_k_actual = None
|
|
|
|
self.paired_mode = False
|
|
self.paired_thread = None
|
|
|
|
self.diagnostics_thread = None
|
|
self.maxstat_thread = None
|
|
|
|
self.use_balanced_accuracy = False
|
|
|
|
# Per-row diagnostic details for the currently displayed Test File Breakdown table,
|
|
# aligned by row index. Populated in update_test_breakdown(), consumed by Tab 4.
|
|
self.current_breakdown_details = []
|
|
|
|
self.init_ui()
|
|
self.load_initial_data()
|
|
|
|
def _scoring(self):
|
|
return 'balanced_accuracy' if self.use_balanced_accuracy else 'accuracy'
|
|
|
|
def init_ui(self):
|
|
top_layout = QVBoxLayout(self)
|
|
|
|
self.tabs = QTabWidget()
|
|
self.tabs.currentChanged.connect(self.on_tab_changed)
|
|
top_layout.addWidget(self.tabs)
|
|
|
|
# TAB 1: SPLITTER & SETUP
|
|
tab_split = QWidget()
|
|
main_layout = QVBoxLayout(tab_split)
|
|
|
|
file_box = QGroupBox("1. Dataset Path & Target Condition")
|
|
file_layout = QVBoxLayout(file_box)
|
|
|
|
mode_row = QHBoxLayout()
|
|
self.paired_mode_checkbox = QCheckBox("Paired (Within-Subjects) Mode")
|
|
self.paired_mode_checkbox.toggled.connect(self.toggle_paired_mode)
|
|
mode_row.addWidget(self.paired_mode_checkbox)
|
|
|
|
self.balanced_acc_checkbox = QCheckBox("Use Balanced Accuracy (recommended for imbalanced test sets)")
|
|
self.balanced_acc_checkbox.toggled.connect(self.toggle_balanced_accuracy)
|
|
mode_row.addWidget(self.balanced_acc_checkbox)
|
|
mode_row.addStretch()
|
|
file_layout.addLayout(mode_row)
|
|
|
|
path_row = QHBoxLayout()
|
|
self.path_input = QLineEdit()
|
|
self.path_input.setText(DEFAULT_CSV_PATH if os.path.exists(DEFAULT_CSV_PATH) else "")
|
|
path_row.addWidget(self.path_input)
|
|
|
|
browse_btn = QPushButton("Browse...")
|
|
browse_btn.clicked.connect(self.browse_file)
|
|
path_row.addWidget(browse_btn)
|
|
|
|
load_btn = QPushButton("Load CSV")
|
|
load_btn.setStyleSheet("font-weight: bold; background-color: #2b5b84; color: white;")
|
|
load_btn.clicked.connect(self.load_csv)
|
|
path_row.addWidget(load_btn)
|
|
file_layout.addLayout(path_row)
|
|
|
|
cond_row = QHBoxLayout()
|
|
cond_row.addWidget(QLabel("Target Condition:"))
|
|
self.condition_combo = QComboBox()
|
|
cond_row.addWidget(self.condition_combo)
|
|
cond_row.addStretch()
|
|
file_layout.addLayout(cond_row)
|
|
|
|
main_layout.addWidget(file_box)
|
|
|
|
table_box = QGroupBox("2. Assign Training & Testing Groups")
|
|
table_layout = QVBoxLayout(table_box)
|
|
self.table = QTableWidget()
|
|
self.table.setColumnCount(5)
|
|
self.table.setHorizontalHeaderLabels(
|
|
["Subject ID / Path", "Class A (Train)", "Class B (Train)", "Testing Pool", "Pair ID"]
|
|
)
|
|
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
|
for col in range(1, 5):
|
|
self.table.horizontalHeader().setSectionResizeMode(col, QHeaderView.Interactive)
|
|
self.table.setColumnWidth(col, 130)
|
|
table_layout.addWidget(self.table)
|
|
main_layout.addWidget(table_box)
|
|
|
|
self.test_box = QGroupBox("3. Assign Testing Ground Truth")
|
|
self.test_layout = QFormLayout(self.test_box)
|
|
self.test_scroll = QScrollArea()
|
|
self.test_scroll.setWidgetResizable(True)
|
|
self.test_scroll.setWidget(self.test_box)
|
|
self.test_scroll.setMaximumHeight(160)
|
|
main_layout.addWidget(self.test_scroll)
|
|
|
|
btn_layout = QHBoxLayout()
|
|
load_config_btn = QPushButton("Load Saved Config")
|
|
load_config_btn.clicked.connect(self.load_config_to_gui)
|
|
btn_layout.addWidget(load_config_btn)
|
|
btn_layout.addStretch()
|
|
|
|
self.run_sweep_btn = QPushButton("Run Metric-Combination Sweep")
|
|
self.run_sweep_btn.setStyleSheet("font-weight: bold; background-color: #d32f2f; color: white; padding: 8px 12px;")
|
|
self.run_sweep_btn.clicked.connect(self.start_sweep)
|
|
btn_layout.addWidget(self.run_sweep_btn)
|
|
|
|
self.paired_model_select = QComboBox()
|
|
self.paired_model_select.addItems(list(CLASSIFIER_FACTORIES.keys()))
|
|
self.paired_model_select.setVisible(False)
|
|
btn_layout.addWidget(self.paired_model_select)
|
|
|
|
self.run_paired_btn = QPushButton("Run Paired Analysis")
|
|
self.run_paired_btn.setStyleSheet("font-weight: bold; background-color: #6a1b9a; color: white; padding: 8px 12px;")
|
|
self.run_paired_btn.clicked.connect(self.start_paired_analysis)
|
|
self.run_paired_btn.setVisible(False)
|
|
btn_layout.addWidget(self.run_paired_btn)
|
|
|
|
self.run_diagnostics_btn = QPushButton("Paired Diagnostics (CI + Power + Bayes)")
|
|
self.run_diagnostics_btn.setStyleSheet("font-weight: bold; background-color: #00695c; color: white; padding: 8px 12px;")
|
|
self.run_diagnostics_btn.clicked.connect(self.start_paired_diagnostics)
|
|
self.run_diagnostics_btn.setVisible(False)
|
|
btn_layout.addWidget(self.run_diagnostics_btn)
|
|
|
|
main_layout.addLayout(btn_layout)
|
|
|
|
btn_layout2 = QHBoxLayout()
|
|
btn_layout2.addStretch()
|
|
|
|
self.run_univariate_btn = QPushButton("Univariate Paired Tests (FDR)")
|
|
self.run_univariate_btn.setStyleSheet("font-weight: bold; background-color: #ef6c00; color: white; padding: 8px 12px;")
|
|
self.run_univariate_btn.clicked.connect(self.start_univariate_test)
|
|
self.run_univariate_btn.setVisible(False)
|
|
btn_layout2.addWidget(self.run_univariate_btn)
|
|
|
|
self.run_stability_btn = QPushButton("Feature Selection Stability")
|
|
self.run_stability_btn.setStyleSheet("font-weight: bold; background-color: #455a64; color: white; padding: 8px 12px;")
|
|
self.run_stability_btn.clicked.connect(self.start_feature_stability)
|
|
self.run_stability_btn.setVisible(False)
|
|
btn_layout2.addWidget(self.run_stability_btn)
|
|
|
|
main_layout.addLayout(btn_layout2)
|
|
self.tabs.addTab(tab_split, "Dataset Splitter")
|
|
|
|
# TAB 2: SWEEP LOGS & LEADERBOARD
|
|
tab_sweep = QWidget()
|
|
sweep_layout = QVBoxLayout(tab_sweep)
|
|
|
|
sweep_layout.addWidget(QLabel("Sweep Output & Real-time Logs:"))
|
|
self.log_output = QTextEdit()
|
|
self.log_output.setReadOnly(True)
|
|
self.log_output.setMaximumHeight(130)
|
|
sweep_layout.addWidget(self.log_output)
|
|
|
|
leaderboard_header_row = QHBoxLayout()
|
|
leaderboard_header_row.addWidget(QLabel(
|
|
"Leaderboard (click a row to inspect file breakdown in Tab 3; click a column header to sort by it):"
|
|
))
|
|
leaderboard_header_row.addStretch()
|
|
self.run_maxstat_btn = QPushButton("Test Search Significance (Max-Stat)")
|
|
self.run_maxstat_btn.setStyleSheet("font-weight: bold; background-color: #ad1457; color: white; padding: 4px 10px;")
|
|
self.run_maxstat_btn.clicked.connect(self.start_max_stat_test)
|
|
leaderboard_header_row.addWidget(self.run_maxstat_btn)
|
|
sweep_layout.addLayout(leaderboard_header_row)
|
|
|
|
self.leaderboard_table = QTableWidget()
|
|
self.leaderboard_table.verticalHeader().setVisible(False)
|
|
self.leaderboard_table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive)
|
|
self.leaderboard_table.horizontalHeader().setSectionsClickable(True)
|
|
self.leaderboard_table.horizontalHeader().sectionClicked.connect(self.on_leaderboard_header_clicked)
|
|
self.leaderboard_table.setSelectionBehavior(QTableWidget.SelectRows)
|
|
self.leaderboard_table.cellClicked.connect(self.on_leaderboard_row_clicked)
|
|
sweep_layout.addWidget(self.leaderboard_table)
|
|
|
|
self.tabs.addTab(tab_sweep, "Metric Sweep Results")
|
|
|
|
# TAB 3: TEST FILE BREAKDOWN & EXPLANATIONS
|
|
tab_breakdown = QWidget()
|
|
bd_layout = QVBoxLayout(tab_breakdown)
|
|
|
|
sel_box = QGroupBox("Select Model & Metric Combination to Inspect")
|
|
sel_form = QHBoxLayout(sel_box)
|
|
|
|
sel_form.addWidget(QLabel("Metric Combination:"))
|
|
self.inspect_combo_select = QComboBox()
|
|
self.inspect_combo_select.currentIndexChanged.connect(self.update_test_breakdown)
|
|
sel_form.addWidget(self.inspect_combo_select, stretch=2)
|
|
|
|
sel_form.addWidget(QLabel("Model Architecture:"))
|
|
self.inspect_model_select = QComboBox()
|
|
self.inspect_model_select.addItems(list(CLASSIFIER_FACTORIES.keys()))
|
|
self.inspect_model_select.currentIndexChanged.connect(self.update_test_breakdown)
|
|
sel_form.addWidget(self.inspect_model_select, stretch=1)
|
|
|
|
self.perm_test_btn = QPushButton("Test Significance")
|
|
self.perm_test_btn.setStyleSheet("font-weight: bold; background-color: #6a1b9a; color: white;")
|
|
self.perm_test_btn.clicked.connect(self.start_permutation_test)
|
|
sel_form.addWidget(self.perm_test_btn)
|
|
|
|
bd_layout.addWidget(sel_box)
|
|
|
|
self.summary_banner = QLabel("Select a completed sweep or configuration to view test predictions.")
|
|
self.summary_banner.setStyleSheet("font-size: 14px; font-weight: bold; color: #2b5b84; padding: 6px;")
|
|
bd_layout.addWidget(self.summary_banner)
|
|
|
|
bd_layout.addWidget(QLabel("Click any row to see a full per-feature explanation in Tab 4:"))
|
|
|
|
self.breakdown_table = QTableWidget()
|
|
self.breakdown_table.setColumnCount(8)
|
|
self.breakdown_table.verticalHeader().setVisible(False)
|
|
self.breakdown_table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive)
|
|
self.breakdown_table.horizontalHeader().setSectionsClickable(True)
|
|
self.breakdown_table.horizontalHeader().sectionClicked.connect(self.on_breakdown_header_clicked)
|
|
self.breakdown_table.setSelectionBehavior(QTableWidget.SelectRows)
|
|
self.breakdown_table.cellClicked.connect(self.on_breakdown_row_clicked)
|
|
bd_layout.addWidget(self.breakdown_table)
|
|
|
|
self.tabs.addTab(tab_breakdown, "Test File Breakdown")
|
|
|
|
# TAB 4: FILE DEEP DIVE
|
|
tab_deep_dive = QWidget()
|
|
dd_layout = QVBoxLayout(tab_deep_dive)
|
|
|
|
self.deep_dive_banner = QLabel("Click a subject row in Tab 3 to see a detailed feature-by-feature explanation here.")
|
|
self.deep_dive_banner.setStyleSheet("font-size: 14px; font-weight: bold; color: #2b5b84; padding: 6px;")
|
|
self.deep_dive_banner.setWordWrap(True)
|
|
dd_layout.addWidget(self.deep_dive_banner)
|
|
|
|
self.deep_dive_text = QTextEdit()
|
|
self.deep_dive_text.setReadOnly(True)
|
|
dd_layout.addWidget(self.deep_dive_text)
|
|
|
|
self.tabs.addTab(tab_deep_dive, "File Deep Dive")
|
|
|
|
def resizeEvent(self, event):
|
|
super().resizeEvent(event)
|
|
self.redistribute_active_tab_columns()
|
|
|
|
def on_tab_changed(self, index):
|
|
self.redistribute_active_tab_columns()
|
|
|
|
def redistribute_active_tab_columns(self):
|
|
current_index = self.tabs.currentIndex()
|
|
if current_index == 1 and hasattr(self, 'leaderboard_table'):
|
|
self.distribute_table_columns_evenly(self.leaderboard_table)
|
|
elif current_index == 2 and hasattr(self, 'breakdown_table'):
|
|
self.distribute_table_columns_evenly(self.breakdown_table)
|
|
|
|
def distribute_table_columns_evenly(self, table):
|
|
total_width = table.viewport().width()
|
|
col_count = table.columnCount()
|
|
if col_count > 0 and total_width > 50: # Ensures tab layout has actually rendered
|
|
col_width = total_width // col_count
|
|
for col in range(col_count):
|
|
table.setColumnWidth(col, col_width)
|
|
return True
|
|
return False
|
|
|
|
def detect_conditions(self):
|
|
conditions = set()
|
|
if self.df is not None:
|
|
for col in self.df.columns:
|
|
parts = col.split('_')
|
|
if len(parts) >= 3 and parts[0] == 'FIR':
|
|
conditions.add(parts[1])
|
|
return sorted(list(conditions)) if conditions else ["Reach"]
|
|
|
|
def browse_file(self):
|
|
filename, _ = QFileDialog.getOpenFileName(self, "Select CSV", "", "CSV Files (*.csv)")
|
|
if filename:
|
|
self.path_input.setText(filename)
|
|
self.load_csv()
|
|
|
|
def load_initial_data(self):
|
|
filepath = self.path_input.text().strip()
|
|
if filepath and os.path.exists(filepath):
|
|
self.load_csv()
|
|
|
|
def load_csv(self):
|
|
filepath = self.path_input.text().strip()
|
|
if not os.path.exists(filepath):
|
|
QMessageBox.warning(self, "Error", f"File path does not exist:\n{filepath}")
|
|
return
|
|
|
|
try:
|
|
self.df = pd.read_csv(filepath)
|
|
self.subject_col = "Participant" if "Participant" in self.df.columns else ("Subject" if "Subject" in self.df.columns else self.df.columns[0])
|
|
self.all_subjects = sorted(self.df[self.subject_col].astype(str).unique().tolist())
|
|
|
|
self.condition_combo.clear()
|
|
self.condition_combo.addItems(self.detect_conditions())
|
|
|
|
self.populate_table()
|
|
|
|
if os.path.exists(CONFIG_FILE):
|
|
self.load_config_to_gui(silent=True)
|
|
if os.path.exists(SWEEP_RESULTS_CSV):
|
|
try:
|
|
self.sweep_results_df = pd.read_csv(SWEEP_RESULTS_CSV)
|
|
self.leaderboard_sort_col = None
|
|
self.leaderboard_sort_asc = False
|
|
self.populate_leaderboard(self.sweep_results_df)
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Error", f"Failed to load CSV file:\n{str(e)}")
|
|
|
|
def populate_table(self):
|
|
self.table.blockSignals(True)
|
|
|
|
self.table.setRowCount(0)
|
|
self.table.setRowCount(len(self.all_subjects))
|
|
|
|
for row, sub in enumerate(self.all_subjects):
|
|
sub_item = QTableWidgetItem(sub)
|
|
sub_item.setFlags(sub_item.flags() ^ Qt.ItemIsEditable)
|
|
self.table.setItem(row, 0, sub_item)
|
|
|
|
chk_a = QTableWidgetItem()
|
|
chk_a.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
|
|
chk_a.setCheckState(Qt.Unchecked)
|
|
self.table.setItem(row, 1, chk_a)
|
|
|
|
chk_b = QTableWidgetItem()
|
|
chk_b.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
|
|
chk_b.setCheckState(Qt.Unchecked)
|
|
self.table.setItem(row, 2, chk_b)
|
|
|
|
test_item = QTableWidgetItem("Testing Pool")
|
|
test_item.setFlags(test_item.flags() ^ Qt.ItemIsEditable)
|
|
self.table.setItem(row, 3, test_item)
|
|
|
|
pair_item = QTableWidgetItem("")
|
|
self.table.setItem(row, 4, pair_item)
|
|
|
|
self.table.itemChanged.connect(self.on_table_cell_changed)
|
|
self.table.blockSignals(False)
|
|
self.update_testing_labels()
|
|
|
|
def on_table_cell_changed(self, item):
|
|
row, col = item.row(), item.column()
|
|
self.table.blockSignals(True)
|
|
|
|
if col == 1 and item.checkState() == Qt.Checked:
|
|
self.table.item(row, 2).setCheckState(Qt.Unchecked)
|
|
elif col == 2 and item.checkState() == Qt.Checked:
|
|
self.table.item(row, 1).setCheckState(Qt.Unchecked)
|
|
|
|
is_a = self.table.item(row, 1).checkState() == Qt.Checked
|
|
is_b = self.table.item(row, 2).checkState() == Qt.Checked
|
|
|
|
if self.paired_mode:
|
|
if is_a:
|
|
self.table.item(row, 3).setText("Condition 1")
|
|
elif is_b:
|
|
self.table.item(row, 3).setText("Condition 2")
|
|
else:
|
|
self.table.item(row, 3).setText("Unassigned")
|
|
else:
|
|
if is_a:
|
|
self.table.item(row, 3).setText("Class A (Train)")
|
|
elif is_b:
|
|
self.table.item(row, 3).setText("Class B (Train)")
|
|
else:
|
|
self.table.item(row, 3).setText("Testing Pool")
|
|
|
|
self.table.blockSignals(False)
|
|
self.update_testing_labels()
|
|
|
|
def update_testing_labels(self):
|
|
while self.test_layout.count():
|
|
child = self.test_layout.takeAt(0)
|
|
if child.widget():
|
|
child.widget().deleteLater()
|
|
|
|
self.test_combos = {}
|
|
|
|
for row in range(self.table.rowCount()):
|
|
sub_id = self.table.item(row, 0).text()
|
|
is_a = self.table.item(row, 1).checkState() == Qt.Checked
|
|
is_b = self.table.item(row, 2).checkState() == Qt.Checked
|
|
|
|
if not is_a and not is_b:
|
|
combo = QComboBox()
|
|
combo.addItems(["Class_A", "Class_B"])
|
|
self.test_combos[sub_id] = combo
|
|
self.test_layout.addRow(QLabel(f"Test Subject {os.path.basename(sub_id)} Ground Truth:"), combo)
|
|
|
|
def get_groups(self):
|
|
class_a_subs, class_b_subs, testing_subs = [], [], []
|
|
for row in range(self.table.rowCount()):
|
|
sub_id = self.table.item(row, 0).text()
|
|
if self.table.item(row, 1).checkState() == Qt.Checked:
|
|
class_a_subs.append(sub_id)
|
|
elif self.table.item(row, 2).checkState() == Qt.Checked:
|
|
class_b_subs.append(sub_id)
|
|
else:
|
|
testing_subs.append(sub_id)
|
|
|
|
test_labels = {}
|
|
for sub_id in testing_subs:
|
|
if sub_id in self.test_combos:
|
|
test_labels[sub_id] = self.test_combos[sub_id].currentText()
|
|
|
|
return class_a_subs, class_b_subs, testing_subs, test_labels
|
|
|
|
def toggle_paired_mode(self, checked):
|
|
self.paired_mode = checked
|
|
headers = (
|
|
["Subject ID / Path", "Condition 1 (No Blindfold)", "Condition 2 (Blindfold)", "Status", "Pair ID"]
|
|
if checked else
|
|
["Subject ID / Path", "Class A (Train)", "Class B (Train)", "Testing Pool", "Pair ID"]
|
|
)
|
|
self.table.setHorizontalHeaderLabels(headers)
|
|
self.run_sweep_btn.setVisible(not checked)
|
|
self.paired_model_select.setVisible(checked)
|
|
self.run_paired_btn.setVisible(checked)
|
|
self.run_diagnostics_btn.setVisible(checked)
|
|
self.run_univariate_btn.setVisible(checked)
|
|
self.run_stability_btn.setVisible(checked)
|
|
self.refresh_status_column()
|
|
|
|
def toggle_balanced_accuracy(self, checked):
|
|
self.use_balanced_accuracy = checked
|
|
|
|
def refresh_status_column(self):
|
|
self.table.blockSignals(True)
|
|
for row in range(self.table.rowCount()):
|
|
is_a = self.table.item(row, 1).checkState() == Qt.Checked
|
|
is_b = self.table.item(row, 2).checkState() == Qt.Checked
|
|
if self.paired_mode:
|
|
text = "Condition 1" if is_a else ("Condition 2" if is_b else "Unassigned")
|
|
else:
|
|
text = "Class A (Train)" if is_a else ("Class B (Train)" if is_b else "Testing Pool")
|
|
self.table.item(row, 3).setText(text)
|
|
self.table.blockSignals(False)
|
|
|
|
def get_paired_groups(self):
|
|
"""
|
|
Reads the table in paired mode and returns (cond1_subs, cond2_subs, subject_to_pair,
|
|
skipped). cond1_subs[i] and cond2_subs[i] are always the same physical subject
|
|
(they are appended together within the same loop iteration below), which matters
|
|
for any code that needs row-to-row alignment between the two conditions, not just
|
|
correct group labeling. Only pair IDs with exactly one Condition-1 row and one
|
|
Condition-2 row are kept -- there is no diff/pairing to compute for a subject who
|
|
only has one condition.
|
|
"""
|
|
pair_rows = {}
|
|
for row in range(self.table.rowCount()):
|
|
sub_id = self.table.item(row, 0).text()
|
|
pair_id = self.table.item(row, 4).text().strip()
|
|
if not pair_id:
|
|
continue
|
|
is_c1 = self.table.item(row, 1).checkState() == Qt.Checked
|
|
is_c2 = self.table.item(row, 2).checkState() == Qt.Checked
|
|
if not is_c1 and not is_c2:
|
|
continue
|
|
entry = pair_rows.setdefault(pair_id, {})
|
|
entry['cond1' if is_c1 else 'cond2'] = sub_id
|
|
|
|
cond1_subs, cond2_subs, subject_to_pair = [], [], {}
|
|
skipped = []
|
|
for pair_id, entry in pair_rows.items():
|
|
if 'cond1' in entry and 'cond2' in entry:
|
|
cond1_subs.append(entry['cond1'])
|
|
cond2_subs.append(entry['cond2'])
|
|
subject_to_pair[entry['cond1']] = pair_id
|
|
subject_to_pair[entry['cond2']] = pair_id
|
|
else:
|
|
skipped.append(pair_id)
|
|
|
|
return cond1_subs, cond2_subs, subject_to_pair, skipped
|
|
|
|
def _build_paired_pipes(self, cond1_subs, cond2_subs):
|
|
target_condition = self.condition_combo.currentText()
|
|
cond1_map = {s: "Condition_1" for s in cond1_subs}
|
|
cond2_map = {s: "Condition_2" for s in cond2_subs}
|
|
|
|
pipe1 = FNIRSClassificationPipeline(
|
|
training_subjects=cond1_subs, target_condition=target_condition,
|
|
selected_metrics=ALL_METRIC_KEYS
|
|
).load_from_dataframe(self.df, self.subject_col, cond1_map)
|
|
|
|
pipe2 = FNIRSClassificationPipeline(
|
|
training_subjects=cond2_subs, target_condition=target_condition,
|
|
selected_metrics=ALL_METRIC_KEYS
|
|
).load_from_dataframe(self.df, self.subject_col, cond2_map)
|
|
|
|
return pipe1, pipe2
|
|
|
|
def start_paired_analysis(self):
|
|
cond1_subs, cond2_subs, subject_to_pair, skipped = self.get_paired_groups()
|
|
|
|
if len(cond1_subs) < 3:
|
|
QMessageBox.warning(
|
|
self, "Error",
|
|
f"Need at least 3 complete pairs (Pair ID present on both a Condition 1 "
|
|
f"and Condition 2 row). Found {len(cond1_subs)} complete pair(s), "
|
|
f"{len(skipped)} incomplete."
|
|
)
|
|
return
|
|
|
|
pipe1, pipe2 = self._build_paired_pipes(cond1_subs, cond2_subs)
|
|
|
|
if pipe1.X_raw.shape[0] == 0 or pipe2.X_raw.shape[0] == 0:
|
|
QMessageBox.warning(self, "Error", "No feature data found for the paired subjects.")
|
|
return
|
|
|
|
X_raw = np.vstack([pipe1.X_raw, pipe2.X_raw])
|
|
y = np.concatenate([np.zeros(pipe1.X_raw.shape[0], dtype=int),
|
|
np.ones(pipe2.X_raw.shape[0], dtype=int)])
|
|
pair_ids = np.array(
|
|
[subject_to_pair[s] for s in pipe1.groups] + [subject_to_pair[s] for s in pipe2.groups]
|
|
)
|
|
|
|
model_name = self.paired_model_select.currentText()
|
|
k_actual = max(1, min(K_FEATURES, X_raw.shape[1]))
|
|
|
|
self.tabs.setCurrentIndex(1)
|
|
self.run_paired_btn.setEnabled(False)
|
|
self.append_log(
|
|
f"--- Paired permutation test: {model_name} | {len(cond1_subs)} pairs "
|
|
f"({len(skipped)} incomplete pairs skipped) | scoring={self._scoring()} ---"
|
|
)
|
|
|
|
self.paired_thread = PairedPermutationWorker(
|
|
X_raw=X_raw, y=y, pair_ids=pair_ids,
|
|
feature_names=pipe1.feature_names, feature_channels=pipe1.feature_channels,
|
|
quality_list=pipe1.quality_list, model_name=model_name,
|
|
k_features=k_actual, scoring=self._scoring(), n_permutations=200,
|
|
)
|
|
self.paired_thread.log_signal.connect(self.append_log)
|
|
self.paired_thread.finished_signal.connect(self.on_paired_finished)
|
|
self.paired_thread.error_signal.connect(self.on_paired_error)
|
|
self.paired_thread.start()
|
|
|
|
def on_paired_finished(self, result):
|
|
self.run_paired_btn.setEnabled(True)
|
|
obs = result['observed_acc']
|
|
null = result['null_accs']
|
|
p = result['p_value']
|
|
null_mean = float(np.mean(null)) if null else 0.0
|
|
|
|
self.append_log(f"Paired LOSO acc: {obs:.2%} | null mean: {null_mean:.2%} | p={p:.4f}")
|
|
|
|
verdict = "distinguishable from chance (p < 0.05)" if p < 0.05 else "NOT distinguishable from chance"
|
|
QMessageBox.information(
|
|
self, "Paired Permutation Test Result",
|
|
f"Observed leave-one-pair-out accuracy: {obs:.2%}\n"
|
|
f"Null distribution mean ({len(null)} sign-flip shuffles): {null_mean:.2%}\n"
|
|
f"p-value: {p:.4f}\n\n"
|
|
f"Result is {verdict}."
|
|
)
|
|
|
|
def on_paired_error(self, err_msg):
|
|
self.run_paired_btn.setEnabled(True)
|
|
QMessageBox.critical(self, "Paired Permutation Test Error", f"An error occurred:\n{err_msg}")
|
|
|
|
def start_paired_diagnostics(self):
|
|
cond1_subs, cond2_subs, subject_to_pair, skipped = self.get_paired_groups()
|
|
|
|
if len(cond1_subs) < 3:
|
|
QMessageBox.warning(
|
|
self, "Error",
|
|
f"Need at least 3 complete pairs. Found {len(cond1_subs)} complete pair(s), "
|
|
f"{len(skipped)} incomplete."
|
|
)
|
|
return
|
|
|
|
pipe1, pipe2 = self._build_paired_pipes(cond1_subs, cond2_subs)
|
|
|
|
if pipe1.X_raw.shape[0] == 0 or pipe2.X_raw.shape[0] == 0:
|
|
QMessageBox.warning(self, "Error", "No feature data found for the paired subjects.")
|
|
return
|
|
|
|
X_raw = np.vstack([pipe1.X_raw, pipe2.X_raw])
|
|
y = np.concatenate([np.zeros(pipe1.X_raw.shape[0], dtype=int),
|
|
np.ones(pipe2.X_raw.shape[0], dtype=int)])
|
|
pair_ids = np.array(
|
|
[subject_to_pair[s] for s in pipe1.groups] + [subject_to_pair[s] for s in pipe2.groups]
|
|
)
|
|
|
|
model_name = self.paired_model_select.currentText()
|
|
k_actual = max(1, min(K_FEATURES, X_raw.shape[1]))
|
|
|
|
self.tabs.setCurrentIndex(1)
|
|
self.run_diagnostics_btn.setEnabled(False)
|
|
self.append_log(
|
|
f"--- Paired diagnostics: {model_name} | {len(cond1_subs)} pairs "
|
|
f"({len(skipped)} incomplete pairs skipped) | scoring={self._scoring()} ---"
|
|
)
|
|
|
|
self.diagnostics_thread = PairedDiagnosticsWorker(
|
|
X_raw=X_raw, y=y, pair_ids=pair_ids,
|
|
feature_names=pipe1.feature_names, feature_channels=pipe1.feature_channels,
|
|
quality_list=pipe1.quality_list, model_name=model_name,
|
|
k_features=k_actual, scoring=self._scoring(), n_bootstrap=500,
|
|
)
|
|
self.diagnostics_thread.log_signal.connect(self.append_log)
|
|
self.diagnostics_thread.finished_signal.connect(self.on_diagnostics_finished)
|
|
self.diagnostics_thread.error_signal.connect(self.on_diagnostics_error)
|
|
self.diagnostics_thread.start()
|
|
|
|
def on_diagnostics_finished(self, result):
|
|
self.run_diagnostics_btn.setEnabled(True)
|
|
obs = result['observed_acc']
|
|
ci_low = result['ci_low']
|
|
ci_high = result['ci_high']
|
|
n_pairs = result['n_pairs']
|
|
mde = result['minimum_detectable_accuracy']
|
|
n_boot_ok = result['n_bootstrap_successful']
|
|
bf01 = result.get('bayes_factor_bf01', float('nan'))
|
|
n_correct = result.get('n_correct')
|
|
n_total = result.get('n_total_predictions')
|
|
|
|
self.append_log(
|
|
f"Observed acc: {obs:.2%} | 95% CI: [{ci_low:.2%}, {ci_high:.2%}] "
|
|
f"| MDE @80% power: {mde:.2%} | BF01={bf01:.3f} | n_pairs={n_pairs}"
|
|
)
|
|
|
|
clears_mde = obs >= mde
|
|
power_note = (
|
|
"Observed accuracy meets or exceeds the minimum detectable effect."
|
|
if clears_mde else
|
|
"Observed accuracy does NOT exceed the minimum detectable effect \u2014 "
|
|
"a real effect at or below this size could exist but not be reliably caught by this design."
|
|
)
|
|
|
|
if np.isnan(bf01):
|
|
bf_note = "Bayes factor unavailable."
|
|
elif bf01 > 3:
|
|
bf_note = f"BF01 = {bf01:.2f} \u2014 substantial-to-strong evidence FOR the null (no effect)."
|
|
elif bf01 > 1:
|
|
bf_note = f"BF01 = {bf01:.2f} \u2014 weak evidence favoring the null, not strong enough to claim equivalence."
|
|
elif bf01 > 1 / 3:
|
|
bf_note = f"BF01 = {bf01:.2f} \u2014 weak evidence favoring a real effect, inconclusive."
|
|
else:
|
|
bf_note = f"BF01 = {bf01:.2f} \u2014 substantial-to-strong evidence AGAINST the null (favors a real effect)."
|
|
|
|
QMessageBox.information(
|
|
self, "Paired Diagnostics Result",
|
|
f"Subject pairs: {n_pairs}\n"
|
|
f"Observed paired LOSO accuracy: {obs:.2%} ({n_correct}/{n_total} predictions correct)\n"
|
|
f"95% Bootstrap CI ({n_boot_ok} successful resamples): [{ci_low:.2%}, {ci_high:.2%}]\n"
|
|
f"Minimum detectable accuracy (~80% power, alpha=0.05): {mde:.2%}\n\n"
|
|
f"{power_note}\n\n"
|
|
f"Bayes factor (accuracy vs. chance): {bf_note}"
|
|
)
|
|
|
|
def on_diagnostics_error(self, err_msg):
|
|
self.run_diagnostics_btn.setEnabled(True)
|
|
QMessageBox.critical(self, "Paired Diagnostics Error", f"An error occurred:\n{err_msg}")
|
|
|
|
def start_univariate_test(self):
|
|
"""
|
|
Runs a Wilcoxon signed-rank test per feature (condition 2 vs condition 1), with
|
|
Benjamini-Hochberg FDR correction across all features. Unlike the classifier-based
|
|
tests, this needs the two conditions' rows explicitly aligned by pair -- row order
|
|
from load_from_dataframe follows the source CSV's row order, not the order pairs
|
|
were entered in the table, so alignment is rebuilt here by pair_id rather than
|
|
assumed from list order.
|
|
"""
|
|
cond1_subs, cond2_subs, subject_to_pair, skipped = self.get_paired_groups()
|
|
|
|
if len(cond1_subs) < 3:
|
|
QMessageBox.warning(
|
|
self, "Error",
|
|
f"Need at least 3 complete pairs. Found {len(cond1_subs)} complete pair(s), "
|
|
f"{len(skipped)} incomplete."
|
|
)
|
|
return
|
|
|
|
pipe1, pipe2 = self._build_paired_pipes(cond1_subs, cond2_subs)
|
|
|
|
if pipe1.X_raw.shape[0] == 0 or pipe2.X_raw.shape[0] == 0:
|
|
QMessageBox.warning(self, "Error", "No feature data found for the paired subjects.")
|
|
return
|
|
if list(pipe1.feature_names) != list(pipe2.feature_names):
|
|
QMessageBox.warning(self, "Error", "Feature sets differ between conditions; cannot align.")
|
|
return
|
|
|
|
pair_to_row1 = {subject_to_pair[s]: i for i, s in enumerate(pipe1.groups) if s in subject_to_pair}
|
|
pair_to_row2 = {subject_to_pair[s]: i for i, s in enumerate(pipe2.groups) if s in subject_to_pair}
|
|
common_pairs = sorted(set(pair_to_row1) & set(pair_to_row2))
|
|
|
|
if len(common_pairs) < 3:
|
|
QMessageBox.warning(self, "Error", "Fewer than 3 subjects have data in both conditions.")
|
|
return
|
|
|
|
row1_idx = [pair_to_row1[p] for p in common_pairs]
|
|
row2_idx = [pair_to_row2[p] for p in common_pairs]
|
|
X_cond1_aligned = pipe1.X_raw[row1_idx]
|
|
X_cond2_aligned = pipe2.X_raw[row2_idx]
|
|
|
|
results = run_univariate_paired_tests(X_cond1_aligned, X_cond2_aligned, pipe1.feature_names)
|
|
results.sort(key=lambda r: r['p_value_fdr'])
|
|
n_sig = sum(1 for r in results if r['significant_fdr'])
|
|
|
|
lines = [
|
|
f"{len(common_pairs)} aligned pairs | {n_sig}/{len(results)} features significant "
|
|
f"after FDR correction (alpha=0.05)\n"
|
|
]
|
|
for r in results[:15]:
|
|
roi_label, metric_key = _split_feature_name(r['feature_name'])
|
|
marker = " *" if r['significant_fdr'] else ""
|
|
lines.append(
|
|
f"{metric_key or '?'} - {roi_label}: mean diff={r['mean_diff']:+.4f}, "
|
|
f"p={r['p_value']:.4f}, FDR q={r['p_value_fdr']:.4f}{marker}"
|
|
)
|
|
if len(results) > 15:
|
|
lines.append(f"... and {len(results) - 15} more features not shown.")
|
|
|
|
self.append_log(f"Univariate paired tests: {n_sig}/{len(results)} significant after FDR.")
|
|
QMessageBox.information(self, "Univariate Paired Test Results (Wilcoxon + BH-FDR)", "\n".join(lines))
|
|
|
|
def start_feature_stability(self):
|
|
"""
|
|
Runs a single (non-permuted) paired LOSO pass with feature-selection tracking
|
|
enabled, to check whether SelectKBest picks roughly the same features fold to
|
|
fold. Wildly inconsistent selection across folds is itself evidence against a
|
|
stable underlying effect, independent of the accuracy/p-value numbers.
|
|
"""
|
|
cond1_subs, cond2_subs, subject_to_pair, skipped = self.get_paired_groups()
|
|
|
|
if len(cond1_subs) < 3:
|
|
QMessageBox.warning(
|
|
self, "Error",
|
|
f"Need at least 3 complete pairs. Found {len(cond1_subs)} complete pair(s), "
|
|
f"{len(skipped)} incomplete."
|
|
)
|
|
return
|
|
|
|
pipe1, pipe2 = self._build_paired_pipes(cond1_subs, cond2_subs)
|
|
|
|
if pipe1.X_raw.shape[0] == 0 or pipe2.X_raw.shape[0] == 0:
|
|
QMessageBox.warning(self, "Error", "No feature data found for the paired subjects.")
|
|
return
|
|
|
|
X_raw = np.vstack([pipe1.X_raw, pipe2.X_raw])
|
|
y = np.concatenate([np.zeros(pipe1.X_raw.shape[0], dtype=int),
|
|
np.ones(pipe2.X_raw.shape[0], dtype=int)])
|
|
pair_ids = np.array(
|
|
[subject_to_pair[s] for s in pipe1.groups] + [subject_to_pair[s] for s in pipe2.groups]
|
|
)
|
|
|
|
model_name = self.paired_model_select.currentText()
|
|
k_actual = max(1, min(K_FEATURES, X_raw.shape[1]))
|
|
n_folds = len(np.unique(pair_ids))
|
|
|
|
_, _, selection_counts = run_loso_cv(
|
|
X_raw=X_raw, y=y, groups=pair_ids,
|
|
feature_names=pipe1.feature_names,
|
|
classifier_factories={model_name: CLASSIFIER_FACTORIES[model_name]},
|
|
k_features=k_actual, tune_hyperparameters=False, scoring=self._scoring(),
|
|
track_feature_selection=True,
|
|
)
|
|
|
|
counts = selection_counts[model_name]
|
|
sorted_counts = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)
|
|
|
|
lines = [f"Feature selection frequency across {n_folds} LOSO folds ({model_name}):\n"]
|
|
any_selected = False
|
|
for feat_name, cnt in sorted_counts[:15]:
|
|
if cnt == 0:
|
|
continue
|
|
any_selected = True
|
|
roi_label, metric_key = _split_feature_name(feat_name)
|
|
lines.append(f"{metric_key or '?'} - {roi_label}: selected in {cnt}/{n_folds} folds")
|
|
if not any_selected:
|
|
lines.append("No feature was selected in any fold.")
|
|
|
|
self.append_log(f"Feature stability check complete for {model_name} ({n_folds} folds).")
|
|
QMessageBox.information(self, "Feature Selection Stability", "\n".join(lines))
|
|
|
|
def start_max_stat_test(self):
|
|
"""
|
|
Reruns the entire metric-combination search under label permutation, tracking only
|
|
the best accuracy per shuffle, so the leaderboard's winning combo can be judged
|
|
against "best of a search this size on pure noise" rather than just "better than
|
|
chance." Operates on the current (non-paired) Class A / Class B leaderboard.
|
|
"""
|
|
if self.sweep_results_df is None or self.sweep_results_df.empty:
|
|
QMessageBox.warning(self, "Error", "Run the metric-combination sweep first.")
|
|
return
|
|
|
|
target_condition = self.condition_combo.currentText()
|
|
class_a, class_b, testing_subs, test_labels = self.get_groups()
|
|
if not class_a or not class_b:
|
|
QMessageBox.warning(self, "Error", "Class A and Class B need at least one subject.")
|
|
return
|
|
|
|
train_label_map = {s: "Class_A" for s in class_a}
|
|
train_label_map.update({s: "Class_B" for s in class_b})
|
|
|
|
extractor = FNIRSClassificationPipeline(
|
|
training_subjects=list(train_label_map.keys()), target_condition=target_condition,
|
|
selected_metrics=ALL_METRIC_KEYS
|
|
).load_from_dataframe(self.df, self.subject_col, train_label_map)
|
|
|
|
if extractor.X_raw.shape[0] == 0:
|
|
QMessageBox.warning(self, "Error", "No feature data found for the current training groups.")
|
|
return
|
|
|
|
feature_keys = build_metric_keys(extractor.feature_names)
|
|
combo_labels = self.sweep_results_df['combo_label'].dropna().unique().tolist()
|
|
|
|
feature_combos, kept_labels = [], []
|
|
for combo_label in combo_labels:
|
|
keys = str(combo_label).split('+')
|
|
col_idx = np.where(np.isin(feature_keys, keys))[0]
|
|
if len(col_idx) > 0:
|
|
feature_combos.append(col_idx)
|
|
kept_labels.append(combo_label)
|
|
|
|
if not feature_combos:
|
|
QMessageBox.warning(self, "Error", "Could not map leaderboard combos back to features.")
|
|
return
|
|
|
|
model_name = self.inspect_model_select.currentText() or next(iter(CLASSIFIER_FACTORIES))
|
|
|
|
self.tabs.setCurrentIndex(1)
|
|
self.run_maxstat_btn.setEnabled(False)
|
|
self.append_log(
|
|
f"--- Max-stat permutation test across {len(feature_combos)} combos "
|
|
f"({model_name}, scoring={self._scoring()}) ---"
|
|
)
|
|
|
|
self.maxstat_thread = MaxStatWorker(
|
|
X_raw=extractor.X_raw, y=extractor.y, groups=extractor.groups,
|
|
feature_combos=feature_combos, combo_labels=kept_labels,
|
|
feature_names=extractor.feature_names, model_name=model_name,
|
|
k_features=K_FEATURES, scoring=self._scoring(), n_permutations=100,
|
|
)
|
|
self.maxstat_thread.log_signal.connect(self.append_log)
|
|
self.maxstat_thread.finished_signal.connect(self.on_maxstat_finished)
|
|
self.maxstat_thread.error_signal.connect(self.on_maxstat_error)
|
|
self.maxstat_thread.start()
|
|
|
|
def on_maxstat_finished(self, result):
|
|
self.run_maxstat_btn.setEnabled(True)
|
|
obs = result['observed_max_acc']
|
|
p = result['p_value']
|
|
null_max_accs = result['null_max_accs']
|
|
null_mean = float(np.mean(null_max_accs)) if null_max_accs else 0.0
|
|
best_idx = result['observed_best_combo_idx']
|
|
combo_labels = result.get('combo_labels', [])
|
|
best_label = combo_labels[best_idx] if 0 <= best_idx < len(combo_labels) else "?"
|
|
|
|
self.append_log(
|
|
f"Max-stat: best combo '{best_label}' acc={obs:.2%} | "
|
|
f"null (best-of-search) mean={null_mean:.2%} | p={p:.4f}"
|
|
)
|
|
|
|
verdict = (
|
|
"survives multiple-comparisons correction (p < 0.05)" if p < 0.05
|
|
else "does NOT survive multiple-comparisons correction"
|
|
)
|
|
QMessageBox.information(
|
|
self, "Max-Stat Permutation Test Result",
|
|
f"Best combo in real search: {best_label}\n"
|
|
f"Observed best accuracy: {obs:.2%}\n"
|
|
f"Null 'best-of-{len(combo_labels)}-combos' mean: {null_mean:.2%}\n"
|
|
f"p-value: {p:.4f}\n\n"
|
|
f"This result {verdict}, accounting for having searched {len(combo_labels)} combinations "
|
|
f"before picking a winner."
|
|
)
|
|
|
|
def on_maxstat_error(self, err_msg):
|
|
self.run_maxstat_btn.setEnabled(True)
|
|
QMessageBox.critical(self, "Max-Stat Test Error", f"An error occurred:\n{err_msg}")
|
|
|
|
def load_config_to_gui(self, silent=False):
|
|
if not os.path.exists(CONFIG_FILE):
|
|
if not silent:
|
|
QMessageBox.information(self, "Config", "No saved configuration file found.")
|
|
return
|
|
|
|
with open(CONFIG_FILE, "r") as f:
|
|
config = json.load(f)
|
|
|
|
class_a = set(config.get("class_a", []))
|
|
class_b = set(config.get("class_b", []))
|
|
test_labels = config.get("test_labels", {})
|
|
|
|
self.table.blockSignals(True)
|
|
for row in range(self.table.rowCount()):
|
|
sub_id = self.table.item(row, 0).text()
|
|
if sub_id in class_a:
|
|
self.table.item(row, 1).setCheckState(Qt.Checked)
|
|
self.table.item(row, 2).setCheckState(Qt.Unchecked)
|
|
elif sub_id in class_b:
|
|
self.table.item(row, 1).setCheckState(Qt.Unchecked)
|
|
self.table.item(row, 2).setCheckState(Qt.Checked)
|
|
else:
|
|
self.table.item(row, 1).setCheckState(Qt.Unchecked)
|
|
self.table.item(row, 2).setCheckState(Qt.Unchecked)
|
|
|
|
self.table.blockSignals(False)
|
|
self.refresh_status_column()
|
|
self.update_testing_labels()
|
|
|
|
for sub_id, label in test_labels.items():
|
|
if sub_id in self.test_combos:
|
|
combo = self.test_combos[sub_id]
|
|
idx = combo.findText(label)
|
|
if idx >= 0:
|
|
combo.setCurrentIndex(idx)
|
|
|
|
def start_sweep(self):
|
|
target_condition = self.condition_combo.currentText()
|
|
if not target_condition:
|
|
QMessageBox.warning(self, "Error", "No target condition selected.")
|
|
return
|
|
|
|
class_a, class_b, testing_subs, test_labels = self.get_groups()
|
|
|
|
if not class_a or not class_b:
|
|
QMessageBox.warning(self, "Error", "Class A and Class B need at least one subject.")
|
|
return
|
|
|
|
train_label_map = {sub_id: "Class_A" for sub_id in class_a}
|
|
train_label_map.update({sub_id: "Class_B" for sub_id in class_b})
|
|
|
|
config_data = {"class_a": class_a, "class_b": class_b, "test_labels": test_labels}
|
|
with open(CONFIG_FILE, "w") as f:
|
|
json.dump(config_data, f, indent=4)
|
|
|
|
self.tabs.setCurrentIndex(1)
|
|
self.log_output.clear()
|
|
self.run_sweep_btn.setEnabled(False)
|
|
|
|
self.sweep_thread = MetricSweepWorker(
|
|
df=self.df,
|
|
subject_col=self.subject_col,
|
|
target_condition=target_condition,
|
|
train_label_map=train_label_map,
|
|
test_label_map=test_labels,
|
|
scoring=self._scoring(),
|
|
)
|
|
self.sweep_thread.log_signal.connect(self.append_log)
|
|
self.sweep_thread.finished_signal.connect(self.on_sweep_finished)
|
|
self.sweep_thread.error_signal.connect(self.on_sweep_error)
|
|
self.sweep_thread.start()
|
|
|
|
def append_log(self, text):
|
|
self.log_output.append(f"[{time.strftime('%H:%M:%S')}] {text}")
|
|
|
|
def on_sweep_finished(self, df):
|
|
self.run_sweep_btn.setEnabled(True)
|
|
self.leaderboard_sort_col = None
|
|
self.leaderboard_sort_asc = False
|
|
self.sweep_results_df = df
|
|
self.populate_leaderboard(df)
|
|
|
|
def on_sweep_error(self, err_msg):
|
|
self.run_sweep_btn.setEnabled(True)
|
|
QMessageBox.critical(self, "Sweep Error", f"An error occurred:\n{err_msg}")
|
|
|
|
def start_permutation_test(self):
|
|
if self.current_train_pipe is None or self.current_train_pipe.X_raw.shape[0] == 0:
|
|
QMessageBox.warning(self, "Error", "Select a valid combination in Tab 3 first.")
|
|
return
|
|
|
|
model_name = self.inspect_model_select.currentText()
|
|
if not model_name:
|
|
return
|
|
|
|
tp = self.current_train_pipe
|
|
self.tabs.setCurrentIndex(1)
|
|
self.perm_test_btn.setEnabled(False)
|
|
self.append_log(
|
|
f"--- Permutation test: {model_name} on {self.inspect_combo_select.currentText()} "
|
|
f"| scoring={self._scoring()} ---"
|
|
)
|
|
|
|
self.perm_thread = PermutationTestWorker(
|
|
X_raw=tp.X_raw, y=tp.y, groups=tp.groups,
|
|
feature_names=tp.feature_names, feature_channels=tp.feature_channels,
|
|
quality_list=tp.quality_list, model_name=model_name,
|
|
k_features=self.current_k_actual, scoring=self._scoring(), n_permutations=200,
|
|
)
|
|
self.perm_thread.log_signal.connect(self.append_log)
|
|
self.perm_thread.finished_signal.connect(self.on_permutation_finished)
|
|
self.perm_thread.error_signal.connect(self.on_permutation_error)
|
|
self.perm_thread.start()
|
|
|
|
def on_permutation_finished(self, result):
|
|
self.perm_test_btn.setEnabled(True)
|
|
obs = result['observed_acc']
|
|
null = result['null_accs']
|
|
p = result['p_value']
|
|
null_mean = float(np.mean(null)) if null else 0.0
|
|
|
|
self.append_log(f"Observed LOSO acc: {obs:.2%} | null mean: {null_mean:.2%} | p={p:.4f}")
|
|
|
|
verdict = "distinguishable from chance (p < 0.05)" if p < 0.05 else "NOT distinguishable from chance"
|
|
QMessageBox.information(
|
|
self, "Permutation Test Result",
|
|
f"Observed LOSO accuracy: {obs:.2%}\n"
|
|
f"Null distribution mean ({len(null)} shuffles): {null_mean:.2%}\n"
|
|
f"p-value: {p:.4f}\n\n"
|
|
f"Result is {verdict} at n={len(self.current_train_pipe.groups)} subjects."
|
|
)
|
|
|
|
def on_permutation_error(self, err_msg):
|
|
self.perm_test_btn.setEnabled(True)
|
|
QMessageBox.critical(self, "Permutation Test Error", f"An error occurred:\n{err_msg}")
|
|
|
|
def populate_leaderboard(self, df):
|
|
if "Rank" not in df.columns:
|
|
df = df.copy()
|
|
df.insert(0, "Rank", range(1, len(df) + 1))
|
|
|
|
self.sweep_results_df = df.reset_index(drop=True)
|
|
|
|
self.leaderboard_table.setRowCount(0)
|
|
self.leaderboard_table.setColumnCount(len(df.columns))
|
|
self.leaderboard_table.setHorizontalHeaderLabels(list(df.columns))
|
|
|
|
self.leaderboard_table.setRowCount(len(df))
|
|
for r_idx, row in df.iterrows():
|
|
for c_idx, val in enumerate(row):
|
|
col_name = df.columns[c_idx]
|
|
if isinstance(val, float):
|
|
val_str = f"{val:.2%}" if "acc" in col_name.lower() else f"{val:.4f}"
|
|
else:
|
|
val_str = str(val)
|
|
item = QTableWidgetItem(val_str)
|
|
item.setFlags(item.flags() ^ Qt.ItemIsEditable)
|
|
self.leaderboard_table.setItem(r_idx, c_idx, item)
|
|
|
|
for c_idx, col_name in enumerate(df.columns):
|
|
label = col_name
|
|
if col_name == self.leaderboard_sort_col:
|
|
label += " \u25b2" if self.leaderboard_sort_asc else " \u25bc"
|
|
self.leaderboard_table.horizontalHeaderItem(c_idx).setText(label)
|
|
|
|
self.distribute_table_columns_evenly(self.leaderboard_table)
|
|
|
|
if 'combo_label' in df.columns:
|
|
combos = df['combo_label'].tolist()
|
|
self.inspect_combo_select.blockSignals(True)
|
|
self.inspect_combo_select.clear()
|
|
self.inspect_combo_select.addItems(combos)
|
|
self.inspect_combo_select.blockSignals(False)
|
|
|
|
if combos:
|
|
self.update_test_breakdown()
|
|
|
|
def on_leaderboard_header_clicked(self, col_idx):
|
|
if self.sweep_results_df is None or self.sweep_results_df.empty:
|
|
return
|
|
|
|
col_name = self.sweep_results_df.columns[col_idx]
|
|
if self.leaderboard_sort_col == col_name:
|
|
self.leaderboard_sort_asc = not self.leaderboard_sort_asc
|
|
else:
|
|
self.leaderboard_sort_col = col_name
|
|
self.leaderboard_sort_asc = True
|
|
|
|
sorted_df = self.sweep_results_df.sort_values(
|
|
col_name, ascending=self.leaderboard_sort_asc, kind="mergesort"
|
|
).reset_index(drop=True)
|
|
|
|
self.populate_leaderboard(sorted_df)
|
|
|
|
# =========================================================================
|
|
# TAB 3: BREAKDOWN POPULATION & SORTING
|
|
# =========================================================================
|
|
def populate_breakdown_table(self, df):
|
|
headers = [
|
|
"Subject / File", "Actual Class", "Predicted Class", "Correct?",
|
|
"Confidence (%)", "Top Driving Feature", "Feature Value", "Feature Impact"
|
|
]
|
|
|
|
if df is None or df.empty:
|
|
self.breakdown_table.setRowCount(0)
|
|
self.breakdown_table.setColumnCount(len(headers))
|
|
self.breakdown_table.setHorizontalHeaderLabels(headers)
|
|
self.breakdown_df = None
|
|
return
|
|
|
|
self.breakdown_df = df.reset_index(drop=True)
|
|
col_keys = ['sub_id', 'actual', 'pred', 'correct', 'conf', 'top_feat', 'raw_val', 'impact']
|
|
|
|
# Build column header labels with active sort arrow
|
|
display_headers = []
|
|
for key, header in zip(col_keys, headers):
|
|
lbl = header
|
|
if key == self.breakdown_sort_col or header == self.breakdown_sort_col:
|
|
lbl += " \u25b2" if self.breakdown_sort_asc else " \u25bc"
|
|
display_headers.append(lbl)
|
|
|
|
self.breakdown_table.setRowCount(0)
|
|
self.breakdown_table.setColumnCount(len(headers))
|
|
self.breakdown_table.setHorizontalHeaderLabels(display_headers)
|
|
|
|
self.breakdown_table.setRowCount(len(df))
|
|
for r_idx, row in df.iterrows():
|
|
detail_obj = row.get('_detail_obj')
|
|
|
|
for c_idx, key in enumerate(col_keys):
|
|
val_str = str(row.get(key, ""))
|
|
item = QTableWidgetItem(val_str)
|
|
item.setFlags(item.flags() ^ Qt.ItemIsEditable)
|
|
|
|
# Attach detail object directly to cell 0 for sort-safe lookup
|
|
if c_idx == 0 and detail_obj is not None:
|
|
item.setData(Qt.UserRole, detail_obj)
|
|
|
|
self.breakdown_table.setItem(r_idx, c_idx, item)
|
|
|
|
self.distribute_table_columns_evenly(self.breakdown_table)
|
|
|
|
def on_breakdown_header_clicked(self, col_idx):
|
|
if self.breakdown_df is None or self.breakdown_df.empty:
|
|
return
|
|
|
|
# Map display column index to sorting field
|
|
col_keys = ['sub_id', 'actual', 'pred', 'correct', '_conf_num', 'top_feat', '_raw_val_num', '_impact_num']
|
|
sort_field = col_keys[col_idx] if col_idx < len(col_keys) else self.breakdown_df.columns[col_idx]
|
|
|
|
if self.breakdown_sort_col == sort_field:
|
|
self.breakdown_sort_asc = not self.breakdown_sort_asc
|
|
else:
|
|
self.breakdown_sort_col = sort_field
|
|
self.breakdown_sort_asc = True
|
|
|
|
sorted_df = self.breakdown_df.sort_values(
|
|
sort_field, ascending=self.breakdown_sort_asc, kind="mergesort"
|
|
).reset_index(drop=True)
|
|
|
|
self.populate_breakdown_table(sorted_df)
|
|
|
|
def on_leaderboard_row_clicked(self, row, col):
|
|
if self.sweep_results_df is None or row < 0 or row >= len(self.sweep_results_df):
|
|
return
|
|
|
|
combo_name = self.sweep_results_df.iloc[row]['combo_label']
|
|
idx = self.inspect_combo_select.findText(combo_name)
|
|
if idx >= 0:
|
|
self.inspect_combo_select.setCurrentIndex(idx)
|
|
|
|
self.tabs.setCurrentIndex(2)
|
|
self.leaderboard_table.clearSelection()
|
|
|
|
def update_test_breakdown(self):
|
|
if self.df is None:
|
|
return
|
|
|
|
target_condition = self.condition_combo.currentText()
|
|
combo_label = self.inspect_combo_select.currentText()
|
|
model_name = self.inspect_model_select.currentText()
|
|
|
|
if not combo_label or not model_name:
|
|
return
|
|
|
|
class_a, class_b, testing_subs, test_labels = self.get_groups()
|
|
if not class_a or not class_b or not test_labels:
|
|
self.summary_banner.setText("Assign Training and Testing subjects in Tab 1 first.")
|
|
self.populate_breakdown_table(None)
|
|
self.breakdown_details_map = {}
|
|
return
|
|
|
|
train_label_map = {sub_id: "Class_A" for sub_id in class_a}
|
|
train_label_map.update({sub_id: "Class_B" for sub_id in class_b})
|
|
|
|
metrics = combo_label.split('+')
|
|
|
|
# Fit training pipeline on full train set
|
|
train_pipe = FNIRSClassificationPipeline(
|
|
training_subjects=list(train_label_map.keys()),
|
|
target_condition=target_condition,
|
|
selected_metrics=metrics
|
|
).load_from_dataframe(self.df, self.subject_col, train_label_map)
|
|
|
|
# Extract test subjects
|
|
test_pipe = FNIRSClassificationPipeline(
|
|
training_subjects=list(test_labels.keys()),
|
|
target_condition=target_condition,
|
|
selected_metrics=metrics
|
|
).load_from_dataframe(self.df, self.subject_col, test_labels)
|
|
|
|
if train_pipe.X_raw.shape[0] == 0 or test_pipe.X_raw.shape[0] == 0:
|
|
self.summary_banner.setText("No data points available for evaluation.")
|
|
self.populate_breakdown_table(None)
|
|
self.breakdown_details_map = {}
|
|
return
|
|
|
|
k_actual = max(1, min(K_FEATURES, train_pipe.X_raw.shape[1]))
|
|
self.current_train_pipe = train_pipe
|
|
self.current_k_actual = k_actual
|
|
factory = CLASSIFIER_FACTORIES[model_name]
|
|
pipeline_template, grid = _make_pipeline_and_grid(factory, k_actual, TUNE_HYPERPARAMETERS)
|
|
fitted_pipe = _fit_with_optional_tuning(pipeline_template, grid, train_pipe.X_raw, train_pipe.y)
|
|
|
|
# Inspect weights and scaler
|
|
scaler = fitted_pipe.named_steps['scaler']
|
|
select = fitted_pipe.named_steps['select']
|
|
clf = fitted_pipe.named_steps['classifier']
|
|
|
|
support_mask = select.get_support()
|
|
selected_indices = np.where(support_mask)[0]
|
|
selected_feature_names = [train_pipe.feature_names[i] for i in selected_indices]
|
|
|
|
has_weights = hasattr(clf, 'coef_') and clf.coef_ is not None
|
|
weights = clf.coef_[0] if has_weights else np.ones(len(selected_indices))
|
|
|
|
has_intercept = hasattr(clf, 'intercept_') and clf.intercept_ is not None
|
|
intercept = float(clf.intercept_[0]) if has_intercept else 0.0
|
|
|
|
unique_labels = sorted(list(set(train_label_map.values())))
|
|
int_to_label = {idx: lbl for idx, lbl in enumerate(unique_labels)}
|
|
|
|
class_raw_values = {}
|
|
class_means = {}
|
|
for cls_int, cls_label in int_to_label.items():
|
|
mask = train_pipe.y == cls_int
|
|
if mask.any():
|
|
class_raw_values[cls_label] = train_pipe.X_raw[mask][:, selected_indices]
|
|
class_means[cls_label] = class_raw_values[cls_label].mean(axis=0)
|
|
else:
|
|
class_raw_values[cls_label] = np.zeros((0, len(selected_indices)))
|
|
class_means[cls_label] = np.zeros(len(selected_indices))
|
|
|
|
rows = []
|
|
self.breakdown_details_map = {}
|
|
correct_count = 0
|
|
|
|
for idx, sub_id in enumerate(test_pipe.groups):
|
|
clean_sub_id = os.path.basename(sub_id)
|
|
x_raw_sample = test_pipe.X_raw[idx]
|
|
actual_int = test_pipe.y[idx]
|
|
actual_label = int_to_label.get(actual_int, str(actual_int))
|
|
|
|
pred_int = fitted_pipe.predict([x_raw_sample])[0]
|
|
pred_label = int_to_label.get(pred_int, str(pred_int))
|
|
is_correct = (pred_int == actual_int)
|
|
if is_correct:
|
|
correct_count += 1
|
|
|
|
decision_score = None
|
|
if hasattr(clf, "decision_function"):
|
|
try:
|
|
decision_score = float(fitted_pipe.decision_function([x_raw_sample])[0])
|
|
except Exception:
|
|
decision_score = None
|
|
|
|
if hasattr(clf, "predict_proba"):
|
|
try:
|
|
probs = fitted_pipe.predict_proba([x_raw_sample])[0]
|
|
conf = probs[pred_int] * 100.0
|
|
except Exception:
|
|
conf = 50.0
|
|
elif decision_score is not None:
|
|
p1 = sigmoid(decision_score)
|
|
p0 = 1.0 - p1
|
|
conf = (p1 if pred_int == 1 else p0) * 100.0
|
|
else:
|
|
conf = 100.0 if is_correct else 50.0
|
|
|
|
x_scaled_all = scaler.transform([x_raw_sample])[0]
|
|
x_scaled_sel = x_scaled_all[selected_indices]
|
|
x_raw_sel = x_raw_sample[selected_indices]
|
|
|
|
contribs = weights * x_scaled_sel
|
|
abs_contribs = np.abs(contribs)
|
|
top_feat_idx = np.argmax(abs_contribs)
|
|
top_feat_name = selected_feature_names[top_feat_idx]
|
|
top_feat_raw_val = x_raw_sel[top_feat_idx]
|
|
top_feat_contrib = contribs[top_feat_idx]
|
|
|
|
total_abs = np.sum(abs_contribs)
|
|
impact_pct_top = (abs_contribs[top_feat_idx] / total_abs * 100.0) if total_abs > 0 else 0.0
|
|
|
|
feature_details = []
|
|
for j, feat_name in enumerate(selected_feature_names):
|
|
impact_pct = (abs_contribs[j] / total_abs * 100.0) if total_abs > 0 else 0.0
|
|
feature_details.append({
|
|
'feat_name': feat_name,
|
|
'col_idx': j,
|
|
'raw_val': x_raw_sel[j],
|
|
'z': x_scaled_sel[j],
|
|
'weight': weights[j],
|
|
'contribution': contribs[j],
|
|
'impact_pct': impact_pct,
|
|
'class_a_mean': class_means.get('Class_A', np.zeros(len(selected_indices)))[j],
|
|
'class_b_mean': class_means.get('Class_B', np.zeros(len(selected_indices)))[j],
|
|
})
|
|
feature_details.sort(key=lambda d: abs(d['contribution']), reverse=True)
|
|
|
|
# Store deep dive detail in dictionary keyed by clean subject ID
|
|
self.breakdown_details_map[clean_sub_id] = {
|
|
'sub_id_full': sub_id,
|
|
'sub_id': clean_sub_id,
|
|
'actual': actual_label,
|
|
'pred': pred_label,
|
|
'correct': is_correct,
|
|
'conf': conf,
|
|
'decision_score': decision_score,
|
|
'contribution_sum': float(np.sum(contribs)),
|
|
'intercept': intercept,
|
|
'model_name': model_name,
|
|
'combo_label': combo_label,
|
|
'features': feature_details,
|
|
'class_a_values': class_raw_values.get('Class_A', np.zeros((0, len(selected_indices)))),
|
|
'class_b_values': class_raw_values.get('Class_B', np.zeros((0, len(selected_indices)))),
|
|
}
|
|
|
|
rows.append({
|
|
'sub_id': clean_sub_id,
|
|
'actual': actual_label,
|
|
'pred': pred_label,
|
|
'correct': "Yes" if is_correct else "No",
|
|
'conf': _fmt_confidence(conf),
|
|
'_conf_num': conf,
|
|
'top_feat': top_feat_name,
|
|
'raw_val': f"{top_feat_raw_val:.4f}",
|
|
'_raw_val_num': top_feat_raw_val,
|
|
'impact': f"{top_feat_contrib:+.3f} ({impact_pct_top:.1f}% rel)",
|
|
'_impact_num': abs(top_feat_contrib),
|
|
})
|
|
|
|
# =========================================================================
|
|
# METRIC CONTRIBUTION BREAKDOWN (For Multi-Metric Combinations)
|
|
# =========================================================================
|
|
metric_summary_text = ""
|
|
if len(metrics) > 1 and len(selected_feature_names) > 0:
|
|
metric_totals = {m: 0.0 for m in metrics}
|
|
metric_feat_counts = {m: 0 for m in metrics}
|
|
total_abs_weight = 0.0
|
|
|
|
# Option A: Based on Model Weight Importance
|
|
for j, feat_name in enumerate(selected_feature_names):
|
|
_, metric_key = _split_feature_name(feat_name)
|
|
|
|
# Match feature back to one of the active combo metrics
|
|
matched_metric = next((m for m in metrics if m.lower() in metric_key.lower()), None)
|
|
if matched_metric is None:
|
|
matched_metric = metrics[0] # Fallback match
|
|
|
|
abs_w = abs(weights[j])
|
|
metric_totals[matched_metric] += abs_w
|
|
metric_feat_counts[matched_metric] += 1
|
|
total_abs_weight += abs_w
|
|
|
|
# Build summary string
|
|
if total_abs_weight > 0:
|
|
parts = []
|
|
for m in metrics:
|
|
pct = (metric_totals[m] / total_abs_weight) * 100.0
|
|
n_feats = metric_feat_counts[m]
|
|
parts.append(f"<b>{m}</b>: {pct:.1f}% ({n_feats} feats)")
|
|
|
|
metric_summary_text = "<b>Metric Share:</b> " + " \u2022 ".join(parts)
|
|
|
|
# Update Tab 3 Banner with accuracy AND metric contribution breakdown
|
|
total_test = len(test_pipe.groups)
|
|
acc_pct = (correct_count / total_test * 100.0) if total_test > 0 else 0.0
|
|
|
|
self.summary_banner.setText(
|
|
f"Model: {model_name} | Combination: {combo_label} | "
|
|
f"Test Accuracy: {acc_pct:.1f}% ({correct_count}/{total_test} Correct)"
|
|
"<br>"
|
|
f"{metric_summary_text}"
|
|
)
|
|
|
|
df = pd.DataFrame(rows)
|
|
self.populate_breakdown_table(df)
|
|
|
|
def on_breakdown_row_clicked(self, row, col):
|
|
if row < 0:
|
|
return
|
|
|
|
item = self.breakdown_table.item(row, 0)
|
|
if item is not None:
|
|
sub_id = item.text()
|
|
details_map = getattr(self, 'breakdown_details_map', {})
|
|
detail = details_map.get(sub_id) or details_map.get(os.path.basename(sub_id))
|
|
if detail:
|
|
self.render_deep_dive(detail)
|
|
|
|
self.tabs.setCurrentIndex(3)
|
|
self.breakdown_table.clearSelection()
|
|
|
|
def render_deep_dive(self, detail):
|
|
"""
|
|
Builds Tab 4's HTML explanation: one section per feature used by the model for this
|
|
subject, ranked by how much it influenced the decision, explaining what the metric
|
|
measures, how this subject's value compares to each class's training average (with an
|
|
overlaid distribution chart, not just numbers), and which direction the model's weight
|
|
pushed the decision. Also explains the *net* effect of all features together.
|
|
"""
|
|
conf_str = _fmt_confidence(detail['conf'])
|
|
verdict_color = "#2e7d32" if detail['correct'] else "#c62828"
|
|
verdict_text = "Correct" if detail['correct'] else "Incorrect"
|
|
|
|
self.deep_dive_banner.setText(
|
|
f"{detail['sub_id']} | Model: {detail['model_name']} | Metrics: {detail['combo_label']} | "
|
|
f"Actual: {detail['actual']} \u2192 Predicted: {detail['pred']} ({conf_str} confidence) | {verdict_text}"
|
|
)
|
|
self.deep_dive_banner.setStyleSheet(
|
|
f"font-size: 14px; font-weight: bold; color: {verdict_color}; padding: 6px;"
|
|
)
|
|
|
|
contribution_sum = detail['contribution_sum']
|
|
intercept = detail.get('intercept', 0.0)
|
|
decision_score = detail['decision_score']
|
|
sum_push = "Class_B" if contribution_sum > 1e-9 else ("Class_A" if contribution_sum < -1e-9 else "neither class")
|
|
intercept_push = "Class_B" if intercept > 1e-9 else ("Class_A" if intercept < -1e-9 else "neutral (0.0)")
|
|
decision_note = ""
|
|
if decision_score is not None:
|
|
decision_note = (
|
|
f" Including the model's intercept term, the full decision score was "
|
|
f"<b>{decision_score:+.4f}</b>."
|
|
)
|
|
|
|
html_parts = [
|
|
"<html><body style='font-family: sans-serif; font-size: 13px;'>",
|
|
f"<h2 style='color:#2b5b84;'>{detail['sub_id']}</h2>",
|
|
f"<p><b>Actual class:</b> {detail['actual']} "
|
|
f"<b>Predicted class:</b> {detail['pred']} "
|
|
f"<b>Confidence:</b> {conf_str} "
|
|
f"<span style='color:{verdict_color}; font-weight:bold;'>{verdict_text}</span></p>",
|
|
"<p>Features are listed from most to least influential on this particular prediction. "
|
|
"\u201cContribution\u201d is the model weight multiplied by this subject's standardized "
|
|
"(z-scored) value for that feature: positive contributions push the decision toward "
|
|
"<b>Class_B</b>, negative contributions push it toward <b>Class_A</b>.</p>",
|
|
f"<p style='background:#f2f2f2; padding:8px; border-radius:4px;'>"
|
|
f"<b>Why the confidence looks so extreme:</b> the features below don't all agree \u2014 some "
|
|
f"push toward Class_A and some push toward Class_B. But confidence is driven by the "
|
|
f"<b>sum</b> of every feature's contribution, not a vote count. Here that sum is "
|
|
f"<b>{contribution_sum:+.4f}</b> (net pull toward <b>{sum_push}</b>).{decision_note} "
|
|
f"A logistic/linear model turns that score into a probability with a sigmoid curve, which "
|
|
f"saturates very quickly \u2014 once the dominant features push the total score this far from "
|
|
f"zero, a couple of small disagreeing features (a few percent of the total influence each) "
|
|
f"aren't enough to meaningfully move the probability, so it rounds to a number very close to "
|
|
f"0% or 100%.</p>",
|
|
f"<div style='background:#f4f6f8; padding:12px; border-left: 4px solid #2b5b84; border-radius:4px; margin-bottom:15px;'>",
|
|
f"<b style='font-size:14px; color:#2b5b84;'>Decision Score & Intercept Breakdown</b>",
|
|
f"<p style='margin-top:6px; margin-bottom:4px;'>",
|
|
f"\u2022 <b>Total Feature Contribution:</b> <code>{contribution_sum:+.4f}</code> (net pull toward <b>{sum_push}</b>)<br>",
|
|
f"\u2022 <b>Model Intercept (Baseline Bias):</b> <code>{intercept:+.4f}</code> (inherent preference toward <b>{intercept_push}</b>)<br>",
|
|
f"\u2022 <b>Final Decision Score:</b> <code>{decision_score:+.4f}</code> <i>(= Feature Sum + Intercept)</i>",
|
|
f"</p>",
|
|
f"<p style='margin-top:8px;'><b>What is the Intercept?</b> The intercept is the model's baseline starting point before considering any of this subject's specific sensor features. If a subject had perfectly average values across all features (z-scores of 0), the model would assign a decision score of <b>{intercept:+.4f}</b>.</p>",
|
|
f"<p style='margin-top:6px;'><b>Why confidence can look extreme:</b> A linear/logistic model passes the final decision score (<code>{decision_score:+.4f}</code>) through an S-shaped sigmoid function to calculate confidence. Because this curve saturates rapidly far from zero, a strong combined score produces near-0% or near-100% confidence, even if a few minor features disagree with the overall verdict.</p>",
|
|
f"</div>",
|
|
"<hr>",
|
|
]
|
|
|
|
class_a_matrix = detail.get('class_a_values')
|
|
class_b_matrix = detail.get('class_b_values')
|
|
|
|
for rank, feat in enumerate(detail['features'], 1):
|
|
roi_label, metric_key = _split_feature_name(feat['feat_name'])
|
|
metric_desc = METRIC_DESCRIPTIONS.get(metric_key, "this measurement of the response")
|
|
metric_label = metric_key.replace('_', ' ') if metric_key else "Unknown metric"
|
|
|
|
raw_val = feat['raw_val']
|
|
class_a_mean = feat['class_a_mean']
|
|
class_b_mean = feat['class_b_mean']
|
|
diff_a = abs(raw_val - class_a_mean)
|
|
diff_b = abs(raw_val - class_b_mean)
|
|
if abs(diff_a - diff_b) < 1e-12:
|
|
closer_text = "roughly equidistant between the two classes' training averages"
|
|
else:
|
|
closer_class = "Class_A" if diff_a < diff_b else "Class_B"
|
|
closer_text = f"closer to the <b>{closer_class}</b> training average"
|
|
|
|
contribution = feat['contribution']
|
|
if contribution > 1e-9:
|
|
push_text = "<b>Class_B</b>"
|
|
elif contribution < -1e-9:
|
|
push_text = "<b>Class_A</b>"
|
|
else:
|
|
push_text = "neither class strongly"
|
|
|
|
img_tag = ""
|
|
col_idx = feat.get('col_idx')
|
|
if class_a_matrix is not None and class_b_matrix is not None and col_idx is not None:
|
|
try:
|
|
a_vals = class_a_matrix[:, col_idx]
|
|
b_vals = class_b_matrix[:, col_idx]
|
|
png_b64 = _render_feature_distribution_png(
|
|
a_vals, b_vals, raw_val, class_a_mean, class_b_mean,
|
|
detail['actual'], metric_label, roi_label, feat['z']
|
|
)
|
|
img_tag = (
|
|
f"<p><img src='data:image/png;base64,{png_b64}' width='620'/></p>"
|
|
)
|
|
except Exception as e:
|
|
img_tag = f"<p><i>(chart unavailable: {e})</i></p>"
|
|
|
|
html_parts.append(
|
|
f"<h3>#{rank}. {metric_label} \u2014 {roi_label}</h3>"
|
|
f"<p><i>{metric_label} measures {metric_desc}.</i></p>"
|
|
f"{img_tag}"
|
|
f"<table cellpadding='4' style='border-collapse:collapse;'>"
|
|
f"<tr><td><b>This subject's value</b></td><td>{_fmt_val(raw_val)}</td></tr>"
|
|
f"<tr><td><b>Class_A training average</b></td><td>{_fmt_val(class_a_mean)}</td></tr>"
|
|
f"<tr><td><b>Class_B training average</b></td><td>{_fmt_val(class_b_mean)}</td></tr>"
|
|
f"<tr><td><b>Standardized value (z-score)</b></td><td>{feat['z']:+.2f} SD from the overall training mean</td></tr>"
|
|
f"<tr><td><b>Model weight</b></td><td>{feat['weight']:+.4f}</td></tr>"
|
|
f"<tr><td><b>Contribution to decision score</b></td><td>{contribution:+.4f}</td></tr>"
|
|
f"<tr><td><b>Share of this prediction's total feature influence</b></td><td>{feat['impact_pct']:.1f}%</td></tr>"
|
|
f"</table>"
|
|
f"<p>This subject's value is {closer_text}. Combined with the model's learned weight for "
|
|
f"this feature, it pushed the prediction toward {push_text}.</p>"
|
|
f"<hr>"
|
|
)
|
|
|
|
html_parts.append("</body></html>")
|
|
self.deep_dive_text.setHtml("".join(html_parts))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_pipeline_and_grid(make_clf, k, tune_hyperparameters):
|
|
pipeline = Pipeline([
|
|
('scaler', StandardScaler()),
|
|
('select', SelectKBest(score_func=f_classif, k=k)),
|
|
('classifier', make_clf()),
|
|
])
|
|
if not tune_hyperparameters or not hasattr(pipeline.named_steps['classifier'], 'C'):
|
|
return pipeline, None
|
|
return pipeline, {'classifier__C': CANDIDATE_C_VALUES}
|
|
|
|
|
|
def _fit_with_optional_tuning(pipeline, param_grid, X, y):
|
|
if param_grid is None:
|
|
pipeline.fit(X, y)
|
|
return pipeline
|
|
|
|
min_class_count = np.bincount(y).min() if len(y) > 0 else 0
|
|
inner_splits = min(INNER_CV_FOLDS, min_class_count)
|
|
# Below ~3 samples/class/fold the inner CV score is not statistically meaningful;
|
|
# tuning on noise tends to select the smallest C (heaviest regularization) on ties
|
|
# and silently collapses the model to near-zero coefficients. Skip tuning instead
|
|
# of pretending the search told us anything.
|
|
if inner_splits < 2 or min_class_count < 2 * INNER_CV_FOLDS:
|
|
pipeline.fit(X, y)
|
|
return pipeline
|
|
|
|
inner_cv = StratifiedKFold(n_splits=inner_splits, shuffle=True, random_state=MODEL_RANDOM_STATE)
|
|
search = GridSearchCV(pipeline, param_grid, cv=inner_cv, scoring='roc_auc', n_jobs=1)
|
|
try:
|
|
search.fit(X, y)
|
|
return search.best_estimator_
|
|
except Exception:
|
|
pipeline.fit(X, y)
|
|
return pipeline
|
|
|
|
|
|
def run_loso_cv(X_raw, y, groups, feature_names=None, feature_channels=None, quality_list=None,
|
|
classifier_factories=None, k_features=5, verbose=False, tune_hyperparameters=True,
|
|
scoring='accuracy', track_feature_selection=False):
|
|
"""
|
|
Executes Leave-One-Group-Out Cross Validation across subject groups.
|
|
|
|
scoring: 'accuracy' (raw) or 'balanced_accuracy' (mean of per-class recall). Balanced
|
|
accuracy is the more honest metric whenever the evaluated set is class-imbalanced --
|
|
raw accuracy on an imbalanced set can look inflated even from a model that leans
|
|
toward the majority class.
|
|
|
|
track_feature_selection: if True, counts how many LOSO folds selected each feature
|
|
(via SelectKBest's support mask) per classifier. A real, stable effect should recruit
|
|
roughly the same features fold to fold; wildly inconsistent selection is itself
|
|
evidence against a stable underlying signal. Adds negligible cost since the support
|
|
mask is already computed during fitting.
|
|
|
|
Returns: (model_accs, fitted_pipes, feature_selection_counts)
|
|
feature_selection_counts is None unless track_feature_selection=True, in which case
|
|
it is {classifier_name: {feature_name: n_folds_selected}}.
|
|
"""
|
|
if classifier_factories is None:
|
|
classifier_factories = CLASSIFIER_FACTORIES
|
|
|
|
score_fn = accuracy_score if scoring == 'accuracy' else balanced_accuracy_score
|
|
|
|
logo = LeaveOneGroupOut()
|
|
model_preds = {name: [] for name in classifier_factories}
|
|
model_trues = {name: [] for name in classifier_factories}
|
|
|
|
feature_selection_counts = None
|
|
if track_feature_selection and feature_names is not None:
|
|
feature_selection_counts = {
|
|
name: {fn: 0 for fn in feature_names} for name in classifier_factories
|
|
}
|
|
|
|
k_actual = max(1, min(k_features, X_raw.shape[1]))
|
|
|
|
# Cross-validation loop
|
|
for train_idx, val_idx in logo.split(X_raw, y, groups):
|
|
X_tr, y_tr = X_raw[train_idx], y[train_idx]
|
|
X_va, y_va = X_raw[val_idx], y[val_idx]
|
|
|
|
for name, factory in classifier_factories.items():
|
|
pipe, grid = _make_pipeline_and_grid(factory, k_actual, tune_hyperparameters)
|
|
fitted_pipe = _fit_with_optional_tuning(pipe, grid, X_tr, y_tr)
|
|
|
|
if feature_selection_counts is not None:
|
|
select = fitted_pipe.named_steps.get('select')
|
|
if select is not None:
|
|
support = select.get_support()
|
|
for f_idx, selected in enumerate(support):
|
|
if selected and f_idx < len(feature_names):
|
|
feature_selection_counts[name][feature_names[f_idx]] += 1
|
|
|
|
preds = fitted_pipe.predict(X_va)
|
|
model_preds[name].extend(preds)
|
|
model_trues[name].extend(y_va)
|
|
|
|
model_accs = {
|
|
name: score_fn(model_trues[name], model_preds[name]) if len(model_trues[name]) > 0 else 0.0
|
|
for name in classifier_factories
|
|
}
|
|
|
|
# Fit final models on all training data
|
|
fitted_pipes = {}
|
|
for name, factory in classifier_factories.items():
|
|
pipe, grid = _make_pipeline_and_grid(factory, k_actual, tune_hyperparameters)
|
|
fitted_pipes[name] = _fit_with_optional_tuning(pipe, grid, X_raw, y)
|
|
|
|
return model_accs, fitted_pipes, feature_selection_counts
|
|
|
|
|
|
class FNIRSClassificationPipeline:
|
|
"""
|
|
Extracts features and targets directly from an loaded DataFrame or raw files.
|
|
"""
|
|
def __init__(self, training_subjects=None, data_root=None, target_condition="Reach",
|
|
fir_delays=None, selected_metrics=None, cache_file=None, n_jobs=1):
|
|
self.training_subjects = training_subjects or []
|
|
self.data_root = data_root
|
|
self.target_condition = target_condition
|
|
self.fir_delays = fir_delays
|
|
self.selected_metrics = selected_metrics
|
|
self.cache_file = cache_file
|
|
self.n_jobs = n_jobs
|
|
|
|
self.X_raw = np.array([])
|
|
self.y = np.array([])
|
|
self.groups = np.array([])
|
|
self.feature_names = []
|
|
self.feature_channels = []
|
|
self.quality_list = []
|
|
|
|
def load_from_dataframe(self, df, subject_col, label_map):
|
|
"""
|
|
Populates dataset matrices from pre-computed master DataFrame based on subject label map.
|
|
"""
|
|
# Filter dataframe for matching subjects
|
|
df_sub = df[df[subject_col].astype(str).isin(label_map.keys())].copy()
|
|
df_sub[subject_col] = df_sub[subject_col].astype(str)
|
|
|
|
# Identify FIR feature columns matching target condition
|
|
fir_cols = [c for c in df_sub.columns if c.startswith('FIR_')]
|
|
if self.target_condition:
|
|
cond_str = f"FIR_{self.target_condition}_"
|
|
matched = [c for c in fir_cols if cond_str.lower() in c.lower()]
|
|
if matched:
|
|
fir_cols = matched
|
|
|
|
# Filter by selected metrics if requested
|
|
if self.selected_metrics:
|
|
valid_cols = []
|
|
for col in fir_cols:
|
|
for met in self.selected_metrics:
|
|
met_label = METRIC_REGISTRY.get(met, met)
|
|
if met == 'Peak_Amp' and col.endswith('_Peak_to_Peak_Amp'):
|
|
continue
|
|
if col.endswith(f"_{met}") or col.endswith(f"_{met_label}"):
|
|
valid_cols.append(col)
|
|
break
|
|
fir_cols = valid_cols if valid_cols else fir_cols
|
|
|
|
self.feature_names = fir_cols
|
|
self.feature_channels = [
|
|
col.split('_')[2] if len(col.split('_')) >= 3 else "ch" for col in fir_cols
|
|
]
|
|
|
|
# Map unique labels to numeric integer targets (0, 1)
|
|
unique_labels = sorted(list(set(label_map.values())))
|
|
label_to_int = {lbl: idx for idx, lbl in enumerate(unique_labels)}
|
|
|
|
X_list, y_list, group_list = [], [], []
|
|
for _, row in df_sub.iterrows():
|
|
sub_id = str(row[subject_col])
|
|
target_class = label_map[sub_id]
|
|
X_list.append(row[fir_cols].values.astype(float))
|
|
y_list.append(label_to_int[target_class])
|
|
group_list.append(sub_id)
|
|
|
|
self.X_raw = np.array(X_list) if X_list else np.empty((0, len(fir_cols)))
|
|
self.y = np.array(y_list)
|
|
self.groups = np.array(group_list)
|
|
self.quality_list = [1.0] * len(self.feature_names)
|
|
return self
|
|
|
|
|
|
def run_permutation_test(X_raw, y, groups, feature_names=None, feature_channels=None,
|
|
quality_list=None, classifier_factories=None, k_features=5,
|
|
tune_hyperparameters=False, n_permutations=200,
|
|
random_state=MODEL_RANDOM_STATE, scoring='accuracy',
|
|
progress_callback=None):
|
|
"""
|
|
Builds a null distribution for LOSO-CV accuracy by repeatedly shuffling class labels
|
|
at the subject level (each subject keeps one consistent shuffled label across its
|
|
rows) and rerunning the *entire* LOSO-CV pipeline -- including SelectKBest feature
|
|
selection -- on the shuffled labels. Feature selection is re-run inside the loop,
|
|
not just the classifier, or the null distribution is invalid: SelectKBest fit on
|
|
real labels would leak real signal into a "null" run.
|
|
|
|
tune_hyperparameters defaults to False: a full inner-CV search inside every outer
|
|
LOSO fit, repeated n_permutations times, is prohibitively slow, and is unstable at
|
|
n=15 anyway -- it would add noise to the null estimate rather than remove it.
|
|
|
|
scoring: 'accuracy' or 'balanced_accuracy' -- passed through to run_loso_cv so the
|
|
null distribution is built with the same scoring rule as the observed statistic.
|
|
|
|
progress_callback(done, total), if provided, is invoked after each permutation so
|
|
a caller (e.g. a QThread) can report status without this function knowing about UI.
|
|
|
|
Returns: {model_name: {'observed_acc', 'null_accs', 'p_value'}}
|
|
p_value uses the standard +1/+1 correction so it's never reported as exactly 0.
|
|
"""
|
|
if classifier_factories is None:
|
|
classifier_factories = CLASSIFIER_FACTORIES
|
|
|
|
rng = np.random.default_rng(random_state)
|
|
|
|
observed_accs, _, _ = run_loso_cv(
|
|
X_raw=X_raw, y=y, groups=groups,
|
|
feature_names=feature_names, feature_channels=feature_channels,
|
|
quality_list=quality_list, classifier_factories=classifier_factories,
|
|
k_features=k_features, tune_hyperparameters=tune_hyperparameters, scoring=scoring,
|
|
)
|
|
|
|
unique_groups = np.unique(groups)
|
|
group_to_label = {g: y[groups == g][0] for g in unique_groups}
|
|
label_values = np.array(list(group_to_label.values()))
|
|
|
|
null_accs = {name: [] for name in classifier_factories}
|
|
|
|
for i in range(n_permutations):
|
|
shuffled = rng.permutation(label_values)
|
|
shuffled_map = dict(zip(unique_groups, shuffled))
|
|
y_perm = np.array([shuffled_map[g] for g in groups])
|
|
|
|
perm_accs, _, _ = run_loso_cv(
|
|
X_raw=X_raw, y=y_perm, groups=groups,
|
|
feature_names=feature_names, feature_channels=feature_channels,
|
|
quality_list=quality_list, classifier_factories=classifier_factories,
|
|
k_features=k_features, tune_hyperparameters=tune_hyperparameters, scoring=scoring,
|
|
)
|
|
for name in classifier_factories:
|
|
null_accs[name].append(perm_accs[name])
|
|
|
|
if progress_callback is not None:
|
|
progress_callback(i + 1, n_permutations)
|
|
|
|
results = {}
|
|
for name in classifier_factories:
|
|
obs = observed_accs[name]
|
|
null = np.array(null_accs[name])
|
|
p_value = (1 + np.sum(null >= obs)) / (1 + n_permutations)
|
|
results[name] = {'observed_acc': obs, 'null_accs': null.tolist(), 'p_value': float(p_value)}
|
|
|
|
return results
|
|
|
|
|
|
def run_paired_permutation_test(X_raw, y, pair_ids, feature_names=None, feature_channels=None,
|
|
quality_list=None, classifier_factories=None, k_features=5,
|
|
tune_hyperparameters=False, n_permutations=200,
|
|
random_state=MODEL_RANDOM_STATE, scoring='accuracy',
|
|
progress_callback=None):
|
|
"""
|
|
Paired (within-subjects) variant of run_permutation_test. Each physical subject
|
|
contributes exactly two rows in X_raw/y -- one per condition -- sharing the same
|
|
pair_id. LOSO-CV groups on pair_id (not row identity), so both of a subject's rows
|
|
are always held out together, preventing same-person leakage across train/test.
|
|
|
|
The null distribution is built by sign-flip permutation, not free label shuffling:
|
|
for each pair, its two condition labels are swapped with 50% probability. This is
|
|
the null appropriate for paired designs (condition assignment within a subject is
|
|
exchangeable) rather than the between-subjects null (subject identity is
|
|
exchangeable), which would be invalid here -- it could assign both of a subject's
|
|
rows the same label, a configuration the real data can never produce.
|
|
"""
|
|
if classifier_factories is None:
|
|
classifier_factories = CLASSIFIER_FACTORIES
|
|
|
|
rng = np.random.default_rng(random_state)
|
|
pair_ids = np.asarray(pair_ids)
|
|
unique_pairs = np.unique(pair_ids)
|
|
|
|
observed_accs, _, _ = run_loso_cv(
|
|
X_raw=X_raw, y=y, groups=pair_ids,
|
|
feature_names=feature_names, feature_channels=feature_channels,
|
|
quality_list=quality_list, classifier_factories=classifier_factories,
|
|
k_features=k_features, tune_hyperparameters=tune_hyperparameters, scoring=scoring,
|
|
)
|
|
|
|
pair_row_idx = {p: np.where(pair_ids == p)[0] for p in unique_pairs}
|
|
null_accs = {name: [] for name in classifier_factories}
|
|
|
|
for i in range(n_permutations):
|
|
y_perm = y.copy()
|
|
for p, idxs in pair_row_idx.items():
|
|
if len(idxs) == 2 and rng.random() < 0.5:
|
|
y_perm[idxs[0]], y_perm[idxs[1]] = y_perm[idxs[1]], y_perm[idxs[0]]
|
|
|
|
perm_accs, _, _ = run_loso_cv(
|
|
X_raw=X_raw, y=y_perm, groups=pair_ids,
|
|
feature_names=feature_names, feature_channels=feature_channels,
|
|
quality_list=quality_list, classifier_factories=classifier_factories,
|
|
k_features=k_features, tune_hyperparameters=tune_hyperparameters, scoring=scoring,
|
|
)
|
|
for name in classifier_factories:
|
|
null_accs[name].append(perm_accs[name])
|
|
|
|
if progress_callback is not None:
|
|
progress_callback(i + 1, n_permutations)
|
|
|
|
results = {}
|
|
for name in classifier_factories:
|
|
obs = observed_accs[name]
|
|
null = np.array(null_accs[name])
|
|
p_value = (1 + np.sum(null >= obs)) / (1 + n_permutations)
|
|
results[name] = {'observed_acc': obs, 'null_accs': null.tolist(), 'p_value': float(p_value)}
|
|
|
|
return results
|
|
|
|
|
|
def binomial_bayes_factor(n_correct, n_total):
|
|
"""
|
|
Closed-form Bayes factor (BF01) comparing "accuracy is exactly chance (p=0.5)" (the
|
|
null) against "accuracy is some unknown value between 0 and 1" (the alternative, with
|
|
a flat/uniform Beta(1,1) prior on that unknown accuracy). Because the alternative's
|
|
prior is uniform, its marginal likelihood integrates to a constant (1/(n+1))
|
|
regardless of the observed count, which makes this Bayes factor exact and cheap --
|
|
no numerical integration needed.
|
|
|
|
BF01 > 1 favors the null (performance indistinguishable from chance); BF01 < 1 favors
|
|
the alternative (performance differs from chance). This directly answers "is there
|
|
positive evidence for no effect," which a p-value alone cannot -- failing to reject
|
|
the null is not the same as evidence for it.
|
|
|
|
Returns float('nan') if n_total is 0.
|
|
"""
|
|
if n_total <= 0:
|
|
return float('nan')
|
|
log_binom_null = (
|
|
stats.binom.logpmf(n_correct, n_total, 0.5)
|
|
)
|
|
log_marginal_alt = -np.log(n_total + 1) # log(1/(n+1)) under a uniform prior
|
|
return float(np.exp(log_binom_null - log_marginal_alt))
|
|
|
|
|
|
def paired_bic_bayes_factor(diffs):
|
|
"""
|
|
Approximate Bayes factor (BF01, evidence for the null of "no mean difference" over
|
|
the alternative "nonzero mean difference") for a paired/one-sample design, using the
|
|
BIC approximation described in Wagenmakers (2007), "A practical solution to the
|
|
pervasive problems of p values." This needs only the paired differences -- no priors
|
|
to specify, no numerical integration -- at the cost of being an approximation rather
|
|
than an exact Bayes factor.
|
|
|
|
BF01 > 1 favors the null (no difference); BF01 < 1 favors a real difference.
|
|
Conventional rough guide (Jeffreys / Kass & Raftery): 1-3 "barely worth mentioning",
|
|
3-10 "substantial" evidence, >10 "strong" evidence; reciprocal thresholds apply in
|
|
favor of the alternative when BF01 < 1.
|
|
|
|
diffs: 1D array of per-subject paired differences (e.g. condition2 - condition1, for
|
|
one feature, or any other paired continuous quantity).
|
|
Returns: (bf01, t_stat, p_value)
|
|
"""
|
|
diffs = np.asarray(diffs, dtype=float)
|
|
n = len(diffs)
|
|
if n < 3 or np.allclose(diffs, diffs[0]):
|
|
return float('nan'), float('nan'), float('nan')
|
|
|
|
t_stat, p_value = stats.ttest_1samp(diffs, popmean=0.0)
|
|
delta_bic = n * np.log(1.0 + (t_stat ** 2) / (n - 1)) - np.log(n)
|
|
bf01 = float(np.exp(delta_bic / 2.0))
|
|
return bf01, float(t_stat), float(p_value)
|
|
|
|
|
|
def paired_accuracy_diagnostics(X_raw, y, pair_ids, feature_names=None, feature_channels=None,
|
|
quality_list=None, classifier_factory=None, k_features=5,
|
|
n_bootstrap=500, random_state=MODEL_RANDOM_STATE,
|
|
scoring='accuracy', progress_callback=None):
|
|
"""
|
|
Companion diagnostics for a paired LOSO result: a bootstrap confidence interval on
|
|
accuracy, the minimum detectable effect (MDE) this sample size supports, and a
|
|
binomial Bayes factor comparing "accuracy is chance" against "accuracy is unknown."
|
|
|
|
Bootstrap CI: resamples pairs (not rows) with replacement, refits + reruns LOSO each
|
|
time. Resampling at the pair level preserves the paired structure -- each bootstrap
|
|
draw still has both condition-rows for every included subject.
|
|
|
|
MDE: with n pairs, treats each pair's correct/incorrect LOSO classification as a
|
|
Bernoulli trial and asks: assuming the true underlying accuracy were p, what is the
|
|
narrowest one-sided margin above 50% (chance) that this design would detect with
|
|
~80% power at alpha=0.05, using a normal approximation to the binomial. This is a
|
|
rough guide, not a substitute for a full simulation-based power analysis, but it is
|
|
enough to state "this design could reliably detect an accuracy of X% or higher."
|
|
|
|
Bayes factor: computed from the observed LOSO predictions (n_correct out of n_total,
|
|
where n_total = 2 * n_pairs since each pair contributes two held-out rows), via
|
|
binomial_bayes_factor. This is the piece that can positively support "no effect,"
|
|
rather than only failing to find one.
|
|
"""
|
|
if classifier_factory is None:
|
|
classifier_factory = CLASSIFIER_FACTORIES['LDA']
|
|
|
|
rng = np.random.default_rng(random_state)
|
|
pair_ids = np.asarray(pair_ids)
|
|
unique_pairs = np.unique(pair_ids)
|
|
n_pairs = len(unique_pairs)
|
|
|
|
pair_row_idx = {p: np.where(pair_ids == p)[0] for p in unique_pairs}
|
|
|
|
# Rerun LOSO once more, manually, to get raw correct/total counts for the Bayes factor
|
|
# (run_loso_cv only returns the aggregated score, not the raw prediction counts).
|
|
logo_preds, logo_trues = [], []
|
|
k_actual = max(1, min(k_features, X_raw.shape[1]))
|
|
for train_idx, val_idx in LeaveOneGroupOut().split(X_raw, y, pair_ids):
|
|
pipe, grid = _make_pipeline_and_grid(classifier_factory, k_actual, False)
|
|
fitted = _fit_with_optional_tuning(pipe, grid, X_raw[train_idx], y[train_idx])
|
|
logo_preds.extend(fitted.predict(X_raw[val_idx]))
|
|
logo_trues.extend(y[val_idx])
|
|
|
|
score_fn = accuracy_score if scoring == 'accuracy' else balanced_accuracy_score
|
|
observed_acc = float(score_fn(logo_trues, logo_preds)) if logo_trues else 0.0
|
|
n_total = len(logo_trues)
|
|
n_correct = int(np.sum(np.array(logo_preds) == np.array(logo_trues)))
|
|
bf01 = binomial_bayes_factor(n_correct, n_total)
|
|
|
|
boot_accs = []
|
|
for i in range(n_bootstrap):
|
|
sampled_pairs = rng.choice(unique_pairs, size=n_pairs, replace=True)
|
|
row_idx = np.concatenate([pair_row_idx[p] for p in sampled_pairs])
|
|
boot_group_ids = np.concatenate([
|
|
np.full(len(pair_row_idx[p]), f"{p}__{j}") for j, p in enumerate(sampled_pairs)
|
|
])
|
|
|
|
try:
|
|
boot_accs_dict, _, _ = run_loso_cv(
|
|
X_raw=X_raw[row_idx], y=y[row_idx], groups=boot_group_ids,
|
|
feature_names=feature_names, feature_channels=feature_channels,
|
|
quality_list=quality_list, classifier_factories={'model': classifier_factory},
|
|
k_features=k_features, tune_hyperparameters=False, scoring=scoring,
|
|
)
|
|
boot_accs.append(boot_accs_dict['model'])
|
|
except Exception:
|
|
pass
|
|
|
|
if progress_callback is not None:
|
|
progress_callback(i + 1, n_bootstrap)
|
|
|
|
boot_accs = np.array(boot_accs)
|
|
ci_low, ci_high = (np.percentile(boot_accs, 2.5), np.percentile(boot_accs, 97.5)) if len(boot_accs) > 0 else (np.nan, np.nan)
|
|
|
|
# Minimum detectable effect: smallest true accuracy p (>0.5) such that a one-sided
|
|
# binomial test at n_pairs trials, alpha=0.05, has ~80% power to exceed the alpha
|
|
# threshold. Uses a normal approximation; z_alpha=1.645, z_power=0.8416 (80% power).
|
|
z_alpha, z_power = 1.645, 0.8416
|
|
p0 = 0.5
|
|
se0 = np.sqrt(p0 * (1 - p0) / n_pairs) if n_pairs > 0 else np.nan
|
|
mde_accuracy = p0 + (z_alpha + z_power) * se0
|
|
|
|
return {
|
|
'observed_acc': observed_acc,
|
|
'ci_low': float(ci_low),
|
|
'ci_high': float(ci_high),
|
|
'n_bootstrap_successful': len(boot_accs),
|
|
'n_pairs': n_pairs,
|
|
'minimum_detectable_accuracy': float(min(mde_accuracy, 1.0)),
|
|
'n_correct': n_correct,
|
|
'n_total_predictions': n_total,
|
|
'bayes_factor_bf01': bf01,
|
|
}
|
|
|
|
|
|
def _benjamini_hochberg(p_values, alpha=0.05):
|
|
"""
|
|
Standard Benjamini-Hochberg step-up FDR correction. Returns adjusted p-values
|
|
(q-values), one per input p-value, in the original input order.
|
|
"""
|
|
p_values = np.asarray(p_values, dtype=float)
|
|
n = len(p_values)
|
|
if n == 0:
|
|
return p_values
|
|
|
|
order = np.argsort(p_values)
|
|
ranked = p_values[order]
|
|
|
|
adjusted = np.empty(n)
|
|
running_min = 1.0
|
|
for i in range(n - 1, -1, -1):
|
|
rank = i + 1
|
|
val = ranked[i] * n / rank
|
|
running_min = min(running_min, val)
|
|
adjusted[i] = running_min
|
|
|
|
out = np.empty(n)
|
|
out[order] = np.clip(adjusted, 0, 1)
|
|
return out
|
|
|
|
|
|
def run_univariate_paired_tests(X_cond1, X_cond2, feature_names, alpha=0.05):
|
|
"""
|
|
Runs a paired Wilcoxon signed-rank test independently for each feature, comparing
|
|
condition 1 vs condition 2 values for the same subjects. X_cond1[i] and X_cond2[i]
|
|
must already be aligned to the same subject/pair before calling this -- this function
|
|
does no subject matching itself.
|
|
|
|
This is a different, complementary question from a multivariate classifier: it asks
|
|
whether any single feature shifts consistently within-subject, rather than whether
|
|
some combination of features can separate the two conditions. It can catch a real,
|
|
small, single-feature effect that a classifier's feature-selection step dilutes or
|
|
steps over when choosing a small combination of features.
|
|
|
|
Falls back to a one-sample t-test for a feature if Wilcoxon cannot be computed (e.g.
|
|
all paired differences are exactly zero for that feature, which scipy raises on).
|
|
|
|
Multiple-comparisons correction uses Benjamini-Hochberg FDR across all features
|
|
tested in this call, since testing ~10-20 features independently and reporting only
|
|
nominal p < 0.05 without correction would inflate the false-positive rate.
|
|
|
|
Returns a list of dicts, one per feature: feature_name, mean_diff, statistic,
|
|
p_value, p_value_fdr, significant_fdr (bool).
|
|
"""
|
|
X_cond1 = np.asarray(X_cond1, dtype=float)
|
|
X_cond2 = np.asarray(X_cond2, dtype=float)
|
|
diffs = X_cond2 - X_cond1
|
|
n_features = X_cond1.shape[1]
|
|
|
|
raw_p = np.full(n_features, np.nan)
|
|
stat_out = np.full(n_features, np.nan)
|
|
mean_diffs = diffs.mean(axis=0)
|
|
|
|
for j in range(n_features):
|
|
col_diffs = diffs[:, j]
|
|
if np.allclose(col_diffs, 0.0):
|
|
raw_p[j] = 1.0
|
|
stat_out[j] = 0.0
|
|
continue
|
|
try:
|
|
stat, p = stats.wilcoxon(col_diffs)
|
|
except ValueError:
|
|
stat, p = stats.ttest_1samp(col_diffs, popmean=0.0)
|
|
raw_p[j] = p
|
|
stat_out[j] = stat
|
|
|
|
p_fdr = _benjamini_hochberg(raw_p, alpha)
|
|
|
|
results = []
|
|
for j in range(n_features):
|
|
results.append({
|
|
'feature_name': feature_names[j],
|
|
'mean_diff': float(mean_diffs[j]),
|
|
'statistic': float(stat_out[j]),
|
|
'p_value': float(raw_p[j]),
|
|
'p_value_fdr': float(p_fdr[j]),
|
|
'significant_fdr': bool(p_fdr[j] < alpha),
|
|
})
|
|
return results
|
|
|
|
|
|
def run_max_stat_permutation_test(X_raw, y, groups, feature_combos, feature_names=None,
|
|
classifier_factories=None, k_features=5,
|
|
tune_hyperparameters=False, n_permutations=200,
|
|
random_state=MODEL_RANDOM_STATE, scoring='accuracy',
|
|
progress_callback=None):
|
|
"""
|
|
Multiple-comparisons-corrected significance test for a leaderboard search: instead of
|
|
asking "is my single best combo's accuracy better than chance," this asks "is my best
|
|
combo's accuracy better than the best accuracy a search across this many combos finds
|
|
on pure noise." For each of n_permutations label shuffles, every combo in
|
|
feature_combos is evaluated and only the single best (max) accuracy across all combos
|
|
for that shuffle is kept, building a null distribution of "best-of-search" accuracies.
|
|
The observed max accuracy (from the real, unshuffled labels) is then compared against
|
|
that null. This corrects for having searched many combinations before selecting a
|
|
winner, which a per-combo permutation test does not.
|
|
|
|
feature_combos: list of arrays of column indices into X_raw, one per combination to
|
|
evaluate (e.g. every single-metric and pair-metric combo from a sweep).
|
|
|
|
This is expensive: each permutation reruns LOSO-CV for every combo, so cost scales as
|
|
n_permutations * len(feature_combos) * n_subjects * n_classifiers. Keep n_permutations
|
|
and/or the combo list modest, and keep tune_hyperparameters=False (default) to keep
|
|
each individual fit cheap.
|
|
|
|
Returns: {'observed_max_acc', 'observed_best_combo_idx', 'null_max_accs', 'p_value'}
|
|
"""
|
|
if classifier_factories is None:
|
|
classifier_factories = {'LDA': CLASSIFIER_FACTORIES['LDA']}
|
|
|
|
rng = np.random.default_rng(random_state)
|
|
|
|
def _best_acc_across_combos(y_labels):
|
|
best_acc = -1.0
|
|
best_idx = -1
|
|
for combo_i, col_idx in enumerate(feature_combos):
|
|
X_sub = X_raw[:, col_idx]
|
|
sub_feature_names = [feature_names[i] for i in col_idx] if feature_names else None
|
|
accs, _, _ = run_loso_cv(
|
|
X_raw=X_sub, y=y_labels, groups=groups,
|
|
feature_names=sub_feature_names,
|
|
classifier_factories=classifier_factories,
|
|
k_features=min(k_features, X_sub.shape[1]),
|
|
tune_hyperparameters=tune_hyperparameters, scoring=scoring,
|
|
)
|
|
combo_acc = float(np.mean(list(accs.values())))
|
|
if combo_acc > best_acc:
|
|
best_acc = combo_acc
|
|
best_idx = combo_i
|
|
return best_acc, best_idx
|
|
|
|
observed_max_acc, observed_best_idx = _best_acc_across_combos(y)
|
|
|
|
unique_groups = np.unique(groups)
|
|
group_to_label = {g: y[groups == g][0] for g in unique_groups}
|
|
label_values = np.array(list(group_to_label.values()))
|
|
|
|
null_max_accs = []
|
|
for i in range(n_permutations):
|
|
shuffled = rng.permutation(label_values)
|
|
shuffled_map = dict(zip(unique_groups, shuffled))
|
|
y_perm = np.array([shuffled_map[g] for g in groups])
|
|
|
|
best_acc, _ = _best_acc_across_combos(y_perm)
|
|
null_max_accs.append(best_acc)
|
|
|
|
if progress_callback is not None:
|
|
progress_callback(i + 1, n_permutations)
|
|
|
|
null_max_accs = np.array(null_max_accs)
|
|
p_value = (1 + np.sum(null_max_accs >= observed_max_acc)) / (1 + n_permutations)
|
|
|
|
return {
|
|
'observed_max_acc': float(observed_max_acc),
|
|
'observed_best_combo_idx': int(observed_best_idx),
|
|
'null_max_accs': null_max_accs.tolist(),
|
|
'p_value': float(p_value),
|
|
}
|
|
|
|
|
|
def build_metric_keys(feature_names):
|
|
"""
|
|
Maps each raw feature column name to its short metric key (e.g. 'Peak_Amp'), by
|
|
matching the longest suffix pattern from METRIC_REGISTRY. Shared by the sweep worker
|
|
and the max-stat permutation test so combo labels (e.g. "Peak_Amp+RMS") can be mapped
|
|
back to column indices consistently in both places.
|
|
"""
|
|
metric_map = {}
|
|
for k, v in METRIC_REGISTRY.items():
|
|
metric_map[k] = k
|
|
metric_map[v] = k
|
|
sorted_patterns = sorted(metric_map.keys(), key=len, reverse=True)
|
|
|
|
keys = []
|
|
for name in feature_names:
|
|
matched = None
|
|
for pattern in sorted_patterns:
|
|
if name.endswith(f"_{pattern}"):
|
|
matched = metric_map[pattern]
|
|
break
|
|
keys.append(matched)
|
|
return np.array(keys)
|
|
|
|
|
|
class Plugin:
|
|
"""Plugin entry point contract loaded by PluginManager."""
|
|
|
|
def __init__(self, main_window: QWidget) -> None:
|
|
self.main_window = main_window
|
|
self.name = "ML Channel Builder"
|
|
self.widget_instance: DataSplitterGUI | None = None
|
|
|
|
def register_menu(self, plugin_menu: QMenu) -> None:
|
|
"""Registers plugin options into the application's top menubar."""
|
|
open_action = QAction("Open ML Tool", self.main_window)
|
|
open_action.triggered.connect(self.show_widget)
|
|
|
|
about_action = QAction("About ML Builder", self.main_window)
|
|
about_action.triggered.connect(self.show_about)
|
|
|
|
plugin_menu.addAction(open_action)
|
|
plugin_menu.addAction(about_action)
|
|
|
|
def show_widget(self) -> None:
|
|
"""Instantiates or focuses the ML Builder window."""
|
|
if self.widget_instance is None or not self.widget_instance.isVisible():
|
|
self.widget_instance = DataSplitterGUI()
|
|
self.widget_instance.show()
|
|
else:
|
|
self.widget_instance.raise_()
|
|
self.widget_instance.activateWindow()
|
|
|
|
def show_about(self) -> None:
|
|
"""Displays plugin information dynamically from plugin.json if available."""
|
|
# Fallback default values
|
|
title = "ROI Channel Builder"
|
|
version = "Unknown"
|
|
author = "Unknown"
|
|
description = (
|
|
"This plugin loads SNIRF binary files using h5py, extracts source-detector "
|
|
"channel pairs, and exports custom ROI channel group JSON files."
|
|
)
|
|
|
|
# Look for plugin.json in the same directory as this file
|
|
json_path = Path(__file__).resolve().parent / "plugin.json"
|
|
|
|
if json_path.is_file():
|
|
try:
|
|
with open(json_path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
if isinstance(data, dict):
|
|
title = data.get("name", title)
|
|
version = data.get("version", version)
|
|
author = data.get("author", author)
|
|
description = data.get("description", description)
|
|
except Exception:
|
|
# Silently catch file read/parse errors to prevent crashing
|
|
pass
|
|
|
|
# Build formatted display message
|
|
message = (
|
|
f"<b>{title}</b><br>"
|
|
f"<b>Version:</b> {version} | <b>Author:</b> {author}<br><br>"
|
|
f"{description}"
|
|
)
|
|
|
|
QMessageBox.about(
|
|
self.main_window,
|
|
f"About {title}",
|
|
message,
|
|
) |