Forecast and main finding

Using the prediction rule defined before scoring, my classroom forecast selects Steve Hilton with 66.7% of the estimated two-candidate vote share, compared with 33.3% for Xavier Becerra. These percentages are shares of positive-scoring eligible posts, not measured voter preferences. The 95% author-bootstrap interval for Hilton is 51.3%–82.1%.

However, the sentiment classifier agreed with the reviewed stance labels on only 2 of 20 posts (10%). This result makes the forecast a weak measure of electoral support. I report the rule’s output for the assignment, but I would not rely on it as an actual election forecast.

Data collection

On October 29, 2026, I collected public Bluesky posts using R and the atrrr package. I searched the exact phrases Xavier Becerra and Steve Hilton, using English-language results sorted by latest. Both searches used the same UTC window: October 22 at 00:00 through, but not including, October 29 at 00:00. This covers October 22–28 in UTC, rather than California local calendar days. The requested maximum was 1,000 results per search; neither search reached that cap.

The searches returned 175 Becerra rows and 149 Hilton rows. Search matches can come from linked previews or other attached content, so a returned result does not necessarily name the candidate in its text. The API results are a convenience sample of searchable posts, not a random sample of California voters or a complete census of Bluesky discussion. A post found in both searches can appear once under each candidate; candidate-row totals therefore are not counts of distinct people or necessarily distinct posts.

Cleaning audit

I preserved the original collection and removed blank text, duplicate post IDs within each candidate search, and repeated text from the same author within each search. Text was lowercased and whitespace standardized for the repeated-text check. That check does not remove every near-duplicate or identify automated accounts.

Cleaning stage Becerra remaining Hilton remaining
Raw search results 175 149
After removing blank text 175 148
After removing duplicate post IDs 175 148
After checking unrelated Hilton matches 175 148
After removing repeated author text 119 146

The unrelated-Hilton filter searched for Paris Hilton, Hilton Hotel/Hotels, and Hilton Honors references, excluding Hilton-search rows with those terms when the text did not also explicitly name Steve Hilton. It removed zero rows. Using full-name search phrases had already reduced this ambiguity. A zero count does not establish that every remaining post was relevant.

I then flagged additional problems:

Flag or eligibility count Becerra Hilton
Cleaned candidate rows 119 146
Missing post dates 7 19
Nonmissing but unparseable dates 0 0
Text does not name assigned surname 33 35
Text names both candidate surnames 18 20
Eligible for main forecast 68 91

These flag counts can overlap and should not simply be added. The missing-date rows remained in the cleaned dataset because collection relied on the API’s date filters; their dates could not be independently verified from the export. Main-forecast eligibility required the assigned candidate’s surname in the text and absence of the other surname. Missing dates were flagged rather than used as an additional exclusion. Surname matching is a conservative operational rule, not a complete relevance classifier: it can miss misspellings, handles, and implicit references.

Sentiment method and results

I used tidytext::unnest_tokens() to split each post into words and joined those words to get_sentiments("bing"). Before tokenization, the code removed URLs, common bare-domain links, and @mentions. For each post, the score equals the number of positive Bing word occurrences minus the number of negative occurrences. Repeated words count repeatedly. Scores above zero are positive, scores below zero are negative, and zero scores are separated into no lexicon matches versus balanced positive and negative matches.

All 265 cleaned rows were preserved for descriptive sentiment summaries:

Candidate Posts Positive Negative No matches Balanced matches Net word sentiment
Xavier Becerra 119 25 27 52 15 0
Steve Hilton 146 39 41 49 17 -11

The main forecast used the 159 eligible rows:

Candidate Eligible posts Positive Negative No matches Balanced matches Net word sentiment
Xavier Becerra 68 14 11 31 12 6
Steve Hilton 91 28 24 25 14 -1

The Bing lexicon measures word tone, not necessarily stance toward a candidate. Positive words can refer to an opponent, appear in sarcasm, or describe something unrelated to the candidate. Likewise, a supportive post can contain negative words criticizing another politician. No lexicon matches provide no dictionary evidence of tone; they do not establish a neutral stance. The method does not interpret negation, sarcasm, emojis, or conversational context.

Reviewed-label validation

The code selected 20 random eligible posts without replacement using seed 20261029. The labeling sheet concealed machine scores and labels. The human categories were supportive, opposed, and neutral/unclear, interpreted toward the candidate named in each row.

Labeling disclosure: ChatGPT supplied suggested interpretations before I finalized the labels. This was an AI-assisted reviewed-label exercise, not fully independent human annotation. It therefore does not provide an independent hand-label validation of the kind originally requested. The reported agreement is descriptive for these reviewed labels; a fresh sample labeled independently would provide stronger validation.

For comparison only, positive machine sentiment was mapped to supportive, negative to opposed, and zero to neutral/unclear. This mapping is a proxy, not a claim that sentiment and stance are equivalent.

Reviewed label Machine supportive Machine opposed Machine neutral/unclear Total
Supportive 0 0 4 4
Opposed 3 1 3 7
Neutral/unclear 6 2 1 9
Total 9 3 8 20

