The richest feature in my media database is fourteen mood tags written by an LLM. They match my entire legitimate feature set at a fraction of the size. They also cannot be used, and the reason is worth dissecting slowly.
I keep a database of everything I watch, rated 1 to 5. When I rate
something, a background model call drafts a one-line description and picks
two to four mood tags from a fixed vocabulary of fourteen: things like
mind-bending, cozy, prestige,
background-watchable. This was built as a convenience. I did
not want to write blurbs for 789 titles, and the tags make the
recommendation prompts better. Nobody designed them to be training
data.
Then I started building a model to predict my ratings, and the tags were the obvious first feature. They describe exactly the thing taste is made of. In the recommender writeup I gave the two reasons they had to be excluded a few paragraphs. This piece is the longer version, because when I went back and measured what training on them would have produced, the result was worse than I expected, in the specific way that makes this class of bug dangerous.
Here is the payload the drafting call receives, from the live route that writes the tags. Four lines. Three of them are fine.
The model that writes the tags is handed my rating first. That is perfectly sensible for the feature's real job: a one-liner for a title I loved should read differently from one I sat through. For training data it is disqualifying. The tags are not an independent description of the title that happens to correlate with my taste. They are written by something that was told the answer and asked to describe the title in that light.
You can see the answer bleeding through in the aggregate. Every tag's mean rating, against a global mean of 3.55 and a global liked rate (rating 4 or 5) of 54.2%:
| tag | titles | mean rating | liked |
|---|---|---|---|
| mind-bending | 64 | 4.20 | 90.6% |
| prestige | 286 | 3.93 | 73.8% |
| high-concept | 114 | 3.84 | 68.4% |
| heavy | 172 | 3.83 | 68.0% |
| dark | 208 | 3.81 | 68.8% |
| tense | 293 | 3.80 | 66.2% |
| funny | 221 | 3.69 | 63.8% |
| gritty | 261 | 3.65 | 59.4% |
| absurdist | 111 | 3.64 | 64.9% |
| feel-good | 137 | 3.57 | 53.3% |
| nostalgic | 190 | 3.45 | 45.3% |
| cozy | 95 | 3.44 | 46.3% |
| campy | 254 | 3.20 | 39.4% |
| background-watchable | 199 | 3.05 | 24.1% |
A title tagged mind-bending is liked 91% of the time. A
title tagged background-watchable, 24%. Some of that spread is
real, because I genuinely do love mind-bending television. But
background-watchable is not a property of a show. It is a
verdict about how much attention the show deserves, and the model issuing
it had just been told I gave the show a 2. The tag vocabulary contains
words that describe titles and words that quietly describe ratings, and
nothing separates them.
The trap is that you cannot tell how much of the spread is taste and how much is echo. The same data produces both, and the only way to decompose them is to regenerate the tags blind, which is the one experiment that has not been run.
Suppose the leak did not exist. Suppose the tags were written by an honest model that had never seen a rating. They still could not be used, for a reason that has nothing to do with statistics.
Tags get drafted when I rate something. So they exist on 788 of 789 rated titles, and on exactly 0 of the 1,610 unseen titles and 0 of the 342 skipped ones. The model's entire job is scoring titles I have not watched. The feature is present on every row the model trains on and absent on every row it will ever be asked about.
This is the quieter failure mode of the two, and I think the more common one in the wild. Nothing about it is subtle once stated, but nothing in the standard workflow surfaces it either. Cross-validation splits the rated rows against each other, and every rated row has tags, so every fold looks perfect. The absence lives entirely outside the training set, on rows no evaluation ever touches.
The exclusion decision was made in July by reading the drafting prompt,
before any model had been trained on the tags. For this writeup I went
back and ran the experiment properly, because "it would have looked good"
is an assertion and the whole point of this series is measuring things.
Same harness as every other number in this project: random forest,
repeated stratified five-fold, five repeats, predicting
liked = rating >= 4.
| features | columns | ROC AUC |
|---|---|---|
| TMDB metadata, the legitimate set | 28 | 0.766 ± .038 |
| vibe tags alone | 14 | 0.766 ± .041 |
| both together | 42 | 0.816 ± .034 |
Read the first two rows together. Fourteen yes/no bits match the entire legitimate feature set: genres, age, popularity, runtime, and the vote-average acclaim signal that is the strongest single predictor this project has. Then read the third. Adding the tags on top jumps the score by 0.050, which is the largest gain from any feature family ever measured here. Bigger than what acclaim itself bought when it arrived (0.712 to 0.760). The best number this project has ever produced is the one built on the poisoned feature, and every point of the improvement is unusable.
There is a control sitting in the July bake-off. Every rating-blind read of a title's content that this project has measured lands at or below the metadata baseline: TF-IDF over the synopses adds +0.002, which is noise; local embeddings naively concatenated actively lose 0.106; even a frontier model reading the titles with no access to my rating history ties the random forest almost exactly, 0.782 against 0.783. Blind readers of content top out at the baseline. The one content-reader that beats it by five points is the one that was shown the ratings. The gap between those two results is the leak, made visible.
The honest caveat: this is an inference, not a decomposition. The blind readers read synopses and the tagger writes from a fixed vocabulary, so they are not the same instrument. A clean measurement needs rating-blind tags from the same vocabulary, which is a real experiment with 2,700 model calls attached, and until it runs, "how much taste signal do honest vibes carry" stays an open question.
The uncomfortable part of this story is that every standard check passes. The cross-validation is honest, the folds are stratified, the spread is reported, there is no test-set contamination in the usual sense. The 0.816 is a real, reproducible number. It is just a real, reproducible number about a world where I only ever score things after telling the scorer what I thought of them.
Leakage of this kind cannot be caught by validating harder, because the leak inflates the exact number validation produces. The check that works is not statistical. It is a provenance question, asked of each feature before anything trains: what did the process that produced this value know at the time it was produced? For the vote average, the answer is "the opinions of strangers, formed before I watched anything," which is why acclaim is legitimate. For the vibes, the answer is "my rating," and that is the whole audit.
The question costs nothing. It needed one minute and one look at the drafting prompt, against weeks of building. The reason it is easy to skip is that it has to be asked at the point of maximum enthusiasm, when the feature looks richest, and the thing it usually tells you is that the richest-looking feature is rich because it is downstream of the label. Features do not get that informative by accident.
The fix is on the roadmap and is honest work rather than a trick: regenerate the tags with the rating withheld, across all 2,700 titles, into a separate column. That solves both problems at once, since blind tags can be drafted for unseen titles too, and it turns the open question above into a measurable one. Until then the model trains on metadata only, and the tags keep doing the job they were actually built for, which they are good at, and which never required them to be honest.