<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: CayllahuaPedro</title>
    <description>The latest articles on DEV Community by CayllahuaPedro (@cayllahuapedro).</description>
    <link>https://dev.to/cayllahuapedro</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4070327%2Fd660f761-d0d1-4e68-b723-e0ea4904c0fd.jpg</url>
      <title>DEV Community: CayllahuaPedro</title>
      <link>https://dev.to/cayllahuapedro</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cayllahuapedro"/>
    <language>en</language>
    <item>
      <title>Three ways my grouped train/test split leaked anyway...</title>
      <dc:creator>CayllahuaPedro</dc:creator>
      <pubDate>Mon, 10 Aug 2026 00:19:47 +0000</pubDate>
      <link>https://dev.to/cayllahuapedro/three-ways-my-grouped-traintest-split-leaked-anyway-3bf9</link>
      <guid>https://dev.to/cayllahuapedro/three-ways-my-grouped-traintest-split-leaked-anyway-3bf9</guid>
      <description>&lt;p&gt;I spent two weeks building a computer vision component to estimate how full a plastic&lt;br&gt;
container is from drone imagery. Translucent white containers, whitish chemical product&lt;br&gt;
inside, shot obliquely from a drone during field inspections.&lt;/p&gt;

&lt;p&gt;The headline number looked good: mean absolute error of 0.055 on fill fraction, Pearson&lt;br&gt;
correlation of 0.97. Then I audited my own evaluation and found that 38 of my 46 test&lt;br&gt;
crops had the same physical container sitting in the training set.&lt;/p&gt;

&lt;p&gt;The arithmetic was fine. The problem was the sentence I had wrapped around it: I was&lt;br&gt;
presenting 0.055 as the error on containers the model had never seen before.&lt;/p&gt;

&lt;p&gt;What makes this worth writing about is that I had the guardrail in place from day one,&lt;br&gt;
and it failed three separate times for three unrelated reasons. Each one is easy to&lt;br&gt;
reproduce in any project that trains on frames extracted from video.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why grouping matters here at all
&lt;/h2&gt;

&lt;p&gt;A drone flies over a site and captures a burst. In my case, 12 frames over 12 seconds.&lt;br&gt;
The same physical container appears in every frame of that burst, from slightly different&lt;br&gt;
angles and distances.&lt;/p&gt;

&lt;p&gt;If you shuffle those crops randomly into train and test, you are asking the model to&lt;br&gt;
recognize a container it has already memorized. The metric you get back describes&lt;br&gt;
interpolation between frames of one burst. It says nothing about a container the model&lt;br&gt;
has never seen.&lt;/p&gt;

&lt;p&gt;This is the most common failure in applied ML and everyone knows about it. Which is&lt;br&gt;
exactly why the next part is worth reading.&lt;/p&gt;
&lt;h2&gt;
  
  
  The guardrail I wrote on day one
&lt;/h2&gt;

&lt;p&gt;My dataset module reads the grouping column from config and does not offer a random&lt;br&gt;
option at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;split&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group_column&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;skid_id&lt;/span&gt;   &lt;span class="c1"&gt;# never random&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The code path for a random split does not exist. You cannot pass a flag to get one.&lt;br&gt;
I wrote it that way on purpose, on the first day, before there was any data to split.&lt;/p&gt;

&lt;p&gt;I still leaked. Three times.&lt;/p&gt;
&lt;h2&gt;
  
  
  Leak 1: the group column held the wrong ID
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;group_column&lt;/code&gt; was set to &lt;code&gt;skid_id&lt;/code&gt;, which is what you want. Group by physical&lt;br&gt;
container.&lt;/p&gt;

&lt;p&gt;The problem was upstream. When the level labels came back from annotation, the &lt;code&gt;skid_id&lt;/code&gt;&lt;br&gt;
column in &lt;code&gt;labels.csv&lt;/code&gt; had been populated with the &lt;strong&gt;scene&lt;/strong&gt; ID, not a container ID. A&lt;br&gt;
scene in my pipeline is a temporal cluster of captures, formed by grouping images taken&lt;br&gt;
less than 120 seconds apart.&lt;/p&gt;

