<?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 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>
    <item>
      <title>Your LLM Got the Variant Right. But Did It Get It Right for the Right Reason?</title>
      <dc:creator>Oluwagbade Odimayo</dc:creator>
      <pubDate>Sun, 21 Jun 2026 02:29:55 +0000</pubDate>
      <link>https://dev.to/gbadedata/your-llm-got-the-variant-right-but-did-it-get-it-right-for-the-right-reason-1oc3</link>
      <guid>https://dev.to/gbadedata/your-llm-got-the-variant-right-but-did-it-get-it-right-for-the-right-reason-1oc3</guid>
      <description>&lt;p&gt;&lt;em&gt;I built a benchmark to find out whether a frontier language model can be trusted to interpret clinical genetic variants. The result surprised me, and the way it surprised me is the whole point of the post.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The model I tested (Claude Opus 4.8) scored 60 percent accuracy against expert consensus. If I had stopped there, I would have written "the model is mediocre, do not deploy." That conclusion would have been wrong. The real finding only appeared once I stopped measuring accuracy and started measuring something else.&lt;/p&gt;

&lt;p&gt;Here is what I learned about building benchmarks for high-stakes domains, with the code and the numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: why variant interpretation is a hard thing to benchmark
&lt;/h2&gt;

&lt;p&gt;When a lab sequences your DNA and finds a change in a gene, the critical question is whether that change matters. Is it pathogenic (disease-causing) or benign (harmless)? This is variant interpretation, and it is hard precisely because you usually cannot verify an interpretation without expert consensus. There is no unit test for "is this BRCA2 missense variant pathogenic."&lt;/p&gt;

&lt;p&gt;That property is exactly what makes it interesting as an evaluation problem. If you want to benchmark an LLM on variant interpretation, your first and hardest job is finding trustworthy ground truth.&lt;/p&gt;

&lt;p&gt;It exists. ClinVar, the NIH's public variant database, assigns every variant a review status on a four-star scale:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stars&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Backed by practice guideline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Reviewed by expert panel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Multiple submitters, no conflicts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Single submitter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;No assertion criteria&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That star rating is an expert-consensus signal baked right into the data. I restricted the oracle to 2-star-and-above, so every "ground truth" label reflects multiple independent laboratories agreeing. When the model disagrees, it is disagreeing with a consensus, not one opinion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The task
&lt;/h2&gt;

&lt;p&gt;For each variant, the model gets a structured packet: gene, HGVS coding and protein notation, molecular consequence, associated condition. It returns strict JSON: a three-class call (pathogenic, benign, uncertain), the gene, the consequence, the mechanism, its reasoning, and the evidence it used. The oracle is hidden until after the model commits.&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;InterpretationResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;variant_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;classification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Classification&lt;/span&gt;  &lt;span class="c1"&gt;# PATHOGENIC | BENIGN | VUS
&lt;/span&gt;    &lt;span class="n"&gt;stated_gene&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="n"&gt;stated_consequence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="n"&gt;stated_mechanism&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="n"&gt;reasoning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="n"&gt;cited_evidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&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="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole framework is model-agnostic behind a &lt;code&gt;VariantInterpreter&lt;/code&gt; protocol, with a deterministic &lt;code&gt;MockInterpreter&lt;/code&gt; for CI (no API key) and a &lt;code&gt;ClaudeInterpreter&lt;/code&gt; for live runs. That separation matters: it let me unit-test the entire scoring engine offline with a controlled-accuracy mock, so a mock that copies the oracle exactly is asserted to score 1.000, and every metric is verified against hand-computed values. You should be able to prove your evaluator is correct before you ever spend a cent on API calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first run looked like a disaster
&lt;/h2&gt;

&lt;p&gt;I ran 30 real variants. The numbers came back ugly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Overall accuracy:  0.533
By difficulty tier:
  easy     acc=0.333
  medium   acc=0.273
  hard     acc=1.000
By class (precision / recall / F1):
  benign      0.000 / 0.000 / 0.000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at that tier pattern. The model scored 0.333 on easy variants and 1.000 on hard ones. That is inverted. A capable model should do best on the easy cases. And benign recall was a flat zero, it got every benign variant wrong.&lt;/p&gt;

&lt;p&gt;My first instinct was "the model is bad at this." My second, better instinct was "a frontier model is not genuinely worse at easy variants than hard ones, so the bug is in my benchmark, not the model." So before changing anything, I dumped the per-variant predictions next to the oracle.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diagnosis: it was abstaining, not failing
&lt;/h2&gt;

&lt;p&gt;The dump made it obvious. Every single mismatch was the model answering "uncertain" where ClinVar had a confident call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BRCA2   easy   oracle: PATHOGENIC   model: VUS
GAMT    easy   oracle: BENIGN       model: VUS
ITGB3   easy   oracle: PATHOGENIC   model: VUS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It never confused pathogenic with benign. Not once. It abstained to "uncertain" whenever it was not sure. And the "hard" tier scored 1.000 because hard-tier variants are mostly genuine VUS, so its abstention happened to match the oracle there.&lt;/p&gt;

&lt;p&gt;Then I read the actual reasoning text, and it was textbook clinical genetics:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"This is a missense variant in ITGB3... no population frequency, functional, segregation, or computational evidence was provided to establish pathogenicity."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The model was not failing. It was correctly recognising that, under ACMG interpretation guidelines, a missense variant genuinely cannot be classified without evidence it had not been given. Meanwhile, on loss-of-function variants where the consequence alone meets a strong ACMG criterion (PVS1), it confidently and correctly called pathogenic:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The variant p.Trp150Ter introduces a premature stop codon early in NPHS1, predicting loss of function via nonsense-mediated decay..."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the behaviour you want from a model in a clinical loop. It defers when it should defer and commits when it should commit. And a plain accuracy score had branded it a failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: score abstention separately from error
&lt;/h2&gt;

&lt;p&gt;The problem was conceptual, not a code bug. Accuracy treats every non-match as equally bad. But in a clinical setting, two kinds of "wrong" are worlds apart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;safe abstention&lt;/strong&gt;: the model says "uncertain" when the truth was a confident call. Not ideal, but safe. A clinician knows to investigate.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;confident error&lt;/strong&gt;: the model makes the opposite confident call (benign when pathogenic, or vice versa). This is the dangerous failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I added an abstention-analysis layer that separates these on confident-truth variants:&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;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;confident_truth&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;classification&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oracle_classification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;correct_calls&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;classification&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_CONFIDENT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="c1"&gt;# opposite confident call
&lt;/span&gt;        &lt;span class="n"&gt;confident_errors&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;              &lt;span class="c1"&gt;# the dangerous bucket
&lt;/span&gt;    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                                  &lt;span class="c1"&gt;# returned VUS
&lt;/span&gt;        &lt;span class="n"&gt;abstentions&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;                   &lt;span class="c1"&gt;# safe
&lt;/span&gt;
&lt;span class="n"&gt;safe_rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;confident_errors&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n_ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;decisiveness&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;correct_calls&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;confident_errors&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_ct&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;safe_rate&lt;/code&gt; is the metric that actually matters: how often does the model avoid a confident wrong call. The &lt;code&gt;decisiveness&lt;/code&gt; tells you how often it commits at all. A model can be perfectly safe and barely decisive, and accuracy alone makes that invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The experiment: does giving the model evidence help?
&lt;/h2&gt;

&lt;p&gt;If the model abstains because it lacks evidence, what happens when you give it some? I added an evidence-rich mode that supplies the real molecular consequence, derived directly from the variant's own HGVS (frameshift, nonsense, splice, missense, synonymous), plus a note on its ACMG relevance.&lt;/p&gt;

&lt;p&gt;A critical design rule here: the evidence had to be real. The tempting move is to inject plausible-looking allele frequencies or functional results to make the task answerable. But fabricating evidence in an evaluation is exactly the failure mode I was trying to detect in the model. So the evidence-rich mode supplies only what can be honestly derived from the variant's own nomenclature, which happens to be the single highest-weighted ACMG criterion. No external data, nothing invented, fully traceable to source.&lt;/p&gt;

&lt;p&gt;I ran both modes on 100 variants. Here is the full result.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Evidence-poor&lt;/th&gt;
&lt;th&gt;Evidence-rich&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Confident errors&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safe rate&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accuracy&lt;/td&gt;
&lt;td&gt;0.600&lt;/td&gt;
&lt;td&gt;0.640&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cohen kappa&lt;/td&gt;
&lt;td&gt;0.362&lt;/td&gt;
&lt;td&gt;0.429&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decisiveness&lt;/td&gt;
&lt;td&gt;0.371&lt;/td&gt;
&lt;td&gt;0.419&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Macro F1&lt;/td&gt;
&lt;td&gt;0.476&lt;/td&gt;
&lt;td&gt;0.570&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The headline: &lt;strong&gt;zero confident errors across 200 interpretations.&lt;/strong&gt; The model never made a dangerous wrong call in either mode. Adding the real consequence evidence improved accuracy and decisiveness without introducing a single error. Evidence made it commit correctly, not recklessly.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small-sample trap worth flagging
&lt;/h2&gt;

&lt;p&gt;At n=30, the evidence-rich mode scored slightly &lt;em&gt;lower&lt;/em&gt; than evidence-poor. If I had written it up then, I would have reported "evidence makes the model worse," which is the opposite of the truth. At n=100 the effect reversed and evidence-rich was clearly better. The same calibration discipline a benchmark demands of the model applies to the person running it. Small samples lie. I treated the n=30 result as a signal to investigate, not a finding to publish.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking the reasoning, not just the label
&lt;/h2&gt;

