Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,41 @@ found 24 defects plus a dozen false README claims. All verified and fixed here.

### Behaviour changes (read these before re-running a study)

- **Atlases resolve by name to their own files.** `pipeline.atlas` is looked up in
source-localization's `registry.yaml`, so allen26 and allen64 no longer pick up
allen32's labels, mapping and `roi_categories.yaml` from the shared `allen/`
directory. For allen26 studies this changes every region-level table
(Frontal-Anterior and Olfactory were silently dropped; Deep Subcortical was built
from 4 of its 8 parcels) and every ROI mosaic (the six merged parcels drew blank).
ROI-level results are unchanged, and allen32/antwerp studies resolve to the same
files as before. An atlas name that cannot be resolved is now an error, not a guess.
- **The R region tier uses the study's categories.** Every ROI R entry point replaced
the study's (or a profile's) `roi_categories` with the atlas-directory file whenever
one existed. Python now hands R the effective map and R prefers it
(`resolve_roi_categories` in `stats_utils.R`); the file is only a fallback for a
config that carries none.
- **The 10x voxel convention is read from the NIfTI header**, as source-localization
does, not guessed from the filename. `Atlas_3DRoisLeftRight.Labels.nii` has stored
true units since source-localization 2026-03-12 but was still shrunk 10x on the
default-affine path. **No statistic changes**: ROI extraction and cluster/NBS region
labels use the raw affine, which was always right. The only visible effect is
cosmetic: the mm axis ranges and slice labels of `plot_brain_roi_mosaic` /
`plot_brain_roi` when drawn on Antwerp, and no analysis module draws them on Antwerp.
(`load_vertex_roi_labels`, the other default-affine consumer, has never run: it reads
the mapping file's top-level keys as label ids and raises on every atlas, and
`vertex_network` swallows the error and falls back to spatial node labels. Left for
the vertex split.)
- **`electrode_signature` compares only within its own paradigm**, preferring
`roi_signature` over `vertex_signature`. It used to take the first
`vertex_signature_results.csv` anywhere under the results tree, which can be a
stale table from another run. `signature_source_vs_sensor.csv` gains a
`source_module` column.
- **Signature fits run single-threaded** (`threadpoolctl`, installed with scikit-learn).
A run fits a tiny model LOOCV x (1 + n_permutations) times, and a multithreaded BLAS
spent that time synchronising threads: logistic fits ran 60-90x slower (FORGE
electrode features: 105-128 s vs 1.4-1.7 s per 21 LOOCV passes, same accuracy).
Results are unchanged; a signature module that took days now takes about an hour.

