Machine learning notebook / August 2026

The tags knew the answer

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.

Rated titles
789
Tag vocabulary
14
Poisoned AUC
0.816
Usable share
0

01 What the tagger was shown

Here is the payload the drafting call receives, from the live route that writes the tags. Four lines. Three of them are fine.

Patriot (2015, tv)
Genres: Comedy, Drama
TMDB synopsis: To prevent Iran from going nuclear, intelligence officer John Tavner must forgo all safety nets and assume…
Connor's rating: 5/5

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%:

All 14 tags, every rated title, August 2026
tagtitlesmean ratingliked
mind-bending644.2090.6%
prestige2863.9373.8%
high-concept1143.8468.4%
heavy1723.8368.0%
dark2083.8168.8%
tense2933.8066.2%
funny2213.6963.8%
gritty2613.6559.4%
absurdist1113.6464.9%
feel-good1373.5753.3%
nostalgic1903.4545.3%
cozy953.4446.3%
campy2543.2039.4%
background-watchable1993.0524.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.

02 The second, unrelated kill

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.

Two independent disqualifiers: written knowing the answer, and absent where the model has to operate. Either one alone kills the feature. Finding both in the first feature I reached for is what made me start checking provenance before checking numbers.

03 What training on them would have produced

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.

What the leak is worth. Rated titles only, where the tags exist
featurescolumnsROC AUC
TMDB metadata, the legitimate set280.766 ± .038
vibe tags alone140.766 ± .041
both together420.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.

How I know it is mostly echo

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.

04 Why validation cannot save you

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.