Cost Outlier Handling (Winsorization, Trimming, Robust/Two-Part Models)
A pre-specified strategy for limiting the influence of extreme high-cost observations on real-world cost estimands using percentile winsorization, dollar-threshold trimming, and distribution-aware models (two-part, gamma-log GLM, quantile/robust regression) rather than untransformed means or OLS.
In plain language
Healthcare cost data almost always includes a small number of patients with catastrophic bills — a transplant, a prolonged ICU stay, a rare disease therapy — that can drag the average cost sky-high and make it look wildly different from what most patients actually cost. Cost outlier handling is a pre-planned rule for deciding what to do with those extreme values before you run any analysis. The two most common tools are winsorization, which caps extreme costs at a chosen ceiling rather than deleting the patient, and trimming, which removes those patients entirely and studies a different, less-severe group. Choosing and documenting your rule in advance keeps your results stable and reviewable — a mean that swings 40% depending on whether one patient is included is not a trustworthy number.
Healthcare cost data are right-skewed, heavy-tailed, and zero-inflated: a small fraction of patients (transplants, CAR-T, prolonged ICU stays, rare catastrophic complications) generate a disproportionate share of total spend, and a single $1–2M claimant can move an unadjusted mean, a per-member-per-month (PMPM) figure, or an OLS coefficient by tens of percent. Cost outlier handling is the pre-specified rule set that bounds the leverage of these observations so that the reported estimand is stable across resamples, databases, and minor cohort-definition changes. It is an operational HEOR decision that belongs in the protocol and statistical analysis plan (SAP) before the data are seen, not a post-hoc reaction to an ugly histogram.
Core conceptual distinction
. Three distinct levers are conflated under "outlier handling," and they change different things. (1) Winsorization replaces values beyond a cap (e.g., the 99th percentile) with the cap value — it keeps the sample size and patient identity intact, shrinks the mean and especially the variance, and biases the estimated mean downward by construction. (2) Trimming/exclusion deletes observations beyond a threshold — it changes the target population and the estimand (you are now estimating cost in a non-catastrophic subpopulation), and should be treated as a redefinition of the cohort, not a cleaning step. (3) Distribution-aware modeling (two-part models, gamma or inverse-Gaussian GLM with a log link, quantile regression, robust M-estimation) keeps every dollar but down-weights the influence of the tail through the likelihood and link rather than by editing data. These are not interchangeable: a winsorized mean answers "what is mean cost if the most extreme spend is capped?"; a gamma-GLM marginal mean answers "what is expected cost given the full skewed distribution?"; a trimmed mean answers a different population's question entirely. The estimand must name which one is primary. A separate, frequently confused choice is the scale: OLS on log-cost requires retransformation (smearing or a heteroscedasticity-consistent correction) to recover the arithmetic-mean cost that budget-impact and ICER calculations need, whereas a gamma-log GLM models the mean directly and sidesteps the retransformation problem.
Pros, cons, and trade-offs
. - Percentile winsorization vs no handling (raw mean / OLS). Winsorization buys reproducibility and finite-sample variance reduction at the cost of a known, deliberate downward bias in the mean and an understated tail. Raw means are unbiased in expectation but so unstable that two random halves of the same cohort can disagree by 20–40%, and OLS coefficients are dominated by leverage points. Prefer winsorization as a transparent sensitivity layer, never as the sole primary analysis for a mean that feeds a budget-impact model, because the capped mean understates true spend. - Winsorization vs trimming/exclusion. Winsorization preserves N and the estimand's population; trimming silently redefines the population and discards the very patients (the most severe, the index-treatment failures) who often drive the policy question. Prefer winsorization or modeling over trimming unless there is a documented data-error rationale (e.g., a duplicate adjudication, an implausible negative or $10M keying error) — clinical extremity is not a data error. - Winsorization vs two-part / gamma-log GLM. GLMs use all data, respect the cost scale, and give the arithmetic-mean estimand directly, but they impose distributional/variance assumptions (the mean–variance relationship of the gamma) that must be checked (modified Park test, Pregibon link test, Pearson/deviance residuals). Winsorization is assumption-light and trivially explained to reviewers but biases the mean. Prefer the two-part + gamma-log GLM for inferential/comparative cost analyses (incremental cost, adjusted PMPM) and reserve winsorization for descriptive tables and as a robustness check on the GLM. - GLM vs OLS-on-log-cost. Log-OLS handles skew but forces retransformation; under heteroscedastic residuals (the norm in cost data) Duan's smearing factor must itself be made covariate-specific or the back-transformed mean is biased. The gamma-log GLM avoids this. Prefer the GLM unless the log-residuals are demonstrably homoscedastic.
When to use
. Any RWE/HEOR analysis whose endpoint is a dollar amount — total cost of care, disease-attributable cost, PMPM/PPPM/PPPY, incremental cost in a cost-effectiveness or budget-impact analysis, cost-of-illness burden — where the empirical distribution is right-skewed and the conclusion could plausibly flip if one to a few claimants were reweighted. Pre-specify the primary handling (typically a two-part + gamma-log GLM for comparative work, or a clearly labeled winsorized/raw descriptive mean) and at least two sensitivity rules (e.g., none / 95th / 99th winsor, or GLM with vs without tail winsorization).
When NOT to use — and when it is actively misleading or dangerous
. - As silent data cleaning that the reader never sees. Winsorizing or trimming without reporting the dollar caps, the percent of patients affected, and the percent of total spend removed is the single most common HTA review flag; it can turn a real cost difference into a manufactured null (or vice versa). - Trimming high-cost patients before a comparative contrast when they cluster in one arm. If the new therapy's failures land in the ICU and you trim the tail, you delete the harm you were measuring. Differential tail mass by arm means trimming is confounding by design. - Capping the mean that feeds a budget-impact model. Payers need expected (arithmetic-mean) spend including the tail; a winsorized mean systematically understates the budget and is the wrong estimand for that decision, however appropriate it is for a stable descriptive table. - Treating clinical extremity as error. A verified $1.4M CAR-T-plus-complications episode is signal, not noise. Outlier rules manage leverage; they do not license deletion of real, adjudicated spend. - Winsorizing on a contaminated denominator. If person-time is mismeasured (Medicare Advantage gaps, partial-year enrollment), percentile caps computed on annualized cost can be driven by a denominator artifact, not true high spend.
Data-source operational depth
. - Administrative claims (FFS vs MA). Cost = adjudicated allowed/paid amount on each line, summed over the analytic window. The dominant failure mode is Medicare Advantage person-time: MA encounter records do not carry reliable paid amounts (capitated/bundled), so an MA member appears artificially low-cost and deflates the percentile caps and the mean for everyone. Restrict cost analyses to fee-for-service-observable person-time (Parts A/B with no MA months, or commercial fully-insured/ASO with complete paid amounts) before computing any cap. Adjudication lag and claim reversals create transient negatives and double counts — net reversals and impose a claims-runout window before freezing the denominator. Bundled/DRG and global-surgery payments mean a single line can legitimately be $80k; decide whether the bundle or its shadow-priced components are the unit being winsorized. Annualize to PPPY only on FFS-observable months and winsorize the annualized value, not the raw window total, when follow-up is incomplete. - EHR. Native EHR "cost" is usually charges or the chargemaster, not paid/allowed — charge-to-cost ratios vary 3–8× across service lines, so winsorizing charges answers a question no payer asks. Link to claims for true paid amounts before any outlier rule. Encounter-driven capture also makes external high-cost events (an out-of-network ICU admission) invisible, which can masquerade as a low-cost patient and distort the tail in the opposite direction from the MA problem. - Registry. Registries rarely carry complete spend but excel at clinical severity and stage; use them to adjudicate whether a high-cost patient is clinically justified (retain) versus a coding/linkage artifact (correct), and link to claims for the actual dollars and to a death index so end-of-life cost spikes are not censored away. - Linked claims–EHR–registry. The ideal substrate (EHR/registry severity to justify the tail + claims completeness + mortality), but linkage selects the linkable subset and introduces date-discrepancy issues; a competing risk that is differential by exposure — death is more common, and end-of-life is the costliest period, in the sicker arm — will bias any naive mean or winsorized mean if mortality and its terminal-care costs are handled inconsistently across arms.
Worked claims example
Question: 24-month all-cause and disease-attributable total cost of care after initiating Drug A vs Drug B in a commercial + Medicare FFS database. (1) Denominator integrity: require 12 months continuous, FFS-observable medical+pharmacy enrollment before `index_date` and follow each patient up to 24 months, censoring at disenrollment, an MA-enrollment month (MA person-time is dropped because paid amounts are unreliable), death, or end of data; apply a 3-month claims-runout window and net all reversals before summing `paid_amt`. (2) Attribution first, then outliers: compute all-cause total cost and, separately, disease-attributable cost (claims with the qualifying diagnosis in any position) — the outlier rule is applied to each resulting series independently, because a $900k unrelated trauma admission belongs in all-cause but not in attributable. (3) Describe the tail: the top 2% of patients (n≈40 of 2,000) hold 31% of all-cause spend; the 95th percentile cap = $148,200 and the 99th = $612,500. (4) Primary descriptive analysis: report the raw arithmetic mean (with its instability flagged) alongside a 99th- percentile-winsorized mean, stating that winsorization reduced the all-cause mean PPPY from $41,300 to $35,800 (−13.3%), affected 1.0% of patients, and removed 13.3% of total spend. (5) Primary comparative analysis: a two-part model — a logistic model for the probability of any positive cost (near-universal here, so the second part dominates) and a gamma GLM with log link for positive cost — adjusted by the propensity score (1:1 matching or overlap weighting on baseline covariates measured in the 12-month lookback), yielding an adjusted incremental cost with a bias-corrected bootstrap CI. (6) Sensitivity: repeat the GLM with the positive tail winsorized at the 99th and at the 95th percentile, and as OLS-on-log with covariate-specific smearing retransformation; report whether the sign and significance of the incremental cost are stable. (7) Reporting (CHEERS): state the primary estimand (adjusted arithmetic-mean incremental cost), the exact dollar caps, the percent of patients and spend affected, and the full sensitivity grid.
Interpreting the output
A cost analysis reports a raw arithmetic mean of $52,920 per patient. One patient (1 of 10, or 10% of the sample) had a $500,000 catastrophic claim. After applying a 90th-percentile winsorization cap of $4,500, the winsorized mean drops to $3,370 — a reduction of more than 93% from the unadjusted figure.
(1) Formal interpretation. The raw mean ($52,920) and the winsorized mean ($3,370) estimate different quantities. The raw mean is an unbiased estimator of the true population mean under this cost distribution — including the right tail — but it is highly sensitive to a single catastrophic claimant whose $500,000 cost drives roughly half of all spend in the sample. The winsorized mean replaces values above $4,500 with $4,500, reducing variance substantially but biasing the estimated mean downward by construction. Winsorization does not remove the patient; it changes the estimand to "cost in a population where tail events are capped." The $500,000 claim is real spend, not a data error — trimming it entirely would change the study population, not just the statistic.
(2) Practical interpretation. When presenting cost results to a payer, report both figures and state explicitly what the winsorization rule did: "Winsorizing at the 90th-percentile cap of $4,500 affected 1 of 10 patients (10%) and reduced the mean by 93.6%. The cap was pre-specified in the SAP." This transparency allows the reviewer to judge which estimate best matches their population's risk-pooling and avoids the appearance of cherry-picking.
Worked example
Scenario
A researcher studying 10 patients who were hospitalized for a serious infection wants to report average 30-day all-cause costs. Nine patients had routine recoveries, but one patient developed a rare complication and required a prolonged ICU stay and a second surgery, resulting in a $500,000 bill. The researcher wants to know how much that single catastrophic case distorts the average — and what the average looks like after winsorizing at the 90th percentile.
Dataset
30-day all-cause costs for 10 patients from a claims database. Each row is one patient.
| patient_id | total_cost_usd |
|---|---|
| P01 | 2800 |
| P02 | 3100 |
| P03 | 4200 |
| P04 | 2600 |
| P05 | 3900 |
| P06 | 4500 |
| P07 | 2300 |
| P08 | 3700 |
| P09 | 2100 |
| P10 | 500000 |
Steps
Add up all 10 costs: 2800 + 3100 + 4200 + 2600 + 3900 + 4500 + 2300 + 3700 + 2100 + 500000 = 529,200.
Divide by 10 to get the raw arithmetic mean: 529,200 / 10 = $52,920. This is almost entirely driven by P10 — none of the other nine patients spent anywhere near that much.
To winsorize at the 90th percentile, sort the 10 costs from lowest to highest: $2,100 / $2,300 / $2,600 / $2,800 / $3,100 / $3,700 / $3,900 / $4,200 / $4,500 / $500,000.
Using the nearest-rank rule, the 90th percentile of 10 values is the 9th value in that sorted list: $4,500. That becomes the cap.
Replace P10's cost ($500,000) with the cap ($4,500). The other nine patients are unchanged because all of their costs are at or below $4,500.
Add up the 10 winsorized costs: 2800 + 3100 + 4200 + 2600 + 3900 + 4500 + 2300 + 3700 + 2100 + 4500 = 33,700.
Divide by 10 to get the winsorized mean: 33,700 / 10 = $3,370.
Result
Raw mean = $52,920; winsorized mean (90th-percentile cap of $4,500) = $3,370. Winsorization reduced the mean by $49,550 — a 94% drop — by capping the single catastrophic patient at $4,500 instead of $500,000. One patient out of 10 (10%) was affected. The winsorized mean is a much better description of what a typical patient in this group costs, but the analyst must also report the raw mean and the cap rule so readers know the full picture.
Runnable example
python implementation
Winsorization, descriptive impact, and a two-part (logistic + gamma-log GLM) adjusted incremental cost on a patient-period cost table. Required input (one row per patient, already attribution-resolved and FFS-observable): costs : person_id, arm ('A'/'B'),...
import numpy as np
import pandas as pd
import statsmodels.api as sm
import statsmodels.formula.api as smf
COVARS = ["age", "female", "charlson", "prior_cost"] # measured in the baseline lookback only
def annualize(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
df["pppy"] = df["total_cost"] * 365.25 / df["followup_days"]
return df
def winsorize(s: pd.Series, upper_pct: float) -> tuple[pd.Series, float, dict]:
cap = np.quantile(s, upper_pct) # e.g. 0.99 -> 99th percentile dollar cap
wins = s.clip(upper=cap)
impact = {
"cap": float(cap),
"pct_patients_affected": float((s > cap).mean()),
"pct_spend_removed": float((s.sum() - wins.sum()) / s.sum()),
"mean_raw": float(s.mean()),
"mean_winsorized": float(wins.mean()),
}
return wins, cap, impact
def twopart_incremental_cost(df: pd.DataFrame, n_boot: int = 1000, seed: int = 1):
# Part 1: P(any cost) via weighted logistic; Part 2: gamma-log GLM on positive cost.
# Adjusted arithmetic-mean cost per arm = recycled prediction averaged over the cohort.
df = df.assign(any_cost=(df["pppy"] > 0).astype(int))
rhs = "arm + " + " + ".join(COVARS)
def fit_predict(d):
m1 = smf.glm("any_cost ~ " + rhs, d, family=sm.families.Binomial(),
freq_weights=d["ps_weight"]).fit()
pos = d[d["pppy"] > 0]
m2 = smf.glm("pppy ~ " + rhs, pos, family=sm.families.Gamma(sm.families.links.Log()),
freq_weights=pos["ps_weight"]).fit()
mu = {}
for a in ("A", "B"):
d_a = d.assign(arm=a)
mu[a] = float(np.average(m1.predict(d_a) * m2.predict(d_a), weights=d["ps_weight"]))
return mu["A"] - mu["B"]
point = fit_predict(df)
rng = np.random.default_rng(seed)
boot = [fit_predict(df.sample(frac=1, replace=True, random_state=int(rng.integers(1e9))))
for _ in range(n_boot)]
lo, hi = np.percentile(boot, [2.5, 97.5])
return {"incremental_cost": point, "ci_low": float(lo), "ci_high": float(hi)}
df = annualize(costs)
for pct in (0.95, 0.99): # sensitivity grid on the descriptive mean
_, _, impact = winsorize(df["pppy"], pct)
print(f"winsor {int(pct*100)}th:", impact)
print(twopart_incremental_cost(df)) # primary comparative estimand (uncapped)r implementation
Winsorization impact table and a two-part (logistic + gamma-log GLM) adjusted incremental cost with bias-corrected bootstrap. Input mirrors the Python version: costs : person_id, arm ('A'/'B'), followup_days, total_cost, ps_weight, and baseline covariates...
library(dplyr)
library(boot)
covars <- c("age", "female", "charlson", "prior_cost")
rhs <- paste(c("arm", covars), collapse = " + ")
annualize <- function(d) mutate(d, pppy = total_cost * 365.25 / followup_days)
winsorize_impact <- function(x, upper_pct) {
cap <- quantile(x, upper_pct, names = FALSE) # dollar cap at the chosen percentile
wins <- pmin(x, cap)
data.frame(percentile = upper_pct, cap = cap,
pct_patients_affected = mean(x > cap),
pct_spend_removed = (sum(x) - sum(wins)) / sum(x),
mean_raw = mean(x), mean_winsorized = mean(wins))
}
twopart_inc <- function(d) {
d$any_cost <- as.integer(d$pppy > 0)
m1 <- glm(reformulate(c("arm", covars), "any_cost"),
family = binomial(), weights = ps_weight, data = d)
pos <- d[d$pppy > 0, ]
m2 <- glm(reformulate(c("arm", covars), "pppy"),
family = Gamma(link = "log"), weights = pos$ps_weight, data = pos)
mu <- sapply(c("A", "B"), function(a) { # recycled prediction per arm
da <- transform(d, arm = a)
weighted.mean(predict(m1, da, type = "response") * predict(m2, da, type = "response"),
w = d$ps_weight)
})
unname(mu["A"] - mu["B"])
}
d <- annualize(costs)
print(do.call(rbind, lapply(c(0.95, 0.99), function(p) winsorize_impact(d$pppy, p))))
bs <- boot(d, function(data, i) twopart_inc(data[i, ]), R = 1000)
print(boot.ci(bs, type = "bca")) # adjusted incremental cost CI