<?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>Most of Your LLM Calls Should Return a Label and a Probability, Not a Sentence</title>
      <dc:creator>Jason Lau</dc:creator>
      <pubDate>Wed, 14 Oct 2026 15:44:49 +0000</pubDate>
      <link>https://dev.to/jasonl888/most-of-your-llm-calls-should-return-a-label-and-a-probability-not-a-sentence-1il9</link>
      <guid>https://dev.to/jasonl888/most-of-your-llm-calls-should-return-a-label-and-a-probability-not-a-sentence-1il9</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Routing, triage, moderation and approval prompts pick an answer from a list you wrote in advance. That makes them classifiers, but they usually skip what makes a classifier safe to run: label definitions the model actually follows, a labelled test set, a probability that means something, and a threshold with an escalation path. Schema-constrained output guarantees a &lt;em&gt;valid&lt;/em&gt; label, not a right one. In a September 2026 study, typed decision models were given the same definitions with the label names swapped between them. With yes/no names, one model changed 76.9% of its decisions, against 6.5% with 0/1 names, and its ranking went from 93.8% AUC to a below-chance 23.2%. The type-error rate was 0% the whole time. The same test runs on your own prompt in an afternoon and needs no labels. Then check whether the model's confidence ranks its own errors, set the threshold from what a mistake costs, and route everything below it to a person.&lt;/p&gt;

&lt;p&gt;The ticket router had been in production for five months when the billing team asked why they kept getting password resets.&lt;/p&gt;

&lt;p&gt;It was a single LLM call. The prompt listed six queues (billing, account access, technical, sales, urgent and general), each with a one-line description, and asked for JSON with one field: &lt;code&gt;queue&lt;/code&gt;. The response was validated against a schema, so the queue name was always one of the six. The integration had never thrown an error. Latency was fine. The dashboard showed six queues filling up at plausible rates.&lt;/p&gt;

&lt;p&gt;When someone finally labelled 500 recent tickets by hand, 40 of them were in the wrong queue. That's 8%, and at 20,000 tickets a month it's about 1,600 tickets a month landing with a team that has to read them, work out they aren't theirs and pass them on. Nothing had flagged it, because nothing in the system could. Every output was a valid queue name.&lt;/p&gt;

&lt;p&gt;This example is invented, but the design is common. The fix isn't a better prompt. It's recognising what the call actually is.&lt;/p&gt;

&lt;h2&gt;
  
  
  It was a classifier the whole time
&lt;/h2&gt;

&lt;p&gt;A call with a fixed set of possible answers, known before any input arrives, is a classification problem. Ticket routing, content moderation, "does this transaction need review", "is this lead qualified", "which of these five intents is the user expressing". These are classifiers, and classification has a mature discipline that most LLM pipelines skip:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A label set with written definitions&lt;/strong&gt;, where the definition decides the answer, not the everyday meaning of the label's name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A labelled evaluation set&lt;/strong&gt; from your own traffic, big enough to measure the error rate you care about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A score that ranks the model's errors&lt;/strong&gt;, ideally calibrated so that "0.9" means right about nine times in ten.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A threshold and an escalation path&lt;/strong&gt;: above the threshold the decision goes through, below it a person decides.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The router had a version of the first item and nothing else. Its prompt returned a label and no score, so there was nothing to set a threshold on and nowhere to send the unsure cases.&lt;/p&gt;

&lt;p&gt;Structured output made this harder to see, not easier. Constraining the response to a JSON schema removes the one error you would definitely have noticed, a malformed answer, and leaves every other kind in place. In September, TypeSafe AI took the idea one step further with &lt;a href="https://typesafe.ai/blog/introducing-system-one-models-and-jev" rel="noopener noreferrer"&gt;Jev&lt;/a&gt;, a model that doesn't generate text at all. It takes a question, an input and a declared set of options, and returns a probability distribution over those options, so its answer can only ever be one of them. That's a real improvement over parsing free text. It is also a guarantee about the format of the answer, and a study published the same month shows how little that guarantee covers.&lt;/p&gt;

&lt;h2&gt;
  
  
  A valid label is not the same as a right one
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://arxiv.org/abs/2609.26758" rel="noopener noreferrer"&gt;&lt;em&gt;Type-Safe Is Not Error-Free&lt;/em&gt;&lt;/a&gt; (Sun, Xu, Shi and Yang, Fudan University and USTC, September 2026) tested Jev and two open-weight models built the same way: Laya, built on ModernBERT, and Open-Jev, built on DeBERTa-v3. In all three, each option is a short &lt;strong&gt;name&lt;/strong&gt; plus a &lt;strong&gt;definition&lt;/strong&gt; of what the option means. For example, the name "yes" might carry the definition "A human should inspect this run", and "no" the definition "No human attention is warranted."&lt;/p&gt;

&lt;p&gt;The experiment was simple. The authors kept the question, the input, the names and the definition texts exactly the same, and only swapped which definition each name was attached to. The new pairing was written out in the input. A model that reads the definitions picks the same &lt;em&gt;definition&lt;/em&gt; in both versions, so it returns the other &lt;em&gt;name&lt;/em&gt;. A model that reads the names keeps returning the same name, which now means the opposite. The test set was 1,200 binary decisions across four workflows: invoice reconciliation, agent-trace triage, security-alert classification and customer-service escalation.&lt;/p&gt;

&lt;p&gt;Here is what happened to Laya:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option names&lt;/th&gt;
&lt;th&gt;Decisions that flipped&lt;/th&gt;
&lt;th&gt;AUC, original pairing&lt;/th&gt;
&lt;th&gt;AUC, swapped pairing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;yes / no&lt;/td&gt;
&lt;td&gt;76.9%&lt;/td&gt;
&lt;td&gt;93.8%&lt;/td&gt;
&lt;td&gt;23.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;false / true&lt;/td&gt;
&lt;td&gt;49.7%&lt;/td&gt;
&lt;td&gt;96.2%&lt;/td&gt;
&lt;td&gt;57.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rejected / accepted&lt;/td&gt;
&lt;td&gt;45.8%&lt;/td&gt;
&lt;td&gt;90.8%&lt;/td&gt;
&lt;td&gt;63.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0 / 1 (neutral)&lt;/td&gt;
&lt;td&gt;6.5%&lt;/td&gt;
&lt;td&gt;94.2%&lt;/td&gt;
&lt;td&gt;92.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A / B (neutral)&lt;/td&gt;
&lt;td&gt;6.0%&lt;/td&gt;
&lt;td&gt;94.2%&lt;/td&gt;
&lt;td&gt;94.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Selected rows from Table 1 of the paper.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;With yes/no names, three decisions in four flipped. The AUC of 23.2% is the more important number. It's below the 50% you'd get by guessing, which means the model's scores still separated the two classes, just in the wrong direction. As the authors point out, AUC doesn't depend on the decision threshold, so "this error cannot be corrected by adjusting the threshold."&lt;/p&gt;

&lt;p&gt;Jev itself was less extreme but showed the same pattern. With yes/no names it flipped 32.5% of decisions, 30.4 points more than with 0/1, and its AUC fell from 81.5% to 58.1%. Because Jev isn't deterministic, the authors also ran the original version twice to see how much it changes on its own. At most 1.33% of decisions changed between those runs, so the yes/no flip rate was 24 times that floor.&lt;/p&gt;

&lt;p&gt;Across every condition in the paper, the type-error rate was 0%. Every answer was a valid option. The authors' conclusion is the one to remember: "Type safety guarantees where a decision can land, but not what the options mean to the model."&lt;/p&gt;

&lt;p&gt;Two results point at the fix. Replacing the names with random five-character strings put all three models back at the neutral-name flip rate, and their accuracy before the swap was within 3.3 points of what they scored with neutral names. The meaning was carried by the definitions, and the model followed them. Polarity mattered more than familiarity, too: "absent/present", "negative/positive" and "rejected/accepted", pairs Laya had never produced during training, still raised its flip rate by 41 points over neutral names.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this matters when your names and definitions agree
&lt;/h3&gt;

&lt;p&gt;Nobody deliberately attaches "no" to the definition of yes, so it's fair to ask what this has to do with a production prompt. The swap is a measurement tool, not a production scenario. It's the cleanest way to find out whether a model reads your definitions or your label names. On ordinary inputs the two agree, so accuracy can't tell you which one the model is following.&lt;/p&gt;

&lt;p&gt;It matters wherever your definition is narrower than the word. The router's "urgent" queue was defined as "a production system is down or customers cannot log in." A ticket that says "I urgently need a copy of last month's invoice" is a billing ticket by that definition and an urgent one by the dictionary. If the model weights the name over the definition, that's where it goes. The same applies to "spam" defined to exclude legitimate cold sales email, "fraud" defined as a specific set of chargeback patterns, or "qualified" defined by your sales team's criteria rather than the word's general sense.&lt;/p&gt;

