splitGraph turns a sample-level metadata table into a typed
dependency graph, validates it, derives a split
constraint (which samples must stay on the same side of any
train/test split), and emits a tool-agnostic
split_spec that downstream resampling
tools consume. It never creates folds itself.
This is the shortest complete path. Each step links to the vignette that goes deeper.
One row per sample. Only sample_id is required; every
other canonical column is optional, and absent ones are simply
skipped.
meta <- data.frame(
sample_id = paste0("S", 1:8),
subject_id = c("P1", "P1", "P2", "P2", "P3", "P3", "P4", "P4"),
batch_id = c("B1", "B1", "B2", "B2", "B3", "B3", "B3", "B4"),
site_id = c("NYC", "NYC", "NYC", "NYC", "BOS", "BOS", "BOS", "BOS"),
timepoint_id = rep(c("T0", "T1"), 4),
time_index = rep(c(0, 1), 4),
outcome_id = c("case", "case", "ctrl", "ctrl", "case", "case", "ctrl", "ctrl"),
stringsAsFactors = FALSE
)Four subjects, two samples each. Note batch B3: it holds
samples from two different subjects, which matters in step
3.
graph_from_metadata() auto-detects these columns:
| Canonical column | Creates | Available as |
|---|---|---|
sample_id (required) |
Sample nodes |
every mode |
subject_id |
Subject + sample_belongs_to_subject |
mode = "subject", and the basis of
"relatedness" |
batch_id |
Batch + sample_processed_in_batch |
mode = "batch" |
study_id |
Study + sample_from_study |
mode = "study" |
site_id |
Site + sample_collected_at_site |
mode = "site" |
region_id |
Region + sample_located_in_region |
mode = "region" |
platform_id |
Platform + sample_run_on_platform |
mode = "platform" |
assay_id |
Assay + sample_measured_by_assay |
mode = "assay" |
timepoint_id (with time_index) |
Timepoint + sample_collected_at_timepoint
(+ timepoint_precedes) |
mode = "time", and order_rank |
featureset_id |
FeatureSet + sample_uses_featureset |
not a constraint mode; feeds the shared-provenance advisory and the dependency queries |
outcome_id or outcome_value |
Outcome + sample_has_outcome |
the stratum annotation |
Identifier columns may be character, factor, or numeric; they are
coerced to character. If your columns are named differently, map them
with columns =, for example
columns = c(subject_id = "donor", batch_id = "run").
Relations that have no column, such as genetic relatedness or spatial
proximity, are built from their own helpers and added to the graph; see
vignette("modeling-structure").
g <- graph_from_metadata(meta, graph_name = "quick-start")
g
#> <dependency_graph> quick-start
#> Nodes: 22
#> Edges: 41
validate_graph(g)
#> <depgraph_validation_report> quick-start
#> Valid: TRUE
#> Issues: 4
#> By severity:
#> - advisory : 4Validation runs three layers, and each issue carries a severity.
Structural problems (a dangling edge, a duplicate id)
are errors and stop the build. Semantic problems (a
sample assigned to two subjects, a time_index that
contradicts the precedence edges) are errors or warnings.
Leakage findings are warnings or advisories: a subject
appearing in two studies or at two sites is a warning, while repeated
measures of one subject are an advisory. This cohort produces four of
those advisories, one per subject, which is exactly the structure the
split has to respect.
Use levels = and severities = to narrow the
report, and validate_graph(g, error_on_fail = TRUE) to stop
on any error.
Nothing has decided a split yet. The report describes what the data contains.
| Your question | mode |
What ends up in one group |
|---|---|---|
| Same individual measured several times? | "subject" |
all samples of a subject |
| Processing batch, plate, or run effects? | "batch" |
all samples of a batch |
| Several studies or cohorts pooled? | "study" |
all samples of a study |
| Multi-centre collection? | "site" |
all samples of a site |
| Tissue region, sequencing platform, assay? | "region", "platform",
"assay" |
likewise |
| Longitudinal, and train must precede test? | "time" |
samples of a timepoint, plus an order_rank |
| Genetic relatives or spatial neighbours? | "relatedness", "spatial" |
connected components over thresholded edges; see
?pairwise_edges |
| Several of these at once? | "composite" |
strict: one group per connected component over every
relation in via (any of the above, including the pairwise
ones). rule_based: the first relation in
priority that the sample has |
Start with the one relation you are most sure about. Here that is the subject:
subject_constraint <- derive_split_constraints(g, mode = "subject")
subject_constraint
#> <split_constraint> subject
#> Samples: 8
#> Groups: 4
grouping_vector(subject_constraint)
#> S1 S2 S3 S4 S5 S6
#> "subject:P1" "subject:P1" "subject:P2" "subject:P2" "subject:P3" "subject:P3"
#> S7 S8
#> "subject:P4" "subject:P4"grouping_vector() returns the group per sample as a
named character vector, which is the handle most resampling tools want.
The full table, with the reason each sample landed where it did, is one
call away:
head(as.data.frame(subject_constraint)[, c("sample_id", "group_id", "explanation")], 2)
#> sample_id group_id
#> 1 S1 subject:P1
#> 2 S2 subject:P1
#> explanation
#> 1 Grouped by subject through sample_belongs_to_subject -> P1.
#> 2 Grouped by subject through sample_belongs_to_subject -> P1.If more than one relation has to be respected, combine them. Grouping
by subject and batch gives three groups rather than four,
because batch B3 links subjects P3 and
P4, so they cannot be separated without splitting a
batch:
composite_constraint <- derive_split_constraints(
g, mode = "composite", via = c("subject", "batch")
)
grouping_vector(composite_constraint)
#> S1 S2 S3 S4 S5
#> "component_1" "component_1" "component_2" "component_2" "component_3"
#> S6 S7 S8
#> "component_3" "component_3" "component_3"That is the trade-off to watch: each relation you add can only merge
groups, never split them. With a few large batches a strict composite
can collapse the whole cohort into one group, which leaves nothing to
hold out. vignette("faq-design-notes") shows when that
happens and what to do instead.
spec <- as_split_spec(subject_constraint, graph = g)
spec
#> <split_spec> subject
#> Samples: 8
#> Groups: 4
#> Block vars: batch_group, site_group
#> Time var: order_rank
#> Stratum var: stratum
#> Recommended resampling: grouped_cvPassing graph = g enriches the spec with everything the
constraint did not use as the primary grouping, so a downstream tool can
block, order, or stratify without ever touching the graph:
spec$group_var # the split unit
#> [1] "group_id"
spec$block_vars # coarser axes that ideally should not straddle a fold
#> [1] "batch_group" "site_group"
spec$time_var # ordering, when the graph carries one
#> [1] "order_rank"
spec$stratum_var # the outcome level each sample has
#> [1] "stratum"Those names point into one sample-level table:
as.data.frame(spec)[, c("sample_id", "group_id", "batch_group", "site_group",
"stratum", "order_rank")]
#> sample_id group_id batch_group site_group stratum order_rank
#> 1 S1 subject:P1 B1 NYC case 1
#> 2 S2 subject:P1 B1 NYC case 2
#> 3 S3 subject:P2 B2 NYC ctrl 1
#> 4 S4 subject:P2 B2 NYC ctrl 2
#> 5 S5 subject:P3 B3 BOS case 1
#> 6 S6 subject:P3 B3 BOS case 2
#> 7 S7 subject:P4 B3 BOS ctrl 1
#> 8 S8 subject:P4 B4 BOS ctrl 2The spec is checked before it leaves R:
The stratum column is an annotation: it records
which outcome level each sample carries so a consumer can stratify.
splitGraph never balances folds itself.
summarize_leakage_risks() folds the graph validation,
the constraint, and the spec into one object. The severed
column is the useful part: it says whether the mode you picked
structurally eliminates each finding.
risks <- summarize_leakage_risks(g, constraint = subject_constraint, split_spec = spec)
risks
#> <leakage_risk_summary>
#> Overview: Detected 8 structural leakage diagnostics across validation, constraint, and split-spec readiness.
#> Diagnostics: 8
unique(as.data.frame(risks)[, c("category", "severity", "severed")])
#> category severity severed
#> 1 repeated_subject_samples advisory TRUE
#> 5 split_spec_ready advisory NA
#> 6 ordering_available advisory NA
#> 7 blocking_available advisory NAWrite the spec to JSON for another session, another language, or an archive. The format is a versioned contract with a JSON Schema shipped in the package:
path <- tempfile(fileext = ".json")
write_split_spec(spec, path)
validate_split_spec_json(path)$valid
#> [1] TRUE
# Reading with validate = TRUE re-checks the file against the schema and runs
# the preflight validator, instead of trusting whatever is on disk.
back <- read_split_spec(path, validate = TRUE)
identical(back$sample_data$group_id, spec$sample_data$group_id)
#> [1] TRUEIn R, the reference consumer is bioLeak, which turns the spec into an executable, leakage-audited split plan:
The released bioLeak (0.3.8) accepts the subject,
batch, study and time modes. For
the others, including composite, hand it the grouping
directly; the split is identical, only the route differs:
joined <- merge(my_frame, spec$sample_data[, c("sample_id", "group_id")],
by = "sample_id")
bioLeak::make_split_plan(joined, outcome = "y",
mode = "subject_grouped", group = "group_id")In Python, the reader ships with the package and needs only the standard library:
vignette("leakage-aware-workflow") — the same path in
full, with querying, validation overrides, every constraint mode, and
graph editing.vignette("modeling-structure") — site, region,
platform, assay, and the thresholded relatedness and spatial
relations.vignette("cross-language-handoff") — R to JSON to
Python to scikit-learn.vignette("adapter-cookbook") — writing your own
adapter.vignette("case-study-gse60424") — a real public cohort
end to end.vignette("faq-design-notes") — why the design is shaped
this way.