<?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>Prompt Engineering Won't Survive — the Skill Underneath It Will</title>
      <dc:creator>Jason Lau</dc:creator>
      <pubDate>Wed, 02 Sep 2026 09:49:37 +0000</pubDate>
      <link>https://dev.to/jasonl888/prompt-engineering-wont-survive-the-skill-underneath-it-will-36j1</link>
      <guid>https://dev.to/jasonl888/prompt-engineering-wont-survive-the-skill-underneath-it-will-36j1</guid>
      <description>&lt;p&gt;For about two years, "prompt engineer" was the job title recruiters attached to six-figure listings and career-changers scrambled to add to a resume. Then the interest mostly stopped converting into jobs. The &lt;a href="https://www.wsj.com/articles/the-hottest-ai-job-of-2023-is-already-obsolete-1961b054" rel="noopener noreferrer"&gt;Wall Street Journal reported&lt;/a&gt; that Indeed searches for the role spiked to 144 per million U.S. searches in April 2023 and have since flatlined at 20-30 per million — and, per Indeed VP of AI Hannah Calhoon, that interest was never matched by actual employer job postings in the first place: "Maybe they talked about the value of prompt engineers, but they weren't then actually hiring for that." A title that rose and mostly stalled out inside two model generations. Microsoft's Jared Spataro put the reason bluntly: "You don't have to have the perfect prompt."&lt;/p&gt;

&lt;p&gt;That's a fair obituary for the "prompt engineer" job title. It's a bad obituary for the underlying work, and the mistake is treating both as the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually died was a set of tricks tuned to specific models
&lt;/h2&gt;

&lt;p&gt;Open any 2023-era prompt-engineering article and it's mostly mantras: "think step by step," phrase it as a role assignment, wrap the instruction in triple quotes, promise the model a tip. These worked because they guided a specific model around a specific weakness — and weaknesses don't persist across model generations.&lt;/p&gt;

&lt;p&gt;Chain-of-thought is the clearest case, because it's the most-cited prompting technique there is and it's now been re-measured against current models. A &lt;a href="https://papers.ssrn.com/sol3/papers.cfm?abstract_id=5285532" rel="noopener noreferrer"&gt;2025 technical report from Wharton's Generative AI Labs&lt;/a&gt; ran chain-of-thought prompting against eight models spanning both non-reasoning and reasoning types. The results don't point one direction. Gemini 2.0 Flash gained 13.5% and Sonnet 3.5 gained 11.7% — real improvements. GPT-4o-mini gained 4.4%, a difference the authors couldn't call statistically significant. Gemini Pro 1.5's rate of getting every part of a problem fully correct &lt;em&gt;dropped&lt;/em&gt; 17.2 percentage points with chain-of-thought turned on. Among the reasoning models — the ones already trained to produce their own extended reasoning — o3-mini and o4-mini picked up a marginal 2.9-3.1%, while Gemini Flash 2.5 lost 3.3%. All of this arrived with a real tax: 35-600% longer response times for non-reasoning models, 20-80% longer for reasoning ones. The paper's own framing is careful rather than dramatic — "its effectiveness depends significantly on model type and specific use case" — but read against the trend, that's a technique whose payoff is shrinking and inverting as the models it was compensating for get better at reasoning on their own.&lt;/p&gt;

&lt;p&gt;A &lt;a href="https://arxiv.org/abs/2510.22251" rel="noopener noreferrer"&gt;2025 arXiv paper&lt;/a&gt; on what it calls the "Prompting Inversion" makes the same point sharper by comparing one technique across two model generations directly. The authors built "Sculpting," a heavily constrained prompting method, and pitted it against plain chain-of-thought. On GPT-4o, Sculpting won clearly — 97% accuracy against chain-of-thought's 93%. On GPT-5, the ranking flips: Sculpting drops to 94.00% while plain chain-of-thought reaches 96.36%. Their explanation is that the same constraints acting as guardrails for a mid-tier model become "handcuffs" on a more capable one — the scaffolding that stopped GPT-4o from wandering off just gets in GPT-5's way. A technique doesn't get more true as models improve; it can flip sign entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The instability was already visible in how fragile the tricks were
&lt;/h2&gt;

&lt;p&gt;None of this should be surprising if you look at how sensitive prompt outputs already were to changes that had nothing to do with meaning. &lt;a href="https://arxiv.org/abs/2310.11324" rel="noopener noreferrer"&gt;Sclar, Choi, Tsvetkov, and Suhr&lt;/a&gt; measured how much purely cosmetic prompt formatting — whitespace, separator characters, capitalization — could move accuracy on the same task with the same content, and found swings of up to 76 accuracy points on LLaMA-2-13B. Critically, they found this sensitivity "remains even when increasing model size, the number of few-shot examples, or performing instruction tuning" — it isn't a small-model problem that scale quietly fixes.&lt;/p&gt;

&lt;p&gt;That finding reframes what a lot of "prompt engineering" tips actually were. If reformatting a prompt with no change in meaning can swing accuracy by 76 points, then a technique's measured 10-point gain on some benchmark was never a stable property of the technique — it was one sample from a distribution of formatting accidents that happened to land favorably on that model, at that snapshot in time. Chasing that kind of gain by memorizing the exact phrasing that worked last week is chasing noise, and noise doesn't transfer to the next model release. You have to separate the parts of a prompt that are doing real work (the schema, the goal, the constraints) from the parts that are stylistic residue — which is exactly the split &lt;a href="https://lms.sophiarch.com/courses/ai-assisted-data-science?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=prompt-engineering-durable-skill" rel="noopener noreferrer"&gt;SophiArch's AI-Assisted Data Science course&lt;/a&gt; spends its second lesson on, precisely because the residue is what breaks first when your model evolves.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually being taught the whole time
&lt;/h2&gt;

&lt;p&gt;Strip out the mantras and what's left in a good prompt is closer to a specification than a sacred phrase. A &lt;a href="https://www.frontiersin.org/journals/education/articles/10.3389/feduc.2024.1366434/full" rel="noopener noreferrer"&gt;2024 peer-reviewed framework in &lt;em&gt;Frontiers in Education&lt;/em&gt;&lt;/a&gt; defines prompt engineering as "the skill of communicating the problem, its context, and the constraints imposed on the desirable solution to an LLM to solve it correctly as fast as possible" — and its authors explicitly push back on the idea that this reduces to generic communication skill, arguing it's closer to task decomposition: knowing what you actually want closely enough to state it, before you ever open a chat window.&lt;/p&gt;

&lt;p&gt;That's the part that doesn't expire when a new model ships, because it isn't a fact about models — it's a fact about the gap between what's in your head and what's on the page. Consider the difference between two requests to deduplicate a customer table:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Clean up the duplicate customers in this table."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;versus:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Two rows are the same customer if &lt;code&gt;email&lt;/code&gt; matches exactly (case-insensitive) OR both &lt;code&gt;phone&lt;/code&gt; and &lt;code&gt;last_name&lt;/code&gt; match. When rows conflict, keep the one with the most recent &lt;code&gt;last_order_date&lt;/code&gt;; if that's tied, keep the one with more non-null fields. Do not merge rows that share only a &lt;code&gt;last_name&lt;/code&gt; — that's a coincidence, not a duplicate. Return a mapping from removed &lt;code&gt;customer_id&lt;/code&gt; to the &lt;code&gt;customer_id&lt;/code&gt; it was merged into, so the removal is auditable."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Neither of these is a prompting trick. No chain-of-thought cue, no role-play framing, no promised tip. The second one just doesn't leave the model — or a junior analyst, for that matter — a single ambiguous decision to make on its own: what counts as a match, which record wins a conflict, what NOT to merge, and how the result gets checked. A model that's gotten better at reasoning will make smarter guesses at filling those gaps than last year's model did. It will still be guessing, and on a dataset that decides refund eligibility or fraud flags, "the model guessed well" is not the standard anyone should be signing off on.&lt;/p&gt;

