From e7d2f5bc5798ece4b665774003890eb4cfe345bb Mon Sep 17 00:00:00 2001 From: tyler Date: Tue, 1 Sep 2026 18:20:34 -0700 Subject: [PATCH] more plugin things --- machine-learning-pack/__init__.py | 2804 ++++++++++++++++++++ machine-learning-pack/plugin.json | 10 + packages/machine-learning-pack-win-x64.zip | Bin 0 -> 95924 bytes packages/roi-builder-pack.zip | Bin 15317 -> 15681 bytes packages/test-plugin.zip | Bin 1604 -> 0 bytes plugins.json | 22 +- roi-builder-pack/__init__.py | 40 +- roi-builder-pack/plugin.json | 2 +- test-plugin/__init__.py | 68 - test-plugin/plugin.json | 10 - 10 files changed, 2852 insertions(+), 104 deletions(-) create mode 100644 machine-learning-pack/__init__.py create mode 100644 machine-learning-pack/plugin.json create mode 100644 packages/machine-learning-pack-win-x64.zip delete mode 100644 packages/test-plugin.zip delete mode 100644 test-plugin/__init__.py delete mode 100644 test-plugin/plugin.json diff --git a/machine-learning-pack/__init__.py b/machine-learning-pack/__init__.py new file mode 100644 index 0000000..bd201d4 --- /dev/null +++ b/machine-learning-pack/__init__.py @@ -0,0 +1,2804 @@ +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__ 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"{m}: {pct:.1f}% ({n_feats} feats)") + + metric_summary_text = "Metric Share: " + "  \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)" + "
" + 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"{decision_score:+.4f}." + ) + + html_parts = [ + "", + f"

{detail['sub_id']}

", + f"

Actual class: {detail['actual']}    " + f"Predicted class: {detail['pred']}    " + f"Confidence: {conf_str}    " + f"{verdict_text}

", + "

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 " + "Class_B, negative contributions push it toward Class_A.

", + f"

" + f"Why the confidence looks so extreme: 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"sum of every feature's contribution, not a vote count. Here that sum is " + f"{contribution_sum:+.4f} (net pull toward {sum_push}).{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%.

", + f"
", + f"Decision Score & Intercept Breakdown", + f"

", + f"\u2022 Total Feature Contribution: {contribution_sum:+.4f} (net pull toward {sum_push})
", + f"\u2022 Model Intercept (Baseline Bias): {intercept:+.4f} (inherent preference toward {intercept_push})
", + f"\u2022 Final Decision Score: {decision_score:+.4f} (= Feature Sum + Intercept)", + f"

", + f"

What is the Intercept? 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 {intercept:+.4f}.

", + f"

Why confidence can look extreme: A linear/logistic model passes the final decision score ({decision_score:+.4f}) 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.

", + f"
", + "
", + ] + + 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 {closer_class} training average" + + contribution = feat['contribution'] + if contribution > 1e-9: + push_text = "Class_B" + elif contribution < -1e-9: + push_text = "Class_A" + 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"

" + ) + except Exception as e: + img_tag = f"

(chart unavailable: {e})

" + + html_parts.append( + f"

#{rank}. {metric_label} \u2014 {roi_label}

" + f"

{metric_label} measures {metric_desc}.

" + f"{img_tag}" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"
This subject's value{_fmt_val(raw_val)}
Class_A training average{_fmt_val(class_a_mean)}
Class_B training average{_fmt_val(class_b_mean)}
Standardized value (z-score){feat['z']:+.2f} SD from the overall training mean
Model weight{feat['weight']:+.4f}
Contribution to decision score{contribution:+.4f}
Share of this prediction's total feature influence{feat['impact_pct']:.1f}%
" + f"

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}.

" + f"
" + ) + + html_parts.append("") + 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"{title}
" + f"Version: {version}  |  Author: {author}

