Download code/a1_pipeline/alignment.py from Mohith202/csai: direct link, hf CLI and curl.
- Browser
- Download file 13.8 kB
-
https://huggingface.co/spaces/Mohith202/csai/resolve/main/code/a1_pipeline/alignment.py
- Command line
-
hf download hf://spaces/Mohith202/csai/code/a1_pipeline/alignment.py
-
curl -L -o alignment.py https://huggingface.co/spaces/Mohith202/csai/resolve/main/code/a1_pipeline/alignment.py
13.8 kB
| """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 | |
| 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 | |
| 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 | |