Overview

This report uses scraped Bluesky posts about the two candidates in the 2026 California governor’s race, Xavier Becerra (D) and Steve Hilton (R), to measure each candidate’s share of the conversation and to estimate vote share and the likely winner.

Important: Volume share is not vote share. Bluesky users are not a representative sample of California voters, and a candidate can get many posts for negative reasons. For that reason, the final prediction blends the Bluesky results with a poll average, and the Bluesky-only result is shown separately.

The files Steve_Hilton.csv and Xavier_Becerra.csv must be in the same folder as this document.

1. Load and Combine the Data

Some posts appear in both files, so the data is reduced to one row per post (uri).

hilton_raw  <- read_csv("Steve Hilton.csv",   show_col_types = FALSE) %>% mutate(source_file = "hilton")
becerra_raw <- read_csv("Xavier Becerra.csv", show_col_types = FALSE) %>% mutate(source_file = "becerra")

posts <- bind_rows(hilton_raw, becerra_raw) %>%
  group_by(uri) %>%
  summarise(
    across(c(text, author_handle, post_data, ends_with("_count")), first),
    found_in = paste(sort(unique(source_file)), collapse = "+"),
    .groups = "drop"
  )

nrow(posts)
## [1] 347

2. Clean the Fields

  • The created_at column is cut off in the CSV files, so the full timestamp is read from post_data.
  • Posts that never name a candidate (for example, link-only posts) are credited to whichever file they came from.
  • A post naming both candidates is split between them.
posts <- posts %>%
  mutate(
    posted_at  = as.POSIXct(str_extract(post_data, "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}"),
                            format = "%Y-%m-%dT%H:%M:%S", tz = "UTC"),
    text_lc    = str_to_lower(coalesce(text, "")),
    b_text     = str_count(text_lc, "becerra"),
    h_text     = str_count(text_lc, "hilton"),
    b_n = case_when(b_text + h_text > 0 ~ b_text,
                    str_detect(found_in, "becerra") ~ 1L, TRUE ~ 0L),
    h_n = case_when(b_text + h_text > 0 ~ h_text,
                    str_detect(found_in, "hilton") ~ 1L, TRUE ~ 0L),
    w_becerra  = b_n / (b_n + h_n),
    w_hilton   = h_n / (b_n + h_n),
    engagement = like_count + repost_count + reply_count + quote_count
  )

range(posts$posted_at, na.rm = TRUE)
## [1] "2026-10-24 13:23:51 UTC" "2026-10-29 21:04:07 UTC"

3. Sentiment

A simple word-list approach (Bing lexicon) labels each post as positive, negative, or neutral. It cannot detect sarcasm, or whether a negative post is aimed at the candidate or at the opponent.

sentiment_by_post <- posts %>%
  select(uri, text_lc) %>%
  unnest_tokens(word, text_lc) %>%
  inner_join(get_sentiments("bing"), by = "word") %>%
  count(uri, sentiment) %>%
  pivot_wider(names_from = sentiment, values_from = n, values_fill = 0)

for (col in c("positive", "negative")) {
  if (!col %in% names(sentiment_by_post)) sentiment_by_post[[col]] <- 0
}

posts <- posts %>%
  left_join(sentiment_by_post, by = "uri") %>%
  mutate(
    positive      = coalesce(positive, 0),
    negative      = coalesce(negative, 0),
    net_sentiment = positive - negative,
    tone = case_when(net_sentiment > 0 ~ "positive",
                     net_sentiment < 0 ~ "negative",
                     TRUE ~ "neutral")
  )

table(posts$tone)
## 
## negative  neutral positive 
##      126      168       53

4. Volume Share

Share of the conversation measured five ways: posts, mentions, unique authors, engagement, and positive posts.

volume <- tibble(
  candidate      = c("Becerra", "Hilton"),
  post_count     = c(sum(posts$w_becerra), sum(posts$w_hilton)),
  mentions       = c(sum(posts$b_n), sum(posts$h_n)),
  authors        = c(n_distinct(posts$author_handle[posts$b_n > 0]),
                     n_distinct(posts$author_handle[posts$h_n > 0])),
  engagement     = c(sum(posts$engagement * posts$w_becerra),
                     sum(posts$engagement * posts$w_hilton)),
  positive_posts = c(sum(posts$w_becerra * (posts$tone == "positive")),
                     sum(posts$w_hilton  * (posts$tone == "positive")))
) %>%
  mutate(
    post_share       = post_count / sum(post_count),
    mention_share    = mentions / sum(mentions),
    author_share     = authors / sum(authors),
    engagement_share = engagement / sum(engagement),
    positive_share   = positive_posts / sum(positive_posts)
  )

volume %>% mutate(across(where(is.numeric), ~ round(.x, 3)))
volume %>%
  select(candidate, post_share, mention_share, author_share, engagement_share, positive_share) %>%
  pivot_longer(-candidate, names_to = "measure", values_to = "share") %>%
  ggplot(aes(x = measure, y = share, fill = candidate)) +
  geom_col(position = "dodge") +
  scale_y_continuous(labels = scales::percent) +
  labs(title = "Share of Bluesky conversation by candidate", x = NULL, y = "Share") +
  theme_minimal()