" + f"{description}" + ) + + QMessageBox.about( + self.main_window, + f"About {title}", + message, + ) \ No newline at end of file diff --git a/machine-learning-pack/plugin.json b/machine-learning-pack/plugin.json new file mode 100644 index 0000000..2db1284 --- /dev/null +++ b/machine-learning-pack/plugin.json @@ -0,0 +1,10 @@ +{ + "id": "machine-learning-pack", + "name": "Machine Learning Pack", + "version": "0.1.0", + "author": "Tyler de Zeeuw", + "description": "Machine Learning on Processed fNIRS Data.", + "min_app_version": "1.7.0", + "platforms": ["win_x64"], + "download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/machine-learning-pack-win-x64.zip" +} \ No newline at end of file diff --git a/packages/machine-learning-pack-win-x64.zip b/packages/machine-learning-pack-win-x64.zip new file mode 100644 index 0000000000000000000000000000000000000000..abf3194fd24dd96eefc5d2b007e13afdfca03946 GIT binary patch literal 95924 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 HQ3L!B>DF1& literal 0 HcmV?d00001 diff --git a/packages/roi-builder-pack.zip b/packages/roi-builder-pack.zip index c8f1844bb30763e73da2790631a077d27d1b8fcc..d678450aa78a20044c114c690079ceef51845194 100644 GIT binary patch delta 7074 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 K2F5u60000l0>yy< diff --git a/packages/test-plugin.zip b/packages/test-plugin.zip deleted file mode 100644 index 195a99ad4d821e277654e7d6dcc7692262c15a26..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1604 zcmWIWW@Zs#0DDNWDJ(=V;yW?*CiNrH*c5MBoMq`2u> z%|Pq}!~tNFI2bq>ZZ)EsB#veh#6Z2Q;`}^phUUaf&jJ|?!liv1xtI(ET&fS&M+BW- zs32DMuOZ?_$BB(`k9HQ^Y&q-3`tknWitvm?%g=4cZI%l&IA2?HaD{i&alP$F(!RZv z+^d~(U-(AtYXv{oF^tvD`uWUGlu zBjZh{$&=am%QvmP9MW5KriwRcPx}PcZBOqw*cA2}UaOBg%J)_1$Z|QEvwP+3!@P}O zzh5+^#NezaUJ`BcRm;LXm_>$UmlR$y3ZF)}b9LS}gp{*Z}}&&(;F6>f7w8!cKwzA1=GcaR=Nmt$Z0ih`O2u4yCE~x#?7zjc9cq4iuDww zI-Bcdm#lKHt<_)o_Vw@5b2~jnTrZzAy}YMEHH-Os=%BL1Pj8%c@UZO9QvS6!rPe={kGiwHs`##yGOO;qjQ7*t zretS4+{@r2i|Lij7h5kvgTW$r&#XhyqbI4U?jWc`r`TjqcEy6MXj&kGn`FH2*E&2O$o1>`u%X|IbC$NY; zvAF8tyU{~u=SqVcyCpTsLvh20 zlS^lcM84aTa`&f4sHXO5zNB!6qGJyP3?v(>gpQl1v+Qr^(G=HSnU!|8w4}qTBah|f zF}7d1tNUuy8Ni-}CExiJa;NRy6~)EzMUa&P~`V>g;j<#fc-U`-?8Nbuv9?zR34g zB<()`@-ye=#r3>fc6;XUhlXJd+0OfSO!tl6$ywp>w09ZD(zjBk#rD6BEL>TpoUUcN zBXYwktG6~AL{m9h=WdKyb2svResfFci=+wDatuNy%g-+SGIv{|X6n`R4L3PuPOg}I zMPpywVk@y3_fF(K6O_>Ce!67#M3!}L>MS_Ur3>t`oZVzBlOm9i`SrG{z?3O_Ql#1B zSJ-O&ikvAdW!^Wj?e}-(z5L$mq-SZkhurkOAv&Kg;mPX2KIZH5t_3wtNV}r;^jBy0 zyvrZ_oA%6`%-d*EaZ^=4zHlx-@5W0%x&l@v#Uy`QC6LDw#B3Y;I9Ni)<9R@ZOOp4= zj2o6WOQsyLUz4XLQ)Vr5_TGDsHpam8!;cM%8GmKCY=h4 zd$sq>-x)!94Aw%T|7Itjkh;Kon`_tfHpR*RLAlqsEBkpQGXuj`PEhV;WD;S`#aH+M4F?rI0p6@^AYU>AArDZy5-W%Y0LXc6uK)l5 diff --git a/plugins.json b/plugins.json index a1db863..bab2b67 100644 --- a/plugins.json +++ b/plugins.json @@ -9,30 +9,10 @@ "platforms": ["win_x64"], "download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/machine-learning-pack-win-x64.zip" }, - { - "id": "theme-pack", - "name": "Dark Theme Pack", - "version": "1.0.0", - "author": "Tyler de Zeeuw", - "description": "Pure Python themes (cross-platform).", - "min_app_version": "1.8.0", - "platforms": ["darwin_arm64", "darwin_x64", "linux_x64"], - "download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/theme-pack-multi.zip" - }, - { - "id": "test-plugin", - "name": "Test Plugin", - "version": "1.0.0", - "description": "A basic test plugin demonstrating menu registration and custom widgets.", - "author": "Tyler de Zeeuw", - "min_app_version": "1.8.0", - "platforms": ["win_x64", "darwin_arm64", "darwin_x64", "linux_x64"], - "download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/test-plugin.zip" - }, { "id": "roi-builder-pack", "name": "ROI Channel Builder Pack", - "version": "1.0.0", + "version": "0.1.0", "description": "Extracts channel labels from SNIRF files and generates custom ROI grouping JSON files.", "author": "Tyler de Zeeuw", "min_app_version": "1.7.0", diff --git a/roi-builder-pack/__init__.py b/roi-builder-pack/__init__.py index af358d6..402ca00 100644 --- a/roi-builder-pack/__init__.py +++ b/roi-builder-pack/__init__.py @@ -9,6 +9,7 @@ License: GPL-3.0 # Built-in imports import os import json +from pathlib import Path from typing import Optional, Any # External library imports @@ -502,12 +503,43 @@ class Plugin: self.widget_instance.activateWindow() def show_about(self) -> None: - """Displays plugin information.""" + """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"{title}
" + f"Version: {version}  |  Author: {author}