&lt;p&gt;None of this is new for language models in general. &lt;a href="https://arxiv.org/abs/2303.03846" rel="noopener noreferrer"&gt;Wei et al. (2023)&lt;/a&gt; showed that small language models "ignore flipped labels presented in-context and thus rely primarily on semantic priors from pretraining," that overriding those priors is "an emergent ability of model scale," and that instruction tuning "strengthens both the use of semantic priors and the capacity to learn input-label mappings, but more of the former." &lt;a href="https://arxiv.org/abs/2309.04992" rel="noopener noreferrer"&gt;Liusie, Manakul and Gales (2023)&lt;/a&gt; found that in zero-shot prompt classifiers "the precise choice of the prompt template and label words can largely influence performance, with semantically equivalent settings often showing notable performance difference." What the 2026 paper adds is a clean way to measure the effect, run on the newest kind of model built to avoid it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the swap on your own prompt
&lt;/h2&gt;

&lt;p&gt;The useful part is that the test needs no labels. It only needs your prompt, a sample of real inputs and two extra calls per input. Here's a minimal version for a prompt that lists options as &lt;code&gt;name: definition&lt;/code&gt;. &lt;code&gt;classify&lt;/code&gt; stands for your existing call, constrained to return one of the allowed names:&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;build_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;options: list of (name, definition) pairs, shown in this order.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;listed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&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;- &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;definition&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;definition&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Pick the option whose definition fits the ticket. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Reply with the option name only.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&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;Options:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;listed&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;Ticket:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;text&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;picked_definition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;build_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&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;span class="c1"&gt;# compare what was chosen by meaning, not by name
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;flip_rate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;definitions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shift&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Share of inputs where moving each name to another definition changes the decision.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;original&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;definitions&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;moved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;definitions&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# shift=0 measures run-to-run noise
&lt;/span&gt;    &lt;span class="n"&gt;flips&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;picked_definition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nf"&gt;picked_definition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;moved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;texts&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;flips&lt;/span&gt; &lt;span class="o"&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;texts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it three ways on a few hundred real inputs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Your real names, shifted&lt;/strong&gt; (&lt;code&gt;shift=1&lt;/code&gt;). This is the number you're after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Neutral names, shifted&lt;/strong&gt;: replace your names with &lt;code&gt;A&lt;/code&gt;, &lt;code&gt;B&lt;/code&gt;, &lt;code&gt;C&lt;/code&gt; and so on and run it again. This is the control, since moving neutral names around should change nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your real names, not shifted&lt;/strong&gt; (&lt;code&gt;shift=0&lt;/code&gt;): the same prompt twice. This is the floor, because a model sampled at a non-zero temperature changes some answers between identical runs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the first number is close to the other two, the model is reading your definitions. If it's far above them, your definitions are partly decoration, and the label names are making some of the decisions. In that case the fix the paper suggests is cheap: use neutral IDs as option names, put all the meaning in the definitions, and map the ID back to your queue name in code. Then re-run your accuracy check, because the change isn't automatically free. On questions with three to sixteen options, even switching Laya to neutral letters changed about half its decisions, though that model was only 56.4% accurate on those questions to begin with. Measure your own prompt rather than assume.&lt;/p&gt;

&lt;p&gt;A caveat on scope: the paper tested typed decision models, not chat models answering through a JSON schema. Whether your model behaves like Laya, like Jev or like neither is exactly what these three numbers tell you.&lt;/p&gt;

&lt;h2&gt;
  
  
  A probability you can set a threshold on
&lt;/h2&gt;

&lt;p&gt;The second thing the router lacked was a score. There are two common ways to get one from an LLM, and only one of them is worth much.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Asking the model for its confidence.&lt;/strong&gt; Adding a &lt;code&gt;"confidence"&lt;/code&gt; field to the schema is easy and mostly uninformative. In a large evaluation of this approach, &lt;a href="https://arxiv.org/abs/2306.13063" rel="noopener noreferrer"&gt;Xiong et al.&lt;/a&gt; found that LLMs "tend to be highly overconfident when verbalizing their confidence," and that the values "predominantly fall within the 80% to 100% range and are typically in multiples of 5, similar to how humans talk about confidence." In the router example, the model reported 0.9 or higher on 488 of the 500 labelled tickets. A threshold has nothing to separate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reading the probability of the label itself.&lt;/strong&gt; If your provider exposes token log-probabilities for the chosen output (not every API or model does), use the probability the model assigned to the label it picked. Single-token, distinct option names make this straightforward. It's a real score, but don't assume it's calibrated. The &lt;a href="https://arxiv.org/abs/2303.08774" rel="noopener noreferrer"&gt;GPT-4 technical report&lt;/a&gt; showed that "the pre-trained model is highly calibrated," but "after the post-training process, the calibration is reduced," and its calibration plot is captioned "the post-training hurts calibration significantly." Typed decision models like Jev return a distribution by design, which is one of their real advantages. The same checks still apply.&lt;/p&gt;

&lt;p&gt;What you need to know is whether the score ranks the model's mistakes. Group your labelled set by score and count how often each group is right. Here is what that table might look like for the router (illustrative numbers):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Probability of the chosen queue&lt;/th&gt;
&lt;th&gt;Tickets&lt;/th&gt;
&lt;th&gt;Correct&lt;/th&gt;
&lt;th&gt;Share correct&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.95 and above&lt;/td&gt;
&lt;td&gt;330&lt;/td&gt;
&lt;td&gt;323&lt;/td&gt;
&lt;td&gt;97.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.80 to 0.95&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;td&gt;81&lt;/td&gt;
&lt;td&gt;90.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.60 to 0.80&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;72.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;below 0.60&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;66.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;All&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;500&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;460&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;92.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The errors aren't spread evenly. A third of the tickets (the 170 below 0.95) contain 33 of the 40 mistakes. That's what makes a threshold possible.&lt;/p&gt;

&lt;p&gt;The table also shows the scores aren't perfectly calibrated: the tickets below 0.60 are right two times in three. If you want the number itself to be trustworthy, fit a calibration correction on a held-out slice of your labels: temperature scaling, which &lt;a href="https://arxiv.org/abs/1706.04599" rel="noopener noreferrer"&gt;Guo et al. (2017)&lt;/a&gt; found "surprisingly effective" for neural networks, or Platt scaling or isotonic regression on the raw scores. Reliability diagrams and those correction methods are the subject of the calibration lesson in SophiArch's &lt;a href="https://lms.sophiarch.com/courses/applied-machine-learning?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=llm-calls-should-be-classifiers" rel="noopener noreferrer"&gt;Applied Machine Learning course&lt;/a&gt;, which works through them on gradient-boosted and random-forest models, whose raw probabilities need the same kind of correction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pick the threshold from what a mistake costs
&lt;/h3&gt;

&lt;p&gt;A threshold isn't a quality setting. It's a trade between two costs, and you should write both down. Suppose a misrouted ticket costs about 20 minutes of someone's time (the wrong team reads it, works out it isn't theirs, passes it on, and the customer waits longer), and a person sorting a ticket by hand takes about 1 minute and gets it right. Per 500 tickets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Auto-route if probability is at least&lt;/th&gt;
&lt;th&gt;Auto-routed&lt;/th&gt;
&lt;th&gt;Errors that get through&lt;/th&gt;
&lt;th&gt;Sent to a person&lt;/th&gt;
&lt;th&gt;Cost in minutes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;no threshold&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;40 × 20 = 800&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.60&lt;/td&gt;
&lt;td&gt;470&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;600 + 30 = 630&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.80&lt;/td&gt;
&lt;td&gt;420&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;320 + 80 = 400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.95&lt;/td&gt;
&lt;td&gt;330&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;170&lt;/td&gt;
&lt;td&gt;140 + 170 = 310&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At these costs, sending everything below 0.95 to a person cuts the cost of routing by about 60% (from 800 minutes per 500 tickets to 310). If sorting a ticket by hand took three minutes instead of one, the 0.80 threshold would win: 320 + 240 = 560 minutes against 140 + 510 = 650. The right threshold comes from your costs, not from the model.&lt;/p&gt;

&lt;p&gt;The tickets sent to a person do something else too. Every one of them comes back with a correct label, from exactly the cases the model was least sure about. After a few months that's a better training set than you could have designed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Climbing the ladder
&lt;/h2&gt;

