← Methods repository
concept

Cascade of Care Analysis

A staged framework that decomposes the patient pathway from diagnosis or eligibility through linkage, treatment initiation, persistence, and response/outcome attainment, quantifying conditional retention and absolute attrition at each gate to locate modifiable bottlenecks.

Descriptive_Epidemiologycascade-of-carecare-cascadetreatment-cascadeattrition-funnelconditional-retentionquality-measurementhealth-equitydescriptive-epidemiology
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 cascade of care tracks a population of patients through a series of ordered steps — from being diagnosed with a disease all the way to achieving a good health outcome — and counts how many people are still in the pipeline at each step. The picture it produces is a shrinking funnel: you can see not only the final fraction who reached the goal but exactly where along the journey the largest groups of patients fell away. This makes it directly useful for answering 'where should a health system focus first?' — a question that a single summary number like '22% of patients are well-controlled' cannot answer. One honest limitation: a drop at any step does not automatically mean a failure in care; some patients appropriately stop treatment for clinical reasons, so the funnel must be interpreted with that in mind.

The cascade of care (care cascade, treatment cascade, "the cascade") is a descriptive-analytic framework that maps the ordered steps a patient must clear to reach a clinically meaningful outcome and quantifies the proportion retained, and the absolute number lost, at each step. It was formalized for HIV (diagnosed -> linked to care -> ART-initiated -> retained -> virally suppressed) and has been transplanted to diabetes (diagnosed -> treated -> controlled), heart failure (eligible -> guideline-directed medical therapy initiated -> up-titrated to target dose), oncology (diagnosed -> staged -> systemic therapy -> response), and opioid use disorder (diagnosed -> medication for OUD initiated -> retained). In RWE it is built entirely from date-stamped events in claims, EHR, or registry data: diagnosis codes for eligibility, pharmacy fills or J-codes for treatment, lab values or CPT-II codes for response, and diagnosis/procedure/death for terminal outcomes.

Core conceptual distinction

A cascade is not a single rate, not persistence, and not lines-of-therapy. A "control rate" or "treated rate" is a scalar that hides where the system leaks; the cascade replaces it with a set of conditional probabilities (of those eligible, p1 are diagnosed; of those diagnosed, p2 are treated; of those treated, p3 respond) plus the absolute count lost at each gate. The denominator semantics are the whole methodological game: a cascade can be read unconditionally (every stage as a share of the original eligible population, so the curve is monotone non-increasing) or conditionally (each stage as a share of the prior stage, exposing which single transition is the bottleneck). Persistence/ PDC measure duration conditional on having initiated a specific drug, so they are blind to the pre-treatment losses (undiagnosed, diagnosed-but-untreated) that often dominate the cascade. Lines-of-therapy describe sequencing after initiation and condition on reaching a later line. The cascade is the only one of the three that spans the full diagnosis- to-outcome arc and makes pre-initiation leakage and end-stage response visible in one object. It is descriptive, not causal: it reports what happened to a real population, not what would happen under an intervention.

Pros, cons, and trade-offs

- vs a single summary metric (e.g., "42% controlled"): the cascade localizes the bottleneck (90% diagnosed but only 35% initiated is a very different problem from 95% initiated but only 40% responding), which makes it directly actionable for health systems, payers, and quality programs. Cost: it demands defensible stage definitions and date logic, and the "leak" attribution shifts with those choices. - vs persistence / time-to-discontinuation: the cascade includes the diagnosis and initiation gaps that persistence never observes and supports equity and access questions across the whole pathway. Persistence is the right tool for the narrower "how long do patients stay on drug X once started?" question and gives finer time resolution within the single stage it covers. - vs treatment-patterns / lines-of-therapy: the cascade exposes the large pre-first-line losses and the terminal response stage; LOT is richer for sequencing, switching, and later-line cost once patients have initiated. Use the cascade for population gap analysis, LOT for describing the complexity of treated patients. - vs discrete-event or Markov simulation: the cascade is the observed evidence layer; simulation is the decision layer that takes cascade-derived transition probabilities as inputs to project "what if we closed the stage-3 gap?" over a lifetime or budget horizon. A cascade alone cannot forecast; a simulation without a credible cascade is ungrounded.

