<?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: Jason Lau</title>
    <description>The latest articles on DEV Community by Jason Lau (@jasonl888).</description>
    <link>https://dev.to/jasonl888</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%2F4040954%2F51c880e9-8eb1-4df8-8b79-d4ca3b884552.jpg</url>
      <title>DEV Community: Jason Lau</title>
      <link>https://dev.to/jasonl888</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jasonl888"/>
    <language>en</language>
    <item>
      <title>You Don't Need a Math PhD for Data Science — You Need to Stop Skipping the Boring Step</title>
      <dc:creator>Jason Lau</dc:creator>
      <pubDate>Wed, 12 Aug 2026 03:08:07 +0000</pubDate>
      <link>https://dev.to/jasonl888/you-dont-need-a-math-phd-for-data-science-you-need-to-stop-skipping-the-boring-step-2p41</link>
      <guid>https://dev.to/jasonl888/you-dont-need-a-math-phd-for-data-science-you-need-to-stop-skipping-the-boring-step-2p41</guid>
      <description>&lt;p&gt;Ask someone switching into data science what worries them and it's almost always the same thing: &lt;em&gt;do I know enough math?&lt;/em&gt; So they spend weeks on gradient descent derivations and eigenvalues before they've ever wrangled a messy CSV. Then the job starts, a real dataset lands on their desk, and it turns out the model was never the hard part.&lt;/p&gt;

&lt;p&gt;The hard part is everything upstream of it. Framing a vague question precisely enough to answer it. Checking whether the data can actually support that answer. Noticing the assumption that breaks silently instead of throwing an error. None of that needs research-level math — most of it doesn't need statistics at all, just the discipline to check before trusting. It needs the willingness to slow down exactly when it's tempting to jump to the model, because the model is the part that feels like progress.&lt;/p&gt;

&lt;p&gt;Three moments from a fairly ordinary analytics project show what that looks like in practice&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;The question shows up vague, and STAYS vague&lt;br&gt;
A stakeholder asks "are our customers happy?" — which is a request for a feeling, not something a query can return. Happy compared to what: last quarter, a competitor, what they expected at signup? Which customers — everyone, or just the ones active in the last 90 days? Measured by what — a survey score, a churn rate, or support-ticket volume, which could reflect a product bug as easily as unhappiness? The junior move is to pick a metric and start coding. The useful move is rewriting the question until two different analysts would agree on exactly what to compute — same population, same comparison point, same metric — even if their pulls differ by a rounding error. Nobody drills that rewrite explicitly; it doesn't sit next to "regression" and "clustering" in a syllabus, but it decides everything that follows it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Somewhere in the pipeline, rows disappear and NOBODY notices&lt;br&gt;
Say the next step is joining an orders table to a customers table:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;merged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# 48,201  →  44,987
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three thousand orders just vanished — every one with a &lt;code&gt;customer_id&lt;/code&gt; that didn't find a match on the other side. They might be test accounts. They might be guest checkouts. They might be exactly the segment the analysis was supposed to describe, quietly deleted by an inner join before anyone got to look at them. &lt;code&gt;merge()&lt;/code&gt; doesn't warn either way; a silently biased dataset and a clean one produce the exact same "no errors" console output. The only defense is checking row counts before and after every join and being able to explain any drop.&lt;/p&gt;

