Skip to contents

1. Why a Bayesian SSM?

The Structural Summary Method (SSM) describes a circumplex profile with an elevation ee, an amplitude aa, and a displacement dd. The package’s ssm_analyze() estimates these with bootstrap or Monte Carlo confidence intervals. A Bayesian alternative is attractive when you want prior information, hierarchical structure (e.g., partial pooling across persons or groups), or full posterior distributions for derived quantities.

The division of labor is deliberate: a general-purpose Bayesian package such as brms fits the model, and ssm_draws() converts the resulting posterior draws into SSM parameter draws and summarizes them with the same circular-statistics machinery the rest of the package uses. Displacement is an angle, so its posterior needs circular treatment — an interval straddling the 0°/360° boundary must wrap rather than invert — and ssm_draws() handles that by construction.

2. The cosine model as a linear regression

The SSM’s cosine model for a profile of scores SjS_j observed at scale angles θj\theta_j is

Sj=e+acos(θjd).S_j = e + a \cos(\theta_j - d).

Expanding the cosine of a difference gives

Sj=e+acosdxcosθj+asindysinθj,S_j = e + \underbrace{a \cos d}_{x} \cos \theta_j + \underbrace{a \sin d}_{y} \sin \theta_j,

a linear regression of the scores on cosθj\cos \theta_j and sinθj\sin \theta_j. The intercept is the elevation ee, the cosine coefficient is xx, and the sine coefficient is yy. The structural parameters are recovered by

a=x2+y2,d=atan2(y,x),a = \sqrt{x^2 + y^2}, \qquad d = \operatorname{atan2}(y,\, x),

with the displacement wrapped into [0°,360°)[0°, 360°).

Note the argument order: atan2(y, x) takes the sine coefficient first. Swapping the arguments is a classic silent error — it returns a valid-looking angle that is wrong for almost every profile. The following check pins the convention with a profile whose displacement is known to be 90°, where the swapped call would instead return 0°:

# Truth: e = 1, a = 2, d = 90 degrees
theta <- as.numeric(octants()) * pi / 180
scores <- 1 + 2 * cos(theta - pi / 2)
fit <- lm(scores ~ cos(theta) + sin(theta))
x_hat <- coef(fit)[["cos(theta)"]]
y_hat <- coef(fit)[["sin(theta)"]]
d_hat <- atan2(y_hat, x_hat) * 180 / pi
round(c(x = x_hat, y = y_hat, d = d_hat), 6)
#>  x  y  d 
#>  0  2 90
stopifnot(isTRUE(all.equal(d_hat, 90)))          # atan2(y, x): correct
stopifnot(!isTRUE(all.equal(atan2(x_hat, y_hat) * 180 / pi, 90)))  # swapped

3. Fitting the model with brms

We model raw octant scores from the jz2017 data in long format (one row per person-scale observation), with a random intercept per person to absorb the dependence among a person’s eight scores. The fixed effects (intercept, cosine coefficient, sine coefficient) are then the group-level (e,x,y)(e, x, y). A seeded subsample of 200 persons keeps the example light.

data("jz2017")
set.seed(12345)
sub <- jz2017[sample(nrow(jz2017), 200), ]
scales <- c("PA", "BC", "DE", "FG", "HI", "JK", "LM", "NO")
theta <- as.numeric(octants()) * pi / 180
dat <- data.frame(
  id = rep(seq_len(200), times = length(scales)),
  cos_theta = rep(cos(theta), each = 200),
  sin_theta = rep(sin(theta), each = 200),
  score = unlist(sub[scales], use.names = FALSE)
)
head(dat)
#>   id    cos_theta sin_theta score
#> 1  1 6.123234e-17         1  1.00
#> 2  2 6.123234e-17         1  0.25
#> 3  3 6.123234e-17         1  0.50
#> 4  4 6.123234e-17         1  0.75
#> 5  5 6.123234e-17         1  0.00
#> 6  6 6.123234e-17         1  0.50

Because Bayesian sampling requires a working Stan toolchain, the model below is not re-fitted when this vignette is rebuilt; its posterior draws were generated once by the seeded script data-raw/bayesian_ssm_draws.R and ship with the package. The normal(0, 1) prior on the regression coefficients is a deliberate modeling choice whose consequences for the amplitude we examine in Section 5.

