Research Question: Can the tone of public Bluesky posts provide a meaningful signal about the contest between Xavier Becerra and Steve Hilton?

This is a classroom social-media analysis, not a scientific voter poll.

1. Beyond the Ballot

Social media has become an important space where people discuss political candidates, policies, and elections. However, online conversations do not always represent how people will vote.

For this project, I analyzed public Bluesky posts mentioning Xavier Becerra and Steve Hilton.

My data covers October 1 through October 29, 2026.

The purpose of this research is to:

  • Examine online conversations about both candidates.
  • Remove unrelated and duplicate posts.
  • Measure positive and negative language.
  • Compare computerized sentiment with human judgments.
  • Create an experimental election prediction.
  • Evaluate uncertainty through bootstrap resampling.

2. Working With Artificial Intelligence

AI Prompt

Important: This is a proposed prompt documenting the analysis method. Replace it with the exact wording you actually sent to your AI assistant if different.

My previous Bluesky R lab uses atrrr::search_post(). My dataset contains uri, text, and indexed_at. Using dplyr, tidytext, and the Bing sentiment lexicon, help me analyze public Bluesky posts about Xavier Becerra and Steve Hilton. Remove duplicate records, posts about Hilton Hotels and Paris Hilton, unrelated posts, and posts discussing both candidates. Preserve posts without matched sentiment words as neutral. Summarize sentiment by candidate. Compare 20 randomly selected hand-labeled posts with the computer-generated labels and calculate accuracy. Create an election prediction using net sentiment and perform 1,000 bootstrap resamples. Present only aggregate results without usernames, post text, URIs, or passwords. Explain the limitations of using social media for election forecasting.

3. Collecting the Conversation

The Bluesky data was collected using the atrrr package from my previous lab.

The following code documents my collection process. It is not rerun during knitting because the data has already been saved.

library(atrrr)
library(dplyr)

# Authenticate separately in the RStudio Console.
# auth("YOUR_BLUESKY_HANDLE")

Becerra_Post <- bind_rows(
  search_post('"Xavier Becerra"', limit = 500L),
  search_post("Becerra governor", limit = 500L)
)

Hilton_Post <- bind_rows(
  search_post('"Steve Hilton"', limit = 500L),
  search_post("Hilton governor", limit = 500L)
)

Post <- bind_rows(
  Becerra_Post,
  Hilton_Post
)

dir.create(
  "private",
  showWarnings = FALSE
)

saveRDS(
  Post,
  "private/bluesky_raw.rds"
)

# Export using the previous lab's method.

Post_flat <- Post %>%
  mutate(across(
    where(is.list),
    ~ vapply(., function(x) {
      paste(
        as.character(unlist(x)),
        collapse = "; "
      )
    }, character(1))
  ))

write.csv(
  Post_flat,
  "private/my_post.csv",
  row.names = FALSE
)

Loading the Dataset

4. Separating Useful Data From Noise

Before analyzing sentiment, I cleaned the dataset to remove unrelated or repeated content.

The cleaning process addressed several issues, including duplicate posts, Hilton Hotel references, Paris Hilton references, missing content, and posts discussing both candidates.

s0 <- Post %>%
  transmute(
    uri = as.character(uri),
    text = as.character(text),
    posted = as.POSIXct(indexed_at, tz = "UTC")
  )

# Remove duplicate post IDs.

s1 <- s0 %>%
  distinct(uri, .keep_all = TRUE)

# Keep posts from the study period.

s2 <- s1 %>%
  filter(
    !is.na(text),
    str_squish(text) != "",
    !is.na(posted),

    as.Date(posted, tz = "UTC") >=
      as.Date("2026-10-01"),

    as.Date(posted, tz = "UTC") <=
      as.Date("2026-10-29")
  ) %>%
  mutate(
    lower = str_to_lower(text)
  )

# Identify unrelated Hilton references.

hotel_pattern <- paste0(
  "\\b(paris\\s+hilton|",
  "hilton\\s+hotels?|",
  "hilton\\s+honors|",
  "hilton\\s+garden\\s+inn|",
  "hilton\\s+resorts?|",
  "hilton\\s+head|",
  "doubletree|",
  "hampton\\s+inn|",
  "conrad\\s+hilton)\\b"
)

s3 <- s2 %>%
  filter(!(
    str_detect(lower, hotel_pattern) &
    !str_detect(
      lower,
      "\\bsteve\\s+hilton\\b"
    )
  ))

# Identify candidate references.

s4 <- s3 %>%
  mutate(

    election_context = str_detect(
      lower,
      "\\b(governor|gubernatorial|election|ballot|voting|vote|voter|votes|campaign|candidate|california)\\b"
    ),

    becerra =
      str_detect(
        lower,
        "\\bxavier\\s+becerra\\b"
      ) |
      (
        str_detect(lower, "\\bbecerra\\b") &
        election_context
      ),

    hilton =
      str_detect(
        lower,
        "\\bsteve\\s+hilton\\b"
      ) |
      (
        str_detect(lower, "\\bhilton\\b") &
        election_context
      ),

    candidate = case_when(
      becerra & hilton ~ "Both",
      becerra ~ "Becerra",
      hilton ~ "Hilton",
      TRUE ~ "Unrelated"
    )
  )

