packages <- c("atrrr", "dplyr", "tidyr", "stringr", "tidytext",
              "ggplot2", "knitr", "rmarkdown")
missing <- packages[!vapply(packages, requireNamespace, logical(1),
                           quietly = TRUE)]
if (length(missing)) {
  install.packages(missing, repos = "https://cloud.r-project.org")
}
library(atrrr)
library(dplyr)

# Authentication happens interactively, never inside the rendered report.
# Use a Bluesky App Password: https://bsky.app/settings/app-passwords
auth("quynhunguyen.bsky.social")

# Replace dates if the instructor specifies another collection window.
# 'since' is inclusive; 'until' is exclusive. Both candidates use the same window.
since_date <- "2026-10-01"
until_date <- "2026-11-04"
limit_per_candidate <- 1000L

post_Becerra <- search_post(
  q = '"Xavier Becerra"', limit = limit_per_candidate,
  sort = "latest", since = since_date, until = until_date, lang = "en"
)
post_Hilton <- search_post(
  q = '"Steve Hilton"', limit = limit_per_candidate,
  sort = "latest", since = since_date, until = until_date, lang = "en"
)
post <- bind_rows(Becerra = post_Becerra, Hilton = post_Hilton,
                  .id = "search_candidate")

# atrrr normally returns text. Accept the older variable name if present.
if (!"text" %in% names(post) && "post_text" %in% names(post)) {
  post <- rename(post, text = post_text)
}
if (!nrow(post)) stop("No posts returned. Try a wider date range.")
if (!"text" %in% names(post)) {
  stop("Missing text column. Inspect names(post) before continuing.")
}

# Save the collection, including provenance, for reproducible knitting.
collection <- list(
  posts = post,
  metadata = list(
    since = since_date, until = until_date,
    collected_at = format(Sys.time(), tz = "UTC", usetz = TRUE),
    limit = limit_per_candidate,
    atrrr_version = as.character(packageVersion("atrrr")),
    query_Becerra = '"Xavier Becerra"', query_Hilton = '"Steve Hilton"',
    language = "en", sort = "latest"
  )
)
saveRDS(collection, "California_Election_Collection.rds")

# Flatten list columns for CSV only; retain structured RDS for analysis.
post_flat <- post %>% mutate(across(where(is.list), ~ vapply(
  .x, function(x) paste(x, collapse = "; "), character(1)
)))
write.csv(post_flat, "California_Election_Posts.csv", row.names = FALSE,
          fileEncoding = "UTF-8")
print(count(post, search_candidate))
print(head(select(post, search_candidate, text), 6))
cat("Files saved in:", getwd(), "\n")

1. Research question

Can the tone of English-language Bluesky posts mentioning Xavier Becerra and Steve Hilton produce an experimental two-candidate election forecast? I use Bing word sentiment to classify posts, select net sentiment as the prediction rule, and bootstrap the resulting share estimates. These percentages are sentiment-based proxies, not estimates calibrated to a representative voter poll.

2. Collecting and inspecting the conversation

data_path <- "California_Election_Collection.rds"
if (!file.exists(data_path)) {
  stop("Run install-packages and collect-posts manually first. Keep the RDS and Rmd together.")
}
collection <- readRDS(data_path)
post <- collection$posts
meta <- collection$metadata
knitr::kable(tibble(
  setting = c("Start (inclusive)", "End (exclusive)", "Collected at",
              "Maximum requested per query", "Language", "Search ranking"),
  value = as.character(c(meta$since, meta$until, meta$collected_at,
                          meta$limit, meta$language, meta$sort))
), caption = "Collection settings")
Collection settings
setting value
Start (inclusive) 2026-10-01
End (exclusive) 2026-11-04
Collected at 2026-11-03 23:31:01 UTC
Maximum requested per query 1000
Language en
Search ranking latest
knitr::kable(count(post, search_candidate, name = "posts_returned"))
search_candidate posts_returned
Becerra 879
Hilton 1053
knitr::kable(head(post %>% transmute(search_candidate,
                                   text = str_trunc(text, 160)), 6),
             caption = "Raw text preview")
