Cross-language handoff: R to JSON to Python to scikit-learn

Selçuk Korkmaz

2026-09-17

split_spec is designed as an interchange format, not as internal plumbing for any one downstream package. This vignette shows the full path: derive a constraint in R, serialize it to JSON, read it in Python with the shipped splitspec reference consumer, and hand the recovered grouping straight to a scikit-learn resampler.

The Python chunks below are shown but not executed, so building the vignette needs no Python. To keep the central claim honest rather than asserted, the vignette does run the shipped Python reader through R whenever a working Python 3 is on the PATH (see “Verify the round-trip”), and shows that the grouping, ordering and stratum it recovers match R’s exactly. Where no interpreter is found it says so rather than quietly printing nothing.

Derive and serialize in R

library(splitGraph)

meta <- data.frame(
  sample_id    = c("S1", "S2", "S3", "S4", "S5"),
  subject_id   = c("P1", "P1", "P2", "P3", "P3"),
  timepoint_id = c("T0", "T1", "T0", "T2", "T0"),
  time_index   = c(0, 1, 0, 2, 0),
  outcome_id   = c("case", "case", "ctrl", "ctrl", "ctrl"),
  stringsAsFactors = FALSE
)

g <- graph_from_metadata(meta, graph_name = "handoff-demo")

# Group so that repeated measures of the same subject never straddle a split.
constraint <- derive_split_constraints(g, mode = "subject")
spec <- as_split_spec(constraint, graph = g)

path <- tempfile(fileext = ".json")
write_split_spec(spec, path)

The written file carries a $schema reference and a schema_version, and can be validated against the shipped JSON Schema before it ever leaves R:

report <- validate_split_spec_json(path)
report$valid
#> [1] TRUE

# The R-side grouping we expect Python to reproduce:
grouping_vector(constraint)
#>           S1           S2           S3           S4           S5 
#> "subject:P1" "subject:P1" "subject:P2" "subject:P3" "subject:P3"

# The outcome travels with the spec as a stratum annotation, so a consumer can
# stratify without touching the graph. splitGraph never balances folds itself.
spec$stratum_var
#> [1] "stratum"
spec$sample_data$stratum
#> [1] "case" "case" "ctrl" "ctrl" "ctrl"

What is actually on disk

Before leaving R it is worth looking at the artifact itself, because that file — not any R or Python object — is the contract. It is a single JSON object with a small, flat shape: scalar declarations at the top, then one row per sample.

on_disk <- jsonlite::fromJSON(path, simplifyVector = FALSE)
names(on_disk)
#>  [1] "$schema"                "splitGraph_object"      "schema_version"        
#>  [4] "group_var"              "block_vars"             "time_var"              
#>  [7] "stratum_var"            "ordering_required"      "constraint_mode"       
#> [10] "constraint_strategy"    "recommended_resampling" "metadata"              
#> [13] "sample_data"

# One sample row. Every declared role above names a column in here.
str(on_disk$sample_data[[1]])
#> List of 14
#>  $ sample_id     : chr "S1"
#>  $ sample_node_id: chr "sample:S1"
#>  $ group_id      : chr "subject:P1"
#>  $ primary_group : chr "subject:P1"
#>  $ batch_group   : NULL
#>  $ study_group   : NULL
#>  $ site_group    : NULL
#>  $ region_group  : NULL
#>  $ platform_group: NULL
#>  $ assay_group   : NULL
#>  $ stratum       : chr "case"
#>  $ timepoint_id  : chr "T0"
#>  $ time_index    : int 0
#>  $ order_rank    : int 1

The top-level fields say how to read the rows: group_var names the grouping column, stratum_var the stratum, time_var the ordering, block_vars the blocking columns. A consumer keys on those names rather than hard-coding group_id, which is what lets the same file drive tools that have never heard of each other. Columns that do not apply to this cohort are null, not absent, so the row shape is the same for every sample.

Read in Python

The reference consumer lives in the installed package under inst/python. On the R side its location is:

system.file("python", package = "splitGraph")
#> [1] "C:/Users/Selçuk/AppData/Local/Temp/RtmpMVPZzM/Rbuild59e43b94464/splitGraph/inst/python"

