Diagnosis Phenotype Algorithm (1 IP / 2 OP, Time Window)
A claims-based case-finding rule that classifies a patient as having a condition if they have at least one inpatient claim, or two outpatient claims on different service dates within a defined time window, carrying the relevant diagnosis code, whose performance (PPV, sensitivity, specificity) is condition-, position-, and data-source-specific and must be validated.
On this page
A diagnosis phenotype algorithm is a rule that combs through billing records to decide whether a patient actually has a given condition, rather than just having a code that was entered to rule something out. The '1 inpatient OR 2 outpatient' version says a patient counts as a case if they were admitted to the hospital with the relevant billing code at least once, OR if a doctor's office or outpatient clinic billed that code on at least two separate visits that are spread far enough apart in time.
The gap between the two outpatient visits and the overall time window both matter — without them, a single 'could this be condition X?' code from one afternoon visit would create a false case. Every such rule carries error: it will flag some patients who do not truly have the condition (false positives) and miss others who do (false negatives), so analysts always report how accurate the rule was when tested against actual medical records.
The 1 inpatient or 2 outpatient (1 IP / 2 OP) diagnosis phenotype is the workhorse case-finding rule of administrative-claims research. Because claims carry billing codes (ICD-9/10-CM) rather than clinical findings, a patient is classified as a case when they accumulate enough coded evidence to make a billing artifact an unlikely explanation: a single inpatient claim with the diagnosis (one institutional encounter that was adjudicated and paid), OR two outpatient claims with the diagnosis on different service dates within a pre-specified window.
The single-IP arm leans on the higher specificity of an admission diagnosis; the two-OP arm requires temporal corroboration so that a one-off "rule-out" or screening code does not by itself create a case. The window length encodes the disease's tempo: 7-30 days for acute events (MI, ischemic stroke, PE), 180-365 days for chronic conditions (atrial fibrillation, diabetes, COPD).
For incident (new-onset) phenotypes the rule is paired with a lookback washout (e.g., no qualifying code in the prior 365 days of continuous enrollment); without it the algorithm captures prevalent, not incident, disease.
Core conceptual distinction
. The rule is a measurement instrument with error, not a diagnosis. Three knobs trade positive predictive value (PPV) against sensitivity. (1) Setting / arm structure — 1 IP OR 2 OP balances the specificity of admissions against the sensitivity of outpatient capture; an IP-only rule raises PPV but misses outpatient-managed disease. (2) Code position — primary (principal) position reflects the reason for the encounter and is more specific; any-position captures comorbidity coding but is noisier and more vulnerable to copy-forward and rule-out codes.
(3) Window and washout — the OP-to-OP window and the incident washout jointly determine whether you measure new onset, and how much delayed-second-visit loss you accept. The two failure directions are not symmetric: false positives (rule-out/screening codes coded as confirmed disease) dilute toward the null in a comparative study, while differential misclassification by exposure (e.g., new initiators are seen more often and coded more completely) biases in an unpredictable direction.
Never treat a code as a clinical diagnosis without validating the algorithm in your own data source and population, and pre-specify position, window, and washout in the protocol before looking at outcomes.
Pros, cons, and trade-offs
.
- vs a broad single-code rule (1 claim, any position, no window): the 1 IP / 2 OP rule sharply cuts false positives from isolated rule-out and screening codes and is widely validated to PPV >80% for many conditions. Cost: it loses the few true cases who have only one outpatient code and die or disenroll before a second visit; it is more code to build. Prefer 1 IP / 2 OP for almost any outcome or covariate that drives an effect estimate.
- vs a high-specificity rule (IP-only, primary position, plus procedure/lab/drug confirmation): the standard rule has higher sensitivity and is easier to harmonize across databases. Cost: lower PPV than a confirmation-augmented rule. Prefer the high-specificity variant when a false positive is costly (e.g., a serious safety outcome that triggers a label change) and you can tolerate missing milder, outpatient-managed cases.
- vs EHR / registry phenotypes (problem lists, NLP on notes, registry adjudication): claims rules scale to millions of patients and decades of history and are consistent across payers if harmonized. Cost: lower clinical specificity than a chart-adjudicated or NLP-confirmed phenotype; they miss out-of-network and uncoded events. Prefer claims for large comparative studies, and use linked EHR/registry as the validation gold standard rather than the primary ascertainment source.
When NOT to use — and when it is actively misleading or dangerous
.
- No validation is feasible and PPV is plausibly low or differential. If the condition is heavily coded as "rule-out" (chest pain coded as MI before troponin returns; "screening for malignancy" before biopsy) and you cannot estimate PPV, an unvalidated rule can manufacture or erase an effect — quantitative bias analysis or a linked validation subset is mandatory before the estimate is trusted.
- The window or washout is misaligned with the disease tempo. A 30-day chronic-disease window or a too-short incident washout converts prevalent cases into "incident" ones, importing immortal time and survivor effects into the analysis.
- MA-only person-time is treated as observed. In Medicare Advantage, fee-for-service institutional and carrier claims are largely absent in legacy claims products; "no prior code" in MA-only spans is missingness, not a clean washout, and silently fabricates incident cases. Restrict the washout to FFS-observable (Parts A/B) enrollment.
- The algorithm differs across arms. Applying a different position/window/setting rule, or a different data source, to exposed vs comparator patients guarantees differential misclassification — the single most dangerous error here.
Data-source operational depth
.
- Claims (Medicare FFS / commercial): build from institutional/facility claims (use the discharge date for the IP arm) plus carrier/professional and outpatient-facility claims (use the service date for the OP arm). Deduplicate same-day OP claims so two line items on one visit count as one. Require continuous medical enrollment across the entire washout so absence of a prior code is observed, not unobserved. Failure modes: rule-out/screening codes inflate OP false positives (mitigate with primary position or a confirmatory procedure/drug); bundled or interim claims and later adjustments perturb counts (prefer final paid claims); MA-only person-time lacks FFS claims so washout and OP counting break (exclude or flag MA-only spans); differential competing risks — in elderly claims, patients who die before a second OP visit are missed for the 2-OP arm, and if death rates differ by exposure this is differential.
- EHR: the IP/OP distinction maps to encounter type; the "second OP claim" can be a second encounter or one encounter plus an active problem-list entry, and NLP on notes can confirm a coded diagnosis. Strength: clinical detail to adjudicate. Weakness: visit-driven capture — sicker, more-engaged patients are coded more completely, and care that leaks out of the system is invisible; link to claims for a complete encounter history.
- Registry: disease registries (SEER for cancer, stroke/MI registries) are the validation gold standard for PPV and for the cases the claims rule misses, and registry death data anchor competing-risk and censoring logic. Weakness: incomplete pharmacy/encounter timing — link to claims to apply the 1 IP / 2 OP rule and to recover timing.
- Linked claims-EHR / claims-registry: the ideal substrate (scale + adjudication), but probabilistic linkage error can be differential by severity (sicker patients link more reliably), biasing sensitivity estimates; quantify linkage error and reconcile service vs discharge vs registry diagnosis dates before assigning the index date.
Worked claims example (incident atrial fibrillation)
Question: incident AF ascertainment in a Medicare FFS + commercial database, ICD-10 I48.x (atrial fibrillation/flutter). Algorithm: 1 IP discharge diagnosis (any position) OR 2 OP claims on service dates ≥7 days apart within a 365-day window, with a 365-day incident washout (no I48.x in the prior year) and continuous Part A/B (or commercial medical) enrollment across that washout; same rule applied identically to every study arm. Suppose 5,000 patients have at least one I48.x code.
Applying the rule: 1,200 qualify on a single inpatient discharge code; of the remainder, 2,800 have ≥2 OP codes ≥7 days apart within 365 days (the other outpatient-only patients have a single isolated code — many of these are "rule-out" or pre-test codes and are correctly not counted). That leaves 4,000 algorithm-positive patients. Applying the 365-day incident washout drops 1,000 with a prior-year I48.x (prevalent), yielding 3,000 incident AF cases; index date = the discharge date (IP arm) or the first of the two qualifying OP dates (OP arm).
Production checks: exclude MA-only person-time before counting (otherwise "no prior code" is missingness and over-counts incident cases); flag that MA risk-adjustment HCC capture makes AF look more prevalent in MA than in FFS, so a multi-database pooled estimate must report algorithm performance by payer and sensitivity-test the window (30 vs 90 vs 365 days) and position (any vs primary).
Validate PPV in a chart-reviewed or registry-linked subset and report it with a 95% CI; payer-specific heterogeneity and differential ascertainment by exposure are the dominant threats to validity, not the rule itself.
Interpreting the output
. The output of the 1 IP / 2 OP algorithm is a classified patient list: each patient either meets the rule or does not, with an assigned index date. In the Medicare AF example, Patient 2201 qualifies via the 2-OP arm — two I48.x outpatient claims 42 days apart, both in primary position — with an index date at the first qualifying OP date. Patient 2202 qualifies via the 1-IP arm — one inpatient claim with I48.x as principal diagnosis — with index date at discharge.
After applying the 365-day incident washout, 3,000 patients remain as incident AF cases.
Formal interpretation: meeting the 1 IP / 2 OP rule means a patient accumulated billing evidence that makes a documentation artifact an unlikely sole explanation. It is not a clinical confirmation of atrial fibrillation. The rule's PPV — the fraction of flagged patients who truly had AF when charts were reviewed — must be reported alongside the result. Rule-out codes are the leading false-positive driver: an I48.x entered to justify a rhythm monitor that came back negative can satisfy the 1-IP arm on its own.
The 2-OP arm's 30-day separation requirement suppresses isolated rule-out codes; tightening or loosening that window is a planned sensitivity analysis, not a post-hoc fix.
Practical interpretation: report the algorithm's PPV and the window/position choices prominently in the methods, because those choices — not the underlying condition — largely determine the false-positive rate. For any regulatory or HTA submission, provide a chart-review validation substudy and a Wilson 95% confidence interval on the PPV estimate.
Decision diagram
flowchart TD
Pop[Enrollees with continuous medical enrollment<br/>FFS-observable across washout] --> Wash[Incident washout:<br/>no target code in prior 365 days]
Wash --> Arms{Case-finding rule}
Arms -->|1 inpatient discharge claim<br/>with target code| IP[IP arm qualifies<br/>index = discharge date]
Arms -->|2 outpatient claims, different<br/>service dates within window| OP[OP arm qualifies<br/>index = 2nd qualifying date]
Arms -->|Single isolated OP code<br/>e.g. rule-out / screening| Excl[Not a case]
IP --> Idx[Index date = earliest qualifying event]
OP --> Idx
Idx --> Valid[Validate PPV in chart-reviewed or<br/>registry-linked subset; report 95% CI]
Idx --> Sens[Apply identical rule to all arms;<br/>sensitivity on window / position / payer]gantt title 1 IP / 2 OP incident phenotype timeline (one patient, OP arm) dateFormat YYYY-MM-DD axisFormat %b %Y section Washout Continuous FFS enrollment, no target code :done, w, 2023-01-01, 2023-12-31 section Case-finding window First outpatient target code :milestone, op1, 2024-01-05, 0d Second outpatient code (>=7 days later, within 365d) :milestone, op2, 2024-02-10, 0d Index date = second qualifying OP date :crit, idx, 2024-02-10, 1d
Worked example
Scenario
We are building an incident atrial fibrillation (AF) cohort from a Medicare claims database using ICD-10 code I48.x. The rule is: qualify as an AF case if you have (a) at least one inpatient discharge claim with I48.x, OR (b) at least two outpatient claims with I48.x on different service dates that are at least 7 days apart and both fall within a 365-day case-finding window. We look at two patients. Patient 2201 has only outpatient visits; we trace her two AF codes to see whether and when she qualifies. Patient 2202 was hospitalized with AF; his single inpatient discharge code qualifies him immediately.
Dataset
Raw claim rows an analyst would see after filtering to I48.x codes. claim_type: IP = inpatient discharge, OP = outpatient service. service_date for IP rows already carries the discharge date.
| person_id | claim_type | service_date | dx_code | dx_position |
|---|---|---|---|---|
| 2201 | OP | 2024-01-08 | I48.11 | primary |
| 2201 | OP | 2024-02-19 | I48.11 | primary |
| 2202 | IP | 2024-03-05 | I48.19 | primary |
Steps
Result
Patient 2201: qualifies via the outpatient arm (2 OP claims, gap = 42 days, within 365-day window); index date = 2024-02-19. Patient 2202: qualifies via the inpatient arm (1 IP discharge claim); index date = 2024-03-05.
Trade-offs
Runnable example
1 IP / 2 OP incident phenotype construction from claims-style inputs. Required inputs (already cleaned, de-duplicated to one row per claim line): dx : diagnosis claims -> person_id, claim_type in {'IP','OP'}, service_date (datetime;
import pandas as pd
import numpy as np
TARGET_CODES = ("I48",) # ICD-10 prefix match for atrial fibrillation/flutter (I48.x)
OP_GAP_DAYS = 7 # minimum days between the two qualifying outpatient claims
OP_WINDOW = 365 # the two OP claims must fall within this many days of each other
WASHOUT_DAYS = 365 # incident clean period: no qualifying code in the prior year
def build_phenotype(dx: pd.DataFrame, enroll: pd.DataFrame,
any_position: bool = True) -> pd.DataFrame:
d = dx.copy()
d = d[d["dx_code"].str.startswith(TARGET_CODES)]
if not any_position:
d = d[d["dx_position"] == "primary"]
d = d.sort_values(["person_id", "service_date"])
# IP arm: a single inpatient claim qualifies; index = that discharge date.
ip = d[d["claim_type"] == "IP"].groupby("person_id", as_index=False)["service_date"].min()
ip = ip.rename(columns={"service_date": "ip_date"})
# OP arm: ANY pair of DIFFERENT service dates OP_GAP_DAYS..OP_WINDOW apart (sliding pair, not
# anchored to the first code) so qualifying pairs after day 365 are not missed.
op = d[d["claim_type"] == "OP"][["person_id", "service_date"]].drop_duplicates()
pairs = op.merge(op, on="person_id", suffixes=("_1", "_2"))
gap = (pairs["service_date_2"] - pairs["service_date_1"]).dt.days
qual = pairs[(gap >= OP_GAP_DAYS) & (gap <= OP_WINDOW)] # service_date_2 is the confirming code
op_idx = qual.groupby("person_id", as_index=False)["service_date_2"].min() # earliest confirming date
op_idx = op_idx.rename(columns={"service_date_2": "op_date"})
cand = ip.merge(op_idx, on="person_id", how="outer")
cand["index_date"] = cand[["ip_date", "op_date"]].min(axis=1) # earliest qualifying event
# Incident washout: drop anyone with a target code in [index - WASHOUT_DAYS, index).
prior = cand.merge(d[["person_id", "service_date"]], on="person_id")
in_wash = prior[(prior["service_date"] < prior["index_date"]) &
(prior["service_date"] >= prior["index_date"] - pd.Timedelta(days=WASHOUT_DAYS))]
cand = cand[~cand["person_id"].isin(in_wash["person_id"])]
# Continuous, FFS-observable enrollment across the washout through index (exclude MA-only spans).
e = enroll.merge(cand[["person_id", "index_date"]], on="person_id")
e["covers"] = ((e["enroll_start"] <= e["index_date"] - pd.Timedelta(days=WASHOUT_DAYS)) &
(e["enroll_end"] >= e["index_date"]) & (~e["ma_only"]))
eligible = e.loc[e["covers"], "person_id"].unique()
out = cand[cand["person_id"].isin(eligible)].copy()
return out[["person_id", "index_date"]].sort_values("person_id")1 IP / 2 OP incident phenotype construction with data.table. Inputs mirror the Python version: dx : person_id, claim_type ('IP'/'OP'), service_date (Date; IP carries discharge date), dx_code, dx_position enroll : person_id, enroll_start, enroll_end, ma_only (logical) Returns one row per incident case with index_date;
library(data.table)
TARGET <- "^I48" # ICD-10 atrial fibrillation/flutter (I48.x)
OP_GAP <- 7L # min days between the two qualifying OP claims
OP_WIN <- 365L # the two OP claims within this many days
WASHOUT <- 365L # incident clean period
build_phenotype <- function(dx, enroll, any_position = TRUE) {
setDT(dx); setDT(enroll)
d <- dx[grepl(TARGET, dx_code)]
if (!any_position) d <- d[dx_position == "primary"]
setorder(d, person_id, service_date)
# IP arm: single inpatient claim; index = earliest discharge date.
ip <- d[claim_type == "IP", .(ip_date = min(service_date)), by = person_id]
# OP arm: ANY pair of distinct service dates OP_GAP..OP_WIN apart (sliding pair, not anchored to the
# first code) so qualifying pairs after day 365 are not missed.
op <- unique(d[claim_type == "OP", .(person_id, service_date)])
pairs <- merge(op, op, by = "person_id", allow.cartesian = TRUE,
suffixes = c("_1", "_2"))
pairs[, gap := as.integer(service_date_2 - service_date_1)]
op_idx <- pairs[gap >= OP_GAP & gap <= OP_WIN,
.(op_date = min(service_date_2)), by = person_id] # earliest confirming date
cand <- merge(ip, op_idx, by = "person_id", all = TRUE)
cand[, index_date := pmin(ip_date, op_date, na.rm = TRUE)]
# Incident washout: drop anyone with a target code in [index - WASHOUT, index).
pr <- merge(cand[, .(person_id, index_date)], d[, .(person_id, service_date)], by = "person_id")
in_wash <- unique(pr[service_date < index_date &
service_date >= index_date - WASHOUT, person_id])
cand <- cand[!person_id %chin% in_wash]
# Continuous FFS-observable enrollment across washout through index (no MA-only spans).
e <- merge(enroll, cand[, .(person_id, index_date)], by = "person_id")
ok <- e[enroll_start <= index_date - WASHOUT &
enroll_end >= index_date & !ma_only, unique(person_id)]
cand[person_id %chin% ok, .(person_id, index_date)][order(person_id)]
}1 IP / 2 OP incident phenotype construction in SAS using PROC SQL. Required input datasets (post data-management, one row per claim line): work.dx : person_id, claim_type ('IP'/'OP'), service_date (IP carries the discharge date), dx_code, dx_position work.enroll : person_id, enroll_start, enroll_end, ma_only (0/1)...
%let target = I48; /* ICD-10 atrial fibrillation/flutter prefix */
%let op_gap = 7; /* min days between the two qualifying OP claims */
%let op_win = 365; /* the two OP claims within this many days */
%let washout = 365; /* incident clean period */
%let primary_only = 0; /* 1 = restrict to principal-position codes */
/* Restrict to target diagnosis codes (and optionally to primary position). */
proc sql;
create table dxsub as
select person_id, claim_type, service_date, dx_position
from work.dx
where dx_code like "&target.%"
and (&primary_only = 0 or dx_position = 'primary');
quit;
/* IP arm: a single inpatient claim qualifies; index = earliest discharge date. */
proc sql;
create table ip as
select person_id, min(service_date) as ip_date format=date9.
from dxsub where claim_type = 'IP'
group by person_id;
quit;
/* OP arm: ANY pair of distinct service dates &op_gap..&op_win apart (sliding pair, not anchored to the
first code) so qualifying pairs after day 365 are not missed. b is the earlier code, a the confirming code. */
proc sql;
create table op as select distinct person_id, service_date
from dxsub where claim_type = 'OP';
create table op_idx as
select a.person_id, min(a.service_date) as op_date format=date9. /* earliest confirming code */
from op a, op b
where a.person_id = b.person_id
and (a.service_date - b.service_date) >= &op_gap
and (a.service_date - b.service_date) <= &op_win
group by a.person_id;
quit;
/* Candidate index = earliest qualifying IP or OP event. */
proc sql;
create table cand as
select coalesce(i.person_id, o.person_id) as person_id,
min(i.ip_date, o.op_date) as index_date format=date9.
from ip i full join op_idx o on i.person_id = o.person_id;
quit;
/* Incident washout: exclude anyone with a target code in [index - &washout, index). */
proc sql;
create table incident as
select c.* from cand c
where not exists (
select 1 from dxsub p
where p.person_id = c.person_id
and p.service_date < c.index_date
and p.service_date >= c.index_date - &washout
);
quit;
/* Require continuous FFS-observable enrollment across the washout through index (no MA-only spans). */
proc sql;
create table cases as
select n.person_id, n.index_date from incident n
where exists (
select 1 from work.enroll e
where e.person_id = n.person_id
and e.ma_only = 0
and e.enroll_start <= n.index_date - &washout
and e.enroll_end >= n.index_date
);
quit;Citations
- [1]Djibo DA, Goldberg JF, McMahill-Walraven CN, et al. Validation of an ICD-10 case-finding algorithm for endometrial cancer in US insurance claims. Pharmacoepidemiology and Drug Safety. 2023.
- [2]Cutrona SL, Toh S, Iyer A, et al. Design for validation of acute myocardial infarction cases in Mini-Sentinel. Pharmacoepidemiology and Drug Safety. 2012;21(S1):274-281.
- [3]Quan H, Sundararajan V, Halfon P, et al. Coding algorithms for defining comorbidities in ICD-9-CM and ICD-10 administrative data. Medical Care. 2005;43(11):1130-1139.