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.
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:
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.
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
)
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
)
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"
)
| 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.")
}
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 | 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 |
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:
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"
)
| 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_stance | code_stance | n |
|---|
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"
)
| 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.
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"
)
| Candidate | Share_percent | Low_95 | High_95 |
|---|---|---|---|
| Becerra | 56.93 | 54.22 | 59.42 |
| Hilton | 43.07 | 40.58 | 45.78 |
Although this research provides an interesting comparison of political discussions on Bluesky, several limitations must be considered.
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.
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.
A candidate receiving more posts does not necessarily have more supporters.
A high number of mentions may result from criticism, controversy, news coverage, or campaign activity.
For this reason, I selected net sentiment instead of relying entirely on post volume.
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.
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.
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.