Which candidate, Xavier Becerra or Steve Hilton, has more positive wording in the collected Bluesky posts? I use the Bing sentiment lexicon to compare the samples and develop a tentative class forecast. Post sentiment is not the same as a vote or an endorsement.
This report reads previously collected posts. It does not sign in to
Bluesky or repeat the search when knitted. It recalculates sentiment
from the text column rather than using existing
Sentiment, Positive, Negative, or
Neutral columns.
Put this R Markdown file and the candidate data files in the
same folder. The report accepts
Xavier_Becerra.csv and Steve_Hilton.csv, or
the uploaded .xlsx versions of those names. If both formats
are present, CSV is used first. The optional
candidate_summary.csv is displayed separately.
file_show.xlsx is a duplicate of the supplied Hilton
workbook and is not imported. The report uses the first worksheet of
each Excel workbook, which contains the posts. Blank additional sheets
are ignored.
The previous lab used atrrr::search_post() to collect
posts. The uploaded files do not provide a complete search log, so exact
search settings, collection times, and pagination completeness cannot be
verified here. The analysis uses the dates represented in the uploaded
records; it does not claim that they form a complete or equal-duration
sample. Before submitting, add the original search queries, requested
limits, and collection details if available.
read_candidate <- function(stem, candidate_label) {
options <- paste0(stem, c(".csv", ".xlsx"))
available <- options[file.exists(options)]
if (!length(available)) {
stop("Missing candidate file. Add ", stem, ".csv or ", stem, ".xlsx beside this Rmd.")
}
path <- available[1]
if (grepl("\\.csv$", path)) {
x <- read_csv(path, col_types = cols(.default = col_character()),
show_col_types = FALSE)
} else {
x <- read_excel(path, sheet = 1, col_types = "text")
}
if (!all(c("uri", "text") %in% names(x))) {
stop(path, " must contain columns named uri and text.")
}
# These optional fields are available in the uploaded workbooks.
for (column in c("author_handle", "created_at", "langs", "embed_data")) {
if (!column %in% names(x)) x[[column]] <- NA_character_
}
x %>% mutate(source_candidate = candidate_label, source_file = basename(path))
}
raw_posts <- bind_rows(
read_candidate("Xavier_Becerra", "Xavier Becerra"),
read_candidate("Steve_Hilton", "Steve Hilton")
)
file_inventory <- raw_posts %>%
group_by(source_file, source_candidate) %>%
summarise(rows = n(), unique_posts = n_distinct(uri, na.rm = TRUE),
.groups = "drop")
knitr::kable(file_inventory, caption = "Imported post files")
| source_file | source_candidate | rows | unique_posts |
|---|---|---|---|
| Steve_Hilton.xlsx | Steve Hilton | 193 | 193 |
| Xavier_Becerra.csv | Xavier Becerra | 198 | 198 |
# Preserve all imported records locally.
write_csv(raw_posts, "analysis_output/raw_combined_posts.csv")
The following prompt specifies the analysis used in this report. It
is also saved locally as
analysis_output/exact_ai_prompt.txt when the report is
knitted.
Write a complete R Markdown report that knits to self-contained HTML for RPubs. Use my existing Xavier_Becerra and Steve_Hilton files, accepting CSV or XLSX. Each row is a Bluesky search result with uri, text, author_handle, created_at, langs, and embed_data columns. Import Excel columns as text and use the first sheet. Ignore file_show.xlsx because it duplicates Steve_Hilton.xlsx. Display an optional candidate_summary.csv separately: it describes a different number of posts and must not be merged into the recalculated results. Preserve raw data. Remove missing IDs/text and duplicate URIs across both files. Flag missing language and keep only explicitly English posts in the main analysis. Identify candidate mentions in post text using surnames, and exclude both-candidate posts for manual review. Remove Paris Hilton or hotel-related Hilton posts without an explicit political candidate reference; flag mixed cases for review. Flag surname-only posts and posts without candidate names in their text for review. Do not silently score embedded article descriptions as authored post text. Export cleaning decisions and review cases. Score only unambiguous retained posts. Use tidytext::unnest_tokens() and get_sentiments(‘bing’) after removing URLs and user handles. Do not remove stop words or weight by engagement. Count positive tokens as +1 and negative tokens as -1. Preserve unmatched posts with net zero but label them no_match, not balanced. Calculate normalized sentiment as net divided by matched tokens, leaving it missing when there are no matches. Summarize counts, authors, mean net, mean normalized sentiment and coverage by candidate, retaining candidates with no usable posts and avoiding NaN. Include tables, two comparison charts, an automatic tentative class prediction using higher mean normalized sentiment, and an explicit pending-review warning. Do not predict if either candidate has no matched posts; report ties. Explain sarcasm, negation, target ambiguity, spam, prolific authors, geographic uncertainty, unmatched words, language and search sampling limitations. Use the supplied records without claiming a verified common collection window. Export results and session information. Never fabricate posts or results.
The main comparison uses posts that explicitly name one candidate and are marked as English. Posts with missing language, surname-only references, both candidates, or mixed hotel/political references are exported for review and excluded from the main comparison. This conservative choice reduces ambiguity but may discard relevant posts.
Candidate search results can match linked article metadata even when
the author’s text does not name the candidate. These posts are sent to
review. Only the text column is scored. The audit assigns
one primary decision per row, using the order shown in the code.
audit <- raw_posts %>%
mutate(
row_id = row_number(),
lower_text = str_to_lower(coalesce(text, "")),
language = str_to_lower(str_trim(coalesce(langs, ""))),
becerra = str_detect(lower_text, "\\bbecerra\\b"),
hilton = str_detect(lower_text, "\\bhilton\\b"),
full_becerra = str_detect(lower_text, "\\bxavier\\s+becerra\\b"),
full_hilton = str_detect(lower_text, "\\bsteve\\s+hilton\\b"),
hotel_or_paris = str_detect(
lower_text,
paste0("\\bparis\\s+hilton\\b|",
"\\bhilton\\b.{0,50}\\b(hotels?|resorts?|honors)\\b|",
"\\b(hotels?|resorts?)\\b.{0,50}\\bhilton\\b")
),
decision = case_when(
is.na(uri) | str_trim(uri) == "" ~ "Remove: missing URI",
str_trim(lower_text) == "" ~ "Remove: missing text",
duplicated(uri) ~ "Remove: duplicate URI",
hotel_or_paris & !full_becerra & !full_hilton ~ "Remove: hotel or Paris Hilton",
becerra & hilton ~ "Review: both candidates",
hotel_or_paris ~ "Review: mixed hotel and politics",
!becerra & !hilton ~ "Review: no candidate name in text",
language == "" ~ "Review: missing language",
!str_detect(language, "^en($|[-_])") ~ "Remove: not explicitly English",
(becerra & !full_becerra) | (hilton & !full_hilton) ~ "Review: surname only",
TRUE ~ "Keep"
),
candidate = case_when(
becerra & !hilton ~ "Xavier Becerra",
hilton & !becerra ~ "Steve Hilton",
TRUE ~ NA_character_
)
)
review_posts <- audit %>% filter(str_starts(decision, "Review:"))
clean_posts <- audit %>%
filter(decision == "Keep") %>%
mutate(
clean_text = str_remove_all(text, "https?://\\S+|www\\.\\S+"),
clean_text = str_remove_all(clean_text, "@[A-Za-z0-9._-]+")
)
write_csv(audit, "analysis_output/cleaning_audit.csv")
write_csv(review_posts, "analysis_output/review_posts.csv")
write_csv(clean_posts, "analysis_output/cleaned_posts.csv")
cleaning_summary <- audit %>% count(decision, name = "rows")
stopifnot(sum(cleaning_summary$rows) == nrow(raw_posts))
knitr::kable(cleaning_summary, caption = "Cleaning decisions across imported rows")
| decision | rows |
|---|---|
| Keep | 177 |
| Remove: duplicate URI | 43 |
| Remove: missing text | 13 |
| Remove: not explicitly English | 3 |
| Review: both candidates | 45 |
| Review: missing language | 44 |
| Review: no candidate name in text | 55 |
| Review: surname only | 11 |
There are 155 rows awaiting manual review. Results below describe the retained subset. Review the exported cases and document any changes to the rules or input before treating the analysis as final. These review flags do not prove that a post is irrelevant.
Other issues include repeated wording under different URIs, spam, highly active accounts, and incomplete language labels. Distinct URIs are retained even when wording repeats, because identical wording alone does not prove a post is spam. Author counts are reported so the volume of posts can be considered alongside the number of accounts.
Bing assigns words a positive or negative category. Every matched token counts once per occurrence. A post’s net score = positive words − negative words. Its normalized score = net score / matched words, ranging from −1 to +1. Mean normalized sentiment gives each matched post equal weight.
A post with no lexicon matches receives net zero, a missing
normalized score, and the label no_match. A post with equal
positive and negative counts and at least one match is
balanced. These labels describe word counts, not verified
neutral opinions.
bing <- get_sentiments("bing")
word_counts <- clean_posts %>%
select(uri, clean_text) %>%
unnest_tokens(word, clean_text) %>%
inner_join(bing, by = "word") %>%
group_by(uri) %>%
summarise(
positive_words = sum(sentiment == "positive"),
negative_words = sum(sentiment == "negative"),
.groups = "drop"
)
post_scores <- clean_posts %>%
left_join(word_counts, by = "uri") %>%
mutate(
positive_words = replace_na(positive_words, 0L),
negative_words = replace_na(negative_words, 0L),
matched_words = positive_words + negative_words,
net_score = positive_words - negative_words,
normalized_score = net_score / na_if(matched_words, 0L),
sentiment = case_when(
matched_words == 0 ~ "no_match",
net_score > 0 ~ "positive",
net_score < 0 ~ "negative",
TRUE ~ "balanced"
)
)
summary_recalculated <- post_scores %>%
group_by(candidate) %>%
summarise(
total_posts = n(),
authors = n_distinct(author_handle[!is.na(author_handle) & author_handle != ""]),
positive_posts = sum(sentiment == "positive"),
negative_posts = sum(sentiment == "negative"),
balanced_posts = sum(sentiment == "balanced"),
no_match_posts = sum(sentiment == "no_match"),
matched_posts = sum(matched_words > 0),
mean_net = safe_mean(net_score),
mean_normalized = safe_mean(normalized_score),
.groups = "drop"
)
count_fields <- c("total_posts", "authors", "positive_posts", "negative_posts",
"balanced_posts", "no_match_posts", "matched_posts")
summary_recalculated <- candidate_names %>%
left_join(summary_recalculated, by = "candidate") %>%
mutate(
across(all_of(count_fields), ~ replace_na(.x, 0L)),
lexicon_coverage = matched_posts / na_if(total_posts, 0L)
)
stopifnot(nrow(post_scores) == nrow(clean_posts))
stopifnot(all(with(summary_recalculated,
total_posts == positive_posts + negative_posts + balanced_posts + no_match_posts)))
write_csv(post_scores, "analysis_output/post_sentiment_scores.csv")
write_csv(summary_recalculated, "analysis_output/recalculated_candidate_summary.csv")
knitr::kable(
summary_recalculated %>%
select(candidate, total_posts, authors, positive_posts,
negative_posts, balanced_posts, no_match_posts),
caption = "Recalculated sentiment counts for retained posts"
)
| candidate | total_posts | authors | positive_posts | negative_posts | balanced_posts | no_match_posts |
|---|---|---|---|---|---|---|
| Xavier Becerra | 116 | 28 | 13 | 56 | 28 | 19 |
| Steve Hilton | 61 | 45 | 14 | 24 | 9 | 14 |
knitr::kable(
summary_recalculated %>%
select(candidate, matched_posts, mean_net, mean_normalized, lexicon_coverage),
digits = 3,
caption = "Recalculated sentiment averages and coverage (coverage is a proportion)"
)
| candidate | matched_posts | mean_net | mean_normalized | lexicon_coverage |
|---|---|---|---|---|
| Xavier Becerra | 97 | -0.336 | -0.036 | 0.836 |
| Steve Hilton | 47 | -0.328 | -0.211 | 0.770 |
plot_counts <- summary_recalculated %>%
select(candidate, positive_posts, negative_posts, balanced_posts, no_match_posts) %>%
pivot_longer(-candidate, names_to = "sentiment", values_to = "posts") %>%
mutate(sentiment = factor(sentiment,
levels = c("positive_posts", "negative_posts", "balanced_posts", "no_match_posts"),
labels = c("Positive", "Negative", "Balanced", "No match")))
ggplot(plot_counts, aes(candidate, posts, fill = sentiment)) +
geom_col(position = "dodge") +
scale_fill_manual(values = c("Positive" = "#247B68", "Negative" = "#B54A53",
"Balanced" = "#7F8B99", "No match" = "#D7DDE3"),
drop = FALSE) +
labs(title = "Sentiment counts in the retained sample",
x = NULL, y = "Posts", fill = NULL) +
theme_minimal(base_size = 12) +
theme(legend.position = "bottom")
if (any(is.finite(summary_recalculated$mean_normalized))) {
ggplot(filter(summary_recalculated, is.finite(mean_normalized)),
aes(candidate, mean_normalized)) +
geom_col(fill = "#35658D", width = 0.55) +
geom_hline(yintercept = 0, color = "grey45") +
coord_cartesian(ylim = c(-1, 1)) +
labs(title = "Average normalized sentiment",
subtitle = "Posts with at least one Bing lexicon match",
x = NULL, y = "Mean normalized score") +
theme_minimal(base_size = 12)
} else {
cat("No matched posts are available for the average sentiment chart.")
}
The uploaded candidate_summary.csv has 328 Becerra posts
and 571 Hilton posts, while the uploaded workbooks contain 198 and 193
rows respectively. The summary therefore cannot be treated as the
summary of these exact workbook rows. It is shown below for reference
and does not drive the recalculated charts or prediction.
if (file.exists("candidate_summary.csv")) {
supplied_summary <- read_csv("candidate_summary.csv", show_col_types = FALSE)
knitr::kable(supplied_summary, digits = 3,
caption = "Previously supplied summary; not recalculated from these workbooks")
} else {
cat("No separate candidate_summary.csv was supplied. The recalculated analysis above is complete without it.")
}
| candidate | total_posts | positive_posts | negative_posts | balanced_posts | no_match_posts | average_sentiment | lexicon_coverage |
|---|---|---|---|---|---|---|---|
| Xavier Becerra | 328 | 93 | 100 | 48 | 87 | 0.107 | 0.735 |
| Steve Hilton | 571 | 169 | 112 | 51 | 239 | 0.215 | 0.581 |
For this lab, the decision rule favors the candidate with the higher mean normalized sentiment among matched posts in the retained subset. It does not use the separate supplied summary. This rule is an exploratory classroom approach and has not been validated as an election forecasting model.
if (any(summary_recalculated$matched_posts == 0) ||
any(!is.finite(summary_recalculated$mean_normalized))) {
cat("**No tentative prediction:** at least one candidate has no usable sentiment matches.\n")
} else if (abs(diff(summary_recalculated$mean_normalized)) < 1e-10) {
cat("**No tentative prediction:** the candidates are tied on mean normalized sentiment.\n")
} else {
favorite <- summary_recalculated$candidate[
which.max(summary_recalculated$mean_normalized)]
cat("The exploratory decision rule favors **", favorite,
"** in the retained sample. This is the lab's tentative forecast, not an established election outcome.\n", sep = "")
}
The exploratory decision rule favors Xavier Becerra in the retained sample. This is the lab’s tentative forecast, not an established election outcome.
if (nrow(review_posts) > 0) {
cat("\n**Manual review remains pending.** The tentative comparison may change after ambiguous posts are resolved.\n")
}
Manual review remains pending. The tentative comparison may change after ambiguous posts are resolved.
Bing measures general word sentiment. It does not determine whether the author supports the named candidate. A post criticizing a candidate’s opponent may score negatively even though it supports that candidate. Sarcasm, negation, quotations, and context can also reverse the intended meaning. Manually reading a sample of scored posts is necessary to assess these errors.
Bluesky accounts do not form a representative sample of California voters. Posters may live elsewhere, may not be eligible voters, and may post many times. Search limits, unequal collection periods, language exclusions, and manual-review exclusions can change the comparison. Lexicon coverage also differs between candidates, so missing matches must be considered alongside average sentiment. Sentiment percentages are not projected vote shares.
The report provides a reproducible comparison of the supplied posts. A stronger election forecast would require representative voter data and validation against past elections.
The analysis_output folder contains the exact AI prompt,
raw combined records, cleaning audit, review cases, cleaned posts, post
sentiment scores, recalculated summary, and R session information. These
local CSVs are not automatically uploaded with the HTML report. This
report displays aggregate results rather than a full table of account
handles and post text.
To publish: open this .Rmd in RStudio, click
Knit to create HTML, inspect the tables and charts,
then click Publish in the HTML preview and choose
RPubs. RPubs publishes the rendered report publicly.
See the RPubs
publishing instructions.
capture.output(sessionInfo(), file = "analysis_output/session_info.txt")
sessionInfo()
## R version 4.6.1 (2026-06-24)
## Platform: x86_64-pc-linux-gnu
## Running under: Ubuntu 24.04.5 LTS
##
## Matrix products: default
## BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
## LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.26.so; LAPACK version 3.12.0
##
## locale:
## [1] LC_CTYPE=C.UTF-8 LC_NUMERIC=C LC_TIME=C.UTF-8
## [4] LC_COLLATE=C.UTF-8 LC_MONETARY=C.UTF-8 LC_MESSAGES=C.UTF-8
## [7] LC_PAPER=C.UTF-8 LC_NAME=C LC_ADDRESS=C
## [10] LC_TELEPHONE=C LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C
##
## time zone: UTC
## tzcode source: system (glibc)
##
## 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
## [6] readxl_1.6.0 readr_2.2.0
##
## loaded via a namespace (and not attached):
## [1] janeaustenr_1.0.0 sass_0.4.10 generics_0.1.4 stringi_1.8.9
## [5] lattice_0.22-9 hms_1.1.4 digest_0.6.39 magrittr_2.0.5
## [9] evaluate_1.0.5 grid_4.6.1 RColorBrewer_1.1-3 fastmap_1.2.0
## [13] cellranger_1.1.0 jsonlite_2.0.0 Matrix_1.7-5 purrr_1.2.2
## [17] scales_1.4.0 jquerylib_0.1.4 cli_3.6.6 rlang_1.3.0
## [21] crayon_1.5.3 tokenizers_0.3.0 bit64_4.8.6 withr_3.0.3
## [25] cachem_1.1.0 yaml_2.3.12 tools_4.6.1 parallel_4.6.1
## [29] tzdb_0.5.0 vctrs_0.7.3 R6_2.6.1 lifecycle_1.0.5
## [33] bit_4.6.0 vroom_1.7.1 pkgconfig_2.0.3 pillar_1.11.1
## [37] bslib_0.12.0 gtable_0.3.6 glue_1.8.1 Rcpp_1.1.2
## [41] xfun_0.62 tibble_3.3.1 tidyselect_1.2.1 knitr_1.52
## [45] farver_2.1.2 htmltools_0.5.9 SnowballC_0.7.1 rmarkdown_2.32
## [49] labeling_0.4.3 compiler_4.6.1 S7_0.2.2