&lt;p&gt;Once a decision has labels, a score and a threshold, you can compare different ways of making it on equal terms. A reasonable order to try them in:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The model you already use&lt;/strong&gt;, with neutral option IDs, output constrained to the label set, and the probability of the chosen label as the score. Stop here if the accuracy, the swap test and the score table are all acceptable at your volume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same model with a calibration correction&lt;/strong&gt; fitted on held-out labels, if you need the probability to mean what it says, for example because a downstream rule reads it as a risk level.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small classifier trained on your labels&lt;/strong&gt;, now that the escalation queue is producing them. Even a logistic regression on TF-IDF features is worth trying as a baseline, because it's deterministic, its probabilities can be calibrated with standard tools, and it costs almost nothing to run. Whether a fine-tuned or distilled small model is worth the step up is a separate cost question.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A purpose-built decision model&lt;/strong&gt; such as Jev. These return a distribution by design and can be fast. Run the swap test on them too, because that's the kind of model the paper studied.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The exit test is the same at every rung: accuracy, the score table and the swap flip rate, all on your own labelled traffic. Not the vendor's benchmark, and not the model's own description of how confident it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five questions for any LLM call that returns a label
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Is the answer always one of a fixed set? Then it's a classifier, whatever the call looks like.&lt;/li&gt;
&lt;li&gt;Do you have a few hundred real inputs labelled by a person, and do you know the error rate on them?&lt;/li&gt;
&lt;li&gt;When you move the names to other definitions, how many decisions change compared with neutral names and with a plain re-run?&lt;/li&gt;
&lt;li&gt;Does the score put the mistakes at the bottom of the table, and is anything reading it as a probability?&lt;/li&gt;
&lt;li&gt;Where do the low-scoring cases go, and who labels them?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The router team answered all five in two weeks. The billing queue stopped getting password resets, and the "urgent" queue got a lot quieter.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Sun, Y., Xu, J., Shi, J., &amp;amp; Yang, Z. (2026). &lt;a href="https://arxiv.org/abs/2609.26758" rel="noopener noreferrer"&gt;Type-Safe Is Not Error-Free: Typed Decision Models Follow the Option Name, Not the Definition Bound to It&lt;/a&gt;. &lt;em&gt;arXiv:2609.26758&lt;/em&gt;. The name–definition swap on Jev, Laya and Open-Jev; source of the flip rates, AUC figures, test-retest floor, random-string control, multi-option results and the neutral-name mitigation.&lt;/li&gt;
&lt;li&gt;TypeSafe AI. &lt;a href="https://typesafe.ai/blog/introducing-system-one-models-and-jev" rel="noopener noreferrer"&gt;Introducing System One models and Jev&lt;/a&gt;. Launch post for the typed decision model studied above.&lt;/li&gt;
&lt;li&gt;Wei, J., Wei, J., Tay, Y., et al. (2023). &lt;a href="https://arxiv.org/abs/2303.03846" rel="noopener noreferrer"&gt;Larger language models do in-context learning differently&lt;/a&gt;. &lt;em&gt;arXiv:2303.03846&lt;/em&gt;. Flipped-label and semantically unrelated label experiments; semantic priors and instruction tuning.&lt;/li&gt;
&lt;li&gt;Liusie, A., Manakul, P., &amp;amp; Gales, M. (2023). &lt;a href="https://arxiv.org/abs/2309.04992" rel="noopener noreferrer"&gt;Mitigating Word Bias in Zero-shot Prompt-based Classifiers&lt;/a&gt;. &lt;em&gt;arXiv:2309.04992&lt;/em&gt;. How the choice of label words shifts zero-shot classifier performance, and a reweighting fix.&lt;/li&gt;
&lt;li&gt;Xiong, M., Hu, Z., Lu, X., Li, Y., Fu, J., He, J., &amp;amp; Hooi, B. (2024). &lt;a href="https://arxiv.org/abs/2306.13063" rel="noopener noreferrer"&gt;Can LLMs Express Their Uncertainty? An Empirical Evaluation of Confidence Elicitation in LLMs&lt;/a&gt;. &lt;em&gt;arXiv:2306.13063&lt;/em&gt; (ICLR 2024). Overconfidence and the 80–100% clustering of verbalized confidence.&lt;/li&gt;
&lt;li&gt;OpenAI (2023). &lt;a href="https://arxiv.org/abs/2303.08774" rel="noopener noreferrer"&gt;GPT-4 Technical Report&lt;/a&gt;. &lt;em&gt;arXiv:2303.08774&lt;/em&gt;. Calibration of the pre-trained versus post-trained model on MMLU (Figure 8).&lt;/li&gt;
&lt;li&gt;Guo, C., Pleiss, G., Sun, Y., &amp;amp; Weinberger, K. Q. (2017). &lt;a href="https://arxiv.org/abs/1706.04599" rel="noopener noreferrer"&gt;On Calibration of Modern Neural Networks&lt;/a&gt;. &lt;em&gt;arXiv:1706.04599&lt;/em&gt; (ICML 2017). Miscalibration in modern networks and temperature scaling.&lt;/li&gt;
&lt;li&gt;SophiArch. &lt;a href="https://lms.sophiarch.com/courses/applied-machine-learning?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=llm-calls-should-be-classifiers" rel="noopener noreferrer"&gt;Applied Machine Learning&lt;/a&gt;. Course covering evaluation with statistical rigour, imbalanced data, cross-validation and leakage, calibration with reliability diagrams and Platt/isotonic scaling, and production pipelines.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>machinelearning</category>
      <category>llm</category>
      <category>classification</category>
      <category>python</category>
    </item>
    <item>
      <title>Most Multi-Agent Systems Should Be One Agent With Better Tools</title>
      <dc:creator>Jason Lau</dc:creator>
      <pubDate>Wed, 07 Oct 2026 03:08:13 +0000</pubDate>
      <link>https://dev.to/jasonl888/most-multi-agent-systems-should-be-one-agent-with-better-tools-40od</link>
      <guid>https://dev.to/jasonl888/most-multi-agent-systems-should-be-one-agent-with-better-tools-40od</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Most published multi-agent wins compare a team of agents against a single agent given far less compute. Studies that match the budget find the advantage mostly disappears: automatically generated multi-agent systems &lt;a href="https://arxiv.org/abs/2606.13003" rel="noopener noreferrer"&gt;underperformed a simple self-consistency baseline while costing up to 10x more&lt;/a&gt;, and on a sequential planning benchmark &lt;a href="https://arxiv.org/abs/2512.08296" rel="noopener noreferrer"&gt;every multi-agent variant tested scored 39–70% worse than a single agent&lt;/a&gt;. In a worked five-agent refund pipeline, 64% of the tokens go on duplicated context and handoff notes, and a fact dropped in one handoff produces a wrong refund. Merging the agents into one fixes the lost fact but not the bill. Redesigning the tools does both, cutting the cost to under half. The test for whether you need a second agent: &lt;strong&gt;can you say what it would be evaluated on by itself?&lt;/strong&gt; Parallel research, context isolation, separate permissions and verification pass that test. Splitting work by job title (planner, coder, tester, reviewer) usually doesn't. Before adding an agent, give your single agent the same token budget and compare them on your own labelled cases.&lt;/p&gt;

&lt;p&gt;"Add another agent" has become the default answer to an agent that isn't good enough. The planner misses things, so a reviewer agent is added. The reviewer lacks context, so a researcher agent is added to feed it. Six months later there's an org chart of five agents, each with its own prompt, and nobody can say which one is responsible when a customer gets the wrong answer.&lt;/p&gt;

&lt;p&gt;The 2026 name for everything around the model is the &lt;em&gt;harness&lt;/em&gt;: tools, permissions, loops, memory, verification. Multi-agent design is a harness decision, and usually the most expensive one available. This article makes the case that most multi-agent systems are one agent whose tools are badly designed. It also gives a test for the cases where that isn't true: &lt;strong&gt;if you can't say what each agent would be evaluated on by itself, you have one agent.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A support pipeline with five agents
&lt;/h2&gt;

&lt;p&gt;Here's an invented but typical example: a B2B software company handling refund requests. The team built it the way the architecture diagrams suggest:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A &lt;strong&gt;router&lt;/strong&gt; reads the ticket and decides what kind of request it is.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;account agent&lt;/strong&gt; looks up the customer and summarises their account.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;billing agent&lt;/strong&gt; pulls invoices and payments and summarises billing history.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;policy agent&lt;/strong&gt; reads the refund policy and decides what the customer is owed.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;writer&lt;/strong&gt; drafts the reply.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each agent passes a short written note to the next. It demos well. Every agent has a clear job title, and the diagram has five neat boxes.&lt;/p&gt;

&lt;p&gt;Then a ticket comes in from a customer cancelling an annual plan. They paid $4,800 in March. In June they downgraded and received a $1,200 credit. The billing agent's note reads: &lt;em&gt;"Annual plan, $4,800, renewed 3 March, no open disputes."&lt;/em&gt; All of that is true. The June credit is in the raw ledger the billing agent read, but it isn't in the note, because the note had a length limit and the agent picked what looked most relevant. The policy agent never sees the ledger, only the note. It calculates a pro-rated refund on the full $4,800. The writer promises it to the customer.&lt;/p&gt;

&lt;p&gt;No agent made a mistake given what it was shown. The failure is in the handoff, and debugging it means opening five traces and working out which summary dropped which fact. Anthropic calls this the &lt;a href="https://claude.com/blog/building-multi-agent-systems-when-and-how-to-use-them" rel="noopener noreferrer"&gt;"telephone game"&lt;/a&gt;: when agents are split by problem type, they end up passing "information back and forth with each handoff degrading fidelity."&lt;/p&gt;