&lt;p&gt;That's the actual content of "prompt engineering" once you take the model-specific mantras out of it, and it's also why the skill was never new — it's requirements-writing and problem specification, wearing a 2023 label because a chat interface made the gap between vague and precise suddenly visible to everyone, all day, on every request. The interface is what's new. The gap it exposed is not, and closing it is a durable skill for exactly the reason the mantras weren't: it doesn't depend on which model happens to be running underneath.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Meincke, L., Mollick, E. R., Mollick, L., &amp;amp; Shapiro, D. (2025). &lt;a href="https://papers.ssrn.com/sol3/papers.cfm?abstract_id=5285532" rel="noopener noreferrer"&gt;Prompting Science Report 2: The Decreasing Value of Chain of Thought in Prompting&lt;/a&gt;. &lt;em&gt;SSRN / Wharton Generative AI Labs&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2510.22251" rel="noopener noreferrer"&gt;You Don't Need Prompt Engineering Anymore: The Prompting Inversion&lt;/a&gt; (2025). &lt;em&gt;arXiv&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Sclar, M., Choi, Y., Tsvetkov, Y., &amp;amp; Suhr, A. (2023). &lt;a href="https://arxiv.org/abs/2310.11324" rel="noopener noreferrer"&gt;Quantifying Language Models' Sensitivity to Spurious Features in Prompt Design&lt;/a&gt;. &lt;em&gt;arXiv&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Bousquette, I. (2025). &lt;a href="https://www.wsj.com/articles/the-hottest-ai-job-of-2023-is-already-obsolete-1961b054" rel="noopener noreferrer"&gt;The Hottest AI Job of 2023 Is Already Obsolete&lt;/a&gt;. &lt;em&gt;The Wall Street Journal&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.frontiersin.org/journals/education/articles/10.3389/feduc.2024.1366434/full" rel="noopener noreferrer"&gt;Prompt Engineering as a New 21st Century Skill&lt;/a&gt; (2024). &lt;em&gt;Frontiers in Education&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;SophiArch. &lt;a href="https://lms.sophiarch.com/courses/ai-assisted-data-science?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=prompt-engineering-durable-skill" rel="noopener noreferrer"&gt;AI-Assisted Data Science&lt;/a&gt; — second lesson module on separating durable prompt structure (schema, goal, constraints) from model-specific tricks.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>promptengineering</category>
      <category>llm</category>
      <category>career</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Your RAG Pipeline Doesn't Have an Accuracy Problem - It Has an Evaluation Problem</title>
      <dc:creator>Jason Lau</dc:creator>
      <pubDate>Wed, 26 Aug 2026 03:37:34 +0000</pubDate>
      <link>https://dev.to/jasonl888/your-rag-pipeline-doesnt-have-an-accuracy-problem-it-has-an-evaluation-problem-4e63</link>
      <guid>https://dev.to/jasonl888/your-rag-pipeline-doesnt-have-an-accuracy-problem-it-has-an-evaluation-problem-4e63</guid>
      <description>&lt;p&gt;A team builds a retrieval-augmented chatbot over the company's internal policy documents. In the demo, someone asks "how many days of parental leave do we get?" and the bot answers correctly, citing the right PDF. Someone asks about expense limits - correct again. Ten questions, ten good answers, applause, ship it.&lt;/p&gt;

&lt;p&gt;Three months later an employee asks whether their contractor status qualifies for the health stipend, gets a confident "yes" assembled from a policy that was superseded last year, and files the claim. Nobody on the team can say when the pipeline started producing answers like that, because nothing was ever measuring whether it did.&lt;/p&gt;

&lt;p&gt;That is the real state of most RAG systems in production. The pipeline is not unusually inaccurate - every retrieval system misses sometimes. What's missing is the apparatus that would &lt;em&gt;notice&lt;/em&gt;. The demo was treated as the evaluation, and a demo is the one test a RAG system essentially cannot fail: the questions were chosen by the people who built the index, phrased the way the documents phrase things, asked about content everyone knew was in there.&lt;/p&gt;

