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
Required filter: remove posts about the Hilton hotel brand or Paris Hilton.
Additional issues identified with AI assistance:
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
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.
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")
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)")
Three prediction rules can be applied, each giving Becerra’s share of the two-candidate vote:
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")
Write this in your own words (roughly 200–300 words). Address each point: