This report analyzes a supplied sample of Bluesky posts concerning Steve Hilton and Xavier Becerra. The primary prediction rule is candidate mention-volume share. This is a measure of attention in this collected Bluesky sample, not a representative estimate of California voters or literal election vote share.
No usernames, author names, handles, post URIs, CIDs, or other direct identifiers are printed.
I have two uploaded CSV files, hilton.csv and xavier.csv, containing Bluesky posts about the 2026 California governor’s race between Steve Hilton and Xavier Becerra. The files have the same 193 rows and are exact duplicates, so analyze only one file; do not append them. Columns include uri, cid, author_handle, author_name, text, author_data, post_data, embed_data, reply_count, repost_count, like_count, quote_count, bookmark_count, indexed_at, created_at, in_reply_to, in_reply_root, quotes, tags, mentions, links, langs, and labels.
Please write reproducible R code using tidytext and the Bing sentiment lexicon. Clean the data by removing posts about “Hilton Hotel” or “Paris Hilton,” but retain posts about Steve Hilton. Also remove empty post text, identify and remove exact duplicate post text to reduce syndicated/repeated content, and exclude known non-English posts from the English Bing sentiment analysis while reporting how many were excluded and how many have missing language metadata. Do not publish or display usernames, author names, handles, URIs, CIDs, or other personal identifiers. Report only aggregate results and anonymized post excerpts/IDs.
For each retained post, tokenize text into words, join to tidytext::get_sentiments(“bing”), and calculate positive, negative, and net sentiment (positive minus negative). Summarize sentiment by candidate using candidate mentions in the post text (Hilton and Becerra). Explicitly flag posts that mention both candidates and explain that whole-post sentiment cannot reliably be attributed to either candidate in those cases. Summarize post volume and sentiment by candidate. Choose volume share as the primary prediction rule, calculate each candidate’s share of all candidate mentions, and label this as a social-media mention-share proxy rather than a literal election forecast. Name the proxy winner and report the proxy vote share.
Run a reproducible nonparametric bootstrap by resampling retained candidate-related posts with replacement, recalculating candidate mention share in each replicate, and report a 95% percentile interval. Also report the random seed and number of replicates. Create a reproducible random sample of 15 distinct candidate-related posts for manual stance coding, with anonymous IDs and no usernames. Include fields for human stance toward Hilton and Becerra; allowed labels should be supportive, opposed, neutral, or unclear/not-mentioned. After the human labels are entered, compare them with a clearly defined automated stance proxy and report overall accuracy and a confusion matrix; do not pretend Bing sentiment equals political stance. Include the exact prompt in the report.
Please identify additional data-quality and inference limitations, including missing/garbled timestamps, missing language metadata, non-English text, repeated/syndicated content, link-only or truncated posts, selection bias in who uses Bluesky and who posts, small sample size, sarcasm, and the distinction between emotional tone and candidate stance. Generate an RPubs-ready R Markdown report with tables and a bootstrap plot. Ensure the report never exposes usernames or direct identifiers.
The two source files were checked before analysis. They are exact
duplicates (193 rows each), so this report reads only
hilton.csv. The post text is the unit of analysis. Exact
duplicate text is collapsed to one row; this reduces repeated/syndicated
copies but can also discard genuinely independent users who shared
identical text.
library(tidyverse)
library(tidytext)
library(knitr)
set.seed(2026)
raw <- readr::read_csv("hilton.csv", show_col_types = FALSE)
# Never print identifying columns. Keep only analysis fields.
stopifnot("text" %in% names(raw))
if (!"langs" %in% names(raw)) raw$langs <- NA_character_
if (!"embed_data" %in% names(raw)) raw$embed_data <- NA_character_
if (!"links" %in% names(raw)) raw$links <- NA_character_
n_raw <- nrow(raw)
text0 <- raw %>%
transmute(
text = as.character(text),
langs = as.character(langs),
search_blob = paste(coalesce(as.character(text), ""),
coalesce(as.character(embed_data), ""),
coalesce(as.character(links), ""))
)
brand_pattern <- regex("\\b(paris\\s+hilton|hilton\\s+hotels?|hotel\\s+hilton)\\b",
ignore_case = TRUE)
brand_n <- sum(str_detect(text0$search_blob, brand_pattern), na.rm = TRUE)
clean <- text0 %>%
filter(!str_detect(search_blob, brand_pattern)) %>%
filter(!is.na(text), str_trim(text) != "")
n_nonempty <- nrow(clean)
n_duplicate_text <- sum(duplicated(clean$text))
clean <- clean %>% distinct(text, .keep_all = TRUE)
# Known non-English posts are excluded from the English sentiment analysis.
# Missing language tags are retained but counted and disclosed.
n_known_nonenglish <- sum(!is.na(clean$langs) & clean$langs != "" & clean$langs != "en")
n_lang_missing <- sum(is.na(clean$langs) | clean$langs == "")
english_analysis <- clean %>%
filter(is.na(langs) | langs == "" | langs == "en")
mentions <- english_analysis %>%
mutate(
hilton = str_detect(text, regex("\\bhilton\\b", ignore_case = TRUE)),
becerra = str_detect(text, regex("\\bbecerra\\b", ignore_case = TRUE)),
both = hilton & becerra
)
audit <- tibble(
Measure = c("Rows in each source CSV (files are exact duplicates)",
"Rows in one source file",
"Posts removed as Hilton Hotel/Paris Hilton references",
"Rows with empty or missing post text",
"Repeated exact-text rows removed",
"Unique nonempty posts after text deduplication",
"Known non-English posts excluded from sentiment",
"Posts with missing language metadata retained",
"Posts mentioning either candidate (English/unknown language subset)"),
Count = c(193, n_raw, brand_n, n_raw - nrow(text0 %>% filter(!is.na(text), str_trim(text)!="")),
n_duplicate_text, nrow(clean), n_known_nonenglish, n_lang_missing,
sum(mentions$hilton | mentions$becerra))
)
knitr::kable(audit)
| Measure | Count |
|---|---|
| Rows in each source CSV (files are exact duplicates) | 193 |
| Rows in one source file | 193 |
| Posts removed as Hilton Hotel/Paris Hilton references | 0 |
| Rows with empty or missing post text | 10 |
| Repeated exact-text rows removed | 24 |
| Unique nonempty posts after text deduplication | 159 |
| Known non-English posts excluded from sentiment | 4 |
| Posts with missing language metadata retained | 42 |
| Posts mentioning either candidate (English/unknown language subset) | 120 |
Additional data-quality notes. The exported
created_at field appears inconsistently formatted (some
entries look like time fragments rather than full timestamps), so this
report does not use it to infer posting trends. Language metadata is
absent for some records. Some posts are link-only, truncated, or mostly
headlines; a text-only lexicon cannot recover the linked article’s full
context. Exact-text deduplication can reduce syndication bias but may
remove legitimate repeated posts.
Bing classifies individual words as positive or negative. Net sentiment is positive-word count minus negative-word count. This is emotional tone, not a direct measure of support or opposition. Posts mentioning both candidates are included in both candidate mention groups, but their whole-post sentiment cannot be attributed cleanly to either person.
bing <- tidytext::get_sentiments("bing")
tokens <- mentions %>%
mutate(post_id = row_number()) %>%
select(post_id, text, hilton, becerra, both) %>%
unnest_tokens(word, text)
post_sentiment <- tokens %>%
inner_join(bing, by = "word") %>%
count(post_id, sentiment) %>%
pivot_wider(names_from = sentiment, values_from = n, values_fill = 0) %>%
right_join(mentions %>% mutate(post_id = row_number()) %>%
select(post_id, hilton, becerra, both), by = "post_id") %>%
mutate(positive = coalesce(positive, 0L),
negative = coalesce(negative, 0L),
net = positive - negative)
candidate_sentiment <- bind_rows(
post_sentiment %>% filter(hilton) %>% mutate(candidate = "Steve Hilton"),
post_sentiment %>% filter(becerra) %>% mutate(candidate = "Xavier Becerra")
) %>%
group_by(candidate) %>%
summarise(
posts_mentioning_candidate = n(),
posts_mentioning_both = sum(both),
positive_word_total = sum(positive),
negative_word_total = sum(negative),
net_word_total = sum(net),
mean_net_words_per_post = mean(net),
.groups = "drop"
)
knitr::kable(candidate_sentiment, digits = 2)
| candidate | posts_mentioning_candidate | posts_mentioning_both | positive_word_total | negative_word_total | net_word_total | mean_net_words_per_post |
|---|---|---|---|---|---|---|
| Steve Hilton | 119 | 36 | 78 | 109 | -31 | -0.26 |
| Xavier Becerra | 37 | 36 | 17 | 22 | -5 | -0.14 |
The bootstrap resamples candidate-related posts with replacement. For each sampled post, candidate mentions are recounted; posts mentioning both candidates count once for each. The interval measures sampling variability within this collected post sample. It does not correct for platform selection, collection design, coordinated posting, or nonrepresentative users.
set.seed(2026)
candidate_posts <- mentions %>% filter(hilton | becerra)
B <- 5000
boot_share <- replicate(B, {
s <- candidate_posts[sample(seq_len(nrow(candidate_posts)),
size = nrow(candidate_posts), replace = TRUE), ]
h <- sum(s$hilton); b <- sum(s$becerra)
if (h + b == 0) NA_real_ else h / (h + b)
})
boot_ci <- quantile(boot_share, c(.025, .5, .975), na.rm = TRUE)
knitr::kable(tibble(
Statistic = c("Replicates", "Random seed", "Hilton share: 2.5th percentile",
"Hilton share: median", "Hilton share: 97.5th percentile"),
Value = c(B, 2026, scales::percent(boot_ci[1], accuracy=.1),
scales::percent(boot_ci[2], accuracy=.1),
scales::percent(boot_ci[3], accuracy=.1))
))
| Statistic | Value |
|---|---|
| Replicates | 5000 |
| Random seed | 2026 |
| Hilton share: 2.5th percentile | 71.5% |
| Hilton share: median | 76.3% |
| Hilton share: 97.5th percentile | 81.4% |
tibble(share = boot_share) %>%
ggplot(aes(x = share)) +
geom_histogram(bins = 35) +
geom_vline(xintercept = boot_ci[c(1,3)], linetype = "dashed") +
scale_x_continuous(labels = scales::percent) +
labs(x = "Bootstrapped Steve Hilton mention share",
y = "Bootstrap replicates",
title = "Bootstrap distribution of candidate mention share")
The companion file hand_label_sample.csv contains 15
reproducibly sampled, distinct candidate-related posts with anonymous
IDs. Label each candidate separately using supportive,
opposed, neutral, or
unclear/not-mentioned. Leave the label blank if the post
does not provide enough context; use neutral only when the
post is genuinely neutral. Do not infer stance from whether a post
merely mentions a candidate.
For a transparent but crude automated comparison, the code below maps a positive Bing net score to supportive, a negative score to opposed, and zero to neutral. This is only a tone-based proxy, not a stance classifier. It reports accuracy only after human labels are supplied.
labels <- readr::read_csv("hand_label_sample.csv", show_col_types = FALSE)
# Join by anonymous text ID only; no author or post identifiers are needed.
# Rebuild safe text-to-score lookup for the sampled excerpts.
score_lookup <- mentions %>%
mutate(post_text_key = str_squish(text)) %>%
select(post_text_key) %>%
distinct() %>%
left_join(
post_sentiment %>%
mutate(post_text_key = str_squish(mentions$text)) %>%
select(post_text_key, sample_net = net),
by = "post_text_key"
)
labels <- labels %>% mutate(post_text_key = str_squish(post_text))
validation_data <- labels %>% left_join(score_lookup, by = "post_text_key") %>%
mutate(auto_tone = case_when(sample_net > 0 ~ "supportive",
sample_net < 0 ~ "opposed",
TRUE ~ "neutral"))
# For each candidate, compare only human labels that are not blank.
# Because the tone proxy is not candidate-targeted, interpret accuracy cautiously.
validation_long <- bind_rows(
validation_data %>% transmute(post_id, candidate = "Steve Hilton",
human = human_stance_hilton, predicted = auto_tone),
validation_data %>% transmute(post_id, candidate = "Xavier Becerra",
human = human_stance_becerra, predicted = auto_tone)
) %>%
filter(!is.na(human), human != "", human != "unclear/not-mentioned")
if (nrow(validation_long) > 0) {
cat("Agreement accuracy (tone proxy vs. hand-coded stance):",
scales::percent(mean(validation_long$human == validation_long$predicted), accuracy = .1))
knitr::kable(validation_long %>% count(candidate, human, predicted))
} else {
cat("Enter human labels in hand_label_sample.csv and rerun this report to calculate validation accuracy.")
}
## Enter human labels in hand_label_sample.csv and rerun this report to calculate validation accuracy.
Bluesky users are not a probability sample of California voters. People who post about politics may be more politically engaged, more partisan, more active online, or more motivated to express strong opinions than voters generally. The dataset is small and its collection method and query coverage may favor particular sources or communities. More posts about a candidate indicate more attention, not necessarily more support; criticism, news coverage, mockery, and reposted headlines all increase volume.
Bing sentiment captures positive and negative word choice, not sarcasm, irony, quotation, target, or political stance. A post can use negative language while supporting a candidate (for example, criticizing an opponent), or positive language while opposing a candidate (for example, describing an opponent’s claim sarcastically). Posts mentioning both candidates are especially difficult to attribute. Missing language tags, Spanish/Chinese posts, links, truncated text, duplicated syndicated headlines, and inconsistent timestamps further limit inference.
The mention-share “vote share” is therefore best understood as a descriptive proxy for attention in this dataset. It should not be presented as a forecast of the actual election result.
Place hilton.csv, this .Rmd file, and the
completed hand_label_sample.csv in the same RStudio
project. Install any missing packages with
install.packages(c("tidyverse","tidytext","knitr","rmarkdown","scales")),
then knit to HTML. Upload the HTML to RPubs using the
Publish button in RStudio. Confirm that the rendered
report contains no usernames or other direct identifiers before
publishing.