&lt;h2&gt;
  
  
  A wrong answer and a right answer look identical
&lt;/h2&gt;

&lt;p&gt;Traditional software fails loudly. A broken API call throws an exception; a bad deploy serves 500 errors; a failing test case turns red. A RAG regression does none of this. Swap the embedding model, change the chunk size, re-index after a document update - the system keeps returning fluent, well-formatted, confidently cited answers. Whether they are &lt;em&gt;grounded&lt;/em&gt; answers is invisible in every signal you get: no exception, no latency spike, no schema violation.&lt;/p&gt;

&lt;p&gt;This isn't an edge case of RAG engineering; it is a recurring finding in the engineering literature on it. A &lt;a href="https://arxiv.org/abs/2401.05856" rel="noopener noreferrer"&gt;CAIN 2024 experience report across three RAG case studies&lt;/a&gt; (research, education, and biomedical domains) catalogued seven recurring failure points - missing content, missed top-ranked documents, answers retrieved but lost in consolidation, answers present in context but not extracted, wrong format, wrong specificity, incomplete answers - and its two headline takeaways are blunt: "validation of a RAG system is only feasible during operation, and the robustness of a RAG system evolves rather than designed in at the start."&lt;/p&gt;

&lt;p&gt;Read that first clause again. You cannot fully validate this class of system before shipping it. Which means the eval harness is not a nice-to-have you add after launch - it &lt;em&gt;is&lt;/em&gt; validation-during-operation, systematised: built from the questions real users actually asked, and run continuously as the system and its corpus change. It is the only mechanism by which you ever learn whether the thing works.&lt;/p&gt;

&lt;h2&gt;
  
  
  "RAG fixes hallucinations" is marketing, and there's a measurement to prove it
&lt;/h2&gt;

&lt;p&gt;The reason teams skip evaluation is an ingrained assumption: retrieval grounds the model, so the hallucination problem is handled. The strongest counterexample comes from a domain with real money behind getting this right. Legal research vendors marketed their RAG products as &lt;a href="https://reglab.stanford.edu/publications/hallucination-free-assessing-the-reliability-of-leading-ai-legal-research-tools/" rel="noopener noreferrer"&gt;"eliminating" or "avoid[ing]" hallucinations, even guaranteeing "hallucination-free" citations&lt;/a&gt;. When Stanford's RegLab ran the first preregistered empirical evaluation of these tools, the flagship products from LexisNexis and Thomson Reuters "each hallucinate between 17% and 33% of the time."&lt;/p&gt;

&lt;p&gt;These are professional-grade systems built by teams with enormous resources over curated, authoritative corpora - the best case for RAG. Retrieval &lt;em&gt;reduced&lt;/em&gt; hallucination relative to a bare model, but between one in six and one in three answers still contained fabrication. The gap between "we added retrieval" and "we measured what retrieval actually delivers on our queries" is exactly the gap the vendors' marketing fell into. If they can fall into it, your internal chatbot certainly can.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Is it accurate?" is two questions wearing one trenchcoat
&lt;/h2&gt;

&lt;p&gt;Suppose you accept the premise and ask: fine, how accurate &lt;em&gt;is&lt;/em&gt; my pipeline? The question is underspecified, and that underspecification is why demo-vibes evaluation persists - there was never an agreed definition of correct to check against.&lt;/p&gt;

