| |
| """Fit and evaluate A1 alignment caches for target masks. |
| |
| This script consumes Phase 6 artifacts from `run_a1_bootstrap.py` and computes |
| ridge-regression performance for: |
| - Protocol C: cross-subject subject-holdout on a shared canonical stimulus |
| |
| It supports three target-mask modes: |
| - `run_top10`: per-run-condition top-10% ISC masks from Swati outputs |
| - `run_top25`: per-run-condition top-25% ISC masks from Swati outputs |
| - `core_roi`: legacy evaluation with the 7 core language ROIs |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import os |
| from pathlib import Path |
| from typing import Any |
|
|
| import nibabel as nib |
| from nibabel.processing import resample_from_to |
| import numpy as np |
| import pandas as pd |
|
|
| from a1_pipeline.io_utils import ensure_directory, write_json |
| from a1_pipeline.participant_runs import ( |
| load_participant_run_map, |
| resolve_subject_actual_run, |
| resolve_subject_canonical_run, |
| ) |
| from a1_pipeline.targets import CORE_ROI_NAMES, load_core_roi_masks |
|
|
|
|
| TARGET_MASK_MODE_CHOICES: tuple[str, ...] = ("run_top10", "run_top25", "core_roi") |
| RUN_MASK_PERCENT_BY_MODE: dict[str, int] = { |
| "run_top10": 10, |
| "run_top25": 25, |
| } |
| SWATI_CONDITION_BY_CANONICAL_RUN: dict[int, str] = { |
| 1: "single_f", |
| 2: "single_m", |
| 3: "mixed_f", |
| 4: "mixed_m", |
| } |
|
|
|
|
| def _resolve_num_fit_workers(requested: str, n_layers: int) -> tuple[int, int]: |
| """Resolve --num-fit-workers to (n_workers, blas_threads_per_worker). |
| |
| 'auto' picks min(n_layers, max(1, cpu_count // 2)) so each worker still |
| has multiple BLAS threads available for matmul. |
| """ |
| cpu_count = os.cpu_count() or 4 |
|
|
| token = str(requested).strip().lower() |
| if token in {"", "auto"}: |
| n_workers = max(1, min(int(n_layers), max(1, cpu_count // 2))) |
| else: |
| try: |
| value = int(token) |
| except ValueError as exc: |
| raise ValueError(f"Invalid --num-fit-workers={requested!r}") from exc |
| n_workers = max(1, min(value, int(n_layers))) |
|
|
| blas_threads = max(1, cpu_count // n_workers) |
| return n_workers, blas_threads |
|
|
|
|
| def _parse_csv_int_list(raw: str) -> list[int]: |
| tokens = [token.strip() for token in raw.split(",") if token.strip()] |
| return [int(token) for token in tokens] |
|
|
|
|
| def _parse_subjects(raw: str) -> list[str] | None: |
| value = raw.strip().lower() |
| if value == "all": |
| return None |
| tokens = [token.strip() for token in raw.split(",") if token.strip()] |
| return sorted(set(tokens)) |
|
|
|
|
| def _parse_layers(raw: str) -> list[int] | None: |
| value = raw.strip().lower() |
| if value == "all": |
| return None |
| layers = sorted(set(_parse_csv_int_list(raw))) |
| if any(layer < 0 for layer in layers): |
| raise ValueError("Layer indices must be non-negative") |
| return layers |
|
|
|
|
| def _parse_protocols(raw: str) -> set[str]: |
| values = {token.strip().upper() for token in raw.split(",") if token.strip()} |
| if not values: |
| raise ValueError("At least one protocol is required") |
|
|
| allowed = {"C"} |
| unknown = values.difference(allowed) |
| if unknown: |
| raise ValueError(f"Unsupported protocol(s): {sorted(unknown)}") |
|
|
| return values |
|
|
|
|
| def _parse_subject_field(raw: Any) -> list[str]: |
| text = str(raw).strip() |
| if text == "" or text.lower() == "nan": |
| return [] |
| return [token.strip() for token in text.split(",") if token.strip()] |
|
|
|
|
| def _build_parser() -> argparse.ArgumentParser: |
| parser = argparse.ArgumentParser(description="Fit and evaluate A1 cached alignment inputs") |
| parser.add_argument( |
| "--bootstrap-output-dir", |
| type=str, |
| default=None, |
| help="Output directory from run_a1_bootstrap.py (required)", |
| ) |
| parser.add_argument( |
| "--model-slug", |
| type=str, |
| default="Qwen_Qwen3-0.6B", |
| help="Model slug in alignment_regressor_summary.csv", |
| ) |
| parser.add_argument( |
| "--model-id", |
| type=str, |
| default=None, |
| help="Optional human-readable model ID for reporting", |
| ) |
| parser.add_argument( |
| "--alpha", |
| type=float, |
| default=1e3, |
| help="Ridge regularization coefficient", |
| ) |
| parser.add_argument( |
| "--subjects", |
| type=str, |
| default="all", |
| help="Comma-separated subject IDs or 'all'", |
| ) |
| parser.add_argument( |
| "--layer-indices", |
| type=str, |
| default="all", |
| help="Comma-separated layer indices or 'all'", |
| ) |
| parser.add_argument( |
| "--protocols", |
| type=str, |
| default="C", |
| help="Protocols to run: C (cross-subject shared-space).", |
| ) |
| parser.add_argument( |
| "--target-mask-mode", |
| type=str, |
| default="run_top10", |
| choices=sorted(TARGET_MASK_MODE_CHOICES), |
| help=( |
| "Target mask family to evaluate. 'run_top10' selects the ISC top-10%% mask for the " |
| "canonical stimulus behind each actual run, 'run_top25' keeps the 25%% mask option, " |
| "and 'core_roi' keeps the legacy 7-ROI evaluation." |
| ), |
| ) |
| parser.add_argument( |
| "--run-mask-dir", |
| "--run-top10-mask-dir", |
| "--run-top25-mask-dir", |
| dest="run_mask_dir", |
| type=str, |
| default=None, |
| help=( |
| "Directory containing Swati ISC run-conditioned masks. Can point either to the Swati " |
| "output root or directly to its isc_group folder." |
| ), |
| ) |
| parser.add_argument( |
| "--roi-mask-dir", |
| type=str, |
| default=None, |
| help="Legacy core ROI mask directory, used only when --target-mask-mode core_roi.", |
| ) |
| parser.add_argument( |
| "--output-dir", |
| type=str, |
| default=None, |
| help="Fit result output directory (default: <bootstrap-output-dir>/fit_results/<model-slug>)", |
| ) |
| parser.add_argument( |
| "--participant-run-info", |
| type=str, |
| default=str((Path(__file__).resolve().parent / "assets" / "participant_run_info.json")), |
| help=( |
| "JSON file mapping each subject run to canonical stimulus condition " |
| "(default: code/assets/participant_run_info.json)" |
| ), |
| ) |
| parser.add_argument( |
| "--num-fit-workers", |
| type=str, |
| default="auto", |
| help=( |
| "Number of CPU workers to evaluate layers in parallel for protocol C. " |
| "'auto' uses min(n_layers, cpu_count // 2). Use 1 to force serial." |
| ), |
| ) |
| return parser |
|
|
|
|
| def _resolve_core_paths(bootstrap_output_dir: Path) -> dict[str, Path]: |
| csv_dir = bootstrap_output_dir / "csv" |
|
|
| def _pick_csv(name: str) -> Path: |
| csv_candidate = csv_dir / name |
| if csv_candidate.exists(): |
| return csv_candidate |
| return bootstrap_output_dir / name |
|
|
| return { |
| "bootstrap_summary": bootstrap_output_dir / "bootstrap_summary.json", |
| "analysis_mask": bootstrap_output_dir / "analysis_mask.nii.gz", |
| "bold_summary": _pick_csv("alignment_bold_summary.csv"), |
| "regressor_summary": _pick_csv("alignment_regressor_summary.csv"), |
| "protocol_c": _pick_csv("protocol_c_cross_subject_folds.csv"), |
| } |
|
|
|
|
| def _check_required_files(path_map: dict[str, Path], protocols: set[str]) -> None: |
| required_names = { |
| "bootstrap_summary", |
| "analysis_mask", |
| "bold_summary", |
| "regressor_summary", |
| } |
| if "C" in protocols: |
| required_names.add("protocol_c") |
|
|
| missing = [name for name in sorted(required_names) if not path_map[name].exists()] |
| if missing: |
| lines = [f"{name}: {path_map[name]}" for name in missing] |
| raise FileNotFoundError("Missing required files:\n" + "\n".join(lines)) |
|
|
|
|
| def _resolve_core_roi_mask_dir(args: argparse.Namespace, bootstrap_summary: dict[str, Any]) -> Path: |
| if args.roi_mask_dir: |
| return Path(args.roi_mask_dir).resolve() |
|
|
| if "roi_mask_dir" in bootstrap_summary: |
| return Path(str(bootstrap_summary["roi_mask_dir"])).resolve() |
|
|
| return (Path(__file__).resolve().parent / "assets" / "roi_masks") |
|
|
|
|
| def _canonical_run_target_name(canonical_run: int, top_percent: int) -> str: |
| if canonical_run not in SWATI_CONDITION_BY_CANONICAL_RUN: |
| raise KeyError( |
| f"Unsupported canonical run for target mask selection: {canonical_run}. " |
| f"Allowed runs: {sorted(SWATI_CONDITION_BY_CANONICAL_RUN)}" |
| ) |
| return f"{SWATI_CONDITION_BY_CANONICAL_RUN[canonical_run]}_top{int(top_percent)}" |
|
|
|
|
| def _analysis_mask_components( |
| analysis_mask_path: Path, |
| ) -> tuple[nib.Nifti1Image, np.ndarray, np.ndarray]: |
| analysis_img = nib.load(str(analysis_mask_path)) |
| analysis_mask_bool = analysis_img.get_fdata() > 0.5 |
| analysis_flat = analysis_mask_bool.ravel(order="C") |
| analysis_flat_indices = np.flatnonzero(analysis_flat) |
| return analysis_img, analysis_mask_bool, analysis_flat_indices |
|
|
|
|
| def _mask_to_vector_indices( |
| mask_bool: np.ndarray, |
| analysis_mask_bool: np.ndarray, |
| analysis_flat_indices: np.ndarray, |
| mask_name: str, |
| ) -> np.ndarray: |
| mask_in_analysis = analysis_mask_bool & mask_bool |
| mask_flat_indices = np.flatnonzero(mask_in_analysis.ravel(order="C")) |
|
|
| if mask_flat_indices.size == 0: |
| raise ValueError(f"Target mask {mask_name} has zero voxels inside analysis mask") |
|
|
| return np.searchsorted(analysis_flat_indices, mask_flat_indices).astype(np.int64) |
|
|
|
|
| def _build_core_roi_index_maps( |
| analysis_mask_path: Path, |
| roi_mask_dir: Path, |
| ) -> dict[int, dict[str, np.ndarray]]: |
| analysis_img, analysis_mask_bool, analysis_flat_indices = _analysis_mask_components( |
| analysis_mask_path=analysis_mask_path, |
| ) |
|
|
| core_roi_masks, _ = load_core_roi_masks(roi_mask_dir=roi_mask_dir, reference_img=analysis_img) |
|
|
| roi_index_map: dict[str, np.ndarray] = {} |
|
|
| for roi_name in CORE_ROI_NAMES: |
| roi_bool = core_roi_masks[roi_name] |
| roi_index_map[roi_name] = _mask_to_vector_indices( |
| mask_bool=roi_bool, |
| analysis_mask_bool=analysis_mask_bool, |
| analysis_flat_indices=analysis_flat_indices, |
| mask_name=roi_name, |
| ) |
|
|
| return { |
| canonical_run: dict(roi_index_map) |
| for canonical_run in sorted(SWATI_CONDITION_BY_CANONICAL_RUN) |
| } |
|
|
|
|
| def _normalize_run_mask_dir(path: Path, top_percent: int) -> Path | None: |
| candidates = [path, path / "isc_group"] |
| required_files = [ |
| f"{condition}_isc_top{int(top_percent)}.nii.gz" |
| for condition in SWATI_CONDITION_BY_CANONICAL_RUN.values() |
| ] |
|
|
| for candidate in candidates: |
| if not candidate.exists() or not candidate.is_dir(): |
| continue |
| if all((candidate / filename).exists() for filename in required_files): |
| return candidate.resolve() |
|
|
| return None |
|
|
|
|
| def _resolve_run_mask_dir(args: argparse.Namespace, top_percent: int) -> Path: |
| script_path = Path(__file__).resolve() |
| workspace_root = script_path.parents[3] |
|
|
| candidate_roots: list[Path] = [] |
| if args.run_mask_dir: |
| candidate_roots.append(Path(args.run_mask_dir).expanduser().resolve()) |
|
|
| candidate_roots.extend( |
| [ |
| workspace_root / "data" / "isc_group", |
| workspace_root / "data", |
| workspace_root / "swati" / "TEAM-9" / "output", |
| workspace_root / "swati" / "TEAM-9" / "output" / "isc_group", |
| ] |
| ) |
|
|
| for candidate in candidate_roots: |
| normalized = _normalize_run_mask_dir(candidate, top_percent=top_percent) |
| if normalized is not None: |
| return normalized |
|
|
| searched = "\n".join(str(path) for path in candidate_roots) |
| raise FileNotFoundError( |
| f"Could not locate Swati run-top{int(top_percent)} masks. Checked:\n" + searched |
| ) |
|
|
|
|
| def _build_run_mask_index_maps( |
| analysis_mask_path: Path, |
| run_mask_dir: Path, |
| top_percent: int, |
| ) -> dict[int, dict[str, np.ndarray]]: |
| analysis_img, analysis_mask_bool, analysis_flat_indices = _analysis_mask_components( |
| analysis_mask_path=analysis_mask_path, |
| ) |
|
|
| index_maps: dict[int, dict[str, np.ndarray]] = {} |
| for canonical_run, condition in sorted(SWATI_CONDITION_BY_CANONICAL_RUN.items()): |
| mask_name = _canonical_run_target_name(canonical_run, top_percent=top_percent) |
| mask_path = run_mask_dir / f"{condition}_isc_top{int(top_percent)}.nii.gz" |
| if not mask_path.exists(): |
| raise FileNotFoundError( |
| f"Missing run-top{int(top_percent)} mask for {condition}: {mask_path}" |
| ) |
|
|
| mask_img = nib.load(str(mask_path)) |
| resampled = resample_from_to( |
| mask_img, |
| (analysis_img.shape, analysis_img.affine), |
| order=0, |
| ) |
| mask_bool = resampled.get_fdata() > 0.0 |
|
|
| index_maps[canonical_run] = { |
| mask_name: _mask_to_vector_indices( |
| mask_bool=mask_bool, |
| analysis_mask_bool=analysis_mask_bool, |
| analysis_flat_indices=analysis_flat_indices, |
| mask_name=mask_name, |
| ) |
| } |
|
|
| return index_maps |
|
|
|
|
| def _build_bold_path_map(bold_summary_df: pd.DataFrame) -> dict[tuple[str, int], Path]: |
| required_columns = {"subject", "run", "bold_z_path"} |
| missing = required_columns.difference(bold_summary_df.columns) |
| if missing: |
| raise ValueError(f"alignment_bold_summary.csv missing columns: {sorted(missing)}") |
|
|
| duplicate_check = bold_summary_df.groupby(["subject", "run"]).size() |
| duplicates = duplicate_check[duplicate_check > 1] |
| if not duplicates.empty: |
| raise ValueError( |
| "Expected unique bold_z_path per (subject,run). Found duplicates for: " |
| + ", ".join([f"({subject},{run})" for subject, run in duplicates.index.tolist()]) |
| ) |
|
|
| mapping: dict[tuple[str, int], Path] = {} |
| for row in bold_summary_df.itertuples(index=False): |
| subject = str(getattr(row, "subject")) |
| run = int(getattr(row, "run")) |
| path = Path(str(getattr(row, "bold_z_path"))) |
| mapping[(subject, run)] = path |
|
|
| return mapping |
|
|
|
|
| def _build_regressor_path_map( |
| regressor_summary_df: pd.DataFrame, |
| model_slug: str, |
| ) -> tuple[dict[tuple[int, int], Path], str]: |
| required_columns = {"model_id", "model_slug", "run", "layer_idx", "regressor_z_path"} |
| missing = required_columns.difference(regressor_summary_df.columns) |
| if missing: |
| raise ValueError(f"alignment_regressor_summary.csv missing columns: {sorted(missing)}") |
|
|
| model_df = regressor_summary_df[regressor_summary_df["model_slug"] == model_slug].copy() |
| if model_df.empty: |
| available = sorted(set(str(value) for value in regressor_summary_df["model_slug"].tolist())) |
| raise ValueError( |
| f"No regressors found for model_slug={model_slug}. Available slugs: {available}" |
| ) |
|
|
| selected_model_id = str(model_df.iloc[0]["model_id"]) |
|
|
| duplicate_check = model_df.groupby(["run", "layer_idx"]).size() |
| duplicates = duplicate_check[duplicate_check > 1] |
| if not duplicates.empty: |
| details = ", ".join([f"(run={run},layer={layer})" for run, layer in duplicates.index.tolist()]) |
| raise ValueError( |
| "Multiple regressors found for run/layer pairs. " |
| "This fitter expects one regressor per (run,layer) for the chosen model. " |
| f"Conflicts: {details}" |
| ) |
|
|
| mapping: dict[tuple[int, int], Path] = {} |
| for row in model_df.itertuples(index=False): |
| run = int(getattr(row, "run")) |
| layer = int(getattr(row, "layer_idx")) |
| path = Path(str(getattr(row, "regressor_z_path"))) |
| mapping[(run, layer)] = path |
|
|
| return mapping, selected_model_id |
|
|
|
|
| def _load_subject_roi_runs( |
| subject: str, |
| runs: list[int], |
| bold_path_map: dict[tuple[str, int], Path], |
| participant_run_map: dict[str, dict[int, int]], |
| target_index_map_by_canonical_run: dict[int, dict[str, np.ndarray]], |
| ) -> dict[int, dict[str, np.ndarray]]: |
| out: dict[int, dict[str, np.ndarray]] = {} |
|
|
| for run in runs: |
| key = (subject, run) |
| if key not in bold_path_map: |
| raise KeyError(f"Missing BOLD cache for subject={subject}, run={run}") |
|
|
| bold_path = bold_path_map[key] |
| bold_mmap = np.load(bold_path, mmap_mode="r") |
| canonical_run = resolve_subject_canonical_run( |
| participant_run_map=participant_run_map, |
| subject=subject, |
| run=run, |
| ) |
| if canonical_run not in target_index_map_by_canonical_run: |
| raise KeyError( |
| f"Missing target mask indices for canonical_run={canonical_run}, subject={subject}, run={run}" |
| ) |
|
|
| roi_run: dict[str, np.ndarray] = {} |
| for roi_name, roi_indices in target_index_map_by_canonical_run[canonical_run].items(): |
| roi_run[roi_name] = np.asarray(bold_mmap[:, roi_indices], dtype=np.float32) |
|
|
| out[run] = roi_run |
|
|
| return out |
|
|
|
|
| def _ridge_projection_matrix(x_train: np.ndarray, alpha: float) -> np.ndarray: |
| if alpha <= 0: |
| raise ValueError("alpha must be positive") |
|
|
| x_train64 = np.asarray(x_train, dtype=np.float64) |
|
|
| xtx = x_train64.T @ x_train64 |
| reg = np.eye(xtx.shape[0], dtype=np.float64) * float(alpha) |
|
|
| return np.linalg.solve(xtx + reg, x_train64.T) |
|
|
|
|
| def _safe_corr_per_voxel(y_true: np.ndarray, y_pred: np.ndarray, eps: float = 1e-12) -> np.ndarray: |
| yt = np.asarray(y_true, dtype=np.float64) |
| yp = np.asarray(y_pred, dtype=np.float64) |
|
|
| yt_centered = yt - np.mean(yt, axis=0, keepdims=True) |
| yp_centered = yp - np.mean(yp, axis=0, keepdims=True) |
|
|
| numerator = np.sum(yt_centered * yp_centered, axis=0) |
| denom = np.sqrt(np.sum(yt_centered**2, axis=0) * np.sum(yp_centered**2, axis=0)) |
|
|
| corr = np.full(denom.shape, np.nan, dtype=np.float64) |
| valid = denom > eps |
| corr[valid] = numerator[valid] / denom[valid] |
|
|
| return corr |
|
|
|
|
| def _safe_r2_per_voxel(y_true: np.ndarray, y_pred: np.ndarray, eps: float = 1e-12) -> np.ndarray: |
| yt = np.asarray(y_true, dtype=np.float64) |
| yp = np.asarray(y_pred, dtype=np.float64) |
|
|
| sse = np.sum((yt - yp) ** 2, axis=0) |
| yt_mean = np.mean(yt, axis=0, keepdims=True) |
| sst = np.sum((yt - yt_mean) ** 2, axis=0) |
|
|
| r2 = np.full(sst.shape, np.nan, dtype=np.float64) |
| valid = sst > eps |
| r2[valid] = 1.0 - (sse[valid] / sst[valid]) |
|
|
| return r2 |
|
|
|
|
| def _two_v_two_accuracy(y_true: np.ndarray, y_pred: np.ndarray, eps: float = 1e-12) -> tuple[float, int]: |
| yt = np.asarray(y_true, dtype=np.float64) |
| yp = np.asarray(y_pred, dtype=np.float64) |
|
|
| if yt.shape != yp.shape: |
| raise ValueError( |
| "2v2 accuracy expects y_true and y_pred to share shape, got " |
| f"{yt.shape} and {yp.shape}" |
| ) |
| if yt.ndim != 2: |
| raise ValueError(f"2v2 accuracy expects 2D matrices, got ndim={yt.ndim}") |
| if yt.shape[0] < 2 or yt.shape[1] == 0: |
| return float("nan"), 0 |
|
|
| yt_centered = yt - np.mean(yt, axis=1, keepdims=True) |
| yp_centered = yp - np.mean(yp, axis=1, keepdims=True) |
|
|
| yt_norm = np.linalg.norm(yt_centered, axis=1) |
| yp_norm = np.linalg.norm(yp_centered, axis=1) |
|
|
| valid_rows = (yt_norm > eps) & (yp_norm > eps) |
| if int(np.sum(valid_rows)) < 2: |
| return float("nan"), 0 |
|
|
| yt_unit = yt_centered[valid_rows] / yt_norm[valid_rows, None] |
| yp_unit = yp_centered[valid_rows] / yp_norm[valid_rows, None] |
|
|
| similarity = yt_unit @ yp_unit.T |
| diagonal = np.diag(similarity) |
|
|
| |
| pair_margin = diagonal[:, None] + diagonal[None, :] - similarity - similarity.T |
| pair_idx = np.triu_indices(pair_margin.shape[0], k=1) |
| margins = pair_margin[pair_idx] |
|
|
| n_pairs = int(margins.size) |
| if n_pairs == 0: |
| return float("nan"), 0 |
|
|
| wins = float(np.sum(margins > eps)) |
| ties = float(np.sum(np.abs(margins) <= eps)) |
| accuracy = (wins + 0.5 * ties) / float(n_pairs) |
|
|
| return float(accuracy), n_pairs |
|
|
|
|
| def _score_matrix(y_true: np.ndarray, y_pred: np.ndarray) -> dict[str, float]: |
| corr = _safe_corr_per_voxel(y_true=y_true, y_pred=y_pred) |
| r2 = _safe_r2_per_voxel(y_true=y_true, y_pred=y_pred) |
| two_v_two_accuracy, n_2v2_pairs = _two_v_two_accuracy(y_true=y_true, y_pred=y_pred) |
|
|
| finite_corr = corr[np.isfinite(corr)] |
| finite_r2 = r2[np.isfinite(r2)] |
|
|
| return { |
| "mean_corr": float(np.mean(finite_corr)) if finite_corr.size > 0 else float("nan"), |
| "median_corr": float(np.median(finite_corr)) if finite_corr.size > 0 else float("nan"), |
| "mean_r2": float(np.mean(finite_r2)) if finite_r2.size > 0 else float("nan"), |
| "two_v_two_accuracy": float(two_v_two_accuracy), |
| "n_2v2_pairs": int(n_2v2_pairs), |
| "n_voxels_scored": int(finite_corr.size), |
| } |
|
|
|
|
| def _collect_protocol_c_subjects(cross_subject_df: pd.DataFrame) -> list[str]: |
| if cross_subject_df.empty: |
| return [] |
|
|
| subjects: set[str] = set() |
| for row in cross_subject_df.itertuples(index=False): |
| subjects.update(_parse_subject_field(getattr(row, "train_subjects"))) |
| subjects.update(_parse_subject_field(getattr(row, "test_subjects"))) |
|
|
| return sorted(subjects) |
|
|
|
|
| def _evaluate_protocol_c( |
| layers: list[int], |
| alpha: float, |
| cross_subject_df: pd.DataFrame, |
| bold_path_map: dict[tuple[str, int], Path], |
| regressor_path_map: dict[tuple[int, int], Path], |
| target_index_map_by_canonical_run: dict[int, dict[str, np.ndarray]], |
| participant_run_map: dict[str, dict[int, int]], |
| model_slug: str, |
| model_id: str, |
| n_workers: int = 1, |
| blas_threads_per_worker: int = 0, |
| ) -> pd.DataFrame: |
| if cross_subject_df.empty: |
| return pd.DataFrame() |
|
|
| all_subjects = _collect_protocol_c_subjects(cross_subject_df) |
| canonical_runs = sorted({int(value) for value in cross_subject_df["canonical_run"].tolist()}) |
|
|
| required_actual_runs: dict[str, list[int]] = {} |
| for subject in all_subjects: |
| actual_runs = sorted( |
| { |
| resolve_subject_actual_run( |
| participant_run_map=participant_run_map, |
| subject=subject, |
| canonical_run=canonical_run, |
| ) |
| for canonical_run in canonical_runs |
| } |
| ) |
| required_actual_runs[subject] = actual_runs |
|
|
| subject_roi_cache: dict[str, dict[int, dict[str, np.ndarray]]] = {} |
| for subject in all_subjects: |
| subject_roi_cache[subject] = _load_subject_roi_runs( |
| subject=subject, |
| runs=required_actual_runs[subject], |
| bold_path_map=bold_path_map, |
| participant_run_map=participant_run_map, |
| target_index_map_by_canonical_run=target_index_map_by_canonical_run, |
| ) |
|
|
| if blas_threads_per_worker <= 0: |
| blas_threads_per_worker = max(1, (os.cpu_count() or 4) // max(1, n_workers)) |
|
|
| def _layer_task(layer_idx: int) -> list[dict[str, Any]]: |
| return _evaluate_protocol_c_layer( |
| layer_idx=int(layer_idx), |
| alpha=alpha, |
| cross_subject_df=cross_subject_df, |
| regressor_path_map=regressor_path_map, |
| canonical_runs=canonical_runs, |
| subject_roi_cache=subject_roi_cache, |
| target_index_map_by_canonical_run=target_index_map_by_canonical_run, |
| participant_run_map=participant_run_map, |
| model_slug=model_slug, |
| model_id=model_id, |
| blas_threads=blas_threads_per_worker, |
| ) |
|
|
| if n_workers > 1 and len(layers) > 1: |
| from joblib import Parallel, delayed |
|
|
| print( |
| f"[fit] Protocol C: parallel layers across {n_workers} threads " |
| f"(layers={len(layers)}, blas_threads/worker={blas_threads_per_worker})", |
| flush=True, |
| ) |
| results = Parallel(n_jobs=int(n_workers), prefer="threads")( |
| delayed(_layer_task)(layer_idx) for layer_idx in layers |
| ) |
| else: |
| results = [_layer_task(layer_idx) for layer_idx in layers] |
|
|
| rows: list[dict[str, Any]] = [] |
| for layer_rows in results: |
| rows.extend(layer_rows) |
|
|
| df = pd.DataFrame(rows) |
| if not df.empty: |
| df = df.sort_values(["layer_idx", "fold_id", "subject", "roi_name"]).reset_index(drop=True) |
| return df |
|
|
|
|
| def _evaluate_protocol_c_noise_ceiling( |
| cross_subject_df: pd.DataFrame, |
| bold_path_map: dict[tuple[str, int], Path], |
| target_index_map_by_canonical_run: dict[int, dict[str, np.ndarray]], |
| participant_run_map: dict[str, dict[int, int]], |
| ) -> pd.DataFrame: |
| if cross_subject_df.empty: |
| return pd.DataFrame() |
|
|
| all_subjects = _collect_protocol_c_subjects(cross_subject_df) |
| canonical_runs = sorted({int(value) for value in cross_subject_df["canonical_run"].tolist()}) |
|
|
| required_actual_runs: dict[str, list[int]] = {} |
| for subject in all_subjects: |
| actual_runs = sorted( |
| { |
| resolve_subject_actual_run( |
| participant_run_map=participant_run_map, |
| subject=subject, |
| canonical_run=canonical_run, |
| ) |
| for canonical_run in canonical_runs |
| } |
| ) |
| required_actual_runs[subject] = actual_runs |
|
|
| subject_roi_cache: dict[str, dict[int, dict[str, np.ndarray]]] = {} |
| for subject in all_subjects: |
| subject_roi_cache[subject] = _load_subject_roi_runs( |
| subject=subject, |
| runs=required_actual_runs[subject], |
| bold_path_map=bold_path_map, |
| participant_run_map=participant_run_map, |
| target_index_map_by_canonical_run=target_index_map_by_canonical_run, |
| ) |
|
|
| rows: list[dict[str, Any]] = [] |
| for fold_row in cross_subject_df.itertuples(index=False): |
| fold_id = str(getattr(fold_row, "fold_id")) |
| canonical_run = int(getattr(fold_row, "canonical_run")) |
| condition_label = str(getattr(fold_row, "condition_label")) |
| train_subjects = _parse_subject_field(getattr(fold_row, "train_subjects")) |
| test_subjects = _parse_subject_field(getattr(fold_row, "test_subjects")) |
|
|
| if not train_subjects or not test_subjects: |
| raise ValueError(f"Protocol C fold {fold_id} must have non-empty train and test subjects") |
|
|
| for roi_name in target_index_map_by_canonical_run[canonical_run].keys(): |
| y_train_blocks: list[np.ndarray] = [] |
| for subject in train_subjects: |
| actual_run = resolve_subject_actual_run( |
| participant_run_map=participant_run_map, |
| subject=subject, |
| canonical_run=canonical_run, |
| ) |
| y_train_subject = subject_roi_cache[subject][actual_run][roi_name] |
| y_train_blocks.append(y_train_subject) |
|
|
| y_train_stack = np.stack(y_train_blocks, axis=0) |
| y_pred = np.mean(y_train_stack, axis=0, dtype=np.float64) |
|
|
| for subject in test_subjects: |
| actual_run = resolve_subject_actual_run( |
| participant_run_map=participant_run_map, |
| subject=subject, |
| canonical_run=canonical_run, |
| ) |
| y_test = subject_roi_cache[subject][actual_run][roi_name] |
| if y_test.shape != y_pred.shape: |
| raise ValueError( |
| "Noise ceiling test TR/voxel mismatch for " |
| f"subject={subject}, actual_run={actual_run}, canonical_run={canonical_run}, " |
| f"roi={roi_name}. y_pred={y_pred.shape}, y_test={y_test.shape}" |
| ) |
|
|
| scores = _score_matrix(y_true=y_test, y_pred=y_pred) |
|
|
| rows.append( |
| { |
| "protocol": "C_cross_subject_noise_ceiling", |
| "subject": subject, |
| "run": int(actual_run), |
| "canonical_run": int(canonical_run), |
| "condition_label": condition_label, |
| "fold_id": fold_id, |
| "train_subjects": ",".join(train_subjects), |
| "test_subjects": ",".join(test_subjects), |
| "n_train_subjects": int(len(train_subjects)), |
| "n_test_subjects": int(len(test_subjects)), |
| "roi_name": roi_name, |
| "n_train_tr": int(y_pred.shape[0] * len(train_subjects)), |
| "n_test_tr": int(y_test.shape[0]), |
| "n_voxels_roi": int(y_test.shape[1]), |
| **scores, |
| } |
| ) |
|
|
| df = pd.DataFrame(rows) |
| if not df.empty: |
| df = df.sort_values(["fold_id", "subject", "roi_name"]).reset_index(drop=True) |
| return df |
|
|
|
|
| def _evaluate_protocol_c_layer( |
| layer_idx: int, |
| alpha: float, |
| cross_subject_df: pd.DataFrame, |
| regressor_path_map: dict[tuple[int, int], Path], |
| canonical_runs: list[int], |
| subject_roi_cache: dict[str, dict[int, dict[str, np.ndarray]]], |
| target_index_map_by_canonical_run: dict[int, dict[str, np.ndarray]], |
| participant_run_map: dict[str, dict[int, int]], |
| model_slug: str, |
| model_id: str, |
| blas_threads: int, |
| ) -> list[dict[str, Any]]: |
| """Evaluate Protocol C for a single layer. Returns list of result rows.""" |
| from threadpoolctl import threadpool_limits |
|
|
| rows: list[dict[str, Any]] = [] |
|
|
| with threadpool_limits(limits=int(blas_threads)): |
| x_by_canonical_run: dict[int, np.ndarray] = {} |
| for canonical_run in canonical_runs: |
| key = (canonical_run, layer_idx) |
| if key not in regressor_path_map: |
| raise KeyError( |
| "Missing regressor cache for " |
| f"canonical_run={canonical_run}, layer={layer_idx}, model={model_slug}" |
| ) |
| x_by_canonical_run[canonical_run] = np.asarray(np.load(regressor_path_map[key]), dtype=np.float32) |
|
|
| projector_cache: dict[tuple[int, int], tuple[np.ndarray, int]] = {} |
|
|
| for fold_row in cross_subject_df.itertuples(index=False): |
| fold_id = str(getattr(fold_row, "fold_id")) |
| canonical_run = int(getattr(fold_row, "canonical_run")) |
| condition_label = str(getattr(fold_row, "condition_label")) |
| train_subjects = _parse_subject_field(getattr(fold_row, "train_subjects")) |
| test_subjects = _parse_subject_field(getattr(fold_row, "test_subjects")) |
|
|
| if not train_subjects or not test_subjects: |
| raise ValueError(f"Protocol C fold {fold_id} must have non-empty train and test subjects") |
|
|
| x_test = x_by_canonical_run[canonical_run] |
| projector_key = (canonical_run, len(train_subjects)) |
| if projector_key not in projector_cache: |
| x_train = np.vstack([x_test for _ in train_subjects]) |
| projector_cache[projector_key] = ( |
| _ridge_projection_matrix(x_train=x_train, alpha=alpha), |
| int(x_train.shape[0]), |
| ) |
|
|
| projector, n_train_tr = projector_cache[projector_key] |
|
|
| for roi_name in target_index_map_by_canonical_run[canonical_run].keys(): |
| y_train_blocks: list[np.ndarray] = [] |
| for subject in train_subjects: |
| actual_run = resolve_subject_actual_run( |
| participant_run_map=participant_run_map, |
| subject=subject, |
| canonical_run=canonical_run, |
| ) |
| y_train_subject = subject_roi_cache[subject][actual_run][roi_name] |
| if y_train_subject.shape[0] != x_test.shape[0]: |
| raise ValueError( |
| "Protocol C train TR mismatch between regressors and BOLD for " |
| f"subject={subject}, actual_run={actual_run}, canonical_run={canonical_run}, " |
| f"layer={layer_idx}, roi={roi_name}. " |
| f"x_train_tr_per_subject={x_test.shape[0]}, y_train_tr={y_train_subject.shape[0]}" |
| ) |
| y_train_blocks.append(y_train_subject) |
|
|
| y_train = np.vstack(y_train_blocks) |
| if y_train.shape[0] != n_train_tr: |
| raise ValueError( |
| "Protocol C pooled train TR mismatch between regressors and BOLD for " |
| f"fold={fold_id}, layer={layer_idx}, roi={roi_name}. " |
| f"x_train_tr={n_train_tr}, y_train_tr={y_train.shape[0]}" |
| ) |
|
|
| weights = projector @ np.asarray(y_train, dtype=np.float64) |
| y_pred = np.asarray(x_test, dtype=np.float64) @ weights |
|
|
| for subject in test_subjects: |
| actual_run = resolve_subject_actual_run( |
| participant_run_map=participant_run_map, |
| subject=subject, |
| canonical_run=canonical_run, |
| ) |
| y_test = subject_roi_cache[subject][actual_run][roi_name] |
| if y_test.shape[0] != x_test.shape[0]: |
| raise ValueError( |
| "Protocol C test TR mismatch between regressors and BOLD for " |
| f"subject={subject}, actual_run={actual_run}, canonical_run={canonical_run}, " |
| f"layer={layer_idx}, roi={roi_name}. " |
| f"x_test_tr={x_test.shape[0]}, y_test_tr={y_test.shape[0]}" |
| ) |
|
|
| scores = _score_matrix(y_true=y_test, y_pred=y_pred) |
|
|
| rows.append( |
| { |
| "protocol": "C_cross_subject_shared_space", |
| "model_id": model_id, |
| "model_slug": model_slug, |
| "subject": subject, |
| "run": int(actual_run), |
| "canonical_run": int(canonical_run), |
| "condition_label": condition_label, |
| "fold_id": fold_id, |
| "train_subjects": ",".join(train_subjects), |
| "test_subjects": ",".join(test_subjects), |
| "n_train_subjects": int(len(train_subjects)), |
| "n_test_subjects": int(len(test_subjects)), |
| "layer_idx": int(layer_idx), |
| "roi_name": roi_name, |
| "alpha": float(alpha), |
| "n_train_tr": int(n_train_tr), |
| "n_test_tr": int(x_test.shape[0]), |
| "n_voxels_roi": int(y_test.shape[1]), |
| **scores, |
| } |
| ) |
|
|
| return rows |
|
|
|
|
| def _summarize_layers(scores_df: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]: |
| if scores_df.empty: |
| return pd.DataFrame(), pd.DataFrame() |
|
|
| layer_summary = ( |
| scores_df.groupby(["protocol", "model_id", "model_slug", "layer_idx", "roi_name"], as_index=False) |
| .agg( |
| mean_corr=("mean_corr", "mean"), |
| std_corr=("mean_corr", "std"), |
| mean_r2=("mean_r2", "mean"), |
| mean_2v2_accuracy=("two_v_two_accuracy", "mean"), |
| std_2v2_accuracy=("two_v_two_accuracy", "std"), |
| n_records=("mean_corr", "size"), |
| ) |
| .sort_values(["protocol", "roi_name", "layer_idx"]) |
| .reset_index(drop=True) |
| ) |
|
|
| best_rows: list[pd.Series] = [] |
| for (_, roi_name), group_df in layer_summary.groupby(["protocol", "roi_name"]): |
| idx = int(group_df["mean_corr"].idxmax()) |
| best_rows.append(layer_summary.loc[idx]) |
|
|
| best_layer_summary = pd.DataFrame(best_rows).reset_index(drop=True) |
| best_layer_summary = best_layer_summary.sort_values(["protocol", "roi_name"]).reset_index(drop=True) |
|
|
| return layer_summary, best_layer_summary |
|
|
|
|
| def _summarize_noise_ceiling(scores_df: pd.DataFrame) -> pd.DataFrame: |
| if scores_df.empty: |
| return pd.DataFrame() |
|
|
| return ( |
| scores_df.groupby(["protocol", "roi_name"], as_index=False) |
| .agg( |
| mean_corr=("mean_corr", "mean"), |
| std_corr=("mean_corr", "std"), |
| mean_r2=("mean_r2", "mean"), |
| mean_2v2_accuracy=("two_v_two_accuracy", "mean"), |
| std_2v2_accuracy=("two_v_two_accuracy", "std"), |
| n_records=("mean_corr", "size"), |
| ) |
| .sort_values(["protocol", "roi_name"]) |
| .reset_index(drop=True) |
| ) |
|
|
|
|
| def _compare_best_layer_to_noise_ceiling( |
| best_layer_df: pd.DataFrame, |
| noise_ceiling_summary_df: pd.DataFrame, |
| ) -> pd.DataFrame: |
| if best_layer_df.empty or noise_ceiling_summary_df.empty: |
| return pd.DataFrame() |
|
|
| ceiling_df = noise_ceiling_summary_df.rename( |
| columns={ |
| "mean_corr": "noise_ceiling_mean_corr", |
| "std_corr": "noise_ceiling_std_corr", |
| "mean_r2": "noise_ceiling_mean_r2", |
| "mean_2v2_accuracy": "noise_ceiling_mean_2v2_accuracy", |
| "std_2v2_accuracy": "noise_ceiling_std_2v2_accuracy", |
| "n_records": "noise_ceiling_n_records", |
| } |
| ) |
|
|
| merged = best_layer_df.merge(ceiling_df, on="roi_name", how="left") |
| if merged.empty: |
| return merged |
|
|
| for metric in ["mean_corr", "mean_r2", "mean_2v2_accuracy"]: |
| denom = np.asarray(merged[f"noise_ceiling_{metric}"], dtype=np.float64) |
| numer = np.asarray(merged[metric], dtype=np.float64) |
| ratio = np.full(denom.shape, np.nan, dtype=np.float64) |
| valid = np.isfinite(denom) & (np.abs(denom) > 1e-12) |
| ratio[valid] = numer[valid] / denom[valid] |
| merged[f"fraction_of_noise_ceiling_{metric}"] = ratio |
|
|
| return merged |
|
|
|
|
| def main() -> None: |
| parser = _build_parser() |
| args = parser.parse_args() |
|
|
| bootstrap_output_dir = Path(args.bootstrap_output_dir).resolve() |
| protocols = _parse_protocols(args.protocols) |
| path_map = _resolve_core_paths(bootstrap_output_dir=bootstrap_output_dir) |
| _check_required_files(path_map=path_map, protocols=protocols) |
|
|
| with path_map["bootstrap_summary"].open("r", encoding="utf-8") as handle: |
| bootstrap_summary = json.load(handle) |
|
|
| target_mask_mode = str(args.target_mask_mode).strip().lower() |
| if target_mask_mode not in TARGET_MASK_MODE_CHOICES: |
| raise ValueError( |
| f"Unsupported --target-mask-mode={target_mask_mode!r}. " |
| f"Allowed: {sorted(TARGET_MASK_MODE_CHOICES)}" |
| ) |
|
|
| roi_mask_dir: Path | None = None |
| run_mask_dir: Path | None = None |
| target_top_percent: int | None = None |
| if target_mask_mode == "core_roi": |
| roi_mask_dir = _resolve_core_roi_mask_dir(args=args, bootstrap_summary=bootstrap_summary) |
| if not roi_mask_dir.exists(): |
| raise FileNotFoundError(f"ROI mask dir not found: {roi_mask_dir}") |
| else: |
| target_top_percent = RUN_MASK_PERCENT_BY_MODE[target_mask_mode] |
| run_mask_dir = _resolve_run_mask_dir(args=args, top_percent=target_top_percent) |
|
|
| output_dir = ( |
| Path(args.output_dir).resolve() |
| if args.output_dir |
| else bootstrap_output_dir / "fit_results" / str(args.model_slug) |
| ) |
| ensure_directory(output_dir) |
|
|
| bold_summary_df = pd.read_csv(path_map["bold_summary"]) |
| regressor_summary_df = pd.read_csv(path_map["regressor_summary"]) |
| cross_subject_df = ( |
| pd.read_csv(path_map["protocol_c"]) if path_map["protocol_c"].exists() else pd.DataFrame() |
| ) |
|
|
| requested_subjects = _parse_subjects(args.subjects) |
|
|
| if requested_subjects is not None and "C" in protocols: |
| raise ValueError("Protocol C currently requires --subjects all so fold membership stays valid") |
|
|
| if requested_subjects is not None: |
| subject_set = set(requested_subjects) |
| bold_summary_df = bold_summary_df[bold_summary_df["subject"].isin(subject_set)].copy() |
|
|
| if cross_subject_df.empty and "C" in protocols: |
| raise ValueError("Protocol C was requested but no cross-subject rows were available") |
|
|
| bold_path_map = _build_bold_path_map(bold_summary_df=bold_summary_df) |
| regressor_path_map, detected_model_id = _build_regressor_path_map( |
| regressor_summary_df=regressor_summary_df, |
| model_slug=str(args.model_slug), |
| ) |
|
|
| model_id = str(args.model_id) if args.model_id else detected_model_id |
|
|
| available_layers = sorted({layer for (_, layer) in regressor_path_map.keys()}) |
| requested_layers = _parse_layers(args.layer_indices) |
|
|
| if requested_layers is None: |
| layers = available_layers |
| else: |
| layer_set = set(available_layers) |
| missing_layers = [layer for layer in requested_layers if layer not in layer_set] |
| if missing_layers: |
| raise ValueError( |
| f"Requested layers are unavailable for model {args.model_slug}: {missing_layers}. " |
| f"Available: {available_layers}" |
| ) |
| layers = requested_layers |
|
|
| if not layers: |
| raise ValueError("No layers selected for fitting") |
|
|
| protocol_c_subjects = _collect_protocol_c_subjects(cross_subject_df) if "C" in protocols else [] |
| subjects = sorted(set(protocol_c_subjects)) |
| if not subjects: |
| raise ValueError("No subjects available after filtering") |
|
|
| participant_run_info_path = Path(args.participant_run_info).resolve() |
| required_runs = sorted({int(value) for value in bold_summary_df["run"].tolist()}) |
| if not required_runs: |
| raise ValueError("No required runs detected for participant run mapping") |
|
|
| participant_run_map = load_participant_run_map( |
| participant_run_info_path=participant_run_info_path, |
| subjects=subjects, |
| runs=required_runs, |
| ) |
|
|
| if target_mask_mode == "core_roi": |
| target_index_map_by_canonical_run = _build_core_roi_index_maps( |
| analysis_mask_path=path_map["analysis_mask"], |
| roi_mask_dir=roi_mask_dir, |
| ) |
| target_mask_source_dir = roi_mask_dir |
| else: |
| target_index_map_by_canonical_run = _build_run_mask_index_maps( |
| analysis_mask_path=path_map["analysis_mask"], |
| run_mask_dir=run_mask_dir, |
| top_percent=target_top_percent, |
| ) |
| target_mask_source_dir = run_mask_dir |
|
|
| canonical_runs_used = sorted( |
| { |
| canonical_run |
| for subject in subjects |
| for canonical_run in participant_run_map[subject].values() |
| } |
| ) |
|
|
| protocol_c_df = pd.DataFrame() |
| noise_ceiling_df = pd.DataFrame() |
|
|
| n_fit_workers, blas_threads_per_worker = _resolve_num_fit_workers( |
| requested=str(args.num_fit_workers), |
| n_layers=int(len(layers)), |
| ) |
| if n_fit_workers > 1: |
| print( |
| f"[fit] Layer-parallel fitting enabled: workers={n_fit_workers}, " |
| f"blas_threads/worker={blas_threads_per_worker}, layers={len(layers)}, " |
| f"cpu_count={os.cpu_count()}", |
| flush=True, |
| ) |
|
|
| if "C" in protocols: |
| protocol_c_df = _evaluate_protocol_c( |
| layers=layers, |
| alpha=float(args.alpha), |
| cross_subject_df=cross_subject_df, |
| bold_path_map=bold_path_map, |
| regressor_path_map=regressor_path_map, |
| target_index_map_by_canonical_run=target_index_map_by_canonical_run, |
| participant_run_map=participant_run_map, |
| model_slug=str(args.model_slug), |
| model_id=model_id, |
| n_workers=n_fit_workers, |
| blas_threads_per_worker=blas_threads_per_worker, |
| ) |
| protocol_c_df.to_csv(output_dir / "protocol_c_core_roi_scores.csv", index=False) |
| noise_ceiling_df = _evaluate_protocol_c_noise_ceiling( |
| cross_subject_df=cross_subject_df, |
| bold_path_map=bold_path_map, |
| target_index_map_by_canonical_run=target_index_map_by_canonical_run, |
| participant_run_map=participant_run_map, |
| ) |
| noise_ceiling_df.to_csv(output_dir / "noise_ceiling_protocol_c_core_roi_scores.csv", index=False) |
|
|
| combined_df = protocol_c_df.copy() |
| combined_df.to_csv(output_dir / "core_roi_scores_all.csv", index=False) |
|
|
| layer_summary_df, best_layer_df = _summarize_layers(scores_df=combined_df) |
| layer_summary_df.to_csv(output_dir / "core_roi_layer_summary.csv", index=False) |
| best_layer_df.to_csv(output_dir / "core_roi_best_layer_summary.csv", index=False) |
| noise_ceiling_summary_df = _summarize_noise_ceiling(scores_df=noise_ceiling_df) |
| noise_ceiling_summary_df.to_csv(output_dir / "noise_ceiling_core_roi_summary.csv", index=False) |
| best_vs_noise_ceiling_df = _compare_best_layer_to_noise_ceiling( |
| best_layer_df=best_layer_df, |
| noise_ceiling_summary_df=noise_ceiling_summary_df, |
| ) |
| best_vs_noise_ceiling_df.to_csv(output_dir / "best_layer_vs_noise_ceiling.csv", index=False) |
|
|
| run_summary = { |
| "bootstrap_output_dir": str(bootstrap_output_dir), |
| "output_dir": str(output_dir), |
| "model_slug": str(args.model_slug), |
| "model_id": model_id, |
| "alpha": float(args.alpha), |
| "subjects": subjects, |
| "n_subjects": int(len(subjects)), |
| "layers": [int(value) for value in layers], |
| "n_layers": int(len(layers)), |
| "protocols": sorted(protocols), |
| "target_mask_mode": target_mask_mode, |
| "target_top_percent": int(target_top_percent) if target_top_percent is not None else None, |
| "target_mask_source_dir": str(target_mask_source_dir), |
| "roi_mask_dir": str(roi_mask_dir) if roi_mask_dir is not None else None, |
| "run_mask_dir": str(run_mask_dir) if run_mask_dir is not None else None, |
| "run_top10_mask_dir": str(run_mask_dir) if target_top_percent == 10 and run_mask_dir is not None else None, |
| "run_top25_mask_dir": str(run_mask_dir) if target_top_percent == 25 and run_mask_dir is not None else None, |
| "participant_run_info_path": str(participant_run_info_path), |
| "required_runs": [int(value) for value in required_runs], |
| "canonical_runs_used": [int(value) for value in canonical_runs_used], |
| "target_names_by_canonical_run": { |
| str(canonical_run): list(target_index_map_by_canonical_run[canonical_run].keys()) |
| for canonical_run in sorted(target_index_map_by_canonical_run) |
| }, |
| "n_protocol_c_rows": int(len(protocol_c_df)), |
| "n_noise_ceiling_rows": int(len(noise_ceiling_df)), |
| "n_total_rows": int(len(combined_df)), |
| } |
| write_json(output_dir / "fit_run_summary.json", run_summary) |
|
|
| print("=" * 72) |
| print("A1 fit complete") |
| print(f"Model slug: {args.model_slug}") |
| print(f"Model id: {model_id}") |
| print(f"Subjects: {len(subjects)}") |
| print(f"Layers: {len(layers)}") |
| print(f"Alpha: {float(args.alpha)}") |
| print(f"Target mask mode: {target_mask_mode}") |
| print(f"Target mask source: {target_mask_source_dir}") |
| print(f"Protocol C rows: {len(protocol_c_df)}") |
| print(f"Noise ceiling rows: {len(noise_ceiling_df)}") |
| print(f"Output directory: {output_dir}") |
| print("=" * 72) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|