1. Data Collection

I used the atrrr package in R to collect public Bluesky posts using the searches “Xavier Becerra” and “Steve Hilton,” with an English-language filter and the same October 22–28, 2026 date window. I requested up to 200 results per candidate and collected 175 Becerra records and 149 Hilton records before cleaning. Some posts appeared in both searches.

2. Data Cleaning and Audit Results

No posts matched the Paris Hilton or Hilton Hotel checks. ChatGPT also helped identify missing text, missing timestamps, repeated content, link-only posts, and ambiguous candidate references as cleaning issues. After cleaning, 55 Becerra posts and 76 Hilton posts remained.

library(dplyr)
library(stringr)
library(readr)

raw <- read_csv("governor_posts_raw.csv", show_col_types = FALSE)

checked <- raw %>%
  mutate(
    text = coalesce(text, ""),
    timestamp = as.POSIXct(created_at, tz = "UTC"),
    body = str_remove_all(
      text,
      "https?://\\S+|\\b(?:[A-Za-z0-9-]+\\.)+[A-Za-z]{2,}(?:/\\S*)?"
    ),
    mentions_becerra = str_detect(
      str_to_lower(body), "becerra|beccera|bacerra"
    ),
    mentions_hilton = str_detect(str_to_lower(body), "hilton"),
    removal_reason = case_when(
      str_trim(text) == "" ~ "Missing text",
      is.na(timestamp) ~ "Missing timestamp",
      timestamp < as.POSIXct("2026-10-22", tz = "UTC") |
        timestamp >= as.POSIXct("2026-10-29", tz = "UTC") ~
        "Outside collection window",
      str_detect(
        str_to_lower(text), "paris\\s+hilton|hilton\\s+hotels?"
      ) & !str_detect(str_to_lower(text), "steve\\s+hilton") ~
        "Unrelated Hilton result",
      !str_detect(body, "[A-Za-z]") ~ "No text beyond links",
      mentions_becerra & mentions_hilton ~ "Both candidates mentioned",
      candidate == "Becerra" & !mentions_becerra ~
        "Candidate absent from post text",
      candidate == "Hilton" & !mentions_hilton ~
        "Candidate absent from post text",
      str_detect(body, fixed("California tiene latinos listos")) ~
        "Spanish-only text reviewed manually",
      TRUE ~ "Keep"
    )
  )

eligible <- checked %>%
  filter(removal_reason == "Keep") %>%
  mutate(
    normalized_text = str_squish(str_to_lower(body)),
    duplicate_content = duplicated(
      data.frame(candidate, normalized_text)
    )
  )

clean_posts <- eligible %>%
  filter(!duplicate_content)

audit <- bind_rows(
  checked %>%
    filter(removal_reason != "Keep") %>%
    count(candidate, removal_reason, name = "posts"),
  eligible %>%
    filter(duplicate_content) %>%
    count(candidate, name = "posts") %>%
    mutate(removal_reason = "Repeated content within candidate"),
  clean_posts %>%
    count(candidate, name = "posts") %>%
    mutate(removal_reason = "Retained")
)

knitr::kable(audit)
candidate removal_reason posts
Becerra Both candidates mentioned 15
Becerra Candidate absent from post text 18
Becerra Missing timestamp 7
Becerra No text beyond links 20
Becerra Spanish-only text reviewed manually 1
Hilton Both candidates mentioned 20
Hilton Candidate absent from post text 13
Hilton Missing text 1
Hilton Missing timestamp 19
Hilton No text beyond links 18
Becerra Repeated content within candidate 59
Hilton Repeated content within candidate 2
Becerra Retained 55
Hilton Retained 76

3. Exact AI Prompt

Write R code for my California governor Bluesky analysis. My cleaned data frame is named clean_posts. It contains candidate (“Becerra” or “Hilton”), uri (post ID), text (original text), and body (text with URLs removed). There are 55 Becerra posts and 76 Hilton posts.

