PyData Berlin 2026
Reporting coefficients is not enough. A single number like “\(\beta_{\text{roas}} = 0.23\)” hides the link function, the scale, and any non-linearity. Off the identity link, the coefficient is not the effect.
Visualization carries the meaning. Showing the predicted response over a grid of inputs answers the question stakeholders actually ask: what happens to the outcome if this input moves?
Uncertainty is part of the answer. Every prediction and every contrast comes with a posterior. Communicating the credible interval is as important as the point estimate.
We follow the mental model from the book Model to Meaning: How to Interpret Statistical Models with marginaleffects: predictions, comparisons, and slopes.

An ad platform sells digital realestates to stores to induce incremental sales. This platform charges stores per click and reports back ROAS (return on ad spend). Stores keep spending while campaigns are worth it; when they are not, they pause.
This is an oversimplified example: we ignore cannibalization, other drivers, and a richer causal structure. In practice this problem is much harder.

Next-month booked budget for twelve random stores.

Next-month booked budget vs ROAS.
The non-linear shape is visible to the eye: budget rises with ROAS, then levels off past \(\text{ROAS} \approx 4\).

Yearly seasonality by month-of-year.
The simplest thing that could work: plain linear regression, Gaussian noise, identity link.
\[\begin{align*} y_i & \sim \text{Normal}(\mu_i, \sigma^2) \\ \mu_i & = \beta_0 + \beta_{\text{age}} \, \text{cohort\_age}_i + \sum_{m=2}^{12} \beta_m \, \mathbb{1}[\text{month}_i = m] + \beta_{\text{roas}} \, \text{roas}_i \end{align*}\]
formula_lm = bmb.Formula("budget_next ~ 1 + cohort_age + C(month_of_year) + roas")
priors_lm = {
"Intercept": bmb.Prior("Normal", mu=0.0, sigma=2.0),
"cohort_age": bmb.Prior("Normal", mu=0.0, sigma=1.0),
"C(month_of_year)": bmb.Prior("ZeroSumNormal", sigma=1.0),
"roas": bmb.Prior("Normal", mu=0.0, sigma=2.0),
"sigma": bmb.Prior("HalfNormal", sigma=5.0),
}
model_lm = bmb.Model(
formula=formula_lm,
data=model_df,
family="gaussian",
link="identity",
priors=priors_lm,
)
Two defects the wrong likelihood creates:

Read straight off the coefficient: an extra unit of ROAS is associated with \(+0.23\) in next month’s budget, holding the rest constant.
By design this holds at every ROAS level: a single slope, no peak, no saturation. That directly contradicts what we just saw in the data.
Instead of reading coefficients, study the posterior of \(\mathbb{E}[Y \mid \text{grid}]\) over a grid of inputs. This is where the thinking happens: which question do we want to answer?
# 1. Build a reference grid: vary ROAS, hold the rest at their mean.
roas_datagrid = datagrid(
roas=roas_grid,
cohort_age=cohort_age_default,
month_of_year=month_of_year_default,
newdata=model_df,
)
# 2. Push the grid through the posterior to get the response mean mu.
def predict_mu(model, idata, grid_pl):
new_idata = model.predict(idata, data=grid_pl, kind="response_params", inplace=False)
return new_idata["posterior"]["mu"]
# 3. Summarize on the response scale (here: 94% HDI bands).
idata_lm_mu_grid = predict_mu(model_lm, idata_lm, roas_datagrid)
az.plot_hdi(roas_grid, idata_lm_mu_grid, hdi_prob=0.94)
...
The slope of this posterior line is exactly \(0.23\): the same value as the regression coefficient.
For a linear identity-link model the grid view equals the coefficient. The point is that the same recipe generalizes to models where no single coefficient exists.
Adding a second variable to the grid splits the effect into one line per cohort age.

The slope (ROAS effect) is identical across cohorts; only the intercept shifts down as cohorts get older. That is the linear model’s rigid signature.
Same story across months: the ROAS slope is shared, the intercept moves.

The variation is non-linear in the month index because of the ZeroSumNormal seasonal contrast, but ROAS itself still enters as one constant slope.

Beyond predictions, we can difference two grids: the gap between month 3 and month 9.
The three marginaleffects primitives:
The difference is constant in ROAS: a direct consequence of linearity.

A linear decay in budget as cohorts age.

Month effects via a forest plot, centered on zero by the ZeroSumNormal contrast.
interpretEverything so far we built by hand. Bambi packages the identical recipe behind three one-liners: plot_predictions, plot_comparisons, plot_slopes.
The first conditional key is the x-axis, a second key becomes the color grouping, a third becomes the panel. Omitted covariates are held at mean or mode.

Same posterior, same grid, same \(\mu\): a single \(94\%\) band instead of our hand-layered bands.
To fix the likelihood we need a response that is non-negative and can produce many zeros.
\[\begin{align*} y_i & \sim \text{HurdleGamma}(\psi, \mu_i, \alpha) \\ \log \mu_i & = \beta_0 + \beta_{\text{age}} \, \text{cohort\_age}_i + \sum_{m} \beta_m \, \mathbb{1}[\text{month}_i = m] + \beta_{\text{roas}} \, \text{roas}_i \end{align*}\]

The likelihood is now right: non-negative response and an explicit zero point-mass.
The fit is much closer to the data than the Gaussian baseline. However, there is still a room for improvement.
The recipe is unchanged: we just swap the model in predict_mu. No need to interpret \(\exp(\beta)\) at all.

The log link bends the line into a monotone curve: stronger growth at higher ROAS.

Split by cohort age: curves now fan out on the response scale (parallel on the log scale).
Better likelihood, but still the wrong shape: monotone growth, no peak, no saturation.
We keep the Hurdle-Gamma likelihood and replace the linear roas term with a Hilbert-space Gaussian process. The GP lets the data shape the curve: no linearity, no polynomial, no knots.
\[\begin{align*} y_i & \sim \text{HurdleGamma}(\psi, \mu_i, \alpha) \\ \log \mu_i & = \beta_0 + \beta_{\text{age}} \, \text{cohort\_age}_i + \sum_{m} \beta_m \, \mathbb{1}[\text{month}_i = m] + f(\text{roas}_i) \\ f & \sim \text{HSGP}(m, c) \end{align*}\]
See A Conceptual and Practical Introduction to Hilbert Space GP Approximation Methods for the HSGP background.

The best posterior predictive of the three models: it captures the zeros, the non-negativity, and the bulk of the budget distribution.

The curve rises then saturates past \(\text{ROAS} \approx 4\), exactly the mechanism we built into the problem.

Split by cohort age: same shape, smaller amplitude for older cohorts.
There is no single ROAS coefficient to report here. The grid-based prediction is the answer, and it reads cleanly off the plot.
Derivative of the ROAS effect with respect to ROAS.


Leave-one-out cross-validation ranks out-of-sample fit (higher elpd_loo is better).

The Hurdle-Gamma + HSGP model tracks the true non-linearity, peak and saturation included. The linear models cannot.
Across all three models the interpretation recipe is identical: build a grid with datagrid, push it through the posterior with predict_mu, summarize on the response scale, with uncertainty.
Raw coefficients answer the wrong question once you leave identity-link land.
Grid-based predictions, comparisons, and slopes answer the right one.
Links

![]()