- **Vertex `absolute` band power is now a density (dB/Hz)**, `10*log10(integral / bandwidth)`,
matching the ROI/electrode definition. Previously `vertex_cluster` / `vertex_specparam`
reported `10*log10(integral)`. Within-band group statistics are unaffected (a per-band
Expand Down Expand Up @@ -35,6 +70,14 @@ found 24 defects plus a dozen false README claims. All verified and fixed here.

### Added

- **`roi_signature`**: ROI-level neural signature (decoding on per-parcel relative band
power), the source-side counterpart of `electrode_signature` with the identical
feature estimator. It needs no vertex estimate, so it runs on any ROI output,
including Monte Carlo operators. `sensor_paradigm:` compares it against an
`electrode_signature` run in another paradigm.
- **`resolve_atlas` / `AtlasSpec`**, and `atlas_files:` in the study config for atlases
that are not registered. Also `header_is_inflated` and `registered_atlases`.

- **`<module>_subnetwork_edges.csv`** next to `roi_nbs_hypotheses.csv` (ROI edge modules):
one row per supra-threshold edge of every NBS component (`hypothesis, band, dv,
component_id, component_p, significant, node_i, node_j, roi_i, roi_j, stat`). The
Expand Down
8 changes: 2 additions & 6 deletions R/roi_aperiodic_analysis.R
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,8 @@ if (!is.null(args$hypothesis) && length(diag_contrasts) > 0) {
message("Study: ", config$name)
message("Groups: ", paste(group_order, collapse = ", "))

# Load roi_categories from atlas file if provided
if (!is.null(args$roi_categories) && file.exists(args$roi_categories)) {
config$roi_categories <- read_yaml(args$roi_categories)
message("Loaded roi_categories from: ", args$roi_categories,
" (", length(config$roi_categories), " regions)")
}
# Study-config categories win; the atlas file is only a fallback (stats_utils.R).
config$roi_categories <- resolve_roi_categories(config$roi_categories, args$roi_categories)

# ============================================================
# Aperiodic-specific LMM functions (no band dimension)
Expand Down
8 changes: 2 additions & 6 deletions R/roi_connectivity_analysis.R
Original file line number Diff line number Diff line change
Expand Up @@ -897,12 +897,8 @@ message("Study: ", config$name)
message("Groups: ", paste(group_order, collapse = ", "))
message("Bands: ", paste(names(config$bands), collapse = ", "))

# Load roi_categories from atlas file if provided
if (!is.null(args$roi_categories) && file.exists(args$roi_categories)) {
config$roi_categories <- read_yaml(args$roi_categories)
message("Loaded roi_categories from: ", args$roi_categories,
" (", length(config$roi_categories), " regions)")
}
# Study-config categories win; the atlas file is only a fallback (stats_utils.R).
config$roi_categories <- resolve_roi_categories(config$roi_categories, args$roi_categories)

# ===========================================================================
# 1. Global connectivity analysis
Expand Down
9 changes: 2 additions & 7 deletions R/roi_cross_freq_edges_analysis.R
Original file line number Diff line number Diff line change
Expand Up @@ -171,13 +171,8 @@ group_labels <- unlist(config$groups)
group_order <- config$group_order
message("Study: ", config$name)

if (!is.null(args$roi_categories) && file.exists(args$roi_categories)) {
rc <- read_yaml(args$roi_categories)
if (length(rc) == 1 && identical(names(rc), "roi_categories")) rc <- rc[["roi_categories"]]
config$roi_categories <- rc
message("Loaded roi_categories from: ", args$roi_categories,
" (", length(config$roi_categories), " regions)")
}
# Study-config categories win; the atlas file is only a fallback (stats_utils.R).
config$roi_categories <- resolve_roi_categories(config$roi_categories, args$roi_categories)

metrics <- if (!is.null(args$metric)) trimws(strsplit(args$metric, ",")[[1]]) else EDGE_METRICS
metrics <- intersect(EDGE_METRICS, metrics)
Expand Down
8 changes: 2 additions & 6 deletions R/roi_evoked_analysis.R
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,8 @@ message("Contrasts: ", length(contrasts))
message("Study: ", config$name)
message("Groups: ", paste(group_order, collapse = ", "))

# Load roi_categories from atlas file if provided
if (!is.null(args$roi_categories) && file.exists(args$roi_categories)) {
config$roi_categories <- read_yaml(args$roi_categories)
message("Loaded roi_categories from: ", args$roi_categories,
" (", length(config$roi_categories), " regions)")
}
# Study-config categories win; the atlas file is only a fallback (stats_utils.R).
config$roi_categories <- resolve_roi_categories(config$roi_categories, args$roi_categories)

# Get unique measure names
measure_names <- unique(measures_df$measure_name)
Expand Down
8 changes: 2 additions & 6 deletions R/roi_pac_analysis.R
Original file line number Diff line number Diff line change
Expand Up @@ -882,12 +882,8 @@ message("Study: ", config$name)
message("Groups: ", paste(group_order, collapse = ", "))
message("Freq pairs: ", paste(unique(pac$freq_pair), collapse = ", "))

# Load roi_categories from atlas file if provided
if (!is.null(args$roi_categories) && file.exists(args$roi_categories)) {
config$roi_categories <- read_yaml(args$roi_categories)
message("Loaded roi_categories from: ", args$roi_categories,
" (", length(config$roi_categories), " regions)")
}
# Study-config categories win; the atlas file is only a fallback (stats_utils.R).
config$roi_categories <- resolve_roi_categories(config$roi_categories, args$roi_categories)

# ===========================================================================
# 1. Global PAC analysis (summary always needed for figures)
Expand Down
8 changes: 2 additions & 6 deletions R/roi_psd_analysis.R
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,8 @@ if ("delta_ref" %in% power_types && !is.null(config$delta_reference$exclude_band
message(" delta_ref: excluded reference band(s) from testing: ", paste(excl, collapse = ", "))
}

# Load roi_categories from atlas file if provided
if (!is.null(args$roi_categories) && file.exists(args$roi_categories)) {
config$roi_categories <- read_yaml(args$roi_categories)
message("Loaded roi_categories from: ", args$roi_categories,
" (", length(config$roi_categories), " regions)")
}
# Study-config categories win; the atlas file is only a fallback (stats_utils.R).
config$roi_categories <- resolve_roi_categories(config$roi_categories, args$roi_categories)

message("Study: ", config$name)
message("Groups: ", paste(group_order, collapse = ", "))
Expand Down
12 changes: 2 additions & 10 deletions R/roi_transfer_entropy_analysis.R
Original file line number Diff line number Diff line change
Expand Up @@ -795,16 +795,8 @@ message("Study: ", config$name)
message("Groups: ", paste(group_order, collapse = ", "))
message("Bands: ", paste(names(config$bands), collapse = ", "))

# Load roi_categories from atlas file if provided. The pipeline passes an
# unwrapped file (categories at top level); the documented proposed file wraps
# them under a single `roi_categories:` key — unwrap that so either form works.
if (!is.null(args$roi_categories) && file.exists(args$roi_categories)) {
rc <- read_yaml(args$roi_categories)
if (length(rc) == 1 && identical(names(rc), "roi_categories")) rc <- rc[["roi_categories"]]
config$roi_categories <- rc
message("Loaded roi_categories from: ", args$roi_categories,
" (", length(config$roi_categories), " regions)")
}
# Study-config categories win; the atlas file is only a fallback (stats_utils.R).
config$roi_categories <- resolve_roi_categories(config$roi_categories, args$roi_categories)

# ===========================================================================
# 1. Global TE analysis (needed for figures — always compute)
Expand Down
30 changes: 30 additions & 0 deletions R/stats_utils.R
Original file line number Diff line number Diff line change
Expand Up @@ -783,3 +783,33 @@ tost_equivalent <- function(estimate, SE, df, margin, alpha = 0.05) {
}
NA_real_
}


#' Resolve the ROI category map an R entry point should use.
#'
#' The study config written by the Python side carries the EFFECTIVE categories:
#' the study's own map, a profile's narrowing of it, or the atlas's default. It
#' always wins. The --roi-categories file is only a fallback for a config that
#' carries none (an R script run by hand). The file used to win, and because it
#' was looked up per directory -- where allen32, allen26 and allen64 all live --
#' an allen26 study got allen32's partition: two categories matched no parcel and
#' vanished, and Deep Subcortical was built from 4 of its 8 parcels.
#'
#' @param config_categories named list from config$roi_categories (may be NULL)
#' @param path optional path to a roi_categories YAML
#' @return named list of ROI name vectors (possibly empty)
resolve_roi_categories <- function(config_categories, path = NULL) {
if (length(config_categories) > 0) {
message("Using roi_categories from the study config (",
length(config_categories), " regions)")
return(config_categories)
}
if (!is.null(path) && file.exists(path)) {
rc <- yaml::read_yaml(path)
if (length(rc) == 1 && identical(names(rc), "roi_categories")) rc <- rc[["roi_categories"]]
rc[["deprecated_aliases"]] <- NULL
message("Loaded roi_categories from: ", path, " (", length(rc), " regions)")
return(rc)
}
config_categories
}
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ the extra to install:
| Extra | Pulls in | Needed by |
|---|---|---|
| `mne` | mne | `roi_evoked`, `vertex_evoked`, `electrode_evoked` (Morlet TFR) |
| `mvpa` | scikit-learn | `vertex_signature`, `electrode_signature` |
| `mvpa` | scikit-learn | `vertex_signature`, `electrode_signature`, `roi_signature` |
| `network` | networkx | `roi_graph`, `vertex_graph`, `*_nbs`, `*_network` |
| `atlas` | nibabel | atlas readers |
| `all` | all of the above + dev tools | a full study |
Expand Down Expand Up @@ -322,6 +322,21 @@ circos_metrics: [imag_coherence, dwpli, pli, aec, coherence] # gallery circos

jobs: -1 # default worker count for --jobs (-1/0 = all but one core)

# ── Atlas (optional) ───────────────────────────────────────────────
# The parcellation the ROI data were extracted with. Resolved BY NAME to that
# atlas's own files through source-localization's registry.yaml, so atlases that
# share a directory (allen32 / allen26 / allen64 all live in allen/) never borrow
# each other's labels or categories. An unknown name is an error, not a guess.
pipeline:
atlas: allen26
# An atlas that is not in the registry names its files instead (relative paths
# are taken from atlas_dir, else from the source-localization atlas data):
# atlas_files:
# brain_labels: /path/to/labels.nii.gz
# roi_mapping: /path/to/roi_mapping.json
# roi_categories: /path/to/roi_categories.yaml # optional; the study's own
# # roi_categories always win

# ── Random epoch sampling (global default; per-analysis override below) ──
# Code defaults when the block is absent: enabled: false, n_bootstrap: 1.
epoch_sampling:
Expand Down Expand Up @@ -378,6 +393,8 @@ paradigms:
| `hypotheses[]` `{name, kind, weights/groups/predictor}` | hypothesis layer | the declarative tests, run by name via `--hypothesis` |
| `hypotheses[]` `{label, role}` | figures, gallery | readable labels + grouping tag (no gating) |
| `bands` | all spectral/connectivity | frequency bands analysed |
| `pipeline.atlas`, `atlas_files`, `atlas_dir` | atlas I/O, R region tier, mosaics | which parcellation the ROI data use: resolved by name through source-localization's `registry.yaml` to that atlas's own labels / mapping / categories / anatomy; `atlas_files` names the files of an unregistered atlas. The 10× voxel convention is read from each NIfTI header, never inferred from its filename |
| `roi_categories` | region tier (Python + R), mosaics | category → ROI map. The study's map (or a profile's narrowing) always wins over the atlas default, on the Python and R sides alike |
| `epoch_sampling` | spectral/connectivity, all levels | random-epoch resampling (`n_bootstrap: 0` = full timeseries). Precedence: global → `vertex.epoch_sampling` → per-analysis block |
| `jobs` | `run --jobs` default | worker count when `--jobs` is not given |
| `<profile>.{include_analyses, include_hypotheses, bands, rois}` | `run --profile` | a narrowed study written to its own tree (see below) |
Expand Down Expand Up @@ -535,7 +552,8 @@ directed families is tracked, equation-checked, in
|---|---|---|---|
| `electrode_comparison` *(suppl.)* | elec | `electrode_psd` **and** `roi_psd` (same paradigm) | source-vs-electrode band-power concordance + effect-size validation |
| `fcd_comparison` *(suppl.)* | elec | `electrode_connectivity` **and** `vertex_connectivity` — normally in *different* paradigms; sibling paradigm dirs are searched, or set `fcd_comparison.{sensor_dir,source_dir}` | source-vs-sensor FCD comparison (mean + spatial CV) per band × metric |
| `electrode_signature` *(suppl. of `electrode_psd`)* | elec | `electrode_psd` | sensor-level neural signature (decoding on electrode band power) — the source-vs-sensor counterpart of `vertex_signature` |
| `electrode_signature` *(suppl. of `electrode_psd`)* | elec | `electrode_psd` | sensor-level neural signature (decoding on electrode band power) — the sensor counterpart of `roi_signature` / `vertex_signature`, compared when one ran in the same paradigm |
| `roi_signature` | roi | — | ROI-level neural signature (decoding on per-parcel band power) — the source-side counterpart of `electrode_signature`; runs on any ROI output, including Monte Carlo operators. Set `sensor_paradigm:` to compare against an `electrode_signature` run in another paradigm |

`ANALYSIS_METADATA` records these as `supplements` (the primary the gallery nests
them under) plus `requires` (every upstream module, for run ordering).
Expand Down Expand Up @@ -700,6 +718,7 @@ $SA resting --analysis electrode_aperiodic
$SA resting --analysis electrode_comparison # ↳ after electrode_psd AND roi_psd
$SA resting --analysis electrode_connectivity # sensor FC comparator
$SA resting --analysis electrode_signature # ↳ after electrode_psd
$SA resting --analysis roi_signature # source side of the decoding comparison

# Vertex paradigm — whole-brain
$SA vertex --analysis vertex_connectivity # PRIMARY (slow; computes matrices)
Expand Down
45 changes: 33 additions & 12 deletions src/source_analytics/analyses/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,17 @@ def __init__(self, config: StudyConfig, output_dir: Path):
self.fig_dir.mkdir(parents=True, exist_ok=True)
self.tbl_dir.mkdir(parents=True, exist_ok=True)

# Resolve atlas directory for on-the-fly ROI extraction
from ..atlas.atlas_utils import find_atlas_dir
# Resolve the study's atlas to its OWN file set (labels, mapping,
# categories, anatomy). The attribute keeps its old name so every
# ``atlas_dir=self._atlas_dir`` call site passes the spec straight
# through: the atlas functions accept a spec wherever they took a dir.
from ..atlas.atlas_utils import AtlasSpec, resolve_atlas

atlas_name = config.raw.get("pipeline", {}).get("atlas")
atlas_dir_cfg = config.raw.get("atlas_dir")
try:
self._atlas_dir: Path | None = find_atlas_dir(
atlas_dir_cfg, atlas_name=atlas_name,
self._atlas_dir: AtlasSpec | None = resolve_atlas(
config.raw.get("atlas_dir"),
atlas_name=config.raw.get("pipeline", {}).get("atlas"),
files=config.raw.get("atlas_files"),
)
except FileNotFoundError:
self._atlas_dir = None
Expand Down Expand Up @@ -461,7 +464,7 @@ def _call_r_figures_only(self, r_script_name: str, data_csv: str) -> bool:
# over from a prior write when this run has no process step (figures-only).
import yaml
config_path = data_dir / "study_config.yaml"
config_data = dict(self.config.raw)
config_data = self._r_config_data()
sfreq = getattr(self, "_sfreq", None)
if sfreq is None and config_path.exists():
try:
Expand Down Expand Up @@ -507,13 +510,31 @@ def _call_r_figures_only(self, r_script_name: str, data_csv: str) -> bool:
return False

def _r_roi_categories_flags(self) -> list[str]:
"""Return ['--roi-categories', path] if atlas roi_categories.yaml exists."""
if self._atlas_dir is not None:
cat_path = self._atlas_dir / "roi_categories.yaml"
if cat_path.exists():
return ["--roi-categories", str(cat_path)]
"""``['--roi-categories', path]`` for the resolved atlas's own category file.

Only a fallback for R: ``_r_config_data`` already hands R the effective
categories, and the R side prefers them (``resolve_roi_categories``).
"""
spec = self._atlas_dir
if spec is not None and spec.roi_categories is not None and spec.roi_categories.exists():
return ["--roi-categories", str(spec.roi_categories)]
return []

def _r_config_data(self) -> dict:
"""The config the R side reads (``study_config.yaml``).

``raw`` plus the EFFECTIVE ``roi_categories``: the study's own map, a
profile's narrowing of it, or the atlas default. ``raw`` carries neither of
the last two, and R used to fill the gap from the category file in the
atlas *directory* -- in ``allen/``, allen32's -- so allen26 studies lost two
categories and built Deep Subcortical from 4 of its 8 parcels.
"""
data = dict(self.config.raw)
if self.config.roi_categories:
data["roi_categories"] = {
cat: list(rois) for cat, rois in self.config.roi_categories.items()}
return data

def _r_no_figures_flags(self) -> list[str]:
"""Return ['--no-figures'] if figure generation is disabled, else []."""
if not self._generate_figures:
Expand Down
2 changes: 1 addition & 1 deletion src/source_analytics/analyses/electrode_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ def summary(self) -> None:
config_path = data_dir / "study_config.yaml"
import yaml

config_data = dict(self.config.raw)
config_data = self._r_config_data()
if self._sfreq is not None:
config_data["sfreq"] = self._sfreq
with open(config_path, "w") as f:
Expand Down
Loading
Loading