Confounding → Propensity Score → Matching, in a Terminal

On this page
อ่านฉบับภาษาไทย — the Thai version of this article
Abstract
A propensity score (Rosenbaum & Rubin, 1983) collapses every measured confounder into one number — the modelled probability of being treated — and matching then pairs treated and untreated patients who share it. The idea is easy to state and hard to see. This article makes it visible by running the whole analysis one line at a time inside an embedded terminal, on a simulated cohort of 600 patients where age, sex and severity drive both who gets treated and how fast they recover. Because the data are simulated we know the answer in advance — the treatment truly cuts recovery by 3.0 days — so we can grade every estimate against the truth. An unadjusted comparison comes out with the opposite sign, thrown off by the confounding; matching on the score recovers a number within a day of the truth. You drive the terminal yourself, in R or Python, and watch each step's table, number or plot appear on the Stage.

Why an unadjusted comparison misleads
We want to know whether a treatment shortens recovery. The obvious move — average the recovery days of the treated, average the untreated, subtract — is almost always misleading in observational data, because nobody rolled a die to decide who got treated.
Everything below runs on a simulated teaching cohort — 600 fabricated patients, not real people — built with a fixed seed so anyone can reproduce it. Treatment was handed out more often to older, sicker patients on purpose, and those same traits also add recovery days, so the confounding is baked in and we can watch matching pull it back out. In the R run the split is 352 treated and 248 controls. Nothing is computed in your browser; the terminal simply replays a real analysis captured offline, one line at a time.
The unadjusted answer even flips sign
Difference the two group means of recovery days, and this is what the terminal prints on the R tab:
The treated look like they recover slower — a positive +3.88 days — even though the drug, by construction, makes people recover faster by 3.0 days. The unadjusted estimate is not merely too large; it points the opposite way. The reason is the confounding: the treated are older and sicker, and age and severity add recovery days faster than the treatment takes them away. The comparison is measuring the illness, not the drug.
How far apart the groups start
We measure the baseline gap with the standardized mean difference (SMD) — the difference in a covariate's mean between groups divided by a pooled standard deviation, so that age (in years) and severity (on its own scale) become comparable. A common rule of thumb is that |SMD| > 0.1 signals meaningful imbalance. On the raw R cohort:
All three sit well over 0.1, severity worst at 0.65. The treated group really is older and sicker before we do anything — which is exactly why the unadjusted difference came out reversed.
The propensity score is a treatment-prediction score
Balancing three covariates by hand is already awkward; real studies have twenty. The propensity score's trick is to squeeze all of them into one number per person — each patient's model-predicted probability of being treated:
$$\text{PS}_i = \Pr(\text{treated}=1 \mid X_i)$$
We get it from an ordinary logistic regression of treatment on the confounders, glm(treated ~ age + sex + severity, family = binomial). In the R run the fitted coefficients come out positive for all three — older patients, the sex = 1 group, and sicker patients are more likely to be treated — which is precisely the confounding, now written as a model. Notice what the score is not: it never sees the outcome. It models treatment assignment only — how treatment was handed out — which is the single most common thing people get wrong about it.
Below you can run the whole thing yourself. The terminal replays the real analysis line by line, from the raw cohort to the matched estimate. Type the highlighted line (or press Enter to autofill and run), press Tab to accept the autocomplete ghost, and the Stage on the right shows the table, number or plot each step produces. Switch between the R and Python tabs to see the same analysis in either language. Remember: nothing is computed in the browser — every value and plot was produced beforehand.
แนวคิด propensity score นิยามได้ง่ายแต่ เห็นภาพ ยาก เทอร์มินัลนี้รันการวิเคราะห์จริงทีละบรรทัดบนข้อมูล จำลอง 600 คน ที่อายุ เพศ และความรุนแรงของโรคมีผลทั้งต่อโอกาสได้รับการรักษา และ จำนวนวันที่ฟื้นตัว การเปรียบเทียบแบบไม่ปรับจึงมีตัวกวนปนหนัก ลองดูเส้นเรื่อง: ผลต่างวันฟื้นตัวแบบไม่ปรับออกมาลำเอียง (กลับทิศและห่างจากค่าจริง) จากนั้นการจับคู่ด้วยคะแนนจะปิดช่องว่างลง และค่าที่ได้จากการจับคู่จะเข้าใกล้ค่าที่เราตั้งไว้ คือ ผลการรักษาจริง −3.0 วัน คุณเป็นคนลงมือเอง — พิมพ์ตามบรรทัดที่ไฮไลต์ (หรือกด Enter เพื่อเติมและรัน) กด Tab เพื่อรับคำที่ระบบเดาให้ แล้วแผง Stage ด้านขวาจะแสดงตาราง ค่า หรือกราฟที่โค้ดแต่ละขั้นสร้างขึ้น สลับแท็บระหว่าง R กับ Python เพื่อดูการวิเคราะห์เดียวกันในทั้งสองภาษา ไม่มีการคำนวณในเบราว์เซอร์ของคุณ ทุกค่าและกราฟถูกคำนวณด้วย R และ Python ไว้ก่อนแล้ว
A propensity score is easy to define and hard to see. This terminal runs the real analysis one line at a time on a simulated cohort of 600 patients, where age, sex and severity push people toward treatment and shape how fast they recover — so an unadjusted comparison is badly confounded. Watch the arc: the unadjusted difference in recovery days comes out biased (opposite sign, far from the truth), then matching on the score closes the gap and the matched estimate lands near the value baked into the simulation, a true effect of −3.0 days. You drive it — type the highlighted line (or press Enter to autofill and run), press Tab to accept the autocomplete ghost, and the Stage on the right shows the table, number, or plot each step produces. Switch between the R and Python tabs to see the same analysis in either language. Nothing runs in your browser; every value and plot was computed in R and Python beforehand.
What matching recovered
Matching is a proposal, not a proof, so we recompute the SMDs on the matched set and look. Using a caliper of 0.181 on the logit propensity score, the greedy 1:1 nearest-neighbour loop keeps 198 of the 352 treated patients — the ones that have a close-enough control. Nearest-neighbour just means pairing each treated patient with the untreated patient whose score is closest, which is exactly the rule MatchIt applies internally — so building the loop by hand and calling MatchIt are two routes to the very same match. In that matched sample the baseline imbalance nearly vanishes:
Every gap collapses under the 0.1 line — severity, the worst offender, falls from 0.65 to −0.02. Now the pay-off. Re-estimate the treatment effect inside the matched sample and line it up against the unadjusted answer and the truth:
The unadjusted estimate had the opposite sign; the matched estimate lands at −2.93 days, within a tenth of a day of the true −3.0. Same data, same outcome column — the only thing that changed is who each treated patient was compared with.
R and Python: same story, different draws
The two tabs are not two views of one dataset; each simulates its own cohort with its own generator and seed, so the exact numbers differ — and that is the point. On the Python tab the unadjusted difference is +2.75 days (again the opposite sign), the caliper keeps 116 matched pairs, and the matched estimate is −3.10 days against the same true −3.0. Two independent draws, two confounded unadjusted answers, two matched estimates within a day of the truth. The arc belongs to the method, not to one lucky sample.
What this simulation does — and does not — prove
The demo is persuasive precisely because it is rigged in an honest way, and it is worth being clear about the limits of that.
- We know the truth here; in real research you never do. The −3.0 is written into the data-generating code, which is the only reason we can grade the estimates against it. With real patients there is no answer key — you check balance and argue from assumptions; you do not get to confirm against a known effect.
- Matching only balances what you measured. Age, sex and severity are in the model, so matching can balance them. A confounder you never recorded cannot be balanced by any amount of matching — and here we simply assumed there are none, which real data will not grant you.
- The caliper throws data away. Keeping 198 of 352 treated (116 pairs on the Python draw) means the leftover treated had no close control and were dropped. The result is then an effect among the matched treated, not the whole treated group — a subtly narrower question than it first appears.
- One matched difference is not a full analysis. We report a point estimate; a real study would attach a confidence interval that accounts for the matched pairs, and would often model the outcome on top of the matching rather than trust the raw matched difference alone.
Final takeaway
Propensity-score matching is one small, honest idea repeated: turn every measured confounder into a single predicted probability, pair people who share it, and then check that the pairing actually made the groups comparable. Watch it once in the terminal above and the black box becomes a short, legible sequence — a biased +3.88 turning into a faithful −2.93, for measured confounders and only for those.
For the same method taught step by step — across both treatment-versus-control and case–control designs, and the same match built by hand and with MatchIt — see Propensity Score for Matching, Step by Step.
Key takeaways
- Confounding comes first. In the R cohort the treated were older and sicker (SMDs 0.36, 0.36, 0.65), so the unadjusted difference came out at +3.88 days — the opposite sign against a true −3.0.
- The propensity score is a treatment-prediction score — $\Pr(\text{treated} \mid X)$ from a logistic model — collapsing every measured confounder into one number. It models treatment, not outcome.
- Matching then balances the groups. With a 0.181 caliper, 198 of 352 treated matched and every SMD fell under 0.1 (severity 0.65 → −0.02).
- The estimate becomes faithful. Matched effect −2.93 days in R and −3.10 days in Python, both within a day of the built-in −3.0.
- Be honest about the limits. Simulation lets us grade against a known truth; real data has no answer key, matching balances only measured confounders, and the caliper narrows the question to the matched treated.