FlyRank ML Internship · Capstone · Lane 2 — Refresh & Content Opportunity Scoring

Which page should an editor open first?

Ranking 18,010 pages by their risk of losing search visibility over the next 30 days — and finding out, along the way, that my own baseline rule could not rank anything at all.

18,010pages ranked, 30 pseudonymous clients
0.616base rate — share that actually declined
0.88precision@50, out-of-fold (rule: 0.74)
0.500ROC-AUC of my own Week-4 rule

Abstract

When an editorial team can review only a few dozen pages a week, the real question is not which pages are bad but which page to open first. Using the public anonymized FlyRank starter slice (30,000 pages × 44 columns, 32 pseudonymous clients, one trailing 90-day snapshot), I split the 90-day window into three exact 30-day sub-windows and kept only signals knowable at the decision point, then ranked pages by their risk of losing more than 20% of search impressions over the next 30 days. A logistic regression over 24 such features, scored out-of-fold under a client-grouped split, placed 88% real decliners in its top 50 against 74% for a transparent rule baseline and a 61.6% base rate — while the rule I had built in Week 4 turned out to score just 12 of 18,010 pages above zero and therefore rank nothing at all (ROC-AUC 0.500). Adding the window-overlapping columns back lifted precision@200 to 0.995, a beautiful number that is a leakage confession rather than a result. A whole-pipeline permutation test (p = 0.005) and a client-clustered bootstrap indicate the ranking advantage is not chance and holds inside 14 of 18 clients — while the precision@50 margin on its own sits within noise. The deliverable is an 18,010-row action queue with reason codes, a suggested action and a confidence label, built to support the order of human review — not to promise that refreshing a page recovers its traffic.

01The decision this supports

Introduction

An editor opens Monday morning with capacity for about fifty pages and a portfolio of tens of thousands. Nothing in that situation is improved by a list of everything that looks unhealthy; what is missing is an order. That is the decision this work supports, and it is the reason the metric here is precision@K at the team's real capacity rather than accuracy.

The unit is one pseudonymous content item in one snapshot. The output is a ranked queue where every row carries a risk score, the reasons behind it, a suggested action and a confidence label. The action a human takes is to review — check business value, check whether a sibling page absorbed the demand, check seasonality — and then refresh, fix metadata, protect or simply watch.

The costs of being wrong are asymmetric. A false alarm burns an editor's hour and disturbs a page that was stable. A miss lets a page with real demand decay quietly until recovery is expensive. Since the top of the list is what gets acted on, the top of the list is what gets measured.

A threshold rule can select a group of pages. It cannot put them in order — and the order is the entire decision.

02The data, and the split that made it a prediction problem

Data

Everything here runs on data/raw/content_refresh_anonymized.csv, the public anonymized starter slice of the FlyRank ML Internship dataset: 30,000 content items across 32 pseudonymous clients, with Google Search Console and GA4 metrics aggregated over one rolling 90-day window. The full ~79M-row warehouse release is gated and requires a token this run did not have; the feature contract is source-agnostic, but the published numbers come from the public slice. That is a limitation, stated rather than hidden.

The snapshot is cross-sectional — one row per page, no time axis — which normally rules out an honest forward-looking task. It does not, because the 90-day totals decompose exactly into three 30-day sub-windows (verified: maximum reconstruction error is zero). That decomposition is what turns a snapshot into a decision point.

How one 90-day snapshot becomes a forward-looking prediction task The 90-day window splits exactly into three 30-day sub-windows. The first two are knowable at the decision point and become features; the last one holds the label. Any 90-day aggregate spans all three and is therefore excluded. THE 90-DAY SNAPSHOT, SPLIT first30 days 61–90 back prev30 days 31–60 back last30 days 1–30 back DECISION POINT Knowable now → the 24 features The outcome → the label WHY THE 90-DAY COLUMNS HAD TO GO impressions_90d · ctr · avg_position · engagement_rate · days_with_impressions Every 90-day aggregate covers all three sub-windows — it already contains the answer. So does days_since_last_update: 68.3% of pages were updated inside the outcome window.
The whole method in one picture. Stand at the boundary. Everything to the left is knowable; everything to the right is the outcome being predicted. Any column that spans the boundary — every 90-day total, every 90-day rate — already contains the answer.

What was excluded, and why

The exclusion that hurt

