Privacy note. All results below are aggregate. No post text, handles, or display names are shown anywhere in this document; posts are referred to only by an anonymous row ID.

1. Data collection

The posts were scraped from Bluesky in the previous lab (one CSV per search term). The scraping code is not repeated here.

read_all <- function(f) read_csv(file.path(params$data_dir, f), col_types = cols(.default = "c"))
x <- read_all("my_post-Xavier.csv")
h <- read_all("my_post-Hilton.csv")
cat("Rows in Xavier file:", nrow(x), "| Rows in Hilton file:", nrow(h), "\n")
## Rows in Xavier file: 198 | Rows in Hilton file: 198
cat("Unique post IDs in each file:", n_distinct(x$uri), "and", n_distinct(h$uri), "\n")
## Unique post IDs in each file: 198 and 198
cat("Same set of post IDs in both files:", setequal(x$uri, h$uri), "\n")
## Same set of post IDs in both files: TRUE
cat("Identical post text in both files:", identical(x$text, h$text), "\n")
## Identical post text in both files: TRUE

Finding #1 (data issue). The two files contain the same 198 posts, so the “Hilton” scrape added nothing beyond the “Xavier” scrape. Candidate labels therefore cannot come from which file a post was in; they are assigned from the post text instead. (The Xavier file’s created_at also lost its date part, probably from being opened and re-saved in a spreadsheet program, so dates are taken from the Hilton file.) A re-scrape with separate, candidate-specific queries would fix this and should be a priority.

2. Cleaning

2.1 The AI/Google cleaning question

I asked an AI assistant: “Besides removing Hilton Hotel / Paris Hilton posts, what else should I check when cleaning Bluesky text for a sentiment forecast?” Its checklist, and what I did with each item:

Issue What I did
Hotel / Paris Hilton / other namesakes Regex filter (below)
Duplicate posts across files De-duplicate on uri
Copy-paste / spam posts by one account De-duplicate on author + text, then on text
Empty or link-only posts (nothing to score) Dropped
Non-English posts (Bing is English only) Dropped when langs is explicitly non-English
News headlines and campaign accounts Kept but flagged as a limitation (not “voters”)
Posts naming both candidates Tagged “both”; analysed separately
Posts naming neither candidate Excluded from candidate counts
Lexicon problems (e.g. “trump” is positive in Bing) Audited in Section 4

2.2 Cleaning pipeline

hotel_rx <- "paris hilton|perez hilton|hilton (hotel|hotels|head|honors|garden|worldwide|resort|curio|inn)|conrad hilton|hampton by hilton|doubletree|waldorf|hilton hhonors"
cand_ctx <- "steve hilton|becerra|governor|gubernatorial|xavier"

stage0 <- bind_rows(x, h)
stage1 <- stage0 %>% distinct(uri, .keep_all = TRUE)                       # same post in both files
stage1 <- stage1 %>% mutate(text = replace_na(text, ""),
                            hotel = str_detect(str_to_lower(text), hotel_rx) &
                                    !str_detect(str_to_lower(text), cand_ctx))
stage2 <- stage1 %>% filter(!hotel)                                        # Hilton Hotel / Paris Hilton
stage2 <- stage2 %>% mutate(clean = text %>%
  str_remove_all("https?://\\S+|www\\.\\S+|\\b[a-z0-9.-]+\\.(com|org|net|co|news)/\\S*") %>%
  str_remove_all("@[A-Za-z0-9_.-]+") %>% str_replace_all("#", " ") %>% str_squish())
stage3 <- stage2 %>% filter(nchar(clean) >= 5)                              # empty / URL-only
stage4 <- stage3 %>% distinct(author_handle, text, .keep_all = TRUE)       # same author, same text
stage5 <- stage4 %>% distinct(text, .keep_all = TRUE)                      # same text, any author