&lt;p&gt;Dat Tran and Douwe Kiela &lt;a href="https://arxiv.org/abs/2604.02460" rel="noopener noreferrer"&gt;put a formal argument behind the same intuition&lt;/a&gt;, using the Data Processing Inequality from information theory. Every agent downstream of a handoff works from a processed version of the original context, and processing can lose information but never add it. A single agent with the whole context in front of it doesn't pay that cost. Their argument also says when multi-agent designs can come out ahead. One case is when a single agent's use of its own context breaks down: "once effective single-agent context utilization deteriorates sufficiently, a well-designed MAS may recover task-relevant information more reliably than a degraded single pass." The other is when the multi-agent system simply gets to spend more compute. That second condition turns out to explain a lot of the published results.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comparisons that flattered multi-agent systems
&lt;/h2&gt;

&lt;p&gt;Most of the evidence for multi-agent designs compares a multi-agent system against a single agent that was given much less to work with. That's the confound each of the three studies below goes after.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Match the compute and the advantage mostly disappears.&lt;/strong&gt; Tran and Kiela's starting point is that reported multi-agent gains are often confounded by extra test-time computation. They compared single-agent and multi-agent setups on multi-hop reasoning, across three model families, with the thinking-token budget held equal, and found single agents matched or beat the multi-agent systems once computation was normalised. A typical row from their results: on four-hop MuSiQue questions with DeepSeek-R1-Distill-Llama-70B at a 2,000-token budget, the single agent scored 0.418, a sequential multi-agent pipeline 0.327, and no multi-agent design in that row scored above 0.352. Their error analysis found the sequential pipeline "over-explores and drifts," and their conclusion is that "many reported MAS gains are better explained by compute and context effects than by inherent architectural superiority."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automatically generated multi-agent systems lost to a simple baseline at a fraction of the cost.&lt;/strong&gt; &lt;a href="https://arxiv.org/abs/2606.13003" rel="noopener noreferrer"&gt;&lt;em&gt;The Illusion of Multi-Agent Advantage&lt;/em&gt;&lt;/a&gt; (Jwalapuram et al., Salesforce Research and collaborators, June 2026) tested six frameworks that design multi-agent systems automatically against one single-agent baseline: chain-of-thought with self-consistency, which just samples the same model five times and takes a majority vote. The authors point out that earlier comparisons "rarely control for inference budgets such as number of LLM calls, total cost, retries, or sampled paths." When they controlled for them, the result was blunt: "automatic MAS consistently underperform CoT-SC despite being up to 10x more expensive."&lt;/p&gt;

&lt;p&gt;The paper then took the generated systems apart to see what the extra agents were doing. Mostly, nothing useful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In DyLAN, a framework built on the premise that agent diversity drives performance, agents "reach immediate, unanimous consensus in ∼70% of GPT-4o cases and &amp;gt;90% of GPT-5 cases." Agents that agree instantly are one agent's answer, paid for several times over.&lt;/li&gt;
&lt;li&gt;Replacing the task-specific expert roles with generic "assistant" agents scored slightly &lt;em&gt;better&lt;/em&gt; (54.4% vs. 53.4%).&lt;/li&gt;
&lt;li&gt;In a framework that searches for the best workflow, 7 of the 14 final workflows it found were a single prompt run three times and aggregated, which is self-consistency under a more complicated name.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The authors' name for agents that cost full price and change nothing is "expensive witnesses."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sequential and tool-heavy work gets worse, not better.&lt;/strong&gt; Google Research and MIT's &lt;a href="https://arxiv.org/abs/2512.08296" rel="noopener noreferrer"&gt;&lt;em&gt;Towards a Science of Scaling Agent Systems&lt;/em&gt;&lt;/a&gt; (Kim et al.) is the largest study of the three: 260 configurations across six agentic benchmarks, five architectures and three model families, with tools, prompts and compute standardised so that only the architecture varied. Three of its findings map directly onto production systems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On a planning benchmark built from sequential constraints, "all multi-agent variants universally degrade performance," by 39% to 70%. A refund decision is a sequential task. Each step depends on what the previous one found.&lt;/li&gt;
&lt;li&gt;Under fixed budgets, "tool-heavy tasks suffer disproportionately from multi-agent inefficiency," because splitting the budget across agents leaves each one less room to use its tools.&lt;/li&gt;
&lt;li&gt;Once a single agent already scored above roughly 45%, adding agents gave negative returns.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same paper also shows where multi-agent designs &lt;em&gt;do&lt;/em&gt; win. Centralised coordination improved performance by 80.8% on a decomposable financial-reasoning benchmark. So "never use multiple agents" is the wrong conclusion. The right one is narrower: the benefit is real for work that splits into genuinely independent pieces, and negative for most other work.&lt;/p&gt;

&lt;p&gt;Even Anthropic's widely cited result needs reading carefully. Its &lt;a href="https://www.anthropic.com/engineering/multi-agent-research-system" rel="noopener noreferrer"&gt;multi-agent research system&lt;/a&gt; "outperformed single-agent Claude Opus 4 by 90.2% on our internal research eval," on the kind of breadth-first research that parallelises well. The same post reports that "token usage by itself explains 80% of the variance" in performance on the BrowseComp benchmark, and that "multi-agent systems use about 15× more tokens than chats." That 15× is measured against a chat interaction, not against a single agent. Much of the improvement came from spending more tokens, which a single agent can also do. The post is clear about where it doesn't apply: "most coding tasks involve fewer truly parallelizable tasks than research."&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the tokens go
&lt;/h2&gt;

&lt;p&gt;Back to the support pipeline. Here's the token accounting for one ticket, using made-up but plausible sizes: a 1,200-token ticket, an 800-token system prompt per agent, 1,500 tokens from each account and billing lookup, and 2,000 tokens of retrieved refund policy.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;System prompt&lt;/th&gt;
&lt;th&gt;Ticket&lt;/th&gt;
&lt;th&gt;Notes read&lt;/th&gt;
&lt;th&gt;Tool results&lt;/th&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Router&lt;/td&gt;
&lt;td&gt;800&lt;/td&gt;
&lt;td&gt;1,200&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;2,000&lt;/td&gt;
&lt;td&gt;250 (note)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Account&lt;/td&gt;
&lt;td&gt;800&lt;/td&gt;
&lt;td&gt;1,200&lt;/td&gt;
&lt;td&gt;250&lt;/td&gt;
&lt;td&gt;1,500&lt;/td&gt;
&lt;td&gt;3,750&lt;/td&gt;
&lt;td&gt;400 (note)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Billing&lt;/td&gt;
&lt;td&gt;800&lt;/td&gt;
&lt;td&gt;1,200&lt;/td&gt;
&lt;td&gt;650&lt;/td&gt;
&lt;td&gt;1,500&lt;/td&gt;
&lt;td&gt;4,150&lt;/td&gt;
&lt;td&gt;400 (note)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Policy&lt;/td&gt;
&lt;td&gt;800&lt;/td&gt;
&lt;td&gt;1,200&lt;/td&gt;
&lt;td&gt;1,050&lt;/td&gt;
&lt;td&gt;2,000&lt;/td&gt;
&lt;td&gt;5,050&lt;/td&gt;
&lt;td&gt;400 (note)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Writer&lt;/td&gt;
&lt;td&gt;800&lt;/td&gt;
&lt;td&gt;1,200&lt;/td&gt;
&lt;td&gt;1,450&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;3,450&lt;/td&gt;
&lt;td&gt;350 (reply)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3,400&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;18,400&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,800&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's 20,200 tokens per ticket. Now split it by what the tokens were &lt;em&gt;for&lt;/em&gt;. The work only one agent had to do is reading the ticket once, one system prompt, the 5,000 tokens of tool results and the 350-token reply: 7,350 tokens, or 36%. The other 64% is the cost of there being five agents. The ticket is read four extra times (4,800), there are four extra system prompts (3,200), and handoff notes are written (1,450) and then re-read by every later agent (3,400). Anthropic's own guidance &lt;a href="https://claude.com/blog/building-multi-agent-systems-when-and-how-to-use-them" rel="noopener noreferrer"&gt;describes the same breakdown&lt;/a&gt;: "In our testing, multi-agent implementations typically use 3-10x more tokens than single-agent approaches for equivalent tasks," from "duplicating context across agents, coordination messages between agents, and summarizing results for handoffs."&lt;/p&gt;

&lt;p&gt;Now the part that surprises people. Collapse the five agents into one agent that calls the &lt;em&gt;same three tools&lt;/em&gt;, and the saving almost disappears. An agent loop re-reads its growing context on every turn, so four turns (three tool calls and a reply) with a 1,200-token system prompt come to about 19,700 input tokens and 650 output: 20,350 in total. It's no cheaper. It does fix the refund, because the one agent read the raw ledger and saw the June credit. But the token bill is the same.&lt;/p&gt;

