This vignette walks through a small executable path for temporal,
spatial, and spatiotemporal encoding, then gives optional recipes for
the heavier graph-based families. For a quick introduction, see
vignette("fmrilatent").
We use the same small simulated dataset throughout:
mask <- array(TRUE, dim = c(4, 4, 4))
mask_vol <- neuroim2::LogicalNeuroVol(mask, neuroim2::NeuroSpace(dim(mask)))
set.seed(42)
n_time <- 20
n_vox <- sum(mask)
X <- matrix(rnorm(n_time * n_vox), nrow = n_time)
parcel_map <- as.integer(rep_len(1:4, n_vox))
red <- make_cluster_reduction(mask_vol, parcel_map)Temporal specs project each voxel’s time series onto a shared set of
basis functions. The data matrix X (time x voxels) becomes a basis B
(time x k) and loadings L (voxels x k). When B is orthonormal, the
loadings simplify to L = t(X) %*% B; otherwise encode()
uses the least-squares projection implied by B, equivalent to solving
with B’B. You choose k to control the compression
ratio.
Discrete prolate spheroidal sequences concentrate energy in a frequency band while minimizing spectral leakage. Use them for resting-state data or any analysis focused on a specific frequency range.
spec_slep <- spec_time_slepian(tr = 2, bandwidth = 0.08, k = 4)
lat_slep <- encode(X, spec_slep, mask = mask_vol, materialize = "matrix")
# 4 basis vectors spanning the 0-0.08 Hz band
dim(basis(lat_slep))
#> [1] 20 4
# Reconstruction error
sqrt(mean((as.matrix(lat_slep) - X)^2))
#> [1] 0.8974354The bandwidth parameter (in Hz) and tr (in
seconds) together determine the normalized half-bandwidth W = bandwidth
* tr. The number of well-concentrated tapers is approximately
2 * n_time * W - 1; setting k lower than this
gives heavier compression.
The discrete cosine transform provides orthogonal frequency
components. It is fast and parameter-free beyond choosing k
— a good default when you have no strong prior about the signal
bandwidth.
B-splines produce smooth, localized basis functions with controllable flexibility. They are useful for modeling slow drifts or the hemodynamic response, where you want smooth temporal structure without frequency-domain assumptions.
lat_bs <- encode(X, spec_time_bspline(k = 6, degree = 3), mask = mask_vol,
materialize = "matrix")
dim(basis(lat_bs))
#> [1] 20 6The degree parameter controls smoothness (3 = cubic, the
default). Set orthonormalize = TRUE (the default) for
orthonormal columns, which simplifies downstream linear algebra.
All three temporal encodings follow the same pattern. The trade-off is between spectral precision (Slepian), simplicity (DCT), and localization (B-spline):
rmse <- function(lat) sqrt(mean((as.matrix(lat) - X)^2))
data.frame(
family = c("Slepian (k=4)", "DCT (k=8)", "B-spline (k=6)"),
k = c(ncol(basis(lat_slep)), ncol(basis(lat_dct)), ncol(basis(lat_bs))),
rmse = round(c(rmse(lat_slep), rmse(lat_dct), rmse(lat_bs)), 4)
)
#> family k rmse
#> 1 Slepian (k=4) 4 0.8974
#> 2 DCT (k=8) 8 0.7784
#> 3 B-spline (k=6) 6 0.8239Spatial specs reduce the number of spatial locations by projecting voxels onto a set of spatial atoms. The roles reverse: the loadings L (voxels x k) define the spatial dictionary, and the temporal coefficients become C. With an orthonormal spatial dictionary C = X %*% L; for non-orthonormal dictionaries the encoder uses the corresponding Gram solve.
Graph Slepian bases are eigenvectors of the graph Laplacian, spectrally concentrated on spatial clusters. They are the spatial analog of temporal DPSS sequences.
The k parameter sets the number of components per
cluster; k_neighbors controls the k-NN graph used for
spatial adjacency.
Heat wavelets approximate diffusion on the voxel graph at multiple time scales, capturing both local and global spatial structure.
lat_heat <- encode(X,
spec_space_heat(scales = c(1, 2, 4, 8), order = 30, threshold = 1e-6),
mask = mask_vol
)The scales parameter controls diffusion time scales
(larger = smoother); order sets the Chebyshev polynomial
approximation order; threshold truncates small coefficients
for sparsity.
HRBF places smooth Gaussian atoms at data-driven centers. This family gives you explicit, interpretable spatial basis functions.
lat_hrbf <- encode(X,
spec_space_hrbf(params = list(
sigma0 = 2, levels = 1,
radius_factor = 2.5, kernel_type = "gaussian"
)),
mask = mask_vol
)The sigma0 parameter controls atom width;
levels controls hierarchy depth (more levels = finer
resolution); radius_factor sets atom spacing relative to
sigma.
The wavelet active encoder applies CDF 5/3 lifting wavelets along each spatial axis, touching only in-mask voxels. It is exact (biorthogonal) and very fast — the best choice for speed-critical pipelines.
lat_wav <- encode(X,
spec_space_wavelet_active(levels_space = 2, levels_time = 0, threshold = 0),
mask = mask_vol
)
# Wavelet active returns an ImplicitLatent; reconstruct with predict()
recon_wav <- predict(lat_wav)
dim(recon_wav)Setting threshold > 0 zeroes out small wavelet
coefficients for lossy compression. With threshold = 0 the
transform is exact.
Parcel-local PCA computes per-cluster SVD to build a block-sparse
spatial dictionary. Each parcel contributes k components.
This is a data-driven approach — the loadings are subject-specific.
lat_pca <- encode(X, spec_space_pca(k = 3, center = TRUE), mask = mask_vol,
reduction = red)
# 4 parcels x 3 components = 12 total spatial atoms
dim(loadings(lat_pca))
#> [1] 64 12The center parameter subtracts voxel means before PCA
(stored in offset()); whiten = TRUE rescales
scores to unit variance.
spec_st() combines a temporal and a spatial spec into a
separable representation. Instead of storing the full reconstructed
matrix, it stores a small core tensor and decodes on demand.
lat_st <- encode(X,
spec_st(
time = spec_time_bspline(k = 4, degree = 3),
space = spec_space_hrbf(params = list(
sigma0 = 2, levels = 0, radius_factor = 2.5, seed = 42L
))
),
mask = mask_vol
)
# Full reconstruction
full <- predict(lat_st)
dim(full)
#> [1] 20 64
# Partial: only time points 1-5
partial <- predict(lat_st, time_idx = 1:5)
dim(partial)
#> [1] 5 64
# Partial: only voxels in an ROI
roi <- array(FALSE, dim = c(4, 4, 4))
roi[1:2, 1:2, 1:2] <- TRUE
roi_data <- predict(lat_st, roi_mask = roi)
dim(roi_data)
#> [1] 20 8Partial decoding is the main advantage of separable encoding. You reconstruct a single time point or a region of interest without paying the cost of full reconstruction. The core tensor is typically tiny (k_time x k_space), so storage is minimal regardless of the original data size.
latent_factory() builds the spec and calls
encode() in one step. It is handy for quick
experiments:
lat_f <- latent_factory("time_dct", x = X, mask = mask_vol, k = 8,
materialize = "matrix")
dim(basis(lat_f))
#> [1] 20 8Family strings follow an <axis>_<method>
convention. Available strings and their underlying specs:
| String | Spec |
|---|---|
"time_slepian" |
spec_time_slepian() |
"time_dct" |
spec_time_dct() |
"space_slepian" |
spec_space_slepian() |
"space_pca" |
spec_space_pca() |
"space_parcel" |
spec_space_parcel() (pass template =) |
"space_heat" |
spec_space_heat() |
"space_wavelet_active" |
spec_space_wavelet_active() |
"st_slepian" |
spec_st() with Slepian time + space |
"st_bspline_hrbf" |
spec_st() with B-spline time + HRBF space |
"hierarchical" |
spec_hierarchical_template() |
The older <method>_<axis> names
("dct_time", "slepian_space", …) still work as
aliases, so existing code does not break.
Extra arguments in ... are forwarded to the spec
constructor. The two-step pattern (spec then encode())
gives you more control; the factory is a shortcut when defaults suffice.
Note that B-spline temporal encoding is not in the factory — use
spec_time_bspline() with encode()
directly.
The materialize argument to encode()
controls how bases are stored internally:
"handle" (default): a lazy object that
generates basis columns on demand. Saves memory when the basis is large
or you only need partial reconstruction."matrix": an explicit dense matrix.
Faster for repeated full reconstructions since the basis is
precomputed.# Eager matrix
lat_eager <- encode(X, spec_time_dct(k = 8), mask = mask_vol,
materialize = "matrix")
# Lazy handle (default)
lat_lazy <- encode(X, spec_time_dct(k = 8), mask = mask_vol,
materialize = "handle")
# Same numerical result
all.equal(as.matrix(lat_eager), as.matrix(lat_lazy))
#> [1] TRUEBoth produce identical numerical results. Choose
"handle" when memory matters; choose "matrix"
when you will call as.matrix() or series()
repeatedly.
benchmark_roundtrip() times the encode-decode cycle and
reports reconstruction error across spatial families. It requires the
bench package (listed in Suggests).
res <- benchmark_roundtrip(
mask_dims = c(8, 8, 4),
n_time = 50,
methods = c("slepian_space", "hrbf", "wavelet_active", "bspline_hrbf_st")
)
res
# Visualize (requires ggplot2)
plot_benchmark_roundtrip(res)The output includes median timing (ms) and reconstruction RMSE for each family. Use this to find the best trade-off for your data size and accuracy requirements.
| Family | Stored | Accuracy | Speed |
|---|---|---|---|
| Slepian (space) | basis + loadings | exact on span | fast, sparse loadings |
| PCA (local) | basis + loadings | optimal k-rank per parcel | fast, block-sparse |
| Parcel template (shared) | basis + loadings | projection onto fixed dict | fast, reusable across subjects |
| HRBF | basis + loadings | improves with atom count | moderate, dense atoms |
| Wavelet active | coefficients | exact (biorthogonal 5/3) | very fast, mask-aware |
| Separable (B-spline + HRBF) | core tensor + bases | approximate | fast, separable decode |
| Separable (Slepian) | core tensor + bases | exact on span | fast, sparse spatial |
| Heat wavelet | basis + loadings | approximate (thresholded) | graph build cost, sparse |