Skip to content

Commit 85c26bb

Browse files
committed
feat: Add demos directory with group contrast example
- Create demos/ directory for educational examples - Add group_contrast_example.R with complete workflow: * Data simulation for 2 groups with different parameters * Hierarchical BRMS model with random effects * Model diagnostics and convergence checks * Group difference analysis with credible intervals * Comprehensive visualization of fitted functions * Parameter summaries with uncertainty quantification - Fix Stan compatibility issues (pnorm -> Phi) - Use dynamic parameter detection for robust analysis - Include both observed data and fitted functions in plots This provides a clean, educational example for learning hierarchical psychometric function modeling with BRMS.
1 parent fcf581d commit 85c26bb

File tree

2 files changed

+387
-0
lines changed

2 files changed

+387
-0
lines changed

Hierarchical-Interoception.Rproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
Version: 1.0
2+
ProjectId: 8a1aba41-2805-488b-8896-2ab1a1adcc67
23

34
RestoreWorkspace: Default
45
SaveWorkspace: Default

demos/group_contrast_example.R

Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
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("\nModel Summary:\n")
164+
print(fit)
165+
166+
# Check R-hat values
167+
cat("\nR-hat values (should be ≤ 1.01):\n")
168+
print(rhat(fit))
169+
170+
# Check effective sample sizes
171+
cat("\nEffective 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("\nGroup 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("\nFitted 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("\nAnalysis complete! Check the plots for visual results.\n")

0 commit comments

Comments
 (0)