" + f"{description}" + ) + QMessageBox.about( self.main_window, - "About ROI Channel Builder", - "This plugin loads SNIRF binary files using h5py, extracts source-detector channel pairs, " - "and exports custom ROI channel group JSON files.", + f"About {title}", + message, ) diff --git a/roi-builder-pack/plugin.json b/roi-builder-pack/plugin.json index 776b385..2b320f0 100644 --- a/roi-builder-pack/plugin.json +++ b/roi-builder-pack/plugin.json @@ -1,7 +1,7 @@ { "id": "roi-builder-pack", "name": "ROI Channel Builder Pack", - "version": "1.0.0", + "version": "0.1.0", "description": "Extracts channel labels from SNIRF files and generates custom ROI grouping JSON files.", "author": "Tyler de Zeeuw", "min_app_version": "1.7.0", diff --git a/test-plugin/__init__.py b/test-plugin/__init__.py deleted file mode 100644 index c5c24ff..0000000 --- a/test-plugin/__init__.py +++ /dev/null @@ -1,68 +0,0 @@ -""" -Basic Test Plugin -Creates a widget window and registers submenu items under the Plugins menu. -""" - -from PySide6.QtGui import QAction -from PySide6.QtWidgets import QLabel, QMenu, QMessageBox, QPushButton, QVBoxLayout, QWidget - - -class PluginWidget(QWidget): - """Custom widget UI owned by the plugin.""" - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setWindowTitle("Test Plugin Window") - self.resize(350, 200) - - layout = QVBoxLayout(self) - - label = QLabel("Hello from the Test Plugin!", self) - btn_action = QPushButton("Click Me", self) - btn_action.clicked.connect(self._on_button_clicked) - - layout.addWidget(label) - layout.addWidget(btn_action) - - def _on_button_clicked(self) -> None: - QMessageBox.information(self, "Plugin Interactive", "Button inside the plugin QWidget was clicked!") - - -class Plugin: - """Plugin entry point contract loaded by the main application.""" - - def __init__(self, main_window: QWidget) -> None: - self.main_window = main_window - self.name = "Test Plugin" - self.widget_instance: PluginWidget | None = None - - def register_menu(self, plugin_menu: QMenu) -> None: - """ - Populates the plugin's dedicated submenu in the main menubar. - Called by the main app after creating the submenu header. - """ - open_action = QAction("Open Tool Window", self.main_window) - open_action.triggered.connect(self.show_widget) - - about_action = QAction("About Test Plugin", 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 and displays the plugin's QWidget window.""" - if self.widget_instance is None or not self.widget_instance.isVisible(): - self.widget_instance = PluginWidget() - self.widget_instance.show() - else: - self.widget_instance.raise_() - self.widget_instance.activateWindow() - - def show_about(self) -> None: - """Secondary menu action example.""" - QMessageBox.about( - self.main_window, - "About Test Plugin", - "This is a basic test plugin demonstrating submenu registration and custom QWidget window spawning.", - ) \ No newline at end of file diff --git a/test-plugin/plugin.json b/test-plugin/plugin.json deleted file mode 100644 index 89fdd28..0000000 --- a/test-plugin/plugin.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "id": "test_plugin", - "name": "Test Plugin", - "version": "1.0.0", - "description": "A basic test plugin demonstrating menu registration and custom widgets.", - "author": "Tyler de Zeeuw", - "min_app_version": "1.7.0", - "platforms": ["win_x64", "darwin_arm64", "darwin_x64", "linux_x64"], - "download_url": "https://git.research.dezeeuw.ca/tyler/flares-plugins/raw/branch/main/packages/test-plugin.zip" -} \ No newline at end of file