&lt;p&gt;A model can be right for the wrong reason, or fabricate evidence. So beyond the label, four validators run independently of the oracle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gene grounding&lt;/strong&gt;: did it name the gene actually carrying the variant?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consequence consistency&lt;/strong&gt;: does its stated consequence match the variant's real one?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mechanism plausibility&lt;/strong&gt;: for a LoF variant called pathogenic, does the reasoning invoke loss of function?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No-fabrication&lt;/strong&gt;: did it invent allele frequencies, citations, patient counts, or studies it was never given?
&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;_FABRICATION_PATTERNS&lt;/span&gt; &lt;span class="o"&gt;=&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;\bgnomad\b&lt;/span&gt;&lt;span class="sh"&gt;"&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;\ballele frequency of\s*[\d.]+&lt;/span&gt;&lt;span class="sh"&gt;"&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\d+\s*(?:patients|families|individuals|cases)\b&lt;/span&gt;&lt;span class="sh"&gt;"&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;\bet al\.?\b&lt;/span&gt;&lt;span class="sh"&gt;"&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(?:19|20)\d{2}\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# a citation year
&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;\bfunctional (?:study|studies|assay) (?:showed|demonstrated|confirmed)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Results: gene grounding 1.000, no-fabrication 1.000 in the poor mode. In the rich mode no-fabrication dipped to 0.970, three variants out of a hundred where the richer prompt nudged the model toward citing evidence types. I report that dip rather than hide it. It is a real and minor finding, and it is exactly what the validator exists to catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway for anyone building evals in a hard domain
&lt;/h2&gt;

&lt;p&gt;The lesson is not about genetics. It is about evaluation design.&lt;/p&gt;

&lt;p&gt;A naive accuracy metric on this task would have told me a careful, safe, well-calibrated model was mediocre. The model was not mediocre. My metric was. The gap between the model and the oracle was not pure error, it was a precise measure of the evidence the model was never given, and the model abstained exactly where that evidence mattered.&lt;/p&gt;

&lt;p&gt;In high-stakes domains, your benchmark has to be at least as sophisticated as the model it judges. Specifically:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Separate safe failures from dangerous ones.&lt;/strong&gt; "Wrong" is not one thing. An honest abstention and a confident error should never share a bucket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit the reasoning, not just the answer.&lt;/strong&gt; Right-for-the-wrong-reason and fabrication are invisible to label accuracy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep your injected evidence real.&lt;/strong&gt; If your eval fabricates inputs, it cannot credibly test the model for fabrication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calibrate before you conclude.&lt;/strong&gt; Small samples reverse. Hold your own analysis to the standard you hold the model.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The code is on GitHub: &lt;a href="https://github.com/gbadedata/clinvar-interpretation-benchmark" rel="noopener noreferrer"&gt;gbadedata/clinvar-interpretation-benchmark&lt;/a&gt;. 91 tests, runs offline against a mock with no API key, CI green. The live path is one flag.&lt;/p&gt;

&lt;p&gt;If you are building evaluation frameworks for models in medicine, law, finance, or any domain where a confident wrong answer is worse than an honest "I don't know," I would genuinely like to hear how you are drawing that line. That distinction, I am increasingly convinced, is where the real work of evaluation lives.&lt;/p&gt;

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

&lt;p&gt;All verified against the primary source.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Landrum MJ et al. (2018). ClinVar: improving access to variant interpretations and supporting evidence. &lt;em&gt;Nucleic Acids Research&lt;/em&gt; 46(D1):D1062-D1067. doi:10.1093/nar/gkx1153&lt;/li&gt;
&lt;li&gt;Richards S et al. (2015). Standards and guidelines for the interpretation of sequence variants (ACMG/AMP). &lt;em&gt;Genetics in Medicine&lt;/em&gt; 17(5):405-424. doi:10.1038/gim.2015.30&lt;/li&gt;
&lt;li&gt;Tavtigian SV et al. (2018). Modeling the ACMG/AMP variant classification guidelines as a Bayesian classification framework. &lt;em&gt;Genetics in Medicine&lt;/em&gt; 20(9):1054-1060. doi:10.1038/gim.2017.210&lt;/li&gt;
&lt;li&gt;Karczewski KJ et al. (2020). The mutational constraint spectrum quantified from variation in 141,456 humans (gnomAD). &lt;em&gt;Nature&lt;/em&gt; 581(7809):434-443. doi:10.1038/s41586-020-2308-7&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>machinelearning</category>
      <category>bioinformatics</category>
      <category>python</category>
      <category>ai</category>
    </item>
    <item>
      <title>Spatial Transcriptomics Plus Topological Data Analysis: A Complete End-to-End Tutorial with squidpy and GUDHI</title>
      <dc:creator>Oluwagbade Odimayo</dc:creator>
      <pubDate>Fri, 19 Jun 2026 23:00:55 +0000</pubDate>
      <link>https://dev.to/gbadedata/spatial-transcriptomics-plus-topological-data-analysis-a-complete-end-to-end-tutorial-with-squidpy-1np2</link>
      <guid>https://dev.to/gbadedata/spatial-transcriptomics-plus-topological-data-analysis-a-complete-end-to-end-tutorial-with-squidpy-1np2</guid>
      <description>&lt;p&gt;&lt;em&gt;&lt;em&gt;Build a real spatial transcriptomics pipeline on Visium mouse brain, then go one step past every tutorial: use persistent homology to find spatial structure that Moran's I misses. Every number here is reproducible from a fresh clone.&lt;/em&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Most spatial transcriptomics tutorials end at the same place. You load a Visium dataset, run Moran's I, colour a few genes on the tissue, and admire the picture. You never find out whether the standard spatial statistic actually captured everything that matters.&lt;/p&gt;

&lt;p&gt;This tutorial goes further. Complete spatial pipeline was built with &lt;strong&gt;squidpy&lt;/strong&gt;, then add a second lens that almost no tutorial covers: &lt;strong&gt;topological data analysis&lt;/strong&gt; with &lt;strong&gt;GUDHI&lt;/strong&gt;. By the end you will have a working benchmark that names a specific gene whose spatial structure Moran's I underestimates, and you will understand exactly what that claim does and does not mean.&lt;/p&gt;




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

&lt;p&gt;Three tasks, each answering a question a real spatial analysis should ask:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Spatially variable gene detection.&lt;/strong&gt; Does Moran's I recover the genes we know are spatially patterned?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Neighbourhood enrichment.&lt;/strong&gt; Do anatomically adjacent brain regions actually sit together in the data?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TDA versus Moran's I.&lt;/strong&gt; Does persistent homology see structure that autocorrelation misses?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The dataset is the canonical &lt;strong&gt;V1 Adult Mouse Brain Visium&lt;/strong&gt; section: 2,688 spots, 15 annotated anatomical regions, about 18,000 genes.&lt;/p&gt;

&lt;p&gt;A quick note on what each task is &lt;em&gt;for&lt;/em&gt;. Tasks 1 and 2 are validation: they prove the pipeline reproduces known biology, so you can trust it. Task 3 is the contribution: it shows the standard method has a blind spot and that topology fills it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install &lt;/span&gt;squidpy gudhi scanpy anndata scipy matplotlib pandas
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A note on versions: this tutorial targets &lt;strong&gt;squidpy 1.8.x&lt;/strong&gt;, which renamed several parameters from older releases. If you copy from a 2022 era tutorial you will hit errors. I flag each one inline.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Load the data
&lt;/h2&gt;

&lt;p&gt;squidpy ships the dataset, so there is nothing to download manually:&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;squidpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;sq&lt;/span&gt;

&lt;span class="n"&gt;adata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;datasets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;visium_hne_adata&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;adata&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# AnnData object with n_obs x n_vars = 2688 x 18078
#   obs: 'cluster'        (15 anatomical region labels)
#   obsm: 'spatial'       (x, y coordinates on the tissue)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things make this &lt;em&gt;spatial&lt;/em&gt; data. &lt;code&gt;obsm['spatial']&lt;/code&gt; holds the physical coordinates of each spot on the tissue slide, and &lt;code&gt;obs['cluster']&lt;/code&gt; holds the manually annotated brain region for each spot. Those region labels are our ground truth in Tasks 1 and 2.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Preprocess and build the spatial graph
&lt;/h2&gt;

&lt;p&gt;Standard scanpy preprocessing, with one critical addition at the end, the spatial neighbourhood graph.&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;scanpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;sc&lt;/span&gt;

&lt;span class="c1"&gt;# Filter, normalise, log transform
&lt;/span&gt;&lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter_genes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_cells&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;adata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;layers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;counts&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;adata&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="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize_total&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_sum&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1e4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log1p&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adata&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;adata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;layers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;log_norm&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;adata&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="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# keep a clean log norm copy
&lt;/span&gt;
&lt;span class="c1"&gt;# Standard embedding
&lt;/span&gt;&lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;highly_variable_genes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_top_genes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;sc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pca&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_comps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# The spatial graph. This is what makes squidpy "spatial"
&lt;/span&gt;&lt;span class="n"&gt;sq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spatial_neighbors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;coord_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grid&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_rings&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;&lt;strong&gt;squidpy 1.8.x gotcha number 1:&lt;/strong&gt; older tutorials use &lt;code&gt;coord_type="visium"&lt;/code&gt;. That value no longer exists. For Visium's hexagonal array you now use &lt;code&gt;coord_type="grid"&lt;/code&gt; with &lt;code&gt;n_rings=1&lt;/code&gt;, which connects each spot to its roughly six immediate neighbours.&lt;/p&gt;