&lt;p&gt;Data type problems hide the same way. A column named &lt;code&gt;signup_date&lt;/code&gt; sounds unambiguous right up until &lt;code&gt;.dtype&lt;/code&gt; returns &lt;code&gt;object&lt;/code&gt; instead of &lt;code&gt;datetime64&lt;/code&gt;, and a sample of five values turns up &lt;code&gt;'2024-01-15'&lt;/code&gt;, &lt;code&gt;'01/15/2024'&lt;/code&gt;, &lt;code&gt;'2024-15-01'&lt;/code&gt;, and &lt;code&gt;'Jan 15 2024'&lt;/code&gt; sitting in the same column — three years of manual entry, a spreadsheet import, and an API migration, never reconciled. &lt;code&gt;01/15/2024&lt;/code&gt; alone is ambiguous between January 15th and an invalid 15th month, depending on which locale wrote it. Parse that carelessly and some rows land on the wrong date, quietly skewing any monthly bucket or tenure calculation downstream. Catching it costs about five minutes: check &lt;code&gt;.dtype&lt;/code&gt;, then eyeball a random sample of raw values before trusting what the column name implies. Skipping it costs a wrong number that someone else finds later.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A join doesn't just drop rows — it can multiply them, and a plausible-looking total hides it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Say the next step is tagging orders with any promo code they used, to break out revenue by month:&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="n"&gt;revenue_by_month&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;promotions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;on&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;groupby&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;month&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="c1"&gt;# reported total: $1,284,000 — actual total: $1,061,000
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If even a handful of orders matched two promotion rows instead of one — a coupon logged twice, a duplicate entry from a batch re-run — the merge doesn't collapse them, it repeats that order's revenue row once per match. The total comes out $223,000 too high, and nothing about the output looks wrong: no error, no null, just a bigger number than reality. The fix costs nothing statistical — it's checking that the row count and the total after a join still match a number you trust from somewhere else, before either one gets reported.&lt;/p&gt;

&lt;p&gt;None of these three moments required anything past intro-level statistics — most required none at all. Each one involved noticing something easy to miss and choosing to stop and check it rather than push on to the modeling step, which is the part that actually feels like work. That's closer to the real shape of an entry-level data science job than most course outlines suggest — the algorithms are documented, implemented, and one import away in scikit-learn; the dropped rows, the ambiguous date format, and the inflated total are not, and they're what a hiring manager is actually screening for whether or not the posting says so.&lt;/p&gt;

&lt;p&gt;For anyone building toward a first role, that argues for a different practice list than "learn another algorithm":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rewrite a vague business question into one with an unambiguous metric and comparison, before writing any code&lt;/li&gt;
&lt;li&gt;Check row counts before and after every join or filter, and be able to explain any change&lt;/li&gt;
&lt;li&gt;Inspect column dtypes and a sample of raw values before trusting what a column's name implies&lt;/li&gt;
&lt;li&gt;After any join, check that both the row count and a key total still match a number you trust from elsewhere&lt;/li&gt;
&lt;li&gt;Practice stating a finding's limitations in the same breath as the finding itself&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of it is glamorous, and none of it shows up as a line on a certificate. It's also the difference between an analysis that survives a second question from a skeptical stakeholder and one that quietly comes apart the first time someone re-runs it on next month's data.&lt;/p&gt;




&lt;p&gt;SophiArch's &lt;a href="https://lms.sophiarch.com/courses/intro-to-data-science?utm_source=dev.to&amp;amp;utm_medium=referral&amp;amp;utm_campaign=boring-step-data-science"&gt;Intro to Data Science course&lt;/a&gt; is built around exactly this — not another pass through the algorithm list, but deliberate practice framing questions, auditing data, and catching this class of mistake before it reaches someone else's desk.&lt;/p&gt;

</description>
      <category>datascience</category>
      <category>careerchange</category>
      <category>statistics</category>
      <category>datacleaning</category>
    </item>
    <item>
      <title>The Most Dangerous Phrase in Analytics Is "The Data Speaks for Itself"</title>
      <dc:creator>Jason Lau</dc:creator>
      <pubDate>Wed, 05 Aug 2026 08:02:13 +0000</pubDate>
      <link>https://dev.to/jasonl888/the-most-dangerous-phrase-in-analytics-is-the-data-speaks-for-itself-5e51</link>
      <guid>https://dev.to/jasonl888/the-most-dangerous-phrase-in-analytics-is-the-data-speaks-for-itself-5e51</guid>
      <description>&lt;p&gt;&lt;code&gt;The data speaks for itself&lt;/code&gt; is usually offered as a defense. It means: &lt;em&gt;I didn't put my thumb on the scale, I just showed you what's there&lt;/em&gt;. It sounds like humility. In practice it's the opposite — it's a claim that the analyst's choices aren't part of the result, when they always are.&lt;/p&gt;