Raw text preview
search_candidate text
Becerra Curious about where Becerra and Hilton overlap and differ on California’s most precious resource? Here’s what the two candidates have said about #CAwater cal…
Becerra And XB has had a good working relationship with current LAPD Chief Jim McDonnell going back to when he was AG.

“Attorney General Becerra and LA County Sher… | |Becerra |“(Perhaps a distinction without a difference to local TV viewers seeing them side by side).” @melmason.bsky.social

“Imagine being so desperate and clueless… | |Becerra |Xavier Becerra said while he’s hoping to make history as the first Latino to be elected governor of California, the reason he’s running is to make a difference. | |Becerra |California gubernatorial candidate Xavier Becerra and his wife Dr. Carolina Reyes cast their ballots at the Belle Cooledge Community Center in Sacramento on … | |Becerra |Vote 🗳️

#MidTerms2026 #California #VoteBlue2026 #SanDiego #SanFrancisco #OrangeCounty #LongBeach #SanGabrielValley #SanFernandoValley #VenturaCounty #Sou… |

Search results are capped and ranked by recency. Equal request limits do not guarantee equal coverage of each candidate’s conversation. Exact-name queries also miss posts using only surnames, nicknames, or images. API language labels are used to select English because Bing is an English lexicon.

3. Removing noise and documenting exclusions

I remove empty text, repeated text, and posts mentioning Paris Hilton or the Hilton hotel brand. As requested, even a post also mentioning a candidate is excluded if it matches this noise rule. This conservative filter can remove some relevant posts, so exclusions are recorded for inspection. Posts mentioning both candidates are excluded because overall tone cannot be assigned to just one.

nonempty <- post %>%
  mutate(text = str_squish(as.character(text))) %>%
  filter(!is.na(text), text != "")
unique_posts <- nonempty %>% distinct(text, .keep_all = TRUE)

noise_pattern <- regex(
  "\\bparis\\s+hilton\\b|\\bhilton\\s+hotels?\\b|\\bhotels?\\s+hilton\\b|\\bhilton\\s+(worldwide|honors|honours|resorts?)\\b",
  ignore_case = TRUE
)

classified <- unique_posts %>%
  mutate(
    hotel_or_paris = str_detect(text, noise_pattern),
    becerra = str_detect(text, regex("\\bbecerra\\b", ignore_case = TRUE)),
    hilton = str_detect(text, regex("\\bsteve\\s+hilton\\b", ignore_case = TRUE)),
    disposition = case_when(
      hotel_or_paris ~ "Excluded: Paris Hilton / Hilton hotel brand",
      becerra & hilton ~ "Excluded: both candidates",
      becerra ~ "Becerra",
      hilton ~ "Hilton",
      TRUE ~ "Excluded: neither candidate in text"
    )
  )

audit <- bind_rows(
  tibble(stage = c("Raw search results", "After removing empty text",
                   "After removing duplicate text"),
         posts = c(nrow(post), nrow(nonempty), nrow(unique_posts))),
  classified %>% count(disposition, name = "posts") %>%
    rename(stage = disposition)
)
knitr::kable(audit, caption = "Cleaning audit (stage totals and final categories)")
Cleaning audit (stage totals and final categories)
stage posts
Raw search results 1932
After removing empty text 1919
After removing duplicate text 1616
Becerra 452
Excluded: both candidates 167
Excluded: neither candidate in text 401
Hilton 596
excluded <- classified %>% filter(!disposition %in% candidate_names) %>%
  select(search_candidate, text, disposition)
write.csv(excluded, "California_Election_Excluded.csv", row.names = FALSE)
knitr::kable(head(excluded %>% transmute(
  reason = disposition, text = str_trunc(text, 160)
), 6), caption = "Examples of excluded posts")
Examples of excluded posts
reason text
Excluded: neither candidate in text “(Perhaps a distinction without a difference to local TV viewers seeing them side by side).” @melmason.bsky.social “Imagine being so desperate and clueless y…
Excluded: both candidates But about 3-4 million people are set to vote for Steve Hilton to be Governor of California (and 6-7 million for his opponent Xavier Becerra) if the polls are…
Excluded: neither candidate in text Perhaps he just forgot to endorse her? nypost.com/2026/11/03/u…
Excluded: neither candidate in text calmatters.org/politics/202…
Excluded: neither candidate in text California voters, here is an impartial guide and helper tool: https://calmatters.org/california-voter-guide-2026 Please vote.
Excluded: neither candidate in text Unsure about some of the races? Check out our full ballot guide here or reply with your questions and we’ll answer them!
clean_data <- classified %>%
  filter(disposition %in% candidate_names) %>%
  transmute(post_id = row_number(), candidate = disposition, text)