&lt;p&gt;The saving comes from the tools. The original lookups return raw API output, full invoice objects and the whole policy document, because the tools were written to return whatever the backend returned. Rewrite them around what the agent actually needs to decide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;get_billing_context(customer_id)&lt;/code&gt; returns one pre-joined record of about 600 tokens: plan, amount paid, and every credit, refund and plan change with its date.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;check_refund_eligibility(customer_id, reason)&lt;/code&gt; runs the refund policy &lt;em&gt;as code&lt;/em&gt; and returns about 150 tokens: eligible or not, the maximum amount, which rule applied, and why.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The loop becomes three turns (look up, check eligibility, reply): about 8,850 input and 550 output tokens, or 9,400 in total. That's under half the five-agent pipeline, and it gets the June credit right, because the credit is a field in the tool's output and not something a summary might drop. Prompt caching would lower the repeated system-prompt cost further. Your ratios will differ with your sizes, but the pattern holds. Removing agents fixes the lost context. Better tools reduce the cost.&lt;/p&gt;

&lt;p&gt;This accounting is only possible if you can see the assembled prompt and the per-step token count for every call, which most orchestration frameworks hide by default. The orchestration lesson in 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=multi-agent-one-agent-better-tools" rel="noopener noreferrer"&gt;AI Applications with LLMs course&lt;/a&gt; starts from that problem: work out what the framework conceals before deciding whether to use one.&lt;/p&gt;

&lt;p&gt;Notice what happened to the policy agent. It became a function. That's common. Look closely at a multi-agent diagram and you'll often find a business rule that has been given a prompt and a job title. A rule written as code can be unit-tested, never paraphrases the policy, and costs nothing per call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test: what would each agent be evaluated on by itself?
&lt;/h2&gt;

&lt;p&gt;Better tools are now standard advice. Anthropic's guide puts it plainly: "a well-designed single agent with appropriate tools can accomplish far more than many developers expect," and notes that "we've seen teams invest months building elaborate multi-agent architectures only to discover that improved prompting on a single agent achieved equivalent results." The advice exists, and teams still reach for a second agent, so something more concrete is needed at the moment of that decision.&lt;/p&gt;

&lt;p&gt;Here's the test. For every agent in the design, write down the evaluation you would run on that agent &lt;em&gt;by itself&lt;/em&gt;, with inputs, a pass condition, and a labelled set to run it on, without running the rest of the system. If you can write it, that agent may be worth separating. If the only way to judge the agent is to look at the final output of the whole system, it isn't a separate agent. It's a step inside one.&lt;/p&gt;

&lt;p&gt;Apply it to the support pipeline:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Independent evaluation?&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Router&lt;/td&gt;
&lt;td&gt;Yes: routing accuracy against labelled tickets. But it has a fixed label set, no tools and no loop.&lt;/td&gt;
&lt;td&gt;A &lt;strong&gt;classification call&lt;/strong&gt;, not an agent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Account agent&lt;/td&gt;
&lt;td&gt;The lookup can be tested (right customer, right records). The summary can't: "complete enough" depends on what the next agent needs.&lt;/td&gt;
&lt;td&gt;A &lt;strong&gt;tool&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Billing agent&lt;/td&gt;
&lt;td&gt;Same as above. The note that dropped the June credit had no pass condition it could have failed.&lt;/td&gt;
&lt;td&gt;A &lt;strong&gt;tool&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Policy agent&lt;/td&gt;
&lt;td&gt;Yes: the correct outcome for each policy case is known in advance.&lt;/td&gt;
&lt;td&gt;A &lt;strong&gt;function&lt;/strong&gt;, tested with unit tests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Writer&lt;/td&gt;
&lt;td&gt;Only by judging the final reply, which is the whole system's output.&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;The agent&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Five agents turn into one agent, two tools, a function and a classifier. Nothing was lost. Every part that &lt;em&gt;could&lt;/em&gt; be tested separately is now easier to test than before, because a function with fixed cases is easier to test than a prompt.&lt;/p&gt;

&lt;p&gt;The test also explains the published results. The successful system in &lt;em&gt;The Illusion of Multi-Agent Advantage&lt;/em&gt; was the one the authors designed by hand for a task built to reward decomposition: a finance problem with independent per-investor calculations over a large table of prices. Their "Expert-MAS" used an extractor agent that retrieves specific data and a calculator agent that reasons over isolated snippets, both of which can be evaluated on their own. Coordination was done by a deterministic Python orchestrator, and the final comparison was computed in code. With GPT-5 it scored 96.5%, against 57.0% for the self-consistency baseline, "with cost comparable to CoT-SC." The multi-agent system that worked was mostly a program, with the model called only for the pieces that could each be checked.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a second agent passes the test
&lt;/h2&gt;

&lt;p&gt;There are real cases, and they're narrow enough to name. Each one comes with an evaluation that doesn't need the rest of the system:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Genuinely parallel work.&lt;/strong&gt; Five independent research questions, five subagents, each judged on whether it found the facts for its own question. This is the case Anthropic's research system and the 80.8% financial-reasoning result both come from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context protection.&lt;/strong&gt; A subagent reads 300 pages of logs and returns 20 lines. Evaluation: does the summary contain the line that explains the incident? The main agent's context stays clean. This is also the condition in which Tran and Kiela's argument says a multi-agent design can come out ahead: when one agent's context would otherwise be too long or too noisy to use well.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different permissions.&lt;/strong&gt; A subagent that can write to a production system, and nothing else, evaluated on whether the end state is correct and it took only allowed actions. The separation exists because of what it's allowed to do, not because of its job title.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verification.&lt;/strong&gt; Anthropic's guide calls the verification subagent "one multi-agent pattern that consistently works well across domains." It has the cleanest independent evaluation of all: seed known defects and measure how many it catches. Kim et al.'s error-amplification finding points the same way. Independent agents with no checkpoint amplified errors 17.2×, while centralised coordination with a validation step held it to 4.4×.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's missing from that list is splitting by job title. Planner, implementer, tester and reviewer is the most common multi-agent design and the one the evidence argues against most directly. Anthropic's guide says "dividing by type of work (one agent writes features, another writes tests, a third reviews code) creates constant coordination overhead," and in their own experiment with those roles "the subagents spent more tokens on coordination than on actual work." A recent preprint, &lt;a href="https://arxiv.org/abs/2609.04217" rel="noopener noreferrer"&gt;&lt;em&gt;At Equal Inference Cost, Multi-Agent Structure Does Not Beat a Single Frozen Agent&lt;/em&gt;&lt;/a&gt; (Dylan et al.), tested the planner–executor–critic split directly, holding the number of model calls fixed and optimising each role's prompt. All of the gain came from the executor. The planner and critic prompts "froze empty," and the full team (0.769) was not statistically separable from the single agent (0.754) while using 1.8× the calls. It's a small study, with one 7B model and two benchmarks, but it points the same way. Berkeley's &lt;a href="https://arxiv.org/abs/2503.13657" rel="noopener noreferrer"&gt;study of multi-agent failures&lt;/a&gt; (Cemri et al.) found failure rates of 41% to 86.7% across seven open-source multi-agent frameworks, and grouped 14 failure modes under three headings: system design, misalignment between agents, and task verification. The middle one, misalignment between agents, is a failure a single agent can't have at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the budget-matched comparison this week
&lt;/h2&gt;

