will-it-bang / README.md
chriscarrollsmith's picture
Add YAML frontmatter to README
b481dbb verified
|
Raw
History Blame Contribute Delete
9.49 kB
---
language: en
license: apache-2.0
library_name: lightgbm
tags:
- twitter
- engagement-prediction
- tabular
- ranking
---
# will-it-bang
> **Can you predict a tweet's engagement from only what's knowable before it's posted?**
This project says yes β€” and shows how. We train a LightGBM model on ~110k tweets from the [Twitter Community Archive](https://www.community-archive.org) to forecast the exposure-adjusted engagement rate of a tweet using:
- The tweet text itself
- Structural features (length, URLs, media, mentions, emoji, etc.)
- The author's pre-tweet history of *replies received*, *quotes received*, and *mentions received* (never dump-time totals)
- Reply/quote parent context from both CA and the X API
- Calendar / platform-era signals
- A semantic embedding of the text (MiniLM β†’ PCA + residual engagement CAV)
- Lexical features (TF-IDF β†’ SVD)
- An **account-level prior** capturing the author's typical engagement rate
**Crucially, nothing leaks the future.** No dump-time favorite/retweet counts as features. No current follower counts (they're snapshots, not histories). No undated likes. The model sees only what was knowable at posting time.
---
## Scoreboard
The primary metric is **within-account Spearman** by stratum β€” given an author's tweets, how well does the model rank them by eventual engagement?
| Metric | Value |
|---|---|
| Within-account Spearman (non-replies, weighted mean) | **0.246** |
| Within-account Spearman (replies, weighted mean) | **0.307** |
| Global Spearman (non-replies) | **0.616** |
| Rate RMSE | **0.588** |
| Training rows / accounts | 88,500 / 868 |
| Validation rows / accounts | 22,125 / 594 |
| Features | 326 |
*Release-v1 checkpoint trained on X-complete full data (~110k / 934 accounts).*
---
## What makes this hard?
**1. Leakage is everywhere.** The naive approach uses a tweet's eventual favorites/retweets as features for that same tweet. Even "just the author's average engagement on previous tweets" is dangerous if that average includes dump-time totals that post-date the candidate. We ban all of that.
**2. The signal is sparse.** Most tweets get little engagement. The model must pick out the ~10% that will outperform an author's own median.
**3. Reply vs. standalone tweets behave differently.** A thoughtful reply to a hot thread has a very different engagement profile than an original post. We model these separately and finally blend them with a within-author ranker.
---
## How it works
### Training pipeline
```
enriched_tweets.parquet (CA dump, ~900MB)
β”‚
β”Œβ”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ prepare.py β”‚ ─── Feature engineering
β”‚ β”‚ - Text β†’ MiniLM β†’ PCA
β”‚ β”‚ - TF-IDF β†’ SVD
β”‚ β”‚ - Ego history (replies/quotes/mentions received)
β”‚ β”‚ - Parent context (CA + X API fallback)
β”‚ β”‚ - Calendar / platform eras
β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”Œβ”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ dataset.parquet β”‚
β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”Œβ”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ train.py β”‚ ─── Training
β”‚ β”‚ - Exposure-rate target (maturity-corrected)
β”‚ β”‚ - Account target encoding (LOO + shrinkage)
β”‚ β”‚ - Residual embedding CAV
β”‚ β”‚ - Asymmetric MSE loss
β”‚ β”‚ - Hybrid level + within-author LambdaRank
β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”Œβ”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ model.txt β”‚ ─── LightGBM booster
│ + transforms │ + PCA, TF-IDF→SVD, residualizer
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
```
### Feature groups
| Group | Description | Count |
|---|---|---|
| **Structure** | text_len, has_url, has_media, n_mentions, caps_ratio, is_reply, etc. | ~20 |
| **Calendar** | hour, day-of-week, month, year, platform era (pre-timeline β†’ musk_late) | ~12 |
| **Ego history** | inbound replies/quotes/mentions (last 7d, 30d, all-time, per-tweet rates), crosses with media/reply flags | ~32 |
| **Parent context** | reply/quote parent MiniLM cosine, parent age, parent's own pre-T replies, same-author flag | ~16 |
| **URL structure** | text urls, domain-level features (twitter, youtube, github, etc.) | ~8 |
| **Account prior** | `account_mean_rate` (shrunken LOO prior, caller-supplied) + `log_account_n` | 2 |
| **Residual CAV** | projection of MiniLM residual onto engagement direction (after regressing out baseline) | 2 |
| **Embedding PCA (MiniLM)** | 96-d PCA of `all-MiniLM-L6-v2` | 96 |
| **Text SVD** | 128-d TF-IDF β†’ TruncatedSVD | 128 |
### The target
```
y = log1p(eng / (min(age_days, 30) + 1))
where eng = favorite_count + retweet_count
```
This is an **exposure rate** β€” the intensity of engagement per unit tweet lifetime, capped at 30 days so dump maturity doesn't distort scores. Retweets (`RT @...`) are excluded from training.
### Leakage rules (non-negotiable)
| Allowed | Banned |
|---|---|
| Pre-T reply/quote/mention arrivals to the author | Dump-time `favorite_count` / `retweet_count` of any tweet |
| Parent tweet content + pre-T parent reply count | Current `all_account.num_followers` (snapshot, leaks future) |
| Calendar / platform era | `account_display_name` (mutable, no as-of-T history) |
| Text and its structure | Undated likes |
| Account-level shrunken mean of *prior* tweets' rates | Username fed into an LM for "reputation" scoring |
---
## What's in this repository
### Inference bundle
| File | Role |
|---|---|
| `model_level.txt` | LightGBM level booster (exposure-rate target) |
| `model_rank_nr.txt` / `model_rank_rp.txt` | Hybrid stratum rankers (non-reply / reply) |
| `emb_pca.joblib` | MiniLM β†’ PCA transform |
| `tfidf.joblib` + `text_svd.joblib` | TF-IDF β†’ TruncatedSVD |
| `res_emb_cav_direction.npy` | Residual engagement CAV direction (384-d) |
| `res_emb_baseline_coef.npy` + `res_emb_baselines.json` | OLS residualizer coefficients |
| `feature_schema.json` | Ordered features, dtypes, defaults, groups |
| `constants.json` | Global priors, encoder ID, exposure parameters |
| `metrics.json` | Full holdout evaluation |
| `predict.py` | Reference scorer (JSON in β†’ score out) |
The bundle excludes: training data, per-account TE maps, emb-kNN train index, CA graph caches. Those are private to the research pipeline.
### Research pipeline (not in this repo)
- `prepare.py` β€” Full feature engineering from CA parquet dump
- `train.py` β€” Model training with all ablation knobs
- `analyze.py` β€” Detailed evaluation with per-stratum breakdowns
- `experiments.md` β€” Complete experiment log (50+ runs with outcomes)
- `AGENTS.md` β€” Technical guidance for the data and leakage rules
---
## Quick start: scoring a tweet
```bash
# Install dependencies
pip install lightgbm scikit-learn numpy pandas joblib pyarrow sentence-transformers
# Download the bundle from Hugging Face
# (you're looking at it β€” clone or download the files above)
# Score a single tweet
cat > tweet.json << 'EOF'
{
"tweet_id": "123456789",
"full_text": "just shipped something i've been working on for months. feels good.",
"is_reply": false,
"has_media": false,
"has_url": false,
"n_mentions": 0,
"n_hashtags": 0,
"account_mean_rate": 0.15,
"log_account_n": 3.5
}
EOF
python predict.py --bundle . --input tweet.json
```
For full context (parent tweets, ego history), populate the remaining schema fields (see `feature_schema.json`). Missing fields default to 0 or the global mean.
### The `account_mean_rate` recipe
This is the most important caller-supplied value. Given the author's earlier tweets with known engagement:
1. For each tweet in the author's history, compute the same exposure rate `y_i`
2. Let `n` = number of historical tweets, `y_bar` = their mean rate
3. Shrink toward the global prior using `m = 20`:
```
account_mean_rate = (n * y_bar + m * global_mean) / (n + m)
log_account_n = log1p(n)
```
If `n = 0`: use `global_mean β‰ˆ 0.151`, `log_account_n = 0`.
---
## Research process
This project ran over 50 experiments across 18 months. The full log is in `experiments.md`, but the headline lessons are:
- **Signal > technique.** The biggest lifts came from denser leakage-safe history (inbound replies/quotes, mentions, parent context from X API) β€” not from embedding discovery, LM labels, or modeling tricks.
- **Metric selection matters.** Within-author ranking (does the model rank an author's own tweets correctly?) is harder and more useful than cross-author level accuracy.
- **Failure modes are open.** Viral non-replies are still under-predicted. Media/image understanding is weak. Reply dyad features don't yet pierce the noise floor.
---
## License
Apache 2.0
## Citation
```bibtex
@misc{will-it-bang,
author = {Chris Carroll Smith},
title = {will-it-bang: Predicting Tweet Engagement from Pre-Publication Features},
year = {2026},
howpublished = {\url{https://huggingface.co/chriscarrollsmith/will-it-bang}}
}
```
---
*Built with the [Twitter Community Archive](https://www.community-archive.org) data, LightGBM, and sentence-transformers/all-MiniLM-L6-v2.*