DQ/Medical Statistics with R and Python
10. Logistic regression and matched studies

Matched studies

Last updated

A confounder distorts an exposure-disease comparison when it is tied to both. You can adjust for it in the analysis, or you can design it out. Matching designs it out. You pick each control to share the case's value of the confounder, so the two cannot differ on it. This lesson builds a matched case-control study, shows why the analysis has to respect the pairing, and works three tools that read a matched table: McNemar's test, the matched odds ratio, and conditional logistic regression. It assumes you can already read a 2x2 table, an odds ratio, and a P-value.

Why matching controls confounding by design

Take a study of pulmonary tuberculosis and current smoking in a Malaysian chest clinic. Age confounds the link. Older adults smoke more, and they also carry more TB. If your cases happen to be older than your controls, the crude comparison mixes the smoking effect with the age effect, and you cannot tell them apart.

Matching removes that mixing at the source. For each TB case you recruit one control of the same age band, same sex, same hospital. Inside a pair, age is fixed: the case and the control hold the same value, so age cannot drive any difference you see. Do that for every pair and the confounder is balanced by construction, not by a model.

The design has a price. Because the matching variable is constant within each pair, you can no longer estimate its effect. You matched on age, so you have thrown away the contrast that would let you measure how age affects TB. You match on a confounder you want to remove, never on the exposure you want to study.

Do not match on the exposure or anything on its causal path

Matching forces case and control to agree on the matched factor. If you match on something the exposure causes (overmatching), you balance away part of the real effect and bias the odds ratio toward 1. Match on confounders only: age, sex, area, recruitment hospital. Never match on the exposure or a variable that sits between exposure and disease.

The matched 2x2: concordant and discordant pairs

A matched pair is one case and the control chosen to match it. The unit of analysis is the pair, not the person. You classify each pair by what the two members did with the exposure. Both exposed, both unexposed, case-only exposed, or control-only exposed. Those four counts make the matched 2x2, where rows are the case and columns are the control, and every cell counts pairs.

Here is the NSAID and upper gastrointestinal bleed study: 175 bleed cases, each matched to one non-bleed control on age, sex, and ward, then asked about regular NSAID use.

Case \ ControlControl exposedControl unexposedTotal
Case exposed2545 (b)70
Case unexposed15 (c)90105
Total40135175

The two diagonal cells are the concordant pairs: 25 pairs where both took NSAIDs and 90 where neither did, 115 in all. In a concordant pair the case and its control did the same thing, so the pair cannot say which exposure goes with disease. Concordant pairs carry no information about the association.

The two off-diagonal cells are the discordant pairs: 45 pairs where only the case was exposed (call this count b) and 15 where only the control was exposed (count c). All 60 of these pairs disagree, and every bit of information about the exposure-disease link lives here.

Concordant vs discordant

Concordant pairs agree, so they drop out of every matched test. Discordant pairs disagree, and they are the whole analysis. A matched study with many pairs but few discordant ones is a small study in disguise: its real sample size is b + c, here just 60.

McNemar's test uses only the discordant pairs

The null hypothesis is no association: exposure has nothing to do with disease. If that holds, a discordant pair is equally likely to break either way, so among the b + c discordant pairs you expect about half to be case-only and half control-only. McNemar's test asks whether b and c are further apart than that even split allows. It compares b with c and ignores the concordant pairs entirely.

For the NSAID data, b = 45 and c = 15, so the statistic is (45 − 15)2 / (45 + 15) = 900 / 60 = 15.0 on 1 degree of freedom, which gives P = 0.0001. There is strong evidence that NSAID use and GI bleeds are associated. R applies a continuity correction by default, which shaves the gap by 1 before squaring and lowers the statistic to (29)2/60 = 14.0, P = 0.0002. The conclusion does not move.

Validity of McNemar's test

The chi-squared approximation is fine when the number of discordant pairs b + c is at least about 10. Below that, use an exact binomial test: under the null, b follows a binomial with n = b + c and probability 0.5, so you can read the P-value straight off the binomial. Here b + c = 60, comfortably valid.

