Biomarker-Defined Cohort (RWE)
A cohort design that restricts eligibility to patients with a specific molecular, genomic, laboratory, pathology, or companion-diagnostic result, with time zero and follow-up anchored to biomarker ascertainment to avoid immortal time and testing-selection bias.
On this page
A biomarker-defined cohort is a study group built only from patients who had a specific molecular or lab test done AND received a qualifying result (for example, a genetic mutation found in their tumor). Because the test happens in routine care rather than a controlled trial, only patients who were actually tested can enter the study, and that tested group is not a random slice of all patients with the disease. The key trap to avoid is counting time before a patient got their test result as if they were already in the study, which makes survival look falsely better than it really is.
A biomarker-defined cohort restricts the study population to patients carrying (or lacking) a specific biomarker — an oncogenic driver mutation (e.g., EGFR, KRAS G12C, BRAF V600E), a protein-expression level (PD-L1 TPS ≥50%, HER2 IHC 3+), a genomic signature (tumor mutational burden, MSI-high/dMMR), or a laboratory threshold (eGFR, LDL-C, viral load). In real-world data the biomarker is not assigned at enrollment as in a trial; it is discovered through a test that was ordered in routine care at some point in the patient's trajectory. The central methodological problem is therefore not "who has the biomarker" but "how does conditioning on a test result — and the timing of that test — distort the cohort, the time axis, and the estimand." Getting the index date and the entry condition right is the entire game.
Core conceptual distinction
A biomarker-defined cohort sits at the intersection of three separable choices, and conflating them is the most common source of bias.
- Eligibility on biomarker status requires that the result actually exist in the data — so the analyzable population is the tested subset, not the underlying disease population, and tested patients differ systematically from untested ones (access, performance status, larger or progressing tumors that warrant profiling, academic-center care).
- Index date / time zero must be chosen so that biomarker status is known at entry and no follow-up is "guaranteed survived." Anchoring follow-up to the date of diagnosis while requiring a later NGS result builds in immortal time: a patient cannot appear in a biomarker-defined cohort unless they survived long enough to be tested and reported.
- Estimand: a descriptive prevalence/outcome in the biomarker-positive subgroup is a very different quantity from a comparative (biomarker-stratified treatment effect), and the latter inherits all the confounding-by-indication problems of any observational comparison plus the testing-selection layer on top.
Pros, cons, and trade-offs
- vs an unselected (all-comers) disease cohort: A biomarker-defined cohort answers the precision-medicine question directly — efficacy, safety, utilization, or cost within the marker stratum — and supports external-control arms for marker-targeted therapies. Cost: it is restricted to the tested subpopulation, so generalizability to the full indication requires an explicit argument about who gets tested. Prefer it when the clinical and reimbursement decision is itself marker-conditional (companion diagnostic, ESCAT/OncoKB-tiered actionability).
- vs defining the cohort on the biomarker test order (a CPT/HCPCS or order flag) rather than the result: Using the test event alone (common when only claims are available) tells you a test happened, never the result; it cannot separate positives from negatives and silently mixes strata. Prefer result-based definition whenever EHR/registry/NGS-report data carry the actual call; fall back to test-order proxies only for utilization/testing-rate questions, never for marker-conditional outcomes.
When NOT to use — and when it is actively misleading
- Claims-only data with no result. Administrative claims carry CPT/HCPCS codes for the test (e.g., 81235 for EGFR, 81445/81455 for NGS panels, 0037U/0048U PLA codes, G-codes) but not the call. Defining "biomarker-positive" from claims alone is impossible; attempting it forces a test-order surrogate that misclassifies massively. Use linked or EHR data, or restrict the question to testing rates.
- Immortal time from result-date anchoring. If you require a biomarker result obtained after the treatment line you are studying, every patient in the cohort survived from line start to test report — the line looks falsely protective. Nakamura et al. (2023) showed exactly this in a 5,743-patient genomic-profiling program: patients enrolled after first-line initiation had a median OS advantage of ~8.9 months purely from immortal time. Anchor entry at the result date with left truncation, or use a landmark/clone-censor-weight design.
- Ignoring testing-selection. Tested patients are not a random sample of the diseased. A "biomarker-positive vs negative" contrast that does not address why some patients were tested (and others were not) confounds marker status with the indication-to-test. Report the tested-vs-untested comparison and, where possible, model selection or restrict to a setting with near-universal testing (e.g., reflex testing).
- Assay/platform and threshold heterogeneity treated as one variable. PD-L1 by 22C3 vs SP263, HER2 IHC vs ISH, NGS variant-allele-frequency cutoffs, and OncoKB/ESCAT actionability tiers drift across labs and calendar time; variant reclassification (VUS → pathogenic) changes membership retroactively. Pooling them without a harmonization rule and a sensitivity analysis on the cut point produces a marker definition no one can reproduce.
Data-source operational depth
- Claims (FFS or commercial): Useful only for the fact and date of testing (procedure/PLA codes) and for downstream utilization/cost once the cohort is defined elsewhere. Require continuous medical enrollment so an absent test code means "not tested," not "unobserved" — and note that Medicare Advantage and capitated/bundled arrangements drop fee-for-service claims, so MA-only person-time cannot confirm a test did or did not occur; restrict to enrollees with observable claims. Never read marker status from claims.
- EHR: The biomarker result lives in pathology/molecular-report fields or unstructured notes; structured genomics tables exist only in mature systems and frequently require NLP/abstraction. Strengths: the actual call, assay, specimen date, and report date, plus indication and severity. Failure modes: results captured only when the test is in-network (external reference-lab reports leak out of the EHR), report-date vs specimen-date ambiguity, and visit-driven loss to follow-up that is differential by marker (positives funnel to targeted therapy and academic centers).
- Registry (e.g., SEER, disease/genomic registries): Strong for adjudicated stage, histology, and often a curated biomarker field with defined assay rules; typically weak for complete treatment and pharmacy exposure. Link to claims for therapy and to a death index to firm up censoring and OS.
- Linked EHR–claims–genomic (e.g., clinicogenomic databases): The ideal substrate — the genomic report supplies the result, the EHR supplies severity and treatment, claims supply completeness and cost, and a vital-records link supplies mortality. Singal et al. (2019) established this design for NSCLC. Costs: linkage selects the linkable subset, the NGS-tested cohort is itself selected, and report/specimen/treatment dates must be reconciled before time-zero assignment.
Worked example (linked EHR–claims–NGS, oncology)
Question: real-world overall survival of first-line osimertinib in EGFR-mutant advanced NSCLC.
- Source: a linked clinicogenomic database with NGS reports, EHR treatment records, and a death index.
- Biomarker definition: a structured EGFR exon-19 deletion or L858R call from an NGS or validated single-gene assay; record `assay_type`, `specimen_date`, and `report_date`; pre-specify how to treat VUS and which platforms qualify.
- Cohort entry to defeat immortal time: a patient becomes eligible only at `max(advanced_dx_date, egfr_result_date)`, and time zero is the first qualifying line start on or after that date — so biomarker status is known at entry and no pre-result, survived-to-test period is counted as exposed follow-up. If the estimand is OS from advanced diagnosis, instead left-truncate each patient's follow-up at `egfr_result_date` (delayed entry) so the risk set excludes time before testing.
- Tested-selection check: compare the EGFR-tested population to the broader advanced-NSCLC EHR population on stage, performance status, and site to characterize generalizability.
- Follow-up: from time zero to death, censoring at last EHR activity (treat as potentially informative) and end of data; reconcile `report_date` vs `specimen_date` and EHR vs claims service dates.
- Sensitivity analyses: alternative index-date rules (result-date entry vs left-truncation vs clone-censor-weight from diagnosis), VAF/threshold and assay-platform variations, and a negative-control marker to probe residual testing-selection.
Decision diagram
flowchart TD
Pop[Advanced-disease patients in routine care] --> Tested{Biomarker test ordered<br/>and result captured?}
Tested -- No --> Untested[Untested subset<br/>differs by access / severity / site]
Tested -- Yes --> Result{Qualifying positive call?<br/>result-based, not test-order}
Result -- No --> Neg[Biomarker-negative stratum]
Result -- Yes --> Ascertain[Ascertainment date = report_date<br/>status now known in data]
Ascertain --> Entry[Entry / time zero on or after ascertainment<br/>or left-truncate from diagnosis]
Entry --> Sel[Report tested-vs-untested selection<br/>+ assay/threshold sensitivity]
Sel --> Analysis[Marker-conditional outcome analysis]gantt title Index-date choice for one biomarker-positive patient (immortal time) dateFormat YYYY-MM-DD axisFormat %b %Y section Pre-result (survived-to-test) Advanced diagnosis :milestone, dx, 2023-02-01, 0d First-line therapy started (status unknown) :crit, fl, 2023-02-10, 60d section Ascertainment NGS report -> EGFR positive :milestone, rep, 2023-04-15, 0d section Immortal-time-safe follow-up Risk set entry at result date (left truncation) :active, ft, 2023-04-15, 200d
Worked example
Scenario
A researcher wants to study survival in patients with advanced lung cancer whose tumors carry an EGFR mutation. The electronic health record contains NGS (genomic panel) test reports. The table below shows five patients: their cancer diagnosis date, the date their EGFR mutation result came back, and whether they were tested at all. The goal is to decide who enters the cohort, when their follow-up clock starts, and what happens to patients who were never tested.
Dataset
Patient records: diagnosis date, EGFR test result date, and result. Untested patients have no result.
| person_id | advanced_dx_date | egfr_result_date | egfr_result | qualifies |
|---|---|---|---|---|
| PT-01 | 2023-01-10 | 2023-02-05 | EGFR exon19del (positive) | YES |
| PT-02 | 2023-01-15 | 2023-02-20 | EGFR wild-type (negative) | NO |
| PT-03 | 2023-02-01 | never tested | no result | NO |
| PT-04 | 2023-02-10 | 2023-03-01 | EGFR L858R (positive) | YES |
| PT-05 | 2023-03-05 | died before result | no result | NO |
Steps
Result
Cohort size = 2 patients (PT-01, PT-04). Index dates: PT-01 on 2023-02-05, PT-04 on 2023-03-01. The 26-day and 19-day pre-result windows are excluded from follow-up. The 3 excluded patients represent the testing-selection issue: findings from this cohort describe EGFR-positive, tested patients only, not all advanced lung cancer patients.
Trade-offs
Runnable example
Biomarker-defined cohort construction from linked EHR/genomic inputs, with index-date logic that defeats immortal time. Required inputs (already cleaned and de-duplicated): dx : advanced-disease diagnosis -> person_id, advanced_dx_date (datetime) bmarker : biomarker results -> person_id, report_date, specimen_date,...
import pandas as pd
QUALIFYING = {("EGFR", "exon19del"), ("EGFR", "L858R")} # pre-specified positive calls
ELIGIBLE_ASSAYS = {"NGS", "single_gene_validated"} # pre-specified platforms
def build_biomarker_cohort(dx: pd.DataFrame, bmarker: pd.DataFrame, tx: pd.DataFrame) -> pd.DataFrame:
# 1) Result-based positive definition; ascertainment date = report_date (when status becomes known).
pos = bmarker[bmarker["assay_type"].isin(ELIGIBLE_ASSAYS)].copy()
pos = pos[pos[["gene", "alteration"]].apply(tuple, axis=1).isin(QUALIFYING)]
# Earliest qualifying report per person = the date biomarker status is first known in the data.
ascertain = (pos.sort_values("report_date")
.groupby("person_id", as_index=False)
.agg(egfr_result_date=("report_date", "first"),
assay_type=("assay_type", "first")))
cohort = dx.merge(ascertain, on="person_id", how="inner") # tested + positive only
# 2) Eligibility starts when BOTH advanced disease and a known positive result exist.
cohort["eligible_from"] = cohort[["advanced_dx_date", "egfr_result_date"]].max(axis=1)
# 3) Time zero = first treatment line starting ON OR AFTER eligibility (status known at entry,
# so no survived-to-test, pre-result person-time is counted as exposed follow-up).
tx_sorted = tx.sort_values("line_start_date")
merged = tx_sorted.merge(cohort[["person_id", "eligible_from"]], on="person_id")
first_line = (merged[merged["line_start_date"] >= merged["eligible_from"]]
.groupby("person_id", as_index=False)
.first()
.rename(columns={"line_start_date": "index_date"}))
out = cohort.merge(first_line[["person_id", "index_date", "regimen"]], on="person_id", how="inner")
# 4) Alternative from-diagnosis estimand: left-truncate the risk set at the result date.
out["truncation_entry"] = out["egfr_result_date"] # delayed entry for OS-from-diagnosis analyses
out["baseline_start"] = out["index_date"] - pd.Timedelta(days=365) # covariate window ends at index
return out[["person_id", "advanced_dx_date", "egfr_result_date", "assay_type",
"index_date", "regimen", "truncation_entry", "baseline_start"]]Biomarker-defined cohort construction with data.table. Inputs mirror the Python version: dx : person_id, advanced_dx_date (Date) bmarker : person_id, report_date (Date), specimen_date (Date), gene, alteration, assay_type tx : person_id, line_start_date (Date), regimen, line_number Index date avoids immortal time;
library(data.table)
QUALIFYING <- list(c("EGFR", "exon19del"), c("EGFR", "L858R"))
ELIGIBLE_ASSAYS <- c("NGS", "single_gene_validated")
build_biomarker_cohort <- function(dx, bmarker, tx) {
setDT(dx); setDT(bmarker); setDT(tx)
# Result-based positive definition; ascertainment = earliest qualifying report date.
pos <- bmarker[assay_type %chin% ELIGIBLE_ASSAYS]
keep <- pos[, paste(gene, alteration) %chin% sapply(QUALIFYING, paste, collapse = " ")]
pos <- pos[keep == TRUE]
setorder(pos, report_date)
ascertain <- pos[, .(egfr_result_date = report_date[1L], assay_type = assay_type[1L]), by = person_id]
cohort <- merge(dx, ascertain, by = "person_id") # tested + positive only
cohort[, eligible_from := pmax(advanced_dx_date, egfr_result_date)]
# Time zero = first line starting on/after eligibility (status known at entry).
setorder(tx, line_start_date)
m <- merge(tx, cohort[, .(person_id, eligible_from)], by = "person_id")
first_line <- m[line_start_date >= eligible_from,
.(index_date = line_start_date[1L], regimen = regimen[1L]), by = person_id]
out <- merge(cohort, first_line, by = "person_id")
out[, truncation_entry := egfr_result_date] # delayed entry for from-diagnosis OS
out[, baseline_start := index_date - 365L]
out[, .(person_id, advanced_dx_date, egfr_result_date, assay_type,
index_date, regimen, truncation_entry, baseline_start)]
}Biomarker-defined cohort construction in SAS (PROC SQL), with immortal-time-safe index dating. Required input datasets (post data-management): work.dx : person_id, advanced_dx_date work.bmarker : person_id, report_date, specimen_date, gene, alteration, assay_type work.tx : person_id, line_start_date, regimen,...
/* 1) Result-based positive definition; ascertainment date = earliest qualifying report. */
proc sql;
create table ascertain as
select person_id,
min(report_date) as egfr_result_date format=date9.
from work.bmarker
where assay_type in ('NGS','single_gene_validated')
and gene = 'EGFR'
and alteration in ('exon19del','L858R')
group by person_id;
quit;
/* 2) Tested + positive cohort; eligibility begins when advanced disease AND a known result both hold. */
proc sql;
create table cohort as
select d.person_id,
d.advanced_dx_date,
a.egfr_result_date,
max(d.advanced_dx_date, a.egfr_result_date) as eligible_from format=date9.
from work.dx d
inner join ascertain a
on d.person_id = a.person_id;
quit;
/* 3) Time zero = first treatment line starting ON OR AFTER eligibility (status known at entry;
no survived-to-test, pre-result person-time is counted as exposed follow-up). */
proc sql;
create table cohort_index as
select c.person_id,
c.advanced_dx_date,
c.egfr_result_date,
min(t.line_start_date) as index_date format=date9.,
c.egfr_result_date as truncation_entry format=date9., /* delayed entry option */
calculated index_date - 365 as baseline_start format=date9.
from cohort c
inner join work.tx t
on t.person_id = c.person_id
and t.line_start_date >= c.eligible_from
group by c.person_id, c.advanced_dx_date, c.egfr_result_date;
quit;
/* 4) Left-truncated (delayed-entry) survival from diagnosis: enter the risk set at the result date. */
/* proc phreg data=surv; */
/* model (truncation_entry, exit_date)*event(0) = trt covars / ties=efron; /* entry, exit time */
/* run; */Citations
- [1]Singal G, Miller PG, Agarwala V, et al. Association of patient characteristics and tumor genomics with clinical outcomes among patients with non-small cell lung cancer using a clinicogenomic database. JAMA. 2019;321(14):1391-1399.
- [2]Suissa S, Dell'Aniello S. Time-related biases in pharmacoepidemiology. Pharmacoepidemiology and Drug Safety. 2020;29(9):1101-1110.
- [3]Hernán MA, Sauer BC, Hernández-Díaz S, Platt R, Shrier I. Specifying a target trial prevents immortal time bias and other self-inflicted injuries in observational analyses. Journal of Clinical Epidemiology. 2016;79:70-75.