if (!all(candidate_names %in% clean_data$candidate)) {
  stop("Both candidates need usable posts. Inspect exclusions or widen the search dates.")
}
knitr::kable(count(clean_data, candidate, name = "usable_posts"))
candidate usable_posts
Becerra 452
Hilton 596
knitr::kable(head(clean_data %>% mutate(text = str_trunc(text, 160)), 6))
post_id candidate text
1 Becerra Curious about where Becerra and Hilton overlap and differ on California’s most precious resource? Here’s what the two candidates have said about #CAwater cal…
2 Becerra And XB has had a good working relationship with current LAPD Chief Jim McDonnell going back to when he was AG. “Attorney General Becerra and LA County Sherif…
3 Becerra Xavier Becerra said while he’s hoping to make history as the first Latino to be elected governor of California, the reason he’s running is to make a difference.
4 Becerra California gubernatorial candidate Xavier Becerra and his wife Dr. Carolina Reyes cast their ballots at the Belle Cooledge Community Center in Sacramento on …
5 Becerra Vote 🗳️ #MidTerms2026 #California #VoteBlue2026 #SanDiego #SanFrancisco #OrangeCounty #LongBeach #SanGabrielValley #SanFernandoValley #VenturaCounty #SouthBa…
6 Becerra Xavier Becerra fought Trump in court as California’s Attorney General. As HHS Secretary, he defended health care for millions of Americans. Now, he’s running…
write.csv(clean_data, "California_Election_Clean.csv", row.names = FALSE,
          fileEncoding = "UTF-8")

4. Measuring tone at the post level

Each Bing positive word receives +1 and each negative word receives -1. A post is positive if its word-score total exceeds zero, negative if below zero, and zero-score otherwise. Zero-score posts include balanced matches and posts with no dictionary matches; these do not establish a neutral political stance. Post IDs are assigned before tokenization, and unmatched posts remain in the data.

tokens <- clean_data %>%
  mutate(text = str_remove_all(text, regex("https?://\\S+|www\\.\\S+",
                                           ignore_case = TRUE))) %>%
  unnest_tokens(word, text)
matches <- tokens %>% inner_join(get_sentiments("bing"), by = "word")
word_counts <- matches %>% group_by(post_id) %>% summarise(
  positive_words = sum(sentiment == "positive"),
  negative_words = sum(sentiment == "negative"), .groups = "drop"
)
scores <- clean_data %>% left_join(word_counts, by = "post_id") %>%
  mutate(
    positive_words = coalesce(positive_words, 0L),
    negative_words = coalesce(negative_words, 0L),
    score = positive_words - negative_words,
    matched_words = positive_words + negative_words,
    post_tone = case_when(score > 0 ~ "Positive", score < 0 ~ "Negative",
                          TRUE ~ "Zero-score"),
    tone_value = sign(score)
  )
summary_sentiment <- scores %>% group_by(candidate) %>% summarise(
  total_posts = n(),
  positive_posts = sum(score > 0), negative_posts = sum(score < 0),
  zero_score_posts = sum(score == 0),
  posts_without_matches = sum(matched_words == 0),
  positive_words = sum(positive_words), negative_words = sum(negative_words),
  net_word_score = sum(score),
  net = mean(tone_value), .groups = "drop"
)
knitr::kable(summary_sentiment, digits = 3,
             caption = "Word counts, post classifications, and bounded net sentiment")
Word counts, post classifications, and bounded net sentiment
candidate total_posts positive_posts negative_posts zero_score_posts posts_without_matches positive_words negative_words net_word_score net
Becerra 452 153 77 222 170 387 248 139 0.168
Hilton 596 166 206 224 147 468 593 -125 -0.067
if (!nrow(matches)) {
  warning("No Bing matches. The report has no sentiment evidence for a winner.")
}
write.csv(scores, "California_Election_Post_Scores.csv", row.names = FALSE,
          fileEncoding = "UTF-8")