posts %>%
  mutate(date = as.Date(posted_at)) %>%
  group_by(date) %>%
  summarise(Becerra = sum(w_becerra), Hilton = sum(w_hilton), .groups = "drop") %>%
  pivot_longer(-date, names_to = "candidate", values_to = "posts") %>%
  ggplot(aes(x = date, y = posts, color = candidate)) +
  geom_line() + geom_point() +
  labs(title = "Bluesky posts per day", x = NULL, y = "Posts") +
  theme_minimal()

5. Social-Only Vote Share Estimate

The estimate is Becerra’s share of all positive posts. A bootstrap (resampling posts 5,000 times) shows how much the number could move from random sampling alone.

pos_b <- posts$w_becerra * (posts$tone == "positive")
pos_h <- posts$w_hilton  * (posts$tone == "positive")

set.seed(123)
boot <- replicate(5000, {
  i <- sample(nrow(posts), replace = TRUE)
  sum(pos_b[i]) / (sum(pos_b[i]) + sum(pos_h[i]))
})

tibble(
  becerra_social_share = mean(boot),
  low_90  = quantile(boot, 0.05),
  high_90 = quantile(boot, 0.95)
) %>% mutate(across(everything(), ~ round(.x, 3)))

6. Poll Average

The same likely-voter polls used in the earlier forecast. Newer and larger polls get more weight. Verify the numbers against the original sources before relying on them.

polls <- tribble(
  ~end_date,    ~n,   ~becerra, ~hilton,
  "2026-10-20",  800, 55, 39,
  "2026-10-19", 1000, 60, 33,
  "2026-10-16",  400, 63, 37,
  "2026-10-14",  909, 55, 35,
  "2026-10-01", 1000, 57, 39,
  "2026-09-28", 2906, 48, 38,
  "2026-09-20", 4512, 58, 33,
  "2026-09-20",  742, 55, 33,
  "2026-09-10", 1103, 60, 38
) %>%
  mutate(
    end_date   = as.Date(end_date),
    days_out   = as.numeric(as.Date("2026-11-03") - end_date),
    weight     = exp(-days_out / 14) * sqrt(n),
    becerra_2p = becerra / (becerra + hilton)
  )

poll_becerra <- weighted.mean(polls$becerra_2p, polls$weight)
round(poll_becerra, 3)
## [1] 0.612

7. Combine Polls and Bluesky, Then Simulate the Election

Two settings here are assumptions, not measured values. Change them to test how the results respond.

  • poll_weight: how much to trust polls versus Bluesky (0 to 1).
  • poll_error: typical polling error in vote share.
poll_weight <- 0.85
poll_error  <- 0.035
n_sims      <- 10000

sim_poll   <- rnorm(n_sims, mean = poll_becerra, sd = poll_error)
sim_social <- sample(boot, n_sims, replace = TRUE)
sim_blend  <- poll_weight * sim_poll + (1 - poll_weight) * sim_social

8. Results

results <- tibble(
  candidate             = c("Becerra (D)", "Hilton (R)"),
  social_positive_share = c(mean(boot), 1 - mean(boot)),
  poll_share            = c(poll_becerra, 1 - poll_becerra),
  predicted_vote_share  = c(mean(sim_blend), 1 - mean(sim_blend)),
  low_90                = c(quantile(sim_blend, 0.05), 1 - quantile(sim_blend, 0.95)),
  high_90               = c(quantile(sim_blend, 0.95), 1 - quantile(sim_blend, 0.05)),
  win_prob              = c(mean(sim_blend > 0.5), mean(sim_blend < 0.5))
)

results %>% mutate(across(where(is.numeric), ~ round(.x, 3)))
cat("Predicted winner (polls + Bluesky):",
    results$candidate[which.max(results$predicted_vote_share)], "\n")
## Predicted winner (polls + Bluesky): Becerra (D)
cat("Winner by Bluesky positive share alone:",
    ifelse(mean(boot) > 0.5, "Becerra (D)", "Hilton (R)"), "\n")
## Winner by Bluesky positive share alone: Hilton (R)
ggplot(tibble(becerra = sim_blend * 100), aes(x = becerra)) +
  geom_histogram(bins = 40, fill = "steelblue", color = "white") +
  geom_vline(xintercept = 50, linetype = "dashed", color = "red") +
  labs(title = "Simulated Becerra two-party vote share",
       x = "Vote share (%)", y = "Simulations") +
  theme_minimal()

Notes and Limitations

  • The data covers a short window (about five days in late October 2026) and roughly 350 unique posts, so the social-only estimate has a wide range.
  • Volume and sentiment from Bluesky are not votes. The poll weight of 85% is an assumption, not a tested value.
  • The word-list sentiment method cannot detect sarcasm or tell who a negative post is aimed at.
  • “Hilton” may also match the hotel brand in some posts.
  • The two-party share ignores undecided voters and minor candidates, so actual percentages on election night will differ.
  • Polls can miss, so the output is a probability, not a certainty.