Use tidytext and the Bing sentiment lexicon. Remove usernames from the text before tokenizing. Count positive and negative word occurrences in each post, and calculate sentiment as positive count minus negative count. Classify scores above zero as positive, below zero as negative, and zero as neutral. Keep every post, including posts with no matching sentiment words, and separately flag those with no matches.

Summarize post counts, positive/negative/neutral counts, positive-post proportions, and average net sentiment by candidate. Display only aggregate results, without usernames, post IDs, or individual post text. Explain that word sentiment measures tone and may not reflect support for a candidate. Give me one R Markdown code chunk that uses my existing clean_posts object.

4. Sentiment Analysis

library(dplyr)
library(stringr)
library(tidytext)

# Assign an internal ID and remove usernames before tokenizing.
posts_for_scoring <- clean_posts %>%
  ungroup() %>%
  mutate(
    post_id = row_number(),
    scoring_text = str_remove_all(
      coalesce(body, ""),
      "@[A-Za-z0-9_.-]+"
    )
  )

bing <- get_sentiments("bing")

# Each occurrence of a matching word contributes one count.
word_counts <- posts_for_scoring %>%
  select(post_id, scoring_text) %>%
  unnest_tokens(word, scoring_text) %>%
  inner_join(bing, by = "word") %>%
  group_by(post_id) %>%
  summarise(
    positive_words = sum(sentiment == "positive"),
    negative_words = sum(sentiment == "negative"),
    .groups = "drop"
  )

# Left join preserves posts with no matching sentiment words.
posts_scored <- posts_for_scoring %>%
  left_join(word_counts, by = "post_id") %>%
  mutate(
    positive_words = coalesce(positive_words, 0L),
    negative_words = coalesce(negative_words, 0L),
    matched_words = positive_words + negative_words,
    no_matches = matched_words == 0,
    net_sentiment = positive_words - negative_words,
    sentiment_label = case_when(
      net_sentiment > 0 ~ "positive",
      net_sentiment < 0 ~ "negative",
      TRUE ~ "neutral"
    )
  )

# Verify that scoring retained every post.
stopifnot(nrow(posts_scored) == nrow(clean_posts))

# Display aggregate results only.
sentiment_summary <- posts_scored %>%
  group_by(candidate) %>%
  summarise(
    total_posts = n(),
    positive_posts = sum(sentiment_label == "positive"),
    negative_posts = sum(sentiment_label == "negative"),
    neutral_posts = sum(sentiment_label == "neutral"),
    no_match_posts = sum(no_matches),
    positive_proportion = mean(sentiment_label == "positive"),
    average_net_sentiment = mean(net_sentiment),
    .groups = "drop"
  )

knitr::kable(sentiment_summary, digits = 3)
candidate total_posts positive_posts negative_posts neutral_posts no_match_posts positive_proportion average_net_sentiment
Becerra 55 13 11 31 21 0.236 0.091
Hilton 76 26 16 34 22 0.342 0.118

5. Hand-Labeling and Validation Accuracy

# Recreate the same random sample: five posts per candidate.
set.seed(123)

validation <- posts_scored %>%
  group_by(candidate) %>%
  slice_sample(n = 5) %>%
  ungroup() %>%
  mutate(
    audit_id = row_number(),
    hand_stance = c(
      rep("supportive", 5),
      rep("opposed", 5)
    ),
    # Map stance labels to the code's three categories.
    hand_label = case_when(
      hand_stance == "supportive" ~ "positive",
      hand_stance == "opposed" ~ "negative",
      TRUE ~ "neutral"
    ),
    agreement = hand_label == sentiment_label
  )

# Display aggregate validation results only.
validation_summary <- bind_rows(
  validation %>%
    group_by(candidate) %>%
    summarise(
      audited_posts = n(),
      agreements = sum(agreement),
      accuracy_percent = 100 * mean(agreement),
      .groups = "drop"
    ),
  validation %>%
    summarise(
      candidate = "Overall",
      audited_posts = n(),
      agreements = sum(agreement),
      accuracy_percent = 100 * mean(agreement)
    )
)

knitr::kable(validation_summary, digits = 1)
candidate audited_posts agreements accuracy_percent
Becerra 5 1 20
Hilton 5 2 40
Overall 10 3 30
# Aggregate comparison of human stance and code sentiment.
comparison <- validation %>%
  count(hand_stance, sentiment_label, name = "posts")