That directory is a complete, installable package — pip install it, or just put it on sys.path:

pip install "<the inst/python path printed above>"            # reader only
pip install "<the inst/python path printed above>[sklearn]"   # + sklearn helpers

The reader itself imports nothing outside the standard library; to_frame() pulls in pandas and the resampler helpers import scikit-learn, both lazily, so a consumer that only needs the grouping pays for neither. Then:

import sys
# sys.path.append(<the inst/python path printed above>)
from splitspec import load_split_spec

spec = load_split_spec("split_spec.json")

spec.schema_version        # "0.3.0"
spec.constraint_mode       # "subject"
spec.recommended_resampling  # "grouped_cv"
spec.stratum_var           # "stratum"
spec.strata()              # ['case', 'case', 'ctrl', 'ctrl', 'ctrl']

# Grouping keyed by sample_id — identical to R's grouping_vector():
spec.grouping()
# {'S1': 'subject:P1', 'S2': 'subject:P1', 'S3': 'subject:P2',
#  'S4': 'subject:P3', 'S5': 'subject:P3'}

df = spec.to_frame()       # pandas DataFrame of sample_data

That is not the whole surface. The reader mirrors every top-level field of the file and adds the accessors a resampler needs:

Attribute From the file
schema_version, constraint_mode, constraint_strategy provenance of the partition
group_var, stratum_var, time_var, block_vars which column plays which role
ordering_required whether the consumer must respect the ordering
recommended_resampling the routine R suggests, as a plain string
metadata the free-form block, including any warnings R recorded
sample_data, sample_ids the rows, in file order
Method Returns
groups() group_var per sample, in file order
strata(column=None) stratum per sample; any other column on request
order_ranks() order_rank per sample, None where undetermined
ordered_index() row indices sorted by order_rank, missing ranks last
grouping() {sample_id: group_id} — the analogue of grouping_vector()
to_frame() sample_data as a pandas DataFrame
group_kfold(), stratified_group_kfold() scikit-learn splitters, wired up

Everything above to_frame() is standard library only.

Versions are part of the contract

The reader checks schema_version and accepts any file whose major version it understands — currently major 0, matching the R side’s policy. A minor bump only ever adds fields, so an older file loads silently with the new fields absent: a spec written before schema 0.3.0 has no stratum, and there spec.stratum_var is None rather than an error. A future major would be refused outright with a ValueError naming the version, instead of being misread.

Going the other way, migrate_split_spec_json() on the R side rewrites an old file at the current version, filling anything added since with null. So a consumer has two options for an aged archive and neither of them is guessing.

Verify the round-trip

Rather than take the comment above on faith, we can run the shipped Python reader on the exact file we just wrote and compare what it recovers to R’s grouping_vector(). This is what inst/python/conformance.py does; the chunk below invokes it through R, so the vignette still builds without Python but says which of the two happened rather than falling silent.

Finding the interpreter takes a little care. A name on the PATH is not proof of a Python: Windows ships python3.exe and python.exe launcher stubs that print “Python not found” and exit non-zero, so Sys.which("python3") can succeed where actually running it does not. The helper below probes each candidate by executing it and keeps the first that reports a Python 3 — the same rule the package’s own conformance test uses.

find_python <- function() {
  for (name in c("python3", "python")) {
    candidate <- Sys.which(name)
    if (!nzchar(candidate)) next
    probe <- tryCatch(
      suppressWarnings(system2(
        candidate, c("-c", shQuote("import sys; print(sys.version_info[0])")),
        stdout = TRUE, stderr = TRUE
      )),
      error = function(e) character()
    )
    if (is.null(attr(probe, "status")) && any(trimws(probe) == "3")) return(candidate)
  }
  ""
}