&lt;p&gt;After this you have about 15,580 edges, roughly 5.8 neighbours per interior spot. That spatial graph is the foundation for both Moran's I and neighbourhood enrichment.&lt;/p&gt;

&lt;p&gt;Keeping the &lt;code&gt;log_norm&lt;/code&gt; layer matters later: the TDA step reads expression from it, so we want a clean copy taken before any scaling.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Task 1, spatially variable genes with Moran's I
&lt;/h2&gt;

&lt;p&gt;Moran's I asks one question per gene: do spatially adjacent spots have similar expression? It returns a value roughly between 0 and 1. High Moran's I means the gene's expression is spatially organised rather than scattered at random.&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;sq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spatial_autocorr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;adata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;moran&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;genes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;adata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;var_names&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tolist&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;n_perms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;corr_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;fdr_bh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;# gotcha number 2: was "correction" pre 1.8
&lt;/span&gt;    &lt;span class="n"&gt;show_progress_bar&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="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;moran&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;adata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uns&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;moranI&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ascending&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="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;moran&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;head&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="c1"&gt;#          I        pval_norm   pval_norm_fdr_bh
# Mbp      0.788    ...
# Slc17a7  0.775    ...
# Nrgn     0.743    ...
# Cck      0.727    ...
# Itpka    0.698    ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;squidpy 1.8.x gotcha number 2:&lt;/strong&gt; the false discovery rate parameter is now &lt;code&gt;corr_method&lt;/code&gt;, not &lt;code&gt;correction&lt;/code&gt;. Results land in &lt;code&gt;adata.uns["moranI"]&lt;/code&gt; with columns &lt;code&gt;I&lt;/code&gt;, &lt;code&gt;pval_norm&lt;/code&gt;, and &lt;code&gt;pval_norm_fdr_bh&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The top gene is &lt;strong&gt;Mbp&lt;/strong&gt; at I = 0.788, a myelin marker sharply concentrated in the white matter fibre tract. That is exactly what we expect. The sharpest spatial domain in the section ranks first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Now validate it against known biology.&lt;/strong&gt; A high Moran's I is only convincing if the genes it promotes are genuinely the ones biology says are spatially patterned. I use a curated panel of Allen Brain Atlas region markers as an oracle. Here is the complete panel, so you can reproduce the exact number:&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;oracle_markers&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;Layer 1&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Reln&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;Ndnf&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;Cxcl14&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;Layer 2/3&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cux1&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;Cux2&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;Calb1&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;Layer 4&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Rorb&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;Scnn1a&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;Rspo1&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;Layer 5&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bcl11b&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;Fezf2&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;Etv1&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;Layer 6&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ntsr1&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;Syt6&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;Ctgf&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;White matter&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mbp&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;Mog&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;Plp1&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;Hippocampus&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Prox1&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;Dkk3&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;C1ql2&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="c1"&gt;# Flatten, keep only markers that survived HVG filtering
&lt;/span&gt;&lt;span class="n"&gt;all_markers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;grp&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;oracle_markers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&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;g&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;grp&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;tested&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;all_markers&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;adata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;var_names&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# 17 of 21 survive
&lt;/span&gt;&lt;span class="n"&gt;top_500&lt;/span&gt; &lt;span class="o"&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;moran&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;head&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&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="n"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;m&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="n"&gt;tested&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;top_500&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;sensitivity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tested&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;tested=&lt;/span&gt;&lt;span class="si"&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;tested&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  found=&lt;/span&gt;&lt;span class="si"&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;found&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  sensitivity=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;sensitivity&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;# tested=17  found=7  sensitivity=0.41
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sensitivity is &lt;strong&gt;0.41&lt;/strong&gt;: seven of the seventeen testable markers appear in the top 500 SVGs. The seven it finds are &lt;code&gt;Mbp&lt;/code&gt;, &lt;code&gt;Mog&lt;/code&gt;, &lt;code&gt;Plp1&lt;/code&gt; (white matter), &lt;code&gt;Dkk3&lt;/code&gt; (hippocampus), and the strong cortical markers &lt;code&gt;Calb1&lt;/code&gt;, &lt;code&gt;Fezf2&lt;/code&gt;, &lt;code&gt;C1ql2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Which markers does it miss, and why? This is the important part. Print their ranks:&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;missed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;m&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="n"&gt;tested&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&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;found&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;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;missed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;moran&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;get_loc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&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="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; rank &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;moran&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;   I=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;moran&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;I&lt;/span&gt;&lt;span class="sh"&gt;'&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="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# Cux2       rank   948   I=0.191
# Reln       rank   989   I=0.181
# Rorb       rank   996   I=0.179
# ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The missed markers are smooth, low amplitude cortical layer gradients. &lt;code&gt;Cux2&lt;/code&gt; (rank 948), &lt;code&gt;Reln&lt;/code&gt; (989), and &lt;code&gt;Rorb&lt;/code&gt; (996) are real layer markers, but their expression changes gradually across the cortex rather than forming a sharp boundary.&lt;/p&gt;

&lt;p&gt;This is not a bug. &lt;strong&gt;Moran's I ranks sharp concentrated domains above broad gradients&lt;/strong&gt;, because a smooth gradient has lower local spot to spot contrast. Hold that thought. It is the entire reason for Step 5.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Task 2, neighbourhood enrichment
&lt;/h2&gt;

&lt;p&gt;Do brain regions that are anatomically adjacent actually co-localise in the data? squidpy runs a permutation test on the spatial graph:&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;sq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nhood_enrichment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cluster_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cluster&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;show_progress_bar&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;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;adata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uns&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cluster_nhood_enrichment&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;zscore&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# 15 x 15 z score matrix
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For every pair of regions, this returns a z score: how much more adjacent they are than you would expect by chance, given the size of each region. A z score above about 1 is genuine enrichment; a negative z score means the two regions are &lt;em&gt;more separated&lt;/em&gt; than chance.&lt;/p&gt;

&lt;p&gt;I validate five pairs that real neuroanatomy says should co-localise:&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;expected_pairs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&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;Hippocampus&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;Pyramidal_layer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;                 &lt;span class="c1"&gt;# z = 24.9
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cortex_4&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;Cortex_5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;                         &lt;span class="c1"&gt;# z = 14.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;Hippocampus&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;Pyramidal_layer_dentate_gyrus&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# z = 13.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;Lateral_ventricle&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;Striatum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;                        &lt;span class="c1"&gt;# z =  5.1
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Fiber_tract&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;Lateral_ventricle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;               &lt;span class="c1"&gt;# z =  5.0
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All five enrich strongly. The hippocampal complex lights up at z = 24.9.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A trap worth knowing about.&lt;/strong&gt; My first attempt validated pairs like &lt;code&gt;Thalamus_1&lt;/code&gt; plus &lt;code&gt;Thalamus_2&lt;/code&gt;, assuming the shared name meant they sit together. They do not. Their z score is about minus 3.8. Those numbered subclusters are transcriptionally distinct territories in &lt;em&gt;separate&lt;/em&gt; parts of the tissue. They are spatially segregated, not adjacent.&lt;/p&gt;

&lt;p&gt;The lesson is concrete: &lt;strong&gt;spatial adjacency and statistical neighbourhood enrichment are different things.&lt;/strong&gt; Trust the enrichment matrix, not the cluster names. I corrected the validated pairs to reflect true anatomy, and the matrix confirmed every one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Task 3, where TDA sees what Moran's I cannot
&lt;/h2&gt;

&lt;p&gt;Here is the step no tutorial covers, and the reason this project exists.&lt;/p&gt;

&lt;p&gt;Moran's I is a single number measuring &lt;em&gt;local pairwise similarity&lt;/em&gt;. It is blind to the &lt;em&gt;shape&lt;/em&gt; of an expression pattern. A gene whose expression forms a ring, or a reticular multi focal network, has rich spatial structure that pairwise correlation barely registers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Persistent homology&lt;/strong&gt; measures shape. Before the code, the one paragraph of theory you actually need:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Imagine each expressing spot as a point on the tissue. Now grow a disc of radius &lt;em&gt;r&lt;/em&gt; around every point and let the discs merge as &lt;em&gt;r&lt;/em&gt; increases. At small &lt;em&gt;r&lt;/em&gt; you have many separate blobs; as &lt;em&gt;r&lt;/em&gt; grows they connect, and sometimes a ring of points encloses an empty region before finally filling in. Persistent homology records, across every value of &lt;em&gt;r&lt;/em&gt; at once, how many connected pieces exist (called &lt;strong&gt;H0&lt;/strong&gt;) and how many enclosed loops exist (called &lt;strong&gt;H1&lt;/strong&gt;), and crucially &lt;em&gt;how long each one survives&lt;/em&gt; as &lt;em&gt;r&lt;/em&gt; grows. A loop that persists over a wide range of &lt;em&gt;r&lt;/em&gt; is a real, robust ring in the data. A loop that appears and vanishes immediately is noise. That construction, discs growing over a point cloud, is a &lt;strong&gt;Vietoris Rips filtration&lt;/strong&gt;, and GUDHI computes it for us.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Build a point cloud per gene
&lt;/h3&gt;

