1+ # Simple Example: Hierarchical BRMS for Interoception Data
2+ # Based on the BRMS demo but simplified for 2 groups, single condition
3+
4+ library(tidyverse )
5+ library(brms )
6+ library(cmdstanr )
7+
8+ # Utility function
9+ inv_logit <- function (x ) {
10+ y = 1 / (1 + exp(- x ))
11+ return (y )
12+ }
13+
14+ # Set seed for reproducibility
15+ set.seed(12345 )
16+
17+ # ============================================================================
18+ # DATA SIMULATION
19+ # ============================================================================
20+
21+ # Design parameters
22+ n_subj_per_group <- 25 # 25 subjects per group
23+ n_trials_per_intensity <- 20 # 20 trials per intensity level
24+ intensities <- seq(- 30 , 30 , 5 ) # Stimulus intensities from -30 to +30
25+ n_groups <- 2
26+
27+ # Group-level parameters (different for each group)
28+ group_params <- list (
29+ group1 = list (
30+ mu_alpha = - 5.0 , # Threshold for group 1
31+ sd_alpha = 8.0 ,
32+ mu_beta = - 2.0 , # Slope for group 1
33+ sd_beta = 0.4 ,
34+ mu_lambda = - 3.5 , # Lapse rate for group 1
35+ sd_lambda = 1.5
36+ ),
37+ group2 = list (
38+ mu_alpha = 2.0 , # Threshold for group 2 (higher = worse performance)
39+ sd_alpha = 8.0 ,
40+ mu_beta = - 1.8 , # Slope for group 2 (steeper = better sensitivity)
41+ sd_beta = 0.4 ,
42+ mu_lambda = - 3.0 , # Lapse rate for group 2
43+ sd_lambda = 1.5
44+ )
45+ )
46+
47+ # Simulate subject-level parameters
48+ subjects <- expand.grid(
49+ group = 1 : n_groups ,
50+ subj = 1 : n_subj_per_group
51+ ) %> %
52+ mutate(
53+ # Generate subject-specific parameters
54+ alpha = case_when(
55+ group == 1 ~ rnorm(n(), group_params $ group1 $ mu_alpha , group_params $ group1 $ sd_alpha ),
56+ group == 2 ~ rnorm(n(), group_params $ group2 $ mu_alpha , group_params $ group2 $ sd_alpha )
57+ ),
58+ beta = case_when(
59+ group == 1 ~ rnorm(n(), group_params $ group1 $ mu_beta , group_params $ group1 $ sd_beta ),
60+ group == 2 ~ rnorm(n(), group_params $ group2 $ mu_beta , group_params $ group2 $ sd_beta )
61+ ),
62+ lambda = case_when(
63+ group == 1 ~ rnorm(n(), group_params $ group1 $ mu_lambda , group_params $ group1 $ sd_lambda ),
64+ group == 2 ~ rnorm(n(), group_params $ group2 $ mu_lambda , group_params $ group2 $ sd_lambda )
65+ )
66+ )
67+
68+ # Expand to create trial-level data
69+ sim_data <- subjects %> %
70+ # Create all intensity levels for each subject
71+ crossing(intensity = intensities ) %> %
72+ # Create multiple trials per intensity
73+ crossing(trial = 1 : n_trials_per_intensity ) %> %
74+ # Calculate psychometric function
75+ mutate(
76+ # Transform parameters to ensure constraints
77+ beta_transformed = exp(beta ),
78+ lambda_transformed = inv_logit(lambda ) / 2 ,
79+
80+ # Calculate probability of correct response
81+ theta = lambda_transformed +
82+ (1 - 2 * lambda_transformed ) *
83+ (0.5 + 0.5 * pnorm(beta_transformed * (intensity - alpha ) / sqrt(2 ))),
84+
85+ # Generate binary responses
86+ response = rbinom(n(), 1 , theta )
87+ ) %> %
88+ # Create unique subject IDs
89+ mutate(subj_id = paste0(" group" , group , " _subj" , subj )) %> %
90+ # Select relevant columns and ensure group is a factor
91+ select(subj_id , group , intensity , response , theta ) %> %
92+ mutate(group = factor (group , levels = c(1 , 2 ), labels = c(" group1" , " group2" ))) %> %
93+ # Group by subject and intensity to get summary statistics
94+ group_by(subj_id , group , intensity , theta ) %> %
95+ summarise(
96+ n_trials = n(),
97+ n_correct = sum(response ),
98+ .groups = ' drop'
99+ )
100+
101+ # ============================================================================
102+ # DATA VISUALIZATION
103+ # ============================================================================
104+
105+ # Plot individual psychometric functions
106+ ggplot(sim_data , aes(x = intensity , y = n_correct / n_trials , color = group )) +
107+ geom_point(alpha = 0.6 ) +
108+ geom_line(aes(y = theta , group = subj_id ), alpha = 0.3 ) +
109+ facet_wrap(~ group , labeller = labeller(group = c(" group1" = " Group 1" , " group2" = " Group 2" ))) +
110+ theme_minimal() +
111+ labs(
112+ title = " Simulated Psychometric Functions" ,
113+ x = " Stimulus Intensity (ΔBPM)" ,
114+ y = " P(correct response)" ,
115+ color = " Group"
116+ ) +
117+ scale_color_manual(values = c(" #0072B2" , " #E69F00" ))
118+
119+ # ============================================================================
120+ # BRMS MODEL FITTING
121+ # ============================================================================
122+
123+ # Define the BRMS formula for hierarchical psychometric function
124+ # Note: Using Phi() for Stan's cumulative normal distribution (equivalent to R's pnorm)
125+ brm_formula <- bf(
126+ n_correct | trials(n_trials ) ~
127+ (inv_logit(lambda ) / 2 +
128+ (1 - inv_logit(lambda )) *
129+ (0.5 + 0.5 * Phi(exp(beta ) * (intensity - alpha ) / sqrt(2 )))),
130+ alpha ~ group + (1 | subj_id ), # Random intercepts for subjects
131+ beta ~ group + (1 | subj_id ), # Random intercepts for subjects
132+ lambda ~ (1 | subj_id ), # Random intercepts for subjects
133+ nl = TRUE ,
134+ family = binomial(link = " identity" )
135+ )
136+
137+ # Use default priors for simplicity (BRMS will automatically set appropriate priors)
138+ priors <- NULL
139+
140+ # Fit the model
141+ cat(" Fitting hierarchical BRMS model...\n " )
142+ fit <- brm(
143+ brm_formula ,
144+ data = sim_data ,
145+ prior = priors ,
146+ chains = 4 ,
147+ cores = 4 ,
148+ seed = 12345 ,
149+ backend = " cmdstan" ,
150+ warmup = 1000 ,
151+ iter = 2000 ,
152+ control = list (
153+ adapt_delta = 0.95 ,
154+ max_treedepth = 10
155+ )
156+ )
157+
158+ # ============================================================================
159+ # MODEL DIAGNOSTICS
160+ # ============================================================================
161+
162+ # Check convergence
163+ cat(" \n Model Summary:\n " )
164+ print(fit )
165+
166+ # Check R-hat values
167+ cat(" \n R-hat values (should be ≤ 1.01):\n " )
168+ print(rhat(fit ))
169+
170+ # Check effective sample sizes
171+ cat(" \n Effective sample sizes (should be ≥ 400):\n " )
172+ print(neff_ratio(fit ))
173+
174+ # ============================================================================
175+ # RESULTS INTERPRETATION
176+ # ============================================================================
177+
178+ # Extract posterior samples
179+ posterior_samples <- as_draws_df(fit )
180+
181+ # Group-level differences - using parameter names from the fitted model
182+ posterior_samples <- as_draws_df(fit )
183+
184+ # Get the actual parameter names from the model
185+ param_names <- names(posterior_samples )
186+ alpha_group_param <- param_names [grepl(" b_alpha_group" , param_names ) & param_names != " b_alpha_Intercept" ][1 ]
187+ beta_group_param <- param_names [grepl(" b_beta_group" , param_names ) & param_names != " b_beta_Intercept" ][1 ]
188+
189+ # Extract the relevant parameters
190+ group_differences <- posterior_samples %> %
191+ select(
192+ b_alpha_Intercept , !! sym(alpha_group_param ),
193+ b_beta_Intercept , !! sym(beta_group_param )
194+ ) %> %
195+ mutate(
196+ # Calculate group means
197+ alpha_group1 = b_alpha_Intercept ,
198+ alpha_group2 = b_alpha_Intercept + !! sym(alpha_group_param ),
199+ beta_group1 = b_beta_Intercept ,
200+ beta_group2 = b_beta_Intercept + !! sym(beta_group_param ),
201+
202+ # Calculate differences
203+ alpha_diff = !! sym(alpha_group_param ),
204+ beta_diff = !! sym(beta_group_param )
205+ ) %> %
206+ summarise(
207+ # Threshold differences
208+ alpha_diff_mean = mean(alpha_diff ),
209+ alpha_diff_lower = quantile(alpha_diff , 0.025 ),
210+ alpha_diff_upper = quantile(alpha_diff , 0.975 ),
211+ alpha_diff_p = 2 * min(mean(alpha_diff > 0 ), mean(alpha_diff < 0 )),
212+
213+ # Slope differences
214+ beta_diff_mean = mean(beta_diff ),
215+ beta_diff_lower = quantile(beta_diff , 0.025 ),
216+ beta_diff_upper = quantile(beta_diff , 0.975 ),
217+ beta_diff_p = 2 * min(mean(beta_diff > 0 ), mean(beta_diff < 0 ))
218+ )
219+
220+ cat(" \n Group Differences:\n " )
221+ cat(" Threshold (α) difference [95% CI]:" ,
222+ round(group_differences $ alpha_diff_mean , 2 ),
223+ " [" , round(group_differences $ alpha_diff_lower , 2 ), " ," ,
224+ round(group_differences $ alpha_diff_upper , 2 ), " ]\n " )
225+ cat(" Pseudo p-value for threshold difference:" ,
226+ round(group_differences $ alpha_diff_p , 3 ), " \n " )
227+
228+ cat(" Slope (β) difference [95% CI]:" ,
229+ round(group_differences $ beta_diff_mean , 2 ),
230+ " [" , round(group_differences $ beta_diff_lower , 2 ), " ," ,
231+ round(group_differences $ beta_diff_upper , 2 ), " ]\n " )
232+ cat(" Pseudo p-value for slope difference:" ,
233+ round(group_differences $ beta_diff_p , 3 ), " \n " )
234+
235+ # ============================================================================
236+ # VISUALIZATION OF RESULTS
237+ # ============================================================================
238+
239+ # Plot posterior distributions of group differences
240+ posterior_samples %> %
241+ select(!! sym(alpha_group_param ), !! sym(beta_group_param )) %> %
242+ pivot_longer(everything(), names_to = " parameter" , values_to = " value" ) %> %
243+ mutate(parameter = case_when(
244+ parameter == alpha_group_param ~ " Threshold Difference (α)" ,
245+ parameter == beta_group_param ~ " Slope Difference (β)"
246+ )) %> %
247+ ggplot(aes(x = value , fill = parameter )) +
248+ geom_density(alpha = 0.7 ) +
249+ geom_vline(xintercept = 0 , linetype = " dashed" , color = " red" ) +
250+ facet_wrap(~ parameter , scales = " free_x" ) +
251+ theme_minimal() +
252+ labs(
253+ title = " Posterior Distributions of Group Differences" ,
254+ x = " Difference (Group 2 - Group 1)" ,
255+ y = " Density"
256+ ) +
257+ theme(legend.position = " none" )
258+
259+ # ============================================================================
260+ # VISUALIZATION OF FITTED FUNCTIONS
261+ # ============================================================================
262+
263+ # Create a grid of intensity values for plotting
264+ intensity_grid <- seq(- 30 , 30 , 0.5 )
265+
266+ # Extract posterior samples for group-level parameters
267+ posterior_summary <- posterior_samples %> %
268+ select(
269+ b_alpha_Intercept , !! sym(alpha_group_param ),
270+ b_beta_Intercept , !! sym(beta_group_param ),
271+ b_lambda_Intercept
272+ ) %> %
273+ mutate(
274+ # Calculate group means
275+ alpha_group1 = b_alpha_Intercept ,
276+ alpha_group2 = b_alpha_Intercept + !! sym(alpha_group_param ),
277+ beta_group1 = b_beta_Intercept ,
278+ beta_group2 = b_beta_Intercept + !! sym(beta_group_param ),
279+ lambda_group1 = b_lambda_Intercept ,
280+ lambda_group2 = b_lambda_Intercept # No group effect on lambda in this model
281+ )
282+
283+ # Generate fitted functions for each group
284+ fitted_functions <- expand.grid(
285+ intensity = intensity_grid ,
286+ group = c(" group1" , " group2" ),
287+ sample = 1 : 100 # Use first 100 posterior samples for efficiency
288+ ) %> %
289+ left_join(
290+ posterior_summary %> %
291+ slice(1 : 100 ) %> %
292+ mutate(sample = 1 : 100 ),
293+ by = " sample"
294+ ) %> %
295+ mutate(
296+ # Get the appropriate parameters for each group
297+ alpha = case_when(
298+ group == " group1" ~ alpha_group1 ,
299+ group == " group2" ~ alpha_group2
300+ ),
301+ beta = case_when(
302+ group == " group1" ~ beta_group1 ,
303+ group == " group2" ~ beta_group2
304+ ),
305+ lambda = case_when(
306+ group == " group1" ~ lambda_group1 ,
307+ group == " group2" ~ lambda_group2
308+ ),
309+
310+ # Calculate fitted probability
311+ fitted_prob = inv_logit(lambda ) / 2 +
312+ (1 - inv_logit(lambda )) *
313+ (0.5 + 0.5 * pnorm(exp(beta ) * (intensity - alpha ) / sqrt(2 )))
314+ )
315+
316+ # Calculate mean and credible intervals for each group
317+ fitted_summary <- fitted_functions %> %
318+ group_by(intensity , group ) %> %
319+ summarise(
320+ mean_prob = mean(fitted_prob ),
321+ lower_ci = quantile(fitted_prob , 0.025 ),
322+ upper_ci = quantile(fitted_prob , 0.975 ),
323+ .groups = ' drop'
324+ ) %> %
325+ mutate(group_label = case_when(
326+ group == " group1" ~ " Group 1" ,
327+ group == " group2" ~ " Group 2"
328+ ))
329+
330+ # Plot fitted functions with credible intervals
331+ ggplot() +
332+ # Add credible intervals
333+ geom_ribbon(data = fitted_summary ,
334+ aes(x = intensity , ymin = lower_ci , ymax = upper_ci , fill = group_label ),
335+ alpha = 0.3 ) +
336+ # Add mean fitted functions
337+ geom_line(data = fitted_summary ,
338+ aes(x = intensity , y = mean_prob , color = group_label ),
339+ linewidth = 1.5 ) +
340+ # Add observed data points
341+ geom_point(data = sim_data ,
342+ aes(x = intensity , y = n_correct / n_trials , color = factor (group ,
343+ levels = c(" group1" , " group2" ),
344+ labels = c(" Group 1" , " Group 2" ))),
345+ alpha = 0.6 , size = 2 ) +
346+ # Add individual subject lines (faded)
347+ geom_line(data = sim_data ,
348+ aes(x = intensity , y = theta , group = subj_id ,
349+ color = factor (group ,
350+ levels = c(" group1" , " group2" ),
351+ labels = c(" Group 1" , " Group 2" ))),
352+ alpha = 0.2 ) +
353+ # Customize appearance
354+ scale_color_manual(values = c(" #0072B2" , " #E69F00" )) +
355+ scale_fill_manual(values = c(" #0072B2" , " #E69F00" )) +
356+ theme_minimal() +
357+ labs(
358+ title = " Fitted Psychometric Functions by Group" ,
359+ subtitle = " Solid lines = fitted means, Shaded areas = 95% credible intervals, Points = observed data" ,
360+ x = " Stimulus Intensity (ΔBPM)" ,
361+ y = " P(correct response)" ,
362+ color = " Group" ,
363+ fill = " Group"
364+ ) +
365+ theme(
366+ legend.position = " bottom" ,
367+ text = element_text(size = 12 )
368+ ) +
369+ coord_cartesian(xlim = c(- 30 , 30 ), ylim = c(0 , 1 ))
370+
371+ # Print summary of fitted parameters
372+ cat(" \n Fitted Group-Level Parameters:\n " )
373+ cat(" Group 1 - Threshold (α):" , round(mean(posterior_summary $ alpha_group1 ), 2 ),
374+ " [" , round(quantile(posterior_summary $ alpha_group1 , 0.025 ), 2 ), " ," ,
375+ round(quantile(posterior_summary $ alpha_group1 , 0.975 ), 2 ), " ]\n " )
376+ cat(" Group 2 - Threshold (α):" , round(mean(posterior_summary $ alpha_group2 ), 2 ),
377+ " [" , round(quantile(posterior_summary $ alpha_group2 , 0.025 ), 2 ), " ," ,
378+ round(quantile(posterior_summary $ alpha_group2 , 0.975 ), 2 ), " ]\n " )
379+ cat(" Group 1 - Slope (β):" , round(mean(exp(posterior_summary $ beta_group1 )), 3 ),
380+ " [" , round(quantile(exp(posterior_summary $ beta_group1 ), 0.025 ), 3 ), " ," ,
381+ round(quantile(exp(posterior_summary $ beta_group1 ), 0.975 ), 3 ), " ]\n " )
382+ cat(" Group 2 - Slope (β):" , round(mean(exp(posterior_summary $ beta_group2 )), 3 ),
383+ " [" , round(quantile(exp(posterior_summary $ beta_group2 ), 0.025 ), 3 ), " ," ,
384+ round(quantile(exp(posterior_summary $ beta_group2 ), 0.975 ), 3 ), " ]\n " )
385+
386+ cat(" \n Analysis complete! Check the plots for visual results.\n " )
0 commit comments