<?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: Oluwagbade Odimayo</title>
    <description>The latest articles on DEV Community by Oluwagbade Odimayo (@gbadedata).</description>
    <link>https://dev.to/gbadedata</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%2F3907243%2Fae52571f-1797-422c-b11f-02825ee4d2a5.png</url>
      <title>DEV Community: Oluwagbade Odimayo</title>
      <link>https://dev.to/gbadedata</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gbadedata"/>
    <language>en</language>
    <item>
      <title>The fraud that pays in full</title>
      <dc:creator>Oluwagbade Odimayo</dc:creator>
      <pubDate>Sun, 12 Jul 2026 19:21:19 +0000</pubDate>
      <link>https://dev.to/gbadedata/the-fraud-that-pays-in-full-20b</link>
      <guid>https://dev.to/gbadedata/the-fraud-that-pays-in-full-20b</guid>
      <description>&lt;p&gt;Two credit accounts land on a risk analyst's desk in the same month. Both sit near their respective limits. Both have stopped paying. Both are sliding into delinquency. On this month's statement, they are indistinguishable.&lt;/p&gt;

&lt;p&gt;One is a family that lost an income and is drowning. The other is a fraudster who spent half a year building a spotless record so the line would be raised, and has now drawn every available dollar with no intention of paying it back.&lt;/p&gt;