&lt;p&gt;Data never speaks. Someone chooses the time window, the bucket size, the baseline, which outliers stay in, and which comparison gets drawn on the same axis. Those choices are what turn raw numbers into a chart anyone can read in five seconds. The phrase &lt;code&gt;the data speaks for itself&lt;/code&gt; is what lets those choices pass without anyone — including the analyst — examining them.&lt;/p&gt;

&lt;p&gt;Here's what that looks like in practice, and why naming the choice out loud is the actual discipline.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Truncated axis
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_ylim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# "to make the trend visible"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6rszr7exa1kpcjap861f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6rszr7exa1kpcjap861f.png" alt="Two bar charts of the same conversion-rate data, 82% to 86%. The left chart uses a 0-100 y-axis and the bars look nearly identical. The right chart uses an 80-100 y-axis and the same bars look like a dramatic surge." width="800" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A bar chart of conversion rate moving from 82% to 86% looks like a rounding error on a 0–100 axis. Truncate the y-axis to 80–100 and the same bars look like a dramatic surge. Neither chart is fabricated — every number on it is correct. The difference is entirely in a choice that's invisible unless someone asks what the axis range is and why.&lt;/p&gt;

&lt;p&gt;Truncated axes aren't always wrong — sometimes the full 0–100 range genuinely hides a real, meaningful shift. The point isn't "never truncate." It's that the decision to truncate is doing real interpretive work, and "the data speaks for itself" is exactly the phrase that lets it go unstated.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Baseline nobody picked on purpose
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;growth&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4n61o9jbfgsfs3qb2xc7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4n61o9jbfgsfs3qb2xc7.png" alt="A revenue line chart over two years. An unusual dip in the second half of year one is shaded and labeled " width="800" height="396"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Year-over-year growth looks like a neutral, standard metric. It's a choice: this quarter compared to the same quarter last year, not last quarter, not a trailing average, not the pre-pandemic baseline. If last year had an unusual dip, this year's "growth" is partly an artifact of a low starting point — and the chart won't tell you that, because a percentage doesn't carry its own denominator's history along with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Outliers that got dropped "for clarity"
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;quantile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.99&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;   &lt;span class="c1"&gt;# cuts the long tail
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgdr6pqbunzof0n98agw2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgdr6pqbunzof0n98agw2.png" alt="A histogram of order values. The bulk of orders cluster near zero and are shown in blue. The top 1 percent of orders, shown in orange past the 99th percentile cutoff, is a thin sliver invisible at this scale" width="800" height="396"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Trimming the top 1% before plotting a distribution is often reasonable — a few extreme values can flatten a histogram into something unreadable. But it's a modeling decision about what counts as signal versus noise, made silently, before the reader ever sees the shape of the full data. If those extreme orders are disproportionately from one customer segment, "clarity" just deleted the most important 1% of the story.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Comparison that implies causation without claiming it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"Users who completed onboarding had 3x higher retention."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkdv0ryx1yok8x9ack1sw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkdv0ryx1yok8x9ack1sw.png" alt="Two bar charts side by side. The left chart shows 30-day retention: 63 percent for users who completed onboarding versus 21 percent for those who didn't. The right chart shows self-reported motivation for the same two groups, measured before signup: 7.8 out of 10 for completers versus 4.1 out of 10 for non-completers — a difference that predates onboarding entirely." width="799" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Looking at the chart on the left, every word of this is true and it will be read as &lt;em&gt;onboarding causes retention&lt;/em&gt; by nearly everyone who sees it. &lt;/p&gt;

&lt;p&gt;What is left unmentioned are possible confounders, like for instance as shown on the right, the people who complete onboarding are typically also the people motivated enough to complete onboarding — and hence we can never establish for sure that the training caused anything. &lt;/p&gt;

&lt;p&gt;The chart didn't lie. The framing did the arguing that implies causation when there may be none.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. p-value treated as a verdict
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ttest_ind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;group_a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;group_b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# p = 0.03 → "significant" → done
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A p-value answers one narrow question — &lt;em&gt;if there were actually no difference between these two groups, how likely would it be to see a difference this big just from random luck?&lt;/em&gt; A p-value of 0.03 means "about a 3% chance of seeing this by luck alone if nothing real is going on." &lt;/p&gt;