When to use

Population-health, quality-improvement, value-based-contracting, or access questions framed as "where in the journey from eligibility to outcome are patients lost?"; comparative work across systems, regions, payers, or pre/post policy to detect differential leakage (an equity lens); therapeutic areas with clear, ordered, measurable stages; and as the empirical scaffold supplying uptake, persistence, and response parameters to a budget-impact or cost-effectiveness model.

When NOT to use -- and when it is actively misleading or dangerous

- The stages are not genuinely sequential, or the "leak" is appropriate care. Many diagnosed patients are correctly not started on a drug (contraindication, mild disease, patient preference, watchful waiting). Drawing a cascade implies every drop-off is a failure; presenting "65% diagnosed but untreated" as a quality gap without clinical adjudication is misleading and can drive harmful over-treatment incentives. - Intermediate stages rest on insensitive or non-specific codes. Defining "response" as the mere absence of a progression code, or "control" from sporadically captured labs, manufactures an artifactual cascade whose shape is driven by coding completeness, not clinical reality. - The denominator is itself selected and presented as the population. Restricting to continuously enrolled patients, or to those with a recorded lab, and then labeling the result "the cascade for condition X" conflates a convenience sample with the target population. This is the cascade-specific form of selection bias. - It is used to claim a drug "works." A cascade has no comparator and no counterfactual; reading "of those treated, 60% responded" as evidence of treatment efficacy is a category error -- that requires an active-comparator new-user design or a target-trial emulation, not a funnel.

Data-source operational depth

- Claims (FFS / commercial): diagnosis from ICD on medical claims (incident phenotypes typically require 1 inpatient or 2 outpatient codes on different days to suppress rule-out coding); treatment from pharmacy fills (NDC + `fill_date` + `days_supply`) or J-codes/HCPCS for infused/injected agents that never appear in pharmacy; response from LOINC labs or CPT-II codes, which are sparsely captured in claims. Failure modes: (1) Medicare Advantage person-time lacks FFS claims -- MA-only enrollees generate no usable medical/pharmacy claims, so absence of a treatment fill or lab is missingness, not a true negative; restrict to enrollees with the relevant benefit (Parts A/B/D or commercial medical+ pharmacy) and exclude MA-only spans, or the later cascade stages collapse artifactually. (2) Continuous-enrollment requirements select the denominator -- requiring 12 months pre/post enrollment drops the sickest (who die or disenroll) and the healthiest (who churn), biasing every downstream conditional. (3) Plan switching and FFS<->MA transitions truncate observation mid-cascade, so a patient who "fails to reach control" may simply have left the observable data; censor explicitly and report the at-risk denominator per stage. (4) Response capture differs by exposure -- patients on actively managed therapy get more labs ordered, so "controlled" looks higher for treated patients partly because they are measured more, an immortal-time/ascertainment artifact at the response gate. - EHR: richer for the response stage (actual lab values, vitals, problem-list updates, e-prescribing orders) but visit-driven and leaky to outside care. Use the actual result/order date, not a claim adjudication date, and require a minimum observation window after each stage before scoring "not achieved," or you misclassify "not yet measured" as "failed." A patient who leaves the system is differentially lost. - Registry: often carries adjudicated stage membership (cancer stage, dialysis initiation, viral load) and is the gold standard for specific steps, but the enrolled population is selected and cost/utilization capture is incomplete; link to claims for the full pathway. - Linked claims-EHR(-vital records): the ideal substrate -- claims give complete capture of diagnosis and initiation, EHR gives the clinical detail and labs for response, and a death index firms up the terminal stage and censoring. Reconcile order/fill/service/result dates across sources before assigning stage entry, or stage ordering inverts.