write.csv(summary_sentiment, "California_Election_Sentiment.csv", row.names = FALSE)
tone_counts <- scores %>% count(candidate, post_tone) %>%
  complete(candidate = candidate_names,
           post_tone = c("Positive", "Negative", "Zero-score"), fill = list(n = 0))
p_tone <- ggplot(tone_counts, aes(candidate, n, fill = post_tone)) +
  geom_col(position = "dodge") +
  scale_fill_manual(values = c(Positive = "#298C83", Negative = "#CD647E",
                                `Zero-score` = "#A6ADB4")) +
  labs(title = "Tone of Posts Mentioning Each Candidate", x = NULL,
       y = "Number of posts", fill = "Bing post tone") +
  theme_minimal(base_size = 12)
print(p_tone)

ggsave("Bluesky_Post_Tone.png", p_tone, width = 8, height = 5, dpi = 300)

5. Prediction rule: net sentiment

I select net sentiment, rather than volume share, to incorporate both positive and negative post classifications. This is a rule for an exploratory assignment, not evidence that tone reliably measures candidate support.

For each candidate, I calculate:

\[ N_c = \frac{\text{positive posts}_c - \text{negative posts}_c} {\text{all usable posts}_c}. \]

This bounded score ranges from -1 to +1. To obtain an experimental two-candidate vote-share proxy that sums to 100%, I apply the same shift-and-normalize approach illustrated in the reference example:

\[ \widehat V_c = 100\frac{1+N_c}{(1+N_B)+(1+N_H)}. \]

The addition of 1 is an arbitrary modeling choice, not a calibrated relationship between sentiment and votes. If both net scores equal -1, the denominator is zero; the code returns a 50-50 tie by convention. Equal scores also produce a tie.

share_rule <- function(b_net, h_net) {
  weights <- c(Becerra = 1 + b_net, Hilton = 1 + h_net)
  if (any(!is.finite(weights)) || any(weights < -1e-12)) {
    stop("Net sentiment must be finite and between -1 and 1.")
  }
  if (any(c(b_net, h_net) > 1 + 1e-12)) stop("Net sentiment exceeds 1.")
  weights <- pmax(weights, 0)
  if (sum(weights) <= .Machine$double.eps) {
    return(c(Becerra = 50, Hilton = 50))
  }
  100 * weights / sum(weights)
}

b_net <- summary_sentiment$net[summary_sentiment$candidate == "Becerra"]
h_net <- summary_sentiment$net[summary_sentiment$candidate == "Hilton"]
predicted_shares <- share_rule(b_net, h_net)
is_tie <- abs(predicted_shares[1] - predicted_shares[2]) < 1e-10
predicted_winner <- if (is_tie) "Tie (no unique winner)" else
  names(which.max(predicted_shares))
prediction <- tibble(candidate = names(predicted_shares),
                      predicted_vote_share = as.numeric(predicted_shares))
knitr::kable(prediction, digits = 2,
             caption = "Experimental predicted two-candidate vote share (%)")
Experimental predicted two-candidate vote share (%)
candidate predicted_vote_share
Becerra 55.6
Hilton 44.4

Prediction from the selected rule: Becerra. The experimental predicted vote shares are Becerra: 55.60% and Hilton: 44.40%. These are two-candidate sentiment proxies; other candidates and turnout are not modeled.

6. Bootstrap: how stable is the predicted share?

I resample entire posts with replacement within each candidate, preserving each candidate’s sample size. For each of 2,000 replications, I recompute the net scores and apply the exact same share rule. The 2.5th and 97.5th percentiles form a 95% bootstrap percentile interval. This measures resampling variation conditional on the collected data, rather than the full uncertainty of an election forecast.