The matched odds ratio from the discordant pairs

The same discordant pairs give the effect size. Treat each pair as its own tiny stratum and combine them with the Mantel-Haenszel method. Concordant strata contribute nothing, and the algebra collapses to a single ratio: the matched odds ratio is the count of case-only-exposed pairs over the count of control-only-exposed pairs.

For the NSAID study that is 45 / 15 = 3.0. NSAID users have three times the odds of a GI bleed, with age, sex, and ward already balanced by the matching. A 95% confidence interval comes from an error factor built on the two discordant counts.

The error factor is exp[1.96 × sqrt(1/45 + 1/15)] = exp(0.584) = 1.79. The interval is 3.0 / 1.79 to 3.0 × 1.79, which is 1.67 to 5.38. The interval clears 1, agreeing with McNemar's small P. This error-factor formula is reliable when the total number of pairs is above about 50.

Never collapse a matched study to an unmatched table

If you ignore the pairing here and cross-tabulate 70 exposed cases and 40 exposed controls as one plain 2x2, the crude odds ratio is (70×135)/(105×40) = 2.25, not 3.0. The crude figure is pulled toward 1 because it spends information balancing the matched factor that the pairing already handled. Analyse pairs with pairs.

Enter the matched 2x2 of pairs, run McNemar's test both ways, then read off the matched odds ratio and its 95% confidence interval.

# rows = case, cols = control; every cell counts PAIRS
# b = case-only exposed, c = control-only exposed
M <- matrix(c(25, 45,
              15, 90), nrow = 2, byrow = TRUE)
dimnames(M) <- list(case = c("exposed","unexposed"),
                    control = c("exposed","unexposed"))
b <- 45; c <- 15
mcnemar.test(M, correct = FALSE)   # discordant pairs only
mcnemar.test(M, correct = FALSE)         # chi2 = 15.0, df = 1, p = 0.000108
mcnemar.test(M)                          # continuity-corrected: 14.02, p = 0.000181
OR <- b / c                              # 3.0
EF <- exp(1.96 * sqrt(1/b + 1/c))        # 1.794
c(OR = OR, lower = OR / EF, upper = OR * EF)   # 3.00, 1.67, 5.38
binom.test(b, b + c, 0.5)                # exact check on the discordant split

Compute McNemar's statistic, its P-value, and the matched odds ratio with a 95% interval, straight from b and c.

import numpy as np
from scipy.stats import chi2, binomtest
b, c = 45, 15
mcnemar = (b - c)**2 / (b + c)
print(mcnemar, chi2.sf(mcnemar, df=1))
mcnemar = (b - c)**2 / (b + c)
print(f"McNemar chi2={mcnemar:.2f}  p={chi2.sf(mcnemar, 1):.6f}")   # 15.00, 0.000108
OR = b / c
EF = np.exp(1.96 * np.sqrt(1/b + 1/c))
print(f"OR={OR:.2f}  95% CI {OR/EF:.2f} to {OR*EF:.2f}")           # 3.00, 1.67 to 5.38
print(binomtest(b, b + c, 0.5).pvalue)                            # exact discordant split

Worked example 2: smoking and TB

Back to the chest clinic. There are 200 matched pairs of a TB case and an age-, sex-, and hospital-matched control, each scored for current smoking. Both smoked in 40 pairs, neither in 76. The discordant pairs are 60 where only the case smoked (b) and 24 where only the control smoked (c).

McNemar's statistic is (60 − 24)2 / (60 + 24) = 1296 / 84 = 15.43 on 1 df, P = 0.00009. The matched odds ratio is 60 / 24 = 2.5. The error factor is exp[1.96 × sqrt(1/60 + 1/24)] = 1.61, so the 95% interval is 2.5 / 1.61 to 2.5 × 1.61, which is 1.56 to 4.01. Current smokers carry two and a half times the odds of TB once age, sex, and hospital are matched out.

In practice (KL chest clinic)

The clinic reports 200 pairs but the smoking estimate rests on only 84 discordant pairs. When you brief the team, lead with that: the precision of the matched odds ratio is set by b + c, not by the headline 200. If a future audit wants a tighter interval, it needs more discordant pairs, which usually means recruiting where exposure is more mixed, not just more pairs.

