<?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>Your Agent Has Observability. It Doesn't Have Evals.</title>
      <dc:creator>Jason Lau</dc:creator>
      <pubDate>Thu, 24 Sep 2026 05:43:11 +0000</pubDate>
      <link>https://dev.to/jasonl888/your-agent-has-observability-it-doesnt-have-evals-3ce5</link>
      <guid>https://dev.to/jasonl888/your-agent-has-observability-it-doesnt-have-evals-3ce5</guid>
      <description>&lt;p&gt;&lt;strong&gt;TLDR:&lt;/strong&gt; In LangChain's survey of 1,340 practitioners, 89% had implemented observability for their agents and 94% of teams with agents in production had it. Offline evaluations: 52.4%. Online evaluations: 37.3%. Fewer than a third of respondents run both. That gap is not a tooling gap — it's a category error. A trace records what the agent &lt;em&gt;did&lt;/em&gt;. Nothing in a trace records whether it was &lt;em&gt;right&lt;/em&gt;, because a confidently wrong tool-call chain emits exactly the same telemetry as a correct one: same token counts, same latencies, same &lt;code&gt;finish_reason&lt;/code&gt;, same green spans end to end. The OpenTelemetry GenAI conventions make this literal — there is no attribute for correctness until you run an evaluator and write one in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two traces
&lt;/h2&gt;

&lt;p&gt;Here is the shape of the problem, in the only form that makes it obvious. A support agent handles refund requests. Two runs, side by side, as your observability platform renders them.&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="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Run&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;A&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;span class="nl"&gt;"trace_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;"a41f…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"gen_ai.operation.name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"agent_run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gen_ai.request.model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"claude-sonnet-4-6"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"duration_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3180&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gen_ai.usage.input_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2847&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"gen_ai.usage.output_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;193&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gen_ai.response.finish_reasons"&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;"stop"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"spans"&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="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lookup_customer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OK"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"duration_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;112&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;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"list_orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OK"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"duration_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;340&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;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"issue_refund"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OK"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"duration_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;908&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;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"error_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&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;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Run&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;B&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;span class="nl"&gt;"trace_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;"b73c…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"gen_ai.operation.name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"agent_run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gen_ai.request.model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"claude-sonnet-4-6"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"duration_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3204&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gen_ai.usage.input_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2851&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"gen_ai.usage.output_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;188&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gen_ai.response.finish_reasons"&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;"stop"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"spans"&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="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lookup_customer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OK"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"duration_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;118&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;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"list_orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OK"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"duration_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;336&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;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"issue_refund"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OK"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"duration_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;913&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;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"error_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&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;Every field that your monitoring dashboard aggregates is, for practical purposes, identical. Both runs are three spans, all &lt;code&gt;OK&lt;/code&gt;, no errors, ~3.2 seconds, ~2,850 input tokens, a clean &lt;code&gt;stop&lt;/code&gt; finish. On a latency percentile chart they are the same point twice. On a token-spend chart they are the same point twice. On an error-rate chart they contribute equally to a rate of zero.&lt;/p&gt;

&lt;p&gt;In Run B the customer had two orders — a $40 one they were complaining about and a $2,300 one they weren't. The agent called &lt;code&gt;issue_refund&lt;/code&gt; on the second.&lt;/p&gt;

&lt;p&gt;Nothing in that trace is wrong. &lt;code&gt;issue_refund&lt;/code&gt; really was called. It really did return &lt;code&gt;OK&lt;/code&gt;, because refunding $2,300 is an entirely successful refund. The span is green because the API call succeeded, and the API call succeeding is the only thing a span status has ever meant.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap has a number attached to it
&lt;/h2&gt;

&lt;p&gt;This is not a hypothetical asymmetry, and it isn't rare. LangChain's &lt;em&gt;State of Agent Engineering&lt;/em&gt; report, published 23 May 2026 and drawn from &lt;a href="https://www.langchain.com/state-of-agent-engineering" rel="noopener noreferrer"&gt;1,340 responses collected between 18 November and 2 December 2025&lt;/a&gt;, puts numbers on both halves:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Adoption&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Agents in production&lt;/td&gt;
&lt;td&gt;57% (67% at 10,000+ employees)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observability implemented&lt;/td&gt;
&lt;td&gt;89% overall, 94% among production teams&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full per-step tracing&lt;/td&gt;
&lt;td&gt;62% overall, 71.5% among production teams&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Offline evaluations&lt;/strong&gt; on a test set&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;52.4%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Online evaluations&lt;/strong&gt; in production&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;37.3%&lt;/strong&gt; (44.8% among production teams)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Both offline &lt;em&gt;and&lt;/em&gt; online&lt;/td&gt;
&lt;td&gt;fewer than a third&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Observability is close to universal; systematic evaluation is roughly a coin flip. And the same survey names quality — accuracy, relevance, consistency, tone — as the top barrier to getting agents into production, cited by about a third of respondents. Those two findings sit awkwardly together. Quality is the thing most likely to block a deployment, and it is the thing least likely to be measured. Roughly nine teams in ten can reconstruct what their agent did last Tuesday. About five in ten can tell you whether it was any good.&lt;/p&gt;

&lt;p&gt;The instinct to instrument first is a reasonable one, and the tooling market rewarded it — tracing is a solved, buyable, drop-in problem. Evaluation is not buyable in the same way, because it requires you to state what &lt;em&gt;correct&lt;/em&gt; means for your domain, and nobody can do that for you. Observability got adopted at the speed of a vendor integration. Evaluation adoption moves at the speed of the hardest product conversation on your team.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the dashboard structurally cannot tell you
&lt;/h2&gt;

