1. Data collection

Posts were scraped from Bluesky with atrrr::search_post() for each general-election candidate: Xavier Becerra (D) and Steve Hilton (R). The scrape runs only once, when no saved data exists, so every later knit uses the same dataset and the results stay reproducible.

becerra <- search_post("Becerra", limit = 1000) %>% mutate(query = "Becerra")
hilton  <- search_post("Steve Hilton", limit = 1000) %>% mutate(query = "Hilton")
saveRDS(bind_rows(becerra, hilton), "raw_posts.rds")   # local only, not published
raw <- readRDS("raw_posts.rds") %>%
  # Privacy: drop every author/account field immediately
  select(-matches("author"))

nrow(raw)
## [1] 2108

2. Data cleaning

Required filter: remove posts about the Hilton hotel brand or Paris Hilton.

Additional issues identified with AI assistance:

  1. Duplicates: the same post can be returned by both searches or by repeated pulls.
  2. Posts naming both candidates: these can’t be assigned to one candidate without reading them, so they are dropped.
  3. Same-name ambiguity: “Becerra” is a common surname (other people, athletes), and “Hilton” appears in places such as Hilton Head. Posts must contain election context.
  4. Lexicon bias: in the Bing lexicon, trump is coded positive. Since President Trump endorsed Hilton, every Trump mention would inflate positivity, so it is removed from the lexicon.
  5. Non-English posts: Bing is English-only, so Spanish-language posts score neutral. This is a known limitation, especially for Becerra.
  6. URLs, @mentions, and hashtags: these are stripped before tokenizing.
  7. Time window: only posts after the June 2, 2026 primary are kept.
noise   <- "paris hilton|hilton hotel|hilton honors|hilton head|hilton garden|doubletree|hampton inn|perez hilton|conrad hilton"
context <- "xavier|steve|governor|\\bgov\\b|california|\\bca\\b|calif|newsom|election|vote|ballot|campaign|debate|sacramento|republican|democrat|gop"

posts <- raw %>%
  distinct(uri, .keep_all = TRUE) %>%
  mutate(text_l = str_to_lower(text),
         date   = as.Date(indexed_at),
         has_b  = str_detect(text_l, "becerra"),
         has_h  = str_detect(text_l, "hilton")) %>%
  filter(!str_detect(text_l, noise),
         str_detect(text_l, context),
         xor(has_b, has_h),                       # exactly one candidate
         date >= as.Date("2026-06-02")) %>%
  mutate(candidate = if_else(has_b, "Becerra", "Hilton"),
         post_id   = row_number(),
         clean_txt = text_l %>%
           str_remove_all("https?://\\S+") %>%
           str_remove_all("@\\S+") %>%
           str_remove_all("#")) %>%
  select(post_id, candidate, date, clean_txt, text)

posts %>% count(candidate, name = "posts_after_cleaning")
## # A tibble: 2 × 2
##   candidate posts_after_cleaning
##   <chr>                    <int>
## 1 Becerra                    486
## 2 Hilton                     606

3. AI prompt (exact text used)

Replace this block with the exact prompt you sent, word for word.