&lt;p&gt;For each gene, take the spots where it is expressed and treat their coordinates as a point cloud:&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;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;gudhi&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;gd&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;gene_point_cloud&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gene&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_points&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;250&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;adata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;var_names&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_loc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gene&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;adata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;layers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;log_norm&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;expr&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;asarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;todense&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;ravel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;hasattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;todense&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;expr&lt;/span&gt;

    &lt;span class="n"&gt;mask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;expr&lt;/span&gt; &lt;span class="o"&gt;&amp;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;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expr&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;expr&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;# spots expressing above median
&lt;/span&gt;    &lt;span class="n"&gt;coords&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;adata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;obsm&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spatial&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;mask&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;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Subsample dense clouds. Critical for memory, see below
&lt;/span&gt;    &lt;span class="k"&gt;if&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;coords&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_points&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;rng&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="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default_rng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;coords&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;coords&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&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;coords&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;max_points&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;replace&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="c1"&gt;# Normalise to the unit square so the filtration scale is comparable across genes
&lt;/span&gt;    &lt;span class="n"&gt;coords&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;coords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&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;coords&lt;/span&gt; &lt;span class="o"&gt;/=&lt;/span&gt; &lt;span class="n"&gt;coords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&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="nf"&gt;clip&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;1e-9&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;coords&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Memory gotcha number 3:&lt;/strong&gt; a Vietoris Rips complex grows roughly &lt;em&gt;cubically&lt;/em&gt; with point count. A gene expressed in 800 spots generates tens of millions of simplices and gets killed by the operating system's out of memory killer. Subsampling to 250 points keeps the topology intact while bounding memory. Because the final analysis uses &lt;em&gt;ranks&lt;/em&gt; rather than raw values, it is robust to the subsample.&lt;/p&gt;

&lt;h3&gt;
  
  
  Compute persistence and summarise it
&lt;/h3&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;h1_entropy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;coords&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_edge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_persistence&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="n"&gt;rips&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RipsComplex&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="n"&gt;coords&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_edge_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;max_edge&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;st&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rips&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_simplex_tree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_dimension&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;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compute_persistence&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;# Keep H1 (loops) that survive longer than the noise floor
&lt;/span&gt;    &lt;span class="n"&gt;h1&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;d&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;dim&lt;/span&gt;&lt;span class="p"&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;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;persistence&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;dim&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&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="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;min_persistence&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;h1&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;lifetimes&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;array&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;for&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;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;h1&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;lifetimes&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;lifetimes&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&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;p&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;p&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;# persistence entropy
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Persistence entropy is one scalar summarising the whole H1 diagram: it is high when a gene has &lt;em&gt;many loops of comparable importance&lt;/em&gt; and low when it has few loops or one dominant one. It is a reasonable single number for "topological complexity", though it is not the only choice, and I will name its limits below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Threshold gotcha number 4, the one that nearly fooled me.&lt;/strong&gt; My first run returned H1 entropy of exactly &lt;code&gt;0.0&lt;/code&gt; for &lt;em&gt;every&lt;/em&gt; gene, including the most structured. The output was clean and uniform, and it was tempting to accept.&lt;/p&gt;

&lt;p&gt;It was completely wrong. GUDHI was finding more than twenty genuine loops in &lt;code&gt;Mbp&lt;/code&gt;, but my &lt;code&gt;min_persistence=0.1&lt;/code&gt; filter discarded all of them. Spatial loops in unit normalised point clouds are &lt;em&gt;short lived&lt;/em&gt;: they live in the 0.01 to 0.1 persistence band. Lowering the threshold to &lt;strong&gt;0.01&lt;/strong&gt; recovered them.&lt;/p&gt;

&lt;p&gt;The takeaway is an instinct worth more than the fix: &lt;strong&gt;distrust a result that looks too clean.&lt;/strong&gt; Zero complexity on a visibly structured dataset is a red flag, not a pass.&lt;/p&gt;

&lt;h3&gt;
  
  
  Compare the two rankings
&lt;/h3&gt;

&lt;p&gt;Rank a panel of genes both ways, by Moran's I and by H1 entropy, then look for disagreement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;

&lt;span class="n"&gt;panel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;moran&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;head&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;   &lt;span class="c1"&gt;# mix of high, mid, low Moran's I genes
&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="ow"&gt;in&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;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gene&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;morans_i&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;moran&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I&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;h1_entropy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;h1_entropy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;gene_point_cloud&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)),&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="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;set_index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gene&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;morans_rank&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;morans_i&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ascending&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;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;tda_rank&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;h1_entropy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ascending&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;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;divergence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;morans_rank&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;tda_rank&lt;/span&gt;&lt;span class="sh"&gt;"&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;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;divergence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ascending&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="nf"&gt;head&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your output will look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;          morans_i  h1_entropy  morans_rank  tda_rank  divergence
gene
Apbb2        0.087       3.340          8.0       1.0         7.0
Gpr17        0.087       3.154          6.0       2.0         4.0
Mbp          0.788       3.035          1.0       8.0         7.0
Cck          0.727       2.905          4.0       9.0         5.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The result, stated precisely
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Apbb2&lt;/strong&gt; is the standout. Moran's I ranks it around tenth in the wider gene set; by H1 persistence entropy it ranks &lt;strong&gt;first&lt;/strong&gt;, the highest topological complexity in the panel. &lt;strong&gt;Gpr17&lt;/strong&gt; shows the same direction of divergence. The mirror image holds for &lt;strong&gt;Mbp&lt;/strong&gt;: Moran's rank 1, the sharpest domain in the whole section, but only TDA rank 8, because a single solid blob has strong autocorrelation and &lt;em&gt;simple&lt;/em&gt; topology.&lt;/p&gt;

&lt;p&gt;Here is the honest framing, because it is what a careful reviewer will probe. What I have measured is that Apbb2 has a high H1 persistence entropy signal, and that this signal disagrees with its Moran's I rank. That is a real, reproducible methodological result. What I have &lt;em&gt;not&lt;/em&gt; done is confirm by independent histology that Apbb2 forms a literal anatomical ring. The topology is evidence of loop like structure in the expressing spots, not proof of a biological annulus. Stating that boundary precisely is part of the result, not a hedge.&lt;/p&gt;

&lt;p&gt;One more honest caveat. The H1 entropy values across the panel cluster fairly tightly, mostly between about 2.9 and 3.3. The &lt;em&gt;ranking&lt;/em&gt; is stable and reproducible, but the absolute separation between genes is modest. The correct reading is "the two methods order genes differently, with Apbb2 the clearest case", not "TDA found a dramatic outlier Moran's I completely missed". The honest version is the more defensible one.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the figures show
&lt;/h2&gt;

&lt;p&gt;The persistence diagram for Apbb2 makes the construction concrete. One H0 component spans the full filtration, the single connected tissue domain, while the rest die near the diagonal. Thirty two H1 loops sit in the 0.05 to 0.2 band, exactly the short lived features the threshold fix recovered. If you ever see an empty H1 diagram on real tissue, you have a threshold bug, not a structureless gene.&lt;/p&gt;

&lt;p&gt;The Moran versus TDA scatter places each gene by its two ranks. Points on the diagonal are genes the two methods agree about. Apbb2 and Gpr17 sit well below the diagonal, low Moran's rank but high TDA rank. That visible distance from the diagonal &lt;em&gt;is&lt;/em&gt; the finding.&lt;/p&gt;




&lt;h2&gt;
  
  
  The five gotchas, collected
&lt;/h2&gt;

&lt;p&gt;If you build this yourself, these will save you hours:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;coord_type="grid"&lt;/code&gt;, not &lt;code&gt;"visium"&lt;/code&gt; (squidpy 1.8.x)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;corr_method=&lt;/code&gt;, not &lt;code&gt;correction=&lt;/code&gt; (squidpy 1.8.x)&lt;/li&gt;
&lt;li&gt;Subsample dense point clouds before the Rips complex, or run out of memory&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;min_persistence&lt;/code&gt; low, around 0.01, because real spatial loops are short lived&lt;/li&gt;
&lt;li&gt;Validate neighbourhood pairs against the enrichment matrix, not against shared cluster names&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Why this matters beyond the tutorial
&lt;/h2&gt;

&lt;p&gt;Anyone can run Moran's I. The skill that separates a spatial genomics engineer from a tutorial follower is knowing the method's blind spots and reaching for the right complementary tool, then being precise about what the complement does and does not prove.&lt;/p&gt;

&lt;p&gt;Persistent homology is that complement for loop rich and multi focal expression patterns. It is not a replacement for Moran's I. It is a second lens that catches a different kind of structure, and now you have a working, validated, honestly caveated example that names a real gene.&lt;/p&gt;

&lt;p&gt;Full code: &lt;a href="https://github.com/gbadedata/squidpy-spatial-benchmark" rel="noopener noreferrer"&gt;github.com/gbadedata/squidpy-spatial-benchmark&lt;/a&gt;. Runtime is about 105 seconds end to end.&lt;/p&gt;

&lt;p&gt;The full pipeline, 135 tests, continuous integration, structured logging, and a unified JSON benchmark report, is on GitHub:&lt;/p&gt;

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




