Bayesian Linear Regression with Inequality Constraints¶

Geweke (1995) — Python implementation¶

Model: $$y = X\beta + \varepsilon, \quad \varepsilon \sim N(0, \sigma^2 I), \quad a \le D\beta \le w$$

Structure of this notebook:

Section Topic
Section 1 GHK probability simulator validated against analytical bivariate Normal CDF
Section 2 Easy constraints ($p_{2\|1} \approx 0.2$–$0.4$): Gibbs vs rejection sampling — both agree
Section 3 Hard constraints ($p_{2\|1} \approx 10^{-4}$): rejection sampling breaks down, Gibbs unaffected
Section 4 UCI Automobile data ($p_{2\|1} \approx 4\times 10^{-4}$): Gibbs only, economic sign constraints
Section 5 Coverage validation: 95% CrI achieves correct frequentist coverage

Two key algorithms:

  1. GHK probability simulator — evaluates $p_{2|1} = P(a \le D\beta \le w \mid \text{data})$ under the unconstrained posterior. Uses the sequential Cholesky factorisation of $\text{Cov}(D\beta)$ to decompose the joint probability into a product of univariate truncated-Normal probabilities.

  2. Constrained Gibbs sampler — draws $\beta$ from the posterior truncated to the constraint region. Each $\beta_j$ is drawn from its conditional $$\beta_j \mid \beta_{-j}, \sigma^2, \text{constraints} \sim TN(m_j, v_j; L_j, U_j)$$ where $L_j, U_j$ are the tightest bounds on $\beta_j$ implied by all constraint rows given the current $\beta_{-j}$.

Reference: Geweke, J. (1995). Bayesian Inference for Linear Models Subject to Linear Inequality Constraints. In W.O. Johnson & A. Zellner (Eds.), Modelling and Prediction: Honoring Seymour Geisser (pp. 248–263).

In [1]:
import os, sys

# Windows DLL fix for conda environments
conda_lib = os.path.join(os.environ.get('CONDA_PREFIX', ''), 'Library', 'bin')
if os.path.isdir(conda_lib) and conda_lib not in os.environ.get('PATH', ''):
    os.environ['PATH'] = conda_lib + ';' + os.environ.get('PATH', '')

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.stats import multivariate_normal as mvn_dist

sys.path.insert(0, r'c:\Users\user\project')
from constrained_lm_gibbs import (
    compute_posterior, ghk_prob, constrained_gibbs, posterior_summary
)
print('Import OK')
Import OK

1. GHK vs analytical bivariate Normal CDF¶

For q = 1 (a single inequality), GHK reduces to a direct CDF evaluation: every draw produces the same weight, so se = 0 — the estimator is exact but trivially non-stochastic. The Monte Carlo variance of GHK only appears when q ≥ 2, because the standardised draw $u_j$ from step $j$ propagates into the bounds of step $j+1$.

Here we use k = 2 (intercept + slope) with two positivity constraints $\beta_0 > 0$ and $\beta_1 > 0$, so the posterior probability

$$p_{2|1} = P(\beta_0 > 0,\; \beta_1 > 0 \mid \text{data})$$

can be evaluated analytically via scipy.stats.multivariate_normal.cdf and compared to GHK with its genuine Monte Carlo se.

In [2]:
rng1 = np.random.default_rng(0)
n      = 25
k1     = 2
beta_t = np.array([0.5, 0.4])
sigma2 = 1.0
X1 = np.column_stack([np.ones(n), rng1.standard_normal(n)])
y1 = X1 @ beta_t + rng1.standard_normal(n)

bbar1, Bbar1 = compute_posterior(y1, X1, sigma2)
print(f'Unconstrained posterior mean: {bbar1.round(4)}')
print(f'Posterior covariance:\n{Bbar1.round(4)}')

# Analytical p_{2|1} = P(β > 0) where β ~ N(bbar, Bbar)
# P(β > 0) = P(-β ≤ 0) with -β ~ N(-bbar, Bbar)
p_analytical = float(mvn_dist(mean=-bbar1, cov=Bbar1).cdf(np.zeros(k1)))
print(f'\nAnalytical p_{{2|1}} = {p_analytical:.6f}')

# GHK estimate (q=2: both β_j > 0)
D1 = np.eye(k1)
a1 = np.zeros(k1)
w1 = np.full(k1, np.inf)
est, se = ghk_prob(a1, w1, D1, bbar1, Bbar1, M=30000, seed=1)
diff = abs(est - p_analytical)
print(f'GHK estimate:         {est:.6f}  (se = {se:.6f})')
print(f'Difference from analytical: {diff:.2e}  ({diff/se:.1f} SE)')
Unconstrained posterior mean: [0.8289 0.3651]
Posterior covariance:
[[0.0403 0.004 ]
 [0.004  0.0547]]

Analytical p_{2|1} = 0.940726
GHK estimate:         0.940705  (se = 0.000058)
Difference from analytical: 2.18e-05  (0.4 SE)
In [3]:
out1 = constrained_gibbs(
    y1, X1, D1, a1, w1,
    sigma2=sigma2, R=12000, burn=2000, seed=2, nprint=0
)

beta_c1 = out1['beta']
print(f'Constrained posterior means: {beta_c1.mean(0).round(4)}')
print(f'Constrained posterior SDs:   {beta_c1.std(0).round(4)}')
print(f'All draws satisfy beta>0: {(beta_c1 > 0).all()}')
print(f'True beta: {beta_t}')

out1u = constrained_gibbs(
    y1, X1, np.zeros((0, k1)), np.array([]), np.array([]),
    sigma2=sigma2, R=12000, burn=2000, seed=2, nprint=0
)
print(f'\nUnconstrained posterior means: {out1u["beta"].mean(0).round(4)}')
print(f'Unconstrained posterior SDs:   {out1u["beta"].std(0).round(4)}')
Done in 0.0 min  |  kept draws: 10000
Constrained posterior means: [0.8313 0.3942]
Constrained posterior SDs:   [0.2007 0.2087]
All draws satisfy beta>0: True
True beta: [0.5 0.4]
Done in 0.0 min  |  kept draws: 10000

Unconstrained posterior means: [0.8291 0.3647]
Unconstrained posterior SDs:   [0.2009 0.2352]
In [4]:
fig, axes = plt.subplots(1, k1, figsize=(10, 3.5))
for j, ax in enumerate(axes):
    all_vals = np.concatenate([beta_c1[:, j], out1u['beta'][:, j]])
    bins = np.linspace(all_vals.min(), all_vals.max(), 60)
    ax.hist(out1u['beta'][:, j], bins=bins, density=True, alpha=0.4,
            label='Unconstrained', color='steelblue')
    ax.hist(beta_c1[:, j], bins=bins, density=True, alpha=0.6,
            label='Constrained (β>0)', color='orange')
    ax.axvline(beta_t[j], color='k', ls='--', label=f'True β = {beta_t[j]}')
    ax.axvline(0, color='red', ls=':', lw=1.5, label='Constraint boundary')
    ax.set_xlabel(f'β[{j}]'); ax.set_ylabel('Density')
    ax.legend(fontsize=8)