knitr::kable(comparison)
hand_stance sentiment_label posts
opposed negative 2
opposed neutral 1
opposed positive 2
supportive neutral 4
supportive positive 1

6. Prediction and Bootstrap Results

I used the positive-share rule, dividing each candidate’s proportion of positive posts by the sum of both candidates’ positive proportions. This predicts Steve Hilton with 59.1% and Xavier Becerra with 40.9%. I ran 5,000 bootstrap resamples to estimate sampling variability, with the intervals shown in the table. These are estimates based on Bluesky posts, not measured voter preferences.Hilton’s 95% bootstrap interval was 45.5%–73.7%, which includes 50%, so resampling uncertainty allows either candidate to lead under this rule. Hilton exceeded 50% in 90.4% of resamples, but this is not a 90.4% probability of winning the election. The bootstrap treats posts as independent and does not account for repeated authors, population bias, or sentiment errors.

# Positive-post proportions for each candidate.
positive_flags <- split(
  posts_scored$sentiment_label == "positive",
  posts_scored$candidate
)

positive_rates <- vapply(positive_flags, mean, numeric(1))
stopifnot(sum(positive_rates) > 0)

# Normalize the proportions to sum to 100%.
estimated_shares <- positive_rates / sum(positive_rates)

# Resample posts within each candidate 5,000 times.
set.seed(456)
bootstrap_runs <- 5000

bootstrap_shares <- replicate(bootstrap_runs, {
  sampled_rates <- vapply(
    positive_flags,
    function(x) mean(sample(x, size = length(x), replace = TRUE)),
    numeric(1)
  )

  if (sum(sampled_rates) == 0) {
    rep(NA_real_, length(sampled_rates))
  } else {
    sampled_rates / sum(sampled_rates)
  }
})

rownames(bootstrap_shares) <- names(positive_rates)

# Identify usable resamples.
usable <- colSums(is.na(bootstrap_shares)) == 0
bootstrap_shares <- bootstrap_shares[, usable, drop = FALSE]

# Percentile bootstrap intervals.
intervals <- t(apply(
  bootstrap_shares,
  1,
  quantile,
  probs = c(0.025, 0.975)
))

prediction_results <- data.frame(
  candidate = names(estimated_shares),
  positive_post_percent = 100 * positive_rates,
  estimated_vote_percent = 100 * estimated_shares,
  lower_95_percent = 100 * intervals[, 1],
  upper_95_percent = 100 * intervals[, 2],
  row.names = NULL
)

knitr::kable(prediction_results, digits = 1)
candidate positive_post_percent estimated_vote_percent lower_95_percent upper_95_percent
Becerra 23.6 40.9 26.3 54.5
Hilton 34.2 59.1 45.5 73.7
bootstrap_summary <- data.frame(
  candidate = rownames(bootstrap_shares),
  percent_resamples_above_50 = 100 * rowMeans(
    bootstrap_shares > 0.5
  ),
  usable_resamples = ncol(bootstrap_shares)
)

knitr::kable(bootstrap_summary, digits = 1)
candidate percent_resamples_above_50 usable_resamples
Becerra Becerra 9.6 5000
Hilton Hilton 90.4 5000
predicted_winner <- if (
  abs(diff(estimated_shares)) < 1e-12
) {
  "Tie"
} else {
  names(which.max(estimated_shares))
}

cat("Predicted winner under the positive-share rule:",
    predicted_winner)
## Predicted winner under the positive-share rule: Hilton

7. Reflection

Bluesky users may not represent California voters, and I cannot confirm whether the authors live or vote in California. The Bing lexicon measures word tone and can miss sarcasm or mistake criticism of someone else for criticism of the candidate. The code agreed with my stance labels on only 3 of 10 posts, or 30%, which limits confidence in the forecast. More posts do not necessarily mean more support because they can include criticism, news, or repeated messages. The bootstrap measures sampling variation but cannot correct these biases or sentiment errors.