flow <- tibble(step = c("Rows in both files combined", "Unique posts (by post ID)", "After removing hotel / Paris Hilton",
                        "After removing empty / URL-only", "After removing same-author duplicates", "After removing identical-text duplicates"),
               posts = c(nrow(stage0), nrow(stage1), nrow(stage2), nrow(stage3), nrow(stage4), nrow(stage5))) %>%
  mutate(removed = lag(posts, default = NA) - posts)
knitr::kable(flow)
step posts removed
Rows in both files combined 396 NA
Unique posts (by post ID) 198 198
After removing hotel / Paris Hilton 198 0
After removing empty / URL-only 186 12
After removing same-author duplicates 126 60
After removing identical-text duplicates 118 8
# Non-English posts: keep langs == "en" or missing; drop explicit non-English
p <- stage5 %>% filter(is.na(langs) | str_detect(langs, "en"))
cat("Dropped explicit non-English:", nrow(stage5) - nrow(p), "\n")
## Dropped explicit non-English: 2
p <- p %>% mutate(low = str_to_lower(text),
                  B = str_detect(low, "becerra|xavier"),
                  H = str_detect(low, "hilton"),
                  group = case_when(B & H ~ "Both", B ~ "Becerra only", H ~ "Hilton only", TRUE ~ "Neither"))
knitr::kable(count(p, group), caption = "Candidate tagging of cleaned posts")
Candidate tagging of cleaned posts
group n
Becerra only 74
Both 28
Hilton only 1
Neither 13
# How concentrated was the raw data?
auth <- stage1 %>% count(author_handle, sort = TRUE)
cat("Raw unique posts:", nrow(stage1), "| distinct authors:", nrow(auth), "\n")
## Raw unique posts: 198 | distinct authors: 86
cat("Share of raw posts from the single most active account:", round(100 * auth$n[1] / nrow(stage1), 1), "%\n")
## Share of raw posts from the single most active account: 36.9 %
cat("Share from the top 5 accounts:", round(100 * sum(auth$n[1:5]) / nrow(stage1), 1), "%\n")
## Share from the top 5 accounts: 52.5 %
cat("Hotel / Paris Hilton posts found:", sum(stage1$hotel), "\n")
## Hotel / Paris Hilton posts found: 0
cat("After cleaning, share of remaining posts from the single most active account:", round(100 * max(table(stage5$author_handle)) / nrow(stage5), 1), "% of", nrow(stage5), "posts\n")
## After cleaning, share of remaining posts from the single most active account: 12.7 % of 118 posts

Finding #2. One account accounts for a very large share of the raw posts by repeating a single message. Without de-duplication, that one voice would dominate every statistic. Finding #3. The hotel filter caught zero posts, because the search query already included the governor’s first name or race terms. I kept the filter in the pipeline anyway. A few posts still differ by one word from the copy-paste message (edited re-posts), so near-duplicates remain, which is a limitation.

3. The AI prompt (exact text used)

I used the AI assistant to write the sentiment code. The prompt, saved verbatim:

 I'm doing a class lab that forecasts the 2026 California governor's race (Xavier Becerra, D vs. Steve Hilton, R) from public Bluesky posts. Please write R code (tidyverse + tidytext) that does the following.

DATA STRUCTURE
- Two CSV files, one per search term, with identical columns: uri, cid, author_handle, author_name, text, author_data, post_data, embed_data, reply_count, repost_count, like_count, quote_count, bookmark_count, indexed_at, created_at, in_reply_to, in_reply_root, quotes, tags, mentions, links, langs, labels.
- One row = one post. `text` is the post body (contains URLs, @handles, hashtags, emoji, line breaks). `uri` is the unique post ID. `author_handle` identifies the author. Read every column as character (the two files parse timestamp columns differently).
- Files may overlap or even be identical: combine them and de-duplicate by `uri`.