&lt;p&gt;&lt;em&gt;Building spatial transcriptomics tools, topological data analysis, or single cell evaluation frameworks? Connect on &lt;a href="https://github.com/gbadedata" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; or &lt;a href="https://www.linkedin.com/in/oluwagbade-odimayo-" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>spatialtranscriptomics</category>
      <category>tutorial</category>
      <category>bioinformatics</category>
      <category>singlecellevaluation</category>
    </item>
    <item>
      <title>A UMAP With Arrows Is Not a Benchmark. This Is</title>
      <dc:creator>Oluwagbade Odimayo</dc:creator>
      <pubDate>Tue, 16 Jun 2026 23:51:24 +0000</pubDate>
      <link>https://dev.to/gbadedata/a-umap-with-arrows-is-not-a-benchmark-this-is-1k7o</link>
      <guid>https://dev.to/gbadedata/a-umap-with-arrows-is-not-a-benchmark-this-is-1k7o</guid>
      <description>&lt;p&gt;&lt;em&gt;How I built a three-task evaluation framework for RNA velocity trajectory inference -- measuring global ordering, pairwise rank preservation, and robustness to missing branches on real pancreatic endocrinogenesis data.&lt;/em&gt; &lt;/p&gt;




&lt;p&gt;Most RNA velocity tutorials end the same way.&lt;/p&gt;

&lt;p&gt;Moments are computed. Velocities are fitted. A UMAP appears with arrows pointing vaguely in the right direction. The author says "and here we can see the trajectory from progenitors to terminal cell types" and closes the notebook.&lt;/p&gt;

&lt;p&gt;What they have produced is a picture. What they have not produced is evidence.&lt;/p&gt;

&lt;p&gt;This post is about building evidence. Specifically, a three-task benchmark framework that evaluates whether RNA velocity actually recovers the known developmental ordering of pancreatic endocrinogenesis and whether the signal holds up when the rarest cell population is surgically removed from the dataset.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the pipeline evaluates
&lt;/h2&gt;

&lt;p&gt;The dataset is pancreatic endocrinogenesis from Bastidas-Ponce et al. 2019 -- 3,696 cells captured at day 15.5 of mouse embryonic development. It is the canonical RNA velocity benchmark dataset, used in the Bergen et al. 2020 Nature Biotechnology paper introducing scVelo. The known developmental trajectory runs from Ductal progenitors through two Ngn3-expressing intermediate stages to four terminal hormone-producing cell types: Alpha, Beta, Delta, and Epsilon.&lt;/p&gt;

&lt;p&gt;The benchmark has three tasks:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Task 1&lt;/strong&gt; measures whether velocity pseudotime correlates with oracle diffusion pseudotime (Spearman rho). These are genuinely independent methods. Diffusion pseudotime uses the transcriptional similarity graph. RNA velocity uses the ratio of unspliced precursor to mature spliced mRNA. High correlation between two independent methods measuring the same biology is meaningful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Task 2&lt;/strong&gt; tests whether each consecutive developmental transition is statistically preserved using a one-sided Mann-Whitney U test. This catches cases where global rho is high but specific transitions are misordered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Task 3&lt;/strong&gt; removes all Epsilon cells (n=73) from the dataset, re-runs the velocity pipeline from scratch, and checks whether the remaining trajectory is still correctly recovered. A rho drop near zero means velocity is detecting global transcriptional momentum. A large drop would mean the pipeline was relying on the presence of the rare terminal state to establish directionality.&lt;/p&gt;




&lt;h2&gt;
  
  
  The oracle design
&lt;/h2&gt;

&lt;p&gt;The oracle is diffusion pseudotime computed by &lt;code&gt;sc.tl.dpt()&lt;/code&gt; rooted at Ductal cells during preprocessing. It is computed fresh -- not loaded from a stored file -- using an independent algorithm.&lt;/p&gt;

&lt;p&gt;This matters. If you evaluate RNA velocity pseudotime against a stored pseudotime that was itself computed by a velocity-adjacent method, you have circularity. The diffusion pseudotime oracle uses the transcriptional similarity graph, which has no knowledge of splicing kinetics. Evaluating a velocity prediction against a similarity-based oracle is a genuine independent test.&lt;/p&gt;

&lt;p&gt;The oracle table before running any velocity:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cell type&lt;/th&gt;
&lt;th&gt;Mean DPT (oracle)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ductal&lt;/td&gt;
&lt;td&gt;0.060&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ngn3 low EP&lt;/td&gt;
&lt;td&gt;0.076&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ngn3 high EP&lt;/td&gt;
&lt;td&gt;0.478&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pre-endocrine&lt;/td&gt;
&lt;td&gt;0.806&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Epsilon&lt;/td&gt;
&lt;td&gt;0.882&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delta&lt;/td&gt;
&lt;td&gt;0.900&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Beta&lt;/td&gt;
&lt;td&gt;0.904&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alpha&lt;/td&gt;
&lt;td&gt;0.905&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is the correct developmental ordering from the published biology. The oracle is valid.&lt;/p&gt;




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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Task 1 -- Trajectory Recovery (Spearman rho)
  rho = 0.8926  |  PASS

Task 2 -- Rank Preservation (Mann-Whitney U)
  5/7 pairs (71.4%)  |  FAIL

Task 3 -- Hidden Branch Recovery
  rho_full=0.8926  rho_masked=0.8897  drop=0.0029  |  ROBUST
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Task 1 rho of 0.8926 is directly comparable to published scVelo benchmarks on the same dataset. Task 3 rho drop of 0.0029 is the most compelling result: removing the rarest cell type causes essentially no degradation. The velocity signal is global.&lt;/p&gt;

&lt;p&gt;Task 2 failing at 71.4% is documented honestly. Here is the full pairwise table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pair&lt;/th&gt;
&lt;th&gt;Earlier mean vpt&lt;/th&gt;
&lt;th&gt;Later mean vpt&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ductal vs Ngn3 low EP&lt;/td&gt;
&lt;td&gt;0.170&lt;/td&gt;
&lt;td&gt;0.233&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ngn3 low EP vs Ngn3 high EP&lt;/td&gt;
&lt;td&gt;0.233&lt;/td&gt;
&lt;td&gt;0.688&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ngn3 high EP vs Pre-endocrine&lt;/td&gt;
&lt;td&gt;0.688&lt;/td&gt;
&lt;td&gt;0.920&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pre-endocrine vs Alpha&lt;/td&gt;
&lt;td&gt;0.920&lt;/td&gt;
&lt;td&gt;0.928&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pre-endocrine vs Beta&lt;/td&gt;
&lt;td&gt;0.920&lt;/td&gt;
&lt;td&gt;0.959&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pre-endocrine vs Delta&lt;/td&gt;
&lt;td&gt;0.920&lt;/td&gt;
&lt;td&gt;0.839&lt;/td&gt;
&lt;td&gt;FAIL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pre-endocrine vs Epsilon&lt;/td&gt;
&lt;td&gt;0.920&lt;/td&gt;
&lt;td&gt;0.891&lt;/td&gt;
&lt;td&gt;FAIL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The linear progenitor axis is perfectly ordered. The two failures are the two rarest terminal cell types: Delta (approximately 168 cells) and Epsilon (73 cells). Sparse populations produce fewer edges in the velocity graph, reducing the directional signal available to velocity pseudotime. This is a known limitation, and the benchmark surfaced it precisely.&lt;/p&gt;




&lt;h2&gt;
  
  
  The branching topology correction that almost nobody gets right
&lt;/h2&gt;

&lt;p&gt;The initial benchmark design listed the known ordering as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ductal -&amp;gt; Ngn3 low EP -&amp;gt; Ngn3 high EP -&amp;gt; Pre-endocrine -&amp;gt; Beta -&amp;gt; Alpha -&amp;gt; Delta -&amp;gt; Epsilon
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Task 2 tested consecutive pairs: Beta &amp;lt; Alpha, Alpha &amp;lt; Delta, and so on.&lt;/p&gt;

&lt;p&gt;This is biologically wrong.&lt;/p&gt;

&lt;p&gt;Alpha, Beta, Delta, and Epsilon are not sequential fates. They are four parallel terminal lineages that branch simultaneously from Pre-endocrine. There is no defined ordering among them -- a Beta cell does not come after an Alpha cell. They are siblings, not parent and child.&lt;/p&gt;

&lt;p&gt;Testing Beta &amp;lt; Alpha imposes a false linear structure on a biological fan-out. The benchmark was finding "failures" that were not failures at all -- they were the correct result of a mis-specified test.&lt;/p&gt;

&lt;p&gt;The fix: test only the linear trunk (Ductal through Pre-endocrine) and each terminal fate separately against their common progenitor (Pre-endocrine). Never test terminal fates against each other.&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;# Wrong: imposes false linear ordering
&lt;/span&gt;&lt;span class="n"&gt;known_ordering&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;Ductal&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;Ngn3 low EP&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;Ngn3 high EP&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;Pre-endocrine&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;Beta&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;Alpha&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;Delta&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;Epsilon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# Correct: respects the branching topology
&lt;/span&gt;&lt;span class="n"&gt;known_ordering&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;Ductal&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;Ngn3 low EP&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;Ngn3 high EP&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;Pre-endocrine&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;Alpha&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;terminal_pairs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&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;Pre-endocrine&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;Alpha&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Pre-endocrine&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;Beta&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Pre-endocrine&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;Delta&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Pre-endocrine&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;Epsilon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This distinction -- between a linear trajectory and a branching fan-out -- is what separates someone who understands developmental biology from someone who has run a tutorial.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three engineering failures worth knowing
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The stochastic model fails with NumPy 2.x
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;scv.tl.velocity(mode='stochastic')&lt;/code&gt; raised &lt;code&gt;ValueError: setting an array element with a sequence&lt;/code&gt; on both synthetic fixtures and the real pancreas data.&lt;/p&gt;