I have an R tibble called `posts` with one row per Bluesky post and these columns:
post_id (integer), candidate ("Becerra" or "Hilton"), date (Date),
clean_txt (lowercase text with URLs, @mentions, and # removed), text (original).

Write R code using tidytext and the Bing lexicon (get_sentiments("bing")) that:
1. Tokenizes clean_txt into words and removes stop words.
2. Removes "trump", "trumps", and "trumped" from the lexicon (it is coded positive
   but is a proper noun in this context).
3. Computes, per post, positive words, negative words, and net = positive - negative,
   keeping posts with zero lexicon matches (net = 0).
4. Labels each post positive (net > 0), negative (net < 0), or neutral.
5. Summarizes by candidate: number of posts, volume share, share of posts that are
   positive and negative, and mean net sentiment.
Constraints: use dplyr/tidytext only, no usernames in any output, and comment each step.

4. Sentiment scoring (tidytext + Bing)

bing <- get_sentiments("bing") %>%
  filter(!word %in% c("trump", "trumps", "trumped"))

word_scores <- posts %>%
  unnest_tokens(word, clean_txt) %>%
  anti_join(stop_words, by = "word") %>%
  inner_join(bing, by = "word") %>%
  count(post_id, sentiment) %>%
  pivot_wider(names_from = sentiment, values_from = n, values_fill = 0)

scored <- posts %>%
  left_join(word_scores, by = "post_id") %>%
  mutate(across(c(positive, negative), ~ coalesce(., 0L)),
         net   = positive - negative,
         label = case_when(net > 0 ~ "positive", net < 0 ~ "negative", TRUE ~ "neutral"))

summary_tbl <- scored %>%
  group_by(candidate) %>%
  summarise(posts        = n(),
            pct_positive = mean(label == "positive"),
            pct_negative = mean(label == "negative"),
            mean_net     = mean(net)) %>%
  mutate(volume_share = posts / sum(posts))

knitr::kable(summary_tbl, digits = 3)
candidate posts pct_positive pct_negative mean_net volume_share
Becerra 486 0.222 0.358 -0.267 0.445
Hilton 606 0.163 0.401 -0.497 0.555
scored %>%
  count(candidate, label) %>%
  group_by(candidate) %>% mutate(share = n / sum(n)) %>%
  ggplot(aes(candidate, share, fill = label)) +
  geom_col() + coord_flip() +
  labs(title = "Sentiment distribution by candidate", x = NULL, y = "Share of posts")

5. Validation: hand-labeled audit

Twenty random posts were labeled by hand for stance toward the candidate (support / oppose / neutral). Post text is labeled offline and not printed here, to protect privacy.

set.seed(2026)
audit <- scored %>% slice_sample(n = 20) %>%
  select(post_id, candidate, text, code_label = label) %>%
  mutate(my_stance = "")          # fill in: support / oppose / neutral

if (!file.exists("audit_labeled.csv")) write_csv(audit, "audit_to_label.csv")
labeled <- read_csv("audit_labeled.csv") %>%
  mutate(code_stance = recode(code_label,
                              positive = "support", negative = "oppose", neutral = "neutral"),
         match = code_stance == my_stance)

accuracy <- mean(labeled$match)
cat("Validation accuracy:", scales::percent(accuracy), "on", nrow(labeled), "posts\n")

knitr::kable(table(Hand = labeled$my_stance, Code = labeled$code_stance),
             caption = "Confusion matrix (hand stance vs. code sentiment)")

6. Prediction and bootstrap

Three prediction rules can be applied, each giving Becerra’s share of the two-candidate vote:

  • Volume share: Becerra posts ÷ all posts
  • Positive share: Becerra positive posts ÷ all positive posts
  • Net sentiment: (Becerra positive + Hilton negative) ÷ all non-neutral posts
rule <- "positive"   # <- choose: "volume", "positive", or "net"

becerra_share <- function(d, rule) {
  s <- d %>% group_by(candidate) %>%
    summarise(n = n(), pos = sum(label == "positive"), neg = sum(label == "negative"))
  B <- s[s$candidate == "Becerra", ]; H <- s[s$candidate == "Hilton", ]
  switch(rule,
         volume   = B$n / (B$n + H$n),
         positive = B$pos / (B$pos + H$pos),
         net      = (B$pos + H$neg) / (B$pos + B$neg + H$pos + H$neg))
}

point <- becerra_share(scored, rule)
winner <- if_else(point > 0.5, "Becerra", "Hilton")
cat("Rule:", rule, "| Predicted winner:", winner,
    "| Becerra", scales::percent(point, 0.1), "– Hilton", scales::percent(1 - point, 0.1), "\n")
## Rule: positive | Predicted winner: Becerra | Becerra 52.2% – Hilton 47.8%
set.seed(2026)
boots <- replicate(1000, {
  scored %>% group_by(candidate) %>%
    slice_sample(prop = 1, replace = TRUE) %>% ungroup() %>%
    becerra_share(rule)
})

ci <- quantile(boots, c(0.025, 0.975))
cat("95% bootstrap CI for Becerra share:", scales::percent(ci, 0.1),
    "| P(Becerra > 50%):", scales::percent(mean(boots > 0.5)), "\n")
## 95% bootstrap CI for Becerra share: 46.3% 58.3% | P(Becerra > 50%): 74%
ggplot(data.frame(share = boots), aes(share)) +
  geom_histogram(bins = 40) +
  geom_vline(xintercept = 0.5, linetype = "dashed") +
  labs(title = "Bootstrap distribution of Becerra's predicted vote share",
       x = "Becerra share", y = "Count")

7. Reflection

Write this in your own words (roughly 200–300 words). Address each point:

  • Who is on Bluesky: Is its user base representative of California voters (age, ideology, platform migration from X)? What direction does that bias push the forecast? Bluesky users are not a random sample of California voters. The platform skews younger and more politically left, partly because many users left X after its ownership change. This likely inflates positive talk about Becerra and negative talk about Hilton, so any Becerra lead in this forecast is probably overstated relative to the actual electorate. Older voters, rural voters, and Spanish-speaking voters are also underrepresented, and Spanish posts score neutral under the English-only Bing lexicon.
  • Sarcasm, tone vs. stance: What did the audit show? Give an aggregate example of where negative words expressed support, or positive words were sarcastic. The audit showed that sentiment is not the same as stance. My validation accuracy was [X%] on [N] posts. The most common error was [e.g., posts that used negative words to attack one candidate while supporting the other, which the code scored as negative]. Sarcastic posts that used positive words to mock a candidate were also misread as support. A dictionary method counts words; it cannot read intent.
  • Volume ≠ support: Do more posts mean more support, or more controversy and criticism? Not necessarily. A candidate can dominate volume because people are criticizing them, or because of a single viral controversy. In my data, [candidate] had [X%] of posts, but [describe whether their posts were mostly positive or negative]. Volume measures attention, not votes.
  • What the bootstrap does and doesn’t capture: It measures sampling noise only, not bias from who posts, lexicon errors, or non-English posts. My predicted Becerra share was [X%], with a 95% bootstrap interval of [X%–X%]. That interval reflects sampling noise only. It does not correct for who is on Bluesky, lexicon errors, or sarcasm, so the true uncertainty is larger than the interval suggests.
  • Other limits: the search API’s sample, bots, the time window, and dictionary methods versus context-aware models. atrrr::auth(“webdata.bsky.social”)