--- title: "Bayesian SSM Analysis" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Bayesian SSM Analysis} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") set.seed(12345) library(ggplot2) ``` ```{r setup} library(circumplex) ``` ## 1. Why a Bayesian SSM? 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. ## 2. The cosine model as a linear regression 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°: ```{r known-direction} # 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) 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)$. A seeded subsample of 200 persons keeps the example light. ```{r data-prep} 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) ``` 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. ```{r brms-fit, eval = FALSE} 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)$: ```{r load-draws} draws <- readRDS("bayesian_ssm_draws.rds") dim(draws) head(round(draws, 3)) ``` `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"`: ```{r adapter} res <- ssm_draws(draws, type = "parameters") summary(res) ``` Each 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. ## 5. The induced prior on amplitude 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: ```{r induced-prior, fig.width = 5, fig.height = 3} 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) ``` 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 = 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. ## 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()`.