Worked claims example (type 2 diabetes; commercial + Medicare FFS, index 2022, follow-up through 2024). Denominator: adults 40-80 with an incident type 2 diabetes phenotype (ICD-10 E11.x: 1 inpatient or 2 outpatient on separate days) in 2022, with 12 months continuous medical+pharmacy enrollment pre-index and 12 months post-index, and no MA-only person-time across that window. Pre-specified stages (denominator = stage 1 throughout for the unconditional curve): 1. Diagnosed (eligible denominator): 184,200 (100%). 2. Linked: primary-care or endocrinology E/M visit within 180 days of the first qualifying diagnosis code: 141,900 (77.0% of diagnosed). 3. Initiated: first fill (NDC + `days_supply`) or administration of any non-insulin antidiabetic (metformin, SGLT2i, GLP-1 RA, DPP-4i, sulfonylurea, TZD) within 180 days of diagnosis or linkage: 98,300 (53.4% of diagnosed; 69.3% of linked). 4. Persistent: PDC >= 0.80 over the first 365 days post-initiation, allowing within-class switches and a 30-day refill grace period: 71,800 (39.0% of diagnosed). 5. Controlled: most recent HbA1c < 7.0% (or a documented individualized < 7.5% target) in the 12-month outcome window, from LOINC labs or CPT-II codes: 42,100 (22.8% of diagnosed). Read-out: the largest absolute loss is at initiation (85,900 diagnosed patients never reach a first antidiabetic fill within the window); among initiators, ~27% fail to persist to 12 months; among persistent patients, ~41% have no documented controlled HbA1c -- but note that part of that last gap is measurement, since the controlled-lab denominator is itself conditional on a lab being captured (a cascade-specific selection trap). Stratifying by Area Deprivation Index quartile shows an 8-11 percentage-point lower retention at every gate for the most-deprived vs least-deprived quartile, the kind of monotone equity signal a single control rate would hide. Sensitivity: widening the initiation window from 90 to 365 days moves stage 3 from 48% to 61% of diagnosed -- proof that stage definitions, not biology, can dominate the apparent leak, which is why the window, denominator choice, and code lists must be pre-specified in the SAP and varied in sensitivity analyses.

Operational variants

(a) Static cross-sectional cascade -- every patient at their own current stage on one fixed date; fast, but mixes incident and prevalent patients. (b) Longitudinal/cohort cascade -- patients followed from a common time-zero (first diagnosis or first treatment opportunity); the cleaner design for attribution. (c) Comparative cascades -- by payer, region, race/ethnicity, or pre/post a formulary or policy change. (d) Conditional cascade -- restricted to patients who reached a prior stage ("of initiators, what share responded?"), useful but easy to misread as the population cascade.

Interpreting the output

A type 2 diabetes care cascade among 184,200 commercially insured + Medicare FFS adults shows: 10,000 diagnosed (denominator rescaled for readability) → 7,700 linked to care (77.0%) → 5,330 initiated on antidiabetics (53.3% of diagnosed; 69.2% of linked) → 3,890 persistent at 12 months (38.9% of diagnosed) → 2,280 with documented glycemic control (22.8% of diagnosed).

(1) Formal interpretation. Each row has two valid denominators, and which you use changes the story. The unconditional fractions (all referenced to the 10,000 diagnosed denominator) give a monotone non-increasing curve showing cumulative leakage from the full eligible population. The conditional fractions (each stage referenced to the prior stage) reveal where the system is most inefficient within each transition. The largest absolute unconditional loss occurs at initiation: 4,670 diagnosed patients never reach a first antidiabetic fill, making the diagnosis-to-treatment gap the dominant bottleneck. Among those who initiated (5,330), approximately 27% failed to persist through 12 months. The controlled-HbA1c stage is subject to measurement truncation — patients without a captured lab are misclassified as uncontrolled, so the 22.8% controlled rate is a lower bound.

(2) Practical interpretation. The cascade tells a payer or quality program where to invest first: closing the diagnosis-to-initiation gap (from 77.0% linked to 53.3% initiated) recovers more patients than improving persistence among those already on therapy. A single summary rate — "22.8% controlled" — hides this entirely. Any intervention targeting a specific transition should be evaluated against the stage-specific conditional rate, not the unconditional overall rate.

