Purpose and prediction

This project uses public Bluesky posts to make an exploratory forecast of the November 3, 2026 California governor election between Xavier Becerra and Steve Hilton. Using volume share, the predicted winner is Xavier Becerra with 78.0% of the two-candidate vote, compared with 22.0% for Steve Hilton. These are assumed vote shares obtained by mapping discussion share directly to vote share. They are not estimates from a representative sample of voters.

Becerra’s 95% author-cluster bootstrap interval is 67.3%–86.6%. This interval measures resampling variation within the retrieved discussion; it does not capture platform bias or establish an electoral margin of error.

Data collection

I used the previous lab’s atrrr package and searched separately for Becerra and Hilton, requesting up to 1,000 posts per search. The package can return more than its requested limit. The export contained 2,148 rows: 1,066 from the Becerra search and 1,082 from the Hilton search. A search label identifies which query retrieved a post, not who the post supports.

The searches were run without a date restriction. For the analysis, I restricted both candidates to the same seven-day posting window: October 22, 2026 at 22:00 UTC through October 29, 2026 at 22:00 UTC, excluding the endpoint (3:00 p.m. Pacific time on each date). The initial searches used a retrieval limit, so applying a common window afterward cannot guarantee equal or complete coverage. Recent high-volume search results may crowd out earlier posts.

library(atrrr)
library(dplyr)
# Authentication was completed privately before searching.
Becerra_posts <- search_post("Becerra", limit = 1000) %>%
  mutate(search_query = "Becerra")
Hilton_posts <- search_post("Hilton", limit = 1000) %>%
  mutate(search_query = "Hilton")
Post <- bind_rows(Becerra_posts, Hilton_posts)

Cleaning process and audit

AI helped identify hotel/Paris Hilton references and additional issues: missing text or dates, duplicate posts, non-English content, unrelated people with the same surname, ambiguous references, and posts mentioning both candidates. I removed URLs and account mentions before tokenizing. I did not strip negation words or apply a stopword filter.

The following audit is sequential: each removal count is relative to the previous row, so overlapping problems are not counted twice.

Cleaning step Rows removed Rows remaining
Original search export — 2,148
Remove missing text or identifiers 92 2,056
Remove invalid/missing creation dates 137 1,919
Restrict to common seven-day window 579 1,340
Remove repeated post URIs 55 1,285
Remove empty/duplicate normalized text 153 1,132
Remove Paris Hilton and hotel/brand references 100 1,032
Require an explicit English language tag 584 448
Require a candidate name with election context 281 167
Remove posts referring to both candidates 34 133
Remove unrelated Chris Hilton post found in audit 1 132

Normalized duplicate text ignores case, whitespace, URLs and account mentions; the earliest post is retained. Candidate relevance requires a full name or concatenated full name, or a surname accompanied by election-related context. The hotel filter covers Paris Hilton, Hilton hotels/resorts, Hilton Honors, Hilton Garden Inn, Hilton Grand Vacations and selected associated brands.

These are conservative rules. They can exclude relevant comparisons, relevant posts that mention hotels, posts with missing language tags, and some voters’ views in languages other than English. The audit also caught a false positive: a post about lawyer Chris Hilton, which was removed. This shows that contextual keyword matching does not guarantee relevance.

Saved AI prompt

The following is the exact saved specification used for the initial AI-generated workflow. Follow-up instructions adapted it to the uploaded atrrr CSV and the returned label worksheet. The final implementation used private account handles for author clustering, rather than the initial specification’s DIDs, and used the final posting window reported above.