METHOD
1. Cleaning: remove "Hilton Hotel"/Paris Hilton/other hotel-brand posts unless the post also mentions the governor's race; remove empty or URL-only posts; remove posts whose `langs` is explicitly non-English (keep NA); remove repeated copy-paste posts (same author + same text, then same text overall). Print a row-count flow table showing how many posts each step removes.
2. Tag each post: mentions Becerra (becerra|xavier), mentions Hilton (hilton), both, or neither.
3. Strip URLs and @handles from the text, then tokenize with tidytext::unnest_tokens().
4. Score sentiment with the Bing lexicon (inner_join with get_sentiments("bing")). Post score = (# positive words) - (# negative words). Classify each post positive (>0), negative (<0) or neutral (=0, including posts with no lexicon words).
5. Summarize by candidate: number of posts, number positive/negative/neutral, positive share, negative share, mean net sentiment. Posts naming both candidates count toward both, and also show a "single-candidate posts only" version.
6. Show the 15 words that contribute most to positive and negative scores so I can audit the lexicon for political mis-scoring (e.g. "trump" is a positive word in Bing).

CONSTRAINTS
- Use only tidyverse and tidytext; no other sentiment packages, no API calls, no web scraping.
- Do not print or display any post text or author handles in the output. Report results only in aggregate.
- Make it reproducible (set.seed) and put every step in its own commented chunk for an R Markdown file.
- Explain, in a comment, any place where Bing will mis-score political text (negation, sarcasm, names). 

4. Sentiment scoring (tidytext + Bing)

bing <- get_sentiments("bing")
p <- p %>% mutate(post_id = row_number())

tokens <- p %>% select(post_id, clean) %>% unnest_tokens(word, clean)
scored <- tokens %>% inner_join(bing, by = "word", relationship = "many-to-many")

post_scores <- scored %>% count(post_id, sentiment) %>% pivot_wider(names_from = sentiment, values_from = n, values_fill = 0)
if (!"positive" %in% names(post_scores)) post_scores$positive <- 0L
if (!"negative" %in% names(post_scores)) post_scores$negative <- 0L

p <- p %>% left_join(post_scores, by = "post_id") %>%
  mutate(across(c(positive, negative), ~replace_na(.x, 0L)),
         score = positive - negative,
         class = case_when(score > 0 ~ "pos", score < 0 ~ "neg", TRUE ~ "neu"),
         author_id = match(author_handle, unique(author_handle)))   # anonymised

cat("Posts with no lexicon words at all (scored neutral):", sum(p$positive + p$negative == 0), "of", nrow(p), "\n")
## Posts with no lexicon words at all (scored neutral): 44 of 116

4.1 Which words drive the scores? (lexicon audit)

top_words <- scored %>% filter(post_id %in% p$post_id[p$B | p$H]) %>% count(word, sentiment, sort = TRUE) %>%
  group_by(sentiment) %>% slice_head(n = 10) %>% ungroup()
knitr::kable(top_words %>% arrange(sentiment, desc(n)))
word sentiment n
corruption negative 17
cruelty negative 15
dishonor negative 8
rail negative 5
hack negative 3
dark negative 2
fume negative 2
mistakes negative 2
poor negative 2
rival negative 2
good positive 17
safe positive 15
like positive 7
hopeful positive 4
work positive 4
great positive 3
trump positive 3
win positive 3
lead positive 2
support positive 2

Bing is a general-purpose lexicon. In political text it will mis-score some words: “trump” is coded positive (it is a verb in the lexicon), and “rail” (as in the high-speed rail authority) is coded negative. Words like “lead”, “like”, “right”, “work” or “clear” are often positive by Bing but neutral in campaign talk. Note also that the most frequent scoring words (“corruption”, “cruelty”, “dishonor”, “good”, “safe”) come overwhelmingly from the near-duplicate copy-paste message, so one account still drives a large share of the lexicon signal. It also does not handle negation (“not good”) and cannot tell whether the target of an insult is the candidate or the candidate’s opponent. I kept pure Bing as the main method (as assigned) and run a “domain-adjusted” version (dropping “trump”) as a sensitivity check.

adj_scores <- tokens %>% filter(word != "trump") %>% inner_join(bing, by = "word", relationship = "many-to-many") %>%
  count(post_id, sentiment) %>% pivot_wider(names_from = sentiment, values_from = n, values_fill = 0)
if (!"positive" %in% names(adj_scores)) adj_scores$positive <- 0L
if (!"negative" %in% names(adj_scores)) adj_scores$negative <- 0L
adj <- p %>% select(post_id) %>% left_join(adj_scores, by = "post_id") %>%
  mutate(across(c(positive, negative), ~replace_na(.x, 0L)), score_adj = positive - negative) %>% select(post_id, score_adj)
p <- p %>% left_join(adj, by = "post_id") %>% mutate(class_adj = case_when(score_adj > 0 ~ "pos", score_adj < 0 ~ "neg", TRUE ~ "neu"))
cat("Posts whose class changes when 'trump' is dropped:", sum(p$class != p$class_adj), "\n")
## Posts whose class changes when 'trump' is dropped: 2

4.2 Results by candidate

cand_long <- bind_rows(p %>% filter(B) %>% mutate(candidate = "Becerra"),
                       p %>% filter(H) %>% mutate(candidate = "Hilton"))
summ <- function(d) d %>% group_by(candidate) %>%
  summarise(posts = n(), positive = sum(class == "pos"), negative = sum(class == "neg"), neutral = sum(class == "neu"),
            pos_share = round(positive / posts, 3), neg_share = round(negative / posts, 3),
            mean_net = round(mean(score), 3), .groups = "drop")
cat("All posts naming the candidate (posts naming both count for both):\n"); knitr::kable(summ(cand_long))
## All posts naming the candidate (posts naming both count for both):
candidate posts positive negative neutral pos_share neg_share mean_net
Becerra 102 23 26 53 0.225 0.255 0.000
Hilton 29 6 7 16 0.207 0.241 -0.103
cat("\nPosts naming only that candidate:\n")
## 
## Posts naming only that candidate:
knitr::kable(summ(cand_long %>% filter(!(B & H))))
candidate posts positive negative neutral pos_share neg_share mean_net
Becerra 74 17 19 38 0.23 0.257 0.041
Hilton 1 0 0 1 0.00 0.000 0.000
cand_long %>% count(candidate, class) %>% group_by(candidate) %>% mutate(share = n / sum(n)) %>%
  ggplot(aes(candidate, share, fill = class)) + geom_col() +
  scale_fill_manual(values = c(neg = "#c0504d", neu = "grey75", pos = "#4f81bd")) +
  labs(y = "Share of posts", x = NULL, title = "Bing sentiment class by candidate") + theme_minimal()

Note. Only a handful of posts name Hilton without Becerra, so the Hilton row is small and mostly made of posts that discuss both men. For those joint posts the code necessarily assigns the same sentiment score to both candidates, which is a structural weakness of post-level scoring.

5. Validation: hand-labelled posts vs. the code

I drew a reproducible random sample of 20 candidate-naming posts (set.seed(2026)), read each one, and labelled the stance toward each candidate named as support, oppose, or neutral/news. Posts naming both candidates get two labels. These labels are listed by anonymous row ID only.

Note on authorship. In this document the labels below were drafted by Claude so the pipeline could run end to end. For your own submission, re-label the same sampled posts yourself and overwrite the labels table; the accuracy numbers will update.

set.seed(2026)
samp <- p %>% filter(B | H) %>% arrange(uri) %>% mutate(id = row_number()) %>% slice_sample(n = 20) %>% arrange(id)

labels <- tribble(
  ~id, ~candidate, ~hand,
    5, "Becerra", "oppose",  5, "Hilton", "support",
   10, "Becerra", "neutral",
   12, "Becerra", "support",
   18, "Becerra", "neutral", 18, "Hilton", "neutral",
   19, "Becerra", "neutral", 19, "Hilton", "neutral",
   31, "Becerra", "support",
   34, "Becerra", "support", 34, "Hilton", "oppose",
   36, "Becerra", "neutral",
   38, "Becerra", "support",
   44, "Becerra", "support",
   45, "Becerra", "support",
   48, "Becerra", "neutral", 48, "Hilton", "neutral",
   54, "Becerra", "neutral", 54, "Hilton", "neutral",
   56, "Becerra", "support",
   58, "Becerra", "support",
   79, "Becerra", "neutral",
   88, "Becerra", "neutral", 88, "Hilton", "neutral",
   91, "Becerra", "support",
   93, "Becerra", "neutral",
   97, "Becerra", "support")

# Safeguard: labels must cover exactly the candidate(s) each sampled post names
expected <- samp %>% select(id, Becerra = B, Hilton = H) %>% pivot_longer(c(Becerra, Hilton), names_to = "candidate") %>% filter(value) %>% select(id, candidate)
stopifnot(nrow(anti_join(expected, labels, by = c("id", "candidate"))) == 0, nrow(anti_join(labels, expected, by = c("id", "candidate"))) == 0)

val <- labels %>% left_join(samp %>% select(id, post_id, class, class_adj, B, H), by = "id") %>%
  mutate(hand_cls = recode(hand, support = "pos", oppose = "neg", neutral = "neu"),
         code_cls = class, match = hand_cls == code_cls, match_adj = hand_cls == class_adj)
stopifnot(nrow(val) == nrow(labels), !any(is.na(val$class)))

cat("Sampled posts:", n_distinct(val$id), "| candidate-level labels:", nrow(val), "\n")
## Sampled posts: 20 | candidate-level labels: 27
acc <- tibble(version = c("Pure Bing", "Bing without 'trump'"), accuracy = c(mean(val$match), mean(val$match_adj)))
knitr::kable(acc %>% mutate(accuracy = scales::percent(accuracy, 0.1)))
version accuracy
Pure Bing 40.7%
Bing without ‘trump’ 40.7%
cat("\nAccuracy among labels with a clear stance (support/oppose only):",
    scales::percent(mean(val$match[val$hand != "neutral"]), 0.1), "(n =", sum(val$hand != "neutral"), ")\n")
## 
## Accuracy among labels with a clear stance (support/oppose only): 23.1% (n = 13 )
cat("Accuracy among neutral/news labels:", scales::percent(mean(val$match[val$hand == "neutral"]), 0.1), "(n =", sum(val$hand == "neutral"), ")\n")
## Accuracy among neutral/news labels: 57.1% (n = 14 )
cat("Accuracy on joint (both-candidate) posts:", scales::percent(mean(val$match[val$B & val$H]), 0.1), "(n =", sum(val$B & val$H), ")\n\n")
## Accuracy on joint (both-candidate) posts: 50.0% (n = 14 )
knitr::kable(table(Hand = val$hand_cls, Code = val$code_cls), caption = "Confusion matrix: hand stance (rows) vs. Bing class (columns)")
Confusion matrix: hand stance (rows) vs. Bing class (columns)
neg neu pos
neg 1 1 0
neu 1 8 5
pos 1 8 2
baseline <- mean(val$hand_cls == "neu")
cat("\nBaseline: labelling every post 'neutral' would be right", scales::percent(baseline, 0.1), "of the time.\n")
## 
## Baseline: labelling every post 'neutral' would be right 51.9% of the time.
cat("With only", nrow(val), "labels, the accuracy figures have a wide margin of error (roughly +/-", round(100 * 1.96 * sqrt(.25 / nrow(val))), "points); treat them as indicative, not precise.\n")
## With only 27 labels, the accuracy figures have a wide margin of error (roughly +/- 19 points); treat them as indicative, not precise.
val %>% arrange(id, candidate) %>% transmute(row_id = id, candidate, hand_stance = hand, bing_class = code_cls, score = p$score[post_id], agree = match) %>%
  knitr::kable(caption = "Label-by-label comparison (anonymous row IDs only)")
Label-by-label comparison (anonymous row IDs only)
row_id candidate hand_stance bing_class score agree
5 Becerra oppose neg -1 TRUE
5 Hilton support neg -1 FALSE
10 Becerra neutral neg -1 FALSE
12 Becerra support pos 2 TRUE
18 Becerra neutral neu 0 TRUE
18 Hilton neutral neu 0 TRUE
19 Becerra neutral neu 0 TRUE
19 Hilton neutral neu 0 TRUE
31 Becerra support pos 3 TRUE
34 Becerra support neu 0 FALSE
34 Hilton oppose neu 0 FALSE
36 Becerra neutral pos 1 FALSE
38 Becerra support neu 0 FALSE
44 Becerra support neu 0 FALSE
45 Becerra support neu 0 FALSE
48 Becerra neutral pos 1 FALSE
48 Hilton neutral pos 1 FALSE
54 Becerra neutral pos 1 FALSE
54 Hilton neutral pos 1 FALSE
56 Becerra support neu 0 FALSE
58 Becerra support neu 0 FALSE
79 Becerra neutral neu 0 TRUE
88 Becerra neutral neu 0 TRUE
88 Hilton neutral neu 0 TRUE
91 Becerra support neu 0 FALSE
93 Becerra neutral neu 0 TRUE
97 Becerra support neu 0 FALSE

6. Prediction

6.1 Chosen rule

Primary rule: volume share — the share of candidate-naming posts that mention each candidate, Becerra posts / (Becerra posts + Hilton posts), used directly as the predicted two-party vote share. I chose it because, as the validation shows, the Bing classes agree with my hand-coded stance only 41% of the time (and only 23% when the post takes a clear side), so a sentiment-based rule would be mostly noise; and because posts naming both candidates get identical sentiment, which cancels out in any comparison. I report positive share and net sentiment as comparison rules.

rule_stats <- function(d) {
  nB <- sum(d$B); nH <- sum(d$H)
  pB <- sum(d$B & d$class == "pos"); pH <- sum(d$H & d$class == "pos")
  nsB <- mean(d$score[d$B]); nsH <- mean(d$score[d$H])
  c(volume = nB / (nB + nH), positive = pB / (pB + pH), net_diff = nsB - nsH)
}
obs <- rule_stats(p)
cand_p <- p %>% filter(B | H)
tibble(rule = c("Volume share (primary)", "Positive-post share", "Net sentiment (mean score), Becerra minus Hilton"),
       statistic = c(obs["volume"], obs["positive"], obs["net_diff"]),
       winner = c(ifelse(obs["volume"] > .5, "Becerra", "Hilton"), ifelse(obs["positive"] > .5, "Becerra", "Hilton"), ifelse(obs["net_diff"] > 0, "Becerra", ifelse(obs["net_diff"] < 0, "Hilton", "Tie")))) %>%
  mutate(statistic = round(statistic, 3)) %>% knitr::kable()
rule statistic winner
Volume share (primary) 0.779 Becerra
Positive-post share 0.793 Becerra
Net sentiment (mean score), Becerra minus Hilton 0.103 Becerra

Prediction (volume-share rule): Becerra wins with a predicted two-party share of 77.9% (Hilton 22.1%).

6.2 Bootstrap: how much is sampling noise?

I resample posts with replacement 5,000 times and recompute each rule. Because a few accounts can post many times, I also run a cluster bootstrap that resamples authors (with all their posts), which gives a more honest interval.

set.seed(2026)
B_reps <- 5000
n <- nrow(p)
boot_naive <- t(replicate(B_reps, rule_stats(p[sample.int(n, n, replace = TRUE), ])))
idx_by_author <- split(seq_len(n), p$author_id)
boot_cluster <- t(replicate(B_reps, {
  a <- sample(seq_along(idx_by_author), length(idx_by_author), replace = TRUE)
  rule_stats(p[unlist(idx_by_author[a]), ])
}))
ci <- function(m, col) quantile(m[, col], c(.025, .975), na.rm = TRUE)
out <- tibble(
  method = rep(c("Post bootstrap", "Author-cluster bootstrap"), each = 3),
  rule = rep(c("Volume share", "Positive share", "Net diff (B - H)"), 2),
  observed = rep(obs, 2),
  lo95 = c(ci(boot_naive, 1)[1], ci(boot_naive, 2)[1], ci(boot_naive, 3)[1], ci(boot_cluster, 1)[1], ci(boot_cluster, 2)[1], ci(boot_cluster, 3)[1]),
  hi95 = c(ci(boot_naive, 1)[2], ci(boot_naive, 2)[2], ci(boot_naive, 3)[2], ci(boot_cluster, 1)[2], ci(boot_cluster, 2)[2], ci(boot_cluster, 3)[2]),
  p_becerra_wins = c(mean(boot_naive[, 1] > .5, na.rm = TRUE), mean(boot_naive[, 2] > .5, na.rm = TRUE), mean(boot_naive[, 3] > 0, na.rm = TRUE),
                     mean(boot_cluster[, 1] > .5, na.rm = TRUE), mean(boot_cluster[, 2] > .5, na.rm = TRUE), mean(boot_cluster[, 3] > 0, na.rm = TRUE))
) %>% mutate(across(where(is.numeric), ~round(.x, 3)))
knitr::kable(out)
method rule observed lo95 hi95 p_becerra_wins
Post bootstrap Volume share 0.779 0.726 0.832 1.000
Post bootstrap Positive share 0.793 0.688 0.917 1.000
Post bootstrap Net diff (B - H) 0.103 -0.258 0.484 0.699
Author-cluster bootstrap Volume share 0.779 0.705 0.854 1.000
Author-cluster bootstrap Positive share 0.793 0.692 0.917 1.000
Author-cluster bootstrap Net diff (B - H) 0.103 -0.283 0.537 0.701
tibble(share = c(boot_naive[, 1], boot_cluster[, 1]), method = rep(c("Post bootstrap", "Author-cluster bootstrap"), each = B_reps)) %>%
  ggplot(aes(share, fill = method)) + geom_histogram(alpha = .6, position = "identity", bins = 40) +
  geom_vline(xintercept = .5, linetype = 2) + geom_vline(xintercept = obs["volume"]) +
  labs(x = "Becerra volume share", y = "Bootstrap replicates", title = "Bootstrap distribution of the volume-share forecast") + theme_minimal()

Reading the intervals. With the author-cluster bootstrap, the Becerra volume share is about 70%–85% (95%); resampling noise alone almost never flips the winner on this rule. The net-sentiment rule, by contrast, is statistically indistinguishable from a tie (its interval spans zero), which is another reason not to rely on it. These intervals measure only sampling noise, not the bias discussed in Section 7.

6.3 Sensitivity: what the cleaning did to the forecast

raw_tag <- stage1 %>% mutate(low = str_to_lower(text), B = str_detect(low, "becerra|xavier"), H = str_detect(low, "hilton"))
raw_share <- sum(raw_tag$B) / (sum(raw_tag$B) + sum(raw_tag$H))
excl_both <- p %>% filter(xor(B, H)); ex_share <- sum(excl_both$B) / nrow(excl_both)
tibble(version = c("Raw unique posts (no duplicate removal)", "Cleaned posts (primary)", "Cleaned, single-candidate posts only"),
       becerra_share = c(raw_share, obs["volume"], ex_share), n = c(sum(raw_tag$B | raw_tag$H), nrow(cand_p), nrow(excl_both))) %>%
  mutate(becerra_share = scales::percent(becerra_share, 0.1)) %>% knitr::kable()
version becerra_share n
Raw unique posts (no duplicate removal) 82.9% 177
Cleaned posts (primary) 77.9% 103
Cleaned, single-candidate posts only 98.7% 75

The forecast moves by about five points when duplicates are removed, so cleaning matters. The “single-candidate only” figure is not a meaningful alternative forecast: only one post names Hilton without Becerra, so that row mostly reflects how the search was built (and that Hilton is usually discussed alongside Becerra), not how Bluesky users feel about the candidates.

6.4 External check (not used in the forecast)

For context only: October 2026 public polls put Becerra well ahead. The RealClearPolling average was about 57% to 35% (as of Oct 19), and Emerson College (Oct 17–19) had 60% to 33%; a Hilton-campaign-commissioned poll in early October was much closer (46% to 40%). Converted to a two-party share, the mainstream polls imply a Becerra share of roughly 62–65%. The Bluesky volume share above is therefore higher than the polls, which is the pattern one would expect from a platform whose users lean left (see Section 7). Official results are not yet in, so the forecast cannot be scored.

7. Reflection: limits of this forecast

Who is on Bluesky? Bluesky is a self-selected platform whose early adopters skew left-leaning, younger, urban, and heavy news-and-politics users, many of them refugees from other social platforms. California voters in general, and especially Republican and low-engagement voters, are underrepresented. A tool that counts posts here measures the mood of a particular online community, not of the electorate. The mismatch with the polls in 6.4 is consistent with this: the volume share overshoots Becerra’s polled two-party share.

Who is posting? A sample this small and concentrated is fragile. One account repeating one message accounted for a large slice of the raw data; even after de-duplication, a handful of campaign accounts, news outlets, and activists supply much of what remains. These are not randomly sampled voters, and some are not voters at all (organizations, bots, accounts outside California). Bluesky has no location variable here, so I cannot restrict to California residents.

Tone vs. stance, and sarcasm. Sentiment lexicons measure the emotional vocabulary of a post, not who the author supports. A furious post attacking Hilton is full of negative words but is pro-Becerra; a sarcastic “great job, Becerra” scores positive but is an attack; and a news headline about a “mileage tax” scores negative without expressing an opinion. Bing also treats “trump” as a positive word, and cannot handle negation. The validation quantifies the damage: Bing agreed with my hand-coded stance only 41% of the time overall, 23% on posts that clearly support or oppose a candidate, and no better than the 52% I would get by calling everything neutral. Many obviously supportive “vote for X” slogan posts contain no lexicon words at all and score zero, while neutral news items pick up a point from words like “work”. Posts naming both candidates receive the same score for each, so net sentiment cannot separate them. That is why I used volume share rather than a sentiment rule as the primary forecast.

Does more posts mean more support? Not necessarily. Volume captures attention, not approval. Candidates attract posts because they are the frontrunner, because they are controversial, or because they are being attacked; a candidate being mocked can generate as many posts as one being praised. The Hilton campaign also produces many posts on other platforms that would not show up in a Bluesky search. Volume share here largely reflects who the Bluesky community talks about and who runs more active supporters, not how Californians will vote.

Sampling noise vs. bias. The bootstrap shows how much the number would move if I had drawn a different set of posts from the same platform and the same search; the author-cluster interval is wider and more honest than the post-level one. It cannot reveal bias: if the sampling frame is skewed (Bluesky users, one search method, one week), the interval can be tight and still wrong. The comparison with the polls is the better evidence of that bias.

Bottom line. The Bluesky data point to a Becerra win, in line with the polls, but the predicted vote share should not be treated as a vote share estimate. The direction of the result is credible mainly because it agrees with independent polling, not because of the Bluesky numbers themselves.


Reproducibility: R 4.6.1, tidytext 0.4.3; set.seed(2026); 5,000 bootstrap replicates.