&lt;p&gt;It's worth being precise about &lt;em&gt;why&lt;/em&gt; the traces above are indistinguishable, because the reason isn't an oversight anyone will patch.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/" rel="noopener noreferrer"&gt;OpenTelemetry GenAI semantic conventions&lt;/a&gt; define the standard attribute registry for instrumenting LLM and agent workloads — the fields your platform is almost certainly collecting. The core ones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;gen_ai.request.model&lt;/code&gt;, &lt;code&gt;gen_ai.provider.name&lt;/code&gt; — which model answered&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gen_ai.usage.input_tokens&lt;/code&gt;, &lt;code&gt;gen_ai.usage.output_tokens&lt;/code&gt;, plus cache-read, cache-creation and reasoning token counts — how much it cost&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gen_ai.response.finish_reasons&lt;/code&gt; — why it stopped&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gen_ai.operation.name&lt;/code&gt; — &lt;code&gt;chat&lt;/code&gt;, &lt;code&gt;tool_call&lt;/code&gt;, &lt;code&gt;agent_run&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gen_ai.input.messages&lt;/code&gt;, &lt;code&gt;gen_ai.output.messages&lt;/code&gt; — what went in and came out&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Read that list looking for the field that goes red when the agent refunds the wrong order. It isn't there, and it can't be, because every one of those attributes is a property of &lt;em&gt;the call&lt;/em&gt; rather than of &lt;em&gt;the answer&lt;/em&gt;. Cost, latency, model version and stop reason are all knowable from inside the request/response cycle. Correctness is only knowable with reference to something outside it — a ground truth, a business rule, a resulting state you can inspect.&lt;/p&gt;

&lt;p&gt;The conventions do include an evaluation namespace — &lt;code&gt;gen_ai.evaluation.name&lt;/code&gt;, &lt;code&gt;gen_ai.evaluation.score.value&lt;/code&gt;, &lt;code&gt;gen_ai.evaluation.score.label&lt;/code&gt;, &lt;code&gt;gen_ai.evaluation.explanation&lt;/code&gt; — and &lt;code&gt;score.label&lt;/code&gt; accepts values like &lt;code&gt;"correct"&lt;/code&gt;. But that namespace is a slot, not a sensor. Those attributes are empty until something you wrote computes a judgement and populates them. The standard has a correctness-shaped hole in it and expects you to bring your own filling. Most teams ship the ninety per cent that auto-instruments and never fill the hole.&lt;/p&gt;

&lt;p&gt;This is the practical consequence: &lt;strong&gt;your alerting is wired to signals that do not move when quality moves.&lt;/strong&gt; Latency won't regress when the agent starts refunding the wrong order — it might even improve. Token counts won't move. Error rate stays at zero, because from the runtime's point of view nothing failed. You will find out from a customer.&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%2Fnxmnyrx1lhevxmo75hjn.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%2Fnxmnyrx1lhevxmo75hjn.png" alt="A two-panel comparison titled " width="800" height="376"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every field a trace carries is a property of the call. Correctness is a property of the outcome, and the outcome has to be checked against something the request doesn't contain.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What measuring the outcome actually looks like
&lt;/h2&gt;

&lt;p&gt;The fix has a well-established precedent, and it predates the current agent wave: verify the resulting state, not the transcript.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://arxiv.org/abs/2406.12045" rel="noopener noreferrer"&gt;τ-bench&lt;/a&gt;, the tool-agent-user benchmark from Sierra, is built on exactly this move. Its authors describe the method plainly: they "employ an efficient and faithful evaluation process that compares the database state at the end of a conversation with the annotated goal state." Not the wording of the reply. Not whether the right tool name appeared. The database, afterwards, against what the database should have contained.&lt;/p&gt;

&lt;p&gt;Applied to Run B above, that check is unglamorous and about four lines long:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_refund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conversation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db_before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db_after&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;annotated_goal&lt;/span&gt;      &lt;span class="c1"&gt;# refund order #4471, $40.00
&lt;/span&gt;    &lt;span class="n"&gt;actual&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db_before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db_after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# refunded order #4473, $2,300.00
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refunded_order_ids&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refunded_order_ids&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_refunded_cents&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_refunded_cents&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That assertion fails on Run B and passes on Run A. No telemetry field distinguished them; a four-line state diff does it instantly. The same pattern generalises to whatever your agent actually touches — if it writes code, run the test suite; if it files tickets, assert on the ticket's fields; if it produces a document, check the document against the source.&lt;/p&gt;

&lt;p&gt;τ-bench also contributes a metric worth stealing outright. Alongside the usual &lt;code&gt;pass@k&lt;/code&gt; ("at least one of k attempts succeeded"), the paper proposes &lt;strong&gt;&lt;code&gt;pass^k&lt;/code&gt;&lt;/strong&gt; — all k attempts succeeded — "to evaluate the reliability of agent behavior over multiple trials." This is the honest metric for anything customer-facing, because your customers are not running your agent eight times and keeping the best result; they're getting one run each. The arithmetic is brutal and worth internalising: an agent that succeeds 90% of the time, if failures are roughly independent, is fully reliable across eight consecutive runs only 0.9⁸ ≈ 43% of the time.&lt;/p&gt;

&lt;p&gt;The benchmark's own headline finding, from the abstract, is the part most teams have not absorbed: "even state-of-the-art function calling agents (like gpt-4o) succeed on &amp;lt;50% of the tasks, and are quite inconsistent (pass^8 &amp;lt;25% in retail)." That was measured on a purpose-built benchmark with annotated goal states. Your production agent has neither, and the assumption that it is doing better is currently unfalsifiable — which is precisely the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  "We'll just add an LLM judge"
&lt;/h2&gt;

&lt;p&gt;This is the standard next move, and it's the right direction, but it is not free and it is not a shortcut past defining correctness. A judge is another model whose agreement with your actual standard is an empirical question — one that has now been measured at scale, and the results argue for treating judges as instruments that need calibration rather than as oracles.&lt;/p&gt;