&lt;p&gt;If you already run a multi-agent system, there's a cheap way to find out whether it's earning its cost. It needs one thing you may already have: a set of labelled cases with a checkable pass condition.&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;budget_matched&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run_multi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run_single&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Compare a multi-agent system against one agent given the same token budget.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;case&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;multi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_multi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                 &lt;span class="c1"&gt;# -&amp;gt; answer, tokens_used
&lt;/span&gt;        &lt;span class="n"&gt;single&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_single&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;# same model, all the tools,
&lt;/span&gt;                            &lt;span class="n"&gt;token_budget&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;multi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens_used&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# spend up to the same budget
&lt;/span&gt;        &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;multi_pass&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;passes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;multi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;single_pass&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;passes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;single&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;multi_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;multi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens_used&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;single_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;single&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens_used&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How &lt;code&gt;run_single&lt;/code&gt; spends the budget is up to you: more reasoning tokens, or several samples with a majority vote, which is exactly the baseline the Illusion paper used. The one rule is that it gets the same budget as the multi-agent run, not a fraction of it. Compare the tokens each side &lt;em&gt;actually used&lt;/em&gt;, not the budget you asked for. Tran and Kiela found that with Gemini 2.5, "the visible thought text produced by SAS tends to plateau well below the requested budget, while MAS surfaces more visible thought content under the same requested budget B, due to multiple calls." A single agent that stops early hasn't had a fair comparison. Then read the results in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If the single agent matches or beats the multi-agent system at the same budget, the extra agents are spending tokens, not adding capability.&lt;/li&gt;
&lt;li&gt;If the multi-agent system wins, look at &lt;em&gt;which&lt;/em&gt; cases it wins. If they're the cases that split into independent parts, you've found a real use for a second agent. Keep it, and give that agent its own evaluation.&lt;/li&gt;
&lt;li&gt;Either way, look at the single agent's tool calls on the cases it failed. Tools that return raw API output, and business rules the model has to re-derive from a document every time, are where the next improvement is.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;A second agent is worth adding when you can evaluate it without the rest of the system. When you can't, what you actually need is a better tool, a function or a cleaner context, and all three are cheaper, faster and easier to test than another agent.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Jwalapuram, P., Lin, H., Li, C., Jiao, F., Wang, S., Ming, Y., Ke, Z., Qin, C., Carenini, G., &amp;amp; Joty, S. (2026). &lt;a href="https://arxiv.org/abs/2606.13003" rel="noopener noreferrer"&gt;The Illusion of Multi-Agent Advantage&lt;/a&gt;. &lt;em&gt;arXiv:2606.13003&lt;/em&gt;. Six automatic multi-agent frameworks against budget-controlled chain-of-thought with self-consistency; source of the "up to 10x more expensive" result, the consensus and role-ablation findings, and the Expert-MAS result.&lt;/li&gt;
&lt;li&gt;Tran, D., &amp;amp; Kiela, D. (2026). &lt;a href="https://arxiv.org/abs/2604.02460" rel="noopener noreferrer"&gt;Single-Agent LLMs Outperform Multi-Agent Systems on Multi-Hop Reasoning Under Equal Thinking Token Budgets&lt;/a&gt;. &lt;em&gt;arXiv:2604.02460&lt;/em&gt;. Equal-budget comparison, the Data Processing Inequality argument, the MuSiQue results and the budget-tracking caveat.&lt;/li&gt;
&lt;li&gt;Kim, Y., et al. (2025, revised 2026). &lt;a href="https://arxiv.org/abs/2512.08296" rel="noopener noreferrer"&gt;Towards a Science of Scaling Agent Systems&lt;/a&gt;. &lt;em&gt;arXiv:2512.08296v3&lt;/em&gt;. 260 controlled configurations across six agentic benchmarks, five architectures and three model families; source of the sequential-planning degradation, tool-coordination trade-off, capability threshold, Finance Agent and error-amplification figures.&lt;/li&gt;
&lt;li&gt;Dylan, D., Brennan, A., Murphy, C., O'Sullivan, N., Kelly, C., &amp;amp; Walsh, S. (2026). &lt;a href="https://arxiv.org/abs/2609.04217" rel="noopener noreferrer"&gt;At Equal Inference Cost, Multi-Agent Structure Does Not Beat a Single Frozen Agent&lt;/a&gt;. &lt;em&gt;arXiv:2609.04217&lt;/em&gt;. Planner–executor–critic team against a single agent at a fixed number of model calls.&lt;/li&gt;
&lt;li&gt;Cemri, M., Pan, M. Z., Yang, S., et al. (2025). &lt;a href="https://arxiv.org/abs/2503.13657" rel="noopener noreferrer"&gt;Why Do Multi-Agent LLM Systems Fail?&lt;/a&gt; &lt;em&gt;arXiv:2503.13657&lt;/em&gt;. 1,642 annotated traces from seven frameworks; the MAST taxonomy of 14 failure modes.&lt;/li&gt;
&lt;li&gt;Hadfield, J., Zhang, B., Lien, K., Scholz, F., Fox, J., &amp;amp; Ford, D. (2025, June 13). &lt;a href="https://www.anthropic.com/engineering/multi-agent-research-system" rel="noopener noreferrer"&gt;How we built our multi-agent research system&lt;/a&gt;. Anthropic Engineering. Source of the 90.2%, 15×-versus-chat and 80%-of-variance figures.&lt;/li&gt;
&lt;li&gt;Phillips, C. (2026, January 23). &lt;a href="https://claude.com/blog/building-multi-agent-systems-when-and-how-to-use-them" rel="noopener noreferrer"&gt;Building multi-agent systems: When and how to use them&lt;/a&gt;. Claude blog. Source of the 3–10× single-agent comparison, the "telephone game" and type-of-work decomposition warnings, and the verification-subagent pattern.&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=multi-agent-one-agent-better-tools" rel="noopener noreferrer"&gt;AI Applications with LLMs&lt;/a&gt;. Course covering the probabilistic contract, context window architecture, output validation layers, evaluation frameworks, orchestration patterns (including what frameworks hide), and cost and latency engineering for LLM systems.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>agents</category>
      <category>multiagent</category>
      <category>llm</category>
      <category>harness</category>
    </item>
    <item>
      <title>Your First Model Should Be Embarrassing</title>
      <dc:creator>Jason Lau</dc:creator>
      <pubDate>Wed, 30 Sep 2026 08:29:33 +0000</pubDate>
      <link>https://dev.to/jasonl888/your-first-model-should-be-embarrassing-1gao</link>
      <guid>https://dev.to/jasonl888/your-first-model-should-be-embarrassing-1gao</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Before anything clever, fit a model that guesses the majority class and a plain logistic regression. Together they take seconds, and they're the only way to tell what the expensive model actually bought you. On six public datasets, guessing alone was 95.3% accurate on hypothyroid. Default boosted trees beat logistic regression by 2 to 15 AUC points. A 200-fit hyperparameter search added more than half a point on only one dataset, and across four random splits its gain on the other five averaged between −0.3 and +0.2 points. Skip the cheap models and the complex one takes credit for all of it.&lt;/p&gt;

&lt;p&gt;Ask an AI coding assistant to "build a model to predict churn" from a CSV and you'll often get a notebook that looks finished: preprocessing, a gradient-boosted model, a hyperparameter search, a classification report, maybe a feature-importance chart. Before you read any of the numbers, search the notebook for one thing: a model that ignores the features entirely.&lt;/p&gt;

&lt;p&gt;If there isn't one, none of the numbers mean anything yet. "95% accuracy" is excellent if guessing gets you 60%, and embarrassing if guessing gets you 95%. A 0.93 AUC from a tuned ensemble is a big win if a plain logistic regression gets 0.80, and the ensemble is mostly overhead if the logistic regression gets 0.92. The assistant didn't do anything wrong. It answered the question you asked, and "build a model" doesn't ask for a floor to measure it against.&lt;/p&gt;

&lt;p&gt;So the first model you fit should be one you'd be embarrassed to ship. It isn't there to be deployed. It's there to set the score everything else has to beat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four models, six datasets
&lt;/h2&gt;

