Quick start: from a metadata table to a split_spec in ten minutes

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.

library(splitGraph)

1. A metadata table

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").

2. Build and validate

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 : 4

Validation 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.

3. Choose a constraint mode

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.

4. Emit the split_spec

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_cv

Passing 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          2

The spec is checked before it leaves R:

validate_split_spec(spec)
#> <split_spec_validation>
#>   Valid: TRUE 
#>   Issues: 0

The stratum column is an annotation: it records which outcome level each sample carries so a consumer can stratify. splitGraph never balances folds itself.

5. See which leakage paths the choice closes

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      NA

6. Hand off

Write 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] TRUE

In R, the reference consumer is bioLeak, which turns the spec into an executable, leakage-audited split plan:

bioLeak::as_leaksplits(spec, data = my_frame, outcome = "y")

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:

from splitspec import load_split_spec

spec = load_split_spec("split_spec.json")
spec.groups()       # group per sample, for GroupKFold(groups=...)
spec.strata()       # stratum per sample, for StratifiedGroupKFold(y=...)
spec.order_ranks()  # sort by this before TimeSeriesSplit

Where to go next