Beyond the classic subject / batch / study / time relations, splitGraph models several further leakage axes, in two families:
subject or batch.This vignette builds and groups by each, shows how the threshold drives the pairwise grouping, and points out the two ways a structure-aware grouping goes wrong — collapsing into one group, or dissolving into singletons.
graph_from_metadata() auto-detects site_id, region_id, platform_id, and assay_id columns and builds the corresponding typed nodes and edges. Each then has its own constraint mode, and all four behave identically — only the column and the mode name change.
meta <- data.frame(
sample_id = paste0("S", 1:6),
subject_id = c("P1", "P1", "P2", "P2", "P3", "P3"),
site_id = c("NYC", "NYC", "BOS", "BOS", "NYC", "BOS"),
region_id = c("cortex", "cortex", "cortex",
"hippocampus", "hippocampus", "hippocampus"),
platform_id = c("illumina", "illumina", "nanopore",
"nanopore", "illumina", "nanopore"),
assay_id = c("rnaseq", "rnaseq", "rnaseq", "wgs", "wgs", "wgs"),
stringsAsFactors = FALSE
)
g <- graph_from_metadata(meta, graph_name = "structure-demo")
cluster_modes <- c("site", "region", "platform", "assay")
do.call(cbind, lapply(
stats::setNames(cluster_modes, cluster_modes),
function(m) grouping_vector(derive_split_constraints(g, mode = m))
))
#> site region platform assay
#> S1 "site:NYC" "region:cortex" "platform:illumina" "assay:rnaseq"
#> S2 "site:NYC" "region:cortex" "platform:illumina" "assay:rnaseq"
#> S3 "site:BOS" "region:cortex" "platform:nanopore" "assay:rnaseq"
#> S4 "site:BOS" "region:hippocampus" "platform:nanopore" "assay:wgs"
#> S5 "site:NYC" "region:hippocampus" "platform:illumina" "assay:wgs"
#> S6 "site:BOS" "region:hippocampus" "platform:nanopore" "assay:wgs"Each column is a different, equally defensible partition of the same six samples. Choosing between them is a scientific question, not a technical one: which of these axes must a model generalise across?
Site structure also has a validation rule of its own. A subject whose samples were collected at more than one site is flagged, because grouping by site alone would then place one individual on both sides of a split:
report <- validate_graph(g)
report$issues[report$issues$code == "subject_cross_site_overlap",
c("severity", "message")]
#> severity message
#> 4 warning Subject `subject:P3` has samples collected at multiple sites.
# Which constraint modes actually sever that path:
risks <- summarize_leakage_risks(g, constraint = derive_split_constraints(g, "subject"))
as.data.frame(risks)[as.data.frame(risks)$category == "subject_cross_site_overlap",
c("category", "severed")]
#> category severed
#> 4 subject_cross_site_overlap TRUEWhatever mode is primary, every detected cluster relation is also carried into the split_spec as a blocking annotation, so a downstream consumer can block on site, region, platform, or assay even when the split unit is something else — here, subject:
spec <- as_split_spec(derive_split_constraints(g, mode = "subject"), graph = g)
spec$block_vars
#> [1] "site_group" "region_group" "platform_group" "assay_group"
head(spec$sample_data[, c("sample_id", "group_id", "site_group",
"region_group", "platform_group", "assay_group")])
#> sample_id group_id site_group region_group platform_group assay_group
#> 1 S1 subject:P1 NYC cortex illumina rnaseq
#> 2 S2 subject:P1 NYC cortex illumina rnaseq
#> 3 S3 subject:P2 BOS cortex nanopore rnaseq
#> 4 S4 subject:P2 BOS hippocampus nanopore wgs
#> 5 S5 subject:P3 NYC hippocampus illumina wgs
#> 6 S6 subject:P3 BOS hippocampus nanopore wgsAny of these relations can also participate in a composite derivation, where several dependency sources are combined and each connected component becomes one group. Site and platform partition this cohort the same way, so grouping on both at once still leaves two groups:
constraint <- derive_split_constraints(
g, mode = "composite", strategy = "strict",
via = c("Site", "Platform")
)
grouping_vector(constraint)
#> S1 S2 S3 S4 S5
#> "component_1" "component_1" "component_2" "component_2" "component_1"
#> S6
#> "component_2"A strict composite can only ever merge groups, never split them, so every relation you add risks merging everything. Add subject to the same call and the entire cohort becomes one group:
collapsed <- derive_split_constraints(
g, mode = "composite", strategy = "strict",
via = c("Site", "Platform", "Subject")
)
collapsed$metadata$n_groups
#> [1] 1
grouping_vector(collapsed)
#> S1 S2 S3 S4 S5
#> "component_1" "component_1" "component_1" "component_1" "component_1"
#> S6
#> "component_1"The validation report predicted this. subject_cross_site_overlap fired above because subject P3 has one sample at each site; in a composite closure that subject is a bridge, so the NYC and BOS groups fuse and there is nothing left to hold out. One group is not an error — it is an arithmetically correct answer to the question that was asked — and splitGraph does not warn about it, so read metadata$n_groups before you rely on a composite.
Two ways out, both already on this page: leave the bridging relation out of via (the Site + Platform call above), or keep the coarse axis as a blocking annotation rather than the split unit, which is what site_group in the spec is for. vignette("faq-design-notes") works through the trade-off.
Spatial proximity works the same way over sample coordinates — for example spot locations from spatial transcriptomics, positions on a tissue slide, or geographic site coordinates. spatial_edges_from_coords() connects samples within a radius (Euclidean distance over the coordinate columns), and mode = "spatial" groups the resulting connected components. The distance is computed over as many coordinate columns as you give it, so a z column for a tissue volume works exactly like a plain x/y slide.
# Two spatial clusters. Cluster 1 (S1-S3) is a chain: neighbouring pairs are
# within the radius, but the endpoints are not.
coords <- data.frame(
sample_id = paste0("S", 1:6),
x = c(0, 1, 2, 6.0, 6.9, 6.2),
y = c(0, 1, 0, 6.0, 6.6, 5.3),
stringsAsFactors = FALSE
)
adj_edges <- spatial_edges_from_coords(coords, radius = 1.5)
meta_s <- data.frame(
sample_id = paste0("S", 1:6),
subject_id = paste0("P", 1:6),
stringsAsFactors = FALSE
)
samples_s <- create_nodes(meta_s, "Sample", "sample_id")
subjects_s <- create_nodes(meta_s, "Subject", "subject_id")
belongs_s <- create_edges(meta_s, "sample_id", "subject_id",
"Sample", "Subject", "sample_belongs_to_subject")
g_sp <- build_dependency_graph(list(samples_s, subjects_s), list(belongs_s, adj_edges))
sp_groups <- grouping_vector(derive_split_constraints(g_sp, mode = "spatial"))
sp_groups
#> S1 S2 S3
#> "spatial:component_1" "spatial:component_1" "spatial:component_1"
#> S4 S5 S6
#> "spatial:component_2" "spatial:component_2" "spatial:component_2"Plotting the coordinates, drawing the within-radius adjacency edges in grey, and colouring points by the derived group makes the transitive closure concrete: S1–S2 and S2–S3 are each within the 1.5 radius, so all three share a group even though S1 and S3 are 2 units apart and were never linked directly. Every sample in the second cluster is likewise reachable from the others, while the two clusters are far enough apart to stay separate:
sp_grp <- factor(sp_groups[coords$sample_id])
row_of <- setNames(seq_len(nrow(coords)), coords$sample_id)
from_i <- row_of[sub("^sample:", "", adj_edges$data$from)]
to_i <- row_of[sub("^sample:", "", adj_edges$data$to)]
palette_sp <- c("#4C78A8", "#F58518")
plot(coords$x, coords$y, type = "n", asp = 1, xlab = "x", ylab = "y",
main = "Spatial groups (radius = 1.5)")
segments(coords$x[from_i], coords$y[from_i],
coords$x[to_i], coords$y[to_i], col = "grey60", lwd = 2)
points(coords$x, coords$y, pch = 19, cex = 3.5, col = palette_sp[as.integer(sp_grp)])
text(coords$x, coords$y, labels = coords$sample_id, col = "white", cex = 0.8, font = 2)
legend("topleft", legend = levels(sp_grp), pch = 19,
col = palette_sp[seq_along(levels(sp_grp))], title = "Spatial group", bty = "n")One thing to watch in a real coordinate table. When coord_cols is not given, every numeric column except the id is treated as a coordinate — so a QC metric or a slide number sitting in the same frame silently joins the distance calculation and can switch the relation off entirely:
coords_qc <- cbind(coords, reads_millions = c(31, 44, 12, 38, 27, 51))
# `reads_millions` is picked up as a third dimension: nothing is within radius.
nrow(as.data.frame(spatial_edges_from_coords(coords_qc, radius = 1.5)))
#> [1] 0
# Naming the coordinate columns restores the five adjacency edges.
nrow(as.data.frame(
spatial_edges_from_coords(coords_qc, radius = 1.5, coord_cols = c("x", "y"))
))
#> [1] 5Pass coord_cols whenever the frame carries anything numeric that is not a coordinate.
Real splits are derived on a subset of samples — the training rows, say. For pairwise (and composite) modes this raises a subtle question: if a sample that bridges two others is left out of the subset, could those two still inherit a shared group from the full graph? They do not. When you pass samples =, grouping is recomputed within that subset, so structure that exists only through an excluded sample never leaks across the split.
The spatial chain makes this visible. S1 and S3 shared a group only because S2 bridged them; ask for S1 and S3 alone, and they correctly fall into separate groups:
grouping_vector(
derive_split_constraints(g_sp, mode = "spatial", samples = c("S1", "S3"))
)
#> S1 S3
#> "spatial:component_1" "spatial:component_2"A subset is sometimes better expressed as a graph in its own right, for example when you want to validate it, plot it, or hand it around. subset_graph() does that, and it uses the same rule, so the grouping agrees with samples =:
g_two <- subset_graph(g_sp, samples = c("S1", "S3"), graph_name = "S1 + S3")
grouping_vector(derive_split_constraints(g_two, mode = "spatial"))
#> S1 S3
#> "spatial:component_1" "spatial:component_2"Pairwise relations are not a separate world: either of them can be listed in a composite via alongside the direct relations, and all of them feed the same connected-component search. That matters when two different mechanisms each link a different pair of samples, and severing only one would leave the other open.
Here relatedness links two subjects that share no batch, while batch links two samples from unrelated subjects. Neither relation alone separates the cohort correctly; the composite does.
The graphs earlier on this page were assembled node set by node set to keep every piece visible, but a pairwise relation does not require that. Build the direct structure from the metadata frame as usual, then attach the thresholded edges to it with add_edges():
meta_c <- data.frame(
sample_id = paste0("S", 1:4),
subject_id = paste0("P", 1:4),
batch_id = c("B1", "B1", "B2", "B3"),
stringsAsFactors = FALSE
)
kin_c <- data.frame(id1 = "P2", id2 = "P3", kinship = 0.25, stringsAsFactors = FALSE)
g_mixed <- add_edges(
graph_from_metadata(meta_c, graph_name = "mixed-structure"),
relatedness_edges_from_kinship(kin_c, threshold = 0.1)
)
# batch alone: {S1,S2} {S3} {S4} relatedness alone: {S2,S3}
grouping_vector(derive_split_constraints(g_mixed, "batch"))
#> S1 S2 S3 S4
#> "batch:B1" "batch:B1" "batch:B2" "batch:B3"
grouping_vector(derive_split_constraints(g_mixed, "relatedness"))
#> S1 S2 S3
#> "relatedness:component_1" "relatedness:component_2" "relatedness:component_2"
#> S4
#> "relatedness:component_3"
# combined, the chain S1-S2 (batch) - S3 (kinship) becomes one group:
mixed <- derive_split_constraints(g_mixed, "composite", via = c("batch", "relatedness"))
grouping_vector(mixed)
#> S1 S2 S3 S4
#> "component_1" "component_1" "component_1" "component_2"
mixed$metadata$via
#> [1] "Batch" "relatedness"Note what via accepts and where. derive_split_constraints() takes either a lower-case mode or a node type, and pairwise modes are valid there — that is what makes the call above possible. The graph-level views take node types only: detect_dependency_components() and plot(focus = "sample_projection") project samples through Subject, Batch and friends, and reject "relatedness", because a pairwise relation is an edge set rather than a node type to route through. Use the derivation to combine them and grouping_vector() to read the result.
Under strategy = "rule_based" a pairwise source behaves as a fallback instead: a sample takes the first relation in priority that gives it a non-singleton group, so relations never chain.
Whichever route you take, the threshold that produced the edges is recorded on the graph, per relation, and is serialised with it — so a reader of the file can see which cut created the grouping:
g_mixed$metadata$edge_sources$subject_related_to[c("threshold", "metric")]
#> $threshold
#> [1] 0.1
#>
#> $metric
#> [1] "kinship"A spec derived from a single pairwise mode also carries that threshold as a scalar in its own metadata. A composite spec does not, because several relations (each with its own cut) may have contributed; the graph’s edge_sources is the complete record in that case:
as_split_spec(derive_split_constraints(g_mixed, "relatedness"),
graph = g_mixed)$metadata[c("threshold", "threshold_metric")]
#> $threshold
#> [1] 0.1
#>
#> $threshold_metric
#> [1] "kinship"Because the threshold (kinship cutoff, spatial radius) is applied up front in the edge-building helpers, it is a derivation input, not a modeling choice: splitGraph forms groups over whatever edges survive and never computes folds itself. The resulting split_spec is handed to a downstream consumer for execution, exactly as with every other mode — see the adapter-cookbook and cross-language-handoff vignettes for that step.