# Keep posts about one candidate.

s5 <- s4 %>%
  filter(
    candidate %in% c("Becerra", "Hilton")
  )

# Remove repeated text.

posts_clean <- s5 %>%
  mutate(
    normalized = str_to_lower(
      str_squish(
        str_remove_all(
          text,
          "https?://\\S+"
        )
      )
    )
  ) %>%
  distinct(
    candidate,
    normalized,
    .keep_all = TRUE
  ) %>%
  arrange(candidate, uri) %>%
  mutate(
    post_id = row_number()
  ) %>%
  select(
    post_id,
    candidate,
    text
  )

Cleaning Audit

n_steps <- c(
  nrow(s0),
  nrow(s1),
  nrow(s2),
  nrow(s3),
  sum(s4$candidate != "Unrelated"),
  nrow(s5),
  nrow(posts_clean)
)

audit <- data.frame(

  Stage = c(
    "Raw search hits",
    "Unique posts",
    "Valid dates and text",
    "Hotel and celebrity cleaning",
    "Candidate-related posts",
    "Single-candidate posts",
    "Unique post text"
  ),

  Remaining = n_steps,

  Removed = c(
    0,
    head(n_steps, -1) -
      tail(n_steps, -1)
  )
)

knitr::kable(
  audit,
  caption = "Data Cleaning Audit"
)
Data Cleaning Audit
Stage Remaining Removed
Raw search hits 2323 0
Unique posts 1572 751
Valid dates and text 1525 47
Hotel and celebrity cleaning 1524 1
Candidate-related posts 1160 364
Single-candidate posts 922 238
Unique post text 798 124
if (!all(c("Becerra", "Hilton") %in%
         posts_clean$candidate)) {
  stop("Both candidates need usable posts.")
}
2323Raw posts
798Cleaned posts
1525Removed posts

5. Measuring Political Conversation

The Bing lexicon identifies positive and negative words in each post.

Each post receives a score calculated by subtracting the number of negative words from the number of positive words.

This method measures language tone rather than actual political support.

bing <- tidytext::get_sentiments("bing")

word_counts <- posts_clean %>%
  select(post_id, text) %>%
  unnest_tokens(word, text) %>%
  inner_join(bing, by = "word") %>%
  group_by(post_id) %>%
  summarise(
    pos = sum(sentiment == "positive"),
    neg = sum(sentiment == "negative"),
    .groups = "drop"
  )

scored <- posts_clean %>%
  left_join(word_counts, by = "post_id") %>%
  mutate(
    pos = coalesce(pos, 0L),
    neg = coalesce(neg, 0L),

    score = pos - neg,

    bing_tone = case_when(
      score > 0 ~ "positive",
      score < 0 ~ "negative",
      TRUE ~ "neutral"
    ),

    code_stance = case_when(
      score > 0 ~ "pro",
      score < 0 ~ "anti",
      TRUE ~ "neutral"
    )
  )

summary_sentiment <- scored %>%
  group_by(candidate) %>%
  summarise(

    total = n(),

    positive = sum(bing_tone == "positive"),

    negative = sum(bing_tone == "negative"),

    neutral = sum(bing_tone == "neutral"),

    positive_pct = 100 * positive / total,

    net = (positive - negative) / total,

    .groups = "drop"
  )

knitr::kable(
  summary_sentiment,
  digits = 3,
  caption = "Candidate Sentiment Summary"
)
Candidate Sentiment Summary
candidate total positive negative neutral positive_pct net
Becerra 320 115 58 147 35.938 0.178
Hilton 478 129 181 168 26.987 -0.109

Visualization: Tone of the Conversation

6. Human Judgment Versus Artificial Intelligence

Automated sentiment analysis can misunderstand political statements.

To investigate this issue, I selected 20 posts for manual review, including 10 about each candidate.

Each post was classified according to whether the author supported, opposed, or expressed no clear stance toward the named candidate.

The three labels are:

  • Pro: Supports the candidate.
  • Anti: Opposes the candidate.
  • Neutral: No clear support or opposition.

Validation Accuracy

manual <- read.csv(
  labels_path,
  stringsAsFactors = FALSE
)

if (!all(
  c("post_id", "manual_stance") %in%
    names(manual)
)) {
  stop("The label file is missing required columns.")
}

manual <- manual %>%
  transmute(

    post_id = as.integer(post_id),

    manual_stance = str_to_lower(
      str_trim(
        as.character(manual_stance)
      )
    )
  ) %>%
  distinct(post_id, .keep_all = TRUE)

comparison <- scored %>%
  select(
    post_id,
    candidate,
    code_stance
  ) %>%
  inner_join(manual, by = "post_id") %>%
  filter(
    manual_stance %in%
      c("pro", "anti", "neutral")
  )

validation <- data.frame(

  Labels_completed = nrow(comparison),

  Correct = sum(
    comparison$code_stance ==
      comparison$manual_stance
  ),

  Accuracy_percent = if (nrow(comparison) > 0) {
    100 * mean(
      comparison$code_stance ==
        comparison$manual_stance
    )
  } else {
    NA_real_
  }
)