&lt;p&gt;It says nothing about how big the difference is, whether it matters in practice, or whether the study was well-designed. It's a statement about coincidence, not about importance — and the 0.05 cutoff itself is just a convention, not a law of nature.&lt;/p&gt;

&lt;p&gt;However, a p-value below 0.05 typically gets treated as an on/off switch: significant, ship it. When in reality, p-value is actually a statement about one specific null hypothesis, under one specific test, on one specific sample — not a verdict on whether the effect is real, large, or durable. &lt;/p&gt;

&lt;p&gt;Reporting "p &amp;lt; 0.05" without reporting effect size, sample size, or how many other comparisons were run alongside this one lets a single threshold stand in for a judgment call about how much evidence is enough.&lt;/p&gt;

&lt;p&gt;Take the example below of four studies (A, B, C, D) with roughly similar p-values (all &amp;lt; 0.05) but different effect sizes, measured using Cohen's d. Effect size is just "how big is the difference, really" — the gap between the two groups, scaled so it's comparable across studies. A small effect size means the two groups barely differ even if the math calls it "significant"; a large effect size means the difference is big enough to see with the naked eye. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fewdskoj9gjv6mykhc3w7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fewdskoj9gjv6mykhc3w7.png" alt="A dot plot of four hypothetical studies, all reporting p less than 0.05. Study A has effect size 1.1 with n=40. Study B has effect size 0.45 with n=120. Study C has effect size 0.18 with n=900. Study D has effect size 0.06 with n=8,000. Dot size scales with sample size, showing that the same p-value threshold covers wildly different real-world effect sizes." width="800" height="417"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The four studies in the chart above all clear the same p-value &amp;lt; 0.05 bar, but they're not telling the same story: Study D ran on 8,000 people and found a difference so tiny it's practically nothing — with a sample that large, even noise can become "significant." Study A ran on just 40 people and found a difference big enough to matter. Same headline, opposite substance. &lt;/p&gt;

&lt;p&gt;That's why the p-value alone can't be the verdict — it only says a difference probably isn't zero, not that the difference is big enough to care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "just double-check the numbers" isn't the fix
&lt;/h2&gt;

&lt;p&gt;Every example above involves numbers that are individually correct. Fact-checking the arithmetic won't catch any of them, because the distortion isn't in the math — it's in which math got chosen, and what got left off the chart. That's a different kind of review: not "is this number right," but "what would this look like if the axis, baseline, or trimming had been chosen differently, and why wasn't that shown?"&lt;/p&gt;

&lt;p&gt;A defensible analysis — the kind that survives a skeptical stakeholder asking "wait, why does it look like that?" — means being able to answer yes to all of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The axis range and why it was chosen would survive being stated out loud&lt;/li&gt;
&lt;li&gt;The baseline or comparison point is named, not just implied by the metric's label&lt;/li&gt;
&lt;li&gt;Any trimming, filtering, or exclusion is disclosed next to the result it affects&lt;/li&gt;
&lt;li&gt;Causal language is reserved for claims that actually support causation&lt;/li&gt;
&lt;li&gt;A significance result is reported with effect size and sample size, not p-value alone&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is about being more honest in some abstract sense. It's about treating every visualization and every summary statistic as an argument with premises — because that's what it is — and being willing to state the premises instead of letting "the data speaks for itself" stand in for them.&lt;/p&gt;




&lt;p&gt;This is the discipline behind &lt;a href="https://lms.sophiarch.com/courses/statistics-for-data-science?utm_source=dev.to&amp;amp;utm_medium=referral&amp;amp;utm_campaign=data-speaks-for-itself"&gt;SophiArch's Statistics for Data Science course&lt;/a&gt;, which teaches distributions, hypothesis testing, and inference specifically through the lens of directing and auditing AI-generated analysis — including catching exactly this kind of framing choice before it reaches a stakeholder.&lt;/p&gt;