&lt;p&gt;The root cause is in scvelo's &lt;code&gt;leastsq_generalized&lt;/code&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="n"&gt;gamma&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="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="n"&gt;linalg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pinv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;dot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In NumPy 2.x, &lt;code&gt;pinv(A).dot(b)&lt;/code&gt; returns a 1-element array rather than a scalar. Assigning a 1-element array into a pre-allocated &lt;code&gt;float32&lt;/code&gt; scalar slot raises &lt;code&gt;ValueError&lt;/code&gt;. This is a known upstream incompatibility (github.com/theislab/scvelo/issues/966).&lt;/p&gt;

&lt;p&gt;The fix: &lt;code&gt;mode='deterministic'&lt;/code&gt;. The deterministic steady-state model from LaManno et al. 2018 is the foundational method and produces correct trajectory ordering without triggering the NumPy 2.x incompatibility.&lt;/p&gt;

&lt;h3&gt;
  
  
  HVG selection after normalisation causes infinity errors
&lt;/h3&gt;

&lt;p&gt;Adding &lt;code&gt;sc.pp.highly_variable_genes&lt;/code&gt; after &lt;code&gt;scv.pp.normalize_per_cell&lt;/code&gt; raised &lt;code&gt;ValueError: cannot specify integer bins when input data contains infinity&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Root cause: some genes have near-zero total counts after per-cell normalisation. When pandas tries to bin gene expression levels for HVG dispersion computation, infinity values in the bin array trigger the error.&lt;/p&gt;

&lt;p&gt;Fix: remove the HVG step entirely. The scvelo workflow handles its own feature selection inside &lt;code&gt;scv.pp.moments&lt;/code&gt;. A separate HVG step before log1p is architecturally incorrect for the scvelo pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  dpt_pseudotime is not pre-stored in the pancreas h5ad
&lt;/h3&gt;

&lt;p&gt;The initial implementation required &lt;code&gt;dpt_pseudotime&lt;/code&gt; to exist in the loaded AnnData. It does not. The pancreas h5ad from scvelo's repository stores cell-type labels and raw counts, but not pseudotime.&lt;/p&gt;

&lt;p&gt;Fix: compute diffusion pseudotime during preprocessing using &lt;code&gt;sc.tl.dpt()&lt;/code&gt; rooted at Ductal cells. This is actually a better design -- the oracle is computed fresh from the data, not loaded from a file that might have been computed by a different pipeline version.&lt;/p&gt;




&lt;h2&gt;
  
  
  The synthetic fixture that validates the benchmark
&lt;/h2&gt;

&lt;p&gt;Unit tests for a velocity benchmark cannot download real data on every run. The solution is a biologically grounded synthetic fixture that embeds a genuine velocity 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="c1"&gt;# Velocity genes: unspliced leads spliced along the trajectory axis
# This is the precursor-product relationship RNA velocity detects
&lt;/span&gt;&lt;span class="n"&gt;pseudotime&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="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="n"&gt;n_cells&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;spliced_velocity&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;outer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pseudotime&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;ones&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_velocity_genes&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;
&lt;span class="n"&gt;unspliced_velocity&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;outer&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;clip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pseudotime&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.15&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="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ones&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_velocity_genes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test &lt;code&gt;test_velocity_signal_in_early_cells&lt;/code&gt; verifies this works:&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;early_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;unspliced&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;early_mask&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;spliced&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;early_mask&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;late_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;unspliced&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;late_mask&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;spliced&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;late_mask&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="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;early_ratio&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;late_ratio&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Early trajectory cells have higher unspliced-to-spliced ratio than late cells. This is the biological constraint RNA velocity is built to detect. If the fixture does not satisfy it, the fixture is wrong. Having a test that verifies this means you can trust the downstream velocity tests.&lt;/p&gt;




&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dataset&lt;/td&gt;
&lt;td&gt;Pancreas endocrinogenesis (Bastidas-Ponce 2019)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cells&lt;/td&gt;
&lt;td&gt;3,696&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Velocity genes&lt;/td&gt;
&lt;td&gt;1,598&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Task 1 Spearman rho&lt;/td&gt;
&lt;td&gt;0.8926 PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Task 2 pairs passing&lt;/td&gt;
&lt;td&gt;5/7 (71.4%) FAIL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Task 3 rho drop&lt;/td&gt;
&lt;td&gt;0.0029 ROBUST&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests passing&lt;/td&gt;
&lt;td&gt;99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test coverage&lt;/td&gt;
&lt;td&gt;92%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pipeline runtime&lt;/td&gt;
&lt;td&gt;47.2 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What this project is actually about
&lt;/h2&gt;

&lt;p&gt;The rho of 0.8926 is a good number. But what the project demonstrates is something different: the ability to ask whether the number is meaningful.&lt;/p&gt;

&lt;p&gt;Anyone can run &lt;code&gt;scv.tl.velocity_pseudotime&lt;/code&gt; and get a number. The question that separates a bioinformatics engineer from a tutorial follower is: how do you know whether that number reflects real biology? The answer requires an independent oracle, a test that checks specific biologically defined transitions, a perturbation experiment, and honest documentation of what fails and why -- including cases like the terminal fates where the benchmark design itself needs correction because the biology is branching, not linear.&lt;/p&gt;

&lt;p&gt;The benchmark framework is the contribution. The rho is just the output.&lt;/p&gt;




&lt;h2&gt;
  
  
  Repository
&lt;/h2&gt;

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

&lt;p&gt;The README covers the full pipeline architecture, all four engineering challenges, the branching topology correction, and complete instructions for reproducing the results from a fresh clone.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Building and shipping bioinformatics, data engineering, or DevOps projects, trajectory inference benchmarks, or single-cell evaluation frameworks? Connect on &lt;a href="https://github.com/gbadedata" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; or &lt;a href="https://www.linkedin.com/in/oluwagbade-odimayo-" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>benchmark</category>
      <category>bioinformatics</category>
      <category>rna</category>
      <category>scientificsoftware</category>
    </item>
    <item>
      <title>Engineering CellFateBench: A Reproducible Python Benchmark for Single-Cell Genomics Reasoning</title>
      <dc:creator>Oluwagbade Odimayo</dc:creator>
      <pubDate>Tue, 16 Jun 2026 22:14:32 +0000</pubDate>
      <link>https://dev.to/gbadedata/engineering-cellfatebench-a-reproducible-python-benchmark-for-single-cell-genomics-reasoning-40ch</link>
      <guid>https://dev.to/gbadedata/engineering-cellfatebench-a-reproducible-python-benchmark-for-single-cell-genomics-reasoning-40ch</guid>
      <description>&lt;p&gt;CellFateBench is a scientific software and benchmark-engineering project for evaluating reasoning over single-cell genomics workflows.&lt;/p&gt;

&lt;p&gt;The project was designed around a practical question:&lt;/p&gt;

&lt;p&gt;How can single-cell analysis outputs be turned into reproducible benchmark tasks with public prompts, hidden answer keys, oracle outputs, scoring, calibration, Docker validation, and CI?&lt;/p&gt;

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

&lt;p&gt;Single-cell genomics workflows often produce outputs such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;clusters;&lt;/li&gt;
&lt;li&gt;embeddings;&lt;/li&gt;
&lt;li&gt;marker tables;&lt;/li&gt;
&lt;li&gt;pseudotime summaries;&lt;/li&gt;
&lt;li&gt;spatial patterns;&lt;/li&gt;
&lt;li&gt;topology summaries;&lt;/li&gt;
&lt;li&gt;RNA velocity layers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those outputs still require interpretation.&lt;/p&gt;

&lt;p&gt;A solver may need to decide which state is likely upstream, whether a branch is terminal, whether a spatial pattern is meaningful, whether a ring-like pattern supports a cyclic claim, or whether RNA velocity evidence is strong enough to support a directionality statement.&lt;/p&gt;

&lt;p&gt;CellFateBench focuses on that reasoning layer.&lt;/p&gt;

&lt;p&gt;It converts single-cell analysis contexts into structured benchmark assets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;public benchmark tasks;&lt;/li&gt;
&lt;li&gt;hidden answer keys;&lt;/li&gt;
&lt;li&gt;oracle outputs;&lt;/li&gt;
&lt;li&gt;deterministic validators;&lt;/li&gt;
&lt;li&gt;scoring outputs;&lt;/li&gt;
&lt;li&gt;calibration logs;&lt;/li&gt;
&lt;li&gt;difficulty rebalancing outputs;&lt;/li&gt;
&lt;li&gt;reproducible pipelines;&lt;/li&gt;
&lt;li&gt;Docker validation;&lt;/li&gt;
&lt;li&gt;GitHub Actions CI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The project is not a notebook-only analysis. It is structured as a reproducible scientific software repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  Repository architecture
&lt;/h2&gt;