&lt;p&gt;To see how much the floor changes the story, here are four models of increasing effort on six public binary-classification datasets from the &lt;a href="https://github.com/EpistasisLab/pmlb" rel="noopener noreferrer"&gt;Penn Machine Learning Benchmark&lt;/a&gt; (PMLB) collection:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Guess the majority class.&lt;/strong&gt; scikit-learn ships this as &lt;a href="https://scikit-learn.org/stable/modules/generated/sklearn.dummy.DummyClassifier.html" rel="noopener noreferrer"&gt;&lt;code&gt;DummyClassifier&lt;/code&gt;&lt;/a&gt;, and its documentation is blunt about the job: it "makes predictions that ignore the input features" and "serves as a simple baseline to compare against other more complex classifiers."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logistic regression&lt;/strong&gt;, with one-hot encoded categories and scaled numeric columns. Nothing tuned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gradient-boosted trees on default settings&lt;/strong&gt; (scikit-learn's &lt;code&gt;HistGradientBoostingClassifier&lt;/code&gt;, a histogram-based implementation inspired by LightGBM). On the two datasets with more than 10,000 rows, the defaults also switch on early stopping, which is a small amount of built-in tuning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same boosted trees after a hyperparameter search&lt;/strong&gt;: 40 random configurations over learning rate, tree size, leaf size, regularisation and number of rounds, each scored with 5-fold cross-validation. That's 200 model fits plus a final refit of the winner, and it's the step that makes a notebook look thorough.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each dataset gets one stratified 80/20 train/test split. The search only sees the training data. Scores are on the held-out 20%. Categorical columns are one-hot encoded for the logistic regression and passed to the trees as native categories. The churn data's phone-number column, which is a unique ID per customer, is dropped.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dataset (rows)&lt;/th&gt;
&lt;th&gt;Guess the majority&lt;/th&gt;
&lt;th&gt;Logistic regression&lt;/th&gt;
&lt;th&gt;Boosted trees, defaults&lt;/th&gt;
&lt;th&gt;Boosted trees, tuned&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Census income (48,842)&lt;/td&gt;
&lt;td&gt;76.1% · 0.500&lt;/td&gt;
&lt;td&gt;85.6% · 0.909&lt;/td&gt;
&lt;td&gt;87.7% · &lt;strong&gt;0.931&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;87.6% · &lt;strong&gt;0.931&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Telecom churn (5,000)&lt;/td&gt;
&lt;td&gt;85.9% · 0.500&lt;/td&gt;
&lt;td&gt;86.6% · 0.797&lt;/td&gt;
&lt;td&gt;95.5% · &lt;strong&gt;0.929&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;95.3% · 0.923&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spam email (4,601)&lt;/td&gt;
&lt;td&gt;60.6% · 0.500&lt;/td&gt;
&lt;td&gt;93.2% · 0.970&lt;/td&gt;
&lt;td&gt;96.0% · &lt;strong&gt;0.990&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;96.0% · &lt;strong&gt;0.990&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gamma telescope (19,020)&lt;/td&gt;
&lt;td&gt;64.8% · 0.500&lt;/td&gt;
&lt;td&gt;79.3% · 0.847&lt;/td&gt;
&lt;td&gt;89.0% · 0.942&lt;/td&gt;
&lt;td&gt;89.3% · &lt;strong&gt;0.946&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hypothyroid (3,163)&lt;/td&gt;
&lt;td&gt;95.3% · 0.500&lt;/td&gt;
&lt;td&gt;96.2% · 0.917&lt;/td&gt;
&lt;td&gt;98.3% · &lt;strong&gt;0.989&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;98.0% · 0.983&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phoneme (5,404)&lt;/td&gt;
&lt;td&gt;70.7% · 0.500&lt;/td&gt;
&lt;td&gt;74.0% · 0.803&lt;/td&gt;
&lt;td&gt;90.1% · 0.957&lt;/td&gt;
&lt;td&gt;92.0% · &lt;strong&gt;0.967&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Each cell shows test accuracy, then ROC AUC. The majority guess scores 0.500 AUC by definition, because it gives every row the same score. Bold is the best AUC in the row, after rounding. The default trees fit in under a second on every dataset. The search took between 43 and 152 seconds per dataset on a 4-core machine. Run with scikit-learn 1.9.1.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Each rung of that ladder answers a different question, and the rung below it supplies the number you need to answer it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the bottom rung tells you
&lt;/h2&gt;

&lt;p&gt;Look at the accuracy column for hypothyroid. A model that has never looked at a patient, and says "no" every time, is 95.3% accurate. That's what 4.8% prevalence does to accuracy. The logistic regression's 96.2% sounds strong on its own, and it's less than one point above doing nothing. Telecom churn is the same: 85.9% for predicting that nobody leaves, 86.6% for the logistic regression. Report "our churn model is 87% accurate" in a meeting and it sounds like a result. Next to the majority-class guess, it's close to nothing.&lt;/p&gt;

&lt;p&gt;This is the cheapest check in applied ML, and it catches the most embarrassing failures before anyone else does. It also shows that accuracy is the wrong headline metric for imbalanced problems like these two. That's a larger topic, but you only notice the problem once you have the floor.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the middle rung tells you
&lt;/h2&gt;

&lt;p&gt;The jump from logistic regression to default boosted trees is where the real decisions are, and the table shows two very different situations.&lt;/p&gt;

&lt;p&gt;On telecom churn, phoneme and the gamma telescope data, the trees add 9 to 15 points of AUC (a point here is 0.01). Hypothyroid is less settled: 7 points on this split, but anywhere from 2 to 8 across the four splits described below. The gap comes from interactions and non-linear thresholds that a plain linear model can't represent. Some of it could be won back with feature engineering, such as binning the number of customer-service calls in the churn data, so treat this logistic regression as a floor, not the best simple model possible. Even so, on those datasets the trees are easily worth the loss of interpretability. On census income and spam, the gain is about 2 points. Whether 2 points is worth giving up coefficients you can read out to a risk committee, a regulator or a customer is a business decision. It might well be. But you can only make that decision if you have the logistic regression number. Start with the ensemble and the question never comes up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the top rung tells you
&lt;/h2&gt;

&lt;p&gt;The tuned search is the most expensive step by two orders of magnitude, and on this run it was the least informative. It improved AUC by more than half a point on one dataset of six (phoneme, +1.0). On census income and spam it changed nothing. On churn and hypothyroid the tuned model scored &lt;em&gt;slightly worse&lt;/em&gt; on the test set than the defaults. That's most likely the search fitting noise in the cross-validation folds, which is easy to do when you pick the best of 40 configurations scored on a few thousand rows.&lt;/p&gt;

&lt;p&gt;This is not a claim that tuning never matters. Phoneme gained a real point, and on other data it can matter a lot. &lt;a href="https://arxiv.org/abs/2305.02997" rel="noopener noreferrer"&gt;McElfresh et al.&lt;/a&gt;, comparing 19 algorithms across 176 datasets, found that "for a surprisingly high number of datasets, either the performance difference between GBDTs and NNs is negligible, or light hyperparameter tuning on a GBDT is more important than choosing between NNs and GBDTs." Note what that compares: &lt;em&gt;light&lt;/em&gt; tuning against switching model families, not tuning against good defaults. On these six datasets, the defaults were already close to what a 200-fit search found. The only way to know which situation &lt;em&gt;your&lt;/em&gt; dataset is in is to have the untuned number to compare against. Without it, the search's best score looks like something the search earned.&lt;/p&gt;

&lt;p&gt;A caveat that works in the argument's favour: the test sets here range from 633 rows to about 9,800. A half-point AUC difference on a thousand held-out rows is within the variation you'd get from a different random split. Re-running the whole comparison on three more random splits didn't change the picture. Phoneme gained about a point from tuning every time. On the other five datasets the tuning gain averaged between −0.3 and +0.2 points across the four splits. On four of them it landed on both sides of zero from one split to the next. Gamma telescope gained every time, but only 0.1 to 0.4 points. The honest reading of the tuned column is "indistinguishable from defaults" on most rows, which is exactly the thing you can't see without a default to compare.&lt;/p&gt;

&lt;h2&gt;
  
  
  The research literature keeps getting embarrassed the same way
&lt;/h2&gt;

&lt;p&gt;None of this is new. Whole subfields have found out, sometimes years late, that their sophisticated methods hadn't been compared against a properly built simple one.&lt;/p&gt;

&lt;p&gt;In recommender systems, &lt;a href="https://arxiv.org/abs/1907.06902" rel="noopener noreferrer"&gt;Ferrari Dacrema, Cremonesi and Jannach&lt;/a&gt; tried to reproduce 18 neural recommendation algorithms from top conferences in 2019. They could reproduce 7 with reasonable effort, and 6 of those 7 could often be beaten by simple nearest-neighbour or graph-based heuristics. The paper won best paper at RecSys that year. The problem hasn't gone away. A May 2026 study (&lt;a href="https://arxiv.org/abs/2605.07125" rel="noopener noreferrer"&gt;Han et al.&lt;/a&gt;) tested an "intentionally simple graph heuristic" against modern generative recommenders. It starts from only the last one or two items a user interacted with and uses no training at all. It "matches or outperforms many modern baselines, with relative NDCG@10 improvements of 38.10% and 44.18% over the best competing baseline" on two widely used Amazon review datasets, and stays competitive on 10 of the 14 datasets tested.&lt;/p&gt;

&lt;p&gt;In time-series forecasting, &lt;a href="https://arxiv.org/abs/2205.13504" rel="noopener noreferrer"&gt;Zeng et al.&lt;/a&gt; introduced what they called "a set of embarrassingly simple one-layer linear models" as a sanity check against the Transformer architectures that had dominated long-horizon forecasting research. Their result: "LTSF-Linear surprisingly outperforms existing sophisticated Transformer-based LTSF models in all cases, and often by a large margin." Later Transformer designs such as &lt;a href="https://arxiv.org/abs/2211.14730" rel="noopener noreferrer"&gt;PatchTST&lt;/a&gt; did beat those linear models, which is the point: once a simple baseline existed, the bar went up.&lt;/p&gt;

&lt;p&gt;In clinical prediction, a &lt;a href="https://pubmed.ncbi.nlm.nih.gov/30763612/" rel="noopener noreferrer"&gt;systematic review by Christodoulou et al.&lt;/a&gt; (&lt;em&gt;Journal of Clinical Epidemiology&lt;/em&gt;, 2019) pooled 282 comparisons between logistic regression and machine-learning models from 71 studies. Among the 145 comparisons at low risk of bias, the difference in discrimination between the two was 0.00 on the logit(AUC) scale (95% CI −0.18 to 0.18). A machine-learning advantage only appeared in the 137 comparisons at high risk of bias. Their conclusion: "We found no evidence of superior performance of ML over LR."&lt;/p&gt;

&lt;p&gt;And on ordinary tabular data, &lt;a href="https://arxiv.org/abs/2207.08815" rel="noopener noreferrer"&gt;Grinsztajn, Oyallon and Varoquaux&lt;/a&gt; benchmarked deep learning against tree ensembles on 45 datasets and found that "tree-based models remain state-of-the-art on medium-sized data (∼10K samples) even without accounting for their superior speed", which is why trees, not neural networks, are rung three above.&lt;/p&gt;

&lt;p&gt;The common thread isn't "simple models are better". Sometimes they're not, as the churn row shows. It's that the complicated method was never properly compared against the simple one. In a research paper that costs credibility. In a company it costs a production system that's harder to explain, slower to retrain and more fragile than it needed to be, delivering an improvement nobody measured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproducing the bottom three rungs
&lt;/h2&gt;

&lt;p&gt;Here are the first three rungs for the census-income dataset. It needs &lt;code&gt;pip install pmlb scikit-learn&lt;/code&gt; and runs in a few seconds. It reproduces the table's numbers on scikit-learn 1.9.1; other versions can differ in the third decimal.&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;pmlb&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;fetch_data&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sklearn.compose&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;make_column_transformer&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sklearn.dummy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DummyClassifier&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sklearn.ensemble&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HistGradientBoostingClassifier&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.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="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.pipeline&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;make_pipeline&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;OneHotEncoder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StandardScaler&lt;/span&gt;

&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;adult&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="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;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;target&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 1 = income above $50K
&lt;/span&gt;&lt;span class="n"&gt;cats&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;workclass&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;education&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;marital-status&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;occupation&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;relationship&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;race&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;sex&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;native-country&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;nums&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&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;columns&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cats&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;X_tr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;X_te&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y_tr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y_te&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;df&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;stratify&lt;/span&gt;&lt;span class="o"&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;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="n"&gt;ladder&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;guess the majority&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DummyClassifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strategy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;most_frequent&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;logistic regression&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;make_pipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;make_column_transformer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OneHotEncoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handle_unknown&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ignore&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;cats&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&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;nums&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="nc"&gt;LogisticRegression&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_iter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;boosted trees, defaults&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;HistGradientBoostingClassifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;categorical_features&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cats&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&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;columns&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="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ladder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&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;fit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X_tr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y_tr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;auc&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_te&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_te&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; accuracy &lt;/span&gt;&lt;span class="si"&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;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X_te&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y_te&lt;/span&gt;&lt;span class="p"&gt;)&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="s"&gt;   AUC &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;auc&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap in your own data and you have the three numbers every later model has to be reported against. If you're working with an assistant, ask for the floor explicitly, because "build a model" won't reliably produce it. Something like: &lt;em&gt;"Before any other model, fit a majority-class DummyClassifier and a plain logistic regression on the same split, and report every later model as an improvement over both."&lt;/em&gt; That's a one-sentence addition to the prompt, and it changes what the rest of the notebook can prove.&lt;/p&gt;

&lt;p&gt;The list of impressive first moves keeps growing. After gradient boosting came tabular deep learning, and now pretrained tabular foundation models, which &lt;a href="https://arxiv.org/abs/2506.16791" rel="noopener noreferrer"&gt;TabArena&lt;/a&gt;, a continuously maintained benchmark, reports "excel on smaller datasets." Each one is worth trying. Each one also makes it easier to skip the floor, because the first result already looks like a finished product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two lines in every model report
&lt;/h2&gt;

&lt;p&gt;The fix is a reporting habit, not a technique. Every model summary, whether it's a notebook, a slide or a pull request description, should include two numbers next to the headline score: the improvement over the majority-class guess, and the improvement over the simplest reasonable model. If the first number is small, you don't have a model yet. If the second is small, you have a decision to make about whether the complexity is worth keeping, and now you have what you need to make it.&lt;/p&gt;

&lt;p&gt;It's the same judgement the tuning lesson in SophiArch's &lt;a href="https://lms.sophiarch.com/courses/intro-to-machine-learning?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=first-model-should-be-embarrassing" rel="noopener noreferrer"&gt;Introduction to Machine Learning course&lt;/a&gt; is built around: it starts from untouched defaults and gives a whole section to deciding when to stop. The table above is that decision with real numbers attached.&lt;/p&gt;

&lt;p&gt;The embarrassing model isn't the one you ship. It's the one that tells you whether the model you ship was worth building.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Olson, R. S., La Cava, W., Orzechowski, P., Urbanowicz, R. J., &amp;amp; Moore, J. H. (2017). &lt;a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC5725843/" rel="noopener noreferrer"&gt;PMLB: a large benchmark suite for machine learning evaluation and comparison&lt;/a&gt;. &lt;em&gt;BioData Mining&lt;/em&gt;, 10, 36. Source of the six datasets; the data is at &lt;a href="https://github.com/EpistasisLab/pmlb" rel="noopener noreferrer"&gt;github.com/EpistasisLab/pmlb&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;scikit-learn. &lt;a href="https://scikit-learn.org/stable/modules/generated/sklearn.dummy.DummyClassifier.html" rel="noopener noreferrer"&gt;&lt;code&gt;DummyClassifier&lt;/code&gt;&lt;/a&gt;. The majority-class baseline used as rung one; quoted wording checked against the docstring in scikit-learn 1.9.1.&lt;/li&gt;
&lt;li&gt;McElfresh, D., Khandagale, S., Valverde, J., Prasad C, V., Feuer, B., Hegde, C., Ramakrishnan, G., Goldblum, M., &amp;amp; White, C. (2023). &lt;a href="https://arxiv.org/abs/2305.02997" rel="noopener noreferrer"&gt;When Do Neural Nets Outperform Boosted Trees on Tabular Data?&lt;/a&gt; &lt;em&gt;NeurIPS 2023 Datasets and Benchmarks Track&lt;/em&gt;. 19 algorithms, 176 datasets; source of the light-tuning finding.&lt;/li&gt;
&lt;li&gt;Ferrari Dacrema, M., Cremonesi, P., &amp;amp; Jannach, D. (2019). &lt;a href="https://arxiv.org/abs/1907.06902" rel="noopener noreferrer"&gt;Are We Really Making Much Progress? A Worrying Analysis of Recent Neural Recommendation Approaches&lt;/a&gt;. &lt;em&gt;RecSys '19&lt;/em&gt;. 7 of 18 algorithms reproducible, 6 of those often beaten by simple heuristics.&lt;/li&gt;
&lt;li&gt;Han, H., Ma, L., Wang, H., Li, B., Zha, D., et al. (2026). &lt;a href="https://arxiv.org/abs/2605.07125" rel="noopener noreferrer"&gt;An Embarrassingly Simple Graph Heuristic Reveals Shortcut-Solvable Benchmarks for Sequential Recommendation&lt;/a&gt;. &lt;em&gt;arXiv:2605.07125&lt;/em&gt;. Simple graph heuristic competitive on 10 of 14 datasets.&lt;/li&gt;
&lt;li&gt;Zeng, A., Chen, M., Zhang, L., &amp;amp; Xu, Q. (2023). &lt;a href="https://arxiv.org/abs/2205.13504" rel="noopener noreferrer"&gt;Are Transformers Effective for Time Series Forecasting?&lt;/a&gt; &lt;em&gt;AAAI 2023&lt;/em&gt;. Source of the one-layer linear (LTSF-Linear) result.&lt;/li&gt;
&lt;li&gt;Nie, Y., Nguyen, N. H., Sinthong, P., &amp;amp; Kalagnanam, J. (2023). &lt;a href="https://arxiv.org/abs/2211.14730" rel="noopener noreferrer"&gt;A Time Series is Worth 64 Words: Long-term Forecasting with Transformers&lt;/a&gt;. &lt;em&gt;ICLR 2023&lt;/em&gt;. PatchTST, a Transformer design that beat the LTSF-Linear baselines.&lt;/li&gt;
&lt;li&gt;Christodoulou, E., Ma, J., Collins, G. S., Steyerberg, E. W., Verbakel, J. Y., &amp;amp; Van Calster, B. (2019). &lt;a href="https://pubmed.ncbi.nlm.nih.gov/30763612/" rel="noopener noreferrer"&gt;A systematic review shows no performance benefit of machine learning over logistic regression for clinical prediction models&lt;/a&gt;. &lt;em&gt;Journal of Clinical Epidemiology&lt;/em&gt;, 110, 12–22.&lt;/li&gt;
&lt;li&gt;Grinsztajn, L., Oyallon, E., &amp;amp; Varoquaux, G. (2022). &lt;a href="https://arxiv.org/abs/2207.08815" rel="noopener noreferrer"&gt;Why do tree-based models still outperform deep learning on tabular data?&lt;/a&gt; &lt;em&gt;NeurIPS 2022 Datasets and Benchmarks Track&lt;/em&gt;. 45-dataset benchmark of trees against deep learning.&lt;/li&gt;
&lt;li&gt;Erickson, N., Purucker, L., Tschalzev, A., Holzmüller, D., et al. (2025). &lt;a href="https://arxiv.org/abs/2506.16791" rel="noopener noreferrer"&gt;TabArena: A Living Benchmark for Machine Learning on Tabular Data&lt;/a&gt;. &lt;em&gt;arXiv:2506.16791&lt;/em&gt;. Continuously maintained tabular benchmark; source of the foundation-model finding.&lt;/li&gt;
&lt;li&gt;SophiArch. &lt;a href="https://lms.sophiarch.com/courses/intro-to-machine-learning?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=first-model-should-be-embarrassing" rel="noopener noreferrer"&gt;Introduction to Machine Learning&lt;/a&gt;. Course covering supervised learning fundamentals, model evaluation metrics, and a hyperparameter tuning workflow that starts from defaults and includes explicit stopping criteria.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>machinelearning</category>
      <category>scikitlearn</category>
      <category>python</category>
      <category>datascience</category>
    </item>
    <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>
  </channel>
</rss>
