Question. Can public Bluesky posts forecast the winner of the November 3, 2026 California governor’s race between Xavier Becerra (D) and Steve Hilton (R)?
Approach. Collect public posts, clean them
(including removing Hilton Hotel and Paris Hilton posts), score each
post with the tidytext Bing lexicon, summarize by
candidate, apply a prediction rule chosen before looking at
results, and bootstrap the result. I then validate the code against my
own hand labels and reflect on the limits.
Privacy. Everything reported here is aggregate. No usernames, post text, author identifiers, or profile links are published. The scraper never stores handles; it stores only a one-way scrambled (hashed) author code, used solely so the bootstrap can treat each account as one unit.
The headline results are collected in Section 8 (Bottom line), after the methods.
I collected the posts with a separate R script (run once, before
knitting) that logs in to Bluesky with an app password typed into a
prompt, searches the API (app.bsky.feed.searchPosts) for
each query term, English only, sorted by latest, pages through the
results, and saves them to data/raw_posts.csv. The script
never saves handles; it stores only a one-way hashed account code. This
report reads that saved file. The chunk below is the equivalent
collection code and runs only if no saved file exists.
Things to keep in mind about this data:
bsky_login <- function() {
request("https://bsky.social/xrpc/com.atproto.server.createSession") |>
req_body_json(list(identifier = Sys.getenv("BSKY_HANDLE"),
password = Sys.getenv("BSKY_APP_PASSWORD"))) |>
req_perform() |>
resp_body_json() |>
pluck("accessJwt")
}
search_posts <- function(q, jwt, since, until, max_pages = 30) {
out <- list(); cursor <- NULL
for (i in seq_len(max_pages)) {
res <- request("https://bsky.social/xrpc/app.bsky.feed.searchPosts") |>
req_auth_bearer_token(jwt) |>
req_url_query(q = q, since = since, until = until, lang = "en",
limit = 100, sort = "latest", cursor = cursor) |>
req_retry(max_tries = 3) |>
req_perform() |>
resp_body_json()
if (length(res$posts) == 0) break
out <- c(out, lapply(res$posts, function(p)
tibble(uri = p$uri,
# one-way hash of the account ID: no handle is ever stored
author_id = rlang::hash(paste0("ca-gov-lab-2026-", p$author$did)),
created_at = p$record$createdAt %||% NA_character_,
text = p$record$text %||% NA_character_,
query = q)))
cursor <- res$cursor
if (is.null(cursor)) break
Sys.sleep(0.5)
}
bind_rows(out)
}
jwt <- bsky_login()
raw <- map_dfr(queries, ~ search_posts(.x, jwt, since, until))
write_csv(raw, raw_path)
raw_in <- read_csv(raw_path, show_col_types = FALSE) |>
rename_with(~ str_to_lower(str_replace_all(.x, "[^A-Za-z0-9]+", "_")))
# Accept exports from the earlier lab / the atrrr package, which name columns differently
first_col <- function(df, candidates) {
nm <- intersect(candidates, names(df))
if (length(nm)) as.character(df[[nm[1]]]) else rep(NA_character_, nrow(df))
}
stopifnot("The data must have a text column (`text` or `post_text`)" =
any(c("text", "post_text") %in% names(raw_in)))
raw <- tibble(
text = first_col(raw_in, c("text", "post_text")),
uri = first_col(raw_in, c("uri", "post_id")),
created_at = first_col(raw_in, c("created_at", "createdat", "indexed_at",
"timestamp_original", "timestamp", "created")),
query = first_col(raw_in, "query"),
author_id = first_col(raw_in, "author_id")
)
raw$uri <- coalesce(raw$uri, as.character(seq_len(nrow(raw))))
# Anonymous author code: use the supplied one, else hash the account ID (DID) found in the post URI.
# Handles and display names are discarded here and never used again.
if (all(is.na(raw$author_id))) {
did <- str_extract(raw$uri, "did:[a-z]+:[A-Za-z0-9._%-]+")
raw$author_id <- map_chr(did, ~ if (is.na(.x)) NA_character_
else rlang::hash(paste0("ca-gov-lab-2026-", .x)))
}
author_available <- any(!is.na(raw$author_id))
if (!author_available) raw$author_id <- raw$uri # last resort: each post is its own "author"
raw$author_id <- coalesce(raw$author_id, raw$uri)
rm(raw_in)
I asked an AI assistant: “I scraped Bluesky posts about Xavier Becerra and Steve Hilton for the California governor’s race. Besides removing Hilton Hotel / Paris Hilton posts, what other cleaning issues should I handle?” Its list, and what I did about each item:
| Issue flagged | Why it matters | What I did |
|---|---|---|
| Hilton Hotels / Paris Hilton and relatives / Hilton Head / Hilton Honors | Brand and celebrity posts are not about the candidate | Regex exclusion (below) |
| Other people named Becerra, or “Hilton” without “Steve” | Surname-only searches pull unrelated posts | Required political-context words or the full name “Steve Hilton” |
| Same post returned by several queries | Double counting inflates volume | De-duplicated by post ID |
| Copy-paste / bot-like duplicate text | One message repeated many times looks like many supporters | Dropped exact duplicate text (kept one) |
| Posts mentioning both candidates | Sentiment cannot be attributed to one candidate | Excluded from the candidate comparison |
| URLs, @mentions, hashtags, HTML entities | Noise for tokenizing; @mentions contain usernames | Stripped before analysis |
| Very short posts | Too few words to score | Dropped posts under 4 words |
| Non-English posts | The Bing lexicon is English-only | Requested English in the search |
| Missing or out-of-window dates | Keeps the sample on the campaign period | Filtered on timestamp (when dates are available) |
| Lexicon traps (e.g., “trump” is coded positive in Bing) | Would add false positive sentiment to political posts | Removed from the lexicon |
| One account posting many times | Heavy posters can dominate counts | Bootstrap resamples accounts, not posts |
hotel_pat <- regex(paste(
"paris hilton", "nicky hilton", "kathy hilton", "perez hilton", "conrad hilton",
"hilton hotel", "hilton worldwide", "hilton honors", "hilton head", "hilton garden",
"hilton (inn|suites|resort|grand vacations)", "hampton (inn )?by hilton",
"doubletree", "waldorf astoria", "hilton (hotels?|stay|lobby|bar|buffet|breakfast)",
sep = "|"), ignore_case = TRUE)
ctx_pat <- regex(paste(
"xavier", "governor", "gubernatorial", "\\bgov\\b", "california", "\\bca\\b", "election",
"\\bvote", "ballot", "\\bhhs\\b", "attorney general", "democrat", "republican", "\\bgop\\b",
"newsom", "campaign", "debate", "\\bprop\\b", "bianco", "fox news", "trump", "sacramento",
sep = "|"), ignore_case = TRUE)
becerra_pat <- regex("becerra", ignore_case = TRUE)
hilton_full <- regex("steve hilton|stephen hilton", ignore_case = TRUE)
hilton_single <- regex("\\bhilton\\b", ignore_case = TRUE)
clean_text <- function(x) {
x |>
str_replace_all("https?://\\S+", " ") |> # URLs
str_replace_all("\\b[a-z0-9-]+\\.(com|org|net|co|io|us)\\S*", " ") |> # bare domains
str_replace_all("@[A-Za-z0-9._-]+", " ") |> # @mentions (contain usernames)
str_replace_all("&", "&") |>
str_replace_all("#", " ") |>
str_squish()
}
log_step <- function(a, label, df)
add_row(a, step = label, remaining = nrow(df),
removed = a$remaining[nrow(a)] - nrow(df))
audit <- tibble(step = "Raw posts collected", remaining = nrow(raw), removed = NA_integer_)
d <- raw |> filter(!is.na(text), str_squish(text) != "")
audit <- log_step(audit, "Dropped empty or missing text", d)
d <- d |> distinct(uri, .keep_all = TRUE)
audit <- log_step(audit, "Dropped duplicate post IDs (same post returned by multiple queries)", d)
d <- d |> mutate(text_clean = clean_text(text))
d <- d |> distinct(text_key = str_to_lower(text_clean), .keep_all = TRUE) |> select(-text_key)
audit <- log_step(audit, "Dropped exact duplicate text (reposts, copy-paste, bot-like)", d)
d <- d |> filter(!str_detect(text, hotel_pat))
audit <- log_step(audit, "Dropped Hilton Hotel / Paris Hilton / other Hilton-brand posts", d)
# Candidate and date flags (computed before filtering so they can be reported)
d <- d |>
mutate(created = coalesce(ymd_hms(created_at, quiet = TRUE), ymd(created_at, quiet = TRUE)),
is_b = str_detect(text_clean, becerra_pat) & str_detect(text_clean, ctx_pat),
is_h = str_detect(text_clean, hilton_full) |
(str_detect(text_clean, hilton_single) & str_detect(text_clean, ctx_pat)),
short_post = str_count(text_clean, "\\S+") < 4)
flags <- tibble(
Flag = c("Rows entering the flag check",
"Missing or unparseable date",
"Not confidently about either candidate",
"Names both candidates",
"Under 4 words after cleaning"),
Posts = c(nrow(d),
sum(is.na(d$created)),
sum(!d$is_b & !d$is_h),
sum(d$is_b & d$is_h),
sum(d$short_post)))
dates_available <- any(!is.na(d$created))
if (dates_available) {
d <- d |> filter(!is.na(created), created >= ymd_hms(since), created < ymd_hms(until))
audit <- log_step(audit, "Dropped posts with missing dates or outside the date window", d)
}
d_about <- d |> filter(is_b | is_h)
audit <- log_step(audit, "Dropped posts not confidently about either candidate", d_about)
d_one <- d_about |> filter(xor(is_b, is_h))
audit <- log_step(audit, "Dropped posts mentioning both candidates", d_one)
posts <- d_one |>
filter(!short_post) |>
mutate(candidate = if_else(is_b, "Becerra", "Hilton"),
post_id = row_number()) |>
select(post_id, uri, author_id, candidate, created, text, text_clean)
audit <- log_step(audit, "Dropped posts under 4 words", posts)
kable(audit, col.names = c("Cleaning step", "Posts remaining", "Posts removed"),
caption = "Cleaning audit: posts remaining and removed at each step")
| Cleaning step | Posts remaining | Posts removed |
|---|---|---|
| Raw posts collected | 4764 | NA |
| Dropped empty or missing text | 4734 | 30 |
| Dropped duplicate post IDs (same post returned by multiple queries) | 2722 | 2012 |
| Dropped exact duplicate text (reposts, copy-paste, bot-like) | 2430 | 292 |
| Dropped Hilton Hotel / Paris Hilton / other Hilton-brand posts | 2428 | 2 |
| Dropped posts with missing dates or outside the date window | 2428 | 0 |
| Dropped posts not confidently about either candidate | 1865 | 563 |
| Dropped posts mentioning both candidates | 1520 | 345 |
| Dropped posts under 4 words | 1510 | 10 |
kable(flags, col.names = c("Problem flag", "Posts"),
caption = "Flag counts (before filtering). Flags can overlap, so they should not be added together.")
| Problem flag | Posts |
|---|---|
| Rows entering the flag check | 2428 |
| Missing or unparseable date | 0 |
| Not confidently about either candidate | 563 |
| Names both candidates | 345 |
| Under 4 words after cleaning | 77 |
n_by_cand <- posts |> count(candidate)
print(kable(n_by_cand, col.names = c("Candidate", "Posts after cleaning"),
caption = "Final analysis sample"))
| Candidate | Posts after cleaning |
|---|---|
| Becerra | 711 |
| Hilton | 799 |
cat("\n\n")
if (!dates_available) cat("*Note: the data had no usable dates, so the date-window filter was skipped.*\n\n")
if (!author_available) cat("*Note: the data had no author IDs, so the bootstrap in Section 5.2 resamples individual posts instead of accounts.*\n\n")
if (nrow(n_by_cand) < 2 || min(n_by_cand$n) < 100) {
cat("**Warning:** fewer than 100 posts for at least one candidate; treat all estimates as very rough.\n")
}
The final sample has 1001 distinct accounts across 1510 posts (accounts identified only by an anonymous code).
The R code in this section was written by an AI assistant (Claude) in response to the following prompt:
I’m writing R code for a class lab that forecasts the 2026 California governor’s race (Xavier Becerra, Democrat, vs. Steve Hilton, Republican) from public Bluesky posts.
DATA: a data frame
postswith one row per post and these columns:post_id(integer),uri(character),author_id(anonymous hashed account code),candidate(“Becerra” or “Hilton”),created(POSIXct),text_clean(character; URLs and @mentions already removed). The posts were already cleaned for Hilton-hotel / Paris Hilton false positives and duplicates.METHOD: Tokenize
text_cleanwithtidytext::unnest_tokens, join toget_sentiments("bing"), count positive and negative words per post, and computenet = positive - negative. Classify each post as “positive” if net > 0, “negative” if net < 0, “balanced” if net == 0 but it has lexicon matches, and “no_match” if it has no lexicon words. Keep all posts. Remove the words “trump”, “like” and “right” from the lexicon (Bing codes them as sentiment words, but here they are a name, a common verb/comparison, and a direction or political label). Summarize by candidate: posts, positive, negative, balanced, no-match, mean net score, and net sentiment = (positive - negative) / (positive + negative). Explain that word sentiment does not necessarily measure stance toward a candidate.PREDICTION: Before computing results, define the rule as each candidate’s count of positive posts divided by the combined positive posts for both candidates. Label it an uncalibrated classroom vote-share estimate, not a representative voter estimate.
BOOTSTRAP: Resample anonymous authors with replacement 2000 times, keeping all of each sampled author’s posts. Handle resamples with zero positive posts explicitly (mark as undefined and report how many).
CONSTRAINTS: Use only tidyverse and tidytext. Use the native pipe
|>. No loops over rows. Never print post text, usernames, or author IDs. The code must run top to bottom inside an R Markdown chunk, with a brief comment on each step. Do not attempt negation handling; I will discuss that as a limitation.
# Bing lexicon, minus words that are mostly not sentiment in this setting:
# "trump" (a name), "like" (usually a verb or comparison, e.g. "looks like"),
# "right" (a direction or political "right", not approval)
bing <- get_sentiments("bing") |> filter(!word %in% c("trump", "like", "right"))
# Tokenize and join to lexicon
post_scores <- posts |>
unnest_tokens(word, text_clean) |>
inner_join(bing, by = "word", relationship = "many-to-many") |>
count(post_id, sentiment) |>
pivot_wider(names_from = sentiment, values_from = n, values_fill = 0)
# Make sure both columns exist even if one sentiment never appears
for (col in c("positive", "negative")) {
if (!col %in% names(post_scores)) post_scores[[col]] <- 0L
}
# Attach scores; keep every post, separating "no evidence" from "balanced evidence"
posts <- posts |>
left_join(post_scores, by = "post_id") |>
mutate(positive = replace_na(positive, 0),
negative = replace_na(negative, 0),
net = positive - negative,
match_type = case_when(net > 0 ~ "positive",
net < 0 ~ "negative",
positive + negative == 0 ~ "no_match",
TRUE ~ "balanced"),
code_stance = case_when(match_type == "positive" ~ "support",
match_type == "negative" ~ "oppose",
TRUE ~ "neutral"))
# Summary by candidate
summ <- posts |>
group_by(candidate) |>
summarise(n_posts = n(),
support = sum(match_type == "positive"),
oppose = sum(match_type == "negative"),
balanced = sum(match_type == "balanced"),
no_match = sum(match_type == "no_match"),
mean_net = mean(net),
.groups = "drop") |>
mutate(volume_share = n_posts / sum(n_posts),
net_sentiment = (support - oppose) / (support + oppose))
summ |>
transmute(Candidate = candidate, Posts = n_posts, Positive = support, Negative = oppose,
`Balanced matches` = balanced, `No matches` = no_match,
`Mean net score` = round(mean_net, 3),
`Net sentiment` = round(net_sentiment, 3),
`Volume share` = percent(volume_share, 0.1)) |>
kable(caption = "Sentiment summary by candidate (aggregate only)")
| Candidate | Posts | Positive | Negative | Balanced matches | No matches | Mean net score | Net sentiment | Volume share |
|---|---|---|---|---|---|---|---|---|
| Becerra | 711 | 236 | 187 | 66 | 222 | 0.135 | 0.116 | 47.1% |
| Hilton | 799 | 164 | 323 | 94 | 218 | -0.441 | -0.326 | 52.9% |
How to read this. The Bing lexicon measures the tone of words, not necessarily stance toward a candidate. Positive words can describe an opponent, appear in sarcasm, or be unrelated to the candidate; a supportive post can contain harsh words aimed at someone else. “No matches” means the dictionary had no evidence, which is not the same as a neutral stance, and “balanced matches” means mixed evidence. The method does not interpret negation, sarcasm, emojis, or conversational context.
posts |>
count(candidate, match_type) |>
mutate(match_type = factor(match_type, levels = c("positive", "negative", "balanced", "no_match"))) |>
ggplot(aes(candidate, n, fill = match_type)) +
geom_col(position = "dodge") +
scale_fill_manual(values = c(positive = "#2c7fb8", negative = "#d95f0e",
balanced = "grey60", no_match = "grey85")) +
labs(x = NULL, y = "Posts", fill = "Lexicon result",
title = "Posts by candidate and lexicon-based sentiment") +
theme_minimal()
The most common sentiment words per candidate (aggregate word counts only) help catch lexicon mistakes, such as political words that Bing scores as emotional. In an earlier run of this check, “like” and “right” were among the most common “positive” words for both candidates even though they are mostly not praise (“looks like”, “the right”), so I added them to the removal list along with “trump”. This is a measurement fix, not a change to the prediction rule, and I made it before hand-labeling so my validation compares against the final code. Other words that remain, such as “win” and “safe”, can also be political jargon rather than praise; I left them in because they can also express real approval, and I note this as a limitation.
posts |>
unnest_tokens(word, text_clean) |>
inner_join(bing, by = "word", relationship = "many-to-many") |>
count(candidate, word, sentiment, sort = TRUE) |>
group_by(candidate) |>
slice_head(n = 8) |>
ungroup() |>
kable(caption = "Top 8 lexicon words per candidate")
| candidate | word | sentiment | n |
|---|---|---|---|
| Becerra | good | positive | 36 |
| Becerra | win | positive | 24 |
| Becerra | better | positive | 23 |
| Becerra | safe | positive | 21 |
| Becerra | well | positive | 19 |
| Becerra | thank | positive | 18 |
| Becerra | support | positive | 16 |
| Becerra | corruption | negative | 14 |
| Hilton | creepy | negative | 43 |
| Hilton | weird | negative | 28 |
| Hilton | bad | negative | 25 |
| Hilton | good | positive | 22 |
| Hilton | win | positive | 21 |
| Hilton | innuendo | negative | 18 |
| Hilton | fuck | negative | 17 |
| Hilton | support | positive | 16 |
I chose the positive-post share rule: a candidate’s predicted two-party vote share is their count of positive posts divided by the combined count of positive posts for both candidates. This is an uncalibrated classroom estimate, not a measurement of voter preferences.
I keep this rule even if the result looks surprising; changing the rule after seeing the output would defeat the purpose.
pos_b <- summ$support[summ$candidate == "Becerra"]
pos_h <- summ$support[summ$candidate == "Hilton"]
pred_share <- pos_b / (pos_b + pos_h) # Becerra's predicted share
winner <- if (pred_share > 0.5) "Becerra" else "Hilton"
winner_share <- max(pred_share, 1 - pred_share)
rules <- tibble(
Rule = c("Volume share (Becerra)", "Positive-post share (Becerra) - CHOSEN",
"Net sentiment, Becerra minus Hilton"),
Value = c(percent(summ$volume_share[summ$candidate == "Becerra"], 0.1),
percent(pred_share, 0.1),
sprintf("%+.3f", summ$net_sentiment[summ$candidate == "Becerra"] -
summ$net_sentiment[summ$candidate == "Hilton"])))
kable(rules, caption = "Point predictions under the three candidate rules")
| Rule | Value |
|---|---|
| Volume share (Becerra) | 47.1% |
| Positive-post share (Becerra) - CHOSEN | 59.0% |
| Net sentiment, Becerra minus Hilton | +0.442 |
There were 400 positive posts in total: 236 for Becerra and 164 for Hilton. Neutral and negative posts are not in the denominator.
Predicted winner under the chosen rule: Becerra, with 59.0% of the two-party estimate (Becerra 59.0%, Hilton 41.0%).
I resampled accounts with replacement 2000 times, keeping every post from each sampled account (an account drawn twice contributes its posts twice). This respects the fact that posts from the same account are not independent, so one heavy poster cannot make the result look more certain than it is. Candidate groups were not resampled separately. In each replicate I recomputed the positive-post share, and a replicate with no positive posts is marked undefined and excluded (and counted below).
set.seed(SEED)
# One row per anonymous account: its positive posts for each candidate
auth <- posts |>
group_by(author_id) |>
summarise(pb = sum(candidate == "Becerra" & match_type == "positive"),
ph = sum(candidate == "Hilton" & match_type == "positive"),
.groups = "drop")
boot_share <- replicate(N_BOOT, {
i <- sample.int(nrow(auth), replace = TRUE)
pb <- sum(auth$pb[i]); ph <- sum(auth$ph[i])
if (pb + ph == 0) NA_real_ else pb / (pb + ph)
})
n_undefined <- sum(is.na(boot_share))
valid <- boot_share[!is.na(boot_share)]
ci <- quantile(valid, c(0.025, 0.975))
boot_se <- sd(valid)
p_b_ahead <- mean(valid > 0.5)
p_h_ahead <- mean(valid < 0.5)
p_tie <- mean(valid == 0.5)
kable(tibble(`Point estimate (Becerra)` = percent(pred_share, 0.1),
`Bootstrap SE` = percent(boot_se, 0.1),
`95% interval (Becerra)` = paste0(percent(ci[1], 0.1), " to ", percent(ci[2], 0.1)),
`Becerra ahead` = percent(p_b_ahead, 0.1),
`Hilton ahead` = percent(p_h_ahead, 0.1),
`Tied` = percent(p_tie, 0.1),
`Undefined replicates` = n_undefined),
caption = "Account-level bootstrap results")
| Point estimate (Becerra) | Bootstrap SE | 95% interval (Becerra) | Becerra ahead | Hilton ahead | Tied | Undefined replicates |
|---|---|---|---|---|---|---|
| 59.0% | 2.8% | 53.6% to 64.4% | 100.0% | 0.0% | 0.0% | 0 |
ggplot(tibble(share = valid), aes(share)) +
geom_histogram(bins = 40, fill = "#2c7fb8", color = "white") +
geom_vline(xintercept = 0.5, linetype = "dashed") +
geom_vline(xintercept = pred_share, color = "#d95f0e", linewidth = 1) +
scale_x_continuous(labels = percent) +
labs(x = "Becerra share of positive posts", y = "Bootstrap resamples",
title = "Bootstrap distribution of the prediction",
subtitle = "Orange = point estimate; dashed = 50%") +
theme_minimal()
“Becerra ahead” and “Hilton ahead” are frequencies under this resampling procedure, not election win probabilities. The bootstrap measures only sampling instability among the accounts I observed. It cannot correct for errors in sentiment scoring, missing search coverage, coordinated or correlated accounts, or the mismatch between Bluesky users and the electorate. An interval that excludes 50% can still surround a biased and invalid proxy.
For context, one public polling average (PollCheck, updated October 27, 2026) put Becerra at about 56.5% and Hilton at about 35.7% among all respondents, or roughly 61% of the two-party total (56.5 / (56.5 + 35.7)). Source: https://www.pollcheck.co.uk/us-midterms-2026/governor/california. Polls are not part of my model; they are an outside benchmark for how far the Bluesky signal sits from other evidence.
poll_two_party <- 56.5 / (56.5 + 35.7)
kable(tibble(Source = c("Bluesky positive-post share (Becerra)", "Polling average, two-party equivalent (Becerra)"),
Share = percent(c(pred_share, poll_two_party), 0.1)))
| Source | Share |
|---|---|
| Bluesky positive-post share (Becerra) | 59.0% |
| Polling average, two-party equivalent (Becerra) | 61.3% |
I took a seeded random sample of 20 cleaned posts and saved them to a
local sheet (data/hand_label_sheet.csv)
without the code’s score, so my labels were blind to
it. For each post I labeled stance toward the named candidate as
support, oppose, or neutral
(neutral = news, a question, or unclear). For sarcasm I labeled the
intended stance. The sheet stays on my computer and is not
published.
Labeling disclosure: I labeled these posts myself, without AI suggestions and without seeing the machine scores.
For comparison only, the code’s positive posts are mapped to support, negative to oppose, and everything else (no matches or balanced matches) to neutral. This mapping is a proxy; it does not claim that sentiment and stance are the same thing.
if (!file.exists(sheet_path)) {
set.seed(SEED)
posts |>
slice_sample(n = min(N_LABEL, nrow(posts))) |>
transmute(uri, candidate, text, hand_stance = NA_character_) |>
write_csv(sheet_path)
}
lab <- read_csv(sheet_path, show_col_types = FALSE,
col_types = cols(.default = col_character())) |>
mutate(hand = str_to_lower(str_trim(hand_stance))) |>
select(uri, hand) |>
inner_join(posts |> select(uri, candidate, code = code_stance), by = "uri")
labels_ready <- nrow(lab) > 0 && all(lab$hand %in% c("support", "oppose", "neutral"))
if (!labels_ready) {
cat("**Hand labels are not complete yet.** Open `data/hand_label_sheet.csv`, fill the `hand_stance` column with support / oppose / neutral for every row, save as CSV, and knit again.\n")
} else {
lv <- c("support", "oppose", "neutral")
n_lab <- nrow(lab)
correct <- sum(lab$hand == lab$code)
val_acc <- correct / n_lab
acc_ci <- prop.test(correct, n_lab)$conf.int
# Baseline: a classifier that always answers the most common hand label
baseline_neutral <- mean(lab$hand == "neutral")
majority_label <- names(which.max(table(factor(lab$hand, lv))))
baseline_major <- mean(lab$hand == majority_label)
cm <- table(`Hand label` = factor(lab$hand, lv), `Code label` = factor(lab$code, lv))
po <- sum(diag(cm)) / sum(cm)
pe <- sum(rowSums(cm) * colSums(cm)) / sum(cm)^2
kappa <- (po - pe) / (1 - pe)
both_dir <- lab |> filter(hand != "neutral", code != "neutral")
dir_acc <- if (nrow(both_dir) > 0) mean(both_dir$hand == both_dir$code) else NA_real_
cat(sprintf("**Overall agreement:** %d of %d posts (%s; 95%% CI %s to %s).\n\n",
correct, n_lab, percent(val_acc, 0.1), percent(acc_ci[1], 1), percent(acc_ci[2], 1)))
cat(sprintf("**Baseline for comparison:** a classifier that always answered \"%s\" (my most common label) would agree with %s of these labels. Always answering \"neutral\" would agree with %s.\n\n",
majority_label, percent(baseline_major, 0.1), percent(baseline_neutral, 0.1)))
cat(sprintf("**Cohen's kappa (agreement beyond chance):** %.2f.\n\n", kappa))
cat(sprintf("**Direction-only agreement** (posts where both my label and the code's were support/oppose): %s (n = %d).\n\n",
if (is.na(dir_acc)) "n/a" else percent(dir_acc, 0.1), nrow(both_dir)))
cat("Confusion matrix (rows = my label, columns = code's label):\n\n")
print(kable(as.data.frame.matrix(cm)))
cat("\n\nWith only", n_lab, "labeled posts, this accuracy estimate is imprecise; a larger independently labeled sample would give a firmer number.\n")
}
Overall agreement: 9 of 20 posts (45.0%; 95% CI 24% to 68%).
Baseline for comparison: a classifier that always answered “support” (my most common label) would agree with 45.0% of these labels. Always answering “neutral” would agree with 15.0%.
Cohen’s kappa (agreement beyond chance): 0.08.
Direction-only agreement (posts where both my label and the code’s were support/oppose): 60.0% (n = 15).
Confusion matrix (rows = my label, columns = code’s label):
| support | oppose | neutral | |
|---|---|---|---|
| support | 6 | 3 | 0 |
| oppose | 3 | 3 | 2 |
| neutral | 3 | 0 | 0 |
With only 20 labeled posts, this accuracy estimate is imprecise; a larger independently labeled sample would give a firmer number.
Who is on Bluesky. Bluesky users are not a random sample of California voters. The platform tends to skew toward people who are politically engaged, heavy internet users, and more left-leaning than the electorate overall, and many California voters, especially older, rural, and less politically engaged ones, are not on it. This dataset does not establish users’ locations, ages, eligibility to vote in California, or likelihood of turning out, and English-only collection leaves out discussion in other languages. Even a perfect reading of every Bluesky post would describe Bluesky, not California. That is a coverage problem the bootstrap cannot fix. A result favoring Becerra would therefore be unsurprising and not very informative, and a result favoring Hilton would more likely signal measurement problems, a burst of posting by a few accounts, or a news cycle than real support.
Sarcasm and tone versus stance. The Bing lexicon counts words, so it measures tone, not stance. A furious post attacking Hilton scores negative but is pro-Becerra, and a lexicon-based stance mapping files it under Hilton’s “oppose” count without crediting Becerra. Sarcasm (“great job, genius”) is scored as praise, negation (“not good”) is ignored, and words like “attack” or “crisis” appear in neutral news posts. My hand validation in Section 6 quantifies this: the code agreed with my labels on 45% of the sampled posts, compared with 15% for a classifier that always says “neutral”. Because many posts also contain no lexicon words, absence of matches is lack of evidence, not a neutral stance.
Does more posts mean more support? No. Posts can be hate-reading, mockery, news sharing, fact-checking, or reactions to a viral moment, and a controversial candidate can lead in volume while being unpopular. I used positive-post share rather than raw volume for that reason, but it still treats a positive post as a supporter. Resampling accounts instead of posts partly addresses the problem of one account posting many times, but it cannot detect bots or coordinated accounts. My search terms, the “latest” sort, and the API’s result caps also determine which days and posts are over-represented.
What the bootstrap does and does not tell us. The interval in Section 5.2 shows how stable the estimate is across the accounts I observed. It does not cover non-representative users, lexicon errors, sarcasm, or search design. A narrow interval around an invalid measure is still invalid, so I treat this as a demonstration of the method and a rough read of Bluesky conversation, not a replacement for polling.
What I would do with more time. Collect a larger independently labeled sample and measure accuracy for each candidate separately, handle negation and tone with a stance classifier, compare reasonable prediction rules without choosing one because of its outcome, and test the approach against representative polls or past election results.
Ethics. All results are aggregate counts and shares. No usernames, post text, author codes, or profile links are published, the scraper never stored handles, and the raw data file is kept local.
cat(sprintf("Under the rule I defined in advance (positive-post share), the forecast selects **%s** with **%s** of the two-candidate estimate (Becerra %s, Hilton %s). The 95%% account-level bootstrap interval for Becerra's share is %s to %s.\n\n",
winner, percent(winner_share, 0.1), percent(pred_share, 0.1), percent(1 - pred_share, 0.1),
percent(ci[1], 0.1), percent(ci[2], 0.1)))
Under the rule I defined in advance (positive-post share), the forecast selects Becerra with 59.0% of the two-candidate estimate (Becerra 59.0%, Hilton 41.0%). The 95% account-level bootstrap interval for Becerra’s share is 53.6% to 64.4%.
if (labels_ready) {
cat(sprintf("However, the sentiment code agreed with my hand labels on %s of the %d sampled posts, versus %s for an always-neutral baseline. %s\n\n",
percent(val_acc, 0.1), nrow(lab), percent(baseline_neutral, 0.1),
if (val_acc < 0.6) "That is weak agreement, so this output is a poor measure of candidate support and should not be treated as an election forecast."
else "That is moderate agreement, but the sample is small and the sampling and coverage limits above still apply."))
} else {
cat("Hand-label validation is not finished yet (see Section 6).\n\n")
}
However, the sentiment code agreed with my hand labels on 45.0% of the 20 sampled posts, versus 15.0% for an always-neutral baseline. That is weak agreement, so this output is a poor measure of candidate support and should not be treated as an election forecast.
cat("These percentages are shares of positive-scoring posts, not measured voter preferences.\n")
These percentages are shares of positive-scoring posts, not measured voter preferences.
Silge, J., and Robinson, D. Text Mining with R, Chapter 2: Sentiment analysis with tidy data. https://www.tidytextmining.com/sentiment
BSKY_HANDLE and
BSKY_APP_PASSWORD in ~/.Renviron (use a
Bluesky app password), delete data/raw_posts.csv
to rescrape, and knit.sessionInfo()
## R version 4.6.1 (2026-06-24)
## Platform: x86_64-pc-linux-gnu
## Running under: Ubuntu 24.04.5 LTS
##
## Matrix products: default
## BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
## LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.26.so; LAPACK version 3.12.0
##
## locale:
## [1] LC_CTYPE=C.UTF-8 LC_NUMERIC=C LC_TIME=C.UTF-8
## [4] LC_COLLATE=C.UTF-8 LC_MONETARY=C.UTF-8 LC_MESSAGES=C.UTF-8
## [7] LC_PAPER=C.UTF-8 LC_NAME=C LC_ADDRESS=C
## [10] LC_TELEPHONE=C LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C
##
## time zone: UTC
## tzcode source: system (glibc)
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] knitr_1.52 scales_1.4.0 httr2_1.3.0 tidytext_0.4.3
## [5] lubridate_1.9.5 forcats_1.0.1 stringr_1.6.0 dplyr_1.2.1
## [9] purrr_1.2.2 readr_2.2.0 tidyr_1.3.2 tibble_3.3.1
## [13] ggplot2_4.0.3 tidyverse_2.0.0
##
## loaded via a namespace (and not attached):
## [1] janeaustenr_1.0.0 sass_0.4.10 generics_0.1.4 stringi_1.8.9
## [5] lattice_0.22-9 hms_1.1.4 digest_0.6.39 magrittr_2.0.5
## [9] evaluate_1.0.5 grid_4.6.1 timechange_0.4.0 RColorBrewer_1.1-3
## [13] fastmap_1.2.0 jsonlite_2.0.0 Matrix_1.7-5 jquerylib_0.1.4
## [17] cli_3.6.6 crayon_1.5.3 rlang_1.3.0 tokenizers_0.3.0
## [21] bit64_4.8.6 withr_3.0.3 cachem_1.1.0 yaml_2.3.12
## [25] otel_0.2.0 parallel_4.6.1 tools_4.6.1 tzdb_0.5.0
## [29] vctrs_0.7.3 R6_2.6.1 lifecycle_1.0.5 bit_4.6.0
## [33] vroom_1.7.1 pkgconfig_2.0.3 pillar_1.11.1 bslib_0.12.0
## [37] gtable_0.3.6 glue_1.8.1 Rcpp_1.1.2 xfun_0.61
## [41] tidyselect_1.2.1 rstudioapi_0.19.0 farver_2.1.2 htmltools_0.5.9
## [45] SnowballC_0.7.1 labeling_0.4.3 rmarkdown_2.32 compiler_4.6.1
## [49] S7_0.2.2