&lt;p&gt;So the config said &lt;code&gt;skid_id&lt;/code&gt;, the code faithfully grouped by whatever was in the column&lt;br&gt;
named &lt;code&gt;skid_id&lt;/code&gt;, and the actual grouping was by scene. Several distinct containers share&lt;br&gt;
a scene. The guardrail did exactly what it was told and the result was still wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson: &lt;code&gt;group_column: skid_id&lt;/code&gt; is a claim about the contents of a column, not a&lt;br&gt;
property the code can enforce.&lt;/strong&gt; Nothing in my pipeline verified that the column named&lt;br&gt;
after physical containers actually distinguished physical containers.&lt;/p&gt;
&lt;h2&gt;
  
  
  Leak 2: the evaluation scripts built their own split
&lt;/h2&gt;

&lt;p&gt;This is the one that stung.&lt;/p&gt;

&lt;p&gt;I wrote a fix for leak 1: a module that clusters crops into physical containers by&lt;br&gt;
spatial proximity of bounding box centers within a scene. Then I moved on, believing the&lt;br&gt;
partitions were now grouped by container.&lt;/p&gt;

&lt;p&gt;When I went back to the code weeks later to correct a report, I checked which modules&lt;br&gt;
imported that clustering function. Exactly one did, and it was the script that renders a&lt;br&gt;
visual verification sheet. The grouping module never entered a partition.&lt;/p&gt;

&lt;p&gt;The actual evaluation code was doing this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nc"&gt;GroupKFold&lt;/span&gt;&lt;span class="p"&gt;(...).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;src&lt;/code&gt; is the source &lt;strong&gt;frame&lt;/strong&gt;. So the split separated frames, and since the same&lt;br&gt;
container appears in all 12 frames of a burst by construction, separating frames&lt;br&gt;
separates nothing.&lt;/p&gt;

&lt;p&gt;The alternative cross-validation I also reported grouped by filename prefix, which has&lt;br&gt;
its own problem (see leak 3). Neither partition ever grouped by container.&lt;/p&gt;

&lt;p&gt;I had written the safe split path, wired the config, removed the unsafe option, and then&lt;br&gt;
my evaluation scripts quietly went around all of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson: a safe path is only safe if it is the only path.&lt;/strong&gt; My &lt;code&gt;dataset&lt;/code&gt; module could&lt;br&gt;
not be misused. My &lt;code&gt;eval&lt;/code&gt; scripts never called it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Leak 3: my cross-site evaluation was not cross-site
&lt;/h2&gt;

&lt;p&gt;Separately from the container problem, I had been reporting a stronger result: train on&lt;br&gt;
one site, evaluate on another. That is the evaluation that actually answers "will this&lt;br&gt;
work somewhere else."&lt;/p&gt;

&lt;p&gt;I inferred the site from the filename prefix. Some images carried the drone's default&lt;br&gt;
camera naming, others carried what looked like a site-specific label. Two prefixes, two&lt;br&gt;
sites, or so I assumed.&lt;/p&gt;

&lt;p&gt;When I finally tabulated the scenes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scene&lt;/th&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;th&gt;Time window&lt;/th&gt;
&lt;th&gt;File prefixes present&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SCENE-01&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;12:03:27 – 12:03:39 (&lt;strong&gt;12 s&lt;/strong&gt;)&lt;/td&gt;
&lt;td&gt;both&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SCENE-02&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;td&gt;12:08:06 – 12:09:12 (66 s)&lt;/td&gt;
&lt;td&gt;both&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SCENE-03&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;12:13:33&lt;/td&gt;
&lt;td&gt;one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SCENE-04&lt;/td&gt;
&lt;td&gt;46&lt;/td&gt;
&lt;td&gt;16:02:41 – 16:02:53 (&lt;strong&gt;12 s&lt;/strong&gt;)&lt;/td&gt;
&lt;td&gt;both&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The drone-default prefix appears in all four scenes, interleaved in time with the other&lt;br&gt;
one. It is a second camera or a second naming convention, and it does not identify a&lt;br&gt;
location.&lt;/p&gt;

&lt;p&gt;My "train on site A, test on site B" evaluation was training on some images from&lt;br&gt;
SCENE-04 and testing on other images from SCENE-04. Same place, same 12 seconds, two&lt;br&gt;
filename conventions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson: if a grouping variable is inferred from a string rather than recorded as&lt;br&gt;
metadata, verify it against something physical before you build a claim on it.&lt;/strong&gt; I now&lt;br&gt;
ask for the site to be recorded at capture time.&lt;/p&gt;

&lt;h2&gt;
  
  
  How bad was it, measured
&lt;/h2&gt;