Write a reproducible R Markdown analysis of public Bluesky posts about Xavier Becerra and Steve Hilton in the California governor election. My previous lab CSV has uri, text, author_handle, langs, created_at, indexed_at and engagement fields. For a fresh API collection use uri, private author_id (DID), text, langs, created_at and query; retain the same timestamps and window for both searches. Use tidytext::unnest_tokens and get_sentiments(“bing”). Clean empty text, invalid or out-of-window dates, duplicate URIs and normalized duplicate text. Remove Paris Hilton and Hilton hotel/resort/brand posts; flag exclusions and count each sequential cleaning step. Restrict to explicitly English-tagged posts, retain only unambiguous candidate references using full names or surnames with election context, and exclude posts referring to both candidates from candidate sentiment attribution. State these restrictions and their possible bias. Remove URLs and mentions before tokenization but do not remove negators or apply stopword filtering. Score each post as positive-word count minus negative-word count. Keep posts with no matches at zero and distinguish these from balanced matches. Summarize counts, coverage and sentiment by candidate. Make a reproducible simple random sample of 20 cleaned posts for a human to label support, oppose, neutral or uncertain toward the named candidate without seeing the code label. Do not invent human labels. Once supplied, compare positive/negative/zero to support/oppose/neutral, report a confusion matrix, accuracy, uncertain exclusions and denominator. Select volume share in advance as the forecast rule; name the larger-share candidate and report the normalized two-candidate share as an assumed vote-share proxy. Bootstrap 2,000 times by author cluster across both candidates and report a percentile interval and win fraction; distinguish sampling variation from platform bias. Keep raw data, identifiers and manual labels private. Publish only aggregate tables, no usernames, post text, profile links or identifiers. Stop if data or 10-20 human labels are missing. Include a reflection about Bluesky users, sarcasm, tone versus stance, repeated authors and why post volume need not mean support.

Sentiment method and results

I used tidytext::unnest_tokens() to split text into words and get_sentiments("bing") to identify positive and negative words. Each post’s score equals its positive-word count minus its negative-word count. Positive scores are positive, negative scores are negative, and zero scores are neutral. Unmatched posts remain in the data with score zero. A zero score can therefore mean no dictionary matches or balanced positive and negative matches.

Candidate Total posts Positive Negative Neutral Mean net score
Becerra 103 35 23 45 0.243
Hilton 29 7 13 9 -0.310

Positive posts represented 34.0% of Becerra’s retained posts and 24.1% of Hilton’s. Becerra had more positive than negative posts, whereas Hilton had more negative than positive posts. These describe dictionary tone, not verified support for either candidate.

library(tidyverse)
library(tidytext)
# posts_clean contains one row per retained post, with post_id,
# candidate, text, text_clean and a private author_handle.
word_scores <- posts_clean %>%
  select(post_id, text_clean) %>%
  unnest_tokens(word, text_clean) %>%
  inner_join(get_sentiments("bing"), by = "word") %>%
  group_by(post_id) %>%
  summarise(
    positive_words = sum(sentiment == "positive"),
    negative_words = sum(sentiment == "negative"),
    .groups = "drop"
  )
scored_posts <- posts_clean %>%
  left_join(word_scores, by = "post_id") %>%
  mutate(
    positive_words = replace_na(positive_words, 0L),
    negative_words = replace_na(negative_words, 0L),
    sentiment_score = positive_words - negative_words,
    code_label = case_when(
      sentiment_score > 0 ~ "support",
      sentiment_score < 0 ~ "oppose",
      TRUE ~ "neutral"
    )
  )
sentiment_summary <- scored_posts %>%
  group_by(candidate) %>%
  summarise(
    total_posts = n(),
    positive_posts = sum(sentiment_score > 0),
    negative_posts = sum(sentiment_score < 0),
    neutral_posts = sum(sentiment_score == 0),
    average_sentiment = mean(sentiment_score),
    .groups = "drop"
  )

Reviewed stance audit and validation

Twenty posts were sampled without replacement from the initial 133-post cleaned pool using a fixed random seed of 4000. The worksheet contained 15 Becerra and 5 Hilton posts. Stance labels were drafted with AI assistance and returned for review, so this is an assisted audit rather than an independent, blinded human validation. Agreement should be interpreted with that limitation.

Support means favoring the named candidate; oppose means criticizing or rejecting the candidate; neutral means no clear preference; uncertain means ambiguous or mixed stance. The worksheet contained 10 support, 7 oppose, 1 neutral, 1 uncertain and 1 off-topic label. Excluding the off-topic and uncertain rows left 18 evaluable posts. The uncertain row is not treated as neutral.

For comparison, a positive dictionary score maps to support, a negative score to oppose, and zero to neutral. This tests whether the tone proxy agrees with reviewed candidate stance.

Reviewed stance Code: support Code: oppose Code: neutral Total
Support 4 3 3 10
Oppose 3 2 2 7
Neutral 0 0 1 1
Total 7 5 6 18