Draw the four pair types, highlight the discordant ones in red, then run McNemar's test and the matched odds ratio for the TB data.

counts <- c(both_smoke = 40, case_only = 60,
            control_only = 24, neither = 76)
b2 <- 60; c2 <- 24
barplot(counts, col = c("grey70","tomato","tomato","grey70"))
barplot(counts, las = 1, ylab = "pairs",
        col = c("grey70","tomato","tomato","grey70"),
        main = "Only the red discordant pairs carry information")
TB <- matrix(c(40, 60,
               24, 76), nrow = 2, byrow = TRUE)
mcnemar.test(TB, correct = FALSE)    # chi2 = 15.43, df = 1, p = 0.000086
b2 / c2                              # matched OR = 2.5

Plot the four pair types with the discordant bars in red, then print McNemar's statistic and the matched odds ratio.

import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import chi2
labels = ["both smoke","case only","control only","neither"]
counts = [40, 60, 24, 76]
colors = ["grey","tomato","tomato","grey"]
plt.bar(labels, counts, color=colors)
plt.show()
plt.bar(labels, counts, color=colors)
plt.ylabel("pairs"); plt.title("Discordant pairs (red) carry the signal")
plt.tight_layout(); plt.show()
b2, c2 = 60, 24
m = (b2 - c2)**2 / (b2 + c2)
print(f"McNemar chi2={m:.2f}  p={chi2.sf(m, 1):.6f}  OR={b2/c2:.1f}")  # 15.43, 0.000086, 2.5

Conditional logistic regression for matched data

McNemar and the ratio b/c handle one exposure with one control per case. Real studies often want more: extra confounders that were not matched on, or several controls per case. Conditional logistic regression is the tool. It is logistic regression that only ever compares a case to controls in the same matched set, so the matching is built into the likelihood. With one control per case and no extra terms, it returns exactly the matched odds ratio you already have.

For 1:1 matching with a single exposure there is a clean shortcut. The conditional likelihood depends only on the difference in exposure within each pair, and the maximum-likelihood coefficient is the log of the discordant ratio.