knitr::kable(
  validation,
  digits = 1,
  caption = "Manual Validation Accuracy"
)
Manual Validation Accuracy
Labels_completed Correct Accuracy_percent
0 0 NA
if (nrow(comparison) < 20) {
  cat(
    "**Manual validation is incomplete. Review all 20 labels and Knit again.**\n"
  )
}
## **Manual validation is incomplete. Review all 20 labels and Knit again.**
knitr::kable(
  count(
    comparison,
    manual_stance,
    code_stance
  ),
  caption = "Manual Versus Automated Classifications"
)
Manual Versus Automated Classifications
manual_stance code_stance n

7. Can Sentiment Predict a Winner?

For my prediction, I selected net sentiment rather than post volume.

Net sentiment considers positive and negative language and therefore provides more information than simply counting how often each candidate is mentioned.

The calculation is:

\[ \text{Net Sentiment} = \frac{\text{Positive Posts} - \text{Negative Posts}} {\text{Total Posts}} \]

To generate an experimental percentage, I add 1 to each candidate’s net sentiment score and normalize the two values to equal 100%.

share_rule <- function(b_net, h_net) {

  weights <- c(
    Becerra = 1 + b_net,
    Hilton = 1 + h_net
  )

  if (sum(weights) == 0) {
    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"
]

shares <- share_rule(b_net, h_net)

forecast <- data.frame(
  Candidate = names(shares),
  Share_percent = as.numeric(shares)
)

knitr::kable(
  forecast,
  digits = 2,
  caption = "Net Sentiment Proxy Forecast"
)
Net Sentiment Proxy Forecast
Candidate Share_percent
Becerra 56.93
Hilton 43.07
if (abs(diff(shares)) < 1e-10) {

  cat("**Model prediction:** Tie.")

} else {

  cat(sprintf(
    "**Model prediction:** %s leads with %.2f%% sentiment-proxy share.",
    names(which.max(shares)),
    max(shares)
  ))
}
## **Model prediction:** Becerra leads with 56.93% sentiment-proxy share.

8. Testing the Prediction With Bootstrap

A single dataset can produce a result that changes when different posts are sampled.

To examine this uncertainty, I performed 1,000 bootstrap resamples.

Each resample randomly selected posts with replacement within each candidate’s dataset.

This process estimates variation in the sentiment-based forecast.

set.seed(20261103)

b <- sign(
  scored$score[
    scored$candidate == "Becerra"
  ]
)

h <- sign(
  scored$score[
    scored$candidate == "Hilton"
  ]
)

boot <- replicate(1000, {

  b_net <- mean(
    sample(
      b,
      length(b),
      replace = TRUE
    )
  )

  h_net <- mean(
    sample(
      h,
      length(h),
      replace = TRUE
    )
  )

  share_rule(b_net, h_net)
})

forecast$Low_95 <- apply(
  boot[c("Becerra", "Hilton"), , drop = FALSE],
  1,
  quantile,
  probs = 0.025
)

forecast$High_95 <- apply(
  boot[c("Becerra", "Hilton"), , drop = FALSE],
  1,
  quantile,
  probs = 0.975
)

knitr::kable(
  forecast,
  digits = 2,
  caption = "Bootstrap Confidence Intervals"
)
Bootstrap Confidence Intervals
Candidate Share_percent Low_95 High_95
Becerra 56.93 54.22 59.42
Hilton 43.07 40.58 45.78

Visualization: A Thousand Alternative Samples

9. Reflection: What the Numbers Cannot Tell Us

Although this research provides an interesting comparison of political discussions on Bluesky, several limitations must be considered.

Bluesky Does Not Represent Every Voter

One major limitation is that Bluesky users are not a random sample of California voters. People who use the platform may have different political interests and opinions from those who do not.

Some individuals may post frequently, while others rarely participate in online conversations. Therefore, the data may reflect the opinions of active users rather than the overall voting population.

Sarcasm and Political Language

The Bing sentiment lexicon identifies positive and negative words, but it does not fully understand sarcasm, humor, or political context.

For example, a person may use positive words sarcastically to criticize a candidate. Likewise, negative language may be used to defend a candidate against criticism.

This distinction between tone and political stance is important because automated sentiment labels may not accurately represent a person’s actual opinion.

Understanding the Bootstrap

The bootstrap analysis measures how much the calculated sentiment-proxy shares vary when posts are resampled.

However, bootstrap confidence intervals cannot eliminate sampling bias or guarantee that the predictions accurately reflect California voters.

Final Thoughts

This project demonstrates how R, tidytext, and publicly available social media data can be used to study political discussions.

Although my analysis generates a predicted winner, the percentages should not be treated as reliable estimates of actual election votes.

Instead, the results provide an exploratory view of online political conversations and demonstrate the importance of evaluating data quality, model accuracy, and sampling limitations.

10. Data Privacy

All results in this report are presented in aggregate form.

No individual usernames, account handles, raw posts, URIs, or passwords are published.

The original Bluesky dataset and manual labeling files are stored privately in the RStudio project and are not included in the public RPubs report.