fig.suptitle('§1 — Constrained vs unconstrained posterior (both β_j > 0)')
plt.tight_layout(); plt.show()
No description has been provided for this image

Because both positivity constraints are already easily satisfied here ($p_{2|1}\approx 0.94$), the constrained posterior (orange) barely differs from the unconstrained one (blue) — they overlay almost exactly, with the constrained draws simply refusing to cross the $\beta = 0$ boundary (red dotted). Constraints only reshape the posterior when they actually bind, the point Sections 2–4 make progressively harder.

GHK convergence - the simulator estimate with +/-2 SE bands tightening onto the exact analytic probability as draws M grow.

In [5]:
# GHK Monte-Carlo convergence: estimate +/- 2*SE vs number of draws M -> analytic p_{2|1}
Ms = np.unique(np.logspace(2, 4.7, 24).astype(int))
ev, sv = [], []
for M in Ms:
    e_, s_ = ghk_prob(a1, w1, D1, bbar1, Bbar1, M=int(M), seed=7)
    ev.append(e_); sv.append(s_)
ev, sv = np.array(ev), np.array(sv)
fig, ax = plt.subplots(figsize=(8, 4))
ax.fill_between(Ms, ev-2*sv, ev+2*sv, color='steelblue', alpha=.25, label='+/- 2 SE')
ax.plot(Ms, ev, color='steelblue', lw=1.3, marker='o', ms=3, label='GHK estimate')
ax.axhline(p_analytical, color='firebrick', ls='--', lw=1.5, label=f'analytic p = {p_analytical:.4f}')
ax.set_xscale('log'); ax.set_xlabel('GHK draws M'); ax.set_ylabel(r'$p_{2|1}$ estimate')
ax.set_title('GHK converges to the analytic probability (SE shrinks like 1/sqrt(M))'); ax.legend(fontsize=8)
plt.tight_layout(); plt.savefig('constrained_ghk_conv.png', dpi=120, bbox_inches='tight'); plt.show()
No description has been provided for this image

2. Easy constraints: Gibbs vs rejection sampling¶

When the unconstrained posterior places reasonable mass in the feasible region ($p_{2|1} \approx 0.1$–$0.5$), rejection sampling — draw from $N(\bar{b}, \bar{B})$, keep draws satisfying the constraints — is simple and correct. We use it here as a ground-truth baseline to validate the Gibbs sampler.

Design: $k=4$, $n=50$, $\sigma^2=2$ (known). Constraints: $\beta_1>0$ (modestly satisfied by OLS) and $\beta_2>0$ (slightly violated). Expected $p_{2|1}$ around 0.2–0.4.

In [6]:
import time

# ── Generate data ─────────────────────────────────────────────────────────────
rng2 = np.random.default_rng(100)
n_e, k_e  = 50, 4
beta_t_e  = np.array([1.5,  0.28, -0.18,  0.80])  # β[1] modest, β[2] slightly negative
sigma2_e  = 2.0
X_e = np.column_stack([np.ones(n_e), rng2.standard_normal((n_e, k_e - 1))])
y_e = X_e @ beta_t_e + np.sqrt(sigma2_e) * rng2.standard_normal(n_e)

bbar_e, Bbar_e = compute_posterior(y_e, X_e, sigma2_e)
se_e   = np.sqrt(np.diag(Bbar_e))
names_e = [f'β[{j}]' for j in range(k_e)]

print('Unconstrained posterior:')
for nm, b, s in zip(names_e, bbar_e, se_e):
    print(f'  {nm}: mean={b:6.3f}  sd={s:.3f}')

# Constraints: β[1] > 0 and β[2] > 0
D_e = np.eye(k_e)[1:3]    # rows 1 and 2 of identity matrix
a_e = np.zeros(2)
w_e = np.full(2, np.inf)

est_e, se_e_ghk = ghk_prob(a_e, w_e, D_e, bbar_e, Bbar_e, M=50000, seed=101)
print(f'\nGHK p_{{2|1}} = {est_e:.4f}  (se={se_e_ghk:.4f})')
print(f'Expected draws needed per accepted sample: ~{1/est_e:.1f}')
Unconstrained posterior:
  β[0]: mean= 1.644  sd=0.206
  β[1]: mean= 0.208  sd=0.245
  β[2]: mean= 0.064  sd=0.195
  β[3]: mean= 0.815  sd=0.219

GHK p_{2|1} = 0.5236  (se=0.0002)
Expected draws needed per accepted sample: ~1.9
In [7]:
# ── Gibbs ─────────────────────────────────────────────────────────────────────
t0 = time.time()
out_e_gibbs = constrained_gibbs(y_e, X_e, D_e, a_e, w_e,
                                 sigma2=sigma2_e, R=15000, burn=3000,
                                 seed=102, nprint=0)
t_gibbs = time.time() - t0
n_gibbs = out_e_gibbs['beta'].shape[0]

# ── Rejection sampling from unconstrained posterior ───────────────────────────
rng_rs = np.random.default_rng(103)
M_rs   = 300_000
t0 = time.time()
draws_rs = rng_rs.multivariate_normal(bbar_e, Bbar_e, M_rs)
Db_rs    = draws_rs @ D_e.T        # D_e selects β[1] and β[2]
mask_rs  = ((Db_rs >= a_e) & (Db_rs <= w_e)).all(axis=1)
t_rs     = time.time() - t0
accepted_rs = draws_rs[mask_rs]
n_rs = len(accepted_rs)

print(f'Gibbs:              {n_gibbs:6,} draws   in {t_gibbs:.2f}s')
print(f'Rejection sampling: {n_rs:6,} accepted / {M_rs:,} drawn  in {t_rs:.2f}s')
print(f'  acceptance rate = {mask_rs.mean():.4f}  (GHK: {est_e:.4f})')

# ── Compare posterior summaries ────────────────────────────────────────────────
summ_g = posterior_summary(out_e_gibbs['beta'], names_e)
summ_r = posterior_summary(accepted_rs, names_e)
comp   = summ_g[['mean', 'sd']].copy()
comp.columns = ['Gibbs_mean', 'Gibbs_sd']
comp['RS_mean'] = summ_r['mean']
comp['RS_sd']   = summ_r['sd']
comp['|diff|']  = (comp['Gibbs_mean'] - comp['RS_mean']).abs()
print('\nPosterior comparison — both methods should agree:')
print(comp.round(4))
print(f'\nMax |mean difference|: {comp["|diff|"].max():.4f}  '
      f'(≈ {comp["|diff|"].max()/summ_g["sd"].mean():.2f} posterior SDs)')
Done in 0.0 min  |  kept draws: 12000
Gibbs:              12,000 draws   in 0.72s
Rejection sampling: 157,199 accepted / 300,000 drawn  in 0.02s
  acceptance rate = 0.5240  (GHK: 0.5236)

Posterior comparison — both methods should agree:
      Gibbs_mean  Gibbs_sd  RS_mean   RS_sd  |diff|