That shortcut means you can fit a 1:1 conditional logistic model in base R with no special package: regress a column of ones, with no intercept, on the within-pair exposure difference (case minus control). Concordant pairs have a difference of zero and fall out, exactly as the theory says. For the TB data this returns OR = 2.5 with a Wald interval of 1.56 to 4.01, matching the error-factor interval. Add a real confounder, or move to two controls per case, and you switch to a proper conditional logistic routine (clogit in R's survival package, or statsmodels in Python).

When to reach past McNemar

Use McNemar and b/c for a quick, exact read of one exposure in a 1:1 matched study. Switch to conditional logistic regression when you need to adjust for a confounder you did not match on, model a dose or continuous exposure, or handle more than one control per case. Do not fit ordinary logistic regression with a dummy per matched set: for finely matched data that biases the odds ratio away from 1, and for 1:1 pairs it returns the square of the right answer.

Fit the 1:1 conditional logistic model for the TB smoking data in base R using the within-pair difference trick.

# xdiff = smoke_case - smoke_control, one row per pair
# 40 both-smoke and 76 neither pairs have xdiff = 0 and drop out
xdiff <- c(rep(0, 40), rep(1, 60), rep(-1, 24), rep(0, 76))
y <- rep(1, length(xdiff))
fit <- glm(y ~ xdiff - 1, family = binomial)
exp(coef(fit))
fit <- glm(y ~ xdiff - 1, family = binomial)
exp(coef(fit))               # xdiff: 2.50 = matched OR = b/c = 60/24
exp(confint.default(fit))    # 1.56 to 4.01, matches the error-factor CI
# only the discordant pairs move the estimate; concordant pairs cancel

Fit the same model with statsmodels ConditionalLogit, grouping by matched set. It returns the matched odds ratio directly.

import numpy as np, pandas as pd
from statsmodels.discrete.conditional_models import ConditionalLogit
# (case_smoke, control_smoke, number_of_pairs)
spec = [(1, 1, 40), (1, 0, 60), (0, 1, 24), (0, 0, 76)]
rows, g = [], 0
for cs, ks, n in spec:
    for _ in range(n):
        g += 1
        rows.append((g, 1, cs))   # the case
        rows.append((g, 0, ks))   # its matched control
df = pd.DataFrame(rows, columns=["set", "case", "smoke"])
res = ConditionalLogit(df["case"], df[["smoke"]], groups=df["set"]).fit(disp=0)
print(np.exp(res.params))
res = ConditionalLogit(df["case"], df[["smoke"]], groups=df["set"]).fit(disp=0)
print(np.exp(res.params))        # smoke: 2.50 = matched OR
print(np.exp(res.conf_int()))    # 1.56 to 4.01
# add a confounder column to df[[...]] to adjust beyond the matching

Common mistakes

  • Analysing matched data as if it were unmatched. Collapsing the pairs into one plain 2x2 and running an ordinary chi-squared throws away the design. The NSAID crude odds ratio (2.25) is biased toward 1 versus the matched 3.0.
  • Putting the concordant pairs into the test. McNemar and b/c use only the discordant cells. Counting the 25 both-exposed and 90 neither-exposed pairs as evidence is the classic error.
  • Reading 175 pairs as the sample size for precision. The interval is driven by the discordant count b + c (60 here), not the pair total. Few discordant pairs means a wide interval however many pairs you recruited.
  • Overmatching. Matching on the exposure, or on a step between exposure and disease, balances away part of the real effect and drags the odds ratio toward 1.
  • Fitting ordinary logistic regression with a dummy per matched set. For finely matched data this is biased away from the null, and for 1:1 pairs it gives the square of the correct odds ratio. Use conditional logistic regression.

Tips

  • Lay the matched 2x2 out as case (rows) by control (columns) every time, and label the off-diagonal cells b and c so the discordant pairs are obvious.
  • Report b + c next to the matched odds ratio. It is the true sample size and it tells the reader how much the estimate can be trusted.
  • For one exposure in a 1:1 study, McNemar's test, the exact binomial on the discordant split, and 1:1 conditional logistic all agree. Pick the one your audience reads fastest.
  • Keep the matched factors out of the regression. You already controlled for them by design, and the matching variable has no within-pair variation to estimate.
  • Reach for conditional logistic regression the moment you need an extra confounder or more than one control per case, not an ordinary model with set dummies.

In the NSAID study there are 25 both-exposed pairs, 90 neither-exposed pairs, 45 case-only-exposed pairs, and 15 control-only-exposed pairs. Which pairs determine the matched odds ratio and McNemar's test?

  • Only the 45 and 15 discordant pairs; the matched OR is 45/15 = 3.0 and McNemar uses (45−15)²/(45+15).
  • All 175 pairs, weighted by how many members were exposed.
  • The 25 and 90 concordant pairs, because they have the largest counts.
Concordant pairs agree on exposure, so they say nothing about which exposure goes with disease and cancel out of both the odds ratio and McNemar's test. The matched OR is the ratio of the two discordant counts (b/c = 3.0), and McNemar compares b with c. The large concordant counts are irrelevant to the estimate.

A colleague matched each TB case to a control on age and hospital, then ran ordinary logistic regression with a dummy variable for each matched pair. Why is this the wrong analysis?

  • Ordinary logistic regression with a parameter per pair is biased away from the null for matched data; for 1:1 pairs it returns the square of the true odds ratio, so conditional logistic regression is needed.
  • It is correct and identical to conditional logistic regression, so either may be reported.
  • It is wrong only because age should have been entered as a continuous covariate.
Estimating one nuisance parameter per matched set breaks the large-sample logic of ordinary logistic regression, and the odds ratio is pushed away from 1. For 1:1 matching the result is exactly the square of the correct estimate. Conditional logistic regression conditions the nuisance parameters out and gives the right matched odds ratio. Entering age would not help, since age was matched on and has no within-pair variation.