Worked example

Scenario

A regional health plan wants to know why its type 2 diabetes quality scores are low. An analyst pulls one year of records and builds a five-stage cascade for 10,000 adults with a new diabetes diagnosis, following them forward to see how many reach each checkpoint: diagnosed, linked to a primary-care provider, started on a diabetes medication, still taking that medication at 12 months, and finally recorded as having their blood sugar under control. The funnel table below shows what the data actually looked like.

Dataset

Population funnel table — each row is one cascade stage. n = patients who reached this stage. pct_of_prior = n divided by the count at the immediately preceding stage (rounded to one decimal).

stagenpct_of_prior
1. Diagnosed (starting population)10000
2. Linked to primary-care provider770077.0%
3. Started a diabetes medication533069.2%
4. Still taking medication at 12 months (persistent)389073.0%
5. Blood sugar recorded as controlled (HbA1c < 7%)228058.6%

Steps

  • Stage 1 is the whole cohort: 10,000 newly diagnosed patients. This is the denominator for all unconditional percentages.

  • Stage 2 — linkage: 7,700 of 10,000 patients had a visit with a primary-care provider within 180 days of diagnosis. That is 77.0% of the starting group; 2,300 patients (23.0%) never showed up to a follow-up visit.

  • Stage 3 — treatment initiation: of the 7,700 linked patients, 5,330 picked up a first diabetes prescription within 180 days. That is 69.2% of those linked (5,330 / 7,700 = 0.692). As a share of the original 10,000, only 53.3% ever started a medication (5,330 / 10,000).

  • Stage 4 — persistence: of the 5,330 who started a medication, 3,890 were still filling their prescription consistently at the 12-month mark (a 'proportion of days covered' of at least 80%). That is 73.0% of initiators (3,890 / 5,330 = 0.730). The remaining 1,440 patients (27%) stopped or had large gaps in their fills.

  • Stage 5 — control: of the 3,890 persistent patients, 2,280 had a blood-sugar lab result below the 7% target by end of year. That is 58.6% of persistent patients (2,280 / 3,890 = 0.586). Expressed as a share of the original 10,000 diagnosed patients, only 22.8% reached control (2,280 / 10,000).

  • Reading the funnel: the single biggest absolute loss happened at linkage (2,300 patients gone before they even saw a doctor) and at initiation (a further 2,370 linked patients never started a medication). Together those two steps shed 4,670 patients — more than all later stages combined. A health plan targeting adherence coaching would be optimizing the wrong stage.

Result

Of 10,000 diagnosed patients, 2,280 (22.8%) reached blood-sugar control. The arithmetic checks at every row: 10,000 × 0.770 = 7,700; 7,700 × 0.692 = 5,330; 5,330 × 0.730 = 3,891 (rounds to 3,890); 3,890 × 0.586 = 2,280. The largest single-stage absolute loss is at linkage (−2,300), and the largest conditional drop is also at linkage (only 77% of diagnosed patients ever saw a provider), making that the primary intervention target.

Runnable example

python implementation

Longitudinal cohort cascade from claims-style tables. Required inputs (cleaned, de-duplicated): dx : medical-claim diagnoses -> person_id, dx_date (datetime), is_inpatient (bool) # T2D phenotype source rx : pharmacy fills -> person_id, fill_date (datetime),...

import pandas as pd
import numpy as np

PRE_DAYS, POST_DAYS = 365, 365      # continuous-enrollment requirement around index
LINK_DAYS, INIT_DAYS = 180, 180     # windows for linkage and initiation
PDC_THRESHOLD, PERSIST_DAYS = 0.80, 365
GRACE_DAYS = 30                     # refill grace inside the persistence window
A1C_TARGET = 7.0