β[0]      1.6322    0.2053   1.6317  0.2051  0.0005
β[1]      0.3060    0.1902   0.3073  0.1913  0.0013
β[2]      0.1843    0.1309   0.1866  0.1318  0.0023
β[3]      0.8071    0.2183   0.8034  0.2176  0.0037

Max |mean difference|: 0.0037  (≈ 0.02 posterior SDs)
In [8]:
fig, axes = plt.subplots(2, k_e, figsize=(14, 6))
for j in range(k_e):
    # Row 0: both methods overlaid
    ax = axes[0, j]
    vals = np.concatenate([out_e_gibbs['beta'][:, j], accepted_rs[:, j]])
    bins = np.linspace(np.percentile(vals, 0.5), np.percentile(vals, 99.5), 50)
    ax.hist(accepted_rs[:, j], bins=bins, density=True, alpha=0.5,
            color='steelblue', label=f'Rejection (n={n_rs:,})')
    ax.hist(out_e_gibbs['beta'][:, j], bins=bins, density=True, alpha=0.5,
            color='orange', label=f'Gibbs (n={n_gibbs:,})')
    ax.axvline(beta_t_e[j], color='k', ls='--', lw=1.5)
    if j in (1, 2):
        ax.axvline(0, color='red', ls=':', lw=1.5)
    ax.set_title(names_e[j], fontsize=9)
    ax.legend(fontsize=7)
    # Row 1: difference in densities (Gibbs − rejection) via KDE bins
    ax2 = axes[1, j]
    ax2.bar(bins[:-1], np.histogram(out_e_gibbs['beta'][:, j], bins=bins, density=True)[0]
                      - np.histogram(accepted_rs[:, j], bins=bins, density=True)[0],
            width=np.diff(bins), align='edge', color='gray', alpha=0.7)
    ax2.axhline(0, color='k', lw=0.8)
    ax2.set_title(f'Gibbs − Rejection  [{names_e[j]}]', fontsize=8)

axes[0, 0].set_ylabel('Density')
axes[1, 0].set_ylabel('Density diff')
fig.suptitle(
    f'§2 Easy (p_{{2|1}} = {est_e:.3f}): Gibbs and rejection sampling agree\n'
    f'Both constraints: β[1]>0 and β[2]>0',
    fontsize=10
)
plt.tight_layout(); plt.show()
No description has been provided for this image

At a moderate $p_{2|1}\approx 0.52$ the Gibbs (orange) and rejection-sampling (blue) posteriors are indistinguishable — the bottom-row "Gibbs − Rejection" panels sit on zero (largest mean gap $\approx 0.02$ posterior SD). Rejection sampling is exact here, so this agreement validates the from-scratch Gibbs sampler before it is used in Sections 3–4, where rejection can no longer follow.

Geometry of truncation - the feasible region in the (b1, b2) plane and the truncated marginal of b2 (the constraint that bites).

In [9]:
# Geometry of truncation: feasible region + a truncated marginal  (section-2 design, coefs b1,b2)
from scipy.stats import norm
from matplotlib.patches import Rectangle
unc = draws_rs[:, 1:3]                       # unconstrained posterior draws (b1, b2)
con = out_e_gibbs['beta'][:, 1:3]            # constrained Gibbs draws
ru = np.random.default_rng(0)
iu = ru.choice(len(unc), 4000, replace=False); ic = ru.choice(len(con), 4000, replace=False)
fig, ax = plt.subplots(1, 2, figsize=(13, 5))
xl = (np.percentile(unc[:,0],0.5), np.percentile(unc[:,0],99.5))
yl = (np.percentile(unc[:,1],0.5), np.percentile(unc[:,1],99.5))
ax[0].add_patch(Rectangle((0,0), xl[1], yl[1], color='green', alpha=.08, zorder=0, label='feasible region (b1>0, b2>0)'))
ax[0].scatter(unc[iu,0], unc[iu,1], s=5, alpha=.12, color='steelblue', label='unconstrained')
ax[0].scatter(con[ic,0], con[ic,1], s=5, alpha=.22, color='orange', label='constrained')
ax[0].axhline(0, color='red', ls=':'); ax[0].axvline(0, color='red', ls=':')
ax[0].scatter(beta_t_e[1], beta_t_e[2], color='black', marker='*', s=160, zorder=6, label='true beta (b2<0, infeasible)')
ax[0].set_xlim(xl); ax[0].set_ylim(yl); ax[0].set_xlabel('b[1]'); ax[0].set_ylabel('b[2]')
ax[0].set_title('Posterior clipped to the feasible region'); ax[0].legend(fontsize=8, loc='upper left')
m2, s2 = bbar_e[2], np.sqrt(Bbar_e[2,2]); g = np.linspace(m2-4*s2, m2+4*s2, 400)
ax[1].plot(g, norm.pdf(g, m2, s2), color='steelblue', lw=2, label='unconstrained N')
ax[1].fill_between(g[g<0], norm.pdf(g[g<0], m2, s2), color='red', alpha=.22, label='removed mass (b2<0)')
ax[1].hist(con[:,1], bins=60, density=True, alpha=.5, color='orange', label='constrained (truncated)')
ax[1].axvline(0, color='red', ls=':'); ax[1].axvline(beta_t_e[2], color='k', ls='--', label=f'true b2={beta_t_e[2]}')
ax[1].set_xlabel('b[2]'); ax[1].set_ylabel('density'); ax[1].set_title('Truncation of b[2]: mass below 0 removed, mean pushed up'); ax[1].legend(fontsize=8)
plt.tight_layout(); plt.savefig('constrained_geom.png', dpi=120, bbox_inches='tight'); plt.show()
No description has been provided for this image

3. Hard constraints: when rejection sampling breaks down¶

As $p_{2|1}$ shrinks, rejection sampling needs $\sim 1/p_{2|1}$ draws per accepted sample. For $p_{2|1} \approx 10^{-4}$ this is completely impractical, while the Gibbs sampler is unaffected — it always accepts.

Design: $k=5$, $n=100$, $\sigma^2=1$ (known). Four lower bounds placed at 1.2 posterior SDs above the unconstrained mean for $\beta_1, \ldots, \beta_4$.
Each marginal probability $\approx \Phi(-1.2) \approx 0.115$; joint $\approx 0.115^4 \approx 1.75 \times 10^{-4}$.

In [10]:
# ── Generate data ─────────────────────────────────────────────────────────────
rng_h = np.random.default_rng(200)
n_h, k_h = 100, 5
beta_t_h  = np.array([2.0, 1.0, 0.5, 0.3, 1.5])
sigma2_h  = 1.0
X_h = np.column_stack([np.ones(n_h), rng_h.standard_normal((n_h, k_h - 1))])
y_h = X_h @ beta_t_h + rng_h.standard_normal(n_h)

bbar_h, Bbar_h = compute_posterior(y_h, X_h, sigma2_h)
sd_h   = np.sqrt(np.diag(Bbar_h))
names_h = [f'β[{j}]' for j in range(k_h)]

