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