library(tidyverse)
library(tidytext)
library(atrrr)
pct <- function(x) scales::percent(x, accuracy = 0.1)
Same steps as the previous lab: log in with auth(),
search with search_post(), save with
write_csv().
The login runs once in the Console, so this chunk is skipped when
knitting (eval = FALSE).
auth(user = "dgurnoor.bsky.social",
password = rstudioapi::askForPassword("Paste your Bluesky app password"),
overwrite = TRUE)
The scrape only runs if the CSV doesn’t exist yet, so knitting reuses the same posts. Only the post ID and text are saved, so no usernames are kept.
if (!file.exists("governor_posts.csv")) {
becerra <- search_post("Becerra", limit = 1000, lang = "en")
hilton <- search_post("Steve Hilton", limit = 1000, lang = "en")
hilton2 <- search_post("Hilton governor", limit = 1000, lang = "en")
bind_rows(becerra, hilton, hilton2) %>%
select(uri, text) %>% # no usernames saved
write_csv("governor_posts.csv")
}
posts_raw <- read_csv("governor_posts.csv", show_col_types = FALSE)
Prompt we gave the AI:
I scraped Bluesky posts with the R package atrrr using the searches "Becerra",
"Steve Hilton", and "Hilton governor" for the 2026 California governor's race.
My data has two columns: uri (post ID) and text. How do I remove posts about
the Hilton hotel or Paris Hilton, and what other cleaning problems should I
fix before running Bing sentiment analysis?
Issues the AI flagged and how we handled them:
lang = "en".# Remove duplicates
step1 <- posts_raw %>%
distinct(uri, .keep_all = TRUE) %>% # same post from two searches
distinct(text, .keep_all = TRUE) # copy-paste duplicates
# Find which candidate each post names; remove Hilton hotel / Paris Hilton
step2 <- step1 %>%
mutate(lower = str_to_lower(text),
becerra = str_detect(lower, "becerra"),
steve = str_detect(lower, "steve hilton"),
noise = str_detect(lower, "paris hilton|hilton hotel|hilton honors|hilton garden|hilton head|hotel|resort"),
hilton = str_detect(lower, "hilton") & (steve | !noise)) %>%
filter(becerra | hilton)
# Drop posts naming both, label the candidate, strip links/@handles/#
posts <- step2 %>%
filter(!(becerra & hilton)) %>%
mutate(candidate = if_else(becerra, "Becerra", "Hilton"),
clean_text = str_remove_all(text, "https?://\\S+|@\\S+|#"),
post_id = row_number()) %>%
select(post_id, candidate, text, clean_text)
# Cleaning audit
tibble(step = c("Raw posts",
"After removing duplicates",
"After removing Hilton hotel / Paris Hilton / no candidate named",
"After removing posts that name both candidates"),
posts = c(nrow(posts_raw), nrow(step1), nrow(step2), nrow(posts))) %>%
knitr::kable(caption = "Cleaning audit")
| step | posts |
|---|---|
| Raw posts | 3184 |
| After removing duplicates | 2456 |
| After removing Hilton hotel / Paris Hilton / no candidate named | 1990 |
| After removing posts that name both candidates | 1598 |
count(posts, candidate)
## # A tibble: 2 × 2
## candidate n
## <chr> <int>
## 1 Becerra 654
## 2 Hilton 944
Exact prompt we gave the AI to write this code:
I'm using R with tidyverse and tidytext. I have a tibble called `posts` with
one row per Bluesky post and these columns: post_id (integer), candidate
("Becerra" or "Hilton"), text (original post), and clean_text (text with links,
@handles, and # removed).
Write R code that:
1. Splits clean_text into words with unnest_tokens() and joins them to the
Bing lexicon from get_sentiments("bing"). Remove duplicate words in Bing,
and drop the words "trump", "like", "right", and "well" because they are
names or filler here, not opinions.
2. Counts positive and negative words for each post. Posts with no Bing words
should stay in the data with 0 and 0.
3. Computes net = positive - negative and labels each post "positive"
(net > 0), "negative" (net < 0), or "neutral" (net = 0).
4. Summarizes by candidate: number of posts, volume share, % positive,
% negative, % neutral, and average net score.
5. Makes a stacked bar chart of the sentiment labels by candidate.
Constraints: only tidyverse and tidytext; add short comments for a beginner;
never print individual posts or usernames, only aggregate tables and charts.
# Bing lexicon minus duplicates and misleading words
bing <- get_sentiments("bing") %>%
distinct(word, .keep_all = TRUE) %>%
filter(!word %in% c("trump", "like", "right", "well"))
# Count positive and negative words in each post
word_scores <- posts %>%
unnest_tokens(word, clean_text) %>%
inner_join(bing, by = "word") %>%
group_by(post_id) %>%
summarise(positive = sum(sentiment == "positive"),
negative = sum(sentiment == "negative"))
# Posts with no Bing words get 0 and 0 (neutral)
scored <- posts %>%
left_join(word_scores, by = "post_id") %>%
mutate(positive = coalesce(positive, 0L),
negative = coalesce(negative, 0L),
net = positive - negative,
label = case_when(net > 0 ~ "positive",
net < 0 ~ "negative",
TRUE ~ "neutral"))
# Summary by candidate
summary_table <- scored %>%
group_by(candidate) %>%
summarise(posts = n(),
pct_positive = mean(label == "positive"),
pct_negative = mean(label == "negative"),
pct_neutral = mean(label == "neutral"),
avg_net = mean(net)) %>%
mutate(volume_share = posts / sum(posts))
knitr::kable(summary_table, digits = 2, caption = "Bing sentiment by candidate")
| candidate | posts | pct_positive | pct_negative | pct_neutral | avg_net | volume_share |
|---|---|---|---|---|---|---|
| Becerra | 654 | 0.31 | 0.29 | 0.41 | 0.01 | 0.41 |
| Hilton | 944 | 0.20 | 0.40 | 0.40 | -0.44 | 0.59 |
ggplot(scored, aes(x = candidate, fill = label)) +
geom_bar(position = "fill") +
scale_y_continuous(labels = scales::percent) +
scale_fill_manual(values = c(negative = "firebrick", neutral = "grey70",
positive = "forestgreen")) +
labs(x = NULL, y = "Share of posts", fill = "Sentiment",
title = "Bing sentiment of Bluesky posts by candidate") +
theme_minimal()
We took 20 random posts (10 per candidate) and labeled each one’s stance toward the candidate:
We read the posts in RStudio with
View(select(label_sample, candidate, text)), which hides
the code’s label while we decide and keeps the posts out of this
document. Then we compared our labels with the code’s labels (positive =
support, negative = oppose, neutral = neutral).
set.seed(1103)
label_sample <- scored %>%
group_by(candidate) %>%
slice_sample(n = 10) %>%
ungroup()
# Our labels, in the same order as the View() list: "support", "oppose", or "neutral"
my_labels <- c(
NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, # posts 1-10 (Becerra)
NA, NA, NA, NA, NA, NA, NA, NA, NA, NA # posts 11-20 (Hilton)
)
audit <- label_sample %>%
mutate(my_stance = my_labels,
code_stance = case_when(label == "positive" ~ "support",
label == "negative" ~ "oppose",
TRUE ~ "neutral"),
match = my_stance == code_stance)
accuracy <- mean(audit$match, na.rm = TRUE)
# Rows = our label, columns = code's label
table(Our_label = audit$my_stance, Code_label = audit$code_stance)
## < table of extent 0 x 3 >
Validation accuracy: NA. The code matched our stance label on 0 of 0 posts.
Our rule: net sentiment. A positive post about Becerra or a negative post about Hilton counts toward Becerra, and the reverse counts toward Hilton. Becerra’s predicted vote share is his side divided by both sides. We picked this rule because it uses both praise and criticism, while volume only measures attention.
becerra_share <- function(d) {
becerra_side <- sum(d$candidate == "Becerra" & d$label == "positive") +
sum(d$candidate == "Hilton" & d$label == "negative")
hilton_side <- sum(d$candidate == "Hilton" & d$label == "positive") +
sum(d$candidate == "Becerra" & d$label == "negative")
becerra_side / (becerra_side + hilton_side)
}
share <- becerra_share(scored)
winner <- if (share > 0.5) "Xavier Becerra" else "Steve Hilton"
# The other two rules, for comparison
volume_share <- mean(scored$candidate == "Becerra")
positive_share <- sum(scored$candidate == "Becerra" & scored$label == "positive") /
sum(scored$label == "positive")
tibble(rule = c("Volume share", "Positive share", "Net sentiment (our rule)"),
becerra = c(volume_share, positive_share, share),
hilton = 1 - becerra) %>%
knitr::kable(digits = 3, caption = "Share of the two-candidate vote under each rule")
| rule | becerra | hilton |
|---|---|---|
| Volume share | 0.409 | 0.591 |
| Positive share | 0.514 | 0.486 |
| Net sentiment (our rule) | 0.605 | 0.395 |
Predicted winner: Xavier Becerra, with 60.5% of the two-candidate vote (Becerra 60.5%, Hilton 39.5%).
We resampled the posts with replacement 2,000 times and recomputed the forecast each time to see how much of it could be sampling noise.
set.seed(2026)
boot <- replicate(2000, becerra_share(scored[sample(nrow(scored), replace = TRUE), ]))
ci <- quantile(boot, c(0.025, 0.975))
ggplot(tibble(boot), aes(x = boot)) +
geom_histogram(bins = 40, fill = "steelblue", color = "white") +
geom_vline(xintercept = 0.5, linetype = "dashed") +
scale_x_continuous(labels = scales::percent) +
labs(x = "Becerra's share (net sentiment rule)", y = "Resamples",
title = "2,000 bootstrap resamples (dashed line = 50%)") +
theme_minimal()
The 95% bootstrap interval for Becerra’s share is 57.4% to 63.6%, and Becerra is above 50% in 100.0% of resamples. If the interval stays on one side of 50%, sampling noise alone would not change the predicted winner. The bootstrap only measures that noise; it can’t fix bias from who is posting.
Who is on Bluesky. Bluesky users are not a random sample of California voters. Many joined after leaving X in 2024, and the platform leans younger and more left-of-center than the electorate. Most voters never post about politics, and we only collected English posts. For comparison, an August UC Berkeley IGS/Los Angeles Times poll of likely voters had Becerra at 55% and Hilton at 37%, about 60% of the two-candidate vote; our forecast gives Becerra 60.5%.
Sarcasm and tone vs. stance. Bing counts words; it doesn’t understand meaning. A sarcastic “oh great, another brilliant idea” is scored positive, “not good” still counts “good,” and a post defending a candidate from “ugly attacks” is scored negative. Our hand audit showed the code matched our stance labels NA of the time, and the forecast carries whatever errors it makes.
Do more posts mean more support? No. Volume share gives Becerra 40.9% of the posts, while net sentiment gives him 60.5%. People often post the most about a candidate they dislike, and news accounts and heavy posters add volume without adding voters.
Bottom line. Our model predicts Xavier Becerra, but it measures the Bluesky conversation, not the California electorate. The bootstrap shows how much sampling noise matters; the bigger errors come from who is on Bluesky and from a lexicon that misses sarcasm and stance.
Privacy: all results are reported in aggregate. No usernames from the collected posts were saved or published, and no individual posts are shown.