&lt;p&gt;The largest systematic study of the approach to date — &lt;a href="https://arxiv.org/abs/2606.19544" rel="noopener noreferrer"&gt;21 judges from nine providers across MT-Bench, JudgeBench and RewardBench, 118 runs and roughly 541,000 individual judgments&lt;/a&gt; — opens by naming the methodological problem directly: "judge validation in practice relies on exact-match agreement, a metric that does not correct for chance and systematically overstates discriminative ability." Two findings in particular should change how you read a judge's score:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chance correction matters enormously.&lt;/strong&gt; The paper reports that "kappa deflation between exact match and Cohen's κ is universal (33–41 pp on MT-Bench)." A judge advertising 80% raw agreement with human labels may be delivering far less genuine discriminative power than that number implies, because a meaningful share of those agreements are what you'd get by guessing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistency is not accuracy.&lt;/strong&gt; The authors document a "consistency–bias paradox," finding that "high test–retest reliability (&amp;gt;0.95) coexists with severe position bias (&amp;gt;0.10)" in production judges. A judge that returns the same verdict every time looks trustworthy on a stability check and can still be reliably wrong in a direction that tracks the order you presented the options in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this makes LLM-as-judge unusable — it remains the only practical option for open-ended generation where no reference string exists. It does mean a judge is a component that itself requires a labelled validation set, a chance-corrected agreement statistic, and a position-swap check before you let it gate a release. Where you can verify state instead, verify state: an assertion on a database row has no position bias.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning the traces you already have into the evals you don't
&lt;/h2&gt;

&lt;p&gt;The useful thing about being in the 89% is that you are already sitting on the raw material. Traces are unlabelled evaluation data. The work is labelling and replaying them.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pull fifty real traces from last month&lt;/strong&gt;, weighted toward the unusual: long tool chains, retries, sessions a human took over, anything a customer followed up on. Not fifty happy paths — happy paths inflate every number you're about to compute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For each one, write down what should have happened&lt;/strong&gt;, in a form a program can check. An order id. A row count. A set of fields. An exit code. If you cannot express it as an assertion, that's the product conversation surfacing, and it is better to have it now than during an incident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replay them on every change&lt;/strong&gt; — prompt edits, model version bumps, tool-schema changes, dependency upgrades — and gate the release on the result. This is the step that converts the set from a document into a regression gate. It is also, mechanically, unit testing; the only novel part is that the assertions run against end state rather than return values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report &lt;code&gt;pass^k&lt;/code&gt;, not just the mean.&lt;/strong&gt; Run each case several times and count only the cases that pass every time. The number will be worse than your average, and it is the one that corresponds to what a single user experiences.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feed failures back.&lt;/strong&gt; Every production failure becomes case fifty-one. The set gets stronger exactly where the system is weakest, which is the whole point of curating it by hand.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 2 and 3 are where teams stall, and the reason is rarely technical — it's that "what should have happened" turns out to be genuinely contested between engineering, support and finance. Working through that with a versioned golden set and a regression gate on every change is a chunk of what 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=agent-observability-not-evaluation" rel="noopener noreferrer"&gt;AI Applications with LLMs course&lt;/a&gt; spends its validation module on, alongside the observability side that most teams have already built. The two lessons sit deliberately close together, because shipping one without the other is the failure this whole article is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-line version
&lt;/h2&gt;

&lt;p&gt;Observability answers "what happened?" Evaluation answers "should it have?" The first question is solved, buyable and nearly universally adopted. The second is the one your customers are actually asking, and roughly half of teams running agents in production still have no systematic way to answer it.&lt;/p&gt;

&lt;p&gt;If you have 89% of the stack, the remaining work isn't another dashboard. It's fifty labelled traces and an assertion that fails.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;LangChain. (2026, May 23). &lt;a href="https://www.langchain.com/state-of-agent-engineering" rel="noopener noreferrer"&gt;State of Agent Engineering&lt;/a&gt; — survey of 1,340 practitioners, fieldwork 18 Nov–2 Dec 2025; source of the observability (89%), offline eval (52.4%), online eval (37.3%) and production (57%) figures. Accessed 2026-09-23.&lt;/li&gt;
&lt;li&gt;Yao, S., Shinn, N., Razavi, P., &amp;amp; Narasimhan, K. (2024). &lt;a href="https://arxiv.org/abs/2406.12045" rel="noopener noreferrer"&gt;τ-bench: A Benchmark for Tool-Agent-User Interaction in Real-World Domains&lt;/a&gt;. &lt;em&gt;arXiv:2406.12045&lt;/em&gt; — source of the database-state evaluation method, the &lt;code&gt;pass^k&lt;/code&gt; metric, and the &lt;code&gt;pass^8 &amp;lt;25%&lt;/code&gt; retail result. Accessed 2026-09-23.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2606.19544" rel="noopener noreferrer"&gt;Reliability without Validity: A Systematic, Large-Scale Evaluation of LLM-as-a-Judge Models Across Agreement, Consistency, and Bias&lt;/a&gt;. (2026). &lt;em&gt;arXiv:2606.19544&lt;/em&gt; — 21 judges, nine providers, ~541,000 judgments; source of the kappa-deflation and consistency–bias findings. Accessed 2026-09-23.&lt;/li&gt;
&lt;li&gt;OpenTelemetry. &lt;a href="https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/" rel="noopener noreferrer"&gt;Gen AI Attributes registry&lt;/a&gt; — the standard &lt;code&gt;gen_ai.*&lt;/code&gt; span attributes, including the &lt;code&gt;gen_ai.evaluation.*&lt;/code&gt; namespace referenced above. Accessed 2026-09-23.&lt;/li&gt;
&lt;li&gt;SophiArch. &lt;a href="https://lms.sophiarch.com/courses/ai-applications-with-llms?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=agent-observability-not-evaluation" rel="noopener noreferrer"&gt;AI Applications with LLMs&lt;/a&gt; — course covering the probabilistic contract, context window architecture, output validation layers, evaluation frameworks and golden sets, orchestration patterns, and LLM observability, including the regression-gate workflow described here.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>agents</category>
      <category>observability</category>
      <category>evaluation</category>
      <category>llm</category>
    </item>
    <item>
      <title>Text-to-SQL Accuracy Isn't a Model Problem. It's a Schema Problem.</title>
      <dc:creator>Jason Lau</dc:creator>
      <pubDate>Wed, 16 Sep 2026 08:10:15 +0000</pubDate>
      <link>https://dev.to/jasonl888/text-to-sql-accuracy-isnt-a-model-problem-its-a-schema-problem-512h</link>
      <guid>https://dev.to/jasonl888/text-to-sql-accuracy-isnt-a-model-problem-its-a-schema-problem-512h</guid>
      <description>&lt;p&gt;&lt;strong&gt;TLDR:&lt;/strong&gt; dbt Labs ran the same eleven questions four different ways and found that &lt;em&gt;modelling the schema&lt;/em&gt; — with no semantic layer involved at all — moved text-to-SQL accuracy from 64.5% to 90.0%. Adding a semantic layer on top of that modelled schema moved it a further 8 points. Snowflake, using a semantic model across four BIRD databases, measured a 21-point average lift. MotherDuck pointed three frontier models at small, clean schemas with nothing but the DDL and reported 95% — though those same runs score 58–64% under BIRD's strict scoring. Read side by side, these look contradictory. They aren't. None of them is measuring the model. They're measuring how much translation work somebody already did to the schema, and how forgivingly the answers were graded.&lt;/p&gt;