days_since_last_update is exactly the signal my Week-4 rule leaned on — and it is measured at export time, so it reports edits that happened inside the outcome window. In this slice 20,480 of 30,000 pages (68.3%) were updated inside that window. The column knows the future, so it had to go, and losing it is a large part of why the honest numbers on this page are modest.

Population

Pages with fewer than 100 impressions in the prior 30 days are excluded: below that floor a “decline” is noise, and no editor should spend an hour on it. That drops 11,990 pages and leaves 18,010 pages across 30 clients. The filter reads only pre-decision columns, so it carries no outcome information — but it does narrow what these results can speak about.

Public safety. No client names, domains, URLs, page titles or search queries appear anywhere in this work — only pseudonymous IDs and aggregates.

03Method: 24 features that existed before the outcome

Methodology

Label. A page is labelled declined when its impressions over the most recent 30 days fell more than 20% against the previous 30 days — the product's own definition of trend_direction == "down". Base rate: 61.6%.

Features. Twenty-four columns, all knowable at the decision point: demand level in the two visible windows (log impressions, clicks, sessions), the momentum already visible between them, prior-window click-through and how it shifted, content age at the decision point, article length with a measured/not-measured flag, and keyword context (search volume, competition, CPC) with its own flag. Missingness in this dataset follows content_type, so a blind fillna(0) would quietly encode content type into the model — flags first, then fill.

Baselines. Two, both frozen before modelling started. The Week-4 rule, kept exactly as written (stale × visible × impressions, reading two contaminated columns), and a legal rewrite of the same idea using only decision-time columns: a page is worth reviewing first if it already lost ground between the two visible windows and still carries demand, with a small penalty for below-median click-through. No fitted weights in either.

Validation. GroupKFold(5) grouped by client_id, with every metric computed out-of-fold. Pages from one client share hidden character — same site, same editorial team — so a random split lets a model memorise the client and call it skill. Measured here, that memorisation is worth +0.0421 PR-AUC: 0.7604 on a random row split versus 0.7183 grouped by client.

Leakage tests. Three, all in the audit notebook. Deliberately adding trend_pct pushes ROC-AUC to 0.9997 — proof the harness can see leakage when it exists. Train-with versus train-without on the window-spanning group is reported below as its own result. A scan of the final feature set for product flags and IDs comes back clean. (My first version of that scan used substring matching and produced false positives on the legal prior_*_trend columns — the check was fixed to match exact names.)

04Results: a modest, real win — and one loud failure

Results

MethodBase rateP@20P@50P@200Lift@50PR-AUCROC-AUC
Random ordering floor0.6160.7000.5800.6350.94×0.61690.4980
Week-4 rule contaminated0.6160.8500.7200.6151.17×0.61590.5004
Rule baseline decision-time columns0.6160.9000.7400.7301.20×0.66760.5621
Logistic regression shipped0.6160.9000.8800.8151.43×0.71830.6406
Random forest0.6160.7500.8000.7901.30×0.71080.6366
Random forest + window-overlapping columns leaky0.6161.0001.0000.9951.62×0.77490.6865

All metrics out-of-fold under GroupKFold(5) grouped by client. Base rate 0.6155 — 11,086 of 18,010 pages declined. The leaky row is shown to be disowned, not claimed.

At every K a real team could work through, the model's queue sits above both rules. The Week-4 rule converges on the base rate: past its twelve flagged pages, its ordering carries no information.

The failure worth more than the win

The rule I built in Week 4 — old content with high impressions — scores 12 of 18,010 pages above zero. Eleven of those twelve did decline, so it is precise on the sliver it can see. For the other 17,998 pages every score ties at zero, which means their order is arbitrary. That is what ROC-AUC 0.500 and precision@200 falling back to the base rate actually describe. The rule is a serviceable filter and not a ranker, and a review queue needs the second.

What leakage would have bought

Put the window-spanning columns back and the same random forest reports precision@50 of 1.000 and precision@200 of 0.995. Nothing improved except the appearance of the result: impressions_90d minus the two visible windows is the label window. This number is on the page to be disowned.

Same model, different feature set. The top bar is what a contaminated feature set looks like from the outside — indistinguishable from excellent work until someone lines the windows up.

How steady is the win?