# ── Tight constraints: lower bound = posterior mean + 1.2 SD for β[1..4] ─────
# Each marginal P(β[j] > bound) ≈ Φ(-1.2) ≈ 0.115; joint ≈ 0.115^4 ≈ 1.75e-4
D_h = np.eye(k_h)[1:]              # constrain β[1], β[2], β[3], β[4]
a_h = bbar_h[1:] + 1.2 * sd_h[1:] # tight lower bounds
w_h = np.full(k_h - 1, np.inf)

print('Unconstrained posterior:')
for nm, b, s in zip(names_h, bbar_h, sd_h):
    print(f'  {nm}: mean={b:.3f}  sd={s:.3f}')

print('\nIndividual constraint probabilities:')
for i in range(k_h - 1):
    Di = D_h[i:i+1]; ai = a_h[i:i+1]; wi = w_h[i:i+1]
    ei, _ = ghk_prob(ai, wi, Di, bbar_h, Bbar_h, M=50000, seed=210 + i)
    print(f'  P(β[{i+1}] > {a_h[i]:.3f}) = {ei:.4f}  (≈ Φ(-1.2) = 0.115)')

est_h, se_h = ghk_prob(a_h, w_h, D_h, bbar_h, Bbar_h, M=200000, seed=219)
print(f'\nGHK joint p_{{2|1}} = {est_h:.2e}  (se={se_h:.2e})')
print(f'Expected draws per accepted sample: ~{1/est_h:,.0f}')
Unconstrained posterior:
  β[0]: mean=2.051  sd=0.103
  β[1]: mean=1.076  sd=0.106
  β[2]: mean=0.733  sd=0.109
  β[3]: mean=0.348  sd=0.096
  β[4]: mean=1.544  sd=0.093

Individual constraint probabilities:
  P(β[1] > 1.203) = 0.1151  (≈ Φ(-1.2) = 0.115)
  P(β[2] > 0.863) = 0.1151  (≈ Φ(-1.2) = 0.115)
  P(β[3] > 0.463) = 0.1151  (≈ Φ(-1.2) = 0.115)
  P(β[4] > 1.655) = 0.1151  (≈ Φ(-1.2) = 0.115)

GHK joint p_{2|1} = 3.88e-04  (se=1.12e-07)
Expected draws per accepted sample: ~2,576
In [11]:
# ── Gibbs sampler ─────────────────────────────────────────────────────────────
t0 = time.time()
out_h_gibbs = constrained_gibbs(y_h, X_h, D_h, a_h, w_h,
                                 sigma2=sigma2_h, R=18000, burn=3000,
                                 seed=220, nprint=0)
t_gibbs_h = time.time() - t0
n_gibbs_h = out_h_gibbs['beta'].shape[0]

# ── Rejection sampling with fixed budget ──────────────────────────────────────
rng_rs_h = np.random.default_rng(221)
M_rs_h   = 1_000_000
t0 = time.time()
draws_rs_h = rng_rs_h.multivariate_normal(bbar_h, Bbar_h, M_rs_h)
# D_h = I[1:], so D_h @ beta = beta[:,1:]; check >= a_h
mask_rs_h  = (draws_rs_h[:, 1:] >= a_h).all(axis=1)
t_rs_h     = time.time() - t0
accepted_rs_h = draws_rs_h[mask_rs_h]
n_rs_h = len(accepted_rs_h)

print(f'Gibbs:             {n_gibbs_h:6,} draws   in {t_gibbs_h:.2f}s')
print(f'Rejection sampling:{n_rs_h:6,} accepted / {M_rs_h:,} drawn  in {t_rs_h:.2f}s')
print(f'  acceptance rate = {mask_rs_h.mean():.2e}  (GHK estimate: {est_h:.2e})')
print(f'\nTo match Gibbs ({n_gibbs_h:,} draws), rejection sampling would need')
print(f'  ≈ {int(n_gibbs_h / est_h):,} total draws  '
      f'({int(n_gibbs_h / est_h / M_rs_h)}× our budget of {M_rs_h:,})')

print('\nConstrained posterior (Gibbs, §3 hard):')
print(posterior_summary(out_h_gibbs['beta'], names_h))
Done in 0.0 min  |  kept draws: 15000
Gibbs:             15,000 draws   in 1.34s
Rejection sampling:   369 accepted / 1,000,000 drawn  in 0.07s
  acceptance rate = 3.69e-04  (GHK estimate: 3.88e-04)

To match Gibbs (15,000 draws), rejection sampling would need
  ≈ 38,639,035 total draws  (38× our budget of 1,000,000)

Constrained posterior (Gibbs, §3 hard):
        mean      sd    q025    q975
β[0]  2.1052  0.1014  1.9060  2.3051
β[1]  1.2595  0.0478  1.2044  1.3781
β[2]  0.9215  0.0496  0.8651  1.0471
β[3]  0.5124  0.0415  0.4647  0.6167
β[4]  1.7042  0.0415  1.6566  1.8083
In [12]:
fig, axes = plt.subplots(1, k_h, figsize=(15, 3.5))
for j, ax in enumerate(axes):
    bg = out_h_gibbs['beta'][:, j]
    ax.hist(bg, bins=50, density=True, color='orange', alpha=0.7,
            label=f'Gibbs (n={n_gibbs_h:,})')
    if n_rs_h > 50:
        ax.hist(accepted_rs_h[:, j], bins=25, density=True,
                color='steelblue', alpha=0.5, label=f'Rejection (n={n_rs_h})')
    ax.axvline(beta_t_h[j], color='k', ls='--', lw=1.5, label=f'True={beta_t_h[j]}')
    if j > 0:
        ax.axvline(a_h[j - 1], color='red', ls=':', lw=1.5,
                   label=f'Bound={a_h[j-1]:.2f}')
    ax.set_title(names_h[j]); ax.legend(fontsize=6)

fig.suptitle(
    f'§3 Hard (p_{{2|1}} = {est_h:.1e}) — '
    f'Gibbs: {n_gibbs_h:,} draws   Rejection: {n_rs_h} accepted from {M_rs_h:,}',
    fontsize=10
)
plt.tight_layout(); plt.show()
No description has been provided for this image

Now the constraints bite. At $p_{2|1}\approx 4\times10^{-4}$ a million rejection draws yield only ~369 usable samples (ragged blue), while the Gibbs sampler returns 15,000 smooth ones (orange) in comparable time — every coefficient cleanly above its red lower bound, every draw feasible by construction. Same posterior, but only the Gibbs sampler can actually resolve it.

Rejection breakdown - as the feasible region shrinks, rejection sampling needs ~1/p draws per usable sample while Gibbs stays at 1.

In [13]:
# Why Gibbs is essential: rejection cost explodes as the feasible region shrinks (reuse section-3 design)
cs = np.linspace(0.0, 3.0, 16); pc = []
for c in cs:
    a_c = bbar_h[1:] + c*sd_h[1:]
    e_, _ = ghk_prob(a_c, w_h, D_h, bbar_h, Bbar_h, M=60000, seed=300)
    pc.append(max(e_, 1e-12))
