---
title: "Understand spatially constrained clustering"
name: spatially-constrained-clustering
description: See how feature similarity, distance, and connectivity shape a parcel map.
output:
rmarkdown::html_vignette:
toc: yes
toc_depth: 2
css: albers.css
includes:
in_header: albers-header.html
params:
family: red
base_size: 13
content_width: 72
preset: homage
resource_files:
- albers.css
- albers.js
- albers-header.html
- fonts
vignette: |
%\VignetteIndexEntry{Understand spatially constrained clustering}
%\VignetteEngine{knitr::rmarkdown}
%\VignetteEncoding{UTF-8}
---
```{r setup, include=FALSE}
knitr::opts_chunk$set(
collapse = TRUE, comment = "#>", message = FALSE, warning = FALSE,
fig.align = "center", fig.retina = 2, out.width = "100%",
fig.width = 7, fig.asp = 0.55
)
if (requireNamespace("albersdown", quietly = TRUE) &&
requireNamespace("ggplot2", quietly = TRUE)) {
ggplot2::theme_set(albersdown::theme_albers(family = params$family, preset = params$preset))
}
library(neurocluster)
```
```{r albers-classes, echo=FALSE, results='asis'}
cat(sprintf(
paste0(''),
params$family, params$preset, params$content_width
))
```
Ordinary clustering can group distant voxels because their time series look
alike. Spatially constrained clustering adds location or neighbourhood
structure so the output is a set of parcels rather than a scattered label map.
The scientific choice is not “spatial or functional”; it is how the method
defines and balances both forms of evidence.
## What does spatial weight change?
In the unified API, `spatial_weight` lies in `[0, 1]`. Higher values give more
influence to spatial compactness. Each wrapper maps that common scale to a
method-native objective, so equal numeric values do not make different
algorithms mathematically identical.
The example below holds data, K, algorithm, and seed fixed while changing only
G3S’s unified spatial weight. It uses a genuinely 3-D fixture so the spatial
term acts across all three axes.
```{r spatial-data, include=FALSE}
syn <- generate_synthetic_volume(
scenario = "gaussian_blobs", dims = c(16, 16, 8),
n_clusters = 4, n_time = 40, noise_sd = 0.08, seed = 42
)
```
```{r spatial-fits}
spatial_weights <- c(0.2, 0.5, 0.8)
weight_fits <- lapply(spatial_weights, function(weight) {
cluster4d(
syn$vec, syn$mask, n_clusters = 4, method = "g3s",
spatial_weight = weight, connectivity = 26, parallel = FALSE
)
})
```
```{r spatial-checks, include=FALSE}
weight_checks <- lapply(weight_fits, validate_cluster4d,
vec = syn$vec, mask = syn$mask)
weight_metrics <- lapply(weight_fits, cluster_metrics, truth = syn$truth)
sensitivity <- data.frame(
spatial_weight = spatial_weights,
ari_to_known = vapply(weight_metrics, `[[`, numeric(1), "ari_truth"),
spatial_rms_mm = vapply(weight_metrics, `[[`, numeric(1),
"spatial_rms_distance_mm")
)
stopifnot(
identical(syn$dims, c(16, 16, 8)), !anyNA(syn$truth),
length(unique(syn$truth)) == 4L,
all(vapply(weight_checks, `[[`, logical(1), "valid")),
all(vapply(weight_fits, `[[`, integer(1), "actual_k") == 4L),
all(is.finite(as.matrix(sensitivity))),
all(diff(sensitivity$spatial_rms_mm) < 0),
length(unique(round(sensitivity$ari_to_known, 8))) > 1L
)
align_to_known <- function(labels, truth) {
ids <- sort(unique(labels))
truth_ids <- sort(unique(truth))
stopifnot(length(ids) == length(truth_ids))
overlap <- table(factor(labels, levels = ids),
factor(truth, levels = truth_ids))
map <- truth_ids[as.integer(clue::solve_LSAP(overlap, maximum = TRUE))]
aligned <- unname(map[match(labels, ids)])
stopifnot(length(unique(aligned)) == length(ids))
aligned
}
```
```{r spatial-summary, echo=FALSE}
knitr::kable(
transform(sensitivity, ari_to_known = round(ari_to_known, 3),
spatial_rms_mm = round(spatial_rms_mm, 2)),
caption = "Observed G3S sensitivity on this generated 3-D fixture."
)
```
```{r spatial-map, echo=FALSE, fig.cap='The same middle axial slice at low and high G3S spatial weight. A one-to-one maximum-overlap assignment preserves four distinct fitted parcels while aligning their arbitrary IDs to the four known colors.', fig.alt='Two four-color axial parcel maps. Every fitted parcel has a distinct color; the high-weight map has visibly more compact boundaries than the low-weight map.'}
palette_cb <- c("#0072B2", "#D55E00", "#009E73", "#CC79A7")
label_slice <- function(fit, z = 4L) {
aligned <- align_to_known(fit$labels, syn$truth)
array(aligned, syn$dims)[, , z]
}
old_par <- par(mfrow = c(1, 2), mar = c(2.4, 2.4, 2.4, 0.8))
image(label_slice(weight_fits[[1]]), col = palette_cb, zlim = c(0.5, 4.5), axes = FALSE,
main = "spatial_weight = 0.2")
box()
image(label_slice(weight_fits[[3]]), col = palette_cb, zlim = c(0.5, 4.5), axes = FALSE,
main = "spatial_weight = 0.8")
box()
par(old_par)
```
```{r sensitivity-figure, echo=FALSE, fig.cap='Two explicit sensitivity estimands. Larger spatial weight reduces physical RMS dispersion on this fixture, while agreement with the known feature-defined partition also changes. Neither axis alone defines quality.', fig.alt='Two line plots across spatial weights 0.2, 0.5, and 0.8. Spatial RMS distance declines; adjusted Rand agreement also changes.'}
old_par <- par(mfrow = c(1, 2), mar = c(4.5, 4.4, 2, 1))
plot(sensitivity$spatial_weight, sensitivity$spatial_rms_mm,
type = "b", pch = 16, lwd = 2, col = "#0072B2", bty = "l",
xlab = "spatial weight", ylab = "spatial RMS distance (mm)")
plot(sensitivity$spatial_weight, sensitivity$ari_to_known,
type = "b", pch = 17, lwd = 2, col = "#D55E00", bty = "l",
ylim = c(0, 1), xlab = "spatial weight",
ylab = "ARI to known partition")
par(old_par)
```
**What to notice.** Changing the weight moves boundaries even though K is
fixed. On this fixture, higher weight produces lower physical dispersion, but
agreement with the known feature-defined partition also falls. That tradeoff is
measured here rather than inferred from appearance; it is not a universal G3S
trend or evidence that either endpoint is “better.”
## What does connectivity mean?
Connectivity defines which mask voxels are neighbours in methods that expose
it. Six-neighbour connectivity uses shared faces. Larger 3-D neighbourhoods
can also include edge- or corner-touching voxels. Supported values are
method-specific, and `cluster4d()` rejects a value a method cannot use rather
than silently ignoring it.
Connectivity is also a feasibility constraint. If a mask has multiple
disconnected components, no connected clustering can produce fewer parcels
than components without crossing excluded space. A requested K is therefore a
target, not permission to violate topology.
## How should artifacts be diagnosed?
Inspect all three anatomical planes, cluster-size distributions, and boundary
sensitivity across defensible settings. Slice-wise algorithms deserve an
explicit cross-slice check; [Consensus and stitching](consensus-stitch.html)
shows what stitching changes. Continue with
[Choose parameters](choose-parameters.html) for tuning and
[Validate and compare clusterings](validate-compare.html) for defined
comparison estimands.