← Methods repository
concept

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.

Study_Designstudy_designbiomarker-defined-cohortprecision-medicineimmortal-time-biastesting-selectionclinicogenomiccompanion-diagnosticspecial-populations-methods
Methods reference only. Use primary source citations and local policy before applying this in a study protocol, regulatory submission, payer dossier, or clinical decision.

In plain language

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. (1) 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). (2) 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. (3) 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. - vs target-trial emulation with clone-censor-weight from diagnosis: A simple "enter at biomarker ascertainment with left truncation" design is easier to specify and communicate and removes immortal time cleanly when the question is outcomes from the result date forward. Cost: it cannot recover the full from-diagnosis estimand and conditions on surviving-to-test. When the policy question is from diagnosis (e.g., the value of universal up-front testing), a clone-censor-weight or sequential-trial emulation that handles the pre-result period is the more defensible (and more complex) tool. Prefer plain left-truncated entry unless the from-diagnosis estimand is genuinely required.

When to use

Comparative effectiveness, safety, natural-history, utilization, or cost analyses where the clinical question is intrinsically marker-conditional; building external/synthetic control arms for single-arm trials of targeted agents (regulatory and HTA submissions); characterizing testing rates, turnaround, and marker prevalence; HEOR models whose inputs (response, PFS, OS) are biomarker-stratified. The substrate is almost always EHR, registry, or linked EHR–claims–genomic data, because the result lives in pathology/molecular reports, not in administrative claims.

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. (1) Source: a linked clinicogenomic database with NGS reports, EHR treatment records, and a death index. (2) 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. (3) 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. (4) Tested-selection check: compare the EGFR-tested population to the broader advanced-NSCLC EHR population on stage, performance status, and site to characterize generalizability. (5) 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. (6) 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.

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_idadvanced_dx_dateegfr_result_dateegfr_resultqualifies
PT-012023-01-102023-02-05EGFR exon19del (positive)YES
PT-022023-01-152023-02-20EGFR wild-type (negative)NO
PT-032023-02-01never testedno resultNO
PT-042023-02-102023-03-01EGFR L858R (positive)YES
PT-052023-03-05died before resultno resultNO

Steps

  • Step 1 - Who qualifies by biomarker result: Only PT-01 and PT-04 have a confirmed qualifying EGFR mutation in their test report. PT-02 tested negative, PT-03 was never tested, and PT-05 died before a result was available.

  • Step 2 - Set each qualifying patient's index date to the later of their diagnosis date or their test result date. For PT-01: result date (2023-02-05) is after diagnosis (2023-01-10), so index date = 2023-02-05. For PT-04: result date (2023-03-01) is after diagnosis (2023-02-10), so index date = 2023-03-01.

  • Step 3 - Do NOT count the time between diagnosis and the result date as study follow-up. PT-01 had 26 days between diagnosis and result; those 26 days are excluded. If we mistakenly started follow-up at diagnosis, every patient in the cohort would be a survivor of that pre-test window, making survival look better than it is (immortal time bias).

  • Step 4 - Note the selection issue: 3 of 5 patients (60%) are excluded because they were untested or negative. The 2 who qualify may differ from the other 3 in ways that affect outcomes (they may have been tested because their disease was progressing rapidly, or because they had access to a comprehensive cancer center).

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.

Runnable example

python implementation

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 :...

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"]]
r implementation

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...

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)]
}