Across five different client holdouts, PR-AUC moves between 0.63 and 0.7021 (mean 0.6678 ± 0.0258) and precision@50 sits at 0.816 ± 0.048. With thirty clients, that range is the honest result — not the single 0.7183. Simplicity also won on merit: the logistic regression edged the random forest (0.7183 vs 0.7108), so the readable model is the one that ships.

Is the win bigger than the noise?

Thirty clients, one of which holds 31.6% of the pages, is a small sample, and a point estimate on a small sample is a claim, not evidence. So the headline was attacked six ways, and each answer was committed as a receipt before this paragraph was written.

Attack on the resultWhat came backVerdict
Whole-pipeline permutation test — retrained 200× on shuffled labels, globally and within each clientReal PR-AUC lift 1.167; the null never passed 1.026. p = 0.005 under both nulls, the floor for 200 permutationssignal is real
Client-clustered paired bootstrap, model − rule, whole rankingROC-AUC +0.079 [+0.004, +0.118]
PR-AUC +0.051 [-0.004, +0.086]
holds
Same bootstrap, precision@50 alone+0.14 [-0.06, +0.24]not secure alone
Inside each client (18 with ≥50 pages)Model beats the rule in 14 of 18 (sign test p = 0.015)not a pooling artefact
Largest client (31.6% of pages) removed / alonePR-AUC 0.740 vs 0.714 without it; 0.635 vs 0.549 inside itholds
4 demand floors × 3 decline thresholdsPR-AUC: model ahead in 12 of 12 settings. Precision@50: 9 wins, 2 ties, 1 lossholds
Gradient-boosting challengerPR-AUC 0.697 vs 0.718; gap +0.021 [-0.008, +0.041]complexity not earned

1000 resamples of the 30 clients with replacement; percentile 95% intervals; every model is scored on the same resample, so gaps are paired. Every row is a receipt in work/outputs/evidence_audit.json, written by work/scripts/evidence_audit.py.

Chance does not get close. The whole pipeline — features, grouped folds, model — retrained on shuffled labels 200 times per null. Shuffling within each client keeps every client's own decline rate, so a model that had only learned “which client declines more” would pass it. It does not.
The honest shape of the win. Against the rule, the whole-ranking metrics sit clear of zero or nearly so; precision@20 and precision@50 do not. “44 of the top 50” against 37 is what happened on this snapshot, not a guarantee for the next one. Against gradient boosting every interval straddles zero — extra complexity bought nothing, so the readable model ships.
Precision at every review capacity, with client-bootstrap bands. The model's line sits above the rule's from K = 30 onward; the bands overlap, which is what thirty clients buys.
Inside each client, not just in the pool. A pooled ranking can win by sorting clients rather than pages. Within-client ROC-AUC rules that out: the model is ahead in 14 of 18 clients, including all four of the largest. Clients are shown by size rank, never by ID.

Can the score be read as a probability?

Mostly, and not at the top. The Brier score beats always predicting the base rate (skill +0.052), the expected calibration error is 0.028, and the middle deciles sit on the diagonal. But the calibration slope is 0.78: the riskiest tenth averages a predicted 0.84 while 0.76 actually declined. The queue therefore prints a confidence label rather than a percentage, and the labels behave as ordered — high 77.7% declined, medium 68.7%, low 48.5%.

Well calibrated in the middle, overconfident at the top. Read the score as a rank.
Predicted-risk decileObserved decline rateLift vs basen
142.7%0.69×1,801
244.8%0.73×1,801
350.5%0.82×1,801
456.1%0.91×1,801
562.0%1.01×1,801
666.1%1.07×1,801
771.0%1.15×1,801
872.1%1.17×1,801
974.4%1.21×1,801
1075.9%1.23×1,801

Decile 10 is the riskiest. Out-of-fold scores.

Real signal, no sharp boundary. The riskiest decile declined at 75.9% against 42.7% in the safest — a 1.78× spread. The middle deciles sit near the base rate, which is exactly where this queue should not be trusted.

What the model leans on

FeaturePermutation importanceStd. coefficientReading (observed, directional)
log_clicks_prev300.0773-0.583Pages that convert impressions into clicks are associated with lower decline risk
content_age_days_at_decision0.0423-0.395In this slice, older pages declined less — not more
log_impr_first300.0400-0.445Sustained earlier demand is associated with stability
log_sessions_prev300.0234-0.177Site-side engagement points the same way as clicks
log_impr_prev300.0208+0.910Holding clicks fixed, a recent impression spike is associated with higher risk
prior_impr_trend_pct0.0136+0.229Momentum already visible before the decision point carries forward

