The Structural Summary Method (SSM) describes a circumplex profile
with an elevation \(e\), an amplitude
\(a\), and a displacement \(d\). 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.
The SSM’s cosine model for a profile of scores \(S_j\) observed at scale angles \(\theta_j\) is
\[S_j = e + a \cos(\theta_j - d).\]
Expanding the cosine of a difference gives
\[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 \theta_j\) and \(\sin \theta_j\). The intercept is the elevation \(e\), the cosine coefficient is \(x\), and the sine coefficient is \(y\). The structural parameters are recovered by
\[a = \sqrt{x^2 + y^2}, \qquad d = \operatorname{atan2}(y,\, x),\]
with the displacement wrapped into \([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))) # swappedWe 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)\). 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.50Because 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.
The draws form a matrix with one row per posterior draw and three columns interpreted in column order as \((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.313ssm_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 FitEach posterior draw of \((x, y)\) was converted to a draw of \((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 \(\sqrt{x^2 + y^2}\) evaluated at the reported \(x\) and \(y\), and the reported displacement is not the direction of the reported \((x, y)\). Each is the honest marginal summary of its own posterior.
Model fit (\(R^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.
Independent priors on \(x\) and \(y\) do not induce a flat prior on the structural parameters. With \(x, y \sim \mathrm{Normal}(0, 1)\), the implied prior on \(a = \sqrt{x^2 + y^2}\) is Rayleigh-shaped — its mass is pushed away from \(a = 0\) — while the implied prior on \(d\) 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.005This 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 = 0\), place priors on \((a, d)\) directly in a custom Stan model instead; its posterior draws can still be summarized here by converting them to \((x, y) = (a \cos d, a \sin d)\) or by passing profile draws.
ssm_parameters_id() and its summary()
method.ssm_draws().ssm_analyze().