This is an excellent set of guidelines for code hygiene, modularity, and maintainability. It's exactly what's needed to ensure fmrireg
remains robust and developer-friendly as it grows. The "TL;DR for Developers" is a perfect summary.
Let's integrate these principles into a revised, comprehensive proposal and ticketed sprint. The existing "Phase 1, 2, 3" structure will be maintained, but the new ARCH tickets will be prioritized as foundational.
Project: Integrated Robust & AR(p) Modeling with Architectural Refinement (Version 4.0)
Goal: Deliver a robust, efficient, user-friendly, and maintainable implementation of fMRI linear modeling in fmrireg
. This version focuses on integrating Iteratively Reweighted Least Squares (IRLS) with Autoregressive (AR(p)) modeling, underpinned by a modular and clean codebase.
Core Design & Architectural Principles:
extra_nuisance
regressors.phi_hat
).Y
) and design (X
).Y_w
, X_w
).phi_hat
and perform a final weighted GLS.R/fmri_lm_config.R
: Configuration object (fmri_lm_config
) creation and validation.R/fmri_lm_context.R
: GLM context object (glm_context
) definition.R/fmri_lm_solver.R
: Core GLM solver (solve_glm_core
) for OLS/WLS.R/fmri_ar_modeling.R
: AR parameter estimation (estimate_ar_parameters
) and data whitening (ar_whiten_transform
).R/fmri_robust_fitting.R
: IRLS engine (robust_iterative_fitter
) using solve_glm_core
and ar_whiten_transform
.R/fmri_lm_orchestrators.R
: runwise_fitter
and chunkwise_fitter
orchestrating the steps.R/fmrilm.R
: Top-level fmri_lm
and fmri_lm_fit
functions.fmri_lm_config
for options and glm_context
for data transfer between modules.solve_glm_core
.lintr
, styler
, and code size checks.fmri_lm_config
object.API Changes (fmri_lm
):
robust
: c(FALSE, "huber", "bisquare")
. Default FALSE
.robust_options
: A list or dedicated S3 object (e.g., robust_control()
) for k_huber
, c_tukey
, max_iter
, scale_scope
, reestimate_phi
. Default NULL
(uses internal defaults).ar_options
: A list or dedicated S3 object (e.g., ar_control()
) for cor_struct
, cor_iter
(for non-robust), cor_global
, ar_p
, ar1_exact_first
. Default NULL
.extra_nuisance
: NULL
, matrix, or formula.keep_extra_nuisance_in_model
: FALSE
.ar_voxelwise
: FALSE
.robust_psi
, robust_k_huber
, robust_c_tukey
, robust_max_iter
, robust_scale_scope
, cor_struct
, cor_iter
, cor_global
, ar_p
, ar1_exact_first
as top-level args.Immediate "Must Fix" Items (Pre-Sprint):
Ticket MUST-FIX-001R: Remove robust && use_fast_path
Blocker
if (robust && use_fast_path) robust <- FALSE
in fmri_lm()
and fmri_lm_fit()
.Ticket MUST-FIX-002R: Explicit NA
Checks
if (anyNA(Y) || anyNA(X)) stop(...)
in core whitening and robust fitting engines before matrix operations commence on data expected to be NA-free.Ticket MUST-FIX-003R: Correct Pooled/Run-Specific Sigma in Robust Paths
sigma_robust
(scalar per run or global) is correctly calculated by robust_iterative_fitter
(new name for fast_rlm_run
) and correctly used for SE calculation in beta_stats_matrix
and fit_lm_contrasts_fast
. Avoid incorrect averaging when pooling.Ticketed Sprint: Integrated Robust & AR(p) Modeling with Architectural Refinement
Phase 0: Architectural Foundation (Blockers for subsequent work)
Ticket ARCH-001: Implement fmri_lm_config
Object & Factory (implemented)
fmri_lm_control()
factory now lives in R/fmri_lm_config.R
and returns
an fmri_lm_config
object containing validated robust
and ar
option
lists.R/fmri_lm_config.R
. Define fmri_lm_config
S3 class. Implement fmri_lm_control(robust_options = list(...), ar_options = list(...), ...)
factory function that takes all relevant fitting options, applies defaults, validates, and returns an fmri_lm_config
object.robust_options
list to contain type
, k_huber
, c_tukey
, max_iter
, scale_scope
, reestimate_phi
. ar_options
list to contain struct
, p
, iter_gls
, global
, voxelwise
, exact_first
.Ticket ARCH-002: Implement glm_context
Object
R/fmri_lm_context.R
. Define glm_context
S3 class (a list) to hold X
, Y
, proj
(from .fast_preproject
), phi_hat
, sigma_robust_scale
, robust_weights
.Ticket ARCH-003: Create Core Solver solve_glm_core
R/fmri_lm_solver.R
. Implement solve_glm_core(glm_ctx, return_fitted = FALSE)
.glm_ctx$robust_weights
is NULL
, performs OLS/GLS using glm_ctx$X
, glm_ctx$Y
, and glm_ctx$proj
.glm_ctx$robust_weights
is not NULL
, it assumes X
and Y
in context are already weighted (i.e., X_w
, Y_w
). It then performs WLS using glm_ctx$proj
(which should be proj_w
)..fast_lm_matrix()
. Output: list with betas
, rss
, sigma2
, fitted
(if requested).Ticket ARCH-004: Modularize AR Functions
R/fmri_ar_modeling.R
. Move estimate_ar_parameters()
and ar_whiten_transform()
here. Ensure they are robust.Ticket ARCH-005: Implement Robust Engine robust_iterative_fitter
(implemented)
R/fmri_robust_fitting.R
. Implement robust_iterative_fitter(initial_glm_ctx, cfg_robust_options, X_orig_for_resid, sigma_fixed = NULL)
.glm_context
(typically after OLS on original or whitened data). It performs the IRLS loop:X_orig_for_resid
which is the X
corresponding to the Y
in initial_glm_ctx
before robust weighting).sigma_robust_scale
(using sigma_fixed
if provided and cfg_robust_options$scale_scope
is global, else from current residuals).robust_weights
.glm_ctx_weighted
with X_w = X_orig_for_resid * sqrt(robust_weights)
, Y_w = initial_glm_ctx$Y * sqrt(robust_weights)
, and proj_w = .fast_preproject(X_w)
.fit_wls <- solve_glm_core(glm_ctx_weighted)
. Update betas
.betas_robust
, XtWXi_final = proj_w$XtXinv
(from last weighted iteration), sigma_robust_scale_final
, robust_weights_final
, dfres
.Ticket ARCH-006: Refactor fmri_lm
and fmri_lm_fit
for Config Object
fmri_lm
to accept robust_options
, ar_options
, etc. Create cfg <- fmri_lm_control(...)
inside fmri_lm
. fmri_lm_fit
now takes cfg
instead of many individual arguments.Ticket ARCH-007: Lintr/Styler CI + Size Guard
Phase 1: Fast Path OLS/GLS and Robust-Only (No Combined AR+Robust Yet)
Ticket SPRINT3-01R: Fast Path Standard OLS/GLS (Non-Robust)
runwise_lm
and chunkwise_lm
fast paths for cfg$robust$type == FALSE
.runwise_lm
:glm_ctx_run_orig
(X_run
, Y_run
, proj_run = .fast_preproject(X_run)
).cfg$ar$struct != "iid"
:phi_hat_run
(or use phi_global
), whiten X_run
, Y_run
into glm_ctx_run_whitened
. Use solve_glm_core
on this context.solve_glm_core
on glm_ctx_run_orig
.chunkwise_lm
:proj_global_orig = .fast_preproject(X_global_orig)
.cfg$ar$struct != "iid"
: Precompute phi_hat_run
s (or phi_global
). Precompute X_global_w
and proj_global_w = .fast_preproject(X_global_w)
.Y_chunk
using appropriate phi_hat_run
s to get Y_chunk_w
. Call solve_glm_core
with X_global_w
, Y_chunk_w
, proj_global_w
.solve_glm_core
with X_global_orig
, Y_chunk_orig
, proj_global_orig
.Ticket SPRINT3-02R: Fast Path Robust-Only (No AR)
runwise_lm
and chunkwise_lm
fast paths for cfg$robust$type != FALSE
and cfg$ar$struct == "iid"
.runwise_lm
:glm_ctx_run_orig
(X_run
, Y_run
, proj_run
).robust_fit_run <- robust_iterative_fitter(glm_ctx_run_orig, cfg$robust, X_run, sigma_fixed = sigma_global_if_scope_global)
.robust_fit_run
.chunkwise_lm
(using "fully pre-weighted" strategy):r
, create glm_ctx_run_r_orig
. Call robust_iterative_fitter
to get w_robust_run_r
and sigma_robust_run_r
.X_global_robustly_weighted
and Y_global_robustly_weighted
by applying sqrt(w_robust_run_r)
to corresponding run segments of X_global_orig
and Y_global_orig
.proj_global_robustly_weighted <- .fast_preproject(X_global_robustly_weighted)
.Y_global_robustly_weighted
(call it Y_chunk_rw
), use solve_glm_core(list(X=X_global_robustly_weighted, Y=Y_chunk_rw, proj=proj_global_robustly_weighted))
.sigma_robust_run_r
(mapped to voxels) and proj_global_robustly_weighted$dfres
.Phase 2: Combined AR + Robust Fast Paths & Advanced AR
Ticket SPRINT3-03R: Fast Path AR + Robust (runwise_lm
)
runwise_lm
fast path.X_run_orig
, Y_run_orig
to get residuals (consider extra_nuisance
).phi_hat_run
(or use phi_global
if cfg$ar$global
).X_run_w, Y_run_w <- ar_whiten_transform(X_run_orig, Y_run_orig, phi_hat_run, cfg$ar$exact_first)
.proj_run_w <- .fast_preproject(X_run_w)
.glm_ctx_run_whitened <- list(X=X_run_w, Y=Y_run_w, proj=proj_run_w)
.robust_fit_run <- robust_iterative_fitter(glm_ctx_run_whitened, cfg$robust, X_orig_for_resid = X_run_w, sigma_fixed = sigma_global_if_scope_global)
.robust_reestimate_phi="once"
:robust_fit_run$residuals_final_robust_weighted
(using phi_hat_run
).phi_hat_run_updated <- estimate_ar_parameters(...)
.X_run_w2, Y_run_w2 <- ar_whiten_transform(X_run_orig, Y_run_orig, phi_hat_run_updated, ...)
.glm_ctx_final_wls
with X_run_w2 * sqrt(robust_fit_run$weights_final)
, Y_run_w2 * sqrt(robust_fit_run$weights_final)
, and its projection.final_fit <- solve_glm_core(glm_ctx_final_wls)
. Use this for results.runwise_lm
handles AR+Robust via fast path, including robust_reestimate_phi
.Ticket SPRINT3-04R: Fast Path AR + Robust (chunkwise_lm
)
chunkwise_lm
fast path. This is complex.r
:phi_hat_run
(or global) from initial OLS on (X_run_orig, Y_run_full)
(after extra_nuisance
).X_run_w, Y_run_full_w <- ar_whiten_transform(X_run_orig, Y_run_orig_full_run, phi_hat_run, ...)
.proj_run_w <- .fast_preproject(X_run_w)
.robust_fit_details_run <- robust_iterative_fitter(list(X=X_run_w, Y=Y_run_full_w, proj=proj_run_w), cfg$robust, X_orig_for_resid=X_run_w, sigma_fixed=sigma_global_if_scope_global)
.phi_hat_run
, w_robust_run = robust_fit_details_run$weights_final
, sigma_robust_run = robust_fit_details_run$sigma_robust_scale_final
.robust_reestimate_phi="once"
: if so, update phi_hat_run
here based on de-whitened robust residuals of this full run fit).X_global_final_w <- matrix(...)
, Y_global_final_w <- matrix(...)
.phi_hat_run
to get X_seg_w, Y_seg_w
. Then apply sqrt(w_robust_run)
to get X_seg_wr, Y_seg_wr
. Concatenate these.proj_global_final_w <- .fast_preproject(X_global_final_w)
.Y_global_final_w
(call it Y_chunk_fwr
), use solve_glm_core(list(X=X_global_final_w, Y=Y_chunk_fwr, proj=proj_global_final_w))
.sigma_robust_run
(mapped to voxels) and proj_global_final_w$dfres
.robust_reestimate_phi
pattern).chunkwise_lm
AR+Robust fast path operational. This is the most challenging ticket.Ticket SPRINT3-05R: Implement ar_voxelwise
(Slow Path Only)
runwise_lm
(slow path !use_fast_path
).Phase 3: Final Touches, Documentation & Testing
Ticket SPRINT3-06R: Effective Degrees of Freedom & Sandwich Docs
calculate_effective_df
and integrate into reporting as previously described.Ticket SPRINT3-07R: Comprehensive Documentation
fmri_lm
and new internal functions. Explain new config objects (fmri_lm_control
).Ticket SPRINT3-08R: Extensive Testing & CI
fast_path=TRUE
vs FALSE
for AR+Robust.robust_reestimate_phi
and ar_voxelwise
.NA
propagation guards.This revised plan heavily emphasizes the architectural changes first (Phase 0), then builds the fast paths incrementally (Robust-only, then AR-only, then combined AR+Robust). The chunkwise
AR+Robust path remains the most intricate. The API is simplified by grouping options into fmri_lm_control
. The "must-fix" items are critical prerequisites.
Phase 4: Voxelwise AR Contrast Support
Problem Statement: The current voxelwise AR implementation in the slow path (SPRINT3-05R) does not compute contrasts. It returns an empty contrast list with a comment "would need proper handling of contrasts". This is a critical gap that prevents users from performing hypothesis testing when using voxelwise AR modeling.
Technical Challenges: 1. Each voxel has different AR parameters, leading to different whitening transformations 2. The (X'X)^-1 matrix differs for each voxel after whitening 3. Standard errors must account for voxel-specific whitening 4. Memory efficiency is crucial when storing per-voxel covariance matrices
Proposed Solution:
Ticket SPRINT4-01R: Refactor Voxelwise AR to Use Modular Components
lm.fit()
calls with the modular glm_context
and solve_glm_core
approach.glm_ctx_voxel
with X_run
and single-voxel Y
phi_voxel
glm_ctx_voxel_w
after ar_whiten_transform
solve_glm_core
to get betasproj$XtXinv
for contrast calculationsTicket SPRINT4-03R: Choose and Implement Voxelwise Strategy (implemented)
Details:
Aligns with fmrireg's existing chunking philosophy
Alternative: QR Storage
Store QR decompositions instead of covariance matrices
Implementation priorities:
Ticket SPRINT4-02R: Implement Memory-Efficient Voxelwise Contrast Engine
Details: Approach 1 - Store QR Decompositions (Recommended): ```r # For each voxel, store compact QR instead of (X'X)^-1 # QR storage: n×p instead of p×p (typically 3-5x more efficient)
fit_lm_contrasts_voxelwise_qr <- function(betas_matrix, qr_list, sigma_vec, conlist, fconlist, dfres) { n_voxels <- ncol(betas_matrix)
# Process each contrast contrast_results <- list()
for (con_name in names(conlist)) { con_spec <- conlist[[con_name]] colind <- attr(con_spec, "colind")
# Allocate result vectors
est_vec <- numeric(n_voxels)
se_vec <- numeric(n_voxels)
# Batch process voxels with similar QR structures
for (v in seq_len(n_voxels)) {
# Use QR to solve for contrast variance efficiently
qr_v <- qr_list[[v]]
l_full <- numeric(ncol(qr_v$qr))
l_full[colind] <- con_spec
# Efficient computation using QR
# var(l'beta) = sigma^2 * l'(X'X)^-1*l = sigma^2 * ||Q'l||^2
Qtl <- qr.qty(qr_v, l_full)
var_contrast <- sum(Qtl[1:qr_v$rank]^2) * sigma_vec[v]^2
est_vec[v] <- sum(l_full * betas_matrix[, v])
se_vec[v] <- sqrt(var_contrast)
}
# Compute statistics
t_vec <- est_vec / se_vec
p_vec <- 2 * pt(abs(t_vec), dfres, lower.tail = FALSE)
contrast_results[[con_name]] <- create_contrast_tibble(
con_name, est_vec, se_vec, t_vec, p_vec, sigma_vec
)
} return(contrast_results) } ```
Approach 2 - Chunked Processing (Memory-Constrained): ```r
fit_lm_contrasts_voxelwise_chunked <- function(X_run, Y_run, phi_matrix, conlist, fconlist, chunk_size = 100) { n_voxels <- ncol(Y_run) n_chunks <- ceiling(n_voxels / chunk_size)
# Pre-allocate storage for all contrasts contrast_storage <- initialize_contrast_storage(conlist, fconlist, n_voxels)
for (chunk_idx in seq_len(n_chunks)) { voxel_idx <- ((chunk_idx-1)chunk_size + 1):min(chunk_idxchunk_size, n_voxels)
# Process chunk of voxels
for (v_local in seq_along(voxel_idx)) {
v_global <- voxel_idx[v_local]
# Whiten for this voxel
phi_v <- phi_matrix[, v_global]
tmp <- ar_whiten_transform(X_run, Y_run[, v_global, drop=FALSE],
phi_v, exact_first = TRUE)
X_w <- tmp$X
Y_w <- tmp$Y
# Compute what we need for contrasts
qr_w <- qr(X_w)
beta_w <- qr.coef(qr_w, Y_w)
sigma_w <- sqrt(sum(qr.resid(qr_w, Y_w)^2) / (nrow(X_w) - qr_w$rank))
# Calculate all contrasts for this voxel
store_voxel_contrasts(contrast_storage, v_global,
qr_w, beta_w, sigma_w, conlist, fconlist)
}
}
return(format_contrast_results(contrast_storage)) } ```
Memory comparison: - Original: p×p×V matrices = 40×40×100000×8 bytes = 1.28 GB - QR approach: n×p×V = 200×40×100000×8 bytes = 640 MB (2x savings) - Chunked: p×p×chunk_size = 40×40×100×8 bytes = 1.28 MB (1000x savings)
Depends on: SPRINT4-01R
Ticket SPRINT4-04R: Integrate Voxelwise Contrasts into runwise_lm
runwise_lm
to call the new contrast engine.ret$contrasts <- list()
with:
r
conres <- fit_lm_contrasts_voxelwise(
betas_voxelwise,
XtXinv_voxel_list,
sigma_voxelwise,
simple_conlist_weights,
fconlist_weights,
rdf
)
beta_stats_matrix
call to handle voxelwise caseTicket SPRINT4-05R: Add Voxelwise AR + Robust Support
robust_iterative_fitter
with single-voxel contextsTicket SPRINT4-06R: Performance Optimization
Implementation Status: parallel processing of voxels via future.apply
has been integrated in runwise_lm
. Progress reporting now uses
progressr
when parallel execution is enabled. Further vectorisation is
limited by per-voxel AR parameter estimation.
Rcpp Optimization Plan: evaluate the heaviest inner loops (whitening,
robust fitting) using profvis
. Candidate functions will be ported to C++
with Rcpp, starting with the voxelwise AR-whitening step. This will be
prototyped in a separate branch once baseline functionality is validated.
Ticket SPRINT4-07R: Comprehensive Testing
Alternative Approach (if memory is critical):
Instead of storing XtXinv for each voxel, we could: 1. Compute a "reference" XtXinv using average AR parameters 2. Store only the deviation of each voxel's XtXinv from reference 3. Use perturbation theory to approximate voxel-specific standard errors
This would trade some accuracy for substantial memory savings.
Phase 5: Code Modularization and Cleanup
Problem Statement:
The fmrilm.R
file has grown to over 2000 lines with mixed responsibilities, duplicated code, and strategy implementations that are nearly 1000 lines each. This violates the modularity principles and makes the code hard to maintain.
Ticket SPRINT5-01R: Extract Model Utilities Module
R/fmri_model_utils.R
and move model-related utilities.get_formula.fmri_model
, term_matrices.fmri_model
create_fmri_model
(consider renaming to fmri_model_from_formula
)Ticket SPRINT5-02R: Extract Results/Methods Module
R/fmri_lm_methods.R
for all S3 methods and result extraction.coef.fmri_lm
, stats.fmri_lm
, standard_error.fmri_lm
, print.fmri_lm
fitted_hrf.fmri_lm
, pull_stat
, pull_stat_revised
reshape_coef
and other result manipulation helpersTicket SPRINT5-03R: Create Strategy Base Module
R/fmri_lm_strategies.R
with shared strategy code.Details: ```r # Extract common patterns into reusable functions:
process_run_standard <- function(run_data, model, cfg, phi_fixed = NULL) { # Common OLS/GLS logic }
process_run_robust <- function(run_data, model, cfg, phi_fixed = NULL, sigma_fixed = NULL) { # Common robust fitting logic }
process_run_ar_robust <- function(run_data, model, cfg, phi_fixed = NULL, sigma_fixed = NULL) { # Common AR+Robust logic }
pool_run_results <- function(run_results) { # Common pooling logic } ``` * Acceptance: Shared logic extracted, no duplication between strategies
Ticket SPRINT5-04R: Refactor runwise_lm Using Shared Components
runwise_lm
to use the shared strategy components.process_run_*
functions from strategy baserunwise_lm_voxelwise
Ticket SPRINT5-05R: Refactor chunkwise_lm Using Shared Components
chunkwise_lm
to use the shared strategy components.prepare_chunkwise_matrices
process_chunk
Ticket SPRINT5-06R: Create Internal Utilities Module
R/fmri_lm_internal.R
for low-level utilities..fast_preproject
, .fast_lm_matrix
(if still needed)is.formula
and other small helpersTicket SPRINT5-07R: Update Imports and Exports
Ticket SPRINT5-08R: Integration Testing
Expected Outcome:
- fmrilm.R
reduced from 2000+ lines to ~400 lines (just core API)
- Clear module structure:
- fmri_model_utils.R
(~200 lines)
- fmri_lm_methods.R
(~300 lines)
- fmri_lm_strategies.R
(~400 lines)
- fmri_lm_runwise.R
(~300 lines)
- fmri_lm_chunkwise.R
(~400 lines)
- fmri_lm_internal.R
(~150 lines)
- Easier to maintain, test, and extend
- Follows single-responsibility principle
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.