1 Research question

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.

2 Files and collection

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

3 Exact AI prompt

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.

4 Cleaning the data

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

5 Sentiment scoring with tidytext and Bing

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

6 Results

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

7 Supplied summary: a separate sample

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.")
}
Previously supplied summary; not recalculated from these workbooks
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

8 Tentative class forecast

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.

9 Limitations and conclusion

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.

10 Reproducibility and publishing

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