Ported from bayesian_regression.py in the USACE-RMC Numerics-Python-Examples repository (0BSD licensed). The upstream script drives the C# Numerics.dll through pythonnet, handing the DEMCzs sampler a list of hand-picked Uniform priors and a Python log-likelihood callback. That callback-likelihood pattern is deliberately not exposed by corehydror, so this version recasts the same regression through arimax_analysis (details below) and the reproduction is statistical rather than exact. The synthetic data, however, are bit-identical to the upstream script’s, and the Python version of this example uses the same core and prints the same numbers.
What you’ll learn
How to regenerate the upstream script’s synthetic data exactly (same seeded Mersenne Twister).
How to express a Bayesian linear regression as an ARIMAX(0,0,0) model with a linear time trend.
What the returned curves are: the MAP fitted line and a posterior predictive band.
Synthetic data
The upstream script simulates a straight line with Normal noise: x = linspace(0, 10, 80) and y = a + b*x + Normal(0, sigma) with a = 2.0, b = 1.4, sigma = 1.2, drawing the noise with seed 123. The port keeps the C# Mersenne Twister bit-exact, so dist_random with the same seed reproduces the upstream noise vector to the last bit. We write x as 10*t/79 for the time index t = 0..79, which is the same vector as the upstream linspace and makes the mapping used below explicit.
One quirk worth knowing about, since the upstream run sees the identical draw: this particular seeded noise vector runs light. Its sample standard deviation is 0.904, well under the nominal 1.2, so any faithful posterior for sigma concentrates near 0.9.
library(corehydror)n <-80true_a <-2.0true_b <-1.4true_sigma <-1.2t_idx <-0:(n -1) # time index 0..79x <-10* t_idx /79# identical to the upstream linspace(0, 10, 80)noise <-dist_random(distribution("Normal", c(0, true_sigma)), n, seed =123)y <- true_a + true_b * x + noisecat("first observations:", round(y[1:4], 8), "\n")
first observations: 2.617127 2.851662 1.676792 2.315323
The upstream script builds a C# List of three priors (Uniform(-10, 10) for a, Uniform(0, 5) for b, Uniform(0.1, 5) for sigma) and a Python callback that sums Normal(0, sigma).LogPDF(residual) over the data. Neither custom priors nor likelihood callbacks are part of the public API here. But regression on the time index is a built-in model: arimax_analysis with orders p = d = q = 0, an intercept, and trend = "Linear" fits
which is Bayesian simple linear regression, sampled with the same DEMCzs sampler the upstream uses. Because x = 10t/79 is an affine map of the time index, the upstream parameters translate directly:
intercept: \(a = \mu\), so the true value is 2.0;
slope: \(\gamma = b \cdot 10/79\), so the true trend per time step is \(1.4 \cdot 10/79 = 0.17722\), and the fitted slope maps back as \(b = \gamma \cdot 79/10\);
sigma is the residual standard deviation in both parameterizations.
Two honest differences from the upstream run. First, the priors are the model’s own data-scaled uniforms (order-of-magnitude bounds built around a least-squares initialization), not the upstream’s hand-picked ones, so posterior numbers can only be compared statistically. Second, the runtime: the upstream script takes about five minutes because every likelihood evaluation crosses back into Python; here the likelihood is compiled, and the modest settings below finish in about a second.
parameters in the returned list is the posterior mode (MAP) vector, in model order: intercept \(\mu\), trend \(\gamma\), then \(\sigma\) last.
fit <-arimax_analysis( y,order_p =0L, order_d =0L, order_q =0L, # no AR, differencing, or MA termstrend ="Linear", # deterministic trend gamma * tinclude_intercept =TRUE, # intercept mutraining_time_steps = n, # fit on all 80 pointsforecasting_time_steps =0L,sampler ="DEMCzs", # the sampler the upstream script usesiterations =2000L,output_length =5000L,credible_level =0.90,seed =45678L)mu_map <- fit$parameters[1]gamma_map <- fit$parameters[2]sigma_map <- fit$parameters[3]b_map <- gamma_map *79/10# slope per time step -> slope per unit xsummary_df <-data.frame(parameter =c("intercept a", "slope b", "slope per step gamma", "sigma"),truth =c(true_a, true_b, true_b *10/79, true_sigma),MAP =c(mu_map, b_map, gamma_map, sigma_map))print(summary_df, row.names =FALSE)
parameter truth MAP
intercept a 2.0000000 2.0436765
slope b 1.4000000 1.3916442
slope per step gamma 0.1772152 0.1761575
sigma 1.2000000 0.9184538
cat(sprintf("\nrmse: %.4f\n", fit$rmse))
rmse: 0.8977
All three parameters land close to the generating values, with sigma tracking the realized noise level (0.904) rather than the nominal 1.2, exactly as the upstream run does with the same data.
Fitted line and band
What the returned curves are, since they differ from the upstream plot:
mode_curve is the deterministic fitted line \(\mu + \gamma t\) evaluated at the MAP parameters.
mean_curve, lower_ci, and upper_ci come from a posterior predictive ensemble: each posterior draw simulates a full series including observation noise. The 90% band is therefore a predictive band for observations, wider than the mean-function credible band the upstream script plots.
The upstream 2x2 figure also shows posterior histograms of a, b, and sigma; the analysis returns curves and MAP values rather than raw chains, so those two panels cannot be reproduced here.
true_line <- true_a + true_b * xop <-par(mfrow =c(1, 2), mar =c(4, 4, 2, 1))plot(x, y,pch =16, cex =0.6, col ="#8c8c7a",ylim =range(fit$lower_ci, fit$upper_ci, y),xlab ="x", ylab ="y", main ="Bayesian regression fit")polygon(c(x, rev(x)), c(fit$lower_ci, rev(fit$upper_ci)),col =adjustcolor("#5b7a8c", alpha.f =0.25), border =NA)points(x, y, pch =16, cex =0.6, col ="#8c8c7a")lines(x, true_line, col ="#b06a3b", lty =2, lwd =1.5)lines(x, fit$mode_curve, col ="#6b7f3f", lwd =2)legend("topleft",legend =c("Observed", "True line", "MAP fit", "90% predictive band"),col =c("#8c8c7a", "#b06a3b", "#6b7f3f", adjustcolor("#5b7a8c", alpha.f =0.25)),pch =c(16, NA, NA, 15), lty =c(NA, 2, 1, NA), lwd =c(NA, 1.5, 2, NA),bty ="n", cex =0.7)residuals <- y - fit$mode_curveplot(fit$mode_curve, residuals,pch =16, cex =0.6, col ="#6b7f3f",xlab ="Fitted", ylab ="Residual", main ="Residuals vs fitted")abline(h =0, col ="#b06a3b", lty =2, lwd =1.5)
observations inside the 90% predictive band: 0.8875
cat(sprintf("ordinates where the band covers the true line: %d of %d\n", sum(covers_true), n))
ordinates where the band covers the true line: 80 of 80
Reproduction check
The upstream file is a script with no committed outputs, so there are no upstream posterior literals to compare against; the reference values are the true generating parameters. The priors and the parameterization deliberately differ (data-scaled uniform priors, time-index trend), so bit-comparison with the upstream posterior is impossible and the parameter checks are statistical. The exact tier covers what is exactly checkable: the seeded synthetic data (the same Mersenne Twister stream the upstream draws) and cross-language identity of the whole analysis (the Python twin asserts the same MAP literals from the same seed).
Quantity
Upstream C#
This port
Status
Seeded data y[1] (seed 123)
same MT stream
2.61712675977869
exact
Seeded data y[80]
same MT stream
15.89237584375904
exact
Intercept a
2.0 (true)
MAP 2.0437
statistical
Slope b
1.4 (true)
MAP 1.3916
statistical
sigma
1.2 nominal, 0.9037 realized
MAP 0.9185
statistical
90% band covers the true line
-
80 of 80 ordinates
statistical
The chunk below fails the render if any value drifts.
# Upstream: examples/bayesian_regression.py (a script with no committed outputs;# the reference values are the true generating parameters, not upstream posteriors).# Exact tier: seeded data and the fit itself, identical across R, Python, and the# C# library. The values are bit-exact (the fixture suite enforces that); the# comparisons carry a 1e-15 relative tolerance only because R's decimal parser can# land one ulp away from the written literal.stopifnot(abs(y[1] /2.6171267597786905-1) <1e-15,abs(y[n] /15.892375843759043-1) <1e-15,abs(mu_map /2.043676501381344-1) <1e-15,abs(gamma_map /0.1761574955229039-1) <1e-15)# Statistical tier: recovered parameters near the truth. Priors and parameterization# deliberately differ from the upstream script, so only closeness can be asserted.stopifnot(abs(mu_map - true_a) <0.5,abs(b_map - true_b) <0.1, sigma_map >0.7, sigma_map <1.2# tracks the realized noise sd, 0.9037)# Statistical tier: the 90% predictive band covers the true line everywhere and# most observations.stopifnot(all(covers_true),mean(inside) >0.8)cat("All reproduction checks passed.\n")