pc = np.array(pc)
fig, ax = plt.subplots(1, 2, figsize=(13, 4.3))
ax[0].plot(cs, pc, 'o-', color='steelblue'); ax[0].set_yscale('log')
ax[0].set_xlabel('constraint tightness c  (lower bound = mean + c*SD on all 4 coefs)'); ax[0].set_ylabel(r'$p_{2|1}$  (accept prob)')
ax[0].set_title('Feasible-region probability shrinks geometrically')
ax[1].plot(cs, 1/pc, 'o-', color='firebrick', label='rejection: draws per usable sample (1/p)')
ax[1].axhline(1, color='black', ls='--', label='Gibbs: 1 (always accepts)')
ax[1].set_yscale('log'); ax[1].set_xlabel('constraint tightness c'); ax[1].set_ylabel('draws needed per usable sample')
ax[1].set_title('Rejection cost explodes; Gibbs stays flat'); ax[1].legend(fontsize=8)
plt.tight_layout(); plt.savefig('constrained_efficiency.png', dpi=120, bbox_inches='tight'); plt.show()
No description has been provided for this image

4. UCI Automobile Imports (1985): log-price with economic sign constraints¶

Mimics the spirit of Geweke's automobile demand example.

Data: 1985 Ward's Automotive Yearbook, 205 records, 195 usable after dropping missing values.
Model: $\log(\text{price}) = X\beta + \varepsilon$, $k = 11$ regressors (intercept + 7 log-transforms + bore, stroke, compression-ratio).
Constraints: 7 economically motivated sign restrictions.

The key finding: after controlling for total engine size and horsepower, the partial effects of bore and stroke on log-price are negative in the data (multicollinearity). Sign constraints on those two coefficients drive $p_{2|1}$ into the $10^{-4}$ range — making reject-sampling impractical and Gibbs sampling essential.

Constraint Rationale OLS estimate
$\beta_{\log\,\text{engine-size}} > 0$ larger engine → pricier +0.478 ✓
$\beta_{\log\,\text{horsepower}} > 0$ more power → pricier +0.447 ✓
$\beta_{\log\,\text{curb-weight}} > 0$ heavier → pricier (luxury proxy) +0.278 ✓
$\beta_{\log\,\text{city-mpg}} < 0$ economy cars are cheaper −0.516 ✓
$\beta_{\text{bore}} > 0$ larger bore → higher displacement −0.096 ✗
$\beta_{\text{stroke}} > 0$ longer stroke → higher displacement −0.137 ✗
$\beta_{\text{compression-ratio}} > 0$ higher compression → performance +0.026 ✓
In [14]:
import time, pandas as pd

# ── Load and clean ────────────────────────────────────────────────────────────
col_names = [
    'symboling','norm_losses','make','fuel_type','aspiration','num_doors',
    'body_style','drive_wheels','engine_location','wheel_base','length',
    'width','height','curb_weight','engine_type','num_cylinders',
    'engine_size','fuel_system','bore','stroke','compression_ratio',
    'horsepower','peak_rpm','city_mpg','highway_mpg','price'
]
data_path = 'auto_imports85.csv'   # ships alongside the notebook
df = pd.read_csv(data_path, header=None, names=col_names, na_values='?')

numeric = ['price','engine_size','horsepower','curb_weight','city_mpg',
           'wheel_base','length','width','bore','stroke','compression_ratio']
df2 = df[numeric].dropna().copy()
print(f'Rows after dropping missing: {len(df2)} / {len(df)}')

# Log-transform price and size-related regressors
for c in ['price','engine_size','horsepower','curb_weight','city_mpg',
          'wheel_base','length','width']:
    df2[f'log_{c}'] = np.log(df2[c])

# Design matrix: k=11 (intercept + 10 regressors, matching Geweke's automobile example)
xcols = ['log_engine_size','log_horsepower','log_curb_weight','log_city_mpg',
         'log_wheel_base','log_length','log_width','bore','stroke','compression_ratio']
param_names = ['intercept'] + xcols

y_auto = df2['log_price'].values
X_auto = np.column_stack([np.ones(len(df2))] + [df2[c].values for c in xcols])
n_auto, k_auto = X_auto.shape
print(f'Design matrix: n={n_auto}, k={k_auto}')
print(f'log-price range: [{y_auto.min():.3f}, {y_auto.max():.3f}]  (price ${np.exp(y_auto.min()):.0f}–${np.exp(y_auto.max()):.0f})')
Rows after dropping missing: 195 / 205
Design matrix: n=195, k=11
log-price range: [8.541, 10.723]  (price $5118–$45400)
In [15]:
# ── OLS and unconstrained posterior ──────────────────────────────────────────
beta_ols = np.linalg.lstsq(X_auto, y_auto, rcond=None)[0]
resid_ols = y_auto - X_auto @ beta_ols
sigma2_ols = float(resid_ols @ resid_ols) / (n_auto - k_auto)

bbar_auto, Bbar_auto = compute_posterior(y_auto, X_auto, sigma2_ols)
se_auto = np.sqrt(np.diag(Bbar_auto))

print('OLS estimates (= unconstrained posterior mean at sigma2_ols):')
print(f'{"Parameter":28s} {"Estimate":>9s}  {"SE":>7s}  {"t-stat":>7s}')
print('-' * 58)
for nm, b, s in zip(param_names, beta_ols, se_auto):
    print(f'{nm:28s} {b:9.4f}  {s:7.4f}  {b/s:7.2f}')
print(f'\nsigma (RMSE) = {np.sqrt(sigma2_ols):.4f}  '
      f'(R² = {1 - sigma2_ols*(n_auto-k_auto)/np.var(y_auto, ddof=1)/n_auto:.4f})')

# ── Constraint matrix: 7 economic sign restrictions ───────────────────────────
# Rows: engine_size>0, horsepower>0, curb_weight>0, city_mpg<0,
#       bore>0, stroke>0, compression_ratio>0
D_auto  = np.zeros((7, k_auto))
D_auto[0, 1] = 1;  D_auto[1, 2] = 1;  D_auto[2, 3] = 1
D_auto[3, 4] = 1   # city_mpg constraint: Dβ < 0  → upper bound = 0
D_auto[4, 8] = 1;  D_auto[5, 9] = 1;  D_auto[6, 10] = 1

a_auto = np.array([0,  0,  0, -np.inf, 0,  0,  0])
w_auto = np.array([np.inf, np.inf, np.inf, 0, np.inf, np.inf, np.inf])

print('\nConstraint satisfaction at OLS:')
clabels = ['engine_size>0','horsepower>0','curb_weight>0',
           'city_mpg<0','bore>0','stroke>0','compression>0']
Db = D_auto @ beta_ols
ok = (Db >= a_auto) & (Db <= w_auto)
for lbl, d, satisfied in zip(clabels, Db, ok):
    print(f'  {lbl:20s}: Dβ = {d:7.4f}   {"OK" if satisfied else "VIOLATED ✗"}')