&lt;p&gt;Ask an AI assistant to write a query against your warehouse and it will, almost every time, produce something that runs. That's the trap. A query that executes and returns a number looks exactly like a query that answers the question — right up until someone downstream builds a forecast on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two queries, one question, two answers
&lt;/h2&gt;

&lt;p&gt;Here's the shape this takes on an ordinary warehouse, away from the benchmark leaderboards. A SaaS company wants last month's revenue. An AI assistant, asked two reasonable questions, produces two queries.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Query A: "what was our revenue last month?"&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount_due&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'paid'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;billing_period&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-08'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Query B: "break down last month's revenue by product"&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_due&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;invoice_line_items&lt;/span&gt; &lt;span class="n"&gt;ili&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ili&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;invoice_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;invoice_id&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ili&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'paid'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;billing_period&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-08'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-- Query A result:
revenue = 1,148,000

-- Query B result:
product            revenue
-----------------  ---------
Platform (base)    1,664,000
Extra seats          988,000
Usage overage        705,000
-----------------  ---------
total              3,357,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both queries ran without error. Both came from reasonable, good-faith prompts. But Query B sums &lt;code&gt;i.amount_due&lt;/code&gt; — the &lt;em&gt;invoice&lt;/em&gt; total — once for every line item attached to that invoice. A customer with a base plan, an extra seat and a usage overage contributes their entire invoice total three times, once under each product. "Platform (base)" doesn't show platform revenue; it shows the full value of every invoice that happened to contain a platform line.&lt;/p&gt;

&lt;p&gt;The fix is one column: sum &lt;code&gt;ili.line_amount&lt;/code&gt;, the line's own amount, not the invoice's. Nobody wrote a bug. The join changed what one row means, and &lt;code&gt;SUM&lt;/code&gt; kept adding as if it hadn't. If Query B's breakdown reaches a board deck before anyone reconciles it against Query A, that takes an awkward meeting to walk back.&lt;/p&gt;

&lt;p&gt;This is not a model failure. Point a frontier model at this schema and it will make the same mistake confidently and articulately, because nothing in the schema says that &lt;code&gt;invoice_line_items&lt;/code&gt; has a different grain than &lt;code&gt;invoices&lt;/code&gt;, or that &lt;code&gt;amount_due&lt;/code&gt; is only additive at the invoice level. That knowledge lives in someone's head, or it lives in a semantic layer. If it lives in neither, which query you get is a coin flip.&lt;/p&gt;

&lt;h2&gt;
  
  
  The spread that looks like disagreement
&lt;/h2&gt;

&lt;p&gt;Three of the most-cited text-to-SQL results around, read together, look like they're arguing. Read separately, each is internally consistent — which is the tell that they're measuring different things.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;dbt Labs' April 2026 benchmark&lt;/strong&gt; tested &lt;a href="https://docs.getdbt.com/blog/semantic-layer-vs-text-to-sql-2026" rel="noopener noreferrer"&gt;four configurations&lt;/a&gt; over an insurance dataset, eleven questions, twenty runs per model. The one people quote is text-to-SQL versus semantic layer on a &lt;em&gt;modelled&lt;/em&gt; project: Claude Sonnet 4.6 at 90.0% against 98.2%, GPT-5.3-Codex at 84.1% against 100.0%. The more interesting comparison is the one underneath it. Against the original normalised tables, plain text-to-SQL managed 64.5% across all eleven questions; the same method against a modelled project hit 90.0%. That 25.5-point improvement came from modelling alone, with no semantic layer anywhere in the picture — and dbt built it by prompting an LLM to write "as few dbt models as possible," which produced just three. Their summary: &lt;em&gt;"Adding even minimal modeling on top of raw tables improved results across the board."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snowflake's semantic-model evaluation&lt;/strong&gt; (March 2025, using Claude 3.5 Sonnet) is often cited as "57% to 78% on BIRD." It's really an average across four BIRD databases, and the spread inside it matters: &lt;code&gt;debit_card_specializing&lt;/code&gt; went 52% → 83%, &lt;code&gt;california_schools&lt;/code&gt; 63% → 80%, &lt;code&gt;thrombosis_prediction&lt;/code&gt; 45% → 70%, &lt;code&gt;toxicology&lt;/code&gt; 69% → 79%. Averaged, &lt;a href="https://www.snowflake.com/en/blog/engineering/agentic-semantic-model-text-to-sql/" rel="noopener noreferrer"&gt;an "approximately 20% increase in accuracy"&lt;/a&gt; — 21 points — from adding a semantic model, not from a newer model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MotherDuck ran the opposite experiment&lt;/strong&gt;, pointing Claude Opus 4.5, GPT-5.2 and Gemini 3 Flash at 500 BIRD questions with, in their words, &lt;a href="https://motherduck.com/blog/bird-bench-and-data-models/" rel="noopener noreferrer"&gt;"No semantic layer. No query history. No special context. Just the schema."&lt;/a&gt; The reported result is 95%. That figure sits at the top of a four-tier evaluation ladder, and the bottom rung is a lot less flattering: 64.0% (train) and 58.2% (test) under strict BIRD execution matching, rising through correction of known benchmark errors and tolerance for formatting differences to 94.9%/94.4% once an LLM judges whether the answer is defensible. Their argument is two-part — that BIRD's strict scoring is itself misleading, because &lt;em&gt;"to score above 62% under strict rules, you have to start reproducing the benchmark's mistakes,"&lt;/em&gt; and that &lt;em&gt;"Good data modeling is the semantic layer."&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So there are two variables moving here, not one, and honest reading requires separating them. &lt;strong&gt;Scoring strictness&lt;/strong&gt; explains most of the distance between MotherDuck's 95% and everyone else's numbers; you cannot line that figure up against dbt's or Snowflake's and treat the gap as real capability. &lt;strong&gt;Schema modelling&lt;/strong&gt; explains the rest, and it's the variable you control. dbt moved text-to-SQL a long way with three models and no semantic layer. Snowflake moved it 21 points with a semantic model over databases they didn't restructure. MotherDuck got high marks with no layer at all — over BIRD databases that, as they note, average seven tables. None of these is a claim about which model writes better SQL.&lt;/p&gt;

