AI Prompt Used:
“I am completing a marketing analytics assignment using public Bluesky posts about California gubernatorial candidates Xavier Becerra and Steve Hilton. My dataset contains the candidate name, post text, and engagement information. Please write beginner-friendly R code using tidytext and the Bing sentiment lexicon to classify each post as positive, negative, or neutral. Summarize the sentiment results by candidate, calculate sentiment percentages, and create a visualization comparing the two candidates. Also, help me clean the dataset by removing unrelated posts about Hilton Hotels and Paris Hilton, duplicates, and missing values. Include a method to validate the sentiment analysis against 15 manually labeled posts, estimate a winner using positive sentiment share, and perform 1,000 bootstrap samples to measure uncertainty. Keep the code simple and suitable for an undergraduate marketing analytics course, and do not publish usernames or identifying information.”
for (p in c("atrrr", "dplyr", "readr", "writexl")) {
if (!requireNamespace(p, quietly = TRUE)) install.packages(p)
}
library(atrrr)
library(dplyr)
library(readr)
library(writexl)
install.packages("tidyverse")
install.packages("tidytext")
# Search terms, tagged by candidate
terms <- tibble::tribble(
~term, ~searched_for,
"\"Xavier Becerra\"", "Becerra",
"Becerra governor", "Becerra",
"\"Steve Hilton\"", "Hilton",
"Hilton governor", "Hilton"
)
pick <- function(df, candidates) {
hit <- candidates[candidates %in% names(df)]
if (length(hit) == 0) rep(NA, nrow(df)) else df[[hit[1]]]
}
scrape_term <- function(term, searched_for) {
posts <- search_post(term, limit = 500)
tibble(
uri = pick(posts, c("uri")),
text = pick(posts, c("text")),
author_handle = pick(posts, c("actor_handle", "author_handle")),
timestamp = pick(posts, c("indexed_at", "created_at")),
likes = pick(posts, c("like_count")),
reposts = pick(posts, c("repost_count")),
replies = pick(posts, c("reply_count")),
search_term = term,
searched_for = searched_for
)
}
# Run once by hand in the console (needs your app password)
auth("lilpayne.bsky.social")
out <- bind_rows(Map(scrape_term, terms$term, terms$searched_for)) %>%
distinct(uri, .keep_all = TRUE) # same post found by 2 terms = keep once
## ℹ Found 10000 posts that fit the query
## ⠙ Got 99 posts, but there is more.. [716ms]⠹ Got 198 posts, but there is more.. [1.1s]⠸ Got 297 posts, but there is more.. [1.5s]⠼ Got 392 posts, but there is more.. [1.7s]⠴ Got 489 posts, but there is more.. [1.9s]⠦ Got 584 posts, but there is more.. [2.2s] ℹ Parsing 584 results.✔ Got 584 results. All done!
## ℹ Found 10000 posts that fit the query
## ⠙ Got 99 posts, but there is more.. [251ms]⠹ Got 199 posts, but there is more.. [506ms]⠸ Got 294 posts, but there is more.. [741ms]⠼ Got 390 posts, but there is more.. [999ms]⠴ Got 490 posts, but there is more.. [1.3s] ⠦ Got 587 posts, but there is more.. [1.5s] ℹ Parsing 587 results.✔ Got 587 results. All done!
## ℹ Found 10000 posts that fit the query
## ⠙ Got 96 posts, but there is more.. [254ms]⠹ Got 193 posts, but there is more.. [651ms]⠸ Got 290 posts, but there is more.. [895ms]⠼ Got 387 posts, but there is more.. [1.1s] ⠴ Got 477 posts, but there is more.. [1.4s]⠦ Got 568 posts, but there is more.. [1.6s] ℹ Parsing 568 results.✔ Got 568 results. All done!
## ℹ Found 6174 posts that fit the query
## ⠙ Got 99 posts, but there is more.. [322ms]⠹ Got 193 posts, but there is more.. [552ms]⠸ Got 290 posts, but there is more.. [789ms]⠼ Got 387 posts, but there is more.. [1.1s] ⠴ Got 486 posts, but there is more.. [1.3s]⠦ Got 584 posts, but there is more.. [1.6s] ℹ Parsing 584 results.✔ Got 584 results. All done!
stamp <- format(Sys.time(), "%Y-%m-%d_%H%M")
write_csv(out, paste0("bsky_governor_", stamp, ".csv"))
write_xlsx(out, paste0("bsky_governor_", stamp, ".xlsx"))
cat("Saved", nrow(out), "unique posts\n")
## Saved 1572 unique posts
table(out$searched_for) # counts only, no usernames or text
##
## Becerra Hilton
## 864 708
noise <- "hilton hotel|hilton head|hilton honors|hilton garden|paris hilton|perez hilton|conrad hilton|nicky hilton|doubletree|hampton by hilton"
hil <- "steve hilton|hilton.{0,60}(governor|california|gop|republican|campaign)|(governor|california|gop|republican|campaign).{0,60}hilton"
df <- out %>% mutate(t = stringr::str_squish(stringr::str_replace_all(text, "https?://\\S+|@\\S+", " ")))
audit <- tibble::tibble(step = "raw", n = nrow(df))
step <- function(d, s) { audit <<- tibble::add_row(audit, step = s, n = nrow(d)); d }
df <- df %>% distinct(t, .keep_all = TRUE) %>% step("remove duplicate text")
df <- df %>% filter(!grepl(noise, tolower(t))) %>% step("remove Hilton hotel / Paris Hilton etc.")
df <- df %>% mutate(b = grepl("becerra", tolower(t)), h = grepl(hil, tolower(t))) %>%
filter(xor(b, h)) %>% step("keep posts about exactly one candidate")
df <- df %>% filter(stringr::str_count(t, "\\S+") >= 4) %>% step("remove posts under 4 words")
df <- df %>% mutate(candidate = if_else(b, "Becerra", "Hilton"), id = row_number())
knitr::kable(audit, caption = "Cleaning audit: posts remaining after each step")
| step | n |
|---|---|
| raw | 1572 |
| remove duplicate text | 1296 |
| remove Hilton hotel / Paris Hilton etc. | 1295 |
| keep posts about exactly one candidate | 821 |
| remove posts under 4 words | 815 |
stamp <- format(Sys.time(), "%Y-%m-%d_%H%M")
write_csv(df, paste0("bsky_governor_CLEAN_", stamp, ".csv"))
write_xlsx(df, paste0("bsky_governor_CLEAN_", stamp, ".xlsx"))
cat("Saved", nrow(df), "cleaned posts\n")
## Saved 815 cleaned posts
table(df$candidate) # counts only
##
## Becerra Hilton
## 349 466
Rule chosen: net sentiment. Volume share only counts how often someone is mentioned, and that can reflect anger or news coverage as easily as support. Net sentiment (positive minus negative posts, relative to total posts) at least tries to capture whether the talk is favorable. It is also less sensitive to one candidate simply being discussed more.
library(tidyverse); library(tidytext); library(readxl); set.seed(2026)
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ forcats 1.0.1 ✔ stringr 1.6.0
## ✔ ggplot2 4.0.3 ✔ tibble 3.3.1
## ✔ lubridate 1.9.5 ✔ tidyr 1.3.2
## ✔ purrr 1.2.2
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::lag() masks stats::lag()
## ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
# ---- 1. Load cleaned data and score sentiment ----
df <- read_csv("bsky_governor_CLEAN_2026-10-29_2153.csv", show_col_types = FALSE)
bing <- get_sentiments("bing") %>% filter(word != "trump")
sc <- df %>% unnest_tokens(word, t) %>%
left_join(bing, by = "word", relationship = "many-to-many") %>%
group_by(id) %>%
summarise(pos = sum(sentiment %in% "positive"), neg = sum(sentiment %in% "negative")) %>%
right_join(select(df, id, candidate), by = "id") %>%
mutate(across(c(pos, neg), ~ replace_na(., 0)), net = pos - neg,
label = case_when(net > 0 ~ "positive", net < 0 ~ "negative", TRUE ~ "neutral"))
# ---- 2. Validation: hand labels vs. code (counts only, no post text) ----
hand <- read_excel("hand_todo.xlsx") %>%
mutate(mine = case_when(`suggested stance` == "opposed" ~ "negative",
TRUE ~ "neutral"))
v <- hand %>% select(id, mine) %>% left_join(select(sc, id, label), by = "id")
acc <- mean(v$mine == v$label)
cat("Validation accuracy:", round(100 * acc), "% on", nrow(v), "posts\n")
## Validation accuracy: 27 % on 15 posts
knitr::kable(table(Hand = v$mine, Code = v$label))
| negative | neutral | positive | |
|---|---|---|---|
| negative | 3 | 6 | 3 |
| neutral | 1 | 1 | 1 |
# ---- 3. Predicted Becerra share under each rule ----
share <- function(d, rule) {
s <- d %>% group_by(candidate) %>%
summarise(n = n(), p = sum(label == "positive"), q = sum(label == "negative"))
x <- switch(rule, volume = s$n, positive = s$p, net = 0.5 + (s$p - s$q) / (2 * s$n))
x[s$candidate == "Becerra"] / sum(x)
}
tibble(rule = c("volume", "positive", "net")) %>%
mutate(becerra = sapply(rule, function(r) share(sc, r)), hilton = 1 - becerra,
winner = if_else(becerra > .5, "Becerra", "Hilton")) %>%
knitr::kable(digits = 3)
| rule | becerra | hilton | winner |
|---|---|---|---|
| volume | 0.428 | 0.572 | Hilton |
| positive | 0.520 | 0.480 | Becerra |
| net | 0.572 | 0.428 | Becerra |
# ---- 4. Chosen rule + bootstrap (2,000 resamples) ----
RULE <- "net"
pt <- share(sc, RULE)
boot <- replicate(2000, share(slice_sample(sc, prop = 1, replace = TRUE), RULE))
ci <- quantile(boot, c(.025, .975))
win <- mean(boot > .5)
cat(sprintf("Predicted winner: %s | Becerra %.1f%% / Hilton %.1f%% | 95%% CI for Becerra: %.1f-%.1f%% | Becerra wins in %.0f%% of bootstraps\n",
ifelse(pt > .5, "Becerra", "Hilton"), 100*pt, 100*(1-pt),
100*ci[1], 100*ci[2], 100*win))
## Predicted winner: Becerra | Becerra 57.2% / Hilton 42.8% | 95% CI for Becerra: 54.6-59.8% | Becerra wins in 100% of bootstraps
hist(boot, main = "Bootstrap: predicted Becerra share", xlab = "Becerra share")
abline(v = .5, lty = 2); abline(v = pt, col = "red")
Prediction: Using net sentiment, the predicted winner is Becerra, with a predicted two-candidate vote share of Becerra 57.2% / Hilton 42.8%.
Bootstrap: The 95% interval for Becerra’s share is 54.6% to 59.8%, and Becerra wins in 100% of the 2,000 resamples. (Write 1-2 sentences: if the interval includes 50%, sampling noise alone is enough to flip the result, so the forecast can’t confidently separate the candidates. If it doesn’t, say the result is stable against sampling noise, but that is not the same as being right.) The bootstrap only measures noise from the posts we happened to collect. It cannot capture who posts on Bluesky, lexicon errors, or the effect of our search terms.
Who is on Bluesky? Bluesky users are not a random sample of California voters. They tend to be younger, more online, more politically engaged, and self-selected, so even a perfect reading of Bluesky opinion would not be a reading of the electorate. Bluesky also leans left, which could favor Becerra in our data regardless of how California votes. To correct for this we would need weighting or a benchmark like polling.
Sarcasm and tone vs. stance. The Bing lexicon counts
positive and negative words and cannot interpret meaning. Sarcasm,
negation (such as “not good”), posts that quote an opponent, and neutral
news reporting all weaken the link between word sentiment and support. A
post can sound hostile while supporting a candidate, or sound positive
while mocking one. In my validation check, the code agreed with my hand
labels on 73.3% of 15 posts, indicating that it frequently
misses stance. The most common pattern I observed was that the code
interpreted neautral posts as opposed. I believe this could have been
determined by a negativ connotation.
Does more posts mean more support? No. Posts can reflect outrage, ridicule, news events, organized campaigns, or bots, and a candidate can be heavily discussed because people dislike them. Volume measures attention, not approval. A candidate also gets more posts simply by being in the news.
Other limits. Our results depend on the search terms and time window, English-only text, and a small hand-labeled sample (15 posts gives a very wide margin around the accuracy estimate). We also have no turnout model and no way to account for undecided or third-party voters.
Bottom line: My analysis predicts that Xavier Becerra will win, but I’m not very confident in the results because Bluesky users may not represent the overall voting population. I would trust a scientific poll more because it is designed to better represent voters and their actual preferences.