&lt;p&gt;A RAG answer can fail in two independent places, and they need different fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retrieval failed.&lt;/strong&gt; The chunks handed to the model didn't contain the answer - it wasn't indexed, didn't rank into the top-k, or got cut during context assembly. No prompt engineering will fix this; you need to change chunking, embeddings, or ranking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generation failed.&lt;/strong&gt; The answer &lt;em&gt;was&lt;/em&gt; in the retrieved context and the model ignored it, contradicted it, or embellished beyond it. Re-ranking harder won't fix this; you need to change the prompt contract, the model, or add output validation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An aggregate "accuracy" number collapses these together and leaves you optimising blind. This decomposition is precisely what the &lt;a href="https://arxiv.org/abs/2309.15217" rel="noopener noreferrer"&gt;RAGAS evaluation framework&lt;/a&gt; formalised into separately measurable quantities: &lt;em&gt;context relevance&lt;/em&gt; scores the retrieval step (did the right evidence show up, without drowning in irrelevant material?), while &lt;em&gt;faithfulness&lt;/em&gt; and &lt;em&gt;answer relevance&lt;/em&gt; score the generation step (is every claim in the answer supported by that evidence, and does it address the question?). The &lt;a href="https://docs.ragas.io/en/stable/concepts/metrics/available_metrics/" rel="noopener noreferrer"&gt;Ragas library&lt;/a&gt; has since split the retrieval side further into context precision and context recall. The individual metric implementations have known rough edges - but the decomposition is the part that matters, because it turns "the bot was wrong" into a bug report that identifies a component.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an actual evaluation harness looks like
&lt;/h2&gt;

&lt;p&gt;None of this requires a research team. A minimum viable harness is three artefacts and a habit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A golden set: your domain's definition of correct, written down.&lt;/strong&gt; Collect 50-100 real questions - from support logs, pilot users, and the subject-matter expert who knows where the knowledge is buried. For each, record the expected answer &lt;em&gt;and&lt;/em&gt; which document(s) it must come from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"policy_031"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"question"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Does the health stipend apply to contractors?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expected_answer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"No - eligibility requires full-time employment status."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"must_cite"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"benefits-eligibility-2026.pdf"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"trap"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"superseded 2024 policy still in corpus says yes"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;trap&lt;/code&gt; field is where the value lives. Easy questions inflate your score; the golden set earns its keep on superseded documents, questions whose answer spans two chunks, questions the corpus &lt;em&gt;cannot&lt;/em&gt; answer (the correct behaviour is "I don't know" - the first failure point in the CAIN taxonomy is systems that fabricate rather than decline), and negation cases where the retrieved text says the opposite of what the surface phrasing suggests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Scoring that preserves the retrieval/generation split.&lt;/strong&gt; Score "did the must-cite document appear in the retrieved set?" separately from "is the answer faithful to what was retrieved?" For the generation side at scale you'll likely use an LLM judge - which is workable but not free of pathology: the &lt;a href="https://arxiv.org/abs/2306.05685" rel="noopener noreferrer"&gt;MT-Bench study of LLM-as-a-judge&lt;/a&gt; found strong judges agree with human raters over 80% of the time, while also documenting systematic verbosity bias (longer answers score better regardless of quality) and signs of self-enhancement bias (judges may favour their own model's outputs). Use a judge, but spot-check it against human labels before trusting it as your regression signal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A regression gate.&lt;/strong&gt; The harness runs on every change - new embedding model, new chunking strategy, re-index, prompt edit, model version bump - and a score drop blocks the change, the same way a failing test suite blocks a merge. This is the step that converts evaluation from a one-time report into an engineering control. Without the gate, your golden set is a benchmark you ran once in a notebook; with it, "did we just get worse?" has an answer before users provide it.&lt;/p&gt;