&lt;p&gt;There's a ceiling worth keeping in view too. BIRD's own paper reports that its human baseline — data engineers and database students — reached &lt;a href="https://arxiv.org/abs/2305.03111" rel="noopener noreferrer"&gt;"the human result of 92.96%"&lt;/a&gt; execution accuracy. People who write SQL for a living, working on databases of seven tables, get roughly one question in fourteen wrong. The bar was never "flawless." It's "at least as reliable as the analyst who used to own this."&lt;/p&gt;

&lt;h2&gt;
  
  
  What "the schema needs modelling" actually means
&lt;/h2&gt;

&lt;p&gt;"Modelling" sounds like an abstraction exercise. In practice, on a schema like the one above, it's answering three concrete questions before anything touches a model:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the grain of each table, in one sentence?&lt;/strong&gt; &lt;code&gt;invoices&lt;/code&gt; is one row per invoice. &lt;code&gt;invoice_line_items&lt;/code&gt; is one row per line on an invoice. Join them and your result set's grain becomes "one row per line item" — so any &lt;code&gt;SUM&lt;/code&gt; of an invoice-level column is now double-, triple- or n-counting, depending on how many lines the average invoice carries. That single fact is what would have caught Query B.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which columns carry business meaning that isn't in their name?&lt;/strong&gt; &lt;code&gt;status = 'paid'&lt;/code&gt; looks unambiguous. Does it need to exclude refunds? Is a partially refunded invoice still "paid" for revenue purposes? These are policy decisions someone made once, verbally, in a meeting nobody minuted — and no assistant can recover them from the schema alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is there more than one path to the same number?&lt;/strong&gt; Revenue might come from &lt;code&gt;invoices.amount_due&lt;/code&gt;, from summing &lt;code&gt;invoice_line_items.line_amount&lt;/code&gt;, or from an &lt;code&gt;mrr_snapshots&lt;/code&gt; table a finance job populates nightly. Three legitimate paths, three numbers that won't quite agree, and none of them "wrong" — they answer slightly different questions that all get asked as "what's our revenue."&lt;/p&gt;

&lt;h2&gt;
  
  
  "Just model your warehouse" is easy advice to give
&lt;/h2&gt;

&lt;p&gt;The obvious objection to all of this: dbt's benchmark fixed its schema with three new models over an eleven-question insurance dataset. You have four hundred tables, three teams who each define revenue differently, and a migration budget of zero. "Model the schema properly" is not a thing you can do this quarter, and anyone who has tried knows that the modelling is the easy half — getting three departments to agree which definition wins is the hard one.&lt;/p&gt;

&lt;p&gt;That constraint is exactly why semantic layers exist. They're the retrofit path: a place to encode grain, join paths and metric definitions &lt;em&gt;without&lt;/em&gt; restructuring the warehouse underneath. Snowflake's 21-point average came this way, over databases nobody rebuilt. Treating "clean DDL" and "semantic layer" as interchangeable is true for benchmarks and false for anyone with legacy tables and a roadmap.&lt;/p&gt;

&lt;p&gt;There's also a second argument for the retrofit that the accuracy columns actively hide. In dbt's unmodelled configuration, the semantic layer scored &lt;strong&gt;0.0%&lt;/strong&gt; on the subset of questions requiring too many joins to resolve — not because it answered them wrongly, but because it declined to answer at all. As dbt puts it: &lt;em&gt;"the Semantic Layer tells you it can't answer. It never returns invalid data. Text-to-SQL will cheerfully give you a wrong number."&lt;/em&gt; Plain text-to-SQL scored 70–100% on that same subset, which looks like a win until you remember that nothing in those results distinguishes a correct answer from a confident one. A coverage gap you can see beats a coverage gap that quietly returns 3,357,000. That asymmetry appears in no accuracy column anywhere, and for a number headed to a board deck it may matter more than the points do.&lt;/p&gt;

&lt;p&gt;What both routes share is scope. Neither dbt's three models nor a semantic layer requires modelling the whole warehouse — only the slice that answers the questions people actually ask. That's the practical move: take the handful of questions your team asks weekly, model just the tables those touch, and leave the other three hundred and eighty alone until someone asks them something.&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%2Fhxub55x7ll6hoo932tqe.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%2Fhxub55x7ll6hoo932tqe.png" alt="A two-column comparison. Left column, labeled Unmodeled schema: cryptic table names, one status column doing three jobs, three tables that could all answer revenue, no documented grain. Right column, labeled Modeled schema (via semantic layer or clean DDL): one canonical revenue metric, documented grain per table, business rules encoded once. An arrow from left to right is labeled as the gap the benchmarks keep measuring." width="800" height="368"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The benchmarks didn't find a model problem. They found a translation-work problem, and measured what happens when someone does the translation once instead of leaving it to a fresh guess on every query.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist that transfers to your warehouse
&lt;/h2&gt;

