<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Scikit-Learn on KbWen Blog</title>
    <link>https://www.kbwen.com/tags/scikit-learn/</link>
    <description>KbWen 的個人技術部落格，分享 Python、機器學習、深度學習、資料工程與 AI 開發的學習筆記與實作心得。</description>
    <generator>Hugo</generator>
    <language>zh-tw</language>
    <image>
      <url>https://www.kbwen.com/images/og-default.png</url>
      <title>KbWen Blog</title>
      <link>https://www.kbwen.com/</link>
    </image>
    
    <lastBuildDate>Mon, 20 Jul 2026 11:10:00 +0800</lastBuildDate><atom:link href="https://www.kbwen.com/tags/scikit-learn/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Fitting preprocessing before the split inflates your accuracy</title>
      <link>https://www.kbwen.com/data-leakage-preprocessing/</link>
      <pubDate>Mon, 20 Jul 2026 11:10:00 +0800</pubDate><dc:creator>KbWen</dc:creator>
      <guid>https://www.kbwen.com/data-leakage-preprocessing/</guid>
      <description>Fitting a preprocessing or feature-selection step on the whole dataset before the train/test split leaks the labels and inflates a model&amp;#39;s estimated accuracy. A pure-noise scikit-learn run shows the gap, and the pipeline fix closes it.</description>
      <content:encoded><![CDATA[<blockquote>
<p>Data leakage is using information at fit time that a real deployment would not have yet. The most common source is preprocessing: fit a scaler or a feature selector on the whole dataset, then split, and the transform has already read the rows you later score on. Fit every transform on the training folds only, which a <code>Pipeline</code> does for you because the split happens first.</p>
</blockquote>
<p>A cross-validation score is meant to estimate how a model does on data it has not seen. That estimate quietly breaks when a preprocessing step is fit on the full dataset before the data is split into training and test parts. The scikit-learn docs give the general definition: &ldquo;Data leakage occurs when information that would not be available at prediction time is used when building the model.&rdquo; The effect they name is the one that matters here, because it looks like success — leakage produces &ldquo;overly optimistic performance estimates.&rdquo;</p>
<p>Feature selection is a clean way to see it happen, because the leak is large and easy to reproduce.</p>
<h2 id="a-run-on-pure-noise">A run on pure noise</h2>
<p>Take a dataset with no signal in it at all: 200 rows, 10,000 features drawn from a normal distribution, and labels assigned at random. There is nothing for any model to learn, so the only honest score is chance, about 0.5.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">numpy</span> <span class="k">as</span> <span class="nn">np</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">sklearn.feature_selection</span> <span class="kn">import</span> <span class="n">SelectKBest</span><span class="p">,</span> <span class="n">f_classif</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">sklearn.linear_model</span> <span class="kn">import</span> <span class="n">LogisticRegression</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">sklearn.model_selection</span> <span class="kn">import</span> <span class="n">cross_val_score</span><span class="p">,</span> <span class="n">KFold</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">sklearn.pipeline</span> <span class="kn">import</span> <span class="n">make_pipeline</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">rng</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">random</span><span class="o">.</span><span class="n">RandomState</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">X</span> <span class="o">=</span> <span class="n">rng</span><span class="o">.</span><span class="n">normal</span><span class="p">(</span><span class="n">size</span><span class="o">=</span><span class="p">(</span><span class="mi">200</span><span class="p">,</span> <span class="mi">10000</span><span class="p">))</span>   <span class="c1"># pure noise: 10000 random features</span>
</span></span><span class="line"><span class="cl"><span class="n">y</span> <span class="o">=</span> <span class="n">rng</span><span class="o">.</span><span class="n">randint</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="n">size</span><span class="o">=</span><span class="mi">200</span><span class="p">)</span>     <span class="c1"># random 0/1 labels — NO real signal</span>
</span></span><span class="line"><span class="cl"><span class="n">cv</span> <span class="o">=</span> <span class="n">KFold</span><span class="p">(</span><span class="n">n_splits</span><span class="o">=</span><span class="mi">5</span><span class="p">,</span> <span class="n">shuffle</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">random_state</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># WRONG: pick the 20 &#34;best&#34; features using the whole dataset, THEN cross-validate</span>
</span></span><span class="line"><span class="cl"><span class="n">sel</span> <span class="o">=</span> <span class="n">SelectKBest</span><span class="p">(</span><span class="n">f_classif</span><span class="p">,</span> <span class="n">k</span><span class="o">=</span><span class="mi">20</span><span class="p">)</span><span class="o">.</span><span class="n">fit</span><span class="p">(</span><span class="n">X</span><span class="p">,</span> <span class="n">y</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">leaked</span> <span class="o">=</span> <span class="n">cross_val_score</span><span class="p">(</span><span class="n">LogisticRegression</span><span class="p">(),</span> <span class="n">sel</span><span class="o">.</span><span class="n">transform</span><span class="p">(</span><span class="n">X</span><span class="p">),</span> <span class="n">y</span><span class="p">,</span> <span class="n">cv</span><span class="o">=</span><span class="n">cv</span><span class="p">)</span><span class="o">.</span><span class="n">mean</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># RIGHT: selection lives in the pipeline, re-fit on each training fold only</span>
</span></span><span class="line"><span class="cl"><span class="n">pipe</span> <span class="o">=</span> <span class="n">make_pipeline</span><span class="p">(</span><span class="n">SelectKBest</span><span class="p">(</span><span class="n">f_classif</span><span class="p">,</span> <span class="n">k</span><span class="o">=</span><span class="mi">20</span><span class="p">),</span> <span class="n">LogisticRegression</span><span class="p">())</span>
</span></span><span class="line"><span class="cl"><span class="n">honest</span> <span class="o">=</span> <span class="n">cross_val_score</span><span class="p">(</span><span class="n">pipe</span><span class="p">,</span> <span class="n">X</span><span class="p">,</span> <span class="n">y</span><span class="p">,</span> <span class="n">cv</span><span class="o">=</span><span class="n">cv</span><span class="p">)</span><span class="o">.</span><span class="n">mean</span><span class="p">()</span>
</span></span></code></pre></div><p><code>SelectKBest(f_classif, k=20)</code> scores each of the 10,000 features against the labels — an ANOVA F-statistic per feature — and keeps the 20 with the highest scores. In the first version that <code>.fit(X, y)</code> runs across all 200 rows. Then <code>cross_val_score</code> splits the already-reduced 20-column matrix into five folds, trains a logistic regression on four of them, and scores it on the fifth.</p>
<p>That first version returns about <strong>0.815</strong>. The pipeline version returns about <strong>0.545</strong>. Same noise, same labels, same model, same five folds — the only difference is where the feature selection was fit.</p>
<h2 id="why-the-number-is-inflated">Why the number is inflated</h2>
<p>The selection step in the first version was fit on <code>X</code> and <code>y</code> together, all 200 rows. When a fold later holds out 40 rows to score itself, those 40 rows were already part of choosing the 20 columns. Their labels helped decide which features survived. So the held-out rows are not really held out: the model is scored on rows that already contributed to how the features were picked.</p>
<p>With 10,000 random columns and 200 rows, a handful will line up with the random labels by chance closely enough to look predictive across the whole set. Selecting on the full data keeps exactly those, and every fold inherits them. The 0.815 is the score of a model that found 20 columns correlated with the labels in the same data it is then measured on. There is nothing to learn, and it still scores well above chance.</p>
<h2 id="fit-on-the-training-folds-only">Fit on the training folds only</h2>
<p>The pipeline version fits the same <code>SelectKBest</code> inside <code>make_pipeline</code>, so on each fold the selection is re-fit on that fold&rsquo;s training rows alone and then applied to the held-out rows without ever seeing their labels. The 20 columns are chosen five separate times, from four folds each time, and the fifth fold is scored on columns picked without it. The result lands near chance, which is what pure noise should score.</p>
<p>This is the rule the scikit-learn docs state: &ldquo;Always split the data into train and test subsets first, particularly before any preprocessing steps,&rdquo; and preprocessing &ldquo;transformations are only learnt from the training data.&rdquo; The general rule they give is &ldquo;to never call&rdquo; <code>fit</code> on the test data — and the same holds for the held-out fold during cross-validation. Wrapping the transform in a <code>Pipeline</code> is their recommended way to enforce it, because the pipeline fits on the training part and only transforms the test part. Kaggle&rsquo;s write-up on leakage puts the same failure in terms of the target: it &ldquo;happens when your training data contains information about the target, but similar data will not be available when the model is used for prediction.&rdquo;</p>
<p>Any transform with a <code>fit</code> step can do this, not just feature selection. The size of the leak varies: a supervised selector reads the labels, so it leaks a lot, while something like a <code>StandardScaler</code> reads only the feature columns and usually leaks very little in a setup like this. The habit is the same either way — fitting before the split lets the transform read rows the model is later graded on, so the estimate comes back higher than it should. Fit transforms on the training folds only; put the preprocessing in a pipeline so the split comes first.</p>
<p>That&rsquo;s the whole demonstration — a small script you can run yourself. If I&rsquo;ve got something wrong here, tell me and I&rsquo;ll fix it.</p>
<h2 id="sources">Sources</h2>
<ul>
<li>scikit-learn, <a href="https://scikit-learn.org/stable/common_pitfalls.html#data-leakage">Common pitfalls and recommended practices — Data leakage</a></li>
<li>scikit-learn, <a href="https://scikit-learn.org/stable/modules/cross_validation.html">Cross-validation</a></li>
<li>Kaggle, <a href="https://www.kaggle.com/code/alexisbcook/data-leakage">Data Leakage</a></li>
</ul>
<p>Run with scikit-learn 1.9.0 and numpy 2.4.6; the seeds make it deterministic.</p>
]]></content:encoded>
    </item>
    
  </channel>
</rss>