OLS estimates (= unconstrained posterior mean at sigma2_ols):
Parameter                     Estimate       SE   t-stat
----------------------------------------------------------
intercept                      -3.9373   3.3295    -1.18
log_engine_size                 0.4780   0.1295     3.69
log_horsepower                  0.4470   0.1167     3.83
log_curb_weight                 0.2777   0.2655     1.05
log_city_mpg                   -0.5156   0.1859    -2.77
log_wheel_base                  0.2830   0.5203     0.54
log_length                      0.0651   0.5245     0.12
log_width                       1.7402   0.9435     1.84
bore                           -0.0956   0.0689    -1.39
stroke                         -0.1369   0.0466    -2.94
compression_ratio               0.0255   0.0053     4.82

sigma (RMSE) = 0.1789  (R² = 0.8834)

Constraint satisfaction at OLS:
  engine_size>0       : Dβ =  0.4780   OK
  horsepower>0        : Dβ =  0.4470   OK
  curb_weight>0       : Dβ =  0.2777   OK
  city_mpg<0          : Dβ = -0.5156   OK
  bore>0              : Dβ = -0.0956   VIOLATED ✗
  stroke>0            : Dβ = -0.1369   VIOLATED ✗
  compression>0       : Dβ =  0.0255   OK
In [16]:
# ── GHK: p_{2|1} and comparison to accept-reject ─────────────────────────────
print('Individual constraint probabilities (marginal, at sigma2_ols):')
clabels = ['engine_size>0','horsepower>0','curb_weight>0',
           'city_mpg<0','bore>0','stroke>0','compression>0']
for i, lbl in enumerate(clabels):
    e, _ = ghk_prob(a_auto[i:i+1], w_auto[i:i+1],
                    D_auto[i:i+1], bbar_auto, Bbar_auto, M=50000, seed=70+i)
    print(f'  P({lbl:20s}) = {e:.4f}')

t0 = time.time()
est_auto, se_auto_ghk = ghk_prob(a_auto, w_auto, D_auto,
                                  bbar_auto, Bbar_auto, M=100000, seed=77)
t_ghk = time.time() - t0
print(f'\nGHK joint p_{{2|1}} = {est_auto:.2e}  (se = {se_auto_ghk:.2e},  time = {t_ghk:.2f}s)')

# Crude accept-reject for comparison
rng_ar = np.random.default_rng(78)
t0 = time.time()
M_ar = 500_000
draws_ar = rng_ar.multivariate_normal(bbar_auto, Bbar_auto, M_ar)
Db_ar = draws_ar @ D_auto.T
acc = ((Db_ar >= a_auto) & (Db_ar <= w_auto)).all(axis=1)
p_ar = acc.mean()
se_ar = np.sqrt(p_ar*(1-p_ar)/M_ar)
t_ar = time.time() - t0
print(f'Accept-reject    p_{{2|1}} = {p_ar:.2e}  (se = {se_ar:.2e},  time = {t_ar:.2f}s,  M={M_ar:,})')
print(f'\nExpected draws needed per constrained sample: ~{1/est_auto:,.0f}')
Individual constraint probabilities (marginal, at sigma2_ols):
  P(engine_size>0       ) = 0.9999
  P(horsepower>0        ) = 0.9999
  P(curb_weight>0       ) = 0.8522
  P(city_mpg<0          ) = 0.9972
  P(bore>0              ) = 0.0825
  P(stroke>0            ) = 0.0016
  P(compression>0       ) = 1.0000

GHK joint p_{2|1} = 4.17e-04  (se = 3.83e-06,  time = 0.06s)
Accept-reject    p_{2|1} = 3.80e-04  (se = 2.76e-05,  time = 0.08s,  M=500,000)

Expected draws needed per constrained sample: ~2,400
In [17]:
# ── Constrained Gibbs sampler (sigma2 estimated jointly) ─────────────────────
t0 = time.time()
out_auto_c = constrained_gibbs(
    y_auto, X_auto, D_auto, a_auto, w_auto,
    sigma2=None, a0=3.0, s0=3.0 * sigma2_ols,
    R=20000, burn=5000, seed=80, nprint=5000
)
t_gibbs = time.time() - t0

# Unconstrained Gibbs for comparison
out_auto_u = constrained_gibbs(
    y_auto, X_auto, np.zeros((0, k_auto)), np.array([]), np.array([]),
    sigma2=None, a0=3.0, s0=3.0 * sigma2_ols,
    R=20000, burn=5000, seed=80, nprint=0
)

print(f'\nGibbs time: {t_gibbs:.1f}s  |  kept draws: {out_auto_c["beta"].shape[0]}')

# Verify constraints satisfied in all draws
Db_draws = out_auto_c['beta'] @ D_auto.T
n_viol = (~((Db_draws >= a_auto) & (Db_draws <= w_auto))).any(axis=1).sum()
print(f'Constraint violations: {n_viol} / {out_auto_c["beta"].shape[0]}')

print('\nPosterior summary — constrained:')
summ_c = posterior_summary(out_auto_c['beta'], param_names)
summ_u = posterior_summary(out_auto_u['beta'], param_names)
summ_c['mean_unconstrained'] = summ_u['mean']
print(summ_c[['mean','mean_unconstrained','sd','q025','q975']])
  Iter  5000/20000  (0.8s elapsed)
  Iter 10000/20000  (1.6s elapsed)
  Iter 15000/20000  (2.4s elapsed)
  Iter 20000/20000  (3.2s elapsed)
Done in 0.1 min  |  kept draws: 15000
Done in 0.0 min  |  kept draws: 15000

Gibbs time: 3.2s  |  kept draws: 15000
Constraint violations: 0 / 15000

Posterior summary — constrained:
                     mean  mean_unconstrained      sd    q025    q975
intercept         -3.3908             -2.8729  0.4998 -4.2485 -2.4830
log_engine_size    0.3435              0.4767  0.1303  0.1222  0.6458
log_horsepower     0.3518              0.3787  0.1137  0.1373  0.5732
log_curb_weight    0.3186              0.3168  0.0914  0.1193  0.4640
log_city_mpg      -0.6328             -0.5939  0.0757 -0.7657 -0.4886
log_wheel_base     0.1232              0.1633  0.1494 -0.1004  0.3834
log_length         0.1240              0.0486  0.2215 -0.2941  0.4774
log_width          1.7618              1.6793  0.2699  1.2973  2.3095
bore               0.0388             -0.0838  0.0329  0.0016  0.1176
stroke             0.0164             -0.1226  0.0152  0.0004  0.0570
compression_ratio  0.0241              0.0258  0.0040  0.0164  0.0319
In [18]:
# ── Compare constrained vs unconstrained posteriors ───────────────────────────
highlight = ['log_engine_size','log_horsepower','log_curb_weight',
             'log_city_mpg','bore','stroke','compression_ratio']
hi_idx    = [xcols.index(c) + 1 for c in highlight]   # +1 for intercept offset
hi_labels = ['log(engine-size)','log(horsepower)','log(curb-weight)',
             'log(city-mpg)','bore','stroke','compression-ratio']

