This exploratory analysis compares Bluesky posts mentioning Xavier Becerra and Steve Hilton. A Bing lexicon score measures word tone, not candidate support. The posts are not a representative sample of voters; calculated vote shares are illustrative sentiment-based proxies, not reliable election forecasts.
I am completing a Marketing Analytics project using Bluesky posts about two California election candidates. I have collected two datasets and exported them from RStudio into Excel-compatible CSV files. Each dataset contains posts about one candidate, including text and available engagement information. Please write R code that imports both datasets, labels them by candidate, uses tidytext and the Bing sentiment lexicon to classify each post as positive, negative, or neutral based on its words, summarizes post volume, positive share, negative share, and net sentiment by candidate, randomly selects 20 posts for manual stance validation, calculates agreement accuracy, predicts a winner and vote share using positive share as a proxy, runs 1,000 bootstrap resamples to estimate uncertainty, and creates summary tables and charts suitable for an R Markdown report. Do not include usernames or individual posts in the published report. Report only aggregate statistics. Explain the difference between tone and stance, sarcasm, and sampling bias on Bluesky.
Replace the blockquote above if the exact prompt you actually submitted to the AI differed.
Put both candidate CSV files in the same RStudio project folder as
this .Rmd file. Change the filenames below to match
your actual files. Do not use
install.packages() inside the report; install packages in
the Console beforehand.
# EDIT THESE TWO FILE NAMES to match your files in the Files pane
becerra_file <- "Xavier_Becerra_Posts.csv"
hilton_file <- "Steve_Hilton_Posts.csv"
becerra <- read_csv(becerra_file, show_col_types=FALSE) %>%
transmute(candidate="Xavier Becerra", text=as.character(text))
hilton <- read_csv(hilton_file, show_col_types=FALSE) %>%
transmute(candidate="Steve Hilton", text=as.character(text))
posts <- bind_rows(becerra, hilton) %>%
mutate(post_id=row_number())
stopifnot(nrow(posts) > 0)
Tokenize each post into words, match against Bing positive/negative terms, and sum +1 for positive and -1 for negative words. A score of zero (including no matched terms) is neutral. This is a tone classifier, not a stance classifier.
bing <- get_sentiments("bing")
word_scores <- posts %>%
select(post_id, text) %>%
unnest_tokens(word, text) %>%
inner_join(bing, by="word") %>%
mutate(word_score=if_else(sentiment=="positive", 1L, -1L)) %>%
group_by(post_id) %>%
summarise(net_score=sum(word_score), .groups="drop")
scored_posts <- posts %>%
left_join(word_scores, by="post_id") %>%
mutate(net_score=replace_na(net_score, 0L),
sentiment=case_when(net_score>0 ~ "positive",
net_score<0 ~ "negative",
TRUE ~ "neutral"))
sentiment_summary <- scored_posts %>%
group_by(candidate) %>%
summarise(total_posts=n(),
positive=sum(sentiment=="positive"),
negative=sum(sentiment=="negative"),
neutral=sum(sentiment=="neutral"),
positive_share=round(100*mean(sentiment=="positive"),1),
net_sentiment=positive-negative,
.groups="drop")
kable(sentiment_summary, caption="Aggregate Bing sentiment by candidate")
| candidate | total_posts | positive | negative | neutral | positive_share | net_sentiment |
|---|---|---|---|---|---|---|
| Steve Hilton | 193 | 33 | 65 | 95 | 17.1 | -32 |
| Xavier Becerra | 162 | 58 | 26 | 78 | 35.8 | 32 |
scored_posts %>%
count(candidate, sentiment) %>%
ggplot(aes(x=candidate, y=n, fill=sentiment)) +
geom_col() +
labs(title="Bing sentiment classifications", x=NULL, y="Posts", fill="Sentiment") +
theme_minimal()
Randomly select 10 posts from each candidate (20 total). Only
for your private review, export the audit file, label
manual_stance as positive (supports
candidate), negative (opposes candidate), or
neutral (unclear/neither), then save as
manual_audit_labeled.csv. Do not publish
the private audit CSV or individual post text.
if (file.exists("manual_audit_labeled.csv")) {
audit_done <- read_csv("manual_audit_labeled.csv", show_col_types=FALSE) %>%
mutate(manual_stance=tolower(trimws(manual_stance))) %>%
filter(manual_stance %in% c("positive","negative","neutral"))
validation <- audit_done %>%
group_by(candidate) %>%
summarise(audited=n(), matched=sum(bing_sentiment==manual_stance),
agreement_percent=round(100*mean(bing_sentiment==manual_stance),1),
.groups="drop")
kable(validation, caption="Aggregate Bing tone vs manually labeled stance agreement")
} else {
cat("Manual audit not yet completed. Label 20 randomly sampled posts privately and upload manual_audit_labeled.csv before final publication.")
}
## Manual audit not yet completed. Label 20 randomly sampled posts privately and upload manual_audit_labeled.csv before final publication.
Interpretation: Agreement between tone and stance is not a definitive measure of model accuracy. A sarcastic post or criticism of a candidate’s opponent may be misclassified.
Resample posts within each candidate with replacement 1,000 times and recompute the same proxy. Percentile intervals capture only resampling variability among collected posts, not voter representativeness, missing posts, bot activity, or tone-versus-stance error.
set.seed(456)
boot <- bind_rows(lapply(seq_len(1000), function(b) {
sampled <- scored_posts %>%
group_by(candidate) %>%
slice_sample(prop=1, replace=TRUE) %>%
ungroup()
sampled %>%
group_by(candidate) %>%
summarise(p=mean(sentiment=="positive"), .groups="drop") %>%
mutate(iteration=b,
proxy_share=if (sum(p)>0) 100*p/sum(p) else NA_real_)
}))
intervals <- boot %>%
group_by(candidate) %>%
summarise(lower_95=quantile(proxy_share, .025, na.rm=TRUE),
upper_95=quantile(proxy_share, .975, na.rm=TRUE),
.groups="drop")
kable(intervals, digits=1, caption="Bootstrap percentile intervals for sentiment proxy (%)")
| candidate | lower_95 | upper_95 |
|---|---|---|
| Steve Hilton | 24.4 | 40.8 |
| Xavier Becerra | 59.2 | 75.6 |
Bluesky users are self-selected and may differ substantially from the voting population. More posts about a candidate can reflect controversy, criticism, or highly active accounts rather than support. The Bing lexicon detects positive and negative words, not who is being praised or criticized. Sarcasm, jokes, negation, and quotes can reverse a post’s intended meaning. Manual stance labels therefore provide an important check, but 20 audited posts are a small sample. Bootstrap intervals measure only sampling variation under the resampling assumptions; they cannot turn this social-media convenience sample into a representative election poll. The prediction above should not be interpreted as a validated vote forecast.
This report intentionally prints only aggregate tables and charts. Do not add screenshots of individual posts, usernames, or the private audit CSV. Before publishing, check the knitted HTML for identifying information.