&lt;p&gt;None of the studies above tells you which side of the gap your own schema sits on. Run this instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Take your five most-asked business questions and write down, for each table involved, its grain in one sentence.&lt;/strong&gt; If you can't do that quickly, an assistant generating SQL against those tables can't either.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find every column whose name promises more precision than its values deliver&lt;/strong&gt; — &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;category&lt;/code&gt; especially. If &lt;code&gt;status&lt;/code&gt; needs a footnote to interpret correctly, that footnote has to live somewhere the assistant can read: a view, a semantic layer, a column comment. Not a Slack thread from 2024.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask the same business question two ways and see whether you get two numbers.&lt;/strong&gt; That's what happened above. If it happens on your warehouse, you have a modelling gap rather than an AI-competence gap, and no model upgrade closes it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The numbers make the same point from three directions: 90% accuracy sounds like a solved problem until you notice the missing 10% is where double-counted joins and silently redefined metrics live, invisible precisely because the query still runs. Building the judgment to catch that — reading a join and knowing what it did to the grain, reconciling a number against a second path before trusting it — is what &lt;a href="https://lms.sophiarch.com/courses/sql-for-data-analysis?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=text-to-sql-schema-problem" rel="noopener noreferrer"&gt;SophiArch's SQL for Data Analysis course&lt;/a&gt; is built around, with a dedicated lesson on joins and double-counting early on and a closing module on auditing SQL that an AI assistant wrote.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Ganz, J., &amp;amp; Perigaud, B. (2026, April 7). &lt;a href="https://docs.getdbt.com/blog/semantic-layer-vs-text-to-sql-2026" rel="noopener noreferrer"&gt;Semantic Layer vs. Text-to-SQL: 2026 Benchmark Update&lt;/a&gt;. &lt;em&gt;dbt Developer Blog&lt;/em&gt;. Accessed 2026-09-16.&lt;/li&gt;
&lt;li&gt;Zayats, T. (2025, March 31). &lt;a href="https://www.snowflake.com/en/blog/engineering/agentic-semantic-model-text-to-sql/" rel="noopener noreferrer"&gt;Agentic Semantic Model Improvement: Elevating Text-to-SQL Performance&lt;/a&gt;. &lt;em&gt;Snowflake Engineering Blog&lt;/em&gt;. Accessed 2026-09-16.&lt;/li&gt;
&lt;li&gt;MotherDuck. &lt;a href="https://motherduck.com/blog/bird-bench-and-data-models/" rel="noopener noreferrer"&gt;Your Data Model Is the Semantic Layer&lt;/a&gt;. &lt;em&gt;MotherDuck Blog&lt;/em&gt; (undated). Accessed 2026-09-16.&lt;/li&gt;
&lt;li&gt;Li, J., Hui, B., Qu, G., et al. (2023). &lt;a href="https://arxiv.org/abs/2305.03111" rel="noopener noreferrer"&gt;Can LLM Already Serve as A Database Interface? A BIg Bench for Large-Scale Database Grounded Text-to-SQLs&lt;/a&gt;. &lt;em&gt;arXiv:2305.03111&lt;/em&gt; — source of the 92.96% human-performance baseline on BIRD-SQL. Accessed 2026-09-16.&lt;/li&gt;
&lt;li&gt;SophiArch. &lt;a href="https://lms.sophiarch.com/courses/sql-for-data-analysis?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=text-to-sql-schema-problem" rel="noopener noreferrer"&gt;SQL for Data Analysis&lt;/a&gt; — course covering filtering, aggregation, joins, CTEs, window functions and cohort analysis, closing with a module on auditing AI-generated SQL for the silent failure modes described here: double-counting joins, NULL traps and misplaced filters.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>sql</category>
      <category>text2sql</category>
      <category>llm</category>
      <category>ai</category>
    </item>
    <item>
      <title>Long Context Didn't Kill RAG. It Killed Lazy RAG</title>
      <dc:creator>Jason Lau</dc:creator>
      <pubDate>Wed, 09 Sep 2026 06:41:30 +0000</pubDate>
      <link>https://dev.to/jasonl888/long-context-didnt-kill-rag-it-killed-lazy-rag-508</link>
      <guid>https://dev.to/jasonl888/long-context-didnt-kill-rag-it-killed-lazy-rag-508</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short version:&lt;/strong&gt; In a controlled comparison published in June 2026, long-context prompting beat semantic RAG on correctness — 73.1% against 65.4% — and cost 26 times as much per query to do it. That single row is the whole argument: long context is genuinely better at the thing people claim it's better at, and the reasons to retrieve anyway are cost, corpus size, freshness, and permissions, not accuracy. What long context actually made obsolete is the naive pipeline — fixed chunks, one embedding model, top-k by cosine, no re-ranking — which was never competitive with a big window and is barely competitive with a well-built retriever either.&lt;/p&gt;

&lt;p&gt;Somebody says it in a planning meeting roughly once a quarter now: &lt;em&gt;models take a million tokens, so why are we still maintaining a vector database?&lt;/em&gt; It is a fair question, asked in bad faith about half the time, and the usual response is a defensive listicle titled "RAG isn't dead." Those posts are mostly right and almost never useful, because they argue the conclusion instead of giving you a way to reach it for your own corpus.&lt;/p&gt;

&lt;p&gt;So let's do it the other way round. Concede the strongest version of the opposing case first, then work out what's left.&lt;/p&gt;

&lt;h2&gt;
  
  
  The strongest case for "just put it in the window"
&lt;/h2&gt;

