FlyRank CTR Opportunity Scoring
- Python
- DuckDB
- Scikit-learn
- Random Forest
- Pandas
The Problem
A content reviewer with time for about 50 pages per cycle needs an ordered queue, not 500 unranked candidates. Rankings don't reliably predict clicks: CTR falls unevenly across ranking positions and content types, so a single fixed rule is too rigid.
What I did
I framed it as ranking via binary classification. A page counts as an anomaly if its CTR is below half the median of pages in the same position tier and content type. The model uses five features knowable before review (impressions, average position, content age, word count, engagement rate); I excluded ctr and clicks because ctr is the numerator of the label. I wrote a transparent baseline rule before any model training, then compared Logistic Regression and Random Forest on the same client-holdout split (75/25 by client, seed 42). I used DuckDB on the ~78.8M-row warehouse to verify grain, availability and scale, then modelled on the 30k-row starter slice (22,006 pages with 100+ impressions, 30 clients). Finally I combined model scores with the rule into six reason-coded action archetypes, including a do-not-act group.
What came of it
On 4,610 held-out rows from 8 unseen clients, the Random Forest reached Precision@50 of 0.640 and Precision@20 of 0.850. My hand-written rule scored 0.260 at Precision@50, about chance level (0.286) on unseen clients. A naive random split had reported 0.940 because 26 of 30 clients (87%) appeared in both train and test. I also planted two leaks on purpose to check that the validation harness catches them: reintroducing trend_pct raised in-sample accuracy from 0.6445 to 0.9999 in the warehouse contract stage, and reintroducing ctr raised Precision@50 from 0.640 to 0.980. Both were removed before any reported result.
| Method | Precision@20 | Precision@50 |
|---|---|---|
| Random chance | 0.286 | 0.286 |
| Hand-written rule | 0.350 | 0.260 |
| Logistic Regression | 0.550 | 0.480 |
| Random Forest (final) | 0.850 | 0.640 |
Same held-out test set for every row: 4,610 rows, 8 clients unseen in training, 28.6% positive rate.
Known limitations
- The label is a proxy (CTR below peer median), not proof that a page's metadata is broken. About 1 in 3 of the top-50 flags is not a real anomaly.
- Observational data: nothing here shows that rewriting a title or meta description recovers clicks.
- All 3 false positives in the top-20 sat near the 100–250 impression floor, where CTR estimates are noisiest.
- One dataset, one time slice, 8 test clients. 0.640 describes this data and label definition, not a guarantee elsewhere.
Guardrails
- Never auto-publish a rewritten title or meta description from a model score alone.
- Never treat a high-confidence tier as proof that a rewrite will recover clicks.
- Never act on a page below the 100-impression visibility floor.
- Never bulk-refresh content purely by age; the freshness evidence was mixed.
- Re-check Precision@50 against 0.640 when new data arrives; a drop below roughly 0.50 pauses the queue.
Next time
Add an impression threshold that scales with the volatility of each position tier to filter out small-sample false positives, and validate the label with a controlled before/after test on real metadata edits.

The illusion of 94% accuracy
A naive split looked almost perfect (0.940). The gap to the honest 0.640 is the size of the illusion: the model was partly memorizing client-specific CTR levels instead of learning a pattern that carries to a new client. Reporting the lower number was the most valuable lesson of the project.