&lt;p&gt;The demo answered the questions you chose. The golden set answers the ones your users will actually ask - including the ones designed to make your pipeline lie. Build the second thing before you trust the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Barnett, S., Kurniawan, S., Thudumu, S., Brannelly, Z., &amp;amp; Abdelrazek, M. (2024). &lt;a href="https://arxiv.org/abs/2401.05856" rel="noopener noreferrer"&gt;Seven Failure Points When Engineering a Retrieval Augmented Generation System&lt;/a&gt;. &lt;em&gt;CAIN '24&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Es, S., James, J., Espinosa-Anke, L., &amp;amp; Schockaert, S. (2023). &lt;a href="https://arxiv.org/abs/2309.15217" rel="noopener noreferrer"&gt;RAGAS: Automated Evaluation of Retrieval Augmented Generation&lt;/a&gt;. &lt;em&gt;arXiv&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Magesh, V. et al. &lt;a href="https://reglab.stanford.edu/publications/hallucination-free-assessing-the-reliability-of-leading-ai-legal-research-tools/" rel="noopener noreferrer"&gt;Hallucination-Free? Assessing the Reliability of Leading AI Legal Research Tools&lt;/a&gt;. &lt;em&gt;Stanford RegLab&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Zheng, L. et al. (2023). &lt;a href="https://arxiv.org/abs/2306.05685" rel="noopener noreferrer"&gt;Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena&lt;/a&gt;. &lt;em&gt;NeurIPS&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Ragas documentation. &lt;a href="https://docs.ragas.io/en/stable/concepts/metrics/available_metrics/" rel="noopener noreferrer"&gt;List of available metrics&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If you want to go deeper than a blog post can, evaluation is where SophiArch's &lt;a href="https://lms.sophiarch.com/courses/ai-applications-with-llms?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=rag-evaluation-problem" rel="noopener noreferrer"&gt;Building AI Applications with LLMs&lt;/a&gt; course plants its flag: a full module on golden sets, LLM-as-judge trade-offs, and regression gates, sitting inside a validation architecture that runs from prompt contracts through output checking to production observability.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>rag</category>
      <category>evaluation</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Clustering Isn't Discovery - It's a Hypothesis You Still Have to Test</title>
      <dc:creator>Jason Lau</dc:creator>
      <pubDate>Wed, 19 Aug 2026 09:56:42 +0000</pubDate>
      <link>https://dev.to/jasonl888/clustering-isnt-discovery-its-a-hypothesis-you-still-have-to-test-ko4</link>
      <guid>https://dev.to/jasonl888/clustering-isnt-discovery-its-a-hypothesis-you-still-have-to-test-ko4</guid>
      <description>&lt;p&gt;An analyst runs &lt;code&gt;KMeans(n_clusters=4)&lt;/code&gt; on dataset of customer purchase history, it gets back four groups, and he/she writes up the report as "from the data, it revealed four customer segments!" Management simply loves this - after long periods struggling to grasp at their customer base, they now have data-backed insights on real-names associated with each segment (&lt;em&gt;Loyal Regulars&lt;/em&gt;, &lt;em&gt;Bargain Hunters&lt;/em&gt;, &lt;em&gt;At-Risk&lt;/em&gt;, &lt;em&gt;New &amp;amp; Exploring&lt;/em&gt;), and pretty soon all the strategy meetings will silently assume those four groups are &lt;em&gt;real&lt;/em&gt; and nobody even dares to question.&lt;/p&gt;

&lt;p&gt;Unfortunately, those groups are &lt;em&gt;not&lt;/em&gt; real and clustering does not "discover"  hidden nuggets of insights in the data. Instead clustering imposes a partitioning based on what the analyst chooses as a similarity rule. If the analyst had chosen another reasonable rule, clustering can also fit the same dataset in a different way. Let's be clear - the four groups are a hypothesis about how to group the customers. To determine if the grouping is &lt;em&gt;accurate&lt;/em&gt;, as in we can use this grouping to predict customer behavior, now that is a separate question that the clustering method can never answer by itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same data, different "discovery"
&lt;/h2&gt;

&lt;p&gt;To illustrate, try running &lt;code&gt;KMeans&lt;/code&gt; clustering on an RFM table (recency, frequency, monetary value per customer) and only change the random seed:&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;from&lt;/span&gt; &lt;span class="n"&gt;sklearn.cluster&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;KMeans&lt;/span&gt;