b_values <- scores$tone_value[scores$candidate == "Becerra"]
h_values <- scores$tone_value[scores$candidate == "Hilton"]
if (length(b_values) < 2 || length(h_values) < 2) {
  stop("At least two usable posts per candidate are needed for this bootstrap.")
}
set.seed(123)
R <- 2000L
boot_matrix <- replicate(R, {
  b <- b_values[sample.int(length(b_values), length(b_values), replace = TRUE)]
  h <- h_values[sample.int(length(h_values), length(h_values), replace = TRUE)]
  share_rule(mean(b), mean(h))
})
boot_shares <- tibble(Becerra = as.numeric(boot_matrix["Becerra", ]),
                      Hilton = as.numeric(boot_matrix["Hilton", ]))
bootstrap_results <- prediction %>% mutate(
  bootstrap_se_pp = c(sd(boot_shares$Becerra), sd(boot_shares$Hilton)),
  lower_95 = c(quantile(boot_shares$Becerra, .025, names = FALSE),
                quantile(boot_shares$Hilton, .025, names = FALSE)),
  upper_95 = c(quantile(boot_shares$Becerra, .975, names = FALSE),
                quantile(boot_shares$Hilton, .975, names = FALSE)),
  bootstrap_lead_percent = c(mean(boot_shares$Becerra > 50),
                             mean(boot_shares$Hilton > 50)) * 100
)
knitr::kable(bootstrap_results, digits = 2,
             caption = "Bootstrap share intervals; SE measured in percentage points")
Bootstrap share intervals; SE measured in percentage points
candidate predicted_vote_share bootstrap_se_pp lower_95 upper_95 bootstrap_lead_percent
Becerra 55.6 1.09 53.53 57.74 100
Hilton 44.4 1.09 42.26 46.47 0
cat(sprintf("Tied bootstrap samples: %.2f%%\n",
            100 * mean(abs(boot_shares$Becerra - 50) < 1e-10)))
## Tied bootstrap samples: 0.00%
write.csv(bootstrap_results, "California_Election_Forecast.csv", row.names = FALSE)
write.csv(boot_shares, "California_Election_Bootstrap.csv", row.names = FALSE)

b_low <- bootstrap_results$lower_95[bootstrap_results$candidate == "Becerra"]
b_high <- bootstrap_results$upper_95[bootstrap_results$candidate == "Becerra"]
noise_statement <- if (b_low <= 50 && b_high >= 50) {
  "The interval includes 50%, so the selected rule does not show a clear lead after accounting for post-resampling variation."
} else {
  "The interval excludes 50%, so the rule's lead is stable under this post-resampling procedure. This does not establish an electoral lead."
}

Becerra’s experimental share has a 95% bootstrap interval of 53.53% to 57.74%. The interval excludes 50%, so the rule’s lead is stable under this post-resampling procedure. This does not establish an electoral lead. The bootstrap lead percentages describe the fraction of resampled datasets in which a candidate leads; they are not probabilities of winning the election. With very few posts or identical tone values, intervals can be misleadingly narrow.

p_forecast <- ggplot(bootstrap_results,
                     aes(candidate, predicted_vote_share, fill = candidate)) +
  geom_col(width = .6) +
  geom_errorbar(aes(ymin = lower_95, ymax = upper_95), width = .15) +
  geom_hline(yintercept = 50, linetype = "dashed", color = "grey40") +
  scale_fill_manual(values = colors) +
  scale_y_continuous(limits = c(0, 100)) +
  labs(title = "Experimental Vote-Share Proxy with Bootstrap Intervals",
       subtitle = "Net-sentiment rule; 95% post-bootstrap percentile intervals",
       x = NULL, y = "Predicted share (%)") +
  theme_minimal(base_size = 12) + theme(legend.position = "none")
print(p_forecast)

ggsave("Bluesky_Forecast_Intervals.png", p_forecast, width = 8, height = 5, dpi = 300)

p_boot <- ggplot(boot_shares, aes(Becerra)) +
  geom_histogram(bins = 35, fill = colors["Becerra"], color = "white") +
  geom_vline(xintercept = 50, linetype = "dashed") +
  geom_vline(xintercept = predicted_shares["Becerra"], color = "#BE4765") +
  labs(title = "Bootstrap Distribution of Becerra's Share",
       subtitle = "Dashed line: 50%; colored line: original estimate",
       x = "Experimental share (%)", y = "Bootstrap samples") +
  theme_minimal(base_size = 12)