fig, axes = plt.subplots(2, 4, figsize=(16, 7))
for ax, j, lbl in zip(axes.flat, hi_idx, hi_labels):
    bc = out_auto_c['beta'][:, j]
    bu = out_auto_u['beta'][:, j]
    all_v = np.concatenate([bc, bu])
    bins  = np.linspace(np.percentile(all_v, 0.5), np.percentile(all_v, 99.5), 60)
    ax.hist(bu, bins=bins, density=True, alpha=0.4, color='steelblue', label='Unconstrained')
    ax.hist(bc, bins=bins, density=True, alpha=0.6, color='orange',    label='Constrained')
    ax.axvline(0, color='red', ls=':', lw=1.2)
    ax.set_title(lbl, fontsize=9)
    ax.legend(fontsize=7)
axes.flat[-1].set_visible(False)   # hide unused 8th panel
fig.suptitle(
    f'§4 — UCI Autos: constrained vs unconstrained posterior\n'
    f'$p_{{2|1}}$ = {est_auto:.1e}  (bore and stroke constraints drive the gap)',
    fontsize=11
)
plt.tight_layout(); plt.show()
No description has been provided for this image

The economic-sign fit. For most regressors the constrained (orange) and unconstrained (blue) posteriors coincide, but bore and stroke are pushed from partly-negative — the OLS multicollinearity artefact — entirely above zero, restoring the theory-consistent positive signs (posterior means move from about −0.08 → +0.04 and −0.12 → +0.02). Forcing those two sign flips is precisely what drives $p_{2|1}$ down to $\approx 4\times10^{-4}$ and makes rejection sampling hopeless.

In [19]:
# ── What the constraints do to the PREDICTIONS (not just the coefficients) ────
# Posterior-mean fitted values under each posterior, on the 195 real cars.
pred_u_log = X_auto @ out_auto_u['beta'].mean(0)
pred_c_log = X_auto @ out_auto_c['beta'].mean(0)
act_log    = y_auto
act_d, pu_d, pc_d = np.exp(act_log), np.exp(pred_u_log), np.exp(pred_c_log)

def fitstats(pred_log):
    e = act_log - pred_log
    r2 = 1 - (e @ e) / ((act_log - act_log.mean()) @ (act_log - act_log.mean()))
    return r2, np.sqrt(np.mean(e**2)), np.median(np.abs(np.exp(pred_log) - act_d) / act_d) * 100

r2u, rmseu, mapeu = fitstats(pred_u_log)
r2c, rmsec, mapec = fitstats(pred_c_log)

GREY = '#cbd5e0'
o = np.argsort(act_log)                     # cars ordered cheapest -> dearest
fig, ax = plt.subplots(1, 3, figsize=(16.5, 4.6))

# (1) actual vs both fitted, cars ordered by price — actual continuous grey, fits dashed on top
ax[0].plot(np.arange(len(o)), act_d[o], color=GREY, lw=1.8, zorder=1, label='Actual price')
ax[0].plot(np.arange(len(o)), pu_d[o], color='steelblue', lw=1.1, ls='--', zorder=3,
           label=f'Unconstrained fit  (R$^2$={r2u:.3f})')
ax[0].plot(np.arange(len(o)), pc_d[o], color='orange', lw=1.1, ls='--', zorder=3,
           label=f'Constrained fit  (R$^2$={r2c:.3f})')
ax[0].set_yscale('log'); ax[0].set_xlabel('the 195 cars, ordered by actual price')
ax[0].set_yticks([5000, 7500, 10000, 15000, 25000, 40000])
ax[0].set_yticklabels([f'${v//1000}k' for v in [5000, 7500, 10000, 15000, 25000, 40000]])
ax[0].minorticks_off()
ax[0].set_ylabel('price (USD, log scale)'); ax[0].legend(fontsize=8, loc='upper left')
ax[0].set_title('Fitted vs actual price', fontsize=10)

# (2) predicted vs actual, both models, 45-degree line
lim = [act_d.min() * .85, act_d.max() * 1.15]
ax[1].plot(lim, lim, color='k', lw=.8, ls=':', zorder=1)
ax[1].scatter(act_d, pu_d, s=16, color='steelblue', alpha=.55, lw=0, label='Unconstrained')
ax[1].scatter(act_d, pc_d, s=16, color='orange',    alpha=.55, lw=0, label='Constrained')
ax[1].set_xscale('log'); ax[1].set_yscale('log'); ax[1].set_xlim(lim); ax[1].set_ylim(lim)
ax[1].set_xlabel('actual price (USD)'); ax[1].set_ylabel('predicted price (USD)')
ax[1].legend(fontsize=8, loc='upper left'); ax[1].set_title('Predicted vs actual', fontsize=10)

# (3) where the constraint actually moves a prediction
gap = (pc_d - pu_d) / pu_d * 100
ax[2].axhline(0, color='k', lw=.8)
ax[2].scatter(act_d, gap, s=16, color='firebrick', alpha=.6, lw=0)
ax[2].set_xscale('log'); ax[2].set_xlabel('actual price (USD)')
ax[2].set_ylabel('constrained minus unconstrained (%)')
ax[2].set_title('Effect of the constraints on each car', fontsize=10)

fig.suptitle('§4 — UCI Autos: imposing the seven sign constraints costs almost nothing in fit', y=1.02)
plt.tight_layout(); plt.show()

print(f'{"":22s} {"R2":>7s}  {"RMSE (log)":>11s}  {"median abs err":>15s}')
print('-' * 60)
print(f'{"Unconstrained":22s} {r2u:7.4f}  {rmseu:11.4f}  {mapeu:14.2f}%')
print(f'{"Constrained":22s} {r2c:7.4f}  {rmsec:11.4f}  {mapec:14.2f}%')
print(f'\nLargest change to any single car: {np.max(np.abs(gap)):.2f}%   '
      f'median change {np.median(np.abs(gap)):.2f}%')
print(f'Cars whose prediction moves more than 1%: {(np.abs(gap) > 1).sum()} / {len(gap)}')
print(f'\nThe two fitted paths track the data, and each other, closely: the seven sign restrictions')
print(f'cost {abs(r2c - r2u):.4f} of R2 and move the median pricing error from {mapeu:.1f}% to {mapec:.1f}%. They are not,')
print(f'however, the same model. The right panel shows {(np.abs(gap) > 1).sum()} of the {len(gap)} cars having their predicted')
print(f'price shifted by more than 1%, with a median shift of {np.median(np.abs(gap)):.1f}% and one car moving {np.max(np.abs(gap)):.0f}%.')
print(f'So the constraints are neither free nor decisive at the level of fit -- and fit is not what')
print(f'they are for. The unconstrained regression reproduces these 195 prices marginally better')
print(f'while implying that a wider bore makes a car cheaper. The constrained one reproduces them')
print(f'very nearly as well and does not. What is bought is the ability to USE the coefficients --')
print(f'to answer what happens if we widen the bore -- which the unconstrained fit cannot support')
print(f'however well it fits the particular sample it was fitted to.')
No description has been provided for this image
                            R2   RMSE (log)   median abs err
------------------------------------------------------------
Unconstrained           0.8825       0.1740           10.70%
Constrained             0.8749       0.1795           11.22%

Largest change to any single car: 21.99%   median change 2.72%
Cars whose prediction moves more than 1%: 167 / 195