def _incident_index(dx: pd.DataFrame) -> pd.DataFrame:
    # Incident phenotype: 1 inpatient OR 2 outpatient diagnoses on separate days; index = first qualifying date.
    dx = dx.sort_values(["person_id", "dx_date"])
    ip = dx.loc[dx["is_inpatient"], ["person_id", "dx_date"]]
    op = dx.loc[~dx["is_inpatient"]].drop_duplicates(["person_id", "dx_date"])
    op2 = op.groupby("person_id").filter(lambda g: g["dx_date"].nunique() >= 2)
    qualifying = pd.concat([ip, op2[["person_id", "dx_date"]]])
    return (qualifying.groupby("person_id")["dx_date"].min()
                      .rename("index_date").reset_index())

def _enrolled(idx: pd.DataFrame, enroll: pd.DataFrame) -> pd.Series:
    # Require a single non-MA-only span covering [index - PRE_DAYS, index + POST_DAYS].
    e = enroll.merge(idx, on="person_id")
    covers = (~e["ma_only"] &
              (e["enroll_start"] <= e["index_date"] - pd.Timedelta(days=PRE_DAYS)) &
              (e["enroll_end"]   >= e["index_date"] + pd.Timedelta(days=POST_DAYS)))
    return e.loc[covers, "person_id"].drop_duplicates()

def build_cascade(dx, rx, visits, labs, enroll):
    idx = _incident_index(dx)
    idx = idx[idx["person_id"].isin(_enrolled(idx, enroll))].copy()

    # Stage 2: linked to PCP/endo within LINK_DAYS of index.
    v = visits[visits["pcp_or_endo"]].merge(idx, on="person_id")
    linked = v.loc[(v["visit_date"] >= v["index_date"]) &
                   (v["visit_date"] <= v["index_date"] + pd.Timedelta(days=LINK_DAYS)),
                   "person_id"].drop_duplicates()

    # Stage 3: first antidiabetic fill within INIT_DAYS of index; capture initiation date for the persistence window.
    r = rx[rx["antidiabetic"]].merge(idx, on="person_id")
    init = r.loc[(r["fill_date"] >= r["index_date"]) &
                 (r["fill_date"] <= r["index_date"] + pd.Timedelta(days=INIT_DAYS))]
    init_date = init.groupby("person_id")["fill_date"].min().rename("init_date")
    initiated = init_date.index

    # Stage 4: PDC >= threshold over PERSIST_DAYS after initiation (grace-extended days_supply, capped at 1.0).
    rp = rx[rx["antidiabetic"]].merge(init_date, on="person_id")
    win_end = rp["init_date"] + pd.Timedelta(days=PERSIST_DAYS)
    in_win = rp[(rp["fill_date"] >= rp["init_date"]) & (rp["fill_date"] < win_end)].copy()
    in_win["covered"] = (in_win["days_supply"] + GRACE_DAYS).clip(upper=PERSIST_DAYS)
    pdc = (in_win.groupby("person_id")["covered"].sum() / PERSIST_DAYS).clip(upper=1.0)
    persistent = pdc[pdc >= PDC_THRESHOLD].index

    # Stage 5: most recent HbA1c in the outcome window below target (denominator conditional on a captured lab).
    lab = labs.merge(idx, on="person_id")
    lab = lab[(lab["result_date"] > lab["index_date"]) &
              (lab["result_date"] <= lab["index_date"] + pd.Timedelta(days=POST_DAYS))]
    last_a1c = lab.sort_values("result_date").groupby("person_id")["a1c_value"].last()
    controlled = last_a1c[last_a1c < A1C_TARGET].index

    n_dx = idx["person_id"].nunique()
    stages = {
        "1_diagnosed":  idx["person_id"].unique(),
        "2_linked":     linked.values,
        "3_initiated":  initiated.values,
        "4_persistent": persistent.values,
        "5_controlled": controlled.values,
    }
    out, prior_n = [], n_dx
    for name, ids in stages.items():
        n = len(set(ids) & set(stages["1_diagnosed"]))  # keep within the eligible denominator
        out.append({"stage": name, "n": n,
                    "pct_of_diagnosed": n / n_dx,
                    "pct_of_prior": np.nan if prior_n == 0 else n / prior_n})
        prior_n = n
    return pd.DataFrame(out)