&lt;p&gt;Freeze the wrong one and you either let a fraud loss run to charge-off, or you slam the line on a struggling borrower at the worst possible moment. This is the problem behind [bustout detection (&lt;a href="https://github.com/gbadedata/bustout-detection" rel="noopener noreferrer"&gt;https://github.com/gbadedata/bustout-detection&lt;/a&gt;), and it is the latest in a short series about fraud signals that are not what they first appear. The &lt;a href="https://github.com/gbadedata/transaction-fraud-scoring" rel="noopener noreferrer"&gt;first&lt;/a&gt; was a card-fraud ring that turned out to be an artefact of the data. The second (&lt;a href="https://github.com/gbadedata/mule-network-detection" rel="noopener noreferrer"&gt;https://github.com/gbadedata/mule-network-detection&lt;/a&gt;) was a money mule the graph flagged, and the evidence did not support. This one is the opposite trap: the fraud that hides by looking like your best customer, and the struggling borrower a lazy model would punish in its place.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a bust-out is
&lt;/h2&gt;

&lt;p&gt;Bust-out is first-party fraud, and it is the expensive kind. There is no stolen card and no victim to call. The account holder is the fraud. The pattern runs in three acts.&lt;/p&gt;

&lt;p&gt;First, cultivation. The account is opened and used carefully. Balances stay low, statements are paid in full, and the behaviour is so clean that the lender does what lenders do with good customers: it raises the limit, again and again. For several months this account is a model borrower, and on paper one of the best on the book.&lt;/p&gt;

&lt;p&gt;Then the ramp. Over a few months utilisation climbs, payments shift off full and toward the minimum, and the balance grows. Often a fresh limit increase is taken right at the turn.&lt;/p&gt;

&lt;p&gt;Then the bust. The whole line is drawn at once, cash is pulled where it can be, the payments stop, and the account rolls to charge-off and goes silent.&lt;/p&gt;

&lt;p&gt;A model that scores an account on this month's numbers never sees it coming, because for most of the account's life there is nothing to see. The signal is not in any single statement. It is in the shape of the months.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: utilisation lies
&lt;/h2&gt;

&lt;p&gt;The obvious way to rank accounts for review is by how stretched they are: utilisation, or days past due. It is intuitive, and it is wrong, because genuine distress produces the same readings. I built the project on a synthetic account-month panel with four kinds of account, because no lender publishes labelled bust-out data and the whole difficulty is telling the classes apart. Alongside good accounts and steady revolvers, the panel carries genuine distress: a household whose utilisation climbs slowly, whose payments taper toward the minimum, whose spend falls as the room runs out, and who ends up delinquent. Distress and bust-out arrive at the same place. They travel there by different roads.&lt;/p&gt;

&lt;p&gt;Here is the same set of accounts averaged over the months that matter, a bust-out in its ramp against a distressed account in trouble:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Bust-out ramp&lt;/th&gt;
&lt;th&gt;Genuine distress&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Utilisation&lt;/td&gt;
&lt;td&gt;0.73&lt;/td&gt;
&lt;td&gt;0.95&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Utilisation slope, 3 months&lt;/td&gt;
&lt;td&gt;0.48&lt;/td&gt;
&lt;td&gt;0.28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recent full-payment streak&lt;/td&gt;
&lt;td&gt;0.76&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Minimum-payment streak&lt;/td&gt;
&lt;td&gt;0.38&lt;/td&gt;
&lt;td&gt;2.16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cash-advance share&lt;/td&gt;
&lt;td&gt;0.21&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Limit growth since open&lt;/td&gt;
&lt;td&gt;1.82x&lt;/td&gt;
&lt;td&gt;1.00x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Months since last limit increase&lt;/td&gt;
&lt;td&gt;3.1&lt;/td&gt;
&lt;td&gt;15.9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Utilisation, the obvious number, is higher for the honest account. Everything about the path is the opposite. The bust-out climbs faster, off a recent run of full payments that has just broken, draws cash, and has grown its limit quickly and recently. Distress has paid the minimum for a long time, draws nothing, and its limit has not moved. Rank on the level and the queue fills with the wrong accounts: on the panel, roughly seven in ten of the accounts a utilisation-ranked queue flags are genuine distress, not fraud, and a delinquency-ranked queue is worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the path instead of the point
&lt;/h2&gt;

&lt;p&gt;The fix is to stop scoring the snapshot and score the trajectory. The features that matter describe how an account is moving, not where it happens to be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How fast utilisation is climbing, and how far it has jumped above the account's own prior peak. A bust-out ramps hard off a low base; distress creeps.&lt;/li&gt;
&lt;li&gt;Whether a run of full payments has just broken. Bust-outs pay in full through cultivation and then stop abruptly; a distressed account has paid the minimum for a long time.&lt;/li&gt;
&lt;li&gt;The share of activity that is a cash draw, which spikes in a bust-out and barely moves in distress.&lt;/li&gt;
&lt;li&gt;Whether spend is accelerating or falling. A bust-out spends into the ramp; distress spends less as the room disappears.&lt;/li&gt;
&lt;li&gt;How fast and how recently the credit line has grown, and where utilisation sat around the last increase.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A trajectory model is easy to get wrong. If a feature for a given month quietly uses a later statement, the model looks brilliant in a notebook and falls over the day it runs on live accounts that have no future yet. So the guard is a test: features computed on a truncated history must equal features computed on the full history, for the same rows.&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;test_features_use_no_future_info&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;full&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cols&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_features&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_features&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;panel&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_index&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="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;merged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;full&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;part&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;cols&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="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;suffixes&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="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_p&lt;/span&gt;&lt;span class="sh"&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;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allclose&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="n"&gt;c&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="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;c&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;equal_nan&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every feature is built strictly from an account's own past, and the forward label, does the account bust within the next few months, is the only thing allowed to look ahead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning it into something a team can use
&lt;/h2&gt;

&lt;p&gt;A score is not a tool. The project wraps the model in the decision a risk team makes each month: monitor, reduce the line, or freeze it. The queue is ranked not by score alone but by exposure at risk, the undrawn portion of the line that a bust-out would draw at the max-out, because the point of acting early is to cut that exposure before it is taken. Each flag carries plain reasons drawn from the trajectory, and the one that separates fraud from hardship is a clean payment history that has just broken.&lt;/p&gt;

&lt;p&gt;Evaluated out of time, training on earlier months and scoring later ones, three things stand out. The model ranks the rare bust-out label well above the point-in-time baselines: average precision around 0.96 on the synthetic test, against about 0.48 for a utilisation ranking and 0.05 for delinquency.&lt;/p&gt;

&lt;p&gt;It is early enough to matter. At the freeze threshold the model catches most bust-outs a median of two months before the draw, while the exposure is still undrawn and a freeze still prevents the loss.&lt;/p&gt;

&lt;p&gt;And it leaves struggling borrowers alone. This is the result that matters. In the top slice of its queue, the trajectory model's share of genuine-distress accounts is zero, against roughly seventy percent for the utilisation queue and more for the delinquency queue. Same review budget, opposite outcome. That gap is the entire argument for modelling the path.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is, and what it is not
&lt;/h2&gt;

&lt;p&gt;Every number here comes from a synthetic panel, and it is worth being plain about what that does and does not buy. There is no public bust-out dataset, so the generator has to carry the realism, and I built it so that distress and bust-out share an ending and differ in their path, rather than being trivially separable. The exact figures move a little with library versions, and the ramp on synthetic data is cleaner than a real fraudster would leave it. Real bust-out is adversarial and shifts as controls tighten, and a live model would need recalibrating on real outcomes.&lt;/p&gt;

&lt;p&gt;What carries over is the shape of the problem and the shape of the answer. When two very different truths produce the same snapshot, the snapshot is not where the answer lives. Rank on the level and the queue fills with the wrong accounts. Rank on the trajectory and it fills with the right ones. The best customer on the book is sometimes the one to worry about, and the way to tell is to watch how it got there.&lt;/p&gt;

&lt;p&gt;The code, the synthetic panel, the model, the freeze queue, and the investigation queries are all in the &lt;a href="https://github.com/gbadedata/bustout-detection" rel="noopener noreferrer"&gt;repository&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>fraud</category>
      <category>machinelearning</category>
      <category>python</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Your fraud model doesn't know what a truck is</title>
      <dc:creator>Oluwagbade Odimayo</dc:creator>
      <pubDate>Sun, 12 Jul 2026 19:06:53 +0000</pubDate>
      <link>https://dev.to/gbadedata/your-fraud-model-doesnt-know-what-a-truck-is-586g</link>
      <guid>https://dev.to/gbadedata/your-fraud-model-doesnt-know-what-a-truck-is-586g</guid>
      <description>&lt;p&gt;A fuel card is swiped at 2:14 in the morning, at a truck stop 2,400 miles from where the same card bought diesel the day before, for 300 gallons at a single pump. Fraud?&lt;/p&gt;

&lt;p&gt;Two of those three facts are ordinary. A long-haul driver keeps odd hours and covers ground; 2,400 miles in a day is a team or a relay rather than an impossibility, and 300 gallons is a normal fill for a rig with two saddle tanks. The third fact, a single pump putting 300 gallons into a tank that holds 240, is the one that matters. A generic card-fraud model cannot pull the three apart, because it does not know what a truck is. It sees an unusual place, an unusual hour, and a large amount, and it either shrugs or flags them all.&lt;/p&gt;

&lt;p&gt;This project, &lt;a href="https://github.com/gbadedata/fuel-card-fraud-monitoring" rel="noopener noreferrer"&gt;fuel-card-fraud-monitoring&lt;/a&gt;, is about the part a generic fraud model misses: the semantics of fuel. It is part of a short series about fraud signals that are not what they first appear, after a card-fraud ring that turned out to be an &lt;a href="https://github.com/gbadedata/transaction-fraud-scoring" rel="noopener noreferrer"&gt;artefact of the data&lt;/a&gt; and a money mule the evidence did not support (&lt;a href="https://github.com/gbadedata/mule-network-detection" rel="noopener noreferrer"&gt;https://github.com/gbadedata/mule-network-detection&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;This one carries two traps, and they pull in opposite directions. One is the driver who looks guilty and is not. The other is the fraud that looks innocent and is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fuel is not retail
&lt;/h2&gt;

&lt;p&gt;Retail card-fraud models live in a world of merchants, amounts, and velocities, and they are good at it. A fuel card lives somewhere else, under physical constraints a retail model never learns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A tank has a size. A 240-gallon fill into a 240-gallon tank is fine. A 400-gallon fill is fuel going somewhere other than the truck.&lt;/li&gt;
&lt;li&gt;Diesel trucks burn diesel. A fuel card buying premium unleaded, or merchandise, or a cash-equivalent, is worth a second look.&lt;/li&gt;
&lt;li&gt;Trucks get a knowable range of miles per gallon. Between two fills the odometer and the gallons imply an economy, and two miles per gallon on a full tank means the fuel did not go into this truck.&lt;/li&gt;
&lt;li&gt;Trucks run corridors. A driver has a home base and a radius they work within, and their fuelling traces a lane across the map.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is in a generic model, and all of it is in this one. The features are the physics: gallons against tank capacity, fuel-type match, implied miles per gallon from the odometer, speed between consecutive fills, distance from the card's own corridor. Each is computed strictly from what was known before the swipe, so nothing from the future leaks into&lt;br&gt;
the score.&lt;/p&gt;
&lt;h2&gt;
  
  
  The driver who looks guilty
&lt;/h2&gt;

&lt;p&gt;Here is the first trap. Suppose you flag a swipe as off-route when it is far from the card's home base. It reads as reasonable, and it is wrong, because a long-haul driver is always far from base. That is the job. Flag on absolute distance and every long-haul card lights up, the queue fills with drivers doing ordinary work, and the real off-route fraud is buried under them.&lt;/p&gt;

&lt;p&gt;The fix is to measure deviation against the card's own history, not against an absolute:&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;# running centroid of the card's prior fuelling sites (strictly before this swipe) off_route = haversine(lat, lon, mean_lat_prior, mean_lon_prior)
# divide by the card's own prior roaming radius, so a driver who always ranges far
# is not mistaken for one that has suddenly gone off-route off_route_ratio = off_route / (prior_mean_off + 100)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A driver who always ranges 900 miles from their centre has a large off-route distance and a small ratio: normal for them. A driver who has always stayed within 150 miles and today sits 600 miles out has a modest distance by long-haul standards but a large ratio: that is the anomaly. The model leans on the ratio, and that is why a card that roams for a living is not punished for roaming.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two layers, on purpose
&lt;/h2&gt;

&lt;p&gt;The decision at the pump has to be instant and explainable. When a card is declined at the island, someone has to be able to say why, in words, that minute. A gradient-boosted probability is not that. So the system has two layers doing two jobs.&lt;/p&gt;

&lt;p&gt;The first is a rules engine: a handful of deterministic checks with plain-language reasons. Impossible travel between two fills. Gallons over the tank. Wrong fuel. An economy below a floor. These fire in real time, they are auditable, and they carry the pump decision with a reason a person can read: "1,138 gallons exceeds the 300-gallon tank; implied 0.8 mpg on the fill."&lt;/p&gt;

&lt;p&gt;But rules alone are blunt in both directions. They flag 5.4% of legitimate swipes, because a fixed threshold catches ordinary variation along with fraud, and they miss the fraud that stays under every threshold. So a model rides on top as the ranker, ordering a review queue by expected loss.&lt;/p&gt;

&lt;p&gt;One design choice is worth stating, because the obvious move is the wrong one. You could blend the rules and the model into a single score. Doing that drags the ranking down, because the rules carry their false positives into the blend. The rules earn their place as the real-time guardrail and the explanation; the model earns its place as the ranker. Keeping them separate makes each one better.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fraud that looks innocent
&lt;/h2&gt;

&lt;p&gt;Here is the second trap, and it is the reason the model exists. Consider a swipe built to stay under every rule:&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;# evasive: every signal kept just under its threshold, anomalous only together
&lt;/span&gt;&lt;span class="n"&gt;gallons&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tank&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.92&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="c1"&gt;# near the tank, not over it
&lt;/span&gt;&lt;span class="n"&gt;odometer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;gallons&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;3.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;4.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# a low economy, but above the floor
&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(...,&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;overnight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;manual&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;above_card_average&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing here trips a rule. The fill is close to the tank but not over it. The economy is poor but not impossible. It is hand-keyed and overnight and above the card's usual spend, but none of those alone is fraud, and roughly one legitimate swipe in twelve is hand-keyed. Each signal sits in the safe zone. Only the combination is wrong.&lt;/p&gt;

&lt;p&gt;The rules catch about four percent of this. The model catches close to ninety, because a gradient-boosted model reads the interaction of features that no single threshold can. That gap is the entire case for the second layer. Across the test set, by typology:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Typology&lt;/th&gt;
&lt;th&gt;Rules alone&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Impossible travel&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tank overflow&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;95%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrong or non-fuel&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;90%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Implausible economy&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rapid repeat&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Off route&lt;/td&gt;
&lt;td&gt;92%&lt;/td&gt;
&lt;td&gt;79%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Evasive&lt;/td&gt;
&lt;td&gt;4%&lt;/td&gt;
&lt;td&gt;87%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rules own the clear typologies, which is what a real-time guardrail should do. The model owns the one that hides in combination, and it does so while flagging only 0.3% of legitimate swipes, against the rules' 5.4%.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it adds up to
&lt;/h2&gt;

&lt;p&gt;On an out-of-time test of about 7,000 swipes at a 2.6% fraud rate, the model ranks fraud far above the rules alone: average precision near 0.99 on the synthetic test, against 0.75 for the rules as a ranker and 0.03 for no-skill. Two operating views matter more than the single number.&lt;/p&gt;

&lt;p&gt;As a review queue ranked by expected loss, working the top 2% of swipes recovers 90% of the fraud value, and the top 5% recovers all of it. A team with a fixed amount of review time spends it where the money is.&lt;/p&gt;

&lt;p&gt;As a real-time gate, declining on a hard rule or a high model probability catches 96% of fraud while declining under 1% of legitimate swipes. Adding a step-up, a prompt for a second factor, on the middle band reaches 98% of fraud while touching 1% of legitimate traffic. Most fraud stopped at the pump, almost no honest driver inconvenienced.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is, and what it is not
&lt;/h2&gt;

&lt;p&gt;The numbers come from a synthetic fuelling panel, and it is worth being plain about that. No fuel-card programme publishes labelled fraud, so the generator has to carry the realism: real hubs and distances, drivers with home bases and radii and tanks, corridors that fills trace across the map, and a rate of hand-keyed entry high enough that entry mode is not a giveaway.&lt;/p&gt;

&lt;p&gt;The typologies, evasive included, are injected against that backdrop. Exact figures move a little with library versions, and real fuel fraud adapts as controls tighten in a way a static generator does not.&lt;/p&gt;

&lt;p&gt;What carries over is the shape of it. Fuel-card fraud is a domain problem before it is a modelling problem. The useful signals are physical, and a model that does not know what a truck is will both miss the fraud that hides and punish the driver who is only doing the job.&lt;/p&gt;

&lt;p&gt;Encode the semantics, measure each swipe against the card's own history, keep a transparent guardrail for the real-time call, and let the model rank what is left.&lt;/p&gt;

&lt;p&gt;The code, the synthetic panel, the rules engine, the model, and the investigation queries are all in the &lt;a href="https://github.com/gbadedata/fuel-card-fraud-monitoring" rel="noopener noreferrer"&gt;repository&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>fraud</category>
      <category>machinelearning</category>
      <category>datascience</category>
      <category>python</category>
    </item>
    <item>
      <title>The mule that wasn't: a graph model, synthetic data, and the accounts that broke it</title>
      <dc:creator>Oluwagbade Odimayo</dc:creator>
      <pubDate>Sat, 11 Jul 2026 22:58:59 +0000</pubDate>
      <link>https://dev.to/gbadedata/the-mule-that-wasnt-a-graph-model-synthetic-data-and-the-accounts-that-broke-it-2cn8</link>
      <guid>https://dev.to/gbadedata/the-mule-that-wasnt-a-graph-model-synthetic-data-and-the-accounts-that-broke-it-2cn8</guid>
      <description>&lt;p&gt;This is the second of a pair. The &lt;a href="https://dev.to/gbadedata/the-fraud-ring-that-wasnt-real-transaction-data-and-the-results-you-can-trust-13b3"&gt;first&lt;/a&gt; was about card fraud, and its lesson was that a chart I was proud of turned out to be a mirror. It looked like my model had found fraud rings, and what it had really found was my own assumptions handed back to me. This post is about money laundering, and it opens on a number I was briefly pleased with and then spent a while trying to explain away: 0.99.&lt;/p&gt;

&lt;p&gt;That was the precision-recall AUC of a mule-detection model on a labelled dataset, with per-typology recall between 93 and 100 percent. If you have spent any time near anti-money-laundering work, you already know that a 0.99 on that problem is not a triumph. It is a symptom. Real laundering detection does not look like that, and when your numbers say it does, the thing to distrust is your numbers.&lt;/p&gt;

&lt;p&gt;Here is what the model was trying to do, why the 0.99 was fake, and what the real data did to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why laundering is a graph problem
&lt;/h2&gt;

&lt;p&gt;Laundering is rare. In the data I used it runs to about 0.18 percent of transfers, and in the wild the uncommon variants are far rarer than that. At those rates, scoring transactions one at a time is close to pointless. A classifier that flags nothing is right 99.8 percent of the time, and whatever signal a single transfer carries is drowned by the base rate.&lt;/p&gt;

&lt;p&gt;The signal is not in the transfer. It is in the shape of the money's path. Mules move funds through networks, and those networks take recognisable forms: many accounts feeding one (fan-in), one account feeding many (fan-out), money walked hop by hop along a chain, closed cycles that return to their start, and rapid pass-through where funds land in an account and leave again within hours. Lift the problem off the individual transaction and onto the structure of the account graph, and the signal concentrates where the typologies live.&lt;/p&gt;

&lt;p&gt;So I built the account graph, computed features over it, scored transfers with those features, and ranked accounts for an investigator to review. The dataset was &lt;a href="https://www.kaggle.com/datasets/ealtman2019/ibm-transactions-for-anti-money-laundering-aml" rel="noopener noreferrer"&gt;IBM's synthetic anti-money-laundering set&lt;/a&gt;, which ships with a per-transaction laundering flag and a separate file naming the typology of each laundering attempt. No bank releases real labelled mule data, so a synthetic set is the firm ceiling on what any of this can claim, and I will come back to exactly how that ceiling bit me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one discipline I carried over
&lt;/h2&gt;

&lt;p&gt;The card-fraud post was, at bottom, about leakage: a feature that quietly peeks at the future inflates every number downstream, and none of it survives contact with reality. So this time I built the leakage guard in first.&lt;/p&gt;

&lt;p&gt;The features split into two kinds, and keeping them apart is the whole discipline. Streaming features describe an account's history strictly before the current transfer: how many distinct counterparties it has had, how much it has moved, its pass-through ratio, and the burst counts, meaning how many counterparties arrived in a short trailing window. These use no future information, so they are what a real-time monitor could score on. A feature computed over an account's later transfers is the graph version of lookahead: knowledge you would not have at the moment you have to decide.&lt;/p&gt;

&lt;p&gt;The second kind, retrospective features, describe an account across the whole window for an investigator working a case after the fact. They are useful, and they are never model inputs, because letting the future into training is how you build a model that works in a notebook and nowhere else.&lt;/p&gt;

&lt;p&gt;The guard is a test, not a comment in the code. Compute the streaming features on the first half of the data, compute them again on the full data, and the early rows have to come out identical. If a later transfer changes an earlier row, information is leaking backward, and the test fails.&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;full&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cols&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;prior_features&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="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;1&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;df&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;prior_features&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;iloc&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="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allclose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;full&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;iloc&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;to_numpy&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                       &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;to_numpy&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On top of those features sat a gradient-boosted model trained on a strict time split, its transaction scores aggregated to a per-account risk, and a queue that ranked accounts by expected laundered value with plain-language reasons attached, so an alert could explain itself.&lt;/p&gt;

&lt;p&gt;All of it passed. The leakage test was green. And the model scored 0.99.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that was measuring the wrong thing
&lt;/h2&gt;

&lt;p&gt;I had built a schema-faithful mock early on so the whole pipeline could run without the download, and I had tuned and tested against it. On that mock the model was close to perfect. Then the real file finished downloading, I pointed the loader at it, changed nothing else, and everything fell through the floor.&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;synthetic mock&lt;/th&gt;
&lt;th&gt;real IBM data&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;transactions&lt;/td&gt;
&lt;td&gt;63,000&lt;/td&gt;
&lt;td&gt;5,080,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;laundering rate&lt;/td&gt;
&lt;td&gt;2.4%&lt;/td&gt;
&lt;td&gt;0.18%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;transaction PR-AUC&lt;/td&gt;
&lt;td&gt;0.99&lt;/td&gt;
&lt;td&gt;0.037&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;per-typology recall&lt;/td&gt;
&lt;td&gt;93 to 100%&lt;/td&gt;
&lt;td&gt;27 to 51%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;laundered value in top 2%&lt;/td&gt;
&lt;td&gt;~99%&lt;/td&gt;
&lt;td&gt;54.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Not 0.99. 0.037. About twenty times better than random at that base rate, which is real signal, but a different universe from what the mock had promised. The same code, the same features, the same leakage guard, and the number fell by a factor of twenty-five the instant it met data I had not generated myself.&lt;/p&gt;

&lt;p&gt;The mock had not been a test. It had been a mirror, the same one from the first post wearing different clothes. I had built the synthetic data to contain the patterns my model looked for, and I had left out the one thing that makes the real problem hard. The 0.99 was not a measurement of the model. It was a measurement of the mock, reflecting my own design decisions back at me and calling them accuracy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every signal that catches a mule also describes a bank
&lt;/h2&gt;

&lt;p&gt;Here is what the mock left out, and what the real data is full of: legitimate high-throughput accounts.&lt;/p&gt;

&lt;p&gt;Payment processors. Exchanges. Settlement accounts. Aggregators. Accounts that receive from hundreds of counterparties in a day and send to thousands, moving enormous volume through the network as their ordinary, entirely legal business. My mock had none of them. Every account in it was either a normal low-volume account or a mule, so the model never had to tell a mule apart from anything except a quiet legitimate account, which is easy.&lt;/p&gt;

&lt;p&gt;Real data does not grant that. One of my investigation queries ranks accounts by fan-in, the number of distinct senders paying into them within a short window. Here is the top of that list on the real data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        account   peak_senders_3d   total_in   laundering_share
   70-100428660               545      638329              0.000
   70-1004286A8               328      372750              0.000
 21611-8051A3FA0                93    22231920              0.000
  4726-808FAF7C0                88     1742220              0.000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five hundred and forty-five distinct senders in three days, and a laundering share of zero. It is completely legitimate. It is also indistinguishable, by every structural signal I had built, from a collector at the center of a fan-in ring. High burst, high fan-in, balanced pass-through: a payment processor lights up all of them, because gathering money from many sources and moving it on is what a processor does and what a mule does. The structure is the same. The intent is not, and intent is not in the graph.&lt;/p&gt;

&lt;p&gt;That is the confound, and it is the entire difficulty of the problem. On synthetic data my structural features looked like a laundering detector. On real data they are closer to a high-activity detector, and high activity is mostly legitimate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The queue that filled up with banks
&lt;/h2&gt;

&lt;p&gt;Nowhere was this clearer than the investigation queue. I had ranked accounts by expected laundered value, which is risk multiplied by the money the account moved, the same value-first logic that worked in the card-fraud project. On real data the top of the queue was dominated by legitimate accounts moving hundreds of billions of dollars, because raw value swamps everything. Two of the top ten were laundering. The other eight were banks.&lt;/p&gt;

&lt;p&gt;The fix came from asking what a mule does that a processor does not, and the answer is timing. A processor is busy all year. A mule is busy for a few days. Its burst is not just large, it is concentrated: a large share of everything the account ever does falls inside one short window. So I added burst concentration, the peak burst over the account's total activity, near one for an account that fired in a single spike and near zero for one that is always active, and folded it into the ranking alongside a log of the value so a handful of mega-accounts could not dominate on size alone.&lt;/p&gt;

&lt;p&gt;Here is the top of the queue after that change. The &lt;code&gt;reasons&lt;/code&gt; column is generated from the structural signals, so each alert says why it is there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; concentration  laundering              reasons
         0.985           1  fans in: 56 inflows; fans out: 3623 outflows; u-turn match 1.00
         0.985           1  fans in: 539 inflows; fans out: 36075 outflows; u-turn match 1.00
         1.000           0  elevated model score without a single clear structural pattern
         1.000           1  fans in: 26 inflows in a short window
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That lifted the laundering share at the top of the queue from two in ten to six in ten. The accounts it surfaces now include genuine laundering hubs with tens of thousands of transfers packed into a short burst, while the always-on processors drop away. It is a real improvement, and I want to be precise about what it is not. It is a first step, not a solution. The false positives that remain are concentrated accounts the model scored high, and concentration alone cannot separate a mule from a small legitimate account that simply had a busy week.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the real number buys you
&lt;/h2&gt;

&lt;p&gt;Stripped of the fantasy 0.99, the model still does something worth having. The precision-recall AUC of 0.037 is the wrong lens, because precision and recall on a 0.18 percent base rate punish you for the base rate. The lens that matters to a review team with a fixed number of alerts they can work is value. Rank every transfer by score, walk down the list, and by the time you have reviewed the top two percent you have recovered 54.5 percent of the laundered value and 38 percent of the laundering transfers.&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%2Fxrvsl5bviuruk8qle6sg.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%2Fxrvsl5bviuruk8qle6sg.png" alt="Laundered value recovered as the review budget grows" width="800" height="489"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is a usable operating point, and it is the number I would put in front of an investigator, not the AUC.&lt;/p&gt;

&lt;p&gt;Per typology the picture is even and modest, which is what real detection looks like. Recall across the labelled typologies runs from 27 percent on fan-out to 51 percent on fan-in, with cycles, stacks, scatter-gather and the rest in between.&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%2F9y4tfm2ni2rdeydy1jyl.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%2F9y4tfm2ni2rdeydy1jyl.png" alt="Detection recall by laundering typology" width="799" height="444"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nothing is solved. Nothing is missed. The typologies whose activity is concentrated in time do a little better than the ones that blend into ordinary volume, which is exactly what you would predict from the confound.&lt;/p&gt;

&lt;p&gt;Getting past this ceiling is not a matter of a better classifier on the same features. It needs peer-group baselines, so an account's throughput is judged against accounts of its own type rather than against everyone, and it needs account history, so a sudden burst on a young account counts for more than steady volume on an old one. It needs to score entities and networks, not lone transfers. That is a larger project than this one, and I would rather ship the version that is clear about its own ceiling than the one that hides behind a 0.99.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same lesson, twice
&lt;/h2&gt;

&lt;p&gt;Both of these projects ended in the same place, and I think it is the place worth ending in. In the first, the trap was temporal: a feature that peeked at the future made the model look like it had learned something it had not. This time I avoided that one. The leakage test was green from the start. And a subtler version of the same self-deception got me anyway, because a synthetic dataset is a kind of lookahead too. It lets you see a world you designed to be solvable and mistake it for the world.&lt;/p&gt;

&lt;p&gt;The discipline that catches both is the same. When a number makes you look good, that is the moment to distrust it and go looking for the thing that breaks it. In the card-fraud project the thing that broke the chart was real transactions. Here it was a legitimate account moving a billion dollars that trips every mule signal there is. Both were sitting in the data the whole time. The only question was whether I would go find them before someone else did.&lt;/p&gt;

&lt;p&gt;The code, the leakage test, and the full write-up of the real numbers are &lt;a href="https://github.com/gbadedata/mule-network-detection" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>datascience</category>
      <category>python</category>
      <category>fintech</category>
    </item>
    <item>
      <title>The fraud ring that wasn't: real transaction data and the results you can trust</title>
      <dc:creator>Oluwagbade Odimayo</dc:creator>
      <pubDate>Sat, 11 Jul 2026 09:07:41 +0000</pubDate>
      <link>https://dev.to/gbadedata/the-fraud-ring-that-wasnt-real-transaction-data-and-the-results-you-can-trust-13b3</link>
      <guid>https://dev.to/gbadedata/the-fraud-ring-that-wasnt-real-transaction-data-and-the-results-you-can-trust-13b3</guid>
      <description>&lt;p&gt;&lt;em&gt;A device-sharing signal looked perfect on my test data and collapsed on the real thing. So did my model's best score. Both failures pointed at the same lesson.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The alert sitting at the top of my fraud queue read: &lt;strong&gt;device shared by 4,661 cards.&lt;/strong&gt; It was a legitimate purchase, and it was the best thing that happened in the whole project.&lt;/p&gt;

&lt;p&gt;That alert was wrong because a chart I trusted was wrong, and the chart was wrong because I had, without meaning to, built the test data that made it look right. This is a story about two results that flattered me, how real data destroyed both, and why the results that survive contact with reality are the only ones worth putting your name on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I was building
&lt;/h2&gt;

&lt;p&gt;A transaction fraud scoring pipeline, built around the constraint that actually shapes fraud operations: a review team can only look at a fixed number of alerts a day. You do not get to flag everything. So the goal is not "detect fraud" in the abstract. It is to prevent the most fraud value inside a fixed review budget, keep false declines low, and notice drift before precision quietly rots. Ranking under a budget, not raw accuracy, is the product. A model that catches most fraud but needs a human to review half of all transactions is worthless.&lt;/p&gt;

&lt;p&gt;I built it on synthetic data, then took it to the real thing: the IEEE-CIS Fraud Detection dataset, 590,540 card-not-present transactions, a 3.5% fraud rate, and about $3.08M in fraud value. IEEE is a good stress test because it withholds what a toy pipeline leans on. There is no account id and no geolocation. You reconstruct identity from anonymised card attributes yourself, which is where the interesting work is, and where I got caught.&lt;/p&gt;

&lt;h2&gt;
  
  
  The signal that looked perfect
&lt;/h2&gt;

&lt;p&gt;Organised fraud reuses infrastructure. One device drives many "different" cards. So a natural hypothesis: count the distinct cards seen on a single device, and a device touching a dozen cards is not a coincidence, it is a ring.&lt;/p&gt;

&lt;p&gt;On my test data this worked beautifully. I bucketed transactions by how many cards had touched the device, and the fraud rate climbed cleanly with sharing, all the way to 100% at the top. It was the kind of chart you screenshot for a slide. Sharing on the x-axis, fraud rate marching up the y-axis, a tidy story about catching rings before the model even weighs in.&lt;/p&gt;

&lt;p&gt;I believed it. I should not have, because I had built the data that guaranteed it. Every simulated device in my mock was unique, so the only devices touching many cards were the rings I had injected myself. The chart was real. It was also a mirror. It reflected my assumption back at me and I mistook the reflection for evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The collapse
&lt;/h2&gt;

&lt;p&gt;Then I ran it on the 590,000 real transactions, and the same chart came back flat. Fraud rate barely moved as sharing increased, and the biggest bucket, devices shared by seven or more cards, held nearly 18,000 transactions at only 8.6% fraud. That is a whisper above the 3.5% base. Whatever this was, it was not a ring detector.&lt;/p&gt;

&lt;p&gt;And the queue produced that alert. Device shared by 4,661 cards, on a transaction that was not fraud. Here is the actual row, sitting near the top of the queue by expected loss:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; TransactionID  card1   amount  risk_score  expected_loss                 reason_codes  is_fraud
       3513661   6021  1600.00    0.417203     667.525150  device shared by 4661+ cards         0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cause was obvious once I looked, and a little embarrassing. In the real data the device field is not a fingerprint. Its most common values are &lt;code&gt;Windows&lt;/code&gt;, &lt;code&gt;iOS Device&lt;/code&gt;, &lt;code&gt;MacOS&lt;/code&gt;, &lt;code&gt;Trident/7.0&lt;/code&gt;. Those are operating system and browser families, shared by enormous numbers of ordinary people. "Many cards on this device" mostly meant "many people use Windows." I was counting popularity and calling it fraud.&lt;/p&gt;

&lt;p&gt;The part that stung: I had already seen this exact failure and guarded against it, just not here. Earlier I had ruled out "cards per email domain" and "cards per billing region" as ring signals, because everyone uses a handful of domains and lives in a handful of regions, so those counts are dominated by how common a value is, not by fraud. I knew the rule. I applied it to email and address. I missed it for the device, because my mock had quietly made every device unique, so the trap never sprang in testing.&lt;/p&gt;

&lt;p&gt;That is the first lesson, and it is bigger than one field: &lt;strong&gt;a low-cardinality, high-frequency value will masquerade as a strong signal in any count of shared entities, and test data you built yourself will hide it if you built it too clean.&lt;/strong&gt; The mock did not just fail to catch the bug. The mock made me confident in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it without lying
&lt;/h2&gt;

&lt;p&gt;Two things needed fixing: the signal and the explanation.&lt;/p&gt;

&lt;p&gt;For the signal, a device string alone is too coarse, so I built a more specific fingerprint by combining the device with the browser and the screen resolution, which splits one giant "Windows" bucket into many specific ones. Then, instead of only handing the model a raw shared-card count, I also gave it how common each fingerprint is overall, so the model can separate a rare fingerprint shared by five cards, which is interesting, from a popular one shared by five thousand, which is not. Both are structural counts. Neither touches the label, so neither can leak.&lt;/p&gt;

&lt;p&gt;For the explanation, I gated the human-facing parts. The reason code and the deterministic ring rule now only fire when a fingerprint is specific enough to mean something. After that, the queue can no longer tell you a Windows machine is shared by four thousand cards. That sentence cannot be produced anymore.&lt;/p&gt;

&lt;p&gt;The honest result: once the generic families are gated out, sharing does correlate with fraud, roughly 9% at two cards and 15% at four to six, against the 3.5% base. But it is modest and non-monotonic, and the largest specific bucket falls back toward the base rate. It is a useful investigator lens and a decent cold-start rule for the window before the model has evidence. It is not the clean 100% detector my test data promised. Demoting it from headline result to tested hypothesis was the most senior move in the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same lesson, wearing a different disguise
&lt;/h2&gt;

&lt;p&gt;While I was being honest about the device signal, the model handed me the same lesson in a different form.&lt;/p&gt;

&lt;p&gt;IEEE has hundreds of engineered features, and strong solutions lean on them, so I fed the model all of them: the full Vesta set, the counting and timedelta columns, the identity fields, frequency encodings, and a client key anchored on a registration-date signal. Feature count went from 52 to about 460.&lt;/p&gt;

&lt;p&gt;PR-AUC went from 0.444 to 0.442.&lt;/p&gt;

&lt;p&gt;Adding the dataset's best features moved the top of the queue but not the curve. So I gave the model more capacity, deeper trees, more iterations, which took it to 0.468. More feature engineering took it to 0.468 again. Four configurations, and the ceiling sat at about 0.47.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Configuration&lt;/th&gt;
&lt;th&gt;Features&lt;/th&gt;
&lt;th&gt;PR-AUC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Thin baseline&lt;/td&gt;
&lt;td&gt;52&lt;/td&gt;
&lt;td&gt;0.444&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All Vesta + timedelta + identity&lt;/td&gt;
&lt;td&gt;451&lt;/td&gt;
&lt;td&gt;0.442&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Higher-capacity model&lt;/td&gt;
&lt;td&gt;451&lt;/td&gt;
&lt;td&gt;0.464&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D1-anchored client key + prior-amount&lt;/td&gt;
&lt;td&gt;459&lt;/td&gt;
&lt;td&gt;0.468&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When your best features do not move the curve and neither does more model, the ceiling is not in your features or your learner. It is in the method. The gap between 0.47 and the scores on the public leaderboard is bridged almost entirely by one move I refused to make, and it is the same trap as the mock, wearing a different disguise.&lt;/p&gt;

&lt;p&gt;The move is aggregating features across the entire dataset. Compute the mean transaction amount for a client over all their rows, including future ones, and attach it to today's transaction. It reliably lifts the offline score. It is also feature lookahead: in production you do not have the client's future transactions when you score the current one, so a feature built from them is a promise you cannot keep. It is a flattering number you construct by peeking at data you will not have. Exactly like the mock, it produces a result that looks like evidence and is really a reflection of what you already assumed you would get. Every aggregate in my pipeline is computed strictly from prior rows, current transaction excluded. Weaker on paper, honest at decision time.&lt;/p&gt;

&lt;p&gt;So 0.47 is not a disappointing number. It is what a single calibrated model can do on this data using only information you would actually have when the transaction arrives. I would rather ship that and explain it than post a bigger number I could not deploy or defend in a review.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually ships value
&lt;/h2&gt;

&lt;p&gt;If the score is not the headline, the operating result is, because it maps to what the team does.&lt;/p&gt;

&lt;p&gt;The queue ranks every transaction by expected loss, probability times amount, so it prioritises the cases that cost the most, not just the ones most likely to be fraud. At the cost-chosen operating point on held-out data, the model recovers $380,070 of $609,934 in fraud value, about 62%, at the review load the cost model picked. The value-versus-budget curve shows the rest of the trade-off: for whatever review capacity a team has, how much fraud value it recovers.&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%2F36578coi8kxqneha0ilr.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%2F36578coi8kxqneha0ilr.png" alt=" " width="800" height="545"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That curve is the real deliverable. It lets someone with a fixed headcount choose their point on it deliberately, instead of accepting whatever threshold a model happens to output. Probabilities are calibrated too, which is what makes multiplying a score by an amount mean anything in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take from this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Evidence that flatters you is usually evidence you built.&lt;/strong&gt; The clean chart came from a mock I had shaped, without noticing, to produce it. The bigger score comes from aggregation that peeks at the future. In both cases the result looked like proof and was really a reflection of my own assumptions. Real data, and production, are the only tests that do not tell you what you want to hear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A count of shared entities lies when the entity is low-cardinality.&lt;/strong&gt; OS families, email domains, regions, anything a large share of your population has in common, will dominate the count and look like signal. Guard for it, or your reason codes will confidently accuse a Windows laptop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Leakage-safe is a discipline, and some famous tricks break it.&lt;/strong&gt; Strictly-before, current row excluded, no aggregation over data you would not have at decision time. The move that lifts your offline score by using the future is the move that fails silently in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Report the honest number and the operating result, not the vanity metric.&lt;/strong&gt; A PR-AUC out of context invites a snap judgment. "Recovers 62% of fraud value at this review budget, here is the curve" invites a conversation with whoever owns the budget. The second one is the one that matters.&lt;/p&gt;

&lt;p&gt;The code, the leakage guards, the investigation queries, and the full device-fingerprint write-up are in the repo: &lt;a href="https://github.com/gbadedata/transaction-fraud-scoring" rel="noopener noreferrer"&gt;github.com/gbadedata/transaction-fraud-scoring&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The best result in the whole project was a wrong alert about a Windows laptop, because it was the moment the data stopped agreeing with me. That is usually the moment something true starts.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>datascience</category>
      <category>python</category>
      <category>fraud</category>
    </item>
    <item>
      <title>The hardest part of an autonomous AI agent is the unhappy path</title>
      <dc:creator>Oluwagbade Odimayo</dc:creator>
      <pubDate>Fri, 03 Jul 2026 20:57:13 +0000</pubDate>
      <link>https://dev.to/gbadedata/the-hardest-part-of-an-autonomous-ai-agent-is-the-unhappy-path-3p2c</link>
      <guid>https://dev.to/gbadedata/the-hardest-part-of-an-autonomous-ai-agent-is-the-unhappy-path-3p2c</guid>
      <description>&lt;p&gt;&lt;em&gt;Most demos of AI agents show you the happy path: a clean question, a tidy answer, everyone claps. The interesting engineering is everywhere else. What does your agent do when the API it depends on is down? When the model would happily keep looping, and your credit card is attached to every step? When it has no data, but is perfectly capable of writing something that looks like data anyway?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I built an autonomous agent for a domain where those questions are not academic, and getting the unhappy path right turned out to be most of the work. Here is what I learned.&lt;/p&gt;

&lt;p&gt;The project is &lt;a href="https://github.com/gbadedata/bioagent" rel="noopener noreferrer"&gt;github.com/gbadedata/bioagent&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;BioAgent is an autonomous quality-control analyst for a genomics pipeline. You give it a sample ID and it does the rest on its own: it pulls concordance and reproducibility metrics from a live pipeline API through a set of tools, works out what the numbers mean against benchmark thresholds, builds a targeted PubMed query from the actual findings, searches the literature, and writes a structured, clinical-grade quality report. It streams the whole thing into a Streamlit chat as it reasons, and exposes a FastAPI endpoint a scheduler can call.&lt;/p&gt;

&lt;p&gt;It is built with LangGraph and Claude. Why LangGraph, and not a plain "here is a list of tools" agent, is the whole point of this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a graph, and why bounded
&lt;/h2&gt;

&lt;p&gt;A plain agent takes a question, maybe calls some tools, and answers. BioAgent has to make decisions in sequence: fetch data, then decide from what came back whether the literature is even worth searching; if the search is empty, broaden it and retry; if the pipeline is unreachable, stop and say so clearly. That is a state machine with cycles and conditional routing, which is exactly what LangGraph models.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;%%{init: {'theme':'base','themeVariables':{'primaryColor':'#eef2f7','primaryBorderColor':'#1b2a4a','primaryTextColor':'#1b2a4a','lineColor':'#4c78a8','fontFamily':'Segoe UI, sans-serif'}}}%%
flowchart TD
    START([sample_id]) --&amp;gt; FETCH["fetch_data&amp;lt;br/&amp;gt;call 5 pipeline API tools"]
    FETCH --&amp;gt;|data collected| ANALYSE["analyse&amp;lt;br/&amp;gt;LLM builds a targeted PubMed query"]
    FETCH --&amp;gt;|"critical tools failed,&amp;lt;br/&amp;gt;retry budget remains"| FETCH
    FETCH --&amp;gt;|"critical tools failed,&amp;lt;br/&amp;gt;retries spent"| DEGRADE["graceful_degradation&amp;lt;br/&amp;gt;report what failed, invent nothing"]
    ANALYSE --&amp;gt; SEARCH["search_literature&amp;lt;br/&amp;gt;query PubMed, broaden and retry if empty"]
    SEARCH --&amp;gt; REPORT["synthesise_report&amp;lt;br/&amp;gt;LLM writes the QC report"]
    REPORT --&amp;gt; DONE([END])
    DEGRADE --&amp;gt; DONE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The property that matters most is that the graph is &lt;strong&gt;bounded&lt;/strong&gt;. Every cycle has a hard retry limit; the agent physically cannot loop forever. When your agent calls paid APIs on every step, "cannot loop forever" is not a nice-to-have, it is a safety requirement.&lt;/p&gt;

&lt;p&gt;And here is a single run, end to end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;%%{init: {'theme':'base','themeVariables':{'primaryColor':'#eef2f7','actorBkg':'#eef2f7','actorBorder':'#1b2a4a','actorTextColor':'#1b2a4a','signalColor':'#4c78a8','signalTextColor':'#1b2a4a','noteBkgColor':'#f4f7fb','noteBorderColor':'#4c78a8'}}}%%
sequenceDiagram
    participant U as User / API
    participant G as LangGraph
    participant P as Pipeline API
    participant C as Claude
    participant L as PubMed
    U-&amp;gt;&amp;gt;G: analyse(sample_id)
    G-&amp;gt;&amp;gt;P: runs, concordance, reproducibility, alerts
    P--&amp;gt;&amp;gt;G: metrics (or structured errors)
    alt critical data still missing after a retry
        G--&amp;gt;&amp;gt;U: graceful-degradation report (no invented data)
    else data collected
        G-&amp;gt;&amp;gt;C: build a PubMed query from the metric values
        C--&amp;gt;&amp;gt;G: query
        G-&amp;gt;&amp;gt;L: search, broaden and retry if empty
        L--&amp;gt;&amp;gt;G: citations and abstracts
        G-&amp;gt;&amp;gt;C: synthesise the QC report from data and abstracts
        C--&amp;gt;&amp;gt;G: structured report
        G--&amp;gt;&amp;gt;U: report, citations, and tool trace
    end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Lesson 1: bounding a loop is easy to get subtly wrong
&lt;/h2&gt;

&lt;p&gt;Here is the routing after the data-fetch step:&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;route_after_fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;critical&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;get_concordance_summary&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;get_pipeline_runs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;critical_failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;critical&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;intersection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failed_tools&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;critical_failed&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fetch_retries&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;gt;&lt;/span&gt; &lt;span class="n"&gt;MAX_FETCH_RETRIES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;graceful_degradation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;critical_failed&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fetch_retries&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;MAX_FETCH_RETRIES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fetch_data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;          &lt;span class="c1"&gt;# retry, bounded
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;analyse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The idea is simple: if the critical tools failed and there is retry budget left, try again; if the budget is spent, give up gracefully; otherwise carry on.&lt;/p&gt;

&lt;p&gt;The subtlety is that a bound is only a bound if the counter actually moves. If the node doing the work forgets to increment the retry count, the router keeps seeing "budget remains" forever, and the graceful exit is never reached. The agent loops until the framework's recursion limit trips and throws, which is the exact opposite of failing safely. The fix is a single line in the fetch node:&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;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fetch_retries&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fetch_retries&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&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="c1"&gt;# the bound only works if this moves
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the kind of bug that never appears while you are building, because you are always testing the happy path where the API is up. It only shows up when the dependency breaks. So the real fix is not the one-line increment, it is a test that runs the agent with the API forced down and asserts it &lt;strong&gt;degrades rather than loops&lt;/strong&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_full_run_degrades_when_api_down&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mock_pipeline_api_down&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HG001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;degraded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the full suite, including that degrade-not-loop test, running green in CI:&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%2F6l3npzzeq4qsg8vpim78.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%2F6l3npzzeq4qsg8vpim78.png" alt="The BioAgent test suite passing in CI" width="799" height="318"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Test the unhappy path, or you have not tested the part that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 2: an agent with no data must not write a report
&lt;/h2&gt;

&lt;p&gt;The most dangerous failure for this kind of system is not a crash. It is a confident, clinical-looking report generated from nothing. So when the critical tools cannot be reached, the graph routes to a dedicated node that reports exactly which tools failed, says what could not be retrieved, tells you how to start the API, and stops. It never fills the gap with plausible numbers.&lt;/p&gt;

&lt;p&gt;That is not just a prompt instruction, it is enforced by a test that asserts the degraded report contains no fabricated metrics:&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;test_report_does_not_hallucinate_metrics&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;graceful_degradation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state_with_failed_tools&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.99&lt;/span&gt;&lt;span class="sh"&gt;"&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;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;report&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;F1&lt;/span&gt;&lt;span class="sh"&gt;"&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;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;report&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;A rule the model is asked to follow is a hope. A rule a test enforces is a guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 3: ground the model in what it actually retrieved
&lt;/h2&gt;

&lt;p&gt;The agent cites PubMed papers in its report, which is a quiet invitation to invent relevance. Early on it fetched abstracts and then discarded them, passing only the PMIDs downstream, so the model was asked to explain how papers supported the findings without ever seeing what those papers said. That is exactly the kind of shortcut that produces confident nonsense.&lt;/p&gt;

&lt;p&gt;The fix was to carry the retrieved abstract text through to the report step and tell the model to ground its literature section only in the abstracts provided, and to say so plainly if none were retrieved. Retrieval is only grounding if the retrieved text actually reaches the place that writes the words.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bound every loop, and prove it.&lt;/strong&gt; In an autonomous agent, an unbounded retry is a runaway bill. A bound only counts if the counter advances, so test that it does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the unhappy path.&lt;/strong&gt; The happy path is the part that was always going to work. Force the dependency down and assert the agent fails safely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No data, no report.&lt;/strong&gt; Make "do not invent" a tested guarantee, not a polite request in a prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grounding means the retrieved text reaches the writer.&lt;/strong&gt; Fetching abstracts and then ignoring them is worse than not fetching at all.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The code, the full LangGraph state machine, the tests, and the architecture diagrams are all in the repo: &lt;a href="https://github.com/gbadedata/bioagent" rel="noopener noreferrer"&gt;github.com/gbadedata/bioagent&lt;/a&gt;. If you want the MCP-server-plus-tool-using-agent take on the same ideas, I wrote that up separately at &lt;a href="https://github.com/gbadedata/mcp-research-agent" rel="noopener noreferrer"&gt;github.com/gbadedata/mcp-research-agent&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>python</category>
      <category>llm</category>
    </item>
    <item>
      <title>I built a transformer from scratch to classify airline complaints. A TF-IDF baseline beat it.</title>
      <dc:creator>Oluwagbade Odimayo</dc:creator>
      <pubDate>Thu, 02 Jul 2026 18:30:50 +0000</pubDate>
      <link>https://dev.to/gbadedata/i-built-a-transformer-from-scratch-to-classify-airline-complaints-a-tf-idf-baseline-beat-it-1dj9</link>
      <guid>https://dev.to/gbadedata/i-built-a-transformer-from-scratch-to-classify-airline-complaints-a-tf-idf-baseline-beat-it-1dj9</guid>
      <description>&lt;p&gt;&lt;em&gt;Customer feedback is one of the great unglamorous NLP problems. Millions of short, messy, opinionated messages, and someone has to turn them into "what is going wrong, and how often." I wanted to build the core of that pipeline properly, so I took about 14,600 real tweets about US airlines and set two tasks: classify each tweet's sentiment, and, for the complaints, classify the reason (late flight, lost luggage, customer service, and so on).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I gave myself one rule: build the transformer from scratch in PyTorch, and do not trust it until it has beaten a stupid-simple baseline.&lt;/p&gt;

&lt;p&gt;It didn't beat the baseline. That turned out to be the most useful result in the project, and the rest of this post is about why.&lt;/p&gt;

&lt;p&gt;The full code is here: &lt;a href="https://github.com/gbadedata/airline-feedback-transformer" rel="noopener noreferrer"&gt;github.com/gbadedata/airline-feedback-transformer&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data and the two tasks
&lt;/h2&gt;

&lt;p&gt;The dataset is the Twitter US Airline Sentiment set: real customer feedback about six airlines from February 2015, labelled for sentiment and, for the negative tweets, a complaint reason.&lt;/p&gt;

&lt;p&gt;It is imbalanced the way genuine complaints are: 9,178 negative, 3,099 neutral, 2,363 positive. I kept that imbalance rather than resampling it away, because production feedback is imbalanced too, and hiding it just moves the problem.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Task 1, sentiment:&lt;/strong&gt; three classes over all tweets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Task 2, reason:&lt;/strong&gt; the complaint reason over negative tweets that have an identifiable one. I dropped the "Can't Tell" bucket, since by definition it has no aspect to learn, which left nine classes over 7,988 tweets. This is the extraction-flavoured task.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both use a stratified 70/15/15 split.&lt;/p&gt;

&lt;h2&gt;
  
  
  The simple baseline I had to beat
&lt;/h2&gt;

&lt;p&gt;Before any deep learning, the thing to beat: TF-IDF features into a class-weighted logistic regression. This is a genuinely strong text classifier and it fits in a few lines.&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.feature_extraction.text&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TfidfVectorizer&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="n"&gt;vec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TfidfVectorizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ngram_range&lt;/span&gt;&lt;span class="o"&gt;=&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;min_df&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sublinear_tf&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_features&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;40000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;clf&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="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="n"&gt;C&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;4.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;class_weight&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;balanced&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;clf&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;vec&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;train_texts&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus a majority-class floor, so I always know what "predicting nothing" scores.&lt;/p&gt;

&lt;h2&gt;
  
  
  The transformer, built from scratch
&lt;/h2&gt;

&lt;p&gt;I could have called &lt;code&gt;AutoModel.from_pretrained(...)&lt;/code&gt;, but building the encoder by hand is a much better way to show the architecture is understood rather than imported: token and learned positional embeddings, multi-head self-attention with a correct padding mask, pre-norm residual blocks with a GELU feed-forward, masked mean pooling, and a small classification head.&lt;/p&gt;

&lt;p&gt;The part that quietly matters most is the padding mask. Tweets are short, so batches are mostly padding, and if the model attends to pad tokens it learns from noise. Padded keys have to be removed before the softmax:&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;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&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="nf"&gt;transpose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&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="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;d_head&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# (B, heads, T, T)
&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;masked_fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pad_mask&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:],&lt;/span&gt; &lt;span class="n"&gt;NEG_INF&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;attn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;softmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dim&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the same mask has to exclude padding from the pooled representation:&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;keep&lt;/span&gt; &lt;span class="o"&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;pad_mask&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;unsqueeze&lt;/span&gt;&lt;span class="p"&gt;(&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="c1"&gt;# (B, T, 1)
&lt;/span&gt;&lt;span class="n"&gt;pooled&lt;/span&gt; &lt;span class="o"&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;keep&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;keep&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# (B, d_model)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I unit-tested both. One test adds extra padding to an input and asserts the prediction does not change, which is exactly the property a correct mask guarantees. The default model is deliberately compact (d_model 128, two layers, four heads, about 0.97M parameters) so it trains on a CPU in minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Training it
&lt;/h2&gt;

&lt;p&gt;A standard, explicit PyTorch loop: class-weighted cross-entropy for the imbalance, AdamW, linear warmup and decay, gradient clipping, and early stopping on validation macro-F1 with the best checkpoint restored.&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;loss_fn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CrossEntropyLoss&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;class_weights&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# counter the imbalance
&lt;/span&gt;&lt;span class="n"&gt;opt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;optim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AdamW&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;parameters&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;lr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;3e-4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weight_decay&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# ... warmup+decay schedule, clip_grad_norm_, early stop on val macro-F1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  One evaluation framework for everyone
&lt;/h2&gt;

&lt;p&gt;Every model (majority, TF-IDF, transformer, and an optional zero-shot LLM) is scored by the same code: accuracy, macro-F1, weighted-F1, a full per-class precision/recall/F1 table, and a confusion matrix. Define the test set once, define the metrics once, run every model through them. That is the piece that makes the comparison trustworthy, and it is the piece that tells you where a model fails rather than just how often.&lt;/p&gt;

&lt;h2&gt;
  
  
  The results
&lt;/h2&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%2F4u69uu6thgrafoulw4ku.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%2F4u69uu6thgrafoulw4ku.png" alt="Transformer vs baselines on both tasks" width="799" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Accuracy&lt;/th&gt;
&lt;th&gt;Macro-F1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sentiment&lt;/td&gt;
&lt;td&gt;Majority&lt;/td&gt;
&lt;td&gt;0.627&lt;/td&gt;
&lt;td&gt;0.257&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;TF-IDF + LogReg&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.790&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.735&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;Transformer (scratch)&lt;/td&gt;
&lt;td&gt;0.769&lt;/td&gt;
&lt;td&gt;0.729&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reason&lt;/td&gt;
&lt;td&gt;Majority&lt;/td&gt;
&lt;td&gt;0.364&lt;/td&gt;
&lt;td&gt;0.059&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;TF-IDF + LogReg&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.648&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.501&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;Transformer (scratch)&lt;/td&gt;
&lt;td&gt;0.612&lt;/td&gt;
&lt;td&gt;0.495&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On both tasks the transformer lands within a single macro-F1 point of the baseline and does not pass it. It is not broken: it learns cleanly and then early-stops.&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%2Fgiprndrk27niydxhswma.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%2Fgiprndrk27niydxhswma.png" alt="Validation macro-F1 per epoch" width="800" height="516"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it wins and where it loses
&lt;/h2&gt;

&lt;p&gt;The per-class view is where the evaluation framework earns its place. On the reason task the model is good at the frequent, distinctive complaints and poor at the rare ones.&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%2F8534lkl7h56lr0udsl8m.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%2F8534lkl7h56lr0udsl8m.png" alt="Per-class F1 on the reason task" width="800" height="375"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Late Flight (0.72), Customer Service Issue (0.68), Lost Luggage (0.68) and Cancelled Flight (0.67) come out well. Damaged Luggage (0.17, with eleven test examples) and longlines (0.27) do not. The confusion matrix shows the errors are sensible: rare and adjacent complaints get pulled toward the big "Customer Service Issue" and "Late Flight" classes.&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%2Fp370zfxeh699v60ktd8z.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%2Fp370zfxeh699v60ktd8z.png" alt="Complaint-reason confusion matrix" width="800" height="716"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is an actionable readout. It tells you which classes need more data or a different approach, which is far more useful than a single headline accuracy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the baseline won, and why that matters
&lt;/h2&gt;

&lt;p&gt;Transformers earn their advantage from pretraining on enormous corpora. Mine was trained from random initialisation on about 10,000 short tweets, which is nowhere near enough for the architecture to express its strengths. The bottleneck was never the model's capacity. It was data and pretraining.&lt;/p&gt;

&lt;p&gt;So at this scale the correct engineering decision is the simpler, faster, more interpretable TF-IDF model, and the value of the whole exercise is being able to say that with numbers instead of assuming the neural model must be better. That is the judgment that separates "I can train a transformer" from "I know when a transformer is worth it."&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do in production
&lt;/h2&gt;

&lt;p&gt;Nothing about the finding says transformers are the wrong tool. It says a from-scratch one is. The pipeline is built for the obvious next step: swap the scratch encoder for a pretrained one and fine-tune it. The pooling, the head, the evaluation framework and the training loop do not change, and that is where the neural approach starts to pull ahead. Parameter-efficient fine-tuning (LoRA) slots in at the same point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Always build the baseline first.&lt;/strong&gt; A class-weighted TF-IDF logistic regression is a high bar, and if your model cannot clear it, that is information, not failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the architecture by hand once.&lt;/strong&gt; Implementing attention, masking and pooling yourself teaches you where the bodies are buried, and the padding mask is one of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure per class, not just overall.&lt;/strong&gt; The headline number hides which complaint types you actually handle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report the result that does not flatter you.&lt;/strong&gt; "The simple model won" is often the most valuable sentence in a project.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code, tests and figures: &lt;a href="https://github.com/gbadedata/airline-feedback-transformer" rel="noopener noreferrer"&gt;github.com/gbadedata/airline-feedback-transformer&lt;/a&gt;. If you want the retrieval and RAG side of this kind of work, I wrote up a biomedical question-answering system separately at &lt;a href="https://github.com/gbadedata/biomedical-rag-qa" rel="noopener noreferrer"&gt;github.com/gbadedata/biomedical-rag-qa&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>nlp</category>
      <category>pytorch</category>
    </item>
    <item>
      <title>I Built a Biomedical RAG System, and a 40-Year-Old Algorithm Beat My Vector Database</title>
      <dc:creator>Oluwagbade Odimayo</dc:creator>
      <pubDate>Thu, 02 Jul 2026 11:45:53 +0000</pubDate>
      <link>https://dev.to/gbadedata/i-built-a-biomedical-rag-system-and-a-40-year-old-algorithm-beat-my-vector-database-2j3b</link>
      <guid>https://dev.to/gbadedata/i-built-a-biomedical-rag-system-and-a-40-year-old-algorithm-beat-my-vector-database-2j3b</guid>
      <description>&lt;p&gt;&lt;em&gt;A hands-on walkthrough of a retrieval-augmented QA pipeline over PubMed abstracts, the evaluation that kept me grounded, and why BM25 out-retrieved a FAISS vector index.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Everyone reaches for a vector database the moment they hear "RAG". I did too. Then I measured it against a lexical baseline from the 1980s, and the baseline won on every metric.&lt;/p&gt;

&lt;p&gt;This post walks through a small retrieval-augmented question-answering system I built over biomedical literature, the evaluation that produced that result, and the two lessons that mattered more than any model choice. The full code is on GitHub: &lt;a href="https://github.com/gbadedata/biomedical-rag-qa" rel="noopener noreferrer"&gt;gbadedata/biomedical-rag-qa&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we are building
&lt;/h2&gt;

&lt;p&gt;The task: given a clinical or biological question, retrieve the passages that bear on it and produce a grounded yes / no / maybe answer, with citations, rather than letting a language model answer from memory.&lt;/p&gt;

&lt;p&gt;The pipeline is five small modules, each usable and testable on its own.&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%2F2whlmx39a2qedw4ia05r.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%2F2whlmx39a2qedw4ia05r.png" alt="The biomedqa pipeline: ingest, corpus, retrieve, generate, evaluate" width="800" height="244"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Ingest from public APIs, build a passage corpus, retrieve, generate a grounded answer, and evaluate every stage against a baseline.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I will focus on the two stages that produced the interesting results: retrieval and answering.&lt;/p&gt;
&lt;h2&gt;
  
  
  The data
&lt;/h2&gt;

&lt;p&gt;I used &lt;a href="https://github.com/pubmedqa/pubmedqa" rel="noopener noreferrer"&gt;PubMedQA&lt;/a&gt; (Jin et al., 2019): 1,000 expert-labelled biomedical questions, each paired with the abstract it was written from, already split into labelled sections, plus a yes / no / maybe decision.&lt;/p&gt;

&lt;p&gt;To turn this into a retrieval benchmark, I treat each abstract section as a passage (3,358 in total, about 60 tokens each) and define a question's gold passages as the sections from its own abstract. A retriever's job is then to rank a question's gold passages against the whole pool. Clean, reproducible, and it lets several retrievers compete on identical ground.&lt;/p&gt;
&lt;h2&gt;
  
  
  Retrieval: three approaches, one interface
&lt;/h2&gt;

&lt;p&gt;The key design choice is that the retriever is a swappable component, not a hard-wired call. Everything sits behind one interface, so I can benchmark a lexical method, a dense vector method, and a random floor without touching the rest of the pipeline.&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;class&lt;/span&gt; &lt;span class="nc"&gt;DenseRetriever&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;TF-IDF -&amp;gt; truncated SVD (LSA) -&amp;gt; L2-normalise -&amp;gt; FAISS inner-product index.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dense_lsa_faiss&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;passages&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&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;passage_id&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;passages&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;mat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_embed&lt;/span&gt;&lt;span class="p"&gt;([&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;text&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;passages&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;fit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# (n, d) float32
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;faiss&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IndexFlatIP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&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="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mat&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;self&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&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;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_embed&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;fit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_ids&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="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&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;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&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;idx&lt;/span&gt;&lt;span class="p"&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;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;!=&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dense retriever builds TF-IDF vectors, compresses them to 256 dimensions with truncated SVD (latent semantic analysis), normalises, and indexes them in FAISS. The lexical retriever is plain BM25. And there is a random retriever, because you always want to know the floor.&lt;/p&gt;

&lt;p&gt;Here is what happened.&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%2Fhkbodr7dos7e6hmhmmzo.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%2Fhkbodr7dos7e6hmhmmzo.png" alt="Retrieval on PubMedQA: BM25 beats the dense vector index on every metric" width="800" height="464"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;BM25 places a gold passage first for 94.3% of questions (MRR 0.959) and leads the dense LSA + FAISS index across the board. Random is the floor.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;BM25 wins everywhere. Its MRR of 0.959 means the first relevant passage is almost always ranked first or second.&lt;/p&gt;

&lt;p&gt;Why did the "simple" method win? Because PubMedQA questions are written from their source abstracts, so they share a lot of vocabulary with the passages that answer them, and lexical overlap is a very strong signal here. Compressing that into 256 LSA dimensions trades away precision the benchmark actually rewards.&lt;/p&gt;

&lt;p&gt;There is a subtlety in recall@1 worth knowing. Each question has about 3.4 gold passages, so you can only ever retrieve one of them at rank 1. That caps mean recall@1 at 0.319. BM25 scores 0.300, which is 94% of the mathematical ceiling. Its top rank is almost always correct.&lt;/p&gt;

&lt;p&gt;The lesson is not "BM25 is better than vectors". It is &lt;strong&gt;measure it&lt;/strong&gt;. On a different corpus, with a biomedical transformer embedder instead of LSA, the result could flip. But you only know by benchmarking against a baseline, and the swap is a one-line change to &lt;code&gt;DenseRetriever._embed&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Does retrieval actually help the answer?
&lt;/h2&gt;

&lt;p&gt;Good retrieval is worth nothing if it does not improve the answer. So I ran a diagnostic: train a simple decision classifier on four feature sets and compare them against a majority-class baseline.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Question only (no retrieval)&lt;/li&gt;
&lt;li&gt;Retrieved context (BM25 top-3)&lt;/li&gt;
&lt;li&gt;Gold context (perfect retrieval, the ceiling)&lt;/li&gt;
&lt;li&gt;The majority baseline itself&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%2F7k5sjail3xi3s1z1k6ln.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%2F7k5sjail3xi3s1z1k6ln.png" alt="Answering yes/no/maybe: retrieval lifts F1 but no condition beats the baseline accuracy" width="800" height="422"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A linear reader lifts macro-F1 well above the baseline by learning the minority classes, but nothing beats the baseline's accuracy of 0.553.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is where it got uncomfortable, and interesting. The classifier lifts macro-F1 from the baseline's 0.237 to about 0.41, but no condition beats the baseline's accuracy, and feeding it retrieved passages by naive concatenation actually hurt it. Break it down by class and the reason is clear:&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%2Fa9zmcj7zzjwih35z2vci.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%2Fa9zmcj7zzjwih35z2vci.png" alt="The reader collapses on the ambiguous " width="800" height="511"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Even with perfect context, a bag-of-words reader manages F1 0.67 on "yes" but only 0.20 on the ambiguous "maybe" class.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;PubMedQA is deliberately built to require reasoning over evidence, and a bag-of-words linear model cannot reason. It handles the easy majority class and falls apart on the ambiguous one.&lt;/p&gt;

&lt;p&gt;This is the second, bigger lesson: &lt;strong&gt;retrieval quality is necessary but not sufficient&lt;/strong&gt;. The value of RAG shows up only with a reader capable of reasoning over the retrieved evidence. Which is exactly why the answer step in the pipeline is an LLM, not a classifier.&lt;/p&gt;
&lt;h2&gt;
  
  
  Grounded generation
&lt;/h2&gt;

&lt;p&gt;The generation step is thin on purpose, and strict about grounding. The model must answer only from the numbered passages, cite them, and return machine-checkable JSON.&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;SYSTEM_PROMPT&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;You are a careful biomedical research assistant. Answer the question using ONLY &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;the numbered passages provided. Decide yes, no, or maybe. Use &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;maybe&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; when the &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;passages are mixed or insufficient. Do not use outside knowledge. Reply as strict &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;JSON with keys &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decision&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;justification&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, and &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;supporting_passages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&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;That contract is what makes the output auditable: you can check that the cited passages exist, and later that they actually support the justification. It also stops the model quietly answering from training memory, which is the whole point of RAG in a domain where a wrong answer matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Engineering choices that paid off
&lt;/h2&gt;

&lt;p&gt;A few decisions that are easy to skip and worth keeping:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Baselines everywhere.&lt;/strong&gt; A random-retrieval floor and a majority-class floor. The random floor is how you catch a silent indexing bug; the majority floor is how you avoid celebrating a model that only learned the class balance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One retriever interface.&lt;/strong&gt; Swapping BM25 for a dense model, or LSA for transformer embeddings, is a local change. The index and search loop do not move.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two API styles behind one schema.&lt;/strong&gt; Ingestion pulls from Europe PMC (REST, cursor pagination) and ClinicalTrials.gov v2 (REST, token pagination), plus Open Targets (GraphQL), all normalised to one passage schema with retry and backoff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metrics that do not need the LLM.&lt;/strong&gt; The reported numbers are retrieval metrics and a linear diagnostic, so anyone can reproduce them without an API key. The generation step is real and runnable, but I did not claim an accuracy number I could not reproduce cheaply.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/gbadedata/biomedical-rag-qa
&lt;span class="nb"&gt;cd &lt;/span&gt;biomedical-rag-qa
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
python scripts/fetch_data.py
python &lt;span class="nt"&gt;-m&lt;/span&gt; biomedqa.cli &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="nt"&gt;--data&lt;/span&gt; data/ori_pqal.json     &lt;span class="c"&gt;# reproduces the numbers above&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The evaluation is deterministic, tests run in CI across Python 3.10 to 3.12, and the whole thing is MIT licensed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;Two things I will carry into the next RAG project. First, benchmark retrieval against a lexical baseline before you assume a vector database is buying you anything, because sometimes it is not. Second, retrieval and generation are separate problems: strong retrieval with a weak reader still fails, so measure them independently and put the reasoning where it belongs.&lt;/p&gt;

&lt;p&gt;Code, tests and full results: &lt;a href="https://github.com/gbadedata/biomedical-rag-qa" rel="noopener noreferrer"&gt;github.com/gbadedata/biomedical-rag-qa&lt;/a&gt;. Questions and critique welcome.&lt;/p&gt;

&lt;p&gt;If you want the classical-ML counterpart, I ran a similar teardown on 215,000 patient drug reviews, sentiment classification plus complaint mining, with the same emphasis on baselines and reporting the results that do not help: github.com/gbadedata/drug-review-nlp.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>ai</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Credit risk is more than predicting default: building the full stack in Python (IFRS 9 ECL, scorecards, monitoring)</title>
      <dc:creator>Oluwagbade Odimayo</dc:creator>
      <pubDate>Tue, 30 Jun 2026 16:32:43 +0000</pubDate>
      <link>https://dev.to/gbadedata/credit-risk-is-more-than-predicting-default-building-the-full-stack-in-python-ifrs-9-ecl-511a</link>
      <guid>https://dev.to/gbadedata/credit-risk-is-more-than-predicting-default-building-the-full-stack-in-python-ifrs-9-ecl-511a</guid>
      <description>&lt;p&gt;&lt;em&gt;Most credit-risk tutorials stop at "train a classifier to predict default." That is maybe a fifth of what a real credit-risk function does, and not the interesting fifth. So I built the rest, on 1.35 million real loans, as three connected projects:&lt;/em&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;an &lt;strong&gt;IFRS 9 expected credit loss engine&lt;/strong&gt; (PD, LGD, EAD, staging, macro scenarios),&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;Weight-of-Evidence scorecard plus an independent model validation&lt;/strong&gt;, and&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;portfolio monitoring and management-information pack&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Stack is deliberately boring and reproducible: &lt;code&gt;pandas&lt;/code&gt;, &lt;code&gt;scikit-learn&lt;/code&gt;, &lt;code&gt;matplotlib&lt;/code&gt;. Data is the public Lending Club accepted-loans tape. This post walks the techniques and the decisions, with the code that matters and the gotchas that bit me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data and the splits
&lt;/h2&gt;

&lt;p&gt;The tape is one file of ~2.26M loans. The trick is that it is really three populations, and you need different slices for different jobs:&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;CHARGED&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;Charged Off&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;Default&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;Does not meet the credit policy. Status:Charged Off&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;PAID&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;Fully Paid&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;Does not meet the credit policy. Status:Fully Paid&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;ACTIVE&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;Current&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;In Grace Period&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;Late (16-30 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;Late (31-120 days)&lt;/span&gt;&lt;span class="sh"&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;completed&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;loan_status&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;isin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CHARGED&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;PAID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# for PD training
&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;default&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;loan_status&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;isin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CHARGED&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="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;active&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;loan_status&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;isin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ACTIVE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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;out_prncp&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;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# the live book
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two habits that run through everything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Out-of-time validation, not random split.&lt;/strong&gt; Train on older vintages, test on newer ones, because that is the only test that tells you how the model behaves on loans it has not seen.
&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;dev&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;issue_year&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="mi"&gt;2015&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# build here
&lt;/span&gt;&lt;span class="n"&gt;oot&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;issue_year&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;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2016&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# judge here
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No leakage.&lt;/strong&gt; I drop the platform's own grade and interest rate from every model, so it earns its signal from borrower attributes rather than copying someone else's pricing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one engineering note up front: the raw file is ~390MB gzipped, so read it in chunks with &lt;code&gt;usecols&lt;/code&gt; to keep memory sane:&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;for&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="ow"&gt;in&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="n"&gt;RAW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;usecols&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;COLS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunksize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;low_memory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Part 1: the IFRS 9 ECL engine
&lt;/h2&gt;

&lt;p&gt;The whole thing reduces to one line, &lt;code&gt;ECL = PD x LGD x EAD&lt;/code&gt;, discounted and summed, but each term is its own small project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PD.&lt;/strong&gt; A logistic model, validated out of time. The model gives a lifetime PD; for the 12-month figure that Stage 1 needs, convert it under a constant-hazard assumption over the remaining term:&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;pd_12m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;pd_life&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;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minimum&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="n"&gt;rem_months&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;rem_months&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;LGD, measured not assumed.&lt;/strong&gt; This is the bit most tutorials skip. For each charged-off loan, exposure at default is the principal still outstanding when it defaulted, and the recovery is the post-default cash, net of fees:&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;ead_at_default&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;funded_amnt&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;total_rec_prncp&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lower&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;lgd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;recoveries&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;ead_at_default&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&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="c1"&gt;# result on this book: mean LGD = 0.91  (a 9% recovery rate)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That 0.91 is high because the loans are unsecured. On a secured (auto) book it would be much lower and more dispersed, and the whole allowance would shrink.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Staging and ECL.&lt;/strong&gt; Stage 3 is impaired (31+ days past due), Stage 2 is significant-increase (arrears backstops plus a PD-based trigger), Stage 1 is the rest. Then:&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;ecl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stage&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;ead&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;pd_12m&lt;/span&gt;  &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;lgd&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;eir&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stage&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ead&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;pd_life&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;lgd&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;eir&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;rem_yrs&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                           &lt;span class="n"&gt;ead&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minimum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lgd&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="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;eir&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;rem_yrs&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;  &lt;span class="c1"&gt;# stage 3, PD=1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result on the live book ($9.5bn EAD): &lt;strong&gt;ECL $1.25bn, coverage 13.1%&lt;/strong&gt;, with coverage rising &lt;strong&gt;6.6% -&amp;gt; 31% -&amp;gt; 77%&lt;/strong&gt; across the three stages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gotcha worth knowing.&lt;/strong&gt; The most consequential input is not a parameter, it is the Stage 2 trigger, because it swaps a 12-month provision for a lifetime one. I swept it:&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;for&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;linspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;thr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;act&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pd_life&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="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;stage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(...,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;act&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pd_life&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;gt;&lt;/span&gt; &lt;span class="n"&gt;thr&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
    &lt;span class="c1"&gt;# total ECL ranges ~$1.10bn -&amp;gt; $1.37bn as you flag 5% -&amp;gt; 25% of the book as Stage 2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A third of a billion dollars hangs on one threshold. Worth knowing before you trust the headline number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: a WoE scorecard, then break it
&lt;/h2&gt;

&lt;p&gt;Scorecards in banking are not gradient-boosted black boxes; they are Weight-of-Evidence logistic models scaled to points, because they have to be explainable. WoE and Information Value:&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;woe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;dist_good&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;eps&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;dist_bad&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;eps&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;iv&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;dist_good&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;dist_bad&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;woe&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;# select features by IV, e.g. &amp;gt;= 0.02
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fit a logistic on the WoE values, then scale to points (the classic PDO formulation, 20 points to double the odds, anchored at 600 for 50:1):&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;factor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PDO&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&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="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BASE&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;factor&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BASE_ODDS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;points&lt;/span&gt; &lt;span class="o"&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;coef&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;woe&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;intercept&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;factor&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;   &lt;span class="c1"&gt;# per characteristic
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the part that matters more than the build: &lt;strong&gt;validation&lt;/strong&gt;. Four tests on the out-of-time sample.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Discrimination&lt;/strong&gt; holds: Gini 0.356 out of time (0.385 in development), KS 0.256.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Calibration is where it nearly slipped.&lt;/strong&gt; Discrimination tells you the ranking is right; it says nothing about whether the predicted probability is right. Check the level:&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;pred_over_observed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oot&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pd&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;oot&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="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# 0.77
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;0.77 means the model under-predicts default by ~23% on recent vintages, on every score band. Fine for ranking, not safe for pricing or ECL until recalibrated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The trap inside the trap: PSI vs calibration.&lt;/strong&gt; Population Stability Index checks whether the &lt;em&gt;applicant mix&lt;/em&gt; shifted:&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;psi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(((&lt;/span&gt;&lt;span class="n"&gt;dev_pct&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;oot_pct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;dev_pct&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;eps&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;oot_pct&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;eps&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;# 0.013, stable
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PSI was a tiny 0.013. A monitor watching only PSI would flash green while the model quietly went biased, because &lt;strong&gt;a stable population does not mean an accurate model&lt;/strong&gt;. Different questions; check both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Effective challenge.&lt;/strong&gt; Benchmark against a &lt;code&gt;HistGradientBoostingClassifier&lt;/code&gt; on raw features: Gini 0.401 vs the scorecard's 0.356. A small lift, not enough to justify losing the transparency, so it becomes a watch item rather than a rebuild.&lt;/p&gt;

&lt;p&gt;Verdict: &lt;strong&gt;approved with conditions&lt;/strong&gt;, written up as a RAG-rated validation report. The deliverable of a second line is not a model, it is a defensible opinion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3: monitoring the live book
&lt;/h2&gt;

&lt;p&gt;A point-in-time book can look healthy while deteriorating, because delinquency lags. The leading view is the &lt;strong&gt;vintage curve&lt;/strong&gt;: group loans by origination year and track cumulative default by months on book.&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;# default timing approximated from last payment date
&lt;/span&gt;&lt;span class="n"&gt;mob_default&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;last_pymnt_month&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;issue_month&lt;/span&gt;          &lt;span class="c1"&gt;# months on book at default
&lt;/span&gt;&lt;span class="n"&gt;cum_default&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;coh_mob&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;m&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="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&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;25&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;   &lt;span class="c1"&gt;# per cohort
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This surfaced the signal the delinquency rate hid: the 12-month-on-book default rate rose from &lt;strong&gt;4.7% (2013) to 6.8% (2016)&lt;/strong&gt;, with the recent cohorts sitting above the older ones at every age.&lt;/p&gt;

&lt;p&gt;Then &lt;strong&gt;scorecard drift&lt;/strong&gt; over time (PSI by vintage vs a baseline, which climbed to 0.13), and &lt;strong&gt;concentration&lt;/strong&gt; via a Herfindahl index:&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;shares&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;exposure&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;addr_state&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="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;exposure&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="n"&gt;hhi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shares&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;2&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;# 0.051 by state (diversified); 58% in one product (watch)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything rolls into a RAG early-warning dashboard. The output is intentionally mixed: current losses green, vintage trend red, model drift and product concentration amber. An all-green dashboard on a quietly worsening book is the failure mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas, collected
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read the big gz in chunks&lt;/strong&gt;; do not load 390MB into a single frame.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exclude the platform's grade/rate&lt;/strong&gt; or your model just relearns someone else's pricing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recent vintages are right-censored&lt;/strong&gt; in the vintage curves; show them, but read them as partial.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Goodness-of-fit stats are useless at 500k rows&lt;/strong&gt; (they reject on noise); use the predicted/observed ratio and a calibration plot instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discrimination is not calibration, and PSI is neither.&lt;/strong&gt; Three different questions, three different checks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LGD of 0.91 is a feature of unsecured lending&lt;/strong&gt;, not a bug; a secured book changes the whole picture.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The repos
&lt;/h2&gt;

&lt;p&gt;Each is standalone, reproducible (&lt;code&gt;pip install -r requirements.txt &amp;amp;&amp;amp; python analysis.py&lt;/code&gt;), and ships an executed notebook plus a written report:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;IFRS 9 ECL engine: &lt;code&gt;github.com/gbadedata/ifrs9-ecl-engine&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Scorecard + independent validation: &lt;code&gt;github.com/gbadedata/pd-scorecard-validation&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Portfolio monitoring + MI pack: &lt;code&gt;github.com/gbadedata/credit-risk-monitoring&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Built on public US consumer data; the methods carry directly to a secured book such as auto finance, where the parameters (recovery above all) would differ. If you build something similar, I would be glad to compare notes in the comments.&lt;/p&gt;

</description>
      <category>python</category>
      <category>datascience</category>
      <category>finance</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Building an Affordability-First Credit Stack: Three ML Projects on Real Lending Data</title>
      <dc:creator>Oluwagbade Odimayo</dc:creator>
      <pubDate>Mon, 29 Jun 2026 22:19:01 +0000</pubDate>
      <link>https://dev.to/gbadedata/building-an-affordability-first-credit-stack-three-ml-projects-on-real-lending-data-597k</link>
      <guid>https://dev.to/gbadedata/building-an-affordability-first-credit-stack-three-ml-projects-on-real-lending-data-597k</guid>
      <description>&lt;h2&gt;
  
  
  &lt;em&gt;The engineering behind three fintech machine-learning projects: behavioural default risk, affordability-based risk, and Open Banking transaction categorisation, with the decisions that actually matter."&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;Most "predict loan default" tutorials make the same three mistakes: they report accuracy on an imbalanced target, they leak future information into the features, and they stop at a probability instead of a decision. This write-up is about avoiding all three, across three projects on real lending data that together build toward an affordability-first view of credit risk.&lt;/p&gt;

&lt;p&gt;A companion narrative piece covers &lt;em&gt;why&lt;/em&gt; this matters. This one is about &lt;em&gt;how&lt;/em&gt;: the data wrangling, the feature engineering, the experiment design, and the production logic. All the code is real, lifted from the repositories linked at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The throughline
&lt;/h2&gt;

&lt;p&gt;Three projects, three datasets, one argument:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Behavioural default risk&lt;/strong&gt; on 30,000 real credit-card customers: the traditional credit-history approach, done with the right metrics and a cost-based decision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Affordability-based risk&lt;/strong&gt; on 1.35 million real Lending Club loans: a controlled test of whether affordability out-predicts a credit score.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transaction categorisation&lt;/strong&gt; on 259,000 real bank transactions: the Open Banking data layer that turns a raw bank feed into the income and spending signals affordability needs.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Lesson 1: under imbalance, accuracy is a trap
&lt;/h2&gt;

&lt;p&gt;Roughly one borrower in five defaults in these datasets. A model that predicts "everyone repays" scores about 80% accuracy and is worthless. So across all three projects the metrics are precision-recall (average precision), the KS statistic, and recall at a chosen operating point, never raw accuracy.&lt;/p&gt;

&lt;p&gt;The precision-recall curve is the meaningful picture because the no-skill baseline is not 0.5, it is the prevalence:&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;average_precision_score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roc_auc_score&lt;/span&gt;
&lt;span class="c1"&gt;# PR-AUC floor is the positive rate (~0.20), not 0.5
&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;PR-AUC &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;average_precision_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;probs&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;  &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;(no-skill = &lt;/span&gt;&lt;span class="si"&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="nf"&gt;mean&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;)&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;For the credit-card model this is the difference between a believable ROC-AUC of 0.78 / PR-AUC 0.56 and a meaningless "97% accurate" headline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 2: a probability is not a decision
&lt;/h2&gt;

&lt;p&gt;A risk score becomes useful only when you decide where to cut. That cut is a business choice, because the two errors cost different amounts: a missed default loses the loan, while a wrongly declined good customer only loses the margin. So instead of defaulting to 0.5, sweep the threshold to minimise expected cost.&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;optimal_threshold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;probs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fn_to_fp_ratio&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;thresholds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;linspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.01&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="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;costs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;fn_to_fp_ratio&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;probs&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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="mi"&gt;1&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;# missed defaults
&lt;/span&gt;                       &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;probs&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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="mi"&gt;0&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;# good customers declined
&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;thresholds&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;thresholds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argmin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;costs&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The threshold moves with the cost ratio, and so does the business outcome: at a 10:1 ratio the credit-card model catches 92% of defaulters; at 2:1 it approves far more and catches half. There is no single correct cutoff without a cost view, and the valuable artefact is this curve, not the raw probability.&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%2F87tueib57je7fzkj4rez.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%2F87tueib57je7fzkj4rez.png" alt="Affordability alone rivals the credit score; together they win" width="800" height="308"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 3: leakage is how credit models cheat
&lt;/h2&gt;

&lt;p&gt;This is the big one, and it is the whole reason the affordability project's numbers are trustworthy. The Lending Club file has 151 columns, and many of them are recorded &lt;em&gt;after&lt;/em&gt; the loan runs: total payments received, recoveries, the latest FICO pull. Train on those and you get a spectacular AUC that collapses in production, because at decision time they do not exist.&lt;/p&gt;

&lt;p&gt;So the rule is strict: keep only what a lender knows at origination. Every post-loan field is dropped, and so are Lending Club's own &lt;code&gt;grade&lt;/code&gt; and &lt;code&gt;int_rate&lt;/code&gt;, because those already encode its internal risk model and would short-circuit the experiment.&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;BAD&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;Charged Off&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;Default&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;Does not meet the credit policy. Status:Charged Off&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;GOOD&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;Fully Paid&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;Does not meet the credit policy. Status:Fully Paid&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 raw file is 1.6 GB, so stream it in chunks and keep only finished loans
&lt;/span&gt;&lt;span class="n"&gt;parts&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;chunk&lt;/span&gt; &lt;span class="ow"&gt;in&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="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;usecols&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ORIGINATION_COLS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                         &lt;span class="n"&gt;compression&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gzip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunksize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300_000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loan_status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BAD&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;GOOD&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&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;done&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loan_status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BAD&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;int8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;parts&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="nf"&gt;engineer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;))&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;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ignore_index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# ~1.35M completed loans
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth noting: &lt;code&gt;usecols&lt;/code&gt; plus &lt;code&gt;chunksize&lt;/code&gt; keeps a 1.6 GB file inside a few hundred MB of RAM, and filtering inside the loop means you never materialise the rows you do not need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 4: engineer affordability properly
&lt;/h2&gt;

&lt;p&gt;Affordability is a ratio of obligations to income, so the features have to express that, and they have to be correct for joint applications (where two people share the liability and one person's income understates capacity).&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;joint&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;application_type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Joint App&lt;/span&gt;&lt;span class="sh"&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;income&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;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;joint&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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;annual_inc_joint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;notna&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;annual_inc_joint&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;annual_inc&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;dti_eff&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;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;joint&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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;dti_joint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;notna&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;dti_joint&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;dti&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;payment_to_income&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="n"&gt;installment&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;12&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;income&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# annual burden
&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;loan_to_income&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="n"&gt;loan_amnt&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;income&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;These three (payment-to-income, debt-to-income, loan-to-income) each produce a clean monotonic default gradient on their own, before any model touches them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 5: design the experiment so the result means something
&lt;/h2&gt;

&lt;p&gt;The headline claim, that affordability out-predicts a credit score, is only credible if the comparison is controlled. So I held the algorithm fixed and changed &lt;em&gt;only&lt;/em&gt; the feature set: credit-and-bureau features, affordability features, then both. Any difference in performance is then attributable to the features, not to model tuning.&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;AFFORD&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;income&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;dti_eff&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;payment_to_income&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;loan_to_income&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;installment&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;loan_amnt&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;term_months&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;emp_length_num&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;CREDIT&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;fico&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;delinq_2yrs&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;inq_last_6mths&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;revol_util&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;open_acc&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;pub_rec&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;total_acc&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;credit_history_yrs&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;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;features&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;XGBClassifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_estimators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_depth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;learning_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                          &lt;span class="n"&gt;scale_pos_weight&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;spw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tree_method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hist&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_jobs&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;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_train&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;features&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;p&lt;/span&gt; &lt;span class="o"&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&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;features&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="k"&gt;return&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;p&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;average_precision_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;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CREDIT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# ROC-AUC 0.617
&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AFFORD&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="c1"&gt;# ROC-AUC 0.699  &amp;lt;- affordability alone wins
&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AFFORD&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;CREDIT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# ROC-AUC 0.706
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tree_method="hist"&lt;/code&gt; is what makes XGBoost comfortable on 1.35 million rows, and &lt;code&gt;scale_pos_weight&lt;/code&gt; set to the negative/positive ratio handles the imbalance without resampling. The result holds up: affordability beats the bureau record, and the two combined beat either alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 6: cleaning real transaction text
&lt;/h2&gt;

&lt;p&gt;The categorisation project is a different kind of engineering. Real bank descriptions are hostile: &lt;code&gt;Earnin  PAYMENT  Donatas Danyal&lt;/code&gt;, transfers buried in authorisation codes, dates and reference numbers everywhere. None of that noise carries category signal, so it gets stripped before vectorising.&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;re&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\d+&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; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;   &lt;span class="c1"&gt;# auth codes, dates, amounts
&lt;/span&gt;    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^a-z\s]&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; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="c1"&gt;# punctuation
&lt;/span&gt;    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\b\w{1,2}\b&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; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# 1-2 char fragments
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\s+&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; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="c1"&gt;# 'Earnin  PAYMENT  Donatas Danyal' -&amp;gt; 'earnin payment donatas danyal'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then TF-IDF over unigrams and bigrams, with transaction amount stitched on as a numeric feature, because payroll and loan amounts are large and coffees are small:&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;scipy.sparse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hstack&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;csr_matrix&lt;/span&gt;
&lt;span class="n"&gt;tfidf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TfidfVectorizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ngram_range&lt;/span&gt;&lt;span class="o"&gt;=&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;min_df&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;max_features&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sublinear_tf&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;X_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tfidf&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;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clean_desc&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="nf"&gt;hstack&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;X_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;csr_matrix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scaled_log_amount&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A class-weighted linear SVM on this reaches 0.80 macro-F1 across 31 categories (baseline 0.01), and crucially it is fully inspectable: reading the top-weighted terms per class shows it learned that &lt;code&gt;mcdonald&lt;/code&gt; means restaurants and &lt;code&gt;uber&lt;/code&gt;/&lt;code&gt;lyft&lt;/code&gt; in incoming payments mean gig income. Macro-F1, not accuracy, again, because the categories are heavily imbalanced.&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%2Frevx3r336s8jegb3d0op.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%2Frevx3r336s8jegb3d0op.png" alt="Where the model confuses categories" width="800" height="674"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The confusions are reassuring rather than alarming: the various transfer types blur into each other because their text genuinely overlaps, which is label ambiguity, not model failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 7: turn confidence into an operating policy
&lt;/h2&gt;

&lt;p&gt;A model that labels everything is a research artefact. A model that knows when to defer is a system. For the linear SVM, the gap between the top two class scores is a usable confidence signal:&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;margins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;clf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decision_function&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;# (n_samples, n_classes)
&lt;/span&gt;&lt;span class="n"&gt;top2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;partition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;margins&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;axis&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;confidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;top2&lt;/span&gt;&lt;span class="p"&gt;[:,&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="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;top2&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;           &lt;span class="c1"&gt;# best minus runner-up
&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argsort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                  &lt;span class="c1"&gt;# most confident first
&lt;/span&gt;&lt;span class="n"&gt;correct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;preds&lt;/span&gt; &lt;span class="o"&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;order&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="c1"&gt;# cumulative accuracy as coverage grows -&amp;gt; the auto-classify / review trade-off
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Auto-classifying the most-confident 80% holds 98% accuracy, sending only a fifth to human review. That single curve is what turns the categoriser into something a lending operation could actually deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 8: explainability is non-negotiable
&lt;/h2&gt;

&lt;p&gt;Regulated lending has to justify decisions, so every model here is explained with SHAP, at both the portfolio level and the individual decision. The single-applicant view is the one a compliance team cares about:&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;shap&lt;/span&gt;
&lt;span class="n"&gt;explainer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TreeExplainer&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="n"&gt;shap_values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;explainer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shap_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X_sample&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# waterfall for one applicant -&amp;gt; exactly what an adverse-action notice needs
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the credit-card model, this produces a legible story for a single 97%-risk applicant: behind in every month, two months behind most recently, repaying almost nothing. Not a black box, a defensible decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;The engineering themes repeat across all three projects, and they are the transferable part:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Judge models on the metric the problem demands&lt;/strong&gt; (PR-AUC and KS under imbalance, macro-F1 across many classes), never on accuracy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat the threshold as a cost decision&lt;/strong&gt;, not a default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be ruthless about leakage&lt;/strong&gt;; it is the single biggest reason credit models look great offline and fail in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design comparisons so the result is attributable&lt;/strong&gt; to the thing you are testing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Engineer for the data you actually have&lt;/strong&gt;, whether that is a 1.6 GB file that needs streaming or transaction text that needs aggressive cleaning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship the human-in-the-loop logic&lt;/strong&gt;, because confidence-based routing is what separates a model from a system.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each project stands alone, but the line they trace, from credit history to affordability to the transaction data that makes affordability computable, is the actual shape of modern credit decisioning.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full code, data notes and reproducible notebooks: &lt;a href="https://github.com/gbadedata/credit-default-prediction" rel="noopener noreferrer"&gt;credit-default risk&lt;/a&gt;, &lt;a href="https://github.com/gbadedata/affordability-default-risk" rel="noopener noreferrer"&gt;affordability-based risk&lt;/a&gt;, &lt;a href="https://github.com/gbadedata/transaction-classification" rel="noopener noreferrer"&gt;transaction categorisation&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>datascience</category>
      <category>python</category>
      <category>fintech</category>
    </item>
    <item>
      <title>From Global Averages to Producer-Level Variation: 3 Python Data Projects on Food's Climate Impact</title>
      <dc:creator>Oluwagbade Odimayo</dc:creator>
      <pubDate>Mon, 29 Jun 2026 18:10:35 +0000</pubDate>
      <link>https://dev.to/gbadedata/from-global-averages-to-producer-level-variation-3-python-data-projects-on-foods-climate-impact-5d0b</link>
      <guid>https://dev.to/gbadedata/from-global-averages-to-producer-level-variation-3-python-data-projects-on-foods-climate-impact-5d0b</guid>
      <description>&lt;p&gt;&lt;em&gt;I spent a few weeks building three connected data-science projects on the greenhouse-gas footprint of what the world eats. They're deliberately a sequence - each one attacks an assumption the previous one had to make - and together they turned into a tidy case study in a few things I care about as a developer: &lt;strong&gt;benchmarking against baselines, respecting system boundaries, filtering data artefacts, and not letting a clean story beat an honest one.&lt;/strong&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This post is the technical tour: the data, the decisions, the code, and the gotchas. Full notebooks and data are in the repos linked at the end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stack:&lt;/strong&gt; Python · pandas · NumPy · scikit-learn · Matplotlib/Seaborn · Jupyter.&lt;/p&gt;




&lt;h2&gt;
  
  
  The data (and a word on provenance)
&lt;/h2&gt;

&lt;p&gt;Three public sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OECD–FAO Agricultural Outlook&lt;/strong&gt; - meat consumption by country/year/type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Our World in Data&lt;/strong&gt; (CC BY 4.0) - national emissions; and the &lt;strong&gt;Poore &amp;amp; Nemecek (2018, &lt;em&gt;Science&lt;/em&gt;)&lt;/strong&gt; life-cycle emission factors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FAOSTAT&lt;/strong&gt; (CC BY 4.0) - country-level &lt;em&gt;emission intensities&lt;/em&gt; of livestock products.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The repos ship the data with a &lt;code&gt;Data licence &amp;amp; attribution&lt;/code&gt; note distinguishing the MIT-licensed &lt;em&gt;code&lt;/em&gt; from the data, which keeps its own terms. If you publish someone's dataset, do this.&lt;/p&gt;




&lt;h2&gt;
  
  
  Project 1 - A footprint metric, diet clustering, and the model that lost
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Goal:&lt;/strong&gt; turn demand into estimated emissions, find diet archetypes, and forecast where consumption is heading.&lt;/p&gt;

&lt;p&gt;The metric itself is trivial - and that's the point; the value is in operationalising it cleanly:&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;# Mean life-cycle GHG, kg CO2e per kg of product (Poore &amp;amp; Nemecek via OWID)
&lt;/span&gt;&lt;span class="n"&gt;EF&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;beef&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;99.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sheep&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;39.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pig&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;12.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;poultry&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;9.9&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;footprint_kt_co2e&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;volume_kt&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;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EF&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That alone surfaces the headline: in 2020 &lt;strong&gt;beef was ~21% of meat volume but ~68% of the footprint&lt;/strong&gt;. A minority of volume drives the majority of emissions.&lt;/p&gt;

&lt;p&gt;Clustering countries by their &lt;em&gt;meat mix&lt;/em&gt; (not absolute volume) separates two levers people conflate - the carbon intensity of the &lt;em&gt;mix&lt;/em&gt; vs the &lt;em&gt;quantity&lt;/em&gt; eaten:&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;shares&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;div&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;volumes&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="n"&gt;axis&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;axis&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="c1"&gt;# per-country type shares
&lt;/span&gt;&lt;span class="n"&gt;X&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="nf"&gt;fit_transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shares&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;42&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&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# archetypes range ~27 (poultry-led) to ~59 (beef/sheep-led) kg CO2e per kg of meat
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The most useful thing I did: benchmark the forecast against a dumb baseline
&lt;/h3&gt;

&lt;p&gt;It's easy to fit a trend, plot it, and declare victory. So I held out 2015–2019 and pitted a linear trend against a naive "next year = this year" baseline:&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;train&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;2014&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;test&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;between&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2015&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2019&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;

&lt;span class="n"&gt;naive_pred&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;train&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iloc&lt;/span&gt;&lt;span class="p"&gt;[&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="c1"&gt;# last observed value
&lt;/span&gt;&lt;span class="n"&gt;naive_mae&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;naive_pred&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;coef&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;polyfit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;train&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;train&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&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="c1"&gt;# linear trend
&lt;/span&gt;&lt;span class="n"&gt;trend_mae&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;polyval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;coef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="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;naive MAE=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;naive_mae&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&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;  trend MAE=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;trend_mae&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&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;# total meat -&amp;gt; naive 0.17 vs trend 4.10   |   poultry -&amp;gt; naive 0.52 vs trend 1.24
&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%2F757d1ksd94d1b4rb2aws.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%2F757d1ksd94d1b4rb2aws.png" alt="Forecast vs naive baseline" width="800" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The naive baseline won by a mile. The series had flattened after ~2014, so a trend fit on the earlier rise overshot. &lt;strong&gt;Lesson #1: a model that can't beat the naive baseline shouldn't be used - and you report that, you don't bury it.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Project 2 - Opening up the single carbon number
&lt;/h2&gt;

&lt;p&gt;Project 1 used &lt;em&gt;one global factor per food&lt;/em&gt;. Project 2 asks what that hides, using the Poore &amp;amp; Nemecek per-product dataset (43 foods, GHG split into seven supply-chain stages plus four other impacts).&lt;/p&gt;

&lt;p&gt;First, a free data-integrity check - the stages should sum to the reported total:&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;stages&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;land_use_change&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;feed&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;farm&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;processing&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;transport&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;packaging&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;retail&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allclose&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;stages&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="n"&gt;axis&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;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;total_ghg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;atol&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1e-6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# passes to ~0 error
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The "food-miles" myth, in three lines
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;animal&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="n"&gt;category&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;animal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;transport_pkg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;animal&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transport&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;packaging&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="nf"&gt;sum&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;transport_pkg&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;animal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_ghg&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;# ~0.047
&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%2Fm9x3g45aevtmgcce4p61.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%2Fm9x3g45aevtmgcce4p61.png" alt="Supply-chain stage decomposition" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Transport + packaging is &lt;strong&gt;~4.7%&lt;/strong&gt; of animal-product emissions; for beef, transport alone is &lt;strong&gt;~0.5%&lt;/strong&gt;. ~90% is farm + feed + land-use change. "Buy local" is a weak lever next to "change what you eat."&lt;/p&gt;

&lt;h3&gt;
  
  
  Is carbon a good proxy for everything? Not water.
&lt;/h3&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;ghg&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;land&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;freshwater&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;eutrophication&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]].&lt;/span&gt;&lt;span class="nf"&gt;corr&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="c1"&gt;# ghg&amp;lt;-&amp;gt;land 0.83 | ghg&amp;lt;-&amp;gt;eutrophication 0.76 | ghg&amp;lt;-&amp;gt;freshwater 0.33
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Carbon tracks land and nutrient pollution well, &lt;strong&gt;freshwater poorly&lt;/strong&gt; (some plants - nuts, rice - are the thirstiest foods). A carbon-only metric can hide a water trade-off. PCA backs this up: PC1 explains ~66% of variance (an overall-impact axis), PC2 ~21% (a distinct &lt;em&gt;water&lt;/em&gt; axis).&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;Z&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="nf"&gt;fit_transform&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;impact_cols&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="nc"&gt;PCA&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_components&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&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;Z&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;explained_variance_ratio_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# [0.66, 0.21]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Re-basing per &lt;strong&gt;100 g of protein&lt;/strong&gt; (the fair comparison, restricted to genuine protein sources) keeps the animal/plant gap enormous - beef ~50–100× pulses or nuts, with &lt;strong&gt;eggs the most efficient animal protein&lt;/strong&gt;. &lt;strong&gt;Lesson #2: measure more than one dimension, or you'll move harm instead of removing it.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Project 3 - The same product, ~70× variation (and the messy data work behind it)
&lt;/h2&gt;

&lt;p&gt;Both earlier projects used global means. Project 3 quantifies what they flattened, using FAOSTAT emission &lt;em&gt;intensities&lt;/em&gt; (kg CO₂e/kg) for livestock products across &lt;strong&gt;250 areas, 1961–2023&lt;/strong&gt;. This is where most of the real engineering lived.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha 1 - encoding.&lt;/strong&gt; Read it as latin-1 and &lt;code&gt;Türkiye&lt;/code&gt; becomes &lt;code&gt;TÃ¼rkiye&lt;/code&gt;. The file is UTF-8:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;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="n"&gt;SRC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# not latin-1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Gotcha 2 - aggregates masquerading as countries.&lt;/strong&gt; FAOSTAT mixes regional aggregates (World, Africa, income groups) into the same &lt;code&gt;Area&lt;/code&gt; column. They use Area Code ≥ 5000:&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;AGG&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;World&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;Africa&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;Americas&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;Asia&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;Europe&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;Oceania&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;income&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;European Union&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;Least Developed&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;Sub-Saharan&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;span class="n"&gt;is_country&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;Area Code&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="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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;Area&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;|&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="n"&gt;AGG&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;na&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Gotcha 3 - data artifacts inflating the headline.&lt;/strong&gt; My first pass gave a beef max/min ratio of &lt;strong&gt;589×&lt;/strong&gt; - driven by places that barely raise cattle (Hong Kong, Lebanon) reporting unreliable near-zero intensities. The fix is a per-product production floor, which also dropped a &lt;code&gt;log(0)&lt;/code&gt; that was crashing the clustering:&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;MINPROD&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;Beef&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;50_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cow milk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;100_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Chicken&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;50_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Eggs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;30_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Pork&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;50_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Sheep meat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;10_000&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;# tonnes/yr
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;substantial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;intensity&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;keep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;production&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;reindex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fillna&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;MINPROD&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&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;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;dropna&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With genuine producers only, the spread is &lt;strong&gt;real, not noise&lt;/strong&gt;: ~70×, from ~3.9 kg CO₂e/kg (Israel) to ~270 (Niger).&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%2Fqr19zt9zft2f7a73t4qj.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%2Fqr19zt9zft2f7a73t4qj.png" alt="Cross-country spread by product" width="800" height="489"&gt;&lt;/a&gt;&lt;br&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%2Fmmjlvebzkbltuhoufsxk.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%2Fmmjlvebzkbltuhoufsxk.png" alt="Most vs least efficient beef producers" width="800" height="659"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha 4 - log before you cluster.&lt;/strong&gt; Intensities are strongly right-skewed, so Euclidean k-means on raw values is dominated by the tail. Log-transform first:&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;X&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;intensity&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;feats&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&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;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nan&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;dropna&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;Z&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="nf"&gt;fit_transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&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;# log: intensities are right-skewed
&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="mi"&gt;3&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;42&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;Z&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# three efficiency tiers: median beef ~16 / 50 / 79 kg CO2e/kg
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the time trend, FAOSTAT publishes a &lt;code&gt;World&lt;/code&gt; aggregate, so you don't have to re-derive it:&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;world&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="n"&gt;Area&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;World&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;amp;&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;Element&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Emissions intensity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="c1"&gt;# world beef -32% since 1961, cow milk -52%, chicken -41% as systems got more productive
&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%2F1w26qvtmjumxmpdcnjq9.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%2F1w26qvtmjumxmpdcnjq9.png" alt="World intensity falling over time" width="800" height="471"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The trap that ties it together: system boundaries
&lt;/h2&gt;

&lt;p&gt;Here's the one that'll bite you if you're not careful. Across these projects, &lt;strong&gt;beef shows up as ~99.5, ~60, and ~30 kg CO₂e/kg.&lt;/strong&gt; Same animal, three numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~99.5&lt;/strong&gt; - OWID's headline life-cycle figure (Project 1).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~60&lt;/strong&gt; - the same Poore &amp;amp; Nemecek study, summed across its stages (Project 2).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~30&lt;/strong&gt; - FAOSTAT's &lt;em&gt;farm-gate&lt;/em&gt; intensity (Project 3).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of them are "wrong." They're measured to &lt;strong&gt;different system boundaries&lt;/strong&gt; (full LCA vs production-only) and processed differently. &lt;strong&gt;Lesson #3: never compare emission factors across boundaries as if they're the same measurement.&lt;/strong&gt; Lean on &lt;em&gt;relative&lt;/em&gt; rankings (beef ≫ poultry) - those are stable across all three. I called this out explicitly rather than quietly picking whichever number suited the slide.&lt;/p&gt;




&lt;h2&gt;
  
  
  Engineering takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Benchmark against a naive baseline.&lt;/strong&gt; If your model can't beat "same as last value," it adds nothing - and saying so is the rigorous move.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Global means hide variation.&lt;/strong&gt; For anything about intervention, the &lt;em&gt;distribution&lt;/em&gt; is the unit of analysis, not the average (~70× for beef).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mind system boundaries.&lt;/strong&gt; 99 vs 60 vs 30 for the same animal. Compare like with like; trust relative rankings over absolute numbers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter artifacts before quoting extremes.&lt;/strong&gt; A production floor turned a noisy 589× into a real 70×.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log-transform right-skewed features&lt;/strong&gt; before distance-based methods (clustering, PCA).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate aggregates from units&lt;/strong&gt; in mixed-grain datasets (FAOSTAT Area Code ≥ 5000).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encoding is not optional&lt;/strong&gt; (&lt;code&gt;utf-8&lt;/code&gt;, or your country names rot).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reproducibility hygiene:&lt;/strong&gt; pinned &lt;code&gt;requirements.txt&lt;/code&gt;, executed notebooks committed, data licensed and attributed.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Repos
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/gbadedata/meat-carbon-footprint" rel="noopener noreferrer"&gt;meat-carbon-footprint&lt;/a&gt;&lt;/strong&gt; - demand, the footprint metric, diet clustering, the baseline-beats-model forecast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/gbadedata/food-environmental-footprint" rel="noopener noreferrer"&gt;food-environmental-footprint&lt;/a&gt;&lt;/strong&gt; - supply-chain stage decomposition, multi-impact correlations, PCA, per-protein.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/gbadedata/livestock-emission-intensity" rel="noopener noreferrer"&gt;livestock-emission-intensity&lt;/a&gt;&lt;/strong&gt; - FAOSTAT cross-country variation, efficiency tiers, the system-boundary write-up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each has the full Jupyter notebook (executed), a runnable &lt;code&gt;analysis.py&lt;/code&gt;, pinned requirements, and a detailed write-up.&lt;/p&gt;

&lt;p&gt;If you take one thing from this: the interesting question is almost never the average. It's the spread, the boundary, and whether your model actually beats doing nothing. Happy to talk methods in the comments.&lt;/p&gt;

</description>
      <category>python</category>
      <category>datascience</category>
      <category>machinelearning</category>
      <category>sustainability</category>
    </item>
    <item>
      <title>When SuSiE Says '95% Confident', Is It?</title>
      <dc:creator>Oluwagbade Odimayo</dc:creator>
      <pubDate>Sun, 21 Jun 2026 23:12:05 +0000</pubDate>
      <link>https://dev.to/gbadedata/when-susie-says-95-confident-is-it-benchmarking-the-honesty-of-fine-mapping-credible-sets-206o</link>
      <guid>https://dev.to/gbadedata/when-susie-says-95-confident-is-it-benchmarking-the-honesty-of-fine-mapping-credible-sets-206o</guid>
      <description>&lt;p&gt;&lt;em&gt;Benchmarking the Honesty of Fine-Mapping Credible Sets&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Fine-mapping has a promise built into its output, and almost nobody checks whether the promise is kept.&lt;/p&gt;

&lt;p&gt;When you run SuSiE on a GWAS locus, it hands you a &lt;em&gt;credible set&lt;/em&gt;: a small group of variants that, at a stated confidence level like 95%, should contain the true causal variant. That 95% is a claim about reality. Among all the loci where SuSiE reports a 95% credible set, the true causal variant should be inside the set 95% of the time.&lt;/p&gt;

&lt;p&gt;Is it? This post is about how to measure that, what I found when I did, and why the answer is more interesting than a single coverage number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you cannot measure this on real data
&lt;/h2&gt;

&lt;p&gt;Here is the catch that shapes everything. To check whether a credible set contains the causal variant, you need to &lt;em&gt;know&lt;/em&gt; the causal variant. On real GWAS data, you do not. That is the entire reason fine-mapping exists.&lt;/p&gt;

&lt;p&gt;So calibration is measured by simulation, and this is not a shortcut, it is the only valid method. You plant a known causal variant in a simulated locus with realistic linkage disequilibrium, generate GWAS summary statistics consistent with that truth, run SuSiE, and check whether its credible sets behave as advertised. This is exactly how SuSiE, FiniMOM, SuSiEx, and the recent SuSiE 2.0 were all validated. Known ground truth is the whole point.&lt;/p&gt;

&lt;p&gt;(To keep the simulation anchored to reality, the project also runs SuSiE on the SORT1 / 1p13 cholesterol locus, one of the rare real loci where the causal variant, rs12740374, is functionally validated. SuSiE recovers it. But the calibration numbers themselves come from simulation, as they must.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The simulation
&lt;/h2&gt;

&lt;p&gt;Each simulated locus needs three things SuSiE-RSS consumes: z-scores, an LD matrix, and a sample size.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight r"&gt;&lt;code&gt;&lt;span class="n"&gt;simulate_locus&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;20000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;n_causal&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                           &lt;/span&gt;&lt;span class="n"&gt;pve&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0.002&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;block_w&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&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="n"&gt;set.seed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;R&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;make_ld_matrix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;block_w&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;block_w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;neighbourhood&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;causal&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sample.int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;n_causal&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;ncp&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;causal&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ncp&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_causal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;as.vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;R&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;%*%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;              &lt;/span&gt;&lt;span class="c1"&gt;# LD spreads the signal to proxies&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;as.vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MASS&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;mvrnorm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Sigma&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;R&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;R&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;R&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;causal&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;causal&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The causal variant gets a true effect; LD spreads marginal signal to its neighbours (which is what makes fine-mapping hard); and the z-scores are drawn consistent with that LD structure. The effect size &lt;code&gt;pve&lt;/code&gt; is tuned to the realistic marginal regime, mean absolute z around 6 at the causal variant, just above genome-wide significance. Make the signal too strong and fine-mapping becomes trivial; this is where it is actually interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The metric, and validating the metric
&lt;/h2&gt;

&lt;p&gt;Coverage is simple to state: across many loci, the fraction whose reported credible set contains the true causal variant. For a calibrated method at the 95% level, that should be about 0.95.&lt;/p&gt;

&lt;p&gt;But a benchmark whose own metric is wrong is worse than no benchmark. So before trusting the metric on SuSiE, I tested it against a mock fine-mapper with &lt;em&gt;known&lt;/em&gt; coverage: if you feed it credible sets that contain the causal variant exactly 95% of the time, the metric must report 0.95; feed it 80%, it must report 0.80.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight r"&gt;&lt;code&gt;&lt;span class="n"&gt;mock&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;true_cov&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;as.logical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;runif&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;true_cov&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;empirical_coverage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;# ~0.95&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;empirical_coverage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0.80&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;# ~0.80, correctly flagged&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only once the metric provably recovers known coverage is it allowed to judge SuSiE. (This logic was prototyped and unit-tested in Python first, then ported to R, so the arithmetic was known-correct independently of any fine-mapping run.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that inflated every credible set
&lt;/h2&gt;

&lt;p&gt;The first real run produced credible sets of 100-plus variants. That is nonsense, no useful fine-mapping returns a 100-variant "credible" set. The cause was a single missing argument.&lt;/p&gt;

&lt;p&gt;SuSiE's credible-set construction includes a &lt;em&gt;purity filter&lt;/em&gt;: it prunes sets down to variants that are genuinely correlated with one another, discarding uncorrelated noise. That filter only runs if you pass the LD matrix to &lt;code&gt;susie_get_cs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight r"&gt;&lt;code&gt;&lt;span class="c1"&gt;# wrong: no purity filter, sets fill with uncorrelated noise&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;cs&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;susie_get_cs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;coverage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c1"&gt;# right: Xcorr = R activates the purity filter&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;cs&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;susie_get_cs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Xcorr&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;R&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;coverage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0.95&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;With the fix, sets collapsed from 100-plus variants to typically one. I found this by running single loci through SuSiE and inspecting the actual set sizes, rather than trusting an assumption about what they should be. The lesson is one the benchmark itself preaches: distrust a number that looks wrong, and check it against ground truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Across thousands of loci, six difficulty conditions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Coverage&lt;/th&gt;
&lt;th&gt;Abstention&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Baseline (1 causal)&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;td&gt;6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strong LD&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;td&gt;5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two causal&lt;/td&gt;
&lt;td&gt;99.8%&lt;/td&gt;
&lt;td&gt;41%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Three causal&lt;/td&gt;
&lt;td&gt;99.7%&lt;/td&gt;
&lt;td&gt;65%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weak effect&lt;/td&gt;
&lt;td&gt;99.4%&lt;/td&gt;
&lt;td&gt;64%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Very weak effect&lt;/td&gt;
&lt;td&gt;98.7%&lt;/td&gt;
&lt;td&gt;85%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Coverage holds at or above the promised 95% everywhere, slightly conservative, which is the safe direction. So far, unremarkable: SuSiE is well-calibrated, as you would hope.&lt;/p&gt;

&lt;p&gt;The interesting column is the second one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The finding is in the abstention
&lt;/h2&gt;

&lt;p&gt;Look at what happens as the loci get harder. SuSiE does not start returning wrong credible sets. It starts returning &lt;em&gt;no&lt;/em&gt; credible set at all. Abstention climbs from 6% on clean single-causal loci, to 41% with two causal variants, to 85% in the lowest-power regime.&lt;/p&gt;

&lt;p&gt;This is the mechanism behind the calibration. SuSiE keeps its sets sharp, usually a single variant, and protects its 95% promise by &lt;em&gt;declining&lt;/em&gt; the loci where it cannot meet that bar. The high coverage is not achieved by hedging with big sets. It is achieved by abstaining on the hard cases instead of guessing.&lt;/p&gt;

&lt;p&gt;That distinction matters enormously for how you read the output. A naive reading of "SuSiE returned no credible set" is failure. The correct reading is honesty: the method is telling you it cannot confidently localize the causal variant here, which is exactly what you want it to say when that is true. A fine-mapper that always returned a confident set, including on the loci where it has no business being confident, would be far more dangerous.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I think this generalizes
&lt;/h2&gt;

&lt;p&gt;I have now built three benchmarks that ask the same question of very different systems: is the model's stated confidence honest? An &lt;a href="https://github.com/gbadedata/clinvar-interpretation-benchmark" rel="noopener noreferrer"&gt;LLM interpreting clinical variants&lt;/a&gt; abstained to "uncertain" exactly where the evidence ran out. A &lt;a href="https://github.com/gbadedata/variant-calling-calibration-benchmark" rel="noopener noreferrer"&gt;variant caller's QUAL scores&lt;/a&gt; could be checked for whether the stated confidence matched empirical precision. And here, SuSiE abstains rather than mislead as the genetics gets harder.&lt;/p&gt;

&lt;p&gt;Three systems, one property worth measuring: not just whether the model is accurate, but whether it knows and admits the limits of what it knows. In all three, the well-built method's answer to "are you sure?" turns out to be the most informative thing it produces.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it
&lt;/h2&gt;

&lt;p&gt;The full framework is R, built on susieR, with the simulation harness, the calibration metrics, the validated mock test, the figures, and the SORT1 real-locus template:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/gbadedata/finemap-calibration-benchmark" rel="noopener noreferrer"&gt;github.com/gbadedata/finemap-calibration-benchmark&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Rscript setup.R                 &lt;span class="c"&gt;# susieR, MASS, jsonlite&lt;/span&gt;
Rscript tests/test_calibration.R  &lt;span class="c"&gt;# metric validation, no susieR needed&lt;/span&gt;
Rscript R/run_benchmark.R       &lt;span class="c"&gt;# the full benchmark&lt;/span&gt;
Rscript R/make_figures.R        &lt;span class="c"&gt;# the figures&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you fine-map, try measuring your own pipeline's credible-set coverage by simulation. The coverage number is reassuring. The abstention behaviour is where you learn what your method actually does when the data gets hard.&lt;/p&gt;

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

&lt;p&gt;Verified against the primary source.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Wang G, Sarkar A, Carbonetto P, Stephens M (2020). A simple new approach to variable selection in regression, with application to genetic fine mapping. &lt;em&gt;JRSS-B&lt;/em&gt; 82(5):1273-1300. doi:10.1111/rssb.12388&lt;/li&gt;
&lt;li&gt;Zou Y, Carbonetto P, Wang G, Stephens M (2022). Fine-mapping from summary data with the "Sum of Single Effects" model. &lt;em&gt;PLOS Genetics&lt;/em&gt; 18(7):e1010299. doi:10.1371/journal.pgen.1010299&lt;/li&gt;
&lt;li&gt;Musunuru K, et al. (2010). From noncoding variant to phenotype via SORT1 at the 1p13 cholesterol locus. &lt;em&gt;Nature&lt;/em&gt; 466(7307):714-719. doi:10.1038/nature09266&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>bioinformatics</category>
      <category>statistics</category>
      <category>datascience</category>
      <category>genetics</category>
    </item>
    <item>
      <title>Your Variant Caller Tells You How Confident It Is. Have You Ever Checked If It's Telling the Truth?</title>
      <dc:creator>Oluwagbade Odimayo</dc:creator>
      <pubDate>Sun, 21 Jun 2026 17:47:45 +0000</pubDate>
      <link>https://dev.to/gbadedata/your-variant-caller-tells-you-how-confident-it-is-have-you-ever-checked-if-its-telling-the-truth-n6k</link>
      <guid>https://dev.to/gbadedata/your-variant-caller-tells-you-how-confident-it-is-have-you-ever-checked-if-its-telling-the-truth-n6k</guid>
      <description>&lt;p&gt;Every variant caller you have ever used attaches a number to each call: QUAL. It is a confidence score, a claim about how likely the call is to be correct. You filter on it constantly. &lt;code&gt;QUAL &amp;gt;= 30&lt;/code&gt;, &lt;code&gt;QUAL &amp;gt;= 20&lt;/code&gt;, whatever your pipeline settled on years ago.&lt;/p&gt;

&lt;p&gt;Here is a question almost nobody asks: is that number honest?&lt;/p&gt;

&lt;p&gt;When the caller stamps a variant at QUAL 30, it is claiming the call is 99.9% likely to be real. Among all the calls it stamps at that confidence, are 99.9% of them actually correct? Or is the caller systematically overstating how sure it is, and if so, where?&lt;/p&gt;

&lt;p&gt;This is the question of &lt;strong&gt;calibration&lt;/strong&gt;, and it is borrowed from machine-learning evaluation, where it is standard practice and where, for variant callers, it is almost completely absent. This post is about why it matters, how to measure it, and a small open-source framework that does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What QUAL actually claims
&lt;/h2&gt;

&lt;p&gt;QUAL is phred-scaled. The definition is precise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;QUAL = -10 * log10(P(call is wrong))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Invert it and you get the caller's stated probability that the call is correct:&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;qual_to_confidence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qual&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;qual&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="n"&gt;p_wrong&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;10.0&lt;/span&gt; &lt;span class="o"&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;qual&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;10.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;p_wrong&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So QUAL 10 claims 90% confidence, QUAL 20 claims 99%, QUAL 30 claims 99.9%. These are not vague quality hints. They are probability statements, and probability statements can be checked against reality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calibration: comparing the claim to the truth
&lt;/h2&gt;

&lt;p&gt;To check them you need ground truth. For human variant calling that means the Genome in a Bottle (GIAB) benchmark set, the community-standard truth set for samples like HG001/NA12878. Run your caller, compare each call against GIAB: true positive if it is in the truth set, false positive if it is not.&lt;/p&gt;

&lt;p&gt;Now group the calls by their stated confidence and, in each group, measure the &lt;strong&gt;empirical precision&lt;/strong&gt;, the fraction that are actually true positives. A well-calibrated caller has, in every bin, empirical precision close to stated confidence. Plot one against the other and an honest caller's points sit on the diagonal.&lt;/p&gt;

&lt;p&gt;The scalar summary of how far off you are is &lt;strong&gt;Expected Calibration Error (ECE)&lt;/strong&gt;: the support-weighted mean absolute gap between stated confidence and empirical precision across the bins.&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;ece&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;bins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;gap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mean_confidence&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;empirical_precision&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ece&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;gap&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ECE of 0 is perfect honesty. A large ECE with stated confidence consistently above empirical precision means the caller is overconfident: its QUAL says more than the calls deliver.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is not academic
&lt;/h2&gt;

&lt;p&gt;If QUAL is inflated, every threshold built on it is wrong, and wrong in a way that hides. Filter at "QUAL &amp;gt;= 30, surely safe" and, if the caller is overconfident, you are quietly keeping false positives you think you excluded. Worse, the inflation is rarely uniform. It concentrates in the hard parts of the genome, low-complexity regions, segmental duplications, low-mappability stretches, which is exactly where clinically important variants sometimes live and exactly where you can least afford a false sense of safety.&lt;/p&gt;

&lt;p&gt;A plain precision/recall benchmark never reveals this. It tells you the caller's overall accuracy. It does not tell you whether you can trust the per-call confidence the caller hands you, which is the thing your filters actually consume.&lt;/p&gt;

&lt;h2&gt;
  
  
  A worked illustration
&lt;/h2&gt;

&lt;p&gt;I built a small framework that computes exactly this: &lt;a href="https://github.com/gbadedata/variant-calling-calibration-benchmark" rel="noopener noreferrer"&gt;variant-calling-calibration-benchmark&lt;/a&gt;. It evaluates a caller on four layers: concordance (precision/recall/F1 vs GIAB), stratification (the same, split by genomic difficulty), calibration (the curve and ECE above), and a filtering-as-abstention analysis (more on that below).&lt;/p&gt;

&lt;p&gt;To show the method end to end without a multi-gigabyte download, the repository ships a synthetic caller, built deliberately with the kind of miscalibration real callers exhibit: overconfidence concentrated in difficult regions and indels. I want to be plain that these demo numbers are synthetic; the point is to illustrate what the framework measures, not to report a discovery. Running it on a real caller is one command, shown at the end.&lt;/p&gt;

&lt;p&gt;On that synthetic caller, the calibration layer reports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Expected Calibration Error (ECE): 0.14
Mean stated confidence:           0.95
Empirical precision:              0.81
Verdict:                          OVERCONFIDENT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caller asserts 95% mean confidence and delivers 81% precision. The calibration curve shows the points sitting below the diagonal across the whole confidence range. That gap is invisible to an F1 score and obvious the moment you plot stated confidence against empirical precision.&lt;/p&gt;

&lt;p&gt;The stratification layer shows where the gap lives: near-perfect concordance (F1 ~0.99) in high-confidence regions, collapsing to ~0.77 in segmental duplications and low-mappability regions. The overconfidence is worst exactly where the genome is hardest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Filtering as an abstention decision
&lt;/h2&gt;

&lt;p&gt;Here is the part I find most useful, and it comes from thinking about callers the way you would think about any model that can decline to answer.&lt;/p&gt;

&lt;p&gt;A QUAL filter is not just a quality cutoff. It is a deferral decision: every call below the threshold is one the caller is choosing not to commit to. Raise the threshold and you remove false positives (good) but also discard true positives (costly). The framework sweeps thresholds and finds two reference points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the threshold that maximises retained F1 (best overall), and&lt;/li&gt;
&lt;li&gt;the lowest threshold that reaches a target precision such as 99%, the point where what you keep is trustworthy enough to act on without review.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The distance between those two is a direct measure of the safe-versus-decisive tradeoff. On the synthetic caller, reaching 99% precision requires filtering all the way up to QUAL ~90, which discards most calls. The caller can be made trustworthy, but only by being made nearly silent. That is a property worth knowing before you deploy it, and a plain benchmark will never tell you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validating the evaluator itself
&lt;/h2&gt;

&lt;p&gt;A benchmark whose metric is wrong is worse than no benchmark, so the ECE implementation is tested against inputs with known calibration. A synthetically honest caller (true-positive probability set exactly to the QUAL-implied confidence) must yield ECE near zero. A synthetically overconfident caller (high QUAL, 50% actually true) must yield large ECE. Both are asserted in the test suite. The metric is proven correct before it is trusted to judge anything.&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;test_well_calibrated_low_ece&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compute_calibration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;well_calibrated_set&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ece&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_overconfident_high_ece&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compute_calibration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;overconfident_set&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ece&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Running it on real data, the right way
&lt;/h2&gt;

&lt;p&gt;The framework's built-in matcher uses exact position-and-allele comparison, which is fine for normalised VCFs but stricter than it should be on real data, where variant representation differences are common. For real GIAB benchmarking the correct tool is hap.py, the GA4GH/GIAB field standard, which does proper normalisation and haplotype-aware matching.&lt;/p&gt;

&lt;p&gt;So the real-data path delegates matching to hap.py and runs the calibration and abstention layers on its annotated output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# hap.py does the matching (the hard, solved problem)&lt;/span&gt;
hap.py truth.vcf.gz your_caller.vcf.gz &lt;span class="nt"&gt;-f&lt;/span&gt; truth.bed &lt;span class="nt"&gt;-r&lt;/span&gt; GRCh38.fasta &lt;span class="nt"&gt;-o&lt;/span&gt; happy_out

&lt;span class="c"&gt;# this framework adds the calibration and abstention analysis hap.py lacks&lt;/span&gt;
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; src.run_benchmark &lt;span class="nt"&gt;--happy-vcf&lt;/span&gt; happy_out.vcf.gz &lt;span class="nt"&gt;--caller-name&lt;/span&gt; gatk-hc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That division of labour is the point: hap.py answers "how accurate, where," and this framework answers "is the confidence honest, and where should the caller stop trusting itself." The second question is the one almost no variant-calling benchmark asks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Accuracy and calibration are different properties, and your filters depend on the second one. A caller can have a respectable F1 and still lie to you about its confidence in exactly the regions where being lied to costs the most. Measuring calibration is cheap, it borrows a standard idea from ML evaluation, and it tells you something a precision/recall table cannot.&lt;/p&gt;

&lt;p&gt;I applied this same way of thinking, measure honesty of confidence, not just accuracy, to large language models interpreting clinical variants in a &lt;a href="https://github.com/gbadedata/clinvar-interpretation-benchmark" rel="noopener noreferrer"&gt;companion project&lt;/a&gt;. Different model, same question. It turns out to be the more interesting question in both cases.&lt;/p&gt;

&lt;p&gt;Code, tests, and the full four-layer framework: &lt;a href="https://github.com/gbadedata/variant-calling-calibration-benchmark" rel="noopener noreferrer"&gt;github.com/gbadedata/variant-calling-calibration-benchmark&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you benchmark variant callers and have never plotted stated confidence against empirical precision, try it on your own GATK or DeepVariant output. I would be curious whether your caller is as honest as you assume.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Krusche P, et al. (2019). Best practices for benchmarking germline small-variant calls in human genomes. &lt;em&gt;Nature Biotechnology&lt;/em&gt; 37(5):555-560. doi:10.1038/s41587-019-0054-x&lt;/li&gt;
&lt;li&gt;Zook JM, et al. (2019). An open resource for accurately benchmarking small variant and reference calls. &lt;em&gt;Nature Biotechnology&lt;/em&gt; 37(5):561-566. doi:10.1038/s41587-019-0074-6&lt;/li&gt;
&lt;li&gt;Wagner J, et al. (2022). Benchmarking challenging small variants with linked and long reads. &lt;em&gt;Cell Genomics&lt;/em&gt; 2(5):100128. doi:10.1016/j.xgen.2022.100128&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>bioinformatics</category>
      <category>machinelearning</category>
      <category>python</category>
      <category>datascience</category>
    </item>
  </channel>
</rss>