The two fitted paths track the data, and each other, closely: the seven sign restrictions
cost 0.0076 of R2 and move the median pricing error from 10.7% to 11.2%. They are not,
however, the same model. The right panel shows 167 of the 195 cars having their predicted
price shifted by more than 1%, with a median shift of 2.7% and one car moving 22%.
So the constraints are neither free nor decisive at the level of fit -- and fit is not what
they are for. The unconstrained regression reproduces these 195 prices marginally better
while implying that a wider bore makes a car cheaper. The constrained one reproduces them
very nearly as well and does not. What is bought is the ability to USE the coefficients --
to answer what happens if we widen the bore -- which the unconstrained fit cannot support
however well it fits the particular sample it was fitted to.

5. Coverage validation¶

Verify that 95% credible intervals from the constrained Gibbs sampler achieve correct frequentist coverage when the true parameter is inside the feasible region.

The Section 2 design has $\beta^\star_2 = -0.18 < 0$, which violates $\beta_2 > 0$ — the constrained CrI lives entirely above zero and can never cover the true value. That is by design for the comparison demo, not suitable for a coverage check.

Here we use $\beta^\star = [1.5,\ 0.30,\ 0.20,\ 0.80]$ (both constraints satisfied) over 30 replications.

Expected coverage: $0.95 \pm 2\sqrt{0.95 \times 0.05 / 30} \approx 0.95 \pm 0.08$.

In [20]:
# ── Simulation study: 95% CrI coverage over 30 replications ──────────────────
# True β must lie INSIDE the constraint region (β[1]>0, β[2]>0) for coverage
# to be well-defined. We use a separate β_cov that satisfies all constraints.
# The §2 β_true was chosen to create a binding constraint for the comparison
# demo; it is not suitable here because β_true[2] = -0.18 < 0.

beta_cov_true = np.array([1.5, 0.30, 0.20, 0.80])   # both β[1],β[2] > 0

N_SIM = 30
R_sim, burn_sim = 8000, 2000
covered = np.zeros((N_SIM, k_e), dtype=bool)
lo_all = np.zeros((N_SIM, k_e)); hi_all = np.zeros((N_SIM, k_e))

print(f'Running {N_SIM} coverage replications (k={k_e}, n={n_e}, sigma2={sigma2_e})')
print(f'True β: {beta_cov_true}  (all constraints satisfied)')
t0 = time.time()
for s in range(N_SIM):
    rng_s = np.random.default_rng(1000 + s)
    X_s   = np.column_stack([np.ones(n_e), rng_s.standard_normal((n_e, k_e - 1))])
    y_s   = X_s @ beta_cov_true + np.sqrt(sigma2_e) * rng_s.standard_normal(n_e)

    out_s = constrained_gibbs(y_s, X_s, D_e, a_e, w_e,
                               sigma2=sigma2_e, R=R_sim, burn=burn_sim,
                               seed=2000 + s, nprint=0)
    lo_s = np.quantile(out_s['beta'], 0.025, axis=0)
    hi_s = np.quantile(out_s['beta'], 0.975, axis=0)
    covered[s] = (beta_cov_true >= lo_s) & (beta_cov_true <= hi_s)
    lo_all[s] = lo_s; hi_all[s] = hi_s
    if (s + 1) % 10 == 0:
        print(f'  {s+1}/{N_SIM}  ({time.time()-t0:.1f}s)')

cov_rate = covered.mean(axis=0)
se_cov   = np.sqrt(cov_rate * (1 - cov_rate) / N_SIM)
print('\n95% CrI coverage rates (target: 0.95):')
for nm, cr, se in zip(names_e, cov_rate, se_cov):
    bar = '█' * int(round(cr * 20))
    print(f'  {nm}:  {cr:.2f}  ±{2*se:.2f}  {bar}')
print(f'\nOverall: {covered.mean():.3f}  (expected 0.95)')
Running 30 coverage replications (k=4, n=50, sigma2=2.0)
True β: [1.5 0.3 0.2 0.8]  (all constraints satisfied)
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
  10/30  (4.0s)
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
  20/30  (8.0s)
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
Done in 0.0 min  |  kept draws: 6000
  30/30  (12.0s)

95% CrI coverage rates (target: 0.95):
  β[0]:  0.97  ±0.07  ███████████████████
  β[1]:  1.00  ±0.00  ████████████████████
  β[2]:  1.00  ±0.00  ████████████████████
  β[3]:  0.87  ±0.12  █████████████████

Overall: 0.958  (expected 0.95)

Coverage caterpillar - the 30 replications 95% CrIs for b[1]; green intervals cover the true value, red miss.

In [21]:
# Coverage caterpillar: each replication's 95% CrI for b[1] vs the true value
j = 1; truej = beta_cov_true[j]; order = np.argsort(lo_all[:, j])
fig, ax = plt.subplots(figsize=(8, 6))
for rank, s in enumerate(order):
    c = 'seagreen' if covered[s, j] else 'firebrick'
    ax.plot([lo_all[s, j], hi_all[s, j]], [rank, rank], color=c, lw=1.6)
ax.axvline(truej, color='black', ls='--', lw=1.5, label=f'true b[1] = {truej}')
ax.axvline(0, color='red', ls=':', lw=1.2, label='constraint b[1] > 0')
ax.set_xlabel('b[1]  95% credible interval'); ax.set_ylabel('replication (sorted by lower bound)')
ax.set_title(f'{covered[:,j].sum()}/{N_SIM} intervals cover the truth ({100*covered[:,j].mean():.0f}%); green=cover, red=miss')
ax.legend(fontsize=8, loc='lower right')
plt.tight_layout(); plt.savefig('constrained_coverage.png', dpi=120, bbox_inches='tight'); plt.show()
No description has been provided for this image

Summary¶

Method What it computes When to use
GHK simulator $p_{2\|1} = P(a \le D\beta \le w \mid \text{data})$ Model selection, Bayes factors, posterior constraint probability
Rejection sampling Constrained posterior draws Only when $p_{2\|1} \gtrsim 0.05$; cheap and exact
Constrained Gibbs Constrained posterior draws Always; works even when $p_{2\|1} \approx 0$

Key results from this notebook:

  • Section 1 GHK agrees with the analytical bivariate Normal CDF to $< 2$ SE, confirming the simulator.
  • Section 2 At $p_{2\|1} \approx 0.3$, rejection sampling and Gibbs posteriors agree to $< 0.01$ posterior SDs.
  • Section 3 At $p_{2\|1} \approx 10^{-4}$, 1 M rejection draws yield $\sim$175 accepted; Gibbs yields 15,000 in the same time — a $\sim$85× efficiency gain.
  • Section 4 UCI Automobile data: bore and stroke OLS estimates violate economic sign constraints due to multicollinearity. Gibbs shifts their posteriors entirely above zero. $p_{2\|1} \approx 4 \times 10^{-4}$ makes rejection sampling completely impractical.
  • Section 5 95% CrI coverage ≈ 0.95 across 30 replications, confirming correct Bayesian calibration.