&lt;p&gt;The repository is organised around a clear separation of source code, workflow scripts, tests, benchmark assets, documentation, and generated outputs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cellfatebench-single-cell-analysis/
├── benchmark_tasks/
│   ├── public/
│   ├── hidden/
│   ├── oracle_outputs/
│   └── calibration_logs/
├── configs/
├── data/
│   ├── raw/
│   ├── processed/
│   ├── reference/
│   └── synthetic/
├── docs/
├── results/
│   ├── figures/
│   ├── reports/
│   └── tables/
├── sample_solver_answers/
├── scripts/
├── src/cellfatebench/
├── tests/
├── Dockerfile
├── Makefile
├── environment.yml
└── README.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key design decision is that benchmark assets are explicit and inspectable.&lt;/p&gt;

&lt;p&gt;Public tasks are not mixed with hidden answers. Oracle outputs are separate. Scoring code is separate from task generation. Pipelines are exposed through Makefile commands.&lt;/p&gt;

&lt;p&gt;That structure makes the project easier to review, test, and extend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two benchmark layers: v1 and v2
&lt;/h2&gt;

&lt;p&gt;CellFateBench currently has two layers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;v1 controlled benchmark&lt;/td&gt;
&lt;td&gt;Synthetic single-cell data with known hidden truth for trajectory, spatial, and topology reasoning&lt;/td&gt;
&lt;td&gt;Complete&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v2 public RNA velocity extension&lt;/td&gt;
&lt;td&gt;Public scVelo pancreas dataset layer with RNA velocity reasoning tasks, solver evaluation, empirical calibration, and difficulty rebalancing&lt;/td&gt;
&lt;td&gt;Complete&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This design allows the project to balance two needs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;controlled hidden truth for deterministic scoring;&lt;/li&gt;
&lt;li&gt;public dataset context for biological realism.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  v1: controlled synthetic benchmark
&lt;/h2&gt;

&lt;p&gt;The v1 layer uses controlled synthetic single-cell data.&lt;/p&gt;

&lt;p&gt;This is important because benchmark scoring requires known answers. In many real datasets, the true biological state, lineage structure, or spatial domain assignment may be uncertain. Synthetic data allows the benchmark to define hidden truth and use that hidden truth for deterministic evaluation.&lt;/p&gt;

&lt;p&gt;The v1 dataset includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;900 synthetic cells;&lt;/li&gt;
&lt;li&gt;60 genes;&lt;/li&gt;
&lt;li&gt;designed root or progenitor state;&lt;/li&gt;
&lt;li&gt;transition state;&lt;/li&gt;
&lt;li&gt;terminal states;&lt;/li&gt;
&lt;li&gt;branch labels;&lt;/li&gt;
&lt;li&gt;pseudotime values;&lt;/li&gt;
&lt;li&gt;spatial coordinates;&lt;/li&gt;
&lt;li&gt;spatial domains;&lt;/li&gt;
&lt;li&gt;topology design.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Generated synthetic outputs include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;data/synthetic/synthetic_cell_metadata.csv
data/synthetic/synthetic_expression_matrix.csv
data/synthetic/synthetic_gene_metadata.csv
data/synthetic/synthetic_hidden_truth.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The synthetic hidden-truth file is central to the v1 benchmark. It allows tasks to be scored against known answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  v1 task families
&lt;/h2&gt;

&lt;p&gt;The v1 benchmark contains three task families.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Trajectory and pseudotime reasoning
&lt;/h3&gt;

&lt;p&gt;Files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;benchmark_tasks/public/trajectory_pseudotime_tasks.json
benchmark_tasks/hidden/trajectory_pseudotime_answers.json
benchmark_tasks/oracle_outputs/trajectory_pseudotime_oracle_outputs.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These tasks test reasoning about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;root-state inference;&lt;/li&gt;
&lt;li&gt;terminal-state inference;&lt;/li&gt;
&lt;li&gt;transition-state placement;&lt;/li&gt;
&lt;li&gt;early-to-late pseudotime ordering;&lt;/li&gt;
&lt;li&gt;masked terminal-state recovery.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Spatial pattern reasoning
&lt;/h3&gt;

&lt;p&gt;Files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;benchmark_tasks/public/spatial_pattern_tasks.json
benchmark_tasks/hidden/spatial_pattern_answers.json
benchmark_tasks/oracle_outputs/spatial_pattern_oracle_outputs.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These tasks test reasoning about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;spatially variable genes;&lt;/li&gt;
&lt;li&gt;domain-specific marker enrichment;&lt;/li&gt;
&lt;li&gt;masked spatial-domain recovery;&lt;/li&gt;
&lt;li&gt;unsupported spatial claims.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Topological persistence reasoning
&lt;/h3&gt;

&lt;p&gt;Files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;benchmark_tasks/public/topological_persistence_tasks.json
benchmark_tasks/hidden/topological_persistence_answers.json
benchmark_tasks/oracle_outputs/topological_persistence_oracle_outputs.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These tasks test reasoning about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;bifurcating structure;&lt;/li&gt;
&lt;li&gt;branch count;&lt;/li&gt;
&lt;li&gt;ring-like spatial signals;&lt;/li&gt;
&lt;li&gt;false-positive loop claims;&lt;/li&gt;
&lt;li&gt;the difference between spatial topology and cell-fate topology.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The topology layer uses GUDHI-based summaries to support topology-aware benchmark tasks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Public tasks, hidden answers, and oracle outputs
&lt;/h2&gt;

&lt;p&gt;A key benchmark-design pattern in CellFateBench is the separation between public prompts and hidden answers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;benchmark_tasks/public/
benchmark_tasks/hidden/
benchmark_tasks/oracle_outputs/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Public tasks are solver-facing.&lt;/p&gt;

&lt;p&gt;Hidden answers contain expected outputs and scoring-relevant evidence.&lt;/p&gt;

&lt;p&gt;Oracle outputs show reference-style answers with rationale, confidence, and supporting evidence.&lt;/p&gt;

&lt;p&gt;This structure helps prevent answer leakage and makes the benchmark easier to review.&lt;/p&gt;

&lt;p&gt;A simplified benchmark structure looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public task
    |
    | visible to solver
    v
solver answer
    |
    | compared privately
    v
hidden answer key
    |
    | scored by validators
    v
score report
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Oracle outputs provide a human-readable reference, but they are not used as public prompts.&lt;/p&gt;

&lt;h2&gt;
  
  
  v2: public RNA velocity extension
&lt;/h2&gt;

&lt;p&gt;The v2 layer adds a public RNA velocity benchmark extension using the scVelo pancreas dataset.&lt;/p&gt;

&lt;p&gt;The dataset preparation validates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;3,696 cells;&lt;/li&gt;
&lt;li&gt;27,998 genes;&lt;/li&gt;
&lt;li&gt;spliced RNA velocity layer;&lt;/li&gt;
&lt;li&gt;unspliced RNA velocity layer;&lt;/li&gt;
&lt;li&gt;cluster annotation column;&lt;/li&gt;
&lt;li&gt;8 annotation groups.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The project deliberately avoids committing the large raw H5AD file. Instead, it loads the public dataset through code and writes lightweight derived outputs.&lt;/p&gt;

&lt;p&gt;Generated v2 dataset outputs include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;results/tables/velocity_dataset_summary.csv
results/tables/velocity_layer_summary.csv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The local public H5AD file is ignored through &lt;code&gt;.gitignore&lt;/code&gt;, which protects the repository from accidental large-file commits.&lt;/p&gt;

&lt;p&gt;That is important for a scientific software repo: data can be reproducible without being committed directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  RNA velocity tasks
&lt;/h2&gt;

&lt;p&gt;The v2 task generator creates RNA velocity reasoning tasks.&lt;/p&gt;

&lt;p&gt;Files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;benchmark_tasks/public/velocity_reasoning_tasks.json
benchmark_tasks/hidden/velocity_reasoning_answers.json
benchmark_tasks/oracle_outputs/velocity_reasoning_oracle_outputs.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The current v2 benchmark includes six velocity reasoning tasks:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task focus&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Root direction inference&lt;/td&gt;
&lt;td&gt;Reason about upstream or progenitor-like states&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Terminal fate support&lt;/td&gt;
&lt;td&gt;Identify terminal endocrine fate evidence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contradiction detection&lt;/td&gt;
&lt;td&gt;Reject reversed or unsupported differentiation claims&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latent-time-style ordering&lt;/td&gt;
&lt;td&gt;Reason about early-to-late ordering&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Low-confidence failure mode&lt;/td&gt;
&lt;td&gt;Avoid overclaiming from incomplete evidence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marker and velocity alignment&lt;/td&gt;
&lt;td&gt;Combine marker and velocity-style evidence&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The v2 layer is careful not to claim more than it computes.&lt;/p&gt;

&lt;p&gt;It validates spliced and unspliced layers and creates benchmark reasoning tasks around RNA velocity context. It does not yet claim to compute a full scVelo velocity graph inside the benchmark pipeline.&lt;/p&gt;

&lt;p&gt;That limitation is documented in the README and limitations file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoring design
&lt;/h2&gt;

&lt;p&gt;CellFateBench uses transparent scoring.&lt;/p&gt;

&lt;p&gt;The v1 scoring layer supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;expected answer matching;&lt;/li&gt;
&lt;li&gt;Boolean claim correctness;&lt;/li&gt;
&lt;li&gt;required evidence-term coverage;&lt;/li&gt;
&lt;li&gt;confidence field presence;&lt;/li&gt;
&lt;li&gt;partial credit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The v2 velocity scoring layer adds behaviour-focused scoring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;correctness;&lt;/li&gt;
&lt;li&gt;evidence support;&lt;/li&gt;
&lt;li&gt;uncertainty discipline;&lt;/li&gt;
&lt;li&gt;no-overclaim discipline;&lt;/li&gt;
&lt;li&gt;penalty for unsupported precision claims.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is important because a scientific answer is not only judged by the final label. A solver can provide the correct answer with weak evidence. Another solver can make a plausible claim but overstate confidence.&lt;/p&gt;

