--- title: "Cross-language handoff: R to JSON to Python to scikit-learn" author: "Selçuk Korkmaz" date: "`r Sys.Date()`" output: rmarkdown::html_vignette: toc: true vignette: > %\VignetteIndexEntry{Cross-language handoff: R to JSON to Python to scikit-learn} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include=FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", message = FALSE, warning = FALSE ) ``` `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 ```{r derive} 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: ```{r validate} report <- validate_split_spec_json(path) report$valid # The R-side grouping we expect Python to reproduce: grouping_vector(constraint) # 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 spec$sample_data$stratum ``` # 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. ```{r json-shape, eval = requireNamespace("jsonlite", quietly = TRUE)} on_disk <- jsonlite::fromJSON(path, simplifyVector = FALSE) names(on_disk) # One sample row. Every declared role above names a column in here. str(on_disk$sample_data[[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: ```{r pypath} system.file("python", package = "splitGraph") ``` That directory is a complete, installable package — `pip install` it, or just put it on `sys.path`: ```bash pip install "" # reader only pip install "[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: ```python import sys # sys.path.append() 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. ```{r find-python} 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) ``` ```{r conformance, eval = requireNamespace("jsonlite", quietly = 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) } } ``` 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: ```python 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: ```python 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`: ```python 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. ```{r cleanup, include = FALSE} unlink(path) ```