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.
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)
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.
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.
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"
)
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)
)
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)
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.
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.