</description>
      <category>datascience</category>
      <category>statistics</category>
      <category>datavisualization</category>
      <category>analytics</category>
    </item>
    <item>
      <title>The 3 Kinds of Data Leakage That Make Your Model Lie to You</title>
      <dc:creator>Jason Lau</dc:creator>
      <pubDate>Wed, 29 Jul 2026 03:16:55 +0000</pubDate>
      <link>https://dev.to/jasonl888/the-3-kinds-of-data-leakage-that-make-your-model-lie-to-you-2gif</link>
      <guid>https://dev.to/jasonl888/the-3-kinds-of-data-leakage-that-make-your-model-lie-to-you-2gif</guid>
      <description>&lt;p&gt;You build a churn model. Cross-validation AUC is 0.91. You ship it. Real-world AUC is 0.76.&lt;/p&gt;

&lt;p&gt;Nothing changed — same data, same algorithm, same infrastructure. The model just doesn't work as well as you measured it would. If this has happened to you, the most likely cause isn't a bad algorithm choice. It's &lt;strong&gt;data leakage&lt;/strong&gt;: information from outside the training boundary reaching the model during development, producing an evaluation score that collapses the moment it has to generalize to genuinely unseen data.&lt;/p&gt;

&lt;p&gt;Leakage is dangerous precisely because it's silent. The code runs. The numbers look good. Nothing throws an exception. The bug only shows up in production, weeks later, as a vague "the model's not performing like we expected" conversation.&lt;/p&gt;

&lt;p&gt;There are three distinct patterns, and they are not equally easy to catch — which matters more than ever now that a growing share of preprocessing code is AI-generated or AI-assisted.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Fit-on-all: the one AI tools actually catch
&lt;/h2&gt;

&lt;p&gt;This is the classic version, and it's mechanical enough that both static analysis and a decent code-review prompt can flag it: a transformer (scaler, imputer, encoder) gets fit on the &lt;em&gt;entire&lt;/em&gt; dataset before the train/test split. The transformer has now learned statistics — mean, variance, category frequencies — from data that's supposed to be unseen. When you evaluate on the "test" set, it's been preprocessed using information from itself.&lt;/p&gt;

&lt;p&gt;Here's the effect, measured directly rather than described:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sklearn.preprocessing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;StandardScaler&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sklearn.linear_model&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;LogisticRegression&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sklearn.model_selection&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;train_test_split&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sklearn.metrics&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;roc_auc_score&lt;/span&gt;

