5  Simulation

Wir laden wie gewohnt das Paket tidyverse. Für die generativen Agentenmodelle nutzen wir das ellmer-Paket zur Interaktion mit verschiedenen Large Language Models, sowie das faux-Paket für die Monte-Carlo-Simulation und lme4 zur statistischen Auswertung.

library(tidyverse)
theme_set(theme_minimal())

library(ellmer)
library(faux)
library(lme4)

5.1 Generative Agenten

Die Definition expliziter Regeln ist bei agentenbasierten Simulationen oft eine Herausforderung. Daher werden zunehmend generative Agentenmodelle eingesetzt. Dieser Ansatz nutzt Large Language Models, um realistische Agentenantworten basierend auf textbasierten Aufgaben zu generieren, anstatt den datengenerierenden Prozess rein algorithmisch zu simulieren.

Zuerst richten wir die Verbindung zum JGU-LLM ein (vgl. das Kapitel Zero-Shot Klassifikation. Mit chat_openai_compatible() definieren wir die Schnittstelle (base_url) und das gewünschte Modell (auto). Dies erstellt das Objekt jgu_chat, über das wir Anfragen an die API senden können. Bitte beachten Sie, dass die Fähigkeiten der Modelle variieren – nicht alle unterstützen multimodale Aufgaben; manche sind auf Text beschränkt.

jgu_chat <- chat_openai_compatible(
  base_url = "https://ki-chat.uni-mainz.de/api",
  model = "auto", credentials = function() {
    "API_KEY_HIER"
  }
)

5.1.1 Ein einzelner LLM-Agent

Wir beginnen mit einem einfachen Beispiel: Wir lassen das Modell eine spezifische Persona annehmen, um Nachrichtenartikel nach ihrem Selektionspotential zu bewerten – ein klassisches Szenario der Forschung zur Nachrichtenauswahl.

Zuerst nutzen wir rvest, um Schlagzeilen von der BBC-News-Website zu laden. Wir extrahieren den Text aus den <h2>-Tags, entfernen Duplikate und ziehen eine Zufallsauswahl von fünf Schlagzeilen (headlines), die unsere Agenten anschließend bewerten werden.

library(rvest)

headlines <- read_html("https://www.bbc.com/news") |>
  html_elements("h2") |>
  html_text(trim = TRUE) |>
  unique() |>
  sample(5)

headlines
[1] "Watch: Father and daughter rescued after fall from Disney cruise ship"         
[2] "Moment United Airlines flight strikes vehicle during landing"                  
[3] "Wildfire tears through hundreds of acres in Arizona"                           
[4] "Antisemitism 'allowed to come into the open' says Bondi victim's daughter"     
[5] "'I was trying to save a life,' says man who intervened in Golders Green attack"

Nun definieren wir eine Persona: Eine 50-jährige schottische Frau, die sich kaum für Politik, dafür aber sehr für Unterhaltung, Wissenschaft und Sport interessiert. Mit parallel_chat_structured senden wir unsere Prompts parallel an das LLM. Durch das Argument type zwingen wir das Modell in ein strukturiertes Ausgabeformat: Wir erwarten einen booleschen Wert (read) und eine kurze Begründung (reason). Dies garantiert, dass die Antworten direkt maschinenlesbar weiterverarbeitet werden können.

prompts <- interpolate("You are a 50 year old Scottish woman who does not care much
about politics, but is quite interested in entertainment, science, and sports.
Would you read this article? Answer true/false and give a short reason.
Article: {{headlines}}")

answers <- parallel_chat_structured(jgu_chat, prompts,
  type = type_object(read = type_boolean(), reason = type_string())
)

tibble(article = headlines) |>
  bind_cols(answers)
# A tibble: 5 × 3
  article                                                           read  reason
  <chr>                                                             <lgl> <chr> 
1 Watch: Father and daughter rescued after fall from Disney cruise… TRUE  As a …
2 Moment United Airlines flight strikes vehicle during landing      FALSE The s…
3 Wildfire tears through hundreds of acres in Arizona               FALSE Not r…
4 Antisemitism 'allowed to come into the open' says Bondi victim's… FALSE I don…
5 'I was trying to save a life,' says man who intervened in Golder… FALSE Not r…

Die Ergebnisse zeigen, wie erfolgreich die Persona durch den Prompt gesteuert werden konnte.

5.1.2 Simuliertes Experiment

Als Nächstes simulieren wir ein vollständiges Experiment: Welchen Einfluss haben Nachrichtenton und Emojis auf die wahrgenommene Freundlichkeit einer WhatsApp-Nachricht? Wir verwenden dazu ein 2x3-Design mit den Faktoren Emoji (mit vs. ohne Emojis) und Tonfall (genervt, neutral, freundlich). Wir könnten es als einfaches Between-Subjects-Design simulieren, aber auch komplexere Szenarien umsetzen, etwa Within-Subject-Designs: Jede simulierte Person bewertet dabei mehrere Nachrichten aus verschiedenen Bedingungskombinationen.

Zuerst generieren wir mit dem JGU KI Modell 15 WhatsApp-Nachrichten über Alltagsthemen. Wir fordern das Modell auf, die Nachrichten in drei verschiedenen Tonfällen zu verfassen – ohne Emojis. Durch type_array() stellen wir sicher, dass wir eine strukturierte Liste zurückerhalten.

type_msg <- type_array(items = type_string())
messages <- jgu_chat$chat_structured("Generate 15 different Whatsapp messages about chores etc. that familymembers or flatmate would send to each other in daily life,
5 in a neutral tone, 5 in a slightly annoyed tone,
5 in a very friendly tone, all without emojis. Output JSON.",
  type = type_object(messages = type_msg)
)$messages

head(messages)
[1] "Could you please take out the trash before you leave for work?"        
[2] "The dishwasher is full again. Can you run it when you get a chance?"   
[3] "We’re out of milk. Could you pick some up on your way home?"           
[4] "Please don’t leave your shoes in the hallway. It’s getting crowded."   
[5] "I’m doing laundry later. Let me know if you want to add anything."     
[6] "Seriously, who left the dishes in the sink again? I just cleaned them."

Nun lassen wir das LLM die Nachrichten um Emojis ergänzen, um unsere zweite experimentelle Bedingung zu schaffen.

with_emojis <- jgu_chat$chat_structured(paste("Add many suiting emojis to every message.
                                                   The emojis can appear anywhere.", messages),
  type = type_object(messages = type_msg)
)$messages

tail(with_emojis)
[1] "I’m tired of being the only one who cleans the kitchen after dinner. 🍽️🧹😩"            
[2] "Hey, you’re awesome — thanks for doing the dishes last night! 🌟🍽️👏"                   
[3] "I made extra coffee this morning — it’s waiting for you if you’re still sleepy. ☕😴💖"
[4] "You left your keys on the counter again — I put them by the door for you :) 🗝️🚪😊"     
[5] "I’m heading to the store — want me to grab anything for you? 🛒🎁❤️"                    
[6] "You’re the best roommate ever — thanks for vacuuming while I was at work! 🧹🌟🤗"      

Wir führen die Daten in einem Tibble zusammen und transformieren sie mit pivot_longer() in das Langformat. So erhalten wir eine klare Struktur mit den Faktoren tone und condition (mit vs. ohne Emojis).

stimuli <- tibble(
  noemo = messages,
  emo = with_emojis,
  tone = rep(c("neutral", "annoyed", "friendly"), each = 5)
) |>
  pivot_longer(c(noemo, emo), names_to = "condition", values_to = "message")

stimuli |>
  sample_n(4)
# A tibble: 4 × 3
  tone     condition message                                                    
  <chr>    <chr>     <chr>                                                      
1 annoyed  emo       Seriously, who left the dishes in the sink again? I just c…
2 annoyed  noemo     I’m tired of being the only one who cleans the kitchen aft…
3 annoyed  noemo     Why is the fridge door always open? It’s wasting electrici…
4 friendly emo       You’re the best roommate ever — thanks for vacuuming while…

Anschließend generieren wir unsere virtuellen “Teilnehmer” (Agenten) durch die Kombination verschiedener demografischer Merkmale (gender und age) und wählen fünf zufällig aus.

respondents <- expand_grid(gender = c("man", "woman"), age = c(14, 25, 35, 50)) |>
  slice_sample(n = 5) |>
  rownames_to_column("respondent_id")

respondents
# A tibble: 5 × 3
  respondent_id gender   age
  <chr>         <chr>  <dbl>
1 1             man       50
2 2             man       35
3 3             man       14
4 4             woman     50
5 5             man       25

Für die eigentliche Simulation kombinieren wir alle Stimuli mit den Probanden und ziehen dann 8 Messages (4 mit und ohne Emoji) pro Proband. Für jede Kombination lassen wir das LLM die Freundlichkeit der Nachricht auf einer Skala von 1 bis 10 bewerten. Wir nutzen erneut chat_structured, um numerische Antworten zu erzwingen.

d_exp <- expand_grid(stimuli, respondents) |>
  group_by(respondent_id, condition) |>
  slice_sample(n = 4) |>
  mutate(
    task = interpolate("You are a {{age}} old {{gender}}.
                      You get the following message from your flatmate: {{message}}.
                      How friendly do you think the message is on a scale of 1 to 10?"),
    response = map(task, ~ jgu_chat$chat_structured(.x, type = type_object(friendly = type_number())))
  ) |>
  unnest_wider(response) |>
  unnest(friendly)

d_exp |>
  select(condition, tone, friendly)
# A tibble: 40 × 4
# Groups:   respondent_id, condition [10]
  respondent_id condition tone     friendly
  <chr>         <chr>     <chr>       <int>
1 1             emo       annoyed         8
2 1             emo       friendly        9
3 1             emo       annoyed         7
4 1             emo       neutral         7
5 1             noemo     friendly        9
# ℹ 35 more rows

Abschließend prüfen wir mit einem linearen gemischten Modell (lmer), ob Emojis und Tonalität einen signifikanten Einfluss auf die Freundlichkeitswahrnehmung haben. Der zufällige Intercept (1 | respondent_id) berücksichtigt dabei, dass Teilnehmer unterschiedliche Basisniveaus bei der Beurteilung haben könnten.

m1 <- lmer(friendly ~ condition * tone + (1 | respondent_id), d_exp)
m1 |>
  report::report_table()
Random effect variances not available. Returned R2 does not account for random effects.
Parameter                            | Coefficient |         95% CI | t(32)
---------------------------------------------------------------------------
(Intercept)                          |        5.50 | [ 4.96,  6.04] | 20.82
condition [no_emo]                   |       -1.17 | [-1.93, -0.41] | -3.12
tone [friendly]                      |        3.83 | [ 3.07,  4.59] | 10.26
tone [neutral]                       |        1.44 | [ 0.73,  2.15] |  4.11
condition [no_emo] × tone [friendly] |        0.62 | [-0.44,  1.68] |  1.19
condition [no_emo] × tone [neutral]  |        0.94 | [-0.08,  1.97] |  1.88
                                     |    1.94e-08 |                |      
                                     |        0.65 |                |      
                                     |             |                |      
AIC                                  |             |                |      
AICc                                 |             |                |      
BIC                                  |             |                |      
R2 (marginal)                        |             |                |      
Sigma                                |             |                |      

Parameter                            |      p | Effects |         Group
-----------------------------------------------------------------------
(Intercept)                          | < .001 |   fixed |              
condition [no_emo]                   | 0.004  |   fixed |              
tone [friendly]                      | < .001 |   fixed |              
tone [neutral]                       | < .001 |   fixed |              
condition [no_emo] × tone [friendly] | 0.242  |   fixed |              
condition [no_emo] × tone [neutral]  | 0.069  |   fixed |              
                                     |        |  random | respondent_id
                                     |        |  random |      Residual
                                     |        |         |              
AIC                                  |        |         |              
AICc                                 |        |         |              
BIC                                  |        |         |              
R2 (marginal)                        |        |         |              
Sigma                                |        |         |              

Parameter                            | Std. Coef. | Std. Coef. 95% CI |    Fit
------------------------------------------------------------------------------
(Intercept)                          |      -0.82 |    [-1.12, -0.52] |       
condition [no_emo]                   |      -0.65 |    [-1.07, -0.23] |       
tone [friendly]                      |       2.13 |    [ 1.71,  2.56] |       
tone [neutral]                       |       0.80 |    [ 0.40,  1.20] |       
condition [no_emo] × tone [friendly] |       0.34 |    [-0.24,  0.93] |       
condition [no_emo] × tone [neutral]  |       0.52 |    [-0.04,  1.09] |       
                                     |            |                   |       
                                     |            |                   |       
                                     |            |                   |       
AIC                                  |            |                   |  94.25
AICc                                 |            |                   |  98.90
BIC                                  |            |                   | 107.76
R2 (marginal)                        |            |                   |   0.87
Sigma                                |            |                   |   0.65

Mit avg_predictions berechnen wir die vorhergesagten Mittelwerte, um die Interaktionseffekte zu visualisieren.

marginaleffects::avg_predictions(m1, variables = c("condition", "tone"))

 condition     tone Estimate Std. Error    z Pr(>|z|)     S 2.5 % 97.5 %
    emo    annoyed      5.50      0.264 20.8   <0.001 317.3  4.98   6.02
    emo    friendly     9.33      0.264 35.3   <0.001 905.5  8.82   9.85
    emo    neutral      6.94      0.229 30.3   <0.001 668.3  6.49   7.39
    no_emo annoyed      4.33      0.264 16.4   <0.001 198.4  3.82   4.85
    no_emo friendly     8.79      0.245 35.9   <0.001 935.9  8.31   9.27
    no_emo neutral      6.71      0.245 27.4   <0.001 548.5  6.23   7.19

Type: response

5.2 Monte-Carlo-Simulation

Während agentenbasierte Modelle auf der Simulation individueller Akteure basieren, nutzt die Monte-Carlo-Simulation statistische Zufallsprozesse, um die Verteilung von Daten unter bestimmten Annahmen zu untersuchen. In der Forschungspraxis ist dies besonders hilfreich, um vor der eigentlichen Datenerhebung (z. B. in einem Pre-Registration-Prozess) zu prüfen, ob das geplante statistische Modell die erwarteten Effekte überhaupt sicher finden kann (Power-Analyse).

5.2.1 Between-Subjects-Design

Wir beginnen mit der Simulation eines einfachen Experiments und vergleichen hierbei die wahrgenommene Freundlichkeit von Nachrichten mit versus ohne Emojis.

Zuerst definieren wir die grundlegenden Parameter, die wir im gesamten Abschnitt wiederverenden werden: die experimentellen Bedingungen (condition), die erwarteten Mittelwerte und die Standardabweichung.

# Grundlegende Parameter - werden wiederverwendet
condition_factor <- list(condition = c("noemo", "emo"))
mu_values <- c(7.5, 8.5) # ohne Emojis: 7.5, mit Emojis: 8.5
sd_value <- 1.5

Mit der Funktion sim_design() aus dem Paket faux können wir Daten generieren, die unseren theoretischen Erwartungen entsprechen. Wir simulieren ein Between-Subjects-Design, bei dem jede Person nur eine Bedingung sieht.

df_between <- sim_design(
  between = condition_factor,
  mu = mu_values,
  sd = sd_value,
  n = 50,
  plot = FALSE
) |>
  as_tibble()

df_between
# A tibble: 100 × 3
  id    condition     y
  <chr> <fct>     <dbl>
1 S001  noemo      7.31
2 S002  noemo      4.68
3 S003  noemo      9.56
4 S004  noemo      8.99
5 S005  noemo      7.29
# ℹ 95 more rows

Nachdem wir die Daten generiert haben, führen wir einen t-Test durch, um zu prüfen, ob der simulierte Unterschied in unserer Stichprobe von \(N=100\) (50 pro Gruppe) statistisch signifikant ist.

t.test(y ~ condition, data = df_between) |>
  report::report_table() |>
  summary()
Difference |         95% CI | t(97.55) |      p |     d
-------------------------------------------------------
-1.43      | [-2.03, -0.83] |    -4.72 | < .001 | -0.96

Alternative hypothesis: two.sided

5.2.2 Power-Analyse

Nun definieren wir zwei Funktionen für die Simulation: eine für Between-Subjects-Designs (unabhängige Gruppen) und eine für Within-Subjects-Designs (Messwiederholung). Beide nutzen dieselben Parameter, die wir oben definiert haben. Der Unterschied ist, dass wir beim Within-Design within statt between verwenden und eine Korrelation r angeben. Letztere definiert, wie stabil die (personenspezifischen) Bewertungen sein könnten - von 0 (jede Bewertung ist völlig unabhängig von der vorherigen) bis 1 (alle Bewertungen desselben Probanden sind identisch). Mangels besserer Ideen legen wir die Korrelation (oder den ICC) auf .5 fest.

# Simulationsfunktion für Between-Subjects-Design
sim_between <- function(n) {
  sim_data <- sim_design(
    between = condition_factor,
    mu = mu_values,
    sd = sd_value,
    n = n,
    plot = FALSE
  )
  t.test(y ~ condition, data = sim_data)$p.value
}

# Simulationsfunktion für Within-Subjects-Design (gepaarter t-Test)
sim_within <- function(n) {
  sim_data <- sim_design(
    within = condition_factor,
    mu = mu_values,
    sd = sd_value,
    r = 0.5, # mittlere Stabilität
    n = n,
    long = TRUE,
    plot = FALSE
  )
  # In Wide-Format konvertieren für gepaarten t-Test
  sim_data_wide <- sim_data |>
    select(id, condition, y) |>
    pivot_wider(names_from = condition, values_from = y)
  t.test(sim_data_wide$emo, sim_data_wide$noemo, paired = TRUE)$p.value
}

Wir testen beide Funktionen einmal mit \(n=30\) und führen dann eine kurze Power-Analyse durch.

sim_between(n = 30)
[1] 0.02600864
sim_within(n = 30)
[1] 0.005637814
p_vals_between <- replicate(100, sim_between(n = 30))
power_between <- mean(p_vals_between <= .05)

p_vals_within <- replicate(100, sim_within(n = 30))
power_within <- mean(p_vals_within <= .05)

power_between
[1] 0.75
power_within
[1] 0.9

Das Within-Subjects-Design hat eine deutlich höhere Power bei derselben Stichprobengröße, da wir durch die Messwiederholungen präzisere Schätzungen der individuellen Baseline und damit auch der Einzelbewertungen erhalten. Je höher die personenspezifische Varianz oder Stabilität (r), desto ausgeprägter ist dieser Unterschied zum Between-Design.

5.2.3 Komplexeres Design (2x3)

Bisher haben wir einf Designs mit einem Faktor simuliert. In der Praxis haben Experimente oft mehrere Faktoren. Zum Abschluss simulieren wir nun ein 2x3-Design mit den Faktoren Emoji (mit vs. ohne Emojis) und Tonfall (genervt, neutral, freundlich).

Wir fassen den gesamten Prozess – von der Datengenerierung über die Modellierung der Effekte bis hin zur statistischen Auswertung – in einer einzigen Funktion sim_whatsapp_experiment() zusammen. Dies erlaubt es uns, flexibel mit verschiedenen Stichprobengrößen zu experimentieren.

sim_whatsapp_experiment <- function(n) {
  factors <- list(
    condition = c("noemo", "emo"),
    tone = c("annoyed", "neutral", "friendly")
  )

  # 1. Basisdaten generieren (Baseline)
  df <- sim_design(
    within = factors, mu = 4.5, sd = 1.5, r = 0.5, n = n,
    long = TRUE, plot = FALSE
  ) |>
    as_tibble() |>
    # 2. Theoretische Haupteffekte hinzufügen
    mutate(y = y +
      (condition == "emo") * 1.0 +
      (tone == "neutral") * 1.5 +
      (tone == "friendly") * 4.0)

  # 3. Statistisches Modell schätzen
  lmer(y ~ condition * tone + (1 | id), data = df)
}

Ein großer Vorteil dieses Ansatzes ist, dass wir vorab prüfen können, ob unser Modellcode korrekt ist. Wir führen die Simulation einmal für \(n=50\) durch:

m_example <- sim_whatsapp_experiment(n = 50)
m_example |>
  report::report_table() |>
  summary()
Parameter                         | Coefficient |        95% CI | t(292)
------------------------------------------------------------------------
(Intercept)                       |        4.69 | [ 4.26, 5.11] |  21.56
condition [emo]                   |        0.77 | [ 0.35, 1.19] |   3.59
tone [neutral]                    |        1.44 | [ 1.02, 1.86] |   6.72
tone [friendly]                   |        3.89 | [ 3.47, 4.32] |  18.20
condition [emo] × tone [neutral]  |        0.09 | [-0.51, 0.69] |   0.30
condition [emo] × tone [friendly] |        0.37 | [-0.23, 0.97] |   1.22
                                  |        1.10 |               |       
                                  |        1.07 |               |       
                                  |             |               |       
AICc                              |             |               |       
R2 (conditional)                  |             |               |       
R2 (marginal)                     |             |               |       
Sigma                             |             |               |       

Parameter                         |      p | Effects |    Group | Std. Coef. |     Fit
--------------------------------------------------------------------------------------
(Intercept)                       | < .001 |   fixed |          |      -0.96 |        
condition [emo]                   | < .001 |   fixed |          |       0.33 |        
tone [neutral]                    | < .001 |   fixed |          |       0.62 |        
tone [friendly]                   | < .001 |   fixed |          |       1.68 |        
condition [emo] × tone [neutral]  | 0.766  |   fixed |          |       0.04 |        
condition [emo] × tone [friendly] | 0.222  |   fixed |          |       0.16 |        
                                  |        |  random |       id |            |        
                                  |        |  random | Residual |            |        
                                  |        |         |          |            |        
AICc                              |        |         |          |            | 1012.06
R2 (conditional)                  |        |         |          |            |    0.79
R2 (marginal)                     |        |         |          |            |    0.57
Sigma                             |        |         |          |            |    1.07

Wenn wir nun wissen wollen, wie viele Versuchspersonen wir für eine zuverlässige Identifikation des Emoji-Effekts benötigen, nutzen wir wieder eine Power-Simulation. Wir extrahieren aus jedem Modelllauf gezielt den p-Wert für den Emo-Parameter.

get_p_val <- function(n) {
  m <- sim_whatsapp_experiment(n)
  report::report_table(m) |>
    slice(2) |> # EMO coefficient
    pull(p)
}

p_vals_mixed <- replicate(10, get_p_val(n = 20))
mean(p_vals_mixed <= .05)
[1] 0.8

Mit diesem Verfahren können wir nun systematisch die Stichprobengröße \(n\) variieren, bis wir die gewünschte Power erreichen. Wir können ebenso auch andere Koeffizienten untersuchen, etwa den Ton.

Hausaufgabe

  1. Simulieren Sie versuchsweise Ihr eigenes Experimentaldesign mit einem LLM (eine Hypothese reicht).
  2. Implementieren Sie eine Power-Analyse für Ihr Design. Wie viele Teilnehmende sind ungefähr notwendig, um eine Power von 80% zu erreichen?