python <- find_python()
nzchar(python)
#> [1] TRUE
if (!nzchar(python)) {
  cat("No usable Python 3 found; skipping the round-trip check.\n")
} else {
  script   <- system.file("python", "conformance.py", package = "splitGraph")
  out_path <- tempfile(fileext = ".json")

  # Run the Python reader on our JSON file; it writes back what it recovered.
  status <- suppressWarnings(system2(
    python, c("-B", shQuote(script), shQuote(path), shQuote(out_path)),
    stdout = FALSE, stderr = FALSE
  ))

  if (!identical(status, 0L) || !file.exists(out_path)) {
    cat("The Python reader could not be run (exit status ", status, ").\n", sep = "")
  } else {
    recovered  <- jsonlite::fromJSON(out_path)
    r_grouping <- grouping_vector(constraint)
    ids        <- spec$sample_data$sample_id

    # Grouping recovered by Python:
    print(unlist(recovered$grouping))

    # Identical to the grouping R produced?
    cat("grouping matches:",
        identical(unlist(recovered$grouping)[names(r_grouping)],
                  r_grouping[names(r_grouping)]), "\n")

    # The script returns the ordering and the stratum annotation too.
    cat("order_rank matches:",
        identical(as.integer(unlist(recovered$order_ranks)[ids]),
                  as.integer(spec$sample_data$order_rank)), "\n")
    cat("stratum matches:",
        identical(unname(unlist(recovered$strata)[ids]),
                  spec$sample_data$stratum), "\n")
    unlink(out_path)
  }
}
#>           S1           S2           S3           S4           S5 
#> "subject:P1" "subject:P1" "subject:P2" "subject:P3" "subject:P3" 
#> grouping matches: TRUE 
#> order_rank matches: TRUE 
#> stratum matches: TRUE

The package’s test suite runs exactly this comparison as an automated conformance test (test-python-conformance.R, skipped when Python is absent and never on CRAN), so the two implementations cannot drift apart unnoticed between releases. The point is that the partition is decided once in R and only reproduced elsewhere — the two languages cannot disagree.

Drive scikit-learn

The grouping vector plugs directly into GroupKFold (or StratifiedGroupKFold), guaranteeing that all samples from a subject land in the same fold:

import numpy as np
from sklearn.model_selection import GroupKFold

groups = spec.groups()          # group_id per sample, in file order
X = np.zeros((len(groups), 1))  # placeholder design matrix

for train_idx, test_idx in GroupKFold(n_splits=3).split(X, groups=groups):
    train_groups = {groups[i] for i in train_idx}
    test_groups  = {groups[i] for i in test_idx}
    assert train_groups.isdisjoint(test_groups)  # no subject leaks across

# Same thing, with the reader building the placeholder X for you:
for train_idx, test_idx in spec.group_kfold(n_splits=3):
    ...

To keep the outcome balanced across folds as well as keeping subjects together, use the stratum annotation. The reader has a helper that wires both in, so the caller never has to line up the two vectors by hand:

from sklearn.model_selection import StratifiedGroupKFold

# Explicit form: grouping and stratum come from the same spec, in file order.
for train_idx, test_idx in StratifiedGroupKFold(n_splits=2).split(
        X, y=spec.strata(), groups=spec.groups()):
    ...

# Equivalent one-liner:
for train_idx, test_idx in spec.stratified_group_kfold(n_splits=2):
    ...

strata() returns the stratum column by default; pass a column name to stratify on something else, such as a blocking variable. It never raises — when the spec carries no stratum it returns None for every sample, which is what a file written before schema 0.3.0 looks like (spec.stratum_var is None there). The check happens one level up: stratified_group_kfold() raises ValueError rather than handing scikit-learn a y full of None.

For an ordered evaluation (a mode = "time" spec), sort by order_rank first and use TimeSeriesSplit:

from sklearn.model_selection import TimeSeriesSplit

order = spec.ordered_index()    # row indices sorted by order_rank
df_ordered = spec.to_frame().iloc[order].reset_index(drop=True)

for train_idx, test_idx in TimeSeriesSplit(n_splits=3).split(df_ordered):
    ...

Why this matters

The leakage-aware partition is decided once, in R, from explicit and validated dependency structure — and every other language merely reproduces it from the split_spec. Nothing about the split logic is re-implemented in Python, so the two sides cannot drift. split_spec is the contract; scikit-learn (here) and rsample (on the R side) are just interchangeable consumers of it. That is what makes it an interchange format rather than internal plumbing for any one tool.