Agreement was 2/20 = 10%. None of the four reviewed supportive posts was classified as supportive, and only one of seven opposed posts was classified as opposed. A constant neutral/unclear classifier would agree with 9/20 labels (45%) in this particular sample. The small, AI-assisted sample is not sufficient to estimate population accuracy precisely, but it clearly warns against treating this sentiment output as reliable candidate support.

Prediction rule and bootstrap

Before calculating results, I defined the rule as:

Candidate share = candidate’s positive eligible posts / combined positive eligible posts for both candidates.

There were 42 positive eligible posts: 14 for Becerra and 28 for Hilton. Neutral and negative posts were not in this denominator. The percentages below sum to 100% because this is a two-candidate normalization, not an estimate accounting for turnout, abstention, or other ballot choices.

Candidate Positive eligible posts Classroom vote-share estimate 95% author-bootstrap interval
Xavier Becerra 14 33.3% 17.9%–48.7%
Steve Hilton 28 66.7% 51.3%–82.1%

I ran 2,000 bootstrap replicates using seed 20261030. Each replicate sampled the pooled 109 anonymous authors represented in the eligible dataset with replacement. All eligible posts from each sampled author were retained; authors sampled more than once contributed their posts again. This preserves within-author clustering rather than pretending every post is independent. Candidate groups were not sampled separately.

The intervals are the 2.5th and 97.5th percentiles of valid bootstrap shares. All 2,000 replicates were valid; zero had no positive posts. The code explicitly marks a zero-positive replicate as undefined and would report its exclusion. Hilton led in 98.0% of replicates, Becerra in 1.35%, and 0.65% were ties. These are frequencies under this resampling procedure, not election win probabilities.

The bootstrap quantifies sampling instability within observed authors. It cannot correct errors in sentiment measurement, missing search coverage, correlated accounts, or the mismatch between Bluesky users and the electorate. Even an interval that excludes 50% can surround a biased and invalid vote-share proxy.

Reflection

This project showed me that a clear numerical prediction can still rest on a weak measurement. The rule predicts Hilton, but the 10% agreement with reviewed stance labels means the positive-post count should not be interpreted confidently as support. I would keep the stated rule for transparency rather than change it afterward to produce a preferred winner.

People discussing an election on Bluesky are self-selected. Their locations, ages, political interests, eligibility to vote in California, and likelihood of turning out are not established by this dataset. English-only collection also leaves out discussion in other languages. Without representative sampling or demographic weighting, these posts cannot stand in for all California voters.

Sarcasm and tone were especially important. A post may use favorable words while mocking a candidate, and a genuine endorsement can contain harsh criticism of another politician. More mentions can mean controversy, news coverage, repetitive campaigning, or criticism rather than more support. Several different messages from one account can also amplify apparent activity even after exact-repeat cleaning. For a stronger forecast, I would obtain independent stance labels, assess candidate-specific classification on a larger sample, compare reasonable rules without selecting one simply because of its outcome, and evaluate the approach against representative polling or historical election data.

Exact AI prompt

The following is the exact prompt sent to ChatGPT to request the analysis code. AI also assisted with cleaning suggestions, label interpretation, code troubleshooting, and drafting this report.

Write R code for my Bluesky election analysis using tidyverse,
tidytext, and the Bing sentiment lexicon.

My RStudio project contains candidate_posts_for_analysis.csv,
with columns post_id, candidate, author_id, text, created_at.
There are 265 rows: 119 for Xavier Becerra and 146 for Steve Hilton.
The author_id column is an anonymous account identifier.
Previous cleaning counts are in cleaning_audit.csv.

Save this exact prompt for inclusion in my report. Flag missing
dates, posts not naming their assigned candidate, and posts naming
both candidates. Preserve the original data and report aggregate
counts for these flags.

Remove URLs and @mentions before tokenizing. Score each post as
positive Bing word count minus negative Bing word count. Preserve
posts with no matches, distinguish them from balanced matches,
and summarize results by candidate. Explain that text sentiment
does not necessarily measure stance toward a candidate.

Use only posts naming their assigned candidate and not the other
candidate for the main forecast. Before calculating results, define
the rule as each candidate's positive-post count divided by the
combined positive-post counts. Label this an uncalibrated classroom
vote-share estimate, not a representative voter estimate.

Select 20 random eligible posts using a fixed seed for me to
hand-label as supportive, opposed, or neutral/unclear. Hide machine
labels until I finish. Do not invent my labels or validation accuracy.

After I supply labels, calculate agreement and a confusion matrix.
Bootstrap the forecast by resampling anonymous authors with
replacement 2000 times, retaining all their eligible posts.
Handle zero-positive resamples explicitly. Report only aggregate
results publicly, with no usernames, post text, or profile links.

Method reference

Silge, J., and Robinson, D. Text Mining with R, Chapter 2, Sentiment analysis with tidy data. https://www.tidytextmining.com/sentiment

All reported results are aggregate. This report contains no usernames, individual post text, anonymous author IDs, or profile links.