&lt;p&gt;The v2 scoring layer makes those behaviours visible.&lt;/p&gt;

&lt;p&gt;Velocity solver evaluation is implemented in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/cellfatebench/velocity_solver_evaluation.py
scripts/13_evaluate_velocity_solvers.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generated outputs include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;results/tables/velocity_solver_performance_summary.csv
results/tables/velocity_task_performance_summary.csv
results/figures/velocity_solver_score_by_profile.png
results/figures/velocity_task_pass_rate.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Calibration and difficulty rebalancing
&lt;/h2&gt;

&lt;p&gt;CellFateBench includes calibration assets because a benchmark should not only generate questions. It should also review task difficulty and likely failure modes.&lt;/p&gt;

&lt;p&gt;The v1 layer includes design-stage calibration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/cellfatebench/calibration.py
benchmark_tasks/calibration_logs/design_stage_calibration_log.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The v2 layer adds empirical sample-solver calibration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/cellfatebench/velocity_calibration.py
scripts/14_generate_velocity_calibration.py
benchmark_tasks/calibration_logs/empirical_velocity_calibration_log.json
results/tables/velocity_task_difficulty_rebalanced.csv
results/figures/velocity_task_difficulty_rebalance.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This calibration is intentionally scoped.&lt;/p&gt;

&lt;p&gt;It is based on local sample solver profiles. It does not claim frontier-model calibration. That would require running the benchmark against actual frontier models or expert human solvers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Makefile workflow
&lt;/h2&gt;

&lt;p&gt;The project exposes core workflows through a Makefile.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;make test
make pipeline
make pipeline-v2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Other useful commands include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;make dataset
make trajectory
make spatial
make topology-summary
make topology-tasks
make calibration
make score
make velocity-data
make velocity-tasks
make velocity-evaluate
make velocity-calibration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives reviewers and future contributors stable commands rather than asking them to run internal Python modules manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the project locally
&lt;/h2&gt;

&lt;p&gt;Clone the repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/gbadedata/cellfatebench-single-cell-analysis.git
&lt;span class="nb"&gt;cd &lt;/span&gt;cellfatebench-single-cell-analysis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;conda &lt;span class="nb"&gt;env &lt;/span&gt;create &lt;span class="nt"&gt;-f&lt;/span&gt; environment.yml
conda activate cellfatebench
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the tests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the v1 pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make pipeline
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the v2 pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make pipeline-v2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;57 passed
CellFateBench full pipeline completed.
CellFateBench v2 public RNA velocity pipeline completed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The public scVelo pancreas dataset may emit AnnData old-format warnings during tests. These warnings come from the upstream dataset format and do not indicate failure of CellFateBench logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docker validation
&lt;/h2&gt;

&lt;p&gt;The repository includes Docker support.&lt;/p&gt;

&lt;p&gt;Build the image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; cellfatebench:latest &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the test suite inside Docker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; cellfatebench:latest make &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the v1 pipeline inside Docker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; cellfatebench:latest make pipeline
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the v2 pipeline inside Docker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; cellfatebench:latest make pipeline-v2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This validates that the benchmark can run in a clean container environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub Actions CI
&lt;/h2&gt;

&lt;p&gt;The project is validated through GitHub Actions.&lt;/p&gt;

&lt;p&gt;CI checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;test suite;&lt;/li&gt;
&lt;li&gt;v1 pipeline;&lt;/li&gt;
&lt;li&gt;v2 pipeline;&lt;/li&gt;
&lt;li&gt;expected output files;&lt;/li&gt;
&lt;li&gt;Docker build;&lt;/li&gt;
&lt;li&gt;Docker test execution;&lt;/li&gt;
&lt;li&gt;Docker v1 pipeline;&lt;/li&gt;
&lt;li&gt;Docker v2 pipeline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Workflow evidence is available here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/gbadedata/cellfatebench-single-cell-analysis/actions" rel="noopener noreferrer"&gt;GitHub Actions CI&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The CI badge is visible at the top of the README.&lt;/p&gt;

&lt;h2&gt;
  
  
  Documentation structure
&lt;/h2&gt;

&lt;p&gt;The documentation layer includes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;README.md
docs/methods.md
docs/evidence_map.md
docs/reviewer_guide.md
docs/limitations.md
docs/project_design.md
docs/v2_velocity_extension_plan.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each document has a specific role.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Document&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;README&lt;/td&gt;
&lt;td&gt;Main technical landing page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;methods.md&lt;/td&gt;
&lt;td&gt;Scientific and benchmark methodology&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;evidence_map.md&lt;/td&gt;
&lt;td&gt;Maps claims to files and outputs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;reviewer_guide.md&lt;/td&gt;
&lt;td&gt;Helps reviewers inspect the project efficiently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;limitations.md&lt;/td&gt;
&lt;td&gt;Documents boundaries and non-claims&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;project_design.md&lt;/td&gt;
&lt;td&gt;Captures design rationale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v2_velocity_extension_plan.md&lt;/td&gt;
&lt;td&gt;Captures v2 extension planning&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This documentation structure is part of the engineering work. It makes the repository easier to evaluate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was validated
&lt;/h2&gt;

&lt;p&gt;At the time of this write-up, the project had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;57 passing tests;&lt;/li&gt;
&lt;li&gt;v1 pipeline passing;&lt;/li&gt;
&lt;li&gt;v2 pipeline passing;&lt;/li&gt;
&lt;li&gt;Docker validation in CI;&lt;/li&gt;
&lt;li&gt;GitHub Actions passing on &lt;code&gt;main&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;README upgraded with badges;&lt;/li&gt;
&lt;li&gt;methods, evidence map, reviewer guide, and limitations updated for v2;&lt;/li&gt;
&lt;li&gt;large public H5AD file ignored and protected from accidental commit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the difference between a script and a reviewable scientific software project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key engineering decisions
&lt;/h2&gt;

&lt;p&gt;Several engineering decisions helped make the project more robust.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Public and hidden assets are separated
&lt;/h3&gt;

&lt;p&gt;Public benchmark tasks do not contain hidden answer fields.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Large public datasets are not committed
&lt;/h3&gt;

&lt;p&gt;The public H5AD file is loaded through code and ignored locally.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Pipelines are exposed through Makefile targets
&lt;/h3&gt;

&lt;p&gt;This makes execution easier for reviewers.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. CI validates more than tests
&lt;/h3&gt;

&lt;p&gt;The workflow validates tests, pipelines, expected outputs, and Docker execution.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Limitations are documented
&lt;/h3&gt;

&lt;p&gt;The project does not overclaim clinical validity, biological discovery, frontier-model calibration, or full RNA velocity graph computation.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. The benchmark is designed for extension
&lt;/h3&gt;

&lt;p&gt;The current structure can support future task families such as full velocity graphs, latent time, spatial neighbourhood reasoning, multi-omic tasks, and expert calibration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Future improvements
&lt;/h2&gt;

&lt;p&gt;Planned improvements include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;full scVelo velocity graph computation;&lt;/li&gt;
&lt;li&gt;latent-time summaries;&lt;/li&gt;
&lt;li&gt;gene-level velocity confidence summaries;&lt;/li&gt;
&lt;li&gt;real solver submissions;&lt;/li&gt;
&lt;li&gt;frontier-model calibration;&lt;/li&gt;
&lt;li&gt;human expert calibration;&lt;/li&gt;
&lt;li&gt;semantic answer matching;&lt;/li&gt;
&lt;li&gt;richer task-specific rubrics;&lt;/li&gt;
&lt;li&gt;expanded public datasets;&lt;/li&gt;
&lt;li&gt;spatial-neighbourhood reasoning;&lt;/li&gt;
&lt;li&gt;multi-omic benchmark tasks;&lt;/li&gt;
&lt;li&gt;release versioning;&lt;/li&gt;
&lt;li&gt;optional dashboard or web review interface.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;CellFateBench was built to show that single-cell genomics benchmarking can go beyond outputs.&lt;/p&gt;

&lt;p&gt;The project asks whether a solver can reason from evidence, separate supported claims from unsupported ones, report uncertainty, and avoid overclaiming.&lt;/p&gt;

&lt;p&gt;That is why the project is positioned as:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A benchmark-engineering project for the reasoning layer of single-cell genomics.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Explore the repository:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/gbadedata/cellfatebench-single-cell-analysis" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read the README:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/gbadedata/cellfatebench-single-cell-analysis#readme" rel="noopener noreferrer"&gt;Project README&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Review CI validation:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/gbadedata/cellfatebench-single-cell-analysis/actions" rel="noopener noreferrer"&gt;GitHub Actions CI&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read the flagship Medium article:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://medium.com/@gbadedata/beyond-clusters-and-plots-building-cellfatebench-a-benchmark-for-single-cell-genomics-reasoning-6d82771de6a0" rel="noopener noreferrer"&gt;Medium article&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bioinformatics</category>
      <category>genomics</category>
      <category>benchmark</category>
      <category>python</category>
    </item>
  </channel>
</rss>
