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 =:
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:
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.