I used the atrrr package in R to collect public Bluesky posts using the searches “Xavier Becerra” and “Steve Hilton,” with an English-language filter and the same October 22–28, 2026 date window. I requested up to 200 results per candidate and collected 175 Becerra records and 149 Hilton records before cleaning. Some posts appeared in both searches.
No posts matched the Paris Hilton or Hilton Hotel checks. ChatGPT also helped identify missing text, missing timestamps, repeated content, link-only posts, and ambiguous candidate references as cleaning issues. After cleaning, 55 Becerra posts and 76 Hilton posts remained.
library(dplyr)
library(stringr)
library(readr)
raw <- read_csv("governor_posts_raw.csv", show_col_types = FALSE)
checked <- raw %>%
mutate(
text = coalesce(text, ""),
timestamp = as.POSIXct(created_at, tz = "UTC"),
body = str_remove_all(
text,
"https?://\\S+|\\b(?:[A-Za-z0-9-]+\\.)+[A-Za-z]{2,}(?:/\\S*)?"
),
mentions_becerra = str_detect(
str_to_lower(body), "becerra|beccera|bacerra"
),
mentions_hilton = str_detect(str_to_lower(body), "hilton"),
removal_reason = case_when(
str_trim(text) == "" ~ "Missing text",
is.na(timestamp) ~ "Missing timestamp",
timestamp < as.POSIXct("2026-10-22", tz = "UTC") |
timestamp >= as.POSIXct("2026-10-29", tz = "UTC") ~
"Outside collection window",
str_detect(
str_to_lower(text), "paris\\s+hilton|hilton\\s+hotels?"
) & !str_detect(str_to_lower(text), "steve\\s+hilton") ~
"Unrelated Hilton result",
!str_detect(body, "[A-Za-z]") ~ "No text beyond links",
mentions_becerra & mentions_hilton ~ "Both candidates mentioned",
candidate == "Becerra" & !mentions_becerra ~
"Candidate absent from post text",
candidate == "Hilton" & !mentions_hilton ~
"Candidate absent from post text",
str_detect(body, fixed("California tiene latinos listos")) ~
"Spanish-only text reviewed manually",
TRUE ~ "Keep"
)
)
eligible <- checked %>%
filter(removal_reason == "Keep") %>%
mutate(
normalized_text = str_squish(str_to_lower(body)),
duplicate_content = duplicated(
data.frame(candidate, normalized_text)
)
)
clean_posts <- eligible %>%
filter(!duplicate_content)
audit <- bind_rows(
checked %>%
filter(removal_reason != "Keep") %>%
count(candidate, removal_reason, name = "posts"),
eligible %>%
filter(duplicate_content) %>%
count(candidate, name = "posts") %>%
mutate(removal_reason = "Repeated content within candidate"),
clean_posts %>%
count(candidate, name = "posts") %>%
mutate(removal_reason = "Retained")
)
knitr::kable(audit)
| candidate | removal_reason | posts |
|---|---|---|
| Becerra | Both candidates mentioned | 15 |
| Becerra | Candidate absent from post text | 18 |
| Becerra | Missing timestamp | 7 |
| Becerra | No text beyond links | 20 |
| Becerra | Spanish-only text reviewed manually | 1 |
| Hilton | Both candidates mentioned | 20 |
| Hilton | Candidate absent from post text | 13 |
| Hilton | Missing text | 1 |
| Hilton | Missing timestamp | 19 |
| Hilton | No text beyond links | 18 |
| Becerra | Repeated content within candidate | 59 |
| Hilton | Repeated content within candidate | 2 |
| Becerra | Retained | 55 |
| Hilton | Retained | 76 |
Write R code for my California governor Bluesky analysis. My cleaned data frame is named clean_posts. It contains candidate (“Becerra” or “Hilton”), uri (post ID), text (original text), and body (text with URLs removed). There are 55 Becerra posts and 76 Hilton posts.
Use tidytext and the Bing sentiment lexicon. Remove usernames from the text before tokenizing. Count positive and negative word occurrences in each post, and calculate sentiment as positive count minus negative count. Classify scores above zero as positive, below zero as negative, and zero as neutral. Keep every post, including posts with no matching sentiment words, and separately flag those with no matches.
Summarize post counts, positive/negative/neutral counts, positive-post proportions, and average net sentiment by candidate. Display only aggregate results, without usernames, post IDs, or individual post text. Explain that word sentiment measures tone and may not reflect support for a candidate. Give me one R Markdown code chunk that uses my existing clean_posts object.
library(dplyr)
library(stringr)
library(tidytext)
# Assign an internal ID and remove usernames before tokenizing.
posts_for_scoring <- clean_posts %>%
ungroup() %>%
mutate(
post_id = row_number(),
scoring_text = str_remove_all(
coalesce(body, ""),
"@[A-Za-z0-9_.-]+"
)
)
bing <- get_sentiments("bing")
# Each occurrence of a matching word contributes one count.
word_counts <- posts_for_scoring %>%
select(post_id, scoring_text) %>%
unnest_tokens(word, scoring_text) %>%
inner_join(bing, by = "word") %>%
group_by(post_id) %>%
summarise(
positive_words = sum(sentiment == "positive"),
negative_words = sum(sentiment == "negative"),
.groups = "drop"
)
# Left join preserves posts with no matching sentiment words.
posts_scored <- posts_for_scoring %>%
left_join(word_counts, by = "post_id") %>%
mutate(
positive_words = coalesce(positive_words, 0L),
negative_words = coalesce(negative_words, 0L),
matched_words = positive_words + negative_words,
no_matches = matched_words == 0,
net_sentiment = positive_words - negative_words,
sentiment_label = case_when(
net_sentiment > 0 ~ "positive",
net_sentiment < 0 ~ "negative",
TRUE ~ "neutral"
)
)
# Verify that scoring retained every post.
stopifnot(nrow(posts_scored) == nrow(clean_posts))
# Display aggregate results only.
sentiment_summary <- posts_scored %>%
group_by(candidate) %>%
summarise(
total_posts = n(),
positive_posts = sum(sentiment_label == "positive"),
negative_posts = sum(sentiment_label == "negative"),
neutral_posts = sum(sentiment_label == "neutral"),
no_match_posts = sum(no_matches),
positive_proportion = mean(sentiment_label == "positive"),
average_net_sentiment = mean(net_sentiment),
.groups = "drop"
)
knitr::kable(sentiment_summary, digits = 3)
| candidate | total_posts | positive_posts | negative_posts | neutral_posts | no_match_posts | positive_proportion | average_net_sentiment |
|---|---|---|---|---|---|---|---|
| Becerra | 55 | 13 | 11 | 31 | 21 | 0.236 | 0.091 |
| Hilton | 76 | 26 | 16 | 34 | 22 | 0.342 | 0.118 |
# Recreate the same random sample: five posts per candidate.
set.seed(123)
validation <- posts_scored %>%
group_by(candidate) %>%
slice_sample(n = 5) %>%
ungroup() %>%
mutate(
audit_id = row_number(),
hand_stance = c(
rep("supportive", 5),
rep("opposed", 5)
),
# Map stance labels to the code's three categories.
hand_label = case_when(
hand_stance == "supportive" ~ "positive",
hand_stance == "opposed" ~ "negative",
TRUE ~ "neutral"
),
agreement = hand_label == sentiment_label
)
# Display aggregate validation results only.
validation_summary <- bind_rows(
validation %>%
group_by(candidate) %>%
summarise(
audited_posts = n(),
agreements = sum(agreement),
accuracy_percent = 100 * mean(agreement),
.groups = "drop"
),
validation %>%
summarise(
candidate = "Overall",
audited_posts = n(),
agreements = sum(agreement),
accuracy_percent = 100 * mean(agreement)
)
)
knitr::kable(validation_summary, digits = 1)
| candidate | audited_posts | agreements | accuracy_percent |
|---|---|---|---|
| Becerra | 5 | 1 | 20 |
| Hilton | 5 | 2 | 40 |
| Overall | 10 | 3 | 30 |
# Aggregate comparison of human stance and code sentiment.
comparison <- validation %>%
count(hand_stance, sentiment_label, name = "posts")
knitr::kable(comparison)
| hand_stance | sentiment_label | posts |
|---|---|---|
| opposed | negative | 2 |
| opposed | neutral | 1 |
| opposed | positive | 2 |
| supportive | neutral | 4 |
| supportive | positive | 1 |
I used the positive-share rule, dividing each candidate’s proportion of positive posts by the sum of both candidates’ positive proportions. This predicts Steve Hilton with 59.1% and Xavier Becerra with 40.9%. I ran 5,000 bootstrap resamples to estimate sampling variability, with the intervals shown in the table. These are estimates based on Bluesky posts, not measured voter preferences.Hilton’s 95% bootstrap interval was 45.5%–73.7%, which includes 50%, so resampling uncertainty allows either candidate to lead under this rule. Hilton exceeded 50% in 90.4% of resamples, but this is not a 90.4% probability of winning the election. The bootstrap treats posts as independent and does not account for repeated authors, population bias, or sentiment errors.
# Positive-post proportions for each candidate.
positive_flags <- split(
posts_scored$sentiment_label == "positive",
posts_scored$candidate
)
positive_rates <- vapply(positive_flags, mean, numeric(1))
stopifnot(sum(positive_rates) > 0)
# Normalize the proportions to sum to 100%.
estimated_shares <- positive_rates / sum(positive_rates)
# Resample posts within each candidate 5,000 times.
set.seed(456)
bootstrap_runs <- 5000
bootstrap_shares <- replicate(bootstrap_runs, {
sampled_rates <- vapply(
positive_flags,
function(x) mean(sample(x, size = length(x), replace = TRUE)),
numeric(1)
)
if (sum(sampled_rates) == 0) {
rep(NA_real_, length(sampled_rates))
} else {
sampled_rates / sum(sampled_rates)
}
})
rownames(bootstrap_shares) <- names(positive_rates)
# Identify usable resamples.
usable <- colSums(is.na(bootstrap_shares)) == 0
bootstrap_shares <- bootstrap_shares[, usable, drop = FALSE]
# Percentile bootstrap intervals.
intervals <- t(apply(
bootstrap_shares,
1,
quantile,
probs = c(0.025, 0.975)
))
prediction_results <- data.frame(
candidate = names(estimated_shares),
positive_post_percent = 100 * positive_rates,
estimated_vote_percent = 100 * estimated_shares,
lower_95_percent = 100 * intervals[, 1],
upper_95_percent = 100 * intervals[, 2],
row.names = NULL
)
knitr::kable(prediction_results, digits = 1)
| candidate | positive_post_percent | estimated_vote_percent | lower_95_percent | upper_95_percent |
|---|---|---|---|---|
| Becerra | 23.6 | 40.9 | 26.3 | 54.5 |
| Hilton | 34.2 | 59.1 | 45.5 | 73.7 |
bootstrap_summary <- data.frame(
candidate = rownames(bootstrap_shares),
percent_resamples_above_50 = 100 * rowMeans(
bootstrap_shares > 0.5
),
usable_resamples = ncol(bootstrap_shares)
)
knitr::kable(bootstrap_summary, digits = 1)
| candidate | percent_resamples_above_50 | usable_resamples | |
|---|---|---|---|
| Becerra | Becerra | 9.6 | 5000 |
| Hilton | Hilton | 90.4 | 5000 |
predicted_winner <- if (
abs(diff(estimated_shares)) < 1e-12
) {
"Tie"
} else {
names(which.max(estimated_shares))
}
cat("Predicted winner under the positive-share rule:",
predicted_winner)
## Predicted winner under the positive-share rule: Hilton
Bluesky users may not represent California voters, and I cannot confirm whether the authors live or vote in California. The Bing lexicon measures word tone and can miss sarcasm or mistake criticism of someone else for criticism of the candidate. The code agreed with my stance labels on only 3 of 10 posts, or 30%, which limits confidence in the forecast. More posts do not necessarily mean more support because they can include criticism, news, or repeated messages. The bootstrap measures sampling variation but cannot correct these biases or sentiment errors.