12. Transformations and regression¶
Sometimes its best to transform the data from its raw form before applying regression. Earlier looked at regression to the mean.
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
import seaborn as sns
import arviz as az
import pymc as pm
import statsmodels.api as sm
import statsmodels.formula.api as smf
from statsmodels.robust.scale import mad
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/arviz/__init__.py:50: FutureWarning: ArviZ is undergoing a major refactor to improve flexibility and extensibility while maintaining a user-friendly interface. Some upcoming changes may be backward incompatible. For details and migration guidance, visit: https://python.arviz.org/en/latest/user_guide/migration_guide.html warn(
def fit_and_plot_lm(data, predictors, outcome, add_constant=True, show_plot=True, scatter_kws=None, line_kws=None):
"""
Fit a linear model using statsmodels, print summary, plot, and show formula.
Args:
data: pandas DataFrame
predictors: list of predictor column names (str)
outcome: outcome column name (str)
add_constant: whether to add intercept (default True)
show_plot: whether to plot (default True)
scatter_kws: dict, kwargs for scatterplot
line_kws: dict, kwargs for regression line
"""
X = data[predictors].copy()
if add_constant:
X = sm.add_constant(X, prepend=False)
y = data[outcome]
model = sm.OLS(y, X)
results = model.fit()
print(results.summary())
# Print formula
params = results.params
formula = f"{outcome} = " + " + ".join([f"{params[name]:.2f}*{name}" for name in predictors])
if add_constant:
formula = f"{outcome} = {params['const']:.2f} + " + " + ".join([f"{params[name]:.2f}*{name}" for name in predictors])
print("Formula:", formula)
# Print residual standard deviation and its uncertainty
sigma = np.sqrt(results.mse_resid)
sigma_se = sigma / np.sqrt(2 * results.df_resid)
print(f"Residual std dev (σ): {sigma:.2f} ± {sigma_se:.2f}")
# Print median absolute deviation of residuals
print("MAD of residuals:", round(mad(results.resid), 2))
# Plot if only one predictor
if show_plot and len(predictors) == 1:
x_name = predictors[0]
ax = sns.scatterplot(data=data, x=x_name, y=outcome, **(scatter_kws or {}))
x_vals = np.linspace(data[x_name].min(), data[x_name].max(), 100)
y_vals = params['const'] + params[x_name] * x_vals if add_constant else params[x_name] * x_vals
ax.plot(x_vals, y_vals, color='red', **(line_kws or {}))
ax.set_title('Linear Regression Fit')
plt.show()
def fit_and_plot_bayes(data, predictors, outcome,
intercept_mu=0, intercept_sigma=50,
slope_mu=0, slope_sigma=50,
sigma_sigma=50,
samples=2000, tune=1000, hdi_prob=0.95,
show_trace=True, show_forest=True,
show_posterior=True, show_regression=True,
show_residuals=False,
n_regression_lines=100):
"""
Fit a Bayesian linear regression with PyMC and produce diagnostic plots.
The model is:
y ~ Normal(mu, sigma)
mu = intercept + sum_k (slope_k * x_k)
intercept ~ Normal(intercept_mu, intercept_sigma)
slope_k ~ Normal(slope_mu, slope_sigma) for each predictor k
sigma ~ HalfNormal(sigma_sigma)
Supports one or multiple predictors. After sampling, prints a posterior
summary and the mean regression formula, then optionally shows trace,
forest, posterior density, regression-line, and residual plots.
Args:
data (pd.DataFrame): DataFrame containing predictor and outcome columns.
predictors (str | list[str]): Predictor column name(s). A single string
is treated as a one-predictor model; a list allows multiple predictors.
outcome (str): Outcome column name.
intercept_mu (float): Prior mean for the intercept Normal prior.
intercept_sigma (float): Prior std dev for the intercept Normal prior.
slope_mu (float): Prior mean for each slope's Normal prior.
slope_sigma (float): Prior std dev for each slope's Normal prior.
sigma_sigma (float): Scale for the HalfNormal prior on residual noise.
samples (int): Number of posterior draws per chain.
tune (int): Number of NUTS tuning (warm-up) steps.
hdi_prob (float): Probability mass used for HDI summaries and plots.
show_trace (bool): If True, plot MCMC traces and marginal densities.
show_forest (bool): If True, plot a forest plot of posterior means + HDIs.
show_posterior (bool): If True, plot posterior density for each parameter.
show_regression (bool): If True, plot data with overlaid posterior
regression lines (one subplot per predictor; other predictors held at mean).
show_residuals (bool): If True, plot residuals (y - y_hat using posterior
mean coefficients) vs fitted values and vs each predictor.
n_regression_lines (int): Number of posterior draws to overlay on the
regression plot when show_regression is True.
Returns:
arviz.InferenceData: The PyMC trace (posterior samples and sampling stats).
"""
if isinstance(predictors, str): # Allow a single predictor to be passed as a bare string.
predictors = [predictors] # Normalise to a list so downstream code can iterate uniformly.
y = data[outcome].values # Extract outcome values as a NumPy array for PyMC.
with pm.Model() as model: # Open a PyMC model context; variables below are registered to it.
intercept = pm.Normal("intercept", mu=intercept_mu, sigma=intercept_sigma) # Normal prior on the intercept.
slopes = [] # Collect slope RVs (not strictly needed, but handy if you want to inspect them).
mu = intercept # Start building the linear predictor; will accumulate slope*x terms below.
for pred in predictors: # Loop over each predictor column name.
s = pm.Normal(f"slope_{pred}", mu=slope_mu, sigma=slope_sigma) # Normal prior on this predictor's slope.
slopes.append(s) # Keep a reference to the slope RV.
mu = mu + s * data[pred].values # Add this predictor's contribution to the linear predictor.
sigma = pm.HalfNormal("sigma", sigma=sigma_sigma) # HalfNormal prior on residual std dev (must be positive).
likelihood = pm.Normal("y", mu=mu, sigma=sigma, observed=y) # Likelihood: observed y conditional on mu and sigma.
trace = pm.sample(samples, tune=tune, idata_kwargs={"log_likelihood": True}) # Run NUTS; store log-likelihood for LOO.
summary = pm.summary(trace, hdi_prob=hdi_prob) # Compute posterior summary stats (mean, sd, HDI, r_hat, ess).
print(summary) # Print the summary table for the user.
# Build and print the mean-posterior regression formula for quick inspection.
posterior = trace.posterior # Shortcut to the posterior group of the InferenceData.
intercept_mean = posterior["intercept"].values.flatten().mean() # Posterior mean of the intercept (flatten chains+draws).
formula = f"{outcome} = {intercept_mean:.2f}" # Start the formula string with the intercept.
for pred in predictors: # Append a "+ slope*predictor" term for each predictor.
slope_mean = posterior[f"slope_{pred}"].values.flatten().mean() # Posterior mean slope for this predictor.
formula += f" + {slope_mean:.2f}*{pred}" # Append this term to the formula string.
print(f"\nRegression formula: {formula}") # Print the assembled formula.
sigma_draws = posterior["sigma"].values.flatten()
print(f"Residual std dev (σ): {sigma_draws.mean():.2f} ± {sigma_draws.std():.2f}")
intercept_draws = posterior["intercept"].values.flatten()
X_mat = data[predictors].values
slope_mat = np.column_stack([posterior[f"slope_{pred}"].values.flatten() for pred in predictors])
y_hat_all = intercept_draws[:, None] + slope_mat @ X_mat.T
bayes_r2 = az.r2_score(y, y_hat_all)
print(f"Bayesian R²: {bayes_r2['r2']:.3f} ± {bayes_r2['r2_std']:.3f}")
loo = az.loo(trace, pointwise=True) # PSIS-LOO; pointwise=True gives per-obs Pareto-k diagnostics.
n_obs = len(y)
print(f"LOO-ELPD: {loo.elpd_loo:.2f} ± {loo.se:.2f} (p_loo={loo.p_loo:.1f}) <- should be lower than number of parameters ({len(predictors) + 2}) for reliable estimates")
print("When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE.")
print(f"LOO log score (per obs): {loo.elpd_loo / n_obs:.3f} ± {loo.se / n_obs:.3f}")
n_bad_k = int((loo.pareto_k.values > 0.7).sum())
if n_bad_k > 0:
print(f" Warning: {n_bad_k} observations with Pareto-k > 0.7 (unreliable LOO estimates)")
if loo.p_loo >= len(predictors) + 2:
print(f" *** WARNING: p_loo ({loo.p_loo:.1f}) >= number of parameters ({len(predictors) + 2}) — LOO estimates may be unreliable ***")
# LOO R²: importance-sample the leave-one-out predicted means from the posterior draws
log_like = trace.log_likelihood["y"].values # (chains, draws, obs)
ll_2d = log_like.reshape(-1, n_obs) # (total_draws, obs)
log_w = -ll_2d # log IS weight = -log p(y_i | theta)
log_w -= log_w.max(axis=0, keepdims=True) # numerical stability before exp
w = np.exp(log_w)
w /= w.sum(axis=0, keepdims=True) # normalize per observation
y_hat_loo = (w * y_hat_all).sum(axis=0) # weighted LOO predicted means, shape (obs,)
loo_r2 = 1 - np.sum((y - y_hat_loo) ** 2) / np.sum((y - y.mean()) ** 2)
print(f"LOO R²: {loo_r2:.3f}")
if loo_r2 < bayes_r2['r2']:
print(f" *** WARNING: LOO R² ({loo_r2:.3f}) < Bayesian R² ({bayes_r2['r2']:.3f}) — model may be overfitting ***")
if show_trace: # Optional: MCMC trace diagnostics.
az.plot_trace(trace) # Plot chain traces and marginal densities per parameter.
plt.tight_layout() # Tidy up subplot spacing.
plt.show() # Render the figure.
if show_forest: # Optional: forest plot of parameter posteriors.
az.plot_forest(trace, hdi_prob=hdi_prob) # Show posterior means and HDIs for all parameters.
plt.show() # Render the figure.
if show_posterior: # Optional: posterior density plots.
az.plot_posterior(trace, hdi_prob=hdi_prob) # Density plot per parameter, annotated with HDI.
plt.show() # Render the figure.
if show_regression: # Optional: overlay posterior regression lines on data.
a_samples = posterior["intercept"].values.flatten() # Flattened posterior draws of the intercept.
slope_samples = {pred: posterior[f"slope_{pred}"].values.flatten() for pred in predictors} # Flattened slope draws per predictor.
idx = np.random.choice(len(a_samples), n_regression_lines, replace=False) # Random subset of draw indices to plot.
fig, axes = plt.subplots(1, len(predictors), figsize=(6 * len(predictors), 5)) # One subplot per predictor.
if len(predictors) == 1: # plt.subplots returns a single Axes when ncols=1; wrap it for uniform handling.
axes = [axes] # Make axes iterable in the single-predictor case.
for ax, pred in zip(axes, predictors): # Plot each predictor's partial regression view.
x = data[pred].values # Observed values of this predictor.
ax.scatter(x, y, alpha=0.5) # Scatter of y vs this predictor.
x_grid = np.linspace(x.min(), x.max(), 100) # Dense grid across this predictor for smooth lines.
# For each posterior draw, compute the contribution of *other* predictors
# held at their sample means so the plotted line isolates this predictor's effect.
other_contribution = np.zeros(len(a_samples)) # Per-draw constant offset from held-fixed predictors.
for other_pred in predictors: # Sum contributions across all other predictors.
if other_pred != pred: # Skip the predictor currently on the x-axis.
other_contribution += slope_samples[other_pred] * data[other_pred].mean() # slope_draw * mean(x_other).
for i in idx: # Overlay a thin gray line per sampled posterior draw.
y_line = a_samples[i] + other_contribution[i] + slope_samples[pred][i] * x_grid # Predicted y on the grid.
ax.plot(x_grid, y_line, alpha=0.05, color="gray") # Low alpha so overlap shades posterior uncertainty.
# Posterior mean line for emphasis.
mean_other = sum(slope_samples[op].mean() * data[op].mean() for op in predictors if op != pred) # Mean offset from other predictors.
y_mean = a_samples.mean() + mean_other + slope_samples[pred].mean() * x_grid # Mean posterior prediction on the grid.
ax.plot(x_grid, y_mean, color="red") # Plot the mean line in red.
ax.set_xlabel(pred) # Label x-axis with the predictor name.
ax.set_ylabel(outcome) # Label y-axis with the outcome name.
ax.set_title(f"{outcome} vs {pred} (others at mean)") # Title notes that other predictors are held at mean.
plt.tight_layout() # Tidy spacing across subplots.
plt.show() # Render the figure.
if show_residuals: # Optional: residual diagnostics using posterior mean coefficients.
a_mean = posterior["intercept"].values.flatten().mean() # Posterior mean intercept (point estimate).
slope_means = {pred: posterior[f"slope_{pred}"].values.flatten().mean() for pred in predictors} # Posterior mean slope per predictor.
y_hat = a_mean + sum(slope_means[pred] * data[pred].values for pred in predictors) # Fitted values using mean coefficients.
residuals = y - y_hat # Residuals: observed minus fitted.
fig, axes = plt.subplots(1, len(predictors) + 1, figsize=(6 * (len(predictors) + 1), 5)) # One subplot for fitted + one per predictor.
axes[0].scatter(y_hat, residuals, alpha=0.5) # Residuals vs fitted values — should look like noise around 0.
axes[0].axhline(0, color="red", linestyle="--") # Zero reference line to judge bias/structure.
axes[0].set_xlabel("Fitted values") # x-axis label.
axes[0].set_ylabel("Residuals") # y-axis label.
axes[0].set_title("Residuals vs Fitted") # Subplot title.
for ax, pred in zip(axes[1:], predictors): # Residuals vs each predictor — check for unmodeled structure.
ax.scatter(data[pred].values, residuals, alpha=0.5) # Scatter residuals against this predictor.
ax.axhline(0, color="red", linestyle="--") # Zero reference line.
ax.set_xlabel(pred) # x-axis label is the predictor name.
ax.set_ylabel("Residuals") # y-axis label.
ax.set_title(f"Residuals vs {pred}") # Subplot title.
plt.tight_layout() # Tidy subplot spacing.
plt.show() # Render the figure.
return trace # Return the InferenceData so the caller can do further analysis.
12.1 Linear transformations¶
Scaling of predictors and regression coefficients¶
The coeffient $\beta_j$ represents the average difference in y, comparing items that differ by one unit on the jth predictor, but are the same on all other predictors. In some cases, a difference of 1 unit in x is not the most relevant comparison.
Earlier predicting earnings from height:
Earnings = -85000 + 1600*height + error, with a residual standard error of 22,000.
The intercept of -85000 is not meaningful, as it corresponds to the predicted earnings of a person with a height of 0 inches. The slope of 1600 means that for every additional inch in height, earnings are predicted to increase by $1600.
Height can be expressed in many different units.
To understand the coefficients, we should better understand the variation in the predictor. One approach is to scale by the standard deviation of the predictor (3.8 inches for height).
Linear transformations of predictors X or outcome y dont affect the fit of the model. They help with
earnings = pd.read_csv('../ros_data/earnings.csv', skiprows=0)
# earnings['earn_k'] = earnings['earn'] / 1000
# earnings['c_height'] = earnings['height'] - 66 # Center height around 66 inches for better interpretability of the intercept
# calculate standard deviation of height
height_std = earnings['height'].std()
print(f"Standard deviation of height: {height_std:.2f} inches")
display(earnings.head())
earnings_clean = earnings.dropna(subset=['height', 'earn'])
earnings_model = fit_and_plot_bayes(earnings_clean, 'height', 'earn',
intercept_mu=0, intercept_sigma=50,
slope_mu=0, slope_sigma=50,
sigma_sigma=50,
samples=2000, tune=1000, hdi_prob=0.95,
show_trace=True, show_forest=False,
show_posterior=False, show_regression=True,
n_regression_lines=100)
print(earnings_model)
Standard deviation of height: 3.83 inches
| height | weight | male | earn | earnk | ethnicity | education | mother_education | father_education | walk | exercise | smokenow | tense | angry | age | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 74 | 210.0 | 1 | 50000.0 | 50.0 | White | 16.0 | 16.0 | 16.0 | 3 | 3 | 2.0 | 0.0 | 0.0 | 45 |
| 1 | 66 | 125.0 | 0 | 60000.0 | 60.0 | White | 16.0 | 16.0 | 16.0 | 6 | 5 | 1.0 | 0.0 | 0.0 | 58 |
| 2 | 64 | 126.0 | 0 | 30000.0 | 30.0 | White | 16.0 | 16.0 | 16.0 | 8 | 1 | 2.0 | 1.0 | 1.0 | 29 |
| 3 | 65 | 200.0 | 0 | 25000.0 | 25.0 | White | 17.0 | 17.0 | NaN | 8 | 1 | 2.0 | 0.0 | 0.0 | 57 |
| 4 | 63 | 110.0 | 0 | 50000.0 | 50.0 | Other | 16.0 | 16.0 | 16.0 | 5 | 6 | 2.0 | 0.0 | 0.0 | 91 |
Initializing NUTS using jitter+adapt_diag... Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_height, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 1 seconds.
mean sd hdi_2.5% hdi_97.5% mcse_mean mcse_sd \
intercept -23.647 50.330 -120.873 76.484 0.507 0.543
slope_height 321.562 2.501 316.625 326.353 0.025 0.026
sigma 6718.789 24.245 6669.107 6764.250 0.239 0.280
ess_bulk ess_tail r_hat
intercept 9837.0 6393.0 1.0
slope_height 10383.0 6284.0 1.0
sigma 10320.0 5888.0 1.0
Regression formula: earn = -23.65 + 321.56*height
Residual std dev (σ): 6718.79 ± 24.24
Bayesian R²: 0.003 ± 0.000
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/arviz/stats/stats.py:782: UserWarning: Estimated shape parameter of Pareto distribution is greater than 0.70 for one or more samples. You should consider using a more robust model, this is because importance sampling is less likely to work well if the marginal posterior and LOO posterior are very different. This is more likely to happen with a non-robust model and highly influential observations. warnings.warn(
LOO-ELPD: -27662.59 ± 1769.98 (p_loo=106.4) <- should be lower than number of parameters (3) for reliable estimates When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE. LOO log score (per obs): -15.233 ± 0.975 Warning: 2 observations with Pareto-k > 0.7 (unreliable LOO estimates)
Inference data with groups: > posterior > log_likelihood > sample_stats > observed_data
Standardisation using z-scores¶
We can scale the coefficients by standardising the predictors. This is done by subtracting the mean and dividing by the standard deviation. The resulting variable is called a z-score.
For height above, this would be: z_height = (height - 66) / 3.8
Coefficients are then interpreted in units of standard deviations with respect to the corresponding predictor.
Why this is useful:
- Coefficient now tells us 'what happens if height increases by one standard deviation?'. One SD is roughly the size of a typical difference between an average person and a randomly selected person.
- Intercept is interpretable. With z-scores, the intercept corresponds to the predicted earnings of a person when all predictors are at their mean.
- We can compare predictors: If we have two predictors, one with a coefficient of 0.5 and another with a coefficient of 1.0, we can say that the second predictor has a stronger relationship with the outcome variable than the first predictor, since a one standard deviation increase in the second predictor is associated with a larger change in the outcome variable compared to a one standard deviation increase in the first predictor. Cant do this with raw data.
Some statisticians prefer dividing by 2 standard deviations - this makes continuous predictors more comparable to binary predictors.
Standardisation is best done using our data if we have a large sample. If we have a small sample, we can use the standard deviation from a larger, more representative dataset.
12.2 Centering and standardizing for models with interactions¶
Similar challenges arise with models that have interaction terms. The interpretation of the main effects becomes more difficult when there are interactions, as the main effects represent the effect of a predictor when the other predictor is at its reference level (often 0).
kid_score = -7.05 + 45.94mom_hs + 0.92mom_iq + -0.43*mom_hs_iq
Coefficient on mom_hs is 45.94, which means that the predicted kid_score is 45.94 points higher for a child whose mother has a high school degree compared to a child whose mother does not have a high school degree, when the mother's IQ is 0. This is not a meaningful comparison, as an IQ of 0 is not realistic.
Centering by subtracting the mean¶
We can simplify interpretation by centering the predictors. This is done by subtracting the mean from each predictor.
Using a conventional centering point¶
We can also use an understandable value as the centering point. E.g., midpoint of the range of the predictor, or a meaningful value.
Standardizing by subtracting the mean and dividing by 2 standard deviations¶
Centering helped with interpretation, but there is still a scaling problem. Coefficients are not comparable across predictors. Mom completing high school is a complete change to a single point in IQ, which is not much of a change in IQ.
Why scaling by 2 standard deviations?¶
So that we can compare the coefficients of continuous predictors to binary predictors.
If we have a binary variable that is 0 50% of the time, its SD is 0.5. When standardising by 1SD, variable becomes -1 to 1, a 2 unit change so coefficient is half the difference between the two groups. When standardising by 2SD, variable becomes -0.5 to 0.5, a 1 unit change so coefficient is the difference between the two groups.
Multiplying each regression coefficient by 2 standard deviations of its predictor¶
For models with no interactions, we can simply multiply each regression coefficient by 2 standard deviations of its predictor.
kidiq = pd.read_csv('../ros_data/kidiq.csv', skiprows=0)
display(kidiq.head())
# interaction term between mom_hs and mom_iq
kidiq['mom_hs_iq'] = kidiq['mom_hs'] * kidiq['mom_iq']
fit1 = fit_and_plot_bayes(kidiq, ['mom_hs', 'mom_iq', 'mom_hs_iq'], 'kid_score',
intercept_mu=0, intercept_sigma=50,
slope_mu=0, slope_sigma=50,
sigma_sigma=50,
samples=2000, tune=1000, hdi_prob=0.95,
show_trace=False, show_forest=False,
show_posterior=False, show_regression=False,
n_regression_lines=100)
# Centering by subtracting the mean
kidiq['c_mom_iq'] = kidiq['mom_iq'] - kidiq['mom_iq'].mean() # Center mom_iq by subtracting its mean
kidiq['c_mom_hs'] = kidiq['mom_hs'] - kidiq['mom_hs'].mean() # Center mom_hs by subtracting its mean
kidiq['c_mom_hs_iq'] = kidiq['c_mom_hs'] * kidiq['c_mom_iq'] # Interaction of centered predictors
fit2 = fit_and_plot_bayes(kidiq, ['c_mom_hs', 'c_mom_iq', 'c_mom_hs_iq'], 'kid_score',
intercept_mu=0, intercept_sigma=50,
slope_mu=0, slope_sigma=50,
sigma_sigma=50,
samples=2000, tune=1000, hdi_prob=0.95,
show_trace=False, show_forest=False,
show_posterior=False, show_regression=False,
n_regression_lines=100)
# Standardising by subtracting the mean and dividing by 2 standard deviations
kidiq['s_mom_iq'] = (kidiq['mom_iq'] - kidiq['mom_iq'].mean()) / (2 * kidiq['mom_iq'].std()) # Standardize mom_iq
kidiq['s_mom_hs'] = (kidiq['mom_hs'] - kidiq['mom_hs'].mean()) / (2 * kidiq['mom_hs'].std()) # Standardize mom_hs
kidiq['s_mom_hs_iq'] = kidiq['s_mom_hs'] * kidiq['s_mom_iq'] # Interaction of standardized predictors
fit3 = fit_and_plot_bayes(kidiq, ['s_mom_hs', 's_mom_iq', 's_mom_hs_iq'], 'kid_score',
intercept_mu=0, intercept_sigma=50,
slope_mu=0, slope_sigma=50,
sigma_sigma=50,
samples=2000, tune=1000, hdi_prob=0.95,
show_trace=False, show_forest=False,
show_posterior=False, show_regression=False,
n_regression_lines=100)
| kid_score | mom_hs | mom_iq | mom_work | mom_age | |
|---|---|---|---|---|---|
| 0 | 65 | 1 | 121.117529 | 4 | 27 |
| 1 | 98 | 1 | 89.361882 | 4 | 25 |
| 2 | 85 | 1 | 115.443165 | 4 | 27 |
| 3 | 83 | 1 | 99.449639 | 3 | 25 |
| 4 | 115 | 1 | 92.745710 | 4 | 27 |
Initializing NUTS using jitter+adapt_diag... Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_mom_hs, slope_mom_iq, slope_mom_hs_iq, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 6 seconds.
mean sd hdi_2.5% hdi_97.5% mcse_mean mcse_sd \
intercept -7.154 13.202 -32.833 18.421 0.325 0.207
slope_mom_hs 46.178 14.660 16.205 73.175 0.361 0.220
slope_mom_iq 0.922 0.143 0.635 1.188 0.004 0.002
slope_mom_hs_iq -0.430 0.155 -0.714 -0.112 0.004 0.002
sigma 18.035 0.624 16.834 19.234 0.011 0.009
ess_bulk ess_tail r_hat
intercept 1647.0 2375.0 1.01
slope_mom_hs 1650.0 2532.0 1.01
slope_mom_iq 1656.0 2481.0 1.01
slope_mom_hs_iq 1642.0 2405.0 1.01
sigma 3420.0 3711.0 1.00
Regression formula: kid_score = -7.15 + 46.18*mom_hs + 0.92*mom_iq + -0.43*mom_hs_iq
Residual std dev (σ): 18.04 ± 0.62
Bayesian R²: 0.228 ± 0.030
Initializing NUTS using jitter+adapt_diag...
LOO-ELPD: -1872.48 ± 14.31 (p_loo=4.7) <- should be lower than number of parameters (5) for reliable estimates When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE. LOO log score (per obs): -4.314 ± 0.033
Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_c_mom_hs, slope_c_mom_iq, slope_c_mom_hs_iq, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 1 seconds.
mean sd hdi_2.5% hdi_97.5% mcse_mean mcse_sd \
intercept 87.618 0.909 85.906 89.472 0.010 0.009
slope_c_mom_hs 2.835 2.465 -2.001 7.667 0.032 0.024
slope_c_mom_iq 0.588 0.062 0.460 0.701 0.001 0.001
slope_c_mom_hs_iq -0.484 0.162 -0.797 -0.162 0.002 0.001
sigma 18.021 0.625 16.807 19.256 0.007 0.007
ess_bulk ess_tail r_hat
intercept 8314.0 6668.0 1.0
slope_c_mom_hs 5991.0 6361.0 1.0
slope_c_mom_iq 7249.0 6052.0 1.0
slope_c_mom_hs_iq 6537.0 6771.0 1.0
sigma 8810.0 6513.0 1.0
Regression formula: kid_score = 87.62 + 2.84*c_mom_hs + 0.59*c_mom_iq + -0.48*c_mom_hs_iq
Residual std dev (σ): 18.02 ± 0.62
Bayesian R²: 0.232 ± 0.031
Initializing NUTS using jitter+adapt_diag...
LOO-ELPD: -1872.60 ± 14.35 (p_loo=4.9) <- should be lower than number of parameters (5) for reliable estimates When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE. LOO log score (per obs): -4.315 ± 0.033
Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_s_mom_hs, slope_s_mom_iq, slope_s_mom_hs_iq, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 1 seconds.
mean sd hdi_2.5% hdi_97.5% mcse_mean mcse_sd \
intercept 87.619 0.908 85.884 89.402 0.010 0.009
slope_s_mom_hs 2.376 1.978 -1.596 6.156 0.025 0.019
slope_s_mom_iq 17.585 1.836 14.013 21.131 0.021 0.018
slope_s_mom_hs_iq -11.892 4.040 -19.694 -3.913 0.051 0.040
sigma 18.017 0.624 16.840 19.262 0.006 0.007
ess_bulk ess_tail r_hat
intercept 8692.0 6853.0 1.0
slope_s_mom_hs 6205.0 6240.0 1.0
slope_s_mom_iq 7743.0 6458.0 1.0
slope_s_mom_hs_iq 6284.0 6180.0 1.0
sigma 9370.0 6291.0 1.0
Regression formula: kid_score = 87.62 + 2.38*s_mom_hs + 17.59*s_mom_iq + -11.89*s_mom_hs_iq
Residual std dev (σ): 18.02 ± 0.62
Bayesian R²: 0.231 ± 0.031
LOO-ELPD: -1872.62 ± 14.35 (p_loo=5.0) <- should be lower than number of parameters (5) for reliable estimates
When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE.
LOO log score (per obs): -4.315 ± 0.033
12.3 Correlation and 'regression to the mean'¶
For a regression with constant term and one predictor, $y = a + bx + error$, if both x and y are standardised ($x = (x - mean(x)) / sd(x)$ and $y = (y - mean(y)) / sd(y)$), then the intercept a will be 0 and the slope b will be the correlation between x and y. Thus it will be between -1 and 1. The slope of regression with one predictor $b = \rho \sigma_y / \sigma_x$, where $\rho$ is the correlation between x and y, $\sigma_y$ is the standard deviation of y, and $\sigma_x$ is the standard deviation of x.
The principal component line and the regression line¶
With two standardised variables, the principal component line is the line that minimises the sum of squared distances from the data points to the line, while the regression line is the line that minimises the sum of squared vertical distances from the data points to the line. The principal component line is y = x. The regression line is flatter than the principal component line. Regression line gives unbiased predictions, in that it goes through the average of y given x.
Regression to the mean¶
When x and y are standardised, slope is always less than 1. So when x is 1SD above the mean, the predicted value of y is between 0 and 1 SD above the mean. This phenomenon is called regression to the mean - y is predicted to be closer to mean than x.
E.g., woman 10 inches taller than average and correlation of mother and daughter height is 0.5, then daughter is predicted to be 5 inches taller than average, which is still taller than average, but closer to the mean than the mother's height.
Similar for not perfectly correlated variables. E.g., we expect a team over two seasons who did very well in the first season to do less well in the second season, and a team that did very badly in the first season to do better in the second season, even if there is no change in the underlying quality of the teams.
Naive interpretation is that variable phenomena become more average over time. This is wrong due to the error, where some points fall closer to mean than others.
12.4 Logarithmic transformations¶
When linearity and additivity are not reasonable assumptions, we can use transformations of the predictors and/or the outcome variable to achieve a better fit. One common transformation is the logarithmic transformation.
Log transformations force the outcome variable to be positive, and they are multiplicative rather than additive.
Plain linear regression can predict negative values, which is not possible for some outcomes (e.g., earnings). Log transformation can help with this issue. Linear models assume effects add the same amount, but in real life, effects often multiply.
Logging outcome fixes both:
- If we model log(y), predictions are on log scale, to get predictions on original scale, we need to exponentiate the predictions. Exponentiating a negative number gives a positive number, so we can never get negative predictions.
- If we model log(y), the effect of a predictor is multiplicative rather than additive. A one unit increase in a predictor is associated with a percentage change in the outcome variable, rather than an absolute change.
$log(y) = b₀ + b₁x₁ + b₂x₂ + ε$
$y = e^{b₀ + b₁x₁ + b₂x₂ + ε}$
$y = e^{b₀} * e^{b₁x₁} * e^{b₂x₂} * e^ε$
Earnings and height¶
Take log of earnings and regress on height:
log_earn = 5.92 + 0.06*height
exponentiating the coefficients gives:
earnings = e^{5.92} * e^{0.06height} earnings = 372 * 1.06height
1 inch increase in height is associated with a 6% increase in earnings.
earnings = pd.read_csv('../ros_data/earnings.csv', skiprows=0)
# earnings['earn_k'] = earnings['earn'] / 1000
# earnings['c_height'] = earnings['height'] - 66 # Center height around 66 inches for better interpretability of the intercept
# Calculate log of earnings
earnings['log_earn'] = np.log(earnings['earn'])
display(earnings.head())
earnings_clean = earnings[earnings['earn'] > 0].dropna(subset=['height', 'log_earn'])
earnings_model = fit_and_plot_bayes(earnings_clean, 'height', 'log_earn',
intercept_mu=0, intercept_sigma=50,
slope_mu=0, slope_sigma=50,
sigma_sigma=50,
samples=2000, tune=1000, hdi_prob=0.95,
show_trace=True, show_forest=False,
show_posterior=False, show_regression=True,
n_regression_lines=100)
# --- Posterior predictive check (PPC) ---
# Extract flattened posterior draws (all chains concatenated) for each parameter.
posterior = earnings_model.posterior
intercept_draws = posterior["intercept"].values.flatten()
slope_draws = posterior["slope_height"].values.flatten()
sigma_draws = posterior["sigma"].values.flatten()
x = earnings_clean["height"].values # observed predictor
y_obs = earnings_clean["log_earn"].values # observed outcome
rng = np.random.default_rng(42) # seeded RNG for reproducibility
n_sims = len(intercept_draws) # total number of posterior draws (chains × samples)
yrep = np.array([ # build yrep: one simulated dataset per posterior draw
rng.normal( # draw y values from Normal(mu, sigma) —
intercept_draws[i] # mu = intercept (scalar, same for all obs in this draw)
+ slope_draws[i] * x, # + slope * height (vector, one value per observation)
sigma_draws[i] # sigma = residual std dev (scalar, same for all obs)
) # result: array of length n_obs for this draw
for i in range(n_sims) # repeat for every posterior draw
]) # final shape: (n_sims, n_obs)
# Randomly select 100 simulated datasets to plot (same as R's `subset <- sample(n_sims, 100)`).
subset = rng.choice(n_sims, 100, replace=False)
# Wrap observed data and the 100 yrep draws into an ArviZ InferenceData object.
# posterior_predictive shape must be (chains, draws, obs); np.newaxis adds the chain dim.
idata_ppc = az.from_dict(
observed_data={"y": y_obs},
posterior_predictive={"y": yrep[np.newaxis, subset, :]}
)
# plot_ppc overlays the density of each simulated dataset (thin lines) against
# the observed data density (thick line) — equivalent to bayesplot::ppc_dens_overlay.
az.plot_ppc(idata_ppc, num_pp_samples=100, kind="kde")
plt.title("PPC: log(earn) ~ height")
plt.show()
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/pandas/core/arraylike.py:402: RuntimeWarning: divide by zero encountered in log result = getattr(ufunc, method)(*inputs, **kwargs)
| height | weight | male | earn | earnk | ethnicity | education | mother_education | father_education | walk | exercise | smokenow | tense | angry | age | log_earn | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 74 | 210.0 | 1 | 50000.0 | 50.0 | White | 16.0 | 16.0 | 16.0 | 3 | 3 | 2.0 | 0.0 | 0.0 | 45 | 10.819778 |
| 1 | 66 | 125.0 | 0 | 60000.0 | 60.0 | White | 16.0 | 16.0 | 16.0 | 6 | 5 | 1.0 | 0.0 | 0.0 | 58 | 11.002100 |
| 2 | 64 | 126.0 | 0 | 30000.0 | 30.0 | White | 16.0 | 16.0 | 16.0 | 8 | 1 | 2.0 | 1.0 | 1.0 | 29 | 10.308953 |
| 3 | 65 | 200.0 | 0 | 25000.0 | 25.0 | White | 17.0 | 17.0 | NaN | 8 | 1 | 2.0 | 0.0 | 0.0 | 57 | 10.126631 |
| 4 | 63 | 110.0 | 0 | 50000.0 | 50.0 | Other | 16.0 | 16.0 | 16.0 | 5 | 6 | 2.0 | 0.0 | 0.0 | 91 | 10.819778 |
Initializing NUTS using jitter+adapt_diag... Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_height, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 5 seconds.
mean sd hdi_2.5% hdi_97.5% mcse_mean mcse_sd ess_bulk \
intercept 5.898 0.390 5.111 6.636 0.008 0.006 2370.0
slope_height 0.057 0.006 0.046 0.069 0.000 0.000 2362.0
sigma 0.878 0.016 0.847 0.908 0.000 0.000 3556.0
ess_tail r_hat
intercept 2520.0 1.0
slope_height 2442.0 1.0
sigma 2886.0 1.0
Regression formula: log_earn = 5.90 + 0.06*height
Residual std dev (σ): 0.88 ± 0.02
Bayesian R²: 0.060 ± 0.011
LOO-ELPD: -2100.63 ± 38.77 (p_loo=3.9) <- should be lower than number of parameters (3) for reliable estimates
When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE.
LOO log score (per obs): -1.290 ± 0.024
Why natural log?¶
Natural logs (base e) are used because they are directly interpretable as proportional differences. Coefficient of 0.05 ~ 5% change. Sometimes written as ln. Log base 10 can be used, but it is not as interpretable, as a coefficient of 0.05 would correspond to a 12% change in the outcome variable, which is less intuitive than a 5% change.
Building a regression model on the log scale¶
Adding another predictor to the model: after adding male to the model, the coefficient for height has changed from 0.06 to 0.02, which means that the predicted percentage increase in earnings for a one inch increase in height is now 2% instead of 6%. The coefficient for male is 0.37 - exponentiating gives 45% increase in earnings for males compared to females.
earnings_model = fit_and_plot_bayes(earnings_clean, ['height', 'male'], 'log_earn',
intercept_mu=0, intercept_sigma=50,
slope_mu=0, slope_sigma=50,
sigma_sigma=50,
samples=2000, tune=1000, hdi_prob=0.95,
show_trace=True, show_forest=False,
show_posterior=False, show_regression=True,
n_regression_lines=100)
Initializing NUTS using jitter+adapt_diag... Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_height, slope_male, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 9 seconds.
mean sd hdi_2.5% hdi_97.5% mcse_mean mcse_sd ess_bulk \
intercept 7.972 0.508 6.957 8.927 0.011 0.007 2085.0
slope_height 0.024 0.008 0.009 0.040 0.000 0.000 2060.0
slope_male 0.372 0.062 0.250 0.493 0.001 0.001 2385.0
sigma 0.869 0.015 0.838 0.896 0.000 0.000 4487.0
ess_tail r_hat
intercept 2762.0 1.0
slope_height 2708.0 1.0
slope_male 3244.0 1.0
sigma 4411.0 1.0
Regression formula: log_earn = 7.97 + 0.02*height + 0.37*male
Residual std dev (σ): 0.87 ± 0.01
Bayesian R²: 0.081 ± 0.012
LOO-ELPD: -2083.54 ± 38.68 (p_loo=4.8) <- should be lower than number of parameters (4) for reliable estimates
When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE.
LOO log score (per obs): -1.279 ± 0.024
Naming inputs: here male is a binary variable, so we can interpret the coefficient as the percentage difference in earnings between males and females, holding height constant. Male = 1 for men.
Residual deviation and R2: \sigma is the residual standard deviation on the log scale, 0.87, implying 68% of log earnings are within 0.87 of the predicted value. 70 inch woman has earnings 7.99 + 0.02 * 70 + 0.37 * 0 = 9.37 +- 0.87, which means that her earnings are predicted to be between e^{9.37 - 0.87} and e^{9.37 + 0.87}, which is between $5k and 28k. This is very wide, which means that the model is not very good at predicting earnings. R2 is 0.08, which means that the model explains only 8% of the variance in log earnings.
Including an interaction: between height and sex: log_earn = 8.56 + 0.01 * height - 0.97 * male + 0.02 * height_male. Intercept is when height and male are 0, which is not meaningful and has no real world interpretation. Height coefficient is 0.01, which is the predicted difference in log earnings for one inch difference in height when male is 0. Coefficient for male is predicted difference in log earnings between males and females when height is 0, which is not meaningful. Coefficient for height_male is the difference in slopes of lines predicting log earnings from height, comparing men to women. Thus a difference of 1 inch in height is associated with a 2% increase in earnings for men compared to women. The standard error is large compared to the coefficient, so we are not very confident in this estimate.
# interaction between height and male
earnings_clean['height_male'] = earnings_clean['height'] * earnings_clean['male']
earnings_model = fit_and_plot_bayes(earnings_clean, ['height', 'male', 'height_male'], 'log_earn',
intercept_mu=0, intercept_sigma=50,
slope_mu=0, slope_sigma=50,
sigma_sigma=50,
samples=2000, tune=1000, hdi_prob=0.95,
show_trace=True, show_forest=False,
show_posterior=False, show_regression=True,
n_regression_lines=100)
Initializing NUTS using jitter+adapt_diag... Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_height, slope_male, slope_height_male, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 16 seconds.
mean sd hdi_2.5% hdi_97.5% mcse_mean mcse_sd \
intercept 8.562 0.672 7.268 9.886 0.015 0.008
slope_height 0.015 0.010 -0.005 0.036 0.000 0.000
slope_male -0.967 1.047 -3.127 0.995 0.024 0.014
slope_height_male 0.020 0.015 -0.009 0.052 0.000 0.000
sigma 0.868 0.015 0.839 0.899 0.000 0.000
ess_bulk ess_tail r_hat
intercept 2071.0 3294.0 1.0
slope_height 2073.0 3395.0 1.0
slope_male 1959.0 3050.0 1.0
slope_height_male 1922.0 2987.0 1.0
sigma 4740.0 4720.0 1.0
Regression formula: log_earn = 8.56 + 0.01*height + -0.97*male + 0.02*height_male
Residual std dev (σ): 0.87 ± 0.02
Bayesian R²: 0.082 ± 0.012
LOO-ELPD: -2083.70 ± 38.78 (p_loo=5.8) <- should be lower than number of parameters (5) for reliable estimates
When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE.
LOO log score (per obs): -1.279 ± 0.024
Linear transformation to make coefficients more interpretable: we can rescale height by having a mean of 0 and a standard deviation of 1. This is done by standardising height: z_height = (height - mean(height)) / sd(height). The coefficient for z_height is now the predicted percentage increase in earnings for a one standard deviation increase in height, which is more interpretable than the coefficient for height in inches.
9.55 + 0.06* z_height + 0.36 * male + 0.08 * z_height_male
Intercept is where z_height and male are 0, which is the predicted log earnings for a person of average height and female = 9.55, which is $14k.
Coefficient for z_height is 0.06, which is the predicted difference in log earnings for a one standard deviation increase in height among females.
Coefficient for male is predicted difference in log earnings between males and females if z_height is 0, which is the predicted difference in log earnings between males and females of average height. Log difference of 0.36 corresponds to a 43% increase in earnings for males compared to females of average height.
Coefficient for z_height_male is the difference in slopes of lines predicting log earnings from z_height, comparing men to females. Thus a difference of 1 standard deviation in height is associated with an 8% increase in earnings for men compared to women.
# standardise height by subtracting mean and dividing by std
earnings_clean['z_height'] = (earnings_clean['height'] - earnings_clean['height'].mean()) / earnings_clean['height'].std()
earnings_clean['z_height_male'] = earnings_clean['z_height'] * earnings_clean['male']
earnings_model = fit_and_plot_bayes(earnings_clean, ['z_height', 'male', 'z_height_male'], 'log_earn',
intercept_mu=0, intercept_sigma=50,
slope_mu=0, slope_sigma=50,
sigma_sigma=50,
samples=2000, tune=1000, hdi_prob=0.95,
show_trace=True, show_forest=False,
show_posterior=False, show_regression=True,
n_regression_lines=100)
Initializing NUTS using jitter+adapt_diag... Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_z_height, slope_male, slope_z_height_male, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 2 seconds.
mean sd hdi_2.5% hdi_97.5% mcse_mean mcse_sd \
intercept 9.549 0.037 9.471 9.617 0.001 0.000
slope_z_height 0.058 0.041 -0.019 0.142 0.001 0.000
slope_male 0.356 0.062 0.232 0.475 0.001 0.001
slope_z_height_male 0.074 0.061 -0.047 0.189 0.001 0.001
sigma 0.869 0.015 0.839 0.899 0.000 0.000
ess_bulk ess_tail r_hat
intercept 4017.0 5331.0 1.0
slope_z_height 4278.0 5094.0 1.0
slope_male 5352.0 5671.0 1.0
slope_z_height_male 4812.0 5198.0 1.0
sigma 7316.0 4555.0 1.0
Regression formula: log_earn = 9.55 + 0.06*z_height + 0.36*male + 0.07*z_height_male
Residual std dev (σ): 0.87 ± 0.02
Bayesian R²: 0.082 ± 0.013
LOO-ELPD: -2083.81 ± 38.73 (p_loo=5.9) <- should be lower than number of parameters (5) for reliable estimates
When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE.
LOO log score (per obs): -1.279 ± 0.024
Further difficulties in interpretation¶
With the model log_earn = 8.00 + 0.02 * height + 0.37 * male
Interpreting the coefficient for height: comparing two adults of same sex, one who is 1 inch taller than the other, the taller adult is predicted to have 2% higher earnings.
Interpreting the coefficient for male: comparing two adults of the same height, but different sex, the male is predicted to have 45% higher earnings than the female. However, the same height means that a woman would be above average height and the male would be below average height, which is not a meaningful comparison.
Log-log model: transforming the input and outcome variables¶
If we transform both the input and outcome, the coefficient can be interpreted as the proportional difference in x.
log_earn = 2.97 + 1.57 * log_height + 0.37 * male
For each 1% increase in height, earnings are predicted to increase by 1.57%. More precisely, comparing two peoople who differ in height by factor of 1.01 (i.e., 1% increase), this corresponds to a difference of 0.01 in predicted log earnings, which corresponds to a 1.57% increase in earnings.
earnings_clean['log_height'] = np.log(earnings_clean['height'])
earnings_model = fit_and_plot_bayes(earnings_clean, ['log_height', 'male'], 'log_earn',
intercept_mu=0, intercept_sigma=50,
slope_mu=0, slope_sigma=50,
sigma_sigma=50,
samples=2000, tune=1000, hdi_prob=0.95,
show_trace=True, show_forest=False,
show_posterior=False, show_regression=True,
n_regression_lines=100)
Initializing NUTS using jitter+adapt_diag... Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_log_height, slope_male, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 35 seconds.
mean sd hdi_2.5% hdi_97.5% mcse_mean mcse_sd \
intercept 2.830 2.182 -1.503 6.992 0.052 0.031
slope_log_height 1.604 0.524 0.611 2.655 0.012 0.007
slope_male 0.372 0.062 0.247 0.490 0.001 0.001
sigma 0.868 0.015 0.839 0.897 0.000 0.000
ess_bulk ess_tail r_hat
intercept 1788.0 2422.0 1.0
slope_log_height 1786.0 2399.0 1.0
slope_male 2357.0 3420.0 1.0
sigma 3460.0 3918.0 1.0
Regression formula: log_earn = 2.83 + 1.60*log_height + 0.37*male
Residual std dev (σ): 0.87 ± 0.02
Bayesian R²: 0.081 ± 0.012
LOO-ELPD: -2083.48 ± 38.69 (p_loo=4.9) <- should be lower than number of parameters (4) for reliable estimates
When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE.
LOO log score (per obs): -1.279 ± 0.024
Taking logarithms even when not necessary¶
If variable has a narrow dynamic range (ratio between high and low values is close to 1), then log transformation may not be necessary.
However, log transformation can still be useful for interpretability, as it allows us to interpret coefficients as percentage changes rather than absolute changes.
12.5 Other transformations¶
Square root transformation¶
Linear (no transformation) treats all differences in the predictor as equally important, but this may not be the case. 0 vs 10k is very different to 80 vs 90k.
Log treats doubling the same, so 5k vs 10k is the same as 80k vs 160k, which may not be appropriate.
Square root transformation squashes the larger values more gently. sqrt(10k) = 100, sqrt(40k) = 200, sqrt(90k) = 300.
However, interpretation is challenging and negative values become positive.
Idiosyncratic transformations¶
Problem 1: Sometimes log doesnt work, for example zero earnings. We can first model if earnings are greater than 0, and then model log earnings for those with positive earnings. This is called a hurdle approach. These two parts do not combine simply additively, they interact non-linearly. Makes coefficients hard to interpret, so best way is to simulate predictions from the model and interpret those predictions.
Problem 2: negative values. We can create discrete buckets of the predictor, e.g., -10k to 0 (-1), 0-10k (0), 10-20k (1), etc. This is called binning. We can then use these buckets as categorical predictors in the regression model.
Using continuous rather than discrete predictors¶
Certain variables that might appear binary or discrete may actually be better represented as continuous variables. For example, handedness is often treated as a binary variable (left-handed vs right-handed), but it can be more accurately represented as a continuous variable that captures the degree of handedness. Binary predictors can be less informative than continuous predictors, as they do not capture the full range of variation in the data.
Using discrete rather than continuous predictors¶
Some variables dont have a clean mathematical relationship with the outcome variable, and it may be more appropriate to use discrete predictors. For example, age may have a non-linear relationship with the outcome variable, and it may be more appropriate to use age categories (e.g., 0-18, 19-35, 36-50, 51+) rather than treating age as a continuous variable. Discrete predictors can also be easier to interpret and communicate to a non-technical audience.
Index and indicator variables¶
Index variables divide a population into catagories: e.g., males = 1 and females = 0, age = 1 (ages 19-29), age = 2 (ages 30-39), etc. Indicator variables are binary variables that unpack the index variables into separate binary variables. e.g., sex_1 = 1 for females and sex_2 = 1 for males.
Indicator variables, identifiability, and the baseline condition]¶
'Dummy variable trap' is when we have perfect multicollinearity in our model, which occurs when one predictor variable can be perfectly predicted from the others. Predictors are collinear, meaning one predictor (or a combination of them) is just a redundant copy of others.
The issue for indicator variables: the intercept is a series of 1's. If we have a indicator, one of the indicators will be 1 and the other will be 0, and the sum will always be 1. So the indicators are perfectly collinear with the intercept, which means we cannot estimate the coefficients for the indicators.
The fix is to drop one of the indicators, which becomes the baseline condition. The coefficients for the remaining indicators are then interpreted as the difference in the outcome variable between that category and the baseline category.
The clean approach is to keep intercept and drop one indicator from each categorical predictor. This allows us to interpret the coefficients as differences from the baseline category, and it avoids issues with multicollinearity.
The dropped indicator is the baseline condition, and the coefficients for the remaining indicators are interpreted as the difference in the outcome variable between that category and the baseline category.
12.6 Building and comparing regression models for prediction¶
Start with simple model and slowly build in complexity, checking for problems along the way.
Key choices are how input variables should be combined or transformed and which predictors to include in the model. Critical as too many predictors can cause parameter estimates to be too variable as to be useless.
General principles¶
- Include input variables (for substantive reasons, not just because they are available), might be expected to be important in predicting the outcome.
- Not always include predictors as separate, they can be combined into an index or a composite variable
- For inputs with large effects, consider interactions
- Use standard errors to get an idea of uncertainties
- Decide on which predictors to include based on context and purpose of the model:
- If coefficient of predictor is estimated precisely (small standard error) generally keep in model
- If standard error is large and no substantive reason to keep predictor, consider dropping it from the model
- If predictor is important for the problem, then keep it in the model even if standard error is large, but be cautious in interpreting the coefficient for that predictor
- If coefficient doesnt make sense, try to understand why. If SE is large, could be due to random variation. If SE is small, more effort to understand why coefficient is not as expected.
Dont discard important information.
Example: predicting the yields of mesquite bushes¶
Aim was to develop a method of estimating total production (biomass) of mesquite leaves using easily measured parameters before harvest. Two separate sets of measurements, one on a group of 26 bushes and another on a group of 20 bushes at a different time. All from same location (ranch). Outcome variable is total weight in grams. Inputs are:
diam1: diameter of canopy in meters along longer axis diam2: canopy diameter in meters along shorter axis canopy_height: height of canopy in meters total_height: height of canopy in meters density: plant unit density (number of primary stems per plant) group: which group (0 or 1)
# Import mesquite.dat from /ros_data
mesquite = pd.read_csv('../ros_data/mesquite.dat',
sep=r'\s+')
# Display the first few rows of the dataset
display(mesquite.head(10))
# print value counts of group
print(mesquite['group'].value_counts())
# change group where == 'MCD' to 0 and 'ALS' to 1
mesquite['group'] = mesquite['group'].map({'MCD': 1, 'ALS': 0})
# print value counts of group
print(mesquite['group'].value_counts())
| obs | group | diam1 | diam2 | total_height | canopy_height | density | weight | |
|---|---|---|---|---|---|---|---|---|
| 0 | 1 | MCD | 1.8 | 1.15 | 1.30 | 1.00 | 1 | 401.3 |
| 1 | 2 | MCD | 1.7 | 1.35 | 1.35 | 1.33 | 1 | 513.7 |
| 2 | 3 | MCD | 2.8 | 2.55 | 2.16 | 0.60 | 1 | 1179.2 |
| 3 | 4 | MCD | 1.3 | 0.85 | 1.80 | 1.20 | 1 | 308.0 |
| 4 | 5 | MCD | 3.3 | 1.90 | 1.55 | 1.05 | 1 | 855.2 |
| 5 | 6 | MCD | 1.4 | 1.40 | 1.20 | 1.00 | 1 | 268.7 |
| 6 | 7 | MCD | 1.5 | 0.50 | 1.00 | 0.90 | 1 | 155.5 |
| 7 | 8 | MCD | 3.9 | 2.30 | 1.70 | 1.30 | 2 | 1253.2 |
| 8 | 9 | MCD | 1.8 | 1.35 | 0.80 | 0.60 | 1 | 328.0 |
| 9 | 10 | MCD | 2.1 | 1.60 | 1.20 | 0.80 | 1 | 614.6 |
group MCD 26 ALS 20 Name: count, dtype: int64 group 1 26 0 20 Name: count, dtype: int64
fit_and_plot_bayes(mesquite, ['diam1', 'diam2', 'canopy_height', 'total_height', 'density', 'group'], 'weight',
intercept_mu=0, intercept_sigma=2000,
slope_mu=0, slope_sigma=1000,
sigma_sigma=50,
samples=2000, tune=1000, hdi_prob=0.95,
show_trace=True, show_forest=False,
show_posterior=False, show_regression=False,
show_residuals=False,
n_regression_lines=100)
Initializing NUTS using jitter+adapt_diag... Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_diam1, slope_diam2, slope_canopy_height, slope_total_height, slope_density, slope_group, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 5 seconds.
mean sd hdi_2.5% hdi_97.5% mcse_mean \
intercept -1083.358 143.596 -1362.766 -796.707 1.954
slope_diam1 192.910 90.487 15.344 366.192 1.390
slope_diam2 365.649 100.533 169.825 566.287 1.580
slope_canopy_height 338.854 167.986 16.334 673.660 2.438
slope_total_height -90.739 149.789 -388.020 201.866 2.134
slope_density 131.413 28.284 77.103 187.893 0.351
slope_group 357.527 81.796 198.507 519.278 1.255
sigma 222.364 17.584 188.343 256.413 0.241
mcse_sd ess_bulk ess_tail r_hat
intercept 1.604 5391.0 5355.0 1.0
slope_diam1 1.001 4241.0 4963.0 1.0
slope_diam2 1.089 4054.0 5026.0 1.0
slope_canopy_height 1.972 4743.0 5198.0 1.0
slope_total_height 1.622 4951.0 5350.0 1.0
slope_density 0.336 6527.0 5161.0 1.0
slope_group 0.869 4233.0 5075.0 1.0
sigma 0.198 5345.0 5277.0 1.0
Regression formula: weight = -1083.36 + 192.91*diam1 + 365.65*diam2 + 338.85*canopy_height + -90.74*total_height + 131.41*density + 357.53*group
Residual std dev (σ): 222.36 ± 17.58
Bayesian R²: 0.836 ± 0.017
LOO-ELPD: -338.28 ± 18.37 (p_loo=22.1) <- should be lower than number of parameters (8) for reliable estimates
When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE.
LOO log score (per obs): -7.354 ± 0.399
Warning: 3 observations with Pareto-k > 0.7 (unreliable LOO estimates)
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/arviz/stats/stats.py:782: UserWarning: Estimated shape parameter of Pareto distribution is greater than 0.70 for one or more samples. You should consider using a more robust model, this is because importance sampling is less likely to work well if the marginal posterior and LOO posterior are very different. This is more likely to happen with a non-robust model and highly influential observations. warnings.warn(
-
<xarray.Dataset> Size: 528kB Dimensions: (chain: 4, draw: 2000) Coordinates: * chain (chain) int64 32B 0 1 2 3 * draw (draw) int64 16kB 0 1 2 3 4 ... 1996 1997 1998 1999 Data variables: intercept (chain, draw) float64 64kB -1.295e+03 ... -1.144e+03 slope_diam1 (chain, draw) float64 64kB 346.0 344.2 ... 81.92 -18.51 slope_diam2 (chain, draw) float64 64kB 269.0 242.8 ... 420.7 449.3 slope_canopy_height (chain, draw) float64 64kB 428.2 436.2 ... 392.0 289.2 slope_total_height (chain, draw) float64 64kB -124.9 -206.1 ... 193.3 slope_density (chain, draw) float64 64kB 113.5 108.5 ... 137.5 114.7 slope_group (chain, draw) float64 64kB 428.8 317.9 ... 328.2 423.0 sigma (chain, draw) float64 64kB 215.9 210.9 ... 209.3 225.2 Attributes: created_at: 2026-06-03T22:04:31.427352+00:00 arviz_version: 0.23.4 inference_library: pymc inference_library_version: 5.28.2 sampling_time: 4.922168016433716 tuning_steps: 1000 -
<xarray.Dataset> Size: 3MB Dimensions: (chain: 4, draw: 2000, y_dim_0: 46) Coordinates: * chain (chain) int64 32B 0 1 2 3 * draw (draw) int64 16kB 0 1 2 3 4 5 6 ... 1994 1995 1996 1997 1998 1999 * y_dim_0 (y_dim_0) int64 368B 0 1 2 3 4 5 6 7 8 ... 38 39 40 41 42 43 44 45 Data variables: y (chain, draw, y_dim_0) float64 3MB -6.314 -6.372 ... -6.336 -8.423 Attributes: created_at: 2026-06-03T22:04:31.700965+00:00 arviz_version: 0.23.4 inference_library: pymc inference_library_version: 5.28.2 -
<xarray.Dataset> Size: 1MB Dimensions: (chain: 4, draw: 2000) Coordinates: * chain (chain) int64 32B 0 1 2 3 * draw (draw) int64 16kB 0 1 2 3 4 ... 1996 1997 1998 1999 Data variables: (12/18) energy (chain, draw) float64 64kB 389.8 388.6 ... 391.8 divergences (chain, draw) int64 64kB 0 0 0 0 0 0 ... 0 0 0 0 0 0 acceptance_rate (chain, draw) float64 64kB 0.9805 0.9863 ... 0.9892 step_size (chain, draw) float64 64kB 0.1375 0.1375 ... 0.1186 lp (chain, draw) float64 64kB -386.9 -386.0 ... -389.4 step_size_bar (chain, draw) float64 64kB 0.1124 0.1124 ... 0.09822 ... ... tree_depth (chain, draw) int64 64kB 6 6 5 4 5 6 ... 6 6 6 5 5 5 energy_error (chain, draw) float64 64kB -0.4797 ... -0.01394 perf_counter_start (chain, draw) float64 64kB 399.0 399.0 ... 402.0 perf_counter_diff (chain, draw) float64 64kB 0.001977 ... 0.0009177 index_in_trajectory (chain, draw) int64 64kB 32 11 -10 3 ... 15 15 -10 reached_max_treedepth (chain, draw) bool 8kB False False ... False False Attributes: created_at: 2026-06-03T22:04:31.446747+00:00 arviz_version: 0.23.4 inference_library: pymc inference_library_version: 5.28.2 sampling_time: 4.922168016433716 tuning_steps: 1000 -
<xarray.Dataset> Size: 736B Dimensions: (y_dim_0: 46) Coordinates: * y_dim_0 (y_dim_0) int64 368B 0 1 2 3 4 5 6 7 8 ... 38 39 40 41 42 43 44 45 Data variables: y (y_dim_0) float64 368B 401.3 513.7 1.179e+03 ... 908.0 213.5 Attributes: created_at: 2026-06-03T22:04:31.450405+00:00 arviz_version: 0.23.4 inference_library: pymc inference_library_version: 5.28.2
LOO computation unstable and p_loo is greater than number of predictors, which suggests normal distribution is not good for this data.
Now try log scale
mesquite['log_weight'] = np.log(mesquite['weight'])
mesquite['log_diam1'] = np.log(mesquite['diam1'])
mesquite['log_diam2'] = np.log(mesquite['diam2'])
mesquite['log_canopy_height'] = np.log(mesquite['canopy_height'])
mesquite['log_total_height'] = np.log(mesquite['total_height'])
mesquite['log_density'] = np.log(mesquite['density'])
fit_and_plot_bayes(mesquite, ['log_diam1', 'log_diam2', 'log_canopy_height', 'log_total_height', 'log_density', 'group'], 'log_weight',
intercept_mu=0, intercept_sigma=2000,
slope_mu=0, slope_sigma=1000,
sigma_sigma=50,
samples=2000, tune=1000, hdi_prob=0.95,
show_trace=True, show_forest=False,
show_posterior=False, show_regression=False,
show_residuals=False,
n_regression_lines=100)
Initializing NUTS using jitter+adapt_diag... Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_log_diam1, slope_log_diam2, slope_log_canopy_height, slope_log_total_height, slope_log_density, slope_group, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 2 seconds.
mean sd hdi_2.5% hdi_97.5% mcse_mean \
intercept 4.769 0.164 4.440 5.075 0.003
slope_log_diam1 0.392 0.300 -0.195 0.980 0.005
slope_log_diam2 1.154 0.219 0.713 1.570 0.004
slope_log_canopy_height 0.373 0.291 -0.207 0.932 0.004
slope_log_total_height 0.393 0.326 -0.267 0.996 0.005
slope_log_density 0.108 0.128 -0.128 0.377 0.002
slope_group 0.584 0.135 0.314 0.840 0.002
sigma 0.341 0.040 0.269 0.422 0.001
mcse_sd ess_bulk ess_tail r_hat
intercept 0.002 4151.0 4767.0 1.0
slope_log_diam1 0.004 3582.0 4006.0 1.0
slope_log_diam2 0.003 3763.0 4406.0 1.0
slope_log_canopy_height 0.003 4415.0 5252.0 1.0
slope_log_total_height 0.004 4443.0 5188.0 1.0
slope_log_density 0.001 5025.0 5045.0 1.0
slope_group 0.002 3905.0 4926.0 1.0
sigma 0.000 5287.0 3915.0 1.0
Regression formula: log_weight = 4.77 + 0.39*log_diam1 + 1.15*log_diam2 + 0.37*log_canopy_height + 0.39*log_total_height + 0.11*log_density + 0.58*group
Residual std dev (σ): 0.34 ± 0.04
Bayesian R²: 0.872 ± 0.016
LOO-ELPD: -19.51 ± 5.28 (p_loo=7.7) <- should be lower than number of parameters (8) for reliable estimates
When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE.
LOO log score (per obs): -0.424 ± 0.115
Warning: 1 observations with Pareto-k > 0.7 (unreliable LOO estimates)
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/arviz/stats/stats.py:782: UserWarning: Estimated shape parameter of Pareto distribution is greater than 0.70 for one or more samples. You should consider using a more robust model, this is because importance sampling is less likely to work well if the marginal posterior and LOO posterior are very different. This is more likely to happen with a non-robust model and highly influential observations. warnings.warn(
-
<xarray.Dataset> Size: 528kB Dimensions: (chain: 4, draw: 2000) Coordinates: * chain (chain) int64 32B 0 1 2 3 * draw (draw) int64 16kB 0 1 2 3 4 ... 1996 1997 1998 1999 Data variables: intercept (chain, draw) float64 64kB 4.523 4.791 ... 4.823 slope_log_diam1 (chain, draw) float64 64kB 0.3947 0.08292 ... 0.453 slope_log_diam2 (chain, draw) float64 64kB 1.224 1.423 ... 1.043 slope_log_canopy_height (chain, draw) float64 64kB 0.702 0.2439 ... 0.5961 slope_log_total_height (chain, draw) float64 64kB 0.1876 0.3326 ... 0.1526 slope_log_density (chain, draw) float64 64kB 0.2322 0.1079 ... 0.1981 slope_group (chain, draw) float64 64kB 0.7947 0.7048 ... 0.6119 sigma (chain, draw) float64 64kB 0.4057 0.3209 ... 0.3171 Attributes: created_at: 2026-06-03T22:04:35.866843+00:00 arviz_version: 0.23.4 inference_library: pymc inference_library_version: 5.28.2 sampling_time: 2.322632074356079 tuning_steps: 1000 -
<xarray.Dataset> Size: 3MB Dimensions: (chain: 4, draw: 2000, y_dim_0: 46) Coordinates: * chain (chain) int64 32B 0 1 2 3 * draw (draw) int64 16kB 0 1 2 3 4 5 6 ... 1994 1995 1996 1997 1998 1999 * y_dim_0 (y_dim_0) int64 368B 0 1 2 3 4 5 6 7 8 ... 38 39 40 41 42 43 44 45 Data variables: y (chain, draw, y_dim_0) float64 3MB -0.17 -0.04161 ... -2.422 Attributes: created_at: 2026-06-03T22:04:36.119808+00:00 arviz_version: 0.23.4 inference_library: pymc inference_library_version: 5.28.2 -
<xarray.Dataset> Size: 1MB Dimensions: (chain: 4, draw: 2000) Coordinates: * chain (chain) int64 32B 0 1 2 3 * draw (draw) int64 16kB 0 1 2 3 4 ... 1996 1997 1998 1999 Data variables: (12/18) energy (chain, draw) float64 64kB 83.64 80.83 ... 80.59 divergences (chain, draw) int64 64kB 0 0 0 0 0 0 ... 0 0 0 0 0 0 acceptance_rate (chain, draw) float64 64kB 0.8935 0.9497 ... 0.5642 step_size (chain, draw) float64 64kB 0.2003 0.2003 ... 0.246 lp (chain, draw) float64 64kB -77.77 -74.65 ... -72.6 step_size_bar (chain, draw) float64 64kB 0.2122 0.2122 ... 0.1969 ... ... tree_depth (chain, draw) int64 64kB 3 4 4 5 4 4 ... 5 5 4 4 4 4 energy_error (chain, draw) float64 64kB 0.1166 0.05265 ... 0.1894 perf_counter_start (chain, draw) float64 64kB 405.0 405.0 ... 406.5 perf_counter_diff (chain, draw) float64 64kB 0.0002503 ... 0.0004614 index_in_trajectory (chain, draw) int64 64kB 7 9 -4 -2 ... 11 -15 8 -8 reached_max_treedepth (chain, draw) bool 8kB False False ... False False Attributes: created_at: 2026-06-03T22:04:35.880251+00:00 arviz_version: 0.23.4 inference_library: pymc inference_library_version: 5.28.2 sampling_time: 2.322632074356079 tuning_steps: 1000 -
<xarray.Dataset> Size: 736B Dimensions: (y_dim_0: 46) Coordinates: * y_dim_0 (y_dim_0) int64 368B 0 1 2 3 4 5 6 7 8 ... 38 39 40 41 42 43 44 45 Data variables: y (y_dim_0) float64 368B 5.995 6.242 7.073 5.73 ... 7.465 6.811 5.364 Attributes: created_at: 2026-06-03T22:04:35.883937+00:00 arviz_version: 0.23.4 inference_library: pymc inference_library_version: 5.28.2
ELPD for two models: -341 and -19 are not comparable as one was transformed. When comparing models with transformed continuous outcomes, we must account for the transformation.
# Re-fit both models (no plots) to get traces for comparison
trace_raw = fit_and_plot_bayes(
mesquite, ['diam1', 'diam2', 'canopy_height', 'total_height', 'density', 'group'], 'weight',
intercept_mu=0, intercept_sigma=2000, slope_mu=0, slope_sigma=1000, sigma_sigma=50,
samples=2000, tune=1000,
show_trace=False, show_forest=False, show_posterior=False, show_regression=False, show_residuals=False
)
trace_log = fit_and_plot_bayes(
mesquite, ['log_diam1', 'log_diam2', 'log_canopy_height', 'log_total_height', 'log_density', 'group'], 'log_weight',
intercept_mu=0, intercept_sigma=2000, slope_mu=0, slope_sigma=1000, sigma_sigma=50,
samples=2000, tune=1000,
show_trace=False, show_forest=False, show_posterior=False, show_regression=False, show_residuals=False
)
# Pointwise LOO for both models
loo_raw = az.loo(trace_raw, pointwise=True)
loo_log = az.loo(trace_log, pointwise=True)
# Jacobian adjustment: outcome is log(weight), so log|Jacobian| = log(1/weight) = -log(weight)
# Subtract log(weight) from each pointwise ELPD to put the log model on the original scale
loo_log_jacobian = loo_log.copy()
loo_log_jacobian.loo_i.values -= np.log(mesquite['weight'].values)
n = len(loo_log_jacobian.loo_i)
loo_log_jacobian["elpd_loo"] = float(loo_log_jacobian.loo_i.sum())
loo_log_jacobian["se"] = float((n * loo_log_jacobian.loo_i.var()) ** 0.5)
print(f"LOO raw model: {loo_raw.elpd_loo:.1f} ± {loo_raw.se:.1f}")
print(f"LOO log model (unadjusted): {loo_log.elpd_loo:.1f} ± {loo_log.se:.1f}")
print(f"LOO log model (adjusted): {loo_log_jacobian['elpd_loo']:.1f} ± {loo_log_jacobian['se']:.1f}")
print()
az.compare({"raw": loo_raw, "log_jacobian": loo_log_jacobian})
Initializing NUTS using jitter+adapt_diag... Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_diam1, slope_diam2, slope_canopy_height, slope_total_height, slope_density, slope_group, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 5 seconds.
mean sd hdi_2.5% hdi_97.5% mcse_mean \
intercept -1083.922 145.081 -1367.901 -803.578 1.924
slope_diam1 191.674 93.663 15.446 384.091 1.449
slope_diam2 367.219 101.872 166.836 563.528 1.505
slope_canopy_height 339.323 169.671 -6.565 659.120 2.500
slope_total_height -90.681 150.924 -377.898 210.347 2.071
slope_density 131.169 28.898 72.864 187.342 0.351
slope_group 357.164 81.415 184.371 501.971 1.141
sigma 222.602 18.064 188.044 258.261 0.228
mcse_sd ess_bulk ess_tail r_hat
intercept 1.579 5686.0 5086.0 1.0
slope_diam1 1.119 4175.0 4620.0 1.0
slope_diam2 1.164 4555.0 5456.0 1.0
slope_canopy_height 1.821 4607.0 4799.0 1.0
slope_total_height 1.643 5318.0 5180.0 1.0
slope_density 0.344 6770.0 5646.0 1.0
slope_group 0.835 5093.0 5458.0 1.0
sigma 0.215 6198.0 5051.0 1.0
Regression formula: weight = -1083.92 + 191.67*diam1 + 367.22*diam2 + 339.32*canopy_height + -90.68*total_height + 131.17*density + 357.16*group
Residual std dev (σ): 222.60 ± 18.06
Bayesian R²: 0.836 ± 0.017
LOO-ELPD: -339.97 ± 19.69 (p_loo=24.0) <- should be lower than number of parameters (8) for reliable estimates
When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE.
LOO log score (per obs): -7.391 ± 0.428
Warning: 2 observations with Pareto-k > 0.7 (unreliable LOO estimates)
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/arviz/stats/stats.py:782: UserWarning: Estimated shape parameter of Pareto distribution is greater than 0.70 for one or more samples. You should consider using a more robust model, this is because importance sampling is less likely to work well if the marginal posterior and LOO posterior are very different. This is more likely to happen with a non-robust model and highly influential observations. warnings.warn( Initializing NUTS using jitter+adapt_diag... Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_log_diam1, slope_log_diam2, slope_log_canopy_height, slope_log_total_height, slope_log_density, slope_group, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 2 seconds.
mean sd hdi_2.5% hdi_97.5% mcse_mean \
intercept 4.765 0.159 4.453 5.068 0.002
slope_log_diam1 0.397 0.294 -0.189 0.966 0.005
slope_log_diam2 1.147 0.218 0.738 1.599 0.003
slope_log_canopy_height 0.370 0.291 -0.183 0.952 0.005
slope_log_total_height 0.398 0.331 -0.231 1.070 0.005
slope_log_density 0.110 0.128 -0.141 0.360 0.002
slope_group 0.583 0.131 0.336 0.846 0.002
sigma 0.341 0.040 0.268 0.421 0.001
mcse_sd ess_bulk ess_tail r_hat
intercept 0.002 4308.0 5360.0 1.0
slope_log_diam1 0.004 4011.0 4717.0 1.0
slope_log_diam2 0.003 3985.0 4273.0 1.0
slope_log_canopy_height 0.003 4119.0 4709.0 1.0
slope_log_total_height 0.004 4517.0 4837.0 1.0
slope_log_density 0.001 5694.0 4832.0 1.0
slope_group 0.001 4625.0 4995.0 1.0
sigma 0.001 5092.0 4807.0 1.0
Regression formula: log_weight = 4.77 + 0.40*log_diam1 + 1.15*log_diam2 + 0.37*log_canopy_height + 0.40*log_total_height + 0.11*log_density + 0.58*group
Residual std dev (σ): 0.34 ± 0.04
Bayesian R²: 0.872 ± 0.016
LOO-ELPD: -19.29 ± 5.22 (p_loo=7.4) <- should be lower than number of parameters (8) for reliable estimates
When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE.
LOO log score (per obs): -0.419 ± 0.113
Warning: 1 observations with Pareto-k > 0.7 (unreliable LOO estimates)
LOO raw model: -340.0 ± 19.7
LOO log model (unadjusted): -19.3 ± 5.2
LOO log model (adjusted): -291.6 ± 7.2
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/arviz/stats/stats.py:782: UserWarning: Estimated shape parameter of Pareto distribution is greater than 0.70 for one or more samples. You should consider using a more robust model, this is because importance sampling is less likely to work well if the marginal posterior and LOO posterior are very different. This is more likely to happen with a non-robust model and highly influential observations. warnings.warn( /opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/arviz/stats/stats.py:782: UserWarning: Estimated shape parameter of Pareto distribution is greater than 0.70 for one or more samples. You should consider using a more robust model, this is because importance sampling is less likely to work well if the marginal posterior and LOO posterior are very different. This is more likely to happen with a non-robust model and highly influential observations. warnings.warn( /opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/arviz/stats/stats.py:782: UserWarning: Estimated shape parameter of Pareto distribution is greater than 0.70 for one or more samples. You should consider using a more robust model, this is because importance sampling is less likely to work well if the marginal posterior and LOO posterior are very different. This is more likely to happen with a non-robust model and highly influential observations. warnings.warn(
| rank | elpd_loo | p_loo | elpd_diff | weight | se | dse | warning | scale | |
|---|---|---|---|---|---|---|---|---|---|
| log_jacobian | 0 | -291.589546 | 7.429382 | 0.000000 | 1.000000e+00 | 7.207880 | 0.000000 | True | log |
| raw | 1 | -339.967081 | 23.965311 | 48.377535 | 6.189493e-13 | 19.693817 | 17.334181 | True | log |
ELPD difference is 46, which means that the model with log transformation has a better fit to the data than the model without log transformation.
def posterior_predict(trace, data, predictors):
post = trace.posterior
intercept = post["intercept"].values.flatten()
slopes = np.column_stack([post[f"slope_{p}"].values.flatten() for p in predictors])
sigma = post["sigma"].values.flatten()
X = data[predictors].values
mu = intercept[:, None] + slopes @ X.T # (n_draws, n_obs)
return np.random.default_rng(42).normal(mu, sigma[:, None])
preds_1 = ['diam1', 'diam2', 'canopy_height', 'total_height', 'density', 'group']
preds_2 = ['log_diam1', 'log_diam2', 'log_canopy_height', 'log_total_height', 'log_density', 'group']
yrep_1 = posterior_predict(trace_raw, mesquite, preds_1)
yrep_2 = posterior_predict(trace_log, mesquite, preds_2)
n_sims = yrep_1.shape[0]
subset = np.random.default_rng(0).choice(n_sims, 100, replace=False)
# Model 1: raw weight — equivalent of ppc_dens_overlay(mesquite$weight, yrep_1[subset,])
idata_1 = az.from_dict(
observed_data={"y": mesquite["weight"].values},
posterior_predictive={"y": yrep_1[np.newaxis, subset, :]}
)
az.plot_ppc(idata_1, num_pp_samples=100, kind="kde")
plt.title("PPC: weight (raw scale)")
plt.show()
# Model 2: log weight — equivalent of ppc_dens_overlay(log(mesquite$weight), yrep_2[subset,])
idata_2 = az.from_dict(
observed_data={"y": np.log(mesquite["weight"].values)},
posterior_predictive={"y": yrep_2[np.newaxis, subset, :]}
)
az.plot_ppc(idata_2, num_pp_samples=100, kind="kde")
plt.title("PPC: log(weight)")
plt.show()
Constructing a simpler model¶
Thinking about a simpler model which makes sense and has good predictive performance by cross validation. Thinking geometrically, leaf weight can be predicted from volume of the leaf canopy:
canopy_volume = diam1 * diam2 * canopy_height
Simplification, as leaves are mostly on outer surface of canopy, not inside.
mesquite['canopy_volume'] = mesquite['diam1'] * mesquite['diam2'] * mesquite['canopy_height']
mesquite['log_canopy_volume'] = np.log(mesquite['canopy_volume'])
fit_and_plot_bayes(mesquite, ['log_canopy_volume'], 'log_weight',
intercept_mu=0, intercept_sigma=2000,
slope_mu=0, slope_sigma=1000,
sigma_sigma=50,
samples=2000, tune=1000, hdi_prob=0.95,
show_trace=True, show_forest=False,
show_posterior=False, show_regression=True,
show_residuals=False,
n_regression_lines=100)
Initializing NUTS using jitter+adapt_diag... Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_log_canopy_volume, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 1 seconds.
mean sd hdi_2.5% hdi_97.5% mcse_mean \
intercept 5.169 0.085 5.000 5.334 0.001
slope_log_canopy_volume 0.722 0.057 0.610 0.833 0.001
sigma 0.427 0.047 0.343 0.523 0.001
mcse_sd ess_bulk ess_tail r_hat
intercept 0.001 3722.0 4660.0 1.0
slope_log_canopy_volume 0.001 3767.0 4540.0 1.0
sigma 0.001 5590.0 4961.0 1.0
Regression formula: log_weight = 5.17 + 0.72*log_canopy_volume
Residual std dev (σ): 0.43 ± 0.05
Bayesian R²: 0.793 ± 0.029
LOO-ELPD: -26.64 ± 4.92 (p_loo=2.5) <- should be lower than number of parameters (3) for reliable estimates
When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE.
LOO log score (per obs): -0.579 ± 0.107
-
<xarray.Dataset> Size: 208kB Dimensions: (chain: 4, draw: 2000) Coordinates: * chain (chain) int64 32B 0 1 2 3 * draw (draw) int64 16kB 0 1 2 3 4 ... 1996 1997 1998 1999 Data variables: intercept (chain, draw) float64 64kB 5.132 5.135 ... 5.072 slope_log_canopy_volume (chain, draw) float64 64kB 0.769 0.6727 ... 0.7685 sigma (chain, draw) float64 64kB 0.4048 0.4599 ... 0.382 Attributes: created_at: 2026-06-03T22:04:47.975425+00:00 arviz_version: 0.23.4 inference_library: pymc inference_library_version: 5.28.2 sampling_time: 0.5926599502563477 tuning_steps: 1000 -
<xarray.Dataset> Size: 3MB Dimensions: (chain: 4, draw: 2000, y_dim_0: 46) Coordinates: * chain (chain) int64 32B 0 1 2 3 * draw (draw) int64 16kB 0 1 2 3 4 5 6 ... 1994 1995 1996 1997 1998 1999 * y_dim_0 (y_dim_0) int64 368B 0 1 2 3 4 5 6 7 8 ... 38 39 40 41 42 43 44 45 Data variables: y (chain, draw, y_dim_0) float64 3MB -0.2953 -0.2077 ... -0.7296 Attributes: created_at: 2026-06-03T22:04:48.112376+00:00 arviz_version: 0.23.4 inference_library: pymc inference_library_version: 5.28.2 -
<xarray.Dataset> Size: 1MB Dimensions: (chain: 4, draw: 2000) Coordinates: * chain (chain) int64 32B 0 1 2 3 * draw (draw) int64 16kB 0 1 2 3 4 ... 1996 1997 1998 1999 Data variables: (12/18) energy (chain, draw) float64 64kB 46.89 47.81 ... 47.77 47.7 divergences (chain, draw) int64 64kB 0 0 0 0 0 0 ... 0 0 0 0 0 0 acceptance_rate (chain, draw) float64 64kB 1.0 0.7113 ... 0.9353 step_size (chain, draw) float64 64kB 0.7811 0.7811 ... 0.8513 lp (chain, draw) float64 64kB -45.47 -46.78 ... -46.1 step_size_bar (chain, draw) float64 64kB 0.7117 0.7117 ... 0.775 ... ... tree_depth (chain, draw) int64 64kB 2 3 2 3 2 3 ... 2 3 3 2 2 3 energy_error (chain, draw) float64 64kB -0.2149 ... -0.03314 perf_counter_start (chain, draw) float64 64kB 418.2 418.2 ... 418.6 perf_counter_diff (chain, draw) float64 64kB 9.192e-05 ... 0.0001731 index_in_trajectory (chain, draw) int64 64kB 3 -6 3 2 1 ... -4 -2 -1 3 -2 reached_max_treedepth (chain, draw) bool 8kB False False ... False False Attributes: created_at: 2026-06-03T22:04:47.989819+00:00 arviz_version: 0.23.4 inference_library: pymc inference_library_version: 5.28.2 sampling_time: 0.5926599502563477 tuning_steps: 1000 -
<xarray.Dataset> Size: 736B Dimensions: (y_dim_0: 46) Coordinates: * y_dim_0 (y_dim_0) int64 368B 0 1 2 3 4 5 6 7 8 ... 38 39 40 41 42 43 44 45 Data variables: y (y_dim_0) float64 368B 5.995 6.242 7.073 5.73 ... 7.465 6.811 5.364 Attributes: created_at: 2026-06-03T22:04:47.993748+00:00 arviz_version: 0.23.4 inference_library: pymc inference_library_version: 5.28.2
Model has 0.7*log_canopy_volume, which suggests that this is attenuating the relationship between canopy volume and leaf weight, which could be due to the fact that leaves are mostly on the outer surface of the canopy, rather than being distributed throughout the entire volume.
# Re-fit both models (no plots) to get traces for comparison
trace_log1 = fit_and_plot_bayes(
mesquite, ['log_diam1', 'log_diam2', 'log_canopy_height', 'log_total_height', 'log_density', 'group'], 'log_weight',
intercept_mu=0, intercept_sigma=50, slope_mu=0, slope_sigma=50, sigma_sigma=50,
samples=2000, tune=1000,
show_trace=False, show_forest=False, show_posterior=False, show_regression=False, show_residuals=False
)
trace_log2 = fit_and_plot_bayes(
mesquite, ['log_canopy_volume'], 'log_weight',
intercept_mu=0, intercept_sigma=50, slope_mu=0, slope_sigma=50, sigma_sigma=50,
samples=2000, tune=1000,
show_trace=False, show_forest=False, show_posterior=False, show_regression=False, show_residuals=False
)
# Pointwise LOO for both models
loo_log1 = az.loo(trace_log1, pointwise=True)
loo_log2 = az.loo(trace_log2, pointwise=True)
print(f"LOO log1 model: {loo_log1.elpd_loo:.1f} ± {loo_log1.se:.1f}")
print(f"LOO log2 model: {loo_log2.elpd_loo:.1f} ± {loo_log2.se:.1f}")
az.compare({"log1": loo_log1, "log2": loo_log2})
Initializing NUTS using jitter+adapt_diag... Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_log_diam1, slope_log_diam2, slope_log_canopy_height, slope_log_total_height, slope_log_density, slope_group, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 2 seconds. Initializing NUTS using jitter+adapt_diag...
mean sd hdi_2.5% hdi_97.5% mcse_mean \
intercept 4.771 0.161 4.434 5.066 0.003
slope_log_diam1 0.391 0.290 -0.170 0.947 0.005
slope_log_diam2 1.153 0.218 0.737 1.588 0.003
slope_log_canopy_height 0.373 0.293 -0.219 0.927 0.004
slope_log_total_height 0.396 0.331 -0.229 1.078 0.005
slope_log_density 0.107 0.128 -0.141 0.353 0.002
slope_group 0.581 0.136 0.306 0.838 0.002
sigma 0.340 0.040 0.265 0.416 0.001
mcse_sd ess_bulk ess_tail r_hat
intercept 0.002 4045.0 4626.0 1.0
slope_log_diam1 0.003 3420.0 4692.0 1.0
slope_log_diam2 0.002 3923.0 5300.0 1.0
slope_log_canopy_height 0.003 4665.0 5013.0 1.0
slope_log_total_height 0.004 4780.0 5491.0 1.0
slope_log_density 0.001 5748.0 5472.0 1.0
slope_group 0.002 4717.0 4418.0 1.0
sigma 0.000 5644.0 5303.0 1.0
Regression formula: log_weight = 4.77 + 0.39*log_diam1 + 1.15*log_diam2 + 0.37*log_canopy_height + 0.40*log_total_height + 0.11*log_density + 0.58*group
Residual std dev (σ): 0.34 ± 0.04
Bayesian R²: 0.872 ± 0.017
LOO-ELPD: -19.38 ± 5.24 (p_loo=7.5) <- should be lower than number of parameters (8) for reliable estimates
When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE.
LOO log score (per obs): -0.421 ± 0.114
Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_log_canopy_volume, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 1 seconds.
mean sd hdi_2.5% hdi_97.5% mcse_mean \
intercept 5.169 0.087 4.996 5.339 0.001
slope_log_canopy_volume 0.723 0.057 0.615 0.834 0.001
sigma 0.427 0.048 0.340 0.525 0.001
mcse_sd ess_bulk ess_tail r_hat
intercept 0.001 4469.0 4673.0 1.0
slope_log_canopy_volume 0.001 3987.0 4588.0 1.0
sigma 0.001 5015.0 4170.0 1.0
Regression formula: log_weight = 5.17 + 0.72*log_canopy_volume
Residual std dev (σ): 0.43 ± 0.05
Bayesian R²: 0.793 ± 0.028
LOO-ELPD: -26.72 ± 4.96 (p_loo=2.6) <- should be lower than number of parameters (3) for reliable estimates
When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE.
LOO log score (per obs): -0.581 ± 0.108
LOO log1 model: -19.4 ± 5.2
LOO log2 model: -26.7 ± 5.0
| rank | elpd_loo | p_loo | elpd_diff | weight | se | dse | warning | scale | |
|---|---|---|---|---|---|---|---|---|---|
| log1 | 0 | -19.378336 | 7.508495 | 0.000000 | 0.839799 | 5.236462 | 0.000000 | False | log |
| log2 | 1 | -26.724994 | 2.643242 | 7.346658 | 0.160201 | 4.959957 | 4.885636 | False | log |
The simpler model has worse elpd_loo
We now try canopy area and canopy shape
mesquite['log_canopy_area'] = np.log(mesquite['diam1'] * mesquite['diam2'])
mesquite['log_canopy_shape'] = np.log(mesquite['diam1'] / mesquite['diam2'])
fit_and_plot_bayes(mesquite, ['log_canopy_area', 'log_canopy_shape', 'log_canopy_volume', 'log_total_height', 'log_density', 'group'], 'log_weight',
intercept_mu=0, intercept_sigma=2000,
slope_mu=0, slope_sigma=1000,
sigma_sigma=50,
samples=2000, tune=1000, hdi_prob=0.95,
show_trace=True, show_forest=False,
show_posterior=False, show_regression=True,
show_residuals=False,
n_regression_lines=100)
Initializing NUTS using jitter+adapt_diag... Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_log_canopy_area, slope_log_canopy_shape, slope_log_canopy_volume, slope_log_total_height, slope_log_density, slope_group, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 4 seconds. There was 1 divergence after tuning. Increase `target_accept` or reparameterize. /opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/arviz/stats/stats.py:782: UserWarning: Estimated shape parameter of Pareto distribution is greater than 0.70 for one or more samples. You should consider using a more robust model, this is because importance sampling is less likely to work well if the marginal posterior and LOO posterior are very different. This is more likely to happen with a non-robust model and highly influential observations. warnings.warn(
mean sd hdi_2.5% hdi_97.5% mcse_mean \
intercept 4.765 0.163 4.445 5.090 0.002
slope_log_canopy_area 0.403 0.309 -0.220 0.990 0.006
slope_log_canopy_shape -0.375 0.241 -0.848 0.109 0.003
slope_log_canopy_volume 0.370 0.295 -0.187 0.973 0.005
slope_log_total_height 0.399 0.333 -0.272 1.044 0.005
slope_log_density 0.108 0.126 -0.137 0.356 0.002
slope_group 0.584 0.134 0.329 0.857 0.002
sigma 0.341 0.040 0.266 0.422 0.001
mcse_sd ess_bulk ess_tail r_hat
intercept 0.002 4296.0 5050.0 1.0
slope_log_canopy_area 0.004 3123.0 4144.0 1.0
slope_log_canopy_shape 0.003 5015.0 5200.0 1.0
slope_log_canopy_volume 0.004 3111.0 3887.0 1.0
slope_log_total_height 0.004 4158.0 4759.0 1.0
slope_log_density 0.001 6285.0 6027.0 1.0
slope_group 0.002 4936.0 4847.0 1.0
sigma 0.001 5010.0 4610.0 1.0
Regression formula: log_weight = 4.76 + 0.40*log_canopy_area + -0.37*log_canopy_shape + 0.37*log_canopy_volume + 0.40*log_total_height + 0.11*log_density + 0.58*group
Residual std dev (σ): 0.34 ± 0.04
Bayesian R²: 0.872 ± 0.017
LOO-ELPD: -19.58 ± 5.26 (p_loo=7.7) <- should be lower than number of parameters (8) for reliable estimates
When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE.
LOO log score (per obs): -0.426 ± 0.114
Warning: 1 observations with Pareto-k > 0.7 (unreliable LOO estimates)
-
<xarray.Dataset> Size: 528kB Dimensions: (chain: 4, draw: 2000) Coordinates: * chain (chain) int64 32B 0 1 2 3 * draw (draw) int64 16kB 0 1 2 3 4 ... 1996 1997 1998 1999 Data variables: intercept (chain, draw) float64 64kB 4.756 4.884 ... 4.55 slope_log_canopy_area (chain, draw) float64 64kB 0.3663 0.1098 ... 0.5732 slope_log_canopy_shape (chain, draw) float64 64kB -0.6298 ... -0.2926 slope_log_canopy_volume (chain, draw) float64 64kB 0.3932 0.7007 ... 0.1585 slope_log_total_height (chain, draw) float64 64kB 0.5352 ... 0.5985 slope_log_density (chain, draw) float64 64kB 0.05741 ... 0.3869 slope_group (chain, draw) float64 64kB 0.6184 0.7657 ... 0.7324 sigma (chain, draw) float64 64kB 0.3303 0.3589 ... 0.299 Attributes: created_at: 2026-06-03T22:04:57.708457+00:00 arviz_version: 0.23.4 inference_library: pymc inference_library_version: 5.28.2 sampling_time: 3.8649630546569824 tuning_steps: 1000 -
<xarray.Dataset> Size: 3MB Dimensions: (chain: 4, draw: 2000, y_dim_0: 46) Coordinates: * chain (chain) int64 32B 0 1 2 3 * draw (draw) int64 16kB 0 1 2 3 4 5 6 ... 1994 1995 1996 1997 1998 1999 * y_dim_0 (y_dim_0) int64 368B 0 1 2 3 4 5 6 7 8 ... 38 39 40 41 42 43 44 45 Data variables: y (chain, draw, y_dim_0) float64 3MB -0.01169 0.135 ... -3.608 Attributes: created_at: 2026-06-03T22:04:58.089608+00:00 arviz_version: 0.23.4 inference_library: pymc inference_library_version: 5.28.2 -
<xarray.Dataset> Size: 1MB Dimensions: (chain: 4, draw: 2000) Coordinates: * chain (chain) int64 32B 0 1 2 3 * draw (draw) int64 16kB 0 1 2 3 4 ... 1996 1997 1998 1999 Data variables: (12/18) energy (chain, draw) float64 64kB 79.19 80.82 ... 82.83 divergences (chain, draw) int64 64kB 0 0 0 0 0 0 ... 0 0 0 0 0 0 acceptance_rate (chain, draw) float64 64kB 0.9858 0.9807 ... 0.6856 step_size (chain, draw) float64 64kB 0.121 0.121 ... 0.08538 lp (chain, draw) float64 64kB -73.5 -76.51 ... -75.92 step_size_bar (chain, draw) float64 64kB 0.1227 0.1227 ... 0.1034 ... ... tree_depth (chain, draw) int64 64kB 5 6 3 6 5 5 ... 5 5 6 5 6 6 energy_error (chain, draw) float64 64kB -0.06507 ... -1.183 perf_counter_start (chain, draw) float64 64kB 425.7 425.7 ... 428.3 perf_counter_diff (chain, draw) float64 64kB 0.0007213 ... 0.00198 index_in_trajectory (chain, draw) int64 64kB -8 -11 -5 -12 ... 24 12 -9 reached_max_treedepth (chain, draw) bool 8kB False False ... False False Attributes: created_at: 2026-06-03T22:04:57.722565+00:00 arviz_version: 0.23.4 inference_library: pymc inference_library_version: 5.28.2 sampling_time: 3.8649630546569824 tuning_steps: 1000 -
<xarray.Dataset> Size: 736B Dimensions: (y_dim_0: 46) Coordinates: * y_dim_0 (y_dim_0) int64 368B 0 1 2 3 4 5 6 7 8 ... 38 39 40 41 42 43 44 45 Data variables: y (y_dim_0) float64 368B 5.995 6.242 7.073 5.73 ... 7.465 6.811 5.364 Attributes: created_at: 2026-06-03T22:04:57.726634+00:00 arviz_version: 0.23.4 inference_library: pymc inference_library_version: 5.28.2
# Re-fit both models (no plots) to get traces for comparison
trace_log4 = fit_and_plot_bayes(
mesquite, ['log_diam1', 'log_diam2', 'log_canopy_height', 'log_total_height', 'log_density', 'group'], 'log_weight',
intercept_mu=0, intercept_sigma=50, slope_mu=0, slope_sigma=50, sigma_sigma=50,
samples=2000, tune=1000,
show_trace=False, show_forest=False, show_posterior=False, show_regression=False, show_residuals=False
)
trace_log3 = fit_and_plot_bayes(
mesquite, ['log_canopy_area', 'log_canopy_shape', 'log_canopy_volume', 'log_total_height', 'log_density', 'group'], 'log_weight',
intercept_mu=0, intercept_sigma=50, slope_mu=0, slope_sigma=50, sigma_sigma=50,
samples=2000, tune=1000,
show_trace=False, show_forest=False, show_posterior=False, show_regression=False, show_residuals=False
)
# Pointwise LOO for both models
loo_log4 = az.loo(trace_log4, pointwise=True)
loo_log3 = az.loo(trace_log3, pointwise=True)
print(f"LOO log4 model: {loo_log4.elpd_loo:.1f} ± {loo_log4.se:.1f}")
print(f"LOO log3 model: {loo_log3.elpd_loo:.1f} ± {loo_log3.se:.1f}")
az.compare({"log4": loo_log4, "log3": loo_log3})
Initializing NUTS using jitter+adapt_diag... Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_log_diam1, slope_log_diam2, slope_log_canopy_height, slope_log_total_height, slope_log_density, slope_group, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 2 seconds.
mean sd hdi_2.5% hdi_97.5% mcse_mean \
intercept 4.770 0.161 4.453 5.086 0.002
slope_log_diam1 0.393 0.292 -0.154 0.981 0.005
slope_log_diam2 1.151 0.220 0.714 1.579 0.003
slope_log_canopy_height 0.375 0.291 -0.208 0.931 0.004
slope_log_total_height 0.392 0.324 -0.221 1.043 0.005
slope_log_density 0.109 0.126 -0.145 0.353 0.002
slope_group 0.581 0.135 0.313 0.837 0.002
sigma 0.340 0.040 0.263 0.418 0.001
mcse_sd ess_bulk ess_tail r_hat
intercept 0.002 4653.0 4929.0 1.0
slope_log_diam1 0.003 3795.0 4639.0 1.0
slope_log_diam2 0.002 3986.0 5022.0 1.0
slope_log_canopy_height 0.003 5026.0 5314.0 1.0
slope_log_total_height 0.004 5143.0 4905.0 1.0
slope_log_density 0.002 5318.0 5429.0 1.0
slope_group 0.002 5056.0 4661.0 1.0
sigma 0.001 4733.0 4727.0 1.0
Regression formula: log_weight = 4.77 + 0.39*log_diam1 + 1.15*log_diam2 + 0.37*log_canopy_height + 0.39*log_total_height + 0.11*log_density + 0.58*group
Residual std dev (σ): 0.34 ± 0.04
Bayesian R²: 0.872 ± 0.017
LOO-ELPD: -19.42 ± 5.25 (p_loo=7.6) <- should be lower than number of parameters (8) for reliable estimates
When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE.
LOO log score (per obs): -0.422 ± 0.114
Warning: 1 observations with Pareto-k > 0.7 (unreliable LOO estimates)
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/arviz/stats/stats.py:782: UserWarning: Estimated shape parameter of Pareto distribution is greater than 0.70 for one or more samples. You should consider using a more robust model, this is because importance sampling is less likely to work well if the marginal posterior and LOO posterior are very different. This is more likely to happen with a non-robust model and highly influential observations. warnings.warn( Initializing NUTS using jitter+adapt_diag... Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_log_canopy_area, slope_log_canopy_shape, slope_log_canopy_volume, slope_log_total_height, slope_log_density, slope_group, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 4 seconds.
mean sd hdi_2.5% hdi_97.5% mcse_mean \
intercept 4.765 0.163 4.444 5.086 0.003
slope_log_canopy_area 0.399 0.302 -0.185 0.999 0.006
slope_log_canopy_shape -0.377 0.240 -0.840 0.102 0.004
slope_log_canopy_volume 0.373 0.289 -0.170 0.941 0.006
slope_log_total_height 0.398 0.319 -0.247 1.006 0.005
slope_log_density 0.109 0.129 -0.163 0.342 0.002
slope_group 0.586 0.133 0.325 0.843 0.002
sigma 0.340 0.040 0.268 0.421 0.001
mcse_sd ess_bulk ess_tail r_hat
intercept 0.002 3753.0 4616.0 1.0
slope_log_canopy_area 0.004 2607.0 4014.0 1.0
slope_log_canopy_shape 0.003 3911.0 5151.0 1.0
slope_log_canopy_volume 0.003 2695.0 3899.0 1.0
slope_log_total_height 0.003 3793.0 5072.0 1.0
slope_log_density 0.002 5193.0 4765.0 1.0
slope_group 0.002 4951.0 5426.0 1.0
sigma 0.000 5260.0 4840.0 1.0
Regression formula: log_weight = 4.76 + 0.40*log_canopy_area + -0.38*log_canopy_shape + 0.37*log_canopy_volume + 0.40*log_total_height + 0.11*log_density + 0.59*group
Residual std dev (σ): 0.34 ± 0.04
Bayesian R²: 0.872 ± 0.017
LOO-ELPD: -19.43 ± 5.29 (p_loo=7.6) <- should be lower than number of parameters (8) for reliable estimates
When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE.
LOO log score (per obs): -0.422 ± 0.115
LOO log4 model: -19.4 ± 5.3
LOO log3 model: -19.4 ± 5.3
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/arviz/stats/stats.py:782: UserWarning: Estimated shape parameter of Pareto distribution is greater than 0.70 for one or more samples. You should consider using a more robust model, this is because importance sampling is less likely to work well if the marginal posterior and LOO posterior are very different. This is more likely to happen with a non-robust model and highly influential observations. warnings.warn(
| rank | elpd_loo | p_loo | elpd_diff | weight | se | dse | warning | scale | |
|---|---|---|---|---|---|---|---|---|---|
| log4 | 0 | -19.424221 | 7.569362 | 0.000000 | 0.918638 | 5.250184 | 0.000000 | True | log |
| log3 | 1 | -19.431993 | 7.613154 | 0.007771 | 0.081362 | 5.286802 | 0.136447 | False | log |
Regression formula: log_weight = 4.77 + 0.40log_canopy_area + -0.37log_canopy_shape + 0.37log_canopy_volume + 0.40log_total_height + 0.11log_density + 0.58group
Interpretation:
- canopy volume and area are positively associated with leaf weight - makes sense - larger canopy means more leaves, so more weight. However large SEs, likely due to collinearity between canopy area and volume, as they are both derived from diam1, diam2, and canopy_height.
- canopy shape is negatively associated with leaf weight - more spherical canopies have more leaves. Again large SE, likely due to collinearity.
- Total height is positively associated with leaf weight, which makes sense as taller bushes are likely to have more leaves. However, the SE is large, again, collinearity.
- Not clear how to interpret density, as no prior information to suggest important. Inclined to exclude.
- Group coefficient is large and SE small.
# Re-fit both models (no plots) to get traces for comparison
trace_log5 = fit_and_plot_bayes(
mesquite, ['log_canopy_shape', 'log_canopy_volume', 'group'], 'log_weight',
intercept_mu=0, intercept_sigma=50, slope_mu=0, slope_sigma=50, sigma_sigma=50,
samples=2000, tune=1000,
show_trace=False, show_forest=False, show_posterior=False, show_regression=False, show_residuals=False
)
trace_log3 = fit_and_plot_bayes(
mesquite, ['log_canopy_area', 'log_canopy_shape', 'log_canopy_volume', 'log_total_height', 'log_density', 'group'], 'log_weight',
intercept_mu=0, intercept_sigma=50, slope_mu=0, slope_sigma=50, sigma_sigma=50,
samples=2000, tune=1000,
show_trace=False, show_forest=False, show_posterior=False, show_regression=False, show_residuals=False
)
# Pointwise LOO for both models
loo_log5 = az.loo(trace_log5, pointwise=True)
loo_log3 = az.loo(trace_log3, pointwise=True)
print(f"LOO log5 model: {loo_log5.elpd_loo:.1f} ± {loo_log5.se:.1f}")
print(f"LOO log3 model: {loo_log3.elpd_loo:.1f} ± {loo_log3.se:.1f}")
az.compare({"log5": loo_log5, "log3": loo_log3})
Initializing NUTS using jitter+adapt_diag... Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_log_canopy_shape, slope_log_canopy_volume, slope_group, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 1 seconds.
mean sd hdi_2.5% hdi_97.5% mcse_mean \
intercept 4.882 0.139 4.611 5.161 0.002
slope_log_canopy_shape -0.380 0.222 -0.812 0.062 0.003
slope_log_canopy_volume 0.806 0.054 0.697 0.911 0.001
slope_group 0.585 0.122 0.339 0.817 0.002
sigma 0.341 0.039 0.267 0.416 0.001
mcse_sd ess_bulk ess_tail r_hat
intercept 0.002 3286.0 4017.0 1.0
slope_log_canopy_shape 0.003 4667.0 4790.0 1.0
slope_log_canopy_volume 0.001 3734.0 4773.0 1.0
slope_group 0.001 4087.0 5030.0 1.0
sigma 0.001 5656.0 4966.0 1.0
Regression formula: log_weight = 4.88 + -0.38*log_canopy_shape + 0.81*log_canopy_volume + 0.58*group
Residual std dev (σ): 0.34 ± 0.04
Bayesian R²: 0.870 ± 0.016
LOO-ELPD: -18.06 ± 5.29 (p_loo=5.4) <- should be lower than number of parameters (5) for reliable estimates
When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE.
LOO log score (per obs): -0.393 ± 0.115
Initializing NUTS using jitter+adapt_diag... Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_log_canopy_area, slope_log_canopy_shape, slope_log_canopy_volume, slope_log_total_height, slope_log_density, slope_group, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 4 seconds. There were 3 divergences after tuning. Increase `target_accept` or reparameterize.
mean sd hdi_2.5% hdi_97.5% mcse_mean \
intercept 4.768 0.160 4.439 5.072 0.003
slope_log_canopy_area 0.393 0.305 -0.220 0.971 0.006
slope_log_canopy_shape -0.376 0.235 -0.823 0.102 0.004
slope_log_canopy_volume 0.380 0.289 -0.164 0.959 0.005
slope_log_total_height 0.391 0.321 -0.204 1.051 0.005
slope_log_density 0.107 0.127 -0.137 0.361 0.002
slope_group 0.584 0.132 0.339 0.852 0.002
sigma 0.340 0.040 0.265 0.421 0.001
mcse_sd ess_bulk ess_tail r_hat
intercept 0.002 3940.0 5013.0 1.0
slope_log_canopy_area 0.004 2925.0 4045.0 1.0
slope_log_canopy_shape 0.003 4297.0 5047.0 1.0
slope_log_canopy_volume 0.004 2885.0 4012.0 1.0
slope_log_total_height 0.004 3798.0 4684.0 1.0
slope_log_density 0.002 5958.0 4782.0 1.0
slope_group 0.002 5135.0 5219.0 1.0
sigma 0.001 4921.0 3657.0 1.0
Regression formula: log_weight = 4.77 + 0.39*log_canopy_area + -0.38*log_canopy_shape + 0.38*log_canopy_volume + 0.39*log_total_height + 0.11*log_density + 0.58*group
Residual std dev (σ): 0.34 ± 0.04
Bayesian R²: 0.872 ± 0.016
LOO-ELPD: -19.29 ± 5.26 (p_loo=7.5) <- should be lower than number of parameters (8) for reliable estimates
When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE.
LOO log score (per obs): -0.419 ± 0.114
LOO log5 model: -18.1 ± 5.3
LOO log3 model: -19.3 ± 5.3
| rank | elpd_loo | p_loo | elpd_diff | weight | se | dse | warning | scale | |
|---|---|---|---|---|---|---|---|---|---|
| log5 | 0 | -18.064652 | 5.399723 | 0.00000 | 1.000000e+00 | 5.294748 | 0.00000 | False | log |
| log3 | 1 | -19.290272 | 7.464691 | 1.22562 | 5.551115e-17 | 5.260358 | 1.46315 | False | log |
Model is better and has less predictors. Dropped collinear predictors, marginal uncertainty reduced. Leaf weight is almost proportional to canopy volume at 0.8 vs 0.4 in previous model.
A simpler model with weak priors means data drives the inference. When we have highly correlated predictors, the coefficients are poorly identified and estimates are noisy. Excluding or combining can get more stable estimates and easier to interpret. Alternatively keep all predictors but use stronger priors.
Finally interactions would be good, but there is not enough data to estimate them reliably.
To conclude: it was better to transform predictors and outcome to log scale, and to drop some predictors to get more stable estimates and better predictive performance. Cross validation is useful for comparing models. Dont chase too many models, or it could result in overfitting.
12.7 Models for regression coefficients¶
Demonstrate standardisation of predictors and models for regression coefficients.
Example of high school studens in Portugal.
Predict final year maths grade for 343 stidents using a large number of predictors. First not standardised, then standardised.
# import student-merged-all.csv
students = pd.read_csv('../ros_data/student-merged-all.csv', skiprows=0)
display(students.head())
predictors = [
"school", "sex", "age", "address", "famsize", "Pstatus", "Medu", "Fedu",
"traveltime", "studytime", "failures", "schoolsup", "famsup", "paid", "activities",
"nursery", "higher", "internet", "romantic", "famrel", "freetime", "goout", "Dalc",
"Walc", "health", "absences"
]
data_G3mat = students.loc[students["G3mat"] > 0, ["G3mat"] + predictors]
student_fit_1 = fit_and_plot_bayes(
data_G3mat, predictors, 'G3mat',
intercept_mu=0, intercept_sigma=50, slope_mu=0, slope_sigma=50, sigma_sigma=50,
samples=2000, tune=1000,
show_trace=False, show_forest=True, show_posterior=False, show_regression=False, show_residuals=False
)
| G1mat | G2mat | G3mat | G1por | G2por | G3por | school | sex | age | address | ... | higher | internet | romantic | famrel | freetime | goout | Dalc | Walc | health | absences | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 7.0 | 10.0 | 10.0 | 13.0 | 13.0 | 13.0 | 0 | 0 | 15 | 0 | ... | 1.0 | 1 | 0.0 | 3.0 | 1.0 | 2.0 | 1.0 | 1.0 | 1.0 | 2.0 |
| 1 | NaN | NaN | NaN | 8.0 | 9.0 | 9.0 | 0 | 0 | 15 | 0 | ... | NaN | 1 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 2 | 8.0 | 6.0 | 5.0 | 13.0 | 11.0 | 11.0 | 0 | 0 | 15 | 0 | ... | 1.0 | 1 | 1.0 | 3.0 | 3.0 | 4.0 | 2.0 | 4.0 | 5.0 | 2.0 |
| 3 | 14.0 | 13.0 | 13.0 | 14.0 | 13.0 | 12.0 | 0 | 0 | 15 | 0 | ... | 1.0 | 0 | 0.0 | 4.0 | 3.0 | 1.0 | 1.0 | 1.0 | 2.0 | 8.0 |
| 4 | 10.0 | 9.0 | 8.0 | 10.0 | 11.0 | 10.0 | 0 | 0 | 15 | 0 | ... | 1.0 | 1 | 0.0 | 4.0 | 3.0 | 2.0 | 1.0 | 1.0 | 5.0 | 2.0 |
5 rows × 32 columns
Initializing NUTS using jitter+adapt_diag... Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_school, slope_sex, slope_age, slope_address, slope_famsize, slope_Pstatus, slope_Medu, slope_Fedu, slope_traveltime, slope_studytime, slope_failures, slope_schoolsup, slope_famsup, slope_paid, slope_activities, slope_nursery, slope_higher, slope_internet, slope_romantic, slope_famrel, slope_freetime, slope_goout, slope_Dalc, slope_Walc, slope_health, slope_absences, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 23 seconds.
mean sd hdi_2.5% hdi_97.5% mcse_mean mcse_sd \
intercept 13.667 2.906 8.097 19.459 0.050 0.033
slope_school -0.683 0.555 -1.756 0.419 0.007 0.006
slope_sex 0.561 0.357 -0.135 1.248 0.004 0.004
slope_age -0.108 0.147 -0.405 0.174 0.002 0.002
slope_address 0.515 0.408 -0.285 1.304 0.005 0.004
slope_famsize 0.315 0.341 -0.339 0.991 0.004 0.004
slope_Pstatus -0.395 0.505 -1.401 0.574 0.005 0.005
slope_Medu 0.246 0.185 -0.110 0.610 0.002 0.002
slope_Fedu 0.234 0.182 -0.114 0.603 0.002 0.002
slope_traveltime 0.044 0.246 -0.430 0.534 0.003 0.002
slope_studytime 0.438 0.204 0.044 0.835 0.002 0.002
slope_failures -0.741 0.253 -1.222 -0.232 0.003 0.003
slope_schoolsup -2.273 0.463 -3.145 -1.328 0.005 0.005
slope_famsup -0.452 0.337 -1.135 0.189 0.004 0.004
slope_paid -0.556 0.330 -1.212 0.074 0.004 0.004
slope_activities 0.116 0.318 -0.488 0.747 0.004 0.003
slope_nursery -0.321 0.403 -1.114 0.472 0.004 0.004
slope_higher 0.233 0.835 -1.423 1.877 0.010 0.009
slope_internet 0.650 0.441 -0.175 1.531 0.005 0.005
slope_romantic -0.114 0.344 -0.791 0.541 0.004 0.004
slope_famrel 0.051 0.176 -0.306 0.385 0.002 0.002
slope_freetime 0.067 0.163 -0.260 0.378 0.002 0.002
slope_goout -0.397 0.164 -0.725 -0.089 0.002 0.002
slope_Dalc 0.067 0.223 -0.354 0.514 0.003 0.002
slope_Walc -0.252 0.175 -0.587 0.097 0.002 0.002
slope_health -0.183 0.114 -0.400 0.044 0.001 0.001
slope_absences -0.068 0.020 -0.108 -0.030 0.000 0.000
sigma 2.848 0.112 2.643 3.071 0.001 0.001
ess_bulk ess_tail r_hat
intercept 3436.0 4739.0 1.0
slope_school 6612.0 5395.0 1.0
slope_sex 7706.0 6315.0 1.0
slope_age 3646.0 4529.0 1.0
slope_address 7066.0 6399.0 1.0
slope_famsize 8993.0 6498.0 1.0
slope_Pstatus 8946.0 6365.0 1.0
slope_Medu 5782.0 6077.0 1.0
slope_Fedu 5912.0 5786.0 1.0
slope_traveltime 7243.0 6248.0 1.0
slope_studytime 7853.0 5978.0 1.0
slope_failures 6467.0 5793.0 1.0
slope_schoolsup 7244.0 6513.0 1.0
slope_famsup 7706.0 6329.0 1.0
slope_paid 8069.0 6493.0 1.0
slope_activities 8204.0 6301.0 1.0
slope_nursery 8370.0 6047.0 1.0
slope_higher 6411.0 5157.0 1.0
slope_internet 8201.0 6368.0 1.0
slope_romantic 7054.0 5887.0 1.0
slope_famrel 8279.0 6308.0 1.0
slope_freetime 7729.0 6273.0 1.0
slope_goout 7816.0 6070.0 1.0
slope_Dalc 5452.0 6106.0 1.0
slope_Walc 5177.0 5687.0 1.0
slope_health 7125.0 5630.0 1.0
slope_absences 6972.0 5897.0 1.0
sigma 8989.0 6239.0 1.0
Regression formula: G3mat = 13.67 + -0.68*school + 0.56*sex + -0.11*age + 0.52*address + 0.32*famsize + -0.40*Pstatus + 0.25*Medu + 0.23*Fedu + 0.04*traveltime + 0.44*studytime + -0.74*failures + -2.27*schoolsup + -0.45*famsup + -0.56*paid + 0.12*activities + -0.32*nursery + 0.23*higher + 0.65*internet + -0.11*romantic + 0.05*famrel + 0.07*freetime + -0.40*goout + 0.07*Dalc + -0.25*Walc + -0.18*health + -0.07*absences
Residual std dev (σ): 2.85 ± 0.11
Bayesian R²: 0.302 ± 0.031
LOO-ELPD: -913.11 ± 12.91 (p_loo=25.8) <- should be lower than number of parameters (28) for reliable estimates
When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE.
LOO log score (per obs): -2.502 ± 0.035
Effect of absences looks small and certain - below we standardise predictors.
from sklearn.preprocessing import scale
datasd_G3mat = data_G3mat.copy()
datasd_G3mat[predictors] = scale(datasd_G3mat[predictors])
student_fit_2 = fit_and_plot_bayes(
datasd_G3mat, predictors, 'G3mat',
intercept_mu=0, intercept_sigma=50, slope_mu=0, slope_sigma=50, sigma_sigma=50,
samples=2000, tune=1000,
show_trace=False, show_forest=True, show_posterior=False, show_regression=False, show_residuals=False
)
Initializing NUTS using jitter+adapt_diag... Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_school, slope_sex, slope_age, slope_address, slope_famsize, slope_Pstatus, slope_Medu, slope_Fedu, slope_traveltime, slope_studytime, slope_failures, slope_schoolsup, slope_famsup, slope_paid, slope_activities, slope_nursery, slope_higher, slope_internet, slope_romantic, slope_famrel, slope_freetime, slope_goout, slope_Dalc, slope_Walc, slope_health, slope_absences, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 3 seconds.
mean sd hdi_2.5% hdi_97.5% mcse_mean mcse_sd \
intercept 11.534 0.149 11.240 11.824 0.001 0.002
slope_school -0.214 0.179 -0.553 0.154 0.002 0.002
slope_sex 0.277 0.172 -0.057 0.615 0.002 0.002
slope_age -0.142 0.183 -0.501 0.210 0.002 0.002
slope_address 0.210 0.174 -0.136 0.551 0.002 0.002
slope_famsize 0.145 0.157 -0.149 0.468 0.001 0.002
slope_Pstatus -0.123 0.158 -0.431 0.187 0.001 0.002
slope_Medu 0.273 0.201 -0.142 0.651 0.002 0.002
slope_Fedu 0.252 0.203 -0.127 0.665 0.002 0.002
slope_traveltime 0.029 0.167 -0.299 0.351 0.002 0.002
slope_studytime 0.364 0.173 0.030 0.709 0.002 0.002
slope_failures -0.499 0.176 -0.855 -0.165 0.002 0.002
slope_schoolsup -0.795 0.163 -1.109 -0.474 0.002 0.002
slope_famsup -0.223 0.169 -0.568 0.097 0.002 0.002
slope_paid -0.275 0.161 -0.574 0.054 0.002 0.002
slope_activities 0.058 0.161 -0.252 0.375 0.001 0.002
slope_nursery -0.128 0.159 -0.447 0.176 0.001 0.002
slope_higher 0.043 0.168 -0.292 0.368 0.002 0.002
slope_internet 0.239 0.161 -0.072 0.559 0.002 0.002
slope_romantic -0.053 0.157 -0.361 0.251 0.001 0.002
slope_famrel 0.047 0.158 -0.266 0.350 0.002 0.002
slope_freetime 0.069 0.170 -0.253 0.412 0.002 0.002
slope_goout -0.432 0.186 -0.792 -0.071 0.002 0.002
slope_Dalc 0.057 0.202 -0.334 0.471 0.002 0.002
slope_Walc -0.324 0.226 -0.757 0.124 0.003 0.002
slope_health -0.253 0.155 -0.565 0.043 0.001 0.002
slope_absences -0.555 0.168 -0.892 -0.239 0.002 0.002
sigma 2.850 0.108 2.634 3.058 0.001 0.001
ess_bulk ess_tail r_hat
intercept 13416.0 6018.0 1.0
slope_school 8483.0 6209.0 1.0
slope_sex 10199.0 6838.0 1.0
slope_age 8455.0 7152.0 1.0
slope_address 10536.0 6433.0 1.0
slope_famsize 11021.0 6526.0 1.0
slope_Pstatus 11199.0 6753.0 1.0
slope_Medu 8389.0 6723.0 1.0
slope_Fedu 8457.0 6500.0 1.0
slope_traveltime 11080.0 5811.0 1.0
slope_studytime 9953.0 6077.0 1.0
slope_failures 10594.0 6587.0 1.0
slope_schoolsup 10613.0 6122.0 1.0
slope_famsup 10964.0 6391.0 1.0
slope_paid 11181.0 6994.0 1.0
slope_activities 12128.0 6331.0 1.0
slope_nursery 11298.0 5672.0 1.0
slope_higher 9754.0 6120.0 1.0
slope_internet 10578.0 6630.0 1.0
slope_romantic 12062.0 6188.0 1.0
slope_famrel 10819.0 5910.0 1.0
slope_freetime 9502.0 6430.0 1.0
slope_goout 8855.0 6662.0 1.0
slope_Dalc 8190.0 5763.0 1.0
slope_Walc 6309.0 6355.0 1.0
slope_health 13244.0 6257.0 1.0
slope_absences 10461.0 6644.0 1.0
sigma 10476.0 6086.0 1.0
Regression formula: G3mat = 11.53 + -0.21*school + 0.28*sex + -0.14*age + 0.21*address + 0.15*famsize + -0.12*Pstatus + 0.27*Medu + 0.25*Fedu + 0.03*traveltime + 0.36*studytime + -0.50*failures + -0.79*schoolsup + -0.22*famsup + -0.27*paid + 0.06*activities + -0.13*nursery + 0.04*higher + 0.24*internet + -0.05*romantic + 0.05*famrel + 0.07*freetime + -0.43*goout + 0.06*Dalc + -0.32*Walc + -0.25*health + -0.55*absences
Residual std dev (σ): 2.85 ± 0.11
Bayesian R²: 0.302 ± 0.032
LOO-ELPD: -913.39 ± 12.90 (p_loo=26.0) <- should be lower than number of parameters (28) for reliable estimates
When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE.
LOO log score (per obs): -2.502 ± 0.035
LOO R²: 0.166
*** WARNING: LOO R² (0.166) < Bayesian R² (0.302) — model may be overfitting ***
After standardising predictors, the coefficient for absences is larger and less certain, which suggests that the effect of absences on final year maths grade is more substantial than it appeared in the unstandardised model. This is because standardising the predictors allows us to compare the coefficients on a common scale, which can reveal relationships that may not be apparent in the unstandardised model.
LOO R² (0.168) < Bayesian R² (0.302) — model may be overfitting
We have 26 predictors each has SD of 1. We use gentle prior: normal mean 0 and SD 2.5 - modest for single predictor.
The issue: Model is predicting linear combination, all 26 predictors and their coefficients add together. Because priors are independent, variances add up. Variance of predicted means is 26 * 2.5^2 = 162.5, and SD is 12.75. Meanwhile prior for noise \sigma SD is 3.3.
Setup: R2 is explained variance / total variance = var(predicted means) / (var(predicted means) + var(noise)^2). This is saying signal has SD 12.7 and noise 3.3 - R2 is being pushed towards 1.
Lesson: weakly informative priors per coefficient can be very informative for things we care about (R2) when we have many predictors.
The fix: decide on a budget. For example, guess that predictors together explain 30% of variance:
Explained part = 0.3 * (var(y), split across 26 predictors, so \sqrt(0.3 / 26) * sd(y) Residual part = 0.7 * (var(y) - \sigma gets prior mean \sqrt(0.7) * sd(y)
Now total signal variance plus noise variance = actual data variance.
prior_scale = datasd_G3mat['G3mat'].std() * np.sqrt(0.3 / 26)
student_fit_2 = fit_and_plot_bayes(
datasd_G3mat, predictors, 'G3mat',
intercept_mu=0, intercept_sigma=50, slope_mu=0, slope_sigma=prior_scale, sigma_sigma=50,
samples=2000, tune=1000,
show_trace=False, show_forest=True, show_posterior=False, show_regression=False, show_residuals=False
)
Initializing NUTS using jitter+adapt_diag... Multiprocess sampling (4 chains in 4 jobs) NUTS: [intercept, slope_school, slope_sex, slope_age, slope_address, slope_famsize, slope_Pstatus, slope_Medu, slope_Fedu, slope_traveltime, slope_studytime, slope_failures, slope_schoolsup, slope_famsup, slope_paid, slope_activities, slope_nursery, slope_higher, slope_internet, slope_romantic, slope_famrel, slope_freetime, slope_goout, slope_Dalc, slope_Walc, slope_health, slope_absences, sigma]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 3 seconds.
mean sd hdi_2.5% hdi_97.5% mcse_mean mcse_sd \
intercept 11.535 0.148 11.248 11.820 0.001 0.002
slope_school -0.159 0.154 -0.457 0.136 0.001 0.002
slope_sex 0.259 0.151 -0.026 0.559 0.001 0.002
slope_age -0.143 0.157 -0.451 0.157 0.001 0.002
slope_address 0.184 0.147 -0.111 0.466 0.001 0.002
slope_famsize 0.123 0.143 -0.146 0.411 0.001 0.002
slope_Pstatus -0.100 0.142 -0.384 0.176 0.001 0.002
slope_Medu 0.256 0.166 -0.063 0.586 0.002 0.002
slope_Fedu 0.210 0.166 -0.092 0.553 0.002 0.002
slope_traveltime -0.007 0.145 -0.306 0.262 0.001 0.002
slope_studytime 0.299 0.150 0.007 0.594 0.001 0.002
slope_failures -0.454 0.151 -0.740 -0.142 0.001 0.002
slope_schoolsup -0.665 0.144 -0.939 -0.370 0.001 0.002
slope_famsup -0.189 0.144 -0.480 0.086 0.001 0.002
slope_paid -0.218 0.147 -0.523 0.051 0.001 0.002
slope_activities 0.057 0.140 -0.213 0.332 0.001 0.002
slope_nursery -0.095 0.143 -0.378 0.176 0.001 0.002
slope_higher 0.059 0.150 -0.240 0.350 0.001 0.002
slope_internet 0.207 0.143 -0.085 0.479 0.001 0.002
slope_romantic -0.051 0.142 -0.334 0.220 0.001 0.002
slope_famrel 0.046 0.142 -0.222 0.337 0.001 0.002
slope_freetime 0.043 0.150 -0.246 0.337 0.001 0.002
slope_goout -0.364 0.153 -0.657 -0.055 0.001 0.002
slope_Dalc -0.013 0.168 -0.349 0.314 0.002 0.002
slope_Walc -0.275 0.177 -0.623 0.072 0.002 0.002
slope_health -0.207 0.143 -0.483 0.068 0.001 0.002
slope_absences -0.470 0.148 -0.762 -0.184 0.001 0.002
sigma 2.838 0.108 2.624 3.051 0.001 0.001
ess_bulk ess_tail r_hat
intercept 18064.0 5667.0 1.0
slope_school 12386.0 6078.0 1.0
slope_sex 12162.0 6410.0 1.0
slope_age 13259.0 6762.0 1.0
slope_address 13427.0 6171.0 1.0
slope_famsize 16883.0 5566.0 1.0
slope_Pstatus 14885.0 6388.0 1.0
slope_Medu 12093.0 6495.0 1.0
slope_Fedu 10966.0 5892.0 1.0
slope_traveltime 14582.0 6586.0 1.0
slope_studytime 12889.0 6312.0 1.0
slope_failures 14834.0 6380.0 1.0
slope_schoolsup 15160.0 6783.0 1.0
slope_famsup 12776.0 6826.0 1.0
slope_paid 14289.0 6341.0 1.0
slope_activities 15789.0 6661.0 1.0
slope_nursery 14115.0 6267.0 1.0
slope_higher 14519.0 5911.0 1.0
slope_internet 12767.0 5826.0 1.0
slope_romantic 15212.0 6059.0 1.0
slope_famrel 16214.0 5835.0 1.0
slope_freetime 13235.0 6442.0 1.0
slope_goout 12318.0 6371.0 1.0
slope_Dalc 11314.0 6398.0 1.0
slope_Walc 8993.0 6210.0 1.0
slope_health 15532.0 5819.0 1.0
slope_absences 12646.0 6688.0 1.0
sigma 14365.0 5837.0 1.0
Regression formula: G3mat = 11.54 + -0.16*school + 0.26*sex + -0.14*age + 0.18*address + 0.12*famsize + -0.10*Pstatus + 0.26*Medu + 0.21*Fedu + -0.01*traveltime + 0.30*studytime + -0.45*failures + -0.67*schoolsup + -0.19*famsup + -0.22*paid + 0.06*activities + -0.10*nursery + 0.06*higher + 0.21*internet + -0.05*romantic + 0.05*famrel + 0.04*freetime + -0.36*goout + -0.01*Dalc + -0.27*Walc + -0.21*health + -0.47*absences
Residual std dev (σ): 2.84 ± 0.11
Bayesian R²: 0.254 ± 0.031
LOO-ELPD: -908.96 ± 12.70 (p_loo=21.0) <- should be lower than number of parameters (28) for reliable estimates
When comparing models, better performing models have higher (less negative) LOO-ELPD - a meaningful difference between models is 2x SE.
LOO log score (per obs): -2.490 ± 0.035
LOO R²: 0.186
*** WARNING: LOO R² (0.186) < Bayesian R² (0.254) — model may be overfitting ***
However, if we think that only a few predictors are important, we can use regularised horseshoe priors, which are designed to handle situations where we have many predictors but only a few of them are expected to have a substantial effect on the outcome variable. The regularised horseshoe prior allows for some coefficients to be large while shrinking the others towards zero.
Instead of dividing the budget across 26 predictors, we assume say 6 are important and 20 not. But we dont know which 6 are important, so we use a regularised horseshoe prior, which allows for some coefficients to be large while shrinking the others towards zero.
How: Each coefficient gets a normal prior with mean 0 and SD τ·λ_j
τ global scale - how much all get shrunk towards zero λ_j local scale - how much each coefficient gets shrunk towards zero
λ has a clever prior: a half-Cauchy, which is mostly small but big tail. Most get small λ, but some can draw a large λ, which allows for some coefficients to be large while shrinking the others towards zero. Predictors are encouraged to be large or small, but not in between.
Why horseshoe? Because of the either large or small nature of the coefficients, the prior distribution has a shape that resembles a horseshoe when plotted - values bunched near 1 and 0. Prior is essentially saying predictor is either important (large coefficient) or not important (small coefficient), but not in between.
Spike and slab - spike is the sharp peak at zero. Slab is is wide flat distribution, to allow coefficients to roam. Plain horseshoe can allow escaping coefficients to be very large. The regularised horseshoe adds a regularisation term to the slab, which prevents coefficients from becoming too large, while still allowing for some coefficients to be large if the data supports it.
If we expect 6 out of 26 (p0 = 6). Slab scale is same as above, but for 6 instead of 26.
The payoff: prior assumes that most predictors are not important, so variance does not pile up like before. R2 is more cautious. Nudges towards simpler models.
Summary: even budget above, says everyone gets a small slice, horseshoe says most get no slice, but some get a big slice and that is decided by the data.
# --- Horseshoe prior parameters ---
# p0: our prior belief about how many predictors actually matter
# global_scale: shrinks most coefficients toward zero globally
# slab_scale: allows the ~p0 important coefficients to escape shrinkage
p = len(predictors)
n = len(datasd_G3mat)
p0 = 6 # expected number of relevant predictors
slab_scale = np.sqrt(0.3 / p0) * datasd_G3mat['G3mat'].std()
global_scale = (p0 / (p - p0)) / np.sqrt(n)
slab_df = 4 # rstanarm default; controls how heavy the slab tails are
y = datasd_G3mat['G3mat'].values
X = datasd_G3mat[predictors].values
with pm.Model() as hs_model:
sigma = pm.HalfNormal("sigma", sigma=50)
# Global shrinkage (scaled by sigma to match rstanarm's internal scaling)
tau = pm.HalfCauchy("tau", beta=global_scale * sigma)
# Local shrinkage: each predictor gets its own scale, allowing a few to be large
lam = pm.HalfCauchy("lam", beta=1, shape=p)
# Slab: prevents important coefficients from being over-shrunk
c2 = pm.InverseGamma("c2", alpha=slab_df / 2, beta=slab_df / 2 * slab_scale**2)
# Regularised local scale combining global, local, and slab (Piironen & Vehtari 2017)
lam_tilde = lam * pm.math.sqrt(c2 / (c2 + tau**2 * lam**2))
intercept = pm.Normal("intercept", mu=0, sigma=50)
beta = pm.Normal("beta", mu=0, sigma=tau * lam_tilde, shape=p)
mu = intercept + pm.math.dot(X, beta)
pm.Normal("y", mu=mu, sigma=sigma, observed=y)
student_fit_3 = pm.sample(2000, tune=1000, idata_kwargs={"log_likelihood": True})
# Print posterior summary for intercept and slopes only (excluding sampler internals)
print(pm.summary(student_fit_3, var_names=["intercept", "beta"]))
# Label beta coefficients by predictor name for readability
coord_map = {f"beta[{i}]": name for i, name in enumerate(predictors)}
az.plot_forest(
student_fit_3,
var_names=["intercept", "beta"],
combined=True,
figsize=(8, 10)
)
# Replace generic "beta[i]" y-tick labels with predictor names
ax = plt.gca()
ax.set_yticklabels([coord_map.get(t.get_text(), t.get_text()) for t in ax.get_yticklabels()])
plt.title("Horseshoe prior: posterior coefficients")
plt.tight_layout()
The plt.show()
Initializing NUTS using jitter+adapt_diag... Multiprocess sampling (4 chains in 4 jobs) NUTS: [sigma, tau, lam, c2, intercept, beta]
/opt/anaconda3/envs/ros_pymc/lib/python3.12/site-packages/rich/live.py:260: UserWarning: install "ipywidgets" for
Jupyter support
warnings.warn('install "ipywidgets" for Jupyter support')
Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 9 seconds. There were 1578 divergences after tuning. Increase `target_accept` or reparameterize. The rhat statistic is larger than 1.01 for some parameters. This indicates problems during sampling. See https://arxiv.org/abs/1903.08008 for details The effective sample size per chain is smaller than 100 for some parameters. A higher number is needed for reliable rhat and ess computation. See https://arxiv.org/abs/1903.08008 for details
mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk \
intercept 11.539 0.148 11.259 11.813 0.004 0.002 1605.0
beta[0] -0.119 0.139 -0.408 0.107 0.004 0.004 1409.0
beta[1] 0.174 0.161 -0.061 0.483 0.007 0.002 425.0
beta[2] -0.109 0.139 -0.405 0.103 0.003 0.003 1906.0
beta[3] 0.145 0.150 -0.075 0.433 0.008 0.003 456.0
beta[4] 0.061 0.114 -0.149 0.292 0.004 0.004 804.0
beta[5] -0.067 0.116 -0.352 0.100 0.005 0.005 611.0
beta[6] 0.233 0.184 -0.064 0.566 0.007 0.002 975.0
beta[7] 0.141 0.161 -0.088 0.477 0.005 0.004 863.0
beta[8] -0.027 0.102 -0.242 0.171 0.002 0.002 1602.0
beta[9] 0.190 0.176 -0.074 0.515 0.009 0.005 363.0
beta[10] -0.503 0.177 -0.827 -0.173 0.007 0.003 675.0
beta[11] -0.697 0.156 -0.981 -0.382 0.003 0.002 2883.0
beta[12] -0.111 0.134 -0.374 0.096 0.004 0.002 1364.0
beta[13] -0.149 0.146 -0.438 0.076 0.003 0.002 1892.0
beta[14] 0.042 0.104 -0.167 0.250 0.003 0.003 1472.0
beta[15] -0.022 0.108 -0.250 0.180 0.007 0.003 271.0
beta[16] 0.030 0.102 -0.151 0.253 0.002 0.002 1488.0
beta[17] 0.142 0.145 -0.076 0.420 0.005 0.002 1088.0
beta[18] -0.029 0.101 -0.250 0.150 0.002 0.002 1336.0
beta[19] 0.033 0.102 -0.164 0.234 0.005 0.002 416.0
beta[20] 0.012 0.105 -0.189 0.230 0.003 0.002 865.0
beta[21] -0.315 0.182 -0.626 0.022 0.005 0.002 1315.0
beta[22] -0.022 0.115 -0.272 0.205 0.003 0.003 1816.0
beta[23] -0.225 0.189 -0.594 0.060 0.006 0.002 1232.0
beta[24] -0.122 0.132 -0.390 0.077 0.003 0.002 1924.0
beta[25] -0.482 0.167 -0.801 -0.173 0.003 0.003 2308.0
ess_tail r_hat
intercept 4705.0 1.01
beta[0] 592.0 1.01
beta[1] 670.0 1.01
beta[2] 3014.0 1.00
beta[3] 1146.0 1.01
beta[4] 630.0 1.01
beta[5] 160.0 1.01
beta[6] 3333.0 1.00
beta[7] 635.0 1.01
beta[8] 2198.0 1.00
beta[9] 649.0 1.01
beta[10] 1186.0 1.01
beta[11] 2314.0 1.01
beta[12] 2180.0 1.00
beta[13] 3655.0 1.02
beta[14] 2109.0 1.01
beta[15] 245.0 1.02
beta[16] 3283.0 1.01
beta[17] 1664.0 1.01
beta[18] 3132.0 1.01
beta[19] 3146.0 1.01
beta[20] 948.0 1.00
beta[21] 2581.0 1.00
beta[22] 1727.0 1.01
beta[23] 3300.0 1.00
beta[24] 1611.0 1.01
beta[25] 3135.0 1.01
The forest plot shows most coefficients are small and close to zero, but a few are larger.
Using this, we have a better idea of which predictors are important, and we can build a model with just those predictors.
However, we dont know how many predictors are important.
Other models for regression coefficients¶
We can use different regularisation priors, not just horseshoe. E.g., ridge, lasso, horseshoe etc.
We can work back from R2. For some prior choices, we can write a formula for implied prior on R2. I.e. I want R2 prior to look like this and then pick the prior on the coefficients that gives me that R2 prior.
Horseshoe is better than lasso when we have many predictors but only a few are expected to be important, as it allows for some coefficients to be large while shrinking the others towards zero, whereas lasso shrinks all coefficients towards zero, which can lead to biased estimates for the important predictors.
Using to choose predictors and warning. We can fit horseshoe and then build a model with fewer predictors. But only if predictors are perfectly independent. Two correlated predictors can be wrongly discarded, when the pair is important but the individual predictors are not.
Big takeaway: gentle priors can be strong on things like R2, fix is to think about sensible budget and structure of the problem (every parameter matters a small amount or few parameters matter a lot).