&lt;p&gt;The most useful comparison I've found isn't a vendor benchmark, it's a preprint that ran both architectures over the same document-grounded task and reported the trade-off as a frontier rather than a winner. &lt;a href="https://arxiv.org/abs/2606.20898" rel="noopener noreferrer"&gt;Hamilton et al.'s "The Token Tax of Epistemic Accuracy"&lt;/a&gt; (June 2026) found that "Long-context prompting achieved the highest correctness (73.1% vs. 65.4% for semantic RAG), but at 26 times the per-query token cost."&lt;/p&gt;

&lt;p&gt;Read that without flinching. Long context won on correctness by nearly eight points. Every architectural argument that starts "but retrieval is more accurate" is arguing against a measurement, and if you have been repeating that line, it needs retiring. Stuffing the documents in works, and it works better than a mid-quality retriever, for the obvious reason: a retriever that never surfaces the right passage has capped the system's accuracy at zero for that query, whereas a big window at least has the passage in the room.&lt;/p&gt;

&lt;p&gt;The honest framing is that retrieval buys you everything &lt;em&gt;except&lt;/em&gt; peak accuracy on a corpus that fits.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't buy you, and where the "26x" goes
&lt;/h2&gt;

&lt;p&gt;Start with the arithmetic, because it's the part people wave at rather than compute. Take a 200,000-token prompt — a modest corpus by 2026 standards — at an illustrative $3 per million input tokens. That's $0.60 per query before the model has emitted a single output token. At 50,000 queries a month, $30,000. Answer the same question from six retrieved chunks, call it 4,000 tokens, and you're at $0.012 a query, or $600 a month. Same model, same questions, fiftyfold difference, and none of it shows up in the demo because the demo ran forty queries.&lt;/p&gt;

&lt;p&gt;There's a real counter-argument here that RAG advocates usually miss: &lt;strong&gt;prompt caching&lt;/strong&gt;. If the 200,000-token prefix is identical across queries, most providers will serve the repeated portion at a steep discount, and the gap narrows dramatically. But notice the condition. Caching pays off when the corpus is small enough to fit, stable enough not to churn, and &lt;em&gt;shared&lt;/em&gt; across users — which is precisely the regime where nobody needed retrieval in the first place. The moment the prefix differs per user, per tenant, or per hour, the discount evaporates and you are back to paying full freight on every token you didn't need.&lt;/p&gt;

&lt;p&gt;Then there's the accuracy claim itself, which is conditional in a way the headline number hides. Three independent lines of evidence say a long window is not a uniform one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chroma's &lt;a href="https://www.trychroma.com/research/context-rot" rel="noopener noreferrer"&gt;Context Rot&lt;/a&gt; study (Hong, Troynikov and Huber, July 2025) evaluated 18 models and found that "model performance varies significantly as input length changes, even on simple tasks," with degradation worsening when distractors are present and when the question shares little wording with the passage that answers it.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2502.05167" rel="noopener noreferrer"&gt;NoLiMa&lt;/a&gt; (Modarressi et al., ICML 2025) tested 13 models that all claim 128K-plus context and reported that "at 32K, for instance, 11 models drop below 50% of their strong short-length baselines" — with GPT-4o falling from a 99.3% baseline to 69.7%.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2411.03538" rel="noopener noreferrer"&gt;Leng et al.&lt;/a&gt; found across 20 models that "while retrieving more documents can improve performance, only a handful of the most recent state of the art LLMs can maintain consistent accuracy at long context above 64k tokens."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Put those next to the Token Tax result and the picture resolves: long context wins when the relevant material fits comfortably and the query wears its keywords on its sleeve. It degrades exactly where enterprise questions live — paraphrased queries, near-duplicate distractors, corpora far past the window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three questions that settle it for your corpus
&lt;/h2&gt;

&lt;p&gt;The reason the general debate never converges is that it's the wrong altitude. Run your own numbers instead. In order:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Does the material relevant to a single query fit in the window with room to spare — every time?&lt;/strong&gt; Not the average query, the worst one. If your corpus is a 40-page product spec, retrieval is architecture you don't need. If it's 80,000 support tickets and the answer depends on which three of them are similar, no window is large enough and the question is settled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. What does it cost at your query volume, after caching?&lt;/strong&gt; Do the multiplication above with your real rate and your real monthly volume. Then ask whether the prefix is genuinely shared across queries or varies per user. Shared and stable, caching rescues long context; per-user or fast-changing, it doesn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Does the corpus change, and does who's asking change what they may see?&lt;/strong&gt; This is the axis that decides more real systems than the other two combined, and it barely appears in the online argument. A window has no concept of freshness — you rebuild the prompt or you serve stale content. It has no concept of permission either: everything in the prompt is visible to whoever's asking. An index has both. Documents are re-embedded when they change; filters are applied per user at query time. If your answer to either half is yes, you need a retrieval layer regardless of what the matrix below says about accuracy alone.&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%2Ftazbdz2197eym6uxkqon.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%2Ftazbdz2197eym6uxkqon.png" alt="A two-by-two decision matrix. Horizontal axis: fits in the context window versus exceeds the window. Vertical axis: static, single audience versus per-user or changes often. Top-left quadrant (fits, static) is labelled Long context - stop building a retriever, with a note that a shared prefix at volume should add caching. Top-right (exceeds, static) is labelled Retrieval - quality is now the accuracy ceiling. Bottom-left (fits, per-user or changing) is labelled Retrieval - a window has no freshness or per-user permissions. Bottom-right (exceeds, per-user or changing) is labelled Retrieval - fails all three tests." width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Accuracy alone points at long context. Freshness and permissions move three of the four quadrants to retrieval.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  "Better retrieval" isn't a vibe, it's a short and specific list
&lt;/h2&gt;

&lt;p&gt;If questions 1–3 land you on retrieval, the Token Tax result becomes a warning rather than a footnote: a mediocre retriever loses to a big window by eight points of correctness. So the pipeline that "everyone knows" — split at 512 tokens, embed, take the top five by cosine, done — isn't a starting point to defend. It's the thing long context genuinely made obsolete.&lt;/p&gt;

