--- title: "Exclusion tracking and subject tracing" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Exclusion tracking and subject tracing} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") library(lineager) ``` The ability to say exactly which rows were excluded, why, and what happened to a specific row across the whole pipeline is one of the primary values of `lineager`. This vignette covers all the tools for that: `lg_filter()` in depth, the exclusion registry, disposition tables, and subject-level tracing. ## Setup: a realistic multi-stage pipeline We build a cohort through three exclusion stages — a pattern common to clinical trials, epidemiological studies, machine learning pipelines, and observational research. ```{r setup-data} lg_start(study_id = "COHORT-001", analysis_id = "main-analysis") # Simulate a patient registry set.seed(42) n <- 20L raw <- data.frame( USUBJID = sprintf("PT-%03d", seq_len(n)), age = sample(15:75, n, replace = TRUE), sex = sample(c("M", "F"), n, replace = TRUE), diagnosis = sample(c("Y", "N", "N"), n, replace = TRUE), consent = sample(c("Y", "Y", "Y", "N"), n, replace = TRUE), prior_drug = sample(c("Y", "N", "N", "N"), n, replace = TRUE), biomarker = round(runif(n, 0.5, 8.5), 2), outcome = ifelse(runif(n) > 0.4, round(rnorm(n, 50, 12), 1), NA_real_), stringsAsFactors = FALSE ) registry <- lg_tag(raw, dataset_id = "REGISTRY", label = "Patient registry — all screened" ) cat("Screened: ", nrow(registry), "patients\n") ``` ## 1. lg_filter() in depth ### Mandatory reason Every call to `lg_filter()` requires a `reason`. The reason becomes the canonical documentation for that exclusion step — it appears in `lg_exclusions()`, `lg_disposition()`, `lg_trace()`, and `lg_report()`. ```{r filter-required} # This would error: # lg_filter(registry, age >= 18L) # Error: A `reason` is required. # Correct: adults <- lg_filter(registry, age >= 18L, reason = "Under minimum age threshold (age < 18 years)" ) ``` ### reason_code: machine-readable classification `reason_code` provides a short, controlled-vocabulary label for the exclusion — useful for grouping similar exclusions programmatically. ```{r filter-reason-code} consented <- lg_filter(adults, consent == "Y", reason = "Did not provide written informed consent", reason_code = "NO_CONSENT" ) diagnosed <- lg_filter(consented, diagnosis == "Y", reason = "Does not meet diagnostic criteria per protocol section 3.1", reason_code = "NO_DIAGNOSIS" ) ``` ### population: grouping exclusions by analysis set `population` groups exclusions into named cohorts — corresponding to analysis set flags in clinical data (SAFFL, ITTFL, etc.) or cohort definitions in epidemiology. ```{r filter-population} no_prior <- lg_filter(diagnosed, prior_drug == "N", reason = "Received prohibited prior medication within wash-out period", reason_code = "PRIOR_MED", population = "ELIGIBLE_SET" ) biomarker_pos <- lg_filter(no_prior, biomarker >= 2.0, reason = "Biomarker below threshold (< 2.0) per protocol section 4.3", reason_code = "LOW_BIOMARKER", population = "BIOMARKER_POS" ) analysis_set <- lg_filter(biomarker_pos, !is.na(outcome), reason = "Missing primary outcome measurement", reason_code = "MISSING_OUTCOME", population = "ANALYSIS_SET" ) cat("Screened: ", nrow(registry), "\n") cat("Adults: ", nrow(adults), "\n") cat("Consented: ", nrow(consented), "\n") cat("Diagnosed: ", nrow(diagnosed), "\n") cat("No prior med: ", nrow(no_prior), "\n") cat("Biomarker+: ", nrow(biomarker_pos), "\n") cat("Analysis set: ", nrow(analysis_set), "\n") ``` ## 2. The exclusion registry Every excluded row is captured in the session store as a structured record. `lg_exclusions()` retrieves the full registry as a data frame. ```{r exclusions-all} excl <- lg_exclusions() cat("Total exclusions:", nrow(excl), "\n") names(excl) ``` ### Filter by population ```{r exclusions-population} # Only exclusions related to the final analysis set analysis_excl <- lg_exclusions(population = "ANALYSIS_SET") analysis_excl[, c("usubjid", "reason", "reason_code")] ``` ### Filter by dataset When multiple datasets are tagged and filtered, query by dataset: ```{r exclusions-dataset} lg_exclusions(dataset_id = "REGISTRY")[ , c("usubjid", "reason_code", "population") ] ``` ### The exclusion record structure Each exclusion record contains: | Field | Content | |---|---| | `excl_id` | Unique exclusion identifier (`op_0001_excl_0001`) | | `op_id` | Which `lg_filter()` operation caused this | | `dataset_id` | Which dataset the row was removed from | | `lid` | The `lineage_id` of the excluded row | | `usubjid` | Subject identifier (from USUBJID column if present) | | `reason` | The documented exclusion reason | | `reason_code` | Short code for programmatic grouping | | `population` | Which population/cohort this relates to | | `excluded_at` | UTC timestamp of exclusion | ## 3. Disposition tables `lg_disposition()` aggregates the exclusion registry into a grouped summary — the data behind a CONSORT flow diagram or study disposition table. ### Group by reason ```{r disposition-reason} lg_disposition(by = "reason") ``` ### Group by population ```{r disposition-population} lg_disposition(by = "population") ``` ### Group by dataset Useful when multiple source datasets are filtered: ```{r disposition-dataset} lg_disposition(by = "dataset") ``` ## 4. Subject tracing `lg_trace()` returns the complete history of a row identified by its USUBJID or any substring matching its lineage ID. This is `lineager`'s most distinctive capability. ### Tracing an excluded subject ```{r trace-excluded} # Find a subject who was excluded excluded_id <- lg_exclusions()$usubjid[[1L]] cat("Tracing excluded subject:", excluded_id, "\n") lg_trace(excluded_id) ``` The trace shows: - Which tagged datasets contain this row - Which operations (in order) touched datasets containing this row - All exclusion records for this row, with reasons and population ### Tracing an included subject ```{r trace-included} included_id <- analysis_set$USUBJID[[1L]] cat("Tracing included subject:", included_id, "\n") lg_trace(included_id) ``` For included subjects, the exclusions section will be empty — they passed every filter. ### Tracing a subject not found ```{r trace-notfound} result <- lg_trace("PT-999", verbose = FALSE) cat("Datasets found in:", length(result$datasets), "\n") ``` ### Using the trace result programmatically `lg_trace()` returns its result invisibly — capture it for programmatic use: ```{r trace-programmatic} result <- lg_trace(excluded_id, verbose = FALSE) cat("Subject: ", result$usubjid, "\n") cat("Found in: ", paste(result$datasets, collapse = ", "), "\n") cat("Operations: ", nrow(result$operations), "\n") cat("Exclusions: ", nrow(result$exclusions), "\n") if (nrow(result$exclusions) > 0L) { cat("Excluded by: ", result$exclusions$reason[[1L]], "\n") cat("Population: ", result$exclusions$population[[1L]], "\n") } ``` ## 5. The operation log `lg_operations()` returns the full sequence of operations as a data frame — useful for understanding the pipeline structure and for automating documentation. ```{r operations} ops <- lg_operations() ops[, c( "op_id", "op_type", "dataset_id", "description", "rows_in", "rows_out" )] ``` The difference between `rows_in` and `rows_out` is the number of rows excluded by that operation — matching the exclusion records registered at that step. ```{r operation-check} # Verify: total excluded == sum of (rows_in - rows_out) across FILTER ops filter_ops <- ops[ops$op_type == "FILTER", ] total_via_ops <- sum(filter_ops$rows_in - filter_ops$rows_out) total_via_excl <- nrow(lg_exclusions()) cat("Excluded via ops: ", total_via_ops, "\n") cat("Excluded via excl: ", total_via_excl, "\n") cat("Match: ", total_via_ops == total_via_excl, "\n") ``` ## 6. Visualise exclusions as a lineage graph After building a pipeline, `lg_lineage()` produces a visual summary showing each filter step, how many rows it removed, and where exclusion branches occur — complementing the tabular output of `lg_exclusions()` and `lg_disposition()`. ```{r lineage} lin <- lg_lineage() print(lin) ``` ```{r lineage-plot, eval = FALSE} lg_plot(lin) ``` ## 7. Common patterns ### Cascaded filters with verbose tracking ```{r cascade} lg_start() cohort <- lg_tag( data.frame( id = sprintf("S%02d", 1:10), enrolled = c(rep(TRUE, 8), FALSE, FALSE), treated = c(rep(TRUE, 6), FALSE, FALSE, FALSE, FALSE), complete = c(rep(TRUE, 4), FALSE, FALSE, rep(FALSE, 4)), stringsAsFactors = FALSE ), dataset_id = "COHORT" ) step1 <- lg_filter(cohort, enrolled == TRUE, reason = "Not enrolled in study" ) step2 <- lg_filter(step1, treated == TRUE, reason = "Did not receive study treatment" ) step3 <- lg_filter(step2, complete == TRUE, reason = "Did not complete the study" ) cat("Enrolled: ", nrow(step1), "\n") cat("Treated: ", nrow(step2), "\n") cat("Completed: ", nrow(step3), "\n") lg_disposition(by = "reason") ``` ```{r cascade-end} lg_end() ``` Continue to `vignette("populations-and-reporting")` for population flag registration and report generation.