We have two CSV exports of Bluesky search results: one for posts mentioning Xavier Becerra (D) and one for Steve Hilton (R). The goal is to turn those posts into (a) an estimate of each candidate’s vote share and (b) a win probability.
Bluesky users are not a random sample of California voters. The platform skews left and the posts come from keyword searches. So we treat the social-media signal as a weak, bias-adjusted input and anchor the forecast on polls. Every assumption is labeled so you can change it and see what happens.
Each row’s record column is a flattened R list:
"app.bsky.feed.post; <createdAt>; [lang;] [list(...) blocks;] <text>".
The helper below strips the metadata and keeps only the post text.
setwd("C:/Users/zxu3/Documents/R/bluesky")
path_becerra <- "post_Xavier.csv"
path_hilton <- "post_steve.csv"
# `record` is a flattened R list: "app.bsky.feed.post; <createdAt>; [lang;]
# [list(...) blocks;] <text>". We strip the metadata and keep the text.
clean_record <- function(rec) {
body <- sub("^app\\.bsky\\.feed\\.post; [^;]+; ?", "", rec)
repeat {
old <- body
body <- sub("(?s)^list\\(.*?\\)\\)\\); ?", "", body, perl = TRUE) # embeds/facets
body <- sub("^[a-z]{2,3}(-[A-Za-z]+)?; ", "", body) # language tag
if (identical(old, body)) break
}
trimws(body)
}
read_posts <- function(path, source_file) {
read_csv(path, show_col_types = FALSE) |>
transmute(
uri,
source_file = source_file,
author_did = str_extract(author, "^[^;]+"),
handle = str_trim(str_split_i(author, ";", 2)),
created_at = str_extract(record, "(?<=app\\.bsky\\.feed\\.post; )[^;]+"),
text = map_chr(record, clean_record),
likes = like_count, reposts = repost_count, replies = reply_count,
quotes = quote_count
)
}
posts_raw <- bind_rows(
read_posts(path_becerra, "Becerra search"),
read_posts(path_hilton, "Hilton search")
)
# 140 posts appear in both files -> keep each post once
posts <- posts_raw |>
distinct(uri, .keep_all = TRUE) |>
filter(nzchar(text))
Now we read both files, stack them, and keep each post once. The two searches overlap, so some posts show up in both files.
posts_raw <- bind_rows(
read_posts(path_becerra, "Becerra search"),
read_posts(path_hilton, "Hilton search")
)
posts <- posts_raw |>
distinct(uri, .keep_all = TRUE) |>
filter(nzchar(text))
tibble(raw_rows = nrow(posts_raw), unique_posts = nrow(posts)) |> kbl(digits = 3) |> kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
| raw_rows | unique_posts |
|---|---|
| 1000 | 845 |
A simple name match labels each post as about Becerra, Hilton, both, or neither. We also build one engagement number per post.
posts <- posts |>
mutate(
about_becerra = str_detect(text, regex("becerra|xavier", ignore_case = TRUE)),
about_hilton = str_detect(text, regex("hilton", ignore_case = TRUE)),
target = case_when(
about_becerra & about_hilton ~ "Both",
about_becerra ~ "Becerra",
about_hilton ~ "Hilton",
TRUE ~ "Neither"
),
engagement = likes + 2 * reposts + replies + quotes
)
One account can post dozens of times. We divide each post’s weight by its author’s post count, then add a small log-scale bonus for engagement. The table shows the heaviest posters so you can eyeball whether they look like bots or news aggregators.
# (a) each author's total weight is capped so one account can't dominate
# (b) engagement adds a little weight, on a log scale
author_counts <- posts |> count(author_did, name = "n_author")
posts <- posts |>
left_join(author_counts, by = "author_did") |>
mutate(w = (1 / n_author) * (1 + log1p(engagement)))
posts |>
count(handle, sort = TRUE) |>
slice_head(n = 10) |>
kbl(digits = 3) |> kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
| handle | n |
|---|---|
| andyheppelle.bsky.social | 74 |
| bluegitana.bsky.social | 37 |
| davidsicairos.bsky.social | 23 |
| christopretensism.bsky.social | 19 |
| nypost.com | 17 |
| porcellato.bsky.social | 15 |
| bsfavourite.bsky.social | 13 |
| plaintanjane.bsky.social | 13 |
| judgement-bot.bsky.social | 12 |
| aldxyz.bsky.social | 9 |
We score each post with the bing lexicon (positive
vs. negative words, excluding the candidates’ names). We also flag
explicit statements such as “voted for Becerra”.
Caution. Many posts are neutral news headlines, so a lexicon is a crude measure of stance. Hand-coding a sample of posts would be a stronger check.
bing <- get_sentiments("bing")
name_words <- c("becerra", "xavier", "hilton", "steve", "california", "governor")
post_sentiment <- posts |>
select(uri, text) |>
unnest_tokens(word, text) |>
filter(!word %in% name_words) |>
inner_join(bing, by = "word") |>
count(uri, sentiment) |>
pivot_wider(names_from = sentiment, values_from = n, values_fill = 0) |>
mutate(net = (positive - negative) / (positive + negative))
posts <- posts |>
left_join(post_sentiment, by = "uri") |>
mutate(
across(c(positive, negative), ~ replace_na(.x, 0)),
net = replace_na(net, 0),
tone = case_when(net > 0 ~ 1, net < 0 ~ -1, TRUE ~ 0)
)
# Explicit intent: "vote Hilton", "I voted for Becerra", ...
vote_for <- function(name) {
regex(as.character(str_glue("(vote[ds]?|voting)( for)?( \\w+){{0,2}} {name}")),
ignore_case = TRUE)
}
posts <- posts |>
mutate(
intent_becerra = str_detect(text, vote_for("(xavier )?becerra")),
intent_hilton = str_detect(text, vote_for("(steve )?hilton"))
)
posts |>
summarise(intent_becerra = sum(intent_becerra), intent_hilton = sum(intent_hilton)) |>
kbl(digits = 3) |> kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
| intent_becerra | intent_hilton |
|---|---|
| 56 | 21 |
summary_tbl <- posts |>
filter(target %in% c("Becerra", "Hilton")) |>
group_by(target) |>
summarise(
posts = n(),
weighted_posts = sum(w),
mean_likes = mean(likes),
pct_positive = mean(tone == 1),
pct_negative = mean(tone == -1),
net_tone = weighted.mean(tone, w),
.groups = "drop"
)
summary_tbl |> kbl(digits = 3) |> kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
| target | posts | weighted_posts | mean_likes | pct_positive | pct_negative | net_tone |
|---|---|---|---|---|---|---|
| Becerra | 296 | 219.608 | 3.172 | 0.240 | 0.307 | 0.216 |
| Hilton | 293 | 367.467 | 6.945 | 0.307 | 0.300 | -0.010 |
# Daily volume
posts |>
filter(target %in% c("Becerra", "Hilton")) |>
mutate(day = ymd(str_sub(created_at, 1, 10), quiet = TRUE)) |>
drop_na(day) |> # ~2% of records have no timestamp in the export
count(day, target) |>
ggplot(aes(day, n, color = target)) +
geom_line(linewidth = 1) +
scale_color_manual(values = c(Becerra = "#2c6fbb", Hilton = "#c0392b")) +
labs(title = "Bluesky posts per day", x = NULL, y = "Posts", color = NULL) +
theme_minimal()
The three rows below are example inputs taken from poll posts inside your data. Verify them at the original source, or replace them with your preferred polling average.
The poll standard deviation adds a 3-point allowance for house effects and late shifts on top of pure sampling error. That is a judgment call.
polls <- tribble(
~pollster, ~n, ~becerra, ~hilton,
"Berkeley IGS", 1477, 61, 34,
"PPIC", 1086, 61, 37,
"SurveyUSA", 799, 54, 32
) |>
mutate(share = becerra / (becerra + hilton)) # two-candidate share
poll_mean <- weighted.mean(polls$share, polls$n)
# Sampling error alone is too optimistic. Add a polling-error term (SD, in share
# points) for house effects and late shifts. 0.03 is a judgment call; change it.
poll_sd <- sqrt(1 / sum(polls$n) * 0.25 + 0.03^2)
Bluesky skews left, so we subtract an assumed 10-point bias from the social estimate and give it a large uncertainty. These are assumptions, not estimates. The two sources are then combined with precision weights (a normal-normal model), and Monte Carlo draws give the win probability.
# and check how much the answer moves (sensitivity table below).
platform_bias_mean <- 0.10 # Bluesky overstates Becerra share by ~10 pts (assumed)
platform_bias_sd <- 0.08
social_adj_mean <- point_est - platform_bias_mean
social_adj_sd <- sqrt(social_sd^2 + platform_bias_sd^2)
# Precision-weighted (normal-normal) combination
w_poll <- 1 / poll_sd^2
w_social <- 1 / social_adj_sd^2
post_mean <- (w_poll * poll_mean + w_social * social_adj_mean) / (w_poll + w_social)
post_sd <- sqrt(1 / (w_poll + w_social))
draws <- rnorm(1e5, post_mean, post_sd)
result <- tibble(
source = c("Polls only", "Bluesky only (bias-adjusted)", "Combined"),
becerra_share = c(poll_mean, social_adj_mean, post_mean),
sd = c(poll_sd, social_adj_sd, post_sd),
p_becerra_wins = pnorm(0.5, becerra_share, sd, lower.tail = FALSE),
weight_in_combined = c(NA, NA, NA)
)
result$weight_in_combined[1:2] <- c(w_poll, w_social) / (w_poll + w_social)
result |>
mutate(across(c(becerra_share, sd, p_becerra_wins, weight_in_combined), ~ round(.x, 3))) |>
kbl(digits = 3) |> kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
| source | becerra_share | sd | p_becerra_wins | weight_in_combined |
|---|---|---|---|---|
| Polls only | 0.632 | 0.031 | 1.000 | 0.892 |
| Bluesky only (bias-adjusted) | 0.473 | 0.090 | 0.384 | 0.108 |
| Combined | 0.615 | 0.029 | 1.000 | NA |
cat(sprintf("\nCombined: Becerra %.1f%% vs Hilton %.1f%% (two-candidate), P(Becerra wins) = %.3f\n",
100 * post_mean, 100 * (1 - post_mean), mean(draws > 0.5)))
##
## Combined: Becerra 61.5% vs Hilton 38.5% (two-candidate), P(Becerra wins) = 1.000
sens <- crossing(bias = c(0, .05, .10, .15, .20), poll_err = c(.02, .03, .05)) |>
mutate(
ps = sqrt(1 / sum(polls$n) * 0.25 + poll_err^2),
ss = sqrt(social_sd^2 + platform_bias_sd^2),
m = ((point_est - bias) / ss^2 + poll_mean / ps^2) / (1 / ss^2 + 1 / ps^2),
s = sqrt(1 / (1 / ss^2 + 1 / ps^2)),
p_becerra_wins = pnorm(0.5, m, s, lower.tail = FALSE),
becerra_share = m
) |>
select(bias, poll_err, becerra_share, p_becerra_wins) |>
mutate(across(c(becerra_share, p_becerra_wins), ~ round(.x, 3)))
sens |> kbl(digits = 3) |> kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
| bias | poll_err | becerra_share | p_becerra_wins |
|---|---|---|---|
| 0.00 | 0.02 | 0.629 | 1.000 |
| 0.00 | 0.03 | 0.626 | 1.000 |
| 0.00 | 0.05 | 0.618 | 0.996 |
| 0.05 | 0.02 | 0.626 | 1.000 |
| 0.05 | 0.03 | 0.621 | 1.000 |
| 0.05 | 0.05 | 0.606 | 0.992 |
| 0.10 | 0.02 | 0.624 | 1.000 |
| 0.10 | 0.03 | 0.615 | 1.000 |
| 0.10 | 0.05 | 0.594 | 0.983 |
| 0.15 | 0.02 | 0.621 | 1.000 |
| 0.15 | 0.03 | 0.610 | 1.000 |
| 0.15 | 0.05 | 0.582 | 0.968 |
| 0.20 | 0.02 | 0.618 | 1.000 |
| 0.20 | 0.03 | 0.605 | 1.000 |
| 0.20 | 0.05 | 0.570 | 0.943 |
tibble(share = draws) |>
ggplot(aes(share)) +
geom_density(fill = "#2c6fbb", alpha = .4) +
geom_vline(xintercept = 0.5, linetype = "dashed") +
scale_x_continuous(labels = scales::percent) +
labs(title = "Posterior: Becerra two-candidate vote share",
subtitle = "Polls + bias-adjusted Bluesky signal",
x = "Becerra share (two-candidate)", y = NULL) +
theme_minimal()
p_tone <- two |>
mutate(tone_lab = factor(tone, c(-1, 0, 1), c("Negative", "Neutral", "Positive"))) |>
count(target, tone_lab) |>
group_by(target) |>
mutate(pct = n / sum(n)) |>
ggplot(aes(target, pct, fill = tone_lab)) +
geom_col(width = .6) +
geom_text(aes(label = scales::percent(pct, accuracy = 1)),
position = position_stack(vjust = .5), size = 3.5) +
scale_fill_manual(values = c(Negative = "#d98880", Neutral = "grey85", Positive = "#82c99b")) +
scale_y_continuous(labels = scales::percent) +
labs(title = "Tone of posts about each candidate", x = NULL, y = NULL, fill = NULL) +
theme_minimal()
p_tone
Dots are daily shares (size = number of posts); the line is a 3-day rolling mean.
# tiny rolling mean so no extra package is needed
roll_mean <- function(x, k) {
map_dbl(seq_along(x), \(i) mean(x[max(1, i - k + 1):i], na.rm = TRUE))
}
# 10c. Daily Bluesky "Becerra tilt" with a 3-day rolling mean ------------------
daily_tilt <- tilt_data |>
mutate(day = ymd(str_sub(created_at, 1, 10), quiet = TRUE)) |>
drop_na(day) |>
group_by(day) |>
summarise(n = n(), share = sum(w[tilt_becerra]) / sum(w), .groups = "drop") |>
arrange(day) |>
mutate(roll = roll_mean(share, 3))
p_trend <- ggplot(daily_tilt, aes(day)) +
geom_hline(yintercept = .5, linetype = "dashed", color = "grey50") +
geom_point(aes(y = share, size = n), alpha = .35, color = cols["Becerra"]) +
geom_line(aes(y = roll), linewidth = 1.1, color = cols["Becerra"]) +
scale_y_continuous(labels = scales::percent, limits = c(0, 1)) +
labs(title = "Daily Bluesky tilt toward Becerra",
subtitle = "Dots = daily share (size = posts); line = 3-day rolling mean",
x = NULL, y = "Becerra share of tilted posts", size = "Posts") +
theme_minimal()
p_trend
p_boot <- tibble(share = boot_shares) |>
ggplot(aes(share)) +
geom_histogram(bins = 40, fill = cols["Becerra"], alpha = .6, color = "white") +
geom_vline(xintercept = point_est, linewidth = 1) +
geom_vline(xintercept = quantile(boot_shares, c(.025, .975)), linetype = "dashed") +
geom_vline(xintercept = .5, color = "grey40") +
scale_x_continuous(labels = scales::percent) +
labs(title = "How uncertain is the Bluesky signal?",
subtitle = "2,000 author-level bootstrap resamples; dashed = 95% interval",
x = "Becerra two-candidate share", y = "Resamples") +
theme_minimal()
p_boot
A few viral posts drive most of the likes.
p_eng <- two |>
ggplot(aes(target, likes + 1, fill = target)) +
geom_boxplot(outlier.alpha = .3, width = .5, show.legend = FALSE) +
scale_y_log10() +
scale_fill_manual(values = cols) +
labs(title = "Likes per post (log scale)",
subtitle = "A few viral posts drive most engagement",
x = NULL, y = "Likes + 1") +
theme_minimal()
p_eng
Some of the words on the Becerra side come from a few accounts repeating hashtags. Raise the minimum word count or drop hashtags for a cleaner view.
stop_extra <- c(name_words, "https", "t.co", "amp", "bsky.app", "www", "com", "gov")
word_odds <- two |>
select(uri, target, text) |>
unnest_tokens(word, text) |>
filter(!word %in% c(stop_words$word, stop_extra), !str_detect(word, "^\\d+$"),
nchar(word) > 2) |>
count(target, word) |>
pivot_wider(names_from = target, values_from = n, values_fill = 0) |>
filter(Becerra + Hilton >= 8) |>
mutate(
logodds = log((Becerra + .5) / (sum(Becerra) + .5)) -
log((Hilton + .5) / (sum(Hilton) + .5)),
leans = if_else(logodds > 0, "Becerra", "Hilton")
)
p_words <- word_odds |>
group_by(leans) |>
slice_max(abs(logodds), n = 12) |>
ungroup() |>
ggplot(aes(logodds, reorder(word, logodds), fill = leans)) +
geom_col(show.legend = FALSE) +
scale_fill_manual(values = cols) +
labs(title = "Words that distinguish each candidate's conversation",
subtitle = "Log-odds ratio (words used >= 8 times)", x = "<- Hilton | Becerra ->", y = NULL) +
theme_minimal()
p_words
p_forest <- bind_rows(
polls |> transmute(source = pollster, est = share,
lo = share - 1.96 * sqrt(share * (1 - share) / n),
hi = share + 1.96 * sqrt(share * (1 - share) / n), type = "Poll"),
tibble(source = "Bluesky (raw)", est = point_est,
lo = quantile(boot_shares, .025), hi = quantile(boot_shares, .975), type = "Bluesky"),
tibble(source = "Bluesky (bias-adj.)", est = social_adj_mean,
lo = social_adj_mean - 1.96 * social_adj_sd,
hi = social_adj_mean + 1.96 * social_adj_sd, type = "Bluesky"),
tibble(source = "Combined", est = post_mean,
lo = post_mean - 1.96 * post_sd, hi = post_mean + 1.96 * post_sd, type = "Combined")
) |>
mutate(source = fct_inorder(source) |> fct_rev()) |>
ggplot(aes(est, source, color = type)) +
geom_vline(xintercept = .5, linetype = "dashed", color = "grey50") +
geom_pointrange(aes(xmin = lo, xmax = hi), linewidth = .9) +
scale_x_continuous(labels = scales::percent) +
scale_color_manual(values = c(Poll = "#444444", Bluesky = "#8e44ad", Combined = "#2c6fbb")) +
labs(title = "Becerra two-candidate share by source",
subtitle = "Points = estimate; bars = 95% interval", x = NULL, y = NULL, color = NULL) +
theme_minimal()
p_forest
p_sens <- sens |>
ggplot(aes(factor(bias), factor(poll_err), fill = becerra_share)) +
geom_tile(color = "white") +
geom_text(aes(label = scales::percent(becerra_share, accuracy = .1))) +
scale_fill_gradient2(low = "#c0392b", mid = "white", high = "#2c6fbb", midpoint = .5,
labels = scales::percent) +
labs(title = "Sensitivity of the combined forecast",
x = "Assumed Bluesky pro-Becerra bias", y = "Assumed poll error (SD)",
fill = "Becerra share") +
theme_minimal()
p_sens
p_top <- two |>
slice_max(engagement, n = 10, with_ties = FALSE) |>
mutate(label = str_trunc(str_replace_all(text, "\\s+", " "), 70)) |>
ggplot(aes(engagement, reorder(label, engagement), fill = target)) +
geom_col() +
scale_fill_manual(values = cols) +
labs(title = "Top 10 most-engaged posts", x = "Likes + 2*reposts + replies + quotes",
y = NULL, fill = NULL) +
theme_minimal()
p_top