library(tidyverse)
library(tidytext)
library(atrrr)

pct <- function(x) scales::percent(x, accuracy = 0.1)

1. Scrape Bluesky posts

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)

2. Clean the data

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:

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

3. Sentiment with tidytext and Bing

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

4. Hand-label audit

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.

5. Prediction

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

6. Bootstrap

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.

7. Reflection

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.