&lt;p&gt;What replaces it is four changes with measurable effects, each of which gets a lesson in the &lt;a href="https://lms.sophiarch.com/courses/retrieval-rag-systems?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=long-context-lazy-rag" rel="noopener noreferrer"&gt;Retrieval &amp;amp; RAG Systems course&lt;/a&gt; I teach, because they are the dials that actually move recall:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid retrieval.&lt;/strong&gt; Combine lexical (BM25) and dense scoring, fused by reciprocal rank. A &lt;a href="https://arxiv.org/abs/2604.01733" rel="noopener noreferrer"&gt;2026 benchmark of ten retrieval strategies over 23,088 financial queries&lt;/a&gt; found the best configuration was a two-stage hybrid-plus-reranking pipeline — and, more pointedly, that "BM25 outperforms state-of-the-art dense retrieval on financial documents." Exact identifiers, product codes and figures are where embeddings are weakest and keyword matching is strongest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-encoder re-ranking.&lt;/strong&gt; Retrieve broadly, then score query-document pairs jointly. &lt;a href="https://arxiv.org/abs/2212.06121" rel="noopener noreferrer"&gt;Rosa et al.&lt;/a&gt; found cross-encoders beat comparable bi-encoders by over four points on BEIR, with the gap widening on domains the model wasn't trained on — and, awkwardly for the default stack, that bi-encoder first-stage retrieval "provides no gains in comparison to a simpler retriever such as BM25 on out-of-domain tasks."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chunking as a retrieval decision.&lt;/strong&gt; Chunk size is not a formatting preference; it decides what a single embedding is asked to represent. Splitting mid-argument is how you get a passage that scores well and answers nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measurement before cleverness.&lt;/strong&gt; The same benchmark (Akarsu et al.) found that "query expansion methods (HyDE, multi-query) and adaptive retrieval provide limited benefit for precise numerical queries." Sophistication isn't automatically an improvement. Recall@k on your own labelled queries is what tells you which of these earned its latency.&lt;/li&gt;
&lt;/ul&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%2F64eea999pvt8rvbzpp6a.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%2F64eea999pvt8rvbzpp6a.png" alt="A retrieval pipeline diagram. A query splits into two parallel branches: BM25 lexical match, and Dense embedding similarity. Both feed into a Reciprocal Rank Fusion stage, which feeds into a Cross-Encoder Re-rank stage, which produces Top-k chunks that go into the prompt. A caption notes that naive RAG stops at Dense to Top-k, skipping the fusion and rerank stages." width="800" height="388"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Two retrieval signals fused by rank, then jointly scored against the query — not a single embedding search returning whatever ranks fifth by cosine.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That last one is the actual discipline. Every technique above is a hypothesis about your corpus, and the corpus gets a vote.&lt;/p&gt;

&lt;p&gt;So: not "is RAG dead." Does the material fit, what does it cost at volume, and does it change or vary by reader. Three questions, answerable in an afternoon with your own numbers, and they will land you somewhere more defensible than either side of the argument you were being asked to join.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Hamilton, A., Singh, R., Wise, M., Yousif, I., Carvalho, A., Shan, Z., Mayyas, M., Cavuoto, L. A., &amp;amp; Megahed, F. M. (2026). &lt;a href="https://arxiv.org/abs/2606.20898" rel="noopener noreferrer"&gt;The Token Tax of Epistemic Accuracy: Comparing RAG and Long-Context Architectures for Document-Grounded Generative AI Applications&lt;/a&gt;. &lt;em&gt;arXiv:2606.20898&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Hong, K., Troynikov, A., &amp;amp; Huber, J. (2025). &lt;a href="https://www.trychroma.com/research/context-rot" rel="noopener noreferrer"&gt;Context Rot: How Increasing Input Tokens Impacts LLM Performance&lt;/a&gt;. &lt;em&gt;Chroma Research&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Modarressi, A., Deilamsalehy, H., Dernoncourt, F., Bui, T., Rossi, R. A., Yoon, S., &amp;amp; Schütze, H. (2025). &lt;a href="https://arxiv.org/abs/2502.05167" rel="noopener noreferrer"&gt;NoLiMa: Long-Context Evaluation Beyond Literal Matching&lt;/a&gt;. &lt;em&gt;ICML 2025&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Leng, Q., Portes, J., Havens, S., Zaharia, M., &amp;amp; Carbin, M. (2024). &lt;a href="https://arxiv.org/abs/2411.03538" rel="noopener noreferrer"&gt;Long Context RAG Performance of Large Language Models&lt;/a&gt;. &lt;em&gt;arXiv:2411.03538&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Akarsu, M., Karaman, R. K., &amp;amp; Mierbach, C. (2026). &lt;a href="https://arxiv.org/abs/2604.01733" rel="noopener noreferrer"&gt;From BM25 to Corrective RAG: Benchmarking Retrieval Strategies for Text-and-Table Documents&lt;/a&gt;. &lt;em&gt;arXiv:2604.01733&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Rosa, G., Bonifacio, L., Jeronymo, V., Abonizio, H., Fadaee, M., Lotufo, R., &amp;amp; Nogueira, R. (2022). &lt;a href="https://arxiv.org/abs/2212.06121" rel="noopener noreferrer"&gt;In Defense of Cross-Encoders for Zero-Shot Retrieval&lt;/a&gt;. &lt;em&gt;arXiv:2212.06121&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;SophiArch. &lt;a href="https://lms.sophiarch.com/courses/retrieval-rag-systems?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=long-context-lazy-rag" rel="noopener noreferrer"&gt;Retrieval &amp;amp; RAG Systems&lt;/a&gt; — course covering chunking strategy, hybrid BM25-plus-dense retrieval with reciprocal rank fusion, cross-encoder re-ranking, retrieval metrics, index freshness and permission-aware retrieval.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>rag</category>
      <category>llm</category>
      <category>ai</category>
    </item>
    <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>