&lt;span class="n"&gt;km_a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;KMeans&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_clusters&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_init&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&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;7&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_scaled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;km_b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;KMeans&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_clusters&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_init&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&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="nf"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X_scaled&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="n"&gt;km_a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inertia_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;km_b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inertia_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# 8421.3   8103.9 - different local optima, neither one "wrong"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;inertia: measure of how tightly grouped the data points are within the clusters&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Notice that you get different inertia values. &lt;code&gt;KMeans&lt;/code&gt; does &lt;em&gt;not&lt;/em&gt; find the globally best grouping - it cannot. Instead the algorithm finds a local optimum based on randomly chosen initial centroids and two runs on the same data can converge to genuinely different groupings. In fact scikit-learn's user guide states &lt;a href="https://scikit-learn.org/stable/modules/clustering.html#k-means" rel="noopener noreferrer"&gt;"Given enough time, K-means will always converge, however this may be to a local minimum. This is highly dependent on the initialization of the centroids"&lt;/a&gt;. As mitigation, the default behavior is to run the algorithm several times with different centroid seeds (&lt;code&gt;n_init&lt;/code&gt;) and retain the run that scored the lowest on inertia - it does &lt;em&gt;not&lt;/em&gt; guarantee a single answer.&lt;/p&gt;

&lt;p&gt;What if you change to use a different method - say hierarchical clustering with average linkage on the same RFM dataset? In hierarchical clustering, you do not need to specify the number of clusters and hence, the group counts that appear "natural" in your dataset also shifts. This is because hierarchical clustering with average linkage and KMeans are optimizing on different objectives ( nested merge vs compact spherical clusters )&lt;/p&gt;

&lt;p&gt;This dissonance between run-to-run, method-to-method is well studied under &lt;em&gt;clustering stability&lt;/em&gt; - a collection of research whose overview by &lt;a href="https://arxiv.org/pdf/1007.1075" rel="noopener noreferrer"&gt;von Luxburg (2010)&lt;/a&gt; shows how sensitive clustering results are to the seed, sample, algorithm and why stability by itself is not sufficient for meaningful clustering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Aren't there any methods to decide?
&lt;/h2&gt;

&lt;p&gt;One might say: "We are very data-driven and we use the elbow method to pick k." &lt;/p&gt;

&lt;p&gt;There are various metrics or methods to measure how well your clustering algorithm groups the dataset:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;elbow (inertia vs k)&lt;/li&gt;
&lt;li&gt;average silhouette method (silhouette score vs k)&lt;/li&gt;
&lt;li&gt;gap statistic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://www.datanovia.com/learn/machine-learning/clustering/optimal-clusters" rel="noopener noreferrer"&gt;Side-by-side comparisons of various methods on same dataset&lt;/a&gt; often leads to contradictory recommendations on cluster count. There is &lt;em&gt;no&lt;/em&gt; one-metric and final decision requires human judgement based on your domain knowledge.&lt;/p&gt;

&lt;p&gt;At times, you might have another situation:&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;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;silhouette_score&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;labels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;KMeans&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_clusters&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_init&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&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;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fit_predict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X_scaled&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="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;silhouette_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X_scaled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;# 2  0.41   &amp;lt;- highest score
# 3  0.33
# 4  0.29   &amp;lt;- the k the business narrative was built around
# 5  0.24
# 6  0.22
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, the silhouette score shows k=2 is highest but you get push back from Marketing - "We need 4 separate personas to target!". Nothing wrong with that request, 2 clusters will usually lead to "Loyal" vs "Non-Loyal" segments and it is meaningless for marketing planning. 4 clusters do help in the business narrative to target specific market segments. If this is the case, a disclaimer is in order that k=4 was due to business narrative (instead of "insights from dataset" as authority)&lt;/p&gt;

&lt;h2&gt;
  
  
  Cluster on demographics may not mean different customer behavior
&lt;/h2&gt;

&lt;p&gt;Your clustering tells you have four groups of customers and they look different based on the &lt;em&gt;features you cluster on&lt;/em&gt; - recency, frequency, spend. Note that their behaviors that businesses care about - who leaves for a competitor, who upgrades to higher tier, who responds to coupons - may look different across those same groups. This discrepancy is what &lt;a href="https://martech.org/why-behavioral-segmentation-beats-personas-for-real-personalization/" rel="noopener noreferrer"&gt;marketing teams struggle with using demographic and behavioral personas&lt;/a&gt; - the behavioral patterns often cut across various demographic personas.&lt;/p&gt;