Importance = drop in PR-AUC when the column is shuffled, measured on held-out clients. These traffic columns are correlated with one another, so the coefficients read as direction, not as isolated effects. Article length sits far below this table at ≈ 0.0009.

Prior-window clicks dominate; article length barely registers. The March 2026 FlyRank study observed growing pages at 3.2K words against 2.3K for declining ones. In this forward-looking framing the gap nearly vanishes — 3,483 versus 3,432 words (n = 12,785). Not a contradiction: one measures an observed state, the other measures predictive power.

Holding clicks fixed, a recent impression spike is associated with higher decline risk. The clearest early warning in this data is exposure that did not convert.

Three signals, checked one at a time

The model is not the only way to read this data, and simple grouped comparisons are easier for an editor to sanity-check. Three, each with the verdict the numbers actually supported:

05What this work cannot claim

Limitations & honest framing

  1. One snapshot, not a time series

    I reconstruct one decision point from one 90-day window. Nothing here tests whether the ranking holds across months.

  2. Decline, seasonality, consolidation and noise look identical here

    A page marked “declining” may simply be handing demand to a sibling page, or following an annual cycle.

  3. The results speak for 18,010 pages, not 30,000

    The ≥100-impression demand floor drops 11,990 low-volume pages. For those, this system is silent — not reassuring.

  4. Thirty clients is a small sample

    PR-AUC moves between 0.63 and 0.7021 depending on which clients are held out. That range is the result, not the single 0.7183. The client-bootstrap interval on the precision@50 gap over the rule, -0.06 to +0.24, crosses zero.

  5. Search position could not be used at all

    The only position column is a 90-day mean that spans the outcome window. A central SEO signal had to be dropped; the warehouse's daily table would restore it.

  6. Content freshness could not be used either

    68.3% of pages were updated inside the outcome window, so days_since_last_update knows the future.

  7. Public slice, not the full warehouse

    The gated ~79M-row release needs a token this run did not have. The feature contract is source-agnostic; the published numbers are from the public slice.

  8. The label is a product definition

    “Down” means a >20% impression drop because that is how the product defines it. Move the threshold and the population moves with it.

  9. No causal evidence exists in this work

    No experiment, no control group, no causal design. This work cannot say that refreshing a page recovers its traffic, and says nothing whatsoever about Google's algorithm.

Language discipline

Every finding on this page is written as observed, measured, associated with or decision-support. There is no experiment behind this work, so the words proves, causes and will increase do not appear in any conclusion — and nothing here describes, tests or reverse-engineers Google's ranking algorithm.

06What to do first

Ranked recommendations

  1. 1Work the model queue instead of an age or freshness sort — start with the top 50.

    EvidencePrecision@50 0.88 against 0.74 for the transparent rule and a 0.616 base rate. In the first 50 hours of review, roughly 44 of 50 land on real decliners instead of 31.

    Wrong ifThe portfolio changes shape, or the period sits inside a strong seasonal swing.

  2. 2Use “old and high-impression” as a filter, never as a ranker.

    EvidenceThat rule scores 12 of 18,010 pages above zero (11 of them declined) and is silent on the rest — every remaining page ties at zero, so its order is arbitrary. ROC-AUC 0.500.

    Wrong ifOn the full warehouse, a properly aligned freshness column could regain its power.

  3. 3Treat “impressions spiking without clicks” as the early-warning sign.

    EvidencePrior-window clicks is the top permutation-importance feature (0.077 PR-AUC) with a negative coefficient (−0.583), while prior-window impressions carries the largest positive one (+0.910). Grouped directly: among pages with ≥500 impressions, the lowest click-through quartile declined at 73.6% against 48.9% in the highest (n ≈ 2,780 per quartile).

    Wrong ifThe page deliberately serves an on-SERP informational need, where low CTR is normal.

  4. 4Split the work by reason code instead of calling everything a refresh.

    EvidenceOf the top 200: 97 refresh reviews, 62 metadata/intent reviews (clicks falling while impressions hold — a title job, not a rewrite), 27 protect-and-refresh, 5 protect-and-watch on pages that are gaining, and 9 monitor-only.

    Wrong ifThe thresholds behind the reason codes move — they are policy choices, not constants.

  5. 5Keep a human gate in front of every action, and automate nothing.

    EvidenceThe model never reads page content, cannot see a sibling page absorbing demand, and cannot separate seasonality from decline. 9 of the top 200 carry only model_pattern_only and are downgraded to monitor by design.

    Wrong ifNothing — this is a design boundary, not a tunable parameter.