&lt;p&gt;Once I had a container-level estimate, I counted how many evaluation crops had their&lt;br&gt;
container also present in training:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Partition&lt;/th&gt;
&lt;th&gt;Eval crops whose container is also in training&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GroupKFold by frame&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;38 of 46&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prefix cross-validation, fold A&lt;/td&gt;
&lt;td&gt;12 of 15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prefix cross-validation, fold B&lt;/td&gt;
&lt;td&gt;24 of 31&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And that is a lower bound. The clustering heuristic that produced these container IDs&lt;br&gt;
over-splits: it reported 19 distinct containers where my manual count on the verification&lt;br&gt;
sheet found 10. When the drone drifts mid-burst, the bounding box center of the same&lt;br&gt;
container moves more than the 50-pixel threshold and the heuristic files it as two&lt;br&gt;
containers. With the true count of 10, the leakage is close to total.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part people skip: what survived
&lt;/h2&gt;

&lt;p&gt;When you find something like this, the temptation is to either bury it or to announce&lt;br&gt;
that everything is invalid. Neither is accurate, and being precise about the boundary is&lt;br&gt;
most of the value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What broke.&lt;/strong&gt; One published claim. My validation report stated that the partitions&lt;br&gt;
ruled out evaluating on frames or containers already seen during training. The "frames"&lt;br&gt;
half was true. The "containers" half was false, and it appeared in three separate&lt;br&gt;
documents. It was a false statement rather than an optimistic one, and I corrected it in&lt;br&gt;
all three places.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What held.&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Relative comparisons.&lt;/strong&gt; Every experiment ran on the same contaminated partition, so
the ranking between approaches stays valid. Classical CV baseline versus frozen
backbone plus regression head, small backbone versus base, rectified crops versus
raw. Those comparisons are what I needed to choose a technical direction, and they
survive intact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The feasibility answer.&lt;/strong&gt; The question on the table was whether the visual signal
exists at all. A correlation of 0.97 answers that even if part of it comes from
leakage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The decision not to re-run everything.&lt;/strong&gt; With 46 crops from a single 12-second
window, no partition of this dataset measures generalization. Fixing the grouping
would produce a different wrong number. The honest move is to declare the limitation
and ask for data from other days.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The only thing that died is reading 0.055 as error on unseen containers. It never was&lt;br&gt;
that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that actually prevents a repeat
&lt;/h2&gt;

&lt;p&gt;I did not fix the grouping heuristic. With this dataset it would not change any decision.&lt;/p&gt;

&lt;p&gt;What I changed is how the limitation is stated. The leakage count is now computed inside&lt;br&gt;
the evaluation run and emitted into the header of the generated results table. It is not&lt;br&gt;
written by hand anywhere.&lt;/p&gt;

&lt;p&gt;The reason is specific. The first time I corrected the report, I fixed the section that&lt;br&gt;
explained the cross-site claim and left four other sections that repeated it. A number&lt;br&gt;
that lives only in prose goes stale the moment the code moves, and nobody notices,&lt;br&gt;
because prose does not fail loudly.&lt;/p&gt;

&lt;p&gt;That is now a house rule for the project: &lt;strong&gt;no number in a report may be typed by a&lt;br&gt;
human. It comes out of the evaluation script or it does not appear.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist, if you train on frames
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Print the group sizes and the target variance per group before you trust a split.&lt;/strong&gt;
If your target barely varies inside a fold, a constant predictor wins that fold and
you will conclude there is no signal. I hit this too, and it sent me down a wrong path
for a week.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify that the group column contains what its name says.&lt;/strong&gt; Render a contact sheet
of the groups and look at it. A grouping error is obvious in five seconds visually and
invisible in a metric.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grep for who imports your safe split.&lt;/strong&gt; If your evaluation scripts construct their
own &lt;code&gt;GroupKFold&lt;/code&gt;, the safe path is decoration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never infer a grouping variable from a filename.&lt;/strong&gt; Ask for it as metadata at capture
time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute the leakage count as a metric.&lt;/strong&gt; Number of test samples whose group appears
in training. Emit it next to your headline number, in every run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When a correction invalidates a number, search the whole corpus for it&lt;/strong&gt;, not just
the place where you explain it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Item 5 is the one I would install first in any project. It turns "we grouped correctly"&lt;br&gt;
from something you believe into something you measure, and it costs about fifteen lines&lt;br&gt;
of code.&lt;/p&gt;




</description>
      <category>ai</category>
      <category>computervision</category>
      <category>machinelearning</category>
    </item>
  </channel>
</rss>