&lt;p&gt;The fix is not to have a better clustering metric but to validate externally based on your business objectives and goals (churn in the next quarter, response rate to campaign, etc) and &lt;a href="https://www.mdpi.com/2071-1050/14/12/7243" rel="noopener noreferrer"&gt;check if they differ between clusters more than within the clusters.&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the correct approach to clustering?
&lt;/h2&gt;

&lt;p&gt;The above does not mean clustering is a useless technique to avoid. Instead what it means is that clustering is only a starting hypothesis for further investigations.&lt;/p&gt;

&lt;p&gt;Before presenting the clustering results:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;re-run with different seeds and subset of data

&lt;ul&gt;
&lt;li&gt;this is to verify the stability of the cluster&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;check with multiple metrics and methods (elbow, silhouette, gap statistics)

&lt;ul&gt;
&lt;li&gt;highlight in report when they disagree&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;validate externally

&lt;ul&gt;
&lt;li&gt;select a business outcome &lt;/li&gt;
&lt;li&gt;confirm it varies across clusters more than within cluster&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;treat the chosen number of clusters as modeling decision &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A clustering that complies with all four does not mean it is "true" but it does earn the right to be acted upon. &lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Kleinberg, J. (2002). &lt;a href="https://www.cs.cornell.edu/home/kleinber/nips15.pdf" rel="noopener noreferrer"&gt;An Impossibility Theorem for Clustering&lt;/a&gt;. &lt;em&gt;NeurIPS&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;von Luxburg, U. (2010). &lt;a href="https://arxiv.org/pdf/1007.1075" rel="noopener noreferrer"&gt;Clustering Stability: An Overview&lt;/a&gt;. &lt;em&gt;arXiv&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;scikit-learn. &lt;a href="https://scikit-learn.org/stable/modules/clustering.html#k-means" rel="noopener noreferrer"&gt;Clustering - K-means, User Guide&lt;/a&gt; - local-minimum convergence and multiple-initialization behavior.&lt;/li&gt;
&lt;li&gt;scikit-learn. &lt;a href="https://scikit-learn.org/stable/modules/generated/sklearn.cluster.KMeans.html" rel="noopener noreferrer"&gt;&lt;code&gt;sklearn.cluster.KMeans&lt;/code&gt; API reference&lt;/a&gt; - &lt;code&gt;n_init&lt;/code&gt; and &lt;code&gt;random_state&lt;/code&gt; parameter definitions.&lt;/li&gt;
&lt;li&gt;Datanovia. &lt;a href="https://www.datanovia.com/learn/machine-learning/clustering/optimal-clusters.html" rel="noopener noreferrer"&gt;Optimal Number of Clusters: Elbow, Silhouette, Gap Statistic&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Ahmed, M. et al. (2022). &lt;a href="https://www.mdpi.com/2071-1050/14/12/7243" rel="noopener noreferrer"&gt;K-Means Clustering Approach for Intelligent Customer Segmentation Using Customer Purchase Behavior Data&lt;/a&gt;. &lt;em&gt;Sustainability&lt;/em&gt;, 14(12).&lt;/li&gt;
&lt;li&gt;MarTech. &lt;a href="https://martech.org/why-behavioral-segmentation-beats-personas-for-real-personalization/" rel="noopener noreferrer"&gt;Why behavioral segmentation beats personas for real personalization&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;SophiArch's &lt;a href="https://lms.sophiarch.com/courses/unsupervised-learning-clustering?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=clustering-hypothesis-not-discovery" rel="noopener noreferrer"&gt;Unsupervised Learning &amp;amp; Clustering course&lt;/a&gt; covers k-means, hierarchical clustering, and validation together for this reason - an algorithm that returns groups is the easy half; judging whether those groups mean anything to the business is the half that determines whether the analysis survives contact with a skeptical stakeholder.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>unsupervisedlearning</category>
      <category>clustering</category>
      <category>datascience</category>
    </item>
    <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>
