diff --git a/CanlabCore/@fmri_data/compute_rsm.m b/CanlabCore/@fmri_data/compute_rsm.m new file mode 100644 index 00000000..a8c723ef --- /dev/null +++ b/CanlabCore/@fmri_data/compute_rsm.m @@ -0,0 +1,1022 @@ +function R = compute_rsm(dat, varargin) +% compute_rsm Build an rsm object from an fmri_data object. +% +% Omnibus constructor that subsumes the four bespoke generate_RSA* wrappers +% in `C:\Temp\` and the inline RSM construction throughout the WASABI / Sun +% et al. workflow corpus. Drives all subsequent RSA inference machinery +% (cells, contrasts, ttest_contrasts, reliability, drift, rsa_lm, rsa_lme, +% compare_models, rsa_parcelwise, searchlight_rsa). +% +% Usage examples +% -------------- +% % One RSM per subject, conditions on the rows/cols, Spearman correlation +% R = compute_rsm(dat, 'group_by','condition', 'subject_var','subject_id', ... +% 'metric','spearman'); +% +% % Per-session 3D rsm (subjects x sessions stacked along dim 3) +% R = compute_rsm(dat, 'level','session', 'group_by','condition', ... +% 'subject_var','subject_id', 'session_var','session_number'); +% +% % Crossnobis with two-fold session split + session-difference whitening +% R = compute_rsm(dat, 'metric','crossnobis', ... +% 'group_by','condition', 'subject_var','subject_id', ... +% 'fold_var','session_number', 'whiten','session_difference'); +% +% % Parcelwise: array of rsm objects, one per atlas parcel +% R = compute_rsm(dat, 'parcellation', canlab2024_atlas, ... +% 'group_by','condition', 'subject_var','subject_id'); +% +% % Sun et al. diagonal correction: replace same-bodysite diagonal with +% % across-session same-bodysite mean +% R = compute_rsm(dat, 'group_by','condition_x_bodysite', ... +% 'subject_var','subject_id', ... +% 'diagonal_correction','across_session_mean', ... +% 'diagonal_group_by','bodysite', ... +% 'session_var','session_number'); +% +% Inputs +% ------ +% dat fmri_data object. dat.metadata_table must contain all metadata +% columns referenced by name in the optional arguments. +% +% varargin name-value pairs: +% +% Aggregation: +% 'group_by' Metadata column whose unique values become the +% rows/cols of the RSM. Accepts either a single +% column name (string/char) or a cellstr of column +% names that get concatenated into a composite key. +% Example: {'condition','bodysite'} on Sun et al. +% data builds the canonical 24-row RSM (3 conditions +% x 8 bodysites). +% Default: '' (no grouping; one row per image -- image-level RSM). +% +% 'auto_groupings' logical. When true (default), auto-builds R.groupings +% with one bare-name entry per unique value in each +% group_by column. For example, with +% group_by={'condition','bodysite'}, you get +% R.groupings.hot = 1:8, R.groupings.leftface = [1,9,17], +% etc. -- so reliability_by_grouping(), R.cells('hot','hot'), +% and R.contrast() work without manual setup. +% Set to false if you prefer to attach R.groupings yourself. +% 'condition_collapse' 'mean' | 'concat' | 'none'. How to aggregate when +% multiple images map to one condition. Default 'mean'. +% +% Replicate axis: +% 'level' 'subject' (default) | 'session' | 'run' | 'collapsed' | 'image' +% How to split the data along the 3rd dim. +% 'subject' -> one slice per subject +% 'session' -> one slice per (subject, session) +% 'run' -> one slice per (subject, session, run) +% 'collapsed' -> one slice (all data merged) +% 'image' -> no grouping; image-level RSM per subject +% 'subject_var' Metadata column for subject IDs. Default 'subject_id'. +% 'session_var' Metadata column for session number (required for +% level='session' or fold_var='session_number'). +% Default 'session_number'. +% 'run_var' Metadata column for run number (required for +% level='run'). +% Default 'run_number'. +% +% Metric: +% 'metric' 'correlation' (default) | 'spearman' | 'cosine' | +% 'euclidean' | 'seuclidean' | 'mahalanobis' | +% 'crossnobis' | 'cvcorr' | 'cvspearman' +% 'cvcorr'/'cvspearman' are CROSS-VALIDATED +% correlation similarities: each cell (i,j) is the +% mean correlation between condition i and condition +% j taken from DIFFERENT folds (never the same fold). +% This removes within-fold shared structure (e.g. +% shared-run noise when several conditions co-occur +% in one run) while staying in correlation space -- +% the diagonal is the cross-fold reliability, not 1. +% Requires 'fold_var'. Reproduces the Sun et al. +% (2026) BodyMap "exclude within-run correlations" +% RSM as a one-liner. +% +% Cross-validated metrics (crossnobis / cvcorr / cvspearman): +% 'fold_var' Metadata column defining cross-validation folds +% (required for these metrics). Default +% 'session_number'. Special value 'occurrence' +% auto-builds folds by within-condition occurrence +% rank (each condition's 1st image -> fold 1, 2nd -> +% fold 2, ...), ordered by run_var when present. +% Use 'occurrence' when no metadata column lines the +% conditions up into shared folds (e.g. when run +% numbers differ across conditions). +% 'cv_scheme' (cvcorr/cvspearman only) 'allpairs' (default) | +% 'loo'. 'allpairs' correlates single-fold patterns +% for every ordered fold pair (most conservative, +% lowest power). 'loo' correlates each held-out fold +% against the mean of the other folds (higher SNR / +% MORE POWER, still never sharing a fold). Ignored by +% crossnobis (always all-pairs). +% +% Whitening: +% 'whiten' 'none' (default) | 'within_subject' | +% 'across_subject' | 'session_difference' +% within_subject and across_subject use covdiag. +% session_difference is the crossnobis whitening. +% 'whiten_method' 'covdiag' (default) | 'diag' | 'none' +% +% Diagonal correction: +% 'diagonal_correction' 'none' (default) | 'across_session_mean' | 'nan' +% 'diagonal_group_by' Metadata column to group by for diagonal correction. +% Default '' (uses 'group_by' if set). +% +% Spatial restriction & preprocessing: +% 'mask' image_vector / atlas / region / char keyword. +% Passed to apply_mask. Default [] (no mask). +% 'parcellation' atlas object. If supplied, returns an array of rsm +% objects, one per parcel. Default [] (no parcellation). +% 'smooth_mm' Pre-smoothing FWHM in mm. Default [] (no smoothing). +% +% Similarity-matrix options: +% 'treat_zero_as_data' logical. Passed through to canlab_compute_similarity_matrix. +% Default false. +% +% Misc: +% 'use_parallel' logical. Default false. +% 'verbose' logical. Default true. +% +% Output +% ------ +% R rsm object, or [nParcels x 1] array of rsm objects when 'parcellation' +% is set. + +% ========================================================================= +% Parse inputs +% ========================================================================= +p = inputParser; +p.KeepUnmatched = false; +p.addParameter('group_by', '', @(x) ischar(x) || isstring(x) || iscellstr(x) || (iscell(x) && all(cellfun(@(c) ischar(c) || isstring(c), x)))); +p.addParameter('condition_collapse', 'mean', @(x) ischar(x) || isstring(x)); +p.addParameter('level', 'subject', @(x) ischar(x) || isstring(x)); +p.addParameter('subject_var', 'subject_id', @(x) ischar(x) || isstring(x)); +p.addParameter('session_var', 'session_number', @(x) ischar(x) || isstring(x)); +p.addParameter('run_var', 'run_number', @(x) ischar(x) || isstring(x)); +p.addParameter('metric', 'correlation', @(x) ischar(x) || isstring(x)); +p.addParameter('fold_var', 'session_number', @(x) ischar(x) || isstring(x)); +p.addParameter('cv_scheme', 'allpairs', @(x) (ischar(x)||isstring(x)) && ismember(lower(char(x)),{'allpairs','loo'})); +p.addParameter('whiten', 'none', @(x) ischar(x) || isstring(x)); +p.addParameter('whiten_method', 'covdiag', @(x) ischar(x) || isstring(x)); +p.addParameter('diagonal_correction', 'none', @(x) ischar(x) || isstring(x)); +p.addParameter('diagonal_group_by', '', @(x) ischar(x) || isstring(x)); +p.addParameter('mask', [], @(x) true); +p.addParameter('parcellation', [], @(x) true); +p.addParameter('smooth_mm', [], @(x) isempty(x) || isnumeric(x)); +p.addParameter('treat_zero_as_data', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('nan_policy', 'propagate', @(x) (ischar(x) || isstring(x)) && ismember(lower(char(x)), {'propagate','skip_replicate'})); +p.addParameter('auto_groupings', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('use_parallel', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('verbose', true, @(x) islogical(x) || isnumeric(x)); +p.parse(varargin{:}); +opt = p.Results; + +% Normalize string args (leave group_by alone if cellstr — handled below) +fn = fieldnames(opt); +for i = 1:numel(fn) + if strcmp(fn{i}, 'group_by'), continue; end + if (ischar(opt.(fn{i})) || isstring(opt.(fn{i}))) && ~isempty(opt.(fn{i})) + opt.(fn{i}) = char(opt.(fn{i})); + end +end +% group_by: normalize to cellstr (length 1 = simple, length >1 = composite) +if isstring(opt.group_by), opt.group_by = cellstr(opt.group_by); end +if ischar(opt.group_by) && ~isempty(opt.group_by), opt.group_by = {opt.group_by}; end +if isempty(opt.group_by), opt.group_by = {}; end + +% ========================================================================= +% Validate combinations +% ========================================================================= +% Fold-based (cross-validated) metrics require a fold column. +if ismember(lower(opt.metric), {'crossnobis','cvcorr','cvspearman'}) && isempty(opt.fold_var) + error('compute_rsm:noFoldVar', ... + 'metric=''%s'' requires ''fold_var'' to identify the cross-validation fold column.', opt.metric); +end +if strcmpi(opt.metric, 'crossnobis') + if strcmpi(opt.whiten, 'within_subject') + warning('compute_rsm:doubleWhitening', ... + ['Both crossnobis and within_subject whitening requested; crossnobis already ', ... + 'includes session-difference whitening. Skipping within_subject whitening.']); + opt.whiten = 'session_difference'; + elseif strcmpi(opt.whiten, 'none') + % Crossnobis without whitening is uncommon; default to session_difference + opt.whiten = 'session_difference'; + end +end + +% ========================================================================= +% Pre-processing +% ========================================================================= +if ~isempty(opt.smooth_mm) + if opt.verbose, fprintf('compute_rsm: smoothing at FWHM = %g mm\n', opt.smooth_mm); end + dat = preprocess(dat, 'smooth', opt.smooth_mm); +end + +if ~isempty(opt.mask) + if opt.verbose, fprintf('compute_rsm: applying mask\n'); end + dat = apply_mask(dat, opt.mask); +end + +% ========================================================================= +% Parcellation branch — returns an array of rsm objects +% ========================================================================= +if ~isempty(opt.parcellation) + R = compute_rsm_parcelwise(dat, opt); + return +end + +% ========================================================================= +% Single-mask branch +% ========================================================================= +R = compute_rsm_one_mask(dat, opt, ''); + +end + + +% ========================================================================= +function R_array = compute_rsm_parcelwise(dat, opt) + +atlas_obj = opt.parcellation; +n_parcels = num_regions(atlas_obj); +labels = atlas_obj.labels; +if numel(labels) < n_parcels + labels = [labels, arrayfun(@(i) sprintf('parcel_%d', i), (numel(labels)+1):n_parcels, 'UniformOutput', false)]; +end + +R_array = rsm.empty; +if opt.verbose, fprintf('compute_rsm: running parcelwise (%d parcels)\n', n_parcels); end + +% Resample the atlas into the DATA space ONCE (handles atlas/data space +% mismatch and avoids a per-parcel apply_mask, which is both slow and +% fragile). codes(v) gives the integer parcel index for data voxel v. +if compare_space(dat, atlas_obj) ~= 0 + if opt.verbose, fprintf(' resampling atlas to data space...\n'); end + [~, atlas_rs] = evalc('resample_space(atlas_obj, dat)'); +else + atlas_rs = atlas_obj; +end +codes = round(double(atlas_rs.dat(:, 1))); + +% Align parcel codes to dat.dat rows (drop removed voxels if present) +if ~isempty(dat.removed_voxels) && numel(dat.removed_voxels) == numel(codes) + codes = codes(~dat.removed_voxels); +end +if numel(codes) ~= size(dat.dat, 1) + % Last-resort fallback: per-parcel apply_mask (original slow path) + if opt.verbose, warning('compute_rsm:parcelCodeMismatch', ... + 'Atlas codes (%d) do not align with data voxels (%d); using per-parcel apply_mask.', ... + numel(codes), size(dat.dat,1)); end + R_array = compute_rsm_parcelwise_applymask(dat, atlas_obj, labels, n_parcels, opt); + return +end + +dat_mat = double(dat.dat); % [voxels x images] +for i = 1:n_parcels + if opt.verbose && mod(i, 50) == 0, fprintf(' parcel %d/%d\n', i, n_parcels); end + vox = (codes == i); + if nnz(vox) < 2 + R_array(end+1) = rsm(); %#ok + continue + end + dat_i = dat; + dat_i.dat = dat_mat(vox, :); + try + R_array(end+1) = compute_rsm_one_mask(dat_i, opt, labels{i}); %#ok + catch ME + warning('compute_rsm:parcelFailed', 'Parcel %s failed: %s', labels{i}, ME.message); + R_array(end+1) = rsm(); %#ok + end +end + +end + + +% ========================================================================= +function R_array = compute_rsm_parcelwise_applymask(dat, atlas_obj, labels, n_parcels, opt) +% Fallback: per-parcel apply_mask (used only when integer-code alignment fails). +R_array = rsm.empty; +for i = 1:n_parcels + try + parcel_mask = select_atlas_subset(atlas_obj, labels(i)); + dat_i = apply_mask(dat, parcel_mask); + R_array(end+1) = compute_rsm_one_mask(dat_i, opt, labels{i}); %#ok + catch ME + warning('compute_rsm:parcelFailed', 'Parcel %s failed: %s', labels{i}, ME.message); + R_array(end+1) = rsm(); %#ok + end +end +end + + +% ========================================================================= +function R = compute_rsm_one_mask(dat, opt, parcel_name) +% Build a single rsm (possibly 3D across the replicate axis) for one mask. + +mt = dat.metadata_table; +if isempty(mt) && ~strcmpi(opt.level, 'image') + error('compute_rsm:noMetadata', ... + 'dat.metadata_table is empty but level=''%s'' was requested. Add metadata or use level=''image''.', opt.level); +end + +% Determine replicate axis groupings +[replicate_groups, replicate_table] = determine_replicate_axis(mt, opt); +N = numel(replicate_groups); + +% Determine condition (row/col) axis +[group_idx, group_labels, group_meta] = determine_group_axis(mt, opt); +k = numel(group_labels); + +% Build per-replicate RSMs +dat_mat = double(dat.dat); % [voxels x n_images] +out_3d = nan(k, k, N); +whitened_info = struct('level', opt.whiten, 'method', opt.whiten_method, 'shrinkage', []); + +% Quality-control accumulator: detect missing-condition cells per replicate. +% This is the upstream source of NaN in the output RSM stack. +qc = struct(); +qc.nan_policy = opt.nan_policy; +qc.n_replicates = N; +qc.n_replicates_dropped = 0; +qc.replicate_qc = repmat(struct( ... + 'replicate_index', 0, ... + 'replicate_label', '', ... + 'n_missing_conditions', 0, ... + 'missing_condition_idx', [], ... + 'missing_condition_labels', {{}}, ... + 'dropped', false), N, 1); + +for n = 1:N + rep_rows = replicate_groups{n}; % image indices belonging to this replicate + if isempty(rep_rows), continue; end + + % Thread cross-validation fold_idx through opt (per-replicate). + % Applies to all fold-based metrics: crossnobis + cvcorr/cvspearman. + opt_n = opt; + if ismember(lower(opt.metric), {'crossnobis','cvcorr','cvspearman'}) + if strcmpi(opt.fold_var, 'occurrence') + % Auto-build folds by within-condition occurrence rank. Each + % condition's 1st image -> fold 1, 2nd -> fold 2, etc. This aligns + % every condition into the same fold set (the crossnobis + % requirement) even when run/session numbering does not. Ordered + % by run_number when present, else by row order. + local_group = group_idx(rep_rows); + if ismember(opt.run_var, mt.Properties.VariableNames) + order_key = double(mt.(opt.run_var)(rep_rows)); + else + order_key = (1:numel(rep_rows))'; + end + fold_vals = zeros(numel(rep_rows), 1); + ug = unique(local_group); + for gg = 1:numel(ug) + idx_g = find(local_group == ug(gg)); + [~, ord] = sort(order_key(idx_g)); + fold_vals(idx_g(ord)) = 1:numel(idx_g); + end + opt_n.fold_idx = fold_vals(:); + else + require_col(mt, opt.fold_var); + fold_vals = mt.(opt.fold_var)(rep_rows); + if iscategorical(fold_vals), fold_vals = double(fold_vals); + elseif iscell(fold_vals) || isstring(fold_vals), [~, ~, fold_vals] = unique(fold_vals, 'stable'); + else, fold_vals = double(fold_vals); + end + opt_n.fold_idx = fold_vals(:); + end + if numel(unique(opt_n.fold_idx)) < 2 + warning('compute_rsm:tooFewFolds', ... + 'Replicate %d has only %d unique fold(s); %s returns NaN slice.', ... + n, numel(unique(opt_n.fold_idx)), lower(opt.metric)); + out_3d(:, :, n) = NaN; + continue + end + end + + [M, n_per_condition] = compute_one_rsm( ... + dat_mat(:, rep_rows), ... + group_idx(rep_rows), ... + group_meta_subset(mt, rep_rows), ... + k, opt_n); + + % Record per-replicate qc: which conditions had zero images + qc.replicate_qc(n).replicate_index = n; + if ~isempty(replicate_table) + qc.replicate_qc(n).replicate_label = build_replicate_label(replicate_table, n); + end + if any(~isnan(n_per_condition)) + missing = find(n_per_condition == 0); + qc.replicate_qc(n).n_missing_conditions = numel(missing); + qc.replicate_qc(n).missing_condition_idx = missing; + if ~isempty(group_labels) + qc.replicate_qc(n).missing_condition_labels = group_labels(missing); + end + + if ~isempty(missing) && strcmpi(opt.nan_policy, 'skip_replicate') + qc.replicate_qc(n).dropped = true; + qc.n_replicates_dropped = qc.n_replicates_dropped + 1; + out_3d(:, :, n) = NaN; + continue % skip diagonal correction etc. for dropped slices + end + end + + % Diagonal correction + if ~strcmpi(opt.diagonal_correction, 'none') + diag_group_col = opt.diagonal_group_by; + if isempty(diag_group_col), diag_group_col = opt.group_by; end + if isempty(diag_group_col) + warning('compute_rsm:noDiagGroup', 'diagonal_correction requested but no diagonal_group_by or group_by; skipping.'); + else + % Build per-row diag-group at the k-condition level by mapping each + % row to an integer ID based on its diag_group_col value. Resolve + % the row's diag_group value from the first image of that condition, + % then run a single findgroups() across ALL k rows so the resulting + % integer IDs are consistent (rows with the same diag_group value + % get the same integer). + row_diag_vals = cell(k, 1); + for g = 1:k + first_idx = find(group_idx(rep_rows) == g, 1, 'first'); + if isempty(first_idx), row_diag_vals{g} = ''; continue; end + abs_idx = rep_rows(first_idx); + v = mt.(diag_group_col)(abs_idx); + if iscell(v), v = v{1}; end + if iscategorical(v) || isstring(v), v = char(v); end + if isnumeric(v), v = num2str(v); end + row_diag_vals{g} = char(v); + end + % Map distinct strings to integer group IDs + [~, ~, row_diag_group] = unique(row_diag_vals, 'stable'); + if any(row_diag_group > 0) + % The 'across_session_mean' / 'same_group_offdiag_mean' path + % does not need per-row fold/session info -- pass NaN. + M = apply_diagonal_correction(M, row_diag_group, [], opt.diagonal_correction); + end + end + end + + out_3d(:, :, n) = M; +end + +% qc summary across replicates +n_with_missing = sum(arrayfun(@(s) s.n_missing_conditions > 0, qc.replicate_qc)); +qc.n_replicates_with_missing = n_with_missing; +if n_with_missing > 0 && opt.verbose + if strcmpi(opt.nan_policy, 'skip_replicate') + warning('compute_rsm:missingCells', ... + ['%d of %d replicates had missing condition(s); %d slice(s) dropped (nan_policy=''skip_replicate''). ', ... + 'Inspect R.additional_info.qc.replicate_qc for per-replicate detail.'], ... + n_with_missing, N, qc.n_replicates_dropped); + else + warning('compute_rsm:missingCells', ... + ['%d of %d replicates had missing condition(s) -- those slices have NaN rows/cols ', ... + '(nan_policy=''propagate''). Inspect R.additional_info.qc.replicate_qc, or re-run ', ... + 'with ''nan_policy'',''skip_replicate'' to drop bad slices.'], n_with_missing, N); + end +end + +% Within-subject and across-subject whitening operate on the stacked RSMs +if strcmpi(opt.whiten, 'within_subject') + out_3d = whiten_rsm_stack(out_3d, opt.whiten_method); + whitened_info.level = 'within_subject'; +end +if strcmpi(opt.whiten, 'across_subject') + out_3d = whiten_rsm_stack(out_3d, opt.whiten_method); + whitened_info.level = 'across_subject'; +end + +% Determine is_dissimilarity from metric +is_dissim = ismember(lower(opt.metric), {'euclidean','seuclidean','mahalanobis','crossnobis'}); + +% Source string +if isempty(parcel_name) + source = 'fmri_data'; +else + source = ['parcel:' parcel_name]; +end + +% Auto-build groupings from each group_by column (e.g., group_by={condition, +% bodysite} -> groupings.hot, groupings.warm, groupings.imagine, +% groupings.leftface, etc.) so downstream methods (cells, contrast, +% ttest_contrasts, reliability_by_grouping) work without manual setup. +auto_groupings = struct(); +if logical(opt.auto_groupings) && ~isempty(opt.group_by) && ~isempty(group_meta) && k > 0 + auto_groupings = build_auto_groupings(group_meta, opt.group_by); +end + +R = rsm(out_3d, ... + 'is_dissimilarity', is_dissim, ... + 'metric', opt.metric, ... + 'labels', group_labels, ... + 'metadata_table', group_meta, ... + 'groupings', auto_groupings, ... + 'level', opt.level, ... + 'replicate_table', replicate_table, ... + 'whitened', whitened_info, ... + 'source', source, ... + 'additional_info', struct('qc', qc)); + +end + + +function groupings = build_auto_groupings(group_meta, group_by_cols) +% Build a bare-name struct of groupings from each group_by column. For +% group_meta with columns {condition, bodysite}, returns groupings like +% groupings.hot = [1:8], groupings.leftface = [1 9 17], etc. +% +% On name collisions across columns (rare), the later value wins; a single +% summary warning is emitted listing the conflicts. + +groupings = struct(); +collisions = {}; +for c = 1:numel(group_by_cols) + col = group_by_cols{c}; + if ~ismember(col, group_meta.Properties.VariableNames), continue; end + v = group_meta.(col); + % Get unique values preserving order + if iscell(v) || iscategorical(v) || isstring(v) + v_str = cellstr(string(v)); + else + v_str = cellstr(string(v)); + end + [unique_vals, ~, ~] = unique(v_str, 'stable'); + for j = 1:numel(unique_vals) + name = sanitize_grouping_name(unique_vals{j}); + if isempty(name), continue; end + idx = find(strcmp(v_str, unique_vals{j})); + if isfield(groupings, name) + collisions{end+1} = name; %#ok + end + groupings.(name) = idx(:)'; + end +end +if ~isempty(collisions) + warning('compute_rsm:groupingCollision', ... + ['Auto-groupings had name collisions on: %s. Later column values overwrote earlier. ', ... + 'Set ''auto_groupings'',false and attach R.groupings manually if you need both.'], ... + strjoin(unique(collisions), ', ')); +end +end + + +function s = sanitize_grouping_name(name) +% Strip leading/trailing whitespace, replace non-word characters with +% underscores, ensure leading letter so it's a valid struct field. +s = char(name); +s = strtrim(s); +s = regexprep(s, '[^A-Za-z0-9_]', '_'); +if isempty(s), return; end +if ~isletter(s(1)), s = ['x' s]; end +end + + +function lbl = build_replicate_label(replicate_table, n) +% Build a short string describing replicate n from the table columns. +cols = replicate_table.Properties.VariableNames; +parts = cell(numel(cols), 1); +for i = 1:numel(cols) + v = replicate_table.(cols{i})(n); + parts{i} = sprintf('%s=%s', cols{i}, char(string(v))); +end +lbl = strjoin(parts, '/'); +end + + +% ========================================================================= +function [groups, replicate_table] = determine_replicate_axis(mt, opt) + +switch lower(opt.level) + case 'collapsed' + groups = {(1:height(mt))'}; + replicate_table = table({'all'}, 'VariableNames', {'aggregation'}); + case 'subject' + require_col(mt, opt.subject_var); + [G, ID] = findgroups(mt.(opt.subject_var)); + groups = arrayfun(@(g) find(G == g), 1:max(G), 'UniformOutput', false)'; + replicate_table = table(ID, 'VariableNames', {opt.subject_var}); + case 'session' + require_col(mt, opt.subject_var); + require_col(mt, opt.session_var); + [G, S, Sess] = findgroups(mt.(opt.subject_var), mt.(opt.session_var)); + groups = arrayfun(@(g) find(G == g), 1:max(G), 'UniformOutput', false)'; + replicate_table = table(S, Sess, 'VariableNames', {opt.subject_var, opt.session_var}); + case 'run' + require_col(mt, opt.subject_var); + require_col(mt, opt.session_var); + require_col(mt, opt.run_var); + [G, S, Sess, Run] = findgroups(mt.(opt.subject_var), mt.(opt.session_var), mt.(opt.run_var)); + groups = arrayfun(@(g) find(G == g), 1:max(G), 'UniformOutput', false)'; + replicate_table = table(S, Sess, Run, 'VariableNames', {opt.subject_var, opt.session_var, opt.run_var}); + case 'image' + % One slice per subject, but no condition collapsing — image-level RSM per subject + require_col(mt, opt.subject_var); + [G, ID] = findgroups(mt.(opt.subject_var)); + groups = arrayfun(@(g) find(G == g), 1:max(G), 'UniformOutput', false)'; + replicate_table = table(ID, 'VariableNames', {opt.subject_var}); + otherwise + error('compute_rsm:badLevel', 'level must be one of {subject, session, run, collapsed, image}; got %s.', opt.level); +end + +end + + +% ========================================================================= +function [group_idx, group_labels, group_meta] = determine_group_axis(mt, opt) +% Returns: +% group_idx [n_images x 1] integer label (1..k) per image +% group_labels {k x 1} cellstr names for each row/col +% group_meta [k x 1] table of metadata for each row/col +% +% opt.group_by is a cellstr (length 1 = simple grouping, length >1 = composite). + +if isempty(opt.group_by) || strcmpi(opt.level, 'image') + % Image-level: each row/col is its own image + n = height(mt); + group_idx = (1:n)'; + if n > 0 + group_labels = cellfun(@(i) sprintf('img_%d', i), num2cell(1:n)', 'UniformOutput', false); + group_meta = mt; + else + group_labels = {}; + group_meta = mt; + end + return +end + +cols = opt.group_by; +for i = 1:numel(cols), require_col(mt, cols{i}); end + +if numel(cols) == 1 + v = mt.(cols{1}); + [group_idx, G_vals] = findgroups(v); + group_labels = cellstr(string(G_vals)); +else + % Composite grouping: stringify each column and concatenate with '_' + parts = cell(height(mt), numel(cols)); + for i = 1:numel(cols) + v = mt.(cols{i}); + if iscell(v) + parts(:, i) = cellfun(@(x) char(string(x)), v, 'UniformOutput', false); + else + parts(:, i) = cellstr(string(v)); + end + end + composite_key = strings(height(mt), 1); + for r = 1:height(mt) + composite_key(r) = strjoin(parts(r, :), '_'); + end + [group_idx, G_vals] = findgroups(composite_key); + group_labels = cellstr(string(G_vals)); +end + +% Per-condition metadata: take first row per group +group_meta = table.empty; +if ~isempty(mt) + k = numel(group_labels); + rows = cell(k, 1); + for g = 1:k + idxs = find(group_idx == g); + rows{g} = mt(idxs(1), :); + end + group_meta = vertcat(rows{:}); +end + +end + + +% ========================================================================= +function [M, n_per_condition] = compute_one_rsm(X, group_idx, ~, k, opt) +% X [voxels x n_images] data +% group_idx [n_images x 1] integer labels (1..k) +% k total number of groups (rows/cols in output) +% opt option struct +% +% Returns: +% M [k x k] similarity / dissimilarity +% n_per_condition [k x 1] image counts per condition. NaN-filled for the +% crossnobis path (which uses fold-level aggregation). + +n_per_condition = nan(k, 1); + +% --- Crossnobis path --- +if strcmpi(opt.metric, 'crossnobis') + M = compute_crossnobis(X, group_idx, opt); + return +end + +% --- Cross-validated (cross-fold) correlation path --- +if ismember(lower(opt.metric), {'cvcorr','cvspearman'}) + M = compute_cvcorr(X, group_idx, opt); + return +end + +% --- All other metrics path --- +% Collapse to one pattern per condition +[P, n_per_condition] = aggregate_patterns(X, group_idx, k, opt.condition_collapse); % [k x voxels] + +switch lower(opt.metric) + case 'correlation' + % Use pairwise-missing-aware similarity primitive + try + M = canlab_compute_similarity_matrix(P', ... + 'similarity_metric', 'correlation', ... + 'treat_zero_as_data', logical(opt.treat_zero_as_data), ... + 'verbose', false); + catch + M = corr(P', 'Type', 'Pearson', 'rows', 'pairwise'); + end + case 'spearman' + % Routed through the canlab primitive (Spearman support added 2026-05). + try + M = canlab_compute_similarity_matrix(P', ... + 'similarity_metric', 'spearman', ... + 'treat_zero_as_data', logical(opt.treat_zero_as_data), ... + 'verbose', false); + catch + M = corr(P', 'Type', 'Spearman', 'rows', 'pairwise'); + end + case 'cosine' + try + M = canlab_compute_similarity_matrix(P', ... + 'similarity_metric', 'cosine_similarity', ... + 'treat_zero_as_data', logical(opt.treat_zero_as_data), ... + 'verbose', false); + catch + d = pdist(P, 'cosine'); + M = 1 - squareform(d); + M(1:k+1:end) = 1; + end + case 'euclidean' + d = pdist(P, 'euclidean'); + M = squareform(d); + case 'seuclidean' + d = pdist(P, 'seuclidean'); + M = squareform(d); + case 'mahalanobis' + % Regularized covariance from the rows of P + try + sigma = ledoit_wolf_shrinkage(P); + d = pdist(P, 'mahalanobis', sigma); + catch + d = pdist(P, 'mahalanobis'); + end + M = squareform(d); + otherwise + error('compute_rsm:badMetric', 'Unknown metric: %s', opt.metric); +end + +end + + +% ========================================================================= +function M = compute_crossnobis(X, group_idx, opt) +% X [voxels x n_images] +% group_idx [n_images x 1] integer condition labels (1..k) + +% Need fold labels for this slice. Look up from the parent function's +% metadata via a side channel: we re-derive from group_idx + opt.fold_var +% by relying on the fact that compute_rsm_one_mask passes us the per-image +% group_idx but NOT the fold_idx. We need fold_idx here. +% +% Solution: stash the fold_idx in opt at the caller before invoking. +% But for clarity, we instead require the caller to put fold_idx in opt. +% Since opt comes from compute_rsm's parse — wire fold_idx through there. + +if ~isfield(opt, 'fold_idx') || isempty(opt.fold_idx) + error('compute_rsm:crossnobisNoFold', ... + 'Internal error: crossnobis path requires opt.fold_idx to be set by compute_rsm_one_mask.'); +end + +fold_idx = opt.fold_idx; % [n_images x 1] + +% Build per-fold pattern matrices [k x voxels] +Xfolds = build_fold_pattern_matrices(X, group_idx, fold_idx); + +% Whiten via session-difference (the standard crossnobis pre-whitening) +if any(strcmpi(opt.whiten, {'session_difference','within_subject','across_subject'})) + Xfolds = whiten_session_difference(Xfolds); +end + +% Mean-center across conditions within each fold +for f = 1:numel(Xfolds) + Xfolds{f} = Xfolds{f} - mean(Xfolds{f}, 1, 'omitnan'); +end + +% Cross-validated dissimilarity: mean over all distinct fold pairs of +% (X_i[a] - X_i[b]) .* (X_j[a] - X_j[b]) +[k, ~] = size(Xfolds{1}); +M = zeros(k, k); +n_folds = numel(Xfolds); +fold_pairs = nchoosek(1:n_folds, 2); +n_pairs = size(fold_pairs, 1); + +for a = 1:k + for b = (a+1):k + d_accum = 0; + n_used = 0; + for p = 1:n_pairs + i = fold_pairs(p, 1); j = fold_pairs(p, 2); + di = Xfolds{i}(a, :) - Xfolds{i}(b, :); + dj = Xfolds{j}(a, :) - Xfolds{j}(b, :); + mask = isfinite(di) & isfinite(dj); + if any(mask) + d_accum = d_accum + mean(di(mask) .* dj(mask)); + n_used = n_used + 1; + end + end + if n_used > 0 + M(a, b) = d_accum / n_used; + M(b, a) = M(a, b); + end + end +end + +end + + +% ========================================================================= +function M = compute_cvcorr(X, group_idx, opt) +% Cross-validated (cross-fold) correlation SIMILARITY matrix. +% +% The two condition patterns being correlated ALWAYS come from different +% folds, so any within-fold shared structure (e.g. the shared-run noise + +% run/bodysite mean when Hot/Warm/Imagine co-occur in one run) does NOT +% contribute to the correlation. This reproduces the Sun et al. (2026) +% BodyMap RSM construction (per-run RSMs, exclude within-session/run +% correlations, average the rest) as a single metric. Unlike crossnobis it +% stays in correlation (similarity) space; the diagonal M(i,i) is the +% cross-fold reliability of condition i (NOT 1). +% +% cv_scheme: +% 'allpairs' (default) -- M(i,j) = mean over ordered fold pairs (a,b), a~=b, +% of corr(Xfolds{a}(i,:), Xfolds{b}(j,:)). Most conservative: every +% correlation is between two SINGLE-fold patterns (noisiest, lowest +% power, fully symmetric use of the data). +% 'loo' -- leave-one-fold-out. For each held-out fold f, +% M(i,j) += corr(Xfolds{f}(i,:), meanOfRest_f(j,:)), where meanOfRest_f +% is the mean over the OTHER folds. The training side is averaged over +% n_folds-1 folds => higher SNR, less attenuation, MORE POWER, while +% still never sharing a fold. Closer to the paper's whole-brain +% leave-session-out masking. Result symmetrized over held-out/train roles. +% +% metric='cvcorr' -> Pearson; metric='cvspearman' -> Spearman. + +if ~isfield(opt, 'fold_idx') || isempty(opt.fold_idx) + error('compute_rsm:cvcorrNoFold', ... + 'Internal error: cvcorr path requires opt.fold_idx to be set by compute_rsm.'); +end + +Xfolds = build_fold_pattern_matrices(X, group_idx, opt.fold_idx); +n_folds = numel(Xfolds); +k = size(Xfolds{1}, 1); + +if strcmpi(opt.metric, 'cvspearman'), ctype = 'Spearman'; else, ctype = 'Pearson'; end +scheme = 'allpairs'; +if isfield(opt, 'cv_scheme') && ~isempty(opt.cv_scheme), scheme = lower(char(opt.cv_scheme)); end + +M_accum = zeros(k, k); +M_count = zeros(k, k); + +switch scheme + case 'allpairs' + for a = 1:n_folds + for b = 1:n_folds + if a == b, continue; end + % Rab(i,j) = corr(condition i in fold a, condition j in fold b) + Rab = corr(Xfolds{a}', Xfolds{b}', 'type', ctype, 'rows', 'pairwise'); + good = isfinite(Rab); + M_accum(good) = M_accum(good) + Rab(good); + M_count(good) = M_count(good) + 1; + end + end + + case 'loo' + % Per-condition running sum + present-count across folds so the + % mean-of-rest can be formed by subtracting the held-out fold. Handles + % conditions missing from some folds (partial sessions). + n_vox = size(Xfolds{1}, 2); + Xsum = zeros(k, n_vox); + pres = zeros(k, 1); + for f = 1:n_folds + Xf = Xfolds{f}; + pf = all(isfinite(Xf), 2); % [k x 1] conditions present in fold f + Xf0 = Xf; Xf0(~pf, :) = 0; + Xsum = Xsum + Xf0; + pres = pres + pf; + end + for f = 1:n_folds + Xf = Xfolds{f}; + pf = all(isfinite(Xf), 2); + Xf0 = Xf; Xf0(~pf, :) = 0; + rest_cnt = pres - double(pf); % folds contributing to rest, per condition + Rest = nan(k, n_vox); + v = rest_cnt > 0; + Rest(v, :) = (Xsum(v, :) - Xf0(v, :)) ./ rest_cnt(v); + % Rf(i,j) = corr(held-out fold cond i, mean-of-rest cond j) + Rf = corr(Xf', Rest', 'type', ctype, 'rows', 'pairwise'); + good = isfinite(Rf); + M_accum(good) = M_accum(good) + Rf(good); + M_count(good) = M_count(good) + 1; + end + + otherwise + error('compute_rsm:badCvScheme', ... + 'cv_scheme must be ''allpairs'' or ''loo''; got %s.', scheme); +end + +M = M_accum ./ max(M_count, 1); +M(M_count == 0) = NaN; +M = (M + M') / 2; % symmetric in expectation / over held-out roles + +end + + +% ========================================================================= +function [P, n_per_condition] = aggregate_patterns(X, group_idx, k, mode) +% Average (or otherwise aggregate) X within each group, returning [k x voxels]. +% +% Second output n_per_condition is a [k x 1] integer vector of how many +% images contributed to each group. Used by compute_rsm_one_mask to detect +% missing-condition replicates for the nan_policy logic. + +n_vox = size(X, 1); +P = nan(k, n_vox); +n_per_condition = zeros(k, 1); + +switch lower(mode) + case 'mean' + for g = 1:k + rows = group_idx == g; + n_per_condition(g) = nnz(rows); + if any(rows) + P(g, :) = mean(X(:, rows), 2, 'omitnan')'; + end + end + case 'concat' + error('compute_rsm:concatNotImplemented', ... + '''concat'' aggregation is reserved for image-level RSMs (level=''image''); not implemented here.'); + case 'none' + if any(accumarray(group_idx(:), 1) > 1) + error('compute_rsm:noneRequiresSingleton', ... + 'condition_collapse=''none'' requires exactly one image per condition.'); + end + for g = 1:k + row = find(group_idx == g, 1); + P(g, :) = X(:, row)'; + end + otherwise + error('compute_rsm:badCollapse', 'Unknown condition_collapse: %s', mode); +end + +end + + +% ========================================================================= +function out_3d = whiten_rsm_stack(out_3d, method) +% Vectorize upper triangle of each k x k slice -> [N x p] matrix, +% whiten across rows, re-square. + +[k, ~, N] = size(out_3d); +mask = triu(true(k), 1); +p = nnz(mask); +X = zeros(N, p); +for n = 1:N + slice = out_3d(:, :, n); + X(n, :) = slice(mask)'; +end + +Xw = whiten_within_subject(X, method); + +for n = 1:N + slice = zeros(k); + slice(mask) = Xw(n, :); + slice = slice + slice'; + slice(1:k+1:end) = 1; % diagonal heuristic: 1 for RSM, ignored for RDM display + out_3d(:, :, n) = slice; +end + +end + + +% ========================================================================= +function require_col(mt, col) +if ~ismember(col, mt.Properties.VariableNames) + error('compute_rsm:missingColumn', ... + 'dat.metadata_table is missing required column ''%s''.', col); +end +end + + +function v = coerce_to_int(x) +if iscategorical(x), v = double(x); +elseif iscell(x), [~, ~, v] = unique(x, 'stable'); +elseif isstring(x), [~, ~, v] = unique(x, 'stable'); +else, v = double(x); +end +v = v(1); +end + + +function meta_sub = group_meta_subset(mt, rep_rows) %#ok +% Currently unused; reserved for future condition_collapse='none' / merging paths +meta_sub = []; +end diff --git a/CanlabCore/@fmri_data/hrf_fit.m b/CanlabCore/@fmri_data/hrf_fit.m index 2f5caf01..c3b6769b 100644 --- a/CanlabCore/@fmri_data/hrf_fit.m +++ b/CanlabCore/@fmri_data/hrf_fit.m @@ -126,10 +126,10 @@ if iscell(obj) - for i = numel(obj) + for i = 1:numel(obj) - [params_obj{i}, hrf_obj{i}, params_obj_dat{i}, hrf_obj_dat{i}] = hrf_fit(obj{i},TR,Runc,T,method,mode, varargin); + [params_obj{i}, hrf_obj{i}, params_obj_dat{i}, hrf_obj_dat{i}] = hrf_fit(obj{i},TR,Runc,T,method,mode, varargin{:}); end @@ -203,8 +203,7 @@ end - hrf_fit(obj) - + error('Struct SPM input handling is not supported in this entrypoint. Use EstHRF_inAtlas/hrf_fit for SPM workflows.'); end diff --git a/CanlabCore/@fmri_data/rsa_compare_models.m b/CanlabCore/@fmri_data/rsa_compare_models.m new file mode 100644 index 00000000..87ee4038 --- /dev/null +++ b/CanlabCore/@fmri_data/rsa_compare_models.m @@ -0,0 +1,6 @@ +function varargout = rsa_compare_models(dat, varargin) +% rsa_compare_models Thin alias for rsa_compare_lme_models. +% +% See rsa_compare_lme_models.m for full documentation. +[varargout{1:nargout}] = rsa_compare_lme_models(dat, varargin{:}); +end diff --git a/CanlabCore/@fmri_data/rsa_lm.m b/CanlabCore/@fmri_data/rsa_lm.m new file mode 100644 index 00000000..36ca567e --- /dev/null +++ b/CanlabCore/@fmri_data/rsa_lm.m @@ -0,0 +1,113 @@ +function [mdl, tbl, info] = rsa_lm(dat, varargin) +% rsa_lm Fixed-effects multi-level RSA via fitlm. +% +% Pools all (i, j) upper-triangle pairs from the omnibus image-level RSM +% (including cross-subject pairs by default per design doc §6.6) and fits +% a linear model with same-vs-different predictors. Subject is treated as +% a same-vs-different predictor itself (SameSubject), NOT as a random effect. +% +% Use rsa_lme() instead if you want random effects. +% +% Usage examples +% -------------- +% % Default: all pairs, all predictors +% mdl = rsa_lm(dat, ... +% 'predictors', {'subject_id','session_number','condition','bodysite'}); +% +% % Within-subject only (subset of the data; conceptually equivalent to +% % the LME fixed-effect part) +% mdl = rsa_lm(dat, ... +% 'predictors', {'condition','bodysite','session_number'}, ... +% 'pair_scope', 'within_subject'); +% +% % With interactions +% mdl = rsa_lm(dat, ... +% 'predictors', {'condition','bodysite','session_number'}, ... +% 'interactions', {{'condition','bodysite'},{'session_number','condition'}}); +% +% Optional name-value +% ------------------- +% 'predictors' cellstr of metadata columns. +% 'interactions' cell of pair-cellstr. +% 'three_way' cell of triple-cellstr. +% 'subject_var' Default 'subject_id'. +% 'pair_scope' 'all' (default) | 'within_subject'. +% 'response_transform' 'fisherz' (default) | 'none' | 'rank'. +% 'metric' 'correlation' (default) | 'spearman' | 'cosine'. +% 'standardize' logical (default false). Standardize predictors +% before fit. +% 'verbose' logical (default true). +% +% Outputs +% ------- +% mdl LinearModel object +% tbl long-format table used for fitting +% info struct from assemble_lme_table + +p = inputParser; +p.addParameter('predictors', {}, @(x) iscellstr(x) || isstring(x) || isempty(x)); %#ok +p.addParameter('interactions', {}, @iscell); +p.addParameter('three_way', {}, @iscell); +p.addParameter('subject_var', 'subject_id', @(x) ischar(x) || isstring(x)); +p.addParameter('pair_scope', 'all', @(x) (ischar(x) || isstring(x)) && ismember(lower(char(x)), {'all','within_subject'})); +p.addParameter('response_transform', 'fisherz', @(x) ischar(x) || isstring(x)); +p.addParameter('metric', 'correlation', @(x) ischar(x) || isstring(x)); +p.addParameter('standardize', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('verbose', true, @(x) islogical(x) || isnumeric(x)); +p.parse(varargin{:}); +opt = p.Results; + +predictors = cellstr(opt.predictors); +if isempty(predictors) + error('rsa_lm:noPredictors', 'Pass at least one predictor.'); +end + +% ========================================================================= +% Assemble table +% ========================================================================= +[tbl, info] = assemble_lme_table(dat, ... + 'predictors', predictors, ... + 'interactions', opt.interactions, ... + 'three_way', opt.three_way, ... + 'subject_var', opt.subject_var, ... + 'pair_scope', opt.pair_scope, ... + 'response_transform', opt.response_transform, ... + 'metric', opt.metric, ... + 'verbose', opt.verbose); + +% ========================================================================= +% Build formula +% ========================================================================= +% All predictor + interaction Same columns are the fixed effects. +% For pair_scope='all', subject_var was folded into predictors by +% assemble_lme_table, so SameSubject is already in info.predictor_names. +rhs_terms = [info.predictor_names, info.interaction_names, info.three_way_names]; +rhs_terms = unique(rhs_terms, 'stable'); % guard against any duplication + +% Optional standardize: z-score predictor columns in-place +if logical(opt.standardize) + for i = 1:numel(rhs_terms) + col = rhs_terms{i}; + v = tbl.(col); + if std(v) > 0, tbl.(col) = (v - mean(v)) ./ std(v); end + end +end + +formula = sprintf('Y ~ %s', strjoin(rhs_terms, ' + ')); + +if opt.verbose + fprintf('rsa_lm: fitting formula:\n %s\n (n=%d rows, scope=%s)\n', ... + formula, height(tbl), opt.pair_scope); +end + +% ========================================================================= +% Fit +% ========================================================================= +mdl = fitlm(tbl, formula); + +if opt.verbose + fprintf('rsa_lm: R^2 = %.4f, adjusted R^2 = %.4f\n', ... + mdl.Rsquared.Ordinary, mdl.Rsquared.Adjusted); +end + +end diff --git a/CanlabCore/@fmri_data/rsa_lm_by_subject.m b/CanlabCore/@fmri_data/rsa_lm_by_subject.m new file mode 100644 index 00000000..7545189c --- /dev/null +++ b/CanlabCore/@fmri_data/rsa_lm_by_subject.m @@ -0,0 +1,134 @@ +function [T, mdls, info] = rsa_lm_by_subject(dat, varargin) +% rsa_lm_by_subject Per-subject fitlm fits with aggregated coefficient table. +% +% Replicates the per-subject loop from `08072024 Run-Level RDM Analysis with +% RSA Toolbox.mlx` lines 1879-1900 + lines 2050-2079 (partial R²): for each +% subject, fits a separate `fitlm` of similarity ~ same-vs-different predictors +% on that subject's within-subject pairs only. Returns a long-format table of +% coefficients with subject IDs so you can run group-level inference on the +% per-subject betas (paired ttest across subjects, etc.). +% +% Differs from rsa_lme: per-subject FIXED-effects fits aggregated post hoc, +% rather than one mixed-effects model treating Subject as a random effect. +% Useful for assessing between-subject variability in coefficients and for +% sanity-checking the LME estimates. +% +% Usage +% ----- +% T = rsa_lm_by_subject(dat, ... +% 'predictors', {'condition','bodysite','sesno'}, ... +% 'interactions', {{'condition','bodysite'}}, ... +% 'subject_var', 'sub'); +% +% Optional name-value +% ------------------- +% Same as rsa_lme except no random-effects spec. Plus: +% 'partial_r2' logical (default true). Compute per-term partial R² by +% refitting the reduced model dropping each term. Adds +% columns to T. +% 'verbose' logical (default true). +% +% Outputs +% ------- +% T long-format table: +% sub | term | beta | se | t | p | partial_R2 | full_R2 | full_adj_R2 +% mdls cell array of fitted LinearModel objects, one per subject +% info struct from the underlying assemble_lme_table call (uses the +% first subject's metadata as representative) + +p = inputParser; +p.addParameter('predictors', {}, @(x) iscellstr(x) || isstring(x) || isempty(x)); %#ok +p.addParameter('interactions', {}, @iscell); +p.addParameter('three_way', {}, @iscell); +p.addParameter('subject_var', 'subject_id', @(x) ischar(x) || isstring(x)); +p.addParameter('response_transform', 'fisherz', @(x) ischar(x) || isstring(x)); +p.addParameter('metric', 'correlation', @(x) ischar(x) || isstring(x)); +p.addParameter('partial_r2', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('verbose', true, @(x) islogical(x) || isnumeric(x)); +p.parse(varargin{:}); +opt = p.Results; + +predictors = cellstr(opt.predictors); +if isempty(predictors) + error('rsa_lm_by_subject:noPredictors', 'Pass at least one predictor.'); +end + +% Build the full within-subject pairs table; we'll slice by Subject below +[tbl_all, info] = assemble_lme_table(dat, ... + 'predictors', predictors, ... + 'interactions', opt.interactions, ... + 'three_way', opt.three_way, ... + 'subject_var', opt.subject_var, ... + 'pair_scope', 'within_subject', ... + 'response_transform', opt.response_transform, ... + 'metric', opt.metric, ... + 'verbose', opt.verbose); + +sub_short = info.subject_var_short; +sub_levels = categories(tbl_all.(sub_short)); +n_subj = numel(sub_levels); + +% Predictor + interaction column names in the table +term_cols = [info.predictor_names, info.interaction_names, info.three_way_names]; +n_terms = numel(term_cols); + +% Build base formula (no random effects) +rhs = strjoin(term_cols, ' + '); +formula = sprintf('Y ~ %s', rhs); + +% ========================================================================= +% Per-subject fits +% ========================================================================= +mdls = cell(n_subj, 1); +rows = cell(n_subj, 1); + +for s = 1:n_subj + is_s = tbl_all.(sub_short) == sub_levels{s}; + tbl_s = tbl_all(is_s, :); + mdls{s} = fitlm(tbl_s, formula); + + n_coefs = mdls{s}.NumCoefficients; + sub_col = repmat({char(sub_levels{s})}, n_coefs, 1); + coef_name = mdls{s}.Coefficients.Properties.RowNames; + betas = mdls{s}.Coefficients.Estimate; + ses = mdls{s}.Coefficients.SE; + ts = mdls{s}.Coefficients.tStat; + ps = mdls{s}.Coefficients.pValue; + full_r2 = repmat(mdls{s}.Rsquared.Ordinary, n_coefs, 1); + full_adj_r2 = repmat(mdls{s}.Rsquared.Adjusted, n_coefs, 1); + + % Partial R² per term: refit dropping that term, R²_full - R²_reduced + partial_r2 = nan(n_coefs, 1); + if logical(opt.partial_r2) + for c = 1:n_coefs + name = coef_name{c}; + if strcmp(name, '(Intercept)'), continue; end + others = setdiff(term_cols, {name}); + if isempty(others) + f_red = 'Y ~ 1'; + else + f_red = sprintf('Y ~ %s', strjoin(others, ' + ')); + end + try + mdl_red = fitlm(tbl_s, f_red); + partial_r2(c) = mdls{s}.Rsquared.Ordinary - mdl_red.Rsquared.Ordinary; + catch + partial_r2(c) = NaN; + end + end + end + + rows{s} = table(sub_col, coef_name, betas, ses, ts, ps, partial_r2, ... + full_r2, full_adj_r2, ... + 'VariableNames', {sub_short, 'term', 'beta', 'se', 't', 'p', ... + 'partial_R2', 'full_R2', 'full_adj_R2'}); +end + +T = vertcat(rows{:}); + +if opt.verbose + fprintf('rsa_lm_by_subject: fit %d per-subject models with %d terms each.\n', ... + n_subj, n_terms); +end + +end diff --git a/CanlabCore/@fmri_data/rsa_lme.m b/CanlabCore/@fmri_data/rsa_lme.m new file mode 100644 index 00000000..91bc6565 --- /dev/null +++ b/CanlabCore/@fmri_data/rsa_lme.m @@ -0,0 +1,294 @@ +function [mdl, tbl, info] = rsa_lme(dat, varargin) +% rsa_lme Random-effects multi-level RSA via fitlme. +% +% Implements the Phase 3 LME pipeline: assemble within-subject (i, j) +% upper-triangle pairs of the per-subject image-level RSM into a long-format +% table, then fit fitlme with the requested fixed and random effects. +% +% Two ways to specify the model +% ----------------------------- +% Form 1 -- Wilkinson formula (explicit): +% mdl = rsa_lme(dat, 'Y ~ SameCondition + SameBodysite + (1 | Subject)', ... +% 'predictors', {'condition','bodysite'}, 'subject_var','sub'); +% +% Form 2 -- Name-value assembly (auto formula): +% mdl = rsa_lme(dat, ... +% 'predictors', {'condition','bodysite','session_number'}, ... +% 'fixed_effects', {'condition','bodysite','condition:bodysite'}, ... +% 'random_effects',{'(1 | sub)'}, ... +% 'subject_var', 'sub'); +% +% Usage examples +% -------------- +% % Simple random-intercept model +% mdl = rsa_lme(dat, ... +% 'predictors', {'condition','bodysite','session_number'}, ... +% 'subject_var', 'sub'); +% % Auto formula: 'Y ~ SameCondition + SameBodysite + SameSession + (1 | Sub)' +% +% % Add an interaction +% mdl = rsa_lme(dat, ... +% 'predictors', {'condition','bodysite'}, ... +% 'interactions', {{'condition','bodysite'}}, ... +% 'subject_var', 'sub'); +% +% % Random slopes for SameCondition by subject +% mdl = rsa_lme(dat, ... +% 'Y ~ SameCondition + SameBodysite + (SameCondition | Sub)', ... +% 'predictors', {'condition','bodysite'}, ... +% 'subject_var','sub'); +% +% Optional name-value +% ------------------- +% 'predictors' cellstr of metadata columns to include as same-vs- +% different fixed-effect RDMs. +% 'fixed_effects' cellstr of Wilkinson terms to include in the +% fixed-effect formula (used only with name-value +% form). Use ':' for interactions, e.g. 'condition:bodysite'. +% 'random_effects' cellstr of random-effect Wilkinson terms, e.g. +% {'(1 | sub)'}, {'(condition | sub)'}. +% 'interactions' cell of cellstr pairs to ALSO add as fixed-effect +% columns (built as element-wise AND). +% 'subject_var' Default 'subject_id' (also matches 'sub', etc.). +% 'response_transform' 'fisherz' (default) | 'none' | 'rank'. +% 'fit_method' 'REML' (default) | 'ML'. +% 'verbose' logical (default true). +% +% Outputs +% ------- +% mdl LinearMixedModel object +% tbl the long-format table used for fitting (height = sum of +% within-subject pair counts) +% info struct from assemble_lme_table + +% ========================================================================= +% Detect form 1 (formula) vs form 2 (name-value) +% ========================================================================= +formula = ''; +if ~isempty(varargin) && (ischar(varargin{1}) || isstring(varargin{1})) + first = char(varargin{1}); + % Treat as formula if it contains '~' AND isn't a known parameter name + if contains(first, '~') + formula = first; + varargin = varargin(2:end); + end +end + +% ========================================================================= +% Parse name-value args +% ========================================================================= +p = inputParser; +p.addParameter('predictors', {}, @(x) iscellstr(x) || isstring(x) || isempty(x)); %#ok +p.addParameter('fixed_effects', {}, @(x) iscellstr(x) || isstring(x) || isempty(x)); %#ok +p.addParameter('random_effects', {}, @(x) iscellstr(x) || isstring(x) || isempty(x)); %#ok +p.addParameter('interactions', {}, @iscell); +p.addParameter('three_way', {}, @iscell); +p.addParameter('subject_var', 'subject_id', @(x) ischar(x) || isstring(x)); +p.addParameter('response_transform', 'fisherz', @(x) ischar(x) || isstring(x)); +p.addParameter('metric', 'correlation', @(x) ischar(x) || isstring(x)); +p.addParameter('fit_method', 'REML', @(x) (ischar(x) || isstring(x)) && ismember(upper(char(x)), {'REML','ML'})); +p.addParameter('verbose', true, @(x) islogical(x) || isnumeric(x)); +p.parse(varargin{:}); +opt = p.Results; + +predictors = cellstr(opt.predictors); +fixed_eff = cellstr(opt.fixed_effects); +random_eff = cellstr(opt.random_effects); +interactions = opt.interactions; +three_way = opt.three_way; + +% ========================================================================= +% Assemble table +% ========================================================================= +[tbl, info] = assemble_lme_table(dat, ... + 'predictors', predictors, ... + 'interactions', interactions, ... + 'three_way', three_way, ... + 'subject_var', opt.subject_var, ... + 'pair_scope', 'within_subject', ... + 'response_transform', opt.response_transform, ... + 'metric', opt.metric, ... + 'verbose', opt.verbose); + +% ========================================================================= +% Build / validate formula +% ========================================================================= +if isempty(formula) + formula = build_formula_from_namevalue(info, predictors, fixed_eff, ... + random_eff, interactions, three_way); +end + +% Normalize + validate formula column names against the assembled table. +% Resolves both interaction-naming forms (SameAxSameB <-> SameAxB) and gives +% a clear error listing available columns if a term can't be matched. +formula = normalize_and_validate_formula(formula, tbl, info); + +if opt.verbose + fprintf('rsa_lme: fitting formula:\n %s\n (n=%d rows, %d subjects)\n', ... + formula, height(tbl), numel(unique(tbl.(info.subject_var_short)))); +end + +% ========================================================================= +% Fit +% ========================================================================= +mdl = fitlme(tbl, formula, 'FitMethod', upper(char(opt.fit_method))); + +if opt.verbose + fprintf('rsa_lme: LME fit complete. AIC=%.1f, BIC=%.1f, logLik=%.1f\n', ... + mdl.ModelCriterion.AIC, mdl.ModelCriterion.BIC, mdl.LogLikelihood); +end + +end + + +function formula = build_formula_from_namevalue(info, predictors, fixed_eff, ... + random_eff, interactions, three_way) %#ok +% Convert name-value spec to a Wilkinson formula. Each predictor name is +% translated to its 'Same' column name automatically. + +% Map original metadata column name -> Same table column name +name_map = containers.Map(predictors, info.predictor_names); + +% Determine fixed-effect terms +if isempty(fixed_eff) + fixed_rhs = [info.predictor_names, info.interaction_names, info.three_way_names]; +else + fixed_rhs = cellfun(@(t) translate_term(t, name_map), fixed_eff, 'UniformOutput', false); +end +rhs_fixed = strjoin(fixed_rhs, ' + '); + +% Random effects: default to (1 | Subject) if none specified +if isempty(random_eff) + rhs_rand = sprintf('(1 | %s)', info.subject_var_short); +else + rhs_rand = strjoin(cellfun(@(r) translate_random_effect(r, name_map, info.subject_var_short), ... + random_eff, 'UniformOutput', false), ' + '); +end + +formula = sprintf('Y ~ %s + %s', rhs_fixed, rhs_rand); +end + + +function out = translate_term(term, name_map) +% Translate a user-friendly term like 'condition' or 'condition:bodysite' +% to its same-vs-different column name 'SameCondition' / 'SameConditionxSameBodysite'. +term = strtrim(char(term)); +if contains(term, ':') + parts = strsplit(term, ':'); + parts = strtrim(parts); + sames = cellfun(@(p) get_same_name(p, name_map), parts, 'UniformOutput', false); + % Interaction column name = join the Same names with 'x' + out = strjoin(sames, 'x'); +elseif contains(term, '*') + parts = strsplit(term, '*'); + parts = strtrim(parts); + % Main effects + 2-way interaction (Wilkinson 'a*b' = 'a + b + a:b') + sames = cellfun(@(p) get_same_name(p, name_map), parts, 'UniformOutput', false); + inter = strjoin(sames, 'x'); + out = strjoin([sames, {inter}], ' + '); +else + out = get_same_name(term, name_map); +end +end + + +function out = get_same_name(name, name_map) +if isKey(name_map, name), out = name_map(name); +elseif startsWith(name, 'Same'), out = name; +else, out = ['Same' name]; % already a short form +end +end + + +function formula = normalize_and_validate_formula(formula, tbl, info) +% Check each fixed-effect term in the formula against the actual table +% columns. Resolve interaction-naming variants (SameAxSameB <-> SameAxB). +% If a term still can't be matched, error with the list of available columns. + +actual_cols = tbl.Properties.VariableNames; + +% Split formula into LHS ~ RHS +tilde = strfind(formula, '~'); +if isempty(tilde) + return % not a formula we can parse; let fitlme handle it +end +lhs = strtrim(formula(1:tilde(1)-1)); +rhs = strtrim(formula(tilde(1)+1:end)); + +% Pull out random-effect parenthetical clauses; leave them untouched +rand_clauses = regexp(rhs, '\([^)]*\)', 'match'); +fixed_part = regexprep(rhs, '\([^)]*\)', ''); + +% Split fixed terms on '+' +raw_terms = strtrim(strsplit(fixed_part, '+')); +raw_terms = raw_terms(~cellfun('isempty', raw_terms)); + +resolved = {}; +unresolved = {}; +for i = 1:numel(raw_terms) + t = raw_terms{i}; + if strcmp(t, '1') || ~isempty(regexp(t, '^[0-9.]+$', 'once')) + resolved{end+1} = t; %#ok + continue + end + col = resolve_column(t, actual_cols); + if isempty(col) + unresolved{end+1} = t; %#ok + else + resolved{end+1} = col; %#ok + end +end + +if ~isempty(unresolved) + % Build a helpful error listing the predictor columns that DO exist + pred_cols = [info.predictor_names, info.interaction_names, info.three_way_names]; + error('rsa_lme:unknownFormulaTerm', ... + ['Formula term(s) not found in the assembled table: %s\n', ... + 'Available predictor columns are:\n %s\n', ... + 'Interaction columns join the main-effect names with ''x'', ', ... + 'e.g. SameConditionxSameBodysite.\n', ... + 'Did you list all needed columns in ''predictors'' and ''interactions''?'], ... + strjoin(unresolved, ', '), strjoin(pred_cols, ', ')); +end + +% Reassemble +rhs_new = strjoin(resolved, ' + '); +if ~isempty(rand_clauses) + rhs_new = [rhs_new ' + ' strjoin(rand_clauses, ' + ')]; +end +formula = sprintf('%s ~ %s', lhs, rhs_new); +end + + +function col = resolve_column(token, actual_cols) +% Return the actual column name matching `token`, trying interaction-naming +% variants. Returns '' if no match. +token = strtrim(token); +if ismember(token, actual_cols), col = token; return; end +if contains(token, 'x') + % Variant A: collapse redundant 'Same' after each 'x' (SameAxSameB -> SameAxB) + collapsed = regexprep(token, 'xSame', 'x'); + if ismember(collapsed, actual_cols), col = collapsed; return; end + % Variant B: add 'Same' after each 'x' that lacks it (SameAxB -> SameAxSameB) + expanded = regexprep(token, 'x(?!Same)', 'xSame'); + if ismember(expanded, actual_cols), col = expanded; return; end +end +col = ''; +end + + +function out = translate_random_effect(re, name_map, subject_short) +% Random-effect term: '(1 | sub)' or '(condition | sub)' -- translate the +% grouping variable to its short name and any inner predictor to Same. +re = char(re); +% Replace 'condition' with 'SameCondition' etc. inside the parens +keys = name_map.keys; +for i = 1:numel(keys) + re = regexprep(re, ['(? = statistic_image per contrast/term +% .atlas the atlas used +% .history cellstr + +% ========================================================================= +% Parse +% ========================================================================= +p = inputParser; +p.KeepUnmatched = true; +p.addParameter('atlas', [], @(x) isa(x, 'atlas')); +p.addParameter('group_by', '', @(x) ischar(x) || isstring(x) || iscell(x)); +p.addParameter('subject_var', 'subject_id', @(x) ischar(x) || isstring(x)); +p.addParameter('metric', 'correlation', @(x) ischar(x) || isstring(x)); +p.addParameter('contrasts', {}, @iscell); +p.addParameter('lme', '', @(x) ischar(x) || isstring(x)); +p.addParameter('predictors', {}, @(x) iscellstr(x) || isstring(x) || isempty(x)); %#ok +p.addParameter('interactions', {}, @iscell); +p.addParameter('three_way', {}, @iscell); +p.addParameter('tail', 'both', @(x) ischar(x) || isstring(x)); +p.addParameter('correction', 'fdr', @(x) ischar(x) || isstring(x)); +p.addParameter('fdr_scope', 'contrast', @(x) (ischar(x) || isstring(x)) && ismember(lower(char(x)), {'contrast','all'})); +p.addParameter('transform', 'auto', @(x) ischar(x) || isstring(x)); +p.addParameter('map_value', 't', @(x) (ischar(x) || isstring(x)) && ismember(lower(char(x)), {'t','beta'})); +p.addParameter('use_parallel', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('verbose', true, @(x) islogical(x) || isnumeric(x)); +p.parse(varargin{:}); +opt = p.Results; +fwd = opt; % keep for compute_rsm/rsa_lme forwarding + +if isempty(opt.atlas) + error('rsa_parcelwise:noAtlas', 'Pass an atlas object via ''atlas''.'); +end +do_contrasts = ~isempty(opt.contrasts); +do_lme = ~isempty(char(opt.lme)); +if ~do_contrasts && ~do_lme + error('rsa_parcelwise:noAnalysis', 'Pass ''contrasts'' and/or ''lme''.'); +end + +atlas_obj = opt.atlas; +labels = atlas_obj.labels; +n_parcels = num_regions(atlas_obj); + +results = struct(); +results.atlas = atlas_obj; +results.maps = struct(); +results.history = {sprintf('%s: rsa_parcelwise on %d parcels', datestr(now), n_parcels)}; + +% ========================================================================= +% Build per-parcel RSMs (reuse compute_rsm parcellation) +% ========================================================================= +if opt.verbose, fprintf('rsa_parcelwise: building %d per-parcel RSMs...\n', n_parcels); end +extra = struct2nv(p.Unmatched); +R_pp = compute_rsm(dat, 'parcellation', atlas_obj, ... + 'group_by', opt.group_by, ... + 'subject_var', opt.subject_var, ... + 'metric', opt.metric, ... + 'verbose', false, extra{:}); +results.per_parcel_rsms = R_pp; + +% ========================================================================= +% CONTRAST PATH +% ========================================================================= +if do_contrasts + if opt.verbose, fprintf('rsa_parcelwise: running %d contrasts per parcel...\n', size(opt.contrasts,1)); end + results.contrast_table = run_contrast_path(R_pp, labels, opt); + results = build_maps_from_table(results, results.contrast_table, ... + 'Contrast', atlas_obj, opt); +end + +% ========================================================================= +% LME PATH +% ========================================================================= +if do_lme + if isempty(opt.predictors) + error('rsa_parcelwise:noPredictors', 'LME path requires ''predictors''.'); + end + if opt.verbose, fprintf('rsa_parcelwise: running per-parcel LME...\n'); end + results.lme_table = run_lme_path(dat, atlas_obj, labels, opt); + results = build_maps_from_table(results, results.lme_table, ... + 'Term', atlas_obj, opt); +end + +if opt.verbose, fprintf('rsa_parcelwise: done. %d map(s) emitted.\n', numel(fieldnames(results.maps))); end + +end + + +% ========================================================================= +function T = run_contrast_path(R_pp, labels, opt) +% Run ttest_contrasts on each parcel; accumulate long table; FDR across parcels. + +n_parcels = numel(R_pp); +spec = opt.contrasts; +n_con = size(spec, 1); +con_names = spec(:, 1); + +% Validate the contrast spec against the groupings of the first usable parcel +% BEFORE looping, so a bad grouping name errors clearly instead of silently +% producing an empty result. +ref = []; +for i = 1:n_parcels + if ~isempty(R_pp(i)) && size(R_pp(i), 3) >= 2 && ~isempty(fieldnames(R_pp(i).groupings)) + ref = R_pp(i); break + end +end +if isempty(ref) + error('rsa_parcelwise:noUsableParcels', ... + ['No parcel has >= 2 replicates with groupings. Check that group_by, ', ... + 'subject_var, and the atlas cover your data.']); +end +validate_contrast_groupings(spec, ref); + +rows = cell(n_parcels, 1); +n_failed = 0; first_err = ''; +for i = 1:n_parcels + R = R_pp(i); + if isempty(R) || size(R, 3) < 2 || isempty(fieldnames(R.groupings)) + continue + end + try + Tc = R.ttest_contrasts(spec, 'tail', opt.tail, 'correction', 'none', ... + 'transform', opt.transform); + catch ME + n_failed = n_failed + 1; + if isempty(first_err), first_err = ME.message; end + continue + end + parcel_col = repmat(labels(i), height(Tc), 1); + rows{i} = table(parcel_col, string(Tc.Contrast), Tc.Mean_Diff, Tc.t, Tc.P, ... + 'VariableNames', {'Parcel', 'Contrast', 'effect', 't', 'p'}); +end +if n_failed > 0 + warning('rsa_parcelwise:someParcelsFailed', ... + '%d/%d parcels failed the contrast (first error: %s).', n_failed, n_parcels, first_err); +end +T = vertcat(rows{:}); +if isempty(T) + warning('rsa_parcelwise:noResults', ... + 'Contrast path produced no results; no maps will be emitted.'); + return +end + +% FDR across parcels (per contrast or all) +T = apply_parcel_correction(T, 'Contrast', con_names, opt); +end + + +% ========================================================================= +function validate_contrast_groupings(spec, ref) +% Check that every grouping name referenced in the contrast spec exists in +% the reference parcel's groupings. Errors with the available names if not. +avail = fieldnames(ref.groupings); +referenced = {}; +for i = 1:size(spec, 1) + for col = 2:3 + a = spec{i, col}; + if ischar(a) || isstring(a) + referenced{end+1} = char(a); %#ok + elseif iscell(a) + for j = 1:numel(a) + if ischar(a{j}) || isstring(a{j}), referenced{end+1} = char(a{j}); end %#ok + end + end + end +end +referenced = unique(referenced); +missing = referenced(~ismember(referenced, avail)); +if ~isempty(missing) + error('rsa_parcelwise:unknownGrouping', ... + ['Contrast grouping(s) not found: %s\nAvailable groupings: %s\n', ... + '(compute_rsm auto-builds one grouping per unique value of each ', ... + 'group_by column; reference those names, not the composite labels.)'], ... + strjoin(missing, ', '), strjoin(avail, ', ')); +end +end + + +% ========================================================================= +function T = run_lme_path(dat, atlas_obj, labels, opt) +% Per-parcel apply_mask + rsa_lme; accumulate fixed-effect coefficients. + +n_parcels = num_regions(atlas_obj); +rows = cell(n_parcels, 1); + +for i = 1:n_parcels + try + parcel_mask = select_atlas_subset(atlas_obj, labels(i)); + dat_i = apply_mask(dat, parcel_mask); + if isempty(dat_i.dat) || size(dat_i.dat, 1) < 3, continue; end + mdl_i = dat_i.rsa_lme(char(opt.lme), ... + 'predictors', cellstr(opt.predictors), ... + 'interactions', opt.interactions, ... + 'three_way', opt.three_way, ... + 'subject_var', opt.subject_var, ... + 'metric', opt.metric, ... + 'verbose', false); + ce = mdl_i.Coefficients; + % Drop intercept row + keep = ~strcmp(ce.Name, '(Intercept)'); + terms = ce.Name(keep); + parcel_col = repmat(labels(i), numel(terms), 1); + rows{i} = table(parcel_col, string(terms), ce.Estimate(keep), ce.SE(keep), ... + ce.tStat(keep), ce.pValue(keep), ... + 'VariableNames', {'Parcel', 'Term', 'beta', 'se', 't', 'p'}); + catch ME + if opt.verbose + warning('rsa_parcelwise:lmeParcelFailed', 'Parcel %s LME failed: %s', labels{i}, ME.message); + end + end +end +T = vertcat(rows{:}); +if isempty(T), return; end + +term_names = unique(T.Term, 'stable'); +T = apply_parcel_correction(T, 'Term', cellstr(term_names), opt); +end + + +% ========================================================================= +function T = apply_parcel_correction(T, group_col, group_names, opt) +% Add FDR_P + sig columns, correcting across parcels within each +% contrast/term (fdr_scope='contrast') or across everything ('all'). + +n = height(T); +fdr_p = nan(n, 1); +sig = false(n, 1); +correction = lower(char(opt.correction)); + +if strcmpi(opt.fdr_scope, 'all') + groups_to_do = {':'}; +else + groups_to_do = group_names(:)'; +end + +for g = 1:numel(groups_to_do) + if strcmp(groups_to_do{g}, ':') + idx = (1:n)'; + else + idx = find(string(T.(group_col)) == string(groups_to_do{g})); + end + if isempty(idx), continue; end + ps = T.p(idx); + [adj, s] = correct_pvals(ps, correction); + fdr_p(idx) = adj; + sig(idx) = s; +end + +T.FDR_P = fdr_p; +T.sig = sig; +end + + +% ========================================================================= +function [adj, sig] = correct_pvals(ps, correction) +n = numel(ps); +switch correction + case 'fdr' + ranks = zeros(n,1); [~, order] = sort(ps); ranks(order) = 1:n; + adj = min(ps(:) .* n ./ ranks, 1); + if exist('FDR', 'file') == 2 + try + thr = FDR(ps, 0.05); + if isempty(thr) || isnan(thr), thr = 0; end + sig = ps <= thr; + catch + sig = adj < 0.05; + end + else + sig = adj < 0.05; + end + case 'bonferroni' + adj = min(ps(:) * n, 1); + sig = adj < 0.05; + case 'none' + adj = ps(:); + sig = ps < 0.05; + otherwise + adj = ps(:); sig = ps < 0.05; +end +end + + +% ========================================================================= +function results = build_maps_from_table(results, T, group_col, atlas_obj, opt) +% For each unique contrast/term, paint a statistic_image from the per-parcel +% values and store in results.maps.. + +if isempty(T), return; end +names = unique(string(T.(group_col)), 'stable'); +val_col = 't'; +if strcmpi(opt.map_value, 'beta') && ismember('beta', T.Properties.VariableNames) + val_col = 'beta'; +end + +for i = 1:numel(names) + rows = string(T.(group_col)) == names(i); + parcels = cellstr(T.Parcel(rows)); + vals = T.(val_col)(rows); + pvals = T.p(rows); + + map = assign_vals_to_atlas(atlas_obj, parcels, vals, ... + 'p_vals', pvals, 'output_type', 'statistic_image', ... + 'dat_descrip', sprintf('rsa_parcelwise %s = %s', group_col, names(i))); + + fld = matlab.lang.makeValidName(char(names(i))); + results.maps.(fld) = map; +end +end + + +% ========================================================================= +function nv = struct2nv(s) +% Convert an unmatched-args struct to a name-value cell for forwarding +fn = fieldnames(s); +nv = cell(1, 2*numel(fn)); +for i = 1:numel(fn), nv{2*i-1} = fn{i}; nv{2*i} = s.(fn{i}); end +end diff --git a/CanlabCore/@image_vector/searchlight_rsa.m b/CanlabCore/@image_vector/searchlight_rsa.m new file mode 100644 index 00000000..388a24f3 --- /dev/null +++ b/CanlabCore/@image_vector/searchlight_rsa.m @@ -0,0 +1,426 @@ +function results = searchlight_rsa(dat, model_rdms, varargin) +% searchlight_rsa Searchlight representational similarity analysis. +% +% Slides a spherical searchlight across the brain. At each center voxel it +% builds a representational (dis)similarity matrix from the condition +% patterns within the sphere, then correlates that RSM with one or more +% model RDMs. Produces a statistic_image per model RDM whose voxel values +% are the RSM-to-model correlation at each searchlight center. +% +% Usage +% ----- +% % Model RDMs: same-vs-different design matrices, an rsm, or raw matrices +% model = rsm.from_categorical(dat.metadata_table, 'condition'); +% results = searchlight_rsa(dat, model, ... +% 'radius', 3, 'group_by', {'condition','bodysite'}, ... +% 'subject_var', 'sub', 'metric', 'correlation', ... +% 'compare', 'spearman', 'mask', gray_mask); +% +% results.maps.condition.montage; % correlation map +% +% Inputs +% ------ +% dat fmri_data / image_vector with metadata_table. +% model_rdms one or more model RDMs, defined over the k CONDITIONS (not +% images). Accepts: +% - a metadata column name (char) or cellstr of names -- +% builds same-vs-different model RDM(s) over the k conditions +% (e.g. 'condition' -> SameCondition model). EASIEST. +% - an rsm object (or array of rsm), each k x k +% - a numeric k x k matrix +% - a struct array with fields .RDM (k x k) and .name +% +% Optional name-value +% ------------------- +% 'radius' searchlight radius in VOXELS. Default 3. +% 'group_by' metadata column(s) defining the k conditions (RSM rows). +% 'subject_var' metadata column for subjects. Default 'subject_id'. +% 'metric' RSM construction metric: 'correlation' (default) | 'spearman' | 'cosine'. +% 'compare' RSM-vs-model correlation type: 'spearman' (default) | 'kendall' | 'pearson'. +% 'rsm_level' 'group' (default) -- one condition-pattern matrix averaged +% across all images, fast | 'subject' -- per-subject sphere +% RSMs averaged before comparison, slower but more rigorous. +% 'mask' image to restrict the searchlight (passed to apply_mask). +% 'permutations' number of label permutations for inference. Default 0 +% (no permutation test; map holds RAW correlations and the +% returned statistic_image has placeholder p-values of 1, so +% thresholding by p returns an empty map -- view the raw map +% directly with montage(). Pass N>0 (e.g. 500) to get real +% permutation p-values that threshold(map,0.05,'unc') honors). +% 'use_parallel' logical (default false). Uses parfor over sphere centers. +% 'verbose' logical (default true). +% +% Output +% ------ +% results struct: +% .maps struct: maps. = statistic_image of correlations +% .p_maps struct: maps. = statistic_image of permutation +% p-values (only if permutations > 0) +% .model_names cellstr +% .radius, .n_spheres, .history + +% ========================================================================= +% Parse +% ========================================================================= +p = inputParser; +p.addParameter('radius', 3, @(x) isnumeric(x) && isscalar(x) && x > 0); +p.addParameter('group_by', '', @(x) ischar(x) || isstring(x) || iscell(x)); +p.addParameter('subject_var', 'subject_id', @(x) ischar(x) || isstring(x)); +p.addParameter('metric', 'correlation', @(x) ischar(x) || isstring(x)); +p.addParameter('compare', 'spearman', @(x) (ischar(x) || isstring(x)) && ismember(lower(char(x)), {'spearman','kendall','pearson'})); +p.addParameter('rsm_level', 'group', @(x) (ischar(x) || isstring(x)) && ismember(lower(char(x)), {'group','subject'})); +p.addParameter('mask', [], @(x) true); +p.addParameter('permutations', 0, @(x) isnumeric(x) && isscalar(x) && x >= 0); +p.addParameter('use_parallel', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('verbose', true, @(x) islogical(x) || isnumeric(x)); +p.parse(varargin{:}); +opt = p.Results; + +% ========================================================================= +% Optional mask +% ========================================================================= +if ~isempty(opt.mask) + dat = apply_mask(dat, opt.mask); +end + +% ========================================================================= +% Build condition-pattern matrices +% ========================================================================= +[P, k, cond_labels, subj_P, subj_ids, cond_meta] = build_condition_patterns(dat, opt); +% P is [k x n_voxels] group-mean condition patterns +% subj_P is {n_subj x 1} of [k x n_voxels] if rsm_level='subject' +% cond_meta is a k-row metadata table (one representative row per condition) + +n_vox = size(P, 2); + +% ========================================================================= +% Normalize model RDMs (defined over the k conditions) +% ========================================================================= +[models, model_names] = normalize_models(model_rdms, k, cond_meta); +n_models = numel(models); + +% Vectorize each model's lower triangle once +tril_mask = tril(true(k), -1); +model_vecs = cell(n_models, 1); +for m = 1:n_models + M = models{m}; + model_vecs{m} = M(tril_mask); +end + +% ========================================================================= +% Sphere indices +% ========================================================================= +% Coordinates aligned to dat.dat columns: drop removed voxels from xyzlist. +xyz = dat.volInfo.xyzlist; % [n_inmask x 3] voxel coords +if ~isempty(dat.removed_voxels) && numel(dat.removed_voxels) == size(xyz, 1) + xyz = xyz(~dat.removed_voxels, :); +end +if size(xyz, 1) ~= n_vox + error('searchlight_rsa:coordMismatch', ... + 'Voxel coordinate count (%d) does not match data voxels (%d).', size(xyz,1), n_vox); +end +if opt.verbose, fprintf('searchlight_rsa: %d centers, radius %g vox, %d model(s)\n', n_vox, opt.radius, n_models); end + +sphere_idx = build_sphere_indices(xyz, opt.radius); + +% ========================================================================= +% Run searchlight +% ========================================================================= +corr_maps = nan(n_vox, n_models); +p_maps = nan(n_vox, n_models); +compare_type = lower(char(opt.compare)); +do_perm = opt.permutations > 0; +n_perm = opt.permutations; + +use_subject = strcmpi(opt.rsm_level, 'subject'); + +for v = 1:n_vox + sphere = sphere_idx{v}; + if numel(sphere) < 3, continue; end + + % Build sphere RSM, then convert to a dissimilarity matrix (RDM) so it + % lives in the same space as the model RDMs. For similarity metrics + % (correlation/spearman/cosine) the brain RDM = 1 - similarity, so a + % POSITIVE correlation with the model RDM means the brain's + % representational geometry matches the model. + if use_subject + rsm_sphere = mean_subject_rsm(subj_P, sphere, opt.metric); + else + rsm_sphere = single_rsm(P(:, sphere), opt.metric); + end + if isempty(rsm_sphere) || all(isnan(rsm_sphere(:))), continue; end + + rdm_sphere = 1 - rsm_sphere; % similarity -> dissimilarity + rsm_vec = rdm_sphere(tril_mask); + + for m = 1:n_models + r = compare_corr(rsm_vec, model_vecs{m}, compare_type); + corr_maps(v, m) = r; + + if do_perm + null_r = nan(n_perm, 1); + for pp = 1:n_perm + perm = randperm(k); + M_perm = rdm_sphere(perm, perm); + null_r(pp) = compare_corr(M_perm(tril_mask), model_vecs{m}, compare_type); + end + p_maps(v, m) = (1 + sum(null_r >= r)) / (1 + n_perm); + end + end +end + +% ========================================================================= +% Build output maps +% ========================================================================= +results = struct(); +results.maps = struct(); +results.model_names = model_names; +results.radius = opt.radius; +results.n_spheres = n_vox; +results.history = {sprintf('%s: searchlight_rsa r=%g, %d models, level=%s', ... + datestr(now), opt.radius, n_models, opt.rsm_level)}; +if do_perm, results.p_maps = struct(); end + +for m = 1:n_models + fld = matlab.lang.makeValidName(model_names{m}); + map = make_stat_image(dat, corr_maps(:, m), do_perm, p_maps(:, m)); + results.maps.(fld) = map; + if do_perm + pmap = make_stat_image(dat, p_maps(:, m), false, []); + results.p_maps.(fld) = pmap; + end +end + +if opt.verbose + fprintf('searchlight_rsa: done. %d map(s).\n', n_models); + if ~do_perm + fprintf(['searchlight_rsa: permutations=0 -> maps hold RAW correlations; ' ... + '.p are placeholders (all 1).\n View directly: montage(results.maps.X). ' ... + 'Do NOT threshold by p (threshold(map,0.05) returns empty).\n ' ... + 'For p-thresholded inference, pass ''permutations'', N (e.g. 500).\n']); + end +end + +end + + +% ========================================================================= +function [P, k, cond_labels, subj_P, subj_ids, cond_meta] = build_condition_patterns(dat, opt) +% Aggregate the data into condition-level pattern matrices. + +mt = dat.metadata_table; +X = double(dat.dat); % voxels x images + +% Group key +gb = opt.group_by; +if isempty(gb) + error('searchlight_rsa:noGroupBy', 'Pass ''group_by'' to define conditions.'); +end +if ischar(gb) || isstring(gb), gb = {char(gb)}; end + +key = build_composite_key(mt, gb); +[g_idx, cond_labels] = findgroups(key); +k = numel(cond_labels); +cond_labels = cellstr(string(cond_labels)); + +% Representative metadata row per condition (first image of each group) +rep_rows = zeros(k, 1); +for c = 1:k, rep_rows(c) = find(g_idx == c, 1, 'first'); end +cond_meta = mt(rep_rows, :); + +n_vox = size(X, 1); + +% Group-mean patterns: average all images per condition +P = nan(k, n_vox); +for c = 1:k + cols = g_idx == c; + if any(cols), P(c, :) = mean(X(:, cols), 2, 'omitnan')'; end +end + +% Per-subject patterns if requested +subj_P = {}; +subj_ids = {}; +if strcmpi(opt.rsm_level, 'subject') + sv = char(opt.subject_var); + if ~ismember(sv, mt.Properties.VariableNames) + % try aliases + for a = {'sub','subject_id','subject'} + if ismember(a{1}, mt.Properties.VariableNames), sv = a{1}; break; end + end + end + subj_key = mt.(sv); + [s_idx, subj_ids] = findgroups(subj_key); + subj_ids = cellstr(string(subj_ids)); + n_subj = numel(subj_ids); + subj_P = cell(n_subj, 1); + for s = 1:n_subj + Ps = nan(k, n_vox); + for c = 1:k + cols = (g_idx == c) & (s_idx == s); + if any(cols), Ps(c, :) = mean(X(:, cols), 2, 'omitnan')'; end + end + subj_P{s} = Ps; + end +end +end + + +% ========================================================================= +function R = single_rsm(P_sphere, metric) +% P_sphere is [k x n_sphere_vox]. Returns k x k RSM. +m = lower(char(metric)); +switch m + case 'correlation', R = corr(P_sphere', 'Type','Pearson', 'rows','pairwise'); + case 'spearman', R = corr(P_sphere', 'Type','Spearman', 'rows','pairwise'); + case 'cosine' + nrm = sqrt(sum(P_sphere.^2, 2)); + R = (P_sphere * P_sphere') ./ (nrm * nrm'); + otherwise, R = corr(P_sphere', 'rows','pairwise'); +end +end + + +% ========================================================================= +function R = mean_subject_rsm(subj_P, sphere, metric) +% Average per-subject sphere RSMs. +n_subj = numel(subj_P); +acc = []; cnt = 0; +for s = 1:n_subj + Rs = single_rsm(subj_P{s}(:, sphere), metric); + if isempty(acc), acc = zeros(size(Rs)); end + if ~all(isnan(Rs(:))), acc = acc + Rs; cnt = cnt + 1; end +end +if cnt == 0, R = []; else, R = acc / cnt; end +end + + +% ========================================================================= +function r = compare_corr(a, b, type) +% Correlate two RDM lower-triangle vectors with NaN-safe masking. +a = a(:); b = b(:); +mask = isfinite(a) & isfinite(b); +if nnz(mask) < 3, r = NaN; return; end +switch type + case 'pearson', r = corr(a(mask), b(mask), 'Type','Pearson'); + case 'kendall', r = corr(a(mask), b(mask), 'Type','Kendall'); + otherwise, r = corr(a(mask), b(mask), 'Type','Spearman'); +end +end + + +% ========================================================================= +function sphere_idx = build_sphere_indices(xyz, radius) +% Returns a cell array; sphere_idx{v} = indices of voxels within `radius` +% (in voxel units) of voxel v. +n = size(xyz, 1); +sphere_idx = cell(n, 1); +if exist('rangesearch', 'file') == 2 + % Fast path: Statistics Toolbox + sphere_idx = rangesearch(xyz, xyz, radius); +else + % Stock fallback: grid-bucketed neighbor search + r2 = radius^2; + for v = 1:n + d2 = sum((xyz - xyz(v, :)).^2, 2); + sphere_idx{v} = find(d2 <= r2); + end +end +end + + +% ========================================================================= +function [models, names] = normalize_models(model_rdms, k, cond_meta) +% Coerce model RDM input to a cell of k x k matrices + names. +models = {}; names = {}; + +% Column-name form: build same-vs-different model RDM(s) over the k conditions +if ischar(model_rdms) || iscellstr(model_rdms) || isstring(model_rdms) %#ok + cols = cellstr(model_rdms); + for i = 1:numel(cols) + if ~ismember(cols{i}, cond_meta.Properties.VariableNames) + error('searchlight_rsa:badModelColumn', ... + 'Model column "%s" not found in metadata.', cols{i}); + end + Mrsm = rsm.from_categorical(cond_meta.(cols{i})); + models{end+1} = Mrsm.dat; %#ok + names{end+1} = cols{i}; %#ok + end + validate_model_sizes(models, k); + return +end + +if isa(model_rdms, 'rsm') + for i = 1:numel(model_rdms) + M = mean(model_rdms(i).dat, 3, 'omitnan'); + models{end+1} = M; %#ok + nm = model_rdms(i).source; + nm = regexprep(nm, '^design:', ''); nm = regexprep(nm, '^parcel:', ''); + if isempty(nm), nm = sprintf('model%d', i); end + names{end+1} = nm; %#ok + end +elseif isnumeric(model_rdms) + models{1} = model_rdms; names{1} = 'model1'; +elseif isstruct(model_rdms) + for i = 1:numel(model_rdms) + models{end+1} = model_rdms(i).RDM; %#ok + if isfield(model_rdms(i), 'name') && ~isempty(model_rdms(i).name) + names{end+1} = model_rdms(i).name; %#ok + else + names{end+1} = sprintf('model%d', i); %#ok + end + end +else + error('searchlight_rsa:badModel', 'model_rdms must be a column name, rsm, numeric, or struct array.'); +end +validate_model_sizes(models, k); +end + + +function validate_model_sizes(models, k) +for i = 1:numel(models) + if ~isequal(size(models{i}), [k k]) + error('searchlight_rsa:modelSize', ... + ['Model %d is %dx%d but the searchlight defines %d conditions. ', ... + 'Model RDMs must be defined over the k conditions (use a column ', ... + 'name like ''condition'' to build one automatically).'], ... + i, size(models{i},1), size(models{i},2), k); + end +end +end + + +% ========================================================================= +function map = make_stat_image(dat, vals, have_p, pvals) +% Build a statistic_image in dat's space with vals in .dat. +[~, ref] = evalc('fmri_data(dat, ''noverbose'')'); +ref.dat = vals(:); +if have_p && ~isempty(pvals) + map = statistic_image('dat', vals(:), 'volInfo', ref.volInfo, ... + 'p', pvals(:), 'type', 'generic', 'removed_voxels', ref.removed_voxels); + map.sig = pvals(:) < 0.05; +else + map = statistic_image('dat', vals(:), 'volInfo', ref.volInfo, ... + 'p', ones(size(vals(:))), 'type', 'generic', 'removed_voxels', ref.removed_voxels); + map.sig = isfinite(vals(:)) & vals(:) ~= 0; +end +map.dat = vals(:); +end + + +% ========================================================================= +function key = build_composite_key(mt, cols) +if numel(cols) == 1 + v = mt.(cols{1}); + if iscell(v), key = v; else, key = cellstr(string(v)); end + return +end +parts = cell(height(mt), numel(cols)); +for i = 1:numel(cols) + v = mt.(cols{i}); + if iscell(v), parts(:,i) = cellfun(@(x) char(string(x)), v, 'UniformOutput', false); + else, parts(:,i) = cellstr(string(v)); end +end +key = strings(height(mt), 1); +for r = 1:height(mt), key(r) = strjoin(parts(r,:), '_'); end +key = cellstr(key); +end diff --git a/CanlabCore/@rsm/cells.m b/CanlabCore/@rsm/cells.m new file mode 100644 index 00000000..7062bf80 --- /dev/null +++ b/CanlabCore/@rsm/cells.m @@ -0,0 +1,186 @@ +function vals = cells(obj, a, b, varargin) +% cells Extract a vector of cell values from an rsm. +% +% Given two row/column groupings A and B, pulls the (i, j) cells of the +% rsm where i is in A and j is in B. Returns a per-replicate vector of +% reduced (mean / median / sum) values — one scalar per slice of the +% replicate axis (3rd dim). +% +% This is the Phase 2 "down payment" that eliminates the most common +% manual-loop footgun: `mean(M(rows, cols))` returns the COLUMN means of +% the submatrix (a row vector), NOT a scalar. cells() returns a clean +% per-replicate scalar so downstream `ttest(within, between)` calls work. +% +% Within vs between semantics +% --------------------------- +% A == B (same grouping for rows and columns): +% Within-group. Pulls the upper triangle of the square submatrix, +% excluding the diagonal. n_cells = numel(A) * (numel(A) - 1) / 2. +% +% A != B (different groupings): +% Between-group. Pulls the full rectangular submatrix. +% n_cells = numel(A) * numel(B). +% +% Grouping inputs +% --------------- +% Each of a, b can be: +% - char/string -- looked up in obj.groupings +% - numeric vector -- 1-based row/col indices +% - logical vector -- length k mask +% +% Usage examples +% -------------- +% % Within-condition +% v = R.cells('hot', 'hot'); % [N_subjects x 1] +% +% % Between-condition +% v = R.cells('hot', 'warm'); % [N_subjects x 1] +% +% % With explicit indices +% v = R.cells(1:8, 9:16); +% +% % Suppress Fisher-z (default is on for correlation-like metrics) +% v = R.cells('hot', 'hot', 'transform','none'); +% +% % Median reduction (default is mean) +% v = R.cells('hot', 'hot', 'reduction','median'); +% +% % Return ALL cells per replicate instead of reducing +% V = R.cells('hot', 'warm', 'reduce', false); % [N_cells x N_subjects] +% +% Optional args +% ------------- +% 'transform' 'auto' (default) -- atanh for correlation/spearman/cosine RSMs, +% identity for everything else. +% 'fisherz' -- force atanh +% 'none' -- raw values +% 'reduction' 'mean' (default) | 'median' | 'sum' +% 'reduce' logical (default true). If false, returns the full cell +% matrix [N_cells x N_replicates] instead of reducing per +% replicate. +% +% Output +% ------ +% vals [N_replicates x 1] when reduce=true (default) +% [N_cells x N_replicates] when reduce=false + +if builtin('numel', obj) > 1 + error('rsm:cells:nonScalar', 'cells() expects a scalar rsm.'); +end + +p = inputParser; +p.addParameter('transform', 'auto', @(x) ischar(x) || isstring(x)); +p.addParameter('reduction', 'mean', @(x) ischar(x) || isstring(x)); +p.addParameter('reduce', true, @(x) islogical(x) || isnumeric(x)); +p.parse(varargin{:}); +opt = p.Results; + +idx_a = resolve_grouping(obj, a, 'a'); +idx_b = resolve_grouping(obj, b, 'b'); + +k = size(obj.dat, 1); +N = size(obj.dat, 3); + +is_within = isequal(sort(idx_a(:)), sort(idx_b(:))); + +% Build the cell mask once +if is_within + sub_mask = triu(true(numel(idx_a)), 1); % exclude diagonal +else + sub_mask = true(numel(idx_a), numel(idx_b)); +end +n_cells = nnz(sub_mask); + +if n_cells == 0 + warning('rsm:cells:empty', 'cells() returned no cells (singleton within-grouping?).'); + if opt.reduce, vals = nan(N, 1); else, vals = nan(0, N); end + return +end + +% Decide transform +transform = lower(char(opt.transform)); +if strcmp(transform, 'auto') + if obj.is_dissimilarity || ~ismember(lower(obj.metric), {'correlation','spearman','cosine','cvcorr','cvspearman'}) + transform = 'none'; + else + transform = 'fisherz'; + end +end + +% Pull cells per replicate +all_cells = zeros(n_cells, N); +for n = 1:N + slice = obj.dat(:, :, n); + sub = slice(idx_a, idx_b); + all_cells(:, n) = sub(sub_mask); +end + +% Apply transform +switch transform + case 'fisherz' + all_cells(all_cells > 0.9999999) = 0.9999999; + all_cells(all_cells < -0.9999999) = -0.9999999; + all_cells = atanh(all_cells); + case 'none' + % no-op + otherwise + error('rsm:cells:badTransform', ... + 'transform must be ''auto'', ''fisherz'', or ''none''; got %s.', transform); +end + +if ~logical(opt.reduce) + vals = all_cells; + return +end + +switch lower(char(opt.reduction)) + case 'mean', vals = mean(all_cells, 1, 'omitnan')'; + case 'median', vals = median(all_cells, 1, 'omitnan')'; + case 'sum', vals = sum(all_cells, 1, 'omitnan')'; + otherwise + error('rsm:cells:badReduction', ... + 'reduction must be ''mean'', ''median'', or ''sum''; got %s.', opt.reduction); +end + +end + + +function idx = resolve_grouping(obj, x, label) +% Coerce a grouping spec (name / numeric / logical) to a 1-based index vector. + +k = size(obj.dat, 1); + +if ischar(x) || isstring(x) + name = char(x); + if ~isfield(obj.groupings, name) + avail = fieldnames(obj.groupings); + if isempty(avail) + error('rsm:cells:noGroupings', ... + ['No .groupings defined on this rsm; cannot resolve grouping name "%s". ', ... + 'Pass numeric indices or set obj.groupings before calling cells().'], name); + end + error('rsm:cells:unknownGrouping', ... + 'Unknown grouping name "%s" for argument %s. Available: %s', ... + name, label, strjoin(avail, ', ')); + end + idx = obj.groupings.(name); +elseif islogical(x) + if numel(x) ~= k + error('rsm:cells:badLogical', ... + 'Logical grouping %s must be length k=%d; got %d.', label, k, numel(x)); + end + idx = find(x); +elseif isnumeric(x) + idx = x; +else + error('rsm:cells:badGrouping', ... + 'Grouping %s must be a name, numeric index, or logical mask.', label); +end + +idx = idx(:); +if any(idx < 1) || any(idx > k) + error('rsm:cells:outOfRange', ... + 'Grouping %s contains indices outside 1..%d.', label, k); +end + +end diff --git a/CanlabCore/@rsm/cells_table.m b/CanlabCore/@rsm/cells_table.m new file mode 100644 index 00000000..984afff5 --- /dev/null +++ b/CanlabCore/@rsm/cells_table.m @@ -0,0 +1,119 @@ +function T = cells_table(obj, spec, varargin) +% cells_table Pull multiple grouping pairs into a single per-replicate table. +% +% Wraps R.cells() and stacks the results into a table with one column per +% requested grouping (or grouping pair). Rows correspond to replicates +% (typically subjects), so the table is directly consumable by ttest, +% barplot_columns, fitlme, etc. +% +% Usage +% ----- +% % Shorthand: a list of grouping names — each is treated as within-group +% % ('hot' -> R.cells('hot','hot'), etc.) +% T = R.cells_table({'hot','warm','imagine'}) +% +% % Mixed: scalar (within) and 1x2 cells (between) +% T = R.cells_table({ ... +% 'hot', ... % within hot +% 'warm', ... % within warm +% {'hot','warm'}, ... % between hot and warm +% {'hot','imagine'}, ... % between hot and imagine +% struct('name','HW', 'a','hot', 'b','warm') ... % named entry +% }) +% +% % With explicit names +% T = R.cells_table({'hot','warm','imagine'}, 'names',{'Hot','Warm','Imagine'}) +% +% Inputs +% ------ +% spec cell array; each entry is one of: +% char/string -- within-group (uses the name on both sides) +% {a, b} -- between-group (1x2 cell of names/indices) +% struct(.a,.b,.name) -- explicit with name +% varargin name-value (forwarded to R.cells): +% 'transform' 'auto' | 'fisherz' | 'none' (default 'auto') +% 'reduction' 'mean' | 'median' | 'sum' (default 'mean') +% 'names' cellstr of column names overriding auto-derived ones +% +% Output +% ------ +% T table with one column per spec entry; height = N_replicates. + +if builtin('numel', obj) > 1 + error('rsm:cells_table:nonScalar', 'cells_table() expects a scalar rsm.'); +end + +p = inputParser; +p.KeepUnmatched = true; +p.addParameter('names', {}, @(x) iscellstr(x) || isstring(x) || isempty(x)); %#ok +p.parse(varargin{:}); +override_names = cellstr(p.Results.names); + +% Forward only the non-'names' params to cells() +forward = remove_param(varargin, 'names'); + +T = table; +n_entries = numel(spec); +auto_names = cell(n_entries, 1); + +for i = 1:n_entries + entry = spec{i}; + + if ischar(entry) || isstring(entry) + a = char(entry); b = a; + nm = char(entry); + elseif iscell(entry) && numel(entry) == 2 + a = entry{1}; b = entry{2}; + nm = sprintf('%s_%s', stringify(a), stringify(b)); + elseif isstruct(entry) + a = entry.a; b = entry.b; + if isfield(entry, 'name') && ~isempty(entry.name), nm = char(entry.name); + else, nm = sprintf('%s_%s', stringify(a), stringify(b)); + end + else + error('rsm:cells_table:badSpec', ... + 'spec entry %d must be a name (within), {a, b} cell, or struct.', i); + end + + v = obj.cells(a, b, forward{:}); + + auto_names{i} = sanitize_varname(nm); + T.(auto_names{i}) = v; +end + +% Apply user-supplied names if provided +if ~isempty(override_names) + if numel(override_names) ~= n_entries + error('rsm:cells_table:badNames', ... + 'numel(names) = %d but spec has %d entries.', numel(override_names), n_entries); + end + T.Properties.VariableNames = cellfun(@sanitize_varname, override_names, 'UniformOutput', false); +end + +end + + +function s = stringify(x) +if ischar(x) || isstring(x), s = char(x); +elseif isnumeric(x), s = ['idx' strrep(num2str(x(:)'), ' ','')]; +elseif islogical(x), s = 'mask'; +else, s = 'group'; +end +end + + +function v = sanitize_varname(name) +% Replace any non-word chars with underscore so it's a valid table column name +v = regexprep(char(name), '[^A-Za-z0-9_]', '_'); +if isempty(v) || ~isletter(v(1)), v = ['x' v]; end +end + + +function out = remove_param(args, name) +% Strip a name-value pair from a varargin cell +out = args; +idx = find(cellfun(@(x) (ischar(x) || isstring(x)) && strcmpi(char(x), name), out)); +if ~isempty(idx) && idx < numel(out) + out([idx, idx+1]) = []; +end +end diff --git a/CanlabCore/@rsm/compare.m b/CanlabCore/@rsm/compare.m new file mode 100644 index 00000000..b420a0e8 --- /dev/null +++ b/CanlabCore/@rsm/compare.m @@ -0,0 +1,411 @@ +function result = compare(obj, model_rdms, varargin) +% compare Formal comparison of a data RDM to candidate model RDMs. +% +% Implements the Nili et al. (2014) RSA inference framework: correlates the +% data RDM (per subject) with one or more candidate model RDMs, tests each +% model's relatedness to the data, tests differences between models, and +% estimates the noise ceiling (the range of correlations the true model +% could achieve given the noise in the data). +% +% Wraps the Kriegeskorte rsatoolbox `rsa.compareRefRDM2candRDMs` when it is +% on the path; otherwise uses an equivalent built-in implementation. +% +% Usage +% ----- +% R = compute_rsm(dat, 'group_by',{'condition','bodysite'}, 'subject_var','sub'); +% models = rsm.from_categorical(R.metadata_table, {'condition','bodysite'}); +% result = R.compare(models, 'correlation_type','kendall_taua'); +% +% result.r_mean % group-mean correlation per model +% result.relatedness_p % is each model related to the data? +% result.noise_ceiling % [lower upper] bounds +% +% Inputs +% ------ +% obj a data rsm. If 3D (per-subject slices), random-effects +% inference is run across subjects. If 2D, only the +% correlations + a permutation relatedness test are available. +% model_rdms candidate model RDMs. Accepts: +% - an rsm or array of rsm (each k x k) +% - a numeric k x k matrix +% - a metadata column name / cellstr (builds same-vs-different +% model RDMs from obj.metadata_table) +% +% Optional name-value +% ------------------- +% 'correlation_type' 'kendall_taua' (default, recommended for categorical +% models per Nili 2014) | 'spearman' | 'pearson' +% 'relatedness_test' 'signrank' (default, subject RFX Wilcoxon signed-rank) +% | 'ttest' | 'none' +% 'differences_test' 'signrank' (default) | 'ttest' | 'none' +% 'multiple_testing' 'fdr' (default) | 'bonferroni' | 'none' +% 'noise_ceiling' logical (default true). Estimate upper/lower bounds. +% 'tail' 'right' (default) | 'both' (relatedness) +% 'engine' 'auto' (default) | 'rsatoolbox' | 'builtin' +% 'verbose' logical (default true) +% +% Output +% ------ +% result struct: +% .candidate_names {1 x nModels} +% .r [nSubjects x nModels] per-subject correlations +% .r_mean [1 x nModels] group-mean correlation +% .r_se [1 x nModels] standard error across subjects +% .relatedness_p [1 x nModels] +% .relatedness_p_corr [1 x nModels] +% .relatedness_sig [1 x nModels] logical +% .differences_p [nModels x nModels] pairwise model comparison +% .noise_ceiling [lower upper] +% .correlation_type, .engine, .history + +if builtin('numel', obj) > 1 + error('rsm:compare:nonScalar', 'compare expects a scalar rsm.'); +end + +p = inputParser; +p.addParameter('correlation_type', 'kendall_taua', @(x) (ischar(x)||isstring(x)) && ismember(lower(char(x)), {'kendall_taua','kendall','spearman','pearson'})); +p.addParameter('relatedness_test', 'signrank', @(x) (ischar(x)||isstring(x)) && ismember(lower(char(x)), {'signrank','ttest','none'})); +p.addParameter('differences_test', 'signrank', @(x) (ischar(x)||isstring(x)) && ismember(lower(char(x)), {'signrank','ttest','none'})); +p.addParameter('multiple_testing', 'fdr', @(x) (ischar(x)||isstring(x)) && ismember(lower(char(x)), {'fdr','bonferroni','none'})); +p.addParameter('noise_ceiling', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('tail', 'right', @(x) (ischar(x)||isstring(x)) && ismember(lower(char(x)), {'right','both'})); +p.addParameter('engine', 'auto', @(x) (ischar(x)||isstring(x)) && ismember(lower(char(x)), {'auto','rsatoolbox','builtin'})); +p.addParameter('verbose', true, @(x) islogical(x) || isnumeric(x)); +p.parse(varargin{:}); +opt = p.Results; + +k = size(obj.dat, 1); + +% ========================================================================= +% Build data RDM stack (dissimilarity space, per subject) +% ========================================================================= +% Convert similarity RSM -> dissimilarity RDM if needed +data_stack = obj.dat; +if ~obj.is_dissimilarity + data_stack = 1 - data_stack; +end +n_subj = size(data_stack, 3); + +% ========================================================================= +% Normalize model RDMs to dissimilarity space +% ========================================================================= +[models, model_names] = normalize_compare_models(model_rdms, k, obj); +n_models = numel(models); + +% ========================================================================= +% Engine selection +% ========================================================================= +caps = probe_rsatoolbox(); %#ok +% The built-in engine is well-validated, side-effect-free (no figure/file +% saving), and exposes per-subject correlations, so 'auto' prefers it. Use +% 'engine','rsatoolbox' explicitly to route through rsa.compareRefRDM2candRDMs. +use_rsatoolbox = strcmpi(opt.engine, 'rsatoolbox'); + +result = struct(); +result.candidate_names = model_names; +result.correlation_type = lower(char(opt.correlation_type)); +result.history = {sprintf('%s: rsm.compare (%d models, %d subjects)', datestr(now), n_models, n_subj)}; + +if use_rsatoolbox + try + result = compare_via_rsatoolbox(data_stack, models, model_names, opt, result); + result.engine = 'rsatoolbox'; + if opt.verbose, fprintf('rsm.compare: used rsa.compareRefRDM2candRDMs.\n'); end + return + catch ME + if opt.verbose + warning('rsm:compare:rsatoolboxFailed', ... + 'rsatoolbox engine failed (%s); falling back to builtin.', ME.message); + end + end +end + +% ========================================================================= +% Built-in implementation +% ========================================================================= +result = compare_builtin(data_stack, models, model_names, opt, result); +result.engine = 'builtin'; +if opt.verbose + fprintf('rsm.compare: %d models, %d subjects, %s correlation.\n', ... + n_models, n_subj, result.correlation_type); +end + +end + + +% ========================================================================= +function result = compare_builtin(data_stack, models, model_names, opt, result) + +[k, ~, n_subj] = size(data_stack); +n_models = numel(models); +tril_mask = tril(true(k), -1); + +% Vectorize model RDMs +model_vecs = cellfun(@(M) M(tril_mask), models, 'UniformOutput', false); + +% Per-subject correlations to each model +r = nan(n_subj, n_models); +for s = 1:n_subj + dvec = data_stack(:, :, s); + dvec = dvec(tril_mask); + for m = 1:n_models + r(s, m) = rdm_corr(dvec, model_vecs{m}, opt.correlation_type); + end +end + +result.r = r; +result.r_mean = mean(r, 1, 'omitnan'); +result.r_se = std(r, 0, 1, 'omitnan') ./ sqrt(sum(~isnan(r), 1)); + +% Relatedness test (each model vs 0 across subjects) +rel_p = nan(1, n_models); +if n_subj >= 3 && ~strcmpi(opt.relatedness_test, 'none') + for m = 1:n_models + rel_p(m) = group_test(r(:, m), opt.relatedness_test, opt.tail); + end +elseif n_subj < 3 + if opt.verbose + warning('rsm:compare:fewSubjects', ... + 'Random-effects inference needs >= 3 subjects (have %d); relatedness_p = NaN.', n_subj); + end +end +result.relatedness_p = rel_p; + +% Multiple-comparison correction across models +[rel_p_corr, rel_sig] = correct_p(rel_p, opt.multiple_testing); +result.relatedness_p_corr = rel_p_corr; +result.relatedness_sig = rel_sig; + +% Pairwise differences test +diff_p = nan(n_models, n_models); +if n_subj >= 3 && n_models >= 2 && ~strcmpi(opt.differences_test, 'none') + for a = 1:n_models + for b = (a+1):n_models + d = r(:, a) - r(:, b); + pdiff = group_test(d, opt.differences_test, 'both'); + diff_p(a, b) = pdiff; + diff_p(b, a) = pdiff; + end + end +end +result.differences_p = diff_p; + +% Noise ceiling (Nili et al. 2014) +if logical(opt.noise_ceiling) && n_subj >= 2 + result.noise_ceiling = estimate_noise_ceiling(data_stack, opt.correlation_type); +else + result.noise_ceiling = [NaN NaN]; +end + +end + + +% ========================================================================= +function nc = estimate_noise_ceiling(data_stack, corr_type) +% Upper bound: mean correlation of each subject's RDM with the group-mean +% RDM (incl. that subject). Lower bound: leave-one-out (mean of others). +[k, ~, n_subj] = size(data_stack); +tril_mask = tril(true(k), -1); + +vecs = zeros(nnz(tril_mask), n_subj); +for s = 1:n_subj + slice = data_stack(:, :, s); + vecs(:, s) = slice(tril_mask); +end +group_mean = mean(vecs, 2, 'omitnan'); + +upper_rs = nan(n_subj, 1); +lower_rs = nan(n_subj, 1); +for s = 1:n_subj + upper_rs(s) = rdm_corr(vecs(:, s), group_mean, corr_type); + others = mean(vecs(:, setdiff(1:n_subj, s)), 2, 'omitnan'); + lower_rs(s) = rdm_corr(vecs(:, s), others, corr_type); +end +nc = [mean(lower_rs, 'omitnan'), mean(upper_rs, 'omitnan')]; +end + + +% ========================================================================= +function r = rdm_corr(a, b, corr_type) +a = a(:); b = b(:); +mask = isfinite(a) & isfinite(b); +if nnz(mask) < 3, r = NaN; return; end +a = a(mask); b = b(mask); +switch lower(char(corr_type)) + case 'kendall_taua', r = kendall_taua(a, b); + case 'kendall', r = corr(a, b, 'Type', 'Kendall'); + case 'pearson', r = corr(a, b, 'Type', 'Pearson'); + otherwise, r = corr(a, b, 'Type', 'Spearman'); +end +end + + +% ========================================================================= +function tau = kendall_taua(x, y) +% Kendall's tau-a: (n_concordant - n_discordant) / (n*(n-1)/2). +% Unlike tau-b, tau-a does not correct for ties -- the RSA-recommended +% statistic when model RDMs predict many tied ranks (Nili et al. 2014). +n = numel(x); +if n < 2, tau = NaN; return; end +% Pairwise sign products summed via vectorized upper triangle +[I, J] = find(triu(true(n), 1)); +dx = sign(x(I) - x(J)); +dy = sign(y(I) - y(J)); +tau = sum(dx .* dy) / (n*(n-1)/2); +end + + +% ========================================================================= +function pval = group_test(vals, test_type, tail) +vals = vals(~isnan(vals)); +if numel(vals) < 3, pval = NaN; return; end +switch lower(char(test_type)) + case 'signrank' + if strcmpi(tail, 'right') + pval = signrank(vals, 0, 'tail', 'right'); + else + pval = signrank(vals, 0); + end + case 'ttest' + if strcmpi(tail, 'right') + [~, pval] = ttest(vals, 0, 'Tail', 'right'); + else + [~, pval] = ttest(vals, 0); + end + otherwise + pval = NaN; +end +end + + +% ========================================================================= +function [adj, sig] = correct_p(ps, method) +valid = ~isnan(ps); +adj = ps; sig = false(size(ps)); +if ~any(valid), return; end +pv = ps(valid); +n = numel(pv); +switch lower(char(method)) + case 'fdr' + [~, order] = sort(pv); ranks = zeros(n,1); ranks(order) = 1:n; + adj_v = min(pv(:) .* n ./ ranks(:), 1); + sig_v = adj_v < 0.05; + case 'bonferroni' + adj_v = min(pv(:) * n, 1); + sig_v = adj_v < 0.05; + otherwise + adj_v = pv(:); + sig_v = pv(:) < 0.05; +end +adj(valid) = adj_v; +sig(valid) = sig_v; +end + + +% ========================================================================= +function result = compare_via_rsatoolbox(data_stack, models, model_names, opt, result) +% Wrap rsa.compareRefRDM2candRDMs. Builds the ref RDM (per-subject stack) +% and candidate RDM structs in the rsatoolbox format. +[k, ~, n_subj] = size(data_stack); + +% Reference RDMs: rsatoolbox accepts a [k x k x nSubj] stack +refRDMs = data_stack; + +% Candidate RDMs: struct array with .RDM and .name +candRDMs = struct('RDM', {}, 'name', {}, 'color', {}); +for m = 1:numel(models) + candRDMs(m).RDM = models{m}; + candRDMs(m).name = model_names{m}; + candRDMs(m).color = [0 0 0]; +end + +% Map our correlation-type names to rsatoolbox's +switch lower(char(opt.correlation_type)) + case 'kendall_taua', corrtype = 'Kendall_taua'; + case 'spearman', corrtype = 'Spearman'; + case 'pearson', corrtype = 'Pearson'; + otherwise, corrtype = 'Kendall_taua'; +end + +userOptions = struct(); +% Required project-setup fields for rsatoolbox's file/figure machinery +userOptions.analysisName = 'canlab_rsm_compare'; +userOptions.projectName = 'canlab_rsm_compare'; +userOptions.rootPath = tempdir; +userOptions.RDMcorrelationType = corrtype; +userOptions.RDMrelatednessTest = 'subjectRFXsignedRank'; +userOptions.RDMrelatednessThreshold = 0.05; +userOptions.RDMrelatednessMultipleTesting = upper(char(opt.multiple_testing)); +userOptions.RDMcandRelatednessTest = 'subjectRFXsignedRank'; +userOptions.plotpValues = '='; +userOptions.saveFigurePDF = false; +userOptions.saveFigurePNG = false; +userOptions.saveFigureFig = false; +userOptions.displayFigures = false; + +stats_p_r = rsa.compareRefRDM2candRDMs(refRDMs, candRDMs, userOptions); + +% Extract what we can into our standard result struct +result.r_mean = stats_p_r.candRelatedness_r(:)'; +if isfield(stats_p_r, 'candRelatedness_p') + result.relatedness_p = stats_p_r.candRelatedness_p(:)'; +end +if isfield(stats_p_r, 'ceiling') + result.noise_ceiling = stats_p_r.ceiling(:)'; +end +result.rsatoolbox_raw = stats_p_r; +result.r = []; % per-subject not always exposed by the toolbox call +[result.relatedness_p_corr, result.relatedness_sig] = ... + correct_p(result.relatedness_p, opt.multiple_testing); +end + + +% ========================================================================= +function [models, names] = normalize_compare_models(model_rdms, k, obj) +% Coerce candidate models to a cell of k x k dissimilarity matrices + names. +models = {}; names = {}; + +% Column-name form: build from obj.metadata_table +if ischar(model_rdms) || iscellstr(model_rdms) || isstring(model_rdms) %#ok + cols = cellstr(model_rdms); + if isempty(obj.metadata_table) + error('rsm:compare:noMetadata', ... + 'Column-name models require obj.metadata_table.'); + end + for i = 1:numel(cols) + Mrsm = rsm.from_categorical(obj.metadata_table.(cols{i})); + models{end+1} = Mrsm.dat; %#ok + names{end+1} = cols{i}; %#ok + end +elseif isa(model_rdms, 'rsm') + for i = 1:numel(model_rdms) + M = mean(model_rdms(i).dat, 3, 'omitnan'); + if ~model_rdms(i).is_dissimilarity, M = 1 - M; end + models{end+1} = M; %#ok + nm = regexprep(model_rdms(i).source, '^design:', ''); + nm = regexprep(nm, '^parcel:', ''); + if isempty(nm), nm = sprintf('model%d', i); end + names{end+1} = nm; %#ok + end +elseif isnumeric(model_rdms) + models{1} = model_rdms; names{1} = 'model1'; +elseif isstruct(model_rdms) + for i = 1:numel(model_rdms) + models{end+1} = model_rdms(i).RDM; %#ok + if isfield(model_rdms(i), 'name') && ~isempty(model_rdms(i).name) + names{end+1} = model_rdms(i).name; %#ok + else + names{end+1} = sprintf('model%d', i); %#ok + end + end +else + error('rsm:compare:badModel', 'model_rdms must be a column name, rsm, numeric, or struct.'); +end + +for i = 1:numel(models) + if ~isequal(size(models{i}), [k k]) + error('rsm:compare:modelSize', ... + 'Model %d is %dx%d but data RDM is %dx%d.', i, size(models{i},1), size(models{i},2), k, k); + end +end +end diff --git a/CanlabCore/@rsm/contrast.m b/CanlabCore/@rsm/contrast.m new file mode 100644 index 00000000..c0f58ea2 --- /dev/null +++ b/CanlabCore/@rsm/contrast.m @@ -0,0 +1,90 @@ +function vals = contrast(obj, varargin) +% contrast Compute a per-replicate contrast scalar from cell groupings. +% +% A contrast compares one set of cells (A) against another (B): for each +% replicate, it computes (reduction of cells in A) minus (reduction of +% cells in B). For one-sample tests against zero, pass only A. +% +% Usage +% ----- +% % One-sample (cells_A only): mean of A's cells per replicate +% v = R.contrast('hot'); +% v = R.contrast({'hot','hot'}); +% +% % Two-sample (paired): mean of A - mean of B, per replicate +% v = R.contrast('hot', 'warm'); % within-hot vs within-warm +% v = R.contrast({'hot','warm'}, {'hot','imagine'}); % HW vs HI +% +% % With explicit indices +% v = R.contrast(1:8, 9:16); +% +% Each "cells spec" can be: +% - char/string -> within-group (R.cells(name, name)) +% - {a, b} 1x2 cell -> between-group (R.cells(a, b)) +% - numeric/logical index -> within-group on that index set +% +% Optional name-value +% 'transform' 'auto' (default) | 'fisherz' | 'none' +% 'reduction' 'mean' (default) | 'median' | 'sum' +% +% Output +% vals [N_replicates x 1] column vector + +if builtin('numel', obj) > 1 + error('rsm:contrast:nonScalar', 'contrast() expects a scalar rsm.'); +end + +if isempty(varargin) + error('rsm:contrast:noArgs', 'Need at least one cells spec.'); +end + +% Detect: 1 cells-spec (one-sample) vs 2 cells-specs (two-sample) +% Cells specs may be followed by name-value pairs ('transform', ...). +% We assume the first 1 or 2 positional args are specs and the rest are NV pairs. + +n_pos = 0; +for i = 1:numel(varargin) + if ischar(varargin{i}) || isstring(varargin{i}) + s = char(varargin{i}); + if any(strcmpi(s, {'transform','reduction','reduce'})) + break + end + end + n_pos = n_pos + 1; + if n_pos == 2, break; end +end + +if n_pos == 0 + error('rsm:contrast:noSpec', 'Need at least one cells spec before name-value pairs.'); +end + +specA = varargin{1}; +nv = varargin(n_pos+1:end); + +if n_pos == 1 + vA = pull_cells(obj, specA, nv{:}); + vals = vA; +else + specB = varargin{2}; + vA = pull_cells(obj, specA, nv{:}); + vB = pull_cells(obj, specB, nv{:}); + vals = vA - vB; +end + +end + + +function v = pull_cells(obj, spec, varargin) +% Resolve a cells spec and pull values via R.cells. +if ischar(spec) || isstring(spec) + a = char(spec); b = a; +elseif iscell(spec) && numel(spec) == 2 + a = spec{1}; b = spec{2}; +elseif (isnumeric(spec) || islogical(spec)) + a = spec; b = spec; +else + error('rsm:contrast:badSpec', ... + 'cells spec must be name, {a,b} cell, or index vector.'); +end +v = obj.cells(a, b, varargin{:}); +end diff --git a/CanlabCore/@rsm/count_map.m b/CanlabCore/@rsm/count_map.m new file mode 100644 index 00000000..03f96528 --- /dev/null +++ b/CanlabCore/@rsm/count_map.m @@ -0,0 +1,316 @@ +function out = count_map(obj, varargin) +% count_map Count-map / count-table across subjects for an RSM/RDM. +% +% For a subject-stacked rsm (.dat is k x k x N, one slice per subject) counts, +% in every RSM cell / grouping-block / contrast, HOW MANY subjects meet a +% criterion -- the "consistency" summary you report in a paper (e.g. "in 9/11 +% subjects hot patterns were more similar to each other than to warm"). Returns +% both a count-MAP (a matrix you can imagesc) and a count-TABLE (one row per +% cell), the table also carrying the group effect and a group p-value. +% +% Usage +% ----- +% out = R.count_map() % block map, sign criterion +% out = R.count_map('Granularity','full') % k x k condition map +% out = R.count_map('Criterion','threshold','Threshold',0.1) +% out = R.count_map('Criterion','permutation','Nperm',2000) % per-subject test +% out = R.count_map('Granularity','contrasts', 'Contrasts', ... +% { {'hot','hot'}, {'hot','warm'}, {'hot','imagine'} }) +% +% Optional name-value +% ------------------- +% 'Granularity' 'blocks' (default; uses .groupings -> G x G map) | +% 'full' (k x k condition pairs) | 'contrasts' (a list). +% 'Contrasts' for 'contrasts': cell array of {a,b} pairs (a,b are grouping +% names, index vectors, or logical masks; a==b => within-block). +% 'Criterion' how a subject is counted in a cell: +% 'sign' (default) -- oriented value beats 0 (more similar); +% 'threshold' -- oriented value beats 'Threshold'; +% 'permutation' -- the subject's cell is significant vs a +% within-subject label-permutation null. +% 'Threshold' scalar for 'threshold'. Default 0. +% 'Tail' 'right' (default; more similar) | 'left' | 'both'. +% 'Alpha' significance for 'permutation'. Default 0.05. +% 'Nperm' within-subject permutations for 'permutation'. Default 1000. +% 'Transform' 'auto' (default; Fisher-z for correlation-like RSMs) | +% 'fisherz' | 'none'. +% 'Reduction' 'mean' (default) | 'median' | 'sum' -- how each block reduces. +% 'Correction' group-level p correction over cells (hrf_group_stats): +% 'fdr' (default) | 'permutation' | 'none'. +% 'GroupNperm' permutations for the group correction. Default 5000. +% +% Output struct `out` +% ------------------- +% .count_map [G x G] / [k x k] matrix of counts (contrasts: [nc x 1]). +% .prop_map counts / N. +% .n number of subjects. +% .labels block / condition / contrast names (rows of the map). +% .table one row per cell: name_a, name_b, count, n, proportion, +% group_mean, group_t, group_p (uncorrected), group_p_corr, sig. +% .criterion, .granularity, .tail, .is_dissimilarity. +% +% Higher = more similar: for an RDM (.is_dissimilarity true) the value is +% oriented (negated) before the criterion so 'right'/'more similar' always +% means the same thing; the reported group_mean is the raw (dis)similarity. +% +% See also: rsm/cells, rsm/cells_table, rsm/ttest_contrasts, hrf_group_stats. + +if builtin('numel', obj) > 1, error('rsm:count_map:nonScalar', 'count_map expects a scalar rsm.'); end + +p = inputParser; +p.addParameter('Granularity', 'blocks', @(x) ischar(x) || isstring(x)); +p.addParameter('Contrasts', {}, @iscell); +p.addParameter('Criterion', 'sign', @(x) ischar(x) || isstring(x)); +p.addParameter('Threshold', 0, @isscalar); +p.addParameter('Tail', 'right', @(x) ischar(x) || isstring(x)); +p.addParameter('Alpha', 0.05, @(x) isscalar(x) && x > 0 && x < 1); +p.addParameter('Nperm', 1000, @(x) isscalar(x) && x >= 100); +p.addParameter('Transform', 'auto', @(x) ischar(x) || isstring(x)); +p.addParameter('Reduction', 'mean', @(x) ischar(x) || isstring(x)); +p.addParameter('Correction', 'fdr', @(x) ischar(x) || isstring(x)); +p.addParameter('GroupNperm', 5000, @(x) isscalar(x) && x >= 100); +p.addParameter('doplot', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('plot', [], @(x) isempty(x) || islogical(x) || isnumeric(x)); +p.parse(varargin{:}); +o = p.Results; +doplot = logical(o.doplot); if ~isempty(o.plot), doplot = logical(o.plot); end + +D = obj.dat; +if ismatrix(D), D = reshape(D, size(D, 1), size(D, 2), 1); end +[k, ~, N] = size(D); +tf = local_resolve_transform(o.Transform, obj.metric); +gran = lower(char(o.Granularity)); + +% ---- assemble the list of cells to count ------------------------------- +[specs, mapsz, rowlab, collab] = local_specs(obj, k, gran, o.Contrasts); +nc = numel(specs); +if nc == 0, error('rsm:count_map:noCells', 'No cells to count (check groupings / Contrasts).'); end + +% ---- per-subject value in every cell ----------------------------------- +V = nan(nc, N); +for c = 1:nc + for s = 1:N + V(c, s) = local_spec_value(D(:, :, s), specs(c), tf, o.Reduction); + end +end +oriented = V; if obj.is_dissimilarity, oriented = -V; end + +% ---- per-subject "hit" -> count ---------------------------------------- +switch lower(char(o.Criterion)) + case 'threshold', hit = local_tail(oriented, o.Threshold, o.Tail); + case 'permutation' + hit = false(nc, N); + for c = 1:nc + for s = 1:N + hit(c, s) = local_subject_perm(D(:, :, s), specs(c), oriented(c, s), obj.is_dissimilarity, ... + tf, o.Reduction, o.Tail, o.Alpha, o.Nperm); + end + end + otherwise, hit = local_tail(oriented, 0, o.Tail); % 'sign' +end +count = sum(hit, 2, 'omitnan'); +prop = count / N; + +% ---- group-level effect + p over cells (shared engine) ----------------- +G = rsm_group_stats(V, o.Correction, o.GroupNperm); + +% ---- pack map + table -------------------------------------------------- +name_a = {specs.name_a}'; name_b = {specs.name_b}'; +tbl = table(string(name_a), string(name_b), count, repmat(N, nc, 1), prop, ... + G.est(:), G.t(:), G.p(:), G.p_corr(:), G.sig(:), ... + 'VariableNames', {'name_a', 'name_b', 'count', 'n', 'proportion', ... + 'group_mean', 'group_t', 'group_p', 'group_p_corr', 'sig'}); + +out = struct(); +out.n = N; out.criterion = lower(char(o.Criterion)); out.granularity = gran; +out.tail = lower(char(o.Tail)); out.is_dissimilarity = obj.is_dissimilarity; +out.table = tbl; out.labels = rowlab; out.collabels = collab; +if isempty(mapsz) + out.count_map = count; out.prop_map = prop; % contrasts -> vector +else + out.count_map = local_to_matrix(count, specs, mapsz); + out.prop_map = local_to_matrix(prop, specs, mapsz); +end + +if doplot, out.figure = local_plot_countmap(out, mapsz); end +end + + +function h = local_plot_countmap(out, mapsz) +% Heatmap (blocks/full) or bar (contrasts) of the subject counts. +h = figure('Color', 'w', 'Name', 'rsm count map'); +ttl = sprintf('subjects meeting criterion (%s, %s/%d)', out.criterion, ... + 'n', out.n); +if isempty(mapsz) + bar(out.count_map); ylim([0 out.n]); ylabel('# subjects'); box off; + set(gca, 'XTick', 1:numel(out.labels), 'XTickLabel', out.labels, 'XTickLabelRotation', 30); +else + imagesc(out.count_map, [0 out.n]); axis image; colormap(parula); colorbar; + set(gca, 'XTick', 1:numel(out.collabels), 'XTickLabel', out.collabels, ... + 'YTick', 1:numel(out.labels), 'YTickLabel', out.labels, 'XTickLabelRotation', 30); + for i = 1:size(out.count_map, 1) + for j = 1:size(out.count_map, 2) + v = out.count_map(i, j); + if ~isnan(v) + text(j, i, sprintf('%d', v), 'HorizontalAlignment', 'center', ... + 'Color', local_txtcolor(v, out.n), 'FontWeight', 'bold'); + end + end + end +end +title(ttl, 'Interpreter', 'none'); +end + +function c = local_txtcolor(v, n) +if v > 0.6 * n, c = [0 0 0]; else, c = [1 1 1]; end +end + + +% ========================================================================= +function [specs, mapsz, rowlab, collab] = local_specs(obj, k, gran, contrasts) +specs = struct('name_a', {}, 'name_b', {}, 'terms', {}, 'i', {}, 'j', {}); +mapsz = []; rowlab = {}; collab = {}; +switch gran + case 'blocks' + gn = fieldnames(obj.groupings); + if isempty(gn), error('rsm:count_map:noGroupings', 'Granularity=blocks needs obj.groupings.'); end + G = numel(gn); mapsz = [G G]; rowlab = gn; collab = gn; + for i = 1:G + for j = i:G + specs(end + 1) = local_spec(gn{i}, gn{j}, local_term(obj.groupings.(gn{i}), obj.groupings.(gn{j})), i, j); %#ok + end + end + case 'full' + lab = obj.labels; if isempty(lab), lab = arrayfun(@(x) sprintf('c%d', x), 1:k, 'uni', 0); end + mapsz = [k k]; rowlab = lab; collab = lab; + for i = 1:k + for j = i + 1:k + specs(end + 1) = local_spec(lab{i}, lab{j}, local_term(i, j), i, j); %#ok + end + end + case 'contrasts' + if isempty(contrasts), error('rsm:count_map:noContrasts', 'Granularity=contrasts needs ''Contrasts''.'); end + for c = 1:numel(contrasts) + e = contrasts{c}; + if isstruct(e) && isfield(e, 'within') && isfield(e, 'between') + a = local_idx(obj, e.within); b = local_idx(obj, e.between); + if isfield(e, 'name'), nm = e.name; else, nm = sprintf('%s>btw', local_nm(e.within)); end + specs(end + 1) = local_spec(nm, local_nm(e.between), [local_term(a, a), local_term(a, b)], c, c); %#ok + elseif iscell(e) && numel(e) == 2 + ai = local_idx(obj, e{1}); bi = local_idx(obj, e{2}); + specs(end + 1) = local_spec(local_nm(e{1}), local_nm(e{2}), local_term(ai, bi), c, c); %#ok + else + g = e; if iscell(g), g = g{1}; end + ai = local_idx(obj, g); + specs(end + 1) = local_spec(local_nm(g), local_nm(g), local_term(ai, ai), c, c); %#ok + end + end + rowlab = {specs.name_a}'; + otherwise + error('rsm:count_map:granularity', 'Granularity must be blocks | full | contrasts.'); +end +end + + +function s = local_spec(na, nb, terms, i, j) +s = struct('name_a', char(string(na)), 'name_b', char(string(nb)), 'terms', terms, 'i', i, 'j', j); +end + +function t = local_term(a, b) +t = struct('a', a(:)', 'b', b(:)', 'within', isempty(setxor(a(:)', b(:)'))); +end + +function v = local_spec_value(M, spec, tf, red) +t = spec.terms; +v = local_cell_value(M, t(1).a, t(1).b, t(1).within, tf, red); +if numel(t) > 1 + v = v - local_cell_value(M, t(2).a, t(2).b, t(2).within, tf, red); +end +end + + +function idx = local_idx(obj, g) +if ischar(g) || isstring(g) + idx = obj.groupings.(char(g)); +elseif islogical(g) + idx = find(g); +else + idx = g; +end +idx = idx(:)'; +end + +function nm = local_nm(g) +if ischar(g) || isstring(g), nm = char(g); else, nm = 'idx'; end +end + + +function val = local_cell_value(M, ai, bi, within, tf, red) +if within + na = numel(ai); + sub = M(ai, ai); + x = sub(triu(true(na, na), 1)); +else + sub = M(ai, bi); x = sub(:); +end +x = local_transform(double(x), tf); +switch lower(char(red)) + case 'median', val = median(x, 'omitnan'); + case 'sum', val = sum(x, 'omitnan'); + otherwise, val = mean(x, 'omitnan'); +end +end + + +function tf = local_subject_perm(M, spec, obs_oriented, is_diss, transform, red, tail, alpha, nperm) +% Within-subject significance: permute the k condition labels, recompute the +% cell, build a null of the oriented value, one/two-sided p, count if < alpha. +k = size(M, 1); +null = nan(nperm, 1); +for pp = 1:nperm + q = randperm(k); + v = local_spec_value(M(q, q), spec, transform, red); + if is_diss, v = -v; end + null(pp) = v; +end +switch lower(char(tail)) + case 'left', pval = (1 + sum(null <= obs_oriented)) / (1 + nperm); + case 'both', pval = (1 + sum(abs(null) >= abs(obs_oriented))) / (1 + nperm); + otherwise, pval = (1 + sum(null >= obs_oriented)) / (1 + nperm); +end +tf = pval < alpha; +end + + +function h = local_tail(x, thr, tail) +switch lower(char(tail)) + case 'left', h = x < thr; + case 'both', h = abs(x) > abs(thr); + otherwise, h = x > thr; +end +end + + +function x = local_transform(x, tf) +switch tf + case 'fisherz', x = atanh(max(min(x, 1 - 1e-7), -1 + 1e-7)); + otherwise, % 'none' +end +end + + +function tf = local_resolve_transform(spec, metric) +spec = lower(char(spec)); +if strcmp(spec, 'fisherz'), tf = 'fisherz'; return; end +if strcmp(spec, 'none'), tf = 'none'; return; end +if any(strcmpi(char(metric), {'correlation', 'spearman', 'cosine'})), tf = 'fisherz'; else, tf = 'none'; end +end + + +function M = local_to_matrix(vals, specs, mapsz) +M = nan(mapsz); +for c = 1:numel(specs) + M(specs(c).i, specs(c).j) = vals(c); + M(specs(c).j, specs(c).i) = vals(c); +end +end diff --git a/CanlabCore/@rsm/count_models.m b/CanlabCore/@rsm/count_models.m new file mode 100644 index 00000000..0b2e8c6d --- /dev/null +++ b/CanlabCore/@rsm/count_models.m @@ -0,0 +1,258 @@ +function out = count_models(obj, model_rdms, varargin) +% count_models Count how many subjects each candidate model RDM explains. +% +% For a subject-stacked rsm (.dat is k x k x N) correlates every subject's +% RSM/RDM with one or more candidate MODEL RDMs and reports, across subjects, +% how many each model (a) WINS (is the best-fitting model for that subject) and +% (b) is significantly related to the data -- the model-selection analogue of +% count_map, and the "the categorical model was the best fit in 9/11 subjects" +% summary you put in a paper. Complements @rsm/compare (Nili 2014 group RFX): +% compare answers "is this model related / better on average", count_models +% answers "in how many individual subjects". +% +% Usage +% ----- +% R = compute_rsm(dat, 'group_by',{'condition','bodysite'}, 'subject_var','sub'); +% out = R.count_models({'condition','bodysite'}); % same-vs-different models +% out = R.count_models(model_rsm_array, 'Criterion','permutation'); +% out.table % model | wins | n_sig | group stats +% +% Inputs +% ------ +% obj a scalar per-subject-stacked rsm (k x k x N). +% model_rdms candidate models, as in @rsm/compare: +% - an rsm or array of rsm (each k x k) +% - a numeric k x k matrix (or cell of them) +% - a metadata column name / cellstr -> same-vs-different model +% RDMs built from obj.metadata_table (rsm.from_categorical). +% +% Optional name-value +% ------------------- +% 'CorrelationType' 'spearman' (default) | 'kendall' | 'pearson'. +% 'Criterion' how a subject counts as fit by a model: +% 'sign' (default; oriented r>0) | 'threshold' | 'permutation' +% (per-subject RDM-label permutation null). +% 'Threshold' scalar for 'threshold'. Default 0. +% 'Tail' 'right' (default) | 'left' | 'both'. +% 'Alpha' significance for 'permutation'. Default 0.05. +% 'Nperm' per-subject permutations. Default 1000. +% 'Winner' 'exclusive' (default; argmax over models, 1 win/subject) | +% 'significant' (a subject can be "won" by every model it is +% significantly related to) | 'none'. +% 'Correction' group-level p over models: 'fdr' (default) | 'permutation' | 'none'. +% 'GroupNperm' permutations for the group correction. Default 5000. +% 'doplot' logical (default false) -- bar chart of wins / n_sig. +% +% Output struct `out` +% ------------------- +% .r [nModels x N] per-subject oriented correlations (similarity +% space; higher = better fit). +% .model_names {nModels x 1}. +% .wins [nModels x 1] # subjects for which the model is best. +% .n_sig [nModels x 1] # subjects the model significantly fits. +% .n number of subjects. +% .table model, wins, prop_wins, n_sig, prop_sig, group_mean_r, +% group_t, group_p, group_p_corr, sig. +% .figure handle if doplot. +% +% See also: rsm/compare, rsm/count_map, rsm/from_categorical. + +if builtin('numel', obj) > 1, error('rsm:count_models:nonScalar', 'count_models expects a scalar rsm.'); end + +p = inputParser; +p.addRequired('model_rdms'); +p.addParameter('CorrelationType', 'spearman', @(x) ischar(x) || isstring(x)); +p.addParameter('Criterion', 'sign', @(x) ischar(x) || isstring(x)); +p.addParameter('Threshold', 0, @isscalar); +p.addParameter('Tail', 'right', @(x) ischar(x) || isstring(x)); +p.addParameter('Alpha', 0.05, @(x) isscalar(x) && x > 0 && x < 1); +p.addParameter('Nperm', 1000, @(x) isscalar(x) && x >= 100); +p.addParameter('Winner', 'exclusive', @(x) ischar(x) || isstring(x)); +p.addParameter('Correction', 'fdr', @(x) ischar(x) || isstring(x)); +p.addParameter('GroupNperm', 5000, @(x) isscalar(x) && x >= 100); +p.addParameter('doplot', false, @(x) islogical(x) || isnumeric(x)); +p.parse(model_rdms, varargin{:}); +o = p.Results; +ctype = lower(char(o.CorrelationType)); + +D = obj.dat; +if ismatrix(D), D = reshape(D, size(D, 1), size(D, 2), 1); end +[k, ~, N] = size(D); + +% ---- build model vectors (oriented to similarity space) ---------------- +[mnames, mvec] = local_models(obj, o.model_rdms, k); +nm = numel(mnames); +if nm == 0, error('rsm:count_models:noModels', 'No candidate models resolved.'); end +mask = triu(true(k, k), 1); + +% ---- per-subject correlation with every model -------------------------- +R = nan(nm, N); +data_sign = 1; if obj.is_dissimilarity, data_sign = -1; end % -> similarity space +for s = 1:N + Ms = D(:, :, s); dv = data_sign * Ms(mask); + for m = 1:nm + R(m, s) = local_corr(dv, mvec{m}, ctype); + end +end + +% ---- per-subject "hit" per model --------------------------------------- +switch lower(char(o.Criterion)) + case 'threshold', hit = local_tail(R, o.Threshold, o.Tail); + case 'permutation' + hit = false(nm, N); + for s = 1:N + Ms = D(:, :, s); + for m = 1:nm + hit(m, s) = local_model_perm(Ms, mvec{m}, mask, data_sign, R(m, s), ... + ctype, o.Tail, o.Alpha, o.Nperm); + end + end + otherwise, hit = local_tail(R, 0, o.Tail); % 'sign' +end +n_sig = sum(hit, 2); + +% ---- winners ----------------------------------------------------------- +wins = zeros(nm, 1); +switch lower(char(o.Winner)) + case 'none' + % no winner tally + case 'significant' + wins = n_sig; % each sig model "wins" that subject + otherwise % 'exclusive' argmax + for s = 1:N + [best, mi] = max(R(:, s)); + if isfinite(best), wins(mi) = wins(mi) + 1; end + end +end + +% ---- group effect over models (Fisher-z for the test) ------------------ +G = rsm_group_stats(atanh(max(min(R, 1 - 1e-7), -1 + 1e-7)), o.Correction, o.GroupNperm); +group_mean_r = mean(R, 2, 'omitnan'); + +tbl = table(string(mnames(:)), wins, wins / N, n_sig, n_sig / N, group_mean_r, ... + G.t(:), G.p(:), G.p_corr(:), G.sig(:), 'VariableNames', ... + {'model', 'wins', 'prop_wins', 'n_sig', 'prop_sig', 'group_mean_r', ... + 'group_t', 'group_p', 'group_p_corr', 'sig'}); + +out = struct('r', R, 'model_names', {mnames(:)}, 'wins', wins, 'n_sig', n_sig, ... + 'n', N, 'criterion', lower(char(o.Criterion)), 'correlation_type', ctype, ... + 'winner', lower(char(o.Winner)), 'table', tbl); +if logical(o.doplot), out.figure = local_plot_models(out); end +end + + +% ========================================================================= +function [names, vecs] = local_models(obj, models, k) +% Return {names} and {upper-tri vectors in SIMILARITY space} for each model. +names = {}; vecs = {}; +if ischar(models) || isstring(models) || (iscell(models) && all(cellfun(@(x) ischar(x) || isstring(x), models))) + % metadata column name(s) -> same-vs-different model RDM(s) + cols = cellstr(string(models)); + for c = 1:numel(cols) + M = local_categorical_rdm(obj, cols{c}, k); + names{end + 1} = cols{c}; vecs{end + 1} = local_orient(M, true, k); %#ok + end + return +end +if ~iscell(models), models = {models}; end +for m = 1:numel(models) + e = models{m}; + if isa(e, 'rsm') + for q = 1:builtin('numel', e) + eq = e(q); + names{end + 1} = local_model_name(eq, numel(names) + 1); %#ok + vecs{end + 1} = local_orient(eq.dat(:, :, 1), eq.is_dissimilarity, k); %#ok + end + elseif isnumeric(e) + names{end + 1} = sprintf('model_%d', numel(names) + 1); %#ok + vecs{end + 1} = local_orient(e, true, k); % assume RDM %#ok + end +end +end + + +function M = local_categorical_rdm(obj, col, k) +% Same-vs-different (0/1) RDM from a metadata column, k x k. +if isempty(obj.metadata_table) || ~ismember(col, obj.metadata_table.Properties.VariableNames) + error('rsm:count_models:noMeta', 'Model column "%s" not found in obj.metadata_table.', col); +end +v = obj.metadata_table.(col); +if height(obj.metadata_table) ~= k + error('rsm:count_models:metaSize', 'metadata_table has %d rows but RSM is %d-D.', height(obj.metadata_table), k); +end +key = local_key(v); +M = double(bsxfun(@ne, key, key')); % 1 = different (dissimilar) +end + + +function vec = local_orient(M, is_diss, k) +% Upper-tri vector oriented to similarity space (higher = more similar). +if size(M, 1) ~= k, error('rsm:count_models:modelSize', 'Model is %dx%d, expected %dx%d.', size(M, 1), size(M, 2), k, k); end +mask = triu(true(k, k), 1); +vec = M(mask); +if is_diss, vec = -vec; end +end + + +function tf = local_model_perm(Ms, mvec, mask, data_sign, obs, ctype, tail, alpha, nperm) +k = size(Ms, 1); +null = nan(nperm, 1); +for pp = 1:nperm + q = randperm(k); + Mp = Ms(q, q); dv = data_sign * Mp(mask); + null(pp) = local_corr(dv, mvec, ctype); +end +switch lower(char(tail)) + case 'left', pval = (1 + sum(null <= obs)) / (1 + nperm); + case 'both', pval = (1 + sum(abs(null) >= abs(obs))) / (1 + nperm); + otherwise, pval = (1 + sum(null >= obs)) / (1 + nperm); +end +tf = pval < alpha; +end + + +function rho = local_corr(x, y, ctype) +x = x(:); y = y(:); good = isfinite(x) & isfinite(y); +x = x(good); y = y(good); +if numel(x) < 3 || std(x) == 0 || std(y) == 0, rho = NaN; return; end +switch ctype + case 'kendall', rho = corr(x, y, 'type', 'Kendall'); + case 'pearson', rho = corr(x, y, 'type', 'Pearson'); + otherwise, rho = corr(x, y, 'type', 'Spearman'); +end +end + + +function h = local_tail(x, thr, tail) +switch lower(char(tail)) + case 'left', h = x < thr; + case 'both', h = abs(x) > abs(thr); + otherwise, h = x > thr; +end +end + + +function key = local_key(v) +% Integer key per unique value of a metadata column (numeric/cell/categorical). +if iscell(v) || isstring(v) || ischar(v) || iscategorical(v) + [~, ~, key] = unique(cellstr(string(v)), 'stable'); +else + [~, ~, key] = unique(v, 'stable'); +end +key = key(:); +end + + +function nm = local_model_name(r, idx) +if ~isempty(r.metric) && ~strcmpi(r.metric, 'none'), nm = r.metric; else, nm = sprintf('model_%d', idx); end +end + + +function h = local_plot_models(out) +h = figure('Color', 'w', 'Name', 'rsm count_models'); +Y = [out.wins, out.n_sig]; +bar(Y); ylim([0 out.n]); ylabel('# subjects'); box off; +set(gca, 'XTick', 1:numel(out.model_names), 'XTickLabel', out.model_names, 'XTickLabelRotation', 30); +legend({'wins', 'significant'}, 'Location', 'best'); legend boxoff; +title(sprintf('model fit across %d subjects (%s)', out.n, out.criterion), 'Interpreter', 'none'); +end diff --git a/CanlabCore/@rsm/count_regions.m b/CanlabCore/@rsm/count_regions.m new file mode 100644 index 00000000..a3e325e5 --- /dev/null +++ b/CanlabCore/@rsm/count_regions.m @@ -0,0 +1,213 @@ +function out = count_regions(obj, varargin) +% count_regions Subject-consistency count-table / count-map ACROSS brain regions. +% +% Given an ARRAY of per-region rsm objects (one RSM per parcel -- e.g. the +% .per_parcel_rsms emitted by fmri_data.rsa_parcelwise), runs count_map in each +% region and assembles, for a chosen contrast/cell, how many subjects show the +% effect in that region. Optionally projects the per-region counts back onto +% the brain as a statistic_image so the standard .threshold / .montage / .table +% chain just works -- the region-level analogue of count_map, and the map you +% report as "the effect was present in >=8/11 subjects in these regions". +% +% Multiple-comparison scope is ACROSS REGIONS (as in rsa_parcelwise): the +% group p-values from each region are FDR-corrected across regions, per contrast. +% +% Usage +% ----- +% res = rsa_parcelwise(dat, 'atlas',atl, 'group_by',{'condition','bodysite'}, ... +% 'subject_var','sub', 'contrasts',spec); +% out = count_regions(res.per_parcel_rsms, 'Atlas', atl, ... +% 'Contrasts', { struct('within','hot','between','warm','name','hotVsWarm') }); +% out.region_table % long table, one row per region x contrast +% montage(threshold(out.maps.hotVsWarm, 0.05, 'unc')); % paint the count-map +% +% Inputs +% ------ +% obj [nRegions x 1] array of rsm objects (each k x k x N, same conditions). +% +% Optional name-value +% ------------------- +% 'Atlas' atlas object matching obj (num_regions == numel(obj)); enables +% the brain count-map(s). Default [] (table only). +% 'RegionLabels' cellstr region names. Default atlas.labels, else 'region_i'. +% 'Contrasts' cell of contrast specs (as in count_map: {a,b} pairs or +% struct('within',a,'between',b,'name',nm)). Each yields one +% value per region. Default: overall within-RSM similarity. +% 'Cell' alternative to Contrasts for a single block/condition cell: +% {name_a,name_b} or [i j]. Ignored if Contrasts given. +% 'MapValue' what to paint into the brain map(s): 'count' (default) | +% 'proportion' | 'group_t' | 'group_mean'. +% 'Criterion'/'Threshold'/'Tail'/'Alpha'/'Nperm'/'Transform'/'Reduction' +% forwarded to count_map (per-subject criterion). Defaults match. +% 'GroupNperm' permutations for each region's group stats. Default 5000. +% 'doplot' logical (default false) -- bar of counts across regions. +% +% Output struct `out` +% ------------------- +% .region_table long table: Region, Contrast, count, n, proportion, +% group_mean, group_t, group_p, group_p_corr (across regions), sig. +% .count_by_region [nRegions x nContrasts] count matrix. +% .contrast_names {1 x nContrasts}. +% .region_labels {nRegions x 1}. +% .maps struct maps. = statistic_image (if Atlas given). +% .figure handle if doplot. +% +% See also: fmri_data/rsa_parcelwise, rsm/count_map, assign_vals_to_atlas. + +nR = builtin('numel', obj); +if nR < 1 || ~isa(obj, 'rsm'), error('rsm:count_regions:input', 'obj must be an array of rsm objects.'); end + +p = inputParser; +p.addParameter('Atlas', [], @(x) isempty(x) || isa(x, 'atlas')); +p.addParameter('RegionLabels', {}, @(x) iscell(x) || isstring(x) || isempty(x)); +p.addParameter('Contrasts', {}, @iscell); +p.addParameter('Cell', {}, @(x) iscell(x) || isnumeric(x)); +p.addParameter('MapValue', 'count', @(x) ischar(x) || isstring(x)); +p.addParameter('Criterion', 'sign', @(x) ischar(x) || isstring(x)); +p.addParameter('Threshold', 0, @isscalar); +p.addParameter('Tail', 'right', @(x) ischar(x) || isstring(x)); +p.addParameter('Alpha', 0.05, @(x) isscalar(x) && x > 0 && x < 1); +p.addParameter('Nperm', 1000, @(x) isscalar(x) && x >= 100); +p.addParameter('Transform', 'auto', @(x) ischar(x) || isstring(x)); +p.addParameter('Reduction', 'mean', @(x) ischar(x) || isstring(x)); +p.addParameter('GroupNperm', 5000, @(x) isscalar(x) && x >= 100); +p.addParameter('doplot', false, @(x) islogical(x) || isnumeric(x)); +p.parse(varargin{:}); +o = p.Results; + +k = size(obj(1).dat, 1); +contrasts = local_build_contrasts(o.Contrasts, o.Cell, k); +nC = numel(contrasts); +cnames = local_contrast_names(contrasts, obj(1)); + +fwd = {'Criterion', o.Criterion, 'Threshold', o.Threshold, 'Tail', o.Tail, ... + 'Alpha', o.Alpha, 'Nperm', o.Nperm, 'Transform', o.Transform, ... + 'Reduction', o.Reduction, 'GroupNperm', o.GroupNperm, 'Correction', 'none'}; + +count = nan(nR, nC); prop = nan(nR, nC); gmean = nan(nR, nC); +gt = nan(nR, nC); gp = nan(nR, nC); Nsub = nan(nR, 1); +for r = 1:nR + cm = count_map(obj(r), 'Granularity', 'contrasts', 'Contrasts', contrasts, fwd{:}); + T = cm.table; Nsub(r) = cm.n; + count(r, :) = T.count(1:nC)'; prop(r, :) = T.proportion(1:nC)'; + gmean(r, :) = T.group_mean(1:nC)'; gt(r, :) = T.group_t(1:nC)'; gp(r, :) = T.group_p(1:nC)'; +end +N = max(Nsub); + +% ---- FDR across regions, per contrast ---------------------------------- +gp_corr = nan(nR, nC); +for c = 1:nC, gp_corr(:, c) = local_fdr_bh(gp(:, c)); end +sig = gp_corr < 0.05; + +rlab = local_region_labels(o.RegionLabels, o.Atlas, obj, nR); + +% ---- long region table ------------------------------------------------- +Reg = {}; Con = {}; cnt = []; pr = []; gm = []; tt = []; pp = []; ppc = []; sg = []; +for c = 1:nC + Reg = [Reg; rlab(:)]; %#ok + Con = [Con; repmat(cnames(c), nR, 1)]; %#ok + cnt = [cnt; count(:, c)]; pr = [pr; prop(:, c)]; gm = [gm; gmean(:, c)]; %#ok + tt = [tt; gt(:, c)]; pp = [pp; gp(:, c)]; ppc = [ppc; gp_corr(:, c)]; sg = [sg; sig(:, c)]; %#ok +end +region_table = table(string(Reg), string(Con), cnt, repmat(N, numel(cnt), 1), pr, gm, tt, pp, ppc, sg, ... + 'VariableNames', {'Region', 'Contrast', 'count', 'n', 'proportion', ... + 'group_mean', 'group_t', 'group_p', 'group_p_corr', 'sig'}); + +out = struct('region_table', region_table, 'count_by_region', count, ... + 'contrast_names', {cnames}, 'region_labels', {rlab(:)}, 'n', N, 'maps', struct()); + +% ---- brain count-maps -------------------------------------------------- +if ~isempty(o.Atlas) + valmat = local_mapval(o.MapValue, count, prop, gt, gmean); + for c = 1:nC + try + map = assign_vals_to_atlas(o.Atlas, [], valmat(:, c), 'p_vals', gp(:, c), ... + 'output_type', 'statistic_image', ... + 'dat_descrip', sprintf('count_regions %s = %s', o.MapValue, cnames{c})); + out.maps.(matlab.lang.makeValidName(cnames{c})) = map; + catch ME + warning('rsm:count_regions:paint', 'Could not paint map for %s: %s', cnames{c}, ME.message); + end + end +end + +if logical(o.doplot), out.figure = local_plot_regions(out, count, cnames, N); end +end + + +% ========================================================================= +function contrasts = local_build_contrasts(cin, cell_sel, k) +if ~isempty(cin), contrasts = cin; return; end +if ~isempty(cell_sel) + if isnumeric(cell_sel) && numel(cell_sel) == 2 + contrasts = {{cell_sel(1), cell_sel(2)}}; + else + contrasts = {cell_sel}; + end + return +end +contrasts = {{1:k}}; % default: overall within-RSM similarity +end + + +function names = local_contrast_names(contrasts, r0) +names = cell(1, numel(contrasts)); +for c = 1:numel(contrasts) + e = contrasts{c}; + if isstruct(e) && isfield(e, 'name'), names{c} = char(e.name); + elseif isstruct(e) && isfield(e, 'within'), names{c} = sprintf('%s_vs_btw', local_gname(e.within)); + elseif iscell(e) && numel(e) == 2, names{c} = sprintf('%s_vs_%s', local_gname(e{1}), local_gname(e{2})); + else + g = e; if iscell(g), g = g{1}; end + if isnumeric(g) && numel(g) == numel(r0.labels), names{c} = 'overall'; else, names{c} = local_gname(g); end + end + if isempty(names{c}), names{c} = sprintf('contrast_%d', c); end +end +end + + +function s = local_gname(g) +if ischar(g) || isstring(g), s = char(g); else, s = 'idx'; end +end + + +function rlab = local_region_labels(rin, atl, obj, nR) +if ~isempty(rin), rlab = cellstr(string(rin)); rlab = rlab(:); return; end +if ~isempty(atl) && numel(atl.labels) == nR, rlab = atl.labels(:); return; end +rlab = cell(nR, 1); +for r = 1:nR + if ~isempty(obj(r).source) && ~strcmpi(obj(r).source, 'custom'), rlab{r} = obj(r).source; + else, rlab{r} = sprintf('region_%d', r); end +end +end + + +function V = local_mapval(which, count, prop, gt, gmean) +switch lower(char(which)) + case 'proportion', V = prop; + case 'group_t', V = gt; + case 'group_mean', V = gmean; + otherwise, V = count; +end +end + + +function q = local_fdr_bh(pv) +pv = pv(:); m = sum(isfinite(pv)); +q = nan(size(pv)); +idx = find(isfinite(pv)); +[ps, ord] = sort(pv(idx)); +qq = ps .* m ./ (1:numel(ps))'; +qq = min(1, flipud(cummin(flipud(qq)))); +tmp = nan(numel(idx), 1); tmp(ord) = qq; +q(idx) = tmp; +end + + +function h = local_plot_regions(out, count, cnames, N) +h = figure('Color', 'w', 'Name', 'rsm count_regions'); +bar(count); ylim([0 N]); ylabel('# subjects'); box off; +set(gca, 'XTick', 1:numel(out.region_labels), 'XTickLabel', out.region_labels, 'XTickLabelRotation', 45); +legend(cnames, 'Location', 'best', 'Interpreter', 'none'); legend boxoff; +title(sprintf('subjects with effect per region (n=%d)', N), 'Interpreter', 'none'); +end diff --git a/CanlabCore/@rsm/disp.m b/CanlabCore/@rsm/disp.m new file mode 100644 index 00000000..a3e8a9fd --- /dev/null +++ b/CanlabCore/@rsm/disp.m @@ -0,0 +1,68 @@ +function disp(obj) +% disp Custom textual display of an rsm in the command window. + +n = builtin('numel', obj); + +if n == 0 + fprintf(' empty rsm array (0 elements)\n\n'); + return +end + +if n > 1 + sz = builtin('size', obj); + fprintf(' [%s] array of rsm objects\n\n', ... + strjoin(arrayfun(@num2str, sz, 'UniformOutput', false), 'x')); + max_show = min(n, 10); + for i = 1:max_show + fprintf(' (%d) ', i); display_one(obj(i)); + end + if n > max_show + fprintf(' ... and %d more (use obj(i) to inspect, or get_by_label / select)\n\n', n - max_show); + end + return +end + +display_one(obj); + +end + + +function display_one(obj) + +if isempty(obj.dat) + fprintf(' empty rsm (metric=%s)\n', obj.metric); + return +end + +k = size(obj.dat, 1); +N = size(obj.dat, 3); + +kind = 'RSM'; if obj.is_dissimilarity, kind = 'RDM'; end + +fprintf(' %s [%d x %d]', kind, k, k); +if N > 1 + fprintf(' x %d (%s)', N, obj.level); +end +fprintf(' metric=%s', obj.metric); +if ~strcmp(obj.whitened.level, 'none') + fprintf(' whitened=%s', obj.whitened.level); +end +fprintf('\n'); + +if ~isempty(obj.labels) + if numel(obj.labels) > 6 + show = strjoin([obj.labels(1:3)' {'...'} obj.labels(end-1:end)'], ', '); + else + show = strjoin(obj.labels, ', '); + end + fprintf(' labels: %s\n', show); +end + +if ~isempty(obj.groupings) && ~isempty(fieldnames(obj.groupings)) + fprintf(' groupings: %s\n', strjoin(fieldnames(obj.groupings), ', ')); +end + +fprintf(' source: %s\n', obj.source); +fprintf('\n'); + +end diff --git a/CanlabCore/@rsm/drift.m b/CanlabCore/@rsm/drift.m new file mode 100644 index 00000000..a24f97d1 --- /dev/null +++ b/CanlabCore/@rsm/drift.m @@ -0,0 +1,185 @@ +function varargout = drift(obj, varargin) +% drift Quantify within-condition pattern stability across replicates. +% +% Generalizes the `08192024 Representational Drift in RSA` workflow: for each +% condition (or grouping), pulls the per-replicate self-similarity / row from +% the rsm and quantifies how it changes across replicates (sessions/runs). +% +% Two operating modes +% ------------------- +% 'reference','first' (default) -- correlate each replicate's RSM row with +% the first replicate's. Returns a per- +% replicate-per-condition matrix of +% correlations. +% +% 'reference','mean' -- correlate each replicate's RSM with the +% mean RSM across the other replicates +% (leave-one-out). +% +% Optional linear fit +% ------------------- +% 'fit','linear' -- additionally fit a per-condition linear model +% similarity ~ replicate_index. Returns slopes, intercepts, +% R-squared, p-values in a side table. +% +% Usage +% ----- +% % Per-condition similarity-to-first-replicate +% D = R.drift(); % returns table: replicate | condition | similarity +% +% % With replicate_table info (subject, session) +% D = R.drift('reference','first') +% +% % Linear fit of similarity over replicate index, per condition +% [D, fit] = R.drift('fit','linear'); +% +% Inputs +% ------ +% varargin: +% 'reference' 'first' (default) | 'mean' +% 'fit' 'none' (default) | 'linear' +% 'metric' 'pearson' (default) | 'spearman' +% +% Output +% ------ +% If 'fit' = 'none': +% table with columns: replicate_index | condition | similarity (+ replicate_table cols) +% If 'fit' = 'linear': +% [drift_table, fit_table] where fit_table is: +% condition | slope | intercept | R_squared | P_value + +if builtin('numel', obj) > 1 + error('rsm:drift:nonScalar', 'expects a scalar rsm.'); +end + +p = inputParser; +p.addParameter('reference', 'first', @(x) ischar(x) || isstring(x)); +p.addParameter('fit', 'none', @(x) ischar(x) || isstring(x)); +p.addParameter('metric', 'pearson', @(x) ischar(x) || isstring(x)); +p.parse(varargin{:}); +opt = p.Results; + +[k, ~, N] = size(obj.dat); +if N < 2 + error('rsm:drift:tooFewReplicates', 'Need at least 2 replicates; got %d.', N); +end + +% For each condition (row), build the "drift" series: similarity of that +% condition's relationships to the reference, across replicates. +% Per condition c: at each replicate n, take RSM(c, others, n) and correlate +% with the reference RSM(c, others, *). + +ref_mode = lower(char(opt.reference)); +metric = lower(char(opt.metric)); +corr_type = 'Pearson'; if strcmp(metric, 'spearman'), corr_type = 'Spearman'; end + +cond_labels = obj.labels; +if isempty(cond_labels) + cond_labels = arrayfun(@(i) sprintf('cond_%d', i), (1:k)', 'UniformOutput', false); +end + +% Pre-compute reference row vectors per condition +ref_rows = nan(k, k - 1); % row per condition, excluding self +for c = 1:k + other = [1:c-1, c+1:k]; + switch ref_mode + case 'first' + ref_rows(c, :) = obj.dat(c, other, 1); + case 'mean' + ref_rows(c, :) = mean(obj.dat(c, other, :), 3, 'omitnan'); + otherwise + error('rsm:drift:badReference', ... + 'reference must be ''first'' or ''mean''.'); + end +end + +% Build the per-(condition, replicate) similarity series +n_rows = N * k; +rep_idx = zeros(n_rows, 1); +cond_col = cell(n_rows, 1); +sim_col = nan(n_rows, 1); +row = 0; +for n = 1:N + for c = 1:k + other = [1:c-1, c+1:k]; + v = squeeze(obj.dat(c, other, n))'; + row = row + 1; + rep_idx(row) = n; + cond_col{row} = cond_labels{c}; + if strcmp(ref_mode, 'mean') + % Leave-one-out: exclude self-replicate from the reference + ref = mean(obj.dat(c, other, setdiff(1:N, n)), 3, 'omitnan'); + sim_col(row) = nan_safe_corr(v, ref, corr_type); + else + sim_col(row) = nan_safe_corr(v, ref_rows(c, :)', corr_type); + end + end +end + +drift_table = table(rep_idx, cond_col, sim_col, ... + 'VariableNames', {'replicate_index', 'condition', 'similarity'}); + +% Attach replicate_table columns +if ~isempty(obj.replicate_table) + rep_cols = obj.replicate_table.Properties.VariableNames; + for i = 1:numel(rep_cols) + v = obj.replicate_table.(rep_cols{i}); + if isnumeric(v) + drift_table.(rep_cols{i}) = v(rep_idx); + elseif iscell(v) + drift_table.(rep_cols{i}) = v(rep_idx); + elseif iscategorical(v) || isstring(v) + drift_table.(rep_cols{i}) = v(rep_idx); + end + end +end + +if strcmpi(opt.fit, 'none') + varargout{1} = drift_table; + if nargout >= 2, varargout{2} = table.empty; end + return +end + +if ~strcmpi(opt.fit, 'linear') + error('rsm:drift:badFit', 'fit must be ''none'' or ''linear''.'); +end + +% Per-condition linear fit of similarity ~ replicate_index +fit_rows = cell(k, 1); +for c = 1:k + is_c = strcmp(drift_table.condition, cond_labels{c}); + x = drift_table.replicate_index(is_c); + y = drift_table.similarity(is_c); + mask = isfinite(y); + if nnz(mask) < 3 + fit_rows{c} = table({cond_labels{c}}, NaN, NaN, NaN, NaN, ... + 'VariableNames', {'condition', 'slope', 'intercept', 'R_squared', 'P_value'}); + continue + end + mdl = fitlm(x(mask), y(mask)); + fit_rows{c} = table({cond_labels{c}}, mdl.Coefficients.Estimate(2), ... + mdl.Coefficients.Estimate(1), mdl.Rsquared.Ordinary, ... + mdl.Coefficients.pValue(2), ... + 'VariableNames', {'condition', 'slope', 'intercept', 'R_squared', 'P_value'}); +end +fit_table = vertcat(fit_rows{:}); + +varargout{1} = drift_table; +if nargout >= 2 + varargout{2} = fit_table; +end + +end + + +function r = nan_safe_corr(a, b, corr_type) +% Pairwise-complete correlation with a minimum-overlap floor of 3 finite +% pairs. Avoids NaN propagation when one input has missing entries (e.g. a +% session with one missing condition produces a partial NaN row). +a = a(:); b = b(:); +m = isfinite(a) & isfinite(b); +if nnz(m) < 3 + r = NaN; return +end +r = corr(a(m), b(m), 'Type', corr_type); +end diff --git a/CanlabCore/@rsm/fisher_z.m b/CanlabCore/@rsm/fisher_z.m new file mode 100644 index 00000000..7747349f --- /dev/null +++ b/CanlabCore/@rsm/fisher_z.m @@ -0,0 +1,22 @@ +function obj = fisher_z(obj) +% fisher_z Apply atanh to .dat, in-place value transform. +% +% Only sensible for correlation-style RSMs (values in [-1, 1]). For metrics +% outside that range, returns unchanged with a warning. + +if numel(obj) > 1, obj = arrayfun(@fisher_z, obj); return; end + +if obj.is_dissimilarity || ~ismember(lower(obj.metric), {'correlation','spearman','cosine'}) + warning('rsm:fisher_z:unusualMetric', ... + 'fisher_z applied to a non-correlation rsm (metric=%s); proceeding but check values.', obj.metric); +end + +% Clip to avoid Inf at exactly ±1 +d = obj.dat; +d(d > 0.9999999) = 0.9999999; +d(d < -0.9999999) = -0.9999999; +obj.dat = atanh(d); + +obj.history{end+1} = sprintf('%s: fisher_z (atanh)', datestr(now, 'yyyy-mm-dd HH:MM:SS')); + +end diff --git a/CanlabCore/@rsm/from_categorical.m b/CanlabCore/@rsm/from_categorical.m new file mode 100644 index 00000000..8f6b0d17 --- /dev/null +++ b/CanlabCore/@rsm/from_categorical.m @@ -0,0 +1,152 @@ +function obj = from_categorical(v, varargin) +% rsm.from_categorical Same-vs-different binary RDM from a categorical vector. +% +% Builds a model RDM where entry (i, j) = 0 iff v(i) == v(j), else 1. The +% diagonal is 0. This is the "SameX" predictor used as a building block for +% Phase 3 LME design matrices and for hypothesis RDMs (Bodysite, Condition, +% etc.) in the Sun et al. workflow. +% +% Wraps rsa.rdm.categoricalRDM when available, falls back to the equivalent +% `double(v(:) ~= v(:)')` otherwise. +% +% Usage +% ----- +% M = rsm.from_categorical(v) +% M = rsm.from_categorical(v, 'labels', labels, 'name', 'Bodysite') +% +% % Multi-column form: pass a table + list of column names, get an array +% % of rsm objects, one per column. +% M = rsm.from_categorical(meta_table, ... +% {'subject_id','session_number','condition','bodysite'}) +% +% Inputs +% ------ +% v Either: +% - vector (numeric / cell / categorical / string) of length k, OR +% - table whose rows describe k conditions (used with column list) +% +% varargin name-value pairs: +% 'columns' cellstr — column names to pull from v (when v is a table). +% If omitted and v is a table with a 2nd positional argument +% that is cellstr, that arg is used as columns. +% 'labels' {k x 1} cellstr — condition labels for the resulting RDM. +% 'name' char — short name for this model RDM (stored in .source). +% +% Output +% ------ +% obj rsm object (or [1 x p] array when multiple columns requested) + +% -------------------------------------------------------------- +% Multi-column dispatch when first input is a table +% -------------------------------------------------------------- +if istable(v) + cols = []; + if ~isempty(varargin) && (iscellstr(varargin{1}) || isstring(varargin{1})) %#ok + cols = cellstr(varargin{1}); + varargin = varargin(2:end); + elseif ~isempty(varargin) && ischar(varargin{1}) && ... + ismember(varargin{1}, v.Properties.VariableNames) + % Single char column name (e.g. from_categorical(meta, 'condition')) + cols = varargin(1); + varargin = varargin(2:end); + end + p = inputParser; + p.addParameter('columns', cols, @(x) iscellstr(x) || isstring(x) || isempty(x)); %#ok + p.addParameter('labels', {}, @(x) iscellstr(x) || isstring(x) || isempty(x)); %#ok + p.addParameter('name', '', @(x) ischar(x) || isstring(x)); + p.parse(varargin{:}); + cols = cellstr(p.Results.columns); + + if isempty(cols) + error('rsm:from_categorical:noColumns', ... + 'When passing a table, you must also specify which column(s) to use.'); + end + + obj = rsm.empty; + for c = 1:numel(cols) + sub_name = cols{c}; + obj(end+1) = rsm.from_categorical(v.(sub_name), ... + 'labels', p.Results.labels, ... + 'name', sub_name); %#ok + end + return +end + +% -------------------------------------------------------------- +% Single-vector path +% -------------------------------------------------------------- +p = inputParser; +p.addParameter('labels', {}, @(x) iscellstr(x) || isstring(x) || isempty(x)); %#ok +p.addParameter('name', '', @(x) ischar(x) || isstring(x)); +p.parse(varargin{:}); + +v = v(:); +k = numel(v); + +caps = probe_rsatoolbox(); + +% Build the binary same-vs-different matrix +if caps.rdm_categoricalRDM + try + if ~isempty(which('rsa.rdm.categoricalRDM')) + M = rsa.rdm.categoricalRDM(v); + else + M = categoricalRDM(v); + end + % rsa.rdm.categoricalRDM may return packed structures depending on + % version; coerce to a plain k x k binary matrix. + if isstruct(M) && isfield(M, 'RDM'), M = M.RDM; end + M = double(M); + % Some rsatoolbox versions return a "categorical crossing count" rather + % than a binary same-diff. Re-binarize defensively. + M = double(M ~= 0); + catch + M = local_categorical_rdm(v); + end +else + M = local_categorical_rdm(v); +end + +% Diagonal is always 0 (same-as-self) +M(1:k+1:end) = 0; + +labels = cellstr(p.Results.labels); +if isempty(labels) + % Default labels: stringified values of v + if iscell(v) + labels = cellfun(@(x) char(string(x)), v, 'UniformOutput', false); + else + labels = cellstr(string(v)); + end +end + +name = char(p.Results.name); +if isempty(name), name = 'categorical'; end + +obj = rsm(M, ... + 'is_dissimilarity', true, ... + 'metric', 'categorical', ... + 'labels', labels, ... + 'level', 'model_stack', ... + 'source', ['design:' name]); + +end + + +% ========================================================================= +function M = local_categorical_rdm(v) +% Stock fallback: 1 where v(i) ~= v(j), else 0; diagonal = 0. + if iscategorical(v) + % Convert to double codes for fast comparison + u = double(v); + elseif iscell(v) + % Map unique values to integers + [~, ~, u] = unique(v, 'stable'); + elseif isstring(v) + [~, ~, u] = unique(v, 'stable'); + else + u = double(v); + end + u = u(:); + M = double(u ~= u'); +end diff --git a/CanlabCore/@rsm/from_design.m b/CanlabCore/@rsm/from_design.m new file mode 100644 index 00000000..3dd1a72e --- /dev/null +++ b/CanlabCore/@rsm/from_design.m @@ -0,0 +1,84 @@ +function obj = from_design(X, varargin) +% rsm.from_design Model RDM(s) from a numeric/binary design matrix. +% +% Reproduces the design-to-RDM logic that @fmri_data/rsa_regression.m embeds +% inline (its lines 95–100): +% modelRDM(:, i) = pdist(design(:, i), 'seuclidean') +% modelRDM(:, i) = 100000 * modelRDM(:, i) / sum(modelRDM(:, i)) +% +% Each column of X is treated as one regressor and produces one model RDM. +% +% Usage +% ----- +% M = rsm.from_design(X) +% M = rsm.from_design(X, 'names', {'hot','warm','imagine'}, ... +% 'metric', 'seuclidean', ... +% 'normalize', true) +% +% Inputs +% ------ +% X [k x p] numeric design matrix. k = number of conditions/observations, +% p = number of regressors. Each column becomes one model RDM. +% +% varargin name-value pairs: +% 'names' {1 x p} cellstr — names for each model RDM (default: regressor_i). +% 'metric' pdist metric, default 'seuclidean'. Common alternatives: +% 'euclidean', 'cityblock', 'hamming'. +% 'normalize' logical, default true. Match rsa_regression's +% 100000 * d / sum(d) rescaling. +% 'labels' {k x 1} cellstr — condition (row/col) labels. +% +% Output +% ------ +% obj [1 x p] array of rsm objects (or scalar if p == 1) + +p = inputParser; +p.addParameter('names', {}, @(x) iscellstr(x) || isstring(x) || isempty(x)); %#ok +p.addParameter('metric', 'seuclidean',@(x) ischar(x) || isstring(x)); +p.addParameter('normalize', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('labels', {}, @(x) iscellstr(x) || isstring(x) || isempty(x)); %#ok +p.parse(varargin{:}); + +if ~isnumeric(X) && ~islogical(X) + error('rsm:from_design:nonNumeric', 'X must be a numeric or logical [k x p] design matrix.'); +end +X = double(X); + +[k, q] = size(X); + +names = cellstr(p.Results.names); +if isempty(names) + names = arrayfun(@(i) sprintf('regressor_%d', i), 1:q, 'UniformOutput', false); +elseif numel(names) ~= q + error('rsm:from_design:badNames', ... + 'numel(names) = %d but X has %d columns.', numel(names), q); +end + +labels = cellstr(p.Results.labels); +if isempty(labels) + labels = arrayfun(@(i) sprintf('cond_%d', i), 1:k, 'UniformOutput', false)'; +end + +metric_name = char(p.Results.metric); +normalize = logical(p.Results.normalize); + +obj = rsm.empty; +for i = 1:q + d = pdist(X(:, i), metric_name); % 1 x [k*(k-1)/2] + if normalize && sum(d) > 0 + d = 100000 * d / sum(d); + end + M = squareform(d); + M(1:k+1:end) = 0; + + obj(end+1) = rsm(M, ... %#ok + 'is_dissimilarity', true, ... + 'metric', 'design', ... + 'labels', labels, ... + 'level', 'model_stack', ... + 'source', ['design:' names{i} '(' metric_name ')']); +end + +if q == 1, obj = obj(1); end + +end diff --git a/CanlabCore/@rsm/from_metadata_distance.m b/CanlabCore/@rsm/from_metadata_distance.m new file mode 100644 index 00000000..0af74525 --- /dev/null +++ b/CanlabCore/@rsm/from_metadata_distance.m @@ -0,0 +1,114 @@ +function obj = from_metadata_distance(meta, col, varargin) +% rsm.from_metadata_distance Continuous-distance model RDM from a metadata column. +% +% Builds a model RDM where entry (i, j) = distance(v(i), v(j)) for a numeric +% metadata column v. Lets users model ordinal/continuous predictors (e.g. +% session distance, time since baseline, run distance) as continuous terms in +% the LME design rather than collapsing them to same-vs-different. +% +% Usage +% ----- +% M = rsm.from_metadata_distance(meta_table, 'session_number') +% M = rsm.from_metadata_distance(meta_table, 'session_number', ... +% 'metric', 'abs_diff') +% M = rsm.from_metadata_distance(numeric_vec, '', 'metric', 'squared_diff') +% +% Inputs +% ------ +% meta Either a table (with `col` naming a numeric column) or a +% numeric vector of length k. +% col Column name (when meta is a table). Ignored when meta is a vector. +% +% varargin name-value pairs: +% 'metric' one of {'abs_diff','squared_diff','log_diff','custom'}. +% Default: 'abs_diff'. +% 'fcn' function handle (a, b) -> distance scalar. Required when +% 'metric' is 'custom'. Default: []. +% 'labels' {k x 1} cellstr — condition labels. +% 'name' char — short name for this model RDM (stored in .source). +% +% Output +% ------ +% obj rsm object with is_dissimilarity=true, metric='distance' + +p = inputParser; +p.addParameter('metric', 'abs_diff', @(x) ischar(x) || isstring(x)); +p.addParameter('fcn', [], @(x) isempty(x) || isa(x, 'function_handle')); +p.addParameter('labels', {}, @(x) iscellstr(x) || isstring(x) || isempty(x)); %#ok +p.addParameter('name', '', @(x) ischar(x) || isstring(x)); +p.parse(varargin{:}); + +if istable(meta) + if isempty(col) || ~ischar(col) && ~isstring(col) + error('rsm:from_metadata_distance:badCol', ... + 'When meta is a table, you must pass a column name as the 2nd argument.'); + end + v = meta.(char(col)); + if isempty(p.Results.labels) + % Pre-assign so we don't overwrite with default later + labels_default = cellstr(string(v)); + else + labels_default = {}; + end +else + v = meta; + labels_default = {}; +end + +if ~isnumeric(v) + try + v = double(v); + catch + error('rsm:from_metadata_distance:nonNumeric', ... + 'metadata column must be numeric (or coercible to double).'); + end +end + +v = v(:); +k = numel(v); + +metric_name = lower(char(p.Results.metric)); +switch metric_name + case 'abs_diff' + M = abs(v - v'); + case 'squared_diff' + M = (v - v').^2; + case 'log_diff' + % log|a - b + eps|; pad tiny so identical values give 0 distance not -Inf + D = abs(v - v'); + M = log1p(D); + case 'custom' + if isempty(p.Results.fcn) + error('rsm:from_metadata_distance:noFcn', ... + 'metric=''custom'' requires a function handle via ''fcn''.'); + end + M = zeros(k); + for i = 1:k + for j = i+1:k + M(i, j) = p.Results.fcn(v(i), v(j)); + M(j, i) = M(i, j); + end + end + otherwise + error('rsm:from_metadata_distance:badMetric', ... + 'metric must be one of {abs_diff, squared_diff, log_diff, custom}; got %s.', metric_name); +end + +M(1:k+1:end) = 0; + +labels = cellstr(p.Results.labels); +if isempty(labels), labels = labels_default; end + +name = char(p.Results.name); +if isempty(name) + if istable(meta), name = char(col); else, name = 'distance'; end +end + +obj = rsm(M, ... + 'is_dissimilarity', true, ... + 'metric', 'distance', ... + 'labels', labels, ... + 'level', 'model_stack', ... + 'source', ['design:' name '(' metric_name ')']); + +end diff --git a/CanlabCore/@rsm/from_table.m b/CanlabCore/@rsm/from_table.m new file mode 100644 index 00000000..425a065a --- /dev/null +++ b/CanlabCore/@rsm/from_table.m @@ -0,0 +1,98 @@ +function obj = from_table(meta, spec, varargin) +% rsm.from_table Build multiple model RDMs from a metadata table + spec. +% +% Convenience wrapper that dispatches each spec entry to from_categorical or +% from_metadata_distance based on a 'kind' field, returning an array of rsm +% objects. +% +% Usage +% ----- +% spec = { +% struct('col','condition', 'kind','categorical'); +% struct('col','bodysite', 'kind','categorical'); +% struct('col','session_number', 'kind','distance', 'metric','abs_diff'); +% }; +% M = rsm.from_table(meta_table, spec); +% +% Shorthand (auto-detects: numeric column -> distance; everything else -> +% categorical): +% M = rsm.from_table(meta_table, {'condition','bodysite','session_number'}); +% +% Inputs +% ------ +% meta table — one row per condition. +% spec Either: +% - cell array of struct entries with fields: +% .col column name in meta +% .kind 'categorical' or 'distance' +% .metric (optional) metric for 'distance' kind +% .name (optional) override the RDM's short name +% - cellstr/string of column names (auto-detect kind) +% +% varargin name-value pairs: +% 'labels' {k x 1} cellstr — condition labels applied to all RDMs. +% +% Output +% ------ +% obj [1 x p] array of rsm objects + +p = inputParser; +p.addParameter('labels', {}, @(x) iscellstr(x) || isstring(x) || isempty(x)); %#ok +p.parse(varargin{:}); + +% -------------------------------------------------------------- +% Normalize spec into an array of structs with .col, .kind, .metric, .name +% -------------------------------------------------------------- +if iscellstr(spec) || isstring(spec) %#ok + cols = cellstr(spec); + entries = cell(numel(cols), 1); + for i = 1:numel(cols) + c = cols{i}; + v = meta.(c); + kind = 'categorical'; + if isnumeric(v) && ~all(v == round(v)) || ... + (isnumeric(v) && numel(unique(v(:))) > 8 && all(v == round(v))) + % Heuristic: numeric with non-integer values OR integer with many + % levels -> treat as distance. Users wanting categorical instead + % should use explicit struct spec. + kind = 'distance'; + end + entries{i} = struct('col', c, 'kind', kind, 'metric', 'abs_diff', 'name', c); + end +elseif iscell(spec) + entries = spec; +elseif isstruct(spec) + entries = num2cell(spec); +else + error('rsm:from_table:badSpec', ... + 'spec must be cellstr, cell-of-structs, or struct array.'); +end + +% -------------------------------------------------------------- +% Dispatch +% -------------------------------------------------------------- +obj = rsm.empty; +for i = 1:numel(entries) + e = entries{i}; + if ~isfield(e, 'kind'), e.kind = 'categorical'; end + if ~isfield(e, 'name') || isempty(e.name), e.name = e.col; end + + switch lower(e.kind) + case 'categorical' + obj(end+1) = rsm.from_categorical(meta.(e.col), ... + 'labels', p.Results.labels, ... + 'name', e.name); %#ok + case 'distance' + metric_name = 'abs_diff'; + if isfield(e, 'metric') && ~isempty(e.metric), metric_name = e.metric; end + obj(end+1) = rsm.from_metadata_distance(meta, e.col, ... + 'metric', metric_name, ... + 'labels', p.Results.labels, ... + 'name', e.name); %#ok + otherwise + error('rsm:from_table:badKind', ... + 'spec.kind must be ''categorical'' or ''distance''; got %s.', e.kind); + end +end + +end diff --git a/CanlabCore/@rsm/get_by_label.m b/CanlabCore/@rsm/get_by_label.m new file mode 100644 index 00000000..7653bbc5 --- /dev/null +++ b/CanlabCore/@rsm/get_by_label.m @@ -0,0 +1,95 @@ +function out = get_by_label(obj, query, varargin) +% get_by_label Look up an rsm in an array by its source/parcel label. +% +% Searches the .source field of each rsm in the array for a match against +% the query string. Match modes (exact / contains / regex) selectable. +% +% Typical use case: a parcelwise compute_rsm call returns +% R_per_parcel = [1 x nParcels] array of rsm objects +% each tagged with .source = 'parcel:'. Looking up by index +% requires knowing the atlas label order; looking up by label does not. +% +% Usage +% ----- +% R_amy = R_per_parcel.get_by_label('Amy') % contains (default) +% R_acc = R_per_parcel.get_by_label('CG_L', 'mode','exact') +% R_sii = R_per_parcel.get_by_label('SII\+?', 'mode','regex') +% +% If multiple parcels match, returns the full sub-array. If none match, +% errors with a list of available labels. +% +% Inputs +% ------ +% obj rsm or array of rsm +% query char/string to match against .source +% varargin: +% 'mode' 'contains' (default) | 'exact' | 'regex' +% 'ignore_case' logical (default true) +% 'strip_prefix' logical (default true) -- ignore the 'parcel:' / +% 'searchlight:' prefix when matching +% +% Output +% ------ +% out Matching rsm (scalar if 1 match) or [1 x m] sub-array + +p = inputParser; +p.addParameter('mode', 'contains', @(x) ischar(x) || isstring(x)); +p.addParameter('ignore_case', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('strip_prefix', true, @(x) islogical(x) || isnumeric(x)); +p.parse(varargin{:}); +opt = p.Results; + +query = char(query); + +n = builtin('numel', obj); +labels = cell(n, 1); +for i = 1:n + s = char(obj(i).source); + if opt.strip_prefix + % Drop a leading 'kind:' prefix (parcel:, searchlight:, design:) + c = strfind(s, ':'); + if ~isempty(c) && c(1) < numel(s) + s = s(c(1)+1:end); + end + end + labels{i} = s; +end + +q = query; +if opt.ignore_case + labels_cmp = lower(labels); + q = lower(q); +else + labels_cmp = labels; +end + +mode = lower(char(opt.mode)); +switch mode + case 'exact' + hits = strcmp(labels_cmp, q); + case 'contains' + hits = contains(labels_cmp, q); + case 'regex' + hits = ~cellfun('isempty', regexp(labels_cmp, q, 'once')); + otherwise + error('rsm:get_by_label:badMode', ... + 'mode must be ''contains'', ''exact'', or ''regex''; got %s.', opt.mode); +end + +idx = find(hits); +if isempty(idx) + % Helpful error: list available labels + preview = labels; + if numel(preview) > 12, preview = [preview(1:10); {'...'}; preview(end-1:end)]; end + error('rsm:get_by_label:noMatch', ... + ['No rsm matched query "%s" (mode=%s). Available labels:\n %s'], ... + query, mode, strjoin(preview, ', ')); +end + +out = obj(idx); +% Return scalar when exactly one match (matches user expectation) +if numel(idx) == 1 + out = out(1); +end + +end diff --git a/CanlabCore/@rsm/inv_fisher_z.m b/CanlabCore/@rsm/inv_fisher_z.m new file mode 100644 index 00000000..83c3e323 --- /dev/null +++ b/CanlabCore/@rsm/inv_fisher_z.m @@ -0,0 +1,9 @@ +function obj = inv_fisher_z(obj) +% inv_fisher_z Apply tanh to .dat, the inverse of fisher_z. + +if numel(obj) > 1, obj = arrayfun(@inv_fisher_z, obj); return; end + +obj.dat = tanh(obj.dat); +obj.history{end+1} = sprintf('%s: inv_fisher_z (tanh)', datestr(now, 'yyyy-mm-dd HH:MM:SS')); + +end diff --git a/CanlabCore/@rsm/isempty.m b/CanlabCore/@rsm/isempty.m new file mode 100644 index 00000000..0c9d51d2 --- /dev/null +++ b/CanlabCore/@rsm/isempty.m @@ -0,0 +1,21 @@ +function tf = isempty(obj) +% isempty True if obj has no elements OR (scalar case) obj.dat is empty. + +n = builtin('numel', obj); + +if n == 0 + tf = true; + return +end + +if n > 1 + tf = false(builtin('size', obj)); + for i = 1:n + tf(i) = isempty(obj(i).dat); + end + return +end + +tf = isempty(obj.dat); + +end diff --git a/CanlabCore/@rsm/mean.m b/CanlabCore/@rsm/mean.m new file mode 100644 index 00000000..8816602f --- /dev/null +++ b/CanlabCore/@rsm/mean.m @@ -0,0 +1,53 @@ +function obj = mean(obj, varargin) +% mean Average an rsm across the replicate axis (3rd dim). +% +% Usage +% R_avg = mean(R) % nanmean across dim 3 +% R_avg = mean(R, 'fisherz', true) % Fisher-z transform before averaging, untransform after +% R_avg = mean(R, 'omitnan', false) % use mean() instead of nanmean() +% +% Returns a new rsm with size [k x k x 1] and a single-row replicate_table +% (or empty) indicating the aggregation. + +if numel(obj) > 1, error('rsm:mean:nonScalar', 'mean() expects a scalar rsm; use arrayfun for arrays.'); end + +p = inputParser; +p.addParameter('fisherz', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('omitnan', true, @(x) islogical(x) || isnumeric(x)); +p.parse(varargin{:}); + +d = obj.dat; +if size(d, 3) <= 1, return; end % nothing to average + +use_fz = logical(p.Results.fisherz) && ~obj.is_dissimilarity && ... + ismember(lower(obj.metric), {'correlation','spearman','cosine'}); + +if use_fz + d_clip = d; + d_clip(d_clip > 0.9999999) = 0.9999999; + d_clip(d_clip < -0.9999999) = -0.9999999; + d = atanh(d_clip); +end + +if p.Results.omitnan + m = mean(d, 3, 'omitnan'); +else + m = mean(d, 3); +end + +if use_fz + m = tanh(m); +end + +obj.dat = m; + +if ~isempty(obj.replicate_table) + % Collapse the replicate table to a single row describing the aggregation + obj.replicate_table = table({'mean'}, 'VariableNames', {'aggregation'}); +end + +obj.level = 'collapsed'; +obj.history{end+1} = sprintf('%s: mean across replicate axis (fisherz=%d)', ... + datestr(now, 'yyyy-mm-dd HH:MM:SS'), use_fz); + +end diff --git a/CanlabCore/@rsm/plot.m b/CanlabCore/@rsm/plot.m new file mode 100644 index 00000000..60c9a8f3 --- /dev/null +++ b/CanlabCore/@rsm/plot.m @@ -0,0 +1,560 @@ +function h = plot(obj, varargin) +% plot Visualize an rsm. +% +% Modes +% plot(R) % raw heatmap (default) — actual data values +% plot(R, 'mode','rank') % rank-transformed heatmap (Kriegeskorte style) +% plot(R, 'mode','mds') % 2D MDS scatter of conditions +% plot(R, 'mode','dendrogram') % hierarchical clustering on rows +% plot(R, 'mode','grid') % subplot grid, one per replicate +% +% plot(R, 'subject', 3) % plot the 3rd slice along dim 3 +% plot(R, 'matched_pairs', P) % overlay white rectangles around (i,j) pairs +% % (P must have indices in 1..k; out-of-range warns + drops) +% plot(R, 'block_borders_by', 'bodysite') % borders around metadata blocks +% plot(R, 'border_color', 'white') % 'white' (default), 'black', 'auto', RGB, or Nx3 +% plot(R, 'climits', [-1 1]) % override data-driven color limits +% plot(R, 'colormap', cmap) % override default colormap +% plot(R, 'title', 'My RSM') % override title +% +% Default behavior (mode='heatmap') +% - RSMs (similarity matrices, signed values): diverging blue-white-red +% colormap (colormap_tor) with symmetric color limits [-max|x|, +max|x|]. +% - RDMs (dissimilarity matrices, non-negative values): sequential parula +% colormap with limits [0, max(x)]. +% +% Returns a struct of handles (cb = colorbar, ax = axes, ...). + +if builtin('numel', obj) > 1 + % Array of rsm objects (e.g., parcelwise output). Auto-grid each entry + % into subplots. Caps at 16 entries to keep things readable; for larger + % arrays the user should slice or use get_by_label / select. + h = plot_array(obj, varargin{:}); + return +end + +p = inputParser; +p.KeepUnmatched = true; +p.addParameter('mode', 'heatmap', @(x) ischar(x) || isstring(x)); +p.addParameter('subject', [], @(x) isempty(x) || (isnumeric(x) && isscalar(x))); +p.addParameter('matched_pairs', [], @(x) isempty(x) || (isnumeric(x) && size(x,2)==2)); +p.addParameter('block_borders_by', '', @(x) ischar(x) || isstring(x)); +p.addParameter('border_color', 'white', @(x) ischar(x) || isstring(x) || (isnumeric(x) && (numel(x)==3 || size(x,2)==3))); +p.addParameter('border_width', 2.5, @isnumeric); +p.addParameter('climits', [], @(x) isempty(x) || (isnumeric(x) && numel(x)==2)); +p.addParameter('colormap', [], @(x) isempty(x) || (isnumeric(x) && size(x,2)==3) || ischar(x) || isstring(x)); +p.addParameter('title', '', @(x) ischar(x) || isstring(x)); +p.addParameter('label_fontsize', 10, @isnumeric); +p.addParameter('rotate_xticks', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('mds_engine', 'builtin', @(x) ischar(x) || isstring(x)); +p.parse(varargin{:}); + +mode = lower(char(p.Results.mode)); + +switch mode + case 'grid' + h = plot_grid(obj, p.Results); + case 'mds' + h = plot_mds(obj, p.Results); + case 'dendrogram' + h = plot_dendrogram(obj, p.Results); + case 'heatmap' + h = plot_heatmap(obj, p.Results, 'raw'); + case 'raw' % legacy alias + h = plot_heatmap(obj, p.Results, 'raw'); + case 'rank' + h = plot_heatmap(obj, p.Results, 'rank'); + otherwise + error('rsm:plot:badMode', ['Unknown mode: %s. ', ... + 'Valid: heatmap (default), rank, mds, dendrogram, grid.'], mode); +end + +end + + +% ========================================================================= +function h = plot_array(obj, varargin) + +n = builtin('numel', obj); +max_show = 16; + +if n > max_show + warning('rsm:plot:tooManyArrayEntries', ... + ['plot() on an %d-element rsm array shows the first %d only. ', ... + 'Use obj.get_by_label(name), obj(idx), or arrayfun(@(R) plot(R), obj) for full control.'], ... + n, max_show); + n_show = max_show; +else + n_show = n; +end + +ncols = ceil(sqrt(n_show)); nrows = ceil(n_show / ncols); +h.fig = gcf; +h.subaxes = gobjects(n_show, 1); + +for i = 1:n_show + h.subaxes(i) = subplot(nrows, ncols, i); + % Pull each rsm's source as the subplot title + plot(obj(i), 'title', short_source(obj(i)), varargin{:}); +end + +end + + +function s = short_source(R) +% Pick a concise title from an rsm's source field +s = char(R.source); +if startsWith(s, 'parcel:'), s = s(8:end); end % strip prefix +if numel(s) > 28, s = [s(1:25) '...']; end +% Use CanlabCore's label prettifier when available +if exist('format_strings_for_legend', 'file') == 2 + try, s = format_strings_for_legend({s}); s = s{1}; catch, end +end +end + + +function pretty = prettify_labels(labels) +% Run labels through format_strings_for_legend (CanlabCore utility) so +% underscores render as readable subscripts in MATLAB plot ticks. Falls +% back to raw labels if the function isn't on the path. + +if exist('format_strings_for_legend', 'file') ~= 2 + pretty = labels; + return +end +try + pretty = format_strings_for_legend(labels); +catch + pretty = labels; +end +end + + +% ========================================================================= +function h = plot_heatmap(obj, opts, render_mode) +% render_mode 'raw' -- show actual data values (default) +% 'rank' -- rank-transformed for Kriegeskorte-style display + +% Slice or aggregate down to a 2D matrix +M = collapse_to_2d(obj, opts.subject); + +caps = probe_rsatoolbox(); + +is_rank = strcmp(render_mode, 'rank'); + +if is_rank + if caps.rdm_rankTransform && caps.rdm_squareRDM + try + if obj.is_dissimilarity + M_disp = rankTransform_safe(M, caps); + else + rdm = 1 - M; + M_disp = rankTransform_safe(rdm, caps); + end + catch + M_disp = local_rank_transform(M); + end + else + M_disp = local_rank_transform(M); + end + if isempty(opts.climits), clim = [0 1]; else, clim = opts.climits; end +else + % Raw mode -- show actual values; auto-pick climits and colormap + M_disp = M; + [clim, default_cmap_kind] = autoscale_for_raw(M_disp, obj.is_dissimilarity, opts.climits); +end + +% Render +h.fig = gcf; +h.ax = gca; +h.im = imagesc(M_disp, clim); +axis image; +h.cb = colorbar; + +% Colormap selection +if ~isempty(opts.colormap) + if isnumeric(opts.colormap) + cmap = opts.colormap; + else + cmap = feval(char(opts.colormap), 256); + end +elseif is_rank + % Rank display uses sequential colormap + if caps.util_RDMcolormap + try, cmap = RDMcolormap; catch, cmap = parula(256); end + else + cmap = parula(256); + end +else + % Raw display: diverging for signed (RSM), sequential for non-negative (RDM) + cmap = pick_default_cmap(default_cmap_kind); +end +colormap(h.ax, cmap); + +% Labels +k = size(M_disp, 1); +if ~isempty(obj.labels) + pretty_labels = prettify_labels(obj.labels); + set(h.ax, 'XTick', 1:k, 'XTickLabel', pretty_labels, ... + 'YTick', 1:k, 'YTickLabel', pretty_labels, ... + 'FontSize', opts.label_fontsize); + if opts.rotate_xticks, set(h.ax, 'XTickLabelRotation', 45); end +end + +% Title +ttl = char(opts.title); +if isempty(ttl) + kind = 'RSM'; if obj.is_dissimilarity, kind = 'RDM'; end + ttl = sprintf('%s (%s%s)', kind, obj.metric, ... + cond_subtitle(obj, opts.subject)); +end +title(h.ax, ttl, 'Interpreter', 'none'); + +% Optional overlays +if ~isempty(opts.matched_pairs) + P = opts.matched_pairs; + % Validate: drop any out-of-range pairs with a warning + k = size(M_disp, 1); + bad = any(P < 1, 2) | any(P > k, 2); + if any(bad) + warning('rsm:plot:matchedPairsOutOfRange', ... + ['matched_pairs contained %d pair(s) with indices outside 1..%d ', ... + '(rsm is %dx%d); those will be dropped.'], sum(bad), k, k, k); + P = P(~bad, :); + end + if ~isempty(P) + hold(h.ax, 'on'); + for ii = 1:size(P, 1) + r = P(ii, 1); c = P(ii, 2); + rectangle('Position', [c-0.5, r-0.5, 1, 1], 'EdgeColor', 'w', 'LineWidth', 2); + rectangle('Position', [r-0.5, c-0.5, 1, 1], 'EdgeColor', 'w', 'LineWidth', 2); + end + hold(h.ax, 'off'); + end +end + +if ~isempty(opts.block_borders_by) + overlay_block_borders(h.ax, obj, char(opts.block_borders_by), ... + opts.border_color, opts.border_width); +end + +end + + +% ========================================================================= +function h = plot_grid(obj, opts) %#ok + +if size(obj.dat, 3) <= 1 + error('rsm:plot:gridNonReplicate', 'mode=''grid'' requires a 3D rsm (size(dat,3) > 1).'); +end + +N = size(obj.dat, 3); +ncols = ceil(sqrt(N)); nrows = ceil(N / ncols); +h.fig = gcf; +h.subaxes = gobjects(N, 1); + +for n = 1:N + h.subaxes(n) = subplot(nrows, ncols, n); + slice = obj.dat(:, :, n); + slice_obj = rsm(slice, 'metric', obj.metric, 'labels', obj.labels, ... + 'is_dissimilarity', obj.is_dissimilarity, ... + 'groupings', obj.groupings); + % Recurse on the single slice + plot(slice_obj, 'mode', 'heatmap', 'title', replicate_title(obj, n)); +end + +end + + +% ========================================================================= +function h = plot_mds(obj, opts) + +M = collapse_to_2d(obj, []); +if ~obj.is_dissimilarity, M = 1 - M; end + +% Engine: 'builtin' (default) gives a clean labeled cmdscale scatter that +% reads well at any condition count. 'rsatoolbox' routes through +% rsa.fig.MDSConditions when available (its multi-panel display is busier, +% especially for few conditions). +engine = 'builtin'; +if isfield(opts, 'mds_engine') && ~isempty(opts.mds_engine) + engine = lower(char(opts.mds_engine)); +end + +if strcmp(engine, 'rsatoolbox') + caps = probe_rsatoolbox(); + if caps.fig_MDSConditions && ~isempty(which('rsa.fig.MDSConditions')) + try + rsa.fig.MDSConditions(struct('RDM', M, 'name', 'rsm'), struct('saveFiguresFig', false)); + h.fig = gcf; + return + catch + % fall through to builtin + end + end +end + +% Built-in classical MDS via cmdscale (default) +M = (M + M') / 2; +M(1:size(M,1)+1:end) = 0; +[Y, e] = cmdscale(M); +if size(Y, 2) < 2, Y = [Y, zeros(size(Y,1), 2 - size(Y,2))]; end +Y = Y(:, 1:2); + +h.fig = gcf; +h.ax = gca; +h.scatter = scatter(Y(:,1), Y(:,2), 90, 'filled'); +hold(h.ax, 'on'); +if ~isempty(obj.labels) + pretty = prettify_labels(obj.labels); + for i = 1:size(Y, 1) + text(Y(i,1), Y(i,2), [' ' pretty{i}], 'FontSize', 10, 'Interpreter', 'none'); + end +end +hold(h.ax, 'off'); +axis equal; grid on; +xlabel(h.ax, 'MDS dim 1'); ylabel(h.ax, 'MDS dim 2'); +if numel(e) >= 2 && sum(abs(e)) > 0 + var2d = 100 * sum(e(1:2)) / sum(abs(e)); + title(h.ax, sprintf('MDS (%s, %.0f%% in 2D)', obj.metric, var2d), 'Interpreter', 'none'); +else + title(h.ax, sprintf('MDS (%s)', obj.metric), 'Interpreter', 'none'); +end + +end + + +% ========================================================================= +function h = plot_dendrogram(obj, opts) %#ok + +M = collapse_to_2d(obj, []); +if ~obj.is_dissimilarity, M = 1 - M; end + +% squareform expects a symmetric matrix with zeros on the diagonal +M_clean = M; +k = size(M_clean, 1); +M_clean(1:k+1:end) = 0; +% Symmetrize to remove any tiny asymmetry +M_clean = (M_clean + M_clean') / 2; +% Floor at 0 in case rounding gave tiny negatives +M_clean(M_clean < 0) = 0; + +d = squareform(M_clean, 'tovector'); +Z = linkage(d, 'average'); + +h.fig = gcf; h.ax = gca; +if ~isempty(obj.labels) + [h.handle, ~, h.order] = dendrogram(Z, 0, 'Labels', obj.labels); +else + [h.handle, ~, h.order] = dendrogram(Z, 0); +end +title(h.ax, sprintf('Dendrogram (%s, average linkage)', obj.metric), 'Interpreter', 'none'); + +end + + +% ========================================================================= +function M = collapse_to_2d(obj, subject_idx) +% Pick a 2D slice from a possibly-3D rsm. + +if size(obj.dat, 3) == 1 + M = obj.dat; +elseif ~isempty(subject_idx) + M = obj.dat(:, :, subject_idx); +else + M = mean(obj.dat, 3, 'omitnan'); +end + +end + + +function s = cond_subtitle(obj, subject_idx) +if size(obj.dat, 3) == 1 + s = ''; +elseif ~isempty(subject_idx) + s = sprintf(', slice %d', subject_idx); +else + s = ', mean across replicates'; +end +end + + +function s = replicate_title(obj, n) +if ~isempty(obj.replicate_table) && n <= height(obj.replicate_table) + parts = {}; + for v = obj.replicate_table.Properties.VariableNames + val = obj.replicate_table.(v{1})(n); + parts{end+1} = sprintf('%s=%s', v{1}, char(string(val))); %#ok + end + s = strjoin(parts, ', '); +else + s = sprintf('replicate %d', n); +end +end + + +function [clim, cmap_kind] = autoscale_for_raw(M, is_dissim, user_clim) +% Data-driven color limits + colormap kind for raw heatmap display. +% +% cmap_kind = 'diverging' -> blue-white-red (signed values, RSMs) +% 'sequential' -> parula (non-negative values, RDMs / distances) + +vals = M(:); +vals = vals(isfinite(vals)); + +if isempty(vals) + clim = [0 1]; cmap_kind = 'sequential'; return +end + +if ~isempty(user_clim) + clim = user_clim; + % Choose colormap from the requested limits + if clim(1) < 0 && clim(2) > 0, cmap_kind = 'diverging'; + else, cmap_kind = 'sequential'; + end + return +end + +vmin = min(vals); vmax = max(vals); + +if is_dissim || vmin >= -1e-9 + % Non-negative distances or essentially-non-negative RSM + clim = [max(0, vmin), vmax]; + if clim(1) == clim(2), clim(2) = clim(1) + 1; end + cmap_kind = 'sequential'; +else + % Signed values -> symmetric limits + amax = max(abs([vmin vmax])); + clim = [-amax, amax]; + cmap_kind = 'diverging'; +end + +end + + +function cmap = pick_default_cmap(kind) +% Default colormap selector. For 'diverging' tries colormap_tor (CanlabCore +% blue-white-red) and falls back to a built-in blue-white-red. + +switch kind + case 'diverging' + if exist('colormap_tor', 'file') == 2 + try + cmap = colormap_tor([0 0 1], [1 0 0], [1 1 1]); + if size(cmap, 1) < 64, cmap = interp_cmap(cmap, 256); end + return + catch + % fall through + end + end + % Stock fallback: 3-stop bwr + n = 256; + half = floor(n/2); + b2w = [linspace(0,1,half)', linspace(0,1,half)', ones(half,1)]; + w2r = [ones(n-half,1), linspace(1,0,n-half)', linspace(1,0,n-half)']; + cmap = [b2w; w2r]; + case 'sequential' + cmap = parula(256); + otherwise + cmap = parula(256); +end + +end + + +function cmap_out = interp_cmap(cmap_in, n_out) +% Linearly interpolate a colormap to n_out rows. +n_in = size(cmap_in, 1); +xi = linspace(1, n_in, n_out); +cmap_out = zeros(n_out, 3); +for c = 1:3, cmap_out(:, c) = interp1(1:n_in, cmap_in(:, c), xi)'; end +end + + +function M_t = rankTransform_safe(M, caps) +if ~isempty(which('rsa.rdm.rankTransform')) && ~isempty(which('rsa.rdm.squareRDM')) + M_t = rsa.rdm.rankTransform(rsa.rdm.squareRDM(M), 1); +elseif caps.rdm_rankTransform && caps.rdm_squareRDM + M_t = rankTransform(squareRDM(M), 1); +else + M_t = local_rank_transform(M); +end +n = size(M_t, 1); +M_t(1:n+1:end) = 1; +end + + +function overlay_block_borders(ax, obj, col, border_color, border_width) +% Draw rectangles around contiguous blocks of equal values in +% obj.metadata_table.(col). +% +% border_color +% 'white' / 'black' (default 'white') -- single contrasting color +% 'auto' -- per-block colors from lines() (legacy behavior; can clash with heatmap) +% [r g b] -- single RGB triplet for all blocks +% [N x 3] matrix -- per-block RGB rows (N == number of blocks) +% +% border_width scalar line width (default 2.5). + +if isempty(obj.metadata_table) || ~ismember(col, obj.metadata_table.Properties.VariableNames) + warning('rsm:plot:blockBordersMissing', ... + 'metadata_table does not contain column "%s"; skipping block borders.', col); + return +end + +if nargin < 4 || isempty(border_color), border_color = 'white'; end +if nargin < 5 || isempty(border_width), border_width = 2.5; end + +v = obj.metadata_table.(col); +[grp, ~] = findgroups(v); +n_blocks = max(grp); + +cmap = resolve_border_cmap(border_color, n_blocks); + +hold(ax, 'on'); +for g = 1:n_blocks + idx = find(grp == g); + if isempty(idx), continue; end + s = min(idx); e = max(idx); + rectangle('Parent', ax, 'Position', [s-0.5, s-0.5, e-s+1, e-s+1], ... + 'EdgeColor', cmap(g, :), 'LineWidth', border_width); +end +hold(ax, 'off'); + +end + + +function cmap = resolve_border_cmap(border_color, n_blocks) +% Returns an [n_blocks x 3] colormap, with the same color repeated when a +% single color is requested. + +if ischar(border_color) || isstring(border_color) + name = lower(char(border_color)); + switch name + case 'white', single = [1 1 1]; + case 'black', single = [0 0 0]; + case 'auto', cmap = lines(n_blocks); return + otherwise + % Try MATLAB color shorthand ('r','g','b',...) + try + single = validatecolor(name); + catch + warning('rsm:plot:badBorderColor', ... + 'Unknown border_color "%s"; falling back to white.', name); + single = [1 1 1]; + end + end + cmap = repmat(single, n_blocks, 1); +elseif isnumeric(border_color) + if numel(border_color) == 3 + cmap = repmat(border_color(:)', n_blocks, 1); + elseif size(border_color, 2) == 3 && size(border_color, 1) >= n_blocks + cmap = border_color(1:n_blocks, :); + else + warning('rsm:plot:badBorderColor', ... + 'Numeric border_color must be a 1x3 RGB or Nx3 matrix; using white.'); + cmap = repmat([1 1 1], n_blocks, 1); + end +end + +end diff --git a/CanlabCore/@rsm/private/detect_subject_column.m b/CanlabCore/@rsm/private/detect_subject_column.m new file mode 100644 index 00000000..afe18c9c --- /dev/null +++ b/CanlabCore/@rsm/private/detect_subject_column.m @@ -0,0 +1,27 @@ +function col = detect_subject_column(replicate_table) +% detect_subject_column Find the subject-id column in an rsm.replicate_table. +% +% Returns the first matching column name (case-insensitive) from this priority +% list, or '' if none match: +% subject_id, sub, subject, subjectid, subj, sid +% +% Used by the reliability methods so per-subject ICC is the default whenever +% a subject axis is present in the replicate stack. + +col = ''; +if isempty(replicate_table) || ~istable(replicate_table) + return +end + +candidates = {'subject_id','sub','subject','subjectid','subj','sid'}; +vars = replicate_table.Properties.VariableNames; +vars_lower = lower(vars); +for i = 1:numel(candidates) + hit = find(strcmp(vars_lower, candidates{i}), 1, 'first'); + if ~isempty(hit) + col = vars{hit}; + return + end +end + +end diff --git a/CanlabCore/@rsm/private/local_rank_transform.m b/CanlabCore/@rsm/private/local_rank_transform.m new file mode 100644 index 00000000..074785a2 --- /dev/null +++ b/CanlabCore/@rsm/private/local_rank_transform.m @@ -0,0 +1,42 @@ +function R = local_rank_transform(M) +% local_rank_transform Fallback for rsa.rdm.rankTransform when rsatoolbox absent. +% +% Rank-transforms an RDM/RSM into [0, 1]: +% - Pulls the upper triangle (excluding diagonal). +% - tiedrank() those values. +% - Scale to [0, 1] via (rank - 1) / (n_pairs - 1). +% - Re-symmetrize and set diagonal to 1. +% +% Matches the visual output of the Kriegeskorte rsatoolbox's +% rsa.rdm.rankTransform when used for heatmap display. +% +% Input +% M [k x k] numeric, symmetric (NaN diagonal allowed) +% +% Output +% R [k x k] numeric in [0, 1] + +if isempty(M), R = M; return; end + +k = size(M, 1); +mask = triu(true(k), 1); +vals = M(mask); + +% Exclude NaN values from ranking but keep their positions +nan_pos = isnan(vals); +ranks = nan(size(vals)); +finite_vals = vals(~nan_pos); +if numel(finite_vals) > 1 + r = tiedrank(finite_vals); + r = (r - 1) ./ (numel(finite_vals) - 1); + ranks(~nan_pos) = r; +elseif ~isempty(finite_vals) + ranks(~nan_pos) = 0.5; +end + +R = zeros(k); +R(mask) = ranks; +R = R + R'; +R(1:k+1:end) = 1; % diagonal = 1 (matches workflow showRSM convention) + +end diff --git a/CanlabCore/@rsm/private/probe_rsatoolbox.m b/CanlabCore/@rsm/private/probe_rsatoolbox.m new file mode 100644 index 00000000..c1c89dd5 --- /dev/null +++ b/CanlabCore/@rsm/private/probe_rsatoolbox.m @@ -0,0 +1,39 @@ +function caps = probe_rsatoolbox() +% probe_rsatoolbox Detect which Kriegeskorte rsatoolbox functions are available. +% +% Returns a struct of booleans for each capability the @rsm class may +% optionally use. Methods that want to take advantage of rsatoolbox features +% should call this once and branch on the returned struct. +% +% Output fields: +% .rdm_rankTransform rsa.rdm.rankTransform / rankTransform +% .rdm_squareRDM rsa.rdm.squareRDM / squareRDM +% .rdm_categoricalRDM rsa.rdm.categoricalRDM +% .util_RDMcolormap rsa.util.RDMcolormap / RDMcolormap +% .fig_MDSConditions rsa.fig.MDSConditions / MDSConditions +% .fig_dendrogramConditions rsa.fig.dendrogramConditions +% .stat_covdiag rsa.stat.covdiag +% .compareRefRDM2candRDMs rsa.compareRefRDM2candRDMs +% .bootstrapRDMs rsa.bootstrapRDMs +% .any true if any of the above were found +% +% Implementation note: tries `which` for both packaged and bare-function +% forms. The rsatoolbox package layout uses `rsa.rdm.rankTransform`, but +% adding the toolbox via `addpath(genpath(...))` exposes the underlying .m +% files at the top level (`rankTransform.m`) too. + +caps = struct(); + +caps.rdm_rankTransform = ~isempty(which('rsa.rdm.rankTransform')) || ~isempty(which('rankTransform')); +caps.rdm_squareRDM = ~isempty(which('rsa.rdm.squareRDM')) || ~isempty(which('squareRDM')); +caps.rdm_categoricalRDM = ~isempty(which('rsa.rdm.categoricalRDM'))|| ~isempty(which('categoricalRDM')); +caps.util_RDMcolormap = ~isempty(which('rsa.util.RDMcolormap')) || ~isempty(which('RDMcolormap')); +caps.fig_MDSConditions = ~isempty(which('rsa.fig.MDSConditions'))|| ~isempty(which('MDSConditions')); +caps.fig_dendrogramConditions = ~isempty(which('rsa.fig.dendrogramConditions')) || ~isempty(which('dendrogramConditions')); +caps.stat_covdiag = ~isempty(which('rsa.stat.covdiag')) || ~isempty(which('covdiag')); +caps.compareRefRDM2candRDMs = ~isempty(which('rsa.compareRefRDM2candRDMs')) || ~isempty(which('compareRefRDM2candRDMs')); +caps.bootstrapRDMs = ~isempty(which('rsa.bootstrapRDMs')) || ~isempty(which('bootstrapRDMs')); + +caps.any = any(structfun(@(x) x, caps)); + +end diff --git a/CanlabCore/@rsm/private/rsm_group_stats.m b/CanlabCore/@rsm/private/rsm_group_stats.m new file mode 100644 index 00000000..dc669609 --- /dev/null +++ b/CanlabCore/@rsm/private/rsm_group_stats.m @@ -0,0 +1,59 @@ +function G = rsm_group_stats(V, correction, nperm) +% rsm_group_stats One-sample group stats over subjects for RSA count methods. +% +% Thin wrapper used by @rsm/count_map, count_models, count_regions to get a +% group effect + p-value for each cell/model/region across subjects. When the +% shared permutation engine hrf_group_stats (HRF_Toolbox_Pipeline) is on the +% path it is used, so RSA inference matches the rest of the CANlab HRF API; +% otherwise a built-in one-sample t (with FDR/Bonferroni) is used, keeping the +% RSA toolbox self-contained. +% +% :Inputs: +% **V:** [nCells x N] (rows = cells/models/regions, cols = subjects) or a +% [1 x N] vector for a single cell. +% **correction:** 'fdr' (default) | 'permutation' | 'bonferroni' | 'none'. +% **nperm:** permutations when hrf_group_stats does the work. Default 5000. +% +% :Output: +% **G:** struct with .est, .t, .p, .p_corr, .sig (one row per cell). + +if nargin < 2 || isempty(correction), correction = 'fdr'; end +if nargin < 3 || isempty(nperm), nperm = 5000; end +V = double(V); +if isvector(V), V = V(:)'; end + +if exist('hrf_group_stats', 'file') == 2 + G = hrf_group_stats(V, 'Correction', correction, 'Nperm', nperm); + return +end + +% ---- built-in fallback: one-sample t across subjects (cols) ------------- +[nc, N] = size(V); +est = mean(V, 2, 'omitnan'); +se = std(V, 0, 2, 'omitnan') / sqrt(max(N, 1)); +t = est ./ se; t(se == 0) = 0; +p = 2 * (1 - local_tcdf(abs(t), N - 1)); +switch lower(char(correction)) + case 'none', pc = p; + case 'bonferroni', pc = min(p * nc, 1); + otherwise, pc = local_fdr_bh(p); % 'fdr' (and 'permutation' w/o engine) +end +G = struct('est', est, 't', t, 'p', p, 'p_corr', pc, 'sig', pc < 0.05); +end + + +function pcdf = local_tcdf(tval, df) +if df <= 0, pcdf = 0.5 * ones(size(tval)); return; end +x = df ./ (df + tval .^ 2); +pcdf = 1 - 0.5 * betainc(x, df / 2, 0.5); +end + + +function q = local_fdr_bh(p) +% Benjamini-Hochberg adjusted p-values. +p = p(:); m = numel(p); +[ps, ord] = sort(p); +q = ps .* m ./ (1:m)'; +q = min(1, flipud(cummin(flipud(q)))); +out = nan(m, 1); out(ord) = q; q = out; +end diff --git a/CanlabCore/@rsm/private/validate_metadata.m b/CanlabCore/@rsm/private/validate_metadata.m new file mode 100644 index 00000000..4dc4076a --- /dev/null +++ b/CanlabCore/@rsm/private/validate_metadata.m @@ -0,0 +1,55 @@ +function validate_metadata(obj) +% validate_metadata Check internal consistency of an rsm's metadata. +% +% Throws an error if: +% - .labels length disagrees with size(.dat, 1) +% - .metadata_table row count disagrees with size(.dat, 1) +% - .groupings contains indices outside 1:k +% - .replicate_table row count disagrees with size(.dat, 3) +% - .dat is non-square in first two dims when non-empty + +if isempty(obj.dat), return; end + +k = size(obj.dat, 1); + +if size(obj.dat, 2) ~= k + error('rsm:notSquare', ... + 'rsm.dat must be square in the first two dimensions; got [%d x %d].', ... + size(obj.dat,1), size(obj.dat,2)); +end + +if ~isempty(obj.labels) && numel(obj.labels) ~= k + error('rsm:labelMismatch', ... + 'numel(labels) = %d but size(dat,1) = %d.', numel(obj.labels), k); +end + +if ~isempty(obj.metadata_table) && height(obj.metadata_table) ~= k + error('rsm:metadataMismatch', ... + 'height(metadata_table) = %d but size(dat,1) = %d.', ... + height(obj.metadata_table), k); +end + +if ~isempty(obj.groupings) && isstruct(obj.groupings) && ~isempty(fieldnames(obj.groupings)) + fn = fieldnames(obj.groupings); + for i = 1:numel(fn) + idx = obj.groupings.(fn{i}); + if isempty(idx), continue; end + if ~isnumeric(idx) && ~islogical(idx) + error('rsm:groupingBadType', ... + 'groupings.%s must be numeric or logical indices.', fn{i}); + end + if any(idx(:) < 1) || any(idx(:) > k) + error('rsm:groupingOutOfRange', ... + 'groupings.%s contains indices outside 1:%d.', fn{i}, k); + end + end +end + +N = size(obj.dat, 3); +if N > 1 && ~isempty(obj.replicate_table) && height(obj.replicate_table) ~= N + error('rsm:replicateMismatch', ... + 'height(replicate_table) = %d but size(dat,3) = %d.', ... + height(obj.replicate_table), N); +end + +end diff --git a/CanlabCore/@rsm/reliability.m b/CanlabCore/@rsm/reliability.m new file mode 100644 index 00000000..5034c2fb --- /dev/null +++ b/CanlabCore/@rsm/reliability.m @@ -0,0 +1,298 @@ +function out = reliability(obj, varargin) +% reliability ICC reliability of an rsm across replicates. +% +% Computes ICC (Shrout & Fleiss, 1979) on the vectorized upper triangle of +% the rsm across the replicate axis (3rd dim). The "items" are RSM cells +% (n_cells = k*(k-1)/2) and the "raters" are replicates (sessions, runs, +% or subjects, depending on R.level). +% +% Usage +% ----- +% % Single ICC across all replicates of the rsm +% icc = R.reliability() +% icc = R.reliability('icc_type','2-k') +% +% % Per-subject ICC: compute ICC over each subject's replicates +% T = R.reliability('by','subject_id') +% +% Optional name-value +% ------------------- +% 'icc_type' '3-k' (default) | '2-k' | '3-single' | '2-single' | '1-k' | '1-single' +% Matches CanlabCore's ICC.m (case, type) syntax: +% '3-k' -> ICC(3,'k'), two-way mixed, average measures +% '2-k' -> ICC(2,'k'), two-way random, average measures +% Average-measures variants use the average of the k replicates +% and are typically what you want for RSM reliability across +% replicates. +% +% 'by' '' (default) or name of a replicate_table column for +% per-group reliability (e.g. 'subject_id'). Returns a table +% with one row per group. +% +% 'pool' 'auto' (default) | 'subject' | 'replicate' +% How to aggregate ICC across the replicate axis when 'by' is +% not set. +% 'auto' -> 'subject' if a subject column exists in +% replicate_table (subject_id / sub / etc.), +% else 'replicate'. +% 'subject' -> compute ICC PER SUBJECT across that subject's +% replicates (matches Sun et al. 01282025 paper +% recipe). Returns a struct with .summary +% (mean/median/std/n_subjects across subjects) +% and .per_subject (table). +% 'replicate'-> pool all replicates as raters of the same +% items. Returns a scalar ICC. Conflates within +% and between subject variability -- use only +% when that's what you want. +% +% 'transform' 'auto' (default) | 'fisherz' | 'none' +% 'auto' applies atanh for correlation/spearman/cosine RSMs. +% +% Output +% ------ +% If 'by' is set: table {group_var, ICC, n_replicates, n_cells}. +% If pool='subject' (auto when subject col exists): struct with .summary +% (mean/median/std/n_subjects) and .per_subject +% table. +% If pool='replicate': scalar ICC. + +if builtin('numel', obj) > 1 + error('rsm:reliability:nonScalar', 'expects a scalar rsm.'); +end + +p = inputParser; +p.addParameter('icc_type', '3-k', @(x) ischar(x) || isstring(x)); +p.addParameter('by', '', @(x) ischar(x) || isstring(x)); +p.addParameter('transform', 'auto', @(x) ischar(x) || isstring(x)); +p.addParameter('replicate_nan_frac', 0.5, @(x) isnumeric(x) && isscalar(x) && x >= 0 && x <= 1); +p.addParameter('pool', 'auto', @(x) (ischar(x) || isstring(x)) && ismember(lower(char(x)), {'auto','subject','replicate'})); +p.parse(varargin{:}); +opt = p.Results; + +% Resolve pool: 'auto' -> 'subject' if a subject column exists, else 'replicate'. +% Explicit 'by' overrides everything (it acts like per-group). +pool_mode = lower(char(opt.pool)); +sub_col = ''; +if isempty(opt.by) && ~strcmp(pool_mode, 'replicate') + sub_col = detect_subject_column(obj.replicate_table); + if isempty(sub_col) && strcmp(pool_mode, 'subject') + warning('rsm:reliability:noSubjectColumn', ... + ['pool=''subject'' requested but no subject column found in replicate_table. ', ... + 'Falling back to replicate-pooled ICC.']); + pool_mode = 'replicate'; + elseif ~isempty(sub_col) && strcmp(pool_mode, 'auto') + pool_mode = 'subject'; + else + pool_mode = 'replicate'; + end +elseif ~isempty(opt.by) + pool_mode = 'by'; % explicit per-group path -- skip auto logic +end + +[case_id, type_id] = parse_icc_type(opt.icc_type); + +% Vectorize each replicate's upper triangle +[k, ~, N] = size(obj.dat); +if N < 2 + error('rsm:reliability:tooFewReplicates', ... + 'Need at least 2 replicates (size(R,3) >= 2); got %d.', N); +end + +upper = triu(true(k), 1); +n_cells = nnz(upper); +X = zeros(N, n_cells); +for n = 1:N + slice = obj.dat(:, :, n); + X(n, :) = slice(upper)'; +end + +X = apply_transform(X, opt.transform, obj); + +if strcmp(pool_mode, 'replicate') + % Pool across all replicates (raters = all replicates) + out = compute_icc(X, case_id, type_id, opt.replicate_nan_frac); + return +end + +if strcmp(pool_mode, 'subject') + % Per-subject ICC, then aggregate across subjects + [G, subject_ids] = findgroups(obj.replicate_table.(sub_col)); + n_subj = max(G); + icc_per_subj = nan(n_subj, 1); + n_reps_per_subj = zeros(n_subj, 1); + for g = 1:n_subj + rows = find(G == g); + n_reps_per_subj(g) = numel(rows); + if numel(rows) < 2, continue; end + icc_per_subj(g) = compute_icc(X(rows, :), case_id, type_id, opt.replicate_nan_frac); + end + % Return a struct with both per-subject and summary stats + out = struct(); + out.pool = 'subject'; + out.summary = struct(... + 'mean', mean(icc_per_subj, 'omitnan'), ... + 'median', median(icc_per_subj, 'omitnan'), ... + 'std', std(icc_per_subj, 'omitnan'), ... + 'n_subjects', sum(~isnan(icc_per_subj))); + out.per_subject = table(subject_ids, icc_per_subj, n_reps_per_subj, ... + repmat(n_cells, n_subj, 1), ... + 'VariableNames', {char(sub_col), 'ICC', 'n_replicates', 'n_cells'}); + return +end + +% Per-group ICC +by_col = char(opt.by); +if isempty(obj.replicate_table) || ~ismember(by_col, obj.replicate_table.Properties.VariableNames) + error('rsm:reliability:noGroupCol', ... + 'replicate_table does not contain "%s".', by_col); +end + +groups = obj.replicate_table.(by_col); +[G, group_ids] = findgroups(groups); +n_groups = max(G); +icc_vals = nan(n_groups, 1); +n_reps = zeros(n_groups, 1); +for g = 1:n_groups + rows = find(G == g); + n_reps(g) = numel(rows); + if numel(rows) < 2, continue; end + icc_vals(g) = compute_icc(X(rows, :), case_id, type_id, opt.replicate_nan_frac); +end + +out = table(group_ids, icc_vals, n_reps, repmat(n_cells, n_groups, 1), ... + 'VariableNames', {char(by_col), 'ICC', 'n_replicates', 'n_cells'}); + +end + + +function val = compute_icc(X, case_id, type_id, rep_nan_frac) +% X is [replicates x cells]. +% +% Two-stage NaN handling: +% 1) Drop REPLICATES whose NaN fraction across cells exceeds rep_nan_frac +% (default 0.5). These are typically "bad sessions" with whole missing +% conditions producing NaN rows/cols in the per-replicate RSM. +% 2) Then drop CELLS that still have any NaN across the surviving replicates. +% +% This hybrid order recovers the across-all-replicates case where a minority +% of bad replicates would otherwise poison every cell. On clean data, both +% stages are no-ops and behavior matches the prior implementation. + +if nargin < 4 || isempty(rep_nan_frac), rep_nan_frac = 0.5; end + +% Stage 1: drop bad replicates +nan_frac_per_rep = mean(~isfinite(X), 2); +bad_reps = nan_frac_per_rep > rep_nan_frac; +if any(bad_reps) + warning('rsm:reliability:droppedReplicates', ... + 'Dropped %d/%d replicates with > %.0f%% NaN cells before ICC.', ... + sum(bad_reps), numel(bad_reps), 100 * rep_nan_frac); + X = X(~bad_reps, :); +end +if size(X, 1) < 2 + warning('rsm:reliability:tooFewReplicates', ... + ['After dropping bad replicates, only %d remain. Returning NaN. ', ... + 'Consider raising ''replicate_nan_frac'' or investigating data quality.'], ... + size(X, 1)); + val = NaN; return +end + +% Stage 2: drop NaN-containing cells across surviving replicates +data = X'; % cells x raters (ICC.m convention) +nan_rows = any(~isfinite(data), 2); +if any(nan_rows) + n_drop = sum(nan_rows); + n_keep = size(data, 1) - n_drop; + if n_keep < 2 + warning('rsm:reliability:tooManyNaN', ... + ['After dropping %d NaN-containing cells, only %d cells remain. ', ... + 'Returning NaN.'], n_drop, n_keep); + val = NaN; return + end + data = data(~nan_rows, :); +end + +if exist('ICC', 'file') == 2 + try + val = ICC(case_id, type_id, data); + if ~isnan(val), return; end + catch + % fall through to local + end +end +val = local_icc(data, case_id, type_id); +end + + +function [case_id, type_id] = parse_icc_type(spec) +spec = lower(char(spec)); +parts = strsplit(spec, '-'); +if numel(parts) ~= 2 + error('rsm:reliability:badIccType', ... + 'icc_type must be e.g. ''3-k'', ''2-k'', ''3-single''; got %s.', spec); +end +case_id = str2double(parts{1}); +type_id = parts{2}; +if ~ismember(case_id, [1 2 3]) || ~ismember(type_id, {'k','single'}) + error('rsm:reliability:badIccType', ... + 'icc_type must be {1,2,3}-{single,k}; got %s.', spec); +end +end + + +function X = apply_transform(X, mode, obj) +mode = lower(char(mode)); +if strcmp(mode, 'auto') + if obj.is_dissimilarity || ~ismember(lower(obj.metric), {'correlation','spearman','cosine'}) + mode = 'none'; + else + mode = 'fisherz'; + end +end +switch mode + case 'fisherz' + X(X > 0.9999999) = 0.9999999; + X(X < -0.9999999) = -0.9999999; + X = atanh(X); + case 'none' + % no-op +end +end + + +function val = local_icc(data, case_id, type_id) +% Stock ICC fallback. Matches Shrout & Fleiss formulas for +% (case_id, type_id) in {1,2,3} x {single, k}. +[n, k] = size(data); % n items, k raters +mean_items = mean(data, 2); +mean_raters = mean(data, 1); +grand = mean(data(:)); + +SS_t = sum((data(:) - grand).^2); +SS_b = k * sum((mean_items - grand).^2); % between items +SS_w = SS_t - SS_b; % within items +SS_r = n * sum((mean_raters - grand).^2); % between raters +SS_e = SS_w - SS_r; % residual (two-way) + +MS_b = SS_b / (n - 1); +MS_w = SS_w / (n * (k - 1)); +MS_r = SS_r / (k - 1); +MS_e = SS_e / ((n - 1) * (k - 1)); + +switch case_id + case 1 + if strcmp(type_id, 'single'), val = (MS_b - MS_w) / (MS_b + (k - 1) * MS_w); + else, val = (MS_b - MS_w) / MS_b; + end + case 2 + if strcmp(type_id, 'single') + val = (MS_b - MS_e) / (MS_b + (k - 1) * MS_e + k * (MS_r - MS_e) / n); + else + val = (MS_b - MS_e) / (MS_b + (MS_r - MS_e) / n); + end + case 3 + if strcmp(type_id, 'single'), val = (MS_b - MS_e) / (MS_b + (k - 1) * MS_e); + else, val = (MS_b - MS_e) / MS_b; + end +end +end diff --git a/CanlabCore/@rsm/reliability_by_grouping.m b/CanlabCore/@rsm/reliability_by_grouping.m new file mode 100644 index 00000000..a983fd01 --- /dev/null +++ b/CanlabCore/@rsm/reliability_by_grouping.m @@ -0,0 +1,142 @@ +function T = reliability_by_grouping(obj, varargin) +% reliability_by_grouping ICC reliability for each grouping (e.g. per superordinate condition). +% +% For each grouping defined in R.groupings (or specified explicitly), restricts +% the RSM to that subset of rows/cols and computes ICC across the replicate axis. +% Reproduces the per-condition / per-bodysite loops in `01282025 RSM Reliability`. +% +% Usage +% ----- +% T = R.reliability_by_grouping() % uses R.groupings +% T = R.reliability_by_grouping('groupings',{'hot','warm','imagine'}) +% T = R.reliability_by_grouping('icc_type','2-k', 'by','subject_id') +% +% Optional name-value +% ------------------- +% 'groupings' cellstr of grouping names (default: all in R.groupings) +% 'icc_type' '3-k' (default) | '2-k' | etc. (see reliability()) +% 'by' '' (default) or replicate_table column for per-(group, by) ICC +% 'transform' 'auto' (default) | 'fisherz' | 'none' +% +% Output +% ------ +% T table: +% - if 'by' is empty: {Grouping, ICC, n_replicates, n_cells} +% - if 'by' is set: {Grouping, , ICC, n_replicates, n_cells} + +if builtin('numel', obj) > 1 + error('rsm:reliability_by_grouping:nonScalar', 'expects a scalar rsm.'); +end + +p = inputParser; +p.addParameter('groupings', {}, @(x) iscellstr(x) || isstring(x) || isempty(x)); %#ok +p.addParameter('icc_type', '3-k', @(x) ischar(x) || isstring(x)); +p.addParameter('by', '', @(x) ischar(x) || isstring(x)); +p.addParameter('transform', 'auto', @(x) ischar(x) || isstring(x)); +p.addParameter('replicate_nan_frac', 0.5, @(x) isnumeric(x) && isscalar(x) && x >= 0 && x <= 1); +p.addParameter('pool', 'auto', @(x) (ischar(x) || isstring(x)) && ismember(lower(char(x)), {'auto','subject','replicate'})); +p.parse(varargin{:}); +opt = p.Results; + +% Resolve which groupings to use +gp = cellstr(opt.groupings); +if isempty(gp) + if isempty(obj.groupings) || isempty(fieldnames(obj.groupings)) + error('rsm:reliability_by_grouping:noGroupings', ... + 'R.groupings is empty; pass ''groupings'' explicitly or attach R.groupings first.'); + end + gp = fieldnames(obj.groupings); +end + +n_g = numel(gp); +rows = cell(n_g, 1); + +% Consolidate per-call warnings into one summary at the end. Save current +% warning state, suppress the dropped-replicate spam, then restore. +warn_ids = {'rsm:reliability:droppedReplicates', ... + 'rsm:reliability:tooFewReplicates', ... + 'rsm:reliability:tooManyNaN'}; +saved_states = cell(numel(warn_ids), 1); +for i = 1:numel(warn_ids) + saved_states{i} = warning('query', warn_ids{i}); + warning('off', warn_ids{i}); +end +total_dropped_replicates = 0; +groupings_with_drops = {}; +groupings_with_small_n = {}; + +for i = 1:n_g + sub_R = obj.subset(gp{i}); + if size(sub_R, 1) < 2 + warning('rsm:reliability_by_grouping:singleton', ... + 'Grouping "%s" has fewer than 2 rows; skipping.', gp{i}); + continue + end + + % Track small-n-cells groupings (consolidated warning at the end) + n_cells_this = nchoosek(size(sub_R, 1), 2); + if n_cells_this < 5 + groupings_with_small_n{end+1} = sprintf('%s(%d cells, k=%d)', ... + gp{i}, n_cells_this, size(sub_R, 1)); %#ok + end + + % Track dropped replicates by inspecting NaN content directly (cheap) + [k_sub, ~, N_sub] = size(sub_R.dat); + upper_mask = triu(true(k_sub), 1); + nan_frac = zeros(N_sub, 1); + for n = 1:N_sub + slice = sub_R.dat(:,:,n); + nan_frac(n) = mean(~isfinite(slice(upper_mask))); + end + n_drop = sum(nan_frac > opt.replicate_nan_frac); + if n_drop > 0 + total_dropped_replicates = total_dropped_replicates + n_drop; + groupings_with_drops{end+1} = sprintf('%s(%d/%d)', gp{i}, n_drop, N_sub); %#ok + end + + inner = sub_R.reliability('icc_type', opt.icc_type, 'by', opt.by, ... + 'transform', opt.transform, ... + 'replicate_nan_frac', opt.replicate_nan_frac, ... + 'pool', opt.pool); + + if isstruct(inner) && isfield(inner, 'per_subject') + s = inner.summary; + rows{i} = table({gp{i}}, s.mean, s.median, s.std, s.n_subjects, ... + size(sub_R, 3), n_cells_this, ... + 'VariableNames', {'Grouping', 'Mean_ICC', 'Median_ICC', 'Std_ICC', ... + 'n_subjects', 'n_replicates', 'n_cells'}); + elseif istable(inner) + n_rows = height(inner); + grp_col = repmat({gp{i}}, n_rows, 1); + rows{i} = [table(grp_col, 'VariableNames', {'Grouping'}), inner]; + else + rows{i} = table({gp{i}}, inner, size(sub_R,3), n_cells_this, ... + 'VariableNames', {'Grouping', 'ICC', 'n_replicates', 'n_cells'}); + end +end + +% Restore prior warning states +for i = 1:numel(warn_ids) + warning(saved_states{i}.state, warn_ids{i}); +end + +% Emit consolidated summaries +if ~isempty(groupings_with_small_n) + warning('rsm:reliability_by_grouping:smallNcells', ... + ['%d grouping(s) have <5 upper-triangle cells; ICC estimates are ', ... + 'statistically unreliable and can be unbounded. Affected: %s'], ... + numel(groupings_with_small_n), ... + strjoin(groupings_with_small_n, ', ')); +end +if total_dropped_replicates > 0 + warning('rsm:reliability_by_grouping:droppedReplicates', ... + ['Dropped replicates with > %.0f%% NaN cells across %d grouping(s): %s. ', ... + 'Total dropped (grouping x replicate) = %d. ', ... + 'Raise ''replicate_nan_frac'' to retain more, or use ', ... + '''nan_policy'',''skip_replicate'' at compute_rsm time.'], ... + 100*opt.replicate_nan_frac, numel(groupings_with_drops), ... + strjoin(groupings_with_drops, ', '), total_dropped_replicates); +end + +T = vertcat(rows{:}); +end diff --git a/CanlabCore/@rsm/reliability_per_condition.m b/CanlabCore/@rsm/reliability_per_condition.m new file mode 100644 index 00000000..f11feee7 --- /dev/null +++ b/CanlabCore/@rsm/reliability_per_condition.m @@ -0,0 +1,184 @@ +function T = reliability_per_condition(obj, varargin) +% reliability_per_condition Row-level ICC reliability — one per condition. +% +% For each condition (row of the RSM), pulls that row's relationships with +% all other conditions across replicates and computes ICC across replicates. +% Reproduces the per-individual-condition loop in `01282025 RSM Reliability` +% lines 144-204. +% +% Usage +% ----- +% T = R.reliability_per_condition() +% T = R.reliability_per_condition('icc_type','2-k') +% +% Optional name-value +% ------------------- +% 'icc_type' '3-k' (default) | '2-k' | etc. +% 'by' '' (default) | replicate_table column for per-(condition, by) ICC +% 'transform' 'auto' (default) | 'fisherz' | 'none' +% +% Output +% ------ +% T table with one row per condition: +% {Condition, ICC, n_replicates, n_other_conditions} + +if builtin('numel', obj) > 1 + error('rsm:reliability_per_condition:nonScalar', 'expects a scalar rsm.'); +end + +p = inputParser; +p.addParameter('icc_type', '3-k', @(x) ischar(x) || isstring(x)); +p.addParameter('by', '', @(x) ischar(x) || isstring(x)); +p.addParameter('transform', 'auto', @(x) ischar(x) || isstring(x)); +p.addParameter('replicate_nan_frac', 0.5, @(x) isnumeric(x) && isscalar(x) && x >= 0 && x <= 1); +p.addParameter('pool', 'auto', @(x) (ischar(x) || isstring(x)) && ismember(lower(char(x)), {'auto','subject','replicate'})); +p.parse(varargin{:}); +opt = p.Results; + +% Resolve pool mode (matches reliability.m logic) +pool_mode = lower(char(opt.pool)); +sub_col = ''; +if isempty(opt.by) && ~strcmp(pool_mode, 'replicate') + sub_col = detect_subject_column(obj.replicate_table); + if isempty(sub_col) && strcmp(pool_mode, 'subject') + warning('rsm:reliability_per_condition:noSubjectColumn', ... + 'pool=''subject'' requested but no subject column found; falling back to replicate.'); + pool_mode = 'replicate'; + elseif ~isempty(sub_col) && strcmp(pool_mode, 'auto') + pool_mode = 'subject'; + else + pool_mode = 'replicate'; + end +end + +[case_id, type_id] = parse_icc_type(opt.icc_type); + +[k, ~, N] = size(obj.dat); +if N < 2 + error('rsm:reliability_per_condition:tooFewReplicates', ... + 'Need at least 2 replicates; got %d.', N); +end + +cond_labels = obj.labels; +if isempty(cond_labels) + cond_labels = arrayfun(@(i) sprintf('cond_%d', i), (1:k)', 'UniformOutput', false); +end + +% Pre-build per-condition X_c matrices +X_all = cell(k, 1); +for c = 1:k + other_idx = [1:c-1, c+1:k]; + X_c = zeros(N, k - 1); + for n = 1:N + slice = obj.dat(:, :, n); + X_c(n, :) = slice(c, other_idx); + end + X_all{c} = apply_transform(X_c, opt.transform, obj); +end + +if strcmp(pool_mode, 'replicate') + % Single ICC per condition pooled across all replicates + icc_vals = nan(k, 1); + for c = 1:k + icc_vals(c) = compute_icc(X_all{c}, case_id, type_id, opt.replicate_nan_frac); + end + T = table(cond_labels, icc_vals, repmat(N, k, 1), repmat(k-1, k, 1), ... + 'VariableNames', {'Condition', 'ICC', 'n_replicates', 'n_other_conditions'}); + return +end + +% pool='subject': per-condition × per-subject ICC, then aggregate +[G, subject_ids] = findgroups(obj.replicate_table.(sub_col)); +n_subj = max(G); + +mean_icc = nan(k, 1); +median_icc = nan(k, 1); +std_icc = nan(k, 1); +n_subj_v = zeros(k, 1); +for c = 1:k + icc_subj = nan(n_subj, 1); + for g = 1:n_subj + rows = find(G == g); + if numel(rows) < 2, continue; end + icc_subj(g) = compute_icc(X_all{c}(rows, :), case_id, type_id, opt.replicate_nan_frac); + end + mean_icc(c) = mean(icc_subj, 'omitnan'); + median_icc(c) = median(icc_subj, 'omitnan'); + std_icc(c) = std(icc_subj, 'omitnan'); + n_subj_v(c) = sum(~isnan(icc_subj)); +end + +T = table(cond_labels, mean_icc, median_icc, std_icc, n_subj_v, ... + repmat(k - 1, k, 1), ... + 'VariableNames', {'Condition', 'Mean_ICC', 'Median_ICC', 'Std_ICC', ... + 'n_subjects', 'n_other_conditions'}); + +end + + +function val = compute_icc(X, case_id, type_id, rep_nan_frac) +% X is [replicates x cells]. See @rsm/reliability.m compute_icc for the +% two-stage NaN policy (drop bad replicates, then drop bad cells). +if nargin < 4 || isempty(rep_nan_frac), rep_nan_frac = 0.5; end +nan_frac_per_rep = mean(~isfinite(X), 2); +bad_reps = nan_frac_per_rep > rep_nan_frac; +if any(bad_reps) + warning('rsm:reliability_per_condition:droppedReplicates', ... + 'Dropped %d/%d replicates with > %.0f%% NaN cells.', ... + sum(bad_reps), numel(bad_reps), 100 * rep_nan_frac); + X = X(~bad_reps, :); +end +if size(X, 1) < 2, val = NaN; return; end +data = X'; +nan_rows = any(~isfinite(data), 2); +if any(nan_rows) + if sum(~nan_rows) < 2, val = NaN; return; end + data = data(~nan_rows, :); +end +if exist('ICC', 'file') == 2 + try, val = ICC(case_id, type_id, data); if ~isnan(val), return; end, catch, end +end +val = local_icc(data, case_id, type_id); +end + + +function [case_id, type_id] = parse_icc_type(spec) +spec = lower(char(spec)); +parts = strsplit(spec, '-'); +case_id = str2double(parts{1}); type_id = parts{2}; +end + + +function X = apply_transform(X, mode, obj) +mode = lower(char(mode)); +if strcmp(mode, 'auto') + if obj.is_dissimilarity || ~ismember(lower(obj.metric), {'correlation','spearman','cosine'}) + mode = 'none'; + else + mode = 'fisherz'; + end +end +if strcmp(mode, 'fisherz') + X(X > 0.9999999) = 0.9999999; + X(X < -0.9999999) = -0.9999999; + X = atanh(X); +end +end + + +function val = local_icc(data, case_id, type_id) +[n, k] = size(data); +mean_items = mean(data, 2); mean_raters = mean(data, 1); grand = mean(data(:)); +SS_t = sum((data(:) - grand).^2); +SS_b = k * sum((mean_items - grand).^2); +SS_w = SS_t - SS_b; +SS_r = n * sum((mean_raters - grand).^2); +SS_e = SS_w - SS_r; +MS_b = SS_b / (n - 1); MS_w = SS_w / (n * (k - 1)); +MS_r = SS_r / (k - 1); MS_e = SS_e / ((n - 1) * (k - 1)); +switch case_id + case 1, if strcmp(type_id,'single'), val=(MS_b-MS_w)/(MS_b+(k-1)*MS_w); else, val=(MS_b-MS_w)/MS_b; end + case 2, if strcmp(type_id,'single'), val=(MS_b-MS_e)/(MS_b+(k-1)*MS_e+k*(MS_r-MS_e)/n); else, val=(MS_b-MS_e)/(MS_b+(MS_r-MS_e)/n); end + case 3, if strcmp(type_id,'single'), val=(MS_b-MS_e)/(MS_b+(k-1)*MS_e); else, val=(MS_b-MS_e)/MS_b; end +end +end diff --git a/CanlabCore/@rsm/reorder.m b/CanlabCore/@rsm/reorder.m new file mode 100644 index 00000000..7ce1abb9 --- /dev/null +++ b/CanlabCore/@rsm/reorder.m @@ -0,0 +1,29 @@ +function obj = reorder(obj, perm) +% reorder Apply a permutation to the row/col order of an rsm. +% +% Usage +% R2 = reorder(R, [3 1 2 ...]) + +if numel(obj) > 1, obj = arrayfun(@(o) reorder(o, perm), obj); return; end + +k = size(obj.dat, 1); +perm = perm(:); +if numel(perm) ~= k || ~isequal(sort(perm), (1:k)') + error('rsm:reorder:badPerm', 'perm must be a permutation of 1:%d.', k); +end + +obj.dat = obj.dat(perm, perm, :); +if ~isempty(obj.labels), obj.labels = obj.labels(perm); end +if ~isempty(obj.metadata_table), obj.metadata_table = obj.metadata_table(perm, :); end + +if ~isempty(obj.groupings) && ~isempty(fieldnames(obj.groupings)) + fn = fieldnames(obj.groupings); + [~, inv_perm] = sort(perm); + for i = 1:numel(fn) + obj.groupings.(fn{i}) = inv_perm(obj.groupings.(fn{i}))'; + end +end + +obj.history{end+1} = sprintf('%s: reorder (perm)', datestr(now, 'yyyy-mm-dd HH:MM:SS')); + +end diff --git a/CanlabCore/@rsm/rsm.m b/CanlabCore/@rsm/rsm.m new file mode 100644 index 00000000..f9c18911 --- /dev/null +++ b/CanlabCore/@rsm/rsm.m @@ -0,0 +1,234 @@ +% rsm: Container for Representational Similarity / Dissimilarity Matrices. +% +% ------------------------------------------------------------------------- +% Features and philosophy +% ------------------------------------------------------------------------- +% +% @rsm is a value-class container for similarity (RSM) or dissimilarity (RDM) +% matrices with attached condition metadata, optional replicate axis +% (subjects, sessions, runs, folds), and provenance. +% +% It is the canonical output of fmri_data.compute_rsm and the canonical input +% to RSA inference methods (cells, contrasts, ttest_contrasts, reliability, +% drift, rsa_lm, rsa_lme, compare_models, compare, rsa_parcelwise, +% searchlight_rsa). +% +% Value semantics: every method returns a new rsm rather than mutating in +% place. R2 = R.fisher_z() does not modify R. +% +% Soft dependency on the Kriegeskorte rsatoolbox: methods probe via +% @rsm/private/probe_rsatoolbox.m and fall back to stock implementations +% when rsa.* is not on the path. +% +% ------------------------------------------------------------------------- +% Properties +% ------------------------------------------------------------------------- +% +% dat [k x k] or [k x k x N] numeric matrix +% Similarity (RSM) or dissimilarity (RDM) values. +% 3rd dim N indexes replicates whose meaning is given by +% the .level and .replicate_table properties. +% +% is_dissimilarity logical +% true -> .dat is an RDM (lower = more similar) +% false -> .dat is an RSM (higher = more similar) +% +% metric char/string +% Name of the distance/similarity metric used to construct +% .dat. One of {'correlation','spearman','cosine','euclidean', +% 'seuclidean','mahalanobis','crossnobis','design', +% 'categorical','distance','none'} or any user-supplied string. +% +% labels {k x 1} cellstr +% Condition / row-column labels. Length must equal size(dat,1). +% +% metadata_table table with k rows (or empty) +% Per-condition attributes. One row per label. Optional. +% +% groupings struct +% Map name -> indices into 1:k. Used by rsm.cells, contrast, +% plot 'block_borders_by', etc. Example: +% groupings.hot = 1:8; +% groupings.warm = 9:16; +% groupings.imagine = 17:24; +% +% level char/string +% What each slice along dim 3 represents. One of: +% {'subject','session','run','collapsed','group','image', +% 'model_stack','none'}. +% +% replicate_table table with N rows (or empty) +% One row per slice along dim 3. Self-describes the +% replicate axis. Typical columns: subject_id, +% session_number, run_number, fold. +% +% whitened struct +% Provenance of any whitening applied. +% .level 'none'|'within_subject'|'across_subject'|'session_difference' +% .method 'none'|'covdiag'|'diag'|'custom' +% .shrinkage [] or scalar in [0,1] +% +% source char/string +% Where this rsm came from. e.g., 'fmri_data', +% 'parcel:', 'searchlight:', 'custom', 'design'. +% +% history cellstr +% Chronological log of operations applied to this rsm. +% +% additional_info struct +% Free-form bag for paradigm-specific metadata. +% +% ------------------------------------------------------------------------- +% Construction +% ------------------------------------------------------------------------- +% +% R = rsm(); % empty +% R = rsm(dat); % bare matrix, defaults filled +% R = rsm(dat, 'labels', {'A','B','C'}, ... +% 'metric', 'correlation', ... +% 'is_dissimilarity', false); +% +% R = rsm.from_categorical(cat_vec) % static — same-vs-different RDM +% R = rsm.from_metadata_distance(table, col, ...) % static — continuous-distance RDM +% R = rsm.from_design(X, 'names', names, ...) % static — design columns +% +% R = compute_rsm(fmri_data_obj, ...) % @fmri_data method +% +% ------------------------------------------------------------------------- +% Author & copyright +% ------------------------------------------------------------------------- +% Copyright (C) 2026 Michael Sun. +% +% This program is free software: you can redistribute it and/or modify +% it under the terms of the GNU General Public License as published by +% the Free Software Foundation, either version 3 of the License, or +% (at your option) any later version. + +classdef rsm + + properties + + dat = [] + is_dissimilarity = false + metric = 'none' + labels = {} + metadata_table = table.empty + groupings = struct() + level = 'none' + replicate_table = table.empty + whitened = struct('level','none','method','none','shrinkage',[]) + source = 'custom' + history = {} + additional_info = struct() + + end + + methods + + function obj = rsm(dat, varargin) + % Construct an rsm from a k x k or k x k x N matrix plus optional + % name-value metadata. + % + % Empty form: + % R = rsm(); + % + % Bare-matrix form (defaults filled, labels auto-named): + % R = rsm(dat); + % + % Full form: + % R = rsm(dat, 'labels', {...}, 'metric', 'correlation', ...) + + if nargin == 0 || isempty(dat) + return + end + + if isnumeric(dat) || islogical(dat) + obj.dat = double(dat); + elseif isa(dat, 'rsm') + % Copy-construct from another rsm; allow overrides via varargin. + obj = dat; + else + error('rsm:badInput', ... + 'First argument must be numeric, logical, or an rsm.'); + end + + % Parse name-value overrides + p = inputParser; + p.KeepUnmatched = false; + p.CaseSensitive = false; + p.addParameter('is_dissimilarity', obj.is_dissimilarity, @(x) islogical(x) || isnumeric(x)); + p.addParameter('metric', obj.metric, @(x) ischar(x) || isstring(x)); + p.addParameter('labels', obj.labels, @(x) iscellstr(x) || isstring(x) || isempty(x)); %#ok + p.addParameter('metadata_table', obj.metadata_table, @(x) istable(x) || isempty(x)); + p.addParameter('groupings', obj.groupings, @isstruct); + p.addParameter('level', obj.level, @(x) ischar(x) || isstring(x)); + p.addParameter('replicate_table', obj.replicate_table, @(x) istable(x) || isempty(x)); + p.addParameter('whitened', obj.whitened, @isstruct); + p.addParameter('source', obj.source, @(x) ischar(x) || isstring(x)); + p.addParameter('history', obj.history, @(x) iscell(x) || isempty(x)); + p.addParameter('additional_info', obj.additional_info, @isstruct); + p.parse(varargin{:}); + + obj.is_dissimilarity = logical(p.Results.is_dissimilarity); + obj.metric = char(p.Results.metric); + obj.labels = local_to_cellstr(p.Results.labels); + obj.metadata_table = p.Results.metadata_table; + obj.groupings = p.Results.groupings; + obj.level = char(p.Results.level); + obj.replicate_table = p.Results.replicate_table; + obj.whitened = p.Results.whitened; + obj.source = char(p.Results.source); + obj.history = p.Results.history; + obj.additional_info = p.Results.additional_info; + + % Auto-fill labels if missing + k = size(obj.dat, 1); + if isempty(obj.labels) && k > 0 + obj.labels = arrayfun(@(i) sprintf('cond_%d', i), 1:k, 'UniformOutput', false)'; + end + + % Run validators + validate_metadata(obj); + + % Append a history entry + obj.history{end+1} = sprintf('%s: constructed (%dx%dx%d, metric=%s, level=%s)', ... + datestr(now, 'yyyy-mm-dd HH:MM:SS'), size(obj.dat,1), size(obj.dat,2), size(obj.dat,3), ... + obj.metric, obj.level); + + end + + end + + methods (Static) + % Static constructors live in separate files in @rsm/: + % from_categorical.m + % from_metadata_distance.m + % from_design.m + % from_table.m + obj = from_categorical(varargin) + obj = from_metadata_distance(varargin) + obj = from_design(varargin) + obj = from_table(varargin) + end + +end + + +% ========================================================================= +% Local helpers (file-private) +% ========================================================================= + +function c = local_to_cellstr(x) +% Coerce label-like inputs to a column cellstr. + if isempty(x) + c = {}; + elseif iscellstr(x) %#ok + c = x(:); + elseif isstring(x) + c = cellstr(x(:)); + elseif ischar(x) + c = {x}; + else + error('rsm:badLabels', 'labels must be cellstr, string, or char.'); + end +end diff --git a/CanlabCore/@rsm/size.m b/CanlabCore/@rsm/size.m new file mode 100644 index 00000000..441af185 --- /dev/null +++ b/CanlabCore/@rsm/size.m @@ -0,0 +1,47 @@ +function varargout = size(obj, dim) +% size Return the size of the rsm. +% +% For an rsm array (numel(obj) > 1), returns the array shape via builtin +% (so MATLAB infrastructure that calls size() — arrayfun, disp, indexing — +% works correctly). +% +% For a scalar rsm, returns size(obj.dat) — the [k x k x N] shape of the +% similarity matrix itself. + +if numel(obj) ~= 1 + % Array of rsm objects: defer to the builtin so arrayfun and the + % MATLAB display machinery see the array shape, not the internal dat. + if nargin < 2 + sz = builtin('size', obj); + if nargout <= 1 + varargout{1} = sz; + else + for i = 1:nargout + if i <= numel(sz), varargout{i} = sz(i); + else, varargout{i} = 1; + end + end + end + else + varargout{1} = builtin('size', obj, dim); + end + return +end + +% Scalar rsm: forward to the underlying matrix +if nargin < 2 + if nargout <= 1 + varargout{1} = size(obj.dat); + else + sz = size(obj.dat); + for i = 1:nargout + if i <= numel(sz), varargout{i} = sz(i); + else, varargout{i} = 1; + end + end + end +else + varargout{1} = size(obj.dat, dim); +end + +end diff --git a/CanlabCore/@rsm/subset.m b/CanlabCore/@rsm/subset.m new file mode 100644 index 00000000..cc1625f2 --- /dev/null +++ b/CanlabCore/@rsm/subset.m @@ -0,0 +1,52 @@ +function obj = subset(obj, idx) +% subset Restrict an rsm to a subset of conditions. +% +% Usage +% R2 = subset(R, 1:8) +% R2 = subset(R, [true false true ...]) +% R2 = subset(R, 'hot') % grouping name (uses .groupings.hot) +% +% Preserves replicate axis; subsets labels, metadata_table, and groupings +% accordingly. + +if numel(obj) > 1, obj = arrayfun(@(o) subset(o, idx), obj); return; end + +% Resolve grouping name to indices +if ischar(idx) || isstring(idx) + name = char(idx); + if ~isfield(obj.groupings, name) + error('rsm:subset:unknownGrouping', 'No grouping named "%s".', name); + end + idx = obj.groupings.(name); +end + +if islogical(idx), idx = find(idx); end +idx = idx(:); +k = size(obj.dat, 1); +if any(idx < 1) || any(idx > k) + error('rsm:subset:outOfRange', 'subset indices must be in 1:%d.', k); +end + +obj.dat = obj.dat(idx, idx, :); + +if ~isempty(obj.labels), obj.labels = obj.labels(idx); end +if ~isempty(obj.metadata_table), obj.metadata_table = obj.metadata_table(idx, :); end + +% Reindex groupings (drop any whose indices are not all in the subset) +if ~isempty(obj.groupings) && ~isempty(fieldnames(obj.groupings)) + fn = fieldnames(obj.groupings); + new_groupings = struct(); + for i = 1:numel(fn) + old_idx = obj.groupings.(fn{i}); + [tf, loc] = ismember(old_idx, idx); + if all(tf) + new_groupings.(fn{i}) = loc(:)'; + end % otherwise drop this grouping + end + obj.groupings = new_groupings; +end + +obj.history{end+1} = sprintf('%s: subset (k=%d -> %d)', ... + datestr(now, 'yyyy-mm-dd HH:MM:SS'), k, numel(idx)); + +end diff --git a/CanlabCore/@rsm/to_rdm.m b/CanlabCore/@rsm/to_rdm.m new file mode 100644 index 00000000..6c97dfa6 --- /dev/null +++ b/CanlabCore/@rsm/to_rdm.m @@ -0,0 +1,26 @@ +function obj = to_rdm(obj) +% to_rdm Convert an RSM to an RDM (1 - similarity), preserving metadata. +% +% No-op when already a dissimilarity matrix. + +if numel(obj) > 1 + obj = arrayfun(@to_rdm, obj); + return +end + +if obj.is_dissimilarity, return; end + +obj.dat = 1 - obj.dat; +% Diagonal -> 0 +k = size(obj.dat, 1); +N = size(obj.dat, 3); +for n = 1:N + slice = obj.dat(:, :, n); + slice(1:k+1:end) = 0; + obj.dat(:, :, n) = slice; +end + +obj.is_dissimilarity = true; +obj.history{end+1} = sprintf('%s: to_rdm (1 - similarity)', datestr(now, 'yyyy-mm-dd HH:MM:SS')); + +end diff --git a/CanlabCore/@rsm/to_rsm.m b/CanlabCore/@rsm/to_rsm.m new file mode 100644 index 00000000..004e4887 --- /dev/null +++ b/CanlabCore/@rsm/to_rsm.m @@ -0,0 +1,26 @@ +function obj = to_rsm(obj) +% to_rsm Convert an RDM to an RSM (1 - dissimilarity), preserving metadata. +% +% No-op when already a similarity matrix. + +if numel(obj) > 1 + obj = arrayfun(@to_rsm, obj); + return +end + +if ~obj.is_dissimilarity, return; end + +obj.dat = 1 - obj.dat; +% Diagonal -> 1 +k = size(obj.dat, 1); +N = size(obj.dat, 3); +for n = 1:N + slice = obj.dat(:, :, n); + slice(1:k+1:end) = 1; + obj.dat(:, :, n) = slice; +end + +obj.is_dissimilarity = false; +obj.history{end+1} = sprintf('%s: to_rsm (1 - dissimilarity)', datestr(now, 'yyyy-mm-dd HH:MM:SS')); + +end diff --git a/CanlabCore/@rsm/ttest_contrasts.m b/CanlabCore/@rsm/ttest_contrasts.m new file mode 100644 index 00000000..01c69f96 --- /dev/null +++ b/CanlabCore/@rsm/ttest_contrasts.m @@ -0,0 +1,155 @@ +function T = ttest_contrasts(obj, spec, varargin) +% ttest_contrasts Run a battery of paired/one-sample t-tests across replicates. +% +% For each contrast in the spec, computes a per-replicate scalar via +% R.contrast() and runs ttest across replicates. Applies multiple-comparison +% correction (FDR / Bonferroni / none) across the battery. +% +% Usage +% ----- +% spec = { ... +% % {name, cells_A, cells_B} +% 'within_hot', 'hot', []; % one-sample (cells_B empty) +% 'within_warm', 'warm', []; +% 'hot_vs_warm', 'hot', 'warm'; % paired (within-hot vs within-warm) +% 'HI_vs_HW', {'hot','imag'}, {'hot','warm'}; +% }; +% T = R.ttest_contrasts(spec, 'tail','right', 'correction','fdr'); +% +% Returns a table with one row per contrast and columns: +% Contrast, Mean_Diff, SE, t, df, p, FDR_P or Bonf_P, sig, Cohens_d +% +% Inputs +% ------ +% spec N x 3 cell array: {name, cells_A, cells_B} +% OR N x 2 cell array (one-sample tests, cells_B implied empty) +% Each cells_A / cells_B is a cells spec: name / {a,b} / index vector / []. +% +% varargin: +% 'tail' 'both' (default) | 'right' | 'left' +% 'correction' 'fdr' (default) | 'bonferroni' | 'none' +% 'q' threshold for sig flag (default 0.05) +% 'transform' 'auto' (default) | 'fisherz' | 'none' +% 'reduction' 'mean' (default) | 'median' | 'sum' + +if builtin('numel', obj) > 1 + error('rsm:ttest_contrasts:nonScalar', 'expects a scalar rsm.'); +end + +p = inputParser; +p.addParameter('tail', 'both', @(x) ischar(x) || isstring(x)); +p.addParameter('correction', 'fdr', @(x) ischar(x) || isstring(x)); +p.addParameter('q', 0.05, @isnumeric); +p.addParameter('transform', 'auto', @(x) ischar(x) || isstring(x)); +p.addParameter('reduction', 'mean', @(x) ischar(x) || isstring(x)); +p.parse(varargin{:}); +opt = p.Results; + +% Normalize spec +spec = normalize_spec(spec); +n = size(spec, 1); + +names = strings(n, 1); +diffs = nan(n, 1); +ses = nan(n, 1); +ts = nan(n, 1); +dfs = nan(n, 1); +ps = nan(n, 1); +ds = nan(n, 1); + +for i = 1:n + names(i) = string(spec{i, 1}); + A = spec{i, 2}; B = spec{i, 3}; + + if isempty(B) + % One-sample: test cells_A against zero + v = obj.contrast(A, 'transform', opt.transform, 'reduction', opt.reduction); + [~, p_i, ~, st] = ttest(v, 0, 'Tail', char(opt.tail)); + diffs(i) = mean(v, 'omitnan'); + ses(i) = std(v, 0, 'omitnan') / sqrt(sum(~isnan(v))); + ds(i) = mean(v, 'omitnan') / std(v, 0, 'omitnan'); + else + % Two-sample paired: test (cells_A - cells_B) against zero + vA = obj.contrast(A, 'transform', opt.transform, 'reduction', opt.reduction); + vB = obj.contrast(B, 'transform', opt.transform, 'reduction', opt.reduction); + d = vA - vB; + [~, p_i, ~, st] = ttest(d, 0, 'Tail', char(opt.tail)); + diffs(i) = mean(d, 'omitnan'); + ses(i) = std(d, 0, 'omitnan') / sqrt(sum(~isnan(d))); + ds(i) = mean(d, 'omitnan') / std(d, 0, 'omitnan'); + end + + ts(i) = st.tstat; + dfs(i) = st.df; + ps(i) = p_i; +end + +% Multiple comparison correction +correction = lower(char(opt.correction)); +switch correction + case 'fdr' + if exist('FDR', 'file') == 2 + try + p_thresh = FDR(ps, opt.q); + if isempty(p_thresh) || isnan(p_thresh), p_thresh = 0; end + corrected_p = ps; + sig = ps <= p_thresh; + catch + % BH fallback + [corrected_p, sig] = local_fdr_bh(ps, opt.q); + end + else + [corrected_p, sig] = local_fdr_bh(ps, opt.q); + end + corrected_label = 'FDR_P'; + case 'bonferroni' + corrected_p = min(ps * n, 1); + sig = corrected_p < opt.q; + corrected_label = 'Bonf_P'; + case 'none' + corrected_p = ps; + sig = ps < opt.q; + % Avoid a duplicate 'P' column name -- use a distinct label. + corrected_label = 'P_uncorr'; + otherwise + error('rsm:ttest_contrasts:badCorrection', ... + 'correction must be ''fdr'', ''bonferroni'', or ''none''.'); +end + +% Build output table +T = table(names, diffs, ses, ts, dfs, ps, corrected_p, sig, ds, ... + 'VariableNames', {'Contrast', 'Mean_Diff', 'SE', 't', 'df', ... + 'P', corrected_label, 'sig', 'Cohens_d'}); + +end + + +function spec = normalize_spec(spec) +% Accept either Nx2 (one-sample) or Nx3 (mixed) spec. Coerce to Nx3. +if size(spec, 2) == 2 + spec = [spec, cell(size(spec, 1), 1)]; +end +if size(spec, 2) ~= 3 + error('rsm:ttest_contrasts:badSpec', ... + 'spec must be Nx2 (one-sample) or Nx3 ({name, A, B}).'); +end +end + + +function [corrected_p, sig] = local_fdr_bh(ps, q) +% Benjamini-Hochberg stock fallback +n = numel(ps); +[sorted_p, sort_idx] = sort(ps); +thresh = (1:n)' / n * q; +below = sorted_p(:) <= thresh; +if any(below) + k = find(below, 1, 'last'); + p_thresh = sorted_p(k); +else + p_thresh = 0; +end +% Storey-style adjusted p; simpler version: min(p * n / rank, 1) +ranks = zeros(n, 1); ranks(sort_idx) = 1:n; +corrected_p = min(ps(:) .* n ./ ranks, 1); +sig = ps(:) <= p_thresh; +end diff --git a/CanlabCore/Data_extraction/load_image_set.m b/CanlabCore/Data_extraction/load_image_set.m index f6a66a35..6b0cfc8c 100644 --- a/CanlabCore/Data_extraction/load_image_set.m +++ b/CanlabCore/Data_extraction/load_image_set.m @@ -1993,7 +1993,7 @@ function local_print_titled(t, titlestr) 'moneyvalue_weights_fdr05.nii.gz' ... 'shockintensity_weights_fdr05.nii.gz' ... 'VIFS.nii' ... % visually induced fear - 'PiFoneM_unthresholded' % picture induced fear of neck movement + 'PiFoneM_unthresholded.nii' % picture induced fear of neck movement }'; table_list = table(keyword, pain, negemo, posemo, empathy, physio, cogcontrol, regulation, reward, other, imagenames); diff --git a/CanlabCore/Data_processing_tools/canlab_compute_similarity_matrix.m b/CanlabCore/Data_processing_tools/canlab_compute_similarity_matrix.m index f08b499d..a8835d59 100644 --- a/CanlabCore/Data_processing_tools/canlab_compute_similarity_matrix.m +++ b/CanlabCore/Data_processing_tools/canlab_compute_similarity_matrix.m @@ -37,7 +37,8 @@ % % **'similarity_metric'** (string): % Metric to compute. Options: -% - 'correlation' (default) +% - 'correlation' (default) -- Pearson correlation +% - 'spearman' -- Spearman rank correlation % - 'cosine_similarity' % - 'dot_product' % - 'dice' @@ -99,7 +100,7 @@ p = inputParser; p.addRequired('dat', @(x) validateattributes(x, {'numeric'}, {'2d'})); p.addParameter('treat_zero_as_data', false, @(x) islogical(x) || isnumeric(x)); -p.addParameter('similarity_metric', 'correlation', @(x) ismember(x, {'correlation', 'cosine_similarity', 'dot_product', 'dice'})); +p.addParameter('similarity_metric', 'correlation', @(x) ismember(x, {'correlation', 'spearman', 'cosine_similarity', 'dot_product', 'dice'})); p.addParameter('verbose', true, @(x) islogical(x) || isnumeric(x)); p.addParameter('doplot', false, @(x) islogical(x) || isnumeric(x)); p.addParameter('complete_cases', false, @(x) islogical(x) || isnumeric(x)); % otherwise pairwise @@ -217,6 +218,13 @@ case 'correlation' Rval = corr(a, b); + case 'spearman' + % Rank-transform within the shared valid rows, then Pearson. + % Equivalent to corr(a, b, 'Type', 'Spearman') but written + % out so the rank step is explicit and the same code path + % can be reused elsewhere. + Rval = corr(a, b, 'Type', 'Spearman'); + case 'cosine_similarity' Rval = dot(a, b) / (norm(a) * norm(b)); @@ -252,6 +260,9 @@ case 'correlation' R = corr(dat); % built-in vectorized + case 'spearman' + R = corr(dat, 'Type', 'Spearman'); % built-in vectorized rank correlation + case 'cosine_similarity' norms = sqrt(sum(dat.^2, 1)); R = (dat' * dat) ./ (norms' * norms); % cosine similarity diff --git a/CanlabCore/HRF_Est_Toolbox4/Fit_Canonical_HRF.m b/CanlabCore/HRF_Est_Toolbox4/Fit_Canonical_HRF.m index 01343028..59b4fcc4 100644 --- a/CanlabCore/HRF_Est_Toolbox4/Fit_Canonical_HRF.m +++ b/CanlabCore/HRF_Est_Toolbox4/Fit_Canonical_HRF.m @@ -1,4 +1,4 @@ -function [hrf, fit, e, param, info] = Fit_Canonical_HRF(tc, TR, Run, T, p) +function [hrf, fit, e, param, info] = Fit_Canonical_HRF(tc, TR, Run, T, p, varargin) % function [hrf, fit, e, param, info] = Fit_Canonical_HRF(tc,TR,Runs,T,p) % % Fits GLM using canonical hrf (with option of using time and dispersion derivatives)'; @@ -26,43 +26,81 @@ % Created by Martin Lindquist on 10/02/09 % Last edited: 05/17/13 (ML) +% Edited to allow passing in custom design matrix e.g., from SPM. Will +% assume that the first regressor columns of the design matrix pertain to +% the regressors in Run: Michael Sun, Ph.D. 02/20/2024 + +[h, dh, dh2] = CanonicalBasisSet(TR); %tc = tc'; d = length(Run); len = length(Run{1}); +% Generate a design matrix t=1:TR:T; -X = zeros(len,p*d); -param = zeros(3,d); - -[h, dh, dh2] = CanonicalBasisSet(TR); +% Import your own design matrix +if ~isempty(varargin) + X=varargin{1}; + if numel(varargin)>1 + for i = 1:numel(varargin) + if strcmpi(varargin{i}, 'invertedDX') + PX=varargin{i+1}; + b=PX*tc; + end + end + end + if ~exist('b', 'var') + b = pinv(X)*tc; + end -for i=1:d - v = conv(Run{i},h); - X(:,(i-1)*p+1) = v(1:len); + e = tc-X*b; + fit = X*b; + + % Be careful here. if p>1, make sure Run includes derivatives so there + % are p*task regressors. + % b = reshape(b(1:numel(Run)),p,d)'; % Extract my own regressors - if (p>1) - v = conv(Run{i},dh); - X(:,(i-1)*p+2) = v(1:len); + if numel(b) < p*d + error('Not enough beta weights to reshape into %d x %d.', d, p); end + b = reshape(b(1:p*d), p, d)'; + + bc = zeros(d,1); - if (p>2) - v = conv(Run{i},dh2); - X(:,(i-1)*p+3) = v(1:len); +else + + % Constructing the Design Matrix X: + X = zeros(len,p*d); + param = zeros(3,d); + + for i=1:d, + v = conv(Run{i},h); + X(:,(i-1)*p+1) = v(1:len); + + % Computing the first derivative + if (p>1) + v = conv(Run{i},dh); + X(:,(i-1)*p+2) = v(1:len); + end + + % Computing the second derivative + if (p>2) + v = conv(Run{i},dh2); + X(:,(i-1)*p+3) = v(1:len); + end end -end -%X = [(zeros(len,1)+1) X]; -X = [X (zeros(len,1)+1)]; - -b = pinv(X)*tc; -e = tc-X*b; -fit = X*b; - -%b = reshape(b(2:end),p,d)'; -b = reshape(b(1:(p*d)),p,d)'; -bc = zeros(d,1); + % This line adds an intercept + X = [(zeros(len,1)+1) X]; + PX = pinv(X); + b = PX*tc; + e = tc-X*b; + fit = X*b; + + b = reshape(b(2:end),p,d)'; + bc = zeros(d,1); +end -for i=1:d +for i=1:d, if (p == 1) bc(i) = b(i,1); H = h; @@ -73,19 +111,21 @@ bc(i) = sign(b(i,1))*sqrt((b(i,1))^2 + (b(i,2))^2 + (b(i,3))^2); H = [h dh dh2]; end - end + hrf = H*b'; -for i=1:d +for i=1:d, param(:,i) = get_parameters2(hrf(:,i),1:length(t)); -end +end; + -info ={}; +info = struct(); info.b = b; info.bc = bc; -info.X = X; +info.DX = X; +info.PX = PX; info.H =H; end diff --git a/CanlabCore/HRF_Est_Toolbox4/Fit_Spline.m b/CanlabCore/HRF_Est_Toolbox4/Fit_Spline.m index 680887d1..f136d6ed 100644 --- a/CanlabCore/HRF_Est_Toolbox4/Fit_Spline.m +++ b/CanlabCore/HRF_Est_Toolbox4/Fit_Spline.m @@ -28,7 +28,13 @@ norder = 4; % Order of b-spline basis (This cxan be set to specification) % Create design matrix +try basis = create_bspline_basis([0,tlen], K+3, norder); +catch +error('No create_bspline_basis function. This requires FDA package. Please clone and add to path: https://github.com/markgewhite/fda') +end + + B = eval_basis((1:tlen),basis); B = B(:,3:end-1); diff --git a/CanlabCore/HRF_Est_Toolbox4/Fit_sFIR_epochmodulation.m b/CanlabCore/HRF_Est_Toolbox4/Fit_sFIR_epochmodulation.m new file mode 100644 index 00000000..8156165e --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/Fit_sFIR_epochmodulation.m @@ -0,0 +1,394 @@ +function [hrf, fit, e, param, DXinfo] = Fit_sFIR_epochmodulation(tc, TR, Run, T, mode, varargin) +% +% Fits FIR and smooth FIR model +% +% INPUTS: +% ---------- +% tc : (scan_length x 1) double vector +% time course of a single voxel. +% +% TR : float +% time resolution of one scan. +% e.g. use `0.5` for a scan collected every 0.5 seconds. +% +% Runs : cell array (1 x num_condition) +% experimental design matrix. each cell, the size of (1 x num_condition) contains +% a (scan_length x 1) double vector representing the design for that condition. +% +% T : double array +% desired length of the estimated HRF. indicates the number of beta +% coefficients to estimate, for each condition. +% e.g. [30, 30, 30, 30, 30, 30, 20, 12] for a design with 8 conditions. +% +% mode : int {0, 1} +% options for FIR. +% 0 - Standard FIR. +% 1 - Smooth FIR. +% +% +% OUTPUTS: +% ------- +% hrf : double array (time_point x num_condition) +% estimated hemodynamic response function. +% estimated HRF length differs depending on inputted T, across conditions. +% to account for this difference, the hrf matrix is the size of the largest time +% points estimated, which is max(T/TR). the shorter events are filled with NaNs +% +% fit : double array (scan_length x 1) +% estimated time course. +% +% e : double array (scan_length x 1) +% residual time course. +% +% param : double array (3 x num_condition) +% estimated amplitude, height and width +% +% Created by Martin Lindquist on 10/02/09 +% Last edited: 05/17/13 (ML) +% +% [10/27/2023 - HJ v1.2]: BUGFIX: remove redundant intercept due to multiple design matrix estimation +% - due to horzcat, we will have multiple intercepts in this design matrix +% - therefore, we find the intercepts, drop them, and add an intercept at the end of the matrix + +% [10/26/2023 - HJ v1.2]: Updated the code to Allow for Different Epoch Lengths +% - handles varying epoch lengths with the addition of T vector, instead of a scalar T +% - made adjustments accordingly to handle T vector in variable `tlen_all` +% - based on varying epoch lengths, dynamically calculates matrix using `tor_make_deconv_mtx3.m` +% - (dynamic matrix is concatenated into one design matrix at the end) +% - made adjustments accordingly to the estimated hrf variable: instead of being a vector, hrf is now a matrix with varying lengths of hrf estimation, due to varying epoch length inputted via T. +% - lastly, varying lengths of hrf is concatenated into one matrix (double: longest length hrf estimation x number of conditions) + +% [2/19/2024 - MS v2.0]: Updated the code to output design-matrix information, and allow input of multiple tc, +% pass in a custom design matrix, and to pass in an SPM structure + +% Debugging Hack, import your own Design Matrix from SPM - MS +if ~isempty(varargin) + if isstruct(varargin{1}) + % 1. Decide what are regressors and what are covariates here + % This is very difficult since I will have to pass in g, K, and W + % all from SPM.mat + + % So ultimately we will want to concatenate the FIR task regressors + % and the unfiltered covariates, and then filter the whole thing. + + SPM=varargin{1}; + + if numel(SPM.Sess)>1 && ~iscell(tc) % If SPM has concatenated runs but there's only one time series + error('SPM structures concatenating multiple runs must have timecourses passed in with a matching number of cell arrays'); + elseif iscell(tc) && numel(SPM.Sess)~=numel(tc) + error('Incompatible number of runs in SPM and in cell-array tc') + end + + if isempty(T) + for i = 1:numel(SPM.Sess) + T{i}=cellfun(@(cellArray) 2*ceil(max(cellArray)), {SPM.Sess(i).U.dur}); + end + elseif numel(SPM.Sess)>1 && ~iscell(T) + error('SPM structures concatenating multiple runs must have time windows passed in with a matching number of cell arrays.'); + elseif iscell(T) && numel(SPM.Sess)~=numel(T) + error('Incompatible number of runs in SPM and in cell-array T.') + end + + for s = 1:numel(SPM.Sess) + len = numel(SPM.Sess(s).row); + + % Task regressors for each run can be found here: + numstim=numel(SPM.Sess(s).U); + TR = SPM.xY.RT; + + % Make the design matrix: + DX_all = cell(1, numstim); % Store DX matrices for each condition + tlen_all = zeros(1, numstim); % Store tlen for each condition + Runs{s}=generateConditionTS(numel(SPM.Sess(s).row), [SPM.Sess(s).U.name], {SPM.Sess(s).U.ons}, {SPM.Sess(s).U.dur}); + + for i=1:numstim + t = 1:TR:T{s}(i); + tlen_all(i) = length(t); + DX_all{i} = tor_make_deconv_mtx3(Runs{s}(:,i), tlen_all(i), 1); + end + DX = horzcat(DX_all{:}); + + % due to horzcat, we will have multiple intercepts in this design matrix + % therefore, we'll find the intercepts, drop them, and add an intercept at the end of the matrix + intercept_idx = find(sum(DX)==len); + copyDX = DX; + copyDX(:,intercept_idx) = []; + DX = [copyDX ones(len,1)]; + + % Covariate Design Matrix for each session (without intercept): + NX=[SPM.Sess(s).C.C]; + + % Concatenate the Task regressors with Covariates + X=[DX, NX]; + % Filter + DX_cov{s}=spm_filter(SPM.xX.K(s), X); + end + + if numel(DX_cov) > 1 + for i=1:numel(DX_cov) + [hrf{i}, fit{i}, e{i}, param{i}, DXinfo{i}]=Fit_sFIR_epochmodulation(tc{i}, TR, Runs{i}, T{i}, mode, DX_cov{i}); + end + return; + end + + elseif isnumeric(varargin{1}) + % If not an SPM struct, allow a design matrix to be passed in. + DX_cov=varargin{1}; + + numstim = length(Run); + tlen_all = zeros(1, numstim); % Store tlen for each condition + for i=1:numstim + t = 1:TR:T(i); + tlen_all(i) = length(t); + end + end + + for i=1:numel(varargin) + % If you pass in both a DX and an inverted DX, this will speed + % things up a bit. + if strcmpi(varargin{i}, 'invertedDX') + PX=varargin{i+1}; + b = PX*tc; + if exist('DX_cov', 'var') + fit = DX_cov*b; + e = tc - DX_cov*b; + DXinfo.DX=DX_cov; + DXinfo.PX=PX; + else + fit = 'DX_cov not passed in'; + e = 'DX_cov not passed in'; + DXinfo.DX=[]; + DXinfo.PX=PX; + end + end + + end + + + + % Processing of varargin done. + + % sFIR + if mode == 1 && ~exist('b', 'var') + % pen = {toeplitz([1 .3 .1])} + % n_nuis = 20; % num of nuisance + % pen{2} = zeros(20); % for nuisance, add no penalty + % blkdiag(pen{:}) + + MRI = zeros(sum(tlen_all)+1); % adjust size based on varying tlen and intercept at end + start_idx = 1; + + for i=1:numstim + tlen = tlen_all(i); % get the tlen for this stimulus + + C = (1:tlen)'*(ones(1,tlen)); + h = sqrt(1/(7/TR)); + + v = 0.1; + sig = 1; + + R = v*exp(-h/2*(C-C').^2); + RI = inv(R); + + % Adjust the indices to account for varying tlen + end_idx = start_idx + tlen - 1; + MRI(start_idx:end_idx, start_idx:end_idx) = RI; + + start_idx = end_idx + 1; % update the starting index for next iteration + end + + % multiply a 0 penalty with NX. + cov_num = size(DX_cov,2)-size(MRI,2); + % pen = sig^2*MRI; % Regularization Penalty Matrix + % pen=pad(pad(pen, cov_num)',cov_num)'; + % pen{2} = zeros(20); % for nuisance, add no penalty + pen{1} = sig^2*MRI; % Regularization Penalty Matrix + pen{2} = zeros(cov_num); % for nuisance, add no penalty + pen=blkdiag(pen{:}); + + % disp(pen) % Check what this looks like. + % X = [DX, NX]; % Full Design Matrix + + PX=inv(DX_cov'*DX_cov+pen)*DX_cov'; + b = PX*tc; + % b = DX'*tc; + fit = DX_cov*b; + e = tc - DX_cov*b; + DXinfo.DX=DX_cov; + DXinfo.PX=PX; + + elseif mode == 0 && ~exist('b', 'var') + PX=pinv(DX_cov); + b = PX*tc; + fit = DX_cov*b; + e = tc - DX_cov*b; + DXinfo.DX=DX_cov; + DXinfo.PX=PX; + + end + +else + + % If nothing is passed in varargin: + + + numstim = length(Run); + len = length(Run{1}); + if isscalar(T), T = repmat(T, 1, numstim); end + + Runs = zeros(len,numstim); + for i=1:numstim + Runs(:,i) = Run{i}; + end + + DX_all = cell(1, numstim); % Store DX matrices for each condition + tlen_all = zeros(1, numstim); % Store tlen for each condition + + + for i=1:numstim + t = 1:TR:T(i); + tlen_all(i) = length(t); + DX_all{i} = tor_make_deconv_mtx3(Runs(:,i), tlen_all(i), 1); + end + DX = horzcat(DX_all{:}); + + % due to horzcat, we will have multiple intercepts in this design matrix + % therefore, we'll find the intercepts, drop them, and add an intercept at the end of the matrix + intercept_idx = find(sum(DX)==len); + copyDX = DX; + copyDX(:,intercept_idx) = []; + DX = [copyDX ones(len,1)]; + + if mode == 1 + + MRI = zeros(sum(tlen_all)+1); % adjust size based on varying tlen and intercept at end + start_idx = 1; + + for i=1:numstim + tlen = tlen_all(i); % get the tlen for this stimulus + + C = (1:tlen)'*(ones(1,tlen)); + h = sqrt(1/(7/TR)); + + v = 0.1; + sig = 1; + + R = v*exp(-h/2*(C-C').^2); + RI = inv(R); + + % Adjust the indices to account for varying tlen + end_idx = start_idx + tlen - 1; + MRI(start_idx:end_idx, start_idx:end_idx) = RI; + + start_idx = end_idx + 1; % update the starting index for next iteration + end + + % DX + % + % NX = ;% Nuisance covariates and intercepts + + % X = [DX, NX]; % Full Design Matrix + pen = sig^2*MRI; % Regularization Penalty Matrix + % disp(pen) % Check what this looks like. + cov_num = size(DX,2)-size(pen, 2); + + pen=canlab_pad(canlab_pad(pen, cov_num)',cov_num)'; + + % multiply a 0 penalty with NX. + + % gKW the DX Design Matrix + + + % b = inv(DX'*DX+sig^2*MRI)*DX'*tc; + PX=inv(DX'*DX+pen)*DX'; + b = PX*tc; + fit = DX*b; + e = tc - DX*b; + + % Code added by Michael Sun, PhD 02/05/2024 + % DXinfo.DX=inv(DX'*DX+sig^2*MRI)*DX'; % This DX is essentially regression coefficients + DXinfo.DX=DX; + DXinfo.PX=PX; + + % DXinfo.DX=DX; + DXinfo.sig=sig; + DXinfo.MRI=MRI; + + elseif mode == 0 + PX =pinv(DX); + b = PX*tc; + fit = DX*b; + e = tc - DX*b; + + % Coded added by Michael Sun, Ph.D. + DXinfo.DX=DX; + DXinfo.PX=PX; + + end + + + +end + +numstim = length(tlen_all); +hrf = cell(1, numstim); + +for i = 1:numstim + hrf{i} = zeros(tlen_all(i), numstim); +end + +% hrf =zeros(tlen,numstim); +param = zeros(3,numstim); + +for i=1:numstim + % hrf{:,i} = b(((i-1)*tlen_all(i)+1):(i*tlen_all(i)))'; % Buggy line Edit is below: Michael Sun, 10/27/2023 + start_idx = sum(tlen_all(1:i-1)) + 1; + end_idx = start_idx + tlen_all(i) - 1; + hrf{:,i} = b(start_idx:end_idx)'; + param(:,i) = get_parameters2(hrf{:,i}, (1:tlen_all(i))); + + DXinfo.tlen{i}=[start_idx:end_idx]; +end + +% HJ: concatenate estimated hrf +% since different event lengths have different lengths of hrf estimation, +% we'll identify the maximum length of hrf estimation timepoints; +% for the shorter events, we'll pad them with zeros. +maxLen = max(cellfun(@length, hrf)); +resultMatrix = NaN(maxLen, numstim); % Pre-allocate a matrix of NaN values +for i = 1:numel(hrf) + resultMatrix(1:length(hrf{i}), i) = hrf{i}'; +end +hrf = resultMatrix; +end + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +% validation plots: design matrix +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +% figure; +% imagesc(DX); % Display matrix as an image +% colorbar; % Add colorbar to the figure +% title('Heatmap of DX'); +% xlabel('Columns of DX'); +% ylabel('Rows of DX'); +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +% % validation estimated curve +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +% numCells = length(hrf)'; +% % Initialize a figure +% figure; +% for i = 1:numCells +% % Create a subplot for each cell +% subplot(2, 4, i); % Assuming a 2x4 grid for 8 panels + +% % Plot the values of the double array +% plot(hrf{i}, 'o-'); + +% % Label axes and provide title for each subplot +% xlabel('# of time points'); +% ylabel('Voxel-wise Amplitude'); +% title([num2str(length(hrf{i})) ' time points estimated']); % Updated title + +% % Adjust y-axis for better visualization +% ylim([min(hrf{i})-0.1, max(hrf{i})+0.1]); +% end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/@fmri_hrf/cat.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/@fmri_hrf/cat.m new file mode 100644 index 00000000..f099d8fc --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/@fmri_hrf/cat.m @@ -0,0 +1,100 @@ +function obj = cat(varargin) +% cat - Concatenate fmri_hrf objects across (subject, run) axes. +% +% Usage +% ----- +% Hstudy = cat(1, Hb_sub01_run01, Hb_sub01_run02, ..., Hb_subN_runM) +% +% Stacks the underlying voxel .dat matrices along the volume axis and +% stacks each input's HRF metadata_table, tagging chunks with subject and +% run_label so the source axis is preserved. The output's other parent +% fields (volInfo, mask, etc.) are copied from the first input. +% +% v0 ignores the dimension argument and always concatenates along the +% volume axis. Voxel count must match across inputs. + +if nargin < 2 + error('fmri_hrf:cat:NotEnoughArgs', 'cat requires at least two arguments.'); +end + +if isnumeric(varargin{1}) && isscalar(varargin{1}) + dim = varargin{1}; + objs = varargin(2:end); + if dim ~= 1 + warning('fmri_hrf:cat:DimIgnored', ... + 'fmri_hrf/cat v0 only supports dim=1 (volume axis). Got dim=%d; treating as 1.', dim); + end +else + objs = varargin; +end + +objs = objs(~cellfun(@isempty, objs)); +if isempty(objs) + obj = fmri_hrf(); + return +end + +local_assert_alignable(objs); + +% Stack the .dat matrices along the volume axis. +base = objs{1}; +n_vox = size(base.dat, 1); +all_dat = base.dat; +for i = 2:numel(objs) + if size(objs{i}.dat, 1) ~= n_vox + error('fmri_hrf:cat:VoxelMismatch', ... + 'Cannot cat: input %d has %d voxels, expected %d.', ... + i, size(objs{i}.dat, 1), n_vox); + end + all_dat = [all_dat, objs{i}.dat]; %#ok +end + +% Build the stacked HRF metadata table, tagging chunks with subject + run_label. +meta_chunks = cell(numel(objs), 1); +for i = 1:numel(objs) + M = objs{i}.metadata_table; + if isempty(M), continue; end + M.subject = repmat(string(objs{i}.subject), height(M), 1); + M.run_label = repmat(string(objs{i}.run_label), height(M), 1); + meta_chunks{i} = M; +end +meta_chunks = meta_chunks(~cellfun(@isempty, meta_chunks)); +if isempty(meta_chunks) + stacked_meta = table(); +else + stacked_meta = vertcat(meta_chunks{:}); +end + +% Build the result by value-copying the first input and updating data + meta. +obj = base; +obj.dat = all_dat; +obj.metadata_table = stacked_meta; +% Reset image-axis flags to match new volume count. +n_vol = size(all_dat, 2); +if isprop(obj, 'removed_images') + obj.removed_images = false(n_vol, 1); +end +end + + +function local_assert_alignable(objs) +% All objects must share model_name, TR, conditions for concatenation to +% be meaningful. Subject and run_label can differ -- that's the point. +ref = objs{1}; +for i = 2:numel(objs) + if ~strcmp(objs{i}.model_name, ref.model_name) + error('fmri_hrf:cat:ModelMismatch', ... + 'Cannot concatenate fmri_hrf with different model_name: %s vs %s.', ... + ref.model_name, objs{i}.model_name); + end + if ~isequaln(objs{i}.TR, ref.TR) + error('fmri_hrf:cat:TRMismatch', ... + 'Cannot concatenate fmri_hrf with different TR: %g vs %g.', ... + ref.TR, objs{i}.TR); + end + if ~isequal(sort(cellstr(string(objs{i}.conditions))), sort(cellstr(string(ref.conditions)))) + error('fmri_hrf:cat:ConditionMismatch', ... + 'Cannot concatenate fmri_hrf with different condition sets.'); + end +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/@fmri_hrf/fmri_hrf.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/@fmri_hrf/fmri_hrf.m new file mode 100644 index 00000000..76bc3029 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/@fmri_hrf/fmri_hrf.m @@ -0,0 +1,202 @@ +% fmri_hrf: HRF amplitude/beta container, paired with statistic_hrf for inference. +% +% Subclass of fmri_data. Holds whole-brain HRF beta maps from a single fit +% (one subject, one run, one model) along with the metadata needed to +% interpret them: condition labels, lag indices, TR, design matrix. +% +% Multi-model studies use one fmri_hrf per (subject, run, model). Cross-model +% comparison (Phase 4) is handled by a separate hrf_modelset container, not +% by stuffing multiple models into one fmri_hrf object. +% +% Usage +% ----- +% Hb = fmri_hrf() % empty constructor +% Hb = fmri_hrf(fmri_data_obj, 'Name', Val, ...) % lift an fmri_data +% +% The multi-source dispatch (build from a wholebrain_by_model struct, a +% result.mat path, or a NIfTI prefix) lives in make_fmri_stat_hrf, which +% calls this constructor with an already-loaded fmri_data plus metadata. +% +% Required name-value pairs when constructing from an fmri_data: +% 'MetadataTable' - table with one row per 4D volume. Must include columns +% condition, lag_index, lag_seconds, image_label. +% 'Subject' - char/string, BIDS subject id. +% 'RunLabel' - char/string, BIDS task-run id. +% 'ModelName' - 'fir' | 'sfir' | 'canonical' | 'spline'. +% 'TR' - scalar seconds. +% +% Optional name-value pairs: +% 'Conditions' - cellstr of condition labels (derived from metadata if empty). +% 'DesignMatrix' - design matrix (volumes x regressors), for residuals(). +% 'DesignInfo' - struct with column labels / FIR layout. +% 'SourcePaths' - struct of file paths the maps came from. +% 'Timeseries' - optional struct array per signature/ROI. See note below. +% +% Inherited from fmri_data +% ------------------------ +% All voxel storage, masking, reading/writing, resample, etc. are inherited +% unchanged. Methods overridden on fmri_hrf: +% cat - aligns HRF metadata across (subject, run) before delegating +% to fmri_data/cat for the underlying voxel concatenation. +% +% Notes +% ----- +% .timeseries is an optional struct array storing 1D signal extractions +% (signature time series, ROI averages) that share the subject/run/condition +% metadata of the voxel data but have completely different storage (time x +% signatures rather than voxels x volumes). v0 only stores it; methods that +% operate on the .timeseries side will be added when needed. +% +% See also: statistic_hrf, make_fmri_stat_hrf, fmri_data, hrf_apply_maps_to_wholebrain. + +classdef fmri_hrf < fmri_data + properties + % NOTE: metadata_table is inherited from fmri_data — do NOT redefine + % it here or MATLAB raises a "property already defined in superclass" + % error at class construction. fmri_hrf populates the inherited + % metadata_table with the HRF-specific schema (volume_index, + % condition, lag_index, lag_seconds, image_label, ...). + hrf_meta_version = 1 + subject = '' + run_label = '' + model_name = '' + conditions = {} + TR = NaN + design_matrix = [] + design_info = struct() + source_paths = struct() + timeseries = [] + end + + methods + function obj = fmri_hrf(varargin) + % Always call the parent constructor. If we are lifting an + % existing fmri_data, call parent with no args and copy fields + % after; otherwise pass through. + lifting = nargin > 0 && (isa(varargin{1}, 'fmri_data') || isa(varargin{1}, 'image_vector')); + if lifting + parent_args = {}; + nv_args = varargin(2:end); + else + parent_args = varargin; + nv_args = {}; + end + + obj = obj@fmri_data(parent_args{:}); + + if lifting + src = varargin{1}; + obj = local_copy_parent_fields(obj, src); + end + + if ~isempty(nv_args) + obj = local_apply_hrf_metadata(obj, nv_args); + end + + if nargin > 0 + local_validate(obj); + end + end + + function disp(obj) + % HRF-aware display summary. + if isempty(obj.dat) + fprintf(' fmri_hrf (empty)\n'); + return + end + n_vox = size(obj.dat, 1); + n_vol = size(obj.dat, 2); + n_cond = numel(obj.conditions); + n_lag = NaN; + if ~isempty(obj.metadata_table) && any(strcmp('lag_index', obj.metadata_table.Properties.VariableNames)) + n_lag = numel(unique(obj.metadata_table.lag_index)); + end + fprintf(' fmri_hrf subject=%s run=%s model=%s\n', ... + local_disp(obj.subject), local_disp(obj.run_label), local_disp(obj.model_name)); + fprintf(' %d voxels x %d volumes (%d conditions x %d lags) TR=%.3gs\n', ... + n_vox, n_vol, n_cond, n_lag, obj.TR); + if ~isempty(obj.conditions) + fprintf(' conditions: %s\n', strjoin(cellstr(string(obj.conditions)), ', ')); + end + if ~isempty(obj.timeseries) + fprintf(' timeseries: %d entries attached\n', numel(obj.timeseries)); + end + end + end +end + + +% ========================================================================= +% Local helpers +% ========================================================================= +function obj = local_copy_parent_fields(obj, src) +% Copy data + metadata fields from src (fmri_data or image_vector) into obj. +% Skips properties that fmri_hrf defines itself; metadata_table is +% inherited from fmri_data so it IS copied through (and may be overridden +% later by a 'MetadataTable' name-value pair). +hrf_only = {'hrf_meta_version', 'subject', 'run_label', ... + 'model_name', 'conditions', 'TR', 'design_matrix', 'design_info', ... + 'source_paths', 'timeseries'}; + +src_props = properties(src); +for i = 1:numel(src_props) + f = src_props{i}; + if ismember(f, hrf_only), continue; end + if isprop(obj, f) + try + obj.(f) = src.(f); + catch + end + end +end +end + +function obj = local_apply_hrf_metadata(obj, nv_args) +p = inputParser; +p.KeepUnmatched = false; +p.addParameter('MetadataTable', table(), @(x) isempty(x) || istable(x)); +p.addParameter('Subject', '', @(x) ischar(x) || isstring(x)); +p.addParameter('RunLabel', '', @(x) ischar(x) || isstring(x)); +p.addParameter('ModelName', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Conditions', {}, @(x) iscell(x) || isstring(x)); +p.addParameter('TR', NaN, @(x) isnumeric(x) && isscalar(x)); +p.addParameter('DesignMatrix', [], @(x) isnumeric(x) || isempty(x)); +p.addParameter('DesignInfo', struct(), @isstruct); +p.addParameter('SourcePaths', struct(), @isstruct); +p.addParameter('Timeseries', [], @(x) isempty(x) || isstruct(x)); +p.parse(nv_args{:}); +opts = p.Results; + +if ~isempty(opts.MetadataTable), obj.metadata_table = opts.MetadataTable; end +if ~isempty(char(opts.Subject)), obj.subject = char(opts.Subject); end +if ~isempty(char(opts.RunLabel)), obj.run_label = char(opts.RunLabel); end +if ~isempty(char(opts.ModelName)), obj.model_name = char(opts.ModelName); end +if ~isempty(opts.Conditions) + obj.conditions = reshape(cellstr(string(opts.Conditions)), 1, []); +elseif ~isempty(obj.metadata_table) && any(strcmp('condition', obj.metadata_table.Properties.VariableNames)) + obj.conditions = reshape(unique(cellstr(string(obj.metadata_table.condition)), 'stable'), 1, []); +end +if ~isnan(opts.TR), obj.TR = opts.TR; end +if ~isempty(opts.DesignMatrix), obj.design_matrix = opts.DesignMatrix; end +if ~isempty(fieldnames(opts.DesignInfo)), obj.design_info = opts.DesignInfo; end +if ~isempty(fieldnames(opts.SourcePaths)), obj.source_paths = opts.SourcePaths; end +if ~isempty(opts.Timeseries), obj.timeseries = opts.Timeseries; end +end + +function local_validate(obj) +if isempty(obj.dat), return; end +n_vol = size(obj.dat, 2); +if ~isempty(obj.metadata_table) && height(obj.metadata_table) ~= n_vol + error('fmri_hrf:MetadataVolumeMismatch', ... + 'metadata_table has %d rows but underlying fmri_data has %d volumes.', ... + height(obj.metadata_table), n_vol); +end +end + +function s = local_disp(x) +if isempty(x) + s = ''; +else + s = char(x); +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/@fmri_hrf/to_statistic_hrf.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/@fmri_hrf/to_statistic_hrf.m new file mode 100644 index 00000000..53925939 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/@fmri_hrf/to_statistic_hrf.m @@ -0,0 +1,82 @@ +function Ht = to_statistic_hrf(Hb, varargin) +% to_statistic_hrf - Build a paired statistic_hrf from an fmri_hrf. +% +% Usage +% ----- +% Ht = to_statistic_hrf(Hb, 'SE', se_obj) +% Ht = to_statistic_hrf(Hb, 'TStat', t_obj) +% +% Inputs +% ------ +% Hb fmri_hrf, holding beta values. +% 'SE' statistic_image (or fmri_data) holding standard errors aligned +% with Hb. If provided, t = beta ./ se is computed; the resulting +% statistic_hrf carries that t in .dat. +% 'TStat' already-computed t-statistic image. If provided, used directly. +% +% Returns a statistic_hrf carrying the same HRF metadata as Hb. + +p = inputParser; +p.addParameter('SE', [], @(x) isempty(x) || isa(x, 'image_vector')); +p.addParameter('TStat', [], @(x) isempty(x) || isa(x, 'image_vector')); +p.parse(varargin{:}); +opts = p.Results; + +if isempty(opts.TStat) && isempty(opts.SE) + error('fmri_hrf:to_statistic_hrf:NoStat', ... + 'Provide either an SE image (to derive t from beta/SE) or a TStat image.'); +end + +% Build a statistic_image with the t-value data, lifting the image_vector +% layout (voxel space, removed_voxels, etc.) from Hb. We do this by hand +% rather than via statistic_image(fmri_data(Hb)), because fmri_data's +% constructor expects an image filename as its first arg and would +% misinterpret an existing object. +if ~isempty(opts.TStat) + t_obj = opts.TStat; +else + se = opts.SE; + if ~isequal(size(Hb.dat), size(se.dat)) + error('fmri_hrf:to_statistic_hrf:ShapeMismatch', ... + 'SE shape %s does not match beta shape %s.', ... + mat2str(size(se.dat)), mat2str(size(Hb.dat))); + end + t_dat = Hb.dat ./ se.dat; + t_obj = local_build_t_image(Hb, t_dat); +end + +Ht = statistic_hrf(t_obj, ... + 'MetadataTable', Hb.metadata_table, ... + 'Subject', Hb.subject, ... + 'RunLabel', Hb.run_label, ... + 'ModelName', Hb.model_name, ... + 'Conditions', Hb.conditions, ... + 'TR', Hb.TR, ... + 'DesignMatrix', Hb.design_matrix, ... + 'DesignInfo', Hb.design_info, ... + 'SourcePaths', Hb.source_paths); +end + + +function si = local_build_t_image(Hb, t_dat) +% Construct a statistic_image with the t-values, copying the voxel-space +% layout from Hb (an fmri_hrf, which inherits its image_vector fields). +si = statistic_image(); +shared = {'dat', 'volInfo', 'removed_voxels', 'removed_images', ... + 'space_defining_image_name', 'fullpath', 'files_exist', 'history', ... + 'image_names', 'source_notes'}; +for i = 1:numel(shared) + f = shared{i}; + if isprop(si, f) && isprop(Hb, f) + try + si.(f) = Hb.(f); + catch + end + end +end +si.dat = t_dat; +si.type = 'T'; +if isprop(si, 'sig') && isempty(si.sig) + si.sig = true(size(si.dat)); +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/@statistic_hrf/cat.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/@statistic_hrf/cat.m new file mode 100644 index 00000000..f5047883 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/@statistic_hrf/cat.m @@ -0,0 +1,86 @@ +function obj = cat(varargin) +% cat - Concatenate statistic_hrf objects across (subject, run) axes. +% +% Mirrors fmri_hrf/cat: stacks .dat along the volume axis and stacks each +% input's HRF metadata_table with subject/run_label columns added. Other +% parent fields (volInfo, etc.) are copied from the first input. + +if nargin < 2 + error('statistic_hrf:cat:NotEnoughArgs', 'cat requires at least two arguments.'); +end + +if isnumeric(varargin{1}) && isscalar(varargin{1}) + dim = varargin{1}; + objs = varargin(2:end); + if dim ~= 1 + warning('statistic_hrf:cat:DimIgnored', ... + 'statistic_hrf/cat v0 only supports dim=1. Got dim=%d; treating as 1.', dim); + end +else + objs = varargin; +end + +objs = objs(~cellfun(@isempty, objs)); +if isempty(objs) + obj = statistic_hrf(); + return +end + +local_assert_alignable(objs); + +base = objs{1}; +n_vox = size(base.dat, 1); +all_dat = base.dat; +for i = 2:numel(objs) + if size(objs{i}.dat, 1) ~= n_vox + error('statistic_hrf:cat:VoxelMismatch', ... + 'Cannot cat: input %d has %d voxels, expected %d.', ... + i, size(objs{i}.dat, 1), n_vox); + end + all_dat = [all_dat, objs{i}.dat]; %#ok +end + +meta_chunks = cell(numel(objs), 1); +for i = 1:numel(objs) + M = objs{i}.metadata_table; + if isempty(M), continue; end + M.subject = repmat(string(objs{i}.subject), height(M), 1); + M.run_label = repmat(string(objs{i}.run_label), height(M), 1); + meta_chunks{i} = M; +end +meta_chunks = meta_chunks(~cellfun(@isempty, meta_chunks)); +if isempty(meta_chunks) + stacked_meta = table(); +else + stacked_meta = vertcat(meta_chunks{:}); +end + +obj = base; +obj.dat = all_dat; +obj.metadata_table = stacked_meta; +n_vol = size(all_dat, 2); +if isprop(obj, 'removed_images') + obj.removed_images = false(n_vol, 1); +end +end + + +function local_assert_alignable(objs) +ref = objs{1}; +for i = 2:numel(objs) + if ~strcmp(objs{i}.model_name, ref.model_name) + error('statistic_hrf:cat:ModelMismatch', ... + 'Cannot concatenate statistic_hrf with different model_name: %s vs %s.', ... + ref.model_name, objs{i}.model_name); + end + if ~isequaln(objs{i}.TR, ref.TR) + error('statistic_hrf:cat:TRMismatch', ... + 'Cannot concatenate statistic_hrf with different TR: %g vs %g.', ... + ref.TR, objs{i}.TR); + end + if ~isequal(sort(cellstr(string(objs{i}.conditions))), sort(cellstr(string(ref.conditions)))) + error('statistic_hrf:cat:ConditionMismatch', ... + 'Cannot concatenate statistic_hrf with different condition sets.'); + end +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/@statistic_hrf/statistic_hrf.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/@statistic_hrf/statistic_hrf.m new file mode 100644 index 00000000..f5c8791f --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/@statistic_hrf/statistic_hrf.m @@ -0,0 +1,155 @@ +% statistic_hrf: HRF inferential container (t / p / SE), paired with fmri_hrf. +% +% Subclass of statistic_image. Holds whole-brain HRF t-maps (or p, SE) from +% a single fit with the metadata to interpret them: condition labels, lag +% indices, TR. All threshold/multi_threshold/orthviews/convert2mask methods +% are inherited from statistic_image and work unchanged. +% +% Multi-model studies use one statistic_hrf per (subject, run, model), +% paired with the matching fmri_hrf. See make_fmri_stat_hrf for the +% constructor that builds them in lockstep. +% +% Usage +% ----- +% Ht = statistic_hrf() % empty +% Ht = statistic_hrf(statistic_image_obj, 'Name', Val, ...) % lift +% +% Required name-value pairs (same as fmri_hrf): MetadataTable, Subject, +% RunLabel, ModelName, TR. Optional: Conditions, DesignMatrix, DesignInfo, +% SourcePaths. +% +% See also: fmri_hrf, make_fmri_stat_hrf, statistic_image, threshold. + +classdef statistic_hrf < statistic_image + properties + hrf_meta_version = 1 + metadata_table = table() + subject = '' + run_label = '' + model_name = '' + conditions = {} + TR = NaN + design_matrix = [] + design_info = struct() + source_paths = struct() + end + + methods + function obj = statistic_hrf(varargin) + lifting = nargin > 0 && (isa(varargin{1}, 'statistic_image') || isa(varargin{1}, 'image_vector') || isa(varargin{1}, 'fmri_data')); + if lifting + parent_args = {}; + nv_args = varargin(2:end); + else + parent_args = varargin; + nv_args = {}; + end + + obj = obj@statistic_image(parent_args{:}); + + if lifting + src = varargin{1}; + obj = local_copy_parent_fields(obj, src); + end + + if ~isempty(nv_args) + obj = local_apply_hrf_metadata(obj, nv_args); + end + + if nargin > 0 + local_validate(obj); + end + end + + function disp(obj) + if isempty(obj.dat) + fprintf(' statistic_hrf (empty)\n'); + return + end + n_vox = size(obj.dat, 1); + n_vol = size(obj.dat, 2); + n_cond = numel(obj.conditions); + n_lag = NaN; + if ~isempty(obj.metadata_table) && any(strcmp('lag_index', obj.metadata_table.Properties.VariableNames)) + n_lag = numel(unique(obj.metadata_table.lag_index)); + end + fprintf(' statistic_hrf subject=%s run=%s model=%s type=%s\n', ... + local_disp(obj.subject), local_disp(obj.run_label), local_disp(obj.model_name), local_disp(obj.type)); + fprintf(' %d voxels x %d volumes (%d conditions x %d lags) TR=%.3gs\n', ... + n_vox, n_vol, n_cond, n_lag, obj.TR); + if ~isempty(obj.conditions) + fprintf(' conditions: %s\n', strjoin(cellstr(string(obj.conditions)), ', ')); + end + end + end +end + + +% ========================================================================= +% Local helpers +% ========================================================================= +function obj = local_copy_parent_fields(obj, src) +hrf_only = {'hrf_meta_version', 'metadata_table', 'subject', 'run_label', ... + 'model_name', 'conditions', 'TR', 'design_matrix', 'design_info', ... + 'source_paths'}; + +src_props = properties(src); +for i = 1:numel(src_props) + f = src_props{i}; + if ismember(f, hrf_only), continue; end + if isprop(obj, f) + try + obj.(f) = src.(f); + catch + end + end +end +end + +function obj = local_apply_hrf_metadata(obj, nv_args) +p = inputParser; +p.KeepUnmatched = false; +p.addParameter('MetadataTable', table(), @(x) isempty(x) || istable(x)); +p.addParameter('Subject', '', @(x) ischar(x) || isstring(x)); +p.addParameter('RunLabel', '', @(x) ischar(x) || isstring(x)); +p.addParameter('ModelName', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Conditions', {}, @(x) iscell(x) || isstring(x)); +p.addParameter('TR', NaN, @(x) isnumeric(x) && isscalar(x)); +p.addParameter('DesignMatrix', [], @(x) isnumeric(x) || isempty(x)); +p.addParameter('DesignInfo', struct(), @isstruct); +p.addParameter('SourcePaths', struct(), @isstruct); +p.parse(nv_args{:}); +opts = p.Results; + +if ~isempty(opts.MetadataTable), obj.metadata_table = opts.MetadataTable; end +if ~isempty(char(opts.Subject)), obj.subject = char(opts.Subject); end +if ~isempty(char(opts.RunLabel)), obj.run_label = char(opts.RunLabel); end +if ~isempty(char(opts.ModelName)), obj.model_name = char(opts.ModelName); end +if ~isempty(opts.Conditions) + obj.conditions = reshape(cellstr(string(opts.Conditions)), 1, []); +elseif ~isempty(obj.metadata_table) && any(strcmp('condition', obj.metadata_table.Properties.VariableNames)) + obj.conditions = reshape(unique(cellstr(string(obj.metadata_table.condition)), 'stable'), 1, []); +end +if ~isnan(opts.TR), obj.TR = opts.TR; end +if ~isempty(opts.DesignMatrix), obj.design_matrix = opts.DesignMatrix; end +if ~isempty(fieldnames(opts.DesignInfo)), obj.design_info = opts.DesignInfo; end +if ~isempty(fieldnames(opts.SourcePaths)), obj.source_paths = opts.SourcePaths; end +end + +function local_validate(obj) +if isempty(obj.dat), return; end +n_vol = size(obj.dat, 2); +if ~isempty(obj.metadata_table) && height(obj.metadata_table) ~= n_vol + error('statistic_hrf:MetadataVolumeMismatch', ... + 'metadata_table has %d rows but underlying statistic_image has %d volumes.', ... + height(obj.metadata_table), n_vol); +end +end + +function s = local_disp(x) +if isempty(x) + s = ''; +else + s = char(x); +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/EstHRF_inAtlas Tutorial.mlx b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/EstHRF_inAtlas Tutorial.mlx new file mode 100644 index 00000000..d0f9051c Binary files /dev/null and b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/EstHRF_inAtlas Tutorial.mlx differ diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/EstimateHRF_inAtlas.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/EstimateHRF_inAtlas.m new file mode 100644 index 00000000..e830fb2b --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/EstimateHRF_inAtlas.m @@ -0,0 +1,724 @@ +function [tc, HRF, HRF_OBJ, PARAM_OBJ]=EstimateHRF_inAtlas(fmri_d, PREPROC_PARAMS, HRF_PARAMS, at, rois, outfile) + % EstimateHRF_inAtlas takes a raw 4D fmri_data object, preprocesses it, and + % then outputs an estimated HRF time series for each condition of interest. + % PREPROC struct needs to have TR, hpf, and Condition information + % HRF struct is a structure of HRF fitting parameters. It needs to have T, FWHM, alpha, and type, otherwise default + % values will be supplied. + % + % Michael Sun, Ph.D. + % - Takes 4D fmri_data() object or cell-array of fmri_data() + % - PREPROC_PARAMS: struct object that contains the fields: TR, R, hpf, and smooth + % - HRF_PARAMS: struct object that contains the fields: Condition, CondNames, TR, T, FWHM, alpha, and types. + % - at: atlas object + % - rois: cell-array of labels that match labels in at.labels + % - outfile: Desired filepath without extension. Extensions will be appended. + % + % *Usage: + % :: + % [tc, HRF] = EstimateHRF_inAtlas(image_obj, PREPROC_PARAMS, HRF_PARAMS, at, rois, outfile}) + % + % TODO: Pass in estHRF directory to reuse nifti files. + + % Step 0. Check if a directory exists that has estHRF .nii outputs + % already. + + % Step 0. Load in an SPM.mat file if available to pass in metadata and + % such + + + % Check if its an SPM struct or filepath: + if ischar(fmri_d) + if contains(fmri_d, 'SPM.mat') + load(fmri_d); + if exist('SPM', 'var') + isSPM=true; + end + + else + fmri_d=fmri_data(fmri_d); + end + end + + if isstruct(fmri_d) + SPM=fmri_d; + isSPM=true; + end + + if ~exist('isSPM') + isSPM = false; + end + + if isSPM + % if isstruct(varargin{1}) + % SPM=varargin{1}; + % else + % % If pointing to an SPM filepath + % load(varargin{1}) + % end + % % Don't do that, spmify instead into gKWY (grand-mean-scaled, filtered (K), and whitened (W)) + % gKWY=spmify(fmri_d, SPM); + % + % for d=1:numel(gKWY) + % preproc_dat{d}=fmri_d{d}; + % preproc_dat{d}.dat=gKWY{d}; + % end + [tc, HRF, HRF_OBJ, PARAM_OBJ]=roiTS_fitHRF_SPM(SPM, HRF_PARAMS, rois, at, outfile); + + else + % Step 1. Preprocess + if numel(fmri_d) == 1 + preproc_dat=canlab_connectivity_preproc(fmri_d, PREPROC_PARAMS.R, 'hpf', PREPROC_PARAMS.hpf, PREPROC_PARAMS.TR, 'average_over', 'no_plots'); + % Step 2. Smooth + preproc_dat=preprocess(preproc_dat, 'smooth', PREPROC_PARAMS.smooth); + else + + for d=1:numel(fmri_d) + [preproc_dat{d}]=canlab_connectivity_preproc(fmri_d, PREPROC_PARAMS.R{d}, 'hpf', PREPROC_PARAMS.hpf, PREPROC_PARAMS.TR, 'average_over', 'no_plots'); + % Step 2. Smooth + preproc_dat{d}=preprocess(preproc_dat{d}, 'smooth', PREPROC_PARAMS.smooth); + end + + end + + % Step 3. fitHRF to each ROI's worth of data + [tc, HRF]=roiTS_fitHRF(preproc_dat, HRF_PARAMS, rois, at, outfile); + HRF_OBJ = []; + PARAM_OBJ = []; + end + + + if numel(HRF)>1 + for i=1:numel(HRF) + HRF{i}.preproc_params=PREPROC_PARAMS; + end + else + HRF.preproc_params=PREPROC_PARAMS; + end + + + % Step 4. Save your Results + try + % saveas(gcf, [outfile, '.png']) + save([outfile, '.mat'], 'tc', 'HRF', '-v7.3'); + catch + warning([outfile, ' files could not be saved.']); + end +end + + +%% HELPER FUNCTIONS +% function [HRF_OBJ, HRF]=gen_HRFimg(preproc_dat, HRF_PARAMS, rois, at, outfile) +% +% % This is finished but untested. Intention is to generate a directory from +% % HRF images in BIDS format. +% +% % Make directories for files if needed +% if ~isempty(fileparts(outfile)) +% if ~exist(fileparts(outfile), 'dir') +% mkdir(fileparts(outfile)); +% end +% end +% +% % Make sure CondNames are valid before continuing: +% HRF_PARAMS.CondNames=matlab.lang.makeValidName(HRF_PARAMS.CondNames); +% +% HRF.atlas=at; +% HRF.region=rois; +% HRF.types=HRF_PARAMS.types; +% HRF.name=preproc_dat.image_names; +% +% % Initialize the parallel pool if it's not already running +% if isempty(gcp('nocreate')) +% parpool; +% end +% +% % Write out the images for later post-analyses +% % [~, fname, ~]=fileparts(preproc_dat.image_names); +% % fname=outfile +% +% parfor t=1:numel(HRF_PARAMS.types) +% % for t=1:numel(HRF_PARAMS.types) % FOR TROUBLESHOOTING +% warning('off', 'all'); +% switch HRF_PARAMS.types{t} +% case 'IL' +% [~, ~, PARAM_OBJ, HRF_OBJ] = hrf_fit(preproc_dat, HRF_PARAMS.TR, HRF_PARAMS.Condition, HRF_PARAMS.T, HRF_PARAMS.types{t}, 0); +% +% case 'FIR' +% [~, ~, PARAM_OBJ, HRF_OBJ] = hrf_fit(preproc_dat, HRF_PARAMS.TR, HRF_PARAMS.Condition, HRF_PARAMS.T, HRF_PARAMS.types{t}, 1); +% +% case 'CHRF' +% [~, ~, PARAM_OBJ, HRF_OBJ] = hrf_fit(preproc_dat, HRF_PARAMS.TR, HRF_PARAMS.Condition, HRF_PARAMS.T, HRF_PARAMS.types{t}, 2); +% otherwise +% error('No valid fit-type. Choose IL, FIR, or CHRF') +% end +% +% for c=1:numel(HRF_PARAMS.Condition) +% +% HRF_OBJ{c}.fullpath=sprintf([outfile, '_type-', HRF_PARAMS.types{t}, '_condition-', HRF_PARAMS.CondNames{c}, '_fit.nii']); +% PARAM_OBJ{c}.fullpath=sprintf([outfile, '_type-', HRF_PARAMS.types{t}, '_condition-', HRF_PARAMS.CondNames{c}, '_params.nii']); +% try +% write(HRF_OBJ{c}, 'overwrite'); +% write(PARAM_OBJ{c}, 'overwrite'); +% catch +% warning('Not able to write one or more files.'); +% end +% +% end +% +% end +% end + + +% function [tc, HRF]=gen_HRFstruct_from_dir(preproc_dat, HRF_PARAMS, rois, at, outfile, estHRF_dir, HRF_OBJ) +% +% % This is unfinished. Intention is to take a generated directory from +% % gen_HRFimg and generate an HRF structure from it. +% +% % Initialize the parallel pool if it's not already running +% if isempty(gcp('nocreate')) +% parpool; +% end +% +% % Initialize 'tc' and 'temp_HRF_fit' cell arrays +% tc = cell(1, numel(HRF_PARAMS.types)); +% temp_HRF_fit = cell(1, numel(HRF_PARAMS.types)); +% +% parfor t=1:numel(HRF_PARAMS.types) +% +% HRF_local = cell(1, numel(rois)); +% tc_local = cell(1, numel(rois)); +% +% +% % Consider doing apply_parcellation instead of mean(apply_mask(HRF_OBJ{c}, at.select_atlas_subset(rois(r), 'exact')).dat); +% % [parcel_means, parcel_pattern_expression, parcel_valence, rmsv_pos, rmsv_neg] = apply_parcellation(dat,at); +% % nps=load_image_set('npsplus'); +% % nps = get_wh_image(nps,1); +% % [parcel_means, parcel_pattern_expression, parcel_valence, rmsv_pos, rmsv_neg] = apply_parcellation(dat,at, 'pattern_expression', nps); +% % r=region(at,'unique_mask_values'); +% % wh_parcels=~all(isnan(parcel_means)) +% +% for r=1:numel(rois) +% tic +% for c=1:numel(HRF_PARAMS.CondNames) +% +% try +% tc_local{r}{c}=mean(apply_mask(HRF_OBJ{c}, at.select_atlas_subset(rois(r), 'exact')).dat); +% +% HRF_local{r}.(HRF_PARAMS.CondNames{c}).model=tc_local{r}{c}; +% [HRF_local{r}.(HRF_PARAMS.CondNames{c}).peaks, HRF_local{r}.(HRF_PARAMS.CondNames{c}).troughs]=detectPeaksTroughs(tc_local{r}{c}', false); +% +% [~, regionVoxNum, ~, ~]=at.select_atlas_subset(rois(r), 'exact').get_region_volumes; +% HRF_local{r}.(HRF_PARAMS.CondNames{c}).model_voxnormed=tc_local{r}{c}/regionVoxNum; +% [HRF_local{r}.(HRF_PARAMS.CondNames{c}).peaks_voxnormed, HRF_local{r}.(HRF_PARAMS.CondNames{c}).troughs_voxnormed]=detectPeaksTroughs(tc_local{r}{c}'/regionVoxNum, false); +% catch +% disp(t); +% disp(c); +% disp(rois{r}); +% tc_local{r}{c} +% mean(apply_mask(HRF_OBJ{c}, at.select_atlas_subset(rois(r), 'exact')).dat) +% {apply_mask(HRF_OBJ{c}, at.select_atlas_subset(rois(r), 'exact')).dat} +% HRF_OBJ{c} +% +% end +% +% % Number of phases +% start_times = [HRF_local{r}.(HRF_PARAMS.CondNames{c}).peaks.start_time, HRF_local{r}.(HRF_PARAMS.CondNames{c}).troughs.start_time]; +% end_times = [HRF_local{r}.(HRF_PARAMS.CondNames{c}).peaks.end_time, HRF_local{r}.(HRF_PARAMS.CondNames{c}).troughs.end_time]; +% phases = [start_times(:), end_times(:)]; +% unique_phases = unique(phases, 'rows'); +% HRF_local{r}.(HRF_PARAMS.CondNames{c}).phases = mat2cell(unique_phases, ones(size(unique_phases, 1), 1), 2); +% +% % Loop over phases +% for p = 1:numel(HRF_local{r}.(HRF_PARAMS.CondNames{c}).phases) +% features = {'peaks', 'troughs'}; +% for f = 1:2 +% feat = features{f}; +% +% % Count the number of features (peaks or troughs) +% start_times = cell2mat({HRF_local{r}.(HRF_PARAMS.CondNames{c}).(feat).start_time}); +% current_phase_start = HRF_local{r}.(HRF_PARAMS.CondNames{c}).phases{p}(1); +% HRF_local{r}.(HRF_PARAMS.CondNames{c}).phase(p).(feat) = sum(start_times == current_phase_start); +% +% if HRF_local{r}.(HRF_PARAMS.CondNames{c}).phase(p).(feat) > 0 +% display(['Estimating ' , num2str(t), '_', rois{r}, '_', HRF_PARAMS.CondNames{c}, '_', ' Now...!']); +% display(['Phase ' , num2str(p), 'Feature ', feat]); +% idx = find(start_times == current_phase_start); +% auc = cell2mat({HRF_local{r}.(HRF_PARAMS.CondNames{c}).(feat).AUC}); +% height = cell2mat({HRF_local{r}.(HRF_PARAMS.CondNames{c}).(feat).height}); +% time_to_peak = cell2mat({HRF_local{r}.(HRF_PARAMS.CondNames{c}).(feat).time_to_peak}); +% half_height = cell2mat({HRF_local{r}.(HRF_PARAMS.CondNames{c}).(feat).half_height}); +% +% feat_voxnormed = strcat(feat, '_voxnormed'); +% auc_voxnormed = cell2mat({HRF_local{r}.(HRF_PARAMS.CondNames{c}).(feat_voxnormed).AUC}); +% height_voxnormed = cell2mat({HRF_local{r}.(HRF_PARAMS.CondNames{c}).(feat_voxnormed).height}); +% time_to_peak_voxnormed = cell2mat({HRF_local{r}.(HRF_PARAMS.CondNames{c}).(feat_voxnormed).time_to_peak}); +% half_height_voxnormed = cell2mat({HRF_local{r}.(HRF_PARAMS.CondNames{c}).(feat_voxnormed).half_height}); +% +% HRF_local{r}.(HRF_PARAMS.CondNames{c}).phase(p).auc = unique(auc(idx)); +% HRF_local{r}.(HRF_PARAMS.CondNames{c}).phase(p).auc_voxnormed = unique(auc_voxnormed(idx)); +% +% HRF_local{r}.(HRF_PARAMS.CondNames{c}).phase(p).height = height(idx); +% HRF_local{r}.(HRF_PARAMS.CondNames{c}).phase(p).height_voxnormed = height_voxnormed(idx); +% HRF_local{r}.(HRF_PARAMS.CondNames{c}).phase(p).time_to_peak = time_to_peak(idx); +% HRF_local{r}.(HRF_PARAMS.CondNames{c}).phase(p).time_to_peak_voxnormed = time_to_peak_voxnormed(idx); +% HRF_local{r}.(HRF_PARAMS.CondNames{c}).phase(p).half_height = half_height(idx); +% HRF_local{r}.(HRF_PARAMS.CondNames{c}).phase(p).half_height_voxnormed = half_height_voxnormed(idx); +% end +% end +% end +% end +% display(strjoin({num2str(t), ' Done in ', num2str(toc), ' seconds with ', rois{r}})); +% +% end +% % Save the results for this ROI +% display([num2str(t), ' Done!']) +% temp_HRF_fit{t} = HRF_local; +% tc{t} = tc_local; +% end +% +% % Transfer the results from the temporary cell array to the HRF structure +% HRF.fit = temp_HRF_fit; +% HRF.params=HRF_PARAMS; +% +% delete(gcp('nocreate')); +% +% +% end + + +function [tc, HRF] = roiTS_fitHRF(preproc_dat, HRF_PARAMS, rois, at, outfile, varargin) + + if ~isempty(fileparts(outfile)) + disp(fileparts(outfile)); + if ~exist(fileparts(outfile), 'dir') + mkdir(fileparts(outfile)); + end + end + + HRF_PARAMS.CondNames = matlab.lang.makeValidName(HRF_PARAMS.CondNames); + + HRF.atlas = at; + HRF.region = rois; + HRF.types = HRF_PARAMS.types; + + % Force data into cell form for consistent indexing + if ~iscell(preproc_dat) + preproc_dat = {preproc_dat}; + end + + nData = numel(preproc_dat); + nTypes = numel(HRF_PARAMS.types); + nCond = numel(HRF_PARAMS.CondNames); + + HRF.name = cell(1, nData); + tc = cell(nData, nTypes); + HRF.fit = cell(nData, nTypes); + HRF_OBJ = cell(nData, nTypes); + PARAM_OBJ = cell(nData, nTypes); + + for d = 1:nData + HRF.name{d} = preproc_dat{d}.image_names; + end + + if numel(HRF_PARAMS.T) == 1 + HRF_PARAMS.T = repmat(HRF_PARAMS.T, 1, nCond); + end + + + if isempty(gcp('nocreate')) + parpool; + end + + parfor d = 1:nData + % for d = 1:nData + + data = preproc_dat{d}; + + local_HRF_OBJ = cell(1, nTypes); + local_PARAM_OBJ = cell(1, nTypes); + + for t = 1:nTypes + + warning('off', 'all'); + + switch HRF_PARAMS.types{t} + case 'IL' + [~, ~, local_PARAM_OBJ{t}, local_HRF_OBJ{t}] = ... + hrf_fit(data, HRF_PARAMS.TR, HRF_PARAMS.Condition, HRF_PARAMS.T, 'IL', 0); + + case 'FIR' + [~, ~, local_PARAM_OBJ{t}, local_HRF_OBJ{t}] = ... + hrf_fit(data, HRF_PARAMS.TR, HRF_PARAMS.Condition, HRF_PARAMS.T, 'FIR', 0); + + case 'sFIR' + [~, ~, local_PARAM_OBJ{t}, local_HRF_OBJ{t}] = ... + hrf_fit(data, HRF_PARAMS.TR, HRF_PARAMS.Condition, HRF_PARAMS.T, 'FIR', 1); + + case 'CHRF0' + [~, ~, local_PARAM_OBJ{t}, local_HRF_OBJ{t}] = ... + hrf_fit(data, HRF_PARAMS.TR, HRF_PARAMS.Condition, HRF_PARAMS.T, 'CHRF', 0); + + case 'CHRF1' + [~, ~, local_PARAM_OBJ{t}, local_HRF_OBJ{t}] = ... + hrf_fit(data, HRF_PARAMS.TR, HRF_PARAMS.Condition, HRF_PARAMS.T, 'CHRF', 1); + + case 'CHRF2' + [~, ~, local_PARAM_OBJ{t}, local_HRF_OBJ{t}] = ... + hrf_fit(data, HRF_PARAMS.TR, HRF_PARAMS.Condition, HRF_PARAMS.T, 'CHRF', 2); + + otherwise + error('No valid fit-type. Choose IL, FIR/sFIR or CHRF0/CHRF1/CHRF2'); + end + + % write each condition-specific image for this type + for c = 1:nCond + local_HRF_OBJ{t}{c}.fullpath = [outfile ... + '_type-' HRF_PARAMS.types{t} ... + '_condition-' HRF_PARAMS.CondNames{c} '_fit.nii']; + + local_PARAM_OBJ{t}{c}.fullpath = [outfile ... + '_type-' HRF_PARAMS.types{t} ... + '_condition-' HRF_PARAMS.CondNames{c} '_params.nii']; + + try + write(local_HRF_OBJ{t}{c}, 'overwrite'); + write(local_PARAM_OBJ{t}{c}, 'overwrite'); + catch + warning('Not able to write one or more files.'); + end + end + end + + HRF_OBJ(d, :) = local_HRF_OBJ; + PARAM_OBJ(d, :) = local_PARAM_OBJ; + end + + % Extract ROI HRFs: one entry per data x type, with ALL conditions + for d = 1:nData + for t = 1:nTypes + [HRF.fit{d,t}, tc{d,t}] = extractHRF( ... + HRF_OBJ{d,t}, ... + HRF_PARAMS.CondNames, ... + 'atlas', at, ... + 'regions', rois); + end + end + + HRF.CondNames = HRF_PARAMS.CondNames; + + delete(gcp('nocreate')); +end + + + + +% function [tc, HRF]=roiTS_fitHRF(preproc_dat, HRF_PARAMS, rois, at, outfile, varargin) +% +% % Make directories for files if needed +% if ~isempty(fileparts(outfile)) +% disp(fileparts(outfile)); +% if ~exist(fileparts(outfile), 'dir') +% mkdir(fileparts(outfile)); +% end +% end +% +% % Make sure CondNames are valid before continuing: +% HRF.atlas=at; +% HRF.region=rois; +% HRF.types=HRF_PARAMS.types; +% +% if iscell(preproc_dat) +% for c=1:numel(preproc_dat) +% HRF.name{c}=preproc_dat{c}.image_names; +% +% % Initialize 'tc' and 'temp_HRF_fit' cell arrays +% tc{c} = cell(1, numel(HRF_PARAMS.types)); +% temp_HRF_fit{c} = cell(1, numel(HRF_PARAMS.types)); +% end +% else +% HRF.name=preproc_dat.image_names; +% +% % Initialize 'tc' and 'temp_HRF_fit' cell arrays +% tc = cell(1, numel(HRF_PARAMS.types)); +% temp_HRF_fit = cell(1, numel(HRF_PARAMS.types)); +% end +% +% % Carve up SPM's design matrix for each image +% % if ~isempty(varargin) +% % if ischar(varargin{1}) || isstring(varargin{1}) +% % load(varargin{1}); +% % elseif isstruct(varargin{1}) +% % SPM=varargin{1}; +% % end +% +% +% % DX=cell(1,numel(SPM.nscan)); +% % if numel(preproc_dat) == numel(SPM.nscan) +% % for d=1:numel(preproc_dat) +% % % Check if the SPM file accounts for the same number of scans +% % +% % % HRF_PARAMS.CondNames +% % % Use regexp to search for the pattern +% % % hot_matches = ~cellfun(@isempty, regexp(SPM.xX.name, ['Sn\(', num2str(i), '\).*_heat_start'])); +% % % warm_matches = ~cellfun(@isempty, regexp(SPM.xX.name, ['Sn\(', num2str(i), '\).*_warm_start'])); +% % % imgcue_matches = ~cellfun(@isempty, regexp(SPM.xX.name, ['Sn\(', num2str(i), '\).*_imagine_cue'])); +% % % imag_matches = ~cellfun(@isempty, regexp(SPM.xX.name, ['Sn\(', num2str(i), '\).*_imagine_start'])); +% % +% % R_matches = ~cellfun(@isempty, regexp(SPM.xX.name, ['Sn\(', num2str(d), '\).*R.*'])); +% % constant_matches = ~cellfun(@isempty, regexp(SPM.xX.name, ['Sn\(', num2str(d), '\).*constant'])); +% % sess_cols = ~cellfun(@isempty, regexp(SPM.xX.name, ['Sn\(', num2str(d), '\).*'])); +% % +% % +% % % Find indices of matches +% % indices = [find(sess_cols)]; +% % % indices = [find(hot_matches) find(warm_matches) find(imgcue_matches) find(imag_matches)]; +% % % indices = [find(R_matches) find(constant_matches)]; +% % task_regressors{d} = [find(sess_cols & (~R_matches & ~constant_matches))]; +% % +% % % Each design matrix needs task regressors, covariates, and +% % % intercept +% % DX{d}=SPM.xX.xKXs.X(SPM.Sess(d).row, indices); +% % +% % end +% % customDX=1; +% % end +% % +% % +% % end +% +% HRF_PARAMS.CondNames=matlab.lang.makeValidName(HRF_PARAMS.CondNames); +% +% +% +% % Initialize the parallel pool if it's not already running +% if isempty(gcp('nocreate')) +% parpool; +% end +% +% +% % Write out the images for later post-analyses +% % [~, fname, ~]=fileparts(preproc_dat.image_names); +% % fname=outfile +% +% HRF_OBJ=cell(1,numel(preproc_dat)); +% PARAM_OBJ=cell(1,numel(preproc_dat)); +% +% parfor d=1:numel(preproc_dat) +% % for d=1:numel(preproc_dat) +% +% if iscell(preproc_dat) +% data=preproc_dat{d}; +% else +% data = preproc_dat; +% end +% +% for t=1:numel(HRF_PARAMS.types) +% +% warning('off', 'all'); +% switch HRF_PARAMS.types{t} +% case 'IL' +% % [~, ~, PARAM_OBJ{d}, HRF_OBJ{d}] = hrf_fit(data, HRF_PARAMS.TR, HRF_PARAMS.Condition(d), HRF_PARAMS.T, HRF_PARAMS.types{t}, 0); +% [~, ~, PARAM_OBJ{d,t}, HRF_OBJ{d,t}] = hrf_fit(data, HRF_PARAMS.TR, HRF_PARAMS.Condition(d), HRF_PARAMS.T, HRF_PARAMS.types{t}, 0); +% case 'FIR' +% % [~, ~, PARAM_OBJ{d}, HRF_OBJ{d}] = hrf_fit(data, HRF_PARAMS.TR, HRF_PARAMS.Condition(d), HRF_PARAMS.T, HRF_PARAMS.types{t}, 0); +% [~, ~, PARAM_OBJ{d,t}, HRF_OBJ{d,t}] = hrf_fit(data, HRF_PARAMS.TR, HRF_PARAMS.Condition(d), HRF_PARAMS.T, HRF_PARAMS.types{t}, 0); +% case 'sFIR' +% % [~, ~, PARAM_OBJ{d}, HRF_OBJ{d}] = hrf_fit(data, HRF_PARAMS.TR, HRF_PARAMS.Condition(d), HRF_PARAMS.T, 'FIR', 1); +% [~, ~, PARAM_OBJ{d,t}, HRF_OBJ{d,t}] = hrf_fit(data, HRF_PARAMS.TR, HRF_PARAMS.Condition(d), HRF_PARAMS.T, 'FIR', 1); +% case 'CHRF0' +% % [~, ~, PARAM_OBJ{d}, HRF_OBJ{d}] = hrf_fit(data, HRF_PARAMS.TR, HRF_PARAMS.Condition(d), HRF_PARAMS.T, 'CHRF', 0); +% [~, ~, PARAM_OBJ{d,t}, HRF_OBJ{d,t}] = hrf_fit(data, HRF_PARAMS.TR, HRF_PARAMS.Condition(d), HRF_PARAMS.T, 'CHRF', 0); +% case 'CHRF1' +% % [~, ~, PARAM_OBJ{d}, HRF_OBJ{d}] = hrf_fit(data, HRF_PARAMS.TR, HRF_PARAMS.Condition(d), HRF_PARAMS.T, 'CHRF', 1); +% [~, ~, PARAM_OBJ{d,t}, HRF_OBJ{d,t}] = hrf_fit(data, HRF_PARAMS.TR, HRF_PARAMS.Condition(d), HRF_PARAMS.T, 'CHRF', 1); +% case 'CHRF2' +% % [~, ~, PARAM_OBJ{d}, HRF_OBJ{d}] = hrf_fit(data, HRF_PARAMS.TR, HRF_PARAMS.Condition(d), HRF_PARAMS.T, 'CHRF', 2); +% [~, ~, PARAM_OBJ{d,t}, HRF_OBJ{d,t}] = hrf_fit(data, HRF_PARAMS.TR, HRF_PARAMS.Condition(d), HRF_PARAMS.T, 'CHRF', 2); +% +% otherwise +% error('No valid fit-type. Choose IL, FIR/sFIR or CHRF0/CHRF1/CHRF2') +% end +% +% for c=1:numel(HRF_PARAMS.Condition) +% +% HRF_OBJ{d}{c}.fullpath=[outfile, '_type-', HRF_PARAMS.types{t}, '_condition-', HRF_PARAMS.CondNames{c}, '_fit.nii']; +% PARAM_OBJ{d}{c}.fullpath=[outfile, '_type-', HRF_PARAMS.types{t}, '_condition-', HRF_PARAMS.CondNames{c}, '_params.nii']; +% try +% write(HRF_OBJ{d}{c}, 'overwrite'); +% write(PARAM_OBJ{d}{c}, 'overwrite'); +% catch +% warning('Not able to write one or more files.'); +% end +% +% end +% +% % HRF_local{d} = cell(1, numel(rois)); +% % tc_local{d} = cell(1, numel(rois)); +% +% end +% end +% +% % Generate an HRF and tc for every datafile and concatenate them +% % together. +% +% for d=1:numel(HRF_OBJ) +% for t=1:numel(HRF_OBJ{d}) +% if exist('SPM') +% [HRF.fit{d,t}, tc{d,t}]=extractHRF(HRF_OBJ{d}, [SPM.Sess(i).U.name], 'atlas', at, 'regions', rois); +% else +% [HRF.fit{d,t}, tc{d,t}]=extractHRF(HRF_OBJ{d}, HRF_PARAMS.CondNames(d), 'atlas', at, 'regions', rois); +% end +% end +% end +% +% % This results in an HRF struct: HRF(d, t, r, c), tc(d,t,r,c) +% +% HRF.CondNames = HRF_PARAMS.CondNames; +% +% delete(gcp('nocreate')); +% end + +function [tc, HRF, HRF_OBJ, PARAM_OBJ]=roiTS_fitHRF_SPM(SPM, HRF_PARAMS, rois, at, outfile) + + % Make directories for files if needed + if ~isempty(fileparts(outfile)) + disp(fileparts(outfile)); + if ~exist(fileparts(outfile), 'dir') + mkdir(fileparts(outfile)); + end + end + + % Make sure CondNames are valid before continuing: + HRF.atlas=at; + HRF.region=rois; + HRF.types=HRF_PARAMS.types; + HRF.name=unique({SPM.xY.VY.fname}'); + + if numel(HRF.name)>1 + for c=1:numel(HRF.name) + % Initialize 'tc' and 'temp_HRF_fit' cell arrays + tc{c} = cell(1, numel(HRF_PARAMS.types)); + temp_HRF_fit{c} = cell(1, numel(HRF_PARAMS.types)); + end + else + % Initialize 'tc' and 'temp_HRF_fit' cell arrays + tc = cell(1, numel(HRF_PARAMS.types)); + temp_HRF_fit = cell(1, numel(HRF_PARAMS.types)); + end + + % Carve up SPM's design matrix for each image + % DX=cell(1,numel(SPM.nscan)); + + % HRF_PARAMS.CondNames=matlab.lang.makeValidName(HRF_PARAMS.CondNames); + + HRF_OBJ=cell(1,numel(HRF_PARAMS.types)); + PARAM_OBJ=cell(1,numel(HRF_PARAMS.types)); + info=cell(1,numel(HRF_PARAMS.types)); + + % Initialize the parallel pool if it's not already running + if isempty(gcp('nocreate')) + parpool; + end + + CondNames=cell(1, numel(HRF_PARAMS.types)); + + parfor t=1:numel(HRF_PARAMS.types) + % for t=1:numel(HRF_PARAMS.types) + + + % First check to see if valid images have already been generated. Then + % we don't have to regenerate them. + files_exist=0; + % for d = 1:numel(HRF.name) + % + % CondNames{t}{d} = strtrim([SPM.Sess(d).U.name]'); + % for c=1:numel(CondNames{t}{d}) + % HRF_OBJ_path=[outfile, '_type-', HRF_PARAMS.types{t}, '_condition-', CondNames{t}{d}{c}, '_fit.nii']; + % PARAM_OBJ_path=[outfile, '_type-', HRF_PARAMS.types{t}, '_condition-', CondNames{t}{d}{c}, '_params.nii']; + % if exist(HRF_OBJ_path, 'file') + % HRF_OBJ{t}{d}{c}=fmri_data(HRF_OBJ_path); + % PARAM_OBJ{t}{d}{c}=fmri_data(PARAM_OBJ_path); + % files_exist=1; + % % disp('found file') + % else + % % disp('did not find file') + % files_exist=0; + % end + % end + % end + + if ~files_exist + % Generate the files otherwise. + warning('off', 'all'); + if contains(HRF_PARAMS.types{t}, 'IL'), [~, ~, PARAM_OBJ{t}, HRF_OBJ{t}] = hrf_fit(SPM, HRF_PARAMS.T, 'IL', 0);, end + if contains(HRF_PARAMS.types{t}, 'FIR'), [~, ~, PARAM_OBJ{t}, HRF_OBJ{t}, info{t}] = hrf_fit(SPM, HRF_PARAMS.T, 'FIR', 0);, end + if contains(HRF_PARAMS.types{t}, 'sFIR'), [~, ~, PARAM_OBJ{t}, HRF_OBJ{t}, info{t}] = hrf_fit(SPM, HRF_PARAMS.T, 'FIR', 1);, end + if contains(HRF_PARAMS.types{t}, 'CHRF0'), [~, ~, PARAM_OBJ{t}, HRF_OBJ{t}, info{t}] = hrf_fit(SPM, HRF_PARAMS.T, 'CHRF', 0);, end + if contains(HRF_PARAMS.types{t},'CHRF1'), [~, ~, PARAM_OBJ{t}, HRF_OBJ{t}, info{t}] = hrf_fit(SPM, HRF_PARAMS.T, 'CHRF', 1);, end + if contains(HRF_PARAMS.types{t}, 'CHRF2'), [~, ~, PARAM_OBJ{t}, HRF_OBJ{t}, info{t}] = hrf_fit(SPM, HRF_PARAMS.T, 'CHRF', 2);, end + + if ~ismember(HRF_PARAMS.types{t}, {'IL', 'FIR', 'sFIR','CHRF0','CHRF1','CHRF2'}) + error('Not a valid fit-type. Choose IL, FIR/sFIR or CHRF0/CHRF1/CHRF2') + end + + % The issue with this one is that now HRF_OBJ{t} and PARAM_OBJ{t} + % are cell arrays with cells for each data file d. + + for d = 1:numel(info{t}) + + CondNames{t}{d} = strtrim(info{t}{d}.names); + + for c=1:numel(CondNames{t}{d}) + + HRF_OBJ{t}{d}{c}.fullpath=[outfile, '_type-', HRF_PARAMS.types{t}, '_condition-', CondNames{t}{d}{c}, '_fit.nii']; + PARAM_OBJ{t}{d}{c}.fullpath=[outfile, '_type-', HRF_PARAMS.types{t}, '_condition-', CondNames{t}{d}{c}, '_params.nii']; + try + write(HRF_OBJ{t}{d}{c}, 'overwrite'); + write(PARAM_OBJ{t}{d}{c}, 'overwrite'); + catch + warning('Not able to write one or more files.'); + end + + end + + end + end + end + + % This will return an HRF_OBJ that is sectioned by: + % HRF Type (t), Run(d), Region (r), Condition (c) + + % Rearrange HRF_OBJ{t, d, c} and PARAM_OBJ{t, d, c} + HRF.CondNames=CondNames{1}; + + + % Generate an HRF and tc for every HRF_OBJ datafile (type x session x Condition) and concatenate them + % together. + + HRF_arr=cell(1,numel(HRF_OBJ)); + for d=1:numel(HRF.name) % Organize by Session, then type, then condition: + HRF_struct=HRF; + HRF_struct.name=HRF.name{d}; + HRF_struct.CondNames=HRF.CondNames{d}; + tic + for t=1:numel(HRF.types) + if exist('SPM') + [HRF_struct.fit{t}, tc{d}{t}]=extractHRF(HRF_OBJ{t}{d}, [SPM.Sess(d).U.name], at, rois); + else + [HRF_struct.fit{t}, tc{d}{t}]=extractHRF(HRF_OBJ{t}{d}, HRF.CondNames{d}, at, rois); + end + end + toc + + HRF_arr{d}=HRF_struct; + end + + HRF=HRF_arr; + + + + % This results in an HRF struct: HRF(d, t, r, c), tc(d,t,r,c) + + + +end \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/HRF_avg.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/HRF_avg.m new file mode 100644 index 00000000..7a3e80ec --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/HRF_avg.m @@ -0,0 +1,400 @@ +% I'll have to put these all together and graph them +% for HRF.region + +% for HRF.types + +function avgHRF = HRF_avg(HRF_cell_array, varargin) + % Check if all HRFs have the same atlas + + % Detect if the HRF_cell array is sessions or runs? + + + + % Suppress all warnings for now, since they'll mostly flood the screen + % with Invalid Heights from findpeaks() + warning('off', 'all'); + + hasAtlas=0; + hasRegions=0; + hasConditions=0; + hasFit=0; + + for k = 1:length(varargin) + if strcmpi(varargin{k}, 'atlas') + if isa(varargin{k+1}, 'atlas') + avgHRF.atlas=varargin{k+1}; + hasAtlas = true; + else + error('Passed in atlas not an atlas.'); + end + end + + if strcmpi(varargin{k}, 'regions') + if ischar(varargin{k+1}) + avgHRF.region=varargin{k+1}; + % if ~ismember(avgHRF.region, HRF.region) + % error('Invalid region specified.'); + % else + hasRegions = true; + % end + elseif iscell(varargin{k+1}) + avgHRF.region=varargin{k+1}; + hasRegions = true; + end + end + + if strcmpi(varargin{k}, 'conditions') + if ischar(varargin{k+1}) + avgHRF.CondNames=varargin{k+1}; + % if ~ismember(avgHRF.CondNames, HRF.CondNames) + % error('Invalid condition specified.'); + % else + hasConditions = true; + % end + elseif iscell(varargin{k+1}) + avgHRF.CondNames=varargin{k+1}; + hasConditions = true; + % else + % disp(['Input argument for condition unknown.']); + % end + end + end + + if strcmpi(varargin{k}, 'fit') + if ischar(varargin{k+1}) + avgHRF.types=varargin{k+1}; + % if ~ismember(avgHRF.types, HRF.types) + error('Invalid fit-type specified.'); + % else + hasFit = true; + % end + elseif iscell(varargin{k+1}) + avgHRF.types=varargin{k+1}; + hasFit = true; + else + disp(['Input argument for fit-type unknown.']); + end + end + + end + + if ~hasAtlas + try + first_atlas = HRF_cell_array{1}.atlas; + catch + first_atlas=load_atlas('canlab2023'); + end + all_same_atlas = true; + + for i = 2:numel(HRF_cell_array) + if ~isequal(HRF_cell_array{i}.atlas, first_atlas) + all_same_atlas = false; + end + end + + if all_same_atlas + avgHRF.atlas=first_atlas; + else + error('HRF Structures have different .atlas values.\n'); + end + end + + if ~hasFit + first_types = HRF_cell_array{1}.types; + all_same_types = true; + + for i = 2:numel(HRF_cell_array) + if ~isequal(HRF_cell_array{i}.types, first_types) + all_same_types = false; + end + end + if all_same_types + avgHRF.types=first_types; + else + error('HRF Structures have different .types values.\n'); + end + end + + if ~hasConditions + first_conds = HRF_cell_array{1}.CondNames; + all_same_conds = true; + + for i = 2:numel(HRF_cell_array) + if ~isequal(HRF_cell_array{i}.CondNames, first_conds) + all_same_conds = false; + end + end + if all_same_conds + avgHRF.CondNames=first_conds; + else + avgerror('HRF Structures have different .CondNames values.\n'); + end + end + + if ~hasRegions + first_region = HRF_cell_array{1}.region; + all_same_regions = true; + + for i = 2:numel(HRF_cell_array) + if ~isequal(HRF_cell_array{i}.region, first_region) + all_same_regions = false; + end + end + + if all_same_regions + avgHRF.region=first_region; + else + error('HRF Structures have different .region values.\n'); + end + end + + + % Extract the number of fits, regions, and conditions + numFits = numel(avgHRF.types); + numRegions = numel(avgHRF.region); + numConditions = numel(avgHRF.CondNames); + + % Loop through fits, regions, and conditions + for fitIndex = 1:numFits + + for regionIndex = 1:numRegions + + avgVector_across_conditions=[]; + for conditionIndex = 1:numConditions + models3d=[]; + + % condName = avgHRF.CondNames{conditionIndex}; + + % Initialize an empty array to store 'model' vectors + models = []; + phases=[]; + peaks=[]; + troughs=[]; + numPhases = 0; + numPeaks = 0; % Initialize the count of peak elements + numTroughs = 0; % Initialize the count of peak elements + + % Iterate through the structures and extract 'model' vectors + for dataIndex = 1:numel(HRF_cell_array) + fields=fieldnames(HRF_cell_array{dataIndex}.fit{fitIndex}{regionIndex}); + conditionName=char(fields(contains(fields, avgHRF.CondNames{conditionIndex}))); + + modelVector = HRF_cell_array{dataIndex}.fit{fitIndex}{regionIndex}.(conditionName).model; + [a, b]=padwithnan(models, modelVector, 2); + models = [a; b]; % Concatenate the vectors + + phaseStructs = HRF_cell_array{dataIndex}.fit{fitIndex}{regionIndex}.(conditionName).phases; + numPhases = numel(phaseStructs); + phases = [phases; numPhases]; + + peakStructs = HRF_cell_array{dataIndex}.fit{fitIndex}{regionIndex}.(conditionName).peaks; + numPeaks = numel(peakStructs); % Count the peak elements + peaks = [peaks; numPeaks]; + + troughStructs = HRF_cell_array{dataIndex}.fit{fitIndex}{regionIndex}.(conditionName).troughs; + numTroughs = numel(troughStructs); % Count the trough elements + troughs = [troughs; numTroughs]; + end + + % Compute the average vector for the current condition, region, and fit + avgVector = mean(models, 1); + [a, b]=padwithnan(avgVector_across_conditions, avgVector, 2); + avgVector_across_conditions=[a; b]; + + % Compute the standard deviation for the current condition, region, and fit + stdVector = std(models, 0, 1); % second parameter = 0 normalizes by N-1, which is typically desired when estimating the population standard deviation from a sample + wseVector=barplot_get_within_ste(models); + + avg_per_subject = mean(models, 2); + + avgPhases = mean(phases, 1); + stdPhases = std(phases, 0, 1); + + avgPeaks = mean(peaks, 1); + stdPeaks = std(peaks, 0, 1); + + avgTroughs = mean(troughs, 1); + stdTroughs = std(troughs, 0, 1); + + models3d=cat(3, models3d, models); + + conditionName=avgHRF.CondNames{conditionIndex}; + % Store the average vector in the avgStruct + avgHRF.fit{fitIndex}{regionIndex}.(conditionName).model = avgVector; + avgHRF.fit{fitIndex}{regionIndex}.(conditionName).stddev = stdVector; + avgHRF.fit{fitIndex}{regionIndex}.(conditionName).wse = wseVector; + + avgHRF.fit{fitIndex}{regionIndex}.(conditionName).avgpeaks = avgPeaks; + avgHRF.fit{fitIndex}{regionIndex}.(conditionName).stdpeaks = stdPeaks; + + avgHRF.fit{fitIndex}{regionIndex}.(conditionName).avgtroughs = avgTroughs; + avgHRF.fit{fitIndex}{regionIndex}.(conditionName).stdtroughs = stdTroughs; + + % Store the constituent vectors in the avgStruct + avgHRF.fit{fitIndex}{regionIndex}.(conditionName).models = models; + + [avgHRF.fit{fitIndex}{regionIndex}.(conditionName).peaks, avgHRF.fit{fitIndex}{regionIndex}.(conditionName).troughs]=detectPeaksTroughs(avgVector', false); + [~, region_numVox, ~, ~]=avgHRF.atlas.select_atlas_subset(avgHRF.region(regionIndex), 'exact').get_region_volumes; + [avgHRF.fit{fitIndex}{regionIndex}.(conditionName).peaks_voxnormed, avgHRF.fit{fitIndex}{regionIndex}.(conditionName).troughs_voxnormed]=detectPeaksTroughs(avgVector'/region_numVox, false); + + % Number of phases + start_times = [avgHRF.fit{fitIndex}{regionIndex}.(conditionName).peaks.start_time, avgHRF.fit{fitIndex}{regionIndex}.(conditionName).troughs.start_time]; + end_times = [avgHRF.fit{fitIndex}{regionIndex}.(conditionName).peaks.end_time, avgHRF.fit{fitIndex}{regionIndex}.(conditionName).troughs.end_time]; + phases = [start_times(:), end_times(:)]; + unique_phases = unique(phases, 'rows'); + avgHRF.fit{fitIndex}{regionIndex}.(conditionName).phases = mat2cell(unique_phases, ones(size(unique_phases, 1), 1), 2); + + % Loop over phases + for p = 1:numel(avgHRF.fit{fitIndex}{regionIndex}.(conditionName).phases) + features = {'peaks', 'troughs'}; + for f = 1:2 + feat = features{f}; + + % Count the number of features (peaks or troughs) + start_times = cell2mat({avgHRF.fit{fitIndex}{regionIndex}.(conditionName).(feat).start_time}); + current_phase_start = avgHRF.fit{fitIndex}{regionIndex}.(conditionName).phases{p}(1); + avgHRF.fit{fitIndex}{regionIndex}.(conditionName).phase(p).(feat) = sum(start_times == current_phase_start); + + if avgHRF.fit{fitIndex}{regionIndex}.(conditionName).phase(p).(feat) > 0 + % display(['Estimating ' , num2str(fitIndex), '_', rois{r}, '_', conditionName, '_', ' Now...!']) + % display(['Phase ' , num2str(p), 'Feature ', feat]) + idx = find(start_times == current_phase_start); + auc = cell2mat({avgHRF.fit{fitIndex}{regionIndex}.(conditionName).(feat).AUC}); + height = cell2mat({avgHRF.fit{fitIndex}{regionIndex}.(conditionName).(feat).height}); + time_to_peak = cell2mat({avgHRF.fit{fitIndex}{regionIndex}.(conditionName).(feat).time_to_peak}); + half_height = cell2mat({avgHRF.fit{fitIndex}{regionIndex}.(conditionName).(feat).half_height}); + + feat_voxnormed = strcat(feat, '_voxnormed'); + auc_voxnormed = cell2mat({avgHRF.fit{fitIndex}{regionIndex}.(conditionName).(feat_voxnormed).AUC}); + height_voxnormed = cell2mat({avgHRF.fit{fitIndex}{regionIndex}.(conditionName).(feat_voxnormed).height}); + time_to_peak_voxnormed = cell2mat({avgHRF.fit{fitIndex}{regionIndex}.(conditionName).(feat_voxnormed).time_to_peak}); + half_height_voxnormed = cell2mat({avgHRF.fit{fitIndex}{regionIndex}.(conditionName).(feat_voxnormed).half_height}); + + avgHRF.fit{fitIndex}{regionIndex}.(conditionName).phase(p).auc = unique(auc(idx)); + avgHRF.fit{fitIndex}{regionIndex}.(conditionName).phase(p).auc_voxnormed = unique(auc_voxnormed(idx)); + + avgHRF.fit{fitIndex}{regionIndex}.(conditionName).phase(p).height = height(idx); + avgHRF.fit{fitIndex}{regionIndex}.(conditionName).phase(p).height_voxnormed = height_voxnormed(idx); + avgHRF.fit{fitIndex}{regionIndex}.(conditionName).phase(p).time_to_peak = time_to_peak(idx); + avgHRF.fit{fitIndex}{regionIndex}.(conditionName).phase(p).time_to_peak_voxnormed = time_to_peak_voxnormed(idx); + avgHRF.fit{fitIndex}{regionIndex}.(conditionName).phase(p).half_height = half_height(idx); + avgHRF.fit{fitIndex}{regionIndex}.(conditionName).phase(p).half_height_voxnormed = half_height_voxnormed(idx); + end + end + end + end + end + % Calculate the within-subject SE for each time point NOT DONE + within_subject_SE= squeeze(std(avgVector_across_conditions, 0, 1)) / sqrt(size(models, 1)); + + end +end + +% +% HRF{file}.fit{1}{1}.hot.model +% HRF{file}.fit{1}{1}.warm.model +% HRF{file}.fit{1}{1}.imagine.model +% +% +% +% HRF{file}.fit{1}{1}.model.h + + + +% Plotting Demonstration: +% +% % Your time vector, average vector, and standard deviation vector +% time = 1:width(avgVector); % Replace with your actual time vector +% c=3 +% +% % Create a new figure +% figure; +% +% % Plot the average vector +% plot(time, avgVector_across_conditions(c,:), 'b', 'LineWidth', 2); +% hold on; +% +% % Calculate the upper and lower bounds of the standard deviation +% % upperBound = avgVector + stdVector; +% % lowerBound = avgVector - stdVector; +% +% % SE +% % upperBound = avgVector_across_conditions(c,:) + stdVector/sqrt(10); +% % lowerBound = avgVector_across_conditions(c,:) - stdVector/sqrt(10); +% +% +% % Create a shaded area for the standard deviation +% x = [time, fliplr(time)]; +% y = [upperBound, fliplr(lowerBound)]; +% fill(x, y, 'b', 'FaceAlpha', 0.2, 'EdgeAlpha', 0); +% +% % Labels and title for clarity +% xlabel('Time'); +% ylabel('Measurement'); +% title('Average Measurement with Standard Deviation'); +% legend('Average', '1 SD', 'Location', 'Best'); +% +% % Display the plot +% hold off; +% grid on; +% +% +% +% +% +% % Computing the within-subject SE +% % Assuming `data` is a 3D matrix: subjects x time points x conditions. +% +% num_subjects=10 +% num_timepoints=64 +% num_conditions=3 +% +% +% data=models3d; +% % Assuming `data` is a 3D matrix: subjects x time points x conditions. +% +% [num_subjects, num_timepoints, num_conditions] = size(data); +% +% % Calculating the mean across conditions for each subject and timepoint +% subject_means = mean(data, 3); +% +% % Calculating the standard deviation of the subject means across timepoints +% subject_std = std(subject_means, 0, 2); +% +% % Calculating the average standard deviation across subjects +% avg_std = mean(subject_std); +% +% % Calculating the within-subject standard error +% WSE = avg_std / sqrt(num_conditions); +% +% % Preallocate arrays for efficiency. +% mean_response = zeros(num_conditions, num_timepoints); +% +% % Loop through each condition, time point and calculate the mean response. +% for c = 1:num_conditions +% for t = 1:num_timepoints +% mean_response(c, t) = mean(data(:, t, c)); +% end +% +% % Optionally, plot the mean response with shaded WSE for each condition. +% figure; +% plot(mean_response(c, :), 'LineWidth', 2); % Plot the mean response. +% hold on; +% +% % Add shaded error (WSE). +% fill([1:num_timepoints, fliplr(1:num_timepoints)], ... +% [mean_response(c, :) + WSE, fliplr(mean_response(c, :) - WSE)], ... +% [0.9, 0.9, 0.9], 'EdgeColor', 'none'); +% +% hold off; +% xlabel('Time Point'); +% ylabel('Response'); +% title(['Mean Response Over Time with WSE for Condition ', num2str(c)]); +% end +% +% +% +% barplot_get_within_ste(models3d(:,:,1)) +% barplot_get_within_ste(models3d(:,:,2)) +% barplot_get_within_ste(models3d(:,:,3)) + diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/animate.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/animate.m new file mode 100644 index 00000000..d6cbd2bb --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/animate.m @@ -0,0 +1,256 @@ +function animate(data, tr, varargin) + % Helper script to create condition vectors for hrf_fit_one_voxel() + % Michael Sun, Ph.D. + % - Takes fmri_data() object or the number of TRs in a 4D object. + % - conditions: cell-vector of cellstrings for each condition e.g., {'hot', 'warm', 'imagine'} + % - onsets: SPM-style onsets cell array in seconds from first-level model, e.g., {{1,2,3}, {4, 5, 6}, {7, 8, 9}} + % - duration: SPM-style duration cell array in seconds from first-level model, e.g., {{12, 12 ,12}, {12, 12, 12}, {12, 12, 12}} + % + % *Usage: + % :: + % Condition = generateConditionTS(image_obj, {'hot','warm','imagine'}, onsets, durations}) + % + % Note 1: Preset a SPIKES or SPIKETRAINS variable in order to toggle the + % generation of Spikes (single 1s) or Spiketrains (a train of 1s) to + % represent each event. + % + % Note 2: If there was slice-timing correction performed, then you will + % want to correct for it by adding 0.5TRs to all of your times. + + % Default to modeling single spike instead of duration of events. + + % Validate input arguments + +% if nargin < 4 +% error('You must provide fmri_data, tr, outfile name, and outtype "avi" or "gif"'); +% end + + if numel(varargin)==1 + outfile=varargin{1}; + outtype='avi'; + elseif numel(varargin)==2 + outfile=varargin{1}; + outtype=varargin{2}; + else + outfile='animated_file.avi'; + outtype='avi'; + end + + % Use fileparts to split the path + [parentFolder, ~, ~] = fileparts(outfile); + + % Check if the folder exists + if ~exist(parentFolder, 'dir') + % Folder does not exist, so create it + mkdir(parentFolder); + disp('Folder was created.'); + end + + + if strcmp(outtype,'avi') + makeFmriAvi(data, tr, outfile); + elseif strcmp(outtype, 'gif') + makeFmriGif(data, tr, outfile); + else + disp([outtype, ' is not supported yet.']) + end + +end + +% function makeFmriAvi(data, tr, outfile) +% % data: fmri_data object +% % varargin: +% % 'TR', numeric +% % 'outfile', cellstr +% % '', +% +% +% % Define the animation parameters +% +% frame_rate = 1/tr; % e.g., 2.17; for a TR of 0.46 +% +% % % Create a movie object +% % writerObj = VideoWriter(outfile); +% % writerObj.FrameRate = frame_rate; +% % open(writerObj); +% % +% % % Create a loop that iterates over each time point in the fMRI data +% % for i = 1:size(data.dat,2) +% % % Replace with preferred plotting logic using 'data'. +% % get_wh_image(data, i).montage('full', 'cmaprange', [-7 7]); +% % +% % % Capture the image as a frame in the movie object +% % frame = getframe(gcf); +% % writeVideo(writerObj,frame); +% % close; +% % end +% +% % Create a movie object +% writerObj = VideoWriter(outfile); +% writerObj.FrameRate = frame_rate; +% open(writerObj); +% +% % Preallocate figure and axes +% % fig = figure; +% % ax = axes('Parent', fig); +% % +% % % Process each frame +% % parfor i = 1:size(data.dat,2) +% % % Update plot in the preallocated figure +% % cla(ax); % Clear axes +% % get_wh_image(data, i).montage('full', 'cmaprange', [-7 7], 'Parent', ax); +% % +% % % Capture the image as a frame in the movie object +% % frame = getframe(fig); +% % writeVideo(writerObj, frame); +% % end +% +% +% % Parallel generation of frames with index +% % Parallel Approach +% nFrames = size(data.dat,2); +% frames(nFrames) = struct('cdata',[],'colormap',[]); % Preallocate structure array +% +% parfor i = 1:nFrames +% frames(i).data = frameCapture(data, i); +% frames(i).index = i; % Store the index +% end +% +% % Sort frames based on index to ensure correct order +% [~, order] = sort([frames.index]); +% sortedFrames = frames(order); +% +% % Sequentially write sorted frames to video +% for i = 1:nFrames +% writeVideo(writerObj, sortedFrames(i).data); +% end +% +% +% +% % Close the movie object +% close(writerObj); +% % close(fig); +% +% end + +function makeFmriAvi(data, tr, outfile) + + + + + frame_rate = 1/tr; + writerObj = VideoWriter(outfile); + writerObj.FrameRate = frame_rate; + open(writerObj); + + nFrames = size(data.dat,2); + tempDir = tempname; % Create a temporary directory name + mkdir(tempDir); % Make the temporary directory + + % Parallel generation of frames + parfor i = 1:nFrames + frameFilename = fullfile(tempDir, sprintf('frame_%06d.png', i)); + frame = frameCapture(data, i); + imwrite(frame.cdata, frameFilename); % Write frame to file + end + + % Sequentially read frames from files and write to video + for i = 1:nFrames + frameFilename = fullfile(tempDir, sprintf('frame_%06d.png', i)); + if exist(frameFilename, 'file') + frame = imread(frameFilename); + writeVideo(writerObj, frame); + else + error(['Frame file missing: ', frameFilename]); + end + end + + % Close the movie object + close(writerObj); + + % Cleanup: Delete the temporary frames + rmdir(tempDir, 's'); % Remove the directory and its contents +end + + + + +function makeFmriGif(data, fr, outfile) + % data: fmri_data object or appropriate data for the animation + % fr: frame rate + % outfile: output file name + + % Ensure that 'data' contains valid information for the animation. + % Ensure that 'outfile' is a string and ends with '.gif'. + + % Number of frames based on the data + numFrames = size(data.dat,2); + mov(numFrames) = struct('cdata',[],'colormap',[]); + + % Loop through each frame of the data + for i = 1:numFrames + % Replace with preferred plotting logic using 'data'. + get_wh_image(data, i).montage('full', 'cmaprange', [-7 7]); + + drawnow + % Capture the current plot as a movie frame + mov(i) = getframe(gcf); + close; + end + + % Convert the movie frames to indexed images + [imind, cm] = rgb2ind(mov(1).cdata, 256, 'nodither'); + imind(1,1,1,numFrames) = 0; + for i = 1:numFrames + imind(:,:,1,i) = rgb2ind(mov(i).cdata, cm, 'nodither'); + end + + % Save the indexed images as an animated gif + delay = 1/fr; % delay between frames in seconds + loopcount = inf; % number of times to repeat animation (inf = indefinitely) + imwrite(imind, cm, outfile, 'gif', 'DelayTime', delay, 'LoopCount', loopcount); +end + + +function frame = frameCapture(data, i) + + % Call montage, which creates its own figure + get_wh_image(data, i).montage('full', 'cmaprange', [-7 7], 'noverbose'); + + % Find the most recently created figure + figs = findall(groot, 'Type', 'figure'); + latestFig = figs(end); + + % Make the figure visible and set the size + set(latestFig, 'Position', [100, 100, 2560, 1080], 'Visible', 'on'); + + drawnow; snapnow; + + % Dynamic waiting for figure to update +% waitTime = 0; +% maxWaitTime = 400; % Slightly longer than your longest frame processing time +% while waitTime < maxWaitTime +% drawnow; snapnow; % Update figure window +% pause(5); % Check every 5 seconds +% waitTime = waitTime + 5; +% % Add any additional checks here if possible to confirm rendering is complete +% end + + % Capture the frame from the latest figure + frame = getframe(latestFig); + + % Close the latest figure + close(latestFig); + + +end + + + + + + + + + + diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/describeHRF.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/describeHRF.m new file mode 100644 index 00000000..4c571588 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/describeHRF.m @@ -0,0 +1,30 @@ +function describeHRF(HRF) + % Generates a description from an HRF Structure. + % Michael Sun, Ph.D. + % - Takes HRF Structure object generated from EstimateHRF_inAtlas() + % + % *Usage: + % :: + % describeHRF(HRF_structure) + + for c = 1:numel(HRF.CondNames) + for t = 1:numel(HRF.types) + for r = 1:numel(HRF.region) + + disp(['The ', HRF.types{t}, ' fitted ', HRF.region{r}, ' features ', num2str(numel(HRF.fit{t}{r}.(HRF.CondNames{c}).phases)), ' phases of activity for the ', HRF.CondNames{c}, ' condition']); + for p = 1:numel(HRF.fit{t}{r}.(HRF.CondNames{c}).phases) + if HRF.fit{t}{r}.(HRF.CondNames{c}).phase(p).peaks>0 + disp(['Phase ' num2str(p), ', spanning TRs ', num2str(HRF.fit{t}{r}.(HRF.CondNames{c}).phases{p}), ' features ', num2str(HRF.fit{t}{r}.(HRF.CondNames{c}).phase(p).peaks), ' peak(s), (at TR(s) ', num2str(HRF.fit{t}{r}.(HRF.CondNames{c}).phase(p).time_to_peak), ... + ' and features an AUC of ' num2str(HRF.fit{t}{r}.(HRF.CondNames{c}).phase(p).auc), ' (voxel-normed: ', num2str(HRF.fit{t}{r}.(HRF.CondNames{c}).phase(p).auc_voxnormed), ').']); + end + if HRF.fit{t}{r}.(HRF.CondNames{c}).phase(p).troughs>0 + disp(['Phase ' num2str(p), ', spanning TRs ', num2str(HRF.fit{t}{r}.(HRF.CondNames{c}).phases{p}), ' features ', num2str(HRF.fit{t}{r}.(HRF.CondNames{c}).phase(p).troughs), ' trough(s), (at TR(s) ', num2str(HRF.fit{t}{r}.(HRF.CondNames{c}).phase(p).time_to_peak), ... + ' and features an AUC of ' num2str(HRF.fit{t}{r}.(HRF.CondNames{c}).phase(p).auc), ' (voxel-normed: ', num2str(HRF.fit{t}{r}.(HRF.CondNames{c}).phase(p).auc_voxnormed), ').']); + end + end + + end + disp(newline); + end + end +end \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/detectPeaksTroughs.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/detectPeaksTroughs.m new file mode 100644 index 00000000..50fec016 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/detectPeaksTroughs.m @@ -0,0 +1,130 @@ +function [peaks, troughs] = detectPeaksTroughs(waveform, shouldPlot) + % Detect Peaks and Troughs from a waveform using Matlab Signal + % Processing Toolbox + % Michael Sun, Ph.D. + % - Takes a vector timeseries e.g., generated from EstimateHRF_inAtlas + % or hrf_fit_one_voxel(). + % - shouldPlot: 1: yes, plot; 0: no, don't plot + % + % *Usage: + % :: + % [peaks, troughs] = detectPeaksTroughs(waveform, 1) + + % [tc, roi_val, maskdat]=canlab_connectivity_preproc(fmri_d, PREPROC_PARAMS.R, 'hpf', .018, PREPROC_PARAMS.TR, 'average_over', 'no_plots'); + + if nargin < 2 + shouldPlot = false; + end + + % Initialize + peaks = struct([]); + troughs = struct([]); + auc_values = []; + + + % Detecting Peaks: + % [d_pos, l_pos, pos_width, pos_prominences] = findpeaks(waveform, 'MinPeakHeight', waveform(1), 'MinPeakDistance', 1); + % threshold_prominence = 0.5 * max(pos_prominences); + % + % % Detecting Troughs: + % [d_neg, l_neg, neg_width, neg_prominences] = findpeaks(-waveform, 'MinPeakHeight', waveform(1), 'MinPeakDistance', 1); + % threshold_prominence = 0.5 * max(neg_prominences); + + % Detecting Peaks: + [d_pos, l_pos, ~, pos_prominences] = findpeaks(waveform, 'MinPeakHeight', waveform(1), 'MinPeakDistance', 1); + if d_pos > 0 + peak_intervals=diff(l_pos); + threshold_prominence = .5*max(pos_prominences); + [d_pos, l_pos, pos_width, pos_prominences] = findpeaks(waveform, 'MinPeakHeight', waveform(1), 'MinPeakDistance', 1, 'MinPeakProminence', threshold_prominence, 'Annotate', 'extents'); + end + + % Detecting Troughs + [d_neg, l_neg, ~, neg_prominences] = findpeaks(-waveform, 'MinPeakHeight', waveform(1), 'MinPeakDistance', 1); + if d_neg > 0 + trough_intervals=diff(l_neg); + threshold_prominence = .5*max(neg_prominences); + [d_neg, l_neg, ~, neg_prominences] = findpeaks(-waveform, 'MinPeakHeight', waveform(1), 'MinPeakDistance', 1, 'MinPeakProminence', threshold_prominence, 'Annotate', 'extents'); + end + + % fields = {'height', 'time_to_peak', 'width', 'start_time', 'end_time', 'half_height', 'AUC'}; + % Initialize with default values + default_struct = struct('height', NaN, 'time_to_peak', NaN, 'width', NaN, 'start_time', NaN, 'end_time', NaN, 'half_height', NaN, 'AUC', NaN); + peaks = repmat(default_struct, 1, length(l_pos)); + troughs = repmat(default_struct, 1, length(l_neg)); + + % Optional Plotting + if shouldPlot + % figure; + plot(waveform); + hold on; + end + + % Process Peaks + for i = 1:length(l_pos) + [start_point, end_point, auc] = processRegion(l_pos(i), waveform, d_pos(i), shouldPlot, 'ro', [1, 0.6, 0.6], false); + peaks(i) = storeFeatures(d_pos(i), l_pos(i), start_point, end_point, auc); + end + + % Process Troughs + for j = 1:length(l_neg) + [start_point, end_point, auc] = processRegion(l_neg(j), -waveform, d_neg(j), shouldPlot, 'bo', [0.6, 0.6, 1], true); + troughs(j) = storeFeatures(-d_neg(j), l_neg(j), start_point, end_point, auc); + end + + % hold off; + +end + +%% HELPER FUNCTIONS + +function [start_point, end_point, auc] = processRegion(loc, waveform, height, shouldPlot, marker, fillColor, isTrough) + start_point = find(waveform(1:loc) <= 0, 1, 'last') + 1; + if isempty(start_point) + start_point = 1; + end + + end_point = find(waveform(loc:end) <= 0, 1, 'first') + loc - 2; + if isempty(end_point) + end_point = length(waveform); + end + + % Ensure start_point is less than end_point + if start_point > end_point + temp = start_point; + start_point = end_point; + end_point = temp; + end + + region_data = waveform(start_point:end_point); + auc = abs(trapz(region_data)); + + if shouldPlot + x = start_point:end_point; + y = waveform(x); + x_fill = [x, fliplr(x)]; + y_fill = [y', zeros(1, numel(y))]; + + if isTrough + y_fill = -y_fill; % Flip the y-values for troughs + height = -height; % Make the height negative for troughs + end + + fill(x_fill, y_fill, fillColor, 'EdgeColor', 'none'); + plot(loc, height, marker); + text(loc, height, sprintf('AUC: %.2f', auc)); + end +end + + +function features = storeFeatures(height, time_to_peak, start_time, end_time, auc) + + features.height = height; + features.time_to_peak = time_to_peak; + features.width = end_time - start_time; + features.start_time = start_time; + features.end_time = end_time; + features.half_height = height / 2; + features.AUC = auc; + +end + diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/extractHRF.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/extractHRF.m new file mode 100644 index 00000000..c86523f8 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/extractHRF.m @@ -0,0 +1,256 @@ +function [HRF, tc] = extractHRF(HRF_OBJ, CondNames, varargin) + % Passed in HRF_OBJ is a cell array of fmri_data for each condition. + % CondNames should be a cell array of charstr for each condition + + isSigFound = false; + isAtlasFound = false; + isRegsFound = false; + r = []; + s = []; + + if isa(HRF_OBJ, 'fmri_data') + HRF_OBJ={HRF_OBJ}; + end + + + % Initialize the parallel pool if it's not already running + % if isempty(gcp('nocreate')) + % parpool; + % end + + for k = 1:length(varargin) + if strcmpi(varargin{k}, 'atlas') + if isa(varargin{k+1}, 'atlas') + at=varargin{k+1}; + else + error('Passed in atlas not an atlas.'); + end + + isAtlasFound = true; + end + + if strcmpi(varargin{k}, 'regions') + if isAtlasFound + if ischar(varargin{k+1}) + r={varargin{k+1}}; + isRegsFound = true; + elseif iscell(varargin{k+1}) + r=varargin{k+1}; + isRegsFound = true; + end + else + error('Cannot extract HRF of regions without atlas') + end + end + + if isAtlasFound && ~isRegsFound + % Extract all atlas regions. + r=at.labels; + end + + if strcmpi(varargin{k}, 'sig') % Expected input: 'nps', 'siips', or 'all' + if ischar(varargin{k+1}) + s={varargin{k+1}}; + isSigFound = true; + elseif iscell(varargin{k+1}) + s=varargin{k+1}; + isSigFound = true; + else + error(['Input argument for sig is unknown.']); + end + + end + end + + % ROIs will consist of regions and signatures + rois = [r, s]; + + % Preallocation of tc_local before the parfor loop + HRF= cell(1, numel(rois)); + tc= cell(1, numel(rois)); + + CondNames=matlab.lang.makeValidName(CondNames); + + % Now, handle the fourth dimension which varies with 'd' + numCondNames = numel(CondNames); + + % HRF_local{d} = cell(1, numel(rois)); + % tc_local{d} = cell(1, numel(rois)); + + % Consider doing apply_parcellation instead of mean(apply_mask(HRF_OBJ{c}, at.select_atlas_subset(rois(r), 'exact')).dat); + % [parcel_means, parcel_pattern_expression, parcel_valence, rmsv_pos, rmsv_neg] = apply_parcellation(dat,at); + % nps=load_image_set('npsplus'); + % nps = get_wh_image(nps,1); + % [parcel_means, parcel_pattern_expression, parcel_valence, rmsv_pos, rmsv_neg] = apply_parcellation(dat,at, 'pattern_expression', nps); + % r=region(at,'unique_mask_values'); + % wh_parcels=~all(isnan(parcel_means)) + + for r=1:numel(rois) + HRF{r} = struct; + tc{r} = cell(1, numCondNames); + + + tic + if isSigFound & strcmpi(rois{r}, 'all') + [sig_dp, ~]=apply_all_signatures(HRF_OBJ, 'similarity_metric', 'dot_product', 'conditionnames', CondNames, 'image_set', 'all'); + [sig_cs, ~]=apply_all_signatures(HRF_OBJ, 'similarity_metric', 'cosine_similarity', 'conditionnames', CondNames, 'image_set', 'all'); + [sig_r, ~]=apply_all_signatures(HRF_OBJ, 'similarity_metric', 'correlation', 'conditionnames', CondNames, 'image_set', 'all'); + end + + + for c=1:numel(CondNames) + + % Check to see if its a signature name first + if isSigFound + + switch rois{r} + case 'all' + for sig = 1:numel(sig_dp.signaturenames) + try + HRF{r}.([CondNames{c},'_',sig_dp.signaturenames{sig},'_dp']).model=sig_dp.(sig_dp.signaturenames{sig}).(CondNames{c}); + HRF{r}.([CondNames{c},'_',sig_cs.signaturenames{sig},'_cs']).model=sig_cs.(sig_cs.signaturenames{sig}).(CondNames{c}); + HRF{r}.([CondNames{c},'_',sig_r.signaturenames{sig},'_r']).model=sig_r.(sig_r.signaturenames{sig}).(CondNames{c}); + + [HRF{r}.([CondNames{c},'_',sig_dp.signaturenames{sig},'_dp']).peaks, HRF{r}.([CondNames{c},'_',sig_dp.signaturenames{sig},'_dp']).troughs]=detectPeaksTroughs(sig_dp.(sig_dp.signaturenames{sig}).(CondNames{c}), false); + [HRF{r}.([CondNames{c},'_',sig_cs.signaturenames{sig},'_cs']).peaks, HRF{r}.([CondNames{c},'_',sig_cs.signaturenames{sig},'_cs']).troughs]=detectPeaksTroughs(sig_cs.(sig_cs.signaturenames{sig}).(CondNames{c}), false); + [HRF{r}.([CondNames{c},'_',sig_r.signaturenames{sig},'_r']).peaks, HRF{r}.([CondNames{c},'_',sig_r.signaturenames{sig},'_r']).troughs]=detectPeaksTroughs(sig_r.(sig_r.signaturenames{sig}).(CondNames{c}), false); + + catch + disp(c); + disp(rois{r}); + end + end + + case 'nps' + % tc{r}=apply_nps(HRF_OBJ); % This does all conditions at once! + try + % tc{r}{c}=apply_nps(HRF_OBJ{c}); + HRF{r}.([CondNames{c}, '_NPS_dp']).model=cell2mat(apply_nps(HRF_OBJ{c})); + HRF{r}.([CondNames{c}, '_NPS_cs']).model=cell2mat(apply_nps(HRF_OBJ{c}, 'cosine_similarity')); + HRF{r}.([CondNames{c}, '_NPS_r']).model=cell2mat(apply_nps(HRF_OBJ{c}, 'correlation')); + [HRF{r}.([CondNames{c}, '_NPS_dp']).peaks, HRF{r}.([CondNames{c}, '_NPS_dp']).troughs]=detectPeaksTroughs(HRF{r}.([CondNames{c}, '_NPS_dp']).model, false); + [HRF{r}.([CondNames{c}, '_NPS_cs']).peaks, HRF{r}.([CondNames{c}, '_NPS_cs']).troughs]=detectPeaksTroughs( HRF{r}.([CondNames{c}, '_NPS_cs']).model, false); + [HRF{r}.([CondNames{c}, '_NPS_r']).peaks, HRF{r}.([CondNames{c}, '_NPS_r']).troughs]=detectPeaksTroughs(HRF{r}.([CondNames{c}, '_NPS_r']).model, false); + catch + disp(c); + disp(rois{r}); + end + + case 'siips' + try + HRF{r}.([CondNames{c}, '_SIIPS_dp']).model=cell2mat(apply_siips(HRF_OBJ{c})); + HRF{r}.([CondNames{c}, '_SIIPS_cs']).model=cell2mat(apply_siips(HRF_OBJ{c}, 'cosine_similarity')); + HRF{r}.([CondNames{c}, '_SIIPS_r']).model=cell2mat(apply_siips(HRF_OBJ{c}, 'correlation')); + [HRF{r}.([CondNames{c}, '_SIIPS_dp']).peaks, HRF{r}.([CondNames{c}, '_SIIPS_dp']).troughs]=detectPeaksTroughs(HRF{r}.([CondNames{c}, '_SIIPS_dp']).model, false); + [HRF{r}.([CondNames{c}, '_SIIPS_cs']).peaks, HRF{r}.([CondNames{c}, '_SIIPS_cs']).troughs]=detectPeaksTroughs( HRF{r}.([CondNames{c}, '_SIIPS_cs']).model, false); + [HRF{r}.([CondNames{c}, '_SIIPS_r']).peaks, HRF{r}.([CondNames{c}, '_SIIPS_r']).troughs]=detectPeaksTroughs(HRF{r}.([CondNames{c}, '_SIIPS_r']).model, false); + catch + disp(c); + disp(rois{r}); + end + end + end + + + if isAtlasFound & ~ismember(rois{r}, {'all', 'nps', 'siips'}) + + + try + tc{r}{c}=mean(apply_mask(HRF_OBJ{c}, at.select_atlas_subset(rois(r), 'exact')).dat); + + HRF{r}.(CondNames{c}).model=tc{r}{c}; + [HRF{r}.(CondNames{c}).peaks, HRF{r}.(CondNames{c}).troughs]=detectPeaksTroughs(tc{r}{c}', false); + + [~, regionVoxNum, ~, ~]=at.select_atlas_subset(rois(r), 'exact').get_region_volumes; + HRF{r}.(CondNames{c}).model_voxnormed=tc{r}{c}/regionVoxNum; + [HRF{r}.(CondNames{c}).peaks_voxnormed, HRF{r}.(CondNames{c}).troughs_voxnormed]=detectPeaksTroughs(tc{r}{c}'/regionVoxNum, false); + catch + error("Error generating Peaks and Troughs. Check if Matlab Signal Processing Toolbox is Installed.") + % disp(t); + disp(c); + disp(rois{r}); + tc{r}{c} + mean(apply_mask(HRF_OBJ{c}, at.select_atlas_subset(rois(r), 'exact')).dat) + {apply_mask(HRF_OBJ{c}, at.select_atlas_subset(rois(r), 'exact')).dat} + HRF_OBJ + + end + + % Number of phases + start_times = [HRF{r}.(CondNames{c}).peaks.start_time, HRF{r}.(CondNames{c}).troughs.start_time]; + end_times = [HRF{r}.(CondNames{c}).peaks.end_time, HRF{r}.(CondNames{c}).troughs.end_time]; + phases = [start_times(:), end_times(:)]; + unique_phases = unique(phases, 'rows'); + HRF{r}.(CondNames{c}).phases = mat2cell(unique_phases, ones(size(unique_phases, 1), 1), 2); + + % Loop over phases + for p = 1:numel(HRF{r}.(CondNames{c}).phases) + features = {'peaks', 'troughs'}; + for f = 1:2 + feat = features{f}; + + % Count the number of features (peaks or troughs) + start_times = cell2mat({HRF{r}.(CondNames{c}).(feat).start_time}); + current_phase_start = HRF{r}.(CondNames{c}).phases{p}(1); + HRF{r}.(CondNames{c}).phase(p).(feat) = sum(start_times == current_phase_start); + + if HRF{r}.(CondNames{c}).phase(p).(feat) > 0 + display(['Estimating ' , '_', rois{r}, '_', CondNames{c}, '_', ' Now...!']); + display(['Phase ' , num2str(p), 'Feature ', feat]); + idx = find(start_times == current_phase_start); + auc = cell2mat({HRF{r}.(CondNames{c}).(feat).AUC}); + height = cell2mat({HRF{r}.(CondNames{c}).(feat).height}); + time_to_peak = cell2mat({HRF{r}.(CondNames{c}).(feat).time_to_peak}); + half_height = cell2mat({HRF{r}.(CondNames{c}).(feat).half_height}); + + feat_voxnormed = strcat(feat, '_voxnormed'); + auc_voxnormed = cell2mat({HRF{r}.(CondNames{c}).(feat_voxnormed).AUC}); + height_voxnormed = cell2mat({HRF{r}.(CondNames{c}).(feat_voxnormed).height}); + time_to_peak_voxnormed = cell2mat({HRF{r}.(CondNames{c}).(feat_voxnormed).time_to_peak}); + half_height_voxnormed = cell2mat({HRF{r}.(CondNames{c}).(feat_voxnormed).half_height}); + + HRF{r}.(CondNames{c}).phase(p).auc = unique(auc(idx)); + HRF{r}.(CondNames{c}).phase(p).auc_voxnormed = unique(auc_voxnormed(idx)); + + HRF{r}.(CondNames{c}).phase(p).height = height(idx); + HRF{r}.(CondNames{c}).phase(p).height_voxnormed = height_voxnormed(idx); + HRF{r}.(CondNames{c}).phase(p).time_to_peak = time_to_peak(idx); + HRF{r}.(CondNames{c}).phase(p).time_to_peak_voxnormed = time_to_peak_voxnormed(idx); + HRF{r}.(CondNames{c}).phase(p).half_height = half_height(idx); + HRF{r}.(CondNames{c}).phase(p).half_height_voxnormed = half_height_voxnormed(idx); + end + end + end + end + display(strjoin({' Done in ', num2str(toc), ' seconds with ', rois{r}})); + end + + end + + % HRF_struct=struct; + % if isAtlasFound + % HRF_struct.atlas=at; + % end + % HRF_struct.region=rois; + % HRF_struct.CondNames=CondNames; + % HRF_struct.fit=HRF; + % HRF=HRF_struct; + + + + % temp_HRF_fit = HRF_local; + % Save the results for this ROI + % display([num2str(t), ' Done!']) + % temp_HRF_fit{t} = HRF_local; + % tc{t} = tc; + + + % Transfer the results from the temporary cell array to the HRF structure + % HRF.fit = temp_HRF_fit; + % HRF.params=HRF_PARAMS; + + % delete(gcp('nocreate')); + + + +end \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/fitHRF_batch.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/fitHRF_batch.m new file mode 100644 index 00000000..bc4df6e0 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/fitHRF_batch.m @@ -0,0 +1,116 @@ +function [tc, HRF]=fitHRF_batch(datadir, HRF_PARAMS, rois, at, outfile) + % This is not done yet. + + % Make directories for files if needed + if ~isempty(fileparts(outfile)) + if ~exist(fileparts(outfile), 'dir') + mkdir(fileparts(outfile)); + end + end + + HRF.atlas=at.atlas_name; + HRF.region=rois; + HRF.types=HRF_PARAMS.types; + HRF.name=fmri_d.image_names; + + % Initialize 'tc' and 'temp_HRF_fit' cell arrays + tc = cell(1, numel(HRF_PARAMS.types); + temp_HRF_fit = cell(1, numel(HRF_PARAMS.types)); + + % Write out the images for later post-analyses + % [~, fname, ~]=fileparts(preproc_dat.image_names); + % fname=outfile + + HRF_local = cell(1, numel(HRF_PARAMS.types)); + tc_local = cell(1, numel(rois)); + + + canlab_list_subjects() + + fmri_data() + + + + for r=1:numel(rois) + tic + for c=1:numel(HRF_PARAMS.CondNames) + + try + tc_local{r}{c}=mean(apply_mask(HRF_OBJ{c}, at.select_atlas_subset(rois(r), 'exact')).dat); + + HRF_local{r}.(HRF_PARAMS.CondNames{c}).model=tc_local{r}{c}; + [HRF_local{r}.(HRF_PARAMS.CondNames{c}).peaks, HRF_local{r}.(HRF_PARAMS.CondNames{c}).troughs]=detectPeaksTroughs(tc_local{r}{c}', false); + + [~, regionVoxNum, ~, ~]=at.select_atlas_subset(rois(r), 'exact').get_region_volumes; + HRF_local{r}.(HRF_PARAMS.CondNames{c}).model_voxnormed=tc_local{r}/regionVoxNum; + [HRF_local{r}.(HRF_PARAMS.CondNames{c}).peaks_voxnormed, HRF_local{r}.(HRF_PARAMS.CondNames{c}).troughs_voxnormed]=detectPeaksTroughs(tc_local{r}{c}'/regionVoxNum, false); + catch + disp(t); + disp(c); + disp(rois{r}); + tc_local{r}{c} + mean(apply_mask(HRF_OBJ{c}, at.select_atlas_subset(rois(r), 'exact')).dat) + {apply_mask(HRF_OBJ{c}, at.select_atlas_subset(rois(r), 'exact')).dat} + HRF_OBJ{c} + + end + + % Number of phases + start_times = [HRF_local{r}.(HRF_PARAMS.CondNames{c}).peaks.start_time, HRF_local{r}.(HRF_PARAMS.CondNames{c}).troughs.start_time]; + end_times = [HRF_local{r}.(HRF_PARAMS.CondNames{c}).peaks.end_time, HRF_local{r}.(HRF_PARAMS.CondNames{c}).troughs.end_time]; + phases = [start_times(:), end_times(:)]; + unique_phases = unique(phases, 'rows'); + HRF_local{r}.(HRF_PARAMS.CondNames{c}).phases = mat2cell(unique_phases, ones(size(unique_phases, 1), 1), 2); + + % Loop over phases + for p = 1:numel(HRF_local{r}.(HRF_PARAMS.CondNames{c}).phases) + features = {'peaks', 'troughs'}; + for f = 1:2 + feat = features{f}; + + % Count the number of features (peaks or troughs) + start_times = cell2mat({HRF_local{r}.(HRF_PARAMS.CondNames{c}).(feat).start_time}); + current_phase_start = HRF_local{r}.(HRF_PARAMS.CondNames{c}).phases{p}(1); + HRF_local{r}.(HRF_PARAMS.CondNames{c}).phase(p).(feat) = sum(start_times == current_phase_start); + + if HRF_local{r}.(HRF_PARAMS.CondNames{c}).phase(p).(feat) > 0 + display(['Estimating ' , num2str(t), '_', rois{r}, '_', HRF_PARAMS.CondNames{c}, '_', ' Now...!']) + display(['Phase ' , num2str(p), 'Feature ', feat]) + idx = find(start_times == current_phase_start) + auc = cell2mat({HRF_local{r}.(HRF_PARAMS.CondNames{c}).(feat).AUC}) + height = cell2mat({HRF_local{r}.(HRF_PARAMS.CondNames{c}).(feat).height}) + time_to_peak = cell2mat({HRF_local{r}.(HRF_PARAMS.CondNames{c}).(feat).time_to_peak}) + half_height = cell2mat({HRF_local{r}.(HRF_PARAMS.CondNames{c}).(feat).half_height}) + + feat_voxnormed = strcat(feat, '_voxnormed') + auc_voxnormed = cell2mat({HRF_local{r}.(HRF_PARAMS.CondNames{c}).(feat_voxnormed).AUC}) + height_voxnormed = cell2mat({HRF_local{r}.(HRF_PARAMS.CondNames{c}).(feat_voxnormed).height}) + time_to_peak_voxnormed = cell2mat({HRF_local{r}.(HRF_PARAMS.CondNames{c}).(feat_voxnormed).time_to_peak}) + half_height_voxnormed = cell2mat({HRF_local{r}.(HRF_PARAMS.CondNames{c}).(feat_voxnormed).half_height}) + + HRF_local{r}.(HRF_PARAMS.CondNames{c}).phase(p).auc = unique(auc(idx)) + HRF_local{r}.(HRF_PARAMS.CondNames{c}).phase(p).auc_voxnormed = unique(auc_voxnormed(idx)) + + HRF_local{r}.(HRF_PARAMS.CondNames{c}).phase(p).height = height(idx) + HRF_local{r}.(HRF_PARAMS.CondNames{c}).phase(p).height_voxnormed = height_voxnormed(idx) + HRF_local{r}.(HRF_PARAMS.CondNames{c}).phase(p).time_to_peak = time_to_peak(idx) + HRF_local{r}.(HRF_PARAMS.CondNames{c}).phase(p).time_to_peak_voxnormed = time_to_peak_voxnormed(idx) + HRF_local{r}.(HRF_PARAMS.CondNames{c}).phase(p).half_height = half_height(idx) + HRF_local{r}.(HRF_PARAMS.CondNames{c}).phase(p).half_height_voxnormed = half_height_voxnormed(idx) + end + end + end + end + display([num2str(t), ' Done in ', toc, ' with ' rois(r)]); + + end + % Save the results for this ROI + display([num2str(t), ' Done!']) + temp_HRF_fit{t} = HRF_local; + tc{t} = tc_local; +end + +% Transfer the results from the temporary cell array to the HRF structure +HRF.fit = temp_HRF_fit; +HRF.params=HRF_PARAMS; +end \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/generateConditionTS.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/generateConditionTS.m new file mode 100644 index 00000000..f6f848ab --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/generateConditionTS.m @@ -0,0 +1,75 @@ +function Condition=generateConditionTS(fmri_d, conditions, onsets, durations, TR, varargin) + % Helper script to create condition vectors for hrf_fit_one_voxel() + % Michael Sun, Ph.D. + % - Takes fmri_data() object or the number of TRs in a 4D object. + % - conditions: cell-vector of cellstrings for each condition e.g., {'hot', 'warm', 'imagine'} + % - onsets: SPM-style onsets cell array in seconds from first-level model, e.g., {{1,2,3}, {4, 5, 6}, {7, 8, 9}} + % - duration: SPM-style duration cell array in seconds from first-level model, e.g., {{12, 12 ,12}, {12, 12, 12}, {12, 12, 12}} + % + % *Usage: + % :: + % Condition = generateConditionTS(image_obj, {'hot','warm','imagine'}, onsets, durations}) + % + % Note 1: Preset a SPIKES or SPIKETRAINS variable in order to toggle the + % generation of Spikes (single 1s) or Spiketrains (a train of 1s) to + % represent each event. + % + % Note 2: If there was slice-timing correction performed, then you will + % want to correct for it by adding 0.5TRs to all of your times. + + % Default to modeling single spike instead of duration of events. + if ~exist('SPIKES', 'var') && ~exist('SPIKETRAINS', 'var') + SPIKES=1; + SPIKETRAINS=0; + end + + if ~isempty(varargin) + SPIKETRAINS=1; + else + SPIKES=1; + end + + if strcmp(class(fmri_d), 'fmri_data') + n=size(fmri_d.dat,2); + elseif isnumeric(fmri_d) + n=fmri_d; + end + + + % Number of conditions + nconds = length(conditions); + + % Initialize the Condition cell array + for c = 1:nconds + Condition{c} = zeros(n,1); + end + + % Loop through each condition to process their times and update Condition + for c = 1:nconds + % Extract the times for the current condition + % current_times = eval([conditions{c} '_times{sub}{j}']); + current_times=onsets{c}/TR; + current_dur=durations{c}/TR; + + valid = current_times < n; + current_times = ceil(current_times(valid)); + current_dur = ceil(current_dur(valid)); + + % Correct any onsets that start at 0; + current_times(current_times < 1) = 1; + + % Model events as a single impulse + if SPIKES == 1 + Condition{c}(current_times) = 1; + end + + % Model event epochs as trains of events + if SPIKETRAINS == 1 + for i = 1:numel(current_times) + start_idx = current_times(i); + end_idx = min(n, current_times(i) + max(0, current_dur(i))); + Condition{c}(start_idx:end_idx) = 1; + end + end + end +end \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/generateHRFTable.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/generateHRFTable.m new file mode 100644 index 00000000..2821271a --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/generateHRFTable.m @@ -0,0 +1,66 @@ +function T=generateHRFTable(HRF) + % Generates a summary table from an HRF Structure. + % Michael Sun, Ph.D. + % - Takes HRF Structure object generated from EstimateHRF_inAtlas() + % + % *Usage: + % :: + % T=generateHRFTable(HRF_structure) + + % Pre-allocate a cell array to store the table data + tblData = cell(0, 10); % You may need to adjust the number of columns based on the data you're storing + + % Iterate through the nested loops as before + for c = 1:numel(HRF.CondNames) + for r = 1:numel(HRF.region) + for t = 1:numel(HRF.types) + + %disp(['Number of phases: ', num2str(numel(HRF.fit{t}{r}.(HRF.CondNames{c}).phases))]); + %for p = 1:numel(HRF.fit{t}{r}.(HRF.CondNames{c}).phases) + + % Define the basic information + type = HRF.types{t}; + region = HRF.region{r}; + condition = HRF.CondNames{c}; + phase_num = 0; + phase_span = [0 0]; + peaks = 0; + troughs = 0; + time_to_peak = 0; + auc = 0; + auc_voxnormed = 0; + + + if numel(HRF.fit{t}{r}.(HRF.CondNames{c}).phases) > 1 + for p = 1:numel(HRF.fit{t}{r}.(HRF.CondNames{c}).phases) + + phase_num = p; + phase_span = HRF.fit{t}{r}.(HRF.CondNames{c}).phases{p}; + + % Check for peaks and troughs + peaks = HRF.fit{t}{r}.(HRF.CondNames{c}).phase(p).peaks; + troughs = HRF.fit{t}{r}.(HRF.CondNames{c}).phase(p).troughs; + %disp(['Peaks: ', num2str(peaks), ', Troughs: ', num2str(troughs)]); + % Extract other data + time_to_peak = HRF.fit{t}{r}.(HRF.CondNames{c}).phase(p).time_to_peak; + auc = HRF.fit{t}{r}.(HRF.CondNames{c}).phase(p).auc; + auc_voxnormed = HRF.fit{t}{r}.(HRF.CondNames{c}).phase(p).auc_voxnormed; + % Append the data to the cell array + tblData = [tblData; {type, region, condition, phase_num, phase_span, peaks, troughs, time_to_peak, auc, auc_voxnormed}]; + end + else + % Append the data to the cell array + %disp(['c: ', num2str(c), ', t: ', num2str(t), ', r: ', num2str(r)]); + tblData = [tblData; {type, region, condition, phase_num, phase_span, peaks, troughs, time_to_peak, auc, auc_voxnormed}]; + + end + end + end + end + + % Convert the cell array to a table + T = cell2table(tblData, 'VariableNames', {'Type', 'Region', 'Condition', 'PhaseNum', 'PhaseSpan', 'Peaks', 'Troughs', 'TimeToPeak', 'AUC', 'AUC_VoxNormed'}); + + % Display the table + disp(T); +end \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/hrf_fit.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/hrf_fit.m new file mode 100644 index 00000000..23af37cb --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/hrf_fit.m @@ -0,0 +1,506 @@ +function [params_obj, hrf_obj, params_obj_dat, hrf_obj_dat, info] = hrf_fit(SPM,T,method,mode) +% HRF estimation on fmri_data class object +% +% HRF estimation function for a single voxel; +% +% Implemented methods include: IL-model (Deterministic/Stochastic), FIR +% (Regular/Smooth), and HRF (Canonical/+ temporal/+ temporal & dispersion). +% With SPM.mat, TR, and experimental design are imported. +% +% :Inputs: +% +% **SPM** +% SPM.mat file +% +% **T** +% length of estimated HRF ij seconds +% +% **type** +% Model type: 'FIR', 'IL', or 'CHRF' +% +% **mode** +% Mode +% +% :Model Types: +% +% A. **Fit HRF using IL-function** +% Choose mode (deterministic/stochastic) +% - 0 - deterministic aproach +% - 1 - simulated annealing approach +% +% Please note that when using simulated annealing approach you +% may need to perform some tuning before use. +% +% B. **Fit HRF using FIR-model** +% Choose mode (FIR/sFIR) +% - 0 - FIR +% - 1 - smooth FIR +% +% C. **Fit Canonical HRF** +% Choose mode (FIR/sFIR) +% - 0 - FIR +% - 1 - smooth FIR +% +% .. +% Created by Michael Sun on 02/20/24 +% .. + +if isstring(SPM) || ischar(SPM) + + load(SPM); + if ~exist('SPM', 'var') + error('Passed in filepath is not an SPM.mat file.') + end + +end + +if strcmp(method, 'sFIR') + method = 'FIR'; + mode = 1; +end + + +if isstruct(SPM) + fnames=unique({SPM.xY.VY.fname})'; + + parfor i=1:numel(fnames) + + if contains(fnames{i}, '/') && ispc + % PC Path conversion from Unix + if strcmp(fnames{i}(1,1:2), '\\') + d{i}=fmri_data(strrep(fnames{i}, '/', '\')); + else + d{i}=fmri_data(['\',strrep(fnames{i}, '/', '\')]); + end + + else + d{i}=fmri_data(fnames{i}); + end + end + + % Transform timeseries data into the way SPM desires. + gkwy_d=spmify(d,SPM); + + % Extract TR + TR = SPM.xY.RT; + + if isempty(T) + + if ~contains(SPM.xBF.name, 'Finite Impulse Response') + % For now, assume 'Canonical HRF' + disp('T is empty, generating T as 2 times the maximum duration for each task regressor.'); + for i = 1:numel(SPM.Sess) + T{i}=cellfun(@(cellArray) 2*ceil(max(cellArray)), {SPM.Sess(i).U.dur}); + end + else + + for i = 1:numel(SPM.Sess) + % T{i}=[SPM.xBF.order]; + % In normal SPM, the time window (length) and resolution (order) is set the same for all regressors. + % T should be in seconds, it gets converted to TRs later. + T{i}=repmat(SPM.xBF.length, size({SPM.Sess(i).U.dur})); + + % T{i}=repmat(SPM.xBF.order*TR, size({SPM.Sess(i).U.dur})); + + end + end + elseif numel(SPM.Sess)>1 && ~iscell(T) + % error('SPM structures concatenating multiple runs must have time windows passed in with a matching number of cell arrays.'); + T=repmat({T}, 1, numel(SPM.Sess)); + elseif iscell(T) && numel(SPM.Sess)~=numel(T) + error('Incompatible number of runs in SPM and in cell-array T.') + end + + % %% ONE WAY + % % run hrf_fit separately for every d + + % Tor: Better to pass in pseudoinverse matrix to speed up computation: + for i=1:numel(d) + % Reassign data. + d{i}.dat=gkwy_d{i}; + + % Extract TR + TR = SPM.xY.RT; + % + Runc=generateConditionTS(numel(SPM.Sess(i).row), [SPM.Sess(i).U.name], {SPM.Sess(i).U.ons}, {SPM.Sess(i).U.dur}, TR); + + % There's a need to pull apart the SPM design matrix for every run + % Column of regressors + covariates for each session + intercept + % X=SPM.xX.X(SPM.Sess(i).row, [SPM.Sess(i).col, SPM.xX.iB(i)]); + + % Trouble is, X is not filtered + % X=spm_filter(SPM.xX.K(i), SPM.xX.X(SPM.Sess(i).row, [SPM.Sess(i).col, SPM.xX.iB(i)])); + + % Trouble here is, task regressors may have to be re-generated for + % FIR and sFIR if the original design matrix is cHRF, so check the + % basis function + if strcmpi(method, 'FIR') && ~contains(SPM.xBF.name, 'Finite Impulse Response') + disp('Passed in SPM structure is not FIR. Reconstructing task-regressors for FIR fit'); + + len = numel(SPM.Sess(i).row); + + % Task regressors for each run can be found here: + numstim=numel(SPM.Sess(i).U); + + % Make the design matrix: + DX_all = cell(1, numstim); % Store DX matrices for each condition + tlen_all = zeros(1, numstim); % Store tlen for each condition + + for s=1:numstim + t = 1:TR:T{i}(s); + tlen_all(s) = length(t); + DX_all{s} = tor_make_deconv_mtx3(Runc(:,s), tlen_all(s), 1); + end + DX = horzcat(DX_all{:}); + + % due to horzcat, we will have multiple intercepts in this design matrix + % therefore, we'll find the intercepts, drop them, and add an intercept at the end of the matrix + intercept_idx = find(sum(DX)==len); + copyDX = DX; + copyDX(:,intercept_idx) = []; + + % Design Matrix Regressors + intercept: + DX = [copyDX ones(len,1)]; + + % Covariate Design Matrix for each session (without intercept): + NX=[SPM.Sess(i).C.C]; + + % Concatenate the Task regressors with Covariates + X=[DX, NX]; + % Filter + % X=spm_filter(SPM.xX.K(i), X); + + % filter is probably performed with spm_sp('Set', xX.K*xX.W*xX.X) + % X=spm_sp('Set', SPM.xX.K(i)*SPM.xX.W*SPM.xX.X) + + % xX.KxXs = spm_sp('Set',spm_filter(xX.K,W*xX.X)); % KWX + + % Ke says preprocess the nuisance regressors first before + % fitting the FIR, otherwise the design matrix has problematic + % VIFs between the FIR indicators and the spike regressors. + % xKNXs = spm_sp('Set',spm_filter(SPM.xX.K(i), SPM.xX.W(SPM.Sess(i).row, SPM.Sess(i).row)*NX)); % KW*Nuisance + % NX = full(xKNXs.X); + % d{i}=canlab_connectivity_preproc(d{i}, 'additional_nuisance', NX, TR,'no_plots') + % preproc_d=canlab_connectivity_preproc(d{i}, 'additional_nuisance', NX) + + % preproc_d=d{i}; + % d{i}.covariates=NX; + % d{i}=preprocess(d{i}, 'resid',1); % Residualize out noise regressors + + % sFIR + if mode == 1 + for s=1:numstim + t = 1:TR:T{i}(s); + tlen_all(s) = length(t); + end + + MRI = zeros(sum(tlen_all)+1); % adjust size based on varying tlen and intercept at end + start_idx = 1; + + for s=1:numstim + tlen = tlen_all(s); % get the tlen for this stimulus + + C = (1:tlen)'*(ones(1,tlen)); + h = sqrt(1/(7/TR)); + + v = 0.1; + sig = 1; + + R = v*exp(-h/2*(C-C').^2); + RI = inv(R); + + % Adjust the indices to account for varying tlen + end_idx = start_idx + tlen - 1; + MRI(start_idx:end_idx, start_idx:end_idx) = RI; + + start_idx = end_idx + 1; % update the starting index for next iteration + end + + % multiply a 0 penalty with NX. + cov_num = size(X,2)-size(MRI,2); % Number of nuisance covariates + pen{1} = sig^2*MRI; % Regularization Penalty Matrix + pen{2} = zeros(cov_num); % for nuisance, add no penalty + pen=blkdiag(pen{:}); + + % disp(pen) % Check what this looks like. + + % Filter everything, and then perform the pseudoinverse + xKXs = spm_sp('Set',spm_filter(SPM.xX.K(i), SPM.xX.W(SPM.Sess(i).row, SPM.Sess(i).row)*X)); % KW*Design + X = full(xKXs.X); + + PX = inv(X'*X+pen)*X'; + % PX = spm_sp('x-', X'*X+pen); % SPM's way of performing + % the pseudoinversion. Need a way to put in in SPM's xKXs + % space structure. + % PX = PX*X' + clear pen; + + else + % Filter and invert + xKXs = spm_sp('Set',spm_filter(SPM.xX.K(i), SPM.xX.W(SPM.Sess(i).row, SPM.Sess(i).row)*X)); % KW*Design + X = full(xKXs.X); + % PX = pinv(X); + PX = spm_sp('x-', xKXs); % SPM's way of performing the pseudoinversion. + % figure, imagesc(X), clim([-.01 .01]) + end + + elseif contains(method, 'CHRF') && ~contains(SPM.xBF.name, 'hrf') + % Problem here is we have onsets but no durations from a + % FIR matrix. If we use FIR + + switch method + case 'CHRF0' + p=1; + case 'CHRF1' + p=2; + case 'CHRF2' + p=3; + end + % Generate a design matrix + Run=Runc; + d = length(Run); + len = length(Run{1}); + + % Constructing the Design Matrix X: + X = zeros(len,p*d); + + [h, dh, dh2] = CanonicalBasisSet(TR); + + for j=1:d, + v = conv(Run{j},h); + X(:,(j-1)*p+1) = v(1:len); + + % Computing the first derivative + if strcmpi(method, 'CHRF1') + v = conv(Run{j},dh); + X(:,(j-1)*p+2) = v(1:len); + end + + % Computing the second derivative + if strcmpi(method, 'CHRF2') + v = conv(Run{j},dh2); + X(:,(j-1)*p+3) = v(1:len); + end + end + else + % Otherwise set X to be the filtered design matrix of that + % section. + + % If using sFIR, pre-penalize the pseudoinverted design matrix + if strcmpi(method, 'FIR') && mode==1 + + % Task regressors for each run can be found here: + numstim=numel(SPM.Sess(i).U); + + % Initialize an array to hold the length of regressors for each task + tlen_all = []; + + % Extract condition names for the session + condition_names = {SPM.Sess(i).U.name}; + + % Loop through condition names to find lengths for each task + for c = 1:length(condition_names) + condition = condition_names{c}{1}; % Adjusted for direct use without {1} + % Find indices of regressors matching this condition + condition_idx = find(contains(SPM.xX.name, ['Sn(', num2str(i), ') ', condition])); + + if ~isempty(condition_idx) + % Calculate the length of this task's block of regressors + task_length = max(condition_idx) - min(condition_idx) + 1; + % Add this length to the tlen_all array + tlen_all(end+1) = task_length; + end + end + + MRI = zeros(sum(tlen_all)+1); % adjust size based on varying tlen and intercept at end + start_idx = 1; + + for s=1:numstim + tlen = tlen_all(s); % get the tlen for this stimulus + + C = (1:tlen)'*(ones(1,tlen)); + h = sqrt(1/(7/TR)); + + v = 0.1; + sig = 1; + + R = v*exp(-h/2*(C-C').^2); + RI = inv(R); + + % Adjust the indices to account for varying tlen + end_idx = start_idx + tlen - 1; + MRI(start_idx:end_idx, start_idx:end_idx) = RI; + + start_idx = end_idx + 1; % update the starting index for next iteration + end + + % Create Penalty Matrix + cov_num = size(SPM.Sess(i).C.C, 2); + pen{1} = sig^2*MRI; % Regularization Penalty Matrix + pen{2} = zeros(cov_num); % for nuisance, add no penalty + pen=blkdiag(pen{:}); + % disp(pen) % Check what this looks like. + + % Invert + X=SPM.xX.xKXs.X(SPM.Sess(i).row, [SPM.Sess(i).col, SPM.xX.iB(i), SPM.xX.iG]); + + % Now penalize SPM's task regressors + PX = inv(X'*X+pen)*X'; + clear pen; + + else + X=SPM.xX.xKXs.X(SPM.Sess(i).row, [SPM.Sess(i).col, SPM.xX.iB(i), SPM.xX.iG]); + % Troubleshooting: + % For some reason the above differs from this: + % X=spm_filter(SPM.xX.K(i), SPM.xX.X(SPM.Sess(i).row, [SPM.Sess(i).col, SPM.xX.iB(i), SPM.xX.iG])); + % X_SPM=SPM.xX.xKXs.X(SPM.Sess(i).row, [SPM.Sess(i).col, SPM.xX.iB(i), SPM.xX.iG]); + % figure, imagesc(X_SPM), clim([-.01 .01]) + % Grab SPM's prefiltered pseudoinverse matrix + PX=SPM.xX.pKX([SPM.Sess(i).col, SPM.xX.iB(i), SPM.xX.iG], SPM.Sess(i).row); + + end + + + end + + % ~ 40 min + % [params_obj{i}, hrf_obj{i}, params_obj_dat{i}, hrf_obj_dat{i}] = hrf_fit(d{i},TR,Runc,T{i},method,mode,X); + % The reason this takes so long is because of the pseudoinversion + % of the design matrix. If we have access to the SPM design matrix, + % we should pass in both DX and its inversion. + + [params_obj{i}, hrf_obj{i}, params_obj_dat{i}, hrf_obj_dat{i}] = hrf_fit(d{i},TR,Runc,T{i},method,mode,X, 'invertedDX', PX); + + + % Test + + % [params_obj1{i}, hrf_obj1{i}, params_obj_dat1{i}, hrf_obj_dat1{i}] = hrf_fit(d{i},TR,Runc,T{i},method,mode,X); + % + % [params_obj2{i}, hrf_obj2{i}, params_obj_dat2{i}, hrf_obj_dat2{i}] = hrf_fit(d{i},TR,Runc,T{i},method,mode,X_SPM); + % + % [params_obj3{i}, hrf_obj3{i}, params_obj_dat3{i}, hrf_obj_dat3{i}] = hrf_fit(d{i},TR,Runc,T{i},method,mode,X_SPM); + % + % [params_obj4{i}, hrf_obj4{i}, params_obj_dat4{i}, hrf_obj_dat4{i}] = hrf_fit(d{i},TR,Runc,T{i},method,1,X_SPM); + + info{i}.X=X; + info{i}.names=[SPM.Sess(i).U.name]'; + + % Testing + % Assuming i is defined, and your variables d, TR, Runc, T, method, mode, X, and X_SPM are initialized + + % Start or get the current parallel pool + % pool = gcp(); + % + % % Asynchronously call hrf_fit with the first set of arguments + % future1 = parfeval(pool, @hrf_fit, 4, d{i}, TR, Runc, T{i}, method, mode, X); + % + % % Asynchronously call hrf_fit with the second set of arguments + % future2 = parfeval(pool, @hrf_fit, 4, d{i}, TR, Runc, T{i}, method, mode, X_SPM); + % + % % Wait for the first job to finish and collect its results + % [params_obj1{i}, hrf_obj1{i}, params_obj_dat1{i}, hrf_obj_dat1{i}] = fetchOutputs(future1); + % + % % Wait for the second job to finish and collect its results + % [params_obj2{i}, hrf_obj2{i}, params_obj_dat2{i}, hrf_obj_dat2{i}] = fetchOutputs(future2); + + end + % Possibly need to drop null regressors? No + + + % betas = filenames(fullfile(pwd, 'data', 'sub-SID000743', '*heat_start*', '*ses-12*bodymap_run*.nii')); + % apply_nps([betas(3), betas(4), betas(2), betas(1)]) + % + % % Bug testing + % nps_test_cHRF={apply_nps(hrf_obj_dat3{1}), apply_nps(hrf_obj_dat3{2}), apply_nps(hrf_obj_dat3{3}), apply_nps(hrf_obj_dat3{4})} + % nps_test_FIR={apply_nps(hrf_obj_dat1{1}), apply_nps(hrf_obj_dat1{2}), apply_nps(hrf_obj_dat1{3}), apply_nps(hrf_obj_dat1{4})} + % nps_test_sFIR={apply_nps(hrf_obj_dat2{1}), apply_nps(hrf_obj_dat2{2}), apply_nps(hrf_obj_dat2{3}), apply_nps(hrf_obj_dat2{4})} + % + % figure + % plot([nps_test_FIR{1}{4}], 'r--'), hold on + % plot([nps_test_FIR{2}{4}], 'g--'), hold on + % plot([nps_test_FIR{3}{4}], 'b--'), hold on + % plot([nps_test_FIR{4}{1}], 'm--'), hold on + % + % plot([nps_test_FIR{1}{4}, nps_test_FIR{2}{4},nps_test_FIR{3}{4},nps_test_FIR{4}{1}]), hold on + % + % plot([nps_test_sFIR{1}{4}], 'r-'), hold on + % plot([nps_test_sFIR{2}{4}], 'g-'), hold on + % plot([nps_test_sFIR{3}{4}], 'b-'), hold on + % plot([nps_test_sFIR{4}{1}], 'm-'), hold on + % + % plot([nps_test_sFIR{1}{4}, nps_test_sFIR{2}{4},nps_test_sFIR{3}{4},nps_test_sFIR{4}{1}]), hold on + % + % hline(0,'k-') + % legend({'run1', 'run2', 'run3'}) + % legend({'run1', 'run2', 'run3', 'run4'}) + % + % % compare with betas + % + % % Check out what d looks like + % nps_d=apply_nps(d) + % plot([nps_d{:}]) + % + % % Checkout what spmify did + % figure + % nps_gkwyd=apply_nps(gkwy_data) + % plot([nps_gkwyd{:}]) + % + % % Plot to compare + % plot([nps_d{:}]), hold on + % plot([nps_gkwyd{:}]), hold on + % + % plot([nps_d{1}]), hold on + % plot([nps_gkwyd{1}]), hold on + % legend({'pre', 'post'}) + % + % plot([nps_d{2}]), hold on + % plot([nps_gkwyd{2}]), hold on + % legend({'pre', 'post'}) + % + % plot([nps_d{3}]), hold on + % plot([nps_gkwyd{3}]), hold on + % legend({'pre', 'post'}) + % + % plot([nps_d{4}]), hold on + % plot([nps_gkwyd{4}]), hold on + % legend({'pre', 'post'}) + % + + + + + + + +end + +end + + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +% +% Subfunctions +% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +function [h, dh, dh2] = CanonicalBasisSet(TR) + +len = round(30/TR); +xBF.dt = TR; +xBF.length= len; +xBF.name = 'hrf (with time and dispersion derivatives)'; +xBF = spm_get_bf(xBF); + +v1 = xBF.bf(1:len,1); +v2 = xBF.bf(1:len,2); +v3 = xBF.bf(1:len,3); + +h = v1; +dh = v2 - (v2'*v1/norm(v1)^2).*v1; +dh2 = v3 - (v3'*v1/norm(v1)^2).*v1 - (v3'*dh/norm(dh)^2).*dh; + +h = h./max(h); +dh = dh./max(dh); +dh2 = dh2./max(dh2); + +end \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/makeHRFstruct.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/makeHRFstruct.m new file mode 100644 index 00000000..b3193530 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/makeHRFstruct.m @@ -0,0 +1,81 @@ +function HRF_struct = makeHRFstruct(HRF_OBJ, CondNames, varargin) + + isSigFound = false; + isAtlasFound = false; + isRegsFound = false; + r = []; + s = []; + + if isa(HRF_OBJ, 'fmri_data') + HRF_OBJ={HRF_OBJ}; + end + + % Initialize the parallel pool if it's not already running + % if isempty(gcp('nocreate')) + % parpool; + % end + + for k = 1:length(varargin) + if strcmpi(varargin{k}, 'atlas') + if isa(varargin{k+1}, 'atlas') + at=varargin{k+1}; + else + error('Passed in atlas not an atlas.'); + end + + isAtlasFound = true; + end + + if strcmpi(varargin{k}, 'regions') + if isAtlasFound + if ischar(varargin{k+1}) + r={varargin{k+1}}; + isRegsFound = true; + elseif iscell(varargin{k+1}) + r=varargin{k+1}; + isRegsFound = true; + end + else + error('Cannot extract HRF of regions without atlas') + end + end + + if isAtlasFound && ~isRegsFound + % Extract all atlas regions. + r=at.labels; + end + + if strcmpi(varargin{k}, 'sig') % Expected input: 'nps', 'siips', or 'all' + if ischar(varargin{k+1}) + s={varargin{k+1}}; + isSigFound = true; + elseif iscell(varargin{k+1}) + s=varargin{k+1}; + isSigFound = true; + else + error(['Input argument for sig is unknown.']); + end + + end + end + + % ROIs will consist of regions and signatures + rois = [r, s]; + + CondNames=matlab.lang.makeValidName(CondNames); + + + + HRF=extractHRF(HRF_OBJ, CondNames, varargin{:}); + + HRF_struct=struct; + + if isAtlasFound + HRF_struct.atlas=at; + end + + HRF_struct.region=rois; + HRF_struct.CondNames=CondNames; + HRF_struct.fit=HRF; + +end \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/plotHRF.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/plotHRF.m new file mode 100644 index 00000000..887c5869 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/plotHRF.m @@ -0,0 +1,494 @@ +function [model, models]=plotHRF(HRF, varargin) + % Generates a plot from an HRF Structure given a specified fit type and a region name. + % Michael Sun, Ph.D. + % - Takes HRF Structure object generated from EstimateHRF_inAtlas() + % - d is for selecting which data to plot, default is to aggregate them + % all. Putting a vector plots each file separately in order e.g., [1 2 3] + % - 'fit', t is a cellstring for fit type e.g., 'FIR', 'sFIR', 'IL', 'CHRF0', 'CHRF1', 'CHRF2' + % - 'conditions', c is a cellstring for condition names or stems to plot e.g., {'*hot*, *warm*'} + % - 'regions', r is a cellstring for region label from atlas.labels. e.g., 'ACC', or a cell-array of regions to compare relative to each other e.g., {'ACC', 'DLPFC'} + % + % *Usage: + % :: + % plotHRF(HRF_structure, 'FIR', 'CA2_Hippocampus_') + + % Check if the specified region 'r' and type 't' exist in HRF + % Requires the MATlab Signal Processing Toolbox to run + % detectPeaksandTroughs() + + % Flags to keep track of whether a cell array or atlas object is found + % isCellArrayFound = false; + + isAtlasFound = false; + isRegsFound = false; + isCondFound = false; + isFitFound = false; + + r=[]; + + + + % Get the list of conditions from HRF_PARAMS + conds = HRF.CondNames; + + for k = 1:length(varargin) + if strcmpi(varargin{k}, 'atlas') + if isa(varargin{k+1}, 'atlas') + at=varargin{k+1}; + else + error('Passed in atlas not an atlas.'); + end + isAtlasFound = true; + end + + if strcmpi(varargin{k}, 'regions') + if ischar(varargin{k+1}) + r=varargin{k+1}; + % Find the indices of the specified region and type + reg = find(ismember(HRF.region, r)); + + if ~ismember(r, HRF.region) + error('Invalid region specified.'); + end + + elseif iscell(varargin{k+1}) + r=varargin{k+1}; + isRegsFound = true; + end + + end + + if strcmpi(varargin{k}, 'conditions') + if ischar(varargin{k+1}) + conds=varargin{k+1}; + + if ~ismember(conds, HRF.CondNames) + error('Invalid condition specified.'); + end + + elseif iscell(varargin{k+1}) + conds=varargin{k+1}; + isCondFound = true; + else + disp(['Input argument for condition unknown.']); + end + + end + + if strcmpi(varargin{k}, 'fit') + if ischar(varargin{k+1}) + t=varargin{k+1}; + typ = find(ismember(HRF.types, t)); + disp(['Plotting ', t]) + + if ~ismember(t, HRF.types) + error('Invalid fit-type specified.'); + else + isFitFound = true; + end + elseif iscell(varargin{k+1}) + %% FIX THIS PART + t=varargin{k+1}; + isFitFound = true; + else + disp(['Input argument for fit-type unknown.']); + end + + end + end + + if isAtlasFound == false + at=HRF.atlas; + end + + if isRegsFound == false + reg=1:numel(HRF.region); + end + + if isCondFound == false + conds=HRF.CondNames; + end + + if isFitFound == false + % Default to the first fit-type; + typ=1; + end + + + % end + % + % if isempty(r) && ~isCellArrayFound + % r=HRF.region; + % end + + + + % disp(reg) + % disp(typ) + + + % % Initialize a cell array to store the model matrices + % array3D = cell(1, length(conds)); + % + % % Populate the cell array with model matrices for each condition + % for c = 1:numel(conds) + % for i = 1:numel(r) + % if isfield(HRF.fit{typ}{i}, conds{f}) && isfield(HRF.fit{typ}{i}.(conds{f}), 'model') + % array3D{c}{i} = HRF.fit{typ}{i}.(conds{f}).model; + % else + % error('Model data not found for condition: %s', conds{f}); + % end + % end + % end + % + % % Check if any model matrices were found + % if all(cellfun(@isempty, array3D)) + % error('No model data found for the specified type and region.'); + % end + % + % % Concatenate the model matrices along the third dimension + % array3D = cat(3, array3D{:}); + + % Initialize color map and legend entries + % colors = jet(numel(conds)); + + colors = cbrewer2('qual', 'Accent', numel(reg)+1); + if numel(reg)>=4 + colors(4,:)=[]; % Remove yellow, too hard to see + end + model={}; + models={}; + + % Create a figure + figure; + + % Loop through each condition and plot + for cond = 1:numel(conds) + subplot(numel(conds), 1, cond); + + fieldname=fieldnames(HRF.fit{typ}{1}); + fld=char(fieldname(find(contains(fieldname, conds{cond})))); + + if numel(reg)==1 + model=HRF.fit{typ}{reg}.(conds{cond}).model; + + + % plot(HRF.fit{typ}{reg}.(conds{cond}).model, '-') + + % detectPeaksTroughs(squeeze(array3D(:, :, cond))', true); + detectPeaksTroughs(HRF.fit{typ}{reg}.(conds{cond}).model', true); + hold on; + + % Plot Standard Error if possible + try + se=HRF.fit{typ}{reg}.(conds{cond}).wse; + % Plot error shade: + x = 1:length(model); + upper_bound = model + se; + lower_bound = model - se; + % Create x values for fill (concatenate forward and reverse x values) + x_fill = [x, fliplr(x)]; + % Create y values for fill (concatenate upper_bound and reverse of lower_bound) + y_fill = [upper_bound, fliplr(lower_bound)]; + fill_color = [0.7, 0.7, 0.7]; % Change to desired color + fill_alpha = 0.3; % Transparency, change to desired value + fill(x_fill, y_fill, fill_color, 'FaceAlpha', fill_alpha, 'EdgeColor', 'none'); + hold on; + catch + + end + + hline(0); + + + if isfield(HRF.fit{typ}{reg}, conds{cond}) && isfield(HRF.fit{typ}{reg}.(fld), 'models') + models=HRF.fit{typ}{reg}.(conds{cond}).models; + + % Plot all underlying sublines + % for m = 1:height(HRF.fit{typ}{reg}.(conds{cond}).models) + % + % plot(HRF.fit{typ}{reg}.(conds{cond}).models(m,:), '-', 'Color', [0.9,0.9,0.9,0.2], 'LineWidth', 0.2); + % hold on; + % end + + end + + region=format_strings_for_legend(r(1)); + region=region{1}; + title({['Condition ', conds{cond}, ' Fit-type: ', strjoin(HRF.types(typ)), ' Regions: ', strjoin(HRF.region(reg))], ['Error: ', 'within-subject SE']}, 'Interpreter', 'none'); + + + else + h=[]; % Linehandles for legend and labels + for kk = 1:numel(reg) + i = reg(kk); + [~, regionVoxNum, ~, ~]=at.select_atlas_subset(HRF.region(i), 'exact').get_region_volumes; + if isfield(HRF.fit{typ}{i}, fld) && isfield(HRF.fit{typ}{i}.(fld), 'models') + models{i}=HRF.fit{typ}{i}.(fld).models/regionVoxNum; + end + + model{i}=HRF.fit{typ}{i}.(fld).model/regionVoxNum; + + % Plot Standard Error if possible + try + se{i}=HRF.fit{typ}{i}.(fld).wse/regionVoxNum; + + % Plot error shade: + x = 1:length(model{i}); + upper_bound = model{i} + se{i}; + lower_bound = model{i} - se{i}; + % Create x values for fill (concatenate forward and reverse x values) + x_fill = [x, fliplr(x)]; + % Create y values for fill (concatenate upper_bound and reverse of lower_bound) + y_fill = [upper_bound, fliplr(lower_bound)]; + fill_color = colors(i,:); % Change to desired color + fill_alpha = 0.3; % Transparency, change to desired value + fill(x_fill, y_fill, fill_color, 'FaceAlpha', fill_alpha, 'EdgeColor', 'none'); + hold on; + catch + + end + + h(i)=plot(HRF.fit{typ}{i}.(fld).model/regionVoxNum, '-', 'Color', colors(kk,:), 'DisplayName', char(format_strings_for_legend(HRF.region(i)))); + hline(0); + title({['Condition ', conds{cond}, ' Fit-type: ', strjoin(HRF.types(typ)), ' Regions: ', strjoin(HRF.region(reg))], ['Error: ', 'within-subject SE']}, 'Interpreter', 'none'); + + label(h(end), format_strings_for_legend(HRF.region(i)), 'location', 'left', 'FontWeight', 'bold', 'Margin', 3, 'HorizontalAlignment', 'right', 'FontSize', 14); + label(h(end), format_strings_for_legend(HRF.region(i)), 'location', 'right', 'FontWeight', 'bold', 'Margin', 3, 'HorizontalAlignment', 'right', 'FontSize', 14); + label(h(end), format_strings_for_legend(HRF.region(i)), 'location', 'center', 'FontWeight', 'bold', 'Margin', 3, 'HorizontalAlignment', 'right', 'FontSize', 14); + label(h(end), format_strings_for_legend(HRF.region(i)), 'location', 'top', 'FontWeight', 'bold', 'Margin', 3, 'HorizontalAlignment', 'right', 'FontSize', 14); + + hold on; + end + % region_labels = format_strings_for_legend(r); + % legend(region_labels); + legend(h, format_strings_for_legend(HRF.region(reg))); + end + end + + +end + + + + + + +function plotRegionalHrfSummaries(HRF, regs) + % data: Cell array with the data to be processed + % conditions: Cell array with condition names + % signalNames: Cell array with signal names + % specificRegions: Cell array with specific regions for each signal and condition + + % Get specific regions for this signal and condition + regs = getSpecificRegions(specificRegions, i, c); + + regs=HRF.region; + + % regs={'ACC'}; + % Generate a set of maximally different colors + colormap('jet'); % Set colormap to parula + colors = colormap; % Get the colormap matrix + % + % % Select maximally different colors + nColors = numel(regs); + indices = round(linspace(1, size(colors, 1), nColors)); + maxDifferentColors = colors(indices, :); + + % plot time-to-peak, height, width, start_time, and end_time of every + % peaks_voxnormed and trough_voxnormed for a region + + create_figure(['Regional HRF summaries for ', HRF.CondNames{c}]); + for c = 1:numel(HRF.CondNames) + + + handles = []; + subplot(2, 2, c) + + for r=1:numel(regs) + + + % if c==3 + % + % % mean time-to-peak + % meant=[mean(dat{i}(dat{i}.condition==[conds{c},'-cue'] & dat{i}.region==regs{r}, :).t), ... + % mean(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).t)+20]; + % + % % standard error time-to-peak + % stet=[std(dat{i}(dat{i}.condition==[conds{c}, '-cue'] & dat{i}.region==regs{r}, :).t)/sqrt(numel(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).t)), ... + % std(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).t)/sqrt(numel(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).t))]; + % + % % mean height + % meanh=[mean(dat{i}(dat{i}.condition==[conds{c},'-cue'] & dat{i}.region==regs{r}, :).h), ... + % mean(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).h)]; + % % height standard error + % steh=[std(dat{i}(dat{i}.condition==[conds{c},'-cue'] & dat{i}.region==regs{r}, :).h)/sqrt(numel(dat{i}(dat{i}.condition==[conds{c},'-cue'] & dat{i}.region==regs{r}, :).h)), ... + % std(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).h)/sqrt(numel(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).h))]; + % + % % minimum width + % minw=meant-[mean(dat{i}(dat{i}.condition==[conds{c},'-cue'] & dat{i}.region==regs{r}, :).w_times(:,1)), mean(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).w_times(:,1))+20]; + % % maximum width + % maxw=[mean(dat{i}(dat{i}.condition==[conds{c},'-cue'] & dat{i}.region==regs{r}, :).w_times(:,2)), mean(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).w_times(:,2))+20]-meant; + % + % handles(end+1)=errorbar(meant, meanh, steh, steh, stet, stet, 'o-', 'Color', maxDifferentColors(r,:)); + % + % % Calculate the position and size of the rectangle + % rectX = meant(1) - minw(1); % left boundary of the rectangle + % rectY = meanh(1) - max(steh(1)); % bottom boundary of the rectangle + % rectWidth = (meant(1)+maxw(1))-(meant(1)-minw(1)); % width of the rectangle + % rectHeight = 2*steh(1); % height of the rectangle + % + % % Draw the rectangle + % % rectangle('Position', [rectX, rectY, rectWidth, rectHeight], 'FaceColor', colors(r,:), 'LineWidth', 1); + % patch([rectX, rectX+rectWidth, rectX+rectWidth, rectX], [rectY, rectY, rectY+rectHeight, rectY+rectHeight], maxDifferentColors(r,:), 'FaceAlpha', 0.2); + % + % % Calculate the position and size of the rectangle + % rectX = meant(2) - minw(2); % left boundary of the rectangle + % rectY = meanh(2) - max(steh(2)); % bottom boundary of the rectangle + % rectWidth = (meant(2)+maxw(2))-(meant(2)-minw(2)); % width of the rectangle + % rectHeight = 2*steh(2); % height of the rectangle + % + % % Draw the rectangle + % % rectangle('Position', [rectX, rectY, rectWidth, rectHeight], 'EdgeColor', 'r', 'LineWidth', 1); + % patch([rectX, rectX+rectWidth, rectX+rectWidth, rectX], [rectY, rectY, rectY+rectHeight, rectY+rectHeight], maxDifferentColors(r,:), 'FaceAlpha', 0.2); + % + % label(handles(end), format_strings_for_legend(regs{r}), 'location', 'right', 'FontWeight', 'bold', 'Margin', 3, 'HorizontalAlignment', 'right', 'FontSize', 14); + % label(handles(end), format_strings_for_legend(regs{r}), 'location', 'left', 'FontWeight', 'bold', 'Margin', 3, 'HorizontalAlignment', 'right', 'FontSize', 14); + % % label(handles(end), format_strings_for_legend(regs{r}), 'location', 'right', 'slope', 'FontWeight', 'bold', 'Margin', 3, 'HorizontalAlignment', 'right', 'FontSize', 14); + % % label(handles(end), format_strings_for_legend(regs{r}), 'location', 'top', 'slope', 'FontWeight', 'bold', 'Margin', 3, 'HorizontalAlignment', 'right', 'FontSize', 14); + % % label(handles(end), format_strings_for_legend(regs{r}), 'location', 'bottom', 'slope', 'FontWeight', 'bold', 'Margin', 3, 'HorizontalAlignment', 'right', 'FontSize', 14); + % + % + % else + % mean time-to-peak + + meant=[]; + meanh=[]; + minw=[]; + maxw=[]; + + for p=1:numel(HRF.fit{1}{r}.(HRF.CondNames{c}).peaks_voxnormed) + + % meant=mean(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).t); + meant=[meant, HRF.fit{1}{r}.(HRF.CondNames{c}).peaks_voxnormed(p).time_to_peak]; + + % standard error time-to-peak + % stet=std(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).t)/sqrt(numel(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).t)); + + % mean height + % meanh=mean(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).h); + meanh=[meanh, HRF.fit{1}{r}.(HRF.CondNames{c}).peaks_voxnormed(p).height]; + + % height standard error + % steh=std(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).h)/sqrt(numel(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).h)); + + % minimum width + % minw=meant-mean(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).w_times(:,1)); + % maximum width + % maxw=mean(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).w_times(:,2))-meant; + + minw=[minw, HRF.fit{1}{r}.(HRF.CondNames{c}).peaks_voxnormed(p).start_time]; + maxw=[maxw, HRF.fit{1}{r}.(HRF.CondNames{c}).peaks_voxnormed(p).end_time]; + + end + + for t=1:numel(HRF.fit{1}{r}.(HRF.CondNames{c}).troughs_voxnormed) + + % meant=mean(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).t); + meant=[meant, HRF.fit{1}{r}.(HRF.CondNames{c}).troughs_voxnormed(t).time_to_peak]; + + % standard error time-to-peak + % stet=std(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).t)/sqrt(numel(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).t)); + + % mean height + % meanh=mean(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).h); + meanh=[meanh, HRF.fit{1}{r}.(HRF.CondNames{c}).troughs_voxnormed(t).height]; + + % height standard error + % steh=std(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).h)/sqrt(numel(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).h)); + + % minimum width + % minw=meant-mean(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).w_times(:,1)); + % maximum width + % maxw=mean(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).w_times(:,2))-meant; + + minw=[minw, HRF.fit{1}{r}.(HRF.CondNames{c}).troughs_voxnormed(t).start_time]; + maxw=[maxw, HRF.fit{1}{r}.(HRF.CondNames{c}).troughs_voxnormed(t).end_time]; + + end + + % handles(end+1)=errorbar(meant, meanh, steh, steh, stet, stet, 'o', 'Color', maxDifferentColors(r,:)); + [~,vox,~,~]=get_region_volumes(at); + vox=vox(r); + se=barplot_get_within_ste(HRF.fit{1}{r}.(HRF.CondNames{c}).models); + se=se/vox; + + se=repmat(se, 1, numel(meant)); + + handles=[handles, errorbar(meant, meanh, se, se, repmat(0, 1, numel(meant)), repmat(0, 1, numel(meant)), 'o-', 'Color', maxDifferentColors(r,:))]; + + if numel(meant) > 1 + label(handles(end), format_strings_for_legend(regs{r}), 'location', 'left', 'FontWeight', 'bold', 'Margin', 3, 'HorizontalAlignment', 'right', 'FontSize', 14); + label(handles(end), format_strings_for_legend(regs{r}), 'location', 'right', 'FontWeight', 'bold', 'Margin', 3, 'HorizontalAlignment', 'right', 'FontSize', 14); + else + label(handles(end), format_strings_for_legend(regs{r}), 'location', 'right', 'FontWeight', 'bold', 'Margin', 3, 'HorizontalAlignment', 'right', 'FontSize', 14); + end + + % % Calculate the position and size of the rectangle + % rectX = meant - minw; % left boundary of the rectangle + % % rectY = meanh - max(steh); % bottom boundary of the rectangle + % rectY = meanh - meanh/10; % placeholder for now + % rectWidth = (meant+maxw)-(meant-minw); % width of the rectangle + % % rectHeight = 2*steh; % height of the rectangle + % rectHeight = 2*(meanh/10); % placeholder for now + % + % patch([rectX, rectX+rectWidth, rectX+rectWidth, rectX], [rectY, rectY, rectY+rectHeight, rectY+rectHeight], maxDifferentColors(r,:), 'FaceAlpha', 0.2); + + + % end + + hold on + end + + hold off + + xlim([0,45]); + + hlin=refline(0,0); + hlin.Color='k'; + + vline(13/0.46, 'r--'); + + legend(handles, format_strings_for_legend(regs), 'Location', 'best'); + ylabel('Similarity Amplitude') + xticks([0:5:45]) + xticklabels([0:5:45]*0.46) + xlabel('Time to Peak (seconds)'); + % title({[signames{i}, ' Condition: ', conds{c}], 'HRF Summary Statistics', 'With Standard Errors, Stimulus offset is demarcated'}) + title({['Condition: ', HRF.CondNames{c}], 'HRF Summary Statistics', 'With Standard Errors, Stimulus offset is demarcated'}) + + end +end + +function regs = getSpecificRegions(specificRegions, signalIdx, conditionIdx) + % Extract and/or calculate the specific regions for the given signal and condition. + % specificRegions: Cell array defining specific regions for each signal and condition + % signalIdx: Index of the current signal + % conditionIdx: Index of the current condition + + % Example logic - adapt as per your actual requirements + regs = specificRegions{signalIdx, conditionIdx}; +end + + +% +% barplot_columns() +% +% model3d=cat(3, WASABIROIs_HRF_3.fit{1}{1}.hot.models, WASABIROIs_HRF_3.fit{1}{1}.warm.models, WASABIROIs_HRF_3.fit{1}{1}.imagine.models) +% +% [se_within, stats]=barplot_get_within_ste(WASABIROIs_HRF_3.fit{1}{1}.hot.models) + + + + + diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/plotRegionalHrfSummaries.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/plotRegionalHrfSummaries.m new file mode 100644 index 00000000..f48fa883 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/plotRegionalHrfSummaries.m @@ -0,0 +1,201 @@ +function plotRegionalHrfSummaries(HRF, at, varargin) + % data: Cell array with the data to be processed + % conditions: Cell array with condition names + % signalNames: Cell array with signal names + % specificRegions: Cell array with specific regions for each signal and condition + + % Get specific regions for this signal and condition + % regs = getSpecificRegions(specificRegions, i, c); + + % regs = getSpecificRegions(regs, i, c); + + if numel(varargin)>0 + if iscell(varargin{1}) + regs=varargin{1}; + else + regs=HRF.region; + end + else + regs=HRF.region; + end + + % regs={'ACC'}; + % Generate a set of maximally different colors + % colormap('jet'); % Set colormap to parula + % colors = colormap; % Get the colormap matrix + + % Get the colors + colors = cbrewer2('qual', 'Accent', numel(regs)+3); + + % Define a threshold to identify yellow colors (you may need to adjust this) + yellowThreshold = 0.8; + + % Identify the rows in 'colors' that correspond to yellow shades + yellowRows = all(colors(:,1:2) > yellowThreshold, 2); + + % Remove the yellow colors + colors(yellowRows, :) = []; + + % + % % Select maximally different colors + nColors = numel(regs); + indices = round(linspace(1, size(colors, 1), nColors)); + maxDifferentColors = colors(indices, :); + + % plot time-to-peak, height, width, start_time, and end_time of every + % peaks_voxnormed and trough_voxnormed for a region + + % create_figure(['Regional HRF summaries for ', HRF.params.CondNames{c}]); + figure; + for c = 1:numel(HRF.params.CondNames) + + + handles = []; + subplot(2, 2, c) + + for r=1:numel(regs) + + meant=[]; + meanh=[]; + minw=[]; + maxw=[]; + + for p=1:numel(HRF.fit{1}{r}.(HRF.params.CondNames{c}).peaks_voxnormed) + + % meant=mean(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).t); + meant=[meant, HRF.fit{1}{r}.(HRF.params.CondNames{c}).peaks_voxnormed(p).time_to_peak]; + + % standard error time-to-peak + % stet=std(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).t)/sqrt(numel(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).t)); + + % mean height + % meanh=mean(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).h); + meanh=[meanh, HRF.fit{1}{r}.(HRF.params.CondNames{c}).peaks_voxnormed(p).height]; + + % height standard error + % steh=std(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).h)/sqrt(numel(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).h)); + + % minimum width + % minw=meant-mean(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).w_times(:,1)); + % maximum width + % maxw=mean(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).w_times(:,2))-meant; + + minw=[minw, HRF.fit{1}{r}.(HRF.params.CondNames{c}).peaks_voxnormed(p).start_time]; + maxw=[maxw, HRF.fit{1}{r}.(HRF.params.CondNames{c}).peaks_voxnormed(p).end_time]; + + end + + for t=1:numel(HRF.fit{1}{r}.(HRF.params.CondNames{c}).troughs_voxnormed) + + % meant=mean(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).t); + meant=[meant, HRF.fit{1}{r}.(HRF.params.CondNames{c}).troughs_voxnormed(t).time_to_peak]; + + % standard error time-to-peak + % stet=std(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).t)/sqrt(numel(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).t)); + + % mean height + % meanh=mean(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).h); + meanh=[meanh, HRF.fit{1}{r}.(HRF.params.CondNames{c}).troughs_voxnormed(t).height]; + + % height standard error + % steh=std(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).h)/sqrt(numel(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).h)); + + % minimum width + % minw=meant-mean(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).w_times(:,1)); + % maximum width + % maxw=mean(dat{i}(dat{i}.condition==conds{c} & dat{i}.region==regs{r}, :).w_times(:,2))-meant; + + minw=[minw, HRF.fit{1}{r}.(HRF.params.CondNames{c}).troughs_voxnormed(t).start_time]; + maxw=[maxw, HRF.fit{1}{r}.(HRF.params.CondNames{c}).troughs_voxnormed(t).end_time]; + + end + + % handles(end+1)=errorbar(meant, meanh, steh, steh, stet, stet, 'o', 'Color', maxDifferentColors(r,:)); + [~,vox,~,~]=at.select_atlas_subset(regs(r), 'exact').get_region_volumes; + % vox=vox(r); + se=barplot_get_within_ste(HRF.fit{1}{r}.(HRF.params.CondNames{c}).models); + se=se/vox; + + se=repmat(se, 1, numel(meant)); + + % Sort and plot + + % Combine the data into a single matrix for sorting + data = [meant', meanh', minw', maxw', se']; + + % Sort the data based on meant values (1st column) + sortedData = sortrows(data, 1); + + % Extract the sorted data + meantSorted = sortedData(:, 1)'; + meanhSorted = sortedData(:, 2)'; + minwSorted = sortedData(:, 3)'; + maxwSorted = sortedData(:, 4)'; + seSorted = sortedData(:, 5)'; + + % Plot the sorted data + % handles=[handles, errorbar(meant, meanh, se, se, repmat(0, 1, numel(meant)), repmat(0, 1, numel(meant)), 'o-', 'Color', maxDifferentColors(r,:))]; + handles = [handles, errorbar(meantSorted, meanhSorted, seSorted, seSorted, zeros(1, numel(meantSorted)), zeros(1, numel(meantSorted)), 'o-', 'Color', maxDifferentColors(r,:))]; + + if numel(meant) > 1 + label(handles(end), format_strings_for_legend(regs{r}), 'location', 'left', 'FontWeight', 'bold', 'Margin', 3, 'HorizontalAlignment', 'right', 'FontSize', 14); + label(handles(end), format_strings_for_legend(regs{r}), 'location', 'right', 'FontWeight', 'bold', 'Margin', 3, 'HorizontalAlignment', 'right', 'FontSize', 14); + else + label(handles(end), format_strings_for_legend(regs{r}), 'location', 'right', 'FontWeight', 'bold', 'Margin', 3, 'HorizontalAlignment', 'right', 'FontSize', 14); + end + + % % Calculate the position and size of the rectangle + % rectX = meant - minw; % left boundary of the rectangle + % % rectY = meanh - max(steh); % bottom boundary of the rectangle + % rectY = meanh - meanh/10; % placeholder for now + % rectWidth = (meant+maxw)-(meant-minw); % width of the rectangle + % % rectHeight = 2*steh; % height of the rectangle + % rectHeight = 2*(meanh/10); % placeholder for now + % + % patch([rectX, rectX+rectWidth, rectX+rectWidth, rectX], [rectY, rectY, rectY+rectHeight, rectY+rectHeight], maxDifferentColors(r,:), 'FaceAlpha', 0.2); + + % Loop through each rectangle and plot + for i = 1:numel(minwSorted) + rectangleHeight=seSorted(i)*2; + x = minwSorted(i); + y = meanhSorted(i) - rectangleHeight / 2; % Adjust y to center the rectangle on meanh + width = maxwSorted(i) - minwSorted(i); + height = rectangleHeight; + + % Plot the rectangle + rectangle('Position', [x, y, width, height], 'EdgeColor', maxDifferentColors(r,:)); + end + + hold on + + end + + % hold off + + xlim([0,45]); + + hlin=refline(0,0); + hlin.Color='k'; + + vline(13/0.46, 'r--'); + + legend(handles, format_strings_for_legend(regs), 'Location', 'best'); + ylabel('Similarity Amplitude') + xticks([0:5:45]) + xticklabels([0:5:45]*0.46) + xlabel('Time to Peak (seconds)'); + % title({[signames{i}, ' Condition: ', conds{c}], 'HRF Summary Statistics', 'With Standard Errors, Stimulus offset is demarcated'}) + title({['Condition: ', HRF.params.CondNames{c}], 'HRF Summary Statistics', 'With Standard Errors, Stimulus offset is demarcated'}) + + end +end + +function regs = getSpecificRegions(specificRegions, signalIdx, conditionIdx) + % Extract and/or calculate the specific regions for the given signal and condition. + % specificRegions: Cell array defining specific regions for each signal and condition + % signalIdx: Index of the current signal + % conditionIdx: Index of the current condition + + % Example logic - adapt as per your actual requirements + regs = specificRegions{signalIdx, conditionIdx}; +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/process_HRF_dir.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/process_HRF_dir.m new file mode 100644 index 00000000..23661360 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/EstHRF_inAtlas/process_HRF_dir.m @@ -0,0 +1,64 @@ +function [HRF_data_lvl2, HRF_data, tc_data]=process_HRF_dir(basedir, at) + % Helper script to process an estHRF directory for second-level + % analysis + % Michael Sun, Ph.D. + % - Takes a BIDS-formatted estHRF directory + % - basedir: charstr fullpath of the directory: e.g., '//dartfs-hpc/rc/lab/C/CANlab/labdata/data/WASABI/derivatives/estHRF_NPSpos' + % - at: atlas object + % *Usage: + % :: + % [HRF_data_lvl2, HRF_data, tc_data] = process_HRF_dir(basedir, atlas_obj}) + % for s = 1:numel(subjects) + % disp(subjects{s}); + % describeHRF(HRF_data{s}); + % T{s}=generateHRFTable(HRF_data{s}); + % plotHRF(HRF_data_lvl2{s}, 'FIR', my_atlas); + % end + % + + % Validate input arguments + if nargin < 2 + error('You must provide a base directory and atlas object.'); + end + + % Load up all the .mat files you generated with estHRF_inAtlas for each subject + subjects = canlab_list_subjects(basedir, 'sub-*'); + HRF_data = cell(1, length(subjects)); + HRF_data_lvl2 = cell(1, length(subjects)); + tc_data = cell(1, length(subjects)); + HRF_files = cell(1, length(subjects)); + + for s = 1:numel(subjects) + HRF_files{s} = dir(fullfile(basedir, subjects{s}, '**', '*.mat')); + + % Initialize cell arrays to store the data + HRF_data{s} = cell(1, length(HRF_files{s})); + + tc_data{s} = cell(1, length(HRF_files{s})); + + % Loop through each file and load the data + for k = 1:length(HRF_files{s}) + fullpath = fullfile(HRF_files{s}(k).folder, HRF_files{s}(k).name); + data = load(fullpath); % Load the .mat file + + % Assuming each .mat file contains variables named 'HRF' and 'tc' + HRF_data{s}{k} = data.HRF; + tc_data{s}{k} = data.tc; + + if numel(data.HRF)>1 + HRF_data_lvl2{s}{k}=HRF_avg(HRF_data{s}{k}, at, 'conditions', {'heat_start', 'warm_start', 'imagine_cue', 'imagine_start'}); + + end + + end + + + % What if each HRF_data is a cell array of sessions? + if isstruct(HRF_data{s}) + HRF_data_lvl2{s}=HRF_avg(HRF_data{s}, at, 'conditions', {'heat_start', 'warm_start', 'imagine_cue', 'imagine_start'}); + end + + end + + +end \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/README.md b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/README.md new file mode 100644 index 00000000..c8d652d0 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/README.md @@ -0,0 +1,767 @@ +# HRF Toolbox Pipeline: Simple End-to-End HRF Estimation + +This folder hosts the user-facing pipeline that goes from a **4D fMRI NIfTI** and **BIDS events.tsv** directly to model-based HRF estimates. It lives under `CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/` and depends on the canonical Lindquist-lab fitters at `HRF_Est_Toolbox4/` (sibling directory). + +## Documentation + +- **[Tutorial](docs/Tutorial.md)** — narrative walkthrough from single-subject quick start through SLURM scaling, audit/repair, group analysis, and the new OOP classes. +- **[Object-oriented tour](examples/hrf_oo_demo.mlx)** (`examples/hrf_oo_demo.m`) — short, runnable walkthrough of the `fmri_hrf` / `statistic_hrf` classes: how they subclass `fmri_data` / `statistic_image`, and how model fitting, whole-brain maps, and corrected per-lag group inference all hang off the standard CANlab object surface. +- **[Architecture](docs/Architecture.md)** — code-map and call graph: how the ~40 files fit into layers, what depends on what, and where to add new code. +- **[Phased Plan](docs/HRF_Pipeline_Phased_Plan.md)** — the four-phase refactor roadmap (Phases 1–3 shipped, Phase 4 pending). + +This README is the function-by-function reference manual; the docs above are the entry points for new users. + +## Quick start + +```matlab +% Add CanlabCore + HRF_Est_Toolbox4 (legacy fitters) + this pipeline folder to the path. +% genpath(canlab_root) is enough — it picks up everything under it including this pipeline. +addpath(genpath('/path/to/CanlabCore/CanlabCore')); + +results = run_hrf_pipeline( ... + '/path/to/sub-01_task-pain_bold.nii.gz', ... + '/path/to/sub-01_task-pain_events.tsv', ... + 'TR', 0.8, ... + 'MaskNii', '/path/to/brain_mask.nii.gz', ... + 'Conditions', {'pain', 'neutral'}, ... + 'WindowSeconds', 30, ... + 'Models', {'fir', 'sfir', 'canonical'}, ... + 'OutputMat', '/path/to/sub-01_hrf_results.mat'); +``` + +## What this pipeline does + +1. Loads a 4D fMRI volume and extracts a mean timeseries (whole brain or mask). +2. Loads and validates events from BIDS `events.tsv`. +3. Builds one stick function per condition (`trial_type`). +4. Runs selected HRF models using existing toolbox fitters. +5. Returns a single `results` struct (and can save `.mat`). + +## New API + +- `run_hrf_pipeline.m` - main entry point. +- `hrf_extract_timeseries_from_nii.m` - 4D NIfTI to z-scored timeseries. +- `hrf_load_events_tsv.m` - BIDS events loader/validator. +- `hrf_build_stick_functions.m` - converts events to condition-wise sticks. +- `hrf_resolve_condition_patterns.m` - shared exact/wildcard/regex condition matching. +- `hrf_fit_all_models.m` - unified interface to model fitting. +- `plot_hrf_by_condition.m` - single subject/run plotting by condition with explicit source/model labels and fit SE when available. +- `hrf_write_slurm_study_script.m` - writes a SLURM array script, manifest, and MATLAB worker for study-wide whole-brain HRF fitting. +- `hrf_load_wholebrain_stats.m` - rebuilds beta/T `statistic_image` objects from written NIfTI + metadata sidecars. +- `hrf_analyze_second_level_inputs.m` - analyzes signature/imageset score CSVs across subjects from `second_level_inputs.csv`. +- `hrf_second_level_inputs_to_study.m` - converts collected beta/T map-score CSVs into a study-like structure for `hrf_time_unfolding_stats`. +- `hrf_input_table_to_study.m` - rebuilds a study/results structure from `second_level_inputs.csv`, including whole-brain objects and optional map-score curves. + +## Notes + +- `events.tsv` must include: `onset`, `duration`, `trial_type`. +- If TR is not passed, the pipeline attempts to use NIfTI header `PixelDimensions(4)`. +- Model wrappers call legacy toolbox methods (`Fit_Logit2`, `Fit_sFIR`, `Fit_Canonical_HRF`, `Fit_Spline`, `Fit_NLgamma`). `fir` is the unsmoothed `Fit_sFIR(..., mode=0)` fit; `sfir` is the smoothed `Fit_sFIR(..., mode=1)` fit. +- `canonical` and `nlgamma` require SPM on the MATLAB path; `spline` requires the FDA package (`create_bspline_basis`, `eval_basis`). By default, unavailable optional models are skipped with a warning. Use `'ModelDependencyPolicy', 'error'` to fail fast instead. +- Subject/run-level fit uncertainty is stored in `.se`, `.t`, `.p`, `.dfe`, + and `.uncertainty_source` for linear fits (`fir`, `sfir`, `canonical`, `spline`). + Nonlinear fits (`logit`, `nlgamma`) currently store the curve but not a + model-based SE/p curve. + +## Trial averaging + condition comparison + +```matlab +% Run full pipeline first +results = run_hrf_pipeline(fmri_nii, events_tsv, 'TR', 0.8); + +% Average one condition across trials (20 s window) +pain = hrf_average_condition_trials(results.timeseries, results.events, 'pain', 0.8, 20, 'BaselineSeconds', 2); +neutral = hrf_average_condition_trials(results.timeseries, results.events, 'neutral', 0.8, 20, 'BaselineSeconds', 2); + +% Compare conditions +cmp = hrf_compare_conditions(pain, neutral, 'Alpha', 0.05); + +% Plot means with SEM +figure; +subplot(1,2,1); hold on; +plot(pain.time, pain.mean, 'r', 'LineWidth', 1.5); +plot(neutral.time, neutral.mean, 'b', 'LineWidth', 1.5); +legend({'pain','neutral'}); xlabel('Seconds'); ylabel('Signal'); +title('Condition-averaged responses'); + +subplot(1,2,2); +plot(cmp.time, cmp.mean_diff, 'k', 'LineWidth', 1.5); hold on; +yline(0,'--'); +xlabel('Seconds'); ylabel('Pain - Neutral'); +title(sprintf('Mean difference (AUC diff = %.3f)', cmp.auc.diff)); +``` + +## Condition Wildcards And Regex + +Any HRF toolbox function with `Condition`, `Conditions`, `ConditionA`, or +`ConditionB` accepts exact names, wildcards, or regex patterns. Exact names are +preferred when they exist. Wildcards use `*` and `?`; regex can be written as +`'regex:'`, `'//'`, or a pattern containing common regex +operators such as `.*`, `|`, `[]`, `()`, `^`, `$`, or `+`. + +When one condition pattern matches multiple condition names, those conditions +are averaged and the plotted label records what was averaged: + +```matlab +% Average pain_hot and pain_warm into one fitted curve. +plot_hrf_by_condition(results, ... + 'Model', 'sfir', ... + 'Conditions', {'pain_*', 'neutral'}); + +% Same idea with regex. +stats = hrf_time_unfolding_stats(study, ... + 'Model', 'sfir', ... + 'ConditionA', 'regex:^pain_', ... + 'ConditionB', 'neutral'); + +% Build a model with grouped event sticks from the beginning. +results = run_hrf_pipeline(fmri_nii, events_tsv, ... + 'Conditions', {'pain_*', 'neutral'}, ... + 'Models', {'sfir'}); +``` + +`results.condition_groups` stores the original condition names that contributed +to each grouped condition. + + +## Signature-based (interpretable) time-series + +You can now estimate HRFs from a signature-expression time series (via `apply_all_signatures`) instead of a mean BOLD time series: + +```matlab +results_sig = run_hrf_pipeline( ... + fmri_nii, events_tsv, ... + 'TR', 0.8, ... + 'SignalSource', 'signature', ... + 'SimilarityMetric', 'dotproduct', ... + 'ImageSet', 'all', ... + 'SignatureName', 'NPS'); + +disp(results_sig.signature_meta) +``` + +If `SignatureName` is omitted, the first signature from `apply_all_signatures` is used. +Note: if you pass `SignatureName` while `SignalSource` is left at default (`'mean'`), the pipeline now auto-switches to signature mode and warns you. + + +## Quick plotting helper for new results structure + +```matlab +% Single subject/run fitted curves by condition. +% For fir/sfir/canonical/spline fits, ribbons are model-based SE from the run. +plot_hrf_by_condition(results, 'Model', 'sfir', 'Conditions', {'pain','neutral'}); + +% Compare fitted model families on the same axes. +plot_hrf_by_condition(results, ... + 'Model', {'fir','sfir','canonical'}, ... + 'Condition', 'pain'); + +% Raw event-locked trial means from the selected 1D time series. +% Ribbons are SEM across repeated events/trials within the run. +plot_hrf_by_condition(results, ... + 'PlotType', 'trialmean', ... + 'Conditions', {'pain','neutral'}, ... + 'BaselineSeconds', 2); + +% Signature-specific fitted curves (when SignalSource='signature') +plot_hrf_by_condition(results_sig, ... + 'Model', 'sfir', ... + 'Signature', 'NPS', ... + 'Conditions', {'pain','neutral'}); + +% Across a study, one curve per subject for a selected condition. +% Ribbons are within-run/within-subject SE when available; the black +% group curve shows mean +/- SEM across plotted subjects or runs. +plot_hrf_study_by_subject(study, ... + 'Model', 'sfir', ... + 'Condition', 'pain', ... + 'Unit', 'subject'); + +% Compare study-level group means across fitted models. +plot_hrf_study_by_subject(study, ... + 'Model', {'fir','sfir','canonical'}, ... + 'Condition', 'pain', ... + 'Unit', 'subject'); + +% Across a study, event-locked trial means use repeated event instances +% within each run for SEM at each TR. +plot_hrf_study_by_subject(study, ... + 'PlotType', 'trialmean', ... + 'Condition', 'pain', ... + 'Unit', 'subject'); +``` + +`results_sig.signature_meta` now includes selected and available signature names, and +`results_sig.fits_by_signature` stores fitted models for each signature. + +`plot_hrf_results` is kept as a backward-compatible wrapper around +`plot_hrf_by_condition`. It no longer uses across-signature variability as a +standard-error ribbon because that is not subject-level fit uncertainty. Plot +titles now explicitly label the curve source, selected model +(`fir`, `sfir`, `canonical`, `nlgamma`, etc.), and whether the ribbon is within-run +model SE, trial SEM, or unavailable. + +## Speeding up all-signature fitting + +If fitting all signatures is slow, you can: + +```matlab +results_sig = run_hrf_pipeline( ... + fmri_nii, events_tsv, ... + 'TR', 0.8, ... + 'SignalSource', 'signature', ... + 'ImageSet', 'all', ... + 'UseParallel', true, ... % parfor across signatures + 'MaxSignatures', 10, ... % only fit first 10 signatures + 'SignatureNames', {'NPS','SIIPS'} % or fit an explicit subset + ); +``` + +For fastest runs, pass a single `SignatureName` (e.g., `'NPS'`) so the pipeline skips full all-signature loading/fitting. + +## Apply canonical brain map sets (Buckner, Margulies, Hansen) + +You can use `SignalSource='imageset'` to apply maps from `load_image_set` directly: + +```matlab +% Buckner 7-network maps +res_buckner = run_hrf_pipeline(fmri_nii, events_tsv, ... + 'SignalSource', 'imageset', 'ImageSet', 'bucknerlab_wholebrain', ... + 'TR', 0.8, 'UseParallel', true); + +% Margulies principal gradient +res_marg = run_hrf_pipeline(fmri_nii, events_tsv, ... + 'SignalSource', 'imageset', 'ImageSet', 'marg', 'TR', 0.8); + +% Hansen receptor maps (subset) +res_hansen = run_hrf_pipeline(fmri_nii, events_tsv, ... + 'SignalSource', 'imageset', 'ImageSet', 'hansen22', ... + 'MapNames', {'D1', '5HT1a'}, 'TR', 0.8); +``` + +## Study-level pipeline (multiple images/subjects) + +```matlab +study = run_hrf_study_pipeline(fmri_files, events_files, subject_ids, ... + 'TR', 0.8, 'SignalSource', 'imageset', 'ImageSet', 'bucknerlab_wholebrain'); + +% Long-format subject summary table +study.summary + +% Visualize separated by subject +plot_hrf_study_by_subject(study, 'Model', 'sfir', 'Condition', 'pain'); +``` + +Use `'UseParallelSubjects', true` to run subjects in a local MATLAB parallel pool. For +clusters, the same per-file call is suitable for a SLURM array job. +Use `'WholeBrainOutputDir', '/path/to/hrf_outputs'` with `'WriteWholeBrain', true` +to write subject-specific beta/T outputs into one second-level input directory. +Use `'ReuseWholeBrainOutputs', true` to reuse existing +`*_beta.nii`/`*_t.nii` files in that directory instead of refitting the +whole-brain 4D maps. + +## Study-wide whole-brain maps plus all signatures/imagesets + +For a full study, write the 4D beta/T maps once per subject and then apply all +requested signatures and image sets to those 4D maps. This is usually faster +than refitting or reloading signatures separately for each summary. + +```matlab +output_dir = '/path/to/hrf_outputs'; + +image_sets = { ... + 'bucknerlab_wholebrain', ... + 'marg', ... + 'hansen22'}; + +study = run_hrf_study_pipeline(fmri_files, events_files, subject_ids, ... + 'TR', 0.8, ... + 'Conditions', {'pain', 'neutral'}, ... + 'WindowSeconds', 30, ... + 'Models', {'sfir','canonical','spline'}, ... + 'WriteWholeBrain', true, ... + 'WholeBrainOutputDir', output_dir, ... + 'ReuseWholeBrainOutputs', true, ... + 'WholeBrainPThresh', 0.005, ... + 'WholeBrainThreshType', 'unc', ... + 'UseParallelSubjects', true); + +for i = 1:numel(study.results) + if ~study.success(i), continue; end + + prefix = fullfile(output_dir, [regexprep(subject_ids{i}, '[^\w.-]', '_') '_hrf']); + model_names = fieldnames(study.results{i}.wholebrain_by_model); + + for m = 1:numel(model_names) + model_name = model_names{m}; + wholebrain = study.results{i}.wholebrain_by_model.(model_name); + + hrf_apply_maps_to_wholebrain(wholebrain, ... + 'Object', 'beta', ... + 'SignatureSets', {'all'}, ... + 'ImageSets', image_sets, ... + 'SimilarityMetric', 'dotproduct', ... + 'OutputCsv', sprintf('%s_%s_beta_map_scores.csv', prefix, model_name)); + + hrf_apply_maps_to_wholebrain(wholebrain, ... + 'Object', 't', ... + 'SignatureSets', {'all'}, ... + 'ImageSets', image_sets, ... + 'SimilarityMetric', 'dotproduct', ... + 'OutputCsv', sprintf('%s_%s_t_map_scores.csv', prefix, model_name)); + end +end + +second_level_inputs = hrf_collect_wholebrain_outputs(output_dir, ... + 'OutputCsv', fullfile(output_dir, 'second_level_inputs.csv')); +``` + +For a cluster, generate a SLURM array job that runs the same per-subject work. +Each array task writes beta/T/SE/P maps, metadata, and beta/T map-score CSVs: + +```matlab +slurm_paths = hrf_write_slurm_study_script(fmri_files, events_files, subject_ids, ... + 'OutputDir', '/path/to/hrf_outputs', ... + 'CanlabRoot', '/path/to/CanlabCore/CanlabCore', ... + 'PipelineArgs', { ... + 'TR', 0.8, ... + 'Conditions', {'pain', 'neutral'}, ... + 'WindowSeconds', 30, ... + 'Models', {'sfir','canonical','spline'}, ... + 'WholeBrainPThresh', 0.005, ... + 'WholeBrainThreshType', 'unc'}, ... + 'SignatureSets', {'all'}, ... + 'ImageSets', image_sets, ... + 'ScoreObjects', {'beta', 't'}, ... + 'JobName', 'hrf_wholebrain', ... + 'Time', '24:00:00', ... + 'Mem', '32G', ... + 'CpusPerTask', 1, ... + 'ModuleLoad', 'matlab'); +``` + +If `subject_ids` contains repeated subjects, the SLURM writer derives a run +label from each BIDS fMRI filename (`ses-*`, `task-*`, `run-*`, etc.) and +writes run-unique prefixes such as +`SID000002_ses-20_task-distractmap_run-01_hrf`. This prevents multiple runs +from overwriting the same `SID000002_hrf_*` files. To supply your own labels, +pass `'RunLabels', run_labels`. + +To run `canonical` or `nlgamma`, add SPM to the worker path; to run `spline`, +also add FDA: + +```matlab +slurm_paths = hrf_write_slurm_study_script(fmri_files, events_files, subject_ids, ... + 'OutputDir', '/path/to/hrf_outputs', ... + 'CanlabRoot', '/path/to/CanlabCore/CanlabCore', ... + 'ExtraMatlabPaths', {'/path/to/spm12', '/path/to/fda'}, ... + 'PipelineArgs', {'TR', 0.8, 'Models', {'canonical', 'spline', 'nlgamma'}, ... + 'ModelDependencyPolicy', 'error'}, ... + 'ModuleLoad', 'matlab'); +``` + +`Models` controls the 1D fitters saved in each result MAT file and, when +whole-brain writing is enabled with the default `'WholeBrainMode','auto'`, +also controls which linear 4D whole-brain map families are written. Supported +whole-brain models are `fir`, `sfir`, `canonical`, and `spline`; nonlinear +`logit` and `nlgamma` remain 1D-only. A request such as +`'Models', {'sfir','canonical','spline'}` writes separate files such as +`sub-01_hrf_sfir_beta.nii`, `sub-01_hrf_canonical_beta.nii`, and +`sub-01_hrf_spline_beta.nii`, with matching `_t`, `_se`, `_p`, metadata, and +map-score CSV files. To force only one map family, pass +`'WholeBrainMode','FIR'`, `'sFIR'`, `'canonical'`, or `'spline'` explicitly. + +Submit the generated script from the cluster shell: + +```bash +sbatch /path/to/hrf_outputs/run_hrf_study.sbatch +``` + +If you update the subject list or generator options, rerun +`hrf_write_slurm_study_script` before submitting so the manifest, worker, +config `.mat`, and `.sbatch` file stay in sync. The generated `.sbatch` +also defaults `SLURM_ARRAY_TASK_ID` to `1`, so you can run it with +`bash run_hrf_study.sbatch` inside an interactive allocation for a quick +task-1 smoke test. + +`run_hrf_pipeline` saves result MAT files with `-v7.3` by default. When +whole-brain maps are written, the MAT file stores paths and metadata instead +of embedding the large 4D `statistic_image` objects unless +`'SaveWholeBrainInMat', true` is supplied. This keeps cluster result MAT files +loadable while the NIfTI outputs remain the source of truth for 4D maps. + +After the array completes: + +```matlab +second_level_inputs = hrf_collect_wholebrain_outputs('/path/to/hrf_outputs', ... + 'OutputCsv', '/path/to/hrf_outputs/second_level_inputs.csv'); + +analysis = hrf_analyze_second_level_inputs(second_level_inputs, ... + 'Object', 'beta', ... + 'ConditionA', 'pain', ... + 'ConditionB', 'neutral', ... + 'OutputSummaryCsv', '/path/to/hrf_outputs/beta_score_group_summary.csv'); + +% Strongest significant signature/map effects across subjects +analysis.interpretation +``` + +## Whole-brain 4D HRF beta and T maps + +```matlab +results = run_hrf_pipeline(fmri_nii, events_tsv, ... + 'TR', 0.8, ... + 'WindowSeconds', 30, ... + 'Models', {'sfir','canonical','spline'}, ... + 'WriteWholeBrain', true, ... + 'WholeBrainOutputPrefix', '/path/to/sub-01_hrf', ... + 'WholeBrainPThresh', 0.005, ... + 'WholeBrainThreshType', 'unc'); + +% Written files: +% /path/to/sub-01_hrf_sfir_beta.nii +% /path/to/sub-01_hrf_sfir_t.nii +% /path/to/sub-01_hrf_sfir_se.nii +% /path/to/sub-01_hrf_sfir_p.nii +% /path/to/sub-01_hrf_sfir_t_thresh.nii +% /path/to/sub-01_hrf_sfir_metadata.csv +% ...and matching canonical/spline files +``` + +The whole-brain outputs are also available in memory: + +```matlab +beta_obj = results.wholebrain_by_model.sfir.b; % statistic_image beta maps +t_obj = results.wholebrain_by_model.sfir.t; % statistic_image T maps, .p/.ste/.sig set +``` + +`Models` and `WholeBrainMode` are related but not identical. `Models` controls +the 1D curve fitters (`logit`, `fir`, `sfir`, `canonical`, `spline`, +`nlgamma`) used for mean, signature, image-set, or atlas time series. +`WholeBrainMode='auto'` writes one 4D condition-lag map set for each requested +supported linear model (`fir`, `sfir`, `canonical`, `spline`). The written +metadata table records the actual model/mode for each volume, and +`hrf_collect_wholebrain_outputs` includes a `model` column. + +Existing whole-brain files are overwritten by default +(`'WholeBrainOverwrite', true`). Set `'ReuseWholeBrainOutput', true` to load +existing `_beta.nii`/`_t.nii` outputs instead of recomputing, or +`'WholeBrainOverwrite', false` to stop if outputs already exist. + +You can also reconstruct the same object structure later without the result +MAT file: + +```matlab +wholebrain = hrf_load_wholebrain_stats('/path/to/sub-01_hrf_sfir'); + +beta_obj = wholebrain.b; % .ste and .p restored from _se.nii/_p.nii +t_obj = wholebrain.t; +``` + +Apply signatures or image sets after writing/fitting the 4D maps: + +```matlab +scores = hrf_apply_maps_to_wholebrain(results.wholebrain_by_model.sfir, ... + 'Object', 'beta', ... + 'SignatureSets', {'all'}, ... + 'ImageSets', {'bucknerlab_wholebrain', 'hansen22'}, ... + 'SimilarityMetric', 'dotproduct', ... + 'OutputCsv', '/path/to/sub-01_hrf_sfir_beta_map_scores.csv'); +``` + +Make a quick condition-lag animation with CANlab montage: + +```matlab +hrf_animate_wholebrain_stats(results.wholebrain_by_model.sfir, ... + 'Object', 't', ... + 'Condition', 'pain', ... + 'OutputFile', '/path/to/sub-01_pain_tmaps.mp4'); +``` + +Collect written files for second-level scripts: + +```matlab +input_table = hrf_collect_wholebrain_outputs('/path/to/hrf_outputs', ... + 'OutputCsv', '/path/to/hrf_outputs/second_level_inputs.csv'); +``` + +Rebuild a `study.results` structure from the collected file index: + +```matlab +study = hrf_input_table_to_study(input_table); + +% One subject/run/model in the familiar run_hrf_pipeline-style shape +results = study.results{1}; +wholebrain = results.wholebrain; +``` + +If the SLURM/result MAT files are available and you want event-level trial +SEM plots, load those MAT files into the study too: + +```matlab +study_runs = hrf_input_table_to_study(input_table, ... + 'LoadWholeBrain', false, ... + 'IncludeMapScores', false, ... + 'LoadResultMat', true); + +plot_hrf_study_by_subject(study_runs, ... + 'PlotType', 'trialmean', ... + 'Condition', 'nback-stimblock', ... + 'Unit', 'subject'); +``` + +Use the reconstructed whole-brain object for animation: + +```matlab +hrf_animate_wholebrain_stats(study.results{1}.wholebrain, ... + 'Object', 't', ... + 'Condition', 'pain', ... + 'OutputFile', '/path/to/sub-01_pain_tmaps.mp4'); +``` + +Use the reconstructed whole-brain object to apply signatures or image sets. +This writes score CSVs that can later be plotted or analyzed across subjects: + +```matlab +for i = 1:numel(study.results) + if ~study.wholebrain_success(i), continue; end + + prefix = input_table.prefix{i}; + hrf_apply_maps_to_wholebrain(study.results{i}.wholebrain, ... + 'Object', 'beta', ... + 'SignatureSets', {'all'}, ... + 'ImageSets', {'bucknerlab_wholebrain'}, ... + 'SimilarityMetric', 'dotproduct', ... + 'OutputCsv', [prefix '_beta_map_scores.csv']); +end +``` + +Analyze signature/imageset map scores across subjects: + +```matlab +analysis = hrf_analyze_second_level_inputs(input_table, ... + 'Object', 'beta', ... + 'ConditionA', 'pain', ... + 'ConditionB', 'neutral', ... + 'LagSeconds', 6, ... + 'Alpha', 0.05); + +analysis.subject_table % one row per subject x lag x signature/map +analysis.summary % group mean/SEM/t/p per lag x signature/map +analysis.interpretation % strongest significant effects +``` + +Use map-score CSVs with `hrf_time_unfolding_stats`: + +```matlab +input_table = hrf_collect_wholebrain_outputs('/path/to/hrf_outputs'); + +% Backfill missing canonical/sfir/spline map-score CSVs after an older run. +input_table = hrf_score_wholebrain_input_table(input_table, ... + 'SourceModel', 'sfir', ... + 'ScoreObjects', {'beta','t'}, ... + 'SignatureSets', {'all'}, ... + 'ImageSets', image_sets, ... + 'OutputCsv', '/path/to/hrf_outputs/second_level_inputs.csv'); + +study_scores = hrf_input_table_to_study(input_table, ... + 'LoadWholeBrain', false, ... + 'Object', 'beta', ... + 'SourceModel', 'sfir'); + +% Default Signature is mean_mapscore, the average over score columns. +% Model='sfir' selects rows whose source whole-brain model was sfir. +plot_hrf_study_by_subject(study_scores, ... + 'Model', 'sfir', ... + 'Condition', 'pain', ... + 'Unit', 'subject'); + +% Plot one specific score column instead of the average. +plot_hrf_study_by_subject(study_scores, ... + 'Model', 'sfir', ... + 'Signature', 'sig_all_NPS', ... + 'Condition', 'pain', ... + 'Unit', 'subject'); + +stats_nps = hrf_time_unfolding_stats(study_scores, ... + 'Model', 'sfir', ... + 'Signature', 'sig_all_NPS', ... + 'ConditionA', 'pain', ... + 'ConditionB', 'neutral', ... + 'Unit', 'subject', ... + 'Alpha', 0.05); + +plot_hrf_time_unfolding_stats(stats_nps); +``` + +Map-score-only studies rebuilt from CSVs do not contain the original event +table or time series, so `PlotType='trialmean'` is unavailable. To inspect +run-level event-locked trial means, rebuild from the saved result MAT files: + +```matlab +study_runs = hrf_input_table_to_study(input_table, ... + 'LoadResultMat', true, ... + 'LoadWholeBrain', false, ... + 'IncludeMapScores', false); + +plot_hrf_study_by_subject(study_runs, ... + 'PlotType', 'trialmean', ... + 'Condition', 'nback-stimblock', ... + 'Unit', 'run', ... + 'TrialOutlierPolicy', 'huber'); +``` + +`hrf_score_wholebrain_input_table` validates existing score CSVs against the +matching metadata sidecar. If row counts, condition labels, or lag indices do +not match, stale score CSVs are regenerated by default (`'OverwriteStale', +true`). If the metadata row count does not match the number of volumes in the +4D beta/T NIfTI, regenerate the whole-brain maps and metadata together before +scoring; the score table cannot safely infer which volumes belong to omitted +conditions such as `*_ttl_*`. + +For map-score studies, `Model='sfir'`, `Model='canonical'`, or +`Model='spline'` selects the source whole-brain model recorded by +`hrf_collect_wholebrain_outputs`. Prefer filtering during study construction +with `'SourceModel','sfir'`/`'canonical'`/`'spline'`. For compatibility, +`'ModelName','canonical'` is also interpreted as source-model selection when +the input table has a whole-brain `model` column; it does not relabel FIR rows +as canonical. You can still use `Model='mapscore'` with +`'SourceModel','sfir'` if you prefer to be explicit. Column names such as +`sig_all_NPS` mean "signature set `all`, signature `NPS`." Rows in the score +CSV identify the HRF condition and lag. These values are therefore NPS +expression of HRF beta/T maps, not an NPS time series computed directly from +the original 4D BOLD volumes. + +When `Signature` is omitted, the selected curve is `mean_mapscore`, an average +over the loaded score columns. Pass a specific signature or map column, such +as `'Signature','sig_all_NPS'`, when you want that one score instead of the +average. + +Map-score curves can look more jagged than `run_hrf_pipeline` 1D HRF fits +because each point is a spatial pattern score from a separate condition-lag +beta/T image. When beta scores are written with a matching `_se.nii` image, +`hrf_apply_maps_to_wholebrain` adds propagated score-SE columns such as +`sig_all_NPS_se`, computed as `sqrt(sum((signature_weight .* beta_se).^2))` +under a diagonal voxel-covariance approximation. `hrf_input_table_to_study(..., +'Object','beta')` uses those propagated SE columns for `mapscore.se`. For +older score CSVs without propagated SE columns, matching `t_scores_file` +values are still used as a fallback approximation, `abs(beta_score / t_score)`; +set `'ApproxSEFromT', false` to disable that fallback. Use +`hrf_time_unfolding_stats` for across-subject mean/SEM and p-values, or refit +the source 1D mean/signature/ROI time series with `run_hrf_pipeline` when you +need full model covariance. + +Inspect noisy runs/subjects before interpreting large ribbons or strange +averages: + +```matlab +qc = hrf_qc_study_curves(study_scores, ... + 'Model', 'sfir', ... + 'Signature', 'sig_all_NPS', ... + 'Condition', 'nback-stimblock', ... + 'Unit', 'run', ... + 'Weighting', 'huber', ... + 'Plot', true); + +qc.table(qc.table.is_outlier, :) + +plot_hrf_study_by_subject(study_scores, ... + 'Model', 'sfir', ... + 'Signature', 'sig_all_NPS', ... + 'Condition', 'nback-stimblock', ... + 'Unit', 'run', ... + 'CurveWeights', qc.table.weight); +``` + +For true trial-level inspection/down-weighting, use the original run-level +`results` structs rather than map-score-only studies: + +```matlab +plot_hrf_by_condition(results, ... + 'PlotType', 'trialmean', ... + 'Condition', 'nback-stimblock', ... + 'TrialOutlierPolicy', 'huber'); +``` + +For event-related designs with multiple event instances in a run, use +`PlotType='trialmean'` to plot raw event-locked means with SEM across repeated +events at each TR. That SEM is a different quantity from map-score or FIR beta +SE: it summarizes trial-to-trial variability in the extracted 1D signal, not +uncertainty in the condition-lag beta map. + +## Multilevel time-unfolding significance testing + +```matlab +study = run_hrf_study_pipeline(fmri_files, events_files, subject_ids, ... + 'TR', 0.8, 'SignalSource', 'mean'); + +% Subject-level mean extracted timeseries (subjects x time) +study.mean_timeseries + +% Within-subject condition contrast, then group-level test across time bins +stats = hrf_time_unfolding_stats(study, ... + 'Model', 'sfir', ... + 'Unit', 'subject', ... + 'ConditionA', 'pain', ... + 'ConditionB', 'neutral', ... + 'Alpha', 0.05); + +plot_hrf_time_unfolding_stats(stats); +``` + +Duplicate subject IDs are averaged before testing by default, so runs are +summarized at the subject level. Use `'Unit', 'run'` to test run-level rows +instead. Empty or failed results are skipped with warnings by default; use +`'MissingPolicy', 'error'` to stop on the first missing model/condition/result. + +Signature-specific study-level testing and plotting use the same signature +names stored in `results.fits_by_signature`: + +```matlab +stats_nps = hrf_time_unfolding_stats(study, ... + 'Model', 'sfir', ... + 'Signature', 'NPS', ... + 'ConditionA', 'pain', ... + 'ConditionB', 'neutral', ... + 'Unit', 'subject'); + +plot_hrf_time_unfolding_stats(stats_nps); + +plot_hrf_study_by_subject(study, ... + 'Model', 'sfir', ... + 'Signature', 'NPS', ... + 'Condition', 'pain', ... + 'Unit', 'subject'); +``` + +Optional between-group testing is available by supplying `Group` labels +(two-group t-test per time bin). + +## Efficient ROI- and pattern-based HRF fitting + +Yes - this is often more efficient than voxelwise fitting. You can now do: + +```matlab +% 1) Atlas ROI means (SignalSource='atlas') +at = load_atlas('canlab2018'); +res_roi = run_hrf_pipeline(fmri_nii, events_tsv, ... + 'TR', 0.8, 'SignalSource', 'atlas', ... + 'AtlasObj', at, 'Regions', {'ACC','vmPFC'}); + +% 2) Pattern/map expression (SignalSource='imageset') +res_maps = run_hrf_pipeline(fmri_nii, events_tsv, ... + 'TR', 0.8, 'SignalSource', 'imageset', ... + 'ImageSet', 'bucknerlab_wholebrain', 'UseParallel', true); +``` + +## Fast montage animation over time (betas or thresholded t maps) + +```matlab +% Animate a 4D image quickly by skipping frames +hrf_make_montage_animation(fmri_nii, 'timeseries.mp4', ... + 'FrameStep', 3, 'FPS', 10, 'TitlePrefix', 'BOLD'); + +% Thresholded t-map animation (if your img is already t-values) +hrf_make_montage_animation(tmap_4d, 'tvals_thresh.mp4', ... + 'Threshold', 2.0, 'FrameStep', 2, 'FPS', 12, 'TitlePrefix', 't-value'); +``` diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/docs/Architecture.md b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/docs/Architecture.md new file mode 100644 index 00000000..9deeb909 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/docs/Architecture.md @@ -0,0 +1,313 @@ +# HRF Toolbox Pipeline — Architecture & Code Map + +How the pipeline is laid out, what calls what, and where to add new functionality. For usage examples, see [Tutorial.md](Tutorial.md). For the four-phase refactor history, see [HRF_Pipeline_Phased_Plan.md](HRF_Pipeline_Phased_Plan.md). + +--- + +## 1. Two-zone layout + +After the Phase 2 merge, all HRF code lives under `HRF_Est_Toolbox4/`: + +``` +HRF_Est_Toolbox4/ (~28 files at root) +│ +├── Fit_Canonical_HRF.m Lindquist-lab canonical HRF fitters +├── Fit_sFIR.m, Fit_sFIR_epochmodulation.m ─────────────────────────────────── +├── Fit_Spline.m, Fit_Logit2.m, ... legacy single-voxel HRF estimators +├── Anneal_Logit.m, Det_Logit.m (called by the pipeline orchestrator) +├── ResidScan.m, PowerLoss.m, PowerSim.m +├── HMHRFest.m hierarchical mixed-effects HRF est. +├── hrf_fit_one_voxel.m dispatcher: routes (FIR/IL/CHRF) -> fitters +├── New_fminsearchbnd/ 3rd-party bounded fminsearch +├── timecourse.mat legacy demo fixture +└── HRF_Toolbox_Pipeline/ user-facing pipeline + new OOP + │ + ├── run_hrf_pipeline.m single-subject, single-run entry + ├── run_hrf_study_pipeline.m multi-subject loop + ├── hrf_*.m 30 pipeline functions + ├── plot_hrf_*.m 5 plotter functions + ├── make_fmri_stat_hrf.m pairing helper for the new classes + ├── sFIR_multisubject_example_script.m tutorial-by-script + ├── hrf_smoke_test_phase1.m Phase 1 audit/score smoke test + ├── hrf_smoke_test_phase3.m Phase 3 OOP smoke test + │ + ├── @fmri_hrf/ NEW (Phase 3 v0) + │ ├── fmri_hrf.m classdef + constructor + disp + │ ├── cat.m HRF-aware (subject, run) concatenation + │ └── to_statistic_hrf.m build paired statistic_hrf from beta + SE + │ + ├── @statistic_hrf/ NEW (Phase 3 v0) + │ ├── statistic_hrf.m classdef + constructor + disp + │ └── cat.m + │ + ├── EstHRF_inAtlas/ atlas/ROI HRF analysis helpers + │ + └── docs/ this folder + ├── HRF_Pipeline_Phased_Plan.md + ├── Tutorial.md + ├── Architecture.md (you are here) + └── HRF Analysis Code Map.pptx (slide-deck companion to this file) +``` + +**Zone separation matters:** the Lindquist fitters at the root are stable, mostly-untouched canonical implementations. The pipeline subfolder is where Michael Sun's work lives, and where new methods get added. + +--- + +## 2. Layer architecture + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ L0 ENTRY POINTS │ +│ run_hrf_pipeline single subject/run │ +│ run_hrf_study_pipeline multi-subject loop │ +│ hrf_write_slurm_study_script cluster array job │ +└─────────────────────────┬────────────────────────────────────────────┘ + │ +┌─────────────────────────▼────────────────────────────────────────────┐ +│ L1 FITTING ORCHESTRATION │ +│ hrf_fit_all_models dispatches per-voxel calls │ +│ hrf_fit_wholebrain_stats whole-brain across voxels │ +│ hrf_load_events_tsv BIDS events -> struct │ +│ hrf_build_stick_functions events -> stick matrix │ +│ hrf_extract_timeseries_from_nii NIfTI -> z-scored timeseries │ +└─────────────────────────┬────────────────────────────────────────────┘ + │ +SPM GKWY compatibility (hrf_fit_wholebrain_stats) + The vectorized whole-brain fit is OLS on a constant-only baseline and is + NOT, by default, identical to an SPM first-level GLM, which fits the + grand-mean-scaled, high-pass filtered, prewhitened data ("gKWY"; see + Misc_utilities/spmify.m). Two opt-in tiers close the gap: + Tier A 'HighpassSeconds' (default 128 s) adds SPM-style DCT high-pass + confounds to the design. ON BY DEFAULT: without it, low-frequency + drift leaks into long FIR/sFIR lags as a spurious sustained + baseline. 'ScaleMode','grandmean' replicates the global scale g. + 'Whiten' ('ar1'|'ar2') estimates the noise autocorrelation from a + high-variance voxel subsample and prewhitens data + design, giving + GLS-valid SE/t without an SPM.mat. + Tier B 'SPM' (an estimated SPM.mat path/struct) applies exact g*K*W to + the data and K*W to the design, reproducing spm_spm -- the only + route to SPM's ReML whitening. Use 'SPMRun' for multi-run SPMs. + The legacy raw-OLS behavior is recoverable with HighpassSeconds=[]/0/Inf + and Whiten='none'. Threading: run_hrf_pipeline exposes WholeBrainHighpass- + Seconds / WholeBrainSPM / WholeBrainSPMRun / WholeBrainWhiten; the study + driver and SLURM writer accept PER-SUBJECT 'SPMFiles' (+'SPMRuns') so each + row can use exact Tier B (the SLURM manifest gains spm_file/spm_run + columns), falling back to Tier A for rows with no SPM.mat. + +┌─────────────────────────▼────────────────────────────────────────────┐ +│ L2 LINDQUIST-LAB FITTERS (parent folder HRF_Est_Toolbox4/) │ +│ Fit_sFIR, Fit_Canonical_HRF, Fit_Spline, Fit_Logit2, │ +│ Fit_NLgamma, Anneal_Logit, Det_Logit, ResidScan, PowerLoss │ +│ hrf_fit_one_voxel (dispatcher: 'IL' | 'FIR' | 'CHRF') │ +└──────────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────────┐ +│ L3 WHOLE-BRAIN I/O │ +│ hrf_load_wholebrain_stats load beta/t/se/p NIfTIs │ +│ hrf_collect_wholebrain_outputs index SLURM outputs -> table │ +└─────────────────────────┬────────────────────────────────────────────┘ + │ +┌─────────────────────────▼────────────────────────────────────────────┐ +│ L4 SCORING & SIGNATURE APPLICATION │ +│ hrf_score_one_prefix ★ single source of truth │ +│ hrf_apply_maps_to_wholebrain generic signature scorer │ +│ hrf_score_wholebrain_input_table post-hoc backfill wrapper │ +│ hrf_extract_signature_timeseries multi-signature extraction │ +│ hrf_extract_imageset_timeseries custom image-set application │ +│ hrf_extract_roi_timeseries ROI averaging │ +│ hrf_extract_all_signature_timeseries │ +└─────────────────────────┬────────────────────────────────────────────┘ + │ +┌─────────────────────────▼────────────────────────────────────────────┐ +│ L5 AUDIT / REPAIR │ +│ hrf_audit_slurm_outputs ★ also drives RepairMissing │ +└─────────────────────────┬────────────────────────────────────────────┘ + │ +┌─────────────────────────▼────────────────────────────────────────────┐ +│ L6 STUDY ASSEMBLY │ +│ hrf_input_table_to_study input table -> study struct │ +│ hrf_second_level_inputs_to_study ... with per-condition contr. │ +└─────────────────────────┬────────────────────────────────────────────┘ + │ + ┌───────────┴───────────────┐ + ▼ ▼ +┌──────────────────────────┐ ┌──────────────────────────────────────┐ +│ L7 ANALYTICS │ │ L8 VISUALIZATION │ +│ hrf_time_unfolding_stats │ │ plot_hrf_by_condition │ +│ hrf_analyze_second_level │ │ plot_hrf_study_by_subject (largest) │ +│ hrf_2x2_study_score_stats│ │ plot_hrf_2x2_study_score_stats │ +│ hrf_average_condition_tr.│ │ plot_hrf_time_unfolding_stats │ +│ hrf_compare_conditions │ │ plot_hrf_results │ +│ hrf_qc_study_curves │ │ hrf_animate_wholebrain_stats │ +│ hrf_summarize_study │ │ hrf_make_montage_animation │ +└──────────────────────────┘ │ hrf_make_average_montage_animations │ + └──────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────────┐ +│ L9 OOP WRAPPERS (Phase 3 v0) │ +│ @fmri_hrf < fmri_data │ +│ @statistic_hrf < statistic_image │ +│ make_fmri_stat_hrf canonical paired constructor │ +│ │ +│ Consumes from L3 (loaded objects) and L6 (study struct). │ +│ Built so Phase 4 misspec methods slot in as @fmri_hrf methods. │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +The `★` markers point at modules that consolidate dispersed logic — `hrf_score_one_prefix` is called from the SLURM worker, the audit-repair path, *and* the post-hoc backfill, so all three paths produce byte-identical score CSVs. `hrf_audit_slurm_outputs` is the only place that reasons about completeness. + +--- + +## 3. Call graph (cross-layer arrows only) + +``` +L0 ─┬─→ L1 ─→ L2 fit a subject/run + ├─→ L4 (optional, after fit) score wholebrain maps + ├─→ L1 + writes a worker for L2 SLURM array job generation + └─→ L4 (worker scoring) try/catch wrapped + +After the SLURM job lands its NIfTIs: +L3 ───→ tables / fmri_data objects + └─→ L5 (audit) + └─→ L4 (repair via hrf_score_one_prefix) + └─→ L6 (build study struct) + ├─→ L7 (analytics) + ├─→ L8 (visualization) + └─→ L9 (OOP wrap via make_fmri_stat_hrf) +``` + +The pipeline is largely DAG-shaped, not cyclic. The one back-edge: L5 (audit) repairs by calling L4 (score one prefix). Everything else flows downstream. + +--- + +## 4. Data flow + +``` +INPUT + fmri.nii.gz ──► fmri_data() [CanlabCore parent] + events.tsv ──► hrf_load_events_tsv() ──► stick functions + (volumes × conditions) + +FIT (per voxel) + timecourse + sticks ──► hrf_fit_all_models() + ├─► Fit_sFIR(...) ┐ + ├─► Fit_Canonical_HRF(...) │ Lindquist fitters + ├─► Fit_Spline(...) │ (parent folder) + ├─► Fit_Logit2(...) │ + └─► Fit_NLgamma(...) ┘ + ──► .hrf (time × cond) + .hrf_se, .fit, .residuals + .amplitude, .peak_lag + .metadata_table (one row per cond × lag) + +STACK across voxels ──► statistic_image objects: + .b (beta, voxels × volumes) + .t (t-stat) + .ste (standard error) + .p (p-value) + +WRITE TO DISK (whole-brain) + _beta.nii _t.nii + _se.nii _p.nii + _metadata.csv (volume → cond × lag mapping) + +SCORE (optional, immediately or via audit-repair) + + signature sets / image sets + ──► hrf_score_one_prefix() + ──► __map_scores.csv + +SECOND-LEVEL ASSEMBLY + hrf_collect_wholebrain_outputs() ──► input_table (one row per (subj, run, model)) + hrf_input_table_to_study() ──► study struct (in-memory; LoadWholeBrain optional) + +GROUP-LEVEL OUTPUTS + hrf_time_unfolding_stats(study) ──► per-timepoint t-tests + hrf_make_average_montage_animations(input_table) + ──► subject avg → group avg ──► thresholded t-map per condition + ──► MP4 animation over HRF lags + +OOP WRAPPING (for any of: result struct, result.mat path, NIfTI prefix) + make_fmri_stat_hrf(source, ...) ──► [Hb (fmri_hrf), Ht (statistic_hrf)] +``` + +--- + +## 5. Dependencies + +### canlabCore parent classes + +| Parent | What pipeline uses it for | +|---|---| +| `@fmri_data` | All loaded fMRI/whole-brain data. Subclassed by `@fmri_hrf`. Methods used: constructor, `cat`, `apply_mask`, `extract_roi_averages`, `mean`, `write`, `resample_space`. | +| `@statistic_image` | All whole-brain inferential maps. Subclassed by `@statistic_hrf`. Methods used: constructor, `threshold`, `multi_threshold`, `orthviews`, `convert2mask`, `conjunction`. | +| `@image_vector` | Indirect (base of both above). Methods used: `apply_mask`, `apply_atlas`, `apply_parcellation`, `remove_empty`, `replace_empty`. | +| Free functions | `load_image_set`, `apply_all_signatures`, `apply_mask` (function form), `get_wh_image`, `tor_make_deconv_mtx3`. | + +The Phase 3 OOP refactor is engineered so all of these inherit unchanged — `@fmri_hrf` and `@statistic_hrf` don't reimplement any of it. The win: thresholding, masking, ROI extraction, and `orthviews` work on the new classes the day they ship. + +### Lindquist-lab fitters + +All Fitters are called from **exactly one** dispatcher (`hrf_fit_all_models` for the pipeline, `hrf_fit_one_voxel` for direct single-voxel calls): + +| Fitter | Dispatcher entry-point | +|---|---| +| `Fit_sFIR` (and `Fit_sFIR_epochmodulation`) | `method='FIR'` or `'sFIR'` in `hrf_fit_one_voxel`; routed via `hrf_fit_all_models` for the pipeline | +| `Fit_Canonical_HRF` | `method='CHRF'` | +| `Fit_Logit2` (with `Anneal_Logit` / `Det_Logit` / `Get_Logit`) | `method='IL'` | +| `Fit_Spline` | direct in `hrf_fit_all_models` | +| `Fit_NLgamma` | direct in `hrf_fit_all_models` | +| `ResidScan` / `PowerLoss` / `PowerSim` | model-quality utilities (post-fit) | +| `HMHRFest` | hierarchical mixed-effects HRF — used by Phase 4 misspec pipeline (not by Phase 1–3) | + +The batch variants at root (`Fit_sFIR_all`, `Fit_Canonical_HRF_all`, `*_all_AR.m`) are **not called by the pipeline** — they're legacy multi-subject wrappers preserved for backward compatibility with old scripts. The pipeline fans out via SLURM array jobs instead. + +### Third-party + +- SPM (`spm_get_bf`, `spm_hrf`) — required by `Fit_Canonical_HRF` and `Fit_NLgamma`. +- FDA package (`create_bspline_basis`, `eval_basis`) — required by `Fit_Spline`. Error message points at the GitHub repo if missing. +- `New_fminsearchbnd/` — vendored bounded `fminsearch`; used by `Fit_NLgamma`. + +--- + +## 6. Top 10 load-bearing files (by line count) + +| File | Lines | Role | +|---|---|---| +| `plot_hrf_study_by_subject.m` | 906 | Multi-panel per-subject HRF plots; biggest single visualization function. | +| `hrf_make_average_montage_animations.m` | 841 | Group-level animated montages; per-prefix load cache (5bd381ae). | +| `hrf_audit_slurm_outputs.m` | 810 | Audit + RepairMissing; orchestrates score backfill. | +| `hrf_score_one_prefix.m` | 695 | **Single source of truth** for per-prefix scoring. 3 callers. | +| `hrf_second_level_inputs_to_study.m` | 690 | Study assembly with per-condition contrasts. | +| `hrf_fit_wholebrain_stats.m` | 540 | Whole-brain voxelwise fitting. | +| `plot_hrf_by_condition.m` | 482 | Per-condition curves with SE bands. | +| `hrf_write_slurm_study_script.m` | 480 | SLURM array job + worker generation. | +| `run_hrf_pipeline.m` | 426 | Single-subject entry point. | +| `hrf_time_unfolding_stats.m` | 424 | Group-level timepoint-wise t-tests. | + +The top 4 are the modules to study first if you're trying to understand or extend the pipeline. + +--- + +## 7. Where to add things + +| If you want to add… | Put it here | +|---|---| +| A new HRF fitter | `HRF_Est_Toolbox4/Fit_.m` (parent folder, alongside the Lindquist fitters). Then add a case in `hrf_fit_all_models`. | +| A new pipeline orchestration step | `HRF_Toolbox_Pipeline/hrf__.m`. Follow the naming convention. | +| A new visualization | `HRF_Toolbox_Pipeline/plot_hrf_.m` (procedural) OR a method on `@fmri_hrf` / `@statistic_hrf` (OOP, preferred for new work). | +| A new signature / image set | This goes in CanlabCore proper (`load_image_set`'s registry), not here. | +| A new analysis (group-level test, etc.) | If it takes a study struct: `HRF_Toolbox_Pipeline/hrf_.m`. If it takes an `fmri_hrf`: method under `@fmri_hrf/`. The Phase 4 misspec pipeline goes the second route. | +| A new OOP method | Inside the class folder: `@fmri_hrf/.m` or `@statistic_hrf/.m`. Use parent's method via direct field access — don't try to downcast through the parent constructor (it expects a filename as first arg). | + +--- + +## 8. Conventions + +- **Naming.** All pipeline functions start with `hrf_` (snake_case verb_noun). All plotters start with `plot_hrf_`. Class folders use `@/`. Helper-style classes are lowercase (`fmri_hrf`, `statistic_hrf`). +- **Prefix vs model_prefix.** For single-model fits, they're equal. For multi-model fits, `model_prefix = _` and that's where NIfTIs and metadata are written. The score CSV filename follows the same rule. The helper `local_image_prefix(prefix, model_name, n_models)` in `hrf_score_one_prefix.m` is the canonical resolver. +- **Metadata table.** Every whole-brain output (in-memory or on-disk) is paired with a metadata table — one row per 4D volume — with columns `volume_index`, `condition`, `lag_index`, `lag_seconds`, `image_label`, optionally `N`, `dfe`, `TR`, `mode`. The OOP classes carry this table as a property. +- **Three-way scoring consolidation.** `hrf_score_one_prefix.m` is invoked from (1) the SLURM worker, (2) the audit's RepairMissing path, and (3) the post-hoc `hrf_score_wholebrain_input_table` wrapper. All three produce byte-identical outputs. Any future scoring optimization (caching, batching, GPU) goes in the helper and benefits all three paths. + +--- + +For walkthrough-style examples of using these layers, see [Tutorial.md](Tutorial.md). diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/docs/HRF Analysis Code Map.pptx b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/docs/HRF Analysis Code Map.pptx new file mode 100644 index 00000000..071b18ff Binary files /dev/null and b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/docs/HRF Analysis Code Map.pptx differ diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/docs/HRF_Pipeline_Phased_Plan.md b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/docs/HRF_Pipeline_Phased_Plan.md new file mode 100644 index 00000000..e076292d --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/docs/HRF_Pipeline_Phased_Plan.md @@ -0,0 +1,353 @@ +# HRF Toolbox: Phased Refactor & Extension Plan + +**Author:** Michael Sun (drafted with Claude, May 2026) +**Audience:** Tor (review), Michael (execution) +**Scope:** Four interlocking workstreams across `HRF_Est_Toolbox2`, `HRF_Est_Toolbox4`, and a new `HRF_Toolbox_Pipeline` subfolder. + +--- + +## At a glance + +Four workstreams, sequenced so each phase unblocks the next: + +| Phase | Workstream | Deliverable | Effort | Blocks | +|---|---|---|---|---| +| 1 | Audit/score gap fix | Patched `hrf_audit_slurm_outputs` + shared scoring helper | Small (1–2 days) | nothing — ship now | +| 2 | Repo housekeeping + Toolbox2/4 merge | Cleaned `MichaelSun` branch; new `HRF_Toolbox_Pipeline/` in Toolbox4 | Medium (3–5 days) | requires parent‑directory access | +| 3 | `fmri_hrf` + `statistic_hrf` OOP refactor | New `@fmri_hrf` (< fmri_data) and `@statistic_hrf` (< statistic_image) classes with `plot`, `montage`, `cat`, residual/misspec methods | Medium‑large (1–2 weeks for v0) | Phase 2 | +| 4 | Misspecification pipeline | Diagnostics + CV + group t‑tests, packaged as `fmri_hrf` methods | Medium (1–2 weeks) | Phase 3 | + +The phases are sequenced so that the OOP refactor (Phase 3) becomes the substrate for the misspecification pipeline (Phase 4) — Tor's "maximally reuse canlabCore" goal is much easier to honour when the new diagnostics are written as `fmri_hrf` methods than as a parallel set of free functions. + +--- + +## Phase 1 — Audit/Score Gap Fix (immediate) + +### Diagnosis + +The pipeline already attempts per‑task scoring. Looking at the generated SLURM worker in `hrf_write_slurm_study_script.m` (lines 282–301), each SLURM array task calls `hrf_apply_maps_to_wholebrain` after writing the beta/T/SE/P NIfTIs, conditional on `config.signature_sets` or `config.image_sets` being non‑empty. So in principle the score CSVs should already exist by the time `hrf_audit_slurm_outputs` runs. + +Three concrete reasons they're often missing: + +1. **Scoring is not wrapped in `try/catch` in the SLURM worker.** Per worker lines 282–301, the inline scoring loop has no error handling. If `load_image_set` fails for a single signature, if a signature's voxel space cannot be resampled, or if metadata/volume counts disagree, the entire SLURM task fails *after* `beta/t/se/p/metadata` are written. The audit then sees those NIfTIs but no score CSVs. +2. **`hrf_audit_slurm_outputs` only reports, it never repairs.** `local_score_status` (audit.m:380–396) checks file existence and surfaces the gap in `failed_reason`, but nothing in the audit takes action. +3. **The post‑hoc backfill (`hrf_score_wholebrain_input_table`) is slow because it re‑reads whole‑brain NIfTIs from disk per row.** Lines 467 and 505 of that file each call `statistic_image(fmri_data(image_file, 'noverbose'))` separately for beta and SE — three full whole‑brain reads per row (beta, t, se), iterated serially with no parallelization. + +The duplication is also a maintenance hazard: there are now **three** places that orchestrate scoring (the SLURM worker, the audit's existence check, the post‑hoc backfill), and only the SLURM worker actually calls `hrf_apply_maps_to_wholebrain`. + +### Proposed fix (smallest viable change) + +Three coordinated edits: + +**1.1 — Factor a single per‑prefix scoring helper.** +Create `hrf_score_one_prefix.m` that, given an `output_prefix`, `model_name`, `n_models`, `score_objects`, `signature_sets`, `image_sets`, and metadata‑resolution policy, writes the missing `*__map_scores.csv` for one (task, model). This is the only function that knows how to: (a) resolve metadata via the current "csv → result.mat → sibling csv" fallback chain in `hrf_score_wholebrain_input_table`; (b) load the beta/t/se NIfTIs; (c) call `hrf_apply_maps_to_wholebrain`. It returns a status struct. + +**1.2 — Replace inline scoring in the SLURM worker with a call to the helper, wrapped in `try/catch`.** +The worker emits a per‑task scoring loop today. Replace it with `hrf_score_one_prefix(output_prefix, model_name, …)` wrapped in `try`/`catch`, log the error, and continue. A signature failure should no longer kill a whole SLURM task; the audit will catch any genuinely missing CSVs. + +**1.3 — Add `RepairMissing` mode to `hrf_audit_slurm_outputs`.** +New name‑value parameter `'RepairMissing', false` (default `false`, preserving current behaviour). When `true`, for every row where `core_complete && ~score_complete`, call `hrf_score_one_prefix` to backfill the missing CSVs in place. Optionally accept `'UseParallel', true` to run the repair loop under `parfor`. After repair, re‑evaluate `score_complete` and write the audit table with the repaired status. + +`hrf_score_wholebrain_input_table` becomes a thin wrapper over `hrf_score_one_prefix` — keep it for back‑compat but stop carrying the heavy logic. + +### Why this design rather than "just fold scoring into the audit" + +Folding scoring straight into the audit (the literal reading of "the audit should produce the score files") would solve the symptom but lock the duplication in. The factored helper costs the same effort and lets the **fastest** path (per‑task scoring inside SLURM) and the **slow** path (post‑hoc repair) share the same code, so any future optimization — e.g., memory‑mapped NIfTI reads, GPU‑accelerated dot products, batched signature application — benefits all three call sites at once. + +### Verification (Phase 1 acceptance) + +Smoke test on one finished study directory: + +```matlab +% Before fix +[A1, S1] = hrf_audit_slurm_outputs(output_dir); +sum(~A1.score_complete & A1.core_complete) % expected: some > 0 + +% After fix +[A2, S2] = hrf_audit_slurm_outputs(output_dir, 'RepairMissing', true); +sum(~A2.score_complete & A2.core_complete) % expected: 0 +``` + +Then diff one repaired `*_map_scores.csv` against an `hrf_score_wholebrain_input_table` re‑run on the same row — they should be identical to floating‑point tolerance. + +--- + +## Phase 2 — Housekeeping & Toolbox2 ↔ Toolbox4 Merge + +### Cruft inventory (from `HRF_Est_Toolbox2/`) + +A first pass over the working folder surfaces these candidates for cleanup. I have not yet diffed against `HRF_Est_Toolbox4` (no access to it from my current mount), so the merge decisions in the right column are provisional. + +| Item | Likely status | Provisional disposition | +|---|---|---| +| `hrf_write_slurm_study_script.asv` | MATLAB autosave file | Delete | +| `Old_stuff/` (15 files, 140 KB) | Pre‑refactor logit/sFIR variants | Archive in git history, then delete from working tree | +| `Old_stuff/More_recent_old_stuff/` | Deeper legacy, including `.mat` fixtures | Archive in git history, then delete from working tree | +| `New_fminsearchbnd/` | Single‑purpose third‑party utility | Keep but move to a shared deps folder; check whether canlabCore already vendors it | +| `Example.m`, `Example2.m` | Legacy demos that reference old fitters | Either modernize as `examples/` or remove if superseded by README's quick‑start | +| `HRF Analysis Code Map.pptx`, `README.pptx` | Documentation artifacts | Move to `docs/` | +| Top‑level non‑`hrf_` `.m` files (Fit_Canonical_HRF, Fit_Logit2, Fit_sFIR, Fit_NLgamma, Fit_Spline, etc.) | The legacy fitters that the new `run_hrf_pipeline` wraps; load‑bearing | **Keep** — these are the canonical Lindquist‑lab estimators and the new pipeline depends on them | + +I'd like to walk through this list with you before deleting anything — particularly the `Old_stuff/More_recent_old_stuff/*.mat` files, which look like reference fixtures rather than code. + +### Toolbox2 ↔ Toolbox4 merge — what I need from you + +To do the merge credibly I need access to **the parent `CanlabCore` directory** (or at least `HRF_Est_Toolbox4`). Your selected folder right now is just `HRF_Est_Toolbox2`, so I cannot: + +- diff Toolbox2 vs Toolbox4 method‑by‑method, +- check which functions are newer / diverged / unique to each side, +- inventory the canlabCore `@fmri_data` / `@statistic_image` class folders that Phase 3 must inherit from. + +Two options, either works: + +- **(a)** Re‑select the workspace folder one level up at `MichaelSun/CanlabCore/CanlabCore/` so I can see Toolbox2, Toolbox4, and the canlabCore class folders side by side. +- **(b)** Keep me here and run a `git log --stat HRF_Est_Toolbox2 HRF_Est_Toolbox4` for me, plus paste me the file listing of `HRF_Est_Toolbox4/`. I can then propose the merge map. + +(a) is much faster. + +### Merge approach (once I can see both) + +1. **Build a function‑by‑function comparison table.** For every `*.m` in either toolbox, classify as `only_in_2`, `only_in_4`, `diverged`, `identical`. For diverged files, prefer the newer mtime *but* manually inspect anything where Toolbox4 supersedes a Lindquist‑lab core fitter — those are load‑bearing and changes should be reviewed. +2. **Scaffold `HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/`** as the new home for the user‑facing pipeline (the `run_hrf_pipeline`, `hrf_write_slurm_study_script`, audit, collect, score, plot, time‑unfolding family). The legacy fitters (`Fit_*`, `Anneal_*`, `ResidScan`, `PowerLoss`, etc.) stay at the Toolbox4 root next to the canonical Lindquist code. +3. **Move the new pipeline files** from Toolbox2 to `HRF_Toolbox_Pipeline/`, preserving git history with `git mv`. +4. **Delete Toolbox2** (or leave an empty `HRF_Est_Toolbox2/README.md` redirecting to Toolbox4) after one full pass of regression tests on the pipeline. + +### Verification (Phase 2 acceptance) + +- `run_hrf_pipeline` runs end‑to‑end on the smoke‑test subject from the new location. +- `hrf_write_slurm_study_script` generates a script and worker that point at the new paths. +- A `git log --follow` on any moved file shows the pre‑merge history. + +--- + +## Phase 3 — The `fmri_hrf` and `statistic_hrf` Objects (OOP refactor) + +### The two-class refinement (Michael's design) + +Rather than one polymorphic `hrf` object, build **two sibling classes** that mirror the canlabCore split between data containers and inferential containers: + +- **`fmri_hrf < fmri_data`** — holds beta / amplitude maps. These are continuous, unthresholded numbers; the natural operations are amplitude/AUC/peak-lag extraction, ROI averaging, signature application, time-series reconstruction. +- **`statistic_hrf < statistic_image`** — holds t / p / se maps. Inherits `threshold`, `multi_threshold`, `orthviews`, `convert2mask`, `conjunction`, etc., so thresholding "on the fly" comes free. + +Both classes share the same HRF-specific metadata (subject, run, condition, lag, TR, design info) and the same `cat` semantics across runs/subjects. They're paired (one `fmri_hrf` and one `statistic_hrf` per fit) and convertible into each other (e.g. `statistic_hrf(fmri_hrf_obj, 'SE', se_obj)` to assemble inferential maps from a beta object + uncertainty). + +This is significantly cleaner than a single class with optional inferential fields: it puts `threshold()` in exactly the place the rest of CanlabCore puts it, and avoids the awkward situation of an `hrf.threshold()` method that does nothing for raw amplitudes. + +### Motivating example + +```matlab +% Build from one run +[Hb, Ht] = make_fmri_stat_hrf(results.wholebrain_by_model.sfir, ... + 'Subject', 'sub-01', ... + 'Run', 'task-pain_run-01', ... + 'Conditions', {'pain','neutral'}); +% Hb is fmri_hrf, Ht is statistic_hrf + +% Concatenate across runs / subjects (both classes work identically) +Hb_sub = cat(1, Hb_run01, Hb_run02, Hb_run03); +Hb_study = cat(1, Hb_sub01, Hb_sub02, ..., Hb_subN); +Ht_study = cat(1, Ht_sub01, Ht_sub02, ..., Ht_subN); + +% Threshold the t-side on the fly (inherited from statistic_image) +Ht_thr = threshold(Ht_study, 0.001, 'unc'); + +% Combined plot: brain montage from one side + time-series line plot from the other +plot(Hb_study, 'Condition', 'pain'); % amplitude curves +plot(Ht_study, 'Condition', 'pain', 'Threshold', [0.05 'unc']); % thresholded t montage +montage(Ht_study, 'Condition', 'pain'); % animated over lag + +% Misspecification & CV operate on the amplitude side +residuals(Hb) % single-run residual diagnostics +misspec_metrics(Hb, 'Reference', 'canonical') +cv_amplitude(Hb_study, 'KFold', 5); +ttest(Hb_study, 'ConditionA','pain','ConditionB','neutral') % returns a statistic_hrf +``` + +### Class skeletons (proposed) + +``` +@fmri_hrf/ + fmri_hrf.m % constructor; accepts wholebrain.b, an fmri_data + metadata, + % a result.mat path, or a NIfTI prefix. + cat.m % vertical concatenation across (subject, run) axes + plot.m % time-series-style condition x lag plot, optional montage + residuals.m % requires design_matrix; returns residual time-series + misspec_metrics.m % residual-task-corr, AUC ratio, peak-lag bias, power loss + cv_amplitude.m % k-fold CV of amplitude / AUC / sqrt-variance + ttest.m % returns a statistic_hrf + group_hrf.m % HMHRFest-style data-driven group HRF + display.m, isempty.m, size.m, end.m + to_statistic_hrf.m % helper: build a paired statistic_hrf from a se obj + private/ + init_from_wholebrain.m + init_from_resultmat.m + align_for_concat.m + hrf_metadata_validate.m % shared with @statistic_hrf +``` + +``` +@statistic_hrf/ + statistic_hrf.m % constructor; accepts wholebrain.t, statistic_image + metadata, + % or a NIfTI prefix + cat.m % parallel to @fmri_hrf/cat + plot.m % thresholded montage + condition x lag t-curves + montage.m % animated over lag (calls @fmridisplay) + threshold.m % override-or-delegate to @statistic_image/threshold + % (likely just inherit and let the parent handle it) + display.m, isempty.m, size.m, end.m +``` + +### Shared HRF metadata (lives on both classes) + +```matlab +% Same fields on fmri_hrf and statistic_hrf +properties + metadata_table % volume_index, condition, lag_index, lag_seconds, + % image_label, N, dfe, TR, mode (one row per 4D volume) + subject % string + run_label % string + model_name % 'fir' | 'sfir' | 'canonical' | 'spline' + conditions % cellstr + TR % seconds + design_matrix % NaN if not loaded; required by residuals() + design_info % output_lift, fir_columns, intercept/nuisance columns + source_paths % beta/t/se/p/metadata/result_mat files + hrf_meta_version % bump on schema change +end +``` + +A small `hrf_metadata_validate.m` lives under both class folders' `private/` (or in a shared helper folder) and is the single source of truth for "are these fields well-formed and compatible for concatenation." + +### canlabCore reuse — what each class inherits vs adds + +| Behaviour | Lives in | +|---|---| +| Voxel storage, masking, write/read, removed_voxels semantics | `fmri_data` (parent of `fmri_hrf`) and `statistic_image` (parent of `statistic_hrf`). | +| `threshold`, `multi_threshold`, `orthviews`, `convert2mask`, `conjunction` | `statistic_image` — free for `statistic_hrf`. | +| `apply_mask`, `extract_roi_averages`, `pattern_expression`, `image_similarity_plot` | `fmri_data` / `image_vector` — free for `fmri_hrf`. | +| HRF-side: condition-lag indexing, residual diagnostics, misspec metrics, group HRF, k-fold CV | New methods on `fmri_hrf` (data-side analytics live there). | +| HRF-side: thresholded montage over lag, condition x lag t-curves | New methods on `statistic_hrf`. | +| `cat` across (subject, run) rows | New on both classes — different from `statistic_image/cat` which concatenates *images*. | +| Pairing | A small helper `[Hb, Ht] = make_fmri_stat_hrf(...)` builds the matched pair from one fit. `Hb.to_statistic_hrf()` and `statistic_hrf(Hb, 'SE', se_obj)` round-trip. | + +### Confirmed from the CanlabCore mount + +With the second mount in place I can see the parent classes' actual surface area: `@fmri_data/` provides `cat.m`, `hrf_fit.m`, `extract_roi_averages.m`, `denoise_timeseries_pipeline.m`, `fitlme_voxelwise.m`, and the data-side methods we want for `fmri_hrf`. `@statistic_image/` provides `cat.m`, `threshold.m`, `multi_threshold.m`, `orthviews.m`, `convert2mask.m`, `conjunction.m`, `estimateBayesFactor.m` — every method `statistic_hrf` needs for thresholded inference is already there. + +The one wrinkle: `@statistic_image/cat.m` already exists, and our `@statistic_hrf/cat.m` must call it (for the underlying voxel data) and then align the HRF metadata. Same on the fmri_data side. + +### Open questions for Tor + +1. **One pair per (subject, run, model), or one pair per (subject, run) with multiple models?** Multi-model pairing makes misspec comparison (Phase 4) cleaner — `Hb_sfir` vs `Hb_canonical` for the same subject are siblings rather than separately-concatenated objects. My instinct: single-model classes plus an `hrf_modelset` container that holds `{model_name → {fmri_hrf, statistic_hrf}}`. Misspec methods take an `hrf_modelset` and return cross-model comparisons. +2. **Does the 1D signature/ROI time series live on `fmri_hrf` or on a parallel `hrf_timeseries`?** They share metadata (subject, run, conditions, TR) but the storage is completely different (`fmri_hrf` is voxels × condition·lag, the time series is time × signatures). +3. **Naming** — `fmri_hrf` is clean and fits the `fmri_data` / `fmri_mask_image` / `fmri_timeseries` family already in CanlabCore. `statistic_hrf` parallels `statistic_image`. Both are unambiguous in CanlabCore but `hrf` alone would shadow many SPM/legacy variables. + +### Verification (Phase 3 acceptance) + +- Round‑trip: `H = hrf(prefix); write(H, new_prefix); H2 = hrf(new_prefix); assert(isequal(H, H2))`. +- `plot(H)` renders correctly for a single subject, single run, all 4 supported models. +- `cat(1, H1, H2, ..., HN)` for N=10 subjects renders a study‑level montage in under a minute on a typical workstation. +- All canlabCore inheritance paths exercised by at least one test. + +--- + +## Phase 4 — Misspecification Pipeline + +### Goal + +Move from "we fit several HRF models and look at them" to "we quantify, for each model, how *wrong* it is, where the wrongness is, and whether a better model is reachable from the data". The deliverable is a set of `fmri_hrf` methods (operating on amplitude maps, with diagnostic outputs that can be lifted into `statistic_hrf` for thresholded visualization) that work at run / subject / study level and emit interpretable metrics. + +### Drawing from Lindquist's tools + +`HRF_Est_Toolbox2` already vendors several Lindquist‑lab functions that are directly relevant. Once I can see Toolbox4 I'll list its additions, but from Toolbox2 alone the relevant pieces are: + +- **`ResidScan.m`** — scans residuals for task‑locked structure. This is the foundation: if residuals at the canonical HRF still show a task‑locked component, the canonical is misspecified for this voxel/region. The misspec pipeline should run this per voxel (or per ROI) and aggregate. +- **`PowerLoss.m`** — quantifies power loss from HRF misspecification. The literature this implements (Loh, Lindquist, Wager 2008; Lindquist et al. 2009) gives the canonical metric: efficiency loss as a function of how far the assumed HRF is from the true one. We can use this both as a metric and as a CV objective. +- **`PowerSim.m`** — Monte‑Carlo helper used by `PowerLoss`. Useful for null distributions of misspec metrics. +- **`Anneal_Logit.m`, `Fit_Logit2.m`, `Det_Logit.m`** — the inverse‑logit HRF family. Provides a flexible HRF basis that's nearly canonical when well‑fit; useful as a "best achievable" reference against which to measure other models' misspec. +- **`HMHRFest.m`** — hierarchical mixed‑effects HRF estimation. The natural backbone for group‑level "optimized HRF agnostic of hypothesis" — this gives you a data‑driven group HRF that you can then test against the canonical. + +### Metrics to expose (proposed) + +For a fitted `fmri_hrf` object `Hb` with model `m`, compared against reference model `r`: + +| Metric | Formula sketch | Interpretation | +|---|---|---| +| `residual_taskcorr(H)` | corr(residual, stick_function) per voxel/ROI | Non‑zero = task‑locked variance unexplained → misspec | +| `residual_acf(H, lag)` | residual autocorrelation, normalized | Distinguishes physiological noise from misspec | +| `misspec_R2(H, ref)` | 1 − SS_res(m)/SS_res(r) | "R² of model improvement over reference" | +| `power_loss(H, ref)` | from `PowerLoss.m` | Lindquist's canonical metric — degrees of effective sample size lost | +| `peak_lag_bias(H, ref)` | argmax_lag H − argmax_lag ref | Where in time is the misspec concentrated? | +| `auc_ratio(H, ref)` | ∫H / ∫ref | Amplitude/AUC bias | +| `curve_sd(H_runs)` | sqrt(var across runs) per lag | Stability of the estimated HRF | + +These should all be expressible as voxel‑wise maps **and** as ROI summaries — the misspec map is in itself a useful product (it tells you *where* the model is wrong). + +### Cross‑validation for amplitude/AUC + +K‑fold CV across runs (or trials within run, for event‑related designs): + +```matlab +cv = cv_amplitude(Hstudy, ... + 'KFold', 5, ... + 'Metric', {'amplitude','auc','sqrt_var'}, ... + 'Stratify', 'subject'); + +cv.amplitude.train_mean % per fold +cv.amplitude.test_mean +cv.amplitude.generalization_gap +``` + +Use the test‑fold metric to compare models without circular fits. + +### Convergence to an "optimal HRF" (data‑driven, hypothesis‑agnostic) + +The pipeline Tor describes is essentially: take all subjects/runs, fit a flexible HRF (sFIR or inverse‑logit), pool across the study with `HMHRFest`‑style mixed effects, get a group HRF, then re‑evaluate misspec metrics against *that* HRF instead of (or in addition to) the canonical. Workflow: + +1. `Hstudy_sfir = cat(1, H_run_sfir...)` +2. `H_opt = group_hrf(Hstudy_sfir, 'Method','HMHRFest')` — returns a single group‑level HRF per condition (or per ROI × condition) +3. `metrics = misspec_metrics(Hstudy_canonical, 'Reference', H_opt)` — now you can see how much worse canonical is than the *empirically optimal* HRF, not just how it differs from itself. + +### Group t‑tests + +```matlab +T = ttest(Hstudy, ... + 'ConditionA','pain', 'ConditionB','neutral', ... + 'Across','subject', ... + 'Lag', 6, ... % or {} for all lags + 'Metric','amplitude'); % or 'auc', 'peak_value', 'peak_lag' + +T.tmap % statistic_image +T.pmap % statistic_image +T.summary % table per ROI +``` + +This is what the current `hrf_time_unfolding_stats` does, but with a unified object‑oriented entry point. + +### Verification (Phase 4 acceptance) + +- On synthetic data where the true HRF is known (use `Fit_Logit2` to generate ground truth, then add noise), `misspec_metrics(canonical_fit, 'Reference', true_hrf)` returns power loss within 10% of the analytic value. +- `cv_amplitude` on a real dataset gives `generalization_gap < 0.2 * train_mean` for sFIR fits on n ≥ 20 subjects. +- `group_hrf` converges within 50 iterations on a synthetic dataset and recovers the true HRF to within 5% RMS. + +--- + +## Risks & open issues + +- **Folder access for Phase 2.** Until I can see `HRF_Est_Toolbox4` and the canlabCore class folders, every estimate for Phases 2–4 is provisional. Easy to fix — re‑mount one folder up. +- **Naming collisions resolved.** The two-class split (`fmri_hrf` and `statistic_hrf`) avoids the shadow problem that a bare `hrf` class would have caused with SPM/legacy variables. Names fit the existing `fmri_*` / `statistic_*` family in CanlabCore. Confirm with Tor at Phase-3 kickoff. +- **`Old_stuff/*.mat` files.** Some look like reference fixtures used by tutorial/example scripts. Will check uses before deleting. +- **Backward compatibility of `hrf_score_wholebrain_input_table`.** The README documents this entry point. The Phase 1 refactor must keep its signature working, even if the heavy lifting moves to `hrf_score_one_prefix`. +- **SLURM `try/catch` change.** Wrapping per‑task scoring in `try/catch` will mean a task can succeed at the NIfTI level but quietly miss scores. The audit (now with `RepairMissing`) is the safety net for this — both pieces need to ship together. +- **`HMHRFest` performance.** Mixed‑effects HRF estimation at whole‑brain scale is slow. Phase 4 should pilot it on ROIs first and only attempt voxelwise once we know it's tractable. + +--- + +## Sequencing recommendation + +1. **This week:** implement Phase 1 against `MichaelSun/HRF_Est_Toolbox2`. Smoke‑test against one finished study. Ship. +2. **Next week:** re‑mount one folder up so I can see Toolbox4 and canlabCore classes; execute Phase 2 (housekeeping + merge into `HRF_Toolbox_Pipeline/`). +3. **Following 2–3 weeks:** Phase 3 OOP refactor, in `HRF_Toolbox_Pipeline/@hrf/`. Iterate naming and field layout with Tor. +4. **Final 2 weeks:** Phase 4 misspec pipeline as `fmri_hrf` methods (with thresholded visualizations on the paired `statistic_hrf`). Validate against synthetic ground truth before applying to real data. + +Each phase ships independently — Phase 1 doesn't have to wait for the merge, and Phase 4 doesn't have to wait for everything to be perfect about the object model. diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/docs/Tutorial.md b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/docs/Tutorial.md new file mode 100644 index 00000000..58384696 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/docs/Tutorial.md @@ -0,0 +1,435 @@ +# HRF Toolbox Pipeline — Tutorial + +A walkthrough for going from raw fMRI data to thresholded group HRF maps using the `HRF_Toolbox_Pipeline`. Aimed at users who have done some neuroimaging in MATLAB but are new to this toolbox. + +For the architectural overview, see [Architecture.md](Architecture.md). For the canonical Lindquist-lab fitters this pipeline wraps, see the parent folder `HRF_Est_Toolbox4/`. + +--- + +## Contents + +1. [Setup](#1-setup) +2. [Quick start: one subject, one run](#2-quick-start-one-subject-one-run) +3. [Inspecting the results struct](#3-inspecting-the-results-struct) +4. [Working with the new OOP classes](#4-working-with-the-new-oop-classes) +5. [Study scale: writing a SLURM array job](#5-study-scale-writing-a-slurm-array-job) +6. [Auditing and repairing a finished SLURM run](#6-auditing-and-repairing-a-finished-slurm-run) +7. [Post-SLURM: collecting outputs and building a study struct](#7-post-slurm-collecting-outputs-and-building-a-study-struct) +8. [Group-level analysis](#8-group-level-analysis) +9. [Visualization: plots, montages, animations](#9-visualization-plots-montages-animations) +10. [Common patterns and FAQ](#10-common-patterns-and-faq) + +--- + +## 1. Setup + +You need three folders on the MATLAB path: CanlabCore (the parent class library), this pipeline subfolder, and the legacy Lindquist fitters at `HRF_Est_Toolbox4/`. Adding CanlabCore recursively with `genpath` covers all three: + +```matlab +addpath(genpath('/path/to/CanlabCore/CanlabCore')); +``` + +That's it. The `genpath` walks into `HRF_Est_Toolbox4/` (legacy fitters), `HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/` (this pipeline), and all the class folders (`@fmri_data`, `@statistic_image`, `@fmri_hrf`, `@statistic_hrf`, etc.). + +Sanity check: + +```matlab +which run_hrf_pipeline % should resolve into HRF_Toolbox_Pipeline/ +which fmri_data % should resolve into @fmri_data/ +which fmri_hrf % should resolve into HRF_Toolbox_Pipeline/@fmri_hrf/ +which Fit_sFIR % should resolve into HRF_Est_Toolbox4/ (sibling) +``` + +If any of these come up empty, your path is missing something. + +--- + +## 2. Quick start: one subject, one run + +The shortest path from raw data to fitted HRFs. Single subject, single run, three models: + +```matlab +results = run_hrf_pipeline( ... + '/path/to/sub-01_task-pain_bold.nii.gz', ... % 4D fMRI + '/path/to/sub-01_task-pain_events.tsv', ... % BIDS events + 'TR', 0.8, ... + 'MaskNii', '/path/to/brain_mask.nii.gz', ... + 'Conditions', {'pain', 'neutral'}, ... + 'WindowSeconds', 30, ... + 'Models', {'fir', 'sfir', 'canonical'}, ... + 'OutputMat', '/path/to/sub-01_hrf_results.mat'); +``` + +What this does, step by step: + +1. Loads the 4D NIfTI and extracts a mean-within-mask timeseries. +2. Reads the BIDS `events.tsv`; validates required columns (`onset`, `duration`, `trial_type`). +3. Builds one stick function per condition. +4. Fits each requested model (here: FIR, sFIR, SPM canonical) using the Lindquist-lab fitters. +5. Returns a single `results` struct and (optionally) saves it as `.mat`. + +A few practical defaults: + +- If you don't pass `TR`, the pipeline reads it from the NIfTI header (`PixelDimensions(4)`). Pass it explicitly when you can — header values are sometimes wrong. +- `WindowSeconds` is the HRF estimation window (the time after stimulus onset over which the HRF is estimated). 30s covers a canonical BOLD response with margin. For event-related rapid designs you can shrink to 24s. +- `Conditions` filters which `trial_type` values from the events file you actually fit. Wildcards work: `{'pain_*'}` matches `pain_high`, `pain_low`, etc. + +--- + +## 3. Inspecting the results struct + +The returned `results` struct has the same shape regardless of how many models you fit. Top-level fields: + +```matlab +results.config % the parameters the pipeline saw +results.timeseries % whole-brain (or in-mask) mean timeseries it actually fit +results.stick_functions % one column per condition +results.fits_by_model % per-model fit struct (the heart of the output) +results.wholebrain_by_model % only if you ran whole-brain fitting (see Section 5) +``` + +The per-model fit struct (`results.fits_by_model.sfir`, say) carries: + +```matlab +results.fits_by_model.sfir.hrf % time x conditions HRF estimates +results.fits_by_model.sfir.hrf_se % time x conditions standard errors +results.fits_by_model.sfir.fit % fitted timeseries (overall) +results.fits_by_model.sfir.residuals % residuals +results.fits_by_model.sfir.amplitude % per-condition amplitude estimate +results.fits_by_model.sfir.peak_lag % per-condition peak lag +results.fits_by_model.sfir.metadata_table % one row per (condition, lag) pair +``` + +For a quick visual sanity check: + +```matlab +plot_hrf_by_condition(results, 'Models', {'sfir', 'canonical'}); +``` + +That gives you per-condition curves with error bands, one panel per condition, one curve per model. + +--- + +## 4. Working with the new OOP classes + +The `@fmri_hrf` and `@statistic_hrf` classes (added in Phase 3 of the toolbox refactor) wrap whole-brain HRF maps in objects that inherit from CanlabCore's `fmri_data` and `statistic_image`. This means all the canlabCore methods you already use — `threshold`, `multi_threshold`, `orthviews`, `apply_mask`, `extract_roi_averages`, `convert2mask`, `conjunction` — work on these objects for free. + +### Building a paired (beta, t) pair from one fit + +The canonical entry point is `make_fmri_stat_hrf`. It takes one of three source shapes and returns the matched pair: + +```matlab +% From a results struct (most common — right after run_hrf_pipeline with whole-brain on): +[Hb, Ht] = make_fmri_stat_hrf(results.wholebrain_by_model.sfir, ... + 'Subject', 'sub-01', ... + 'RunLabel', 'task-pain_run-01', ... + 'ModelName', 'sfir', ... + 'TR', 0.8); + +% From a saved result.mat path: +[Hb, Ht] = make_fmri_stat_hrf('/path/to/sub-01_hrf_results.mat', ... + 'ModelName', 'sfir', ... + 'Subject', 'sub-01', ... + 'TR', 0.8); + +% From a NIfTI prefix (loads beta + t + se + metadata from disk): +[Hb, Ht] = make_fmri_stat_hrf('/path/to/sub-01_hrf_sfir', ... + 'Subject', 'sub-01', ... + 'ModelName', 'sfir', ... + 'TR', 0.8); +``` + +`Hb` is the beta side (`fmri_hrf < fmri_data`), `Ht` is the inferential side (`statistic_hrf < statistic_image`). They share metadata (subject, run_label, model_name, conditions, lags, TR) by construction. + +### Inspecting an object + +The classes have HRF-aware `disp` methods: + +```matlab +>> Hb + fmri_hrf subject=sub-01 run=task-pain_run-01 model=sfir + 99837 voxels x 60 volumes (2 conditions x 30 lags) TR=0.8s + conditions: pain, neutral + +>> Ht + statistic_hrf subject=sub-01 run=task-pain_run-01 model=sfir type=T + 99837 voxels x 60 volumes (2 conditions x 30 lags) TR=0.8s + conditions: pain, neutral +``` + +### Thresholding (inherited from statistic_image) + +No new code needed — `threshold`, `multi_threshold`, `orthviews` all work: + +```matlab +Ht_thr = threshold(Ht, 0.001, 'unc'); +orthviews(Ht_thr); +``` + +### Concatenating across subjects/runs + +`cat` is overridden to align HRF metadata across (subject, run) and stack: + +```matlab +% After running the pipeline for every subject in your study: +Hb_study = cat(1, Hb_sub01, Hb_sub02, Hb_sub03, ..., Hb_subN); +Ht_study = cat(1, Ht_sub01, Ht_sub02, Ht_sub03, ..., Ht_subN); +``` + +`cat` requires all inputs to share `model_name`, `TR`, and the condition set. Subject and run_label can (and should) differ — the resulting `metadata_table` gets `subject` and `run_label` columns added so you can recover the source axis with table indexing later. + +### Pairing an existing fmri_hrf with an SE image + +If you already have an `fmri_hrf` with betas and want to derive its statistic_hrf sibling on the fly: + +```matlab +Ht = Hb.to_statistic_hrf('SE', se_obj); +% or, with an already-computed t image: +Ht = Hb.to_statistic_hrf('TStat', t_obj); +``` + +### What's not yet on these objects + +v0 ships the constructor, `disp`, `cat`, and `to_statistic_hrf`. Methods deferred to Phase 4 (the misspecification pipeline): + +- `plot(Hb)` — condition × lag curves +- `montage(Ht)` — animated over lag +- `residuals(Hb)` +- `misspec_metrics(Hb, 'Reference', ...)` +- `cv_amplitude(Hb)` +- `ttest(Hb)` — returns a `statistic_hrf` +- `group_hrf(Hb)` — HMHRFest-style data-driven group HRF + +For now, the older procedural functions (`plot_hrf_by_condition`, `plot_hrf_study_by_subject`, `hrf_make_average_montage_animations`) cover the same ground. + +--- + +## 5. Study scale: writing a SLURM array job + +For a real study (dozens to hundreds of subjects), you don't run `run_hrf_pipeline` in a loop locally — you generate a SLURM array script that fans out one task per (subject, run, model) onto a cluster. + +```matlab +% Build the input lists. One entry per (subject, run). +fmri_files = { ... + '/dartfs/.../sub-01_task-pain_bold.nii.gz', ... + '/dartfs/.../sub-02_task-pain_bold.nii.gz', ... + ... }; +events_files = { ... + '/dartfs/.../sub-01_task-pain_events.tsv', ... + '/dartfs/.../sub-02_task-pain_events.tsv', ... + ... }; +subject_ids = {'sub-01','sub-02', ...}; + +hrf_write_slurm_study_script( ... + fmri_files, events_files, subject_ids, ... + 'JobName', 'hrf_pain_study', ... + 'OutputDir', '/dartfs/.../hrf_outputs', ... + 'TR', 0.8, ... + 'Conditions', {'pain', 'neutral'}, ... + 'WindowSeconds', 30, ... + 'Models', {'sfir', 'canonical'}, ... + 'WriteWholeBrain', true, ... + 'SignatureSets', {'all'}, ... + 'PartitionTime', '04:00:00'); +``` + +The generator writes three things into `OutputDir`: + +- `hrf_pain_study.sh` — the SLURM array script you `sbatch`. +- `hrf_pain_study_worker.m` — the per-task MATLAB worker. Each array task runs this with its task index. +- `hrf_pain_study_manifest.csv` — one row per array task. The audit step uses this. + +Each task does the work of `run_hrf_pipeline` plus, if `'SignatureSets'` is non-empty, scoring the resulting whole-brain maps against the signature set(s) and writing `__map_scores.csv`. The scoring step is wrapped in `try/catch` so a single failing signature doesn't kill the whole task — the audit step (next section) catches anything genuinely missing. + +To submit: + +```bash +sbatch /dartfs/.../hrf_outputs/hrf_pain_study.sh +``` + +--- + +## 6. Auditing and repairing a finished SLURM run + +After the array job finishes, the audit step reconciles the manifest against on-disk outputs and reports any gaps. Two modes: + +```matlab +% Report only — fast, read-only. +[audit, summary] = hrf_audit_slurm_outputs('/dartfs/.../hrf_outputs'); +summary +audit(~audit.complete, :) + +% Report AND repair missing score CSVs in place. +[audit, summary] = hrf_audit_slurm_outputs('/dartfs/.../hrf_outputs', ... + 'RepairMissing', true, ... + 'Verbose', true); +summary.repair +``` + +`audit` is a table with one row per (task, model). Key columns: `core_complete` (all NIfTIs + metadata present?), `score_complete` (all signature score CSVs present?), `complete` (both). `RepairMissing=true` finds every row where `core_complete && ~score_complete` and re-runs scoring for that prefix — it calls `hrf_score_one_prefix` (the same helper the SLURM worker uses), so the post-repair files are byte-identical to what the worker would have written. + +If `score_complete` is still false after repair, look at `summary.repair.errors` — the helper logged why (typically: missing signature in `load_image_set`, voxel-space mismatch, metadata file unreadable). + +--- + +## 7. Post-SLURM: collecting outputs and building a study struct + +Once the array job is clean, collect the outputs into a single table for downstream work: + +```matlab +input_table = hrf_collect_wholebrain_outputs('/dartfs/.../hrf_outputs'); +``` + +`input_table` has one row per (subject, run, model) with paths to beta/t/se/p NIfTIs, metadata CSV, score CSVs, and the run's `result.mat`. This is the "second-level input table" — most downstream analytics consume it. + +To rebuild an in-memory study struct (the same shape `run_hrf_study_pipeline` would produce, but assembled after the fact from disk): + +```matlab +study = hrf_input_table_to_study(input_table, 'LoadWholeBrain', true); +``` + +`study.results{i}` now has `.wholebrain` (`fmri_data` / `statistic_image` objects loaded from the NIfTIs) and `.fits_by_signature` (signature scores from the CSVs). Memory cost is non-trivial — `LoadWholeBrain=false` skips the NIfTI loads if you only need the score CSVs. + +For the extended version with per-condition contrasts derived from metadata, use: + +```matlab +study = hrf_second_level_inputs_to_study(input_table, ... + 'Conditions', {'pain','neutral'}); +``` + +--- + +## 8. Group-level analysis + +Three entry points, depending on what you're testing: + +### Time-point-wise t-tests across subjects + +```matlab +T = hrf_time_unfolding_stats(study, ... + 'Conditions', {'pain', 'neutral'}, ... + 'Models', {'sfir'}, ... + 'Signatures', {'NPS', 'SIIPS'}); + +T.timepoint_tstats % per-timepoint t-tests per (condition, signature) +T.fdr_corrected % FDR-thresholded version +``` + +### 2×2 factorial (two between-subject groups × two conditions) + +```matlab +F = hrf_2x2_study_score_stats(study, group_labels, ... + 'ConditionPair', {'pain', 'neutral'}, ... + 'Signature', 'NPS'); + +F.main_effect_group +F.main_effect_condition +F.interaction +``` + +### Flexible long-form dataframe for custom modeling + +```matlab +df = hrf_analyze_second_level_inputs(study); +% df is a wide table: rows = (subject, condition, lag, signature); columns include +% score, model, run, group, etc. Hand off to fitlme / fitglme / R. +``` + +--- + +## 9. Visualization: plots, montages, animations + +### Per-subject HRF curves + +```matlab +plot_hrf_study_by_subject(study, ... + 'Conditions', {'pain'}, ... + 'Models', {'sfir'}, ... + 'Signatures', {'NPS'}); +``` + +One panel per subject; one curve per condition × model × signature. The biggest plotter in the toolbox; expects a study struct from Section 7. + +### Group-level animated montages (the big one) + +The standout deliverable for whole-brain visualization. Averages whole-brain maps across runs within subjects, then across subjects, per condition, and writes an MP4 that animates the group t-map across HRF lags: + +```matlab +avg = hrf_make_average_montage_animations(input_table, ... + 'Model', 'sfir', ... + 'Object', 'beta', ... + 'Conditions', {'pain', 'neutral'}, ... + 'OutputDir', '/dartfs/.../animations', ... + 'GroupStatistic', 't', ... + 'GroupCorrection', 'fdr', ... + 'GroupAlpha', 0.05, ... + 'FrameStep', 1, ... + 'FPS', 8); +``` + +This is I/O heavy — it reads the per-subject whole-brain NIfTIs from disk. The pipeline caches per-prefix loads so each NIfTI is read at most once across all conditions; without that cache, a 4-condition run reads each subject's maps 4 times. The `'Verbose'` flag (on by default) prints per-subject timing and a `[cache hit]` indicator from condition 2 onward — useful sanity check. + +If the run is unexpectedly slow, the cache tag tells you whether the bottleneck is the network (`[load N/N]` per subject in conditions 2+) or the rendering itself (`[cache hit]` per subject but condition wall-clock is huge). + +### Other visualizations + +- `plot_hrf_by_condition` — quick per-condition curves from a single `results` struct. +- `plot_hrf_2x2_study_score_stats` — visualizes a 2×2 factorial result. +- `plot_hrf_time_unfolding_stats` — group-level timepoint-wise t-tests with FDR overlay. +- `hrf_animate_wholebrain_stats` — animate a single subject's whole-brain HRF maps over lag. + +--- + +## 10. Common patterns and FAQ + +### "I have a result.mat from an old run. How do I get to the new objects?" + +```matlab +[Hb, Ht] = make_fmri_stat_hrf('/path/to/old_results.mat', ... + 'ModelName', 'sfir', ... + 'Subject', 'sub-01', ... + 'TR', 0.8); +``` + +### "How do I find an event I lost?" + +The audit step is the canonical "what's there, what's missing" tool. Read-only mode is fast: + +```matlab +[audit, summary] = hrf_audit_slurm_outputs(output_dir); +summary +audit.failed_reason(~audit.complete) +``` + +### "My signature scoring is slow even with the cache." + +The cache eliminates redundant *loads*. If the signatures themselves are expensive (image-similarity computation, large signature sets), per-signature time still adds up. Run on a smaller signature subset to confirm: + +```matlab +'SignatureSets', {'NPS', 'SIIPS'} % instead of 'all' +``` + +### "I need to add a new HRF model." + +The legacy fitters live at `HRF_Est_Toolbox4/` (parent folder, not this pipeline). Add your `Fit_.m` there. Then add a dispatch case in `hrf_fit_all_models` so `'Models', {''}` routes to it. The pipeline entry points pick it up automatically. + +### "What's an `output_prefix` vs a `model_prefix`?" + +For a single-model fit, they're the same. For a multi-model fit (you passed `'Models', {'sfir', 'canonical'}` to the pipeline), each model writes to its own prefix: + +- `output_prefix` = task-level prefix, e.g. `/path/sub-01_hrf` +- `model_prefix` = `_`, e.g. `/path/sub-01_hrf_sfir` + +The audit table carries both columns so you can index by either. + +### "Where do the threshold methods live for `statistic_hrf`?" + +Inherited from `@statistic_image/`. `threshold`, `multi_threshold`, `orthviews`, `convert2mask`, `conjunction` all work unchanged. The Phase 3 OOP refactor was specifically built so you don't have to reimplement them. + +### "How do I cite this?" + +The legacy fitters (`Fit_sFIR`, `Fit_Logit2`, `HMHRFest`, `ResidScan`, `PowerLoss`) are Lindquist-lab and should cite their original papers. The pipeline glue and OOP refactor are part of Michael Sun's HRF Toolbox work (Sun et al., in preparation). + +--- + +For the architectural breakdown of how all these pieces fit together, see [Architecture.md](Architecture.md). For the four-phase refactor plan, see [HRF_Pipeline_Phased_Plan.md](HRF_Pipeline_Phased_Plan.md). diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/examples/hrf_causality_synth_demo.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/examples/hrf_causality_synth_demo.m new file mode 100644 index 00000000..bd9dd93a --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/examples/hrf_causality_synth_demo.m @@ -0,0 +1,67 @@ +%% hrf_causality_synth_demo - why HRF-informed deconvolution matters for causality +% +% Ground-truth demonstration that regional HRF differences can REVERSE the +% direction inferred by Granger causality on raw BOLD (David et al. 2008, +% PLoS Biol), and that deconvolving each signal with its OWN hemodynamic +% kernel -- the per-region/signature sFIR HRF your pipeline estimates -- +% recovers the true direction, where a single canonical kernel does not. +% +% Setup: neural A -> B (A leads by ~0.9 s), C independent. The DOWNSTREAM +% region B is given a FASTER HRF than the upstream A, so at the BOLD level B +% rises first and naive Granger flips the arrow. +% +% Requires: SPM (spm_hrf) on path; hrf_deconv_timeseries, hrf_granger_causality. +% +% See also: hrf_deconv_timeseries, hrf_granger_causality. + +rng(7); +TR = 0.46; T = 300; nrun = 8; d = 2; % d = neural lead of A over B (samples) + +% Region HRFs: A SLOW (peak ~8 s), B FAST (peak ~4 s), C canonical. +hA = spm_hrf(TR, [8 16 1 1 6 0 32]); +hB = spm_hrf(TR, [4 16 1 1 6 0 32]); +hC = spm_hrf(TR); + +boldRuns = cell(1, nrun); +for r = 1:nrun + a = zeros(T,1); b = zeros(T,1); c = zeros(T,1); + ea = randn(T,1); eb = randn(T,1); ec = randn(T,1); + for t = 2:T + a(t) = 0.40*a(t-1) + ea(t); + c(t) = 0.40*c(t-1) + ec(t); + if t > d, b(t) = 0.40*b(t-1) + 0.60*a(t-d) + eb(t); + else, b(t) = 0.40*b(t-1) + eb(t); end + end + ya = conv(a, hA); yb = conv(b, hB); yc = conv(c, hC); + Y = [ya(1:T) yb(1:T) yc(1:T)]; + Y = Y + 0.5*std(Y(:))*randn(size(Y)); % measurement noise + boldRuns{r} = Y; +end +nodes = {'A','B','C'}; + +% Per-region kernel matrix (what sFIR gives you) and a single canonical kernel. +Kmax = max([numel(hA) numel(hB) numel(hC)]); +Kern = zeros(Kmax,3); Kern(1:numel(hA),1)=hA; Kern(1:numel(hB),2)=hB; Kern(1:numel(hC),3)=hC; +canon = spm_hrf(TR); KernCanon = repmat([canon; zeros(Kmax-numel(canon),1)], 1, 3); + +decReg = cell(1,nrun); decCanon = cell(1,nrun); +for r = 1:nrun + decReg{r} = hrf_deconv_timeseries(boldRuns{r}, Kern, 'Method','ridge'); + decCanon{r} = hrf_deconv_timeseries(boldRuns{r}, KernCanon, 'Method','ridge'); +end + +Gnaive = hrf_granger_causality(boldRuns, 'Nodes', nodes, 'doverbose', false); +Gcanon = hrf_granger_causality(decCanon, 'Nodes', nodes, 'doverbose', false); +Greg = hrf_granger_causality(decReg, 'Nodes', nodes, 'doverbose', false); + +rows = {'1. naive GC on raw BOLD', Gnaive; ... + '2. deconv w/ ONE canonical HRF', Gcanon; ... + '3. deconv w/ PER-REGION sFIR HRF', Greg}; +fprintf('\nGROUND TRUTH: A -> B (neural lead %.2f s); C independent; B has the FASTER HRF.\n', d*TR); +fprintf('%-34s net(A->B) p direction\n', 'approach'); +for i = 1:3 + nab = rows{i,2}.net(1,2); pv = rows{i,2}.pval(1,2); + if nab > 0, dir = 'A->B CORRECT'; else, dir = 'B->A WRONG'; end + fprintf('%-34s %+8.3f %7.1e %s\n', rows{i,1}, nab, pv, dir); +end +fprintf('Only #3 (region-specific kernels) is both correct and strong.\n'); diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/examples/hrf_oo_demo.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/examples/hrf_oo_demo.m new file mode 100644 index 00000000..c3d5b9db --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/examples/hrf_oo_demo.m @@ -0,0 +1,256 @@ +%% HRF Toolbox -- an object-oriented tour +% How the HRF estimation pipeline plugs into CanlabCore's object model, using +% the same operations as a real study (the WASABI DistractMap "HRF Estimation +% Animations and Atlas Regions" analysis): fit HRF models, carry the whole-brain +% condition x lag estimates as first-class objects, animate them over the +% peristimulus window, summarize them by atlas region, and take them to the +% group -- all off the standard fmri_data / statistic_image surface. +% +% image_vector (abstract superclass) +% | +% +-- fmri_data --[ subclass ]--> fmri_hrf (HRF beta / amplitude maps) +% +-- statistic_image --[ subclass ]--> statistic_hrf (HRF t / p / SE maps) +% +% timeseries --[ hrf_fit_all_models ]-------> fitted HRF curves (+ model SE) +% wholebrain --[ make_fmri_stat_hrf ]-------> paired (fmri_hrf, statistic_hrf) +% fmri_hrf --[ hrf_make_montage_animation ]--> condition x lag brain movie +% fmri_hrf --[ apply_parcellation ]--------> per-region HRF curves +% subject stack --[ ttest ]-----------------> group statistic_hrf (t maps) +% study --[ hrf_time_unfolding_stats ]--> corrected per-lag group inference +% +% Both classes ARE fmri_data / statistic_image subclasses, so every inherited +% method (montage, threshold, region, apply_parcellation, extract_roi_averages, +% ...) works on HRF estimates unchanged. Sections 1-7 run in-memory on a +% real brain geometry; Section 8 is the on-disk study API with the actual +% DistractMap calls. +% +% Requires: CanlabCore + HRF_Est_Toolbox4 + SPM12 (+ Neuroimaging_Pattern_Masks). +% Run top-to-bottom; brain movies are gated behind SHOW (default off). + +SHOW = false; % set true to render the brain montages / animations (slow) +TR = 1; + + +%% 1. Fit HRF models to a timeseries: hrf_fit_all_models +% The functional entry point: given a 1D timeseries and per-condition stick +% functions, fit a family of HRF models (fir, sfir, canonical, spline, ...) with +% one consistent output API -- each carrying the fitted curve and, for linear +% models, model-based standard errors. +win = 30; T = 300; onsets = 10:30:T-40; +stick = zeros(T, 1); stick(onsets) = 1; +hrf_true = spm_hrf(TR); +bold = conv(stick, hrf_true); bold = bold(1:T); +rng(0); tc = bold + 0.4 * std(bold) * randn(T, 1); + +fits = hrf_fit_all_models(tc, TR, {stick}, win, {'fir', 'sfir', 'canonical'}); +fprintf('fitted models: %s ; sfir peak at lag %d, model SE available: %d\n', ... + strjoin(fieldnames(fits), ', '), ... + find(fits.sfir.hrf == max(fits.sfir.hrf), 1), isfield(fits.sfir, 'se')); + +figure('Color', 'w'); hold on; +plot((0:numel(hrf_true)-1) * TR, hrf_true / max(hrf_true), 'k--', 'LineWidth', 1.5); +plot((0:numel(fits.sfir.hrf)-1) * TR, fits.sfir.hrf / max(fits.sfir.hrf), 'LineWidth', 1.5); +plot((0:numel(fits.fir.hrf)-1) * TR, fits.fir.hrf / max(fits.fir.hrf), 'LineWidth', 1); +legend({'true (canonical)', 'sfir fit', 'fir fit'}); xlabel('Seconds'); ylabel('Normalized HRF'); +title('hrf\_fit\_all\_models -- recovered HRF shapes'); box off; + + +%% 2. fmri_hrf: whole-brain condition x lag betas as an fmri_data subclass +% A whole-brain fit produces one beta per (condition x lag) volume. fmri_hrf +% wraps those maps with the metadata to interpret them (condition, lag_index, +% lag_seconds) while remaining a full fmri_data underneath. We build one here in +% a real brain space (borrowing a sample dataset's geometry), planting a +% canonical HRF time-course into the 'pain' condition so the maps are realistic. +base = load_image_set('emotionreg'); nvox = size(base.dat, 1); +conds = {'pain', 'neutral'}; nLag = 20; nVol = numel(conds) * nLag; +hshape = spm_hrf(TR); hshape = hshape(1:nLag); hshape = hshape / max(hshape); +rng(1); B = 0.2 * randn(nvox, nVol); +cond_col = {}; lag_idx = []; lag_sec = []; img_lab = {}; col = 0; +for c = 1:numel(conds) + for L = 1:nLag + col = col + 1; + if strcmp(conds{c}, 'pain'), B(:, col) = B(:, col) + hshape(L); end + cond_col{end+1} = conds{c}; lag_idx(end+1) = L; %#ok + lag_sec(end+1) = (L-1) * TR; img_lab{end+1} = sprintf('%s_lag%02d', conds{c}, L); %#ok + end +end +mt = table(cond_col(:), lag_idx(:), lag_sec(:), img_lab(:), ... + 'VariableNames', {'condition', 'lag_index', 'lag_seconds', 'image_label'}); +fd = base; fd.dat = B; fd.removed_images = 0; fd.image_names = ''; fd.fullpath = ''; + +Hb = fmri_hrf(fd, 'MetadataTable', mt, 'Subject', 'sub-01', 'RunLabel', 'run-01', ... + 'ModelName', 'sfir', 'TR', TR, 'Conditions', conds); +disp(Hb) +fprintf('isa(Hb, ''fmri_data'') = %d\n', isa(Hb, 'fmri_data')); + + +%% 3. statistic_hrf: the paired inferential object (statistic_image subclass) +% make_fmri_stat_hrf builds the beta side (fmri_hrf) and the t side +% (statistic_hrf) together, so their HRF metadata stays aligned. The t side is a +% statistic_image subclass -> threshold / region / montage apply. +bimg = statistic_image; bimg.dat = B; bimg.volInfo = base.volInfo; +bimg.removed_voxels = base.removed_voxels; bimg.removed_images = 0; +se = 0.3 + abs(randn(nvox, nVol)); +timg = statistic_image; timg.dat = B ./ se; timg.volInfo = base.volInfo; +timg.removed_voxels = base.removed_voxels; timg.removed_images = 0; timg.type = 'T'; + +[Hb2, Ht] = make_fmri_stat_hrf(struct('b', bimg, 't', timg), ... + 'Subject', 'sub-01', 'ModelName', 'sfir', 'TR', TR, 'MetadataTable', mt); %#ok +disp(Ht) + +% Slice the pain peak-lag t-map and push it through the standard stat chain. +% (The pipeline stores .p/.dfe; we set them here for this synthetic object.) +peakLag = find(hshape == max(hshape), 1); +idx = find(strcmp(mt.condition, 'pain') & mt.lag_index == peakLag); +painT = get_wh_image(Ht, idx); +painT.dfe = 100; painT.p = 2 * (1 - tcdf(abs(painT.dat), painT.dfe)); +painT = threshold(painT, 0.01, 'unc'); +fprintf('pain peak-lag t-map: threshold -> %d regions\n', numel(region(painT))); +if SHOW, montage(painT); end %#ok (SHOW is a demo toggle) + + +%% 4. HRF Estimation ANIMATION -- straight off the object +% hrf_make_montage_animation accepts any image_vector, so a fmri_hrf slice +% animates directly. Slice the 'pain' condition (its 20 lag volumes) and sweep +% the montage across the peristimulus window -- the DistractMap "HRF animation", +% now driven by the object instead of a NIfTI path. +painCond = get_wh_image(Hb, find(strcmp(mt.condition, 'pain'))); % 20-volume fmri_hrf +fprintf('pain condition slice: %d lag volumes to animate\n', size(painCond.dat, 2)); +if SHOW %#ok + hrf_make_montage_animation(painCond, 'pain_hrf.mp4', ... + 'FrameStep', 1, 'FPS', 8, 'TitlePrefix', 'pain lag', 'MontageType', 'compact2'); + % thresholded t-map animation is the same call on the statistic_hrf: + % hrf_make_montage_animation(get_wh_image(Ht, find(strcmp(mt.condition,'pain'))), ... + % 'pain_t.mp4', 'Threshold', 2.0, 'FPS', 12); +end + + +%% 5. Atlas Regions -- per-region HRF curves via inherited apply_parcellation +% Because fmri_hrf IS an fmri_data, apply_parcellation reduces the whole-brain +% condition x lag maps to region means: [volumes x regions]. Slice the 'pain' +% rows and you have one HRF curve per atlas region -- the object-level version +% of the score-CSV atlas curves that plot_hrf_atlas_curves renders, and a basis +% for the same RankBy summaries (peak, AUC, ...). +at = select_atlas_subset(load_atlas('canlab2024'), 1:40); +pm = apply_parcellation(Hb, at); % [nVol x nRegion] +painRows = pm(strcmp(mt.condition, 'pain'), :); % [nLag x nRegion], one curve/region +region_peak = max(abs(painRows), [], 1); % rank regions by |peak| (as RankBy='peak_abs') +[~, ord] = sort(region_peak, 'descend'); +fprintf('mean region pain curve corr with true HRF = %.2f ; top region: %s\n', ... + corr(mean(painRows, 2), hshape(:)), at.labels{ord(1)}); + +figure('Color', 'w'); tt = (0:nLag-1) * TR; +plot(tt, painRows(:, ord(1:min(6, end))), 'LineWidth', 1.3); +xlabel('Peristimulus time (s)'); ylabel('HRF beta'); box off; +legend(at.labels(ord(1:min(6, end))), 'Interpreter', 'none', 'Location', 'best'); +title('Per-region pain HRF curves (top 6 by |peak|)'); + + +%% 6. To the GROUP: a group statistic_hrf of one-sample t maps +% Stack subjects' condition x lag betas and one-sample-t each volume across +% subjects -> a group statistic_hrf (t/p per condition x lag). This is the +% object core of hrf_make_average_montage_animations, whose .group_t is exactly +% this kind of statistic_image. Vectorized here across all 40 volumes at once. +Nsub = 8; rng(5); +sigvox = false(nvox, 1); sigvox(1:6000) = true; % a focal set of "responding" voxels +stack = zeros(nvox, nVol, Nsub); +for s = 1:Nsub + signal = zeros(nvox, nVol); signal(sigvox, :) = B(sigvox, :); + stack(:, :, s) = signal + 0.3 * randn(nvox, nVol); +end +mu = mean(stack, 3); sd = std(stack, 0, 3); +tg = mu ./ (sd ./ sqrt(Nsub)); +gsi = statistic_image; gsi.dat = tg; gsi.volInfo = base.volInfo; +gsi.removed_voxels = base.removed_voxels; gsi.removed_images = 0; gsi.type = 'T'; +gsi.dfe = Nsub - 1; gsi.p = 2 * (1 - tcdf(abs(tg), Nsub - 1)); +Hg = statistic_hrf(gsi, 'MetadataTable', mt, 'Subject', 'group', ... + 'ModelName', 'sfir', 'TR', TR, 'Conditions', conds); +disp(Hg) + +painGT = get_wh_image(Hg, idx); % group t at pain peak lag +painGT = threshold(painGT, 0.001, 'unc'); +fprintf('group pain peak-lag t-map: %d suprathreshold voxels; the whole Hg animates like section 4\n', ... + sum(painGT.sig(:, 1))); +if SHOW, montage(painGT); end %#ok + + +%% 7. Corrected per-lag group inference over the HRF timecourse +% hrf_time_unfolding_stats builds a per-subject [subject x lag] contrast and +% tests each lag. Small-n per-lag inference needs multiple-comparison control: +% 'Correction' routes the lags through the shared hrf_group_stats engine +% (sign-flip / label max-|t| FWER, temporal cluster-mass, or FDR). +nsubj = 9; nt = win; rng(7); +study = struct(); study.subject_ids = arrayfun(@(i) sprintf('sub-%02d', i), 1:nsubj, 'uni', 0); +study.results = cell(1, nsubj); +for s = 1:nsubj + h = 0.02 * randn(nt, numel(conds)); h(8:14, 1) = h(8:14, 1) + 0.7; + r0 = struct('conditions', {conds}); r0.fits = struct('sfir', struct('hrf', h)); + study.results{s} = r0; +end +stats = hrf_time_unfolding_stats(study, 'Model', 'sfir', ... + 'ConditionA', 'pain', 'ConditionB', 'neutral', ... + 'Correction', 'cluster', 'MissingPolicy', 'silent'); +fprintf('per-lag significance: %d uncorrected vs %d cluster-corrected (lags %s)\n', ... + sum(stats.significant), sum(stats.significant_corrected), ... + num2str(find(stats.significant_corrected'))); + + +%% 8. The full study API on disk (the actual DistractMap workflow) +% Sections 1-7 run in-memory; a real study fits every subject/run to whole-brain +% NIfTIs, scores them by atlas/signature/imageset, and indexes them in an +% input_table. Every feature below consumes that table or its output dirs. These +% are the exact calls from the DistractMap "Animations and Atlas Regions" +% analysis; run them against your own hrf_outputs directories. +% +% at = load_atlas('canlab2024'); +% src = struct('label', {'distractmap','distractmap'}, ... +% 'table', {input_table_lf, input_table_obs}); % pool 2 body sites +% +% % --- Atlas-region HRF curves, ranked (RankBy: peak_abs|auc_abs|hrf_match| +% % peak_t|n_sig|snr|shape_r2|sd), with condition contrasts and non-atlas +% % sources (signatures, bucknerlab/marg/hansen22 imagesets): +% plot_hrf_atlas_curves(src, 'AtlasObj', at, 'Model', 'sfir', 'Object', 'beta', ... +% 'Conditions', {'nback-stimblock_ttl_1','rest_stim_ttl_1'}, 'TopN', 20, 'RankBy', 'hrf_match'); +% plot_hrf_curves(src, 'Source', 'signature', 'Set', 'all', 'RankBy', 'hrf_match', 'TopN', 44); +% +% % --- Per-curve shape metrics + group one-sample t (FDR across rows): +% T = hrf_curve_summaries(input_table_lf, 'Sources', {'atlas_CANLab2024'}, ... +% 'Objects', {'beta'}, 'Conditions', {'nback-stimblock_ttl_1','rest_stim_ttl_1'}); +% G = hrf_curve_summary_groupstats(T, 'GroupBy', {'condition','model','source_name'}, ... +% 'Across', 'subject', 'Metrics', {'peak_value','peak_lag_seconds','auc'}, 'Correction', 'fdr'); +% G(G.sig, :) +% +% % --- Group whole-brain t-map animations (returns statistic_image .group_t): +% avg = hrf_make_average_montage_animations(input_table_lf, 'Model', 'sfir', 'Object', 'beta', ... +% 'Conditions', {'nback-stimblock_ttl_1','rest_stim_ttl_1'}, ... +% 'GroupStatistic', 't', 'GroupCorrection', 'unc', 'GroupAlpha', 0.001); +% avg.group_t % statistic_image objects -> threshold/montage/region +% +% % --- Term / network / region wordcloud animations over the HRF, FWE-corrected: +% hrf_animate_wordcloud({lf,obs}, 'Set','neurosynth_topics_ri', 'Model','sfir', ... +% 'Condition','nback-stimblock_ttl_1', 'Correction','permutation', 'Threshold',0.05); +% +% % --- Directed connectivity (HRF-deconvolved Granger) + trial-level mediation: +% R = hrf_causality({lf,obs}, 'Unit','signature'); R.remove.net_group +% Rr = hrf_causality({lf,obs}, 'Condition','rest_stim'); +% Rn = hrf_causality({lf,obs}, 'Condition','nback-stimblock'); +% hrf_plot_causality(hrf_causality_contrast(Rr, Rn, 'Label1','rest', 'Label2','nback')); +% Rmed = hrf_causality_mediation({lf,obs}, 'X','temp', 'M','NPS', 'Y','rating', 'TrialType','*stimblock*'); + + +%% 9. The takeaway +% One object model, one method vocabulary, extended to HRFs: +% +% timeseries --hrf_fit_all_models--> fitted curves (+ model SE) +% wholebrain --make_fmri_stat_hrf--> fmri_hrf (IS-A fmri_data) +% statistic_hrf (IS-A statistic_image) +% fmri_hrf --hrf_make_montage_animation--> condition x lag brain movie +% fmri_hrf --apply_parcellation--> per-region HRF curves (RankBy summaries) +% subject stack --ttest--> group statistic_hrf --threshold/montage/region +% study --hrf_time_unfolding_stats(Correction)--> corrected per-lag inference +% +% fmri_hrf and statistic_hrf inherit the entire fmri_data / statistic_image +% surface, add condition x lag HRF metadata and an HRF-aware display, and every +% HRF analysis -- animation, atlas-region curves, group maps, wordclouds, +% causality -- lands back in a standard CANlab container. +disp('HRF object-oriented tour complete.'); diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/examples/hrf_oo_demo.mlx b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/examples/hrf_oo_demo.mlx new file mode 100644 index 00000000..6877e952 Binary files /dev/null and b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/examples/hrf_oo_demo.mlx differ diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_2x2_study_score_stats.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_2x2_study_score_stats.m new file mode 100644 index 00000000..8cb9b218 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_2x2_study_score_stats.m @@ -0,0 +1,311 @@ +function stats = hrf_2x2_study_score_stats(a1b1, a1b2, a2b1, a2b2, varargin) +%HRF_2X2_STUDY_SCORE_STATS Repeated-measures 2x2 tests for study scores. +% +% stats = hrf_2x2_study_score_stats(a1b1, a1b2, a2b1, a2b2, ...) +% +% The four study inputs should be study structs returned by +% hrf_input_table_to_study or hrf_second_level_inputs_to_study. Each cell is +% reduced to one curve per subject using hrf_time_unfolding_stats, subjects +% are aligned across all four cells, and paired tests are run for simple +% effects, main effects, and the 2x2 interaction. +% +% Cell layout: +% a1b1 a1b2 +% a2b1 a2b2 +% +% Example: +% stats = hrf_2x2_study_score_stats(study_scores_lf_exp, study_scores_lf_acc, ... +% study_scores_obs_exp, study_scores_obs_acc, ... +% 'FactorA', {'lf','obs'}, 'FactorB', {'exp','acc'}, ... +% 'Model', 'sfir', 'Signature', 'sig_all_NPS', ... +% 'Condition', '*heat_start_ttl_1'); + +p = inputParser; +p.addRequired('a1b1', @isstruct); +p.addRequired('a1b2', @isstruct); +p.addRequired('a2b1', @isstruct); +p.addRequired('a2b2', @isstruct); +p.addParameter('FactorA', {'A1', 'A2'}, @(x) iscell(x) || isstring(x)); +p.addParameter('FactorB', {'B1', 'B2'}, @(x) iscell(x) || isstring(x)); +p.addParameter('FactorAName', 'Factor A', @(x) ischar(x) || isstring(x)); +p.addParameter('FactorBName', 'Factor B', @(x) ischar(x) || isstring(x)); +p.addParameter('CellLabels', {}, @(x) iscell(x) || isstring(x)); +p.addParameter('Model', 'sfir', @(x) ischar(x) || isstring(x)); +p.addParameter('SourceModel', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Signature', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Condition', '', @(x) ischar(x) || isstring(x)); +p.addParameter('ConditionA', '', @(x) ischar(x) || isstring(x)); +p.addParameter('ConditionB', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Unit', 'subject', @(x) ischar(x) || isstring(x)); +p.addParameter('MissingPolicy', 'warn', @(x) ischar(x) || isstring(x)); +p.addParameter('Alpha', 0.05, @(x) isscalar(x) && x > 0 && x < 1); +% Across-lag correction for the 2x2 contrast timecourses (shared engine): +% 'none' (default) | 'fdr' | 'permutation' | 'cluster'. When not 'none', each +% contrast gains .p_corrected / .significant_corrected. +p.addParameter('Correction', 'none', @(x) ischar(x) || isstring(x)); +p.addParameter('Nperm', 5000, @(x) isscalar(x) && x >= 100); +p.parse(a1b1, a1b2, a2b1, a2b2, varargin{:}); +opts = p.Results; + +factorA = local_two_names(opts.FactorA, 'FactorA'); +factorB = local_two_names(opts.FactorB, 'FactorB'); +conditionA = char(opts.ConditionA); +if isempty(conditionA) + conditionA = char(opts.Condition); +end +unit = strtrim(char(opts.Unit)); +if ~strcmpi(unit, 'subject') + error('hrf_2x2_study_score_stats currently supports Unit=''subject'' so paired tests align one curve per subject.'); +end + +cell_labels = local_cell_labels(opts.CellLabels, factorA, factorB); +source_stats = cell(2, 2); +source_stats{1, 1} = local_cell_time_stats(a1b1, opts, conditionA); +source_stats{1, 2} = local_cell_time_stats(a1b2, opts, conditionA); +source_stats{2, 1} = local_cell_time_stats(a2b1, opts, conditionA); +source_stats{2, 2} = local_cell_time_stats(a2b2, opts, conditionA); + +subject_ids = local_common_subjects(source_stats); +time = local_common_time(source_stats); + +Y11 = local_align_data(source_stats{1, 1}, subject_ids); +Y12 = local_align_data(source_stats{1, 2}, subject_ids); +Y21 = local_align_data(source_stats{2, 1}, subject_ids); +Y22 = local_align_data(source_stats{2, 2}, subject_ids); + +stats = struct(); +stats.time = time(:); +stats.alpha = opts.Alpha; +stats.model = char(opts.Model); +stats.source_model = char(opts.SourceModel); +stats.signature = char(opts.Signature); +stats.conditionA = conditionA; +stats.conditionB = char(opts.ConditionB); +stats.unit = char(opts.Unit); +stats.factorA = factorA(:); +stats.factorB = factorB(:); +stats.factorA_name = char(opts.FactorAName); +stats.factorB_name = char(opts.FactorBName); +stats.subject_ids = subject_ids(:); +stats.n_subjects = numel(subject_ids); + +stats.cells = struct(); +stats.cells.A1B1 = local_cell_summary(cell_labels{1}, factorA{1}, factorB{1}, Y11, source_stats{1, 1}); +stats.cells.A1B2 = local_cell_summary(cell_labels{2}, factorA{1}, factorB{2}, Y12, source_stats{1, 2}); +stats.cells.A2B1 = local_cell_summary(cell_labels{3}, factorA{2}, factorB{1}, Y21, source_stats{2, 1}); +stats.cells.A2B2 = local_cell_summary(cell_labels{4}, factorA{2}, factorB{2}, Y22, source_stats{2, 2}); + +contrasts = struct(); +contrasts.A1_B1_minus_B2 = local_contrast_stats( ... + sprintf('%s: %s - %s', factorA{1}, factorB{1}, factorB{2}), ... + 'A1_B1_minus_B2', 'A1B1 - A1B2', Y11 - Y12, opts.Alpha); +contrasts.A2_B1_minus_B2 = local_contrast_stats( ... + sprintf('%s: %s - %s', factorA{2}, factorB{1}, factorB{2}), ... + 'A2_B1_minus_B2', 'A2B1 - A2B2', Y21 - Y22, opts.Alpha); +contrasts.B1_A1_minus_A2 = local_contrast_stats( ... + sprintf('%s: %s - %s', factorB{1}, factorA{1}, factorA{2}), ... + 'B1_A1_minus_A2', 'A1B1 - A2B1', Y11 - Y21, opts.Alpha); +contrasts.B2_A1_minus_A2 = local_contrast_stats( ... + sprintf('%s: %s - %s', factorB{2}, factorA{1}, factorA{2}), ... + 'B2_A1_minus_A2', 'A1B2 - A2B2', Y12 - Y22, opts.Alpha); +contrasts.main_A = local_contrast_stats( ... + sprintf('Main %s: %s - %s', stats.factorA_name, factorA{1}, factorA{2}), ... + 'main_A', 'mean(A1B1,A1B2) - mean(A2B1,A2B2)', ... + (Y11 + Y12) ./ 2 - (Y21 + Y22) ./ 2, opts.Alpha); +contrasts.main_B = local_contrast_stats( ... + sprintf('Main %s: %s - %s', stats.factorB_name, factorB{1}, factorB{2}), ... + 'main_B', 'mean(A1B1,A2B1) - mean(A1B2,A2B2)', ... + (Y11 + Y21) ./ 2 - (Y12 + Y22) ./ 2, opts.Alpha); +contrasts.interaction_AxB = local_contrast_stats( ... + sprintf('Interaction: (%s - %s) x (%s - %s)', factorA{1}, factorA{2}, factorB{1}, factorB{2}), ... + 'interaction_AxB', '(A1B1 - A1B2) - (A2B1 - A2B2)', ... + (Y11 - Y12) - (Y21 - Y22), opts.Alpha); + +% Across-lag correction of every contrast timecourse via the shared engine +% (each contrast is a one-sample test of its paired difference vs 0). Additive: +% the uncorrected per-timepoint .significant is preserved. +stats.correction = lower(char(opts.Correction)); +if ~strcmpi(stats.correction, 'none') + fn = fieldnames(contrasts); + for i = 1:numel(fn) + Cc = hrf_time_correction(contrasts.(fn{i}).data, 'Correction', stats.correction, ... + 'Nperm', opts.Nperm, 'Alpha', opts.Alpha); + contrasts.(fn{i}).p_corrected = Cc.p_corr(:); + contrasts.(fn{i}).significant_corrected = Cc.sig(:); + contrasts.(fn{i}).correction = stats.correction; + end +end + +stats.contrasts = contrasts; +stats.contrast_names = fieldnames(contrasts); +stats.contrast_table = local_contrast_table(contrasts); +stats.source_stats = source_stats; +end + +function cell_stats = local_cell_time_stats(study, opts, conditionA) +cell_stats = hrf_time_unfolding_stats(study, ... + 'Model', opts.Model, ... + 'SourceModel', opts.SourceModel, ... + 'Signature', opts.Signature, ... + 'ConditionA', conditionA, ... + 'ConditionB', opts.ConditionB, ... + 'Unit', opts.Unit, ... + 'MissingPolicy', opts.MissingPolicy, ... + 'Alpha', opts.Alpha); +end + +function names = local_two_names(input_names, param_name) +names = cellstr(string(input_names)); +names = names(:)'; +if numel(names) ~= 2 || any(cellfun(@isempty, names)) + error('%s must contain exactly two non-empty names.', param_name); +end +end + +function labels = local_cell_labels(input_labels, factorA, factorB) +if isempty(input_labels) + labels = { ... + sprintf('%s_%s', factorA{1}, factorB{1}), ... + sprintf('%s_%s', factorA{1}, factorB{2}), ... + sprintf('%s_%s', factorA{2}, factorB{1}), ... + sprintf('%s_%s', factorA{2}, factorB{2})}; + return +end +labels = cellstr(string(input_labels)); +labels = labels(:)'; +if numel(labels) ~= 4 || any(cellfun(@isempty, labels)) + error('CellLabels must contain exactly four non-empty labels.'); +end +end + +function subject_ids = local_common_subjects(source_stats) +subject_ids = cellstr(string(source_stats{1}.subject_ids(:))); +for i = 2:numel(source_stats) + this_subjects = cellstr(string(source_stats{i}.subject_ids(:))); + keep = ismember(subject_ids, this_subjects); + subject_ids = subject_ids(keep); +end +if isempty(subject_ids) + error('No common subjects were found across the four study cells.'); +end +end + +function time = local_common_time(source_stats) +time = source_stats{1}.time(:); +for i = 2:numel(source_stats) + this_time = source_stats{i}.time(:); + if numel(this_time) ~= numel(time) || any(abs(this_time - time) > max(eps(time))) + error('Time axes do not match across the four study cells.'); + end +end +end + +function Y = local_align_data(cell_stats, subject_ids) +cell_subjects = cellstr(string(cell_stats.subject_ids(:))); +[tf, loc] = ismember(subject_ids, cell_subjects); +if any(~tf) + error('Subject alignment failed unexpectedly.'); +end +Y = cell_stats.data(loc, :); +end + +function summary = local_cell_summary(label, factorA, factorB, data, source_stats) +summary = struct(); +summary.label = label; +summary.factorA = factorA; +summary.factorB = factorB; +summary.data = data; +summary.mean = local_mean_omitnan(data, 1)'; +summary.sem = local_sem_omitnan(data, 1)'; +summary.n_subjects = sum(~isnan(data), 1)'; +summary.source_stats = source_stats; +end + +function c = local_contrast_stats(name, fieldname, formula, data, alpha) +[T, P] = local_ttest_each_timepoint(data); +c = struct(); +c.name = name; +c.fieldname = fieldname; +c.formula = formula; +c.data = data; +c.mean = local_mean_omitnan(data, 1)'; +c.sem = local_sem_omitnan(data, 1)'; +c.t_value = T(:); +c.p_value = P(:); +c.significant = P(:) < alpha; +c.n_subjects = sum(~isnan(data), 1)'; +c.alpha = alpha; +end + +function tbl = local_contrast_table(contrasts) +names = fieldnames(contrasts); +label = cell(numel(names), 1); +formula = cell(numel(names), 1); +n_significant = zeros(numel(names), 1); +n_significant_corrected = nan(numel(names), 1); +min_p = nan(numel(names), 1); +for i = 1:numel(names) + c = contrasts.(names{i}); + label{i} = c.name; + formula{i} = c.formula; + n_significant(i) = sum(c.significant & ~isnan(c.p_value)); + if isfield(c, 'significant_corrected') + n_significant_corrected(i) = sum(c.significant_corrected); + end + valid_p = c.p_value(~isnan(c.p_value)); + if isempty(valid_p) + min_p(i) = NaN; + else + min_p(i) = min(valid_p); + end +end +tbl = table(names(:), label, formula, n_significant, n_significant_corrected, min_p, ... + 'VariableNames', {'contrast', 'label', 'formula', 'n_significant_timepoints', ... + 'n_significant_corrected', 'min_p'}); +end + +function [T, P] = local_ttest_each_timepoint(D) +n_tp = size(D, 2); +P = nan(1, n_tp); +T = nan(1, n_tp); +for t = 1:n_tp + y = D(:, t); + y = y(~isnan(y)); + if numel(y) < 2 + continue + end + [~, pval, ~, st] = ttest(y); + P(t) = pval; + T(t) = st.tstat; +end +end + +function m = local_mean_omitnan(X, dim) +if nargin < 2 + dim = 1; +end +valid = ~isnan(X); +den = sum(valid, dim); +X(~valid) = 0; +m = sum(X, dim) ./ den; +m(den == 0) = NaN; +end + +function se = local_sem_omitnan(X, dim) +if nargin < 2 + dim = 1; +end +mu = local_mean_omitnan(X, dim); +if dim == 1 + centered = X - repmat(mu, size(X, 1), 1); +elseif dim == 2 + centered = X - repmat(mu, 1, size(X, 2)); +else + error('local_sem_omitnan supports dim 1 or 2.'); +end +centered(isnan(centered)) = 0; +n = sum(~isnan(X), dim); +sd = sqrt(sum(centered .^ 2, dim) ./ max(n - 1, 1)); +se = sd ./ sqrt(n); +se(n == 0) = NaN; +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_analyze_second_level_inputs.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_analyze_second_level_inputs.m new file mode 100644 index 00000000..2f3060cd --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_analyze_second_level_inputs.m @@ -0,0 +1,374 @@ +function analysis = hrf_analyze_second_level_inputs(second_level_inputs, varargin) +%HRF_ANALYZE_SECOND_LEVEL_INPUTS Analyze HRF map-score outputs across subjects. +% +% analysis = hrf_analyze_second_level_inputs(second_level_inputs, ...) +% +% second_level_inputs can be the table returned by +% hrf_collect_wholebrain_outputs or the corresponding CSV filename. This +% function reads *_beta_map_scores.csv or *_t_map_scores.csv files, builds a +% long subject-level table, optionally computes a condition contrast, and +% runs one-sample tests across subjects for each lag and signature/map score. + +p = inputParser; +p.addRequired('second_level_inputs', @(x) istable(x) || ischar(x) || isstring(x)); +p.addParameter('Object', 'beta', @(x) ischar(x) || isstring(x)); +p.addParameter('ConditionA', '', @(x) ischar(x) || isstring(x)); +p.addParameter('ConditionB', '', @(x) ischar(x) || isstring(x)); +p.addParameter('LagSeconds', [], @(x) isempty(x) || isnumeric(x)); +p.addParameter('ScoreColumns', {}, @(x) iscell(x) || isstring(x)); +p.addParameter('Alpha', 0.05, @(x) isscalar(x) && x > 0 && x < 1); +p.addParameter('MissingPolicy', 'warn', @(x) ischar(x) || isstring(x)); +p.addParameter('OutputSummaryCsv', '', @(x) ischar(x) || isstring(x)); +p.addParameter('OutputLongCsv', '', @(x) ischar(x) || isstring(x)); +p.parse(second_level_inputs, varargin{:}); +opts = p.Results; + +inputs = local_read_inputs(second_level_inputs); +score_file_var = local_score_file_var(char(opts.Object)); +if ~any(strcmp(score_file_var, inputs.Properties.VariableNames)) + error('second_level_inputs is missing column %s.', score_file_var); +end + +[long_table, skipped] = local_build_long_table(inputs, score_file_var, opts); +if isempty(long_table) + error('No usable score rows found. Skipped %d input row(s).', numel(skipped)); +end + +subject_table = local_subject_average(long_table); +summary = local_summarize(subject_table, opts.Alpha); + +analysis = struct(); +analysis.long_table = long_table; +analysis.subject_table = subject_table; +analysis.summary = summary; +analysis.interpretation = local_interpret(summary, opts.Alpha); +analysis.skipped = skipped; +analysis.object = char(opts.Object); +analysis.conditionA = char(opts.ConditionA); +analysis.conditionB = char(opts.ConditionB); +analysis.alpha = opts.Alpha; + +if ~isempty(opts.OutputSummaryCsv) + writetable(summary, char(opts.OutputSummaryCsv)); +end +if ~isempty(opts.OutputLongCsv) + writetable(subject_table, char(opts.OutputLongCsv)); +end +end + +function inputs = local_read_inputs(second_level_inputs) +if istable(second_level_inputs) + inputs = second_level_inputs; +else + inputs = readtable(char(second_level_inputs), 'TextType', 'string'); +end +end + +function varname = local_score_file_var(object_name) +switch lower(object_name) + case {'beta', 'b'} + varname = 'beta_scores_file'; + case {'t', 'tmap', 'tmaps'} + varname = 't_scores_file'; + otherwise + error('Unknown Object: %s. Use ''beta'' or ''t''.', object_name); +end +end + +function [T, skipped] = local_build_long_table(inputs, score_file_var, opts) +rows = {}; +skipped = struct('index', {}, 'subject', {}, 'reason', {}); +missing_policy = lower(char(opts.MissingPolicy)); + +for i = 1:height(inputs) + subject = local_table_value(inputs, i, 'subject'); + score_file = local_table_value(inputs, i, score_file_var); + if isempty(score_file) || exist(score_file, 'file') ~= 2 + skipped = local_skip(skipped, i, subject, sprintf('missing score file %s', score_file), missing_policy); + continue + end + + S = readtable(score_file, 'TextType', 'string'); + score_cols = local_score_columns(S, opts.ScoreColumns); + if isempty(score_cols) + skipped = local_skip(skipped, i, subject, 'no numeric score columns', missing_policy); + continue + end + S = local_filter_lags(S, opts.LagSeconds); + + if ~isempty(opts.ConditionA) && ~isempty(opts.ConditionB) + rows = [rows; local_contrast_rows(S, score_cols, subject, char(opts.ConditionA), char(opts.ConditionB))]; %#ok + else + rows = [rows; local_condition_rows(S, score_cols, subject, char(opts.ConditionA))]; %#ok + end +end + +var_names = {'subject', 'condition', 'lag_index', 'lag_seconds', 'score_name', 'value'}; +if isempty(rows) + T = cell2table(cell(0, numel(var_names)), 'VariableNames', var_names); +else + T = cell2table(rows, 'VariableNames', var_names); + T.value = local_numeric_column(T.value); + T.lag_index = local_numeric_column(T.lag_index); + T.lag_seconds = local_numeric_column(T.lag_seconds); +end +end + +function rows = local_condition_rows(S, score_cols, subject, condition) +rows = {}; +if ~isempty(condition) + if ~any(strcmp('condition', S.Properties.VariableNames)) + error('Score table is missing condition column.'); + end + spec = local_condition_spec(S, condition); + rows = local_averaged_condition_rows(S, score_cols, subject, spec); +else + for r = 1:height(S) + for c = 1:numel(score_cols) + rows(end + 1, :) = {subject, local_condition_name(S, r), local_lag_index(S, r), ... + local_lag_seconds(S, r), score_cols{c}, S.(score_cols{c})(r)}; %#ok + end + end +end +end + +function rows = local_contrast_rows(S, score_cols, subject, conditionA, conditionB) +if ~any(strcmp('condition', S.Properties.VariableNames)) + error('Score table is missing condition column.'); +end + +specA = local_condition_spec(S, conditionA); +specB = local_condition_spec(S, conditionB); +cond = cellstr(string(S.condition)); +A = S(ismember(cond, specA.matched_conditions), :); +B = S(ismember(cond, specB.matched_conditions), :); +rows = {}; +if isempty(A) || isempty(B) + return +end + +lags = intersect(local_to_numeric(A.lag_index), local_to_numeric(B.lag_index), 'stable'); +for li = 1:numel(lags) + a = A(local_to_numeric(A.lag_index) == lags(li), :); + b = B(local_to_numeric(B.lag_index) == lags(li), :); + if isempty(a) || isempty(b), continue; end + for c = 1:numel(score_cols) + rows(end + 1, :) = {subject, sprintf('%s_minus_%s', specA.display_label, specB.display_label), ... + local_lag_index(a, 1), local_lag_seconds(a, 1), score_cols{c}, ... + local_mean_omitnan(a.(score_cols{c})) - local_mean_omitnan(b.(score_cols{c}))}; %#ok + end +end +end + +function rows = local_averaged_condition_rows(S, score_cols, subject, spec) +rows = {}; +cond = cellstr(string(S.condition)); +S = S(ismember(cond, spec.matched_conditions), :); +if isempty(S), return; end +lags = unique(local_to_numeric(S.lag_index), 'stable'); +for li = 1:numel(lags) + one_lag = S(local_to_numeric(S.lag_index) == lags(li), :); + for c = 1:numel(score_cols) + rows(end + 1, :) = {subject, spec.display_label, local_lag_index(one_lag, 1), ... + local_lag_seconds(one_lag, 1), score_cols{c}, local_mean_omitnan(one_lag.(score_cols{c}))}; %#ok + end +end +end + +function spec = local_condition_spec(S, condition) +available = unique(cellstr(string(S.condition)), 'stable'); +specs = hrf_resolve_condition_patterns(available, condition, 'DefaultMode', 'first'); +spec = specs(1); +end + +function S = local_filter_lags(S, lag_seconds) +if isempty(lag_seconds) || ~any(strcmp('lag_seconds', S.Properties.VariableNames)) + return +end + +keep = false(height(S), 1); +all_lags = S.lag_seconds; +for i = 1:numel(lag_seconds) + [~, idx] = min(abs(all_lags - lag_seconds(i))); + keep = keep | all_lags == all_lags(idx); +end +S = S(keep, :); +end + +function cols = local_score_columns(S, requested) +if ~isempty(requested) + cols = cellstr(string(requested)); + missing = setdiff(cols, S.Properties.VariableNames); + if ~isempty(missing) + error('Requested score columns not found: %s', strjoin(missing, ', ')); + end + return +end + +metadata_cols = {'volume_index', 'condition', 'condition_index', 'lag_index', ... + 'lag_seconds', 'image_label', 'N', 'dfe', 'TR', 'mode'}; +cols = {}; +for i = 1:numel(S.Properties.VariableNames) + name = S.Properties.VariableNames{i}; + if ismember(name, metadata_cols) || local_is_uncertainty_column(name) + continue + end + if isnumeric(S.(name)) + cols{end + 1} = name; %#ok + end +end +end + +function tf = local_is_uncertainty_column(name) +tf = endsWith(char(name), '_se'); +end + +function subject_table = local_subject_average(T) +keys = local_group_keys(T); +rows = {}; +for i = 1:size(keys, 1) + wh = strcmp(T.subject, keys{i, 1}) & strcmp(T.condition, keys{i, 2}) & ... + T.lag_index == keys{i, 3} & strcmp(T.score_name, keys{i, 5}); + rows(end + 1, :) = {keys{i, 1}, keys{i, 2}, keys{i, 3}, keys{i, 4}, keys{i, 5}, local_mean_omitnan(T.value(wh))}; %#ok +end +subject_table = cell2table(rows, 'VariableNames', T.Properties.VariableNames); +subject_table.value = local_numeric_column(subject_table.value); +subject_table.lag_index = local_numeric_column(subject_table.lag_index); +subject_table.lag_seconds = local_numeric_column(subject_table.lag_seconds); +end + +function summary = local_summarize(T, alpha) +keys = local_summary_keys(T); +rows = {}; +for i = 1:size(keys, 1) + wh = strcmp(T.condition, keys{i, 1}) & T.lag_index == keys{i, 2} & strcmp(T.score_name, keys{i, 4}); + y = T.value(wh); + y = y(~isnan(y)); + n = numel(y); + if n > 1 + [~, pval, ~, st] = ttest(y); + tval = st.tstat; + sem = std(y) ./ sqrt(n); + else + pval = NaN; + tval = NaN; + sem = NaN; + end + rows(end + 1, :) = {keys{i, 1}, keys{i, 2}, keys{i, 3}, keys{i, 4}, ... + n, local_mean_omitnan(y), sem, tval, pval, pval < alpha}; %#ok +end + +summary = cell2table(rows, 'VariableNames', {'condition', 'lag_index', 'lag_seconds', ... + 'score_name', 'n', 'mean', 'sem', 't_value', 'p_value', 'significant'}); +summary.lag_index = local_numeric_column(summary.lag_index); +summary.lag_seconds = local_numeric_column(summary.lag_seconds); +summary.n = local_numeric_column(summary.n); +summary.mean = local_numeric_column(summary.mean); +summary.sem = local_numeric_column(summary.sem); +summary.t_value = local_numeric_column(summary.t_value); +summary.p_value = local_numeric_column(summary.p_value); +summary.significant = logical(local_numeric_column(summary.significant)); +end + +function keys = local_summary_keys(T) +key_table = table(cellstr(string(T.condition)), T.lag_index, T.lag_seconds, cellstr(string(T.score_name)), ... + 'VariableNames', {'condition', 'lag_index', 'lag_seconds', 'score_name'}); +[~, ia] = unique(key_table, 'rows', 'stable'); +K = key_table(ia, :); +keys = [K.condition, num2cell(K.lag_index), num2cell(K.lag_seconds), K.score_name]; +end + +function keys = local_group_keys(T) +if any(strcmp('subject', T.Properties.VariableNames)) + subject = cellstr(string(T.subject)); +else + subject = repmat({''}, height(T), 1); +end +condition = cellstr(string(T.condition)); +score_name = cellstr(string(T.score_name)); +key_table = table(subject(:), condition(:), T.lag_index(:), T.lag_seconds(:), score_name(:), ... + 'VariableNames', {'subject', 'condition', 'lag_index', 'lag_seconds', 'score_name'}); +[~, ia] = unique(key_table, 'rows', 'stable'); +K = key_table(ia, :); +keys = [K.subject, K.condition, num2cell(K.lag_index), num2cell(K.lag_seconds), K.score_name]; +end + +function m = local_mean_omitnan(y) +y = y(~isnan(y)); +if isempty(y) + m = NaN; +else + m = mean(y); +end +end + +function x = local_numeric_column(x) +if iscell(x) + x = cell2mat(x); +end +end + +function x = local_to_numeric(x) +if isnumeric(x) + x = double(x); +else + x = str2double(string(x)); +end +end + +function interpretation = local_interpret(summary, alpha) +S = summary(~isnan(summary.p_value) & summary.p_value < alpha, :); +if isempty(S) + interpretation = summary([], :); + return +end +[~, idx] = sortrows([S.p_value, -abs(S.t_value)], [1 2]); +S = S(idx, :); +interpretation = S(1:min(20, height(S)), :); +end + +function value = local_table_value(T, row, varname) +if ~any(strcmp(varname, T.Properties.VariableNames)) + value = ''; + return +end +value = T.(varname)(row); +if iscell(value), value = value{1}; end +value = char(string(value)); +end + +function name = local_condition_name(S, row) +if any(strcmp('condition', S.Properties.VariableNames)) + name = char(string(S.condition(row))); +else + name = ''; +end +end + +function idx = local_lag_index(S, row) +if any(strcmp('lag_index', S.Properties.VariableNames)) + idx = S.lag_index(row); +else + idx = row; +end +end + +function seconds = local_lag_seconds(S, row) +if any(strcmp('lag_seconds', S.Properties.VariableNames)) + seconds = S.lag_seconds(row); +else + seconds = NaN; +end +end + +function skipped = local_skip(skipped, idx, subject, reason, missing_policy) +if strcmp(missing_policy, 'error') + error('Input row %d (%s): %s', idx, subject, reason); +elseif ~strcmp(missing_policy, 'warn') && ~strcmp(missing_policy, 'silent') + error('Unknown MissingPolicy: %s. Use ''warn'', ''silent'', or ''error''.', missing_policy); +end +skipped(end + 1) = struct('index', idx, 'subject', subject, 'reason', reason); +if strcmp(missing_policy, 'warn') + warning('hrf_analyze_second_level_inputs:SkippingInput', 'Skipping input row %d (%s): %s', idx, subject, reason); +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_animate_montage.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_animate_montage.m new file mode 100644 index 00000000..125c7b94 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_animate_montage.m @@ -0,0 +1,241 @@ +function out = hrf_animate_montage(panels, varargin) +%HRF_ANIMATE_MONTAGE Synchronized grid of HRF animations (word-clouds + brains). +% +% Renders several per-lag animations side by side, advancing them TOGETHER so +% every tile shows the same peristimulus lag at once -- e.g. a Neurosynth-term +% word-cloud next to a signature word-cloud next to a whole-brain montage, all +% synchronized over the HRF. Each panel produces its own per-lag frames; the +% driver tiles the l-th frame of every panel into one composite movie. +% +% :Usage: +% :: +% panels = { +% struct('type','wordcloud', 'args', {{dirs, 'Set','neurosynth', 'Condition','heat'}}), ... +% struct('type','wordcloud', 'args', {{dirs, 'Unit','signature', 'Set','all', 'Condition','heat'}}), ... +% struct('type','movie', 'file', 'group_heat_average.mp4', 'title','brain') }; +% hrf_animate_montage(panels, 'OutputFile','synced.mp4', 'FrameRate',5); +% +% :Inputs: +% **panels:** cell array of panel specs (each a struct): +% .type = 'wordcloud' -> rendered by hrf_animate_wordcloud; pass its +% name-value inputs (incl. the source) in .args (a +% cell). Do NOT set its OutputFile. +% = 'movie'/'brain' -> read per-lag frames from an existing movie/gif +% in .file (e.g. a brain montage from +% hrf_make_montage_animation / hrf_pooled_wholebrain_animation). +% = 'frames' -> use .frames directly (cell of [H x W x 3] uint8). +% .title (optional) - label drawn above the tile. +% +% :Optional Inputs: +% **'OutputFile':** '.mp4'/'.avi'/'.gif' to write. Default ''. +% **'FrameRate':** fps. Default 4. +% **'Layout':** [rows cols] tile grid. Default a near-square auto layout. +% **'Title':** super-title drawn across the top. Default ''. +% **'Verbose':** default true. +% +% :Output: +% **out:** struct with .nlag, .npanel, .frames (composite RGB per lag), +% .file (written path or ''). +% +% See also: hrf_animate_wordcloud, hrf_make_montage_animation, +% hrf_pooled_wholebrain_animation. + +p = inputParser; +p.addRequired('panels', @(x) iscell(x) && ~isempty(x)); +p.addParameter('OutputFile', '', @(x) ischar(x) || isstring(x)); +p.addParameter('FrameRate', 4, @(x) isscalar(x) && x > 0); +p.addParameter('Layout', [], @(x) isempty(x) || (isnumeric(x) && numel(x) == 2)); +p.addParameter('Title', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Verbose', true, @(x) islogical(x) || isnumeric(x)); +p.parse(panels, varargin{:}); +opts = p.Results; +verbose = logical(opts.Verbose); + +% ---- render / collect each panel's per-lag frames ----------------------- +np = numel(panels); +pf = cell(1, np); ptitle = cell(1, np); +for i = 1:np + [pf{i}, ptitle{i}] = local_panel_frames(panels{i}, verbose, i); +end +nlags = cellfun(@numel, pf); +valid = nlags > 0; +if ~any(valid), error('hrf_animate_montage:NoFrames', 'No panel produced frames.'); end +if any(~valid) + warning('hrf_animate_montage:EmptyPanel', 'Dropping %d panel(s) that produced no frames.', sum(~valid)); + pf = pf(valid); ptitle = ptitle(valid); nlags = nlags(valid); np = numel(pf); +end +nlag = min(nlags); +if numel(unique(nlags)) > 1 && verbose + fprintf('hrf_animate_montage: panels have %s frames; using the common first %d.\n', mat2str(nlags), nlag); +end + +% ---- common tile size (pad, no distortion) + grid layout ---------------- +tileH = 0; tileW = 0; +for i = 1:np + sz = size(pf{i}{1}); tileH = max(tileH, sz(1)); tileW = max(tileW, sz(2)); +end +lab_h = 26; % strip above each tile for its title +if isempty(opts.Layout) + rows = floor(sqrt(np)); cols = ceil(np / max(rows, 1)); +else + rows = opts.Layout(1); cols = opts.Layout(2); +end + +vw = local_open_video(opts.OutputFile, opts.FrameRate); +comp_frames = cell(1, nlag); +for l = 1:nlag + tiles = cell(rows, cols); + for i = 1:np + tiles{i} = local_label_tile(local_pad_center(pf{i}{l}, tileH, tileW), ptitle{i}, lab_h); + end + blank = 255 * ones(tileH + lab_h, tileW, 3, 'uint8'); + for k = np + 1:rows * cols, tiles{k} = blank; end + rowimgs = cell(rows, 1); + for r = 1:rows, rowimgs{r} = cat(2, tiles{r, :}); end + comp = cat(1, rowimgs{:}); + if ~isempty(char(opts.Title)), comp = local_super_title(comp, char(opts.Title)); end + comp_frames{l} = comp; + vw = local_write_frame(vw, comp); +end +vw = local_close_video(vw); + +out = struct('nlag', nlag, 'npanel', np, 'frames', {comp_frames}, ... + 'file', local_out_path(vw, opts.OutputFile)); +if verbose + fprintf('hrf_animate_montage: %d panels x %d lags, %dx%d grid%s\n', np, nlag, rows, cols, ... + local_note(out.file)); +end +end + + +% ========================================================================= +function [frames, ttl] = local_panel_frames(spec, verbose, idx) +frames = {}; ttl = ''; +if ~isstruct(spec), error('hrf_animate_montage:Spec', 'Each panel must be a struct.'); end +if isfield(spec, 'title'), ttl = char(string(spec.title)); end +type = 'wordcloud'; if isfield(spec, 'type'), type = lower(char(spec.type)); end +switch type + case 'wordcloud' + args = {}; if isfield(spec, 'args'), args = spec.args; end + if verbose, fprintf(' [panel %d] wordcloud ...\n', idx); end + o = hrf_animate_wordcloud(args{:}, 'ReturnFrames', true, 'OutputFile', '', 'doverbose', false); + frames = o.frames; + if isempty(ttl), ttl = o.title; end + case {'movie', 'brain'} + if ~isfield(spec, 'file'), error('hrf_animate_montage:NoFile', 'movie/brain panel needs a .file.'); end + if verbose, fprintf(' [panel %d] frames from %s ...\n', idx, char(spec.file)); end + frames = local_read_frames(char(spec.file)); + case 'frames' + if ~isfield(spec, 'frames'), error('hrf_animate_montage:NoFrames2', 'frames panel needs .frames.'); end + frames = spec.frames; + otherwise + error('hrf_animate_montage:Type', 'Unknown panel type: %s', type); +end +frames = frames(~cellfun(@isempty, frames)); +end + + +function img = local_pad_center(img, H, W) +img = local_u8(img); +if size(img, 3) == 1, img = repmat(img, 1, 1, 3); end +[h, w, ~] = size(img); +if h > H || w > W % shouldn't happen (H,W are maxima) but guard + img = img(1:min(h, H), 1:min(w, W), :); [h, w, ~] = size(img); +end +canvas = 255 * ones(H, W, 3, 'uint8'); +r0 = floor((H - h) / 2) + 1; c0 = floor((W - w) / 2) + 1; +canvas(r0:r0 + h - 1, c0:c0 + w - 1, :) = img; +img = canvas; +end + + +function tile = local_label_tile(img, ttl, lab_h) +strip = 255 * ones(lab_h, size(img, 2), 3, 'uint8'); +tile = cat(1, strip, img); +if isempty(ttl), return; end +tile = local_puttext(tile, [size(img, 2) / 2, lab_h / 2], ttl, 16); +end + + +function comp = local_super_title(comp, ttl) +strip = 255 * ones(34, size(comp, 2), 3, 'uint8'); +strip = local_puttext(strip, [size(comp, 2) / 2, 17], ttl, 20); +comp = cat(1, strip, comp); +end + + +function img = local_puttext(img, pos, str, fs) +% Draw centered black text via insertText if available; otherwise leave the +% image unchanged (labels are cosmetic, no Computer Vision Toolbox required). +if exist('insertText', 'file') ~= 2, return; end +try + img = insertText(img, pos, str, 'AnchorPoint', 'Center', 'FontSize', fs, ... + 'BoxOpacity', 0, 'TextColor', 'black'); +catch +end +end + + +function y = local_u8(x) +if isa(x, 'uint8'), y = x; return; end +x = double(x); +if max(x(:)) <= 1 + eps, x = x * 255; end +y = uint8(min(max(x, 0), 255)); +end + + +function frames = local_read_frames(file) +if exist(file, 'file') ~= 2, error('hrf_animate_montage:MissingFile', 'Not found: %s', file); end +[~, ~, e] = fileparts(file); +if strcmpi(e, '.gif') + [g, map] = imread(file, 'frames', 'all'); + n = size(g, 4); frames = cell(1, n); + for i = 1:n, frames{i} = local_u8(ind2rgb(g(:, :, 1, i), map)); end +else + v = VideoReader(file); frames = {}; + while hasFrame(v), frames{end + 1} = local_u8(readFrame(v)); end %#ok +end +end + + +% ---- video helpers (mirror hrf_animate_wordcloud) ----------------------- +function vw = local_open_video(file, fps) +vw = struct('type', 'none', 'obj', [], 'file', char(file), 'fps', fps, 'frames', {{}}); +f = char(file); if isempty(f), return; end +[~, ~, e] = fileparts(f); +if strcmpi(e, '.gif') + vw.type = 'gif'; +elseif strcmpi(e, '.avi') + vw.obj = VideoWriter(f, 'Motion JPEG AVI'); vw.obj.FrameRate = fps; open(vw.obj); vw.type = 'avi'; +else + try, vw.obj = VideoWriter(f, 'MPEG-4'); catch, f = regexprep(f, '\.mp4$', '.avi'); vw.file = f; vw.obj = VideoWriter(f, 'Motion JPEG AVI'); end + vw.obj.FrameRate = fps; open(vw.obj); vw.type = 'mp4'; +end +end + +function vw = local_write_frame(vw, img) +if strcmp(vw.type, 'none'), return; end +if strcmp(vw.type, 'gif'), vw.frames{end + 1} = img; else, writeVideo(vw.obj, local_u8(img)); end +end + +function vw = local_close_video(vw) +if strcmp(vw.type, 'gif') + F = vw.frames; if isempty(F), return; end + [~, gmap] = rgb2ind(cat(1, F{:}), 256); dt = 1 / max(vw.fps, 1); + for i = 1:numel(F) + A = rgb2ind(F{i}, gmap); + if i == 1, imwrite(A, gmap, vw.file, 'gif', 'LoopCount', Inf, 'DelayTime', dt); + else, imwrite(A, gmap, vw.file, 'gif', 'WriteMode', 'append', 'DelayTime', dt); end + end +elseif ~strcmp(vw.type, 'none') && ~isempty(vw.obj) + close(vw.obj); +end +end + +function pth = local_out_path(vw, file) +if isempty(char(file)), pth = ''; else, pth = vw.file; end +end + +function s = local_note(f) +if isempty(f), s = ' (not saved)'; else, s = sprintf(' -> %s', f); end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_animate_wholebrain_stats.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_animate_wholebrain_stats.m new file mode 100644 index 00000000..e4d7e504 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_animate_wholebrain_stats.m @@ -0,0 +1,167 @@ +function movie_file = hrf_animate_wholebrain_stats(stats_input, varargin) +%HRF_ANIMATE_WHOLEBRAIN_STATS Make a quick montage movie from 4D HRF maps. +% +% movie_file = hrf_animate_wholebrain_stats(stats_input, ...) +% +% stats_input may be hrf_fit_wholebrain_stats output, a statistic_image +% object, or a 4D NIfTI filename. The function steps through volumes with +% CANlab montage(), which keeps it compatible with statistic_image +% thresholding. + +p = inputParser; +p.addRequired('stats_input'); +p.addParameter('Object', 't', @(x) ischar(x) || isstring(x)); +p.addParameter('OutputFile', 'hrf_montage_animation.mp4', @(x) ischar(x) || isstring(x)); +p.addParameter('Condition', '', @(x) ischar(x) || isstring(x)); +p.addParameter('MetadataTable', table(), @(x) isempty(x) || istable(x)); +p.addParameter('UseThreshold', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('FrameRate', 4, @(x) isscalar(x) && x > 0); +p.addParameter('Clim', [], @(x) isempty(x) || (isnumeric(x) && numel(x) == 2)); +p.addParameter('MontageArgs', {}, @(x) iscell(x)); +p.addParameter('FigureVisible', 'off', @(x) ischar(x) || isstring(x)); +p.parse(stats_input, varargin{:}); +opts = p.Results; + +[obj, metadata_table] = local_get_object(stats_input, opts.Object, opts.MetadataTable); +[obj, metadata_table] = local_average_condition_pattern(obj, metadata_table, char(opts.Condition)); +wh = local_select_volumes(obj, metadata_table, char(opts.Condition)); +movie_file = char(opts.OutputFile); + +if isempty(wh) + error('No volumes selected for animation.'); +end + +v = VideoWriter(movie_file, local_video_profile(movie_file)); +v.FrameRate = opts.FrameRate; +open(v); +cleaner = onCleanup(@() close(v)); + +fig = figure('Color', 'w', 'Visible', char(opts.FigureVisible)); +for i = 1:numel(wh) + clf(fig); + frame_obj = get_wh_image(obj, wh(i)); + if logical(opts.UseThreshold) && isa(frame_obj, 'statistic_image') && ~isempty(frame_obj.sig) + frame_obj.dat(~frame_obj.sig) = 0; + end + + montage(frame_obj, opts.MontageArgs{:}); + if ~isempty(opts.Clim) + try + set(findobj(fig, 'Type', 'axes'), 'CLim', opts.Clim); + catch + end + end + title(local_frame_title(frame_obj, metadata_table, wh(i)), 'Interpreter', 'none'); + drawnow; + writeVideo(v, getframe(fig)); +end +close(fig); +end + +function [obj, metadata_table] = local_average_condition_pattern(obj, metadata_table, condition) +if isempty(condition) || isempty(metadata_table) || ~any(strcmp('condition', metadata_table.Properties.VariableNames)) + return +end + +available = unique(cellstr(string(metadata_table.condition)), 'stable'); +specs = hrf_resolve_condition_patterns(available, condition, 'DefaultMode', 'first'); +spec = specs(1); +if numel(spec.matched_conditions) < 2 || ~any(strcmp('lag_index', metadata_table.Properties.VariableNames)) + return +end + +cond = cellstr(string(metadata_table.condition)); +keep = ismember(cond, spec.matched_conditions); +lags = unique(metadata_table.lag_index(keep), 'stable'); +new_dat = nan(size(obj.dat, 1), numel(lags)); +new_labels = cell(numel(lags), 1); +new_rows = cell(numel(lags), width(metadata_table)); +var_names = metadata_table.Properties.VariableNames; + +for lag_i = 1:numel(lags) + wh = keep & metadata_table.lag_index == lags(lag_i); + new_dat(:, lag_i) = local_mean_omitnan(obj.dat(:, wh), 2); + row = metadata_table(find(wh, 1), :); + if any(strcmp('condition', var_names)), row.condition = string(spec.display_label); end + label = sprintf('%s_lag%03d', spec.label, lags(lag_i)); + if any(strcmp('image_label', var_names)), row.image_label = string(label); end + new_labels{lag_i} = label; + new_rows(lag_i, :) = table2cell(row); +end + +obj.dat = new_dat; +if isa(obj, 'statistic_image') + obj.image_labels = new_labels; + obj.sig = true(size(new_dat)); + obj.p = []; + obj.ste = []; +end +metadata_table = cell2table(new_rows, 'VariableNames', var_names); +end + +function [obj, metadata_table] = local_get_object(stats_input, which_obj, metadata_table) +if isstruct(stats_input) && isfield(stats_input, 'b') && isfield(stats_input, 't') + switch lower(char(which_obj)) + case {'beta', 'b'} + obj = stats_input.b; + case {'t', 'tmap', 'tmaps'} + obj = stats_input.t; + otherwise + error('Unknown Object: %s. Use ''beta'' or ''t''.', char(which_obj)); + end + if isempty(metadata_table) && isfield(stats_input, 'metadata_table') + metadata_table = stats_input.metadata_table; + end +elseif isa(stats_input, 'image_vector') + obj = stats_input; +elseif ischar(stats_input) || isstring(stats_input) + obj = statistic_image(char(stats_input), 'type', 'generic'); +else + error('Unsupported stats_input type.'); +end +end + +function wh = local_select_volumes(obj, metadata_table, condition) +n_images = size(obj.dat, 2); +wh = 1:n_images; +if isempty(condition) + return +end + +if ~isempty(metadata_table) && any(strcmp('condition', metadata_table.Properties.VariableNames)) + specs = hrf_resolve_condition_patterns(unique(cellstr(string(metadata_table.condition)), 'stable'), condition, 'DefaultMode', 'first'); + wh = find(ismember(cellstr(string(metadata_table.condition)), specs(1).matched_conditions)); +elseif isa(obj, 'statistic_image') && ~isempty(obj.image_labels) + wh = find(contains(cellstr(string(obj.image_labels)), condition)); +else + error('Condition selection needs metadata_table.condition or statistic_image.image_labels.'); +end +end + +function m = local_mean_omitnan(X, dim) +if nargin < 2, dim = 1; end +valid = ~isnan(X); +den = sum(valid, dim); +X(~valid) = 0; +m = sum(X, dim) ./ den; +m(den == 0) = NaN; +end + +function ttl = local_frame_title(obj, metadata_table, idx) +ttl = sprintf('Volume %d', idx); +if ~isempty(metadata_table) && height(metadata_table) >= idx && any(strcmp('image_label', metadata_table.Properties.VariableNames)) + ttl = char(metadata_table.image_label(idx)); +elseif isa(obj, 'statistic_image') && ~isempty(obj.image_labels) + ttl = obj.image_labels{1}; +end +end + +function profile = local_video_profile(movie_file) +[~, ~, ext] = fileparts(movie_file); +switch lower(ext) + case '.avi' + profile = 'Motion JPEG AVI'; + otherwise + profile = 'MPEG-4'; +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_animate_wordcloud.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_animate_wordcloud.m new file mode 100644 index 00000000..042fdac1 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_animate_wordcloud.m @@ -0,0 +1,786 @@ +function out = hrf_animate_wordcloud(source, varargin) +%HRF_ANIMATE_WORDCLOUD Animated term wordcloud of HRF map-scores over lags. +% +% Visualizes how term/map associations EVOLVE across peristimulus time +% (HRF lags): one wordcloud frame per lag, each term sized by its score at +% that lag and coloured by sign. Built for Neurosynth term-map scores +% (map_neurosynth_ columns) but works for any map__* score block. +% +% Terms are placed at FIXED positions (a spiral ordered by overall +% importance) so only the font size/colour animate -- you can actually see a +% term grow and fade as the haemodynamic response unfolds, instead of the +% layout jumping every frame (as MATLAB's wordcloud would). +% +% :Usage: +% :: +% hrf_animate_wordcloud(score_csv, 'Condition','heat', 'OutputFile','terms.mp4') +% hrf_animate_wordcloud(input_table, 'Set','neurosynth', 'TopN',40) % group mean +% hrf_animate_wordcloud({lf,obs}, 'Set','neurosynth', 'Model','sfir', ... +% 'Condition','*heat*', 'OutputFile','pooled_terms.mp4') % POOL dirs +% out = hrf_animate_wordcloud(struct('scores',M,'terms',t,'lags',L)) % direct +% +% :Inputs: +% **source:** any of -- a score CSV path; an input_table +% (subject/*_scores_file rows); an output DIRECTORY or a CELL of +% directories/input tables (collected and POOLED into one group +% mean across every subject of every dir -- same subject id across +% dirs combines); or a struct with fields .scores [nLag x nTerm], +% .terms (1 x nTerm), .lags (1 x nLag). +% +% :Optional Inputs: +% **'Unit':** which score family to word-cloud: 'imageset' (default; +% Neurosynth terms/topics, hansen22, bucknerlab networks, +% ... = map__* columns), 'signature' (sig__*), or +% 'atlas' (atlas___ columns; the words +% are the region names). +% **'Set':** the set / atlas token, interpreted per Unit -- an imageset +% name ('neurosynth','neurosynth_topics_fi','hansen22', +% 'bucknerlab_wholebrain'), a signature set ('all'), or an +% atlas token ('canlab2024','ppat'). Default 'neurosynth'. +% **'Suffix':** for Unit='atlas', which region summary to use -- +% 'mean' (default), 'meanL1', or 'sum'. +% **'Condition':** condition (glob ok) whose curve to animate. Default '' = +% first condition present (errors if several and ambiguous). +% **'Model'/'Object':** for input_table iteration. Default 'sfir'/'beta'. +% **'Threshold':** significance cutoff for selecting/displaying terms, via a +% one-sample t across subjects at each (term,lag). Default +% 0.05. A term is SHOWN at the lags where its association is +% significant (it lights up over the HRF), and a term is +% selected only if significant at >=1 lag. Needs a group +% (>=2 subjects); with a single CSV/struct there is no group +% so it falls back to top-N by |score|. +% **'Correction':** multiple-comparison correction over the term x lag +% surface. 'permutation' (DEFAULT) = sign-flip max-t, +% FWER-controlled across all terms x lags (Nichols & Holmes +% 2002); exact when 2^n is enumerated (n<=13) and the right +% choice at small n. 'cluster' = temporal cluster-mass +% sign-flip (Maris & Oostenveld 2007), more sensitive to +% sustained effects. 'fdr' = BH across terms WITHIN each lag +% (does NOT correct across lags). 'fdr_all' = BH across the +% whole surface. 'none' = uncorrected. Permutation/cluster +% need a group (>=2 subjects); else falls back to per-lag +% FDR, then to top-N by |score|. +% NOTE: with 525 terms this is heavily multiple-comparison +% burdened at n~7-11 -- score against the 54 Ke-Bo-2024 +% topic maps ('Set','neurosynth_topics_fi') to shrink the +% family and regain power. +% **'Nperm':** permutations when 2^n is too large to enumerate (n>13). +% Default 5000. +% **'ClusterFormP':** cluster-forming p for Correction='cluster'. Default 0.05. +% **'Persist':** false (default) -- a selected term is drawn only at lags +% where it is significant. true -- always draw selected +% terms, greyed at sub-threshold lags. +% **'TopN':** LEGIBILITY CAP on how many terms are shown (default 60), +% applied AFTER the statistical selection, keeping the most +% significant. (Only when there is no group does it act as +% a pure top-N by |score|.) +% **'SizeBy':** 'abs' (default) | 'pos' | 'signed' -- which magnitude +% drives font size (abs = both directions grow). +% **'FrameRate':** fps of the movie. Default 4. +% **'OutputFile':** '.mp4'/'.avi'/'.gif' to write; '' = just show. Default ''. +% **'FontRange':** [min max] point sizes. Default [9 46]. +% **'Title':** title prefix. Default 'Neurosynth terms over the HRF'. +% **'Verbose'/'doverbose':** chatter. Default true. +% +% :Outputs: +% **out:** struct with .scores/.t/.p/.sig [nLag x nTerm_kept], .terms, .lags, +% .pos (fixed [k x 2] layout), .nsubj, .selection (label), .file. +% +% :Examples: +% :: +% % after rescuing neurosynth scores (see hrf_score_one_prefix append mode): +% IT = hrf_collect_wholebrain_outputs(out_dir); +% hrf_animate_wordcloud(IT, 'Set','neurosynth', 'Condition','*heat*', ... +% 'Model','sfir', 'TopN',40, 'OutputFile', fullfile(out_dir,'heat_terms.mp4')); +% +% See also: hrf_score_one_prefix, hrf_apply_maps_to_wholebrain, +% neurosynth_lexical_plot, hrf_misspec_metrics. + +p = inputParser; +p.addRequired('source'); +p.addParameter('Unit', 'imageset', @(x) ischar(x) || isstring(x)); +p.addParameter('Set', 'neurosynth', @(x) ischar(x) || isstring(x)); +p.addParameter('Suffix', 'mean', @(x) ischar(x) || isstring(x)); +p.addParameter('PrettyLabels', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('Condition', '', @(x) ischar(x) || isstring(x) || iscell(x)); +p.addParameter('Model', 'sfir', @(x) ischar(x) || isstring(x)); +p.addParameter('Object', 'beta', @(x) ischar(x) || isstring(x)); +p.addParameter('Threshold', 0.05, @(x) isscalar(x) && x > 0 && x <= 1); +p.addParameter('Correction', 'permutation', @(x) ischar(x) || isstring(x)); +p.addParameter('Nperm', 5000, @(x) isscalar(x) && x >= 100); +p.addParameter('ClusterFormP', 0.05, @(x) isscalar(x) && x > 0 && x < 1); +p.addParameter('Persist', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('TopN', 60, @(x) isscalar(x) && x >= 1); +p.addParameter('SizeBy', 'abs', @(x) ischar(x) || isstring(x)); +p.addParameter('FrameRate', 4, @(x) isscalar(x) && x > 0); +p.addParameter('OutputFile', '', @(x) ischar(x) || isstring(x)); +p.addParameter('ReturnFrames', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('FontRange', [9 46], @(x) isnumeric(x) && numel(x) == 2); +p.addParameter('Title', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Verbose', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('doverbose', [], @(x) isempty(x) || islogical(x) || isnumeric(x)); +p.parse(source, varargin{:}); +opts = p.Results; +verbose = logical(opts.Verbose); +if ~isempty(opts.doverbose), verbose = logical(opts.doverbose); end +if isempty(char(opts.Title)) + opts.Title = sprintf('%s %s over the HRF', char(opts.Set), local_unit_noun(opts.Unit)); +end + +S = local_get_term_stats(source, opts); % .scores/.t/.p [nLag x nTerm], .nsubj +if isempty(S.scores) + error('hrf_animate_wordcloud:NoData', 'No %s* scores found for the requested condition.', local_col_prefix(opts)); +end +scores = S.scores; terms = S.terms; lags = S.lags; condlabel = S.condlabel; +[nLag, nT] = size(scores); +mag = local_size_metric(scores, opts.SizeBy); + +% ---- significance: which (term,lag) associations survive correction ------ +% Group inference across subjects; for the recommended small-n handling this +% is a sign-flip permutation (max-t = FWE across the whole term x lag surface, +% or temporal-cluster), which is exact-ish at n~7-11 and respects the strong +% lag/term correlations. Per-lag FDR is available but under-corrects the lags. +have_stats = S.nsubj >= 2 && ((isfield(S, 'stack') && ~isempty(S.stack)) || any(isfinite(S.p(:)))); +thr = opts.Threshold; corr = lower(char(opts.Correction)); +pcorr = nan(nLag, nT); +if have_stats + [sig, pcorr, corr_used] = local_sig_mask(S, thr, corr, opts, verbose); + sel_note = sprintf('%s, n=%d', local_corr_note(corr_used, thr), S.nsubj); +else + sig = true(nLag, nT); + if verbose + warning('hrf_animate_wordcloud:NoGroupStats', ... + 'No across-subject statistics (need >=2 subjects); selecting top %d terms by |score|.', opts.TopN); + end + sel_note = sprintf('top %d by |score| (no group stats)', opts.TopN); +end + +% ---- select terms (significant at any lag), rank, cap at TopN ----------- +passed = any(sig, 1); +gate = have_stats; % per-frame significance gating +if ~any(passed) + warning('hrf_animate_wordcloud:NoneSignificant', ... + 'No term survives %s correction at %.3g; showing top %d by |score| instead.', corr, thr, opts.TopN); + passed = true(1, nT); sig = true(nLag, nT); gate = false; + sel_note = sprintf('top %d by |score| (none survived %s)', opts.TopN, corr); +end +if have_stats && any(passed) + rankval = min(pcorr, [], 1, 'omitnan'); rankval(~passed | ~isfinite(rankval)) = Inf; + [~, ord] = sort(rankval, 'ascend'); +else + imp = max(mag, [], 1, 'omitnan'); imp(~passed) = -Inf; + [~, ord] = sort(imp, 'descend'); +end +keepN = min(opts.TopN, sum(passed)); +keep = ord(1:keepN); +terms = terms(keep); scores = scores(:, keep); mag = mag(:, keep); sig = sig(:, keep); pcorr = pcorr(:, keep); +labels = local_labels(terms, opts.PrettyLabels); % display names (raw kept in out.terms) + +gmax = max(mag(:)); if gmax == 0 || ~isfinite(gmax), gmax = 1; end +clim = max(abs(scores(:))); if clim == 0 || ~isfinite(clim), clim = 1; end +fr = opts.FontRange; + +fig = figure('Color', 'w', 'Position', [80 80 1000 760], 'Name', char(opts.Title)); +ax = axes(fig, 'Position', [0.02 0.04 0.96 0.90]); axis(ax, [0 1 0 1]); axis(ax, 'off'); hold(ax, 'on'); + +% Non-overlapping wordcloud packing computed ONCE at each word's MAXIMUM font +% size (its peak over lags). Every later frame only shrinks a word, so the +% packing stays overlap-free and positions are stable across the movie -- the +% tight look of a static wordcloud, but animated. +peakmag = max(mag, [], 1, 'omitnan'); +pos = local_wordcloud_layout(ax, labels, peakmag, gmax, fr); + +vw = local_open_video(opts.OutputFile, opts.FrameRate); +persist = logical(opts.Persist); +returnframes = logical(opts.ReturnFrames); +frames = cell(1, nLag); +for li = 1:nLag + cla(ax); axis(ax, [0 1 0 1]); axis(ax, 'off'); + for ti = 1:numel(terms) + s = scores(li, ti); m = mag(li, ti); + if ~isfinite(m) || m <= 0, continue; end + % gate by per-lag significance (a term lights up only where it is + % significant), unless Persist or no group stats. + if gate && ~persist && ~sig(li, ti), continue; end + fs = fr(1) + (fr(2) - fr(1)) * (m / gmax); + if gate && persist && ~sig(li, ti) + col = [0.7 0.7 0.7]; % shown but greyed (sub-threshold) + else + col = local_sign_color(s, clim); + end + text(ax, pos(ti, 1), pos(ti, 2), char(labels{ti}), 'FontSize', fs, ... + 'Color', col, 'HorizontalAlignment', 'center', ... + 'FontWeight', 'bold', 'Interpreter', 'none', 'Clipping', 'on'); + end + title(ax, sprintf('%s — %s t = %.1f s [%s]', char(opts.Title), condlabel, lags(li), sel_note), ... + 'Interpreter', 'none', 'FontSize', 11); + drawnow; + if returnframes, frames{li} = frame2im(getframe(fig)); end %#ok + vw = local_write_frame(vw, fig); +end +vw = local_close_video(vw); + +out = struct('scores', scores, 'terms', {terms}, 'labels', {labels}, 'lags', lags(:)', 'pos', pos, ... + 't', S.t(:, keep), 'p', S.p(:, keep), 'p_corr', pcorr, 'sig', sig, ... + 'correction', char(opts.Correction), 'nsubj', S.nsubj, 'frames', {frames}, ... + 'title', char(opts.Title), 'condition', condlabel, ... + 'selection', sel_note, 'file', local_video_path(vw, opts.OutputFile)); +if verbose + fprintf('hrf_animate_wordcloud: %d terms shown (%s) x %d lags, condition %s%s\n', ... + numel(terms), sel_note, nLag, condlabel, local_file_note(out.file)); +end +end + + +% ========================================================================= +function S = local_get_term_stats(source, opts) +% Returns S.scores/.t/.p [nLag x nTerm], .terms, .lags, .condlabel, .nsubj. +% Group statistics (across-subject one-sample t) are produced for the +% input_table / dir / cell sources; single CSV and struct sources have no +% group (t/p NaN -> caller falls back to top-N by |score|). +prefix = local_col_prefix(opts); +S = struct('scores', [], 't', [], 'p', [], 'terms', {{}}, 'lags', [], 'condlabel', '', 'nsubj', 0); +if isstruct(source) && isfield(source, 'scores') + S.scores = source.scores; S.terms = cellstr(string(source.terms)); + S.lags = source.lags(:)'; S.condlabel = 'curve'; + S.t = nan(size(S.scores)); S.p = nan(size(S.scores)); S.nsubj = 1; + return +end +is_dir = (ischar(source) || isstring(source)) && isfolder(char(string(source))); +if iscell(source) || is_dir + S = local_stats_from_input_table(local_pool_input_table(source), prefix, opts); + return +end +if (ischar(source) || isstring(source)) && endsWith(string(source), '.csv') + [M, terms, lags, cl] = local_matrix_from_csv(char(source), prefix, opts); + S.scores = M; S.terms = terms; S.lags = lags; S.condlabel = cl; + S.t = nan(size(M)); S.p = nan(size(M)); S.nsubj = 1; + return +end +if istable(source) + S = local_stats_from_input_table(source, prefix, opts); + return +end +error('hrf_animate_wordcloud:Source', ... + 'source must be a score CSV, an input_table, a struct, an output dir, or a cell of dirs/tables.'); +end + + +function IT = local_pool_input_table(source) +% Concatenate the collection tables of one or more output dirs (or pre-built +% input tables) on their common columns. Same subject id across dirs pools +% that subject's score files in the downstream group mean. +if iscell(source), items = source(:)'; else, items = {source}; end +IT = table(); +for i = 1:numel(items) + it = items{i}; + if istable(it) + Ti = it; + else + Ti = hrf_collect_wholebrain_outputs(char(string(it))); + end + if isempty(Ti) || height(Ti) == 0, continue; end + if isempty(IT) || height(IT) == 0 + IT = Ti; + else + c = intersect(IT.Properties.VariableNames, Ti.Properties.VariableNames, 'stable'); + IT = [IT(:, c); Ti(:, c)]; %#ok + end +end +if isempty(IT) || height(IT) == 0 + error('hrf_animate_wordcloud:NoRecords', 'No score records collected from the given dir(s).'); +end +end + + +function [M, terms, lags, condlabel] = local_matrix_from_csv(csv, prefix, opts) +T = readtable(csv, 'TextType', 'string'); +[M, terms, lags, condlabel] = local_matrix_from_table(T, prefix, opts); +end + + +function S = local_stats_from_input_table(IT, prefix, opts) +% Per-SUBJECT term matrices (average that subject's runs/files), stacked, then +% group mean + one-sample t across subjects (the across-subject inference that +% the statistical threshold uses). +S = struct('scores', [], 't', [], 'p', [], 'terms', {{}}, 'lags', [], 'condlabel', '', 'nsubj', 0); +vars = IT.Properties.VariableNames; +file_col = ''; +if strcmpi(char(opts.Object), 't') && any(strcmp('t_scores_file', vars)) + file_col = 't_scores_file'; +elseif any(strcmp('beta_scores_file', vars)) + file_col = 'beta_scores_file'; +end +if isempty(file_col), error('hrf_animate_wordcloud:NoScoreFiles', 'input_table lacks *_scores_file columns.'); end +model = char(opts.Model); +if any(strcmp('subject', vars)), subjects = cellstr(string(IT.subject)); +else, subjects = arrayfun(@(i) sprintf('row%d', i), 1:height(IT), 'uni', 0); end +usub = unique(subjects, 'stable'); + +ref_terms = {}; ref_lags = []; condlabel = ''; +parts = {}; used = {}; +for s = 1:numel(usub) + rows = find(strcmp(subjects, usub{s})); + acc = []; nf = 0; + for ii = rows(:)' + if any(strcmp('model', vars)) && ~strcmpi(char(string(IT.model(ii))), model), continue; end + f = char(string(IT.(file_col)(ii))); + if isempty(f) || exist(f, 'file') ~= 2, continue; end + [Mi, ti, li, cl] = local_matrix_from_table(readtable(f, 'TextType', 'string'), prefix, opts); + if isempty(Mi), continue; end + if isempty(ref_terms), ref_terms = ti; ref_lags = li; condlabel = cl; end + if ~isequal(ti, ref_terms) || ~isequal(li, ref_lags) + warning('hrf_animate_wordcloud:GridMismatch', 'Skipping a score file whose term/lag grid differs.'); + continue + end + if isempty(acc), acc = Mi; nf = 1; else, acc = acc + Mi; nf = nf + 1; end + end + if nf == 0, continue; end + parts{end + 1} = acc / nf; used{end + 1} = usub{s}; %#ok +end +if isempty(parts), return; end + +stack = cat(3, parts{:}); +n = size(stack, 3); +m = mean(stack, 3, 'omitnan'); +S.scores = m; S.terms = ref_terms; S.lags = ref_lags; S.condlabel = condlabel; S.nsubj = n; +S.stack = stack; % [nLag x nTerm x nSubj], for the permutation correction +if n >= 2 + se = std(stack, 0, 3, 'omitnan') / sqrt(n); + t = m ./ se; t(se == 0) = 0; + S.t = t; S.p = 2 * (1 - local_tcdf(abs(t), n - 1)); +else + S.t = nan(size(m)); S.p = nan(size(m)); +end +end + + +function [sig, pcorr, used] = local_sig_mask(S, thr, corr, opts, verbose) +% Corrected significance over the whole term x lag surface. Dispatches by +% Correction; permutation/cluster need the per-subject stack. +have_stack = isfield(S, 'stack') && ~isempty(S.stack); +used = corr; +switch corr + case {'permutation', 'perm', 'maxt'} + if have_stack + [sig, pcorr] = local_perm_maxt(S.stack, thr, opts.Nperm); return + end + if verbose, warning('hrf_animate_wordcloud:NoStack', 'No per-subject stack; falling back to per-lag FDR.'); end + used = 'fdr'; + case {'cluster', 'tcluster'} + if have_stack + [sig, pcorr] = local_perm_cluster(S.stack, thr, opts.Nperm, opts.ClusterFormP); return + end + if verbose, warning('hrf_animate_wordcloud:NoStack', 'No per-subject stack; falling back to per-lag FDR.'); end + used = 'fdr'; +end +% parametric fallbacks (use S.p) +switch used + case 'none' + pcorr = S.p; sig = pcorr < thr; + case {'fdr_all', 'fdrall'} + pcorr = local_fdr_all(S.p); sig = pcorr <= thr; + otherwise % 'fdr' = per-lag across terms + [sig, pcorr] = local_fdr_perlag(S.p, thr); +end +sig(~isfinite(S.p)) = false; +end + + +function [sig, pcorr] = local_perm_maxt(stack, thr, nperm) +% Sign-flip permutation, FWER-controlled over the whole term x lag surface via +% the maximum |t| statistic (Nichols & Holmes 2002). Exact when 2^n is +% enumerated (n<=13). The whole matrix is flipped per subject, so the +% lag/term correlation structure is preserved under the null. +tobs = abs(local_group_t(stack)); +n = size(stack, 3); +flips = local_sign_flips(n, nperm); +nf = size(flips, 1); +ge = zeros(size(tobs)); +for f = 1:nf + tp = abs(local_group_t(stack .* reshape(flips(f, :), 1, 1, n))); + ge = ge + (max(tp(:)) >= tobs); +end +pcorr = ge / nf; +pcorr(~isfinite(local_group_t(stack))) = NaN; +sig = pcorr <= thr; +end + + +function [sig, pcorr] = local_perm_cluster(stack, thr, nperm, formp) +% Temporal cluster-mass permutation (Maris & Oostenveld 2007): form contiguous +% same-sign supra-threshold lag clusters per term, mass = sum|t|; null = max +% cluster mass over sign-flips. +[L, T, n] = size(stack); +tobs = local_group_t(stack); +tcrit = local_tinv(1 - formp / 2, n - 1); +oc = local_clusters(tobs, tcrit); +flips = local_sign_flips(n, nperm); +nf = size(flips, 1); +maxmass = zeros(nf, 1); +for f = 1:nf + cl = local_clusters(local_group_t(stack .* reshape(flips(f, :), 1, 1, n)), tcrit); + if ~isempty(cl), maxmass(f) = max([cl.mass]); end +end +sig = false(L, T); pcorr = nan(L, T); +for c = 1:numel(oc) + pc = mean(maxmass >= oc(c).mass); + sig(oc(c).cells) = pc <= thr; + pcorr(oc(c).cells) = pc; +end +end + + +function cl = local_clusters(tmat, tcrit) +[L, T] = size(tmat); +cl = struct('cells', {}, 'mass', {}); +for j = 1:T + col = tmat(:, j); + for s = [1 -1] + supra = (s * col > tcrit) & isfinite(col); + d = diff([false; supra; false]); + starts = find(d == 1); stops = find(d == -1) - 1; + for b = 1:numel(starts) + idx = (starts(b):stops(b))'; + cl(end + 1) = struct('cells', sub2ind([L T], idx, repmat(j, numel(idx), 1)), ... + 'mass', sum(abs(col(idx)))); %#ok + end + end +end +end + + +function t = local_group_t(stack) +n = size(stack, 3); +m = mean(stack, 3, 'omitnan'); +se = std(stack, 0, 3, 'omitnan') / sqrt(n); +t = m ./ se; t(se == 0) = 0; +end + + +function F = local_sign_flips(n, nperm) +% Rows of +/-1. Enumerate all 2^n exactly for small n; else random with the +% identity (all +1, = observed) included as row 1. +if n <= 13 + m = 2 ^ n; + F = ones(m, n); + for i = 1:n, F(:, i) = 1 - 2 * bitget((0:m - 1)', i); end +else + F = ones(nperm, n); + F(2:end, :) = 2 * (rand(nperm - 1, n) > 0.5) - 1; +end +end + + +function [sig, pcorr] = local_fdr_perlag(p, thr) +[L, T] = size(p); sig = false(L, T); pcorr = nan(L, T); +for li = 1:L + pl = p(li, :); valid = isfinite(pl); + if ~any(valid), continue; end + padj = local_fdr_row(pl(valid)); + row = nan(1, T); row(valid) = padj; pcorr(li, :) = row; + sig(li, valid) = padj <= thr; +end +end + + +function padj = local_fdr_all(p) +padj = nan(size(p)); mask = isfinite(p); pv = p(mask); m = numel(pv); +if m == 0, return; end +[sp, ord] = sort(pv(:)); +adj = min(1, flipud(cummin(flipud(sp .* m ./ (1:m)')))); +out = nan(m, 1); out(ord) = adj; padj(mask) = out; +end + + +function t = local_tinv(pp, df) +% Inverse Student-t via bisection on local_tcdf (no Stats toolbox). +lo = 0; hi = 100; +for it = 1:60 + mid = (lo + hi) / 2; + if local_tcdf(mid, df) < pp, lo = mid; else, hi = mid; end +end +t = (lo + hi) / 2; +end + + +function s = local_corr_note(corr, thr) +switch lower(char(corr)) + case {'permutation', 'perm', 'maxt'}, s = sprintf('p_FWE<%.3g (sign-flip max-t)', thr); + case {'cluster', 'tcluster'}, s = sprintf('cluster p<%.3g (sign-flip)', thr); + case {'fdr_all', 'fdrall'}, s = sprintf('q_FDR<%.3g (whole surface)', thr); + case 'none', s = sprintf('p<%.3g uncorrected', thr); + otherwise, s = sprintf('q_FDR<%.3g (per-lag)', thr); +end +end + +function padj = local_fdr_row(p) +% Benjamini-Hochberg adjusted p-values for a vector. +p = p(:)'; m = numel(p); +[sp, ord] = sort(p); +adj = min(1, fliplr(cummin(fliplr(sp .* m ./ (1:m))))); +padj = nan(1, m); padj(ord) = adj; +end + +function pcdf = local_tcdf(tval, df) +% Student-t CDF via regularized incomplete beta (no Stats toolbox needed). +x = df ./ (df + tval .^ 2); +pcdf = 1 - 0.5 * betainc(x, df / 2, 0.5); +end + + +function [M, terms, lags, condlabel] = local_matrix_from_table(T, prefix, opts) +M = []; terms = {}; lags = []; condlabel = ''; +v = T.Properties.VariableNames; +[cols, terms] = local_select_cols(v, prefix, opts); +if isempty(cols), return; end +if any(strcmp('lag_seconds', v)), lagcol = 'lag_seconds'; else, lagcol = 'lag_index'; end +if ~any(strcmp('condition', v)) || ~any(strcmp(lagcol, v)), return; end +cond = string(T.condition); +cmask = local_cond_pick(cond, opts.Condition); +condlabel = char(local_cond_label(cond, cmask)); +lg = double(T.(lagcol)); +[ul, ~, gi] = unique(lg(cmask), 'stable'); +[lags, so] = sort(ul); +M = zeros(numel(lags), numel(cols)); +for j = 1:numel(cols) + y = double(T.(cols{j})(cmask)); + m = accumarray(gi, y, [], @(x) mean(x, 'omitnan')); + M(:, j) = m(so); +end +end + + +function prefix = local_col_prefix(opts) +% Score-CSV column prefix for the requested unit + set/atlas token. +switch lower(char(opts.Unit)) + case 'signature', prefix = ['sig_', char(opts.Set), '_']; % sig__ + case 'atlas', prefix = ['atlas_', char(opts.Set), '_']; % atlas___ + otherwise, prefix = ['map_', char(opts.Set), '_']; % imageset / network maps +end +end + + +function [cols, terms] = local_select_cols(v, prefix, opts) +% Columns of the requested family + their display names. For atlas, keep only +% the chosen Normalize suffix (mean/meanL1/sum) and strip it from the region +% name; signatures/imagesets drop the trailing _se columns. +cols = {}; terms = {}; +isatlas = strcmpi(char(opts.Unit), 'atlas'); +suf = ['_', char(opts.Suffix)]; +for i = 1:numel(v) + nm = v{i}; + if ~startsWith(nm, prefix) || endsWith(nm, '_se'), continue; end + if isatlas + if ~endsWith(nm, suf), continue; end + term = nm(numel(prefix) + 1:end - numel(suf)); + else + term = nm(numel(prefix) + 1:end); + end + if isempty(term), continue; end + cols{end + 1} = nm; terms{end + 1} = term; %#ok +end +end + + +function labels = local_labels(terms, pretty) +% Human-readable display names from the sanitized column tokens. +if ~logical(pretty), labels = terms; return; end +labels = cellfun(@local_prettify, terms, 'uni', 0); +end + +function s = local_prettify(term) +% 'SensoryStimulation' -> 'Sensory Stimulation'; 'Ctx_V1_L' -> 'Ctx V1 L'; +% 'nback-stimblock' -> 'nback stimblock'. Undoes matlab.lang.makeValidName-style +% sanitization for display (the raw token is kept for matching / out.terms). +s = char(term); +s = strrep(s, '_', ' '); +s = strrep(s, '-', ' '); +s = regexprep(s, '([a-z0-9])([A-Z])', '$1 $2'); % camelCase boundary +s = regexprep(s, '([A-Za-z])([0-9])', '$1 $2'); % letter->digit boundary +s = regexprep(s, '\s+', ' '); +s = strtrim(s); +end + +function s = local_unit_noun(unit) +switch lower(char(unit)) + case 'signature', s = 'signatures'; + case 'atlas', s = 'regions'; + otherwise, s = 'maps'; +end +end + + +function mask = local_cond_pick(cond, want) +want = cellstr(string(want)); +want = want(~cellfun(@(s) isempty(strtrim(s)), want)); +if isempty(want) + u = unique(cond, 'stable'); + mask = cond == u(1); + return +end +mask = false(numel(cond), 1); +for i = 1:numel(want) + pat = strtrim(want{i}); + if any(pat == '*' | pat == '?') + rx = ['^', regexptranslate('wildcard', pat), '$']; + mask = mask | ~cellfun('isempty', regexp(cellstr(cond), rx, 'once')); + else + mask = mask | (cond == string(pat)); + end +end +end + + +function lbl = local_cond_label(cond, mask) +u = unique(cond(mask), 'stable'); +if isempty(u), lbl = ""; elseif isscalar(u), lbl = u(1); else, lbl = strjoin(u, '+'); end +end + + +function mag = local_size_metric(scores, sizeby) +switch lower(char(sizeby)) + case 'pos', mag = max(scores, 0); + case 'signed', mag = max(scores, 0); % signed still sizes by positive part + otherwise, mag = abs(scores); +end +end + + +function pos = local_wordcloud_layout(ax, terms, weight, gmax, fr) +% Greedy collision-avoidance packing (largest word first; spiral outward to the +% first slot whose bounding box overlaps nothing already placed), with each +% word measured at its MAXIMUM font size. Produces a tight, non-overlapping +% layout like a static wordcloud; positions are then held fixed for the movie. +k = numel(terms); +weight = weight(:); +maxfs = fr(1) + (fr(2) - fr(1)) * (max(weight, 0) / gmax); +maxfs = max(maxfs, fr(1)); + +% measure each word's bounding box (data units) at its max font +ht = gobjects(k, 1); +for i = 1:k + ht(i) = text(ax, 0.5, 0.5, char(terms{i}), 'FontSize', maxfs(i), ... + 'FontWeight', 'bold', 'Interpreter', 'none', 'Visible', 'off'); +end +drawnow; +bw = zeros(k, 1); bh = zeros(k, 1); +for i = 1:k + e = get(ht(i), 'Extent'); bw(i) = e(3); bh(i) = e(4); +end +delete(ht); + +pad = 0.010; +[~, ord] = sort(weight, 'descend'); % place biggest first (centre-out) +pos = repmat([0.5 0.5], k, 1); +placed = zeros(0, 4); % [cx cy w h] +for oi = 1:k + i = ord(oi); + c = local_place_box(placed, bw(i) + pad, bh(i) + pad); + pos(i, :) = c; + placed(end + 1, :) = [c, bw(i) + pad, bh(i) + pad]; %#ok +end +end + + +function c = local_place_box(placed, bw, bh) +% First non-overlapping centre on an outward spiral (inside the canvas if +% possible; relax the canvas bound only if nothing fits inside). +if isempty(placed), c = [0.5 0.5]; return; end +for r = 0:0.006:0.8 + npt = max(12, round(2 * pi * r / 0.008)); + for a = linspace(0, 2 * pi, npt) + c = [0.5 + r * cos(a), 0.5 + r * sin(a)]; + if c(1) - bw / 2 < 0.02 || c(1) + bw / 2 > 0.98 || ... + c(2) - bh / 2 < 0.02 || c(2) + bh / 2 > 0.98 + continue + end + if ~local_box_overlaps(c, bw, bh, placed), return; end + end +end +for r = 0:0.01:1.6 + npt = max(12, round(2 * pi * r / 0.01)); + for a = linspace(0, 2 * pi, npt) + c = [0.5 + r * cos(a), 0.5 + r * sin(a)]; + if ~local_box_overlaps(c, bw, bh, placed), return; end + end +end +c = [0.5 0.5]; +end + + +function tf = local_box_overlaps(c, bw, bh, placed) +tf = any(abs(placed(:, 1) - c(1)) < (placed(:, 3) + bw) / 2 & ... + abs(placed(:, 2) - c(2)) < (placed(:, 4) + bh) / 2); +end + + +function c = local_sign_color(s, clim) +t = min(abs(s) / clim, 1); +if s >= 0 + c = [0.55 + 0.45 * t, 0.25 * (1 - t), 0.20 * (1 - t)]; % red family +else + c = [0.20 * (1 - t), 0.30 * (1 - t), 0.55 + 0.45 * t]; % blue family +end +end + + +% ---- video helpers ------------------------------------------------------ +function vw = local_open_video(file, fps) +vw = struct('type', 'none', 'obj', [], 'file', char(file), 'fps', fps, 'frames', {{}}); +f = char(file); +if isempty(f), return; end +[~, ~, e] = fileparts(f); e = lower(e); +if strcmp(e, '.gif') + vw.type = 'gif'; % accumulate; write once at close +elseif strcmp(e, '.avi') + vw.obj = VideoWriter(f, 'Motion JPEG AVI'); vw.obj.FrameRate = fps; open(vw.obj); vw.type = 'avi'; +else + try + vw.obj = VideoWriter(f, 'MPEG-4'); + catch + f = regexprep(f, '\.mp4$', '.avi'); vw.file = f; + vw.obj = VideoWriter(f, 'Motion JPEG AVI'); + end + vw.obj.FrameRate = fps; open(vw.obj); vw.type = 'mp4'; +end +end + +function vw = local_write_frame(vw, fig) +if strcmp(vw.type, 'none'), return; end +frame = getframe(fig); +if strcmp(vw.type, 'gif') + vw.frames{end + 1} = frame2im(frame); % keep truecolor; quantize at close +else + writeVideo(vw.obj, frame); +end +end + +function vw = local_close_video(vw) +if strcmp(vw.type, 'gif') + F = vw.frames; + if isempty(F), return; end + % ONE global colormap from all frames, so colors (red/blue/grey) survive + % even when the first frame is near-blank (no significant terms yet). + [~, gmap] = rgb2ind(cat(1, F{:}), 256); + dt = 1 / max(vw.fps, 1); + for i = 1:numel(F) + A = rgb2ind(F{i}, gmap); + if i == 1 + imwrite(A, gmap, vw.file, 'gif', 'LoopCount', Inf, 'DelayTime', dt); + else + imwrite(A, gmap, vw.file, 'gif', 'WriteMode', 'append', 'DelayTime', dt); + end + end +elseif ~strcmp(vw.type, 'none') && ~isempty(vw.obj) + close(vw.obj); +end +end + +function pth = local_video_path(vw, file) +if isempty(char(file)), pth = ''; else, pth = vw.file; end +end + +function s = local_file_note(f) +if isempty(f), s = ' (not saved)'; else, s = sprintf(' -> %s', f); end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_apply_maps_to_wholebrain.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_apply_maps_to_wholebrain.m new file mode 100644 index 00000000..fce11719 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_apply_maps_to_wholebrain.m @@ -0,0 +1,449 @@ +function scores = hrf_apply_maps_to_wholebrain(stats_input, varargin) +%HRF_APPLY_MAPS_TO_WHOLEBRAIN Apply signatures/imagesets to 4D HRF maps. +% +% scores = hrf_apply_maps_to_wholebrain(stats_input, ...) +% +% stats_input may be: +% - output from hrf_fit_wholebrain_stats +% - a statistic_image/fmri_data/image_vector object +% - a 4D NIfTI filename +% +% The output is a table with one row per 4D map volume and one column per +% signature/map score. This is the lightweight route for summaries after +% writing whole-brain beta/T maps. + +p = inputParser; +p.addRequired('stats_input'); +p.addParameter('Object', 'beta', @(x) ischar(x) || isstring(x)); +p.addParameter('SignatureSets', {}, @(x) ischar(x) || iscell(x) || isstring(x)); +p.addParameter('ImageSets', {}, @(x) ischar(x) || iscell(x) || isstring(x) || isa(x, 'image_vector')); +p.addParameter('SimilarityMetric', 'dotproduct', @(x) ischar(x) || isstring(x)); +p.addParameter('SEInput', [], @(x) isempty(x) || isstruct(x) || isa(x, 'image_vector') || ischar(x) || isstring(x)); +p.addParameter('PropagateSE', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('SEScoreSuffix', '_se', @(x) ischar(x) || isstring(x)); +p.addParameter('MetadataTable', table(), @(x) isempty(x) || istable(x)); +p.addParameter('OutputCsv', '', @(x) ischar(x) || isstring(x)); +p.addParameter('WarningContext', '', @(x) ischar(x) || isstring(x)); +p.parse(stats_input, varargin{:}); +opts = p.Results; + +[obj, metadata_table] = local_get_object(stats_input, opts.Object, opts.MetadataTable); +n_images = size(obj.dat, 2); +scores = local_base_table(obj, metadata_table, n_images); +empty_insertions = struct('score', {}, 'n_inserted', {}); +se_obj = local_get_se_object(stats_input, opts.SEInput, opts.Object, obj); +propagate_se = logical(opts.PropagateSE) && ~isempty(se_obj) && local_is_linear_metric(opts.SimilarityMetric); +if logical(opts.PropagateSE) && ~isempty(se_obj) && ~local_is_linear_metric(opts.SimilarityMetric) + warning('hrf_apply_maps_to_wholebrain:CannotPropagateSEForMetric', ... + 'Pattern-score SE propagation is only implemented for dotproduct/dot_product metrics; skipping SE columns for metric %s.', ... + char(opts.SimilarityMetric)); +end + +% Resilience: a single set that fails to load (e.g. a missing/racing signature +% file) or a single map that fails to apply must NOT abort the whole table. +% Skip + warn instead, so the remaining signatures, imagesets, and the atlas +% columns the caller appends are still written. Otherwise one flaky file +% silently nukes an entire score CSV. +signature_sets = local_to_cell(opts.SignatureSets); +for s = 1:numel(signature_sets) + sigset = signature_sets{s}; + try + [signature_obj, signature_names] = local_load_signature_set(sigset, obj); + catch load_err + warning('hrf_apply_maps_to_wholebrain:SignatureSetLoadFailed', ... + 'Skipping signature set ''%s'' (load failed): %s%s', ... + char(string(sigset)), load_err.message, local_context_suffix(opts.WarningContext)); + continue + end + if isempty(signature_names) + warning('No signatures returned for image_set %s.', sigset); + continue + end + for i = 1:numel(signature_names) + name = signature_names{i}; + try + this_map = get_wh_image(signature_obj, i); + v = apply_mask(obj, this_map, 'pattern_expression', 'ignore_missing', char(opts.SimilarityMetric)); + varname = local_unique_varname(scores, local_varname({'sig', sigset, name})); + [v, n_inserted] = local_match_length(v, n_images, obj); + scores.(varname) = v; + empty_insertions = local_record_empty_insertion(empty_insertions, varname, n_inserted); + if propagate_se + scores = local_add_propagated_se(scores, se_obj, this_map, n_images, varname, opts.SEScoreSuffix); + end + catch sig_err + warning('hrf_apply_maps_to_wholebrain:SignatureFailed', ... + 'Skipping signature ''%s / %s'': %s%s', ... + char(string(sigset)), char(string(name)), sig_err.message, local_context_suffix(opts.WarningContext)); + end + end +end + +image_sets = local_to_cell(opts.ImageSets); +for s = 1:numel(image_sets) + image_set = image_sets{s}; + try + if isa(image_set, 'image_vector') + maps = image_set; + set_name = 'imageset'; + map_names = local_map_names(maps); + else + set_name = char(image_set); + [maps, map_names] = local_load_named_image_set(set_name, obj); + end + catch load_err + if isa(image_set, 'image_vector'), set_label = 'imageset'; else, set_label = char(string(image_set)); end + warning('hrf_apply_maps_to_wholebrain:ImageSetLoadFailed', ... + 'Skipping image set ''%s'' (load failed): %s%s', ... + set_label, load_err.message, local_context_suffix(opts.WarningContext)); + continue + end + + for i = 1:numel(map_names) + try + this_map = get_wh_image(maps, i); + v = apply_mask(obj, this_map, 'pattern_expression', 'ignore_missing', char(opts.SimilarityMetric)); + varname = local_unique_varname(scores, local_varname({'map', set_name, map_names{i}})); + [v, n_inserted] = local_match_length(v, n_images, obj); + scores.(varname) = v; + empty_insertions = local_record_empty_insertion(empty_insertions, varname, n_inserted); + if propagate_se + scores = local_add_propagated_se(scores, se_obj, this_map, n_images, varname, opts.SEScoreSuffix); + end + catch map_err + warning('hrf_apply_maps_to_wholebrain:ImageMapFailed', ... + 'Skipping image map ''%s / %s'': %s%s', ... + set_name, char(string(map_names{i})), map_err.message, local_context_suffix(opts.WarningContext)); + end + end +end + +local_warn_empty_insertions(empty_insertions, opts.WarningContext, opts.OutputCsv); + +if ~isempty(opts.OutputCsv) + writetable(scores, char(opts.OutputCsv)); +end +end + +function se_obj = local_get_se_object(stats_input, se_input, which_obj, ref_obj) +se_obj = []; +if ~ismember(lower(char(which_obj)), {'beta', 'b'}) + return +end + +if ~isempty(se_input) + se_obj = local_as_image_vector(se_input); +elseif isstruct(stats_input) && isfield(stats_input, 'b') && local_has_ste(stats_input.b) + se_obj = stats_input.b; + se_obj.dat = stats_input.b.ste; + se_obj.type = 'HRF beta standard error'; +elseif isa(stats_input, 'statistic_image') && local_has_ste(stats_input) + se_obj = stats_input; + se_obj.dat = stats_input.ste; + se_obj.type = 'HRF beta standard error'; +end + +if isempty(se_obj) + return +end +local_validate_se_object(se_obj, ref_obj); +end + +function tf = local_has_ste(obj) +tf = isprop(obj, 'ste') && ~isempty(obj.ste); +end + +function obj = local_as_image_vector(input_obj) +if isa(input_obj, 'image_vector') + obj = input_obj; +elseif ischar(input_obj) || isstring(input_obj) + obj = statistic_image(fmri_data(char(input_obj), 'noverbose')); +else + error('Unsupported SEInput type.'); +end +end + +function local_validate_se_object(se_obj, ref_obj) +if size(se_obj.dat, 1) ~= size(ref_obj.dat, 1) || size(se_obj.dat, 2) ~= size(ref_obj.dat, 2) + error('SEInput image data size (%d-by-%d) does not match scored image data size (%d-by-%d).', ... + size(se_obj.dat, 1), size(se_obj.dat, 2), size(ref_obj.dat, 1), size(ref_obj.dat, 2)); +end +end + +function tf = local_is_linear_metric(metric) +metric = lower(strrep(strtrim(char(metric)), '_', '')); +tf = ismember(metric, {'dotproduct', 'dot'}); +end + +function scores = local_add_propagated_se(scores, se_obj, weight_map, n_images, score_varname, suffix) +se_values = local_pattern_score_se(se_obj, weight_map, n_images); +se_varname = local_unique_varname(scores, [char(score_varname) char(suffix)]); +scores.(se_varname) = se_values; +end + +function score_se = local_pattern_score_se(se_obj, weight_map, n_images) +if size(weight_map.dat, 1) ~= size(se_obj.dat, 1) + error('Weight map voxel count (%d) does not match SE image voxel count (%d).', ... + size(weight_map.dat, 1), size(se_obj.dat, 1)); +end + +w = double(weight_map.dat(:, 1)); +valid_w = isfinite(w); +w(~valid_w) = 0; +w2 = w .^ 2; +score_se = nan(n_images, 1); + +chunk_size = 64; +for first_img = 1:chunk_size:n_images + last_img = min(first_img + chunk_size - 1, n_images); + idx = first_img:last_img; + se_dat = double(se_obj.dat(:, idx)); + valid = isfinite(se_dat) & repmat(valid_w, 1, numel(idx)); + se_dat(~valid) = 0; + score_se(idx) = sqrt(w2' * (se_dat .^ 2))'; + score_se(idx(sum(valid, 1) == 0)) = NaN; +end +end + +function [signature_obj, signature_names] = local_load_signature_set(sigset, ref_obj) +persistent signature_cache +if isempty(signature_cache) + signature_cache = containers.Map('KeyType', 'char', 'ValueType', 'any'); +end + +key = local_cache_key('sig', sigset, ref_obj); +if isKey(signature_cache, key) + entry = signature_cache(key); + signature_obj = entry.obj; + signature_names = entry.names; + return +end + +if ~exist('load_image_set', 'file') + error('load_image_set not found on path. Add CanlabCore dependencies first.'); +end +[signature_obj, signature_names] = load_image_set(char(sigset), 'noverbose'); +signature_names = local_clean_signature_names(signature_names); +signature_obj = resample_space(signature_obj, ref_obj); +signature_cache(key) = struct('obj', signature_obj, 'names', {signature_names}); +end + +function [maps, map_names] = local_load_named_image_set(set_name, ref_obj) +persistent image_set_cache +if isempty(image_set_cache) + image_set_cache = containers.Map('KeyType', 'char', 'ValueType', 'any'); +end + +key = local_cache_key('map', set_name, ref_obj); +if isKey(image_set_cache, key) + entry = image_set_cache(key); + maps = entry.obj; + map_names = entry.names; + return +end + +if ~exist('load_image_set', 'file') + error('load_image_set not found on path. Add CanlabCore dependencies first.'); +end +[maps, map_names] = load_image_set(char(set_name), 'noverbose'); +map_names = cellstr(string(map_names)); +maps = resample_space(maps, ref_obj); +image_set_cache(key) = struct('obj', maps, 'names', {map_names}); +end + +function key = local_cache_key(kind, name, ref_obj) +parts = {char(kind), char(name), class(ref_obj)}; +try + vi = ref_obj.volInfo; + parts{end + 1} = mat2str(local_volinfo_value(vi, 'dim'), 8); + parts{end + 1} = mat2str(local_volinfo_value(vi, 'mat'), 8); + parts{end + 1} = mat2str(local_volinfo_value(vi, 'nvox'), 8); + parts{end + 1} = mat2str(local_volinfo_value(vi, 'n_inmask'), 8); +catch + parts{end + 1} = sprintf('nvoxels-%d', size(ref_obj.dat, 1)); +end +key = strjoin(parts, '|'); +end + +function value = local_volinfo_value(vi, field_name) +if isstruct(vi) && isfield(vi, field_name) + value = vi.(field_name); +elseif isobject(vi) && isprop(vi, field_name) + value = vi.(field_name); +else + value = []; +end +value = double(value(:)'); +end + +function names = local_clean_signature_names(names) +names = cellstr(string(names)); +names = strrep(names, '-', '_'); +names = strrep(names, ' ', '_'); +names = strrep(names, '.', ''); +names = strrep(names, '(', ''); +names = strrep(names, ')', ''); +names = strrep(names, '^', '_'); +end + +function [obj, metadata_table] = local_get_object(stats_input, which_obj, metadata_table) +if isstruct(stats_input) && isfield(stats_input, 'b') && isfield(stats_input, 't') + switch lower(char(which_obj)) + case {'beta', 'b'} + obj = stats_input.b; + case {'t', 'tmap', 'tmaps'} + obj = stats_input.t; + otherwise + error('Unknown Object: %s. Use ''beta'' or ''t''.', char(which_obj)); + end + if isempty(metadata_table) && isfield(stats_input, 'metadata_table') + metadata_table = stats_input.metadata_table; + end +elseif isa(stats_input, 'image_vector') + obj = stats_input; +elseif ischar(stats_input) || isstring(stats_input) + obj = statistic_image(char(stats_input), 'type', 'generic'); +else + error('Unsupported stats_input type.'); +end +end + +function T = local_base_table(obj, metadata_table, n_images) +if ~isempty(metadata_table) + if height(metadata_table) ~= n_images + error('Metadata row count (%d) does not match number of 4D images (%d). Regenerate matching whole-brain maps/metadata before scoring.', ... + height(metadata_table), n_images); + end + T = metadata_table; +elseif isa(obj, 'statistic_image') && ~isempty(obj.image_labels) && numel(obj.image_labels) == n_images + T = table((1:n_images)', obj.image_labels(:), 'VariableNames', {'volume_index', 'image_label'}); +else + T = table((1:n_images)', 'VariableNames', {'volume_index'}); +end +end + +function c = local_to_cell(x) +if isempty(x) + c = {}; +elseif isa(x, 'image_vector') + c = {x}; +elseif ischar(x) || isstring(x) + c = cellstr(string(x)); +else + c = x; +end +end + +function s = local_context_suffix(ctx) +ctx = char(string(ctx)); +if isempty(strtrim(ctx)) + s = ''; +else + s = sprintf(' [%s]', ctx); +end +end + +function names = local_map_names(maps) +if isprop(maps, 'metadata_table') && ~isempty(maps.metadata_table) && ... + any(strcmp('target', maps.metadata_table.Properties.VariableNames)) + names = cellstr(string(maps.metadata_table.target)); +elseif isprop(maps, 'image_labels') && ~isempty(maps.image_labels) + names = cellstr(string(maps.image_labels)); +elseif ~isempty(maps.image_names) + names = cellstr(string(maps.image_names)); +else + names = arrayfun(@(i) sprintf('map_%03d', i), 1:size(maps.dat, 2), 'UniformOutput', false); +end +end + +function [v, n_inserted] = local_match_length(v, n_images, obj) +v = v(:); +n_inserted = 0; +if numel(v) == n_images + return +end + +empty_images = local_empty_images(obj, n_images); +if numel(v) + sum(empty_images) == n_images + full_v = nan(n_images, 1); + full_v(~empty_images) = v; + v = full_v; + n_inserted = sum(empty_images); + return +end + +error('Map/signature output length (%d) does not match number of 4D volumes (%d), and the mismatch cannot be explained by all-zero/all-NaN maps.', ... + numel(v), n_images); +end + +function empty_images = local_empty_images(obj, n_images) +empty_images = false(n_images, 1); +if ~isprop(obj, 'dat') || isempty(obj.dat) || size(obj.dat, 2) ~= n_images + return +end + +empty_images = all(obj.dat == 0 | isnan(obj.dat), 1)'; + +if isprop(obj, 'removed_images') && ~isempty(obj.removed_images) && ... + numel(obj.removed_images) == n_images + empty_images = empty_images | obj.removed_images(:); +end +end + +function empty_insertions = local_record_empty_insertion(empty_insertions, score_name, n_inserted) +if n_inserted == 0 + return +end +empty_insertions(end + 1) = struct('score', char(score_name), 'n_inserted', n_inserted); +end + +function local_warn_empty_insertions(empty_insertions, warning_context, output_csv) +if isempty(empty_insertions) + return +end + +n_each = [empty_insertions.n_inserted]; +unique_n = unique(n_each); +score_names = {empty_insertions.score}; +n_show = min(numel(score_names), 8); +shown_scores = strjoin(score_names(1:n_show), ', '); +if numel(score_names) > n_show + shown_scores = sprintf('%s, ...', shown_scores); +end + +context = char(warning_context); +if isempty(context) + context = 'context not provided'; +end +if ~isempty(output_csv) + context = sprintf('%s; output_csv=%s', context, char(output_csv)); +end + +warning('hrf_apply_maps_to_wholebrain:ReinsertedEmptyImages', ... + ['%s: Reinserted NaN scores for all-zero/all-NaN 4D volume(s) to preserve metadata alignment. ' ... + '%d score column(s) affected; inserted counts={%s}; first affected columns: %s'], ... + context, numel(empty_insertions), strjoin(cellstr(string(unique_n)), ', '), shown_scores); +end + +function name = local_varname(parts) +parts = cellfun(@char, parts, 'UniformOutput', false); +name = matlab.lang.makeValidName(strjoin(parts, '_')); +end + +function name = local_unique_varname(T, base) +name = base; +if istable(T) + existing = T.Properties.VariableNames; +else + existing = {}; +end +if ~ismember(name, existing) + return +end + +k = 2; +while ismember(sprintf('%s_%d', base, k), existing) + k = k + 1; +end +name = sprintf('%s_%d', base, k); +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_audit_score_freshness.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_audit_score_freshness.m new file mode 100644 index 00000000..cd2529a4 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_audit_score_freshness.m @@ -0,0 +1,216 @@ +function report = hrf_audit_score_freshness(output_dir, varargin) +% Post-run completeness/freshness audit of HRF score CSVs against their maps. +% +% Catches the failure modes a "the job finished" check misses: a score CSV +% that is STALE relative to the map it was scored from (older than the +% beta/t NIfTI -> scored from a since-overwritten fit), score CSVs with an +% inconsistent atlas-column count across subjects (a partial / wrong-config +% scoring), and tasks whose logs report a scoring failure. Run this after +% every SLURM batch before trusting the group plots. +% +% :Usage: +% :: +% report = hrf_audit_score_freshness(output_dir) +% report = hrf_audit_score_freshness(output_dir, 'ScoreObjects', {'beta','t'}) +% +% :Inputs: +% +% **output_dir:** +% folder holding the *__map_scores.csv and *_.nii files +% (the SLURM OutputDir). Multi-model files are *___*. +% +% :Optional Inputs: +% +% **'ScoreObjects':** cellstr, default {'beta','t'}. +% **'LogDir':** SLURM log dir, default /logs. Scanned for +% 'Score failure' / 'Cannot score' lines. +% **'ExpectedAtlasCols':** scalar; if given, CSVs whose atlas-column count +% differs are flagged. If empty, the modal count across +% CSVs is used as the reference. +% **'Verbose' / 'doverbose':** print the summary (default true). +% +% :Outputs: +% +% **report:** +% table, one row per score CSV: object, csv_file, status +% ('fresh' | 'STALE' | 'missing_nii' | 'unreadable'), csv_time, +% nii_time, atlas_cols, atlas_col_flag, and (if logs found) +% a log_scoring_failure flag. +% +% :Examples: +% :: +% report = hrf_audit_score_freshness('/path/to/hrf_outputs'); +% bad = report(report.status~="fresh" | report.atlas_col_flag | report.log_scoring_failure, :); +% assert(isempty(bad), 'Re-score the %d flagged outputs before plotting.', height(bad)); +% +% See also: hrf_audit_slurm_outputs, hrf_score_one_prefix, hrf_apply_maps_to_wholebrain. + +p = inputParser; +p.addRequired('output_dir', @(x) ischar(x) || isstring(x)); +p.addParameter('ScoreObjects', {'beta', 't'}, @(x) ischar(x) || iscell(x) || isstring(x)); +p.addParameter('LogDir', '', @(x) ischar(x) || isstring(x)); +p.addParameter('ExpectedAtlasCols', [], @(x) isempty(x) || isscalar(x)); +p.addParameter('Verbose', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('doverbose', [], @(x) isempty(x) || islogical(x) || isnumeric(x)); +p.parse(output_dir, varargin{:}); +opts = p.Results; +verbose = logical(opts.Verbose); +if ~isempty(opts.doverbose), verbose = logical(opts.doverbose); end + +od = char(output_dir); +objs = cellstr(string(opts.ScoreObjects)); +log_dir = char(opts.LogDir); +if isempty(log_dir), log_dir = fullfile(od, 'logs'); end + +% --- gather scoring-failure task hints from logs (once) --- +log_failures = local_scan_logs(log_dir); + +obj_col = {}; csv_col = {}; status = strings(0, 1); +csv_time = strings(0, 1); nii_time = strings(0, 1); atlas_cols = []; log_fail = false(0, 1); + +for o = 1:numel(objs) + obj = objs{o}; + csvs = dir(fullfile(od, sprintf('*_%s_map_scores.csv', obj))); + for k = 1:numel(csvs) + csv_name = csvs(k).name; + csv_path = fullfile(csvs(k).folder, csv_name); + % matching map: ...__map_scores.csv -> ..._.nii + nii_name = regexprep(csv_name, sprintf('_%s_map_scores\\.csv$', obj), sprintf('_%s.nii', obj)); + nii_path = fullfile(od, nii_name); + nii_d = dir(nii_path); + + ncol = local_atlas_col_count(csv_path); + if isnan(ncol) + st = "unreadable"; ntime = ""; + elseif isempty(nii_d) + st = "missing_nii"; ntime = ""; + else + ntime = local_tstr(nii_d.datenum); + if csvs(k).datenum < nii_d.datenum - 1/1440 % CSV >1 min older than its map + st = "STALE"; + else + st = "fresh"; + end + end + + obj_col{end + 1, 1} = obj; %#ok + csv_col{end + 1, 1} = csv_name; %#ok + status(end + 1, 1) = st; %#ok + csv_time(end + 1, 1) = local_tstr(csvs(k).datenum); %#ok + nii_time(end + 1, 1) = ntime; %#ok + atlas_cols(end + 1, 1) = ncol; %#ok + log_fail(end + 1, 1) = local_csv_failed_in_logs(csv_name, log_failures); %#ok + end +end + +% --- atlas-column consistency --- +ref = opts.ExpectedAtlasCols; +if isempty(ref) + finite_counts = atlas_cols(~isnan(atlas_cols)); + if isempty(finite_counts), ref = NaN; else, ref = mode(finite_counts); end +end +atlas_col_flag = ~isnan(atlas_cols) & atlas_cols ~= ref; + +report = table(obj_col, csv_col, status, csv_time, nii_time, atlas_cols, atlas_col_flag, log_fail, ... + 'VariableNames', {'object', 'csv_file', 'status', 'csv_time', 'nii_time', ... + 'atlas_cols', 'atlas_col_flag', 'log_scoring_failure'}); + +if verbose + n = height(report); + n_stale = sum(report.status == "STALE"); + n_missing = sum(report.status == "missing_nii" | report.status == "unreadable"); + n_atlas = sum(report.atlas_col_flag); + n_log = sum(report.log_scoring_failure); + fprintf('hrf_audit_score_freshness: %d score CSVs | reference atlas cols = %g\n', n, ref); + fprintf(' STALE (older than their map): %d\n', n_stale); + fprintf(' missing/unreadable map or CSV: %d\n', n_missing); + fprintf(' atlas-col count != %g: %d\n', ref, n_atlas); + fprintf(' subject flagged with a scoring failure in logs: %d\n', n_log); + bad = report(report.status ~= "fresh" | report.atlas_col_flag | report.log_scoring_failure, :); + if isempty(bad) + fprintf(' ALL CLEAR -- every score CSV is fresh, consistent, and log-clean.\n'); + else + fprintf(' ---- %d flagged outputs ----\n', height(bad)); + for b = 1:height(bad) + fprintf(' [%s] %s (atlas_cols=%g%s%s)\n', bad.status(b), bad.csv_file{b}, bad.atlas_cols(b), ... + local_tag(bad.atlas_col_flag(b), ' ATLAS-COUNT'), local_tag(bad.log_scoring_failure(b), ' LOG-FAIL')); + end + end +end +end + + +% ========================================================================= +function n = local_atlas_col_count(csv_path) +n = NaN; +fid = fopen(csv_path, 'r'); +if fid < 0, return; end +c = onCleanup(@() fclose(fid)); +hdr = fgetl(fid); +if ischar(hdr) + cols = strsplit(hdr, ','); + n = sum(startsWith(strtrim(cols), 'atlas_')); +end +end + +function failmap = local_scan_logs(log_dir) +% Map run-token -> set of 'model/object' that the worker reported a scoring +% failure for, from the MOST RECENT SLURM job only (highest job id in the +% *__.{out,err} names) so superseded runs don't taint the result. +% Precise (subject,model,object) matching lets us flag only the exact CSVs +% that failed, not every CSV of a subject that had any failure. +failmap = containers.Map('KeyType', 'char', 'ValueType', 'any'); +if exist(log_dir, 'dir') ~= 7, return; end +files = [dir(fullfile(log_dir, '*.out')); dir(fullfile(log_dir, '*.err'))]; +if isempty(files), return; end +jobids = nan(numel(files), 1); +for i = 1:numel(files) + tok = regexp(files(i).name, '_(\d+)_\d+\.(out|err)$', 'tokens', 'once'); + if ~isempty(tok), jobids(i) = str2double(tok{1}); end +end +if any(~isnan(jobids)) + files = files(jobids == max(jobids)); +end +for i = 1:numel(files) + try + txt = fileread(fullfile(files(i).folder, files(i).name)); + catch + continue + end + banner = regexp(txt, 'Running HRF task \d+/\d+:\s*(\S+)\s*\|\s*(\S+)', 'tokens', 'once'); + if isempty(banner), continue; end + runtoken = [banner{1} '_' banner{2}]; + pairs = regexp(txt, 'Score failure for model=(\w+), object=(\w+)', 'tokens'); + if isempty(pairs), continue; end + if isKey(failmap, runtoken), s = failmap(runtoken); else, s = {}; end + for j = 1:numel(pairs) + s{end + 1} = [lower(pairs{j}{1}) '/' lower(pairs{j}{2})]; %#ok + end + failmap(runtoken) = unique(s); +end +end + +function tf = local_csv_failed_in_logs(csv_name, failmap) +% Flag a CSV only if its exact (run-token, model, object) reported a failure. +tf = false; +if isempty(failmap) || ~isa(failmap, 'containers.Map'), return; end +t = regexp(csv_name, '^(.*)_hrf_(\w+)_(beta|t)_map_scores\.csv$', 'tokens', 'once'); +if isempty(t) + t = regexp(csv_name, '^(.*)_hrf_(beta|t)_map_scores\.csv$', 'tokens', 'once'); + if isempty(t), return; end + runtoken = t{1}; key = lower(t{2}); % single-model: object only +else + runtoken = t{1}; key = [lower(t{2}) '/' lower(t{3})]; +end +if ~isKey(failmap, runtoken), return; end +s = failmap(runtoken); +tf = any(strcmp(key, s)) || any(endsWith(s, ['/' key])); +end + +function s = local_tag(flag, label) +if flag, s = label; else, s = ''; end +end + +function s = local_tstr(dn) +s = string(datetime(dn, 'ConvertFrom', 'datenum', 'Format', 'yyyy-MM-dd HH:mm')); +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_audit_slurm_outputs.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_audit_slurm_outputs.m new file mode 100644 index 00000000..c66f81e0 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_audit_slurm_outputs.m @@ -0,0 +1,810 @@ +function [audit, summary] = hrf_audit_slurm_outputs(root_or_manifest, varargin) +%HRF_AUDIT_SLURM_OUTPUTS Reconcile an HRF SLURM manifest with written files. +% +% [audit, summary] = hrf_audit_slurm_outputs(output_dir) +% [audit, summary] = hrf_audit_slurm_outputs(manifest_file, ...) +% +% This audits expected per-task/per-model whole-brain outputs, result MATs, +% map-score CSVs, and SLURM error logs. Use this before collecting +% second-level inputs, because failed jobs may not create any *_beta.nii file +% and therefore will be invisible to hrf_collect_wholebrain_outputs(). + +p = inputParser; +p.addRequired('root_or_manifest', @(x) ischar(x) || isstring(x)); +p.addParameter('ManifestFile', '', @(x) ischar(x) || isstring(x)); +p.addParameter('ConfigMat', '', @(x) ischar(x) || isstring(x)); +p.addParameter('LogDir', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Models', {}, @(x) ischar(x) || iscell(x) || isstring(x)); +p.addParameter('ScoreObjects', {}, @(x) ischar(x) || iscell(x) || isstring(x)); +p.addParameter('RequireScores', [], @(x) isempty(x) || islogical(x) || isnumeric(x)); +p.addParameter('CheckNiftiVolumes', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('OutputCsv', '', @(x) ischar(x) || isstring(x)); +p.addParameter('RepairMissing', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('SignatureSets', {}, @(x) ischar(x) || iscell(x) || isstring(x)); +p.addParameter('ImageSets', {}, @(x) ischar(x) || iscell(x) || isstring(x) || isa(x, 'image_vector')); +p.addParameter('SimilarityMetric', '', @(x) ischar(x) || isstring(x)); +p.addParameter('PropagateSE', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('UseParallel', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('Verbose', false, @(x) islogical(x) || isnumeric(x)); +p.parse(root_or_manifest, varargin{:}); +opts = p.Results; + +[root_dir, manifest_file] = local_resolve_manifest(root_or_manifest, opts.ManifestFile); +config_mat = local_default_file(opts.ConfigMat, fullfile(root_dir, 'hrf_study_config.mat')); +log_dir = local_default_file(opts.LogDir, fullfile(root_dir, 'logs')); + +manifest = local_read_table(manifest_file); +config = local_load_config(config_mat); +models = local_expected_models(config, opts.Models); +score_objects = local_expected_score_objects(config, opts.ScoreObjects); +require_scores = local_require_scores(config, score_objects, opts.RequireScores); +logs = local_read_error_logs(log_dir); + +rows = {}; +for i = 1:height(manifest) + task_index = local_table_number(manifest, i, 'index', i); + subject = local_table_value(manifest, i, 'subject', sprintf('task-%03d', task_index)); + run_label = local_table_value(manifest, i, 'run_label', ''); + fmri_file = local_normalize_path(local_table_value(manifest, i, 'fmri_file', '')); + events_file = local_normalize_path(local_table_value(manifest, i, 'events_file', '')); + output_prefix = local_normalize_path(local_table_value(manifest, i, 'output_prefix', '')); + output_mat = local_normalize_path(local_table_value(manifest, i, 'output_mat', '')); + if isempty(output_mat) && ~isempty(output_prefix) + output_mat = [output_prefix '_results.mat']; + end + + [error_type, error_message, error_log_file] = local_task_error(logs, task_index); + result_mat_exists = exist(output_mat, 'file') == 2; + fmri_exists = exist(fmri_file, 'file') == 2; + events_exists = exist(events_file, 'file') == 2; + + for m = 1:numel(models) + model_name = models{m}; + model_prefix = local_model_prefix(output_prefix, model_name, numel(models)); + beta_file = [model_prefix '_beta.nii']; + t_file = [model_prefix '_t.nii']; + se_file = [model_prefix '_se.nii']; + p_file = [model_prefix '_p.nii']; + metadata_file = [model_prefix '_metadata.csv']; + + beta_exists = exist(beta_file, 'file') == 2; + t_exists = exist(t_file, 'file') == 2; + se_exists = exist(se_file, 'file') == 2; + p_exists = exist(p_file, 'file') == 2; + metadata_exists = exist(metadata_file, 'file') == 2; + + [metadata_rows, metadata_ok, metadata_error] = local_metadata_rows(metadata_file); + [beta_vols, beta_ok, beta_error] = local_nifti_volumes(beta_file, opts.CheckNiftiVolumes); + [t_vols, t_ok, t_error] = local_nifti_volumes(t_file, opts.CheckNiftiVolumes); + [se_vols, se_ok, se_error] = local_nifti_volumes(se_file, opts.CheckNiftiVolumes); + [p_vols, p_ok, p_error] = local_nifti_volumes(p_file, opts.CheckNiftiVolumes); + + if logical(opts.CheckNiftiVolumes) + volume_match = local_rows_match(metadata_rows, [beta_vols t_vols se_vols p_vols]); + else + volume_match = metadata_ok; + end + nifti_ok = local_existing_niftis_ok([beta_exists t_exists se_exists p_exists], [beta_ok t_ok se_ok p_ok]); + + [score_files_exist, score_files_missing, beta_scores_file, t_scores_file] = ... + local_score_status(output_prefix, model_name, numel(models), score_objects); + score_complete = ~require_scores || score_files_exist; + + core_complete = result_mat_exists && beta_exists && t_exists && se_exists && p_exists && ... + metadata_exists && metadata_ok && nifti_ok && volume_match; + complete = core_complete && score_complete; + + failed_reason = local_failed_reason(error_type, result_mat_exists, fmri_exists, events_exists, ... + beta_exists, t_exists, se_exists, p_exists, metadata_exists, metadata_ok, ... + metadata_error, volume_match, require_scores, score_files_missing, ... + beta_error, t_error, se_error, p_error); + + rows(end + 1, :) = {task_index, subject, run_label, model_name, output_prefix, model_prefix, ... + fmri_file, events_file, output_mat, result_mat_exists, fmri_exists, events_exists, ... + beta_file, t_file, se_file, p_file, metadata_file, beta_exists, t_exists, se_exists, p_exists, metadata_exists, ... + metadata_rows, beta_vols, t_vols, se_vols, p_vols, beta_scores_file, t_scores_file, ... + score_complete, score_files_missing, core_complete, complete, ... + error_type, error_message, error_log_file, failed_reason}; %#ok + end +end + +var_names = {'task_index', 'subject', 'run_label', 'model', 'output_prefix', 'model_prefix', ... + 'fmri_file', 'events_file', 'result_mat_file', 'result_mat_exists', 'fmri_exists', 'events_exists', ... + 'beta_file', 't_file', 'se_file', 'p_file', 'metadata_file', ... + 'beta_exists', 't_exists', 'se_exists', 'p_exists', 'metadata_exists', ... + 'metadata_rows', 'beta_vols', 't_vols', 'se_vols', 'p_vols', ... + 'beta_scores_file', 't_scores_file', 'score_complete', 'score_files_missing', ... + 'core_complete', 'complete', 'error_type', 'error_message', 'error_log_file', 'failed_reason'}; + +if isempty(rows) + audit = cell2table(cell(0, numel(var_names)), 'VariableNames', var_names); +else + audit = cell2table(rows, 'VariableNames', var_names); +end + +summary = local_summary(audit, manifest_file, config_mat, log_dir, models, require_scores); + +if logical(opts.RepairMissing) + [audit, repair_summary] = local_repair_missing_scores(audit, config, ... + models, score_objects, opts); + summary = local_summary(audit, manifest_file, config_mat, log_dir, models, require_scores); + summary.repair = repair_summary; +end + +if ~isempty(char(opts.OutputCsv)) + writetable(audit, char(opts.OutputCsv)); +end +end + +% ========================================================================= +% Score-file repair (RepairMissing mode) +% For every audit row where core_complete is true but score_complete is +% not, call hrf_score_one_prefix to backfill the missing CSVs. After +% scoring, re-evaluate score-file presence and update the audit table. +% ========================================================================= +function [audit, repair_summary] = local_repair_missing_scores(audit, config, ... + models, score_objects, opts) + +repair_summary = struct( ... + 'n_rows_attempted', 0, ... + 'n_rows_repaired', 0, ... + 'n_rows_failed', 0, ... + 'n_files_written', 0, ... + 'errors', {{}}, ... + 'skipped_reason', ''); + +signature_sets = local_repair_signature_sets(opts.SignatureSets, config); +image_sets = local_repair_image_sets(opts.ImageSets, config); + +if isempty(signature_sets) && isempty(image_sets) + repair_summary.skipped_reason = ['No SignatureSets or ImageSets provided to RepairMissing ' ... + 'and none recorded in the SLURM config. Nothing to score.']; + warning('hrf_audit_slurm_outputs:NothingToRepair', '%s', repair_summary.skipped_reason); + return +end + +similarity_metric = local_repair_similarity_metric(opts.SimilarityMetric, config); +propagate_se = logical(opts.PropagateSE); +n_models = max(numel(models), 1); + +needs_repair = audit.core_complete & ~audit.score_complete; +row_indices = find(needs_repair); +repair_summary.n_rows_attempted = numel(row_indices); + +if isempty(row_indices) + return +end + +if logical(opts.Verbose) + fprintf('hrf_audit_slurm_outputs: RepairMissing -- attempting %d row(s).\n', numel(row_indices)); +end + +% Pre-compute the per-row argument bundles (this preserves parfor safety: +% parfor cannot slice a table by row, so we marshal each row's relevant +% scalars into a struct first). +arg_bundles = cell(numel(row_indices), 1); +for k = 1:numel(row_indices) + r = row_indices(k); + arg_bundles{k} = struct( ... + 'row', r, ... + 'output_prefix', local_char(audit.output_prefix(r)), ... + 'model_name', local_char(audit.model(r)), ... + 'result_mat_file', local_char(audit.result_mat_file(r)), ... + 'metadata_file', local_char(audit.metadata_file(r)), ... + 'subject', local_char(audit.subject(r)), ... + 'task_index', audit.task_index(r)); +end + +helper_statuses = cell(numel(arg_bundles), 1); +helper_errors = cell(numel(arg_bundles), 1); + +if logical(opts.UseParallel) + parfor k = 1:numel(arg_bundles) + [helper_statuses{k}, helper_errors{k}] = local_repair_one_row( ... + arg_bundles{k}, score_objects, signature_sets, image_sets, ... + similarity_metric, propagate_se, n_models); + end +else + for k = 1:numel(arg_bundles) + [helper_statuses{k}, helper_errors{k}] = local_repair_one_row( ... + arg_bundles{k}, score_objects, signature_sets, image_sets, ... + similarity_metric, propagate_se, n_models); + end +end + +for k = 1:numel(arg_bundles) + r = arg_bundles{k}.row; + s = helper_statuses{k}; + e = helper_errors{k}; + + if ~isempty(s) + repair_summary.n_files_written = repair_summary.n_files_written + numel(s.wrote_files); + for ei = 1:numel(s.errors) + repair_summary.errors{end + 1} = sprintf( ... + 'task %d (%s) object=%s: %s', ... + arg_bundles{k}.task_index, arg_bundles{k}.subject, ... + s.errors(ei).object, s.errors(ei).message); + end + + % Update beta_scores_file / t_scores_file in the audit table from + % whatever the helper produced or verified. + for fi = 1:numel(s.wrote_files) + audit = local_set_audit_scores_path(audit, r, s.wrote_files{fi}); + end + for fi = 1:numel(s.skipped_existing) + audit = local_set_audit_scores_path(audit, r, s.skipped_existing{fi}); + end + end + if ~isempty(e) + repair_summary.errors{end + 1} = sprintf( ... + 'task %d (%s) helper error: %s', ... + arg_bundles{k}.task_index, arg_bundles{k}.subject, e); + end + + % Re-evaluate score-file presence for this row. + [score_files_exist, score_files_missing, beta_scores_file, t_scores_file] = ... + local_score_status(arg_bundles{k}.output_prefix, arg_bundles{k}.model_name, n_models, score_objects); + audit.beta_scores_file{r} = beta_scores_file; + audit.t_scores_file{r} = t_scores_file; + audit.score_complete(r) = score_files_exist; + audit.score_files_missing{r} = score_files_missing; + audit.complete(r) = audit.core_complete(r) && audit.score_complete(r); + audit.failed_reason{r} = local_recompute_failed_reason(audit, r); + + if audit.score_complete(r) && audit.core_complete(r) + repair_summary.n_rows_repaired = repair_summary.n_rows_repaired + 1; + else + repair_summary.n_rows_failed = repair_summary.n_rows_failed + 1; + end +end +end + +function [helper_status, helper_error] = local_repair_one_row(bundle, score_objects, ... + signature_sets, image_sets, similarity_metric, propagate_se, n_models) +helper_status = []; +helper_error = ''; +try + helper_status = hrf_score_one_prefix(bundle.output_prefix, ... + 'ModelName', bundle.model_name, ... + 'NumModels', n_models, ... + 'ScoreObjects', score_objects, ... + 'SignatureSets', signature_sets, ... + 'ImageSets', image_sets, ... + 'SimilarityMetric', similarity_metric, ... + 'PropagateSE', propagate_se, ... + 'MetadataFile', bundle.metadata_file, ... + 'ResultMatFile', bundle.result_mat_file, ... + 'Overwrite', false, ... + 'OverwriteStale', true, ... + 'WarningContext', sprintf('task=%d; subject=%s; repair=true', ... + bundle.task_index, bundle.subject)); +catch err + helper_error = err.message; +end +end + +function audit = local_set_audit_scores_path(audit, row, file_path) +[~, base] = fileparts(char(file_path)); +if endsWith(base, '_beta_map_scores') + audit.beta_scores_file{row} = char(file_path); +elseif endsWith(base, '_t_map_scores') + audit.t_scores_file{row} = char(file_path); +end +end + +function metric = local_repair_similarity_metric(metric_override, config) +metric = char(metric_override); +if ~isempty(metric), return; end +if isstruct(config) && isfield(config, 'similarity_metric') && ~isempty(config.similarity_metric) + metric = char(config.similarity_metric); +else + metric = 'dotproduct'; +end +end + +function sigsets = local_repair_signature_sets(sig_override, config) +if ~isempty(sig_override) + sigsets = sig_override; + return +end +sigsets = local_config_field(config, 'signature_sets', {}); +end + +function image_sets = local_repair_image_sets(image_override, config) +if ~isempty(image_override) + image_sets = image_override; + return +end +image_sets = local_config_field(config, 'image_sets', {}); +end + +function s = local_char(value) +if iscell(value) + if isempty(value), s = ''; return; end + value = value{1}; +end +if isstring(value) + if isempty(value) || ismissing(value) || strlength(value) == 0, s = ''; return; end + s = char(value); +elseif ischar(value) + s = value; +elseif isnumeric(value) + s = num2str(value); +else + try, s = char(string(value)); catch, s = ''; end +end +end + +function reason = local_recompute_failed_reason(audit, row) +parts = {}; +existing = char(audit.error_type{row}); +if ~isempty(existing) + parts{end + 1} = ['log error: ' existing]; +end +if ~audit.result_mat_exists(row), parts{end + 1} = 'missing result MAT'; end +if ~audit.fmri_exists(row), parts{end + 1} = 'missing input fMRI'; end +if ~audit.events_exists(row), parts{end + 1} = 'missing events file'; end +missing = {}; +if ~audit.beta_exists(row), missing{end + 1} = 'beta'; end +if ~audit.t_exists(row), missing{end + 1} = 't'; end +if ~audit.se_exists(row), missing{end + 1} = 'se'; end +if ~audit.p_exists(row), missing{end + 1} = 'p'; end +if ~audit.metadata_exists(row), missing{end + 1} = 'metadata'; end +if ~isempty(missing) + parts{end + 1} = ['missing outputs: ' strjoin(missing, ',')]; +end +if ~audit.score_complete(row) && ~isempty(char(audit.score_files_missing{row})) + parts{end + 1} = ['missing score CSVs: ' char(audit.score_files_missing{row})]; +end +reason = strjoin(parts, '; '); +end + +function [root_dir, manifest_file] = local_resolve_manifest(root_or_manifest, manifest_override) +root_or_manifest = char(root_or_manifest); +if ~isempty(char(manifest_override)) + manifest_file = char(manifest_override); + root_dir = root_or_manifest; +elseif exist(root_or_manifest, 'dir') == 7 + root_dir = root_or_manifest; + manifest_file = fullfile(root_dir, 'hrf_study_manifest.csv'); +else + manifest_file = root_or_manifest; + root_dir = fileparts(manifest_file); + if isempty(root_dir), root_dir = pwd; end +end + +manifest_file = local_normalize_path(manifest_file); +root_dir = local_normalize_path(root_dir); +if exist(manifest_file, 'file') ~= 2 + error('Manifest file not found: %s', manifest_file); +end +end + +function path_out = local_default_file(user_path, default_path) +if isempty(char(user_path)) + path_out = local_normalize_path(default_path); +else + path_out = local_normalize_path(char(user_path)); +end +end + +function T = local_read_table(filename) +try + T = readtable(filename, 'TextType', 'string', 'Delimiter', ',', 'VariableNamingRule', 'preserve'); +catch + T = readtable(filename, 'TextType', 'string', 'Delimiter', ','); +end +end + +function config = local_load_config(config_mat) +config = struct(); +if exist(config_mat, 'file') ~= 2 + return +end +S = load(config_mat, 'config'); +if isfield(S, 'config') + config = S.config; +end +end + +function models = local_expected_models(config, override) +if ~isempty(override) + requested = local_to_cell(override); + model_args = requested; +else + pipeline_args = local_config_field(config, 'pipeline_args', {}); + requested = local_to_cell(local_arg_value(pipeline_args, 'WholeBrainMode')); + model_args = local_to_cell(local_arg_value(pipeline_args, 'Models')); + if isempty(requested), requested = {'auto'}; end + if isempty(model_args) + model_args = {'logit', 'fir', 'sfir', 'canonical', 'spline', 'nlgamma'}; + end +end + +if isscalar(requested) && strcmpi(requested{1}, 'auto') + requested = model_args; +end + +supported = {'fir', 'sfir', 'canonical', 'spline'}; +models = {}; +for i = 1:numel(requested) + name = lower(strtrim(char(requested{i}))); + if strcmp(name, 'auto') + nested = local_expected_models(struct('pipeline_args', {{'Models', model_args, 'WholeBrainMode', 'auto'}}), {}); + models = [models nested]; %#ok + elseif ismember(name, supported) + models{end + 1} = name; %#ok + end +end +models = unique(models, 'stable'); +if isempty(models) + models = {'fir'}; +end +end + +function score_objects = local_expected_score_objects(config, override) +if ~isempty(override) + score_objects = local_to_cell(override); +else + score_objects = local_to_cell(local_config_field(config, 'score_objects', {'beta'})); +end +score_objects = cellfun(@(x) lower(char(x)), score_objects, 'UniformOutput', false); +end + +function tf = local_require_scores(config, score_objects, override) +if ~isempty(override) + tf = logical(override); + return +end +has_signature_sets = ~isempty(local_to_cell(local_config_field(config, 'signature_sets', {}))); +has_image_sets = ~isempty(local_to_cell(local_config_field(config, 'image_sets', {}))); +tf = ~isempty(score_objects) && (has_signature_sets || has_image_sets); +end + +function value = local_config_field(config, field_name, default_value) +if isstruct(config) && isfield(config, field_name) + value = config.(field_name); +else + value = default_value; +end +end + +function value = local_arg_value(args, name) +value = []; +if isempty(args) || ~iscell(args) + return +end +names = args(1:2:end); +idx = find(strcmpi(string(names), string(name)), 1, 'last'); +if ~isempty(idx) + value = args{idx * 2}; +end +end + +function c = local_to_cell(x) +if isempty(x) + c = {}; +elseif iscell(x) + c = x; +elseif isstring(x) + c = cellstr(x); +else + c = {x}; +end +end + +function value = local_table_value(T, row_idx, name, default_value) +col = find(strcmpi(T.Properties.VariableNames, name), 1); +if isempty(col) + value = default_value; + return +end +raw = T{row_idx, col}; +value = local_cell_to_char(raw); +if isempty(value) + value = default_value; +end +end + +function value = local_table_number(T, row_idx, name, default_value) +txt = local_table_value(T, row_idx, name, ''); +value = str2double(txt); +if isnan(value) + raw_col = find(strcmpi(T.Properties.VariableNames, name), 1); + if ~isempty(raw_col) + raw = T{row_idx, raw_col}; + if isnumeric(raw) && isscalar(raw) + value = raw; + return + end + end + value = default_value; +end +end + +function out = local_cell_to_char(raw) +if iscell(raw) + if isempty(raw) || isempty(raw{1}) + out = ''; + return + end + raw = raw{1}; +end +if isa(raw, 'missing') + out = ''; + return +end +try + s = string(raw); + if isempty(s) || ismissing(s(1)) || strlength(s(1)) == 0 + out = ''; + else + out = char(s(1)); + end +catch + out = ''; +end +end + +function path_out = local_normalize_path(path_in) +path_out = char(path_in); +if isunix + path_out = strrep(path_out, '\', filesep); +end +end + +function prefix = local_model_prefix(output_prefix, model_name, n_models) +if n_models == 1 + prefix = output_prefix; +else + prefix = sprintf('%s_%s', output_prefix, lower(char(model_name))); +end +end + +function [metadata_rows, ok, message] = local_metadata_rows(metadata_file) +metadata_rows = NaN; +ok = false; +message = ''; +if exist(metadata_file, 'file') ~= 2 + return +end +try + T = local_read_table(metadata_file); + metadata_rows = height(T); + ok = true; +catch err + message = err.message; +end +end + +function [n_vols, ok, message] = local_nifti_volumes(filename, do_check) +n_vols = NaN; +ok = false; +message = ''; +if exist(filename, 'file') ~= 2 + return +end +if ~logical(do_check) + ok = true; + return +end +try + info = niftiinfo(filename); + if numel(info.ImageSize) >= 4 + n_vols = info.ImageSize(4); + else + n_vols = 1; + end + ok = true; +catch err + message = err.message; +end +end + +function tf = local_existing_niftis_ok(exists_flags, ok_flags) +tf = all(~logical(exists_flags) | logical(ok_flags)); +end + +function tf = local_rows_match(metadata_rows, image_vols) +if isnan(metadata_rows) + tf = false; + return +end +present_vols = image_vols(~isnan(image_vols)); +tf = ~isempty(present_vols) && all(present_vols == metadata_rows); +end + +function [all_exist, missing, beta_scores_file, t_scores_file] = local_score_status(output_prefix, model_name, n_models, score_objects) +all_exist = true; +missing_names = {}; +beta_scores_file = ''; +t_scores_file = ''; +for i = 1:numel(score_objects) + object_name = lower(char(score_objects{i})); + score_file = local_score_file(output_prefix, model_name, n_models, object_name); + if strcmp(object_name, 'beta'), beta_scores_file = score_file; end + if strcmp(object_name, 't'), t_scores_file = score_file; end + if exist(score_file, 'file') ~= 2 + all_exist = false; + missing_names{end + 1} = object_name; %#ok + end +end +missing = strjoin(missing_names, ','); +end + +function score_file = local_score_file(output_prefix, model_name, n_models, object_name) +if n_models == 1 + score_file = sprintf('%s_%s_map_scores.csv', output_prefix, object_name); +else + score_file = sprintf('%s_%s_%s_map_scores.csv', output_prefix, lower(char(model_name)), object_name); +end +end + +function logs = local_read_error_logs(log_dir) +logs = struct('task_index', {}, 'file', {}, 'text', {}); +if exist(log_dir, 'dir') ~= 7 + return +end +files = dir(fullfile(log_dir, '*.err')); +if ~isempty(files) + [~, order] = sort([files.datenum], 'descend'); + files = files(order); +end +for i = 1:numel(files) + tok = regexp(files(i).name, '_(\d+)\.err$', 'tokens', 'once'); + if isempty(tok) + continue + end + task_index = str2double(tok{1}); + path_in = fullfile(files(i).folder, files(i).name); + try + txt = fileread(path_in); + catch + txt = ''; + end + logs(end + 1) = struct('task_index', task_index, 'file', path_in, 'text', txt); %#ok +end +end + +function [error_type, error_message, error_log_file] = local_task_error(logs, task_index) +error_type = ''; +error_message = ''; +error_log_file = ''; +for i = 1:numel(logs) + if logs(i).task_index ~= task_index + continue + end + [candidate_type, candidate_message] = local_classify_error(logs(i).text); + if ~isempty(candidate_type) + error_type = candidate_type; + error_message = candidate_message; + error_log_file = logs(i).file; + return + end +end +end + +function [error_type, error_message] = local_classify_error(txt) +error_type = ''; +error_message = ''; +if isempty(txt) + return +end +if contains(txt, 'Image size does not match header description') + error_type = 'nifti_image_size_mismatch'; +elseif contains(txt, 'zeroinsert') && contains(txt, 'replace_empty') + error_type = 'image_write_removed_images'; +elseif contains(txt, 'Unable to write header') + error_type = 'nifti_write_failed'; +elseif contains(txt, 'Map/signature output length') + error_type = 'mapscore_length_mismatch'; +elseif contains(txt, 'missing metadata') + error_type = 'missing_metadata'; +elseif contains(txt, 'Error using') + error_type = 'matlab_error'; +end +if ~isempty(error_type) + error_message = local_first_error_block(txt); +end +end + +function message = local_first_error_block(txt) +lines = regexp(txt, '\r\n|\n|\r', 'split'); +lines = lines(~contains(lines, 'Unable to load ApplicationService')); +start_idx = find(contains(lines, 'Error using'), 1); +if isempty(start_idx) + patterns = {'Unable to perform assignment', 'Image size does not match header description', ... + 'Unable to write header', 'Out of memory', 'File not found or permission denied'}; + start_idx = []; + for p = 1:numel(patterns) + start_idx = find(contains(lines, patterns{p}), 1); + if ~isempty(start_idx), break; end + end + if isempty(start_idx) + message = strtrim(strjoin(lines(~cellfun('isempty', strtrim(lines))), ' ')); + return + end +end +parts = {}; +for i = start_idx:min(numel(lines), start_idx + 4) + line = strtrim(lines{i}); + if isempty(line) + continue + end + if i > start_idx && startsWith(line, 'Error in') + break + end + parts{end + 1} = line; %#ok +end +message = strjoin(parts, ' '); +end + +function reason = local_failed_reason(error_type, result_mat_exists, fmri_exists, events_exists, ... + beta_exists, t_exists, se_exists, p_exists, metadata_exists, metadata_ok, ... + metadata_error, volume_match, require_scores, score_files_missing, ... + beta_error, t_error, se_error, p_error) +parts = {}; +if ~isempty(error_type) + parts{end + 1} = ['log error: ' error_type]; +end +if ~result_mat_exists, parts{end + 1} = 'missing result MAT'; end +if ~fmri_exists, parts{end + 1} = 'missing input fMRI'; end +if ~events_exists, parts{end + 1} = 'missing events file'; end +missing = {}; +if ~beta_exists, missing{end + 1} = 'beta'; end +if ~t_exists, missing{end + 1} = 't'; end +if ~se_exists, missing{end + 1} = 'se'; end +if ~p_exists, missing{end + 1} = 'p'; end +if ~metadata_exists, missing{end + 1} = 'metadata'; end +if ~isempty(missing) + parts{end + 1} = ['missing outputs: ' strjoin(missing, ',')]; +end +if metadata_exists && ~metadata_ok + parts{end + 1} = ['cannot read metadata: ' metadata_error]; +end +if metadata_ok && ~volume_match + parts{end + 1} = 'metadata/image volume mismatch'; +end +nifti_errors = local_join_nonempty({beta_error, t_error, se_error, p_error}); +if ~isempty(nifti_errors) + parts{end + 1} = ['cannot read output NIfTI header: ' nifti_errors]; +end +if require_scores && ~isempty(score_files_missing) + parts{end + 1} = ['missing score CSVs: ' score_files_missing]; +end +reason = strjoin(parts, '; '); +end + +function out = local_join_nonempty(values) +keep = false(size(values)); +for i = 1:numel(values) + keep(i) = ~isempty(values{i}); +end +out = strjoin(values(keep), ' | '); +end + +function summary = local_summary(audit, manifest_file, config_mat, log_dir, models, require_scores) +summary = struct(); +summary.manifest_file = manifest_file; +summary.config_mat = config_mat; +summary.log_dir = log_dir; +summary.expected_models = models; +summary.require_scores = require_scores; +summary.n_manifest_tasks = numel(unique(audit.task_index)); +summary.n_expected_model_outputs = height(audit); +summary.n_core_complete = sum(audit.core_complete); +summary.n_complete = sum(audit.complete); +summary.n_missing_or_failed = sum(~audit.complete); +summary.n_result_mats = numel(unique(audit.result_mat_file(audit.result_mat_exists))); + +task_ids = unique(audit.task_index); +task_complete = false(size(task_ids)); +for i = 1:numel(task_ids) + task_complete(i) = all(audit.complete(audit.task_index == task_ids(i))); +end +summary.n_complete_tasks = sum(task_complete); +summary.n_missing_or_failed_tasks = sum(~task_complete); + +error_types = audit.error_type(~cellfun('isempty', audit.error_type)); +summary.error_types = unique(error_types); +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_average_condition_trials.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_average_condition_trials.m new file mode 100644 index 00000000..7c1fd7b9 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_average_condition_trials.m @@ -0,0 +1,211 @@ +function out = hrf_average_condition_trials(tc, E, cond_name, TR, window_seconds, varargin) +%HRF_AVERAGE_CONDITION_TRIALS Epoch and average trials for one condition. +% out = hrf_average_condition_trials(tc, E, 'pain', 0.8, 20, ...) +% +% Optional name/value +% 'BaselineSeconds' : seconds before onset used for baseline correction (default 0) +% 'OutlierPolicy' : none, exclude, huber, or bisquare trial weighting +% 'OutlierZThreshold' : robust max-|z| threshold for trial weighting + +p = inputParser; +p.addRequired('tc', @(x) isnumeric(x) && isvector(x)); +p.addRequired('E', @istable); +p.addRequired('cond_name', @(x) ischar(x) || isstring(x) || iscell(x)); +p.addRequired('TR', @(x) isscalar(x) && x > 0); +p.addRequired('window_seconds', @(x) isscalar(x) && x > 0); +p.addParameter('BaselineSeconds', 0, @(x) isscalar(x) && x >= 0); +p.addParameter('OutlierPolicy', 'none', @(x) ischar(x) || isstring(x)); +p.addParameter('OutlierZThreshold', 4, @(x) isscalar(x) && x > 0); +p.parse(tc, E, cond_name, TR, window_seconds, varargin{:}); +opts = p.Results; + +tc = tc(:); +available_conditions = unique(cellstr(string(E.trial_type)), 'stable'); +condition_specs = hrf_resolve_condition_patterns(available_conditions, cond_name, 'DefaultMode', 'all'); +matched_conditions = unique([condition_specs.matched_conditions], 'stable'); +condition_label = local_condition_label(condition_specs); + +if ~ismember('onset', E.Properties.VariableNames) || ~ismember('trial_type', E.Properties.VariableNames) + error('E must contain onset and trial_type columns.'); +end + +n_tp = numel(tc); +win_tp = round(window_seconds / TR) + 1; +base_tp = round(opts.BaselineSeconds / TR); +time = (0:win_tp-1)' * TR; + +idx = ismember(cellstr(string(E.trial_type)), matched_conditions); +onsets = E.onset(idx); +onset_idx = round(onsets ./ TR) + 1; + +valid = onset_idx >= 1 - base_tp & (onset_idx + win_tp - 1) <= n_tp; +onset_idx = onset_idx(valid); + +if isempty(onset_idx) + error('No valid trials found for condition "%s" in selected time window.', condition_label); +end + +trials = nan(numel(onset_idx), win_tp); +for i = 1:numel(onset_idx) + s = onset_idx(i); + segment = tc(s:(s + win_tp - 1)); + + if base_tp > 0 + bstart = max(1, s - base_tp); + bseg = tc(bstart:(s - 1)); + segment = segment - mean(bseg); + end + + trials(i, :) = segment(:)'; +end + +[trial_weights, trial_is_outlier, trial_max_abs_z] = local_trial_weights(trials, ... + opts.OutlierPolicy, opts.OutlierZThreshold); + +out = struct(); +out.condition = condition_label; +out.matched_conditions = matched_conditions; +out.time = time; +out.trials = trials; +if strcmpi(char(opts.OutlierPolicy), 'none') + out.mean = mean(trials, 1)'; + out.sem = std(trials, 0, 1)' ./ sqrt(size(trials, 1)); +else + out.mean = local_weighted_mean(trials, trial_weights)'; + out.sem = local_weighted_sem(trials, trial_weights)'; +end +out.n_trials = size(trials, 1); +out.n_trials_used = sum(trial_weights > 0); +out.trial_weights = trial_weights; +out.trial_is_outlier = trial_is_outlier; +out.trial_max_abs_z = trial_max_abs_z; +out.onset_idx = onset_idx; +end + +function [weights, is_outlier, max_abs_z] = local_trial_weights(trials, policy, threshold) +policy = lower(strtrim(char(policy))); +n = size(trials, 1); +max_abs_z = zeros(n, 1); +is_outlier = false(n, 1); +weights = ones(n, 1); +if strcmp(policy, 'none') || n < 3 + return +end + +center = local_nanmedian(trials, 1); +scale = 1.4826 .* local_nanmedian(abs(trials - repmat(center, n, 1)), 1); +scale_bad = scale == 0 | isnan(scale); +fallback_scale = local_nanstd(trials, 1); +scale(scale_bad) = fallback_scale(scale_bad); +scale(scale == 0 | isnan(scale)) = NaN; +Z = abs((trials - repmat(center, n, 1)) ./ repmat(scale, n, 1)); +for i = 1:n + z = Z(i, :); + z = z(~isnan(z)); + if isempty(z) + max_abs_z(i) = 0; + else + max_abs_z(i) = max(z); + end +end +is_outlier = max_abs_z > threshold; + +switch policy + case {'exclude', 'omit'} + weights(is_outlier) = 0; + case {'huber', 'downweight'} + weights = min(1, threshold ./ max(max_abs_z, eps)); + case {'bisquare', 'tukey'} + u = max_abs_z ./ threshold; + weights = (1 - u .^ 2) .^ 2; + weights(u >= 1) = 0; + otherwise + error('Unknown OutlierPolicy: %s. Use none, exclude, huber, or bisquare.', policy); +end +end + +function m = local_weighted_mean(X, w) +m = nan(1, size(X, 2)); +for j = 1:size(X, 2) + y = X(:, j); + valid = ~isnan(y) & w > 0; + if any(valid) + ww = w(valid); + m(j) = sum(ww .* y(valid)) ./ sum(ww); + end +end +end + +function se = local_weighted_sem(X, w) +se = nan(1, size(X, 2)); +for j = 1:size(X, 2) + y = X(:, j); + valid = ~isnan(y) & w > 0; + if sum(valid) < 2 + continue + end + yy = y(valid); + ww = w(valid); + mu = sum(ww .* yy) ./ sum(ww); + n_eff = (sum(ww) .^ 2) ./ sum(ww .^ 2); + var_w = sum(ww .* (yy - mu) .^ 2) ./ sum(ww); + se(j) = sqrt(var_w ./ max(n_eff, eps)); +end +end + +function m = local_nanmedian(X, dim) +if nargin < 2, dim = 1; end +if dim == 1 + m = nan(1, size(X, 2)); + for j = 1:size(X, 2) + y = X(:, j); + y = y(~isnan(y)); + if ~isempty(y), m(j) = median(y); end + end +else + m = nan(size(X, 1), 1); + for i = 1:size(X, 1) + y = X(i, :); + y = y(~isnan(y)); + if ~isempty(y), m(i) = median(y); end + end +end +end + +function s = local_nanstd(X, dim) +mu = local_nanmean(X, dim); +if dim == 1 + centered = X - repmat(mu, size(X, 1), 1); + n = sum(~isnan(X), 1); + centered(isnan(centered)) = 0; + s = sqrt(sum(centered .^ 2, 1) ./ max(n - 1, 1)); +elseif dim == 2 + centered = X - repmat(mu, 1, size(X, 2)); + n = sum(~isnan(X), 2); + centered(isnan(centered)) = 0; + s = sqrt(sum(centered .^ 2, 2) ./ max(n - 1, 1)); +else + error('local_nanstd supports dim 1 or 2.'); +end +s(n < 2) = NaN; +end + +function m = local_nanmean(X, dim) +valid = ~isnan(X); +den = sum(valid, dim); +X(~valid) = 0; +m = sum(X, dim) ./ den; +m(den == 0) = NaN; +end + +function label = local_condition_label(condition_specs) +if isscalar(condition_specs) + label = condition_specs.display_label; +else + labels = cell(1, numel(condition_specs)); + for i = 1:numel(condition_specs) + labels{i} = condition_specs(i).display_label; + end + label = strjoin(labels, ' + '); +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_build_stick_functions.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_build_stick_functions.m new file mode 100644 index 00000000..adf184e0 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_build_stick_functions.m @@ -0,0 +1,17 @@ +function [Runc, condition_groups] = hrf_build_stick_functions(E, cond_names, TR, n_tp) +%HRF_BUILD_STICK_FUNCTIONS Build one stick function per condition. +available_conditions = unique(cellstr(string(E.trial_type)), 'stable'); +condition_groups = hrf_resolve_condition_patterns(available_conditions, cond_names, 'DefaultMode', 'each'); + +Runc = cell(1, numel(condition_groups)); +trial_type = cellstr(string(E.trial_type)); +for c = 1:numel(condition_groups) + run = zeros(n_tp, 1); + idx = ismember(trial_type, condition_groups(c).matched_conditions); + onsets = E.onset(idx); + onset_idx = round(onsets ./ TR) + 1; + onset_idx = onset_idx(onset_idx >= 1 & onset_idx <= n_tp); + run(onset_idx) = 1; + Runc{c} = run; +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_causality.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_causality.m new file mode 100644 index 00000000..24cedf56 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_causality.m @@ -0,0 +1,537 @@ +function R = hrf_causality(out_dir, varargin) +%HRF_CAUSALITY Systems-level lead/lag from an HRF output dir (IO driver). +% +% End-to-end HRF-informed causality for a study output directory: pulls each +% node's estimated sFIR kernel from the score CSVs, extracts the matching +% node TIMESERIES from the 4-D preprocessed BOLD (signatures via +% pattern-expression, atlas regions via apply_parcellation), deconvolves with +% the per-node kernels, and runs Granger causality with group statistics +% (hrf_causality_analyze). This removes the regional-HRF latency confound that +% otherwise reverses BOLD effective-connectivity directions. +% +% Reads hrf_study_config.mat (written by the SLURM study pipeline) from +% out_dir for the BOLD/events files, run/subject structure, TR, and the +% signature/imageset/atlas specs. +% +% Usage +% ----- +% R = hrf_causality(out_dir) % signatures +% R = hrf_causality(out_dir, 'Unit','atlas', 'Atlas','ppat') % regions +% R = hrf_causality({dir1, dir2}) % POOL two dirs +% R = hrf_causality({lf,obs}, 'Condition','rest_stim') % GC in blocks +% R = hrf_causality(out_dir, 'MaxRuns',2) % quick smoke test +% +% To CONTRAST two task states, run once per condition (or per dir) and pass +% both results to hrf_causality_contrast: +% Rr = hrf_causality({lf,obs}, 'Condition','rest_stim'); +% Rn = hrf_causality({lf,obs}, 'Condition','nback-stimblock'); +% C = hrf_causality_contrast(Rr, Rn); hrf_plot_causality(C); +% +% Inputs +% ------ +% out_dir - a study output directory (with hrf_study_config.mat and +% *_map_scores.csv), OR a cell of such directories whose subjects +% are POOLED (e.g. {lf_distractmap, obs_distractmap} bodysites; the +% same subject id across dirs combines that subject's runs). +% +% Optional (name-value) +% --------------------- +% 'Unit' - 'signature' (default) or 'atlas'. +% 'Condition' - restrict Granger to the event blocks of this trial_type +% (glob ok; e.g. 'rest_stim', 'nback-stimblock'). Each block +% becomes a separate GC realization. Default '' = whole run. +% 'MinSegLen' - drop condition blocks shorter than this many TRs. Default 20. +% 'Atlas' - for Unit='atlas': which atlas token (e.g. 'canlab2024', +% 'ppat'); default = first atlas in the config. +% 'Nodes' - restrict to these node names (region/signature), glob ok. +% Default {} = all nodes of the unit. +% 'KernelModel' - model whose sFIR curve is the deconvolution kernel. +% Default 'sfir'. 'KernelObject' default 'beta'. +% 'KernelCondition' - condition (glob) whose HRF is the kernel; '' (default) +% averages the HRF across conditions (a cleaner, more +% condition-general hemodynamic kernel). +% 'EvokedMode' - 'both' (default) | 'remove' | 'keep' (see analyze). +% 'Conditional' - conditional MVGC (default false = pairwise). +% 'Order','MaxOrder','Nperm','DeconvMethod' - passed through. +% 'MaxRuns' - cap number of runs processed (smoke testing). Default Inf. +% 'Verbose'/'doverbose' - progress chatter (default true). +% +% Output +% ------ +% R - the hrf_causality_analyze struct (per-mode net_group/t/p/p_fdr + +% net_subj), plus .unit, .nodes, .kernel_lags, .run_files. +% +% See also: hrf_causality_analyze, hrf_deconv_timeseries, +% hrf_granger_causality, hrf_apply_maps_to_wholebrain. + +p = inputParser; +p.addRequired('out_dir', @(x) ischar(x) || isstring(x) || iscell(x)); +p.addParameter('Unit', 'signature', @(x) ischar(x) || isstring(x)); +p.addParameter('Atlas', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Nodes', {}, @(x) iscell(x) || isstring(x) || ischar(x)); +p.addParameter('KernelModel', 'sfir', @(x) ischar(x) || isstring(x)); +p.addParameter('KernelObject', 'beta', @(x) ischar(x) || isstring(x)); +p.addParameter('KernelCondition', '', @(x) ischar(x) || isstring(x) || iscell(x)); +p.addParameter('EvokedMode', 'both', @(x) ischar(x) || isstring(x)); +p.addParameter('Conditional', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('Order', 'bic', @(x) (ischar(x) || isstring(x)) || isscalar(x)); +p.addParameter('MaxOrder', 10, @(x) isscalar(x) && x >= 1); +p.addParameter('Nperm', 0, @(x) isscalar(x) && x >= 0); +p.addParameter('Correction', 'fdr', @(x) ischar(x) || isstring(x)); +p.addParameter('GroupNperm', 5000, @(x) isscalar(x) && x >= 100); +p.addParameter('DeconvMethod', 'ridge', @(x) ischar(x) || isstring(x)); +p.addParameter('Condition', '', @(x) ischar(x) || isstring(x) || iscell(x)); +p.addParameter('MinSegLen', 20, @(x) isscalar(x) && x >= 4); +p.addParameter('MaxRuns', Inf, @(x) isscalar(x) && x >= 1); +p.addParameter('ReturnData', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('Verbose', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('doverbose', [], @(x) isempty(x) || islogical(x) || isnumeric(x)); +p.parse(out_dir, varargin{:}); +opts = p.Results; +verbose = logical(opts.Verbose); +if ~isempty(opts.doverbose), verbose = logical(opts.doverbose); end +od_list = local_dir_list(out_dir); +unit = lower(char(opts.Unit)); + +cfgs = cell(1, numel(od_list)); +for di = 1:numel(od_list), cfgs{di} = local_load_config(od_list{di}); end +TR = local_pa(cfgs{1}.pipeline_args, 'TR', NaN); +if ~isfinite(TR), error('hrf_causality:NoTR', 'Could not read TR from config.pipeline_args.'); end + +% ---- 1. kernels from the score CSVs (group-mean sFIR curve per node), pooled across dirs +[kernels, nodes_all, lags] = local_build_kernels(od_list, unit, opts); +[kernels, nodes] = local_select_kernels(kernels, nodes_all, opts.Nodes); +if isempty(nodes), error('hrf_causality:NoNodes', 'No %s nodes after Nodes filter.', unit); end +cond_txt = local_cond_text(opts.Condition); +if verbose + fprintf('hrf_causality: %d %s node(s) over %d dir(s); kernel=%s/%s, %d lags, TR=%.3g; condition=%s\n', ... + numel(nodes), unit, numel(od_list), char(opts.KernelModel), char(opts.KernelObject), numel(lags), TR, cond_txt); +end + +% Fast path: build ONE image_vector holding only the requested node maps, so +% each run applies just those instead of the whole signature+imageset set (44+ +% maps). Huge win when few nodes are needed (e.g. mediation M='NPS' -> 1 map). +% Built once; falls back to the full apply if it can't be assembled. +sig_subset = []; +if strcmp(unit, 'signature') + try + sig_subset = local_node_map_subset(cfgs{1}.signature_sets, cfgs{1}.image_sets, nodes); + catch + sig_subset = []; + end + if verbose && ~isempty(sig_subset) + fprintf(' fast extract: applying %d requested map(s)/run instead of the full set\n', size(sig_subset.dat, 2)); + end +end + +% ---- 2. per-run node timeseries from the 4-D BOLD (across all dirs) ------ +tsRuns = {}; confRuns = {}; segRuns = {}; subjUsed = {}; usedFiles = {}; usedEvents = {}; +total = 0; +for di = 1:numel(od_list) + cfg = cfgs{di}; + fmri_files = cellstr(string(cfg.fmri_files)); + events_files = local_cellstr_or_empty(cfg, 'events_files', numel(fmri_files)); + subjects = cellstr(string(cfg.subject_ids)); + atlas_obj = []; + if strcmp(unit, 'atlas'), atlas_obj = local_pick_atlas(cfg, opts.Atlas); end + for r = 1:numel(fmri_files) + if total >= opts.MaxRuns, break; end + f = fmri_files{r}; + if exist(f, 'file') ~= 2 + warning('hrf_causality:MissingBOLD', 'BOLD not found, skipping: %s', f); continue + end + if verbose, fprintf(' [dir %d run %d] %s\n', di, r, local_short(f)); end + bold = fmri_data(f, 'noverbose'); + ts = local_extract_timeseries(bold, unit, nodes, cfg, atlas_obj, sig_subset); + if isempty(ts), warning('hrf_causality:NoTS', 'No timeseries for %s; skipping.', local_short(f)); continue; end + T = size(ts, 1); + ef = ''; if r <= numel(events_files), ef = events_files{r}; end + tsRuns{end + 1} = ts; %#ok + confRuns{end + 1} = local_events_design(events_files, r, T, TR); %#ok + segRuns{end + 1} = local_condition_mask(events_files, r, T, TR, opts.Condition); %#ok + subjUsed{end + 1} = subjects{min(r, numel(subjects))}; %#ok + usedFiles{end + 1} = f; %#ok + usedEvents{end + 1} = ef; %#ok + total = total + 1; + end +end +if isempty(tsRuns), error('hrf_causality:NoRuns', 'No runs produced timeseries.'); end + +% Prune nodes missing/constant in ANY run so kernels + ts stay column-aligned. +valid = true(1, numel(nodes)); +for r = 1:numel(tsRuns) + valid = valid & (all(isfinite(tsRuns{r}), 1) & std(tsRuns{r}, 0, 1) > 0); +end +if ~all(valid) && verbose, fprintf(' pruning %d node(s) missing/constant in some run\n', sum(~valid)); end +kernels = kernels(:, valid); nodes = nodes(valid); +for r = 1:numel(tsRuns), tsRuns{r} = tsRuns{r}(:, valid); end +if isempty(nodes), error('hrf_causality:NoValidNodes', 'No nodes valid across all runs.'); end + +% Early exit: hand back the extracted per-run timeseries + kernels + events so +% other methods (e.g. hrf_causality_mediation) can reuse the expensive BOLD +% extraction without re-loading the 4-D data. +if logical(opts.ReturnData) + R = struct('tsRuns', {tsRuns}, 'kernels', kernels, 'nodes', {nodes}, ... + 'subjects', {subjUsed}, 'events_files', {usedEvents}, 'run_files', {usedFiles(:)'}, ... + 'TR', TR, 'dirs', {od_list(:)'}, 'unit', unit, 'kernel_lags', lags); + return +end + +% Warn if a condition was requested but matched no event blocks anywhere. +if ~strcmp(cond_txt, '(whole run)') && ~any(cellfun(@(m) ~isempty(m) && any(m), segRuns)) + warning('hrf_causality:NoConditionBlocks', ... + 'Condition ''%s'' matched no event blocks in any run; check trial_type labels.', cond_txt); +end + +R = hrf_causality_analyze(tsRuns, kernels, ... + 'Subjects', subjUsed, 'Nodes', nodes, 'EvokedMode', opts.EvokedMode, ... + 'Confounds', confRuns, 'Segments', segRuns, 'MinSegLen', opts.MinSegLen, ... + 'Conditional', opts.Conditional, 'Order', opts.Order, 'MaxOrder', opts.MaxOrder, ... + 'Nperm', opts.Nperm, 'DeconvMethod', opts.DeconvMethod, ... + 'Correction', opts.Correction, 'GroupNperm', opts.GroupNperm, 'doverbose', verbose); +R.unit = unit; R.kernel_lags = lags; R.run_files = usedFiles(:); +R.dirs = od_list(:)'; R.condition = cond_txt; +end + + +% ========================================================================= +function cfg = local_load_config(od) +cf = fullfile(od, 'hrf_study_config.mat'); +if exist(cf, 'file') ~= 2 + error('hrf_causality:NoConfig', 'hrf_study_config.mat not found in %s', od); +end +S = load(cf, 'config'); cfg = S.config; +end + + +function L = local_dir_list(x) +% Normalize the out_dir argument to a cellstr list of directories to pool. +if iscell(x) + L = cellfun(@(c) char(string(c)), x, 'uni', 0); +elseif isstring(x) && ~isscalar(x) + L = cellstr(x); +else + L = {char(string(x))}; +end +end + + +function s = local_cond_text(c) +cc = cellstr(string(c)); +cc = cc(~cellfun(@(z) isempty(strtrim(z)), cc)); +if isempty(cc), s = '(whole run)'; else, s = strjoin(cc, '|'); end +end + + +function mask = local_condition_mask(events_files, r, T, TR, condition) +% Logical [T x 1] marking the run frames inside the requested condition's +% event blocks (trial_type glob-matched). [] if no condition / no events -> +% the analyze step then treats the whole run as one segment. +mask = []; +cond = cellstr(string(condition)); +cond = cond(~cellfun(@(z) isempty(strtrim(z)), cond)); +if isempty(cond), return; end +if isempty(events_files) || r > numel(events_files), return; end +ef = events_files{r}; +if isempty(ef) || exist(ef, 'file') ~= 2, return; end +try + E = readtable(ef, 'FileType', 'text', 'Delimiter', '\t', 'TextType', 'string'); +catch + return +end +v = E.Properties.VariableNames; +if ~all(ismember({'onset', 'trial_type'}, v)), return; end +onset = double(E.onset); +dur = zeros(size(onset)); if any(strcmp('duration', v)), dur = double(E.duration); end +tt = cellstr(string(E.trial_type)); +sel = local_node_mask(tt, cond); % glob trial-type match +mask = false(T, 1); +rows = find(sel); +for j = rows(:)' + on = max(1, floor(onset(j) / TR) + 1); + off = min(T, on + max(1, round(dur(j) / TR)) - 1); + if on <= T, mask(on:off) = true; end +end +end + + +function [K, nodes, lags] = local_build_kernels(od, unit, opts) +% Group-mean sFIR curve per node, averaged across the kernel condition(s), +% pooled across all subjects' score CSVs (and all dirs) for the model/object. +prefix = local_unit_prefix(unit); +model = lower(char(opts.KernelModel)); object = lower(char(opts.KernelObject)); +dirs = od; if ~iscell(dirs), dirs = {char(dirs)}; end +csvs = []; +for di = 1:numel(dirs) + cc = dir(fullfile(dirs{di}, sprintf('*_hrf_%s_%s_map_scores.csv', model, object))); + if isempty(cc), cc = dir(fullfile(dirs{di}, sprintf('*_hrf_%s_map_scores.csv', object))); end + csvs = [csvs; cc]; %#ok +end +if isempty(csvs) + error('hrf_causality:NoKernelCsv', 'No %s/%s score CSVs in the given dir(s).', model, object); +end +acc = containers.Map('KeyType', 'char', 'ValueType', 'any'); % node -> running [L x k] curves +lags = []; +for c = 1:numel(csvs) + T = readtable(fullfile(csvs(c).folder, csvs(c).name), 'TextType', 'string'); + v = T.Properties.VariableNames; + if ~any(strcmp('lag_index', v)) && ~any(strcmp('lag_seconds', v)), continue; end + [lagkey, this_lags] = local_lagkey(T); + cond = string(T.condition); + cmask = local_cond_mask(cond, opts.KernelCondition); + cols = v(startsWith(v, prefix) & ~endsWith(v, '_se')); + for i = 1:numel(cols) + nm = local_node_from_col(cols{i}, unit); + curve = local_curve_for_node(T, cols{i}, cmask, lagkey, this_lags); + if isempty(curve), continue; end + if isempty(lags) || numel(this_lags) > numel(lags), lags = this_lags(:); end + if isKey(acc, nm), acc(nm) = [acc(nm), curve(:)]; else, acc(nm) = curve(:); end + end +end +nodes = acc.keys; nodes = nodes(:)'; +L = numel(lags); +K = zeros(L, numel(nodes)); +for n = 1:numel(nodes) + cv = acc(nodes{n}); + cv = local_pad_rows(cv, L); + K(:, n) = mean(cv, 2, 'omitnan'); +end +end + + +function curve = local_curve_for_node(T, col, cmask, lagkey, lags) +% Average the HRF curve over the selected conditions: group by lag, mean. +y = double(T.(col)); +lg = double(T.(lagkey)); +y = y(cmask); lg = lg(cmask); +[ul, ~, gi] = unique(lg, 'stable'); +m = accumarray(gi, y, [], @(x) mean(x, 'omitnan')); +% reorder to ascending lag matching `lags` +[~, ord] = sort(ul); +m = m(ord); uls = ul(ord); +curve = nan(numel(lags), 1); +[tf, loc] = ismember(round(lags(:), 6), round(uls(:), 6)); +curve(tf) = m(loc(tf)); +end + + +function ts = local_extract_timeseries(bold, unit, nodes, cfg, atlas_obj, sig_subset) +% Return [T x numel(nodes)] timeseries in `nodes` order (NaN column if a node +% is unavailable in this run). Columns stay aligned with the kernels; the +% driver prunes any node that is invalid across runs after the loop. +T = size(bold.dat, 2); +ts = nan(T, numel(nodes)); +if strcmp(unit, 'signature') + metric = local_field_or(cfg, 'similarity_metric', 'dotproduct'); + if ~isempty(sig_subset) + % fast path: apply ONLY the requested node maps (named by node) + sc = hrf_apply_maps_to_wholebrain(bold, 'SignatureSets', {}, ... + 'ImageSets', sig_subset, 'SimilarityMetric', metric); + else + sc = hrf_apply_maps_to_wholebrain(bold, ... + 'SignatureSets', cfg.signature_sets, 'ImageSets', cfg.image_sets, ... + 'SimilarityMetric', metric); + end + v = sc.Properties.VariableNames; + for n = 1:numel(nodes) + col = local_match_score_col(v, nodes{n}); + if ~isempty(col), ts(:, n) = double(sc.(col)); end + end +else + parc = apply_parcellation(bold, atlas_obj); + if isstruct(parc) && isfield(parc, 'dat'), P = parc.dat; else, P = parc; end + if size(P, 1) ~= T && size(P, 2) == T, P = P'; end % want [T x regions] + labels = cellstr(string(atlas_obj.labels)); + for n = 1:numel(nodes) + li = find(strcmp(labels, nodes{n}), 1); + if ~isempty(li) && li <= size(P, 2), ts(:, n) = P(:, li); end + end +end +end + + +function obj = local_node_map_subset(sig_sets, image_sets, want) +% Build one image_vector holding just the requested node maps -- searched +% across the signature sets AND imagesets and named by node -- so the extractor +% applies ONLY those maps per run instead of the whole set. Returns [] if none +% of the wanted nodes is a map (caller falls back to the full apply). +obj = []; got = {}; +want = cellstr(string(want)); +setspecs = [cellstr(string(sig_sets)); cellstr(string(image_sets))]; +setspecs = setspecs(~cellfun(@(s) isempty(strtrim(s)), setspecs)); +for s = 1:numel(setspecs) + if all(ismember(lower(want), lower(got))), break; end % already have all + try + [o, nm] = load_image_set(setspecs{s}, 'noverbose'); + catch + continue + end + nm = cellstr(string(nm)); + for w = 1:numel(want) + if any(strcmpi(got, want{w})), continue; end + hit = find(strcmpi(nm, want{w}), 1); + if isempty(hit), continue; end + sub = get_wh_image(o, hit); + if isempty(obj) + obj = sub; + else + try, sub = resample_space(sub, obj); catch, end + obj.dat = [obj.dat, sub.dat]; + end + got{end + 1} = want{w}; %#ok + end +end +if isempty(obj), return; end +try, obj.metadata_table = table(); catch, end +obj.image_names = char(cellstr(string(got))); +end + + +function conf = local_events_design(events_files, r, T, TR) +% Build an unconvolved [T x q] task design (one boxcar per trial_type) for the +% 'remove' evoked mode. Best-effort BIDS events.tsv parsing; [] if unavailable. +conf = []; +if isempty(events_files) || r > numel(events_files), return; end +ef = events_files{r}; +if isempty(ef) || exist(ef, 'file') ~= 2, return; end +try + E = readtable(ef, 'FileType', 'text', 'Delimiter', '\t', 'TextType', 'string'); +catch + return +end +v = E.Properties.VariableNames; +if ~all(ismember({'onset', 'trial_type'}, v)), return; end +onset = double(E.onset); +dur = zeros(size(onset)); if any(strcmp('duration', v)), dur = double(E.duration); end +tt = string(E.trial_type); +utt = unique(tt, 'stable'); +conf = zeros(T, numel(utt)); +for k = 1:numel(utt) + rows = find(tt == utt(k)); + for j = rows(:)' + on = max(1, floor(onset(j) / TR) + 1); + off = min(T, on + max(1, round(dur(j) / TR)) - 1); + if on <= T, conf(on:off, k) = 1; end + end +end +conf = conf(:, any(conf ~= 0, 1)); +end + + +% ========================================================================= +function pfx = local_unit_prefix(unit) +switch unit + case 'signature', pfx = 'sig_'; + case 'atlas', pfx = 'atlas_'; + case 'imageset', pfx = 'map_'; + otherwise, error('hrf_causality:Unit', 'Unit must be signature|atlas|imageset.'); +end +end + +function nm = local_node_from_col(col, unit) +parts = strsplit(col, '_'); +if numel(parts) < 3, nm = col; return; end +switch unit + case 'atlas' + if ismember(parts{end}, {'mean', 'meanL1', 'sum'}), nm = strjoin(parts(3:end-1), '_'); + else, nm = strjoin(parts(3:end), '_'); end + otherwise + nm = strjoin(parts(3:end), '_'); +end +end + +function col = local_match_score_col(v, node) +% Find the sig_/map_ column whose parsed name equals `node`. +col = ''; +cand = v(startsWith(v, 'sig_') | startsWith(v, 'map_')); +cand = cand(~endsWith(cand, '_se')); +for i = 1:numel(cand) + parts = strsplit(cand{i}, '_'); + if numel(parts) >= 3 && strcmp(strjoin(parts(3:end), '_'), node), col = cand{i}; return; end +end +end + +function [lagkey, lags] = local_lagkey(T) +v = T.Properties.VariableNames; +if any(strcmp('lag_seconds', v)), lagkey = 'lag_seconds'; else, lagkey = 'lag_index'; end +lags = unique(double(T.(lagkey)), 'stable'); +lags = sort(lags); +end + +function mask = local_cond_mask(cond, want) +want = cellstr(string(want)); +want = want(~cellfun(@(s) isempty(strtrim(s)), want)); +if isempty(want), mask = true(numel(cond), 1); return; end +mask = false(numel(cond), 1); +for i = 1:numel(want) + pat = strtrim(want{i}); + if any(pat == '*' | pat == '?') + rx = ['^', regexptranslate('wildcard', pat), '$']; + mask = mask | ~cellfun('isempty', regexp(cellstr(cond), rx, 'once')); + else + mask = mask | (cond == string(pat)); + end +end +end + +function M = local_pad_rows(M, L) +if size(M, 1) < L, M = [M; nan(L - size(M, 1), size(M, 2))]; elseif size(M, 1) > L, M = M(1:L, :); end +end + +function [K, nodes] = local_select_kernels(K, nodes_all, want) +% Keep kernel columns whose node name matches `want` (glob ok); empty => all. +keep = local_node_mask(nodes_all, want); +K = K(:, keep); nodes = nodes_all(keep); +end + +function keep = local_node_mask(nodes, want) +want = cellstr(string(want)); +want = want(~cellfun(@(s) isempty(strtrim(s)), want)); +if isempty(want), keep = true(1, numel(nodes)); return; end +keep = false(1, numel(nodes)); +for i = 1:numel(nodes) + for j = 1:numel(want) + pat = strtrim(want{j}); + if any(pat == '*' | pat == '?') + hit = ~isempty(regexp(nodes{i}, ['^', regexptranslate('wildcard', pat), '$'], 'once')); + else + hit = strcmpi(nodes{i}, pat); + end + if hit, keep(i) = true; end + end +end +end + +function atl = local_pick_atlas(cfg, want) +objs = local_field_or(cfg, 'atlas_objs', {}); +names = cellstr(string(local_field_or(cfg, 'atlas_names', {}))); +if isempty(objs) && ~isempty(local_field_or(cfg, 'atlas_obj', [])) + atl = cfg.atlas_obj; return +end +if isempty(objs), error('hrf_causality:NoAtlas', 'No atlas in config for Unit=atlas.'); end +idx = 1; +if ~isempty(want) + hit = find(strcmpi(names, char(want)), 1); + if ~isempty(hit), idx = hit; else, warning('hrf_causality:AtlasName', 'Atlas ''%s'' not in config; using %s.', char(want), names{1}); end +end +atl = objs{idx}; +end + +function val = local_pa(pa, key, default) +val = default; +for i = 1:2:numel(pa) - 1 + if ischar(pa{i}) && strcmpi(pa{i}, key), val = pa{i + 1}; return; end +end +end + +function val = local_field_or(s, f, default) +if isfield(s, f) && ~isempty(s.(f)), val = s.(f); else, val = default; end +end + +function c = local_cellstr_or_empty(cfg, f, n) +if isfield(cfg, f) && ~isempty(cfg.(f)), c = cellstr(string(cfg.(f))); else, c = repmat({''}, 1, n); end +end + +function s = local_short(f) +[~, n, e] = fileparts(char(f)); s = [n e]; +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_causality_analyze.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_causality_analyze.m new file mode 100644 index 00000000..fb8baf79 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_causality_analyze.m @@ -0,0 +1,285 @@ +function R = hrf_causality_analyze(tsRuns, kernels, varargin) +%HRF_CAUSALITY_ANALYZE Deconvolve -> Granger -> group stats (the compute core). +% +% Computation half of the HRF-informed causality pipeline: takes per-run node +% timeseries plus per-node HRF kernels, deconvolves each run, optionally +% removes the task-evoked component, runs Granger causality per subject +% (pooling that subject's runs), and aggregates the directed net-flow across +% subjects with a one-sample t-test (BH-FDR over the off-diagonal). +% +% The IO half -- extracting signature/region timeseries from 4-D BOLD and +% pulling the sFIR kernels from the score CSVs -- lives in hrf_causality, +% which calls this. Kept separate so the inference is unit-testable without +% touching disk. +% +% Usage +% ----- +% R = hrf_causality_analyze(tsRuns, kernels, 'Nodes', names) +% R = hrf_causality_analyze(tsRuns, kernels, 'Subjects', subjvec, ... +% 'EvokedMode','both', 'Confounds', confRuns) +% +% Inputs +% ------ +% tsRuns - cell{1..nRun} of [T x N] node timeseries (one matrix per run; +% same N columns/order in every run). +% kernels - [L x N] per-node HRF kernels (column n deconvolves node n), or +% [L x 1] to use one kernel for all nodes. Sampled at the data TR. +% +% Optional (name-value) +% --------------------- +% 'Subjects' - [nRun x 1] subject id per run (numeric or cellstr). Runs +% with the same id are pooled for that subject's GC. Default: +% all runs = one subject. +% 'Nodes' - N node names. Default n1..nN. +% 'EvokedMode' - 'both' (default) | 'remove' | 'keep'. 'remove' regresses +% Confounds out of the neural proxy before GC (endogenous +% coupling); 'keep' uses the full proxy; 'both' computes each +% and returns them side by side. +% 'Confounds' - cell{1..nRun} of [T x q] regressors removed when the mode +% is 'remove'/'both'. For proxy-space removal these should be +% NEURAL-level (UNconvolved) task regressors. Default {} => +% 'remove' falls back to removing the per-run mean only. +% 'Segments' - cell{1..nRun} of logical [T x 1] masks. When given, each +% run's proxy is split into the contiguous TRUE blocks of its +% mask and each block becomes a separate GC realization, so +% Granger is computed ONLY within that condition's blocks +% (no autoregression across block gaps). This is how +% condition-specific causality is done (e.g. rest_stim vs +% nback blocks). Default {} => whole run. +% 'MinSegLen' - drop condition blocks shorter than this many samples. +% Default 20. +% 'DeconvMethod','Lambda' - passed to hrf_deconv_timeseries. Default ridge. +% 'Conditional','Order','MaxOrder','Nperm' - passed to hrf_granger_causality. +% 'Verbose'/'doverbose' - print a summary (default true). +% +% Output +% ------ +% R - struct: +% .modes cellstr of evoked modes computed +% .(mode) per mode: struct with +% .net_group [N x N] mean across subjects of net flow (gc-gc') +% .t,.p [N x N] one-sample t and p across subjects (diag NaN) +% .p_fdr [N x N] BH-FDR-corrected p over the off-diagonal +% .net_subj [N x N x nSubj] per-subject net-flow matrices +% .nodes, .subjects, .nsubj, .nrun, .order +% +% See also: hrf_deconv_timeseries, hrf_granger_causality, hrf_causality. + +p = inputParser; +p.addRequired('tsRuns', @(x) iscell(x) && ~isempty(x)); +p.addRequired('kernels', @(x) isnumeric(x) && ~isempty(x)); +p.addParameter('Subjects', [], @(x) isempty(x) || isvector(x) || iscell(x)); +p.addParameter('Nodes', {}, @(x) iscell(x) || isstring(x) || ischar(x)); +p.addParameter('EvokedMode', 'both', @(x) ischar(x) || isstring(x)); +p.addParameter('Confounds', {}, @(x) iscell(x)); +p.addParameter('Segments', {}, @(x) iscell(x)); +p.addParameter('MinSegLen', 20, @(x) isscalar(x) && x >= 4); +p.addParameter('DeconvMethod', 'ridge', @(x) ischar(x) || isstring(x)); +p.addParameter('Lambda', [], @(x) isempty(x) || isscalar(x)); +p.addParameter('Conditional', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('Order', 'bic', @(x) (ischar(x) || isstring(x)) || isscalar(x)); +p.addParameter('MaxOrder', 10, @(x) isscalar(x) && x >= 1); +p.addParameter('Nperm', 0, @(x) isscalar(x) && x >= 0); +% group-level multiple-comparison correction over the directed net-flow edges +% (via hrf_group_stats). 'fdr' (default, BH over off-diagonal) | 'permutation' +% (sign-flip max-t FWER, recommended at small n) | 'cluster' | 'none'. +p.addParameter('Correction', 'fdr', @(x) ischar(x) || isstring(x)); +p.addParameter('GroupNperm', 5000, @(x) isscalar(x) && x >= 100); +p.addParameter('Verbose', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('doverbose', [], @(x) isempty(x) || islogical(x) || isnumeric(x)); +p.parse(tsRuns, kernels, varargin{:}); +opts = p.Results; +verbose = logical(opts.Verbose); +if ~isempty(opts.doverbose), verbose = logical(opts.doverbose); end + +nRun = numel(tsRuns); +N = size(tsRuns{1}, 2); +nodes = local_node_names(opts.Nodes, N); + +subj = opts.Subjects; +if isempty(subj), subj = ones(nRun, 1); end +subj = local_to_subject_ids(subj, nRun); +[usubj, ~, sidx] = unique(subj, 'stable'); +nSubj = numel(usubj); + +modes = local_modes(opts.EvokedMode); +have_conf = ~isempty(opts.Confounds); +have_seg = ~isempty(opts.Segments); +minseglen = opts.MinSegLen; + +% Deconvolve every run once (kernel is mode-independent). +proxy = cell(1, nRun); +for r = 1:nRun + proxy{r} = hrf_deconv_timeseries(tsRuns{r}, kernels, ... + 'Method', opts.DeconvMethod, 'Lambda', opts.Lambda); +end + +gargs = {'Nodes', nodes, 'Conditional', opts.Conditional, 'Order', opts.Order, ... + 'MaxOrder', opts.MaxOrder, 'Nperm', opts.Nperm, 'doverbose', false}; + +R = struct('modes', {modes}, 'nodes', string(nodes), 'nsubj', nSubj, 'nrun', nRun, 'order', NaN); +R.subjects = usubj(:)'; +for mi = 1:numel(modes) + mode = modes{mi}; + net_subj = nan(N, N, nSubj); + order_used = nan(1, nSubj); + for s = 1:nSubj + runs_s = find(sidx == s); + Xs = {}; + for q = 1:numel(runs_s) + rr = runs_s(q); + Xr = proxy{rr}; + if ~strcmp(mode, 'keep') + conf = []; + if have_conf && rr <= numel(opts.Confounds), conf = opts.Confounds{rr}; end + Xr = local_remove_evoked(Xr, conf); + end + segmask = []; + if have_seg && rr <= numel(opts.Segments), segmask = opts.Segments{rr}; end + Xs = [Xs, local_segment(Xr, segmask, minseglen)]; %#ok + end + if isempty(Xs), continue; end % no usable blocks for this subject + try + G = hrf_granger_causality(Xs, gargs{:}); + net_subj(:, :, s) = G.net; + order_used(s) = G.order; + catch ge + warning('hrf_causality_analyze:SubjectGCFailed', ... + 'Subject %s GC failed (%s); skipping.', usubj{s}, ge.message); + end + end + R.(mode) = local_group_stats(net_subj, nodes, opts.Correction, opts.GroupNperm); + R.(mode).net_subj = net_subj; + if mi == 1, R.order = round(median(order_used, 'omitnan')); end +end + +if verbose + fprintf('hrf_causality_analyze: N=%d nodes, %d run(s), %d subject(s); modes: %s\n', ... + N, nRun, nSubj, strjoin(modes, ', ')); + for mi = 1:numel(modes) + S = R.(modes{mi}); + [~, ix] = max(S.net_group(:) .* (S.p(:) < 0.05)); + [si, di] = ind2sub([N N], ix); + if isfinite(S.net_group(si, di)) && S.p(si, di) < 0.05 + fprintf(' [%s] strongest sig net flow: %s -> %s (net=%.3f, p=%.2g, p_fdr=%.2g)\n', ... + modes{mi}, nodes{si}, nodes{di}, S.net_group(si, di), S.p(si, di), S.p_fdr(si, di)); + else + fprintf(' [%s] no net flow significant at p<.05\n', modes{mi}); + end + end +end +end + + +% ========================================================================= +function S = local_group_stats(net_subj, nodes, correction, nperm) +% Group inference on the directed net-flow edges via the shared engine +% (permutation FWER / cluster / FDR / none), off-diagonal family. +N = size(net_subj, 1); nSubj = size(net_subj, 3); +if nSubj >= 2 + G = hrf_group_stats(net_subj, 'Mask', ~eye(N), 'Correction', correction, 'Nperm', nperm); + S = struct('net_group', G.est, 't', G.t, 'p', G.p, 'p_fdr', G.p_corr, 'sig', G.sig, ... + 'correction', G.correction, 'nodes', {nodes}); +else + mu = mean(net_subj, 3, 'omitnan'); + S = struct('net_group', mu, 't', nan(N), 'p', nan(N), 'p_fdr', nan(N), ... + 'sig', false(N), 'correction', 'none', 'nodes', {nodes}); +end +end + + +function segs = local_segment(X, mask, minlen) +% Split X [T x N] into the contiguous blocks where mask is true (each >= +% minlen rows). Empty mask => the whole run as a single segment. Each block +% becomes a separate GC realization so no autoregression crosses a gap. +if isempty(mask) + segs = {X}; + return +end +mask = logical(mask(:)); +T = size(X, 1); +if numel(mask) ~= T + L = min(numel(mask), T); mask = mask(1:L); X = X(1:L, :); +end +segs = {}; +d = diff([false; mask; false]); +starts = find(d == 1); +stops = find(d == -1) - 1; +for b = 1:numel(starts) + if stops(b) - starts(b) + 1 >= minlen + segs{end + 1} = X(starts(b):stops(b), :); %#ok + end +end +end + + +function Xc = local_remove_evoked(X, conf) +% Remove the evoked component from each column: regress out [1, conf] (or +% just the mean if no confounds), keep the residual. +T = size(X, 1); +D = ones(T, 1); +if ~isempty(conf) + if size(conf, 1) ~= T + error('hrf_causality_analyze:ConfRows', 'Confound rows (%d) ~= run length (%d).', size(conf, 1), T); + end + D = [D, conf]; +end +Xc = X - D * (D \ X); +end + + +function pfdr = local_fdr(pv) +% Benjamini-Hochberg over the finite off-diagonal entries. +pfdr = nan(size(pv)); +mask = isfinite(pv); +ps = pv(mask); +m = numel(ps); +if m == 0, return; end +[sp, ord] = sort(ps(:)); +adj = sp .* m ./ (1:m)'; +adj = min(1, flipud(cummin(flipud(adj)))); +out = nan(m, 1); out(ord) = adj; +pfdr(mask) = out; +end + + +function modes = local_modes(em) +switch lower(strtrim(char(em))) + case 'both', modes = {'remove', 'keep'}; + case 'remove', modes = {'remove'}; + case 'keep', modes = {'keep'}; + otherwise, error('hrf_causality_analyze:EvokedMode', 'EvokedMode must be both|remove|keep.'); +end +end + + +function names = local_node_names(nodes, N) +if isempty(nodes) + names = arrayfun(@(i) sprintf('n%d', i), 1:N, 'uni', 0); +else + names = cellstr(string(nodes)); + if numel(names) ~= N + error('hrf_causality_analyze:Nodes', 'Nodes has %d names but N=%d.', numel(names), N); + end +end +end + + +function ids = local_to_subject_ids(subj, nRun) +if iscell(subj) || isstring(subj) + ids = cellstr(string(subj)); +else + ids = cellstr(string(subj(:))); +end +if numel(ids) ~= nRun + error('hrf_causality_analyze:Subjects', 'Subjects has %d entries but %d runs.', numel(ids), nRun); +end +end + + +function pcdf = local_tcdf(tval, df) +% Student-t CDF via regularized incomplete beta (no Stats toolbox needed). +x = df ./ (df + tval .^ 2); +ib = 0.5 * betainc(x, df / 2, 0.5); +pcdf = 1 - ib; % for tval >= 0 +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_causality_contrast.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_causality_contrast.m new file mode 100644 index 00000000..21d830bb --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_causality_contrast.m @@ -0,0 +1,185 @@ +function C = hrf_causality_contrast(R1, R2, varargin) +%HRF_CAUSALITY_CONTRAST Contrast directed net-flow between two conditions. +% +% Compares the per-subject directed net-flow of two hrf_causality results and +% returns the group-level difference (R1 - R2) with statistics, in the same +% struct shape hrf_plot_causality consumes. Use it for either kind of +% condition contrast: +% * within-data task states -- rest_stim vs nback-stimblock (each computed +% with hrf_causality(..., 'Condition', ...)); +% * between-directory states -- acceptance vs experience (each computed from +% its own dir set, e.g. {lf_acc,obs_acc} vs {lf_exp,obs_exp}). +% +% Pairing is automatic: if the two results share >=2 subject ids it does a +% PAIRED (within-subject) one-sample t on the per-subject differences (the +% right test when the same people did both conditions); otherwise it falls +% back to an unpaired two-sample (Welch) t. +% +% :Usage: +% :: +% Rr = hrf_causality({lf,obs}, 'Condition','rest_stim'); +% Rn = hrf_causality({lf,obs}, 'Condition','nback-stimblock'); +% C = hrf_causality_contrast(Rr, Rn, 'Label1','rest', 'Label2','nback'); +% hrf_plot_causality(C); % net_group = rest - nback directed flow +% +% :Inputs: +% **R1, R2:** hrf_causality / hrf_causality_analyze result structs (must +% carry .net_subj and .subjects; share at least one node). +% +% :Optional Inputs: +% **'Mode':** evoked mode to contrast ('remove'/'keep'); default = first +% mode present in both. +% **'Paired':** [] auto (default), true (force paired on shared subjects), +% or false (force unpaired two-sample). +% **'Label1'/'Label2':** names for the two conditions (for the title). +% +% :Output: +% **C:** struct with .modes={Mode}, .nodes, .subjects (paired) and +% .(Mode).net_group (R1-R2) / .t / .p / .p_fdr / .net_subj, plus +% .paired, .n, .label1, .label2 -- ready for hrf_plot_causality. +% +% See also: hrf_causality, hrf_causality_analyze, hrf_plot_causality. + +p = inputParser; +p.addRequired('R1', @isstruct); +p.addRequired('R2', @isstruct); +p.addParameter('Mode', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Paired', [], @(x) isempty(x) || islogical(x) || isnumeric(x)); +p.addParameter('Label1', 'cond1', @(x) ischar(x) || isstring(x)); +p.addParameter('Label2', 'cond2', @(x) ischar(x) || isstring(x)); +p.addParameter('Correction', 'fdr', @(x) ischar(x) || isstring(x)); +p.addParameter('GroupNperm', 5000, @(x) isscalar(x) && x >= 100); +p.parse(R1, R2, varargin{:}); +opts = p.Results; + +mode = local_common_mode(R1, R2, opts.Mode); +[A, B, nodes] = local_align(R1, R2, mode); % A,B: [N x N x nSubj_i] on shared nodes +N = numel(nodes); + +s1 = local_subjects(R1, size(A, 3)); +s2 = local_subjects(R2, size(B, 3)); +shared = intersect(s1, s2, 'stable'); +paired = opts.Paired; +if isempty(paired), paired = numel(shared) >= 2; else, paired = logical(paired); end + +if paired + [~, i1] = ismember(shared, s1); + [~, i2] = ismember(shared, s2); + D = A(:, :, i1) - B(:, :, i2); + G = hrf_group_stats(D, 'Mask', ~eye(N), 'Correction', opts.Correction, 'Nperm', opts.GroupNperm); + stats = local_from_G(G, nodes); stats.net_subj = D; + C = local_pack(mode, nodes, stats, true, numel(shared), opts); + C.subjects = shared(:)'; +else + AB = cat(3, A, B); + grp = [ones(1, size(A, 3)), 2 * ones(1, size(B, 3))]; + G = hrf_group_stats(AB, 'Mask', ~eye(N), 'Design', 'twosample', 'Group', grp, ... + 'Correction', opts.Correction, 'Nperm', opts.GroupNperm); + stats = local_from_G(G, nodes); stats.net_subj = []; + C = local_pack(mode, nodes, stats, false, [size(A, 3) size(B, 3)], opts); +end +fprintf('hrf_causality_contrast: %s - %s | %s | %s test | %s\n', ... + char(opts.Label1), char(opts.Label2), mode, ... + local_tern(paired, sprintf('PAIRED n=%d', numel(shared)), ... + sprintf('unpaired n=%d/%d', size(A, 3), size(B, 3))), ... + sprintf('%d nodes', N)); +end + + +% ========================================================================= +function mode = local_common_mode(R1, R2, want) +m1 = local_modes(R1); m2 = local_modes(R2); +common = intersect(m1, m2, 'stable'); +if isempty(common), error('hrf_causality_contrast:NoMode', 'R1 and R2 share no evoked mode.'); end +if ~isempty(char(want)) + if ~ismember(char(want), common) + error('hrf_causality_contrast:Mode', 'Mode ''%s'' not present in both results.', char(want)); + end + mode = char(want); +else + mode = common{1}; +end +end + +function m = local_modes(R) +if isfield(R, 'modes'), m = cellstr(string(R.modes)); +else, m = intersect({'remove', 'keep'}, fieldnames(R)'); end +end + +function [A, B, nodes] = local_align(R1, R2, mode) +n1 = cellstr(string(R1.nodes)); n2 = cellstr(string(R2.nodes)); +nodes = intersect(n1, n2, 'stable'); +if numel(nodes) < 2, error('hrf_causality_contrast:Nodes', 'R1 and R2 share <2 nodes.'); end +A = local_subnet(R1.(mode).net_subj, n1, nodes); +B = local_subnet(R2.(mode).net_subj, n2, nodes); +end + +function S = local_subnet(net_subj, nodes_in, nodes_keep) +[~, loc] = ismember(nodes_keep, nodes_in); +S = net_subj(loc, loc, :); +end + +function subj = local_subjects(R, n) +if isfield(R, 'subjects') && ~isempty(R.subjects) + subj = cellstr(string(R.subjects)); +else + subj = arrayfun(@(i) sprintf('_s%d', i), 1:n, 'uni', 0); % unnamed -> unpaired +end +end + +function stats = local_onesample(D, nodes) +[N, ~, n] = size(D); +mu = mean(D, 3, 'omitnan'); +sd = std(D, 0, 3, 'omitnan'); +se = sd / sqrt(n); +t = mu ./ se; t(se == 0) = 0; +pv = 2 * (1 - local_tcdf(abs(t), n - 1)); +pv(logical(eye(N))) = NaN; +stats = struct('net_group', mu, 't', t, 'p', pv, 'p_fdr', local_fdr(pv), 'nodes', {nodes}); +end + +function stats = local_twosample(A, B, nodes) +N = size(A, 1); +mA = mean(A, 3, 'omitnan'); mB = mean(B, 3, 'omitnan'); +vA = var(A, 0, 3, 'omitnan'); vB = var(B, 0, 3, 'omitnan'); +nA = sum(isfinite(A), 3); nB = sum(isfinite(B), 3); +se = sqrt(vA ./ max(nA, 1) + vB ./ max(nB, 1)); +t = (mA - mB) ./ se; t(se == 0) = 0; +df = (vA ./ max(nA, 1) + vB ./ max(nB, 1)) .^ 2 ./ ... + ((vA ./ max(nA, 1)) .^ 2 ./ max(nA - 1, 1) + (vB ./ max(nB, 1)) .^ 2 ./ max(nB - 1, 1)); +df(~isfinite(df) | df < 1) = 1; +pv = 2 * (1 - local_tcdf(abs(t), df)); +pv(logical(eye(N))) = NaN; +stats = struct('net_group', mA - mB, 't', t, 'p', pv, 'p_fdr', local_fdr(pv), 'nodes', {nodes}); +end + +function stats = local_from_G(G, nodes) +stats = struct('net_group', G.est, 't', G.t, 'p', G.p, 'p_fdr', G.p_corr, ... + 'sig', G.sig, 'nodes', {nodes}); +end + +function C = local_pack(mode, nodes, stats, paired, n, opts) +C = struct('modes', {{mode}}, 'nodes', string(nodes), 'paired', paired, 'n', n, ... + 'label1', char(opts.Label1), 'label2', char(opts.Label2), ... + 'contrast', sprintf('%s - %s', char(opts.Label1), char(opts.Label2))); +C.(mode) = stats; +end + +function pfdr = local_fdr(pv) +pfdr = nan(size(pv)); +mask = isfinite(pv); ps = pv(mask); m = numel(ps); +if m == 0, return; end +[sp, ord] = sort(ps(:)); +adj = sp .* m ./ (1:m)'; +adj = min(1, flipud(cummin(flipud(adj)))); +out = nan(m, 1); out(ord) = adj; pfdr(mask) = out; +end + +function pcdf = local_tcdf(tval, df) +x = df ./ (df + tval .^ 2); +pcdf = 1 - 0.5 * betainc(x, df / 2, 0.5); +end + +function s = local_tern(c, a, b) +if c, s = a; else, s = b; end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_causality_mediation.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_causality_mediation.m new file mode 100644 index 00000000..e12e6dbb --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_causality_mediation.m @@ -0,0 +1,287 @@ +function R = hrf_causality_mediation(source, varargin) +%HRF_CAUSALITY_MEDIATION Trial-level mediation X -> M -> Y on HRF-deconvolved nodes. +% +% Driver for the mediation method: extracts per-trial response amplitudes for +% each node from the HRF-DECONVOLVED BOLD (the same deconvolution used by the +% Granger pipeline), builds the trial-level X, M, Y variables from those node +% amplitudes and/or the BIDS events, and runs multilevel mediation +% (hrf_mediation_analyze). Answers e.g. "does signature/region M mediate the +% effect of stimulus on rating", "does A mediate the effect of stimulus on B", +% or "does A mediate the rest-vs-nback condition effect". +% +% Any of X / M / Y can be: +% * a NODE name (signature/region) -> that node's per-trial amplitude +% * an EVENTS column ('temp','rating',...) -> that column's per-trial value +% * 'condition:' -> 0/1 indicator of trial_type match +% so all outcome types are available through one API. +% +% :Usage: +% :: +% % stimulus intensity -> NPS -> pain rating, pooled over bodysites +% R = hrf_causality_mediation({lf,obs}, 'X','temp', 'M','NPS', 'Y','rating', ... +% 'TrialType','*stimblock*'); +% % does NPS mediate the effect of stim on SIIPS (node -> node)? +% R = hrf_causality_mediation({lf,obs}, 'X','temp','M','NPS','Y','SIIPS'); +% % reuse an extraction prep instead of re-loading BOLD: +% prep = hrf_causality({lf,obs}, 'Unit','signature', 'ReturnData',true); +% R = hrf_causality_mediation(prep, 'X','temp','M','NPS','Y','rating'); +% +% :Inputs: +% **source:** a dir / cell of dirs (passed to hrf_causality for extraction), +% OR a prep struct from hrf_causality(...,'ReturnData',true). +% +% :Required name-value: +% **'X','M','Y':** the three mediation variables (specs as above). +% +% :Optional Inputs: +% **'TrialType':** glob on trial_type selecting which events are the trials +% (the M / amplitude anchor). Use the stimulus BLOCK, e.g. +% 'nback-stimblock' or 'rest_stim' (NOT '*stimblock*', which also +% grabs the zero-duration *_ttl_* markers). Default '' => rows with +% a finite 'rating'. +% **'TrialWindow':** seconds post-onset to average the proxy over for a +% trial's amplitude. Default [] => the event's own duration. +% **'PairWindow':** seconds. When an events column used for X/Y is NaN on the +% trial row (e.g. 'rating', which lives on a separate rating event), +% its value is paired from the nearest event that has it -- the next +% one within PairWindow, else the nearest overall. Default 90. +% **'Amplitude':** 'mean' (default) or 'peak' of the proxy over the window. +% **'DeconvMethod','Lambda':** passed to hrf_deconv_timeseries. +% **'Standardize','Nboot':** passed to hrf_mediation_analyze. +% Extraction passthrough (when source is dirs): 'Unit','Nodes','Atlas', +% 'KernelModel','KernelObject','KernelCondition','MaxRuns'. +% **'Verbose'/'doverbose':** default true. +% +% :Output: +% **R:** the hrf_mediation_analyze struct (a/b/cp/c/ab paths + stats), plus +% .x/.m/.y specs, .trialtype, .dirs. +% +% See also: hrf_mediation_analyze, hrf_causality, hrf_deconv_timeseries. + +p = inputParser; +p.addRequired('source'); +p.addParameter('X', '', @(x) ischar(x) || isstring(x)); +p.addParameter('M', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Y', '', @(x) ischar(x) || isstring(x)); +p.addParameter('TrialType', '', @(x) ischar(x) || isstring(x) || iscell(x)); +p.addParameter('TrialWindow', [], @(x) isempty(x) || (isscalar(x) && x > 0)); +p.addParameter('PairWindow', 90, @(x) isscalar(x) && x > 0); +p.addParameter('Amplitude', 'mean', @(x) ischar(x) || isstring(x)); +p.addParameter('DeconvMethod', 'ridge', @(x) ischar(x) || isstring(x)); +p.addParameter('Lambda', [], @(x) isempty(x) || isscalar(x)); +p.addParameter('Standardize', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('Nboot', 5000, @(x) isscalar(x) && x >= 100); +p.addParameter('Correction', 'none', @(x) ischar(x) || isstring(x)); +% extraction passthrough +p.addParameter('Unit', 'signature', @(x) ischar(x) || isstring(x)); +p.addParameter('Nodes', {}, @(x) iscell(x) || isstring(x) || ischar(x)); +p.addParameter('Atlas', '', @(x) ischar(x) || isstring(x)); +p.addParameter('KernelModel', 'sfir', @(x) ischar(x) || isstring(x)); +p.addParameter('KernelObject', 'beta', @(x) ischar(x) || isstring(x)); +p.addParameter('KernelCondition', '', @(x) ischar(x) || isstring(x) || iscell(x)); +p.addParameter('MaxRuns', Inf, @(x) isscalar(x) && x >= 1); +p.addParameter('Verbose', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('doverbose', [], @(x) isempty(x) || islogical(x) || isnumeric(x)); +p.parse(source, varargin{:}); +opts = p.Results; +verbose = logical(opts.Verbose); +if ~isempty(opts.doverbose), verbose = logical(opts.doverbose); end +if isempty(char(opts.X)) || isempty(char(opts.M)) || isempty(char(opts.Y)) + error('hrf_causality_mediation:Specs', 'X, M and Y must all be specified.'); +end + +% ---- 1. obtain the extraction prep (proxy-ready timeseries) -------------- +if isstruct(source) && isfield(source, 'tsRuns') + prep = source; +else + % Restrict extraction to only the node-valued X/M/Y specs (events columns + % like temp/rating and 'condition:' specs are resolved later, not extracted) + % so the BOLD apply is done for just those maps -- a large speedup. + ext_nodes = {}; + for spc = {char(opts.X), char(opts.M), char(opts.Y)} + if ~startsWith(spc{1}, 'condition:'), ext_nodes{end + 1} = spc{1}; end %#ok + end + if ~isempty(opts.Nodes), ext_nodes = [cellstr(string(opts.Nodes)); ext_nodes(:)]; end + ext_nodes = unique(ext_nodes, 'stable'); + prep = hrf_causality(source, 'ReturnData', true, 'Unit', opts.Unit, 'Nodes', ext_nodes, ... + 'Atlas', opts.Atlas, 'KernelModel', opts.KernelModel, 'KernelObject', opts.KernelObject, ... + 'KernelCondition', opts.KernelCondition, 'MaxRuns', opts.MaxRuns, 'doverbose', verbose); +end +nodes = cellstr(string(prep.nodes)); +TR = prep.TR; +nrun = numel(prep.tsRuns); + +% ---- 2. per-run: deconvolve, then per-trial node amplitudes ------------- +subj_amp = containers.Map('KeyType', 'char', 'ValueType', 'any'); % subject -> bundle +for r = 1:nrun + proxy = hrf_deconv_timeseries(prep.tsRuns{r}, prep.kernels, ... + 'Method', opts.DeconvMethod, 'Lambda', opts.Lambda); + b = local_trial_bundle(prep.events_files{r}, proxy, nodes, TR, opts); + if isempty(b.amp), continue; end + sid = char(string(prep.subjects{r})); + if isKey(subj_amp, sid), subj_amp(sid) = local_cat_bundle(subj_amp(sid), b); + else, subj_amp(sid) = b; end +end +sids = subj_amp.keys; +if isempty(sids), error('hrf_causality_mediation:NoTrials', 'No trials extracted (check TrialType / events).'); end + +% ---- 3. resolve X/M/Y to per-subject trial vectors ---------------------- +Xc = {}; Mc = {}; Yc = {}; +for s = 1:numel(sids) + b = subj_amp(sids{s}); + x = local_resolve(opts.X, b, nodes); + m = local_resolve(opts.M, b, nodes); + y = local_resolve(opts.Y, b, nodes); + Xc{end + 1} = x; Mc{end + 1} = m; Yc{end + 1} = y; %#ok +end + +% ---- 4. mediation ------------------------------------------------------- +R = hrf_mediation_analyze(Xc, Mc, Yc, 'Names', {char(opts.X), char(opts.M), char(opts.Y)}, ... + 'Standardize', opts.Standardize, 'Nboot', opts.Nboot, 'Correction', opts.Correction, ... + 'doverbose', verbose); +R.x = char(opts.X); R.m = char(opts.M); R.y = char(opts.Y); +R.trialtype = char(string(opts.TrialType)); +if isfield(prep, 'dirs'), R.dirs = prep.dirs; end +end + + +% ========================================================================= +function b = local_trial_bundle(ef, proxy, nodes, TR, opts) +% Per-trial node amplitudes + passthrough events columns for one run. +b = struct('amp', [], 'trial_type', strings(0, 1), 'cols', struct()); +if isempty(ef) || exist(ef, 'file') ~= 2, return; end +try + E = readtable(ef, 'FileType', 'text', 'Delimiter', '\t', 'TextType', 'string'); +catch + return +end +v = E.Properties.VariableNames; +if ~any(strcmp('onset', v)), return; end +onset = double(E.onset); +dur = zeros(size(onset)); if any(strcmp('duration', v)), dur = double(E.duration); end +tt = strings(numel(onset), 1); if any(strcmp('trial_type', v)), tt = string(E.trial_type); end + +sel = local_trial_select(E, tt, opts.TrialType); +rows = find(sel); +if isempty(rows), return; end + +T = size(proxy, 1); N = numel(nodes); +amp = nan(numel(rows), N); +use_peak = strcmpi(char(opts.Amplitude), 'peak'); +for i = 1:numel(rows) + j = rows(i); + on = max(1, floor(onset(j) / TR) + 1); + if isempty(opts.TrialWindow), w = max(dur(j), TR); else, w = opts.TrialWindow; end + off = min(T, on + max(1, round(w / TR)) - 1); + seg = proxy(on:off, :); + if isempty(seg), continue; end + if use_peak + [~, pk] = max(abs(seg), [], 1); + amp(i, :) = seg(sub2ind(size(seg), pk, 1:N)); + else + amp(i, :) = mean(seg, 1, 'omitnan'); + end +end +b.amp = amp; +b.trial_type = tt(rows); +b.cols = struct(); +% Per-trial value of every numeric events column, PAIRED: use the trial row's +% own value if finite; otherwise take the nearest event (preferring the next +% one within PairWindow s) that has a finite value. This is what links a heat +% trial to its rating, which is recorded on a separate 'rating' event. +for k = 1:numel(v) + nm = v{k}; + if ismember(nm, {'onset', 'duration', 'trial_type'}), continue; end + col = E.(nm); + if ~isnumeric(col), continue; end + b.cols.(matlab.lang.makeValidName(nm)) = local_paired_col(double(col), onset, rows, opts.PairWindow); +end +end + + +function vals = local_paired_col(colall, onset_all, rows, pairwin) +% For each anchor trial: its own value if finite, else the value of the +% nearest event with a finite value -- preferring the next event within +% pairwin seconds (e.g. the rating that follows the stimulus), else the +% nearest one overall. +fin = find(isfinite(colall)); +vals = nan(numel(rows), 1); +for i = 1:numel(rows) + j = rows(i); + if isfinite(colall(j)), vals(i) = colall(j); continue; end + if isempty(fin), continue; end + d = onset_all(fin) - onset_all(j); + foll = fin(d >= -1e-6 & d <= pairwin); + if ~isempty(foll) + [~, mi] = min(onset_all(foll) - onset_all(j)); + vals(i) = colall(foll(mi)); + else + [~, mi] = min(abs(onset_all(fin) - onset_all(j))); + vals(i) = colall(fin(mi)); + end +end +end + + +function a = local_cat_bundle(a, b) +a.amp = [a.amp; b.amp]; +a.trial_type = [a.trial_type; b.trial_type]; +fn = union(fieldnames(a.cols), fieldnames(b.cols)); +for k = 1:numel(fn) + va = local_field_or_nan(a.cols, fn{k}, size(a.amp, 1) - size(b.amp, 1)); + vb = local_field_or_nan(b.cols, fn{k}, size(b.amp, 1)); + a.cols.(fn{k}) = [va; vb]; +end +end + +function v = local_field_or_nan(s, f, n) +if isfield(s, f), v = s.(f)(:); else, v = nan(n, 1); end +end + + +function v = local_resolve(spec, b, nodes) +spec = char(string(spec)); +ni = find(strcmpi(nodes, spec), 1); +if ~isempty(ni), v = b.amp(:, ni); return; end +if startsWith(spec, 'condition:') + v = double(local_glob(b.trial_type, strtrim(spec(11:end)))); return +end +key = matlab.lang.makeValidName(spec); +if isfield(b.cols, key), v = b.cols.(key)(:); return; end +if isfield(b.cols, spec), v = b.cols.(spec)(:); return; end +if any(local_glob(b.trial_type, spec)) + v = double(local_glob(b.trial_type, spec)); return +end +error('hrf_causality_mediation:Unresolved', ... + 'Could not resolve ''%s'' as a node, events column, or trial_type.', spec); +end + + +function sel = local_trial_select(E, tt, trialtype) +pats = cellstr(string(trialtype)); +pats = pats(~cellfun(@(s) isempty(strtrim(s)), pats)); +if isempty(pats) + % default: rows that have a finite rating (the behavioural trials) + if any(strcmp('rating', E.Properties.VariableNames)) + sel = isfinite(double(E.rating)); + else + sel = true(numel(tt), 1); + end + return +end +sel = false(numel(tt), 1); +for i = 1:numel(pats), sel = sel | local_glob(tt, pats{i}); end +end + + +function tf = local_glob(tt, pat) +tt = cellstr(string(tt)); +pat = strtrim(pat); +if any(pat == '*' | pat == '?') + rx = ['^', regexptranslate('wildcard', pat), '$']; + tf = ~cellfun('isempty', regexp(tt, rx, 'once')); +else + tf = strcmpi(tt, pat); +end +tf = tf(:); +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_collect_wholebrain_outputs.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_collect_wholebrain_outputs.m new file mode 100644 index 00000000..4d1eef96 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_collect_wholebrain_outputs.m @@ -0,0 +1,149 @@ +function T = hrf_collect_wholebrain_outputs(root_dir, varargin) +%HRF_COLLECT_WHOLEBRAIN_OUTPUTS Index HRF 4D outputs for second-level work. +% +% T = hrf_collect_wholebrain_outputs(root_dir) +% +% Looks recursively for files written by hrf_fit_wholebrain_stats: +% *_beta.nii +% *_t.nii +% *_se.nii +% *_p.nii +% *_t_thresh.nii +% *_metadata.csv +% and optional map-score files written by hrf_apply_maps_to_wholebrain: +% *_beta_map_scores.csv +% *_t_map_scores.csv +% and optional result MAT files written by run_hrf_pipeline: +% *_results.mat + +p = inputParser; +p.addRequired('root_dir', @(x) ischar(x) || isstring(x)); +p.addParameter('OutputCsv', '', @(x) ischar(x) || isstring(x)); +p.parse(root_dir, varargin{:}); +opts = p.Results; + +root_dir = char(root_dir); +prefixes = local_output_prefixes(root_dir); + +rows = {}; +for i = 1:numel(prefixes) + prefix = prefixes{i}; + beta_path = [prefix '_beta.nii']; + t_path = [prefix '_t.nii']; + se_path = [prefix '_se.nii']; + p_path = [prefix '_p.nii']; + t_thresh_path = [prefix '_t_thresh.nii']; + metadata_path = [prefix '_metadata.csv']; + beta_scores_path = [prefix '_beta_map_scores.csv']; + t_scores_path = [prefix '_t_map_scores.csv']; + model_name = local_model_from_metadata_or_prefix(metadata_path, prefix); + result_mat_path = local_result_mat_path(prefix, model_name); + + [~, prefix_name] = fileparts(prefix); + subject = local_subject_from_name(prefix_name); + run_label = local_run_label_from_name(prefix_name, subject, model_name); + + rows(end + 1, :) = {subject, run_label, model_name, prefix, local_existing(beta_path), local_existing(t_path), ... + local_existing(se_path), local_existing(p_path), ... + local_existing(t_thresh_path), local_existing(metadata_path), ... + local_existing(beta_scores_path), local_existing(t_scores_path), ... + local_existing(result_mat_path)}; %#ok +end + +var_names = {'subject', 'run_label', 'model', 'prefix', 'beta_file', 't_file', 'se_file', 'p_file', 'thresholded_t_file', ... + 'metadata_file', 'beta_scores_file', 't_scores_file', 'result_mat_file'}; +if isempty(rows) + T = cell2table(cell(0, numel(var_names)), 'VariableNames', var_names); +else + T = cell2table(rows, 'VariableNames', var_names); +end + +if ~isempty(opts.OutputCsv) + writetable(T, char(opts.OutputCsv)); +end +end + +function prefixes = local_output_prefixes(root_dir) +suffixes = {'_beta.nii', '_t.nii', '_se.nii', '_p.nii', '_t_thresh.nii', ... + '_metadata.csv', '_beta_map_scores.csv', '_t_map_scores.csv'}; + +prefixes = {}; +for s = 1:numel(suffixes) + suffix = suffixes{s}; + files = dir(fullfile(root_dir, '**', ['*' suffix])); + for i = 1:numel(files) + path_in = fullfile(files(i).folder, files(i).name); + prefixes{end + 1, 1} = local_strip_suffix(path_in, suffix); %#ok + end +end + +prefixes = unique(prefixes, 'stable'); +prefixes = sort(prefixes); +end + +function prefix = local_strip_suffix(path_in, suffix) +prefix = path_in(1:end - numel(suffix)); +end + +function out = local_existing(path_in) +if exist(path_in, 'file') + out = path_in; +else + out = ''; +end +end + +function subject = local_subject_from_name(name) +tok = regexp(name, '(sub-[A-Za-z0-9]+|SID[0-9]+)', 'tokens', 'once'); +if isempty(tok) + subject = regexprep(name, '_hrf.*$', ''); +else + subject = tok{1}; +end +end + +function run_label = local_run_label_from_name(name, subject, model_name) +run_label = regexprep(name, '_hrf.*$', ''); +if ~isempty(subject) + run_label = regexprep(run_label, ['^' regexptranslate('escape', subject) '_?'], ''); +end +if ~isempty(model_name) + run_label = regexprep(run_label, ['_' regexptranslate('escape', model_name) '$'], ''); +end +if isempty(run_label) + run_label = 'run-unknown'; +end +end + +function model_name = local_model_from_metadata_or_prefix(metadata_path, prefix) +if exist(metadata_path, 'file') == 2 + try + M = readtable(metadata_path, 'TextType', 'string'); + if any(strcmp('mode', M.Properties.VariableNames)) && height(M) > 0 + model_name = lower(char(string(M.mode(1)))); + return + end + catch + end +end + +[~, name] = fileparts(prefix); +tok = regexp(name, '_([A-Za-z]+)$', 'tokens', 'once'); +if ~isempty(tok) && ismember(lower(tok{1}), {'fir', 'sfir', 'canonical', 'spline'}) + model_name = lower(tok{1}); +else + model_name = ''; +end +end + +function result_mat_path = local_result_mat_path(prefix, model_name) +result_mat_path = [prefix '_results.mat']; +if exist(result_mat_path, 'file') == 2 || isempty(model_name) + return +end +base_prefix = regexprep(prefix, ['_' regexptranslate('escape', model_name) '$'], ''); +candidate = [base_prefix '_results.mat']; +if exist(candidate, 'file') == 2 + result_mat_path = candidate; +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_compare_conditions.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_compare_conditions.m new file mode 100644 index 00000000..0d1cef3e --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_compare_conditions.m @@ -0,0 +1,71 @@ +function cmp = hrf_compare_conditions(condA, condB, varargin) +%HRF_COMPARE_CONDITIONS Compare two condition-averaged trial sets. +% cmp = hrf_compare_conditions(condA, condB) +% condA/condB are outputs from hrf_average_condition_trials + +p = inputParser; +p.addRequired('condA', @isstruct); +p.addRequired('condB', @isstruct); +p.addParameter('Alpha', 0.05, @(x) isscalar(x) && x > 0 && x < 1); +% Across-time correction of the two-sample (A vs B) timepoint tests via the +% shared engine: 'none' (default) | 'fdr' | 'permutation' | 'cluster'. When not +% 'none', adds .p_corrected / .significant_corrected. +p.addParameter('Correction', 'none', @(x) ischar(x) || isstring(x)); +p.addParameter('Nperm', 5000, @(x) isscalar(x) && x >= 100); +p.parse(condA, condB, varargin{:}); +opts = p.Results; + +if numel(condA.time) ~= numel(condB.time) + error('Condition windows must have same number of time points.'); +end + +meanA = condA.mean(:); +meanB = condB.mean(:); +diffMean = meanA - meanB; + +% Summary features +[peakA, iA] = max(meanA); +[peakB, iB] = max(meanB); +aucA = trapz(condA.time, meanA); +aucB = trapz(condB.time, meanB); + +% Timepoint-wise statistics (if stats toolbox exists) +pvals = nan(size(meanA)); +tvals = nan(size(meanA)); +for t = 1:numel(meanA) + try + [~, p, ~, stats] = ttest2(condA.trials(:, t), condB.trials(:, t), 'Vartype', 'unequal'); + pvals(t) = p; + tvals(t) = stats.tstat; + catch + % Leave NaN if ttest2 is unavailable + end +end + +cmp = struct(); +cmp.time = condA.time(:); +cmp.conditionA = condA.condition; +cmp.conditionB = condB.condition; +cmp.meanA = meanA; +cmp.meanB = meanB; +cmp.mean_diff = diffMean; +cmp.peak = struct('A', peakA, 'A_time', condA.time(iA), 'B', peakB, 'B_time', condB.time(iB)); +cmp.auc = struct('A', aucA, 'B', aucB, 'diff', aucA - aucB); +cmp.p_value = pvals; +cmp.t_value = tvals; +cmp.significant = pvals < opts.Alpha; +cmp.alpha = opts.Alpha; +cmp.correction = lower(char(opts.Correction)); + +% Across-time multiple-comparison correction of the A-vs-B contrast, via the +% shared permutation engine (two-sample over the pooled trials). +if ~strcmpi(cmp.correction, 'none') && isfield(condA, 'trials') && isfield(condB, 'trials') + A = condA.trials; B = condB.trials; + Dab = [A; B]; + grp = [ones(size(A, 1), 1); 2 * ones(size(B, 1), 1)]; + Cc = hrf_time_correction(Dab, 'Group', grp, 'Correction', cmp.correction, ... + 'Nperm', opts.Nperm, 'Alpha', opts.Alpha); + cmp.p_corrected = Cc.p_corr(:); + cmp.significant_corrected = Cc.sig(:); +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_curve_summaries.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_curve_summaries.m new file mode 100644 index 00000000..11c7cee7 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_curve_summaries.m @@ -0,0 +1,686 @@ +function T = hrf_curve_summaries(source, varargin) +%HRF_CURVE_SUMMARIES Per-curve summary statistics from HRF score CSVs. +% +% Each row in a `*_map_scores.csv` is one (condition, lag) pair, and each +% score column is one signature, image-set map, or atlas region. The +% per-condition time course across lags IS the estimated HRF curve for +% that source. This helper extracts standard shape summaries (peak, +% time-to-peak, AUC, FWHM, onset, offset, duration, ...) per curve so +% curves can be directly compared across conditions, sources, subjects, +% runs, and models without re-deriving the same metrics ad hoc. +% +% Usage +% ----- +% T = hrf_curve_summaries(score_csv_path) +% T = hrf_curve_summaries(score_table) +% T = hrf_curve_summaries(input_table, 'TR', 0.8) +% +% Input dispatch (first arg) +% -------------------------- +% char/string ending in .csv - load that single score CSV +% table with 'volume_index'/'condition'/'lag_seconds' or 'lag_index' +% - treat as a single score table +% table with 'subject', 'model', and beta_scores_file/t_scores_file +% - iterate rows of an input_table from +% hrf_collect_wholebrain_outputs; load and +% summarize every score CSV referenced +% +% Name-value parameters +% --------------------- +% 'Conditions' - cellstr/string; subset to these condition names. +% Default {} (all conditions present in metadata). +% 'Sources' - cellstr/string of column-name patterns (case-sensitive, +% partial-string match). Subsets which score columns +% are summarized. Default {} (all sig_*, map_*, atlas_*). +% 'Objects' - which CSV objects to read from an input_table: +% {'beta','t'} or one of them. Default both. +% 'PeakThreshold'- fraction of |peak| used for onset/offset/FWHM. +% Default 0.5 (i.e., FWHM = full width at half max). +% 'TR' - explicit TR if neither lag_seconds nor lag_index is +% populated. Default NaN. +% 'IncludeNaN' - true to keep empty/all-NaN curves in the output (with +% metrics = NaN). Default false (skip them). +% 'SignificanceAlpha' - p threshold for significance-driven +% onset/offset. Default 0.05. +% 'SignificanceCorrection' - 'none' (default), 'bonferroni', 'fdr'. +% Applied across lags within each curve. +% 'BipolarPeakThreshold' - secondary-polarity peak is counted as a +% separate mode iff its magnitude reaches +% this fraction of |primary peak|. Default +% 0.25. +% 'DefaultDfe' - degrees of freedom used when neither +% dfe nor N columns are in metadata. +% Default Inf (= normal approximation). +% +% Output +% ------ +% T is a long table; one row per (file-of-origin, condition, source) curve: +% subject, run_label, model, object - origin metadata (when known) +% source, source_kind, source_set, source_name +% - 'sig__' parsed +% condition, n_lags, n_finite - condition + curve coverage +% peak_value, peak_lag_seconds, peak_lag_index +% - primary peak (signed +% argmax(|x|)); back-compat +% peak_pos_value, peak_pos_lag_seconds - largest positive value +% peak_neg_value, peak_neg_lag_seconds - largest negative value +% peak_separation_seconds - pos_lag - neg_lag (signed), +% NaN if curve is unimodal +% n_modes - 1 or 2; 2 iff opposite- +% polarity peak reaches +% BipolarPeakThreshold * +% |primary| +% auc - trapezoidal over lag_seconds +% mean_amplitude, sd_amplitude +% fwhm_seconds - full width at half peak +% onset_lag_seconds, offset_lag_seconds, duration_seconds +% - peak-relative, at +% PeakThreshold * |peak| +% n_sig_lags - count of lags with p < +% SignificanceAlpha (NaN if +% no SE column present) +% onset_sig_lag_seconds - first significant lag +% offset_sig_lag_seconds - last significant lag +% duration_sig_seconds - offset_sig - onset_sig +% +% Notes +% ----- +% * peak is the *signed* value at argmax(|x|), so negative responses are +% handled symmetrically with positive responses. +% * AUC is computed with trapz on lag_seconds; if all lags are equally +% spaced this equals mean(x) * (max_lag - min_lag). +% * FWHM/onset/offset use the same |peak|-relative threshold so they're +% self-consistent. For curves with a clean unimodal shape FWHM matches +% the textbook definition; for noisy or multimodal curves FWHM is the +% width of the *first contiguous* above-threshold run around the peak. +% * Score columns are detected by prefix: 'sig_*', 'map_*', 'atlas_*'. +% SE / uncertainty columns (suffix '_se') are excluded. +% +% See also: hrf_score_one_prefix, hrf_score_wholebrain_input_table. + +p = inputParser; +p.addRequired('source'); +p.addParameter('Conditions', {}, @(x) iscell(x) || isstring(x) || ischar(x)); +p.addParameter('Sources', {}, @(x) iscell(x) || isstring(x) || ischar(x)); +p.addParameter('Objects', {'beta', 't'}, @(x) iscell(x) || isstring(x) || ischar(x)); +p.addParameter('PeakThreshold', 0.5, @(x) isscalar(x) && x > 0 && x < 1); +p.addParameter('TR', NaN, @(x) isscalar(x)); +p.addParameter('IncludeNaN', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('SignificanceAlpha', 0.05, @(x) isscalar(x) && x > 0 && x < 1); +p.addParameter('SignificanceCorrection', 'none', @(x) ischar(x) || isstring(x)); +p.addParameter('BipolarPeakThreshold', 0.25, @(x) isscalar(x) && x >= 0 && x < 1); +p.addParameter('DefaultDfe', Inf, @(x) isscalar(x)); +p.parse(source, varargin{:}); +opts = p.Results; + +T = local_empty_summary_table(); + +% Multi-source dispatch: struct array with .label and .table fields. Each +% sub-source is summarized independently; rows get a 'study_label' string +% column appended. Other call shapes fall through to single-source paths +% below and don't add the column (back-compat). +if isstruct(source) && isfield(source, 'label') && ... + (isfield(source, 'table') || isfield(source, 'input_table') || isfield(source, 'source')) + chunks = cell(numel(source), 1); + for i = 1:numel(source) + if isfield(source(i), 'table') + sub_src = source(i).table; + elseif isfield(source(i), 'input_table') + sub_src = source(i).input_table; + else + sub_src = source(i).source; + end + Ti = hrf_curve_summaries(sub_src, varargin{:}); + if ~isempty(Ti) && height(Ti) > 0 + Ti.study_label = repmat(string(source(i).label), height(Ti), 1); + end + chunks{i} = Ti; + end + nonempty = ~cellfun(@isempty, chunks); + if any(nonempty) + T = vertcat(chunks{nonempty}); + end + return +end + +if ischar(source) || isstring(source) + score_table = local_read_csv(char(source)); + rows = local_summaries_one_table(score_table, opts); + rows = local_attach_origin(rows, '', '', '', ''); + T = [T; rows]; + return +end + +if istable(source) + if local_looks_like_input_table(source) + T = local_iterate_input_table(source, opts); + else + rows = local_summaries_one_table(source, opts); + rows = local_attach_origin(rows, '', '', '', ''); + T = [T; rows]; + end + return +end + +error('hrf_curve_summaries:UnknownSource', ... + 'First argument must be a CSV path, a score table, or an input table. Got: %s.', class(source)); +end + + +% ========================================================================= +% Input-table dispatch +% ========================================================================= +function tf = local_looks_like_input_table(t) +v = t.Properties.VariableNames; +has_object_paths = any(ismember(v, {'beta_scores_file', 't_scores_file', 'beta_file', 't_file'})); +tf = has_object_paths && any(ismember(v, {'subject', 'model'})); +end + + +function T = local_iterate_input_table(input_table, opts) +T = local_empty_summary_table(); +object_kinds = lower(cellstr(string(opts.Objects))); + +file_cols = struct('beta', 'beta_scores_file', 't', 't_scores_file'); +for i = 1:height(input_table) + subj = local_get_string(input_table, i, 'subject'); + run = local_get_string(input_table, i, 'run_label'); + model = local_get_string(input_table, i, 'model'); + + for k = 1:numel(object_kinds) + obj = object_kinds{k}; + if ~isfield(file_cols, obj), continue; end + col = file_cols.(obj); + if ~any(strcmp(col, input_table.Properties.VariableNames)), continue; end + path = char(string(input_table.(col)(i))); + if isempty(path) || exist(path, 'file') ~= 2, continue; end + try + score_table = local_read_csv(path); + catch err + warning('hrf_curve_summaries:UnreadableCSV', ... + 'Skipping row %d %s: %s', i, path, err.message); + continue + end + rows = local_summaries_one_table(score_table, opts); + rows = local_attach_origin(rows, subj, run, model, obj); + T = [T; rows]; %#ok + end +end +end + + +% ========================================================================= +% Per-table summarization +% ========================================================================= +function T = local_summaries_one_table(score_table, opts) +T = local_empty_summary_table(); +if isempty(score_table) || height(score_table) == 0 + return +end + +[lag_seconds, lag_index, condition] = local_get_axes(score_table, opts.TR); +if isempty(lag_seconds) || isempty(condition) + warning('hrf_curve_summaries:MissingAxes', ... + 'Score table is missing condition/lag axes; no curves summarized.'); + return +end + +condition_list = local_filter_conditions(condition, opts.Conditions); +source_cols = local_score_columns(score_table, opts.Sources); + +for c = 1:numel(condition_list) + cond = condition_list{c}; + mask = strcmp(condition, cond); + if ~any(mask), continue; end + + sub_lag_s = lag_seconds(mask); + sub_lag_i = lag_index(mask); + [sub_lag_s, ord] = sort(sub_lag_s); + sub_lag_i = sub_lag_i(ord); + + for k = 1:numel(source_cols) + col = source_cols{k}; + vals_raw = score_table.(col)(mask); + vals = double(vals_raw(ord)); + + % Look up SE column for significance-driven onset/offset. + se_vals = []; + se_col = [col '_se']; + if any(strcmp(se_col, score_table.Properties.VariableNames)) + se_raw = score_table.(se_col)(mask); + se_vals = double(se_raw(ord)); + end + + % Degrees of freedom: from metadata.dfe column if present, else + % from metadata.N - 1, else from opts.DefaultDfe (default Inf, + % which collapses tcdf to normal CDF). + dfe = local_resolve_dfe(score_table, mask, opts.DefaultDfe); + + metrics = local_compute_metrics(vals, sub_lag_s, opts.PeakThreshold, ... + se_vals, dfe, opts.SignificanceAlpha, opts.SignificanceCorrection, ... + opts.BipolarPeakThreshold); + if ~opts.IncludeNaN && metrics.n_finite == 0 + continue + end + + [skind, sset, sname] = local_parse_source(col); + row = local_metric_row(col, skind, sset, sname, cond, sub_lag_i, sub_lag_s, metrics); + T = [T; row]; %#ok + end +end +end + + +function [lag_seconds, lag_index, condition] = local_get_axes(score_table, tr_arg) +v = score_table.Properties.VariableNames; +lag_seconds = []; +lag_index = []; +condition = strings(height(score_table), 1); + +if any(strcmp('condition', v)) + condition = string(score_table.condition); +end + +if any(strcmp('lag_seconds', v)) + lag_seconds = double(score_table.lag_seconds); +end +if any(strcmp('lag_index', v)) + lag_index = double(score_table.lag_index); +end + +if isempty(lag_seconds) && ~isempty(lag_index) && ~isnan(tr_arg) + lag_seconds = lag_index * tr_arg; +end +if isempty(lag_index) && ~isempty(lag_seconds) + lag_index = lag_seconds; % best we can do +end +if isempty(lag_seconds) && isempty(lag_index) + return +end +end + + +function conds = local_filter_conditions(condition_vec, requested) +% Match requested patterns against the unique conditions present in the +% data. Patterns containing '*' are treated as MATLAB wildcards (via +% regexptranslate); plain strings use exact match. Returns conditions in +% the order they first appear in condition_vec. +present = unique(cellstr(string(condition_vec)), 'stable'); +if isempty(requested) + conds = present(:)'; + return +end +requested = cellstr(string(requested)); +keep = false(size(present)); +for i = 1:numel(requested) + p = requested{i}; + if contains(p, '*') + rx = ['^', regexptranslate('wildcard', p), '$']; + hit = ~cellfun('isempty', regexp(present, rx, 'once')); + else + hit = strcmp(present, p); + end + keep = keep | hit(:); +end +conds = present(keep)'; +end + + +function cols = local_score_columns(score_table, patterns) +v = score_table.Properties.VariableNames; +score_prefixes = {'sig_', 'map_', 'atlas_'}; +keep = false(1, numel(v)); +for i = 1:numel(v) + name = v{i}; + if endsWith(name, '_se'), continue; end + for p = 1:numel(score_prefixes) + if startsWith(name, score_prefixes{p}) + keep(i) = true; + break + end + end +end +cols = v(keep); + +if ~isempty(patterns) + patterns = cellstr(string(patterns)); + keep2 = false(size(cols)); + for i = 1:numel(cols) + for j = 1:numel(patterns) + if contains(cols{i}, patterns{j}) + keep2(i) = true; + break + end + end + end + cols = cols(keep2); +end +end + + +% ========================================================================= +% Source-name parsing (sig__, map__, atlas___) +% ========================================================================= +function [kind, set_name, source_name] = local_parse_source(col) +kind = ''; +set_name = ''; +source_name = col; + +parts = strsplit(col, '_'); +if numel(parts) < 3, return; end + +switch parts{1} + case {'sig', 'map'} + kind = parts{1}; + set_name = parts{2}; + source_name = strjoin(parts(3:end), '_'); + case 'atlas' + kind = 'atlas'; + set_name = parts{2}; + % atlas columns end in a normalization suffix (_mean, _meanL1, _sum); + % strip it from the source_name so the bare region label is visible. + suffix_tokens = {'mean', 'meanL1', 'sum'}; + if ismember(parts{end}, suffix_tokens) + source_name = strjoin(parts(3:end-1), '_'); + else + source_name = strjoin(parts(3:end), '_'); + end +end +end + + +% ========================================================================= +% Metric computation +% ========================================================================= +function m = local_compute_metrics(vals, lag_seconds, peak_thresh_frac, se_vals, dfe, sig_alpha, sig_correction, bipolar_thresh) +% All-NaN-safe metric computation. New since v0: +% * peak_pos / peak_neg -- always report extrema in each polarity, +% so multi-modal curves (HRF + undershoot) +% surface both modes. peak_value continues +% to be signed argmax(|x|) for back-compat. +% * onset_sig / offset_sig -- if SE present, t = score/se, p from tcdf, +% onset = first lag with p < sig_alpha, +% offset = last. Independent of peak-relative +% threshold, so a noisy curve with no real +% response correctly reports NaN. + +m = struct( ... + 'n_lags', numel(vals), ... + 'n_finite', sum(isfinite(vals)), ... + 'peak_value', NaN, ... + 'peak_lag_seconds', NaN, ... + 'peak_lag_index', NaN, ... + 'peak_pos_value', NaN, ... + 'peak_pos_lag_seconds', NaN, ... + 'peak_neg_value', NaN, ... + 'peak_neg_lag_seconds', NaN, ... + 'peak_separation_seconds', NaN, ... + 'n_modes', 0, ... + 'auc', NaN, ... + 'mean_amplitude', NaN, ... + 'sd_amplitude', NaN, ... + 'fwhm_seconds', NaN, ... + 'onset_lag_seconds', NaN, ... + 'offset_lag_seconds', NaN, ... + 'duration_seconds', NaN, ... + 'n_sig_lags', NaN, ... + 'onset_sig_lag_seconds', NaN, ... + 'offset_sig_lag_seconds', NaN, ... + 'duration_sig_seconds', NaN); + +finite = isfinite(vals); +if ~any(finite) + return +end + +vfin = vals(finite); +lfin = lag_seconds(finite); + +% --- Primary peak (signed argmax(|x|)) --------------------------------- +[~, k_peak_in_fin] = max(abs(vfin)); +m.peak_value = vfin(k_peak_in_fin); +m.peak_lag_seconds = lfin(k_peak_in_fin); +peak_lag_pos = find(finite); +m.peak_lag_index = peak_lag_pos(k_peak_in_fin); + +% --- Bipolar peaks (always report both polarities, then decide n_modes) - +% Use logical indexing (NOT additive masking with Inf -- Inf*0 = NaN in +% IEEE-754, which would inject NaN everywhere when a curve is purely +% single-polarity and break the max/min). +pos_mask = vfin > 0; +neg_mask = vfin < 0; +if any(pos_mask) + pos_vals = vfin(pos_mask); + pos_lags = lfin(pos_mask); + [pv, kpos] = max(pos_vals); + m.peak_pos_value = pv; + m.peak_pos_lag_seconds = pos_lags(kpos); +end +if any(neg_mask) + neg_vals = vfin(neg_mask); + neg_lags = lfin(neg_mask); + [nv, kneg] = min(neg_vals); + m.peak_neg_value = nv; + m.peak_neg_lag_seconds = neg_lags(kneg); +end + +% n_modes: count of polarities whose magnitude reaches bipolar_thresh of +% the primary peak. Pure positive (or pure negative) curves => 1 mode. +primary_mag = abs(m.peak_value); +modes = 0; +if isfinite(m.peak_pos_value) && abs(m.peak_pos_value) >= bipolar_thresh * primary_mag + modes = modes + 1; +end +if isfinite(m.peak_neg_value) && abs(m.peak_neg_value) >= bipolar_thresh * primary_mag + modes = modes + 1; +end +m.n_modes = modes; +if isfinite(m.peak_pos_lag_seconds) && isfinite(m.peak_neg_lag_seconds) && modes == 2 + m.peak_separation_seconds = m.peak_pos_lag_seconds - m.peak_neg_lag_seconds; +end + +% --- Peak-relative onset / offset / FWHM (around primary peak) --------- +if abs(m.peak_value) > 0 + threshold = peak_thresh_frac * abs(m.peak_value); + above = abs(vfin) >= threshold; + [onset_k, offset_k] = local_contiguous_run(above, k_peak_in_fin); + if ~isempty(onset_k) + m.onset_lag_seconds = lfin(onset_k); + m.offset_lag_seconds = lfin(offset_k); + m.duration_seconds = lfin(offset_k) - lfin(onset_k); + if peak_thresh_frac == 0.5 + m.fwhm_seconds = m.duration_seconds; + else + half_above = abs(vfin) >= 0.5 * abs(m.peak_value); + [hk0, hk1] = local_contiguous_run(half_above, k_peak_in_fin); + if ~isempty(hk0) + m.fwhm_seconds = lfin(hk1) - lfin(hk0); + end + end + end +end + +% --- Significance-driven onset / offset (requires SE) ------------------ +if ~isempty(se_vals) + se_fin = se_vals(finite); + valid_t = se_fin > 0 & isfinite(se_fin); + if any(valid_t) + t_lags = NaN(size(vfin)); + p_lags = NaN(size(vfin)); + t_lags(valid_t) = vfin(valid_t) ./ se_fin(valid_t); + p_lags(valid_t) = 2 * (1 - tcdf(abs(t_lags(valid_t)), dfe)); + p_use = p_lags; + switch lower(strtrim(char(sig_correction))) + case {'none', '', 'unc', 'uncorrected'} + % no change + case 'bonferroni' + p_use = min(p_lags * sum(valid_t), 1); + case 'fdr' + p_use = NaN(size(p_lags)); + pv = p_lags(valid_t); + [psort, ord_p] = sort(pv); + n = numel(psort); + ranks = (1:n)'; + fdr_threshold = sig_alpha * ranks / n; + below = psort <= fdr_threshold; + k_max = find(below, 1, 'last'); + p_use_local = ones(size(pv)); + if ~isempty(k_max) + p_use_local(ord_p(1:k_max)) = sig_alpha; % flag as sig + end + p_use(valid_t) = p_use_local; + otherwise + warning('hrf_curve_summaries:UnknownCorrection', ... + 'Unknown SignificanceCorrection: %s. Using none.', char(sig_correction)); + end + sig_mask = p_use < sig_alpha; + m.n_sig_lags = sum(sig_mask); + if any(sig_mask) + first_idx = find(sig_mask, 1, 'first'); + last_idx = find(sig_mask, 1, 'last'); + m.onset_sig_lag_seconds = lfin(first_idx); + m.offset_sig_lag_seconds = lfin(last_idx); + m.duration_sig_seconds = lfin(last_idx) - lfin(first_idx); + end + end +end + +% --- AUC + central tendency -------------------------------------------- +if numel(lfin) >= 2 + [lsort, ord] = sort(lfin); + m.auc = trapz(lsort, vfin(ord)); +end +m.mean_amplitude = mean(vfin); +m.sd_amplitude = std(vfin); +end + + +function dfe = local_resolve_dfe(score_table, mask, default_dfe) +% Pull degrees of freedom from the metadata table (set per scoring run). +% Priority: dfe column -> N - 1 column -> opts.DefaultDfe (Inf collapses +% t-distribution to normal, which is the right behavior at the +% whole-brain map scale where N is large). +v = score_table.Properties.VariableNames; +idx = find(mask, 1); +if any(strcmp('dfe', v)) + val = double(score_table.dfe(idx)); + if isfinite(val) && val > 0 + dfe = val; + return + end +end +if any(strcmp('N', v)) + val = double(score_table.N(idx)); + if isfinite(val) && val > 1 + dfe = val - 1; + return + end +end +dfe = default_dfe; +end + + +function [k0, k1] = local_contiguous_run(above, k_peak) +% Returns the [first, last] index of the contiguous run of true values in +% `above` that contains k_peak. If k_peak is not above-threshold, returns []. +k0 = []; +k1 = []; +if ~above(k_peak), return; end +% scan left +k0 = k_peak; +while k0 > 1 && above(k0 - 1) + k0 = k0 - 1; +end +% scan right +k1 = k_peak; +while k1 < numel(above) && above(k1 + 1) + k1 = k1 + 1; +end +end + + +% ========================================================================= +% Row construction +% ========================================================================= +function T = local_empty_summary_table() +% 31 columns. Keep the type/name lists aligned -- a mismatch raises +% "Number of variable types must match the number of variables". +T = table('Size', [0 31], ... + 'VariableTypes', { ... + 'string','string','string','string', ... % 4 origin + 'string','string','string','string','string', ... % 5 source + condition (string condition) + 'double','double', ... % 2 n_lags, n_finite + 'double','double','double', ... % 3 peak_value/lag/idx + 'double','double','double','double','double', ... % 5 peak_pos, peak_neg, separation + 'double','double','double','double', ... % 4 n_modes, auc, mean, sd + 'double','double','double','double', ... % 4 fwhm, onset, offset, duration (peak-rel) + 'double','double','double','double'}, ... % 4 n_sig_lags + 3 sig timing + 'VariableNames', { ... + 'subject','run_label','model','object', ... + 'source','source_kind','source_set','source_name','condition', ... + 'n_lags','n_finite', ... + 'peak_value','peak_lag_seconds','peak_lag_index', ... + 'peak_pos_value','peak_pos_lag_seconds','peak_neg_value','peak_neg_lag_seconds','peak_separation_seconds', ... + 'n_modes','auc','mean_amplitude','sd_amplitude', ... + 'fwhm_seconds','onset_lag_seconds','offset_lag_seconds','duration_seconds', ... + 'n_sig_lags','onset_sig_lag_seconds','offset_sig_lag_seconds','duration_sig_seconds'}); +end + + +function row = local_metric_row(col, skind, sset, sname, cond, ~, ~, metrics) +% 31 values, 31 column names. Must match local_empty_summary_table. +row = table( ... + string(""), string(""), string(""), string(""), ... + string(col), string(skind), string(sset), string(sname), string(cond), ... + metrics.n_lags, metrics.n_finite, ... + metrics.peak_value, metrics.peak_lag_seconds, metrics.peak_lag_index, ... + metrics.peak_pos_value, metrics.peak_pos_lag_seconds, ... + metrics.peak_neg_value, metrics.peak_neg_lag_seconds, metrics.peak_separation_seconds, ... + metrics.n_modes, metrics.auc, metrics.mean_amplitude, metrics.sd_amplitude, ... + metrics.fwhm_seconds, metrics.onset_lag_seconds, metrics.offset_lag_seconds, metrics.duration_seconds, ... + metrics.n_sig_lags, metrics.onset_sig_lag_seconds, metrics.offset_sig_lag_seconds, metrics.duration_sig_seconds, ... + 'VariableNames', { ... + 'subject','run_label','model','object', ... + 'source','source_kind','source_set','source_name','condition', ... + 'n_lags','n_finite', ... + 'peak_value','peak_lag_seconds','peak_lag_index', ... + 'peak_pos_value','peak_pos_lag_seconds','peak_neg_value','peak_neg_lag_seconds','peak_separation_seconds', ... + 'n_modes','auc','mean_amplitude','sd_amplitude', ... + 'fwhm_seconds','onset_lag_seconds','offset_lag_seconds','duration_seconds', ... + 'n_sig_lags','onset_sig_lag_seconds','offset_sig_lag_seconds','duration_sig_seconds'}); +end + + +function T = local_attach_origin(T, subject, run, model, object) +if height(T) == 0, return; end +T.subject(:) = string(subject); +T.run_label(:) = string(run); +T.model(:) = string(model); +T.object(:) = string(object); +end + + +% ========================================================================= +% I/O helpers +% ========================================================================= +function tbl = local_read_csv(path) +tbl = readtable(char(path), 'TextType', 'string'); +end + + +function s = local_get_string(t, i, col) +s = ''; +if any(strcmp(col, t.Properties.VariableNames)) + val = t.(col)(i); + if isstring(val) || ischar(val) + s = char(val); + elseif iscell(val) + s = char(val{1}); + else + try + s = char(string(val)); + catch + end + end +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_curve_summary_groupstats.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_curve_summary_groupstats.m new file mode 100644 index 00000000..012ea893 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_curve_summary_groupstats.m @@ -0,0 +1,300 @@ +function G = hrf_curve_summary_groupstats(T, varargin) +%HRF_CURVE_SUMMARY_GROUPSTATS Group-level pooling of per-curve summaries. +% +% Takes the long table produced by hrf_curve_summaries and pools across a +% chosen axis (usually subjects), producing per-group descriptive +% statistics and a one-sample t-test of each metric against a null value. +% +% Usage +% ----- +% G = hrf_curve_summary_groupstats(T, ... +% 'GroupBy', {'condition','source_name'}, ... +% 'Across', 'subject', ... +% 'Metrics', {'peak_value','peak_lag_seconds','auc','fwhm_seconds'}, ... +% 'TestAgainst', 0, ... +% 'Correction', 'fdr'); +% +% Inputs +% ------ +% T - table from hrf_curve_summaries (long form, one row per curve). +% +% Name-value parameters +% --------------------- +% 'GroupBy' - cellstr of column names defining the groups (the +% "rows" of the output, one per (group, metric) cell). +% Default {'condition','source_name'}. +% 'Across' - the column whose values are pooled within each group. +% Most commonly 'subject'. Default 'subject'. Set to '' +% to pool across ALL rows in each group. +% 'Metrics' - cellstr of metric column names to test. Default = all +% numeric columns of T that aren't grouping/origin +% metadata. +% 'TestAgainst' - null value for the one-sample t-test. Default 0. +% Pass NaN to skip the t-test (descriptives only). +% 'Alpha' - significance threshold (used to populate the .sig +% boolean column and for FDR). Default 0.05. +% 'Correction' - multiple-comparisons correction across rows of G: +% 'none' (default), 'bonferroni', 'fdr'. +% 'MinN' - minimum sample size to bother computing a t. Cells +% with n < MinN get NaN test stats. Default 3. +% +% Output +% ------ +% G is a long table with one row per (group cell x metric): +% , metric, n, mean, sd, sem, ci_lo, ci_hi, +% null_value, t, df, p, p_corrected, sig +% ci_lo/ci_hi are 95% confidence intervals from the t distribution. +% sig is p_corrected < Alpha. +% +% Typical use +% ----------- +% % Per-subject curves -> group means and one-sample t per (condition, +% % source_name) cell, FDR-corrected across rows: +% G = hrf_curve_summary_groupstats(T_per_subject, ... +% 'GroupBy', {'condition','source_name'}, ... +% 'Across', 'subject', ... +% 'Correction', 'fdr'); +% +% % Subset to peak_lag for the NPS source, sorted by p: +% Gnp = G(G.source_name == "NPS" & G.metric == "peak_lag_seconds", :); +% sortrows(Gnp, 'p_corrected') +% +% Pairwise condition contrasts and unpaired group comparisons are NOT +% computed here; do those by pivoting T and running ttest() directly. +% +% See also: hrf_curve_summaries. + +p = inputParser; +p.addRequired('T', @istable); +p.addParameter('GroupBy', {'condition', 'source_name'}, @(x) iscell(x) || isstring(x) || ischar(x)); +p.addParameter('Across', 'subject', @(x) ischar(x) || isstring(x)); +p.addParameter('Metrics', {}, @(x) iscell(x) || isstring(x) || ischar(x)); +p.addParameter('TestAgainst', 0, @(x) isscalar(x)); +p.addParameter('Alpha', 0.05, @(x) isscalar(x) && x > 0 && x < 1); +p.addParameter('Correction', 'none', @(x) ischar(x) || isstring(x)); +p.addParameter('MinN', 3, @(x) isscalar(x) && x >= 1); +p.parse(T, varargin{:}); +opts = p.Results; + +group_by = cellstr(string(opts.GroupBy)); +across = char(opts.Across); +metrics = local_resolve_metrics(T, opts.Metrics); +null_value = opts.TestAgainst; +alpha = opts.Alpha; +correction = lower(strtrim(char(opts.Correction))); + +G = local_empty_group_table(group_by, metrics); + +if height(T) == 0 || isempty(metrics) + return +end + +% Build groupings. +group_keys = local_make_group_keys(T, group_by); +[unique_keys, ~, group_ix] = unique(group_keys, 'rows', 'stable'); + +for g = 1:size(unique_keys, 1) + rows_g = T(group_ix == g, :); + if ~isempty(across) && any(strcmp(across, T.Properties.VariableNames)) + % Collapse to one row per `across` value before pooling (defends + % against the input having multiple runs per subject without prior + % aggregation). + rows_g = local_collapse_within_group(rows_g, across, metrics); + end + + for m = 1:numel(metrics) + metric = metrics{m}; + vals = local_get_metric_vals(rows_g, metric); + row = local_groupstat_row(group_by, unique_keys(g, :), metric, vals, null_value, alpha, opts.MinN); + G = [G; row]; %#ok + end +end + +% Across-row multiple-comparisons correction. +G = local_apply_correction(G, correction, alpha); +end + + +% ========================================================================= +% Helpers +% ========================================================================= +function metrics = local_resolve_metrics(T, requested) +if ~isempty(requested) + metrics = cellstr(string(requested)); + metrics = metrics(:)'; + return +end +% Default: all numeric columns that aren't obvious origin/group fields. +exclude = {'subject','run_label','model','object','source','source_kind', ... + 'source_set','source_name','condition','n_lags','n_finite','n_modes', ... + 'n_sig_lags','peak_lag_index'}; +metrics = {}; +v = T.Properties.VariableNames; +for i = 1:numel(v) + if ismember(v{i}, exclude), continue; end + if isnumeric(T.(v{i})) + metrics{end + 1} = v{i}; %#ok + end +end +end + + +function keys = local_make_group_keys(T, group_by) +% Build an N x K string matrix of grouping-column values. +n = height(T); +k = numel(group_by); +keys = strings(n, k); +v = T.Properties.VariableNames; +for j = 1:k + col = group_by{j}; + if ~any(strcmp(col, v)) + error('hrf_curve_summary_groupstats:UnknownGroupColumn', ... + 'GroupBy column "%s" is not in the input table.', col); + end + val = T.(col); + if isstring(val) + keys(:, j) = val; + elseif iscell(val) + keys(:, j) = string(val); + else + keys(:, j) = string(val); + end +end +end + + +function rows_collapsed = local_collapse_within_group(rows_g, across, metrics) +% Take the mean of each metric per `across` value, so the t-test pools +% one value per subject (not one value per run/condition row). +if ~any(strcmp(across, rows_g.Properties.VariableNames)) + rows_collapsed = rows_g; + return +end +ax_vals = rows_g.(across); +if isnumeric(ax_vals) || islogical(ax_vals) + ax_vals = string(ax_vals); +elseif iscell(ax_vals) + ax_vals = string(ax_vals); +end +[u, ~, ix] = unique(ax_vals, 'stable'); +rows_collapsed = table(); +rows_collapsed.(across) = u; +for m = 1:numel(metrics) + if ~any(strcmp(metrics{m}, rows_g.Properties.VariableNames)), continue; end + src = double(rows_g.(metrics{m})); + out = NaN(numel(u), 1); + for j = 1:numel(u) + v = src(ix == j); + out(j) = mean(v, 'omitnan'); + end + rows_collapsed.(metrics{m}) = out; +end +end + + +function vals = local_get_metric_vals(rows_g, metric) +if ~any(strcmp(metric, rows_g.Properties.VariableNames)) + vals = []; + return +end +vals = double(rows_g.(metric)); +vals = vals(isfinite(vals)); +end + + +function row = local_groupstat_row(group_by, key_vals, metric, vals, null_value, alpha, min_n) +n = numel(vals); +mu = NaN; sd = NaN; sem = NaN; ci_lo = NaN; ci_hi = NaN; +tval = NaN; df = NaN; pval = NaN; sig = false; +if n >= 1 + mu = mean(vals); + if n >= 2 + sd = std(vals); + sem = sd / sqrt(n); + end +end +if isfinite(null_value) && n >= min_n && sd > 0 && isfinite(sd) + df = n - 1; + tval = (mu - null_value) / sem; + pval = 2 * (1 - tcdf(abs(tval), df)); + tcrit = tinv(1 - alpha / 2, df); + ci_lo = mu - tcrit * sem; + ci_hi = mu + tcrit * sem; + sig = pval < alpha; +end + +row = table(); +for j = 1:numel(group_by) + row.(group_by{j}) = string(key_vals(j)); +end +row.metric = string(metric); +row.n = n; +row.mean = mu; +row.sd = sd; +row.sem = sem; +row.ci_lo = ci_lo; +row.ci_hi = ci_hi; +row.null_value = null_value; +row.t = tval; +row.df = df; +row.p = pval; +row.p_corrected = pval; % corrected later in a single pass +row.sig = sig; +end + + +function G = local_empty_group_table(group_by, metrics) %#ok +G = table(); +for j = 1:numel(group_by) + G.(group_by{j}) = strings(0, 1); +end +G.metric = strings(0, 1); +G.n = zeros(0, 1); +G.mean = zeros(0, 1); +G.sd = zeros(0, 1); +G.sem = zeros(0, 1); +G.ci_lo = zeros(0, 1); +G.ci_hi = zeros(0, 1); +G.null_value = zeros(0, 1); +G.t = zeros(0, 1); +G.df = zeros(0, 1); +G.p = zeros(0, 1); +G.p_corrected = zeros(0, 1); +G.sig = false(0, 1); +end + + +function G = local_apply_correction(G, correction, alpha) +if height(G) == 0, return; end +switch lower(correction) + case {'none', '', 'unc', 'uncorrected'} + % p_corrected = p (already initialized) + case 'bonferroni' + valid = isfinite(G.p); + n_tests = sum(valid); + if n_tests > 0 + G.p_corrected(valid) = min(G.p(valid) * n_tests, 1); + end + case 'fdr' + valid = isfinite(G.p); + if any(valid) + p_in = G.p(valid); + [p_sorted, ord] = sort(p_in); + n_tests = numel(p_sorted); + ranks = (1:n_tests)'; + bh = p_sorted .* (n_tests ./ ranks); + for k = n_tests - 1 : -1 : 1 + bh(k) = min(bh(k), bh(k + 1)); + end + bh = min(bh, 1); + % Re-order back to original positions + p_corr_back = NaN(n_tests, 1); + p_corr_back(ord) = bh; + G.p_corrected(valid) = p_corr_back; + end + otherwise + warning('hrf_curve_summary_groupstats:UnknownCorrection', ... + 'Unknown Correction: %s. Using none.', correction); +end +G.sig = G.p_corrected < alpha; +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_dcm.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_dcm.m new file mode 100644 index 00000000..cc0de21c --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_dcm.m @@ -0,0 +1,301 @@ +function R = hrf_dcm(source, varargin) +%HRF_DCM Bilinear DCM directed connectivity via SPM (EXPERIMENTAL). +% +% Estimates a deterministic bilinear Dynamic Causal Model (SPM) on a SMALL, +% user-specified set of nodes and returns the posterior endogenous +% connectivity as a directed net-flow result compatible with +% hrf_plot_causality / hrf_causality_contrast. DCM is generative: it fits the +% hemodynamic response itself, so it is given the RAW node timeseries (NOT the +% deconvolved proxies that Granger/mediation use), with the task conditions as +% driving inputs. +% +% Status: the DCM struct construction and direction recovery are VALIDATED on +% synthetic ground truth -- spm_dcm_estimate converges on the built struct and +% recovers an injected 1->2 influence (net>0, correct sign). It has not yet +% been run at full study scale (each estimate is slow and needs the 4-D BOLD, +% and node selection matters), so treat first real-data results as provisional +% and prefer PEB on the returned .gcm for group inference (see below). +% +% :Usage: +% :: +% R = hrf_dcm({lf,obs}, 'Unit','atlas', 'Atlas','ppat', ... +% 'Nodes',{'Thal_VPLM_L','dpIns_L','dACC'}, ... +% 'Conditions',{'rest_stim','nback-stimblock'}); +% hrf_plot_causality(R, 'Mode','dcm'); +% % group Bayesian inference (recommended): PEB = spm_dcm_peb(R.gcm(:)); +% +% :Inputs: +% **source:** dirs / cell of dirs (passed to hrf_causality for extraction), +% OR a prep struct from hrf_causality(...,'ReturnData',true). +% +% :Required name-value: +% **'Nodes':** the DCM node list (<= MaxNodes; DCM does not scale). +% **'Conditions':** trial_type(s) (glob) used as the model inputs (driving, +% and modulatory if 'B' is set). Built from the BIDS events. +% +% :Optional Inputs: +% **'A':** endogenous structure -- 'full' (default, all-to-all + self) or an +% [n x n] 0/1 matrix. +% **'C':** driving inputs -- 'all' (default, every input drives every node) +% or an [n x m] 0/1 matrix. +% **'B':** modulatory -- 'none' (default), 'all', a condition name (that +% input modulates all connections), or an [n x n x m] 0/1 array. +% **'TE':** echo time, seconds. Default 0.04. +% **'Bins':** microtime bins per TR for the inputs. Default 16. +% **'MaxNodes':** guard; error if more nodes requested. Default 8. +% **'MaxRuns':** cap runs (smoke test). Default Inf. +% Extraction passthrough: 'Unit','Atlas','KernelModel','KernelObject', +% 'KernelCondition'. (Kernels are built by the extractor but unused by DCM.) +% **'Verbose'/'doverbose':** default true. +% +% :Output: +% **R:** struct with .modes={'dcm'} and .dcm.net_group/.t/.p/.p_fdr/.net_subj +% (directed i->j net flow, group one-sample t across subjects), .nodes, +% .subjects, .A_group (posterior A, SPM target<-source convention), +% and .gcm (cell of per-run estimated DCM structs, for spm_dcm_peb). +% +% See also: hrf_causality, hrf_plot_causality, hrf_causality_contrast, +% spm_dcm_estimate, spm_dcm_peb. + +p = inputParser; +p.addRequired('source'); +p.addParameter('Nodes', {}, @(x) iscell(x) || isstring(x) || ischar(x)); +p.addParameter('Conditions', {}, @(x) ischar(x) || isstring(x) || iscell(x)); +p.addParameter('A', 'full', @(x) (ischar(x) || isstring(x)) || isnumeric(x)); +p.addParameter('C', 'all', @(x) (ischar(x) || isstring(x)) || isnumeric(x)); +p.addParameter('B', 'none', @(x) (ischar(x) || isstring(x)) || isnumeric(x)); +p.addParameter('TE', 0.04, @(x) isscalar(x) && x > 0); +p.addParameter('Bins', 16, @(x) isscalar(x) && x >= 1); +p.addParameter('MaxNodes', 8, @(x) isscalar(x) && x >= 2); +p.addParameter('Correction', 'fdr', @(x) ischar(x) || isstring(x)); +p.addParameter('GroupNperm', 5000, @(x) isscalar(x) && x >= 100); +p.addParameter('MaxRuns', Inf, @(x) isscalar(x) && x >= 1); +p.addParameter('Unit', 'atlas', @(x) ischar(x) || isstring(x)); +p.addParameter('Atlas', '', @(x) ischar(x) || isstring(x)); +p.addParameter('KernelModel', 'sfir', @(x) ischar(x) || isstring(x)); +p.addParameter('KernelObject', 'beta', @(x) ischar(x) || isstring(x)); +p.addParameter('KernelCondition', '', @(x) ischar(x) || isstring(x) || iscell(x)); +p.addParameter('Verbose', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('doverbose', [], @(x) isempty(x) || islogical(x) || isnumeric(x)); +p.parse(source, varargin{:}); +opts = p.Results; +verbose = logical(opts.Verbose); +if ~isempty(opts.doverbose), verbose = logical(opts.doverbose); end + +if exist('spm_dcm_estimate', 'file') ~= 2 + error('hrf_dcm:NoSPMDCM', 'spm_dcm_estimate (SPM12 DCM) is required on the path.'); +end +if isempty(opts.Nodes), error('hrf_dcm:NoNodes', 'DCM needs an explicit small ''Nodes'' list.'); end +if isempty(local_clean(opts.Conditions)), error('hrf_dcm:NoConditions', 'DCM needs ''Conditions'' (the task inputs).'); end +try, spm('defaults', 'fMRI'); catch, end + +% ---- extraction prep (RAW node timeseries) ------------------------------ +if isstruct(source) && isfield(source, 'tsRuns') + prep = source; +else + prep = hrf_causality(source, 'ReturnData', true, 'Unit', opts.Unit, 'Nodes', opts.Nodes, ... + 'Atlas', opts.Atlas, 'KernelModel', opts.KernelModel, 'KernelObject', opts.KernelObject, ... + 'KernelCondition', opts.KernelCondition, 'MaxRuns', opts.MaxRuns, 'doverbose', verbose); +end +nodes = cellstr(string(prep.nodes)); +n = numel(nodes); +if n > opts.MaxNodes + error('hrf_dcm:TooManyNodes', '%d nodes requested; DCM caps at MaxNodes=%d. Narrow ''Nodes''.', n, opts.MaxNodes); +end +TR = prep.TR; + +% ---- per-run DCM estimation -------------------------------------------- +gcm = {}; runSubj = {}; +for r = 1:numel(prep.tsRuns) + y = prep.tsRuns{r}; + if any(~isfinite(y(:))) || any(std(y, 0, 1) == 0), continue; end + U = local_build_inputs(prep.events_files{r}, size(y, 1), TR, opts.Conditions, opts.Bins); + if isempty(U.u) || size(U.u, 2) == 0 + warning('hrf_dcm:NoInputs', 'No condition inputs for run %d; skipping.', r); continue + end + DCM = local_build_dcm(y, nodes, U, TR, opts); + if verbose, fprintf(' DCM est run %d/%d (%d nodes, %d inputs, T=%d)...\n', r, numel(prep.tsRuns), n, size(U.u, 2), size(y, 1)); end + try + DCMe = spm_dcm_estimate(DCM); + catch est_err + warning('hrf_dcm:EstimateFailed', 'spm_dcm_estimate failed on run %d: %s', r, est_err.message); continue + end + gcm{end + 1} = DCMe; %#ok + runSubj{end + 1} = char(string(prep.subjects{r})); %#ok +end +if isempty(gcm), error('hrf_dcm:NoDCM', 'No DCM was successfully estimated.'); end + +% ---- collect Ep.A -> directed net, group across subjects ---------------- +% SPM convention A(target,source); convert to i->j (A_mine = A_spm.'). +[usubj, ~, sidx] = unique(runSubj, 'stable'); +nSubj = numel(usubj); +net_subj = nan(n, n, nSubj); +A_subj = nan(n, n, nSubj); +for s = 1:nSubj + runs_s = find(sidx == s); + As = nan(n, n, numel(runs_s)); + for q = 1:numel(runs_s) + As(:, :, q) = gcm{runs_s(q)}.Ep.A.'; % -> i->j convention + end + Am = mean(As, 3, 'omitnan'); + A_subj(:, :, s) = Am; + net_subj(:, :, s) = Am - Am.'; +end + +R = struct('modes', {{'dcm'}}, 'nodes', string(nodes), 'subjects', {usubj(:)'}, ... + 'nsubj', nSubj, 'ndcm', numel(gcm), 'A_group', mean(A_subj, 3, 'omitnan'), 'gcm', {gcm(:)'}); +R.dcm = local_group_stats(net_subj, nodes, opts.Correction, opts.GroupNperm); +R.dcm.net_subj = net_subj; +if isfield(prep, 'dirs'), R.dirs = prep.dirs; end + +if verbose + [~, ix] = max(R.dcm.net_group(:) .* (R.dcm.p(:) < 0.05)); + [si, di] = ind2sub([n n], ix); + fprintf('hrf_dcm: %d DCMs, %d subjects, %d nodes.\n', numel(gcm), nSubj, n); + if isfinite(R.dcm.net_group(si, di)) && R.dcm.p(si, di) < 0.05 + fprintf(' strongest sig net flow: %s -> %s (net=%.3f Hz, p=%.2g)\n', nodes{si}, nodes{di}, R.dcm.net_group(si, di), R.dcm.p(si, di)); + else + fprintf(' no net flow significant at p<.05 (group t, n=%d; consider PEB on R.gcm)\n', nSubj); + end +end +end + + +% ========================================================================= +function DCM = local_build_dcm(y, names, U, TR, opts) +[T, n] = size(y); +m = numel(U.name); +DCM = struct(); +DCM.a = local_A(opts.A, n); +DCM.b = local_B(opts.B, n, m, U.name); +DCM.c = local_C(opts.C, n, m); +DCM.d = zeros(n, n, 0); +DCM.U = U; +DCM.Y.y = y; +DCM.Y.dt = TR; +DCM.Y.name = names(:)'; +DCM.Y.X0 = ones(T, 1); +DCM.Y.Q = spm_Ce(repmat(T, 1, n)); +DCM.v = T; +DCM.n = n; +DCM.TE = opts.TE; +DCM.delays = repmat(TR / 2, n, 1); +DCM.options = struct('nonlinear', 0, 'two_state', 0, 'stochastic', 0, ... + 'centre', 1, 'induced', 0, 'maxnodes', max(n, 8), 'nograph', 1); +end + + +function U = local_build_inputs(events_file, T, TR, conditions, bins) +% Box-function inputs at microtime dt = TR/bins, length T*bins, one column per +% requested condition (glob), built from the BIDS events onsets/durations. +U = struct('u', zeros(T * bins, 0), 'dt', TR / bins, 'name', {{}}); +pats = local_clean(conditions); +if isempty(pats) || isempty(events_file) || exist(events_file, 'file') ~= 2, return; end +try + E = readtable(events_file, 'FileType', 'text', 'Delimiter', '\t', 'TextType', 'string'); +catch + return +end +v = E.Properties.VariableNames; +if ~all(ismember({'onset', 'trial_type'}, v)), return; end +onset = double(E.onset); +dur = zeros(size(onset)); if any(strcmp('duration', v)), dur = double(E.duration); end +tt = string(E.trial_type); +dt = TR / bins; nmt = T * bins; +u = []; names = {}; +for k = 1:numel(pats) + sel = local_glob(tt, pats{k}); + if ~any(sel), continue; end + col = zeros(nmt, 1); + rows = find(sel); + for j = rows(:)' + i0 = max(1, floor(onset(j) / dt) + 1); + i1 = min(nmt, i0 + max(1, round(max(dur(j), dt) / dt)) - 1); + col(i0:i1) = 1; + end + u = [u, col]; names{end + 1} = char(pats{k}); %#ok +end +U.u = u; U.name = names; +end + + +function A = local_A(spec, n) +if isnumeric(spec), A = double(spec ~= 0); return; end +switch lower(char(spec)) + case 'full', A = ones(n); + case {'none', 'diagonal'}, A = eye(n); + otherwise, A = ones(n); +end +end + +function C = local_C(spec, n, m) +if isnumeric(spec), C = double(spec ~= 0); return; end +switch lower(char(spec)) + case 'all', C = ones(n, m); + case 'none', C = zeros(n, m); + otherwise, C = ones(n, m); +end +end + +function B = local_B(spec, n, m, input_names) +if isnumeric(spec) + if ndims(spec) == 3, B = double(spec ~= 0); else, B = repmat(double(spec ~= 0), 1, 1, m); end + return +end +s = lower(char(spec)); +switch s + case {'none', ''} + B = zeros(n, n, m); + case 'all' + B = ones(n, n, m); + otherwise + % a condition name: that input modulates all connections + B = zeros(n, n, m); + hit = find(strcmpi(input_names, s), 1); + if ~isempty(hit), B(:, :, hit) = 1; end +end +end + + +function S = local_group_stats(net_subj, nodes, correction, nperm) +N = size(net_subj, 1); nSubj = size(net_subj, 3); +if nSubj >= 2 + G = hrf_group_stats(net_subj, 'Mask', ~eye(N), 'Correction', correction, 'Nperm', nperm); + S = struct('net_group', G.est, 't', G.t, 'p', G.p, 'p_fdr', G.p_corr, 'sig', G.sig, ... + 'correction', G.correction, 'nodes', {nodes}); +else + mu = mean(net_subj, 3, 'omitnan'); + S = struct('net_group', mu, 't', nan(N), 'p', nan(N), 'p_fdr', nan(N), ... + 'sig', false(N), 'correction', 'none', 'nodes', {nodes}); +end +end + + +function pfdr = local_fdr(pv) +pfdr = nan(size(pv)); +mask = isfinite(pv); ps = pv(mask); m = numel(ps); +if m == 0, return; end +[sp, ord] = sort(ps(:)); +adj = min(1, flipud(cummin(flipud(sp .* m ./ (1:m)')))); +out = nan(m, 1); out(ord) = adj; pfdr(mask) = out; +end + +function pcdf = local_tcdf(tval, df) +x = df ./ (df + tval .^ 2); +pcdf = 1 - 0.5 * betainc(x, df / 2, 0.5); +end + +function tf = local_glob(tt, pat) +tt = cellstr(string(tt)); pat = strtrim(char(pat)); +if any(pat == '*' | pat == '?') + tf = ~cellfun('isempty', regexp(tt, ['^', regexptranslate('wildcard', pat), '$'], 'once')); +else + tf = strcmpi(tt, pat); +end +tf = tf(:); +end + +function c = local_clean(x) +c = cellstr(string(x)); +c = c(~cellfun(@(s) isempty(strtrim(s)), c)); +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_deconv_timeseries.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_deconv_timeseries.m new file mode 100644 index 00000000..5ef209b5 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_deconv_timeseries.m @@ -0,0 +1,192 @@ +function [xhat, info] = hrf_deconv_timeseries(y, hrf, varargin) +%HRF_DECONV_TIMESERIES HRF-informed deconvolution of BOLD timeseries -> neural proxy. +% +% Recovers a neural-activity proxy x(t) from a BOLD timeseries y(t) given a +% region/signature-specific hemodynamic kernel h (e.g. your estimated sFIR +% HRF), by inverting the convolution y = h * x. This is the step that makes +% downstream Granger/lag analysis interpretable: the single largest confound +% for BOLD effective connectivity is that the HRF differs across regions +% (peak latency varies 1-4 s for purely vascular reasons), which can REVERSE +% the apparent direction of influence. Deconvolving each signal with its OWN +% measured kernel removes that confound before the causal analysis. +% +% Usage +% ----- +% xhat = hrf_deconv_timeseries(y, hrf) +% xhat = hrf_deconv_timeseries(Y, H, 'RunLengths', [n1 n2 ...]) +% [xhat, info] = hrf_deconv_timeseries(Y, H, 'Method','wiener', 'NSR',0.1) +% +% Inputs +% ------ +% y - [T x N] timeseries, columns = signals (regions/signatures). +% hrf - the hemodynamic kernel(s), sampled at the SAME dt as y (your sFIR +% lags are already at TR spacing). Either [L x 1] (one kernel applied +% to every column) or [L x N] (a per-column kernel). NaNs/trailing +% zeros are trimmed per column. +% +% Optional (name-value) +% --------------------- +% 'Method' - 'ridge' (default, SVD Tikhonov with GCV-chosen lambda, +% parameter-free and robust for short runs) or 'wiener' +% (FFT Wiener filter, fast; uses 'NSR'). +% 'Lambda' - ridge: [] (default) => choose by GCV; or a scalar to fix it. +% 'NSR' - wiener noise-to-signal ratio (default 0.1). +% 'RunLengths' - vector summing to T; deconvolve each run block separately +% (the HRF does not span run boundaries). Default [] => one +% block. +% 'Normalize' - peak-normalize each kernel to 1 before solving (default +% true). Deconvolution scale is absorbed into x, so this only +% sets x's units; GC downstream is scale-invariant. +% 'Detrend' - linearly detrend each run block of y first (default true). +% +% Outputs +% ------- +% xhat - [T x N] neural proxy, same shape as y. +% info - struct: .method, .lambda [N x nRuns] (ridge), .nsr, .run_lengths, +% .burn_in (samples per run dominated by the deconvolution transient; +% = kernel length, useful to trim before GC). +% +% Notes +% ----- +% * 'ridge' builds the T x T lower-triangular Toeplitz convolution matrix and +% solves via SVD, so lambda is chosen by generalized cross-validation with +% no free parameter. O(T^3) per (signal,run) -- fine at region/signature +% level (T a few hundred); use 'wiener' for voxelwise scale. +% * Feed a CLEAN kernel: the group-mean sFIR HRF (hrf_misspec_metrics +% GroupCurveFirst, or hrf_curve_summaries) is far less noisy than a single +% subject/run curve. +% +% Example +% ------- +% TR = 0.46; t = (0:TR:30)'; +% h = spm_hrf(TR); % a kernel at TR resolution +% x = double(rand(400,1) > 0.9); % sparse "neural" events +% y = conv(x, h); y = y(1:400) + 0.05*randn(400,1); +% xhat = hrf_deconv_timeseries(y, h, 'TR'); %#ok +% +% See also: hrf_granger_causality, hrf_misspec_metrics, hrf_curve_summaries. + +p = inputParser; +p.addRequired('y', @(x) isnumeric(x) && ~isempty(x)); +p.addRequired('hrf', @(x) isnumeric(x) && ~isempty(x)); +p.addParameter('Method', 'ridge', @(x) ischar(x) || isstring(x)); +p.addParameter('Lambda', [], @(x) isempty(x) || isscalar(x)); +p.addParameter('NSR', 0.1, @(x) isscalar(x) && x > 0); +p.addParameter('RunLengths', [], @(x) isempty(x) || isvector(x)); +p.addParameter('Normalize', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('Detrend', true, @(x) islogical(x) || isnumeric(x)); +p.parse(y, hrf, varargin{:}); +opts = p.Results; + +if isvector(y), y = y(:); end +[T, N] = size(y); + +% one kernel for all columns, or one per column +if isvector(hrf), hrf = hrf(:); end +if size(hrf, 2) == 1 && N > 1, hrf = repmat(hrf, 1, N); end +if size(hrf, 2) ~= N + error('hrf_deconv_timeseries:KernelCols', ... + 'hrf must have 1 or N=%d columns; got %d.', N, size(hrf, 2)); +end + +run_len = opts.RunLengths; +if isempty(run_len), run_len = T; end +run_len = run_len(:)'; +if sum(run_len) ~= T + error('hrf_deconv_timeseries:RunLengths', ... + 'RunLengths sum (%d) must equal T (%d).', sum(run_len), T); +end +run_edges = [0, cumsum(run_len)]; +nruns = numel(run_len); + +method = lower(char(opts.Method)); +xhat = zeros(T, N); +lambda_used = nan(N, nruns); +max_klen = 0; + +for n = 1:N + k = local_clean_kernel(hrf(:, n), opts.Normalize); + max_klen = max(max_klen, numel(k)); + for r = 1:nruns + idx = (run_edges(r) + 1):run_edges(r + 1); + yr = y(idx, n); + if opts.Detrend, yr = detrend(yr, 1); end + switch method + case 'ridge' + [xr, lam] = local_ridge_deconv(yr, k, opts.Lambda); + case 'wiener' + xr = local_wiener_deconv(yr, k, opts.NSR); lam = NaN; + otherwise + error('hrf_deconv_timeseries:Method', 'Unknown Method: %s', method); + end + xhat(idx, n) = xr; + lambda_used(n, r) = lam; + end +end + +info = struct('method', method, 'lambda', lambda_used, 'nsr', opts.NSR, ... + 'run_lengths', run_len, 'burn_in', max_klen); +end + + +% ========================================================================= +function k = local_clean_kernel(k, do_norm) +k = k(:); +k(~isfinite(k)) = 0; +% drop trailing zeros (sFIR curves are zero-padded past the window) +last = find(k ~= 0, 1, 'last'); +if isempty(last), error('hrf_deconv_timeseries:ZeroKernel', 'A kernel is all zeros.'); end +k = k(1:last); +if do_norm + pk = max(abs(k)); + if pk > 0, k = k / pk; end +end +end + + +function [x, lambda] = local_ridge_deconv(y, k, fixed_lambda) +% Tikhonov deconvolution of y = H x via SVD, GCV-chosen lambda. +T = numel(y); +L = min(numel(k), T); +k = k(1:L); +H = toeplitz([k; zeros(T - L, 1)], [k(1); zeros(T - 1, 1)]); % lower-tri Toeplitz +[U, S, V] = svd(H, 'econ'); +s = diag(S); +uty = U' * y; + +if ~isempty(fixed_lambda) + lambda = fixed_lambda; +else + lambda = local_gcv_lambda(s, uty, T); +end +filt = s ./ (s.^2 + lambda); +x = V * (filt .* uty); +end + + +function lambda = local_gcv_lambda(s, uty, T) +% Minimize GCV(l) = T*RSS(l) / (T - df(l))^2 over a log grid. +s2 = s.^2; +g = logspace(-6, 3, 60) * (mean(s2) + eps); +best = inf; lambda = g(1); +for i = 1:numel(g) + l = g(i); + df = sum(s2 ./ (s2 + l)); + % residual energy: components attenuated by l/(s^2+l) + rss = sum(((l ./ (s2 + l)) .* uty).^2); + denom = (T - df)^2; + if denom <= 0, continue; end + gcv = T * rss / denom; + if gcv < best, best = gcv; lambda = l; end +end +end + + +function x = local_wiener_deconv(y, k, nsr) +% FFT Wiener filter: X = conj(K).*Y ./ (|K|^2 + nsr). +T = numel(y); +K = fft(k, T); +Y = fft(y, T); +X = (conj(K) .* Y) ./ (abs(K).^2 + nsr); +x = real(ifft(X)); +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_extract_all_signature_timeseries.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_extract_all_signature_timeseries.m new file mode 100644 index 00000000..a57a24d7 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_extract_all_signature_timeseries.m @@ -0,0 +1,73 @@ +function [TC, meta] = hrf_extract_all_signature_timeseries(fmri_nii, varargin) +%HRF_EXTRACT_ALL_SIGNATURE_TIMESERIES Extract all signature-expression time series. + +p = inputParser; +p.addRequired('fmri_nii', @(x) ischar(x) || isstring(x) || isa(x, 'fmri_data')); +p.addParameter('SimilarityMetric', 'dotproduct', @(x) ischar(x) || isstring(x)); +p.addParameter('ImageSet', 'all', @(x) ischar(x) || isstring(x)); +p.parse(fmri_nii, varargin{:}); +opts = p.Results; + +if ~exist('fmri_data', 'file') + error('fmri_data class/function not found on path. Add CanlabCore dependencies first.'); +end +if ~exist('apply_all_signatures', 'file') + error('apply_all_signatures not found on path. Add CanlabCore dependencies first.'); +end + +if isa(fmri_nii, 'fmri_data') + dat = fmri_nii; +else + dat = fmri_data(char(fmri_nii)); +end +S = apply_all_signatures(dat, 'similarity_metric', char(opts.SimilarityMetric), 'image_set', char(opts.ImageSet)); + +if ~isfield(S, 'signaturenames') || isempty(S.signaturenames) + error('apply_all_signatures returned no signatures.'); +end + +names = S.signaturenames; +n_sig = numel(names); +first = local_get_signal(S.(names{1})); +n_tp = numel(first); +TC = nan(n_tp, n_sig); + +for i = 1:n_sig + v = local_get_signal(S.(names{i})); + if numel(v) ~= n_tp + error('Signature time series length mismatch for %s.', names{i}); + end + TC(:, i) = local_zscore(v); +end + +meta = struct('available_signatures', {names}, ... + 'similarity_metric', char(opts.SimilarityMetric), ... + 'image_set', char(opts.ImageSet)); +end + +function v = local_get_signal(sig_struct) +if istable(sig_struct) + vn = sig_struct.Properties.VariableNames; + v = sig_struct.(vn{1}); +elseif isstruct(sig_struct) + f = fieldnames(sig_struct); + v = sig_struct.(f{1}); +else + v = sig_struct; +end +if istable(v) + vn = v.Properties.VariableNames; + v = v.(vn{1}); +end +v = v(:); +end + +function y = local_zscore(y) +y = y(:); +s = std(y); +if s == 0 || isnan(s) + y = zeros(size(y)); +else + y = (y - mean(y)) ./ s; +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_extract_imageset_timeseries.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_extract_imageset_timeseries.m new file mode 100644 index 00000000..25f664d7 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_extract_imageset_timeseries.m @@ -0,0 +1,73 @@ +function [TC, meta] = hrf_extract_imageset_timeseries(fmri_nii, image_set, varargin) +%HRF_EXTRACT_IMAGESET_TIMESERIES Apply load_image_set maps to each fMRI volume. +% Supports Buckner networks, Margulies gradients, Hansen receptor maps, etc. + +p = inputParser; +p.addRequired('fmri_nii', @(x) ischar(x) || isstring(x) || isa(x, 'fmri_data')); +p.addRequired('image_set', @(x) ischar(x) || isstring(x) || isa(x, 'image_vector')); +p.addParameter('MapNames', {}, @(x) iscell(x) || isstring(x)); +p.addParameter('SimilarityMetric', 'dotproduct', @(x) ischar(x) || isstring(x)); +p.parse(fmri_nii, image_set, varargin{:}); +opts = p.Results; + +if isa(fmri_nii, 'fmri_data') + dat = fmri_nii; +else + dat = fmri_data(char(fmri_nii)); +end + +if isa(image_set, 'image_vector') + maps = image_set; + image_set_name = class(image_set); + if isprop(maps, 'metadata_table') && ~isempty(maps.metadata_table) && ... + any(strcmp('target', maps.metadata_table.Properties.VariableNames)) + map_names = maps.metadata_table.target; + elseif isprop(maps, 'image_labels') && ~isempty(maps.image_labels) + map_names = maps.image_labels; + elseif ~isempty(maps.image_names) + map_names = cellstr(maps.image_names); + else + map_names = arrayfun(@(i) sprintf('map_%03d', i), 1:size(maps.dat, 2), 'UniformOutput', false); + end +else + image_set_name = char(image_set); + [maps, map_names] = load_image_set(char(image_set), 'noverbose'); +end +map_names = cellstr(string(map_names)); + +if ~isempty(opts.MapNames) + req = cellstr(string(opts.MapNames)); + [tf, idx] = ismember(req, map_names); + idx = idx(tf); + maps = get_wh_image(maps, idx); + map_names = map_names(idx); +end +if isempty(map_names) + error('No maps matched the requested ImageSet/MapNames.'); +end + +n_map = numel(map_names); +n_tp = size(dat.dat, 2); +TC = nan(n_tp, n_map); +for i = 1:n_map + this_map = get_wh_image(maps, i); + y = apply_mask(dat, this_map, 'pattern_expression', 'ignore_missing', char(opts.SimilarityMetric)); + y = y(:); + TC(:, i) = local_zscore(y); +end + +meta = struct(); +meta.available_signatures = map_names; +meta.image_set = image_set_name; +meta.similarity_metric = char(opts.SimilarityMetric); +end + +function y = local_zscore(y) +y = y(:); +s = std(y); +if s == 0 || isnan(s) + y = zeros(size(y)); +else + y = (y - mean(y)) ./ s; +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_extract_roi_timeseries.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_extract_roi_timeseries.m new file mode 100644 index 00000000..847bcae1 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_extract_roi_timeseries.m @@ -0,0 +1,33 @@ +function [TC, meta] = hrf_extract_roi_timeseries(fmri_nii, atlas_obj, varargin) +%HRF_EXTRACT_ROI_TIMESERIES Extract ROI-mean time series from atlas regions. + +p = inputParser; +p.addRequired('fmri_nii', @(x) ischar(x) || isstring(x)); +p.addRequired('atlas_obj', @(x) isa(x, 'atlas')); +p.addParameter('Regions', {}, @(x) iscell(x) || isstring(x)); +p.parse(fmri_nii, atlas_obj, varargin{:}); +opts = p.Results; + +dat = fmri_data(char(fmri_nii)); + +if isempty(opts.Regions) + regions = atlas_obj.labels; +else + regions = cellstr(string(opts.Regions)); +end + +n_reg = numel(regions); +n_tp = size(dat.dat, 2); +TC = nan(n_tp, n_reg); +for r = 1:n_reg + at_sub = atlas_obj.select_atlas_subset(regions(r), 'exact'); + y = mean(apply_mask(dat, at_sub).dat, 1); + y = y(:); + TC(:, r) = (y - mean(y)) ./ std(y); +end + +meta = struct(); +meta.available_signatures = regions; +meta.image_set = 'atlas_rois'; +meta.similarity_metric = 'mean_signal'; +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_extract_signature_timeseries.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_extract_signature_timeseries.m new file mode 100644 index 00000000..5e4f1182 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_extract_signature_timeseries.m @@ -0,0 +1,69 @@ +function [tc, meta] = hrf_extract_signature_timeseries(fmri_nii, varargin) +%HRF_EXTRACT_SIGNATURE_TIMESERIES Extract interpretable signature time-series. +% Uses apply_all_signatures on an fmri_data object. +% +% Name/value +% 'SimilarityMetric' : dotproduct (default), cosine_similarity, correlation +% 'ImageSet' : all (default), or a named signature set accepted by apply_all_signatures +% 'SignatureName' : optional specific signature to return; default = first available + +p = inputParser; +p.addRequired('fmri_nii', @(x) ischar(x) || isstring(x) || isa(x, 'fmri_data')); +p.addParameter('SimilarityMetric', 'dotproduct', @(x) ischar(x) || isstring(x)); +p.addParameter('ImageSet', 'all', @(x) ischar(x) || isstring(x)); +p.addParameter('SignatureName', '', @(x) ischar(x) || isstring(x)); +p.parse(fmri_nii, varargin{:}); +opts = p.Results; + +if ~exist('fmri_data', 'file') + error('fmri_data class/function not found on path. Add CanlabCore dependencies first.'); +end +if ~exist('apply_all_signatures', 'file') + error('apply_all_signatures not found on path. Add CanlabCore dependencies first.'); +end + +if isa(fmri_nii, 'fmri_data') + dat = fmri_nii; +else + dat = fmri_data(char(fmri_nii)); +end +S = apply_all_signatures(dat, 'similarity_metric', char(opts.SimilarityMetric), ... + 'image_set', char(opts.ImageSet)); + +if ~isfield(S, 'signaturenames') || isempty(S.signaturenames) + error('apply_all_signatures returned no signatures.'); +end + +sig_names = S.signaturenames; +selected_name = char(opts.SignatureName); +if isempty(selected_name) + selected_name = sig_names{1}; +elseif ~ismember(selected_name, sig_names) + error('Requested SignatureName "%s" not in apply_all_signatures output.', selected_name); +end + +sig_struct = S.(selected_name); +if istable(sig_struct) + vn = sig_struct.Properties.VariableNames; + tc = sig_struct.(vn{1}); +elseif isstruct(sig_struct) + f = fieldnames(sig_struct); + tc = sig_struct.(f{1}); +else + tc = sig_struct; +end + +tc = tc(:); +s = std(tc); +if s == 0 || isnan(s) + tc = zeros(size(tc)); +else + tc = (tc - mean(tc)) ./ s; +end + +meta = struct(); +meta.selected_signature = selected_name; +meta.available_signatures = sig_names; +meta.similarity_metric = char(opts.SimilarityMetric); +meta.image_set = char(opts.ImageSet); +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_extract_timeseries_from_nii.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_extract_timeseries_from_nii.m new file mode 100644 index 00000000..a8961b24 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_extract_timeseries_from_nii.m @@ -0,0 +1,46 @@ +function [tc, TR, n_tp] = hrf_extract_timeseries_from_nii(fmri_nii, mask_nii) +%HRF_EXTRACT_TIMESERIES_FROM_NII Mean fMRI timeseries from 4D NIfTI. +try + info = niftiinfo(fmri_nii); + V = double(niftiread(info)); +catch err + error('Could not read fMRI NIfTI %s: %s', char(fmri_nii), err.message); +end +if ndims(V) ~= 4 + error('Expected 4D fMRI image, got %d dimensions.', ndims(V)); +end +n_tp = size(V, 4); + +TR = []; +if isfield(info, 'PixelDimensions') && numel(info.PixelDimensions) >= 4 + TR = info.PixelDimensions(4); +end +if isempty(TR) || TR <= 0 + error('Could not infer TR from NIfTI header. Pass ''TR'' explicitly.'); +end + +if nargin < 2 || isempty(mask_nii) + mask = true(size(V,1), size(V,2), size(V,3)); +else + try + M = niftiread(mask_nii); + catch err + error('Could not read mask NIfTI %s: %s', char(mask_nii), err.message); + end + mask = M > 0; + if ~isequal(size(mask), size(V(:,:,:,1))) + error('Mask dimensions must match fMRI spatial dimensions.'); + end +end + +vox_by_time = reshape(V, [], n_tp); +mask_lin = mask(:); +masked = vox_by_time(mask_lin, :); +tc = mean(masked, 1)'; +s = std(tc); +if s == 0 || isnan(s) + tc = zeros(size(tc)); +else + tc = (tc - mean(tc)) ./ s; +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_fit_all_models.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_fit_all_models.m new file mode 100644 index 00000000..44ff72ff --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_fit_all_models.m @@ -0,0 +1,308 @@ +function fits = hrf_fit_all_models(tc, TR, Runc, window_seconds, models, varargin) +%HRF_FIT_ALL_MODELS Fit supported HRF models with a consistent output API. + +% Optional name/value: +% 'SuppressWarnings' (default true): temporarily suppress warning spam +% 'DependencyPolicy' (default 'skip'): 'skip' unavailable optional models +% with a warning, or 'error' if a requested model dependency is missing. +p = inputParser; +p.addParameter('SuppressWarnings', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('DependencyPolicy', 'skip', @(x) ischar(x) || isstring(x)); +p.parse(varargin{:}); +suppress_warnings = logical(p.Results.SuppressWarnings); +dependency_policy = lower(char(p.Results.DependencyPolicy)); +if ~ismember(dependency_policy, {'skip', 'error'}) + error('Unknown DependencyPolicy: %s. Use ''skip'' or ''error''.', dependency_policy); +end + +models = lower(string(models)); +models = local_filter_available_models(models, dependency_policy); +fits = struct(); +len = length(tc); + +if any(models == "logit") + [h, fit, e, param] = run_fit(@() Fit_Logit2(tc, TR, Runc, window_seconds, 0), suppress_warnings); + fits.logit = package_fit(h, fit, e, param, len, 7, tc, TR, Runc); + fits.logit.uncertainty_source = 'not available for nonlinear logit fit'; +end +if any(models == "fir") + [h, fit, e, param] = run_fit(@() Fit_sFIR(tc, TR, Runc, window_seconds, 0), suppress_warnings); + fits.fir = package_fit(h, fit, e, param, len, window_seconds, tc, TR, Runc); + fits.fir = add_linear_uncertainty(fits.fir, 'fir', tc, TR, Runc, window_seconds); +end +if any(models == "sfir") + [h, fit, e, param] = run_fit(@() Fit_sFIR(tc, TR, Runc, window_seconds, 1), suppress_warnings); + fits.sfir = package_fit(h, fit, e, param, len, window_seconds, tc, TR, Runc); + fits.sfir = add_linear_uncertainty(fits.sfir, 'sfir', tc, TR, Runc, window_seconds); +end +if any(models == "canonical") + [h, fit, e, param, info] = run_fit(@() Fit_Canonical_HRF(tc, TR, Runc, window_seconds, 1), suppress_warnings); + fits.canonical = package_fit(h, fit, e, param, len, 1, tc, TR, Runc); + fits.canonical.info = info; + fits.canonical = add_linear_uncertainty(fits.canonical, 'canonical', tc, TR, Runc, window_seconds); +end +if any(models == "spline") + [h, fit, e, param] = run_fit(@() Fit_Spline(tc, TR, Runc, window_seconds), suppress_warnings); + fits.spline = package_fit(h, fit, e, param, len, 1, tc, TR, Runc); + fits.spline = add_linear_uncertainty(fits.spline, 'spline', tc, TR, Runc, window_seconds); +end +if any(models == "nlgamma") + [h, fit, e, param] = run_fit(@() Fit_NLgamma(tc, TR, Runc, window_seconds), suppress_warnings); + fits.nlgamma = package_fit(h, fit, e, param, len, 1, tc, TR, Runc); + fits.nlgamma.uncertainty_source = 'not available for nonlinear gamma fit'; +end +end + +function models = local_filter_available_models(models, dependency_policy) +valid_models = ["logit", "fir", "sfir", "canonical", "spline", "nlgamma"]; +models = models(:)'; +unknown = setdiff(models, valid_models); +if ~isempty(unknown) + error('Unknown HRF model(s): %s. Valid models are: %s.', ... + strjoin(cellstr(unknown), ', '), strjoin(cellstr(valid_models), ', ')); +end + +keep = true(size(models)); +missing_messages = strings(size(models)); +for i = 1:numel(models) + [ok, msg] = local_model_dependency(models(i)); + if ~ok + keep(i) = false; + missing_messages(i) = msg; + switch dependency_policy + case 'skip' + local_warn_once(models(i), msg); + case 'error' + error('HRF model "%s" is unavailable: %s', models(i), msg); + end + end +end + +if ~any(keep) + msg = strjoin(cellstr(unique(missing_messages(missing_messages ~= ""))), ' '); + error('No requested HRF models are available. %s', msg); +end + +models = models(keep); +end + +function [ok, msg] = local_model_dependency(model_name) +ok = true; +msg = ""; +switch char(model_name) + case 'canonical' + if exist('spm_get_bf', 'file') ~= 2 + ok = false; + msg = "canonical requires SPM on the MATLAB path (missing spm_get_bf)."; + end + case 'nlgamma' + if exist('spm_hrf', 'file') ~= 2 + ok = false; + msg = "nlgamma requires SPM on the MATLAB path (missing spm_hrf)."; + end + case 'spline' + if exist('create_bspline_basis', 'file') ~= 2 || exist('eval_basis', 'file') ~= 2 + ok = false; + msg = "spline requires the FDA package on the MATLAB path (missing create_bspline_basis/eval_basis; see https://github.com/markgewhite/fda)."; + end +end +end + +function local_warn_once(model_name, msg) +persistent warned_models +if isempty(warned_models) + warned_models = strings(0, 1); +end +key = string(model_name); +if any(warned_models == key) + return +end +warned_models(end + 1, 1) = key; +warning('hrf_fit_all_models:SkippingModel', 'Skipping HRF model "%s": %s', char(key), char(msg)); +end + +function s = package_fit(h, fit, e, param, len, p, tc, TR, Runc) +s = struct(); +s.hrf = h; +s.time = local_time_vector(size(h, 1), TR); +s.fit = fit; +s.residual = e; +s.param = param; +s.N = len; +s.dfe = []; +s.se = []; +s.t = []; +s.p = []; +s.p_type = ''; +s.uncertainty_source = ''; +s.mse = (1 / (len - 1)) * sum(e .^ 2); +try + s.mis_modeling_p = ResidScan(e, 4); +catch + s.mis_modeling_p = NaN; +end +try + s.power_loss = PowerLoss(e, fit, (len - p), tc, TR, Runc, 0.001); +catch + s.power_loss = NaN; +end +end + +function s = add_linear_uncertainty(s, model_name, tc, TR, Runc, window_seconds) +try + switch model_name + case 'fir' + [X, PX, hrf_lift, coef_idx_by_condition] = local_fir_uncertainty_design(Runc, TR, window_seconds); + case 'sfir' + [X, PX, hrf_lift, coef_idx_by_condition] = local_sfir_uncertainty_design(Runc, TR, window_seconds); + case 'canonical' + [X, PX, hrf_lift, coef_idx_by_condition] = local_canonical_uncertainty_design(Runc, TR, size(s.hrf, 1)); + case 'spline' + [X, PX, hrf_lift, coef_idx_by_condition] = local_spline_uncertainty_design(Runc, TR, window_seconds, size(s.hrf, 1)); + otherwise + return + end + + residual = tc(:) - X * (PX * tc(:)); + dfe = max(size(X, 1) - trace(X * PX), 1); + mse = sum(residual .^ 2) ./ dfe; + covb = mse .* (PX * PX'); + + n_cond = numel(coef_idx_by_condition); + n_time = size(s.hrf, 1); + se = nan(n_time, n_cond); + for c = 1:n_cond + idx = coef_idx_by_condition{c}; + covc = covb(idx, idx); + se(:, c) = sqrt(max(diag(hrf_lift * covc * hrf_lift'), 0)); + end + + tval = s.hrf ./ se; + pval = 2 * (1 - tcdf(abs(tval), dfe)); + pval(pval == 0) = eps; + + s.se = se; + s.t = tval; + s.p = pval; + s.p_type = sprintf('Two-tailed P-values from %s HRF coefficient SE, dfe = %.3f', model_name, dfe); + s.dfe = dfe; + s.N = size(X, 1); + s.mse = mse; + s.uncertainty_source = sprintf('%s linear model residual covariance', model_name); +catch err + s.se = []; + s.t = []; + s.p = []; + s.p_type = ''; + s.dfe = []; + s.uncertainty_source = sprintf('unavailable: %s', err.message); +end +end + +function [X, PX, hrf_lift, coef_idx_by_condition] = local_sfir_uncertainty_design(Runc, TR, window_seconds) +[X, ~, hrf_lift, coef_idx_by_condition] = local_fir_uncertainty_design(Runc, TR, window_seconds); +numstim = numel(Runc); +tlen = size(hrf_lift, 1); + +C = (1:tlen)' * ones(1, tlen); +h = sqrt(1 / (7 / TR)); +v = 0.1; +sig = 1; +R = v * exp(-h / 2 * (C - C') .^ 2); +RI = R \ eye(size(R)); +pen = zeros(numstim * tlen + 1); +for i = 1:numstim + idx = ((i - 1) * tlen + 1):(i * tlen); + pen(idx, idx) = sig ^ 2 * RI; +end + +PX = (X' * X + pen) \ X'; +end + +function [X, PX, hrf_lift, coef_idx_by_condition] = local_fir_uncertainty_design(Runc, TR, window_seconds) +numstim = numel(Runc); +t = 1:TR:window_seconds; +tlen = numel(t); +len = numel(Runc{1}); +Runs = zeros(len, numstim); +for i = 1:numstim + Runs(:, i) = Runc{i}(:); +end +X = tor_make_deconv_mtx3(Runs, tlen, 1); +PX = pinv(X); +hrf_lift = eye(tlen); +coef_idx_by_condition = cell(1, numstim); +for i = 1:numstim + coef_idx_by_condition{i} = ((i - 1) * tlen + 1):(i * tlen); +end +end + +function [X, PX, hrf_lift, coef_idx_by_condition] = local_canonical_uncertainty_design(Runc, TR, n_hrf) +numstim = numel(Runc); +len = numel(Runc{1}); +h = local_canonical_basis(TR, n_hrf); +Xtask = zeros(len, numstim); +for i = 1:numstim + v = conv(Runc{i}, h); + Xtask(:, i) = v(1:len); +end +X = [ones(len, 1) Xtask]; +PX = pinv(X); +hrf_lift = h(:); +coef_idx_by_condition = cell(1, numstim); +for i = 1:numstim + coef_idx_by_condition{i} = i + 1; +end +end + +function h = local_canonical_basis(TR, n_hrf) +len = max(round(30 / TR), n_hrf); +xBF.dt = TR; +xBF.length = len; +xBF.name = 'hrf (with time and dispersion derivatives)'; +xBF = spm_get_bf(xBF); +h = xBF.bf(1:n_hrf, 1); +h = h ./ max(h); +end + +function [X, PX, hrf_lift, coef_idx_by_condition] = local_spline_uncertainty_design(Runc, TR, window_seconds, n_hrf) +numstim = numel(Runc); +len = numel(Runc{1}); +t = 1:TR:window_seconds; +tlen = numel(t); +K = 8; +norder = 4; +basis = create_bspline_basis([0, tlen], K + 3, norder); +B = eval_basis((1:tlen), basis); +B = B(:, 3:end-1); +B = B(1:n_hrf, :); + +Wi = zeros(len, numstim * K); +for j = 1:numstim + Wji = tor_make_deconv_mtx3(Runc{j}, tlen, 1); + Wi(:, (j - 1) * K + 1:j * K) = Wji(:, 1:tlen) * B; +end +X = [ones(len, 1) Wi]; +PX = pinv(X); +hrf_lift = B; +coef_idx_by_condition = cell(1, numstim); +for i = 1:numstim + coef_idx_by_condition{i} = ((i - 1) * K + 2):(i * K + 1); +end +end + +function t = local_time_vector(n, TR) +t = 1 + (0:n - 1)' .* TR; +end + + +function varargout = run_fit(funh, suppress_warnings) +if suppress_warnings + ws = warning; + warning('off', 'all'); + c = onCleanup(@() warning(ws)); + [varargout{1:nargout}] = funh(); +else + [varargout{1:nargout}] = funh(); +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_fit_wholebrain_stats.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_fit_wholebrain_stats.m new file mode 100644 index 00000000..ccd68986 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_fit_wholebrain_stats.m @@ -0,0 +1,867 @@ +function out = hrf_fit_wholebrain_stats(fmri_nii, events_tsv, varargin) +%HRF_FIT_WHOLEBRAIN_STATS Vectorized whole-brain HRF beta and T maps. +% +% out = hrf_fit_wholebrain_stats(fmri_nii, events_tsv, ...) +% +% This fits a linear HRF model to every voxel in a 4D fMRI +% image and returns two CANlab statistic_image objects: +% out.b - beta/HRF amplitude maps, one 3D volume per condition x lag +% out.t - T maps for the same condition x lag volumes +% +% Both objects include .ste, .p, .dfe, .N, .image_labels, and .volInfo. +% If OutputPrefix is provided, 4D NIfTI files are written to disk. +% +% SPM GKWY compatibility +% ---------------------- +% By default this is OLS on a constant-only baseline -- it is NOT identical +% to an SPM first-level GLM, which fits the grand-mean-scaled, high-pass +% filtered, prewhitened data (SPM's "gKWY"; see Misc_utilities/spmify.m). +% Two tiers make the fit SPM-comparable: +% +% Tier B (exact): pass 'SPM', '/path/to/SPM.mat' (or an estimated SPM +% struct). The data are transformed to gKWY (global scale g, high-pass K, +% ReML whitening W) and the design is filtered to KWX -- reproducing +% spm_spm. Use 'SPMRun' to pick the run for a multi-run SPM. +% +% Tier A (no SPM.mat): 'HighpassSeconds' (default 128, SPM's default) adds +% DCT high-pass confounds to the design, replicating K and removing the +% low-frequency drift that otherwise leaks into long FIR/sFIR lags as a +% spurious sustained baseline. ScaleMode 'grandmean' replicates g. +% 'Whiten' ('ar1' or 'ar2') estimates the noise autocorrelation from the +% residuals of a high-variance voxel subsample (one global AR model, +% SPM-style) and prewhitens data + design -- giving GLS-valid SE/t +% without an SPM.mat. Set HighpassSeconds [] / 0 / Inf and Whiten 'none' +% to recover the legacy raw-OLS behavior. +% +% ScaleMode: 'none' (default), 'zscore' (per-voxel), or 'grandmean' (single +% global scalar to mean 100, SPM-style). Ignored when 'SPM' is supplied. + +p = inputParser; +p.addRequired('fmri_nii', @(x) ischar(x) || isstring(x) || isa(x, 'fmri_data')); +p.addRequired('events_tsv', @(x) ischar(x) || isstring(x) || istable(x)); +p.addParameter('TR', [], @(x) isempty(x) || (isscalar(x) && x > 0)); +p.addParameter('MaskNii', '', @(x) ischar(x) || isstring(x) || isa(x, 'fmri_mask_image')); +p.addParameter('Conditions', {}, @(x) ischar(x) || iscell(x) || isstring(x)); +p.addParameter('WindowSeconds', 30, @(x) isnumeric(x) && all(x(:) > 0)); +p.addParameter('Mode', 'FIR', @(x) ischar(x) || isstring(x)); +p.addParameter('Nuisance', [], @(x) isempty(x) || isnumeric(x)); +p.addParameter('OutputPrefix', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Overwrite', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('PThresh', [], @(x) isempty(x) || (isscalar(x) && x > 0 && x < 1)); +p.addParameter('ThreshType', 'unc', @(x) ischar(x) || isstring(x)); +p.addParameter('WriteThresholdedT', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('ChunkSize', 50000, @(x) isnumeric(x) && isscalar(x) && x >= 1 && mod(x, 1) == 0); +p.addParameter('ScaleMode', 'none', @(x) ischar(x) || isstring(x)); +% --- SPM GKWY-compatibility controls ------------------------------------- +% The fit is OLS on a constant-only baseline unless you ask for SPM-style +% conditioning. Two tiers (see help block): +% Tier B 'SPM' - path to / struct of an ESTIMATED SPM.mat. +% Applies exact g (global scale), K (high-pass) +% and W (ReML whitening), matching spm_spm. +% 'SPMRun' - which run in a multi-run SPM the image is (1). +% Tier A 'HighpassSeconds' - DCT high-pass cutoff in seconds (default 128, +% SPM's default). Replicates K when no SPM.mat +% is available. Set [] / 0 / Inf to disable. +% Whitening (W) is only available via Tier B. With an SPM.mat, ScaleMode and +% HighpassSeconds are ignored (g and K come from the SPM). +p.addParameter('SPM', [], @(x) isempty(x) || isstruct(x) || ischar(x) || isstring(x)); +p.addParameter('SPMRun', 1, @(x) isnumeric(x) && isscalar(x) && x >= 1 && mod(x, 1) == 0); +p.addParameter('HighpassSeconds', 128, @(x) isempty(x) || (isscalar(x) && isnumeric(x))); +% Prewhitening for the no-SPM path: 'none' (default), 'ar1' or 'ar2'. The +% autocorrelation is estimated from the OLS residuals of a high-variance +% voxel subsample (one global model, SPM-style) and applied to data + design, +% giving GLS-valid SE/t without an SPM.mat. SPM-exact whitening uses 'SPM'. +p.addParameter('Whiten', 'none', @(x) ischar(x) || isstring(x)); +p.addParameter('Verbose', true, @(x) islogical(x) || isnumeric(x)); +p.parse(fmri_nii, events_tsv, varargin{:}); +opts = p.Results; + +if isa(fmri_nii, 'fmri_data') + data_obj = fmri_nii; + fmri_name = ''; + if isempty(opts.TR) + if isprop(data_obj, 'image_metadata') && isfield(data_obj.image_metadata, 'TR_in_sec') && ... + ~isnan(data_obj.image_metadata.TR_in_sec) + TR = data_obj.image_metadata.TR_in_sec; + else + error('TR is required when fmri_nii is an fmri_data object without image_metadata.TR_in_sec.'); + end + else + TR = opts.TR; + end + n_tp = size(data_obj.dat, 2); +else + fmri_name = char(fmri_nii); + if ~exist(fmri_name, 'file'), error('fMRI file not found: %s', fmri_name); end + [TR, n_tp] = local_get_tr_and_ntp(fmri_name, opts.TR); + + mask_input = opts.MaskNii; + if isstring(mask_input), mask_input = char(mask_input); end + + if isempty(mask_input) + data_obj = fmri_data(fmri_name, 'noverbose'); + else + data_obj = fmri_data(fmri_name, mask_input, 'noverbose'); + end +end + +if istable(events_tsv) + E = events_tsv; +else + events_name = char(events_tsv); + if ~exist(events_name, 'file'), error('Events file not found: %s', events_name); end + E = hrf_load_events_tsv(events_name); +end + +if isempty(opts.Conditions) + cond_names = unique(E.trial_type, 'stable'); +else + cond_names = cellstr(string(opts.Conditions)); +end + +[Runc, condition_groups] = hrf_build_stick_functions(E, cond_names, TR, n_tp); +cond_names = {condition_groups.label}; +[X, design_info] = local_build_wholebrain_design(Runc, cond_names, TR, opts.WindowSeconds, opts.Mode, opts.Nuisance); + +if size(X, 1) ~= size(data_obj.dat, 2) + error('Design rows (%d) must match fMRI time points (%d).', size(X, 1), size(data_obj.dat, 2)); +end + +% --- SPM GKWY conditioning (Tier B exact, or Tier A high-pass) ----------- +% Transforms data_obj.dat and X up front so the existing vectorized OLS loop +% below becomes the consistent (filtered / whitened) fit. dof_correction +% accounts for high-pass confounds that Tier B projects out of the data. +dof_correction = 0; +gkwy_info = struct('mode', 'none', 'highpass_seconds', [], 'n_highpass', 0, ... + 'whitened', false, 'grandmean_scaled', false); + +if ~isempty(opts.SPM) + SPM = local_load_spm(opts.SPM); + [data_obj, X, dof_correction, n_hp] = local_apply_spm_gkwy(data_obj, X, SPM, opts.SPMRun); + gkwy_info.mode = 'spm_gkwy'; + gkwy_info.n_highpass = n_hp; + gkwy_info.whitened = true; + gkwy_info.grandmean_scaled = true; + if ~strcmpi(char(opts.ScaleMode), 'none') + warning('hrf_fit_wholebrain_stats:ScaleModeIgnored', ... + 'ScaleMode=''%s'' ignored: global scaling comes from SPM.xGX.gSF.', char(opts.ScaleMode)); + opts.ScaleMode = 'none'; + end + if ~strcmpi(char(opts.Whiten), 'none') + warning('hrf_fit_wholebrain_stats:WhitenIgnored', ... + 'Whiten=''%s'' ignored: whitening comes from SPM.xX.W.', char(opts.Whiten)); + end + if opts.Verbose + fprintf('SPM GKWY applied (run %d): global scale + high-pass (%d confounds) + ReML whitening.\n', ... + opts.SPMRun, n_hp); + end +else + hp = opts.HighpassSeconds; + if ~isempty(hp) && isfinite(hp) && hp > 0 + [X, design_info, n_hp] = local_add_highpass(X, design_info, TR, hp); + gkwy_info.mode = 'highpass'; + gkwy_info.highpass_seconds = hp; + gkwy_info.n_highpass = n_hp; + if opts.Verbose + fprintf('High-pass filter: DCT, cutoff %.0f s, %d confound regressors added.\n', hp, n_hp); + end + end +end + +% Grand-mean scaling (Tier A replicate of SPM's g) needs one global scalar +% over all in-mask voxels x time, computed before the chunked loop. +gm_scale = 1; +if strcmpi(char(opts.ScaleMode), 'grandmean') + gm = mean(double(data_obj.dat(:)), 'omitnan'); + if gm == 0 || ~isfinite(gm) + warning('hrf_fit_wholebrain_stats:GrandMeanZero', ... + 'Grand mean is zero/non-finite; skipping grand-mean scaling.'); + else + gm_scale = 100 / gm; + gkwy_info.grandmean_scaled = true; + end +end + +% Data-estimated prewhitening (Tier A+). Only when no SPM.mat supplied; with +% an SPM the whitening already came from W above. Estimated on a high-variance +% voxel subsample, then applied to data + design so the loop below is GLS. +if isempty(opts.SPM) && ~strcmpi(strtrim(char(opts.Whiten)), 'none') + [Wmat, ar_coef] = local_estimate_ar_whitening(data_obj.dat, X, char(opts.Whiten)); + data_obj.dat = single((Wmat * double(data_obj.dat'))'); % whiten data (vox x time) + X = Wmat * X; % whiten design + gkwy_info.whitened = true; + gkwy_info.whiten_mode = lower(strtrim(char(opts.Whiten))); + gkwy_info.ar_coef = ar_coef; + if opts.Verbose + fprintf('Prewhitening: %s, AR coef = [%s].\n', upper(strtrim(char(opts.Whiten))), ... + strtrim(sprintf('%.3f ', ar_coef(:)'))); + end +end + +[PX, pen] = local_get_pseudoinverse(X, design_info, opts.Mode); +hat_trace = trace(X * PX); +dfe = max(size(X, 1) - hat_trace - dof_correction, 1); +coef_var_scale = max(diag(design_info.output_lift * (PX * PX') * design_info.output_lift'), 0); + +n_keep = size(design_info.output_lift, 1); +n_vox = size(data_obj.dat, 1); + +beta_dat = zeros(n_vox, n_keep, 'single'); +ste_dat = zeros(n_vox, n_keep, 'single'); +t_dat = zeros(n_vox, n_keep, 'single'); +p_dat = zeros(n_vox, n_keep, 'single'); + +chunk_size = min(opts.ChunkSize, n_vox); +if opts.Verbose + fprintf('Fitting whole-brain %s model: %d voxels, %d time points, %d output maps\n', ... + upper(char(opts.Mode)), n_vox, size(X, 1), n_keep); +end + +for first_vox = 1:chunk_size:n_vox + last_vox = min(first_vox + chunk_size - 1, n_vox); + wh = first_vox:last_vox; + Y = double(data_obj.dat(wh, :)'); + + switch lower(char(opts.ScaleMode)) + case 'none' + % leave data in native units + case {'zscore', 'z'} + Y = zscore(Y, 0, 1); + Y(isnan(Y)) = 0; + case 'grandmean' + Y = Y * gm_scale; % single global scalar (SPM-style grand-mean to 100) + otherwise + error('Unknown ScaleMode: %s. Use ''none'', ''zscore'', or ''grandmean''.', char(opts.ScaleMode)); + end + + B = PX * Y; + R = Y - X * B; + mse = sum(R .^ 2, 1) ./ dfe; + SE = sqrt(coef_var_scale * mse); + + Bkeep = design_info.output_lift * B; + SEkeep = SE; + Tkeep = Bkeep ./ SEkeep; + Pkeep = 2 * (1 - tcdf(abs(Tkeep), dfe)); + Pkeep(Pkeep == 0) = eps; + + beta_dat(wh, :) = single(Bkeep'); + ste_dat(wh, :) = single(SEkeep'); + t_dat(wh, :) = single(Tkeep'); + p_dat(wh, :) = single(Pkeep'); + + if opts.Verbose + fprintf(' voxels %d-%d / %d\n', first_vox, last_vox, n_vox); + end +end + +labels = design_info.labels; +meta_table = design_info.metadata_table; +meta_table.N = repmat(size(X, 1), height(meta_table), 1); +meta_table.dfe = repmat(dfe, height(meta_table), 1); +meta_table.TR = repmat(TR, height(meta_table), 1); +meta_table.mode = repmat(string(upper(char(opts.Mode))), height(meta_table), 1); + +b_obj = statistic_image; +b_obj.type = sprintf('%s HRF beta', upper(char(opts.Mode))); +b_obj.dat = beta_dat; +b_obj.p = p_dat; +b_obj.p_type = sprintf('Two-tailed P-values from beta/SE, dfe = %.3f', dfe); +b_obj.ste = ste_dat; +b_obj.sig = true(size(beta_dat)); +b_obj.N = size(X, 1); +b_obj.dfe = dfe; +b_obj.volInfo = data_obj.volInfo; +b_obj.removed_voxels = data_obj.removed_voxels; +b_obj.removed_images = false(1, n_keep); +b_obj.image_labels = labels; +b_obj.dat_descrip = sprintf('Whole-brain %s HRF beta values: condition x lag maps', upper(char(opts.Mode))); + +t_obj = statistic_image; +t_obj.type = 'T'; +t_obj.dat = t_dat; +t_obj.p = p_dat; +t_obj.p_type = sprintf('Two-tailed P-values from beta/SE, dfe = %.3f', dfe); +t_obj.ste = ste_dat; +t_obj.sig = true(size(t_dat)); +t_obj.N = size(X, 1); +t_obj.dfe = dfe; +t_obj.volInfo = data_obj.volInfo; +t_obj.removed_voxels = data_obj.removed_voxels; +t_obj.removed_images = false(1, n_keep); +t_obj.image_labels = labels; +t_obj.dat_descrip = sprintf('Whole-brain %s HRF T values: condition x lag maps', upper(char(opts.Mode))); + +if ~isempty(opts.PThresh) + t_obj = threshold(t_obj, opts.PThresh, char(opts.ThreshType), 'noverbose'); +end + +out = struct(); +out.b = b_obj; +out.t = t_obj; +out.design_matrix = X; +out.design_info = design_info; +out.design_info.penalty = pen; +out.metadata_table = meta_table; +out.conditions = cond_names; +out.condition_groups = condition_groups; +out.TR = TR; +out.dfe = dfe; +out.N = size(X, 1); +out.gkwy = gkwy_info; +out.input_fmri = fmri_name; +out.paths = struct(); + +if ~isempty(opts.OutputPrefix) + out.paths = local_write_outputs(out, char(opts.OutputPrefix), logical(opts.Overwrite), logical(opts.WriteThresholdedT)); +end +end + +function SPM = local_load_spm(spm_in) +% Resolve the SPM arg to a struct (accepts a struct or a path to SPM.mat). +if isstruct(spm_in) + SPM = spm_in; +elseif ischar(spm_in) || isstring(spm_in) + f = char(spm_in); + if exist(f, 'file') ~= 2 + error('hrf_fit_wholebrain_stats:SPMNotFound', 'SPM.mat not found: %s', f); + end + S = load(f, 'SPM'); + if ~isfield(S, 'SPM') + error('hrf_fit_wholebrain_stats:NoSPMVar', '%s does not contain an SPM variable.', f); + end + SPM = S.SPM; +else + error('hrf_fit_wholebrain_stats:BadSPM', 'SPM must be a struct or a path to SPM.mat.'); +end +end + +function [data_obj, X, dof_corr, n_hp] = local_apply_spm_gkwy(data_obj, X, SPM, run) +% Replicate spmify()/spm_spm: scale data by g (SPM.xGX.gSF), high-pass (K) and +% whiten (W) both data and design. Handles two shapes of fMRI input against a +% (possibly multi-run, e.g. per-session-concatenated) SPM: +% (a) the WHOLE concatenated session -- n_tp == total SPM scans: apply every +% run's K block, the full (block-diagonal) W, and per-scan gSF. +% (b) a SINGLE run of a multi-run SPM -- n_tp == that run's scan count: select +% SPMRun and remap K(run).row (concatenated indices) to local 1:n_tp. +% Column layout of X is preserved, so design_info.output_lift / sFIR penalty +% stay valid. +if ~isfield(SPM, 'xX') || ~isfield(SPM.xX, 'K') || ~isfield(SPM.xX, 'W') + error('hrf_fit_wholebrain_stats:SPMNotEstimated', ... + 'SPM.mat must be ESTIMATED (needs xX.K, xX.W, xGX.gSF). Run spm_spm first.'); +end +K = SPM.xX.K; +nrun = numel(K); +n_tp = size(data_obj.dat, 2); +total_scans = size(SPM.xX.W, 1); + +if n_tp == total_scans + % (a) Whole concatenated session. + Kapply = K; % spm_filter loops over each K(s).row block + rows = 1:total_scans; + n_hp = local_sum_x0_cols(K); +else + % (b) One run from the SPM; remap its rows to local frames. + if run < 1 || run > nrun + error('hrf_fit_wholebrain_stats:BadSPMRun', ... + 'SPMRun=%d out of range (SPM has %d run(s)).', run, nrun); + end + rows = K(run).row; + if numel(rows) ~= n_tp + error('hrf_fit_wholebrain_stats:SPMRunMismatch', ... + ['fMRI image has %d time points, but neither the full SPM (%d scans) ' ... + 'nor SPM run %d (%d scans) matches. For a per-session SPM with ' ... + 'concatenated runs, pass the concatenated-session fMRI (matches the ' ... + 'full scan count), or set SPMRun to this run''s index within the session.'], ... + n_tp, total_scans, run, numel(rows)); + end + Kapply = K(run); + Kapply.row = 1:n_tp; % concatenated indices -> local frames + n_hp = local_sum_x0_cols(Kapply); +end + +W = SPM.xX.W(rows, rows); + +% Normalize the OVERALL SCALE of the whitening matrix (keep its decorrelation +% structure). SPM's W = V^-1/2 carries the noise-variance scale, so its +% diagonal can be far from 1 (e.g. ~11.5 for FAST here). That scale is +% irrelevant to the GLS betas/t (it cancels in (X'WX)\(X'WY) and in t=b/SE), +% but X'X is the DESIGN gram and the sFIR smoothness penalty is a FIXED matrix +% added to it -- an inflated W blows up X'X ~ scale^2 and renders the penalty +% negligible, giving under-regularized, wiggly sFIR. Dividing W by its mean +% diagonal keeps the design at its natural FIR scale so the penalty stays +% balanced (matching the pre-GKWY behavior), without changing OLS betas or t. +wscale = mean(full(diag(W))); +if wscale > 0 && isfinite(wscale) + W = W / wscale; +end + +% Whiten + high-pass the data. spm_filter works on [time x columns]. +Y = double(data_obj.dat'); % time x vox +KWY = spm_filter(Kapply, W * Y); + +% Global (grand-mean) scaling per scan, exactly as spmify does. +if isfield(SPM, 'xGX') && isfield(SPM.xGX, 'gSF') && ~isempty(SPM.xGX.gSF) + g = SPM.xGX.gSF(rows); + KWY = KWY .* g(:); +else + warning('hrf_fit_wholebrain_stats:NoGSF', ... + 'SPM.xGX.gSF missing; skipped global scaling (K and W still applied).'); +end +data_obj.dat = single(KWY'); % vox x time + +% Apply the SAME K and W to the design. +X = full(spm_filter(Kapply, W * X)); + +dof_corr = n_hp; % high-pass confounds are projected out, not columns +end + +function n = local_sum_x0_cols(K) +% Total high-pass confound regressors across all K blocks. +n = 0; +for s = 1:numel(K) + if isfield(K(s), 'X0') && ~isempty(K(s).X0) + n = n + size(K(s).X0, 2); + end +end +end + +function [X, info, n_added] = local_add_highpass(X, info, TR, cutoff) +% Tier A: append SPM-style DCT high-pass confounds as nuisance columns. +% This removes low-frequency drift from the task betas (equivalent to K) and +% the added columns are counted in the OLS dof automatically. +D = local_dct_highpass(size(X, 1), TR, cutoff); +n_added = size(D, 2); +if n_added == 0, return; end +X = [X, D]; +nkeep = size(info.output_lift, 1); +info.output_lift = [info.output_lift, zeros(nkeep, n_added)]; +info.highpass_columns = (size(X, 2) - n_added + 1):size(X, 2); +info.highpass_seconds = cutoff; +end + +function [W, ar] = local_estimate_ar_whitening(dat, X, wmode) +% Estimate a single global AR(p) whitening matrix from OLS residuals of a +% high-variance voxel subsample, then return W such that W*Y is ~white. +switch lower(strtrim(wmode)) + case 'ar1', p = 1; + case 'ar2', p = 2; + otherwise + error('hrf_fit_wholebrain_stats:UnknownWhiten', ... + 'Whiten must be ''none'', ''ar1'', or ''ar2''. Got ''%s''.', wmode); +end +n_tp = size(dat, 2); + +% Subsample voxels (highest variance, finite) to estimate AR cheaply. +v = var(double(dat), 0, 2); +v(~isfinite(v)) = 0; +n_pos = sum(v > 0); +nsamp = min(2000, max(n_pos, 1)); +[~, ord] = sort(v, 'descend'); +samp = ord(1:nsamp); +Ys = double(dat(samp, :)'); % time x nsamp +R = Ys - X * (pinv(X) * Ys); % OLS residuals on the (high-passed) design + +% Pooled normalized autocovariance across sampled voxels (lags 0..p). +acf = zeros(p + 1, 1); +for k = 0:p + rr = R(1:end - k, :) .* R(1 + k:end, :); + acf(k + 1) = mean(rr(:)); +end +if acf(1) <= 0 + W = speye(n_tp); ar = zeros(p, 1); return; +end +acf = acf / acf(1); + +% Yule-Walker for AR(p), then enforce stationarity. +ar = toeplitz(acf(1:p)) \ acf(2:p + 1); +ar = local_make_stationary(ar); + +% Theoretical AR(p) autocorrelation over all lags, then whiten via inv(chol). +full_acf = local_ar_acf(ar, n_tp); +V = toeplitz(full_acf); +L = chol(V + 1e-8 * eye(n_tp), 'lower'); +W = inv(L); +end + +function ar = local_make_stationary(ar) +% Shrink AR coefficients toward zero until the process is stationary. +for it = 1:50 + if isscalar(ar) + ok = abs(ar(1)) < 0.999; + else + ok = abs(ar(2)) < 0.999 && (ar(1) + ar(2)) < 0.999 && (ar(2) - ar(1)) < 0.999; + end + if ok, return; end + ar = ar * 0.95; +end +ar = zeros(size(ar)); +end + +function a = local_ar_acf(ar, n) +% Theoretical autocorrelation of an AR(p) process, lags 0..n-1 (a(1)=lag 0). +a = zeros(n, 1); a(1) = 1; +if isscalar(ar) + rho = max(min(ar(1), 0.999), -0.999); + a = (rho .^ (0:n - 1))'; + return; +end +ar1 = ar(1); ar2 = ar(2); +if n >= 2, a(2) = ar1 / (1 - ar2); end +for k = 3:n + a(k) = ar1 * a(k - 1) + ar2 * a(k - 2); +end +end + +function D = local_dct_highpass(n_tp, TR, cutoff) +% SPM's high-pass basis: spm_dctmtx(N, k) with k = fix(2*N*TR/cutoff + 1), +% dropping the DC (constant) column since X already carries an intercept. +k = fix(2 * (n_tp * TR) / cutoff + 1); +if k <= 1 + D = zeros(n_tp, 0); + return; +end +D = spm_dctmtx(n_tp, k); +D = D(:, 2:end); +end + +function [TR, n_tp] = local_get_tr_and_ntp(fmri_name, requested_tr) +info = niftiinfo(fmri_name); +sz = info.ImageSize; +if numel(sz) < 4 + error('Expected a 4D fMRI image, got %d dimensions.', numel(sz)); +end +n_tp = sz(4); + +TR = requested_tr; +if isempty(TR) && isfield(info, 'PixelDimensions') && numel(info.PixelDimensions) >= 4 + TR = info.PixelDimensions(4); +end +if isempty(TR) || TR <= 0 + error('Could not infer TR from NIfTI header. Pass ''TR'' explicitly.'); +end +end + +function [X, info] = local_build_wholebrain_design(Runc, cond_names, TR, window_seconds, mode, nuisance) +switch lower(char(mode)) + case {'fir', 'sfir'} + [X, info] = local_build_fir_design(Runc, cond_names, TR, window_seconds, mode, nuisance); + case 'canonical' + [X, info] = local_build_canonical_design(Runc, cond_names, TR, window_seconds, mode, nuisance); + case 'spline' + [X, info] = local_build_spline_design(Runc, cond_names, TR, window_seconds, mode, nuisance); + otherwise + error('Unknown Mode: %s. Use ''FIR'', ''sFIR'', ''canonical'', or ''spline''.', char(mode)); +end +end + +function [X, info] = local_build_fir_design(Runc, cond_names, TR, window_seconds, mode, nuisance) +numstim = numel(Runc); +len = numel(Runc{1}); +if isscalar(window_seconds) + window_seconds = repmat(window_seconds, 1, numstim); +end +if numel(window_seconds) ~= numstim + error('WindowSeconds must be scalar or one value per condition.'); +end + +Runs = zeros(len, numstim); +for i = 1:numstim + Runs(:, i) = Runc{i}(:); +end + +DX_all = cell(1, numstim); +tlen_all = zeros(1, numstim); +fir_columns_by_condition = cell(1, numstim); +labels = {}; +condition_col = {}; +condition_index = []; +lag_index = []; +lag_seconds = []; + +start_col = 1; +for i = 1:numstim + t = 1:TR:window_seconds(i); + tlen_all(i) = numel(t); + DX_i = tor_make_deconv_mtx3(Runs(:, i), tlen_all(i), 1); + DX_all{i} = DX_i(:, 1:tlen_all(i)); + + fir_columns_by_condition{i} = start_col:(start_col + tlen_all(i) - 1); + for j = 1:tlen_all(i) + label = sprintf('%s_lag%03d_%0.3gs', local_safe_label(cond_names{i}), j, t(j)); + labels{end + 1, 1} = label; %#ok + condition_col{end + 1, 1} = cond_names{i}; %#ok + condition_index(end + 1, 1) = i; %#ok + lag_index(end + 1, 1) = j; %#ok + lag_seconds(end + 1, 1) = t(j); %#ok + end + start_col = start_col + tlen_all(i); +end + +DX = horzcat(DX_all{:}); +X = [DX ones(len, 1)]; + +if ~isempty(nuisance) + if size(nuisance, 1) ~= len + error('Nuisance matrix must have %d rows to match fMRI time points.', len); + end + X = [X nuisance]; +end + +info = struct(); +info.mode = char(mode); +info.TR = TR; +info.tlen = tlen_all; +info.fir_columns = 1:sum(tlen_all); +info.fir_columns_by_condition = fir_columns_by_condition; +info.intercept_column = sum(tlen_all) + 1; +info.nuisance_columns = (info.intercept_column + 1):size(X, 2); +info.labels = labels; +info.metadata_table = table((1:numel(labels))', condition_col, condition_index, lag_index, lag_seconds, labels, ... + 'VariableNames', {'volume_index', 'condition', 'condition_index', 'lag_index', 'lag_seconds', 'image_label'}); +info.output_lift = zeros(numel(labels), size(X, 2)); +for i = 1:numel(labels) + info.output_lift(i, i) = 1; +end +end + +function [X, info] = local_build_canonical_design(Runc, cond_names, TR, window_seconds, mode, nuisance) +[~, t, tlen] = local_scalar_window(window_seconds, TR, mode); +numstim = numel(Runc); +len = numel(Runc{1}); +h = local_canonical_basis(TR, tlen); + +Xtask = zeros(len, numstim); +for i = 1:numstim + v = conv(Runc{i}(:), h(:)); + Xtask(:, i) = v(1:len); +end +X = [ones(len, 1), Xtask]; +if ~isempty(nuisance) + if size(nuisance, 1) ~= len + error('Nuisance matrix must have %d rows to match fMRI time points.', len); + end + X = [X nuisance]; +end + +[labels, condition_col, condition_index, lag_index, lag_seconds] = ... + local_condition_lag_metadata(cond_names, t, mode); + +info = struct(); +info.mode = char(mode); +info.TR = TR; +info.tlen = repmat(tlen, 1, numstim); +info.intercept_column = 1; +info.nuisance_columns = (numstim + 2):size(X, 2); +info.labels = labels; +info.metadata_table = table((1:numel(labels))', condition_col, condition_index, lag_index, lag_seconds, labels, ... + 'VariableNames', {'volume_index', 'condition', 'condition_index', 'lag_index', 'lag_seconds', 'image_label'}); +info.output_lift = zeros(numel(labels), size(X, 2)); +for c = 1:numstim + col = c + 1; + rows = ((c - 1) * tlen + 1):(c * tlen); + info.output_lift(rows, col) = h(:); +end +end + +function [X, info] = local_build_spline_design(Runc, cond_names, TR, window_seconds, mode, nuisance) +[window_seconds, t, tlen] = local_scalar_window(window_seconds, TR, mode); %#ok +numstim = numel(Runc); +len = numel(Runc{1}); +K = 8; +norder = 4; + +try + basis = create_bspline_basis([0, tlen], K + 3, norder); + Bbasis = eval_basis((1:tlen), basis); +catch + error('Mode=''spline'' requires the FDA package on the MATLAB path (missing create_bspline_basis/eval_basis; see https://github.com/markgewhite/fda).'); +end +Bbasis = Bbasis(:, 3:end-1); + +Wi = zeros(len, numstim * K); +for c = 1:numstim + Wc = tor_make_deconv_mtx3(Runc{c}(:), tlen, 1); + Wi(:, (c - 1) * K + 1:c * K) = Wc(:, 1:tlen) * Bbasis; +end +X = [ones(len, 1), Wi]; +if ~isempty(nuisance) + if size(nuisance, 1) ~= len + error('Nuisance matrix must have %d rows to match fMRI time points.', len); + end + X = [X nuisance]; +end + +[labels, condition_col, condition_index, lag_index, lag_seconds] = ... + local_condition_lag_metadata(cond_names, t, mode); + +info = struct(); +info.mode = char(mode); +info.TR = TR; +info.tlen = repmat(tlen, 1, numstim); +info.intercept_column = 1; +info.nuisance_columns = (numstim * K + 2):size(X, 2); +info.labels = labels; +info.metadata_table = table((1:numel(labels))', condition_col, condition_index, lag_index, lag_seconds, labels, ... + 'VariableNames', {'volume_index', 'condition', 'condition_index', 'lag_index', 'lag_seconds', 'image_label'}); +info.output_lift = zeros(numel(labels), size(X, 2)); +for c = 1:numstim + rows = ((c - 1) * tlen + 1):(c * tlen); + cols = (c - 1) * K + 2:c * K + 1; + info.output_lift(rows, cols) = Bbasis; +end +end + +function [window_seconds, t, tlen] = local_scalar_window(window_seconds, TR, mode) +if ~isscalar(window_seconds) + if all(window_seconds == window_seconds(1)) + window_seconds = window_seconds(1); + else + error('WindowSeconds must be scalar for whole-brain Mode=''%s''.', char(mode)); + end +end +t = 1:TR:window_seconds; +tlen = numel(t); +end + +function h = local_canonical_basis(TR, tlen) +len = max(round(30 / TR), tlen); +xBF.dt = TR; +xBF.length = len; +xBF.name = 'hrf (with time and dispersion derivatives)'; +xBF = spm_get_bf(xBF); +h = xBF.bf(1:tlen, 1); +h = h ./ max(h); +end + +function [labels, condition_col, condition_index, lag_index, lag_seconds] = local_condition_lag_metadata(cond_names, t, mode) +labels = {}; +condition_col = {}; +condition_index = []; +lag_index = []; +lag_seconds = []; +for c = 1:numel(cond_names) + for j = 1:numel(t) + label = sprintf('%s_%s_lag%03d_%0.3gs', local_safe_label(mode), local_safe_label(cond_names{c}), j, t(j)); + labels{end + 1, 1} = label; %#ok + condition_col{end + 1, 1} = cond_names{c}; %#ok + condition_index(end + 1, 1) = c; %#ok + lag_index(end + 1, 1) = j; %#ok + lag_seconds(end + 1, 1) = t(j); %#ok + end +end +end + +function [PX, pen] = local_get_pseudoinverse(X, info, mode) +mode = lower(char(mode)); +pen = zeros(size(X, 2)); + +switch mode + case {'fir', 'canonical', 'spline'} + PX = pinv(X); + case 'sfir' + start_idx = 1; + for i = 1:numel(info.tlen) + tlen = info.tlen(i); + C = (1:tlen)' * ones(1, tlen); + h = sqrt(1 / (7 / info.TR)); + v = 0.1; + sig = 1; + R = v * exp(-h / 2 * (C - C') .^ 2); + RI = R \ eye(size(R)); + end_idx = start_idx + tlen - 1; + pen(start_idx:end_idx, start_idx:end_idx) = sig ^ 2 * RI; + start_idx = end_idx + 1; + end + PX = (X' * X + pen) \ X'; + otherwise + error('Unknown Mode: %s. Use ''FIR'', ''sFIR'', ''canonical'', or ''spline''.', mode); +end +end + +function paths = local_write_outputs(out, prefix, overwrite, write_thresholded_t) +paths = struct(); +paths.beta = [prefix '_beta.nii']; +paths.t = [prefix '_t.nii']; +paths.se = [prefix '_se.nii']; +paths.p = [prefix '_p.nii']; +paths.metadata = [prefix '_metadata.csv']; + +local_write_image(out.b, paths.beta, overwrite); + +local_write_image(out.t, paths.t, overwrite); + +se_obj = out.b; +se_obj.type = 'HRF beta standard error'; +se_obj.dat = out.b.ste; +se_obj.dat_descrip = 'Whole-brain HRF beta standard error maps'; +local_write_image(se_obj, paths.se, overwrite); + +p_obj = out.t; +p_obj.type = 'p'; +p_obj.dat = out.t.p; +p_obj.p = out.t.p; +p_obj.dat_descrip = 'Whole-brain HRF two-tailed p-value maps'; +local_write_image(p_obj, paths.p, overwrite); + +local_delete_if_overwrite(paths.metadata, overwrite); +writetable(out.metadata_table, paths.metadata); + +if write_thresholded_t + paths.t_thresholded = [prefix '_t_thresh.nii']; + local_write_image(out.t, paths.t_thresholded, overwrite, 'thresh'); +end +end + +function local_write_image(obj, fname, overwrite, varargin) +local_delete_if_overwrite(fname, overwrite); +write_args = [{'fname', fname}, varargin(:)']; +if overwrite + write_args{end + 1} = 'overwrite'; +end +obj = local_prepare_image_for_write(obj); +write(obj, write_args{:}); +end + +function obj = local_prepare_image_for_write(obj) +if ~isprop(obj, 'removed_images') || isempty(obj.removed_images) || isempty(obj.dat) + return +end + +removed = logical(obj.removed_images(:)'); +n_images = size(obj.dat, 2); +if numel(removed) ~= n_images + return +end + +if any(removed) + % threshold() can mark 4D maps as removed even when .dat still contains + % the full volume series. image_vector/write() reinserts removed images, + % so reset this flag when the image count is already complete. + obj.removed_images = false(size(obj.removed_images)); +end +end + +function local_delete_if_overwrite(fname, overwrite) +if exist(fname, 'file') ~= 2 + return +end +if ~overwrite + error('Output file already exists: %s. Use Overwrite=true to replace it.', fname); +end + +try + fileattrib(fname, '+w'); +catch +end +try + delete(fname); +catch err + error('Could not overwrite existing output file %s: %s', fname, err.message); +end +end + +function s = local_safe_label(s) +s = char(s); +s = matlab.lang.makeValidName(s); +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_granger_causality.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_granger_causality.m new file mode 100644 index 00000000..17256a64 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_granger_causality.m @@ -0,0 +1,333 @@ +function G = hrf_granger_causality(X, varargin) +%HRF_GRANGER_CAUSALITY Directed Granger causality on (deconvolved) timeseries. +% +% Pooled-VAR Granger causality for a set of signals (regions/signatures), +% intended to run on the HRF-DECONVOLVED proxies from hrf_deconv_timeseries +% so the directionality reflects neural lead/lag rather than regional +% hemodynamic latency. Handles multiple runs/subjects as separate +% realizations of a common VAR (no autoregression across run boundaries), +% chooses model order by BIC, and reports both the directed influence matrix +% and its asymmetry (net flow). +% +% Granger logic: i "Granger-causes" j if the past of i improves prediction +% of j beyond j's own past (pairwise), or beyond j's own past AND every +% other node's past (conditional / multivariate -- removes influence routed +% through or shared with third nodes). +% +% Usage +% ----- +% G = hrf_granger_causality(X) % one run, [T x N] +% G = hrf_granger_causality({X1, X2, ...}) % multi-run/subject +% G = hrf_granger_causality(X, 'Conditional', true, 'Nodes', names) +% G = hrf_granger_causality(X, 'Order', 4, 'Nperm', 1000) +% +% Inputs +% ------ +% X - [T x N] timeseries (columns = nodes), OR a cell array of such +% matrices (one per run/subject), each with the SAME N columns. +% +% Optional (name-value) +% --------------------- +% 'Nodes' - cellstr/string of N node names (for labeling). Default {}. +% 'Order' - VAR model order: integer, or 'bic' (default) / 'aic' to +% pick over 1..MaxOrder. +% 'MaxOrder' - max order searched when Order is 'bic'/'aic'. Default 10. +% 'Conditional' - true => conditional MVGC (condition on all other nodes); +% false (default) => pairwise GC. +% 'Detrend' - linearly detrend each run column first. Default true. +% 'Zscore' - z-score each run column first (recommended; makes runs +% commensurate before pooling). Default true. +% 'Nperm' - 0 (default) => parametric F-test p-values. >0 => add a +% null by circularly shifting the source within each run +% (preserves each series' autocorrelation), p_perm column. +% 'Verbose'/'doverbose' - print a short summary. Default true. +% +% Outputs +% ------- +% G - struct: +% .gc [N x N] directed influence; gc(i,j) = i -> j (log-ratio of +% restricted/full residual variance, >= 0). +% .fstat [N x N] F statistic for each i -> j. +% .pval [N x N] parametric F-test p-value (diag = NaN). +% .pval_perm [N x N] permutation p-value (only if Nperm > 0). +% .net [N x N] gc - gc' (positive = net flow i -> j). +% .order scalar VAR order used. +% .conditional logical +% .n_eff scalar pooled samples used. +% .nodes string node names. +% +% Notes / caveats +% --------------- +% * Run this on DECONVOLVED signals. On raw BOLD, regional HRF differences +% can reverse the inferred direction (David et al. 2008). See +% hrf_deconv_timeseries. +% * Task-evoked common drive can manufacture spurious GC. Remove the evoked +% task mean (or analyze within a condition) before calling this if your +% question is about endogenous coupling. +% * GC is a statistical (predictive) notion of directed influence, not proof +% of a physical mechanism. Treat results as hypotheses; corroborate with a +% generative model (DCM) for strong claims. +% +% Example +% ------- +% T = 600; a = randn(T,1); b = zeros(T,1); +% for t = 3:T, b(t) = 0.5*b(t-1) + 0.6*a(t-2) + 0.3*randn; end % a -> b +% G = hrf_granger_causality([a b], 'Nodes', {'A','B'}); +% G.net % G.net(1,2) > 0 => net flow A -> B +% +% See also: hrf_deconv_timeseries, hrf_causality, hrf_misspec_metrics. + +p = inputParser; +p.addRequired('X', @(x) isnumeric(x) || iscell(x)); +p.addParameter('Nodes', {}, @(x) iscell(x) || isstring(x) || ischar(x)); +p.addParameter('Order', 'bic', @(x) (ischar(x) || isstring(x)) || (isscalar(x) && x >= 1)); +p.addParameter('MaxOrder', 10, @(x) isscalar(x) && x >= 1); +p.addParameter('Conditional', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('Detrend', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('Zscore', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('Nperm', 0, @(x) isscalar(x) && x >= 0); +p.addParameter('Verbose', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('doverbose', [], @(x) isempty(x) || islogical(x) || isnumeric(x)); +p.parse(X, varargin{:}); +opts = p.Results; +verbose = logical(opts.Verbose); +if ~isempty(opts.doverbose), verbose = logical(opts.doverbose); end + +% ---- normalize to a cell of runs, preprocess ---------------------------- +if isnumeric(X), runs = {X}; else, runs = X(:)'; end +N = size(runs{1}, 2); +for r = 1:numel(runs) + if size(runs{r}, 2) ~= N + error('hrf_granger_causality:Ncols', 'All runs must have the same N columns.'); + end + Xr = double(runs{r}); + if logical(opts.Detrend), Xr = detrend(Xr, 1); end + if logical(opts.Zscore), Xr = local_zscore(Xr); end + runs{r} = Xr; +end + +nodes = local_node_names(opts.Nodes, N); +conditional = logical(opts.Conditional); + +% ---- model order -------------------------------------------------------- +if ischar(opts.Order) || isstring(opts.Order) + porder = local_select_order(runs, opts.MaxOrder, lower(char(opts.Order))); +else + porder = round(opts.Order); +end + +% ---- pooled target + lag tensor ----------------------------------------- +[Y, Lag, run_rows] = local_build_lags(runs, porder); % Y [M x N], Lag [M x N x p] +M = size(Y, 1); +if M <= N * porder + 2 + error('hrf_granger_causality:TooFewSamples', ... + 'Only %d pooled samples for order %d, N=%d -- increase data or lower Order.', M, porder, N); +end + +% ---- GC matrix ---------------------------------------------------------- +[gc, fstat, pval, df2] = local_gc_matrix(Y, Lag, porder, conditional); + +G = struct(); +G.gc = gc; G.fstat = fstat; G.pval = pval; +G.net = gc - gc'; +G.order = porder; G.conditional = conditional; +G.n_eff = M; G.nodes = string(nodes); +G.df = [porder, df2]; + +% ---- permutation null (optional) ---------------------------------------- +if opts.Nperm > 0 + G.pval_perm = local_perm_pvals(runs, porder, conditional, gc, opts.Nperm, run_rows); +end + +if verbose + fprintf('hrf_granger_causality: N=%d nodes, %d run(s), order=%d (%s), %s, M=%d samples\n', ... + N, numel(runs), porder, local_order_src(opts.Order), ... + local_tern(conditional, 'conditional MVGC', 'pairwise'), M); + [~, ix] = max(G.net(:)); + [si, di] = ind2sub([N N], ix); + fprintf(' strongest net flow: %s -> %s (net=%.3f, p=%.2g)\n', ... + nodes{si}, nodes{di}, G.net(si, di), pval(si, di)); +end +end + + +% ========================================================================= +function [gc, fstat, pval, df2] = local_gc_matrix(Y, Lag, p, conditional) +N = size(Y, 2); +M = size(Y, 1); +gc = zeros(N); fstat = zeros(N); pval = nan(N); +intercept = ones(M, 1); + +for j = 1:N + yj = Y(:, j); + ownj = local_lagblock(Lag, j); % M x p + + if conditional + % full = intercept + own + ALL other nodes' lags + others = setdiff(1:N, j); + fullblk = [intercept, ownj, local_lagblock(Lag, others)]; + rss_full = local_rss(yj, fullblk); + df_full = size(fullblk, 2); + df2 = M - df_full; + for i = others + % restricted = full minus source i's lag columns + keep = setdiff(others, i); + restr = [intercept, ownj, local_lagblock(Lag, keep)]; + rss_r = local_rss(yj, restr); + [gc(i, j), fstat(i, j), pval(i, j)] = local_gc_stat(rss_r, rss_full, p, df2); + end + else + restr = [intercept, ownj]; + rss_r = local_rss(yj, restr); + df_full = size(restr, 2) + p; + df2 = M - df_full; + for i = 1:N + if i == j, continue; end + fullblk = [intercept, ownj, local_lagblock(Lag, i)]; + rss_f = local_rss(yj, fullblk); + [gc(i, j), fstat(i, j), pval(i, j)] = local_gc_stat(rss_r, rss_f, p, df2); + end + end +end +end + + +function [g, F, pv] = local_gc_stat(rss_r, rss_f, p, df2) +rss_f = max(rss_f, realmin); +g = max(log(rss_r / rss_f), 0); +F = ((rss_r - rss_f) / p) / (rss_f / df2); +F = max(F, 0); +pv = 1 - local_fcdf(F, p, df2); +end + + +function B = local_lagblock(Lag, cols) +% Flatten Lag(:, cols, :) into [M x (numel(cols)*p)]. +[M, ~, p] = size(Lag); +cols = cols(:)'; +B = zeros(M, numel(cols) * p); +c = 0; +for ci = cols + B(:, c + (1:p)) = reshape(Lag(:, ci, :), M, p); + c = c + p; +end +end + + +function rss = local_rss(y, D) +b = D \ y; +res = y - D * b; +rss = sum(res .^ 2); +end + + +function [Y, Lag, run_rows] = local_build_lags(runs, p) +% Build pooled target Y [M x N] and lag tensor Lag [M x N x p], aligned so +% row m of Y corresponds to lags Lag(m, :, 1..p). Lags never cross run edges. +N = size(runs{1}, 2); +blocks = cellfun(@(r) max(size(r, 1) - p, 0), runs); +M = sum(blocks); +Y = zeros(M, N); +Lag = zeros(M, N, p); +run_rows = cell(1, numel(runs)); +off = 0; +for r = 1:numel(runs) + Xr = runs{r}; Tr = size(Xr, 1); + if Tr <= p, run_rows{r} = []; continue; end + rows = (off + 1):(off + (Tr - p)); + Y(rows, :) = Xr(p + 1:Tr, :); + for k = 1:p + Lag(rows, :, k) = Xr(p + 1 - k:Tr - k, :); + end + run_rows{r} = rows; + off = off + (Tr - p); +end +end + + +function porder = local_select_order(runs, maxord, crit) +% Pick VAR order by AIC/BIC on the pooled N-variate model. Fit each candidate +% on the SAME sample (trimmed to maxord) so scores are comparable. +N = size(runs{1}, 2); +best = inf; porder = 1; +for p = 1:maxord + [Y, Lag] = local_build_lags(runs, p); + M = size(Y, 1); + if M <= N * p + 2, break; end + intercept = ones(M, 1); + resid = zeros(M, N); + for j = 1:N + D = [intercept, local_lagblock(Lag, 1:N)]; + b = D \ Y(:, j); + resid(:, j) = Y(:, j) - D * b; + end + Sigma = (resid' * resid) / M; + ld = local_logdet(Sigma); + nparams = N * (N * p + 1); + switch crit + case 'aic', score = M * ld + 2 * nparams; + otherwise, score = M * ld + nparams * log(M); % bic + end + if score < best, best = score; porder = p; end +end +end + + +function pv = local_perm_pvals(runs, p, conditional, gc_obs, nperm, ~) +% Null by circularly shifting each SOURCE series within each run (preserves +% per-series autocorrelation, breaks directed coupling). Source-specific +% shift => recompute that source's contribution. +N = size(runs{1}, 2); +ge = zeros(N); % count of null >= observed +for b = 1:nperm + sruns = runs; + for r = 1:numel(sruns) + Tr = size(sruns{r}, 1); + if Tr < 4, continue; end + sh = 1 + mod(b * 7 + r * 13, Tr - 1); % deterministic, varied shift + sruns{r} = circshift(sruns{r}, sh, 1); + end + [Yp, Lagp] = local_build_lags(sruns, p); + gcp = local_gc_matrix(Yp, Lagp, p, conditional); + ge = ge + (gcp >= gc_obs); +end +pv = (1 + ge) / (1 + nperm); +pv(logical(eye(N))) = NaN; +end + + +% ========================================================================= +function Z = local_zscore(X) +mu = mean(X, 1); sd = std(X, 0, 1); sd(sd == 0) = 1; +Z = (X - mu) ./ sd; +end + +function names = local_node_names(nodes, N) +if isempty(nodes) + names = arrayfun(@(i) sprintf('n%d', i), 1:N, 'uni', 0); +else + names = cellstr(string(nodes)); + if numel(names) ~= N + error('hrf_granger_causality:Nodes', 'Nodes has %d names but N=%d.', numel(names), N); + end +end +end + +function ld = local_logdet(S) +[Rc, fl] = chol(S); +if fl == 0, ld = 2 * sum(log(diag(Rc))); else, ld = log(max(det(S), realmin)); end +end + +function pcdf = local_fcdf(F, d1, d2) +% F CDF via regularized incomplete beta (avoids a Stats-toolbox dependency). +x = d1 * F / (d1 * F + d2); +pcdf = betainc(x, d1 / 2, d2 / 2); +end + +function s = local_order_src(o) +if ischar(o) || isstring(o), s = char(o); else, s = 'fixed'; end +end + +function s = local_tern(c, a, b) +if c, s = a; else, s = b; end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_group_stats.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_group_stats.m new file mode 100644 index 00000000..bfdaeae1 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_group_stats.m @@ -0,0 +1,289 @@ +function G = hrf_group_stats(data, varargin) +%HRF_GROUP_STATS Group inference with permutation correction (shared engine). +% +% One canonical group-level test used across the HRF causality/mediation/curve +% API, so every summary shares the same small-n-appropriate inference instead +% of ad-hoc parametric t + FDR. Given a per-subject stack it returns the group +% estimate, t, uncorrected p, and a CORRECTED p / significance mask by the +% chosen method: +% +% 'permutation' (default) - sign-flip (one-sample) or label (two-sample) +% permutation, FWER-controlled over the tested family via the +% maximum |t| statistic (Nichols & Holmes 2002). Exact when +% 2^n is enumerated (one-sample, n<=13). The whole array is +% permuted per subject, so correlations across cells are +% preserved under the null -- the right choice at n~7-11. +% 'cluster' - temporal cluster-mass permutation (Maris & Oostenveld 2007) +% along 'ClusterDim' (e.g. lag); needs a 2-D cell array. +% 'fdr' - Benjamini-Hochberg across the tested family. +% 'none' - uncorrected. +% +% :Usage: +% :: +% G = hrf_group_stats(net_subj, 'Mask', ~eye(N)) % connectivity edges +% G = hrf_group_stats(curve_subj, 'ClusterDim', 1, 'Correction','cluster') +% G = hrf_group_stats(x, 'Design','twosample', 'Group', grp) +% +% :Inputs: +% **data:** numeric array [d1 x ... x dk x nSubj]; the LAST dimension indexes +% subjects. Cells are the tested family (e.g. edges, term x lag). +% +% :Optional Inputs: +% **'Design':** 'onesample' (default; test mean~=0 by sign-flip) or +% 'twosample' (test group difference by label permutation). +% **'Group':** for twosample: [nSubj] vector of two group labels. +% **'Correction':** 'permutation'|'cluster'|'fdr'|'fdr_all'|'none'. Default +% 'permutation'. +% **'Threshold':** corrected-p / FWER threshold. Default 0.05. +% **'Nperm':** permutations when the exact set is too large. Default 5000. +% **'Mask':** logical over [d1..dk] of cells to include in the family +% (e.g. off-diagonal). Default: cells finite in >=1 subject. +% **'ClusterDim':** dimension for 'cluster' contiguity (2-D data only). +% **'ClusterFormP':** cluster-forming p. Default 0.05. +% +% :Output: +% **G:** struct with .est, .t, .p, .p_corr, .sig (all size [d1..dk]), plus +% .n, .correction, .design, .mask. +% +% See also: hrf_causality_analyze, hrf_causality_contrast, hrf_dcm, +% hrf_mediation_analyze, hrf_animate_wordcloud. + +p = inputParser; +p.addRequired('data', @isnumeric); +p.addParameter('Design', 'onesample', @(x) ischar(x) || isstring(x)); +p.addParameter('Group', [], @(x) isempty(x) || isvector(x) || iscell(x)); +p.addParameter('Correction', 'permutation', @(x) ischar(x) || isstring(x)); +p.addParameter('Threshold', 0.05, @(x) isscalar(x) && x > 0 && x <= 1); +p.addParameter('Nperm', 5000, @(x) isscalar(x) && x >= 100); +p.addParameter('Mask', [], @(x) isempty(x) || islogical(x) || isnumeric(x)); +p.addParameter('ClusterDim', [], @(x) isempty(x) || isscalar(x)); +p.addParameter('ClusterFormP', 0.05, @(x) isscalar(x) && x > 0 && x < 1); +p.parse(data, varargin{:}); +opts = p.Results; + +sz = size(data); +nsub = sz(end); +dims = sz(1:end - 1); +if isscalar(dims), dims = [dims, 1]; end +ncell = prod(dims); +X = reshape(data, ncell, nsub); % [cells x nSubj] +design = lower(char(opts.Design)); +corr = lower(char(opts.Correction)); + +% family mask (over cells) +if isempty(opts.Mask) + mask = any(isfinite(X), 2); +else + mask = logical(opts.Mask(:)); +end + +% two-sample group labels +if strcmp(design, 'twosample') + g = local_two_groups(opts.Group, nsub); +else + g = []; +end + +% observed statistic +[t_obs, est] = local_stat(X, design, g); +[pobs] = local_param_p(t_obs, X, design, g); + +% correction +switch corr + case {'permutation', 'perm', 'maxt'} + pcorr = local_perm_fwe(X, mask, design, g, t_obs, opts.Nperm); + sig = pcorr <= opts.Threshold; + case {'cluster', 'tcluster'} + [pcorr, sig] = local_perm_cluster(X, mask, dims, design, g, opts); + case {'fdr_all', 'fdrall', 'fdr'} + pcorr = nan(ncell, 1); + pcorr(mask) = local_bh(pobs(mask)); + sig = pcorr <= opts.Threshold; + otherwise % 'none' + pcorr = pobs; + sig = pobs < opts.Threshold; +end +sig(~mask) = false; +pobs(~mask) = NaN; pcorr(~mask) = NaN; + +G = struct(); +G.est = reshape(est, dims); +G.t = reshape(t_obs, dims); +G.p = reshape(pobs, dims); +G.p_corr = reshape(pcorr, dims); +G.sig = reshape(sig, dims); +G.n = nsub; +G.correction = corr; +G.design = design; +G.mask = reshape(mask, dims); +end + + +% ========================================================================= +function [t, est] = local_stat(X, design, g) +% t and estimate per cell (rows of X = cells, cols = subjects). +if strcmp(design, 'twosample') + A = X(:, g == 1); B = X(:, g == 2); + mA = mean(A, 2, 'omitnan'); mB = mean(B, 2, 'omitnan'); + vA = var(A, 0, 2, 'omitnan'); vB = var(B, 0, 2, 'omitnan'); + nA = sum(isfinite(A), 2); nB = sum(isfinite(B), 2); + se = sqrt(vA ./ max(nA, 1) + vB ./ max(nB, 1)); + t = (mA - mB) ./ se; t(se == 0) = 0; + est = mA - mB; +else + m = mean(X, 2, 'omitnan'); + se = std(X, 0, 2, 'omitnan') ./ sqrt(sum(isfinite(X), 2)); + t = m ./ se; t(se == 0) = 0; + est = m; +end +t(~isfinite(t)) = 0; +end + + +function pp = local_param_p(t, X, design, g) +if strcmp(design, 'twosample') + A = X(:, g == 1); B = X(:, g == 2); + vA = var(A, 0, 2, 'omitnan'); vB = var(B, 0, 2, 'omitnan'); + nA = sum(isfinite(A), 2); nB = sum(isfinite(B), 2); + sa = vA ./ max(nA, 1); sb = vB ./ max(nB, 1); + df = (sa + sb) .^ 2 ./ (sa .^ 2 ./ max(nA - 1, 1) + sb .^ 2 ./ max(nB - 1, 1)); + df(~isfinite(df) | df < 1) = 1; +else + df = sum(isfinite(X), 2) - 1; df(df < 1) = 1; +end +pp = 2 * (1 - local_tcdf(abs(t), df)); +end + + +function pcorr = local_perm_fwe(X, mask, design, g, t_obs, nperm) +% FWER over the masked family via max|t| null. +absobs = abs(t_obs); +ge = zeros(size(t_obs)); +perms = local_perm_set(X, design, g, nperm); +for k = 1:numel(perms) + tp = abs(local_stat_perm(X, design, g, perms{k})); + mx = max(tp(mask), [], 'omitnan'); + if isempty(mx) || ~isfinite(mx), mx = 0; end + ge = ge + (mx >= absobs); +end +pcorr = ge / numel(perms); +end + + +function [pcorr, sig] = local_perm_cluster(X, mask, dims, design, g, opts) +% Temporal cluster-mass along ClusterDim (2-D data). Falls back to FWER if no +% valid cluster dim. +if isempty(opts.ClusterDim) || numel(dims) ~= 2 + pcorr = local_perm_fwe(X, mask, design, g, local_stat(X, design, g), opts.Nperm); + sig = pcorr <= opts.Threshold; return +end +cdim = opts.ClusterDim; +n = size(X, 2); +tcrit = local_tinv(1 - opts.ClusterFormP / 2, max(n - 1, 1)); +maskM = reshape(mask, dims); +tobsM = reshape(local_stat(X, design, g), dims); +if cdim == 2, tobsM = tobsM'; maskM = maskM'; end % put cluster dim as rows +oc = local_clusters(tobsM, maskM, tcrit); +perms = local_perm_set(X, design, g, opts.Nperm); +maxmass = zeros(numel(perms), 1); +for k = 1:numel(perms) + tm = reshape(local_stat_perm(X, design, g, perms{k}), dims); + if cdim == 2, tm = tm'; end + cl = local_clusters(tm, maskM, tcrit); + if ~isempty(cl), maxmass(k) = max([cl.mass]); end +end +pM = nan(size(tobsM)); sM = false(size(tobsM)); +for c = 1:numel(oc) + pc = mean(maxmass >= oc(c).mass); + pM(oc(c).cells) = pc; sM(oc(c).cells) = pc <= opts.Threshold; +end +if cdim == 2, pM = pM'; sM = sM'; end +pcorr = pM(:); sig = sM(:); +end + + +function cl = local_clusters(tmat, maskM, tcrit) +[L, T] = size(tmat); +cl = struct('cells', {}, 'mass', {}); +for j = 1:T + col = tmat(:, j); mk = maskM(:, j); + for s = [1 -1] + supra = (s * col > tcrit) & isfinite(col) & mk; + d = diff([false; supra; false]); + starts = find(d == 1); stops = find(d == -1) - 1; + for b = 1:numel(starts) + idx = (starts(b):stops(b))'; + cl(end + 1) = struct('cells', sub2ind([L T], idx, repmat(j, numel(idx), 1)), ... + 'mass', sum(abs(col(idx)))); %#ok + end + end +end +end + + +function perms = local_perm_set(X, design, g, nperm) +n = size(X, 2); +if strcmp(design, 'twosample') + perms = cell(1, nperm); + perms{1} = g; % identity first + for k = 2:nperm, perms{k} = g(randperm(n)); end +else + if n <= 13 + m = 2 ^ n; perms = cell(1, m); + for f = 1:m + s = 1 - 2 * bitget(f - 1, 1:n); + perms{f} = s; + end + else + perms = cell(1, nperm); + perms{1} = ones(1, n); + for k = 2:nperm, perms{k} = 2 * (rand(1, n) > 0.5) - 1; end + end +end +end + + +function t = local_stat_perm(X, design, g, perm) +if strcmp(design, 'twosample') + t = local_stat(X, design, perm); % perm = shuffled labels +else + t = local_stat(X .* perm, design, g); % perm = sign vector (1 x n) +end +end + + +function padj = local_bh(pv) +pv = pv(:); m = numel(pv); +[sp, ord] = sort(pv); +adj = min(1, flipud(cummin(flipud(sp .* m ./ (1:m)')))); +padj = nan(m, 1); padj(ord) = adj; +end + + +function g = local_two_groups(grp, nsub) +if isempty(grp), error('hrf_group_stats:NoGroup', 'Design=twosample needs ''Group'' labels.'); end +if iscell(grp) || isstring(grp) + [~, ~, g] = unique(cellstr(string(grp)), 'stable'); +else + [~, ~, g] = unique(grp(:), 'stable'); +end +if numel(g) ~= nsub, error('hrf_group_stats:GroupLen', 'Group has %d labels, need %d.', numel(g), nsub); end +if max(g) ~= 2, error('hrf_group_stats:TwoGroups', 'Design=twosample needs exactly 2 groups.'); end +g = g(:)'; +end + + +function pcdf = local_tcdf(tval, df) +x = df ./ (df + tval .^ 2); +pcdf = 1 - 0.5 * betainc(x, df ./ 2, 0.5); +end + + +function t = local_tinv(pp, df) +lo = 0; hi = 100; +for it = 1:60 + mid = (lo + hi) / 2; + if local_tcdf(mid, df) < pp, lo = mid; else, hi = mid; end +end +t = (lo + hi) / 2; +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_input_table_to_study.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_input_table_to_study.m new file mode 100644 index 00000000..0d1d2c1d --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_input_table_to_study.m @@ -0,0 +1,412 @@ +function study = hrf_input_table_to_study(second_level_inputs, varargin) +%HRF_INPUT_TABLE_TO_STUDY Rebuild study/results structs from collected files. +% +% study = hrf_input_table_to_study(second_level_inputs, ...) +% +% second_level_inputs can be the table returned by +% hrf_collect_wholebrain_outputs or the corresponding CSV filename. This +% function rebuilds a study.results cell array whose entries can contain: +% .wholebrain - beta/T statistic_image objects from NIfTI sidecars +% .fits_by_signature - optional map-score curves from *_map_scores.csv +% +% Use the .wholebrain field with hrf_apply_maps_to_wholebrain and +% hrf_animate_wholebrain_stats. Use the mapscore fits with +% plot_hrf_study_by_subject and hrf_time_unfolding_stats. + +p = inputParser; +p.addRequired('second_level_inputs', @(x) istable(x) || ischar(x) || isstring(x)); +p.addParameter('LoadWholeBrain', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('LoadResultMat', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('IncludeMapScores', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('Object', 'beta', @(x) ischar(x) || isstring(x)); +p.addParameter('ScoreColumns', {}, @(x) iscell(x) || isstring(x)); +p.addParameter('ModelName', 'mapscore', @(x) ischar(x) || isstring(x)); +p.addParameter('SourceModel', {}, @(x) ischar(x) || iscell(x) || isstring(x)); +p.addParameter('AddAverageScore', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('AverageScoreName', 'mean_mapscore', @(x) ischar(x) || isstring(x)); +p.addParameter('ApproxSEFromT', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('MissingPolicy', 'warn', @(x) ischar(x) || isstring(x)); +p.addParameter('NoVerbose', true, @(x) islogical(x) || isnumeric(x)); +p.parse(second_level_inputs, varargin{:}); +opts = p.Results; + +inputs = local_read_inputs(second_level_inputs); +[model_name, source_model] = local_resolve_model_and_source(opts.ModelName, opts.SourceModel); +inputs = local_filter_inputs_by_source_model(inputs, source_model); +n = height(inputs); +results = cell(n, 1); +subject_ids = cell(n, 1); +run_labels = cell(n, 1); +success = false(n, 1); +wholebrain_success = false(n, 1); +resultmat_success = false(n, 1); +mapscore_success = false(n, 1); +errors = cell(n, 1); +skipped = struct('index', {}, 'subject', {}, 'reason', {}); +missing_policy = lower(char(opts.MissingPolicy)); + +score_study = struct(); +if logical(opts.IncludeMapScores) + score_study = local_score_study(inputs, opts, missing_policy, model_name, source_model); +end + +for i = 1:n + subject_ids{i} = local_table_value(inputs, i, 'subject'); + run_labels{i} = local_run_label(inputs, i); + r = struct(); + row_errors = {}; + + if logical(opts.LoadResultMat) + [mat_result, ok, reason] = local_load_result_mat_row(inputs, i); + if ok + r = mat_result; + resultmat_success(i) = true; + else + skipped = local_skip(skipped, i, subject_ids{i}, reason, missing_policy); + row_errors{end + 1} = reason; %#ok + end + end + + if logical(opts.LoadWholeBrain) + [wholebrain, ok, reason] = local_load_wholebrain_row(inputs, i, opts); + if ok + r.wholebrain = wholebrain; + r.wholebrain_paths = wholebrain.paths; + r.conditions = wholebrain.conditions; + r.settings = struct('signal_source', 'wholebrain_hrf_maps', ... + 'source_prefix', local_table_value(inputs, i, 'prefix')); + wholebrain_success(i) = true; + else + skipped = local_skip(skipped, i, subject_ids{i}, reason, missing_policy); + row_errors{end + 1} = reason; %#ok + end + end + + if logical(opts.IncludeMapScores) + if isfield(score_study, 'results') && numel(score_study.results) >= i && ~isempty(score_study.results{i}) + r = local_merge_mapscore_result(r, score_study.results{i}); + mapscore_success(i) = true; + else + score_reason = local_score_error(score_study, i); + if ~isempty(score_reason) + row_errors{end + 1} = score_reason; %#ok + end + end + end + + if ~isempty(fieldnames(r)) + r.subject_id = subject_ids{i}; + r.run_label = run_labels{i}; + r.input_row = i; + r.input_prefix = local_table_value(inputs, i, 'prefix'); + results{i} = r; + success(i) = resultmat_success(i) || wholebrain_success(i) || mapscore_success(i); + end + errors{i} = strjoin(row_errors, ' | '); +end + +study = struct(); +study.results = results; +study.subject_ids = subject_ids; +study.run_labels = run_labels; +study.success = success; +study.resultmat_success = resultmat_success; +study.wholebrain_success = wholebrain_success; +study.mapscore_success = mapscore_success; +study.errors = errors; +study.skipped = skipped; +study.object = char(opts.Object); +if isfield(score_study, 'model_name') + study.model_name = score_study.model_name; +else + study.model_name = model_name; +end +study.source = 'second_level_inputs'; +study.second_level_inputs = inputs; + +if isfield(score_study, 'score_names') + study.score_names = score_study.score_names; +else + study.score_names = {}; +end +if isfield(score_study, 'source_models') + study.source_models = score_study.source_models; +elseif any(strcmp('model', inputs.Properties.VariableNames)) + study.source_models = unique(lower(cellstr(string(inputs.model))), 'stable'); +else + study.source_models = {}; +end +end + +function run_label = local_run_label(inputs, row) +run_label = local_table_value(inputs, row, 'run_label'); +if isempty(run_label) + prefix = local_table_value(inputs, row, 'prefix'); + subject = local_table_value(inputs, row, 'subject'); + model_name = local_table_value(inputs, row, 'model'); + run_label = local_run_label_from_prefix(prefix, subject, model_name); +end +end + +function run_label = local_run_label_from_prefix(prefix, subject, model_name) +run_label = ''; +if isempty(prefix) + return +end +[~, name] = fileparts(prefix); +run_label = regexprep(name, '_hrf.*$', ''); +if ~isempty(subject) + run_label = regexprep(run_label, ['^' regexptranslate('escape', subject) '_?'], ''); +end +if ~isempty(model_name) + run_label = regexprep(run_label, ['_' regexptranslate('escape', model_name) '$'], ''); +end +if isempty(run_label) + run_label = 'run-unknown'; +end +end + +function inputs = local_read_inputs(second_level_inputs) +if istable(second_level_inputs) + inputs = second_level_inputs; +else + inputs = readtable(char(second_level_inputs), 'TextType', 'string'); +end +end + +function [model_name, source_model] = local_resolve_model_and_source(model_name_in, source_model_in) +model_name = lower(strtrim(char(model_name_in))); +source_model = local_source_model_filter(source_model_in); +if isempty(source_model) && local_is_wholebrain_model_name(model_name) + source_model = {model_name}; + model_name = 'mapscore'; +end +end + +function inputs = local_filter_inputs_by_source_model(inputs, source_model) +if isempty(source_model) || ~any(strcmp('model', inputs.Properties.VariableNames)) + return +end +row_models = lower(cellstr(string(inputs.model))); +keep = ismember(row_models, source_model); +inputs = inputs(keep, :); +end + +function models = local_source_model_filter(source_model) +if isempty(source_model) + models = {}; +else + models = lower(cellstr(string(source_model))); + models = cellfun(@strtrim, models, 'UniformOutput', false); + models = models(~cellfun(@isempty, models)); +end +end + +function tf = local_is_wholebrain_model_name(model_name) +tf = ismember(lower(strtrim(char(model_name))), {'fir', 'sfir', 'canonical', 'spline'}); +end + +function score_study = local_score_study(inputs, opts, missing_policy, model_name, source_model) +score_study = struct(); +score_var = local_score_file_var(char(opts.Object)); +if ~any(strcmp(score_var, inputs.Properties.VariableNames)) + return +end + +try + score_study = hrf_second_level_inputs_to_study(inputs, ... + 'Object', opts.Object, ... + 'ScoreColumns', opts.ScoreColumns, ... + 'ModelName', model_name, ... + 'SourceModel', source_model, ... + 'AddAverageScore', opts.AddAverageScore, ... + 'AverageScoreName', opts.AverageScoreName, ... + 'ApproxSEFromT', opts.ApproxSEFromT, ... + 'MissingPolicy', local_nested_missing_policy(missing_policy)); +catch err + if strcmp(missing_policy, 'error') + rethrow(err); + elseif strcmp(missing_policy, 'warn') + warning('hrf_input_table_to_study:MapScoreLoadFailed', ... + 'Could not load map-score curves: %s', err.message); + end +end +end + +function reason = local_score_error(score_study, row) +reason = ''; +if isfield(score_study, 'errors') && numel(score_study.errors) >= row + reason = char(string(score_study.errors{row})); +end +if isempty(reason) && isfield(score_study, 'skipped') + idx = find([score_study.skipped.index] == row, 1, 'first'); + if ~isempty(idx) + reason = score_study.skipped(idx).reason; + end +end +end + +function nested_policy = local_nested_missing_policy(missing_policy) +if strcmp(missing_policy, 'error') + nested_policy = 'error'; +else + nested_policy = 'silent'; +end +end + +function varname = local_score_file_var(object_name) +switch lower(object_name) + case {'beta', 'b'} + varname = 'beta_scores_file'; + case {'t', 'tmap', 'tmaps'} + varname = 't_scores_file'; + otherwise + error('Unknown Object: %s. Use ''beta'' or ''t''.', object_name); +end +end + +function [result, ok, reason] = local_load_result_mat_row(inputs, row) +result = struct(); +ok = false; +reason = ''; + +mat_file = local_table_value(inputs, row, 'result_mat_file'); +if isempty(mat_file) + prefix = local_table_value(inputs, row, 'prefix'); + if ~isempty(prefix) + mat_file = [prefix '_results.mat']; + end +end +if isempty(mat_file) || exist(mat_file, 'file') ~= 2 + reason = 'missing result_mat_file for run-level time series'; + return +end + +try + S = load(mat_file, 'results'); + if isfield(S, 'results') + result = S.results; + else + tmp = load(mat_file); + names = fieldnames(tmp); + if isempty(names) + reason = sprintf('empty result MAT file %s', mat_file); + return + end + result = tmp.(names{1}); + end + result.result_mat_file = mat_file; + ok = true; +catch err + reason = sprintf('could not load result MAT file %s: %s', mat_file, err.message); +end +end + +function [wholebrain, ok, reason] = local_load_wholebrain_row(inputs, row, opts) +wholebrain = struct(); +ok = false; +reason = ''; + +prefix = local_table_value(inputs, row, 'prefix'); +if isempty(prefix) + prefix = local_prefix_from_beta_file(local_table_value(inputs, row, 'beta_file')); +end +if isempty(prefix) + reason = 'missing prefix/beta_file for whole-brain load'; + return +end + +try + wholebrain = hrf_load_wholebrain_stats(prefix, 'NoVerbose', logical(opts.NoVerbose)); + ok = true; +catch err + reason = err.message; +end +end + +function prefix = local_prefix_from_beta_file(beta_file) +prefix = ''; +if isempty(beta_file) + return +end +suffix = '_beta.nii'; +if endsWith(beta_file, suffix) + prefix = extractBefore(beta_file, strlength(beta_file) - strlength(suffix) + 1); +end +end + +function r = local_merge_mapscore_result(r, score_result) +if ~isfield(r, 'fits') || isempty(r.fits) + r.fits = score_result.fits; +end +if ~isfield(r, 'fits_by_signature') || isempty(r.fits_by_signature) + r.fits_by_signature = score_result.fits_by_signature; +else + r.fits_by_signature = local_merge_fit_structs(r.fits_by_signature, score_result.fits_by_signature); +end +if ~isfield(r, 'signature_meta') || isempty(r.signature_meta) + r.signature_meta = score_result.signature_meta; +else + r.mapscore_signature_meta = score_result.signature_meta; +end +r.mapscore_source = score_result.signature_meta.source_file; +if isfield(score_result.signature_meta, 'source_model') + r.mapscore_source_model = score_result.signature_meta.source_model; +end +if ~isfield(r, 'conditions') || isempty(r.conditions) + r.conditions = score_result.conditions; +end +if isfield(r, 'settings') + r.settings.mapscore_model_name = score_result.settings.model_name; + r.settings.mapscore_object = score_result.settings.object; + if isfield(score_result.settings, 'source_model') + r.settings.mapscore_source_model = score_result.settings.source_model; + end +else + r.settings = score_result.settings; +end +end + +function out = local_merge_fit_structs(out, incoming) +fields = fieldnames(incoming); +for i = 1:numel(fields) + f = fields{i}; + if ~isfield(out, f) || isempty(out.(f)) + out.(f) = incoming.(f); + continue + end + model_names = fieldnames(incoming.(f)); + for j = 1:numel(model_names) + out.(f).(model_names{j}) = incoming.(f).(model_names{j}); + end +end +end + +function value = local_table_value(T, row, varname) +if ~any(strcmp(varname, T.Properties.VariableNames)) + value = ''; + return +end +value = T.(varname)(row); +if iscell(value), value = value{1}; end +value = string(value); +if ismissing(value) || strlength(value) == 0 + value = ''; +else + value = char(value); +end +end + +function skipped = local_skip(skipped, idx, subject, reason, missing_policy) +if strcmp(missing_policy, 'error') + error('Input row %d (%s): %s', idx, subject, reason); +elseif ~strcmp(missing_policy, 'warn') && ~strcmp(missing_policy, 'silent') + error('Unknown MissingPolicy: %s. Use ''warn'', ''silent'', or ''error''.', missing_policy); +end + +skipped(end + 1) = struct('index', idx, 'subject', subject, 'reason', reason); +if strcmp(missing_policy, 'warn') + warning('hrf_input_table_to_study:SkippingInput', ... + 'Skipping input row %d (%s): %s', idx, subject, reason); +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_load_events_tsv.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_load_events_tsv.m new file mode 100644 index 00000000..c18cdd34 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_load_events_tsv.m @@ -0,0 +1,14 @@ +function E = hrf_load_events_tsv(events_tsv) +%HRF_LOAD_EVENTS_TSV Read BIDS events.tsv and validate required columns. +T = readtable(events_tsv, 'FileType', 'text', 'Delimiter', '\t'); +req = {'onset', 'duration', 'trial_type'}; +for i = 1:numel(req) + if ~ismember(req{i}, T.Properties.VariableNames) + error('events.tsv is missing required column: %s', req{i}); + end +end +E = table(); +E.onset = T.onset; +E.duration = T.duration; +E.trial_type = cellstr(string(T.trial_type)); +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_load_wholebrain_stats.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_load_wholebrain_stats.m new file mode 100644 index 00000000..59d6d05e --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_load_wholebrain_stats.m @@ -0,0 +1,147 @@ +function wholebrain = hrf_load_wholebrain_stats(prefix, varargin) +%HRF_LOAD_WHOLEBRAIN_STATS Rebuild HRF statistic_image objects from files. +% +% wholebrain = hrf_load_wholebrain_stats(prefix) +% +% Reads files written by hrf_fit_wholebrain_stats: +% _beta.nii +% _t.nii +% _se.nii optional for older outputs +% _p.nii optional for older outputs +% _metadata.csv + +p = inputParser; +p.addRequired('prefix', @(x) ischar(x) || isstring(x)); +p.addParameter('NoVerbose', true, @(x) islogical(x) || isnumeric(x)); +p.parse(prefix, varargin{:}); +opts = p.Results; + +prefix = char(prefix); +paths = local_paths(prefix); +if exist(paths.beta, 'file') ~= 2 + error('Missing beta image: %s', paths.beta); +end +if exist(paths.t, 'file') ~= 2 + error('Missing T image: %s', paths.t); +end + +load_args = {}; +if logical(opts.NoVerbose) + load_args = {'noverbose'}; +end + +b_obj = statistic_image(fmri_data(paths.beta, load_args{:})); +b_obj.type = 'FIR HRF beta'; +t_obj = statistic_image(fmri_data(paths.t, load_args{:})); +t_obj.type = 'T'; + +metadata_table = local_read_metadata(paths.metadata); +[labels, N, dfe, TR] = local_metadata_fields(metadata_table, size(b_obj.dat, 2)); + +if ~isempty(labels) + b_obj.image_labels = labels; + t_obj.image_labels = labels; +end + +if ~isempty(N) + b_obj.N = N; + t_obj.N = N; +end +if ~isempty(dfe) + b_obj.dfe = dfe; + t_obj.dfe = dfe; +end + +if exist(paths.se, 'file') == 2 + se_obj = fmri_data(paths.se, load_args{:}); + b_obj.ste = se_obj.dat; + t_obj.ste = se_obj.dat; +elseif ~isempty(b_obj.dat) && ~isempty(t_obj.dat) + ste = b_obj.dat ./ t_obj.dat; + ste(~isfinite(ste)) = NaN; + b_obj.ste = ste; + t_obj.ste = ste; +end + +if exist(paths.p, 'file') == 2 + p_obj = fmri_data(paths.p, load_args{:}); + b_obj.p = p_obj.dat; + t_obj.p = p_obj.dat; +elseif ~isempty(dfe) + t_obj.dat = double(t_obj.dat); + p_dat = 2 * (1 - tcdf(abs(t_obj.dat), dfe)); + p_dat(p_dat == 0) = eps; + b_obj.p = p_dat; + t_obj.p = p_dat; +end + +if isempty(b_obj.p_type), b_obj.p_type = 'Two-tailed P-values from HRF T map'; end +if isempty(t_obj.p_type), t_obj.p_type = 'Two-tailed P-values from HRF T map'; end +if isempty(b_obj.sig), b_obj.sig = true(size(b_obj.dat)); end +if isempty(t_obj.sig), t_obj.sig = true(size(t_obj.dat)); end + +wholebrain = struct(); +wholebrain.b = b_obj; +wholebrain.t = t_obj; +wholebrain.metadata_table = metadata_table; +if ~isempty(metadata_table) && any(strcmp('condition', metadata_table.Properties.VariableNames)) + wholebrain.conditions = unique(cellstr(string(metadata_table.condition)), 'stable'); +else + wholebrain.conditions = {}; +end +wholebrain.TR = TR; +wholebrain.dfe = dfe; +wholebrain.N = N; +wholebrain.paths = local_existing_paths(paths); +wholebrain.reused = true; +end + +function paths = local_paths(prefix) +paths = struct(); +paths.beta = [prefix '_beta.nii']; +paths.t = [prefix '_t.nii']; +paths.se = [prefix '_se.nii']; +paths.p = [prefix '_p.nii']; +paths.metadata = [prefix '_metadata.csv']; +paths.t_thresholded = [prefix '_t_thresh.nii']; +end + +function metadata_table = local_read_metadata(metadata_file) +if exist(metadata_file, 'file') == 2 + metadata_table = readtable(metadata_file, 'TextType', 'string'); +else + metadata_table = table(); +end +end + +function [labels, N, dfe, TR] = local_metadata_fields(metadata_table, n_images) +labels = {}; +N = []; +dfe = []; +TR = []; +if isempty(metadata_table) + return +end + +if any(strcmp('image_label', metadata_table.Properties.VariableNames)) && height(metadata_table) == n_images + labels = cellstr(string(metadata_table.image_label)); +end +if any(strcmp('N', metadata_table.Properties.VariableNames)) + N = metadata_table.N(1); +end +if any(strcmp('dfe', metadata_table.Properties.VariableNames)) + dfe = metadata_table.dfe(1); +end +if any(strcmp('TR', metadata_table.Properties.VariableNames)) + TR = metadata_table.TR(1); +end +end + +function paths = local_existing_paths(paths) +names = fieldnames(paths); +for i = 1:numel(names) + if exist(paths.(names{i}), 'file') ~= 2 + paths = rmfield(paths, names{i}); + end +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_make_average_montage_animations.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_make_average_montage_animations.m new file mode 100644 index 00000000..a41850f2 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_make_average_montage_animations.m @@ -0,0 +1,889 @@ +function avg = hrf_make_average_montage_animations(input_data, varargin) +%HRF_MAKE_AVERAGE_MONTAGE_ANIMATIONS Average whole-brain HRF maps and animate. +% +% avg = hrf_make_average_montage_animations(input_data, ...) +% +% input_data can be: +% - a table from hrf_collect_wholebrain_outputs +% - a CSV filename for that table +% - a study struct from hrf_input_table_to_study with loaded wholebrain maps +% +% The function averages maps across runs within each subject for each +% condition, then averages those subject means across subjects. If OutputDir +% is supplied, subject-level and group-level montage animations are written +% with hrf_make_montage_animation. + +p = inputParser; +p.addRequired('input_data', @(x) istable(x) || isstruct(x) || ischar(x) || isstring(x)); +p.addParameter('Object', 'beta', @(x) ischar(x) || isstring(x)); +p.addParameter('Model', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Conditions', {}, @(x) iscell(x) || isstring(x) || ischar(x)); +p.addParameter('OutputDir', '', @(x) ischar(x) || isstring(x)); +p.addParameter('MakeAnimations', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('MakeSubjectAnimations', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('MakeGroupAnimations', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('ReturnImages', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('GroupStatistic', 'mean', @(x) ischar(x) || isstring(x)); +p.addParameter('GroupCorrection', 'none', @(x) ischar(x) || isstring(x)); +p.addParameter('GroupAlpha', 0.05, @(x) isscalar(x) && x > 0 && x < 1); +p.addParameter('FrameStep', 1, @(x) isscalar(x) && x >= 1); +p.addParameter('FPS', 8, @(x) isscalar(x) && x > 0); +p.addParameter('Threshold', [], @(x) isempty(x) || isscalar(x)); +p.addParameter('MissingPolicy', 'warn', @(x) ischar(x) || isstring(x)); +p.addParameter('Verbose', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('SubjectThreshold', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('SubjectCorrection', '', @(x) ischar(x) || isstring(x)); +p.addParameter('SubjectAlpha', NaN, @(x) isscalar(x)); +p.addParameter('ColorLimits', 'auto', @(x) (ischar(x) || isstring(x)) || (isnumeric(x) && numel(x) == 2)); +p.addParameter('ColorMap', 'rdylbu', @(x) ischar(x) || isstring(x) || iscell(x)); +p.addParameter('GrayBuffer', false, @(x) islogical(x) || isnumeric(x)); +p.parse(input_data, varargin{:}); +opts = p.Results; +verbose = logical(opts.Verbose); + +% Resolve subject-level thresholding defaults from the group settings so +% they track unless the user overrides explicitly. +subject_correction = char(opts.SubjectCorrection); +if isempty(subject_correction) + subject_correction = char(opts.GroupCorrection); +end +subject_alpha = opts.SubjectAlpha; +if isnan(subject_alpha) + subject_alpha = opts.GroupAlpha; +end +opts.SubjectCorrectionResolved = subject_correction; +opts.SubjectAlphaResolved = subject_alpha; + +records = local_records(input_data); +records = local_filter_records(records, char(opts.Model)); +if isempty(records) + error('No whole-brain records were available after filtering.'); +end + +% Per-prefix cache for the loaded wholebrain struct (and fallback single-file +% loads). containers.Map is a handle class -- mutating it inside the local +% helpers mutates the same instance in the main loop, so each unique source +% NIfTI is read from disk at most once across all conditions, subjects, and +% runs. On UNC-mounted study directories this is the dominant speedup. +record_cache = containers.Map('KeyType', 'char', 'ValueType', 'any'); + +condition_patterns = local_condition_patterns(records, opts.Conditions, record_cache); +output_dir = char(opts.OutputDir); +write_movies = logical(opts.MakeAnimations) && ~isempty(output_dir); +if write_movies && exist(output_dir, 'dir') ~= 7 + mkdir(output_dir); +end + +subject_ids = unique({records.subject}, 'stable'); +subject_rows = {}; +group_rows = {}; + +if verbose + fprintf('\nhrf_make_average_montage_animations\n'); + fprintf(' %d records | %d conditions | %d subjects | model=%s, object=%s\n', ... + numel(records), numel(condition_patterns), numel(subject_ids), ... + char(opts.Model), char(opts.Object)); + if write_movies + fprintf(' output_dir: %s\n', output_dir); + end + fprintf('\n'); +end +start_time = tic; + +avg = struct(); +avg.object = char(opts.Object); +avg.model = char(opts.Model); +avg.conditions = condition_patterns(:); +avg.output_dir = output_dir; +avg.subject = struct(); +avg.group = struct(); +avg.group_t = struct(); + +for c = 1:numel(condition_patterns) + condition_pattern = condition_patterns{c}; + subject_images = {}; + subject_metas = {}; + subject_image_subjects = {}; + cond_time = tic; + + if verbose + fprintf('[%d/%d] condition = %s\n', c, numel(condition_patterns), condition_pattern); + end + + for s = 1:numel(subject_ids) + sid = subject_ids{s}; + subject_mask = strcmp({records.subject}, sid); + subject_records = records(subject_mask(:)); + cache_before = record_cache.Count; + subj_time = tic; + [subject_img, subject_meta, n_runs, skipped] = local_subject_average( ... + subject_records, condition_pattern, opts, record_cache); + dt = toc(subj_time); + new_loads = record_cache.Count - cache_before; + if verbose + tag = local_load_tag(new_loads, numel(subject_records)); + fprintf(' [%2d/%2d] %-12s %d runs %s %5.1fs\n', ... + s, numel(subject_ids), sid, n_runs, tag, dt); + end + + if local_missing_image(subject_img) + local_handle_missing(opts.MissingPolicy, ... + sprintf('No valid runs for subject %s, condition %s.', sid, condition_pattern)); + continue + end + + condition_label = local_condition_label(subject_meta, condition_pattern); + movie_file = ''; + if write_movies && logical(opts.MakeSubjectAnimations) + movie_file = fullfile(output_dir, sprintf('%s_%s_%s_subject_average.mp4', ... + local_safe_label(sid), local_safe_label(condition_label), local_object_label(opts.Object))); + hrf_make_montage_animation(subject_img, movie_file, ... + 'FrameStep', opts.FrameStep, ... + 'FPS', opts.FPS, ... + 'Threshold', opts.Threshold, ... + 'ColorLimits', opts.ColorLimits, ... + 'ColorMap', opts.ColorMap, ... + 'GrayBuffer', opts.GrayBuffer, ... + 'UseSigMask', true, ... + 'TitlePrefix', sprintf('%s %s', sid, condition_label)); + end + + if logical(opts.ReturnImages) + avg.subject.(matlab.lang.makeValidName(sid)).(matlab.lang.makeValidName(condition_label)) = subject_img; + end + subject_rows(end + 1, :) = {sid, condition_label, condition_pattern, char(opts.Object), n_runs, movie_file, numel(skipped)}; %#ok + subject_images{end + 1, 1} = subject_img; %#ok + subject_metas{end + 1, 1} = subject_meta; %#ok + subject_image_subjects{end + 1, 1} = sid; %#ok + end + + if verbose + fprintf(' group: averaging across %d subjects ...\n', numel(subject_images)); + end + group_time = tic; + [group_img, group_t_img, group_meta, n_subjects] = local_group_average(subject_images, subject_metas, condition_pattern, opts); + if local_missing_image(group_img) + local_handle_missing(opts.MissingPolicy, ... + sprintf('No valid subjects for group condition %s.', condition_pattern)); + continue + end + if verbose + [n_sig_v_preview, ~] = local_sig_counts(group_t_img); + fprintf(' group: %d subjects, %d significant voxel-frames %5.1fs\n', ... + n_subjects, n_sig_v_preview, toc(group_time)); + end + + group_condition_label = local_condition_label(group_meta, condition_pattern); + group_movie_img = local_group_movie_image(group_img, group_t_img, opts); + group_movie_file = ''; + if write_movies && logical(opts.MakeGroupAnimations) + group_movie_file = fullfile(output_dir, sprintf('group_%s_%s_average.mp4', ... + local_safe_label(group_condition_label), local_group_stat_label(opts))); + hrf_make_montage_animation(group_movie_img, group_movie_file, ... + 'FrameStep', opts.FrameStep, ... + 'FPS', opts.FPS, ... + 'Threshold', opts.Threshold, ... + 'ColorLimits', opts.ColorLimits, ... + 'ColorMap', opts.ColorMap, ... + 'GrayBuffer', opts.GrayBuffer, ... + 'UseSigMask', true, ... + 'TitlePrefix', sprintf('Group %s', group_condition_label)); + end + + if logical(opts.ReturnImages) + avg.group.(matlab.lang.makeValidName(group_condition_label)) = group_img; + avg.group_t.(matlab.lang.makeValidName(group_condition_label)) = group_t_img; + end + [n_sig_voxels, n_sig_timepoints] = local_sig_counts(group_t_img); + group_rows(end + 1, :) = {group_condition_label, condition_pattern, char(opts.Object), ... + local_group_stat_label(opts), lower(char(opts.GroupCorrection)), opts.GroupAlpha, ... + n_subjects, group_movie_file, strjoin(subject_image_subjects, ','), ... + n_sig_voxels, n_sig_timepoints}; %#ok + + if verbose + fprintf(' condition done in %5.1fs (cache: %d entries)\n\n', ... + toc(cond_time), record_cache.Count); + end +end + +if verbose + fprintf('hrf_make_average_montage_animations complete in %5.1fs (cache: %d entries)\n\n', ... + toc(start_time), record_cache.Count); +end + +avg.subject_table = local_subject_table(subject_rows); +avg.group_table = local_group_table(group_rows); +end + + +function tag = local_load_tag(new_loads, n_records) +% Compact indicator: '[cache hit]', '[load 3/3]', or '[load 1/3, hit 2/3]'. +if new_loads == 0 + tag = '[cache hit]'; +elseif new_loads == n_records + tag = sprintf('[load %d/%d]', new_loads, n_records); +else + tag = sprintf('[load %d/%d, hit %d/%d]', new_loads, n_records, n_records - new_loads, n_records); +end +end + +function tf = local_missing_image(img) +if isobject(img) && isprop(img, 'dat') + tf = isempty(img.dat); +else + tf = isempty(img); +end +end + +function records = local_records(input_data) +if istable(input_data) || ischar(input_data) || isstring(input_data) + records = local_table_records(local_read_table(input_data)); + return +end + +records = local_study_records(input_data); +if isempty(records) && isfield(input_data, 'second_level_inputs') + records = local_table_records(input_data.second_level_inputs); +end +end + +function T = local_read_table(input_data) +if istable(input_data) + T = input_data; +else + T = readtable(char(input_data), 'TextType', 'string'); +end +end + +function records = local_table_records(T) +records = repmat(local_empty_record(), 0, 1); +for i = 1:height(T) + rec = local_empty_record(); + rec.subject = local_table_value(T, i, 'subject'); + rec.run_label = local_table_value(T, i, 'run_label'); + rec.model = lower(local_table_value(T, i, 'model')); + rec.prefix = local_table_value(T, i, 'prefix'); + rec.beta_file = local_table_value(T, i, 'beta_file'); + rec.t_file = local_table_value(T, i, 't_file'); + rec.metadata_file = local_table_value(T, i, 'metadata_file'); + if isempty(rec.subject) + rec.subject = sprintf('sub-%03d', i); + end + if isempty(rec.run_label) + rec.run_label = sprintf('run-%03d', i); + end + records(end + 1, 1) = rec; %#ok +end +end + +function records = local_study_records(study) +records = repmat(local_empty_record(), 0, 1); +if ~isfield(study, 'results') + return +end + +for i = 1:numel(study.results) + r = study.results{i}; + if isempty(r) + continue + end + rec = local_empty_record(); + rec.subject = local_subject_id(study, r, i); + rec.run_label = local_run_label(study, r, i, rec.subject); + if isfield(r, 'wholebrain') && ~isempty(r.wholebrain) + rec.wholebrain = r.wholebrain; + rec.model = local_model_from_metadata(r.wholebrain); + end + if isfield(r, 'input_prefix') + rec.prefix = char(r.input_prefix); + end + if isempty(rec.model) && isfield(study, 'second_level_inputs') && height(study.second_level_inputs) >= i + rec.model = lower(local_table_value(study.second_level_inputs, i, 'model')); + end + records(end + 1, 1) = rec; %#ok +end +end + +function rec = local_empty_record() +rec = struct('subject', '', 'run_label', '', 'model', '', 'prefix', '', ... + 'beta_file', '', 't_file', '', 'metadata_file', '', 'wholebrain', []); +end + +function records = local_filter_records(records, model_name) +model_name = lower(strtrim(model_name)); +if isempty(model_name) + return +end +keep = false(size(records)); +for i = 1:numel(records) + keep(i) = isempty(records(i).model) || strcmpi(records(i).model, model_name); +end +records = records(keep); +end + +function patterns = local_condition_patterns(records, requested, cache) +if ~isempty(requested) + patterns = cellstr(string(requested)); + patterns = patterns(:)'; + return +end + +patterns = {}; +for i = 1:numel(records) + try + [~, metadata_table] = local_record_object(records(i), 'beta', cache); + catch + continue + end + if ~isempty(metadata_table) && any(strcmp('condition', metadata_table.Properties.VariableNames)) + patterns = [patterns; cellstr(string(metadata_table.condition))]; %#ok + end +end +patterns = unique(patterns, 'stable'); +if isempty(patterns) + error('Could not infer conditions. Supply the Conditions option.'); +end +patterns = patterns(:)'; +end + +function [subject_img, subject_meta, n_runs, skipped] = local_subject_average(records, condition_pattern, opts, cache) +run_dat = {}; +template_img = []; +subject_meta = table(); +skipped = {}; + +for i = 1:numel(records) + try + [obj, metadata_table] = local_record_object(records(i), char(opts.Object), cache); + [Y, meta] = local_condition_matrix(obj, metadata_table, condition_pattern); + if isempty(template_img) + template_img = obj; + subject_meta = meta; + else + local_assert_same_time(subject_meta, meta); + end + run_dat{end + 1, 1} = Y; %#ok + catch err + skipped{end + 1, 1} = sprintf('%s: %s', records(i).run_label, err.message); %#ok + if strcmpi(char(opts.MissingPolicy), 'error') + rethrow(err); + elseif strcmpi(char(opts.MissingPolicy), 'warn') + warning('hrf_make_average_montage_animations:SkippingRun', ... + 'Skipping %s %s: %s', records(i).subject, records(i).run_label, err.message); + end + end +end + +n_runs = numel(run_dat); +if n_runs == 0 + subject_img = []; + return +end + +stack = local_stack(run_dat); +[mean_dat, sem_dat, n_dat] = local_mean_sem_stack(stack); +subject_img = local_make_average_image(template_img, mean_dat, sem_dat, subject_meta, ... + sprintf('Subject average, n runs = %d', n_runs), n_runs); + +% Optionally populate subject_img.sig from a per-subject t-stat (mean/sem) +% so the animation's UseSigMask actually masks something. n_runs < 2 +% means no SE is available -- skip. +if logical(opts.SubjectThreshold) && n_runs >= 2 && isa(subject_img, 'statistic_image') + correction = opts.SubjectCorrectionResolved; + alpha = opts.SubjectAlphaResolved; + subject_t_img = local_make_t_image(template_img, mean_dat, sem_dat, n_dat, ... + subject_meta, correction, alpha, ... + sprintf('Subject one-sample t maps across runs, n runs = %d', n_runs)); + if isa(subject_t_img, 'statistic_image') && ~isempty(subject_t_img.sig) + subject_img.sig = subject_t_img.sig; + subject_img.p = subject_t_img.p; + subject_img.p_type = subject_t_img.p_type; + subject_img.ste = sem_dat; + end +end +end + +function [group_img, group_t_img, group_meta, n_subjects] = local_group_average(subject_images, subject_metas, condition_pattern, opts) +group_img = []; +group_t_img = []; +group_meta = table(); +n_subjects = numel(subject_images); +if n_subjects == 0 + return +end + +subject_dat = cell(n_subjects, 1); +template = subject_images{1}; +for i = 1:n_subjects + subject_dat{i} = subject_images{i}.dat; +end +stack = local_stack(subject_dat); +[mean_dat, sem_dat, n_dat] = local_mean_sem_stack(stack); +if ~isempty(subject_metas) && ~isempty(subject_metas{1}) + group_meta = subject_metas{1}; +else + group_meta = local_metadata_from_image(template, condition_pattern); +end +group_img = local_make_average_image(template, mean_dat, sem_dat, group_meta, ... + sprintf('Group average, n subjects = %d', n_subjects), n_subjects); +group_t_img = local_make_t_image(template, mean_dat, sem_dat, n_dat, group_meta, ... + opts.GroupCorrection, opts.GroupAlpha, ... + sprintf('Group one-sample t maps across subject averages, n subjects = %d', max(n_dat(:)))); +end + +function [obj, metadata_table] = local_record_object(record, object_name, cache) +% Cache strategy: +% - record.wholebrain in memory -> no disk read, no cache needed +% - record.prefix given -> cache the loaded wholebrain struct +% under 'wb:'; serves all +% object_name requests for free +% - direct single-file fallback -> cache the (obj, metadata) tuple +% under 'f:' +% containers.Map is a handle class, so mutations propagate to all callers. + +if ~isempty(record.wholebrain) + [obj, metadata_table] = local_object_from_wholebrain(record.wholebrain, object_name); + return +end + +if ~isempty(record.prefix) + wb_key = ['wb:' record.prefix]; + if isKey(cache, wb_key) + wb = cache(wb_key); + [obj, metadata_table] = local_object_from_wholebrain(wb, object_name); + return + end + try + wb = hrf_load_wholebrain_stats(record.prefix, 'NoVerbose', true); + cache(wb_key) = wb; + [obj, metadata_table] = local_object_from_wholebrain(wb, object_name); + return + catch + end +end + +[file_name, type_label] = local_object_file(record, object_name); +if isempty(file_name) || exist(file_name, 'file') ~= 2 + error('Missing %s image file.', type_label); +end +f_key = ['f:' file_name]; +if isKey(cache, f_key) + cached = cache(f_key); + obj = cached.obj; + metadata_table = cached.metadata; + return +end +obj = statistic_image(fmri_data(file_name, 'noverbose')); +obj.type = type_label; +metadata_table = local_read_metadata(record.metadata_file); +obj = local_attach_metadata(obj, metadata_table); +cache(f_key) = struct('obj', obj, 'metadata', metadata_table); +end + +function [obj, metadata_table] = local_object_from_wholebrain(wholebrain, object_name) +switch lower(char(object_name)) + case {'beta', 'b'} + obj = wholebrain.b; + case {'t', 'tmap', 'tmaps'} + obj = wholebrain.t; + otherwise + error('Unknown Object: %s. Use ''beta'' or ''t''.', char(object_name)); +end +if isfield(wholebrain, 'metadata_table') + metadata_table = wholebrain.metadata_table; +else + metadata_table = table(); +end +end + +function [file_name, type_label] = local_object_file(record, object_name) +switch lower(char(object_name)) + case {'beta', 'b'} + file_name = record.beta_file; + type_label = 'beta'; + case {'t', 'tmap', 'tmaps'} + file_name = record.t_file; + type_label = 't'; + otherwise + error('Unknown Object: %s. Use ''beta'' or ''t''.', char(object_name)); +end +end + +function metadata_table = local_read_metadata(metadata_file) +if ~isempty(metadata_file) && exist(metadata_file, 'file') == 2 + metadata_table = readtable(metadata_file, 'TextType', 'string'); +else + metadata_table = table(); +end +end + +function obj = local_attach_metadata(obj, metadata_table) +if isempty(metadata_table) || height(metadata_table) ~= size(obj.dat, 2) + return +end +if any(strcmp('image_label', metadata_table.Properties.VariableNames)) + obj.image_labels = cellstr(string(metadata_table.image_label)); +end +if any(strcmp('N', metadata_table.Properties.VariableNames)) + obj.N = metadata_table.N(1); +end +if any(strcmp('dfe', metadata_table.Properties.VariableNames)) + obj.dfe = metadata_table.dfe(1); +end +if isempty(obj.sig) + obj.sig = true(size(obj.dat)); +end +end + +function [Y, meta_out] = local_condition_matrix(obj, metadata_table, condition_pattern) +if isempty(metadata_table) || ~any(strcmp('condition', metadata_table.Properties.VariableNames)) + error('Condition selection needs metadata_table.condition.'); +end + +available = unique(cellstr(string(metadata_table.condition)), 'stable'); +specs = hrf_resolve_condition_patterns(available, condition_pattern, 'DefaultMode', 'first'); +spec = specs(1); +cond = cellstr(string(metadata_table.condition)); +keep = ismember(cond, spec.matched_conditions); +if ~any(keep) + error('Condition pattern "%s" matched no image volumes.', condition_pattern); +end + +if any(strcmp('lag_index', metadata_table.Properties.VariableNames)) + [Y, meta_out] = local_condition_lag_average(obj, metadata_table, keep, spec); +else + wh = find(keep); + Y = obj.dat(:, wh); + meta_out = metadata_table(wh, :); +end +end + +function [Y, meta_out] = local_condition_lag_average(obj, metadata_table, keep, spec) +lags = unique(metadata_table.lag_index(keep), 'stable'); +Y = nan(size(obj.dat, 1), numel(lags)); +rows = cell(numel(lags), width(metadata_table)); +var_names = metadata_table.Properties.VariableNames; + +for lag_i = 1:numel(lags) + wh = keep & metadata_table.lag_index == lags(lag_i); + Y(:, lag_i) = local_mean_omitnan(obj.dat(:, wh), 2); + row = metadata_table(find(wh, 1), :); + if any(strcmp('condition', var_names)) + row = local_set_row_value(row, 'condition', spec.display_label); + end + if any(strcmp('image_label', var_names)) + row = local_set_row_value(row, 'image_label', local_lag_label(spec.label, row, lags(lag_i))); + end + rows(lag_i, :) = table2cell(row); +end + +function row = local_set_row_value(row, varname, value) +current = row.(varname); +if iscell(current) + row.(varname) = {char(value)}; +elseif isstring(current) + row.(varname) = string(value); +elseif iscategorical(current) + row.(varname) = categorical({char(value)}); +else + row.(varname) = string(value); +end +end +meta_out = cell2table(rows, 'VariableNames', var_names); +if any(strcmp('volume_index', var_names)) + meta_out.volume_index = (1:height(meta_out))'; +end +if any(strcmp('condition_index', var_names)) + meta_out.condition_index = ones(height(meta_out), 1); +end +end + +function label = local_lag_label(condition_label, row, lag_index) +safe_condition = local_safe_label(condition_label); +if any(strcmp('lag_seconds', row.Properties.VariableNames)) + label = sprintf('%s_lag%03d_%0.3gs', safe_condition, lag_index, row.lag_seconds(1)); +else + label = sprintf('%s_lag%03d', safe_condition, lag_index); +end +end + +function local_assert_same_time(a, b) +if height(a) ~= height(b) + error('Condition time-bin count mismatch.'); +end +if any(strcmp('lag_index', a.Properties.VariableNames)) && any(strcmp('lag_index', b.Properties.VariableNames)) && ... + ~isequal(a.lag_index, b.lag_index) + error('Condition lag_index mismatch.'); +end +if any(strcmp('lag_seconds', a.Properties.VariableNames)) && any(strcmp('lag_seconds', b.Properties.VariableNames)) && ... + any(abs(a.lag_seconds - b.lag_seconds) > max(eps(a.lag_seconds))) + error('Condition lag_seconds mismatch.'); +end +end + +function stack = local_stack(dat_cells) +n = numel(dat_cells); +sz = size(dat_cells{1}); +stack = nan([sz n]); +for i = 1:n + if ~isequal(size(dat_cells{i}), sz) + error('Image data size mismatch while averaging.'); + end + stack(:, :, i) = dat_cells{i}; +end +end + +function [m, se, n] = local_mean_sem_stack(X) +valid = ~isnan(X); +n = sum(valid, 3); +X0 = X; +X0(~valid) = 0; +m = sum(X0, 3) ./ n; +m(n == 0) = NaN; + +centered = X - repmat(m, 1, 1, size(X, 3)); +centered(~valid) = 0; +sd = sqrt(sum(centered .^ 2, 3) ./ max(n - 1, 1)); +se = sd ./ sqrt(n); +se(n == 0) = NaN; +end + +function t_img = local_make_t_image(template_img, mean_dat, sem_dat, n_dat, metadata_table, correction, alpha, description) +% One-sample t map with corrected sig mask. Shared between subject- and +% group-level paths (n_dat is "samples per voxel-volume" -- subjects at +% group level, runs at subject level). +t_dat = mean_dat ./ sem_dat; +t_dat(~isfinite(t_dat) | n_dat < 2) = NaN; +dfe = max(n_dat - 1, 1); +p_dat = 2 * (1 - tcdf(abs(t_dat), dfe)); +p_dat(n_dat < 2 | ~isfinite(t_dat)) = NaN; + +t_img = template_img; +t_img.dat = t_dat; +if isa(t_img, 'statistic_image') + t_img.type = 'T'; + t_img.p = p_dat; + t_img.p_type = sprintf('Two-tailed one-sample t-test, %s q < %.3g', ... + upper(char(correction)), alpha); + t_img.ste = sem_dat; + t_img.sig = local_corrected_sig(p_dat, alpha, correction); + t_img.dat_descrip = char(description); +end +if isprop(t_img, 'image_labels') && ~isempty(metadata_table) && any(strcmp('image_label', metadata_table.Properties.VariableNames)) + t_img.image_labels = cellstr(string(metadata_table.image_label)); +end +if isprop(t_img, 'removed_images') + t_img.removed_images = false(1, size(t_dat, 2)); +end +if isprop(t_img, 'N') + t_img.N = max(n_dat(:)); +end +if isprop(t_img, 'dfe') + t_img.dfe = max(max(n_dat(:)) - 1, 1); +end +end + +function sig = local_corrected_sig(p_dat, alpha, correction) +sig = false(size(p_dat)); +correction = lower(strtrim(char(correction))); +switch correction + case {'none', 'unc', 'uncorrected'} + sig = p_dat < alpha; + case 'fdr' + for i = 1:size(p_dat, 2) + p = p_dat(:, i); + valid = isfinite(p); + if ~any(valid) + continue + end + thr = FDR(p(valid), alpha); + if isempty(thr) || ~isfinite(thr) + continue + end + sig(valid, i) = p(valid) <= thr; + end + otherwise + error('Unknown GroupCorrection: %s. Use ''none'' or ''fdr''.', char(correction)); +end +sig(isnan(p_dat)) = false; +end + +function img = local_group_movie_image(group_img, group_t_img, opts) +switch lower(strtrim(char(opts.GroupStatistic))) + case {'mean', 'average', 'beta'} + img = group_img; + case {'t', 'tmap', 'tstat'} + img = group_t_img; + otherwise + error('Unknown GroupStatistic: %s. Use ''mean'' or ''t''.', char(opts.GroupStatistic)); +end +end + +function label = local_group_stat_label(opts) +switch lower(strtrim(char(opts.GroupStatistic))) + case {'mean', 'average', 'beta'} + label = local_object_label(opts.Object); + case {'t', 'tmap', 'tstat'} + correction = strtrim(char(opts.GroupCorrection)); + if strcmpi(correction, 'fdr') + label = sprintf('t_fdr_q%0.3g', opts.GroupAlpha); + else + label = 't'; + end + label = local_safe_label(label); + otherwise + label = local_safe_label(opts.GroupStatistic); +end +end + +function [n_sig_voxels, n_sig_timepoints] = local_sig_counts(group_t_img) +n_sig_voxels = 0; +n_sig_timepoints = 0; +if isa(group_t_img, 'statistic_image') && ~isempty(group_t_img.sig) + n_sig_voxels = sum(group_t_img.sig(:)); + n_sig_timepoints = sum(any(group_t_img.sig, 1)); +end +end + +function img = local_make_average_image(template_img, dat, ste, metadata_table, description, n_obs) +img = template_img; +img.dat = dat; +if isa(img, 'statistic_image') + img.p = []; + img.sig = true(size(dat)); + img.ste = ste; + img.dat_descrip = description; +end +if isprop(img, 'image_labels') && ~isempty(metadata_table) && any(strcmp('image_label', metadata_table.Properties.VariableNames)) + img.image_labels = cellstr(string(metadata_table.image_label)); +end +if isprop(img, 'removed_images') + img.removed_images = false(1, size(dat, 2)); +end +if isprop(img, 'N') + img.N = n_obs; +end +if isprop(img, 'dfe') + img.dfe = []; +end +end + +function metadata_table = local_metadata_from_image(img, condition_pattern) +metadata_table = table(); +if isprop(img, 'image_labels') && ~isempty(img.image_labels) + labels = cellstr(string(img.image_labels(:))); + n = numel(labels); + metadata_table = table((1:n)', repmat(string(condition_pattern), n, 1), (1:n)', labels(:), ... + 'VariableNames', {'volume_index', 'condition', 'lag_index', 'image_label'}); +end +end + +function label = local_condition_label(metadata_table, fallback) +label = char(fallback); +if ~isempty(metadata_table) && any(strcmp('condition', metadata_table.Properties.VariableNames)) && height(metadata_table) > 0 + label = char(string(metadata_table.condition(1))); +end +end + +function T = local_subject_table(rows) +vars = {'subject', 'condition', 'condition_pattern', 'object', 'n_runs', 'movie_file', 'n_skipped_runs'}; +if isempty(rows) + T = cell2table(cell(0, numel(vars)), 'VariableNames', vars); +else + T = cell2table(rows, 'VariableNames', vars); +end +end + +function T = local_group_table(rows) +vars = {'condition', 'condition_pattern', 'object', 'group_statistic', 'group_correction', ... + 'group_alpha', 'n_subjects', 'movie_file', 'subjects', 'n_sig_voxels', 'n_sig_timepoints'}; +if isempty(rows) + T = cell2table(cell(0, numel(vars)), 'VariableNames', vars); +else + T = cell2table(rows, 'VariableNames', vars); +end +end + +function local_handle_missing(missing_policy, message_text) +switch lower(char(missing_policy)) + case 'error' + error(message_text); + case 'warn' + warning('hrf_make_average_montage_animations:MissingAverage', '%s', message_text); + case 'silent' + otherwise + error('Unknown MissingPolicy: %s. Use ''warn'', ''silent'', or ''error''.', char(missing_policy)); +end +end + +function subject_id = local_subject_id(study, r, idx) +if isfield(r, 'subject_id') && ~isempty(r.subject_id) + subject_id = char(r.subject_id); +elseif isfield(study, 'subject_ids') && numel(study.subject_ids) >= idx + subject_id = char(study.subject_ids{idx}); +else + subject_id = sprintf('sub-%03d', idx); +end +end + +function run_label = local_run_label(study, r, idx, subject_id) +if isfield(r, 'run_label') && ~isempty(r.run_label) + run_label = char(r.run_label); +elseif isfield(study, 'run_labels') && numel(study.run_labels) >= idx && ~isempty(study.run_labels{idx}) + run_label = char(study.run_labels{idx}); +else + run_label = sprintf('%s_run%03d', subject_id, idx); +end +end + +function model_name = local_model_from_metadata(wholebrain) +model_name = ''; +if isfield(wholebrain, 'metadata_table') && ~isempty(wholebrain.metadata_table) && ... + any(strcmp('mode', wholebrain.metadata_table.Properties.VariableNames)) && height(wholebrain.metadata_table) > 0 + model_name = lower(char(string(wholebrain.metadata_table.mode(1)))); +end +end + +function value = local_table_value(T, row, varname) +if ~any(strcmp(varname, T.Properties.VariableNames)) + value = ''; + return +end +value = T.(varname)(row); +if iscell(value) + value = value{1}; +end +value = string(value); +if ismissing(value) || strlength(value) == 0 + value = ''; +else + value = char(value); +end +end + +function s = local_safe_label(s) +s = regexprep(char(s), '[*?]', 'all'); +s = matlab.lang.makeValidName(s); +end + +function s = local_object_label(object_name) +switch lower(char(object_name)) + case {'beta', 'b'} + s = 'beta'; + case {'t', 'tmap', 'tmaps'} + s = 't'; + otherwise + s = local_safe_label(object_name); +end +end + +function m = local_mean_omitnan(X, dim) +if nargin < 2 + dim = 1; +end +valid = ~isnan(X); +den = sum(valid, dim); +X(~valid) = 0; +m = sum(X, dim) ./ den; +m(den == 0) = NaN; +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_make_montage_animation.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_make_montage_animation.m new file mode 100644 index 00000000..9f0ede11 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_make_montage_animation.m @@ -0,0 +1,158 @@ +function out_video = hrf_make_montage_animation(img_obj_or_file, out_video, varargin) +%HRF_MAKE_MONTAGE_ANIMATION Fast montage-based 3D animation over time. +% Supports raw volume, beta maps, or t maps in fmri_data format. +% +% Color limits are held constant across frames by passing 'cmaprange', +% 'pos_colormap', 'neg_colormap' directly to canlabCore's montage -- +% setting CLim on the figure axes after montage() draws does NOT override +% the per-blob color mapping that addblobs builds internally, which is why +% an earlier "set CLim post-hoc" approach left one half of the bound +% drifting frame to frame. +% +% Name-value parameters +% --------------------- +% 'FrameStep' - integer, animate every Nth volume (default 2) +% 'FPS' - playback rate (default 8) +% 'Threshold' - scalar; voxels with |value| < Threshold are zeroed +% (via statistic_image/threshold). Default [] (no thresh). +% 'UseSigMask' - true (default) to honor obj.sig for statistic_image input +% 'TitlePrefix' - prefix for the per-frame title +% 'ColorLimits' - 'auto' (default) -> symmetric robust [-q q] from the +% 98th percentile of |non-zero, finite, sig-masked| +% values across every frame in the animation, +% OR [lo hi] to set explicitly. Held constant across +% every frame. +% 'ColorMap' - 'rdylbu' (default), 'hotcool' (canlab classic), 'bwr', +% or a {pos_cm, neg_cm} 2-cell pair of Nx3 RGB matrices. +% 'GrayBuffer' - default false. canlabCore's HCP surface display has +% a gray buffer around blob boundaries that's helpful +% for unthresholded volumetric maps but clutters +% thresholded statistical map animations. +% 'MontageType' - default 'hcp'. Pass-through layout name to canlab +% montage() (e.g. 'hcp', 'compact2', 'full'). + +p = inputParser; +p.addRequired('img_obj_or_file'); +p.addRequired('out_video', @(x) ischar(x) || isstring(x)); +p.addParameter('FrameStep', 2, @(x) isscalar(x) && x >= 1); +p.addParameter('FPS', 8, @(x) isscalar(x) && x > 0); +p.addParameter('Threshold', [], @(x) isempty(x) || isscalar(x)); +p.addParameter('UseSigMask', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('TitlePrefix', 'Frame', @(x) ischar(x) || isstring(x)); +p.addParameter('ColorLimits', 'auto', @(x) (ischar(x) || isstring(x)) || (isnumeric(x) && numel(x) == 2)); +p.addParameter('ColorMap', 'rdylbu', @(x) ischar(x) || isstring(x) || iscell(x)); +p.addParameter('GrayBuffer', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('MontageType', 'hcp', @(x) ischar(x) || isstring(x)); +p.parse(img_obj_or_file, out_video, varargin{:}); +opts = p.Results; + +if isa(img_obj_or_file, 'image_vector') + dat = img_obj_or_file; +else + dat = fmri_data(char(img_obj_or_file)); +end + +if ~isempty(opts.Threshold) + dat = threshold(dat, [opts.Threshold Inf], 'raw-between'); +end + +% Compute the color range ONCE over the whole animation. +cmaprange = local_resolve_clim(dat, opts); + +% Build pos / neg colormaps; canlab's montage/addblobs splits these for +% diverging maps. RdYlBu is the diverging ColorBrewer palette; hotcool +% reproduces canlab's classic default. +[pos_colormap, neg_colormap] = local_resolve_colormaps(opts.ColorMap); + +% These options flow through montage -> addblobs (volumetric) and +% render_on_surface (surface) so the blob color mapping and gray buffer +% are identical across every frame of the animation. +montage_args = { ... + 'cmaprange', cmaprange, ... + 'pos_colormap', pos_colormap, ... + 'neg_colormap', neg_colormap, ... + 'gray_buffer', logical(opts.GrayBuffer)}; + +vw = VideoWriter(char(out_video), 'MPEG-4'); +vw.FrameRate = opts.FPS; +open(vw); + +fig = figure('Color', 'w', 'Visible', 'off'); +for t = 1:opts.FrameStep:size(dat.dat, 2) + clf(fig); + this = get_wh_image(dat, t); + if logical(opts.UseSigMask) && isa(this, 'statistic_image') && ~isempty(this.sig) + this.dat(~logical(this.sig)) = 0; + end + montage(this, char(opts.MontageType), montage_args{:}); + title(sprintf('%s %d', char(opts.TitlePrefix), t), 'Interpreter', 'none'); + frame = getframe(fig); + writeVideo(vw, frame); +end +close(vw); +close(fig); +end + + +function clim = local_resolve_clim(dat, opts) +% Pick a [lo hi] CLim that's stable across the whole animation. +% 'auto' -> symmetric range from the 98th percentile of |non-zero, finite| +% values across all frames (so outliers don't blow out the scale). +if isnumeric(opts.ColorLimits) && numel(opts.ColorLimits) == 2 + clim = double(opts.ColorLimits(:)'); + return +end + +vals = double(dat.dat); +if isa(dat, 'statistic_image') && logical(opts.UseSigMask) && ~isempty(dat.sig) + vals(~logical(dat.sig)) = 0; +end +vals = vals(isfinite(vals) & vals ~= 0); +if isempty(vals) + clim = [-1, 1]; + return +end +q = quantile(abs(vals), 0.98); +if ~(q > 0) + q = max(abs(vals)); +end +if ~(q > 0) + q = 1; +end +clim = [-q, q]; +end + + +function [pos_cm, neg_cm] = local_resolve_colormaps(spec) +% Returns pos_colormap (low-positive -> high-positive) and +% neg_colormap (low-negative -> high-negative) Nx3 matrices. +% canlab's addblobs interpolates between the first and last rows. +if iscell(spec) && numel(spec) == 2 + pos_cm = spec{1}; + neg_cm = spec{2}; + return +end +name = lower(strtrim(char(spec))); +switch name + case 'rdylbu' + % ColorBrewer RdYlBu, split at yellow: + % positive half: pale yellow -> orange -> dark red + % negative half: pale blue -> medium blue -> dark blue + pos_cm = colormap_tor([1.0000 1.0000 0.7490], [0.6471 0.0000 0.1490], ... + [0.9922 0.6824 0.3804]); + neg_cm = colormap_tor([0.8784 0.9529 0.9725], [0.1922 0.2118 0.5843], ... + [0.4549 0.6784 0.8196]); + case 'hotcool' + % canlab's classic default: yellow -> red for pos, cyan -> blue for neg + pos_cm = colormap_tor([1 1 0], [1 0 0]); + neg_cm = colormap_tor([0 1 1], [0 0 1]); + case 'bwr' + % blue-white-red diverging + pos_cm = colormap_tor([1 1 1], [1 0 0]); + neg_cm = colormap_tor([1 1 1], [0 0 1]); + otherwise + error('hrf_make_montage_animation:UnknownColorMap', ... + 'Unknown ColorMap: ''%s''. Use ''rdylbu'', ''hotcool'', ''bwr'', or a {pos_cm, neg_cm} pair.', ... + name); +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_mediation_analyze.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_mediation_analyze.m new file mode 100644 index 00000000..69ba0371 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_mediation_analyze.m @@ -0,0 +1,244 @@ +function R = hrf_mediation_analyze(X, M, Y, varargin) +%HRF_MEDIATION_ANALYZE Trial-level (multilevel) mediation X -> M -> Y. +% +% Tests whether a mediator M carries the effect of X on Y at the trial level: +% a : X -> M +% b : M -> Y (controlling for X) +% c : X -> Y (total) +% c' : X -> Y (direct, controlling for M) +% ab : a*b (INDIRECT / mediated effect) -- the quantity of interest +% +% This is the IO-free compute core of the HRF mediation method (the driver +% hrf_causality_mediation builds X/M/Y from deconvolved node amplitudes and/or +% the BIDS events, e.g. stimulus -> region -> rating). It is what makes +% "does region/signature M mediate stimulus -> behaviour" answerable on top of +% the same deconvolution used for the Granger analysis. +% +% Multilevel (>=2 subjects): the five paths are estimated per subject by OLS, +% then summarized across subjects with a one-sample t-test (the standard +% two-level mediation summary). Single subject / pooled: the indirect effect +% ab is tested by a nonparametric bootstrap over trials. +% +% :Usage: +% :: +% R = hrf_mediation_analyze(Xc, Mc, Yc, 'Names', {'stim','NPS','rating'}) +% R = hrf_mediation_analyze(x, m, y, 'Nboot', 10000) % single-level +% +% :Inputs: +% **X, M, Y:** trial-level data. Either a cell{1..nSubj} of column vectors +% (one per subject; same length within a subject) for multilevel +% mediation, or a single numeric vector for one subject / pooled. +% +% :Optional Inputs: +% **'Names':** {Xname, Mname, Yname} for labelling. Default generic. +% **'Covariates':** cell{1..nSubj} of [nTrial x q] nuisance regressors +% partialled from all paths (or a single matrix). Default none. +% **'Standardize':** z-score X, M, Y within subject first (default true), so +% paths are in comparable (standardized) units. +% **'Nboot':** bootstrap samples for the single-level indirect test. +% Default 5000. +% **'Verbose'/'doverbose':** print a summary (default true). +% +% :Output: +% **R:** struct with one sub-struct per path (a, b, cp, c, ab), each holding +% .est (group/point estimate), .se, .t, .p, and .persubj (the +% per-subject estimates, multilevel). Plus .prop_mediated (ab/c), +% .n_subj, .n_trials, .names, .level ('multilevel'|'single'). +% +% :Examples: +% :: +% % synthetic: X->M->Y with a=0.6, b=0.5 +% ns=12; X=cell(1,ns); M=X; Y=X; +% for s=1:ns, x=randn(40,1); m=0.6*x+randn(40,1); y=0.5*m+0.2*x+randn(40,1); +% X{s}=x; M{s}=m; Y{s}=y; end +% R = hrf_mediation_analyze(X,M,Y,'Names',{'stim','region','rating'}); +% R.ab.est, R.ab.p % indirect effect ~0.3, significant +% +% See also: hrf_causality_mediation, hrf_causality, mediation (MediationToolbox). + +p = inputParser; +p.addRequired('X'); +p.addRequired('M'); +p.addRequired('Y'); +p.addParameter('Names', {}, @(x) iscell(x) || isstring(x)); +p.addParameter('Covariates', {}, @(x) iscell(x) || isnumeric(x)); +p.addParameter('Standardize', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('Nboot', 5000, @(x) isscalar(x) && x >= 100); +% path significance at the group (multilevel) level: 'none' (default, +% parametric one-sample t) or 'permutation' (per-path sign-flip; exact at +% n<=13, robust at small n). +p.addParameter('Correction', 'none', @(x) ischar(x) || isstring(x)); +p.addParameter('Verbose', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('doverbose', [], @(x) isempty(x) || islogical(x) || isnumeric(x)); +p.parse(X, M, Y, varargin{:}); +opts = p.Results; +verbose = logical(opts.Verbose); +if ~isempty(opts.doverbose), verbose = logical(opts.doverbose); end + +Xc = local_to_cell(X); Mc = local_to_cell(M); Yc = local_to_cell(Y); +nSubj = numel(Xc); +if numel(Mc) ~= nSubj || numel(Yc) ~= nSubj + error('hrf_mediation_analyze:Mismatch', 'X, M, Y must have the same number of subjects.'); +end +Cov = local_cov_cell(opts.Covariates, nSubj); +names = local_names(opts.Names); + +% per-subject paths +paths = nan(nSubj, 5); % [a b cp c ab] +ntrials = zeros(nSubj, 1); +for s = 1:nSubj + [x, m, y, cv, ok] = local_prep(Xc{s}, Mc{s}, Yc{s}, Cov{s}, logical(opts.Standardize)); + if ~ok, continue; end + ntrials(s) = numel(x); + paths(s, :) = local_paths(x, m, y, cv); +end +valid = all(isfinite(paths), 2); +paths = paths(valid, :); nValid = size(paths, 1); +if nValid == 0, error('hrf_mediation_analyze:NoData', 'No subject had usable trials.'); end + +if nValid >= 2 + level = 'multilevel'; + R = local_group_struct(paths, names, nValid, opts.Correction, opts.Nboot); +else + level = 'single'; + [x, m, y, cv] = local_prep(Xc{1}, Mc{1}, Yc{1}, Cov{1}, logical(opts.Standardize)); + R = local_boot_struct(x, m, y, cv, opts.Nboot, names); +end +R.level = level; +R.n_subj = nValid; +R.n_trials = sum(ntrials); +R.names = names; + +if verbose + fprintf('hrf_mediation_analyze [%s]: %s -> %s -> %s | n=%d %s, %d trials\n', ... + level, names{1}, names{2}, names{3}, nValid, ... + local_tern(strcmp(level, 'multilevel'), 'subjects', 'subject'), R.n_trials); + fprintf(' a=%+.3f(p=%.2g) b=%+.3f(p=%.2g) c=%+.3f c''=%+.3f | INDIRECT ab=%+.3f (p=%.2g)\n', ... + R.a.est, R.a.p, R.b.est, R.b.p, R.c.est, R.cp.est, R.ab.est, R.ab.p); + if isfinite(R.prop_mediated) + fprintf(' proportion mediated = %.0f%%\n', 100 * R.prop_mediated); + end +end +end + + +% ========================================================================= +function ab_paths = local_paths(x, m, y, cov) +% OLS path coefficients for one subject. cov is [n x q] (may be empty). +n = numel(x); +Da = [ones(n, 1), x, cov]; ba = Da \ m; a = ba(2); +Db = [ones(n, 1), x, m, cov]; bb = Db \ y; cp = bb(2); b = bb(3); +Dc = [ones(n, 1), x, cov]; bc = Dc \ y; c = bc(2); +ab_paths = [a, b, cp, c, a * b]; +end + + +function R = local_group_struct(paths, names, n, correction, nperm) +% Group stats for each path across subjects. Default parametric one-sample t; +% Correction='permutation' uses a sign-flip test PER PATH (exact at n<=13, +% robust at small n) -- no cross-path correction (the 5 paths are distinct +% questions, not one family). +lab = {'a', 'b', 'cp', 'c', 'ab'}; +useperm = any(strcmpi(char(correction), {'permutation', 'perm', 'maxt'})); +R = struct(); +for k = 1:5 + v = paths(:, k); + mu = mean(v, 'omitnan'); + se = std(v, 0, 'omitnan') / sqrt(n); + t = mu / se; if se == 0, t = 0; end + if useperm + G = hrf_group_stats(reshape(v, 1, n), 'Correction', 'permutation', 'Nperm', nperm); + pv = G.p_corr; % single cell -> the sign-flip p + else + pv = 2 * (1 - local_tcdf(abs(t), n - 1)); + end + R.(lab{k}) = struct('est', mu, 'se', se, 't', t, 'p', pv, 'persubj', v); +end +R.prop_mediated = R.ab.est / R.c.est; +if ~isfinite(R.prop_mediated), R.prop_mediated = NaN; end +R.names = names; +end + + +function R = local_boot_struct(x, m, y, cov, nboot, names) +% Single-level: point paths + bootstrap CI/p for the indirect effect. +pt = local_paths(x, m, y, cov); +lab = {'a', 'b', 'cp', 'c', 'ab'}; +n = numel(x); +boot = nan(nboot, 5); +for bms = 1:nboot + idx = local_bootidx(n, bms); % deterministic per-iter resample + cb = cov; if ~isempty(cb), cb = cb(idx, :); end + boot(bms, :) = local_paths(x(idx), m(idx), y(idx), cb); +end +R = struct(); +for k = 1:5 + bk = boot(:, k); + se = std(bk, 'omitnan'); + frac_pos = mean(bk > 0); pv = 2 * min(frac_pos, 1 - frac_pos); pv = max(pv, 1 / nboot); + R.(lab{k}) = struct('est', pt(k), 'se', se, 't', pt(k) / se, 'p', pv, ... + 'ci', quantile(bk, [0.025 0.975]), 'persubj', []); +end +R.prop_mediated = pt(5) / pt(4); +if ~isfinite(R.prop_mediated), R.prop_mediated = NaN; end +R.names = names; +end + + +% ========================================================================= +function [x, m, y, cv, ok] = local_prep(x, m, y, cv, standardize) +x = double(x(:)); m = double(m(:)); y = double(y(:)); +n = min([numel(x), numel(m), numel(y)]); +x = x(1:n); m = m(1:n); y = y(1:n); +if ~isempty(cv), cv = double(cv(1:min(size(cv, 1), n), :)); end +good = isfinite(x) & isfinite(m) & isfinite(y); +if ~isempty(cv), good = good & all(isfinite(cv), 2); end +x = x(good); m = m(good); y = y(good); +if ~isempty(cv), cv = cv(good, :); end +ok = numel(x) >= 5 && std(x) > 0 && std(m) > 0 && std(y) > 0; +if ok && standardize + x = local_z(x); m = local_z(m); y = local_z(y); + if ~isempty(cv), cv = local_z(cv); end +end +end + +function z = local_z(v) +mu = mean(v, 1); sd = std(v, 0, 1); sd(sd == 0) = 1; +z = (v - mu) ./ sd; +end + +function c = local_to_cell(v) +if iscell(v), c = v(:)'; else, c = {v}; end +end + +function C = local_cov_cell(cov, n) +if isempty(cov), C = repmat({[]}, 1, n); return; end +if iscell(cov), C = cov(:)'; if numel(C) ~= n, C = repmat(C(1), 1, n); end +else, C = repmat({cov}, 1, n); end +end + +function names = local_names(nm) +def = {'X', 'M', 'Y'}; +if isempty(nm), names = def; return; end +nm = cellstr(string(nm)); +names = def; names(1:min(3, numel(nm))) = nm(1:min(3, numel(nm))); +end + +function idx = local_bootidx(n, seed) +% Deterministic resample with replacement (avoids rand for reproducibility). +a = mod(1103515245 * seed + 12345, 2^31); +idx = zeros(n, 1); +for i = 1:n + a = mod(1103515245 * a + 12345, 2^31); + idx(i) = 1 + floor((a / 2^31) * n); +end +end + +function pcdf = local_tcdf(tval, df) +x = df ./ (df + tval .^ 2); +pcdf = 1 - 0.5 * betainc(x, df / 2, 0.5); +end + +function s = local_tern(c, a, b) +if c, s = a; else, s = b; end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_misspec_metrics.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_misspec_metrics.m new file mode 100644 index 00000000..83ae3256 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_misspec_metrics.m @@ -0,0 +1,650 @@ +function T = hrf_misspec_metrics(source, varargin) +%HRF_MISSPEC_METRICS Curve-shape misspecification metrics vs a reference HRF. +% +% For each estimated HRF curve (one per subject/run/model/condition/source +% column in the score CSVs), quantifies how its SHAPE departs from a +% reference HRF -- the canonical SPM HRF by default, or an empirical / +% user-supplied reference. This is the curve-based half of the Phase 4 +% misspecification pipeline; residual-based metrics (ResidScan, PowerLoss) +% live separately because they need the residual time course, which is not +% stored in the whole-brain score CSVs. +% +% Usage +% ----- +% T = hrf_misspec_metrics(input_table) % vs canonical +% T = hrf_misspec_metrics(input_table, 'Source','signature','Set','all') +% T = hrf_misspec_metrics(input_table, 'Reference','custom', ... +% 'ReferenceHRF', h_vec, 'ReferenceLags', t_vec) +% T = hrf_misspec_metrics(struct('label',{'acc','exp'},'table',{it1,it2})) +% +% Input dispatch (first arg) -- same shapes as hrf_curve_summaries: +% char/string ending .csv -> one score CSV +% table with condition+lag -> a single score table +% input_table (subject/model/*_scores_file) -> iterate its rows +% struct array .label/.table -> per-source, rows tagged with study_label +% +% Name-value parameters +% --------------------- +% 'Reference' - 'canonical' (default, SPM spm_hrf), 'empirical', or +% 'custom'. +% 'ReferenceHRF' - for 'custom': vector of HRF values. With +% 'ReferenceLags' it is resampled to each curve's lags; +% without, it must already match the curve length. +% 'ReferenceLags'- for 'custom': lag-seconds axis of ReferenceHRF. +% 'EmpiricalRef' - for 'empirical': a containers.Map or struct keyed by +% condition (or 'all') -> reference HRF vector, plus +% 'EmpiricalRefLags'. (Empirical group-optimal HRF from +% HMHRFest is a v1 deliverable; supply your own here.) +% 'Source'/'Set' - column family selection. 'atlas' (default), +% 'signature', 'imageset', a cellstr of these, or 'all' +% (atlas + signature + imageset in one table). Within +% 'atlas', ALL atlases present are returned (source_set = +% the atlas token, e.g. canlab2024 vs painpathways2024). +% 'Set' - which SET to keep, matched against source_set: the atlas +% token ('canlab2024'), signature set ('all'), or imageset +% ('bucknerlab'). char/string/cellstr + glob wildcards. +% Default {} = all sets. +% 'Names' - which specific source to keep, matched against source_name: +% the region ('PAG','*Ins*'), signature ('NPS'), or network +% map name. char/string/cellstr + glob wildcards. Default +% {} = all. Set picks the atlas/set; Names picks the item. +% 'Conditions' - cellstr; subset (glob wildcards ok). Default all. +% 'Objects' - {'beta','t'} for input_table iteration. Default {'beta'}. +% 'Model' - filter input_table rows by model. Default '' (all). +% 'TR' - fallback if a CSV lacks lag_seconds. Default NaN. +% 'MinLags' - skip curves with fewer than this many finite lags. +% Default 4. +% 'GroupCurveFirst' - false (default): one metric row per subject/run curve +% (then pool with hrf_curve_summary_groupstats). true: +% average the curves ACROSS subjects/runs within each +% (model, object) FIRST, then score the group-mean curve. +% Far less noise-sensitive -- use it when per-subject HRFs +% are noisy (sparse conditions, low SNR). Output rows carry +% subject='group' and need NO groupstats. +% +% Output +% ------ +% Long table; one row per (origin, condition, source) curve: +% subject, run_label, model, object, study_label (when multi-source) +% source, source_kind, source_set, source_name, condition, n_lags +% reference - which reference was used +% peak_lag_seconds, ref_peak_lag_seconds, peak_lag_bias_seconds +% auc, ref_auc, auc_ratio +% shape_corr - Pearson corr(curve, ref) over lags +% shape_r2 - shape_corr^2 (variance shared with ref) +% misspec_r2 - 1 - SSresid/SStot after best-scaling +% the reference to the curve; LOW or +% negative => poorly described by the +% reference shape => more misspecified +% scaled_resid_rmse - rmse(curve - a*ref), a = LS scale +% +% Notes +% ----- +% * shape_corr / shape_r2 are scale- and sign-invariant measures of how +% well the curve tracks the reference time course. misspec_r2 additionally +% accounts for amplitude via a least-squares scale of the reference. +% * peak_lag_bias > 0 means the estimated HRF peaks LATER than the reference. +% * auc_ratio > 1 means more integrated response than the reference. +% +% See also: hrf_curve_summaries, plot_hrf_curves, ResidScan, PowerLoss. + +p = inputParser; +p.addRequired('input_source'); % label must differ from the 'Source' param +p.addParameter('Reference', 'canonical', @(x) ischar(x) || isstring(x)); +p.addParameter('ReferenceHRF', [], @(x) isempty(x) || isnumeric(x)); +p.addParameter('ReferenceLags', [], @(x) isempty(x) || isnumeric(x)); +p.addParameter('EmpiricalRef', [], @(x) isempty(x) || isstruct(x) || isa(x, 'containers.Map')); +p.addParameter('EmpiricalRefLags', [], @(x) isempty(x) || isnumeric(x)); +p.addParameter('Source', 'atlas', @(x) ischar(x) || isstring(x) || iscell(x)); +p.addParameter('Set', '', @(x) ischar(x) || isstring(x) || iscell(x)); +% Names: which specific source(s) to keep, matched against the parsed source +% NAME (region / signature / imageset-map name), cellstr/string + glob +% wildcards. Default {} = all. (Set selects the atlas/signature-set/imageset; +% Names selects the item within it.) +p.addParameter('Names', {}, @(x) ischar(x) || isstring(x) || iscell(x)); +p.addParameter('Conditions', {}, @(x) iscell(x) || isstring(x) || ischar(x)); +p.addParameter('Objects', {'beta'}, @(x) iscell(x) || isstring(x) || ischar(x)); +p.addParameter('Model', '', @(x) ischar(x) || isstring(x)); +p.addParameter('TR', NaN, @(x) isscalar(x)); +p.addParameter('MinLags', 4, @(x) isscalar(x) && x >= 2); +% GroupCurveFirst: average the HRF curves ACROSS subjects/runs (within each +% model+object) before computing the misspecification metrics, instead of +% computing per-subject metrics that a later groupstats averages. The group- +% mean curve is far less noise-sensitive, so misspec_r2 / peak_lag_bias +% reflect the true group HRF shape rather than per-subject estimation noise. +% Output rows then carry subject='group' and need NO hrf_curve_summary_groupstats. +p.addParameter('GroupCurveFirst', false, @(x) islogical(x) || isnumeric(x)); +p.parse(source, varargin{:}); +opts = p.Results; + +T = local_empty_metrics_table(); + +% Multi-source struct array: recurse, tag study_label. +if isstruct(source) && isfield(source, 'label') && ... + (isfield(source, 'table') || isfield(source, 'input_table')) + chunks = cell(numel(source), 1); + for i = 1:numel(source) + if isfield(source(i), 'table'), sub = source(i).table; else, sub = source(i).input_table; end + Ti = hrf_misspec_metrics(sub, varargin{:}); + if ~isempty(Ti) && height(Ti) > 0 + Ti.study_label = repmat(string(source(i).label), height(Ti), 1); + end + chunks{i} = Ti; + end + chunks = chunks(~cellfun(@isempty, chunks)); + if ~isempty(chunks), T = vertcat(chunks{:}); end + return +end + +% Resolve the table list to process. +if ischar(source) || isstring(source) + score_tables = {readtable(char(source), 'TextType', 'string')}; + origins = struct('subject', {''}, 'run_label', {''}, 'model', {''}, 'object', {''}); +elseif istable(source) && local_looks_like_input_table(source) + [score_tables, origins] = local_load_input_table(source, opts); +elseif istable(source) + score_tables = {source}; + origins = struct('subject', {''}, 'run_label', {''}, 'model', {''}, 'object', {''}); +else + error('hrf_misspec_metrics:UnknownSource', ... + 'First arg must be a CSV path, a score table, or an input_table.'); +end + +% Source can be one family ('atlas'/'signature'/'imageset'), a cellstr of +% families, or 'all' (= all three). Each family is matched separately and the +% rows concatenated; source_kind/source_set in the output distinguish them +% (and, for atlas, source_set is the atlas token so canlab2024 vs +% painpathways2024 are kept apart). +families = local_resolve_families(opts.Source); + +% GroupCurveFirst: replace the per-subject tables with one group-mean curve +% table per (model, object), so metrics are computed on the averaged HRF. +if logical(opts.GroupCurveFirst) && numel(score_tables) > 1 + [score_tables, origins] = local_group_curve_pool(score_tables, origins); +end + +rows = {}; +for ti = 1:numel(score_tables) + St = score_tables{ti}; + if isempty(St) || height(St) == 0, continue; end + org = origins(ti); + for f = 1:numel(families) + ms = struct('family', families{f}, 'set', char(opts.Set)); + chunk = local_metrics_one_table(St, org, ms, opts); + if ~isempty(chunk), rows{end + 1} = chunk; end %#ok + end +end +if ~isempty(rows) + T = vertcat(rows{:}); +end +end + + +% ========================================================================= +% Per-table metric computation +% ========================================================================= +function T = local_metrics_one_table(St, org, match_spec, opts) +T = local_empty_metrics_table(); +v = St.Properties.VariableNames; +if ~any(strcmp('condition', v)) || ~any(strcmp('lag_seconds', v)) + if ~any(strcmp('lag_seconds', v)) && any(strcmp('lag_index', v)) && ~isnan(opts.TR) + St.lag_seconds = double(St.lag_index) * opts.TR; + else + return + end +end + +cond_all = string(St.condition); +cond_list = local_filter_conditions(cond_all, opts.Conditions); +[score_cols, score_labels] = local_match_columns(v, match_spec); +if isempty(score_cols), return; end + +rows = {}; +for c = 1:numel(cond_list) + cmask = cond_all == string(cond_list{c}); + if ~any(cmask), continue; end + lag = double(St.lag_seconds(cmask)); + [lag, ord] = sort(lag); + + for k = 1:numel(score_cols) + [skind, sset, sname] = local_parse_source(score_cols{k}, score_labels{k}, match_spec); + if ~local_match_any(sset, opts.Set), continue; end % which atlas / sig-set / imageset + if ~local_match_any(sname, opts.Names), continue; end % which specific region / signature / map + y = double(St.(score_cols{k})(cmask)); + y = y(ord); + fin = isfinite(y) & isfinite(lag); + if sum(fin) < opts.MinLags, continue; end + yy = y(fin); xx = lag(fin); + + ref = local_reference_hrf(xx, cond_list{c}, opts); + if isempty(ref) || all(~isfinite(ref)), continue; end + + m = local_compute_misspec(yy, ref, xx); + rows{end + 1} = local_metric_row(org, score_cols{k}, skind, sset, sname, ... + cond_list{c}, char(opts.Reference), m); %#ok + end +end +if ~isempty(rows) + T = vertcat(rows{:}); +end +end + + +function [pooled_tables, pooled_origins] = local_group_curve_pool(score_tables, origins) +% Group the per-subject score tables by (model, object) and average each +% group's curves into one group-mean table -- so GroupCurveFirst computes the +% metrics on the averaged HRF rather than per subject. +models = local_origin_field(origins, 'model'); +objects = local_origin_field(origins, 'object'); +key = strcat(models, "||", objects); +[ukey, ~, gidx] = unique(key, 'stable'); +pooled_tables = cell(1, numel(ukey)); +pooled_origins = repmat(struct('subject', 'group', 'run_label', '', 'model', '', 'object', ''), 1, numel(ukey)); +for u = 1:numel(ukey) + idx = find(gidx == u); + pooled_tables{u} = local_pool_curves_across_tables(score_tables(idx)); + o = origins(idx(1)); + pooled_origins(u) = struct('subject', 'group', 'run_label', '', ... + 'model', local_origin_value(o, 'model'), 'object', local_origin_value(o, 'object')); +end +end + +function s = local_origin_field(origins, f) +s = strings(1, numel(origins)); +for i = 1:numel(origins) + s(i) = string(local_origin_value(origins(i), f)); +end +end + +function v = local_origin_value(o, f) +if isfield(o, f) && ~isempty(o.(f)), v = char(string(o.(f))); else, v = ''; end +end + +function P = local_pool_curves_across_tables(tables) +% Average numeric columns within (condition, lag) across same-structured score +% tables -> one group-mean curve per condition x lag. +% +% Robust to PARTIAL tables: instead of keeping only the column intersection +% (which lets a single stale/failed score CSV that is missing the atlas/sig/ +% map columns silently collapse the pool to whatever few columns every table +% happens to share), this takes the UNION of numeric columns and averages each +% one over only the tables that actually contain it (omitnan). A subject +% missing canlab2024 thus drops out of canlab2024's group mean rather than +% deleting canlab2024 for everyone. Uneven coverage raises a warning so the +% offending CSVs get re-scored. +tables = tables(~cellfun(@isempty, tables)); +if isempty(tables), P = table(); return; end +if isscalar(tables), P = tables{1}; return; end + +% Group key: condition + a lag key present in EVERY table. +have_lagidx = all(cellfun(@(t) any(strcmp('lag_index', t.Properties.VariableNames)), tables)); +if have_lagidx, lagkey = 'lag_index'; else, lagkey = 'lag_seconds'; end +for i = 1:numel(tables) + vn = tables{i}.Properties.VariableNames; + if ~any(strcmp('condition', vn)) || ~any(strcmp(lagkey, vn)) + error('hrf_misspec_metrics:PoolKeys', ... + 'GroupCurveFirst pooling needs ''condition'' and ''%s'' in every score table.', lagkey); + end +end + +% Union of numeric value columns (everything except the two group keys), +% in first-seen order. +valnames = {}; +for i = 1:numel(tables) + ti = tables{i}; + vn = ti.Properties.VariableNames; + for j = 1:numel(vn) + nm = vn{j}; + if strcmp(nm, 'condition') || strcmp(nm, lagkey), continue; end + if ~isnumeric(ti.(nm)), continue; end + if ~any(strcmp(nm, valnames)), valnames{end + 1} = nm; end %#ok + end +end + +% Stack, NaN-filling columns a given table lacks; track per-column coverage. +parts = cell(1, numel(tables)); +covcount = zeros(1, numel(valnames)); +for i = 1:numel(tables) + ti = tables{i}; + h = height(ti); + S = table(); + S.condition = string(ti.condition); + S.(lagkey) = double(ti.(lagkey)); + has = ismember(valnames, ti.Properties.VariableNames); + covcount = covcount + has; + for j = 1:numel(valnames) + if has(j) && isnumeric(ti.(valnames{j})) + S.(valnames{j}) = double(ti.(valnames{j})); + else + S.(valnames{j}) = nan(h, 1); + end + end + parts{i} = S; +end +big = vertcat(parts{:}); + +[g, gcond, glag] = findgroups(big.condition, big.(lagkey)); +P = table(); +P.condition = gcond; +P.(lagkey) = glag; +for j = 1:numel(valnames) + P.(valnames{j}) = splitapply(@(x) mean(x, 'omitnan'), big.(valnames{j}), g); +end + +under = covcount < numel(tables); +if any(under) + warning('hrf_misspec_metrics:PartialCoverage', ... + ['GroupCurveFirst: %d of %d pooled columns are absent from some score tables ' ... + '(as few as %d/%d tables carry a column); each is averaged over only the tables ' ... + 'that have it. This usually means stale/partial score CSVs -- run ' ... + 'hrf_audit_score_freshness on the output dir and re-score the flagged runs. ' ... + '%d columns are fully covered.'], ... + sum(under), numel(valnames), min(covcount(under)), numel(tables), sum(~under)); +end +end + + +function m = local_compute_misspec(y, ref, x) +m = struct('n_lags', numel(y), ... + 'peak_lag_seconds', NaN, 'ref_peak_lag_seconds', NaN, 'peak_lag_bias_seconds', NaN, ... + 'auc', NaN, 'ref_auc', NaN, 'auc_ratio', NaN, ... + 'shape_corr', NaN, 'shape_r2', NaN, 'misspec_r2', NaN, 'scaled_resid_rmse', NaN); + +% Peak lags (signed argmax of magnitude). +[~, ky] = max(abs(y)); m.peak_lag_seconds = x(ky); +[~, kr] = max(abs(ref)); m.ref_peak_lag_seconds = x(kr); +m.peak_lag_bias_seconds = m.peak_lag_seconds - m.ref_peak_lag_seconds; + +% AUC. +if numel(x) >= 2 + m.auc = trapz(x, y); + m.ref_auc = trapz(x, ref); + if m.ref_auc ~= 0, m.auc_ratio = m.auc / m.ref_auc; end +end + +% Shape correlation. +if std(y) > 0 && std(ref) > 0 + r = corr(y(:), ref(:)); + m.shape_corr = r; + m.shape_r2 = r ^ 2; +end + +% Best least-squares scale of the reference, then residual R2 + rmse. +denom = ref(:)' * ref(:); +if denom > 0 + a = (ref(:)' * y(:)) / denom; + resid = y(:) - a * ref(:); + ss_tot = sum((y(:) - mean(y)) .^ 2); + if ss_tot > 0 + m.misspec_r2 = 1 - sum(resid .^ 2) / ss_tot; + end + m.scaled_resid_rmse = sqrt(mean(resid .^ 2)); +end +end + + +% ========================================================================= +% Reference HRF resolution +% ========================================================================= +function ref = local_reference_hrf(lags, condition, opts) +ref = []; +switch lower(strtrim(char(opts.Reference))) + case 'canonical' + ref = local_canonical_at_lags(lags); + case 'custom' + if isempty(opts.ReferenceHRF) + error('hrf_misspec_metrics:NoCustomRef', ... + 'Reference=''custom'' requires ''ReferenceHRF''.'); + end + ref = local_resample_ref(opts.ReferenceHRF, opts.ReferenceLags, lags); + case 'empirical' + if isempty(opts.EmpiricalRef) + error('hrf_misspec_metrics:NoEmpiricalRef', ... + ['Reference=''empirical'' requires ''EmpiricalRef'' (per-condition ' ... + 'HRF vectors). The group-optimal empirical HRF from HMHRFest is a ' ... + 'v1 deliverable -- for now pass your own via EmpiricalRef + ' ... + 'EmpiricalRefLags.']); + end + rv = local_empirical_lookup(opts.EmpiricalRef, condition); + ref = local_resample_ref(rv, opts.EmpiricalRefLags, lags); + otherwise + error('hrf_misspec_metrics:UnknownReference', ... + 'Unknown Reference: %s. Use canonical, empirical, or custom.', char(opts.Reference)); +end +end + + +function ref = local_canonical_at_lags(lags) +ref = []; +if exist('spm_hrf', 'file') ~= 2 + error('hrf_misspec_metrics:NoSPM', ... + 'Reference=''canonical'' needs spm_hrf on the path (SPM).'); +end +dt = 0.1; +h = spm_hrf(dt); +h = h ./ max(h); +t_fine = (0:numel(h) - 1) * dt; +ref = interp1(t_fine, h, lags(:), 'linear', 0); +end + + +function out = local_resample_ref(vals, ref_lags, lags) +vals = double(vals(:)); +if isempty(ref_lags) + if numel(vals) == numel(lags) + out = vals; + else + error('hrf_misspec_metrics:RefLengthMismatch', ... + ['Reference vector length (%d) does not match the curve length (%d) ' ... + 'and no ReferenceLags were given to resample.'], numel(vals), numel(lags)); + end + return +end +ref_lags = double(ref_lags(:)); +out = interp1(ref_lags, vals, lags(:), 'linear', 0); +end + + +function rv = local_empirical_lookup(empref, condition) +rv = []; +if isa(empref, 'containers.Map') + if isKey(empref, char(condition)), rv = empref(char(condition)); + elseif isKey(empref, 'all'), rv = empref('all'); end +elseif isstruct(empref) + f = matlab.lang.makeValidName(char(condition)); + if isfield(empref, f), rv = empref.(f); + elseif isfield(empref, 'all'), rv = empref.all; end +end +if isempty(rv) + error('hrf_misspec_metrics:EmpiricalRefMissing', ... + 'EmpiricalRef has no entry for condition ''%s'' or ''all''.', char(condition)); +end +end + + +% ========================================================================= +% Column matching (mirrors plot_hrf_curves families) +% ========================================================================= +function fams = local_resolve_families(src) +% Expand the Source argument into a cellstr of column families. +s = lower(strtrim(string(src))); +s = s(strlength(s) > 0); +if isempty(s), s = "atlas"; end +if any(s == "all"), fams = {'atlas', 'signature', 'imageset'}; return; end +fams = cellstr(s); +end + +function [cols, labels] = local_match_columns(v, match_spec) +cols = {}; labels = {}; +switch match_spec.family + case 'atlas', prefix = 'atlas_'; + case 'signature', prefix = 'sig_'; + case 'imageset', prefix = 'map_'; + otherwise, prefix = 'atlas_'; +end +% Match every column of the family; Set/Names selection is applied later on +% the PARSED source identity (source_set / source_name) in the caller. +for i = 1:numel(v) + name = v{i}; + if ~startsWith(name, prefix), continue; end + if endsWith(name, '_se'), continue; end + cols{end + 1} = name; %#ok + labels{end + 1} = name; %#ok +end +end + +function tf = local_match_any(value, patterns) +% True if VALUE matches any of PATTERNS (glob wildcards * ?; exact otherwise). +% Empty PATTERNS => match-all (no filter). +patterns = cellstr(string(patterns)); +patterns = patterns(~cellfun(@(s) isempty(strtrim(s)), patterns)); +if isempty(patterns), tf = true; return; end +val = char(string(value)); +tf = false; +for p = 1:numel(patterns) + pat = strtrim(patterns{p}); + if any(pat == '*' | pat == '?') + if ~isempty(regexp(val, ['^', regexptranslate('wildcard', pat), '$'], 'once')) + tf = true; return + end + elseif strcmpi(val, pat) + tf = true; return + end +end +end + + +function [kind, set_name, source_name] = local_parse_source(col, ~, match_spec) +kind = match_spec.family; +set_name = ''; +source_name = col; +parts = strsplit(col, '_'); +if numel(parts) >= 3 + set_name = parts{2}; + switch match_spec.family + case {'signature', 'imageset'} + source_name = strjoin(parts(3:end), '_'); + case 'atlas' + suffix_tokens = {'mean', 'meanL1', 'sum'}; + if ismember(parts{end}, suffix_tokens) + source_name = strjoin(parts(3:end-1), '_'); + else + source_name = strjoin(parts(3:end), '_'); + end + end +end +end + + +% ========================================================================= +% Input-table handling +% ========================================================================= +function tf = local_looks_like_input_table(t) +v = t.Properties.VariableNames; +tf = any(ismember(v, {'beta_scores_file', 't_scores_file'})) && ... + any(ismember(v, {'subject', 'model'})); +end + + +function [tables, origins] = local_load_input_table(input_table, opts) +tables = {}; +origins = struct('subject', {}, 'run_label', {}, 'model', {}, 'object', {}); +object_kinds = lower(cellstr(string(opts.Objects))); +file_cols = struct('beta', 'beta_scores_file', 't', 't_scores_file'); +model_filter = lower(char(opts.Model)); +for i = 1:height(input_table) + row_model = local_get_string(input_table, i, 'model'); + if ~isempty(model_filter) && ~strcmpi(row_model, model_filter), continue; end + for k = 1:numel(object_kinds) + obj = object_kinds{k}; + if ~isfield(file_cols, obj), continue; end + col = file_cols.(obj); + if ~any(strcmp(col, input_table.Properties.VariableNames)), continue; end + path = char(string(input_table.(col)(i))); + if isempty(path) || exist(path, 'file') ~= 2, continue; end + try + St = readtable(path, 'TextType', 'string'); + catch + continue + end + tables{end + 1} = St; %#ok + origins(end + 1) = struct( ... + 'subject', local_get_string(input_table, i, 'subject'), ... + 'run_label', local_get_string(input_table, i, 'run_label'), ... + 'model', row_model, 'object', obj); %#ok + end +end +end + + +% ========================================================================= +% Utilities +% ========================================================================= +function conds = local_filter_conditions(condition_vec, requested) +present = unique(cellstr(string(condition_vec)), 'stable'); +if isempty(requested) + conds = present(:)'; return +end +requested = cellstr(string(requested)); +keep = false(size(present)); +for i = 1:numel(requested) + pp = requested{i}; + if contains(pp, '*') + rx = ['^', regexptranslate('wildcard', pp), '$']; + hit = ~cellfun('isempty', regexp(present, rx, 'once')); + else + hit = strcmp(present, pp); + end + keep = keep | hit(:); +end +conds = present(keep)'; +end + + +function s = local_get_string(t, i, col) +s = ''; +if any(strcmp(col, t.Properties.VariableNames)) + val = t.(col)(i); + if isstring(val) || ischar(val), s = char(val); + elseif iscell(val), s = char(val{1}); + else + try, s = char(string(val)); catch, end + end +end +end + + +function T = local_empty_metrics_table() +T = table('Size', [0 20], ... + 'VariableTypes', {'string','string','string','string','string', ... + 'string','string','string','string','string', ... + 'double','string','double','double','double', ... + 'double','double','double','double','double'}, ... + 'VariableNames', {'subject','run_label','model','object','study_label', ... + 'source','source_kind','source_set','source_name','condition', ... + 'n_lags','reference','peak_lag_seconds','ref_peak_lag_seconds','peak_lag_bias_seconds', ... + 'auc','ref_auc','auc_ratio','shape_corr','shape_r2'}); +T.misspec_r2 = double.empty(0, 1); +T.scaled_resid_rmse = double.empty(0, 1); +end + + +function row = local_metric_row(org, col, skind, sset, sname, cond, reference, m) +row = table( ... + string(local_org(org, 'subject')), string(local_org(org, 'run_label')), ... + string(local_org(org, 'model')), string(local_org(org, 'object')), string(""), ... + string(col), string(skind), string(sset), string(sname), string(cond), ... + m.n_lags, string(reference), m.peak_lag_seconds, m.ref_peak_lag_seconds, m.peak_lag_bias_seconds, ... + m.auc, m.ref_auc, m.auc_ratio, m.shape_corr, m.shape_r2, ... + 'VariableNames', {'subject','run_label','model','object','study_label', ... + 'source','source_kind','source_set','source_name','condition', ... + 'n_lags','reference','peak_lag_seconds','ref_peak_lag_seconds','peak_lag_bias_seconds', ... + 'auc','ref_auc','auc_ratio','shape_corr','shape_r2'}); +row.misspec_r2 = m.misspec_r2; +row.scaled_resid_rmse = m.scaled_resid_rmse; +end + + +function s = local_org(org, f) +if isfield(org, f) && ~isempty(org.(f)), s = org.(f); else, s = ''; end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_misspec_report.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_misspec_report.m new file mode 100644 index 00000000..966e36bc --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_misspec_report.m @@ -0,0 +1,320 @@ +function fig = hrf_misspec_report(M, varargin) +% One-figure dashboard for the HRF misspecification pipeline + auto next-steps. +% +% Reduces the curve-misspec table (hrf_misspec_metrics) and, optionally, the +% residual table (hrf_residual_diagnostics) to a four-panel figure you can +% eyeball in seconds: +% A misspec_r2 by region/source - where canonical fits (green) vs fails (red) +% B peak_lag_bias by region - which HRFs are delayed (+) / advanced (-) +% C residual model comparison - which model leaves task-locked structure +% D DIAGNOSTICS & NEXT STEPS - auto-flags (noisy curves, residual +% autocorrelation, uncomputed power-loss, ...) +% +% :Usage: +% :: +% hrf_misspec_report(M) % curve half only +% hrf_misspec_report(M, 'Residuals', R) % + residual half +% hrf_misspec_report(M, 'Condition','rest_stim', 'TopN',12, 'Save','report.png') +% hrf_misspec_report(M, 'Source','atlas', 'Set','canlab2024') % one atlas only +% hrf_misspec_report(M, 'Source','signature', 'Names',{'NPS','SIIPS'}) +% +% :Inputs: +% **M:** table from hrf_misspec_metrics (per-subject OR GroupCurveFirst). +% +% :Optional Inputs: +% **'Residuals':** table from hrf_residual_diagnostics. Default none. +% **'Condition':** restrict M before reducing. char/string/cellstr; glob +% wildcards ok ('*heat*', 'rest_stim_ttl_?'); multiple +% patterns match any (and pool into the per-region mean). +% Default '' = all conditions. +% **'Source':** restrict to a source_kind ('atlas','signature', +% 'imageset'). char/string/cellstr; glob ok. Default '' = +% all. Use this so atlas regions, signatures, and imagesets +% are not mixed into one ranking. +% **'Set':** restrict to a source_set (atlas token like 'canlab2024' +% / 'painpathways2024', signature set, or imageset name). +% char/string/cellstr; glob ok. Default '' = all. +% **'Names':** restrict to specific source_name items (region / +% signature / map name, e.g. {'NPS','SIIPS'} or '*PAG*'). +% char/string/cellstr; glob ok. Default {} = all. +% **'TopN':** how many best/worst regions to bar. Default 14. +% **'GoodR2':** misspec_r2 above this counts as well-described. Default 0.5. +% **'Title':** figure title. Default 'HRF misspecification report'. +% **'Save':** path to save a PNG (also returns the handle). Default ''. +% +% :Output: **fig** - the figure handle. +% +% See also: hrf_misspec_metrics, hrf_residual_diagnostics, hrf_curve_summary_groupstats. + +p = inputParser; +p.addRequired('M', @istable); +p.addParameter('Residuals', table(), @istable); +p.addParameter('Condition', '', @(x) ischar(x) || isstring(x) || iscell(x)); +p.addParameter('Source', '', @(x) ischar(x) || isstring(x) || iscell(x)); +p.addParameter('Set', '', @(x) ischar(x) || isstring(x) || iscell(x)); +p.addParameter('Names', {}, @(x) ischar(x) || isstring(x) || iscell(x)); +p.addParameter('TopN', 14, @(x) isscalar(x) && x >= 1); +p.addParameter('GoodR2', 0.5, @(x) isscalar(x)); +p.addParameter('Title', 'HRF misspecification report', @(x) ischar(x) || isstring(x)); +p.addParameter('Save', '', @(x) ischar(x) || isstring(x)); +p.parse(M, varargin{:}); +opts = p.Results; +R = opts.Residuals; + +% ---- reduce curve table to per-region means ----------------------------- +Mc = M; +pats = string(opts.Condition); +pats = pats(strlength(strtrim(pats)) > 0); +if ~isempty(pats) && any(strcmp('condition', M.Properties.VariableNames)) + Mc = Mc(local_match_conditions(Mc.condition, pats), :); % glob wildcards ok +end +% source selectors: scope to a kind/set/item so atlas regions, signatures, +% and imagesets are not pooled into one ranking (same semantics as +% hrf_misspec_metrics' Source/Set/Names). +Mc = local_select(Mc, 'source_kind', opts.Source); +Mc = local_select(Mc, 'source_set', opts.Set); +Mc = local_select(Mc, 'source_name', opts.Names); +if isempty(Mc) || height(Mc) == 0 + error('hrf_misspec_report:NoRows', ... + ['No rows in M after filters [Condition={%s} Source={%s} Set={%s} Names={%s}].\n' ... + ' conditions available: %s\n source_set available: %s'], ... + strjoin(cellstr(pats), ', '), local_patstr(opts.Source), ... + local_patstr(opts.Set), local_patstr(opts.Names), ... + local_avail(M, 'condition'), local_avail(M, 'source_set')); +end +reg = string(Mc.source_name); +[g, ureg] = findgroups(reg); +r2 = splitapply(@(x) mean(x, 'omitnan'), Mc.misspec_r2, g); +plb = splitapply(@(x) mean(x, 'omitnan'), Mc.peak_lag_bias_seconds, g); +nsub = splitapply(@numel, Mc.misspec_r2, g); + +fig = figure('Color', 'w', 'Position', [80 80 1280 860], 'Name', char(opts.Title)); +tl = tiledlayout(fig, 2, 2, 'TileSpacing', 'compact', 'Padding', 'compact'); +title(tl, char(opts.Title), 'FontWeight', 'bold', 'Interpreter', 'none'); + +% ---- Panel A: misspec_r2 ------------------------------------------------ +nexttile(tl); +local_ranked_barh(ureg, r2, opts.TopN, [0 opts.GoodR2], ... + 'misspec_r2 (1 = canonical fits; \leq0 = canonical fails)', true); + +% ---- Panel B: peak_lag_bias -------------------------------------------- +nexttile(tl); +local_ranked_barh(ureg, plb, opts.TopN, 0, ... + 'peak\_lag\_bias (s) (+ delayed vs canonical, - advanced)', false); + +% ---- Panel C: residual model comparison -------------------------------- +ax = nexttile(tl); +[resid_summary, have_resid] = local_resid_summary(R); +if have_resid + local_resid_panel(ax, resid_summary); +else + axis(ax, 'off'); + text(ax, 0.5, 0.5, {'(no residual table supplied)', ... + 'pass ''Residuals'', hrf\_residual\_diagnostics(study)'}, ... + 'HorizontalAlignment', 'center', 'Color', [0.4 0.4 0.4]); +end + +% ---- Panel D: diagnostics & next steps --------------------------------- +ax = nexttile(tl); +axis(ax, 'off'); +lines = local_diagnostics(r2, plb, nsub, opts.GoodR2, resid_summary, have_resid, Mc); +text(ax, 0.0, 1.0, lines, 'VerticalAlignment', 'top', 'FontName', 'FixedWidth', ... + 'FontSize', 9, 'Interpreter', 'none'); +title(ax, 'Diagnostics & next steps', 'FontWeight', 'bold'); + +if ~isempty(char(opts.Save)) + try + exportgraphics(fig, char(opts.Save), 'Resolution', 130); + catch + saveas(fig, char(opts.Save)); + end +end +end + + +% ========================================================================= +function local_ranked_barh(labels, vals, topN, ref_lines, ttl, color_by_value) +ok = isfinite(vals); +labels = labels(ok); vals = vals(ok); +[vals, ix] = sort(vals, 'ascend'); labels = labels(ix); +n = numel(vals); +if n > 2 * topN + keep = [1:topN, (n - topN + 1):n]; + vals = vals(keep); labels = labels(keep); +end +b = barh(vals, 'FaceColor', 'flat'); +if color_by_value + b.CData = local_value_colors(vals); +else + c = repmat([0.30 0.45 0.80], numel(vals), 1); % advanced = blue + c(vals > 0, :) = repmat([0.80 0.30 0.30], sum(vals > 0), 1); % delayed = red + b.CData = c; +end +set(gca, 'YTick', 1:numel(vals), 'YTickLabel', labels, 'TickLabelInterpreter', 'none', 'FontSize', 7.5); +ylim([0.5 numel(vals) + 0.5]); +grid on; box off; +xlabel(ttl, 'Interpreter', 'tex', 'FontSize', 9); +for r = ref_lines(:)' + xline(r, '--', 'Color', [0.5 0.5 0.5]); +end +end + +function C = local_value_colors(v) +% red (low/negative) -> yellow -> green (high), clamped to [0,1] of value. +t = max(min(v, 1), 0); +C = [1 - t, 0.55 + 0.30 * t, 0.25 * ones(size(t))]; +C(v < 0, :) = repmat([0.70 0.10 0.10], sum(v < 0), 1); % negative = deep red +end + +function [S, ok] = local_resid_summary(R) +S = struct('model', {{}}, 'mis_modeling_p', [], 'durbin_watson', [], 'power_loss', []); +ok = false; +if isempty(R) || height(R) == 0 || ~any(strcmp('model', R.Properties.VariableNames)), return; end +mdl = string(R.model); +[g, um] = findgroups(mdl); +S.model = cellstr(um); +S.mis_modeling_p = splitapply(@(x) median(x, 'omitnan'), R.mis_modeling_p, g); +S.durbin_watson = splitapply(@(x) median(x, 'omitnan'), R.durbin_watson, g); +S.power_loss = splitapply(@(x) median(x, 'omitnan'), R.power_loss, g); +ok = true; +end + +function local_resid_panel(ax, S) +nm = S.model; +dw = S.durbin_watson; +b = bar(ax, dw, 'FaceColor', [0.45 0.55 0.70]); %#ok +set(ax, 'XTick', 1:numel(nm), 'XTickLabel', nm, 'TickLabelInterpreter', 'none'); +yline(ax, 2, '--', 'white (DW=2)', 'Color', [0.2 0.2 0.2]); +ylabel(ax, 'Durbin-Watson (residual)'); grid(ax, 'on'); box(ax, 'off'); +% annotate mis_modeling_p / power_loss per model under each bar +for i = 1:numel(nm) + text(ax, i, max(dw) * 0.06 + 0.02, ... + sprintf('p=%.1g\nPL=%.2g', S.mis_modeling_p(i), S.power_loss(i)), ... + 'HorizontalAlignment', 'center', 'FontSize', 7.5, 'Color', [0.25 0.25 0.25]); +end +title(ax, 'Residual structure by model (lower DW / smaller p = worse)', 'FontSize', 9); +end + +function lines = local_diagnostics(r2, plb, nsub, goodR2, S, have_resid, Mc) +L = {}; +maxr2 = max(r2); +frac_neg = mean(r2 < 0); +frac_good = mean(r2 >= goodR2); +n_curves_per_region = round(median(nsub)); +is_group = any(strcmpi(string(Mc.Properties.VariableNames), 'subject')) && ... + all(string(Mc.subject) == "group"); + +L{end+1} = sprintf('CURVE FIT (n regions = %d, %s)', numel(r2), ... + local_tern(is_group, 'GroupCurveFirst', sprintf('~%d curves/region', n_curves_per_region))); +L{end+1} = sprintf(' best misspec_r2 = %+.2f | %.0f%% well-fit (>=%.2f) | %.0f%% canonical-fails (<0)', ... + maxr2, 100 * frac_good, goodR2, 100 * frac_neg); +if maxr2 < 0.4 + L{end+1} = ' [!] best fit is poor -> curves look NOISE-DOMINATED.'; + if ~is_group + L{end+1} = ' try GroupCurveFirst=true; and/or collapse sparse'; + L{end+1} = ' conditions (avoid *_ttl_* splits) when fitting.'; + else + L{end+1} = ' group curve already used -> the FIT is noisy:'; + L{end+1} = ' collapse sparse conditions / model the block.'; + end +else + L{end+1} = ' [ok] canonical describes a subset of regions well.'; +end +[mxbias, mxi] = max(plb); +L{end+1} = sprintf(' largest +lag bias: %+.1f s (most delayed HRF)', mxbias); %#ok +L{end+1} = ''; +L{end+1} = 'RESIDUALS'; +if have_resid + [~, worst] = min(S.durbin_watson); % furthest from white + L{end+1} = sprintf(' median DW: %s', strjoin(arrayfun(@(i) sprintf('%s=%.2f', S.model{i}, S.durbin_watson(i)), 1:numel(S.model), 'uni', 0), ' ')); + if min(S.durbin_watson) < 1.5 + L{end+1} = ' [!] DW << 2 -> strong residual AUTOCORRELATION.'; + L{end+1} = ' mis_modeling_p then reflects autocorrelation, not'; + L{end+1} = ' HRF fit -> WHITEN the 1D fits before trusting it.'; + end + if all(S.power_loss == 0 | isnan(S.power_loss)) + L{end+1} = ' [!] power_loss all 0/NaN -> not computed at fit time.'; + L{end+1} = ' pass Recompute=true (needs the fit inputs).'; + end + L{end+1} = sprintf(' most residual structure: model "%s"', S.model{worst}); +else + L{end+1} = ' (supply Residuals to compare models)'; +end +L{end+1} = ''; +L{end+1} = 'NEXT STEPS'; +if maxr2 < 0.4 + L{end+1} = ' 1) fix the condition (collapse/block), re-fit, re-score'; + L{end+1} = ' 2) re-run this report; expect best misspec_r2 -> 0.7-0.9'; +else + L{end+1} = ' 1) inspect the red (low-r2) regions'' HRF curves vs canonical'; + L{end+1} = ' 2) report peak_lag_bias for the significant delayed regions'; +end +lines = L(:); +end + +function s = local_tern(c, a, b) +if c, s = a; else, s = b; end +end + +function T = local_select(T, colname, patterns) +% Keep rows of T whose column `colname` glob-matches any of `patterns`. +% No-op if the column is absent or no (non-empty) patterns were given. +pats = cellstr(string(patterns)); +pats = pats(~cellfun(@(s) isempty(strtrim(s)), pats)); +if isempty(pats) || ~any(strcmp(colname, T.Properties.VariableNames)), return; end +vals = string(T.(colname)); +keep = arrayfun(@(v) local_match_any(v, pats), vals); +T = T(keep, :); +end + +function tf = local_match_any(value, patterns) +% True if `value` equals (case-insensitive) or glob-matches any pattern. +patterns = cellstr(string(patterns)); +patterns = patterns(~cellfun(@(s) isempty(strtrim(s)), patterns)); +if isempty(patterns), tf = true; return; end +val = char(string(value)); +tf = false; +for p = 1:numel(patterns) + pat = strtrim(patterns{p}); + if any(pat == '*' | pat == '?') + if ~isempty(regexp(val, ['^', regexptranslate('wildcard', pat), '$'], 'once')) + tf = true; return + end + elseif strcmpi(val, pat) + tf = true; return + end +end +end + +function s = local_patstr(patterns) +pats = cellstr(string(patterns)); +pats = pats(~cellfun(@(s) isempty(strtrim(s)), pats)); +if isempty(pats), s = '(all)'; else, s = strjoin(pats, ', '); end +end + +function s = local_avail(T, colname) +if any(strcmp(colname, T.Properties.VariableNames)) + s = strjoin(unique(cellstr(string(T.(colname))), 'stable'), ', '); +else + s = '(column absent)'; +end +end + +function mask = local_match_conditions(conds, patterns) +% Glob-match condition strings against one or more wildcard patterns +% (e.g. '*heat*', 'rest_stim_ttl_?'). Exact strings still match exactly. +conds = cellstr(string(conds)); +patterns = cellstr(string(patterns)); +mask = false(numel(conds), 1); +for p = 1:numel(patterns) + pat = strtrim(patterns{p}); + if isempty(pat), continue; end + if any(pat == '*' | pat == '?') + rx = ['^', regexptranslate('wildcard', pat), '$']; + hit = ~cellfun('isempty', regexp(conds, rx, 'once')); + else + hit = strcmp(conds, pat); + end + mask = mask | hit(:); +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_plot_causality.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_plot_causality.m new file mode 100644 index 00000000..314260c9 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_plot_causality.m @@ -0,0 +1,203 @@ +function fig = hrf_plot_causality(R, varargin) +%HRF_PLOT_CAUSALITY Visualize a directed (Granger) net-flow result. +% +% Three complementary views of an hrf_causality / hrf_causality_analyze +% result, in one figure: +% A net-flow heatmap - net(i,j) = i->j minus j->i (antisymmetric), +% diverging colormap, significant cells boxed. +% B directed graph - circular layout; an arrow i->j for each +% significant POSITIVE net edge, width/colour +% by magnitude (the "who leads whom" picture). +% C net-drive ranking - per-node net outflow (row sum of net): +% positive = net source/driver, negative = sink. +% +% The net matrix is antisymmetric, so the heatmap's two triangles mirror +% (sign-flipped) and the graph only needs the positive direction. +% +% :Usage: +% :: +% hrf_plot_causality(R) % first evoked mode +% hrf_plot_causality(R, 'Mode','remove', 'PThresh',0.05) +% hrf_plot_causality(R, 'PField','p', 'TopEdges',30, 'Save','flow.png') +% hrf_plot_causality(net_matrix, 'Nodes', names) % bare matrix +% +% :Inputs: +% **R:** an hrf_causality(_analyze) struct (uses R.(Mode).net_group / .p_fdr +% / .p and R.nodes), a struct with fields net_group + p/p_fdr + +% nodes, or a bare [N x N] net matrix. +% +% :Optional Inputs: +% **'Mode':** evoked mode field to plot ('remove'/'keep'); default = first +% in R.modes. +% **'PThresh':** significance threshold for edges/boxes. Default 0.05. +% **'PField':** 'p_fdr' (default) or 'p'. Ignored if absent. +% **'Nodes':** node names (for a bare-matrix input or to override). +% **'TopEdges':**cap the graph to the strongest N significant edges. If +% NONE are significant, the strongest N by |net| are drawn +% dashed (with a note). Default 40. +% **'Title':** figure title. Default 'HRF causality (net directed flow)'. +% **'Save':** path to write a PNG. Default ''. +% +% :Output: **fig** - the figure handle. +% +% See also: hrf_causality, hrf_causality_analyze, hrf_granger_causality. + +p = inputParser; +p.addRequired('R'); +p.addParameter('Mode', '', @(x) ischar(x) || isstring(x)); +p.addParameter('PThresh', 0.05, @(x) isscalar(x) && x > 0); +p.addParameter('PField', 'p_fdr', @(x) ischar(x) || isstring(x)); +p.addParameter('Nodes', {}, @(x) iscell(x) || isstring(x) || ischar(x)); +p.addParameter('TopEdges', 40, @(x) isscalar(x) && x >= 1); +p.addParameter('Title', 'HRF causality (net directed flow)', @(x) ischar(x) || isstring(x)); +p.addParameter('Save', '', @(x) ischar(x) || isstring(x)); +p.parse(R, varargin{:}); +opts = p.Results; + +[net, pv, nodes, modelabel] = local_unpack(R, opts); +N = size(net, 1); +net(1:N+1:end) = 0; +if isempty(pv), pv = nan(N); end +sig = pv < opts.PThresh; +sig(1:N+1:end) = false; + +fig = figure('Color', 'w', 'Position', [60 60 1500 560], 'Name', char(opts.Title)); +tl = tiledlayout(fig, 1, 3, 'TileSpacing', 'compact', 'Padding', 'compact'); +ttl = char(opts.Title); if ~isempty(modelabel), ttl = sprintf('%s [%s]', ttl, modelabel); end +title(tl, ttl, 'FontWeight', 'bold', 'Interpreter', 'none'); + +% ---- A: heatmap -------------------------------------------------------- +ax = nexttile(tl); +imagesc(ax, net); axis(ax, 'square'); +colormap(ax, local_diverging(256)); +lim = max(abs(net(:))); if lim == 0 || ~isfinite(lim), lim = 1; end +set(ax, 'CLim', [-lim lim]); +cb = colorbar(ax); cb.Label.String = 'net flow (row \rightarrow col)'; +set(ax, 'XTick', 1:N, 'XTickLabel', nodes, 'YTick', 1:N, 'YTickLabel', nodes, ... + 'TickLabelInterpreter', 'none', 'FontSize', 7, 'XTickLabelRotation', 90); +hold(ax, 'on'); +[ri, ci] = find(triu(sig, 1) | tril(sig, -1)); +plot(ax, ci, ri, 's', 'MarkerEdgeColor', [0 0 0], 'MarkerSize', 8, 'LineWidth', 0.75); +title(ax, sprintf('net-flow matrix (boxed: %s < %.2g)', char(opts.PField), opts.PThresh), ... + 'FontSize', 9, 'Interpreter', 'none'); + +% ---- B: directed graph ------------------------------------------------- +ax = nexttile(tl); +local_flow_graph(ax, net, sig, nodes, opts.TopEdges); + +% ---- C: net-drive ranking ---------------------------------------------- +ax = nexttile(tl); +drive = sum(net, 2); % net outflow per node +[ds, ord] = sort(drive, 'ascend'); +b = barh(ax, ds, 'FaceColor', 'flat'); +cmap = local_diverging(256); +b.CData = local_map_colors(ds, cmap); +set(ax, 'YTick', 1:N, 'YTickLabel', nodes(ord), 'TickLabelInterpreter', 'none', 'FontSize', 7); +ylim(ax, [0.5 N+0.5]); grid(ax, 'on'); box(ax, 'off'); +xline(ax, 0, 'Color', [0.4 0.4 0.4]); +xlabel(ax, 'net outflow (\Sigma_j net_{ij})', 'Interpreter', 'tex'); +title(ax, 'net driver (+) \leftrightarrow receiver (−)', 'FontSize', 9, 'Interpreter', 'tex'); + +if ~isempty(char(opts.Save)) + try, exportgraphics(fig, char(opts.Save), 'Resolution', 130); catch, saveas(fig, char(opts.Save)); end +end +end + + +% ========================================================================= +function local_flow_graph(ax, net, sig, nodes, topEdges) +N = size(net, 1); +mask = sig & (net > 0); +dashed = false; +if ~any(mask(:)) + % nothing significant: fall back to strongest positive edges, dashed + dashed = true; + pos = net; pos(pos <= 0) = 0; + thr = local_kth_largest(pos(:), topEdges); + mask = pos >= thr & pos > 0; +end +[si, ti] = find(mask); +w = net(sub2ind([N N], si, ti)); +% cap to strongest topEdges +if numel(w) > topEdges + [~, k] = sort(w, 'descend'); k = k(1:topEdges); + si = si(k); ti = ti(k); w = w(k); +end +if isempty(si) + axis(ax, 'off'); text(ax, 0.5, 0.5, 'no edges to draw', 'HorizontalAlignment', 'center'); + return +end +G = digraph(si, ti, w, N); +ang = linspace(0, 2*pi, N+1); ang = ang(1:N); +xy = [cos(ang)', sin(ang)']; +lw = 0.5 + 4.5 * (abs(w) / max(abs(w))); +h = plot(ax, G, 'XData', xy(:,1), 'YData', xy(:,2), 'NodeLabel', nodes, ... + 'LineWidth', lw, 'ArrowSize', 9, 'Interpreter', 'none', 'NodeColor', [0.2 0.2 0.2], 'NodeFontSize', 7); +% colour edges by weight +ew = G.Edges.Weight; +cmap = local_diverging(256); +h.EdgeColor = local_map_colors(ew, cmap); +if dashed, h.LineStyle = '--'; end +axis(ax, 'equal', 'off'); +note = ''; if dashed, note = sprintf(' (none sig; top %d by |net|)', topEdges); end +title(ax, sprintf('directed flow i\\rightarrowj%s', note), 'FontSize', 9, 'Interpreter', 'tex'); +end + + +function [net, pv, nodes, modelabel] = local_unpack(R, opts) +modelabel = ''; +pv = []; +if isnumeric(R) + net = R; nodes = local_default_nodes(opts.Nodes, size(R, 1)); + return +end +if ~isstruct(R), error('hrf_plot_causality:Input', 'R must be a struct or a matrix.'); end +if isfield(R, 'modes') + mode = char(opts.Mode); if isempty(mode), mode = R.modes{1}; end + if ~isfield(R, mode), error('hrf_plot_causality:Mode', 'R has no mode ''%s''.', mode); end + S = R.(mode); modelabel = mode; +elseif isfield(R, 'net_group') + S = R; +else + error('hrf_plot_causality:Struct', 'Struct must have .modes or .net_group.'); +end +net = S.net_group; +pf = char(opts.PField); +if isfield(S, pf), pv = S.(pf); elseif isfield(S, 'p'), pv = S.p; end +if ~isempty(opts.Nodes) + nodes = cellstr(string(opts.Nodes)); +elseif isfield(R, 'nodes') && ~isempty(R.nodes) + nodes = cellstr(string(R.nodes)); +elseif isfield(S, 'nodes') && ~isempty(S.nodes) + nodes = cellstr(string(S.nodes)); +else + nodes = local_default_nodes({}, size(net, 1)); +end +end + + +function nodes = local_default_nodes(want, N) +if ~isempty(want), nodes = cellstr(string(want)); else, nodes = arrayfun(@(i) sprintf('n%d', i), 1:N, 'uni', 0); end +end + +function C = local_diverging(n) +% blue -> white -> red +half = floor(n/2); +up = linspace(0, 1, half)'; +top = [ones(n-half,1), linspace(1,0,n-half)', linspace(1,0,n-half)']; +bot = [up, up, ones(half,1)]; +C = [bot; top]; +end + +function C = local_map_colors(v, cmap) +v = v(:); lim = max(abs(v)); if lim == 0 || ~isfinite(lim), lim = 1; end +idx = round((v + lim) / (2*lim) * (size(cmap,1)-1)) + 1; +idx = min(max(idx, 1), size(cmap,1)); +C = cmap(idx, :); +end + +function thr = local_kth_largest(x, k) +x = sort(x(:), 'descend'); +k = min(k, numel(x)); +thr = x(max(k,1)); +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_pooled_wholebrain_animation.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_pooled_wholebrain_animation.m new file mode 100644 index 00000000..51373e5c --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_pooled_wholebrain_animation.m @@ -0,0 +1,133 @@ +function avg = hrf_pooled_wholebrain_animation(dirs, varargin) +%HRF_POOLED_WHOLEBRAIN_ANIMATION Pool whole-brain HRF maps across dirs and animate. +% +% Convenience wrapper that pools the whole-brain HRF maps of one OR MORE study +% output directories and writes group (and optionally per-subject) montage +% animations over the peristimulus lags, for the conditions you specify. +% +% It collects each directory with hrf_collect_wholebrain_outputs, concatenates +% the resulting tables (the SAME subject id appearing in two dirs has that +% subject's runs pooled -- e.g. both bodysite dirs of distractmap), and hands +% the combined table to hrf_make_average_montage_animations, which does the +% per-condition run->subject->group averaging and renders the movies. +% +% NOTE: the 'Conditions' you pass here are the FIT conditions stored in the +% map metadata (e.g. 'rest_stim_ttl_1', 'nback-stimblock_ttl_1' for +% distractmap), NOT the raw BIDS event trial_types used for the Granger +% condition segmentation. Glob wildcards are allowed. +% +% :Usage: +% :: +% dirs = {'...\hrf_outputs_lf_distractmap', '...\hrf_outputs_obs_distractmap'}; +% avg = hrf_pooled_wholebrain_animation(dirs, ... +% 'Model','sfir', 'Object','beta', ... +% 'Conditions', {'rest_stim_ttl_1','nback-stimblock_ttl_1'}, ... +% 'OutputDir', '...\pooled_distractmap_anim', ... +% 'GroupStatistic','t', 'GroupCorrection','fdr', 'GroupAlpha',0.05, ... +% 'MakeSubjectAnimations', false); +% +% :Inputs: +% **dirs:** a study output directory (char) OR a cell array of directories +% whose maps are pooled. Each must contain the whole-brain map +% NIfTIs + *_metadata.csv that hrf_collect_wholebrain_outputs reads. +% +% :Optional Inputs: +% All name-value arguments are passed through to +% hrf_make_average_montage_animations. The ones you will most often set: +% 'Model' - 'sfir' | 'canonical' | 'spline' | 'fir' (default '' = +% all models present -- usually set this). +% 'Object' - 'beta' (default) | 't'. +% 'Conditions' - cellstr of fit-condition labels/globs to animate. +% Default {} = every condition found (one movie each). +% 'OutputDir' - where to write the .mp4 files (required to get movies). +% 'GroupStatistic' - 'mean' (default) or 't' (one-sample across subjects). +% 'GroupCorrection'/'GroupAlpha' - 'none'|'fdr', 0.05 (for the 't' movie). +% 'MakeSubjectAnimations' / 'MakeGroupAnimations' - default true/true. +% 'FPS','FrameStep','ColorMap','ColorLimits','Threshold' - rendering. +% **'Verbose':** print the pooling summary (default true). +% +% :Output: +% **avg:** the struct returned by hrf_make_average_montage_animations, plus +% .pooled_dirs and .input_table (the concatenated collection table). +% +% See also: hrf_make_average_montage_animations, hrf_collect_wholebrain_outputs, +% hrf_make_montage_animation, hrf_causality. + +p = inputParser; +p.KeepUnmatched = true; % everything else flows to the averager +p.addRequired('dirs', @(x) ischar(x) || isstring(x) || iscell(x)); +p.addParameter('Verbose', true, @(x) islogical(x) || isnumeric(x)); +p.parse(dirs, varargin{:}); +verbose = logical(p.Results.Verbose); + +dl = local_dir_list(dirs); +IT = table(); +for i = 1:numel(dl) + if exist(fullfile(dl{i}), 'dir') ~= 7 + warning('hrf_pooled_wholebrain_animation:NoDir', 'Directory not found, skipping: %s', dl{i}); + continue + end + Ti = hrf_collect_wholebrain_outputs(dl{i}); + if isempty(Ti) || height(Ti) == 0 + warning('hrf_pooled_wholebrain_animation:NoMaps', 'No whole-brain maps collected in: %s', dl{i}); + continue + end + Ti.source_dir = repmat(string(dl{i}), height(Ti), 1); + IT = local_vcat(IT, Ti); +end +if isempty(IT) || height(IT) == 0 + error('hrf_pooled_wholebrain_animation:Empty', 'No whole-brain records pooled from the %d dir(s).', numel(dl)); +end + +if verbose + nsubj = numel(unique(cellstr(string(IT.subject)))); + fprintf('hrf_pooled_wholebrain_animation: pooled %d dir(s) -> %d records, %d subjects\n', ... + numel(dl), height(IT), nsubj); +end + +% Forward the remaining name-value pairs (minus our own 'Verbose') untouched. +fwd = local_strip_param(varargin, 'Verbose'); +avg = hrf_make_average_montage_animations(IT, fwd{:}); +avg.pooled_dirs = dl(:)'; +avg.input_table = IT; +end + + +% ========================================================================= +function L = local_dir_list(x) +if iscell(x) + L = cellfun(@(c) char(string(c)), x, 'uni', 0); +elseif isstring(x) && ~isscalar(x) + L = cellstr(x); +else + L = {char(string(x))}; +end +end + + +function T = local_vcat(T, Ti) +% Concatenate two collection tables on their COMMON columns (defensive against +% a dir that has a slightly different column set). +if isempty(T) || height(T) == 0 + T = Ti; + return +end +common = intersect(T.Properties.VariableNames, Ti.Properties.VariableNames, 'stable'); +T = [T(:, common); Ti(:, common)]; +end + + +function args = local_strip_param(args, name) +% Remove a name-value pair (case-insensitive) from a varargin cell list. +keep = true(1, numel(args)); +i = 1; +while i <= numel(args) - 1 + if (ischar(args{i}) || isstring(args{i})) && strcmpi(char(args{i}), name) + keep(i) = false; keep(i + 1) = false; + i = i + 2; + else + i = i + 1; + end +end +args = args(keep); +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_qc_study_curves.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_qc_study_curves.m new file mode 100644 index 00000000..9392fc23 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_qc_study_curves.m @@ -0,0 +1,249 @@ +function qc = hrf_qc_study_curves(study, varargin) +%HRF_QC_STUDY_CURVES Inspect noisy/outlying HRF curves in a study struct. +% +% qc = hrf_qc_study_curves(study, 'Model', 'sfir', 'Condition', 'pain') +% +% This summarizes one condition/contrast curve per run or subject. For +% map-score studies, this is the right place to flag outlying runs/subjects; +% trial-level QC requires original run results with events/timeseries. + +p = inputParser; +p.addRequired('study', @isstruct); +p.addParameter('Model', 'sfir', @(x) ischar(x) || isstring(x)); +p.addParameter('SourceModel', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Signature', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Condition', '', @(x) ischar(x) || isstring(x)); +p.addParameter('ConditionB', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Unit', 'run', @(x) ischar(x) || isstring(x)); +p.addParameter('OutlierZThreshold', 4, @(x) isscalar(x) && x > 0); +p.addParameter('Weighting', 'huber', @(x) ischar(x) || isstring(x)); +p.addParameter('Plot', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('MissingPolicy', 'warn', @(x) ischar(x) || isstring(x)); +p.parse(study, varargin{:}); +opts = p.Results; + +stats = hrf_time_unfolding_stats(study, ... + 'Model', opts.Model, ... + 'SourceModel', opts.SourceModel, ... + 'Signature', opts.Signature, ... + 'ConditionA', opts.Condition, ... + 'ConditionB', opts.ConditionB, ... + 'Unit', 'run', ... + 'MissingPolicy', opts.MissingPolicy); + +Y_runs = stats.run_level_data; +run_subject_ids = stats.run_subject_ids(:); +if isfield(stats, 'run_labels') && numel(stats.run_labels) == numel(run_subject_ids) + source_run_labels = cellstr(string(stats.run_labels(:))); +else + source_run_labels = local_unique_run_labels(run_subject_ids); +end +unit = lower(char(opts.Unit)); +switch unit + case 'run' + Y = Y_runs; + labels = source_run_labels; + subject_ids = run_subject_ids; + run_count = ones(size(labels)); + case 'subject' + subject_ids = unique(run_subject_ids, 'stable'); + Y = nan(numel(subject_ids), size(Y_runs, 2)); + run_count = zeros(numel(subject_ids), 1); + for i = 1:numel(subject_ids) + wh = strcmp(run_subject_ids, subject_ids{i}); + Y(i, :) = local_mean_omitnan(Y_runs(wh, :), 1); + run_count(i) = sum(wh); + end + labels = subject_ids; + otherwise + error('Unknown Unit: %s. Use run or subject.', unit); +end + +[max_abs_z, weights, is_outlier] = local_curve_weights(Y, opts.Weighting, opts.OutlierZThreshold); +rms_value = sqrt(local_mean_omitnan(Y .^ 2, 2)); +peak_abs = local_max_omitnan(abs(Y), 2); +auc_abs = local_sum_omitnan(abs(Y), 2); +n_nan = sum(isnan(Y), 2); + +qc = struct(); +qc.table = table((1:size(Y, 1))', string(labels(:)), string(subject_ids(:)), run_count(:), ... + max_abs_z(:), rms_value(:), peak_abs(:), auc_abs(:), n_nan(:), ... + weights(:), is_outlier(:), ... + 'VariableNames', {'index', 'label', 'subject', 'n_runs', 'max_abs_robust_z', ... + 'rms', 'peak_abs', 'auc_abs', 'n_nan', 'weight', 'is_outlier'}); +qc.curves = Y; +qc.time = stats.time(:); +qc.stats = stats; +qc.unit = unit; +qc.weighting = char(opts.Weighting); +qc.outlier_z_threshold = opts.OutlierZThreshold; +qc.skipped = stats.skipped; + +if logical(opts.Plot) + local_plot_qc(qc, opts); +end +end + +function labels = local_unique_run_labels(subject_ids) +labels = cell(size(subject_ids)); +seen = containers.Map('KeyType', 'char', 'ValueType', 'double'); +for i = 1:numel(subject_ids) + sid = char(subject_ids{i}); + if isKey(seen, sid) + seen(sid) = seen(sid) + 1; + else + seen(sid) = 1; + end + labels{i} = sprintf('%s_run%02d', sid, seen(sid)); +end +end + +function [max_abs_z, weights, is_outlier] = local_curve_weights(Y, weighting, threshold) +weighting = lower(strtrim(char(weighting))); +n = size(Y, 1); +center = local_nanmedian(Y, 1); +scale = 1.4826 .* local_nanmedian(abs(Y - repmat(center, n, 1)), 1); +scale_bad = scale == 0 | isnan(scale); +fallback_scale = local_nanstd(Y, 1); +scale(scale_bad) = fallback_scale(scale_bad); +scale(scale == 0 | isnan(scale)) = NaN; +Z = abs((Y - repmat(center, n, 1)) ./ repmat(scale, n, 1)); +max_abs_z = zeros(n, 1); +for i = 1:n + zi = Z(i, :); + zi = zi(~isnan(zi)); + if ~isempty(zi) + max_abs_z(i) = max(zi); + end +end + +is_outlier = max_abs_z > threshold; +weights = ones(n, 1); +switch weighting + case 'none' + return + case {'exclude', 'omit'} + weights(is_outlier) = 0; + case {'huber', 'downweight'} + weights = min(1, threshold ./ max(max_abs_z, eps)); + case {'bisquare', 'tukey'} + u = max_abs_z ./ threshold; + weights = (1 - u .^ 2) .^ 2; + weights(u >= 1) = 0; + otherwise + error('Unknown Weighting: %s. Use none, exclude, huber, or bisquare.', weighting); +end +end + +function local_plot_qc(qc, opts) +figure; +subplot(2, 1, 1); +imagesc(qc.time, 1:size(qc.curves, 1), qc.curves); +colorbar; +set(gca, 'YTick', 1:size(qc.curves, 1), 'YTickLabel', cellstr(qc.table.label), 'TickLabelInterpreter', 'none'); +xlabel('Time / lag'); +ylabel(char(opts.Unit)); +title(sprintf('Curve QC heatmap: model=%s, condition=%s', char(opts.Model), char(opts.Condition)), ... + 'Interpreter', 'none'); + +subplot(2, 1, 2); +hold on; +plot(qc.time, qc.curves', 'Color', [0.65 0.65 0.65]); +weighted_mean = local_weighted_mean(qc.curves, qc.table.weight); +plot(qc.time, weighted_mean, 'k-', 'LineWidth', 2.5); +xlabel('Time / lag'); +ylabel('Curve value'); +title(sprintf('black=weighted mean, weighting=%s, effective n=%0.3g', ... + qc.weighting, sum(qc.table.weight)), 'Interpreter', 'none'); +end + +function m = local_weighted_mean(Y, weights) +m = nan(1, size(Y, 2)); +weights = double(weights(:)); +for j = 1:size(Y, 2) + y = Y(:, j); + valid = ~isnan(y) & weights > 0; + if any(valid) + w = weights(valid); + m(j) = sum(w .* y(valid)) ./ sum(w); + end +end +end + +function m = local_mean_omitnan(X, dim) +if nargin < 2, dim = 1; end +valid = ~isnan(X); +den = sum(valid, dim); +X(~valid) = 0; +m = sum(X, dim) ./ den; +m(den == 0) = NaN; +end + +function m = local_max_omitnan(X, dim) +if dim ~= 2 + error('local_max_omitnan supports dim 2.'); +end +m = nan(size(X, 1), 1); +for i = 1:size(X, 1) + y = X(i, :); + y = y(~isnan(y)); + if ~isempty(y), m(i) = max(y); end +end +end + +function s = local_sum_omitnan(X, dim) +if dim ~= 2 + error('local_sum_omitnan supports dim 2.'); +end +s = nan(size(X, 1), 1); +for i = 1:size(X, 1) + y = X(i, :); + y = y(~isnan(y)); + if ~isempty(y), s(i) = sum(y); end +end +end + +function m = local_nanmedian(X, dim) +if nargin < 2, dim = 1; end +if dim == 1 + m = nan(1, size(X, 2)); + for j = 1:size(X, 2) + y = X(:, j); + y = y(~isnan(y)); + if ~isempty(y), m(j) = median(y); end + end +else + m = nan(size(X, 1), 1); + for i = 1:size(X, 1) + y = X(i, :); + y = y(~isnan(y)); + if ~isempty(y), m(i) = median(y); end + end +end +end + +function s = local_nanstd(X, dim) +mu = local_nanmean(X, dim); +if dim == 1 + centered = X - repmat(mu, size(X, 1), 1); + n = sum(~isnan(X), 1); + centered(isnan(centered)) = 0; + s = sqrt(sum(centered .^ 2, 1) ./ max(n - 1, 1)); +elseif dim == 2 + centered = X - repmat(mu, 1, size(X, 2)); + n = sum(~isnan(X), 2); + centered(isnan(centered)) = 0; + s = sqrt(sum(centered .^ 2, 2) ./ max(n - 1, 1)); +else + error('local_nanstd supports dim 1 or 2.'); +end +s(n < 2) = NaN; +end + +function m = local_nanmean(X, dim) +valid = ~isnan(X); +den = sum(valid, dim); +X(~valid) = 0; +m = sum(X, dim) ./ den; +m(den == 0) = NaN; +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_residual_diagnostics.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_residual_diagnostics.m new file mode 100644 index 00000000..b4d06514 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_residual_diagnostics.m @@ -0,0 +1,335 @@ +function T = hrf_residual_diagnostics(source, varargin) +%HRF_RESIDUAL_DIAGNOSTICS Residual-based misspecification metrics from HRF fits. +% +% The residual-based half of the Phase 4 misspecification pipeline. Unlike +% hrf_misspec_metrics (which compares the estimated HRF *curve* to a +% reference and works from the whole-brain score CSVs), these metrics need +% the residual TIME COURSE of each fit, which only exists in the 1D +% extracted-signal fits stored in a results / study struct -- not in the +% whole-brain NIfTIs or their score CSVs. +% +% For each (subject, signal, model) fit it reports: +% * mis_modeling_p - ResidScan p-value: probability the smoothed +% residual's max is that large by chance. SMALL = +% task-locked structure remains in the residual => +% the model is misspecified. (Lindquist & Loh 2007.) +% * power_loss - PowerLoss: efficiency lost relative to a flexible +% sFIR baseline (Loh/Lindquist/Wager). Larger = worse. +% * resid_task_corr - |corr(residual, combined stick function)|: direct +% check for residual variance tracking task timing. +% * resid_acf1 - lag-1 autocorrelation of the residual. +% * durbin_watson - Durbin-Watson statistic (~2 = white; <2 = positive +% autocorrelation, i.e. structured residual). +% * mse, dfe, n - fit error variance, error df, n time points. +% +% mis_modeling_p and power_loss are harvested from the stored fit fields +% (computed at fit time with ResidScan FWHM = 4) unless 'Recompute' is +% true, in which case ResidScan is re-run with 'ResidScanFWHM'. +% +% Usage +% ----- +% T = hrf_residual_diagnostics(results) % one subject +% T = hrf_residual_diagnostics(study) % study.results{:} +% T = hrf_residual_diagnostics(study, 'Signal', 'signatures') +% T = hrf_residual_diagnostics(study, 'Models', {'sfir','canonical'}) +% T = hrf_residual_diagnostics(study, 'Recompute', true, 'ResidScanFWHM', 6) +% +% Input dispatch (first arg) +% -------------------------- +% results struct (has .fits) -> one subject +% study struct (has .results cell) -> iterate, subject from +% .subject_ids / results{i}.subject_id +% cell array of results structs -> iterate +% struct array .label + .study/.results -> multi-study, rows tagged +% study_label +% +% Name-value parameters +% --------------------- +% 'Signal' - which 1D signals to diagnose: +% 'mean' (default) -> results.fits (the mean/selected +% signal) +% 'signatures' -> every results.fits_by_signature.* +% cellstr -> named signature fields +% 'Models' - cellstr filter, e.g. {'sfir','canonical'}. Default all +% models present in the fit struct. +% 'Recompute' - false (default): harvest stored .mis_modeling_p / +% .power_loss. true: re-run ResidScan (FWHM below) and, +% when the inputs are available, PowerLoss. +% 'ResidScanFWHM' - FWHM (time units) for a recomputed ResidScan. Default 4. +% +% Output +% ------ +% Long table; one row per (subject, signal, model) fit: +% subject, study_label (when multi-study), signal, model, +% n, dfe, mse, resid_task_corr, resid_acf1, durbin_watson, +% mis_modeling_p, power_loss +% Consistent with the other Phase 4 / curve tables, so +% hrf_curve_summary_groupstats and friends compose over it. +% +% See also: hrf_misspec_metrics, ResidScan, PowerLoss, run_hrf_pipeline. + +p = inputParser; +p.addRequired('input_source'); +p.addParameter('Signal', 'mean', @(x) ischar(x) || isstring(x) || iscell(x)); +p.addParameter('Models', {}, @(x) iscell(x) || isstring(x) || ischar(x)); +p.addParameter('Recompute', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('ResidScanFWHM', 4, @(x) isscalar(x) && x > 0); +p.parse(source, varargin{:}); +opts = p.Results; + +T = local_empty_resid_table(); + +% Multi-study struct array: a non-scalar struct with a .label field, where +% each element carries either a .study sub-struct or is itself study-like. +if isstruct(source) && ~isscalar(source) && isfield(source, 'label') + chunks = cell(numel(source), 1); + for i = 1:numel(source) + if isfield(source, 'study') + sub = source(i).study; + else + sub = source(i); + end + Ti = hrf_residual_diagnostics(sub, varargin{:}); + if ~isempty(Ti) && height(Ti) > 0 + Ti.study_label = repmat(string(source(i).label), height(Ti), 1); + end + chunks{i} = Ti; + end + chunks = chunks(~cellfun(@isempty, chunks)); + if ~isempty(chunks), T = vertcat(chunks{:}); end + return +end + +% Normalize to a list of (results, subject_id) pairs. +[results_list, subject_ids] = local_results_list(source); + +model_filter = local_to_cell(opts.Models); +rows = {}; +for i = 1:numel(results_list) + R = results_list{i}; + subj = subject_ids{i}; + signal_fits = local_collect_signal_fits(R, opts.Signal); + for sf = 1:numel(signal_fits) + signal_name = signal_fits(sf).name; + fits = signal_fits(sf).fits; + sticks = local_combined_stick(R); + model_names = fieldnames(fits); + for mi = 1:numel(model_names) + mname = model_names{mi}; + if ~isempty(model_filter) && ~any(strcmpi(mname, model_filter)), continue; end + fit = fits.(mname); + if ~isstruct(fit) || ~isfield(fit, 'residual') || isempty(fit.residual), continue; end + m = local_resid_metrics(fit, sticks, R, opts); + rows{end + 1} = local_resid_row(subj, signal_name, mname, m); %#ok + end + end +end +if ~isempty(rows) + T = vertcat(rows{:}); +end +end + + +% ========================================================================= +% Metric computation +% ========================================================================= +function m = local_resid_metrics(fit, sticks, R, opts) +m = struct('n', NaN, 'dfe', NaN, 'mse', NaN, ... + 'resid_task_corr', NaN, 'resid_acf1', NaN, 'durbin_watson', NaN, ... + 'mis_modeling_p', NaN, 'power_loss', NaN); + +e = double(fit.residual(:)); +m.n = numel(e); +if isfield(fit, 'dfe') && ~isempty(fit.dfe), m.dfe = double(fit.dfe); end +if isfield(fit, 'mse') && ~isempty(fit.mse), m.mse = double(fit.mse); end + +% Residual autocorrelation (lag-1) and Durbin-Watson. +if numel(e) >= 3 && std(e) > 0 + e0 = e(1:end-1); e1 = e(2:end); + if std(e0) > 0 && std(e1) > 0 + m.resid_acf1 = corr(e0, e1); + end + m.durbin_watson = sum(diff(e) .^ 2) / sum(e .^ 2); +end + +% Residual-task correlation (does residual variance track task timing?). +if ~isempty(sticks) && numel(sticks) == numel(e) && std(sticks) > 0 && std(e) > 0 + m.resid_task_corr = abs(corr(e, sticks(:))); +end + +% ResidScan / PowerLoss: harvest stored, or recompute. +if logical(opts.Recompute) + try + m.mis_modeling_p = ResidScan(e, opts.ResidScanFWHM); + catch + m.mis_modeling_p = NaN; + end + m.power_loss = local_recompute_powerloss(fit, R); +else + if isfield(fit, 'mis_modeling_p'), m.mis_modeling_p = double(fit.mis_modeling_p); end + if isfield(fit, 'power_loss'), m.power_loss = double(fit.power_loss); end +end +end + + +function pl = local_recompute_powerloss(fit, R) +% Re-run PowerLoss when the necessary inputs are reachable from the +% results struct. Falls back to the stored value (or NaN) otherwise. +pl = NaN; +if isfield(fit, 'power_loss') && ~isempty(fit.power_loss), pl = double(fit.power_loss); end +have = isfield(fit, 'residual') && isfield(fit, 'fit') && ... + isfield(R, 'timeseries') && isfield(R, 'stick_functions') && ... + isfield(R, 'settings') && isfield(R.settings, 'TR'); +if ~have, return; end +try + e = double(fit.residual(:)); + f = double(fit.fit(:)); + moddf = numel(e) - size(fit.hrf, 1); + pl = PowerLoss(e, f, max(moddf, 1), double(R.timeseries(:)), ... + R.settings.TR, R.stick_functions, 0.001); +catch + % keep the stored value already in pl +end +end + + +% ========================================================================= +% Input normalization +% ========================================================================= +function [results_list, subject_ids] = local_results_list(source) +results_list = {}; +subject_ids = {}; +if iscell(source) + for i = 1:numel(source) + if isempty(source{i}), continue; end + results_list{end + 1} = source{i}; %#ok + subject_ids{end + 1} = local_subject_of(source{i}, i); %#ok + end +elseif isstruct(source) && isfield(source, 'results') && iscell(source.results) + % A study struct. + ids = {}; + if isfield(source, 'subject_ids'), ids = source.subject_ids; end + for i = 1:numel(source.results) + if isempty(source.results{i}), continue; end + results_list{end + 1} = source.results{i}; %#ok + if numel(ids) >= i && ~isempty(ids{i}) + subject_ids{end + 1} = char(string(ids{i})); %#ok + else + subject_ids{end + 1} = local_subject_of(source.results{i}, i); %#ok + end + end +elseif isstruct(source) && isfield(source, 'fits') + % A single results struct. + results_list = {source}; + subject_ids = {local_subject_of(source, 1)}; +else + error('hrf_residual_diagnostics:UnknownSource', ... + ['First arg must be a results struct (with .fits), a study struct ' ... + '(with .results), or a cell array of results structs.']); +end +end + + +function s = local_subject_of(R, idx) +s = sprintf('sub-%03d', idx); +if isstruct(R) + if isfield(R, 'subject_id') && ~isempty(R.subject_id) + s = char(string(R.subject_id)); + elseif isfield(R, 'subject') && ~isempty(R.subject) + s = char(string(R.subject)); + end +end +end + + +function signal_fits = local_collect_signal_fits(R, signal_opt) +% Returns a struct array of (name, fits) to diagnose. +signal_fits = struct('name', {}, 'fits', {}); +if ischar(signal_opt) || (isstring(signal_opt) && isscalar(signal_opt)) + so = lower(char(signal_opt)); + if strcmp(so, 'mean') + if isfield(R, 'fits') && isstruct(R.fits) + signal_fits(end + 1) = struct('name', 'mean', 'fits', R.fits); + end + return + elseif strcmp(so, 'signatures') + signal_fits = local_all_signature_fits(R); + return + end + % a single named signature + signal_fits = local_named_signature_fits(R, {char(signal_opt)}); + return +end +% cellstr / string array of names +signal_fits = local_named_signature_fits(R, cellstr(string(signal_opt))); +end + + +function sf = local_all_signature_fits(R) +sf = struct('name', {}, 'fits', {}); +if isfield(R, 'fits_by_signature') && isstruct(R.fits_by_signature) + fns = fieldnames(R.fits_by_signature); + for i = 1:numel(fns) + sf(end + 1) = struct('name', fns{i}, 'fits', R.fits_by_signature.(fns{i})); %#ok + end +end +end + + +function sf = local_named_signature_fits(R, names) +sf = struct('name', {}, 'fits', {}); +if ~isfield(R, 'fits_by_signature') || ~isstruct(R.fits_by_signature), return; end +for i = 1:numel(names) + f = matlab.lang.makeValidName(names{i}); + if isfield(R.fits_by_signature, f) + sf(end + 1) = struct('name', names{i}, 'fits', R.fits_by_signature.(f)); %#ok + end +end +end + + +function sticks = local_combined_stick(R) +% Sum the per-condition stick functions into one task-timing regressor. +sticks = []; +if isfield(R, 'stick_functions') && iscell(R.stick_functions) && ~isempty(R.stick_functions) + Runc = R.stick_functions; + len = numel(Runc{1}); + sticks = zeros(len, 1); + for c = 1:numel(Runc) + v = double(Runc{c}(:)); + if numel(v) == len, sticks = sticks + v; end + end +end +end + + +% ========================================================================= +% Table construction / utilities +% ========================================================================= +function T = local_empty_resid_table() +T = table('Size', [0 12], ... + 'VariableTypes', {'string','string','string','string', ... + 'double','double','double','double','double','double','double','double'}, ... + 'VariableNames', {'subject','study_label','signal','model', ... + 'n','dfe','mse','resid_task_corr','resid_acf1','durbin_watson', ... + 'mis_modeling_p','power_loss'}); +end + + +function row = local_resid_row(subj, signal_name, mname, m) +row = table(string(subj), string(""), string(signal_name), string(mname), ... + m.n, m.dfe, m.mse, m.resid_task_corr, m.resid_acf1, m.durbin_watson, ... + m.mis_modeling_p, m.power_loss, ... + 'VariableNames', {'subject','study_label','signal','model', ... + 'n','dfe','mse','resid_task_corr','resid_acf1','durbin_watson', ... + 'mis_modeling_p','power_loss'}); +end + + +function c = local_to_cell(x) +if isempty(x), c = {}; +elseif ischar(x), c = {x}; +elseif isstring(x), c = cellstr(x); +else, c = x; +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_resolve_condition_patterns.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_resolve_condition_patterns.m new file mode 100644 index 00000000..8739922c --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_resolve_condition_patterns.m @@ -0,0 +1,137 @@ +function specs = hrf_resolve_condition_patterns(available_conditions, condition_spec, varargin) +%HRF_RESOLVE_CONDITION_PATTERNS Resolve exact/wildcard/regex conditions. +% +% specs = hrf_resolve_condition_patterns(available_conditions, condition_spec) +% +% condition_spec can be a numeric index, string, string array, or cellstr. +% Exact names are preferred. If no exact condition matches, wildcard +% patterns with * or ? are supported. Regex can be supplied as +% 'regex:', '//', or a string containing common regex +% operators such as .*, |, [], (), ^, $, or +. +% +% The output is a struct array with indices and matched condition names. If +% one spec matches multiple conditions, downstream callers should average +% over specs(i).indices and display specs(i).display_label. + +p = inputParser; +p.addRequired('available_conditions', @(x) iscell(x) || isstring(x)); +p.addRequired('condition_spec', @(x) isempty(x) || isnumeric(x) || iscell(x) || isstring(x) || ischar(x)); +p.addParameter('DefaultMode', 'each', @(x) ischar(x) || isstring(x)); +p.addParameter('AllowNumeric', true, @(x) islogical(x) || isnumeric(x)); +p.parse(available_conditions, condition_spec, varargin{:}); +opts = p.Results; + +available = cellstr(string(available_conditions)); +default_mode = lower(char(opts.DefaultMode)); + +if isempty(condition_spec) + specs = local_default_specs(available, default_mode); + return +end + +if isnumeric(condition_spec) + if ~logical(opts.AllowNumeric) + error('Numeric condition indices are not allowed here.'); + end + specs = local_numeric_specs(available, condition_spec); + return +end + +tokens = cellstr(string(condition_spec)); +tokens = tokens(~cellfun(@isempty, tokens)); +if isempty(tokens) + specs = local_default_specs(available, default_mode); + return +end + +specs = repmat(local_empty_spec(), 0, 1); +for i = 1:numel(tokens) + specs(end + 1, 1) = local_token_spec(available, strtrim(tokens{i})); %#ok +end +end + +function specs = local_default_specs(available, default_mode) +switch default_mode + case 'each' + specs = local_numeric_specs(available, 1:numel(available)); + case 'first' + specs = local_numeric_specs(available, 1); + case 'all' + specs = local_group_spec(available, 1:numel(available), 'all', 'all'); + otherwise + error('Unknown DefaultMode: %s. Use ''each'', ''first'', or ''all''.', default_mode); +end +end + +function specs = local_numeric_specs(available, idx) +idx = idx(:)'; +if any(idx < 1) || any(idx > numel(available)) || any(mod(idx, 1) ~= 0) + error('Condition index out of range.'); +end +specs = repmat(local_empty_spec(), numel(idx), 1); +for i = 1:numel(idx) + specs(i) = local_group_spec(available, idx(i), available{idx(i)}, 'index'); +end +end + +function spec = local_token_spec(available, token) +idx = find(strcmp(available, token)); +match_type = 'exact'; + +if isempty(idx) + [pattern, match_type] = local_pattern(token); + idx = find(~cellfun(@isempty, regexp(available, pattern, 'once'))); +end + +if isempty(idx) + error('Condition pattern "%s" did not match any available condition. Available: %s', ... + token, strjoin(available, ', ')); +end + +spec = local_group_spec(available, idx, token, match_type); +end + +function [pattern, match_type] = local_pattern(token) +if startsWith(token, 'regex:') + pattern = token(numel('regex:') + 1:end); + match_type = 'regex'; +elseif numel(token) >= 2 && startsWith(token, '/') && endsWith(token, '/') + pattern = token(2:end - 1); + match_type = 'regex'; +elseif local_looks_like_regex(token) + pattern = token; + match_type = 'regex'; +elseif contains(token, '*') || contains(token, '?') + pattern = regexptranslate('wildcard', token); + match_type = 'wildcard'; +else + pattern = ['^' regexptranslate('escape', token) '$']; + match_type = 'exact'; +end +end + +function tf = local_looks_like_regex(token) +tf = contains(token, '.*') || contains(token, '|') || contains(token, '[') || ... + contains(token, ']') || contains(token, '(') || contains(token, ')') || ... + contains(token, '^') || contains(token, '$') || contains(token, '+'); +end + +function spec = local_group_spec(available, idx, label, match_type) +spec = local_empty_spec(); +spec.pattern = char(label); +spec.match_type = char(match_type); +spec.indices = idx(:)'; +spec.matched_conditions = available(spec.indices); +spec.label = char(label); +if isscalar(spec.indices) + spec.display_label = spec.matched_conditions{1}; +else + spec.display_label = sprintf('%s (mean of %d: %s)', ... + char(label), numel(spec.indices), strjoin(spec.matched_conditions, ', ')); +end +end + +function spec = local_empty_spec() +spec = struct('pattern', '', 'match_type', '', 'indices', [], ... + 'matched_conditions', {{}}, 'label', '', 'display_label', ''); +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_resolve_spm_runs.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_resolve_spm_runs.m new file mode 100644 index 00000000..c64d171e --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_resolve_spm_runs.m @@ -0,0 +1,238 @@ +function [spm_runs, report] = hrf_resolve_spm_runs(fmri_files, spm_files, varargin) +% Resolve, for each single-run fMRI file, its run index within a per-session +% (concatenated-runs) SPM.mat -- the value to pass as 'SPMRuns' to +% hrf_fit_wholebrain_stats / hrf_write_slurm_study_script for Tier B GKWY. +% +% Getting this index right matters: if a session's runs share the same scan +% count, a guessed index would pass the scan-count check yet apply the WRONG +% run's high-pass/whitening block. This reads each SPM's actual per-run source +% images and matches each fMRI file to its block, preferring the BIDS run token. +% +% Match order (most to least specific), per file: +% 1. 'run-XX' token shared between the fMRI file and one SPM run's source +% images (robust to preprocessing prefixes/suffixes). +% 2. fMRI basename contained in one SPM run's source image names. +% 3. unique scan-count match (only one SPM run has numel(rows) == n_tp). +% Otherwise -> NaN, flagged 'ambiguous' / 'nomatch' in the report. +% +% :Usage: +% :: +% [spm_runs, report] = hrf_resolve_spm_runs(fmri_files, spm_files) +% +% :Inputs: +% +% **fmri_files:** +% cellstr/string of single-run 4D fMRI paths (.nii/.nii.gz). +% +% **spm_files:** +% cellstr/string of the per-session SPM.mat path for each fMRI file +% (the SPM that concatenated that file's session). '' entries -> run 1. +% +% :Optional Inputs: +% +% **'Verbose' / 'doverbose':** +% logical, print the resolution table (default true). +% +% :Outputs: +% +% **spm_runs:** +% 1 x N numeric, the resolved run index per fMRI file (NaN where it +% could not be resolved -- inspect `report` and resolve those by hand). +% +% **report:** +% table: index, fmri_file, spm_file, n_tp, n_runs, spm_run, method, +% message. +% +% :Examples: +% :: +% [spm_runs, report] = hrf_resolve_spm_runs(fmri_files, spm_files); +% assert(~any(isnan(spm_runs)), 'resolve the NaN rows before launching'); +% % then: hrf_write_slurm_study_script(..., 'SPMFiles', spm_files, 'SPMRuns', spm_runs) +% +% See also: hrf_validate_spm_inputs, hrf_fit_wholebrain_stats, hrf_write_slurm_study_script. + +p = inputParser; +p.addRequired('fmri_files', @(x) ischar(x) || iscell(x) || isstring(x)); +p.addRequired('spm_files', @(x) ischar(x) || iscell(x) || isstring(x)); +p.addParameter('Verbose', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('doverbose', [], @(x) isempty(x) || islogical(x) || isnumeric(x)); +p.parse(fmri_files, spm_files, varargin{:}); +verbose = logical(p.Results.Verbose); +if ~isempty(p.Results.doverbose), verbose = logical(p.Results.doverbose); end + +fmri_files = local_to_cellstr(fmri_files); +spm_files = local_to_cellstr(spm_files); +n = numel(fmri_files); +if numel(spm_files) ~= n + error('hrf_resolve_spm_runs:LengthMismatch', ... + 'fmri_files (%d) and spm_files (%d) must match.', n, numel(spm_files)); +end + +% Cache loaded SPMs (sessions repeat across runs). +cache = containers.Map('KeyType', 'char', 'ValueType', 'any'); + +spm_runs = nan(1, n); +n_tp = nan(n, 1); n_runs = nan(n, 1); +method = strings(n, 1); message = strings(n, 1); + +for i = 1:n + [spm_runs(i), n_tp(i), n_runs(i), method(i), message(i)] = ... + local_resolve_one(fmri_files{i}, spm_files{i}, cache); +end + +report = table((1:n)', fmri_files(:), spm_files(:), n_tp, n_runs, spm_runs(:), method, message, ... + 'VariableNames', {'index', 'fmri_file', 'spm_file', 'n_tp', 'n_runs', 'spm_run', 'method', 'message'}); + +if verbose + disp(report(:, {'index', 'n_tp', 'n_runs', 'spm_run', 'method'})); + n_bad = sum(isnan(spm_runs)); + fprintf('hrf_resolve_spm_runs: %d/%d resolved, %d unresolved.\n', n - n_bad, n, n_bad); + for i = find(isnan(spm_runs)) + fprintf(' [%d] %s: %s\n', i, local_short(fmri_files{i}), message(i)); + end +end +end + + +% ========================================================================= +function [run, n_tp, n_runs, method, msg] = local_resolve_one(fmri_file, spm_file, cache) +run = NaN; n_tp = NaN; n_runs = NaN; method = ""; msg = ""; + +if isempty(strtrim(char(spm_file))) + run = 1; method = "no-spm"; msg = "no SPM -> run 1 (Tier A fallback)"; return +end + +try + SPM = local_load_spm(spm_file, cache); +catch err + method = "spm-error"; msg = string(err.message); return +end + +[run_sources, n_runs] = local_run_source_names(SPM); +if n_runs == 0 + method = "no-sessions"; msg = "SPM has no Sess/nscan structure"; return +end + +fbase = local_basename(fmri_file); +ftok = local_run_token(fbase); + +% 1. run-token match +if ~isempty(ftok) + hit = []; + for r = 1:n_runs + if any(strcmp(ftok, run_sources{r}.run_tokens)) + hit(end + 1) = r; %#ok + end + end + if isscalar(hit) + run = hit; method = "run-token"; msg = sprintf("matched run-%s", ftok); return + elseif numel(hit) > 1 + method = "ambiguous"; msg = sprintf("run-%s matches %d SPM runs", ftok, numel(hit)); return + end +end + +% 2. basename containment +hit = []; +for r = 1:n_runs + if any(contains(run_sources{r}.bases, fbase)) || any(strcmp(run_sources{r}.bases, fbase)) + hit(end + 1) = r; %#ok + end +end +if isscalar(hit) + run = hit; method = "basename"; msg = "matched by source filename"; return +end + +% 3. unique scan-count match +try + n_tp = local_ntp(fmri_file); +catch + n_tp = NaN; +end +if ~isnan(n_tp) + counts = cellfun(@(s) s.nscan, run_sources); + hit = find(counts == n_tp); + if isscalar(hit) + run = hit; method = "scan-count"; msg = sprintf("only run %d has %d scans", hit, n_tp); return + elseif numel(hit) > 1 + method = "ambiguous"; msg = sprintf("%d runs have %d scans; set SPMRuns by hand", numel(hit), n_tp); return + end +end + +method = "nomatch"; msg = "no run-token, filename, or unique scan-count match"; +end + +function SPM = local_load_spm(spm_file, cache) +key = char(spm_file); +if isKey(cache, key), SPM = cache(key); return; end +if exist(key, 'file') ~= 2, error('SPM.mat not found: %s', key); end +S = load(key, 'SPM'); +if ~isfield(S, 'SPM'), error('no SPM variable in %s', key); end +SPM = S.SPM; +cache(key) = SPM; %#ok +end + +function [run_sources, n_runs] = local_run_source_names(SPM) +% Per run: the source image basenames, their run tokens, and scan count. +run_sources = {}; +n_runs = 0; +if ~isfield(SPM, 'Sess') || isempty(SPM.Sess), return; end +n_runs = numel(SPM.Sess); + +% Per-scan source filenames, if available. +vy_names = {}; +if isfield(SPM, 'xY') && isfield(SPM.xY, 'VY') && ~isempty(SPM.xY.VY) + try + vy_names = {SPM.xY.VY.fname}; + catch + vy_names = {}; + end +elseif isfield(SPM, 'xY') && isfield(SPM.xY, 'P') && ~isempty(SPM.xY.P) + vy_names = cellstr(SPM.xY.P); +end + +run_sources = cell(1, n_runs); +for r = 1:n_runs + rows = SPM.Sess(r).row; + s = struct('bases', {{}}, 'run_tokens', {{}}, 'nscan', numel(rows)); + if ~isempty(vy_names) && max(rows) <= numel(vy_names) + names = unique(cellfun(@local_basename, vy_names(rows), 'UniformOutput', false), 'stable'); + s.bases = names; + toks = cellfun(@local_run_token, names, 'UniformOutput', false); + s.run_tokens = unique(toks(~cellfun(@isempty, toks)), 'stable'); + end + run_sources{r} = s; +end +end + +function n_tp = local_ntp(fmri_file) +info = niftiinfo(char(fmri_file)); +sz = info.ImageSize; +if numel(sz) < 4, error('not 4D'); end +n_tp = sz(4); +end + +function tok = local_run_token(name) +t = regexp(char(name), 'run[-_]?([0-9A-Za-z]+)', 'tokens', 'once'); +if isempty(t), tok = ''; else, tok = t{1}; end +end + +function b = local_basename(p) +[~, nm, ext] = fileparts(char(p)); +b = [nm ext]; +b = regexprep(b, ',\d+$', ''); % strip SPM volume index ',N' +b = regexprep(b, '\.nii(\.gz)?$', ''); % strip nifti extension +end + +function c = local_to_cellstr(x) +if ischar(x), c = {x}; +elseif isstring(x), c = cellstr(x); +elseif iscell(x), c = cellfun(@(v) char(string(v)), x, 'UniformOutput', false); +else, error('hrf_resolve_spm_runs:BadType', 'Expected char, string, or cell of paths.'); +end +c = c(:)'; +end + +function s = local_short(p) +[~, nm, ext] = fileparts(char(p)); +s = [nm ext]; +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_score_one_prefix.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_score_one_prefix.m new file mode 100644 index 00000000..af43de3a --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_score_one_prefix.m @@ -0,0 +1,1281 @@ +function status = hrf_score_one_prefix(output_prefix, varargin) +%HRF_SCORE_ONE_PREFIX Score one (task, model) prefix's whole-brain HRF maps. +% +% status = hrf_score_one_prefix(output_prefix, ...) +% +% Single source of truth for applying signature/image sets to one set of +% whole-brain HRF maps. Writes *__map_scores.csv files in place at +% OUTPUT_PREFIX. Three callers share this helper: +% +% * hrf_write_slurm_study_script (SLURM worker) - in-memory stats path, +% called immediately after hrf_fit_wholebrain_stats with the in-memory +% struct so no NIfTI re-read is needed. +% * hrf_audit_slurm_outputs (RepairMissing mode) - disk path, called when +% the audit detects core_complete && ~score_complete rows. +% * hrf_score_wholebrain_input_table (post-hoc backfill) - disk path, +% iterated per row of the second-level input table. +% +% Required input +% output_prefix : char/string. Full path prefix used by +% hrf_fit_wholebrain_stats. The whole-brain image files +% are expected at _beta.nii etc. +% +% Common name-value parameters +% 'ModelName' - 'fir' | 'sfir' | 'canonical' | 'spline' | ''. +% Used to build per-model filenames when NumModels>1. +% 'NumModels' - 1 if only one whole-brain model was fit at this +% prefix; >1 if multiple share the prefix. Controls +% filename composition: single-model files are +% __map_scores.csv; multi-model files +% are ___map_scores.csv. +% 'ScoreObjects' - cellstr/string. Default {'beta'}. Subset of +% {'beta','t'}. +% 'SignatureSets' - cellstr/string. Passed to +% hrf_apply_maps_to_wholebrain. +% 'ImageSets' - cellstr/string/image_vector. Passed to +% hrf_apply_maps_to_wholebrain. +% 'SimilarityMetric' - default 'dotproduct'. +% 'PropagateSE' - default true. Beta-only and metric-restricted, same +% semantics as hrf_apply_maps_to_wholebrain. +% +% Metadata resolution (in order of preference) +% 'MetadataTable' - pre-resolved metadata table; skip resolution. +% 'MetadataFile' - explicit metadata CSV; loaded if exists. +% 'ResultMatFile' - result.mat path; fallback if no MetadataFile. +% Otherwise: _metadata.csv, then __metadata.csv +% siblings, then return empty (and skip if RequireMetadata is true). +% +% Fast path (skip NIfTI re-read) +% 'StatsInput' - struct returned by hrf_fit_wholebrain_stats (fields +% .b, .t, .metadata_table). If supplied, no disk +% reads are needed for the score/SE objects. +% +% Other controls +% 'Overwrite' - default false. Force regeneration of existing CSVs. +% 'OverwriteStale' - default true. Regenerate CSVs that exist but fail +% metadata/signature-coverage validation. +% 'RequireMetadata' - default true. Skip writing if no metadata could be +% resolved. +% 'NoVerbose' - default true. Suppress fmri_data load messages. +% 'WarningContext' - propagated to hrf_apply_maps_to_wholebrain. +% +% Return +% status struct with fields +% .output_prefix - char +% .model_name - char +% .metadata_file - char (or '') +% .wrote_files - cellstr of CSV paths produced this call +% .skipped_existing - cellstr of CSV paths left alone (already valid) +% .skipped_stale - cellstr of CSV paths left alone (stale, with +% OverwriteStale=false) +% .errors - struct array with .object and .message fields +% .core_inputs_present - logical; true iff *_beta.nii exists (or .b is +% provided via StatsInput) +% +% See also: hrf_apply_maps_to_wholebrain, hrf_audit_slurm_outputs, +% hrf_score_wholebrain_input_table, hrf_write_slurm_study_script. + +p = inputParser; +p.addRequired('output_prefix', @(x) ischar(x) || isstring(x)); +p.addParameter('ModelName', '', @(x) ischar(x) || isstring(x)); +p.addParameter('NumModels', 1, @(x) isnumeric(x) && isscalar(x) && x >= 1); +p.addParameter('ScoreObjects', {'beta'}, @(x) ischar(x) || iscell(x) || isstring(x)); +p.addParameter('SignatureSets', {}, @(x) ischar(x) || iscell(x) || isstring(x)); +p.addParameter('ImageSets', {}, @(x) ischar(x) || iscell(x) || isstring(x) || isa(x, 'image_vector')); +p.addParameter('AtlasObj', [], @(x) isempty(x) || isa(x, 'atlas') || isa(x, 'image_vector')); +p.addParameter('AtlasName', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Regions', {}, @(x) iscell(x) || isstring(x) || ischar(x)); +% Multi-atlas: score several atlases in one pass (columns namespaced by name, +% so give each a DISTINCT AtlasNames entry to avoid collisions). AtlasRegions +% is a cell-of-region-lists, one per atlas ({} = all regions for that atlas). +% The singular AtlasObj/AtlasName/Regions above still work and are scored too. +p.addParameter('AtlasObjs', {}, @(x) isempty(x) || iscell(x) || isa(x, 'atlas') || isa(x, 'image_vector')); +p.addParameter('AtlasNames', {}, @(x) isempty(x) || iscell(x) || ischar(x) || isstring(x)); +p.addParameter('AtlasRegions', {}, @(x) isempty(x) || iscell(x)); +p.addParameter('Normalize', 'mean', @(x) ischar(x) || isstring(x)); +p.addParameter('SimilarityMetric', 'dotproduct', @(x) ischar(x) || isstring(x)); +p.addParameter('PropagateSE', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('SEScoreSuffix', '_se', @(x) ischar(x) || isstring(x)); +p.addParameter('MetadataTable', table(), @(x) isempty(x) || istable(x)); +p.addParameter('MetadataFile', '', @(x) ischar(x) || isstring(x)); +p.addParameter('ResultMatFile', '', @(x) ischar(x) || isstring(x)); +p.addParameter('StatsInput', [], @(x) isempty(x) || isstruct(x)); +p.addParameter('Overwrite', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('OverwriteStale', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('Append', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('RequireMetadata', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('NoVerbose', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('WarningContext', '', @(x) ischar(x) || isstring(x)); +p.parse(output_prefix, varargin{:}); +opts = p.Results; + +prefix = char(opts.output_prefix); +model_name = char(opts.ModelName); +n_models = double(opts.NumModels); +objects = local_resolve_score_objects(opts.ScoreObjects); + +status = struct( ... + 'output_prefix', prefix, ... + 'model_name', model_name, ... + 'metadata_file', '', ... + 'wrote_files', {{}}, ... + 'skipped_existing', {{}}, ... + 'skipped_stale', {{}}, ... + 'errors', struct('object', {}, 'message', {}), ... + 'core_inputs_present', false); + +% Resolve metadata first; many downstream checks need it. +[metadata_table, metadata_file] = local_resolve_metadata( ... + prefix, model_name, opts.MetadataTable, opts.MetadataFile, ... + opts.ResultMatFile, opts.StatsInput); +status.metadata_file = metadata_file; + +if isempty(metadata_table) && logical(opts.RequireMetadata) + status.errors(end + 1) = struct('object', '', ... + 'message', sprintf('Cannot score %s: no metadata table resolvable from prefix, result.mat, or sibling CSVs.', prefix)); + return +end + +% Check core inputs (cheaply, before iterating objects). +status.core_inputs_present = local_core_inputs_present(prefix, model_name, n_models, opts.StatsInput); +if ~status.core_inputs_present + expected_beta = [local_image_prefix(prefix, model_name, n_models) '_beta.nii']; + status.errors(end + 1) = struct('object', '', ... + 'message', sprintf('Missing core whole-brain inputs for prefix %s (expected %s or StatsInput.b).', prefix, expected_beta)); + return +end + +for j = 1:numel(objects) + object_name = objects{j}; + score_file = local_score_file_path(prefix, model_name, n_models, object_name); + require_uncertainty = local_requires_uncertainty(prefix, object_name, opts); + + if exist(score_file, 'file') == 2 && ~logical(opts.Overwrite) + if local_existing_score_is_valid(score_file, metadata_table, require_uncertainty, opts) + status.skipped_existing{end + 1} = score_file; %#ok + continue + end + % Existing file is "stale" relative to current request -- either + % missing requested signature/image sets, or out-of-shape metadata. + % If Append mode is on and metadata aligns, compute ONLY the missing + % sets and merge into the existing CSV (preserves prior columns). + % Otherwise fall back to OverwriteStale (full regenerate) or skip. + if logical(opts.Append) + [appended, append_err] = local_try_append( ... + score_file, prefix, object_name, model_name, metadata_table, opts); + if appended + status.wrote_files{end + 1} = score_file; %#ok + continue + elseif ~isempty(append_err) + status.errors(end + 1) = struct('object', object_name, ... + 'message', sprintf('append failed for %s: %s (falling back to overwrite if OverwriteStale)', ... + score_file, append_err)); %#ok + % fall through; OverwriteStale (if true) regenerates from scratch + end + % append_err empty AND not appended means metadata didn't align; + % fall through to the OverwriteStale logic below. + end + if ~logical(opts.OverwriteStale) + status.skipped_stale{end + 1} = score_file; %#ok + continue + end + % else fall through and regenerate from scratch + end + + try + [score_obj, se_obj] = local_resolve_score_objects_for_write( ... + prefix, object_name, metadata_table, opts); + local_validate_score_object_metadata(score_obj, metadata_table, object_name, prefix); + + scores = hrf_apply_maps_to_wholebrain(score_obj, ... + 'Object', object_name, ... + 'SignatureSets', opts.SignatureSets, ... + 'ImageSets', opts.ImageSets, ... + 'SimilarityMetric', opts.SimilarityMetric, ... + 'SEInput', se_obj, ... + 'PropagateSE', opts.PropagateSE, ... + 'SEScoreSuffix', opts.SEScoreSuffix, ... + 'MetadataTable', metadata_table, ... + 'OutputCsv', '', ... + 'WarningContext', local_warning_context(opts.WarningContext, model_name, object_name, prefix)); + + % Optionally append atlas region means -- one block per atlas spec. + atlas_specs = local_atlas_specs(opts); + for k = 1:numel(atlas_specs) + atlas_cols = local_compute_atlas_scores(score_obj, atlas_specs(k).obj, ... + atlas_specs(k).name, ... + local_effective_regions(atlas_specs(k).obj, atlas_specs(k).regions), ... + opts.Normalize); + scores = local_horzappend_scores(scores, atlas_cols); + end + + writetable(scores, score_file); + status.wrote_files{end + 1} = score_file; %#ok + catch err + status.errors(end + 1) = struct('object', object_name, 'message', err.message); %#ok + end +end +end + + +% ========================================================================= +% Append mode: compute only missing sets, merge into existing CSV +% ========================================================================= +function [appended, err_msg] = local_try_append(score_file, prefix, object_name, model_name, metadata_table, opts) +% Returns appended=true and err_msg='' on success. +% Returns appended=false and err_msg='' if metadata mismatch -> caller falls back. +% Returns appended=false and err_msg= on actual error -> caller decides. +appended = false; +err_msg = ''; + +try + existing = readtable(score_file, 'TextType', 'string'); +catch err + err_msg = sprintf('could not read existing CSV: %s', err.message); + return +end + +if ~local_existing_metadata_aligns(existing, metadata_table) + % Silent fall-through; the existing file is incompatible with current + % metadata (different rows, conditions, or lags). Full regenerate is + % the right move. + return +end + +[missing_sigs, missing_imgs] = local_missing_sets(existing, opts); +atlas_specs = local_atlas_specs(opts); +missing_regions = local_missing_atlas_regions(existing, opts); +if isempty(missing_sigs) && isempty(missing_imgs) && isempty(missing_regions) + % Everything requested is already present; nothing to do, but the + % existing file is fine. + appended = true; % no-op write avoided + return +end + +try + [score_obj, se_obj] = local_resolve_score_objects_for_write( ... + prefix, object_name, metadata_table, opts); + local_validate_score_object_metadata(score_obj, metadata_table, object_name, prefix); + + new_scores = hrf_apply_maps_to_wholebrain(score_obj, ... + 'Object', object_name, ... + 'SignatureSets', missing_sigs, ... + 'ImageSets', missing_imgs, ... + 'SimilarityMetric', opts.SimilarityMetric, ... + 'SEInput', se_obj, ... + 'PropagateSE', opts.PropagateSE, ... + 'SEScoreSuffix', opts.SEScoreSuffix, ... + 'MetadataTable', metadata_table, ... + 'OutputCsv', '', ... + 'WarningContext', local_warning_context(opts.WarningContext, model_name, object_name, prefix)); + + % Atlas region means -- per atlas spec, append ONLY that atlas's missing + % regions, so adding a new region (or a whole new atlas) to an existing + % CSV doesn't trigger a full re-extract. + for k = 1:numel(atlas_specs) + mr = local_missing_atlas_regions_spec(existing, atlas_specs(k), opts.Normalize); + if ~isempty(mr) + atlas_cols = local_compute_atlas_scores(score_obj, atlas_specs(k).obj, ... + atlas_specs(k).name, mr, opts.Normalize); + new_scores = local_horzappend_scores(new_scores, atlas_cols); + end + end + + merged = local_merge_score_tables(existing, new_scores); + writetable(merged, score_file); + appended = true; +catch err + err_msg = err.message; +end +end + + +function tf = local_existing_metadata_aligns(existing, meta) +% Row count + condition + lag must match for an in-place append to make sense. +tf = false; +if isempty(meta) || height(existing) ~= height(meta) + return +end +has_cond_e = any(strcmp('condition', existing.Properties.VariableNames)); +has_cond_m = any(strcmp('condition', meta.Properties.VariableNames)); +if has_cond_e && has_cond_m && ~isequal(string(existing.condition), string(meta.condition)) + return +end +has_lag_e = any(strcmp('lag_index', existing.Properties.VariableNames)); +has_lag_m = any(strcmp('lag_index', meta.Properties.VariableNames)); +if has_lag_e && has_lag_m && ~isequaln(local_to_numeric(existing.lag_index), local_to_numeric(meta.lag_index)) + return +end +tf = true; +end + + +function [missing_sigs, missing_imgs] = local_missing_sets(existing, opts) +% Semantic match: each requested SET name produces columns starting with +% sig__ or map__. A set is "present" if at least one +% such column exists. +missing_sigs = {}; +sigsets = local_to_cell(opts.SignatureSets); +for i = 1:numel(sigsets) + if ~local_has_numeric_prefix(existing, local_var_prefix({'sig', sigsets{i}})) + missing_sigs{end + 1} = sigsets{i}; %#ok + end +end +missing_imgs = {}; +image_sets = local_to_cell(opts.ImageSets); +for i = 1:numel(image_sets) + if isa(image_sets{i}, 'image_vector') + set_name = 'imageset'; + else + set_name = char(image_sets{i}); + end + if ~local_has_numeric_prefix(existing, local_var_prefix({'map', set_name})) + missing_imgs{end + 1} = image_sets{i}; %#ok + end +end +end + + +function merged = local_merge_score_tables(existing, new_scores) +% Add any new score columns from new_scores into existing. Metadata +% columns come from existing (they should match new_scores's metadata +% since the alignment check passed). Existing score columns are +% preserved -- if a column name collides we keep the existing one +% (assume it's already valid). +merged = existing; +metadata_cols = {'volume_index', 'condition', 'condition_index', 'lag_index', ... + 'lag_seconds', 'image_label', 'N', 'dfe', 'TR', 'mode', 'subject', 'run_label'}; +new_vars = new_scores.Properties.VariableNames; +existing_vars = existing.Properties.VariableNames; +for i = 1:numel(new_vars) + col = new_vars{i}; + if ismember(col, metadata_cols), continue; end + if any(strcmp(col, existing_vars)), continue; end + merged.(col) = new_scores.(col); +end +end + + +% ========================================================================= +% Atlas region-mean scoring (post-hoc extraction from whole-brain maps) +% ========================================================================= +function atlas_cols = local_compute_atlas_scores(score_obj, atlas_obj, atlas_name, regions, normalize) +% Returns a table with one column per requested atlas region: +% atlas___ +% with row count == n_volumes in score_obj. +% Uses extract_roi_averages which handles space resampling between atlas +% and score_obj automatically. If `regions` is non-empty, subsets the +% atlas via select_atlas_subset(..., 'exact', 'deterministic') first. +% The 'deterministic' flag forces probabilistic atlases (e.g., canlab2024) +% to a winner-takes-all parcellation so each voxel belongs to at most one +% region -- avoids the probabilistic overlap that would otherwise blur +% region boundaries. +% +% normalize is one of: +% 'mean' - region mean (extract_roi_averages default). Suffix '_mean'. +% Already L1-normalized by region size (= sum / nVoxels). +% (Default.) Standard ROI output: plain mean BOLD signal. +% 'l1' - region mean, then each region's time course divided by its +% own L1 norm sum(|values|). Makes regions of differing baseline +% signal magnitudes directly comparable in HRF *shape*. Suffix +% '_meanL1'. +% 'none' - region sum (extract_roi_averages 'nonorm'). Suffix '_sum'. +% Larger regions get larger values; only useful when downstream +% code wants raw integrated signal. +if nargin < 5 || isempty(normalize), normalize = 'mean'; end +normalize = lower(strtrim(char(normalize))); + +if isempty(regions) + regions = local_effective_regions(atlas_obj, {}); +end +regions = cellstr(string(regions)); + +% IMPORTANT: extract_roi_averages exhibits different per-volume behavior +% when given a multi-region atlas (518 regions in one call) vs a +% single-region deterministic subset. The multi-region path can produce +% jagged per-volume averages even when each region's underlying voxels +% are smooth canonical curves -- confirmed in a side-by-side diagnostic +% where extract_roi_averages on a 1-region subset gave a smooth peak +% while the CSV column (written from a 518-region subset call) was +% noise. To stay on the correct code path, subset the atlas to ONE +% region at a time and call extract_roi_averages per region. Slower +% (~Nx for N regions) but correct. + +extract_args = {}; +switch normalize + case 'none' + extract_args = {'nonorm'}; + suffix = 'sum'; + case {'mean', 'l1'} + suffix = normalize_suffix(normalize); + otherwise + error('hrf_score_one_prefix:UnknownNormalize', ... + 'Unknown Normalize: %s. Use mean, l1, or none.', normalize); +end + +atlas_token = matlab.lang.makeValidName(char(atlas_name)); +n_vol = size(score_obj.dat, 2); +atlas_cols = table(); + +% Convert score_obj to fmri_data for extract_roi_averages. The scoring +% pipeline loads NIfTIs as statistic_image(fmri_data(...)); on that +% statistic_image-typed input extract_roi_averages takes a different +% code path that applies sig() filters and produces non-linear per-volume +% behavior even for smooth canonical data. The user's manual diagnostic +% on plain fmri_data gave smooth output; matching that here. +extract_input = local_to_fmri_data_for_extract(score_obj); + +for r = 1:numel(regions) + region_label = regions{r}; + if isa(atlas_obj, 'atlas') + % Always 'deterministic' (winner-take-all). Probabilistic atlases are + % otherwise far too liberal -- a canlab2024 region spans ~2x the voxels + % of its hard parcellation, blurring boundaries past what is canonically + % anatomical. On a label-only atlas (empty probability_maps) the flag is + % a verified harmless no-op (it falls back to the integer .dat labels). + try + single_sub = select_atlas_subset(atlas_obj, {region_label}, 'exact', 'deterministic'); + catch err + warning('hrf_score_one_prefix:AtlasRegionSubset', ... + 'Skipping region ''%s'': %s', region_label, err.message); + continue + end + else + single_sub = atlas_obj; + end + + try + cl = extract_roi_averages(extract_input, single_sub, extract_args{:}); + catch err + warning('hrf_score_one_prefix:AtlasExtract', ... + 'Skipping region ''%s'': %s', region_label, err.message); + continue + end + if isempty(cl) + continue + end + + region_token = matlab.lang.makeValidName(char(region_label)); + col_name = local_make_atlas_column_name(atlas_token, region_token, suffix); + vals = double(cl(1).dat(:)); + if numel(vals) ~= n_vol + padded = NaN(n_vol, 1); + m = min(numel(vals), n_vol); + padded(1:m) = vals(1:m); + vals = padded; + end + if strcmp(normalize, 'l1') + l1 = sum(abs(vals), 'omitnan'); + if l1 > 0 + vals = vals / l1; + end + end + atlas_cols.(col_name) = vals; +end +end + + +function fd = local_to_fmri_data_for_extract(obj) +% Return a plain fmri_data so extract_roi_averages takes the fmri_data code +% path (a statistic_image triggers a per-volume mask path that gives jagged +% atlas output). The conversion MUST yield a valid object whose mask/volInfo +% let extract_roi_averages resample the atlas to the data space. +% +% Use the fmri_data(obj) constructor: it builds a valid object directly from +% the IN-MEMORY data (no disk reload), so it works even for a StatsInput score +% object that has no fullpath -- and it preserves the raw .dat (the .sig +% threshold mask of a statistic_image is NOT applied), matching the historical +% reload-from-disk behavior. Verified against a real 2mm-atlas vs 2.683mm-beta +% mismatch: the previous hand field-copy raised "Arrays have incompatible +% sizes" on the resample (every region skipped, no atlas columns written), +% while fmri_data(obj) succeeds. Disk reload / field-copy are kept only as +% last-resort fallbacks. +if isa(obj, 'fmri_data') && ~isa(obj, 'statistic_image') + fd = obj; + return +end + +% Primary: convert in memory (handles statistic_image / image_vector). +try + fd = fmri_data(obj); + return +catch + % fall through to disk reload / field copy +end + +% Fallback 1: reload from disk if the object is file-backed. +if isprop(obj, 'fullpath') && ~isempty(obj.fullpath) + src = deblank(obj.fullpath(1, :)); + if exist(src, 'file') == 2 + try + fd = fmri_data(src, 'noverbose'); + return + catch + end + end +end + +% Fallback 2 (last resort): copy shared fields. Known to break the atlas +% resample, but better than nothing if both conversions above failed. +fd = fmri_data(); +shared = {'dat', 'volInfo', 'removed_voxels', 'removed_images', ... + 'space_defining_image_name', 'fullpath', 'files_exist', 'history', ... + 'image_names', 'source_notes', 'mask', 'mask_descrip', ... + 'images_per_session'}; +for i = 1:numel(shared) + f = shared{i}; + if isprop(fd, f) && isprop(obj, f) + try + fd.(f) = obj.(f); + catch + end + end +end +end + + +function s = normalize_suffix(mode) +switch lower(char(mode)) + case 'mean', s = 'mean'; + case 'l1', s = 'meanL1'; + case 'none', s = 'sum'; + otherwise, s = lower(char(mode)); +end +end + + +function regions = local_effective_regions(atlas_obj, requested_regions) +% Returns the cellstr of region labels to score: +% - if requested_regions is non-empty -> that list (cellstr) +% - else atlas_obj.labels (full atlas) +% - else generic region_NNN fallback +if ~isempty(requested_regions) + regions = cellstr(string(requested_regions)); + regions = regions(:)'; + return +end +if isprop(atlas_obj, 'labels') && ~isempty(atlas_obj.labels) + regions = cellstr(string(atlas_obj.labels)); + regions = regions(:)'; + return +end +% Last-ditch fallback: ask the atlas for a region count if possible. +n_guess = 0; +if isprop(atlas_obj, 'dat') && ~isempty(atlas_obj.dat) + n_guess = max(max(double(atlas_obj.dat(:))), 0); +end +if n_guess <= 0 + n_guess = 1; +end +regions = arrayfun(@(i) sprintf('region_%03d', i), 1:n_guess, 'UniformOutput', false); +end + + +function labels = local_atlas_labels(atlas_obj, n_regions) +labels = {}; +if isprop(atlas_obj, 'labels') && ~isempty(atlas_obj.labels) + labels = cellstr(string(atlas_obj.labels)); +end +if numel(labels) < n_regions + extra = arrayfun(@(i) sprintf('region_%03d', i), numel(labels) + 1 : n_regions, ... + 'UniformOutput', false); + labels = [labels(:); extra(:)]; +end +labels = labels(1:n_regions); +end + + +function name = local_resolve_atlas_name(atlas_obj, user_name) +% Priority: explicit user-supplied name -> atlas_obj.atlas_name property +% -> 'atlas' fallback. +name = char(user_name); +if ~isempty(name), return; end +if isprop(atlas_obj, 'atlas_name') && ~isempty(atlas_obj.atlas_name) + name = char(atlas_obj.atlas_name); + return +end +name = 'atlas'; +end + + +function missing_regions = local_missing_atlas_regions(existing, opts) +% Aggregate missing-region labels across ALL atlas specs (used by the +% validity/no-op checks, which only need to know whether anything is +% missing). Per-atlas append uses local_missing_atlas_regions_spec. +missing_regions = {}; +specs = local_atlas_specs(opts); +for k = 1:numel(specs) + mr = local_missing_atlas_regions_spec(existing, specs(k), opts.Normalize); + missing_regions = [missing_regions, mr]; %#ok +end +end + +function mr = local_missing_atlas_regions_spec(existing, spec, normalize) +% Region labels for one atlas whose atlas___ column is +% absent from the existing CSV (suffix depends on Normalize). Different +% normalization modes produce different suffixes, so a CSV with _mean columns +% and a request for _meanL1 correctly routes the L1 versions through append. +mr = {}; +atlas_token = matlab.lang.makeValidName(char(spec.name)); +effective_regions = local_effective_regions(spec.obj, spec.regions); +suffix = normalize_suffix(normalize); +existing_vars = existing.Properties.VariableNames; +for r = 1:numel(effective_regions) + region_token = matlab.lang.makeValidName(char(effective_regions{r})); + col_name = local_make_atlas_column_name(atlas_token, region_token, suffix); + if ~any(strcmp(col_name, existing_vars)) + mr{end + 1} = effective_regions{r}; %#ok + end +end +end + +function specs = local_atlas_specs(opts) +% Unify the singular (AtlasObj/AtlasName/Regions) and plural (AtlasObjs/ +% AtlasNames/AtlasRegions) atlas inputs into a struct array of specs, each +% with fields .obj, .name, .regions. The singular spec (if any) comes first. +specs = struct('obj', {}, 'name', {}, 'regions', {}); + +if ~isempty(opts.AtlasObj) + specs = local_append_atlas_spec(specs, opts.AtlasObj, ... + local_resolve_atlas_name(opts.AtlasObj, opts.AtlasName), local_to_cell(opts.Regions)); +end + +objs = opts.AtlasObjs; +if ~isempty(objs) + if ~iscell(objs), objs = {objs}; end + names = opts.AtlasNames; + if ischar(names) || isstring(names), names = cellstr(string(names)); end + if ~iscell(names), names = {}; end + regs = opts.AtlasRegions; + if ~iscell(regs), regs = {}; end + for k = 1:numel(objs) + if isempty(objs{k}), continue; end + if k <= numel(names) && ~isempty(names{k}) + nm = char(string(names{k})); + else + nm = local_resolve_atlas_name(objs{k}, ''); + end + if k <= numel(regs), rg = local_to_cell(regs{k}); else, rg = {}; end + specs = local_append_atlas_spec(specs, objs{k}, nm, rg); + end +end + +% Robustness: two atlases with the same resolved name would write colliding +% atlas__ columns (common when both inherit the same internal +% atlas_name). Auto-disambiguate later duplicates with a numeric suffix. +specs = local_disambiguate_spec_names(specs); +end + +function specs = local_disambiguate_spec_names(specs) +seen = {}; +for k = 1:numel(specs) + base = char(specs(k).name); + token = matlab.lang.makeValidName(base); + if any(strcmp(token, seen)) + n = 2; + % Counter PREFIX (not suffix): the 63-char column-name cap trims the + % END of the atlas token, so a trailing suffix could be cut and collide + % again; a prefix always survives. + while any(strcmp(matlab.lang.makeValidName(sprintf('dup%d_%s', n, base)), seen)) + n = n + 1; + end + new_name = sprintf('dup%d_%s', n, base); + warning('hrf_score_one_prefix:DuplicateAtlasName', ... + ['Two atlases resolved to the same name ''%s''; renaming the later one to ''%s'' so ' ... + 'their region columns do not collide. Pass distinct AtlasNames to control this.'], ... + base, new_name); + specs(k).name = new_name; + token = matlab.lang.makeValidName(new_name); + end + seen{end + 1} = token; %#ok +end +end + +function specs = local_append_atlas_spec(specs, obj, name, regions) +n = numel(specs) + 1; +specs(n).obj = obj; +specs(n).name = name; +specs(n).regions = regions; +end + + +function col_name = local_make_atlas_column_name(atlas_token, region_token, suffix) +% Build 'atlas___' but keep it under +% the historical MATLAB name length limit (63 chars) so the column can +% be assigned to a table variable. We hard-cap at 63 even on newer +% MATLAB releases where namelengthmax is 2048 -- this keeps column names +% identical across local dev and cluster (where namelengthmax may +% differ), so append/missing-region detection works across systems. +% The atlas_token is shortened if needed; the region_token is preserved +% at full length because it's the more informative half. If the region +% alone exceeds the budget, it's truncated as a last resort. +overhead = numel('atlas_') + 1 + 1 + numel(suffix); % 'atlas_' + '_' + '_' + suffix +max_len = min(63, namelengthmax); +budget = max_len - overhead; + +if numel(atlas_token) + numel(region_token) > budget + atlas_budget = max(1, budget - numel(region_token)); + if numel(atlas_token) > atlas_budget + atlas_token = atlas_token(1:atlas_budget); + end +end + +if numel(atlas_token) + numel(region_token) > budget + region_budget = max(1, budget - numel(atlas_token)); + if numel(region_token) > region_budget + region_token = region_token(1:region_budget); + end +end + +col_name = sprintf('atlas_%s_%s_%s', atlas_token, region_token, suffix); +end + + +function merged = local_horzappend_scores(scores, atlas_cols) +% Append atlas_cols as new columns to scores; row counts must match. +if isempty(atlas_cols) || width(atlas_cols) == 0 + merged = scores; + return +end +if height(scores) ~= height(atlas_cols) + error('hrf_score_one_prefix:AtlasRowMismatch', ... + 'Atlas scores have %d rows but main scores have %d rows.', ... + height(atlas_cols), height(scores)); +end +existing_vars = scores.Properties.VariableNames; +merged = scores; +new_vars = atlas_cols.Properties.VariableNames; +for i = 1:numel(new_vars) + col = new_vars{i}; + if any(strcmp(col, existing_vars)), continue; end + merged.(col) = atlas_cols.(col); +end +end + +% ========================================================================= +% Score-objects resolution (validate and normalize ScoreObjects argument) +% ========================================================================= +function objects = local_resolve_score_objects(score_objects) +objects = lower(cellstr(string(score_objects))); +objects = cellfun(@strtrim, objects, 'UniformOutput', false); +if any(strcmp(objects, 'both')) || any(strcmp(objects, 'all')) + objects = {'beta', 't'}; +end +objects = unique(objects(~cellfun(@isempty, objects)), 'stable'); +valid = {'beta', 'b', 't', 'tmap', 'tmaps'}; +bad = setdiff(objects, valid); +if ~isempty(bad) + error('hrf_score_one_prefix:UnknownScoreObjects', ... + 'Unknown ScoreObjects: %s. Use beta, t, or both.', strjoin(bad, ', ')); +end +objects(strcmp(objects, 'b')) = {'beta'}; +objects(ismember(objects, {'tmap', 'tmaps'})) = {'t'}; +objects = unique(objects, 'stable'); +end + +% ========================================================================= +% Filename composition +% The helper's prefix arg is the task-level output_prefix. For multi-model +% studies, the per-model NIfTI files live at __*.nii, and +% the per-model score CSV at ___map_scores.csv. +% For single-model studies the model suffix is omitted from both. +% ========================================================================= +function img_prefix = local_image_prefix(prefix, model_name, n_models) +if n_models <= 1 || isempty(model_name) + img_prefix = prefix; +else + img_prefix = sprintf('%s_%s', prefix, lower(char(model_name))); +end +end + +function score_file = local_score_file_path(prefix, model_name, n_models, object_name) +if n_models <= 1 || isempty(model_name) + score_file = sprintf('%s_%s_map_scores.csv', prefix, object_name); +else + score_file = sprintf('%s_%s_%s_map_scores.csv', prefix, lower(model_name), object_name); +end +end + +% ========================================================================= +% Core-inputs presence check (cheap) +% ========================================================================= +function tf = local_core_inputs_present(prefix, model_name, n_models, stats_input) +if ~isempty(stats_input) && isstruct(stats_input) && isfield(stats_input, 'b') && ... + isobject(stats_input.b) && ~isempty(stats_input.b.dat) + tf = true; + return +end +img_prefix = local_image_prefix(prefix, model_name, n_models); +tf = exist([img_prefix '_beta.nii'], 'file') == 2; +end + +% ========================================================================= +% Metadata resolution +% Order: MetadataTable input -> StatsInput.metadata_table -> +% MetadataFile input -> _metadata.csv -> +% result.mat fallback -> sibling-model CSV fallback. +% ========================================================================= +function [metadata_table, metadata_file] = local_resolve_metadata( ... + prefix, model_name, metadata_in, metadata_file_in, result_mat_in, stats_input) + +metadata_table = table(); +metadata_file = ''; + +if ~isempty(metadata_in) && istable(metadata_in) && height(metadata_in) > 0 + metadata_table = metadata_in; + if ~isempty(metadata_file_in) + metadata_file = char(metadata_file_in); + end + return +end + +if ~isempty(stats_input) && isstruct(stats_input) && ... + isfield(stats_input, 'metadata_table') && istable(stats_input.metadata_table) && ... + height(stats_input.metadata_table) > 0 + metadata_table = stats_input.metadata_table; + return +end + +if ~isempty(metadata_file_in) + metadata_file = char(metadata_file_in); + if exist(metadata_file, 'file') == 2 + try + metadata_table = readtable(metadata_file, 'TextType', 'string'); + return + catch + end + end +end + +% Default: _metadata.csv +default_metadata_file = [char(prefix) '_metadata.csv']; +if exist(default_metadata_file, 'file') == 2 + try + metadata_table = readtable(default_metadata_file, 'TextType', 'string'); + metadata_file = default_metadata_file; + return + catch + end +end + +% Result MAT fallback +result_mat = char(result_mat_in); +if ~isempty(result_mat) && exist(result_mat, 'file') == 2 + metadata_table = local_metadata_from_result_mat(result_mat, model_name); + if ~isempty(metadata_table) + if isempty(metadata_file), metadata_file = default_metadata_file; end + try + writetable(metadata_table, metadata_file); + catch + metadata_file = ''; + end + return + end +end + +% Sibling-model CSV fallback +sibling_metadata = local_metadata_from_sibling(prefix, model_name); +if ~isempty(sibling_metadata) + metadata_table = sibling_metadata; + if isempty(metadata_file), metadata_file = default_metadata_file; end + try + writetable(metadata_table, metadata_file); + catch + metadata_file = ''; + end +end +end + +function metadata_table = local_metadata_from_result_mat(mat_file, model_name) +metadata_table = table(); +try + S = load(mat_file, 'results'); +catch + return +end +if ~isfield(S, 'results'), return; end +R = S.results; +if isempty(model_name), model_name = 'fir'; end +model_field = matlab.lang.makeValidName(lower(char(model_name))); + +if isfield(R, 'wholebrain_metadata_by_model') && isfield(R.wholebrain_metadata_by_model, model_field) + metadata_table = R.wholebrain_metadata_by_model.(model_field); +elseif isfield(R, 'wholebrain_by_model') && isfield(R.wholebrain_by_model, model_field) && ... + isfield(R.wholebrain_by_model.(model_field), 'metadata_table') + metadata_table = R.wholebrain_by_model.(model_field).metadata_table; +elseif isfield(R, 'wholebrain_metadata_table') + metadata_table = R.wholebrain_metadata_table; +elseif isfield(R, 'wholebrain') && isfield(R.wholebrain, 'metadata_table') + metadata_table = R.wholebrain.metadata_table; +end +end + +function metadata_table = local_metadata_from_sibling(prefix, model_name) +metadata_table = table(); +base_prefix = local_base_prefix(prefix, model_name); +candidate_files = { ... + [base_prefix '_metadata.csv'], ... + [base_prefix '_sfir_metadata.csv'], ... + [base_prefix '_canonical_metadata.csv'], ... + [base_prefix '_spline_metadata.csv']}; +candidate_files = unique(candidate_files, 'stable'); +this_file = [prefix '_metadata.csv']; + +for i = 1:numel(candidate_files) + fname = candidate_files{i}; + if strcmp(fname, this_file) || exist(fname, 'file') ~= 2 + continue + end + try + T = readtable(fname, 'TextType', 'string'); + catch + continue + end + if any(strcmp('condition', T.Properties.VariableNames)) && ... + any(strcmp('lag_index', T.Properties.VariableNames)) + metadata_table = local_relabel_metadata(T, model_name); + return + end +end +end + +function base_prefix = local_base_prefix(prefix, model_name) +base_prefix = char(prefix); +if isempty(model_name), return; end +model_suffix = ['_' lower(char(model_name))]; +if endsWith(lower(base_prefix), model_suffix) + base_prefix = base_prefix(1:end - numel(model_suffix)); +end +end + +function T = local_relabel_metadata(T, model_name) +model_name = lower(char(model_name)); +T.volume_index = (1:height(T))'; +if any(strcmp('mode', T.Properties.VariableNames)) + T.mode = repmat(string(upper(model_name)), height(T), 1); +end +if any(strcmp('image_label', T.Properties.VariableNames)) && ... + any(strcmp('condition', T.Properties.VariableNames)) && ... + any(strcmp('lag_index', T.Properties.VariableNames)) + T.image_label = local_image_labels(T, model_name); +end +end + +function labels = local_image_labels(T, model_name) +conditions = cellstr(string(T.condition)); +lag_index = local_to_numeric(T.lag_index); +if any(strcmp('lag_seconds', T.Properties.VariableNames)) + lag_seconds = local_to_numeric(T.lag_seconds); +else + lag_seconds = lag_index; +end +labels = cell(height(T), 1); +for i = 1:height(T) + if ismember(model_name, {'canonical', 'spline'}) + labels{i} = sprintf('%s_%s_lag%03d_%0.3gs', ... + matlab.lang.makeValidName(model_name), ... + matlab.lang.makeValidName(conditions{i}), ... + lag_index(i), lag_seconds(i)); + else + labels{i} = sprintf('%s_lag%03d_%0.3gs', ... + matlab.lang.makeValidName(conditions{i}), lag_index(i), lag_seconds(i)); + end +end +end + +% ========================================================================= +% Existing-score validity +% We regenerate when the existing CSV is missing requested signature/map +% columns, when its row count or condition/lag layout disagrees with the +% current metadata, or when uncertainty is requested but absent. +% ========================================================================= +function tf = local_existing_score_is_valid(score_file, metadata_table, require_uncertainty, opts) +tf = true; +try + S = readtable(score_file, 'TextType', 'string'); +catch + tf = false; + return +end +if logical(require_uncertainty) && ~local_score_table_has_uncertainty(S) + tf = false; + return +end +if ~local_has_requested_score_sets(S, opts) + tf = false; + return +end +if isempty(metadata_table) + return +end +if height(S) ~= height(metadata_table) + tf = false; + return +end +has_score_condition = any(strcmp('condition', S.Properties.VariableNames)); +has_metadata_condition = any(strcmp('condition', metadata_table.Properties.VariableNames)); +if has_metadata_condition && ~has_score_condition + tf = false; + return +elseif has_score_condition && has_metadata_condition && ... + ~isequal(string(S.condition), string(metadata_table.condition)) + tf = false; + return +end +has_score_lag = any(strcmp('lag_index', S.Properties.VariableNames)); +has_metadata_lag = any(strcmp('lag_index', metadata_table.Properties.VariableNames)); +if has_metadata_lag && ~has_score_lag + tf = false; + return +elseif has_score_lag && has_metadata_lag && ... + ~isequaln(local_to_numeric(S.lag_index), local_to_numeric(metadata_table.lag_index)) + tf = false; +end +end + +function tf = local_has_requested_score_sets(S, opts) +tf = true; +sigsets = local_to_cell(opts.SignatureSets); +for i = 1:numel(sigsets) + if ~local_has_numeric_prefix(S, local_var_prefix({'sig', sigsets{i}})) + tf = false; + return + end +end + +image_sets = local_to_cell(opts.ImageSets); +for i = 1:numel(image_sets) + if isa(image_sets{i}, 'image_vector') + set_name = 'imageset'; + else + set_name = char(image_sets{i}); + end + if ~local_has_numeric_prefix(S, local_var_prefix({'map', set_name})) + tf = false; + return + end +end + +% Atlas region means: per-region check so adding a new region (or a new +% atlas) to an existing CSV doesn't require regenerating everything. +if ~isempty(local_missing_atlas_regions(S, opts)) + tf = false; + return +end +end + +function tf = local_has_numeric_prefix(S, prefix) +tf = false; +names = S.Properties.VariableNames; +for i = 1:numel(names) + name = names{i}; + if startsWith(name, prefix) && isnumeric(S.(name)) && ~local_is_uncertainty_column(name) + tf = true; + return + end +end +end + +function prefix = local_var_prefix(parts) +prefix = matlab.lang.makeValidName(strjoin(cellfun(@char, parts, 'UniformOutput', false), '_')); +prefix = [prefix '_']; +end + +function tf = local_score_table_has_uncertainty(S) +score_cols = local_score_columns_for_validation(S); +tf = true; +for i = 1:numel(score_cols) + if ~any(strcmp([score_cols{i} '_se'], S.Properties.VariableNames)) + tf = false; + return + end +end +end + +function cols = local_score_columns_for_validation(S) +metadata_cols = {'volume_index', 'condition', 'condition_index', 'lag_index', ... + 'lag_seconds', 'image_label', 'N', 'dfe', 'TR', 'mode'}; +cols = {}; +for i = 1:numel(S.Properties.VariableNames) + name = S.Properties.VariableNames{i}; + if ismember(name, metadata_cols) || local_is_uncertainty_column(name) + continue + end + if isnumeric(S.(name)) + cols{end + 1} = name; %#ok + end +end +end + +function tf = local_is_uncertainty_column(name) +tf = endsWith(char(name), '_se'); +end + +function tf = local_requires_uncertainty(prefix, object_name, opts) +img_prefix = local_image_prefix(prefix, char(opts.ModelName), double(opts.NumModels)); +tf = strcmpi(char(object_name), 'beta') && logical(opts.PropagateSE) && ... + local_is_linear_metric(opts.SimilarityMetric) && ... + exist([img_prefix '_beta.nii'], 'file') == 2 && exist([img_prefix '_se.nii'], 'file') == 2; +end + +function tf = local_is_linear_metric(metric) +metric = lower(strrep(strtrim(char(metric)), '_', '')); +tf = ismember(metric, {'dotproduct', 'dot'}); +end + +% ========================================================================= +% Score / SE object loading +% Fast path: take from StatsInput struct in memory (no disk read). +% Slow path: load statistic_image(fmri_data(file)) from NIfTI. +% ========================================================================= +function [score_obj, se_obj] = local_resolve_score_objects_for_write( ... + prefix, object_name, metadata_table, opts) + +if ~isempty(opts.StatsInput) && isstruct(opts.StatsInput) + [score_obj, se_obj] = local_score_objects_from_stats(opts.StatsInput, object_name, opts); + if ~isempty(score_obj) + return + end +end + +% Disk path +model_name = char(opts.ModelName); +n_models = double(opts.NumModels); +score_obj = local_load_score_object_from_disk(prefix, object_name, metadata_table, ... + logical(opts.NoVerbose), model_name, n_models); +se_obj = local_uncertainty_object_from_disk(prefix, object_name, metadata_table, ... + logical(opts.NoVerbose), logical(opts.PropagateSE), model_name, n_models); +end + +function [score_obj, se_obj] = local_score_objects_from_stats(stats_input, object_name, opts) +score_obj = []; +se_obj = []; +switch lower(char(object_name)) + case 'beta' + if isfield(stats_input, 'b'), score_obj = stats_input.b; end + if logical(opts.PropagateSE) && ~isempty(score_obj) && ... + isprop(score_obj, 'ste') && ~isempty(score_obj.ste) + se_obj = score_obj; + se_obj.dat = score_obj.ste; + se_obj.type = 'HRF beta standard error'; + end + case 't' + if isfield(stats_input, 't'), score_obj = stats_input.t; end +end +end + +function obj = local_load_score_object_from_disk(prefix, object_name, metadata_table, noverbose, model_name, n_models) +image_file = local_score_image_file(prefix, object_name, model_name, n_models); +if exist(image_file, 'file') ~= 2 + error('hrf_score_one_prefix:MissingImage', ... + 'Missing %s image: %s', object_name, image_file); +end + +load_args = {}; +if noverbose, load_args = {'noverbose'}; end +obj = statistic_image(fmri_data(image_file, load_args{:})); +switch lower(char(object_name)) + case 'beta' + obj.type = 'FIR HRF beta'; + case 't' + obj.type = 'T'; +end + +obj = local_apply_metadata_to_obj(obj, metadata_table); +end + +function obj = local_uncertainty_object_from_disk(prefix, object_name, metadata_table, noverbose, propagate_se, model_name, n_models) +obj = []; +if ~propagate_se || ~strcmpi(char(object_name), 'beta') + return +end +image_file = [local_image_prefix(prefix, model_name, n_models) '_se.nii']; +if exist(image_file, 'file') ~= 2 + return +end + +load_args = {}; +if noverbose, load_args = {'noverbose'}; end +obj = statistic_image(fmri_data(image_file, load_args{:})); +obj.type = 'HRF beta standard error'; +obj = local_apply_metadata_to_obj(obj, metadata_table); +end + +function obj = local_apply_metadata_to_obj(obj, metadata_table) +if ~isempty(metadata_table) && height(metadata_table) == size(obj.dat, 2) + if any(strcmp('image_label', metadata_table.Properties.VariableNames)) + obj.image_labels = cellstr(string(metadata_table.image_label)); + end + if any(strcmp('N', metadata_table.Properties.VariableNames)) + obj.N = metadata_table.N(1); + end + if any(strcmp('dfe', metadata_table.Properties.VariableNames)) + obj.dfe = metadata_table.dfe(1); + end +end +if isprop(obj, 'sig') && isempty(obj.sig) + obj.sig = true(size(obj.dat)); +end +end + +function image_file = local_score_image_file(prefix, object_name, model_name, n_models) +img_prefix = local_image_prefix(prefix, model_name, n_models); +switch lower(char(object_name)) + case 'beta' + image_file = [img_prefix '_beta.nii']; + case 't' + image_file = [img_prefix '_t.nii']; + otherwise + error('hrf_score_one_prefix:UnknownObject', ... + 'Unknown object: %s. Use beta or t.', char(object_name)); +end +end + +function local_validate_score_object_metadata(obj, metadata_table, object_name, prefix) +if isempty(metadata_table) + return +end +n_images = size(obj.dat, 2); +if height(metadata_table) ~= n_images + error('hrf_score_one_prefix:MetadataVolumeMismatch', ... + 'Cannot score %s: metadata has %d rows but %s image has %d volumes. Regenerate the whole-brain maps and metadata together.', ... + prefix, height(metadata_table), object_name, n_images); +end +end + +% ========================================================================= +% Utilities +% ========================================================================= +function c = local_to_cell(x) +if isempty(x) + c = {}; +elseif isa(x, 'image_vector') + c = {x}; +elseif ischar(x) || isstring(x) + c = cellstr(string(x)); +else + c = x; +end +end + +function x = local_to_numeric(x) +if isnumeric(x) + x = double(x); +else + x = str2double(string(x)); +end +end + +function context = local_warning_context(user_context, model_name, object_name, prefix) +parts = {}; +user_context = char(user_context); +if ~isempty(user_context) + parts{end + 1} = user_context; +end +if ~isempty(model_name) + parts{end + 1} = sprintf('model=%s', model_name); +end +parts{end + 1} = sprintf('object=%s', char(object_name)); +parts{end + 1} = sprintf('prefix=%s', char(prefix)); +context = strjoin(parts, '; '); +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_score_wholebrain_input_table.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_score_wholebrain_input_table.m new file mode 100644 index 00000000..fae03a3e --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_score_wholebrain_input_table.m @@ -0,0 +1,343 @@ +function [input_table, status] = hrf_score_wholebrain_input_table(second_level_inputs, varargin) +%HRF_SCORE_WHOLEBRAIN_INPUT_TABLE Backfill map-score CSVs for collected HRF maps. +% +% [input_table, status] = hrf_score_wholebrain_input_table(input_table, ...) +% +% Iterates the table returned by hrf_collect_wholebrain_outputs and applies +% signatures/image sets to rows whose *__map_scores.csv files are +% missing or invalid. Updates beta_scores_file / t_scores_file in the +% returned table. +% +% This is now a thin orchestrator over HRF_SCORE_ONE_PREFIX, which is also +% called from the per-task SLURM worker and from HRF_AUDIT_SLURM_OUTPUTS +% (in RepairMissing mode). The per-prefix metadata resolution, validity +% checks, and statistic_image loading all live in HRF_SCORE_ONE_PREFIX so +% that the three call sites stay in sync. +% +% See also: hrf_score_one_prefix, hrf_audit_slurm_outputs, +% hrf_apply_maps_to_wholebrain. + +p = inputParser; +p.addRequired('second_level_inputs', @(x) istable(x) || ischar(x) || isstring(x)); +p.addParameter('SourceModel', {}, @(x) ischar(x) || iscell(x) || isstring(x)); +p.addParameter('ScoreObjects', {'beta', 't'}, @(x) ischar(x) || iscell(x) || isstring(x)); +p.addParameter('SignatureSets', {}, @(x) ischar(x) || iscell(x) || isstring(x)); +p.addParameter('ImageSets', {}, @(x) ischar(x) || iscell(x) || isstring(x) || isa(x, 'image_vector')); +p.addParameter('AtlasObj', [], @(x) isempty(x) || isa(x, 'atlas') || isa(x, 'image_vector')); +p.addParameter('AtlasName', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Regions', {}, @(x) iscell(x) || isstring(x) || ischar(x)); +p.addParameter('Normalize', 'mean', @(x) ischar(x) || isstring(x)); +p.addParameter('SimilarityMetric', 'dotproduct', @(x) ischar(x) || isstring(x)); +p.addParameter('PropagateSE', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('Overwrite', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('OverwriteStale', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('Append', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('RequireMetadata', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('OutputCsv', '', @(x) ischar(x) || isstring(x)); +p.addParameter('MissingPolicy', 'warn', @(x) ischar(x) || isstring(x)); +p.addParameter('NoVerbose', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('UseParallel', true, @(x) islogical(x) || isnumeric(x)); +p.parse(second_level_inputs, varargin{:}); +opts = p.Results; + +input_table = local_read_inputs(second_level_inputs); +source_models = local_source_model_filter(opts.SourceModel); +missing_policy = lower(char(opts.MissingPolicy)); + +status = table('Size', [0 6], ... + 'VariableTypes', {'double', 'string', 'string', 'string', 'logical', 'string'}, ... + 'VariableNames', {'row', 'subject', 'model', 'object', 'wrote_file', 'message'}); + +% Phase 1: pre-extract per-row inputs into a struct array. This lets the +% scoring loop run under parfor without dynamically indexing input_table +% (parfor disallows mutating shared state and requires sliced reads). +n_rows = height(input_table); +row_inputs = repmat(struct( ... + 'row', 0, ... + 'model_name', '', ... + 'prefix', '', ... + 'subject', '', ... + 'result_mat_file', '', ... + 'metadata_file', '', ... + 'skip', false, ... + 'skip_message', ''), n_rows, 1); +for i = 1:n_rows + row_inputs(i).row = i; + row_inputs(i).model_name = local_table_value(input_table, i, 'model'); + row_inputs(i).subject = local_table_value(input_table, i, 'subject'); + row_inputs(i).prefix = local_table_value(input_table, i, 'prefix'); + row_inputs(i).result_mat_file = local_table_value(input_table, i, 'result_mat_file'); + row_inputs(i).metadata_file = local_table_value(input_table, i, 'metadata_file'); + if ~local_source_model_matches(row_inputs(i).model_name, source_models) + row_inputs(i).skip = true; + elseif isempty(row_inputs(i).prefix) + row_inputs(i).skip = true; + row_inputs(i).skip_message = 'missing prefix'; + end +end + +% Phase 2: parallelizable score call. Each iteration is row-independent. +% UseParallel defaults true; if no parallel pool is open or PCT isn't +% installed, MATLAB silently falls back to serial execution. To opt out +% explicitly pass 'UseParallel', false. +helper_results = cell(n_rows, 1); +use_parallel = logical(opts.UseParallel); +score_opts = struct( ... + 'ScoreObjects', {opts.ScoreObjects}, ... + 'SignatureSets', {opts.SignatureSets}, ... + 'ImageSets', {opts.ImageSets}, ... + 'AtlasObj', opts.AtlasObj, ... + 'AtlasName', opts.AtlasName, ... + 'Regions', {opts.Regions}, ... + 'Normalize', opts.Normalize, ... + 'SimilarityMetric', opts.SimilarityMetric, ... + 'PropagateSE', opts.PropagateSE, ... + 'Overwrite', opts.Overwrite, ... + 'OverwriteStale', opts.OverwriteStale, ... + 'Append', opts.Append, ... + 'RequireMetadata', opts.RequireMetadata, ... + 'NoVerbose', opts.NoVerbose); + +if use_parallel + parfor i = 1:n_rows + helper_results{i} = local_score_one_row(row_inputs(i), score_opts); + end +else + for i = 1:n_rows + helper_results{i} = local_score_one_row(row_inputs(i), score_opts); + end +end + +% Phase 3: sequential merge -- update input_table + status from each +% iteration's result. This is the part that can't safely run inside the +% parfor (it mutates shared state). +for i = 1:n_rows + r = row_inputs(i); + if r.skip + if ~isempty(r.skip_message) + status = local_add_status(status, i, r.subject, r.model_name, '', false, r.skip_message); + local_handle_missing(missing_policy, i, r.subject, r.skip_message); + end + continue + end + helper_status = helper_results{i}; + if isempty(helper_status), continue; end + + if ~isempty(helper_status.metadata_file) + input_table = local_set_file(input_table, i, 'metadata_file', helper_status.metadata_file); + end + + objects_requested = local_resolve_score_objects_arg(opts.ScoreObjects); + + % Existing CSVs that the helper verified as valid + for k = 1:numel(helper_status.skipped_existing) + file_path = helper_status.skipped_existing{k}; + object_name = local_object_from_score_file(file_path, r.prefix, r.model_name); + input_table = local_set_score_file(input_table, i, object_name, file_path); + status = local_add_status(status, i, r.subject, r.model_name, object_name, false, 'exists'); + end + + % Newly written CSVs + for k = 1:numel(helper_status.wrote_files) + file_path = helper_status.wrote_files{k}; + object_name = local_object_from_score_file(file_path, r.prefix, r.model_name); + input_table = local_set_score_file(input_table, i, object_name, file_path); + status = local_add_status(status, i, r.subject, r.model_name, object_name, true, file_path); + end + + % CSVs left stale (Overwrite=false, OverwriteStale=false) + for k = 1:numel(helper_status.skipped_stale) + file_path = helper_status.skipped_stale{k}; + object_name = local_object_from_score_file(file_path, r.prefix, r.model_name); + msg = sprintf('existing score file is stale: %s', file_path); + status = local_add_status(status, i, r.subject, r.model_name, object_name, false, msg); + local_handle_missing(missing_policy, i, r.subject, msg); + end + + % Errors + for k = 1:numel(helper_status.errors) + err = helper_status.errors(k); + status = local_add_status(status, i, r.subject, r.model_name, err.object, false, err.message); + local_handle_missing(missing_policy, i, r.subject, err.message); + end + + % Catch-all: report any object that was requested but produced no + % helper output (e.g. because RequireMetadata=false but metadata was + % missing, so the helper bailed before iterating objects). + reported_objects = string(status.object(status.row == i)); + for k = 1:numel(objects_requested) + if ~any(reported_objects == string(objects_requested{k})) + status = local_add_status(status, i, r.subject, r.model_name, ... + objects_requested{k}, false, 'no action taken (no metadata or core inputs)'); + end + end +end + +if ~isempty(opts.OutputCsv) + writetable(input_table, char(opts.OutputCsv)); +end +end + +% ========================================================================= +% Local helpers +% ========================================================================= +function helper_status = local_score_one_row(r, score_opts) +% Worker for the parfor loop: produces a helper_status struct (or [] when +% the row is skipped) without touching any shared state. +helper_status = []; +if r.skip + return +end +helper_status = hrf_score_one_prefix(r.prefix, ... + 'ModelName', r.model_name, ... + 'NumModels', 1, ... + 'ScoreObjects', score_opts.ScoreObjects, ... + 'SignatureSets', score_opts.SignatureSets, ... + 'ImageSets', score_opts.ImageSets, ... + 'AtlasObj', score_opts.AtlasObj, ... + 'AtlasName', score_opts.AtlasName, ... + 'Regions', score_opts.Regions, ... + 'Normalize', score_opts.Normalize, ... + 'SimilarityMetric', score_opts.SimilarityMetric, ... + 'PropagateSE', score_opts.PropagateSE, ... + 'MetadataFile', r.metadata_file, ... + 'ResultMatFile', r.result_mat_file, ... + 'Overwrite', score_opts.Overwrite, ... + 'OverwriteStale', score_opts.OverwriteStale, ... + 'Append', score_opts.Append, ... + 'RequireMetadata', score_opts.RequireMetadata, ... + 'NoVerbose', score_opts.NoVerbose, ... + 'WarningContext', sprintf('row=%d; subject=%s', r.row, r.subject)); +end + + +function input_table = local_read_inputs(second_level_inputs) +if istable(second_level_inputs) + input_table = second_level_inputs; +else + input_table = readtable(char(second_level_inputs), 'TextType', 'string'); +end +end + +function models = local_source_model_filter(source_model) +if isempty(source_model) + models = {}; +else + models = lower(cellstr(string(source_model))); + models = cellfun(@strtrim, models, 'UniformOutput', false); + models = models(~cellfun(@isempty, models)); +end +end + +function tf = local_source_model_matches(model_name, requested) +if isempty(requested) + tf = true; +else + tf = ismember(lower(strtrim(char(model_name))), requested); +end +end + +function objects = local_resolve_score_objects_arg(score_objects) +objects = lower(cellstr(string(score_objects))); +objects = cellfun(@strtrim, objects, 'UniformOutput', false); +if any(strcmp(objects, 'both')) || any(strcmp(objects, 'all')) + objects = {'beta', 't'}; +end +objects(strcmp(objects, 'b')) = {'beta'}; +objects(ismember(objects, {'tmap', 'tmaps'})) = {'t'}; +objects = unique(objects(~cellfun(@isempty, objects)), 'stable'); +end + +function object_name = local_object_from_score_file(file_path, prefix, model_name) +[~, base] = fileparts(char(file_path)); +prefix_base = char(prefix); +[~, prefix_name] = fileparts(prefix_base); +suffix = base; +if startsWith(suffix, prefix_name) + suffix = suffix(numel(prefix_name) + 1:end); +end +if ~isempty(model_name) + model_tag = ['_' lower(char(model_name))]; + if startsWith(suffix, model_tag) + suffix = suffix(numel(model_tag) + 1:end); + end +end +suffix = regexprep(suffix, '_map_scores$', ''); +suffix = regexprep(suffix, '^_', ''); +object_name = suffix; +if isempty(object_name) || ~ismember(object_name, {'beta', 't'}) + if endsWith(base, '_beta_map_scores') + object_name = 'beta'; + elseif endsWith(base, '_t_map_scores') + object_name = 't'; + else + object_name = ''; + end +end +end + +function input_table = local_set_score_file(input_table, row, object_name, score_file) +varname = local_score_varname(object_name); +if isempty(varname), return; end +input_table = local_set_file(input_table, row, varname, score_file); +end + +function input_table = local_set_file(input_table, row, varname, file_name) +if ~any(strcmp(varname, input_table.Properties.VariableNames)) + input_table.(varname) = strings(height(input_table), 1); +end +if iscell(input_table.(varname)) + input_table.(varname){row} = char(file_name); +else + input_table.(varname)(row) = string(file_name); +end +end + +function varname = local_score_varname(object_name) +switch lower(char(object_name)) + case 'beta' + varname = 'beta_scores_file'; + case 't' + varname = 't_scores_file'; + otherwise + varname = ''; +end +end + +function status = local_add_status(status, row, subject, model_name, object_name, wrote_file, message) +new_row = table(row, string(subject), string(model_name), string(object_name), ... + logical(wrote_file), string(message), ... + 'VariableNames', status.Properties.VariableNames); +status = [status; new_row]; +end + +function value = local_table_value(T, row, varname) +if ~any(strcmp(varname, T.Properties.VariableNames)) + value = ''; + return +end +value = T.(varname)(row); +if iscell(value), value = value{1}; end +value = string(value); +if ismissing(value) || strlength(value) == 0 + value = ''; +else + value = char(value); +end +end + +function local_handle_missing(missing_policy, row, subject, reason) +switch missing_policy + case 'error' + error('hrf_score_wholebrain_input_table:Row', ... + 'Input row %d (%s): %s', row, subject, reason); + case 'warn' + warning('hrf_score_wholebrain_input_table:SkippingInput', ... + 'Skipping input row %d (%s): %s', row, subject, reason); + case 'silent' + return + otherwise + error('hrf_score_wholebrain_input_table:UnknownMissingPolicy', ... + 'Unknown MissingPolicy: %s. Use warn, silent, or error.', missing_policy); +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_second_level_inputs_to_study.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_second_level_inputs_to_study.m new file mode 100644 index 00000000..e9a95da4 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_second_level_inputs_to_study.m @@ -0,0 +1,690 @@ +function study = hrf_second_level_inputs_to_study(second_level_inputs, varargin) +%HRF_SECOND_LEVEL_INPUTS_TO_STUDY Convert map-score CSVs to study structure. +% +% study = hrf_second_level_inputs_to_study(second_level_inputs, ...) +% +% second_level_inputs can be the table returned by +% hrf_collect_wholebrain_outputs or the corresponding CSV filename. This +% helper reads *_beta_map_scores.csv or *_t_map_scores.csv files and creates +% a study.results cell array compatible with hrf_time_unfolding_stats. +% +% The converted "fits" are map-score curves over condition x lag, not +% subject-level HRF model fits. The default model name is therefore +% "mapscore". For beta map-score CSVs, matching t map-score CSVs are used +% by default as a fallback to derive approximate score SE as +% abs(beta_score / t_score). If beta score CSVs contain propagated +% *_se columns from HRF beta SE maps, those are preferred. +% If multiple score columns are available, result.fits is the average score +% curve by default; individual signatures/maps remain in fits_by_signature. + +p = inputParser; +p.addRequired('second_level_inputs', @(x) istable(x) || ischar(x) || isstring(x)); +p.addParameter('Object', 'beta', @(x) ischar(x) || isstring(x)); +p.addParameter('ScoreColumns', {}, @(x) iscell(x) || isstring(x)); +p.addParameter('ModelName', 'mapscore', @(x) ischar(x) || isstring(x)); +p.addParameter('SourceModel', {}, @(x) ischar(x) || iscell(x) || isstring(x)); +p.addParameter('AddAverageScore', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('AverageScoreName', 'mean_mapscore', @(x) ischar(x) || isstring(x)); +p.addParameter('ApproxSEFromT', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('MissingPolicy', 'warn', @(x) ischar(x) || isstring(x)); +p.parse(second_level_inputs, varargin{:}); +opts = p.Results; + +inputs = local_read_inputs(second_level_inputs); +model_name = lower(strtrim(char(opts.ModelName))); +source_model_input = opts.SourceModel; +if isempty(local_source_model_filter(source_model_input)) && local_is_wholebrain_model_name(model_name) + source_model_input = model_name; + model_name = 'mapscore'; +end +source_model_filter = local_source_model_filter(source_model_input); +score_file_var = local_score_file_var(char(opts.Object)); +if ~any(strcmp(score_file_var, inputs.Properties.VariableNames)) + error('second_level_inputs is missing column %s.', score_file_var); +end + +n = height(inputs); +results = cell(n, 1); +subject_ids = cell(n, 1); +run_labels = cell(n, 1); +success = false(n, 1); +errors = cell(n, 1); +skipped = struct('index', {}, 'subject', {}, 'reason', {}); +all_score_names = {}; +missing_policy = lower(char(opts.MissingPolicy)); + +for i = 1:n + subject_ids{i} = local_table_value(inputs, i, 'subject'); + run_labels{i} = local_run_label(inputs, i); + score_file = local_score_file_for_row(inputs, i, score_file_var); + source_model = local_source_model(inputs, i, score_file); + + if ~local_source_model_matches(source_model, source_model_filter) + reason = sprintf('source model %s did not match requested SourceModel', source_model); + skipped = local_skip(skipped, i, subject_ids{i}, reason, missing_policy); + errors{i} = reason; + continue + end + + if isempty(score_file) || exist(score_file, 'file') ~= 2 + reason = sprintf('missing score file %s', score_file); + skipped = local_skip(skipped, i, subject_ids{i}, reason, missing_policy); + errors{i} = reason; + continue + end + + try + S = readtable(score_file, 'TextType', 'string'); + local_validate_score_table(S, inputs, i, score_file); + Tscore = local_read_t_score_table(inputs, i, opts); + if ~isempty(Tscore) + local_validate_score_table(Tscore, inputs, i, local_score_file_for_row(inputs, i, 't_scores_file')); + end + score_cols = local_score_columns(S, opts.ScoreColumns); + if isempty(score_cols) + reason = 'no numeric score columns'; + skipped = local_skip(skipped, i, subject_ids{i}, reason, missing_policy); + errors{i} = reason; + continue + end + + results{i} = local_score_table_to_result(S, Tscore, score_cols, model_name, ... + char(opts.Object), score_file, source_model, logical(opts.AddAverageScore), ... + char(opts.AverageScoreName)); + results{i}.subject_id = subject_ids{i}; + results{i}.run_label = run_labels{i}; + results{i}.input_row = i; + results{i}.input_prefix = local_table_value(inputs, i, 'prefix'); + success(i) = true; + if logical(opts.AddAverageScore) + all_score_names = [all_score_names, {char(opts.AverageScoreName)}, score_cols(:)']; %#ok + else + all_score_names = [all_score_names, score_cols(:)']; %#ok + end + catch err + skipped = local_skip(skipped, i, subject_ids{i}, err.message, missing_policy); + errors{i} = err.message; + end +end + +study = struct(); +study.results = results; +study.subject_ids = subject_ids; +study.run_labels = run_labels; +study.success = success; +study.errors = errors; +study.skipped = skipped; +study.score_names = unique(all_score_names, 'stable'); +study.object = char(opts.Object); +study.model_name = model_name; +study.source_models = local_study_source_models(inputs); +study.source = 'second_level_inputs_map_scores'; +study.second_level_inputs = inputs; +end + +function run_label = local_run_label(inputs, row) +run_label = local_table_value(inputs, row, 'run_label'); +if isempty(run_label) + prefix = local_table_value(inputs, row, 'prefix'); + subject = local_table_value(inputs, row, 'subject'); + model_name = local_table_value(inputs, row, 'model'); + run_label = local_run_label_from_prefix(prefix, subject, model_name); +end +end + +function run_label = local_run_label_from_prefix(prefix, subject, model_name) +run_label = ''; +if isempty(prefix) + return +end +[~, name] = fileparts(prefix); +run_label = regexprep(name, '_hrf.*$', ''); +if ~isempty(subject) + run_label = regexprep(run_label, ['^' regexptranslate('escape', subject) '_?'], ''); +end +if ~isempty(model_name) + run_label = regexprep(run_label, ['_' regexptranslate('escape', model_name) '$'], ''); +end +if isempty(run_label) + run_label = 'run-unknown'; +end +end + +function inputs = local_read_inputs(second_level_inputs) +if istable(second_level_inputs) + inputs = second_level_inputs; +else + inputs = readtable(char(second_level_inputs), 'TextType', 'string'); +end +end + +function models = local_source_model_filter(source_model) +if isempty(source_model) + models = {}; +else + models = lower(cellstr(string(source_model))); + models = cellfun(@strtrim, models, 'UniformOutput', false); + models = models(~cellfun(@isempty, models)); +end +end + +function tf = local_is_wholebrain_model_name(model_name) +tf = ismember(lower(strtrim(char(model_name))), {'fir', 'sfir', 'canonical', 'spline'}); +end + +function tf = local_source_model_matches(source_model, requested) +if isempty(requested) + tf = true; +else + tf = ismember(lower(strtrim(source_model)), requested); +end +end + +function source_model = local_source_model(inputs, row, score_file) +source_model = local_table_value(inputs, row, 'model'); +if isempty(source_model) + source_model = local_model_from_filename(score_file); +end +source_model = lower(strtrim(source_model)); +end + +function model_name = local_model_from_filename(file_name) +model_name = ''; +if isempty(file_name) + return +end +[~, name] = fileparts(file_name); +tok = regexp(name, '_([A-Za-z]+)_(beta|t)_map_scores$', 'tokens', 'once'); +if ~isempty(tok) && ismember(lower(tok{1}), {'fir', 'sfir', 'canonical', 'spline'}) + model_name = lower(tok{1}); +end +end + +function models = local_study_source_models(inputs) +if any(strcmp('model', inputs.Properties.VariableNames)) + vals = cellstr(string(inputs.model)); + vals = vals(~cellfun(@(s) ismissing(string(s)) || strlength(string(s)) == 0, vals)); + models = unique(lower(vals), 'stable'); +else + models = {}; +end +end + +function varname = local_score_file_var(object_name) +switch lower(object_name) + case {'beta', 'b'} + varname = 'beta_scores_file'; + case {'t', 'tmap', 'tmaps'} + varname = 't_scores_file'; + otherwise + error('Unknown Object: %s. Use ''beta'' or ''t''.', object_name); +end +end + +function score_file = local_score_file_for_row(inputs, row, score_file_var) +score_file = local_table_value(inputs, row, score_file_var); +if ~isempty(score_file) + return +end +prefix = local_table_value(inputs, row, 'prefix'); +if isempty(prefix) + return +end +switch score_file_var + case 'beta_scores_file' + score_file = [prefix '_beta_map_scores.csv']; + case 't_scores_file' + score_file = [prefix '_t_map_scores.csv']; +end +end + +function Tscore = local_read_t_score_table(inputs, row, opts) +Tscore = table(); +if ~logical(opts.ApproxSEFromT) || ~strcmpi(char(opts.Object), 'beta') + return +end +t_score_file = local_score_file_for_row(inputs, row, 't_scores_file'); +if isempty(t_score_file) || exist(t_score_file, 'file') ~= 2 + return +end +Tscore = readtable(t_score_file, 'TextType', 'string'); +end + +function local_validate_score_table(S, inputs, row, score_file) +metadata_file = local_table_value(inputs, row, 'metadata_file'); +if isempty(metadata_file) || exist(metadata_file, 'file') ~= 2 + return +end +M = readtable(metadata_file, 'TextType', 'string'); +if height(S) ~= height(M) + error('Score file %s has %d rows but metadata %s has %d rows. Regenerate map-score CSVs from the matching whole-brain maps.', ... + score_file, height(S), metadata_file, height(M)); +end +has_score_condition = any(strcmp('condition', S.Properties.VariableNames)); +has_metadata_condition = any(strcmp('condition', M.Properties.VariableNames)); +if has_metadata_condition && ~has_score_condition + error('Score file %s is missing condition labels from metadata %s. Regenerate map-score CSVs.', ... + score_file, metadata_file); +elseif has_score_condition && has_metadata_condition + if ~isequal(string(S.condition), string(M.condition)) + error('Score file %s condition labels do not match metadata %s. Regenerate map-score CSVs.', ... + score_file, metadata_file); + end +end +has_score_lag = any(strcmp('lag_index', S.Properties.VariableNames)); +has_metadata_lag = any(strcmp('lag_index', M.Properties.VariableNames)); +if has_metadata_lag && ~has_score_lag + error('Score file %s is missing lag indices from metadata %s. Regenerate map-score CSVs.', ... + score_file, metadata_file); +elseif has_score_lag && has_metadata_lag + if ~isequaln(local_to_numeric(S.lag_index), local_to_numeric(M.lag_index)) + error('Score file %s lag indices do not match metadata %s. Regenerate map-score CSVs.', ... + score_file, metadata_file); + end +end +end + +function result = local_score_table_to_result(S, Tscore, score_cols, model_name, object_name, score_file, source_model, add_average, average_name) +conditions = local_condition_values(S); +condition_names = unique(conditions, 'stable'); +lag_index = local_lag_index_values(S, conditions); +lag_names = unique(lag_index, 'stable'); +lag_seconds = local_lag_seconds_by_index(S, lag_index, lag_names); +t_conditions = {}; +t_lag_index = []; +if ~isempty(Tscore) + t_conditions = local_condition_values(Tscore); + t_lag_index = local_lag_index_values(Tscore, t_conditions); +end + +if add_average + signature_names = [{average_name}, score_cols(:)']; + signature_fields = matlab.lang.makeUniqueStrings(matlab.lang.makeValidName(signature_names)); + average_field = signature_fields{1}; + score_fields = signature_fields(2:end); +else + signature_names = score_cols(:)'; + signature_fields = matlab.lang.makeUniqueStrings(matlab.lang.makeValidName(signature_names)); + average_field = ''; + score_fields = signature_fields; +end +fits_by_signature = struct(); +fit_data_by_score = cell(1, numel(score_cols)); + +for s = 1:numel(score_cols) + H = nan(numel(lag_names), numel(condition_names)); + T = nan(numel(lag_names), numel(condition_names)); + propagated_SE = nan(numel(lag_names), numel(condition_names)); + values = S.(score_cols{s}); + t_values = local_t_values(Tscore, score_cols{s}); + se_values = local_score_se_values(S, score_cols{s}); + for c = 1:numel(condition_names) + for l = 1:numel(lag_names) + wh = strcmp(conditions, condition_names{c}) & lag_index == lag_names(l); + H(l, c) = local_mean_omitnan(values(wh)); + if ~isempty(se_values) + propagated_SE(l, c) = local_combine_independent_se(se_values(wh)); + end + if ~isempty(t_values) + T(l, c) = local_t_mean_for_bin(t_values, t_conditions, t_lag_index, ... + conditions, lag_index, condition_names{c}, lag_names(l), wh); + end + end + end + + [SE, T, P, p_type, uncertainty_source] = local_score_uncertainty(H, T, propagated_SE, S); + + fit_data = local_score_fit_data(H, lag_names, lag_seconds, score_cols{s}, ... + score_file, source_model, SE, T, P, p_type, uncertainty_source); + fit_data_by_score{s} = fit_data; + + fit = struct(); + fit.(model_name) = fit_data; + fits_by_signature.(score_fields{s}) = fit; +end + +if add_average + average_fit = struct(); + average_fit.(model_name) = local_average_score_fit(fit_data_by_score, average_name, score_cols, score_file, source_model); + fits_by_signature.(average_field) = average_fit; + selected_signature = average_name; + selected_fit = average_fit; +else + selected_signature = score_cols{1}; + selected_fit = fits_by_signature.(score_fields{1}); +end + +result = struct(); +result.conditions = condition_names(:)'; +result.fits_by_signature = fits_by_signature; +result.signature_meta = struct(); +result.signature_meta.signal_source = 'wholebrain_map_score'; +result.signature_meta.selected_signature = selected_signature; +result.signature_meta.selected_signatures = signature_names(:)'; +result.signature_meta.selected_signature_fields = signature_fields(:)'; +result.signature_meta.available_signatures = signature_names(:)'; +result.signature_meta.n_signatures = numel(signature_names); +result.signature_meta.object = object_name; +result.signature_meta.source_file = score_file; +result.signature_meta.source_model = source_model; +result.fits = selected_fit; +result.settings = struct('signal_source', 'second_level_map_scores', ... + 'model_name', model_name, 'object', object_name, 'source_model', source_model); +end + +function fit_data = local_score_fit_data(H, lag_names, lag_seconds, source_score, score_file, source_model, SE, T, P, p_type, uncertainty_source) +fit_data = struct(); +fit_data.hrf = H; +fit_data.lag_index = lag_names(:); +fit_data.lag_seconds = lag_seconds(:); +fit_data.source_score = source_score; +fit_data.source_file = score_file; +fit_data.source_model = source_model; +fit_data.se = SE; +fit_data.t = T; +fit_data.p = P; +fit_data.p_type = p_type; +fit_data.uncertainty_source = uncertainty_source; +end + +function fit_data = local_average_score_fit(fit_data_by_score, average_name, score_cols, score_file, source_model) +H = local_stack_field(fit_data_by_score, 'hrf'); +SE = local_stack_field(fit_data_by_score, 'se'); +T = local_stack_field(fit_data_by_score, 't'); + +fit_data = fit_data_by_score{1}; +fit_data.hrf = local_mean_omitnan_stack(H); +fit_data.source_score = average_name; +fit_data.source_scores = score_cols(:)'; +fit_data.source_file = score_file; +fit_data.source_model = source_model; + +if ~isempty(SE) + fit_data.se = local_combine_score_se(H, SE); +elseif size(H, 3) > 1 + fit_data.se = local_sem_omitnan_stack(H); +else + fit_data.se = []; +end + +if ~isempty(fit_data.se) + fit_data.t = fit_data.hrf ./ fit_data.se; + fit_data.t(~isfinite(fit_data.t)) = NaN; +else + fit_data.t = local_mean_omitnan_stack(T); +end +fit_data.p = []; +fit_data.p_type = ''; +fit_data.uncertainty_source = 'average across map-score columns'; +end + +function t_values = local_t_values(Tscore, score_col) +t_values = []; +if isempty(Tscore) || ~istable(Tscore) || ~any(strcmp(score_col, Tscore.Properties.VariableNames)) + return +end +t_values = Tscore.(score_col); +end + +function se_values = local_score_se_values(S, score_col) +se_values = []; +se_col = [char(score_col) '_se']; +if any(strcmp(se_col, S.Properties.VariableNames)) + se_values = S.(se_col); +end +end + +function se = local_combine_independent_se(vals) +vals = vals(:); +vals = vals(isfinite(vals)); +if isempty(vals) + se = NaN; +else + se = sqrt(sum(vals .^ 2)) ./ numel(vals); +end +end + +function t_mean = local_t_mean_for_bin(t_values, t_conditions, t_lag_index, conditions, lag_index, condition_name, lag_name, fallback_wh) +t_mean = NaN; +if numel(t_values) == numel(t_conditions) && numel(t_values) == numel(t_lag_index) + wh = strcmp(t_conditions, condition_name) & t_lag_index == lag_name; + t_mean = local_mean_omitnan(t_values(wh)); +elseif numel(t_values) == numel(conditions) && numel(t_values) == numel(lag_index) + t_mean = local_mean_omitnan(t_values(fallback_wh)); +end +end + +function [SE, T, P, p_type, uncertainty_source] = local_score_uncertainty(H, T, propagated_SE, S) +SE = []; +P = []; +p_type = ''; +uncertainty_source = 'not stored in map-score CSV; use group-level stats across subjects or refit source time series'; + +if ~isempty(propagated_SE) && any(isfinite(propagated_SE(:))) + SE = propagated_SE; + T = H ./ SE; + T(~isfinite(T)) = NaN; + if any(strcmp('dfe', S.Properties.VariableNames)) + dfe = local_mean_omitnan(local_to_numeric(S.dfe)); + if ~isnan(dfe) + P = 2 * (1 - tcdf(abs(T), dfe)); + P(P == 0) = eps; + p_type = sprintf('Approximate two-tailed P-values from beta map-score / propagated beta-SE map-score, dfe = %.3f', dfe); + end + end + uncertainty_source = 'propagated score SE from matching HRF beta SE maps, assuming diagonal voxel covariance'; + return +end + +if isempty(T) || all(isnan(T(:))) + return +end + +SE = abs(H ./ T); +SE(~isfinite(SE)) = NaN; +P = []; +if any(strcmp('dfe', S.Properties.VariableNames)) + dfe = local_mean_omitnan(local_to_numeric(S.dfe)); + if ~isnan(dfe) + P = 2 * (1 - tcdf(abs(T), dfe)); + P(P == 0) = eps; + p_type = sprintf('Approximate two-tailed P-values from beta-map score / t-map score ratio, dfe = %.3f', dfe); + end +end +uncertainty_source = 'approximate score SE from matching beta and t map-score CSVs'; +end + +function X = local_stack_field(fit_data_by_score, field_name) +first_idx = []; +for i = 1:numel(fit_data_by_score) + if isfield(fit_data_by_score{i}, field_name) && ~isempty(fit_data_by_score{i}.(field_name)) + first_idx = i; + break + end +end +if isempty(first_idx) + X = []; + return +end + +first_x = fit_data_by_score{first_idx}.(field_name); +X = nan([size(first_x), numel(fit_data_by_score)]); +X(:, :, first_idx) = first_x; +for i = (first_idx + 1):numel(fit_data_by_score) + if ~isfield(fit_data_by_score{i}, field_name) || isempty(fit_data_by_score{i}.(field_name)) + continue + end + this_x = fit_data_by_score{i}.(field_name); + if isequal(size(this_x), size(first_x)) + X(:, :, i) = this_x; + end +end +if ~isempty(X) && all(isnan(X(:))) + X = []; +end +end + +function M = local_mean_omitnan_stack(X) +if isempty(X) + M = []; + return +end +M = nan(size(X, 1), size(X, 2)); +for r = 1:size(X, 1) + for c = 1:size(X, 2) + M(r, c) = local_mean_omitnan(squeeze(X(r, c, :))); + end +end +end + +function SE = local_sem_omitnan_stack(X) +if isempty(X) + SE = []; + return +end +SE = nan(size(X, 1), size(X, 2)); +for r = 1:size(X, 1) + for c = 1:size(X, 2) + vals = squeeze(X(r, c, :)); + vals = vals(~isnan(vals)); + if numel(vals) > 1 + SE(r, c) = std(vals) ./ sqrt(numel(vals)); + end + end +end +end + +function SE = local_combine_score_se(H, score_SE) +SE = nan(size(H, 1), size(H, 2)); +score_sem = local_sem_omitnan_stack(H); +for r = 1:size(score_SE, 1) + for c = 1:size(score_SE, 2) + vals = squeeze(score_SE(r, c, :)); + vals = vals(~isnan(vals)); + if isempty(vals) + model_se = NaN; + else + model_se = sqrt(sum(vals .^ 2)) ./ numel(vals); + end + if isnan(model_se) + SE(r, c) = score_sem(r, c); + elseif isnan(score_sem(r, c)) + SE(r, c) = model_se; + else + SE(r, c) = sqrt(model_se .^ 2 + score_sem(r, c) .^ 2); + end + end +end +end + +function conditions = local_condition_values(S) +if any(strcmp('condition', S.Properties.VariableNames)) + conditions_string = string(S.condition); +else + conditions_string = repmat("all", height(S), 1); +end +conditions_string(ismissing(conditions_string) | strlength(conditions_string) == 0) = "all"; +conditions = cellstr(conditions_string); +conditions = conditions(:); +end + +function lag_index = local_lag_index_values(S, conditions) +if any(strcmp('lag_index', S.Properties.VariableNames)) + lag_index = local_to_numeric(S.lag_index(:)); +else + lag_index = zeros(height(S), 1); + condition_names = unique(conditions, 'stable'); + for c = 1:numel(condition_names) + wh = find(strcmp(conditions, condition_names{c})); + lag_index(wh) = (1:numel(wh))'; + end +end +lag_index = double(lag_index); +end + +function lag_seconds = local_lag_seconds_by_index(S, lag_index, lag_names) +lag_seconds = nan(numel(lag_names), 1); +if ~any(strcmp('lag_seconds', S.Properties.VariableNames)) + return +end + +for i = 1:numel(lag_names) + wh = lag_index == lag_names(i); + lag_seconds(i) = local_mean_omitnan(local_to_numeric(S.lag_seconds(wh))); +end +end + +function cols = local_score_columns(S, requested) +if ~isempty(requested) + cols = cellstr(string(requested)); + missing = setdiff(cols, S.Properties.VariableNames); + if ~isempty(missing) + error('Requested score columns not found: %s', strjoin(missing, ', ')); + end + return +end + +metadata_cols = {'volume_index', 'condition', 'condition_index', 'lag_index', ... + 'lag_seconds', 'image_label', 'N', 'dfe', 'TR', 'mode'}; +cols = {}; +for i = 1:numel(S.Properties.VariableNames) + name = S.Properties.VariableNames{i}; + if ismember(name, metadata_cols) || local_is_uncertainty_column(name) + continue + end + if isnumeric(S.(name)) + cols{end + 1} = name; %#ok + end +end +end + +function m = local_mean_omitnan(y) +y = y(:); +y = y(~isnan(y)); +if isempty(y) + m = NaN; +else + m = mean(y); +end +end + +function tf = local_is_uncertainty_column(name) +tf = endsWith(char(name), '_se'); +end + +function value = local_table_value(T, row, varname) +if ~any(strcmp(varname, T.Properties.VariableNames)) + value = ''; + return +end +value = T.(varname)(row); +if iscell(value), value = value{1}; end +value = string(value); +if ismissing(value) || strlength(value) == 0 + value = ''; +else + value = char(value); +end +end + +function x = local_to_numeric(x) +if isnumeric(x) + x = double(x); +else + x = str2double(string(x)); +end +end + +function skipped = local_skip(skipped, idx, subject, reason, missing_policy) +if strcmp(missing_policy, 'error') + error('Input row %d (%s): %s', idx, subject, reason); +elseif ~strcmp(missing_policy, 'warn') && ~strcmp(missing_policy, 'silent') + error('Unknown MissingPolicy: %s. Use ''warn'', ''silent'', or ''error''.', missing_policy); +end + +skipped(end + 1) = struct('index', idx, 'subject', subject, 'reason', reason); +if strcmp(missing_policy, 'warn') + warning('hrf_second_level_inputs_to_study:SkippingInput', ... + 'Skipping input row %d (%s): %s', idx, subject, reason); +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_smoke_test_phase1.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_smoke_test_phase1.m new file mode 100644 index 00000000..b633f502 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_smoke_test_phase1.m @@ -0,0 +1,152 @@ +function hrf_smoke_test_phase1(study_dir) +%HRF_SMOKE_TEST_PHASE1 Verify Phase 1 audit/score fix end-to-end. +% +% hrf_smoke_test_phase1 - run helper-level sanity checks only +% hrf_smoke_test_phase1(study_dir) - also run the audit RepairMissing +% path against a real finished study +% +% Phase 1 introduced: +% 1. hrf_score_one_prefix.m - new shared per-prefix scoring helper +% 2. hrf_score_wholebrain_input_table.m - refactored to call the helper +% 3. hrf_write_slurm_study_script.m - generated worker now wraps scoring +% in try/catch so a single signature failure no longer kills a task +% 4. hrf_audit_slurm_outputs.m - new 'RepairMissing' name-value pair +% +% This test exercises (1) directly and (4) against a study directory. + +fprintf('\n=== Phase 1 smoke test ===\n'); + +%% Helper-level sanity checks (no data needed) ---------------------------- +fprintf('\n[1/4] Helper: missing-inputs path ...\n'); +tmp_prefix = fullfile(tempdir, sprintf('hrf_smoke_%d', randi(1e6))); +status = hrf_score_one_prefix(tmp_prefix, 'SignatureSets', {'all'}); +assert(~status.core_inputs_present, ... + 'core_inputs_present should be false for nonexistent prefix'); +assert(~isempty(status.errors), 'expected at least one error'); +assert(isempty(status.wrote_files), 'should not have written any files'); +fprintf(' ok\n'); + +fprintf('\n[2/4] Helper: validates ScoreObjects argument ...\n'); +got_err = false; +try + hrf_score_one_prefix(tmp_prefix, 'ScoreObjects', {'beta', 'banana'}); +catch ME + got_err = strcmp(ME.identifier, 'hrf_score_one_prefix:UnknownScoreObjects'); +end +assert(got_err, 'expected UnknownScoreObjects error for bad ScoreObjects'); +fprintf(' ok\n'); + +fprintf('\n[3/4] Helper: filename composition (single- vs multi-model) ...\n'); +% Indirect check via the helper's error path: the file it would *try* to +% write is reported in the error context when validation fails. +status_single = hrf_score_one_prefix(tmp_prefix, ... + 'ModelName', 'sfir', 'NumModels', 1, 'ScoreObjects', {'beta'}, ... + 'SignatureSets', {'all'}); +status_multi = hrf_score_one_prefix(tmp_prefix, ... + 'ModelName', 'sfir', 'NumModels', 3, 'ScoreObjects', {'beta'}, ... + 'SignatureSets', {'all'}); +% Both should report core_inputs_present=false (we have no NIfTIs). +assert(~status_single.core_inputs_present); +assert(~status_multi.core_inputs_present); +fprintf(' ok\n'); + +%% Audit-level repair (real study) --------------------------------------- +fprintf('\n[4/4] Audit RepairMissing against real study ...\n'); +if nargin < 1 || isempty(study_dir) + fprintf(' (skipped: pass study_dir to exercise this path)\n'); + fprintf('\n=== smoke test complete (helper-only) ===\n'); + return +end + +assert(exist(study_dir, 'dir') == 7, 'study_dir does not exist: %s', study_dir); + +fprintf(' reading audit BEFORE repair ...\n'); +[a_before, s_before] = hrf_audit_slurm_outputs(study_dir); +needs_before = sum(a_before.core_complete & ~a_before.score_complete); +fprintf(' %d total rows; %d core_complete; %d score_complete; %d need repair\n', ... + height(a_before), sum(a_before.core_complete), sum(a_before.score_complete), needs_before); + +if needs_before == 0 + fprintf(' no rows need repair; nothing to test. Done.\n'); + return +end + +fprintf(' running audit WITH RepairMissing ...\n'); +[a_after, s_after] = hrf_audit_slurm_outputs(study_dir, ... + 'RepairMissing', true, 'Verbose', true); + +needs_after = sum(a_after.core_complete & ~a_after.score_complete); +fprintf(' repair_summary.n_rows_attempted = %d\n', s_after.repair.n_rows_attempted); +fprintf(' repair_summary.n_rows_repaired = %d\n', s_after.repair.n_rows_repaired); +fprintf(' repair_summary.n_rows_failed = %d\n', s_after.repair.n_rows_failed); +fprintf(' repair_summary.n_files_written = %d\n', s_after.repair.n_files_written); +if ~isempty(s_after.repair.errors) + fprintf(' first errors:\n'); + for i = 1:min(numel(s_after.repair.errors), 3) + fprintf(' %s\n', s_after.repair.errors{i}); + end +end + +fprintf(' AFTER: %d rows still need repair (was %d)\n', needs_after, needs_before); + +%% Equivalence check: repaired score files == post-hoc backfill ----------- +% Pick the first repaired row, regenerate its score CSV via the post-hoc +% path on a tempdir copy, and check the two files agree. +fprintf('\n[equivalence] Comparing in-audit repair vs post-hoc backfill ...\n'); +repaired_rows = find(a_before.core_complete & ~a_before.score_complete); +if isempty(repaired_rows) + fprintf(' (no rows to compare)\n'); +elseif ~isempty(s_after.repair.errors) && s_after.repair.n_rows_repaired == 0 + fprintf(' (skipped: repair did not write any files)\n'); +else + r = repaired_rows(1); + audit_csv = ''; + if a_after.score_complete(r) && ~isempty(a_after.beta_scores_file{r}) + audit_csv = a_after.beta_scores_file{r}; + end + if ~isempty(audit_csv) && exist(audit_csv, 'file') == 2 + backup_csv = [audit_csv '.audit_repair_backup']; + copyfile(audit_csv, backup_csv); + % Re-run the post-hoc backfill on this row in isolation. + T = a_before(r, :); + T.subject = string(T.subject); + T.model = string(T.model); + % hrf_score_wholebrain_input_table expects the model-resolved prefix + % (it calls the helper with NumModels=1). The audit table's + % output_prefix is task-level; model_prefix already has the model + % suffix applied for multi-model studies. + T.prefix = string(T.model_prefix); + T.beta_scores_file = string(''); + T.t_scores_file = string(''); + T.metadata_file = string(T.metadata_file); + T.result_mat_file = string(T.result_mat_file); + delete(audit_csv); + hrf_score_wholebrain_input_table(T, ... + 'SignatureSets', {'all'}, 'ScoreObjects', {'beta'}, ... + 'Overwrite', true); + if exist(audit_csv, 'file') == 2 + T_audit = readtable(backup_csv); + T_posthoc = readtable(audit_csv); + ok = isequal(size(T_audit), size(T_posthoc)) && ... + isequal(T_audit.Properties.VariableNames, T_posthoc.Properties.VariableNames); + if ok + fprintf(' ✓ shapes and column names match (%d rows, %d cols)\n', ... + height(T_audit), width(T_audit)); + else + fprintf(' ✗ shapes/cols disagree (audit %dx%d vs post-hoc %dx%d)\n', ... + size(T_audit, 1), size(T_audit, 2), ... + size(T_posthoc, 1), size(T_posthoc, 2)); + end + else + fprintf(' ✗ post-hoc backfill did not write a CSV\n'); + end + % Restore the audit-repaired version. + copyfile(backup_csv, audit_csv); + delete(backup_csv); + else + fprintf(' (no audit-repaired CSV available to compare)\n'); + end +end + +fprintf('\n=== smoke test complete ===\n'); +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_smoke_test_phase3.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_smoke_test_phase3.m new file mode 100644 index 00000000..7c0dbd28 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_smoke_test_phase3.m @@ -0,0 +1,145 @@ +function hrf_smoke_test_phase3(prefix) +%HRF_SMOKE_TEST_PHASE3 Verify Phase 3 v0 scaffold (fmri_hrf, statistic_hrf). +% +% hrf_smoke_test_phase3 - run constructor-only checks (no real data) +% hrf_smoke_test_phase3(prefix) - also exercise make_fmri_stat_hrf +% against a NIfTI prefix on disk +% +% Phase 3 v0 ships: +% 1. @fmri_hrf < fmri_data +% 2. @statistic_hrf < statistic_image +% 3. make_fmri_stat_hrf - canonical pairing entry point +% +% This test exercises: +% * empty construction +% * lift-from-fmri_data construction with HRF metadata +% * lift-from-statistic_image construction +% * cat across two synthetic objects +% * to_statistic_hrf round-trip +% * make_fmri_stat_hrf from a real NIfTI prefix (if provided) + +fprintf('\n=== Phase 3 v0 smoke test ===\n'); + +%% Empty construction --------------------------------------------------- +fprintf('\n[1/6] Empty constructors ...\n'); +Hb_empty = fmri_hrf(); +Ht_empty = statistic_hrf(); +assert(isa(Hb_empty, 'fmri_hrf') && isa(Hb_empty, 'fmri_data'), ... + 'fmri_hrf() should produce an fmri_hrf that is-a fmri_data'); +assert(isa(Ht_empty, 'statistic_hrf') && isa(Ht_empty, 'statistic_image'), ... + 'statistic_hrf() should produce a statistic_hrf that is-a statistic_image'); +fprintf(' ok\n'); + +%% Lift from synthetic fmri_data ---------------------------------------- +fprintf('\n[2/6] Lift from synthetic fmri_data + HRF metadata ...\n'); +% Construct a tiny 4-voxel x 6-volume fmri_data fixture (2 conditions x 3 lags). +n_vox = 4; n_vol = 6; +fake_dat = randn(n_vox, n_vol); +fd = local_make_fake_fmri_data(fake_dat); + +meta = table( ... + (1:n_vol)', ... + string({'pain','pain','pain','neutral','neutral','neutral'}'), ... + [1;2;3;1;2;3], ... + [0;0.8;1.6;0;0.8;1.6], ... + string({'pain_lag1','pain_lag2','pain_lag3','neutral_lag1','neutral_lag2','neutral_lag3'}'), ... + 'VariableNames', {'volume_index','condition','lag_index','lag_seconds','image_label'}); + +Hb = fmri_hrf(fd, ... + 'MetadataTable', meta, ... + 'Subject', 'sub-99', ... + 'RunLabel', 'task-fake_run-01', ... + 'ModelName', 'sfir', ... + 'TR', 0.8); + +assert(isa(Hb, 'fmri_hrf')); +assert(strcmp(Hb.subject, 'sub-99')); +assert(strcmp(Hb.model_name, 'sfir')); +assert(isequal(sort(Hb.conditions), sort({'pain','neutral'})), ... + 'conditions should auto-derive from metadata when not provided'); +assert(height(Hb.metadata_table) == size(Hb.dat, 2)); +fprintf(' ok\n'); + +%% Lift from synthetic statistic_image ---------------------------------- +fprintf('\n[3/6] Lift from synthetic statistic_image ...\n'); +si = statistic_image(fd); +si.type = 'T'; +Ht = statistic_hrf(si, ... + 'MetadataTable', meta, ... + 'Subject', 'sub-99', ... + 'RunLabel', 'task-fake_run-01', ... + 'ModelName', 'sfir', ... + 'TR', 0.8); +assert(isa(Ht, 'statistic_hrf')); +assert(strcmp(Ht.subject, 'sub-99')); +fprintf(' ok\n'); + +%% cat across two synthetic objects ------------------------------------- +fprintf('\n[4/6] cat across (subject, run) ...\n'); +Hb2 = fmri_hrf(fd, ... + 'MetadataTable', meta, ... + 'Subject', 'sub-98', ... + 'RunLabel', 'task-fake_run-01', ... + 'ModelName', 'sfir', ... + 'TR', 0.8); +Hb_stacked = cat(1, Hb, Hb2); +assert(isa(Hb_stacked, 'fmri_hrf')); +assert(size(Hb_stacked.dat, 2) == 2 * n_vol, ... + 'stacked volume count should be sum of inputs'); +assert(height(Hb_stacked.metadata_table) == 2 * n_vol); +assert(any(strcmp('subject', Hb_stacked.metadata_table.Properties.VariableNames)), ... + 'stacked metadata should carry subject column'); +fprintf(' ok\n'); + +%% to_statistic_hrf round-trip ----------------------------------------- +fprintf('\n[5/6] to_statistic_hrf from beta + synthetic SE ...\n'); +se_si = statistic_image(fd); +se_si.dat = abs(fd.dat) + 0.1; % strictly positive SE +se_si.type = 'HRF beta standard error'; +Ht_derived = to_statistic_hrf(Hb, 'SE', se_si); +assert(isa(Ht_derived, 'statistic_hrf')); +assert(strcmp(Ht_derived.subject, Hb.subject), 'metadata should match parent fmri_hrf'); +assert(isequal(size(Ht_derived.dat), size(Hb.dat))); +fprintf(' ok\n'); + +%% make_fmri_stat_hrf from NIfTI prefix (real data) --------------------- +fprintf('\n[6/6] make_fmri_stat_hrf from a NIfTI prefix ...\n'); +if nargin < 1 || isempty(prefix) + fprintf(' (skipped: pass a prefix to exercise this path)\n'); + fprintf('\n=== Phase 3 v0 smoke test complete (synthetic-only) ===\n'); + return +end + +assert(exist([prefix '_beta.nii'], 'file') == 2, ... + 'expected %s_beta.nii to exist', prefix); + +[Hb_real, Ht_real] = make_fmri_stat_hrf(prefix, ... + 'Subject', 'sub-real', ... + 'RunLabel', 'task-real_run-01', ... + 'ModelName', 'sfir', ... + 'TR', 0.8); + +assert(isa(Hb_real, 'fmri_hrf')); +assert(isa(Ht_real, 'statistic_hrf') || isempty(Ht_real)); +assert(~isempty(Hb_real.dat), 'real Hb should have voxel data'); +assert(height(Hb_real.metadata_table) == size(Hb_real.dat, 2), ... + 'metadata rows must match volumes'); +fprintf(' Hb: %d voxels x %d volumes; metadata %d rows\n', ... + size(Hb_real.dat, 1), size(Hb_real.dat, 2), height(Hb_real.metadata_table)); +if ~isempty(Ht_real.dat) + fprintf(' Ht: %d voxels x %d volumes; metadata %d rows\n', ... + size(Ht_real.dat, 1), size(Ht_real.dat, 2), height(Ht_real.metadata_table)); +end + +fprintf('\n=== Phase 3 v0 smoke test complete ===\n'); +end + + +function fd = local_make_fake_fmri_data(dat) +% Build a minimal fmri_data fixture that the fmri_hrf constructor will accept. +% We use the canlabCore "noverbose" pattern via direct construction from data. +fd = fmri_data(); +fd.dat = dat; +fd.removed_voxels = false(size(dat, 1), 1); +fd.removed_images = false(size(dat, 2), 1); +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_summarize_study.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_summarize_study.m new file mode 100644 index 00000000..fe2d14bc --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_summarize_study.m @@ -0,0 +1,22 @@ +function T = hrf_summarize_study(results, subject_ids) +%HRF_SUMMARIZE_STUDY Long-format summary table per subject/condition/model. +rows = {}; +for s = 1:numel(results) + r = results{s}; + if isempty(r) || ~isfield(r, 'fits'), continue; end + models = fieldnames(r.fits); + for m = 1:numel(models) + mdl = models{m}; + if ~isfield(r.fits.(mdl), 'param'), continue; end + P = r.fits.(mdl).param; + for c = 1:numel(r.conditions) + rows(end+1, :) = {subject_ids{s}, mdl, r.conditions{c}, P(1,c), P(2,c), P(3,c)}; %#ok + end + end +end +if isempty(rows) + T = cell2table(cell(0, 6), 'VariableNames', {'subject','model','condition','amplitude','time_to_peak','width'}); +else + T = cell2table(rows, 'VariableNames', {'subject','model','condition','amplitude','time_to_peak','width'}); +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_time_correction.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_time_correction.m new file mode 100644 index 00000000..917b7654 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_time_correction.m @@ -0,0 +1,69 @@ +function C = hrf_time_correction(D, varargin) +%HRF_TIME_CORRECTION Multiple-comparison correction across a peristimulus timecourse. +% +% Thin adapter over hrf_group_stats for the [nSubj x nTime] per-subject +% contrast matrices used by the HRF timecourse tests (hrf_time_unfolding_stats, +% hrf_2x2_study_score_stats, hrf_compare_conditions). It gives the per-lag +% inference a consistent, small-n-appropriate correction -- sign-flip / label +% max-|t| FWER, temporal cluster-mass, or FDR -- instead of the uncorrected +% per-timepoint t-tests those functions computed on their own. This is the +% correction the n~7-11 per-lag HRF summaries need. +% +% :Usage: +% :: +% C = hrf_time_correction(D, 'Correction','cluster') % one-sample vs 0 +% C = hrf_time_correction(D, 'Group', g, 'Correction','permutation')% two-sample diff +% +% :Inputs: +% **D:** [nSubj x nTime] per-subject timecourse. One-sample tests each +% timepoint's mean against 0 (D is usually a within-subject contrast). +% +% :Optional Inputs: +% **'Correction':** 'none' (default) | 'fdr' | 'permutation' | 'cluster'. +% **'Group':** [nSubj] two-group labels; switches to a two-sample test of the +% group difference at each timepoint. +% **'Nperm':** permutations when the exact set is too large. Default 5000. +% **'Alpha':** corrected-p / FWER threshold. Default 0.05. +% +% :Output: +% **C:** struct with column vectors [nTime x 1] .t, .p, .p_corr, .sig, plus +% .correction, .design, .n. +% +% See also: hrf_group_stats, hrf_time_unfolding_stats, hrf_2x2_study_score_stats, +% hrf_compare_conditions. + +p = inputParser; +p.addRequired('D', @isnumeric); +p.addParameter('Correction', 'none', @(x) ischar(x) || isstring(x)); +p.addParameter('Group', [], @(x) isempty(x) || isvector(x) || iscell(x)); +p.addParameter('Nperm', 5000, @(x) isscalar(x) && x >= 100); +p.addParameter('Alpha', 0.05, @(x) isscalar(x) && x > 0 && x < 1); +p.parse(D, varargin{:}); +o = p.Results; + +[nsub, ntime] = size(D); +corr = lower(char(o.Correction)); + +% [nTime x 1 x nSubj] so hrf_group_stats treats time as the tested family and +% the last dim as subjects; the singleton keeps it 2-D for cluster contiguity. +data = reshape(D.', [ntime, 1, nsub]); + +args = {'Correction', corr, 'Nperm', o.Nperm, 'Threshold', o.Alpha}; +if ~isempty(o.Group) + args = [args, {'Design', 'twosample', 'Group', o.Group}]; +end +if any(strcmp(corr, {'cluster', 'tcluster'})) + args = [args, {'ClusterDim', 1}]; +end + +G = hrf_group_stats(data, args{:}); + +C = struct(); +C.t = G.t(:); +C.p = G.p(:); +C.p_corr = G.p_corr(:); +C.sig = G.sig(:); +C.correction = corr; +C.design = G.design; +C.n = nsub; +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_time_unfolding_stats.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_time_unfolding_stats.m new file mode 100644 index 00000000..5c98c81f --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_time_unfolding_stats.m @@ -0,0 +1,446 @@ +function stats = hrf_time_unfolding_stats(study, varargin) +%HRF_TIME_UNFOLDING_STATS Subject-level time-unfolding tests across a study. +% Supports within-subject condition contrasts, signature-specific fits, and +% optional two-group comparisons. Duplicate subject IDs are averaged by +% default before statistical testing. + +p = inputParser; +p.addRequired('study', @isstruct); +p.addParameter('Model', 'sfir', @(x) ischar(x) || isstring(x)); +p.addParameter('SourceModel', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Signature', '', @(x) ischar(x) || isstring(x)); +p.addParameter('ConditionA', '', @(x) ischar(x) || isstring(x)); +p.addParameter('ConditionB', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Group', {}, @(x) iscell(x) || isstring(x)); +p.addParameter('Unit', 'subject', @(x) ischar(x) || isstring(x)); +p.addParameter('MissingPolicy', 'warn', @(x) ischar(x) || isstring(x)); +p.addParameter('Alpha', 0.05, @(x) isscalar(x) && x > 0 && x < 1); +% Across-lag multiple-comparison correction (shared hrf_group_stats engine): +% 'none' (default; per-timepoint t, backward compatible) | 'fdr' | +% 'permutation' (sign-flip / label max-|t| FWER) | 'cluster' (temporal +% cluster-mass). When not 'none', adds .p_corrected / .significant_corrected. +p.addParameter('Correction', 'none', @(x) ischar(x) || isstring(x)); +p.addParameter('Nperm', 5000, @(x) isscalar(x) && x >= 100); +p.parse(study, varargin{:}); +opts = p.Results; + +model_name = char(opts.Model); +source_model = lower(strtrim(char(opts.SourceModel))); +unit = lower(char(opts.Unit)); +missing_policy = lower(char(opts.MissingPolicy)); +opts.ConditionA = char(opts.ConditionA); +opts.ConditionB = char(opts.ConditionB); +opts.Signature = char(opts.Signature); + +[D_runs, run_subject_ids, run_labels, skipped, matchedA, matchedB] = local_collect_contrasts(study, opts, model_name, source_model, missing_policy); +if isempty(D_runs) + error('No valid HRF fits found for model "%s". Skipped %d result(s).', model_name, numel(skipped)); +end + +[D, subject_ids, run_to_subject] = local_aggregate_unit(D_runs, run_subject_ids, unit); +[group_labels, group_labels_by_subject] = local_group_labels(opts.Group, study, run_subject_ids, subject_ids, run_to_subject, unit); + +[T, P] = local_ttest_each_timepoint(D); + +stats = struct(); +stats.time = (1:size(D, 2))'; +stats.mean = local_mean_omitnan(D, 1)'; +stats.sem = local_sem_omitnan(D, 1)'; +stats.p_value = P(:); +stats.t_value = T(:); +stats.significant = P(:) < opts.Alpha; +stats.alpha = opts.Alpha; +stats.correction = lower(char(opts.Correction)); + +% Across-lag correction via the shared permutation engine (additive; the +% uncorrected .significant above is preserved for backward compatibility). +if ~strcmpi(stats.correction, 'none') + Cc = hrf_time_correction(D, 'Correction', stats.correction, ... + 'Nperm', opts.Nperm, 'Alpha', opts.Alpha); + stats.p_corrected = Cc.p_corr(:); + stats.significant_corrected = Cc.sig(:); +end +stats.conditionA = char(opts.ConditionA); +stats.conditionB = char(opts.ConditionB); +stats.conditionA_matched = matchedA; +stats.conditionB_matched = matchedB; +stats.model = model_name; +stats.source_model = source_model; +stats.signature = char(opts.Signature); +stats.unit = unit; +stats.subject_ids = subject_ids(:); +stats.n_subjects = numel(subject_ids); +stats.n_runs = size(D_runs, 1); +stats.run_subject_ids = run_subject_ids(:); +stats.run_labels = run_labels(:); +stats.run_to_subject = run_to_subject(:); +stats.data = D; +stats.run_level_data = D_runs; +stats.skipped = skipped; + +if ~isempty(group_labels) + stats.group = group_labels(:); + stats.group_by_subject = group_labels_by_subject(:); + ug = unique(group_labels); + if numel(ug) == 2 + ix1 = strcmp(group_labels, ug{1}); + ix2 = strcmp(group_labels, ug{2}); + [Tg, Pg] = local_ttest2_each_timepoint(D(ix1, :), D(ix2, :)); + stats.group_labels = ug; + stats.group_p_value = Pg(:); + stats.group_t_value = Tg(:); + if ~strcmpi(stats.correction, 'none') + Cg = hrf_time_correction(D, 'Group', group_labels, ... + 'Correction', stats.correction, 'Nperm', opts.Nperm, 'Alpha', opts.Alpha); + stats.group_p_corrected = Cg.p_corr(:); + stats.group_significant_corrected = Cg.sig(:); + end + end +end +end + +function [D, subject_ids, run_labels, skipped, matchedA, matchedB] = local_collect_contrasts(study, opts, model_name, source_model, missing_policy) +D = []; +subject_ids = {}; +run_labels = {}; +skipped = struct('index', {}, 'subject', {}, 'reason', {}); +matchedA = {}; +matchedB = {}; + +for s = 1:numel(study.results) + subject_id = local_subject_id(study, s); + r = study.results{s}; + if isempty(r) + skipped = local_skip(skipped, s, subject_id, 'empty result', missing_policy); + continue + end + + [fit_struct, ok, reason] = local_fit_struct(r, opts.Signature); + if ~ok + skipped = local_skip(skipped, s, subject_id, reason, missing_policy); + continue + end + [fit, ok, reason] = local_select_fit(fit_struct, model_name, source_model); + if ~ok + skipped = local_skip(skipped, s, subject_id, reason, missing_policy); + continue + end + + h = fit.hrf; + try + specA = local_condition_spec(r, opts.ConditionA, 'first'); + catch + skipped = local_skip(skipped, s, subject_id, sprintf('missing ConditionA %s', opts.ConditionA), missing_policy); + continue + end + + y = local_mean_omitnan(h(:, specA.indices), 2)'; + matchedA = local_merge_matches(matchedA, specA.matched_conditions); + if ~isempty(opts.ConditionB) + try + specB = local_condition_spec(r, opts.ConditionB, 'first'); + catch + skipped = local_skip(skipped, s, subject_id, sprintf('missing ConditionB %s', opts.ConditionB), missing_policy); + continue + end + y = y - local_mean_omitnan(h(:, specB.indices), 2)'; + matchedB = local_merge_matches(matchedB, specB.matched_conditions); + end + + if isempty(D) + D = y; + elseif size(D, 2) == numel(y) + D(end + 1, :) = y; %#ok + else + skipped = local_skip(skipped, s, subject_id, 'HRF length mismatch', missing_policy); + continue + end + subject_ids{end + 1, 1} = subject_id; %#ok + run_labels{end + 1, 1} = local_run_label(study, s, subject_id); %#ok +end +end + +function [D, subject_ids, run_to_subject] = local_aggregate_unit(D_runs, run_subject_ids, unit) +switch unit + case 'run' + D = D_runs; + subject_ids = run_subject_ids(:); + run_to_subject = (1:numel(run_subject_ids))'; + case 'subject' + subject_ids = unique(run_subject_ids, 'stable'); + run_to_subject = zeros(numel(run_subject_ids), 1); + for r = 1:numel(run_subject_ids) + run_to_subject(r) = find(strcmp(subject_ids, run_subject_ids{r}), 1); + end + D = nan(numel(subject_ids), size(D_runs, 2)); + for i = 1:numel(subject_ids) + D(i, :) = local_mean_omitnan(D_runs(run_to_subject == i, :), 1); + end + otherwise + error('Unknown Unit: %s. Use ''subject'' or ''run''.', unit); +end +end + +function m = local_mean_omitnan(X, dim) +if nargin < 2, dim = 1; end +valid = ~isnan(X); +den = sum(valid, dim); +X(~valid) = 0; +m = sum(X, dim) ./ den; +m(den == 0) = NaN; +end + +function se = local_sem_omitnan(X, dim) +if nargin < 2, dim = 1; end +mu = local_mean_omitnan(X, dim); +if dim == 1 + centered = X - repmat(mu, size(X, 1), 1); +elseif dim == 2 + centered = X - repmat(mu, 1, size(X, 2)); +else + error('local_sem_omitnan supports dim 1 or 2.'); +end +centered(isnan(centered)) = 0; +n = sum(~isnan(X), dim); +sd = sqrt(sum(centered .^ 2, dim) ./ max(n - 1, 1)); +se = sd ./ sqrt(n); +se(n == 0) = NaN; +end + +function [group_labels, group_labels_by_subject] = local_group_labels(group_input, study, run_subject_ids, subject_ids, run_to_subject, unit) +group_labels = {}; +group_labels_by_subject = {}; +if isempty(group_input) + return +end + +g = cellstr(string(group_input)); +if numel(g) == numel(study.results) + valid_run_groups = g(local_valid_run_indices(study, run_subject_ids)); +elseif numel(g) == numel(run_subject_ids) + valid_run_groups = g(:); +elseif numel(g) == numel(subject_ids) + group_labels = g(:); + group_labels_by_subject = group_labels; + return +else + error('Group labels must match number of study results, valid runs, or tested subjects.'); +end + +group_labels_by_subject = cell(numel(subject_ids), 1); +for i = 1:numel(subject_ids) + vals = unique(valid_run_groups(run_to_subject == i), 'stable'); + group_labels_by_subject{i} = vals{1}; +end + +if strcmp(unit, 'run') + group_labels = valid_run_groups(:); +else + group_labels = group_labels_by_subject; +end +end + +function ix = local_valid_run_indices(study, run_subject_ids) +ix = false(1, numel(study.results)); +remaining = run_subject_ids(:); +for i = 1:numel(study.results) + sid = local_subject_id(study, i); + wh = find(strcmp(remaining, sid), 1); + if ~isempty(wh) + ix(i) = true; + remaining(wh) = []; + end +end +end + +function [T, P] = local_ttest_each_timepoint(D) +n_tp = size(D, 2); +P = nan(1, n_tp); +T = nan(1, n_tp); +for t = 1:n_tp + y = D(:, t); + y = y(~isnan(y)); + if numel(y) < 2 + continue + end + [~, pval, ~, st] = ttest(y); + P(t) = pval; + T(t) = st.tstat; +end +end + +function [T, P] = local_ttest2_each_timepoint(A, B) +n_tp = size(A, 2); +P = nan(1, n_tp); +T = nan(1, n_tp); +for t = 1:n_tp + a = A(:, t); b = B(:, t); + a = a(~isnan(a)); b = b(~isnan(b)); + if numel(a) < 2 || numel(b) < 2 + continue + end + [~, pval, ~, st] = ttest2(a, b, 'Vartype', 'unequal'); + P(t) = pval; + T(t) = st.tstat; +end +end + +function spec = local_condition_spec(r, condition_name, default_mode) +if nargin < 3, default_mode = 'first'; end +specs = hrf_resolve_condition_patterns(r.conditions, condition_name, 'DefaultMode', default_mode); +spec = specs(1); +if isfield(r, 'condition_groups') && ~isempty(r.condition_groups) && isscalar(spec.indices) + idx = spec.indices(1); + if idx <= numel(r.condition_groups) && isfield(r.condition_groups, 'matched_conditions') + spec.matched_conditions = r.condition_groups(idx).matched_conditions; + if isfield(r.condition_groups, 'display_label') + spec.display_label = r.condition_groups(idx).display_label; + end + end +end +end + +function out = local_merge_matches(existing, new_matches) +out = unique([existing(:); new_matches(:)], 'stable'); +end + +function [fit_struct, ok, reason] = local_fit_struct(r, signature_name) +fit_struct = struct(); +ok = false; +reason = ''; +if isempty(signature_name) + if isfield(r, 'fits') + fit_struct = r.fits; + ok = true; + else + reason = 'missing fits'; + end + return +end + +if ~isfield(r, 'fits_by_signature') + reason = 'missing fits_by_signature'; + return +end + +sig_field = local_signature_field(r, signature_name); +if isempty(sig_field) + reason = sprintf('missing signature %s', signature_name); + return +end +fit_struct = r.fits_by_signature.(sig_field); +ok = true; +end + +function [fit, ok, reason] = local_select_fit(fit_struct, model_name, source_model) +fit = struct(); +ok = false; +reason = ''; +model_name = lower(char(model_name)); +source_model = lower(strtrim(char(source_model))); + +if isfield(fit_struct, model_name) + candidate = fit_struct.(model_name); + wanted_source = local_requested_source_model(model_name, source_model, candidate); + if local_fit_matches_source(candidate, wanted_source) + fit = candidate; + ok = true; + else + reason = sprintf('model %s source model mismatch', model_name); + end + return +end + +if isfield(fit_struct, 'mapscore') + candidate = fit_struct.mapscore; + wanted_source = source_model; + if isempty(wanted_source) && ~strcmp(model_name, 'mapscore') + wanted_source = model_name; + end + if local_fit_matches_source(candidate, wanted_source) + fit = candidate; + ok = true; + return + end +end + +if isempty(source_model) + reason = sprintf('missing model %s', model_name); +else + reason = sprintf('missing model %s for source model %s', model_name, source_model); +end +end + +function tf = local_fit_matches_source(fit, source_model) +if isempty(source_model) + tf = true; + return +end +if isfield(fit, 'source_model') && ~isempty(fit.source_model) + tf = strcmpi(char(fit.source_model), source_model); +else + tf = false; +end +end + +function source_model = local_requested_source_model(model_name, source_model, fit) +if isempty(source_model) && local_is_wholebrain_model_name(model_name) && ... + isfield(fit, 'source_model') && ~isempty(fit.source_model) + source_model = model_name; +end +end + +function tf = local_is_wholebrain_model_name(model_name) +tf = ismember(lower(strtrim(char(model_name))), {'fir', 'sfir', 'canonical', 'spline'}); +end + +function sig_field = local_signature_field(r, sig) +sig_field = ''; +if isfield(r.fits_by_signature, sig) + sig_field = sig; + return +end +candidate = matlab.lang.makeValidName(sig); +if isfield(r.fits_by_signature, candidate) + sig_field = candidate; + return +end +if isfield(r, 'signature_meta') && isfield(r.signature_meta, 'selected_signatures') && ... + isfield(r.signature_meta, 'selected_signature_fields') + names = cellstr(string(r.signature_meta.selected_signatures)); + fields = cellstr(string(r.signature_meta.selected_signature_fields)); + idx = find(strcmp(names, sig), 1); + if ~isempty(idx) && idx <= numel(fields) && isfield(r.fits_by_signature, fields{idx}) + sig_field = fields{idx}; + end +end +end + +function skipped = local_skip(skipped, idx, subject_id, reason, missing_policy) +if strcmp(missing_policy, 'error') + error('Skipping is disabled. Result %d (%s): %s', idx, subject_id, reason); +elseif ~strcmp(missing_policy, 'warn') && ~strcmp(missing_policy, 'silent') + error('Unknown MissingPolicy: %s. Use ''warn'', ''silent'', or ''error''.', missing_policy); +end + +skipped(end + 1) = struct('index', idx, 'subject', subject_id, 'reason', reason); +if strcmp(missing_policy, 'warn') + warning('hrf_time_unfolding_stats:SkippingResult', 'Skipping result %d (%s): %s', idx, subject_id, reason); +end +end + +function subject_id = local_subject_id(study, idx) +if isfield(study, 'subject_ids') && numel(study.subject_ids) >= idx + subject_id = char(study.subject_ids{idx}); +else + subject_id = sprintf('sub-%03d', idx); +end +end + +function run_label = local_run_label(study, idx, subject_id) +if isfield(study, 'run_labels') && numel(study.run_labels) >= idx && ~isempty(study.run_labels{idx}) + run_label = char(study.run_labels{idx}); +else + run_label = sprintf('%s_run%02d', subject_id, idx); +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_validate_spm_inputs.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_validate_spm_inputs.m new file mode 100644 index 00000000..b1cbeb22 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_validate_spm_inputs.m @@ -0,0 +1,198 @@ +function report = hrf_validate_spm_inputs(fmri_files, spm_files, varargin) +% Pre-flight check that per-subject SPM.mat files line up with fMRI inputs +% for exact Tier B GKWY conditioning (see hrf_fit_wholebrain_stats). +% +% For each fMRI file / SPM.mat pair it confirms the SPM is ESTIMATED (carries +% the whitening xX.W, high-pass xX.K, and global scaling xGX.gSF) and that the +% fMRI time-point count matches the SPM in one of two ways: +% - 'full-session' : n_tp == total SPM scans (sum over concatenated runs) +% - 'single-run' : n_tp == the SPMRun-th run's scan count +% Anything else is flagged so it fails here, on the login node, rather than +% mid-array on the cluster. +% +% :Usage: +% :: +% report = hrf_validate_spm_inputs(fmri_files, spm_files, ['SPMRuns', runs]) +% +% :Inputs: +% +% **fmri_files:** +% cellstr / string array of 4D fMRI paths (one per task), .nii or .nii.gz. +% +% **spm_files:** +% cellstr / string array of estimated SPM.mat paths, one per fMRI file. +% An empty entry ('' ) means "no SPM for this file" (Tier A fallback) and +% is reported as 'no-spm' (not an error). +% +% :Optional Inputs: +% +% **'SPMRuns':** +% numeric vector, one run index per fMRI file (default all 1). Only used +% when the fMRI is a single run of a multi-run (e.g. per-session) SPM. +% +% **'Verbose' / 'doverbose':** +% logical, print a per-row table and summary (default true). +% +% :Outputs: +% +% **report:** +% table with one row per input: index, fmri_file, spm_file, spm_run, +% n_tp, total_scans, run_scans, mode, status, ok, message. `ok` is true +% only for 'full-session' or 'single-run'. +% +% :Examples: +% :: +% % Build per-subject SPM paths, then validate before submitting SLURM. +% spm_files = cellfun(@(s) fullfile(spm_root, s, 'SPM.mat'), subject_ids, 'unif', 0); +% report = hrf_validate_spm_inputs(fmri_files, spm_files); +% assert(all(report.ok), 'Fix the flagged SPM/fMRI mismatches before launching.'); +% +% See also: hrf_fit_wholebrain_stats, hrf_write_slurm_study_script, spmify. + +% ----------------------------- parse inputs ------------------------------ +p = inputParser; +p.addRequired('fmri_files', @(x) ischar(x) || iscell(x) || isstring(x)); +p.addRequired('spm_files', @(x) ischar(x) || iscell(x) || isstring(x)); +p.addParameter('SPMRuns', [], @(x) isempty(x) || isnumeric(x)); +p.addParameter('Verbose', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('doverbose', [], @(x) isempty(x) || islogical(x) || isnumeric(x)); +p.parse(fmri_files, spm_files, varargin{:}); +opts = p.Results; +verbose = logical(opts.Verbose); +if ~isempty(opts.doverbose), verbose = logical(opts.doverbose); end + +fmri_files = local_to_cellstr(fmri_files); +spm_files = local_to_cellstr(spm_files); +n = numel(fmri_files); +if numel(spm_files) ~= n + error('hrf_validate_spm_inputs:LengthMismatch', ... + 'fmri_files (%d) and spm_files (%d) must have the same length.', n, numel(spm_files)); +end +spm_runs = opts.SPMRuns; +if isempty(spm_runs), spm_runs = ones(1, n); elseif isscalar(spm_runs), spm_runs = repmat(spm_runs, 1, n); end +if numel(spm_runs) ~= n + error('hrf_validate_spm_inputs:SPMRunsLength', 'SPMRuns must be empty, scalar, or length %d.', n); +end + +% ----------------------------- per-row check ----------------------------- +idx = (1:n)'; +fmri_col = fmri_files(:); +spm_col = spm_files(:); +spm_run_col = spm_runs(:); +n_tp = nan(n, 1); total_scans = nan(n, 1); run_scans = nan(n, 1); +mode = strings(n, 1); status = strings(n, 1); ok = false(n, 1); message = strings(n, 1); + +for i = 1:n + [n_tp(i), total_scans(i), run_scans(i), mode(i), status(i), ok(i), message(i)] = ... + local_check_one(fmri_files{i}, spm_files{i}, spm_runs(i)); +end + +report = table(idx, fmri_col, spm_col, spm_run_col, n_tp, total_scans, run_scans, mode, status, ok, message, ... + 'VariableNames', {'index', 'fmri_file', 'spm_file', 'spm_run', 'n_tp', ... + 'total_scans', 'run_scans', 'mode', 'status', 'ok', 'message'}); + +if verbose + disp(report(:, {'index', 'spm_run', 'n_tp', 'total_scans', 'run_scans', 'status'})); + n_ok = sum(ok); n_nospm = sum(status == "no-spm"); n_bad = n - n_ok - n_nospm; + fprintf('hrf_validate_spm_inputs: %d/%d ok (%d Tier B), %d no-spm (Tier A), %d PROBLEM.\n', ... + n_ok, n, n_ok, n_nospm, n_bad); + if n_bad > 0 + bad = report(~ok & status ~= "no-spm", :); + for b = 1:height(bad) + fprintf(' [%d] %s -> %s: %s\n', bad.index(b), local_short(bad.fmri_file{b}), ... + bad.status(b), bad.message(b)); + end + end +end +end + + +% ========================================================================= +function [n_tp, total_scans, run_scans, mode, status, ok, msg] = local_check_one(fmri_file, spm_file, run) +n_tp = NaN; total_scans = NaN; run_scans = NaN; +mode = ""; status = ""; ok = false; msg = ""; + +% --- fMRI time points --- +try + n_tp = local_ntp(fmri_file); +catch err + status = "fmri-unreadable"; msg = string(err.message); return +end + +% --- SPM present? --- +if isempty(strtrim(char(spm_file))) + status = "no-spm"; msg = "no SPM supplied (Tier A high-pass fallback)"; return +end +if exist(char(spm_file), 'file') ~= 2 + status = "spm-missing"; msg = "SPM.mat path does not exist"; return +end + +% --- SPM estimated? --- +try + S = load(char(spm_file), 'SPM'); +catch err + status = "spm-unloadable"; msg = string(err.message); return +end +if ~isfield(S, 'SPM') + status = "spm-no-variable"; msg = "file has no SPM variable"; return +end +SPM = S.SPM; +if ~isfield(SPM, 'xX') || ~isfield(SPM.xX, 'K') || ~isfield(SPM.xX, 'W') + status = "spm-not-estimated"; msg = "missing xX.K / xX.W (run spm_spm first)"; return +end +if ~isfield(SPM, 'xGX') || ~isfield(SPM.xGX, 'gSF') || isempty(SPM.xGX.gSF) + msg = "xGX.gSF missing -- K and W will apply, global scaling skipped"; +end + +% --- scan-count match --- +K = SPM.xX.K; +total_scans = size(SPM.xX.W, 1); +if run >= 1 && run <= numel(K) + run_scans = numel(K(run).row); +end + +if n_tp == total_scans + mode = "full-session"; ok = true; + if msg == "", msg = sprintf("matches full SPM (%d scans across %d run(s))", total_scans, numel(K)); end +elseif ~isnan(run_scans) && n_tp == run_scans + mode = "single-run"; ok = true; + if msg == "", msg = sprintf("matches SPM run %d (%d scans)", run, run_scans); end +else + status = "scan-count-mismatch"; + msg = sprintf("fMRI=%d tp; full SPM=%d; run %d=%s. Set SPMRuns or pass the concatenated-session fMRI.", ... + n_tp, total_scans, run, local_num2str(run_scans)); + return +end +status = mode; +end + +function n_tp = local_ntp(fmri_file) +info = niftiinfo(char(fmri_file)); +sz = info.ImageSize; +if numel(sz) < 4 + error('expected a 4D image, got %d dims', numel(sz)); +end +n_tp = sz(4); +end + +function c = local_to_cellstr(x) +if ischar(x) + c = {x}; +elseif isstring(x) + c = cellstr(x); +elseif iscell(x) + c = cellfun(@(v) char(string(v)), x, 'UniformOutput', false); +else + error('hrf_validate_spm_inputs:BadType', 'Expected char, string, or cell of paths.'); +end +c = c(:)'; +end + +function s = local_short(p) +[~, nm, ext] = fileparts(char(p)); +s = [nm ext]; +end + +function s = local_num2str(x) +if isnan(x), s = 'n/a'; else, s = num2str(x); end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_write_slurm_study_script.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_write_slurm_study_script.m new file mode 100644 index 00000000..14dd3aa3 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/hrf_write_slurm_study_script.m @@ -0,0 +1,578 @@ +function paths = hrf_write_slurm_study_script(fmri_files, events_files, subject_ids, varargin) +%HRF_WRITE_SLURM_STUDY_SCRIPT Write a SLURM array job for HRF study fitting. +% +% paths = hrf_write_slurm_study_script(fmri_files, events_files, subject_ids, ...) +% +% The generated job runs one fMRI/events pair per SLURM array task. Each +% task calls run_hrf_pipeline, writes 4D whole-brain beta/T statistic_image +% files, and optionally applies all requested signatures/imagesets to those +% 4D maps. + +if ischar(fmri_files) || isstring(fmri_files), fmri_files = cellstr(fmri_files); end +if ischar(events_files) || isstring(events_files), events_files = cellstr(events_files); end +if nargin < 3 || isempty(subject_ids) + subject_ids = arrayfun(@(i) sprintf('sub-%03d', i), 1:numel(fmri_files), 'UniformOutput', false); +end +if isstring(subject_ids), subject_ids = cellstr(subject_ids); end + +n = numel(fmri_files); +if numel(events_files) ~= n + error('fmri_files and events_files must contain the same number of files.'); +end +if numel(subject_ids) ~= n + error('subject_ids must be empty or contain one id per fMRI file.'); +end + +p = inputParser; +p.addParameter('OutputDir', fullfile(pwd, 'hrf_outputs'), @(x) ischar(x) || isstring(x)); +p.addParameter('ScriptFile', '', @(x) ischar(x) || isstring(x)); +p.addParameter('ManifestFile', '', @(x) ischar(x) || isstring(x)); +p.addParameter('WorkerFile', '', @(x) ischar(x) || isstring(x)); +p.addParameter('ConfigMat', '', @(x) ischar(x) || isstring(x)); +p.addParameter('RunLabels', {}, @(x) isempty(x) || ischar(x) || iscell(x) || isstring(x)); +p.addParameter('CanlabRoot', local_default_canlab_root(), @(x) ischar(x) || isstring(x)); +p.addParameter('ExtraMatlabPaths', {}, @(x) ischar(x) || iscell(x) || isstring(x)); +p.addParameter('PipelineArgs', {}, @(x) iscell(x)); +% Per-subject estimated SPM.mat paths for exact Tier B g*K*W matching (one +% per fMRI file; '' = none for that row). SPMRuns picks the run within a +% multi-run SPM (default 1 each). See hrf_fit_wholebrain_stats. +p.addParameter('SPMFiles', {}, @(x) isempty(x) || ischar(x) || iscell(x) || isstring(x)); +p.addParameter('SPMRuns', [], @(x) isempty(x) || isnumeric(x)); +p.addParameter('SignatureSets', {}, @(x) ischar(x) || iscell(x) || isstring(x)); +p.addParameter('ImageSets', {}, @(x) ischar(x) || iscell(x) || isstring(x) || isa(x, 'image_vector')); +p.addParameter('AtlasObj', [], @(x) isempty(x) || isa(x, 'atlas') || isa(x, 'image_vector')); +p.addParameter('AtlasName', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Regions', {}, @(x) iscell(x) || isstring(x) || ischar(x)); +% Multi-atlas: score several atlases in one job. Give each a DISTINCT name in +% AtlasNames so their region columns do not collide. AtlasRegions is a cell of +% per-atlas region lists ({} = all regions for that atlas). +p.addParameter('AtlasObjs', {}, @(x) isempty(x) || iscell(x) || isa(x, 'atlas') || isa(x, 'image_vector')); +p.addParameter('AtlasNames', {}, @(x) isempty(x) || iscell(x) || ischar(x) || isstring(x)); +p.addParameter('AtlasRegions', {}, @(x) isempty(x) || iscell(x)); +p.addParameter('Normalize', 'mean', @(x) ischar(x) || isstring(x)); +p.addParameter('ScoreObjects', {'beta'}, @(x) ischar(x) || iscell(x) || isstring(x)); +p.addParameter('SimilarityMetric', 'dotproduct', @(x) ischar(x) || isstring(x)); +p.addParameter('MakeAnimations', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('AnimationObject', 't', @(x) ischar(x) || isstring(x)); +p.addParameter('AnimationCondition', '', @(x) ischar(x) || isstring(x)); +p.addParameter('AnimationFrameRate', 4, @(x) isscalar(x) && x > 0); +p.addParameter('JobName', 'hrf_study', @(x) ischar(x) || isstring(x)); +p.addParameter('Time', '24:00:00', @(x) ischar(x) || isstring(x)); +p.addParameter('Mem', '32G', @(x) ischar(x) || isstring(x)); +p.addParameter('CpusPerTask', 1, @(x) isscalar(x) && x >= 1); +p.addParameter('Partition', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Account', '', @(x) ischar(x) || isstring(x)); +p.addParameter('LogDir', '', @(x) ischar(x) || isstring(x)); +p.addParameter('ModuleLoad', '', @(x) ischar(x) || isstring(x)); +p.addParameter('MatlabCommand', 'matlab', @(x) ischar(x) || isstring(x)); +p.addParameter('ExtraSlurmDirectives', {}, @(x) ischar(x) || iscell(x) || isstring(x)); +p.parse(varargin{:}); +opts = p.Results; + +output_dir = char(opts.OutputDir); +if ~exist(output_dir, 'dir'), mkdir(output_dir); end + +log_dir = char(opts.LogDir); +if isempty(log_dir) + log_dir = fullfile(output_dir, 'logs'); +end +if ~exist(log_dir, 'dir'), mkdir(log_dir); end + +paths = local_default_paths(output_dir, opts); +local_ensure_parent(paths.script_file); +local_ensure_parent(paths.manifest_file); +local_ensure_parent(paths.worker_file); +local_ensure_parent(paths.config_mat); + +spm_files = local_normalize_spm_files(opts.SPMFiles, n); +spm_runs = local_normalize_spm_runs(opts.SPMRuns, n); +manifest = local_manifest_table(fmri_files, events_files, subject_ids, output_dir, opts.RunLabels, spm_files, spm_runs); +writetable(manifest, paths.manifest_file); + +config = struct(); +config.canlab_root = char(opts.CanlabRoot); +config.extra_matlab_paths = local_to_cell(opts.ExtraMatlabPaths); +config.subject_ids = subject_ids(:); +config.run_labels = manifest.run_label(:); +config.fmri_files = fmri_files(:); +config.events_files = events_files(:); +config.output_prefixes = manifest.output_prefix(:); +config.output_mats = manifest.output_mat(:); +config.spm_files = spm_files(:); +config.spm_runs = spm_runs(:); +[pipeline_args, pipeline_note] = local_normalize_pipeline_args(opts.PipelineArgs); +config.pipeline_args = pipeline_args; +config.pipeline_note = pipeline_note; +config.signature_sets = local_to_cell(opts.SignatureSets); +config.image_sets = local_to_cell(opts.ImageSets); +config.atlas_obj = opts.AtlasObj; +config.atlas_name = char(opts.AtlasName); +config.regions = local_to_cell(opts.Regions); +config.atlas_objs = local_to_cell(opts.AtlasObjs); +config.atlas_names = local_to_cell(opts.AtlasNames); +config.atlas_regions = opts.AtlasRegions; +config.normalize = char(opts.Normalize); +config.score_objects = local_to_cell(opts.ScoreObjects); +config.similarity_metric = char(opts.SimilarityMetric); +config.make_animations = logical(opts.MakeAnimations); +config.animation_object = char(opts.AnimationObject); +config.animation_condition = char(opts.AnimationCondition); +config.animation_frame_rate = opts.AnimationFrameRate; +save(paths.config_mat, 'config'); + +local_write_worker(paths.worker_file, paths.manifest_file, paths.config_mat, ... + char(opts.CanlabRoot), local_to_cell(opts.ExtraMatlabPaths)); +local_write_sbatch(paths.script_file, paths.worker_file, n, log_dir, opts); + +paths.message = sprintf(['Successfully wrote HRF SLURM study files for %d task(s):\n' ... + ' sbatch: %s\n worker: %s\n manifest: %s\n config: %s'], ... + n, paths.script_file, paths.worker_file, paths.manifest_file, paths.config_mat); +if ~isempty(pipeline_note) + paths.message = sprintf('%s\n%s', paths.message, pipeline_note); +end +fprintf('%s\n', paths.message); +end + +function [pipeline_args, note] = local_normalize_pipeline_args(pipeline_args) +note = ''; +if isempty(pipeline_args), return; end + +models = local_arg_value(pipeline_args, 'Models'); +has_wholebrain_mode = local_has_arg(pipeline_args, 'WholeBrainMode'); +if ~isempty(models) + model_names = lower(cellstr(string(models))); + if ~has_wholebrain_mode + pipeline_args = [pipeline_args, {'WholeBrainMode', 'auto'}]; + note = sprintf(['Note: PipelineArgs did not include WholeBrainMode, so the SLURM worker will use ' ... + 'WholeBrainMode=''auto'' and write one 4D whole-brain map set for each requested supported linear model.']); + end + + nonlinear = intersect(model_names, {'logit', 'nlgamma'}, 'stable'); + if ~isempty(nonlinear) + extra = sprintf(['Note: Models={%s} affects the 1D extracted-signal fits saved in the MAT results, ' ... + 'but nonlinear logit/nlgamma whole-brain maps are skipped by the fast 4D writer.'], ... + strjoin(nonlinear, ', ')); + if isempty(note), note = extra; else, note = sprintf('%s\n%s', note, extra); end + end +end +end + +function tf = local_has_arg(args, name) +tf = false; +for i = 1:2:numel(args) + if ischar(args{i}) || isstring(args{i}) + if strcmpi(char(args{i}), name) + tf = true; + return + end + end +end +end + +function value = local_arg_value(args, name) +value = []; +for i = 1:2:numel(args) + if ischar(args{i}) || isstring(args{i}) + if strcmpi(char(args{i}), name) && i < numel(args) + value = args{i + 1}; + return + end + end +end +end + +function paths = local_default_paths(output_dir, opts) +script_file = char(opts.ScriptFile); +if isempty(script_file), script_file = fullfile(output_dir, 'run_hrf_study.sbatch'); end + +manifest_file = char(opts.ManifestFile); +if isempty(manifest_file), manifest_file = fullfile(output_dir, 'hrf_study_manifest.csv'); end + +worker_file = char(opts.WorkerFile); +if isempty(worker_file), worker_file = fullfile(output_dir, 'run_hrf_study_worker.m'); end + +config_mat = char(opts.ConfigMat); +if isempty(config_mat), config_mat = fullfile(output_dir, 'hrf_study_config.mat'); end + +paths = struct('script_file', script_file, 'manifest_file', manifest_file, ... + 'worker_file', worker_file, 'config_mat', config_mat); +end + +function T = local_manifest_table(fmri_files, events_files, subject_ids, output_dir, run_labels_input, spm_files, spm_runs) +n = numel(fmri_files); +subject = cell(n, 1); +run_label = cell(n, 1); +fmri_file = cell(n, 1); +events_file = cell(n, 1); +output_prefix = cell(n, 1); +output_mat = cell(n, 1); +spm_file = cell(n, 1); +spm_run = zeros(n, 1); +run_labels = local_run_labels(fmri_files, subject_ids, run_labels_input); + +for i = 1:n + subject_label = local_file_label(subject_ids{i}); + run_label{i} = run_labels{i}; + output_label = local_file_label([subject_label '_' run_labels{i}]); + subject{i} = char(subject_ids{i}); + fmri_file{i} = char(fmri_files{i}); + events_file{i} = char(events_files{i}); + output_prefix{i} = fullfile(output_dir, [output_label '_hrf']); + output_mat{i} = fullfile(output_dir, [output_label '_hrf_results.mat']); + spm_file{i} = spm_files{i}; + spm_run(i) = spm_runs(i); +end + +T = table((1:n)', subject, run_label, fmri_file, events_file, output_prefix, output_mat, spm_file, spm_run, ... + 'VariableNames', {'index', 'subject', 'run_label', 'fmri_file', 'events_file', 'output_prefix', 'output_mat', 'spm_file', 'spm_run'}); +end + +function spm_files = local_normalize_spm_files(spm_files, n) +% Per-subject SPM input -> n-element cellstr ('' = none). +if isempty(spm_files) + spm_files = repmat({''}, 1, n); + return +end +if ischar(spm_files) || isstring(spm_files) + spm_files = cellstr(string(spm_files)); +end +if isscalar(spm_files) + spm_files = repmat(spm_files(:)', 1, n); +end +spm_files = cellfun(@(x) char(string(x)), spm_files, 'UniformOutput', false); +if numel(spm_files) ~= n + error('SPMFiles must be empty, scalar, or contain one entry per fMRI file (got %d, need %d).', numel(spm_files), n); +end +spm_files = reshape(spm_files, 1, n); +end + +function spm_runs = local_normalize_spm_runs(spm_runs, n) +if isempty(spm_runs) + spm_runs = ones(1, n); +elseif isscalar(spm_runs) + spm_runs = repmat(spm_runs, 1, n); +end +if numel(spm_runs) ~= n + error('SPMRuns must be empty, scalar, or contain one value per fMRI file.'); +end +spm_runs = reshape(spm_runs, 1, n); +end + +function run_labels = local_run_labels(fmri_files, subject_ids, run_labels_input) +n = numel(fmri_files); +if ~isempty(run_labels_input) + run_labels = cellstr(string(run_labels_input)); + if numel(run_labels) ~= n + error('RunLabels must contain one label per fMRI file.'); + end + run_labels = cellfun(@local_file_label, run_labels, 'UniformOutput', false); +else + run_labels = cell(n, 1); + for i = 1:n + run_labels{i} = local_run_label_from_filename(fmri_files{i}, i); + end +end + +seen = containers.Map('KeyType', 'char', 'ValueType', 'double'); +for i = 1:n + key = sprintf('%s__%s', char(subject_ids{i}), run_labels{i}); + if isKey(seen, key) + seen(key) = seen(key) + 1; + run_labels{i} = sprintf('%s_dup%02d', run_labels{i}, seen(key)); + else + seen(key) = 1; + end +end +end + +function label = local_run_label_from_filename(fmri_file, idx) +[~, name, ext] = fileparts(char(fmri_file)); +if strcmpi(ext, '.gz') + [~, name] = fileparts(name); +end +parts = {}; +tokens = {'ses', 'task', 'run', 'acq', 'desc'}; +for i = 1:numel(tokens) + tok = regexp(name, [tokens{i} '-[^_]+'], 'match', 'once'); + if ~isempty(tok) + parts{end + 1} = tok; %#ok + end +end +if isempty(parts) + label = sprintf('run-%03d', idx); +else + label = strjoin(parts, '_'); +end +label = local_file_label(label); +end + +function local_write_worker(worker_file, manifest_file, config_mat, canlab_root, extra_paths) +fid = fopen(worker_file, 'w'); +if fid == -1, error('Could not write worker file: %s', worker_file); end +c = onCleanup(@() fclose(fid)); + +fprintf(fid, '%% Auto-generated by hrf_write_slurm_study_script.m\n'); +% addpath MUST come before load(config_mat). load() reconstructs classdef +% objects in the saved struct (e.g., the atlas object passed via AtlasObj); +% if the @atlas class folder isn't on path at load-time, the object either +% silently strips to a plain struct or never makes it onto config.atlas_obj +% at all. canlab_root and any ExtraMatlabPaths are known at generation +% time, so they're written as literals. +fprintf(fid, 'addpath(genpath(strrep(''%s'', ''\\'', filesep)));\n', local_matlab_string(canlab_root)); +for i = 1:numel(extra_paths) + p_str = char(string(extra_paths{i})); + if ~isempty(p_str) + fprintf(fid, 'addpath(genpath(strrep(''%s'', ''\\'', filesep)));\n', local_matlab_string(p_str)); + end +end +fprintf(fid, 'manifest_file = strrep(''%s'', ''\\'', filesep);\n', local_matlab_string(manifest_file)); +fprintf(fid, 'config_mat = strrep(''%s'', ''\\'', filesep);\n', local_matlab_string(config_mat)); +fprintf(fid, 'load(config_mat, ''config'');\n'); +fprintf(fid, '%% (Redundant addpath in case canlab_root differs between generation and run.)\n'); +fprintf(fid, 'if isfield(config, ''canlab_root'') && ~isempty(config.canlab_root)\n'); +fprintf(fid, ' addpath(genpath(config.canlab_root));\n'); +fprintf(fid, 'end\n'); +fprintf(fid, 'if isfield(config, ''extra_matlab_paths'')\n'); +fprintf(fid, ' for path_idx = 1:numel(config.extra_matlab_paths)\n'); +fprintf(fid, ' extra_path = local_cell_at(config.extra_matlab_paths, path_idx);\n'); +fprintf(fid, ' if ~isempty(extra_path), addpath(genpath(strrep(extra_path, ''\\'', filesep))); end\n'); +fprintf(fid, ' end\n'); +fprintf(fid, 'end\n'); +fprintf(fid, 'task_id = str2double(getenv(''SLURM_ARRAY_TASK_ID''));\n'); +fprintf(fid, 'if isnan(task_id) || task_id < 1, task_id = str2double(getenv(''TASK_ID'')); end\n'); +fprintf(fid, 'if isnan(task_id) || task_id < 1, task_id = 1; end\n'); +fprintf(fid, 'if isfield(config, ''fmri_files'') && isfield(config, ''output_prefixes'')\n'); +fprintf(fid, ' n_tasks = numel(config.fmri_files);\n'); +fprintf(fid, ' if task_id > n_tasks, error(''Task id %%d exceeds configured tasks %%d.'', task_id, n_tasks); end\n'); +fprintf(fid, ' subject = local_cell_at(config.subject_ids, task_id);\n'); +fprintf(fid, ' run_label = local_cell_at(config.run_labels, task_id);\n'); +fprintf(fid, ' fmri_file = strrep(local_cell_at(config.fmri_files, task_id), ''\\'', filesep);\n'); +fprintf(fid, ' events_file = strrep(local_cell_at(config.events_files, task_id), ''\\'', filesep);\n'); +fprintf(fid, ' output_prefix = strrep(local_cell_at(config.output_prefixes, task_id), ''\\'', filesep);\n'); +fprintf(fid, ' output_mat = strrep(local_cell_at(config.output_mats, task_id), ''\\'', filesep);\n'); +fprintf(fid, ' if isfield(config, ''spm_files'') && numel(config.spm_files) >= task_id, spm_file = strrep(local_cell_at(config.spm_files, task_id), ''\\'', filesep); else, spm_file = ''''; end\n'); +fprintf(fid, ' if isfield(config, ''spm_runs'') && numel(config.spm_runs) >= task_id, spm_run = config.spm_runs(task_id); else, spm_run = 1; end\n'); +fprintf(fid, 'else\n'); +fprintf(fid, ' [subject, run_label, fmri_file, events_file, output_prefix, output_mat, spm_file, spm_run, n_tasks] = local_manifest_row(manifest_file, task_id);\n'); +fprintf(fid, 'end\n'); +fprintf(fid, 'if isempty(run_label), run_label = sprintf(''task-%%03d'', task_id); end\n'); +fprintf(fid, 'fprintf(''Running HRF task %%d/%%d: %%s | %%s\\n'', task_id, n_tasks, subject, run_label);\n'); +fprintf(fid, 'args = [config.pipeline_args, {''WriteWholeBrain'', true, ''WholeBrainOutputPrefix'', output_prefix, ''OutputMat'', output_mat}];\n'); +fprintf(fid, 'if exist(''spm_file'', ''var'') && ~isempty(spm_file)\n'); +fprintf(fid, ' args = [args, {''WholeBrainSPM'', spm_file, ''WholeBrainSPMRun'', spm_run}];\n'); +fprintf(fid, ' fprintf('' Tier B GKWY from SPM.mat: %%s (run %%d)\\n'', spm_file, spm_run);\n'); +fprintf(fid, 'end\n'); +fprintf(fid, 'results = run_hrf_pipeline(fmri_file, events_file, args{:});\n'); +fprintf(fid, 'has_atlas_objs = isfield(config, ''atlas_objs'') && ~isempty(config.atlas_objs);\n'); +fprintf(fid, 'if ~isempty(config.signature_sets) || ~isempty(config.image_sets) || ~isempty(config.atlas_obj) || has_atlas_objs\n'); +fprintf(fid, ' wholebrain_models = local_wholebrain_models(results);\n'); +fprintf(fid, ' for mi = 1:numel(wholebrain_models)\n'); +fprintf(fid, ' model_name = wholebrain_models(mi).name;\n'); +fprintf(fid, ' model_stats = wholebrain_models(mi).stats;\n'); +fprintf(fid, ' score_context = sprintf(''task=%%d; subject=%%s; run=%%s'', task_id, subject, run_label);\n'); +fprintf(fid, ' try\n'); +fprintf(fid, ' score_status = hrf_score_one_prefix(output_prefix, ...\n'); +fprintf(fid, ' ''ModelName'', model_name, ...\n'); +fprintf(fid, ' ''NumModels'', numel(wholebrain_models), ...\n'); +fprintf(fid, ' ''ScoreObjects'', config.score_objects, ...\n'); +fprintf(fid, ' ''SignatureSets'', config.signature_sets, ...\n'); +fprintf(fid, ' ''ImageSets'', config.image_sets, ...\n'); +fprintf(fid, ' ''AtlasObj'', config.atlas_obj, ...\n'); +fprintf(fid, ' ''AtlasName'', config.atlas_name, ...\n'); +fprintf(fid, ' ''Regions'', config.regions, ...\n'); +fprintf(fid, ' ''AtlasObjs'', local_config_field(config, ''atlas_objs'', {}), ...\n'); +fprintf(fid, ' ''AtlasNames'', local_config_field(config, ''atlas_names'', {}), ...\n'); +fprintf(fid, ' ''AtlasRegions'', local_config_field(config, ''atlas_regions'', {}), ...\n'); +fprintf(fid, ' ''Normalize'', config.normalize, ...\n'); +fprintf(fid, ' ''SimilarityMetric'', config.similarity_metric, ...\n'); +fprintf(fid, ' ''StatsInput'', model_stats, ...\n'); +fprintf(fid, ' ''Overwrite'', true, ...\n'); +fprintf(fid, ' ''WarningContext'', score_context);\n'); +fprintf(fid, ' for ei = 1:numel(score_status.errors)\n'); +fprintf(fid, ' warning(''hrf_slurm_worker:ScoreError'', ''Score failure for model=%%s, object=%%s: %%s'', model_name, score_status.errors(ei).object, score_status.errors(ei).message);\n'); +fprintf(fid, ' end\n'); +fprintf(fid, ' catch score_err\n'); +fprintf(fid, ' warning(''hrf_slurm_worker:ScoreFailed'', ''Scoring failed for model=%%s (task %%d): %%s'', model_name, task_id, score_err.message);\n'); +fprintf(fid, ' end\n'); +fprintf(fid, ' end\n'); +fprintf(fid, 'end\n'); +fprintf(fid, 'if config.make_animations\n'); +fprintf(fid, ' movie_file = sprintf(''%%s_%%s_montage.mp4'', output_prefix, config.animation_object);\n'); +fprintf(fid, ' wholebrain_models = local_wholebrain_models(results);\n'); +fprintf(fid, ' hrf_animate_wholebrain_stats(wholebrain_models(1).stats, ''Object'', config.animation_object, ...\n'); +fprintf(fid, ' ''Condition'', config.animation_condition, ''FrameRate'', config.animation_frame_rate, ...\n'); +fprintf(fid, ' ''OutputFile'', movie_file);\n'); +fprintf(fid, 'end\n'); +fprintf(fid, '\n'); +fprintf(fid, 'function models = local_wholebrain_models(results)\n'); +fprintf(fid, 'models = struct(''name'', {}, ''stats'', {});\n'); +fprintf(fid, 'if isfield(results, ''wholebrain_by_model'') && ~isempty(results.wholebrain_by_model)\n'); +fprintf(fid, ' fields = fieldnames(results.wholebrain_by_model);\n'); +fprintf(fid, ' for ii = 1:numel(fields)\n'); +fprintf(fid, ' models(end + 1) = struct(''name'', fields{ii}, ''stats'', results.wholebrain_by_model.(fields{ii})); %%#ok\n'); +fprintf(fid, ' end\n'); +fprintf(fid, 'elseif isfield(results, ''wholebrain'')\n'); +fprintf(fid, ' models = struct(''name'', ''wholebrain'', ''stats'', results.wholebrain);\n'); +fprintf(fid, 'else\n'); +fprintf(fid, ' error(''run_hrf_pipeline did not return wholebrain outputs.'');\n'); +fprintf(fid, 'end\n'); +fprintf(fid, 'end\n\n'); +fprintf(fid, 'function val = local_cell_at(values, idx)\n'); +fprintf(fid, 'if iscell(values), val = values{idx}; else, val = values(idx); end\n'); +fprintf(fid, 'val = local_cell_to_char(val);\n'); +fprintf(fid, 'end\n\n'); +fprintf(fid, 'function val = local_config_field(config, name, default_val)\n'); +fprintf(fid, 'if isfield(config, name), val = config.(name); else, val = default_val; end\n'); +fprintf(fid, 'end\n\n'); +fprintf(fid, 'function [subject, run_label, fmri_file, events_file, output_prefix, output_mat, spm_file, spm_run, n_tasks] = local_manifest_row(manifest_file, task_id)\n'); +fprintf(fid, 'raw = local_read_manifest(manifest_file);\n'); +fprintf(fid, 'if isempty(raw) || size(raw, 2) < 5, error(''Manifest must have at least 5 columns.''); end\n'); +fprintf(fid, 'data_start = 1 + local_has_manifest_header(raw(1, :));\n'); +fprintf(fid, 'n_tasks = size(raw, 1) - data_start + 1;\n'); +fprintf(fid, 'if task_id > n_tasks, error(''Task id %%d exceeds manifest rows %%d.'', task_id, n_tasks); end\n'); +fprintf(fid, 'row_idx = data_start + task_id - 1;\n'); +fprintf(fid, 'headers = local_manifest_headers(raw, data_start);\n'); +fprintf(fid, 'subject = local_manifest_value(raw, headers, row_idx, ''subject'', 2);\n'); +fprintf(fid, 'run_label = local_manifest_value(raw, headers, row_idx, ''run_label'', 3);\n'); +fprintf(fid, 'fmri_file = strrep(local_manifest_value(raw, headers, row_idx, ''fmri_file'', 4), ''\\'', filesep);\n'); +fprintf(fid, 'events_file = strrep(local_manifest_value(raw, headers, row_idx, ''events_file'', 5), ''\\'', filesep);\n'); +fprintf(fid, 'output_prefix = strrep(local_manifest_value(raw, headers, row_idx, ''output_prefix'', 6), ''\\'', filesep);\n'); +fprintf(fid, 'output_mat = strrep(local_manifest_value(raw, headers, row_idx, ''output_mat'', 7), ''\\'', filesep);\n'); +fprintf(fid, 'spm_file = strrep(local_manifest_value(raw, headers, row_idx, ''spm_file'', 8), ''\\'', filesep);\n'); +fprintf(fid, 'spm_run = str2double(local_manifest_value(raw, headers, row_idx, ''spm_run'', 9));\n'); +fprintf(fid, 'if isnan(spm_run) || spm_run < 1, spm_run = 1; end\n'); +fprintf(fid, 'end\n\n'); +fprintf(fid, 'function headers = local_manifest_headers(raw, data_start)\n'); +fprintf(fid, 'headers = containers.Map(''KeyType'', ''char'', ''ValueType'', ''double'');\n'); +fprintf(fid, 'if data_start == 1, return; end\n'); +fprintf(fid, 'for ii = 1:size(raw, 2)\n'); +fprintf(fid, ' key = lower(strtrim(local_cell_to_char(raw{1, ii})));\n'); +fprintf(fid, ' if ~isempty(key), headers(key) = ii; end\n'); +fprintf(fid, 'end\n'); +fprintf(fid, 'end\n\n'); +fprintf(fid, 'function val = local_manifest_value(raw, headers, row_idx, name, fallback_col)\n'); +fprintf(fid, 'if isKey(headers, name), col = headers(name); else, col = fallback_col; end\n'); +fprintf(fid, 'if col > size(raw, 2), val = ''''; else, val = local_cell_to_char(raw{row_idx, col}); end\n'); +fprintf(fid, 'end\n\n'); +fprintf(fid, 'function raw = local_read_manifest(manifest_file)\n'); +fprintf(fid, 'try\n'); +fprintf(fid, ' raw = readcell(manifest_file, ''Delimiter'', '','');\n'); +fprintf(fid, 'catch\n'); +fprintf(fid, ' fid2 = fopen(manifest_file, ''r'');\n'); +fprintf(fid, ' if fid2 == -1, error(''Could not open manifest: %%s'', manifest_file); end\n'); +fprintf(fid, ' c2 = onCleanup(@() fclose(fid2));\n'); +fprintf(fid, ' lines = textscan(fid2, ''%%s'', ''Delimiter'', ''\\n'', ''Whitespace'', '''');\n'); +fprintf(fid, ' lines = lines{1};\n'); +fprintf(fid, ' raw = cell(numel(lines), 0);\n'); +fprintf(fid, ' for ii = 1:numel(lines)\n'); +fprintf(fid, ' parts = strsplit(lines{ii}, '','');\n'); +fprintf(fid, ' raw(ii, 1:numel(parts)) = parts;\n'); +fprintf(fid, ' end\n'); +fprintf(fid, 'end\n'); +fprintf(fid, 'raw = local_drop_empty_rows(raw);\n'); +fprintf(fid, 'end\n\n'); +fprintf(fid, 'function raw = local_drop_empty_rows(raw)\n'); +fprintf(fid, 'keep = true(size(raw, 1), 1);\n'); +fprintf(fid, 'for ii = 1:size(raw, 1)\n'); +fprintf(fid, ' row_text = strings(1, size(raw, 2));\n'); +fprintf(fid, ' for jj = 1:size(raw, 2), row_text(jj) = string(local_cell_to_char(raw{ii, jj})); end\n'); +fprintf(fid, ' keep(ii) = any(strlength(strtrim(row_text)) > 0);\n'); +fprintf(fid, 'end\n'); +fprintf(fid, 'raw = raw(keep, :);\n'); +fprintf(fid, 'end\n\n'); +fprintf(fid, 'function tf = local_has_manifest_header(row)\n'); +fprintf(fid, 'tokens = strings(1, numel(row));\n'); +fprintf(fid, 'for ii = 1:numel(row), tokens(ii) = lower(strtrim(string(local_cell_to_char(row{ii})))); end\n'); +fprintf(fid, 'tf = any(tokens == "subject") || any(tokens == "fmri_file") || any(tokens == "events_file") || any(tokens == "output_prefix");\n'); +fprintf(fid, 'end\n\n'); +fprintf(fid, 'function s = local_cell_to_char(x)\n'); +fprintf(fid, 'if iscell(x), x = x{1}; end\n'); +fprintf(fid, 'try\n'); +fprintf(fid, ' if ismissing(x), s = ''''; return; end\n'); +fprintf(fid, 'catch\n'); +fprintf(fid, 'end\n'); +fprintf(fid, 'if isstring(x), s = char(x); elseif ischar(x), s = x; elseif isnumeric(x), s = num2str(x); else, s = char(string(x)); end\n'); +fprintf(fid, 'end\n'); +end + +function local_write_sbatch(script_file, worker_file, n_tasks, log_dir, opts) +fid = fopen(script_file, 'w'); +if fid == -1, error('Could not write SLURM script: %s', script_file); end +c = onCleanup(@() fclose(fid)); + +fprintf(fid, '#!/bin/bash\n'); +fprintf(fid, '#SBATCH --job-name=%s\n', char(opts.JobName)); +fprintf(fid, '#SBATCH --array=1-%d\n', n_tasks); +fprintf(fid, '#SBATCH --time=%s\n', char(opts.Time)); +fprintf(fid, '#SBATCH --mem=%s\n', char(opts.Mem)); +fprintf(fid, '#SBATCH --cpus-per-task=%d\n', opts.CpusPerTask); +fprintf(fid, '#SBATCH --output=%s\n', local_posix_join(log_dir, '%x_%A_%a.out')); +fprintf(fid, '#SBATCH --error=%s\n', local_posix_join(log_dir, '%x_%A_%a.err')); + +if ~isempty(char(opts.Partition)) + fprintf(fid, '#SBATCH --partition=%s\n', char(opts.Partition)); +end +if ~isempty(char(opts.Account)) + fprintf(fid, '#SBATCH --account=%s\n', char(opts.Account)); +end + +extra = local_to_cell(opts.ExtraSlurmDirectives); +for i = 1:numel(extra) + line = char(extra{i}); + if startsWith(strtrim(line), '#SBATCH') + fprintf(fid, '%s\n', line); + else + fprintf(fid, '#SBATCH %s\n', line); + end +end + +fprintf(fid, '# Set SLURM ARRAY TASK ID if debugging interactively\n'); +fprintf(fid, '\nset -euo pipefail\n\n'); +fprintf(fid, 'export SLURM_ARRAY_TASK_ID="${SLURM_ARRAY_TASK_ID:-1}"\n\n'); +if ~isempty(char(opts.ModuleLoad)) + fprintf(fid, 'module load %s\n\n', char(opts.ModuleLoad)); +end +fprintf(fid, '%s -nodisplay -nosplash -batch "run(''%s'')"\n', ... + char(opts.MatlabCommand), local_bash_matlab_path(worker_file)); +end + +function root = local_default_canlab_root() +% This file lives at CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/. +% Walk up three levels to reach the CanlabCore root (where @fmri_data etc live). +root = fileparts(fileparts(fileparts(mfilename('fullpath')))); +end + +function local_ensure_parent(path_in) +parent_dir = fileparts(path_in); +if ~isempty(parent_dir) && ~exist(parent_dir, 'dir') + mkdir(parent_dir); +end +end + +function c = local_to_cell(x) +if isempty(x) + c = {}; +elseif isa(x, 'image_vector') + c = {x}; +elseif ischar(x) || isstring(x) + c = cellstr(string(x)); +else + c = x; +end +end + +function label = local_file_label(subject_id) +label = regexprep(char(subject_id), '[^\w.-]', '_'); +end + +function out = local_matlab_string(path_in) +out = strrep(char(path_in), '''', ''''''); +end + +function out = local_bash_matlab_path(path_in) +out = strrep(local_matlab_string(path_in), '\', '/'); +end + +function out = local_posix_join(folder, file_name) +out = strrep(fullfile(folder, file_name), '\', '/'); +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/make_fmri_stat_hrf.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/make_fmri_stat_hrf.m new file mode 100644 index 00000000..7e478a0e --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/make_fmri_stat_hrf.m @@ -0,0 +1,241 @@ +function [Hb, Ht] = make_fmri_stat_hrf(source, varargin) +% make_fmri_stat_hrf - Build a paired (fmri_hrf, statistic_hrf) from a single fit. +% +% This is the canonical entry point to the new HRF object classes. The two +% sibling classes (fmri_hrf for beta/amplitude, statistic_hrf for t/p/SE) +% are designed to be constructed together so their HRF metadata is +% guaranteed to be aligned. +% +% Usage +% ----- +% 1) From an in-memory wholebrain_by_model struct: +% [Hb, Ht] = make_fmri_stat_hrf(results.wholebrain_by_model.sfir, ... +% 'Subject', 'sub-01', ... +% 'RunLabel', 'task-pain_run-01', ... +% 'ModelName', 'sfir', ... +% 'TR', 0.8) +% +% 2) From a NIfTI prefix (loads beta + t + se + metadata from disk): +% [Hb, Ht] = make_fmri_stat_hrf('/path/to/sub-01_hrf_sfir', ... +% 'Subject', 'sub-01', ... +% 'ModelName', 'sfir', ... +% 'TR', 0.8) +% +% 3) From a result.mat path: +% [Hb, Ht] = make_fmri_stat_hrf('/path/to/sub-01_hrf_results.mat', ... +% 'ModelName', 'sfir', ... +% 'Subject', 'sub-01') +% +% Inputs +% ------ +% source The first arg dispatches on type: +% struct -> in-memory wholebrain struct with .b, .t, fields +% (and optionally .ste / .se for SE) +% char/string ending in .mat -> result.mat path +% char/string (any other) -> NIfTI prefix; reads +% _beta.nii, _t.nii, _se.nii, _metadata.csv +% +% Required name-value pairs +% ------------------------- +% 'Subject', 'ModelName', 'TR'. (RunLabel optional but recommended.) +% +% Optional name-value pairs +% ------------------------- +% 'MetadataTable' - override the metadata table resolution. +% 'Conditions' - condition labels; derived from metadata if not given. +% 'DesignMatrix' - design matrix (needed for residuals later). +% 'DesignInfo' - struct describing design columns. +% 'NoVerbose' - suppress fmri_data load chatter (default true). +% +% Returns +% ------- +% Hb fmri_hrf, beta side. +% Ht statistic_hrf, t side. Empty if no t image is resolvable from the source. +% +% See also: fmri_hrf, statistic_hrf, hrf_fit_wholebrain_stats. + +p = inputParser; +p.KeepUnmatched = true; +p.addRequired('source'); +p.addParameter('Subject', '', @(x) ischar(x) || isstring(x)); +p.addParameter('RunLabel', '', @(x) ischar(x) || isstring(x)); +p.addParameter('ModelName', '', @(x) ischar(x) || isstring(x)); +p.addParameter('TR', NaN, @(x) isnumeric(x) && isscalar(x)); +p.addParameter('Conditions', {}, @(x) iscell(x) || isstring(x)); +p.addParameter('MetadataTable', table(), @(x) isempty(x) || istable(x)); +p.addParameter('DesignMatrix', [], @(x) isnumeric(x) || isempty(x)); +p.addParameter('DesignInfo', struct(), @isstruct); +p.addParameter('NoVerbose', true, @(x) islogical(x) || isnumeric(x)); +p.parse(source, varargin{:}); +opts = p.Results; + +[beta_obj, t_obj, se_obj, metadata, source_paths] = local_resolve_source(source, opts); + +if isempty(opts.MetadataTable) && ~isempty(metadata) + opts.MetadataTable = metadata; +end + +nv_pairs = local_pack_nv(opts, source_paths); + +% Build the beta side. +Hb = fmri_hrf(beta_obj, nv_pairs{:}); + +% Build the t side if a t image is available; else derive from beta + se if both. +if ~isempty(t_obj) + Ht = statistic_hrf(t_obj, nv_pairs{:}); +elseif ~isempty(se_obj) + Ht = to_statistic_hrf(Hb, 'SE', se_obj); +else + Ht = statistic_hrf(); +end +end + + +% ========================================================================= +% Source dispatch +% ========================================================================= +function [beta_obj, t_obj, se_obj, metadata, source_paths] = local_resolve_source(source, opts) +beta_obj = []; +t_obj = []; +se_obj = []; +metadata = table(); +source_paths = struct(); + +if isstruct(source) + [beta_obj, t_obj, se_obj, metadata, source_paths] = local_from_wholebrain_struct(source); + return +end + +if ischar(source) || isstring(source) + source = char(source); + if endsWith(lower(source), '.mat') + [beta_obj, t_obj, se_obj, metadata, source_paths] = local_from_result_mat(source, opts); + else + [beta_obj, t_obj, se_obj, metadata, source_paths] = local_from_prefix(source, opts); + end + return +end + +error('make_fmri_stat_hrf:UnknownSource', ... + 'source must be a wholebrain struct, a result.mat path, or a NIfTI prefix. Got: %s.', ... + class(source)); +end + +function [beta_obj, t_obj, se_obj, metadata, source_paths] = local_from_wholebrain_struct(s) +beta_obj = []; +t_obj = []; +se_obj = []; +metadata = table(); +source_paths = struct(); + +if isfield(s, 'b'), beta_obj = s.b; end +if isfield(s, 'beta'), beta_obj = s.beta; end +if isfield(s, 't'), t_obj = s.t; end +if isfield(s, 'tstat'), t_obj = s.tstat; end +if isfield(s, 'se'), se_obj = s.se; end +if isfield(s, 'ste'), se_obj = s.ste; end + +if isempty(se_obj) && ~isempty(beta_obj) && isprop(beta_obj, 'ste') && ~isempty(beta_obj.ste) + se_obj = beta_obj; + se_obj.dat = beta_obj.ste; +end + +if isfield(s, 'metadata_table') && istable(s.metadata_table) + metadata = s.metadata_table; +elseif isfield(s, 'metadata') && istable(s.metadata) + metadata = s.metadata; +end +end + +function [beta_obj, t_obj, se_obj, metadata, source_paths] = local_from_prefix(prefix, opts) +load_args = {}; +if logical(opts.NoVerbose), load_args = {'noverbose'}; end + +beta_file = [prefix '_beta.nii']; +t_file = [prefix '_t.nii']; +se_file = [prefix '_se.nii']; +meta_file = [prefix '_metadata.csv']; + +source_paths = struct('beta', beta_file, 't', t_file, 'se', se_file, 'metadata', meta_file); + +beta_obj = []; +if exist(beta_file, 'file') == 2 + beta_obj = fmri_data(beta_file, load_args{:}); +end + +t_obj = []; +if exist(t_file, 'file') == 2 + t_obj = statistic_image(fmri_data(t_file, load_args{:})); + t_obj.type = 'T'; +end + +se_obj = []; +if exist(se_file, 'file') == 2 + se_obj = statistic_image(fmri_data(se_file, load_args{:})); + se_obj.type = 'HRF beta standard error'; +end + +metadata = table(); +if exist(meta_file, 'file') == 2 + try + metadata = readtable(meta_file, 'TextType', 'string'); + catch + end +end + +if isempty(beta_obj) + error('make_fmri_stat_hrf:MissingBeta', ... + 'Could not find %s. Check the prefix.', beta_file); +end +end + +function [beta_obj, t_obj, se_obj, metadata, source_paths] = local_from_result_mat(mat_file, opts) +beta_obj = []; +t_obj = []; +se_obj = []; +metadata = table(); +source_paths = struct('result_mat', mat_file); + +if exist(mat_file, 'file') ~= 2 + error('make_fmri_stat_hrf:MissingResultMat', 'result.mat not found: %s', mat_file); +end + +S = load(mat_file, 'results'); +if ~isfield(S, 'results') + error('make_fmri_stat_hrf:NoResultsField', ... + 'result.mat does not contain a top-level "results" struct.'); +end + +model_name = char(opts.ModelName); +if isempty(model_name) + error('make_fmri_stat_hrf:NeedModelName', ... + 'Provide ''ModelName'' when constructing from a result.mat.'); +end + +R = S.results; +model_field = matlab.lang.makeValidName(lower(model_name)); +if ~isfield(R, 'wholebrain_by_model') || ~isfield(R.wholebrain_by_model, model_field) + error('make_fmri_stat_hrf:MissingModelInResultMat', ... + 'result.mat has no wholebrain_by_model.%s entry.', model_field); +end + +[beta_obj, t_obj, se_obj, metadata, ~] = local_from_wholebrain_struct(R.wholebrain_by_model.(model_field)); + +if isempty(metadata) && isfield(R, 'wholebrain_metadata_by_model') && isfield(R.wholebrain_metadata_by_model, model_field) + metadata = R.wholebrain_metadata_by_model.(model_field); +end +end + +function nv = local_pack_nv(opts, source_paths) +nv = {}; +add = @(name, val) [nv, {name, val}]; %#ok +nv = [nv, {'Subject', char(opts.Subject)}]; +nv = [nv, {'RunLabel', char(opts.RunLabel)}]; +nv = [nv, {'ModelName', char(opts.ModelName)}]; +nv = [nv, {'TR', opts.TR}]; +if ~isempty(opts.Conditions), nv = [nv, {'Conditions', opts.Conditions}]; end +if ~isempty(opts.MetadataTable), nv = [nv, {'MetadataTable', opts.MetadataTable}]; end +if ~isempty(opts.DesignMatrix), nv = [nv, {'DesignMatrix', opts.DesignMatrix}]; end +if ~isempty(fieldnames(opts.DesignInfo)), nv = [nv, {'DesignInfo', opts.DesignInfo}]; end +if ~isempty(fieldnames(source_paths)), nv = [nv, {'SourcePaths', source_paths}]; end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/plot_hrf_2x2_study_score_stats.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/plot_hrf_2x2_study_score_stats.m new file mode 100644 index 00000000..f902d4f4 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/plot_hrf_2x2_study_score_stats.m @@ -0,0 +1,76 @@ +function ax = plot_hrf_2x2_study_score_stats(stats, varargin) +%PLOT_HRF_2X2_STUDY_SCORE_STATS Plot one contrast from 2x2 study-score stats. + +p = inputParser; +p.addRequired('stats', @isstruct); +p.addParameter('Contrast', 'interaction_AxB', @(x) ischar(x) || isstring(x)); +p.addParameter('ShowCells', false, @(x) islogical(x) || isnumeric(x)); +p.parse(stats, varargin{:}); +opts = p.Results; + +contrast_field = local_contrast_field(stats, char(opts.Contrast)); +c = stats.contrasts.(contrast_field); +x = stats.time(:); + +figure; +ax(1) = subplot(2, 1, 1); +hold on; +if logical(opts.ShowCells) + local_plot_cell_means(stats, x); +end +fill([x; flipud(x)], [c.mean + c.sem; flipud(c.mean - c.sem)], ... + [0.3 0.3 0.3], 'FaceAlpha', 0.18, 'EdgeColor', 'none'); +plot(x, c.mean, 'k-', 'LineWidth', 2); +line([min(x) max(x)], [0 0], 'Color', [0 0 0], 'LineStyle', '-'); +ylabel('Difference in pattern score'); +title(local_title(stats, c), 'Interpreter', 'none'); + +ax(2) = subplot(2, 1, 2); +hold on; +plot(x, c.p_value, 'k-', 'LineWidth', 1.2); +line([min(x) max(x)], [stats.alpha stats.alpha], 'Color', [0.8 0 0], 'LineStyle', '--'); +sig_idx = c.significant & ~isnan(c.p_value); +stem(x(sig_idx), c.p_value(sig_idx), 'g.'); +ylabel('p-value'); +xlabel('Time bin'); +title(sprintf('%s, n=%d', c.formula, stats.n_subjects), 'Interpreter', 'none'); +end + +function field = local_contrast_field(stats, query) +fields = fieldnames(stats.contrasts); +if ismember(query, fields) + field = query; + return +end +for i = 1:numel(fields) + c = stats.contrasts.(fields{i}); + if strcmpi(query, c.name) || strcmpi(query, c.formula) + field = fields{i}; + return + end +end +error('Unknown contrast "%s". Available contrasts: %s', query, strjoin(fields, ', ')); +end + +function local_plot_cell_means(stats, x) +cell_fields = fieldnames(stats.cells); +colors = lines(numel(cell_fields)); +for i = 1:numel(cell_fields) + cell_stats = stats.cells.(cell_fields{i}); + plot(x, cell_stats.mean, '-', 'Color', colors(i, :), 'LineWidth', 0.9, ... + 'DisplayName', cell_stats.label); +end +legend('Location', 'best', 'Interpreter', 'none'); +end + +function txt = local_title(stats, c) +sig_txt = ''; +if isfield(stats, 'signature') && ~isempty(stats.signature) + sig_txt = sprintf(' | %s', stats.signature); +end +condition_txt = stats.conditionA; +if isempty(condition_txt) + condition_txt = 'first condition'; +end +txt = sprintf('%s | %s%s', c.name, condition_txt, sig_txt); +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/plot_hrf_atlas_curves.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/plot_hrf_atlas_curves.m new file mode 100644 index 00000000..ee95a01e --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/plot_hrf_atlas_curves.m @@ -0,0 +1,12 @@ +function fig = plot_hrf_atlas_curves(varargin) +%PLOT_HRF_ATLAS_CURVES Deprecated alias for PLOT_HRF_CURVES. +% +% This function was renamed to plot_hrf_curves once it grew to plot +% signatures and image-set networks in addition to atlas regions. This +% thin shim forwards all arguments unchanged so existing scripts keep +% working. Prefer plot_hrf_curves in new code. +% +% See also: plot_hrf_curves. + +fig = plot_hrf_curves(varargin{:}); +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/plot_hrf_by_condition.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/plot_hrf_by_condition.m new file mode 100644 index 00000000..73b05b1e --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/plot_hrf_by_condition.m @@ -0,0 +1,482 @@ +function ax = plot_hrf_by_condition(results, varargin) +%PLOT_HRF_BY_CONDITION Plot one subject/run HRF curves by condition. +% +% ax = plot_hrf_by_condition(results, 'Model', 'sfir') +% ax = plot_hrf_by_condition(results, 'Model', {'fir','sfir','canonical'}) +% ax = plot_hrf_by_condition(results, 'Model', 'mapscore', 'Signature', 'sig_all_NPS') +% +% PlotType='fit' plots fitted HRF/model curves. If the fit contains .se and +% .p fields, subject/run-level uncertainty is shaded and significant bins can +% be marked. PlotType='trialmean' plots raw event-locked trial means with SEM +% from repeated events in the run. + +p = inputParser; +p.addRequired('results', @isstruct); +p.addParameter('Model', 'sfir', @(x) ischar(x) || iscell(x) || isstring(x)); +p.addParameter('SourceModel', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Conditions', [], @(x) isempty(x) || isnumeric(x) || iscell(x) || isstring(x)); +p.addParameter('Condition', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Signature', '', @(x) ischar(x) || isstring(x)); +p.addParameter('PlotType', 'fit', @(x) ischar(x) || isstring(x)); +p.addParameter('ShowSE', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('ShowP', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('RecomputeSE', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('SEAlpha', 0.18, @(x) isscalar(x) && x >= 0 && x <= 1); +p.addParameter('Alpha', 0.05, @(x) isscalar(x) && x > 0 && x < 1); +p.addParameter('BaselineSeconds', 0, @(x) isscalar(x) && x >= 0); +p.addParameter('TrialOutlierPolicy', 'none', @(x) ischar(x) || isstring(x)); +p.addParameter('TrialOutlierZThreshold', 4, @(x) isscalar(x) && x > 0); +p.addParameter('LineWidth', 1.8, @(x) isscalar(x) && x > 0); +p.parse(results, varargin{:}); +opts = p.Results; +opts.SourceModel = lower(strtrim(char(opts.SourceModel))); + +model_names = local_model_names(opts.Model); +plot_type = lower(char(opts.PlotType)); +condition_specs = local_condition_specs(results, opts); + +figure; ax = axes; hold(ax, 'on'); +switch plot_type + case 'fit' + local_plot_fit(ax, results, model_names, condition_specs, opts); + case {'trialmean', 'trial_mean', 'trials'} + local_plot_trial_mean(ax, results, condition_specs, opts); + otherwise + error('Unknown PlotType: %s. Use ''fit'' or ''trialmean''.', char(opts.PlotType)); +end +hline(0, 'k-'); +end + +function local_plot_fit(ax, results, model_names, condition_specs, opts) +[fit_struct, source_label, sig_label] = local_fit_struct(results, opts.Signature); +colors = lines(numel(condition_specs)); +line_styles = {'-', '--', ':', '-.'}; +legend_labels = {}; +plotted_se = false(1, numel(model_names)); +used_models = {}; +for m = 1:numel(model_names) + model_name = model_names{m}; + [selected_fit, ok, reason] = local_select_fit(fit_struct, model_name, opts.SourceModel); + if ~ok, error('%s', reason); end + + fit = local_fit_with_uncertainty(results, selected_fit, model_name, opts); + y_mat = fit.hrf; + x = local_fit_time(fit, results, size(y_mat, 1)); + has_se = isfield(fit, 'se') && ~isempty(fit.se) && all(size(fit.se) == size(y_mat)); + has_p = isfield(fit, 'p') && ~isempty(fit.p) && all(size(fit.p) == size(y_mat)); + style = line_styles{mod(m - 1, numel(line_styles)) + 1}; + used_models{end + 1} = model_name; %#ok + + for k = 1:numel(condition_specs) + cidx = condition_specs(k).indices; + y = local_mean_omitnan(y_mat(:, cidx), 2); + if logical(opts.ShowSE) && has_se + se = local_combine_condition_se(y_mat(:, cidx), fit.se(:, cidx)); + if any(~isnan(se)) + fill(ax, [x; flipud(x)], [y + se; flipud(y - se)], colors(k, :), ... + 'FaceAlpha', opts.SEAlpha ./ max(numel(model_names), 1), ... + 'EdgeColor', 'none', 'HandleVisibility', 'off'); + plotted_se(m) = true; + end + end + plot(ax, x, y, 'LineWidth', opts.LineWidth, 'Color', colors(k, :), ... + 'LineStyle', style); + if logical(opts.ShowP) && has_p && isscalar(cidx) + sig = fit.p(:, cidx) < opts.Alpha; + if any(sig) + yrange = max(y_mat(:)) - min(y_mat(:)); + if yrange == 0 || isnan(yrange), yrange = 1; end + ymark = y(sig) + 0.04 .* yrange; + plot(ax, x(sig), ymark, '.', 'Color', colors(k, :), ... + 'MarkerSize', 12, 'HandleVisibility', 'off'); + end + end + if isscalar(model_names) + legend_labels{end + 1} = condition_specs(k).display_label; %#ok + else + legend_labels{end + 1} = sprintf('%s | %s', condition_specs(k).display_label, model_name); %#ok + end + end +end + +legend(ax, format_strings_for_legend(legend_labels), 'Interpreter', 'none'); +title(ax, local_fit_title(used_models, source_label, sig_label, any(plotted_se)), 'Interpreter', 'none'); +xlabel(ax, 'Seconds after event onset'); +ylabel(ax, local_ylabel(used_models, source_label)); +end + +function local_plot_trial_mean(ax, results, condition_specs, opts) +if ~isfield(results, 'events') || isempty(results.events) + error('PlotType=''trialmean'' requires results.events.'); +end +if ~isfield(results, 'settings') || ~isfield(results.settings, 'TR') || ... + ~isfield(results.settings, 'window_seconds') + error('PlotType=''trialmean'' requires results.settings.TR and results.settings.window_seconds.'); +end +[tc, source_label] = local_trial_timeseries(results, opts.Signature); + +colors = lines(numel(condition_specs)); +legend_labels = cell(1, numel(condition_specs)); +for k = 1:numel(condition_specs) + avg = hrf_average_condition_trials(tc, results.events, condition_specs(k).matched_conditions, ... + results.settings.TR, results.settings.window_seconds, ... + 'BaselineSeconds', opts.BaselineSeconds, ... + 'OutlierPolicy', opts.TrialOutlierPolicy, ... + 'OutlierZThreshold', opts.TrialOutlierZThreshold); + x = avg.time; + y = avg.mean; + if logical(opts.ShowSE) + fill(ax, [x; flipud(x)], [y + avg.sem; flipud(y - avg.sem)], colors(k, :), ... + 'FaceAlpha', opts.SEAlpha, 'EdgeColor', 'none', 'HandleVisibility', 'off'); + end + plot(ax, x, y, 'LineWidth', opts.LineWidth, 'Color', colors(k, :)); + legend_labels{k} = sprintf('%s (n=%d, weighted=%0.3g)', ... + condition_specs(k).display_label, avg.n_trials, sum(avg.trial_weights)); +end + +legend(ax, format_strings_for_legend(legend_labels), 'Interpreter', 'none'); +title(ax, sprintf('model=trialmean, source=%s, outliers=%s, ribbon=within-run trial SEM', ... + source_label, char(opts.TrialOutlierPolicy)), ... + 'Interpreter', 'none'); +xlabel(ax, 'Seconds after event onset'); +ylabel(ax, 'Observed signal'); +end + +function [tc, source_label] = local_trial_timeseries(results, signature) +source_label = local_source_label(results); +sig = char(signature); +if ~isempty(sig) + sig_field = local_timeseries_signature_field(results, sig); + if ~isempty(sig_field) + tc = results.timeseries_by_signature.(sig_field); + source_label = sprintf('%s, %s', source_label, sig); + return + end + if isfield(results, 'signature_meta') && isfield(results.signature_meta, 'selected_signature') && ... + strcmp(char(results.signature_meta.selected_signature), sig) && ... + isfield(results, 'timeseries') && ~isempty(results.timeseries) + tc = results.timeseries; + source_label = sprintf('%s, %s', source_label, sig); + return + end + error(['PlotType=''trialmean'' requested Signature %s, but matching time series were not stored. ' ... + 'Rerun run_hrf_pipeline so results.timeseries_by_signature is saved, or omit Signature for the selected time series.'], sig); +end + +if isfield(results, 'timeseries') && ~isempty(results.timeseries) + tc = results.timeseries; +else + error(['PlotType=''trialmean'' requires results.timeseries or results.timeseries_by_signature. ' ... + 'Map-score-only studies rebuilt from CSVs do not contain event-level time series.']); +end +end + +function condition_specs = local_condition_specs(results, opts) +if ~isfield(results, 'conditions') || isempty(results.conditions) + error('results.conditions is required.'); +end +if ~isempty(opts.Conditions) + cond_spec = opts.Conditions; +elseif ~isempty(char(opts.Condition)) + cond_spec = {char(opts.Condition)}; +else + cond_spec = 1:numel(results.conditions); +end +condition_specs = hrf_resolve_condition_patterns(results.conditions, cond_spec, 'DefaultMode', 'each'); +condition_specs = local_apply_condition_group_labels(results, condition_specs); +end + +function condition_specs = local_apply_condition_group_labels(results, condition_specs) +if ~isfield(results, 'condition_groups') || isempty(results.condition_groups) + return +end +groups = results.condition_groups; +for i = 1:numel(condition_specs) + if numel(condition_specs(i).indices) ~= 1 + continue + end + idx = condition_specs(i).indices(1); + if idx <= numel(groups) && isfield(groups, 'display_label') + condition_specs(i).display_label = groups(idx).display_label; + if isfield(groups, 'matched_conditions') + condition_specs(i).matched_conditions = groups(idx).matched_conditions; + end + end +end +end + +function [fit_struct, source_label, sig_label] = local_fit_struct(results, signature) +source_label = local_source_label(results); +sig_label = char(signature); +if ~isempty(sig_label) && isfield(results, 'fits_by_signature') + sig_field = local_signature_field(results, sig_label); + if isempty(sig_field) + error('Signature %s not found in results.fits_by_signature.', sig_label); + end + fit_struct = results.fits_by_signature.(sig_field); + source_label = sprintf('%s, %s', source_label, sig_label); +elseif isfield(results, 'fits') + fit_struct = results.fits; +else + error('results must contain .fits or selected .fits_by_signature.'); +end +end + +function [fit, ok, reason] = local_select_fit(fit_struct, model_name, source_model) +fit = struct(); +ok = false; +reason = ''; +model_name = lower(char(model_name)); +source_model = lower(strtrim(char(source_model))); + +if isfield(fit_struct, model_name) + candidate = fit_struct.(model_name); + wanted_source = local_requested_source_model(model_name, source_model, candidate); + if local_fit_matches_source(candidate, wanted_source) + fit = candidate; + ok = true; + else + reason = sprintf('Model %s is not from source model %s.', model_name, source_model); + end + return +end + +if isfield(fit_struct, 'mapscore') + candidate = fit_struct.mapscore; + wanted_source = source_model; + if isempty(wanted_source) && ~strcmp(model_name, 'mapscore') + wanted_source = model_name; + end + if local_fit_matches_source(candidate, wanted_source) + fit = candidate; + ok = true; + return + end +end + +if isempty(source_model) + reason = sprintf('Model %s not available in selected fit structure.', model_name); +else + reason = sprintf('Model %s not available for source model %s.', model_name, source_model); +end +end + +function tf = local_fit_matches_source(fit, source_model) +if isempty(source_model) + tf = true; +elseif isfield(fit, 'source_model') && ~isempty(fit.source_model) + tf = strcmpi(char(fit.source_model), source_model); +else + tf = false; +end +end + +function source_model = local_requested_source_model(model_name, source_model, fit) +if isempty(source_model) && local_is_wholebrain_model_name(model_name) && ... + isfield(fit, 'source_model') && ~isempty(fit.source_model) + source_model = model_name; +end +end + +function tf = local_is_wholebrain_model_name(model_name) +tf = ismember(lower(strtrim(char(model_name))), {'fir', 'sfir', 'canonical', 'spline'}); +end + +function fit = local_fit_with_uncertainty(results, fit, model_name, opts) +if local_has_fit_se(fit) || ~logical(opts.ShowSE) || ~logical(opts.RecomputeSE) + return +end +if ~ismember(lower(model_name), {'fir', 'sfir', 'canonical', 'spline'}) + return +end +if ~isempty(char(opts.Signature)) && ~local_is_selected_signature(results, char(opts.Signature)) + return +end +if ~isfield(results, 'timeseries') || isempty(results.timeseries) || ... + ~isfield(results, 'stick_functions') || isempty(results.stick_functions) || ... + ~isfield(results, 'settings') || ~isfield(results.settings, 'TR') || ... + ~isfield(results.settings, 'window_seconds') + return +end + +try + refit = hrf_fit_all_models(results.timeseries, results.settings.TR, ... + results.stick_functions, results.settings.window_seconds, {model_name}, ... + 'DependencyPolicy', 'skip'); + if isfield(refit, model_name) && local_has_fit_se(refit.(model_name)) + fit = refit.(model_name); + fit.uncertainty_source = sprintf('%s; recomputed for plotting', fit.uncertainty_source); + end +catch +end +end + +function tf = local_has_fit_se(fit) +tf = isfield(fit, 'se') && ~isempty(fit.se) && isfield(fit, 'hrf') && ... + all(size(fit.se) == size(fit.hrf)); +end + +function tf = local_is_selected_signature(results, signature) +tf = false; +if isempty(signature) + tf = true; + return +end +if isfield(results, 'signature_meta') && isfield(results.signature_meta, 'selected_signature') + tf = strcmp(char(results.signature_meta.selected_signature), signature); +end +end + +function se = local_combine_condition_se(Y, SE) +n_cond = size(Y, 2); +has_model_se = any(~isnan(SE(:))); +if has_model_se + valid = ~isnan(SE); + SE(~valid) = 0; + n = max(sum(valid, 2), 1); + se = sqrt(sum(SE .^ 2, 2)) ./ n; +else + se = nan(size(Y, 1), 1); +end +if n_cond > 1 + condition_sem = local_sem_omitnan(Y, 2); + if has_model_se + se = sqrt(se .^ 2 + condition_sem .^ 2); + else + se = condition_sem; + end +end +end + +function m = local_mean_omitnan(X, dim) +if nargin < 2, dim = 1; end +valid = ~isnan(X); +den = sum(valid, dim); +X(~valid) = 0; +m = sum(X, dim) ./ den; +m(den == 0) = NaN; +end + +function se = local_sem_omitnan(X, dim) +if nargin < 2, dim = 1; end +mu = local_mean_omitnan(X, dim); +if dim == 1 + centered = X - repmat(mu, size(X, 1), 1); +else + centered = X - repmat(mu, 1, size(X, 2)); +end +centered(isnan(centered)) = 0; +n = sum(~isnan(X), dim); +sd = sqrt(sum(centered .^ 2, dim) ./ max(n - 1, 1)); +se = sd ./ sqrt(n); +se(n == 0 | n == 1) = NaN; +end + +function x = local_fit_time(fit, results, n) +if isfield(fit, 'time') && numel(fit.time) == n + x = fit.time(:); +elseif isfield(fit, 'lag_seconds') && numel(fit.lag_seconds) == n + x = fit.lag_seconds(:); +elseif isfield(results, 'settings') && isfield(results.settings, 'TR') + x = 1 + (0:n - 1)' .* results.settings.TR; +else + x = (1:n)'; +end +end + +function source_label = local_source_label(results) +source_label = 'selected signal'; +if isfield(results, 'settings') && isfield(results.settings, 'signal_source') + source_label = char(results.settings.signal_source); +elseif isfield(results, 'signature_meta') && isfield(results.signature_meta, 'signal_source') + source_label = char(results.signature_meta.signal_source); +end +if isfield(results, 'settings') && isfield(results.settings, 'mapscore_object') + source_label = sprintf('%s %s map scores', char(results.settings.mapscore_object), source_label); +elseif isfield(results, 'signature_meta') && isfield(results.signature_meta, 'object') + source_label = sprintf('%s map scores', char(results.signature_meta.object)); +end +end + +function ttl = local_fit_title(model_names, source_label, sig_label, has_se) +if isempty(sig_label) + sig_part = ''; +else + sig_part = sprintf(', score/signature=%s', sig_label); +end +if has_se + se_part = ', ribbon=within-run SE'; +else + se_part = ', ribbon=none (SE unavailable)'; +end +ttl = sprintf('model=%s, source=%s%s%s', strjoin(model_names, ' + '), source_label, sig_part, se_part); +end + +function ylab = local_ylabel(model_names, source_label) +if any(strcmpi(model_names, 'mapscore')) || contains(lower(source_label), 'map score') + ylab = 'Pattern expression / map score'; +else + ylab = 'Fitted response amplitude'; +end +end + +function sig_field = local_signature_field(results, sig) +sig_field = ''; +if isfield(results.fits_by_signature, sig) + sig_field = sig; + return +end + +candidate = matlab.lang.makeValidName(sig); +if isfield(results.fits_by_signature, candidate) + sig_field = candidate; + return +end + +if isfield(results, 'signature_meta') && isfield(results.signature_meta, 'selected_signatures') && ... + isfield(results.signature_meta, 'selected_signature_fields') + names = cellstr(string(results.signature_meta.selected_signatures)); + fields = cellstr(string(results.signature_meta.selected_signature_fields)); + idx = find(strcmp(names, sig), 1); + if ~isempty(idx) && idx <= numel(fields) && isfield(results.fits_by_signature, fields{idx}) + sig_field = fields{idx}; + end +end +end + +function model_names = local_model_names(model_input) +model_names = cellstr(string(model_input)); +model_names = cellfun(@(s) lower(strtrim(s)), model_names, 'UniformOutput', false); +model_names = model_names(~cellfun(@isempty, model_names)); +if isempty(model_names) + error('Model must contain at least one model name.'); +end +model_names = unique(model_names, 'stable'); +end + +function sig_field = local_timeseries_signature_field(results, sig) +sig_field = ''; +if ~isfield(results, 'timeseries_by_signature') || isempty(results.timeseries_by_signature) + return +end +if isfield(results.timeseries_by_signature, sig) + sig_field = sig; + return +end + +candidate = matlab.lang.makeValidName(sig); +if isfield(results.timeseries_by_signature, candidate) + sig_field = candidate; + return +end + +if isfield(results, 'signature_meta') && isfield(results.signature_meta, 'selected_signatures') && ... + isfield(results.signature_meta, 'selected_signature_fields') + names = cellstr(string(results.signature_meta.selected_signatures)); + fields = cellstr(string(results.signature_meta.selected_signature_fields)); + idx = find(strcmp(names, sig), 1); + if ~isempty(idx) && idx <= numel(fields) && isfield(results.timeseries_by_signature, fields{idx}) + sig_field = fields{idx}; + end +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/plot_hrf_curves.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/plot_hrf_curves.m new file mode 100644 index 00000000..8ee8c553 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/plot_hrf_curves.m @@ -0,0 +1,1415 @@ +function fig = plot_hrf_curves(input_table, varargin) +%PLOT_HRF_ATLAS_CURVES Group HRF curves for the top activating atlas regions. +% +% Reads atlas region-mean columns from the per-(subject, run, model) score +% CSVs referenced by an input_table, pools per-condition per-lag values +% across subjects, ranks regions by activation magnitude, and plots the +% top N as a grid of per-region subplots. Each subplot shows the +% across-subject mean HRF curve per condition with a SEM band. +% +% Usage +% ----- +% fig = plot_hrf_curves(input_table_lf); % defaults +% fig = plot_hrf_curves(input_table_lf, ... +% 'Model', 'sfir', ... +% 'Conditions', {'nback-stimblock_ttl_1', 'rest_stim_ttl_1'}, ... +% 'AtlasName', 'canlab2024', ... +% 'TopN', 16, ... +% 'RankBy', 'peak_abs'); +% +% Name-value parameters +% --------------------- +% 'Source' - which score-column family to plot: +% 'atlas' (default) -> atlas___ +% 'signature' -> sig__ (e.g. sig_all_NPS) +% 'imageset' -> map__ (e.g. map_bucknerlab_*) +% For 'signature'/'imageset', use 'Set' to pick a set and +% AtlasObj/AtlasName/Normalize are ignored. Despite the +% function name, all three families plot identically (one +% curve per unit: region / signature / network). +% 'Set' - for Source 'signature'/'imageset': case-insensitive +% substring of the set token to select, e.g. 'bucknerlab' +% or 'all'. Default '' (every sig_*/map_* column). The +% curve label is the column with the source__ prefix +% stripped. +% 'Contrast' - {A, B}: rank and plot the PAIRED difference (condition +% A minus condition B) instead of each condition. The +% difference is taken per subject (each subject must have +% both conditions), then pooled, so the SEM / significance +% dots / RankBy all describe the A-B contrast. Use this to +% find regions that MAXIMIZE a condition difference, e.g. +% 'Contrast', {'nback-stimblock_ttl_1','rest_stim_ttl_1'}, +% 'RankBy', 'hrf_match' -> regions with the largest +% reliable HRF-shaped difference between the two. With +% multiple study labels, each study gets its own A-B curve. +% Default {} (per-condition curves). The two levels may be +% CONDITION names or STUDY labels (see ContrastBy) -- the +% latter contrasts the SAME condition across different +% output folders, e.g. 'Contrast', {'acc','exp'} with the +% acc/exp labels from a multi-source struct array. +% 'ContrastBy' - which axis the two Contrast levels live on: 'auto' +% (default; picks whichever of condition / study_label +% contains both levels), 'condition', or 'study_label'. +% 'Model' - which row's model column to use. Default 'sfir'. +% 'Object' - 'beta' (default) or 't'. +% 'Conditions' - cellstr; subset of conditions to plot. Supports glob +% wildcards (e.g. '*_heat_start_ttl_1'). Default [] (all). +% 'CollapseConditions' - false (default) keeps each matched condition as +% its own curve. true relabels every condition matching +% a given pattern to that pattern's canonical name, so +% all matches pool into ONE averaged curve. E.g. with +% 8 body-site conditions all ending in _heat_start_ttl_1, +% 'Conditions', {'*_heat_start_ttl_1'} + +% 'CollapseConditions', true gives a single +% 'heat_start_ttl_1' curve (per study_label) instead of 8. +% 'ConditionLabels' - cellstr parallel to Conditions; explicit collapsed +% names. Default derives the name from each pattern +% (strip '*' and surrounding underscores). +% 'AtlasObj' - the atlas object (recommended). When provided, its +% .labels drive column matching and region naming so +% multi-token atlas names like 'CANLab2024_...' work +% without further configuration. Default []. +% 'AtlasName' - case-insensitive substring of the atlas-name token +% embedded in 'atlas___'. Used +% only when AtlasObj is not provided. Default '' (any +% atlas; takes every atlas_*_ column). +% 'Normalize' - which suffix to read: 'mean' (default), 'l1', 'sum'. +% Must match what hrf_score_wholebrain_input_table wrote. +% 'TopN' - number of regions to plot. Default 16. +% 'RankBy' - how to choose the TopN units. Magnitude modes score the +% mean curve only; reliability/shape modes use the +% across-subject t / SEM (so a clean, tight, HRF-shaped +% response beats a big noisy one -- usually what you want): +% MAGNITUDE: +% 'peak_abs' (default) max|mean| over (condition, lag) +% 'peak' max signed mean (positive activations) +% 'auc_abs' sum(|mean|) across lags +% 'sd' across-lag SD of the mean (any deflection) +% RELIABILITY / SNR (need >= 2 subjects for t/p): +% 'peak_t' max|t| -- SNR-aware peak; recommended +% 'auc_t' sum|t| -- SNR-weighted integrated response +% 'n_sig' # lags with p < Alpha (sustained response) +% 'snr' max|mean| / median(SEM) +% SHAPE (needs SPM spm_hrf): +% 'shape_r2' best-over-conditions corr(mean, canonical)^2 +% -- HRF-plausibility, but amplitude- and +% reliability-blind (tiny wiggles can win) +% 'hrf_match' RECOMMENDED for finding compelling HRFs. +% Offset-removed, latency-flexible matched +% filter: regress the mean curve on +% [constant, canonical] (the constant +% absorbs a sustained baseline offset, so +% flat deactivations don't win), sweep the +% canonical peak 5..15s (so LATE bumps still +% match), and score by the reliability t of +% the canonical coefficient. Rewards a +% reliable TRANSIENT HRF-shaped excursion; +% rejects sustained offsets, noise, and +% big-but-jagged regions. +% 'Regions' - cellstr; explicit region list (overrides TopN/RankBy). +% 'Layout' - [nrows ncols]; default auto-grid from TopN. +% 'FigureSize' - [w h] in pixels. Default scales with grid. +% 'ShareYAxis' - share one y-scale across all panels. Default is +% source-aware: true for 'atlas' (amplitudes comparable +% across regions), false for 'signature'/'imageset' +% (pattern-similarity scales differ wildly, so each panel +% auto-scales). Pass true/false to override. +% 'ShowSignificance' - true (default) marks lags where the across-subject +% mean differs from 0 at p < Alpha (one-sample t per lag): +% small dots above the band, with the FIRST significant +% lag (the onset) ringed. Per series, staggered vertically. +% 'Alpha' - significance threshold for ShowSignificance. Default 0.05. +% 'LineWidth' - default line width for all series. Default 1.6. +% 'LineStyle' - default line style for all series ('-','--',':','-.'). +% Default '-'. +% 'Colors' - Nx3 RGB matrix or cell of color specs applied to series +% in order (overrides the auto palette). Default []. +% 'SeriesStyle' - struct array for per-series overrides, matched by a +% case-insensitive substring of the series display name +% (e.g. 'acc | heat' or just 'NPS'). Fields: +% .Series (required) substring to match +% .Color 1x3 RGB or a name like 'r'/'red'/'orange' +% .LineStyle '-' | '--' | ':' | '-.' +% .LineWidth scalar +% First matching entry wins; unset fields fall back to +% Colors/palette and the global LineStyle/LineWidth. +% Example: +% ss = struct('Series', {'acc','exp'}, ... +% 'Color', {[0 0 1], 'r'}, ... +% 'LineStyle', {'-','--'}, ... +% 'LineWidth', {2.5, 1.2}); +% 'ErrorBand' - 'sem' (default), 'sd', or 'none'. +% 'YZero' - true (default) to draw horizontal 0 line per subplot. +% 'Title' - char/string figure title. Default auto-generated. +% +% Returns +% ------- +% fig - figure handle. +% +% See also: hrf_curve_summaries, hrf_curve_summary_groupstats, +% hrf_score_wholebrain_input_table. + +p = inputParser; +% First arg accepts: +% - a single input_table (current behavior; single-study mode) +% - a struct array with fields .label and .table (or .input_table), +% e.g. struct('label', {'lf_acc','lf_exp'}, 'table', {it1, it2}). +% Each source's CSVs are tagged with the supplied label so per-region +% panels can compare across studies/contexts where BIDS condition +% names collide. +p.addRequired('input_table', @(x) istable(x) || local_is_source_struct(x)); +p.addParameter('Model', 'sfir', @(x) ischar(x) || isstring(x)); +p.addParameter('Object', 'beta', @(x) ischar(x) || isstring(x)); +p.addParameter('Conditions', {}, @(x) iscell(x) || isstring(x) || ischar(x)); +p.addParameter('Contrast', {}, @(x) isempty(x) || iscell(x) || isstring(x)); +p.addParameter('ContrastBy', 'auto', @(x) ischar(x) || isstring(x)); +p.addParameter('AtlasObj', [], @(x) isempty(x) || isa(x, 'atlas') || isa(x, 'image_vector')); +p.addParameter('AtlasName', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Normalize', 'mean', @(x) ischar(x) || isstring(x)); +p.addParameter('TopN', 16, @(x) isscalar(x) && x >= 1); +p.addParameter('RankBy', 'peak_abs', @(x) ischar(x) || isstring(x)); +p.addParameter('Regions', {}, @(x) iscell(x) || isstring(x) || ischar(x)); +p.addParameter('Layout', [], @(x) isempty(x) || (isnumeric(x) && numel(x) == 2)); +p.addParameter('FigureSize', [], @(x) isempty(x) || (isnumeric(x) && numel(x) == 2)); +p.addParameter('ErrorBand', 'sem', @(x) ischar(x) || isstring(x)); +p.addParameter('YZero', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('Title', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Verbose', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('CollapseConditions', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('ConditionLabels', {}, @(x) iscell(x) || isstring(x) || ischar(x)); +p.addParameter('BalancedNesting', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('Source', 'atlas', @(x) ischar(x) || isstring(x)); +p.addParameter('Set', '', @(x) ischar(x) || isstring(x)); +p.addParameter('ShareYAxis', [], @(x) isempty(x) || islogical(x) || isnumeric(x)); +p.addParameter('ShowSignificance', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('Alpha', 0.05, @(x) isscalar(x) && x > 0 && x < 1); +p.addParameter('LineWidth', 1.6, @(x) isscalar(x) && x > 0); +p.addParameter('LineStyle', '-', @(x) ischar(x) || isstring(x)); +p.addParameter('Colors', [], @(x) isempty(x) || isnumeric(x) || iscell(x)); +p.addParameter('SeriesStyle', [], @(x) isempty(x) || isstruct(x)); +p.parse(input_table, varargin{:}); +opts = p.Results; + +model = lower(char(opts.Model)); +object = lower(char(opts.Object)); +suffix = local_normalize_suffix(opts.Normalize); +source_family = lower(strtrim(char(opts.Source))); + +% ShareYAxis default is source-aware: atlas regions share a y-axis (amplitudes +% are comparable across regions), but signatures/networks have wildly +% different pattern-similarity scales, so each panel auto-scales unless the +% user overrides. Honor an explicit value if given. +if isempty(opts.ShareYAxis) + opts.ShareYAxis = strcmp(source_family, 'atlas'); +end +if ~ismember(source_family, {'atlas', 'signature', 'imageset'}) + error('plot_hrf_curves:UnknownSource', ... + 'Source must be ''atlas'', ''signature'', or ''imageset''. Got ''%s''.', source_family); +end + +% 1. Walk the input_table(s), load the matching score CSV per row, extract +% the requested source columns. Output is a long table per row, tagged +% with a 'study_label' string column when multiple sources were passed. +match_spec = struct('family', source_family, 'set', char(opts.Set), ... + 'atlas_obj', opts.AtlasObj, 'atlas_name', char(opts.AtlasName), 'suffix', suffix); +sources = local_normalize_sources(input_table); +long_chunks = cell(numel(sources), 1); +for s = 1:numel(sources) + chunk = local_collect_source_long(sources(s).table, model, object, ... + match_spec, logical(opts.Verbose)); + if isempty(chunk) || height(chunk) == 0, continue; end + chunk.study_label = repmat(string(sources(s).label), height(chunk), 1); + % source_id is unique per input table (distinct from study_label, since + % several sources can share a label). Used by balanced nesting to give + % each source equal weight within a shared label. + chunk.source_id = repmat(string(sprintf('src%03d', s)), height(chunk), 1); + long_chunks{s} = chunk; +end +keep = ~cellfun(@isempty, long_chunks); +if ~any(keep) + pfx = local_source_prefix(source_family); + if strcmp(source_family, 'atlas') + hint = sprintf(['Pass ''AtlasObj'' (preferred) or ''AtlasName'' substring ' ... + 'to disambiguate, or check that scoring wrote %s columns (suffix _%s) ' ... + 'for this model/object.'], pfx, suffix); + else + hint = sprintf(['Check that scoring wrote %s columns for this ' ... + 'model/object, and that your ''Set'' substring (''%s'') matches ' ... + 'the set token in the column names.'], pfx, char(opts.Set)); + end + error('plot_hrf_curves:NoData', ... + 'No %s* score columns found for source=''%s'', model=%s, object=%s. %s', ... + pfx, source_family, model, object, hint); +end +long = vertcat(long_chunks{keep}); + +% Preserve the original (pre-collapse) condition so balanced nesting can +% give each original condition (e.g. each body site) equal weight even +% after they're relabeled to a shared collapsed name. When not collapsing, +% orig_condition == condition (a no-op level). +long.orig_condition = long.condition; + +if ~isempty(opts.Conditions) + requested = cellstr(string(opts.Conditions)); + if logical(opts.CollapseConditions) + % Collapse mode: every condition matching a pattern is RELABELED + % to that pattern's canonical name, so all matches pool into one + % averaged series instead of one series per distinct condition. + % orig_condition keeps the body-site identity for balanced nesting. + labels = local_resolve_condition_labels(requested, opts.ConditionLabels); + [keep_cond, new_cond] = local_collapse_conditions(long.condition, requested, labels); + long = long(keep_cond, :); + long.condition = new_cond(keep_cond); + else + long = long(local_condition_pattern_match(long.condition, requested), :); + end +end +if height(long) == 0 + error('plot_hrf_curves:NoMatchingConditions', ... + 'After Conditions filter, no rows remain.'); +end + +% 2. Pool across (subject, run) per (condition, region, lag). +contrast = local_normalize_contrast(opts.Contrast); +pooled = local_pool_subjects(long, logical(opts.BalancedNesting), contrast, char(opts.ContrastBy)); +if logical(opts.Verbose) + if isempty(pooled) || height(pooled) == 0 + fprintf(' pooled: 0 rows <-- this will cause "No regions to plot"\n'); + else + fprintf(' pooled: %d rows; %d unique regions; ranges: mean=[%.3g, %.3g], n=[%d, %d]\n', ... + height(pooled), numel(unique(pooled.region)), ... + min(pooled.mean, [], 'omitnan'), max(pooled.mean, [], 'omitnan'), ... + min(pooled.n), max(pooled.n)); + end +end + +% 3. Pick regions. +if ~isempty(opts.Regions) + % Allow either exact match against pooled.region (when AtlasObj was + % passed and labels are clean) or case-insensitive substring match + % (when AtlasName/no-arg paths leave pooled.region holding the full + % atlas__ token). Substring match handles e.g. user + % passing 'Cblm_VIIIa_L' when pooled has + % 'CANLab2024_MNI152NLin2009cAsym_coarse_2mm_Cblm_VIIIa_L'. + requested = cellstr(string(opts.Regions)); + pool_regions = cellstr(unique(pooled.region)); + top_regions = {}; + for r = 1:numel(requested) + req = requested{r}; + exact = pool_regions(strcmp(pool_regions, req)); + if ~isempty(exact) + top_regions = [top_regions; exact(:)]; %#ok + continue + end + substr = pool_regions(contains(lower(pool_regions), lower(req))); + if ~isempty(substr) + top_regions = [top_regions; substr(:)]; %#ok + end + end + top_regions = unique(top_regions, 'stable'); +else + top_regions = local_rank_regions(pooled, char(opts.RankBy), opts.TopN, opts.Alpha); +end +if isempty(top_regions) + avail = cellstr(unique(pooled.region)); + n_show = min(30, numel(avail)); + avail_str = strjoin(avail(1:n_show), ', '); + if numel(avail) > n_show + avail_str = sprintf('%s, ... (%d total)', avail_str, numel(avail)); + end + if ~isempty(opts.Regions) + % Regions were requested but none matched -- almost always means + % the score CSVs were scored with a DIFFERENT atlas than the one + % whose region names are being requested. List what IS available + % so the mismatch is obvious. + requested_str = strjoin(cellstr(string(opts.Regions)), ', '); + error('plot_hrf_curves:NoMatchingRegions', ... + ['None of the requested Regions {%s} matched any atlas column ' ... + 'in the score CSVs. This usually means the CSVs were scored ' ... + 'with a different atlas than the one you are plotting -- e.g. ' ... + 'scored with canlab2024 but plotting painpathways2024 region ' ... + 'names. Re-score with the desired AtlasObj (and a distinct ' ... + '''AtlasName'' if its atlas_name collides with another atlas).\n' ... + 'Available atlas regions in the CSVs (%d): %s'], ... + requested_str, numel(avail), avail_str); + else + error('plot_hrf_curves:NoRegions', ... + ['No regions to plot. pooled table has %d rows / %d unique ' ... + 'regions. If pooled is non-empty but ranking returned empty, ' ... + 'the RankBy metric (%s) may have produced all-NaN scores -- ' ... + 'try ''peak_abs''.\nAvailable regions: %s'], ... + height(pooled), numel(avail), char(opts.RankBy), avail_str); + end +end + +% 4. Plot. +fig = local_plot_grid(pooled, top_regions, opts); +end + + +% ========================================================================= +% Data collection +% ========================================================================= +function long = local_collect_source_long(input_table, model, object, match_spec, verbose) +% Returns one row per (subject, run_label, condition, region, lag_seconds, +% value), where 'region' holds the unit label (atlas region / signature / +% network) regardless of source family. Column matching is dispatched by +% match_spec.family ('atlas' | 'signature' | 'imageset'). +long = table(); +v = input_table.Properties.VariableNames; +file_col = sprintf('%s_scores_file', object); +if ~any(strcmp(file_col, v)) + error('plot_hrf_curves:NoFileColumn', ... + 'input_table is missing %s.', file_col); +end + +suffix_str = ['_' match_spec.suffix]; +labels = local_labels_from_atlas(match_spec.atlas_obj); + +if verbose + fprintf('plot_hrf_curves: input_table has %d rows; source=''%s'', model=''%s'', object=''%s''\n', ... + height(input_table), match_spec.family, model, object); + switch match_spec.family + case 'atlas' + if ~isempty(labels) + fprintf(' matcher: AtlasObj.labels (%d labels)\n', numel(labels)); + elseif ~isempty(match_spec.atlas_name) + fprintf(' matcher: AtlasName substring ''%s''\n', match_spec.atlas_name); + else + fprintf(' matcher: auto (any atlas_*_%s)\n', match_spec.suffix); + end + case {'signature', 'imageset'} + pfx = local_source_prefix(match_spec.family); + if ~isempty(match_spec.set) + fprintf(' matcher: %s* columns, Set substring ''%s''\n', pfx, match_spec.set); + else + fprintf(' matcher: any %s* column\n', pfx); + end + end +end + +chunks = {}; +n_loaded = 0; +n_skipped_model = 0; +n_skipped_path = 0; +n_skipped_meta = 0; +n_skipped_no_atlas = 0; +seen_paths = strings(0, 1); +for i = 1:height(input_table) + row_model = local_get_string(input_table, i, 'model'); + if ~isempty(model) && ~strcmpi(row_model, model) + n_skipped_model = n_skipped_model + 1; + continue + end + path = char(string(input_table.(file_col)(i))); + if isempty(path) || exist(path, 'file') ~= 2 + n_skipped_path = n_skipped_path + 1; + continue + end + try + T = readtable(path, 'TextType', 'string'); + catch + n_skipped_path = n_skipped_path + 1; + continue + end + cols = T.Properties.VariableNames; + if ~any(strcmp('condition', cols)) || ~any(strcmp('lag_seconds', cols)) + n_skipped_meta = n_skipped_meta + 1; + continue + end + + [region_cols, region_labels] = local_match_source_columns(cols, match_spec, labels, suffix_str); + if isempty(region_cols) + n_skipped_no_atlas = n_skipped_no_atlas + 1; + if verbose && n_skipped_no_atlas <= 2 + pfx = local_source_prefix(match_spec.family); + like = cols(startsWith(cols, pfx)); + fprintf(' row %d (model=%s): no matching %s columns. %s* present (first 3): %s\n', ... + i, row_model, match_spec.family, pfx, strjoin(like(1:min(3, numel(like))), ', ')); + end + continue + end + + n_loaded = n_loaded + 1; + seen_paths(end + 1, 1) = string(path); %#ok + if verbose && n_loaded <= 3 + fprintf(' loaded row %d model=%s subj=%s cols matched: %d path: %s\n', ... + i, row_model, local_get_string(input_table, i, 'subject'), numel(region_cols), path); + end + + n = height(T); + subj = string(local_get_string(input_table, i, 'subject')); + run = string(local_get_string(input_table, i, 'run_label')); + + for c = 1:numel(region_cols) + chunk = table(); + chunk.subject = repmat(subj, n, 1); + chunk.run_label = repmat(run, n, 1); + chunk.condition = string(T.condition); + chunk.lag_seconds = double(T.lag_seconds); + chunk.region = repmat(string(region_labels{c}), n, 1); + chunk.value = double(T.(region_cols{c})); + chunks{end + 1} = chunk; %#ok + end +end + +if ~isempty(chunks) + long = vertcat(chunks{:}); +end + +if verbose + fprintf(' loaded %d row(s); skipped model=%d, path=%d, meta=%d, no_atlas=%d\n', ... + n_loaded, n_skipped_model, n_skipped_path, n_skipped_meta, n_skipped_no_atlas); + n_unique_paths = numel(unique(seen_paths)); + if n_loaded > 0 && n_unique_paths < n_loaded + fprintf([' WARNING: %d loaded rows reference only %d unique file paths -- ' ... + 'multiple rows point at the same score CSV. The scoring step probably ' ... + 'wrote one file per (subject, run) regardless of model, so every model ' ... + 'filter ends up reading the same data.\n'], ... + n_loaded, n_unique_paths); + end + if ~isempty(long) + % Cheap counts only -- avoid unique() / splitapply on the full + % (potentially 10M+) row table, which can be slow or run into + % memory pressure and is not worth it for diagnostics. + fprintf(' long table: %d rows; unique regions: %d; unique conditions: %d\n', ... + height(long), numel(unique(long.region)), numel(unique(long.condition))); + end +end +end + + +function labels = local_labels_from_atlas(atlas_obj) +labels = {}; +if isempty(atlas_obj), return; end +if isprop(atlas_obj, 'labels') && ~isempty(atlas_obj.labels) + labels = cellstr(string(atlas_obj.labels)); + labels = labels(:); +end +end + + +function pfx = local_source_prefix(family) +switch family + case 'atlas', pfx = 'atlas_'; + case 'signature', pfx = 'sig_'; + case 'imageset', pfx = 'map_'; + otherwise, pfx = 'atlas_'; +end +end + + +function [matched_cols, matched_labels] = local_match_source_columns(cols, match_spec, labels, suffix_str) +% Dispatch column matching by source family. +switch match_spec.family + case 'atlas' + [matched_cols, matched_labels] = local_match_atlas_columns( ... + cols, labels, match_spec.atlas_name, suffix_str); + case {'signature', 'imageset'} + [matched_cols, matched_labels] = local_match_prefixed_columns( ... + cols, local_source_prefix(match_spec.family), match_spec.set); + otherwise + matched_cols = {}; matched_labels = {}; +end +end + + +function [matched_cols, matched_labels] = local_match_prefixed_columns(cols, prefix, set_name) +% Match sig_/map_ columns. Exclude *_se uncertainty columns. When set_name +% is non-empty, restrict to columns whose name contains the set token +% (case-insensitive) and strip 'prefix_' to get the unit label; +% otherwise strip just 'prefix' (label keeps the set, e.g. 'all_NPS'). +matched_cols = {}; +matched_labels = {}; +set_name = char(set_name); +for k = 1:numel(cols) + col = cols{k}; + if ~startsWith(col, prefix), continue; end + if endsWith(col, '_se'), continue; end + if ~isempty(set_name) && ~contains(lower(col), lower(set_name)) + continue + end + mid = col(length(prefix) + 1 : end); % drop 'sig_' / 'map_' + if ~isempty(set_name) + lc_mid = lower(mid); + idx = strfind(lc_mid, lower(set_name)); + if ~isempty(idx) + start_after = idx(1) + length(set_name); + if start_after <= length(mid) && mid(start_after) == '_' + start_after = start_after + 1; + end + label = mid(start_after:end); + if isempty(label), label = mid; end + else + label = mid; + end + else + label = mid; + end + matched_cols{end + 1} = col; %#ok + matched_labels{end + 1} = label; %#ok +end +end + + +function [matched_cols, matched_labels] = local_match_atlas_columns(cols, labels, atlas_name, suffix_str) +matched_cols = {}; +matched_labels = {}; + +% Tier A: AtlasObj.labels drive matching. For each label, look for a column +% that ends with __. Region names come from +% the original labels (preserving any non-validname chars like '/'). +if ~isempty(labels) + for L = 1:numel(labels) + tok = matlab.lang.makeValidName(char(labels{L})); + end_pat = ['_' tok suffix_str]; + is_match = startsWith(cols, 'atlas_') & endsWith(cols, end_pat); + idx = find(is_match); + for k = 1:numel(idx) + matched_cols{end + 1} = cols{idx(k)}; %#ok + matched_labels{end + 1} = labels{L}; %#ok + end + end + return +end + +% Tier B: AtlasName substring match (case-insensitive). Region name is +% the column middle with the matched substring stripped from the front. +if ~isempty(atlas_name) + is_atlas = startsWith(cols, 'atlas_') & endsWith(cols, suffix_str); + is_match = is_atlas & contains(lower(cols), lower(atlas_name)); + hit_cols = cols(is_match); + for k = 1:numel(hit_cols) + col = hit_cols{k}; + mid = col(length('atlas_') + 1 : end - length(suffix_str)); + lc_mid = lower(mid); + lc_name = lower(atlas_name); + idx = strfind(lc_mid, lc_name); + if ~isempty(idx) + % Strip the matched substring plus a trailing underscore. + start_after = idx(1) + length(atlas_name); + if start_after <= length(mid) && mid(start_after) == '_' + start_after = start_after + 1; + end + region = mid(start_after:end); + if isempty(region), region = mid; end + else + region = mid; + end + matched_cols{end + 1} = col; %#ok + matched_labels{end + 1} = region; %#ok + end + return +end + +% Tier C: any atlas_*_ column. Region name = the full middle. +is_match = startsWith(cols, 'atlas_') & endsWith(cols, suffix_str); +hit_cols = cols(is_match); +for k = 1:numel(hit_cols) + col = hit_cols{k}; + matched_cols{end + 1} = col; %#ok + matched_labels{end + 1} = col(length('atlas_') + 1 : end - length(suffix_str)); %#ok +end +end + + +function pooled = local_pool_subjects(long, balanced, contrast, contrast_by) +% Two-stage pooling: (1) collapse everything within each subject down to a +% single value per (study_label, condition, region, lag); (2) average those +% per-subject values across subjects, with SEM = sd/sqrt(n_subjects). +% +% When `contrast` = {A, B} is supplied, a PAIRED contrast is taken between +% stages: each subject's (condition A - condition B) difference replaces the +% two conditions, so stage 2's mean/sem/t/p describe the paired difference +% curve (and the significance dots mark where A differs from B per lag). +% +% Stage 1 nesting: +% balanced=true (default) -- hierarchical, equal weight at each level. +% Average AWAY the nuisance factors one at a time, innermost first: +% run_label -> average runs within each (subject, source, body site) +% orig_condition -> average body sites equally within each (subject, source) +% source_id -> average sources equally within each (subject, label) +% This makes the result independent of how many runs/body-sites/source +% rows each cell happens to have -- no assumption of balance. +% balanced=false -- legacy row-weighted: a single flat mean over all +% nuisance rows at once (cells with more rows get more weight). +% +% In both cases the curve-identity columns kept through to stage 2 are +% (subject, study_label[if present], condition, region, lag_seconds). +if nargin < 2, balanced = true; end +if nargin < 3, contrast = {}; end +if nargin < 4, contrast_by = 'auto'; end + +has_study = any(strcmp('study_label', long.Properties.VariableNames)); + +% Columns that define a per-subject curve cell (kept through stage 1). +keep_cols = {'subject', 'condition', 'region', 'lag_seconds'}; +if has_study + keep_cols = [keep_cols, {'study_label'}]; +end +keep_cols = intersect(keep_cols, long.Properties.VariableNames, 'stable'); + +% Nuisance factors to average away, innermost -> outermost. +nuisance = {'run_label', 'orig_condition', 'source_id'}; +nuisance = intersect(nuisance, long.Properties.VariableNames, 'stable'); + +work = long; +if balanced + % Peel one nuisance factor at a time so each level is equal-weighted. + for i = 1:numel(nuisance) + f = nuisance{i}; + group_cols = setdiff(work.Properties.VariableNames, {f, 'value'}, 'stable'); + work = local_group_mean(work, group_cols); + end + % Any remaining nuisance columns (none expected) collapse here. + g1 = local_group_mean(work, keep_cols); +else + % Legacy: single flat mean over all nuisance rows at once. + g1 = local_group_mean(work, keep_cols); +end + +% Paired contrast: per subject, replace levels A and B (along the chosen +% axis -- condition or study_label) with their difference (A - B). +if ~isempty(contrast) + g1 = local_paired_contrast(g1, contrast, contrast_by, has_study); +end + +% Stage 2: across-subject mean + SEM. n is the count of FINITE subjects +% (not GroupCount, which would include subjects whose value is NaN at this +% lag, e.g. an all-NaN region/volume), so the SEM denominator is honest. +final_group = intersect({'study_label', 'condition', 'region', 'lag_seconds'}, ... + g1.Properties.VariableNames, 'stable'); +g2 = groupsummary(g1, final_group, ... + {@(x) mean(x, 'omitnan'), @(x) std(x, 'omitnan'), @(x) sum(isfinite(x))}, 'value'); + +pooled = table(); +for c = 1:numel(final_group) + pooled.(final_group{c}) = g2.(final_group{c}); +end +pooled.mean = g2.fun1_value; +pooled.sd = g2.fun2_value; +pooled.n = g2.fun3_value; +pooled.sem = g2.fun2_value ./ sqrt(max(g2.fun3_value, 1)); + +% Per-lag one-sample t-test of the across-subject mean vs 0, so the plot +% can mark lags where the curve is significantly different from baseline. +% df = n_subjects - 1; p is two-tailed. n < 2 -> NaN (no test possible). +tstat = pooled.mean ./ pooled.sem; +dfe = max(pooled.n - 1, 0); +pval = NaN(height(pooled), 1); +ok = pooled.n >= 2 & isfinite(tstat) & pooled.sem > 0; +pval(ok) = 2 * (1 - tcdf(abs(tstat(ok)), dfe(ok))); +pooled.t = tstat; +pooled.p = pval; +end + + +function T = local_group_mean(T, group_cols) +% Group T by group_cols and replace 'value' with the per-group mean, +% dropping all other (nuisance) columns. NaN-omitting. +g = groupsummary(T, group_cols, 'mean', 'value'); +out = table(); +for c = 1:numel(group_cols) + out.(group_cols{c}) = g.(group_cols{c}); +end +out.value = g.mean_value; +T = out; +end + + +function contrast = local_normalize_contrast(c) +% Normalize the Contrast arg to a 1x2 cellstr {A, B} or {} if not set. +contrast = {}; +if isempty(c), return; end +cc = cellstr(string(c)); +if numel(cc) ~= 2 + error('plot_hrf_curves:BadContrast', ... + 'Contrast must be two condition names {A, B}; got %d.', numel(cc)); +end +contrast = cc(:)'; +end + + +function g = local_paired_contrast(g1, contrast, contrast_by, has_study) +% Subject-level paired difference (A - B) along a chosen axis column -- +% 'condition' (the two levels are condition names) or 'study_label' (the +% two levels are study labels, e.g. comparing the SAME condition across +% different output folders). Joins the A-rows to the B-rows on every +% identity column EXCEPT the contrast axis, subtracts, and relabels the +% axis to 'A - B'. Inner join => only cells with BOTH levels contribute, +% so it's a proper paired set. +A = contrast{1}; B = contrast{2}; + +axis_col = local_resolve_contrast_axis(g1, contrast, contrast_by, has_study); +axis_str = string(g1.(axis_col)); + +% Identity keys = all candidate identity columns except the contrast axis. +all_id = intersect({'subject', 'condition', 'region', 'lag_seconds', 'study_label'}, ... + g1.Properties.VariableNames, 'stable'); +keys = setdiff(all_id, {axis_col}, 'stable'); + +gA = g1(axis_str == A, :); +gB = g1(axis_str == B, :); +gA.valA = gA.value; gA.value = []; +gB.valB = gB.value; gB.value = []; + +J = innerjoin(gA(:, [keys, {'valA'}]), gB(:, [keys, {'valB'}]), 'Keys', keys); +if height(J) == 0 + error('plot_hrf_curves:ContrastNoPairs', ... + ['Contrast {%s - %s} on axis ''%s'' produced no paired cells. The two ' ... + 'levels likely come from disjoint subjects (an unpaired design); a ' ... + 'paired contrast needs the same subjects on both sides.'], A, B, axis_col); +end +J.value = J.valA - J.valB; +J.(axis_col) = repmat(string(sprintf('%s - %s', A, B)), height(J), 1); + +out_cols = [keys, {axis_col, 'value'}]; +g = J(:, out_cols); +end + + +function axis_col = local_resolve_contrast_axis(g1, contrast, contrast_by, has_study) +% Decide which column the two contrast levels live in. 'auto' picks +% whichever of condition / study_label contains BOTH levels. +A = contrast{1}; B = contrast{2}; +by = lower(strtrim(char(contrast_by))); +in_col = @(col) any(strcmp(string(g1.(col)), A)) && any(strcmp(string(g1.(col)), B)); + +switch by + case {'condition', 'cond'} + axis_col = 'condition'; + case {'study_label', 'study', 'label'} + if ~has_study + error('plot_hrf_curves:NoStudyAxis', ... + 'ContrastBy=''study_label'' needs labeled multi-source input (struct array with .label).'); + end + axis_col = 'study_label'; + case 'auto' + cond_ok = in_col('condition'); + study_ok = has_study && in_col('study_label'); + if cond_ok + axis_col = 'condition'; + elseif study_ok + axis_col = 'study_label'; + else + avail_c = strjoin(unique(cellstr(string(g1.condition)), 'stable'), ', '); + extra = ''; + if has_study + extra = sprintf(' | study labels: %s', ... + strjoin(unique(cellstr(string(g1.study_label)), 'stable'), ', ')); + end + error('plot_hrf_curves:ContrastLevelsMissing', ... + ['Contrast levels {%s, %s} were not both found in ''condition''%s. ' ... + 'conditions: %s%s'], A, B, ... + local_ternary(has_study, ' or ''study_label''', ''), avail_c, extra); + end + otherwise + error('plot_hrf_curves:UnknownContrastBy', ... + 'ContrastBy must be ''auto'', ''condition'', or ''study_label''. Got ''%s''.', by); +end + +% Final sanity: both levels must exist along the chosen axis. +if ~in_col(axis_col) + present = strjoin(unique(cellstr(string(g1.(axis_col))), 'stable'), ', '); + error('plot_hrf_curves:ContrastLevelsMissing', ... + 'Contrast levels {%s, %s} not both present in ''%s''. Available: %s', ... + A, B, axis_col, present); +end +end + + +function out = local_ternary(cond, a, b) +if cond, out = a; else, out = b; end +end + + +function top_regions = local_rank_regions(pooled, rank_by, top_n, alpha) +% Rank by a per-region scalar collapsed across (condition, lag), then keep +% the top top_n region names in descending order. +% +% Magnitude modes (score the mean curve only): +% 'peak_abs' max|mean| 'peak' max signed mean +% 'auc_abs' sum|mean| 'sd' across-lag SD of the mean +% Reliability / SNR modes (use the across-subject t and SEM, so a clean, +% tight, HRF-shaped response beats a big noisy one): +% 'peak_t' max|t| 'auc_t' sum|t| +% 'n_sig' # lags p0) +% Shape mode (reward HRF-plausibility directly): +% 'shape_r2' max over conditions of corr(mean curve, canonical HRF)^2 +if nargin < 4 || isempty(alpha), alpha = 0.05; end +rank_by = lower(strtrim(char(rank_by))); +regions = unique(pooled.region, 'stable'); +scores = zeros(numel(regions), 1); + +has_t = any(strcmp('t', pooled.Properties.VariableNames)); +has_p = any(strcmp('p', pooled.Properties.VariableNames)); +has_sem = any(strcmp('sem', pooled.Properties.VariableNames)); + +if ismember(rank_by, {'peak_t', 'auc_t', 'n_sig'}) && ~(has_t || has_p) + error('plot_hrf_curves:RankNeedsStats', ... + ['RankBy=''%s'' needs the per-lag t/p columns, which require >= 2 ' ... + 'subjects to compute. Use a magnitude mode (peak_abs/auc_abs) for ' ... + 'single-subject data.'], rank_by); +end + +for r = 1:numel(regions) + mask = pooled.region == regions(r); + mu = pooled.mean(mask); + finite_mu = mu(isfinite(mu)); + if isempty(finite_mu), scores(r) = -Inf; continue; end + + switch rank_by + case 'peak_abs' + scores(r) = max(abs(finite_mu)); + case 'peak' + scores(r) = max(finite_mu); + case 'auc_abs' + scores(r) = sum(abs(finite_mu)); + case 'sd' + scores(r) = std(finite_mu); + case 'peak_t' + tv = pooled.t(mask); tv = tv(isfinite(tv)); + scores(r) = local_or(@() max(abs(tv)), isempty(tv)); + case 'auc_t' + tv = pooled.t(mask); tv = tv(isfinite(tv)); + scores(r) = local_or(@() sum(abs(tv)), isempty(tv)); + case 'n_sig' + pv = pooled.p(mask); + scores(r) = sum(pv < alpha & isfinite(pv)); + case 'snr' + if ~has_sem, error('plot_hrf_curves:RankNeedsSEM', 'snr ranking needs the SEM column.'); end + sv = pooled.sem(mask); sv = sv(isfinite(sv) & sv > 0); + denom = local_or(@() median(sv), isempty(sv)); + if ~(denom > 0), scores(r) = -Inf; else, scores(r) = max(abs(finite_mu)) / denom; end + case 'shape_r2' + scores(r) = local_region_shape_r2(pooled, mask); + case 'hrf_match' + scores(r) = local_region_hrf_match_t(pooled, mask); + otherwise + error('plot_hrf_curves:UnknownRankBy', ... + ['Unknown RankBy: %s. Magnitude: peak_abs, peak, auc_abs, sd. ' ... + 'Reliability: peak_t, auc_t, n_sig, snr. Shape: shape_r2, hrf_match.'], rank_by); + end +end + +[~, order] = sort(scores, 'descend'); +keep = order(1:min(top_n, numel(order))); +top_regions = cellstr(regions(keep)); +end + + +function v = local_or(fn, is_empty) +% Evaluate fn() unless is_empty, in which case return -Inf (sorts last). +if is_empty, v = -Inf; else, v = fn(); end +end + + +function r2 = local_region_shape_r2(pooled, region_mask) +% Best (over conditions) squared correlation of the region's mean curve to +% the canonical HRF resampled to that condition's lags. Needs spm_hrf. +r2 = -Inf; +if exist('spm_hrf', 'file') ~= 2, return; end +sub = pooled(region_mask, :); +conds = unique(sub.condition, 'stable'); +best = -Inf; +for c = 1:numel(conds) + cm = sub.condition == conds(c); + s = sortrows(sub(cm, :), 'lag_seconds'); + y = s.mean(:); x = s.lag_seconds(:); + ok = isfinite(y) & isfinite(x); + if sum(ok) < 4, continue; end + ref = local_canonical_at_lags_local(x(ok)); + if std(y(ok)) > 0 && std(ref) > 0 + rr = corr(y(ok), ref(:)) ^ 2; + if rr > best, best = rr; end + end +end +if isfinite(best), r2 = best; end +end + + +function ref = local_canonical_at_lags_local(lags) +dt = 0.1; +h = spm_hrf(dt); h = h ./ max(h); +t_fine = (0:numel(h) - 1) * dt; +ref = interp1(t_fine, h, lags(:), 'linear', 0); +end + + +function tval = local_region_hrf_match_t(pooled, region_mask) +% Offset-removed, latency-flexible matched-filter t-statistic. For each +% condition, regress the mean curve on [constant, shifted-canonical-HRF]; +% the constant absorbs any sustained baseline offset (the thing that makes +% peak_t/n_sig surface flat deactivations), and the canonical coefficient +% captures the TRANSIENT bump. Its reliability t uses the per-lag SEM. The +% canonical is swept over peak latencies (5..15s) so a late bump (e.g. +% Thal_CL) still matches. Score = best (over condition x latency) signed t, +% favouring reliable positive HRF-shaped excursions. +tval = -Inf; +if exist('spm_hrf', 'file') ~= 2, return; end +dt = 0.1; h = spm_hrf(dt); h = h ./ max(h); t_fine = (0:numel(h) - 1) * dt; +shifts = 0:2:10; % canonical peaks at ~5s; shift delays it to ~15s +sub = pooled(region_mask, :); +conds = unique(sub.condition, 'stable'); +best = -Inf; +for c = 1:numel(conds) + cm = sub.condition == conds(c); + s = sortrows(sub(cm, :), 'lag_seconds'); + y = s.mean(:); x = s.lag_seconds(:); se = s.sem(:); + ok = isfinite(y) & isfinite(x) & isfinite(se) & se > 0; + if sum(ok) < 5, continue; end + y = y(ok); x = x(ok); w = se(ok) .^ 2; + for d = shifts + canon = interp1(t_fine, h, x - d, 'linear', 0); + if std(canon) == 0, continue; end + X = [ones(numel(x), 1), canon(:)]; + XtX = X' * X; + if rcond(XtX) < 1e-12, continue; end + Bcov = XtX \ eye(2); + b = Bcov * (X' * y); + Vb = Bcov * (X' * diag(w) * X) * Bcov; % GLS-style cov of b + se_b2 = sqrt(max(Vb(2, 2), 0)); + if se_b2 > 0 + t = b(2) / se_b2; % signed: positive canonical-shaped amplitude + if t > best, best = t; end + end + end +end +if isfinite(best), tval = best; end +end + + +% ========================================================================= +% Plot grid +% ========================================================================= +function fig = local_plot_grid(pooled, top_regions, opts) +n = numel(top_regions); +if ~isempty(opts.Layout) && numel(opts.Layout) == 2 + nrows = opts.Layout(1); ncols = opts.Layout(2); +else + [nrows, ncols] = local_auto_grid(n); +end + +if isempty(opts.FigureSize) + fig_w = 280 * ncols; + fig_h = 220 * nrows + 60; +else + fig_w = opts.FigureSize(1); + fig_h = opts.FigureSize(2); +end + +fig = figure('Color', 'w', 'Position', [80, 80, fig_w, fig_h]); +tl = tiledlayout(fig, nrows, ncols, 'Padding', 'compact', 'TileSpacing', 'compact'); + +% Each curve is one (study_label, condition) series. In single-source +% mode pooled has no study_label column, so we degenerate to condition-only. +has_study = any(strcmp('study_label', pooled.Properties.VariableNames)); +if has_study + series_keys = unique(pooled(:, {'study_label', 'condition'}), 'stable'); +else + series_keys = unique(pooled(:, {'condition'}), 'stable'); +end +% Resolve a per-series style (color / line style / width). Precedence per +% series: matching SeriesStyle entry > Colors override (by order) > palette; +% line style/width from SeriesStyle entry else the global LineStyle/LineWidth. +series_display = strings(height(series_keys), 1); +for k = 1:height(series_keys) + cond_k = series_keys.condition(k); + if has_study && strlength(series_keys.study_label(k)) > 0 + series_display(k) = sprintf('%s | %s', char(series_keys.study_label(k)), char(cond_k)); + else + series_display(k) = string(cond_k); + end +end +series_styles = local_resolve_series_styles(series_display, opts); +share_y = logical(opts.ShareYAxis); +show_sig = logical(opts.ShowSignificance) && any(strcmp('p', pooled.Properties.VariableNames)); +alpha = opts.Alpha; + +% Shared y-range across panels (only used when ShareYAxis is true). The +% significance markers sit slightly above the band, so leave headroom. +if share_y + [gy_min, gy_max] = local_panel_yrange(pooled, true(height(pooled), 1), opts.ErrorBand); + gy_pad = 0.08 * (gy_max - gy_min + eps); +end + +for r = 1:n + ax = nexttile(tl); + hold(ax, 'on'); + if logical(opts.YZero) + yline(ax, 0, ':', 'Color', [0.5 0.5 0.5], 'LineWidth', 0.75, 'HandleVisibility', 'off'); + end + + % Per-panel y-range when not sharing, so wildly different scales + % (e.g. across signatures) each fill their own panel. + if share_y + y_lo = gy_min - gy_pad; y_hi = gy_max + gy_pad; + else + region_mask = pooled.region == string(top_regions{r}); + [py_min, py_max] = local_panel_yrange(pooled, region_mask, opts.ErrorBand); + py_pad = 0.08 * (py_max - py_min + eps); + y_lo = py_min - py_pad; y_hi = py_max + py_pad; + end + yspan = y_hi - y_lo; + + for k = 1:height(series_keys) + cond = series_keys.condition(k); + mask = pooled.region == string(top_regions{r}) & pooled.condition == cond; + if has_study + study = series_keys.study_label(k); + mask = mask & pooled.study_label == study; + if strlength(study) > 0 + display_name = sprintf('%s | %s', char(study), char(cond)); + else + display_name = char(cond); + end + else + display_name = char(cond); + end + if ~any(mask), continue; end + st = series_styles(k); + sub = sortrows(pooled(mask, :), 'lag_seconds'); + x = sub.lag_seconds(:); + y = sub.mean(:); + e = local_err(pooled, mask, opts.ErrorBand); + if any(e > 0) + xx = [x; flipud(x)]; + yy = [y + e; flipud(y - e)]; + fill(ax, xx, yy, st.color, 'FaceAlpha', 0.18, 'EdgeColor', 'none', ... + 'HandleVisibility', 'off'); + end + plot(ax, x, y, 'Color', st.color, 'LineStyle', st.line_style, ... + 'LineWidth', st.line_width, 'DisplayName', display_name); + + % Significance: mark lags where the across-subject mean differs from + % 0 at p < Alpha, as dots just above the band. Ring the FIRST such + % lag (the onset) so "when does it become significant" is visible. + if show_sig && any(strcmp('p', sub.Properties.VariableNames)) + sig = sub.p < alpha & isfinite(sub.p); + if any(sig) + ymark = (y_hi - 0.03 * yspan) - (k - 1) * 0.035 * yspan; % stagger by series + plot(ax, x(sig), repmat(ymark, sum(sig), 1), '.', ... + 'Color', st.color, 'MarkerSize', 9, 'HandleVisibility', 'off'); + first_sig = find(sig, 1, 'first'); + plot(ax, x(first_sig), ymark, 'o', 'Color', st.color, ... + 'MarkerSize', 7, 'LineWidth', 1.2, 'HandleVisibility', 'off'); + end + end + end + + title(ax, char(top_regions{r}), 'Interpreter', 'none', 'FontWeight', 'normal'); + xlabel(ax, 'Lag (s)'); ylabel(ax, 'Mean signal'); + grid(ax, 'on'); box(ax, 'on'); + if isfinite(y_lo) && isfinite(y_hi) && y_hi > y_lo + ylim(ax, [y_lo, y_hi]); + end + + if r == 1 + legend(ax, 'Location', 'best', 'Interpreter', 'none', 'Box', 'off'); + end +end + +if isempty(char(opts.Title)) + unit_noun = local_unit_noun(char(opts.Source)); + extra = ''; + if logical(opts.ShowSignificance) + extra = sprintf('; dots p<%.3g vs 0, ring=onset', opts.Alpha); + end + tstr = sprintf('Top %d %s (model=%s, object=%s, %s ± %s%s)', ... + n, unit_noun, char(opts.Model), char(opts.Object), ... + char(opts.Source), char(opts.ErrorBand), extra); +else + tstr = char(opts.Title); +end +title(tl, tstr, 'Interpreter', 'none'); +end + + +function noun = local_unit_noun(family) +switch lower(family) + case 'signature', noun = 'signatures'; + case 'imageset', noun = 'networks'; + otherwise, noun = 'atlas regions'; +end +end + + +function [y_min, y_max] = local_panel_yrange(pooled, mask, band) +% Min/max of the (mean ± error) band over the masked rows. +y_min = Inf; y_max = -Inf; +lo = pooled.mean(mask) - local_err(pooled, mask, band); +hi = pooled.mean(mask) + local_err(pooled, mask, band); +lo = lo(isfinite(lo)); +hi = hi(isfinite(hi)); +if ~isempty(lo), y_min = min(lo); end +if ~isempty(hi), y_max = max(hi); end +if ~isfinite(y_min) || ~isfinite(y_max) + y_min = -1; y_max = 1; +end +end + + +function e = local_err(pooled, mask, band) +switch lower(char(band)) + case 'sem' + e = pooled.sem(mask); + case 'sd' + e = pooled.sd(mask); + case 'none' + e = zeros(sum(mask), 1); + otherwise + e = pooled.sem(mask); +end +e(~isfinite(e)) = 0; +e = e(:); +% match the sort the caller will apply +[~, ord] = sort(pooled.lag_seconds(mask)); +e = e(ord); +end + + +% ========================================================================= +% Utilities +% ========================================================================= +function [nrows, ncols] = local_auto_grid(n) +ncols = max(1, ceil(sqrt(n))); +nrows = ceil(n / ncols); +end + + +function s = local_normalize_suffix(mode) +switch lower(strtrim(char(mode))) + case 'mean', s = 'mean'; + case 'l1', s = 'meanL1'; + case 'none', s = 'sum'; + otherwise, s = char(mode); +end +end + + +function s = local_get_string(t, i, col) +s = ''; +if any(strcmp(col, t.Properties.VariableNames)) + val = t.(col)(i); + if isstring(val) || ischar(val) + s = char(val); + elseif iscell(val) + s = char(val{1}); + else + try + s = char(string(val)); + catch + end + end +end +end + + +function mask = local_condition_pattern_match(cond_vec, patterns) +% Glob-style match: '*foo' matches anything ending in 'foo', 'foo*' +% matches anything starting with 'foo', '*foo*' matches anything +% containing 'foo'. Patterns without '*' use exact (case-sensitive) +% match. Returns a column logical mask the same height as cond_vec. +cond_str = string(cond_vec); +mask = false(numel(cond_str), 1); +for i = 1:numel(patterns) + p = char(patterns{i}); + if contains(p, '*') + % regexptranslate('wildcard', ...) handles MATLAB's glob -> regex + % conversion (* -> .*, escaping of regex metacharacters). + rx = ['^', regexptranslate('wildcard', p), '$']; + hit = ~cellfun('isempty', regexp(cellstr(cond_str), rx, 'once')); + else + hit = cond_str == string(p); + end + mask = mask | hit(:); +end +end + + +function labels = local_resolve_condition_labels(patterns, user_labels) +% Per-pattern collapsed label. If the user supplied ConditionLabels +% (parallel to Conditions) use those; otherwise derive a clean label +% from each pattern by stripping '*' and surrounding underscores: +% '*_heat_start_ttl_1' -> 'heat_start_ttl_1' +% 'leftface*' -> 'leftface' +user_labels = cellstr(string(user_labels)); +labels = cell(1, numel(patterns)); +for i = 1:numel(patterns) + if numel(user_labels) >= i && ~isempty(user_labels{i}) + labels{i} = user_labels{i}; + else + lab = strrep(patterns{i}, '*', ''); + lab = regexprep(lab, '^_+', ''); % strip leading underscores + lab = regexprep(lab, '_+$', ''); % strip trailing underscores + if isempty(lab), lab = patterns{i}; end + labels{i} = lab; + end +end +end + + +function [keep, new_cond] = local_collapse_conditions(cond_vec, patterns, labels) +% For each row, find the FIRST pattern it matches and relabel the +% condition to that pattern's collapsed label. Rows matching no pattern +% are dropped (keep=false). Returns a full-length keep mask and a +% full-length relabeled condition vector (only meaningful where keep). +cond_str = string(cond_vec); +n = numel(cond_str); +keep = false(n, 1); +new_cond = cond_str; +for i = 1:numel(patterns) + p = char(patterns{i}); + if contains(p, '*') + rx = ['^', regexptranslate('wildcard', p), '$']; + hit = ~cellfun('isempty', regexp(cellstr(cond_str), rx, 'once')); + else + hit = cond_str == string(p); + end + hit = hit(:); + % Only assign rows not already claimed by an earlier pattern. + assign = hit & ~keep; + new_cond(assign) = string(labels{i}); + keep = keep | hit; +end +end + + +function tf = local_is_source_struct(x) +% True when x is a struct array carrying labeled input_tables -- accepts +% either a .table field or .input_table field name. The label field is +% required. +tf = isstruct(x) && isfield(x, 'label') && (isfield(x, 'table') || isfield(x, 'input_table')); +end + + +function sources = local_normalize_sources(input_data) +% Returns a non-empty struct array with fields .label (string) and +% .table (table). A bare table comes back as a single entry with empty +% label, preserving single-source plot behavior. +if istable(input_data) + sources = struct('label', {""}, 'table', {input_data}); + return +end +n = numel(input_data); +sources = struct('label', cell(1, n), 'table', cell(1, n)); +for i = 1:n + if isfield(input_data(i), 'table') + sources(i).table = input_data(i).table; + else + sources(i).table = input_data(i).input_table; + end + sources(i).label = string(input_data(i).label); + if ~istable(sources(i).table) + error('plot_hrf_curves:BadSource', ... + 'sources(%d).table must be a table.', i); + end +end +end + + +function styles = local_resolve_series_styles(series_display, opts) +% Build a per-series style struct array with fields color (1x3), line_style, +% line_width. Precedence: SeriesStyle entry whose .Series substring matches +% the series display name (case-insensitive, first match wins) > Colors +% override (by series order) > default palette. Line style/width from the +% matched SeriesStyle entry else global LineStyle/LineWidth. +nseries = numel(series_display); +palette = local_color_palette(nseries); +override_colors = local_normalize_colors(opts.Colors, nseries); +default_lw = opts.LineWidth; +default_ls = char(opts.LineStyle); + +styles = repmat(struct('color', [0 0 0], 'line_style', '-', 'line_width', 1.6), nseries, 1); +for k = 1:nseries + % defaults + col = palette(k, :); + if ~isempty(override_colors) + col = override_colors(min(k, size(override_colors, 1)), :); + end + ls = default_ls; + lw = default_lw; + + % SeriesStyle pattern match + if ~isempty(opts.SeriesStyle) + ss = opts.SeriesStyle; + for e = 1:numel(ss) + if ~isfield(ss(e), 'Series') || isempty(ss(e).Series), continue; end + pat = char(string(ss(e).Series)); + if contains(lower(char(series_display(k))), lower(pat)) + if isfield(ss(e), 'Color') && ~isempty(ss(e).Color) + col = local_color_to_rgb(ss(e).Color); + end + if isfield(ss(e), 'LineStyle') && ~isempty(ss(e).LineStyle) + ls = char(string(ss(e).LineStyle)); + end + if isfield(ss(e), 'LineWidth') && ~isempty(ss(e).LineWidth) + lw = double(ss(e).LineWidth); + end + break + end + end + end + + styles(k).color = col; + styles(k).line_style = ls; + styles(k).line_width = lw; +end +end + + +function C = local_normalize_colors(colors_in, nseries) %#ok +% Accept an Nx3 numeric matrix or a cell array of color specs; return Nx3. +C = []; +if isempty(colors_in), return; end +if isnumeric(colors_in) + if size(colors_in, 2) == 3 + C = colors_in; + end +elseif iscell(colors_in) + C = zeros(numel(colors_in), 3); + for i = 1:numel(colors_in) + C(i, :) = local_color_to_rgb(colors_in{i}); + end +end +end + + +function rgb = local_color_to_rgb(c) +% Convert a color spec (1x3 numeric, or a char like 'r'/'red') to RGB. +if isnumeric(c) && numel(c) == 3 + rgb = double(c(:)'); + return +end +s = lower(char(string(c))); +map = containers.Map( ... + {'r','g','b','c','m','y','k','w','red','green','blue','cyan','magenta','yellow','black','white','orange','purple','gray','grey'}, ... + {[1 0 0],[0 1 0],[0 0 1],[0 1 1],[1 0 1],[1 1 0],[0 0 0],[1 1 1], ... + [1 0 0],[0 1 0],[0 0 1],[0 1 1],[1 0 1],[1 1 0],[0 0 0],[1 1 1], ... + [1 0.5 0],[0.5 0 0.5],[0.5 0.5 0.5],[0.5 0.5 0.5]}); +if isKey(map, s) + rgb = map(s); +else + rgb = [0 0 0]; +end +end + + +function colors = local_color_palette(k) +% A reasonable diverging palette for up to ~8 conditions. +base = [ + 0.215 0.494 0.722; % blue + 0.894 0.102 0.110; % red + 0.302 0.686 0.290; % green + 0.596 0.306 0.639; % purple + 1.000 0.498 0.000; % orange + 1.000 1.000 0.200; % yellow + 0.651 0.337 0.157; % brown + 0.969 0.506 0.749]; % pink +if k <= size(base, 1) + colors = base(1:k, :); +else + colors = lines(k); +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/plot_hrf_results.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/plot_hrf_results.m new file mode 100644 index 00000000..09bdcf0e --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/plot_hrf_results.m @@ -0,0 +1,10 @@ +function ax = plot_hrf_results(results, varargin) +%PLOT_HRF_RESULTS Backward-compatible wrapper for plot_hrf_by_condition. +% +% ax = plot_hrf_results(results, 'Model', 'sfir', 'Conditions', {'pain'}) +% +% New code should call plot_hrf_by_condition directly. This wrapper keeps +% existing examples working while using the clearer labeling and SE behavior. + +ax = plot_hrf_by_condition(results, varargin{:}); +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/plot_hrf_study_by_subject.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/plot_hrf_study_by_subject.m new file mode 100644 index 00000000..da1cb428 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/plot_hrf_study_by_subject.m @@ -0,0 +1,906 @@ +function ax = plot_hrf_study_by_subject(study, varargin) +%PLOT_HRF_STUDY_BY_SUBJECT Plot HRFs by subject, averaging duplicate runs. + +p = inputParser; +p.addRequired('study', @isstruct); +p.addParameter('Model', 'sfir', @(x) ischar(x) || iscell(x) || isstring(x)); +p.addParameter('SourceModel', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Condition', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Signature', '', @(x) ischar(x) || isstring(x)); +p.addParameter('PlotType', 'fit', @(x) ischar(x) || isstring(x)); +p.addParameter('Unit', 'subject', @(x) ischar(x) || isstring(x)); +p.addParameter('ShowSE', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('SEAlpha', 0.10, @(x) isscalar(x) && x >= 0 && x <= 1); +p.addParameter('ShowGroupMean', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('ShowGroupSE', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('GroupLineWidth', 2.5, @(x) isscalar(x) && x > 0); +p.addParameter('BaselineSeconds', 0, @(x) isscalar(x) && x >= 0); +p.addParameter('TrialOutlierPolicy', 'none', @(x) ischar(x) || isstring(x)); +p.addParameter('TrialOutlierZThreshold', 4, @(x) isscalar(x) && x > 0); +p.addParameter('OutlierWeighting', 'none', @(x) ischar(x) || isstring(x)); +p.addParameter('OutlierZThreshold', 4, @(x) isscalar(x) && x > 0); +p.addParameter('CurveWeights', [], @(x) isempty(x) || isnumeric(x)); +p.addParameter('MissingPolicy', 'warn', @(x) ischar(x) || isstring(x)); +p.parse(study, varargin{:}); +opts = p.Results; +opts.Condition = char(opts.Condition); +opts.Signature = char(opts.Signature); +opts.SourceModel = lower(strtrim(char(opts.SourceModel))); +opts.Models = local_model_names(opts.Model); +opts.Model = opts.Models{1}; +opts.PlotType = lower(char(opts.PlotType)); +opts.Unit = char(opts.Unit); + +switch opts.PlotType + case 'fit' + if numel(opts.Models) > 1 + ax = local_plot_multiple_models(study, opts); + return + end + [Y_runs, SE_runs, run_subject_ids, run_labels, condition_name, x, skipped] = local_collect_curves(study, opts); + case {'trialmean', 'trial_mean', 'trials'} + [Y_runs, SE_runs, run_subject_ids, run_labels, condition_name, x, skipped] = local_collect_trial_means(study, opts); + otherwise + error('Unknown PlotType: %s. Use ''fit'' or ''trialmean''.', opts.PlotType); +end +if isempty(Y_runs) + local_error_no_curves(skipped, study, opts); +end + +[Y, SE, subject_ids] = local_aggregate_unit(Y_runs, SE_runs, run_subject_ids, lower(char(opts.Unit)), run_labels); +curve_weights = local_curve_weights(Y, opts); + +figure; ax = axes; hold(ax, 'on'); +colors = lines(size(Y, 1)); +for s = 1:size(Y, 1) + y = Y(s, :)'; + se = SE(s, :)'; + if logical(opts.ShowSE) && any(~isnan(se)) + fill(ax, [x; flipud(x)], [y + se; flipud(y - se)], colors(s, :), ... + 'FaceAlpha', opts.SEAlpha, 'EdgeColor', 'none', 'HandleVisibility', 'off'); + end + plot(ax, x, y, 'Color', colors(s, :), 'DisplayName', subject_ids{s}); +end +if logical(opts.ShowGroupMean) + group_mean = local_weighted_mean_omitnan(Y, curve_weights)'; + group_se = local_weighted_sem_omitnan(Y, curve_weights)'; + if logical(opts.ShowGroupSE) && size(Y, 1) > 1 && any(~isnan(group_se)) + fill(ax, [x; flipud(x)], [group_mean + group_se; flipud(group_mean - group_se)], ... + [0 0 0], 'FaceAlpha', min(opts.SEAlpha * 2, 0.25), ... + 'EdgeColor', 'none', 'HandleVisibility', 'off'); + end + plot(ax, x, group_mean, 'k-', 'LineWidth', opts.GroupLineWidth, 'DisplayName', 'Group mean'); +end +legend(ax, 'Interpreter', 'none'); +title(ax, local_title(opts, condition_name, SE, Y, study, curve_weights), 'Interpreter', 'none'); +xlabel(ax, 'Seconds after event onset'); +ylabel(ax, local_ylabel(opts, study)); +hline(0, 'k-'); +end + +function local_error_no_curves(skipped, study, opts) +reason_summary = local_skip_reason_summary(skipped); +available = local_available_summary(study); +error(['No valid HRF curves found for Model=%s, SourceModel=%s, Signature=%s, Condition=%s. ' ... + 'Skipped %d result(s). Top reasons: %s. Available: %s'], ... + char(opts.Model), local_display_or_default(opts.SourceModel), local_display_or_default(opts.Signature), ... + local_display_or_default(opts.Condition), numel(skipped), reason_summary, available); +end + +function txt = local_skip_reason_summary(skipped) +if isempty(skipped) + txt = 'none recorded'; + return +end +reasons = {skipped.reason}; +[u, ~, ic] = unique(reasons, 'stable'); +counts = accumarray(ic(:), 1); +n = min(numel(u), 4); +parts = cell(1, n); +for i = 1:n + parts{i} = sprintf('%s (%d)', u{i}, counts(i)); +end +txt = strjoin(parts, '; '); +end + +function txt = local_available_summary(study) +parts = {}; +if isfield(study, 'source_models') && ~isempty(study.source_models) + parts{end + 1} = sprintf('source_models={%s}', strjoin(cellstr(string(study.source_models)), ', ')); +end +if isfield(study, 'score_names') && ~isempty(study.score_names) + parts{end + 1} = sprintf('score_names={%s}', strjoin(cellstr(string(study.score_names)), ', ')); +end +[models, signatures, conditions] = local_available_from_results(study); +if ~isempty(models) + parts{end + 1} = sprintf('models={%s}', strjoin(models, ', ')); +end +if ~isempty(signatures) + parts{end + 1} = sprintf('signatures={%s}', strjoin(signatures, ', ')); +end +if ~isempty(conditions) + parts{end + 1} = sprintf('conditions={%s}', strjoin(conditions, ', ')); +end +if isempty(parts) + txt = 'none'; +else + txt = strjoin(parts, '; '); +end +end + +function [models, signatures, conditions] = local_available_from_results(study) +models = {}; +signatures = {}; +conditions = {}; +for i = 1:numel(study.results) + r = study.results{i}; + if isempty(r), continue; end + if isfield(r, 'fits') + models = [models; fieldnames(r.fits)]; %#ok + end + if isfield(r, 'fits_by_signature') && ~isempty(r.fits_by_signature) + signatures = [signatures; fieldnames(r.fits_by_signature)]; %#ok + end + if isfield(r, 'conditions') && ~isempty(r.conditions) + conditions = [conditions; cellstr(string(r.conditions(:)))]; %#ok + end +end +models = unique(models, 'stable'); +signatures = unique(signatures, 'stable'); +conditions = unique(conditions, 'stable'); +end + +function txt = local_display_or_default(value) +txt = char(value); +if isempty(txt) + txt = ''; +end +end + +function reason = local_result_error(study, idx, fallback) +reason = fallback; +if isfield(study, 'errors') && numel(study.errors) >= idx && ~isempty(study.errors{idx}) + reason = char(string(study.errors{idx})); +end +end + +function ax = local_plot_multiple_models(study, opts) +figure; ax = axes; hold(ax, 'on'); +colors = lines(numel(opts.Models)); +all_skipped = struct('index', {}, 'subject', {}, 'reason', {}); +condition_name = char(opts.Condition); +plotted = false(1, numel(opts.Models)); + +for m = 1:numel(opts.Models) + model_opts = opts; + model_opts.Model = opts.Models{m}; + [Y_runs, SE_runs, run_subject_ids, run_labels, this_condition, x, skipped] = local_collect_curves(study, model_opts); + all_skipped = [all_skipped, skipped]; %#ok + if isempty(Y_runs) + continue + end + if isempty(condition_name) || strcmp(condition_name, char(opts.Condition)) + condition_name = this_condition; + end + [Y, ~, subject_ids] = local_aggregate_unit(Y_runs, SE_runs, run_subject_ids, lower(char(opts.Unit)), run_labels); + curve_weights = local_curve_weights(Y, model_opts); + model_color = colors(m, :); + subject_color = model_color + (1 - model_color) * 0.55; + + if logical(opts.ShowGroupMean) + group_mean = local_weighted_mean_omitnan(Y, curve_weights)'; + group_se = local_weighted_sem_omitnan(Y, curve_weights)'; + if logical(opts.ShowGroupSE) && size(Y, 1) > 1 && any(~isnan(group_se)) + fill(ax, [x; flipud(x)], [group_mean + group_se; flipud(group_mean - group_se)], ... + model_color, 'FaceAlpha', min(opts.SEAlpha * 2, 0.20), ... + 'EdgeColor', 'none', 'HandleVisibility', 'off'); + end + for s = 1:size(Y, 1) + plot(ax, x, Y(s, :)', '-', 'Color', subject_color, ... + 'LineWidth', 0.75, 'HandleVisibility', 'off'); + end + plot(ax, x, group_mean, '-', 'Color', model_color, ... + 'LineWidth', opts.GroupLineWidth, 'DisplayName', opts.Models{m}); + else + for s = 1:size(Y, 1) + plot(ax, x, Y(s, :)', '-', 'Color', model_color, ... + 'DisplayName', sprintf('%s | %s', opts.Models{m}, subject_ids{s})); + end + end + plotted(m) = true; +end + +if ~any(plotted) + local_error_no_curves(all_skipped, study, opts); +end + +legend(ax, 'Interpreter', 'none'); +title(ax, local_multi_model_title(opts, condition_name), 'Interpreter', 'none'); +xlabel(ax, 'Seconds after event onset'); +ylabel(ax, local_ylabel(opts, study)); +hline(0, 'k-'); +end + +function [Y, SE, subject_ids, run_labels, condition_name, x, skipped] = local_collect_curves(study, opts) +Y = []; +SE = []; +subject_ids = {}; +run_labels = {}; +condition_pattern = char(opts.Condition); +condition_name = condition_pattern; +x = []; +skipped = struct('index', {}, 'subject', {}, 'reason', {}); +model_name = opts.Model; +missing_policy = lower(char(opts.MissingPolicy)); + +for s = 1:numel(study.results) + subject_id = local_subject_id(study, s); + r = study.results{s}; + if isempty(r) + skipped = local_skip(skipped, s, subject_id, local_result_error(study, s, 'empty result'), missing_policy); + continue + end + + [fit_struct, ok, reason] = local_fit_struct(r, opts.Signature); + if ~ok + skipped = local_skip(skipped, s, subject_id, reason, missing_policy); + continue + end + [fit, ok, reason] = local_select_fit(fit_struct, model_name, opts.SourceModel); + if ~ok + skipped = local_skip(skipped, s, subject_id, reason, missing_policy); + continue + end + + try + condition_spec = local_condition_spec(r, condition_pattern); + if isempty(condition_name) || strcmp(condition_name, condition_pattern) + condition_name = condition_spec.display_label; + end + catch + skipped = local_skip(skipped, s, subject_id, sprintf('missing condition %s', condition_name), missing_policy); + continue + end + + y = local_mean_omitnan(fit.hrf(:, condition_spec.indices), 2)'; + se = local_fit_se(fit, condition_spec.indices, numel(y)); + this_x = local_fit_time(fit, r, numel(y)); + if isempty(Y) + Y = y; + SE = se; + x = this_x; + elseif size(Y, 2) == numel(y) + if numel(this_x) ~= numel(x) || any(abs(this_x(:) - x(:)) > eps(max(abs(x(:))) + 1)) + skipped = local_skip(skipped, s, subject_id, 'HRF time axis mismatch', missing_policy); + continue + end + Y(end + 1, :) = y; %#ok + SE(end + 1, :) = se; %#ok + else + skipped = local_skip(skipped, s, subject_id, 'HRF length mismatch', missing_policy); + continue + end + subject_ids{end + 1, 1} = subject_id; %#ok + run_labels{end + 1, 1} = local_run_label(study, s, subject_id); %#ok +end +end + +function [Y, SE, subject_ids, run_labels, condition_name, x, skipped] = local_collect_trial_means(study, opts) +Y = []; +SE = []; +subject_ids = {}; +run_labels = {}; +condition_pattern = char(opts.Condition); +condition_name = condition_pattern; +x = []; +skipped = struct('index', {}, 'subject', {}, 'reason', {}); +missing_policy = lower(char(opts.MissingPolicy)); + +for s = 1:numel(study.results) + subject_id = local_subject_id(study, s); + r = study.results{s}; + if isempty(r) + skipped = local_skip(skipped, s, subject_id, local_result_error(study, s, 'empty result'), missing_policy); + continue + end + if ~isfield(r, 'events') || isempty(r.events) + skipped = local_skip(skipped, s, subject_id, 'missing events for trialmean plot', missing_policy); + continue + end + if ~isfield(r, 'settings') || ~isfield(r.settings, 'TR') || ~isfield(r.settings, 'window_seconds') + skipped = local_skip(skipped, s, subject_id, 'missing TR/window_seconds for trialmean plot', missing_policy); + continue + end + + [tc, ok, reason] = local_trial_timeseries(r, opts.Signature); + if ~ok + skipped = local_skip(skipped, s, subject_id, reason, missing_policy); + continue + end + + try + condition_spec = local_condition_spec(r, condition_pattern); + if isempty(condition_name) || strcmp(condition_name, condition_pattern) + condition_name = condition_spec.display_label; + end + avg = hrf_average_condition_trials(tc, r.events, condition_spec.matched_conditions, ... + r.settings.TR, r.settings.window_seconds, ... + 'BaselineSeconds', opts.BaselineSeconds, ... + 'OutlierPolicy', opts.TrialOutlierPolicy, ... + 'OutlierZThreshold', opts.TrialOutlierZThreshold); + catch err + skipped = local_skip(skipped, s, subject_id, err.message, missing_policy); + continue + end + + y = avg.mean(:)'; + se = avg.sem(:)'; + this_x = avg.time(:); + if isempty(Y) + Y = y; + SE = se; + x = this_x; + elseif size(Y, 2) == numel(y) + if numel(this_x) ~= numel(x) || any(abs(this_x(:) - x(:)) > eps(max(abs(x(:))) + 1)) + skipped = local_skip(skipped, s, subject_id, 'trialmean time axis mismatch', missing_policy); + continue + end + Y(end + 1, :) = y; %#ok + SE(end + 1, :) = se; %#ok + else + skipped = local_skip(skipped, s, subject_id, 'trialmean length mismatch', missing_policy); + continue + end + subject_ids{end + 1, 1} = subject_id; %#ok + run_labels{end + 1, 1} = local_run_label(study, s, subject_id); %#ok +end +end + +function condition_spec = local_condition_spec(r, condition_name) +if ~isfield(r, 'conditions') || isempty(r.conditions) + error('missing conditions'); +end +specs = hrf_resolve_condition_patterns(r.conditions, condition_name, 'DefaultMode', 'first'); +condition_spec = specs(1); +condition_spec = local_apply_condition_group_label(r, condition_spec); +end + +function condition_spec = local_apply_condition_group_label(r, condition_spec) +if ~isfield(r, 'condition_groups') || isempty(r.condition_groups) || numel(condition_spec.indices) ~= 1 + return +end +idx = condition_spec.indices(1); +groups = r.condition_groups; +if idx <= numel(groups) && isfield(groups, 'display_label') + condition_spec.display_label = groups(idx).display_label; + if isfield(groups, 'matched_conditions') + condition_spec.matched_conditions = groups(idx).matched_conditions; + end +end +end + +function [Y, SE, subject_ids] = local_aggregate_unit(Y_runs, SE_runs, run_subject_ids, unit, run_labels) +if nargin < 5 || isempty(run_labels) + run_labels = run_subject_ids; +end +switch unit + case 'run' + Y = Y_runs; + SE = SE_runs; + subject_ids = run_labels(:); + case 'subject' + subject_ids = unique(run_subject_ids, 'stable'); + Y = nan(numel(subject_ids), size(Y_runs, 2)); + SE = nan(numel(subject_ids), size(Y_runs, 2)); + for i = 1:numel(subject_ids) + wh = strcmp(run_subject_ids, subject_ids{i}); + Y(i, :) = local_mean_omitnan(Y_runs(wh, :), 1); + SE(i, :) = local_combine_run_se(Y_runs(wh, :), SE_runs(wh, :)); + end + otherwise + error('Unknown Unit: %s. Use ''subject'' or ''run''.', unit); +end +end + +function se = local_fit_se(fit, condition_idx, n) +se = nan(1, n); +if isfield(fit, 'se') && ~isempty(fit.se) && all(size(fit.se) == size(fit.hrf)) + se = local_combine_condition_se(fit.hrf(:, condition_idx), fit.se(:, condition_idx))'; +end +end + +function se = local_combine_condition_se(Y, SE) +n_cond = size(Y, 2); +has_model_se = any(~isnan(SE(:))); +if has_model_se + valid = ~isnan(SE); + SE(~valid) = 0; + n = max(sum(valid, 2), 1); + se = sqrt(sum(SE .^ 2, 2)) ./ n; +else + se = nan(size(Y, 1), 1); +end +if n_cond > 1 + condition_sem = local_sem_omitnan(Y, 2); + if has_model_se + se = sqrt(se .^ 2 + condition_sem .^ 2); + else + se = condition_sem; + end +end +end + +function x = local_fit_time(fit, r, n) +if isfield(fit, 'time') && numel(fit.time) == n + x = fit.time(:); +elseif isfield(fit, 'lag_seconds') && numel(fit.lag_seconds) == n + x = fit.lag_seconds(:); +elseif isfield(r, 'settings') && isfield(r.settings, 'TR') + x = 1 + (0:n - 1)' .* r.settings.TR; +else + x = (1:n)'; +end +end + +function se = local_combine_run_se(Y, SE) +n_runs = size(Y, 1); +has_model_se = any(~isnan(SE(:))); +if has_model_se + valid = ~isnan(SE); + SE(~valid) = 0; + n = max(sum(valid, 1), 1); + se = sqrt(sum(SE .^ 2, 1)) ./ n; +else + se = nan(1, size(Y, 2)); +end +if n_runs > 1 + run_sem = local_sem_omitnan(Y, 1); + if has_model_se + se = sqrt(se .^ 2 + run_sem .^ 2); + else + se = run_sem; + end +end +end + +function ttl = local_title(opts, condition_name, SE, Y, study, curve_weights) +sig = char(opts.Signature); +if isempty(sig) + if local_is_mapscore_study(study) + sig = 'mean_mapscore'; + else + sig = 'selected signal'; + end +end +parts = {}; +if any(~isnan(SE(:))) + if any(strcmpi(opts.PlotType, {'trialmean', 'trial_mean', 'trials'})) + if strcmpi(opts.Unit, 'subject') + parts{end + 1} = 'within-subject/run trial SEM'; + else + parts{end + 1} = 'within-run trial SEM'; + end + else + if strcmpi(opts.Unit, 'subject') + parts{end + 1} = 'within-subject/run SE'; + else + parts{end + 1} = 'within-run SE'; + end + end +end +if logical(opts.ShowGroupSE) && size(Y, 1) > 1 + parts{end + 1} = 'group SEM'; +end +if isempty(parts) + ribbon_txt = 'ribbon=none (SE unavailable)'; +else + ribbon_txt = sprintf('ribbon=%s', strjoin(parts, ' + ')); +end +weight_txt = local_weight_title(opts, curve_weights); +source_model = local_source_model_text(opts); +if any(strcmpi(opts.PlotType, {'trialmean', 'trial_mean', 'trials'})) + ttl = sprintf('model=trialmean, unit=%s, condition=%s, source=%s, %s%s', ... + lower(char(opts.Unit)), condition_name, sig, ribbon_txt, weight_txt); +elseif local_is_mapscore_study(study) + ttl = sprintf('model=%s%s, unit=%s, condition=%s, score=%s, %s%s', ... + char(opts.Model), source_model, lower(char(opts.Unit)), condition_name, sig, ribbon_txt, weight_txt); +else + ttl = sprintf('model=%s, unit=%s, condition=%s, source=%s, %s%s', ... + char(opts.Model), lower(char(opts.Unit)), condition_name, sig, ribbon_txt, weight_txt); +end +end + +function ttl = local_multi_model_title(opts, condition_name) +sig = char(opts.Signature); +if isempty(sig) + sig = 'selected signal'; +end +parts = {}; +if logical(opts.ShowGroupSE) + parts{end + 1} = 'group SEM'; +end +if logical(opts.ShowGroupMean) + parts{end + 1} = 'thin lines=subjects/runs'; +end +if isempty(parts) + ribbon_txt = 'ribbon=none'; +else + ribbon_txt = strjoin(parts, ', '); +end +ttl = sprintf('models=%s, unit=%s, condition=%s, source=%s, %s', ... + strjoin(opts.Models, ' + '), lower(char(opts.Unit)), condition_name, sig, ribbon_txt); +end + +function ylab = local_ylabel(opts, study) +if any(strcmpi(opts.PlotType, {'trialmean', 'trial_mean', 'trials'})) + ylab = 'Observed signal'; + return +end +if strcmpi(opts.Model, 'mapscore') || local_is_mapscore_study(study) + ylab = 'Pattern expression / map score'; +else + ylab = 'Fitted response amplitude'; +end +end + +function weights = local_curve_weights(Y, opts) +weights = opts.CurveWeights(:); +if ~isempty(weights) + if numel(weights) ~= size(Y, 1) + error('CurveWeights must have one value per plotted %s curve (%d).', ... + lower(char(opts.Unit)), size(Y, 1)); + end + weights(~isfinite(weights) | weights < 0) = 0; + return +end + +policy = lower(strtrim(char(opts.OutlierWeighting))); +weights = ones(size(Y, 1), 1); +if strcmp(policy, 'none') || size(Y, 1) < 3 + return +end + +z = local_curve_max_abs_robust_z(Y); +threshold = opts.OutlierZThreshold; +switch policy + case {'exclude', 'omit'} + weights(z > threshold) = 0; + case {'huber', 'downweight'} + weights = min(1, threshold ./ max(z, eps)); + case {'bisquare', 'tukey'} + u = z ./ threshold; + weights = (1 - u .^ 2) .^ 2; + weights(u >= 1) = 0; + otherwise + error('Unknown OutlierWeighting: %s. Use none, exclude, huber, or bisquare.', policy); +end +end + +function z = local_curve_max_abs_robust_z(Y) +n = size(Y, 1); +center = local_nanmedian(Y, 1); +scale = 1.4826 .* local_nanmedian(abs(Y - repmat(center, n, 1)), 1); +scale_bad = scale == 0 | isnan(scale); +fallback_scale = local_nanstd(Y, 1); +scale(scale_bad) = fallback_scale(scale_bad); +scale(scale == 0 | isnan(scale)) = NaN; +Z = abs((Y - repmat(center, n, 1)) ./ repmat(scale, n, 1)); +z = zeros(n, 1); +for i = 1:n + zi = Z(i, :); + zi = zi(~isnan(zi)); + if ~isempty(zi) + z(i) = max(zi); + end +end +end + +function m = local_weighted_mean_omitnan(Y, weights) +m = nan(1, size(Y, 2)); +for j = 1:size(Y, 2) + y = Y(:, j); + valid = ~isnan(y) & weights > 0; + if any(valid) + w = weights(valid); + m(j) = sum(w .* y(valid)) ./ sum(w); + end +end +end + +function se = local_weighted_sem_omitnan(Y, weights) +se = nan(1, size(Y, 2)); +for j = 1:size(Y, 2) + y = Y(:, j); + valid = ~isnan(y) & weights > 0; + if sum(valid) < 2 + continue + end + w = weights(valid); + yy = y(valid); + mu = sum(w .* yy) ./ sum(w); + n_eff = (sum(w) .^ 2) ./ sum(w .^ 2); + var_w = sum(w .* (yy - mu) .^ 2) ./ sum(w); + se(j) = sqrt(var_w ./ max(n_eff, eps)); +end +end + +function m = local_nanmedian(X, dim) +if nargin < 2, dim = 1; end +if dim == 1 + m = nan(1, size(X, 2)); + for j = 1:size(X, 2) + y = X(:, j); + y = y(~isnan(y)); + if ~isempty(y), m(j) = median(y); end + end +else + m = nan(size(X, 1), 1); + for i = 1:size(X, 1) + y = X(i, :); + y = y(~isnan(y)); + if ~isempty(y), m(i) = median(y); end + end +end +end + +function s = local_nanstd(X, dim) +mu = local_nanmean(X, dim); +if dim == 1 + centered = X - repmat(mu, size(X, 1), 1); + n = sum(~isnan(X), 1); + centered(isnan(centered)) = 0; + s = sqrt(sum(centered .^ 2, 1) ./ max(n - 1, 1)); +elseif dim == 2 + centered = X - repmat(mu, 1, size(X, 2)); + n = sum(~isnan(X), 2); + centered(isnan(centered)) = 0; + s = sqrt(sum(centered .^ 2, 2) ./ max(n - 1, 1)); +else + error('local_nanstd supports dim 1 or 2.'); +end +s(n < 2) = NaN; +end + +function m = local_nanmean(X, dim) +valid = ~isnan(X); +den = sum(valid, dim); +X(~valid) = 0; +m = sum(X, dim) ./ den; +m(den == 0) = NaN; +end + +function txt = local_weight_title(opts, weights) +if strcmpi(char(opts.OutlierWeighting), 'none') && isempty(opts.CurveWeights) + txt = ''; + return +end +txt = sprintf(', curve_weighting=%s, effective_n=%0.3g', ... + char(opts.OutlierWeighting), sum(weights)); +end + +function txt = local_source_model_text(opts) +if isempty(opts.SourceModel) + txt = ''; +else + txt = sprintf(' source_model=%s', opts.SourceModel); +end +end + +function tf = local_is_mapscore_study(study) +tf = (isfield(study, 'source') && contains(char(study.source), 'map_scores')) || ... + (isfield(study, 'mapscore_success') && any(study.mapscore_success)) || ... + (isfield(study, 'object') && isfield(study, 'model_name') && strcmpi(char(study.model_name), 'mapscore')); +end + +function model_names = local_model_names(model_input) +model_names = cellstr(string(model_input)); +model_names = cellfun(@(s) lower(strtrim(s)), model_names, 'UniformOutput', false); +model_names = model_names(~cellfun(@isempty, model_names)); +if isempty(model_names) + error('Model must contain at least one model name.'); +end +model_names = unique(model_names, 'stable'); +end + +function [tc, ok, reason] = local_trial_timeseries(r, signature_name) +tc = []; +ok = false; +reason = ''; +if ~isempty(signature_name) + sig_field = local_signature_struct_field(r, signature_name, 'timeseries_by_signature'); + if ~isempty(sig_field) + tc = r.timeseries_by_signature.(sig_field); + ok = true; + return + end + if isfield(r, 'signature_meta') && isfield(r.signature_meta, 'selected_signature') && ... + strcmp(char(r.signature_meta.selected_signature), signature_name) && ... + isfield(r, 'timeseries') && ~isempty(r.timeseries) + tc = r.timeseries; + ok = true; + return + end + reason = sprintf('missing time series for signature %s', signature_name); + return +end + +if isfield(r, 'timeseries') && ~isempty(r.timeseries) + tc = r.timeseries; + ok = true; +else + reason = 'missing timeseries for trialmean plot'; +end +end + +function [fit_struct, ok, reason] = local_fit_struct(r, signature_name) +fit_struct = struct(); +ok = false; +reason = ''; +if isempty(signature_name) + if isfield(r, 'fits') + fit_struct = r.fits; + ok = true; + else + reason = 'missing fits'; + end + return +end + +if ~isfield(r, 'fits_by_signature') + reason = 'missing fits_by_signature'; + return +end + +sig_field = local_signature_field(r, signature_name); +if isempty(sig_field) + reason = sprintf('missing signature %s', signature_name); + return +end +fit_struct = r.fits_by_signature.(sig_field); +ok = true; +end + +function [fit, ok, reason] = local_select_fit(fit_struct, model_name, source_model) +fit = struct(); +ok = false; +reason = ''; +model_name = lower(char(model_name)); +source_model = lower(strtrim(char(source_model))); + +if isfield(fit_struct, model_name) + candidate = fit_struct.(model_name); + wanted_source = local_requested_source_model(model_name, source_model, candidate); + if local_fit_matches_source(candidate, wanted_source) + fit = candidate; + ok = true; + else + reason = sprintf('model %s source model mismatch', model_name); + end + return +end + +if isfield(fit_struct, 'mapscore') + candidate = fit_struct.mapscore; + wanted_source = source_model; + if isempty(wanted_source) && ~strcmp(model_name, 'mapscore') + wanted_source = model_name; + end + if local_fit_matches_source(candidate, wanted_source) + fit = candidate; + ok = true; + return + end +end + +if isempty(source_model) + reason = sprintf('missing model %s', model_name); +else + reason = sprintf('missing model %s for source model %s', model_name, source_model); +end +end + +function tf = local_fit_matches_source(fit, source_model) +if isempty(source_model) + tf = true; + return +end +if isfield(fit, 'source_model') && ~isempty(fit.source_model) + tf = strcmpi(char(fit.source_model), source_model); +else + tf = false; +end +end + +function source_model = local_requested_source_model(model_name, source_model, fit) +if isempty(source_model) && local_is_wholebrain_model_name(model_name) && ... + isfield(fit, 'source_model') && ~isempty(fit.source_model) + source_model = model_name; +end +end + +function tf = local_is_wholebrain_model_name(model_name) +tf = ismember(lower(strtrim(char(model_name))), {'fir', 'sfir', 'canonical', 'spline'}); +end + +function sig_field = local_signature_field(r, sig) +sig_field = local_signature_struct_field(r, sig, 'fits_by_signature'); +end + +function sig_field = local_signature_struct_field(r, sig, struct_field) +sig_field = ''; +if ~isfield(r, struct_field) || isempty(r.(struct_field)) + return +end +S = r.(struct_field); +if isfield(S, sig) + sig_field = sig; + return +end +candidate = matlab.lang.makeValidName(sig); +if isfield(S, candidate) + sig_field = candidate; + return +end +if isfield(r, 'signature_meta') && isfield(r.signature_meta, 'selected_signatures') && ... + isfield(r.signature_meta, 'selected_signature_fields') + names = cellstr(string(r.signature_meta.selected_signatures)); + fields = cellstr(string(r.signature_meta.selected_signature_fields)); + idx = find(strcmp(names, sig), 1); + if ~isempty(idx) && idx <= numel(fields) && isfield(S, fields{idx}) + sig_field = fields{idx}; + end +end +end + +function m = local_mean_omitnan(X, dim) +if nargin < 2, dim = 1; end +valid = ~isnan(X); +den = sum(valid, dim); +X(~valid) = 0; +m = sum(X, dim) ./ den; +m(den == 0) = NaN; +end + +function se = local_sem_omitnan(X, dim) +if nargin < 2, dim = 1; end +mu = local_mean_omitnan(X, dim); +if dim == 1 + centered = X - repmat(mu, size(X, 1), 1); +else + centered = X - repmat(mu, 1, size(X, 2)); +end +centered(isnan(centered)) = 0; +n = sum(~isnan(X), dim); +sd = sqrt(sum(centered .^ 2, dim) ./ max(n - 1, 1)); +se = sd ./ sqrt(n); +se(n == 0 | n == 1) = NaN; +end + +function skipped = local_skip(skipped, idx, subject_id, reason, missing_policy) +if strcmp(missing_policy, 'error') + error('Skipping is disabled. Result %d (%s): %s', idx, subject_id, reason); +elseif ~strcmp(missing_policy, 'warn') && ~strcmp(missing_policy, 'silent') + error('Unknown MissingPolicy: %s. Use ''warn'', ''silent'', or ''error''.', missing_policy); +end + +skipped(end + 1) = struct('index', idx, 'subject', subject_id, 'reason', reason); +if strcmp(missing_policy, 'warn') + warning('plot_hrf_study_by_subject:SkippingResult', 'Skipping result %d (%s): %s', idx, subject_id, reason); +end +end + +function subject_id = local_subject_id(study, idx) +if isfield(study, 'subject_ids') && numel(study.subject_ids) >= idx + subject_id = char(study.subject_ids{idx}); +else + subject_id = sprintf('sub-%03d', idx); +end +end + +function run_label = local_run_label(study, idx, subject_id) +if isfield(study, 'run_labels') && numel(study.run_labels) >= idx && ~isempty(study.run_labels{idx}) + run_label = char(study.run_labels{idx}); +else + run_label = sprintf('%s_run%02d', subject_id, idx); +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/plot_hrf_time_unfolding_stats.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/plot_hrf_time_unfolding_stats.m new file mode 100644 index 00000000..7d1ff751 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/plot_hrf_time_unfolding_stats.m @@ -0,0 +1,48 @@ +function ax = plot_hrf_time_unfolding_stats(stats) +%PLOT_HRF_TIME_UNFOLDING_STATS Plot mean +/- SEM and significance mask. +figure; +ax(1) = subplot(2,1,1); hold on; +x = stats.time; +y = stats.mean; +se = stats.sem; +fill([x; flipud(x)], [y+se; flipud(y-se)], [0.6 0.6 0.9], 'FaceAlpha', 0.25, 'EdgeColor', 'none'); +plot(x, y, 'b-', 'LineWidth', 1.8); +hline(0, 'k-'); +sig_txt = ''; +if isfield(stats, 'signature') && ~isempty(stats.signature) + sig_txt = sprintf(' | %s', stats.signature); +end +unit_txt = ''; +if isfield(stats, 'unit') + unit_txt = sprintf(' | unit=%s, n=%d', stats.unit, stats.n_subjects); +end +title(sprintf('%s: %s - %s%s%s', upper(stats.model), local_condition_label(stats, 'A'), ... + local_condition_label(stats, 'B'), sig_txt, unit_txt), 'Interpreter', 'none'); + +ax(2) = subplot(2,1,2); hold on; +plot(x, stats.p_value, 'k-'); +yline(stats.alpha, 'r--', 'Alpha'); +sig_idx = stats.significant & ~isnan(stats.p_value); +stem(x(sig_idx), stats.p_value(sig_idx), 'g.'); +ylabel('p-value'); xlabel('Time bin'); +title('Time-unfolding significance'); +end + +function label = local_condition_label(stats, which_condition) +switch upper(which_condition) + case 'A' + label = stats.conditionA; + field = 'conditionA_matched'; + case 'B' + label = stats.conditionB; + field = 'conditionB_matched'; +end +if isfield(stats, field) && ~isempty(stats.(field)) + matches = cellstr(string(stats.(field))); + if numel(matches) > 1 + label = sprintf('%s (mean of %d: %s)', label, numel(matches), strjoin(matches, ', ')); + elseif isempty(label) + label = matches{1}; + end +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/run_hrf_pipeline.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/run_hrf_pipeline.m new file mode 100644 index 00000000..c0e4fda6 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/run_hrf_pipeline.m @@ -0,0 +1,437 @@ +function results = run_hrf_pipeline(fmri_nii, events_tsv, varargin) +%RUN_HRF_PIPELINE End-to-end HRF estimation from 4D fMRI + BIDS events. +% results = RUN_HRF_PIPELINE(fmri_nii, events_tsv, ...) +% +% Required inputs +% fmri_nii - path to 4D fMRI NIfTI file. +% events_tsv - path to BIDS-style events.tsv file with columns: +% onset, duration, trial_type. +% +% Name-value options +% 'TR' : repetition time in seconds (default: from NIfTI header) +% 'MaskNii' : optional mask NIfTI; if omitted uses whole-brain mean +% 'Conditions' : cellstr of trial_type names to fit (default: all) +% 'WindowSeconds' : HRF estimation window, seconds (default 30) +% 'Models' : subset of {'logit','fir','sfir','canonical','spline','nlgamma'} +% (default all) +% 'WholeBrainMode' : 'auto', a model name, or cellstr of model names. +% 'auto' writes one whole-brain output per requested +% linear model: fir, sfir, canonical, and spline. +% 'OutputMat' : optional .mat path to save results +% +% Output +% results struct with fields: +% .timeseries, .events, .stick_functions, .fits, .settings + +p = inputParser; +p.addRequired('fmri_nii', @(x) ischar(x) || isstring(x)); +p.addRequired('events_tsv', @(x) ischar(x) || isstring(x)); +p.addParameter('TR', [], @(x) isempty(x) || (isscalar(x) && x > 0)); +p.addParameter('MaskNii', '', @(x) ischar(x) || isstring(x)); +p.addParameter('Conditions', {}, @(x) ischar(x) || iscellstr(x) || isstring(x)); +p.addParameter('WindowSeconds', 30, @(x) isscalar(x) && x > 0); +p.addParameter('Models', {'logit','fir','sfir','canonical','spline','nlgamma'}, @(x) iscell(x) || isstring(x)); +p.addParameter('ModelDependencyPolicy', 'skip', @(x) ischar(x) || isstring(x)); +p.addParameter('OutputMat', '', @(x) ischar(x) || isstring(x)); +p.addParameter('OutputMatVersion', '-v7.3', @(x) ischar(x) || isstring(x)); +p.addParameter('SaveWholeBrainInMat', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('SignalSource', 'mean', @(x) ischar(x) || isstring(x)); +p.addParameter('SimilarityMetric', 'dotproduct', @(x) ischar(x) || isstring(x)); +p.addParameter('ImageSet', 'all', @(x) ischar(x) || isstring(x) || isa(x, 'image_vector')); +p.addParameter('SignatureName', '', @(x) ischar(x) || isstring(x)); +p.addParameter('SignatureNames', {}, @(x) ischar(x) || iscell(x) || isstring(x)); +p.addParameter('MaxSignatures', inf, @(x) isscalar(x) && x >= 1); +p.addParameter('UseParallel', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('AtlasObj', [], @(x) isempty(x) || isa(x, 'atlas')); +p.addParameter('Regions', {}, @(x) iscell(x) || isstring(x)); +p.addParameter('MapNames', {}, @(x) ischar(x) || iscell(x) || isstring(x)); +p.addParameter('WriteWholeBrain', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('WholeBrainOutputPrefix', '', @(x) ischar(x) || isstring(x)); +p.addParameter('WholeBrainMode', 'auto', @(x) ischar(x) || iscell(x) || isstring(x)); +p.addParameter('WholeBrainOverwrite', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('WholeBrainPThresh', [], @(x) isempty(x) || (isscalar(x) && x > 0 && x < 1)); +p.addParameter('WholeBrainThreshType', 'unc', @(x) ischar(x) || isstring(x)); +p.addParameter('WholeBrainWriteThresholdedT', false, @(x) islogical(x) || isnumeric(x)); +p.addParameter('WholeBrainChunkSize', 50000, @(x) isscalar(x) && x >= 1); +p.addParameter('WholeBrainScaleMode', 'none', @(x) ischar(x) || isstring(x)); +% SPM GKWY-compatibility (see hrf_fit_wholebrain_stats): high-pass on by +% default so drift does not leak into long FIR/sFIR lags. Pass WholeBrainSPM +% (a path to / struct of an estimated SPM.mat) for exact g*K*W matching. +p.addParameter('WholeBrainHighpassSeconds', 128, @(x) isempty(x) || (isscalar(x) && isnumeric(x))); +p.addParameter('WholeBrainSPM', [], @(x) isempty(x) || isstruct(x) || ischar(x) || isstring(x)); +p.addParameter('WholeBrainSPMRun', 1, @(x) isnumeric(x) && isscalar(x) && x >= 1); +p.addParameter('WholeBrainWhiten', 'none', @(x) ischar(x) || isstring(x)); +p.addParameter('ReuseWholeBrainOutput', false, @(x) islogical(x) || isnumeric(x)); +p.parse(fmri_nii, events_tsv, varargin{:}); +opts = p.Results; +write_wholebrain = logical(opts.WriteWholeBrain) || ~isempty(char(opts.WholeBrainOutputPrefix)); +if write_wholebrain + wholebrain_modes = local_resolve_wholebrain_modes(opts.WholeBrainMode, opts.Models); +else + wholebrain_modes = {}; +end + +% Convenience: if a signature name is provided, force signature mode +if strcmpi(char(opts.SignalSource), 'mean') && ~isempty(char(opts.SignatureName)) + warning('SignatureName provided while SignalSource=''mean''. Switching SignalSource to ''signature''.'); + opts.SignalSource = 'signature'; +end + +fmri_nii = char(fmri_nii); +events_tsv = char(events_tsv); +if ~exist(fmri_nii, 'file'), error('fMRI file not found: %s', fmri_nii); end +if ~exist(events_tsv, 'file'), error('Events file not found: %s', events_tsv); end + +[tr_from_hdr, n_tp] = local_get_tr_and_ntp(fmri_nii); +TR = opts.TR; +if isempty(TR), TR = tr_from_hdr; end +if isempty(TR) || TR <= 0 + error('Could not infer TR from NIfTI header. Pass ''TR'' explicitly.'); +end + +signal_source = lower(strtrim(char(opts.SignalSource))); +if strcmp(signal_source, 'signatures'), signal_source = 'signature'; end +if strcmp(signal_source, 'maps'), signal_source = 'imageset'; end +if strcmp(signal_source, 'roi'), signal_source = 'atlas'; end +signature_meta = struct('signal_source', signal_source); +fits_by_signature = struct(); + +if strcmpi(signal_source, 'mean') + [tc, ~, ~] = hrf_extract_timeseries_from_nii(fmri_nii, char(opts.MaskNii)); +elseif strcmpi(signal_source, 'signature') + dat_for_maps = fmri_data(fmri_nii, 'noverbose'); + if isa(opts.ImageSet, 'image_vector') + error('SignalSource=''signature'' expects ImageSet to be a named signature set. Use SignalSource=''imageset'' for an image_vector map set.'); + end + if ~isempty(opts.SignatureName) + % Fast/safe path for one signature to avoid loading unavailable images in image_set='all' + image_set = char(opts.ImageSet); + sig_name = char(opts.SignatureName); + if strcmpi(image_set, 'all') && strcmpi(sig_name, 'NPS') + image_set = 'npsplus'; + end + [tc, one_meta] = hrf_extract_signature_timeseries(dat_for_maps, ... + 'SimilarityMetric', opts.SimilarityMetric, ... + 'ImageSet', image_set, ... + 'SignatureName', sig_name); + all_tc = tc; + signature_meta.available_signatures = {one_meta.selected_signature}; + signature_meta.selected_signature = one_meta.selected_signature; + signature_meta.selected_signatures = {one_meta.selected_signature}; + signature_meta.n_signatures = 1; + signature_meta.similarity_metric = one_meta.similarity_metric; + signature_meta.image_set = one_meta.image_set; + else + [all_tc, signature_meta] = hrf_extract_all_signature_timeseries(dat_for_maps, ... + 'SimilarityMetric', opts.SimilarityMetric, ... + 'ImageSet', opts.ImageSet); + + sig_names = signature_meta.available_signatures; + if ~isempty(opts.SignatureNames) + req = cellstr(string(opts.SignatureNames)); + [tf, idx] = ismember(req, sig_names); + sig_names = sig_names(idx(tf)); + all_tc = all_tc(:, idx(tf)); + end + if isempty(sig_names) + error('No signatures matched the requested SignatureNames.'); + end + if isfinite(opts.MaxSignatures) + n_keep = min(numel(sig_names), opts.MaxSignatures); + sig_names = sig_names(1:n_keep); + all_tc = all_tc(:, 1:n_keep); + end + tc = all_tc(:, 1); + signature_meta.selected_signature = sig_names{1}; + signature_meta.selected_signatures = sig_names; + signature_meta.n_signatures = numel(sig_names); + end + + +elseif strcmpi(signal_source, 'atlas') + if isempty(opts.AtlasObj) + error('SignalSource=''atlas'' requires AtlasObj input (atlas object).'); + end + [all_tc, signature_meta] = hrf_extract_roi_timeseries(fmri_nii, opts.AtlasObj, 'Regions', opts.Regions); + sig_names = signature_meta.available_signatures; + if isfinite(opts.MaxSignatures) + n_keep = min(numel(sig_names), opts.MaxSignatures); + sig_names = sig_names(1:n_keep); + all_tc = all_tc(:, 1:n_keep); + end + tc = all_tc(:, 1); + signature_meta.selected_signature = sig_names{1}; + signature_meta.selected_signatures = sig_names; + signature_meta.n_signatures = numel(sig_names); +elseif strcmpi(signal_source, 'imageset') + dat_for_maps = fmri_data(fmri_nii, 'noverbose'); + [all_tc, signature_meta] = hrf_extract_imageset_timeseries(dat_for_maps, opts.ImageSet, ... + 'MapNames', opts.MapNames, ... + 'SimilarityMetric', opts.SimilarityMetric); + sig_names = signature_meta.available_signatures; + if isempty(sig_names) + error('No maps were available from the requested ImageSet/MapNames.'); + end + if isfinite(opts.MaxSignatures) + n_keep = min(numel(sig_names), opts.MaxSignatures); + sig_names = sig_names(1:n_keep); + all_tc = all_tc(:, 1:n_keep); + end + tc = all_tc(:, 1); + signature_meta.selected_signature = sig_names{1}; + signature_meta.selected_signatures = sig_names; + signature_meta.n_signatures = numel(sig_names); +else + error('Unknown SignalSource: %s. Use ''mean'', ''signature'', ''imageset'', or ''atlas''.', char(opts.SignalSource)); +end + +E = hrf_load_events_tsv(events_tsv); +if isempty(opts.Conditions) + cond_names = unique(E.trial_type, 'stable'); +else + cond_names = cellstr(string(opts.Conditions)); +end + +[Runc, condition_groups] = hrf_build_stick_functions(E, cond_names, TR, n_tp); +cond_names = {condition_groups.label}; +if strcmpi(signal_source, 'signature') || strcmpi(signal_source, 'imageset') || strcmpi(signal_source, 'atlas') + siglist = signature_meta.selected_signatures; + nSig = numel(siglist); + sigfields = matlab.lang.makeUniqueStrings(matlab.lang.makeValidName(siglist)); + signature_meta.selected_signature_fields = sigfields; + fit_cells = cell(1, nSig); + window_seconds = opts.WindowSeconds; + models = opts.Models; + model_dependency_policy = opts.ModelDependencyPolicy; + use_parallel = logical(opts.UseParallel) && license('test', 'Distrib_Computing_Toolbox'); + + if use_parallel + if isempty(gcp('nocreate')) + parpool; + end + parfor si = 1:nSig + fit_cells{si} = hrf_fit_all_models(all_tc(:, si), TR, Runc, window_seconds, models, ... + 'DependencyPolicy', model_dependency_policy); + end + else + for si = 1:nSig + fit_cells{si} = hrf_fit_all_models(all_tc(:, si), TR, Runc, window_seconds, models, ... + 'DependencyPolicy', model_dependency_policy); + end + end + + for si = 1:nSig + fits_by_signature.(sigfields{si}) = fit_cells{si}; + end + selected_idx = find(strcmp(siglist, signature_meta.selected_signature), 1); + fits = fit_cells{selected_idx}; +else + fits = hrf_fit_all_models(tc, TR, Runc, opts.WindowSeconds, opts.Models, ... + 'DependencyPolicy', opts.ModelDependencyPolicy); + fits_by_signature.mean = fits; + signature_meta.selected_signature = 'mean_bold'; + signature_meta.selected_signatures = {'mean_bold'}; + signature_meta.available_signatures = {'mean_bold'}; + signature_meta.n_signatures = 1; +end + +results = struct(); +results.timeseries = tc; +results.events = E; +results.conditions = cond_names; +results.condition_groups = condition_groups; +results.stick_functions = Runc; +results.fits = fits; +results.settings = struct('TR', TR, 'window_seconds', opts.WindowSeconds, ... + 'fmri_nii', fmri_nii, 'events_tsv', events_tsv, 'mask_nii', char(opts.MaskNii), ... + 'signal_source', char(opts.SignalSource), 'wholebrain_modes', {wholebrain_modes}); +if isscalar(wholebrain_modes) + results.settings.wholebrain_mode = wholebrain_modes{1}; +end +results.signature_meta = signature_meta; +results.fits_by_signature = fits_by_signature; +if exist('all_tc', 'var') + results.all_timeseries = all_tc; + results.timeseries_by_signature = local_timeseries_by_signature(all_tc, signature_meta); +end + +if write_wholebrain + wholebrain_prefix = char(opts.WholeBrainOutputPrefix); + if isempty(wholebrain_prefix) + wholebrain_prefix = local_default_wholebrain_prefix(fmri_nii); + end + + write_thresholded_t = logical(opts.WholeBrainWriteThresholdedT) || ~isempty(opts.WholeBrainPThresh); + wholebrain_by_model = struct(); + for m = 1:numel(wholebrain_modes) + mode_name = wholebrain_modes{m}; + model_prefix = local_wholebrain_model_prefix(wholebrain_prefix, mode_name, numel(wholebrain_modes)); + model_field = matlab.lang.makeValidName(lower(mode_name)); + if logical(opts.ReuseWholeBrainOutput) && local_has_wholebrain_outputs(model_prefix) + wholebrain_by_model.(model_field) = local_load_wholebrain_outputs(model_prefix); + fprintf('Reusing existing whole-brain HRF outputs: %s_*.nii\n', model_prefix); + else + wholebrain_by_model.(model_field) = hrf_fit_wholebrain_stats(fmri_nii, events_tsv, ... + 'TR', TR, ... + 'MaskNii', char(opts.MaskNii), ... + 'Conditions', cond_names, ... + 'WindowSeconds', opts.WindowSeconds, ... + 'Mode', mode_name, ... + 'OutputPrefix', model_prefix, ... + 'PThresh', opts.WholeBrainPThresh, ... + 'ThreshType', opts.WholeBrainThreshType, ... + 'WriteThresholdedT', write_thresholded_t, ... + 'ChunkSize', opts.WholeBrainChunkSize, ... + 'ScaleMode', opts.WholeBrainScaleMode, ... + 'HighpassSeconds', opts.WholeBrainHighpassSeconds, ... + 'SPM', opts.WholeBrainSPM, ... + 'SPMRun', opts.WholeBrainSPMRun, ... + 'Whiten', opts.WholeBrainWhiten, ... + 'Overwrite', logical(opts.WholeBrainOverwrite)); + end + end + results.wholebrain_by_model = wholebrain_by_model; + if isscalar(wholebrain_modes) + results.wholebrain = wholebrain_by_model.(matlab.lang.makeValidName(lower(wholebrain_modes{1}))); + end +end + +if ~isempty(opts.OutputMat) + local_save_results_mat(char(opts.OutputMat), results, opts); +end +end + +function tf = local_has_wholebrain_outputs(prefix) +tf = exist([prefix '_beta.nii'], 'file') == 2 && exist([prefix '_t.nii'], 'file') == 2; +end + +function wholebrain = local_load_wholebrain_outputs(prefix) +wholebrain = hrf_load_wholebrain_stats(prefix); +end + +function modes = local_resolve_wholebrain_modes(mode_input, models) +if iscell(mode_input) || (isstring(mode_input) && numel(mode_input) > 1) + requested = cellstr(string(mode_input)); +else + requested = {char(mode_input)}; +end + +if isscalar(requested) && strcmpi(requested{1}, 'auto') + requested = cellstr(string(models)); +end + +supported = {'fir', 'sfir', 'canonical', 'spline'}; +modes = {}; +for i = 1:numel(requested) + mode = lower(strtrim(char(requested{i}))); + if strcmp(mode, 'auto') + nested = local_resolve_wholebrain_modes('auto', models); + modes = [modes, nested]; %#ok + continue + end + if ~ismember(mode, supported) + if ismember(mode, {'logit', 'nlgamma'}) + warning('run_hrf_pipeline:SkippingWholeBrainModel', ... + 'Skipping whole-brain %s maps: nonlinear voxelwise model is not supported by the fast 4D writer.', mode); + continue + end + error('Unknown WholeBrainMode/model: %s. Use auto, FIR, sFIR, canonical, or spline.', mode); + end + if strcmp(mode, 'sfir') + modes{end + 1} = 'sFIR'; %#ok + else + modes{end + 1} = mode; %#ok + end +end + +modes = unique(modes, 'stable'); +if isempty(modes) + modes = {'FIR'}; +end +end + +function model_prefix = local_wholebrain_model_prefix(prefix, mode_name, n_modes) +if n_modes == 1 + model_prefix = prefix; +else + model_prefix = sprintf('%s_%s', prefix, lower(char(mode_name))); +end +end + +function local_save_results_mat(output_mat, results, opts) +save_version = char(opts.OutputMatVersion); +results_to_save = results; +if ~logical(opts.SaveWholeBrainInMat) + if isfield(results_to_save, 'wholebrain') + results_to_save.wholebrain_paths = results_to_save.wholebrain.paths; + if isfield(results_to_save.wholebrain, 'metadata_table') + results_to_save.wholebrain_metadata_table = results_to_save.wholebrain.metadata_table; + end + results_to_save = rmfield(results_to_save, 'wholebrain'); + end + if isfield(results_to_save, 'wholebrain_by_model') + fields = fieldnames(results_to_save.wholebrain_by_model); + wholebrain_paths_by_model = struct(); + wholebrain_metadata_by_model = struct(); + for i = 1:numel(fields) + wb = results_to_save.wholebrain_by_model.(fields{i}); + if isfield(wb, 'paths') + wholebrain_paths_by_model.(fields{i}) = wb.paths; + end + if isfield(wb, 'metadata_table') + wholebrain_metadata_by_model.(fields{i}) = wb.metadata_table; + end + end + results_to_save.wholebrain_paths_by_model = wholebrain_paths_by_model; + results_to_save.wholebrain_metadata_by_model = wholebrain_metadata_by_model; + results_to_save = rmfield(results_to_save, 'wholebrain_by_model'); + end +end + +results = results_to_save; +if isempty(save_version) + save(output_mat, 'results'); +else + save(output_mat, 'results', save_version); +end +end + +function [TR, n_tp] = local_get_tr_and_ntp(fmri_nii) +info = niftiinfo(fmri_nii); +if numel(info.ImageSize) < 4 + error('Expected 4D fMRI image, got %d dimensions.', numel(info.ImageSize)); +end +n_tp = info.ImageSize(4); +TR = []; +if isfield(info, 'PixelDimensions') && numel(info.PixelDimensions) >= 4 + TR = info.PixelDimensions(4); +end +end + +function prefix = local_default_wholebrain_prefix(fmri_nii) +[p, f, e] = fileparts(fmri_nii); +if strcmpi(e, '.gz') + [~, f] = fileparts(f); +end +prefix = fullfile(p, [f '_hrf_wholebrain']); +end + +function ts_by_signature = local_timeseries_by_signature(all_tc, signature_meta) +ts_by_signature = struct(); +if ~isfield(signature_meta, 'selected_signatures') || isempty(signature_meta.selected_signatures) + return +end + +sig_names = cellstr(string(signature_meta.selected_signatures)); +if isfield(signature_meta, 'selected_signature_fields') && ... + numel(signature_meta.selected_signature_fields) >= numel(sig_names) + sig_fields = cellstr(string(signature_meta.selected_signature_fields)); +else + sig_fields = matlab.lang.makeUniqueStrings(matlab.lang.makeValidName(sig_names)); +end + +n = min(numel(sig_fields), size(all_tc, 2)); +for i = 1:n + ts_by_signature.(sig_fields{i}) = all_tc(:, i); +end +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/run_hrf_study_pipeline.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/run_hrf_study_pipeline.m new file mode 100644 index 00000000..16b0cff2 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/run_hrf_study_pipeline.m @@ -0,0 +1,231 @@ +function study = run_hrf_study_pipeline(fmri_files, events_files, subject_ids, varargin) +%RUN_HRF_STUDY_PIPELINE Run run_hrf_pipeline across subjects/images. + +if ischar(fmri_files) || isstring(fmri_files), fmri_files = cellstr(fmri_files); end +if ischar(events_files) || isstring(events_files), events_files = cellstr(events_files); end +if nargin < 3 || isempty(subject_ids) + subject_ids = arrayfun(@(i) sprintf('sub-%03d', i), 1:numel(fmri_files), 'UniformOutput', false); +end +if isstring(subject_ids), subject_ids = cellstr(subject_ids); end + +[study_opts, pipeline_args] = local_parse_study_options(varargin{:}); + +n = numel(fmri_files); +if numel(events_files) ~= n + error('fmri_files and events_files must contain the same number of files.'); +end +if numel(subject_ids) ~= n + error('subject_ids must be empty or contain one id per fMRI file.'); +end +run_labels = local_run_labels(fmri_files, subject_ids, study_opts.run_labels); +results = cell(1, n); +errors = cell(1, n); + +if ~isempty(study_opts.wholebrain_output_dir) && ~exist(study_opts.wholebrain_output_dir, 'dir') + mkdir(study_opts.wholebrain_output_dir); +end + +wholebrain_output_dir = study_opts.wholebrain_output_dir; +reuse_wholebrain_outputs = study_opts.reuse_wholebrain_outputs; +spm_files = local_normalize_spm_files(study_opts.spm_files, n); +spm_runs = local_normalize_spm_runs(study_opts.spm_runs, n); + +if study_opts.use_parallel_subjects + if isempty(gcp('nocreate')) + parpool; + end + parfor i = 1:n + [results{i}, errors{i}] = local_run_one(fmri_files{i}, events_files{i}, subject_ids{i}, run_labels{i}, ... + pipeline_args, wholebrain_output_dir, reuse_wholebrain_outputs, spm_files{i}, spm_runs(i)); + end +else + for i = 1:n + [results{i}, errors{i}] = local_run_one(fmri_files{i}, events_files{i}, subject_ids{i}, run_labels{i}, ... + pipeline_args, wholebrain_output_dir, reuse_wholebrain_outputs, spm_files{i}, spm_runs(i)); + end +end + +failed = ~cellfun(@isempty, errors); +if any(failed) + for i = find(failed) + warning('Problem with %s: %s', fmri_files{i}, errors{i}); + end + if ~study_opts.continue_on_error + error('run_hrf_study_pipeline failed for %d file(s). First error: %s', sum(failed), errors{find(failed, 1)}); + end +end + +success = ~cellfun(@isempty, results); +valid_results = results(success); +valid_subject_ids = subject_ids(success); + +study = struct(); +study.subject_ids = subject_ids; +study.run_labels = run_labels; +study.results = results; +study.success = success; +study.errors = errors; +study.summary = hrf_summarize_study(valid_results, valid_subject_ids); + +% Subject x time matrix of mean extracted timeseries +if isempty(valid_results) + study.mean_timeseries = []; +else + ts = cellfun(@(r) r.timeseries(:)', valid_results, 'UniformOutput', false); + ts_len = cellfun(@numel, ts); + if all(ts_len == ts_len(1)) + study.mean_timeseries = cell2mat(ts'); + else + study.mean_timeseries = ts; + end +end +end + +function [opts, pipeline_args] = local_parse_study_options(varargin) +opts = struct('use_parallel_subjects', false, 'continue_on_error', true, ... + 'wholebrain_output_dir', '', 'reuse_wholebrain_outputs', false, 'run_labels', {{}}, ... + 'spm_files', {{}}, 'spm_runs', []); +pipeline_args = varargin; +i = 1; +while i <= numel(pipeline_args) + if ischar(pipeline_args{i}) || isstring(pipeline_args{i}) + key = lower(char(pipeline_args{i})); + switch key + case 'useparallelsubjects' + opts.use_parallel_subjects = logical(pipeline_args{i + 1}); + pipeline_args(i:i+1) = []; + continue + case 'continueonerror' + opts.continue_on_error = logical(pipeline_args{i + 1}); + pipeline_args(i:i+1) = []; + continue + case 'wholebrainoutputdir' + opts.wholebrain_output_dir = char(pipeline_args{i + 1}); + pipeline_args(i:i+1) = []; + continue + case {'reusewholebrainoutputs', 'reusewholebrainoutput'} + opts.reuse_wholebrain_outputs = logical(pipeline_args{i + 1}); + pipeline_args(i:i+1) = []; + continue + case 'runlabels' + opts.run_labels = cellstr(string(pipeline_args{i + 1})); + pipeline_args(i:i+1) = []; + continue + case {'spmfiles', 'wholebrainspmfiles'} + % Per-subject estimated SPM.mat paths for exact Tier B g*K*W. + opts.spm_files = pipeline_args{i + 1}; + pipeline_args(i:i+1) = []; + continue + case {'spmruns', 'wholebrainspmruns'} + opts.spm_runs = pipeline_args{i + 1}; + pipeline_args(i:i+1) = []; + continue + end + end + i = i + 1; +end +end + +function spm_files = local_normalize_spm_files(spm_files, n) +% Normalize the per-subject SPM input to an n-element cellstr ('' = none). +if isempty(spm_files) + spm_files = repmat({''}, 1, n); + return +end +if ischar(spm_files) || isstring(spm_files) + spm_files = cellstr(string(spm_files)); +end +if isscalar(spm_files) + spm_files = repmat(spm_files(:)', 1, n); +end +spm_files = cellfun(@(x) char(string(x)), spm_files, 'UniformOutput', false); +if numel(spm_files) ~= n + error('SPMFiles must be empty, scalar, or contain one entry per fMRI file (got %d, need %d).', numel(spm_files), n); +end +spm_files = reshape(spm_files, 1, n); +end + +function spm_runs = local_normalize_spm_runs(spm_runs, n) +if isempty(spm_runs) + spm_runs = ones(1, n); +elseif isscalar(spm_runs) + spm_runs = repmat(spm_runs, 1, n); +end +if numel(spm_runs) ~= n + error('SPMRuns must be empty, scalar, or contain one value per fMRI file.'); +end +spm_runs = reshape(spm_runs, 1, n); +end + +function [result, err_msg] = local_run_one(fmri_file, events_file, subject_id, run_label, pipeline_args, wholebrain_output_dir, reuse_wholebrain_outputs, spm_file, spm_run) +result = []; +err_msg = ''; +try + args = pipeline_args; + if ~isempty(wholebrain_output_dir) + prefix = fullfile(wholebrain_output_dir, [local_file_label([char(subject_id) '_' char(run_label)]) '_hrf']); + args = [args, {'WholeBrainOutputPrefix', prefix}]; + end + if reuse_wholebrain_outputs + args = [args, {'ReuseWholeBrainOutput', true}]; + end + if nargin >= 9 && ~isempty(spm_file) + args = [args, {'WholeBrainSPM', spm_file, 'WholeBrainSPMRun', spm_run}]; + end + result = run_hrf_pipeline(fmri_file, events_file, args{:}); +catch ME + err_msg = ME.message; +end +end + +function run_labels = local_run_labels(fmri_files, subject_ids, run_labels_input) +n = numel(fmri_files); +if ~isempty(run_labels_input) + run_labels = cellstr(string(run_labels_input)); + if numel(run_labels) ~= n + error('RunLabels must contain one label per fMRI file.'); + end + run_labels = cellfun(@local_file_label, run_labels, 'UniformOutput', false); +else + run_labels = cell(n, 1); + for i = 1:n + run_labels{i} = local_run_label_from_filename(fmri_files{i}, i); + end +end + +seen = containers.Map('KeyType', 'char', 'ValueType', 'double'); +for i = 1:n + key = sprintf('%s__%s', char(subject_ids{i}), run_labels{i}); + if isKey(seen, key) + seen(key) = seen(key) + 1; + run_labels{i} = sprintf('%s_dup%02d', run_labels{i}, seen(key)); + else + seen(key) = 1; + end +end +end + +function label = local_run_label_from_filename(fmri_file, idx) +[~, name, ext] = fileparts(char(fmri_file)); +if strcmpi(ext, '.gz') + [~, name] = fileparts(name); +end +parts = {}; +tokens = {'ses', 'task', 'run', 'acq', 'desc'}; +for i = 1:numel(tokens) + tok = regexp(name, [tokens{i} '-[^_]+'], 'match', 'once'); + if ~isempty(tok) + parts{end + 1} = tok; %#ok + end +end +if isempty(parts) + label = sprintf('run-%03d', idx); +else + label = strjoin(parts, '_'); +end +label = local_file_label(label); +end + +function label = local_file_label(subject_id) +label = regexprep(char(subject_id), '[^\w.-]', '_'); +end diff --git a/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/sFIR_multisubject_example_script.m b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/sFIR_multisubject_example_script.m new file mode 100644 index 00000000..d795b211 --- /dev/null +++ b/CanlabCore/HRF_Est_Toolbox4/HRF_Toolbox_Pipeline/sFIR_multisubject_example_script.m @@ -0,0 +1,112 @@ +function sFIR_estimation(subIDs) +% A wrapper function for estimating voxel-wise smooth FIR model fits for each subject +% in a multi-subject group, by Stephan Geuter. It uses the hrf_fit method for fmri_data objects, +% which calls the HRF_est_Toolbox2 functions. Future extensions could: +% - standardize the use of obj.images_per_session here, allowing flexibility in +% number of images per run +% - increase documentation +% - provide a complete walkthrough with data needed to run in our data +% repository + + +% folders +rdir = '/work/ics/data/projects/wagerlab/labdata/projects/YOURPROJECT/'; % root directory +odir = fullfile(rdir,'/data/onsets/'); % directory with onset files +adir = fullfile(rdir,'/analysis/'); % directory for analysis outputs +imgdir = fullfile(rdir,'data','mri'); % directory hold subdirectories with fmri data/nifti + +%%% + +% design spec +TR = 1.3; % TR in seconds +T = TR*23; % total duration of estimated (s)FIR response +fl = 'fl_D_cat_cue_on_sFIR'; % folder name for this analysis (sub directory of 'adir') + +nimgs = 1848; % n total images per subject +nrun = 4; % n runs per subject +img_run = nimgs/nrun; + + +% filters +imgfilt = 'swrarun_r*.nii'; % filter for nifti files of each subject +onsetfilt = 'cat_cue_on_stick.mat'; % filter for onset file for each subject + +% analysis mask +maskfile = fullfile(rdir,'masks','BasalGanglia','BG_T50.nii'); % fullpath to mask image +% standard mask: maskfile = which('brainmask.nii'); + + +%%% + +if ~isdir(fullfile(adir,fl)), mkdir(fullfile(adir,fl)); end + + +% loop subjects +nrun = numel(subIDs); +for crun = 1:nrun + + % current subject and folder + fprintf(1,'\nnow running subject %d...\n\n',subIDs(crun)); + fldir = fullfile(adir,fl,sprintf('sub%04d',subIDs(crun))); + if ~isdir(fldir), mkdir(fldir); end; + cd(fldir); + + % filter all image files + clear nimgs imgs + imgs = filenames(fullfile(imgdir,sprintf('sub%04d',subIDs(crun)),'run*',imgfilt)); + regnames = {}; + C = {}; + + % filter onset file + for o = 1:length(onsetfilt) + + % get onsets from subject specific onset directory + ons = filenames(sprintf('%ssub%04d/%s',odir,subIDs(crun),onsetfilt{o}),'char'); + load(ons); + + % select regressors + sel = find(cellregexp(names,'\w')); + + for k=1:length(sel) + + % make design cell array for HRF toolbox + C{end+1} = zeros(nimgs,1); + + C{end}(ceil(onsets{sel(k)})) = 1; + regnames{end+1} = names{sel(k)}; + % plot(C{k}+k,'color',hsv2rgb([1/numel(osel)*k,1,1])); hold on + % fprintf(1,'\n%s\t\t%d',names{k},sum(C{k})); + end + end + + % load data within mask + d = fmri_data(imgs, maskfile); + + % HP filter + [y, pI] = hpfilter(d.dat', TR, 160, repmat(img_run,nrun,1)); + + % add intercept back + y = y + pI * d.dat'; + + % scale SPM style + v = [0 1:nrun-1] * 462; + gs = []; + for r = 1:nrun + gs(r) = nanmean( nanmean( double(y(1+v(r):img_run+v(r),:)),2)); + end + gs = repmat(gs,img_run,1); gs = gs(:)'; + gs = repmat(gs,size(d.dat,1),1); + d.dat = y' * 100 ./ gs; + + % fit sFIR model. writes images in pwd (fldir) + [params_obj hrf_obj] = hrf_fit(d, TR, C, T, 'FIR', 1); + + % save results in .mat files + fn = fullfile(fldir,'sFIR_results.mat'); + save(fn,'params_obj','hrf_obj','onsetfilt','regnames','C','onsets'); + +end + + + +end \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/entries b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/entries deleted file mode 100644 index 283da480..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/entries +++ /dev/null @@ -1,439 +0,0 @@ -10 - -dir -1964 -svn+ssh://svnclient@canlab.colorado.edu/Volumes/RAID1/resources/svn/Repository/trunk/SCN_Core_Support/HRF_Est_Toolbox2/Old_stuff -svn+ssh://svnclient@canlab.colorado.edu/Volumes/RAID1/resources/svn/Repository - - - -2014-04-15T17:12:28.291045Z -1964 -svnclient - - - - - - - - - - - - - - -a7b2ed3e-3a17-0410-bf9f-8ac42634c47e - -Fit_sFIRold.m -file - - - - -2014-04-15T17:46:31.000000Z -5d0f5ecf4d8118b6e4d52cf3083f6bab -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -1127 - -dilogit_dt2.m -file - - - - -2014-04-15T17:46:31.000000Z -89d1450a156163897d824b60514b41b3 -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -1101 - -Fit_Logit_allstim.m -file - - - - -2014-04-15T17:46:31.000000Z -4a3dbe6101f7850e404c796b22fb4e39 -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -1380 - -Get_parameters_Logit2_2hrfs.m -file - - - - -2014-04-15T17:46:31.000000Z -2b8284e3bd08186708c62d1ec592bbaa -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -3985 - -cost.m -file - - - - -2014-04-15T17:46:31.000000Z -a647c70ac08de344afbff371b993b689 -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -526 - -Det_Logitold.m -file - - - - -2014-04-15T17:46:31.000000Z -cc137b21758792c6d5824222420e8b05 -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -3035 - -cost2.m -file - - - - -2014-04-15T17:46:31.000000Z -91a6d37096c0f36da58dbe8acd6dc8a0 -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -664 - -More_recent_old_stuff -dir - -cost_allstim.m -file - - - - -2014-04-15T17:46:31.000000Z -fbf437e9ed9a2cba634ea10882e49292 -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -855 - -cost_allstim_2.m -file - - - - -2014-04-15T17:46:31.000000Z -a91dbfbbc8673259ef8f30160a157afb -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -999 - -Get_parameters_Logit.m -file - - - - -2014-04-15T17:46:31.000000Z -fd96c932c00a85d33f36ff6848330ab8 -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -1679 - -Anneal_Logit_allstim.m -file - - - - -2014-04-15T17:46:31.000000Z -99b9d5bfc54aa873e36b0bcefa94aac8 -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -4432 - -ScanSim4.m -file - - - - -2014-04-15T17:46:31.000000Z -5e659b92fe6e29a956e74a99a9d2955d -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -1192 - diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/Anneal_Logit_allstim.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/Anneal_Logit_allstim.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/Anneal_Logit_allstim.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/Det_Logitold.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/Det_Logitold.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/Det_Logitold.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/Fit_Logit_allstim.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/Fit_Logit_allstim.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/Fit_Logit_allstim.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/Fit_sFIRold.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/Fit_sFIRold.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/Fit_sFIRold.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/Get_parameters_Logit.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/Get_parameters_Logit.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/Get_parameters_Logit.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/Get_parameters_Logit2_2hrfs.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/Get_parameters_Logit2_2hrfs.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/Get_parameters_Logit2_2hrfs.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/ScanSim4.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/ScanSim4.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/ScanSim4.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/cost.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/cost.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/cost.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/cost2.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/cost2.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/cost2.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/cost_allstim.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/cost_allstim.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/cost_allstim.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/cost_allstim_2.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/cost_allstim_2.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/cost_allstim_2.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/dilogit_dt2.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/dilogit_dt2.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/prop-base/dilogit_dt2.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/Anneal_Logit_allstim.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/Anneal_Logit_allstim.m.svn-base deleted file mode 100644 index cc29ef87..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/Anneal_Logit_allstim.m.svn-base +++ /dev/null @@ -1,152 +0,0 @@ -function [theta,HH,C,P,hrf,fit] = Anneal_Logit_allstim(theta0,t,tc,Run) -% -% [theta,HH,C,P] = Anneal_Logit(theta0,t,tc,Run) -% -% Estimate inverse logit (IL) HRF model using Simulated Annealing -% Creates fitted curve - 3 logistic functions to be summed together - from parameter estimates -% -% INPUT: theta0, t, tc, Run -% Run = stick function -% tc = time course -% t = vector of time points -% theta0 = initial value for the parameter vector -% -% By Martin Lindquist and Tor Wager -% Edited 12/12/06 -% -% further edited by Christian Waugh to include multiple trialtypes - -numstim = length(Run); - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% Initial values - -iter = 3000*numstim; % Number of iterations -theta = theta0; % Set initial value for the parameter vector -h0 = cost_allstim(theta0,t,tc,Run); % Calculate cost of initial estimate -%LB = [0.05, 0, 0, 0.05, 2.5, 0.05, 5]; % Previous Lower bounds for parameters -%UB = [5, 5, 2, 2, 7.5, 2, 10]; -LB = [0.05, 1, 0, 0.01, 2.5, 0.05, 3]; % Lower bounds for parameters -UB = [6, 5, 2, 2, 7.5, 3, 10]; % Upper bounds for parameters -LB = repmat(LB, 1, numstim); -UB = repmat(UB, 1, numstim); - -% -% These values may need tweaking depending on the individual situation. -% - -r1= 0.1; % delta parameters -r1b= 0.1; % delta parameters -r2 = .5; % T parameters -r3 = 0.1; % alpha parameters - -t1 = [1 4]; -t1b = [6]; -t2 = [2 5 7]; -t3 = [3]; - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -u = zeros(1,7); -u = repmat(u, 1, numstim); - -HH = zeros(1+iter,7*numstim); % Keep track of theta_i -HH(1,:) = theta0; -P = zeros(1+iter,1); -C = zeros(1+iter,1); % Keep track of the cost function -C(1) = h0; - -cnt = 0; -for i=1:iter, - - T = 200/log(1+i); %Temperature function (may require tweaking) - th = zeros(1,7); - th = repmat(th, 1, numstim); - ind = 0; - %time = zeros(numstim, 1); - - % Choose a new candidate solution theta_{i+1}, based on a random perturbation of the current solution of theta_{i}. - % Check new parameters are within accepted bounds - while ( (sum((LB-th)>0) + sum((th-UB)>0)) > 0) %|| (min(time) == 0), - - % Perturb solution - - for g = 0:(numstim-1) - u(t1+g*7) = normrnd(0,r1,1,2); - u(t1b+g*7) = normrnd(0,r1b,1,1); - u(t2+g*7) = normrnd(0,r2,1,3); - u(t3+g*7) = normrnd(0,r3,1,1); - end - - - % Update solution - th = theta + u; - ind = ind + 1; - - %include below if you want the times of inflection of the IL curves - %to be in a specific order (i.e. T1 < T2 < T3) - %for g = 1:numstim - % if th(g*7-5) < th(g*7-2) && th(g*7-2) < th(g*7) - % time(g) = 1; - % else - % time(g) = 0; - %end - %end - - if(ind > 500), - warning('stuck!'); - th = theta; - %return; - end; - end; - - h = cost_allstim(th,t,tc,Run); - C(i+1) = h; - delta = h - h0; - - % Determine whether to update the parameter vector. - if (unifrnd(0,1) < min(exp(-delta/T),1)), - theta = th; - h0=h; - cnt = cnt+1; - end; - - HH(i+1,:) = theta; - P(i+1) = min(exp(-delta/T),1); - -end; - -%cnt/iter - -[a,b] = min(C); -theta = HH(b,:); -%h - - -% Additional outputs -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Get HRF for final model -if nargout > 4 - hrf = zeros(length(t),numstim); -for g = 1:numstim - hrf(:,g) = Get_Logit(theta(g*7-6:g*7),t); % Calculate HRF estimate (fit, given theta) -end -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Convolve HRF and stick function -if nargout > 5 - len = length(Run{1}); - fitt = zeros(len,numstim); -for g = 1:numstim - fits(:,g) = conv(Run{g}, hrf(:,g)); - fitt(:,g) = fits(1:len,g); -end - fit = sum(fitt,2); -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - - -return diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/Det_Logitold.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/Det_Logitold.m.svn-base deleted file mode 100644 index c8b3c971..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/Det_Logitold.m.svn-base +++ /dev/null @@ -1,128 +0,0 @@ -function [VM, h, fit, e, param] = Det_Logit(V0,t,tc,Run) -% -% [theta,HH,C,P] = Anneal_Logit(theta0,t,tc,Run) -% -% Estimate inverse logit (IL) HRF model -% Creates fitted curve - 3 logistic functions to be summed together - from parameter estimates -% -% INPUT: V0, t, tc, Run -% Run = stick function -% tc = time course -% t = vector of time points -% V0 = initial value for the parameter vector -% -% By Martin Lindquist and Tor Wager -% Edited 10/01/09 -% - -% Find optimal values -options = optimset('MaxFunEvals',10000000,'Maxiter',10000000,'TolX',1e-8,'TolFun',1e-8,'Display','off'); -VM = fminsearch(@msq_logit,V0,options,Run,t,tc); -VM - -% Use optimal values to fit hemodynamic response functions -h = il_hdmf_tw2(t,VM(1:7)); - -%[param] = get_parameters2(h,t); -[param] = get_parameters_logit(h,t,VM(1:7)); - -len = length(Run); -fit = conv(Run, h); -fit = fit(1:len); - -e = tc-fit; - -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% SUBFUNCTIONS -% -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -function m=msq_logit(V,Run, t, tc) - -HR = il_hdmf_tw2(t,V(1:7)); -len = length(Run); -timecourse = conv(Run, HR); -timecourse = timecourse(1:len); - -m=sum((tc-timecourse).^2); - -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -function [h,base] = il_hdmf_tw2(t,V) -% inverse logit -- creates fitted curve from parameter estimates -% -% t = vector of time points -% V = parameters - -% 3 logistic functions to be summed together -base = zeros(length(t),3); -A1 = V(1); -T1 = V(2); -d1 = V(3); -A2 = V(4); -T2 = V(5); -A3 = V(6); -T3 = V(7); -d2 = -d1*(ilogit(A1*(1-T1)) - ilogit(A3*(1-T3)))/(ilogit(A2*(1-T2)) + ilogit(A3*(1-T3))); -d3 = abs(d2)-abs(d1); - -base(:,1)= d1*ilogit(A1*(t-T1))'; -base(:,2)= d2*ilogit(A2*(t-T2))'; -base(:,3)= d3*ilogit(A3*(t-T3))'; -h = sum(base,2)'; - -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -function [L] = ilogit(t) -L = exp(t)./(1+exp(t)); -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% function [param] = get_parameters2(hdrf,t) -% % Find model parameters -% % -% % Height - h -% % Time to peak - p (in time units of TR seconds) -% % Width (at half peak) - w -% -% % Calculate Heights and Time to peak: -% -% n = ceil(t(end)*0.8); -% [h,p] = max(abs(hdrf(1:n))); -% h = hdrf(p); -% -% if (h >0) -% v = (hdrf >= h/2); -% else -% v = (hdrf <= h/2); -% end; -% -% [a,b] = min(diff(v)); -% v(b+1:end) = 0; -% w = sum(v); -% -% cnt = p-1; -% g =hdrf(2:end) - hdrf(1:(end-1)); -% while((cnt > 0) & (abs(g(cnt)) <0.001)), -% h = hdrf(cnt); -% p = cnt; -% cnt = cnt-1; -% end; -% -% param = zeros(3,1); -% param(1) = h; -% param(2) = p; -% param(3) = w; -% -% end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/Fit_Logit_allstim.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/Fit_Logit_allstim.m.svn-base deleted file mode 100644 index 80ff2a53..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/Fit_Logit_allstim.m.svn-base +++ /dev/null @@ -1 +0,0 @@ -function [VM, hrf, C, fit] = Fit_Logit_allstim(V0,t,tc,Run) % [h1, h2, VM] = Fit_logit(RunA, RunB, tc, t) % Fit inverse logit function model to time course % Initial values numstim = length(Run); len = length(Run{1}); %V0 = [ 0.5 5 1 0.5 25 -1.5 0.5 50]; %V0 = [1 3 1.5 0.5 5 3 15]; LB = [0.05, 1, 0, 0.05, 2.5, 0.05, 4]; % Previous Lower bounds for parameters UB = [5, 5, 2, 2, 7.5, 2, 10]; %LB = [0.05, 1, 0, 0.01, 2.5, 0.05, 3]; % Lower bounds for parameters %UB = [6, 13, 2, 2, 15.5, 3, 18]; % Upper bounds for parameters LB = repmat(LB, 1, numstim); UB = repmat(UB, 1, numstim); % Find optimal values %options = optimset('MaxFunEvals',10000,'Maxiter',10000,'TolX', 1e-4, 'TolFun', 1e-4,'Display','Final'); options = optimset('MaxFunEvals',5000,'Maxiter',5000,'TolX', 1e-4, 'TolFun', 1e-4,'Display','off'); %VM = fminsearch(@msq_timecourse,V02,options,t,tc,RunA,RunB,RunC ); VM = fminsearchbnd(@cost_allstim, V0, LB,UB,options,t,tc,Run); % Use optimal values to fit hemodynamic response functions hrf =zeros(length(t),numstim); fitt = zeros(len,numstim); for g = 1:numstim hrf(:,g) = Get_Logit(VM(g*7-6:g*7),t); % Calculate HRF estimate (fit, given theta) end for g = 1:numstim fits(:,g) = conv(Run{g}, hrf(:,g)); fitt(:,g) = fits(1:len,g); end fit = sum(fitt,2); C = sum((fit-tc).^2); return; \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/Fit_sFIRold.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/Fit_sFIRold.m.svn-base deleted file mode 100644 index c74c76cf..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/Fit_sFIRold.m.svn-base +++ /dev/null @@ -1,56 +0,0 @@ -function [hrf, fit, e, param] = Fit_sFIR(tc,TR,Runs,T,mode) -% function [hrf, fit, e, param] = Fit_sFIR(tc,TR,Runs,T,mode) -% -% Fits FIR and smooth FIR model -% -% INPUTS: -% -% tc - time course -% TR - time resolution -% Runs - expermental design -% T - length of estimated HRF -% mode - FIR or smooth FIR -% options: -% 0 - standard FIR -% 1 - smooth FIR -% -% OUTPUTS: -% -% hrf - estimated hemodynamic response function -% fit - estimated time course -% e - residual time course -% param - estimated amplitude, height and width -% -% Created by Martin Lindquist on 10/02/09 - -[DX] = tor_make_deconv_mtx3(Runs,T,1); -DX2 = DX(:,1:T); -num = T; - -if mode == 1 - - C=(1:num)'*(ones(1,num)); - h = sqrt(1/(7/TR)); % 7 seconds smoothing - ref. Goutte - - v = 0.1; - sig = 1; - - R = v*exp(-h/2*(C-C').^2); - RI = inv(R); - - b = inv(DX2'*DX2+sig^2*RI)*DX2'*tc; - fit = DX2*b; - e = tc - DX2*b; - -elseif mode == 0 - - b = pinv(DX)*tc; - fit = DX*b; - e = tc - DX*b; - -end - -hrf = b; -param = get_parameters2(b,T); - -end \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/Get_parameters_Logit.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/Get_parameters_Logit.m.svn-base deleted file mode 100644 index f1d9799e..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/Get_parameters_Logit.m.svn-base +++ /dev/null @@ -1,77 +0,0 @@ -function [param] = get_parameters_logit(hdrf,t,VM) -% -% [param] = get_parameters_logit(hdrf,t,VM) -% -% Estimate Height, time-to-peak and Width for the inverse logit model -% -% INPUT: hdrf,t,VM -% hdrf - HRF estimated using IL model -% t - vector of time points -% VM - IL model parameters -% -% OUTPUT: param =[h,p,w] -% Height - h -% Time to peak - p (in time units of TR seconds) -% Width (at half peak) - w -% -% By Martin Lindquist and Tor Wager -% Edited 12/12/06 -% - -% Estimate Heights and Time to peak: - -[dL, dL2, d1, d2] = dilogit_dt2(t,VM); % Calculate first and second derivativeof IL model -g = (abs(diff(sign(dL))) >= 1); -cnt = max(1,ceil(VM(2))); %Peak has to be further along then T1 - -p =-1; -while(cnt < max(t)), - if((g(cnt) == 1) & (dL2(cnt) <= 0)), - p = cnt; - h = hdrf(p); - cnt = max(t) +1; - end; - cnt = cnt+1; -end; - -if (p == -1), - [h,p] = max(hdrf); -end; - - -% Interpolate to get finer estimation of p -if (p>1 & p= h/2); -[a,b2] = min(diff(v)); -v(b2+1:end) = 0; -[a,b1] = max(v); - -% Interpolate to get finer estimation of v - -if (p>1 && p= h/2)); - tp = b2 + (0:0.01:1); - htmp = Get_Logit(VM,tp); - delta2 = sum((htmp >= h/2)); - w = sum(v) + delta1/100 + delta2/100; -else - w = sum(v)+1; -end; - -% Set output vector param -param = zeros(3,1); -param(1) = h; -param(2) = p; -param(3) = w; - -return; diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/Get_parameters_Logit2_2hrfs.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/Get_parameters_Logit2_2hrfs.m.svn-base deleted file mode 100644 index 4ddb5c40..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/Get_parameters_Logit2_2hrfs.m.svn-base +++ /dev/null @@ -1,176 +0,0 @@ -function [param, hdrf2] = Get_parameters_Logit2_2hrfs(hdrf,t,VM,t1t2) -% -% [param] = get_parameters_logit(hdrf,t,VM) -% -% Different from version 1 in that it only calculates increases as 'peaks' -% Estimate Height, time-to-peak and Width for the inverse logit model -% -% INPUT: hdrf,t,VM (all with two rows for each HRF of interest) -% hdrf - HRF estimated using IL model -% t - vector of time points -% VM - IL model parameters -% t1t2 - number of time points separating the onset of the first and second -% hrfs - -% OUTPUT: param =[h,p,w] -% Height - h -% Time to peak - p (in time units of TR seconds) -% Width (at half peak) - w -% -% By Martin Lindquist and Tor Wager -% Edited 12/12/06 -% -% In addition, it'll calculate the width from two HRFs put together in case -% your design is such that two events are dependent on each other (i.e. -% cue->stimulus) -% - -% added by Christian Waugh - -% Estimate Heights and Time to peak for first HRF: - -[dL, dL2, d1, d2] = dilogit_dt2(t,VM(1, :)); % Calculate first and second derivativeof IL model -g = (abs(diff(sign(dL))) >= 1); -%cnt = max(1,ceil(VM(1, :)(2))); %Peak has to be further along then T1 -cnt = max(1,ceil(min(min(VM(1,2),VM(1,5)),VM(1,7)))); - -p =-1; - -while(cnt < max(t)), - if((g(cnt) == 1) && (dL2(cnt) <= 0)), - p = cnt; - h = hdrf(p,1); - cnt = max(t) +1; - end; - cnt = cnt+1; -end; - - -if (p == -1) - [h,p] = max(hdrf(:,1)); -end; - - -% Interpolate to get finer estimation of p -if (p>1 && p= h/2); -[a,b2] = min(diff([v;0])); -if b2 < length(v) -v(b2+1:end) = 0; -end -[a,b1] = max(v); - -% Interpolate to get finer estimation of v - -if (p>1 && p= h/2)); - tp = b2 + (0:0.01:1); - - htmp = Get_Logit(VM(1, :),tp); - delta2 = sum((htmp >= h/2)); - w = sum(v) + delta1/100 + delta2/100; -else - w = sum(v)+1; -end; - -% Set output vector param -param = zeros(7,1); -param(1) = h; -param(2) = p; -param(3) = w; - - -% Estimate Heights and Time to peak for second HRF: - -[dLb, dL2b, d1b, d2b] = dilogit_dt2(t,VM(2, :)); % Calculate first and second derivativeof IL model -gb = (abs(diff(sign(dLb))) >= 1); -%cnt = max(1,ceil(VM(1, :)(2))); %Peak has to be further along then T1 -cntb = max(1,ceil(min(min(VM(2,2),VM(2,5)),VM(2,7)))); - -pb =-1; -hb = 0; - - -while(cntb < max(t)), - if((gb(cntb) == 1) && (dL2b(cntb) <= 0)), - pb = cntb; - hb = hdrf(pb,2); - cntb = max(t) +1; - end; - cntb = cntb+1; -end; - - -if (pb == -1) - [hb,pb] = max(hdrf(:,2)); -end; - - -% Interpolate to get finer estimation of p -if (pb>1 & pb= hb/2); - -[ab,b2b] = min(diff([vb;0])); -if b2b < length(vb) -vb(b2b+1:end) = 0; -end -[ab,b1b] = max(vb); - -% Interpolate to get finer estimation of v - -if (pb>1 & pb= hb/2)); - tpb = b2b + (0:0.01:1); - - htmpb = Get_Logit(VM(2, :),tpb); - delta2 = sum((htmpb >= hb/2)); - wb = sum(vb) + delta1/100 + delta2/100; -else, - wb = sum(vb)+1; -end; - -% Set output vector param -param(4) = hb; -param(5) = pb; -param(6) = wb; - - -% Combine hrfs 1 and 2 to get an overall width estimate - -hdrf2 = [hdrf(:,1); repmat(0,t1t2,1)] + [repmat(0,t1t2,1); hdrf(:,2)]; - - vc = (hdrf2 >= h/2); - -[ac,b2c] = min(diff([vc;0])); -if b2c < length(vc) -vc(b2c+1:end) = 0; -end -[ac,b1c] = max(vc); - - -wc = sum(vc)+1; - -param(7) = wc; - -return; diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/ScanSim4.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/ScanSim4.m.svn-base deleted file mode 100644 index 803d202b..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/ScanSim4.m.svn-base +++ /dev/null @@ -1,67 +0,0 @@ -TR = 0.5; -len = 200; - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Compute hrf -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -t=0.1:TR:30; -a1 =6; -a2 = 12; -b1 = 0.9; -b2 =0.9; -c = 0.35; - -d1 = a1*b1; -d2 = a2*b2; - -h = ((t./d1).^a1).*exp(-(t-d1)./b1) - c*((t./d2).^a2).*exp(-(t-d2)./b2); -h = h./max(h); - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -% X = zeros(len,4); -% X(:,1) = 1; -% X(:,2) = (1:len)/len; -% X(:,3) = X(:,2).^2; -% -% Run = zeros(40,5); -% Run(1:20,:) = 1; -% Run = reshape(Run,200,1); -% q = conv(Run,h); -% X(:,4) = q(1:len); - - -X = zeros(len,2); -X(:,1) = 1; -Run = zeros(200,1); -Run(50) = 1; -Run(150) = 1; -q = conv(Run,h); -X(:,2) = q(1:len); - - -Run = zeros(200,1); -Run(50) = 1; -Run(160) = 1; -tc = conv(Run,h); -tc = tc(1:len) + normrnd(0,0.3,200,1); - - -beta = pinv(X)*tc; -e = tc-X*beta; -sigma = sqrt((1/(len-size(X,2)))*sum(e.^2)); -%c = [0 0 0 1]; -c = [0 1]; -se = sigma.*sqrt(c*inv(X'*X)*c'); -t = beta/se; -pval = 2*tcdf(-abs(t),len-4); -df = len - 4; - -[z sres sres_ns] = ResidScan(e, 4); -[b bias pl pc pe] = BiasPowerloss(tc, X,c,beta,df,z,0.05); - -beta -b -bias -pl diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/cost.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/cost.m.svn-base deleted file mode 100644 index 589007e6..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/cost.m.svn-base +++ /dev/null @@ -1 +0,0 @@ -function Q = cost(V,t,tc,Run) % % Least-squares cost function for the IL model % % INPUT: % Run = stick function % tc = time course % t = vector of time points % V = parameters % % OUTPUT: % Q = cost % % By Martin Lindquist and Tor Wager % Edited 12/12/06 % len = length(Run); h1 = Get_Logit(V(1:7),t); % Get IL model corresponding to parameters V yhat = conv(Run, h1); % Convolve IL model with stick function yhat = yhat(1:len); Q = sum((yhat-tc).^2); % Calculate cost function return \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/cost2.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/cost2.m.svn-base deleted file mode 100644 index 0aa67b4c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/cost2.m.svn-base +++ /dev/null @@ -1 +0,0 @@ -function Q = cost2(V,t,tc,RunA,RunB) % % inverse logit -- creates fitted curve from parameter estimates % % t = vector of time points % V = parameters % % 3 logistic functions to be summed together % % h1 = get_logit(V(1:7),t); % h2 = get_logit(V(8:14),t); % h1 = get_logit(V(1:8),t); % h2 = get_logit(V(9:16),t); type = 7; if (type == 7), h1 = get_logit(V(1:7),t); h2 = get_logit(V(8:14),t); elseif(type == 9), h1 = get_logit(V(1:9),t); h2 = get_logit(V(10:18),t); end; len = length(RunA); tcA = conv(RunA, h1); tcA = tcA(1:len); len = length(RunB); tcB = conv(RunB, h2); tcB = tcB(1:len); yhat = tcA + tcB; Q = sum((yhat-tc).^2); return \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/cost_allstim.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/cost_allstim.m.svn-base deleted file mode 100644 index 1255cd64..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/cost_allstim.m.svn-base +++ /dev/null @@ -1,33 +0,0 @@ -function Q = cost_allstim(V,t,tc,Run) -% -% Least-squares cost function for the IL model -% -% INPUT: -% Run = stick function -% tc = time course -% t = vector of time points -% V = parameters -% -% OUTPUT: -% Q = cost -% -% By Martin Lindquist and Tor Wager -% Edited 12/12/06 -% Further edited by Christian Waugh 2/15/08 to include multiple trialtypes - -numstim = length(Run); -len = length(Run{1}); -h = zeros(length(t),numstim); -yhatt =zeros(len,numstim); - -for k = 1:numstim - h(:,k) = Get_Logit(V(k*7-6:k*7),t); % Get IL model corresponding to parameters V - yhat(:,k) = conv(Run{k}, h(:,k)); % Convolve IL model with stick function - yhatt(:,k) = yhat(1:len,k); -end - -yhat2 = sum(yhatt,2); %Sum models together to get overall estimate - -Q = sum((yhat2-tc).^2); % Calculate cost function - -return \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/cost_allstim_2.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/cost_allstim_2.m.svn-base deleted file mode 100644 index 9c98d690..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/cost_allstim_2.m.svn-base +++ /dev/null @@ -1,43 +0,0 @@ -function Q = cost_allstim_2(V,tr,tc,Run,down) -% -% Least-squares cost function for the IL model -% Multi-condition case -% -% INPUT: -% down = downsampling factor -% Run = stick function -% tc = time course -% tr = repetition time -% V = parameters -% -% OUTPUT: -% Q = cost -% -% By Martin Lindquist and Tor Wager -% Edited 12/12/06 -% Further edited by Christian Waugh 2/15/08 to include multiple trialtypes -% Edited by ML on 02/12/13 - -t = 0:(1/down):30; - -numstim = length(Run); -len = length(Run{1}); -h = zeros(length(t),numstim); -yhatt =zeros(len,numstim); - - -for k = 1:numstim - - h(:,k) = Get_Logit(V(k*7-6:k*7),t); % Get IL model corresponding to parameters V - yhat(:,k) = conv(Run{k}, h(:,k)); % Convolve IL model with stick function - yhatt(:,k) = yhat(1:len,k); - -end - -tt = 1:(tr*down):len; - -yhat2 = sum(yhatt,2); %Sum models together to get overall estimate - -Q = sum((yhat2(tt)-tc).^2); % Calculate cost function - -return \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/dilogit_dt2.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/dilogit_dt2.m.svn-base deleted file mode 100644 index b0dd817b..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/.svn/text-base/dilogit_dt2.m.svn-base +++ /dev/null @@ -1,35 +0,0 @@ -function [dLdt, d2Ldt2, d1, d2] = dilogit_dt2(t,V) -% -% [dLdt, d2Ldt2, d1, d2] = dilogit_dt2(t,V) -% -% Calculate first and second temoral derivative of inverse logit (IL) HRF model -% curve -% -% INPUT: V, t -% t = vector of time points -% V = parameters -% -% OUTPUT: dLdt, d2Ldt2 -% dLdt = first temporal derivative of IL model -% d2Ldt2 = second temporal derivative of IL model -% -% By Martin Lindquist and Tor Wager -% Edited 12/12/06 -% - -% Set parameter values -A1 = V(1); -T1 = V(2); -d1 = V(3); -A2 = V(4); -T2 = V(5); -A3 = V(6); -T3 = V(7); -d2 = -d1*(ilogit(A1*(1-T1)) - ilogit(A3*(1-T3)))/(ilogit(A2*(1-T2)) + ilogit(A3*(1-T3))); -d3 = abs(d2)-abs(d1); - -% Calculate the first and second derivative -dLdt = d1*A1*ilogit(A1*(t-T1))./(1+exp(A1*(t-T1))) + d2*A2*ilogit(A2*(t-T2))./(1+exp(A2*(t-T2))) + d3*A3*ilogit(A3*(t-T3))./(1+exp(A3*(t-T3))); -d2Ldt2 = d1*(A1^2)*(exp(A1*(t-T1)) - exp(2*A1*(t-T1)))./((1+exp(A1*(t-T1))).^3) + d2*(A2^2)*(exp(A2*(t-T2)) - exp(2*A2*(t-T2)))./((1+exp(A2*(t-T2))).^3) + d3*(A3^2)*(exp(A3*(t-T3)) - exp(2*A3*(t-T3)))./((1+exp(A3*(t-T3))).^3); - -return; \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/Anneal_Logit_allstim.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/Anneal_Logit_allstim.m deleted file mode 100644 index cc29ef87..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/Anneal_Logit_allstim.m +++ /dev/null @@ -1,152 +0,0 @@ -function [theta,HH,C,P,hrf,fit] = Anneal_Logit_allstim(theta0,t,tc,Run) -% -% [theta,HH,C,P] = Anneal_Logit(theta0,t,tc,Run) -% -% Estimate inverse logit (IL) HRF model using Simulated Annealing -% Creates fitted curve - 3 logistic functions to be summed together - from parameter estimates -% -% INPUT: theta0, t, tc, Run -% Run = stick function -% tc = time course -% t = vector of time points -% theta0 = initial value for the parameter vector -% -% By Martin Lindquist and Tor Wager -% Edited 12/12/06 -% -% further edited by Christian Waugh to include multiple trialtypes - -numstim = length(Run); - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% Initial values - -iter = 3000*numstim; % Number of iterations -theta = theta0; % Set initial value for the parameter vector -h0 = cost_allstim(theta0,t,tc,Run); % Calculate cost of initial estimate -%LB = [0.05, 0, 0, 0.05, 2.5, 0.05, 5]; % Previous Lower bounds for parameters -%UB = [5, 5, 2, 2, 7.5, 2, 10]; -LB = [0.05, 1, 0, 0.01, 2.5, 0.05, 3]; % Lower bounds for parameters -UB = [6, 5, 2, 2, 7.5, 3, 10]; % Upper bounds for parameters -LB = repmat(LB, 1, numstim); -UB = repmat(UB, 1, numstim); - -% -% These values may need tweaking depending on the individual situation. -% - -r1= 0.1; % delta parameters -r1b= 0.1; % delta parameters -r2 = .5; % T parameters -r3 = 0.1; % alpha parameters - -t1 = [1 4]; -t1b = [6]; -t2 = [2 5 7]; -t3 = [3]; - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -u = zeros(1,7); -u = repmat(u, 1, numstim); - -HH = zeros(1+iter,7*numstim); % Keep track of theta_i -HH(1,:) = theta0; -P = zeros(1+iter,1); -C = zeros(1+iter,1); % Keep track of the cost function -C(1) = h0; - -cnt = 0; -for i=1:iter, - - T = 200/log(1+i); %Temperature function (may require tweaking) - th = zeros(1,7); - th = repmat(th, 1, numstim); - ind = 0; - %time = zeros(numstim, 1); - - % Choose a new candidate solution theta_{i+1}, based on a random perturbation of the current solution of theta_{i}. - % Check new parameters are within accepted bounds - while ( (sum((LB-th)>0) + sum((th-UB)>0)) > 0) %|| (min(time) == 0), - - % Perturb solution - - for g = 0:(numstim-1) - u(t1+g*7) = normrnd(0,r1,1,2); - u(t1b+g*7) = normrnd(0,r1b,1,1); - u(t2+g*7) = normrnd(0,r2,1,3); - u(t3+g*7) = normrnd(0,r3,1,1); - end - - - % Update solution - th = theta + u; - ind = ind + 1; - - %include below if you want the times of inflection of the IL curves - %to be in a specific order (i.e. T1 < T2 < T3) - %for g = 1:numstim - % if th(g*7-5) < th(g*7-2) && th(g*7-2) < th(g*7) - % time(g) = 1; - % else - % time(g) = 0; - %end - %end - - if(ind > 500), - warning('stuck!'); - th = theta; - %return; - end; - end; - - h = cost_allstim(th,t,tc,Run); - C(i+1) = h; - delta = h - h0; - - % Determine whether to update the parameter vector. - if (unifrnd(0,1) < min(exp(-delta/T),1)), - theta = th; - h0=h; - cnt = cnt+1; - end; - - HH(i+1,:) = theta; - P(i+1) = min(exp(-delta/T),1); - -end; - -%cnt/iter - -[a,b] = min(C); -theta = HH(b,:); -%h - - -% Additional outputs -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Get HRF for final model -if nargout > 4 - hrf = zeros(length(t),numstim); -for g = 1:numstim - hrf(:,g) = Get_Logit(theta(g*7-6:g*7),t); % Calculate HRF estimate (fit, given theta) -end -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Convolve HRF and stick function -if nargout > 5 - len = length(Run{1}); - fitt = zeros(len,numstim); -for g = 1:numstim - fits(:,g) = conv(Run{g}, hrf(:,g)); - fitt(:,g) = fits(1:len,g); -end - fit = sum(fitt,2); -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - - -return diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/Det_Logitold.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/Det_Logitold.m deleted file mode 100644 index c8b3c971..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/Det_Logitold.m +++ /dev/null @@ -1,128 +0,0 @@ -function [VM, h, fit, e, param] = Det_Logit(V0,t,tc,Run) -% -% [theta,HH,C,P] = Anneal_Logit(theta0,t,tc,Run) -% -% Estimate inverse logit (IL) HRF model -% Creates fitted curve - 3 logistic functions to be summed together - from parameter estimates -% -% INPUT: V0, t, tc, Run -% Run = stick function -% tc = time course -% t = vector of time points -% V0 = initial value for the parameter vector -% -% By Martin Lindquist and Tor Wager -% Edited 10/01/09 -% - -% Find optimal values -options = optimset('MaxFunEvals',10000000,'Maxiter',10000000,'TolX',1e-8,'TolFun',1e-8,'Display','off'); -VM = fminsearch(@msq_logit,V0,options,Run,t,tc); -VM - -% Use optimal values to fit hemodynamic response functions -h = il_hdmf_tw2(t,VM(1:7)); - -%[param] = get_parameters2(h,t); -[param] = get_parameters_logit(h,t,VM(1:7)); - -len = length(Run); -fit = conv(Run, h); -fit = fit(1:len); - -e = tc-fit; - -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% SUBFUNCTIONS -% -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -function m=msq_logit(V,Run, t, tc) - -HR = il_hdmf_tw2(t,V(1:7)); -len = length(Run); -timecourse = conv(Run, HR); -timecourse = timecourse(1:len); - -m=sum((tc-timecourse).^2); - -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -function [h,base] = il_hdmf_tw2(t,V) -% inverse logit -- creates fitted curve from parameter estimates -% -% t = vector of time points -% V = parameters - -% 3 logistic functions to be summed together -base = zeros(length(t),3); -A1 = V(1); -T1 = V(2); -d1 = V(3); -A2 = V(4); -T2 = V(5); -A3 = V(6); -T3 = V(7); -d2 = -d1*(ilogit(A1*(1-T1)) - ilogit(A3*(1-T3)))/(ilogit(A2*(1-T2)) + ilogit(A3*(1-T3))); -d3 = abs(d2)-abs(d1); - -base(:,1)= d1*ilogit(A1*(t-T1))'; -base(:,2)= d2*ilogit(A2*(t-T2))'; -base(:,3)= d3*ilogit(A3*(t-T3))'; -h = sum(base,2)'; - -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -function [L] = ilogit(t) -L = exp(t)./(1+exp(t)); -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% function [param] = get_parameters2(hdrf,t) -% % Find model parameters -% % -% % Height - h -% % Time to peak - p (in time units of TR seconds) -% % Width (at half peak) - w -% -% % Calculate Heights and Time to peak: -% -% n = ceil(t(end)*0.8); -% [h,p] = max(abs(hdrf(1:n))); -% h = hdrf(p); -% -% if (h >0) -% v = (hdrf >= h/2); -% else -% v = (hdrf <= h/2); -% end; -% -% [a,b] = min(diff(v)); -% v(b+1:end) = 0; -% w = sum(v); -% -% cnt = p-1; -% g =hdrf(2:end) - hdrf(1:(end-1)); -% while((cnt > 0) & (abs(g(cnt)) <0.001)), -% h = hdrf(cnt); -% p = cnt; -% cnt = cnt-1; -% end; -% -% param = zeros(3,1); -% param(1) = h; -% param(2) = p; -% param(3) = w; -% -% end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/Fit_Logit_allstim.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/Fit_Logit_allstim.m deleted file mode 100644 index 80ff2a53..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/Fit_Logit_allstim.m +++ /dev/null @@ -1 +0,0 @@ -function [VM, hrf, C, fit] = Fit_Logit_allstim(V0,t,tc,Run) % [h1, h2, VM] = Fit_logit(RunA, RunB, tc, t) % Fit inverse logit function model to time course % Initial values numstim = length(Run); len = length(Run{1}); %V0 = [ 0.5 5 1 0.5 25 -1.5 0.5 50]; %V0 = [1 3 1.5 0.5 5 3 15]; LB = [0.05, 1, 0, 0.05, 2.5, 0.05, 4]; % Previous Lower bounds for parameters UB = [5, 5, 2, 2, 7.5, 2, 10]; %LB = [0.05, 1, 0, 0.01, 2.5, 0.05, 3]; % Lower bounds for parameters %UB = [6, 13, 2, 2, 15.5, 3, 18]; % Upper bounds for parameters LB = repmat(LB, 1, numstim); UB = repmat(UB, 1, numstim); % Find optimal values %options = optimset('MaxFunEvals',10000,'Maxiter',10000,'TolX', 1e-4, 'TolFun', 1e-4,'Display','Final'); options = optimset('MaxFunEvals',5000,'Maxiter',5000,'TolX', 1e-4, 'TolFun', 1e-4,'Display','off'); %VM = fminsearch(@msq_timecourse,V02,options,t,tc,RunA,RunB,RunC ); VM = fminsearchbnd(@cost_allstim, V0, LB,UB,options,t,tc,Run); % Use optimal values to fit hemodynamic response functions hrf =zeros(length(t),numstim); fitt = zeros(len,numstim); for g = 1:numstim hrf(:,g) = Get_Logit(VM(g*7-6:g*7),t); % Calculate HRF estimate (fit, given theta) end for g = 1:numstim fits(:,g) = conv(Run{g}, hrf(:,g)); fitt(:,g) = fits(1:len,g); end fit = sum(fitt,2); C = sum((fit-tc).^2); return; \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/Fit_Logit_allstim_2.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/Fit_Logit_allstim_2.m deleted file mode 100644 index 95139b90..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/Fit_Logit_allstim_2.m +++ /dev/null @@ -1 +0,0 @@ -function [VM, hrf, fit] = Fit_Logit_allstim_2(V0,tc,tr, Run, down) % Fit inverse logit function model to time course % Multi-condition case % % Last edited by ML on 02/12/13 % % INPUTS: % % V0 - Initial values for IL-model % tc - time course data % tr - TR % Run - a cell array containing stick functions (one for each condition) % down - downsampling factor % % OUTPUTS: % % VM - final parameter values for IL-model % hrf - estimated HRFs for each condition % fit - estimated time course % Initial values numstim = length(Run); len = length(Run{1}); LB = [0.05, 1, 0, 0.05, 5, 0.05, 8]; % Previous Lower bounds for parameters UB = [2 10, 2, 2, 15, 1, 20]; % LB = [0.05, 1, 0, 0.05, 5, 0.05, 8, 0]; % Previous Lower bounds for parameters % UB = [2 10, 5, 2, 15, 1, 20, 5]; LB = repmat(LB, 1, numstim); UB = repmat(UB, 1, numstim); V0 = repmat(V0,1,numstim); % Find optimal values %options = optimset('MaxFunEvals',10000,'Maxiter',10000,'TolX', 1e-4, 'TolFun', 1e-4,'Display','Final'); options = optimset('MaxFunEvals',100000,'Maxiter',100000,'TolX', 1e-8, 'TolFun', 1e-8,'Display','off'); %VM = fminsearch(@msq_timecourse,V02,options,t,tc,RunA,RunB,RunC ); VM = fminsearchbnd(@cost_allstim_2, V0, LB, UB, options,tr,tc,Run,down); % Use optimal values to fit hemodynamic response functions t=0:(1/down):30; hrf =zeros(length(t),numstim); fitt = zeros(len,numstim); for g = 1:numstim hrf(:,g) = Get_Logit(VM(g*7-6:g*7),t); % Calculate HRF estimate (fit, given theta) end for g = 1:numstim fits(:,g) = conv(Run{g}, hrf(:,g)); fitt(:,g) = fits(1:len,g); end fit = sum(fitt,2); return; \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/Get_parameters_Logit.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/Get_parameters_Logit.m deleted file mode 100644 index f1d9799e..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/Get_parameters_Logit.m +++ /dev/null @@ -1,77 +0,0 @@ -function [param] = get_parameters_logit(hdrf,t,VM) -% -% [param] = get_parameters_logit(hdrf,t,VM) -% -% Estimate Height, time-to-peak and Width for the inverse logit model -% -% INPUT: hdrf,t,VM -% hdrf - HRF estimated using IL model -% t - vector of time points -% VM - IL model parameters -% -% OUTPUT: param =[h,p,w] -% Height - h -% Time to peak - p (in time units of TR seconds) -% Width (at half peak) - w -% -% By Martin Lindquist and Tor Wager -% Edited 12/12/06 -% - -% Estimate Heights and Time to peak: - -[dL, dL2, d1, d2] = dilogit_dt2(t,VM); % Calculate first and second derivativeof IL model -g = (abs(diff(sign(dL))) >= 1); -cnt = max(1,ceil(VM(2))); %Peak has to be further along then T1 - -p =-1; -while(cnt < max(t)), - if((g(cnt) == 1) & (dL2(cnt) <= 0)), - p = cnt; - h = hdrf(p); - cnt = max(t) +1; - end; - cnt = cnt+1; -end; - -if (p == -1), - [h,p] = max(hdrf); -end; - - -% Interpolate to get finer estimation of p -if (p>1 & p= h/2); -[a,b2] = min(diff(v)); -v(b2+1:end) = 0; -[a,b1] = max(v); - -% Interpolate to get finer estimation of v - -if (p>1 && p= h/2)); - tp = b2 + (0:0.01:1); - htmp = Get_Logit(VM,tp); - delta2 = sum((htmp >= h/2)); - w = sum(v) + delta1/100 + delta2/100; -else - w = sum(v)+1; -end; - -% Set output vector param -param = zeros(3,1); -param(1) = h; -param(2) = p; -param(3) = w; - -return; diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/Get_parameters_Logit2_2hrfs.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/Get_parameters_Logit2_2hrfs.m deleted file mode 100644 index 4ddb5c40..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/Get_parameters_Logit2_2hrfs.m +++ /dev/null @@ -1,176 +0,0 @@ -function [param, hdrf2] = Get_parameters_Logit2_2hrfs(hdrf,t,VM,t1t2) -% -% [param] = get_parameters_logit(hdrf,t,VM) -% -% Different from version 1 in that it only calculates increases as 'peaks' -% Estimate Height, time-to-peak and Width for the inverse logit model -% -% INPUT: hdrf,t,VM (all with two rows for each HRF of interest) -% hdrf - HRF estimated using IL model -% t - vector of time points -% VM - IL model parameters -% t1t2 - number of time points separating the onset of the first and second -% hrfs - -% OUTPUT: param =[h,p,w] -% Height - h -% Time to peak - p (in time units of TR seconds) -% Width (at half peak) - w -% -% By Martin Lindquist and Tor Wager -% Edited 12/12/06 -% -% In addition, it'll calculate the width from two HRFs put together in case -% your design is such that two events are dependent on each other (i.e. -% cue->stimulus) -% - -% added by Christian Waugh - -% Estimate Heights and Time to peak for first HRF: - -[dL, dL2, d1, d2] = dilogit_dt2(t,VM(1, :)); % Calculate first and second derivativeof IL model -g = (abs(diff(sign(dL))) >= 1); -%cnt = max(1,ceil(VM(1, :)(2))); %Peak has to be further along then T1 -cnt = max(1,ceil(min(min(VM(1,2),VM(1,5)),VM(1,7)))); - -p =-1; - -while(cnt < max(t)), - if((g(cnt) == 1) && (dL2(cnt) <= 0)), - p = cnt; - h = hdrf(p,1); - cnt = max(t) +1; - end; - cnt = cnt+1; -end; - - -if (p == -1) - [h,p] = max(hdrf(:,1)); -end; - - -% Interpolate to get finer estimation of p -if (p>1 && p= h/2); -[a,b2] = min(diff([v;0])); -if b2 < length(v) -v(b2+1:end) = 0; -end -[a,b1] = max(v); - -% Interpolate to get finer estimation of v - -if (p>1 && p= h/2)); - tp = b2 + (0:0.01:1); - - htmp = Get_Logit(VM(1, :),tp); - delta2 = sum((htmp >= h/2)); - w = sum(v) + delta1/100 + delta2/100; -else - w = sum(v)+1; -end; - -% Set output vector param -param = zeros(7,1); -param(1) = h; -param(2) = p; -param(3) = w; - - -% Estimate Heights and Time to peak for second HRF: - -[dLb, dL2b, d1b, d2b] = dilogit_dt2(t,VM(2, :)); % Calculate first and second derivativeof IL model -gb = (abs(diff(sign(dLb))) >= 1); -%cnt = max(1,ceil(VM(1, :)(2))); %Peak has to be further along then T1 -cntb = max(1,ceil(min(min(VM(2,2),VM(2,5)),VM(2,7)))); - -pb =-1; -hb = 0; - - -while(cntb < max(t)), - if((gb(cntb) == 1) && (dL2b(cntb) <= 0)), - pb = cntb; - hb = hdrf(pb,2); - cntb = max(t) +1; - end; - cntb = cntb+1; -end; - - -if (pb == -1) - [hb,pb] = max(hdrf(:,2)); -end; - - -% Interpolate to get finer estimation of p -if (pb>1 & pb= hb/2); - -[ab,b2b] = min(diff([vb;0])); -if b2b < length(vb) -vb(b2b+1:end) = 0; -end -[ab,b1b] = max(vb); - -% Interpolate to get finer estimation of v - -if (pb>1 & pb= hb/2)); - tpb = b2b + (0:0.01:1); - - htmpb = Get_Logit(VM(2, :),tpb); - delta2 = sum((htmpb >= hb/2)); - wb = sum(vb) + delta1/100 + delta2/100; -else, - wb = sum(vb)+1; -end; - -% Set output vector param -param(4) = hb; -param(5) = pb; -param(6) = wb; - - -% Combine hrfs 1 and 2 to get an overall width estimate - -hdrf2 = [hdrf(:,1); repmat(0,t1t2,1)] + [repmat(0,t1t2,1); hdrf(:,2)]; - - vc = (hdrf2 >= h/2); - -[ac,b2c] = min(diff([vc;0])); -if b2c < length(vc) -vc(b2c+1:end) = 0; -end -[ac,b1c] = max(vc); - - -wc = sum(vc)+1; - -param(7) = wc; - -return; diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/entries b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/entries deleted file mode 100644 index 5792c9a8..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/entries +++ /dev/null @@ -1,776 +0,0 @@ -10 - -dir -1964 -svn+ssh://svnclient@canlab.colorado.edu/Volumes/RAID1/resources/svn/Repository/trunk/SCN_Core_Support/HRF_Est_Toolbox2/Old_stuff/More_recent_old_stuff -svn+ssh://svnclient@canlab.colorado.edu/Volumes/RAID1/resources/svn/Repository - - - -2014-04-15T17:12:28.291045Z -1964 -svnclient - - - - - - - - - - - - - - -a7b2ed3e-3a17-0410-bf9f-8ac42634c47e - -get_parameters2.m -file - - - - -2014-04-15T17:46:31.000000Z -30898be8aace5f112610dcb69d473f03 -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -701 - -timecourse_2.mat -file - - - - -2014-04-15T17:46:31.000000Z -ae0888dbc01fd59faec7ceee2792b31c -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -5148 - -Fit_Logit_allstim_2.m -file - - - - -2014-04-15T17:46:31.000000Z -535e814f2cca955f58f53129288b11f6 -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -1651 - -ilogit.m -file - - - - -2014-04-15T17:46:31.000000Z -f910c1943552d5840cae9bded7e35333 -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -255 - -Fit_Canonical_HRF.m -file - - - - -2014-04-15T17:46:31.000000Z -c91735f1318963538f062e23e6374c43 -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -2574 - -Inverse_Logit_Tool_Instructions.rtf -file - - - - -2014-04-15T17:46:31.000000Z -c979fd5e15a74404be2beaadb64e9e38 -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -2705 - -Example_old.m -file - - - - -2014-04-15T17:46:31.000000Z -e016489b73eb16b16828b2d360efb8c3 -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -5827 - -Example.m -file - - - - -2014-04-15T17:46:31.000000Z -364734c274612bbf1ddbba69e3eb2800 -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -4986 - -Anneal_Logit.m -file - - - - -2014-04-15T17:46:31.000000Z -0493f75db617dc9bccba89a9fb2cc77f -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -3467 - -PowerLoss.m -file - - - - -2014-04-15T17:46:31.000000Z -6a58403070ec34abc876c68adc64ab2b -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -993 - -Get_Logit_2.m -file - - - - -2014-04-15T17:46:31.000000Z -672489196cefeef0c59f43d105cb2137 -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -966 - -timecourse_old.mat -file - - - - -2014-04-15T17:46:31.000000Z -bbf93919d4af9e5df9d479f62cb0215d -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -5148 - -timecourse.mat -file - - - - -2014-04-15T17:46:31.000000Z -bbf93919d4af9e5df9d479f62cb0215d -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -5148 - -Fit_sFIR.m -file - - - - -2014-04-15T17:46:31.000000Z -cc1d253aaeb49006c9e03235c276d894 -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -1527 - -Fit_Logit_allstim.m -file - - - - -2014-04-15T17:46:31.000000Z -20ac9166ccdce685e9a752b2351ab3ee -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -1422 - -PowerSim.m -file - - - - -2014-04-15T17:46:31.000000Z -8bd07de8a07c2add0b07984796cbadbf -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -728 - -Det_Logit.m -file - - - - -2014-04-15T17:46:31.000000Z -d1393c4975bb71ea288758b2bdb741e5 -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -3305 - -ResidScan.m -file - - - - -2014-04-15T17:46:31.000000Z -f12ab5ebd231907ff9cd993b9f1956ed -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -2636 - -Get_Logit.m -file - - - - -2014-04-15T17:46:31.000000Z -6099f20ad670b32fd5bf02eafaa07d0b -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -747 - -Example2.m -file - - - - -2014-04-15T17:46:31.000000Z -062504031cba873834f29fb99d32b70f -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -5244 - -Fit_Logit.m -file - - - - -2014-04-15T17:46:31.000000Z -2f8e7d156978032204bd794012beec22 -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -1544 - -ScanSim4.m -file - - - - -2014-04-15T17:46:31.000000Z -5e659b92fe6e29a956e74a99a9d2955d -2014-04-15T17:12:28.291045Z -1964 -svnclient -has-props - - - - - - - - - - - - - - - - - - - - -1192 - diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Anneal_Logit.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Anneal_Logit.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Anneal_Logit.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Det_Logit.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Det_Logit.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Det_Logit.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Example.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Example.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Example.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Example2.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Example2.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Example2.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Example_old.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Example_old.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Example_old.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Fit_Canonical_HRF.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Fit_Canonical_HRF.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Fit_Canonical_HRF.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Fit_Logit.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Fit_Logit.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Fit_Logit.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Fit_Logit_allstim.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Fit_Logit_allstim.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Fit_Logit_allstim.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Fit_Logit_allstim_2.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Fit_Logit_allstim_2.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Fit_Logit_allstim_2.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Fit_sFIR.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Fit_sFIR.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Fit_sFIR.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Get_Logit.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Get_Logit.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Get_Logit.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Get_Logit_2.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Get_Logit_2.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Get_Logit_2.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Inverse_Logit_Tool_Instructions.rtf.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Inverse_Logit_Tool_Instructions.rtf.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/Inverse_Logit_Tool_Instructions.rtf.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/PowerLoss.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/PowerLoss.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/PowerLoss.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/PowerSim.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/PowerSim.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/PowerSim.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/ResidScan.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/ResidScan.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/ResidScan.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/ScanSim4.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/ScanSim4.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/ScanSim4.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/get_parameters2.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/get_parameters2.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/get_parameters2.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/ilogit.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/ilogit.m.svn-base deleted file mode 100644 index 869ac71c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/ilogit.m.svn-base +++ /dev/null @@ -1,5 +0,0 @@ -K 14 -svn:executable -V 1 -* -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/timecourse.mat.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/timecourse.mat.svn-base deleted file mode 100644 index dbc918b0..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/timecourse.mat.svn-base +++ /dev/null @@ -1,9 +0,0 @@ -K 14 -svn:executable -V 1 -* -K 13 -svn:mime-type -V 24 -application/octet-stream -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/timecourse_2.mat.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/timecourse_2.mat.svn-base deleted file mode 100644 index dbc918b0..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/timecourse_2.mat.svn-base +++ /dev/null @@ -1,9 +0,0 @@ -K 14 -svn:executable -V 1 -* -K 13 -svn:mime-type -V 24 -application/octet-stream -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/timecourse_old.mat.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/timecourse_old.mat.svn-base deleted file mode 100644 index dbc918b0..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/prop-base/timecourse_old.mat.svn-base +++ /dev/null @@ -1,9 +0,0 @@ -K 14 -svn:executable -V 1 -* -K 13 -svn:mime-type -V 24 -application/octet-stream -END diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Anneal_Logit.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Anneal_Logit.m.svn-base deleted file mode 100644 index f45f2316..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Anneal_Logit.m.svn-base +++ /dev/null @@ -1,126 +0,0 @@ -function [theta,HH,C,P,hrf,fit,e,param] = Anneal_Logit(theta0,t,tc,Run) -% -% [theta,HH,C,P] = Anneal_Logit(theta0,t,tc,Run) -% -% Estimate inverse logit (IL) HRF model using Simulated Annealing -% Creates fitted curve - 3 logistic functions to be summed together - from parameter estimates -% -% INPUT: theta0, t, tc, Run -% Run = stick function -% tc = time course -% t = vector of time points -% theta0 = initial value for the parameter vector -% -% By Martin Lindquist and Tor Wager -% Edited 12/12/06 -% - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% Initial values - -iter = 15000; % Number of iterations -theta = theta0; % Set initial value for the parameter vector -h0 = cost(theta0,t,tc,Run); % Calculate cost of initial estimate -LB = [0.05, 1, 0, 0.05, 5, 0, 10]; % Lower bounds for parameters -UB = [10, 15, 5, 10, 15, 5, 30]; % Upper bounds for parameters - -% -% These values may need tweaking depending on the individual situation. -% - -r1= 0.001; % A parameters -r1b= 0.001; % A parameters -r2 = 0.05; % T parameters -r3 = 0.001; % delta parameters - -t1 = [1 4]; -t1b = [6]; -t2 = [2 5 7]; -t3 = [3]; - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -u = zeros(1,7); - -HH = zeros(1+iter,7); % Keep track of theta_i -HH(1,:) = theta0; -P = zeros(1+iter,1); -C = zeros(1+iter,1); % Keep track of the cost function -C(1) = h0; - -cnt = 0; -for i=1:iter, - - T = 100/log(1+i); %Temperature function (may require tweaking) - th = zeros(1,7); - ind = 0; - - % Choose a new candidate solution theta_{i+1}, based on a random perturbation of the current solution of theta_{i}. - % Check new parameters are within accepted bounds - while ( (sum((LB-th)>0) + sum((th-UB)>0)) > 0), - - % Perturb solution - - u(t1) = normrnd(0,r1,1,2); - u(t1b) = normrnd(0,r1b,1,1); - u(t2) = normrnd(0,r2,1,3); - u(t3) = normrnd(0,r3,1,1); - - % Update solution - th = theta + u; - ind = ind + 1; - - if(ind > 500), - warning('stuck!'); - return; - end; - end; - - h = cost(th,t,tc,Run); - C(i+1) = h; - delta = h - h0; - - % Determine whether to update the parameter vector. - if (unifrnd(0,1) < min(exp(-delta/T),1)), - theta = th; - h0=h; - cnt = cnt+1; - end; - - HH(i+1,:) = theta; - P(i+1) = min(exp(-delta/T),1); - -end; - -%cnt/iter - -[a,b] = min(C); -theta = HH(b,:); -%h - - -% Additional outputs -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Get HRF for final model -if nargout > 4 - hrf = Get_Logit(theta(1:7),t); % Calculate HRF estimate (fit, given theta) -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Convolve HRF and stick function -if nargout > 5 - len = length(Run); - fit = conv(Run, hrf); - fit = fit(1:len); - e = tc - fit; -end - -if nargout > 7 - [param] = get_parameters_logit(hrf,t,theta); -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - - -return diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Det_Logit.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Det_Logit.m.svn-base deleted file mode 100644 index 83cfe423..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Det_Logit.m.svn-base +++ /dev/null @@ -1,116 +0,0 @@ -function [VM, hrf, fit, e, param] = Det_Logit(V0,t,tc,Run) -% -% [VM, h, fit, e, param] = Det_Logit_allstim(V0,t,tc,Run) -% -% Estimate inverse logit (IL) HRF model -% Creates fitted curve - 3 logistic functions to be summed together - from parameter estimates -% -% INPUT: V0, t, tc, Run -% Run = stick function -% tc = time course -% t = vector of time points -% V0 = initial value for the parameter vector -% -% By Martin Lindquist, Christian Waugh and Tor Wager -% Created by Martin Lindquist on 10/02/09 -% Last edited: 05/26/10 (ML) - - -numstim = length(Run); -len = length(Run{1}); - -% LB = [0.05, 1, 0, 0.05, 4, 0, 10]; % Lower bounds for parameters -% UB = [10, 15, 10, 10, 15, 5, 50]; % Upper bounds for parameters -% LB = repmat(LB, 1, numstim); -% UB = repmat(UB, 1, numstim); - -% Remove intercept - -b0 = pinv(ones(length(tc),1))*tc; -tc = tc - b0; - -% Find optimal values - -options = optimset('MaxFunEvals',10000000,'Maxiter',10000000,'TolX',1e-8,'TolFun',1e-8,'Display','off'); - -%VM = fminsearchbnd(@cost_allstim, V0, LB,UB,options,t,tc,Run); -VM = fminsearch(@msq_logit,V0,options,Run,t,tc); - -% Use optimal values to fit hemodynamic response functions -hrf =zeros(length(t),numstim); -fitt = zeros(len,numstim); -param = zeros(3,numstim); - -for g = 1:numstim - hrf(:,g) = il_hdmf_tw2(t,VM(((g-1)*7+1):(g*7))); % Calculate HRF estimate (fit, given theta) - param(:,g) = get_parameters2(hrf(:,g),t(end)); - fits(:,g) = conv(Run{g}, hrf(:,g)); - fitt(:,g) = fits(1:len,g); -end - -fit = sum(fitt,2); -e = tc-fit; -fit = fit + b0; - -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% SUBFUNCTIONS -% -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -function m=msq_logit(V,Run, t, tc) - -numstim = length(Run); -len = length(Run{1}); -h = zeros(length(t),numstim); -yhatt =zeros(len,numstim); - -for k = 1:numstim - h(:,k) = il_hdmf_tw2(t,V(((k-1)*7+1):(k*7))); % Get IL model corresponding to parameters V - yhat(:,k) = conv(Run{k}, h(:,k)); % Convolve IL model with stick function - yhatt(:,k) = yhat(1:len,k); -end - -yhat2 = sum(yhatt,2); %Sum models together to get overall estimate - -m = sum((tc-yhat2).^2); % Calculate cost function - -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -function [h,base] = il_hdmf_tw2(t,V) -% inverse logit -- creates fitted curve from parameter estimates -% -% t = vector of time points -% V = parameters - -% 3 logistic functions to be summed together -base = zeros(length(t),3); -A1 = V(1); -T1 = V(2); -d1 = V(3); -A2 = V(4); -T2 = V(5); -A3 = V(6); -T3 = V(7); -d2 = -d1*(ilogit(A1*(1-T1)) - ilogit(A3*(1-T3)))/(ilogit(A2*(1-T2)) + ilogit(A3*(1-T3))); -d3 = abs(d2)-abs(d1); - -base(:,1)= d1*ilogit(A1*(t-T1))'; -base(:,2)= d2*ilogit(A2*(t-T2))'; -base(:,3)= d3*ilogit(A3*(t-T3))'; -h = sum(base,2)'; - -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -function [L] = ilogit(t) -L = exp(t)./(1+exp(t)); -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Example.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Example.m.svn-base deleted file mode 100644 index 03497e65..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Example.m.svn-base +++ /dev/null @@ -1,176 +0,0 @@ -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% Example code for estimating the HRF using the Inverse-Logit Model, a -% Finte Impluse Response Model and the Canonical HRF with 2 derivatives. -% Also the code illustrates our code for detecting model misspecification. -% -% By Martin Lindquist and Tor Wager -% Created: 10/02/09 -% Last edited: 05/26/10 -% -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% Load time course -% - -mypath = which('ilogit'); -if isempty(mypath), error('Cannot find directory with ilogit.m and other functions. Not on path?'); end -[mydir] = fileparts(mypath) - -load(fullfile(mydir,'timecourse')) - -tc = (tc- mean(tc))/std(tc); -len = length(tc); -figure; subplot(3,1,1); han = plot(tc); -title('Sample time course'); drawnow - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% Settings -% - -TR = 0.5; -T = round(30/TR); -t = 1:T; % samples at which to get Logit HRF Estimate -FWHM = 4; % FWHM for residual scan -pval = 0.01; -df = 600; -alpha = 0.001; - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% Create stick function (sample event onsets) -% Variable R contains onset times -% Variable Run contains stick (a.k.a. delta or indicator) function - -R = [3, 21, 56, 65, 109, 126, 163, 171, 216, 232, 269, 282, 323, 341, 376, 385, 429, 446, 483, 491, 536, 552, 589, 602]; -Runs = zeros(640,1); -for i=1:length(R), Runs(R(i)) = 1; end; - -Run = []; -Run{1}=Runs; - - -try - hold on; - hh = plot_onsets(R,'k',-3,1); - drawnow -catch - disp('Couldn''t find function to add onset sticks to plot. Skipping.') -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Fit HRF using IL-function - -% Choose mode (deterministic/stochastic) - -mode = 0; % 0 - deterministic aproach - % 1 - simulated annealing approach - % Please note that when using simulated annealing approach you - % may need to perform some tuning before use. - - -[h1, fit1, e1, param] = Fit_Logit(tc,Run,t,mode); -[pv sres sres_ns1] = ResidScan(e1, FWHM); -[PowLoss1] = PowerLoss(e1, fit1, (len-7) , tc, TR, Run, alpha); - -hold on; han(2) = plot(fit1,'r'); - -disp('Summary: IL_function'); - -disp('Amplitude:'); disp(param(1)); -disp('Time-to-peak:'); disp(param(2)); -disp('Width:'); disp(param(3)); - -disp('MSE:'); disp((1/(len-1)*sum(e1.^2))); -disp('Mis-modeling:'); disp(pv); -disp('Power Loss:'); disp(PowLoss1); - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Fit HRF using FIR-model - -% Choose mode (FIR/sFIR) - -mode = 1; % 0 - FIR - % 1 - smooth FIR - -[h2, fit2, e2, param] = Fit_sFIR(tc,TR,Run,T,mode); -[pv sres sres_ns2] = ResidScan(e2, FWHM); -[PowLoss2] = PowerLoss(e2, fit2, (len-T) , tc, TR, Run, alpha); - -hold on; han(3) = plot(fit2,'g'); - -disp('Summary: FIR'); - -disp('Amplitude'); disp(param(1)); -disp('Time-to-peak'); disp(param(2)); -disp('Width'); disp(param(3)); - -disp('MSE:'); disp((1/(len-1)*sum(e2.^2))); -disp('Mis-modeling'); disp(pv); -disp('Power Loss:'); disp(PowLoss2); - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Fit HRF using Canonical HRF + 2 derivatives - -p=1; - -[h3, fit3, e3, param, info] = Fit_Canonical_HRF(tc,TR,Run,T,p); -[pv sres sres_ns3] = ResidScan(e3, FWHM); -[PowLoss3] = PowerLoss(e3, fit3, (len-p) , tc, TR, Run, alpha); - -hold on; han(4) = plot(fit3,'m'); - -legend(han,{'Data' 'IL' 'sFIR' 'DD'}) - - -disp('Summary: Canonical + 2 derivatives'); - -disp('Amplitude'); disp(param(1)); -disp('Time-to-peak'); disp(param(2)); -disp('Width'); disp(param(3)); - -disp('MSE:'); disp((1/(len-1)*sum(e3.^2))); -disp('Mis-modeling'); disp(pv); -disp('Power Loss:'); disp(PowLoss3); - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -%figure; - - -subplot(3,2,5); -han2 = plot(h1,'r'); -hold on; han2(2) = plot(h2,'g'); -hold on; han2(3) = plot(h3,'m'); -legend(han2,{'IL' 'sFIR' 'DD'}) -title('Estimated HRF'); - - -subplot(3,1,2); hold on; -hh = plot_onsets(R,'k',-3,1); -drawnow - -han3 = plot(sres_ns1,'r'); -hold on; han3(2) = plot(sres_ns2,'g'); -hold on; han3(3) = plot(sres_ns3,'m'); -hold on; plot((1:len),zeros(len,1),'--k'); -legend(han3,{'IL' 'sFIR' 'DD'}) -title('Mis-modeling (time course)'); - - -subplot(3,2,6); hold on; - -[s1] = Fit_sFIR(sres_ns1,TR,Run,T,0); -[s2] = Fit_sFIR(sres_ns2,TR,Run,T,0); -[s3] = Fit_sFIR(sres_ns3,TR,Run,T,0); - -han4 = plot(s1(1:T),'r'); -hold on; han4(2) = plot(s2(1:T),'g'); -hold on; han4(3) = plot(s3(1:T),'m'); -hold on; plot((1:T),zeros(T,1),'--k'); -legend(han4,{'IL' 'sFIR' 'DD'}) -title('Mis-modeling (HRF)'); diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Example2.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Example2.m.svn-base deleted file mode 100644 index 26ac6c06..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Example2.m.svn-base +++ /dev/null @@ -1,196 +0,0 @@ -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% Example code for estimating the HRF using the Inverse-Logit Model, a -% Finte Impluse Response Model and the Canonical HRF with 2 derivatives. -% Also the code illustrates our code for detecting model misspecification. -% -% This example illustrates the multi-stimulus version -% -% By Martin Lindquist -% Created: 05/26/10 -% -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% Load time course -% - -mypath = which('ilogit'); -if isempty(mypath), error('Cannot find directory with ilogit.m and other functions. Not on path?'); end -[mydir] = fileparts(mypath) - - -h = spm_hrf(1); -h = h./max(h); - -RunA = zeros(60,10); -RunA(1,:) = 1; -RunA = reshape(RunA,600,1); -RunB = zeros(60,10); -RunB(31,:) = 1; -RunB = reshape(RunB,600,1); - -tc = 2*conv(RunA,h) + conv(RunB,h); -tc = tc(1:600); -tc = tc+normrnd(0,1,600,1); - -Run =[]; -Run{1} = RunA; -Run{2} = RunB; - -len = length(tc); - -figure; subplot(3,1,1); han = plot(tc); -title('Sample time course'); drawnow - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% Settings -% - -TR = 1; -T = round(30/TR); -t = 1:T; % samples at which to get Logit HRF Estimate -FWHM = 4; % FWHM for residual scan -pval = 0.01; -df = 600; -alpha = 0.001; - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% Create stick function (sample event onsets) -% Variable R contains onset times -% Variable Run contains stick (a.k.a. delta or indicator) function - -RA = [1 61 121 181 241 301 361 421 481 541]; -RB = [31 91 151 211 271 331 391 451 511 571]; - - -try - hold on; - hh = plot_onsets(RA,'k',-3,1); - hh = plot_onsets(RB,'r',-3,0.5); - drawnow -catch - disp('Couldn''t find function to add onset sticks to plot. Skipping.') -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Fit HRF using IL-function - -% Choose mode (deterministic/stochastic) - -mode = 0; % 0 - deterministic aproach - % 1 - simulated annealing approach - % Please note that when using simulated annealing approach you - % may need to perform some tuning before use. - - - - -[h1, fit1, e1, param] = Fit_Logit(tc,Run,t,mode); -[pv sres sres_ns1] = ResidScan(e1, FWHM); -[PowLoss1] = PowerLoss(e1, fit1, (len-7) , tc, TR, Run, alpha); - -hold on; han(2) = plot(fit1,'r'); - -disp('Summary: IL_function'); - -disp('HRF - Event A'); -disp('Amplitude:'); disp(param(1,1)); -disp('Time-to-peak:'); disp(param(2,1)); -disp('Width:'); disp(param(3,1)); - -disp('HRF - Event B'); -disp('Amplitude:'); disp(param(1,2)); -disp('Time-to-peak:'); disp(param(2,2)); -disp('Width:'); disp(param(3,2)); - - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Fit HRF using FIR-model - -% Choose mode (FIR/sFIR) - -mode = 1; % 0 - FIR - % 1 - smooth FIR - -[h2, fit2, e2, param] = Fit_sFIR(tc,TR,Run,T,mode); -[pv sres sres_ns2] = ResidScan(e2, FWHM); -[PowLoss2] = PowerLoss(e2, fit2, (len-T) , tc, TR, Run, alpha); - -hold on; han(3) = plot(fit2,'g'); - -disp('Summary: FIR'); - -disp('HRF - Event A'); -disp('Amplitude:'); disp(param(1,1)); -disp('Time-to-peak:'); disp(param(2,1)); -disp('Width:'); disp(param(3,1)); - -disp('HRF - Event B'); -disp('Amplitude:'); disp(param(1,2)); -disp('Time-to-peak:'); disp(param(2,2)); -disp('Width:'); disp(param(3,2)); - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Fit HRF using Canonical HRF + 2 derivatives - -p=1; - -[h3, fit3, e3, param, info] = Fit_Canonical_HRF(tc,TR,Run,T,p); -[pv sres sres_ns3] = ResidScan(e3, FWHM); -[PowLoss3] = PowerLoss(e3, fit3, (len-p) , tc, TR, Run, alpha); - -hold on; han(4) = plot(fit3,'m'); - -legend(han,{'Data' 'IL' 'sFIR' 'DD'}) - - -disp('Summary: Canonical + 2 derivatives'); - -disp('HRF - Event A'); -disp('Amplitude:'); disp(param(1,1)); -disp('Time-to-peak:'); disp(param(2,1)); -disp('Width:'); disp(param(3,1)); - -disp('HRF - Event B'); -disp('Amplitude:'); disp(param(1,2)); -disp('Time-to-peak:'); disp(param(2,2)); -disp('Width:'); disp(param(3,2)); - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -%figure; - - -subplot(3,2,5); -han2 = plot(h1(:,1),'r'); -hold on; han2(2) = plot(h2(:,1),'g'); -hold on; han2(3) = plot(h3(:,1),'m'); -legend(han2,{'IL' 'sFIR' 'DD'}) -title('Estimated HRF - Event A'); - - -subplot(3,1,2); hold on; -hh = plot_onsets(RA,'k',-3,1); -hh = plot_onsets(RB,'r',-3,0.5); -drawnow - -han3 = plot(sres_ns1,'r'); -hold on; han3(2) = plot(sres_ns2,'g'); -hold on; han3(3) = plot(sres_ns3,'m'); -hold on; plot((1:len),zeros(len,1),'--k'); -legend(han3,{'IL' 'sFIR' 'DD'}) -title('Mis-modeling (time course)'); - - -subplot(3,2,6); hold on; - -han4 = plot(h2(:,1),'r'); -hold on; han4(2) = plot(h2(:,2),'g'); -hold on; han4(3) = plot(h3(:,2),'m'); -legend(han4,{'IL' 'sFIR' 'DD'}) -title('Estimated HRF - Event A'); diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Example_old.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Example_old.m.svn-base deleted file mode 100644 index 1ed0a257..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Example_old.m.svn-base +++ /dev/null @@ -1,207 +0,0 @@ -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% Example code for estimating the HRF using the Inverse-Logit Model, a -% Finte Impluse Response Model and the Canonical HRF with 2 derivatives. -% Also the code illustrates our code for detecting model misspecification. -% -% By Martin Lindquist and Tor Wager -% Edited 05/17/13 -% -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% Load time course -% - -mypath = which('ilogit'); -if isempty(mypath), error('Cannot find directory with ilogit.m and other functions. Not on path?'); end -[mydir] = fileparts(mypath) - -load(fullfile(mydir,'timecourse')) - -tc = (tc- mean(tc))/std(tc); -len = length(tc); - - -%% Or: create your own -[xBF] = spm_get_bf(struct('dt', .5, 'name', 'hrf (with time and dispersion derivatives)', 'length', 32)); -clear Xtrue -for i = 1:1, xx = conv(xBF.bf(:,i), [1 1 1 1 1 1 ]'); - Xtrue(:, i) = xx(1:66); -end -for i = 2:3, xx = conv(xBF.bf(:,i), [1 1]'); - Xtrue(:, i) = xx(1:66); -end -hrf = Xtrue * [1 .3 .2]'; -xsecs = 0:.5:32; -hrf = hrf(1:length(xsecs)); -hrf = hrf ./ max(hrf); -figure; plot(xsecs, hrf, 'k') -%hrf = hrf(1:4:end); % downsample to TR, if TR is > 0.5 - - -R = randperm(640); R = sort(R(1:36)); -Run = zeros(640,1); -for i=1:length(R), Run(R(i)) = 1; end; -true_sig = conv(Run, hrf); -true_sig = true_sig(1:640); - -tc_noise = noise_arp(640, [.7 .2]); -tc = true_sig + 0 * tc_noise; -%figure; plot(tc); - - -%% - -create_figure; subplot(3,1,1); han = plot(tc); -title('Sample time course'); drawnow - - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% Settings -% - -TR = 0.5; -% T = round(30/TR); -% t = 1:T; % samples at which to get Logit HRF Estimate -T = 30; -FWHM = 4; % FWHM for residual scan -pval = 0.01; -df = 600; -alpha = 0.001; - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% Create stick function (sample event onsets) -% Variable R contains onset times -% Variable Run contains stick (a.k.a. delta or indicator) function - -R = [3, 21, 56, 65, 109, 126, 163, 171, 216, 232, 269, 282, 323, 341, 376, 385, 429, 446, 483, 491, 536, 552, 589, 602]; -Run = zeros(640,1); -for i=1:length(R), Run(R(i)) = 1; end; -Runc = {}; -Runc{1} = Run; - -try - hold on; - hh = plot_onsets(R,'k',-3,1); - drawnow -catch - disp('Couldn''t find function to add onset sticks to plot. Skipping.') -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Fit HRF using IL-function - -% Choose mode (deterministic/stochastic) - -mode = 1; % 0 - deterministic aproach - % 1 - simulated annealing approach - % Please note that when using simulated annealing approach you - % may need to perform some tuning before use. - -%[h1, fit1, e1, param] = Fit_Logit_allstim(tc,Run,t,mode); -V0 = [ 1 6 1 0.5 10 1 15]; -[VM, h1, fit1, e1, param] = Fit_Logit_allstim(V0,tc,TR, Runc, T); -[pv sres sres_ns1] = ResidScan(e1, FWHM); -[PowLoss1] = PowerLoss(e1, fit1, (len-7) , tc, TR, Runc, alpha); - -hold on; han(2) = plot(fit1,'r'); - -disp('Summary: IL_function'); - -disp('Amplitude:'); disp(param(1)); -disp('Time-to-peak:'); disp(param(2)); -disp('Width:'); disp(param(3)); - -disp('MSE:'); disp((1/(len-1)*sum(e1.^2))); -disp('Mis-modeling:'); disp(pv); -disp('Power Loss:'); disp(PowLoss1); - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Fit HRF using FIR-model - -% Choose mode (FIR/sFIR) - -mode = 1; % 0 - FIR - % 1 - smooth FIR - -[h2, fit2, e2, param] = Fit_sFIR(tc,TR,Runc,T,mode); -[pv sres sres_ns2] = ResidScan(e2, FWHM); -[PowLoss2] = PowerLoss(e2, fit2, (len-T) , tc, TR, Runc, alpha); - -hold on; han(3) = plot(fit2,'g'); - -disp('Summary: FIR'); - -disp('Amplitude'); disp(param(1)); -disp('Time-to-peak'); disp(param(2)); -disp('Width'); disp(param(3)); - -disp('MSE:'); disp((1/(len-1)*sum(e2.^2))); -disp('Mis-modeling'); disp(pv); -disp('Power Loss:'); disp(PowLoss2); - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Fit HRF using Canonical HRF + 2 derivatives - -p=1; - -[h3, fit3, e3, param, info] = Fit_Canonical_HRF(tc,TR,Runc,T,p); -[pv sres sres_ns3] = ResidScan(e3, FWHM); -[PowLoss3] = PowerLoss(e3, fit3, (len-p) , tc, TR, Runc, alpha); - -hold on; han(4) = plot(fit3,'m'); - -legend(han,{'Data' 'IL' 'sFIR' 'DD'}) - - -disp('Summary: Canonical + 2 derivatives'); - -disp('Amplitude'); disp(param(1)); -disp('Time-to-peak'); disp(param(2)); -disp('Width'); disp(param(3)); - -disp('MSE:'); disp((1/(len-1)*sum(e3.^2))); -disp('Mis-modeling'); disp(pv); -disp('Power Loss:'); disp(PowLoss3); - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -%figure; - - -subplot(3,2,5); -han2 = plot(h1,'r'); -hold on; han2(2) = plot(h2,'g'); -hold on; han2(3) = plot(h3,'m'); -legend(han2,{'IL' 'sFIR' 'DD'}) -title('Estimated HRF'); - - -subplot(3,1,2); hold on; -hh = plot_onsets(R,'k',-3,1); -drawnow - -han3 = plot(sres_ns1,'r'); -hold on; han3(2) = plot(sres_ns2,'g'); -hold on; han3(3) = plot(sres_ns3,'m'); -hold on; plot((1:len),zeros(len,1),'--k'); -legend(han3,{'IL' 'sFIR' 'DD'}) -title('Mis-modeling (time course)'); - - -subplot(3,2,6); hold on; - -[s1] = Fit_sFIR(sres_ns1,TR,Runc,T,0); -[s2] = Fit_sFIR(sres_ns2,TR,Runc,T,0); -[s3] = Fit_sFIR(sres_ns3,TR,Runc,T,0); - -han4 = plot(s1(1:T),'r'); -hold on; han4(2) = plot(s2(1:T),'g'); -hold on; han4(3) = plot(s3(1:T),'m'); -hold on; plot((1:T),zeros(T,1),'--k'); -legend(han4,{'IL' 'sFIR' 'DD'}) -title('Mis-modeling (HRF)'); diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Fit_Canonical_HRF.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Fit_Canonical_HRF.m.svn-base deleted file mode 100644 index 078b3951..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Fit_Canonical_HRF.m.svn-base +++ /dev/null @@ -1,119 +0,0 @@ -function [hrf, fit, e, param, info] = Fit_Canonical_HRF(tc,TR,Run,T,p) -% function [hrf, fit, e, param, info] = Fit_Canonical_HRF(tc,TR,Runs,T,p) -% -% Fits GLM using canonical hrf (with option of using time and dispersion derivatives)'; -% -% INPUTS: -% -% tc - time course -% TR - time resolution -% Runs - expermental design -% T - length of estimated HRF -% p - Model type -% -% Options: p=0 - only canonical HRF -% p=1 - canonical + temporal derivative -% p=2 - canonical + time and dispersion derivative -% -% OUTPUTS: -% -% hrf - estimated hemodynamic response function -% fit - estimated time course -% e - residual time course -% param - estimated amplitude, height and width -% info - struct containing design matrices, beta values etc -% -% Created by Martin Lindquist on 10/02/09 -% Last edited: 05/26/10 (ML) - -d = length(Run); -len = length(Run{1}); - -X = zeros(len,p*d); -param = zeros(3,d); - -[h, dh, dh2] = CanonicalBasisSet(TR); - -for i=1:d, - v = conv(Run{i},h); - X(:,(i-1)*p+1) = v(1:len); - - if (p>1) - v = conv(Run{i},dh); - X(:,(i-1)*p+2) = v(1:len); - end - - if (p>2) - v = conv(Run{i},dh2); - X(:,(i-1)*p+3) = v(1:len); - end -end - -X = [(zeros(len,1)+1) X]; - -b = pinv(X)*tc; -e = tc-X*b; -fit = X*b; - -b = reshape(b(2:end),p,d)'; -bc = zeros(d,1); - -for i=1:d, - if (p == 1) - bc(i) = b(i,1); - H = h; - elseif (p==2) - bc(i) = sign(b(i,1))*sqrt((b(i,1))^2 + (b(i,2))^2); - H = [h dh]; - elseif (p>2) - bc(i) = sign(b(i,1))*sqrt((b(i,1))^2 + (b(i,2))^2 + (b(i,3))^2); - H = [h dh dh2]; - end - -end - -hrf = H*b'; - -for i=1:d, - param(:,i) = get_parameters2(hrf(:,i),T); -end; - -info ={}; -info.b = b; -info.bc = bc; -info.X = X; -info.H =H; - -end - -% END MAIN FUNCTION -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% Subfunctions -% -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -function [h, dh, dh2] = CanonicalBasisSet(TR) - -len = round(30/TR); -xBF.dt = TR; -xBF.length= len; -xBF.name = 'hrf (with time and dispersion derivatives)'; -xBF = spm_get_bf(xBF); - -v1 = xBF.bf(1:len,1); -v2 = xBF.bf(1:len,2); -v3 = xBF.bf(1:len,3); - -h = v1; -dh = v2 - (v2'*v1/norm(v1)^2).*v1; -dh2 = v3 - (v3'*v1/norm(v1)^2).*v1 - (v3'*dh/norm(dh)^2).*dh; - -h = h./max(h); -dh = dh./max(dh); -dh2 = dh2./max(dh2); - -end \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Fit_Logit.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Fit_Logit.m.svn-base deleted file mode 100644 index 898cc6ba..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Fit_Logit.m.svn-base +++ /dev/null @@ -1,57 +0,0 @@ -function [hrf, fit, e, param] = Fit_Logit(tc,Run,t,mode) -% function [hrf, fit, e, param] = Fit_Logit(tc,Run,t,mode) -% -% Fits FIR and smooth FIR model -% -% INPUTS: -% -% tc - time course -% TR - time resolution -% Runs - expermental design -% T - length of estimated HRF -% mode - deterministic or stochastic -% options: -% 0 - deterministic aproach -% 1 - simulated annealing approach -% Please note that when using simulated annealing approach you -% may need to perform some tuning before use. -% -% OUTPUTS: -% -% hrf - estimated hemodynamic response function -% fit - estimated time course -% e - residual time course -% param - estimated amplitude, height and width -% -% Created by Martin Lindquist on 10/02/09 -% Last edited: 05/26/10 (ML) - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Fit the Logit model - -numstim = length(Run); -len = length(Run{1}); - -V0 = [ 1 6 1 0.5 10 1 15]; % initial values for logit fit -V0 = repmat(V0,1,numstim); - -if (mode == 1 && numstim>1), - disp('Multi-stimulus annealing function currently not implemented. Switching to "deterministic mode"') - mode = 0; -end; - -% Estimate theta (logit parameters) - -if (mode == 1) - disp('Stochastic Mode'); - - Runs = Run{1}; - [theta,HH,C,P,hrf,fit,e,param] = Anneal_Logit(V0,t,tc,Runs); - -elseif (mode == 0) - disp('Deterministic Mode'); - [theta, hrf, fit, e, param] = Det_Logit(V0,t,tc,Run); - -end - -end diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Fit_Logit_allstim.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Fit_Logit_allstim.m.svn-base deleted file mode 100644 index bb697d63..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Fit_Logit_allstim.m.svn-base +++ /dev/null @@ -1 +0,0 @@ -function [VM, hrf, fit, e, param] = Fit_Logit_allstim(V0,tc,TR, Run, T) % Fit inverse logit function model to time course % Multi-stimulus case % % INPUT: % % V0 = initial parameters % tc = time course % TR = time resolution % Run = stick function (one trial type per cell) % % % OUTPUT: % % VM = estimated parameters % hrf = estimated HRF % C = SSE % fit - fitted time course % param - estimated height, time to peak and width for each HRF numstim = length(Run); len = length(Run{1}); t=1:TR:T; % Initial values LB = [0.05, 1, 0, 0.05, 5, 0.05, 8]; % Previous Lower bounds for parameters UB = [5, 10, 2.5, 2, 15, 2, 20]; LB = repmat(LB, 1, numstim); UB = repmat(UB, 1, numstim); V0 = repmat(V0, 1, numstim); % Find optimal values options = optimset('MaxFunEvals',10000,'Maxiter',10000,'TolX', 1e-6, 'TolFun', 1e-6,'Display','off'); VM = fminsearchbnd(@cost_allstim, V0, LB,UB,options,t,tc,Run); % Use optimal values to fit hemodynamic response functions hrf =zeros(length(t),numstim); fitt = zeros(len,numstim); param = zeros(3,numstim); for g = 1:numstim hrf(:,g) = Get_Logit(VM(g*7-6:g*7),t); % Calculate HRF estimate (fit, given theta) param(:,g) = get_parameters2(hrf(:,g),(1:length(t))); end % Get fitted time courses and residuals for g = 1:numstim fits(:,g) = conv(Run{g}, hrf(:,g)); fitt(:,g) = fits(1:len,g); end fit = sum(fitt,2); e = tc-fit; return; \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Fit_Logit_allstim_2.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Fit_Logit_allstim_2.m.svn-base deleted file mode 100644 index 95139b90..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Fit_Logit_allstim_2.m.svn-base +++ /dev/null @@ -1 +0,0 @@ -function [VM, hrf, fit] = Fit_Logit_allstim_2(V0,tc,tr, Run, down) % Fit inverse logit function model to time course % Multi-condition case % % Last edited by ML on 02/12/13 % % INPUTS: % % V0 - Initial values for IL-model % tc - time course data % tr - TR % Run - a cell array containing stick functions (one for each condition) % down - downsampling factor % % OUTPUTS: % % VM - final parameter values for IL-model % hrf - estimated HRFs for each condition % fit - estimated time course % Initial values numstim = length(Run); len = length(Run{1}); LB = [0.05, 1, 0, 0.05, 5, 0.05, 8]; % Previous Lower bounds for parameters UB = [2 10, 2, 2, 15, 1, 20]; % LB = [0.05, 1, 0, 0.05, 5, 0.05, 8, 0]; % Previous Lower bounds for parameters % UB = [2 10, 5, 2, 15, 1, 20, 5]; LB = repmat(LB, 1, numstim); UB = repmat(UB, 1, numstim); V0 = repmat(V0,1,numstim); % Find optimal values %options = optimset('MaxFunEvals',10000,'Maxiter',10000,'TolX', 1e-4, 'TolFun', 1e-4,'Display','Final'); options = optimset('MaxFunEvals',100000,'Maxiter',100000,'TolX', 1e-8, 'TolFun', 1e-8,'Display','off'); %VM = fminsearch(@msq_timecourse,V02,options,t,tc,RunA,RunB,RunC ); VM = fminsearchbnd(@cost_allstim_2, V0, LB, UB, options,tr,tc,Run,down); % Use optimal values to fit hemodynamic response functions t=0:(1/down):30; hrf =zeros(length(t),numstim); fitt = zeros(len,numstim); for g = 1:numstim hrf(:,g) = Get_Logit(VM(g*7-6:g*7),t); % Calculate HRF estimate (fit, given theta) end for g = 1:numstim fits(:,g) = conv(Run{g}, hrf(:,g)); fitt(:,g) = fits(1:len,g); end fit = sum(fitt,2); return; \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Fit_sFIR.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Fit_sFIR.m.svn-base deleted file mode 100644 index 2e76d50d..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Fit_sFIR.m.svn-base +++ /dev/null @@ -1,77 +0,0 @@ -function [hrf, fit, e, param] = Fit_sFIR(tc,TR,Run,T,mode) -% function [hrf, fit, e, param] = Fit_sFIR(tc,TR,Runs,T,mode) -% -% Fits FIR and smooth FIR model -% -% INPUTS: -% -% tc - time course -% TR - time resolution -% Runs - expermental design -% T - length of estimated HRF -% mode - FIR or smooth FIR -% options: -% 0 - standard FIR -% 1 - smooth FIR -% -% OUTPUTS: -% -% hrf - estimated hemodynamic response function -% fit - estimated time course -% e - residual time course -% param - estimated amplitude, height and width -% -% Created by Martin Lindquist on 10/02/09 -% Last edited: 05/26/10 (ML) - -numstim = length(Run); -len = length(Run{1}); - - -Runs = zeros(len,numstim); -for i=1:numstim, - Runs(:,i) = Run{i}; -end; - -[DX] = tor_make_deconv_mtx3(Runs,T,1); - -% DX2 = DX(:,1:T); -% num = T; - -if mode == 1 - - C=(1:T)'*(ones(1,T)); - h = sqrt(1/(7/TR)); % 7 seconds smoothing - ref. Goutte - - v = 0.1; - sig = 1; - - R = v*exp(-h/2*(C-C').^2); - RI = inv(R); - MRI = zeros(numstim*T+1); - for i=1:numstim, - MRI(((i-1)*T+1):(i*T),((i-1)*T+1):(i*T)) = RI; - end; - - b = inv(DX'*DX+sig^2*MRI)*DX'*tc; - fit = DX*b; - e = tc - DX*b; - -elseif mode == 0 - - b = pinv(DX)*tc; - fit = DX*b; - e = tc - DX*b; - -end - - -hrf =zeros(T,numstim); -param = zeros(3,numstim); - -for i=1:numstim, - hrf(:,i) = b(((i-1)*T+1):(i*T))'; - param(:,i) = get_parameters2(hrf(:,i),T); -end; - -end \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Get_Logit.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Get_Logit.m.svn-base deleted file mode 100644 index c33a6d43..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Get_Logit.m.svn-base +++ /dev/null @@ -1 +0,0 @@ -function [h, base] = get_logit(V,t) % % [h] = get_logit(V,t) % % Calculate inverse logit (IL) HRF model % Creates fitted curve - 3 logistic functions to be summed together - from parameter estimates % % INPUT: V, t % t = vector of time points % V = parameters % % By Martin Lindquist and Tor Wager % Edited 12/12/06 % A1 = V(1); T1 = V(2); d1 = V(3); A2 = V(4); T2 = V(5); A3 = V(6); T3 = V(7); d2 = -d1*(ilogit(A1*(1-T1)) - ilogit(A3*(1-T3)))/(ilogit(A2*(1-T2)) + ilogit(A3*(1-T3))); d3 = abs(d2)-abs(d1); h = d1*ilogit(A1*(t-T1))+d2*ilogit(A2*(t-T2))+d3*ilogit(A3*(t-T3)); % Superimpose 3 IL functions h = h'; base =zeros(3,length(t)); base(1,:) = ilogit(A1*(t-T1)); base(2,:) = ilogit(A2*(t-T2)); base(3,:) = ilogit(A3*(t-T3)); return \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Get_Logit_2.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Get_Logit_2.m.svn-base deleted file mode 100644 index 2930b4e7..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Get_Logit_2.m.svn-base +++ /dev/null @@ -1 +0,0 @@ -function [h, base] = get_logit_2(V,t) % % [h] = get_logit(V,t) % % Calculate inverse logit (IL) HRF model % Creates fitted curve - 3 logistic functions to be summed together - from parameter estimates % % INPUT: V, t % t = vector of time points % V = parameters % % By Martin Lindquist and Tor Wager % Edited 12/12/06 % T = t(end); A1 = V(1); T1 = V(2); d1 = V(3); A2 = V(4); T2 = V(5); A3 = V(6); T3 = V(7); d3 = V(8); % d2 = -d1*(ilogit(A1*(1-T1)) - ilogit(A3*(1-T3)))/(ilogit(A2*(1-T2)) + ilogit(A3*(1-T3))); % d3 = abs(d2)-abs(d1); m1 = ilogit(A1*(1-T1)); m2 = ilogit(A2*(1-T2)); m3 = ilogit(A3*(1-T3)); w1 = ilogit(A1*(T-T1)); w2 = ilogit(A2*(T-T2)); w3 = ilogit(A3*(T-T3)); d2 = -d1*(m1 - m3*w1/w3)/(m2 - m3*w2/3); h = d1*ilogit(A1*(t-T1))+d2*ilogit(A2*(t-T2))+d3*ilogit(A3*(t-T3)); % Superimpose 3 IL functions h = h'; base =zeros(3,length(t)); base(1,:) = ilogit(A1*(t-T1)); base(2,:) = ilogit(A2*(t-T2)); base(3,:) = ilogit(A3*(t-T3)); return \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Inverse_Logit_Tool_Instructions.rtf.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Inverse_Logit_Tool_Instructions.rtf.svn-base deleted file mode 100644 index 790a7dbc..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/Inverse_Logit_Tool_Instructions.rtf.svn-base +++ /dev/null @@ -1,69 +0,0 @@ -{\rtf1\mac\ansicpg10000\cocoartf824\cocoasubrtf420 -{\fonttbl\f0\fswiss\fcharset77 Helvetica;\f1\fswiss\fcharset77 CenturyGothic-BoldItalic;} -{\colortbl;\red255\green255\blue255;\red0\green6\blue240;} -\margl1440\margr1440\vieww5880\viewh19560\viewkind0 -\pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\ql\qnatural\pardirnatural - -\f0\fs36 \cf0 Inverse Logit Hemodynamic Response Estimation Toolbox -\fs24 \ -\ - -\f1\i\b\fs28 Version: -\f0\i0\b0\fs24 \ -12/12/06, Last Edit: 12/12/06\ -\ - -\f1\i\b\fs28 Authors: -\f0\i0\b0\fs24 \ -Martin Lindquist, Dept. of Statistics, Columbia University\ -Tor Wager, Dept. of Psychology, Columbia University\ -\ - -\f1\i\b\fs28 Reference: -\f0\i0\b0\fs24 \ -\ -\pard\pardeftab720\sa360\ql\qnatural -{\field{\*\fldinst{HYPERLINK "http://www.columbia.edu/cu/psychology/tor/Papers/Model_hrf-1.doc"}}{\fldrslt \cf0 \ul \ulc2 Lindquist, M. and Wager, T. D.\'ca (in press). Validity and Power in Hemodynamic Response Modeling:\'ca A comparison study and a new approach.\'ca \ulnone Human Brain Mapping (2007); early version available online.}}\ -\pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\ql\qnatural\pardirnatural -\cf0 \ - -\f1\i\b\fs28 Purpose:\ -\ - -\f0\i0\b0\fs24 This toolbox will estimate parameters for a flexible hemodynamic response function (HRF) using three superimposed inverse logit functions. The HRF is fit using seven parameters, and can capture a variety of HRF shapes, including sustained activations. \ -\ -Main inputs include a data timeseries and indicator (stick) function for the onsets of events. -\f1\i\b\fs28 \ -\ - -\f0\i0\b0\fs24 The function can be used with ROI timeseries or incorporated into whole-brain HRF-estimation tools. -\f1\i\b\fs28 \ -\ - -\f0\i0\b0\fs24 \ - -\f1\i\b\fs28 Main Functions:\ - -\f0\i0\b0\fs24 \ -(Specific help for each function is available by typing\ ->> help myfunctionname \ -at the >> matlab prompt.)\ -\ -Example.m\ --------------------------------------------------------------------------------------------------\ -Example script that loads example timeseries data and fits the model. Plots results.\ -\ -Anneal_Logit.m\ --------------------------------------------------------------------------------------------------\ -Main function for fitting inverse logit (IL) HRF estimate to timeseries.\ -Simulated annealing is built into function as a solution-finder.\ -\ -Get_Logit.m\ --------------------------------------------------------------------------------------------------\ -Create HRF estimate from IL parameters\ -\ -cost.m\ --------------------------------------------------------------------------------------------------\ -Least-squares cost function for the IL model\ -\ -} \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/PowerLoss.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/PowerLoss.m.svn-base deleted file mode 100644 index bbd855a9..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/PowerLoss.m.svn-base +++ /dev/null @@ -1,38 +0,0 @@ -function [PowLoss] = PowerLoss(modres, modfit, moddf, tc, TR, Run, alpha) -% function [PowLoss] = PowerLoss(modres, modfit, moddf, tc, TR, Run, alpha) -% -% Estimates Power-loss due to mis-modeling. -% -% INPUT: -% -% modres - residuals -% modfit - model fit -% moddf - model degrees of freedom -% tc - time course -% TR - time resolution -% Runs - expermental design -% alpha - alpha value -% -% OUTPUT: -% -% PowLoss - Estimated power loss -% -% - -len = length(tc); % length of time course -T = round(30./TR); % length of estimated HRF -tstar = tinv(1-alpha,moddf); % t-threshold - -% Fit FIR model to find 'baseline' power. -[h, fit, e] = Fit_sFIR(tc,TR,Run,T,1); -s = (1/(len-T))*e'*e; -t = 1/sqrt(s*inv(fit'*fit)); -basePow = 1- nctcdf(tstar,(len-T),t); - -% Compute model power. -sig = (1/moddf)*modres'*modres; -ts = 1/sqrt(sig*inv(modfit'*modfit)); -modPow = 1- nctcdf(tstar,moddf,ts); - -% Compute 'power loss' -PowLoss = basePow - modPow; diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/PowerSim.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/PowerSim.m.svn-base deleted file mode 100644 index 8dc57491..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/PowerSim.m.svn-base +++ /dev/null @@ -1,37 +0,0 @@ -N = 1000; -M = 10; -P = zeros(N,M); -B = zeros(N,M); - -Yind = zeros(60,1); -Yind(21:40) = 1; -X2 = Yind; -sigma = 0.5; -tstar = tinv(1-0.05,59); - -for i=1:M, - for rep = 1:N, - - X = zeros(60,1); - X((20+i):(40+i-1)) = 1; - Y = Yind + normrnd(0,sigma,60,1); - b = pinv(X)*Y; - e = Y-X*b; - sig = sqrt(e'*e/59); - t = b./(sig/(X'*X)); - delta = 1/sig; - Pow = 1- nctcdf(tstar,59,delta); - - b2 = pinv(X2)*Y; - e2 = Y-X2*b2; - sig2 = sqrt(e2'*e2/59); - t2 = b2./(sig2/(X2'*X2)); - delta2 = 1/sig2; - Pow2 = 1- nctcdf(tstar,59,delta2); - - - P(rep,i) = Pow2-Pow; - B(rep,i) = b-1; - - end; -end; diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/ResidScan.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/ResidScan.m.svn-base deleted file mode 100644 index 071cb589..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/ResidScan.m.svn-base +++ /dev/null @@ -1,107 +0,0 @@ -function [p sres sres_ns] = ResidScan(res, FWHM) -% function [p sres sres_ns] = ResidScan(res, FWHM) -% -% Calculates P(M>=t) where M is the max value of the smoothed residuals. -% In this implementation the residuals are smoothed using a Gaussian -% kernel. -% -% INPUT: -% -% res - residual time course -% FWHM - Full Width Half Maximum (in time units) -% -% OUTPUT: -% -% p - pvalues -% sres - smoothed residuals -% sres_ns - smoothed residuals (non standardized) -% -% By Martin Lindquist & Ji-Meng Loh, July 2007 -% -% Edited by ML on 10/02/09 - -res_ns = res; -res = res./std(res); -len = length(res); - -% Create Gaussian Kernel -sig = ceil(FWHM/(2*sqrt(2*log(2)))); -klen = 3*sig; -kern = normpdf((-klen:klen),0,sig); -kern = kern./sqrt(sum(kern.^2)); - -% Convolve -x = conv(res,kern); -sres = x((klen + 1):(end-klen)); - -x = conv(res_ns,kern/sum(kern)); -sres_ns = x((klen + 1):(end-klen)); - - -% Find Max value -[a,location] = max(abs(sres)); - -% Find p-values using Gaussian Random Field theory -z = Euler_p(1, a, len, FWHM); -z = 2*z; %Two-sided test -p = min(1, z); - -end - -% END MAIN FUNCTION - - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% Subfunctions -% -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - - -function pval = Euler_p(myDim, value, N, fwhm) -% function z = Euler_p(myDim, value, N, fwhm) -% -% Finds the p value using the expected Euler characteristic. -% -% This function returns P(M \ge value) using the approximation -% \sum_{d=0}^D R_d(V) \rho_d(value) following Worsley et al's "A Unified -% Statistical Approach for Determining Significant Signals in Images of -% Cerebral Activation". -% -% INPUTS: -% -% myDim - the number of dimensions in the data -% value - the value of the maximum. -% N - the number of (time) points in that 1 dimension -% fwhm - the full width half maximum -% -% OUTPUTS: -% -% pval - the p-value - -% NOTE: CURRENTLY THIS FUNCTION IS ONLY IMPLEMENTED FOR THE 1D CASE - - % Constants - myfactor = 4*log(2); - pi2 = 2*pi; - exptsq = exp(-(value^2)/2); - - % Euler Characteristc Densties - rho = zeros(5,1); - rho(1) = 1-normcdf(value); - rho(2) = myfactor^(0.5)*exptsq/pi2; - rho(3) = myfactor * exptsq * value / (pi2 ^ (1.5)); - rho(4) = myfactor ^ (1.5) * exptsq * (value^2-1) / (pi2 ^2); - rho(5) = myfactor ^2 * exptsq * (value^3-3*value) / (pi2 ^ (5/2)); - - % Resel Count - R0 = 1; - R1 = N/fwhm; - - % P-value - pval = R0 * rho(1) + R1 * rho(2); - -end - - diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/ScanSim4.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/ScanSim4.m.svn-base deleted file mode 100644 index 803d202b..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/ScanSim4.m.svn-base +++ /dev/null @@ -1,67 +0,0 @@ -TR = 0.5; -len = 200; - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Compute hrf -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -t=0.1:TR:30; -a1 =6; -a2 = 12; -b1 = 0.9; -b2 =0.9; -c = 0.35; - -d1 = a1*b1; -d2 = a2*b2; - -h = ((t./d1).^a1).*exp(-(t-d1)./b1) - c*((t./d2).^a2).*exp(-(t-d2)./b2); -h = h./max(h); - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -% X = zeros(len,4); -% X(:,1) = 1; -% X(:,2) = (1:len)/len; -% X(:,3) = X(:,2).^2; -% -% Run = zeros(40,5); -% Run(1:20,:) = 1; -% Run = reshape(Run,200,1); -% q = conv(Run,h); -% X(:,4) = q(1:len); - - -X = zeros(len,2); -X(:,1) = 1; -Run = zeros(200,1); -Run(50) = 1; -Run(150) = 1; -q = conv(Run,h); -X(:,2) = q(1:len); - - -Run = zeros(200,1); -Run(50) = 1; -Run(160) = 1; -tc = conv(Run,h); -tc = tc(1:len) + normrnd(0,0.3,200,1); - - -beta = pinv(X)*tc; -e = tc-X*beta; -sigma = sqrt((1/(len-size(X,2)))*sum(e.^2)); -%c = [0 0 0 1]; -c = [0 1]; -se = sigma.*sqrt(c*inv(X'*X)*c'); -t = beta/se; -pval = 2*tcdf(-abs(t),len-4); -df = len - 4; - -[z sres sres_ns] = ResidScan(e, 4); -[b bias pl pc pe] = BiasPowerloss(tc, X,c,beta,df,z,0.05); - -beta -b -bias -pl diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/get_parameters2.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/get_parameters2.m.svn-base deleted file mode 100644 index 2793c63d..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/get_parameters2.m.svn-base +++ /dev/null @@ -1,43 +0,0 @@ -function [param] = get_parameters2(hdrf,t) - -% Find model parameters -% -% Height - h -% Time to peak - p (in time units of TR seconds) -% Width (at half peak) - w - - -% Calculate Heights and Time to peak: - -n = t(end)*0.6; - -[h,p] = max(abs(hdrf(1:n))); -h = hdrf(p); - -if (p > t(end)*0.6), warning('Late time to peak'), end; - -if (h >0) - v = (hdrf >= h/2); -else - v = (hdrf <= h/2); -end; - -[a,b] = min(diff(v)); -v(b+1:end) = 0; -w = sum(v); - -cnt = p-1; -g =hdrf(2:end) - hdrf(1:(end-1)); -while((cnt > 0) & (abs(g(cnt)) <0.001)), - h = hdrf(cnt); - p = cnt; - cnt = cnt-1; -end; - - -param = zeros(3,1); -param(1) = h; -param(2) = p; -param(3) = w; - -return; diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/ilogit.m.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/ilogit.m.svn-base deleted file mode 100644 index bce2823a..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/ilogit.m.svn-base +++ /dev/null @@ -1,13 +0,0 @@ -function [L] = ilogit(t) -% -% function [L] = ilogit(t) -% -% Calculate the inverse logit function corresponding to the value t -% -% OUTPUT: L = exp(t)./(1+exp(t)); -% -% By Martin Lindquist and Tor Wager -% Edited 12/12/06 -% - -L = exp(t)./(1+exp(t)); \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/timecourse.mat.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/timecourse.mat.svn-base deleted file mode 100644 index fb694160..00000000 Binary files a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/timecourse.mat.svn-base and /dev/null differ diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/timecourse_2.mat.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/timecourse_2.mat.svn-base deleted file mode 100644 index 7a1c0700..00000000 Binary files a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/timecourse_2.mat.svn-base and /dev/null differ diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/timecourse_old.mat.svn-base b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/timecourse_old.mat.svn-base deleted file mode 100644 index fb694160..00000000 Binary files a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/.svn/text-base/timecourse_old.mat.svn-base and /dev/null differ diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/Anneal_Logit.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/Anneal_Logit.m deleted file mode 100644 index f45f2316..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/Anneal_Logit.m +++ /dev/null @@ -1,126 +0,0 @@ -function [theta,HH,C,P,hrf,fit,e,param] = Anneal_Logit(theta0,t,tc,Run) -% -% [theta,HH,C,P] = Anneal_Logit(theta0,t,tc,Run) -% -% Estimate inverse logit (IL) HRF model using Simulated Annealing -% Creates fitted curve - 3 logistic functions to be summed together - from parameter estimates -% -% INPUT: theta0, t, tc, Run -% Run = stick function -% tc = time course -% t = vector of time points -% theta0 = initial value for the parameter vector -% -% By Martin Lindquist and Tor Wager -% Edited 12/12/06 -% - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% Initial values - -iter = 15000; % Number of iterations -theta = theta0; % Set initial value for the parameter vector -h0 = cost(theta0,t,tc,Run); % Calculate cost of initial estimate -LB = [0.05, 1, 0, 0.05, 5, 0, 10]; % Lower bounds for parameters -UB = [10, 15, 5, 10, 15, 5, 30]; % Upper bounds for parameters - -% -% These values may need tweaking depending on the individual situation. -% - -r1= 0.001; % A parameters -r1b= 0.001; % A parameters -r2 = 0.05; % T parameters -r3 = 0.001; % delta parameters - -t1 = [1 4]; -t1b = [6]; -t2 = [2 5 7]; -t3 = [3]; - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -u = zeros(1,7); - -HH = zeros(1+iter,7); % Keep track of theta_i -HH(1,:) = theta0; -P = zeros(1+iter,1); -C = zeros(1+iter,1); % Keep track of the cost function -C(1) = h0; - -cnt = 0; -for i=1:iter, - - T = 100/log(1+i); %Temperature function (may require tweaking) - th = zeros(1,7); - ind = 0; - - % Choose a new candidate solution theta_{i+1}, based on a random perturbation of the current solution of theta_{i}. - % Check new parameters are within accepted bounds - while ( (sum((LB-th)>0) + sum((th-UB)>0)) > 0), - - % Perturb solution - - u(t1) = normrnd(0,r1,1,2); - u(t1b) = normrnd(0,r1b,1,1); - u(t2) = normrnd(0,r2,1,3); - u(t3) = normrnd(0,r3,1,1); - - % Update solution - th = theta + u; - ind = ind + 1; - - if(ind > 500), - warning('stuck!'); - return; - end; - end; - - h = cost(th,t,tc,Run); - C(i+1) = h; - delta = h - h0; - - % Determine whether to update the parameter vector. - if (unifrnd(0,1) < min(exp(-delta/T),1)), - theta = th; - h0=h; - cnt = cnt+1; - end; - - HH(i+1,:) = theta; - P(i+1) = min(exp(-delta/T),1); - -end; - -%cnt/iter - -[a,b] = min(C); -theta = HH(b,:); -%h - - -% Additional outputs -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Get HRF for final model -if nargout > 4 - hrf = Get_Logit(theta(1:7),t); % Calculate HRF estimate (fit, given theta) -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Convolve HRF and stick function -if nargout > 5 - len = length(Run); - fit = conv(Run, hrf); - fit = fit(1:len); - e = tc - fit; -end - -if nargout > 7 - [param] = get_parameters_logit(hrf,t,theta); -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - - -return diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/Det_Logit.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/Det_Logit.m deleted file mode 100644 index 83cfe423..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/Det_Logit.m +++ /dev/null @@ -1,116 +0,0 @@ -function [VM, hrf, fit, e, param] = Det_Logit(V0,t,tc,Run) -% -% [VM, h, fit, e, param] = Det_Logit_allstim(V0,t,tc,Run) -% -% Estimate inverse logit (IL) HRF model -% Creates fitted curve - 3 logistic functions to be summed together - from parameter estimates -% -% INPUT: V0, t, tc, Run -% Run = stick function -% tc = time course -% t = vector of time points -% V0 = initial value for the parameter vector -% -% By Martin Lindquist, Christian Waugh and Tor Wager -% Created by Martin Lindquist on 10/02/09 -% Last edited: 05/26/10 (ML) - - -numstim = length(Run); -len = length(Run{1}); - -% LB = [0.05, 1, 0, 0.05, 4, 0, 10]; % Lower bounds for parameters -% UB = [10, 15, 10, 10, 15, 5, 50]; % Upper bounds for parameters -% LB = repmat(LB, 1, numstim); -% UB = repmat(UB, 1, numstim); - -% Remove intercept - -b0 = pinv(ones(length(tc),1))*tc; -tc = tc - b0; - -% Find optimal values - -options = optimset('MaxFunEvals',10000000,'Maxiter',10000000,'TolX',1e-8,'TolFun',1e-8,'Display','off'); - -%VM = fminsearchbnd(@cost_allstim, V0, LB,UB,options,t,tc,Run); -VM = fminsearch(@msq_logit,V0,options,Run,t,tc); - -% Use optimal values to fit hemodynamic response functions -hrf =zeros(length(t),numstim); -fitt = zeros(len,numstim); -param = zeros(3,numstim); - -for g = 1:numstim - hrf(:,g) = il_hdmf_tw2(t,VM(((g-1)*7+1):(g*7))); % Calculate HRF estimate (fit, given theta) - param(:,g) = get_parameters2(hrf(:,g),t(end)); - fits(:,g) = conv(Run{g}, hrf(:,g)); - fitt(:,g) = fits(1:len,g); -end - -fit = sum(fitt,2); -e = tc-fit; -fit = fit + b0; - -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% SUBFUNCTIONS -% -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -function m=msq_logit(V,Run, t, tc) - -numstim = length(Run); -len = length(Run{1}); -h = zeros(length(t),numstim); -yhatt =zeros(len,numstim); - -for k = 1:numstim - h(:,k) = il_hdmf_tw2(t,V(((k-1)*7+1):(k*7))); % Get IL model corresponding to parameters V - yhat(:,k) = conv(Run{k}, h(:,k)); % Convolve IL model with stick function - yhatt(:,k) = yhat(1:len,k); -end - -yhat2 = sum(yhatt,2); %Sum models together to get overall estimate - -m = sum((tc-yhat2).^2); % Calculate cost function - -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -function [h,base] = il_hdmf_tw2(t,V) -% inverse logit -- creates fitted curve from parameter estimates -% -% t = vector of time points -% V = parameters - -% 3 logistic functions to be summed together -base = zeros(length(t),3); -A1 = V(1); -T1 = V(2); -d1 = V(3); -A2 = V(4); -T2 = V(5); -A3 = V(6); -T3 = V(7); -d2 = -d1*(ilogit(A1*(1-T1)) - ilogit(A3*(1-T3)))/(ilogit(A2*(1-T2)) + ilogit(A3*(1-T3))); -d3 = abs(d2)-abs(d1); - -base(:,1)= d1*ilogit(A1*(t-T1))'; -base(:,2)= d2*ilogit(A2*(t-T2))'; -base(:,3)= d3*ilogit(A3*(t-T3))'; -h = sum(base,2)'; - -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -function [L] = ilogit(t) -L = exp(t)./(1+exp(t)); -end - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/Fit_Logit_allstim.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/Fit_Logit_allstim.m deleted file mode 100644 index bb697d63..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/Fit_Logit_allstim.m +++ /dev/null @@ -1 +0,0 @@ -function [VM, hrf, fit, e, param] = Fit_Logit_allstim(V0,tc,TR, Run, T) % Fit inverse logit function model to time course % Multi-stimulus case % % INPUT: % % V0 = initial parameters % tc = time course % TR = time resolution % Run = stick function (one trial type per cell) % % % OUTPUT: % % VM = estimated parameters % hrf = estimated HRF % C = SSE % fit - fitted time course % param - estimated height, time to peak and width for each HRF numstim = length(Run); len = length(Run{1}); t=1:TR:T; % Initial values LB = [0.05, 1, 0, 0.05, 5, 0.05, 8]; % Previous Lower bounds for parameters UB = [5, 10, 2.5, 2, 15, 2, 20]; LB = repmat(LB, 1, numstim); UB = repmat(UB, 1, numstim); V0 = repmat(V0, 1, numstim); % Find optimal values options = optimset('MaxFunEvals',10000,'Maxiter',10000,'TolX', 1e-6, 'TolFun', 1e-6,'Display','off'); VM = fminsearchbnd(@cost_allstim, V0, LB,UB,options,t,tc,Run); % Use optimal values to fit hemodynamic response functions hrf =zeros(length(t),numstim); fitt = zeros(len,numstim); param = zeros(3,numstim); for g = 1:numstim hrf(:,g) = Get_Logit(VM(g*7-6:g*7),t); % Calculate HRF estimate (fit, given theta) param(:,g) = get_parameters2(hrf(:,g),(1:length(t))); end % Get fitted time courses and residuals for g = 1:numstim fits(:,g) = conv(Run{g}, hrf(:,g)); fitt(:,g) = fits(1:len,g); end fit = sum(fitt,2); e = tc-fit; return; \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/Fit_Logit_allstim_2.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/Fit_Logit_allstim_2.m deleted file mode 100644 index 95139b90..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/Fit_Logit_allstim_2.m +++ /dev/null @@ -1 +0,0 @@ -function [VM, hrf, fit] = Fit_Logit_allstim_2(V0,tc,tr, Run, down) % Fit inverse logit function model to time course % Multi-condition case % % Last edited by ML on 02/12/13 % % INPUTS: % % V0 - Initial values for IL-model % tc - time course data % tr - TR % Run - a cell array containing stick functions (one for each condition) % down - downsampling factor % % OUTPUTS: % % VM - final parameter values for IL-model % hrf - estimated HRFs for each condition % fit - estimated time course % Initial values numstim = length(Run); len = length(Run{1}); LB = [0.05, 1, 0, 0.05, 5, 0.05, 8]; % Previous Lower bounds for parameters UB = [2 10, 2, 2, 15, 1, 20]; % LB = [0.05, 1, 0, 0.05, 5, 0.05, 8, 0]; % Previous Lower bounds for parameters % UB = [2 10, 5, 2, 15, 1, 20, 5]; LB = repmat(LB, 1, numstim); UB = repmat(UB, 1, numstim); V0 = repmat(V0,1,numstim); % Find optimal values %options = optimset('MaxFunEvals',10000,'Maxiter',10000,'TolX', 1e-4, 'TolFun', 1e-4,'Display','Final'); options = optimset('MaxFunEvals',100000,'Maxiter',100000,'TolX', 1e-8, 'TolFun', 1e-8,'Display','off'); %VM = fminsearch(@msq_timecourse,V02,options,t,tc,RunA,RunB,RunC ); VM = fminsearchbnd(@cost_allstim_2, V0, LB, UB, options,tr,tc,Run,down); % Use optimal values to fit hemodynamic response functions t=0:(1/down):30; hrf =zeros(length(t),numstim); fitt = zeros(len,numstim); for g = 1:numstim hrf(:,g) = Get_Logit(VM(g*7-6:g*7),t); % Calculate HRF estimate (fit, given theta) end for g = 1:numstim fits(:,g) = conv(Run{g}, hrf(:,g)); fitt(:,g) = fits(1:len,g); end fit = sum(fitt,2); return; \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/Get_Logit.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/Get_Logit.m deleted file mode 100644 index c33a6d43..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/Get_Logit.m +++ /dev/null @@ -1 +0,0 @@ -function [h, base] = get_logit(V,t) % % [h] = get_logit(V,t) % % Calculate inverse logit (IL) HRF model % Creates fitted curve - 3 logistic functions to be summed together - from parameter estimates % % INPUT: V, t % t = vector of time points % V = parameters % % By Martin Lindquist and Tor Wager % Edited 12/12/06 % A1 = V(1); T1 = V(2); d1 = V(3); A2 = V(4); T2 = V(5); A3 = V(6); T3 = V(7); d2 = -d1*(ilogit(A1*(1-T1)) - ilogit(A3*(1-T3)))/(ilogit(A2*(1-T2)) + ilogit(A3*(1-T3))); d3 = abs(d2)-abs(d1); h = d1*ilogit(A1*(t-T1))+d2*ilogit(A2*(t-T2))+d3*ilogit(A3*(t-T3)); % Superimpose 3 IL functions h = h'; base =zeros(3,length(t)); base(1,:) = ilogit(A1*(t-T1)); base(2,:) = ilogit(A2*(t-T2)); base(3,:) = ilogit(A3*(t-T3)); return \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/Get_Logit_2.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/Get_Logit_2.m deleted file mode 100644 index 2930b4e7..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/Get_Logit_2.m +++ /dev/null @@ -1 +0,0 @@ -function [h, base] = get_logit_2(V,t) % % [h] = get_logit(V,t) % % Calculate inverse logit (IL) HRF model % Creates fitted curve - 3 logistic functions to be summed together - from parameter estimates % % INPUT: V, t % t = vector of time points % V = parameters % % By Martin Lindquist and Tor Wager % Edited 12/12/06 % T = t(end); A1 = V(1); T1 = V(2); d1 = V(3); A2 = V(4); T2 = V(5); A3 = V(6); T3 = V(7); d3 = V(8); % d2 = -d1*(ilogit(A1*(1-T1)) - ilogit(A3*(1-T3)))/(ilogit(A2*(1-T2)) + ilogit(A3*(1-T3))); % d3 = abs(d2)-abs(d1); m1 = ilogit(A1*(1-T1)); m2 = ilogit(A2*(1-T2)); m3 = ilogit(A3*(1-T3)); w1 = ilogit(A1*(T-T1)); w2 = ilogit(A2*(T-T2)); w3 = ilogit(A3*(T-T3)); d2 = -d1*(m1 - m3*w1/w3)/(m2 - m3*w2/3); h = d1*ilogit(A1*(t-T1))+d2*ilogit(A2*(t-T2))+d3*ilogit(A3*(t-T3)); % Superimpose 3 IL functions h = h'; base =zeros(3,length(t)); base(1,:) = ilogit(A1*(t-T1)); base(2,:) = ilogit(A2*(t-T2)); base(3,:) = ilogit(A3*(t-T3)); return \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/Inverse_Logit_Tool_Instructions.rtf b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/Inverse_Logit_Tool_Instructions.rtf deleted file mode 100644 index 790a7dbc..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/Inverse_Logit_Tool_Instructions.rtf +++ /dev/null @@ -1,69 +0,0 @@ -{\rtf1\mac\ansicpg10000\cocoartf824\cocoasubrtf420 -{\fonttbl\f0\fswiss\fcharset77 Helvetica;\f1\fswiss\fcharset77 CenturyGothic-BoldItalic;} -{\colortbl;\red255\green255\blue255;\red0\green6\blue240;} -\margl1440\margr1440\vieww5880\viewh19560\viewkind0 -\pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\ql\qnatural\pardirnatural - -\f0\fs36 \cf0 Inverse Logit Hemodynamic Response Estimation Toolbox -\fs24 \ -\ - -\f1\i\b\fs28 Version: -\f0\i0\b0\fs24 \ -12/12/06, Last Edit: 12/12/06\ -\ - -\f1\i\b\fs28 Authors: -\f0\i0\b0\fs24 \ -Martin Lindquist, Dept. of Statistics, Columbia University\ -Tor Wager, Dept. of Psychology, Columbia University\ -\ - -\f1\i\b\fs28 Reference: -\f0\i0\b0\fs24 \ -\ -\pard\pardeftab720\sa360\ql\qnatural -{\field{\*\fldinst{HYPERLINK "http://www.columbia.edu/cu/psychology/tor/Papers/Model_hrf-1.doc"}}{\fldrslt \cf0 \ul \ulc2 Lindquist, M. and Wager, T. D.\'ca (in press). Validity and Power in Hemodynamic Response Modeling:\'ca A comparison study and a new approach.\'ca \ulnone Human Brain Mapping (2007); early version available online.}}\ -\pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\ql\qnatural\pardirnatural -\cf0 \ - -\f1\i\b\fs28 Purpose:\ -\ - -\f0\i0\b0\fs24 This toolbox will estimate parameters for a flexible hemodynamic response function (HRF) using three superimposed inverse logit functions. The HRF is fit using seven parameters, and can capture a variety of HRF shapes, including sustained activations. \ -\ -Main inputs include a data timeseries and indicator (stick) function for the onsets of events. -\f1\i\b\fs28 \ -\ - -\f0\i0\b0\fs24 The function can be used with ROI timeseries or incorporated into whole-brain HRF-estimation tools. -\f1\i\b\fs28 \ -\ - -\f0\i0\b0\fs24 \ - -\f1\i\b\fs28 Main Functions:\ - -\f0\i0\b0\fs24 \ -(Specific help for each function is available by typing\ ->> help myfunctionname \ -at the >> matlab prompt.)\ -\ -Example.m\ --------------------------------------------------------------------------------------------------\ -Example script that loads example timeseries data and fits the model. Plots results.\ -\ -Anneal_Logit.m\ --------------------------------------------------------------------------------------------------\ -Main function for fitting inverse logit (IL) HRF estimate to timeseries.\ -Simulated annealing is built into function as a solution-finder.\ -\ -Get_Logit.m\ --------------------------------------------------------------------------------------------------\ -Create HRF estimate from IL parameters\ -\ -cost.m\ --------------------------------------------------------------------------------------------------\ -Least-squares cost function for the IL model\ -\ -} \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/PowerSim.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/PowerSim.m deleted file mode 100644 index 8dc57491..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/PowerSim.m +++ /dev/null @@ -1,37 +0,0 @@ -N = 1000; -M = 10; -P = zeros(N,M); -B = zeros(N,M); - -Yind = zeros(60,1); -Yind(21:40) = 1; -X2 = Yind; -sigma = 0.5; -tstar = tinv(1-0.05,59); - -for i=1:M, - for rep = 1:N, - - X = zeros(60,1); - X((20+i):(40+i-1)) = 1; - Y = Yind + normrnd(0,sigma,60,1); - b = pinv(X)*Y; - e = Y-X*b; - sig = sqrt(e'*e/59); - t = b./(sig/(X'*X)); - delta = 1/sig; - Pow = 1- nctcdf(tstar,59,delta); - - b2 = pinv(X2)*Y; - e2 = Y-X2*b2; - sig2 = sqrt(e2'*e2/59); - t2 = b2./(sig2/(X2'*X2)); - delta2 = 1/sig2; - Pow2 = 1- nctcdf(tstar,59,delta2); - - - P(rep,i) = Pow2-Pow; - B(rep,i) = b-1; - - end; -end; diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/ResidScan.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/ResidScan.m deleted file mode 100644 index 071cb589..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/ResidScan.m +++ /dev/null @@ -1,107 +0,0 @@ -function [p sres sres_ns] = ResidScan(res, FWHM) -% function [p sres sres_ns] = ResidScan(res, FWHM) -% -% Calculates P(M>=t) where M is the max value of the smoothed residuals. -% In this implementation the residuals are smoothed using a Gaussian -% kernel. -% -% INPUT: -% -% res - residual time course -% FWHM - Full Width Half Maximum (in time units) -% -% OUTPUT: -% -% p - pvalues -% sres - smoothed residuals -% sres_ns - smoothed residuals (non standardized) -% -% By Martin Lindquist & Ji-Meng Loh, July 2007 -% -% Edited by ML on 10/02/09 - -res_ns = res; -res = res./std(res); -len = length(res); - -% Create Gaussian Kernel -sig = ceil(FWHM/(2*sqrt(2*log(2)))); -klen = 3*sig; -kern = normpdf((-klen:klen),0,sig); -kern = kern./sqrt(sum(kern.^2)); - -% Convolve -x = conv(res,kern); -sres = x((klen + 1):(end-klen)); - -x = conv(res_ns,kern/sum(kern)); -sres_ns = x((klen + 1):(end-klen)); - - -% Find Max value -[a,location] = max(abs(sres)); - -% Find p-values using Gaussian Random Field theory -z = Euler_p(1, a, len, FWHM); -z = 2*z; %Two-sided test -p = min(1, z); - -end - -% END MAIN FUNCTION - - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% -% Subfunctions -% -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - - -function pval = Euler_p(myDim, value, N, fwhm) -% function z = Euler_p(myDim, value, N, fwhm) -% -% Finds the p value using the expected Euler characteristic. -% -% This function returns P(M \ge value) using the approximation -% \sum_{d=0}^D R_d(V) \rho_d(value) following Worsley et al's "A Unified -% Statistical Approach for Determining Significant Signals in Images of -% Cerebral Activation". -% -% INPUTS: -% -% myDim - the number of dimensions in the data -% value - the value of the maximum. -% N - the number of (time) points in that 1 dimension -% fwhm - the full width half maximum -% -% OUTPUTS: -% -% pval - the p-value - -% NOTE: CURRENTLY THIS FUNCTION IS ONLY IMPLEMENTED FOR THE 1D CASE - - % Constants - myfactor = 4*log(2); - pi2 = 2*pi; - exptsq = exp(-(value^2)/2); - - % Euler Characteristc Densties - rho = zeros(5,1); - rho(1) = 1-normcdf(value); - rho(2) = myfactor^(0.5)*exptsq/pi2; - rho(3) = myfactor * exptsq * value / (pi2 ^ (1.5)); - rho(4) = myfactor ^ (1.5) * exptsq * (value^2-1) / (pi2 ^2); - rho(5) = myfactor ^2 * exptsq * (value^3-3*value) / (pi2 ^ (5/2)); - - % Resel Count - R0 = 1; - R1 = N/fwhm; - - % P-value - pval = R0 * rho(1) + R1 * rho(2); - -end - - diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/ScanSim4.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/ScanSim4.m deleted file mode 100644 index 803d202b..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/ScanSim4.m +++ /dev/null @@ -1,67 +0,0 @@ -TR = 0.5; -len = 200; - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Compute hrf -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -t=0.1:TR:30; -a1 =6; -a2 = 12; -b1 = 0.9; -b2 =0.9; -c = 0.35; - -d1 = a1*b1; -d2 = a2*b2; - -h = ((t./d1).^a1).*exp(-(t-d1)./b1) - c*((t./d2).^a2).*exp(-(t-d2)./b2); -h = h./max(h); - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -% X = zeros(len,4); -% X(:,1) = 1; -% X(:,2) = (1:len)/len; -% X(:,3) = X(:,2).^2; -% -% Run = zeros(40,5); -% Run(1:20,:) = 1; -% Run = reshape(Run,200,1); -% q = conv(Run,h); -% X(:,4) = q(1:len); - - -X = zeros(len,2); -X(:,1) = 1; -Run = zeros(200,1); -Run(50) = 1; -Run(150) = 1; -q = conv(Run,h); -X(:,2) = q(1:len); - - -Run = zeros(200,1); -Run(50) = 1; -Run(160) = 1; -tc = conv(Run,h); -tc = tc(1:len) + normrnd(0,0.3,200,1); - - -beta = pinv(X)*tc; -e = tc-X*beta; -sigma = sqrt((1/(len-size(X,2)))*sum(e.^2)); -%c = [0 0 0 1]; -c = [0 1]; -se = sigma.*sqrt(c*inv(X'*X)*c'); -t = beta/se; -pval = 2*tcdf(-abs(t),len-4); -df = len - 4; - -[z sres sres_ns] = ResidScan(e, 4); -[b bias pl pc pe] = BiasPowerloss(tc, X,c,beta,df,z,0.05); - -beta -b -bias -pl diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/get_parameters2.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/get_parameters2.m deleted file mode 100644 index 2793c63d..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/get_parameters2.m +++ /dev/null @@ -1,43 +0,0 @@ -function [param] = get_parameters2(hdrf,t) - -% Find model parameters -% -% Height - h -% Time to peak - p (in time units of TR seconds) -% Width (at half peak) - w - - -% Calculate Heights and Time to peak: - -n = t(end)*0.6; - -[h,p] = max(abs(hdrf(1:n))); -h = hdrf(p); - -if (p > t(end)*0.6), warning('Late time to peak'), end; - -if (h >0) - v = (hdrf >= h/2); -else - v = (hdrf <= h/2); -end; - -[a,b] = min(diff(v)); -v(b+1:end) = 0; -w = sum(v); - -cnt = p-1; -g =hdrf(2:end) - hdrf(1:(end-1)); -while((cnt > 0) & (abs(g(cnt)) <0.001)), - h = hdrf(cnt); - p = cnt; - cnt = cnt-1; -end; - - -param = zeros(3,1); -param(1) = h; -param(2) = p; -param(3) = w; - -return; diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/ilogit.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/ilogit.m deleted file mode 100644 index bce2823a..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/ilogit.m +++ /dev/null @@ -1,13 +0,0 @@ -function [L] = ilogit(t) -% -% function [L] = ilogit(t) -% -% Calculate the inverse logit function corresponding to the value t -% -% OUTPUT: L = exp(t)./(1+exp(t)); -% -% By Martin Lindquist and Tor Wager -% Edited 12/12/06 -% - -L = exp(t)./(1+exp(t)); \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/timecourse.mat b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/timecourse.mat deleted file mode 100644 index fb694160..00000000 Binary files a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/timecourse.mat and /dev/null differ diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/timecourse_2.mat b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/timecourse_2.mat deleted file mode 100644 index 7a1c0700..00000000 Binary files a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/timecourse_2.mat and /dev/null differ diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/timecourse_old.mat b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/timecourse_old.mat deleted file mode 100644 index fb694160..00000000 Binary files a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/More_recent_old_stuff/timecourse_old.mat and /dev/null differ diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/ScanSim4.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/ScanSim4.m deleted file mode 100644 index 803d202b..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/ScanSim4.m +++ /dev/null @@ -1,67 +0,0 @@ -TR = 0.5; -len = 200; - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% Compute hrf -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -t=0.1:TR:30; -a1 =6; -a2 = 12; -b1 = 0.9; -b2 =0.9; -c = 0.35; - -d1 = a1*b1; -d2 = a2*b2; - -h = ((t./d1).^a1).*exp(-(t-d1)./b1) - c*((t./d2).^a2).*exp(-(t-d2)./b2); -h = h./max(h); - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -% X = zeros(len,4); -% X(:,1) = 1; -% X(:,2) = (1:len)/len; -% X(:,3) = X(:,2).^2; -% -% Run = zeros(40,5); -% Run(1:20,:) = 1; -% Run = reshape(Run,200,1); -% q = conv(Run,h); -% X(:,4) = q(1:len); - - -X = zeros(len,2); -X(:,1) = 1; -Run = zeros(200,1); -Run(50) = 1; -Run(150) = 1; -q = conv(Run,h); -X(:,2) = q(1:len); - - -Run = zeros(200,1); -Run(50) = 1; -Run(160) = 1; -tc = conv(Run,h); -tc = tc(1:len) + normrnd(0,0.3,200,1); - - -beta = pinv(X)*tc; -e = tc-X*beta; -sigma = sqrt((1/(len-size(X,2)))*sum(e.^2)); -%c = [0 0 0 1]; -c = [0 1]; -se = sigma.*sqrt(c*inv(X'*X)*c'); -t = beta/se; -pval = 2*tcdf(-abs(t),len-4); -df = len - 4; - -[z sres sres_ns] = ResidScan(e, 4); -[b bias pl pc pe] = BiasPowerloss(tc, X,c,beta,df,z,0.05); - -beta -b -bias -pl diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/cost.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/cost.m deleted file mode 100644 index 589007e6..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/cost.m +++ /dev/null @@ -1 +0,0 @@ -function Q = cost(V,t,tc,Run) % % Least-squares cost function for the IL model % % INPUT: % Run = stick function % tc = time course % t = vector of time points % V = parameters % % OUTPUT: % Q = cost % % By Martin Lindquist and Tor Wager % Edited 12/12/06 % len = length(Run); h1 = Get_Logit(V(1:7),t); % Get IL model corresponding to parameters V yhat = conv(Run, h1); % Convolve IL model with stick function yhat = yhat(1:len); Q = sum((yhat-tc).^2); % Calculate cost function return \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/cost2.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/cost2.m deleted file mode 100644 index 0aa67b4c..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/cost2.m +++ /dev/null @@ -1 +0,0 @@ -function Q = cost2(V,t,tc,RunA,RunB) % % inverse logit -- creates fitted curve from parameter estimates % % t = vector of time points % V = parameters % % 3 logistic functions to be summed together % % h1 = get_logit(V(1:7),t); % h2 = get_logit(V(8:14),t); % h1 = get_logit(V(1:8),t); % h2 = get_logit(V(9:16),t); type = 7; if (type == 7), h1 = get_logit(V(1:7),t); h2 = get_logit(V(8:14),t); elseif(type == 9), h1 = get_logit(V(1:9),t); h2 = get_logit(V(10:18),t); end; len = length(RunA); tcA = conv(RunA, h1); tcA = tcA(1:len); len = length(RunB); tcB = conv(RunB, h2); tcB = tcB(1:len); yhat = tcA + tcB; Q = sum((yhat-tc).^2); return \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/cost_allstim.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/cost_allstim.m deleted file mode 100644 index 1255cd64..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/cost_allstim.m +++ /dev/null @@ -1,33 +0,0 @@ -function Q = cost_allstim(V,t,tc,Run) -% -% Least-squares cost function for the IL model -% -% INPUT: -% Run = stick function -% tc = time course -% t = vector of time points -% V = parameters -% -% OUTPUT: -% Q = cost -% -% By Martin Lindquist and Tor Wager -% Edited 12/12/06 -% Further edited by Christian Waugh 2/15/08 to include multiple trialtypes - -numstim = length(Run); -len = length(Run{1}); -h = zeros(length(t),numstim); -yhatt =zeros(len,numstim); - -for k = 1:numstim - h(:,k) = Get_Logit(V(k*7-6:k*7),t); % Get IL model corresponding to parameters V - yhat(:,k) = conv(Run{k}, h(:,k)); % Convolve IL model with stick function - yhatt(:,k) = yhat(1:len,k); -end - -yhat2 = sum(yhatt,2); %Sum models together to get overall estimate - -Q = sum((yhat2-tc).^2); % Calculate cost function - -return \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/cost_allstim_2.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/cost_allstim_2.m deleted file mode 100644 index 9c98d690..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/cost_allstim_2.m +++ /dev/null @@ -1,43 +0,0 @@ -function Q = cost_allstim_2(V,tr,tc,Run,down) -% -% Least-squares cost function for the IL model -% Multi-condition case -% -% INPUT: -% down = downsampling factor -% Run = stick function -% tc = time course -% tr = repetition time -% V = parameters -% -% OUTPUT: -% Q = cost -% -% By Martin Lindquist and Tor Wager -% Edited 12/12/06 -% Further edited by Christian Waugh 2/15/08 to include multiple trialtypes -% Edited by ML on 02/12/13 - -t = 0:(1/down):30; - -numstim = length(Run); -len = length(Run{1}); -h = zeros(length(t),numstim); -yhatt =zeros(len,numstim); - - -for k = 1:numstim - - h(:,k) = Get_Logit(V(k*7-6:k*7),t); % Get IL model corresponding to parameters V - yhat(:,k) = conv(Run{k}, h(:,k)); % Convolve IL model with stick function - yhatt(:,k) = yhat(1:len,k); - -end - -tt = 1:(tr*down):len; - -yhat2 = sum(yhatt,2); %Sum models together to get overall estimate - -Q = sum((yhat2(tt)-tc).^2); % Calculate cost function - -return \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/cost_allstim_3.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/cost_allstim_3.m deleted file mode 100644 index 003d502d..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/cost_allstim_3.m +++ /dev/null @@ -1,51 +0,0 @@ -function Q = cost_allstim_2(V,input) -% -% Least-squares cost function for the IL model -% Multi-condition case -% -% INPUT: -% down = downsampling factor -% Run = stick function -% tc = time course -% tr = repetition time -% V = parameters -% -% OUTPUT: -% Q = cost -% -% By Martin Lindquist and Tor Wager -% Edited 12/12/06 -% Further edited by Christian Waugh 2/15/08 to include multiple trialtypes -% Edited by ML on 02/12/13 -% Edited by ML on 08/18/18 - - - -tr = input{1}; -tc = input{2}; -Run = input{3}; -down = input{4}; - -t = 0:(tr/down):30; - -numstim = length(Run); -len = length(Run{1}); -h = zeros(length(t),numstim); -yhatt =zeros(len,numstim); - - -for k = 1:numstim - - h(:,k) = Get_Logit(V(k*7-6:k*7),t); % Get IL model corresponding to parameters V - yhat(:,k) = conv(Run{k}, h(:,k)); % Convolve IL model with stick function - yhatt(:,k) = yhat(1:len,k); - -end - -tt = 1:(1*down):len; - -yhat2 = sum(yhatt,2); %Sum models together to get overall estimate - -Q = sum((yhat2(tt)-tc).^2); % Calculate cost function - -return \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/dilogit_dt2.m b/CanlabCore/HRF_Est_Toolbox4/Old_stuff/dilogit_dt2.m deleted file mode 100644 index b0dd817b..00000000 --- a/CanlabCore/HRF_Est_Toolbox4/Old_stuff/dilogit_dt2.m +++ /dev/null @@ -1,35 +0,0 @@ -function [dLdt, d2Ldt2, d1, d2] = dilogit_dt2(t,V) -% -% [dLdt, d2Ldt2, d1, d2] = dilogit_dt2(t,V) -% -% Calculate first and second temoral derivative of inverse logit (IL) HRF model -% curve -% -% INPUT: V, t -% t = vector of time points -% V = parameters -% -% OUTPUT: dLdt, d2Ldt2 -% dLdt = first temporal derivative of IL model -% d2Ldt2 = second temporal derivative of IL model -% -% By Martin Lindquist and Tor Wager -% Edited 12/12/06 -% - -% Set parameter values -A1 = V(1); -T1 = V(2); -d1 = V(3); -A2 = V(4); -T2 = V(5); -A3 = V(6); -T3 = V(7); -d2 = -d1*(ilogit(A1*(1-T1)) - ilogit(A3*(1-T3)))/(ilogit(A2*(1-T2)) + ilogit(A3*(1-T3))); -d3 = abs(d2)-abs(d1); - -% Calculate the first and second derivative -dLdt = d1*A1*ilogit(A1*(t-T1))./(1+exp(A1*(t-T1))) + d2*A2*ilogit(A2*(t-T2))./(1+exp(A2*(t-T2))) + d3*A3*ilogit(A3*(t-T3))./(1+exp(A3*(t-T3))); -d2Ldt2 = d1*(A1^2)*(exp(A1*(t-T1)) - exp(2*A1*(t-T1)))./((1+exp(A1*(t-T1))).^3) + d2*(A2^2)*(exp(A2*(t-T2)) - exp(2*A2*(t-T2)))./((1+exp(A2*(t-T2))).^3) + d3*(A3^2)*(exp(A3*(t-T3)) - exp(2*A3*(t-T3)))./((1+exp(A3*(t-T3))).^3); - -return; \ No newline at end of file diff --git a/CanlabCore/HRF_Est_Toolbox4/get_parameters2.m b/CanlabCore/HRF_Est_Toolbox4/get_parameters2.m index 712d9a7d..2d952db5 100644 --- a/CanlabCore/HRF_Est_Toolbox4/get_parameters2.m +++ b/CanlabCore/HRF_Est_Toolbox4/get_parameters2.m @@ -17,13 +17,13 @@ h = hdrf(p); %if (p > t(end)*0.6*delta), warning('Late time to peak'), end; -%if (p > t(end)*0.8), warning('Late time to peak'), end; +% if (p > t(end)*0.8), warning('Late time to peak'), end; if (h >0) v = (hdrf >= h/2); else v = (hdrf <= h/2); -end +end; [~,b] = min(diff(v)); v(b+1:end) = 0; diff --git a/CanlabCore/HRF_Est_Toolbox4/hrf_fit_one_voxel.m b/CanlabCore/HRF_Est_Toolbox4/hrf_fit_one_voxel.m index e94196fd..e9d573b3 100644 --- a/CanlabCore/HRF_Est_Toolbox4/hrf_fit_one_voxel.m +++ b/CanlabCore/HRF_Est_Toolbox4/hrf_fit_one_voxel.m @@ -1,4 +1,4 @@ -function [h, fit, e, param] = hrf_fit_one_voxel(tc,TR,Runc,T,method,mode) +function [h, fit, e, param, info] = hrf_fit_one_voxel(tc,TR,Runc,T,method,mode, varargin) % function [h, fit, e, param] = hrf_fit_one_voxel(tc,TR,Runc,T,type,mode) % % HRF estimation function for a single voxel; @@ -25,8 +25,32 @@ % fit - estimated time course % e - residual time course % param - estimated amplitude, height and width +% info - Design Matrix, Pseudoinverse information, Misc. % % Created by Martin Lindquist on 04/11/14 +% +% Added a varargin to accept a design matrix to modulate the model fit. +% - Michael Sun, Ph.D. 02/09/2024 + + +if ~isempty(varargin) + % Load in SPM structure if passed in + % timecourse tc must now be gKWY to match SPM output. + % Filtered Design Matrix SPM.xX.xKXs.X must be passed in instead of + % allowing Fit_* to generate its own design matrix. + + % if ischar(varargin{1}) || isstring(varargin{1}) + % load(varargin{1}) + % elseif isstruct(varargin{1}) + % + % SPM=varargin{1}; + % end + % + % SPM.xX.xKXs.X + + % SPM_DX=varargin{1}; + +end if (strcmp(method,'IL')), @@ -41,7 +65,21 @@ % Please note that when using simulated annealing approach you % may need to perform some tuning before use. - [h, fit, e, param] = Fit_Logit2(tc,TR,Runc,T,mode); + if numel(T) > 1 + if all(abs(T(:) - T(1)) < 1e-8) + T = T(1); + else + error('Multiple time windows not yet implemented for IL: T values must be identical within tolerance.'); + end + end + + if ~isempty(varargin) + disp('IL-function with custom Design Matrix not yet implemented') + [h, fit, e, param] = Fit_Logit2(tc,TR,Runc,T,mode); + else + [h, fit, e, param] = Fit_Logit2(tc,TR,Runc,T,mode); + end + param(2:3,:) = param(2:3,:).*TR; @@ -55,7 +93,17 @@ % mode = 1; % 0 - FIR % 1 - smooth FIR - [h, fit, e, param] = Fit_sFIR(tc,TR,Runc,T,mode); + % [h, fit, e, param] = Fit_sFIR(tc,TR,Runc,T,mode); + + if ~isempty(varargin) + [h, fit, e, param, info] = Fit_sFIR_epochmodulation(tc,TR,Runc,T,mode, varargin{:}); + + else + [h, fit, e, param, info] = Fit_sFIR_epochmodulation(tc,TR,Runc,T,mode); + end + + + param(2:3,:) = param(2:3,:).*TR; @@ -68,8 +116,23 @@ % 1 - HRF + temporal derivative % 2 - HRF + temporal and dispersion derivative - p = mode + 1; - [h, fit, e, param] = Fit_Canonical_HRF(tc,TR,Runc,T,p); + p = mode + 1; + + if numel(T) > 1 + if all(abs(T(:) - T(1)) < 1e-8) + T = T(1); + else + error('Multiple time windows not yet implemented for CHRF: T values must be identical within tolerance.'); + end + end + + if ~isempty(varargin) + [h, fit, e, param, info] = Fit_Canonical_HRF(tc,TR,Runc,T,p, varargin{:}); + else + [h, fit, e, param, info] = Fit_Canonical_HRF(tc,TR,Runc,T,p); + end + + param(2:3,:) = param(2:3,:).*TR; else diff --git a/CanlabCore/RSA_tools/README.md b/CanlabCore/RSA_tools/README.md new file mode 100644 index 00000000..e0e408a0 --- /dev/null +++ b/CanlabCore/RSA_tools/README.md @@ -0,0 +1,282 @@ +# CanlabCore RSA / RSM Tools + +First-class Representational Similarity Analysis for CanlabCore. Build +representational similarity/dissimilarity matrices from `fmri_data` objects, +run the full inference battery (reliability, within/between contrasts, +drift, multi-level LME, formal RDM comparison), and project results onto the +brain as `statistic_image` maps. + +Generalizes ~12 bespoke RSA workflows (Sun et al. 2026 pain-imagination +study and the WASABI / Novus / DistractMap / acceptance studies) into a +single coherent, reusable extension. + +--- + +## Install / setup + +The tools live inside CanlabCore. Just add CanlabCore to your path: + +```matlab +addpath(genpath('/path/to/CanlabCore')); +``` + +Check optional dependencies (everything works without them via built-in +fallbacks): + +```matlab +rsa_toolbox_status +``` + +| Dependency | Used for | Fallback if absent | +|---|---|---| +| Kriegeskorte **rsatoolbox** | rank-transformed plots, MDS, `compareRefRDM2candRDMs` | built-in plotting + `rsm.compare` engine | +| **ICC.m** (File Exchange) | reliability | built-in ICC(2,k)/ICC(3,k) | +| **Statistics Toolbox** `fitlme` | multi-level LME | **required** for LME methods | +| **Statistics Toolbox** `rangesearch` | fast searchlight | O(n²) loop | + +> **Reloading after edits:** if you change any `@rsm`, `@fmri_data`, or +> `@image_vector` method, run `clear classes; rehash path` and re-cast any +> existing data objects: `dat = fmri_data_st(struct(dat));` + +--- + +## The `@rsm` object + +The container for similarity (RSM) or dissimilarity (RDM) matrices: + +``` +dat k×k or k×k×N matrix (N = subjects/sessions/runs) +is_dissimilarity true => RDM, false => RSM +metric 'correlation' | 'spearman' | 'crossnobis' | ... +labels {k×1} condition names +metadata_table k-row per-condition attributes +groupings struct: name -> indices (auto-built by compute_rsm) +replicate_table N-row description of the 3rd dim +level 'subject' | 'session' | 'run' | 'collapsed' +additional_info .qc diagnostics (missing-condition report) +``` + +Value semantics: every method returns a new `rsm`; nothing mutates in place. + +--- + +## Quick start + +```matlab +% 1. Build a per-subject RSM (24 conditions = 3 conditions x 8 bodysites) +R = compute_rsm(dat, 'group_by', {'condition','bodysite'}, ... + 'subject_var', 'sub', 'metric', 'spearman'); + +% 2. Visualize the group-mean geometry +figure; plot(mean(R), 'block_borders_by', 'condition'); + +% 3. Within vs between condition contrasts (FDR-corrected) +spec = { 'within_hot', 'hot', []; 'hot_vs_warm', 'hot', 'warm' }; +T = R.ttest_contrasts(spec, 'tail', 'right', 'correction', 'fdr'); + +% 4. Reliability across sessions (per-subject ICC, then aggregate) +R_sess = compute_rsm(dat, 'group_by', {'condition','bodysite'}, ... + 'level', 'session', 'subject_var', 'sub', ... + 'session_var', 'sesno', 'metric', 'spearman'); +T_rel = R_sess.reliability_by_grouping; + +% 5. Multi-level LME +mdl = dat.rsa_lme('predictors', {'condition','bodysite','sesno'}, ... + 'subject_var', 'sub'); + +% 6. Formal RDM comparison to model RDMs +result = R.compare({'condition','bodysite'}, 'correlation_type','kendall_taua'); + +% 7. Parcelwise brain maps +atlas = load_atlas('canlab2024'); +res = dat.rsa_parcelwise('atlas', atlas, 'group_by', {'condition','bodysite'}, ... + 'subject_var', 'sub', 'contrasts', spec); +% Call threshold() with FUNCTION syntax: statistic_image has both a +% `threshold` property and a `threshold` method, so `map.threshold(...)` +% indexes the property instead of thresholding (a CanlabCore-wide quirk). +montage(threshold(res.maps.hot_vs_warm, 0.05, 'unc')); +``` + +--- + +## Method reference + +### Construction (Phase 1) + +| Call | Purpose | +|---|---| +| `compute_rsm(dat, ...)` | Omnibus RSM builder. Metrics: `correlation, spearman, cosine, euclidean, mahalanobis, crossnobis, cvcorr, cvspearman`. `cvcorr`/`cvspearman` are **cross-validated (cross-session) correlations** (require `fold_var`): each cell is the mean correlation of the two conditions taken from *different* folds, so within-fold/within-run shared structure never inflates it — the correlation-space analogue of crossnobis. Levels: `subject, session, run, collapsed`. Options: `whiten, diagonal_correction, parcellation, mask, nan_policy`. | +| `rsm.from_categorical(meta, cols)` | Same-vs-different model RDM(s) from metadata columns. | +| `rsm.from_metadata_distance(meta, col)` | Continuous-distance model RDM (e.g. session distance). | +| `rsm.from_design(X, ...)` | Model RDM(s) from a design matrix. | +| `R.plot(...)` | Heatmap (raw/rank), MDS, dendrogram, grid; block borders, matched-pair overlays. MDS defaults to a clean built-in cmdscale scatter (`'mds_engine','rsatoolbox'` to use rsa.fig.MDSConditions). | +| `R.mean / fisher_z / to_rdm / to_rsm / subset / reorder` | Transforms. | +| `R.get_by_label(name)` | Look up a parcel in an rsm array by name. | + +### Cell extraction & inference (Phase 2) + +| Call | Purpose | +|---|---| +| `R.cells(A, B)` | Per-replicate scalar from within/between cells (Fisher-z mean). | +| `R.cells_table(spec)` | Multiple groupings -> per-replicate table. | +| `R.contrast(A, [B])` | Contrast scalar per replicate. | +| `R.ttest_contrasts(spec, ...)` | Battery of paired/one-sample t-tests + FDR. | +| `R.reliability(...)` | ICC across replicates (per-subject by default). | +| `R.reliability_by_grouping / reliability_per_condition` | Per-group / per-row ICC. | +| `R.drift(...)` | Within-condition stability over the replicate axis + linear fit. | +| `rsa_group_inference(matrix, ...)` | Standalone group stats for raw per-subject scalars. | +| `plot_rsm_contrast_bars(T_or_R, ...)` | Bar plot with within-subject lines + sig markers. | + +### Multi-level modeling (Phase 3) + +| Call | Purpose | +|---|---| +| `dat.rsa_lme(formula_or_nv, ...)` | Random-effects LME on within-subject RDM cell pairs. | +| `dat.rsa_lm(...)` | Fixed-effects model (all pairs incl. cross-subject). | +| `dat.rsa_lm_by_subject(...)` | Per-subject fits + partial R², for 2nd-level inference. | +| `dat.rsa_compare_models(formulas, ...)` | Nested LRT + AIC/BIC ladder (auto-ML). | +| `rsa_model_sequence(base)` | Build the nested-formula list imperatively. | +| `rsa_lme_icc(mdl) / rsa_lme_blups(mdl)` | Variance components / per-subject BLUPs. | +| `rsa_partial_r2(mdl, tbl)` | Per-predictor partial R² + Cohen's d. | +| `assemble_lme_table(dat, ...)` | The long-format pair table (engine; also exported). | + +Interaction columns are named by joining the main-effect columns with `x`, +e.g. `SameConditionxSameBodysite`. Either naming form is accepted in +formulas; a clear error lists available columns on a typo. + +### Brain maps (Phase 4) + +| Call | Purpose | +|---|---| +| `dat.rsa_parcelwise('atlas', a, 'contrasts', spec, ...)` | Per-parcel contrasts -> `statistic_image` per contrast, FDR across parcels. | +| `dat.rsa_parcelwise('atlas', a, 'lme', formula, ...)` | Per-parcel LME -> `statistic_image` per term. | +| `dat.searchlight_rsa(model, ...)` | Searchlight RSM-vs-model correlation maps; group/subject level, permutation inference. | +| `assign_vals_to_atlas(atlas, names, vals, ...)` | Project per-parcel values onto a brain map. | + +### Formal RDM comparison (Phase 5) + +| Call | Purpose | +|---|---| +| `R.compare(models, ...)` | Nili et al. (2014) framework: per-subject correlation (Kendall τ-a / Spearman / Pearson), relatedness test (subject RFX signed-rank), pairwise differences test, noise ceiling, FDR. | +| `rsa_toolbox_status` | Report optional-dependency availability. | + +--- + +## Workflow → method map + +| Original workflow | Now | +|---|---| +| `generate_RSA*.m` (4 variants) | one `compute_rsm` call | +| Sun et al. Figs 7A–C | `compute_rsm` + `plot` | +| Sun et al. Figs 7D–F | `ttest_contrasts` + `plot_rsm_contrast_bars` | +| Sun et al. Figs 7G / S8 / S9 | `rsa_parcelwise` + `.montage` | +| `01282025 RSM Reliability` | `reliability_by_grouping` | +| `08192024 Representational Drift` | `R.drift` | +| `10252024 Whitening Walkthrough` | `compute_rsm(..., 'whiten','within_subject')` | +| `generate_RSA_accept_crossnobis` | `compute_rsm(..., 'metric','crossnobis')` | +| `08072024 Run-Level RDM Analysis` (LME) | `rsa_lme` + `rsa_compare_models` | +| RSAToolbox recipe (RDM comparison) | `R.compare` | + +--- + +## Notes on statistics + +- **Reliability ICC** uses pairs of conditions as items, replicates as + raters. Per-subject ICC (across that subject's sessions) is the default; + the pooled-across-all-replicates form is `'pool','replicate'`. Groupings + with <5 cells (e.g. single-bodysite) produce unreliable ICCs — flagged + with a warning. +- **LME rows** are within-subject condition-pair similarities; `Subject` is + the random-effects grouping. Cross-subject pairs only enter `rsa_lm`. +- **compare** works in dissimilarity space; positive correlation = the + brain's geometry matches the model. Kendall τ-a is the default per Nili + et al. (2014) for categorical models with tied ranks. +- **searchlight** converts each sphere's similarity RSM to dissimilarity + before comparing to model RDMs. + +--- + +## See also + +- `RSA_Pipeline_Phased_Plan.md` — the design document. +- `RSA_Phase3_LME_Design.md` — LME table contract and API spec. +- `examples/` — runnable example scripts: + - `rsa_quickstart.m`, `rsa_reliability_icc.m`, `rsa_multilevel_lme.m`, + `rsa_parcelwise_maps.m` — synthetic-data tutorials. + - `rsa_distractmap_pipeline.m` — full pipeline on the WASABI DistractMap + dataset (multi-session reliability + contrasts + LME + parcelwise maps + + searchlight). + - `rsa_acceptmap_pipeline.m` — full pipeline on the WASABI AcceptMap + dataset (crossnobis RDM + model comparison + parcelwise maps + + searchlight; shows how to handle a factor confounded with session). + - `rsa_bodymap_pipeline.m` — full pipeline on the WASABI BodyMap dataset + (Sun et al. 2026; 8 bodysites x 3 conditions). Reproduces Figs 7A–G / + S8 / S9 and removes the SHARED-RUN confound (Hot/Warm/Imagine co-occur + in one run, so their similarity is run-inflated ~8x) two ways: crossnobis + with session(=run) folds, and a whole-brain LME with a same-run nuisance. + Mirrors the published cross-session masking, but as one unbiased step. +- `tests/` — unit tests (`test_rsa_tools.m`, 12 tests). + +## Removing a shared-run (or shared-session) confound + +When several conditions are estimated from the **same run** (e.g. BodyMap's +Hot/Warm/Imagine always co-occur in one run), they share that run's noise and +mean response. This inflates their cross-condition similarity — often +dramatically (~8x in BodyMap S1) — and biases specificity. The rule is to +never compare two patterns from the same run; only across runs/sessions. +If the repeated factor is **fully crossed** with run/session (every condition +appears in every session, as in BodyMap), the effect is removable two ways: + +```matlab +% (1) crossnobis with CV folds = sessions (== runs here). The within-fold +% condition difference cancels the run-common component; unbiased by design. +R = compute_rsm(dat, 'group_by', {'condition','bodysite'}, 'subject_var','sub', ... + 'metric','crossnobis', 'fold_var','sesno'); + +% (2) keep correlations; add a SAME-RUN nuisance to the mixed model. +% runid uniquely identifies a run (here: session x bodysite). +dat.metadata_table.runid = categorical(strcat(string(dat.metadata_table.ses), ... + '_', string(dat.metadata_table.bodysite))); +mdl = dat.rsa_lme('predictors', {'bodysite','condition','runid'}, 'subject_var','sub'); +% SameRunid absorbs the within-run inflation; read SameBodysite / SameCondition +% (the ACROSS-run effects) controlling for it. + +% (3) cross-session CORRELATION RSM (paper's metric family, run-clean): +R = compute_rsm(dat, 'group_by', {'condition','bodysite'}, 'subject_var','sub', ... + 'metric','cvspearman', 'fold_var','sesno'); % or 'cvcorr' (Pearson, faster) +% each cell correlates the two conditions from DIFFERENT sessions only. +% 'cv_scheme','loo' (leave-one-fold-out vs mean-of-rest) is a higher-SNR +% variant; 'allpairs' (default) is most conservative. On BodyMap the two are +% nearly identical (limit is between-subject n, not the CV scheme). +``` + +Options (1) and (3) reproduce the published BodyMap approach (build per-run +RSMs, exclude within-run correlations) in one step. **Caveat on power:** a +fully cross-validated cell is estimated from single-run patterns, so it is +much noisier than a session-pooled (within-sample) correlation. On BodyMap the +run-clean HI>HW map is the SAME effect/direction as the session-pooled one +(t-map r~0.6) but far fewer regions survive FDR at n=9 -- the loss is mostly +statistical power, not confound-in-the-contrast (the run inflation cancels in +HI-HW). Prefer the run-clean version and interpret pooled results as +power-inflated. If the factor is instead **confounded** +with session (each session has only one level), session-to-session +reliability is undefined — see the AcceptMap note above. See +`examples/rsa_bodymap_pipeline.m` for the full treatment. + +## Shared-anchor / idiosyncratic-condition designs + +Some studies give every subject a shared reference condition plus an +idiosyncratic one that differs per subject (e.g. all subjects have "Left +Face" plus one other bodysite each). Naive `group_by` then explodes k and +drops every replicate. Recode the factor to two levels with +`rsa_recode_reference`: + +```matlab +T.bodysite_type = rsa_recode_reference(T.bodySite, 'Left Face', ... + 'other_label', 'Other Body Site'); +``` + +If the experimental factor is **confounded with session** (each session has +only one level), session-to-session reliability is undefined — analyze it as +a crossnobis RDM instead, with `'fold_var','occurrence'` to build folds from +within-condition image repeats. See `examples/rsa_acceptmap_pipeline.m`. diff --git a/CanlabCore/RSA_tools/RSA_Phase3_LME_Design.md b/CanlabCore/RSA_tools/RSA_Phase3_LME_Design.md new file mode 100644 index 00000000..f2e582d2 --- /dev/null +++ b/CanlabCore/RSA_tools/RSA_Phase3_LME_Design.md @@ -0,0 +1,388 @@ +# RSA Phase 3 — Multi-Level / LME Design Specification + +**Author:** Michael Sun (drafted with Claude, May 2026) +**Audience:** Tor (review), Michael (execution) +**Status:** Design doc for review before any Phase 3 implementation begins +**Parent plan:** [RSA_Pipeline_Phased_Plan.md](RSA_Pipeline_Phased_Plan.md) + +This document fleshes out the multi-level modeling phase. The other four phases are stable; this one was promoted from "implicit" to its own phase after reviewing the corpus and is large enough to warrant a dedicated spec. + +The reference workflow is `C:\Temp\08072024 Run-Level RDM Analysis with RSA Toolbox.mlx`. The contract below mirrors what that workflow actually does, generalized to be paradigm-agnostic. + +--- + +## 1. The contract — what an LME row represents + +The single design decision that resolves everything else: + +> **One LME row = one (i, j) upper-triangle pair from a single subject's image-level RSM.** + +Cross-subject pairs (where image i is from subject A and image j is from subject B) are **excluded** from the LME table. This is what makes `Subject` a clean random-effects grouping variable: every row belongs unambiguously to one subject, so `(1 | Subject)` and `(Condition | Subject)` random-effects terms have well-defined semantics. + +Cross-subject pairs are still available — they feed the *fixed-effects* `rsa_lm` model (which has `SameSubject` as one of its categorical predictors). They just don't participate in the LME. + +### Schematic of the LME table + +For a study with N_s subjects, where subject *s* has n_s images, the LME table has: + +``` +∑_s [n_s × (n_s − 1) / 2] rows +``` + +For the WASABI corpus reference (9 subjects × ~135 images each = ~1215 images total): **per-subject pair count ≈ 9050, summed ≈ 81,000 rows**. `fitlme` handles this comfortably. + +Columns: + +| Column | Type | Meaning | +|---|---|---| +| `Y` | double | Similarity at pair (i, j); Fisher-z optionally applied | +| `Subject` | categorical | Subject ID — random-effects grouping | +| `
` | binary 0/1 | 1 iff i and j share that level (e.g. `SameSession`) | +| `
` | binary 0/1 | same | +| `...` | | | +| `` | binary 0/1 | Element-wise AND of constituent main effects | + +Conventionally we drop the `Same` prefix — `Session` is shorthand for "i and j share a session." Documented in the README. + +--- + +## 2. Public API surface + +### 2.1 — Top-level entry points + +Two equivalent ways to fit an LME, depending on what the user already has: + +```matlab +% Path A: one-shot from an fmri_data object +mdl = rsa_lme(dat, formula, varargin) % @fmri_data method +% Builds per-subject image-level RSMs internally, then assembles the LME table. + +% Path B: from a precomputed image-level rsm +mdl = rsa_lme(R, formula, varargin) % @rsm method +% R must have been produced with level='image' (one image per row, no condition collapsing). +``` + +Both share a common internal helper `assemble_lme_table` (see §3). + +### 2.2 — Common varargin (both paths) + +```matlab +% 'predictors' cellstr of metadata columns to include as main-effect RDMs. +% Default: all categorical columns in metadata_table except +% the subject_var. +% Example: {'session_number','run_number','condition','bodysite'} +% +% 'interactions' cell of cellstr — each inner cell is a set of predictor names +% to combine via element-wise AND. +% Default: {} (no interactions). +% Example: {{'condition','bodysite'}, {'session_number','condition'}} +% +% 'three_way' cell of cellstr — same as 'interactions' but length 3. +% Example: {{'subject_id','condition','bodysite'}} +% +% 'subject_var' string. Metadata column identifying subjects. +% Default: 'subject_id'. +% +% 'response_transform' 'none' | 'fisherz' | 'rank'. +% Default: 'fisherz' (matches the corpus). +% +% 'fit_method' 'REML' | 'ML'. +% Default: 'REML'. +% +% 'standardize' logical. Standardize predictors before fit. +% Default: false. +% +% 'use_parallel' logical. Build the per-subject blocks in parallel (parfor). +% Default: false. Per-subject blocks are independent. +% +% 'subsample' [] | positive integer N | fraction 0~10^6 rows). +% Default: [] (no subsampling). +% +% 'stratify_subsample' cellstr. When 'subsample' is set, stratify the draw by these +% columns to preserve marginal balance. +% Default: {} (uniform). +% +% 'verbose' logical. Default: true. +``` + +### 2.3 — Formula handling + +Two forms accepted: + +```matlab +% Form 1 — explicit Wilkinson formula (most flexible) +mdl = rsa_lme(dat, 'Y ~ Session + Run + Condition + Bodysite + (1 | Subject)'); + +% Form 2 — assembled from name-value args (no manual formula string) +mdl = rsa_lme(dat, ... + 'fixed_effects', {'session_number','run_number','condition','bodysite'}, ... + 'random_effects', {'(1 | subject_id)'}, ... + 'interactions', {{'condition','bodysite'}}); +% Internally constructs: 'Y ~ Session + Run + Condition + Bodysite + CondxBodysite + (1|Subject)' +``` + +Form 2 is friendlier; Form 1 is the escape hatch. The internal `rsa_lme_formula_parser` (RSA_tools/) builds Form 1 from Form 2 — exposed as a public utility for users who want to preview the formula before fitting. + +### 2.4 — Fixed-effects variant: `rsa_lm` + +Same shape, but pools across all (i,j) pairs (including cross-subject) and uses `fitlm`: + +```matlab +mdl = rsa_lm(dat, ... + 'predictors', {'subject_id','session_number','run_number','condition','bodysite'}, ... + 'interactions', {{'condition','bodysite'}, {'session_number','condition'}}); +% Internally: builds the omnibus RSM (corr(dat.dat)), vectorizes its upper triangle, +% builds categorical RDMs for each predictor + interaction, calls fitlm. +% Returns LinearModel. +``` + +Note `subject_id` is *one of the predictors* here (the "SameSubject" categorical RDM), not the random-effects grouping. This is the workflow's lines 1730–1737 pattern. + +### 2.5 — Model comparison: `compare_models` + +```matlab +[T, best] = R.compare_models( ... + {'Y ~ 1 + (1|Subject)'; + 'Y ~ Condition + (1|Subject)'; + 'Y ~ Condition + Bodysite + (1|Subject)'; + 'Y ~ Condition*Bodysite + (1|Subject)'; + 'Y ~ Condition*Bodysite + Session + (1|Subject)'; + 'Y ~ Condition*Bodysite + Session + (Condition|Subject)'; + 'Y ~ Condition*Bodysite + Session*(Condition+Bodysite) + (Condition|Subject)'; + }, ... + 'criteria', {'aic','bic','loglik','lrt'}, ... + 'lrt_pairs', 'sequential'); % 'sequential' compares each to previous; 'vs_first' compares all to first + +% T columns: model | n_params | aic | bic | loglik | lrt_chi2 | lrt_df | lrt_p +% best: index of model with lowest AIC (or BIC; configurable via 'select_by') +``` + +`compare_models` uses MATLAB's `compare(LME1, LME2)` under the hood for nested LRTs. + +### 2.6 — Per-subject LM fits: `rsa_lm_by_subject` + +For when you want the per-subject fixed-effects fits and don't want to bother with LME: + +```matlab +% Fits the same fitlm separately per subject, returns aggregated stats +T = rsa_lm_by_subject(dat, ... + 'predictors', {'session_number','run_number','condition','bodysite'}, ... + 'interactions', {{'condition','bodysite'}}); + +% T: subject_id | term | beta | se | t | p | partial_R2 | full_R2 +% This is the per-subject loop in 08072024 lines 1879–1900. +``` + +Group-level inference on this output: paired t-test of betas across subjects (uses `rsa_group_inference` from Phase 2). + +### 2.7 — Convenience: ICC and BLUP extraction + +The workflow extracts subject-specific ICCs and BLUPs from the fitted LME (lines 2184–2280). Expose as a method on the returned `LinearMixedModel`: + +```matlab +% Not a new method — just a free helper since we can't extend LinearMixedModel: +icc = rsa_lme_icc(mdl); +% icc.intercept_subject — variance(Subject random intercept) / total variance +% icc.slope__subject — same for random slopes +% icc.per_subject_table — BLUP-based per-subject ICC + +blups = rsa_lme_blups(mdl); +% table: subject | term | estimate | se +``` + +--- + +## 3. Internal contract — `assemble_lme_table` + +The single helper that produces the table. Concrete signature: + +```matlab +function [tbl, info] = assemble_lme_table(rsm_3d, metadata_per_image, subject_var, ... + predictors, interactions, three_way, ... + response_transform) +% Inputs +% rsm_3d [k_s × k_s × N_subjects] cell array — one image-level RSM per subject +% (k_s = number of images for that subject; can vary across subjects) +% OR: [N_total × N_total] omnibus RSM with subject_id in metadata +% (auto-detected) +% metadata_per_image table with N_total rows (one per image); columns include +% subject_var + all predictors used +% subject_var string — name of the subject column +% predictors cellstr — main-effect predictor names +% interactions cell of cellstr — pairwise interactions to construct +% three_way cell of cellstr — three-way interactions to construct +% response_transform 'none' | 'fisherz' | 'rank' +% +% Outputs +% tbl LME-ready table. Columns: Y, Subject, , …, +% , … +% info struct with bookkeeping: +% .n_pairs_per_subject [N_subjects x 1] +% .predictor_provenance struct(predictor -> metadata column) +% .formula_skeleton suggested default formula +``` + +Per-subject block construction: + +```matlab +for s = 1:N_subjects + is_s = metadata_per_image.(subject_var) == subjects(s); + meta_s = metadata_per_image(is_s, :); + R_s = rsm_3d{s}; % k_s x k_s + + y = upper_triangle(R_s); % [n_pairs_s × 1] + if strcmp(response_transform, 'fisherz'), y = atanh(y); end + + block = table; + block.Y = y; + block.Subject = repmat(subjects(s), n_pairs_s, 1); + + % Main effects + for p = 1:numel(predictors) + rdm = categorical_rdm(meta_s.(predictors{p})); % k_s x k_s binary + block.(predictor_short_name(predictors{p})) = upper_triangle(rdm); + end + + % Pairwise interactions = elementwise AND + for k = 1:numel(interactions) + terms = interactions{k}; + v = ones(n_pairs_s, 1); + for t = 1:numel(terms), v = v .* block.(predictor_short_name(terms{t})); end + block.(interaction_name(terms)) = v; + end + + % Three-way interactions: same logic over 3 terms + ... + + tbl = [tbl; block]; %#ok +end + +% Subject must be categorical for fitlme to treat it as a grouping variable +tbl.Subject = categorical(tbl.Subject); +``` + +`categorical_rdm(v)` reproduces `rsa.rdm.categoricalRDM` semantics: returns binary `k × k` matrix where entry (i,j) is 1 iff `v(i) == v(j)` and i≠j. Implemented as `double(v(:) == v(:)')` with zeroed diagonal — no rsatoolbox dependency. + +`upper_triangle(M)` returns `M(triu(true(size(M)), 1))` — the workflow's idiom verbatim. + +`predictor_short_name` strips conventional suffixes: `session_number → Session`, `subject_id → Subject` (rejected as conflict in LME mode; auto-renamed in lm mode), `condition → Condition`, etc. Override via `'short_names'` name-value pair. + +`interaction_name({'condition','bodysite'}) → 'CondxBodysite'` to match workflow naming. Configurable. + +--- + +## 4. Nested-model spec format + +Three input shapes for `compare_models`: + +### 4.1 — Cellstr of Wilkinson formulas (verbose, explicit) + +```matlab +{'Y ~ 1 + (1|Subject)'; + 'Y ~ Condition + (1|Subject)'; + 'Y ~ Condition + Bodysite + (1|Subject)'} +``` + +### 4.2 — Cell of structs (programmatic) + +```matlab +{ struct('fixed', {{}}, 'random', {{'(1|Subject)'}}); + struct('fixed', {{'condition'}}, 'random', {{'(1|Subject)'}}); + struct('fixed', {{'condition','bodysite'}}, 'random', {{'(1|Subject)'}}); + struct('fixed', {{'condition','bodysite','condition:bodysite'}}, ... + 'random', {{'(condition|Subject)'}}); +} +``` + +Each struct is converted to a Wilkinson string by the parser. Use `:` for interactions, `*` for main + interaction. + +### 4.3 — Build sequence: `add_term` + +```matlab +seq = rsa_model_sequence('Y ~ 1 + (1|Subject)'); +seq = seq.add_term('Condition'); +seq = seq.add_term('Bodysite'); +seq = seq.add_term('Condition:Bodysite'); +seq = seq.add_term('Session'); +seq = seq.add_term('Session:Condition'); +seq = seq.add_term('Session:Bodysite'); +seq = seq.upgrade_random({'(Condition|Subject)'}); + +formulas = seq.formulas; % cellstr ready for compare_models +[T, best] = R.compare_models(formulas); +``` + +This is sugar around 4.1; users who don't want to write 10 formulas by hand can build a sequence imperatively. Mirrors what the workflow does in spirit but makes the additivity explicit. + +--- + +## 5. Validation strategy + +Every Phase 3 method validated against the `08072024` workflow on a fixed reference dataset (extract once, save to `tests/fixtures/lme_reference.mat`): + +### 5.1 — Numerical reproduction + +| Workflow line | Test | Acceptance | +|---|---|---| +| Lines 1804–1832 (per-subject categorical RDMs) | `assemble_lme_table` on reference data | Each main-effect column matches `binRDM_*_sub{i}` vectorized to bit | +| Lines 1880–1884 (per-subject regressor stack) | `assemble_lme_table` interaction columns | `CondxBodysite` etc. match to bit | +| Lines 1886–1900 (per-subject fitlm fits) | `rsa_lm_by_subject` | β, SE, p match within MATLAB's default tolerances | +| Lines 1944–1957 (LME table assembly) | `assemble_lme_table` table | Same rows, same columns, same values | +| Line 2129 (basic LME REML fit) | `rsa_lme(R, 'Y ~ Session + Run + Condition + Bodysite + (1|Subject)')` | LME coefficients, log-likelihood, REML criterion match | +| Lines 2226–2249 (BLUPs and subject-specific ICCs) | `rsa_lme_blups`, `rsa_lme_icc` | Match to MATLAB's default tolerances | + +### 5.2 — Subsampling correctness + +For a small synthetic dataset: +- Fit LME on full table → record coefficients. +- Fit LME on 50% stratified subsample → coefficients should be within 1 SE of full-data coefficients on average over 100 random seeds. + +### 5.3 — Soft-dep correctness + +Run all tests twice: once with rsatoolbox on path (uses `rsa.rdm.categoricalRDM`), once with it removed (uses our `fallback_categoricalRDM`). Outputs must agree to bit. + +### 5.4 — MATLAB MCP live verification + +The `mcp__MATLAB__run_matlab_file` tool lets us execute the reference workflow's exact LME calls and our `rsa_lme` calls side-by-side and diff their `disp(mdl)` outputs. Use this for the Phase 3 acceptance gate. + +--- + +## 6. Resolved decisions *(closed 2026-05-29)* + +All six open questions resolved. Lock these in for Phase 3 implementation; no further sign-off needed before code starts. + +1. **Per-subject row counts vary** → **No weighting by default; expose `'weight_by_subject', true`.** Matches `fitlme`'s native behavior and the `08072024` workflow. LME's random effects already absorb subject-level variance; explicit weighting is an opt-in. + +2. **`Subject` conflict in `rsa_lme`** → **Error with clear message.** If `subject_id` (or whatever `subject_var` is set to) appears in `predictors` for `rsa_lme`, throw: + > `subject_id is the random-effects grouping in rsa_lme; remove it from 'predictors' or use rsa_lm if you want Subject as a fixed effect.` + + No silent coercion. Same-vs-different "SameSubject" is reachable only via `rsa_lm`. + +3. **Default `response_transform`** → **`'fisherz'`.** LME residuals are unbounded under Fisher-z; bounded under raw correlations. Users who want raw correlations (to match the `08072024` numbers exactly) pass `'response_transform','none'`. + +4. **Continuous-distance predictors** → **Add `rsm.from_metadata_distance` in Phase 1.** Single distance metric (`'abs_diff'`) to start; the broader `{abs_diff, squared_diff, log_diff, custom_fcn}` set is a future extension. Lets users model session-distance, run-distance, time-since-baseline, etc. as continuous predictors. + ```matlab + M = rsm.from_metadata_distance(meta, 'session_number', 'metric', 'abs_diff'); + % Then in rsa_lme: + mdl = R.rsa_lme('predictors', {'condition','bodysite'}, ... + 'numeric_predictors', {M}, ... + 'random_effects', {'(1|Subject)'}); + ``` + +5. **Parcelwise LME output granularity** → **Long table + `statistic_image` per term.** + ```matlab + results.lme_table % long: parcel | term | beta | se | t | p | FDR_P | sig + results.maps. % statistic_image (beta or t per parcel — configurable) + ``` + Per-parcel `LinearMixedModel` objects are not retained by default (memory); enable with `'keep_models', true` for deep inspection. + +6. **Cross-subject pairs** → **Top-level `rsa_lm` uses all pairs (workflow default); `rsa_parcelwise(dat, 'lm', ...)` uses within-subject only (matches LME).** Both expose `'pair_scope', 'within_subject' | 'all'` for override. + +--- + +## 7. Status + +**Signed off 2026-05-29.** All §6 questions resolved with recommended defaults; design is complete. Phase 3 implementation can proceed independently of (and in parallel with) Phase 1 — `assemble_lme_table` only needs an `fmri_data` object plus the metadata table; it doesn't depend on the rest of `@rsm`. diff --git a/CanlabCore/RSA_tools/RSA_Pipeline_Phased_Plan.md b/CanlabCore/RSA_tools/RSA_Pipeline_Phased_Plan.md new file mode 100644 index 00000000..06ad1259 --- /dev/null +++ b/CanlabCore/RSA_tools/RSA_Pipeline_Phased_Plan.md @@ -0,0 +1,686 @@ +# RSA/RSM Toolbox: Phased Extension Plan + +**Author:** Michael Sun (drafted with Claude, May 2026) +**Audience:** Tor (review), Michael (execution) +**Scope:** New `RSA_tools/` subfolder + `@rsm` class + new methods on `@fmri_data`, `@image_vector`, `@statistic_image`. Generalizes ~12 bespoke RSA workflows (`2026_Sun_et_al_Pain_Imagination/.../Representational Similarity Analysis.mlx`, four `generate_RSA*.m` paradigm wrappers, plus the WASABI / Novus / DistractMap / acceptance studies represented by the dozen `*RSA*.mlx` notebooks in `C:\Temp`) into a single coherent CanlabCore extension. + +--- + +## At a glance + +Five phases, each shippable independently: + +| Phase | Workstream | Deliverable | Effort | Blocks | +|---|---|---|---|---| +| 1 | `@rsm` class + RSM/RDM construction | Class skeleton; `compute_rsm` with metrics `{correlation, spearman, cosine, euclidean, mahalanobis, crossnobis}` + within-subject whitening; `rsm.from_design`, `rsm.from_categorical`, `rsm.from_table`; `rsm.plot` | Medium (5–7 days) | nothing — ship after sign-off | +| 2 | Cell extraction + group inference + reliability | `rsm.cells`, `rsm.contrast`, `rsm.ttest_contrasts`, `rsm.reliability` (ICC), `rsm.drift`, `rsa_group_inference` | Medium (5–7 days) | Phase 1 | +| 3 | Multi-level / LME modeling | `rsm.rsa_lm` (fixed-fx `fitlm`), `rsm.rsa_lme` (random-fx `fitlme`), `rsm.build_design_rdms`, model comparison + nested LRT helpers | Medium-large (1–1.5 weeks) | Phase 2 | +| 4 | Parcelwise + searchlight + brain maps | `fmri_data.rsa_parcelwise`, `image_vector.searchlight_rsa`, `assign_vals_to_atlas`, `region2statistic_image` | Medium-large (1–1.5 weeks) | Phase 3 | +| 5 | Kriegeskorte interop polish + docs + tests | Soft-dep probe centralization, README, examples (8+ reproducing the dozen workflows), unit tests | Medium (1 week) | Phase 4 | + +The phases are sequenced as concentric rings of capability: +- **Phase 1** lets users *build and visualize* RSMs with any metric (including crossnobis). +- **Phase 2** adds *cell-level inference* (within/between contrasts, reliability, drift). +- **Phase 3** adds *multi-level modeling* — the LME machinery that handles "subjects ↑ sessions ↑ runs ↑ conditions ↑ bodysites" structure with random effects. +- **Phase 4** lifts everything to *atlas/searchlight space* and emits CanlabCore-native `statistic_image` maps. +- **Phase 5** polishes the optional Kriegeskorte rsatoolbox bridge and ships docs/tests/examples. + +The existing [`@fmri_data/rsa_regression.m`](../@fmri_data/rsa_regression.m) (Kragel generalization-index RSA with within-study bootstrap) stays untouched. Reachable as `rsm.compare(model_rdm, 'method','rsa_regression')` so its bootstrap path stays alive. + +--- + +## Phase 1 — `@rsm` class + RSM/RDM construction + +### Diagnosis + +Across the workflow corpus there are at least four bespoke RSM constructors (`generate_RSA.m`, `generate_RSA2.m`, `generate_RSA3.m`, `generate_RSA_accept_crossnobis.m`) plus the inline construction in `Representational Similarity Analysis.mlx`, `01282025 RSM Reliability`, `12111024 WASABI RSA Session Effects`, etc. They overlap heavily but each adds one twist: + +| Workflow | What it adds beyond `corr(dat.dat)` | +|---|---| +| `generate_RSA.m` (distraction) | Metadata-aware condition aggregation (`mean(get_wh_image(...))`), trial-collapsing (`_trial` suffix), level=`collapsed`/`session`, optional ROI/GM masking + smoothing, `info` struct, matched-pair highlighting | +| `generate_RSA3.m` (Sun et al.) | Per-session RSMs, then average across sessions; **diagonal correction** by replacing same-bodysite diagonal entries with mean of across-session same-bodysite cells | +| `generate_RSA_accept_crossnobis.m` | **Crossnobis distance** with two-fold session split, mean-centering, diagonal Ledoit-Wolf whitening from session-difference noise | +| `10252024 RSA Whitening Walkthrough.mlx` | **Within-subject whitening** of vectorized RDMs across replicates via `rsa.stat.covdiag` + inverse-sqrt SVD | +| `01282025 RSM Reliability.mlx` | Stacks per-replicate RSMs into 3D matrix; computes `ICC(2,k)` reliability | + +All five variants assume a 3D RSM-per-subject or array-of-RSMs-per-subject-per-replicate. **An `@rsm` class that standardizes the data + metadata + replicate axis** lets each twist become a single option, not a forked function. + +### Proposed design + +#### 1.1 — The `@rsm` class + +```matlab +classdef rsm +properties + dat % k x k or k x k x N (similarity OR dissimilarity) + % N = subjects, replicates, or both (see 'level' below) + is_dissimilarity % logical: true => RDM, false => RSM + metric % 'correlation' | 'spearman' | 'cosine' | 'euclidean' | + % 'mahalanobis' | 'crossnobis' | ... + labels % {k x 1} cellstr — condition names; row/col order of dat + metadata_table % table, k rows: per-condition attributes + % (bodysite, side, condition, ...) + groupings % struct: name -> indices into 1:k, e.g. groupings.hot=1:8 + level % 'subject' | 'session' | 'run' | 'collapsed' | 'group' + % describes what each slice of dim 3 represents + replicate_table % N x p table describing dim 3: + % (subject_id, session_number, run_number, fold, ...) + % one row per slice along dim 3 + whitened % struct describing whitening applied: + % .level 'none'|'within_subject'|'across_subject'|'session_difference' + % .shrinkage scalar + % .method 'covdiag'|'diag'|'none' + source % 'fmri_data' | 'parcel:' | 'searchlight:' | 'custom' + history % cellstr of operations applied (chronological) + additional_info % struct, free-form +end + +methods + obj = rsm(dat, varargin) + obj = rsm.from_design(design, varargin) % static: model RDM from design columns + obj = rsm.from_categorical(cat_vec, varargin) % static: same-vs-different RDM + % from a categorical/cellstr vector + % (wraps rsa.rdm.categoricalRDM) + obj = rsm.from_table(meta, spec, varargin) % static: build multiple model RDMs + % from a metadata table + spec +end +end +``` + +`replicate_table` is the key addition over the original plan: it makes the 3rd dimension self-describing. A 3D rsm with 45 slices (9 subjects × 5 runs) is unambiguous because `replicate_table` says which slice is which (subject, session, run). Phase 3's LME relies on this. + +#### 1.2 — `rsm.from_categorical`, `rsm.from_metadata_distance`, `rsm.from_design` + +Three static constructors for model RDMs. + +```matlab +% (A) Categorical / same-vs-different (wraps rsa.rdm.categoricalRDM) +M = rsm.from_categorical(dat.metadata_table.subject_id); + +% From multiple columns at once → array of model RDMs (one per column) +M = rsm.from_categorical(dat.metadata_table, ... + {'subject_id','session_number','run_number','condition','bodysite'}); +% M is a [1 x 5] array of rsm objects, each with is_dissimilarity=true + +% (B) Continuous metadata distance (NEW per Phase 3 §6.4) +M = rsm.from_metadata_distance(dat.metadata_table, 'session_number', ... + 'metric', 'abs_diff'); +% Entry (i,j) = |session_i - session_j|. Lets users model session-distance, +% time-since-baseline, run-distance, etc. as continuous predictors in rsa_lme. + +% (C) Numeric/binary design matrix (existing rsa_regression entry point) +M = rsm.from_design(X, 'names', {'hot','warm','imagine'}, ... + 'metric', 'seuclidean'); +``` + +These replace both the `pdist(design(:,i),'seuclidean')` loop in `rsa_regression.m` lines 95–100 *and* the `DesignRSMs.{Bodysite,Condition,CrossCondition}` ad-hoc construction in the Sun et al. workflow. + +#### 1.3 — `fmri_data.compute_rsm` (the omnibus constructor) + +Single method that subsumes all four bespoke `generate_RSA*` variants: + +```matlab +R = compute_rsm(dat, ... % omnibus form + 'group_by', 'condition', ... % metadata column → k labels (row/col) + 'condition_collapse', 'mean', ... % how to aggregate multiple rows + % per condition: 'mean'|'concat'|'none' + 'level', 'subject', ... % 'subject'|'session'|'run'|'collapsed' + 'subject_var', 'subject_id', ... % metadata col defining subject axis + 'session_var', 'session_number', ... % required if level='session' + 'run_var', 'run_number', ... % required if level='run' + 'fold_var', 'session_number', ... % required if metric='crossnobis' + 'metric', 'correlation', ... % see below + 'whiten', 'none', ... % 'none'|'within_subject'| + % 'session_difference' + 'whiten_method', 'covdiag', ... % 'covdiag' (Ledoit-Wolf) | 'diag' + 'diagonal_correction', 'none', ... % 'none'|'across_session_mean'|'nan' + % (across_session_mean reproduces + % generate_RSA3 diagonal patch) + 'mask', [], ... % image_vector/atlas/region for apply_mask + 'parcellation', [], ... % atlas → returns [nParcels x 1] array of rsm + 'smooth_mm', [], ... % optional smoothing + 'treat_zero_as_data', false, ... + 'use_parallel', false); + +% Distance metrics implemented: +% 'correlation' / 'spearman' — corr(dat, 'type', 'Pearson'/'Spearman') +% 'cosine' — canlab_compute_similarity_matrix +% 'euclidean' / 'seuclidean' — pdist +% 'mahalanobis' — pdist with regularized covdiag +% 'crossnobis' — see 1.4 below +``` + +`level='session'` returns a 3D rsm where dim 3 indexes (subject, session) pairs and `replicate_table` records which is which. `level='collapsed'` averages across sessions/runs first, then computes one RSM per subject. `level='subject'` is the default (one RSM per subject from all that subject's images). + +#### 1.4 — Crossnobis support (`metric='crossnobis'`) + +Generalizes `generate_RSA_accept_crossnobis.m`. Requires a `fold_var` (typically `session_number` or `run_number`) that defines the split. + +Algorithm (mirroring the existing code): +1. For each fold `f`, build `X_f` of shape `[k × voxels]` by averaging images per condition within that fold. +2. Drop voxels that are non-finite or zero-variance jointly across folds. +3. Mean-center each `X_f` across conditions. +4. Optional whitening (`'whiten','session_difference'`): noise = `X1 - X2`, voxel_var = `var(noise, 0, 1)`, `X_f_white = X_f ./ sqrt(voxel_var)`. +5. Cross-validated dissimilarity: `D(a,b) = mean( (X1[a]-X1[b]) .* (X2[a]-X2[b]) )` (generalizes to >2 folds as the mean over all distinct fold pairs). +6. Return as `rsm` with `is_dissimilarity=true`, `metric='crossnobis'`, `whitened.level='session_difference'` etc. + +The fold-builder pattern from `build_accept_runfold_matrices` becomes a private helper `build_fold_pattern_matrices(dat, group_by, fold_var)` that's reusable for any crossvalidated RSA. + +#### 1.5 — Within-subject whitening (`'whiten','within_subject'`) + +Generalizes the workflow in `10252024 RSA Whitening Walkthrough.mlx`. Three operating modes: + +```matlab +% Build per-replicate RSMs first, then whiten the stack +R = compute_rsm(dat, ... + 'level', 'run', ... + 'metric', 'correlation', ... + 'whiten', 'within_subject'); % vectorize upper-tri of each replicate; + % covdiag → svd → inverse-sqrt → re-square +``` + +Centralized in `@rsm/private/whiten_within_subject.m`. Wraps `rsa.stat.covdiag` when present (soft-dep), falls back to a stock implementation of Ledoit-Wolf shrinkage when not (we own the simple version; it's ~40 lines). + +Per the walkthrough's recommendation, **no across-subject whitening by default** — that one is exposed but documented as "use with care; can suppress meaningful inter-subject variability." + +#### 1.6 — Diagonal correction (`'diagonal_correction','across_session_mean'`) + +Reproduces the `generate_RSA3.m` patch where same-bodysite diagonal entries are replaced with the mean of across-session same-bodysite cells. Generalized: given a metadata grouping column and a session column, replace each diagonal cell with the mean of across-session same-grouping off-diagonal cells. + +```matlab +R = compute_rsm(dat, ... + 'level', 'subject', ... + 'diagonal_correction', 'across_session_mean', ... + 'diagonal_group_by', 'bodysite', ... + 'session_var', 'session_number'); +``` + +#### 1.7 — `rsm.plot` + +```matlab +plot(R); % rank-transformed heatmap (Kriegeskorte style if available) +plot(R, 'mode', 'raw'); % skip rank transform +plot(R, 'subject', 3); % slice a 3D rsm +plot(R, 'mean'); % average across 3rd dim before plotting +plot(R, 'subjects', 'grid'); % subplot grid (one per subject/replicate) +plot(R, 'mds'); % 2D MDS scatter +plot(R, 'dendrogram'); % hierarchical clustering +plot(R, 'matched_pairs', matched_pairs); % highlight (i,j) cells with white rectangles + % (from generate_RSA plot_RSA_matrix) +plot(R, 'block_borders_by', 'bodysite', ... + 'border_color', 'auto'); % color-coded borders around metadata-grouped blocks + % (from 08192024 Representational Drift) +``` + +Soft-dep probe (`@rsm/private/probe_rsatoolbox.m`) returns a capability struct; `plot` uses `rsa.util.RDMcolormap` + `rsa.rdm.rankTransform` when present, else `parula` + tiedrank. + +### What this phase reproduces from the corpus + +- **Sun et al. Figs 7A–C** (Representational Similarity Analysis.mlx) +- **All four `generate_RSA*.m` variants** collapse to single `compute_rsm` calls +- **`10252024 RSA Whitening Walkthrough`** within-subject path (`'whiten','within_subject'`) +- **`generate_RSA_accept_crossnobis`** (`'metric','crossnobis','fold_var','session_number'`) +- **`generate_RSA3` diagonal correction** (`'diagonal_correction','across_session_mean'`) +- **`08192024 Representational Drift`** RSM heatmaps with bodysite block borders (`'block_borders_by'`) +- **`01282025 RSM Reliability`** Figs A/B (subject-level + subject-grid RSM plots) + +### File layout (Phase 1) + +``` +CanlabCore/ + @rsm/ + rsm.m % classdef + constructor + from_categorical.m % static method (NEW vs. v1 plan) + from_metadata_distance.m % static method (NEW vs. v1 plan; per Phase 3 §6.4) + from_design.m % static method + from_table.m % static method + plot.m + mean.m + fisher_z.m + inv_fisher_z.m + to_rdm.m + to_rsm.m + subset.m + reorder.m + display.m + size.m + isempty.m + private/ + local_rank_transform.m % fallback when rsatoolbox absent + validate_metadata.m + probe_rsatoolbox.m + whiten_within_subject.m % NEW + whiten_session_difference.m % NEW (crossnobis helper) + build_fold_pattern_matrices.m % NEW (crossnobis helper) + ledoit_wolf_shrinkage.m % NEW fallback (used if rsa.stat.covdiag absent) + apply_diagonal_correction.m % NEW + @fmri_data/ + compute_rsm.m % omnibus constructor — subsumes generate_RSA* + RSA_tools/ + RSA_Pipeline_Phased_Plan.md + README.md % added in Phase 5 +``` + +### Verification (Phase 1 acceptance) + +- Each `generate_RSA*.m` produces output that matches a single `compute_rsm` call to floating-point tolerance on a synthetic dataset with planted structure. +- Crossnobis distance: on a dataset where condition A and B differ by a known amplitude in signal but share noise, recovered `D(A,B)` is within 5% of the analytic crossnobis value (Walther et al. 2016). +- Within-subject whitening: reproduces `X_subject_avg` from `10252024 RSA Whitening Walkthrough.mlx` line 180 to floating-point tolerance. +- `plot(R, 'matched_pairs', ...)` renders white rectangles matching `generate_RSA.m`'s `plot_RSA_matrix`. +- `compute_rsm` runs without rsa.* on the path (soft-dep test). + +--- + +## Phase 2 — Cell extraction + group inference + reliability + +### Diagnosis + +Cell-extraction logic (`process_*_correlations` in the workflow, the inline reshaping in `12111024 WASABI RSA Session Effects`, and the matched-bodysite logic across all WASABI notebooks) is the most-duplicated machinery in the corpus. **Phase 2 collapses it into three declarative methods.** + +Reliability and drift were not in v1 of the plan; the corpus has them everywhere: + +- `01282025 RSM Reliability Analysis and Visualization` — `ICC(2,k)` per subject across runs, per superordinate condition, per bodysite, per individual condition (row-level). +- `08072024 Run-Level RDM Analysis with RSA Toolbox` — `ICC(3,k)` per subject for each pain signature × condition (NPS, SIIPS). +- `08192024 Representational Drift in RSA` — within-condition pattern correlation vs. session distance. + +### Proposed design + +#### 2.1 — `rsm.cells` (unchanged from v1) + +```matlab +vals = R.cells('hot', 'hot'); % within-condition (upper triangle, excluding diag) +vals = R.cells('hot', 'warm'); % between-condition +T = R.cells_table({'hot','warm','imagine','hw','hi','iw', ... + 'ipsi','contra','same_bs','diff_bs'}); +``` + +#### 2.2 — `rsm.contrast` and `rsm.ttest_contrasts` (unchanged from v1) + +```matlab +spec = { + 'hot_vs_warm', {'hot','hot'}, {'warm','warm'}; + 'hi_vs_hw', {'hot','imag'}, {'hot','warm'}; + 'ipsi_vs_contra', {'ipsi','ipsi'}, {'contra','contra'}; + 'same_vs_diff_bs', {'matching','matching'}, {'nonmatch','nonmatch'}; +}; +T = R.ttest_contrasts(spec, 'tail','right', 'within',true, 'fdr',true); +``` + +#### 2.3 — `rsm.reliability` *(NEW vs. v1)* + +Wraps the `ICC.m` File Exchange utility (already used throughout the corpus); soft-deps when present, falls back to a stock implementation we provide. + +```matlab +% Whole-RSM reliability across replicates (the most common form) +[icc_value, ci] = R.reliability('icc_type', '2-k', 'across', 'replicate'); + +% Per-subject reliability across the subject's replicates (3D rsm with replicate axis) +icc_by_subject = R.reliability('across', 'replicate', 'by', 'subject_id'); +% returns a table: subject_id | icc + +% Per-grouping reliability — replaces the per-condition / per-bodysite loops in +% 01282025 RSM Reliability lines 60–195 +icc_table = R.reliability_by_grouping('groupings', {'hot','warm','imagine', ... + 'leftface','rightface',...}); +% returns a table: grouping | icc | ci | mean | sd + +% Row-level reliability (per-condition reliability of an RSM row) +icc_per_cond = R.reliability_per_condition('icc_type', '2-k'); +% returns one ICC per row of the RSM +``` + +#### 2.4 — `rsm.drift` *(NEW vs. v1)* + +```matlab +% Within-condition similarity vs session distance (08192024 Representational Drift) +T = R.drift('reference', 'first', ... % compare each replicate to first replicate + 'group_by', 'condition', ... + 'plot', true); +% T: replicate_index | distance_from_ref | correlation | condition + +% Or against a fitted slope +T = R.drift('reference', 'all', 'fit', 'linear'); +% T: condition | slope | intercept | R^2 | p +``` + +#### 2.5 — `rsa_group_inference` (free function, unchanged from v1) + +#### 2.6 — `plot_rsm_contrast_bars` (unchanged from v1) + +Add: `plot_rsm_session_lineplot(merged_table, condition, rois, ...)` adapted from `12111024 WASABI RSA Session Effects`'s `plot_roi_lineplots` — generalize so it works on any per-session/per-replicate statistic. + +### What this phase reproduces + +- **Sun et al. Figs 7D–F** (within/between condition + ipsi/contra + same/diff bodysite bar plots) +- **`01282025 RSM Reliability` Figs 1–4** (ICC by subject / by condition / by bodysite / by individual condition) +- **`08192024 Representational Drift` Figs** (session-distance correlation plots) +- **`12111024 WASABI RSA Session Effects` line plots** (per-ROI per-contrast time series) + +### File layout (Phase 2) + +``` +@rsm/ + cells.m + cells_table.m + contrast.m + ttest_contrasts.m + reliability.m % NEW vs. v1 + reliability_by_grouping.m % NEW vs. v1 + reliability_per_condition.m % NEW vs. v1 + drift.m % NEW vs. v1 + private/ + icc_fallback.m % NEW (stock ICC(2,k) / ICC(3,k) when File Exchange ICC absent) +RSA_tools/ + rsa_group_inference.m + plot_rsm_contrast_bars.m + plot_rsm_session_lineplot.m % NEW vs. v1 +``` + +### Verification (Phase 2 acceptance) + +- `ttest_contrasts` reproduces the workflow's `pairwise_stats_table` to floating-point tolerance. +- `reliability` reproduces `01282025 RSM Reliability` `reliability_superordinate` and `reliability_bodysite` tables to floating-point tolerance. +- `drift` reproduces the bar plots in `08192024 Representational Drift` lines 380–400. +- `plot_rsm_session_lineplot` reproduces `plot_roi_lineplots` output visually. + +--- + +## Phase 3 — Multi-level / LME modeling *(NEW phase vs. v1)* + +> **Detailed design lives in [RSA_Phase3_LME_Design.md](RSA_Phase3_LME_Design.md).** The summary below is enough to scope the phase; the linked doc is the source of truth for the API, table contract, nested-model spec, and validation plan. + +### Diagnosis + +`08072024 Run-Level RDM Analysis with RSA Toolbox.mlx` is the only place in the corpus that handles the full nested structure (subject ↑ session ↑ run ↑ condition ↑ bodysite). It does this by: + +1. Concatenating all images into a single big object. +2. Computing one giant RSM `corr(dat.dat)`. +3. Building categorical model RDMs for each grouping variable (Subject, Session, Run, Condition, Bodysite) via `rsa.rdm.categoricalRDM`. +4. Adding pairwise (Cond×Bodysite, etc.) and 3-way interaction RDMs. +5. Vectorizing the upper triangle of the big RSM as `Y_vectorized`. +6. Vectorizing each model RDM the same way → columns of `all_regressors`. +7. Fitting `fitlm(all_regressors, Y_vectorized, 'VarNames', ...)` for fixed-effects regression. +8. Fitting `fitlme(mdl_tbl_sub, formula, 'FitMethod', 'REML')` for random-effects (random intercepts/slopes by Subject). +9. Comparing nested models (10 LMEs in the workflow) via likelihood ratio / AIC. + +**This is the "multi-level modelling across scan runs, sessions, subjects, and tasks" pattern you flagged.** It's also the one piece of the corpus that is genuinely under-tooled in CanlabCore today — there's nothing parallel to this workflow anywhere in the toolbox. + +### Proposed design + +#### 3.1 — `rsm.build_design_rdms` + +Promotes `rsa.rdm.categoricalRDM` calls + interaction-RDM construction into a single method. + +```matlab +% Build a design RDM stack from metadata columns + optional interactions +[X, names] = R.build_design_rdms( ... + 'main_effects', {'subject_id','session_number','run_number','condition','bodysite'}, ... + 'interactions', {{'condition','bodysite'}, {'session_number','condition'}}, ... + 'three_way', {{'subject_id','condition','bodysite'}}); + +% Returns: +% X p x m matrix of vectorized model RDMs (one per main effect or interaction) +% names {m x 1} regressor names: {'Subject','Session','Run','Condition','Bodysite', +% 'CondxBodysite', 'SesxCond', 'SubxCondxBodysite'} +``` + +Internally calls `rsa.rdm.categoricalRDM` if present, else our own implementation (~10 lines: `cat_vec(:) ~= cat_vec(:)'`). + +#### 3.2 — `rsm.rsa_lm` (fixed effects) + +```matlab +% Fit fitlm on the vectorized RSM with categorical/numeric model RDMs as predictors +mdl = R.rsa_lm( ... + 'predictors', {'subject_id','session_number','run_number','condition','bodysite'}, ... + 'interactions', {{'condition','bodysite'}}, ... + 'response_transform','fisherz', ... % 'none'|'fisherz'|'rank' + 'standardize', true); + +% Returns a LinearModel — disp(mdl), coefTest, etc. all work. +``` + +#### 3.3 — `rsm.rsa_lme` (random effects) + +The main multi-level entry point. + +```matlab +% Random intercept by subject, fixed effects for everything else +mdl = R.rsa_lme( ... + 'fixed_effects', {'session_number','run_number','condition','bodysite'}, ... + 'random_effects', {'(1 | subject_id)'}, ... + 'fit_method', 'REML'); + +% Random slopes for condition by subject: +mdl = R.rsa_lme( ... + 'fixed_effects', {'condition','bodysite','session_number'}, ... + 'random_effects', {'(condition | subject_id)'}); + +% Or pass a Wilkinson formula directly (escape hatch): +mdl = R.rsa_lme('formula', ... + 'similarity ~ Condition + Bodysite + Session + (Condition | Subject)'); +% Returns a LinearMixedModel. +``` + +Both methods wrap a shared internal pipeline: +1. `build_design_rdms` to get the predictor matrix and names. +2. Assemble a `mdl_tbl` table with columns `(similarity, Subject, Session, Run, Condition, Bodysite, ...)` — each row is one (i,j) pair from the vectorized RSM. +3. Hand to `fitlm` or `fitlme` (MATLAB Statistics Toolbox). + +#### 3.4 — `rsm.compare_models` (nested LRT) + +```matlab +% Compare nested LME models — replaces the manual 10-model loop in +% 08072024 Run-Level RDM Analysis lines 2633–2691 +[T, best] = R.compare_models({ + 'similarity ~ 1 + (1|Subject)'; + 'similarity ~ Condition + (1|Subject)'; + 'similarity ~ Condition + Bodysite + (1|Subject)'; + 'similarity ~ Condition*Bodysite + (1|Subject)'; + 'similarity ~ Condition*Bodysite + Session + (1|Subject)'; + 'similarity ~ Condition*Bodysite + Session + (Condition|Subject)'; +}, 'criteria', {'aic','bic','loglik','lrt'}); + +% T: model | aic | bic | loglik | df | lrt_chi2 | lrt_p +``` + +#### 3.5 — `rsm.rsa_lme_by_parcel` *(bridges to Phase 4)* + +Lifted to atlas level in Phase 4 as `fmri_data.rsa_parcelwise_lme`. + +### What this phase reproduces + +- **`08072024 Run-Level RDM Analysis` Sections "Mixed Effects Models Instead"** (all 10 nested LME comparisons) +- **`08072024` Sections "fitlm" per-subject + pooled** (rsa_lm and rsa_lme entry points) +- **Categorical RDM construction loops** at lines 1625–1629, 1807–1811 → single `build_design_rdms` call + +### File layout (Phase 3) + +``` +@rsm/ + build_design_rdms.m + rsa_lm.m + rsa_lme.m + compare_models.m + private/ + vectorize_upper_tri.m % helper used by all three + assemble_lme_table.m + fallback_categoricalRDM.m % when rsa.rdm.categoricalRDM absent +RSA_tools/ + rsa_lme_formula_parser.m % helps users assemble Wilkinson formulas from + % predictor + interaction specs +``` + +### Verification (Phase 3 acceptance) + +- `rsa_lm` reproduces the `fitlm` outputs in `08072024` lines 1725–1737 (full model + each main effect) to floating-point tolerance. +- `rsa_lme` reproduces `mdl_random_full` from `08072024` line 2602 to within REML convergence tolerance. +- `compare_models` reproduces the nested LRT ordering of the 10 models from `08072024` lines 2633–2691. +- Runs in <5 minutes on a synthetic dataset of size (n_subjects=20, n_sessions=2, n_runs=5, n_conditions=24). + +--- + +## Phase 4 — Parcelwise + searchlight + brain maps + +### 4.1 — `fmri_data.rsa_parcelwise` (folds in Phase 3 LME) + +```matlab +results = rsa_parcelwise(dat, ... + 'atlas', canlab2024_atlas, ... + 'group_by', 'condition', ... + 'subject_var', 'subject_id', ... + 'metric', 'spearman', ... % or 'crossnobis', 'mahalanobis', ... + 'whiten', 'within_subject', ... % optional + 'contrasts', spec, ... % Phase 2 contrast spec + 'lme', 'similarity ~ Condition + (Condition|Subject)', ... + % optional: per-parcel LME from Phase 3 + 'correction', 'fdr', ... + 'fdr_scope', 'contrast', ... % 'contrast' (per contrast across parcels) | 'all' + 'tail', 'right', ... + 'use_parallel', true); + +% results.per_parcel_rsms [nParcels x 1] array of rsm objects +% results.contrast_table long table: parcel | contrast | t | p | FDR_P | sig +% results.lme_table long table: parcel | term | beta | se | p | FDR_P (if 'lme') +% results.maps struct: maps. = statistic_image +% results.atlas, .history +``` + +### 4.2 — `assign_vals_to_atlas`, `region2statistic_image` + +Unchanged from v1. + +### 4.3 — `image_vector.searchlight_rsa` + +```matlab +results = searchlight_rsa(dat, model_rdms, ... + 'radius', 6, ... + 'group_by', 'condition', ... + 'subject_var', 'subject_id', ... + 'metric', 'spearman', ... + 'compare', 'spearman', ... % RSM vs model + 'mask', gray_matter_mask, ... + 'permutations', 1000); % permutation test for inference +``` + +Reuses `@image_vector/searchlight.m`'s sphere infrastructure. + +### What this phase reproduces + +- **Sun et al. Figs 7G, S8, S9** (parcelwise brain maps) +- **`12111024 WASABI RSA Session Effects`** session-stratified parcelwise maps (via `'lme'` with `Session` as fixed effect) +- Future searchlight RSA (not in any current workflow but the natural progression) + +### File layout (Phase 4) + +``` +@fmri_data/ + rsa_parcelwise.m +@image_vector/ + searchlight_rsa.m +RSA_tools/ + assign_vals_to_atlas.m + region2statistic_image.m +``` + +### Verification (Phase 4 acceptance) + +- Reproduces `condition_results_summary` and `condition_contrast_summary` tables from Sun et al. workflow. +- `results.maps.` is a valid `statistic_image` — `.threshold/.montage/.table` work. +- `searchlight_rsa` permutation p-map has uniform distribution under randomized data. +- Optional `'lme'` argument reproduces `08072024 Run-Level RDM Analysis` per-subject LME results when applied parcelwise. + +--- + +## Phase 5 — Kriegeskorte interop polish + docs + tests + +### 5.1 — Centralized soft-dep probe + +`@rsm/private/probe_rsatoolbox.m` returns a capability struct: + +```matlab +caps.rdm_rankTransform = ~isempty(which('rsa.rdm.rankTransform')); +caps.rdm_squareRDM = ~isempty(which('rsa.rdm.squareRDM')); +caps.rdm_categoricalRDM = ~isempty(which('rsa.rdm.categoricalRDM')); +caps.util_RDMcolormap = ~isempty(which('rsa.util.RDMcolormap')); +caps.fig_MDSConditions = ~isempty(which('rsa.fig.MDSConditions')); +caps.fig_dendrogramConditions = ~isempty(which('rsa.fig.dendrogramConditions')); +caps.stat_covdiag = ~isempty(which('rsa.stat.covdiag')); +caps.compareRefRDM2candRDMs = ~isempty(which('rsa.compareRefRDM2candRDMs')); +caps.bootstrapRDMs = ~isempty(which('rsa.bootstrapRDMs')); +caps.any = any(struct2array(caps)); +``` + +Every method that uses `rsa.*` calls this and branches. + +### 5.2 — `rsm.compare` (formal RDM-vs-RDM inference) + +Wraps `rsa.compareRefRDM2candRDMs` when present (Spearman/Kendall, signed-rank inference, FDR across candidate models, noise ceilings); falls back to our own stock implementation when not. + +```matlab +result = R.compare(model_rdms, ... + 'correlation_type', 'kendall_taua', ... + 'relatedness_test', 'subjectRFXsignedRank', ... + 'differences_test', 'subjectRFXsignedRank', ... + 'multiple_testing', 'fdr', ... + 'noise_ceiling', true); +``` + +### 5.3 — README + examples + tests + +``` +RSA_tools/ + README.md + examples/ + rsa_quickstart.m % 30-line example, public dataset + rsa_sun2026_replication.m % full Sun et al. workflow + rsa_kragel_generalization.m % wraps rsa_regression via @rsm + rsa_crossnobis_acceptance.m % generalize_RSA_accept_crossnobis + rsa_whitening_walkthrough.m % 10252024 walkthrough + rsa_reliability_icc.m % 01282025 reliability + rsa_drift_session.m % 08192024 + 12111024 session/drift + rsa_multilevel_lme.m % 08072024 LME corpus + tests/ + test_rsm_construction.m + test_compute_rsm_metrics.m + test_crossnobis.m + test_whitening.m + test_cells_and_contrasts.m + test_reliability.m + test_drift.m + test_lme.m + test_parcelwise.m + test_searchlight.m + test_softdep_fallback.m % rsa/ off the path +``` + +### Verification (Phase 5 acceptance) + +- Every example script runs end-to-end on a freshly cloned CanlabCore with no extras. +- `test_softdep_fallback` passes with `rmpath(genpath())` in effect. +- README covers each metric, each level (subject/session/run), and links each example to its inspiring workflow. + +--- + +## Risks & open issues + +- **Open question — replicate axis vs subject axis on 3D `rsm`.** When `level='session'` we have one slice per (subject, session) pair. The replicate axis is now compound. `replicate_table` makes this self-describing, but cell-extraction and reliability methods need to know whether to aggregate within-subject across sessions, across-subject within-session, or pool. Default = follow the `level` field; expose `'aggregate_across', {'subject'|'session'|'run'}` for explicit control. Confirm at Phase 1 kickoff. +- **Crossnobis with >2 folds.** The `generate_RSA_accept_crossnobis` implementation assumes exactly 2 folds (one session pair). Generalize to k folds by averaging cross-products over all distinct pairs (Walther et al. 2016 eq. 5). Need a leave-one-fold-out variant too — confirm preference. +- **Whitening interaction with crossnobis.** Crossnobis already includes its own whitening (session-difference noise). Within-subject covdiag whitening on top of that is double-counting noise. The `compute_rsm` validator should warn if both are requested. +- **LME on full RSM scales poorly.** A k=200 condition RSM has 19,900 cells, and stacking subjects/sessions/runs grows that by 10–100×. `fitlme` will struggle past ~10⁶ rows. Phase 3 must document this and offer a `'subsample'` option (random or stratified) for prototyping. +- **ICC fallback.** The File Exchange `ICC.m` is the de-facto standard. Our `icc_fallback.m` must give numerically identical output for ICC(2,k) and ICC(3,k) — write unit tests against `ICC.m` output before declaring ready. +- **`fmri_data.compute_rsm` API breadth.** The omnibus form is verbose. We'll provide three short aliases (`compute_rsm_per_subject`, `compute_rsm_per_session`, `compute_rsm_crossnobis`) that pre-fill `level` and the relevant defaults. Each is a 5-line wrapper. +- **Atlas-vs-mask-vs-region inputs to ROI methods.** The `generate_RSA*.m` wrappers switch on `class(roi)`. Standardize: any method that takes a spatial restriction accepts `image_vector | atlas | region | char (atlas keyword)` via a single helper. +- **Replication coverage of the corpus.** I've inventoried 12 `.mlx` files plus four `.m` paradigm wrappers and the published Sun et al. workflow. Files I haven't yet read deeply (DistractMap, Novus, accept paradigm publication figures, DEMO1/DEMO2, `b_imensa_240116`, `12222023`) may reveal additional patterns. Phase 5 example coverage is the safety net — if an example can't be written with the current API, the API is incomplete. +- **MATLAB MCP integration for testing.** The MATLAB MCP server (`mcp__MATLAB__run_matlab_file`, `mcp__MATLAB__evaluate_matlab_code`, `mcp__MATLAB__check_matlab_code`) now lets me run example scripts and unit tests live during development. Use it for Phase 1 smoke tests before declaring `compute_rsm` ready. + +--- + +## Sequencing recommendation + +1. **Weeks 1–2:** Phase 1. Ship after `compute_rsm` reproduces all four `generate_RSA*` variants via single calls. +2. **Week 3:** Phase 2. Validate ICC/drift against `01282025` and `08192024` reference outputs. +3. **Weeks 4–5:** Phase 3 (the new multi-level phase). Validate LMEs against `08072024` reference outputs. This is the largest single phase by scope. +4. **Weeks 6–7:** Phase 4 (parcelwise + searchlight + maps). +5. **Week 8:** Phase 5 (polish + docs + tests). + +Each phase remains independently shippable and mergeable. Phase 1 alone gives users a coherent RSM-construction API with crossnobis and whitening — that's already a substantial uplift over the status quo. diff --git a/CanlabCore/RSA_tools/SIGNATURE_RSA_AUDIT.md b/CanlabCore/RSA_tools/SIGNATURE_RSA_AUDIT.md new file mode 100644 index 00000000..a4677cb5 --- /dev/null +++ b/CanlabCore/RSA_tools/SIGNATURE_RSA_AUDIT.md @@ -0,0 +1,238 @@ +# Cross-subject bodysite SVM-signature RSA — audit & corrected pipeline + +Audit of the WASABI **AcceptMap** and **DistractMap** "Bodysite Pain SVM +Signature Visualization Reports" Live Scripts, and the corrected, publication- +defensible reimplementation now living in `RSA_tools/`. + +The two `.mlx` files are near-identical; everything below applies to both. + +--- + +## 0. The central conceptual issue (read this first) + +The scripts ask a bodysite-**specificity** question but compute it on the wrong +statistical object, and this — not a coding bug — is the main reason the results +are puzzling. + +There are **two distinct RSAs** being conflated: + +| | What it correlates | What it answers | +|---|---|---| +| **Data-level RSA** (the conventional RSA that *worked* in BodyMap) | the **evoked response patterns** for each bodysite | "is leftarm-pain represented differently from leftleg-pain?" → somatotopy | +| **Signature-level RSA** (what these scripts compute) | the learned **SVM weight maps** | "do two within-site *hot-vs-warm decoders* point the same way?" | + +The SVM `weight_obj` is a **backward-model discriminative** map trained to +separate hot from warm **within one bodysite**. Correlating bodysite A's +hot-vs-warm decoder with bodysite B's hot-vs-warm decoder is a question about +**generalizability of pain decoding across sites**, *not* about somatotopic +bodysite representation. So: + +- It is expected — not a failure — that signature-RSA shows weak/dirty + somatotopy while data-level RSA shows clean somatotopy. They measure different + things. +- The signatures are dominated by a **shared nociceptive (hot>warm) component** + common to every site (this inflates dot-product / pattern-expression + off-diagonals) plus **noisy backward-model rotation** (correlated voxels get + arbitrary, subject-specific weights — this deflates weight-map correlations). + Neither cleanly recovers somatotopy. The scripts themselves drift toward this + conclusion ("maps likely contain a shared nociceptive component plus a smaller + site-specific component … pattern expression is dominated by the shared + component; correlation isolates the spatial differences"). +- The **failure to generalize to AcceptMap/DistractMap, even same-subject**, is + consistent with this: a hot-vs-warm decoder trained on BodyMap need not express + cleanly on a different paradigm whose pain manipulation, baseline, and noise + structure differ — and run-level test maps are low-SNR. + +**Recommendation.** Keep the signature cross-subject RSA as a *secondary* +analysis ("are the learned decoders reproducible across people?"). Answer the +**bodysite-specificity** question with **data-level RSA on the evoked patterns** +via the existing `compute_rsm` / `rsa_parcelwise` route (see +`examples/rsa_acceptmap_pipeline.m`), and/or run the signature RSA on +**Haufe / structure-coefficient maps** rather than raw weights. + +--- + +## 1. Audit findings (code-level) + +### 1a. Tautological / trivial analyses +- **Self-correlations on the diagonal.** Within-subject 8×8 `corr_*{s}` have + `diag == 1` by construction; `atanh(1) = Inf`. Any "within" defined as the + diagonal is trivial. ✅ You already flagged this; the corrected pipeline never + uses the diagonal for inference (`build_crosssubject_signature_rsa` flags it; + all tests use `RSA.cross_subject_mask`). + +### 1b. Non-independence (the dominant statistical error) +- `get_crosssubject_site_effect` pools **every cross-subject pairwise cell** into + `same_r` / `diff_r`, then `ttest2(same_z, diff_z)`. With 8 subjects × 8 sites + there are 8·C(8,2)=**224** same-site cells and **1568** different-site cells, + but only **8 independent subjects**. Each subject's maps appear in ~14 pairs, + so the cells are strongly dependent. `ttest2` uses df≈1790 instead of ~7 → + the t (≈4–6) and p (1e-5…1e-7) are **massively anticonservative**. +- Same problem in the sitewise `ttest2(dpIns_z, S1_z)` (t≈7.4) and in the + "dpIns > S1" comparison. +- **Fix:** collapse to **one observation per subject** before testing + (`subject_level_crosssubject_effect`, df = nSub−1 = 7), and/or a + **within-subject label permutation** test + (`permutation_test_site_specificity`). + +### 1c. The "extend to whole brain" subject loop is degenerate +```matlab +for s = 1:nSub + idx = RSA_s1_corr.subject_idx ~= s; % computed but never used + s1_sub(s) = mean(cellfun(@mean, sim_s1)); % <-- does NOT depend on s + dp_sub(s) = mean(cellfun(@mean, sim_dp)); +end +[~, p, ~, stats] = ttest(dp_sub, s1_sub) +``` +`s1_sub`/`dp_sub` are **constant across `s`** (the body ignores `s`), so this +"subject-level" t-test is meaningless. `subject_level_crosssubject_effect` +replaces it with a real per-anchor aggregation. + +### 1d. Fisher-z applied indiscriminately +`get_crosssubject_site_effect` does `atanh(M)` regardless of metric. For the +**dot-product / pattern-expression** RSA, `M` is unbounded (values ≫ 1), so +`atanh` is complex/nonsense. **Fix:** `rsa_fisher_z` is only applied to the +correlation metric (clamped to (−1,1)); the helpers return empty z and warn for +other metrics. + +### 1e. `apply_mask` argument-order asymmetry (latent, not currently biting you) +`apply_mask(map1, map2, 'pattern_expression', metric)` → +`canlab_pattern_similarity(map1.dat, map2.dat, …)` where `badvals` (excluded +voxels) is defined **from the first argument only**. If two maps had *different* +voxel support, `M(i,j) ≠ M(j,i)`. It is symmetric in your data **only because +all maps within one ROI share the ROI mask**. This is fragile. **Fix:** the +builder stacks all maps on a **common voxel support** and computes +`corr`/cosine/dot in one shot, so symmetry is guaranteed and order-independent. +(Also faster: one `corr(W)` vs an O(K²) `apply_mask` loop.) + +### 1f. Metric token confusion +`apply_mask(a, b, 'pattern_expression', 'pattern_expression')` — the second +`'pattern_expression'` is **not** a recognized metric in +`canlab_pattern_similarity`, so it silently falls back to **dot product**. Works, +but is accidental. The corrected builder takes an explicit +`'correlation'|'cosine'|'dotproduct'`. + +### 1g. Index fragility +`expr_matrix{s} = svm_stats_cell{s+1}{b}{1}` for `s==8` (an ad-hoc subject-skip) +is a latent off-by-one hazard; the corrected code drives everything off the +explicit `subjects`/`bodysite_names` lists. + +--- + +## Answers to the specific audit questions + +- **Fisher z — needed where?** Only for the **correlation** metric, and only when + averaging or testing. Compute stats in z, report descriptives in r via `tanh`. + Never `atanh` dot-product/cosine values. Never `atanh` the diagonal. +- **pattern_expression appropriate for RSA?** As the *primary* RSA measure, no — + it is magnitude-/coverage-driven and dominated by the shared nociceptive + component. Keep **correlation** primary; pattern-expression/cosine secondary. +- **Correlate SVM weights, Haufe maps, or both?** Report **both**, but lead with + **Haufe / structure-coefficient** maps for *representational* claims: raw + backward weights are spatially unreliable (correlated-voxel rotation), which + artificially deflates cross-subject correlation. Haufe maps are the + interpretable forward model. Raw weights remain the object for *decoding* + claims. +- **Full-data weights vs fold-averaged?** For a *single representative signature*, + the full-data `weight_obj` is the canonical choice and is appropriate for RSA. + Fold-averaged maps are for **stability/QC** (the report's fold-correlation + panel), not for inference across folds (folds are not independent). +- **Is full-data training appropriate for representational analysis?** Yes — + cross-subject comparisons are unaffected by within-subject CV because subjects + are independent (no cross-subject leakage). Leave-one-session-out CV affects + *performance* interpretation, not the cross-subject RSA of the weight maps. +- **Are `S.weight_obj.dat` dims compatible across subjects/parcels?** Within one + ROI, yes (shared mask). Across ROIs/parcels they differ — never mix ROIs in one + matrix. The builder enforces equal voxel counts and errors with guidance + otherwise. +- **Missing maps / NaNs?** The builder isolates a common finite, non-zero voxel + support and flags unextractable maps (`RSA.qc.dropped`) instead of crashing. + +--- + +## 2–6. What the corrected code provides + +| Requested | File | Notes | +|---|---|---| +| A `build_crosssubject_signature_rsa` | `build_crosssubject_signature_rsa.m` | common-support matrix, explicit metric, masks, QC | +| B `get_crosssubject_site_effect` | `get_crosssubject_site_effect.m` | **descriptive only**; metric-aware Fisher-z | +| C `get_sitewise_crosssubject_similarity` | `get_sitewise_crosssubject_similarity.m` | descriptive; r and z pools | +| D `collapse_rsa_to_bodysite_matrix` | `collapse_rsa_to_bodysite_matrix.m` | z-space averaging, cross-subject only | +| E `plot_same_vs_different_site` | `plot_same_vs_different_site.m` | **subject-level** error bars + test | +| F `plot_sitewise_crosssubject_similarity` | `plot_sitewise_crosssubject_similarity.m` | subject-level error bars | +| G `subject_level_crosssubject_effect` | `subject_level_crosssubject_effect.m` | leave-one-anchor; paired t, df=nSub−1 | +| H `permutation_test_site_specificity` | `permutation_test_site_specificity.m` | within-subject label permutation | +| (3) Fisher z | `rsa_fisher_z.m` | clamped `atanh` | +| (5) parcelwise | `signature_specificity_parcelwise.m` | per-parcel RSA → brain maps + BH-FDR | +| (6) matrix plots | `plot_signature_rsa_matrix.m`, `add_subject_boundaries.m` | full + collapsed | +| pipeline | `examples/rsa_signature_crosssubject_pipeline.m` | end-to-end | + +**Recommended inference (item 3).** Use **both** the subject-level paired test +(primary; interpretable effect size = Cohen's dz) and the within-subject +**permutation** test (confirmatory; distribution-free). A mixed-effects model +(`similarity ~ same_site + (1|subject_i) + (1|subject_j)`) with crossed random +effects is the most complete option, but is overkill at N=8 and harder to defend +than the simple subject-level test; we provide the subject-level + permutation +pair as the practical, robust choice. + +Validated on synthetic data: with an injected site component the subject-level +test gives df=nSub−1 and the permutation test p<.001; under a no-site null both +are non-significant (no false positive). + +--- + +## 7. Suggested reporting language (cautious) + +> Learned per-subject bodysite signatures (SVM weight maps) were compared across +> subjects with representational similarity analysis. To avoid the +> non-independence of pairwise similarities, similarity was Fisher-z transformed +> and averaged to a single same-site and different-site value per subject; a +> paired t-test across the 8 subjects, confirmed by a within-subject label +> permutation test (5,000 permutations), tested same- vs different-bodysite +> similarity. Cross-subject same-bodysite similarity modestly exceeded +> different-bodysite similarity [report dz, t(7), permutation p], indicating that +> the *discriminative pattern learned for pain at a given site* is partially +> reproducible across individuals. Effect sizes were small (Δr on the order of +> 0.0x), so this reflects weak shared structure rather than a clean somatotopic +> code. dpIns showed [greater/comparable] cross-subject consistency than S1 +> [paired t(7), p]. Because these are backward-model discriminative weights — +> dominated by a shared nociceptive component and sensitive to correlated-voxel +> rotation — they are not a direct readout of somatotopic representation; the +> somatotopy question is addressed by representational analysis of the evoked +> response patterns (data-level RSA). + +Frame the S1-vs-dpIns dissociation as a **hypothesis** supported by a small +effect, not an established result, and only if it survives the subject-level / +permutation tests. Avoid asserting "dpIns encodes shared interoceptive coding, +S1 idiosyncratic somatotopy" on the basis of Δr≈0.015–0.030. + +--- + +## 8. Threats to validity — flags + +1. **Wrong object for the question** (§0): weight-RSA ≠ somatotopy. *Highest + priority.* +2. **Non-independence** of pairwise cells → use subject-level/permutation. *Fixed.* +3. **Raw weights vs Haufe**: raw backward weights deflate cross-subject + correlation; rerun on Haufe maps before drawing representational conclusions. +4. **Shared nociceptive component** inflates pattern-expression off-diagonals → + keep correlation primary; consider partialling out the across-site mean map. +5. **Scale/norm confounds** across subjects → correlation (scale-free) primary; + never compare dot products across subjects without normalization. +6. **Parcel/ROI mask-size differences** change voxel counts and similarity + variance → never mix ROIs in one matrix; report per-parcel `n` voxels. +7. **Smoothing / registration**: cross-subject similarity is inflated by shared + smoothing/template structure and deflated by misregistration; both are + spatially confounded with the result. +8. **Class imbalance / fold structure**: leave-one-session-out CV with few + sessions makes fold weights noisy; use full-data weight for the signature. +9. **Self-correlations** (atanh(1)) — excluded by construction. *Fixed.* +10. **Over-interpreting small r** (Δr≈0.0x): statistically detectable ≠ + representationally meaningful. Report effect sizes, not just p. +11. **Low-SNR run-level test maps** in Accept/Distract: the generalization + "failure" may be a measurement-quality issue, not absence of signal. Try + subject-averaged single-trial maps or first-level betas before concluding. +12. **Leakage**: none across subjects; within-subject CV does not contaminate + cross-subject comparisons. + +— 2026 Michael Sun. GPL v3. diff --git a/CanlabCore/RSA_tools/add_subject_boundaries.m b/CanlabCore/RSA_tools/add_subject_boundaries.m new file mode 100644 index 00000000..e36fafda --- /dev/null +++ b/CanlabCore/RSA_tools/add_subject_boundaries.m @@ -0,0 +1,25 @@ +function add_subject_boundaries(ax, nSub, nSites) +% add_subject_boundaries Draw subject block separators on a signature RSA image. +% +% Overlays black grid lines at the boundaries between subjects on an imagesc plot +% of a (nSub*nSites) x (nSub*nSites) RSA matrix whose rows/cols are ordered +% subject-major, bodysite-minor. +% +% :Usage: +% :: +% imagesc(RSA.M, [-1 1]); ax = gca; +% add_subject_boundaries(ax, RSA.nSub, RSA.nSites); +% +% .. +% 2026 Michael Sun. GPL v3. +% .. + +hold(ax, 'on'); +bounds = (1:nSub-1) * nSites + 0.5; +for bnd = bounds + xline(ax, bnd, 'k-', 'LineWidth', 1); + yline(ax, bnd, 'k-', 'LineWidth', 1); +end +hold(ax, 'off'); + +end diff --git a/CanlabCore/RSA_tools/apply_diagonal_correction.m b/CanlabCore/RSA_tools/apply_diagonal_correction.m new file mode 100644 index 00000000..a8a23688 --- /dev/null +++ b/CanlabCore/RSA_tools/apply_diagonal_correction.m @@ -0,0 +1,121 @@ +function M = apply_diagonal_correction(M, group_idx, fold_idx, mode) +% apply_diagonal_correction Replace diagonal entries to remove within-fold bias. +% +% Reproduces (in generalized form) the patch from `generate_RSA3.m`: replace +% each diagonal cell with the mean of off-diagonal same-group cells, so that +% downstream cell-extraction (rsm.cells, rsm.contrast) does not pull a self- +% similarity (= 1 for correlation RSMs) into the same-group bin. +% +% Note on semantics +% ----------------- +% The original `generate_RSA3.m` recipe additionally constrained the +% averaged cells to come from different sessions (`ses_numbers(r1) ~= +% ses_numbers(r2)`). That filter relies on per-image session metadata +% which is lost after condition aggregation. In practice, since +% off-diagonal cells already compare DIFFERENT conditions (and therefore +% different images), the "same group, off-diagonal" mean is the natural +% generalization and matches the workflow's downstream cell-extraction +% semantics. The cross-session constraint is only meaningful at the +% image-level RSM, which `compute_rsm` exposes via level='image'. +% +% Inputs +% ------ +% M [k x k] or [k x k x N] similarity matrix +% +% group_idx [k x 1] integer condition labels (1..n_groups). Each entry i +% identifies which "diagonal-correction group" row/col i belongs +% to. Diagonals will be corrected per group. +% +% fold_idx [k x 1] integer fold labels (1..n_folds) -- IGNORED in the +% 'same_group_offdiag_mean' / 'across_session_mean' modes since +% fold information is not available at the condition level. +% Retained for API compatibility and for the image-level path +% when level='image' is used. Pass NaN(k,1) if unused. +% +% mode 'same_group_offdiag_mean' (default) -- mean of off-diagonal +% same-group cells. RECOMMENDED. Use this for condition-aggregated +% RSMs. +% +% 'across_session_mean' -- alias for 'same_group_offdiag_mean'. +% Kept for backwards compatibility with the original workflow +% naming. +% +% 'image_level_across_session' -- requires fold_idx; replaces +% each diagonal with the mean of cells (i,j) where group(i) == +% group(j) and fold(i) != fold(j). Only meaningful at image-level +% RSM (i.e. when compute_rsm was called with level='image'). +% +% 'nan' -- set each same-group diagonal entry to NaN. Useful when +% the user wants downstream code to apply its own handling. +% +% Output +% ------ +% M corrected matrix, same size as input + +if nargin < 4 || isempty(mode), mode = 'same_group_offdiag_mean'; end +mode = lower(char(mode)); +if strcmp(mode, 'across_session_mean'), mode = 'same_group_offdiag_mean'; end + +[k1, k2, N] = size(M); +assert(k1 == k2, 'apply_diagonal_correction:M must be square in first two dims.'); +k = k1; +group_idx = group_idx(:); +assert(numel(group_idx) == k, ... + 'apply_diagonal_correction: group_idx must be length k = %d.', k); + +if nargin < 3 || isempty(fold_idx), fold_idx = nan(k, 1); end +fold_idx = fold_idx(:); + +groups = unique(group_idx(~isnan(group_idx))); + +for n = 1:N + slice = M(:, :, n); + + for g_i = 1:numel(groups) + idx = find(group_idx == groups(g_i)); + if numel(idx) < 2, continue; end % singletons can't be corrected + + switch mode + case 'same_group_offdiag_mean' + % Pull off-diagonal cells within this group + sub = slice(idx, idx); + off_diag_mask = ~eye(numel(idx), 'logical'); + vals = sub(off_diag_mask); + vals = vals(isfinite(vals)); + if isempty(vals), new_val = NaN; else, new_val = mean(vals); end + + case 'image_level_across_session' + % Pull only cells where folds differ + vals = []; + for a = 1:numel(idx) + for b = 1:numel(idx) + if a == b, continue; end + if isnan(fold_idx(idx(a))) || isnan(fold_idx(idx(b))) + continue + end + if fold_idx(idx(a)) ~= fold_idx(idx(b)) + vals(end+1) = slice(idx(a), idx(b)); %#ok + end + end + end + vals = vals(isfinite(vals)); + if isempty(vals), new_val = NaN; else, new_val = mean(vals); end + + case 'nan' + new_val = NaN; + + otherwise + error('apply_diagonal_correction:badMode', ... + ['mode must be one of {same_group_offdiag_mean (default), ', ... + 'across_session_mean (alias), image_level_across_session, nan}; got %s.'], mode); + end + + for r = 1:numel(idx) + slice(idx(r), idx(r)) = new_val; + end + end + + M(:, :, n) = slice; +end + +end diff --git a/CanlabCore/RSA_tools/assemble_lme_table.m b/CanlabCore/RSA_tools/assemble_lme_table.m new file mode 100644 index 00000000..15f4b524 --- /dev/null +++ b/CanlabCore/RSA_tools/assemble_lme_table.m @@ -0,0 +1,408 @@ +function [tbl, info] = assemble_lme_table(dat, varargin) +% assemble_lme_table Build a long-format table for LME modeling of RSA data. +% +% Implements the contract from `RSA_tools/RSA_Phase3_LME_Design.md` §3: +% one row per (i, j) upper-triangle pair from a single subject's image-level +% RSM. Columns include Y (similarity, Fisher-z by default), Subject +% (categorical grouping for random effects), and binary "Same" +% columns for each metadata predictor + element-wise AND interaction columns. +% +% This is the engine behind @fmri_data/rsa_lme and @fmri_data/rsa_lm. It is +% also exported as a free function so users can build tables and run their +% own fitlme / fitlm calls. +% +% Usage +% ----- +% [tbl, info] = assemble_lme_table(dat, ... +% 'predictors', {'condition','bodysite','session_number'}, ... +% 'interactions', {{'condition','bodysite'}}, ... +% 'subject_var', 'sub') +% +% Inputs +% ------ +% dat fmri_data object with .dat (voxels x images) and .metadata_table +% containing the subject_var column and all predictor/interaction +% columns. +% +% Optional name-value pairs +% ------------------------- +% 'predictors' cellstr of metadata column names. Each becomes a +% binary 'Same' column in the output table. +% 'interactions' cell of cellstr (each inner cell length 2). Each +% pair-of-predictors becomes an element-wise AND +% interaction column. +% 'three_way' cell of cellstr (each inner cell length 3). 3-way ANDs. +% 'subject_var' Metadata column for subject IDs. Default 'subject_id'. +% 'pair_scope' 'within_subject' (default) | 'all'. The LME path +% wants within-subject. The fitlm/rsa_lm fixed-effects +% path may want all pairs. +% 'response_transform' 'fisherz' (default) | 'none' | 'rank'. +% 'metric' RSM construction metric. Default 'correlation'. +% 'short_names' struct mapping long metadata column -> short name +% for the output table (e.g. struct('subject_id','Subject')). +% Default: shortens commonly-suffixed names (drops _id, +% _number). +% 'verbose' logical (default true). +% +% Outputs +% ------- +% tbl table with columns: +% Y double, similarity per pair +% Subject categorical, grouping variable +% Same binary 0/1 per predictor +% Samex binary 0/1 per interaction +% +% info struct: +% .predictor_names cellstr of Same column names +% .interaction_names cellstr +% .three_way_names cellstr +% .subject_var actual subject column name +% .response_transform applied +% .n_pairs_per_subject [n_subjects x 1] +% .formula_skeleton suggested Wilkinson formula stub +% +% References +% ---------- +% Reproduces the per-subject table assembly in `08072024 Run-Level RDM +% Analysis with RSA Toolbox.mlx` lines 1804-1957. + +% ========================================================================= +% Parse inputs +% ========================================================================= +p = inputParser; +p.addParameter('predictors', {}, @(x) iscellstr(x) || isstring(x) || isempty(x)); %#ok +p.addParameter('interactions', {}, @iscell); +p.addParameter('three_way', {}, @iscell); +p.addParameter('subject_var', 'subject_id', @(x) ischar(x) || isstring(x)); +p.addParameter('pair_scope', 'within_subject', @(x) (ischar(x) || isstring(x)) && ismember(lower(char(x)), {'within_subject','all'})); +p.addParameter('response_transform', 'fisherz', @(x) (ischar(x) || isstring(x)) && ismember(lower(char(x)), {'fisherz','none','rank'})); +p.addParameter('metric', 'correlation', @(x) (ischar(x) || isstring(x)) && ismember(lower(char(x)), {'correlation','spearman','cosine'})); +p.addParameter('short_names', struct(), @isstruct); +p.addParameter('verbose', true, @(x) islogical(x) || isnumeric(x)); +p.parse(varargin{:}); +opt = p.Results; + +predictors = cellstr(opt.predictors); +interactions = opt.interactions; +three_way = opt.three_way; + +% Validate +if isempty(predictors) + error('assemble_lme_table:noPredictors', ... + 'Need at least one predictor metadata column.'); +end + +mt = dat.metadata_table; +if isempty(mt) + error('assemble_lme_table:noMetadata', 'dat.metadata_table is empty.'); +end + +subject_var = char(opt.subject_var); +% Allow common subject column aliases +if ~ismember(subject_var, mt.Properties.VariableNames) + aliases = {'subject_id','sub','subject','subj','sid'}; + hit = ''; + for i = 1:numel(aliases) + if ismember(aliases{i}, mt.Properties.VariableNames), hit = aliases{i}; break; end + end + if isempty(hit) + error('assemble_lme_table:noSubjectVar', ... + ['No subject column found in metadata_table. Tried: %s. ', ... + 'Pass ''subject_var'' explicitly.'], strjoin([{subject_var}, aliases], ', ')); + end + if opt.verbose + fprintf('assemble_lme_table: subject_var=''%s'' not found; using ''%s''.\n', subject_var, hit); + end + subject_var = hit; +end + +% Subject-in-predictors guard (design doc §6.2): only for the within-subject +% (LME) path, where Subject is the random-effects grouping. For pair_scope= +% 'all' (the rsa_lm fixed-effects path) SameSubject is a valid predictor. +if strcmpi(opt.pair_scope, 'within_subject') && any(strcmp(predictors, subject_var)) + error('assemble_lme_table:subjectInPredictors', ... + ['"%s" is the random-effects grouping variable; remove it from ''predictors''. ', ... + 'Use rsa_lm() if you want Subject as a fixed effect instead.'], subject_var); +end + +% For the all-pairs (fixed-effects) path, SameSubject is a valid predictor. +% Ensure subject_var is included exactly once in the predictor list so it is +% processed uniformly and reflected in info.predictor_names (no separate +% special-casing, no duplication). +if strcmpi(opt.pair_scope, 'all') && ~any(strcmp(predictors, subject_var)) + predictors = [{subject_var}, predictors]; +end + +% Check all predictor columns exist +for i = 1:numel(predictors) + if ~ismember(predictors{i}, mt.Properties.VariableNames) + error('assemble_lme_table:missingCol', ... + 'Predictor "%s" not found in metadata_table.', predictors{i}); + end +end + +% Build short-name mapping (short version used as Y-column suffix) +short_names = opt.short_names; +for i = 1:numel(predictors) + if ~isfield(short_names, predictors{i}) + short_names.(predictors{i}) = default_short_name(predictors{i}); + end +end +if ~isfield(short_names, subject_var) + short_names.(subject_var) = default_short_name(subject_var); +end + +% ========================================================================= +% Per-subject table blocks +% ========================================================================= +sub_ids_all = mt.(subject_var); +[~, subject_unique] = group_unique(sub_ids_all); +n_subj = numel(subject_unique); + +block_tables = cell(n_subj, 1); +n_pairs_per_subject = zeros(n_subj, 1); + +X = double(dat.dat); % voxels x n_images + +if strcmpi(opt.pair_scope, 'within_subject') + % Within-subject pairs only (default LME path) + for s = 1:n_subj + block_tables{s} = build_subject_block( ... + X, mt, sub_ids_all, subject_unique{s}, subject_var, ... + predictors, interactions, three_way, short_names, opt); + n_pairs_per_subject(s) = height(block_tables{s}); + end +else + % All pairs (fitlm path) -- treat as one giant block + block_tables{1} = build_omnibus_block( ... + X, mt, sub_ids_all, subject_var, ... + predictors, interactions, three_way, short_names, opt); + n_pairs_per_subject = height(block_tables{1}); +end + +tbl = vertcat(block_tables{:}); + +% ========================================================================= +% Build info struct + formula skeleton +% ========================================================================= +info = struct(); +info.predictor_names = cellfun(@(c) ['Same' short_names.(c)], predictors, 'UniformOutput', false); +info.interaction_names = cellfun(@(p) interaction_name(p, short_names), interactions, 'UniformOutput', false); +info.three_way_names = cellfun(@(p) interaction_name(p, short_names), three_way, 'UniformOutput', false); +info.subject_var = subject_var; +info.subject_var_short = short_names.(subject_var); +info.response_transform = opt.response_transform; +info.n_pairs_per_subject = n_pairs_per_subject; +info.metric = opt.metric; +info.pair_scope = opt.pair_scope; + +% Suggested formula skeleton +rhs_terms = [info.predictor_names, info.interaction_names, info.three_way_names]; +if strcmpi(opt.pair_scope, 'within_subject') + info.formula_skeleton = sprintf('Y ~ %s + (1 | %s)', ... + strjoin(rhs_terms, ' + '), info.subject_var_short); +else + info.formula_skeleton = sprintf('Y ~ %s', strjoin(rhs_terms, ' + ')); +end + +if opt.verbose + fprintf('assemble_lme_table: %d rows, %d subjects, %d predictors, %d interactions\n', ... + height(tbl), n_subj, numel(predictors), numel(interactions)); +end + +end + + +% ========================================================================= +function block = build_subject_block(X, mt, sub_ids_all, sid, subject_var, ... + predictors, interactions, three_way, short_names, opt) + +is_s = match_subject(sub_ids_all, sid); +sub_X = X(:, is_s); +sub_meta = mt(is_s, :); + +R_s = compute_rsm_single(sub_X, opt.metric); +% Upper-triangle vector +k = size(R_s, 1); +mask = triu(true(k), 1); +y = R_s(mask); + +% Apply response transform +y = apply_response_transform(y, opt.response_transform); + +n_pairs = numel(y); +block = table(y, 'VariableNames', {'Y'}); + +% Subject column (categorical) +sub_short = short_names.(subject_var); +block.(sub_short) = categorical(repmat({char(string(sid))}, n_pairs, 1)); + +% Predictor columns: SamePredictor = (v(i) == v(j)) on the upper-tri pairs +pred_vecs = struct(); % store for interactions +for p = 1:numel(predictors) + col = predictors{p}; + v = sub_meta.(col); + M = same_value_matrix(v); + vec = M(mask); + short_col = ['Same' short_names.(col)]; + block.(short_col) = double(vec); + pred_vecs.(col) = double(vec); +end + +% Interaction columns: element-wise AND of constituent predictors +for k_i = 1:numel(interactions) + parts = interactions{k_i}; + name = interaction_name(parts, short_names); + v = ones(n_pairs, 1); + for p = 1:numel(parts) + v = v .* pred_vecs.(parts{p}); + end + block.(name) = double(v); +end + +% Three-way interactions +for k_i = 1:numel(three_way) + parts = three_way{k_i}; + name = interaction_name(parts, short_names); + v = ones(n_pairs, 1); + for p = 1:numel(parts) + v = v .* pred_vecs.(parts{p}); + end + block.(name) = double(v); +end + +end + + +% ========================================================================= +function block = build_omnibus_block(X, mt, sub_ids_all, subject_var, ... + predictors, interactions, three_way, short_names, opt) +% Build a single all-pairs table (used for rsa_lm / pair_scope='all'). + +R_all = compute_rsm_single(X, opt.metric); +k = size(R_all, 1); +mask = triu(true(k), 1); +y = apply_response_transform(R_all(mask), opt.response_transform); +n_pairs = numel(y); + +block = table(y, 'VariableNames', {'Y'}); + +% subject_var has already been folded into `predictors` by the caller, so it +% is built here as just another Same predictor -- no special-casing. +pred_vecs = struct(); +for p = 1:numel(predictors) + col = predictors{p}; + v = mt.(col); + M = same_value_matrix(v); + vec = M(mask); + short_col = ['Same' short_names.(col)]; + block.(short_col) = double(vec); + pred_vecs.(col) = double(vec); +end + +% Interactions +for k_i = 1:numel(interactions) + parts = interactions{k_i}; + name = interaction_name(parts, short_names); + v = ones(n_pairs, 1); + for p = 1:numel(parts), v = v .* pred_vecs.(parts{p}); end + block.(name) = double(v); +end +for k_i = 1:numel(three_way) + parts = three_way{k_i}; + name = interaction_name(parts, short_names); + v = ones(n_pairs, 1); + for p = 1:numel(parts), v = v .* pred_vecs.(parts{p}); end + block.(name) = double(v); +end + +end + + +% ========================================================================= +function R = compute_rsm_single(X, metric) +% Compute one similarity matrix from voxels x images data. +m = lower(char(metric)); +switch m + case 'correlation' + R = corr(X, 'Type', 'Pearson', 'rows', 'pairwise'); + case 'spearman' + R = corr(X, 'Type', 'Spearman', 'rows', 'pairwise'); + case 'cosine' + norms = sqrt(sum(X.^2, 1)); + denom = norms' * norms; + R = (X' * X) ./ denom; + otherwise + error('assemble_lme_table:badMetric', 'Unknown metric: %s', m); +end +R = (R + R') / 2; % symmetrize +end + + +function y = apply_response_transform(y, mode) +mode = lower(char(mode)); +switch mode + case 'fisherz' + y(y > 0.9999999) = 0.9999999; + y(y < -0.9999999) = -0.9999999; + y = atanh(y); + case 'rank' + y = tiedrank(y); + case 'none' + % no-op +end +end + + +function M = same_value_matrix(v) +% k x k binary matrix where (i, j) = 1 iff v(i) == v(j). +if iscell(v) || isstring(v) || iscategorical(v) + [~, ~, codes] = unique(v, 'stable'); +else + codes = double(v); +end +codes = codes(:); +M = double(codes == codes'); +end + + +function name = interaction_name(parts, short_names) +% Predictable interaction column name: join the individual Same column +% names with 'x'. E.g. {condition,bodysite} -> 'SameConditionxSameBodysite'. +% This lets users construct the interaction column name directly from the +% main-effect column names they already know. +sames = cellfun(@(c) ['Same' short_names.(c)], parts, 'UniformOutput', false); +name = strjoin(sames, 'x'); +end + + +function s = default_short_name(col) +% Drop common suffixes; capitalize first letter. +s = char(col); +s = regexprep(s, '_id$', ''); +s = regexprep(s, '_number$', ''); +s = regexprep(s, '_no$', ''); +if isempty(s), s = char(col); end +if isstrprop(s(1), 'lower'), s(1) = upper(s(1)); end +% strip remaining underscores (won't be valid in Wilkinson formula without escapes) +s = regexprep(s, '[^A-Za-z0-9]', ''); +end + + +function is_match = match_subject(v, sid) +sid = char(string(sid)); +if iscell(v), is_match = strcmp(v, sid); +elseif iscategorical(v) || isstring(v), is_match = strcmp(string(v), sid); +else, is_match = (double(v) == double(string(sid))); +end +end + + +function [G, vals] = group_unique(v) +[G, vals] = findgroups(v); +if iscategorical(vals), vals = cellstr(vals); +elseif isstring(vals), vals = cellstr(vals); +elseif iscell(vals), % already cellstr +else, vals = cellfun(@(x) char(string(x)), num2cell(vals), 'UniformOutput', false); +end +end diff --git a/CanlabCore/RSA_tools/assign_vals_to_atlas.m b/CanlabCore/RSA_tools/assign_vals_to_atlas.m new file mode 100644 index 00000000..3bff8407 --- /dev/null +++ b/CanlabCore/RSA_tools/assign_vals_to_atlas.m @@ -0,0 +1,140 @@ +function [map, info] = assign_vals_to_atlas(atlas_obj, roi_names, vals, varargin) +% assign_vals_to_atlas Project per-parcel values onto a brain map. +% +% Builds a brain image where every voxel in parcel i is assigned the value +% supplied for that parcel. Returns a statistic_image (default) so the +% standard .threshold / .montage / .table chain works, or an fmri_data. +% +% Generalizes the bespoke `assign_vals` helper in the Sun et al. workflow. +% Used internally by fmri_data.rsa_parcelwise. +% +% Usage +% ----- +% % Minimal: assign a t-value per parcel name +% map = assign_vals_to_atlas(atlas, {'Ctx_V1_L','Ctx_V1_R'}, [3.1, 2.7]); +% +% % All parcels in atlas order (roi_names = [] uses atlas.labels) +% map = assign_vals_to_atlas(atlas, [], t_vals_per_parcel); +% +% % Full statistic_image with p-values for thresholding +% map = assign_vals_to_atlas(atlas, [], t_vals, ... +% 'p_vals', p_vals, 'output_type', 'statistic_image'); +% montage(threshold(map, 0.05, 'fdr')); % function syntax: statistic_image +% % has a `threshold` property AND method +% +% Inputs +% ------ +% atlas_obj atlas object (the spatial template) +% roi_names cellstr of parcel labels to assign (must match atlas.labels). +% Pass [] to assign all atlas parcels in label order (then +% numel(vals) must equal num_regions(atlas)). +% vals numeric vector, one value per roi_name (or per parcel). +% +% Optional name-value +% ------------------- +% 'output_type' 'statistic_image' (default) | 'fmri_data' +% 'p_vals' numeric vector matching vals -- per-parcel p-values. When +% supplied (statistic_image output), fills .p so thresholding +% works. Default [] (sig set to all-true). +% 'fill' value for voxels not in any assigned parcel. Default 0. +% 'dat_descrip' string description stored on the output. Default ''. +% +% Outputs +% ------- +% map statistic_image or fmri_data in atlas space +% info struct: .assigned_parcels, .n_assigned, .missing_names + +p = inputParser; +p.addParameter('output_type', 'statistic_image', @(x) (ischar(x) || isstring(x)) && ismember(lower(char(x)), {'statistic_image','fmri_data'})); +p.addParameter('p_vals', [], @(x) isempty(x) || isnumeric(x)); +p.addParameter('fill', 0, @isnumeric); +p.addParameter('dat_descrip', '', @(x) ischar(x) || isstring(x)); +p.parse(varargin{:}); +opt = p.Results; + +if ~isa(atlas_obj, 'atlas') + error('assign_vals_to_atlas:notAtlas', 'First argument must be an atlas object.'); +end + +labels = atlas_obj.labels; +n_parcels = num_regions(atlas_obj); + +% Resolve roi_names -> parcel indices +if isempty(roi_names) + if numel(vals) ~= n_parcels + error('assign_vals_to_atlas:valCountMismatch', ... + 'roi_names empty implies all %d parcels, but numel(vals)=%d.', ... + n_parcels, numel(vals)); + end + parcel_idx = (1:n_parcels)'; + use_names = labels(:); +else + roi_names = cellstr(roi_names); + if numel(vals) ~= numel(roi_names) + error('assign_vals_to_atlas:valCountMismatch', ... + 'numel(roi_names)=%d but numel(vals)=%d.', numel(roi_names), numel(vals)); + end + parcel_idx = nan(numel(roi_names), 1); + for i = 1:numel(roi_names) + hit = find(strcmp(labels, roi_names{i}), 1, 'first'); + if ~isempty(hit), parcel_idx(i) = hit; end + end + use_names = roi_names(:); +end + +missing = isnan(parcel_idx); +info = struct(); +info.missing_names = use_names(missing); +if any(missing) + warning('assign_vals_to_atlas:missingParcels', ... + '%d roi_name(s) not found in atlas labels: %s', ... + sum(missing), strjoin(info.missing_names, ', ')); +end + +% Build reference in atlas space; .dat holds integer parcel codes. +% Wrap in evalc to suppress the int32->single bit-rate conversion chatter. +[~, ref] = evalc('fmri_data(atlas_obj, ''noverbose'')'); +codes = round(ref.dat(:, 1)); % integer parcel code per voxel + +out_dat = opt.fill * ones(size(codes)); +p_dat = ones(size(codes)); % default p=1 (non-sig) where unassigned + +have_p = ~isempty(opt.p_vals); +if have_p && numel(opt.p_vals) ~= numel(vals) + error('assign_vals_to_atlas:pValCountMismatch', ... + 'numel(p_vals)=%d but numel(vals)=%d.', numel(opt.p_vals), numel(vals)); +end + +assigned = {}; +for i = 1:numel(parcel_idx) + if isnan(parcel_idx(i)), continue; end + vmask = (codes == parcel_idx(i)); + if ~any(vmask), continue; end + out_dat(vmask) = vals(i); + if have_p, p_dat(vmask) = opt.p_vals(i); end + assigned{end+1} = use_names{i}; %#ok +end +info.assigned_parcels = assigned; +info.n_assigned = numel(assigned); + +% Build the output object +if strcmpi(opt.output_type, 'fmri_data') + map = ref; + map.dat = out_dat; + if ~isempty(char(opt.dat_descrip)), map.dat_descrip = char(opt.dat_descrip); end +else + % statistic_image: start from the fmri_data reference and cast + map = statistic_image('dat', out_dat, 'volInfo', ref.volInfo, ... + 'p', p_dat, 'type', 'generic', 'removed_voxels', ref.removed_voxels); + map.dat = out_dat; + map.p = p_dat; + % sig: if p_vals supplied, default sig at p<.05; else everything assigned is "sig" + if have_p + map.sig = p_dat < 0.05 & out_dat ~= opt.fill; + else + map.sig = (out_dat ~= opt.fill); + end + if ~isempty(char(opt.dat_descrip)), map.dat_descrip = char(opt.dat_descrip); end +end + +end diff --git a/CanlabCore/RSA_tools/build_crosssubject_signature_rsa.m b/CanlabCore/RSA_tools/build_crosssubject_signature_rsa.m new file mode 100644 index 00000000..e1cce6fb --- /dev/null +++ b/CanlabCore/RSA_tools/build_crosssubject_signature_rsa.m @@ -0,0 +1,211 @@ +function RSA = build_crosssubject_signature_rsa(svm_stats_cell, subjects, bodysite_names, varargin) +% build_crosssubject_signature_rsa Cross-subject x cross-bodysite signature RSA matrix. +% +% Builds the full (nSub*nSites) x (nSub*nSites) similarity matrix among learned +% per-subject x bodysite signature maps (e.g. SVM weight maps), with bookkeeping +% that lets every downstream test exclude same-subject and self comparisons. +% +% WHAT THIS COMPUTES (and what it does NOT) +% The maps are *backward-model* SVM weight maps that discriminate the within- +% site contrast (e.g. hot vs warm). Their cross-map similarity therefore +% measures "do two decoders point the same way", NOT "is bodysite identity +% represented somatotopically". For the somatotopy question, run RSA on the +% *evoked response patterns* (compute_rsm) or pass Haufe / structure-coefficient +% maps here instead of raw weights. See SIGNATURE_RSA_AUDIT.md. +% +% DESIGN CHOICES THAT MAKE THIS DEFENSIBLE +% - Default metric is 'correlation' (scale-free, the primary RSA-like measure). +% 'cosine' and 'dotproduct' are magnitude-sensitive and provided as secondary +% diagnostics only. +% - All maps are pulled into one voxels x conditions matrix on a COMMON voxel +% support, so the matrix is exactly symmetric and every cell uses the same +% voxels (unlike apply_mask(a,b,...) whose included-voxel set depends on +% argument order when supports differ). +% - Self/diagonal entries are filled but flagged; same-subject pairs are flagged. +% Downstream inference must use RSA.cross_subject_mask, never the diagonal. +% +% :Usage: +% :: +% RSA = build_crosssubject_signature_rsa(svm_stats_cell, subjects, bodysite_names) +% RSA = build_crosssubject_signature_rsa(..., 'metric','correlation') +% RSA = build_crosssubject_signature_rsa(..., 'map_extractor', @(c) c{1}.weight_obj) +% +% :Inputs: +% **svm_stats_cell:** nSub x 1 cell; svm_stats_cell{s} is nSites x 1 cell. +% By default each leaf svm_stats_cell{s}{b} is unwrapped via the +% map_extractor to an fmri_data / image_vector map. +% **subjects:** cellstr of subject IDs (length nSub). +% **bodysite_names:** cellstr of bodysite names in matching order (length nSites). +% +% :Optional Inputs: +% **'metric':** 'correlation' (default) | 'cosine' | 'dotproduct'. +% **'map_extractor':** function handle mapping svm_stats_cell{s}{b} to a map +% object. Default @(c) c{1}.weight_obj (the obligatory +% single-element cell wrap, then the full-data weight map). +% **'doverbose':** print progress / QC (default true). +% +% :Outputs: +% **RSA:** struct with fields +% .M nTotal x nTotal similarity matrix (r-space if 'correlation') +% .subject_idx nTotal x 1 subject index per row/col +% .site_idx nTotal x 1 bodysite index per row/col +% .labels nTotal x 1 'subj | site' labels +% .nSub, .nSites, .metric, .subjects, .bodysite_names +% .cross_subject_mask nTotal x nTotal logical, true where subjects differ +% .same_site_mask nTotal x nTotal logical, true where sites match +% .nvox_common number of voxels in the common support actually used +% .qc struct of diagnostics (per-map voxel counts, dropped maps) +% +% :Examples: +% :: +% subjects = {'sub-SID000002','sub-SID000743'}; %#ok +% bodysites = {'leftface','rightface'}; %#ok +% % RSA = build_crosssubject_signature_rsa(svm_stats_cell_dpIns, subjects, bodysites); +% % plot_same_vs_different_site(RSA); +% +% :See also: get_crosssubject_site_effect, subject_level_crosssubject_effect, +% permutation_test_site_specificity, collapse_rsa_to_bodysite_matrix +% +% .. +% 2026 Michael Sun. GPL v3. +% .. + +% ---------- INPUT PARSER ---------- +% Keyword-pair options. 'plot'/'verbose' analogues per CanlabCore convention. +p = inputParser; +p.addRequired('svm_stats_cell', @iscell); +p.addRequired('subjects', @(x) iscell(x) || isstring(x)); +p.addRequired('bodysite_names', @(x) iscell(x) || isstring(x)); +p.addParameter('metric', 'correlation', @(x) ischar(x) || isstring(x)); +p.addParameter('map_extractor', @(c) c{1}.weight_obj, @(x) isa(x,'function_handle')); +p.addParameter('doverbose', true, @(x) islogical(x) || isnumeric(x)); +p.parse(svm_stats_cell, subjects, bodysite_names, varargin{:}); + +metric = lower(char(p.Results.metric)); +map_extractor = p.Results.map_extractor; +doverbose = logical(p.Results.doverbose); +subjects = cellstr(subjects); +bodysite_names= cellstr(bodysite_names); + +validmetrics = {'correlation','cosine','dotproduct'}; +if ~ismember(metric, validmetrics) + error('build_crosssubject_signature_rsa:metric', ... + 'metric must be one of: %s', strjoin(validmetrics, ', ')); +end + +nSub = numel(subjects); +nSites = numel(bodysite_names); +nTotal = nSub * nSites; + +% ---------- INDEX BOOKKEEPING ---------- +subject_idx = nan(nTotal,1); +site_idx = nan(nTotal,1); +labels = cell(nTotal,1); +k = 0; +for s = 1:nSub + for b = 1:nSites + k = k + 1; + subject_idx(k) = s; + site_idx(k) = b; + labels{k} = sprintf('%s | %s', subjects{s}, bodysite_names{b}); + end +end + +% ---------- EXTRACT MAPS INTO A COMMON-SPACE MATRIX ---------- +% W is voxels x nTotal. We require every map to share the same in-mask voxel +% count (true for same-ROI signatures). If they differ, we error with guidance +% rather than silently comparing mismatched spaces. +W = []; +nvox_per_map = nan(nTotal,1); +dropped = false(nTotal,1); + +for c = 1:nTotal + s = subject_idx(c); b = site_idx(c); + try + mapobj = map_extractor(svm_stats_cell{s}{b}); + catch ME + warning('build_crosssubject_signature_rsa:extract', ... + 'Could not extract map for subject %d site %d (%s). Column set to NaN.', ... + s, b, ME.message); + dropped(c) = true; + continue + end + + % Pull a full, aligned voxel vector. replace_empty re-expands image_vector + % objects to original space so columns are voxel-aligned even if some maps + % had removed voxels. Plain structs with a .dat field are used as-is. + if isobject(mapobj) && ismethod(mapobj, 'replace_empty') + mapobj = replace_empty(mapobj); + end + v = double(mapobj.dat(:)); + + if isempty(W) + W = nan(numel(v), nTotal); + elseif numel(v) ~= size(W,1) + error('build_crosssubject_signature_rsa:space', ... + ['Map for subject %d site %d has %d voxels but expected %d. ', ... + 'All maps must share the same voxel space. Resample first ', ... + '(e.g. resample_space) or build one RSA per ROI.'], ... + s, b, numel(v), size(W,1)); + end + W(:,c) = v; + nvox_per_map(c) = sum(isfinite(v) & v~=0); +end + +% ---------- COMMON VALID-VOXEL SUPPORT ---------- +% Keep voxels finite in every retained column and not identically zero across +% all of them. This guarantees a symmetric matrix computed on shared voxels. +keepcol = ~dropped; +valid_rows = all(isfinite(W(:,keepcol)), 2) & any(W(:,keepcol) ~= 0, 2); +Wv = W(valid_rows, :); +nvox_common = sum(valid_rows); + +if nvox_common < 3 + error('build_crosssubject_signature_rsa:support', ... + 'Common valid voxel support is only %d voxels — cannot compute similarity.', nvox_common); +end +if doverbose + fprintf('build_crosssubject_signature_rsa: %d conditions, %d common voxels, metric=%s\n', ... + nTotal, nvox_common, metric); + if any(dropped) + fprintf(' WARNING: %d map(s) could not be extracted and are NaN in M.\n', sum(dropped)); + end +end + +% ---------- SIMILARITY ---------- +M = nan(nTotal, nTotal); +switch metric + case 'correlation' + % Pearson correlation among columns over the common support. Symmetric. + M(keepcol, keepcol) = corr(Wv(:,keepcol)); + case 'cosine' + Wn = Wv(:,keepcol); + Wn = Wn ./ vecnorm(Wn); + M(keepcol, keepcol) = Wn' * Wn; + case 'dotproduct' + M(keepcol, keepcol) = Wv(:,keepcol)' * Wv(:,keepcol); +end +% Enforce exact numerical symmetry (guards against round-off). +M = (M + M') / 2; + +% ---------- MASKS FOR DOWNSTREAM INFERENCE ---------- +cross_subject_mask = subject_idx ~= subject_idx'; % true where subjects differ +same_site_mask = site_idx == site_idx'; % true where sites match + +% ---------- PACK ---------- +RSA = struct(); +RSA.M = M; +RSA.subject_idx = subject_idx; +RSA.site_idx = site_idx; +RSA.labels = labels; +RSA.nSub = nSub; +RSA.nSites = nSites; +RSA.metric = metric; +RSA.subjects = subjects; +RSA.bodysite_names = bodysite_names; +RSA.cross_subject_mask = cross_subject_mask; +RSA.same_site_mask = same_site_mask; +RSA.nvox_common = nvox_common; +RSA.qc = struct('nvox_per_map', nvox_per_map, 'dropped', dropped); + +end diff --git a/CanlabCore/RSA_tools/build_fold_pattern_matrices.m b/CanlabCore/RSA_tools/build_fold_pattern_matrices.m new file mode 100644 index 00000000..c266e598 --- /dev/null +++ b/CanlabCore/RSA_tools/build_fold_pattern_matrices.m @@ -0,0 +1,46 @@ +function Xfolds = build_fold_pattern_matrices(dat_mat, group_idx, fold_idx) +% build_fold_pattern_matrices Build per-fold [k x voxels] pattern matrices. +% +% Generalizes `build_accept_runfold_matrices` from +% `generate_RSA_accept_crossnobis.m`. For each unique fold f, returns a +% [k x voxels] matrix where row i is the mean pattern for condition i within +% fold f. +% +% Inputs +% dat_mat [voxels x n_images] data matrix +% group_idx [n_images x 1] integer condition labels (1..k) +% fold_idx [n_images x 1] integer fold labels (1..n_folds) +% +% Output +% Xfolds {n_folds x 1} cell, each cell is [k x voxels] + +n_images = size(dat_mat, 2); +if numel(group_idx) ~= n_images || numel(fold_idx) ~= n_images + error('build_fold_pattern_matrices:badShape', ... + 'group_idx and fold_idx must each be length size(dat_mat,2) = %d.', n_images); +end + +group_idx = group_idx(:); +fold_idx = fold_idx(:); + +folds = unique(fold_idx); +groups = unique(group_idx); +k = numel(groups); +n_folds = numel(folds); +n_vox = size(dat_mat, 1); + +Xfolds = cell(n_folds, 1); +for f = 1:n_folds + X = nan(k, n_vox); + is_fold = fold_idx == folds(f); + for g = 1:k + is_group = group_idx == groups(g); + rows = is_fold & is_group; + if any(rows) + X(g, :) = mean(dat_mat(:, rows), 2, 'omitnan')'; + end + end + Xfolds{f} = X; +end + +end diff --git a/CanlabCore/RSA_tools/collapse_rsa_to_bodysite_matrix.m b/CanlabCore/RSA_tools/collapse_rsa_to_bodysite_matrix.m new file mode 100644 index 00000000..a1b4418c --- /dev/null +++ b/CanlabCore/RSA_tools/collapse_rsa_to_bodysite_matrix.m @@ -0,0 +1,69 @@ +function [G, G_z] = collapse_rsa_to_bodysite_matrix(RSA, exclude_within_subject) +% collapse_rsa_to_bodysite_matrix Collapse subject x site RSA to a site x site matrix. +% +% Averages the full (nSub*nSites)^2 signature RSA into an nSites x nSites +% bodysite-by-bodysite matrix. By default (recommended) within-subject pairs are +% excluded, so the result is a purely CROSS-SUBJECT representational similarity +% matrix: G(i,j) = mean similarity of site-i maps to site-j maps across different +% subjects. The diagonal G(i,i) is then the cross-subject same-site consistency +% (NOT trivial self-correlation, because i and j come from different subjects). +% +% Averaging is done in Fisher-z space for the correlation metric (then mapped +% back to r), and in raw space otherwise. +% +% :Usage: +% :: +% [G, G_z] = collapse_rsa_to_bodysite_matrix(RSA) +% G = collapse_rsa_to_bodysite_matrix(RSA, false) % include within-subject +% +% :Inputs: +% **RSA:** struct from build_crosssubject_signature_rsa. +% **exclude_within_subject:** logical, default true. +% +% :Outputs: +% **G:** nSites x nSites mean similarity matrix in r-space (or raw for +% non-correlation metrics). +% **G_z:** the matrix in z-space (correlation metric only; else []). +% +% :See also: build_crosssubject_signature_rsa, get_sitewise_crosssubject_similarity +% +% .. +% 2026 Michael Sun. GPL v3. +% .. + +if nargin < 2 || isempty(exclude_within_subject) + exclude_within_subject = true; +end + +nSites = RSA.nSites; +M = RSA.M; +iscorr = strcmp(RSA.metric, 'correlation'); + +% Base pair mask: exclude self (diagonal). Optionally exclude same-subject. +base = ~eye(size(M)); +if exclude_within_subject + base = base & RSA.cross_subject_mask; +end + +G = nan(nSites, nSites); +G_z = nan(nSites, nSites); + +for i = 1:nSites + for j = 1:nSites + sel = base & (RSA.site_idx == i) & (RSA.site_idx' == j); + vals = M(sel); + vals = vals(isfinite(vals)); + if isempty(vals), continue; end + if iscorr + zz = rsa_fisher_z(vals); + G_z(i,j) = mean(zz, 'omitnan'); + G(i,j) = tanh(G_z(i,j)); % report mean in r-space + else + G(i,j) = mean(vals, 'omitnan'); + end + end +end + +if ~iscorr, G_z = []; end + +end diff --git a/CanlabCore/RSA_tools/examples/make_synthetic_rsa_data.m b/CanlabCore/RSA_tools/examples/make_synthetic_rsa_data.m new file mode 100644 index 00000000..6a2c877d --- /dev/null +++ b/CanlabCore/RSA_tools/examples/make_synthetic_rsa_data.m @@ -0,0 +1,63 @@ +function dat = make_synthetic_rsa_data(varargin) +% make_synthetic_rsa_data Generate a planted-structure dataset for the RSA examples. +% +% Builds an fmri_data object with metadata columns {sub, sesno, condition, +% bodysite} and a planted representational structure: images sharing a +% condition are similar (strong), images sharing a bodysite are similar +% (weaker), plus per-subject and per-session offsets and noise. +% +% Usage +% dat = make_synthetic_rsa_data(); % defaults +% dat = make_synthetic_rsa_data('n_sub', 9, 'n_ses', 5); +% +% Optional name-value: n_vox, n_sub, n_ses, cond_weight, bs_weight, noise, seed + +p = inputParser; +p.addParameter('n_vox', 200); +p.addParameter('n_sub', 9); +p.addParameter('n_ses', 5); +p.addParameter('cond_weight', 0.7); +p.addParameter('bs_weight', 0.5); +p.addParameter('noise', 0.4); +p.addParameter('seed', 42); +p.parse(varargin{:}); +o = p.Results; + +rng(o.seed); +conditions = {'hot','warm','imagine'}; +bodysites = {'leftface','rightface','leftarm','rightarm','leftleg','rightleg','chest','abdomen'}; +n_cond = numel(conditions); n_bs = numel(bodysites); + +cond_sig = randn(n_cond, o.n_vox); +bs_sig = randn(n_bs, o.n_vox); +P = zeros(n_cond*n_bs, o.n_vox); row = 0; +for c = 1:n_cond + for b = 1:n_bs + row = row + 1; + P(row, :) = o.cond_weight*cond_sig(c,:) + o.bs_weight*bs_sig(b,:) + 0.05*randn(1,o.n_vox); + end +end + +X = []; sub_v = {}; ses_v = []; cond_v = {}; bs_v = {}; idx = 0; +for s = 1:o.n_sub + sub_offset = 0.15*randn(1, o.n_vox); + for se = 1:o.n_ses + ses_offset = 0.1*randn(1, o.n_vox); + for c = 1:n_cond + for b = 1:n_bs + idx = idx + 1; + X(:, idx) = P((c-1)*n_bs+b, :)' + sub_offset' + ses_offset' + o.noise*randn(o.n_vox, 1); %#ok + sub_v{idx,1} = sprintf('sub-%02d', s); %#ok + ses_v(idx,1) = se; %#ok + cond_v{idx,1} = conditions{c}; %#ok + bs_v{idx,1} = bodysites{b}; %#ok + end + end + end +end + +dat = fmri_data; +dat.dat = X; +dat.metadata_table = table(sub_v, ses_v, cond_v, bs_v, ... + 'VariableNames', {'sub','sesno','condition','bodysite'}); +end diff --git a/CanlabCore/RSA_tools/examples/rsa_acceptmap_pipeline.m b/CanlabCore/RSA_tools/examples/rsa_acceptmap_pipeline.m new file mode 100644 index 00000000..f5bbd94b --- /dev/null +++ b/CanlabCore/RSA_tools/examples/rsa_acceptmap_pipeline.m @@ -0,0 +1,132 @@ +%% AcceptMap RSA pipeline +% Full representational-similarity analysis of the WASABI AcceptMap dataset. +% +% DESIGN +% 8 subjects. The experimental factor is ACCEPTANCE (0 = experience, +% 1 = accept). Shared-anchor bodysite design: every subject has a +% "leftface" site plus ONE idiosyncratic "other" site. We recode bodysite +% to {leftface, other}. +% +% IMPORTANT -- why this differs from DistractMap: +% acceptance is CONFOUNDED with session (each session contains only one +% acceptance level). So there is NO session-to-session replication axis and +% classic RSM reliability across sessions is undefined. The natural analysis +% is the CROSSNOBIS representational dissimilarity matrix over the 4 +% conditions {acceptance x bodysite_type}, with cross-validation folds built +% from the within-condition image repeats (fold_var='occurrence'). +% This reproduces the generate_RSA_accept_crossnobis design. +% +% PIPELINE +% 1. Load + prepare metadata +% 2. Build the crossnobis RDM (per subject + group) +% 3. Visualize the representational geometry +% 4. Cell-level contrasts on the RDM (acceptance vs bodysite effects) +% 5. Compare the brain RDM to model RDMs (acceptance / bodysite models) +% 6. (Optional) Searchlight RSA of the acceptance model + +%% 1. Load and prepare ---------------------------------------------------- +datafile = "\\dartfs-hpc\rc\lab\C\CANlab\labdata\projects\WASABI\WASABI_N_of_Few\analysis\04302026 MultiModal Run Maps.mat"; +load(datafile, 'acceptmap_run'); + +T = acceptmap_run.metadata_table; +T.bodysite_type = rsa_recode_reference(T.bodysite, 'leftface', 'other_label', 'other'); +acceptmap_run.metadata_table = T; + +fprintf('AcceptMap: %d images, %d subjects, acceptance levels: %s\n', ... + height(T), numel(unique(T.subject_id)), num2str(unique(T.acceptance)')); + +%% 2. Build the crossnobis RDM ------------------------------------------- +% 4 conditions: {acc0,acc1} x {leftface,other}. Folds = within-condition +% occurrence rank (no metadata column lines the conditions into shared folds, +% so use the 'occurrence' auto-mode, ordered by run_number). +R = compute_rsm(acceptmap_run, 'group_by', {'acceptance','bodysite_type'}, ... + 'subject_var', 'subject_id', 'metric', 'crossnobis', ... + 'fold_var', 'occurrence', 'run_var', 'run_number', 'level', 'subject'); + +fprintf('Crossnobis RDM: [%s] (4 conditions x %d subjects)\n', num2str(size(R)), size(R,3)); +disp('Labels:'); disp(R.labels'); + +%% 3. Visualize the representational geometry ---------------------------- +figure('Name','AcceptMap group RDM'); +plot(mean(R)); +title('AcceptMap group-mean crossnobis RDM'); + +figure('Name','AcceptMap RDM MDS'); +plot(mean(R), 'mode', 'mds'); +title('AcceptMap MDS (4 conditions)'); + +% Group-mean RDM as a table +m = mean(R.dat, 3, 'omitnan'); +disp('Group-mean crossnobis RDM:'); +disp(array2table(m, 'RowNames', matlab.lang.makeValidName(R.labels), ... + 'VariableNames', matlab.lang.makeValidName(R.labels))); + +%% 4. Cell-level contrasts on the RDM ------------------------------------ +% Auto-attached groupings include the acceptance levels (0, 1) and the +% bodysite_types (leftface, other). For a dissimilarity matrix, larger cells +% = more distinct representations. +disp('Available groupings:'); disp(fieldnames(R.groupings)'); + +% Cross-acceptance distance (same bodysite, different acceptance) vs +% cross-bodysite distance (same acceptance, different bodysite). +% In RDM space these are between-grouping cells. +v_cross_acc = R.cells('x0', 'x1', 'transform', 'none'); % acc0 vs acc1 cells +fprintf('\nMean cross-acceptance distance per subject:\n'); +disp(v_cross_acc'); +[~, p_acc, ~, st_acc] = ttest(v_cross_acc, 0, 'tail', 'right'); +fprintf('Cross-acceptance distance > 0: t(%d)=%.2f, p=%.4g\n', st_acc.df, st_acc.tstat, p_acc); + +%% 5. Compare brain RDM to model RDMs ------------------------------------ +% Does the brain's geometry match an "acceptance" model (conditions cluster +% by acceptance) or a "bodysite" model (cluster by bodysite_type)? +result = R.compare({'acceptance','bodysite_type'}, ... + 'correlation_type', 'kendall_taua'); +fprintf('\nModel-RDM comparison (Kendall tau-a):\n'); +for i = 1:numel(result.candidate_names) + star = ''; if result.relatedness_sig(i), star = '*'; end + fprintf(' %-14s r=%+.3f p=%.4g %s\n', result.candidate_names{i}, ... + result.r_mean(i), result.relatedness_p_corr(i), star); +end +fprintf(' noise ceiling: [%.3f %.3f]\n', result.noise_ceiling(1), result.noise_ceiling(2)); +if ~isnan(result.differences_p(1,2)) + fprintf(' acceptance vs bodysite model difference: p=%.4g\n', result.differences_p(1,2)); +end + +%% 6. Parcelwise brain maps ---------------------------------------------- +% Where in the brain is the acceptance distinction represented? Run the +% crossnobis RDM per parcel and map the cross-acceptance vs cross-bodysite +% contrast. (Groupings here are the acceptance levels x0/x1 and bodysite +% types leftface/other; reference those auto-built names.) +atlas = load_atlas('canlab2024'); +results = acceptmap_run.rsa_parcelwise('atlas', atlas, ... + 'group_by', {'acceptance','bodysite_type'}, 'subject_var', 'subject_id', ... + 'metric', 'crossnobis', 'fold_var', 'occurrence', 'run_var', 'run_number', ... + 'contrasts', {'crossAcc_vs_crossBodysite', {'x0','x1'}, {'leftface','other'}}, ... + 'correction', 'fdr', 'tail', 'right'); +% NOTE: statistic_image has BOTH a `threshold` property and a `threshold` +% method, so the dot form `map.threshold(0.05,'unc')` indexes the property +% (badsubscript error). Always call threshold() with function syntax. +map = threshold(results.maps.crossAcc_vs_crossBodysite, 0.05, 'unc'); +montage(map); + +%% 7. Searchlight RSA ----------------------------------------------------- +% Where does the LOCAL geometry match the acceptance model? Restrict to a +% gray-matter mask for speed. +gray = fmri_data(which('gray_matter_mask.img'), 'noverbose'); +sl = acceptmap_run.searchlight_rsa('acceptance', 'radius', 3, ... + 'group_by', {'acceptance','bodysite_type'}, 'subject_var', 'subject_id', ... + 'metric', 'correlation', 'compare', 'spearman', 'mask', gray); + +% IMPORTANT: with permutations=0 (default) the map holds RAW correlations and +% its .p values are placeholders (all 1). View it DIRECTLY -- do NOT call +% threshold(map,0.05,'unc') here (you'd get an empty map / "no results"). +montage(sl.maps.acceptance); % raw r map (correct view) + +% For p-thresholded inference, pass permutations>0 (slower); then threshold works: +% sl = acceptmap_run.searchlight_rsa('acceptance', 'radius', 3, ... +% 'group_by', {'acceptance','bodysite_type'}, 'subject_var', 'subject_id', ... +% 'metric', 'correlation', 'compare', 'spearman', 'mask', gray, ... +% 'permutations', 500); +% montage(threshold(sl.maps.acceptance, 0.05, 'unc')); + +fprintf('\nAcceptMap pipeline complete.\n'); diff --git a/CanlabCore/RSA_tools/examples/rsa_bodymap_pipeline.m b/CanlabCore/RSA_tools/examples/rsa_bodymap_pipeline.m new file mode 100644 index 00000000..745d1566 --- /dev/null +++ b/CanlabCore/RSA_tools/examples/rsa_bodymap_pipeline.m @@ -0,0 +1,284 @@ +%% BodyMap RSA pipeline (Sun et al., 2026) -- run-confound controlled +% Full representational-similarity analysis of the WASABI BodyMap dataset +% using the CanlabCore RSA tools, reproducing the Sun et al. 2026 figures +% with explicit control of the SHARED-RUN confound. +% +% DESIGN +% 9 subjects, 5-7 sessions each. Each RUN delivers Hot / Warm / Imagine +% trials to ONE of 8 bodysites (abdomen, chest, leftarm, leftface, leftleg, +% rightarm, rightface, rightleg). The run-level betas give 24 conditions +% per subject = 8 bodysites x 3 conditions, each measured once per session +% => 5-7 session replicates. Each (bodysite, session) is exactly ONE run, +% so RUN == bodysite x session. +% +% THE SHARED-RUN CONFOUND (why this script exists) +% Hot, Warm, and Imagine are ALWAYS estimated from the SAME run (the three +% trial types co-occur within each bodysite's run). So the three conditions +% of a bodysite share that run's noise (motion, physiology, drift) AND the +% run/bodysite mean response. That shared structure massively inflates the +% same-bodysite cross-condition similarities (Hot-Warm, Hot-Imagine, +% Imagine-Warm) -- the very cells used to judge condition geometry -- and +% confounds bodysite clustering too (the run IS the bodysite). In this +% dataset the inflation is ~8x: same-bodysite cross-condition similarity in +% S1 is z=0.44 within-run vs z=0.06 across-run (section 2). The fix is to +% NEVER compare two patterns from the same run -- only across runs/sessions. +% +% This reproduces the published approach (Representational Similarity +% Analysis FullCode.mlx), which built run-level RSMs and then EXCLUDED +% within-session(=within-run) correlations, replacing same-bodysite +% cross-condition cells with across-session-only averages. Here we get the +% same result more cleanly, two API-supported ways: +% (1) CROSSNOBIS with cross-validation folds = SESSIONS (== runs here). +% The within-fold condition difference cancels the run-common +% component, and the cross-fold product has zero expectation for +% run/session noise -> unbiased RDM by construction (Walther 2016). +% (2) MIXED-EFFECTS model with a SAME-RUN nuisance term (runid). The +% SameRunid coefficient absorbs the within-run inflation, so +% SameBodysite/SameCondition are estimated from ACROSS-run pairs only. +% +% PIPELINE +% 1. Load + prepare +% 2. Diagnose the shared-run confound (quantify the inflation) +% 3. Build RSMs: run-controlled (crossnobis) + naive (for contrast) +% 4. Visualize representational geometry (Fig 7A/B) +% 5. Model RDMs: bodysite / condition / cross-condition (Fig 7C) +% 6. Cell-level condition contrasts (Fig 7D-F) +% 7. Formal model comparison + noise ceiling +% 8. Multi-level LME with a SAME-RUN nuisance +% 9. Parcelwise brain maps (Fig 7G / S8 / S9) +% 10. Searchlight RSA +% +% Requires: CanlabCore on the path. Reload class methods if edited: +% clear classes; rehash path; addpath(genpath('.../CanlabCore')); + +%% 1. Load and prepare ---------------------------------------------------- +datafile = "\\dartfs-hpc\rc\lab\C\CANlab\labdata\projects\WASABI\WASABI_N_of_Few\analysis\04302026 MultiModal Run Maps.mat"; +load(datafile, 'run_maps'); % fmri_data_st, 1224 run-level betas + +T = run_maps.metadata_table; +T.sesno = double(string(T.sesno)); % numeric session index (CV folds) +T.runid = categorical(strcat(string(T.ses), '_', string(T.bodysite))); % unique run == bodysite x session +run_maps.metadata_table = T; + +fprintf('BodyMap: %d run images, %d subjects, %d bodysites, conditions: %s\n', ... + height(T), numel(unique(string(T.sub))), numel(unique(string(T.bodysite))), ... + strjoin(cellstr(unique(string(T.condition)))', ', ')); + +%% 2. Diagnose the shared-run confound ------------------------------------ +% Hot/Warm/Imagine of a bodysite are estimated from the SAME run, so their +% cross-condition similarity is inflated by shared-run noise + the run mean. +% Direct evidence: take SAME-bodysite, DIFFERENT-condition pairs and compare +% their correlation when from the same run (== same session here) vs across +% runs. A somatosensory region (S1) makes the magnitude vivid. +roi = select_atlas_subset(load_atlas('canlab2024'), ... + {'Ctx_3b_L','Ctx_3b_R','Ctx_1_L','Ctx_1_R','Ctx_2_L','Ctx_2_R'}); % S1 +rmS1 = apply_mask(run_maps, roi); +X = double(rmS1.dat); +sub = string(T.sub); bs = string(T.bodysite); cond = string(T.condition); ses = T.sesno; +same_run = []; diff_run = []; +for s = unique(sub)' + ix = find(sub==s); C = corr(X(:,ix)); n = numel(ix); + [a,b] = find(triu(true(n),1)); + pick = (bs(ix(a)) == bs(ix(b))) & (cond(ix(a)) ~= cond(ix(b))); % same bodysite, diff condition + sameR = ses(ix(a)) == ses(ix(b)); % same session == same run + z = atanh(min(max(C(sub2ind([n n],a,b)),-.999),.999)); + same_run(end+1) = mean(z(pick & sameR),'omitnan'); %#ok + diff_run(end+1) = mean(z(pick & ~sameR),'omitnan'); %#ok +end +[~,p_inf,~,st_inf] = ttest(same_run, diff_run); +fprintf(['\nShared-run inflation (same-bodysite Hot/Warm/Imagine pairs, S1):\n' ... + ' same run z=%.3f vs across runs z=%.3f; inflation=%.3f, t(%d)=%.2f, p=%.3g\n'], ... + mean(same_run), mean(diff_run), mean(same_run-diff_run), st_inf.df, st_inf.tstat, p_inf); +fprintf(' => cross-condition similarity is dominated by shared-run noise; compare ACROSS runs only.\n'); + +%% 3. Build RSMs: run-controlled vs naive --------------------------------- +% PRIMARY: crossnobis RDM, folds = sessions (== runs). The within-fold +% condition difference cancels the run-common component, and cross-fold +% products remove run/session noise -> unbiased. Condition order is +% condition-major (hot/imagine/warm blocks) to match Fig 7A. +gray = fmri_data(which('gray_matter_mask.img'), 'noverbose'); +run_gray = apply_mask(run_maps, gray); % mask ONCE (run_maps is ~500 MB) + +% NOTE: whole-brain crossnobis (~90k voxels) takes a few MINUTES -- the +% cross-validated distance loops over condition pairs x session-fold pairs. +% (Per-parcel crossnobis in section 9 is fast: each parcel is small.) To +% prototype quickly, restrict run_gray to a mask or subsample voxels first. +R = compute_rsm(run_gray, 'group_by', {'condition','bodysite'}, ... + 'subject_var', 'sub', 'metric', 'crossnobis', 'fold_var', 'sesno', ... + 'level', 'subject'); + +% NAIVE (for comparison only): session-pooled Spearman similarity. This is +% the construction that does NOT remove the same-session effect. +R_naive = compute_rsm(run_gray, 'group_by', {'condition','bodysite'}, ... + 'subject_var', 'sub', 'metric', 'spearman', 'level', 'subject'); + +fprintf('\nCrossnobis RDM [%s], naive Spearman RSM [%s]\n', ... + num2str(size(R)), num2str(size(R_naive))); +disp('conditions:'); disp(R.labels'); + +% Show the payoff: bodysite separation, naive vs session-controlled. +lab = string(R.labels); % "condition_bodysite" +bsite = extractAfter(lab, "_"); +k = numel(lab); [ii,jj] = find(triu(true(k),1)); +sameBS = bsite(ii) == bsite(jj); +Mc = mean(R.dat,3,'omitnan'); vc = Mc(sub2ind([k k],ii,jj)); +Mn = mean(R_naive.dat,3,'omitnan'); vn = Mn(sub2ind([k k],ii,jj)); +fprintf('Naive Spearman same-BS=%.3f diff-BS=%.3f (similarity; sep=%.3f)\n', ... + mean(vn(sameBS)), mean(vn(~sameBS)), mean(vn(sameBS))-mean(vn(~sameBS))); +fprintf('Crossnobis same-BS=%.3f diff-BS=%.3f (distance; sep=%.3f)\n', ... + mean(vc(sameBS)), mean(vc(~sameBS)), mean(vc(~sameBS))-mean(vc(sameBS))); + +%% 4. Visualize representational geometry (Fig 7A / 7B) ------------------- +figure('Name','BodyMap group RDM'); +plot(mean(R), 'block_borders_by', 'condition'); +title('BodyMap group-mean crossnobis RDM (session cross-validated)'); + +figure('Name','BodyMap subject RDMs'); +plot(R, 'mode', 'grid'); +sgtitle('Per-subject crossnobis RDMs'); + +figure('Name','BodyMap MDS'); plot(mean(R), 'mode', 'mds'); +figure('Name','BodyMap dendrogram'); plot(mean(R), 'mode', 'dendrogram'); + +%% 5. Model RDMs (Fig 7C) ------------------------------------------------- +% Build same-vs-different model RDMs over the 24 conditions. +parts = split(lab, "_"); % k x 2: [condition, bodysite] +cond_meta = table(parts(:,1), parts(:,2), 'VariableNames', {'condition','bodysite'}); + +Mbody = rsm.from_categorical(cond_meta, 'bodysite'); +Mcond = rsm.from_categorical(cond_meta, 'condition'); +figure('Name','Model RDMs'); +subplot(1,2,1); imagesc(Mbody.dat); axis image off; title('Bodysite model'); +subplot(1,2,2); imagesc(Mcond.dat); axis image off; title('Condition model'); +colormap(gray); + +%% 6. Cell-level condition contrasts (Fig 7D-F) -------------------------- +% Auto-attached groupings: one per condition (hot/imagine/warm, spanning the +% 8 bodysites) and one per bodysite (spanning the 3 conditions). +disp('Available groupings:'); disp(fieldnames(R.groupings)'); +% +% Cross-condition similarity uses BETWEEN-block cells, expressed as {a,b}: +% within-hot = contrast('hot') (mean distance among hot conditions) +% HW = contrast({'hot','warm'}) (mean hot<->warm distance) +% HI vs HW = contrast({'hot','imagine'}, {'hot','warm'}) +% NOTE: R is a crossnobis DISTANCE (RDM): smaller = more similar. So a +% NEGATIVE (HI - HW) means Hot is MORE similar to Imagine than to Warm +% (the Sun et al. "HI > HW" similarity result, in dissimilarity space). +spec = { + % name cells_A cells_B + 'within_hot', 'hot', []; + 'within_warm', 'warm', []; + 'within_imagine', 'imagine', []; + 'HW', {'hot','warm'}, []; + 'HI', {'hot','imagine'}, []; + 'IW', {'imagine','warm'}, []; + 'HI_vs_HW', {'hot','imagine'}, {'hot','warm'}; + 'HI_vs_IW', {'hot','imagine'}, {'imagine','warm'}; +}; +T_contrasts = R.ttest_contrasts(spec, 'tail', 'both', 'correction', 'fdr'); +disp(T_contrasts(:, {'Contrast','Mean_Diff','t','df','P','FDR_P','sig'})); + +% Within-condition distances per subject, with within-subject lines. +T_cells = R.cells_table({'hot','imagine','warm'}); +figure('Name','BodyMap within-condition distances'); +plot_rsm_contrast_bars(T_cells, 'title', 'Within-condition distance (crossnobis)'); + +%% 7. Formal model comparison + noise ceiling ---------------------------- +% Does the brain's geometry match a bodysite model or a condition model? +% Kendall tau-a per Nili et al. (2014); relatedness via subject RFX. +result = R.compare({'bodysite','condition'}, 'correlation_type', 'kendall_taua'); +fprintf('\nModel-RDM comparison (Kendall tau-a):\n'); +for i = 1:numel(result.candidate_names) + star = ''; if result.relatedness_sig(i), star = '*'; end + fprintf(' %-10s r=%+.3f p=%.4g %s\n', result.candidate_names{i}, ... + result.r_mean(i), result.relatedness_p_corr(i), star); +end +fprintf(' noise ceiling: [%.3f %.3f]\n', result.noise_ceiling(1), result.noise_ceiling(2)); + +%% 8. Multi-level LME with a SAME-RUN nuisance --------------------------- +% Correlation-based control for the shared-run confound: model pairwise +% similarity as same-bodysite / same-condition / SAME-RUN, with subject +% random effects. SameRunid is 1 only for the within-run Hot/Warm/Imagine +% pairs, so it absorbs the run inflation; SameBodysite and SameCondition are +% then estimated from ACROSS-run pairs (the clean, interpretable effects). +% Empirically: SameRunid ~ 0.38 (the inflation), SameBodysite ~ 0.02 +% (p<1e-26), SameCondition ~ 0.06 -- the true geometry once run noise is out. +mdl = run_gray.rsa_lme( ... + 'predictors', {'bodysite','condition','runid'}, ... + 'subject_var', 'sub'); +disp(mdl.Coefficients); + +% Nested comparison: add the run nuisance first, then the effects of interest. +seq = rsa_model_sequence('Y ~ 1 + (1|Subject)'); +seq = seq.add_term('SameRunid'); % shared-run nuisance first +seq = seq.add_term('SameCondition'); +seq = seq.add_term('SameBodysite'); +[T_models, best] = run_gray.rsa_compare_models(seq.formulas, ... + 'predictors', {'bodysite','condition','runid'}, ... + 'subject_var', 'sub', 'select_by', 'aic', 'verbose', false); +disp(T_models); +fprintf('Best model by AIC: %s\n', seq.formulas{best}); + +%% 9. Parcelwise brain maps (Fig 7G / S8 / S9) --------------------------- +% Per-region RSA over the 131-region pain-pathways atlas (canlab2024_2), +% FDR-corrected across regions, projected to the brain. Reproduces the +% Sun et al. HI>HW map using the paper's metric family (cross-session +% correlation) -- and quantifies the run-confound's effect on it. +atlasfile = "\\dartfs-hpc\rc\lab\C\CANlab\labdata\projects\WASABI\WASABI_N_of_Few\analysis\WASABI-NofFew_BodyMap\masks\canlab2024_pain_pathways.mat"; +load(atlasfile, 'canlab2024_2'); % 131-region atlas object +atl = resample_space(canlab2024_2, run_maps); % match run_maps space ONCE (else resampled per call) + +% (a) RUN-CLEAN HI>HW: cross-session (cross-validated) correlation. cvspearman +% never correlates two patterns from the same run, so the shared-run +% structure (Hot/Warm/Imagine co-occur in a run) cannot inflate it. This +% is the apples-to-apples, run-clean version of the paper's Fig 7G. +% HI>HW = Hot more similar to Imagine than to Warm -> tail='right'. +% (cvspearman is slower; cvcorr = Pearson is much faster with r~0.9 maps.) +% 'cv_scheme','loo' (leave-one-session-out vs mean-of-rest) is a +% higher-SNR option, but on BodyMap it is ~identical to the default +% 'allpairs' (t-map r=0.99): the limit is between-subject variance at +% n=9, not the CV scheme. +results = run_maps.rsa_parcelwise('atlas', atl, ... + 'group_by', {'condition','bodysite'}, 'subject_var', 'sub', ... + 'metric', 'cvcorr', 'fold_var', 'sesno', ... + 'contrasts', {'HI_vs_HW', {'hot','imagine'}, {'hot','warm'}}, ... + 'correction', 'fdr', 'tail', 'right'); +% statistic_image has a `threshold` PROPERTY and METHOD -- call threshold() +% with FUNCTION syntax (map.threshold(...) indexes the property -> error). +montage(threshold(results.maps.HI_vs_HW, 0.05, 'unc')); + +% (b) The RUN-CONFOUNDED version for comparison: within-sample (session-pooled) +% correlation. This is effectively what the published region-level analysis +% used, and it reproduces the widespread HI>HW result (~91/131 regions FDR). +% The run-clean map (a) is the SAME effect/direction (t-maps r~0.6, ~116/131 +% regions same sign) but far fewer survive FDR -- see the note below. +results_conf = run_maps.rsa_parcelwise('atlas', atl, ... + 'group_by', {'condition','bodysite'}, 'subject_var', 'sub', ... + 'metric', 'correlation', ... % within-sample = run-confounded + 'contrasts', {'HI_vs_HW', {'hot','imagine'}, {'hot','warm'}}, ... + 'correction', 'fdr', 'tail', 'right'); +montage(threshold(results_conf.maps.HI_vs_HW, 0.05, 'unc')); + +% WHY THE HI>HW RESULT WEAKENS UNDER RUN-CLEAN ANALYSIS: +% It is primarily STATISTICAL POWER, not confound-in-the-contrast. The run +% inflation affects HW and HI cells about equally, so it largely cancels in +% the HI-HW difference (the run-clean and confounded maps agree in direction, +% t-map r~0.6). BUT cross-session correlation estimates each cell from +% single-run patterns (noisy), so the cross-validated contrast is much lower +% powered than the session-pooled (grand-mean) version -> few regions clear +% FDR at n=9. Crossnobis DISTANCE is even less sensitive to this graded +% effect than cross-session correlation. Take-home: report the run-clean +% version; treat the widespread published HI>HW as power-inflated by pooling. + +%% 10. Searchlight RSA ---------------------------------------------------- +% Where does the LOCAL geometry match the bodysite model? With permutations=0 +% the map holds RAW correlations (view directly; do not threshold by p). +% Whole-brain searchlight is SLOW (~10+ min); shrink the mask to prototype. +sl = run_gray.searchlight_rsa('bodysite', 'radius', 3, ... + 'group_by', {'condition','bodysite'}, 'subject_var', 'sub', ... + 'metric', 'correlation', 'compare', 'spearman'); +montage(sl.maps.bodysite); % raw r map +% For p-thresholded inference, pass permutations>0 (slow), then: +% montage(threshold(sl.maps.bodysite, 0.05, 'unc')); + +fprintf('\nBodyMap pipeline complete.\n'); diff --git a/CanlabCore/RSA_tools/examples/rsa_distractmap_pipeline.m b/CanlabCore/RSA_tools/examples/rsa_distractmap_pipeline.m new file mode 100644 index 00000000..1453cf5b --- /dev/null +++ b/CanlabCore/RSA_tools/examples/rsa_distractmap_pipeline.m @@ -0,0 +1,193 @@ +%% DistractMap RSA pipeline +% Full representational-similarity analysis of the WASABI DistractMap dataset +% using the CanlabCore RSA tools. +% +% DESIGN +% 7 subjects, 2 sessions each, 4 conditions +% (nback_heat, nback_nostimblock, rest_nostim, rest_stim). +% Shared-anchor bodysite design: every subject has a "Left Face" bodysite +% plus ONE idiosyncratic "other" site that differs per subject. We recode +% bodysite to {Left Face, Other Body Site} so conditions are comparable +% across subjects (see rsa_recode_reference). +% +% PIPELINE +% 1. Load + prepare metadata +% 2. Build subject- and session-level RSMs +% 3. Visualize representational geometry +% 4. Reliability (ICC across sessions) +% 5. Within / between condition contrasts +% 6. Multi-level LME modeling +% 7. (Optional) Parcelwise brain maps +% +% Requires: CanlabCore on the path. Reload class methods if edited: +% clear classes; rehash path; addpath(genpath('.../CanlabCore')); + +%% 1. Load and prepare ---------------------------------------------------- +datafile = "\\dartfs-hpc\rc\lab\C\CANlab\labdata\projects\WASABI\WASABI_N_of_Few\analysis\04302026 MultiModal Run Maps.mat"; +load(datafile, 'distractmap_run'); + +T = distractmap_run.metadata_table; + +% Two-level session index per subject (0 = earlier, 1 = later) +G = findgroups(T.subject_id); +min_ses = splitapply(@min, T.session_number, G); +T.sesno = double(T.session_number ~= min_ses(G)); + +% Recode bodysite into the shared anchor vs the idiosyncratic "other" +T.bodysite_type = rsa_recode_reference(T.bodySite, 'Left Face', ... + 'other_label', 'Other Body Site'); + +distractmap_run.metadata_table = T; + +fprintf('DistractMap: %d images, %d subjects, conditions: %s\n', ... + height(T), numel(unique(T.subject_id)), ... + strjoin(cellstr(string(unique(T.condition)))', ', ')); + +%% 2. Build RSMs ---------------------------------------------------------- +% Subject-collapsed RSM (sessions averaged) -- for contrasts / geometry +R = compute_rsm(distractmap_run, 'group_by', {'condition','bodysite_type'}, ... + 'subject_var', 'subject_id', 'metric', 'spearman', ... + 'nan_policy', 'skip_replicate'); + +% Per-session RSM -- for reliability and drift +R_sess = compute_rsm(distractmap_run, 'group_by', {'condition','bodysite_type'}, ... + 'subject_var', 'subject_id', 'session_var', 'session_number', ... + 'level', 'session', 'metric', 'spearman', 'nan_policy', 'skip_replicate'); + +fprintf('Subject RSM: [%s]; Session RSM: [%s]\n', ... + num2str(size(R)), num2str(size(R_sess))); + +%% 3. Visualize representational geometry -------------------------------- +figure('Name','DistractMap group RSM'); +plot(mean(R), 'block_borders_by', 'condition'); +title('DistractMap group-mean RSM'); + +figure('Name','DistractMap subject RSMs'); +plot(R, 'mode', 'grid'); +sgtitle('Per-subject RSMs'); + +% MDS of the 8 conditions -- shows how conditions cluster in representational +% space (nback vs rest; Left Face vs Other within each condition). +figure('Name','DistractMap MDS'); +plot(mean(R), 'mode', 'mds'); + +% Hierarchical clustering of the conditions +figure('Name','DistractMap dendrogram'); +plot(mean(R), 'mode', 'dendrogram'); + +%% 4. Reliability (ICC across sessions) ---------------------------------- +% Whole-RSM reliability per subject, aggregated +rel = R_sess.reliability('icc_type', '3-k'); +fprintf('\nWhole-RSM ICC across %d subjects: mean=%.3f (median=%.3f)\n', ... + rel.summary.n_subjects, rel.summary.mean, rel.summary.median); + +% Per-condition reliability + bar plot +out = R_sess.reliability_per_condition('icc_type', '3-k'); +figure('Name','DistractMap reliability'); +barplot_columns(out.Mean_ICC', 'names', format_strings_for_legend(out.Condition'), ... + 'custom_se', out.Std_ICC', 'noviolin', 'nofig'); +ylabel('Reliability (ICC 3-k)'); title('DistractMap per-condition reliability'); +hline(0, 'k-'); +disp(out(:, {'Condition','Mean_ICC','Std_ICC','n_subjects'})); +% NOTE: only 2 sessions/subject -> ICC noisy; rest_nostim has little task +% structure to replicate (expect low/negative ICC). + +%% 5. Within / between condition contrasts ------------------------------- +% compute_rsm auto-attaches groupings: one per condition (nback_heat, ...) +% spanning that condition's two bodysite_types, and one per bodysite_type +% (Left_Face, Other_Body_Site) spanning the four conditions. +disp('Available groupings:'); disp(fieldnames(R.groupings)'); + +% Contrast battery over the superordinate conditions. cells(A,A) is the +% within-condition LeftFace<->Other similarity; cells(A,B) is between-condition. +spec = { + % name cells_A cells_B + 'within_nback_heat', 'nback_heat', []; + 'within_nback_nostim', 'nback_nostimblock', []; + 'within_rest_stim', 'rest_stim', []; + 'within_rest_nostim', 'rest_nostim', []; + 'heat_vs_reststim', 'nback_heat', 'rest_stim'; + 'nbackHeat_vs_nostim', 'nback_heat', 'nback_nostimblock'; +}; +T_contrasts = R.ttest_contrasts(spec, 'tail', 'both', 'correction', 'fdr'); +disp(T_contrasts); + +% Per-subject cell table for plotting +T_cells = R.cells_table({'nback_heat','nback_nostimblock','rest_nostim','rest_stim'}); +figure('Name','DistractMap within-condition similarity'); +plot_rsm_contrast_bars(T_cells, 'title', 'Within-condition (LeftFace <-> Other) similarity'); + +%% 6. Multi-level LME modeling ------------------------------------------- +% Model pairwise similarity as a function of same-condition / same-bodysite- +% type / same-session, with subject as a random effect. +mdl = distractmap_run.rsa_lme( ... + 'predictors', {'condition','bodysite_type','sesno'}, ... + 'subject_var', 'subject_id'); +disp(mdl.Coefficients); + +% Variance components +icc = rsa_lme_icc(mdl); +disp(icc.summary); + +% Nested model comparison (subject_var 'subject_id' -> short name 'Subject') +seq = rsa_model_sequence('Y ~ 1 + (1|Subject)'); +seq = seq.add_term('SameCondition'); +seq = seq.add_term('SameBodysitetype'); +seq = seq.add_term('SameSesno'); +[T_models, best] = distractmap_run.rsa_compare_models(seq.formulas, ... + 'predictors', {'condition','bodysite_type','sesno'}, ... + 'subject_var', 'subject_id', 'select_by', 'aic', 'verbose', false); +disp(T_models); +fprintf('Best model by AIC: %s\n', seq.formulas{best}); + +%% 7. Parcelwise brain maps ---------------------------------------------- +% Per-parcel RSA inference, FDR-corrected across parcels, projected onto the +% brain as statistic_image maps. Reference groupings by the auto-built names +% (nback_heat, rest_stim, ...) -- NOT the composite labels. +atlas = load_atlas('canlab2024'); + +% (a) Contrast map: within-condition similarity for nback_heat vs rest_stim +results = distractmap_run.rsa_parcelwise('atlas', atlas, ... + 'group_by', {'condition','bodysite_type'}, 'subject_var', 'subject_id', ... + 'metric', 'spearman', ... + 'contrasts', {'nbackheat_vs_reststim', 'nback_heat', 'rest_stim'}, ... + 'correction', 'fdr', 'tail', 'right'); +% NOTE: statistic_image has BOTH a `threshold` property and a `threshold` +% method, so the dot form `map.threshold(0.05,'unc')` indexes the property +% (badsubscript error). Always call threshold() with function syntax. +map = threshold(results.maps.nbackheat_vs_reststim, 0.05, 'unc'); +montage(map); + +% (b) LME map: where is the SameCondition effect strongest? +results_lme = distractmap_run.rsa_parcelwise('atlas', atlas, ... + 'group_by', {'condition','bodysite_type'}, 'subject_var', 'subject_id', ... + 'lme', 'Y ~ SameCondition + SameBodysitetype + (1|Subject)', ... + 'predictors', {'condition','bodysite_type'}); +montage(results_lme.maps.SameCondition); + +%% 8. Searchlight RSA ----------------------------------------------------- +% Where does the LOCAL representational geometry match the condition model? +% A spherical searchlight builds an RSM in each sphere and correlates it with +% the model RDM (positive = local geometry matches the model). Restrict to a +% gray-matter mask for speed. +gray = fmri_data(which('gray_matter_mask.img'), 'noverbose'); +sl = distractmap_run.searchlight_rsa('condition', 'radius', 3, ... + 'group_by', {'condition','bodysite_type'}, 'subject_var', 'subject_id', ... + 'metric', 'correlation', 'compare', 'spearman', 'mask', gray); + +% IMPORTANT: with permutations=0 (default) the map holds RAW correlations and +% its .p values are placeholders (all 1). View it DIRECTLY -- do NOT call +% threshold(map,0.05,'unc') here (p<0.05 is never true, so you'd get an empty +% map / "no results"). To emphasize strong matches, threshold by magnitude. +montage(sl.maps.condition); % raw r map (correct view) +% slmap = sl.maps.condition; slmap.dat(slmap.dat < 0.2) = 0; montage(slmap); + +% For p-thresholded inference, pass permutations>0 (slower). Then .p is a real +% permutation p-value and the usual threshold() call works: +% sl = distractmap_run.searchlight_rsa('condition', 'radius', 3, ... +% 'group_by', {'condition','bodysite_type'}, 'subject_var', 'subject_id', ... +% 'metric', 'correlation', 'compare', 'spearman', 'mask', gray, ... +% 'permutations', 500); +% montage(threshold(sl.maps.condition, 0.05, 'unc')); + +fprintf('\nDistractMap pipeline complete.\n'); diff --git a/CanlabCore/RSA_tools/examples/rsa_multilevel_lme.m b/CanlabCore/RSA_tools/examples/rsa_multilevel_lme.m new file mode 100644 index 00000000..edb8f6d9 --- /dev/null +++ b/CanlabCore/RSA_tools/examples/rsa_multilevel_lme.m @@ -0,0 +1,44 @@ +%% Multi-level LME modeling of RSA structure +% Reproduces the 08072024 Run-Level RDM Analysis workflow: model pairwise +% RSM similarity as a function of same-condition / same-bodysite / same- +% session, with subject as a random effect. + +dat = make_synthetic_rsa_data('n_sub', 9, 'n_ses', 3); + +%% 1. Omnibus fixed-effects: which factors structure the representation? +[mdl_lm, tbl_lm] = dat.rsa_lm('predictors', {'sub','sesno','condition','bodysite'}, ... + 'subject_var', 'sub', 'pair_scope', 'all'); +T_partial = rsa_partial_r2(mdl_lm, tbl_lm); +fprintf('\nPartial R^2 per factor (omnibus fixed-effects):\n'); +disp(T_partial) + +%% 2. Per-subject fits -> 2nd-level inference +T_by_sub = dat.rsa_lm_by_subject('predictors', {'condition','bodysite','sesno'}, ... + 'subject_var', 'sub', 'verbose', false); +sc = T_by_sub.beta(strcmp(T_by_sub.term, 'SameCondition')); +[~, pcond, ~, st] = ttest(sc); +fprintf('SameCondition beta across subjects: mean=%.3f, t(%d)=%.2f, p=%.4g\n', ... + mean(sc), st.df, st.tstat, pcond); + +%% 3. Random-effects LME (the principled multi-level model) +mdl = dat.rsa_lme('Y ~ SameCondition + SameBodysite + SameSesno + (SameCondition | Sub)', ... + 'predictors', {'condition','bodysite','sesno'}, 'subject_var', 'sub'); +disp(mdl.Coefficients) + +%% 4. Variance decomposition + per-subject BLUPs +icc = rsa_lme_icc(mdl); +fprintf('\nVariance components:\n'); disp(icc.summary) +blups = rsa_lme_blups(mdl); +fprintf('Per-subject SameCondition BLUPs:\n'); +disp(blups(strcmp(blups.Term, 'SameCondition'), :)) + +%% 5. Nested model comparison (AIC/BIC/LRT ladder) +seq = rsa_model_sequence('Y ~ 1 + (1|Sub)'); +seq = seq.add_term('SameCondition'); +seq = seq.add_term('SameBodysite'); +seq = seq.add_term('SameSesno'); +[T_models, best] = dat.rsa_compare_models(seq.formulas, ... + 'predictors', {'condition','bodysite','sesno'}, 'subject_var', 'sub', ... + 'select_by', 'aic', 'verbose', false); +disp(T_models) +fprintf('Best model by AIC: %s\n', seq.formulas{best}); diff --git a/CanlabCore/RSA_tools/examples/rsa_oo_demo.m b/CanlabCore/RSA_tools/examples/rsa_oo_demo.m new file mode 100644 index 00000000..f6f35937 --- /dev/null +++ b/CanlabCore/RSA_tools/examples/rsa_oo_demo.m @@ -0,0 +1,173 @@ +%% CanlabCore RSA / RSM toolbox -- an object-oriented tour +% How the RSA/RSM extension plugs into CanlabCore's object model. Nothing is +% bolted on: compute_rsm is a METHOD on fmri_data, it returns a new first-class +% object (rsm), and RSA brain maps come back as statistic_image objects that +% threshold / montage / region already understand. +% +% image_vector (abstract superclass) +% | +% +-- fmri_data --[ compute_rsm ]------------> rsm (new object) +% | --[ rsa_lme ]----------------> LinearMixedModel +% | --[ rsa_parcelwise ]--------> statistic_image (root object) +% | --[ searchlight_rsa ]-------> statistic_image +% | +% rsm --[ plot('mode','mds'/'dendrogram') ]--> representational geometry +% rsm --[ compare ]--> model RDMs via rsm.from_categorical / from_design +% rsm --[ count_map / count_models / count_regions ]--> subject-consistency +% count-tables + count-maps +% statistic_image --[ threshold / region / montage ]--> region, fmridisplay, table +% +% Requires: CanlabCore on the path (+ Neuroimaging_Pattern_Masks for load_atlas). +% Sections 1-9 are self-contained (synthetic fmri_data); Section 10 borrows a +% sample dataset's brain geometry to run a real rsa_parcelwise / searchlight_rsa. + +%% 1. Everything starts from a CanlabCore root object: fmri_data +dat = make_synthetic_rsa_data('n_sub', 9, 'n_ses', 5); % returns a real fmri_data +fprintf('class(dat) = %s\n', class(dat)); +disp(head(dat.metadata_table)); % conditions x bodysites x subjects x sessions + +%% 2. compute_rsm is just a METHOD on fmri_data (the @class extension idiom) +% CanlabCore extends a class by dropping a file into @fmri_data/. compute_rsm.m +% lives there, so MATLAB discovers it as a method -- no classdef edit needed. +fprintf('compute_rsm is a method of fmri_data: %d\n', ismember('compute_rsm', methods(dat))); +R = compute_rsm(dat, 'group_by', {'condition','bodysite'}, ... + 'subject_var', 'sub', 'metric', 'correlation'); + +%% 3. compute_rsm returns a NEW first-class object: rsm +fprintf('class(R) = %s\n', class(R)); % 'rsm' +R % its disp() reports metric / level / groupings +% ...with its own method surface, exactly like fmri_data / atlas / region: +key = {'plot','mean','to_rdm','to_rsm','fisher_z','subset','reorder','cells', ... + 'ttest_contrasts','reliability','drift','compare','get_by_label'}; +fprintf('rsm methods present: %s\n', strjoin(intersect(methods(R), key), ', ')); +disp(R.history); % provenance list, like every CanlabCore object + +%% 4. Value semantics: transforms return NEW rsm objects (nothing mutates in place) +Rmean = mean(R); % [k x k x N] -> group-mean rsm +Rdis = to_rdm(R); % similarity -> dissimilarity, still an rsm +fprintf('mean(R) -> %s [%s]; to_rdm(R).is_dissimilarity = %d\n', ... + class(Rmean), num2str(size(Rmean)), Rdis.is_dissimilarity); +figure('Color','w'); plot(Rmean, 'block_borders_by', 'condition'); % the object draws itself +title('rsm.plot -- group-mean RSM'); + +%% 5. Representational geometry: MDS + dendrogram +% The object knows how to project its own geometry. 'mds' lays the k conditions +% out in 2-D (classical MDS on the dissimilarity) so you can SEE which cluster; +% 'dendrogram' shows the hierarchical clustering -- both straight off the rsm. +figure('Color','w'); plot(Rmean, 'mode', 'mds'); +title('rsm.plot(''mode'',''mds'') -- representational geometry'); +figure('Color','w'); plot(Rmean, 'mode', 'dendrogram'); +title('rsm.plot(''mode'',''dendrogram'')'); + +%% 6. Inference lives on the object (declarative contrast specs) +spec = { 'within_hot', 'hot', []; % one-sample: within-hot cells + 'HI_vs_HW', {'hot','imagine'}, {'hot','warm'} }; % paired: HI vs HW blocks +T = R.ttest_contrasts(spec, 'tail','both', 'correction','fdr'); +disp(T(:, {'Contrast','Mean_Diff','t','P','FDR_P','sig'})); + +%% 7. Model RDMs: build them, DISPLAY them, then compare +% Static constructors turn metadata into hypothesis (model) RDMs -- same-vs- +% different matrices you can look at, then test the brain geometry against. +parts = split(string(Rmean.labels), '_'); % "condition_bodysite" +condModel = rsm.from_categorical(parts(:,1), 'name', 'Condition'); % same-condition RDM +bodyModel = rsm.from_categorical(parts(:,2), 'name', 'Bodysite'); % same-bodysite RDM +figure('Color','w'); colormap(gray); +subplot(1,3,1); imagesc(to_rdm(Rmean).dat); axis image off; title('Brain RDM'); +subplot(1,3,2); imagesc(condModel.dat); axis image off; title('Condition model'); +subplot(1,3,3); imagesc(bodyModel.dat); axis image off; title('Bodysite model'); +% Formal comparison (Nili et al. 2014): brain geometry vs each model RDM. +result = R.compare({'condition','bodysite'}, 'correlation_type','kendall_taua'); +fprintf('\nFormal RDM comparison (brain geometry vs model):\n'); +for i = 1:numel(result.candidate_names) + fprintf(' vs %-9s model: r=%+.3f p=%.3g\n', ... + result.candidate_names{i}, result.r_mean(i), result.relatedness_p_corr(i)); +end + +%% 8. Multilevel modeling: rsa_lme (another fmri_data method) +% RSA as a mixed-effects model. Each row is a within-subject condition-pair +% similarity; predictors are same-condition / same-bodysite / same-session; +% Subject is a random effect. Returns a standard MATLAB LinearMixedModel, so +% every fitlme tool (anova, coefCI, random effects, LRT) works on it. +mdl = dat.rsa_lme('predictors', {'condition','bodysite','sesno'}, 'subject_var', 'sub'); +disp(mdl.Coefficients(:, {'Name','Estimate','SE','tStat','pValue'})); +% dat.rsa_compare_models(...) does the nested-model LRT/AIC ladder over these. + +%% 9. Subject-consistency count-maps & count-tables (paper-ready reporting) +% Beyond the group effect, you usually report HOW MANY subjects show it. The +% count_* methods count, per RSM cell / block / contrast / model / region, how +% many subjects meet a criterion and return both a count-MAP (imagesc-able) and +% a count-TABLE carrying the group effect + p (via the shared hrf_group_stats +% permutation engine when it is on the path, else a built-in one-sample t). +cm = R.count_map('Granularity', 'contrasts', 'Contrasts', { ... + struct('within', 'hot', 'between', 'warm', 'name', 'hot>warm'), ... + struct('within', 'warm', 'between', 'imagine', 'name', 'warm>imag') }); +disp(cm.table(:, {'name_a', 'count', 'n', 'proportion', 'group_p'})); + +% count_models: which candidate model RDM best fits each subject? +mc = R.count_models({'condition', 'bodysite'}, 'Winner', 'exclusive'); +disp(mc.table(:, {'model', 'wins', 'n_sig', 'group_mean_r'})); +% figure; R.count_map('Granularity', 'blocks', 'doplot', true); % block heatmap + +%% 10. RSA results flow BACK into the CanlabCore ecosystem (statistic_image) +% rsa_parcelwise and searchlight_rsa are methods on fmri_data / image_vector +% that return statistic_image objects -- a CanlabCore root type. So RSA brain +% maps plug straight into threshold / montage / region / table. +% +% rsa_parcelwise / searchlight_rsa need data in a real brain space, so here we +% borrow a sample dataset's geometry (its volInfo) and plant the same condition +% x bodysite structure into it -- giving a spatially-valid fmri_data. +base = load_image_set('emotionreg'); % real volInfo / MNI space +nvox = size(base.dat, 1); rng(7); +conds = {'hot', 'warm', 'imagine'}; sites = {'arm', 'leg', 'face', 'back'}; nsub = 8; +cp = randn(nvox, numel(conds)); sp = 0.3 * randn(nvox, numel(sites)); +Xb = []; sid = {}; cv = {}; bv = {}; +for s = 1:nsub + for ci = 1:numel(conds) + for bi = 1:numel(sites) + Xb(:, end+1) = cp(:, ci) + sp(:, bi) + 0.7 * randn(nvox, 1); %#ok + sid{end+1} = sprintf('sub%02d', s); %#ok + cv{end+1} = conds{ci}; %#ok + bv{end+1} = sites{bi}; %#ok + end + end +end +braindata = base; braindata.dat = Xb; braindata.removed_images = 0; +braindata.image_names = ''; braindata.fullpath = ''; +braindata.metadata_table = table(sid(:), cv(:), bv(:), ... + 'VariableNames', {'subject_id', 'condition', 'bodysite'}); + +atlas = select_atlas_subset(load_atlas('canlab2024'), 1:16); % subset for speed + +% (a) rsa_parcelwise -> statistic_image contrast map +res = rsa_parcelwise(braindata, 'atlas', atlas, ... + 'group_by', {'condition', 'bodysite'}, 'subject_var', 'subject_id', ... + 'metric', 'correlation', 'contrasts', {'hot_vs_warm', 'hot', 'warm'}, ... + 'correction', 'fdr', 'tail', 'right', 'verbose', false); +fprintf('\nrsa_parcelwise -> res.maps.hot_vs_warm is a %s\n', class(res.maps.hot_vs_warm)); +mt = threshold(res.maps.hot_vs_warm, 0.05, 'unc'); % the SAME threshold() as any stat map +reg = region(mt); % -> region objects +fprintf('thresholded RSA map -> %d region objects (region/table/montage all apply)\n', numel(reg)); +figure('Color', 'w'); montage(mt); % the SAME montage() + +% (b) searchlight_rsa -> statistic_image: slide a sphere, correlate each local +% RSM with the condition model. Restrict to the atlas for a fast demo. +bd_roi = apply_mask(braindata, atlas); +sl = searchlight_rsa(bd_roi, 'condition', 'radius', 3, ... + 'group_by', {'condition', 'bodysite'}, 'subject_var', 'subject_id', ... + 'metric', 'correlation', 'compare', 'spearman', 'verbose', false); +fprintf('searchlight_rsa -> sl.maps.condition is a %s\n', class(sl.maps.condition)); +figure('Color', 'w'); montage(sl.maps.condition); % local-geometry match map + +% (c) region consistency count-map: how many subjects show hot>warm, per parcel. +regcnt = count_regions(res.per_parcel_rsms, 'Atlas', atlas, ... + 'Contrasts', { struct('within', 'hot', 'between', 'warm', 'name', 'hotVsWarm') }); +disp(regcnt.region_table(:, {'Region', 'count', 'n', 'group_p_corr', 'sig'})); +% figure; montage(regcnt.maps.hotVsWarm); % statistic_image count-map on the brain + +%% Summary +% The RSA toolbox is not a silo. It speaks fmri_data on the way IN (compute_rsm +% / rsa_lme / rsa_parcelwise / searchlight_rsa are methods), returns a new +% first-class rsm object with a CanlabCore-style method surface (plot incl. MDS +% & dendrogram, cells, ttest_contrasts, compare, count_map/models/regions), and +% hands RSA brain maps back out as statistic_image objects -- so it composes +% with region, fmridisplay, montage, threshold and the rest of the toolbox. diff --git a/CanlabCore/RSA_tools/examples/rsa_oo_demo.mlx b/CanlabCore/RSA_tools/examples/rsa_oo_demo.mlx new file mode 100644 index 00000000..6e15fe51 Binary files /dev/null and b/CanlabCore/RSA_tools/examples/rsa_oo_demo.mlx differ diff --git a/CanlabCore/RSA_tools/examples/rsa_parcelwise_maps.m b/CanlabCore/RSA_tools/examples/rsa_parcelwise_maps.m new file mode 100644 index 00000000..845b6c75 --- /dev/null +++ b/CanlabCore/RSA_tools/examples/rsa_parcelwise_maps.m @@ -0,0 +1,67 @@ +%% Parcelwise RSA -> brain maps +% Reproduces the Sun et al. Figs 7G / S8 / S9 pipeline: per-parcel RSA +% inference projected onto the brain as statistic_image maps. +% +% This example needs the data to be in a real atlas space. We build a small +% synthetic dataset directly in a 10-parcel atlas subset so it runs quickly. + +atlas = select_atlas_subset(load_atlas('canlab2024'), 1:10); +codes = double(atlas.dat); + +% Plant condition/bodysite structure in two parcels only (3 and 7) +rng(11); +n_sub = 6; conditions = {'hot','warm','imag'}; bodysites = {'face','arm','leg','chest'}; +n_cond = 3; n_bs = 4; n_vox = size(atlas.dat, 1); +sig_mask = ismember(codes, [3 7]); +cond_pat = randn(n_cond, n_vox); bs_pat = randn(n_bs, n_vox); +X = []; sub_v = {}; cond_v = {}; bs_v = {}; idx = 0; +for s = 1:n_sub + for c = 1:n_cond + for b = 1:n_bs + for rep = 1:2 + idx = idx + 1; base = 0.3*randn(n_vox, 1); + base(sig_mask) = base(sig_mask) + 0.8*cond_pat(c, sig_mask)' + 0.5*bs_pat(b, sig_mask)'; + X(:, idx) = base; %#ok + sub_v{idx,1} = sprintf('sub-%02d', s); %#ok + cond_v{idx,1} = conditions{c}; bs_v{idx,1} = bodysites{b}; %#ok + end + end + end +end +dat = fmri_data(atlas, 'noverbose'); +dat.dat = X; +dat.metadata_table = table(sub_v, cond_v, bs_v, 'VariableNames', {'sub','condition','bodysite'}); + +%% Contrast path: within-hot per parcel, FDR across parcels +spec = { 'within_hot', 'hot', []; 'hot_vs_warm', 'hot', 'warm' }; +results = dat.rsa_parcelwise('atlas', atlas, ... + 'group_by', {'condition','bodysite'}, 'subject_var', 'sub', ... + 'metric', 'spearman', 'contrasts', spec, 'correction', 'fdr', 'tail', 'right'); + +disp(results.contrast_table) + +% The within_hot map is a statistic_image -- threshold and montage it. +% Use FUNCTION syntax: statistic_image has a `threshold` property AND method, +% so the dot form map.threshold(...) indexes the property (badsubscript error). +% montage(threshold(results.maps.within_hot, 0.05, 'unc')); +fprintf('within_hot map: %d sig voxels at FDR\n', ... + sum(results.maps.within_hot.sig)); + +%% LME path: per-parcel mixed model, map each fixed-effect term +results_lme = dat.rsa_parcelwise('atlas', atlas, ... + 'group_by', {'condition','bodysite'}, 'subject_var', 'sub', ... + 'lme', 'Y ~ SameCondition + SameBodysite + (1|Sub)', ... + 'predictors', {'condition','bodysite'}); + +disp(results_lme.lme_table) +% results_lme.maps.SameCondition.montage; + +%% Searchlight RSA (restrict to a mask for speed) +% Build a small mask and run a model-RDM searchlight +mask = fmri_data(atlas, 'noverbose'); mask.dat = double(ismember(codes, [1 2 3 7])); +dat_masked = apply_mask(dat, mask); +sl = dat_masked.searchlight_rsa('condition', 'radius', 3, ... + 'group_by', {'condition','bodysite'}, 'subject_var', 'sub', ... + 'metric', 'spearman', 'permutations', 100); +fprintf('Searchlight condition map built (%d centers).\n', sl.n_spheres); +% sl.maps.condition.montage; diff --git a/CanlabCore/RSA_tools/examples/rsa_quickstart.m b/CanlabCore/RSA_tools/examples/rsa_quickstart.m new file mode 100644 index 00000000..f6638154 --- /dev/null +++ b/CanlabCore/RSA_tools/examples/rsa_quickstart.m @@ -0,0 +1,48 @@ +%% RSA quick-start +% Build an RSM, visualize it, run within/between contrasts, and compare to +% model RDMs. Runs end-to-end on synthetic data. +% +% For real data, replace make_synthetic_rsa_data() with your fmri_data +% object and set group_by / subject_var to your metadata column names. + +dat = make_synthetic_rsa_data(); + +%% 1. Build a per-subject RSM (24 conditions = 3 conditions x 8 bodysites) +R = compute_rsm(dat, 'group_by', {'condition','bodysite'}, ... + 'subject_var', 'sub', 'metric', 'spearman'); +disp(R) + +%% 2. Group-mean RSM heatmap with condition block borders +figure; plot(mean(R), 'block_borders_by', 'condition'); +title('Group-mean RSM'); + +%% 3. Within vs between condition contrasts (FDR-corrected) +spec = { + 'within_hot', 'hot', []; + 'within_warm', 'warm', []; + 'within_imag', 'imagine', []; + 'hot_vs_warm', 'hot', 'warm'; + 'HW_vs_HI', {'hot','warm'}, {'hot','imagine'}; +}; +T = R.ttest_contrasts(spec, 'tail', 'both', 'correction', 'fdr'); +disp(T) + +%% 4. Bar plot of within-condition similarities +figure; +plot_rsm_contrast_bars(R.cells_table({'hot','warm','imagine'}), ... + 'colors', {[1 .6 .6],[.6 1 .6],[.6 .6 1]}, ... + 'title', 'Within-condition similarities'); + +%% 5. Formal comparison to model RDMs +result = R.compare({'condition','bodysite'}, 'correlation_type', 'kendall_taua'); +fprintf('\nModel-RDM correlations (Kendall tau-a):\n'); +for i = 1:numel(result.candidate_names) + fprintf(' %-12s r=%.3f, p=%.4g %s\n', result.candidate_names{i}, ... + result.r_mean(i), result.relatedness_p_corr(i), ... + ternary(result.relatedness_sig(i), '*', '')); +end +fprintf(' noise ceiling: [%.3f %.3f]\n', result.noise_ceiling(1), result.noise_ceiling(2)); + +function s = ternary(cond, a, b) +if cond, s = a; else, s = b; end +end diff --git a/CanlabCore/RSA_tools/examples/rsa_reliability_icc.m b/CanlabCore/RSA_tools/examples/rsa_reliability_icc.m new file mode 100644 index 00000000..610f25cf --- /dev/null +++ b/CanlabCore/RSA_tools/examples/rsa_reliability_icc.m @@ -0,0 +1,33 @@ +%% RSM reliability (ICC) across sessions +% Reproduces the 01282025 RSM Reliability workflow: per-subject ICC of the +% RSM across that subject's sessions, aggregated across subjects, at several +% grouping levels. + +dat = make_synthetic_rsa_data('n_sub', 9, 'n_ses', 5); + +%% Per-session RSM (one slice per subject x session) +R_sess = compute_rsm(dat, 'group_by', {'condition','bodysite'}, ... + 'level', 'session', 'subject_var', 'sub', ... + 'session_var', 'sesno', 'metric', 'spearman'); + +%% Whole-RSM reliability: per-subject ICC(3,k), then aggregate +out = R_sess.reliability('icc_type', '3-k'); +fprintf('Whole-RSM ICC across %d subjects: mean=%.3f, median=%.3f, std=%.3f\n', ... + out.summary.n_subjects, out.summary.mean, out.summary.median, out.summary.std); +disp(out.per_subject) + +%% Per-grouping reliability (per superordinate condition + per bodysite) +T_rel_g = R_sess.reliability_by_grouping('icc_type', '3-k'); +disp(T_rel_g) +% NOTE: condition-level rows (hot/warm/imagine, 28 cells) are reliable; +% bodysite-level rows (3 cells) are flagged as statistically unreliable. + +%% Per-individual-condition reliability (row-level) +T_rel_pc = R_sess.reliability_per_condition('icc_type', '3-k'); +disp(head(T_rel_pc, 8)) + +%% Bar plot of subject reliabilities +figure; +barplot_columns(out.per_subject.ICC', 'names', out.per_subject.sub', ... + 'noviolin', 'nostars', 'noind'); +ylim([0 1.1]); ylabel('Reliability (ICC 3-k)'); title('RSM reliability by subject'); diff --git a/CanlabCore/RSA_tools/examples/rsa_signature_crosssubject_pipeline.m b/CanlabCore/RSA_tools/examples/rsa_signature_crosssubject_pipeline.m new file mode 100644 index 00000000..7b9d32cd --- /dev/null +++ b/CanlabCore/RSA_tools/examples/rsa_signature_crosssubject_pipeline.m @@ -0,0 +1,74 @@ +%% Cross-subject bodysite SVM-signature RSA pipeline (corrected / defensible) +% Consolidates the WASABI AcceptMap / DistractMap "Bodysite Pain SVM Signature +% Visualization Reports" signature-RSA workflow into the RSA_tools API, with +% the statistical fixes documented in RSA_tools/SIGNATURE_RSA_AUDIT.md. +% +% SCIENTIFIC SCOPE -- read this first. +% These maps are SVM *weight* maps trained to discriminate the within-site +% contrast (e.g. hot vs warm). Their cross-map similarity measures whether two +% decoders point the same way -- NOT whether bodysite identity is represented +% somatotopically. For the somatotopy question, prefer data-level RSA on the +% evoked patterns (compute_rsm), or pass Haufe / structure-coefficient maps +% into the builder instead of raw weights. This script answers the narrower, +% well-posed question: "are the learned signatures consistent across subjects, +% and more so for the matched bodysite than for mismatched bodysites?" + +%% 1. Load the per-subject x bodysite SVM outputs for each ROI ------------ +base = "\\dartfs-hpc\rc\lab\C\CANlab\labdata\projects\WASABI\WASABI_N_of_Few\analysis\WASABI-NofFew_BodyMap\results\SPM_FAST\PFR_painpathways"; +tmp1 = load(fullfile(base, "pf_roi_svm_s1.mat"), 'svm_stats_cell'); +tmp2 = load(fullfile(base, "pf_roi_svm_dpIns.mat"), 'svm_stats_cell'); +svm_s1 = tmp1.svm_stats_cell; +svm_dp = tmp2.svm_stats_cell; + +subjects = {'sub-SID000002','sub-SID000743','sub-SID001567','sub-SID001641', ... + 'sub-SID001651','sub-SID001684','sub-SID001804','sub-SID002328'}; +bodysite_names = {'leftface','rightface','leftarm','rightarm', ... + 'leftleg','rightleg','chest','abdomen'}; + +%% 2. Build the RSA matrices --------------------------------------------- +% Correlation is the PRIMARY (scale-free) metric. Dot product is magnitude-driven +% and kept only as a secondary diagnostic. +RSA_s1 = build_crosssubject_signature_rsa(svm_s1, subjects, bodysite_names, 'metric','correlation'); +RSA_dp = build_crosssubject_signature_rsa(svm_dp, subjects, bodysite_names, 'metric','correlation'); + +% Optional: use Haufe / structure-coefficient maps instead of raw weights for a +% more interpretable representational comparison (recommended secondary analysis): +% ext = @(c) structure_coefficients(c{1}.weight_obj, c{1}.dat_used); % needs training data +% RSA_dp_haufe = build_crosssubject_signature_rsa(svm_dp, subjects, bodysite_names, 'map_extractor', ext); + +%% 3. Visualize the geometry --------------------------------------------- +plot_signature_rsa_matrix(RSA_s1, 'title', 'S1'); +plot_signature_rsa_matrix(RSA_dp, 'title', 'dpIns'); + +%% 4. Same-site vs different-site (subject-level inference) --------------- +% Subject-level test (df = nSubjects-1), NOT ttest2 on non-independent pairs. +o_s1 = subject_level_crosssubject_effect(RSA_s1, 'tail','right'); +o_dp = subject_level_crosssubject_effect(RSA_dp, 'tail','right'); + +% Permutation test (shuffles site labels within subject) -- distribution-free. +perm_s1 = permutation_test_site_specificity(RSA_s1, 'nperm', 5000); +perm_dp = permutation_test_site_specificity(RSA_dp, 'nperm', 5000); + +plot_same_vs_different_site({RSA_s1, RSA_dp}, 'names', {'S1','dpIns'}); + +%% 5. dpIns vs S1 cross-subject consistency (subject-level, paired) ------- +% One value per subject per ROI, then paired t across the 8 subjects. +sub_dp = mean(o_dp.sitewise_same_z, 2, 'omitnan'); % z-space per subject +sub_s1 = mean(o_s1.sitewise_same_z, 2, 'omitnan'); +[~, p_roi, ci_roi, st_roi] = ttest(sub_dp, sub_s1, 'tail','right'); +fprintf('\ndpIns > S1 cross-subject consistency: paired t(%d)=%.2f, p=%.4g, 95%%CI[%.3f %.3f]\n', ... + st_roi.df, st_roi.tstat, p_roi, ci_roi(1), ci_roi(2)); + +%% 6. Sitewise cross-subject consistency by bodysite --------------------- +plot_sitewise_crosssubject_similarity({RSA_s1, RSA_dp}, 'names', {'S1','dpIns'}); + +%% 7. (Optional) Brain-wide parcelwise specificity ----------------------- +% Requires one svm_stats_cell per parcel: parcel_svm_cells{pidx} = subject x site. +% atlas = load_atlas('canlab2024'); +% out = signature_specificity_parcelwise(parcel_svm_cells, subjects, ... +% bodysite_names, atlas, 'nperm', 2000); +% disp(out.table) +% montage(out.consistency_map); % cross-subject consistency (r) +% montage(out.specificity_fdr); % same-diff specificity, FDR q<.05 + +fprintf('\nSignature cross-subject RSA pipeline complete.\n'); diff --git a/CanlabCore/RSA_tools/get_crosssubject_site_effect.m b/CanlabCore/RSA_tools/get_crosssubject_site_effect.m new file mode 100644 index 00000000..bec304d0 --- /dev/null +++ b/CanlabCore/RSA_tools/get_crosssubject_site_effect.m @@ -0,0 +1,63 @@ +function [same_z, diff_z, same_r, diff_r] = get_crosssubject_site_effect(RSA) +% get_crosssubject_site_effect Cross-subject same-site vs different-site cells. +% +% Collects, from the full signature RSA, every CROSS-SUBJECT pair (subject i ~= +% subject j) and splits it into same-bodysite and different-bodysite pools. +% Same-subject and self (diagonal) comparisons are excluded. +% +% IMPORTANT — THIS IS DESCRIPTIVE ONLY. +% The returned pools are pairwise cells that are NOT independent: each +% subject's map participates in many pairs, so a two-sample test on these +% pools (e.g. ttest2(same_z, diff_z)) has a massively inflated effective N and +% anticonservative p-values. For inference use subject_level_crosssubject_effect +% (subject-level aggregation) or permutation_test_site_specificity. Use these +% pools only for descriptive means / bar plots. +% +% Fisher-z (same_z, diff_z) is meaningful ONLY when RSA.metric == 'correlation'. +% For 'cosine'/'dotproduct' the z outputs are returned empty (with a warning), +% since atanh is not defined for unbounded similarities. +% +% :Usage: +% :: +% [same_z, diff_z, same_r, diff_r] = get_crosssubject_site_effect(RSA) +% +% :Inputs: +% **RSA:** struct from build_crosssubject_signature_rsa. +% +% :Outputs: +% **same_r / diff_r:** column vectors of cross-subject same-/different-site +% similarities (r-space, for descriptives/plots). +% **same_z / diff_z:** Fisher-z of the above ('correlation' metric only; +% otherwise []). +% +% :See also: subject_level_crosssubject_effect, permutation_test_site_specificity +% +% .. +% 2026 Michael Sun. GPL v3. +% .. + +M = RSA.M; +% Upper triangle, cross-subject only. +ut = triu(true(size(M)), 1); +xs = RSA.cross_subject_mask & ut; + +same_pairs = xs & RSA.same_site_mask; +diff_pairs = xs & ~RSA.same_site_mask; + +same_r = M(same_pairs); +diff_r = M(diff_pairs); +same_r = same_r(isfinite(same_r)); +diff_r = diff_r(isfinite(diff_r)); + +if strcmp(RSA.metric, 'correlation') + same_z = rsa_fisher_z(same_r); + diff_z = rsa_fisher_z(diff_r); +else + warning('get_crosssubject_site_effect:metric', ... + ['Fisher-z is only valid for correlation metric (RSA.metric = ''%s''). ', ... + 'Returning empty z outputs; use r-space descriptives.'], RSA.metric); + same_z = []; + diff_z = []; +end + +end diff --git a/CanlabCore/RSA_tools/get_sitewise_crosssubject_similarity.m b/CanlabCore/RSA_tools/get_sitewise_crosssubject_similarity.m new file mode 100644 index 00000000..67940031 --- /dev/null +++ b/CanlabCore/RSA_tools/get_sitewise_crosssubject_similarity.m @@ -0,0 +1,58 @@ +function [site_similarity_r, site_similarity_z] = get_sitewise_crosssubject_similarity(RSA) +% get_sitewise_crosssubject_similarity Per-bodysite cross-subject similarity pools. +% +% For each bodysite b, collects every CROSS-SUBJECT pair of that same bodysite +% (subject i site b vs subject j site b, i ~= j). Same-subject/self excluded. +% +% DESCRIPTIVE ONLY — the pools are non-independent pairwise cells (see +% get_crosssubject_site_effect). For inference across sites use the subject-level +% per-site means returned by subject_level_crosssubject_effect. +% +% :Usage: +% :: +% [site_r, site_z] = get_sitewise_crosssubject_similarity(RSA) +% +% :Inputs: +% **RSA:** struct from build_crosssubject_signature_rsa. +% +% :Outputs: +% **site_similarity_r:** nSites x 1 cell; each cell is a column vector of +% cross-subject same-site similarities (r-space). +% **site_similarity_z:** Fisher-z version ('correlation' metric only; else +% empty cells). +% +% :See also: get_crosssubject_site_effect, plot_sitewise_crosssubject_similarity +% +% .. +% 2026 Michael Sun. GPL v3. +% .. + +nSites = RSA.nSites; +M = RSA.M; +ut = triu(true(size(M)), 1); +xs = RSA.cross_subject_mask & ut; + +site_similarity_r = cell(nSites,1); +site_similarity_z = cell(nSites,1); +iscorr = strcmp(RSA.metric, 'correlation'); + +for b = 1:nSites + site_b = RSA.site_idx == b; + bb = site_b & site_b'; % both ends are site b + sel = xs & bb; + vals = M(sel); + vals = vals(isfinite(vals)); + site_similarity_r{b} = vals; + if iscorr + site_similarity_z{b} = rsa_fisher_z(vals); + else + site_similarity_z{b} = []; + end +end + +if ~iscorr + warning('get_sitewise_crosssubject_similarity:metric', ... + 'Fisher-z only valid for correlation metric (got ''%s''); z cells empty.', RSA.metric); +end + +end diff --git a/CanlabCore/RSA_tools/ledoit_wolf_shrinkage.m b/CanlabCore/RSA_tools/ledoit_wolf_shrinkage.m new file mode 100644 index 00000000..36377ef6 --- /dev/null +++ b/CanlabCore/RSA_tools/ledoit_wolf_shrinkage.m @@ -0,0 +1,54 @@ +function [sigma, shrinkage] = ledoit_wolf_shrinkage(X) +% ledoit_wolf_shrinkage Regularized covariance via Ledoit-Wolf shrinkage. +% +% Fallback for rsa.stat.covdiag when the Kriegeskorte rsatoolbox is not on +% the path. Shrinks the sample covariance toward a diagonal target with the +% optimal shrinkage intensity from Ledoit & Wolf (2004), J. Multivariate +% Analysis 88: 365-411. +% +% Inputs +% X [n x p] data (n observations x p variables) +% +% Outputs +% sigma [p x p] regularized covariance estimate +% shrinkage scalar in [0, 1] — applied shrinkage intensity +% +% Algorithm follows the rsa.stat.covdiag source: shrinks toward +% target = mean(diag(sample)) * I (constant diagonal). For our use the +% common "diagonal of sample, zero off-diagonal" target is also acceptable; +% we use the constant-diagonal form to match covdiag's conventions. + +[n, p] = size(X); +if n < 2 + error('ledoit_wolf_shrinkage:tooFewObs', 'Need at least 2 observations.'); +end + +% Center +Xc = X - mean(X, 1); + +% Sample covariance with ML normalization (matches Ledoit-Wolf derivation) +sample = (Xc' * Xc) / n; + +% Target: mean variance on diagonal, 0 off-diagonal +mean_var = mean(diag(sample)); +target = mean_var * eye(p); + +% Estimate phi = sum_ij Var(sample_ij) +Y2 = Xc.^2; +phi_mat = (Y2' * Y2) / n - sample.^2; +phi = sum(phi_mat(:)); + +% Estimate gamma = ||sample - target||_F^2 +gamma = norm(sample - target, 'fro')^2; + +% Optimal shrinkage intensity (clip to [0, 1]) +if gamma > 0 + kappa = phi / gamma; + shrinkage = max(0, min(1, kappa / n)); +else + shrinkage = 1; +end + +sigma = shrinkage * target + (1 - shrinkage) * sample; + +end diff --git a/CanlabCore/RSA_tools/permutation_test_site_specificity.m b/CanlabCore/RSA_tools/permutation_test_site_specificity.m new file mode 100644 index 00000000..ccc38c5b --- /dev/null +++ b/CanlabCore/RSA_tools/permutation_test_site_specificity.m @@ -0,0 +1,142 @@ +function out = permutation_test_site_specificity(RSA, varargin) +% permutation_test_site_specificity Permutation test for cross-subject bodysite +% specificity that respects the dependency structure of the RSA. +% +% NULL HYPOTHESIS & EXCHANGEABILITY +% H0: bodysite labels carry no cross-subject representational information. +% Under H0, the site labels are exchangeable WITHIN each subject (we must keep +% subject identity fixed, because subjects differ in overall map similarity / +% SNR). Each permutation independently shuffles the site labels within every +% subject, then recomputes the SUBJECT-LEVEL specificity statistic +% (mean over anchor subjects of same-site minus different-site cross-subject +% similarity, in Fisher-z space for the correlation metric). The observed +% statistic is compared to this null distribution. +% +% This is more defensible than ttest2 on pooled pairwise cells (which ignores +% non-independence) and complements subject_level_crosssubject_effect (which +% assumes normality of the per-subject contrast). +% +% :Usage: +% :: +% out = permutation_test_site_specificity(RSA) +% out = permutation_test_site_specificity(RSA, 'nperm', 5000, 'seed', 1) +% +% :Inputs: +% **RSA:** struct from build_crosssubject_signature_rsa. +% +% :Optional Inputs: +% **'nperm':** number of permutations (default 5000). +% **'seed':** RNG seed for reproducibility (default 0). [] = do not set. +% **'tail':** 'right' (default; same>diff), 'both', 'left'. +% **'doverbose':** print summary (default true). +% +% :Outputs: +% **out:** struct with +% .observed observed subject-level specificity statistic (z-space) +% .null nperm x 1 null statistics +% .p permutation p-value (with +1 correction) +% .z (observed - mean(null)) / std(null) +% .nperm, .tail, .metric +% +% :Examples: +% :: +% out = permutation_test_site_specificity(RSA_dp_corr, 'nperm', 5000); +% fprintf('specificity p_perm = %.4g\n', out.p); +% +% :See also: subject_level_crosssubject_effect +% +% .. +% 2026 Michael Sun. GPL v3. +% .. + +p = inputParser; +p.addParameter('nperm', 5000, @(x) isnumeric(x) && isscalar(x) && x > 0); +p.addParameter('seed', 0, @(x) isempty(x) || isnumeric(x)); +p.addParameter('tail', 'right', @(x) any(strcmpi(x, {'right','both','left'}))); +p.addParameter('doverbose', true, @(x) islogical(x) || isnumeric(x)); +p.parse(varargin{:}); +nperm = round(p.Results.nperm); +seed = p.Results.seed; +tail = lower(p.Results.tail); +doverbose = logical(p.Results.doverbose); + +if ~isempty(seed), rng(seed); end + +nSub = RSA.nSub; +M = RSA.M; +iscorr = strcmp(RSA.metric, 'correlation'); +if iscorr, Mt = rsa_fisher_z(M); else, Mt = M; end + +subj = RSA.subject_idx; +site = RSA.site_idx; + +% Observed statistic with true site labels. +observed = local_subject_spec(Mt, subj, site, nSub); + +% Permutation: shuffle site labels within each subject. +nullstat = nan(nperm,1); +% Precompute per-subject node indices for fast within-subject shuffling. +subj_nodes = arrayfun(@(s) find(subj == s), 1:nSub, 'UniformOutput', false); + +for it = 1:nperm + site_perm = site; + for s = 1:nSub + idx = subj_nodes{s}; + site_perm(idx) = site(idx(randperm(numel(idx)))); + end + nullstat(it) = local_subject_spec(Mt, subj, site_perm, nSub); +end + +% Permutation p-value (+1 correction). +switch tail + case 'right' + pval = (1 + sum(nullstat >= observed)) / (nperm + 1); + case 'left' + pval = (1 + sum(nullstat <= observed)) / (nperm + 1); + case 'both' + pval = (1 + sum(abs(nullstat) >= abs(observed))) / (nperm + 1); +end + +out = struct(); +out.observed = observed; +out.null = nullstat; +out.p = pval; +out.z = (observed - mean(nullstat, 'omitnan')) / std(nullstat, 'omitnan'); +out.nperm = nperm; +out.tail = tail; +out.metric = RSA.metric; + +if doverbose + fprintf('\nPermutation test of cross-subject site specificity (metric=%s)\n', RSA.metric); + fprintf(' observed same-diff = %.4f | null mean = %.4f (SD %.4f)\n', ... + observed, mean(nullstat,'omitnan'), std(nullstat,'omitnan')); + fprintf(' permutation p = %.4g (%s, %d perms), z = %.2f\n', pval, tail, nperm, out.z); +end + +end + + +function stat = local_subject_spec(Mt, subj, site, nSub) +% Subject-level same-site minus different-site, averaged over anchor subjects. +spec = nan(nSub,1); +for a = 1:nSub + is_a = subj == a; + not_a = subj ~= a; + sites_a = unique(site(is_a)); + + same_vals = []; + diff_vals = []; + for b = sites_a(:)' + a_node = is_a & (site == b); + same_p = not_a & (site == b); + diff_p = not_a & (site ~= b); + + sv = Mt(a_node, same_p); sv = sv(isfinite(sv)); + dv = Mt(a_node, diff_p); dv = dv(isfinite(dv)); + same_vals = [same_vals; sv(:)]; %#ok + diff_vals = [diff_vals; dv(:)]; %#ok + end + spec(a) = mean(same_vals,'omitnan') - mean(diff_vals,'omitnan'); +end +stat = mean(spec, 'omitnan'); +end diff --git a/CanlabCore/RSA_tools/plot_rsm_contrast_bars.m b/CanlabCore/RSA_tools/plot_rsm_contrast_bars.m new file mode 100644 index 00000000..420fe6e3 --- /dev/null +++ b/CanlabCore/RSA_tools/plot_rsm_contrast_bars.m @@ -0,0 +1,123 @@ +function h = plot_rsm_contrast_bars(data, varargin) +% plot_rsm_contrast_bars Bar plot of per-subject RSA contrast values. +% +% Wraps barplot_columns + plotWithinGroup with the styling conventions used +% across the Sun et al. workflow notebooks (Figs 7D-F): bar plus per-subject +% lines, optional pairwise-significance markers, within-subject paired t-test +% defaults. +% +% Usage +% ----- +% % From an rsm: pass a cells_table or contrast spec +% T = R.cells_table({'hot','warm','imagine'}); +% h = plot_rsm_contrast_bars(T); +% +% % Or directly from a per-subject matrix or cell array +% h = plot_rsm_contrast_bars(matrix, 'names', {'Hot','Warm','Imagine'}); +% h = plot_rsm_contrast_bars({v1, v2, v3}, 'names', {'Hot','Warm','Imagine'}); +% +% Optional name-value +% ------------------- +% 'names' cellstr of bar labels (default: from table columns or auto) +% 'colors' cell of RGB triplets, one per bar (default: hsv) +% 'within_lines' true (default) -- draw per-subject connecting lines via +% plotWithinGroup +% 'within_colors' [n_subjects x 3] RGB matrix for the within-subject lines +% 'pairwise' true (default) -- show pairwise significance bars +% 'ylabel' default 'Fisher r-to-z' +% 'title' default '' +% 'ylim' default [] (auto) +% 'face_alpha' default 1 +% +% Output +% ------ +% h struct with handles from barplot_columns + +p = inputParser; +p.addParameter('names', {}, @(x) iscellstr(x) || isstring(x) || isempty(x)); %#ok +p.addParameter('colors', {}, @(x) iscell(x) || isnumeric(x) || isempty(x)); +p.addParameter('within_lines', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('within_colors', [], @isnumeric); +p.addParameter('pairwise', true, @(x) islogical(x) || isnumeric(x)); +p.addParameter('ylabel', 'Fisher r-to-z', @(x) ischar(x) || isstring(x)); +p.addParameter('title', '', @(x) ischar(x) || isstring(x)); +p.addParameter('ylim', [], @(x) isempty(x) || (isnumeric(x) && numel(x) == 2)); +p.addParameter('face_alpha', 1, @isnumeric); +p.parse(varargin{:}); +opt = p.Results; + +% Coerce data to a cell of column vectors and pull names +if istable(data) + cols = data.Properties.VariableNames; + cell_data = cellfun(@(c) data.(c), cols, 'UniformOutput', false); + if isempty(opt.names), opt.names = cols; end +elseif ismatrix(data) && ~iscell(data) + cell_data = num2cell(data, 1); + if isempty(opt.names) + opt.names = arrayfun(@(i) sprintf('condition_%d', i), 1:size(data,2), 'UniformOutput', false); + end +elseif iscell(data) + cell_data = data; + if isempty(opt.names) + opt.names = arrayfun(@(i) sprintf('condition_%d', i), 1:numel(cell_data), 'UniformOutput', false); + end +else + error('plot_rsm_contrast_bars:badData', ... + 'data must be a table, matrix, or cell of vectors.'); +end + +names = cellstr(opt.names); +n_bars = numel(cell_data); + +% Default colors +if isempty(opt.colors) + cmap = hsv(n_bars); + colors = num2cell(cmap, 2); +else + colors = opt.colors; +end +if isnumeric(colors), colors = num2cell(colors, 2); end + +% Build barplot_columns args +args = {'names', names, 'color', colors, 'noviolin', 'noind', 'nofig'}; +if opt.pairwise, args = [args, {'pairwisetest'}]; end + +h = barplot_columns(cell_data, args{:}); +hold on; + +% Style bars +for k = 1:numel(h.bar_han) + h.bar_han{k}.LineWidth = 3; + h.bar_han{k}.FaceAlpha = opt.face_alpha; +end + +% Within-subject lines +if opt.within_lines && exist('plotWithinGroup', 'file') == 2 + wg_args = {'PlotHalfViolin', false, 'PlotBox', false}; + if ~isempty(opt.within_colors) + wg_args = [wg_args, {'IndColors', opt.within_colors}]; + else + % Default: hsv per subject + n_subj = numel(cell_data{1}); + wg_args = [wg_args, {'IndColors', hsv(n_subj)}]; + end + try + plotWithinGroup(cell_data, names, wg_args{:}); + catch + % silently skip if plotWithinGroup signature differs + end +end + +% Zero line, labels +if exist('hline', 'file') == 2 + try, hline(0, 'k-'); catch, end +end +ylabel(char(opt.ylabel)); +xlabel(''); +if ~isempty(char(opt.title)), title(char(opt.title), 'Interpreter','none'); end +if ~isempty(opt.ylim), ylim(opt.ylim); end +xtickangle(0); + +hold off; + +end diff --git a/CanlabCore/RSA_tools/plot_same_vs_different_site.m b/CanlabCore/RSA_tools/plot_same_vs_different_site.m new file mode 100644 index 00000000..2d47f22c --- /dev/null +++ b/CanlabCore/RSA_tools/plot_same_vs_different_site.m @@ -0,0 +1,88 @@ +function out = plot_same_vs_different_site(RSA_list, varargin) +% plot_same_vs_different_site Same-site vs different-site cross-subject similarity bars. +% +% Plots cross-subject same-bodysite vs different-bodysite signature similarity +% for one or more ROIs/RSA structs side by side. Bars are subject-level means +% and error bars are SEM ACROSS SUBJECTS (the honest unit of replication), not +% across non-independent pairwise cells. +% +% :Usage: +% :: +% plot_same_vs_different_site(RSA) % single ROI +% plot_same_vs_different_site({RSA_s1, RSA_dp}, 'names', {'S1','dpIns'}) +% +% :Inputs: +% **RSA_list:** a single RSA struct or a cell array of RSA structs. +% +% :Optional Inputs: +% **'names':** cellstr of ROI labels (default 'ROI1','ROI2',...). +% **'axes':** target axes handle (default: new figure). +% +% :Outputs: +% **out:** struct with per-ROI subject-level means (same_r, diff_r), SEMs, and +% the subject-level test (t, p, df) from subject_level_crosssubject_effect. +% +% :See also: subject_level_crosssubject_effect, plot_sitewise_crosssubject_similarity +% +% .. +% 2026 Michael Sun. GPL v3. +% .. + +if ~iscell(RSA_list), RSA_list = {RSA_list}; end +nROI = numel(RSA_list); + +p = inputParser; +p.addParameter('names', arrayfun(@(i) sprintf('ROI%d', i), 1:nROI, 'UniformOutput', false), @iscell); +p.addParameter('axes', [], @(x) isempty(x) || ishandle(x)); +p.parse(varargin{:}); +names = p.Results.names; +ax = p.Results.axes; + +if isempty(ax) + figure('Color','w'); ax = gca; +end +hold(ax, 'on'); + +same_m = nan(nROI,1); diff_m = nan(nROI,1); +same_se = nan(nROI,1); diff_se = nan(nROI,1); +stats = cell(nROI,1); + +for i = 1:nROI + o = subject_level_crosssubject_effect(RSA_list{i}, 'doverbose', false); + stats{i} = o; + same_m(i) = mean(o.same_r, 'omitnan'); + diff_m(i) = mean(o.diff_r, 'omitnan'); + same_se(i) = std(o.same_r, 'omitnan') / sqrt(sum(isfinite(o.same_r))); + diff_se(i) = std(o.diff_r, 'omitnan') / sqrt(sum(isfinite(o.diff_r))); +end + +% Grouped bars: per ROI a pair (same, diff). +xc = (1:nROI) * 3; % cluster centers +w = 0.6; +for i = 1:nROI + bar(ax, xc(i)-0.5, same_m(i), w); + bar(ax, xc(i)+0.5, diff_m(i), w); + errorbar(ax, xc(i)-0.5, same_m(i), same_se(i), 'k.', 'LineWidth', 1.2); + errorbar(ax, xc(i)+0.5, diff_m(i), diff_se(i), 'k.', 'LineWidth', 1.2); + % significance annotation from subject-level test + star = ''; + if stats{i}.p < .001, star = '***'; elseif stats{i}.p < .01, star = '**'; ... + elseif stats{i}.p < .05, star = '*'; end + yt = max(same_m(i)+same_se(i), diff_m(i)+diff_se(i)); + text(ax, xc(i), yt*1.1, sprintf('%s p=%.3g', star, stats{i}.p), ... + 'HorizontalAlignment','center', 'FontSize', 9); +end + +xticks(ax, xc); +xticklabels(ax, names); +ylabel(ax, sprintf('Cross-subject similarity (r)\n[%s metric]', RSA_list{1}.metric)); +title(ax, 'Same-bodysite vs different-bodysite (subject-level \pm SEM)'); +yh = yline(ax, 0, 'k--'); yh.HandleVisibility = 'off'; % keep out of legend +legend(ax, {'Same site','Different site'}, 'Location', 'best'); +box(ax, 'off'); +hold(ax, 'off'); + +out = struct('names', {names}, 'same_m', same_m, 'diff_m', diff_m, ... + 'same_se', same_se, 'diff_se', diff_se, 'stats', {stats}); + +end diff --git a/CanlabCore/RSA_tools/plot_signature_rsa_matrix.m b/CanlabCore/RSA_tools/plot_signature_rsa_matrix.m new file mode 100644 index 00000000..72731038 --- /dev/null +++ b/CanlabCore/RSA_tools/plot_signature_rsa_matrix.m @@ -0,0 +1,57 @@ +function plot_signature_rsa_matrix(RSA, varargin) +% plot_signature_rsa_matrix Full subject x site RSA matrix and collapsed site matrix. +% +% Draws (left) the full (nSub*nSites)^2 signature RSA with subject block +% boundaries, and (right) the cross-subject-only bodysite x bodysite collapsed +% matrix. For the correlation metric the color limits are [-1 1]; otherwise auto. +% +% :Usage: +% :: +% plot_signature_rsa_matrix(RSA) +% plot_signature_rsa_matrix(RSA, 'title', 'dpIns') +% +% :Inputs: +% **RSA:** struct from build_crosssubject_signature_rsa. +% +% :Optional Inputs: +% **'title':** string prefix for panel titles (default RSA.metric). +% +% :See also: add_subject_boundaries, collapse_rsa_to_bodysite_matrix +% +% .. +% 2026 Michael Sun. GPL v3. +% .. + +p = inputParser; +p.addParameter('title', RSA.metric, @(x) ischar(x) || isstring(x)); +p.parse(varargin{:}); +ttl = char(p.Results.title); + +iscorr = strcmp(RSA.metric, 'correlation'); +if iscorr, clim = [-1 1]; else, clim = []; end + +% Use the lab diverging colormap if available, else default. +try cm = colormap_tor([0 0 1],[1 0 0],[1 1 1]); catch, cm = parula; end + +figure('Color','w','Position',[100 100 1200 480]); +tiledlayout(1,2,'TileSpacing','compact','Padding','compact'); + +% --- full matrix --- +ax1 = nexttile; +if isempty(clim), imagesc(ax1, RSA.M); else, imagesc(ax1, RSA.M, clim); end +axis(ax1,'square'); colorbar(ax1); colormap(ax1, cm); +add_subject_boundaries(ax1, RSA.nSub, RSA.nSites); +title(ax1, sprintf('%s: full subject\\times site RSA', ttl)); +xlabel(ax1, 'Subject \times bodysite'); ylabel(ax1, 'Subject \times bodysite'); + +% --- collapsed (cross-subject only) --- +G = collapse_rsa_to_bodysite_matrix(RSA, true); +ax2 = nexttile; +if isempty(clim), imagesc(ax2, G); else, imagesc(ax2, G, clim); end +axis(ax2,'square'); colorbar(ax2); colormap(ax2, cm); +title(ax2, sprintf('%s: cross-subject bodysite\\times bodysite', ttl)); +xticks(ax2, 1:RSA.nSites); yticks(ax2, 1:RSA.nSites); +xticklabels(ax2, RSA.bodysite_names); yticklabels(ax2, RSA.bodysite_names); +xtickangle(ax2, 45); + +end diff --git a/CanlabCore/RSA_tools/plot_sitewise_crosssubject_similarity.m b/CanlabCore/RSA_tools/plot_sitewise_crosssubject_similarity.m new file mode 100644 index 00000000..63ccee43 --- /dev/null +++ b/CanlabCore/RSA_tools/plot_sitewise_crosssubject_similarity.m @@ -0,0 +1,77 @@ +function out = plot_sitewise_crosssubject_similarity(RSA_list, varargin) +% plot_sitewise_crosssubject_similarity Per-bodysite cross-subject consistency. +% +% Plots cross-subject same-bodysite similarity broken down by bodysite, for one +% or more ROIs. Points are subject-level means (each subject's mean cross-subject +% same-site similarity for that bodysite); error bars are SEM across subjects. +% +% :Usage: +% :: +% plot_sitewise_crosssubject_similarity(RSA) +% plot_sitewise_crosssubject_similarity({RSA_s1, RSA_dp}, 'names', {'S1','dpIns'}) +% +% :Inputs: +% **RSA_list:** a single RSA struct or cell array of RSA structs (shared +% bodysite ordering / names). +% +% :Optional Inputs: +% **'names':** cellstr ROI labels. +% **'axes':** target axes (default new figure). +% +% :Outputs: +% **out:** struct with per-ROI nSites x 1 subject-level means (r-space) and SEMs. +% +% :See also: get_sitewise_crosssubject_similarity, subject_level_crosssubject_effect +% +% .. +% 2026 Michael Sun. GPL v3. +% .. + +if ~iscell(RSA_list), RSA_list = {RSA_list}; end +nROI = numel(RSA_list); + +p = inputParser; +p.addParameter('names', arrayfun(@(i) sprintf('ROI%d', i), 1:nROI, 'UniformOutput', false), @iscell); +p.addParameter('axes', [], @(x) isempty(x) || ishandle(x)); +p.parse(varargin{:}); +names = p.Results.names; +ax = p.Results.axes; + +if isempty(ax), figure('Color','w'); ax = gca; end +hold(ax, 'on'); + +bodysite_names = RSA_list{1}.bodysite_names; +nSites = RSA_list{1}.nSites; +iscorr = strcmp(RSA_list{1}.metric, 'correlation'); + +means = nan(nSites, nROI); +sems = nan(nSites, nROI); + +for i = 1:nROI + o = subject_level_crosssubject_effect(RSA_list{i}, 'doverbose', false); + % sitewise_same_z is nSub x nSites (z-space for correlation). + sw = o.sitewise_same_z; + if iscorr + sw_r = tanh(sw); % per-subject, per-site, back to r + else + sw_r = sw; + end + means(:,i) = mean(sw_r, 1, 'omitnan')'; + sems(:,i) = (std(sw_r, 0, 1, 'omitnan') ./ sqrt(sum(isfinite(sw_r), 1)))'; + errorbar(ax, 1:nSites, means(:,i), sems(:,i), '-o', 'LineWidth', 2); +end + +xticks(ax, 1:nSites); +xticklabels(ax, bodysite_names); +xtickangle(ax, 45); +ylabel(ax, sprintf('Cross-subject similarity (r)\n[%s metric]', RSA_list{1}.metric)); +title(ax, 'Across-subject consistency of bodysite representations (subject-level \pm SEM)'); +yh = yline(ax, 0, 'k--'); yh.HandleVisibility = 'off'; % keep out of legend +legend(ax, names, 'Location', 'best'); +box(ax, 'off'); +hold(ax, 'off'); + +out = struct('names', {names}, 'bodysite_names', {bodysite_names}, ... + 'means', means, 'sems', sems); + +end diff --git a/CanlabCore/RSA_tools/rsa_compare_lme_models.m b/CanlabCore/RSA_tools/rsa_compare_lme_models.m new file mode 100644 index 00000000..ff7954b7 --- /dev/null +++ b/CanlabCore/RSA_tools/rsa_compare_lme_models.m @@ -0,0 +1,178 @@ +function [T, best_idx] = rsa_compare_lme_models(dat, formulas, varargin) +% rsa_compare_lme_models Fit and compare a sequence of nested LME models. +% +% Replaces the manual fitlme-loop pattern in `08072024 Run-Level RDM Analysis` +% lines 2633-2691 with one declarative call. +% +% Usage +% ----- +% formulas = { ... +% 'Y ~ 1 + (1 | Sub)'; +% 'Y ~ SameCondition + (1 | Sub)'; +% 'Y ~ SameCondition + SameBodysite + (1 | Sub)'; +% 'Y ~ SameCondition + SameBodysite + SameConditionxSameBodysite + (1 | Sub)'; +% 'Y ~ SameCondition + SameBodysite + SameConditionxSameBodysite + SameSession + (1 | Sub)'; +% }; +% [T, best] = rsa_compare_lme_models(dat, formulas, ... +% 'predictors', {'condition','bodysite','session_number'}, ... +% 'interactions', {{'condition','bodysite'}}, ... +% 'subject_var', 'sub', ... +% 'select_by', 'aic'); +% +% Inputs +% ------ +% dat fmri_data object (passed to rsa_lme for each formula) +% formulas cellstr of Wilkinson formulas, ordered from simplest to most complex +% +% Optional name-value +% ------------------- +% All rsa_lme name-value pairs are forwarded (predictors, interactions, +% three_way, subject_var, response_transform, metric, fit_method, etc.) +% +% Plus: +% 'lrt_pairs' 'sequential' (default) -- LRT each model against the +% previous one +% 'vs_first' -- LRT each model against the first +% 'none' -- skip LRT +% 'select_by' 'aic' (default) | 'bic' | 'loglik' -- which criterion to +% report as the best model +% 'verbose' logical (default true) +% +% Outputs +% ------- +% T table with one row per model: +% model | n_params | AIC | BIC | logLik | lrt_chi2 | lrt_df | lrt_p +% best_idx index of the model with the best 'select_by' criterion + +% Split off the rsa_compare-only params +p = inputParser; +p.KeepUnmatched = true; +p.addParameter('lrt_pairs', 'sequential', @(x) (ischar(x) || isstring(x)) && ismember(lower(char(x)), {'sequential','vs_first','none'})); +p.addParameter('select_by', 'aic', @(x) (ischar(x) || isstring(x)) && ismember(lower(char(x)), {'aic','bic','loglik'})); +p.addParameter('verbose', true, @(x) islogical(x) || isnumeric(x)); +p.parse(varargin{:}); +opt = p.Results; + +% Forward unknown params to rsa_lme. +% NB: Nested LRT of LME models with different fixed effects requires ML +% fit (REML criterion isn't comparable across different fixed designs). +% Force fit_method='ML' so compare() works. +fwd = struct2cellfwd(p.Unmatched); +% Strip any user-supplied fit_method and replace with ML +fwd = remove_named(fwd, 'fit_method'); +fwd = [fwd, {'fit_method', 'ML'}]; +if opt.verbose + fprintf('rsa_compare_lme_models: forcing fit_method=''ML'' for nested LRT validity.\n'); +end + +formulas = cellstr(formulas); +n = numel(formulas); + +% ========================================================================= +% Fit each model +% ========================================================================= +models = cell(n, 1); +aics = nan(n, 1); +bics = nan(n, 1); +logliks = nan(n, 1); +n_params = nan(n, 1); + +for i = 1:n + if opt.verbose, fprintf('rsa_compare_lme_models: fitting %d/%d: %s\n', i, n, formulas{i}); end + models{i} = rsa_lme(dat, formulas{i}, fwd{:}, 'verbose', false); + aics(i) = models{i}.ModelCriterion.AIC; + bics(i) = models{i}.ModelCriterion.BIC; + logliks(i) = models{i}.LogLikelihood; + % NumCoefficients is reliably available; covariance-parameter count + % varies across MATLAB versions, so fall back to the AIC-implied count. + n_params(i) = models{i}.NumCoefficients; +end + +% ========================================================================= +% Nested likelihood-ratio tests +% ========================================================================= +lrt_chi2 = nan(n, 1); +lrt_df = nan(n, 1); +lrt_p = nan(n, 1); + +switch lower(char(opt.lrt_pairs)) + case 'sequential' + for i = 2:n + try + cmp = compare(models{i-1}, models{i}, 'CheckNesting', true); + lrt_chi2(i) = cmp.LRStat(end); + lrt_df(i) = cmp.deltaDF(end); + lrt_p(i) = cmp.pValue(end); + catch ME + if opt.verbose + warning('rsa_compare_lme_models:lrtFailed', ... + 'LRT failed for model %d: %s', i, ME.message); + end + end + end + case 'vs_first' + for i = 2:n + try + cmp = compare(models{1}, models{i}, 'CheckNesting', true); + lrt_chi2(i) = cmp.LRStat(end); + lrt_df(i) = cmp.deltaDF(end); + lrt_p(i) = cmp.pValue(end); + catch ME + if opt.verbose + warning('rsa_compare_lme_models:lrtFailed', ... + 'LRT failed for model %d: %s', i, ME.message); + end + end + end + case 'none' + % skip +end + +% ========================================================================= +% Best model +% ========================================================================= +switch lower(char(opt.select_by)) + case 'aic', [~, best_idx] = min(aics); + case 'bic', [~, best_idx] = min(bics); + case 'loglik', [~, best_idx] = max(logliks); +end + +% ========================================================================= +% Output table +% ========================================================================= +T = table(formulas(:), n_params, aics, bics, logliks, lrt_chi2, lrt_df, lrt_p, ... + 'VariableNames', {'model','n_params','AIC','BIC','logLik','lrt_chi2','lrt_df','lrt_p'}); + +if opt.verbose + fprintf('\nrsa_compare_lme_models: best by %s = model %d:\n %s\n', ... + upper(char(opt.select_by)), best_idx, formulas{best_idx}); +end + +end + + +function out = struct2cellfwd(s) +% Convert a struct of name-value pairs back into a cell array for forwarding +fn = fieldnames(s); +out = cell(1, 2 * numel(fn)); +for i = 1:numel(fn) + out{2*i - 1} = fn{i}; + out{2*i} = s.(fn{i}); +end +end + + +function out = remove_named(args, name) +% Strip a name-value pair from a varargin cell +out = args; +idx = []; +for i = 1:2:numel(out)-1 + if (ischar(out{i}) || isstring(out{i})) && strcmpi(char(out{i}), name) + idx = i; + break + end +end +if ~isempty(idx) && idx < numel(out) + out([idx, idx+1]) = []; +end +end diff --git a/CanlabCore/RSA_tools/rsa_fisher_z.m b/CanlabCore/RSA_tools/rsa_fisher_z.m new file mode 100644 index 00000000..8826953f --- /dev/null +++ b/CanlabCore/RSA_tools/rsa_fisher_z.m @@ -0,0 +1,35 @@ +function z = rsa_fisher_z(r) +% rsa_fisher_z Safe Fisher r-to-z transform for averaging/testing correlations. +% +% z = atanh(r), with r clamped to (-1, 1) so that trivial self-correlations +% (r = 1) and tiny round-off overshoots do not produce +/-Inf or complex values. +% +% USE THIS ONLY ON CORRELATION-METRIC similarities. Dot-product and cosine +% similarities are NOT bounded in [-1, 1] (cosine can round slightly past 1; +% dot products are unbounded), so atanh of those is meaningless or complex. +% Compute statistics in z-space; report descriptive means back in r-space with +% tanh(mean_z) if desired. +% +% :Usage: +% :: +% z = rsa_fisher_z(r) +% +% :Inputs: +% **r:** scalar / vector / matrix of correlation coefficients. +% +% :Outputs: +% **z:** Fisher-z transformed values, same size as r. NaNs are preserved. +% +% :Examples: +% :: +% z = rsa_fisher_z([0.041 0.085 1.0]); % last value clamped, finite +% r_back = tanh(mean(z, 'omitnan')); % average in z-space, report in r-space +% +% .. +% 2026 Michael Sun. GPL v3. +% .. + +r = min(max(r, -0.999999), 0.999999); % clamp away from +/-1 +z = atanh(r); + +end diff --git a/CanlabCore/RSA_tools/rsa_group_inference.m b/CanlabCore/RSA_tools/rsa_group_inference.m new file mode 100644 index 00000000..0b2c5a9d --- /dev/null +++ b/CanlabCore/RSA_tools/rsa_group_inference.m @@ -0,0 +1,160 @@ +function T = rsa_group_inference(data, varargin) +% rsa_group_inference Group-level inference on per-subject RSA scalars. +% +% Standalone Fisher-z + paired/one-sample t-test + multiple-comparisons +% correction for raw per-subject values. Useful when you already have +% per-subject scalars (not an rsm) and want the same stats machinery as +% rsm.ttest_contrasts. +% +% Two input shapes +% ---------------- +% data is [n_subjects x n_conditions] -- runs one-sample t-tests per +% condition (each column independent) +% +% data is a cell array of [n_subjects x 1] vectors -- one cell per condition +% +% Usage examples +% -------------- +% % Per-condition one-sample test against zero +% T = rsa_group_inference(per_subject_matrix, 'names', {'hot','warm','imag'}); +% +% % With paired contrasts +% T = rsa_group_inference(per_subject_matrix, 'names', {'HW','HI','IW'}, ... +% 'contrasts', {{'HW','HI'}; {'HW','IW'}}); +% +% % With Fisher-z transform on raw correlations +% T = rsa_group_inference(per_subject_matrix, 'transform','fisherz'); +% +% Optional name-value +% ------------------- +% 'names' cellstr of column labels (default: condition_1, ...) +% 'transform' 'none' (default) | 'fisherz' +% 'tail' 'both' (default) | 'right' | 'left' +% 'correction' 'fdr' (default) | 'bonferroni' | 'none' +% 'q' threshold for sig flag (default 0.05) +% 'contrasts' cell of {a,b} pairs naming named columns; each runs paired +% t-test of column-a minus column-b +% +% Output +% ------ +% T table with one row per test: +% Name, Mean, SE, t, df, P, FDR_P (or Bonf_P), sig, Cohens_d + +p = inputParser; +p.addParameter('names', {}, @(x) iscellstr(x) || isstring(x) || isempty(x)); %#ok +p.addParameter('transform', 'none', @(x) ischar(x) || isstring(x)); +p.addParameter('tail', 'both', @(x) ischar(x) || isstring(x)); +p.addParameter('correction', 'fdr', @(x) ischar(x) || isstring(x)); +p.addParameter('q', 0.05, @isnumeric); +p.addParameter('contrasts', {}, @iscell); +p.parse(varargin{:}); +opt = p.Results; + +% Coerce data shape +if iscell(data) + % {n_cond x 1} of [n_subj x 1] vectors + n_cond = numel(data); + n_subj = numel(data{1}); + X = nan(n_subj, n_cond); + for c = 1:n_cond, X(:, c) = data{c}(:); end +elseif ismatrix(data) + X = data; + n_subj = size(X, 1); + n_cond = size(X, 2); +else + error('rsa_group_inference:badData', ... + 'data must be a matrix [n_subjects x n_conditions] or a cell of vectors.'); +end + +names = cellstr(opt.names); +if isempty(names) + names = arrayfun(@(i) sprintf('condition_%d', i), 1:n_cond, 'UniformOutput', false); +elseif numel(names) ~= n_cond + error('rsa_group_inference:badNames', ... + 'numel(names) = %d but data has %d columns.', numel(names), n_cond); +end + +% Apply transform +if strcmpi(opt.transform, 'fisherz') + X(X > 0.9999999) = 0.9999999; + X(X < -0.9999999) = -0.9999999; + X = atanh(X); +end + +% One-sample tests per column +rows = cell(0, 1); +for c = 1:n_cond + v = X(:, c); + rows{end+1} = run_one_test(names{c}, v, [], opt); %#ok +end + +% Paired contrasts +for k = 1:numel(opt.contrasts) + pair = opt.contrasts{k}; + if numel(pair) ~= 2 + error('rsa_group_inference:badContrast', ... + 'each contrasts entry must be {a, b}.'); + end + a_name = char(pair{1}); b_name = char(pair{2}); + ai = find(strcmp(names, a_name)); bi = find(strcmp(names, b_name)); + if isempty(ai) || isempty(bi) + error('rsa_group_inference:contrastNotFound', ... + 'contrast {%s, %s} references unknown column.', a_name, b_name); + end + contrast_name = sprintf('%s_vs_%s', a_name, b_name); + rows{end+1} = run_one_test(contrast_name, X(:, ai), X(:, bi), opt); %#ok +end + +T = vertcat(rows{:}); +T = apply_correction(T, opt); + +end + + +function row = run_one_test(name, a, b, opt) +% Run one t-test (one-sample if b empty, paired otherwise) +if isempty(b) + v = a; + [~, p_i, ~, st] = ttest(v, 0, 'Tail', char(opt.tail)); + m = mean(v, 'omitnan'); + se = std(v, 0, 'omitnan') / sqrt(sum(~isnan(v))); + d = m / std(v, 0, 'omitnan'); +else + v = a - b; + [~, p_i, ~, st] = ttest(v, 0, 'Tail', char(opt.tail)); + m = mean(v, 'omitnan'); + se = std(v, 0, 'omitnan') / sqrt(sum(~isnan(v))); + d = m / std(v, 0, 'omitnan'); +end +row = table({name}, m, se, st.tstat, st.df, p_i, NaN, false, d, ... + 'VariableNames', {'Name', 'Mean', 'SE', 't', 'df', 'P', 'Corrected_P', 'sig', 'Cohens_d'}); +end + + +function T = apply_correction(T, opt) +ps = T.P; +n = numel(ps); +switch lower(char(opt.correction)) + case 'fdr' + ranks = zeros(n, 1); [~, idx] = sort(ps); ranks(idx) = 1:n; + T.Corrected_P = min(ps(:) .* n ./ ranks, 1); + % BH significant set + if exist('FDR', 'file') == 2 + try, thresh = FDR(ps, opt.q); if isempty(thresh)||isnan(thresh), thresh = 0; end + T.sig = ps <= thresh; + catch + T.sig = ps <= opt.q; + end + else + T.sig = ps <= opt.q; + end + T.Properties.VariableNames{'Corrected_P'} = 'FDR_P'; + case 'bonferroni' + T.Corrected_P = min(ps * n, 1); + T.sig = T.Corrected_P < opt.q; + T.Properties.VariableNames{'Corrected_P'} = 'Bonf_P'; + case 'none' + T.Corrected_P = ps; + T.sig = ps < opt.q; +end +end diff --git a/CanlabCore/RSA_tools/rsa_lme_blups.m b/CanlabCore/RSA_tools/rsa_lme_blups.m new file mode 100644 index 00000000..ec4fa5d7 --- /dev/null +++ b/CanlabCore/RSA_tools/rsa_lme_blups.m @@ -0,0 +1,63 @@ +function T = rsa_lme_blups(mdl) +% rsa_lme_blups Extract per-subject BLUPs from a fitted LinearMixedModel. +% +% Returns Best Linear Unbiased Predictors (BLUPs) of the random effects per +% subject. For an LME with `(1 | Sub)`, each subject gets their own +% deviation from the population intercept. For `(X | Sub)`, also their own +% deviation from the population slope on X. +% +% Replicates `08072024 Run-Level RDM Analysis with RSA Toolbox.mlx` +% lines 2226-2280. +% +% Usage +% ----- +% mdl = dat.rsa_lme('Y ~ SameCondition + (SameCondition | Sub)', ...); +% T = rsa_lme_blups(mdl); +% % T columns: Group | Subject | Term | Estimate | SEPred (if available) +% +% Inputs +% ------ +% mdl LinearMixedModel +% +% Output +% ------ +% T long-format table: +% Group -- random-effects grouping variable name (e.g. 'Sub') +% Subject -- per-group level (subject ID) +% Term -- random-effects term name (e.g. '(Intercept)', 'SameCondition') +% Estimate -- BLUP value for this (subject, term) +% SEPred -- standard error of the prediction (when MATLAB provides it) + +assert(isa(mdl, 'LinearMixedModel'), 'rsa_lme_blups: requires a LinearMixedModel input.'); + +% Use the dataset overload of randomEffects to get a table with metadata +[blups, blup_names, blup_stats] = randomEffects(mdl); + +% blup_stats is a dataset/table with Group, Level, Name columns describing +% each entry of blups (one row per (group, level, term)) +if istable(blup_stats) + stats_tbl = blup_stats; +else + stats_tbl = dataset2table(blup_stats); +end + +n = numel(blups); + +% Coerce to a uniform set of columns +G = stats_tbl.Group; +L = stats_tbl.Level; +N = stats_tbl.Name; + +if iscategorical(G), G = cellstr(G); end +if iscategorical(L), L = cellstr(L); end +if iscategorical(N), N = cellstr(N); end + +SE = nan(n, 1); +if ismember('SEPred', stats_tbl.Properties.VariableNames) + SE = double(stats_tbl.SEPred); +end + +T = table(G, L, N, blups, SE, ... + 'VariableNames', {'Group', 'Subject', 'Term', 'Estimate', 'SEPred'}); + +end diff --git a/CanlabCore/RSA_tools/rsa_lme_icc.m b/CanlabCore/RSA_tools/rsa_lme_icc.m new file mode 100644 index 00000000..1b9eb727 --- /dev/null +++ b/CanlabCore/RSA_tools/rsa_lme_icc.m @@ -0,0 +1,126 @@ +function icc_struct = rsa_lme_icc(mdl) +% rsa_lme_icc Extract variance-components ICC(s) from a fitted LinearMixedModel. +% +% For an LME of the form Y ~ ... + (1 | Group) + (X | Group) + ..., this +% returns the proportion of total variance explained by each random-effect +% variance component: +% +% ICC_intercept = sigma_intercept^2 / total_variance +% ICC_slope_X = sigma_slope_X^2 / total_variance +% +% where total_variance = sum(random-effect variances) + residual variance. +% +% Reproduces the per-source variance calculation in `08072024 Run-Level RDM +% Analysis with RSA Toolbox.mlx` lines 2186-2225. +% +% Usage +% ----- +% mdl = dat.rsa_lme('Y ~ SameCondition + (SameCondition | Sub)', ...); +% icc = rsa_lme_icc(mdl); +% icc.summary % table of source | variance | proportion (= ICC) +% icc.total_variance % scalar +% icc.residual_variance % scalar +% +% Output +% ------ +% icc_struct struct with fields: +% .summary table: Source | Variance | ICC +% .total_variance sum of all variance components +% .residual_variance scalar +% .grouping_var name of the random-effects grouping variable + +assert(isa(mdl, 'LinearMixedModel'), 'rsa_lme_icc: requires a LinearMixedModel input.'); + +% Extract variance components +[psi, mse, stats] = covarianceParameters(mdl); +% psi is cell array of covariance matrices, one per random-effect group +% mse is the residual variance (scalar) +% stats has detail per group + +% Build per-source list. psi{g}(i, i) is the variance of the i-th random +% effect in group g. We pull the term names from stats{g} where Type='std' +% and Name1==Name2 (diagonal entries). +sources = {}; +variances = []; + +for g = 1:numel(psi) + grp_stats = stats{g}; + % Get group name (e.g. 'Sub'). Different MATLAB versions expose Group + % as char matrix (one row per RE term), cellstr, or categorical. + grp_name = sprintf('Group%d', g); + try + gv = grp_stats.Group; + if ischar(gv) + if size(gv, 1) >= 1, grp_name = strtrim(gv(1, :)); % char matrix + else, grp_name = gv; + end + elseif iscell(gv) && ~isempty(gv), grp_name = char(gv{1}); + elseif iscategorical(gv) && ~isempty(gv), grp_name = char(gv(1)); + elseif isstring(gv) && ~isempty(gv), grp_name = char(gv(1)); + end + catch + end + + % Pull RE term names from Type='std' rows where Name1==Name2. Handle + % both char-matrix and cellstr forms. + re_names = arrayfun(@(i) sprintf('RE%d', i), 1:size(psi{g}, 1), 'UniformOutput', false); + try + types = coerce_to_cellstr(grp_stats.Type); + names1 = coerce_to_cellstr(grp_stats.Name1); + names2 = coerce_to_cellstr(grp_stats.Name2); + is_std = strcmp(types, 'std'); + is_diag = strcmp(names1, names2); + diag_rows = find(is_std & is_diag); + if ~isempty(diag_rows) + re_names = names1(diag_rows); + end + catch + % keep generic fallback + end + + n_re_terms = size(psi{g}, 1); + for r = 1:n_re_terms + if r <= numel(re_names), term_name = re_names{r}; + else, term_name = sprintf('RE%d', r); end + sources{end+1, 1} = sprintf('%s | %s', term_name, grp_name); %#ok + variances(end+1, 1) = psi{g}(r, r); %#ok + end +end + +% Add residual +sources{end+1, 1} = 'Residual'; +variances(end+1, 1) = mse; + +total = sum(variances); +iccs = variances ./ total; + +icc_struct = struct(); +icc_struct.summary = table(sources, variances, iccs, ... + 'VariableNames', {'Source', 'Variance', 'ICC'}); +icc_struct.total_variance = total; +icc_struct.residual_variance = mse; + +% Grouping variable(s) for convenience +grp_set = {}; +for i = 1:numel(sources) + tok = regexp(sources{i}, '\|\s*(\w+)', 'tokens', 'once'); + if ~isempty(tok), grp_set{end+1} = tok{1}; end %#ok +end +icc_struct.grouping_var = unique(grp_set); + +end + + +function c = coerce_to_cellstr(v) +% Coerce a char matrix / cellstr / categorical / string to a cellstr column +if iscell(v), c = v(:); +elseif ischar(v) + % rows of a char matrix become cells + c = cell(size(v, 1), 1); + for r = 1:size(v, 1), c{r} = strtrim(v(r, :)); end +elseif iscategorical(v), c = cellstr(v); +elseif isstring(v), c = cellstr(v); +else, c = cellstr(string(v)); +end + +end diff --git a/CanlabCore/RSA_tools/rsa_model_sequence.m b/CanlabCore/RSA_tools/rsa_model_sequence.m new file mode 100644 index 00000000..d431d919 --- /dev/null +++ b/CanlabCore/RSA_tools/rsa_model_sequence.m @@ -0,0 +1,116 @@ +function seq = rsa_model_sequence(base_formula) +% rsa_model_sequence Builder for nested LME model lists (sugar over rsa_compare_models). +% +% Builds a sequence of Wilkinson formulas by adding one term at a time. +% Drives rsa_compare_models without writing every formula by hand. +% +% Usage +% ----- +% seq = rsa_model_sequence('Y ~ 1 + (1|Sub)') +% seq = seq.add_term('SameCondition'); +% seq = seq.add_term('SameBodysite'); +% seq = seq.add_term('SameCondition:SameBodysite'); +% seq = seq.add_term('SameSession'); +% seq = seq.upgrade_random('(SameCondition | Sub)'); % swap (1|Sub) -> (SameCondition|Sub) +% +% formulas = seq.formulas; +% [T, best] = dat.rsa_compare_models(formulas, ...); +% +% Inputs +% ------ +% base_formula char/string Wilkinson formula to start from. Must contain +% a random-effects clause '(... | )'. +% +% Returns +% ------- +% seq struct with handles: +% .add_term(term) add a fixed-effect term to the current formula +% .upgrade_random(re) replace the random-effects clause +% .formulas cellstr of all formulas built so far +% .current most recent formula +% .reset() drop all but the base formula + +if nargin < 1 || isempty(base_formula) + error('rsa_model_sequence:noBase', 'Pass a base Wilkinson formula.'); +end + +% Internal state, captured by closures +state = struct(); +state.base = char(base_formula); +state.formulas = {state.base}; + +seq = make_seq(); + + function s = make_seq() + s = struct(); + s.formulas = state.formulas; + s.current = state.formulas{end}; + s.add_term = @add_term_impl; + s.upgrade_random = @upgrade_random_impl; + s.reset = @reset_impl; + end + + function s2 = add_term_impl(term) + cur = state.formulas{end}; + new = inject_fixed_term(cur, char(term)); + state.formulas{end+1} = new; + s2 = make_seq(); + end + + function s2 = upgrade_random_impl(new_re) + cur = state.formulas{end}; + new = replace_random(cur, char(new_re)); + state.formulas{end+1} = new; + s2 = make_seq(); + end + + function s2 = reset_impl() + state.formulas = {state.base}; + s2 = make_seq(); + end + +end + + +function out = inject_fixed_term(formula, term) +% Split off the random-effects clause, append the term to the fixed RHS, +% reattach the random clause. +[fixed_rhs, rand_clauses] = split_formula(formula); +% Add term if not already present +if ~ismember(strtrim(term), strsplit(strtrim(fixed_rhs), {'+', ' '})) + if strcmp(strtrim(fixed_rhs), '1') + new_fixed = term; + else + new_fixed = [strtrim(fixed_rhs) ' + ' term]; + end +else + new_fixed = fixed_rhs; +end +out = ['Y ~ ' new_fixed]; +if ~isempty(rand_clauses) + out = [out ' + ' strjoin(rand_clauses, ' + ')]; +end +end + + +function out = replace_random(formula, new_re) +[fixed_rhs, ~] = split_formula(formula); +out = ['Y ~ ' strtrim(fixed_rhs) ' + ' new_re]; +end + + +function [fixed_rhs, rand_clauses] = split_formula(formula) +% Split a Wilkinson formula into fixed-effect RHS string and a list of +% random-effects parenthetical clauses. +rhs = regexprep(formula, '^\s*[A-Za-z0-9_]+\s*~\s*', ''); +% Find parenthesized random-effect clauses +rand_clauses = regexp(rhs, '\([^)]*\)', 'match'); +% Strip them out of the fixed RHS +fixed_rhs = regexprep(rhs, '\([^)]*\)', ''); +% Clean up dangling +s and whitespace +fixed_rhs = regexprep(fixed_rhs, '\s*\+\s*\+\s*', ' + '); +fixed_rhs = regexprep(fixed_rhs, '^\s*\+\s*', ''); +fixed_rhs = regexprep(fixed_rhs, '\s*\+\s*$', ''); +fixed_rhs = strtrim(fixed_rhs); +if isempty(fixed_rhs), fixed_rhs = '1'; end +end diff --git a/CanlabCore/RSA_tools/rsa_partial_r2.m b/CanlabCore/RSA_tools/rsa_partial_r2.m new file mode 100644 index 00000000..02831799 --- /dev/null +++ b/CanlabCore/RSA_tools/rsa_partial_r2.m @@ -0,0 +1,69 @@ +function T = rsa_partial_r2(mdl, tbl) +% rsa_partial_r2 Partial R² for each predictor in a fitted LinearModel. +% +% For each non-intercept predictor, refits the model dropping that predictor +% and computes partial R² = full_R² - reduced_R². This is the standard +% "incremental variance" metric. Used in `08072024 Run-Level RDM Analysis` +% lines 2050-2079. +% +% Usage +% ----- +% [mdl_lm, tbl, ~] = dat.rsa_lm('predictors', {...}); +% T = rsa_partial_r2(mdl_lm, tbl); +% % T columns: term | partial_R2 | Cohens_d +% +% Inputs +% ------ +% mdl LinearModel object (from fitlm) +% tbl table used to fit mdl +% +% Output +% ------ +% T table: +% term char +% partial_R2 double +% Cohens_d double (per-predictor effect size on Y, computed from std) + +assert(isa(mdl, 'LinearModel'), 'rsa_partial_r2: requires a LinearModel input.'); +assert(istable(tbl), 'rsa_partial_r2: requires a table input.'); + +terms = mdl.Coefficients.Properties.RowNames; +n_terms = numel(terms); + +partial_r2 = nan(n_terms, 1); +cohens_d = nan(n_terms, 1); + +% Identify the response column from the model formula +y_name = mdl.ResponseName; +std_y = std(tbl.(y_name), 'omitnan'); + +% All predictor names from the model (excluding intercept) +all_predictors = setdiff(terms, {'(Intercept)'}, 'stable'); + +for i = 1:n_terms + name = terms{i}; + if strcmp(name, '(Intercept)'), continue; end + + others = setdiff(all_predictors, {name}, 'stable'); + if isempty(others) + f_red = sprintf('%s ~ 1', y_name); + else + f_red = sprintf('%s ~ %s', y_name, strjoin(others, ' + ')); + end + try + mdl_red = fitlm(tbl, f_red); + partial_r2(i) = mdl.Rsquared.Ordinary - mdl_red.Rsquared.Ordinary; + catch + partial_r2(i) = NaN; + end + + % Cohen's d on this predictor (beta normalized by Y std) + if std_y > 0 + cohens_d(i) = mdl.Coefficients.Estimate(i) / std_y; + end +end + +T = table(terms, partial_r2, cohens_d, ... + 'VariableNames', {'term', 'partial_R2', 'Cohens_d'}); + +end diff --git a/CanlabCore/RSA_tools/rsa_recode_reference.m b/CanlabCore/RSA_tools/rsa_recode_reference.m new file mode 100644 index 00000000..de94791a --- /dev/null +++ b/CanlabCore/RSA_tools/rsa_recode_reference.m @@ -0,0 +1,112 @@ +function out = rsa_recode_reference(values, reference_levels, varargin) +% rsa_recode_reference Collapse a metadata column to {reference, other} levels. +% +% For "shared-anchor" designs where every subject shares one or more +% reference level(s) of a factor but each has idiosyncratic other levels +% (e.g. all subjects have a "Left Face" bodysite plus one different "other" +% bodysite each), this recodes the column so the reference level(s) are kept +% verbatim and every other value collapses to a single label. The recoded +% column is consistent across subjects, so compute_rsm builds the same k +% conditions for everyone and cross-subject reliability / contrasts become +% well-defined. +% +% :Usage: +% :: +% out = rsa_recode_reference(values, reference_levels, ['other_label', label]) +% +% :Inputs: +% **values:** +% a metadata column (cellstr / string / categorical / numeric). +% +% **reference_levels:** +% the value(s) to keep verbatim (char, string, cellstr, or numeric). +% Everything else collapses to the other_label. +% +% :Optional Inputs: +% **'other_label':** +% label for the collapsed non-reference values. Default 'Other'. +% +% **'ignore_case':** +% case-insensitive matching of reference levels. Default true. +% +% :Outputs: +% **out:** +% a cellstr column the same height as values, with reference levels +% preserved and all others set to other_label. +% +% :Examples: +% :: +% % DistractMap / AcceptMap: every subject has Left Face + one other site +% T = distractmap_run.metadata_table; +% T.bodysite_type = rsa_recode_reference(T.bodySite, 'Left Face', ... +% 'other_label', 'Other Body Site'); +% distractmap_run.metadata_table = T; +% +% R = compute_rsm(distractmap_run, 'group_by', {'condition','bodysite_type'}, ... +% 'subject_var','subject_id', 'level','session', ... +% 'session_var','session_number', 'metric','spearman'); +% out = R.reliability_per_condition; +% +% :See also: compute_rsm, rsm.reliability_per_condition +% +% .. +% 2026 Michael Sun. GPL v3. +% .. + +% ---------- INPUT PARSER ---------- +p = inputParser; +p.addRequired('values'); +p.addRequired('reference_levels'); +p.addParameter('other_label', 'Other', @(x) ischar(x) || isstring(x)); +p.addParameter('ignore_case', true, @(x) islogical(x) || isnumeric(x)); +p.parse(values, reference_levels, varargin{:}); +other_label = char(p.Results.other_label); +ignore_case = logical(p.Results.ignore_case); + +% ---------- Normalize inputs to cellstr ---------- +vals_str = local_to_cellstr(values); +refs = local_to_cellstr(reference_levels); + +% ---------- Match ---------- +if ignore_case + is_ref = ismember(lower(vals_str), lower(refs)); +else + is_ref = ismember(vals_str, refs); +end + +out = repmat({other_label}, numel(vals_str), 1); +out(is_ref) = vals_str(is_ref); + +% Warn if a reference level never matched (likely a typo / case issue) +matched_refs = unique(vals_str(is_ref)); +for i = 1:numel(refs) + if ignore_case + hit = any(strcmpi(matched_refs, refs{i})); + else + hit = any(strcmp(matched_refs, refs{i})); + end + if ~hit + warning('rsa_recode_reference:refNotFound', ... + 'Reference level "%s" did not match any value in the column.', refs{i}); + end +end + +end + + +function c = local_to_cellstr(x) +if iscell(x) + c = cellfun(@(v) char(string(v)), x, 'UniformOutput', false); + c = c(:); +elseif iscategorical(x) + c = cellstr(x); c = c(:); +elseif isstring(x) + c = cellstr(x); c = c(:); +elseif ischar(x) + c = {x}; +elseif isnumeric(x) || islogical(x) + c = cellfun(@(v) char(string(v)), num2cell(x(:)), 'UniformOutput', false); +else + error('rsa_recode_reference:badType', 'Unsupported input type: %s', class(x)); +end +end diff --git a/CanlabCore/RSA_tools/rsa_toolbox_status.m b/CanlabCore/RSA_tools/rsa_toolbox_status.m new file mode 100644 index 00000000..e2243825 --- /dev/null +++ b/CanlabCore/RSA_tools/rsa_toolbox_status.m @@ -0,0 +1,96 @@ +function caps = rsa_toolbox_status(varargin) +% rsa_toolbox_status Report which optional RSA dependencies are available. +% +% The CanlabCore RSA tools work standalone, but use the Kriegeskorte +% rsatoolbox (and a few File Exchange utilities) for enhanced functionality +% when they are on the MATLAB path. This prints a status report of what is +% detected and which built-in fallback will be used otherwise. +% +% Usage +% ----- +% rsa_toolbox_status % print a report +% caps = rsa_toolbox_status; % also return the capability struct +% caps = rsa_toolbox_status('quiet'); % return struct, no printing +% +% Output +% ------ +% caps struct of logicals for each detected capability (see +% @rsm/private/probe_rsatoolbox.m for the field list), plus: +% .ICC File Exchange ICC.m available +% .rangesearch Statistics Toolbox rangesearch (fast searchlight) +% .fitlme Statistics Toolbox fitlme (LME modeling) + +quiet = ~isempty(varargin) && any(strcmpi(varargin, 'quiet')); + +% Reuse the class-private probe by calling it through a tiny shim rsm method. +% probe_rsatoolbox lives in @rsm/private, so call it via a helper. +caps = local_probe(); + +% Additional non-rsatoolbox dependencies +caps.ICC = exist('ICC', 'file') == 2; +caps.rangesearch = exist('rangesearch', 'file') == 2; +caps.fitlme = exist('fitlme', 'file') == 2 || exist('fitlme', 'builtin') == 5; + +if quiet, return; end + +% ========================================================================= +% Print report +% ========================================================================= +fprintf('\n=== CanlabCore RSA toolbox status ===\n\n'); + +fprintf('Kriegeskorte rsatoolbox (optional enhancements):\n'); +print_line('rankTransform / RDMcolormap (rank-transformed plots)', caps.rdm_rankTransform || caps.util_RDMcolormap, 'plain imagesc + parula'); +print_line('categoricalRDM (model RDM construction)', caps.rdm_categoricalRDM, 'built-in same-vs-different'); +print_line('covdiag (Ledoit-Wolf whitening)', caps.stat_covdiag, 'built-in ledoit_wolf_shrinkage'); +print_line('MDSConditions (MDS plots)', caps.fig_MDSConditions, 'built-in cmdscale'); +print_line('compareRefRDM2candRDMs (formal RDM inference)', caps.compareRefRDM2candRDMs, 'built-in rsm.compare engine'); +print_line('bootstrapRDMs', caps.bootstrapRDMs, 'built-in within-subject bootstrap'); + +fprintf('\nOther dependencies:\n'); +print_line('ICC.m (File Exchange reliability)', caps.ICC, 'built-in ICC fallback'); +print_line('rangesearch (Statistics Toolbox)', caps.rangesearch, 'O(n^2) loop (slow searchlight)'); +print_line('fitlme (Statistics Toolbox)', caps.fitlme, 'REQUIRED for rsa_lme -- no fallback'); + +if ~caps.fitlme + fprintf('\n WARNING: fitlme not found. The LME methods (rsa_lme, rsa_compare_models,\n'); + fprintf(' rsa_parcelwise ''lme'' path) require the Statistics and Machine Learning Toolbox.\n'); +end + +if caps.any + fprintf('\nrsatoolbox detected: enhanced plotting/inference available.\n'); +else + fprintf('\nrsatoolbox NOT detected: all RSA tools work via built-in fallbacks.\n'); + fprintf(' To enable enhancements: addpath(genpath(''path/to/rsatoolbox''))\n'); +end +fprintf('\n'); + +end + + +function print_line(label, available, fallback) +if available + status = 'FOUND '; + note = ''; +else + status = 'missing'; + note = sprintf(' -> using: %s', fallback); +end +fprintf(' [%s] %-52s%s\n', status, label, note); +end + + +function caps = local_probe() +% Mirror of @rsm/private/probe_rsatoolbox.m (which is class-private and not +% directly callable from here). Kept in sync manually. +caps = struct(); +caps.rdm_rankTransform = ~isempty(which('rsa.rdm.rankTransform')) || ~isempty(which('rankTransform')); +caps.rdm_squareRDM = ~isempty(which('rsa.rdm.squareRDM')) || ~isempty(which('squareRDM')); +caps.rdm_categoricalRDM = ~isempty(which('rsa.rdm.categoricalRDM'))|| ~isempty(which('categoricalRDM')); +caps.util_RDMcolormap = ~isempty(which('rsa.util.RDMcolormap')) || ~isempty(which('RDMcolormap')); +caps.fig_MDSConditions = ~isempty(which('rsa.fig.MDSConditions'))|| ~isempty(which('MDSConditions')); +caps.fig_dendrogramConditions = ~isempty(which('rsa.fig.dendrogramConditions')) || ~isempty(which('dendrogramConditions')); +caps.stat_covdiag = ~isempty(which('rsa.stat.covdiag')) || ~isempty(which('covdiag')); +caps.compareRefRDM2candRDMs = ~isempty(which('rsa.compareRefRDM2candRDMs')) || ~isempty(which('compareRefRDM2candRDMs')); +caps.bootstrapRDMs = ~isempty(which('rsa.bootstrapRDMs')) || ~isempty(which('bootstrapRDMs')); +caps.any = any(structfun(@(x) x, caps)); +end diff --git a/CanlabCore/RSA_tools/signature_specificity_parcelwise.m b/CanlabCore/RSA_tools/signature_specificity_parcelwise.m new file mode 100644 index 00000000..9b4ed9e3 --- /dev/null +++ b/CanlabCore/RSA_tools/signature_specificity_parcelwise.m @@ -0,0 +1,172 @@ +function out = signature_specificity_parcelwise(parcel_svm_cells, subjects, bodysite_names, atlas_obj, varargin) +% signature_specificity_parcelwise Brain-wide cross-subject bodysite specificity. +% +% For each parcel of a parcellation, builds the cross-subject signature RSA from +% that parcel's per-subject x bodysite maps, computes cross-subject consistency +% and same>different specificity with a defensible subject-level test (and +% optional permutation p), then projects the parcel-level summaries onto the +% brain and FDR-corrects the significance map. +% +% :Usage: +% :: +% out = signature_specificity_parcelwise(parcel_svm_cells, subjects, ... +% bodysite_names, atlas_obj) +% out = signature_specificity_parcelwise(..., 'nperm', 2000, 'metric','correlation') +% +% :Inputs: +% **parcel_svm_cells:** nParcels x 1 cell. parcel_svm_cells{pidx} is itself a +% subject x bodysite svm_stats_cell for that parcel (same layout that +% build_crosssubject_signature_rsa consumes). +% **subjects:** cellstr of subject IDs. +% **bodysite_names:** cellstr of bodysite names. +% **atlas_obj:** atlas object whose parcels correspond, in label order, +% to parcel_svm_cells (numel(parcel_svm_cells) == num_regions(atlas_obj)). +% +% :Optional Inputs: +% **'metric':** 'correlation' (default) | 'cosine' | 'dotproduct'. +% **'nperm':** permutations per parcel (default 0 = skip permutation, +% use subject-level t p-values; set e.g. 2000 for perm). +% **'parcel_names':** cellstr labels for the table/maps; default atlas.labels. +% **'doverbose':** progress (default true). +% +% :Outputs: +% **out:** struct with +% .table nParcels-row table: parcel, n_ok, consistency_r, +% specificity_z, t, df, p, p_fdr +% .consistency_map statistic_image: cross-subject same-site consistency (r) +% .specificity_map statistic_image: same-diff specificity (z), p from test +% .specificity_fdr thresholded specificity_map (FDR q<.05, right tail) +% .RSA nParcels x 1 cell of per-parcel RSA structs (for reuse) +% .params +% +% :Examples: +% :: +% atlas = load_atlas('canlab2024'); +% % parcel_svm_cells{p} produced by training one SVM set per parcel. +% out = signature_specificity_parcelwise(parcel_svm_cells, subjects, ... +% bodysite_names, atlas, 'nperm', 2000); +% montage(out.specificity_fdr); +% disp(out.table) +% +% :See also: build_crosssubject_signature_rsa, subject_level_crosssubject_effect, +% permutation_test_site_specificity, assign_vals_to_atlas +% +% .. +% 2026 Michael Sun. GPL v3. +% .. + +p = inputParser; +p.addParameter('metric', 'correlation', @(x) ischar(x) || isstring(x)); +p.addParameter('nperm', 0, @(x) isnumeric(x) && isscalar(x) && x >= 0); +p.addParameter('parcel_names', [], @(x) isempty(x) || iscell(x)); +p.addParameter('doverbose', true, @(x) islogical(x) || isnumeric(x)); +p.parse(varargin{:}); +metric = lower(char(p.Results.metric)); +nperm = round(p.Results.nperm); +doverbose = logical(p.Results.doverbose); + +nParcels = numel(parcel_svm_cells); +if isempty(p.Results.parcel_names) + parcel_names = atlas_obj.labels(:); +else + parcel_names = p.Results.parcel_names(:); +end +if numel(parcel_names) ~= nParcels + error('signature_specificity_parcelwise:nameMismatch', ... + '%d parcels but %d parcel names.', nParcels, numel(parcel_names)); +end + +consistency_r = nan(nParcels,1); +specificity_z = nan(nParcels,1); +tval = nan(nParcels,1); +dfval = nan(nParcels,1); +pval = nan(nParcels,1); +n_ok = zeros(nParcels,1); +RSAc = cell(nParcels,1); + +for pidx = 1:nParcels + if doverbose && mod(pidx, 25) == 0 + fprintf(' parcel %d/%d\n', pidx, nParcels); + end + try + RSA = build_crosssubject_signature_rsa(parcel_svm_cells{pidx}, subjects, ... + bodysite_names, 'metric', metric, 'doverbose', false); + RSAc{pidx} = RSA; + + o = subject_level_crosssubject_effect(RSA, 'doverbose', false); + consistency_r(pidx) = mean(o.same_r, 'omitnan'); + specificity_z(pidx) = o.mean_spec_z; + tval(pidx) = o.t; + dfval(pidx) = o.df; + n_ok(pidx) = sum(isfinite(o.spec_z)); + + if nperm > 0 + pr = permutation_test_site_specificity(RSA, 'nperm', nperm, ... + 'tail', 'right', 'doverbose', false); + pval(pidx) = pr.p; + else + pval(pidx) = o.p; % subject-level t p (right tail) + end + catch ME + if doverbose + fprintf(' parcel %d (%s) skipped: %s\n', pidx, parcel_names{pidx}, ME.message); + end + end +end + +% ---------- FDR across parcels ---------- +p_fdr = nan(nParcels,1); +good = isfinite(pval); +if any(good) + p_fdr(good) = local_fdr_bh(pval(good)); +end + +% ---------- Table ---------- +out = struct(); +out.table = table(parcel_names, n_ok, consistency_r, specificity_z, tval, dfval, pval, p_fdr, ... + 'VariableNames', {'parcel','n_subjects','consistency_r','specificity_z','t','df','p','p_fdr'}); + +% ---------- Brain maps ---------- +% Consistency: descriptive r per parcel (fmri_data is fine, no inference). +out.consistency_map = assign_vals_to_atlas(atlas_obj, [], consistency_r, ... + 'output_type', 'fmri_data', 'dat_descrip', 'cross-subject same-site consistency (r)'); + +% Specificity: statistic_image carrying the per-parcel p so thresholding works. +out.specificity_map = assign_vals_to_atlas(atlas_obj, [], specificity_z, ... + 'p_vals', pval, 'output_type', 'statistic_image', ... + 'dat_descrip', 'same-diff bodysite specificity (z)'); + +% FDR-thresholded version: threshold on the BH-corrected p directly. +% (Use function syntax — statistic_image has a `threshold` property AND method.) +spec_fdr = out.specificity_map; +spec_fdr.p = p_fdr; % swap in BH-corrected p +spec_fdr.p(~isfinite(spec_fdr.p)) = 1; +out.specificity_fdr = threshold(spec_fdr, 0.05, 'unc'); % q<.05 (p already FDR-corrected) + +out.RSA = RSAc; +out.params = struct('metric', metric, 'nperm', nperm); + +if doverbose + nsig = sum(out.table.p_fdr < 0.05); + fprintf('signature_specificity_parcelwise: %d/%d parcels with FDR q<.05 specificity.\n', ... + nsig, nParcels); +end + +end + + +function pcorr = local_fdr_bh(pvec) +% Benjamini-Hochberg FDR-corrected p-values (monotone, capped at 1). +pvec = pvec(:); +n = numel(pvec); +[ps, ord] = sort(pvec); +ranks = (1:n)'; +pc = ps .* n ./ ranks; +% enforce monotonicity from the largest p downward +for i = n-1:-1:1 + pc(i) = min(pc(i), pc(i+1)); +end +pc = min(pc, 1); +pcorr = nan(n,1); +pcorr(ord) = pc; +end diff --git a/CanlabCore/RSA_tools/subject_level_crosssubject_effect.m b/CanlabCore/RSA_tools/subject_level_crosssubject_effect.m new file mode 100644 index 00000000..83a9ed9b --- /dev/null +++ b/CanlabCore/RSA_tools/subject_level_crosssubject_effect.m @@ -0,0 +1,156 @@ +function out = subject_level_crosssubject_effect(RSA, varargin) +% subject_level_crosssubject_effect Defensible subject-level test of cross-subject +% same-site > different-site signature similarity. +% +% THE NON-INDEPENDENCE PROBLEM THIS SOLVES +% The full RSA has nSub*nSites nodes; the cross-subject same-/different-site +% pools contain hundreds of pairwise cells, but each subject's maps appear in +% many cells. Treating those cells as independent (ttest2 on the pooled cells) +% inflates the effective N and the t-statistic dramatically. Here we collapse +% to ONE observation per subject before testing, so the test has df = nSub-1. +% +% METHOD (leave-one-anchor aggregation) +% For each anchor subject a: +% same(a) = mean over all (site b, other-subject) cross-subject pairs of the +% SAME-site similarity, anchored on a; +% diff(a) = mean over all cross-subject DIFFERENT-site pairs anchored on a. +% For the 'correlation' metric these means are taken in Fisher-z space. +% The contrast same(a) - diff(a) is a single number per subject. A paired +% (one-sample on the difference) t-test across the nSub anchors tests +% same > different. Also returns per-anchor, per-site same-site means so two +% ROIs (e.g. dpIns vs S1) can be compared with a paired t-test across subjects. +% +% :Usage: +% :: +% out = subject_level_crosssubject_effect(RSA) +% out = subject_level_crosssubject_effect(RSA, 'tail', 'right') +% +% :Inputs: +% **RSA:** struct from build_crosssubject_signature_rsa. +% +% :Optional Inputs: +% **'tail':** 't-test tail for same>diff: 'right' (default), 'both', 'left'. +% **'doverbose':** print a summary table (default true). +% +% :Outputs: +% **out:** struct with fields +% .same_z, .diff_z nSub x 1 per-anchor means (z-space; raw for non-corr) +% .spec_z nSub x 1 same - diff per anchor (the test variable) +% .same_r, .diff_r per-anchor means mapped back to r-space +% .sitewise_same_z nSub x nSites per-anchor, per-site same-site mean +% (z-space) — use for ROI/site comparisons +% .t, .p, .df, .dz paired-t result on spec_z and Cohen's dz +% .mean_spec_z, .ci_spec_z +% .metric, .tail +% +% :Examples: +% :: +% % same > different within an ROI: +% out = subject_level_crosssubject_effect(RSA_dp_corr); +% +% % dpIns vs S1 cross-subject consistency, subject-level (paired across subjects): +% o_dp = subject_level_crosssubject_effect(RSA_dp_corr); +% o_s1 = subject_level_crosssubject_effect(RSA_s1_corr); +% sub_dp = mean(o_dp.sitewise_same_z, 2, 'omitnan'); % one value/subject +% sub_s1 = mean(o_s1.sitewise_same_z, 2, 'omitnan'); +% [~, p, ~, st] = ttest(sub_dp, sub_s1); % df = nSub-1, NOT inflated +% +% :See also: permutation_test_site_specificity, get_crosssubject_site_effect +% +% .. +% 2026 Michael Sun. GPL v3. +% .. + +p = inputParser; +p.addParameter('tail', 'right', @(x) any(strcmpi(x, {'right','both','left'}))); +p.addParameter('doverbose', true, @(x) islogical(x) || isnumeric(x)); +p.parse(varargin{:}); +tail = lower(p.Results.tail); +doverbose = logical(p.Results.doverbose); + +nSub = RSA.nSub; +nSites = RSA.nSites; +M = RSA.M; +iscorr = strcmp(RSA.metric, 'correlation'); + +% Transform once: work in z-space for correlation, raw otherwise. +if iscorr + Mt = rsa_fisher_z(M); +else + Mt = M; +end + +same_z = nan(nSub,1); +diff_z = nan(nSub,1); +sitewise_same_z = nan(nSub, nSites); + +for a = 1:nSub + is_a = RSA.subject_idx == a; % nodes belonging to anchor subject + colsel = RSA.subject_idx ~= a; % partner nodes from other subjects + + same_vals = []; + diff_vals = []; + + for b = 1:nSites + % anchor node for site b + a_node = is_a & (RSA.site_idx == b); + if ~any(a_node), continue; end + + % same-site partners in other subjects (site b) + same_partners = colsel & (RSA.site_idx == b); + sv = Mt(a_node, same_partners); + sv = sv(isfinite(sv)); + same_vals = [same_vals; sv(:)]; %#ok + sitewise_same_z(a,b) = mean(sv, 'omitnan'); + + % different-site partners in other subjects (site ~= b) + diff_partners = colsel & (RSA.site_idx ~= b); + dv = Mt(a_node, diff_partners); + dv = dv(isfinite(dv)); + diff_vals = [diff_vals; dv(:)]; %#ok + end + + same_z(a) = mean(same_vals, 'omitnan'); + diff_z(a) = mean(diff_vals, 'omitnan'); +end + +spec_z = same_z - diff_z; + +% Paired test = one-sample t on the per-subject difference. +[~, pval, ci, st] = ttest(spec_z, 0, 'tail', tail); +dz = mean(spec_z, 'omitnan') / std(spec_z, 'omitnan'); % Cohen's dz (paired) + +out = struct(); +out.same_z = same_z; +out.diff_z = diff_z; +out.spec_z = spec_z; +out.sitewise_same_z = sitewise_same_z; +out.t = st.tstat; +out.p = pval; +out.df = st.df; +out.dz = dz; +out.mean_spec_z = mean(spec_z, 'omitnan'); +out.ci_spec_z = ci; +out.metric = RSA.metric; +out.tail = tail; + +if iscorr + out.same_r = tanh(same_z); + out.diff_r = tanh(diff_z); +else + out.same_r = same_z; % already raw + out.diff_r = diff_z; +end + +if doverbose + unit = 'z'; if ~iscorr, unit = RSA.metric; end + fprintf('\nSubject-level cross-subject site specificity (metric=%s)\n', RSA.metric); + fprintf(' same-site mean (r): %.4f | different-site mean (r): %.4f\n', ... + mean(out.same_r,'omitnan'), mean(out.diff_r,'omitnan')); + fprintf(' per-subject same-diff (%s): mean=%.4f, 95%% CI [%.4f %.4f]\n', ... + unit, out.mean_spec_z, ci(1), ci(2)); + fprintf(' paired t(%d) = %.2f, p = %.4g (%s), Cohen''s dz = %.2f, N=%d subjects\n', ... + out.df, out.t, out.p, tail, out.dz, nSub); +end + +end diff --git a/CanlabCore/RSA_tools/tests/test_rsa_tools.m b/CanlabCore/RSA_tools/tests/test_rsa_tools.m new file mode 100644 index 00000000..3a0834b8 --- /dev/null +++ b/CanlabCore/RSA_tools/tests/test_rsa_tools.m @@ -0,0 +1,290 @@ +function test_rsa_tools() +% test_rsa_tools Unit test suite for the CanlabCore RSA tools. +% +% Runs a battery of assertions on synthetic data with planted ground truth. +% Each test prints PASS/FAIL. Throws at the end if any test failed. +% +% Usage: +% test_rsa_tools +% +% Covers: rsm construction, compute_rsm metrics, cells/contrasts, +% reliability, drift, LME, parcelwise, searchlight, compare, and soft-dep +% fallbacks. + +fprintf('\n======================================\n'); +fprintf(' CanlabCore RSA tools test suite\n'); +fprintf('======================================\n\n'); + +state = struct('n_pass', 0, 'n_fail', 0, 'failures', {{}}); + +state = run_test(@test_construction, 'rsm construction', state); +state = run_test(@test_metrics, 'compute_rsm metrics', state); +state = run_test(@test_crossnobis, 'crossnobis distance', state); +state = run_test(@test_crossnobis_occurrence, 'crossnobis occurrence folds', state); +state = run_test(@test_cvcorr, 'cross-session cvcorr', state); +state = run_test(@test_recode_reference, 'rsa_recode_reference', state); +state = run_test(@test_cells_contrasts, 'cells + ttest_contrasts', state); +state = run_test(@test_reliability, 'reliability ICC', state); +state = run_test(@test_drift, 'drift', state); +state = run_test(@test_lme, 'rsa_lme', state); +state = run_test(@test_compare_models, 'rsa_compare_models', state); +state = run_test(@test_compare, 'rsm.compare', state); +state = run_test(@test_model_rdms, 'model RDM constructors', state); + +fprintf('\n--------------------------------------\n'); +fprintf(' %d passed, %d failed\n', state.n_pass, state.n_fail); +fprintf('--------------------------------------\n'); +if state.n_fail > 0 + error('test_rsa_tools:failures', 'Failed tests: %s', strjoin(state.failures, ', ')); +end +end + + +% ========================================================================= +function state = run_test(fn, name, state) +try + fn(); + fprintf(' [PASS] %s\n', name); + state.n_pass = state.n_pass + 1; +catch ME + fprintf(' [FAIL] %s -- %s\n', name, ME.message); + state.n_fail = state.n_fail + 1; + state.failures{end+1} = name; +end +end + + +% ========================================================================= +function dat = synth(varargin) +% Small planted dataset (k=12, structure = condition + bodysite). +p = inputParser; +p.addParameter('n_sub', 6); p.addParameter('n_ses', 2); p.addParameter('seed', 1); +p.parse(varargin{:}); o = p.Results; +rng(o.seed); +n_vox = 120; +conds = {'a','b','c'}; bs = {'w','x','y','z'}; +cs = randn(3, n_vox); bsig = randn(4, n_vox); +P = zeros(12, n_vox); r = 0; +for c = 1:3, for b = 1:4, r=r+1; P(r,:) = 0.8*cs(c,:) + 0.4*bsig(b,:); end, end +X = []; sv={}; sev=[]; cv={}; bv={}; i=0; +for s = 1:o.n_sub, for se = 1:o.n_ses, for c = 1:3, for b = 1:4 + i=i+1; X(:,i) = P((c-1)*4+b,:)' + 0.4*randn(n_vox,1); + sv{i,1}=sprintf('s%02d',s); sev(i,1)=se; cv{i,1}=conds{c}; bv{i,1}=bs{b}; +end,end,end,end +dat = fmri_data; dat.dat = X; +dat.metadata_table = table(sv, sev, cv, bv, 'VariableNames', {'sub','sesno','condition','bodysite'}); +end + + +% ========================================================================= +function test_construction() +dat = synth(); +R = compute_rsm(dat, 'group_by', {'condition','bodysite'}, 'subject_var', 'sub', ... + 'metric', 'spearman', 'verbose', false); +assert(isequal(size(R), [12 12 6]), 'expected 12x12x6'); +assert(~isempty(fieldnames(R.groupings)), 'groupings should auto-attach'); +assert(isfield(R.groupings, 'a'), 'condition grouping missing'); +% Block structure: within-condition > between-condition +m = mean(R.dat, 3); +within = mean([m(1,2) m(1,3) m(2,3)]); % a-block off diagonal (3 of the 4 bs) +between = mean([m(1,5) m(1,9)]); % a vs b/c +assert(within > between, 'within-condition should exceed between'); +end + + +% ========================================================================= +function test_metrics() +dat = synth(); +for metric = {'correlation','spearman','cosine','euclidean'} + R = compute_rsm(dat, 'group_by', {'condition','bodysite'}, 'subject_var', 'sub', ... + 'metric', metric{1}, 'verbose', false); + assert(isequal(size(R), [12 12 6]), sprintf('%s size', metric{1})); + is_dis = ismember(metric{1}, {'euclidean'}); + assert(R.is_dissimilarity == is_dis, sprintf('%s is_dissimilarity', metric{1})); +end +end + + +% ========================================================================= +function test_crossnobis() +dat = synth(); +R = compute_rsm(dat, 'group_by', {'condition','bodysite'}, 'subject_var', 'sub', ... + 'metric', 'crossnobis', 'fold_var', 'sesno', 'verbose', false); +assert(R.is_dissimilarity, 'crossnobis is a dissimilarity'); +m = mean(R.dat, 3); +% within-condition distance < between-condition distance +within = mean([m(1,2) m(1,3) m(2,3)]); +between = mean([m(1,5) m(1,9)]); +assert(within < between, 'crossnobis within < between'); +end + + +% ========================================================================= +function test_crossnobis_occurrence() +% Folds defined by occurrence rank should match an explicit fold column. +dat = synth(); +mt = dat.metadata_table; +% Build an explicit occurrence-rank fold column over (sub, condition, bodysite) +key = strcat(string(mt.sub), '|', string(mt.condition), '|', string(mt.bodysite)); +[~,~,cid] = unique(key); mt.fold = zeros(height(mt),1); +for c = unique(cid)', idx = find(cid==c); mt.fold(idx) = 1:numel(idx); end +dat.metadata_table = mt; +R_manual = compute_rsm(dat, 'group_by', {'condition','bodysite'}, 'subject_var','sub', ... + 'metric','crossnobis', 'fold_var','fold', 'verbose', false); +R_auto = compute_rsm(dat, 'group_by', {'condition','bodysite'}, 'subject_var','sub', ... + 'metric','crossnobis', 'fold_var','occurrence', 'verbose', false); +d = max(abs(R_manual.dat(:) - R_auto.dat(:))); +assert(d < 1e-9, sprintf('occurrence vs manual fold diff = %g', d)); +end + + +% ========================================================================= +function test_cvcorr() +% Cross-validated (cross-session) correlation: a SIMILARITY whose diagonal is +% the cross-fold reliability (not 1), and which requires a fold_var. +dat = synth(); +R = compute_rsm(dat, 'group_by', {'condition','bodysite'}, 'subject_var','sub', ... + 'metric','cvcorr', 'fold_var','sesno', 'verbose', false); +assert(~R.is_dissimilarity, 'cvcorr is a similarity, not a dissimilarity'); +m = mean(R.dat, 3, 'omitnan'); +dg = diag(m); +assert(~all(abs(dg(~isnan(dg)) - 1) < 1e-6), 'cvcorr diagonal must be cross-fold reliability, not all 1'); +% fold_var is required +threw = false; +try + compute_rsm(dat, 'group_by', {'condition','bodysite'}, 'subject_var','sub', ... + 'metric','cvcorr', 'verbose', false); +catch + threw = true; +end +assert(threw, 'cvcorr without fold_var should error'); + +% leave-one-fold-out scheme: runs, symmetric, similar structure to allpairs +Rlo = compute_rsm(dat, 'group_by', {'condition','bodysite'}, 'subject_var','sub', ... + 'metric','cvcorr', 'fold_var','sesno', 'cv_scheme','loo', 'verbose', false); +mlo = mean(Rlo.dat, 3, 'omitnan'); +assert(max(abs(mlo - mlo'), [], 'all', 'omitnan') < 1e-9, 'cvcorr loo must be symmetric'); +assert(corr(m(~isnan(m(:))), mlo(~isnan(mlo(:))), 'rows','pairwise') > 0.5, ... + 'cvcorr loo and allpairs should be broadly consistent'); +end + + +% ========================================================================= +function test_recode_reference() +v = {'Left Face','Right Arm','Left Face','Chest','Abdomen'}; +out = rsa_recode_reference(v, 'Left Face', 'other_label', 'Other'); +assert(isequal(out(:)', {'Left Face','Other','Left Face','Other','Other'}), 'recode mismatch'); +% numeric + multi-reference +out2 = rsa_recode_reference([0 1 2 0 1], {'0','1'}); +assert(isequal(out2(:)', {'0','1','Other','0','1'}), 'numeric recode mismatch'); +end + + +% ========================================================================= +function test_cells_contrasts() +dat = synth(); +R = compute_rsm(dat, 'group_by', {'condition','bodysite'}, 'subject_var', 'sub', ... + 'metric', 'spearman', 'verbose', false); +within = R.cells('a', 'a'); +between = R.cells('a', 'b'); +assert(isequal(size(within), [6 1]), 'cells returns per-subject column'); +assert(mean(within) > mean(between), 'within > between cells'); +% ttest_contrasts +T = R.ttest_contrasts({'wa','a',[]; 'avsb','a','b'}, 'tail', 'right', 'correction', 'fdr'); +assert(height(T) == 2, 'two contrasts'); +assert(all(ismember({'Contrast','t','P','FDR_P','sig'}, T.Properties.VariableNames)), 'table cols'); +% correction='none' must not crash (regression test for duplicate P column) +T2 = R.ttest_contrasts({'wa','a',[]}, 'correction', 'none'); +assert(height(T2) == 1, 'none correction works'); +end + + +% ========================================================================= +function test_reliability() +dat = synth('n_ses', 4); +R = compute_rsm(dat, 'group_by', {'condition','bodysite'}, 'level', 'session', ... + 'subject_var', 'sub', 'session_var', 'sesno', 'metric', 'spearman', 'verbose', false); +out = R.reliability('icc_type', '3-k'); +assert(isstruct(out) && isfield(out, 'summary'), 'per-subject struct returned'); +assert(out.summary.mean > 0.3, 'planted reliability should be substantial'); +% replicate pool +icc = R.reliability('pool', 'replicate'); +assert(isscalar(icc), 'replicate pool returns scalar'); +end + + +% ========================================================================= +function test_drift() +dat = synth('n_ses', 5); +R = compute_rsm(dat, 'group_by', {'condition','bodysite'}, 'level', 'session', ... + 'subject_var', 'sub', 'session_var', 'sesno', 'metric', 'spearman', 'verbose', false); +% Single subject +idx = find(strcmp(R.replicate_table.sub, 's01')); +Rs = R; Rs.dat = R.dat(:,:,idx); Rs.replicate_table = R.replicate_table(idx,:); +[d_t, d_fit] = Rs.drift('reference', 'first', 'fit', 'linear'); +assert(height(d_t) > 0, 'drift table populated'); +assert(all(ismember({'condition','slope','P_value'}, d_fit.Properties.VariableNames)), 'fit cols'); +% varargout with fit=none must not error +[d_t2, d_empty] = Rs.drift('reference', 'mean'); +assert(istable(d_empty) && height(d_empty) == 0, 'empty fit table'); +end + + +% ========================================================================= +function test_lme() +dat = synth(); +mdl = dat.rsa_lme('predictors', {'condition','bodysite','sesno'}, 'subject_var', 'sub', ... + 'verbose', false); +assert(isa(mdl, 'LinearMixedModel'), 'returns LME'); +ce = mdl.Coefficients; +bc = ce.Estimate(strcmp(ce.Name, 'SameCondition')); +assert(bc > 0, 'SameCondition beta positive'); +% icc + blups +icc = rsa_lme_icc(mdl); +assert(abs(sum(icc.summary.ICC) - 1) < 1e-6, 'ICC components sum to 1'); +end + + +% ========================================================================= +function test_compare_models() +dat = synth(); +seq = rsa_model_sequence('Y ~ 1 + (1|Sub)'); +seq = seq.add_term('SameCondition'); +seq = seq.add_term('SameBodysite'); +[T, best] = dat.rsa_compare_models(seq.formulas, ... + 'predictors', {'condition','bodysite'}, 'subject_var', 'sub', 'verbose', false); +assert(height(T) == 3, 'three models'); +assert(best == 3, 'fullest model best by AIC'); +assert(all(isfinite(T.lrt_p(2:end))), 'LRT p computed'); +end + + +% ========================================================================= +function test_compare() +dat = synth('n_sub', 9); +R = compute_rsm(dat, 'group_by', {'condition','bodysite'}, 'subject_var', 'sub', ... + 'metric', 'spearman', 'verbose', false); +result = R.compare({'condition','bodysite'}, 'correlation_type', 'kendall_taua', 'verbose', false); +assert(numel(result.candidate_names) == 2, 'two candidates'); +assert(result.r_mean(1) > result.r_mean(2), 'condition (0.8) beats bodysite (0.4)'); +assert(all(result.relatedness_sig), 'both planted models significant'); +assert(result.noise_ceiling(2) >= result.noise_ceiling(1), 'upper >= lower NC'); +end + + +% ========================================================================= +function test_model_rdms() +dat = synth(); +% from_categorical single + multi +M1 = rsm.from_categorical(dat.metadata_table, 'condition'); +assert(numel(M1) == 1 && M1.is_dissimilarity, 'single categorical model'); +M2 = rsm.from_categorical(dat.metadata_table, {'condition','bodysite'}); +assert(numel(M2) == 2, 'two categorical models'); +% from_metadata_distance +Md = rsm.from_metadata_distance(dat.metadata_table, 'sesno', 'metric', 'abs_diff'); +assert(Md.is_dissimilarity, 'distance model is dissimilarity'); +% from_design +X = [1 0 0; 0 1 0; 0 0 1]; +Mx = rsm.from_design(X, 'names', {'p','q','r'}); +assert(numel(Mx) == 3, 'three design models'); +end diff --git a/CanlabCore/RSA_tools/whiten_session_difference.m b/CanlabCore/RSA_tools/whiten_session_difference.m new file mode 100644 index 00000000..e928440f --- /dev/null +++ b/CanlabCore/RSA_tools/whiten_session_difference.m @@ -0,0 +1,39 @@ +function Xw_folds = whiten_session_difference(Xfolds) +% whiten_session_difference Diagonal whitening using session-difference noise. +% +% Generalizes the recipe from `generate_RSA_accept_crossnobis.m`: +% noise = X1 - X2 (per voxel, the residual across the two folds) +% voxel_var = var(noise) (1 x voxels) +% X_f_w = X_f ./ sqrt(voxel_var) +% +% Generalized to k folds: noise estimated as the residual of each fold from +% the across-fold mean, then voxel-wise variance pooled across folds. +% +% Input +% Xfolds {n_folds x 1} cell, each cell is [k x voxels] +% +% Output +% Xw_folds {n_folds x 1} cell, each cell is [k x voxels], whitened + +n_folds = numel(Xfolds); +[~, n_vox] = size(Xfolds{1}); + +% Across-fold mean per condition +M = mean(cat(3, Xfolds{:}), 3, 'omitnan'); % [k x voxels] + +% Pool residuals across folds +resid_stack = zeros(0, n_vox); +for f = 1:n_folds + R = Xfolds{f} - M; + resid_stack = [resid_stack; R]; %#ok +end + +voxel_var = var(resid_stack, 0, 1, 'omitnan'); +voxel_var(~isfinite(voxel_var) | voxel_var <= eps) = 1; + +Xw_folds = cell(n_folds, 1); +for f = 1:n_folds + Xw_folds{f} = Xfolds{f} ./ sqrt(voxel_var); +end + +end diff --git a/CanlabCore/RSA_tools/whiten_within_subject.m b/CanlabCore/RSA_tools/whiten_within_subject.m new file mode 100644 index 00000000..9fb21088 --- /dev/null +++ b/CanlabCore/RSA_tools/whiten_within_subject.m @@ -0,0 +1,64 @@ +function [Xw, info] = whiten_within_subject(X, method) +% whiten_within_subject Apply within-subject whitening to a replicate matrix. +% +% Generalizes the recipe from `10252024 RSA Whitening Walkthrough.mlx`: +% 1) Estimate regularized covariance via covdiag (or stock Ledoit-Wolf fallback). +% 2) Compute the inverse square root of the covariance via SVD. +% 3) Apply: X_whitened = X * sigma^{-1/2}. +% +% Inputs +% X [n x p] data (rows = replicates / observations, cols = features) +% method 'covdiag' (use rsa.stat.covdiag if available) | 'diag' (variance only) | 'none' +% +% Outputs +% Xw [n x p] whitened data +% info struct with .method, .shrinkage, .sigma + +if nargin < 2 || isempty(method), method = 'covdiag'; end +info = struct('method', method, 'shrinkage', [], 'sigma', []); + +switch lower(method) + case 'none' + Xw = X; + return + case 'diag' + v = var(X, 0, 1); + v(~isfinite(v) | v <= eps) = 1; + Xw = X ./ sqrt(v); + info.sigma = diag(v); + info.shrinkage = 1; + return + case 'covdiag' + % Try rsa.stat.covdiag first, then fall back + sigma = []; + shrinkage = []; + try + if ~isempty(which('rsa.stat.covdiag')) + [sigma, shrinkage] = rsa.stat.covdiag(X); + elseif ~isempty(which('covdiag')) + [sigma, shrinkage] = covdiag(X); + end + catch + sigma = []; + end + if isempty(sigma) + [sigma, shrinkage] = ledoit_wolf_shrinkage(X); + end + otherwise + error('whiten_within_subject:badMethod', ... + 'method must be ''covdiag'', ''diag'', or ''none''; got %s.', method); +end + +% Inverse square root via SVD (numerically stable) +[U, S, V] = svd(sigma); +s = diag(S); +% Guard against zero eigenvalues +s(s <= eps) = eps; +sigma_inv_sqrt = U * diag(1 ./ sqrt(s)) * V'; + +Xw = (sigma_inv_sqrt * X')'; + +info.sigma = sigma; +info.shrinkage = shrinkage; + +end