library(brms)
bfit <- brm(
  score ~ cos_theta + sin_theta + (1 | id),
  data = dat,
  prior = set_prior("normal(0, 1)", class = "b"),
  chains = 4, iter = 2000, cores = 4, seed = 12345
)
draws <- as.matrix(bfit,
                   variable = c("b_Intercept", "b_cos_theta", "b_sin_theta"))

4. From posterior draws to SSM summaries

The draws form a matrix with one row per posterior draw and three columns interpreted in column order as (e,x,y)(e, x, y):

draws <- readRDS("bayesian_ssm_draws.rds")
dim(draws)
#> [1] 4000    3
head(round(draws, 3))
#>     variable
#> draw b_Intercept b_cos_theta b_sin_theta
#>    1       0.990       0.315      -0.294
#>    2       0.938       0.382      -0.353
#>    3       0.931       0.356      -0.331
#>    4       0.921       0.339      -0.277
#>    5       0.946       0.382      -0.295
#>    6       0.913       0.355      -0.313

ssm_draws() accepts two draw shapes: three-column parameter draws like these, or profile draws (one column per scale, with angles supplied). A three-column matrix without angles is ambiguous — it could also be profile draws from a three-scale instrument — so the shape must be stated explicitly with type = "parameters":

res <- ssm_draws(draws, type = "parameters")
summary(res)
#> 
#> Statistical Basis:   Posterior Draws 
#> Posterior Draws:  4000 
#> Credible Level:       0.95 
#> Draw Shape:       Parameters 
#> 
#> # Posterior Summary:
#> 
#>                Estimate   Lower CrI   Upper CrI
#> Elevation         0.933       0.868       0.998
#> X-Value           0.352       0.303       0.401
#> Y-Value          -0.319      -0.365      -0.273
#> Amplitude         0.476       0.427       0.522
#> Displacement    317.790     312.274     323.286
#> Model Fit

Each posterior draw of (x,y)(x, y) was converted to a draw of (a,d)(a, d), so the displacement’s credible interval comes from circular quantiles (centered on the circular mean and re-wrapped), never from naive linear quantiles that would misbehave near 0°/360°. Point estimates are posterior medians for the linear parameters — the amplitude posterior is right-skewed, so a mean would overstate it — and the circular mean for displacement. One caveat to keep in mind: these marginal summaries are not jointly coherent. The reported amplitude is the median of the amplitude draws, which is not x2+y2\sqrt{x^2 + y^2} evaluated at the reported xx and yy, and the reported displacement is not the direction of the reported (x,y)(x, y). Each is the honest marginal summary of its own posterior.

Model fit (R2R^2) is not reported here: parameter draws carry no profile to measure fit against. Feeding ssm_draws() profile draws (shape B) does yield fit draws.

5. The induced prior on amplitude

Independent priors on xx and yy do not induce a flat prior on the structural parameters. With x,yNormal(0,1)x, y \sim \mathrm{Normal}(0, 1), the implied prior on a=x2+y2a = \sqrt{x^2 + y^2} is Rayleigh-shaped — its mass is pushed away from a=0a = 0 — while the implied prior on dd is uniform. A ten-line prior-predictive simulation makes this visible:

x_prior <- rnorm(10000, 0, 1)
y_prior <- rnorm(10000, 0, 1)
a_prior <- sqrt(x_prior^2 + y_prior^2)
ggplot(data.frame(a = a_prior), aes(x = a)) +
  geom_histogram(bins = 60, fill = "grey35") +
  labs(
    x = "Amplitude implied by the priors on x and y",
    y = "Prior draws",
    title = "Rayleigh-shaped induced prior on amplitude"
  ) +
  theme_minimal()

round(c(prior_median = median(a_prior), prior_mass_below_0.1 =
          mean(a_prior < 0.1)), 3)
#>         prior_median prior_mass_below_0.1 
#>                1.164                0.005

This is not a defect — it is a modeling choice to be aware of: the prior mildly disfavors exactly-flat profiles. If your application needs prior mass concentrated near a=0a = 0, place priors on (a,d)(a, d) directly in a custom Stan model instead; its posterior draws can still be summarized here by converting them to (x,y)=(acosd,asind)(x, y) = (a \cos d, a \sin d) or by passing profile draws.

6. Where to go next

  • Per-person descriptive SSM parameters (no pooling): ssm_parameters_id() and its summary() method.
  • Hierarchical pooling across persons or groups is exactly what the brms/Stan route is for — fit the model you need and feed the posterior draws (parameter or profile shape) back through ssm_draws().
  • Frequentist inference on group profiles and contrasts: ssm_analyze().