Let’s say we’re interested in the effect of smoking on low infant birthweight, where “low infant birthweight” is an indicator based on birthweight falling below 2500 grams. In an experimental study, we would randomly assign expectant mothers to either smoke or not smoke, record their infant’s birthweight, and then compare the proportion of low birthweights between the two groups. Obviously that would be unethical and no review board would approve such a study. We also probably wouldn’t get many participants.
Another approach is to compare infant’s birthweights by using observational data where expectant mothers are either smokers or nonsmokers. Instead of randomizing mothers to be smokers or nonsmokers, we have let the mothers decide which group to join. A potential problem with this approach is that the two groups could be very different in other respects in addition to their smoking status. For example, the nonsmokers may be younger on average than the smokers. In this case it may be hard to tell whether differences in birthweights are due to age or smoking status.
To help address this, we could modify the design of the study by matching mothers with low infant birthweights to mothers with normal infant birthweights, and record their smoking status. For example, we might match on age. If we observe a 28 year old mother with a low birthweight infant, we could match her with another 28 year old mother with a normal birthweight infant. In each case we would also make note of their smoking status. If we continued to do this, we would compile data consisting of 1-to-1 matches of low and normal infant birthweights with smoking status being randomly observed.1 Since we’re investigating low infant birthweights, we might call these observations the cases and the normal infant birthweights the controls. This is the idea behind a case-control study.
Now the interesting thing about this study design is that our response variable of interest, infant birthweight, is the fixed variable while our explanatory variable, smoking status, is random. We collected information on smoking status conditional on the cases (low birthweight) and controls (normal birthweight) we observed. This means we can’t estimate the probability of low infant birthweight conditional on smoking status. The study design did not condition on smoking status. We can, however, go the other way and estimate the probability of smoking status conditional on low infant birthweight. Why would this be of interest? It turns out that when we convert probabilities to odds and then take the odds ratio2, the odds ratio treats variables symmetrically. This means the odds ratio is the same whether we’re looking at the effect of infant birthweight given smoking status, or the effect of smoking status given infant birthweight.
Matched Case-Control Study
To illustrate this, let’s look at data that utilize the study design we just described. We’ll use the R Statistical Computing Environment (R Core Team, 2026). The data below, from Hosmer et al. (2013), present matched pairs of infants, where each pair consists of one infant with low birthweight (the case) and another with regular birthweight (the control). The data are matched on age of the mother. The variable definitions are as follows:
- pairid: pair identifier
- low: low birthweight (1 = yes, 0 = no)
- age: age of mother
- lwt: weight at last menstrual period
- smoke: smoking status (1 = smoker, 0 = nonsmoker)
- ptd: previous preterm baby (1 = yes, 0 = no)
- ht: presence of hypertension (1 = yes, 0 = no)
- ui: presence of uterine irritability (1 = yes, 0 = no)
- race: white, black, other
We first load the data using the readRDS() function and then use the head() function to look at the first six rows, or the first three pairs. The “pairid” variable identifies the pairs. The “low” variable indicates the cases and controls. A 0 indicates a control (normal birthweight) and a 1 indicates a case (low birthweight). You’ll notice within each pair there is exactly one control and one case, and that the pair has matching mother’s ages. This is sometimes called a 1–1 matched study design.
btw <- readRDS(url("https://static.lib.virginia.edu/statlab/materials/data/btw.Rds"))
head(btw)
pairid low age lwt smoke ptd ht ui race
1 1 0 14 135 0 0 0 0 White
2 1 1 14 101 1 1 0 0 Other
3 2 0 15 98 0 0 0 0 Black
4 2 1 15 115 0 0 0 1 Other
5 3 0 16 95 0 0 0 0 Other
6 3 1 16 130 0 0 0 0 Other
To investigate the association of smoking status with infant birthweight, we can make a 2-way contingency table of “low” and “smoke” using the xtabs() function. We can also add column totals by piping the result into the addmargins() function with the argument margin = 1. Notice we have equal numbers of cases and controls.
xtabs(~ smoke + low, data = btw) |>
addmargins(margin = 1)
low
smoke 0 1
0 40 26
1 16 30
Sum 56 56
While we can’t estimate the probability of low birthweight given smoking status, we can estimate the probability of smoking status given low birthweight. To do this we pipe the table into the proportions() function and specify we want to calculate proportions down the columns using the argument margin = 2 (i.e., conditional on low birthweight).
xtabs(~ smoke + low, data = btw) |>
proportions(margin = 2)
low
smoke 0 1
0 0.7142857 0.4642857
1 0.2857143 0.5357143
Based on these proportions, the estimated probability of being a smoker given the control group (low = 0; normal birthweight) is about 0.29, and the estimated probability of being a smoker given the case group (low = 1; low birthweight) is about 0.54. Let’s convert these probabilities to odds and find the odds ratio. Below we use indexing brackets to extract values from the table. For example, tab[2,1] says “get the value in row 2 column 1.”
# create table of proportions
tab <- xtabs(~ smoke + low, data = btw) |>
# proportion of smoke given low
proportions(margin = 2)
# convert to odds
odds_control <- tab[2,1]/(1 - tab[2,1])
odds_case <- tab[2,2]/(1 - tab[2,2])
# calculate odds ratio
odds_case/odds_control
[1] 2.884615
Since the odds ratio is symmetric, we can interpret this as the odds of low birthweight given a mother is smoker is about 2.9 times higher than the odds of low birthweight given a mother is a nonsmoker.
We can verify the odds ratio is symmetric by calculating proportions based on the row margins (instead of the columns) and recalculating the odds ratio. Notice the odds ratio is the same.
# create table of proportions
tab2 <- xtabs(~ smoke + low, data = btw) |>
# proportion of low given smoke
proportions(margin = 1)
# convert to odds
odds_control <- tab2[1,2]/(1 - tab2[1,2])
odds_case <- tab2[2,2]/(1 - tab2[2,2])
# calculate odds ratio
odds_case/odds_control
[1] 2.884615
We can also estimate the odds ratio using logistic regression. To get the odds ratio we need to exponentiate the coefficient for the “smoke” variable.
m <- glm(low ~ smoke, data = btw, family = binomial)
exp(coef(m))["smoke"]
smoke
2.884615
We can also easily get a confidence interval on this odds ratio using the confint() function.
exp(confint(m))["smoke",]
Waiting for profiling to be done...
2.5 % 97.5 %
1.335052 6.419939
So based on the lower bound of the confidence interval, we might conclude the odds of low birthweight are at least 34% higher if the mother is a smoker. 3
You’ll notice we didn’t show or discuss the intercept in the logistic regression model above. That’s because it simply returns the proportion of nonsmokers who are cases. That’s not of interest since the distribution of cases and controls is fixed by the study design. To see this, take the inverse logit of the intercept to convert the log-odds to a proportion.
# plogis() calculates the inverse logit
plogis(coef(m)["(Intercept)"])
(Intercept)
0.3939394
Notice this is the same as simply calculating the proportion of nonsmokers that are cases using the two-way table.
xtabs(~ smoke + low, data = btw) |>
proportions(margin = 1)
low
smoke 0 1
0 0.6060606 0.3939394
1 0.3478261 0.6521739
But again, this is not useful, since smoking status is conditional on case/control status, not the other way around.
Case-Control Data Per Pair
Data in a 1-1 matched case-control study is sometimes organized as one row per pair. We can create this structure using the pivot_wider() function from the tidyr package (Wickham et al. 2025). If you don’t have this package, you can install it using the syntax install.packages("tidyr"). The result shows smoking status for each pair. For example, the first pair shows the control (normal birthweight) is a nonsmoker (0) while the case (low birthweight) is a smoker (1).
library(tidyr)
btw2 <- btw[,c("low", "smoke", "pairid")]
btw2 <- pivot_wider(btw2, names_from = low, values_from = smoke,
id_cols = pairid)
names(btw2)[2:3] <- c("control", "case")
head(btw2)
# A tibble: 6 × 3
pairid control case
<dbl> <dbl> <dbl>
1 1 0 1
2 2 0 0
3 3 0 0
4 4 0 1
5 5 1 1
6 6 0 1
Now we can create a contingency table of matched pairs.
xtabs(~ control + case, data = btw2)
case
control 0 1
0 18 22
1 8 8
The interesting part of this table is the off-diagonals. The first one shows us that we have 22 pairs where the case (low birthweight) is a smoker and the control (normal birthweight) is not. The other off-diagonal shows we only have eight pairs where the case is not a smoker and the control is a smoker. Based on these off-diagonal counts, it seems smokers are more likely to have a low birthweight infant than nonsmokers. To test this hypothesis, we can run McNemar’s chi-squared test. The null hypothesis of this test is that the probabilities of being classified into the off-diagonal cells are the same. With our data in this structure, we can run this test using the mcnemar.test() function that is included with base R. We set correct = FALSE to turn off the continuity correction, which is too conservative. (Agresti 2019, page 229) The reason we use McNemar’s chi-squared test instead of the standard chi-square test is because we have paired data, not independent observations.
mcnemar.test(btw2$control, btw2$case, correct = FALSE)
McNemar's Chi-squared test
data: btw2$control and btw2$case
McNemar's chi-squared = 6.5333, df = 1, p-value = 0.01059
The small p-value of 0.01 suggests we can reject the null hypothesis of equal probabilities in the off-diagonals (at the 0.05 level) and conclude that there does appear to be some association between smoking status and low birthweight. However this test doesn’t tell us anything about the direction or magnitude of the association. To address this we could calculate a difference in proportions between the off-diagonal cells. The PropCIs package helps us to do this with the diffpropci.Wald.mp() function. This function calculates a confidence interval for a difference of proportions with matched pairs. First we save the table of counts to an object called “tab3” and then use index notation to assign each off-diagonal count to the b and c arguments, respectively. The total table count is provided to the n argument.
tab3 <- xtabs(~ control + case, data = btw2)
library(PropCIs)
diffpropci.Wald.mp(b = tab3[2,1], c = tab3[1,2], n = sum(tab3),
conf.level = 0.95)
data:
95 percent confidence interval:
0.06982978 0.43017022
sample estimates:
[1] 0.25
The expected difference in off-diagonal proportions appears to be anywhere from 0.07 to 0.43.
We can also use logistic regression to analyze the per pair data. To demonstrate this approach we need to convert the cross-tabulation of control and case pairs to a data frame, which we can do with as.data.frame().
tab4 <- xtabs(~ control + case, data = btw2) |>
as.data.frame()
tab4
control case Freq
1 0 0 18
2 1 0 8
3 0 1 22
4 1 1 8
Now we fit an intercept-only model to the case variable using the Freq column as weights. Notice we also only use two rows of the data frame: rows 2 and 3. This is because rows 1 and 4 are not informative. These are the case-control pairs that have the same smoking status. There’s nothing to learn from these pairs. (These are the diagonal pairs in the table above.) Once we fit the model, we exponentiate the intercept to get the odds ratio.
m2 <- glm(case ~ 1, data = tab4[2:3,],
family = binomial, weights = Freq)
exp(coef(m2))
(Intercept)
2.75
This odds ratio is slightly different from the previous result (though still in agreement). The difference is due to the models. In the first logistic regression model above, we fit a marginal model. That is, we modeled the difference in the probabilities of smoking status given case/control status. We call this a “marginal model” because the estimated probabilities are conditional on the column totals in the table. (In other words, the column totals in the margin of the table.) Recall the table that showed the column totals.
xtabs(~ smoke + low, data = btw) |>
addmargins(margin = 1)
low
smoke 0 1
0 40 26
1 16 30
Sum 56 56
We can fit a logistic regression model that specifically uses these column totals. First we create a new data frame with case-control counts (cc), column total counts (n), and a case-control indicator (low). Then we fit the model explicitly using the column totals in the formula syntax cc/n ~ low. Notice we get the same odds ratio as the first model: 2.884615.
d <- data.frame(cc = c(16, 30), n = c(56, 56), low = c(0, 1))
m3 <- glm(cc/n ~ low, data = d, family = binomial, weights = n)
exp(coef(m3))["low"]
low
2.884615
The logistic regression model fit to the per pair data frame is a conditional model. This means the estimated odds ratio is conditional on the subject as opposed to being conditional on the column totals. With a simple binary predictor, there are only four possible states a subject pair can be in. Pairs 1, 2, 5, and 9 show the four possible states.
xtabs(~ smoke + low + pairid, data = btw)[,,c(1,2,5,9)]
, , pairid = 1
low
smoke 0 1
0 1 0
1 0 1
, , pairid = 2
low
smoke 0 1
0 1 1
1 0 0
, , pairid = 5
low
smoke 0 1
0 0 0
1 1 1
, , pairid = 9
low
smoke 0 1
0 0 1
1 1 0
Pairs 2 and 5 do not contribute any information to estimating the probability of being a case given smoking status, so really only two possible states are of any consequence:
xtabs(~ smoke + low + pairid, data = btw)[,,c(1,9)]
, , pairid = 1
low
smoke 0 1
0 1 0
1 0 1
, , pairid = 9
low
smoke 0 1
0 0 1
1 1 0
These subject-specific states are used to estimate the odds ratio in a conditional model, as we demonstrated with model “m2”. Recall from the contingency table of matched pairs that we had 22 pairs of the first state and eight pairs of the second.
xtabs(~ control + case, data = btw2)
case
control 0 1
0 18 22
1 8 8
In the special case of 1-1 matching and a single binary explanatory variable, there isn’t much difference between marginal and conditional models. But if we want to include additional covariates, we need to proceed with conditional logistic regression models.
Conditional Logistic Regression
In the previous example we considered one primary variable of interest: binary smoking status.4 The simple binary nature of the explanatory variable allowed us to essentially ignore the paired structure of the data. However, if we want to include additional covariates we need to respect the structure of the data and account for the matched pairs.
We can do this using conditional logistic regression. Conditional logistic regression allows us to fit a model to each matched pair with an intercept specific to each pair, but slope coefficients common to all pairs. However, it turns out that the likelihood equation used to estimate the coefficients in this model results in the intercepts dropping out. So all that is left are coefficients common to all matched pairs. See Hosmer et al. (2013, pages 243 - 247) for the derivation of the likelihood.
We can implement conditional logistic regression using the clogit() function in the survival package. (The survival package comes installed with R.) It works just like the lm() and glm() functions except that we need to specify which variable identifies the matched pairs using the strata() function. Below we replicate the conditional model from the previous section. In the output we get the same result: an odds ratio of 2.75 along with a 95% confidence interval of [1.22, 6.18].5 Notice also that the intercept is not estimated.
library(survival)
m4 <- clogit(low ~ smoke + strata(pairid), data = btw)
summary(m4)
Call:
coxph(formula = Surv(rep(1, 112L), low) ~ smoke + strata(pairid),
data = btw, method = "exact")
n= 112, number of events= 56
coef exp(coef) se(coef) z Pr(>|z|)
smoke 1.0116 2.7500 0.4129 2.45 0.0143 *
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
exp(coef) exp(-coef) lower .95 upper .95
smoke 2.75 0.3636 1.224 6.177
Concordance= 0.625 (se = 0.065 )
Likelihood ratio test= 6.79 on 1 df, p=0.009
Wald test = 6 on 1 df, p=0.01
Score (logrank) test = 6.53 on 1 df, p=0.01
Of course the main reason to use conditional logistic regression is to include more covariates. Below we add “lwt” (weight at last menstrual period), “ptd” (mother had previous preterm baby), “ht” (mother has hypertension), “ui” (uterine irritability), and “race” (white/black/other).
m5 <- clogit(low ~ smoke + lwt + ptd + ht + ui + race + strata(pairid),
data = btw)
summary(m5)
Call:
coxph(formula = Surv(rep(1, 112L), low) ~ smoke + lwt + ptd +
ht + ui + race + strata(pairid), data = btw, method = "exact")
n= 112, number of events= 56
coef exp(coef) se(coef) z Pr(>|z|)
smoke 1.40066 4.05786 0.62784 2.231 0.0257 *
lwt -0.01838 0.98179 0.01008 -1.823 0.0683 .
ptd 1.80801 6.09829 0.78865 2.293 0.0219 *
ht 2.36115 10.60316 1.08613 2.174 0.0297 *
ui 1.40193 4.06303 0.69616 2.014 0.0440 *
raceBlack 0.57136 1.77068 0.68964 0.828 0.4074
raceOther -0.02531 0.97500 0.69920 -0.036 0.9711
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
exp(coef) exp(-coef) lower .95 upper .95
smoke 4.0579 0.24644 1.1854 13.890
lwt 0.9818 1.01855 0.9626 1.001
ptd 6.0983 0.16398 1.2999 28.609
ht 10.6032 0.09431 1.2616 89.115
ui 4.0630 0.24612 1.0382 15.901
raceBlack 1.7707 0.56475 0.4583 6.842
raceOther 0.9750 1.02564 0.2477 3.839
Concordance= 0.768 (se = 0.08 )
Likelihood ratio test= 26.04 on 7 df, p=5e-04
Wald test = 12.72 on 7 df, p=0.08
Score (logrank) test = 20.27 on 7 df, p=0.005
Controlling for these other variables results in an increased odds ratio of about 4 for smoking status, but also a much wider and more uncertain 95% confidence interval of [1.19, 13.89].
Eagle-eyed readers may have noticed that the output of clogit() says the coxph() function was used to fit the model. The coxph() function fits Cox proportional hazards models, which models time until an event occurs. It turns out that if we set the follow-up time to 1 day for each matched pair, and set the event status to case or control, the Cox proportional hazards model uses the same likelihood as conditional logistic regression.
The three tests at the end of the output (Likelihood, Wald and Score) test the null hypothesis that all model coefficients are simultaneously equal to 0. These are usually all in agreement. In this case we reject the null and conclude that at least one of the model coefficients is not equal to 0.
The statistic labeled “Concordance” reports the fraction of all pairs of subjects where the model correctly predicts the case. Values in the 0.50 - 0.55 range suggest a flip of a coin would do as well as our model at correctly predicting the case. The value of 0.768 (+/- 0.08) is promising.
The conditional logistic regression model makes predictions for each matched pair. The prediction is the probability of being the case. Within each pair the probabilities sum to 1. Let’s look at the first six predictions. We specify type = "expected" to get predicted probability.
head(predict(m5, type = "expected"))
1 2 3 4 5 6
0.0217081 0.9782919 0.3792192 0.6207808 0.6554647 0.3445353
Notice each pair of predictions sum to 1:
sum(predict(m5, type = "expected")[1:2])
[1] 1
sum(predict(m5, type = "expected")[3:4])
[1] 1
sum(predict(m5, type = "expected")[5:6])
[1] 1
If we add the predicted probabilities to the data frame and look at the first six rows, we see that the model correctly predicts the case in the first two pairs, but incorrectly predicts the control as the case in the third pair.
btw$pred <- predict(m5, type = "expected")
head(btw[,c("pairid", "low", "pred")])
# A tibble: 6 × 3
pairid low pred
<dbl> <dbl> <dbl>
1 1 0 0.0217
2 1 1 0.978
3 2 0 0.379
4 2 1 0.621
5 3 0 0.655
6 3 1 0.345
The proportion of observed cases with a predicted probability greater than 0.5 is the Concordance reported in the output.
mean(btw$pred[btw$low == 1] > 0.5)
[1] 0.7678571
Conditional logistic regression is not limited to 1-1 matched pairs. Your data can have one case matched to two or more controls. You can also have a variety of matched pairs in your data. For example, the first pair may be a 1-1 match while the second pair is a 1-3 match (one case, three controls). It usually doesn’t make sense to exceed five controls per matched set. (Faraway 2016, page 76)
Hopefully you have a better understanding of case-control studies and how to use conditional logistic regression. For a deeper dive into this topic and more examples, see Hosmer et al (2013), chapter 7.
R Session Details
The analysis was done using the R Statistical language (v4.6.1; R Core Team, 2026) on Windows 11 x64, using the packages PropCIs (v0.3.0), survival (v3.8.6) and tidyr (v1.3.2).
References
- Agresti A. (2019). An Introduction to Categorical Data Analysis, 3rd edition. Wiley.
- Faraway J. (2016). Extending the Linear Model with R, 2nd edition. CRC Press.
- Hosmer et al. (2013). Applied Logistic Regression, 3rd edition. Wiley.
- R Core Team (2026). R: A Language and Environment for Statistical Computing. R Foundation for Statistical Computing, Vienna, Austria. doi:10.32614/R.manuals https://doi.org/10.32614/R.manuals. https://www.R-project.org/.
- Scherer R (2018). PropCIs: Various Confidence Interval Methods for Proportions. doi:10.32614/CRAN.package.PropCIs https://doi.org/10.32614/CRAN.package.PropCIs. R package version 0.3-0, https://CRAN.R-project.org/package=PropCIs.
- Therneau T (2026). A Package for Survival Analysis in R. R package version 3.8-6, https://CRAN.R-project.org/package=survival.
- Wickham H, Vaughan D, Girlich M (2025). tidyr: Tidy Messy Data. doi:10.32614/CRAN.package.tidyr https://doi.org/10.32614/CRAN.package.tidyr. R package version 1.3.2, https://CRAN.R-project.org/package=tidyr.
Clay Ford
Statistical Research Consultant
University of Virginia Library
July 23, 2026
- It’s worth noting that the matching described in this article is not the same as propensity score matching. The latter is a form of modeling that predicts a probability (or score) of being in a “treated” group. This score is then used to match up control and treated subjects who had the same “propensity” of being in the treated group. See our StatLab article Getting Started with Matching Methods for more information on propensity score matching.↩︎
- If the probability of two events are p1 and p2, then the odds of those events are p1/(1-p1) and p2/(1-p2), respectively. To compare the odds between two events, we take the ratio of their odds, For example, if the two probabilities are 0.50 and 0.60, the odds are 0.50/(1-0.50) = 1 and 0.60/(1-0.60) = 1.5. The odds ratio is 1.5/1 = 1.5. This says the odds of the second event are 50% higher than the odds of the first event.↩︎
- Odds ratios describe a relative (multiplicative) increase as opposed to an absolute (additive) increase. This means an odds ratio of 1.34 describes a 34% increase. In other words, multipling a value by 1.34 increases that value by 34%.↩︎
- We also implicitly controlled for age by incorporating it into the study design.↩︎
- The output also reports
exp(-coef)with 0.3636. This is the odds ratio based on not smoking. 1 - 0.3636 = 0.6364, which implies the odds of being a case for nonsmokers is about 64% less than the odds of being a case for smokers.↩︎
For questions or clarifications regarding this article, contact statlab@virginia.edu.
View the entire collection of UVA Library StatLab articles, or learn how to cite.