"""TR-level HRF regressor and z-score alignment utilities.""" from __future__ import annotations from dataclasses import asdict, dataclass from pathlib import Path from typing import Any import nibabel as nib import numpy as np import pandas as pd def _zscore_columns(values: np.ndarray, eps: float = 1e-8) -> np.ndarray: mean = np.mean(values, axis=0, keepdims=True) std = np.std(values, axis=0, keepdims=True) std[std < eps] = 1.0 return ((values - mean) / std).astype(np.float32) def _trim_time_axis(values: np.ndarray, trim_start_tr: int, trim_end_tr: int) -> np.ndarray: if trim_start_tr < 0 or trim_end_tr < 0: raise ValueError("trim_start_tr and trim_end_tr must be non-negative") start = int(trim_start_tr) stop = values.shape[0] - int(trim_end_tr) if start >= stop: raise ValueError( "Trimming removed all time points: " f"n_tr={values.shape[0]}, trim_start={trim_start_tr}, trim_end={trim_end_tr}" ) return values[start:stop] def _format_tr_for_filename(tr_seconds: float) -> str: return f"{tr_seconds:.4f}".replace(".", "p") def _assert_same_grid(img: nib.Nifti1Image, reference_img: nib.Nifti1Image, atol: float = 1e-5) -> None: if img.shape[:3] != reference_img.shape: raise ValueError( "Image shape mismatch against analysis mask grid: " f"image={img.shape[:3]}, reference={reference_img.shape}" ) if not np.allclose(img.affine, reference_img.affine, atol=atol): raise ValueError("Image affine mismatch against analysis mask grid") def _extract_masked_bold_2d( bold_path: Path, analysis_mask_bool: np.ndarray, analysis_mask_img: nib.Nifti1Image, ) -> np.ndarray: img = nib.load(str(bold_path)) if len(img.shape) != 4: raise ValueError(f"Expected 4D BOLD image, got shape={img.shape} for {bold_path}") _assert_same_grid(img=img, reference_img=analysis_mask_img) data = np.asanyarray(img.dataobj) # Convert from (x,y,z,t) to (t,voxels) bold_2d = data[analysis_mask_bool, :].T.astype(np.float32) return bold_2d def compute_hrf_regressor_matrix( word_features: np.ndarray, onsets_s: np.ndarray, offsets_s: np.ndarray, tr_seconds: float, n_volumes: int, hrf_model: str, ) -> np.ndarray: """Build TR-level HRF-convolved regressors from word-level features.""" from nilearn.glm.first_level import compute_regressor if word_features.ndim != 2: raise ValueError("word_features must be 2D: (n_words, hidden_dim)") if onsets_s.shape[0] != word_features.shape[0] or offsets_s.shape[0] != word_features.shape[0]: raise ValueError("onset/offset lengths must match n_words in word_features") durations = offsets_s - onsets_s frame_times = np.arange(n_volumes, dtype=np.float64) * float(tr_seconds) + 0.5 * float(tr_seconds) regressors = np.zeros((int(n_volumes), int(word_features.shape[1])), dtype=np.float32) for feature_index in range(word_features.shape[1]): amplitudes = word_features[:, feature_index].astype(np.float64) exp_condition = np.array([onsets_s, durations, amplitudes], dtype=np.float64) signal, _ = compute_regressor(exp_condition, hrf_model, frame_times) regressors[:, feature_index] = signal[:, 0].astype(np.float32) return regressors @dataclass(frozen=True) class BoldAlignmentRecord: """Summary for one subject-run BOLD alignment artifact.""" subject: str run: int tr_seconds: float n_volumes_raw: int n_tr_aligned: int n_voxels: int trim_start_tr: int trim_end_tr: int bold_z_path: str @dataclass(frozen=True) class RegressorAlignmentRecord: """Summary for one model-run-layer regressor artifact.""" model_id: str model_slug: str run: int tr_seconds: float n_volumes_raw: int n_tr_aligned: int layer_idx: int n_features: int trim_start_tr: int trim_end_tr: int hrf_model: str regressor_z_path: str def build_and_cache_alignment_inputs( manifest_df: pd.DataFrame, run_events_df: pd.DataFrame, feature_summary_df: pd.DataFrame, analysis_mask_path: Path, output_dir: Path, trim_start_tr: int, trim_end_tr: int, hrf_model: str, overwrite: bool, alignment_subjects: list[str] | None = None, ) -> tuple[pd.DataFrame, pd.DataFrame, dict[str, Any]]: """Cache z-scored BOLD targets and HRF-convolved z-scored regressors.""" if manifest_df.empty: raise ValueError("Manifest is empty; cannot build alignment inputs") if run_events_df.empty: raise ValueError("run_events_df is empty; cannot build regressors") if feature_summary_df.empty: raise ValueError("feature_summary_df is empty; feature extraction must run first") required_manifest_columns = {"subject", "run", "tr_seconds", "n_volumes", "derivatives_bold_path"} missing_manifest = required_manifest_columns.difference(manifest_df.columns) if missing_manifest: raise ValueError(f"manifest_df missing required columns: {sorted(missing_manifest)}") required_feature_columns = {"model_id", "model_slug", "run", "features_npz_path"} missing_feature = required_feature_columns.difference(feature_summary_df.columns) if missing_feature: raise ValueError(f"feature_summary_df missing required columns: {sorted(missing_feature)}") analysis_mask_img = nib.load(str(analysis_mask_path)) analysis_mask_bool = analysis_mask_img.get_fdata() > 0.5 manifest_alignment_df = manifest_df requested_subjects = [value for value in (alignment_subjects or []) if str(value).strip()] if requested_subjects: subject_set = {str(value) for value in requested_subjects} manifest_alignment_df = manifest_df[manifest_df["subject"].isin(subject_set)].copy() if manifest_alignment_df.empty: raise ValueError( "Alignment subject filter produced no rows. " f"Requested subjects={sorted(subject_set)}" ) output_dir = output_dir.resolve() bold_output_dir = output_dir / "bold_z" regressor_output_dir = output_dir / "regressors" bold_output_dir.mkdir(parents=True, exist_ok=True) regressor_output_dir.mkdir(parents=True, exist_ok=True) bold_rows: list[BoldAlignmentRecord] = [] for row in manifest_alignment_df.itertuples(index=False): subject = str(getattr(row, "subject")) run = int(getattr(row, "run")) tr_seconds = float(getattr(row, "tr_seconds")) n_volumes = int(getattr(row, "n_volumes")) bold_path = Path(str(getattr(row, "derivatives_bold_path"))) bold_z_path = bold_output_dir / f"{subject}_run-{run:02d}_bold_z.npy" if bold_z_path.exists() and not overwrite: aligned_bold = np.load(bold_z_path) else: bold_2d = _extract_masked_bold_2d( bold_path=bold_path, analysis_mask_bool=analysis_mask_bool, analysis_mask_img=analysis_mask_img, ) if bold_2d.shape[0] != n_volumes: raise ValueError( f"BOLD volume mismatch for {subject} run {run}: " f"manifest={n_volumes}, loaded={bold_2d.shape[0]}" ) aligned_bold = _trim_time_axis( values=bold_2d, trim_start_tr=trim_start_tr, trim_end_tr=trim_end_tr, ) aligned_bold = _zscore_columns(aligned_bold) np.save(bold_z_path, aligned_bold) bold_rows.append( BoldAlignmentRecord( subject=subject, run=run, tr_seconds=tr_seconds, n_volumes_raw=n_volumes, n_tr_aligned=int(aligned_bold.shape[0]), n_voxels=int(aligned_bold.shape[1]), trim_start_tr=int(trim_start_tr), trim_end_tr=int(trim_end_tr), bold_z_path=str(bold_z_path), ) ) regressor_rows: list[RegressorAlignmentRecord] = [] # Compute run configuration keys from manifest; regressors depend on run + TR + n_volumes. run_config_map: dict[int, list[tuple[float, int]]] = {} for run, group_df in manifest_alignment_df.groupby("run"): unique_pairs = sorted( {(float(tr), int(n_vol)) for tr, n_vol in zip(group_df["tr_seconds"], group_df["n_volumes"])}, key=lambda pair: (pair[0], pair[1]), ) run_config_map[int(run)] = unique_pairs for feature_row in feature_summary_df.itertuples(index=False): model_id = str(getattr(feature_row, "model_id")) model_slug = str(getattr(feature_row, "model_slug")) run = int(getattr(feature_row, "run")) features_npz_path = Path(str(getattr(feature_row, "features_npz_path"))) if run not in run_config_map: continue run_events = run_events_df[run_events_df["run"] == run].sort_values("word_index") onsets_s = run_events["onset_s"].to_numpy(dtype=np.float64) offsets_s = run_events["offset_s"].to_numpy(dtype=np.float64) feature_bundle = np.load(features_npz_path) layer_keys = sorted( [key for key in feature_bundle.files if key.startswith("layer_")], key=lambda value: int(value.split("_")[1]), ) for tr_seconds, n_volumes in run_config_map[run]: tr_tag = _format_tr_for_filename(tr_seconds) model_regressor_dir = regressor_output_dir / model_slug model_regressor_dir.mkdir(parents=True, exist_ok=True) for layer_key in layer_keys: layer_idx = int(layer_key.split("_")[1]) layer_features = np.asarray(feature_bundle[layer_key], dtype=np.float32) if layer_features.shape[0] != onsets_s.shape[0]: raise ValueError( f"Word count mismatch for model={model_id}, run={run}, layer={layer_idx}: " f"features={layer_features.shape[0]}, events={onsets_s.shape[0]}" ) regressor_z_path = ( model_regressor_dir / f"run-{run:02d}_tr-{tr_tag}_nvol-{n_volumes}_layer-{layer_idx:03d}_regressor_z.npy" ) if regressor_z_path.exists() and not overwrite: regressors_aligned = np.load(regressor_z_path) else: regressors = compute_hrf_regressor_matrix( word_features=layer_features, onsets_s=onsets_s, offsets_s=offsets_s, tr_seconds=tr_seconds, n_volumes=n_volumes, hrf_model=hrf_model, ) regressors_aligned = _trim_time_axis( values=regressors, trim_start_tr=trim_start_tr, trim_end_tr=trim_end_tr, ) regressors_aligned = _zscore_columns(regressors_aligned) np.save(regressor_z_path, regressors_aligned) regressor_rows.append( RegressorAlignmentRecord( model_id=model_id, model_slug=model_slug, run=run, tr_seconds=tr_seconds, n_volumes_raw=n_volumes, n_tr_aligned=int(regressors_aligned.shape[0]), layer_idx=layer_idx, n_features=int(regressors_aligned.shape[1]), trim_start_tr=int(trim_start_tr), trim_end_tr=int(trim_end_tr), hrf_model=hrf_model, regressor_z_path=str(regressor_z_path), ) ) bold_summary_df = pd.DataFrame([asdict(row) for row in bold_rows]) if not bold_summary_df.empty: bold_summary_df = bold_summary_df.sort_values(["subject", "run"]).reset_index(drop=True) regressor_summary_df = pd.DataFrame([asdict(row) for row in regressor_rows]) if not regressor_summary_df.empty: regressor_summary_df = regressor_summary_df.sort_values( ["model_slug", "run", "layer_idx", "tr_seconds", "n_volumes_raw"] ).reset_index(drop=True) alignment_qc: dict[str, Any] = { "analysis_mask_path": str(analysis_mask_path), "hrf_model": hrf_model, "trim_start_tr": int(trim_start_tr), "trim_end_tr": int(trim_end_tr), "alignment_subjects": sorted({str(value) for value in manifest_alignment_df["subject"].tolist()}), "n_bold_rows": int(len(bold_summary_df)), "n_regressor_rows": int(len(regressor_summary_df)), } if not bold_summary_df.empty: alignment_qc["bold_n_tr_min"] = int(bold_summary_df["n_tr_aligned"].min()) alignment_qc["bold_n_tr_max"] = int(bold_summary_df["n_tr_aligned"].max()) alignment_qc["bold_n_voxels_min"] = int(bold_summary_df["n_voxels"].min()) alignment_qc["bold_n_voxels_max"] = int(bold_summary_df["n_voxels"].max()) if not regressor_summary_df.empty: alignment_qc["regressor_n_tr_min"] = int(regressor_summary_df["n_tr_aligned"].min()) alignment_qc["regressor_n_tr_max"] = int(regressor_summary_df["n_tr_aligned"].max()) alignment_qc["regressor_n_features_min"] = int(regressor_summary_df["n_features"].min()) alignment_qc["regressor_n_features_max"] = int(regressor_summary_df["n_features"].max()) return bold_summary_df, regressor_summary_df, alignment_qc