Validation accuracy = 7 / 18 = 38.9%. Eleven labels disagreed. Always predicting the most common reviewed class, support, would score 55.6% on this small set, so the dictionary proxy performed worse than that simple baseline. I did not tune the lexicon after seeing the audit.

# reviewed_labels is the private returned worksheet.
evaluable_labels <- reviewed_labels %>%
  filter(human_label %in% c("support", "oppose", "neutral"))
validation <- evaluable_labels %>%
  select(candidate, text, human_label) %>%
  inner_join(
    scored_posts %>% select(candidate, text, code_label),
    by = c("candidate", "text")
  )
validation_summary <- validation %>%
  summarise(
    evaluated_posts = n(),
    correct_labels = sum(human_label == code_label),
    accuracy_percent = 100 * mean(human_label == code_label)
  )

Prediction rule and bootstrap

I chose volume share as the prediction rule before running the bootstrap:

Assumed vote share = retained posts about a candidate / all retained posts.

Becerra’s share is 103/132 = 78.0%; Hilton’s is 29/132 = 22.0%. The forecast therefore names Xavier Becerra as the winner. This rule counts mentions regardless of support, so it is not an endorsement count. It is also sensitive to the unequal coverage of the original capped searches.

I ran 2,000 bootstrap replicates, with seed 4001, sampling authors with replacement and keeping each selected author’s posts about both candidates. This preserves within-author repetition and allows candidate volume shares to vary. The percentile interval uses the 2.5th and 97.5th percentiles.

Candidate Assumed two-candidate vote share 95% bootstrap interval Share of bootstrap replicates leading
Becerra 78.0% 67.3%–86.6% 100%
Hilton 22.0% 13.4%–32.7% 0%

Becerra led in all 2,000 resampled datasets. This suggests the lead is not easily explained by resampling variation within this retrieved pool. It does not mean a 100% probability of winning the election. The bootstrap does not measure errors caused by platform selection, incomplete search coverage or the untested conversion of discussion volume into votes.

becerra_share <- mean(scored_posts$candidate == "Becerra")
author_counts <- scored_posts %>%
  count(author_handle, candidate, name = "posts") %>%
  complete(
    author_handle,
    candidate = c("Becerra", "Hilton"),
    fill = list(posts = 0L)
  ) %>%
  pivot_wider(names_from = candidate, values_from = posts)
set.seed(4001)
bootstrap_becerra <- replicate(2000, {
  selected_authors <- sample.int(
    nrow(author_counts), nrow(author_counts), replace = TRUE
  )
  sample_counts <- author_counts[selected_authors, ]
  sum(sample_counts$Becerra) /
    sum(sample_counts$Becerra + sample_counts$Hilton)
})
100 * quantile(bootstrap_becerra, c(0.025, 0.975))
100 * mean(bootstrap_becerra > 0.5)

Reflection

This activity showed me that collecting more posts does not automatically make an election forecast reliable. Bluesky users who post about politics choose to participate, and they may differ from California voters in age, location, political views and level of political involvement. The dataset does not verify residency, voter registration or turnout. I cannot assume its users represent the people who will actually cast ballots.

The low validation accuracy also showed why tone and stance are different. Someone can support a candidate while using negative words to criticize the opponent. Sarcasm can sound positive while expressing disapproval. A simple word dictionary misses those meanings, as well as negation, quoted speech and context supplied by replies or images. The small, assisted review sample further limits how much confidence I can place in the accuracy result.

More discussion can mean more support, but it can also mean controversy, criticism, news coverage or repeated campaigning by a few accounts. Removing duplicate text and bootstrapping authors helps with repetition, but does not identify every automated or coordinated account. The English-only filter and strict relevance rules also exclude some potentially meaningful opinions. Although this exercise predicts Becerra, I would treat the 78.0% figure as a classroom discussion-share forecast and compare it with representative polling before using it to make claims about the electorate.

Reproducibility and privacy

The numerical results above are fixed snapshots of the completed R analysis exported on October 29, 2026. The displayed R chunks document the method and are not rerun when this report is knitted. This lets the public report render without loading or publishing the private post data or label worksheet. The original analysis script and private inputs are retained separately.

Only aggregate results appear here. No usernames, account identifiers, profile links or individual post text are published.

Method references