As-Treated Risk Window Construction
The exposure-definition rule that converts dispensing or administration records into on-treatment follow-up time by stitching supply intervals, applying grace periods and carryover, and censoring person-time when treatment stops or switches, so that risk is attributed only while the drug is plausibly acting.
On this page
An as-treated risk window marks exactly the days a patient had a drug on hand and counts only those days when attributing side effects or outcomes to the drug. You project each prescription fill forward by the number of days it is supposed to last, stitch consecutive fills together when a new fill arrives before the previous one runs out (or within a short grace period), and close the window when the patient goes too long without refilling. Any event that happens inside the window is charged to the drug; any event outside the window — after the patient has stopped — is not, because the drug was no longer acting. The method cannot see cash-paid fills or free samples, so gaps in the data may look like the patient stopped when they actually kept taking the drug.
As-treated (on-treatment) risk window construction
is the operational step that turns a stream of exposure records into time at risk: the spans of follow-up during which a patient is counted as actively exposed. It answers three coupled questions — when does exposed person-time start (almost always at the index fill/order, i.e. time zero), when is a patient still on treatment (supply-interval stitching, grace periods, carryover of oversupply), and when does exposed person-time end (run-out, discontinuation, switch, or the structural censoring events of disenrollment, death, and end of data).
It is the engine behind an as-treated / per-protocol estimand and is distinct from an intention-to-treat (ITT) / first-line analysis that attributes all post-initiation follow-up to the initial drug regardless of later behavior.
Core conceptual / estimand distinction
The risk-window rule is the estimand made concrete. Under ITT you count outcomes for the whole observation window from time zero; the rule is trivial (one window per person) but the contrast is the effect of starting a strategy and dilutes with discontinuation and switching.
Under as-treated you censor (or split) person-time when the patient leaves the protocol, so the contrast approaches the effect of staying on the drug — but only validly if you weight for the informative censoring that discontinuation/switching induce (inverse-probability-of-censoring weighting, IPCW). A naive as-treated analysis with no IPCW silently conditions on staying treated, which is a post-baseline variable on the causal pathway, and is biased whenever prognosis predicts who stays.
The window rule also fixes the lag/induction structure — whether the first N days after initiation are "at risk" (acute outcomes) or excluded (latency for chronic outcomes) — and whether an outcome during a grace-period extension or a post-discontinuation "legacy" window still counts (carryover, depletion of the pharmacologic effect).
Pros, cons, and trade-offs
- vs intention-to-treat / first-line attribution: As-treated targets the biologically interpretable on-treatment effect, recovers dose-response and acute toxicities that ITT washes out, and matches the labeling question "what happens while a patient takes this." Cost: it requires careful episode logic and IPCW; done naively it reintroduces selection bias and is less defensible than a clean ITT. Prefer ITT for the policy/adherence question and as the primary in a target-trial emulation; prefer as-treated as the per-protocol companion or when the mechanism is acute and on-treatment timing dominates.
- vs a single fixed risk window (e.g. "90 days after the index fill" for everyone): A fixed window is simple, immune to gap-rule arbitrariness, and standard for acute, single-dose, or vaccine-style exposures. Cost: it misclassifies person-time for chronic refilled therapy — patients who refilled for two years get 90 days, patients who stopped at day 10 get 90 days. Prefer fixed windows for acute/one-shot exposures and as a sensitivity analysis; prefer stitched as-treated windows for chronic, refillable drugs.
- vs current-vs-former-vs-never time-updated exposure (the fuller g-method machinery): Time-updated exposure with a marginal structural model handles time-varying confounding affected by prior treatment that as-treated censoring cannot. Cost: far heavier specification and data demands. Prefer the as-treated window when discontinuation is not strongly confounded by evolving prognosis; escalate to time-updated/MSM when it is.
- Grace period is the central nuisance parameter. Too short and you create artifactual gaps, fragment one true episode into many, and manufacture immortal/"unexposed" time between fills; too long and you carry exposure status far past the last pill, misattributing late events to a drug no longer present. The grace period must be pre-specified and varied in sensitivity analysis — it is the single choice most likely to move a hazard ratio.
When NOT to use — and when it is actively misleading or dangerous
- When discontinuation/switching is strongly driven by evolving prognosis and you do not weight for it. Sick patients stop drugs (or are stopped by clinicians); naive as-treated censoring then makes the remaining on-treatment time look healthier than it is — a textbook healthy-adherer / informative-censoring bias. If you cannot estimate censoring weights, an ITT primary is safer and more honest.
- For chronic-effect or carcinogenic outcomes with long latency. Counting only current on-treatment time, with no induction lag and no legacy window, biases toward the null because the relevant etiologic exposure occurred years earlier. Use exposure-lag/induction windows or cumulative-dose metrics instead.
- In procedure or hospitalization studies where the window is anchored to a future event. Defining "exposed" time using post-index information (e.g., counting the days until a procedure that only treated patients receive) creates immortal time — guaranteed event-free survival assigned to the exposed arm. The window must be built only from information available at its start.
- In data that cannot observe the off-drug state. If supply, stop dates, or enrollment are not reliably captured, a "discontinuation" is indistinguishable from data loss and the window boundaries are noise.
Data-source operational depth
- Claims (FFS): Exposure spans come from pharmacy claims (`ndc`, `fill_date`, `days_supply`). The standard construction: sort fills per `person_id`, project each fill forward by `days_supply`, and if the next `fill_date` falls within the supply end plus the grace period, stitch the two into one continuous episode; otherwise close the episode at `supply_end + grace` (or at the last supply end). Carryover/stockpiling: when an early refill overlaps unused supply, shift the new supply start to the prior supply end so on-hand days accumulate (capped to avoid implausible hoarding). Failure modes and workarounds: (1) Medicare Advantage / capitated person-time lacks FFS pharmacy claims — an MA enrollee's "gap" is missingness, not discontinuation; restrict to Parts A/B/D (or commercial pharmacy benefit) and exclude MA-only spans. (2) 90-day mail-order and sample fills distort `days_supply`, lengthening or hiding episodes. (3) Inpatient days suppress outpatient pharmacy claims even though the drug is administered; bridge known inpatient stays so they are not scored as gaps. (4) Last fill near death leaves leftover supply that should be censored at death, not carried forward.
- EHR: Exposure is the order or administration, not a paid claim; an active prescription with no fill is not on-treatment. e-Prescribing and medication-administration records help, but external-care leakage (fills at pharmacies outside the system) makes apparent discontinuation unreliable; link to dispensing where possible and treat loss to follow-up as potentially informative. Encounter-driven capture means the absence of a stop note is not evidence of continued use.
- Registry: Often records treatment lines or start/stop at adjudicated visits but rarely day-level supply; derive coarse windows from visit-anchored start/stop and link to claims for granular refill stitching.
- Linked claims–EHR–vital records: The ideal substrate — EHR start dates + claims fill completeness + a death index for the right-censoring boundary — but order/fill/service-date discrepancies must be reconciled before the window is built, or episodes will start and end on the wrong dates.
Competing risks within the window
In elderly claims populations, death is a frequent and differentially distributed competing event: an exposure that delays death lengthens on-treatment person-time and inflates the observed rate of any non-fatal outcome. Decide explicitly whether death censors the window (cause-specific) or is a competing event (subdistribution); a cause-specific window with differential mortality by arm can mislead.
Worked claims example
Question: rate of major GI bleeding while on low-dose aspirin in a Medicare FFS + commercial cohort. (1) Eligibility / time zero: first aspirin fill (`index_date`) after ≥365 days continuous A/B/D (or commercial medical+pharmacy) enrollment with no aspirin fill in the lookback (incident user). (2) Window start: day after `index_date` (or `index_date` itself, pre-specified); apply a 1-day induction so a bleed coded on the index day is not attributed to a drug not yet taken.
(3) Stitching: for each subsequent fill, `supply_end = fill_date + days_supply`; if the next `fill_date <= supply_end + 30` (30-day grace), continue the episode and, if `fill_date < supply_end`, carry the unused days forward (cap total on-hand at 90 days). (4) Window end (censor exposed time at the earliest of): last `supply_end + 30`-day grace run-out (discontinuation), switch to a different antiplatelet (NDC change), disenrollment, death, or end of data. (5) Bridging: any inpatient stay overlapping a stitched episode is treated as on-treatment (drug administered in hospital), not a gap.
(6) Outcome: first inpatient claim with a primary GI-bleed `dx` during open on-treatment person-time; person-time and events outside open windows are excluded (or contribute to an "off-treatment" comparison group). (7) Sensitivity: rerun with grace = 0/15/60 days, induction = 0/7 days, a 30-day post-discontinuation legacy window, and IPCW for informative discontinuation; report how the rate and any comparative HR move with each.
Decision diagram
flowchart TD
F[Pharmacy fills: fill_date + days_supply] --> P[Project each fill to supply_end]
P --> G{Next fill within<br/>supply_end + grace?}
G -->|Yes, same drug| S[Stitch: carry unused supply forward<br/>capped at CARRYOVER_CAP]
G -->|No, or switch| C[Close episode at<br/>supply_end + grace]
S --> P
C --> H[Right-censor at earliest of:<br/>disenroll / death / data end]
H --> W[On-treatment risk window]
W --> A[Attribute outcomes + person-time<br/>only inside open windows]
A --> Z[Sensitivity: vary grace / induction /<br/>legacy window / IPCW]gantt title On-treatment windows for one patient (claims) dateFormat YYYY-MM-DD axisFormat %b %Y section Episode 1 (drug A) Fill 1 supply (30d) :done, f1, 2024-01-01, 30d Fill 2 within grace (stitched) :done, f2, 2024-02-05, 30d Grace tail (run-out + 30d) :active, g1, 2024-03-06, 30d section Gap (off-treatment, censored) No fill > grace -> episode closed :crit, gap, 2024-04-05, 60d section Episode 2 (drug A restart) Fill 3 new episode :done, f3, 2024-06-04, 30d
Worked example
Scenario
Patient 2001 is newly started on metoprolol (a blood pressure pill) on January 1, 2024. She fills it twice before stopping. We want to know which days count as 'at risk' — meaning the drug was plausibly in her system — so we can correctly attribute a heart-rate event to the drug only if it happened while she was actually taking it. We use a 30-day grace period: if her next fill arrives within 30 days of her supply running out, the two fills are joined into one unbroken risk window.
Dataset
Pharmacy claims rows for patient 2001 — exactly the columns an analyst sees in a real table.
| person_id | fill_date | drug | days_supply |
|---|---|---|---|
| 2001 | 2024-01-01 | metoprolol | 30 |
| 2001 | 2024-02-15 | metoprolol | 30 |
Steps
Result
105 on-treatment days; 1 event inside the window; incidence rate = 9.5 events per 1,000 person-days. Event B (May 1) does not contribute because the risk window closed on Apr 14.
Trade-offs
Runnable example
Build as-treated on-treatment risk windows from claims-style pharmacy fills. Required inputs (cleaned, de-duplicated): rx : person_id, fill_date (datetime), ndc/drug_class, days_supply (int) censor : person_id, disenroll_date, death_date, data_end (datetime;
import pandas as pd
import numpy as np
GRACE_DAYS = 30 # permissible gap between fills before an episode is closed
CARRYOVER_CAP = 90 # max stockpiled on-hand days (guards against implausible hoarding)
def build_at_windows(rx: pd.DataFrame, censor: pd.DataFrame) -> pd.DataFrame:
rx = rx.sort_values(["person_id", "fill_date"]).copy()
episodes = []
for pid, g in rx.groupby("person_id"):
# Earliest structural censoring date for this person (NaT-safe min).
c = censor.loc[censor["person_id"] == pid, ["disenroll_date", "death_date", "data_end"]]
hard_stop = c.min(axis=1).min() if len(c) else pd.NaT
ep_start = None
on_hand_end = None # running supply-end including carried-over days
ep_drug = None
for _, f in g.iterrows():
start = f["fill_date"]
supply_end = start + pd.Timedelta(days=int(f["days_supply"]))
if ep_start is None:
ep_start, on_hand_end, ep_drug = start, supply_end, f["drug_class"]
continue
# Switch closes the current episode at run-out + grace.
switched = f["drug_class"] != ep_drug
within_grace = start <= on_hand_end + pd.Timedelta(days=GRACE_DAYS)
if within_grace and not switched:
# Carry forward unused supply (stockpiling), capped.
base = max(on_hand_end, start)
on_hand_end = min(base + pd.Timedelta(days=int(f["days_supply"])),
start + pd.Timedelta(days=CARRYOVER_CAP))
else:
episodes.append((pid, ep_drug, ep_start,
on_hand_end + pd.Timedelta(days=GRACE_DAYS)))
ep_start, on_hand_end, ep_drug = start, supply_end, f["drug_class"]
if ep_start is not None:
episodes.append((pid, ep_drug, ep_start,
on_hand_end + pd.Timedelta(days=GRACE_DAYS)))
out = pd.DataFrame(episodes,
columns=["person_id", "drug_class", "episode_start", "episode_end"])
# Right-censor every episode end at the structural stop (disenroll / death / data end).
out = out.merge(
censor.assign(hard_stop=censor[["disenroll_date", "death_date", "data_end"]].min(axis=1))
[["person_id", "hard_stop"]],
on="person_id", how="left")
out["episode_end"] = out[["episode_end", "hard_stop"]].min(axis=1)
out = out[out["episode_end"] > out["episode_start"]] # drop empty windows
return out.drop(columns="hard_stop").reset_index(drop=True)As-treated on-treatment windows with data.table. Inputs mirror the Python version: rx : person_id, fill_date (Date), drug_class, days_supply (integer) censor : person_id, disenroll_date, death_date, data_end (Date; NA allowed) Returns one row per on-treatment episode, right-censored at the earliest structural stop.
library(data.table)
GRACE_DAYS <- 30L
CARRYOVER_CAP <- 90L
build_at_windows <- function(rx, censor) {
setDT(rx); setDT(censor)
setorder(rx, person_id, fill_date)
one_person <- function(g) {
ep_start <- on_hand_end <- ep_drug <- NULL
eps <- list()
for (i in seq_len(nrow(g))) {
start <- g$fill_date[i]
supply_end <- start + g$days_supply[i]
if (is.null(ep_start)) {
ep_start <- start; on_hand_end <- supply_end; ep_drug <- g$drug_class[i]; next
}
switched <- g$drug_class[i] != ep_drug
within_grace <- start <= on_hand_end + GRACE_DAYS
if (within_grace && !switched) {
base <- max(on_hand_end, start) # carry unused supply forward
on_hand_end <- min(base + g$days_supply[i], start + CARRYOVER_CAP)
} else {
eps[[length(eps) + 1L]] <- list(ep_drug, ep_start, on_hand_end + GRACE_DAYS)
ep_start <- start; on_hand_end <- supply_end; ep_drug <- g$drug_class[i]
}
}
eps[[length(eps) + 1L]] <- list(ep_drug, ep_start, on_hand_end + GRACE_DAYS)
data.table(drug_class = vapply(eps, `[[`, "", 1L),
episode_start = as.Date(vapply(eps, function(e) as.numeric(e[[2L]]), 0), origin = "1970-01-01"),
episode_end = as.Date(vapply(eps, function(e) as.numeric(e[[3L]]), 0), origin = "1970-01-01"))
}
out <- rx[, one_person(.SD), by = person_id]
hs <- censor[, .(hard_stop = pmin(disenroll_date, death_date, data_end, na.rm = TRUE)),
by = person_id]
out <- merge(out, hs, by = "person_id", all.x = TRUE)
out[!is.na(hard_stop), episode_end := pmin(episode_end, hard_stop)]
out[episode_end > episode_start, .(person_id, drug_class, episode_start, episode_end)]
}As-treated on-treatment windows in SAS via PROC SQL + DATA step. Required input datasets (post data-management): work.rx : person_id, fill_date, drug_class, days_supply work.censor : person_id, disenroll_date, death_date, data_end (missing allowed) The DATA step uses BY-group retain logic to stitch fills within the...
%let grace = 30;
%let cap = 90;
proc sort data=work.rx; by person_id fill_date; run;
/* Stitch fills into on-treatment episodes with grace period + capped carryover. */
data episodes;
set work.rx;
by person_id;
retain ep_start on_hand_end ep_drug;
format ep_start episode_end date9.;
supply_end = fill_date + days_supply;
if first.person_id then do;
ep_start = fill_date; on_hand_end = supply_end; ep_drug = drug_class;
end;
else do;
if fill_date <= on_hand_end + &grace and drug_class = ep_drug then do;
/* within grace, same drug: carry unused supply forward, capped */
base = max(on_hand_end, fill_date);
on_hand_end = min(base + days_supply, fill_date + &cap);
end;
else do;
/* gap exceeded or switch: emit the closed episode, then open a new one */
drug_class_out = ep_drug; episode_start = ep_start;
episode_end = on_hand_end + &grace; output;
ep_start = fill_date; on_hand_end = supply_end; ep_drug = drug_class;
end;
end;
if last.person_id then do;
drug_class_out = ep_drug; episode_start = ep_start;
episode_end = on_hand_end + &grace; output;
end;
keep person_id drug_class_out episode_start episode_end;
rename drug_class_out = drug_class;
run;
/* Right-censor each episode at the earliest of disenrollment / death / data end. */
proc sql;
create table at_windows as
select e.person_id, e.drug_class, e.episode_start,
min(e.episode_end,
coalesce(c.disenroll_date, e.episode_end),
coalesce(c.death_date, e.episode_end),
coalesce(c.data_end, e.episode_end)) as episode_end format=date9.
from episodes e
left join work.censor c on e.person_id = c.person_id
having calculated episode_end > e.episode_start;
quit;Citations
- [1]Ray WA. Evaluating medication effects outside of clinical trials: new-user designs. American Journal of Epidemiology. 2003;158(9):915-920.
- [2]Suissa S. Immortal time bias in pharmacoepidemiology. American Journal of Epidemiology. 2008;167(4):492-499.
- [3]Hernán MA, Robins JM. Using big data to emulate a target trial when a randomized trial is not available. American Journal of Epidemiology. 2016;183(8):758-764.
- [4]Vrijens B, De Geest S, Hughes DA, et al. A new taxonomy for describing and defining adherence to medications. British Journal of Clinical Pharmacology. 2012;73(5):691-705.