The four states, and which of them the engine can act on

The lane asks for pages scored as growing, declining, recovering or worth review. Those states sit on opposite sides of the decision point, so the queue carries them in two separate columns and never mixes them:

Why recovery can be reported but not predicted

Spotting a recovery before it happens needs two consecutive deltas — a fall, then a rise. This slice exposes only two pre-decision windows, which is a single delta. So the engine can tell you that 1,063 pages recovered, and it cannot tell you in advance which ones will. Fixing that needs the warehouse's daily table, not a better model.

Slice of the queuedecliningrecoveringgrowingstable
Top 5088%6%4%2%
Top 20082%5%4%10%
Top 50078%5%3%13%
Top 1,00077%6%3%14%
Whole population62%6%7%25%

What actually happened to the pages the engine ranked highest. Read the top row against the bottom one: the queue concentrates decline and pushes growth out.

The ranking separates decline from growth, gradually. Real decline climbs from 43% in the safest decile to 76% in the riskiest, while growing pages fall from 11% to 3%. The engine is not merely surfacing big pages — though the slope is gentle, which is the same "no sharp boundary" caveat as before.

The queue itself

#Page (pseudonymous)RiskActionState at decisionReason codesConfidenceImpr. prev 30dPrior trend
1content_d4786a3f75440.963review_for_refreshvisibility_slipping low_ctr_high_exposurehigh2,775-23%
2content_d8fe64aaac6b0.956review_for_refreshvisibility_slipping low_ctr_high_exposuremedium850-48%
3content_ec5e5f49929b0.949review_for_refreshvisibility_slipping low_ctr_high_exposurehigh1,952-53%
4content_7760048e16090.948review_metadata_and_intentlow_ctr_high_exposurehigh1,597-8%
5content_900f828b925b0.947review_metadata_and_intentlow_ctr_high_exposure growing_with_demandmedium927+26%
6content_82f523ddc57e0.945review_for_refreshvisibility_slipping low_ctr_high_exposurehigh2,824-44%
7content_e9cc09b619e70.943review_metadata_and_intentclicks_falling_while_visible low_ctr_high_exposurehigh1,341-15%
8content_b23fa9e12c1d0.942review_for_refreshvisibility_slippingmedium143-84%

The first eight rows of an 18,010-row queue. IDs are pseudonyms — no client, domain, URL or query appears anywhere in this work.

The top 200 is not one kind of work. Refreshing content, fixing titles and descriptions, and protecting high-demand pages are three different jobs for three different people — which is why the queue carries reason codes rather than a single score.

Confidence: medium at the top of the queue (precision@50 holds at 0.816 ± 0.048 across client holdouts), low through the middle deciles, and out of scope entirely below the 100-impression floor, where the system stays silent rather than reassuring.

07Run it yourself

Reproducibility

Every number on this page is produced by one script and stored as JSON in the repository, and this page is generated from those files — so the paper cannot drift away from the pipeline.

git clone https://github.com/tu-h-nguyn/FlyRank-Machine-Learning-Internship
cd FlyRank-Machine-Learning-Internship
pip install -r requirements.txt

python work/scripts/capstone_pipeline.py   # metrics + figures  (~25s, seed 42)
python work/scripts/build_paper.py         # rebuilds this page

Seed 42 throughout; GroupKFold is deterministic. Python 3.11, pandas 2.x, scikit-learn 1.7.x. Random-forest numbers can move a point or two between scikit-learn versions; the shipped logistic regression is stable. The 18,010-row queue CSV is deliberately not committed — repository policy blocks dataset files — and is regenerated by the command above. No sealed-holdout claim is made anywhere: these are out-of-fold cross-validation numbers on one snapshot.

08Acknowledgments & data credit

Credit

Built on the FlyRank ML Internship dataset — real, anonymized search and content performance data made available for this internship: flyrank.ai. The March 2026 FlyRank research paper is the reference audited in the validation notebook; the questions raised there are methodological, offered in the spirit of making the claims stronger rather than correcting them.