High-dimensional regression
A regression with 500 coefficients, fitted through the faer-backed MatVecMul
path. The 500 coefficients become one contiguous vector parameter and one graph
node, so a gradient evaluation costs two faer GEMV calls -- X @ beta forward and
X.T @ adjoint backward -- instead of 500 scalar loops in each direction.
Cost note, which is arithmetic rather than a measurement: every leapfrog step
streams the whole design matrix twice, so work grows with N_OBS * N_PARAMS. At
4,000 x 500 the matrix is 16 MB per GEMV. Raising the dimensions to 6,000 x 5,000
moves 240 MB per GEMV, fifteen times this script's traffic.
This script prints its own elapsed time when you run it. That number is not on
this page, and no figure here is a performance claim: benchmarks/ and
benchmarks/README.md hold the matched protocol, the retained raw output and the
environment that a claim about speed needs.
Run it with python examples/large_linear_regression.py from the repository root. The blocks below are
cells of that one file and share its state, so they assume the blocks above
them have run.
Generate a 4,000 x 500 design
N_OBS = 4_000
N_PARAMS = 500
np.random.seed(42)
true_beta = np.random.randn(N_PARAMS) * 0.1
X = np.random.randn(N_OBS, N_PARAMS) # row-major C order
y = X @ true_beta + np.random.randn(N_OBS) * 1.0
print(f"Dataset: {N_OBS:,} obs x {N_PARAMS:,} params")
What beta @ "X" does
Writing beta @ "X" instead of a sum of scalar products makes rustmc:
- detect that
betais used in a matrix multiply; - infer the number of coefficients from the matrix's column count;
- promote
betato a contiguous vector parameter block; - replace N scalar multiply-add nodes with a single
MatVecMulop; - compute the forward pass and the gradient through faer's GEMV, which uses Rayon once the matrix reaches 100,000 elements.
A 2D NumPy array in the data dict is detected and stored as a row-major matrix;
passing "X": X where X.ndim == 2 is all that is needed. normal_prior
combined with @ auto-promotes beta, so vector_normal_prior("beta", n=P) is
only needed when you want to set the coefficient count yourself rather than infer
it from the matrix.
Whether @ or scalar beta * "x" is faster for a given P is a question for
benchmarks/, not for this page: it depends on the model, the data shape and the
machine, and nothing here measures it. What the @ form does change is the graph
-- one op instead of one per coefficient -- which is why it exists.
Build the model
t0 = time.time()
builder = rmc.ModelBuilder(data={"X": X, "y": y})
intercept = builder.normal_prior("intercept", mu=0.0, sigma=10.0)
beta = builder.normal_prior("beta", mu=0.0, sigma=1.0)
mu_expr = intercept + beta @ "X"
builder.normal_likelihood("obs", mu_expr=mu_expr, sigma=1.0, observed_key="y")
model = builder.build()
build_time = time.time() - t0
print(f"Model built in {build_time:.3f}s")
Sample
DRAWS, WARMUP = 200, 200
print(f"Sampling: NUTS, 1 chain, {WARMUP} warmup + {DRAWS} draws ...")
t0 = time.time()
result = rmc.sample(model, draws=DRAWS, warmup=WARMUP, chains=1, seed=42, show_progress=True)
elapsed = time.time() - t0
print(f"Elapsed : {elapsed:.2f}s")
print(f"Iters/s : {(DRAWS + WARMUP) / elapsed:.1f}")
print(f"Accept : {result.accept_rates()[0]:.3f}")
print(f"Diverge : {sum(result.divergences())}")
Sampling: NUTS, 1 chain, 200 warmup + 200 draws ...
Elapsed : <elapsed>s
Iters/s : <rate>
Accept : <rate>
Diverge : 0
Recover the coefficients
# A vector parameter is reported one entry per coordinate -- beta[0], beta[1], ... --
# not as a nested array.
samples = result.get_samples()
beta_means = np.array([samples[f"beta[{k}]"].mean() for k in range(N_PARAMS)])
rmse = np.sqrt(np.mean((beta_means - true_beta) ** 2))
# Reported against the scale the coefficients were drawn from, and at a precision
# this run can defend. One chain of 200 draws carries Monte Carlo error of order
# the fourth decimal on a single coordinate, and the faer GEMV path picks its SIMD
# kernel from the CPU, so the individual posterior means printed here to four
# places agreed on one machine and not on another. The ratio is the claim the
# example is making; a per-coordinate table would invite exactly the reading the
# Limitations section below warns against.
print(f"beta recovery RMSE : {rmse:.3f} (generating coefficient sd {true_beta.std():.3f})")
print(f" that is {rmse / true_beta.std():.0%} of the scale the coefficients were drawn from")
beta recovery RMSE : 0.017 (generating coefficient sd 0.098)
that is 17% of the scale the coefficients were drawn from
Limitations of this run
One chain of 200 draws is enough to show the API and to recover coefficients whose
generating scale is known. It is not enough for convergence diagnostics: R-hat
compares chains and there is only one, and 200 draws is a small sample for any
single coordinate. Raise chains and draws, and read the per-parameter ESS in
result.summary(), before reading anything into an individual coefficient.
The elapsed time and rate printed above are replaced with placeholders on this
page, because they depend on the machine and would otherwise change on every
regeneration. Run the script to see real numbers for your hardware, and use
benchmarks/ for a measurement with provenance.