Logit and Probit Models for Binary Outcomes

By Jiye Kwon (Yale School of Public Health) Published 2026-08-18

This guide introduces binary limited dependent variable models for applied researchers. It compares the linear probability model, logit, and probit; explains odds, probabilities, marginal effects, and uncertainty; and gives practical guidance for model assessment and reporting.

  • Binary Response Models
  • Limited Dependent Variables
  • Linear Probability Model
  • Logistic Regression
  • Probit Regression
  • Maximum Likelihood Estimation
  • Odds Ratios
  • Predicted Probabilities
  • Marginal Effects.

Logit and Probit Models for Binary Outcomes

Author
Affiliation

Jiye Kwon

Yale School of Public Health

Published

August 18, 2026

This guide introduces binary limited dependent variable models for applied researchers. It compares the linear probability model, logit, and probit; explains odds, probabilities, marginal effects, and uncertainty; and gives practical guidance for model assessment and reporting.

Keywords

Binary Response Models, Limited Dependent Variables, Linear Probability Model, Logistic Regression, Probit Regression, Maximum Likelihood Estimation, Odds Ratios, Predicted Probabilities, Marginal Effects.

Introduction

Many research questions involve outcomes that are inherently binary, taking one of only two possible values. Examples include whether a patient survives or dies, whether a student graduates or drops out, and whether a loan applicant defaults or repays. These outcomes are typically coded 0 and 1, where 1 conventionally indicates the presence of the event or condition of interest. Because the response variable takes only two values, the central quantity of interest is a conditional probability:

\[ p_i = \Pr(Y_i = 1 \mid X_i) \]

Modeling this probability requires care. Ordinary regression can produce impossible fitted values and heteroskedastic errors.1 This guide explains how to handle binary outcomes correctly, starting with why ordinary regression struggles and building toward the logit and probit models.

To get the most from this guide, we suggest that researchers begin by identifying whether their research goals are inferential or predictive in nature. A model used for inference is judged by the estimand, identification, and uncertainty; a model used for prediction is judged by calibration, discrimination, and validation. To learn more about how these goals dictate appropriate use, see our FAQ on inference versus prediction.

By the end of the guide, you should be able to:

  • Recognize what the linear probability model estimates and when it is useful.
  • Fit and compare logit and probit models.
  • Convert log odds, odds, and odds ratios into predicted probabilities.
  • Estimate marginal effects and risk differences.
  • Communicate uncertainty around predicted probabilities and contrasts.
  • Choose model diagnostics that match your inferential or predictive goal.

Examples in this guide draw on data and course design from BIS 505 at Yale University, taught by Prof. Lee Kennedy-Shaffer.

Binary Outcomes and Moments

Each observation of a binary outcome is a single trial with some probability \(p_{i}\) of success. That probability corresponds to a Bernoulli distribution:

\[ Y_i \sim \text{Bernoulli}(p_i) \]

For a Bernoulli outcome, the mean and variance are:

\[ E(Y_i \mid X_i) = p_i \]

\[ \text{Var}(Y_i \mid X_i) = p_i(1 - p_i) \]

Two things follow directly. First, the conditional mean is a probability, so any model for \(E(Y_i \mid X_i)\) is a model for the probability of the event. Second, the variance depends on the mean: it is largest near 0.5 and shrinks toward the boundaries. This is why the homoskedasticity assumption behind ordinary least squares does not hold for binary outcomes. That does not make OLS useless. It means you need to know what the linear probability model is doing and where it runs into trouble.

The Linear Probability Model

When OLS is used to regress a binary outcome \(Y_{i}\) on predictors, the estimator is known as the linear probability model (LPM). The name for the LPM is derived from the fact that because \(E(Y_i \mid X_i) = p_i\), the fitted value is interpreted as a predicted probability:

