cohort provides one
R6 class, CohortPipeline. It builds analytic cohorts with
full provenance. Cohort construction stays strictly upstream of
analysis. The pipeline produces analytic data tables, then hands them to
whatever consumes them downstream.
We start with a small simulated dataset.
library(cohort)
#> cohort 2026.8.21
#> https://www.rwhite.no/cohort/
library(data.table)
#>
#> Attaching package: 'data.table'
#> The following object is masked from 'package:base':
#>
#> %notin%
set.seed(1)
d <- data.table(
id = 1:200,
age = sample(c(NA, 16:80), 200, replace = TRUE),
sex = sample(c("F", "M", NA), 200, replace = TRUE,
prob = c(0.48, 0.48, 0.04)),
grp = sample(c("a", "b"), 200, replace = TRUE)
)cp <- CohortPipeline$new(
d,
cache_file = file.path(tempdir(), "cohort_cache.rds"),
label = "Eligible patients"
)
on.exit(cp$save(), add = TRUE)CohortPipeline$new() makes a defensive copy of
d once. The pipeline never mutates your data table,
whatever operations you run on it.
The cache_file argument enables incremental
re-execution. On the first run the pipeline writes a snapshot to disk.
On later runs it restores the snapshot, and matching operations replay
instantly with no recomputation. The label argument is the
cohort’s display label in CONSORT diagrams, and defaults to
"Cohort participants". The identifier "root"
is universal, and code always uses it.
Every exclusion takes a human-readable reason and an R expression as a string. The pipeline parses the string and evaluates it against the rows currently included on the branch.
cp$exclude_and_track("root", "Missing sex", "is.na(sex)")
cp$exclude_and_track("root", "Missing age", "is.na(age)")
cp$exclude_and_track("root", "Under 18", "age < 18")The pipeline treats NA predicate results as
FALSE and keeps those rows. You therefore do not need a
defensive !is.na(...) clause for every column.
cp$new_cohort("adults_female", from = "root", label = "Adult females")
cp$exclude_and_track("adults_female", "Not female", "sex != 'F'")
cp$new_cohort("adults_grp_a", from = "root",
label = "Adults, group A")
cp$exclude_and_track("adults_grp_a", "Group not a", "grp != 'a'")Your code references identifiers ("adults_female",
"adults_grp_a"). Figures show labels. Call
new_cohort() again with a different label. The pipeline
updates the label silently and does not invalidate the cache.
A branch starts identical to its parent at the moment of branching. Sibling branches evolve independently.
A cohort becomes frozen the first time either:
After a cohort freezes, $exclude_and_track() on it
errors. The rule guarantees that a cohort’s name maps to one definition
forever. Once children depend on a cohort, its exclusion list is fixed.
The practical workflow is: apply all exclusions on a cohort, then branch
from it or attach artifacts. Multi-way forks are unaffected. You can
branch a frozen cohort as many times as you like.
set_artifact() is for reusable per-cohort objects
(analytic-ready data tables, summary statistics, baseline tables). Each
callback receives a copy of the included rows. It also receives the
named list of artifacts already attached to the cohort.
cp$set_artifact("dt_for_analysis",
from = "adults_female",
fn = function(dt, sib) {
dt[, age_group := cut(age,
breaks = c(18, 30, 50, Inf),
right = FALSE,
labels = c("18-29", "30-49", "50+"))]
dt
}
)Each set_artifact() callback receives a fresh
copy of the cohort’s included rows. It does not see changes that
previous artifacts made. To chain derivations, read the previous
artifact from the sib argument:
print(cp)
#> <CohortPipeline>
#> root: loaded = 200, included = 181, excluded = 19, 3 exclusion step(s)
#> adults_female: branched from root at n = 181, own excluded = 93, included = 88, 1 own step(s)
#> $ dt_for_analysis
#> $ baseline_table
#> adults_grp_a: branched from root at n = 181, own excluded = 93, included = 88, 1 own step(s)
cp$list_cohorts()
#> name parent n_total n_included n_excluded n_own_steps n_artifacts
#> <char> <char> <int> <int> <int> <int> <int>
#> 1: root <NA> 200 181 19 3 0
#> 2: adults_female root 200 88 112 1 2
#> 3: adults_grp_a root 200 88 112 1 0
#> frozen
#> <lgcl>
#> 1: TRUE
#> 2: TRUE
#> 3: FALSE
cp$consort()
#> branch parent step reason expr_str n_excluded n_remaining
#> <char> <char> <int> <char> <char> <int> <int>
#> 1: root <NA> 1 Missing sex is.na(sex) 8 192
#> 2: root <NA> 2 Missing age is.na(age) 5 187
#> 3: root <NA> 3 Under 18 age < 18 6 181
#> 4: adults_female root 4 Not female sex != 'F' 93 88
#> 5: adults_grp_a root 4 Group not a grp != 'a' 93 88$consort() returns a long-form table — one row per
exclusion step, across every branch. Each branch contributes only its
own steps. A step that a branch inherits from its parent appears under
the parent.
Cached artifacts are plain R objects. Read them with
$get_artifact(), then pass them to whatever consumes the
analytic data.
analytic_dt <- cp$get_artifact("adults_female", "dt_for_analysis")
baseline <- cp$get_artifact("adults_female", "baseline_table")
head(analytic_dt)
#> id age sex grp age_group
#> <int> <int> <char> <char> <fctr>
#> 1: 3 48 F b 30-49
#> 2: 5 28 F a 18-29
#> 3: 6 73 F b 50+
#> 4: 9 68 F a 50+
#> 5: 10 21 F b 18-29
#> 6: 14 58 F a 50+
baseline
#> age_group N mean_age
#> <fctr> <int> <num>
#> 1: 18-29 14 25.14286
#> 2: 30-49 30 39.80000
#> 3: 50+ 44 63.54545If you use an analysis-orchestration package, register each artifact
as a named data entry — typically one short loop over
cp$list_artifacts().
Use schemas to declare a column-type contract on a branch and verify it before downstream code consumes the data.
cp$declare_schema("adults_female", schema = list(
age = list(type = "numeric", na = FALSE),
sex = list(type = "character", na = FALSE)
))
cp$validate()
#> [validate] All CohortPipeline schemas passedIf a column is missing, has the wrong type, or carries unexpected
NAs, $validate() throws one error that lists
every problem. Pass auto_validate = TRUE to
CohortPipeline$new() to fail at the failure site. Every
$new_cohort() and $set_artifact() call then
validates after it runs.
The simplest way to draw CONSORT diagrams is $plot().
With no arguments it renders one panel per cohort. Each panel walks the
root-to-cohort path, and cohort names become box labels.
You can name specific cohorts:
Or save to disk:
For full control over labels, panel titles, side branches and layout,
use $draw_consort_panels() (see
?CohortPipeline).
The worked example already uses cache_file and
on.exit(cp$save()). On the second run of the same script,
the pipeline checks every operation against the cached log.
| Method | Cache key |
|---|---|
exclude_and_track |
(branch, reason, expr_str) |
new_cohort |
(name, from) |
declare_schema |
overwrites; not cached |
set_artifact |
(name, from, body(fn), argset) |
A match advances the replay cursor with no work done. A mismatch
truncates the log at that point and drops downstream artifacts. It also
cascades to descendant cohorts whose inherited prefix is now stale. It
then recomputes only what changed. Labels are presentation, not part of
the cache key. Call
new_cohort("adults_female", from = "root", label = "Adult women")
again, and the pipeline updates the label. It invalidates nothing.
For set_artifact, you SHOULD use the 3-argument
signature function(dt, sib, argset). Data dependencies in
argset participate in the cache key. A change from
argset = list(washout = 84L) to washout = 90L
therefore triggers a recompute. The 2-argument form
function(dt, sib) still works. It does not catch
closure-captured changes.
The cache key uses body(fn) literally. If
fn calls a helper that you change, the cache cannot detect
the change. Either include a version tag in argset, or call
cp$invalidate(cohort) to drop the cohort and force a
recompute.
| Method | Returns | Mutation safe? |
|---|---|---|
$get_included(c) |
Independent copy | Yes |
Callback in $set_artifact |
Independent copy of subset | Yes |
$get_everyone(c) |
Independent copy + status | Yes |
Operations that go through the public API never modify the user’s input data table or another branch.
$exclude_and_track() evaluates the predicate against
the included subset only. A predicate can therefore assume that earlier
exclusions already removed invalid rows.data.table only on read ($consort(),
$list_cohorts()). That avoids quadratic rbind
growth.