r implementation

Longitudinal cohort cascade with data.table. Inputs mirror the Python version: dx : person_id, dx_date (Date), is_inpatient (logical) rx : person_id, fill_date (Date), antidiabetic (logical), days_supply (integer) visits : person_id, visit_date (Date),...

library(data.table)

PRE_DAYS <- 365L; POST_DAYS <- 365L
LINK_DAYS <- 180L; INIT_DAYS <- 180L
PDC_THRESHOLD <- 0.80; PERSIST_DAYS <- 365L; GRACE_DAYS <- 30L; A1C_TARGET <- 7.0

build_cascade <- function(dx, rx, visits, labs, enroll) {
  setDT(dx); setDT(rx); setDT(visits); setDT(labs); setDT(enroll)

  # Incident phenotype: 1 inpatient OR 2 outpatient on separate days; index = earliest qualifying date.
  ip  <- dx[is_inpatient == TRUE, .(person_id, dx_date)]
  op2 <- unique(dx[is_inpatient == FALSE, .(person_id, dx_date)])
  op2 <- op2[, if (uniqueN(dx_date) >= 2L) .SD, by = person_id]
  idx <- rbind(ip, op2)[, .(index_date = min(dx_date)), by = person_id]

  # Non-MA-only continuous enrollment spanning [index - PRE, index + POST].
  e <- merge(enroll, idx, by = "person_id")
  ok <- e[ma_only == FALSE &
          enroll_start <= index_date - PRE_DAYS &
          enroll_end   >= index_date + POST_DAYS, unique(person_id)]
  idx <- idx[person_id %chin% ok]

  # Stage 2: linkage within LINK_DAYS.
  v <- merge(visits[pcp_or_endo == TRUE], idx, by = "person_id")
  linked <- v[visit_date >= index_date & visit_date <= index_date + LINK_DAYS, unique(person_id)]

  # Stage 3: first antidiabetic fill within INIT_DAYS; keep initiation date for the persistence window.
  r <- merge(rx[antidiabetic == TRUE], idx, by = "person_id")
  init <- r[fill_date >= index_date & fill_date <= index_date + INIT_DAYS,
            .(init_date = min(fill_date)), by = person_id]
  initiated <- init$person_id

  # Stage 4: PDC >= threshold over PERSIST_DAYS (grace-extended days_supply, capped at the window).
  rp <- merge(rx[antidiabetic == TRUE], init, by = "person_id")
  rp <- rp[fill_date >= init_date & fill_date < init_date + PERSIST_DAYS]
  rp[, covered := pmin(days_supply + GRACE_DAYS, PERSIST_DAYS)]
  pdc <- rp[, .(pdc = pmin(sum(covered) / PERSIST_DAYS, 1.0)), by = person_id]
  persistent <- pdc[pdc >= PDC_THRESHOLD, person_id]

  # Stage 5: most recent HbA1c in the outcome window below target.
  l <- merge(labs, idx, by = "person_id")
  l <- l[result_date > index_date & result_date <= index_date + POST_DAYS]
  setorder(l, person_id, result_date)
  last_a1c <- l[, .(a1c = a1c_value[.N]), by = person_id]
  controlled <- last_a1c[a1c < A1C_TARGET, person_id]

  n_dx <- idx[, uniqueN(person_id)]
  ids <- list(`1_diagnosed` = idx$person_id, `2_linked` = linked,
              `3_initiated` = initiated, `4_persistent` = persistent,
              `5_controlled` = controlled)
  prior <- n_dx
  res <- rbindlist(lapply(names(ids), function(nm) {
    n <- length(intersect(ids[[nm]], idx$person_id))
    row <- data.table(stage = nm, n = n,
                      pct_of_diagnosed = n / n_dx,
                      pct_of_prior = if (prior == 0) NA_real_ else n / prior)
    prior <<- n
    row
  }))
  res[]
}