\[ \widehat{p}_i = X_i'\widehat{\beta} \]

The main advantage of the LPM is that coefficients are easy to interpret. Under the LPM, each coefficient is a risk difference: the change in the probability of the event for a one-unit change in the predictor, holding other variables fixed. A coefficient of −0.05, for example, means a 5 percentage point lower probability of the event. With a binary predictor, the coefficient is simply the probability difference between the two groups. Risk differences are directly interpretable and can be compared across studies.

Warning

Out-of-bounds predictions: When baseline probabilities are well away from 0 or 1 (roughly 0.2 to 0.8 for most covariate values), the LPM’s out-of-bounds predictions are rarely a practical problem. When predicted values fall outside [0, 1] for a large share of the sample, or when the research goal requires well-calibrated probabilities at the boundaries, switch to logit or probit.

For many applied problems, especially causal designs with a clear estimand and moderate probabilities, the LPM is a useful baseline. Use heteroskedasticity-robust or cluster-robust standard errors, and compare its risk differences with logit/probit average marginal effects rather than with logit/probit coefficients. Logit and probit address these limitations by applying a link function that keeps predicted probabilities within the [0, 1] range.

Generalized Linear Models for Binary Outcomes

Despite the conveniences of the LPM, there are a few drawbacks. Using OLS can sometimes produce predicted values that fall below zero or above one, violating the natural bounds of a probability. The linear function can also be a poor approximation when probabilities change sharply over the observed data. Finally, the standard errors of the LPM are heteroskedastic by construction because the variance of a binary outcome depends on the predicted probability \(\text{Var}(u_i \mid x_i) = \hat{p}_i(1 - \hat{p}_i)\). These features make OLS ill suited for prediction. Generalized Linear Models or GLMs are a useful alternative. Unlike OLS, which presumes linear dependencies between outcomes and covariates, GLMs apply a link function \(g\) to smooth over predicted values that fall outside of the range of observed values:

\[ p_i = g(\eta_i) = g(X_i'\beta) \]

For a binary outcome, the two most common link functions are the logistic CDF and standard normal CDF. In regression settings these two links are informally referred to as logistic (logit) and probit regression. As shown in the figure below, both estimators transform a linear index into a probability between 0 and 1 by applying a sigmoidal function. The main difference between the two functions is in the tails. Logistic regression has fatter tails than the probit model, meaning predicted probabilities approach zero and one more gradually. In practice, the two estimators usually produce similar probabilities and marginal effects across the covariate distribution. Differences between these two models only become material when predicted probabilities are themselves extreme, as in rare-event settings or out-of-sample extrapolation. While probit may sometimes be preferred if the underlying error structure of a model is plausibly Gaussian, the decision to use probit or logit is mostly a preference question.

The choice of \(F\) defines the model:

Model Probability model Main practical consequence
Linear probability model \(p_i = X_i'\beta\) Easy risk-difference interpretation, but predictions can fall outside \([0,1]\).
Logit \(p_i = \Lambda(X_i'\beta)\) Uses the logistic curve; coefficients can be exponentiated into odds ratios.
Probit \(p_i = \Phi(X_i'\beta)\) Uses the standard normal curve; common in latent-index traditions.
Code
curve_data <- tibble(
    eta = rep(seq(-4, 4, length.out = 200), 3),
    model = rep(c("LPM", "Logit", "Probit"), each = 200)
) |>
    mutate(
        probability = case_when(
            model == "LPM" ~ 0.15 * eta + 0.5,
            model == "Logit" ~ plogis(eta),
            model == "Probit" ~ pnorm(eta)
        )
    )

ggplot(curve_data, aes(x = eta, y = probability, color = model, linetype = model)) +
    geom_line(linewidth = 1.1) +
    geom_hline(yintercept = c(0, 1), linetype = "dotted", color = "gray60") +
    scale_color_manual(values = c("LPM" = "#E69F00", "Logit" = "#0072B2", "Probit" = "#009E73")) +
    scale_linetype_manual(values = c("LPM" = "dashed", "Logit" = "solid", "Probit" = "solid")) +
    scale_y_continuous(limits = c(-0.15, 1.15), breaks = c(0, 0.25, 0.5, 0.75, 1)) +
    labs(
        x = expression(paste("Linear index ", eta)),
        y = "Predicted probability",
        color = NULL, linetype = NULL
    ) +
    theme_minimal() +
    theme(legend.position = "bottom")

Comparison of LPM, logit, and probit link functions

Formally, the logit model is:

\[ \text{logit}(p_i) = \log\left(\frac{p_i}{1 - p_i}\right) = X_i'\beta \]

Note

Log odds (logit): The natural log of the odds, \(\log(p/(1-p))\). Log-odds run from \(-\infty\) to \(+\infty\), making it a natural quantity for a linear model. A log-odds of 0 corresponds to a probability of 0.5; positive values correspond to probabilities above 0.5.

Odds: The ratio \(p/(1-p)\). Odds of 2 mean the event is twice as likely to occur as not to occur, corresponding to a probability of \(2/3 \approx 0.67\).

Note

A latent-variable story is another way to motivate the same models. Imagine an unobserved continuous propensity, risk, or utility:

\[ Y_i^* = X_i'\beta + \epsilon_i \]

We do not observe \(Y_i^*\) directly. We only observe whether it crosses a threshold:

\[ Y_i = \begin{cases} 1 & \text{if } Y_i^* \ge 0 \\ 0 & \text{if } Y_i^* < 0 \end{cases} \]

If the unobserved error \(\epsilon_i\) is logistic, the model is logit. If it is standard normal, the model is probit. This story can be useful, but the latent score itself is usually not the quantity practitioners should report.

Maximum Likelihood Estimation

Because binary outcomes do not have normally distributed residuals, the OLS criterion of minimizing squared residuals is no longer the natural objective. Logit and probit are instead estimated by maximum likelihood. The goal is to find the coefficient vector \(\beta\) that makes the observed pattern of 0s and 1s as probable as possible under the model.

For each observation, the likelihood contribution depends on what was actually observed. If \(Y_i = 1\), the contribution is the model’s predicted probability \(p_i\); if \(Y_i = 0\), it is one minus that probability. Formally:

\[ L_i(\beta) = p_i^{Y_i}(1 - p_i)^{1 - Y_i} \]

The full likelihood is the product of these contributions across all observations:

\[ L(\beta) = \prod_{i=1}^n p_i^{Y_i}(1 - p_i)^{1 - Y_i} \]

We want this product to be as large as possible. In practice, software maximizes the log-likelihood, which converts the product to a sum and is more numerically stable:

\[ \ell(\beta) = \sum_{i=1}^n \left[Y_i \log(p_i) + (1 - Y_i)\log(1 - p_i)\right] \]

The maximum is found by numerical optimization, often using Newton-Raphson, Fisher scoring, or iteratively reweighted least squares in standard binary GLMs; more complex likelihoods, such as mixed-effects models, may use other constrained or derivative-free optimizers.2 Standard errors come from the curvature of the log-likelihood at the maximum: a sharper peak means more precise estimates and smaller standard errors. If the model is misspecified, observations are clustered, or the sample has unusual dependence, model-based standard errors can be too optimistic. In those cases, use robust or cluster-robust standard errors.

Implementation

The sections below demonstrate how to fit the LPM, logit, and probit to the same outcome and predictors. Keeping the specification constant across models makes it straightforward to see where they agree, where they diverge, and why the choice of reporting scale often matters more than the choice of link function.

Data

The example uses the WASH Benefits Bangladesh data discussed in Lin et al. (2018). We model whether a child had Giardia infection (Giardia) using treatment assignment and covariates.

Paper: Lin, A., et al. (2018). Effects of water, sanitation, handwashing, and nutritional interventions on environmental enteric dysfunction in young children: WASH Benefits Bangladesh. Clinical Infectious Diseases, 67(10), 1515–1526.

Data: The WASH Benefits Bangladesh data used in this example are available from the Open Science Framework project. The example models whether a child had Giardia infection (Giardia) using randomized treatment assignment and child- and household-level covariates. We use a derived dataset from the original data, which can be accessed via this link: washb-index.csv

Code
data_path <- here::here(
    "./research-guides/logit-probit/assets/data/washb-index.csv"
)

wshi <- read.csv(data_path)
wshi$WSH <- factor(wshi$WSH, levels = c("Control", "WSH"))
wshi$Treatment <- factor(wshi$Treatment)
wshi$AgeMonths <- wshi$Age / 30.4375

head(wshi)
  Treatment     WSH Giardia DistanceH20 Age NumChildren Cluster Block AgeMonths
1     N+WSH     WSH       0           3 906           1     282     1  29.76591
2     N+WSH     WSH       0           0 911           1     282     1  29.93018
3   Control Control       0           0 898           2     283     1  29.50308
4   Control Control       1           1 962           2     283     1  31.60575
5   Control Control       0           0 933           3     283     1  30.65298
6   Control Control       1           0 920           1     286     1  30.22587
Code
import pandas as pd

wshi = r.wshi
wshi["WSH"] = pd.Categorical(wshi["WSH"], categories=["Control", "WSH"])
wshi["AgeMonths"] = wshi["Age"] / 30.4375
wshi.head()
Code
import delimited "data/washb-index.csv", clear
encode WSH, gen(wsh_group)
label list wsh_group
gen AgeMonths = Age / 30.4375
Code
using CSV, DataFrames, CategoricalArrays

wshi = CSV.read("data/washb-index.csv", DataFrame)
wshi.WSH = categorical(wshi.WSH; levels=["Control", "WSH"])
wshi.AgeMonths = wshi.Age ./ 30.4375
first(wshi, 6)

With the data loaded, the treatment variable factored with the control group as the reference, and age converted to months, the next step is to examine unadjusted event rates directly before reaching for a regression model.

Descriptive Risk

Before fitting a model, compute the event rate by group. This is the simplest estimate of risk.

Code
risk_table <- wshi |>
    group_by(WSH) |>
    summarize(
        no_giardia = sum(Giardia == 0),
        giardia = sum(Giardia == 1),
        total = n(),
        risk = mean(Giardia == 1)
    )

risk_table
# A tibble: 2 × 5
  WSH     no_giardia giardia total  risk
  <fct>        <int>   <int> <int> <dbl>
1 Control        545     253   798 0.317
2 WSH            706     240   946 0.254
Code
risk_table = (
    wshi.groupby("WSH")
    .agg(
        no_giardia=("Giardia", lambda x: (x == 0).sum()),
        giardia=("Giardia", lambda x: (x == 1).sum()),
        total=("Giardia", "size"),
        risk=("Giardia", "mean"),
    )
    .reset_index()
)
print(risk_table)
Code
tabulate wsh_group Giardia, row
mean Giardia, over(wsh_group)
Code
using Statistics

risk_table = combine(groupby(wshi, :WSH),
    :Giardia => (x -> sum(x .== 0)) => :no_giardia,
    :Giardia => (x -> sum(x .== 1)) => :giardia,
    :Giardia => length => :total,
    :Giardia => mean => :risk
)

These raw event rates are the simplest possible estimate of risk by group. Any model-adjusted estimate should be broadly reconcilable with them; if adjusted and unadjusted estimates diverge substantially without a clear explanation it can be worthwhile to investigate. (See Why Do My Adjusted and Unadjusted Estimates Diverge?.)

Linear Probability Model

The linear probability model estimates risk differences directly by regressing the binary outcome on predictors using OLS. Because binary outcomes are heteroskedastic by construction, the default standard errors are biased; always use robust standard errors when reporting LPM results.

Code
lpm <- lm(Giardia ~ WSH + AgeMonths + NumChildren + DistanceH20, data = wshi)
lpm_robust <- lmtest::coeftest(lpm, vcov. = sandwich::vcovHC(lpm, type = "HC1"))

lpm_robust

t test of coefficients:

              Estimate Std. Error t value Pr(>|t|)   
(Intercept)  0.1344828  0.1707227  0.7877 0.430964   
WSHWSH      -0.0631009  0.0217068 -2.9070 0.003696 **
AgeMonths    0.0054059  0.0057134  0.9462 0.344185   
NumChildren  0.0086971  0.0089332  0.9736 0.330407   
DistanceH20  0.0117610  0.0101575  1.1579 0.247079   
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Code
import statsmodels.formula.api as smf

lpm = smf.ols(
    "Giardia ~ C(WSH, Treatment(reference='Control')) + AgeMonths + NumChildren + DistanceH20",
    data=wshi
).fit(cov_type="HC1")

print(lpm.summary())
Code
regress Giardia i.wsh_group AgeMonths NumChildren DistanceH20, vce(robust)
Code
using GLM, StatsModels

lpm = lm(@formula(Giardia ~ WSH + AgeMonths + NumChildren + DistanceH20), wshi)
coeftable(lpm)

# For robust standard errors in Julia, use CovarianceMatrices.jl:
# vcov(HC1(), lpm)

The WSH coefficient is an adjusted risk difference: a negative value means the WSH group has a lower probability of infection than the control group, after accounting for age, household size, and distance to water. A coefficient of \(-0.03\), for example, means a 3 percentage point lower risk of infection. These estimates are already on the probability scale and require no transformation. The nonlinear models below estimate the same relationship on the log-odds or probit scale; translating them back to probability differences requires additional steps, covered in the sections that follow.

Logit Model

The logit model applies the logistic link function to keep predicted probabilities within the (0, 1) interval. Rather than estimating probability differences directly, it models the log-odds of the outcome as a linear function of the predictors.

Code
logit_fit <- glm(
    Giardia ~ WSH + AgeMonths + NumChildren + DistanceH20,
    family = binomial(link = "logit"),
    data = wshi
)

summary(logit_fit)

Call:
glm(formula = Giardia ~ WSH + AgeMonths + NumChildren + DistanceH20, 
    family = binomial(link = "logit"), data = wshi)

Coefficients:
            Estimate Std. Error z value Pr(>|z|)   
(Intercept) -1.67158    0.86968  -1.922  0.05460 . 
WSHWSH      -0.31091    0.10677  -2.912  0.00359 **
AgeMonths    0.02677    0.02900   0.923  0.35608   
NumChildren  0.04276    0.04410   0.969  0.33231   
DistanceH20  0.05656    0.04663   1.213  0.22517   
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

(Dispersion parameter for binomial family taken to be 1)

    Null deviance: 2077  on 1743  degrees of freedom
Residual deviance: 2065  on 1739  degrees of freedom
AIC: 2075

Number of Fisher Scoring iterations: 4
Code
import statsmodels.formula.api as smf

formula = "Giardia ~ C(WSH, Treatment(reference='Control')) + AgeMonths + NumChildren + DistanceH20"

logit_fit = smf.logit(formula, data=wshi).fit()
print(logit_fit.summary())
Code
logit Giardia i.wsh_group AgeMonths NumChildren DistanceH20
Code
using GLM, StatsModels

logit_fit = glm(
    @formula(Giardia ~ WSH + AgeMonths + NumChildren + DistanceH20),
    wshi,
    Binomial(),
    LogitLink()
)

The Estimate column gives log-odds coefficients: each value is the change in log-odds of the outcome for a one-unit increase in that predictor, holding others constant. A negative coefficient on WSHWSH means the WSH group has lower log-odds of Giardia infection than the control group. The magnitude is not directly interpretable as a probability difference.

Probit Model

The probit model uses the standard normal CDF as its link function rather than the logistic curve.

Code
probit_fit <- glm(
    Giardia ~ WSH + AgeMonths + NumChildren + DistanceH20,
    family = binomial(link = "probit"),
    data = wshi
)

summary(probit_fit)

Call:
glm(formula = Giardia ~ WSH + AgeMonths + NumChildren + DistanceH20, 
    family = binomial(link = "probit"), data = wshi)

Coefficients:
            Estimate Std. Error z value Pr(>|z|)   
(Intercept) -1.01809    0.52097  -1.954  0.05068 . 
WSHWSH      -0.18574    0.06401  -2.901  0.00371 **
AgeMonths    0.01607    0.01738   0.924  0.35527   
NumChildren  0.02513    0.02652   0.948  0.34326   
DistanceH20  0.03406    0.02828   1.204  0.22841   
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

(Dispersion parameter for binomial family taken to be 1)

    Null deviance: 2077.0  on 1743  degrees of freedom
Residual deviance: 2065.1  on 1739  degrees of freedom
AIC: 2075.1

Number of Fisher Scoring iterations: 4
Code
probit_fit = smf.probit(formula, data=wshi).fit()
print(probit_fit.summary())
Code
probit Giardia i.wsh_group AgeMonths NumChildren DistanceH20
Code
probit_fit = glm(
    @formula(Giardia ~ WSH + AgeMonths + NumChildren + DistanceH20),
    wshi,
    Binomial(),
    ProbitLink()
)

The probit coefficients are on the standard normal scale and are not directly comparable in magnitude to the logit coefficients. As with the logit, the most interpretable summaries come from converting to predicted probabilities or average marginal effects, covered in the sections below.

Note

Standard error choice for logit and probit. The right SE depends on your sampling design, treatment assignment, clustering, repeated measurements, and study design and not the choice of link function. For logit and probit, robust or cluster-robust covariance choices affect coefficient SEs, Wald tests, and uncertainty intervals for marginal effects and predicted contrasts. They do not change MLE point estimates. See the Standard Errors guide.

Convert Logit Coefficients to Odds Ratios and Confidence Intervals

The logit coefficients are on the log-odds scale, which is rarely interpretable on its own. Exponentiating each coefficient transforms it into an odds ratio: a multiplicative comparison of the odds of the event between two conditions. Odds ratios are the conventional reporting scale in clinical epidemiology and case-control studies, and they appear as the default output in many published tables. Confidence intervals for odds ratios are obtained by exponentiating the confidence interval endpoints for the corresponding log-odds coefficient.

Note

Odds ratio (OR): The ratio of the odds of the event under two conditions. An OR of 1 means equal odds. An OR of 2 means the odds are twice as high in the exposed group. The OR is a multiplicative comparison of odds, not of probabilities.

Code
logit_or <- broom::tidy(logit_fit, conf.int = TRUE) |>
    mutate(
        odds_ratio = exp(estimate),
        or_low = exp(conf.low),
        or_high = exp(conf.high)
    ) |>
    select(term, estimate, std.error, odds_ratio, or_low, or_high, p.value)

logit_or
# A tibble: 5 × 7
  term        estimate std.error odds_ratio or_low or_high p.value
  <chr>          <dbl>     <dbl>      <dbl>  <dbl>   <dbl>   <dbl>
1 (Intercept)  -1.67      0.870       0.188 0.0340   1.03  0.0546 
2 WSHWSH       -0.311     0.107       0.733 0.594    0.903 0.00359
3 AgeMonths     0.0268    0.0290      1.03  0.970    1.09  0.356  
4 NumChildren   0.0428    0.0441      1.04  0.957    1.14  0.332  
5 DistanceH20   0.0566    0.0466      1.06  0.964    1.16  0.225  
Code
import numpy as np

ci = logit_fit.conf_int()
or_table = pd.DataFrame({
    "term": logit_fit.params.index,
    "log_odds": logit_fit.params.values,
    "odds_ratio": np.exp(logit_fit.params.values),
    "or_low": np.exp(ci[0].values),
    "or_high": np.exp(ci[1].values),
    "p_value": logit_fit.pvalues.values
})
print(or_table)
Code
logit Giardia i.wsh_group AgeMonths NumChildren DistanceH20, or
Code
coef_names = coefnames(logit_fit)
log_odds = coef(logit_fit)
odds_ratio = exp.(log_odds)
DataFrame(term=coef_names, log_odds=log_odds, odds_ratio=odds_ratio)

Each row shows a predictor’s log-odds coefficient alongside its exponentiated counterpart, the odds ratio. An odds_ratio greater than 1 indicates higher odds of the event; less than 1 indicates lower odds — for example, an odds_ratio of 0.80 for WSHWSH would mean the WSH group has 20% lower odds of Giardia infection than the control group, adjusting for the other covariates in the model.

Warning

The odds ratio is not the risk ratio. If the baseline probability is \(p_0 = 0.40\) and the OR is 0.5, the corresponding risk ratio is approximately \(0.71\), not \(0.5\). The two quantities are close only when the outcome is rare (roughly below 10%). When communicating results to a general audience, supplement odds ratios with predicted probabilities or risk differences.

Calculating Predicted Probabilities

To convert a log-odds estimate back to a probability, apply the inverse-logit transformation:

\[ p_i = \frac{\exp(X_i'\beta)}{1 + \exp(X_i'\beta)} \]

One common mistake when transforming data from log-odds back to a probability is combining odds ratios before transforming the data. Because the log-odds scale is additive, combination must happen their first; you should only exponentiate after you have combined predictors.

When combining multiple predictors, always work on the log-odds scale before transforming to probabilities. Suppose predictors A and B have odds ratios of 2 and 1.5; the combined odds ratio is \(\exp(\log 2 + \log 1.5) = \exp(1.099) \approx 3.0\), not \(3.5\).

Following the same logic, it is best to derive confidence intervals on the log-odds scale and then transform them using plogis() to ensure bounds remain \(\in [0,1]\).

Code
profile_grid <- tibble(
    WSH = factor(c("Control", "WSH"), levels = c("Control", "WSH")),
    AgeMonths = mean(wshi$AgeMonths, na.rm = TRUE),
    NumChildren = mean(wshi$NumChildren, na.rm = TRUE),
    DistanceH20 = mean(wshi$DistanceH20, na.rm = TRUE)
)

logit_pred_link <- predict(logit_fit, newdata = profile_grid, type = "link", se.fit = TRUE)

profile_pred <- profile_grid |>
    mutate(
        eta = logit_pred_link$fit,
        se_eta = logit_pred_link$se.fit,
        probability = plogis(eta),
        low = plogis(eta - 1.96 * se_eta),
        high = plogis(eta + 1.96 * se_eta)
    )

profile_pred
# A tibble: 2 × 9
  WSH    AgeMonths NumChildren DistanceH20    eta se_eta probability   low  high
  <fct>      <dbl>       <dbl>       <dbl>  <dbl>  <dbl>       <dbl> <dbl> <dbl>
1 Contr…      29.8        1.61       0.608 -0.770 0.0762       0.317 0.285 0.350
2 WSH         29.8        1.61       0.608 -1.08  0.0748       0.253 0.227 0.282
Code
profile_grid = pd.DataFrame({
    "WSH": pd.Categorical(["Control", "WSH"], categories=["Control", "WSH"]),
    "AgeMonths": [wshi["AgeMonths"].mean()] * 2,
    "NumChildren": [wshi["NumChildren"].mean()] * 2,
    "DistanceH20": [wshi["DistanceH20"].mean()] * 2,
})

pred = logit_fit.get_prediction(profile_grid)
print(pred.summary_frame(alpha=0.05))
Code
logit Giardia i.wsh_group AgeMonths NumChildren DistanceH20
margins wsh_group, atmeans predict(pr)
Code
profile_grid = DataFrame(
    WSH=categorical(["Control", "WSH"]; levels=["Control", "WSH"]),
    AgeMonths=fill(mean(skipmissing(wshi.AgeMonths)), 2),
    NumChildren=fill(mean(skipmissing(wshi.NumChildren)), 2),
    DistanceH20=fill(mean(skipmissing(wshi.DistanceH20)), 2)
)

profile_grid.probability = predict(logit_fit, profile_grid)
profile_grid

Plotting Predicted Probabilities

Code
ggplot(profile_pred, aes(x = WSH, y = probability)) +
    geom_point(size = 3, color = "darkgreen") +
    geom_errorbar(aes(ymin = low, ymax = high), width = 0.12, color = "darkgreen") +
    scale_y_continuous(limits = c(0, max(profile_pred$high) + 0.05)) +
    labs(
        title = "Adjusted Predicted Probability of Giardia Infection",
        x = NULL,
        y = "Predicted probability"
    ) +
    theme_minimal()

Code
import matplotlib.pyplot as plt

plt.errorbar(
    profile_grid["WSH"],
    pred.summary_frame()["predicted"],
    yerr=[
        pred.summary_frame()["predicted"] - pred.summary_frame()["ci_lower"],
        pred.summary_frame()["ci_upper"] - pred.summary_frame()["predicted"],
    ],
    fmt="o",
    color="darkgreen",
    capsize=4,
)
plt.ylabel("Predicted probability")
plt.xlabel("")
plt.title("Adjusted Predicted Probability of Giardia Infection")
plt.show()
Code
logit Giardia i.wsh_group AgeMonths NumChildren DistanceH20
margins wsh_group, atmeans predict(pr)
marginsplot, ///
    ytitle("Predicted probability") ///
    title("Adjusted Predicted Probability of Giardia Infection")
Code
using Plots

scatter(
    profile_grid.WSH,
    profile_grid.probability,
    color=:darkgreen,
    legend=false,
    xlabel="",
    ylabel="Predicted probability",
    title="Adjusted Predicted Probability of Giardia Infection"
)

The WSH group has an adjusted predicted probability of Giardia infection of 0.25, compared to 0.32 in the control group. Thus, we can say that those who were treated were seven percentage points less likely to contract Giardia, all else equal. Note that this gap is smaller in magnitude than the raw log-odds coefficient (-0.31) would suggest because the inverse-logit compresses differences near 0 and 1.

Average Marginal Effects

Odds ratios and log-odds coefficients are difficult to communicate to general audiences, and neither is a probability difference. Average marginal effects translate a nonlinear model back to the probability scale, producing an estimate directly comparable to the LPM’s risk difference coefficient. For a binary predictor, the AME is the average over all observations of the individual predicted probability change when switching from the reference to the comparison category — computed at each observation’s own covariate values, not at a hypothetical average person.

The choice between marginal effects, predicted probabilities, and odds ratios is a choice about what to report, not about model fit. Report average marginal effects when your estimand is a risk difference, when you want results directly comparable to the LPM, or when communicating to readers unfamiliar with odds ratios. Use predicted probabilities at specific covariate profiles instead when absolute risk for a defined group is the quantity of interest — for example, the predicted probability of infection for a child aged 24 months assigned to the control condition. Odds ratios remain appropriate when field convention requires them (particularly in clinical epidemiology and case-control designs), but should always be accompanied by predicted probabilities or marginal effects so readers can assess magnitude on a familiar scale.

Note

Average marginal effect (AME): Each observation’s marginal effect is computed at its own covariate values and then averaged over the sample. For a binary predictor, the AME is the average predicted probability difference when you switch everyone from control to treatment.

Marginal effect at the mean (MEM): The marginal effect evaluated at the sample means of all covariates. For binary predictors, the “average person” defined by MEM may not correspond to any real observation in the data. The AME is usually preferred.

For a binary treatment indicator, the average marginal effect is the average predicted probability difference when the same observations are predicted under treatment and under control.

Code
control_data <- wshi
treated_data <- wshi
control_data$WSH <- factor("Control", levels = c("Control", "WSH"))
treated_data$WSH <- factor("WSH", levels = c("Control", "WSH"))

ame_logit <- mean(predict(logit_fit, newdata = treated_data, type = "response") -
    predict(logit_fit, newdata = control_data, type = "response"))

ame_probit <- mean(predict(probit_fit, newdata = treated_data, type = "response") -
    predict(probit_fit, newdata = control_data, type = "response"))

tibble(
    model = c("LPM", "Logit", "Probit"),
    probability_difference = c(coef(lpm)["WSHWSH"], ame_logit, ame_probit)
)
# A tibble: 3 × 2
  model  probability_difference
  <chr>                   <dbl>
1 LPM                   -0.0631
2 Logit                 -0.0631
3 Probit                -0.0628
Code
control_data = wshi.copy()
treated_data = wshi.copy()
control_data["WSH"] = "Control"
treated_data["WSH"] = "WSH"

ame_logit = (
    logit_fit.predict(treated_data) -
    logit_fit.predict(control_data)
).mean()

ame_probit = (
    probit_fit.predict(treated_data) -
    probit_fit.predict(control_data)
).mean()

print({"logit_ame": ame_logit, "probit_ame": ame_probit})
Code
logit Giardia i.wsh_group AgeMonths NumChildren DistanceH20
margins, dydx(wsh_group)

probit Giardia i.wsh_group AgeMonths NumChildren DistanceH20
margins, dydx(wsh_group)
Code
control_data = copy(wshi)
treated_data = copy(wshi)
control_data.WSH .= "Control"
treated_data.WSH .= "WSH"

ame_logit = mean(predict(logit_fit, treated_data) .- predict(logit_fit, control_data))
ame_probit = mean(predict(probit_fit, treated_data) .- predict(probit_fit, control_data))
Warning

AMEs are estimates and need uncertainty intervals. The point estimates above show only the average probability difference. Without confidence intervals they cannot support inference. The section below shows how to compute AMEs with standard errors and CIs.

AME Confidence Intervals

For most applied work, the margins package in R and the get_margeff() method in Python’s statsmodels return AMEs with standard errors and confidence intervals using the delta method. These are easier to use than the manual g-computation above and should be the default reporting approach.

Code
# install.packages("margins") if needed
library(margins)

ame_with_ci <- margins(logit_fit, variables = "WSH")
summary(ame_with_ci)
 factor     AME     SE       z      p   lower   upper
 WSHWSH -0.0631 0.0217 -2.9071 0.0036 -0.1056 -0.0206
Code
# get_margeff() returns AMEs with SEs and CIs by default
margeff = logit_fit.get_margeff()
print(margeff.summary())
Code
logit Giardia i.wsh_group AgeMonths NumChildren DistanceH20
* margins already returns SEs and CIs
margins, dydx(wsh_group)
Code
# Manual delta-method AME CIs in Julia require computing the Jacobian
# of the AME with respect to the coefficient vector and then applying
# the delta method: Var(AME) = J * vcov(fit) * J'
# The Effects.jl package provides a higher-level interface for marginal effects.
# using Effects
# effects(Dict(:WSH => ["Control", "WSH"]), logit_fit)
Note

The margins package (R) and get_margeff() (Python) use the delta method to compute standard errors. For complex functions of the AME or for robustness checks, bootstrap confidence intervals are an alternative. For clustered data, pass cluster-robust variance matrices to the margins call via the vcov argument.

Predicted Probability Across a Continuous Predictor

A logit or probit coefficient for a continuous predictor is not a fixed marginal effect — it varies depending on all covariates. The best way to show this is to plot the predicted probability over the range of the predictor, with a confidence ribbon. The example below plots predicted probability of Giardia infection across observed child ages, holding other covariates at their sample means.

Code
age_grid <- tibble(
    AgeMonths = seq(min(wshi$AgeMonths, na.rm = TRUE), max(wshi$AgeMonths, na.rm = TRUE), length.out = 100),
    WSH = factor("Control", levels = c("Control", "WSH")),
    NumChildren = mean(wshi$NumChildren, na.rm = TRUE),
    DistanceH20 = mean(wshi$DistanceH20, na.rm = TRUE)
)

age_pred <- predict(logit_fit, newdata = age_grid, type = "link", se.fit = TRUE)

age_grid <- age_grid |>
    mutate(
        eta = age_pred$fit,
        se_eta = age_pred$se.fit,
        probability = plogis(eta),
        low = plogis(eta - 1.96 * se_eta),
        high = plogis(eta + 1.96 * se_eta)
    )

ggplot(age_grid, aes(x = AgeMonths, y = probability)) +
    geom_ribbon(aes(ymin = low, ymax = high), alpha = 0.2, fill = "darkgreen") +
    geom_line(color = "darkgreen", linewidth = 1) +
    labs(
        title = "Predicted Probability of Giardia by Age (Control group, other covariates at means)",
        x = "Age (months)",
        y = "Predicted probability"
    ) +
    theme_minimal()

Code
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

age_grid = pd.DataFrame({
    "AgeMonths": np.linspace(wshi["AgeMonths"].min(), wshi["AgeMonths"].max(), 100),
    "WSH": pd.Categorical(["Control"] * 100, categories=["Control", "WSH"]),
    "NumChildren": [wshi["NumChildren"].mean()] * 100,
    "DistanceH20": [wshi["DistanceH20"].mean()] * 100,
})

pred = logit_fit.get_prediction(age_grid).summary_frame(alpha=0.05)

plt.plot(age_grid["AgeMonths"], pred["predicted"], color="darkgreen")
plt.fill_between(age_grid["AgeMonths"], pred["ci_lower"], pred["ci_upper"], alpha=0.2, color="darkgreen")
plt.xlabel("Age (months)")
plt.ylabel("Predicted probability")
plt.title("Predicted Probability of Giardia by Age")
plt.show()
Code
logit Giardia i.wsh_group AgeMonths NumChildren DistanceH20
margins, at(AgeMonths=(25(.5)37) WSH==1) atmeans predict(pr)
marginsplot, ///
    ytitle("Predicted probability") ///
    xtitle("Age (months)") ///
    title("Predicted Probability of Giardia by Age")
Code
using DataFrames, Plots, Statistics

age_vals = range(minimum(skipmissing(wshi.AgeMonths)), maximum(skipmissing(wshi.AgeMonths)), length=100)
age_grid = DataFrame(
    AgeMonths = collect(age_vals),
    WSH = categorical(fill("Control", 100); levels=["Control", "WSH"]),
    NumChildren = fill(mean(skipmissing(wshi.NumChildren)), 100),
    DistanceH20 = fill(mean(skipmissing(wshi.DistanceH20)), 100)
)

age_grid.probability = predict(logit_fit, age_grid)

plot(age_grid.AgeMonths, age_grid.probability,
    color=:darkgreen,
    xlabel="Age (months)",
    ylabel="Predicted probability",
    title="Predicted Probability of Giardia by Age",
    legend=false)
Note

The S-shape of the curve illustrates why a single “the effect of Age” is hard to state: the slope of the probability is steepest near the midpoint and flatter at the tails. For a summary measure, report the AME or a specific contrast (e.g., predicted probability at the 25th vs. 75th percentile of Age).

Uncertainty for Predicted Probabilities

Predicted probabilities are estimates, so they need uncertainty intervals. A common mistake in limited dependent variable models is to report fitted or predicted probabilities as if they were known exactly. Herron (1999) emphasizes that this practice can overstate precision, especially when researchers report functions of probabilities without intervals.

One transparent approach is simulation from the estimated coefficient distribution:

\[ \tilde{\beta}^{(s)} \sim N(\widehat{\beta}, \widehat{V}) \]

For each draw, compute the predicted probability or probability difference. The empirical quantiles of the simulated quantities form an uncertainty interval.

Code
set.seed(505)

simulate_profile_prob <- function(fit, newdata, draws = 2000) {
    x <- model.matrix(delete.response(terms(fit)), newdata)
    beta_hat <- coef(fit)
    vcov_hat <- vcov(fit)
    beta_draws <- MASS::mvrnorm(draws, beta_hat, vcov_hat)
    prob_draws <- plogis(beta_draws %*% t(x))
    as_tibble(prob_draws)
}

prob_draws <- simulate_profile_prob(logit_fit, profile_grid)

sim_summary <- tibble(
    WSH = c("Control", "WSH"),
    estimate = as.numeric(profile_pred$probability),
    low = apply(prob_draws, 2, quantile, probs = 0.025),
    high = apply(prob_draws, 2, quantile, probs = 0.975)
)

sim_summary
# A tibble: 2 × 4
  WSH     estimate   low  high
  <chr>      <dbl> <dbl> <dbl>
1 Control    0.317 0.284 0.349
2 WSH        0.253 0.228 0.281
Code
import numpy as np

rng = np.random.default_rng(505)
beta_draws = rng.multivariate_normal(
    mean=logit_fit.params,
    cov=logit_fit.cov_params(),
    size=2000
)

design = logit_fit.model.data.design_info.builder.build_design_matrices(
    [logit_fit.model.data.design_info],
    profile_grid
)[0]

prob_draws = 1 / (1 + np.exp(-(beta_draws @ np.asarray(design).T)))
intervals = np.quantile(prob_draws, [0.025, 0.975], axis=0)
print(intervals)
Code
logit Giardia i.wsh_group AgeMonths NumChildren DistanceH20
margins wsh_group, atmeans predict(pr) post
estat vce

* For simulation-based intervals, use drawnorm with e(b) and e(V),
* or bootstrap the full model and margins workflow.
Code
using Distributions, LinearAlgebra, Random

Random.seed!(505)
beta_hat = coef(logit_fit)
vc = vcov(logit_fit)
beta_draws = rand(MvNormal(beta_hat, Symmetric(vc)), 2000)'

# Build a model matrix for profile_grid using the same formula when needed,
# then transform eta draws with the inverse logit.

Model Fit, Diagnostics, and Reporting

Goodness of fit is not one number. Hagle and Mitchell (1992) review several fit measures for probit and logit and show why pseudo-\(R^2\) values should not be read as ordinary OLS \(R^2\). Raftery (1995) is useful when model comparison is the goal because AIC and BIC connect fit to model complexity.

Choose diagnostics based on the research goal:

  • For explanatory or causal questions, focus on the estimand, model specification, uncertainty, and robustness.
  • For prediction questions, focus on calibration, discrimination, and validation.
  • For communication, report predicted probabilities and contrasts with intervals.
Code
fit_summary <- tibble(
    model = c("LPM", "Logit", "Probit"),
    log_likelihood = c(NA, as.numeric(logLik(logit_fit)), as.numeric(logLik(probit_fit))),
    AIC = c(AIC(lpm), AIC(logit_fit), AIC(probit_fit)),
    BIC = c(BIC(lpm), BIC(logit_fit), BIC(probit_fit))
)

fit_summary
# A tibble: 3 × 4
  model  log_likelihood   AIC   BIC
  <chr>           <dbl> <dbl> <dbl>
1 LPM               NA  2166. 2199.
2 Logit          -1033. 2075. 2102.
3 Probit         -1033. 2075. 2102.
Code
logit_prob <- predict(logit_fit, type = "response")
brier <- mean((wshi$Giardia - logit_prob)^2)
roc_obj <- pROC::roc(wshi$Giardia, logit_prob, quiet = TRUE)
auc_value <- as.numeric(pROC::auc(roc_obj))

tibble(
    metric = c("Brier score", "AUC"),
    value = c(brier, auc_value)
)
# A tibble: 2 × 2
  metric      value
  <chr>       <dbl>
1 Brier score 0.201
2 AUC         0.550
Code
from sklearn.metrics import brier_score_loss, roc_auc_score

logit_prob = logit_fit.predict(wshi)
fit_summary = pd.DataFrame({
    "model": ["Logit", "Probit"],
    "log_likelihood": [logit_fit.llf, probit_fit.llf],
    "AIC": [logit_fit.aic, probit_fit.aic],
    "BIC": [logit_fit.bic, probit_fit.bic],
})

metrics = {
    "brier_score": brier_score_loss(wshi["Giardia"], logit_prob),
    "auc": roc_auc_score(wshi["Giardia"], logit_prob),
}
print(fit_summary)
print(metrics)
Code
logit Giardia i.wsh_group AgeMonths NumChildren DistanceH20
estat ic
lroc
estat classification
Code
logit_prob = predict(logit_fit)
brier = mean((wshi.Giardia .- logit_prob).^2)

DataFrame(metric=["Brier score"], value=[brier])

Hosmer-Lemeshow Test

The Hosmer-Lemeshow test checks whether the model’s predicted probabilities match observed event rates across groups of the data (usually deciles). A non-significant result suggests adequate calibration; a significant result indicates systematic miscalibration.

Warning

The Hosmer-Lemeshow test is sensitive to the number of groups used and has low power in small samples. Use it as a diagnostic prompt, not a definitive verdict. A calibration plot gives more information.

Code
# install.packages("ResourceSelection") if needed
library(ResourceSelection)
hoslem.test(wshi$Giardia, fitted(logit_fit), g = 10)

    Hosmer and Lemeshow goodness of fit (GOF) test

data:  wshi$Giardia, fitted(logit_fit)
X-squared = 9.9793, df = 8, p-value = 0.2665
Code
import numpy as np
from scipy.stats import chi2

def hosmer_lemeshow(y, phat, g=10):
    data = pd.DataFrame({"y": y, "phat": phat})
    data["decile"] = pd.qcut(data["phat"], q=g, duplicates="drop")
    hl = data.groupby("decile").agg(
        obs=("y", "sum"), exp=("phat", "sum"), n=("y", "count")
    )
    hl["obs_0"] = hl["n"] - hl["obs"]
    hl["exp_0"] = hl["n"] - hl["exp"]
    stat = ((hl["obs"] - hl["exp"]) ** 2 / hl["exp"] +
            (hl["obs_0"] - hl["exp_0"]) ** 2 / hl["exp_0"]).sum()
    pval = chi2.sf(stat, df=g - 2)
    return stat, pval

stat, pval = hosmer_lemeshow(wshi["Giardia"], logit_fit.predict(wshi))
print(f"HL statistic: {stat:.3f}, p-value: {pval:.3f}")
Code
logit Giardia i.wsh_group AgeMonths NumChildren DistanceH20
estat gof, group(10)
Code
# A Hosmer-Lemeshow implementation in Julia can be built manually
# following the same decile-grouping approach as the Python version above.
# HypothesisTests.jl does not currently include an HL test.

Calibration Plot

A calibration plot compares predicted probabilities with observed event rates after grouping observations by fitted risk. Each point is one decile: the x-position is that decile’s mean predicted probability and the y-position is its observed event rate. Points near the 45-degree line are well calibrated; points above the line mean the model underpredicted events for that decile, while points below the line mean it overpredicted. If the points are tightly grouped, the model is assigning a fairly narrow range of risks, so the plot mainly evaluates calibration within that narrow band rather than across the full 0 to 1 probability scale.

Note

AUC measures discrimination: can the model rank who gets the event? Calibration measures accuracy: are the predicted probabilities correct on average? A model can discriminate well but be poorly calibrated, or calibrate well but discriminate poorly. Report both.

Code
cal_data <- tibble(
    y = wshi$Giardia,
    phat = predict(logit_fit, type = "response")
) |>
    mutate(decile = ntile(phat, 10)) |>
    group_by(decile) |>
    summarize(
        mean_obs = mean(y),
        mean_pred = mean(phat)
    )

ggplot(cal_data, aes(x = mean_pred, y = mean_obs)) +
    geom_point(size = 3, color = "darkgreen") +
    geom_abline(intercept = 0, slope = 1, linetype = "dashed", color = "gray50") +
    scale_x_continuous(limits = c(0, 1)) +
    scale_y_continuous(limits = c(0, 1)) +
    labs(
        title = "Calibration Plot (logit model, by decile)",
        x = "Mean predicted probability",
        y = "Mean observed proportion"
    ) +
    theme_minimal()

Each point is one decile of children sorted by the logit model’s predicted probability of Giardia. The x-axis shows the mean predicted probability within that decile, and the y-axis shows the observed Giardia proportion. Points on the dashed line are perfectly calibrated; points above the line indicate underprediction, and points below indicate overprediction. The tight cluster shows that this fitted model assigns a fairly narrow range of risks, so the plot is most informative about calibration within that range.
Code
import matplotlib.pyplot as plt

cal = pd.DataFrame({"y": wshi["Giardia"], "phat": logit_fit.predict(wshi)})
cal["decile"] = pd.qcut(cal["phat"], q=10, duplicates="drop")
cal_summary = cal.groupby("decile").agg(mean_obs=("y", "mean"), mean_pred=("phat", "mean"))

plt.scatter(cal_summary["mean_pred"], cal_summary["mean_obs"], color="darkgreen")
plt.plot([0, 1], [0, 1], linestyle="--", color="gray")
plt.xlabel("Mean predicted probability")
plt.ylabel("Mean observed proportion")
plt.title("Calibration Plot")
plt.show()
Code
logit Giardia i.wsh_group AgeMonths NumChildren DistanceH20
predict phat, pr
xtile decile = phat, nq(10)
collapse (mean) mean_obs=Giardia mean_pred=phat, by(decile)
twoway (scatter mean_obs mean_pred) ///
       (line mean_pred mean_pred, lpattern(dash)), ///
    xtitle("Mean predicted probability") ///
    ytitle("Mean observed proportion") ///
    title("Calibration Plot") legend(off)
Code
using Statistics, Plots, StatsBase

phat = predict(logit_fit)
ntiles = nquantile(phat, 10)
decile = searchsortedlast.(Ref(ntiles), phat)
decile = clamp.(decile, 1, 10)

cal = DataFrame(y=wshi.Giardia, phat=phat, decile=decile)
cal_summary = combine(groupby(cal, :decile),
    :y => mean => :mean_obs,
    :phat => mean => :mean_pred)

scatter(cal_summary.mean_pred, cal_summary.mean_obs,
    xlabel="Mean predicted probability",
    ylabel="Mean observed proportion",
    title="Calibration Plot",
    legend=false)
plot!([0, 1], [0, 1], linestyle=:dash, color=:gray)

Reporting Checklist

A good report should let readers see what was estimated, on which scale, and how much uncertainty surrounds the post-estimation quantities. Use this checklist as a final pass before sharing results, with extra attention to calibration and discrimination when the goal is prediction.

Checklist
  • State the outcome coding: which category is \(Y = 1\).
  • State the model and link function.
  • Report the estimand: odds ratio, predicted probability, risk difference, or AME.
  • For odds ratios, supplement with predicted probabilities or probability contrasts.
  • Include confidence intervals for all post-estimation quantities, not just coefficients.
  • Use robust or clustered standard errors when the design requires them.
  • Report calibration and discrimination metrics when the goal is prediction.
  • For interactions, interpret on the probability scale, not only the product coefficient.
  • Avoid relying on percent correctly predicted as the main fit statistic.

FAQs

Should I Use LPM, Logit, or Probit?

The choice depends mostly on your estimand and the conventions of your field. When the primary goal is a causal risk difference and probabilities stay well away from the boundaries, the LPM is usually the right starting point: its coefficients require no transformation and compare directly with AMEs from the nonlinear models. Always use robust or clustered standard errors with the LPM.

Health and epidemiology typically expect odds ratios, so logit fits naturally in those settings. In economics and political methodology, the latent-normal framing makes probit the conventional choice. In practice, logit and probit produce nearly identical predicted probabilities and marginal effects across most of the covariate distribution, and the choice between them rarely changes any substantive conclusion.

The most useful check is to compute average marginal effects from all three models and put them on the same probability scale. When they land in the same neighborhood, the model choice does not matter much. When they diverge, report that explicitly rather than selecting the model whose estimate you prefer.

What Scale Should I Report?

Predicted probabilities or risk differences should be the primary reported results in most settings. Use odds ratios when field convention requires them, particularly in clinical epidemiology and case-control designs, and supplement them with predicted probabilities so readers can assess magnitude on a scale they can interpret.

The choice of reporting scale shapes what readers understand. Each scale has legitimate uses and common misuses:

Scale Definition When appropriate
Log-odds coefficient \(\hat{\beta}\) from the logit model Internal model comparison; rarely useful alone
Odds ratio (OR) \(\exp(\hat{\beta})\) Conventional in clinical epidemiology and case-control designs; misread as risk ratio when outcome is common
Predicted probability \(\hat{p}\) at a covariate profile Any audience; clearest for communicating absolute risk
Risk difference / AME \(\hat{p}_1 - \hat{p}_0\) Causal inference, policy; directly interpretable magnitude
Risk ratio \(\hat{p}_1 / \hat{p}_0\) Useful when baseline risk varies across subgroups; needs baseline probability to interpret fully

For the odds-ratio caveat, see the earlier note.

Is This an Inference Model or a Prediction Model?

This question should be settled before the model is fit. An inference model aims to estimate a causal or descriptive estimand with well-characterized uncertainty: the focus is on the estimand itself, credible identification, and correctly specified standard errors. Goodness-of-fit statistics like AUC or pseudo-R² are beside the point if the uncertainty intervals are not honest.

A prediction model aims to assign probabilities accurately to new observations. Here calibration (are predicted probabilities correct on average?) and discrimination (does the model rank who gets the event?) matter most, and performance is evaluated on held-out data through cross-validation or train/test splits. Whether a coefficient is interpretable matters far less than whether the predictions hold up.

Logit and probit are frequently used for inference. For prediction, unregularized logistic regression is often outperformed by penalized methods — lasso, ridge, or elastic net — when the goal is predictive accuracy rather than a causal estimate. Cross-validation workflows and ML-style validation will be covered in a future guide.

How Do I Interpret Interactions?

Interaction coefficients in logit and probit are rarely meaningful on their own. A better approach is to compute predicted probabilities for each combination of the interacting variables and take the difference-in-differences on the probability scale.

In logit and probit, an interaction coefficient is an interaction on the log-odds scale. It does not equal the interaction in predicted probabilities, and its sign can even differ from the sign of the probability-scale interaction. The only reliable interpretation is through predicted probabilities.

Adding an interaction between WSH and Age and computes the probability-scale interaction (difference-in-differences).

Warning

A non-significant interaction coefficient in logit or probit does not imply a non-significant interaction in predicted probabilities. Always check both.

Code
logit_int <- glm(
    Giardia ~ WSH * AgeMonths + NumChildren + DistanceH20,
    family = binomial(link = "logit"),
    data = wshi
)

# Define four profiles: WSH x young (25th pct) / old (75th pct)
age_q <- quantile(wshi$AgeMonths, c(0.25, 0.75), na.rm = TRUE)

int_grid <- tibble(
    WSH = factor(rep(c("Control", "WSH"), each = 2), levels = c("Control", "WSH")),
    AgeMonths = rep(age_q, 2),
    NumChildren = mean(wshi$NumChildren, na.rm = TRUE),
    DistanceH20 = mean(wshi$DistanceH20, na.rm = TRUE)
)

int_grid$prob <- predict(logit_int, newdata = int_grid, type = "response")

# Difference-in-differences on probability scale
did <- with(
    int_grid,
    (prob[WSH == "WSH" & AgeMonths == age_q[2]] - prob[WSH == "Control" & AgeMonths == age_q[2]]) -
        (prob[WSH == "WSH" & AgeMonths == age_q[1]] - prob[WSH == "Control" & AgeMonths == age_q[1]])
)

int_grid
# A tibble: 4 × 5
  WSH     AgeMonths NumChildren DistanceH20  prob
  <fct>       <dbl>       <dbl>       <dbl> <dbl>
1 Control      28.6        1.61       0.608 0.302
2 Control      31.1        1.61       0.608 0.330
3 WSH          28.6        1.61       0.608 0.253
4 WSH          31.1        1.61       0.608 0.253
Code
cat("Probability-scale interaction (DiD):", round(did, 4), "\n")
Probability-scale interaction (DiD): -0.0284 
Code
import numpy as np
import pandas as pd

formula_int = "Giardia ~ C(WSH, Treatment(reference='Control')) * AgeMonths + NumChildren + DistanceH20"
logit_int = smf.logit(formula_int, data=wshi).fit()

age_q = wshi["AgeMonths"].quantile([0.25, 0.75])
int_grid = pd.DataFrame({
    "WSH": pd.Categorical(["Control", "Control", "WSH", "WSH"], categories=["Control", "WSH"]),
    "AgeMonths": [age_q[0.25], age_q[0.75], age_q[0.25], age_q[0.75]],
    "NumChildren": [wshi["NumChildren"].mean()] * 4,
    "DistanceH20": [wshi["DistanceH20"].mean()] * 4,
})
int_grid["prob"] = logit_int.predict(int_grid)
print(int_grid)
Code
logit Giardia i.wsh_group##c.AgeMonths NumChildren DistanceH20

* Probability-scale interaction: margins at 25th and 75th pct of AgeMonths
summarize AgeMonths, detail
local p25 = r(p25)
local p75 = r(p75)
margins wsh_group, at(AgeMonths=(`p25' `p75')) predict(pr)
marginsplot
Code
using GLM, StatsModels, DataFrames, Statistics

logit_int = glm(
    @formula(Giardia ~ WSH * AgeMonths + NumChildren + DistanceH20),
    wshi,
    Binomial(),
    LogitLink()
)

age_q = quantile(skipmissing(wshi.AgeMonths), [0.25, 0.75])
int_grid = DataFrame(
    WSH = categorical(["Control","Control","WSH","WSH"]; levels=["Control","WSH"]),
    AgeMonths = [age_q[1], age_q[2], age_q[1], age_q[2]],
    NumChildren = fill(mean(skipmissing(wshi.NumChildren)), 4),
    DistanceH20 = fill(mean(skipmissing(wshi.DistanceH20)), 4)
)
int_grid.prob = predict(logit_int, int_grid)

What About Separation?

Separation occurs when a predictor or combination of predictors perfectly predicts the outcome. When that happens, the likelihood has no finite maximum, the MLE simply does not exist, and any estimates your software returns can be misleading. Complete separation means the groups never overlap at all; quasi-complete separation means they barely do.

A concrete example. The table below shows complete separation: every low-risk patient tests negative, every high-risk patient tests positive.

Risk group Test negative (Y = 0) Test positive (Y = 1)
Low risk (X = 0) 20 0
High risk (X = 1) 0 15

No logistic curve can fit these data at any finite coefficient. To push the predicted probability to exactly 0 for all low-risk patients and exactly 1 for all high-risk patients, the log-odds coefficient on X would need to be \(+\infty\). The MLE does not exist, and the optimizer keeps walking uphill without converging.

Code
sep_data <- data.frame(
    x = c(rep(0, 20), rep(1, 15)),
    y = c(rep(0, 20), rep(1, 15))
)
fit_sep <- glm(y ~ x, data = sep_data, family = binomial)
summary(fit_sep)

Call:
glm(formula = y ~ x, family = binomial, data = sep_data)

Coefficients:
            Estimate Std. Error z value Pr(>|z|)
(Intercept)   -25.57   48299.10  -0.001    1.000
x              51.13   73778.06   0.001    0.999

(Dispersion parameter for binomial family taken to be 1)

    Null deviance: 4.7804e+01  on 34  degrees of freedom
Residual deviance: 5.5195e-10  on 33  degrees of freedom
AIC: 4

Number of Fisher Scoring iterations: 24

R returns a coefficient on x that is large in absolute value (often 20 or more) with a standard error in the hundreds. The convergence warning may or may not appear. Check fitted values directly:

Code
range(fitted(fit_sep))
[1] 7.884924e-12 1.000000e+00
Code
any(fitted(fit_sep) < 1e-6 | fitted(fit_sep) > 1 - 1e-6)
[1] TRUE
Code
import numpy as np
import statsmodels.api as sm

x = np.array([0] * 20 + [1] * 15)
y = np.array([0] * 20 + [1] * 15)
X = sm.add_constant(x)

fit_sep = sm.Logit(y, X).fit()
print(fit_sep.summary())
print(fit_sep.fittedvalues)
Code
clear
set obs 35
gen x = (_n > 20)
gen y = x
logit y x

Separation shows up in several ways: logit coefficients larger than 10 in absolute value, standard errors in the hundreds, or fitted probabilities that collapse to exactly 0 or 1. R’s glm() can converge without a warning even when separation is present — check directly with any(fitted(logit_fit) < 1e-6 | fitted(logit_fit) > 1 - 1e-6). In Stata, logit may print “convergence not achieved” but still return output.

When separation is the culprit, the simplest fix is to simplify the model or combine sparse categories. Firth logistic regression addresses the problem more directly by penalizing the log-likelihood to prevent infinite estimates: logistf::logistf() in R, firthlogit in Stata (install via SSC), or the logistf package in Python. Bayesian logistic regression with a weakly informative prior — rstanarm in R, for instance — is another option when stronger regularization is warranted.

Warning

Firth logistic regression changes coefficient magnitudes relative to standard logit. Do not compare Firth and standard logit coefficients directly. Compare the substantive conclusions — predicted probabilities and contrasts — rather than the coefficients.

What If Treatment Is Endogenous?

Switching from the LPM to logit or probit does not fix an endogeneity problem. Endogeneity is a design issue, and a different link function does not change that. Angrist (2001) argues that for binary outcomes, researchers should focus on the causal estimand first: with a valid instrument or a randomized design, a linear IV estimator (2SLS with a binary outcome) can recover a meaningful local average treatment effect without requiring distributional assumptions. Nonlinear IV approaches — biprobit, the control function method — exist but impose stronger assumptions, particularly joint normality of the endogenous variable and the structural error. In most applied settings, sorting out the identification strategy is the priority; the choice of outcome model comes second.

What If the Outcome Has More Than Two Categories?

The right extension depends on whether the categories have a natural order. For unordered categories — say, mode of transportation chosen from {car, transit, bike} — use multinomial logit or multinomial probit. For ordered categories — mild, moderate, severe — use ordered logit or ordered probit, which respects the ranking without treating the gaps between categories as equal. In both cases, the reporting advice from this guide carries over: coefficients from these models are even harder to interpret directly than in the binary case, so predicted probabilities and contrasts across groups are usually the clearest way to communicate results.

What Is the Difference Between AME and MEM?

Both summarize how a predictor shifts the predicted probability, but they do it differently. The average marginal effect (AME) computes each observation’s marginal effect at its own covariate values and then averages over the sample — so it reflects the actual distribution of the data. The marginal effect at the mean (MEM) instead plugs in the sample mean of every covariate, which can describe a hypothetical person who doesn’t exist in the data (for instance, a person who is 0.4 female). For continuous predictors near the center of the distribution the two are usually close; they can diverge near the tails. Use AME as the default.

My Model Did Not Converge. What Should I Do?

Non-convergence usually has one of four causes, and it is worth working through them in order:

  1. Separation. This is the most common culprit. See the separation section above for how to detect and fix it.
  2. Covariates on very different scales. A variable measured in millions sitting alongside one measured in single digits can cause numerical instability. Standardizing predictors usually resolves this.
  3. Too few events. Models with many predictors and few outcome events are numerically fragile (see below). Simplify the model or consider Firth logistic regression.
  4. Miscoded outcome. Make sure the outcome variable actually has both 0s and 1s. A coding mistake that produces only one value creates a degenerate likelihood with no solution.

What if My Outcome Is Rare or I Have Few Events?

The relevant quantity to count is outcome events, not total observations. Below 10–15 events per predictor, estimates become unreliable and Firth logistic regression helps reduce bias. Very rare outcomes raise additional estimation problems that go beyond simply having few events — these two issues are related but distinct.

Few events relative to predictors

The relevant count is outcome events — the number of 1s — not total observations. A study with 10,000 participants and 50 events is not large for a logistic regression with ten predictors.

The standard rule of thumb is 10 to 15 events per predictor variable (EPV). Below roughly 10 EPV, coefficient estimates can be substantially biased, confidence intervals tend to be too wide, and complete or quasi-complete separation becomes more likely. Firth logistic regression (also called penalized likelihood logistic regression) reduces small-sample bias by adding a Jeffreys-prior penalty to the log-likelihood. It is a good default when EPV is low.

In R, use logistf::logistf(). In Stata, install firthlogit via SSC (ssc install firthlogit), then firthlogit. In Python, the logistf package or statsmodels with a penalty term. Note that Firth logistic regression changes coefficient magnitudes. Thus it is better to compare substantive conclusions via predicted probabilities, not raw coefficients.

Rare-events data

When the event rate is very low (roughly below 1–2%), standard logistic regression can underestimate the probability of the rare event because the data are dominated by non-events. This is a different problem from having few events relative to predictors: you may have many events in absolute terms but a very small proportion.

King and Zeng (2001) developed a bias-correction for rare-events logistic regression and the relogit command in Stata or the Zelig package in R implement their approach. Their methods are worth consulting when the event rate is below 1–2% and correct probability estimation — not just significance testing — is the goal.

Warning

Do not confuse rare-outcome OR interpretation with rare-event estimation. The OR approximates the risk ratio when outcomes are rare — that is a mathematical property of the OR formula. Rare-event bias in MLE is a separate estimation problem that occurs when the proportion of 1s in the sample is very small. They are related but distinct issues.

Why Do My Adjusted and Unadjusted Estimates Diverge?

Suppose the descriptive table shows a raw risk difference of 7 percentage points between groups. After fitting the logit model and computing an average marginal effect, the adjusted estimate comes back at only 2 percentage points — or, in the other direction, at 12. Should you trust the model or the raw comparison?

In an observational study, adjustment can legitimately change the estimate: if covariates predict both treatment assignment and the outcome, the unadjusted comparison conflates real effects with group differences in those covariates. A large change from raw to adjusted is often the model doing what it is supposed to do.

In a randomized trial, the logic reverses. Randomization should balance covariates between arms, so the unadjusted and adjusted estimates should be close. A large divergence in that setting is worth examining before reporting. Common causes include a covariate that is actually a mediator — on the causal path between treatment and outcome, so adjusting for it absorbs part of the treatment effect — a near-collider that induces bias when conditioned on, or a specification problem such as a badly scaled variable or an undetected nonlinearity.

A few diagnostic steps help narrow down the cause. Start with the balance table: if covariates are similarly distributed between groups, they should not move the estimate much regardless of whether they are included. Then drop covariates one at a time and watch how the estimate shifts; a single variable that moves the result by several percentage points deserves a closer look. Finally, compare the model-predicted group means at observed covariate values with the empirical rates from the descriptive table. If the model-adjusted means look very different from the raw event rates without a clear explanation, resolve that calibration concern before reporting either number.

References

Angrist, Joshua D. 2001. “Estimation of Limited Dependent Variable Models With Dummy Endogenous Regressors: Simple Strategies for Empirical Practice.” Journal of Business & Economic Statistics 19(1): 2-28. https://doi.org/10.1198/07350010152472571

Faraway, Julian J. 2016. Extending the Linear Model with R: Generalized Linear, Mixed Effects and Nonparametric Regression Models. 2nd ed. Chapman and Hall/CRC.

Greene, William H. 2018. Econometric Analysis. 8th ed. Pearson.

Hagle, Timothy M., and Glenn E. Mitchell II. 1992. “Goodness-of-Fit Measures for Probit and Logit.” American Journal of Political Science 36(3): 762-784. https://doi.org/10.2307/2111590

Herron, Michael C. 1999. “Postestimation Uncertainty in Limited Dependent Variable Models.” Political Analysis 8(1): 83-98.

Huckfeldt, Robert, and John Sprague. 1992. “Political Parties and Electoral Mobilization: Political Structure, Social Structure, and the Party Canvass.” American Political Science Review 86(1): 70-86.

James, Gareth, Daniela Witten, Trevor Hastie, and Robert Tibshirani. 2021. An Introduction to Statistical Learning: With Applications in R. 2nd ed. Springer. https://www.statlearning.com

King, Gary. 1998. Unifying Political Methodology: The Likelihood Theory of Statistical Inference. University of Michigan Press.

King, Gary, and Langche Zeng. 2001. “Logistic Regression in Rare Events Data.” Political Analysis 9(2): 137-163. https://doi.org/10.1093/oxfordjournals.pan.a004868

Lin, Audrie, et al. 2018. “Effects of Water, Sanitation, Handwashing, and Nutritional Interventions on Environmental Enteric Dysfunction in Young Children: WASH Benefits Bangladesh.” Clinical Infectious Diseases 67(10): 1515-1526.

Long, J. Scott. 1997. Regression Models for Categorical and Limited Dependent Variables. Sage.

Nagler, Jonathan. 1994. “Scobit: An Alternative Estimator to Logit and Probit.” American Journal of Political Science 38(1): 230-255.

Raftery, Adrian E. 1995. “Bayesian Model Selection in Social Research.” Sociological Methodology 25: 111-163. https://doi.org/10.2307/271063

Footnotes

  1. Heteroskedasticity means the variance of errors or residuals is not constant across observations in a regression model.↩︎

  2. Optimization routines vary by software and model class. For example, R’s glm() default fitting method uses iteratively weighted least squares (R glm() documentation); statsmodels’ Logit.fit() and Probit.fit() default to Newton-Raphson and allow alternatives such as BFGS, L-BFGS, Nelder-Mead, and Powell (statsmodels Logit.fit(), statsmodels Probit.fit()); and lme4’s glmerControl() defaults to bobyqa followed by Nelder_Mead, while lmerControl() defaults to nloptwrap (lme4 control documentation). BOBYQA is a derivative-free trust-region method for bound-constrained optimization that builds quadratic approximations (minqa bobyqa documentation). To see what your software used, check the model object, fitting output, and documentation for method, control, or optimizer arguments. Consider changing optimizers, increasing iteration or function-evaluation limits, or trying multiple starting values when you get convergence warnings, failed gradient/Hessian checks, boundary estimates, or materially different log-likelihoods across fits.↩︎

Citation

BibTeX citation:
@online{kwon2026,
  author = {Kwon, Jiye},
  publisher = {Yale Library StatLab},
  title = {Logit and {Probit} {Models} for {Binary} {Outcomes}},
  date = {2026-08-18},
  langid = {en},
  abstract = {This guide introduces binary limited dependent variable
    models for applied researchers. It compares the linear probability
    model, logit, and probit; explains odds, probabilities, marginal
    effects, and uncertainty; and gives practical guidance for model
    assessment and reporting.}
}
For attribution, please cite this work as:
Kwon, Jiye. 2026. “Logit and Probit Models for Binary Outcomes.” Yale Library StatLab. August 18, 2026.

Data & materials

Files used in this guide. Everything here is downloadable directly.

Data