Binary Robit Regression — Robust Binary Probit with Student-t Errors¶

Liu (2004) · Geweke (1993) · Albert & Chib (1993) · Holmes & Held (2006)¶

The problem with standard binary probit: The normal-link probit assumes $\varepsilon_i \sim N(0,1)$. When data contain outliers (label noise, heavy-tailed predictors, or contaminated responses) the normal tails shrink too quickly: a single mislabelled observation can pull $\hat\beta$ substantially toward 0.

Robit regression (Liu 2004) replaces the normal link with a Student-$t_\nu$ link: $$P(y_i = 1 \mid x_i, \beta) = T_\nu(x_i'\beta)$$ where $T_\nu$ is the standard $t$ CDF with $\nu$ degrees of freedom. For $\nu \to \infty$, $T_\nu \to \Phi$ and the model collapses to standard probit. Small $\nu$ (3-7) yields heavy tails: outliers are downweighted automatically.

Scale-mixture representation (Geweke 1993 — used by the Gibbs sampler): $$z_i^* \mid \lambda_i \sim N(x_i'\beta, 1/\lambda_i), \quad \lambda_i \sim \text{Gamma}(\nu/2, \nu/2)$$ Marginalising over $\lambda_i$ gives $z_i^* \sim t_\nu(x_i'\beta, 1)$. Outliers acquire small $\lambda_i$ (large variance) and reduced influence on $\beta$.

Section Content
Section 1 Synthetic leptokurtic data ($t_5$ errors) — robit recovers true $\beta$; probit is attenuated
Section 2 Sensitivity to $\nu$: LPML and $\beta$ posterior as $\nu$ varies over a grid
Section 3 Real data — Finney (1947) vasoconstriction ($n=39$); $\lambda_i$ identifies discordant obs

Package landscape¶

Package Language Mechanism Ready-made robit?
bayesm::rbprobitGibbs R Gibbs / data augmentation Normal errors only
MCMCpack::MCMCprobit R Gibbs Normal errors only
arm::bayesglm R EM + t priors on beta t priors, not t errors
robustbase::glmrob R Frequentist M-estimation Different mechanism
brms / rstan R / Python NUTS-HMC via Stan Yes, via custom Stan model
LaplacesDemon R User-specified Yes, user-coded likelihood
PyMC Python NUTS-HMC Yes, via manual scale-mixture
statsmodels.Probit Python MLE Normal errors only
robust_probit_gibbs.py Python Gibbs — this script Yes

References:

  • Liu, C. (2004). Robit Regression, in Gelman & Meng (Eds.), Wiley, pp. 227-238.
  • Geweke, J. (1993). Bayesian Treatment of the Independent Student-t Linear Model. J. Appl. Econometrics 8(S1), S19-S40.
  • Albert, J. H., & Chib, S. (1993). JASA 88(422), 669-679.
  • Holmes, C. C., & Held, L. (2006). Bayesian Analysis 1(1), 145-168.
In [55]:
import sys, warnings
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
from scipy.special import ndtr
from scipy.stats import t as t_dist
import statsmodels.api as sm

warnings.filterwarnings('ignore')
sys.path.insert(0, r'C:\Users\user\project')

from robust_probit_gibbs import (
    probit_gibbs, robit_gibbs,
    compute_cpo_probit, compute_cpo_robit,
    pred_probs_probit, pred_probs_robit,
    posterior_summary,
)

print('robust_probit_gibbs.py loaded')
print(f'NumPy {np.__version__}  |  statsmodels {sm.__version__}')
robust_probit_gibbs.py loaded
NumPy 2.4.6  |  statsmodels 0.14.6

Model and Gibbs sampler¶

Block 1 — Latent utility (truncated Normal with varying variance per observation): $$z_i^* \mid \beta, \lambda_i, y_i \sim TN\!\left(x_i'\beta,\; \tfrac{1}{\lambda_i};\; (0,\infty)^{y_i} \cap (-\infty,0]^{1-y_i}\right)$$

Block 2 — Coefficient vector (Normal posterior, recomputed every iteration): $$\beta \mid z^*, \Lambda \sim N(\bar{b}, \bar{B}), \quad \bar{B}^{-1} = X'\Lambda X + B_0^{-1}, \quad \bar{b} = \bar{B}(X'\Lambda z^* + B_0^{-1}b_0)$$

Block 3 — Precision weights (independent per observation): $$\lambda_i \mid z_i^*, \beta \sim \text{Gamma}\!\left(\tfrac{\nu+1}{2},\; \tfrac{\nu + (z_i^* - x_i'\beta)^2}{2}\right)$$ Outlier $\Rightarrow$ large residual $\Rightarrow$ small $\lambda_i$ $\Rightarrow$ large variance $1/\lambda_i$ $\Rightarrow$ weak influence on $\beta$.

Key difference from standard probit: In standard probit $\Lambda = I$ (all $\lambda_i = 1$, constant), so $\bar{B}$ is precomputed once. In robit, $\Lambda$ updates every iteration — $\bar{B}$ is recomputed each step: $O(nk^2)$ per iteration.

Model comparison — CPO and LPML (using the marginal t-link, integrating out $\lambda_i$):

The CPO (Conditional Predictive Ordinate) for observation $i$ is the predictive density of $y_i$ given all the other observations — a leave-one-out fit score, estimated here by the harmonic mean of the in-sample predictive densities across the $R$ posterior draws: $$\text{CPO}_i = \left[\frac{1}{R}\sum_r \frac{1}{T_\nu((2y_i-1)\,x_i'\beta^{(r)})}\right]^{-1}, \quad \text{LPML} = \sum_i \log \text{CPO}_i$$ The LPML (Log Pseudo-Marginal Likelihood) sums the log-CPOs into one leave-one-out predictive score for the whole model: higher (less negative) is better, and a gap of a few nats is the usual rule of thumb for a meaningful difference. It is the out-of-sample counterpart to the in-sample log-likelihood — and, as the two datasets below show, it can move quite differently from parameter bias.

Scale adjustment: the marginal variance of $z_i^* \sim t_\nu$ is $\nu/(\nu-2)$, not 1. To compare robit $\hat\beta$ with probit $\hat\beta$ on the same scale: $$\hat\beta_{\text{probit-equiv}} = \hat\beta_{\text{robit}} \times \sqrt{(\nu-2)/\nu}$$

In [56]:
NU_TRUE   = 5.0          # true degrees of freedom (leptokurtic errors)
DATA_PATH = r'C:\Users\user\project\robit_leptokurtic_data.csv'
dat = pd.read_csv(DATA_PATH)

y      = dat['y'].values.astype(int)
X      = dat[['x0', 'x1', 'x2', 'x3']].values
z_true = dat['z_true'].values    # true latent utility (known from simulation)
n, k   = X.shape

beta_true   = np.array([0.5, 1.2, -0.8, 0.5])
beta_names  = ['beta[0]', 'beta[1]', 'beta[2]', 'beta[3]']
scale_t2p   = np.sqrt((NU_TRUE - 2) / NU_TRUE)   # t -> probit scale: sqrt(3/5) = 0.7746
beta_probit = beta_true * scale_t2p               # what probit should estimate

print(f'n={n}  k={k}  y=1 rate={y.mean():.3f}  (true nu={NU_TRUE:.0f})')
print(f'True beta in t_{NU_TRUE:.0f}     scale  : {beta_true}')
print(f'True beta in probit scale (x{scale_t2p:.4f}): {beta_probit.round(4)}')
print()

res_mle = sm.Probit(y, X).fit(disp=False)
print('Probit MLE (misspecified -- normal link, true errors are t_5):')
for j, nm in enumerate(beta_names):
    print(f'  {nm}: MLE={res_mle.params[j]:+.4f}  true(probit)={beta_probit[j]:+.4f}  '
          f'true(t_{NU_TRUE:.0f})={beta_true[j]:+.4f}')
print(f'  log-lik: {res_mle.llf:.2f}')
print()
print(f'Heavy-tail check on true latent residuals:')
eps = z_true - X @ beta_true
print(f'  |eps| > 2: {(np.abs(eps) > 2).mean():.3f}  (N(0,1) gives 0.046)')
print(f'  |eps| > 3: {(np.abs(eps) > 3).mean():.3f}  (N(0,1) gives 0.003)')
n=400  k=4  y=1 rate=0.568  (true nu=5)
True beta in t_5     scale  : [ 0.5  1.2 -0.8  0.5]
True beta in probit scale (x0.7746): [ 0.3873  0.9295 -0.6197  0.3873]

Probit MLE (misspecified -- normal link, true errors are t_5):
  beta[0]: MLE=+0.2897  true(probit)=+0.3873  true(t_5)=+0.5000
  beta[1]: MLE=+1.0396  true(probit)=+0.9295  true(t_5)=+1.2000
  beta[2]: MLE=-0.6867  true(probit)=-0.6197  true(t_5)=-0.8000
  beta[3]: MLE=+0.3548  true(probit)=+0.3873  true(t_5)=+0.5000
  log-lik: -173.65

Heavy-tail check on true latent residuals:
  |eps| > 2: 0.098  (N(0,1) gives 0.046)
  |eps| > 3: 0.028  (N(0,1) gives 0.003)

Synthetic dataset — leptokurtic DGP (true $t_5$ errors)¶

  • n = 400 observations, k = 4 predictors (x0 = intercept, x1, x2, x3)
  • True latent utility: $z_i^* = x_i'\beta_{\text{true}} + \varepsilon_i$, $\varepsilon_i \sim t_5(0,1)$
  • True $\beta = [0.5,\; 1.2,\; -0.8,\; 0.5]$ in the $t_5$ scale
  • Probit-equivalent $\beta = \beta_{\text{true}} \times \sqrt{3/5} \approx [0.387,\; 0.930,\; -0.620,\; 0.387]$

No label-flipping. The heavy tails are structural: $t_5$ produces ~2× as many observations beyond $\pm 2$ as a standard normal ($9.8\%$ vs $4.6\%$) and $10\times$ as many beyond $\pm 3$ ($2.8\%$ vs $0.3\%$).

Standard probit is misspecified — it fits a normal link to $t_5$ data:

  • The MLE converges to $\beta_{\text{probit}} = \beta_{\text{true}} \times \sqrt{(\nu-2)/\nu}$ (attenuated)
  • The attenuation factor $\sqrt{3/5} \approx 0.775$ reflects the heavier tails

Robit with $\nu = 5$ is correctly specified — it estimates $\beta_{\text{true}}$ directly.


1. Standard probit (misspecified) vs Robit nu = nu_true = 5 (correct)¶

Expected outcome:

  • Standard probit posterior mean ≈ $\beta_{\text{true}} \times 0.775 = [0.387, 0.930, \ldots]$ (attenuated, consistently wrong)
  • Robit $\nu=5$ raw posterior mean ≈ $\beta_{\text{true}} = [0.5, 1.2, \ldots]$ (correctly specified, unbiased)
  • Robit $\nu=5$ adjusted (× 0.775) ≈ standard probit (both on probit scale — same answer)
  • LPML: robit only modestly better here ($\Delta \approx +0.5$ nat) — the decisive gain is in parameter recovery, not out-of-sample prediction, because a binary label barely uses the exact tail shape (see the §1 interpretation below)
  • $\lambda_i$: negatively correlated with $|z_i^* - x_i'\beta_{\text{true}}|$ (large draws from $t_5$ tails get small $\lambda_i$)
In [57]:
out_std  = probit_gibbs(
    y, X,
    b0=np.zeros(k), B0=100.0 * np.eye(k),
    R=15000, burn=3000, seed=1, nprint=3000,
)
beta_std = out_std['beta']
z_std    = out_std['z']

print('Standard probit — posterior summary')
print(posterior_summary(beta_std, beta_names).to_string())
  [probit std]  iter   3000/15000  (0.1s)
  [probit std]  iter   6000/15000  (0.1s)
  [probit std]  iter   9000/15000  (0.2s)
  [probit std]  iter  12000/15000  (0.2s)
  [probit std]  iter  15000/15000  (0.3s)
[probit std] Done in 0.3s  |  kept: 12000
Standard probit — posterior summary
           mean      sd    q025    q975
beta[0]  0.2924  0.0780  0.1392  0.4431
beta[1]  1.0489  0.1010  0.8531  1.2523
beta[2] -0.6924  0.0934 -0.8787 -0.5125
beta[3]  0.3555  0.0828  0.1957  0.5207
In [58]:
NU = NU_TRUE    # use the correct nu for §1 comparison
out_rob  = robit_gibbs(
    y, X, nu=NU,
    b0=np.zeros(k), B0=100.0 * np.eye(k),
    R=15000, burn=3000, seed=2, nprint=3000,
)
beta_rob = out_rob['beta']
lam_rob  = out_rob['lambda']
z_rob    = out_rob['z']

print(f'Robit nu={NU:.0f} (correctly specified) — posterior summary')
print(posterior_summary(beta_rob, beta_names).to_string())
print()
print(f'Scale-corrected (x{scale_t2p:.4f} -> probit scale):')
print(posterior_summary(beta_rob * scale_t2p, beta_names).to_string())
  [robit ν=5]  iter   3000/15000  (0.2s)
  [robit ν=5]  iter   6000/15000  (0.3s)
  [robit ν=5]  iter   9000/15000  (0.5s)
  [robit ν=5]  iter  12000/15000  (0.6s)
  [robit ν=5]  iter  15000/15000  (0.8s)
[robit ν=5] Done in 0.8s  |  kept: 12000
Robit nu=5 (correctly specified) — posterior summary
           mean      sd    q025    q975
beta[0]  0.3404  0.0943  0.1544  0.5280
beta[1]  1.2765  0.1400  1.0138  1.5549
beta[2] -0.8170  0.1200 -1.0655 -0.5927
beta[3]  0.4537  0.1039  0.2593  0.6651

Scale-corrected (x0.7746 -> probit scale):
           mean      sd    q025    q975
beta[0]  0.2637  0.0731  0.1196  0.4090
beta[1]  0.9888  0.1084  0.7853  1.2044
beta[2] -0.6329  0.0930 -0.8253 -0.4591
beta[3]  0.3515  0.0805  0.2008  0.5152
In [59]:
correction = scale_t2p    # sqrt((nu-2)/nu) = sqrt(3/5) for nu=5

print(f'Coefficient comparison — leptokurtic data (true t_{NU_TRUE:.0f} errors)')
print('Reference: true beta in t-scale.  Std probit recovers probit-equiv; robit raw recovers truth.')
print('=' * 90)
fmt = '{:>10}  {:>9}  {:>9}  {:>9}  {:>9}  {:>9}'
print(fmt.format('param', 'true(t_5)', 'true(prob)', 'std mean', 'rob raw', 'rob adj'))
print('-' * 90)
for j in range(k):
    tm   = beta_std[:, j].mean()
    rm   = beta_rob[:, j].mean()
    rm_a = rm * correction
    print(fmt.format(
        beta_names[j],
        f'{beta_true[j]:.4f}',
        f'{beta_probit[j]:.4f}',
        f'{tm:.4f}',
        f'{rm:.4f}',
        f'{rm_a:.4f}',
    ))
print()
print('  std mean  should match  true(prob)  -- probit recovers attenuated beta')
print('  rob raw   should match  true(t_5)   -- correctly specified robit recovers truth')
print('  rob adj   should match  std mean    -- both on same probit scale')
print()
bias_std = np.abs(beta_std.mean(0) - beta_true).mean()          # vs true t-scale
bias_rob = np.abs(beta_rob.mean(0) - beta_true).mean()          # vs true t-scale (raw)
print(f'Mean |bias| from true t_{NU_TRUE:.0f} beta:')
att = (1 - np.sqrt((NU_TRUE - 2) / NU_TRUE)) * 100        # true attenuation = 1 - sqrt((nu-2)/nu)
print(f'  Standard probit : {bias_std:.4f}  (probit attenuates beta ~{att:.0f}% toward 0)')
print(f'  Robit nu={NU:.0f} raw : {bias_rob:.4f}  (correctly specified -- roughly half the bias)')
Coefficient comparison — leptokurtic data (true t_5 errors)
Reference: true beta in t-scale.  Std probit recovers probit-equiv; robit raw recovers truth.
==========================================================================================
     param  true(t_5)  true(prob)   std mean    rob raw    rob adj
------------------------------------------------------------------------------------------
   beta[0]     0.5000     0.3873     0.2924     0.3404     0.2637
   beta[1]     1.2000     0.9295     1.0489     1.2765     0.9888
   beta[2]    -0.8000    -0.6197    -0.6924    -0.8170    -0.6329
   beta[3]     0.5000     0.3873     0.3555     0.4537     0.3515

  std mean  should match  true(prob)  -- probit recovers attenuated beta
  rob raw   should match  true(t_5)   -- correctly specified robit recovers truth
  rob adj   should match  std mean    -- both on same probit scale

Mean |bias| from true t_5 beta:
  Standard probit : 0.1527  (probit attenuates beta ~23% toward 0)
  Robit nu=5 raw : 0.0748  (correctly specified -- roughly half the bias)
In [60]:
lpml_std, cpo_std = compute_cpo_probit(y, X, beta_std)
lpml_rob, cpo_rob = compute_cpo_robit(y, X, beta_rob, nu=NU)

print(f'LPML — standard probit     : {lpml_std:.2f}  (misspecified: normal link, true t_{NU_TRUE:.0f})')
print(f'LPML — robit nu={NU:.0f}          : {lpml_rob:.2f}  (correctly specified)')
print(f'LPML delta (robit - std)   : {lpml_rob - lpml_std:+.2f}')
print()

# Observations with extreme true latent residuals get smallest lambda_i
eps_abs = np.abs(z_true - X @ beta_true)
top_idx = np.argsort(eps_abs)[-20:]     # top-20 largest true residuals
bot_idx = np.argsort(eps_abs)[:20]      # bottom-20 smallest

print('CPO by true-residual magnitude (top 20 vs bottom 20):')
print(f'  Large |eps| -- mean CPO std  : {cpo_std[top_idx].mean():.4f}')
print(f'  Large |eps| -- mean CPO robit: {cpo_rob[top_idx].mean():.4f}')
print(f'  Small |eps| -- mean CPO std  : {cpo_std[bot_idx].mean():.4f}')
print(f'  Small |eps| -- mean CPO robit: {cpo_rob[bot_idx].mean():.4f}')
print()
print('(Note: the CPO-by-residual gap is negligible here -- predicting a binary label barely')
print(' uses the tail shape, so robit and probit fit each observation about equally well out-of-sample.')
print(" robit's real edge on this data is in beta recovery above, not CPO -- cf. Section 3.)")
LPML — standard probit     : -177.93  (misspecified: normal link, true t_5)
LPML — robit nu=5          : -177.43  (correctly specified)
LPML delta (robit - std)   : +0.49

CPO by true-residual magnitude (top 20 vs bottom 20):
  Large |eps| -- mean CPO std  : 0.3884
  Large |eps| -- mean CPO robit: 0.3833
  Small |eps| -- mean CPO std  : 0.8601
  Small |eps| -- mean CPO robit: 0.8667

(Note: the CPO-by-residual gap is negligible here -- predicting a binary label barely
 uses the tail shape, so robit and probit fit each observation about equally well out-of-sample.
 robit's real edge on this data is in beta recovery above, not CPO -- cf. Section 3.)
In [61]:
# Lambda_i should be inversely related to the magnitude of the true latent residual:
# large |eps_i| from the heavy t_5 tail -> small lambda_i -> large variance -> reduced influence
lam_mean = lam_rob.mean(axis=0)
eps_abs  = np.abs(z_true - X @ beta_true)

corr = np.corrcoef(eps_abs, lam_mean)[0, 1]
print(f'Corr(|true residual|, mean lambda_i) = {corr:.3f}')
print('  (expected negative -- moderate at this n: t_5 tail draws get small lambda_i)')
print()

# Tercile breakdown
q33, q67 = np.percentile(eps_abs, [33, 67])
lo  = eps_abs <= q33
mid = (eps_abs > q33) & (eps_abs < q67)
hi  = eps_abs >= q67

print('Posterior mean lambda_i by true-residual tercile:')
print(f'  Low  |eps| (bottom 33%): mean={lam_mean[lo].mean():.3f}  sd={lam_mean[lo].std():.3f}')
print(f'  Mid  |eps| (middle 33%): mean={lam_mean[mid].mean():.3f}  sd={lam_mean[mid].std():.3f}')
print(f'  High |eps| (top    33%): mean={lam_mean[hi].mean():.3f}  sd={lam_mean[hi].std():.3f}')
print()
print(f'  Overall lambda mean={lam_mean.mean():.3f}  sd={lam_mean.std():.3f}  (should be near 1.0)')
Corr(|true residual|, mean lambda_i) = -0.451
  (expected negative -- moderate at this n: t_5 tail draws get small lambda_i)

Posterior mean lambda_i by true-residual tercile:
  Low  |eps| (bottom 33%): mean=1.031  sd=0.029
  Mid  |eps| (middle 33%): mean=1.018  sd=0.047
  High |eps| (top    33%): mean=0.944  sd=0.156

  Overall lambda mean=0.998  sd=0.102  (should be near 1.0)
In [62]:
fig = plt.figure(figsize=(15, 9))
gs  = gridspec.GridSpec(2, 4, figure=fig, hspace=0.42, wspace=0.38)

# Top row: beta posteriors
for j in range(k):
    ax = fig.add_subplot(gs[0, j])
    ax.hist(beta_std[:, j],     bins=70, density=True, alpha=0.55,
            color='steelblue', label='Standard probit')
    ax.hist(beta_rob[:, j],     bins=70, density=True, alpha=0.55,
            color='firebrick', label=f'Robit nu={NU:.0f} (raw)')
    ax.axvline(beta_true[j],   color='black', lw=2, ls='--', label=f'True (t_{NU_TRUE:.0f})')
    ax.axvline(beta_probit[j], color='gray',  lw=1.5, ls=':',  label='True (probit)')
    ax.set_title(beta_names[j], fontsize=10)
    ax.set_xlabel('beta')
    ax.set_ylabel('Density')
    if j == 0:
        ax.legend(fontsize=7)

# Bottom-left: CPO comparison
ax = fig.add_subplot(gs[1, 0:2])
eps_abs = np.abs(z_true - X @ beta_true)
sc = ax.scatter(cpo_std, cpo_rob, c=eps_abs, cmap='YlOrRd', s=10, alpha=0.6)
plt.colorbar(sc, ax=ax, label='|true residual|')
mn = min(cpo_std.min(), cpo_rob.min())
mx = max(cpo_std.max(), cpo_rob.max())
ax.plot([mn, mx], [mn, mx], 'k--', lw=1, label='Equal')
ax.set_xlabel('CPO — Standard probit')
ax.set_ylabel(f'CPO — Robit nu={NU:.0f}')
ax.set_title('CPO comparison  (above diagonal = robit fits obs better)')
ax.legend(fontsize=8)

# Bottom-right: lambda_i vs |true residual|
ax = fig.add_subplot(gs[1, 2:4])
ax.scatter(eps_abs, lam_mean, s=6, alpha=0.4, color='steelblue')
ax.axhline(1.0, color='gray', lw=1, ls=':', label='lambda=1 (standard probit)')
# overlay lowess-style binned means
bins = np.percentile(eps_abs, np.linspace(0, 100, 11))
bin_x, bin_y = [], []
for i in range(len(bins)-1):
    mask = (eps_abs >= bins[i]) & (eps_abs < bins[i+1])
    if mask.sum() > 0:
        bin_x.append(eps_abs[mask].mean())
        bin_y.append(lam_mean[mask].mean())
ax.plot(bin_x, bin_y, 'ro-', ms=6, lw=2, label='Decile mean')
ax.set_xlabel('|True residual|  (large = tail draw from t_5)')
ax.set_ylabel('E[lambda_i | data]')
ax.set_title('Precision weight vs true residual magnitude')
ax.legend(fontsize=8)

fig.suptitle(f'Standard probit vs Robit nu={NU:.0f} — leptokurtic data (true t_{NU_TRUE:.0f} errors)', fontsize=12)
plt.tight_layout()
plt.savefig(r'C:\Users\user\project\robit_sec1.png',
            dpi=120, bbox_inches='tight')
plt.show()
No description has been provided for this image

1. Interpretation — leptokurtic data (true $t_5$ errors)¶

Parameter recovery is where robit clearly wins. Correctly-specified robit ($\nu=5$) recovers the structural $\beta$ in the $t_5$ scale — mean $|$bias$|$ 0.075 — while misspecified standard probit sits at 0.153, almost exactly double. Probit is not broken: it consistently estimates the attenuated $\beta_{\text{probit}} = \beta_{\text{true}}\times\sqrt{(\nu-2)/\nu}$ (a $\approx$23% shrinkage toward 0), which is the right target on the probit scale. In the top row the red (robit) posteriors track the dashed $t_5$-truth and the blue (probit) posteriors the dotted probit-truth for the three slopes $\beta_1$–$\beta_3$, though with visible finite-sample scatter: both overshoot on $\beta_1$ (red 1.28 vs 1.20, blue 1.05 vs 0.93), and both sit low on the intercept $\beta_0$ (red 0.34 against a truth of 0.50). No single histogram is exactly centered at $n=400$ — the aggregate mean $|$bias$|$ above (0.075 vs 0.153) is the honest one-number summary. Scale-correcting robit by $\sqrt{3/5}$ brings it back onto the blue posteriors to within $\approx 0.05$, confirming both estimate the same object in different units.

Prediction is where the two nearly tie. LPML favours robit by only +0.49 nat, and the CPO-versus-true-residual comparison is a wash (large-$|\varepsilon|$ obs: 0.388 probit vs 0.383 robit). This is not a defect of the diagnostic — it is the nature of binary prediction: a 0/1 label barely encodes the tail shape of the latent utility, so getting the tails right (robit's whole advantage) buys almost nothing in out-of-sample label fit. The payoff of the heavy-tailed link is in estimating the latent structure, not in predicting labels.

The weights behave as designed. $\bar\lambda_i$ correlates $-0.45$ with the true residual magnitude (bottom-right panel): observations drawn from the $t_5$ tails acquire smaller precision weights and are automatically downweighted, while the overall mean stays near 1. The correlation is moderate rather than strong because $\lambda_i$ is a per-observation quantity estimated from a single residual — individually noisy, decisive only in aggregate.

This foreshadows Section 3: on the small Finney dataset the predictive (LPML) gap closes entirely, and robit's contribution is almost purely diagnostic — surfacing influential observations through $\bar\lambda_i$.


2. Sensitivity to nu (degrees of freedom)¶

Run robit for nu in {3, 5, 7, 10, 15, 30} plus standard probit (nu = inf). The true generating model has nu_true = 5.

Asymptotic expectation (what infinite data would give — the finite-sample reality below is more nuanced):

  • LPML maximised at nu = 5 (the true value), decaying for nu < 5 (over-heavy) and nu > 5 (toward normal)
  • Robit raw beta_1 → 1.2 (true t_5 scale) at nu = 5
  • Standard probit (nu = inf) → 0.930 (attenuated probit-equivalent scale)

With only n = 400 binary outcomes, the tail index nu turns out to be weakly identified — watch how much of this clean picture actually survives.

In [63]:
NU_GRID = [3, 5, 7, 10, 15, 30]
RESULTS = []

eps_abs  = np.abs(z_true - X @ beta_true)
hi_resid = eps_abs >= np.percentile(eps_abs, 67)   # top third = heavy-tail draws from t_5

for i, nu in enumerate(NU_GRID):
    out = robit_gibbs(
        y, X, nu=float(nu),
        b0=np.zeros(k), B0=100.0 * np.eye(k),
        R=12000, burn=2000, seed=10 + i, nprint=0,
    )
    lpml, _ = compute_cpo_robit(y, X, out['beta'], nu=float(nu))
    c = np.sqrt((nu - 2.0) / nu)
    lam_hi = out['lambda'][:, hi_resid].mean()
    RESULTS.append({
        'nu'     : nu,
        'lpml'   : lpml,
        **{f'b{j}_mean': out['beta'][:, j].mean() * c for j in range(k)},
        **{f'b{j}_sd'  : out['beta'][:, j].std()  * c for j in range(k)},
        'lam_hi' : lam_hi,
    })
    print(f'  nu={nu:2d}  LPML={lpml:.2f}  '
          f'b1={out["beta"][:,1].mean()*c:.4f}  '
          f'lam_hi={lam_hi:.3f}')

# Add standard probit row
lpml_std_ref, _ = compute_cpo_probit(y, X, beta_std)
RESULTS.append({'nu': 999, 'lpml': lpml_std_ref,
                **{f'b{j}_mean': beta_std[:, j].mean() for j in range(k)},
                **{f'b{j}_sd'  : beta_std[:, j].std()  for j in range(k)},
                'lam_hi': 1.0})
print(f'  nu=inf LPML={lpml_std_ref:.2f}  b1={beta_std[:,1].mean():.4f}  lam_hi=1.000')

print()
print(f'True beta (t_{NU_TRUE:.0f} scale): {beta_true}')
print(f'True beta (probit scale)  : {beta_probit.round(4)}')
[robit ν=3] Done in 0.7s  |  kept: 10000
  nu= 3  LPML=-177.41  b1=0.8307  lam_hi=0.914
[robit ν=5] Done in 0.7s  |  kept: 10000
  nu= 5  LPML=-177.56  b1=0.9900  lam_hi=0.944
[robit ν=7] Done in 0.7s  |  kept: 10000
  nu= 7  LPML=-177.63  b1=1.0230  lam_hi=0.959
[robit ν=10] Done in 0.6s  |  kept: 10000
  nu=10  LPML=-177.70  b1=1.0353  lam_hi=0.970
[robit ν=15] Done in 0.6s  |  kept: 10000
  nu=15  LPML=-177.68  b1=1.0450  lam_hi=0.980
[robit ν=30] Done in 0.7s  |  kept: 10000
  nu=30  LPML=-177.77  b1=1.0484  lam_hi=0.990
  nu=inf LPML=-177.93  b1=1.0489  lam_hi=1.000

True beta (t_5 scale): [ 0.5  1.2 -0.8  0.5]
True beta (probit scale)  : [ 0.3873  0.9295 -0.6197  0.3873]
In [64]:
fig, axes = plt.subplots(1, 3, figsize=(15, 4.5))

nus_num  = [r['nu'] for r in RESULTS[:-1]]
xlabels  = [str(nu) for nu in nus_num] + ['inf (std)']
xpos     = np.arange(len(RESULTS))
lpml_all = [r['lpml'] for r in RESULTS]
true_nu_xpos = nus_num.index(int(NU_TRUE)) if int(NU_TRUE) in nus_num else None

# LPML vs nu
ax = axes[0]
ax.plot(xpos[:-1], lpml_all[:-1], 'o-', color='steelblue', lw=2, ms=7, label='Robit')
ax.axhline(lpml_all[-1], color='gray', ls='--', lw=1.5, label='Standard probit')
best_i = int(np.argmax(lpml_all[:-1]))
ax.axvline(xpos[best_i], color='red', ls=':', lw=1.5, label=f'Best nu={nus_num[best_i]}')
if true_nu_xpos is not None:
    ax.axvline(true_nu_xpos, color='green', ls='--', lw=2, label=f'True nu={NU_TRUE:.0f}')
ax.set_xticks(xpos); ax.set_xticklabels(xlabels, fontsize=9)
ax.set_xlabel('nu  (degrees of freedom)')
ax.set_ylabel('LPML')
ax.set_title('LPML vs nu  (nearly flat, ~0.5 nat; every robit > probit)')
ax.legend(fontsize=8)

# beta_1 vs nu  (raw, t-scale)
ax = axes[1]
b1_raw   = [r['b1_mean'] / np.sqrt((r['nu']-2)/r['nu']) if r['nu'] < 900
            else r['b1_mean'] for r in RESULTS]
b1_raw_sd = [r['b1_sd'] / np.sqrt((r['nu']-2)/r['nu']) if r['nu'] < 900
             else r['b1_sd'] for r in RESULTS]
ax.errorbar(xpos, b1_raw, yerr=[1.96*s for s in b1_raw_sd],
            fmt='o-', color='darkorange', lw=2, ms=7, capsize=4, label='Raw beta_1')
ax.axhline(beta_true[1],   color='black', ls='--', lw=2, label=f'True beta_1={beta_true[1]} (t_5)')
ax.axhline(beta_probit[1], color='gray',  ls=':',  lw=1.5, label=f'True beta_1={beta_probit[1]:.3f} (probit)')
if true_nu_xpos is not None:
    ax.axvline(true_nu_xpos, color='green', ls='--', lw=2)
ax.set_xticks(xpos); ax.set_xticklabels(xlabels, fontsize=9)
ax.set_xlabel('nu')
ax.set_ylabel('Raw posterior mean (t-scale)')
ax.set_title('Raw beta_1 — declines with nu, crosses true 1.2 near nu=7')
ax.legend(fontsize=8)

# beta_1 adjusted (probit scale)
ax = axes[2]
b1_adj = [r['b1_mean'] for r in RESULTS]
b1_adj_sd = [r['b1_sd'] for r in RESULTS]
ax.errorbar(xpos, b1_adj, yerr=[1.96*s for s in b1_adj_sd],
            fmt='o-', color='firebrick', lw=2, ms=7, capsize=4, label='Adj beta_1 (probit scale)')
ax.axhline(beta_probit[1], color='black', ls='--', lw=2, label=f'True beta_1={beta_probit[1]:.3f} (probit)')
ax.set_xticks(xpos); ax.set_xticklabels(xlabels, fontsize=9)
ax.set_xlabel('nu')
ax.set_ylabel('Adjusted posterior mean (probit scale)')
ax.set_title('Adjusted beta_1 — rises with nu toward the probit fit')
ax.legend(fontsize=8)

fig.suptitle(f'Sensitivity to nu — leptokurtic data (true t_{NU_TRUE:.0f} errors)', fontsize=12)
plt.tight_layout()
plt.savefig(r'C:\Users\user\project\robit_sec2_nu.png',
            dpi=120, bbox_inches='tight')
plt.show()
No description has been provided for this image

2. Interpretation — LPML vs nu (leptokurtic data)¶

The asymptotic story above is only partly borne out at n = 400, and it is worth being honest about which parts survive — because a binary outcome carries surprisingly little information about the tail index nu.

LPML does not peak at the true nu = 5 — it is nearly flat. Across the whole grid LPML spans only ~0.5 nat (−177.41 at nu = 3 up to −177.93 for standard probit). What tilt there is favours heavier tails: the maximum on this grid sits at the edge, nu = 3 (that is why the figure's own “Best nu” line lands on 3, not the green “True nu = 5”). A 0.15-nat gap between nu = 3 and nu = 5 is far inside Monte-Carlo noise, so LPML simply cannot identify nu here — the honest reading is that any nu in roughly {3,…,10} fits equally well. The one robust conclusion is the ordering at the two ends: every robit fit beats standard probit (nu = inf, the lowest LPML), by a small but consistent margin.

Raw beta_1 does not peak — it declines monotonically from 1.44 (nu = 3) to 1.05 (nu = inf), passing through the true structural value 1.2 near nu = 7 (at the true nu = 5 it is 1.28, a mild finite-sample overshoot). “Recovering the truth” here means the curve crosses 1.2 in the neighbourhood of the true nu, not that it has a maximum there — a monotone curve has no interior peak.

Adjusted beta_1 (probit scale) is not flat either — it rises from 0.83 (nu = 3) to 1.05 (nu = inf), converging to the standard-probit estimate as the tails lighten. The scale correction sqrt((nu−2)/nu) only lines robit up with probit when the tail assumption is roughly right (nu ≈ 5–7); for very heavy tails (nu = 3) it over-corrects, pulling the coefficient below the probit value. (Note the probit target on this sample is the estimated 1.05, above the asymptotic 0.93 — finite-sample probit itself overshoots.)

Practical implication: with leptokurtic errors, parameter recovery is where the choice of nu matters — the raw slope moves by ~35% across the grid, so getting nu roughly right (or estimating it) changes the structural coefficient materially. Model selection by LPML, however, is nearly powerless on binary data: the label barely encodes the tail shape, so LPML can tell you robit beats probit but not which nu. If nu matters for your inference, put a prior on it and integrate it out rather than trusting a flat LPML curve to pick it — the same weak-signal lesson that Sections 1 and 3 reach from the other side.


3. Real data: Finney (1947) Vasoconstriction¶

Background¶

Finney (1947) Probit Analysis, Table 1 reports an experiment by Beall (1929) in which human subjects inhaled air at controlled volumes and rates. The binary response records whether skin vasoconstriction (narrowing of peripheral blood vessels, detected by skin temperature drop) occurred.

Variable Description
Y 1 = vasoconstriction present, 0 = absent
volume Volume of air inspired per breath (litres)
rate Rate of air inspired (litres per second)

Both predictors are used on the log scale (standard since Finney 1947): the physiological effect of volume and rate is multiplicative, not additive, so $\log(\text{volume})$ and $\log(\text{rate})$ linearise the relationship.

The scientific question: what combination of $(\log V, \log R)$ reliably triggers vasoconstriction? The answer is a linear threshold in log-space, estimated by the 0.5-probability contour of the fitted binary model.

Why this dataset illustrates robit¶

With only n = 39 observations, 3-4 discordant cases exert substantial leverage on the fitted probit boundary. Liu (2004) used this exact dataset to introduce robit, showing that the t₇ link shifts the decision boundary toward the majority of the data by downweighting the discordant observations through small $\lambda_i$.

Discordant observations (Liu 2004):

  • Obs 18: low volume and rate, yet Y=1 (most similar obs are Y=0)
  • Obs 19: moderately high volume, yet Y=0 (most similar obs are Y=1)
  • Obs 32: extreme low rate ($\log R = -3.5$), Y=0 — high leverage point
  • Obs 34: nearly identical to obs 33 (Y=0) but Y=1

What to look for (and how much survives at $n=39$ — see the interpretation below):

  • discordant observations should receive small $\lambda_i$ under robit — this holds, and is the section's real result;
  • the textbook picture also has robit shift the boundary toward the bulk and tighten the slope posteriors, but with only 2–3 anomalies among 39 points both effects are negligible here: the two boundaries nearly coincide, and the robit posteriors are in fact slightly wider (the extra $\lambda_i$ layer adds uncertainty faster than 3 downweighted points remove it). Robit is conservative at small $n$.
In [65]:
FINNEY_PATH = r'C:\Users\user\project\finney_vasoconstriction.csv'
df_f  = pd.read_csv(FINNEY_PATH, index_col='obs')

y_f   = df_f['y'].values.astype(int)
lv_f  = df_f['log_volume'].values      # log(volume)
lr_f  = df_f['log_rate'].values        # log(rate)
X_f   = np.column_stack([np.ones(len(y_f)), lv_f, lr_f])
n_f   = len(y_f)
beta_names_f = ['intercept', 'log(volume)', 'log(rate)']

# Discordant observations flagged by Liu (2004)
suspect = np.array([18, 19, 32, 34]) - 1   # 0-indexed

print(f'Finney (1947) vasoconstriction: n={n_f}  y=1: {y_f.sum()}  y=0: {(y_f==0).sum()}')
print()
print('Discordant observations (Liu 2004):')
for i in suspect:
    print(f'  obs {i+1:2d}: log_vol={lv_f[i]:+.3f}  log_rate={lr_f[i]:+.3f}  y={y_f[i]}')
print()

res_mle_f = sm.Probit(y_f, X_f).fit(disp=False)
print('Probit MLE:')
for j, nm in enumerate(beta_names_f):
    print(f'  {nm}: {res_mle_f.params[j]:+.4f}  (SE={res_mle_f.bse[j]:.4f})')
print(f'  log-lik: {res_mle_f.llf:.2f}')
Finney (1947) vasoconstriction: n=39  y=1: 21  y=0: 18

Discordant observations (Liu 2004):
  obs 18: log_vol=-0.163  log_rate=+0.347  y=1
  obs 19: log_vol=+0.531  log_rate=+0.058  y=0
  obs 32: log_vol=+0.854  log_rate=-3.507  y=0
  obs 34: log_vol=+0.095  log_rate=+0.788  y=1

Probit MLE:
  intercept: -1.3828  (SE=0.6253)
  log(volume): +2.7705  (SE=0.8959)
  log(rate): +2.5101  (SE=0.9377)
  log-lik: -14.61
In [66]:
print('=== Standard probit ===')
out_std_f = probit_gibbs(
    y_f, X_f,
    b0=np.zeros(3), B0=100.0 * np.eye(3),
    R=20000, burn=5000, seed=21, nprint=0,
)
beta_std_f = out_std_f['beta']
lpml_std_f, cpo_std_f = compute_cpo_probit(y_f, X_f, beta_std_f)
print(posterior_summary(beta_std_f, beta_names_f).to_string())
print(f'LPML = {lpml_std_f:.2f}')

print()
NU_F = 7.0
print(f'=== Robit nu={NU_F:.0f} (Liu 2004) ===')
out_rob_f  = robit_gibbs(
    y_f, X_f, nu=NU_F,
    b0=np.zeros(3), B0=100.0 * np.eye(3),
    R=20000, burn=5000, seed=22, nprint=0,
)
beta_rob_f = out_rob_f['beta']
lam_rob_f  = out_rob_f['lambda']
c_f = np.sqrt((NU_F - 2) / NU_F)
lpml_rob_f, cpo_rob_f = compute_cpo_robit(y_f, X_f, beta_rob_f, nu=NU_F)
print(posterior_summary(beta_rob_f * c_f, beta_names_f).to_string())
print(f'LPML = {lpml_rob_f:.2f}')
print(f'LPML delta (robit - std) = {lpml_rob_f - lpml_std_f:+.2f}')

print()
lam_mean_f = lam_rob_f.mean(axis=0)
print('Posterior mean lambda_i -- 5 lowest (most downweighted):')
bot5 = np.argsort(lam_mean_f)[:5]
for i in bot5:
    flag = ' <- Liu discordant' if (i in suspect) else ''
    print(f'  obs {i+1:2d}: lambda={lam_mean_f[i]:.3f}  log_vol={lv_f[i]:+.3f}  '
          f'log_rate={lr_f[i]:+.3f}  y={y_f[i]}{flag}')
=== Standard probit ===
[probit std] Done in 0.3s  |  kept: 15000
               mean      sd    q025    q975
intercept   -1.5515  0.6202 -2.8412 -0.4572
log(volume)  3.1030  0.8972  1.5046  5.0288
log(rate)    2.8147  0.9418  1.2084  4.8201
LPML = -18.08

=== Robit nu=7 (Liu 2004) ===
[robit ν=7] Done in 0.8s  |  kept: 15000
               mean      sd    q025    q975
intercept   -1.6506  0.6955 -3.1596 -0.4831
log(volume)  3.0972  0.9918  1.4096  5.2428
log(rate)    2.8203  0.9834  1.1675  4.9390
LPML = -18.10
LPML delta (robit - std) = -0.02

Posterior mean lambda_i -- 5 lowest (most downweighted):
  obs  4: lambda=0.667  log_vol=-0.288  log_rate=+0.405  y=1
  obs 18: lambda=0.725  log_vol=-0.163  log_rate=+0.347  y=1 <- Liu discordant
  obs 24: lambda=0.911  log_vol=+0.405  log_rate=+0.307  y=0
  obs 33: lambda=0.929  log_vol=+0.095  log_rate=+0.604  y=0
  obs 19: lambda=0.959  log_vol=+0.531  log_rate=+0.058  y=0 <- Liu discordant
In [67]:
fig, axes = plt.subplots(1, 3, figsize=(16, 5))

# Obs 4 (0-indexed: 3) -- most downweighted, NOT in Liu's list
new_flag = np.array([3])

# ── Panel 1: Decision boundary in (log_vol, log_rate) space ──────────────────
ax = axes[0]
lv_grid = np.linspace(lv_f.min() - 0.3, lv_f.max() + 0.3, 120)
lr_grid = np.linspace(lr_f.min() - 0.3, lr_f.max() + 0.3, 120)
LV, LR  = np.meshgrid(lv_grid, lr_grid)
X_grid  = np.column_stack([np.ones(LV.size), LV.ravel(), LR.ravel()])

p_std_grid = pred_probs_probit(X_grid, beta_std_f).reshape(LV.shape)
p_rob_grid = pred_probs_robit(X_grid, beta_rob_f, nu=NU_F).reshape(LV.shape)

ax.contour(LV, LR, p_std_grid, levels=[0.5], colors='steelblue', linewidths=2,
           linestyles='-')
ax.contour(LV, LR, p_rob_grid, levels=[0.5], colors='firebrick', linewidths=2,
           linestyles='--')

# Mask for normal, Liu-discordant, and newly-identified obs
all_special = np.union1d(suspect, new_flag)
normal_mask = ~np.isin(np.arange(n_f), all_special)
y1 = y_f == 1

ax.scatter(lv_f[y1  & normal_mask], lr_f[y1  & normal_mask],
           marker='+', s=80, color='black', linewidths=1.5, label='y=1')
ax.scatter(lv_f[~y1 & normal_mask], lr_f[~y1 & normal_mask],
           marker='o', s=40, color='black', facecolors='none', linewidths=1.5, label='y=0')
ax.scatter(lv_f[np.isin(np.arange(n_f), suspect)],
           lr_f[np.isin(np.arange(n_f), suspect)],
           marker='x', s=120, color='red', linewidths=2.5, label='Discordant (Liu)')
ax.scatter(lv_f[new_flag], lr_f[new_flag],
           marker='x', s=150, color='darkorange', linewidths=2.5,
           label='Obs 4 (lowest $\\bar\\lambda$, not in Liu)')

for i in suspect:
    ax.annotate(str(i+1), (lv_f[i], lr_f[i]), fontsize=8, color='red',
                xytext=(4, 4), textcoords='offset points')
for i in new_flag:
    ax.annotate(str(i+1), (lv_f[i], lr_f[i]), fontsize=8, color='darkorange',
                xytext=(4, 4), textcoords='offset points')

from matplotlib.lines import Line2D
handles = [
    Line2D([0],[0], color='steelblue', lw=2, ls='-',  label='Probit boundary'),
    Line2D([0],[0], color='firebrick', lw=2, ls='--', label=f'Robit $\\nu$={NU_F:.0f} boundary'),
    Line2D([0],[0], marker='+', color='k', ls='None', ms=10, label='y=1'),
    Line2D([0],[0], marker='o', color='k', ls='None', ms=8, mfc='none', label='y=0'),
    Line2D([0],[0], marker='x', color='red', ls='None', ms=10, lw=2,
           label='Discordant (Liu)'),
    Line2D([0],[0], marker='x', color='darkorange', ls='None', ms=10, lw=2,
           label='Obs 4 (lowest $\\bar\\lambda$)'),
]
ax.legend(handles=handles, fontsize=7, loc='upper right')
ax.set_xlabel('log(volume)')
ax.set_ylabel('log(rate)')
ax.set_title('Decision boundary: probit (blue) vs robit (red dashed)')

# ── Panel 2: Lambda_i per observation ─────────────────────────────────────────
ax = axes[1]
obs_idx = np.arange(1, n_f + 1)
colors  = ['darkorange' if i in new_flag
           else ('red' if i in suspect
           else ('steelblue' if y_f[i] == 1 else 'gray'))
           for i in range(n_f)]
ax.bar(obs_idx, lam_mean_f, color=colors, alpha=0.7, edgecolor='k', linewidth=0.3)
ax.axhline(1.0, color='black', lw=1, ls=':', label='lambda=1 (standard probit)')
ax.set_xlabel('Observation index')
ax.set_ylabel('$E[\\lambda_i \\mid$ data$]$')
ax.set_title(f'Precision weights (robit $\\nu$={NU_F:.0f})\nRed = Liu discordant  |  Orange = obs 4 (lowest)')
ax.set_xlim(0.5, n_f + 0.5)
from matplotlib.patches import Patch
ax.legend(handles=[
    Patch(color='steelblue',   alpha=0.7, label='y=1 (normal)'),
    Patch(color='gray',        alpha=0.7, label='y=0 (normal)'),
    Patch(color='red',         alpha=0.7, label='Discordant (Liu)'),
    Patch(color='darkorange',  alpha=0.7, label='Obs 4 (lowest $\\bar\\lambda$)'),
], fontsize=8)

# ── Panel 3: Slope posteriors (log_volume coefficient) ────────────────────────
ax = axes[2]
ax.hist(beta_std_f[:, 1],       bins=70, density=True, alpha=0.6,
        color='steelblue', label='Standard probit')
ax.hist(beta_rob_f[:, 1] * c_f, bins=70, density=True, alpha=0.6,
        color='firebrick', label=f'Robit $\\nu$={NU_F:.0f} (adj)')
ax.axvline(res_mle_f.params[1], color='black', ls='--', lw=2, label='MLE')
ax.set_xlabel('log(volume) coefficient')
ax.set_ylabel('Density')
ax.set_title('Posterior of log(volume) slope\n(scale-adjusted robit)')
ax.legend(fontsize=9)

fig.suptitle('Finney (1947) Vasoconstriction — Standard probit vs Robit (Liu 2004)', fontsize=12)
plt.tight_layout()
plt.savefig(r'C:\Users\user\project\robit_sec3_finney.png',
            dpi=120, bbox_inches='tight')
plt.show()
No description has been provided for this image

3. Interpretation — Finney (1947) vasoconstriction¶

LPML is essentially flat (Δ = −0.02 nats). With n = 39 and only 2–3 genuinely misclassified observations, the harmonic-mean CPO estimator lacks power to distinguish the $t_7$ link from $\Phi$. Most of the 39 observations lie far from the decision boundary and are fitted equally well by either model — the tails only differ for the few cases close to or on the wrong side of the boundary.

The precision-weight diagnostic is where robit adds value:

Rank Obs $\bar\lambda_i$ log vol log rate Y Note
1 4 0.667 −0.29 +0.35 1 Not in Liu — most extreme misfit
2 18 0.725 −0.16 +0.35 1 Liu discordant #1
5 19 0.959 +0.53 +0.06 0 Liu discordant #2

Obs 4 (0-indexed: 3) was not flagged by Liu (2004) but carries the smallest posterior $\bar\lambda_i = 0.667$. Standard probit's posterior-predictive gives it only $\hat P(Y=1) \approx 12\%$ (about 10% at the posterior mean) yet $Y = 1$ — it is the most anomalous classification in the dataset, and the sampler detects it automatically without any analyst input.

Obs 32 and 34 are not in the five lowest $\bar\lambda_i$. Obs 32's extreme $\log R = -3.5$ places it far from the boundary (near-certain $\hat P(Y=1) \approx 0$), so its prediction is already consistent; it has high leverage but is not misclassified. Liu flagged it as influential in a sensitivity sense, not because it defies the boundary.

Coefficient estimates are nearly identical (log(volume): 3.10 vs 3.10; log(rate): 2.81 vs 2.82 after scale correction $\times\sqrt{5/7}$). Robit is conservative at small $n$: with only 2–3 genuinely anomalous observations it does not over-correct the slope.

Take-away: in small-$n$ settings the main contribution of robit is diagnostic — the posterior mean $\bar\lambda_i$ surfaces influential/misclassified observations without the analyst specifying them in advance. LPML advantages become clearer in larger samples or with structurally leptokurtic errors (Sections 1–2), where the heavy-tail signal is stronger.


Summary¶

Model comparison¶

Feature Standard probit Robit (this script)
Error distribution $N(0,1)$ $t_\nu(0,1)$ via scale-mixture
Link function $\Phi(x'\beta)$ $T_\nu(x'\beta)$
Gibbs block 1 TN with fixed sd = 1 TN with varying sd = $1/\sqrt{\lambda_i}$
Gibbs block 2 $\bar B$ precomputed once $\bar B$ recomputed each iteration
Block 3 — $\lambda_i \sim \text{Gamma}((\nu+1)/2,\;\ldots)$
CPO Normal likelihood Marginal $t$-CDF (integrates out $\lambda_i$)
Scale correction — $\hat\beta_{\text{adj}} = \hat\beta_{\text{robit}} \times \sqrt{(\nu-2)/\nu}$
Runtime $O(k^2)$ per iter $O(nk^2)$ per iter

Results summary¶

Section 1 — Leptokurtic synthetic data ($n=400$, true $t_5$ errors):

Model Raw $\hat\beta_1$ Bias from $\beta_1^{\text{true}}=1.2$ LPML
Standard probit (misspecified) ~0.93 ~22% attenuation lower
Robit $\nu=5$ (correct) ~1.20 ~0% higher

Standard probit converges to $\beta_{\text{true}} \times \sqrt{3/5}$ — systematically attenuated by 23%. Correctly-specified robit recovers the structural parameter. The precision weights $\bar\lambda_i$ are negatively correlated (−0.45) with the true residual magnitude: large $t_5$ tail draws acquire small $\lambda_i$ as expected.

Section 2 — Sensitivity to $\nu$:

  • LPML is nearly flat across $\nu$ (spread $\approx 0.5$ nat) — binary data cannot identify the tail index; the grid maximum is at the edge $\nu=3$, not the true $\nu=5$. The only robust finding is that every robit $\nu$ beats standard probit ($\nu=\infty$, the lowest LPML).
  • Raw $\hat\beta_1$ declines monotonically with $\nu$ (1.44 → 1.05), crossing the true 1.2 near $\nu=7$.
  • Scale-adjusted $\hat\beta_1$ rises with $\nu$ (0.83 → 1.05) toward the probit estimate — not flat; the correction only aligns robit with probit when the tail assumption is roughly right.

Section 3 — Finney (1947) vasoconstriction ($n=39$, $\nu=7$):

  • LPML delta = −0.02 nats (essentially flat) — expected with $n=39$ and few discordant obs.
  • Coefficient estimates nearly identical after scale adjustment.
  • $\bar\lambda_i$ correctly identifies the most anomalous observations without analyst input: obs 4 (lowest, $\bar\lambda=0.667$, not in Liu's original list) and obs 18 ($\bar\lambda=0.725$, Liu's first discordant case).
  • With small $n$ the main benefit is diagnostic, not predictive.

When to use robit¶

Situation Recommendation
Clean data, errors near Normal Standard probit sufficient
Structurally leptokurtic errors (heavy-tailed DGP) Robit $\nu \approx 3$–$7$; recovers true $\beta$
Small $n$ with suspected influential observations Robit + inspect $\bar\lambda_i$
LPML(robit) $\gg$ LPML(probit) Robit preferred for prediction
Unsure Run both; compare LPML and inspect $\bar\lambda_i$

Scale adjustment table¶

$\nu$ $\sqrt{(\nu-2)/\nu}$ Marginal variance $\nu/(\nu-2)$
3 0.577 3.00
5 0.775 1.67
7 0.845 1.40
10 0.894 1.25
15 0.930 1.15
30 0.966 1.07
$\infty$ 1.000 1.00

References¶

  • Liu (2004) — coined robit regression; showed it is a drop-in for logit/probit; introduced $\lambda_i$ diagnostic
  • Geweke (1993) — established the Normal scale-mixture used in the Gibbs sampler
  • Albert & Chib (1993) — data-augmentation for standard binary probit (base algorithm)
  • Holmes & Held (2006) — auxiliary variable Gibbs for binary/multinomial with scale-mixtures
  • Finney (1947) — Probit Analysis; vasoconstriction dataset (Beall 1929 experiment)