Yes/no outcomes that move together.

A multivariate probit predicts several binary outcomes at once and keeps track of how they depend on each other. This is a short introduction to the model, why the obvious shortcut gets joint questions wrong, and an open source implementation that works with any classifier and scores a million rows in about a second.

A target that breaks into parts

Sales teams often qualify a lead with four checks: does the buyer have budget, the authority to sign, a real need, and the right timing? A lead is qualified when all four hold. You could train one model on "qualified or not", but then you learn nothing about why a lead fails, and every change to the definition means retraining.

The alternative is to model the four parts and combine them. Suppose a model says each check passes with probability 0.6 for some lead. The probability that all four pass is then 0.6 × 0.6 × 0.6 × 0.6 = 0.13, right?

Only if the checks are independent, and they rarely are. A company with budget is more likely to have a decision maker in the room and a near-term need. If the four checks share a moderate correlation of 0.5, the probability that all four pass is 0.29, more than twice the naive answer. The probability that none pass goes from 0.026 to 0.13, five times higher. Multiplying probabilities is wrong in both directions at once, and it's wrong in exactly the questions you care about.

The question a multivariate probit answers: given each part's probability, and how the parts move together, what is the probability of any combination of them?

The model in one picture

Behind each yes/no outcome is a hidden continuous score. The outcome is "yes" when the score lands above a threshold. Your features set where the threshold sits for each row, and the hidden scores are drawn together from a normal distribution with a correlation matrix, Σ. That is the whole model:

Y_j = 1[ η_j(x) + e_j > 0 ],    e ~ N(0, Σ),    j = 1 … d
Two correlated hidden scores and the four outcome patterns A tilted ellipse shows the joint distribution of two positively correlated hidden scores. A vertical and a horizontal threshold line split the plane into four regions, one per outcome pattern. Most of the ellipse falls in the both-yes and both-no regions. budget yes, timing yes budget no, timing yes budget no, timing no budget yes, timing no budget threshold timing threshold
Two hidden scores with correlation 0.5. Each dashed line is one outcome's threshold; the four regions are the four outcome patterns. Because the cloud is tilted, it puts more mass on "both yes" and "both no" than independence would.

Two properties keep its structure readable, whatever classifier sits under each outcome:

Every joint question is an exact consequence of those two pieces: a full pattern, "all of these", "any of these", "none of these", or one outcome given the others. The probabilities of all 2d patterns add up to exactly 1. Nothing is assembled by hand or bolted on afterwards.

proba = model.predict_proba(X)
proba.all([0, 1, 2, 3])               # qualified: all four checks pass
proba.any([0, 1, 2, 3])               # at least one passes
proba.conditional(3, given={0: 1})    # P(timing | budget confirmed, x)

That last line is where the parts pay off. If a rep has confirmed budget, the chance that timing is also right goes up, here from 0.60 to 0.73, and with budget, authority and need all confirmed it reaches 0.84. Those updates come from the same fitted model, with no retraining.

Why fitting it has been hard

The textbook way to fit a multivariate probit is to maximise its full likelihood over every parameter at once. Each row's likelihood is the probability of its observed pattern, a d-dimensional normal integral with no closed form. So every optimizer step means thousands of numerical integrals, and every margin's coefficients are tied to every other's.

That's why the Python options are thin. statsmodels fits one probit per outcome, which gets the parts right but has no Σ at all. On a test with a known correlation of 0.6, its estimate of "at least one outcome" was off by 0.072; the package below was off by 0.020. The other route is the full likelihood, which is what the classical implementations, R’s mvProbit and Stata’s mvprobit, maximise. It works, but every optimizer step pays for a d-dimensional integral on every row.

IFM: fit the parts, then the dependence

Inference Functions for Margins (IFM) splits the problem in two:

  1. Fit each outcome on its own, with whatever model suits it. Each fit is cross-validated so the next stage only sees out-of-fold predictions. Skip that, and an overfit model makes every outcome look perfectly correlated with every other.
  2. Hold those fits fixed and estimate Σ by maximum likelihood. Estimating it one pair of outcomes at a time needs only two-dimensional integrals, which have a fast closed form.

In testing, the pair-at-a-time fit matched the full-likelihood fit's accuracy at about 1/3000 of the time: 0.07 seconds against 2–4 minutes at four outcomes. And because stage one never needs gradients from stage two, the per-outcome model can be anything, including a boosted ensemble that a joint optimizer could never reach inside.

Measurements: comparators study. Algorithm and the alternatives rejected: ifm.md.

Bring your own model, or use ours

The second stage only ever sees each outcome's predicted probability, mapped onto the hidden-score scale. So any classifier with predict_proba can stand in for any outcome, and different outcomes can use different models.

from multivariate_probit import MultivariateProbit

MultivariateProbit(inner="linear")                       # probit regression (default)
MultivariateProbit(inner="xgboost")                      # XGBoost, tuned for calibrated probabilities
MultivariateProbit(inner="rf")                           # random forest
MultivariateProbit(inner=MyCalibratedClassifier())       # anything scikit-learn shaped
MultivariateProbit(inner=["linear", "xgboost", "rf", "linear"])  # one per outcome

The one requirement is honest probabilities. A raw score on an arbitrary scale is rejected, and each fitted outcome reports a calibration slope so a badly scaled model doesn't quietly distort Σ.

Scoring at scale

Fitting is the cheap part. Scoring a fitted model is where the cost hides: every row of a test set needs its own d-dimensional integral, and the standard tools slow down sharply as outcomes are added.

Line chart, log scale, of seconds to score one row against the number of outcomes from 3 to 20. Exact quadrature rises from 80 microseconds at 3 outcomes to 58 seconds at 7. SciPy sits between 6 milliseconds and 2 seconds. GHK simulation with 1000 draws runs 0.3 to 1.7 milliseconds and with 100 draws 50 to 360 microseconds. The compiled orthant backend is lowest at 16 to 79 microseconds across the range.
Seconds per row on a correlation matrix shaped like a real pair-at-a-time fit. GHK is a standard simulation method, run here as plain vectorised numpy. Source: the GHK study.
Speed, with a small accuracy trade-off. The compiled backend scores a row in 20–90 µs up to 20 outcomes, and the hosted GPU service scores about a million rows a second. SciPy takes 3 ms to 2 s per row. The price is a median error of up to about 0.007 on a probability, roughly the accuracy of 100-draw simulation, and the same answer on every call.

There are three ways to score, and the choice is yours:

RouteSpeedAccuracy
Exact quadrature (default)fine to 6 outcomes, impractical past 7about 1e-5
SciPy3 ms to 2 s per rowreference grade, random
Compiled orthant backend20–90 µs per row, to 20 outcomesmedian error ≤ 0.007, deterministic
Hosted quantecarlo.orthant_cdfabout 1 s per million rows at 20 outcomessame as compiled

The compiled backend ships with the package for Linux on CPython 3.11 and 3.12. Without a key it handles up to three outcomes; a key unlocks the rest. The hosted service is for test sets too large for one machine and comes with the quantecarlo client.

Where it falls short

The full list: limitations.md.

Try it

pip install multivariate-probit            # numpy + scipy only
pip install multivariate-probit[all]       # adds xgboost, scikit-learn and the compiled backend