The columns of an fMRI array are voxels, or vertices, or parcels, or latent components. A frame’s feature space says which. It names every column with a stable ID and knows how to turn a row of values back into a spatial object. This vignette builds the space types the package ships with and shows how views restrict them. It explains what spatial identity means, then moves a frame from one space to another through an explicit, lazy map.
A masked volume
volume_space() describes a packed volume: a 3-D grid, a
voxel-to-world affine, and a support, the linear indices of the
voxels that are in play. Feature IDs are derived from the support, so
they survive masking and reordering unchanged.
grid <- c(4L, 4L, 2L)
mask <- array(FALSE, grid)
mask[2:3, 2:3, ] <- TRUE
brain <- volume_space(dim = grid, affine = diag(4), support = mask, template = "toy")
n_features(brain)
#> [1] 8
feature_ids(brain)
#> [1] "voxel-6" "voxel-7" "voxel-10" "voxel-11" "voxel-22" "voxel-23" "voxel-26"
#> [8] "voxel-27"
head(feature_data(brain), 3)
#> # A tibble: 3 × 5
#> .feature_id .linear_index i j k
#> <chr> <int> <int> <int> <int>
#> 1 voxel-6 6 2 2 1
#> 2 voxel-7 7 3 2 1
#> 3 voxel-10 10 2 3 1The feature table gives each voxel its linear index and grid
coordinates. Predicates in select_features() can use any of
these columns.
The frame used for the rest of the vignette has six observations over this masked volume.
set.seed(2)
n_obs <- 6L
signal <- matrix(
round(rnorm(n_obs * n_features(brain)), 2),
nrow = n_obs, ncol = n_features(brain)
)
frame <- fmri_frame(
assays = list(bold = signal),
observations = data.frame(
.obs_id = sprintf("vol-%02d", seq_len(n_obs)),
run_id = "run-1",
TR = 2
),
space = brain
)
frame
#> <fmri_frame> 6 observations x 8 features
#> assays: bold
#> active: bold
#> space: volume_space af6c4ea8564dViews restrict the space
A view’s feature space is the base space restricted to the selected features, in the selected order. Restriction is an operation on the space itself, not on a copy of its table. The restricted space still knows its grid, its affine, and where its voxels sit.
pair <- frame[, c("voxel-11", "voxel-6")]
space(pair)$support
#> [1] 11 6
feature_ids(pair)
#> [1] "voxel-11" "voxel-6"
same_space(space(pair), restrict_space(brain, c(4L, 1L)))$same
#> [1] TRUERestricting to nothing is legal and gives a space with zero features,
which is what an empty select_features() returns.
n_features(restrict_space(brain, integer()))
#> [1] 0A row of the frame can always be reconstructed into the native
spatial object. For a volume that is a neuroim2::NeuroVol
with NA outside the support.
map <- spatial_map(frame, "vol-01")
dim(map)
#> [1] 4 4 2
sum(!is.na(map))
#> [1] 8The same works from a view, where the unselected voxels are also
NA.
sum(!is.na(spatial_map(pair, "vol-01")))
#> [1] 2Same space, not merely same size
same_space() reports exact semantic identity: the two
spaces must agree on class, digest, and ordered feature IDs.
compatible_space() is an alias for the same check, kept for
the 1.0 transition. Neither word means a map exists.
An independently constructed space with the same grid, affine, support, and template is the same space.
again <- volume_space(dim = grid, affine = diag(4), support = mask, template = "toy")
same_space(brain, again)$same
#> [1] TRUEChange the affine and the voxels have the same IDs but sit somewhere else in the world. The IDs still agree; the digest does not; the spaces are different.
rescaled <- volume_space(
dim = grid, affine = diag(c(2, 2, 2, 1)), support = mask, template = "toy"
)
report <- same_space(brain, rescaled)
report[c("same", "same_class", "same_digest", "same_feature_ids")]
#> $same
#> [1] FALSE
#>
#> $same_class
#> [1] TRUE
#>
#> $same_digest
#> [1] FALSE
#>
#> $same_feature_ids
#> [1] TRUEassert_compatible_space() turns that report into a
structured error, and it is what bind_observations() and
map_features() call before doing anything else.
assert_compatible_space(brain, rescaled)
#> Error:
#> ! Feature spaces differ in type, digest, or IDs.Parcels over a volume
A parcel_space() is linked to a parent space through a
membership matrix. It has one row per parent feature and one column per
parcel, holding non-negative weights. Here the two slices of the masked
volume become two parcels. The atlas identity is part of every parcel’s
feature ID.
slice <- feature_data(brain)$k
membership <- sparseMatrix(
i = seq_len(n_features(brain)), j = slice, x = 1,
dims = c(n_features(brain), 2L)
)
slabs <- parcel_space(
parent = brain,
parcel_ids = c("lower", "upper"),
membership = membership,
atlas = "slab",
data = data.frame(id = c("lower", "upper"), label = c("Lower slab", "Upper slab"))
)
feature_ids(slabs)
#> [1] "slab:lower" "slab:upper"
parcel_aggregation(slabs)
#> 2 x 8 sparse Matrix of class "dgCMatrix"
#>
#> [1,] 0.25 0.25 0.25 0.25 . . . .
#> [2,] . . . . 0.25 0.25 0.25 0.25The aggregation operator is derived from the membership: weighted
means by default, weighted sums on request.
parcel_space_from_atlas() builds the same object from a
neuroatlas atlas aligned to a parent space.
Moving a frame between spaces
A parcel space owns the canonical map from its parent, so
map_features() needs only the target. The result is a new
frame in the parcel space. Its assay is a lazy transformed
source: nothing is aggregated until it is read.
by_parcel <- map_features(frame, target = slabs)
by_parcel
#> <fmri_frame> 6 observations x 2 features
#> assays: bold
#> active: bold
#> space: parcel_space 789147486788
collect_assay(by_parcel)
#> [,1] [,2]
#> [1,] 0.1075 -0.4575
#> [2,] -0.1675 -0.2825
#> [3,] 1.8600 0.4050
#> [4,] -1.1950 0.2150
#> [5,] 0.7025 -0.1700
#> [6,] 0.7750 -0.5900Those are the per-slice means of signal, checked here
against a direct computation.
direct <- cbind(rowMeans(signal[, slice == 1]), rowMeans(signal[, slice == 2]))
all.equal(collect_assay(by_parcel), direct)
#> [1] TRUEThe transformation is recorded in the new frame’s provenance, and a parcel frame reconstructs back into the parent volume by spreading each parcel value over its member voxels.
by_parcel$provenance
#> <provenance_graph> 1 records
#> tips: af035af7e5cacad5e1914b22ffac34438055155ecee144b462e5db798b26967e
range(spatial_map(by_parcel, "vol-01"), na.rm = TRUE)
#> [1] -0.4575 0.1075Any other linear transformation is an explicit
feature_map(): a target-by-source operator plus both
spaces. The operator’s dimensions are checked against both. The source
space must be the frame’s space exactly.
pairs <- index_space(2L, ids = c("pair-a", "pair-b"))
operator <- sparseMatrix(
i = c(1, 1, 2, 2), j = c(1, 2, 3, 4), x = 0.5,
dims = c(2L, n_features(brain))
)
pair_means <- feature_map(from = brain, to = pairs, operator = operator, map_type = "pair_mean")
pair_means
#> <feature_map> 8 source features -> 2 target features
#> type: pair_mean
#> digest: d487b5cd0a11
collect_assay(map_features(frame, map = pair_means))[1:2, ]
#> [,1] [,2]
#> [1,] -0.095 0.310
#> [2,] -0.030 -0.305A map declared from a different space is refused even though the operator would fit.
wrong <- feature_map(from = rescaled, to = pairs, operator = operator)
map_features(frame, map = wrong)
#> Error:
#> ! Feature spaces differ in type, digest, or IDs.Bases and synthesis-only bases
A basis_space() is a representational axis over a parent
space. It holds an encoder that projects parent values onto components,
a decoder that synthesizes parent values from component scores, or both.
When you hold only a decoder, basis_space_from_decoder()
derives the exact least-squares encoder and validates that it is a left
inverse.
decoder <- matrix(0, n_features(brain), 3L)
decoder[1:4, 1L] <- 1
decoder[5:8, 2L] <- 1
decoder[, 3L] <- seq_len(n_features(brain)) / 8
components <- basis_space_from_decoder(
parent = brain,
component_ids = c("c1", "c2", "c3"),
decoder = decoder,
basis_type = "toy_basis"
)
basis_projection_info(components)[c("left_inverse_validated", "left_inverse_error")]
#> $left_inverse_validated
#> [1] TRUE
#>
#> $left_inverse_error
#> [1] 5.551115e-16
dim(basis_analysis(components))
#> [1] 3 8With an encoder available, map_features() projects the
frame onto the components lazily, and spatial_map()
synthesizes a volume back from scores.
scores <- map_features(frame, target = components)
round(collect_assay(scores)[1:2, ], 3)
#> [,1] [,2] [,3]
#> [1,] 0.153 -0.340 -0.144
#> [2,] -1.640 -4.111 4.712
range(spatial_map(scores, "vol-01"), na.rm = TRUE)
#> [1] -0.4845 0.1345Fitted dictionaries from ICA or dictionary learning are often
rank-deficient, so no exact left inverse exists. Ask for
encoder = "none" and you get a synthesis-only basis. It can
reconstruct parent values from scores but has no canonical map from the
parent. A frame of scores must therefore be constructed directly in that
space.
synthesis_only <- basis_space_from_decoder(
parent = brain,
component_ids = c("c1", "c2", "c3"),
decoder = decoder,
encoder = "none"
)
is.null(basis_analysis(synthesis_only))
#> [1] TRUE
basis_projection_info(synthesis_only)$left_inverse_validated
#> [1] FALSE
score_frame <- fmri_frame(
assays = list(scores = matrix(round(rnorm(n_obs * 3), 2), n_obs, 3L)),
observations = observations(frame),
space = synthesis_only
)
range(spatial_map(score_frame, "vol-01"), na.rm = TRUE)
#> [1] -2.84000 -0.40375Because the encoder is absent, vectorize_space() on this
basis raises an error that says so, and
map_features(frame, target = synthesis_only) has no
operator to build a map from.
Composite spaces
A composite_space() concatenates named child spaces into
one feature axis, for example left cortex, right cortex, and subcortical
volume. Feature IDs are part-qualified and the routing order is
authoritative. Each child stays the authority for its own identity and
reconstruction.
mixed_space <- composite_space(
list(cortex = slabs, latent = components),
composite_type = "toy_composite"
)
feature_ids(mixed_space)
#> [1] "cortex::slab:lower" "cortex::slab:upper" "latent::c1"
#> [4] "latent::c2" "latent::c3"
feature_data(mixed_space)[, c(".feature_id", ".part", ".part_index")]
#> # A tibble: 5 × 3
#> .feature_id .part .part_index
#> <chr> <chr> <int>
#> 1 cortex::slab:lower cortex 1
#> 2 cortex::slab:upper cortex 2
#> 3 latent::c1 latent 1
#> 4 latent::c2 latent 2
#> 5 latent::c3 latent 3A frame over a composite space is selected and reconstructed like any other. Selecting one part yields a composite space that holds only that part, and a spatial map returns one native object per part.
mixed <- fmri_frame(
assays = list(mixed = cbind(collect_assay(by_parcel), collect_assay(scores))),
observations = observations(frame),
space = mixed_space
)
latent_only <- select_features(mixed, .part == "latent")
feature_ids(latent_only)
#> [1] "latent::c1" "latent::c2" "latent::c3"
composite_part_names(space(latent_only))
#> [1] "latent"
parts <- spatial_map(mixed, "vol-01")
names(parts$parts)
#> [1] "cortex" "latent"Where the package stops
fmridataset owns spatial identity, restriction, the
linear map descriptor, and lazy application of that map. It does not fit
bases, choose atlases, or run models. neuroatlas supplies
parcellations. fmrilatent supplies fitted bases through
basis_space_from_fmrilatent(). neurosurf
supplies surface geometry for
surface_space_from_neurosurf(). Each hands the package a
serializable space; the package hands back frames whose columns still
mean what they meant.