Undo standardization after fit with brms

Hello Stan forum,

a small question, that many of you most likely have come across already.

After standardization of data and model fit I would like to rescale the posterior back to the original non-standardized unit for better interpretation / presentation.

What is your best practice to perform it on a brms fit object?

I understand the considerations behind the design decisions not to include this function into brms: https://github.com/paul-buerkner/brms/issues/440

I usually use „standardize“ from the effectsize package (https://easystats.github.io/effectsize). Which saves the used standardization values (center, scale) per parameter in an attribute with the data which is also in the later brms fit object. So maybe there is an elegant way to access this afterwards and still use the conditional_effects plots from brms?

Is there a tidybayse / tidyverse approach you know of? I could not find anything in that direction so far.

Thanks for your ideas.

Kruschke covered this topic for several single-level models in his text (http://doingbayesiandataanalysis.blogspot.com/), chapters 16 and 17, I believe.

Thanks for the hint to this more workflow related question. The book is orderd at the library and I have not checked your brms version of it yet, but I will do now.

The center and scale value can be retrived via the code below after standardization with the effectsize package. This should also work with the brms object later on:

attr(data_stan$parameter_name, "center")
attr(data_stan$parameter_name, "scale")
1 Like

Hey all. I’ve had reason to revisit this issue, and I’d like to add an alternative solution. The one I provided before based on Kruschke’s text works well if your goal is to convert the standardized model parameters themselves to an unstandardized metric. But if you are instead primarily interested in model predictions, there’s an easier way. To demonstrate, here we fit a simple Gaussian model with two continuous predictors. All variables have been standardized.

# Load the packages
library(tidyverse)
library(brms)
library(tidybayes)

# Add standardized variables to the data
mtcars <- mtcars |> 
  mutate(mpg_z = (mpg - mean(mpg)) / sd(mpg),
         disp_z = (disp - mean(disp)) / sd(disp),
         wt_z = (wt - mean(wt)) / sd(wt))

# Fit a simple Gaussian model with weakly-regularizing priors
my_fit <- brm(
  data = mtcars,
  mpg_z ~ disp_z + wt_z,
  prior = prior(normal(0, 1), class = Intercept) +
    prior(normal(0, 1), class = b) +
    prior(exponential(1), class = sigma),
  cores = 4, seed = 1)

# The model summary, for the curious
print(my_fit)
 Family: gaussian 
  Links: mu = identity; sigma = identity 
Formula: mpg_z ~ disp_z + wt_z 
   Data: mtcars (Number of observations: 32) 
  Draws: 4 chains, each with iter = 2000; warmup = 1000; thin = 1;
         total post-warmup draws = 4000

Regression Coefficients:
          Estimate Est.Error l-95% CI u-95% CI Rhat Bulk_ESS Tail_ESS
Intercept    -0.00      0.09    -0.17     0.18 1.00     2778     2198
disp_z       -0.36      0.19    -0.76     0.00 1.00     2222     2074
wt_z         -0.54      0.19    -0.91    -0.14 1.00     2170     1694

Further Distributional Parameters:
      Estimate Est.Error l-95% CI u-95% CI Rhat Bulk_ESS Tail_ESS
sigma     0.50      0.07     0.39     0.65 1.00     2262     1964

Draws were sampled using sampling(NUTS). For each parameter, Bulk_ESS
and Tail_ESS are effective sample size measures, and Rhat is the potential
scale reduction factor on split chains (at convergence, Rhat = 1).

Let’s say you wanted to show the predicted values for the criterion variable mpg_z across the observed range of the first predictor disp_z, while holding the second predictor wt_z at its mean. Here’s how you might plot the results against the data, using help from the {tidybayes} package.

# Define the predictor values, and save in a data frame
nd <- data.frame(
  disp_z = seq(from = min(mtcars$disp_z), to = max(mtcars$disp_z), length.out = 101),
  wt_z = 0)

# Pass the predictor values to the model
nd |> 
  add_epred_draws(my_fit) |> 
  
  # Plot
  ggplot(aes(x = disp_z)) +
  stat_lineribbon(aes(y = .epred),
                  .width = 0.95, color = "skyblue", fill = alpha("skyblue", 0.5)) +
  geom_point(data = mtcars,
             aes(y = mpg_z)) +
  labs(y = "mpg_z",
       title = "Standardized",
       subtitle = "(holding wt_z at its mean)")

To put the results back on the original un-standardized metric, you simply un-standardize the primary predictor values (disp_z) and the expected values (.epred) with help from the sample statistics for disp and mpg. That can look like so.

# Compute and save the sample statistics
disp_mean <- mean(mtcars$disp)
disp_sd <- sd(mtcars$disp)

mpg_mean <- mean(mtcars$mpg)
mpg_sd <- sd(mtcars$mpg)

# Pass the predictor values to the model
nd |> 
  add_epred_draws(my_fit) |> 
  # Convert to the non-standardized metric
  mutate(disp = (disp_z * disp_sd) + disp_mean,
         mpg = (.epred * mpg_sd) + mpg_mean) |> 
  
  # Plot
  ggplot(aes(x = disp, y = mpg)) +
  stat_lineribbon(.width = 0.95, color = "skyblue", fill = alpha("skyblue", 0.5)) +
  geom_point(data = mtcars) +
  labs(y = "mpg",
       title = "Un-standardized",
       subtitle = "(holding wt_z at its mean)")

This basic approach will generalize to more complicated models, such as those with many predictors, and complications like multilevel models.

2 Likes

I’m guessing you are standardizing each covariate independently with a simple z-transform (this is trickier if you do them all with the Q matrix of a QR-decomposition, but conceptually identical).

x_std = (x - mean(x)) / sd(x)

Now suppose I have a model where I fit a regression coefficient beta so that beta * x_std shows up in the linear predictor. We just turn the crank and get the following.

beta * x_std
    = beta * (x - mean(x)) / sd(x)
    = beta * x / sd(x) - beta * mean(x) / sd(x)

So the new coefficient we need is beta / sd(x), but we also need to add -beta * mean(x) / sd(x) to the intercept.