print(p_boot)

7. Reflection: what this forecast cannot tell us

This analysis gives a reproducible way to turn online conversation into an experimental forecast, but the sample is shaped by who uses Bluesky and who chooses to post about politics. Users may include journalists, activists, campaign accounts, and people outside California or ineligible to vote. People who are absent from the platform or remain silent are not represented. Therefore, the data should not be treated as a random sample of California voters.

Bing measures word tone rather than candidate stance. Sarcasm can use positive words to criticize a candidate, while a supportive post can contain negative words about an opponent or a problem the candidate hopes to solve. Negation, quotations, and political context can also change meaning. Reading a sample of posts manually would help evaluate these classification errors; that review has not been performed by this script.

More posts do not necessarily mean more support. A candidate may receive attention because of controversy, criticism, news coverage, or repeated posting by a few accounts. I therefore selected net sentiment instead of volume share, although net sentiment still cannot resolve the difference between attention, tone, and actual voting intention. Duplicate-text removal does not make the remaining posts independent or ensure one post per person.

The bootstrap shows how the chosen rule varies when the observed posts are resampled. It does not remove platform bias, correct sentiment errors, or validate the arbitrary conversion from net sentiment to vote share. Repeated authors and related news events can make the post-level independence assumption unrealistic and the intervals too narrow. An author-cluster bootstrap would be a useful extension. The requested search limits and date window also affect which posts enter the sample. I interpret the predicted winner as the output of this classroom rule and the reported share as an illustrative two-candidate proxy, rather than a reliable election prediction.

8. Reproducibility and data handling

The saved RDS preserves the collection used in this report, so knitting does not silently retrieve a different sample. CSV exports include raw/cleaned text and should be kept private for coursework; avoid publishing handles or identifiable examples unnecessarily. Public availability does not imply permission to republish everything.

Method references: atrrr search_post documentation, atrrr authentication, and tidytext sentiment lexicons.

sessionInfo()
## R version 4.5.3 (2026-03-11 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 11 x64 (build 22631)
## 
## Matrix products: default
##   LAPACK version 3.12.1
## 
## locale:
## [1] LC_COLLATE=English_United States.utf8 
## [2] LC_CTYPE=English_United States.utf8   
## [3] LC_MONETARY=English_United States.utf8
## [4] LC_NUMERIC=C                          
## [5] LC_TIME=English_United States.utf8    
## 
## time zone: America/Los_Angeles
## tzcode source: internal
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
## [1] ggplot2_4.0.3  tidytext_0.4.3 stringr_1.6.0  tidyr_1.3.2    dplyr_1.2.1   
## 
## loaded via a namespace (and not attached):
##  [1] Matrix_1.7-4       gtable_0.3.6       jsonlite_2.0.0     janeaustenr_1.0.0 
##  [5] compiler_4.5.3     tidyselect_1.2.1   Rcpp_1.1.2         jquerylib_0.1.4   
##  [9] textshaping_1.0.5  systemfonts_1.3.2  scales_1.4.0       yaml_2.3.12       
## [13] fastmap_1.2.0      lattice_0.22-9     R6_2.6.1           labeling_0.4.3    
## [17] SnowballC_0.7.1    generics_0.1.4     knitr_1.52         tibble_3.3.1      
## [21] RColorBrewer_1.1-3 bslib_0.12.0       pillar_1.11.1      tokenizers_0.3.0  
## [25] rlang_1.3.0        cachem_1.1.0       stringi_1.8.9      xfun_0.62         
## [29] S7_0.2.2           sass_0.4.10        otel_0.2.0         cli_3.6.6         
## [33] withr_3.0.3        magrittr_2.0.5     digest_0.6.39      grid_4.5.3        
## [37] rstudioapi_0.19.0  lifecycle_1.0.5    vctrs_0.7.3        evaluate_1.0.5    
## [41] glue_1.8.1         farver_2.1.2       ragg_1.5.2         rmarkdown_2.32    
## [45] purrr_1.2.2        tools_4.5.3        pkgconfig_2.0.3    htmltools_0.5.9