diff --git a/machine-learning-pack-2/__init__.py b/machine-learning-pack-2/__init__.py new file mode 100644 index 0000000..4c33c15 --- /dev/null +++ b/machine-learning-pack-2/__init__.py @@ -0,0 +1,3918 @@ +"""ML Channel Builder plugin: N-vs-T diversity/density grid experiment (fNIRS, LDA). + +Pick a condition, label participants as Class A / Class B, and sweep every combination of +(training participants N) x (trials per participant T) x (seed), ranking feature-metric +subsets by held-out participant accuracy. All grid settings are exposed in the GUI. The best +metric set can then be exported as a joblib model bundle. A second tool (ModelInferenceGUI) +loads that bundle and classifies newly collected data in the main window; the same logic is +available in code via `load_model_bundle`, `classify_dataframe` and `classify_participant`. +""" + +# ========================================================================== +# Imports +# ========================================================================== +import html +import json +import math +import multiprocessing +import os +import platform +import re +import subprocess +import threading +import time +import traceback +from collections import Counter +from dataclasses import dataclass +from itertools import combinations +from pathlib import Path + +import joblib +import numpy as np +import pandas as pd +from openpyxl import load_workbook +from openpyxl.formatting.rule import ColorScaleRule +from openpyxl.styles import PatternFill +from openpyxl.utils import get_column_letter +from openpyxl.worksheet.table import Table, TableStyleInfo +from PySide6.QtCore import Qt, QThread, Signal +from PySide6.QtGui import QAction, QColor, QTextCursor +from PySide6.QtWidgets import ( + QApplication, QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QFrame, QGridLayout, QGroupBox, + QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMenu, QMessageBox, QPushButton, + QScrollArea, QSpinBox, QSplitter, QTableWidget, QTableWidgetItem, QTabWidget, QTextEdit, QVBoxLayout, + QWidget, +) +from scipy import stats +from sklearn.discriminant_analysis import LinearDiscriminantAnalysis +from sklearn.impute import SimpleImputer +from sklearn.metrics import balanced_accuracy_score +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler + +try: # Windows-only; absent on macOS/Linux + import winreg +except ImportError: + winreg = None + +try: # embedded plots for the Model Explanation tab (the tab falls back to tables without it) + from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT + from matplotlib.figure import Figure + from matplotlib.patches import Patch, Rectangle + MPL_OK = True +except Exception: + MPL_OK = False + + +# ========================================================================== +# Constants +# ========================================================================== +# Maps short metric keys to feature-name 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", +} + +# Chromophore tags recognised in feature names (matched as whole "_"-separated tokens) +KNOWN_CHROMOPHORES = ("hbo", "hbr", "hbt", "o2hb", "hhb") + +META_COLS = {"Participant", "Trial", "Condition", "File", "Kind", "TrialNum"} + +CLASS_NEG, CLASS_POS = "Class A", "Class B" # LDA decision score > 0 <=> CLASS_POS +UNASSIGNED = "Unassigned" +LIST_SEP = " | " # separator for participant lists stored in one results cell (paths may contain commas) +KIND_TRIAL, KIND_PARTICIPANT = "trial", "participant" + +MIN_PER_CLASS = 6 # required labelled participants per class +MAX_WORKERS = 30 # upper cap for the worker-process spinbox default +DEFAULT_WORKERS = min(os.cpu_count() or 4, MAX_WORKERS) +DEFAULT_BUDGET_TOLERANCE = 0.05 # +/- fraction around each target total-trial budget + +BUNDLE_VERSION = 1 # version of the exported joblib bundle layout + + +# ========================================================================== +# System info +# ========================================================================== +def get_cpu_model_name(): + """Detect detailed processor info, collapsing Windows registry whitespace padding.""" + try: + system = platform.system() + + if system == "Windows" and winreg is not None: + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, + r"HARDWARE\DESCRIPTION\System\CentralProcessor\0", + ) as key: + raw_cpu_name, _ = winreg.QueryValueEx(key, "ProcessorNameString") + cpu_name = " ".join(raw_cpu_name.split()) + + command = ( + "Get-CimInstance Win32_Processor | " + "Select-Object NumberOfCores, NumberOfLogicalProcessors, MaxClockSpeed | " + "ConvertTo-Json -Compress" + ) + cpu = json.loads(subprocess.check_output( + ["powershell", "-NoProfile", "-Command", command], + text=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + )) + base_ghz = cpu["MaxClockSpeed"] / 1000 + return (f"{cpu_name} @ {base_ghz:.1f}GHz " + f"{cpu['NumberOfCores']}c/{cpu['NumberOfLogicalProcessors']}t") + + if system == "Darwin": + return subprocess.check_output( + ["sysctl", "-n", "machdep.cpu.brand_string"], text=True + ).strip() + + if system == "Linux": + with open("/proc/cpuinfo") as f: + for line in f: + if "model name" in line: + return line.split(":")[1].strip() + except Exception: + pass + + return platform.processor() or "Multi-Core CPU" + + +# ========================================================================== +# Data parsing (no Qt) +# ========================================================================== +def trial_number(trial_id): + try: + return int(str(trial_id).split("_trial_")[1].split("_")[0]) + except Exception: + return 0 + + +def _strip_fir(name): + return name[4:] if name.startswith("FIR_") else name + + +def split_feature_name(name): + """Return (condition, trial_id or None, feature_key). + + 'FIR_Reach_trial_3_ROI_X_hbo_Peak_Amp' -> ('Reach', 'Reach_trial_3', 'ROI_X_hbo_Peak_Amp') + 'FIR_Reach_ROI_X_hbo_Peak_Amp' -> ('Reach', None, 'ROI_X_hbo_Peak_Amp') + """ + if "_trial_" in name: + cond_part, rest = name.split("_trial_", 1) + trial_num, _, key = rest.partition("_") + cond = _strip_fir(cond_part) + return cond, f"{cond}_trial_{trial_num}", key + + parts = name.split("_") + if len(parts) >= 3 and parts[0] == "FIR": + return parts[1], None, "_".join(parts[2:]) + return "Overall", None, name + + +def parse_fir_dict(fir_dict): + """Turn main_window.fir_feature_dict into a long table. + + One row per (participant, trial) -- or per (participant, condition) when no + trial-specific features exist (Step 21.5 not run). + + Returns (df, kind, info). kind is KIND_TRIAL if ANY file has trial features, + else KIND_PARTICIPANT, else None. Files lacking trial features are dropped from a + trial-kind dataset and listed in info['dropped']. + """ + rows = {} + for file_path, packet in (fir_dict or {}).items(): + if not isinstance(packet, dict): + continue + feats = packet.get("features", np.array([])) + names = packet.get("feature_names", []) + if len(feats) == 0 or len(feats) != len(names): + continue + + abs_path = str(Path(file_path).resolve()) + for val, name in zip(feats, names): + cond, trial_id, key = split_feature_name(str(name)) + tid = trial_id or f"{cond}_participant" + rec = rows.setdefault((abs_path, tid), { + "Participant": abs_path, + "Trial": tid, + "Condition": cond, + "File": file_path, + "Kind": KIND_TRIAL if trial_id else KIND_PARTICIPANT, + "TrialNum": trial_number(tid), + }) + rec[key] = float(val) + + if not rows: + return pd.DataFrame(), None, {"dropped": []} + + df = pd.DataFrame(list(rows.values())) + feat_cols = [c for c in df.columns if c not in META_COLS] + df[feat_cols] = df[feat_cols].replace([np.inf, -np.inf], np.nan) + + info = {"dropped": []} + if (df["Kind"] == KIND_TRIAL).any(): + kind = KIND_TRIAL + bad = df[df["Kind"] == KIND_PARTICIPANT]["Participant"].unique().tolist() + if bad: + info["dropped"] = bad + df = df[df["Kind"] == KIND_TRIAL].copy() + else: + kind = KIND_PARTICIPANT + + return df.reset_index(drop=True), kind, info + + +# ========================================================================== +# Feature-column selection (no Qt) +# ========================================================================== +def name_tokens(name): + """Lower-cased alphanumeric tokens of a feature name ('ROI_X_hbo_Peak_Amp' -> {roi, x, hbo, ...}).""" + return set(re.split(r"[^a-z0-9]+", name.lower())) - {""} + + +def detect_chromophores_in(columns): + """Chromophore tags (upper-case) that appear as a whole token in any column name.""" + found = set() + for col in columns: + found |= name_tokens(col) & set(KNOWN_CHROMOPHORES) + return sorted(t.upper() for t in found) + + +def select_columns(all_cols, metrics, chromos): + """Feature columns ending in a chosen metric suffix and tagged with a chosen chromophore. + + A chromophore matches only as a whole token, so 'hbo' selects 'ROI_X_hbo_Peak_Amp' + but not a column that merely contains the letters inside another word. + """ + wanted = {c.lower() for c in chromos} + cols = [] + for m in metrics: + suffix = f"_{METRIC_REGISTRY[m]}" + for c in all_cols: + if not c.endswith(suffix): + continue + if m == "Peak_Amp" and c.endswith("_to_Peak_Amp"): + continue + if name_tokens(c) & wanted: + cols.append(c) + return sorted(set(cols)) + + +def describe_feature_columns(all_cols, chromos): + """Diagnostics for the log: columns per chromophore, total, and metrics with no columns. + + Returns (per_chromophore_counts, total_columns, metrics_without_columns). + Counts cover all metrics, so they show exactly what each chromophore contributes. + """ + all_metrics = list(METRIC_REGISTRY) + per_chromo = {ch: len(select_columns(all_cols, all_metrics, [ch])) for ch in chromos} + total = len(select_columns(all_cols, all_metrics, chromos)) + empty = [m for m in all_metrics if not select_columns(all_cols, [m], chromos)] + return per_chromo, total, empty + + +# ========================================================================== +# Evaluation core (no Qt) +# ========================================================================== +def make_pipeline(): + return Pipeline([ + ("imputer", SimpleImputer(strategy="median")), # fitted on training data only + ("scaler", StandardScaler()), + ("lda", LinearDiscriminantAnalysis(solver="lsqr", shrinkage="auto")), + ]) + + +def _fit(train_df, cols, classes): + X = train_df[cols].to_numpy(float) + y = train_df["Participant"].map(classes).to_numpy() + if len(set(y)) < 2: + raise ValueError("Training data contains only one class.") + pipe = make_pipeline() + pipe.fit(X, y) + if list(pipe.classes_) != [CLASS_NEG, CLASS_POS]: + raise ValueError(f"Unexpected class order {list(pipe.classes_)}") + return pipe + + +def evaluate_feature_set(train_df, test_df, cols, classes): + """Fit on train_df, score test_df. Returns participant- and trial-level metrics. + + Participant prediction = sign of the mean trial decision score (> 0 -> Class B). + """ + pipe = _fit(train_df, cols, classes) + scores = pipe.decision_function(test_df[cols].to_numpy(float)) + + trial_true = test_df["Participant"].map(classes).to_numpy() + trial_pred = np.where(scores > 0, CLASS_POS, CLASS_NEG) + + mean_scores = ( + pd.DataFrame({"Participant": test_df["Participant"].to_numpy(), "Score": scores}) + .groupby("Participant")["Score"].mean() + ) + part_true = mean_scores.index.map(classes).to_numpy() + part_pred = np.where(mean_scores.to_numpy() > 0, CLASS_POS, CLASS_NEG) + + part_acc = float(np.mean(part_true == part_pred)) + try: + part_bal = float(balanced_accuracy_score(part_true, part_pred)) + except Exception: + part_bal = part_acc + wrong = [str(p) for p in mean_scores.index[part_true != part_pred]] + + return { + "Wrong_Participants": wrong, + "Part_Correct": int(np.sum(part_true == part_pred)), + "Part_Total": int(len(part_true)), + "Part_Acc": part_acc, + "Part_Bal_Acc": part_bal, + "Trial_Acc": float(np.mean(trial_true == trial_pred)), + "N_Features": len(cols), + } + + +def metric_sets_per_task(top_k, n_metrics=len(METRIC_REGISTRY)): + """How many metric sets one grid configuration evaluates (used for the GUI estimate). + + singles + all pairs + every subset of size >= 3 drawn from the top_k single metrics + (subsets of size 1 and 2 of the top_k were already covered, so they are not repeated). + """ + k = min(top_k, n_metrics) + expanded = sum(math.comb(k, s) for s in range(3, k + 1)) + return n_metrics + math.comb(n_metrics, 2) + expanded + + +def parse_metric_list(text): + """'rms, ttp' -> (('RMS', 'TTP'), []). Returns (valid metric keys in order, unknown tokens).""" + keys = {k.lower(): k for k in METRIC_REGISTRY} + good, bad = [], [] + for tok in re.split(r"[,;\s]+", str(text).strip()): + if not tok: + continue + key = keys.get(tok.lower()) + if key: + good.append(key) + else: + bad.append(tok) + return tuple(dict.fromkeys(good)), bad + + +def run_metric_sweep(train_df, test_df, classes, all_feature_cols, chromos, top_k=5, + fixed_metrics=()): + """Metric sweep for one train/test split. + + With fixed_metrics (stage 2) NO sweep happens: exactly that one metric set is evaluated, + so nothing about the held-out participants influences which features are used. + + Phase 1: every single metric (10). + Phase 2: every pair (45). + Phase 3: take the top_k single metrics from phase 1 and evaluate every subset of them + of size 3..top_k (for top_k = 5: 10 + 5 + 1 = 16 new sets). Subsets of size 1-2 + were already done in phases 1-2. Order within a set is irrelevant to the model, + so these are combinations, not permutations. + + Returns a DataFrame ranked by (Part_Bal_Acc, Trial_Acc), empty if nothing evaluated. + """ + metric_cols = {m: select_columns(all_feature_cols, [m], chromos) for m in METRIC_REGISTRY} + usable = [m for m, cols in metric_cols.items() if cols] + results, seen = [], set() + + def run_combo(metrics): + key = ", ".join(sorted(metrics)) + if key in seen: + return + cols = sorted(set().union(*(metric_cols[m] for m in metrics))) + try: + rec = evaluate_feature_set(train_df, test_df, cols, classes) + except Exception: + return + seen.add(key) + results.append({"Metrics": key, **rec}) + + def ranked(): + return (pd.DataFrame(results) + .sort_values(["Part_Bal_Acc", "Trial_Acc"], ascending=False, kind="mergesort") + .reset_index(drop=True)) + + if fixed_metrics: # Stage 2: one pre-specified set + if all(m in usable for m in fixed_metrics): + run_combo(list(fixed_metrics)) + return ranked() if results else pd.DataFrame() + + for m in usable: # Phase 1: singles + run_combo([m]) + if not results: + return pd.DataFrame() + + top_singles = ranked().head(top_k)["Metrics"].tolist() # best top_k single metrics + + for combo in combinations(usable, 2): # Phase 2: all pairs + run_combo(list(combo)) + + for size in range(3, len(top_singles) + 1): # Phase 3: subsets of the top_k + for combo in combinations(top_singles, size): + run_combo(list(combo)) + + return ranked() + + +def _participant_rows(sub_df, t_max=None): + """A participant's rows for one condition; with t_max, only the first t_max trials. + + "First" means lowest TrialNum, i.e. true chronological order (trial 2 before trial 10). + Whole-participant data (no trial rows) is always returned unchanged. + """ + if t_max is None or (sub_df["Kind"] == KIND_PARTICIPANT).all(): + return sub_df + ordered = sub_df.sort_values("TrialNum", kind="stable") + return ordered.iloc[:t_max] + + +def _run_grid_task(task): + """Worker-process entry point: run one configuration, return its top-3 metric sets.""" + cond_df = task["cond_df"] + classes = task["class_assign"] + train_parts = task["train_parts"] + test_parts = task["test_participants"] + + def rows_for(sub, t_max=None): + return _participant_rows(cond_df[cond_df["Participant"] == sub], t_max) + + train_df = pd.concat([rows_for(s, task["t_max"]) for s in train_parts], ignore_index=True) + test_df = pd.concat([rows_for(s) for s in test_parts], ignore_index=True) + + ranked = run_metric_sweep( + train_df, test_df, classes, task["all_feature_cols"], task["selected_chromos"], + top_k=task["top_k_expand"], fixed_metrics=task["fixed_metrics"], + ) + if ranked.empty: + return [] + + test_str = LIST_SEP.join(sorted(str(p) for p in test_parts)) + return [ + { + "Run_ID": int(task["run_id"]), + "Seed": int(task["seed"]), + "Rank": rank, + "Test_K_Per_Class": int(task["test_k"]), + "Metrics": str(row["Metrics"]), + "N_Features": int(row["N_Features"]), + "Balanced": bool(task["balanced"]), + "N_Train_Subjects": len(train_parts), + "T_Trials_Per_Subject": int(task["t_max"]), + "Train_Trials_Actual": int(len(train_df)), + "Part_Correct": int(row["Part_Correct"]), + "Part_Total": int(row["Part_Total"]), + "Part_Accuracy": float(row["Part_Acc"]), + "Part_Bal_Accuracy": float(row["Part_Bal_Acc"]), + "Trial_Accuracy": float(row["Trial_Acc"]), + "Test_Participants": test_str, + "Misclassified": LIST_SEP.join(sorted(row["Wrong_Participants"])), + } + for rank, (_, row) in enumerate(ranked.head(3).iterrows(), start=1) + ] + + +# ========================================================================== +# Best-model selection, export and reuse (no Qt) +# ========================================================================== +def rank_metric_sets(summary_df): + """Leaderboard of metric sets across every grid configuration. + + Top1_Count : configurations in which the set ranked first + Top3_Count : configurations in which it made the top 3 + Mean_Part_Bal_Acc : mean held-out participant balanced accuracy over those appearances + + Sorted by Top1_Count, then Top3_Count, then mean accuracy. Frequency comes first because + a mean over only the appearances is biased upward (a set is only logged when it did well). + """ + board = ( + summary_df.groupby("Metrics") + .agg(Top1_Count=("Rank", lambda r: int((r == 1).sum())), + Top3_Count=("Rank", "size"), + Mean_Part_Bal_Acc=("Part_Bal_Accuracy", "mean")) + .reset_index() + .sort_values(["Top1_Count", "Top3_Count", "Mean_Part_Bal_Acc"], ascending=False) + .reset_index(drop=True) + ) + return board + + +def leaderboard_lines(board, n=5): + """Log lines for the top n metric sets, padded so every column lines up (monospace log).""" + top = board.head(n) + name_w = max(len(str(m)) for m in top["Metrics"]) + c1_w = max(len(str(c)) for c in top["Top1_Count"]) + c3_w = max(len(str(c)) for c in top["Top3_Count"]) + lines = ["\n[Best metric sets across all configurations]:"] + for i, row in enumerate(top.itertuples(index=False), start=1): + lines.append( + f" {i:>{len(str(len(top)))}}. {row.Metrics:<{name_w}} | " + f"1st in {row.Top1_Count:>{c1_w}} / top-3 in {row.Top3_Count:>{c3_w}} configs | " + f"mean bal. acc {row.Mean_Part_Bal_Acc * 100:6.2f}%") + return lines + + +def problem_participants(summary_df, classes, n=5): + """Held-out participants most often misclassified. + + Uses the rank-1 (best) metric set of every configuration. Times_Tested counts the + configurations in which the participant was held out; Times_Wrong counts those where the + best set got them wrong. Returns the n worst by error rate (empty DataFrame if no data). + """ + top = summary_df[summary_df["Rank"] == 1] + if top.empty or "Misclassified" not in top.columns: + return pd.DataFrame() + + def split(cell): + return [p for p in cell.split(LIST_SEP) if p] if isinstance(cell, str) else [] + + tested, wrong = Counter(), Counter() + for t_cell, w_cell in zip(top["Test_Participants"], top["Misclassified"]): + tested.update(split(t_cell)) + wrong.update(split(w_cell)) + + board = pd.DataFrame([ + {"Participant": p, "True_Class": classes.get(p, "?"), "Times_Tested": cnt, + "Times_Wrong": wrong.get(p, 0), "Error_Rate": wrong.get(p, 0) / cnt} + for p, cnt in tested.items() + ]) + return (board.sort_values(["Error_Rate", "Times_Wrong"], ascending=False, kind="mergesort") + .head(n).reset_index(drop=True)) + + +def problem_participant_lines(board): + """Log lines for problem_participants(); paths are shortened to parent/file.""" + def short(p): + parts = Path(p).parts + return "/".join(parts[-2:]) if len(parts) > 1 else p + + if board.empty: + return [] + name_w = max(len(short(p)) for p in board["Participant"]) + lines = ["\n[Most frequently misclassified held-out participants " + "(by each configuration's best metric set)]:"] + for i, r in enumerate(board.itertuples(index=False), start=1): + lines.append( + f" {i}. {short(r.Participant):<{name_w}} | {r.True_Class} | " + f"wrong in {r.Times_Wrong}/{r.Times_Tested} tests ({r.Error_Rate * 100:.1f}%)") + return lines + + +def train_final_model(df, condition, classes, metrics_str, all_feature_cols, chromos, + class_hints=None, selection=None): + """Fit the chosen metric set on ALL labelled participants (all trials) and bundle it. + + Returns a dict ready for joblib.dump. Reload it with load_model_bundle and apply it + with classify_participant. + """ + metrics = [m.strip() for m in metrics_str.split(",")] + cols = select_columns(all_feature_cols, metrics, chromos) + if not cols: + raise ValueError(f"No feature columns match metrics [{metrics_str}] and chromophores {list(chromos)}.") + + cond_df = df[df["Condition"] == condition] + frames = [_participant_rows(cond_df[cond_df["Participant"] == s]) for s in classes] + train_df = pd.concat([f for f in frames if len(f)], ignore_index=True) + pipe = _fit(train_df, cols, classes) + + per_class = train_df.drop_duplicates("Participant")["Participant"].map(classes).value_counts() + return { + "bundle_version": BUNDLE_VERSION, + "created": time.strftime("%Y-%m-%d %H:%M:%S"), + "pipeline": pipe, + "feature_columns": cols, # order matters: the pipeline expects exactly this order + "metrics": metrics, + "chromophores": list(chromos), + "condition": condition, + "class_neg": CLASS_NEG, + "class_pos": CLASS_POS, + "class_hints": class_hints or {}, + "n_train_participants": {CLASS_NEG: int(per_class.get(CLASS_NEG, 0)), + CLASS_POS: int(per_class.get(CLASS_POS, 0))}, + "n_train_rows": int(len(train_df)), + "selection": selection or {}, + "decision_rule": ("Participant score = mean LDA decision score over their trials; " + "> 0 -> class_pos, else class_neg."), + } + + +def load_model_bundle(path): + """Load a bundle written by the plugin. Only load files you trust (joblib uses pickle).""" + bundle = joblib.load(path) + if not isinstance(bundle, dict) or "pipeline" not in bundle or "feature_columns" not in bundle: + raise ValueError("Not a valid ML Channel Builder model bundle.") + if bundle.get("bundle_version") != BUNDLE_VERSION: + raise ValueError(f"Unsupported bundle version {bundle.get('bundle_version')} " + f"(expected {BUNDLE_VERSION}).") + return bundle + + +def missing_feature_columns(bundle, df): + """Model feature columns that are absent from df (the data cannot be scored without them).""" + return [c for c in bundle["feature_columns"] if c not in df.columns] + + +def trial_scores(bundle, rows_df): + """LDA decision score for every row (> 0 -> class_pos). Missing values are imputed by the + pipeline using the training medians; entirely missing columns are an error.""" + missing = missing_feature_columns(bundle, rows_df) + if missing: + raise ValueError(f"{len(missing)} required feature column(s) missing, e.g. {missing[:3]}") + if rows_df.empty: + raise ValueError("No trial rows supplied.") + X = rows_df[bundle["feature_columns"]].replace([np.inf, -np.inf], np.nan).to_numpy(float) + return bundle["pipeline"].decision_function(X) + + +def classify_dataframe(bundle, df): + """Score every row of df (already filtered to bundle['condition']) and summarise per participant. + + Returns (trials_df, participants_df). A participant's prediction is the sign of the mean + of their trial scores, exactly as in the grid evaluation. Missing_Frac is the share of the + model's feature values that were missing (and therefore imputed) for that participant. + """ + cols = bundle["feature_columns"] + neg, pos = bundle["class_neg"], bundle["class_pos"] + if "TrialNum" in df.columns: + df = df.sort_values(["Participant", "TrialNum"], kind="stable") + scores = trial_scores(bundle, df) + + trials = df[["Participant", "Trial", "Condition"]].copy() + trials["Score"] = scores + trials["Predicted_Class"] = np.where(scores > 0, pos, neg) + trials["Missing_Frac"] = df[cols].replace([np.inf, -np.inf], np.nan).isna().mean(axis=1).to_numpy() + + parts = ( + trials.groupby("Participant", sort=True) + .agg(N_Trials=("Score", "size"), + Mean_Score=("Score", "mean"), + Frac_Trials_Class_B=("Score", lambda s: float((s > 0).mean())), + Missing_Frac=("Missing_Frac", "mean")) + .reset_index() + ) + parts["Predicted_Class"] = np.where(parts["Mean_Score"] > 0, pos, neg) + return trials.drop(columns="Missing_Frac").reset_index(drop=True), parts + + +def classify_participant(bundle, trial_df): + """Classify one participant from their trial rows. + + trial_df: that participant's rows for bundle['condition'], in the format produced by + parse_fir_dict (one row per trial, feature columns named as in training). Example: + + df, _, _ = parse_fir_dict(main_window.fir_feature_dict) + rows = df[(df["File"] == my_file) & (df["Condition"] == bundle["condition"])] + result = classify_participant(load_model_bundle("best_model.joblib"), rows) + """ + scores = trial_scores(bundle, trial_df) + mean_score = float(np.mean(scores)) + return { + "Predicted_Class": bundle["class_pos"] if mean_score > 0 else bundle["class_neg"], + "Mean_Score": mean_score, + "Frac_Trials_Class_B": float(np.mean(scores > 0)), + "N_Trials": int(len(scores)), + } + + +# ========================================================================== +# Iso-budget analysis and Excel export (no Qt) +# ========================================================================== +@dataclass(frozen=True) +class BudgetTier: + """A matched total-trial budget (N x T) compared across few-participants/many-trials + (low N) versus many-participants/few-trials (high N). + + Only (N, T) cells whose total lands within target +/- tolerance count, so both arms are + compared at (nearly) the same number of training trials. N is the TOTAL number of + training participants (both classes). + """ + name: str + target: int + tolerance: float # fraction, e.g. 0.05 = +/-5 % + low_max_n: int + low_min_t: int + high_min_n: int + high_max_t: int + fill: str # Excel highlight colour + + @property + def min_total(self): + return math.ceil(self.target * (1 - self.tolerance) - 1e-9) + + @property + def max_total(self): + return math.floor(self.target * (1 + self.tolerance) + 1e-9) + + def masks(self, n, t): + """(low_mask, high_mask) for scalars or pandas Series of N and T.""" + total = n * t + in_budget = (total >= self.min_total) & (total <= self.max_total) + low = in_budget & (n <= self.low_max_n) & (t >= self.low_min_t) + high = in_budget & (n >= self.high_min_n) & (t <= self.high_max_t) + return low, high + + @property + def criteria(self): + return (f"Low-Density: N ≤ {self.low_max_n}, T ≥ {self.low_min_t} | " + f"High-Density: N ≥ {self.high_min_n}, T ≤ {self.high_max_t} " + f"(Matched Training Volume: {self.min_total}-{self.max_total} Total Trials)") + + +# (target, low_max_n, low_min_t, high_min_n, high_max_t, fill) +_TIER_SPECS = ( + (72, 8, 8, 16, 5, "FCE4D6"), # e.g. 6x12, 8x9 vs 18x4, 24x3 (soft orange) + (96, 8, 12, 16, 6, "D9E1F2"), # e.g. 6x16, 8x12 vs 16x6, 24x4 (soft blue) + (144, 10, 14, 20, 8, "E2EFDA"), # e.g. 8x18, 10x14 vs 20x7, 24x6 (soft green) +) + + +def make_budget_tiers(tolerance=DEFAULT_BUDGET_TOLERANCE): + return tuple( + BudgetTier( + name=f"Permutation {i}: Target Budget ~{target} Trials (±{tolerance * 100:g}%)", + target=target, tolerance=tolerance, low_max_n=low_n, low_min_t=low_t, + high_min_n=high_n, high_max_t=high_t, fill=fill) + for i, (target, low_n, low_t, high_n, high_t, fill) in enumerate(_TIER_SPECS, start=1)) + + +DEFAULT_TIERS = make_budget_tiers() + + +def cell_in_budget(tiers, n, t): + """True if the (total N, T) cell belongs to the low or high arm of any budget tier.""" + return any(any(tier.masks(n, t)) for tier in tiers) + + +SHEET_GRID = "Grid Results" +SHEET_ACC = "Strategy Pivot (Balanced)" +SHEET_TRIALS = "Total Training Trials" + + +def _significance_label(p): + if p < 0.001: + return f"Statistically Significant (p = {p:.2e} < 0.001) ***" + if p < 0.01: + return f"Statistically Significant (p = {p:.4f} < 0.01) **" + if p < 0.05: + return f"Statistically Significant (p = {p:.4f} < 0.05) *" + return f"Not Statistically Significant (p = {p:.4f} >= 0.05)" + + +def build_pivots(summary_df): + """Balanced-runs pivots (N x T): mean participant balanced accuracy and total trials.""" + balanced = summary_df[summary_df["Balanced"]] + kwargs = dict(index="N_Train_Subjects", columns="T_Trials_Per_Subject", aggfunc="mean") + return ( + balanced.pivot_table(values="Part_Bal_Accuracy", **kwargs), + balanced.pivot_table(values="Total_Trials", **kwargs), + ) + + +def iso_budget_report(summary_df, tiers=DEFAULT_TIERS, fixed=False): + """Log lines comparing high-N/low-T against low-N/high-T at matched trial budgets. + + fixed=True labels the numbers as coming from a pre-specified metric set (stage 2). + + Paired by seed: every seed uses ONE held-out set for all (N, T) cells, so for each seed + the rank-1 balanced accuracy is averaged within each arm and the two arms are compared on + that seed. The test is then a paired test over seeds (n = number of seeds), which removes + held-out-set difficulty and does not count the top-3 rows of a configuration as + independent samples. + """ + best = summary_df[summary_df["Balanced"] & (summary_df["Rank"] == 1)] + n, t = best["N_Train_Subjects"], best["T_Trials_Per_Subject"] + lines = [] + + for tier in tiers: + low_mask, high_mask = tier.masks(n, t) + low_by_seed = best.loc[low_mask].groupby("Seed")["Part_Bal_Accuracy"].mean().rename("low") + high_by_seed = best.loc[high_mask].groupby("Seed")["Part_Bal_Accuracy"].mean().rename("high") + paired = pd.concat([low_by_seed, high_by_seed], axis=1).dropna() + + lines += [f"\n-> {tier.name}", f" Criteria: {tier.criteria}"] + if len(paired) < 2: + lines.append(" -> Insufficient paired seeds for statistical testing in this budget tier.") + continue + + diff = paired["high"] - paired["low"] + k = len(paired) + mean_d, sd_d = float(diff.mean()), float(diff.std(ddof=1)) + lines += [ + f" • Low-Diversity / High-Density: Mean Acc = {paired['low'].mean() * 100:.2f}% " + f"({'pre-specified metric set' if fixed else 'best set per seed'}, {k} paired seeds)", + f" • High-Diversity / Low-Density: Mean Acc = {paired['high'].mean() * 100:.2f}%", + ] + + if sd_d > 0: + t_stat, p_val = stats.ttest_1samp(diff, 0.0) + half = float(stats.t.ppf(0.975, k - 1)) * sd_d / math.sqrt(k) + lines.append( + f" -> Paired difference (high - low): {mean_d * 100:+.2f}% " + f"[95% CI {(mean_d - half) * 100:+.2f}, {(mean_d + half) * 100:+.2f}] | " + f"t({k - 1}) = {t_stat:.2f}, Cohen's dz = {mean_d / sd_d:.2f}") + try: + w_p = stats.wilcoxon(diff).pvalue + lines.append(f" -> Wilcoxon signed-rank p = {w_p:.4g} " + f"(seeds: high better {int((diff > 0).sum())}, " + f"low better {int((diff < 0).sum())}, tied {int((diff == 0).sum())})") + except ValueError: + pass + lines.append(f" -> Verdict: {_significance_label(p_val)}") + else: + lines.append(f" -> Paired difference (high - low): {mean_d * 100:+.2f}% " + f"(identical in every seed; no variance to test)") + + return lines + + +def _format_grid_sheet(ws): + narrow = {"Run_ID", "Seed", "Rank", "Test_K_Per_Class", "N_Features", "Balanced"} + percent_like = {"Part_Accuracy", "Part_Bal_Accuracy", "Trial_Accuracy"} + count_like = {"Total_Trials", "Train_Trials_Actual"} + + for col_idx in range(1, ws.max_column + 1): + header = ws.cell(row=1, column=col_idx).value + if header in narrow: + width = 9.25 + elif header == "Metrics": + width = 18.5 * 1.75 + else: + width = 18.5 + ws.column_dimensions[get_column_letter(col_idx)].width = width + + if ws.max_row <= 1: + return + + table = Table(displayName="DensityGridResults", + ref=f"A1:{get_column_letter(ws.max_column)}{ws.max_row}") + table.tableStyleInfo = TableStyleInfo( + name="TableStyleMedium9", showFirstColumn=False, showLastColumn=False, + showRowStripes=True, showColumnStripes=True) + ws.add_table(table) + + for col_idx in range(1, ws.max_column + 1): + header = ws.cell(row=1, column=col_idx).value + if header in percent_like or header in count_like: + fmt = "#,##0" if header in count_like else "0.0000" + for row_idx in range(2, ws.max_row + 1): + cell = ws.cell(row=row_idx, column=col_idx) + if isinstance(cell.value, (int, float)): + cell.number_format = fmt + + +def _format_pivot_sheet(ws, number_format, heatmap=False): + for col_idx in range(1, ws.max_column + 1): + ws.column_dimensions[get_column_letter(col_idx)].width = 14.0 + + if ws.max_row <= 1 or ws.max_column <= 1: + return + + if heatmap: + ws.conditional_formatting.add( + f"B2:{get_column_letter(ws.max_column)}{ws.max_row}", + ColorScaleRule( + start_type="num", start_value=0.50, start_color="F8696B", + mid_type="num", mid_value=0.75, mid_color="FFEB84", + end_type="num", end_value=1.00, end_color="63BE7B"), + ) + + for r in range(2, ws.max_row + 1): + for c in range(2, ws.max_column + 1): + cell = ws.cell(row=r, column=c) + if isinstance(cell.value, (int, float)): + cell.number_format = number_format + + +def _highlight_budget_tiers(ws, tiers): + """Colour each (N, T) cell by the first budget tier it belongs to.""" + fills = [PatternFill(start_color=t.fill, end_color=t.fill, fill_type="solid") + for t in tiers] + for r in range(2, ws.max_row + 1): + n = ws.cell(row=r, column=1).value + if not isinstance(n, (int, float)): + continue + for c in range(2, ws.max_column + 1): + t = ws.cell(row=1, column=c).value + if not isinstance(t, (int, float)): + continue + for tier, fill in zip(tiers, fills): + if any(tier.masks(n, t)): + ws.cell(row=r, column=c).fill = fill + break + + +def export_results_xlsx(path, summary_df, pivot_acc=None, pivot_trials=None, tiers=DEFAULT_TIERS): + """Write grid results plus the two pivot tabs, formatted and tier-highlighted.""" + def usable(p): + return p is not None and not p.empty + + with pd.ExcelWriter(path, engine="openpyxl") as writer: + summary_df.to_excel(writer, index=False, sheet_name=SHEET_GRID) + if usable(pivot_acc): + pivot_acc.to_excel(writer, sheet_name=SHEET_ACC) + if usable(pivot_trials): + pivot_trials.to_excel(writer, sheet_name=SHEET_TRIALS) + + wb = load_workbook(path) + _format_grid_sheet(wb[SHEET_GRID]) + if usable(pivot_acc): + _format_pivot_sheet(wb[SHEET_ACC], "0.0000", heatmap=True) + _highlight_budget_tiers(wb[SHEET_ACC], tiers) + if usable(pivot_trials): + _format_pivot_sheet(wb[SHEET_TRIALS], "#,##0") + _highlight_budget_tiers(wb[SHEET_TRIALS], tiers) + wb.save(path) + + +# ========================================================================== +# Grid settings and planning (no Qt) +# ========================================================================== +# -------------------------------------------------------------------------- +# Null-run and learning-curve reporting (no Qt) +# -------------------------------------------------------------------------- +def per_seed_accuracy(summary_df): + """Mean rank-1 balanced participant accuracy of each seed (over its balanced cells).""" + best = summary_df[summary_df["Balanced"] & (summary_df["Rank"] == 1)] + return best.groupby("Seed")["Part_Bal_Accuracy"].mean() + + +def settings_differences(a, b): + """Names of the settings that differ between two runs (the ones that matter for comparing).""" + keys = ("n_min", "n_max", "t_min", "t_max", "test_k", "budget_only", "budget_tolerance", + "fixed_metrics", "top_k_expand", "include_unbalanced") + return [k for k in keys if getattr(a, k) != getattr(b, k)] + + +def null_report_lines(null_df, null_settings, real_df=None, real_settings=None): + """Summarise a label-shuffle run and compare it with the latest real-label run.""" + ns = per_seed_accuracy(null_df) + if ns.empty: + return ["The null run produced no balanced results."] + q95, q99 = ns.quantile([0.95, 0.99]) + excess = float(ns.mean()) - 0.5 + lines = [ + "Class labels were shuffled across participants (same class sizes each seed).", + "With no real signal every accuracy should sit at 50%; anything above that is bias " + "built into the pipeline.", + f" Null accuracy per seed (mean over cells): mean {ns.mean() * 100:.1f}% | " + f"SD {ns.std(ddof=1) * 100:.1f} | 95th pct {q95 * 100:.1f}% | 99th pct {q99 * 100:.1f}% | " + f"max {ns.max() * 100:.1f}% ({len(ns)} permutations)", + f" Excess over chance: {excess * 100:+.1f} points" + + (" (selecting among metric sets inflates even random labels)" + if not null_settings.fixed_metrics and excess > 0.02 else ""), + ] + if real_df is None or real_df.empty or real_settings is None: + lines.append(" No real-label run in this session to compare with; run the real labels with " + "the same settings.") + return lines + real_mean = float(per_seed_accuracy(real_df).mean()) + k = int((ns >= real_mean).sum()) + lines += [ + f" Real labels (last real run): mean {real_mean * 100:.1f}%", + f" Null permutations at or above the real mean: {k} of {len(ns)} -> permutation " + f"p <= {(1 + k) / (1 + len(ns)):.4f}", + " (Conservative: each null value is a single split, while the real mean averages many.)", + ] + diff = settings_differences(null_settings, real_settings) + if diff: + lines.append(f" Warning: these settings differ from the real run ({', '.join(diff)}), so the " + f"comparison is only rough.") + return lines + + +def learning_curve_table(summary_df): + """(table, T) of accuracy vs training people, or None unless the run used a single T.""" + best = summary_df[summary_df["Balanced"] & (summary_df["Rank"] == 1)] + if best.empty or best["T_Trials_Per_Subject"].nunique() != 1 or best["N_Train_Subjects"].nunique() < 4: + return None + g = best.groupby("N_Train_Subjects")["Part_Bal_Accuracy"] + tbl = pd.DataFrame({"N_Total": g.mean().index.to_numpy(), "Mean": g.mean().to_numpy(), + "SE": (g.std(ddof=1) / np.sqrt(g.size())).to_numpy(), + "Seeds": g.size().to_numpy()}) + tbl["N_Per_Class"] = tbl["N_Total"] / 2.0 + return tbl.sort_values("N_Total").reset_index(drop=True), int(best["T_Trials_Per_Subject"].iloc[0]) + + +def learning_curve_fit(tbl): + """Least-squares fit acc = a - b / N_per_class (descriptive; 1/N is the shape the variance + model predicts for the error of the class means). Returns (a, b).""" + slope, intercept = np.polyfit(1.0 / tbl["N_Per_Class"].to_numpy(), tbl["Mean"].to_numpy(), 1) + return float(intercept), float(-slope) + + +def learning_curve_lines(summary_df): + """Log lines for a single-T run: the curve, its fitted plateau, and whether it is still rising.""" + res = learning_curve_table(summary_df) + if res is None: + return [] + tbl, t_val = res + lines = [f"\n[Learning curve at T = {t_val} trials per person: accuracy vs training people per class]"] + for r in tbl.itertuples(index=False): + lines.append(f" {r.N_Per_Class:>5g} per class (N = {int(r.N_Total):>2d}): {r.Mean * 100:5.1f}% " + f"± {r.SE * 100:.1f} (SE over {int(r.Seeds)} seeds)") + a, b = learning_curve_fit(tbl) + if b > 0: + lines.append(f" Fit (descriptive): accuracy ≈ {a * 100:.1f}% − {b * 100:.1f}% / N_per_class. " + f"Asymptote ≈ {a * 100:.1f}%; within 1 point of it from about {b / 0.01:.0f} per class, " + f"within 0.5 point from about {b / 0.005:.0f}.") + else: + lines.append(" The fit shows no rise with the number of training people.") + best = summary_df[summary_df["Balanced"] & (summary_df["Rank"] == 1)] + piv = best.pivot_table(index="Seed", columns="N_Train_Subjects", values="Part_Bal_Accuracy") + levels = sorted(piv.columns) + if len(levels) >= 4: + hi, lo = levels[-1], levels[-4] + d = (piv[hi] - piv[lo]).dropna() + if len(d) > 2 and d.std(ddof=1) > 0: + half = float(stats.t.ppf(0.975, len(d) - 1)) * float(d.std(ddof=1)) / math.sqrt(len(d)) + lines.append( + f" Still rising? {lo / 2:g} -> {hi / 2:g} per class (paired by seed): " + f"{d.mean() * 100:+.1f} points [95% CI {(d.mean() - half) * 100:+.1f}, " + f"{(d.mean() + half) * 100:+.1f}]. Seeds resample the same people, so this interval " + f"reflects split noise, not uncertainty about new people.") + return lines + + +@dataclass(frozen=True) +class GridSettings: + """Every tunable of the N-vs-T grid experiment (all exposed in the GUI).""" + n_min: int = 3 # training participants PER CLASS, lower bound + n_max: int = 14 # ... upper bound + t_min: int = 2 # trials per training participant, lower bound + t_max: int = 18 # ... upper bound + test_k: int = 3 # held-out participants per class + n_seeds: int = 300 # Monte Carlo seeds + seed_start: int = 0 + include_unbalanced: bool = False # also run "all remaining Class B" training sets + budget_only: bool = True # only (N, T) cells used by the budget tiers + budget_tolerance: float = DEFAULT_BUDGET_TOLERANCE + top_k_expand: int = 5 # expand this many best single metrics into all 3+ subsets (0/1/2 = off) + fixed_metrics: tuple = () # stage 2: evaluate ONLY this metric set (no sweep, no selection) + shuffle_labels: bool = False # null run: give each seed a random relabelling of the people + workers: int = DEFAULT_WORKERS + + @property + def tiers(self): + return make_budget_tiers(self.budget_tolerance) + + @property + def balance_modes(self): + return (True, False) if self.include_unbalanced else (True,) + + +def _is_pruned(n_sub, t_max): + """Skip extreme corners (ultra-low N with ultra-low T, ultra-high both).""" + return (n_sub <= 3 and t_max <= 3) or (n_sub >= 12 and t_max >= 18) + + +def plan_cells(settings, pool_a, pool_b): + """The (N, T) cells a run will cover, given the per-class pool sizes left after hold-out. + + Returns a list of (balanced, n_sub, n_b, [t values]). N in the budget check is the total + number of training participants (class A + class B), like N_Train_Subjects in the results. + """ + tiers = settings.tiers + cells = [] + for balanced in settings.balance_modes: + for n_sub in range(settings.n_min, settings.n_max + 1): + if n_sub > pool_a or (balanced and n_sub > pool_b): + continue + n_b = n_sub if balanced else pool_b + n_total = n_sub + n_b + ts = [t for t in range(settings.t_min, settings.t_max + 1) + if not _is_pruned(n_sub, t) + and (not settings.budget_only or cell_in_budget(tiers, n_total, t))] + if ts: + cells.append((balanced, n_sub, n_b, ts)) + return cells + + +def estimate_workload(settings, n_class_a, n_class_b): + """(configurations, metric sets per configuration) for the current settings and labels.""" + if min(n_class_a, n_class_b) <= settings.test_k: + return 0, 0 + cells = plan_cells(settings, n_class_a - settings.test_k, n_class_b - settings.test_k) + configs = settings.n_seeds * sum(len(ts) for *_, ts in cells) + sets_each = 1 if settings.fixed_metrics else metric_sets_per_task(settings.top_k_expand) + return configs, sets_each + + +# ========================================================================== +# Background worker: N vs T grid +# ========================================================================== +def _format_seconds(sec): + """'45s', '7m 16s' or '2h 05m 09s' - larger units only when needed.""" + sec = int(sec) + hours, rem = divmod(sec, 3600) + mins, secs = divmod(rem, 60) + if hours: + return f"{hours}h {mins:02d}m {secs:02d}s" + if mins: + return f"{mins}m {secs:02d}s" + return f"{secs}s" + + +class DiversityDensityGridWorker(QThread): + log_signal = Signal(str) + progress_log_signal = Signal(str) + finished_signal = Signal(object) # summary DataFrame (partial if cancelled) + + def __init__(self, df, class_assign, all_feature_cols, selected_chromos, condition, settings): + super().__init__() + self.df = df + self.class_assign = class_assign # participant -> CLASS_NEG / CLASS_POS (assigned only) + self.all_feature_cols = all_feature_cols + self.selected_chromos = selected_chromos + self.condition = condition + self.settings = settings + self._cancel = threading.Event() + self.was_cancelled = False + self.first_error = None # traceback of the first failed configuration, for the log + + def cancel(self): + """Ask the worker to stop: all worker processes are killed, results so far are kept.""" + self._cancel.set() + + def _build_tasks(self): + s = self.settings + class_a = [p for p, c in self.class_assign.items() if c == CLASS_NEG] + class_b = [p for p, c in self.class_assign.items() if c == CLASS_POS] + if min(len(class_a), len(class_b)) <= s.test_k: + return [] + + cond_df = self.df[self.df["Condition"] == self.condition] + cells = plan_cells(s, len(class_a) - s.test_k, len(class_b) - s.test_k) + tasks = [] + + people = list(self.class_assign) + real_labels = [self.class_assign[p] for p in people] + + for seed in range(s.seed_start, s.seed_start + s.n_seeds): + rng = np.random.default_rng(seed) + assign, seed_a, seed_b = self.class_assign, class_a, class_b + if s.shuffle_labels: + # Null run: the same class sizes, but the labels land on random people + # (own RNG stream, so the split draws below are unaffected). + perm = np.random.default_rng(seed + 1_000_003).permutation(len(people)) + assign = {p: real_labels[i] for p, i in zip(people, perm)} + seed_a = [p for p in people if assign[p] == CLASS_NEG] + seed_b = [p for p in people if assign[p] == CLASS_POS] + test_a = rng.choice(seed_a, s.test_k, replace=False).tolist() + test_b = rng.choice(seed_b, s.test_k, replace=False).tolist() + pool_a = [p for p in seed_a if p not in test_a] + pool_b = [p for p in seed_b if p not in test_b] + + for balanced, n_sub, n_b, t_values in cells: + train_parts = (rng.choice(pool_a, n_sub, replace=False).tolist() + + rng.choice(pool_b, n_b, replace=False).tolist()) + for t_max in t_values: + tasks.append({ + "run_id": len(tasks) + 1, + "seed": seed, + "test_k": s.test_k, + "balanced": balanced, + "train_parts": train_parts, + "test_participants": test_a + test_b, + "t_max": t_max, + "cond_df": cond_df, + "class_assign": assign, + "all_feature_cols": self.all_feature_cols, + "selected_chromos": self.selected_chromos, + "top_k_expand": s.top_k_expand, + "fixed_metrics": s.fixed_metrics, + }) + return tasks + + def _run_pool(self, tasks): + """Run tasks in a process pool. Returns (results, completed_count, failures). + + Polls with a short timeout so a cancel request is noticed quickly; on cancel (or + completion) the pool is terminated, which kills every worker process immediately. + """ + total = len(tasks) + max_workers = max(1, self.settings.workers) + self.log_signal.emit(f"Launching process pool using {max_workers} parallel workers...") + + results, done, failures = [], 0, 0 + start = time.time() + bar_len = 25 + + pool = multiprocessing.Pool(processes=max_workers) + try: + iterator = pool.imap_unordered(_run_grid_task, tasks) + while done < total: + if self._cancel.is_set(): + break + try: + res = iterator.next(timeout=0.25) + except multiprocessing.TimeoutError: + continue + except StopIteration: + break + except Exception: + failures += 1 + if self.first_error is None: + self.first_error = traceback.format_exc() + else: + results.extend(res or []) + done += 1 + + elapsed = time.time() - start + eta = elapsed / done * (total - done) + filled = bar_len * done // total + bar = "█" * filled + "-" * (bar_len - filled) + self.progress_log_signal.emit( + f"Progress: |{bar}| {done}/{total} ({done / total * 100:.1f}%) | " + f"Elapsed: {_format_seconds(elapsed)} | ETA: ~{_format_seconds(eta)}") + finally: + pool.terminate() + pool.join() + + return results, done, failures + + def run(self): + try: + self.log_signal.emit( + f"=== Starting Multi-Process Targeted Grid Sweep on {get_cpu_model_name()} ===") + + tasks = self._build_tasks() + self.log_signal.emit(f"Generated {len(tasks)} configuration tasks.") + if not tasks: + self.finished_signal.emit(pd.DataFrame()) + return + + results, done, failures = self._run_pool(tasks) + if failures: + self.log_signal.emit( + f"\nWarning: {failures} of {done} finished configurations failed and were skipped.") + if self.first_error: + self.log_signal.emit(f"First error:\n{self.first_error}") + + self.was_cancelled = self._cancel.is_set() and done < len(tasks) + if self.was_cancelled: + self.log_signal.emit( + f"\n=== Cancelled: all workers stopped after {done} of {len(tasks)} configurations. " + f"Reporting partial results. ===") + else: + self.log_signal.emit( + f"\n=== Grid Experiment Complete! Logged top 3 metric sets across " + f"{len(tasks)} configurations. ===") + self.finished_signal.emit(pd.DataFrame(results)) + except Exception as e: + self.log_signal.emit(f"Error during grid execution: {e}\n{traceback.format_exc()}") + self.finished_signal.emit(pd.DataFrame()) + + +# ========================================================================== +# Model explanation (no Qt) +# ========================================================================== +METRIC_ORDER = {m: i for i, m in enumerate(METRIC_REGISTRY)} +CHROMO_COLOURS = {"HBO": "#c0392b", "HBR": "#2e86c1", "HBT": "#7d3c98"} +CLASS_COLOURS = {CLASS_NEG: "#3a6ea5", CLASS_POS: "#d98c1f"} + + +def parse_feature_key(col): + """Split a feature column like 'ROI_X_hbo_Peak_Amp' into (ROI, CHROMOPHORE, metric key).""" + for metric, suffix in sorted(METRIC_REGISTRY.items(), key=lambda kv: -len(kv[1])): + if not col.endswith(f"_{suffix}"): + continue + if metric == "Peak_Amp" and col.endswith("_to_Peak_Amp"): + continue + stem = col[: -(len(suffix) + 1)] + tokens = stem.split("_") + chromo = next((t.upper() for t in tokens if t.lower() in KNOWN_CHROMOPHORES), "?") + roi = "_".join(t for t in tokens if t.lower() not in KNOWN_CHROMOPHORES) or stem + return roi, chromo, metric + return col, "?", "?" + + +def short_id(path): + """Compact participant label for plots ('sub-15' when the file name has one).""" + name = Path(path).name + m = re.search(r"sub-[A-Za-z0-9]+", name) + return m.group(0) if m else name[:14] + + +def _bh_qvalues(p): + """Benjamini-Hochberg adjusted p-values (NaNs ignored).""" + p = np.asarray(p, float) + q = np.full(p.shape, np.nan) + ok = np.isfinite(p) + n = int(ok.sum()) + if n == 0: + return q + pv = p[ok] + order = np.argsort(pv) + scaled = pv[order] * n / np.arange(1, n + 1) + scaled = np.minimum.accumulate(scaled[::-1])[::-1] + out = np.empty(n) + out[order] = np.minimum(scaled, 1.0) + q[ok] = out + return q + + +def _labelled_rows(df, condition, classes): + """All trial rows of every labelled participant for one condition.""" + cond_df = df[df["Condition"] == condition] + frames = [_participant_rows(cond_df[cond_df["Participant"] == s]) for s in classes] + return pd.concat([f for f in frames if len(f)], ignore_index=True) + + +def explain_model(bundle, df, classes): + """Why does this model separate Class A from Class B? + + Per feature (participant-level, so every participant counts once): + Mean_A / Mean_B / Delta / Pct_Diff raw group means (B - A) + Cohen_d, AUC (B > A), P_Value, q_BH effect size, rank-based separability, Mann-Whitney + p and its Benjamini-Hochberg adjustment + LDA_Weight weight on the standardised feature + Contribution weight x (mean standardised B - mean standardised A); + the contributions SUM to the gap between the two + groups' mean decision scores (exact, the model is linear) + Share contribution as % of that gap + Contributions of correlated features can offset each other, so shares can be negative or + exceed 100%; the ablation in the GUI is the check on what really matters. + """ + cols = bundle["feature_columns"] + rows = _labelled_rows(df, bundle["condition"], classes) + X = rows[cols].replace([np.inf, -np.inf], np.nan) + part_key = rows["Participant"].to_numpy() + + part_means = X.groupby(part_key).mean() + labels = np.array([classes[p] for p in part_means.index]) + is_a = labels == CLASS_NEG + + pipe = bundle["pipeline"] + Z = pipe[:-1].transform(X.to_numpy(float)) # imputed + standardised trial rows + z_means = pd.DataFrame(Z).groupby(part_key).mean().reindex(part_means.index).to_numpy() + delta_z = z_means[~is_a].mean(axis=0) - z_means[is_a].mean(axis=0) + weights = pipe.named_steps["lda"].coef_.ravel() # > 0 pushes towards Class B + contrib = weights * delta_z + gap = float(contrib.sum()) + + recs = [] + for j, col in enumerate(cols): + a = part_means.loc[is_a, col].dropna().to_numpy() + b = part_means.loc[~is_a, col].dropna().to_numpy() + ma = float(a.mean()) if len(a) else np.nan + mb = float(b.mean()) if len(b) else np.nan + d = auc = p = np.nan + if len(a) > 1 and len(b) > 1: + pooled = np.sqrt(((len(a) - 1) * a.var(ddof=1) + (len(b) - 1) * b.var(ddof=1)) + / (len(a) + len(b) - 2)) + d = (mb - ma) / pooled if pooled > 0 else np.nan + try: + res = stats.mannwhitneyu(b, a, alternative="two-sided") + auc = float(res.statistic) / (len(a) * len(b)) + p = float(res.pvalue) + except ValueError: + pass + roi, chromo, metric = parse_feature_key(col) + recs.append({ + "Feature": col, "ROI": roi, "Chromophore": chromo, "Metric": metric, + "Mean_A": ma, "Mean_B": mb, "Delta": mb - ma, + "Pct_Diff": (mb - ma) / abs(ma) * 100 if ma not in (0, np.nan) and np.isfinite(ma) else np.nan, + "Cohen_d": d, "AUC": auc, "P_Value": p, + "LDA_Weight": float(weights[j]), "Contribution": float(contrib[j]), + "Share": float(contrib[j]) / gap * 100 if gap else np.nan, + }) + st = pd.DataFrame(recs) + st["q_BH"] = _bh_qvalues(st["P_Value"]) + + roi_tbl = ( + st.groupby("ROI") + .agg(Contribution=("Contribution", "sum"), + Mean_Abs_d=("Cohen_d", lambda s: float(np.nanmean(np.abs(s))) if s.notna().any() else np.nan), + N_Features=("Feature", "size")) + .sort_values("Contribution", ascending=False) + ) + roi_tbl["Share"] = roi_tbl["Contribution"] / gap * 100 if gap else np.nan + + return {"bundle": bundle, "stats": st, "roi_table": roi_tbl, "part_means": part_means, + "labels": labels, "gap": gap, "n_a": int(is_a.sum()), "n_b": int((~is_a).sum())} + + +def lopo_participant_scores(X, y, groups, col_idx): + """Leave-one-participant-out: each participant's mean decision score from a model trained + on all the other participants (all their trials), using only the columns in col_idx.""" + Xs = X[:, col_idx] + scores = {} + for p in pd.unique(groups): + test = groups == p + pipe = make_pipeline() + pipe.fit(Xs[~test], y[~test]) + scores[p] = float(np.mean(pipe.decision_function(Xs[test]))) + return pd.Series(scores) + + +def lopo_balanced_accuracy(scores, classes): + true = np.array([classes[p] for p in scores.index]) + pred = np.where(scores.to_numpy() > 0, CLASS_POS, CLASS_NEG) + return float(balanced_accuracy_score(true, pred)) + + +def _fmt_p(v): + if not np.isfinite(v): + return "—" + return f"{v:.2e}" if v < 1e-3 else f"{v:.4f}" + + +def explanation_html(expl, ablation=None): + """Plain-language summary of the model for the explanation tab.""" + esc = html.escape + b, st, roi_tbl, gap = expl["bundle"], expl["stats"], expl["roi_table"], expl["gap"] + metrics = ", ".join(b["metrics"]) + lines = [ + f"Model: {esc(metrics)} | {len(st)} features = {st['ROI'].nunique()} ROI(s) × " + f"{st['Chromophore'].nunique()} chromophore(s) × {len(b['metrics'])} metric(s) | trained on " + f"{expl['n_a']} {CLASS_NEG} + {expl['n_b']} {CLASS_POS} participants.", + f"How it decides: every trial gets an LDA score; a participant is {CLASS_POS} when " + f"their mean trial score is > 0. The two groups' mean scores differ by {gap:.2f} " + f"units, and that gap equals the sum of the per-feature contributions (weight × group " + f"difference, standardised) listed in the table.", + ] + + if len(roi_tbl): + top = [f"{esc(str(r))} ({v:.0f}%)" for r, v in roi_tbl["Share"].head(3).items() + if np.isfinite(v)] + if top: + lines.append("ROIs carrying the separation (share of the gap): " + ", ".join(top) + ".") + + items = [] + for rec in st.sort_values("Contribution", ascending=False).head(3).itertuples(): + pct = f" ({rec.Pct_Diff:+.0f}%)" if np.isfinite(rec.Pct_Diff) else "" + items.append( + f"
  • {esc(rec.ROI)} · {rec.Chromophore} · {rec.Metric}: {CLASS_NEG} {rec.Mean_A:.4g} vs " + f"{CLASS_POS} {rec.Mean_B:.4g}{pct}; d = {rec.Cohen_d:.2f}, AUC = {rec.AUC:.2f}, " + f"q = {rec.q_BH:.3g}; carries {rec.Share:.0f}% of the gap.
  • ") + lines.append("Strongest features:") + + higher = int((st["Delta"] > 0).sum()) + lines.append(f"{CLASS_POS} has the higher {esc(metrics)} in {higher} of {len(st)} features " + f"(participant-level means).") + lines.append("Features are correlated, so shares are a guide (they can be negative or exceed " + "100%). Run the ablation to see what the model really depends on.") + + if ablation is not None: + rows, base = ablation["rows"], ablation["baseline"] + lines.append(f"Leave-one-participant-out balanced accuracy, all features: {base * 100:.1f}%") + for kind in ("ROI", "Chromophore"): + sub = rows[rows["Kind"] == kind] + if sub.empty: + continue + worst = sub.loc[sub["Without"].idxmin()] + best_only = sub.loc[sub["Only"].idxmax()] + lines.append( + f"{kind}: removing {esc(str(worst['Group']))} hurts most " + f"({worst['Without'] * 100:.1f}%, {(worst['Without'] - base) * 100:+.1f} pts); " + f"{esc(str(best_only['Group']))} alone reaches {best_only['Only'] * 100:.1f}%.") + return "
    ".join(lines) + + +# ========================================================================== +# Model explanation widget (tab 3) +# ========================================================================== +class _PopWindow(QWidget): + """Floating top-level window that hosts a plot panel; reports when the user closes it.""" + + def __init__(self, title, on_close): + super().__init__(None) + self.setWindowTitle(title) + self._on_close = on_close + lay = QVBoxLayout(self) + lay.setContentsMargins(4, 4, 4, 4) + + def closeEvent(self, event): + cb, self._on_close = self._on_close, None + if cb: + cb() + event.accept() + + +class PopOutController: + """Moves a plot panel between its docked spot and its own window (e.g. on a second monitor). + + The view selector stays in the main window, so the plot can be changed while it is + floating, and the docked space it leaves behind is given back to the table. + """ + + def __init__(self, panel, host_layout, button, title, stretch=1, on_change=None, placeholder_stretch=None): + self.panel, self.host, self.button, self.title = panel, host_layout, button, title + self.stretch = stretch + self.placeholder_stretch = stretch if placeholder_stretch is None else placeholder_stretch + self.on_change = on_change + self.window = None + self._placeholder = None + self._index = 0 + button.setText("Pop out plot") + button.setToolTip("Move the plot into its own window (e.g. onto a second monitor).\n" + "Close that window, or press this button again, to dock it back.") + button.clicked.connect(self.toggle) + + def toggle(self): + if self.window is None: + self.pop_out() + else: + self.dock() + + def pop_out(self): + self._index = max(0, self.host.indexOf(self.panel)) + self.host.removeWidget(self.panel) + self._placeholder = QLabel("The plot is in its own window.\nClose that window or press " + "'Dock plot' to bring it back.") + self._placeholder.setAlignment(Qt.AlignCenter) + self._placeholder.setStyleSheet("color: #888888;") + self.host.insertWidget(self._index, self._placeholder, self.placeholder_stretch) + + win = _PopWindow(self.title, lambda: self.dock(close_window=False)) + win.layout().addWidget(self.panel) + self.panel.show() + win.resize(1100, 800) + win.show() + self.window = win + self.button.setText("Dock plot") + if self.on_change: + self.on_change(True) + + def dock(self, close_window=True): + win, self.window = self.window, None + if win is None: + return + win._on_close = None + win.layout().removeWidget(self.panel) + if self._placeholder is not None: + self.host.removeWidget(self._placeholder) + self._placeholder.deleteLater() + self._placeholder = None + self.host.insertWidget(self._index, self.panel, self.stretch) + self.panel.show() + self.button.setText("Pop out plot") + if close_window: + win.close() + win.deleteLater() + if self.on_change: + self.on_change(False) + + def close(self): + """Dock the plot back and close its window (used when the main window closes).""" + self.dock() + + +class NumItem(QTableWidgetItem): + """Table cell that shows formatted text but sorts by its numeric value.""" + + def __init__(self, value, text): + super().__init__(text) + v = float(value) + self._v = v if np.isfinite(v) else float("-inf") + self.setFlags(self.flags() & ~Qt.ItemIsEditable) + self.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + + def __lt__(self, other): + o = getattr(other, "_v", None) + return self._v < o if o is not None else super().__lt__(other) + + +class AblationWorker(QThread): + """Leave-one-participant-out accuracy of the full model, and with each ROI / chromophore + kept alone or removed. Runs off the GUI thread.""" + progress = Signal(str) + done = Signal(object) # result dict, or None if stopped + + def __init__(self, rows, cols, classes): + super().__init__() + self.cols = list(cols) + self.classes = classes + self.X = rows[self.cols].replace([np.inf, -np.inf], np.nan).to_numpy(float) + self.groups = rows["Participant"].to_numpy() + self.y = np.array([classes[p] for p in self.groups]) + self._halt = threading.Event() + + def stop(self): + self._halt.set() + + def run(self): + try: + parsed = [parse_feature_key(c) for c in self.cols] + all_idx = np.arange(len(self.cols)) + variants = [] + for kind, pos in (("ROI", 0), ("Chromophore", 1)): + names = sorted({p[pos] for p in parsed}) + if len(names) < 2: + continue # nothing to remove + for name in names: + idx = np.array([i for i, p in enumerate(parsed) if p[pos] == name]) + variants.append((kind, name, idx, np.setdiff1d(all_idx, idx))) + + total = 1 + 2 * len(variants) + step = 0 + + def score(idx): + nonlocal step + step += 1 + self.progress.emit(f"Ablation: fit set {step}/{total} ...") + return lopo_participant_scores(self.X, self.y, self.groups, idx) + + base_scores = score(all_idx) + base_acc = lopo_balanced_accuracy(base_scores, self.classes) + out = [] + for kind, name, idx, rest in variants: + if self._halt.is_set(): + self.done.emit(None) + return + only = lopo_balanced_accuracy(score(idx), self.classes) + without = lopo_balanced_accuracy(score(rest), self.classes) + out.append({"Kind": kind, "Group": name, "Only": only, "Without": without, + "N_Features": int(len(idx))}) + self.done.emit({"baseline": base_acc, "scores": base_scores, "rows": pd.DataFrame(out)}) + except Exception as e: + self.progress.emit(f"Ablation failed: {e}") + self.done.emit(None) + + +class ModelExplainWidget(QWidget): + """Tab 3: explains the model that 'Export Best Model' would write (or any top-10 metric set).""" + + COLS = [("ROI", "ROI", "text"), ("Chromophore", "Chromophore", "text"), ("Metric", "Metric", "text"), + ("Mean Class A", "Mean_A", "num"), ("Mean Class B", "Mean_B", "num"), + ("Δ (B−A)", "Delta", "num"), ("% diff", "Pct_Diff", "pct"), + ("Cohen's d", "Cohen_d", "num"), ("AUC (B>A)", "AUC", "num"), + ("p", "P_Value", "p"), ("q (BH)", "q_BH", "p"), + ("LDA weight", "LDA_Weight", "num"), ("Contribution", "Contribution", "num"), + ("Share %", "Share", "pct")] + VIEWS = ["Selected feature: Class A vs Class B", + "Effect-size heatmap: ROI × chromophore/metric (click a cell)", + "LDA contribution per feature (click a bar)", + "ROI importance (summed contribution)", + "Ablation: accuracy with only / without each ROI & chromophore", + "Participant scores (leave-one-participant-out)"] + + def __init__(self, parent=None): + super().__init__(parent) + self.run = None + self.board = None + self.expl = None + self.ablation = None + self.selected = None + self.worker = None + self._click = None + self._main_ax = None + self._hover = None # hover tooltip state for the current plot (see _register_hover) + self._build_ui() + + # ------------------------------------------------------------------ UI + def _build_ui(self): + root = QVBoxLayout(self) + + top = QHBoxLayout() + top.addWidget(QLabel("Explain metric set:")) + self.metric_combo = QComboBox() + self.metric_combo.currentIndexChanged.connect(self.on_metric_changed) + top.addWidget(self.metric_combo, 1) + self.ablation_btn = QPushButton("Run ablation (leave-one-participant-out)") + self.ablation_btn.setToolTip( + "Refits the model many times: with every ROI / chromophore removed, and with each kept alone,\n" + "scoring each participant with a model that never saw them.") + self.ablation_btn.setEnabled(False) + self.ablation_btn.clicked.connect(self.run_ablation) + top.addWidget(self.ablation_btn) + root.addLayout(top) + + self.status_label = QLabel("Run the grid experiment to load the best model here.") + root.addWidget(self.status_label) + + self.info = QTextEdit() + self.info.setReadOnly(True) + self.info.setMaximumHeight(230) + root.addWidget(self.info) + + splitter = QSplitter(Qt.Horizontal) + self.table = QTableWidget() + self.table.setColumnCount(len(self.COLS)) + self.table.setHorizontalHeaderLabels([c[0] for c in self.COLS]) + self.table.setSelectionBehavior(QTableWidget.SelectRows) + self.table.setSelectionMode(QTableWidget.SingleSelection) + self.table.setEditTriggers(QTableWidget.NoEditTriggers) + self.table.currentCellChanged.connect(self.on_table_cell_changed) + splitter.addWidget(self.table) + + right = QWidget() + right_l = QVBoxLayout(right) + right_l.setContentsMargins(0, 0, 0, 0) + view_row = QHBoxLayout() + view_row.addWidget(QLabel("View:")) + self.view_combo = QComboBox() + self.view_combo.addItems(self.VIEWS) + self.view_combo.currentIndexChanged.connect(lambda _i: self.draw()) + view_row.addWidget(self.view_combo, 1) + self.popout_btn = QPushButton() + view_row.addWidget(self.popout_btn) + right_l.addLayout(view_row) + + self.plot_panel = QWidget() + panel_l = QVBoxLayout(self.plot_panel) + panel_l.setContentsMargins(0, 0, 0, 0) + if MPL_OK: + self.fig = Figure(figsize=(6, 5), dpi=100) + self.canvas = FigureCanvasQTAgg(self.fig) + self.toolbar = NavigationToolbar2QT(self.canvas, self) + panel_l.addWidget(self.toolbar) + panel_l.addWidget(self.canvas, 1) + self.canvas.mpl_connect("button_press_event", self.on_canvas_click) + self.canvas.mpl_connect("motion_notify_event", self.on_canvas_move) + else: + self.fig = self.canvas = self.toolbar = None + panel_l.addWidget(QLabel("matplotlib is not installed, so plots are unavailable " + "(the table and summary still work)."), 1) + right_l.addWidget(self.plot_panel, 1) + splitter.addWidget(right) + self.splitter = splitter + splitter.setSizes([700, 650]) + root.addWidget(splitter, 1) + self.popout = PopOutController( + self.plot_panel, right_l, self.popout_btn, "Model Explanation - plot", stretch=1, + on_change=lambda popped: self.splitter.setSizes([1050, 220] if popped else [700, 650])) + + def close_popout(self): + self.popout.close() + + # ------------------------------------------------------------- lifecycle + def shutdown(self): + if self.worker is not None and self.worker.isRunning(): + self.worker.stop() + self.worker.wait(20000) + + def clear(self): + self.shutdown() + self.run = self.board = self.expl = self.ablation = self.selected = None + self.metric_combo.blockSignals(True) + self.metric_combo.clear() + self.metric_combo.blockSignals(False) + self.table.setRowCount(0) + self.info.clear() + self.ablation_btn.setEnabled(False) + self.metric_combo.setEnabled(True) + self.status_label.setText("Run the grid experiment to load the best model here.") + self.draw() + + def set_context(self, run, board): + """Called when a grid run finishes: load the model that would be exported.""" + self.shutdown() + self.run = run + self.board = board.head(10).reset_index(drop=True) + self.metric_combo.blockSignals(True) + self.metric_combo.clear() + for i, row in enumerate(self.board.itertuples(index=False)): + tag = " ★ best (this is what 'Export Best Model' saves)" if i == 0 else "" + self.metric_combo.addItem( + f"{row.Metrics} | 1st in {row.Top1_Count}, top-3 in {row.Top3_Count}{tag}", row.Metrics) + self.metric_combo.blockSignals(False) + self.build_explanation() + + def on_metric_changed(self, _index): + if self.run is not None: + self.build_explanation() + + def build_explanation(self): + run = self.run + metrics_str = self.metric_combo.currentData() + if run is None or not metrics_str: + return + bundle = train_final_model(run["df"], run["condition"], run["classes"], metrics_str, + run["feature_cols"], run["chromos"], class_hints=run["class_hints"]) + self.expl = explain_model(bundle, run["df"], run["classes"]) + self.ablation = None + self.selected = None + self.populate_table() + self.info.setHtml(explanation_html(self.expl)) + self.ablation_btn.setEnabled(True) + self.status_label.setText( + f"Explaining [{metrics_str}] | condition {run['condition']} | " + f"chromophores {', '.join(run['chromos'])}") + self.draw() + + # ----------------------------------------------------------------- table + @staticmethod + def _fmt(v, kind): + if not np.isfinite(v): + return "—" + if kind == "p": + return _fmt_p(v) + if kind == "pct": + return f"{v:.1f}" + return f"{v:.4g}" + + def populate_table(self): + st = self.expl["stats"] + t = self.table + t.setSortingEnabled(False) + t.setRowCount(len(st)) + for r, rec in enumerate(st.to_dict("records")): + for c, (_header, key, kind) in enumerate(self.COLS): + v = rec[key] + if kind == "text": + item = QTableWidgetItem(str(v)) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + if c == 0: + item.setData(Qt.UserRole, rec["Feature"]) + else: + item = NumItem(v, self._fmt(v, kind)) + t.setItem(r, c, item) + t.setSortingEnabled(True) + contrib_col = [c[1] for c in self.COLS].index("Contribution") + t.sortItems(contrib_col, Qt.DescendingOrder) + t.resizeColumnsToContents() + if t.rowCount(): + t.setCurrentCell(0, 0) + + def on_table_cell_changed(self, row, _col, _prow, _pcol): + item = self.table.item(row, 0) if row >= 0 else None + self.selected = item.data(Qt.UserRole) if item is not None else None + if self.view_combo.currentIndex() in (0, 1, 2): + self.draw() + + def select_feature(self, name): + for r in range(self.table.rowCount()): + item = self.table.item(r, 0) + if item is not None and item.data(Qt.UserRole) == name: + self.table.setCurrentCell(r, 0) + self.table.scrollToItem(item) + return + + # -------------------------------------------------------------- ablation + def run_ablation(self): + if self.expl is None or (self.worker is not None and self.worker.isRunning()): + return + run = self.run + rows = _labelled_rows(run["df"], run["condition"], run["classes"]) + cols = self.expl["bundle"]["feature_columns"] + self.worker = AblationWorker(rows, cols, run["classes"]) + self.worker.progress.connect(self.status_label.setText) + self.worker.done.connect(self.on_ablation_done) + self.ablation_btn.setEnabled(False) + self.metric_combo.setEnabled(False) + self.status_label.setText("Ablation: starting ...") + self.worker.start() + + def on_ablation_done(self, result): + self.metric_combo.setEnabled(True) + if self.expl is None: + return + self.ablation_btn.setEnabled(True) + if result is None: + self.status_label.setText("Ablation did not complete.") + return + self.ablation = result + self.status_label.setText( + f"Ablation done | leave-one-participant-out balanced accuracy " + f"{result['baseline'] * 100:.1f}%") + self.info.setHtml(explanation_html(self.expl, result)) + self.view_combo.setCurrentIndex(4) # triggers a redraw + + # ------------------------------------------------------------------ plots + def on_canvas_click(self, event): + if (self._click is None or event.inaxes is None or event.inaxes is not self._main_ax + or event.xdata is None or event.ydata is None): + return + if self.toolbar is not None and str(self.toolbar.mode): # zoom / pan active + return + mode, payload = self._click + name = None + if mode == "grid": + r, c = int(round(event.ydata)), int(round(event.xdata)) + if 0 <= r < len(payload) and 0 <= c < len(payload[0]): + name = payload[r][c] + elif mode == "bars": + i = int(round(event.ydata)) + if 0 <= i < len(payload): + name = payload[i] + if name: + self.select_feature(name) + + def _register_hover(self, ax, xy, texts, max_items=8): + """Make the points at data coordinates xy show their texts when the mouse is near. + + Points that overlap are all listed (nearest first), so bunched participants can be told apart. + """ + ann = ax.annotate("", xy=(0, 0), xytext=(14, 14), textcoords="offset points", fontsize=8, + bbox=dict(boxstyle="round", fc="white", ec="#555555", alpha=0.95), + arrowprops=dict(arrowstyle="-", color="#555555"), zorder=20, + annotation_clip=False) + ann.set_visible(False) + self._hover = {"ax": ax, "xy": np.asarray(xy, float), "text": list(texts), "ann": ann, + "max": max_items} + + def on_canvas_move(self, event): + h = self._hover + if h is None: + return + ann = h["ann"] + if event.inaxes is not h["ax"] or event.x is None: + if ann.get_visible(): + ann.set_visible(False) + self.canvas.draw_idle() + return + disp = h["ax"].transData.transform(h["xy"]) + dist = np.hypot(disp[:, 0] - event.x, disp[:, 1] - event.y) + near = np.where(dist <= 12)[0] + if near.size == 0: + if ann.get_visible(): + ann.set_visible(False) + self.canvas.draw_idle() + return + near = near[np.argsort(dist[near])][: h["max"]] + ann.xy = tuple(h["xy"][near[0]]) + ann.set_text("\n".join(h["text"][i] for i in near)) + right_half = event.x > h["ax"].bbox.x0 + h["ax"].bbox.width / 2 + ann.xyann = (-14, 14) if right_half else (14, 14) # keep the label inside the plot + ann.set_ha("right" if right_half else "left") + ann.set_visible(True) + self.canvas.draw_idle() + + def draw(self): + if not MPL_OK or self.canvas is None: + return + fig = self.fig + fig.clear() + self._click = None + self._main_ax = None + self._hover = None + if self.expl is None: + self._placeholder(fig, "Run the grid experiment to load a model here.") + else: + draw_fns = [self._draw_feature, self._draw_heatmap, self._draw_contrib, + self._draw_roi, self._draw_ablation, self._draw_scores] + try: + draw_fns[self.view_combo.currentIndex()](fig) + fig.tight_layout() + except Exception as e: + fig.clear() + self._placeholder(fig, f"Could not draw this view:\n{e}") + self.canvas.draw_idle() + + @staticmethod + def _placeholder(fig, message): + ax = fig.add_subplot(111) + ax.axis("off") + ax.text(0.5, 0.5, message, ha="center", va="center", fontsize=11, wrap=True) + + def _draw_feature(self, fig): + name = self.selected + if not name: + return self._placeholder(fig, "Select a feature in the table\n(or click a heatmap cell / bar).") + rec = self.expl["stats"].set_index("Feature").loc[name] + pm, labels = self.expl["part_means"], self.expl["labels"] + groups = [(cls, pm.loc[labels == cls, name].dropna()) for cls in (CLASS_NEG, CLASS_POS)] + + ax = fig.add_subplot(111) + self._main_ax = ax + rng = np.random.default_rng(0) + hover_xy, hover_txt = [], [] + for i, (cls, vals) in enumerate(groups): + if len(vals) == 0: + continue + bp = ax.boxplot(vals.to_numpy(), positions=[i], widths=0.5, showfliers=False, + patch_artist=True) + for patch in bp["boxes"]: + patch.set_facecolor(CLASS_COLOURS[cls]) + patch.set_alpha(0.25) + for med in bp["medians"]: + med.set_color("black") + xs = i + rng.uniform(-0.15, 0.15, len(vals)) + ax.scatter(xs, vals.to_numpy(), color=CLASS_COLOURS[cls], edgecolor="white", s=38, zorder=3) + ax.hlines(vals.mean(), i - 0.3, i + 0.3, color="black", lw=2.2, zorder=4) + for x, y, who in zip(xs, vals.to_numpy(), vals.index): + hover_xy.append((x, y)) + hover_txt.append(f"{short_id(who)} ({cls}) {y:.4g}") + ax.set_xticks([0, 1]) + ax.set_xticklabels([f"{cls}\n(n={len(v)})" for cls, v in groups]) + ax.set_ylabel(f"{rec['Metric']} · {rec['Chromophore']} (participant mean; bar = group mean)") + ax.set_title(f"{rec['ROI']} — {rec['Chromophore']} — {rec['Metric']}") + txt = (f"Δ (B−A) = {rec['Delta']:.4g}\nCohen's d = {rec['Cohen_d']:.2f}\n" + f"AUC (B>A) = {rec['AUC']:.2f}\np = {_fmt_p(rec['P_Value'])}, " + f"q(BH) = {_fmt_p(rec['q_BH'])}\ncontributes {rec['Share']:.0f}% of the gap") + ax.text(0.02, 0.98, txt, transform=ax.transAxes, va="top", fontsize=9, + bbox=dict(boxstyle="round", fc="white", ec="#999999", alpha=0.9)) + self._register_hover(ax, hover_xy, hover_txt) # hover a dot to see who it is + + def _draw_heatmap(self, fig): + st = self.expl["stats"] + rois = sorted(st["ROI"].unique()) + keys = sorted({(r.Chromophore, r.Metric) for r in st.itertuples(index=False)}, + key=lambda k: (k[0], METRIC_ORDER.get(k[1], 99))) + r_idx = {r: i for i, r in enumerate(rois)} + k_idx = {k: j for j, k in enumerate(keys)} + mat = np.full((len(rois), len(keys)), np.nan) + names = [[None] * len(keys) for _ in rois] + for rec in st.itertuples(index=False): + i, j = r_idx[rec.ROI], k_idx[(rec.Chromophore, rec.Metric)] + mat[i, j] = rec.Cohen_d + names[i][j] = rec.Feature + + finite = np.isfinite(mat) + vmax = max(0.3, float(np.nanmax(np.abs(mat)))) if finite.any() else 1.0 + ax = fig.add_subplot(111) + self._main_ax = ax + im = ax.imshow(np.ma.masked_invalid(mat), cmap="coolwarm", vmin=-vmax, vmax=vmax, aspect="auto") + ax.set_xticks(range(len(keys))) + ax.set_xticklabels([f"{c}\n{m}" for c, m in keys], fontsize=8) + ax.set_yticks(range(len(rois))) + ax.set_yticklabels(rois, fontsize=8) + if mat.size <= 120: + for i in range(mat.shape[0]): + for j in range(mat.shape[1]): + if finite[i, j]: + ax.text(j, i, f"{mat[i, j]:.2f}", ha="center", va="center", fontsize=8) + for i, row in enumerate(names): + for j, nm in enumerate(row): + if nm is not None and nm == self.selected: + ax.add_patch(Rectangle((j - 0.5, i - 0.5), 1, 1, fill=False, ec="black", lw=2.5)) + fig.colorbar(im, ax=ax, label="Cohen's d (Class B − Class A)", shrink=0.85) + ax.set_title("Participant-level effect size by ROI — click a cell") + self._click = ("grid", names) + + def _draw_contrib(self, fig): + st = self.expl["stats"].copy() + st["abs"] = st["Contribution"].abs() + top = st.sort_values("abs", ascending=False).head(40).sort_values("Contribution") + multi = st["Metric"].nunique() > 1 + labels = [f"{r.ROI} · {r.Chromophore}" + (f" · {r.Metric}" if multi else "") + for r in top.itertuples(index=False)] + y = np.arange(len(top)) + ax = fig.add_subplot(111) + self._main_ax = ax + bars = ax.barh(y, top["Contribution"].to_numpy(), + color=[CHROMO_COLOURS.get(c, "#7f8c8d") for c in top["Chromophore"]]) + for bar, name in zip(bars, top["Feature"]): + if name == self.selected: + bar.set_edgecolor("black") + bar.set_linewidth(2.5) + ax.set_yticks(y) + ax.set_yticklabels(labels, fontsize=8) + ax.axvline(0, color="black", lw=0.8) + ax.set_xlabel("Contribution to the Class B − Class A score gap (weight × Δz)") + suffix = " (top 40 by |contribution|)" if len(st) > 40 else "" + ax.set_title(f"LDA contribution per feature{suffix} — click a bar") + present = [c for c in CHROMO_COLOURS if c in set(top["Chromophore"])] + if present: + ax.legend(handles=[Patch(color=CHROMO_COLOURS[c], label=c) for c in present], fontsize=8) + self._click = ("bars", top["Feature"].tolist()) + + def _draw_roi(self, fig): + st, gap = self.expl["stats"], self.expl["gap"] + piv = st.pivot_table(index="ROI", columns="Chromophore", values="Contribution", + aggfunc="sum").fillna(0) + piv["__total"] = piv.sum(axis=1) + piv = piv.sort_values("__total", ascending=False) + total = piv.pop("__total") + x = np.arange(len(piv)) + k = max(piv.shape[1], 1) + width = 0.8 / k + ax = fig.add_subplot(111) + for i, ch in enumerate(piv.columns): + ax.bar(x + (i - (k - 1) / 2) * width, piv[ch].to_numpy(), width, + color=CHROMO_COLOURS.get(ch, "#7f8c8d"), label=ch) + ax.plot(x, total.to_numpy(), "kD", label="ROI total", zorder=5) + if gap: + for xi, tv in zip(x, total.to_numpy()): + ax.annotate(f"{tv / gap * 100:.0f}%", (xi, tv), textcoords="offset points", + xytext=(0, 7), ha="center", fontsize=8) + ax.axhline(0, color="black", lw=0.8) + ax.set_xticks(x) + ax.set_xticklabels(piv.index, rotation=30, ha="right") + ax.set_ylabel("Summed LDA contribution") + ax.set_title("ROI importance (labels = share of the group gap)") + ax.legend(fontsize=8) + + def _draw_ablation(self, fig): + ab = self.ablation + if ab is None: + return self._placeholder( + fig, "Press 'Run ablation (leave-one-participant-out)' above.\n" + "It refits the model without each ROI / chromophore and with each alone.") + rows, base = ab["rows"], ab["baseline"] + kinds = [k for k in ("ROI", "Chromophore") if (rows["Kind"] == k).any()] + if not kinds: + return self._placeholder(fig, "Only one ROI and one chromophore: nothing to remove.") + axes = fig.subplots(len(kinds), 1, squeeze=False)[:, 0] + for n, (ax, kind) in enumerate(zip(axes, kinds)): + sub = rows[rows["Kind"] == kind].sort_values("Without") + y = np.arange(len(sub)) + ax.barh(y + 0.2, sub["Only"], 0.38, color="#27ae60", label="Only this group") + ax.barh(y - 0.2, sub["Without"], 0.38, color="#c0392b", label="Everything except this group") + ax.axvline(base, color="black", ls="--", label=f"All features ({base * 100:.1f}%)") + ax.axvline(0.5, color="#999999", ls=":", label="Chance") + ax.set_yticks(y) + ax.set_yticklabels(sub["Group"], fontsize=8) + ax.set_xlim(0.3, 1.02) + ax.set_xlabel("Leave-one-participant-out balanced accuracy") + ax.set_title(f"By {kind}") + if n == 0: + ax.legend(fontsize=7, loc="lower right") + + def _draw_scores(self, fig): + ab = self.ablation + if ab is None: + return self._placeholder( + fig, "Press 'Run ablation (leave-one-participant-out)' above\n" + "to compute out-of-sample participant scores.") + classes = self.run["classes"] + sc = ab["scores"].sort_values(ascending=False) + true = [classes[p] for p in sc.index] + wrong = [(s > 0) != (t == CLASS_POS) for s, t in zip(sc.to_numpy(), true)] + ax = fig.add_subplot(111) + x = np.arange(len(sc)) + bars = ax.bar(x, sc.to_numpy(), color=[CLASS_COLOURS[t] for t in true]) + for bar, w in zip(bars, wrong): + if w: + bar.set_edgecolor("red") + bar.set_linewidth(2) + bar.set_hatch("//") + self._register_hover( + ax, list(zip(x, sc.to_numpy())), + [f"{short_id(p)} ({t}) score {s:+.2f}" + (" MISCLASSIFIED" if w else "") + for p, t, w, s in zip(sc.index, true, wrong, sc.to_numpy())], max_items=3) + ax.axhline(0, color="black", lw=0.8) + ax.set_xticks(x) + ax.set_xticklabels([short_id(p) for p in sc.index], rotation=90, fontsize=7) + ax.set_ylabel("Mean LDA score (> 0 → Class B)") + ax.set_title(f"Out-of-sample participant scores — balanced accuracy {ab['baseline'] * 100:.1f}%") + ax.legend(handles=[Patch(color=CLASS_COLOURS[CLASS_NEG], label=CLASS_NEG), + Patch(color=CLASS_COLOURS[CLASS_POS], label=CLASS_POS), + Patch(fc="white", ec="red", hatch="//", label="Misclassified")], fontsize=8) + + +# ========================================================================== +# Variance decomposition and theory (no Qt) +# ========================================================================== +ARM_COLOURS = {"low": "#3a6ea5", "high": "#d98c1f", None: "#8c8c8c"} + + +def lopo_trial_scores(X, y, groups): + """Out-of-sample LDA score for EVERY trial (> 0 -> Class B). + + Each participant's trials are scored by a model trained on all the other participants, so + no score has seen the person it describes. + """ + out = np.full(len(y), np.nan) + for p in pd.unique(groups): + test = groups == p + pipe = make_pipeline() + pipe.fit(X[~test], y[~test]) + out[test] = pipe.decision_function(X[test]) + return out + + +def variance_components(x, subj, cls): + """Random-effects split of one outcome into class gap, person-to-person and trial-to-trial. + + x[i, t] = mu_class + u_i + e[i, t] u_i ~ (0, s2b) e ~ (0, s2w) + + Method-of-moments (one-way ANOVA with class as a fixed factor, unbalanced-safe): + s2w = within-person mean square, s2b = (between-person MS - s2w) / n0 floored at 0. + ICC = s2b / (s2b + s2w): the share of within-class variation that is stable per person. + Returns None when there are too few participants or a class is missing. + """ + d = pd.DataFrame({"x": np.asarray(x, float), "s": np.asarray(subj), "c": np.asarray(cls)}) + d = d.dropna(subset=["x"]) + if d.empty: + return None + g = d.groupby("s", sort=False) + n_i, m_i, c_i = g["x"].size(), g["x"].mean(), g["c"].first() + n_part = len(n_i) + if CLASS_NEG not in set(c_i) or CLASS_POS not in set(c_i): + return None + dfw, dfb = int(n_i.sum() - n_part), n_part - 2 + if dfw <= 0 or dfb <= 0: + return None + + msw = float(((d["x"] - d["s"].map(m_i)) ** 2).sum() / dfw) + class_mean = d.groupby("c")["x"].mean() + msb = float((n_i * (m_i - c_i.map(class_mean)) ** 2).sum() / dfb) + num = 0.0 + for c in (CLASS_NEG, CLASS_POS): + nc = n_i[c_i == c] + tc = float(nc.sum()) + num += tc - float((nc ** 2).sum()) / tc + n0 = num / dfb + s2b = max(0.0, (msb - msw) / n0) if n0 > 0 else 0.0 + s2w = msw + + mu_a = float(m_i[c_i == CLASS_NEG].mean()) + mu_b = float(m_i[c_i == CLASS_POS].mean()) + delta = mu_b - mu_a + s2c = delta ** 2 / 4.0 + total = s2c + s2b + s2w + rho = s2b / (s2b + s2w) if (s2b + s2w) > 0 else float("nan") + return { + "n_part": int(n_part), "n_trials": int(n_i.sum()), "mean_trials": float(n_i.mean()), + "mu_a": mu_a, "mu_b": mu_b, "delta": delta, + "s2c": s2c, "s2b": s2b, "s2w": s2w, "rho": rho, + "share_c": s2c / total if total > 0 else float("nan"), + "share_b": s2b / total if total > 0 else float("nan"), + "share_w": s2w / total if total > 0 else float("nan"), + "d_between": abs(delta) / math.sqrt(s2b) if s2b > 0 else float("inf"), + "t_sat": (1 - rho) / rho if rho and rho > 0 else float("inf"), + } + + +def design_effect(rho, t): + """Variance inflation from clustering: 1 + (T - 1) * ICC.""" + return 1.0 + (t - 1.0) * rho + + +def predicted_accuracy(comp, n_per_class, t_train, t_test): + """Expected held-out participant accuracy for a one-dimensional score and midpoint rule. + + A test person's mean score ~ N(mu_class, s2b + s2w / t_test). The threshold is the midpoint + of the two estimated class means, each estimated from n_per_class people with t_train trials + (variance (s2b + s2w / t_train) / n_per_class). So + accuracy = Phi( (delta / 2) / sqrt(v_test + v_train / (2 n)) ). + It ignores the extra error of estimating many LDA weights, so use it for direction and + shape, not as an exact accuracy. + """ + v_train = comp["s2b"] + comp["s2w"] / max(t_train, 1.0) + v_test = comp["s2b"] + comp["s2w"] / max(t_test, 1.0) + sd = math.sqrt(v_test + v_train / (2.0 * max(n_per_class, 1e-9))) + return float(stats.norm.cdf(abs(comp["delta"]) / 2.0 / sd)) if sd > 0 else float("nan") + + +def theory_tables(comp, summary_df, tiers): + """(cells, tier_table): predicted vs observed accuracy per (N, T) cell and per budget tier. + + Observed = mean rank-1 balanced participant accuracy of balanced runs in that cell. + """ + best = summary_df[summary_df["Balanced"] & (summary_df["Rank"] == 1)] + if best.empty: + return pd.DataFrame(), pd.DataFrame() + obs = (best.groupby(["N_Train_Subjects", "T_Trials_Per_Subject"])["Part_Bal_Accuracy"] + .mean().reset_index()) + rho, t_test = comp["rho"], comp["mean_trials"] + rows = [] + for r in obs.itertuples(index=False): + n_tot, t = int(r.N_Train_Subjects), int(r.T_Trials_Per_Subject) + n_cls = n_tot / 2.0 + target = arm = None + for tier in tiers: + low, high = tier.masks(n_tot, t) + if low or high: + target, arm = tier.target, ("low" if low else "high") + break + de = design_effect(rho, t) + rows.append({ + "N_Total": n_tot, "N_Per_Class": n_cls, "T": t, "Budget": n_tot * t, + "Tier": target, "Arm": arm, "Design_Effect": de, "N_Eff_Per_Class": n_cls * t / de, + "Pred": predicted_accuracy(comp, n_cls, min(t, t_test), t_test), + "Obs": float(r.Part_Bal_Accuracy)}) + cells = pd.DataFrame(rows).sort_values(["Budget", "N_Total"]).reset_index(drop=True) + + tier_rows = [] + for tier in tiers: + sub = cells[cells["Tier"] == tier.target] + lo, hi = sub[sub["Arm"] == "low"], sub[sub["Arm"] == "high"] + if lo.empty or hi.empty: + continue + tier_rows.append({ + "Target": tier.target, + "Obs_Low": lo["Obs"].mean(), "Obs_High": hi["Obs"].mean(), + "Pred_Low": lo["Pred"].mean(), "Pred_High": hi["Pred"].mean(), + "Obs_Diff": hi["Obs"].mean() - lo["Obs"].mean(), + "Pred_Diff": hi["Pred"].mean() - lo["Pred"].mean()}) + return cells, pd.DataFrame(tier_rows) + + +def icc_reading(rho): + """Plain-language reading of an intraclass correlation for the N-vs-T question.""" + if not np.isfinite(rho): + return "The ICC could not be estimated." + if rho >= 0.5: + return ("**High ICC.** Most of what varies is a stable property of the person. A second, " + "third or tenth trial from the same person mostly repeats what is already known, so " + "spending the budget on **more people** should pay off clearly.") + if rho >= 0.2: + return ("**Moderate ICC.** A person's trials share a stable part but also carry real " + "trial-to-trial noise. The first few trials per person are valuable and then returns " + "fade, so **more people beats more trials once each person is past the saturation " + "point Tsat**, and the gap should shrink as the total budget grows.") + return ("**Low ICC.** Person-to-person differences are small next to trial-to-trial noise, so " + "trials are close to interchangeable and it should matter little whether they come from " + "few or many people. Expect little or no diversity advantage.") + + +def theory_markdown(run, metrics_str, comp, feat, tier_tbl, cells): + """Plain-language variance-decomposition report: between-person variability, within-person + variability, and what they imply for splitting a fixed budget between people and trials.""" + s = run["settings"] + fixed = bool(s.fixed_metrics) + rho = comp["rho"] + sb, sw = math.sqrt(comp["s2b"]), math.sqrt(comp["s2w"]) + pts = lambda v: f"{v * 100:+.1f} points" + L = [ + "# Variance decomposition: people versus trials", + f"*Condition **{run['condition']}** · metric set **{metrics_str}** · numbers are from your " + f"latest run.*", + "", + "## The framework", + "Every measurement is split into three parts: a **class effect** (the Class A versus Class B " + "gap), a **person effect** (how much people differ from each other within a class, " + "between-subject variability, σb²) and **trial noise** (how much one person varies from trial " + "to trial, within-subject variability, σw²). How a fixed budget of N people × T trials should " + "be allocated depends only on how big the person part is compared with the noise part. That " + "ratio is the intraclass correlation, ICC = σb² / (σb² + σw²).", + "", + "## What the numbers are", + f"Estimated on the model's out-of-sample score (every person scored by a model that never saw " + f"them; {comp['n_part']} people, {comp['n_trials']} trials, {comp['mean_trials']:.1f} trials " + f"each on average):", + f"- Class gap between group means: **{abs(comp['delta']):.3g}** score units " + f"({comp['d_between']:.1f} person-to-person standard deviations).", + f"- Between-person spread (σb): **{sb:.3g}**. Within-person, trial-to-trial spread (σw): " + f"**{sw:.3g}**.", + f"- Intraclass correlation **ICC = {rho:.2f}**: {rho * 100:.0f}% of the variation within a class " + f"is stable per person, {(1 - rho) * 100:.0f}% is trial noise.", + icc_reading(rho), + ] + if len(feat): + q1, med, q3 = feat["rho"].quantile([0.25, 0.5, 0.75]) + L.append(f"Across the {len(feat)} individual features the median ICC is {med:.2f} " + f"(middle half {q1:.2f} to {q3:.2f}).") + + L += ["", "## What that means for allocating a fixed budget"] + if rho > 1e-6: + eff = ", ".join(f"T={t}: {t / design_effect(rho, t):.1f} of {t}" for t in (3, 6, 12, 18)) + L += [ + f"- **Effective trials.** N people × T trials are worth N·T / (1 + (T−1)·ICC) independent " + f"trials. With ICC = {rho:.2f}, one person's trials are worth {eff}. Even with unlimited " + f"trials one person is worth at most {1 / rho:.1f} independent trials.", + f"- **Saturation point.** Trial noise per person (σw²/T) falls below the stable person " + f"spread (σb²) at Tsat = (1 − ICC) / ICC = **{comp['t_sat']:.1f} trials**. Beyond that, more " + f"trials from the same person add little.", + ] + else: + L.append("- The ICC is essentially zero, so every trial is worth a full independent trial and " + "there is no saturation point.") + L.append(f"- **Error of a class mean** from N people with T trials each is σb²/N + σw²/(N·T). At a " + f"fixed budget b = N·T per class the second term is constant, so only the first changes, and " + f"only more people shrinks it. The prize for diversity is therefore set by σb² " + f"(here {comp['s2b']:.3g}) relative to the budget.") + + L += ["", "## Does the theory match your grid?"] + if len(tier_tbl): + agree = 0 + for r in tier_tbl.itertuples(index=False): + agree += int(np.sign(round(r.Obs_Diff, 3)) == np.sign(round(r.Pred_Diff, 3))) + L.append(f"- **~{r.Target} trials:** the variance model predicts the high-N arm to be " + f"{pts(r.Pred_Diff)} versus the low-N arm; you observed {pts(r.Obs_Diff)}.") + L.append(f"The predicted and observed differences point the same way at {agree} of " + f"{len(tier_tbl)} budgets.") + offset = float((cells["Obs"] - cells["Pred"]).mean()) if len(cells) else float("nan") + L.append(f"Across all cells, observed accuracy is {pts(offset)} relative to the prediction on " + f"average. The model treats the classifier as a one-dimensional score and ignores the " + f"extra error of estimating the weights from small training sets, so read it for " + f"**direction and plateau, not absolute accuracy**." + + ("" if fixed else " Observed numbers here are best-of-sweep and therefore optimistic; " + "re-run with a fixed metric set for a clean comparison.")) + else: + L.append("Predicted-versus-observed needs balanced results in both arms of at least one budget.") + return "\n\n".join(L) + + +def build_theory(run, metrics_str, summary_df): + """Variance components, predicted-vs-observed tables and the plain-language report.""" + bundle = train_final_model(run["df"], run["condition"], run["classes"], metrics_str, + run["feature_cols"], run["chromos"]) + cols = bundle["feature_columns"] + rows = _labelled_rows(run["df"], run["condition"], run["classes"]) + groups = rows["Participant"].to_numpy() + y = rows["Participant"].map(run["classes"]).to_numpy() + X = rows[cols].replace([np.inf, -np.inf], np.nan).to_numpy(float) + + comp = variance_components(lopo_trial_scores(X, y, groups), groups, y) + if comp is None: + raise ValueError("Too few labelled participants/trials to estimate variance components.") + + feat_rows = [] + for j, col in enumerate(cols): + fc = variance_components(X[:, j], groups, y) + if fc is None: + continue + roi, chromo, metric = parse_feature_key(col) + feat_rows.append({"Feature": col, "ROI": roi, "Chromophore": chromo, "Metric": metric, + "rho": fc["rho"], "s2b": fc["s2b"], "s2w": fc["s2w"], "delta": fc["delta"]}) + feat = pd.DataFrame(feat_rows) + + cells, tier_tbl = theory_tables(comp, summary_df, run["tiers"]) + md = theory_markdown(run, metrics_str, comp, feat, tier_tbl, cells) + return {"comp": comp, "feat": feat, "cells": cells, "tiers": tier_tbl, + "markdown": md, "metrics": metrics_str} + + +# ========================================================================== +# Variance / theory widget (tab 4) +# ========================================================================== +class VarianceTheoryWidget(QWidget): + """Tab 4: variance decomposition, theory-vs-grid comparison and the plain-language report.""" + + VIEWS = ["Variance components: class, person, trial", + "Fixed-budget curves: theory vs your grid", + "Predicted vs observed per (N, T) cell", + "Effective trials per person vs T", + "Learning curve (needs a single-T run)"] + CELL_COLS = [("Budget", "Budget", "int"), ("N / class", "N_Per_Class", "num"), ("T", "T", "int"), + ("Arm", "Arm", "text"), ("Design effect", "Design_Effect", "num"), + ("Eff. N / class", "N_Eff_Per_Class", "num"), ("Predicted %", "Pred", "pct"), + ("Observed %", "Obs", "pct"), ("Obs − Pred (pts)", "Diff", "pts")] + + def __init__(self, parent=None): + super().__init__(parent) + self.run = None + self.summary = None + self.res = None + self._build_ui() + + def _build_ui(self): + root = QVBoxLayout(self) + top = QHBoxLayout() + top.addWidget(QLabel("Metric set:")) + self.metric_combo = QComboBox() + self.metric_combo.currentIndexChanged.connect(self.on_metric_changed) + top.addWidget(self.metric_combo, 1) + self.save_btn = QPushButton("Save report (.md)") + self.save_btn.setEnabled(False) + self.save_btn.clicked.connect(self.save_report) + top.addWidget(self.save_btn) + root.addLayout(top) + + self.status_label = QLabel("Run the grid experiment to load the variance analysis here.") + self.status_label.setWordWrap(True) + root.addWidget(self.status_label) + + splitter = QSplitter(Qt.Horizontal) + self.report = QTextEdit() + self.report.setReadOnly(True) + splitter.addWidget(self.report) + + right = QWidget() + rl = QVBoxLayout(right) + rl.setContentsMargins(0, 0, 0, 0) + row = QHBoxLayout() + row.addWidget(QLabel("View:")) + self.view_combo = QComboBox() + self.view_combo.addItems(self.VIEWS) + self.view_combo.currentIndexChanged.connect(lambda _i: self.draw()) + row.addWidget(self.view_combo, 1) + self.popout_btn = QPushButton() + row.addWidget(self.popout_btn) + rl.addLayout(row) + + self.plot_panel = QWidget() + panel_l = QVBoxLayout(self.plot_panel) + panel_l.setContentsMargins(0, 0, 0, 0) + if MPL_OK: + self.fig = Figure(figsize=(6, 4.5), dpi=100) + self.canvas = FigureCanvasQTAgg(self.fig) + self.toolbar = NavigationToolbar2QT(self.canvas, self) + panel_l.addWidget(self.toolbar) + panel_l.addWidget(self.canvas, 1) + else: + self.fig = self.canvas = None + panel_l.addWidget(QLabel("matplotlib is not installed, so plots are unavailable " + "(the report and table still work)."), 1) + rl.addWidget(self.plot_panel, 3) + self.table = QTableWidget() + self.table.setColumnCount(len(self.CELL_COLS)) + self.table.setHorizontalHeaderLabels([c[0] for c in self.CELL_COLS]) + self.table.setEditTriggers(QTableWidget.NoEditTriggers) + rl.addWidget(self.table, 2) + splitter.addWidget(right) + self.splitter = splitter + splitter.setSizes([650, 700]) + root.addWidget(splitter, 1) + # popped out: the table takes the plot's space (placeholder stays one slim line) + self.popout = PopOutController( + self.plot_panel, rl, self.popout_btn, "Variance & Theory - plot", stretch=3, + placeholder_stretch=0, + on_change=lambda popped: self.splitter.setSizes([480, 870] if popped else [650, 700])) + + def close_popout(self): + self.popout.close() + + # ------------------------------------------------------------- lifecycle + def clear(self): + self.run = self.summary = self.res = None + self.metric_combo.blockSignals(True) + self.metric_combo.clear() + self.metric_combo.blockSignals(False) + self.report.clear() + self.table.setRowCount(0) + self.save_btn.setEnabled(False) + self.status_label.setText("Run the grid experiment to load the variance analysis here.") + self.draw() + + def set_context(self, run, board, summary_df): + self.run, self.summary = run, summary_df + top = board.head(10).reset_index(drop=True) + self.metric_combo.blockSignals(True) + self.metric_combo.clear() + for i, row in enumerate(top.itertuples(index=False)): + tag = " ★ best" if i == 0 else "" + self.metric_combo.addItem(f"{row.Metrics}{tag}", row.Metrics) + self.metric_combo.blockSignals(False) + self.recompute() + + def on_metric_changed(self, _i): + if self.run is not None: + self.recompute() + + def recompute(self): + metrics = self.metric_combo.currentData() + if self.run is None or not metrics: + return + QApplication.setOverrideCursor(Qt.WaitCursor) + err = None + try: + self.res = build_theory(self.run, metrics, self.summary) + except Exception as e: + self.res, err = None, f"{e}" + finally: + QApplication.restoreOverrideCursor() + if self.res is None: + self.report.setPlainText(f"Could not compute the variance analysis:\n{err}") + self.table.setRowCount(0) + self.save_btn.setEnabled(False) + self.status_label.setText("Variance analysis failed.") + else: + c = self.res["comp"] + self.report.setMarkdown(self.res["markdown"]) + self.populate_table() + self.save_btn.setEnabled(True) + self.status_label.setText( + f"[{metrics}] | ICC {c['rho']:.2f} | person SD {math.sqrt(c['s2b']):.3g} | " + f"trial SD {math.sqrt(c['s2w']):.3g} | saturation at about {c['t_sat']:.1f} trials/person") + self.draw() + + def save_report(self): + if self.res is None: + return + path, _ = QFileDialog.getSaveFileName( + self, "Save plain-language report", "variance_theory_report.md", "Markdown (*.md)") + if not path: + return + try: + Path(path).write_text(self.res["markdown"], encoding="utf-8") + QMessageBox.information(self, "Saved", f"Report saved to:\n{path}") + except Exception as e: + QMessageBox.critical(self, "Save Error", f"Failed to save report:\n{e}") + + # ----------------------------------------------------------------- table + def populate_table(self): + cells = self.res["cells"].copy() + t = self.table + t.setSortingEnabled(False) + t.setRowCount(len(cells)) + if len(cells): + cells["Diff"] = cells["Obs"] - cells["Pred"] + for r, rec in enumerate(cells.to_dict("records")): + for c, (_h, key, kind) in enumerate(self.CELL_COLS): + v = rec[key] + if kind == "text": + blank = v is None or (isinstance(v, float) and np.isnan(v)) + item = QTableWidgetItem("" if blank else str(v)) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + elif kind == "int": + item = NumItem(v, f"{int(v)}") + elif kind == "pct": + item = NumItem(v, f"{v * 100:.1f}") + elif kind == "pts": + item = NumItem(v, f"{v * 100:+.1f}") + else: + item = NumItem(v, f"{v:.2f}") + t.setItem(r, c, item) + t.setSortingEnabled(True) + t.resizeColumnsToContents() + + # ------------------------------------------------------------------ plots + def draw(self): + if not MPL_OK or self.canvas is None: + return + fig = self.fig + fig.clear() + if self.res is None: + ax = fig.add_subplot(111) + ax.axis("off") + ax.text(0.5, 0.5, "Run the grid experiment to load the variance analysis.", + ha="center", va="center", fontsize=11) + else: + fns = [self._draw_components, self._draw_curves, self._draw_scatter, self._draw_effective, + self._draw_learning] + try: + fns[self.view_combo.currentIndex()](fig) + fig.tight_layout() + except Exception as e: + fig.clear() + ax = fig.add_subplot(111) + ax.axis("off") + ax.text(0.5, 0.5, f"Could not draw this view:\n{e}", ha="center", va="center") + self.canvas.draw_idle() + + def _draw_components(self, fig): + comp, feat = self.res["comp"], self.res["feat"] + ax1, ax2 = fig.subplots(2, 1, gridspec_kw={"height_ratios": [1, 2]}) + parts = [("Class gap (A vs B)", comp["share_c"], "#2e86c1"), + ("Person-to-person", comp["share_b"], "#d98c1f"), + ("Trial-to-trial", comp["share_w"], "#7f8c8d")] + left = 0.0 + for label, share, colour in parts: + ax1.barh([0], [share * 100], left=left, color=colour, label=f"{label} {share * 100:.0f}%") + left += share * 100 + ax1.set_xlim(0, 100) + ax1.set_yticks([]) + ax1.set_xlabel("% of total variance of the out-of-sample model score") + ax1.legend(fontsize=7, ncol=3, loc="lower center", bbox_to_anchor=(0.5, 1.02)) + if len(feat): + ax2.hist(feat["rho"].dropna(), bins=np.linspace(0, 1, 21), color="#b8c4d0", edgecolor="white") + ax2.axvline(feat["rho"].median(), color="black", ls=":", label=f"median feature {feat['rho'].median():.2f}") + ax2.axvline(comp["rho"], color="#c0392b", lw=2, label=f"model score {comp['rho']:.2f}") + ax2.set_xlim(0, 1) + ax2.set_xlabel("ICC (0 = a person's trials are interchangeable, 1 = a person is a single fixed value)") + ax2.set_ylabel("Number of features") + ax2.legend(fontsize=8) + + def _draw_curves(self, fig): + comp, cells = self.res["comp"], self.res["cells"] + tiers = self.run["tiers"] + axes = fig.subplots(1, len(tiers), sharey=True, squeeze=False)[0] + t_test = comp["mean_trials"] + n_grid = np.arange(1, 21) + for ax, tier in zip(axes, tiers): + pred = [predicted_accuracy(comp, n, min(tier.target / (2 * n), t_test), t_test) for n in n_grid] + ax.plot(n_grid, np.array(pred) * 100, color="black", lw=1.6, label="theory") + if len(cells): + sub = cells[cells["Tier"] == tier.target] + for arm in ("low", "high"): + s = sub[sub["Arm"] == arm] + if len(s): + ax.scatter(s["N_Per_Class"], s["Obs"] * 100, s=45, color=ARM_COLOURS[arm], + edgecolor="white", zorder=3, + label=f"observed, {'low-N / high-T' if arm == 'low' else 'high-N / low-T'}") + ax.set_title(f"~{tier.target} trials", fontsize=10) + ax.set_xlabel("Training people per class (T = budget / N)") + axes[0].set_ylabel("Held-out participant accuracy (%)") + axes[0].legend(fontsize=7, loc="lower right") + + def _draw_scatter(self, fig): + cells = self.res["cells"] + ax = fig.add_subplot(111) + if cells.empty: + ax.axis("off") + ax.text(0.5, 0.5, "No balanced cells to compare.", ha="center", va="center") + return + for arm in ("low", "high", None): + s = cells[cells["Arm"].isna()] if arm is None else cells[cells["Arm"] == arm] + if len(s): + ax.scatter(s["Pred"] * 100, s["Obs"] * 100, s=50, color=ARM_COLOURS[arm], + edgecolor="white", zorder=3, + label={"low": "low-N / high-T", "high": "high-N / low-T", None: "other"}[arm]) + for r in cells.itertuples(index=False): + ax.annotate(f"{int(r.N_Total)}x{int(r.T)}", (r.Pred * 100, r.Obs * 100), + textcoords="offset points", xytext=(4, 4), fontsize=7) + lo = min(cells["Pred"].min(), cells["Obs"].min()) * 100 - 1 + hi = max(cells["Pred"].max(), cells["Obs"].max()) * 100 + 1 + ax.plot([lo, hi], [lo, hi], color="#999999", ls="--", label="perfect agreement") + ax.set_xlabel("Predicted accuracy (%) from the variance model") + ax.set_ylabel("Observed accuracy (%) in your grid") + ax.set_title("Each point is one (N total x T) cell") + ax.legend(fontsize=8) + + def _draw_learning(self, fig): + ax = fig.add_subplot(111) + res = learning_curve_table(self.summary) if self.summary is not None else None + if res is None: + ax.axis("off") + ax.text(0.5, 0.5, "This view needs a single-T learning-curve run.\nUse 'Learning-curve preset' " + "on the setup tab (ideally with a fixed metric set).", + ha="center", va="center", fontsize=10) + return + tbl, t_val = res + x = tbl["N_Per_Class"].to_numpy() + ax.errorbar(x, tbl["Mean"] * 100, yerr=tbl["SE"] * 100, fmt="o", color="#2e86c1", capsize=3, + label="observed (mean ± SE over seeds)") + xs = np.linspace(x.min(), x.max() * 1.6, 120) + a, b = learning_curve_fit(tbl) + if b > 0: + ax.plot(xs, (a - b / xs) * 100, color="black", lw=1.5, + label=f"fit {a * 100:.1f}% − {b * 100:.1f}% / N") + ax.axhline(a * 100, color="#999999", ls=":", label=f"asymptote {a * 100:.1f}%") + comp = self.res["comp"] + t_test = comp["mean_trials"] + ax.plot(xs, [predicted_accuracy(comp, n, min(t_val, t_test), t_test) * 100 for n in xs], + color="#c0392b", ls="--", label="variance-model prediction") + ax.set_xlabel("Training people per class") + ax.set_ylabel("Held-out participant accuracy (%)") + ax.set_title(f"Learning curve at T = {t_val} trials per person") + ax.legend(fontsize=8) + + def _draw_effective(self, fig): + comp = self.res["comp"] + rho = comp["rho"] + ax = fig.add_subplot(111) + t = np.arange(1, 31) + ax.plot(t, t, color="#999999", ls=":", label="if trials were independent") + for r, ls in ((0.1, "--"), (0.3, "--"), (0.6, "--")): + ax.plot(t, t / design_effect(r, t), ls=ls, lw=1, label=f"ICC {r}") + ax.plot(t, t / design_effect(rho, t), color="#c0392b", lw=2.5, label=f"this model, ICC {rho:.2f}") + if np.isfinite(comp["t_sat"]) and comp["t_sat"] < 30: + ax.axvline(comp["t_sat"], color="#c0392b", ls=":", lw=1) + ax.text(comp["t_sat"], ax.get_ylim()[1] * 0.02, f" Tsat = {comp['t_sat']:.1f}", color="#c0392b", + fontsize=8, va="bottom") + ax.set_xlabel("Trials per person (T)") + ax.set_ylabel("Effective independent trials from one person") + ax.set_title("Diminishing returns of extra trials from the same person") + ax.legend(fontsize=8) + + +# ========================================================================== +# GUI +# ========================================================================== +class DataSplitterGUI(QWidget): + """Assign participants to classes and launch the N vs T grid experiment.""" + + def __init__(self, parent: QWidget | None = None, main_window: QWidget | None = None) -> None: + super().__init__(parent) + self.setWindowTitle("fNIRS ML Builder - N vs T Grid Experiment") + self.resize(1350, 950) + + self.main_window = main_window + self.df = None + self.all_subjects = [] + self.class_assign = {} # subject -> UNASSIGNED / CLASS_NEG / CLASS_POS + self.chromo_checkboxes = {} + self.grid_worker = None + self.last_summary = None # summary DataFrame of the latest run (partial if cancelled) + self.last_run = None # snapshot of the settings that produced last_summary + self.last_pivots = (None, None) # (accuracy pivot, total-trials pivot) of the latest run + self._progress_block = None # log block index of the in-place progress line + + self.init_ui() + self.pull_data_from_main_window(silent=True) + + # ------------------------------------------------------------------ UI + @staticmethod + def _spin(lo, hi, value, tip=""): + box = QSpinBox() + box.setRange(lo, hi) + box.setValue(value) + if tip: + box.setToolTip(tip) + return box + + @staticmethod + def _group(title): + """A titled box with a roomy grid inside.""" + box = QGroupBox(title) + lay = QGridLayout(box) + lay.setHorizontalSpacing(8) + lay.setVerticalSpacing(6) + return box, lay + + @staticmethod + def _hint(text): + """Small grey explanatory text.""" + label = QLabel(text) + label.setWordWrap(True) + label.setStyleSheet("color: gray; font-size: 11px;") + return label + + def _build_settings_panel(self): + """The run settings, grouped by what they decide: design, metrics, sampling, checks. + + Every widget keeps its attribute name; only the layout is organised differently. + """ + d = GridSettings() + + self.spin_n_min = self._spin(1, 100, d.n_min, "Fewest training participants per class.") + self.spin_n_max = self._spin(1, 100, d.n_max, "Most training participants per class.") + self.spin_t_min = self._spin(1, 500, d.t_min, "Fewest trials per training participant.") + self.spin_t_max = self._spin(1, 500, d.t_max, "Most trials per training participant.") + self.spin_test_k = self._spin(1, 50, d.test_k, "Held-out participants per class (same for every cell).") + self.spin_seeds = self._spin(1, 1_000_000, d.n_seeds, "Number of random train/test splits (Monte Carlo seeds).") + self.spin_seed_start = self._spin(0, 1_000_000, d.seed_start, "First seed value (change to draw a fresh set of splits).") + self.spin_workers = self._spin(1, max(64, DEFAULT_WORKERS), d.workers, "Worker processes.") + + self.spin_tol = QDoubleSpinBox() + self.spin_tol.setRange(0.5, 50.0) + self.spin_tol.setSingleStep(0.5) + self.spin_tol.setDecimals(1) + self.spin_tol.setSuffix(" %") + self.spin_tol.setValue(d.budget_tolerance * 100) + self.spin_tol.setToolTip( + "How close N x T must be to each target budget (~72, ~96, ~144 total trials).\n" + "Smaller = tighter matching between the low-N and high-N arms.") + + self.spin_combo = self._spin( + 0, len(METRIC_REGISTRY), d.top_k_expand, + "After all singles and pairs, take this many best single metrics and test every\n" + "subset of them with 3 or more metrics. 5 adds 16 sets; 0 disables.") + self.chk_unbalanced = QCheckBox("Also run unbalanced sets (all remaining Class B)") + self.chk_unbalanced.setChecked(d.include_unbalanced) + self.chk_unbalanced.setToolTip("The pivots and iso-budget report always use balanced runs only.") + self.chk_budget_only = QCheckBox("Budget-only: run just the (N, T) cells of the three budget groups") + self.chk_budget_only.setChecked(d.budget_only) + self.chk_budget_only.setToolTip( + "Checked: skip every (N, T) cell that is not part of a budget group.\n" + "Unchecked: run the full N x T table (including cells never used in the report).") + + # Two-stage design: stage 1 sweeps metric sets (exploratory), stage 2 re-runs the grid + # with ONE pre-specified set so nothing about the held-out people picks the features. + self.chk_fixed = QCheckBox("Stage 2: use ONLY this metric set (no sweep)") + self.chk_fixed.setToolTip( + "Evaluate ONLY the metric set typed here (comma-separated, e.g. RMS or RMS, TTP).\n" + "No selection happens on the held-out participants, so accuracies are free of\n" + "selection bias, and the run is ~70x cheaper, so many more seeds are practical.\n" + f"Valid names: {', '.join(METRIC_REGISTRY)}") + self.edit_fixed = QLineEdit("RMS") + self.edit_fixed.setEnabled(False) + self.btn_use_best = QPushButton("Use best from last run") + self.btn_use_best.setToolTip("Fill in the top-ranked metric set of the latest sweep.") + self.btn_use_best.setEnabled(False) + self.btn_use_best.clicked.connect(self.use_best_as_fixed) + + self.chk_null = QCheckBox("Null run: shuffle class labels (accuracy should fall to ~50%)") + self.chk_null.setToolTip( + "Each seed gives the Class A / Class B labels to randomly chosen people, then runs the\n" + "whole pipeline as usual. Real signal disappears, so any accuracy above 50% is bias in the\n" + "pipeline itself (e.g. picking the best of many metric sets). Use the SAME settings as the\n" + "real run; the log then compares the two. Tabs and exports keep showing your last real run.") + self.btn_curve = QPushButton("Learning-curve preset") + self.btn_curve.setToolTip( + "Sets one trials-per-person value (5), turns budget-only OFF and spans training people\n" + "from 2 per class up to the most your class sizes allow, to show where accuracy levels off.\n" + "Best combined with 'Stage 2: use ONLY this metric set'.") + self.btn_curve.clicked.connect(self.apply_learning_curve_preset) + + self.estimate_label = QLabel("") + self.estimate_label.setWordWrap(True) + self.estimate_label.setStyleSheet("padding: 6px; border: 1px solid gray; border-radius: 4px;") + + panel = QWidget() + col = QVBoxLayout(panel) + col.setContentsMargins(0, 0, 0, 0) + + # --- Design --- + design, g = self._group("3. Design: what is compared") + g.addWidget(QLabel("Training people per class"), 0, 0) + g.addWidget(self.spin_n_min, 0, 1) + g.addWidget(QLabel("to"), 0, 2) + g.addWidget(self.spin_n_max, 0, 3) + g.addWidget(QLabel("Trials per person (T)"), 1, 0) + g.addWidget(self.spin_t_min, 1, 1) + g.addWidget(QLabel("to"), 1, 2) + g.addWidget(self.spin_t_max, 1, 3) + g.addWidget(QLabel("Held-out people per class"), 2, 0) + g.addWidget(self.spin_test_k, 2, 1) + g.addWidget(self.chk_budget_only, 3, 0, 1, 5) + g.addWidget(QLabel("Budget tolerance (±)"), 4, 0) + g.addWidget(self.spin_tol, 4, 1) + g.addWidget(self.chk_unbalanced, 5, 0, 1, 5) + g.addWidget(self.btn_curve, 6, 0, 1, 5) + g.setColumnStretch(4, 1) + col.addWidget(design) + + # --- Metrics --- + metrics, m = self._group("4. Metrics: what the classifier sees") + m.addWidget(QLabel("Stage 1: sweep (exploratory)"), 0, 0, 1, 3) + m.addWidget(QLabel("Expand the top-K single metrics (K)"), 1, 0, 1, 2) + m.addWidget(self.spin_combo, 1, 2) + m.addWidget(QLabel("Stage 2: fixed metric set (confirmatory)"), 2, 0, 1, 3) + m.addWidget(self.chk_fixed, 3, 0, 1, 3) + m.addWidget(self.edit_fixed, 4, 0, 1, 2) + m.addWidget(self.btn_use_best, 4, 2) + m.addWidget(self._hint("Stage 1 tries every single metric, every pair and the subsets of the top K, " + "and keeps the best per split, so its accuracies are optimistic. Stage 2 " + "evaluates only the set you name."), 5, 0, 1, 3) + m.setColumnStretch(1, 1) + col.addWidget(metrics) + + # --- Sampling and compute --- + sampling, s = self._group("5. Sampling and compute") + s.addWidget(QLabel("Seeds (random splits)"), 0, 0) + s.addWidget(self.spin_seeds, 0, 1) + s.addWidget(QLabel("First seed"), 1, 0) + s.addWidget(self.spin_seed_start, 1, 1) + s.addWidget(QLabel("Worker processes"), 2, 0) + s.addWidget(self.spin_workers, 2, 1) + s.setColumnStretch(2, 1) + col.addWidget(sampling) + + # --- Checks --- + checks, c = self._group("6. Sanity check") + c.addWidget(self.chk_null, 0, 0) + col.addWidget(checks) + col.addStretch() + + for w in (self.spin_n_min, self.spin_n_max, self.spin_t_min, self.spin_t_max, + self.spin_test_k, self.spin_seeds, self.spin_seed_start, self.spin_combo): + w.valueChanged.connect(self.update_estimate) + self.spin_tol.valueChanged.connect(self.update_estimate) + for chk in (self.chk_unbalanced, self.chk_budget_only, self.chk_fixed): + chk.toggled.connect(self.update_estimate) + self.chk_fixed.toggled.connect(self.edit_fixed.setEnabled) + self.chk_fixed.toggled.connect(lambda on: self.spin_combo.setEnabled(not on)) + self.edit_fixed.textChanged.connect(self.update_estimate) + return panel + + def init_ui(self): + main_layout = QVBoxLayout(self) + self.tabs = QTabWidget() + main_layout.addWidget(self.tabs) + + # ---- Tab 1: setup ---- + def button_css(color): + # A plain background-color stylesheet hides Qt's disabled look, so state it explicitly + return (f"QPushButton {{ font-weight: bold; color: white; padding: 10px 18px; " + f"font-size: 13px; background-color: {color}; }} " + f"QPushButton:disabled {{ background-color: #8c8c8c; color: #d8d8d8; }}") + + self.export_btn = QPushButton("Export Best Model (.joblib)") + self.export_btn.setStyleSheet(button_css("#3a6ea5")) + self.export_btn.setEnabled(False) + self.export_btn.clicked.connect(self.export_best_model) + + self.export_xlsx_btn = QPushButton("Export Results (.xlsx)") + self.export_xlsx_btn.setStyleSheet(button_css("#7a5a9e")) + self.export_xlsx_btn.setEnabled(False) + self.export_xlsx_btn.clicked.connect(self.export_results_excel) + + self.cancel_btn = QPushButton("Cancel") + self.cancel_btn.setStyleSheet(button_css("#a33a3a")) + self.cancel_btn.setEnabled(False) + self.cancel_btn.clicked.connect(self.cancel_grid_experiment) + + self.grid_exp_btn = QPushButton("Run N vs T Grid Experiment") + self.grid_exp_btn.setStyleSheet(button_css("#4a7c59")) + self.grid_exp_btn.clicked.connect(self.start_grid_experiment) + + tab_setup = QWidget() + setup = QVBoxLayout(tab_setup) + + setup.addWidget(self._hint( + "Workflow: load data and assign classes (left) → run a Stage 1 sweep to find the best " + "metric set → press 'Use best from last run' and run Stage 2 with many seeds → use the " + "learning-curve preset and a null run as checks. Results and exports are on the other tabs.")) + + # ---- Row 1: data, condition, chromophores ---- + top = QGroupBox("1. Data and features") + top_l = QGridLayout(top) + self.data_info_label = QLabel("No data loaded.") + self.data_info_label.setWordWrap(True) + top_l.addWidget(self.data_info_label, 0, 0, 1, 3) + reload_btn = QPushButton("Reload from App Data") + reload_btn.clicked.connect(lambda: self.pull_data_from_main_window(silent=False)) + top_l.addWidget(reload_btn, 0, 3) + top_l.addWidget(QLabel("Condition:"), 1, 0) + self.condition_combo = QComboBox() + top_l.addWidget(self.condition_combo, 1, 1) + top_l.addWidget(QLabel("Chromophores:"), 1, 2, Qt.AlignRight) + self.chromo_container_layout = QHBoxLayout() + top_l.addLayout(self.chromo_container_layout, 1, 3) + top_l.setColumnStretch(0, 1) + setup.addWidget(top) + + # ---- Row 2: participants (left) | run settings (right) ---- + split = QSplitter(Qt.Horizontal) + + people = QGroupBox("2. Participants and classes") + people_l = QVBoxLayout(people) + assign = QGridLayout() + assign.addWidget(QLabel("Class A if path contains:"), 0, 0) + self.class_a_edit = QLineEdit("CVI") + assign.addWidget(self.class_a_edit, 0, 1) + assign.addWidget(QLabel("Class B if path contains:"), 0, 2) + self.class_b_edit = QLineEdit("Control") + assign.addWidget(self.class_b_edit, 0, 3) + assign_btn = QPushButton("Assign Classes from Path") + assign_btn.clicked.connect(self.assign_classes_from_path) + assign.addWidget(assign_btn, 0, 4) + people_l.addLayout(assign) + self.assign_summary_label = QLabel("") + people_l.addWidget(self.assign_summary_label) + self.table = QTableWidget() + self.table.setColumnCount(2) + self.table.setHorizontalHeaderLabels(["Subject Absolute File Path", "True Class"]) + header = self.table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.Stretch) + header.setSectionResizeMode(1, QHeaderView.Interactive) + self.table.setColumnWidth(1, 150) + people_l.addWidget(self.table, 1) + split.addWidget(people) + + settings_scroll = QScrollArea() + settings_scroll.setWidgetResizable(True) + settings_scroll.setFrameShape(QFrame.NoFrame) + settings_scroll.setWidget(self._build_settings_panel()) + + run_col = QWidget() + run_l = QVBoxLayout(run_col) + run_l.setContentsMargins(0, 0, 0, 0) + run_l.addWidget(settings_scroll, 1) + run_l.addWidget(self.estimate_label) + run_l.addWidget(self.grid_exp_btn) + run_col.setMinimumWidth(470) + split.addWidget(run_col) + split.setStretchFactor(0, 3) + split.setStretchFactor(1, 2) + split.setSizes([760, 560]) + setup.addWidget(split, 1) + + # ---- Tab 2: logs ---- + tab_logs = QWidget() + logs_l = QVBoxLayout(tab_logs) + logs_l.addWidget(QLabel("Real-time sweep logs:")) + self.log_text = QTextEdit() + self.log_text.setReadOnly(True) + self.log_text.setStyleSheet( + "background-color: #1e1e1e; color: #00ff00; " + "font-family: Consolas, monospace; font-size: 12px;") + logs_l.addWidget(self.log_text) + + # Run controls live next to the log so cancelling/exporting never needs a tab switch + log_btn_row = QHBoxLayout() + log_btn_row.addStretch() + log_btn_row.addWidget(self.export_xlsx_btn) + log_btn_row.addWidget(self.export_btn) + log_btn_row.addWidget(self.cancel_btn) + logs_l.addLayout(log_btn_row) + + self.tabs.addTab(tab_setup, "Experiment Setup") + self.tabs.addTab(tab_logs, "Processing Logs") + + self.explain_tab = ModelExplainWidget() + self.tabs.addTab(self.explain_tab, "Model Explanation") + + self.theory_tab = VarianceTheoryWidget() + self.tabs.addTab(self.theory_tab, "Variance & Theory") + + self.update_estimate() + + def closeEvent(self, event): + """Don't leave worker processes running if the window is closed mid-run.""" + if self.grid_worker is not None and self.grid_worker.isRunning(): + self.grid_worker.cancel() + self.grid_worker.wait(10000) + self.explain_tab.shutdown() + self.explain_tab.close_popout() + self.theory_tab.close_popout() + super().closeEvent(event) + + # ------------------------------------------------------------ settings + def read_settings(self): + """GridSettings from the widgets, or None (after warning) if they are inconsistent.""" + if self.spin_n_min.value() > self.spin_n_max.value(): + QMessageBox.warning(self, "Configuration Error", + "Minimum training participants per class exceeds the maximum.") + return None + if self.spin_t_min.value() > self.spin_t_max.value(): + QMessageBox.warning(self, "Configuration Error", + "Minimum trials per participant exceeds the maximum.") + return None + if self.chk_fixed.isChecked(): + fixed, bad = parse_metric_list(self.edit_fixed.text()) + if bad or not fixed: + QMessageBox.warning( + self, "Configuration Error", + (f"Unknown metric name(s): {', '.join(bad)}.\n" if bad else "No metric given.\n") + + f"Valid names: {', '.join(METRIC_REGISTRY)}") + return None + return self._settings_unchecked() + + def _settings_unchecked(self): + return GridSettings( + n_min=self.spin_n_min.value(), n_max=self.spin_n_max.value(), + t_min=self.spin_t_min.value(), t_max=self.spin_t_max.value(), + test_k=self.spin_test_k.value(), + n_seeds=self.spin_seeds.value(), seed_start=self.spin_seed_start.value(), + include_unbalanced=self.chk_unbalanced.isChecked(), + budget_only=self.chk_budget_only.isChecked(), + budget_tolerance=self.spin_tol.value() / 100.0, + top_k_expand=self.spin_combo.value(), + fixed_metrics=(parse_metric_list(self.edit_fixed.text())[0] + if self.chk_fixed.isChecked() else ()), + shuffle_labels=self.chk_null.isChecked(), + workers=self.spin_workers.value(), + ) + + def use_best_as_fixed(self): + """Stage 1 -> stage 2: copy the best metric set of the latest sweep into the fixed field.""" + if self.last_summary is None: + QMessageBox.warning(self, "No results", "Run a sweep first.") + return + best = rank_metric_sets(self.last_summary).iloc[0]["Metrics"] + self.edit_fixed.setText(str(best)) + self.chk_fixed.setChecked(True) + + def apply_learning_curve_preset(self): + """Settings for a single-T learning curve: N from 1 up to the most the class sizes allow.""" + values = list(self.class_assign.values()) + n_a, n_b = values.count(CLASS_NEG), values.count(CLASS_POS) + max_n = min(n_a, n_b) - self.spin_test_k.value() + if max_n < 4: + QMessageBox.warning(self, "Learning curve", + "Assign participants to both classes first (each class needs at least " + "4 more people than the held-out count).") + return + self.chk_budget_only.setChecked(False) + self.chk_unbalanced.setChecked(False) + self.spin_n_min.setValue(2) + self.spin_n_max.setValue(max_n) + self.spin_t_min.setValue(5) + self.spin_t_max.setValue(5) + self.append_log(f"Learning-curve preset: 2 to {max_n} training people per class at T = 5, " + f"budget-only off. Tick 'Stage 2' for an unbiased curve.") + + def update_estimate(self, *_): + """Live size of the planned run (configurations x metric sets per configuration).""" + if not hasattr(self, "estimate_label"): + return + values = list(self.class_assign.values()) + n_a, n_b = values.count(CLASS_NEG), values.count(CLASS_POS) + if n_a == 0 or n_b == 0: + self.estimate_label.setText("Assign participants to Class A and Class B to see the run-size estimate.") + return + s = self._settings_unchecked() + if s.n_min > s.n_max or s.t_min > s.t_max: + self.estimate_label.setText("Min exceeds max in a range.") + return + configs, sets_each = estimate_workload(s, n_a, n_b) + if configs == 0: + self.estimate_label.setText( + "No configurations match these settings (check class sizes vs. held-out count, " + "N/T ranges and budget-only mode).") + return + mode = "budget cells only" if s.budget_only else "full N x T table" + warn = "" + if self.chk_fixed.isChecked() and (not s.fixed_metrics + or parse_metric_list(self.edit_fixed.text())[1]): + warn = " (Fixed metric field is empty or has unknown names.)" + self.estimate_label.setText( + f"Planned run: {configs:,} configurations ({mode}) × {sets_each} metric sets each " + f"≈ {configs * sets_each:,} LDA fits.{warn}") + + # ------------------------------------------------------------- data in + def pull_data_from_main_window(self, silent=False): + def warn(msg): + self.data_info_label.setText(msg) + if not silent: + QMessageBox.warning(self, "Warning", msg) + + if not self.main_window or not hasattr(self.main_window, "fir_feature_dict"): + warn("No main window / fir_feature_dict available.") + return + fir_dict = self.main_window.fir_feature_dict + if not isinstance(fir_dict, dict) or not fir_dict: + warn("main_window.fir_feature_dict is currently empty.") + return + df, kind, info = parse_fir_dict(fir_dict) + if df.empty: + warn("Could not extract valid features from fir_feature_dict.") + return + if info["dropped"] and not silent: + QMessageBox.warning( + self, "Mixed data", + f"{len(info['dropped'])} file(s) have no trial-specific features (Step 21.5 did not run " + f"for them) and were excluded from this trial-wise dataset.") + self.load_from_dataframe(df, kind) + + def load_from_dataframe(self, df: pd.DataFrame, kind: str): + try: + self.df = df + self.all_subjects = sorted(df["Participant"].astype(str).unique().tolist()) + for s in self.all_subjects: + self.class_assign.setdefault(s, UNASSIGNED) + + self.condition_combo.clear() + self.condition_combo.addItems(sorted(df["Condition"].unique().tolist())) + + while self.chromo_container_layout.count(): + item = self.chromo_container_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + self.chromo_checkboxes = {} + detected = self.detect_chromophores() + for ch in detected: + chk = QCheckBox(ch) + chk.setChecked(True) + self.chromo_checkboxes[ch] = chk + self.chromo_container_layout.addWidget(chk) + + if kind == KIND_TRIAL: + counts = df[df["Kind"] == KIND_TRIAL].groupby(["Participant", "Condition"]).size() + kind_txt = (f"trial-wise features (median {int(counts.median())} trials per " + f"participant and condition)") + else: + kind_txt = "whole-participant features (no trial-specific features; Step 21.5 not run)" + chromo_txt = ("" if detected else + " | No chromophore tags (hbo/hbr/hbt/...) found in the feature names") + self.data_info_label.setText( + f"Loaded {len(self.all_subjects)} participants | {kind_txt}{chromo_txt}") + + self.populate_table() + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to process pulled data from main window:\n{e}") + + def feature_columns(self): + return [c for c in self.df.columns if c not in META_COLS] + + def detect_chromophores(self): + """Chromophores actually present in the feature names (nothing is invented).""" + return detect_chromophores_in(self.feature_columns()) if self.df is not None else [] + + # ------------------------------------------------------ class assignment + def assigned_classes(self): + return {s: c for s, c in self.class_assign.items() if c in (CLASS_NEG, CLASS_POS)} + + def selected_chromophores(self): + return [ch for ch, chk in self.chromo_checkboxes.items() if chk.isChecked()] + + def assign_classes_from_path(self): + a_kw = self.class_a_edit.text().strip().lower() + b_kw = self.class_b_edit.text().strip().lower() + n_a = n_b = n_u = 0 + for sub in self.all_subjects: + low = sub.lower() + in_a = bool(a_kw) and a_kw in low + in_b = bool(b_kw) and b_kw in low + if in_a and not in_b: + self.class_assign[sub] = CLASS_NEG + n_a += 1 + elif in_b and not in_a: + self.class_assign[sub] = CLASS_POS + n_b += 1 + else: + self.class_assign[sub] = UNASSIGNED + n_u += 1 + self.append_log(f"Assigned classes from path: Class A={n_a}, Class B={n_b}, unassigned={n_u}") + self.populate_table() + + def refresh_summary(self): + values = [self.class_assign.get(s, UNASSIGNED) for s in self.all_subjects] + self.assign_summary_label.setText( + f"Class A={values.count(CLASS_NEG)} | Class B={values.count(CLASS_POS)} | " + f"Unassigned={values.count(UNASSIGNED)}") + self.update_estimate() + + def populate_table(self): + if self.df is None or self.df.empty: + return + + self.table.setRowCount(len(self.all_subjects)) + for row, sub in enumerate(self.all_subjects): + item = QTableWidgetItem(sub) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + self.table.setItem(row, 0, item) + + combo = QComboBox() + combo.addItems([UNASSIGNED, CLASS_NEG, CLASS_POS]) + combo.setCurrentText(self.class_assign.get(sub, UNASSIGNED)) + combo.currentTextChanged.connect( + lambda value, subject=sub: self.on_class_changed(subject, value)) + self.table.setCellWidget(row, 1, combo) + + self.refresh_summary() + + def on_class_changed(self, sub, text): + self.class_assign[sub] = text + self.refresh_summary() + + # ---------------------------------------------------------------- run + def start_grid_experiment(self): + if self.df is None: + QMessageBox.warning(self, "No data", "No data loaded.") + return + + assigned = self.assigned_classes() + n_a = sum(c == CLASS_NEG for c in assigned.values()) + n_b = sum(c == CLASS_POS for c in assigned.values()) + if n_a < MIN_PER_CLASS or n_b < MIN_PER_CLASS: + QMessageBox.warning( + self, "Configuration Error", + f"Please assign at least {MIN_PER_CLASS} participants to Class A and " + f"{MIN_PER_CLASS} to Class B.") + return + + settings = self.read_settings() + if settings is None: + return + if min(n_a, n_b) < settings.test_k + settings.n_min: + QMessageBox.warning( + self, "Configuration Error", + f"With {settings.test_k} held-out per class and at least {settings.n_min} training " + f"participants per class, each class needs {settings.test_k + settings.n_min} participants " + f"(Class A={n_a}, Class B={n_b}).") + return + configs, sets_each = estimate_workload(settings, n_a, n_b) + if configs == 0: + QMessageBox.warning( + self, "Configuration Error", + "No (N, T) configurations match these settings. Widen the N/T ranges, loosen the budget " + "tolerance, or turn off budget-only mode.") + return + + chromos = self.selected_chromophores() + if not chromos: + QMessageBox.warning(self, "Configuration Error", "Please select at least one chromophore.") + return + + feature_cols = self.feature_columns() + per_chromo, total_cols, empty_metrics = describe_feature_columns(feature_cols, chromos) + no_cols = [ch for ch, n in per_chromo.items() if n == 0] + if no_cols: + QMessageBox.warning( + self, "Configuration Error", + f"No feature columns match chromophore(s): {', '.join(no_cols)}.\n" + f"Uncheck them, or check the feature names (e.g. {feature_cols[:2]}).") + return + if settings.fixed_metrics and not select_columns(feature_cols, settings.fixed_metrics, chromos): + QMessageBox.warning( + self, "Configuration Error", + f"The fixed metric set [{', '.join(settings.fixed_metrics)}] matches no feature columns " + f"for the selected chromophores.") + return + + condition = self.condition_combo.currentText() + run_info = { + "df": self.df, + "classes": assigned, + "condition": condition, + "chromos": chromos, + "feature_cols": feature_cols, + "settings": settings, + "tiers": settings.tiers, + "class_hints": {CLASS_NEG: self.class_a_edit.text().strip(), + CLASS_POS: self.class_b_edit.text().strip()}, + } + if not settings.shuffle_labels: # a null run never replaces the real results or tabs + self.last_summary = None + self.last_pivots = (None, None) + self.explain_tab.clear() + self.theory_tab.clear() + self.last_run = run_info + + self.tabs.setCurrentIndex(1) + self.log_text.clear() + self._progress_block = None + self.append_log(f"Condition: {condition} | Chromophores used: {', '.join(chromos)}") + self.append_log("Feature columns per chromophore (all metrics): " + + ", ".join(f"{ch}={n}" for ch, n in per_chromo.items()) + + f" | total used together: {total_cols}") + if empty_metrics: + self.append_log(f"Metrics with no matching columns (skipped): {', '.join(empty_metrics)}") + self.append_log("Selected chromophores are pooled into one feature set; " + "the sweep varies metrics, not chromophores.") + self.append_log( + f"Grid: train/class {settings.n_min}-{settings.n_max} | trials {settings.t_min}-{settings.t_max} | " + f"held-out {settings.test_k}/class | seeds {settings.seed_start}..." + f"{settings.seed_start + settings.n_seeds - 1} | " + f"{'budget cells only' if settings.budget_only else 'full N x T table'} " + f"(tolerance ±{settings.budget_tolerance * 100:g}%) | " + f"{'balanced + unbalanced' if settings.include_unbalanced else 'balanced only'}") + if settings.fixed_metrics: + fixed_cols = select_columns(feature_cols, settings.fixed_metrics, chromos) + self.append_log( + f"STAGE 2: metric set fixed in advance = [{', '.join(settings.fixed_metrics)}] " + f"({len(fixed_cols)} features). No selection on held-out participants | " + f"{configs:,} configurations planned") + else: + self.append_log( + f"STAGE 1 (exploratory sweep): all singles + all pairs + every 3+ subset of the top " + f"{settings.top_k_expand} singles = {sets_each} sets per configuration | " + f"{configs:,} configurations planned") + + if settings.shuffle_labels: + self.append_log("NULL RUN: class labels are shuffled across participants in every seed. " + "Your last real results stay in the tabs and exports.") + self.set_running(True) + self.grid_worker = DiversityDensityGridWorker( + self.df, assigned, feature_cols, chromos, condition, settings) + self.grid_worker.log_signal.connect(self.append_log) + self.grid_worker.progress_log_signal.connect(self.update_progress_log) + self.grid_worker.finished_signal.connect(self.on_grid_finished) + self.grid_worker.start() + + def set_running(self, running): + self.grid_exp_btn.setEnabled(not running) + self.cancel_btn.setEnabled(running) + if running: + self.export_btn.setEnabled(False) + self.export_xlsx_btn.setEnabled(False) + + def cancel_grid_experiment(self): + if self.grid_worker is not None and self.grid_worker.isRunning(): + self.cancel_btn.setEnabled(False) + self.append_log("\nCancel requested - stopping all workers...") + self.grid_worker.cancel() + + def _finish_null_run(self, summary_df, cancelled): + """Report a label-shuffle run and compare it with the latest real run.""" + rule = "=" * 65 + self.append_log(f"\n{rule}") + self.append_log(f"=== {'PARTIAL ' if cancelled else ''}NULL RUN (SHUFFLED LABELS) ===") + self.append_log(rule) + real_settings = self.last_run["settings"] if self.last_run else None + try: + for line in null_report_lines(summary_df, self.grid_worker.settings, + self.last_summary, real_settings): + self.append_log(line) + except Exception as e: + self.append_log(f"Note: could not complete the null report: {e}") + self.append_log(f"{rule}\nThe tabs and exports still show your last real run.\n") + + def on_grid_finished(self, summary_df): + self.set_running(False) + cancelled = bool(self.grid_worker and self.grid_worker.was_cancelled) + # Exports belong to the last REAL run; a null run must not leave them greyed out + self.export_btn.setEnabled(self.last_summary is not None) + self.export_xlsx_btn.setEnabled(self.last_summary is not None) + + if summary_df.empty: + msg = ("The run was cancelled before any configuration finished." + if cancelled else "The grid sweep completed, but no results were generated.") + QMessageBox.warning(self, "Experiment Finished", msg) + return + + if self.grid_worker is not None and self.grid_worker.settings.shuffle_labels: + self._finish_null_run(summary_df, cancelled) + return + + tiers = self.last_run["tiers"] if self.last_run else DEFAULT_TIERS + summary_df["Total_Trials"] = summary_df["N_Train_Subjects"] * summary_df["T_Trials_Per_Subject"] + self.last_summary = summary_df + self.export_btn.setEnabled(True) + self.export_xlsx_btn.setEnabled(True) + self.btn_use_best.setEnabled(True) + + title = "PARTIAL RESULTS (CANCELLED) - " if cancelled else "" + rule = "=" * 65 + self.append_log(f"\n{rule}") + self.append_log(f"=== {title}ISO-BUDGET STRATEGY & STATISTICAL REPORT (MATCHED TRIAL VOLUMES) ===") + self.append_log(rule) + + pivot_acc = pivot_trials = None + try: + for line in leaderboard_lines(rank_metric_sets(summary_df)): + self.append_log(line) + classes = self.last_run["classes"] if self.last_run else {} + for line in problem_participant_lines(problem_participants(summary_df, classes)): + self.append_log(line) + pivot_acc, pivot_trials = build_pivots(summary_df) + self.last_pivots = (pivot_acc, pivot_trials) + self.append_log("\n[Balanced Runs Only - Mean Held-Out Participant Balanced Accuracy]:") + self.append_log(str(pivot_acc.round(4))) + fixed_run = bool(self.last_run and self.last_run["settings"].fixed_metrics) + for line in iso_budget_report(summary_df, tiers, fixed=fixed_run): + self.append_log(line) + for line in learning_curve_lines(summary_df): + self.append_log(line) + self.append_log(f"{rule}\n") + except Exception as e: + self.append_log(f"Note: Could not complete the report: {e}") + try: + self.explain_tab.set_context(self.last_run, rank_metric_sets(summary_df)) + self.append_log("Model Explanation tab loaded with the best metric set.") + except Exception as e: + self.append_log(f"Note: could not build the model explanation: {e}\n{traceback.format_exc()}") + try: + self.theory_tab.set_context(self.last_run, rank_metric_sets(summary_df), summary_df) + self.append_log("Variance & Theory tab loaded.") + except Exception as e: + self.append_log(f"Note: could not build the variance analysis: {e}\n{traceback.format_exc()}") + self.append_log("Use 'Export Results (.xlsx)' to save the workbook " + "(it stays available until the next run).") + + def export_results_excel(self): + """Save the latest run's grid results and pivots as a formatted Excel workbook.""" + if self.last_summary is None: + QMessageBox.warning(self, "No results", "Run the grid experiment first.") + return + path, _ = QFileDialog.getSaveFileName( + self, "Save N vs T Experiment Results", + "diversity_density_experiment_results.xlsx", "Excel Files (*.xlsx)") + if not path: + return + tiers = self.last_run["tiers"] if self.last_run else DEFAULT_TIERS + pivot_acc, pivot_trials = self.last_pivots + try: + export_results_xlsx(path, self.last_summary, pivot_acc, pivot_trials, tiers) + self.append_log(f">>> Results exported to Excel workbook with permutation highlighting:\n{path}") + QMessageBox.information(self, "Success", f"Experiment results saved to Excel:\n{path}") + except Exception as e: + QMessageBox.critical(self, "Save Error", f"Failed to save Excel file:\n{e}") + + # --------------------------------------------------------- model export + def export_best_model(self): + """Train the best-ranked metric set on all labelled participants and save it.""" + if self.last_summary is None or self.last_run is None: + QMessageBox.warning(self, "No results", "Run the grid experiment first.") + return + + board = rank_metric_sets(self.last_summary) + best = board.iloc[0] + run = self.last_run + + path, _ = QFileDialog.getSaveFileName( + self, "Export Best Model", "best_model.joblib", "Joblib Files (*.joblib)") + if not path: + return + try: + bundle = train_final_model( + run["df"], run["condition"], run["classes"], best["Metrics"], + run["feature_cols"], run["chromos"], class_hints=run["class_hints"], + selection={"Top1_Count": int(best["Top1_Count"]), + "Top3_Count": int(best["Top3_Count"]), + "Mean_Part_Bal_Acc": float(best["Mean_Part_Bal_Acc"]), + "Grid_Rows": int(len(self.last_summary))}) + joblib.dump(bundle, path) + except Exception as e: + QMessageBox.critical(self, "Export Error", f"Failed to export model:\n{e}") + return + + n = bundle["n_train_participants"] + self.append_log( + f">>> Exported best model [{best['Metrics']}] ({len(bundle['feature_columns'])} features, " + f"{', '.join(run['chromos'])}) trained on {n[CLASS_NEG]} Class A + {n[CLASS_POS]} Class B " + f"participants ({bundle['n_train_rows']} rows):\n{path}") + QMessageBox.information( + self, "Model exported", + f"Saved model using metrics: {best['Metrics']}\n\n{path}\n\n" + f"Load with load_model_bundle() and apply with classify_participant().") + + # ---------------------------------------------------------------- logs + def update_progress_log(self, text): + """Keep a single progress line in the log, updated in place. + + The line is tracked by block index rather than assumed to be last, so other log + messages (e.g. "Cancel requested") or late progress updates never create a duplicate. + """ + doc = self.log_text.document() + block = (doc.findBlockByNumber(self._progress_block) + if self._progress_block is not None else None) + if block is not None and block.isValid() and block.text().startswith("Progress:"): + cursor = QTextCursor(block) + cursor.movePosition(QTextCursor.StartOfBlock) + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + cursor.removeSelectedText() + cursor.insertText(text) + else: + self.log_text.append(text) + self._progress_block = doc.blockCount() - 1 + + def append_log(self, text: str): + self.log_text.append(text) + bar = self.log_text.verticalScrollBar() + bar.setValue(bar.maximum()) + + +# ========================================================================== +# Tool 2: apply a saved model to newly collected data +# ========================================================================== +class ModelInferenceGUI(QWidget): + """Load an exported model bundle and classify the data currently in the main window. + + Uses the same parser as training (parse_fir_dict), so feature names and conditions match + exactly, and the same decision rule: a participant's class is the sign of their mean + trial score. Model files are pickles - only open bundles you created or trust. + """ + + PART_HEADERS = ["Subject File", "Trials", "Mean Score", f"% Trials {CLASS_POS}", + "Missing Features", "Predicted Class"] + TRIAL_HEADERS = ["Subject File", "Trial", "Score", "Predicted Class"] + + def __init__(self, parent: QWidget | None = None, main_window: QWidget | None = None) -> None: + super().__init__(parent) + self.setWindowTitle("fNIRS Model Inference - classify new participants") + self.resize(1100, 700) + + self.main_window = main_window + self.bundle = None + self.trials_df = None + self.participants_df = None + + self.init_ui() + + # ------------------------------------------------------------------ UI + @staticmethod + def _make_table(headers): + table = QTableWidget() + table.setColumnCount(len(headers)) + table.setHorizontalHeaderLabels(headers) + header = table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.Stretch) + for col in range(1, len(headers)): + header.setSectionResizeMode(col, QHeaderView.Interactive) + table.setColumnWidth(col, 140) + return table + + def init_ui(self): + layout = QVBoxLayout(self) + + model_box = QGroupBox("1. Load trained model (.joblib)") + model_l = QHBoxLayout(model_box) + load_btn = QPushButton("Browse Model...") + load_btn.clicked.connect(self.browse_model) + model_l.addWidget(load_btn) + self.model_info_label = QLabel("No model loaded.") + self.model_info_label.setWordWrap(True) + model_l.addWidget(self.model_info_label, 1) + layout.addWidget(model_box) + + run_box = QGroupBox("2. Classify the data currently loaded in the main window") + run_l = QHBoxLayout(run_box) + self.run_btn = QPushButton("Run Predictions") + self.run_btn.setStyleSheet( + "font-weight: bold; background-color: #2b5b84; color: white; padding: 8px 16px;") + self.run_btn.clicked.connect(self.run_inference) + run_l.addWidget(self.run_btn) + run_l.addStretch() + self.export_parts_btn = QPushButton("Export Participants CSV...") + self.export_parts_btn.setEnabled(False) + self.export_parts_btn.clicked.connect(lambda: self.export_csv("participants")) + run_l.addWidget(self.export_parts_btn) + self.export_trials_btn = QPushButton("Export Trials CSV...") + self.export_trials_btn.setEnabled(False) + self.export_trials_btn.clicked.connect(lambda: self.export_csv("trials")) + run_l.addWidget(self.export_trials_btn) + layout.addWidget(run_box) + + self.summary_label = QLabel("") + self.summary_label.setWordWrap(True) + layout.addWidget(self.summary_label) + + self.result_tabs = QTabWidget() + self.part_table = self._make_table(self.PART_HEADERS) + self.part_table.setSelectionBehavior(QTableWidget.SelectRows) + self.part_table.setSelectionMode(QTableWidget.SingleSelection) + # Clicking (or pressing Enter on) a participant jumps to just their trials + self.part_table.cellClicked.connect(self.on_participant_chosen) + self.part_table.cellActivated.connect(self.on_participant_chosen) + + trials_page = QWidget() + trials_l = QVBoxLayout(trials_page) + trials_l.setContentsMargins(0, 0, 0, 0) + filter_row = QHBoxLayout() + self.trial_filter_label = QLabel("") + filter_row.addWidget(self.trial_filter_label, 1) + self.show_all_btn = QPushButton("Show all trials") + self.show_all_btn.clicked.connect(lambda: self.show_trials(None)) + filter_row.addWidget(self.show_all_btn) + trials_l.addLayout(filter_row) + self.trial_table = self._make_table(self.TRIAL_HEADERS) + trials_l.addWidget(self.trial_table, 1) + + self.trial_filter = None # participant path currently shown on the Trials tab (None = all) + self.result_tabs.addTab(self.part_table, "Participants") + self.result_tabs.addTab(trials_page, "Trials") + layout.addWidget(self.result_tabs, 1) + self.update_trial_filter_label() + + note = QLabel( + f"A participant is {CLASS_POS} when the mean of their trial scores is above 0, otherwise " + f"{CLASS_NEG}. Scores are uncalibrated LDA decision scores, not probabilities. " + f"'Missing Features' is the share of the model's feature values that were absent " + f"and filled with the training median.") + note.setWordWrap(True) + layout.addWidget(note) + + # --------------------------------------------------------------- model + def browse_model(self): + path, _ = QFileDialog.getOpenFileName(self, "Open Model Bundle", "", "Joblib Files (*.joblib)") + if not path: + return + try: + bundle = load_model_bundle(path) + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to load model file:\n{e}") + return + + self.bundle = bundle + self.clear_results() + + esc = html.escape + n = bundle.get("n_train_participants", {}) + neg, pos = bundle["class_neg"], bundle["class_pos"] + sel = bundle.get("selection", {}) + acc_txt = (f" | Grid estimate (bal. acc): {sel['Mean_Part_Bal_Acc'] * 100:.1f}%" + if "Mean_Part_Bal_Acc" in sel else "") + self.model_info_label.setText( + f"Loaded: {esc(Path(path).name)} | Condition: {esc(str(bundle['condition']))} | " + f"Metrics: {esc(', '.join(bundle['metrics']))} | " + f"Chromophores: {esc(', '.join(bundle['chromophores']))} | " + f"Features: {len(bundle['feature_columns'])} | " + f"Trained on: {n.get(neg, '?')} {neg} + {n.get(pos, '?')} {pos} participants" + f"{acc_txt}") + + # ------------------------------------------------------------- results + def update_trial_filter_label(self): + if self.trial_filter is None: + n = 0 if self.trials_df is None else len(self.trials_df) + self.trial_filter_label.setText( + f"Showing all trials ({n}). Click a participant on the Participants tab to see only theirs.") + else: + n = int((self.trials_df["Participant"] == self.trial_filter).sum()) + self.trial_filter_label.setText( + f"Showing {n} trial(s) for: {html.escape(self.trial_filter)}") + self.show_all_btn.setVisible(self.trial_filter is not None) + + def show_trials(self, participant=None): + """Fill the Trials tab with every trial, or only one participant's (without switching tabs).""" + self.trial_filter = participant + trials = self.trials_df + if trials is None: + self.trial_table.setRowCount(0) + else: + if participant is not None: + trials = trials[trials["Participant"] == participant] + self._fill_table(self.trial_table, [ + [r.Participant, r.Trial, f"{r.Score:+.3f}", r.Predicted_Class] + for r in trials.itertuples(index=False)], class_col=3) + self.result_tabs.setTabText(1, "Trials" if participant is None else f"Trials ({len(trials)})") + self.update_trial_filter_label() + + def on_participant_chosen(self, row, _column=0): + """Participants tab -> Trials tab, filtered to the chosen participant.""" + item = self.part_table.item(row, 0) + if item is None or self.trials_df is None: + return + self.show_trials(item.text()) + self.result_tabs.setCurrentIndex(1) + + def clear_results(self): + self.trials_df = self.participants_df = None + self.part_table.setRowCount(0) + self.show_trials(None) + self.summary_label.setText("") + self.export_parts_btn.setEnabled(False) + self.export_trials_btn.setEnabled(False) + + def _fill_table(self, table, rows, class_col): + """Fill a table with text rows and colour the predicted-class column.""" + neg_colour, pos_colour = QColor(200, 225, 255), QColor(255, 240, 200) + table.setRowCount(len(rows)) + for r, values in enumerate(rows): + for c, text in enumerate(values): + item = QTableWidgetItem(str(text)) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + if c == class_col: + is_neg = text == self.bundle["class_neg"] + item.setBackground(neg_colour if is_neg else pos_colour) + item.setForeground(QColor(0, 0, 0)) + table.setItem(r, c, item) + + def run_inference(self): + if self.bundle is None: + QMessageBox.warning(self, "Warning", "Please load a trained model (.joblib) first.") + return + + fir_dict = getattr(self.main_window, "fir_feature_dict", None) + if not isinstance(fir_dict, dict) or not fir_dict: + QMessageBox.warning(self, "Warning", + "No data: main_window.fir_feature_dict is missing or empty.") + return + + df, kind, info = parse_fir_dict(fir_dict) + if df.empty: + QMessageBox.warning(self, "Warning", "Could not extract valid features from the main window data.") + return + + condition = self.bundle["condition"] + rows = df[df["Condition"] == condition] + if rows.empty: + available = ", ".join(sorted(df["Condition"].unique())) or "none" + QMessageBox.warning( + self, "Warning", + f"No data for the model's condition '{condition}'.\nConditions in the main window: {available}") + return + + missing = missing_feature_columns(self.bundle, rows) + if missing: + QMessageBox.warning( + self, "Features missing", + f"The data lacks {len(missing)} of the model's {len(self.bundle['feature_columns'])} " + f"feature columns, e.g.:\n" + "\n".join(missing[:5]) + + "\n\nCheck that the same FIR/feature steps were run as when the model was trained.") + return + + try: + trials, parts = classify_dataframe(self.bundle, rows) + except Exception as e: + QMessageBox.critical(self, "Prediction Error", f"Failed during model prediction:\n{e}") + return + + self.trials_df, self.participants_df = trials, parts + + self._fill_table(self.part_table, [ + [r.Participant, r.N_Trials, f"{r.Mean_Score:+.3f}", f"{r.Frac_Trials_Class_B * 100:.0f}%", + f"{r.Missing_Frac * 100:.1f}%", r.Predicted_Class] + for r in parts.itertuples(index=False)], class_col=5) + self.show_trials(None) + self.result_tabs.setCurrentIndex(0) + + neg, pos = self.bundle["class_neg"], self.bundle["class_pos"] + n_pos = int((parts["Predicted_Class"] == pos).sum()) + text = (f"{len(parts)} participants ({len(parts) - n_pos} {neg}, {n_pos} {pos}) | " + f"{len(trials)} trials | condition: {html.escape(str(condition))}") + if info["dropped"]: + text += (f" | {len(info['dropped'])} file(s) skipped (no trial-specific features)") + self.summary_label.setText(text) + self.export_parts_btn.setEnabled(True) + self.export_trials_btn.setEnabled(True) + + def export_csv(self, which): + df = self.participants_df if which == "participants" else self.trials_df + if df is None or df.empty: + QMessageBox.warning(self, "Warning", "No prediction results available to export.") + return + path, _ = QFileDialog.getSaveFileName( + self, f"Export {which.title()}", f"model_predictions_{which}.csv", "CSV Files (*.csv)") + if not path: + return + try: + out = df.copy() + if "Missing_Frac" in out.columns: + out = out.rename(columns={"Missing_Frac": "Missing_Features_Frac"}) + out.to_csv(path, index=False) + QMessageBox.information(self, "Success", f"Exported to:\n{path}") + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to export CSV:\n{e}") + + +# ========================================================================== +# Plugin entry point +# ========================================================================== +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 + self.widget_instance2: ModelInferenceGUI | None = None + + def register_menu(self, plugin_menu: QMenu) -> None: + open_action = QAction("Open ML Tool", self.main_window) + open_action.triggered.connect(self.show_widget) + open_action2 = QAction("Open ML Tool 2 (Model Inference)", self.main_window) + open_action2.triggered.connect(self.show_widget2) + about_action = QAction("About ML Builder", self.main_window) + about_action.triggered.connect(self.show_about) + plugin_menu.addAction(open_action) + plugin_menu.addAction(open_action2) + plugin_menu.addAction(about_action) + + def show_widget(self) -> None: + if self.widget_instance is None or not self.widget_instance.isVisible(): + self.widget_instance = DataSplitterGUI(main_window=self.main_window) + self.widget_instance.show() + else: + self.widget_instance.raise_() + self.widget_instance.activateWindow() + + def show_widget2(self) -> None: + if self.widget_instance2 is None or not self.widget_instance2.isVisible(): + self.widget_instance2 = ModelInferenceGUI(main_window=self.main_window) + self.widget_instance2.show() + else: + self.widget_instance2.raise_() + self.widget_instance2.activateWindow() + + def show_about(self) -> None: + title = "ML Channel Builder" + version = "2.1.0" + author = "User" + description = ("fNIRS LDA classification: N-vs-T diversity/density grid experiment " + "with metric-combination sweeping and best-model export, plus a " + "model inference tool for classifying newly collected data.") + + 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: + pass + + QMessageBox.about( + self.main_window, f"About {title}", + f"{title}
    Version: {version}  |  " + f"Author: {author}

    {description}") \ No newline at end of file diff --git a/machine-learning-pack-2/plugin.json b/machine-learning-pack-2/plugin.json new file mode 100644 index 0000000..f4cc17a --- /dev/null +++ b/machine-learning-pack-2/plugin.json @@ -0,0 +1,10 @@ +{ + "id": "machine-learning-pack-2", + "name": "Machine Learning Pack", + "version": "0.5.6", + "author": "Tyler de Zeeuw", + "description": "Machine Learning on Processed fNIRS Data. Runs lots of combinations to determine which is best.", + "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/machine-learning-pack-2.zip" +} \ No newline at end of file diff --git a/packages/machine-learning-pack-2.zip b/packages/machine-learning-pack-2.zip new file mode 100644 index 0000000..d605579 Binary files /dev/null and b/packages/machine-learning-pack-2.zip differ diff --git a/plugins.json b/plugins.json index 30f6a6a..cf9611a 100644 --- a/plugins.json +++ b/plugins.json @@ -1,4 +1,14 @@ [ + { + "id": "machine-learning-pack-2", + "name": "Machine Learning Pack", + "version": "0.5.6", + "author": "Tyler de Zeeuw", + "description": "Machine Learning on Processed fNIRS Data. Runs lots of combinations to determine which is best.", + "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/machine-learning-pack-2.zip" + }, { "id": "machine-learning-pack", "name": "Machine Learning Pack",