&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transactions_merged_features.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;X&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;monthly_spend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;support_tickets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenure_days&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan_encoded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;churned&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# --- The leaked version ---
&lt;/span&gt;&lt;span class="n"&gt;scaler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;StandardScaler&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;X_scaled_all&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scaler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fit_transform&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="c1"&gt;# fit on ALL data, including test
&lt;/span&gt;
&lt;span class="n"&gt;X_train_l&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;X_test_l&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y_train&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y_test&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;train_test_split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;X_scaled_all&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;test_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;random_state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LogisticRegression&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X_train_l&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y_train&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;auc_leaked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;roc_auc_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y_test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;predict_proba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X_test_l&lt;/span&gt;&lt;span class="p"&gt;)[:,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AUC (leaked):  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;auc_leaked&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# 0.912
&lt;/span&gt;
&lt;span class="c1"&gt;# --- The honest version ---
&lt;/span&gt;&lt;span class="n"&gt;X_train&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;X_test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y_train&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y_test&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;train_test_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;test_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;random_state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;scaler_honest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;StandardScaler&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;X_train_scaled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scaler_honest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fit_transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X_train&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# fit on train only
&lt;/span&gt;&lt;span class="n"&gt;X_test_scaled&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scaler_honest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X_test&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;# transform only
&lt;/span&gt;
&lt;span class="n"&gt;model_honest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LogisticRegression&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X_train_scaled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y_train&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;auc_honest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;roc_auc_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y_test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model_honest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;predict_proba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X_test_scaled&lt;/span&gt;&lt;span class="p"&gt;)[:,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AUC (honest):  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;auc_honest&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# 0.841
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same data, same model, same random seed. AUC drops from &lt;strong&gt;0.912 to 0.841&lt;/strong&gt; — a 7-point gap caused entirely by fitting the scaler before the split. At a business level, that's the difference between a model you'd confidently deploy and one you'd send back for more work.&lt;/p&gt;

&lt;p&gt;The reason AI coding assistants &lt;em&gt;can&lt;/em&gt; catch this one: it's a pattern-matchable rule — "does &lt;code&gt;fit&lt;/code&gt; or &lt;code&gt;fit_transform&lt;/code&gt; appear before &lt;code&gt;train_test_split&lt;/code&gt; in the same variable's lineage?" Ask any capable AI assistant to review a preprocessing script for this specific issue and it will generally find it, because it's a syntactic property of the code, not a fact about your business.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Target leakage: the one that requires knowing your business, not your syntax
&lt;/h2&gt;

&lt;p&gt;This is where AI code review quietly stops being useful, because target leakage isn't a code mistake — it's a &lt;strong&gt;feature design problem&lt;/strong&gt;. The code is syntactically fine. The leakage is in what the column &lt;em&gt;means&lt;/em&gt;.&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="c1"&gt;# Feature audit — ask for each column: would I have this at prediction time?
&lt;/span&gt;&lt;span class="n"&gt;feature_audit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenure_days&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;          &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;YES — calculated from signup_date, known at any time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;monthly_spend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;YES — last billing cycle is known&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;support_tickets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;YES — CRM records exist at prediction time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan_encoded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;YES — current plan is known&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;days_to_first_ticket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NO — some customers haven&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t opened a ticket yet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cancellation_flag&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NO — only exists for customers already mid-cancellation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;cancellation_flag&lt;/code&gt; is the textbook case. The model "predicts" churn by noticing that churned customers have a cancellation flag set. That's not a prediction, it's a tautology — and it will produce a beautiful AUC in development, because the leaked feature is, definitionally, almost perfectly correlated with the label.&lt;/p&gt;

&lt;p&gt;An AI assistant reviewing this code has no way to know that &lt;code&gt;cancellation_flag&lt;/code&gt; is only populated after a customer has already initiated cancellation, unless that constraint is written down somewhere it can read — a schema comment, a data dictionary, a docstring. Column names that are suggestive (&lt;code&gt;churn_date&lt;/code&gt;, &lt;code&gt;days_since_cancellation&lt;/code&gt;) sometimes get flagged. Column names that are business-specific and non-obvious (&lt;code&gt;at_risk_flag&lt;/code&gt;, populated by a CS rep's note that may or may not predate the churn event) will not.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Temporal leakage: the one that hides in aggregation windows
&lt;/h2&gt;

&lt;p&gt;The third pattern is the hardest to spot in a code diff, because the leaking value is often computed correctly in isolation — the leakage is in the &lt;em&gt;time window&lt;/em&gt; the aggregation covers relative to the prediction point.&lt;/p&gt;

&lt;p&gt;Example: a &lt;code&gt;12_month_avg_spend&lt;/code&gt; feature used to predict whether a customer churned in month 3. The average is computed over the full 12 months — which includes months 4 through 12, all of which happened &lt;em&gt;after&lt;/em&gt; the prediction point. The feature is real, the arithmetic is correct, and it's still leakage, because at the moment you'd actually need this prediction (month 3, for a live customer), months 4–12 haven't happened yet.&lt;/p&gt;

&lt;p&gt;This is the one that survives code review most often, because there's no syntax cue at all — just a &lt;code&gt;groupby().mean()&lt;/code&gt; that looks completely ordinary. Catching it requires drawing the prediction-point boundary explicitly and checking every feature against it:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8qqd0x6tjyk2mn9pzujk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8qqd0x6tjyk2mn9pzujk.png" alt=" " width="799" height="340"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Every feature derived from data on the wrong side of the prediction point — regardless of how predictive it looks in training — is leakage.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The one question that catches all three
&lt;/h2&gt;

&lt;p&gt;For every feature, ask: &lt;strong&gt;at the moment I make this prediction for a live customer, would this value already exist?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the answer is "no," or "only sometimes," the feature needs to be dropped or rebuilt using only historically-available data. This question doesn't require a tool — it requires knowing your data's timeline, which is exactly the part an AI assistant wasn't in the room for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The structural fix for at least one of these
&lt;/h2&gt;

&lt;p&gt;Fit-on-all leakage — pattern #1 — has an actual structural fix, not just a discipline-based one: &lt;code&gt;sklearn.pipeline.Pipeline&lt;/code&gt;. Wrapping your scaler, imputer, and model in a single &lt;code&gt;Pipeline&lt;/code&gt; makes it impossible to call &lt;code&gt;fit_transform&lt;/code&gt; on the full dataset by accident, because the pipeline controls the fit/transform order for you. It doesn't help with target or temporal leakage — those require the feature audit above — but it closes off the one failure mode that's purely mechanical.&lt;/p&gt;




&lt;p&gt;If you want the full walkthrough — building the &lt;code&gt;Pipeline&lt;/code&gt;, composing &lt;code&gt;ColumnTransformer&lt;/code&gt;s for mixed-type data, and a systematic protocol for auditing AI-generated preprocessing code specifically — it's the subject of &lt;a href="https://lms.sophiarch.com/courses/feature-engineering-pipelines?utm_source=dev.to&amp;amp;utm_medium=referral&amp;amp;utm_campaign=data-leakage-3-kinds"&gt;SophiArch's Feature Engineering &amp;amp; Pipelines course&lt;/a&gt;, including the lesson this article's timeline diagram and code examples are drawn from.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>scikitlearn</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Your AI's EDA Looks Right. Here's How to Tell When It Isn't.</title>
      <dc:creator>Jason Lau</dc:creator>
      <pubDate>Wed, 22 Jul 2026 02:51:22 +0000</pubDate>
      <link>https://dev.to/jasonl888/your-ais-eda-looks-right-heres-how-to-tell-when-it-isnt-4826</link>
      <guid>https://dev.to/jasonl888/your-ais-eda-looks-right-heres-how-to-tell-when-it-isnt-4826</guid>
      <description>&lt;p&gt;Ask an AI coding assistant to run exploratory data analysis on a new dataset and it will produce, in seconds, something that looks like a competent analyst's first pass: &lt;code&gt;.describe()&lt;/code&gt;, a correlation matrix, some distribution plots, a groupby or two, and a paragraph of narrative summarizing what it found.&lt;/p&gt;

&lt;p&gt;The problem is not that this output is wrong. Often it's correct. The problem is that &lt;strong&gt;it looks identical whether it's correct or not&lt;/strong&gt; — and the failure modes that make it wrong are exactly the ones a quick skim won't catch, because nothing throws an error and nothing looks incomplete.&lt;/p&gt;

&lt;p&gt;Here are six patterns that show up constantly in AI-generated EDA, each one silently producing output that reads as clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Profiling before type conversion
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# The AI commonly generates this order — the problem is invisible in the output
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;                                    &lt;span class="c1"&gt;# order_date excluded — it's still a string
&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;     &lt;span class="c1"&gt;# too late
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.describe()&lt;/code&gt; silently drops non-numeric columns. If a date column is still sitting as a string object when you profile, it just... isn't in the summary. Nothing errors. You get a clean-looking table with one fewer column than you meant to check, and no indication anything was skipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix is ordering, not code complexity&lt;/strong&gt;: type conversions belong in the very first block, before any statistic is computed.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Reaching for &lt;code&gt;.mean()&lt;/code&gt; on a right-skewed column
&lt;/h2&gt;

&lt;p&gt;AI assistants default to &lt;code&gt;.mean()&lt;/code&gt; for basically every "typical value" question, because it's the statistically obvious first move. For revenue, spend, session duration — anything with a long right tail — that default quietly overstates what's typical:&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="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupby&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;channel&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# a handful of large B2B orders pull this up
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A groupby that reports mean without also reporting median, count, and std produces a number that's technically correct and practically misleading. The fix is cheap — always pull all four together — but it requires knowing to ask, since the AI won't flag its own choice of statistic as a judgment call.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;.dropna()&lt;/code&gt; before you've profiled the missingness
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dropna&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;# applied early, "to make analysis cleaner"
# profiling happens on what's left
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Profiling exists to characterize &lt;em&gt;how&lt;/em&gt; and &lt;em&gt;where&lt;/em&gt; values are missing — whether it's random, or concentrated in a specific segment, or correlated with another column. Drop the rows first and that pattern is gone before you ever looked at it. The output afterward looks complete. It's complete for a dataset that no longer represents the one you started with.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The silent subset filter
&lt;/h2&gt;

&lt;p&gt;This is the one most likely to change your conclusions without you noticing:&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="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delivered&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;     &lt;span class="c1"&gt;# ← added mid-script, nothing downstream is labeled
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every correlation, every groupby, every chart that runs after this line applies to delivered orders only — but nothing in the output says so. A stakeholder reading "42% of orders come from the enterprise channel" has no way to know that's 42% &lt;em&gt;of delivered orders&lt;/em&gt;, unless someone happened to scroll up to a filter line with no comment attached to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Narrative summaries that quietly assert causation
&lt;/h2&gt;

&lt;p&gt;AI-generated EDA often ends with a paragraph of plain-language commentary. That commentary is where causal language creeps in — "unit price drives revenue," "the strong correlation confirms X" — from analysis that only ever established correlation. The numbers above the paragraph are usually fine. The prose interpreting them is where scope creep happens, because narrative generation and statistical rigor are not the same task, even when the same model produces both in one response.&lt;/p&gt;

&lt;p&gt;Read the numbers. Treat the narrative paragraph as a first draft to revise, not a finding to cite.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Treating "no issues found" as "the data is clean"
&lt;/h2&gt;

&lt;p&gt;If you ask an AI to check for data quality issues and it reports none, that's not confirmation. It means the checks it ran didn't find anything — which says nothing about the checks it didn't run. Cardinality on string columns, structural missingness patterns, multivariate outliers: these don't show up unless something specifically goes looking for them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe236hz0odhr489jv1joz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe236hz0odhr489jv1joz.png" alt="Six failure patterns in two columns: Profile before type conversion (date columns silently absent), Mean for skewed columns (typical value overstated), dropna before profiling (missingness pattern lost), Silent subset filter (all subsequent analysis scoped without a label), Accept narrative without reading numbers (causal claims go unchallenged), No findings equals clean data (unchecked assumptions pass through)" width="800" height="414"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;All six patterns share the same shape: the output looks complete, and completeness is exactly what a quick review checks for.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "just review the code" isn't quite the answer
&lt;/h2&gt;

&lt;p&gt;Reviewing AI-generated code before running it catches some of this — type conversions before statistics, mean vs. median, subset filters — because those are visible in the script itself. What code review alone won't catch is #5 and #6: causal language buried in a narrative summary, and the false confidence of a clean-looking "no issues found." Those require reading the &lt;em&gt;output&lt;/em&gt;, not just the code, and holding it against what you already know about the domain.&lt;/p&gt;

&lt;p&gt;A defensible EDA — AI-assisted or not — means you can answer yes to all of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Type conversions happened before any statistic was computed&lt;/li&gt;
&lt;li&gt;Missingness was profiled before anything was dropped&lt;/li&gt;
&lt;li&gt;Every group summary includes count and spread, not just mean&lt;/li&gt;
&lt;li&gt;No subset filter changed scope without being documented&lt;/li&gt;
&lt;li&gt;Causal language has been replaced with correlational language where causation wasn't established&lt;/li&gt;
&lt;li&gt;"No findings" was treated as "the checks I ran found nothing," not as "the data is clean"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is really about AI. It's the same discipline that's always separated a defensible analysis from a plausible-looking one. What's changed is the speed at which plausible-looking output gets produced — which means the checklist matters more, not less.&lt;/p&gt;




&lt;p&gt;This is the framework behind &lt;a href="https://lms.sophiarch.com/courses/exploratory-data-analysis?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=ai-eda-failure-patterns" rel="noopener noreferrer"&gt;SophiArch's Exploratory Data Analysis course&lt;/a&gt;, including a lab where you're handed a full AI-generated EDA notebook and asked to find exactly these kinds of issues before they reach a stakeholder.&lt;/p&gt;

</description>
      <category>datascience</category>
      <category>python</category>
      <category>pandas</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
