Simulating the Backlash Game.
Now let \(r\) be common knowledge.
After foreign choice: \[ \mu_F \;\geq\; T_F(r) \equiv \frac{1+r}{1+2r}. \]
After domestic choice: \[ \mu_D \;\geq\; T_D(r) \equiv \frac{1+r}{1+r/2}. \]
Given \((\theta_D,\theta_F)\) and \(r\):
Suppose \(\mu_F = 0.74\) after a foreign showcase.
Same performance, different electorates \(\Rightarrow\) backlash arises.
ChatGPT assisted in the generation of the following code. All creative work and modeling decisions are the original and sole contribution of the author.
# ---- setup ----
library(tidyverse)
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ dplyr 1.1.4 ✔ readr 2.1.5
## ✔ forcats 1.0.0 ✔ stringr 1.5.1
## ✔ ggplot2 3.5.2 ✔ tibble 3.2.1
## ✔ lubridate 1.9.4 ✔ tidyr 1.3.1
## ✔ purrr 1.0.4
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::lag() masks stats::lag()
## ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
set.seed(921) # I had this idea on the 21st night of september
# Prior belief for the showcased (max) competence under independent Uniform(0,1):
# mean = 2/3, var = 1/18 approx. 0.0555556
PRIOR_MEAN <- 2/3 # recall, the expectation of two uniform draws, taking the highest, is 2/3 (in a [0,1] distribution).
PRIOR_VAR <- 1/18
# Normal-Normal posterior mean update for continuous signals:
# prior: N(m0, s0^2); likelihood: x ~ N(theta, s^2)
# posterior mean for theta given x: (m0/s0^2 + x/s^2) / (1/s0^2 + 1/s^2)
post_mean_normal <- function(x, m0 = PRIOR_MEAN, s0 = sqrt(PRIOR_VAR), s = 0.20) {
num <- m0/(s0^2) + x/(s^2)
den <- 1/(s0^2) + 1/(s^2)
num / den
}
# Given the selection "showcase the max", the *hidden* competence (the min) has E[min | max=θ] = θ/2.
infer_hidden_mean <- function(mu_revealed) mu_revealed / 2
# Voter evaluation rule: weighted average vs 0.5
# Eval = (mu_D + r * mu_F) / (1 + r). Retain if Eval >= 0.5
retain_decision <- function(muD, muF, r) {
eval <- (muD + r * muF) / (1 + r)
eval >= 0.5
}
# Analytical thresholds (useful for checks/plots)
TF <- function(r) (1 + r) / (1 + 2*r) # required mu_F if Foreign is showcased
TD <- function(r) (1 + r) / (2 + r) # required mu_D if Domestic is showcased
simulate_hidden_r <- function(
N = 100000,
noise_sd = 0.20,
r_sampler = function(n) runif(n, 0.2, 2.0) # customize electorate mix here
) {
# Draw types
thetaD <- runif(N)
thetaF <- runif(N)
# Leader chooses the stronger domain (unknown r baseline)
choice <- ifelse(thetaF >= thetaD, "F", "D")
# Draw r per voter (one representative voter per leader for sim purposes)
r <- r_sampler(N)
# Generate signals & posteriors
# NOTE: Voters' prior for the revealed dimension already reflects "max" selection.
# We model x = theta + eps, eps ~ N(0, noise_sd^2), then compute posterior mean.
xF <- thetaF + rnorm(N, 0, noise_sd)
xD <- thetaD + rnorm(N, 0, noise_sd)
muF_revealed <- post_mean_normal(xF, s = noise_sd)
muD_revealed <- post_mean_normal(xD, s = noise_sd)
# Build muD, muF based on which side was revealed; infer hidden as half of revealed
muF <- ifelse(choice == "F", muF_revealed, infer_hidden_mean(muD_revealed))
muD <- ifelse(choice == "D", muD_revealed, infer_hidden_mean(muF_revealed))
retained <- retain_decision(muD, muF, r)
tibble(
thetaD, thetaF, r, choice, xF, xD, muF_revealed, muD_revealed, muF, muD, retained
)
}
# Example run + quick summaries
hidden_out <- simulate_hidden_r(N = 200000, noise_sd = 0.20)
hidden_out %>%
summarize(
share_foreign = mean(choice == "F"),
overall_retention = mean(retained),
retention_if_foreign = mean(retained[choice == "F"]),
retention_if_domestic = mean(retained[choice == "D"])
)
## # A tibble: 1 × 4
## share_foreign overall_retention retention_if_foreign retention_if_domestic
## <dbl> <dbl> <dbl> <dbl>
## 1 0.501 0.509 0.500 0.518
# Retention as a function of r, stratified by showcased domain
hidden_out %>%
mutate(choice = factor(choice, levels = c("D","F"))) %>%
group_by(choice, rbin = cut(r, breaks = seq(0.2, 2.0, by = 0.1), include.lowest = TRUE)) %>%
summarize(retain = mean(retained), r_mid = mean((as.numeric(rbin)-0.5)*0.1 + 0.2), .groups = "drop") %>%
ggplot(aes(r_mid, retain, color = choice)) +
geom_line(size = 1) +
labs(x = "r (relative weight on foreign)", y = "Retention rate",
color = "Showcased", title = "Hidden-r: Retention vs r by showcased domain") +
theme_minimal(base_size = 12)
## Warning: Using `size` aesthetic for lines was deprecated in ggplot2 3.4.0.
## ℹ Please use `linewidth` instead.
## This warning is displayed once every 8 hours.
## Call `lifecycle::last_lifecycle_warnings()` to see where this warning was
## generated.
# Compare observed posterior mu_revealed to analytical thresholds TF/TD (hidden-r):
# (Even though leader doesn't know r, voters do; we visualize where signals pass/fail for different r)
grid_r <- tibble(r = seq(0.2, 2, by = 0.01), TF = TF(r), TD = TD(r)) %>%
pivot_longer(c(TF, TD), names_to = "which", values_to = "thr")
ggplot(grid_r, aes(r, thr, color = which)) +
geom_line(size = 1) +
labs(x = "r", y = "Threshold", color = "Threshold",
title = "Analytical thresholds TF(r), TD(r)") +
theme_minimal(base_size = 12)
simulate_public_r <- function(
N = 10000,
noise_sd = 0.20,
r_sampler = function(n) runif(n, 0.2, 2.0),
alpha = 0.05 # small policy taste to break ties (only matters when both/none pass)
) {
thetaD <- runif(N)
thetaF <- runif(N)
r <- r_sampler(N)
# Leader evaluates viability using the analytical thresholds with mu ≈ theta
viableF <- thetaF >= TF(r)
viableD <- thetaD >= TD(r)
# Choice rule:
choice <- ifelse(
viableF & !viableD, "F",
ifelse(!viableF & viableD, "D",
ifelse(viableF & viableD,
# both viable: pick favorite; if no favorite given, pick stronger
ifelse(thetaF >= thetaD, "F", "D"),
# neither viable: pick favorite; if none, pick stronger (for display)
ifelse(thetaF >= thetaD, "F", "D")
))
)
# Generate signals and posteriors as in hidden-r, given realized choice
xF <- thetaF + rnorm(N, 0, noise_sd)
xD <- thetaD + rnorm(N, 0, noise_sd)
muF_revealed <- post_mean_normal(xF, s = noise_sd)
muD_revealed <- post_mean_normal(xD, s = noise_sd)
muF <- ifelse(choice == "F", muF_revealed, infer_hidden_mean(muD_revealed))
muD <- ifelse(choice == "D", muD_revealed, infer_hidden_mean(muF_revealed))
retained <- retain_decision(muD, muF, r)
tibble(thetaD, thetaF, r, choice, xF, xD, muF_revealed, muD_revealed, muF, muD, retained,
viableF, viableD)
}
public_out <- simulate_public_r(N = 200000, noise_sd = 0.20)
public_out %>%
summarize(
share_foreign = mean(choice == "F"),
overall_retention = mean(retained),
retention_if_foreign = mean(retained[choice == "F"]),
retention_if_domestic = mean(retained[choice == "D"])
)
## # A tibble: 1 × 4
## share_foreign overall_retention retention_if_foreign retention_if_domestic
## <dbl> <dbl> <dbl> <dbl>
## 1 0.498 0.512 0.507 0.517
# Share choosing Foreign vs r & retention vs r
public_out %>%
group_by(rbin = cut(r, breaks = seq(0.2, 2.0, by = 0.1), include.lowest = TRUE)) %>%
summarize(
r_mid = mean((as.numeric(rbin)-0.5)*0.1 + 0.2),
share_foreign = mean(choice == "F"),
retain = mean(retained),
.groups = "drop"
) %>%
pivot_longer(c(share_foreign, retain), names_to = "metric", values_to = "value") %>%
ggplot(aes(r_mid, value, color = metric)) +
geom_line(size = 1) +
labs(x = "r", y = "Rate", color = "Metric",
title = "Public-r: share choosing Foreign & retention vs r") +
theme_minimal(base_size = 12)
# Hidden-r: condition on foreign showcase and test the pass/fail boundary
# For each r bin, compute the fraction of cases with muF >= TF(r), and compare to retention
chk_hidden <- hidden_out %>%
filter(choice == "F") %>%
mutate(passF = muF >= TF(r)) %>%
group_by(rbin = cut(r, breaks = seq(0.2, 2.0, by = 0.1), include.lowest = TRUE)) %>%
summarize(
r_mid = mean((as.numeric(rbin)-0.5)*0.1 + 0.2),
frac_pass = mean(passF),
frac_ret = mean(retained),
.groups = "drop"
)
ggplot(chk_hidden, aes(r_mid)) +
geom_line(aes(y = frac_pass, color = "Above TF(r)")) +
geom_line(aes(y = frac_ret, color = "Retained")) +
labs(x = "r", y = "Fraction (foreign-showcase cases only)",
color = "", title = "Hidden-r: passing TF(r) aligns with retention") +
theme_minimal(base_size = 12)
# Public-r: the choice rule uses thresholds; realized retention should align closely
chk_public <- public_out %>%
mutate(
passF = (choice == "F") & (muF >= TF(r)),
passD = (choice == "D") & (muD >= TD(r))
) %>%
group_by(rbin = cut(r, breaks = seq(0.2, 2.0, by = 0.1), include.lowest = TRUE)) %>%
summarize(
r_mid = mean((as.numeric(rbin)-0.5)*0.1 + 0.2),
frac_ret = mean(retained),
frac_pass = mean(passF | passD),
share_F = mean(choice == "F"),
.groups = "drop"
)
ggplot(chk_public, aes(r_mid)) +
geom_line(aes(y = frac_pass, color = "Pass threshold")) +
geom_line(aes(y = frac_ret, color = "Retained")) +
geom_line(aes(y = share_F, color = "Share choosing F"), linetype = 2) +
labs(x = "r", y = "Rate", color = "",
title = "Public-r: thresholds, retention, and foreign choice vs r") +
theme_minimal(base_size = 12)
library(tidyverse)
# binary indicators
df <- public_out %>%
transmute(r,
retained_i = as.integer(retained),
showF_i = as.integer(choice == "F"))
# correlation by r-bin + CIs (Fisher z)
bw <- 0.10
corr_by_r <- df %>%
mutate(rbin = cut(r, breaks = seq(0.2, 2.0, by = bw), include.lowest = TRUE)) %>%
group_by(rbin) %>%
summarize(
r_mid = mean((as.numeric(rbin)-0.5)*bw + 0.2),
n = n(),
cor_rf = cor(retained_i, showF_i), # Pearson = point-biserial
.groups = "drop"
) %>%
mutate(
z = atanh(cor_rf),
se = 1 / sqrt(pmax(n - 3, 1)),
zlo = z - 1.96*se, zhi = z + 1.96*se,
lo = tanh(zlo), hi = tanh(zhi)
)
ggplot(corr_by_r, aes(r_mid, cor_rf)) +
geom_hline(yintercept = 0, linetype = 3) +
geom_ribbon(aes(ymin = lo, ymax = hi), alpha = 0.15) +
geom_line(size = 1) +
labs(x = "r", y = "Corr(retained, show Foreign)",
title = "Public-r: correlation between retention and choosing Foreign by r") +
theme_minimal(12)