1 Research question and interpretation

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.

2 Exact AI prompt used

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.

3 Data and cleaning audit

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.

4 Bing sentiment by candidate

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

5 Prediction rule: mention-volume share

Each post contributes one mention to Hilton if it contains “Hilton” and one to Becerra if it contains “Becerra.” A post mentioning both contributes one to each. The resulting percentage is the share of candidate mentions, not the share of voters.

mention_counts <- mentions %>%
  summarise(Hilton = sum(hilton), Becerra = sum(becerra))
mention_total <- sum(mention_counts)
shares <- tibble(
  candidate = c("Steve Hilton", "Xavier Becerra"),
  mentions = c(mention_counts$Hilton, mention_counts$Becerra),
  mention_share = c(mention_counts$Hilton, mention_counts$Becerra) / mention_total
)
winner <- shares$candidate[which.max(shares$mention_share)]
winner_share <- max(shares$mention_share)
knitr::kable(shares %>% mutate(mention_share = scales::percent(mention_share, accuracy = 0.1)))
candidate mentions mention_share
Steve Hilton 119 76.3%
Xavier Becerra 37 23.7%
cat("Mention-volume proxy winner:", winner,
    "with a proxy share of", scales::percent(winner_share, accuracy = 0.1), ".")
## Mention-volume proxy winner: Steve Hilton with a proxy share of 76.3% .

6 Bootstrap uncertainty

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")

7 Human stance validation

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.

8 Reflection and limitations

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.

9 Reproducibility and publishing

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.