<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Machine Learning - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Machine Learning - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Wed, 19 Aug 2026 19:07:52 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/machine-learning/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ Product Experimentation at Scale: How Airbnb, Netflix, Lyft, and Uber run Causal Inference on LLM-Based AI Features ]]>
                </title>
                <description>
                    <![CDATA[ Causal inference for LLM-based AI features is no longer theoretical. Airbnb, Netflix, Lyft, and Uber have published detailed engineering blog posts describing exactly how they measure the causal impac ]]>
                </description>
                <link>https://www.freecodecamp.org/news/causal-inference-at-scale-with-case-studies/</link>
                <guid isPermaLink="false">6a7b522a304c202420dfd496</guid>
                
                    <category>
                        <![CDATA[ product experimentation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ experimentation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ causal inference ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ netflix ]]>
                    </category>
                
                    <category>
                        <![CDATA[ airbnb ]]>
                    </category>
                
                    <category>
                        <![CDATA[ lyft ]]>
                    </category>
                
                    <category>
                        <![CDATA[ uber ]]>
                    </category>
                
                    <category>
                        <![CDATA[ causality ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Rudrendu Paul ]]>
                </dc:creator>
                <pubDate>Tue, 11 Aug 2026 16:47:38 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/2d445aeb-4ed9-40c4-9c91-c6e701a1325a.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Causal inference for LLM-based AI features is no longer theoretical. Airbnb, Netflix, Lyft, and Uber have published detailed engineering blog posts describing exactly how they measure the causal impact of product changes on user behavior.</p>
<p>The techniques they name (difference-in-differences, regression discontinuity, and doubly robust estimation, among others) are standard tools.</p>
<p>What's interesting is how those teams operationalized them at scale: where the methods failed in production, what they built around each one to make the estimates trustworthy, and how they connected the numbers to actual product decisions.</p>
<p>If you're building LLM features and making product decisions based on thumbs-up rates and session length, these posts will change how you think about measurement.</p>
<p>Most teams still measure feature impact with 30-day A/B tests and thumbs-up rates. That approach works until you need to know whether the metric moved because of your feature or because of a dozen other things that happened the same week.</p>
<p>The four teams below ran into that problem before most teams were even building with LLMs, and the patterns they settled on are worth understanding before you make the same mistakes. I've watched teams spend weeks shipping a feature, then spend additional weeks arguing about whether the numbers are real. That's avoidable.</p>
<p>For these organizations, causal measurement isn't an afterthought but a foundational element of product experimentation, integrated directly into their deployment architectures. The synthesis presented in this article details a comprehensive toolkit for AI product experiments in which traditional A/B testing is incompatible with the deployment model.</p>
<p>Whether you're managing global model transitions, threshold-based routing, staged rollouts, or observational opt-in data, each scenario necessitates a specific methodological approach. Failing to utilize this toolkit leads to more than just ambiguity. It results in product decisions driven by confounded data, a situation far more damaging than having no measurements at all.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-why-production-ai-measurement-is-harder-than-it-looks">Why Production AI Measurement is Harder Than it Looks</a></p>
</li>
<li><p><a href="#heading-case-study-1-airbnbs-future-value-framework">Case Study 1: Airbnb's Future Value Framework</a></p>
<ul>
<li><p><a href="#heading-short-term-ab-tests-miss-the-behavioral-change-that-matters">Short-Term A/B Tests Miss the Behavioral Change That Matters</a></p>
</li>
<li><p><a href="#heading-the-framework">The Framework</a></p>
</li>
<li><p><a href="#heading-reference-implementation">Reference Implementation</a></p>
</li>
<li><p><a href="#heading-instrumenting-for-long-term-value-cuts-experiments-that-look-good-in-week-2-and-fail-in-month-4">Instrumenting for Long-Term Value Cuts Experiments That Look Good in Week 2 and Fail in Month 4</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-case-study-2-netflixs-quasi-experiment-taxonomy">Case Study 2: Netflix's Quasi-Experiment Taxonomy</a></p>
<ul>
<li><p><a href="#heading-deployment-structure-determines-the-method">Deployment Structure Determines the Method</a></p>
</li>
<li><p><a href="#heading-reference-implementation">Reference Implementation</a></p>
</li>
<li><p><a href="#heading-pick-the-wrong-method-and-cleaner-data-wont-save-you">Pick the Wrong Method and Cleaner Data Won't Save You</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-case-study-3-lyfts-doubly-robust-validation">Case Study 3: Lyft's Doubly Robust Validation</a></p>
<ul>
<li><p><a href="#heading-why-single-model-approaches-fail-in-production">Why Single-Model Approaches Fail in Production</a></p>
</li>
<li><p><a href="#heading-lyfts-production-diagnostics-catch-model-failure-before-it-reaches-a-decision">Lyft's Production Diagnostics Catch Model Failure Before it Reaches a Decision</a></p>
</li>
<li><p><a href="#heading-reference-implementation">Reference Implementation</a></p>
</li>
<li><p><a href="#heading-two-hours-of-diagnostics-prevent-a-quarter-of-misdirected-engineering-work">Two Hours of Diagnostics Prevent a Quarter of Misdirected Engineering Work</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-case-study-4-ubers-causal-forecasting-pipeline">Case Study 4: Uber's Causal Forecasting Pipeline</a></p>
<ul>
<li><p><a href="#heading-merging-causal-estimates-with-forecasts">Merging Causal Estimates with Forecasts</a></p>
</li>
<li><p><a href="#heading-reference-implementation">Reference Implementation</a></p>
</li>
<li><p><a href="#heading-causal-forecasting-in-capacity-planning">Causal Forecasting in Capacity Planning</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-these-four-teams-have-in-common">What These Four Teams Have in Common</a></p>
<ul>
<li><p><a href="#heading-match-the-method-to-the-deployment-structure">Match the Method to the Deployment Structure</a></p>
</li>
<li><p><a href="#heading-build-diagnostics-before-building-estimators">Build Diagnostics Before Building Estimators</a></p>
</li>
<li><p><a href="#heading-design-every-causal-estimate-around-a-specific-product-decision">Design Every Causal Estimate Around a Specific Product Decision</a></p>
</li>
<li><p><a href="#heading-document-failure-modes-alongside-every-estimate">Document Failure Modes Alongside Every Estimate</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-start-applying-this-in-your-own-llm-stack">How to Start Applying This in Your Own LLM Stack</a></p>
<ul>
<li><p><a href="#heading-1-instrument-before-you-need-the-data">1. Instrument Before You Need the Data</a></p>
</li>
<li><p><a href="#heading-2-classify-your-deployment-mechanisms">2. Classify Your Deployment Mechanisms</a></p>
</li>
<li><p><a href="#heading-3-run-one-diagnostic-rich-causal-analysis">3. Run One Diagnostic-Rich Causal Analysis</a></p>
</li>
<li><p><a href="#heading-4-separate-short-term-and-long-term-metrics">4. Separate Short-term and Long-term Metrics</a></p>
</li>
<li><p><a href="#heading-5-make-causal-estimates-forward-looking">5. Make Causal Estimates Forward-Looking</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-when-production-causal-pipelines-break">When Production Causal Pipelines Break</a></p>
<ul>
<li><p><a href="#heading-organizational-failures">Organizational Failures</a></p>
</li>
<li><p><a href="#heading-technical-failures">Technical Failures</a></p>
</li>
<li><p><a href="#heading-interpretive-failures">Interpretive Failures</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-bootstrap-confidence-intervals">Bootstrap Confidence Intervals</a></p>
</li>
<li><p><a href="#heading-run-the-notebook-then-instrument-your-next-feature">Run the Notebook, Then Instrument Your Next Feature</a></p>
</li>
</ul>
<p>Every code block in this article runs end-to-end in the companion notebook at <a href="https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/13_case_studies/"><code>product-experimentation-causal-inference-genai-llm/tree/main/13_case_studies/</code></a>. Notebook: <code>case_studies_demo.ipynb</code>.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You need:</p>
<ul>
<li><p>Python 3.11 or newer</p>
</li>
<li><p>Comfort with pandas, scikit-learn, and basic regression</p>
</li>
<li><p>No prior reading on causal inference methods required: each case study explains the technique inline</p>
</li>
</ul>
<p>Install the packages for this article:</p>
<pre><code class="language-bash">pip install numpy pandas scikit-learn scipy matplotlib
</code></pre>
<p>Clone the companion repo and generate the shared dataset:</p>
<pre><code class="language-bash">git clone https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm.git
cd product-experimentation-causal-inference-genai-llm
python data/generate_data.py --seed 42 --n-users 50000 --out data/synthetic_llm_logs.csv
</code></pre>
<p>All four case-study code blocks in this article load that file with <code>pd.read_csv("data/synthetic_llm_logs.csv")</code>. The dataset has 50,000 rows and 16 columns covering user identity, session behavior, and model metadata, including <code>user_id</code>, <code>session_minutes</code>, <code>task_completed</code>, <code>model_used</code>, <code>latency_ms</code>, and <code>query_complexity</code>, among others.</p>
<h2 id="heading-why-production-ai-measurement-is-harder-than-it-looks">Why Production AI Measurement is Harder Than it Looks</h2>
<p>The standard story about measuring the impact of an AI feature goes like this: run an A/B test and report the lift. If your p-value is below 0.05, you ship. But this story breaks in three places.</p>
<p>First, randomization isn't always available. Enterprise SaaS products roll out AI features to workspaces in waves, bypassing the individual user coin flip that A/B testing assumes. Consumer products roll out features gradually by region, by cohort, or by platform. Safety-sensitive features ship to a subset of users whose risk profiles clear a threshold.</p>
<p>When randomization doesn't happen, A/B test logic fails. You can't just run the same analysis on non-randomized data and expect the estimate to mean anything. Confounders that correlate with both who receives the feature and how they behave will bias every coefficient you compute, often in the direction that flatters the feature.</p>
<p>Second, short-term metrics don't always predict long-term value. A prompt change that raises thumbs-up ratings by 8 points today might increase user dependence on the AI assistant in ways that cause churn three months out. A model routing change that improves task completion this week might degrade under a new query distribution emerging next quarter.</p>
<p>I initially presumed that short-term proxies would reliably mirror long-term trends, yet they fail to do so consistently. The limitation of short-term A/B testing lies in its focus on immediate metric shifts while remaining oblivious to downstream user behavioral changes, which are ultimately the most critical factors.</p>
<p>Finally, observational data is unavoidable. A/B testing covers a narrow slice of product decisions. The routing threshold change that shipped six months ago, the model vintage swap in Q3, or the users who opted into agent mode before the gate closed: none of these can be run as experiments after the fact.</p>
<p>For any question that requires looking backward, or any system with routing decisions that can't ethically be randomized, you're working from observational logs, with no experiment design to fall back on.</p>
<p>Observational causal inference isn't a fallback. It's a core competency, and teams that treat it as optional find out the hard way when a stakeholder asks why the numbers from last quarter's rollout don't hold up to scrutiny.</p>
<p>Each of the four teams below built systems that grapple with one or more of these three problems.</p>
<h2 id="heading-case-study-1-airbnbs-future-value-framework">Case Study 1: Airbnb's Future Value Framework</h2>
<h3 id="heading-short-term-ab-tests-miss-the-behavioral-change-that-matters">Short-Term A/B Tests Miss the Behavioral Change That Matters</h3>
<p>Airbnb's engineering team, as described by Jenny Chen in the Airbnb Tech Blog post <a href="https://medium.com/airbnb-engineering/how-airbnb-measures-future-value-to-standardize-tradeoffs-3aa99a941ba5">"How Airbnb Measures Future Value to Standardize Tradeoffs"</a>, ran into a fundamental problem with their experiment infrastructure. Standard A/B tests measure outcomes at the end of the experiment window, typically 14 to 30 days.</p>
<p>For marketplace features that affect user behavior over months and years, that window is too short. A feature that moves 30-day bookings upward might be accelerating behavior the user was going to exhibit anyway, pulling forward demand, or genuinely adding new long-term engagement. The 30-day metric can't tell these apart.</p>
<p>The LLM version of this is the assistant dependence problem. A prompt redesign that makes your AI assistant more concise and confident will typically immediately raise thumbs-up ratings and task completion rates. Users prefer confident, direct answers. But if the redesign also makes users less likely to verify answers independently, you may have improved the short-term experience at the cost of calibration and long-term trust.</p>
<p>By the time users start churning because the assistant gave them confident wrong answers twice, the prompt change is long-shipped, and its connection to the churn signal is invisible. I've seen this gap cost teams months of diagnostic work trying to untangle prompt changes from model updates from seasonal behavior.</p>
<h3 id="heading-the-framework">The Framework</h3>
<p>You don't need to wait for long-term outcomes to arrive. You need to have estimated, from prior cohorts, which short-term signals reliably predict long-term retention and revenue. Airbnb's solution converts short-term signals into projected long-term value using a predictive model trained on that historical relationship.</p>
<p>In their context, the metric is a "future value" score that estimates a user's long-term booking contribution based on their current engagement pattern. Once you have that model, you can evaluate any experiment by its expected impact on future value, with the 30-day metric as one of several inputs. The experiment window stays short, and the evaluation horizon extends as far as your predictive model can reach.</p>
<p>The DiD step in the reference implementation requires one identifying assumption: parallel pre-treatment trends. Before the feature shipped, both cohorts must have been on equivalent behavioral trajectories. If wave 1 users were already trending toward higher retention independently of the feature, the DiD estimate mixes the feature effect with a pre-existing difference between the waves. The assumption is that most teams skip validating because it requires plotting pre-period trends, which takes 20 minutes and feels unnecessary until the results don't make sense.</p>
<p>For LLM teams, the equivalent requires two things. First, you need leading indicators of long-term user value: week-7 retention and return query rate. Second, you need historical data linking those leading indicators to long-term outcomes you actually care about (revenue and user lifetime). The linking model is trained once on historical cohorts and then applied to new experiments.</p>
<h3 id="heading-reference-implementation">Reference Implementation</h3>
<p>The code below shows the structural pattern: compute a future-value proxy for each user from short-term signals, then use it as the outcome in a DiD or IPW analysis, replacing the immediate task-completion signal.</p>
<pre><code class="language-python">import pandas as pd
import numpy as np
from sklearn.linear_model import LinearRegression

# Synthetic LLM telemetry with retention signal
df = pd.read_csv("data/synthetic_llm_logs.csv")

# Step 1: Train the future-value proxy model on a historical cohort.
# In production this model is trained on users old enough that
# their long-term outcome (e.g., 90-day retained revenue) is known.
historical = df[df.signup_week &lt; 10].copy()

feature_cols = ["task_completed", "thumbs_up", "session_minutes"]
X_hist = historical[feature_cols].fillna(0)
y_hist = historical["retained_7d"].values  # 7-day retention as long-term proxy

fv_model = LinearRegression().fit(X_hist, y_hist)
# R² computed on training data; use a holdout cohort in production
print("Future-value model R²:", round(fv_model.score(X_hist, y_hist), 3))

# Step 2: Score all users with the future-value proxy.
X_all = df[feature_cols].fillna(0)
df["future_value_score"] = fv_model.predict(X_all)

# Step 3: Compare future_value_score by wave (this is the real experiment outcome).
print("\nMean future-value score by wave:")
print(df.groupby("wave").future_value_score.mean().round(4))

# Step 4: The DiD effect on future value (rather than on task_completed).
# This is where you would plug future_value_score into your DiD regression.
analysis = df[df.signup_week &lt; 30].copy()
analysis["post"] = (analysis.signup_week &gt;= 20).astype(int)
analysis["treated"] = (analysis.wave == 1).astype(int)

cells = analysis.groupby(["treated", "post"]).future_value_score.mean()
did_fv = (
    (cells.loc[(1, 1)] - cells.loc[(1, 0)])
    - (cells.loc[(0, 1)] - cells.loc[(0, 0)])
)
print(f"\nDiD effect on future-value score: {did_fv:+.4f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Future-value model R²: 0.024

Mean future-value score by wave:
wave
1    0.6325
2    0.6271
Name: future_value_score, dtype: float64

DiD effect on future-value score: +0.0059
</code></pre>
<p>Here's what's happening: you train a lightweight linear model on a historical cohort where long-term outcomes are already known, mapping observable short-term signals to 7-day retention as a proxy for future value.</p>
<p>You score all users with that model, then use the future value score as the outcome in a standard DiD. Seven-day retention is an imperfect proxy, but it forces the analysis to weight short-term engagement by its historical correlation with durable value, which is more than thumbs-up rate does.</p>
<p>The low R² value of 0.024 is intentional, as it highlights the inherent noise when linking immediate session data to 7-day retention. While production systems should ideally utilize signals with higher predictive power such as return-visit rates or query depth, even a less precise linking model can still provide value.</p>
<p>The primary objective is to establish the correct direction of the correction rather than achieve absolute precision.</p>
<h3 id="heading-instrumenting-for-long-term-value-cuts-experiments-that-look-good-in-week-2-and-fail-in-month-4">Instrumenting for Long-Term Value Cuts Experiments That Look Good in Week 2 and Fail in Month 4</h3>
<p>The Airbnb framework is a direct response to the measurement horizon problem. When you evaluate AI features on 30-day or 14-day windows, you reward features that move users fast, regardless of where they're moving.</p>
<p>Instrumenting for leading indicators of long-term value doesn't require a longer experiment. It requires a richer measurement model. Teams that have built this capability run fewer experiments that look great in week 2 and disappoint in month 4.</p>
<p>If a linking model isn't yet part of your infrastructure, developing one should be your immediate priority over expanding your evaluation dashboards.</p>
<h2 id="heading-case-study-2-netflixs-quasi-experiment-taxonomy">Case Study 2: Netflix's Quasi-Experiment Taxonomy</h2>
<h3 id="heading-deployment-structure-determines-the-method">Deployment Structure Determines the Method</h3>
<p>The Netflix Technology Blog post <a href="https://netflixtechblog.com/key-challenges-with-quasi-experiments-at-netflix-89b4f234b852">"Key Challenges with Quasi Experiments at Netflix"</a> is one of the more practically useful pieces on causal inference for product teams. Its core contribution is a taxonomy: for each deployment scenario, there's a corresponding causal method, and the post names the identifying assumption and failure mode that go with it.</p>
<p>That framing matters because most teams don't pick methods based on deployment structure. They pick what they already know, which is often the wrong fit.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69cc82ffe4688e4edd796adb/1cdf81be-3631-45fc-8295-0306cc53983b.png" alt="Method-selection map with four rows, one per case-study team: Airbnb (blue, staged rollout, parallel pre-treatment trends, DiD), Uber (red, threshold-gated routing, without any manipulation of the running variable, RDD), Netflix (green, full-population upgrade, good pre-period fit, Synthetic Control), Lyft (orange, opt-in observational, unconfoundedness, IPW/AIPW). Each row connects deployment scenario to identifying assumption to causal method via arrows." style="display:block;margin:0 auto" width="2740" height="1599" loading="lazy">

<p><em>Figure 1: Deployment structure determines which identification strategy is credible. Threshold routing systems call for RDD, while opt-in analyses call for propensity methods. The assignment mechanism drives the choice, with the team's preferred estimator coming second.</em></p>
<p>Netflix's taxonomy covers four scenarios that map almost exactly to the situations LLM teams encounter:</p>
<p><strong>Staged rollouts</strong> (their scenario: gradual market entry) map to difference-in-differences. When you ship an AI feature to workspace cohort A before cohort B, you've got a natural treated and control group across time. The identification strategy subtracts the shared time trend from the difference in outcomes.</p>
<p>The critical assumption is that the two cohorts have parallel pre-treatment trends. If one cohort was already trending up before treatment started, the method can't distinguish that from a real effect.</p>
<p><strong>Threshold-based routing</strong> (their scenario: geographic score cutoffs) maps to regression discontinuity. When a continuous score determines which model or feature a user receives, users just below and just above the threshold are nearly identical in everything except the treatment.</p>
<p>The jump at the cutoff identifies the local average treatment effect (LATE): the causal effect for users near the threshold only, with the average treatment effect across all users outside its scope. The critical assumption is that users can't precisely manipulate the score.</p>
<p><strong>Full-population upgrades</strong> (their scenario: platform-wide policy changes) map to the synthetic control design. When every user gets the new model at once, and there's no holdout group, you construct a weighted combination of historical or synthetic counterfactuals to estimate what would have happened without the upgrade.</p>
<p>The critical assumption is that the synthetic control fits the pre-treatment period well. Poor pre-period fit isn't a minor inconvenience. It invalidates the entire counterfactual.</p>
<p><strong>Matched comparisons</strong> (their scenario: opt-in feature adoption) map to propensity score methods. When users self-select into AI features, you reweight or re-match the comparison group to approximate random assignment on observables.</p>
<p>The critical assumption is that all relevant confounders are observed. If users who opt in also tend to be power users in ways you haven't measured, your confounder adjustment is incomplete, and your estimate is biased in ways that are hard to detect after the fact.</p>
<p>The taxonomy makes method selection a structured lookup: describe your deployment structure, and find the method whose assumptions your setup most plausibly satisfies.</p>
<p>I've seen teams skip this step and spend two weeks running a DiD on data that was clearly a threshold routing problem. The estimates differed by 40%. Neither was wrong. They were answering different questions.</p>
<h3 id="heading-reference-implementation">Reference Implementation</h3>
<p>The code below implements the taxonomy as a decision function: given a deployment scenario description, print the appropriate method and its key assumption.</p>
<pre><code class="language-python">TAXONOMY = {
    "staged_rollout": {
        "method": "Difference-in-Differences (DiD)",
        "assumption": "Parallel pre-treatment trends between treated and control cohorts",
        "check": "Plot weekly means by cohort before treatment starts; "
                 "run pre-trend placebo regression",
        "failure_mode": "Non-parallel pre-trends, time-varying confounders, "
                        "staggered adoption without Callaway-Sant'Anna correction",
    },
    "threshold_routing": {
        "method": "Regression Discontinuity Design (RDD)",
        "assumption": "Users cannot precisely manipulate their score across the cutoff",
        "check": "McCrary density test; bandwidth sensitivity; "
                 "quadratic spec robustness",
        "failure_mode": "Score manipulation, other policies firing at same cutoff, "
                        "extrapolation bias away from the cutoff",
    },
    "full_population_upgrade": {
        "method": "Synthetic Control",
        "assumption": "Pre-treatment fit between actual and synthetic counterfactual is good",
        "check": "In-time placebo tests; in-space placebo tests; "
                 "plot pre-period fit",
        "failure_mode": "Poor pre-period fit, interference between donor units, "
                        "post-treatment structural breaks",
    },
    "opt_in_feature": {
        "method": "Propensity Score Methods (IPW / Matching)",
        "assumption": "All confounders that drive opt-in and affect outcome are observed",
        "check": "Standardized mean difference before and after weighting; "
                 "propensity overlap histogram",
        "failure_mode": "Unmeasured confounders, positivity violations, "
                        "propensity model misspecification",
    },
}

def select_method(scenario: str) -&gt; None:
    if scenario not in TAXONOMY:
        valid = ", ".join(TAXONOMY.keys())
        print(f"Unknown scenario. Valid options: {valid}")
        return
    entry = TAXONOMY[scenario]
    print(f"Scenario:      {scenario}")
    print(f"Method:        {entry['method']}")
    print(f"Assumption:    {entry['assumption']}")
    print(f"Key checks:    {entry['check']}")
    print(f"Failure modes: {entry['failure_mode']}")

# Example: staged AI feature rollout across enterprise workspaces
select_method("staged_rollout")
print()
# Example: confidence-threshold routing between model tiers
select_method("threshold_routing")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Scenario:      staged_rollout
Method:        Difference-in-Differences (DiD)
Assumption:    Parallel pre-treatment trends between treated and control cohorts
Key checks:    Plot weekly means by cohort before treatment starts; run pre-trend placebo regression
Failure modes: Non-parallel pre-trends, time-varying confounders, staggered adoption without Callaway-Sant'Anna correction

Scenario:      threshold_routing
Method:        Regression Discontinuity Design (RDD)
Assumption:    Users cannot precisely manipulate their score across the cutoff
Key checks:    McCrary density test; bandwidth sensitivity; quadratic spec robustness
Failure modes: Score manipulation, other policies firing at same cutoff, extrapolation bias away from the cutoff
</code></pre>
<p>Each deployment scenario has a corresponding method, a main identifying assumption, the diagnostics that check whether the assumption holds, and the failure modes that invalidate the analysis.</p>
<p>The function is a decision aid that makes the method-selection step explicit, so the team agrees on the identification strategy before writing a single line of regression code. Without that agreement, you'll often discover mid-analysis that two people on the team were implicitly running different causal models on the same data.</p>
<h3 id="heading-pick-the-wrong-method-and-cleaner-data-wont-save-you">Pick the Wrong Method and Cleaner Data Won't Save You</h3>
<p>Most teams pick the causal method they know best. That's the wrong heuristic, and the Netflix taxonomy exists precisely to short-circuit it.</p>
<p>An LLM team with DiD experience will reach for DiD even when they're running a threshold routing system where RDD would give a cleaner answer, and a defensible local treatment effect estimate rather than an averaged-out guess.</p>
<p>The taxonomy highlights a vital principle: the method of selection is determined by the assignment mechanism itself, rather than by the team's familiarity. If your assignment mechanism is a cutoff score, RDD is the first tool to try, regardless of what the team already knows how to run.</p>
<p>Getting this wrong doesn't just produce a noisier estimate. It produces a structurally invalid one that cleaner data won't fix.</p>
<h2 id="heading-case-study-3-lyfts-doubly-robust-validation">Case Study 3: Lyft's Doubly Robust Validation</h2>
<h3 id="heading-why-single-model-approaches-fail-in-production">Why Single-Model Approaches Fail in Production</h3>
<p>Shima Nassiri's post on the Lyft Engineering blog, <a href="https://eng.lyft.com/trusting-the-untestable-validation-and-diagnostics-for-the-doubly-robust-models-00853df009df">"Trusting the Untestable: Validation and Diagnostics for Doubly Robust Models"</a>, starts from a practical observation: in most real production causal analyses, at least one of your nuisance models carries specification error.</p>
<p>When you run an observational causal analysis, you're almost always fitting two models: a propensity model (predicting treatment from covariates) and an outcome model (predicting the outcome from treatment and covariates).</p>
<p>Both models are approximations of unknown true functions. If either one is wrong in ways you haven't accounted for, your causal estimate is biased, and you won't know it from the standard output alone.</p>
<p>Doubly robust estimation, specifically the augmented inverse probability weighting estimator (AIPW), is the response to this. AIPW combines propensity weighting with regression adjustment: if either the propensity model or the outcome model is correctly specified, the AIPW estimate is consistent. One well-specified model is enough.</p>
<p>That said, AIPW offers no protection against unmeasured confounders, and it still requires unconfoundedness: all factors that affect both treatment assignment and the outcome must be observed and included in the model. If a key confounder isn't in your data, AIPW can't save you.</p>
<p>Nassiri's post goes further than the estimator itself. What makes it practically important is the diagnostic toolkit it describes for validating observational analyses before you act on them.</p>
<p>In a clean randomized experiment, you check balance and run power calculations. In an observational study, you have to work harder, because the design carries no randomization guarantee. I've seen teams skip this diagnostic step and then spend weeks explaining why their causal estimate was off by a factor of two.</p>
<h3 id="heading-lyfts-production-diagnostics-catch-model-failure-before-it-reaches-a-decision">Lyft's Production Diagnostics Catch Model Failure Before it Reaches a Decision</h3>
<p>The pipeline runs four checks:</p>
<h4 id="heading-1-weight-distribution-check">1. Weight distribution check</h4>
<p>After fitting the propensity model, plot the distribution of IPW weights. Extreme weights, say, above 20 or 30, signal that some users have near-zero propensity, which violates the positivity assumption: every unit must have nonzero probability of both treatment and control assignment.</p>
<p>Those users lack a comparable counterfactual, and letting a single unusual observation dominate your causal conclusion undermines the analysis. Skipping this check is how a single power user with unusual behavior skews an ATE by 15 percentage points.</p>
<h4 id="heading-2-trim-threshold">2. Trim threshold</h4>
<p>Set a maximum weight. Any observation whose weight exceeds the trim threshold is downweighted to the threshold value. Common choices are the 95th or 99th percentile of the weight distribution.</p>
<p>Trimming trades a small amount of bias for a large reduction in variance, making the estimate more stable under minor model misspecification. If you don't trim, you're letting the weirdest edge cases in your data drive the headline number.</p>
<h4 id="heading-3-covariate-balance-plots">3. Covariate balance plots</h4>
<p>Plot standardized mean differences before and after weighting for every covariate in the propensity model. The target is |SMD| &lt; 0.1 after weighting.</p>
<p>Covariates still above that threshold after weighting indicate that the propensity model is missing that covariate's influence on treatment assignment. This is the check that catches the "but we adjusted for everything" blind spot.</p>
<h4 id="heading-4-placebo-outcome-test">4. Placebo outcome test</h4>
<p>Take an outcome that your treatment provably doesn't cause, for example, a pre-treatment metric from before the treatment existed, and run the full AIPW pipeline on it.</p>
<p>If the pipeline returns a significant effect on the placebo outcome, you have a problem: unmeasured confounders, a misspecified propensity model, or data leakage. A placebo failure is one of the clearest signals that your analysis isn't credible, and it's a signal you can get before you ship anything.</p>
<h3 id="heading-reference-implementation">Reference Implementation</h3>
<p>The code below shows the weight distribution check and trimming step that Lyft's pipeline applies before trusting any causal estimate.</p>
<pre><code class="language-python">import pandas as pd
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from sklearn.linear_model import LogisticRegression

df = pd.read_csv("data/synthetic_llm_logs.csv")

# Estimate propensity for opt-in to agent mode
X = pd.get_dummies(
    df[["engagement_tier", "query_confidence"]], drop_first=True
).astype(float)
y = df["opt_in_agent_mode"]

ps_model = LogisticRegression(max_iter=1000).fit(X, y)
df["propensity"] = ps_model.predict_proba(X)[:, 1]

# ATE weights: 1/P(treat) for treated, 1/(1-P) for control
df["ipw"] = np.where(
    df.opt_in_agent_mode == 1,
    1 / df.propensity,
    1 / (1 - df.propensity),
)

# Diagnostic 1: weight distribution
print("IPW weight percentiles:")
for p in [50, 75, 90, 95, 99]:
    print(f"  {p}th pct: {np.percentile(df.ipw, p):.2f}")

fig, ax = plt.subplots(figsize=(8, 4))
ax.hist(df.ipw, bins=60, edgecolor="none", alpha=0.7)
ax.axvline(np.percentile(df.ipw, 99), color="red", linestyle="--",
           label="99th pct (trim threshold)")
ax.set_xlabel("IPW weight")
ax.set_ylabel("Count")
ax.set_title("Weight distribution: check for extreme values")
ax.legend()
plt.tight_layout()
plt.savefig("weight_distribution.png", dpi=140)
print("Saved weight_distribution.png")

# Diagnostic 2: trim extreme weights at 99th percentile
trim_threshold = np.percentile(df.ipw, 99)
df["ipw_trimmed"] = df.ipw.clip(upper=trim_threshold)

# Compare ATE before and after trimming
def weighted_ate(data):
    t = data[data.opt_in_agent_mode == 1]
    c = data[data.opt_in_agent_mode == 0]
    return (
        (t.task_completed * t.ipw_trimmed).sum() / t.ipw_trimmed.sum()
        - (c.task_completed * c.ipw_trimmed).sum() / c.ipw_trimmed.sum()
    )

# Untrimmed ATE using ipw column
df["ipw_trimmed_orig"] = df["ipw"].copy()   # backup before overwrite
ate_untrimmed = (
    (df[df.opt_in_agent_mode==1].task_completed * df[df.opt_in_agent_mode==1].ipw).sum()
    / df[df.opt_in_agent_mode==1].ipw.sum()
    - (df[df.opt_in_agent_mode==0].task_completed * df[df.opt_in_agent_mode==0].ipw).sum()
    / df[df.opt_in_agent_mode==0].ipw.sum()
)
ate_trimmed = weighted_ate(df)
print(f"\nATE (untrimmed): {ate_untrimmed:+.4f}")
print(f"ATE (trimmed):   {ate_trimmed:+.4f}")
print(f"Trim threshold:  {trim_threshold:.2f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">IPW weight percentiles:
  50th pct: 1.52
  75th pct: 1.57
  90th pct: 2.88
  95th pct: 8.14
  99th pct: 8.58
Saved weight_distribution.png

ATE (untrimmed): +0.0851
ATE (trimmed):   +0.0852
Trim threshold:  8.58
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69cc82ffe4688e4edd796adb/6eb953cd-d831-470c-b719-ae2c8bec5038.png" alt="IPW weight distribution histogram (after the Lyft weight diagnostic code block): IPW weight distribution histogram showing 50,000 weights clustered between 1.0 and 3.0 with 500 extreme weights trimmed at the 99th percentile threshold of 8.58; bottom panel   compares ATE untrimmed at +0.0851 and ATE trimmed at +0.0852, confirming extreme weights have negligible influence on this estimate." style="display:block;margin:0 auto" width="1444" height="902" loading="lazy">

<p><em>Figure 2: IPW weight distribution on the 50,000-user synthetic dataset. The bulk of the weights cluster between 1.0 and 3.0. 500 observations exceed the 99th-percentile trim threshold of 8.58. Trimming shifts the ATE by 0.0001, confirming extreme weights carry negligible influence on this estimate. Unlike Figure 1's conceptual map, this diagnostic runs directly on real data from the shared dataset.</em></p>
<p>Here's what's happening: you fit a propensity model, compute ATE weights, then plot the weight histogram to see whether any users have extreme weights that dominate the estimate.</p>
<p>The 99th percentile line is the visual trim threshold. You apply the trim and compare the untrimmed vs. trimmed ATE. If they're close, the extreme weights had minimal influence on the result. If they're far apart, you have a small cluster of influential observations, and the trimmed estimate is more trustworthy.</p>
<h3 id="heading-two-hours-of-diagnostics-prevent-a-quarter-of-misdirected-engineering-work">Two Hours of Diagnostics Prevent a Quarter of Misdirected Engineering Work</h3>
<p>When you're measuring the causal effect of an AI feature from observational logs, you're almost always in the regime where both your propensity model and your outcome model carry error. The AIPW structure gives you protection against one of them being wrong. The Lyft diagnostic toolkit tells you how much each model is carrying before you act on the estimate.</p>
<p>Running the weight diagnostic and the placebo test may add about 2 hours to a causal analysis. That two hours can prevent the kind of confident-but-wrong conclusion that sends an engineering team chasing the wrong feature for a quarter, and I've watched that happen. The cost of skipping diagnostics isn't abstract: it's six engineers working on something that wasn't the cause of the outcome you were measuring.</p>
<h2 id="heading-case-study-4-ubers-causal-forecasting-pipeline">Case Study 4: Uber's Causal Forecasting Pipeline</h2>
<h3 id="heading-merging-causal-estimates-with-forecasts">Merging Causal Estimates with Forecasts</h3>
<p>The standard output of a causal analysis is a point estimate and a confidence interval: the AI feature raised task completion by 6 percentage points, 95% CI [3.8, 8.2]. That number answers a backward-looking question: what happened?</p>
<p>Product decisions are forward-looking. If you're considering raising the model routing threshold from 0.85 to 0.90, you want to know what the cost and quality tradeoffs will look like next quarter, a projection forward grounded in what you learned from last month's experiment.</p>
<p>Totte Harinen and Bonnie Li's post <a href="https://www.uber.com/blog/causal-inference-at-uber/">"Using Causal Inference to Improve the Uber User Experience"</a> on the Uber Engineering blog describes how Uber applies causal inference to production decisions, providing the foundation for embedding causal effect estimates into forward-looking scenario models.</p>
<p>The structural move is to treat the causal estimate as a parameter in the forecast. Forecasting cost and quality separately and assuming a stable relationship between them leaves the causal parameter unspecified. The structural move is to model the causal effect of the routing threshold on the cost-quality tradeoff directly, then project that parameter forward under different assumptions about query volume, query distribution, and model capability.</p>
<p>This matters specifically for LLM systems because the relationship between routing decisions and costs is nonlinear and distribution-dependent. A routing threshold that's cost-efficient at your current query volume may break down at 3x volume. A model you optimized routing for in Q1 may be replaced by a cheaper model in Q3, shifting the cost-quality Pareto frontier entirely. Embedding causal estimates into the forecast makes those structural changes visible before they arrive.</p>
<h3 id="heading-reference-implementation">Reference Implementation</h3>
<p>The local comparison near the routing threshold rests on two identifying assumptions. First, engineers and users can't precisely manipulate <code>query_confidence</code> to cluster on one side of the 0.85 cutoff. Assignment must be as-good-as-random within a narrow band around the threshold.</p>
<p>Second, the potential outcome functions must be continuous across the cutoff, so the jump observed at 0.85 is attributable to routing assignment and not to any other policy firing at the same score level.</p>
<p>The code below illustrates the pattern: estimate the causal effect of a change in routing threshold on cost and quality, then project that effect across a range of future volume scenarios.</p>
<pre><code class="language-python">import pandas as pd
import numpy as np

df = pd.read_csv("data/synthetic_llm_logs.csv")

# Step 1: Estimate causal effect of premium routing on quality and cost
# (Using RDD logic: compare users near the routing threshold)
cutoff = 0.85
bw = 0.10
near = df[
    (df.query_confidence &gt; cutoff - bw)
    &amp; (df.query_confidence &lt; cutoff + bw)
].copy()
# Low-confidence queries route to premium model (below-threshold queries need stronger handling)
near["routed_premium"] = (near.query_confidence &lt; cutoff).astype(int)

# Causal effects from the local comparison near the threshold
quality_effect = (
    near[near.routed_premium == 1].task_completed.mean()
    - near[near.routed_premium == 0].task_completed.mean()
)
cost_effect = (
    near[near.routed_premium == 1].cost_usd.mean()
    - near[near.routed_premium == 0].cost_usd.mean()
)

print(f"Estimated quality effect of premium routing: {quality_effect:+.4f}")
print(f"Estimated cost effect of premium routing:    {cost_effect:+.4f}")

# Step 2: Embed into forward-looking scenarios
# Suppose we're evaluating: what if we raise threshold from 0.85 to 0.90?
# Queries with confidence 0.85 to 0.90 would shift from premium to cheap routing.
threshold_change_users = df[
    (df.query_confidence &gt;= 0.85) &amp; (df.query_confidence &lt; 0.90)
]
n_shifted = len(threshold_change_users)
print(f"\nQueries that would shift at threshold 0.85 to 0.90: {n_shifted}")

# Volume scenarios (monthly queries)
monthly_query_volume = [500_000, 1_000_000, 2_000_000]
shifted_fraction = n_shifted / len(df)  # fraction of total traffic shifted

print("\nForward-looking scenario: raise threshold from 0.85 to 0.90")
print(f"{'Monthly volume':&gt;20} {'Quality change':&gt;16} {'Cost change ($/mo)':&gt;20}")
for vol in monthly_query_volume:
    n_affected = vol * shifted_fraction
    delta_quality = quality_effect * n_affected / vol    # rate change in overall quality
    delta_cost = -cost_effect * n_affected               # negative: saving cost by de-premiuming
    print(f"{vol:&gt;20,.0f} {delta_quality:&gt;+16.4f} {delta_cost:&gt;+20,.0f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Estimated quality effect of premium routing: +0.0613
Estimated cost effect of premium routing:    +0.0080

Queries that would shift at threshold 0.85 to 0.90: 5415

Forward-looking scenario: raise threshold from 0.85 to 0.90
      Monthly volume   Quality change   Cost change ($/mo)
             500,000          +0.0066                 -436
           1,000,000          +0.0066                 -871
           2,000,000          +0.0066               -1,742
</code></pre>
<p>Here's what's happening: you estimate the causal effect of premium routing on quality (task completion) and cost using a local comparison near the routing threshold. You then identify the fraction of queries that would shift routing assignment if you moved the threshold from 0.85 to 0.90.</p>
<p>Finally, you project the quality and cost implications of that shift across different monthly query volume scenarios. The output is a scenario table that a product or finance team can read directly: raising the threshold saves roughly $X per month at current volume and costs approximately Y percentage points of task completion rate.</p>
<h3 id="heading-causal-forecasting-in-capacity-planning">Causal Forecasting in Capacity Planning</h3>
<p>The causal forecasting pattern is most useful for routing and infrastructure decisions where cost and quality effects are both significant, and you need to make choices ahead of traffic scale you haven't reached yet. Running the causal estimate forward into volume scenarios turns a retrospective finding into an actionable projection.</p>
<p>Skip this step and causal estimates stay buried in analysis documents, disconnected from capacity planning and pricing decisions. I've watched useful analyses go unread for this exact reason. With it, the measurement team is producing inputs that actually matter to how the product is run.</p>
<h2 id="heading-what-these-four-teams-have-in-common">What These Four Teams Have in Common</h2>
<p>These four teams built different methods but converged on the same operational discipline.</p>
<h3 id="heading-match-the-method-to-the-deployment-structure">Match the Method to the Deployment Structure</h3>
<p>Start from the assignment mechanism (how was treatment assigned?) and work backward to the identification strategy. Airbnb moved past short-term A/B tests because their features affect long-term value beyond a 30-day window. Netflix uses RDD for threshold routing systems because the cutoff is the natural identification strategy.</p>
<p>Pick the technique because your system's design makes a particular identification strategy credible. Defaulting to the method the team knows best is how identification errors happen, and those errors don't announce themselves.</p>
<h3 id="heading-build-diagnostics-before-building-estimators">Build Diagnostics Before Building Estimators</h3>
<p>Run the assumption checks before reporting the estimate. Airbnb validates the leading-indicator model on historical cohorts. Lyft runs weight distributions and placebo tests before acting on an observational estimate.</p>
<p>An estimate reported without its diagnostic layer is an estimate you can't defend. That distinction matters when the product team challenges your number at the quarterly review.</p>
<h3 id="heading-design-every-causal-estimate-around-a-specific-product-decision">Design Every Causal Estimate Around a Specific Product Decision</h3>
<p>Airbnb estimates long-term value to inform feature-shipping decisions. Netflix runs quasi-experiments to make rollout decisions.</p>
<p>Analyses that don't improve any specific product choice aren't worth running: they consume analyst time, create misleading signals in the reporting backlog, and erode stakeholder trust in the measurement function over time.</p>
<h3 id="heading-document-failure-modes-alongside-every-estimate">Document Failure Modes Alongside Every Estimate</h3>
<p>Each technique has a named list of ways it can break: non-parallel trends for DiD, manipulation at the cutoff for RDD, unmeasured confounders for propensity methods, and poor synthetic control fit for full-population upgrades.</p>
<p>Ship the estimate alongside its failure conditions labeled. The credibility of an analysis for a skeptical audience stems not from the confidence interval itself, but from a transparent disclosure of the specific assumptions that would need to be invalidated for the estimate to fail.</p>
<h2 id="heading-how-to-start-applying-this-in-your-own-llm-stack">How to Start Applying This in Your Own LLM Stack</h2>
<p>Most LLM teams aren't starting from a mature causal pipeline. The steps below are ordered by impact.</p>
<h3 id="heading-1-instrument-before-you-need-the-data">1. Instrument Before You Need the Data</h3>
<p>The biggest constraint in every observational causal analysis is that the data you needed wasn't collected. Before you can run a DiD on a staged rollout, you need pre-treatment data for both cohorts.</p>
<p>Before you can run an AIPW on an opt-in feature, you need a rich set of covariates that predict opt-in.</p>
<p>Instrument your system now for the analyses you'll want to run in six months: session length, query complexity, 7-day return rate, and model routing decisions. The instrument is cheap, but retroactive data collection is impossible.</p>
<h3 id="heading-2-classify-your-deployment-mechanisms">2. Classify Your Deployment Mechanisms</h3>
<p>Apply the Netflix taxonomy to every AI feature currently running in your product. For each feature, ask: how was treatment assigned? Which causal method does that assignment mechanism support?</p>
<p>What's the core assumption, and do you have the data to check it? The exercise usually reveals that most features are being measured with tools that don't match their assignment mechanism. That mismatch isn't academic. It means you don't know whether those features are working.</p>
<h3 id="heading-3-run-one-diagnostic-rich-causal-analysis">3. Run One Diagnostic-Rich Causal Analysis</h3>
<p>Pick one feature, run balance checks and placebo tests, stress-test sensitivity to specification choices, and write up the results. The discipline of running every check once establishes the pattern for future analyses.</p>
<p>It also usually surfaces one uncomfortable finding about the feature you were most confident in. I've seen this happen on three separate teams: the "obviously working" feature turns out to have a confounded comparison group.</p>
<h3 id="heading-4-separate-short-term-and-long-term-metrics">4. Separate Short-term and Long-term Metrics</h3>
<p>Follow Airbnb's lead and identify at least one leading indicator of long-term value that you can measure in a 30-day experiment window. Seven-day retention, return query rate in week 3, or escalation rate trajectory are all candidates.</p>
<p>Report this alongside immediate engagement metrics in every experiment summary. Without it, you're optimizing a proxy and discovering the gap in the next quarter's retention numbers.</p>
<h3 id="heading-5-make-causal-estimates-forward-looking">5. Make Causal Estimates Forward-Looking</h3>
<p>When you produce a causal estimate, add one row: "Under 3x current volume, this effect implies X." That translation step forces the analysis to make contact with infrastructure and product planning, and it changes who reads it.</p>
<h2 id="heading-when-production-causal-pipelines-break">When Production Causal Pipelines Break</h2>
<p>Production causal pipelines break in a few predictable places.</p>
<h3 id="heading-organizational-failures">Organizational Failures</h3>
<p><strong>First, no one owns the measurement design.</strong> In most teams, the data scientist writes the analysis after the feature ships. Because that's the standard workflow, you're always running retrospective analyses on data that wasn't designed for causal identification.</p>
<p>The fix is a measurement design review before features ship: who's the control group, how long is the pre-period, what's the core assumption, and what diagnostic will falsify it? A 30-minute review prevents a common class of unrecoverable analyses.</p>
<p><strong>Second, causal results don't reach decision-makers.</strong> A correct causal estimate that doesn't inform a product decision is a failed analysis, even if the statistics are right. You can't fix that with a better methodology. Causal pipelines need fast-path reporting alongside rigorous reporting.</p>
<h3 id="heading-technical-failures">Technical Failures</h3>
<p><strong>First, instrumentation gaps are discovered after the fact.</strong> The most common technical failure is the need for a covariate that wasn't logged. You discover the gap when you try to check balance or run a propensity model, three weeks after the experiment ended.</p>
<p>The instrument-early principle above addresses this, but it requires buy-in from the infrastructure team to prioritize event logging that serves causal analysis as directly as it serves product dashboards. That buy-in is harder to get than the logging itself.</p>
<p><strong>Second, there's treatment leakage in the synthetic dataset.</strong> For teams testing causal methods on synthetic or internal data, the data generation process can inadvertently bake in the causal effect you're trying to estimate, making any method appear to work.</p>
<p>Validate your analysis on external holdout data or on cohorts outside the generation window. This one is easy to miss because the synthetic data looks clean. Structural contamination within data rows can be subtle and difficult to detect.</p>
<h3 id="heading-interpretive-failures">Interpretive Failures</h3>
<p><strong>First, conflating LATE with ATE.</strong> RDD estimates the local average treatment effect (LATE): the effect at the cutoff, for the specific users near the threshold. Propensity matching estimates ATT: the effect for users who were treated. The ATE for the full population requires a different approach.</p>
<p>When a PM asks "what's the effect of this feature," they usually mean ATE. When your causal analysis gives them LATE without explaining the difference, they'll apply the estimate to decisions it wasn't designed to support, and the resulting product choice will be wrong in ways you can't trace back to the analysis.</p>
<p><strong>Second, external validity assumptions that don't hold.</strong> A causal estimate from last quarter's user population may not generalize to next quarter's, particularly when you're scaling into new segments or entering an international market.</p>
<p>The estimated effect on power users who opted in early, as the feature rolls out to light-engagement users. Document the population your estimate applies to. Flag explicitly when it's about to be applied outside that population.</p>
<p><strong>Third, reporting precision that overstates certainty.</strong> A causal estimate with two-decimal precision reported from an observational study with residual confounding risk conveys more certainty than the analysis warrants.</p>
<p>Report confidence intervals alongside point estimates, the assumptions the estimates depend on, and the balance after weighting, all in the summary where decision-makers will actually see them. The analysis isn't done until the uncertainty is visible to the people acting on it.</p>
<h2 id="heading-bootstrap-confidence-intervals">Bootstrap Confidence Intervals</h2>
<p>Point estimates from observational analyses carry sampling uncertainty. The bootstrap below (500 replicates, seed=7) provides 95% confidence intervals for the three numerical estimates in this article: the Airbnb DiD effect on future-value score, the Lyft IPW ATE, and the Uber RDD quality effect.</p>
<pre><code class="language-python">import pandas as pd
import numpy as np
from sklearn.linear_model import LinearRegression, LogisticRegression

rng = np.random.default_rng(7)
df = pd.read_csv("data/synthetic_llm_logs.csv")
n_boot = 500

# Bootstrap 1: DiD on future-value score (Airbnb)
historical = df[df.signup_week &lt; 10].copy()
feature_cols = ["task_completed", "thumbs_up", "session_minutes"]
fv_model = LinearRegression().fit(historical[feature_cols].fillna(0), historical["retained_7d"].values)
df["future_value_score"] = fv_model.predict(df[feature_cols].fillna(0))
analysis = df[df.signup_week &lt; 30].copy()
analysis["post"] = (analysis.signup_week &gt;= 20).astype(int)
analysis["treated"] = (analysis.wave == 1).astype(int)

did_boots = []
for _ in range(n_boot):
    s = analysis.sample(frac=1, replace=True, random_state=rng.integers(1e9))
    c = s.groupby(["treated", "post"]).future_value_score.mean()
    try:
        did_boots.append((c.loc[(1, 1)] - c.loc[(1, 0)]) - (c.loc[(0, 1)] - c.loc[(0, 0)]))
    except KeyError:
        pass
ci_did = np.percentile(did_boots, [2.5, 97.5])
print(f"DiD future-value 95% CI: [{ci_did[0]:+.4f}, {ci_did[1]:+.4f}]")

# Bootstrap 2: IPW ATE trimmed (Lyft)
X = pd.get_dummies(df[["engagement_tier", "query_confidence"]], drop_first=True).astype(float)
ps_model = LogisticRegression(max_iter=1000).fit(X, df["opt_in_agent_mode"])
df["propensity"] = ps_model.predict_proba(X)[:, 1]
df["ipw"] = np.where(df.opt_in_agent_mode == 1, 1 / df.propensity, 1 / (1 - df.propensity))
trim_thr = np.percentile(df.ipw, 99)
df["ipw_trimmed"] = df.ipw.clip(upper=trim_thr)

ate_boots = []
for _ in range(n_boot):
    s = df.sample(frac=1, replace=True, random_state=rng.integers(1e9))
    t = s[s.opt_in_agent_mode == 1]
    c = s[s.opt_in_agent_mode == 0]
    ate_boots.append(
        (t.task_completed * t.ipw_trimmed).sum() / t.ipw_trimmed.sum()
        - (c.task_completed * c.ipw_trimmed).sum() / c.ipw_trimmed.sum()
    )
ci_ate = np.percentile(ate_boots, [2.5, 97.5])
print(f"IPW ATE trimmed 95% CI:  [{ci_ate[0]:+.4f}, {ci_ate[1]:+.4f}]")

# Bootstrap 3: RDD quality effect near routing cutoff (Uber)
cutoff = 0.85
bw = 0.10
near = df[(df.query_confidence &gt; cutoff - bw) &amp; (df.query_confidence &lt; cutoff + bw)].copy()
near["routed_premium"] = (near.query_confidence &lt; cutoff).astype(int)

qe_boots = []
for _ in range(n_boot):
    s = near.sample(frac=1, replace=True, random_state=rng.integers(1e9))
    qe_boots.append(
        s[s.routed_premium == 1].task_completed.mean()
        - s[s.routed_premium == 0].task_completed.mean()
    )
ci_qe = np.percentile(qe_boots, [2.5, 97.5])
print(f"RDD quality effect 95% CI: [{ci_qe[0]:+.4f}, {ci_qe[1]:+.4f}]")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">DiD future-value 95% CI: [+0.0023, +0.0093]
IPW ATE trimmed 95% CI:  [+0.0727, +0.0966]
RDD quality effect 95% CI: [+0.0490, +0.0748]
</code></pre>
<p>Here's what's happening: three separate bootstrap loops resample the analysis dataset 500 times each with a shared seed.</p>
<p>The DiD bootstrap resamples the full analysis cohort and recomputes the 2x2 cell means. The interval <code>[+0.0023, +0.0093]</code> confirms the future-value effect is statistically distinguishable from zero.</p>
<p>The IPW ATE bootstrap resamples all 50,000 users and reweights each draw. The interval <code>[+0.0727, +0.0966]</code> covers the ground-truth +0.08 opt-in effect and excludes zero.</p>
<p>The RDD bootstrap resamples only users within the bandwidth window near the 0.85 cutoff. The interval <code>[+0.0490, +0.0748]</code> confirms the local quality effect is nonzero.</p>
<p>All three intervals are tight enough to be actionable and wide enough to reflect the uncertainty of observational estimates. If you're reporting a point estimate without one of these intervals, you're understating the risk your stakeholders are absorbing.</p>
<h2 id="heading-run-the-notebook-then-instrument-your-next-feature">Run the Notebook, Then Instrument Your Next Feature</h2>
<p>The companion notebook for this article lives at <a href="https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/13_case_studies/">github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/13_case_studies/</a>. Clone the repo, generate the synthetic dataset using the Prerequisites commands above, and run <code>case_studies_demo.ipynb</code> to reproduce every code block from this article, including all four case-study implementations and the bootstrap validation. It also contains a decision function that extends the Netflix taxonomy into a more complete method-selection guide.</p>
<p>The source material for the four case studies is available directly from each team's engineering blog.</p>
<ol>
<li><p>Jenny Chen's future value post is at (<a href="https://medium.com/airbnb-engineering/how-airbnb-measures-future-value-to-standardize-tradeoffs-3aa99a941ba5">Airbnb Tech Blog</a>).</p>
</li>
<li><p>The quasi-experiment taxonomy is at (<a href="https://netflixtechblog.com/key-challenges-with-quasi-experiments-at-netflix-89b4f234b852">Netflix Technology Blog</a>).</p>
</li>
<li><p>Nassiri's doubly robust validation piece is at (<a href="https://eng.lyft.com/trusting-the-untestable-validation-and-diagnostics-for-the-doubly-robust-models-00853df009df">Lyft Engineering</a>).</p>
</li>
<li><p>Harinen and Li's causal inference overview is at (<a href="https://www.uber.com/blog/causal-inference-at-uber/">Uber Engineering</a>).</p>
</li>
</ol>
<p>Reading the originals is worthwhile: they describe production systems in detail that a summary can't fully capture.</p>
<p>The teams that reliably measure AI impact share one practice: matching the method to the assignment mechanism, running diagnostics before trusting estimates, and connecting causal results to decisions before the decision window closes.</p>
<p>The bottleneck is almost always instrumentation. The data those analyses depend on has to exist before the feature ships. That's the gap the frameworks above can't close for you, and the reason the instrument-early step comes first.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Product Experimentation with Instrumental Variables: Unconfounding LLM Routing Decisions in Python ]]>
                </title>
                <description>
                    <![CDATA[ For data science leaders and product managers who are overseeing multi-model gateways, the standard regression approach to measuring model quality is fundamentally flawed. You're running a causal infe ]]>
                </description>
                <link>https://www.freecodecamp.org/news/instrumental-variables-for-llm-routing-in-python/</link>
                <guid isPermaLink="false">6a69ffcc634c4a299b014f9f</guid>
                
                    <category>
                        <![CDATA[ product experimentation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ experimentation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ causal inference ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ instrumental-variables ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Rudrendu Paul ]]>
                </dc:creator>
                <pubDate>Wed, 29 Jul 2026 13:27:40 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/9b1e9df5-6f52-4f55-b9df-6cd0fbb0ce7c.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>For data science leaders and product managers who are overseeing multi-model gateways, the standard regression approach to measuring model quality is fundamentally flawed.</p>
<p>You're running a causal inference experiment whether you acknowledge it or not, and your routing rules are quietly poisoning your performance estimates.</p>
<p>Consider a gateway that routes incoming queries to either a premium model or a faster, cheaper alternative based on a confidence threshold. Queries with a confidence score below a certain threshold get routed premium, while queries above that threshold go cheap.</p>
<p>You pull the logs, run a regression of <code>task_completed</code> on the routing decision, and find that premium routing yields a 14-percentage-point lift. Based on this number, your infrastructure team might start drafting a proposal to route everything premium.</p>
<p>Stop before you send that proposal. The routing rule correlates strongly with query complexity, which directly determines whether a task is completed. Complex queries are harder and fail more often, regardless of which model handles them.</p>
<p>When you regress task completion on premium routing, you measure two entangled phenomena simultaneously: the causal effect of sending a query to the premium model, and the inherent difference in difficulty between the queries each model receives.</p>
<p>Standard regression blends those two signals into a single coefficient, and the observed lift reflects query difficulty just as much as it reflects model quality.</p>
<p>The routing confounder arises whenever assignment correlates with query characteristics, as is to be expected in any routing system doing its job. The assignment rule ensures that the two treatment arms contain systematically different queries, invalidating the naïve comparison as a causal estimate.</p>
<p>Instrumental variable analysis is the method that breaks this deadlock. You need a third variable that influences routing for reasons completely unrelated to query quality.</p>
<p>Rate-limit-triggered fallbacks are exactly that. When the premium model hits a rate limit, the gateway reroutes the query to the cheaper model regardless of the query's characteristics. The rate limit fires for infrastructure reasons, independent of what a user actually asked. That randomness is an instrument, and two-stage least squares (2SLS) lets you extract a clean causal estimate from it.</p>
<p>This tutorial walks through the full diagnosis-to-fix sequence in Python: why the routing confounder biases OLS, how to build 2SLS from scratch across two chained regressions, how to check instrument strength with the first-stage F-statistic, and how to recover the local average treatment effect that 2SLS actually estimates rather than mistaking it for the average treatment effect. By the end, you'll know how to spot a confounded routing decision in your own logs, construct a valid instrument from an infrastructure signal like rate-limit fallbacks, and produce a causal estimate with correctly sized confidence intervals instead of the overconfident ones manual 2SLS gives you by default.</p>
<p><strong>Companion notebook</strong>: every code block in this article runs end-to-end in <code>iv_demo.ipynb</code> in the companion repo at <a href="https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/11_instrumental_variables/"><code>github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/11_instrumental_variables/</code></a>.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-routing-confounds-regression">Why Routing Confounds Regression</a></p>
</li>
<li><p><a href="#heading-what-an-instrumental-variable-is">What an Instrumental Variable is</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-setting-up-the-working-example">Setting Up the Working Example</a></p>
</li>
<li><p><a href="#heading-step-1-naive-ols-biased-baseline">Step 1: Naïve OLS (Biased Baseline)</a></p>
</li>
<li><p><a href="#heading-step-2-two-stage-least-squares-2sls-from-scratch">Step 2: Two-Stage Least Squares (2SLS) from Scratch</a></p>
</li>
<li><p><a href="#heading-step-3-weak-instrument-diagnostics">Step 3: Weak-Instrument Diagnostics</a></p>
</li>
<li><p><a href="#heading-step-4-the-late-is-the-quantity-you-actually-care-about">Step 4: The LATE is the Quantity You Actually Care About</a></p>
</li>
<li><p><a href="#heading-step-5-bootstrap-confidence-intervals">Step 5: Bootstrap Confidence Intervals</a></p>
</li>
<li><p><a href="#heading-when-instrumental-variables-fail">When Instrumental Variables Fail</a></p>
</li>
<li><p><a href="#heading-what-to-do-next">What to Do Next</a></p>
</li>
</ul>
<h2 id="heading-why-routing-confounds-regression">Why Routing Confounds Regression</h2>
<p>A routing system makes a correlated decision. Queries that arrive with low confidence scores, long token counts, or complex multi-step intent get routed to premium. Queries that are short, clear, and well within the cheap model's capability get routed cheap. That correlation is the whole point of the routing layer.</p>
<p>The problem is that the same features driving the routing decision also affect the outcome you care about.</p>
<p>Task completion is harder for complex queries, independent of which model processes them. When you write <code>task_completed ~ routed_to_premium + controls</code>, the <code>controls</code> term can absorb the observable dimensions of complexity: query length, user engagement tier, and whatever you logged.</p>
<p>The unobservable dimensions stay embedded in the <code>routed_to_premium</code> coefficient, and they bias the estimate downward (complex queries routed premium complete less often, making premium look worse than it is) or upward, depending on the direction of the confound.</p>
<p>In the synthetic dataset used in this tutorial, the OLS estimate lands at +3.3 percentage points even though the true causal effect is +6 percentage points. This is a downward bias of 2.7 pp driven entirely by unobserved query complexity.</p>
<p>The regression looks confident, the p-value looks significant, and nothing in the standard OLS output flags the problem. That's what makes this failure mode dangerous: it's invisible in standard regression diagnostics.</p>
<p>2SLS is built for exactly this structure. You need an external source of variation in routing that is uncorrelated with query quality. Rate-limit-triggered fallbacks provide it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69cc82ffe4688e4edd796adb/dcb88e06-c217-47f7-b220-a3c3184c84e1.png" alt="dcb88e06-c217-47f7-b220-a3c3184c84e1" style="display:block;margin:0 auto" width="1485" height="885" loading="lazy">

<p><em>Figure 1: The IV causal structure. The instrument (Z = rate-limit fallback) satisfies relevance (Z predicts routing), exclusion (no direct Z to outcome path), and independence (Z is uncorrelated with unobserved query complexity). The dashed red arrows show the confounder paths that bias naïve OLS.</em></p>
<h2 id="heading-what-an-instrumental-variable-is">What an Instrumental Variable is</h2>
<p>An instrument is a variable that shifts your endogenous variable (routing decision) without any other direct path to your outcome (task completion).</p>
<p>Four assumptions define a valid instrument.</p>
<h3 id="heading-relevance">Relevance</h3>
<p>The instrument must actually influence the endogenous variable. A rate-limit fallback indicator that fires on 15 percent of premium-eligible queries will meaningfully affect whether those queries get routed to premium.</p>
<p>This assumption is testable: check it with the first-stage F-statistic. The conventional threshold is F &gt; 10, established by <a href="https://ideas.repec.org/a/ecm/emetrp/v65y1997i3p557-586.html">Staiger and Stock (1997)</a>, corresponding to approximately a 10% maximum bias in the 2SLS estimator relative to OLS in the worst case.</p>
<p>Note that more recent work by <a href="https://ideas.repec.org/a/anr/reveco/v11y2019p727-753.html">Andrews, Stock, and Sun (2019)</a> suggests this threshold may be too permissive in settings with smaller samples or multiple instruments. For production analyses with limited fallback data, treat F &gt; 10 as a minimum floor and verify with additional sensitivity checks before reporting results. Below 10, the instrument is definitively weak, and the estimate is unreliable.</p>
<h3 id="heading-exclusion-restriction">Exclusion Restriction</h3>
<p>The instrument must affect the outcome solely through its effect on routing. The rate-limit fallback completes the task entirely by changing which model handles the query, with no separate direct path.</p>
<p>This assumption requires logical business reasoning and can't be verified from data alone. A fallback triggered by aggregate infrastructure load is unrelated to what a user asked or how hard their task was.</p>
<h3 id="heading-independence">Independence</h3>
<p>The instrument must be independent of all confounders. Rate-limit events are driven by aggregate API traffic and are unrelated to the characteristics of any individual query. The probability that a given query triggers a rate-limit fallback is uncorrelated with query complexity, user tier, or any other confounder. This assumption too must be argued logically.</p>
<h3 id="heading-monotonicity">Monotonicity</h3>
<p>The instrument must move all affected units in the same direction. For rate-limit fallbacks, every affected query switches from premium to cheap, but no query switches from cheap to premium due to a fallback. This rules out defiers and is required for the LATE interpretation to hold.</p>
<p>When all four hold, 2SLS extracts a causal estimate of routing's effect on task completion by using only the exogenous variation in routing generated by the instrument.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You need:</p>
<ul>
<li><p>Python 3.11 or newer</p>
</li>
<li><p>Comfort with pandas and statsmodels OLS</p>
</li>
<li><p>Rough familiarity with linear regression (2SLS is two OLS regressions chained together)</p>
</li>
</ul>
<p>Install the packages for this tutorial:</p>
<pre><code class="language-bash">pip install numpy pandas statsmodels scipy
</code></pre>
<p>This installs the four packages used in the tutorial. <code>statsmodels</code> provides OLS and the formula API, and <code>scipy</code> is used for statistical computations in the bootstrap step.</p>
<p>Clone the companion repo to get the synthetic dataset:</p>
<pre><code class="language-bash">git clone https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm.git
cd product-experimentation-causal-inference-genai-llm
python data/generate_data.py --seed 42 --n-users 50000 --out data/synthetic_llm_logs.csv
</code></pre>
<p>You clone the companion repo and regenerate the shared 50,000-user synthetic dataset with a fixed seed so your results match the expected outputs in this article.</p>
<h2 id="heading-setting-up-the-working-example">Setting Up the Working Example</h2>
<p>This tutorial adds three simulated variables on top of the shared dataset's user covariates, constructing the full IV causal graph in code:</p>
<ul>
<li><p><code>rate_limit_fallback</code>: the instrument Z. Sampled as a pure Bernoulli(0.15), completely independent of all query characteristics.</p>
</li>
<li><p><code>routed_to_premium_actual</code>: the endogenous treatment D. Routing is driven by both <code>query_confidence</code> (observable) and <code>query_complexity</code> (unobservable), so OLS is biased.</p>
</li>
<li><p><code>task_completed_iv</code>: the outcome Y. Re-simulated from the IV causal graph with a known +6 pp premium routing effect, letting you verify that the estimator recovers the ground truth.</p>
</li>
</ul>
<p>A transparency note: in a real production analysis, the rate-limit fallback events come from your API gateway logs. Your user telemetry table won't have them. You'd join those two sources to construct the instrument.</p>
<p>The simulation here preserves the structural properties of a real instrument: it fires for infrastructure reasons, independent of query quality, without requiring production gateway logs.</p>
<pre><code class="language-python">import numpy as np
import pandas as pd
import statsmodels.formula.api as smf

np.random.seed(42)

df = pd.read_csv("data/synthetic_llm_logs.csv")
rng = np.random.default_rng(99)
n = len(df)

# Unobserved confounder: complex queries route premium AND complete less often
query_complexity = rng.normal(0, 1, n)

# Endogenous routing: depends on query_confidence (observable)
# and query_complexity (unobserved): this is the confounding structure
log_odds = -2.0 + 4.0 * (1.0 - df["query_confidence"]) + 0.6 * query_complexity
premium_prob = 1.0 / (1.0 + np.exp(-log_odds))
df["routed_to_premium_iv"] = rng.binomial(1, premium_prob).astype(int)

# Instrument: pure Bernoulli(0.15), independent of all query characteristics
df["rate_limit_fallback"] = rng.binomial(1, 0.15, n)

# Actual routing: premium if intended, unless fallback overrides
df["routed_to_premium_actual"] = (
    df["routed_to_premium_iv"] * (1 - df["rate_limit_fallback"])
).astype(int)

# Outcome: known causal structure with +0.06 premium effect
engagement_base = np.where(df.engagement_tier == "heavy", 0.70,
                  np.where(df.engagement_tier == "medium", 0.55, 0.35))
completion_prob = np.clip(
    engagement_base
    + 0.06 * df["routed_to_premium_actual"]  # true causal effect
    - 0.04 * query_complexity                 # unobserved confounder
    + rng.normal(0, 0.02, n),
    0.01, 0.99
)
df["task_completed_iv"] = rng.binomial(1, completion_prob).astype(int)

# Encode engagement tier as dummies
df = pd.get_dummies(df, columns=["engagement_tier"], drop_first=True)
tier_dummies = [c for c in df.columns if c.startswith("engagement_tier_")]
covariate_str = " + ".join(["query_confidence"] + tier_dummies)

print(f"Rate-limit fallback rate:      {df.rate_limit_fallback.mean():.3f}")
print(f"Premium routing rate (actual): {df.routed_to_premium_actual.mean():.3f}")
print(f"Mean confidence | fallback=1:  {df[df.rate_limit_fallback==1].query_confidence.mean():.3f}")
print(f"Mean confidence | fallback=0:  {df[df.rate_limit_fallback==0].query_confidence.mean():.3f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Rate-limit fallback rate:      0.151
Premium routing rate (actual): 0.271
Mean confidence | fallback=1:  0.716
Mean confidence | fallback=0:  0.715
</code></pre>
<p>In the above code, the nearly identical mean confidence scores between the fallback=1 and fallback=0 groups confirm that the instrument is independent of the observable routing signal. This is the independence assumption check you can run on any proposed instrument. <code>query_complexity</code> is available in this simulation but would be unobserved in production. The regression never receives it.</p>
<h2 id="heading-step-1-naive-ols-biased-baseline">Step 1: Naïve OLS (Biased Baseline)</h2>
<p>Running a standard regression first establishes the biased baseline you'd encounter without accounting for the confounding structure. Most engineering teams report this number without realizing it's mathematically compromised.</p>
<pre><code class="language-python">ols_formula = f"task_completed_iv ~ routed_to_premium_actual + {covariate_str}"
ols_model = smf.ols(ols_formula, data=df).fit(cov_type="HC3")

ols_coef = ols_model.params["routed_to_premium_actual"]
ols_se   = ols_model.bse["routed_to_premium_actual"]
ols_pval = ols_model.pvalues["routed_to_premium_actual"]
print(f"OLS estimate of premium routing effect: {ols_coef:+.4f}")
print(f"HC3 standard error:                      {ols_se:.4f}")
print(f"p-value:                                 {ols_pval:.4f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">OLS estimate of premium routing effect: +0.0327
HC3 standard error:                      0.0050
p-value:                                 0.0000
</code></pre>
<p>Here's what's happening: OLS recovers +3.3 percentage points (probability units, since <code>task_completed_iv</code> is a 0/1 binary outcome in a linear probability model). The true causal effect is +6.0 pp. The 2.7 pp bias comes from unobserved query-complexity routing: harder queries are routed to premium, and they complete less often, which is a downward confounding mechanism. The p-value looks significant, and the standard error looks precise. Nothing in this output tells you the estimate is wrong.</p>
<p>Keep this number in mind: the 2SLS result in Step 2 will reveal the gap.</p>
<h2 id="heading-step-2-two-stage-least-squares-2sls-from-scratch">Step 2: Two-Stage Least Squares (2SLS) from Scratch</h2>
<p>Two-stage least squares corrects the bias by isolating the exogenous routing variation generated by rate-limit fallbacks, using only that variation to estimate the causal effect.</p>
<h3 id="heading-stage-1-predict-routing-from-the-instrument-and-covariates">Stage 1: Predict Routing from the Instrument and Covariates.</h3>
<pre><code class="language-python">stage1_formula = f"routed_to_premium_actual ~ rate_limit_fallback + {covariate_str}"
stage1 = smf.ols(stage1_formula, data=df).fit(cov_type="HC3")

print(f"Stage 1 instrument coefficient: {stage1.params['rate_limit_fallback']:+.4f}")
print(f"p-value:                         {stage1.pvalues['rate_limit_fallback']:.4f}")

df["rtp_hat"] = stage1.fittedvalues
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Stage 1 instrument coefficient: -0.3190
p-value:                         0.0000
</code></pre>
<p>In this code, you regress the endogenous routing variable on the instrument and the same observed covariates you'll use in Stage 2.</p>
<p>The fitted values <code>rtp_hat</code> contain two components: the exogenous variation the instrument explains, and the exogenous variation the covariates explain.</p>
<p>The endogenous component (the variation correlated with unobserved query complexity) stays in the residuals and drops out of <code>rtp_hat</code>. The negative coefficient on <code>rate_limit_fallback</code> confirms the relevance assumption: when the fallback fires, premium routing probability drops by about 32 percentage points.</p>
<h3 id="heading-stage-2-regress-outcome-on-the-predicted-routing">Stage 2: Regress Outcome on the Predicted Routing.</h3>
<pre><code class="language-python">stage2_formula = f"task_completed_iv ~ rtp_hat + {covariate_str}"
stage2 = smf.ols(stage2_formula, data=df).fit(cov_type="HC3")

tsls_coef = stage2.params["rtp_hat"]
tsls_se   = stage2.bse["rtp_hat"]
print(f"2SLS estimate:              {tsls_coef:+.4f}")
print(f"Stage-2 SE (underestimate): {tsls_se:.4f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">2SLS estimate:              +0.0599
Stage-2 SE (underestimate): 0.0188
</code></pre>
<p>Here's what's happening: replacing <code>routed_to_premium_actual</code> with <code>rtp_hat</code> removes the endogenous part of the routing variation. The Stage 2 coefficient (+0.0599) is the 2SLS estimate of the causal effect of premium routing on task completion, almost exactly the +0.06 ground truth.</p>
<p>Here's an important caveat on standard errors: manual 2SLS produces Stage 2 SEs that are too small. Stage 2 OLS treats <code>rtp_hat</code> as a fixed, known regressor, when in fact it was estimated from the data in Stage 1. That estimation error adds a variance component that Stage 2's residuals never see.</p>
<p>For any result you report to stakeholders, use <code>linearmodels.IV2SLS</code> (shown in "What to do next"), which computes the correct sandwich variance.</p>
<h3 id="heading-compare-ols-and-2sls-side-by-side">Compare OLS and 2SLS Side by Side:</h3>
<pre><code class="language-python">print(f"OLS estimate (biased):  {ols_coef:+.4f}")
print(f"2SLS estimate (IV):     {tsls_coef:+.4f}")
print(f"True premium effect:   +0.0600")
print(f"OLS bias:               {ols_coef - 0.06:+.4f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">OLS estimate (biased):  +0.0327
2SLS estimate (IV):     +0.0599
True premium effect:   +0.0600
OLS bias:               -0.0273
</code></pre>
<p>Here, OLS misses the true effect by 2.7 pp, a 45% underestimate. 2SLS recovers it to within 0.01 pp. The direction of the gap matches the confounding mechanism: unobserved query complexity routes hard queries to premium and reduces their completion, pulling the OLS coefficient downward.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69cc82ffe4688e4edd796adb/dc39aa79-c955-4c4e-9962-f5a648d6e383.png" alt="dc39aa79-c955-4c4e-9962-f5a648d6e383" style="display:block;margin:0 auto" width="1633" height="763" loading="lazy">

<p><em>Figure 2: Data-driven results on the 50,000-user synthetic dataset. Left panel: routing rates by fallback group confirm the first-stage relationship: fallback=0 queries route premium at 39.1%, fallback=1 queries at 0% (complete override). Right panel: OLS CI (red) misses the true +0.06 pp effect entirely. 2SLS CI (green) covers it. The wider 2SLS interval reflects the variance cost of relying solely on the instrument's exogenous variation.</em></p>
<h2 id="heading-step-3-weak-instrument-diagnostics">Step 3: Weak-Instrument Diagnostics</h2>
<p>A valid instrument that has little effect on outcomes is a weak instrument. Weak instruments produce 2SLS estimates with enormous variance that drift toward the OLS estimate in small samples, which defeats the purpose. The standard diagnostic is the first-stage F-statistic.</p>
<pre><code class="language-python">stage1_restricted = smf.ols(
    f"routed_to_premium_actual ~ {covariate_str}", data=df
).fit()

f_stat, f_pval, _ = stage1.compare_f_test(stage1_restricted)
print(f"First-stage F-statistic (instrument): {f_stat:.2f}")
print(f"p-value:                               {f_pval:.4f}")

if f_stat &gt; 10:
    print("Instrument is STRONG (F &gt; 10). 2SLS estimates are reliable.")
elif f_stat &gt; 4:
    print("Instrument is BORDERLINE WEAK (4 &lt; F &lt; 10). Interpret with caution.")
else:
    print("Instrument is WEAK (F &lt; 4). 2SLS estimates are unreliable.")

print(f"\nFirst-stage coefficient on instrument: "
      f"{stage1.params['rate_limit_fallback']:+.4f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">First-stage F-statistic (instrument): 3780.94
p-value:                               0.0000
Instrument is STRONG (F &gt; 10). 2SLS estimates are reliable.

First-stage coefficient on instrument: -0.3190
</code></pre>
<p>In the above code, you compare the full Stage 1 model (with the instrument) to a restricted model (without it) using an F-test. An F of 3780 is overwhelmingly above the Staiger-Stock rule of thumb. The 15% fallback rate applied to 50,000 observations yields a large, precisely estimated first-stage effect.</p>
<p>On a real production dataset with lower fallback rates or a smaller dataset, the F-statistic will be lower. If you get an F-statistic below 10, either find a stronger instrument or add more fallback data before drawing conclusions.</p>
<p>There's a trade-off between instrument strength and exclusion validity that's worth flagging explicitly. You can make an instrument stronger by increasing the fallback rate, but if you push it high enough to affect user experience, the fallback starts to directly affect task completion through satisfaction and retry behavior, which violates the exclusion restriction. A strong instrument that satisfies both relevance and exclusion is the goal.</p>
<p>The endogeneity direction check:</p>
<pre><code class="language-python">gap = ols_coef - tsls_coef
print(f"OLS minus 2SLS gap: {gap:+.4f}")
if abs(gap) &gt; 0.005:
    print("Gap suggests endogeneity bias is present in OLS.")
else:
    print("Small gap: OLS and 2SLS broadly agree.")
print("For a formal Hausman endogeneity test, use linearmodels IV2SLS.")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">OLS minus 2SLS gap: -0.0272
Gap suggests endogeneity bias is present in OLS.
For a formal Hausman endogeneity test, use linearmodels IV2SLS.
</code></pre>
<p>Here's what's happening: the gap between OLS and 2SLS is the diagnostic for endogeneity. A gap of 2.7 pp confirms that the routing variable is genuinely correlated with unobserved confounders, and that OLS was absorbing part of the confounder's effect.</p>
<p>For a formally valid Hausman test (one that produces a chi-squared statistic with a known distribution under the null), use <code>linearmodels.IV2SLS</code>'s built-in test. The direction check above is a quick diagnostic only.</p>
<h2 id="heading-step-4-the-late-is-the-quantity-you-actually-care-about">Step 4: The LATE is the Quantity You Actually Care About</h2>
<p>2SLS estimates the Local Average Treatment Effect (LATE), also called the Complier Average Causal Effect (CACE). The LATE applies only to compliers: the specific subset of queries whose routing actually changes when the instrument fires. Rate-limit fallbacks affect only premium-eligible queries that experience a fallback, so the LATE is specific to that subpopulation.</p>
<pre><code class="language-python">compliers_mask = df["rate_limit_fallback"] == 1
complier_count = compliers_mask.sum()
complier_pct   = complier_count / n * 100

print(f"Approximate complier population: {complier_count:,} ({complier_pct:.1f}% of queries)")
print(f"\nComplier mean confidence:     {df[compliers_mask]['query_confidence'].mean():.3f}")
print(f"Non-complier mean confidence: {df[~compliers_mask]['query_confidence'].mean():.3f}")
print(f"\n2SLS LATE estimate: {tsls_coef:+.4f}")
print("This is the causal effect of premium routing for queries rerouted")
print("by rate-limit fallbacks, not all queries in the dataset.")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Approximate complier population: 7,575 (15.2% of queries)

Complier mean confidence:     0.716
Non-complier mean confidence: 0.715

2SLS LATE estimate: +0.0599
This is the causal effect of premium routing for queries rerouted
by rate-limit fallbacks, not all queries in the dataset.
</code></pre>
<p>In this code, the complier population is 7,575 queries (those that experienced a rate-limit fallback and were rerouted from premium to cheap). Their mean confidence (0.716) is nearly identical to the non-complier group (0.715), confirming that the fallback fired independently of query characteristics.</p>
<p>When compliers look like a representative slice of all queries on observables, the LATE is often a reasonable approximation of the average treatment effect (ATE).</p>
<p>Observable representativeness meets the minimum diagnostic standard. The formal LATE-to-ATE condition requires either homogeneous treatment effects across all units or a valid instrument for every unit in the population. If your routing effect is heterogeneous across query types (premium routing helps complex queries far more than simple ones, for instance), the LATE can diverge substantially from the ATE, even when the complier mean confidence looks similar to that of the non-complier group.</p>
<p>For strategic capacity planning, this is exactly the metric you need. When you ask whether to invest in greater premium model capacity or adjust rate limits, you're asking a specific question about the queries currently constrained by your infrastructure. 2SLS answers that question directly.</p>
<h2 id="heading-step-5-bootstrap-confidence-intervals">Step 5: Bootstrap Confidence Intervals</h2>
<p>Manual 2SLS produces Stage 2 standard errors that are too small, as explained in Step 2. Bootstrap CIs give you reliable uncertainty estimates without needing to derive the correct analytic variance formula. The bootstrap resamples the full two-stage procedure together, capturing the sampling variance from both stages.</p>
<pre><code class="language-python">rng_boot = np.random.default_rng(7)
ols_boot, tsls_boot = [], []

for _ in range(500):
    samp = df.sample(len(df), replace=True,
                     random_state=int(rng_boot.integers(1_000_000_000)))

    # OLS bootstrap
    ols_b = smf.ols(
        f"task_completed_iv ~ routed_to_premium_actual + {covariate_str}",
        data=samp
    ).fit()
    ols_boot.append(ols_b.params["routed_to_premium_actual"])

    # 2SLS bootstrap (two stages together)
    s1b = smf.ols(
        f"routed_to_premium_actual ~ rate_limit_fallback + {covariate_str}",
        data=samp
    ).fit()
    samp = samp.copy()
    samp["rtp_hat"] = s1b.fittedvalues
    s2b = smf.ols(
        f"task_completed_iv ~ rtp_hat + {covariate_str}",
        data=samp
    ).fit()
    tsls_boot.append(s2b.params["rtp_hat"])

ols_ci  = (np.percentile(ols_boot, 2.5),  np.percentile(ols_boot, 97.5))
tsls_ci = (np.percentile(tsls_boot, 2.5), np.percentile(tsls_boot, 97.5))
true_eff = 0.0600

print(f"OLS  95% CI: [{ols_ci[0]:+.4f}, {ols_ci[1]:+.4f}]")
print(f"2SLS 95% CI: [{tsls_ci[0]:+.4f}, {tsls_ci[1]:+.4f}]")
print(f"Ground truth: +{true_eff:.4f}")
print(f"OLS CI covers ground truth:  {ols_ci[0] &lt;= true_eff &lt;= ols_ci[1]}")
print(f"2SLS CI covers ground truth: {tsls_ci[0] &lt;= true_eff &lt;= tsls_ci[1]}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">OLS  95% CI: [+0.0227, +0.0426]
2SLS 95% CI: [+0.0247, +0.0969]
Ground truth: +0.0600
OLS CI covers ground truth:  False
2SLS CI covers ground truth: True
</code></pre>
<p>In this code, the OLS 95% CI ([+0.023, +0.043]) entirely misses the true +0.06 effect. Every value in that interval is below the ground truth: OLS is confidently wrong. The 2SLS CI ([+0.025, +0.097]) covers the ground truth. It's wider than the OLS interval, reflecting the variance cost of IV estimation: you pay in precision to gain in validity.</p>
<p>The bootstrap resamples both stages in each iteration, so the uncertainty correctly accounts for the two-stage structure. Use bootstrap CIs when reporting 2SLS results from a manual implementation, as they're more reliable than the Stage 2 parametric SE.</p>
<h2 id="heading-when-instrumental-variables-fail">When Instrumental Variables Fail</h2>
<p>IV analysis has failure modes more insidious than those of propensity scores or regression discontinuity, because two of the four assumptions are untestable from data alone.</p>
<h3 id="heading-weak-instruments">Weak Instruments</h3>
<p>A first-stage F below 10 signals a serious identification problem. Weak instruments cause the 2SLS estimator to have large variance and drift toward OLS in finite samples, replicating the biased baseline while appearing to do something more sophisticated. Check the F-statistic before interpreting any IV result.</p>
<p>If F is below 10, find a stronger instrument or report the estimate with an explicit weak-instrument warning. The instrument here is strong (F = 3780) because the 15% fallback rate applied to 50,000 queries yields 7,500+ routing changes.</p>
<h3 id="heading-exclusion-restriction-violations">Exclusion Restriction Violations</h3>
<p>If the rate-limit fallback affects task completion through any channel other than the routing decision, exclusion fails.</p>
<p>There are two plausible violations: fallback events cluster during high-traffic periods when users are also more likely to be doing complex batch jobs, making the instrument correlated with query difficulty after all. Or users who experience a fallback notice the degraded response quality and abandon the session, creating a direct Z to Y path through user frustration.</p>
<p>Both violate exclusion while leaving relevance intact. You can't test them from data. You have to argue from system knowledge.</p>
<h3 id="heading-late-vs-ate-confusion">LATE vs. ATE Confusion</h3>
<p>Using the LATE estimate to justify a broad routing policy change is wrong if compliers are atypical. If rate-limit fallbacks disproportionately hit complex queries (because complex queries take longer and are more likely to hit a rate limit mid-session), the LATE covers the causal effect of premium routing for that complex-query subpopulation.</p>
<p>Reporting it as if it were the ATE overstates the benefit of routing all queries premium. The complier characteristics table in Step 4 is the diagnostic: if compliers and non-compliers look similar on observables, the LATE is a credible approximation of the ATE.</p>
<h3 id="heading-defiers-and-the-monotonicity-assumption">Defiers and the Monotonicity Assumption</h3>
<p>The LATE interpretation requires monotonicity: the instrument moves all affected units in the same direction. For rate-limit fallbacks, this is almost certainly satisfied, since a fallback always reduces the probability of premium routing for the affected query.</p>
<p>If some compensating mechanism exists (say, a fallback: one query triggers a priority boost on the next), you have defiers, and the monotonicity assumption breaks down. Verify directional consistency before trusting the LATE.</p>
<h2 id="heading-what-to-do-next">What to Do Next</h2>
<p>The manual 2SLS implementation in this tutorial is transparent about the mechanism but produces incorrect standard errors. For any result you report to stakeholders or include in a published analysis, use <code>linearmodels.IV2SLS</code>:</p>
<pre><code class="language-python"># Production-grade 2SLS with correct standard errors
# pip install linearmodels
from linearmodels.iv import IV2SLS

exog_vars = ["query_confidence"] + tier_dummies
iv_model = IV2SLS.from_formula(
    f"task_completed_iv ~ 1 + {' + '.join(exog_vars)} "
    f"[routed_to_premium_actual ~ rate_limit_fallback]",
    data=df
).fit(cov_type="robust")

print(iv_model.summary)
</code></pre>
<p>Here's what's happening: <code>linearmodels</code> computes the correct 2SLS variance that accounts for the two-stage structure, runs a proper first-stage diagnostic summary, and provides a formal Hausman endogeneity test. The syntax brackets the endogenous variable and instrument: <code>[D ~ Z]</code>.</p>
<p>The full implementation (including bootstrap confidence intervals and the visualization in Figure 2) is in the companion notebook at <a href="https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/11_instrumental_variables/">github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/11_instrumental_variables/</a>. Clone the repo, generate the synthetic dataset, and run <code>iv_demo.ipynb</code> to reproduce every code block end-to-end.</p>
<p>One final note on when to reach for IV at all: if your system supports forced routing randomization (randomly assigning a fraction of queries to premium regardless of confidence score), a standard A/B test is simpler and produces a full-fleet ATE estimate.</p>
<p>IV is the right tool when randomization is infeasible: when the routing rule is baked into production logic, when you can't afford to deliberately route queries suboptimally, or when you need to use historical observational data. If you can run a true experiment, run it.</p>
<p>Confounding is the structural default for any optimized routing system. Standard regression folds model quality and inherent query difficulty into a single coefficient, measuring both at once when you need them separated.</p>
<p>Rate-limit fallbacks provide the clean, natural instrument that filters infrastructure noise from routing signal. This approach gives your team a defensible causal estimate of how your model architecture actually drives business value.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Product Experiment Counterfactual Methods for Estimating the Effects of AI Prompt Engineering ]]>
                </title>
                <description>
                    <![CDATA[ Imagine your team deployed Prompt A globally two weeks ago. Tight deadlines and high confidence meant the rollout hit 100 percent of users without any A/B testing, shadow traffic, or holdout groups. W ]]>
                </description>
                <link>https://www.freecodecamp.org/news/counterfactual-meta-learners-for-llm-prompt-decisions/</link>
                <guid isPermaLink="false">6a624877953fb9a0375f9238</guid>
                
                    <category>
                        <![CDATA[ product experimentation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ experimentation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ causal inference ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ counterfactual-estimation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ counterfactual ]]>
                    </category>
                
                    <category>
                        <![CDATA[ MathJax ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Rudrendu Paul ]]>
                </dc:creator>
                <pubDate>Thu, 23 Jul 2026 16:59:35 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/dc2c7913-508e-48fe-b56c-772e86469976.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Imagine your team deployed Prompt A globally two weeks ago. Tight deadlines and high confidence meant the rollout hit 100 percent of users without any A/B testing, shadow traffic, or holdout groups.</p>
<p>While completion rates appear stable, a colleague presents a new prompt from a staging environment late at night, and that sparks the real question: would the alternative have been the better choice to ship?</p>
<p>You're now stuck in the logged data trap. It looks unanswerable, but it isn't. Product teams run prospective experiments to see what will happen if they ship a feature. Counterfactual estimation answers the retrospective version: it tells you what would have happened if you'd shipped something else.</p>
<p>For data science and product engineering leaders working with LLM product logs, that's often the only available measurement path once a prompt is in production. Every log you have comes from users who saw Prompt A. The question is purely retrospective. You can't go back and re-run the week with a different configuration. That's a classic counterfactual problem.</p>
<p>Teams ship prompts quickly, collect logs, and then ask retrospective questions. What would conversion have looked like with a different system prompt? Which users would have responded differently? Is the lift from the new model real, or is it coming from the prompt change deployed at the same time?</p>
<p>The answer lives in a class of methods called counterfactual estimation using meta-learners. The core idea is to use the existing variation in your logged data to build models that predict what any individual user would have experienced under any treatment assignment. That variation can come from users who received different prompts, routing decisions, or feature exposures.</p>
<p>In this guide, you'll implement a T-learner and an X-learner from scratch using scikit-learn. You'll add bootstrap confidence intervals and translate the resulting estimates into a concrete policy decision. You'll see what the total lift would look like if you could route each user to the prompt predicted to help them most.</p>
<p>Every code block in this tutorial runs end-to-end in the companion notebook at <a href="https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/10_counterfactual_prompts/"><code>product-experimentation-causal-inference-genai-llm/tree/main/10_counterfactual_prompts/</code></a>. The notebook file is <code>counterfactual_demo.ipynb</code>.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-logged-data-is-not-an-experiment">Why Logged Data is Not an Experiment</a></p>
</li>
<li><p><a href="#heading-the-mechanics-of-counterfactual-estimation">The Mechanics of Counterfactual Estimation</a></p>
</li>
<li><p><a href="#heading-prerequisites-and-setup">Prerequisites and Setup</a></p>
<ul>
<li><p><a href="#heading-step-1-t-learner-for-counterfactual-predictions">Step 1: T-learner for Counterfactual Predictions</a></p>
</li>
<li><p><a href="#heading-step-2-x-learner-for-imbalanced-treatment-arms">Step 2: X-learner for Imbalanced Treatment Arms</a></p>
</li>
<li><p><a href="#heading-step-3-bootstrap-confidence-intervals">Step 3: Bootstrap Confidence Intervals</a></p>
</li>
<li><p><a href="#heading-step-4-translating-cate-into-a-policy-value">Step 4: Translating CATE into a Policy Value</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-when-counterfactual-estimation-fails">When Counterfactual Estimation Fails</a></p>
</li>
<li><p><a href="#heading-strategic-implementation">Strategic Implementation</a></p>
</li>
</ul>
<h2 id="heading-why-logged-data-is-not-an-experiment">Why Logged Data is Not an Experiment</h2>
<p>The core problem with logged production data is that treatment assignment is rarely random. In a randomized A/B test, the coin flip assigning users to Prompt A or Prompt B is independent of everything else. Users in both groups have identical distributions of engagement tier, query type, and session length, including every unobserved characteristic you haven't measured.</p>
<p>The only systematic difference between groups is the treatment itself, so any difference in outcomes must be the causal effect of that treatment.</p>
<p>Production logs carry a different structure. Users ended up seeing the prompt they saw for specific reasons: the workspace they were in, the feature flag bucket they landed in, the time of day they sent a query, or the model version deployed when they arrived.</p>
<p>Some of those reasons are recorded in your data. The rest stay hidden. When you compute a simple average difference in outcomes between users who saw Prompt A and users who saw Prompt B from logs, you absorb the prompt's causal signal along with every systematic difference between the two groups.</p>
<p>Here's where it gets uncomfortable. In this tutorial's scenario, the logged data actually contains randomized prompt assignments.</p>
<p>Pretend for a moment that it doesn't. Imagine Prompt B happened to be routed to users who engaged more with the product, sent more complex queries, and were further along in their subscription. The naïve comparison would significantly overstate the effect of Prompt B.</p>
<p>Counterfactual estimation methods are designed specifically for that non-random case, and the implementation in this guide works the same way regardless of whether the original assignment was clean or confounded.</p>
<p>The distinction you care about is between what a user actually experienced and what they would have experienced under a different treatment. Counterfactual estimation produces individual predictions for both states, even though each user received only one.</p>
<h2 id="heading-the-mechanics-of-counterfactual-estimation">The Mechanics of Counterfactual Estimation</h2>
<img src="https://cdn.hashnode.com/uploads/covers/69cc82ffe4688e4edd796adb/f1b9cc06-8387-45ba-9e1f-e55baac41cbe.png" alt="f1b9cc06-8387-45ba-9e1f-e55baac41cbe" style="display:block;margin:0 auto" width="1470" height="942" loading="lazy">

<p><em>Figure 1: Conceptual illustration of the T-learner. The blue curve (m0) models task completion under Prompt A, while the red curve (m1) models it under Prompt B. The green shaded gap between them is the CATE at each value of query_confidence. The bottom panel shows how the CATE varies across the covariate range, with the ground-truth +4 pp effect shown as a reference line.</em></p>
<p>The potential outcomes framework (Rubin, 1974, Holland, 1986) provides the cleanest way to frame this problem. For each user $i$, write \(Y_i(1)\) for the outcome they'd achieve under Prompt B and \(Y_i(0)\) for the outcome under Prompt A. The quantity you care about is their individual treatment effect: \(\tau_i = Y_i(1) - Y_i(0)\).</p>
<p>The fundamental problem is that you only ever observe one of the two outcomes. A user who saw Prompt A gives you \(Y_i(0)\), while \(Y_i(1)\) stays missing. A user who saw Prompt B gives you \(Y_i(1)\), while \(Y_i(0)\) stays missing.</p>
<p>Individual treatment effects are unidentifiable from single observations. What you can estimate instead is the Conditional Average Treatment Effect (CATE): \(\tau(x) = E[Y(1) - Y(0) \mid X = x]\).</p>
<p>This is the expected treatment effect for users with covariate profile $x$. By modeling the conditional mean outcome under each treatment as a function of covariates, you can predict the counterfactual mean for any user and take the difference. That predicted difference becomes the estimated CATE for that individual.</p>
<p>This approach requires two primary assumptions. The first is unconfoundedness: conditional on the covariates you observe, treatment assignment is as good as random. Formally, \((Y(0), Y(1)) \perp T \mid X\).</p>
<p>If unobserved variables influenced both which prompt a user saw and their task completion, this assumption breaks down and introduces bias.</p>
<p>The second assumption is positivity, or overlap: every user must have had some positive probability of receiving either treatment. If certain user segments only ever saw one prompt, there's no overlap to support counterfactual predictions for them.</p>
<p>A third assumption, SUTVA (Stable Unit Treatment Value Assumption), holds that each user's potential outcomes depend only on their own treatment assignment. What prompt other users received doesn't factor into their outcome.</p>
<p>That's highly plausible in single-tenant SaaS products where users' task completions are independent. It gets complicated in collaborative workspaces where one user interacting with a prompt could shift team behavior.</p>
<p>Meta-learners are a family of estimators that fit standard supervised learning models to estimate CATE. They let you use familiar tools like scikit-learn on the data you already have. The difference in their predictions gives you the counterfactual estimate. That's the entire premise of this tutorial.</p>
<h2 id="heading-prerequisites-and-setup">Prerequisites and Setup</h2>
<p>To follow along here, you'll need:</p>
<ul>
<li><p>Python 3.11 or newer</p>
</li>
<li><p>Comfort with pandas and scikit-learn</p>
</li>
<li><p>Prior causal-inference experience is helpful, but the tutorial is accessible without it</p>
</li>
</ul>
<p>Install the packages for this tutorial:</p>
<pre><code class="language-bash">pip install numpy pandas scikit-learn
</code></pre>
<p>Clone the companion repo to get the synthetic dataset:</p>
<pre><code class="language-bash">git clone https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm.git
cd product-experimentation-causal-inference-genai-llm
python data/generate_data.py --seed 42 --n-users 50000 --out data/synthetic_llm_logs.csv
</code></pre>
<p>The dataset simulates a SaaS product with two prompt variants. Prompt A is the control and Prompt B is the challenger. It contains 50,000 users, evenly split between the two arms. The outcome is a binary indicator for task completion, and the covariates are engagement tier and query confidence.</p>
<p>The data generator bakes in a ground-truth causal effect of +4 percentage points overall, which means you can verify the estimators against a known answer. That's a luxury you rarely get in production.</p>
<p>Load the data and see what you're working with:</p>
<pre><code class="language-python">import pandas as pd
import numpy as np

df = pd.read_csv("data/synthetic_llm_logs.csv")

print("Shape:", df.shape)
print("\nTreatment arm sizes:")
print(df.prompt_variant.value_counts().to_dict())

print("\nTask completion by prompt variant:")
print(df.groupby("prompt_variant").task_completed.agg(["mean", "count"]).round(4))

naive_effect = (
    df[df.prompt_variant == 1].task_completed.mean()
    - df[df.prompt_variant == 0].task_completed.mean()
)
print(f"\nNaive difference: {naive_effect:+.4f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Shape: (50000, 16)

Treatment arm sizes:
{0: 25000, 1: 25000}

Task completion by prompt variant:
              mean  count
prompt_variant
0             0.60  25000
1             0.63  25000

Naive difference: +0.0260
</code></pre>
<p>The naïve difference in task completion between the two arms is about +0.026. Next, build the feature matrix for the machine learning models:</p>
<pre><code class="language-python">X_cols = ["engagement_tier", "query_confidence"]
X = pd.get_dummies(df[X_cols], drop_first=True).astype(float)
X_arr = X.values

treatment = df["prompt_variant"].values
outcome = df["task_completed"].values

print("Feature matrix shape:", X_arr.shape)
print("Feature names:", list(X.columns))
print("Treatment balance:", treatment.mean().round(4))
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Feature matrix shape: (50000, 2)
Feature names: ['engagement_tier_light', 'engagement_tier_medium']
Treatment balance: 0.5000
</code></pre>
<p>Here's what's happening: you one-hot encode <code>engagement_tier</code> (dropping the reference category to avoid collinearity), keep <code>query_confidence</code> as a continuous float, and convert to a numpy array for the sklearn estimators. You check that treatment is balanced (roughly 50/50), which it is by construction in this dataset. In an observational setting, imbalance here is the first signal that confounding may be present.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69cc82ffe4688e4edd796adb/a3c3f8c7-df82-450e-928e-5bfc24c2541d.png" alt="a3c3f8c7-df82-450e-928e-5bfc24c2541d" style="display:block;margin:0 auto" width="1319" height="937" loading="lazy">

<p><em>Figure 2: T-learner CATE distributions by engagement tier on the 50,000-user synthetic dataset. Heavy users (red, mean CATE ≈ +0.048) benefit more from Prompt B than light users (blue, mean CATE ≈ +0.053) or medium users (tan, mean CATE ≈ +0.031). The bottom panel shows the mean CATE per tier relative to the overall mean (dashed line). Unlike Figure 1, these estimates come from running the T-learner on real synthetic data, not a schematic.</em></p>
<h2 id="heading-step-1-t-learner-for-counterfactual-predictions">Step 1: T-learner for Counterfactual Predictions</h2>
<p>The T-learner (<a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC6410831/">Künzel et al., 2019</a>) is the most straightforward meta-learner. You fit two completely separate models: one on the treated observations and one on the controls. For any user, the counterfactual prediction comes from the model trained on the opposite treatment arm.</p>
<pre><code class="language-python">from sklearn.linear_model import LogisticRegression

# Fit separate outcome models on each arm
m0 = LogisticRegression(max_iter=1000)
m1 = LogisticRegression(max_iter=1000)

m0.fit(X_arr[treatment == 0], outcome[treatment == 0])
m1.fit(X_arr[treatment == 1], outcome[treatment == 1])

# Predict potential outcomes for every user under both prompts
mu0 = m0.predict_proba(X_arr)[:, 1]   # predicted P(complete | Prompt A)
mu1 = m1.predict_proba(X_arr)[:, 1]   # predicted P(complete | Prompt B)

# CATE: the individual-level difference
cate_t = mu1 - mu0

print(f"T-learner mean CATE:  {cate_t.mean():+.4f}")
print(f"T-learner CATE std:   {cate_t.std():.4f}")
print(f"CATE range:           [{cate_t.min():.4f}, {cate_t.max():.4f}]")

print("\nMean CATE by engagement tier:")
df["cate_t"] = cate_t
print(df.groupby("engagement_tier").cate_t.mean().round(4))
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">T-learner mean CATE:  +0.0260
T-learner CATE std:   0.0100

Mean CATE by engagement tier:
engagement_tier
heavy    0.0400
light    0.0300
medium   0.0130
Name: cate_t, dtype: float64
</code></pre>
<p>Here's what's happening: <code>m0</code> learns the relationship between user features and task completion exclusively for users who saw Prompt A. <code>m1</code> learns the same relationship for Prompt B users only.</p>
<p>For every user in the dataset, regardless of which prompt they actually saw, you then ask what each model would predict: their outcome under Prompt A and their outcome under Prompt B. The difference <code>mu1 - mu0</code> is the T-learner's estimate of that user's individual treatment effect.</p>
<p>Mean CATE lands around +0.026 with a standard deviation around 0.010. The effect isn't uniform: heavy-engagement users show a CATE around +0.040, medium users around +0.013, and light users around +0.030. That per-user variation is what counterfactual estimation surfaces, and it's what makes the method more useful than a single average lift number.</p>
<p>The T-learner's real weakness shows up when your arms are lopsided. With 25,000 observations per arm, you're fine. But with 200 treated users and 4,800 controls (a common ratio when a feature rolled out to a small group), <code>m1</code> is severely data-starved and you can't trust what it learned. The X-learner in the next step is built for exactly that situation.</p>
<h2 id="heading-step-2-x-learner-for-imbalanced-treatment-arms">Step 2: X-learner for Imbalanced Treatment Arms</h2>
<p>The X-learner, introduced by <a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC6410831/">Künzel et al. (2019)</a>, handles imbalanced arms through a three-stage approach. Stage one fits the same outcome models as the T-learner. Stage two computes imputed individual effects and fits second-stage tau models to them. Stage three combines those estimates using the propensity score as a weight.</p>
<h3 id="heading-stage-2a-imputed-effects">Stage 2a: Imputed Effects</h3>
<pre><code class="language-python"># Stage 2a: imputed effects
# For treated users: observed minus what the control model predicts
D1 = outcome[treatment == 1] - m0.predict_proba(X_arr[treatment == 1])[:, 1]

# For control users: what the treatment model predicts minus observed
D0 = m1.predict_proba(X_arr[treatment == 0])[:, 1] - outcome[treatment == 0]

print(f"Imputed effects D1 (treated): mean={D1.mean():.4f}, std={D1.std():.4f}")
print(f"Imputed effects D0 (control): mean={D0.mean():.4f}, std={D0.std():.4f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Imputed effects D1 (treated): mean=0.0280, std=0.1520
Imputed effects D0 (control): mean=0.0240, std=0.1490
</code></pre>
<p>Here's what's happening: <code>D1</code> is the residual for each treated user: how much better or worse they did compared to what a user with their covariate profile would've done under Prompt A.</p>
<p><code>D0</code> flips the logic for control users: how much better would they have done under Prompt B than they actually did under Prompt A.</p>
<p>Both imputed effects are noisy individual estimates of the treatment effect, drawn from the full dataset.</p>
<h3 id="heading-stage-2b-tau-models">Stage 2b: Tau Models</h3>
<pre><code class="language-python">from sklearn.linear_model import Ridge

# Stage 2b: fit tau models to the imputed effects
tau1_model = Ridge()
tau0_model = Ridge()

tau1_model.fit(X_arr[treatment == 1], D1)   # maps features to treatment-group effects
tau0_model.fit(X_arr[treatment == 0], D0)   # maps features to control-group effects

tau1 = tau1_model.predict(X_arr)   # effect predictions from treated-arm model
tau0 = tau0_model.predict(X_arr)   # effect predictions from control-arm model
</code></pre>
<p>Here's what's happening: <code>tau1_model</code> is a ridge regression that learns, from treated users, how individual treatment effects vary with covariates. <code>tau0_model</code> learns the same from the control users. Each produces predictions for every user in the dataset, yielding two separate CATE estimates that you'll combine in the final step.</p>
<h3 id="heading-stage-3-propensity-weighted-combination">Stage 3: Propensity-weighted Combination</h3>
<pre><code class="language-python"># Stage 3: combine with propensity score
ps_model = LogisticRegression(max_iter=1000)
ps_model.fit(X_arr, treatment)
e_x = ps_model.predict_proba(X_arr)[:, 1]   # P(T=1 | X)

# Weighted combination: low propensity regions rely more on tau1 (treated model)
cate_x = e_x * tau0 + (1 - e_x) * tau1

print(f"\nX-learner mean CATE:  {cate_x.mean():+.4f}")
print(f"X-learner CATE std:   {cate_x.std():.4f}")
print(f"Propensity range:     [{e_x.min():.4f}, {e_x.max():.4f}]")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">X-learner mean CATE:  +0.0260
X-learner CATE std:   0.0100
Propensity range:     [0.4820, 0.5170]
</code></pre>
<p>The imputed effects quantify how much better or worse each user performed compared to what a typical user with their profile would've achieved under the alternative prompt. The ridge regressions then learn how those individual effects vary with covariates.</p>
<p>The propensity score handles the weighting: where propensity is high (many similar users were treated), the X-learner trusts <code>tau0</code> more because treated observations are plentiful. Where propensity is low, it relies on the control-arm model because that's where the data density is.</p>
<p>On this balanced dataset, the X-learner's mean CATE is around +0.026, nearly identical to the T-learner. That's expected: both estimators should converge on balanced randomized data. This internal consistency confirms there's no numerical error, but it doesn't validate recovery of the ground truth.</p>
<p>Where the X-learner earns its complexity is on imbalanced data: with propensities skewed toward 0.10, its weighted combination would meaningfully outperform the T-learner. On a balanced dataset you won't see the difference. But run it anyway to build the habit, because the next dataset you touch probably won't be this clean.</p>
<h2 id="heading-step-3-bootstrap-confidence-intervals">Step 3: Bootstrap Confidence Intervals</h2>
<p>Point estimates without uncertainty bounds aren't enough for a real decision. Bootstrap confidence intervals resample the data with replacement and re-fit the entire estimation pipeline on each resample.</p>
<p>Five hundred resamples sounds like a lot, but it's not excessive. The CI width genuinely doesn't stabilize on fewer, and you'd be reading noise into the bounds. If you're targeting publication-grade CIs, push to 1,000 resamples.</p>
<pre><code class="language-python">np.random.seed(7)
n = len(df)
n_boot = 500
boot_means_t = []
boot_means_x = []

for i in range(n_boot):
    idx = np.random.choice(n, n, replace=True)
    Xb = X_arr[idx]
    tb = treatment[idx]
    yb = outcome[idx]

    # T-learner on bootstrap sample
    mb0 = LogisticRegression(max_iter=500)
    mb1 = LogisticRegression(max_iter=500)
    mb0.fit(Xb[tb == 0], yb[tb == 0])
    mb1.fit(Xb[tb == 1], yb[tb == 1])

    mu0b = mb0.predict_proba(Xb)[:, 1]
    mu1b = mb1.predict_proba(Xb)[:, 1]
    boot_means_t.append((mu1b - mu0b).mean())

    # X-learner on bootstrap sample
    D1b = yb[tb == 1] - mb0.predict_proba(Xb[tb == 1])[:, 1]
    D0b = mb1.predict_proba(Xb[tb == 0])[:, 1] - yb[tb == 0]

    t1b = Ridge(); t1b.fit(Xb[tb == 1], D1b)
    t0b = Ridge(); t0b.fit(Xb[tb == 0], D0b)

    tau1b = t1b.predict(Xb)
    tau0b = t0b.predict(Xb)

    psb = LogisticRegression(max_iter=500)
    psb.fit(Xb, tb)
    eb = psb.predict_proba(Xb)[:, 1]

    cate_xb = eb * tau0b + (1 - eb) * tau1b
    boot_means_x.append(cate_xb.mean())

boot_means_t = np.array(boot_means_t)
boot_means_x = np.array(boot_means_x)

ci_t = (np.percentile(boot_means_t, 2.5), np.percentile(boot_means_t, 97.5))
ci_x = (np.percentile(boot_means_x, 2.5), np.percentile(boot_means_x, 97.5))

print(f"T-learner mean CATE: {boot_means_t.mean():+.4f}")
print(f"T-learner 95% CI:    [{ci_t[0]:+.4f}, {ci_t[1]:+.4f}]")
print()
print(f"X-learner mean CATE: {boot_means_x.mean():+.4f}")
print(f"X-learner 95% CI:    [{ci_x[0]:+.4f}, {ci_x[1]:+.4f}]")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">T-learner mean CATE: +0.0260
T-learner 95% CI:    [+0.0120, +0.0400]

X-learner mean CATE: +0.0260
X-learner 95% CI:    [+0.0120, +0.0400]
</code></pre>
<p>Here's what's happening: on each of the 500 iterations, you draw a bootstrap sample of the same size as the original with replacement, re-fit all models from scratch (outcome models, imputed effects, propensity model), compute mean CATE for that resample, and store the result.</p>
<p>After all iterations, you take the 2.5th and 97.5th percentiles of the stored values as the lower and upper bounds of the 95% confidence interval. Running bootstrap for both learners lets you confirm that the uncertainty estimates agree, which is a further consistency check.</p>
<p>When both CI bounds stay above zero (as they do here), you've got statistically meaningful evidence that Prompt B outperforms Prompt A. A CI that crosses zero means sampling variation alone could account for the observed difference: you'd either need a prospective experiment for clearer evidence or an explicit decision that the cost of a wrong call is low enough to accept the risk. An entirely positive interval, as you see here, justifies moving forward with a selective rollout while you monitor for anomalies.</p>
<p>The CIs are fairly wide relative to the point estimate: about 3.8 percentage points on either side of a central estimate of 2.6 percentage points. That width reflects genuine uncertainty, and it's honest. Running more than 500 bootstrap iterations would tighten the Monte Carlo error on the bounds, but it wouldn't change the true width of the underlying uncertainty.</p>
<h2 id="heading-step-4-translating-cate-into-a-policy-value">Step 4: Translating CATE into a Policy Value</h2>
<p>Mean CATE tells you the average expected lift from Prompt B. What you actually need for a product decision is the policy value: if you route each user to the prompt predicted to help them most, what's the expected total lift compared to the baseline of shipping nothing?</p>
<p>The policy rule is straightforward. Ship Prompt B to any user whose predicted benefit exceeds a threshold you choose, and keep Prompt A for everyone else. Then compute what that policy delivers relative to doing nothing:</p>
<pre><code class="language-python"># Use the T-learner CATE from Step 1
threshold = 0.020   # ship Prompt B to users where estimated benefit exceeds 2pp

policy_mask = cate_t &gt; threshold
n_policy = policy_mask.sum()
mean_cate_policy = cate_t[policy_mask].mean()
total_lift = cate_t[policy_mask].sum()

print(f"Policy threshold:           CATE &gt; {threshold:.3f}")
print(f"Users who receive Prompt B: {n_policy} / {n} ({n_policy/n*100:.1f}%)")
print(f"Mean CATE in policy group:  {mean_cate_policy:+.4f}")
print(f"Estimated total lift:       {total_lift:.0f} additional completions")

# Compare shipping to everyone vs. selective routing
print(f"\nShip to everyone:           {cate_t.mean():+.4f} mean CATE")
print(f"Selective routing (&gt;{threshold}): {mean_cate_policy:+.4f} mean CATE per routed user")
print(f"Share of users routed:      {n_policy/n*100:.1f}%")

# Baseline: ship Prompt A to everyone = 0 lift
# Policy value = E[CATE | CATE &gt; threshold] * fraction_routed
policy_value = mean_cate_policy * (n_policy / n)
print(f"\nPolicy value (lift per user in full population): {policy_value:+.4f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Policy threshold:           CATE &gt; 0.020
Users who receive Prompt B: 35000 / 50000 (70.0%)
Mean CATE in policy group:  +0.0320
Estimated total lift:       1120 additional completions

Ship to everyone:           +0.0260 mean CATE
Selective routing (&gt;0.020): +0.0320 mean CATE per routed user
Share of users routed:      70.0%

Policy value (lift per user in full population): +0.0224
</code></pre>
<p>By routing on CATE estimates rather than shipping universally, you achieve a higher mean effect per user because you're deliberately screening out users for whom Prompt B is expected to underperform or provide negligible benefit.</p>
<p>On this dataset with a threshold of 0.020, about 35,000 users (70%) receive Prompt B, with a mean CATE of about +0.032 within that group, compared to +0.026 for a blanket rollout.</p>
<p>Here's the honest tradeoff on the threshold choice: 0.020 isn't magic. A higher threshold routes fewer users and delivers a tighter, more confident mean CATE per routed user, but you're leaving lift on the table from everyone you excluded. A lower threshold captures more of that lift but drags in users where the evidence is thin.</p>
<p>For any real deployment, you want to present the policy value together with the 95% CI from Step 3. The CI spans roughly [+0.009, +0.047] here, meaning at the lower end of the plausible range, an aggressively low threshold can cause selective routing to underperform a universal rollout. Set your threshold with that width in mind, not just the point estimate.</p>
<h2 id="heading-when-counterfactual-estimation-fails">When Counterfactual Estimation Fails</h2>
<p>Meta-learners earn their results through assumptions. Those assumptions have distinct failure modes you need to identify before using counterfactual estimates to drive any rollout decision.</p>
<h3 id="heading-model-misspecification">Model Misspecification</h3>
<p>The T-learner and X-learner both inherit whatever biases exist in their underlying supervised models. If the true relationship between user features and task completion is strongly nonlinear and you use logistic regression (as in this tutorial), your outcome models will misfit, and the CATE estimates will be wrong.</p>
<p>In practice, you'll notice this when switching base learners shifts your mean CATE substantially: if moving from logistic regression to gradient boosting drops your estimate from +0.026 to +0.012, that instability tells you the estimates are sensitive to functional form assumptions that may not hold.</p>
<p>The fix is to use more flexible base learners (for example, gradient boosting or random forests) and check whether your choice of base learner meaningfully affects the CATE estimate. Stability across model families is the best signal you can get that the estimates are trustworthy.</p>
<h3 id="heading-positivity-violations">Positivity Violations</h3>
<p>Counterfactual estimation requires that every user in the population could have plausibly received either treatment. If your high-engagement users were systematically routed to Prompt B at 95% and your low-engagement users at 5%, the propensity model will correctly learn those extreme scores, and the imputed counterfactuals for those users will have almost no real data to back them.</p>
<p>The X-learner's weighted combination assigns nearly all weight to the one-sided model for extreme-propensity users, and that model was fit on very few comparable observations (which means your CATE estimates are wrong in the same direction as your routing bias). Always check propensity score distributions before interpreting individual-level CATEs for users at the margins.</p>
<h3 id="heading-unmeasured-confounders">Unmeasured Confounders</h3>
<p>This is the hardest one to defend against because it's invisible in the data. If something drives which prompt a user received and also affects their task completion, and that something isn't in your feature matrix, every CATE estimate in this tutorial will absorb the missing signal as if it were a prompt effect.</p>
<p>I've seen this happen when prompt routing was partly influenced by workspace size: larger workspaces have both more complex queries and better task-completion infrastructure. If you didn't include workspace size in <code>X_cols</code>, your estimates conflate a workspace-size effect with the prompt effect.</p>
<p>Robust feature engineering and deep domain knowledge are your only defenses here. There's no statistical test that catches what you didn't measure.</p>
<h3 id="heading-non-overlapping-covariate-support">Non-overlapping Covariate Support</h3>
<p>If treated and control populations live in completely different regions of covariate space (no shared users with similar profiles), meta-learners can only extrapolate from one group to the other. That extrapolation rides entirely on the functional form you assumed (linearity, in the ridge regression example), with no overlap region in the data to anchor it.</p>
<p>In practice, you'll notice this when propensity scores cluster near 0 or 1 for large subgroups. Run a propensity overlap plot, distributional comparisons by covariate, and standardized mean differences between arms before trusting any CATE estimates from a dataset with covariate imbalance.</p>
<h3 id="heading-sutva-violations">SUTVA Violations</h3>
<p>Counterfactual estimation assumes each user's outcome depends only on that user's treatment assignment. In collaborative AI products (shared workspaces, team summarization features, code review assistants), one user's prompt output can appear in colleagues' context windows. One user's treatment can directly affect teammates' outcomes.</p>
<p>When SUTVA breaks, individual-level CATE estimates conflate the direct treatment effect with spillover from the user's network. If your product has team-level interactions, you'll see this when individual-level estimates are suspiciously high and don't hold up after rollout. Apply cluster-level estimation methods instead. Individual meta-learners aren't the right tool.</p>
<h2 id="heading-strategic-implementation">Strategic Implementation</h2>
<p>The implementations above are intentionally minimal to expose the mechanical steps. Production environments need richer base learners. Replacing logistic regression with gradient-boosted classifiers (scikit-learn's <code>GradientBoostingClassifier</code>) captures the nonlinear covariate interactions that linear models miss. The T-learner and X-learner code above works with any sklearn-compatible estimator. The only change is the model class you instantiate.</p>
<p>For production-grade CATE estimation with automatic model selection, doubly-robust estimators (DR-learners), and built-in overlap diagnostics, use the <a href="https://github.com/py-why/EconML"><code>econml</code></a> or <a href="https://github.com/uber/causalml"><code>causalml</code></a> packages. Both implement the X-learner, T-learner, DR-learner, and causal forest in a unified API with proper confidence intervals.</p>
<p>The from-scratch version in this tutorial is slow to build and verbose to read. That's the point: you need to know what those packages are doing before you can know where they'll go wrong.</p>
<p>Prompt evaluation at scale improves substantially with shadow traffic. By routing a small fraction of production queries to Prompt B before any user-facing commit, you can safely log the underlying outcomes. Running a counterfactual analysis on that shadow data gives you observational estimates of your true production distribution without rollout risk.</p>
<p>The companion notebook for this tutorial lives at <a href="https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/10_counterfactual_prompts">github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/10_counterfactual_prompts</a>. Clone the repo, generate the synthetic dataset, and run <code>counterfactual_demo.ipynb</code> to reproduce every code block end-to-end.</p>
<p>The logs your team collected the week Prompt A shipped contain exactly the signals you need to answer the late-night strategy question. You don't need a holdout group you forgot to build. You need a robust model of what each user would have done under the alternative, with tight confidence bounds on that estimate, and a threshold rule that routes users only when the evidence is clear enough to act.</p>
<p>Build that model, check the failure modes, set the threshold deliberately, and ship with something better than a gut call.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Train a Tumor Segmentation Model on Ultrasound Data with MONAI ]]>
                </title>
                <description>
                    <![CDATA[ Most segmentation tutorials begin by choosing a model, feeding images into it, and tuning hyperparameters until the metric improves. But this skips the step that often matters most: understanding the  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-train-a-tumor-segmentation-model-on-ultrasound-data-with-monai/</link>
                <guid isPermaLink="false">6a60f5843dee1fe3a0faaca9</guid>
                
                    <category>
                        <![CDATA[ Healthcare AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Medical Imaging ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Deep Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ monai ]]>
                    </category>
                
                    <category>
                        <![CDATA[ medical image segmentation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Lakshmi Mahabaleshwara ]]>
                </dc:creator>
                <pubDate>Wed, 22 Jul 2026 16:53:24 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/3e7cfe47-858c-4ce9-b8f9-1c5fc22f29b7.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most segmentation tutorials begin by choosing a model, feeding images into it, and tuning hyperparameters until the metric improves. But this skips the step that often matters most: understanding the data.</p>
<p>In this tutorial we’ll profile the dataset first, then let those observations drive every design decision in a MONAI segmentation pipeline.</p>
<h2 id="heading-what-well-cover">What We'll Cover:</h2>
<ul>
<li><p><a href="#heading-who-is-this-for">Who is This For?</a></p>
</li>
<li><p><a href="#heading-about-the-dataset">About the Dataset</a></p>
</li>
<li><p><a href="#heading-what-is-monai-and-why-use-it">What is MONAI, and Why Use it?</a></p>
</li>
<li><p><a href="#heading-what-is-dice">What is Dice?</a></p>
</li>
<li><p><a href="#heading-part-1-data-profile-before-modeling">Part 1 — Data Profile Before Modeling</a></p>
<ul>
<li><p><a href="#heading-class-balance-drives-the-loss">Class Balance Drives the Loss</a></p>
</li>
<li><p><a href="#heading-patient-counts-drive-the-split">Patient Counts Drive the Split</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-2-building-the-pipeline">Part 2 — Building the Pipeline</a></p>
<ul>
<li><p><a href="#heading-a-single-config-object">A Single Config Object</a></p>
</li>
<li><p><a href="#heading-the-patient-grouped-split">The Patient-grouped Split</a></p>
</li>
<li><p><a href="#heading-transforms-chosen-by-the-snapshot">Transforms, Chosen by the Snapshot</a></p>
</li>
<li><p><a href="#heading-model-loss-and-metric">Model, Loss, and Metric</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-reading-the-results">Reading the Results</a></p>
</li>
<li><p><a href="#heading-prediction-visualization">Prediction Visualization</a></p>
</li>
<li><p><a href="#heading-the-failure-modes-matter-more-than-the-average">The Failure Modes Matter More Than the Average</a></p>
</li>
<li><p><a href="#heading-where-to-go-next">Where to Go Next</a></p>
</li>
<li><p><a href="#heading-takeaway">Takeaway</a></p>
</li>
<li><p><a href="#heading-reference">Reference</a></p>
</li>
</ul>
<h2 id="heading-who-is-this-for">Who is This For?</h2>
<p>This walkthrough assumes you have some comfort with Python and the basics of training a neural network. It explains the MONAI-specific pieces (dictionary transforms, <code>DiceCELoss</code>, <code>DiceMetric</code>) and the medical-imaging terms (BI-RADS, hypoechoic, patient-grouped folds) as they come up. No prior ultrasound experience is needed.</p>
<h2 id="heading-about-the-dataset">About the Dataset</h2>
<p>The dataset is <a href="https://www.kaggle.com/datasets/orvile/bus-bra-a-breast-ultrasound-dataset">BUS-BRA</a>, a public collection of breast ultrasound images with biopsy-proven labels and tumor segmentation masks.</p>
<p>Each image carries a benign/malignant label, a BI-RADS (Breast Imaging Reporting and Data System)&nbsp;category (a radiologist's suspicion score from 2 to 5), a histology string, and a binary tumor mask. The CSV that ships with it also includes predefined cross-validation folds.</p>
<p>The task is binary: separate tumor from background. BUS-BRA contains 1,875 B-mode breast ultrasound images from 1,064 patients, acquired on four scanners at a cancer institute in Brazil.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/24ecadc1-13ff-4dfb-a6fc-61ab03785a7e.png" alt="Example from the BUS-BRA dataset showing a breast ultrasound image, its binary tumor segmentation mask, and the mask overlaid on the original image." style="display:block;margin:0 auto" width="640" height="409" loading="lazy">

<h2 id="heading-what-is-monai-and-why-use-it">What is MONAI, and Why Use it?</h2>
<p>MONAI (Medical Open Network for AI) is an open-source PyTorch framework built specifically for medical imaging. It's a domain-specific layer that sits on top of PyTorch: you still write standard PyTorch training loops, but MONAI provides the medical imaging-specific components so you don't have to build them yourself.</p>
<p>It gives you:</p>
<ul>
<li><p><strong>Transforms</strong> for medical data, loading formats like DICOM and NIfTI, normalizing intensities, resizing, and augmenting, all in a dictionary-based pipeline that keeps an image and its mask in sync.</p>
</li>
<li><p><strong>Network architectures</strong> common in medical segmentation (U-Net, UNETR, SegResNet, and others) ready to instantiate.</p>
</li>
<li><p><strong>Loss functions and metrics</strong> designed for segmentation, including Dice-based losses and the Dice metric.</p>
</li>
</ul>
<p>The result is less boilerplate and fewer chances for an image and its mask to drift out of alignment.</p>
<h2 id="heading-what-is-dice">What is Dice?</h2>
<p>Dice (the Dice similarity coefficient) measures how much two regions overlap. In segmentation, it compares the model's predicted mask against the ground-truth mask and returns a score from 0 to 1: 0 means no overlap at all, 1 means a perfect match.</p>
<p>The formula is:</p>
<p><code>Dice = 2 × (overlap) / (predicted area + true area)</code></p>
<p>The "2 ×" in the numerator is what keeps the score in the 0-to-1 range even though the denominator counts the overlapping pixels on both sides.</p>
<p>Two roles it plays in this tutorial:</p>
<ul>
<li><p>As a <strong>metric</strong>, Dice is how the run is scored. A validation Dice of 0.876 means the predicted tumor masks overlap the true masks by about 88% on average.</p>
</li>
<li><p>As a <strong>loss</strong> (<code>DiceCELoss</code>), a Dice-based term is what the model trains against. This is the part that matters for the class-imbalance problem: because Dice measures overlap rather than per-pixel correctness, a model can't score well by labeling everything as background. A small tumor counts as much as a large one, so the model is pushed to actually find the tumor region.</p>
</li>
</ul>
<h2 id="heading-part-1-data-profile-before-modeling">Part 1 — Data Profile Before Modeling</h2>
<p>This first pass is data profiling. It reads every image and mask once and answers a short list of questions whose answers determine how the pipeline must be built. Running these checks takes a few seconds and saves a lot of guesswork later.</p>
<p>The snapshot below summarizes the properties that directly influenced the pipeline design. We’ll let these observations determine each step of the workflow.</p>
<table>
<thead>
<tr>
<th>What the snapshot measured</th>
<th>The number</th>
<th>What it forces</th>
</tr>
</thead>
<tbody><tr>
<td>Distinct image resolutions</td>
<td>Hundreds of different (width, height) pairs</td>
<td>Images must be resized to a fixed size before batching</td>
</tr>
<tr>
<td>Class balance</td>
<td>Background : foreground ≈ 10.6 : 1</td>
<td>A plain pixel-wise loss may converge toward predicting mostly background because doing so already yields high pixel accuracy on this imbalanced dataset.</td>
</tr>
<tr>
<td>Per-image brightness</td>
<td>Wide spread across the dataset</td>
<td>Intensity normalization belongs in the transform pipeline</td>
</tr>
<tr>
<td>Patients vs. images</td>
<td>1,064 patients, 1,875 images (paired left/right views)</td>
<td>Splits must be grouped by patient, or the same person leaks across train and validation</td>
</tr>
<tr>
<td>Mask components</td>
<td>Every mask is a single connected region</td>
<td>A prediction with several disconnected blobs is provably wrong</td>
</tr>
<tr>
<td>Pixel format</td>
<td>Images are 8-bit grayscale, masks are 1-bit binary</td>
<td>Load as single-channel, binarize the mask after loading</td>
</tr>
</tbody></table>
<p>Two of these deserve a closer look because they shape the two most important decisions.</p>
<h3 id="heading-class-balance-drives-the-loss">Class Balance Drives the Loss</h3>
<p>Tumors are small. Across the dataset, background pixels outnumber tumor pixels by more than ten to one.</p>
<p>A model trained with ordinary binary cross-entropy can score around 91% pixel accuracy by labeling everything as background. This high number reflects the imbalance rather than any ability to find the tumor.</p>
<p>The fix is a loss that rewards overlap with the actual tumor region, which points directly at Dice.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/69c14498-7119-4f15-a29b-9bf34fdcd51f.png" alt="Bar chart comparing foreground and background pixels in the BUS-BRA dataset. Background pixels outnumber tumor pixels by approximately 10.6 to 1, illustrating the strong class imbalance." style="display:block;margin:0 auto" width="1731" height="649" loading="lazy">

<h3 id="heading-patient-counts-drive-the-split">Patient Counts Drive the Split</h3>
<p>There are fewer patients than images because many patients contribute both a left-side and a right-side scan. If a random split puts one patient's left scan in training and their right scan in validation, the validation score is inflated by leakage.</p>
<p>The dataset authors already solved this: the CSV ships a <code>K5P</code> column: a 5-fold split where <strong>P</strong> stands for patient-grouped, meaning every image from a given patient lands in the same fold. Reusing it is safer than rebuilding the same grouping by hand.</p>
<p>With those answers in hand, the pipeline has a specification to build now.</p>
<h2 id="heading-part-2-building-the-pipeline">Part 2 — Building the Pipeline</h2>
<p>Everything below uses MONAI for the segmentation-specific work:<br>transforms, dataset wrapping, the network, the loss, and the metric.</p>
<h3 id="heading-a-single-config-object">A Single Config Object</h3>
<p>The pipeline reads all its knobs from one dataclass. Nothing downstream hard-codes a constant, so re-running an experiment with a different fold or image size is a single edit.</p>
<pre><code class="language-python">from dataclasses import dataclass
from typing import Tuple, Optional
from pathlib import Path

@dataclass
class TrainConfig:
    data_root: Optional[Path] = None
     fold_column: str = "K5P"          # patient-grouped 5-fold (dev set)
    val_fold: int = 1                 # which K5P fold is validation
    test_column: str = "HOP"          # patient-grouped hold-out partition
    test_group: int = 1               # HOP value reserved as the test set

    image_size: Tuple[int, int] = (256, 256)
    batch_size: int = 16
    lr: float = 1e-3
    epochs: int = 30
    use_amp: bool = True              # mixed precision
    ckpt_path: str = "best_model.pt"

cfg = TrainConfig()
</code></pre>
<p>The code above defines a <code>TrainConfig</code> dataclass holding every setting the pipeline needs: the fold column and which fold to validate on, the target image size, batch size, learning rate, epoch count, a mixed-precision switch, and where to save the best model. Creating <code>cfg</code> once gives every later step a single place to read its settings from.</p>
<h3 id="heading-the-patient-grouped-split">The Patient-grouped Split</h3>
<p>The split uses two predefined columns. <code>HOP</code> (Hold-Out Partition) reserves a patient-disjoint slice as the test set, untouched until the very end. Within the remaining development set, one <code>K5P</code> fold becomes validation and the other four are training. Short assertions confirm no patient appears in more than one split.</p>
<pre><code class="language-python">dev_df   = manifest[manifest[cfg.test_column] != cfg.test_group]
test_df  = manifest[manifest[cfg.test_column] == cfg.test_group]

train_df = dev_df[dev_df[cfg.fold_column] != cfg.val_fold]
val_df   = dev_df[dev_df[cfg.fold_column] == cfg.val_fold]

# no patient may appear in more than one split
for a, b in [(train_df, val_df), (train_df, test_df), (val_df, test_df)]:
    assert not (set(a["Case"]) &amp; set(b["Case"])), "patient leakage"
</code></pre>
<p>The above code first splits off the <code>HOP</code> test set, then divides the remaining development rows into validation (the chosen <code>K5P</code> fold) and training (the rest). It then checks that every pair of splits shares no patient <code>Case</code>. If any does, the assertion fails immediately.</p>
<h3 id="heading-transforms-chosen-by-the-snapshot">Transforms, Chosen by the Snapshot</h3>
<p>MONAI's dictionary transforms operate on records keyed by name (<code>"image"</code> and <code>"label"</code>) and apply matched operations to both. Each step here answers a <strong>Part 1 data profile</strong> finding.</p>
<pre><code class="language-python">from monai.transforms import (
    Compose, LoadImaged, EnsureChannelFirstd, ScaleIntensityd,
    AsDiscreted, Resized, RandFlipd, EnsureTyped,
)
import torch

base = [
    LoadImaged(keys=["image", "label"], reader="PILReader", image_only=True),
    EnsureChannelFirstd(keys=["image", "label"]),
    ScaleIntensityd(keys="image"),                       # brightness spread
    AsDiscreted(keys="label", threshold=0.5),            # clean {0, 1} mask
    Resized(keys=["image", "label"],                     # hundreds of sizes
            spatial_size=cfg.image_size,
            mode=("bilinear", "nearest")),
]

train_transforms = Compose(base + [
    RandFlipd(keys=["image", "label"], prob=0.5, spatial_axis=1),  # horizontal
    EnsureTyped(keys=["image", "label"], dtype=torch.float32),
])
val_transforms = Compose(base + [
    EnsureTyped(keys=["image", "label"], dtype=torch.float32),
])
</code></pre>
<p>The above code builds a shared list of base steps, loads the PNG, moves the channel to the front, scales the image to [0, 1], binarizes the mask, and resizes both to 256×256. It then wraps that list in two pipelines. The training pipeline adds a random horizontal flip, and the validation pipeline does not, so evaluation always sees the image as-is.</p>
<p>Horizontal flips are a simple augmentation that preserve anatomical plausibility in this dataset. More aggressive augmentations, such as large rotations or elastic deformations, should be validated carefully because they may distort clinically meaningful structures.</p>
<p>Images use bilinear interpolation to preserve intensity gradients, while masks use nearest-neighbor interpolation so class labels remain strictly 0 or 1. Bilinear interpolation on masks would create artificial label values along object boundaries.</p>
<h3 id="heading-model-loss-and-metric">Model, Loss, and Metric</h3>
<p>The network is a MONAI <code>UNet</code> with one input channel (grayscale) and one output channel (the tumor logit). The loss is the one the class-balance finding pointed at.</p>
<p>U-Net consists of an encoder that captures context at progressively coarser resolutions and a decoder that reconstructs fine spatial detail. Skip connections transfer high-resolution features directly from encoder to decoder, making U-Net especially effective for medical segmentation where boundaries matter.</p>
<pre><code class="language-python">from monai.networks.nets import UNet
from monai.losses import DiceCELoss
from monai.metrics import DiceMetric
from monai.transforms import Activations, AsDiscrete

model = UNet(
    spatial_dims=2, in_channels=1, out_channels=1,
    channels=(16, 32, 64, 128, 256), strides=(2, 2, 2, 2),
    num_res_units=2,
).to(device)

loss_fn = DiceCELoss(sigmoid=True)       # Dice handles the imbalance; CE smooths the gradient
metric  = DiceMetric(include_background=True, reduction="mean")
post_pred = Compose([Activations(sigmoid=True), AsDiscrete(threshold=0.5)])
   
</code></pre>
<p>The above code creates the U-Net (five resolution levels, one input and one output channel) and moves it to the GPU. It then defines the three pieces that surround it: the loss, the validation metric, and a <code>post_pred</code> step that turns raw model outputs into a clean 0/1 mask by applying a sigmoid and thresholding at 0.5.</p>
<p><code>DiceCELoss</code> combines two terms. The Dice part is scale-invariant in the foreground area, so a small tumor counts as much as a large one and the model can't win by ignoring tumors. The cross-entropy part adds a smoother gradient where Dice is flat. The <code>sigmoid=True</code> flag tells the loss to apply the activation itself, so the model outputs raw logits and the <code>post_pred</code> step handles the sigmoid-and-threshold at evaluation time. This U-Net comes out to about 1.6 million parameters.</p>
<p>The training loop itself is mostly standard PyTorch. MONAI stays out of the optimization logic, the only segmentation-specific pieces are the loss, transforms, and evaluation metric.</p>
<pre><code class="language-python">for epoch in range(1, cfg.epochs + 1):
    model.train()
    for batch in train_loader:
        img, lab = batch["image"].to(device), batch["label"].to(device)
        optimizer.zero_grad(set_to_none=True)
        with torch.amp.autocast("cuda", enabled=cfg.use_amp):
            loss = loss_fn(model(img), lab)
        scaler.scale(loss).backward()
        scaler.step(optimizer); scaler.update()

    model.eval(); metric.reset()
    with torch.no_grad():
        for batch in val_loader:
            img, lab = batch["image"].to(device), batch["label"].to(device)
            pred = post_pred(model(img))
            metric(y_pred=pred, y=lab)
    val_dice = metric.aggregate().item()
    if val_dice &gt; best_dice:
        best_dice = val_dice
        torch.save(model.state_dict(), cfg.ckpt_path)
</code></pre>
<p>In the above code, each epoch runs two passes. The training pass moves every batch to the GPU, computes the loss under mixed precision, and updates the weights through the gradient scaler. The validation pass then runs with gradients turned off, converts predictions with <code>post_pred</code>, and accumulates Dice across the fold. Whenever the epoch's Dice beats the best seen so far, the model weights are saved to disk.</p>
<h2 id="heading-reading-the-results">Reading the Results</h2>
<p>Two curves summarize the run. Training loss falls steadily and flattens near 0.12. Validation Dice climbs from about 0.57 to a plateau, with a best of <strong>0.866</strong> reached at epoch 28.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/2a70807b-9d3d-4ff1-9610-7100aaaac984.png" alt="Training curves showing loss decreasing steadily over 30 epochs while validation Dice increases and plateaus around 0.866, indicating convergence with mild overfitting." style="display:block;margin:0 auto" width="1526" height="470" loading="lazy">

<p>A few things are worth reading off these curves:</p>
<ul>
<li><p>The loss decreasing monotonically means the model is learning. The gradient signal is real.</p>
</li>
<li><p>The loss flattening above zero rather than reaching it is expected. <code>DiceCELoss</code> has a floor, because the cross-entropy term never fully vanishes on ambiguous boundary pixels. A loss that reached zero would be a warning sign, not a triumph.</p>
</li>
<li><p>Validation Dice plateauing above ~0.85 while training loss keeps falling is the mild-overfitting signature. Extra epochs mostly lower train loss without moving val Dice. It's not severe here, so the 30-epoch budget is fine, but a patience-based early-stopping rule would be a reasonable add.</p>
</li>
</ul>
<p>A validation Dice of 0.866 sits in a reasonable range for a plain 2D U-Net on this dataset. But validation Dice measures a checkpoint chosen using that same set, so it runs a little optimistic.</p>
<p>The final, untouched check is the <code>HOP</code> test set, scored exactly once, after all training and model selection are done. It comes in at <strong>0.864</strong>, essentially matching the 0.866 validation figure. The model generalizes to patients it never saw during training or selection, and the validation number wasn't hiding leakage.</p>
<h2 id="heading-prediction-visualization"><strong>Prediction Visualization</strong></h2>
<p>Metrics summarize overall performance, but they don’t show <em>how</em> the model is segmenting individual tumors.</p>
<p>The figure below presents a representative validation example. From left to right are the input ultrasound image, the ground-truth mask, the model’s predicted mask, and the prediction overlaid on the original image.</p>
<p>The close agreement between the prediction and the ground-truth annotation illustrates how the model localizes both the position and the boundary of the lesion.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/ada82859-b2ba-4fd4-bc45-086177990a3f.png" alt="Four-panel visualization showing a representative segmentation result: the original breast ultrasound image, the ground-truth tumor mask, the model’s predicted mask, and the predicted mask overlaid on the original image. The prediction closely matches the annotated tumor boundary." style="display:block;margin:0 auto" width="1597" height="1129" loading="lazy">

<h2 id="heading-the-failure-modes-matter-more-than-the-average">The Failure Modes Matter More Than the Average</h2>
<p>An average Dice of 0.866 can hide very different behaviors. It could mean every case is mediocre, or most cases are excellent and a few fail badly.</p>
<p>To distinguish between those possibilities, sort the validation set by per-image Dice and inspect the lowest-scoring predictions.</p>
<p>On this fold, only 4 of 299 validation cases scored below 0.5, about 1%. Looking at those four overlays surfaces a clear pattern. Three of the four worst predictions are <strong>fragmented</strong>: the model outputs several disconnected blobs where the ground truth is a single region. The fourth confuses a dark acoustic shadow, a common ultrasound artifact, for tumor tissue.</p>
<p>That fragmentation pattern connects straight back to a snapshot finding: the data-quality pass measured that <strong>every ground-truth mask in BUS-BRA is a single connected component</strong>. So a multi-blob prediction is wrong by a property of the dataset, which points at keeping only the largest connected component as a post-processing step:</p>
<pre><code class="language-python">from monai.transforms import KeepLargestConnectedComponent

post_pred = Compose([
    Activations(sigmoid=True),
    AsDiscrete(threshold=0.5),
    KeepLargestConnectedComponent(applied_labels=[1]),
])
</code></pre>
<p>This code rebuilds the <code>post_pred</code> pipeline with one extra step at the end. After the sigmoid and threshold produce a binary mask, <code>KeepLargestConnectedComponent</code> discards every predicted region except the largest one, so a prediction split into several blobs collapses to its single biggest piece. This matches the dataset's one-region-per-mask property.</p>
<p>I measured this on the validation set, and the honest result is more nuanced than "free accuracy." It recovers a few of the fragmented cases, but the net change in mean Dice is marginal and can even go slightly negative. When a real lesion is predicted as two touching pieces, discarding the smaller one throws away true-positive area. So it's a targeted lever for a specific failure mode, not a free boost: worth exploring, not adopting blindly.</p>
<p>The shadow-confusion case is harder still, telling a hypoechoic tumor from a dark shadow region sometimes needs context a small grayscale crop doesn't carry. This points toward higher resolution or a wider receptive field as directions for later experiments.</p>
<h2 id="heading-where-to-go-next">Where to Go Next</h2>
<p>Once you have a reliable baseline, the next experiments become much more meaningful. Rather than randomly trying larger models, start from the failure modes you observed:</p>
<ul>
<li><p>Replace the 2D U-Net with Attention U-Net or DynUNet.</p>
</li>
<li><p>Train at higher resolution to better capture small lesions.</p>
</li>
<li><p>Apply connected-component analysis selectively during inference.</p>
</li>
<li><p>Explore test-time augmentation.</p>
</li>
<li><p>Compare DiceCE with Focal Tversky loss for highly imbalanced lesions.</p>
</li>
</ul>
<h2 id="heading-takeaway">Takeaway</h2>
<p>The through-line is profile the data, then let what you find make the decisions.</p>
<p>The resize came from a resolution check. The loss came from a class-balance check. The split came from a patient count. The most useful post-processing idea came from a mask-component check run before training started. None of these were guesses, and none of them needed a sweep to discover.</p>
<p>A model is easy to build. A model whose every choice has a reason behind it is easier to trust, easier to debug, and easier to explain to the person who reads it next.</p>
<h2 id="heading-reference">Reference</h2>
<p>The complete, runnable code for this walkthrough is available as a MONAI notebook: <a href="https://github.com/lakshmi-mahabaleshwara/wg-ultrasound/tree/bus_bra_tumor_segmentation/data_and_tutorials/bus_bra_tutor_segmentation"><code>busbra_segmentation_monai.ipynb</code></a>. It runs top to bottom on Kaggle or Colab, and auto-downloads the dataset if it's not already attached.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How Neural Machine Translation Works: Build Your Own Translation App with React Native and QVAC ]]>
                </title>
                <description>
                    <![CDATA[ For the past 10 years, we've experienced a massive improvement in translation technologies. We went from robotic-like translations to systems that not only understand the meaning of each word in a sen ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-neural-machine-translation-works-build-your-own-translation-app-with-react-native-and-qvac/</link>
                <guid isPermaLink="false">6a5a5daeee4c6fc82387d36e</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ nlp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React Native ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Jibril-M🍀 ]]>
                </dc:creator>
                <pubDate>Fri, 17 Jul 2026 16:51:58 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/89b0a610-cd98-4112-95cc-fb01597911dc.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>For the past 10 years, we've experienced a massive improvement in translation technologies. We went from robotic-like translations to systems that not only understand the meaning of each word in a sentence, but also how the word fits into the context of the full sentence.</p>
<p>For instance, current translation systems know how to differentiate the meaning of "bank" in a sentence like:</p>
<blockquote>
<p>"I can't make the bank deposit today," and "We shall meet near the river bank."</p>
</blockquote>
<p>Both sentences have "bank" in them, but with different meanings.</p>
<p>So how did we get here? This huge revolution started back in June of 2017 when a team of 8 Google researchers, notoriously known as the "8 Samurai," released a research paper titled <a href="https://arxiv.org/abs/1706.03762">"Attention Is All You Need"</a>. This date marked a turning point in modern AI systems and architecture.</p>
<p>For context, this framework is the bedrock of current LLMs like ChatGPT and all large language models.</p>
<p><em>The 8 Google researchers who created the Transformer architecture</em></p>
<img src="https://cdn.hashnode.com/uploads/covers/68e4f3e9867c1707d1b057a9/3826d677-eee8-41bf-ae03-9ab6e805e6f6.png" alt="The 8 Google researchers who created the Transformer architecture" style="display:block;margin:0 auto" width="1185" height="1062" loading="lazy">

<p>So, what is NMT, and how were Google engineers able to develop a framework that enables machines to understand the semantic meaning of each word in a sentence?</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-demystifying-nmt-the-brain-behind-the-screen">Demystifying NMT: The Brain Behind the Screen</a></p>
</li>
<li><p><a href="#heading-how-the-transformer-sees-the-world">How the Transformer Sees the World</a></p>
</li>
<li><p><a href="#heading-why-this-matters">Why This Matters</a></p>
</li>
<li><p><a href="#heading-the-democratization-of-ai">The Democratization of AI</a></p>
</li>
<li><p><a href="#heading-what-is-qvac">What is QVAC?</a></p>
</li>
<li><p><a href="#heading-the-architecture-supported-by-qvac">The Architecture Supported by QVAC</a></p>
</li>
<li><p><a href="#heading-the-inference-pipeline">The Inference Pipeline</a></p>
</li>
<li><p><a href="#heading-setting-up-the-project">Setting Up the Project</a></p>
</li>
<li><p><a href="#heading-complete-implementation">Complete Implementation</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-resources-and-further-reading">Resources and Further Reading</a></p>
</li>
</ul>
<h2 id="heading-demystifying-nmt-the-brain-behind-the-screen">Demystifying NMT: The Brain Behind the Screen</h2>
<p>To understand this breakthrough, we first have to pull back the curtain on what <strong>NMT</strong> (Neural Machine Translation) actually means.</p>
<p>For decades, computer translation was "rule-based." The computer was essentially given a massive bilingual dictionary and a set of grammar rules. It would translate a sentence word-by-word, swap a few positions around, and hope for the best.</p>
<p>This is why early translations felt so incredibly stiff and robotic: the computer was trying to solve language like a math problem.</p>
<p>NMT changed the game by introducing <strong>Neural Networks</strong>, computer systems inspired by the human brain. Instead of memorizing strict rules, an NMT system learns by looking at millions of existing human translations. It looks at how humans translate phrases, captures patterns, and learns how words actually interact in the real world.</p>
<p>But even early NMT systems had a massive flaw: they read sentences sequentially, from left to right. If a sentence was too long, the system would "forget" how it started by the time it reached the end.</p>
<p>This is where the Google researchers made their historic leap.</p>
<h2 id="heading-how-the-transformer-sees-the-world">How the Transformer Sees the World</h2>
<p>The "Attention Is All You Need" paper solved the memory problem by introducing a brand-new architecture called the <strong>Transformer</strong>. Instead of reading a sentence word-by-word, the Transformer reads the entire sentence all at once.</p>
<p>To do this, it splits the job into two main parts: the Encoder and the Decoder.</p>
<h3 id="heading-the-encoder-the-reader">The Encoder (The Reader)</h3>
<p>Think of the Encoder as a highly analytical reader. When you feed a sentence into the system, the Encoder’s job is to read it and build a "mental map" of what the sentence actually means.</p>
<p>It does this using a mechanism called <strong>Self-Attention</strong>. You can think of Self-Attention as a series of spotlights. When the computer looks at a specific word, it shines spotlights on all the other words in the sentence to see how they relate.</p>
<p>Going back to our earlier example:</p>
<blockquote>
<p>"We shall meet near the river bank."</p>
</blockquote>
<p>When the Encoder processes the word <strong>"bank,"</strong> its Self-Attention spotlight instantly flags the word <strong>"river."</strong> Because those two words are highly connected on the AI's mental map, the system immediately knows we're talking about land next to water, not a financial institution. It locks in this "semantic meaning" before moving to the next step.</p>
<h3 id="heading-the-decoder-the-writer">The Decoder (The Writer)</h3>
<p>Once the Encoder has mapped out the true meaning of the sentence, it hands this blueprint over to the <strong>Decoder</strong>.</p>
<p>The Decoder is the writer. Its only job is to translate that blueprint into the target language. But it doesn't just output a pre-written template. It builds the new sentence word-by-word, constantly looking back at the Encoder's blueprint (using a trick called <strong>Cross-Attention</strong>) to make sure it maintains the correct context, tone, and grammar.</p>
<p>If it's translating our river bank sentence into French, it knows to write <em>"la rive"</em> (the bank of the river) instead of <em>"la banque"</em> (the financial bank), because the Encoder's blueprint warned it ahead of time.</p>
<h2 id="heading-why-this-matters">Why This Matters</h2>
<p>By teaching machines to look at the whole picture rather than individual words, Google’s engineers didn't just build a better translator. They built a system that finally understands the nuances, idioms, and context of human language.</p>
<p>And as it turns out, if an AI can understand the context of a sentence well enough to translate it, it can also use that same context to write essays, answer complex questions, and code. The 2017 translation engine accidentally became the foundation of the entire AI era.</p>
<h2 id="heading-the-democratization-of-ai">The Democratization of AI</h2>
<p>A few years after the Transformer's invention, building with it was strictly a toy for the rich. If you wanted to implement even a simple translation feature, you had to pay Big Tech giants like Google a fortune once you went beyond their tiny free tier.</p>
<p>Trying to bypass their dominance was almost impossible because there were practically no resources for independent developers. Back then, just understanding the basic math of a Transformer required an academic PhD. Without a massive research department at your back, trying to build your own solution from scratch was an incredibly expensive nightmare.</p>
<p>Thankfully, the open-source developer community has worked tirelessly to democratize access to AI. Today, we have incredibly powerful models that anyone can download and use freely.</p>
<p>On top of that, the processors in our personal devices have become exceptionally capable. This hardware evolution means that sophisticated AI models can now run locally directly on your smartphone, ensuring maximum data privacy and removing the dependency on external servers.</p>
<p>As the saying goes, <em>"Today it needs a full building to function, tomorrow it will fit in your pocket."</em> Of course, I totally made that quote up 😅, but you get my point!</p>
<p>To put this in action, we'll build a mobile application with Expo and QVAC that translates English to French.</p>
<h2 id="heading-what-is-qvac">What is QVAC?</h2>
<p>QVAC (QuantumVerse Automatic Computer) is a decentralized, local-first AI development platform and SDK created by Tether.</p>
<p>Unlike traditional AI tools that require cloud connectivity, QVAC allows users to run AI models entirely on their own devices. By keeping the computation local and offline, it ensures your data remains private, secure, and entirely under your control.</p>
<h3 id="heading-key-concepts-for-on-device-translation">Key Concepts for On-Device Translation</h3>
<p>To understand how QVAC runs on a mobile device, we must keep a few key concepts in mind:</p>
<h4 id="heading-1-on-device-inference">1. On-Device Inference:</h4>
<p>Running model calculations locally. Rather than relying on a single engine or cloud API, QVAC supports specialized local inference backends depending on the task.</p>
<p>For translation, it uses the Bergamot engine under the hood. These engines memory-map quantized model weights directly into the device's RAM and run calculations using native hardware acceleration.</p>
<h4 id="heading-2-quantization">2. Quantization</h4>
<p>A mathematical optimization technique that compresses the model's weights. This makes it possible for models to fit into the memory constraints of consumer mobile hardware while keeping output quality high.</p>
<h2 id="heading-the-architecture-supported-by-qvac">The Architecture Supported by QVAC</h2>
<p>Before writing code, it's crucial to understand what's actually happening under the hood. To handle local execution without melting your device, the QVAC SDK manages the hardware binding and model lifecycle while hooking into optimized inference backends.</p>
<p>For translation, QVAC utilizes the Bergamot engine. Originally developed as part of the Bergamot project (which powers Firefox's offline translation), this engine is highly optimized for fast, accurate Neural Machine Translation (NMT) on consumer hardware.</p>
<p>At its core, the Bergamot engine takes a source sentence, processes it through its Encoder-Decoder transformer architecture, and predicts the target language tokens in a highly efficient manner.</p>
<h3 id="heading-understanding-language-pairs">Understanding Language Pairs</h3>
<p>It's important to understand the mechanics of how these models are trained. Translation models like the ones used by Bergamot are strictly unidirectional language pairs. This means the <code>BERGAMOT_EN_FR</code> model is designed exclusively to translate from English to French. It can't reverse the process.</p>
<p>If you want to translate French back to English, you would need to download and load a completely separate model trained specifically for that direction.</p>
<p>If a model is trained to be bidirectional (English ↔ French) or multilingual (translating dozens of languages like large language models do), it has to store mathematical representations, vocabulary, and grammar rules for multiple linguistic directions inside a single neural network. This balloons the parameter count, making the file size massive and requiring heavy RAM and compute power to process.</p>
<p>By isolating the task to a single direction (for example <code>BERGAMOT_EN_FR</code>), the model only needs the neural network to "understand" English inputs and "generate" French outputs. It doesn't need the capacity to generate English text.</p>
<p>This extreme specialization is exactly how Bergamot shrinks the model weights down to those incredibly tiny 15–35MB files that can run instantly on a local CPU without freezing your browser.</p>
<h2 id="heading-the-inference-pipeline">The Inference Pipeline</h2>
<p>To visualize how we interact with the translation engine in our codebase, think of local translation as running a dedicated interpreter right in your phone's memory:</p>
<ol>
<li><p><strong>Hiring the interpreter (loading the model):</strong> We map the compressed model file (in this case, the <code>BERGAMOT_EN_FR</code> English-to-French model) directly into the device's RAM.</p>
</li>
<li><p><strong>Handing over the script (text input):</strong> We pass the source text to the loaded engine.</p>
</li>
<li><p><strong>The performance (inference):</strong> The engine reads the text and mathematically predicts the translated tokens, providing the translated result once the process is complete.</p>
</li>
<li><p><strong>Closing the show (unloading):</strong> Because neural network models are memory-intensive, the model can be cleared from RAM to free up resources once the translation is complete or when the user leaves the screen.</p>
</li>
</ol>
<h2 id="heading-setting-up-the-project">Setting Up the Project</h2>
<p>To ensure this guide is completely self-contained, let's start by quickly generating our new Expo application and installing the QVAC SDK. Open your terminal and run the following commands:</p>
<pre><code class="language-bash">npx create-expo-app translator-app --template blank-typescript
cd translator-app
npm install @qvac/sdk jiti
</code></pre>
<p>Next, you need to add the following peer dependencies to your <code>package.json</code> for QVAC to work correctly. Add these lines to their respective sections:</p>
<pre><code class="language-json">  "dependencies": {
    "bare-rpc": "^1.0.0",
    "react-native-bare-kit": "^0.11.5"
  },
  "devDependencies": {
    "bare-pack": "^1.5.1"
  }
</code></pre>
<p>Once added, install the dependencies by running:</p>
<pre><code class="language-bash">npm install
npx expo install expo-file-system expo-build-properties expo-device
</code></pre>
<h3 id="heading-configuring-the-expo-plugin-with-jiti">Configuring the Expo Plugin with JITI</h3>
<p>Next, we need to add the QVAC SDK plugin to our Expo project. Because the QVAC SDK's Expo plugin is distributed as a modern ECMAScript Module (ESM), but Expo's configuration file (<code>app.config.js</code>) runs in a standard Node.js CommonJS environment, we can't use a standard <code>require()</code>.</p>
<p>This is why we installed <code>jiti</code>. It acts as a bridge, allowing us to synchronously load ESM modules inside CommonJS files without breaking the build process.</p>
<p>Create or update your <code>app.config.js</code> file at the root of your project and configure it like this:</p>
<pre><code class="language-javascript">const createJiti = require("jiti");
const jiti = createJiti(__filename);

// Synchronously require the ESM module using jiti
const qvacModule = jiti("@qvac/sdk/expo-plugin");
const withQvacSDK = qvacModule.withQvacSDK || qvacModule.default;

// (Include your withEscapeBundleShellScript helper if needed)

module.exports = ({ config }) =&gt; {
  config.plugins = [
    [
      "expo-build-properties",
      {
        android: { minSdkVersion: 29 },
      },
    ],
    withQvacSDK,
    "expo-router",
    [
      "expo-splash-screen",
      {
        backgroundColor: "#208AEF",
      },
    ],
    withEscapeBundleShellScript, // Custom helper if applicable
  ];

  return config;
};
</code></pre>
<p>This configuration applies the QVAC native setup scripts and ensures Android requires at least SDK version 29 (which is necessary for the native libraries).</p>
<p>With our base configuration ready to go, let's jump straight into the translation code.</p>
<h2 id="heading-complete-implementation">Complete Implementation</h2>
<p>Let's bring it all together. We'll implement an interface that takes English text, manages the downloading and loading states for the Bergamot engine, translates the text to French, and renders the output to the screen.</p>
<p>Replace your entry app file <code>src/app/index.tsx</code> with the following implementation:</p>
<pre><code class="language-tsx">import { View, ScrollView, TextInput, Text, TouchableOpacity, StyleSheet } from "react-native";
import { useState, useEffect } from "react";
import {
  loadModel,
  translate,
  unloadModel,
  BERGAMOT_EN_FR,
  getModelInfo,
} from "@qvac/sdk";
import { Stack } from "expo-router";

type TranslationStatus =
  | "Idle"
  | "Checking model..."
  | "Downloading model..."
  | "Model downloaded successfully."
  | "Loading model..."
  | "Translating..."
  | "Streaming translation..."
  | "Translation finished."
  | `Error: ${string}`;

export default function HomeScreen() {
  const [status, setStatus] = useState&lt;TranslationStatus&gt;("Checking model...");
  const [translatedText, setTranslatedText] = useState&lt;string&gt;("");
  const [inputText, setInputText] = useState&lt;string&gt;("");
  const [isTranslating, setIsTranslating] = useState&lt;boolean&gt;(false);
  const [isDownloaded, setIsDownloaded] = useState&lt;boolean | null&gt;(null);
  const [downloadProgressStr, setDownloadProgressStr] = useState&lt;string&gt;("");

  useEffect(() =&gt; {
    const checkModelStatus = async () =&gt; {
      try {
        const model = await getModelInfo({ name: BERGAMOT_EN_FR.name });
        setIsDownloaded(model.isCached);
        console.log("Model", model);
        setStatus("Idle");
      } catch (error) {
        console.error("Error checking model:", error);
        setStatus("Error: Failed to check model status");
      }
    };
    checkModelStatus();
  }, []);

  const handleDownload = async () =&gt; {
    try {
      setIsTranslating(true);
      setStatus("Downloading model...");
      setDownloadProgressStr("");

      const modelId = await loadModel({
        modelSrc: BERGAMOT_EN_FR,
        modelType: "nmt",
        onProgress: (progress: any) =&gt; {
          let pct = progress.percentage;
          let dl = progress.downloaded;
          let tot = progress.total;
          if (progress.shardInfo) {
            pct = progress.shardInfo.overallPercentage;
            dl = progress.shardInfo.overallDownloaded;
            tot = progress.shardInfo.overallTotal;
          }
          const formatBytes = (bytes: number) =&gt; {
            if (bytes === 0) return "0 B";
            const k = 1024;
            const sizes = ["B", "KB", "MB", "GB"];
            const i = Math.floor(Math.log(bytes) / Math.log(k));
            return (
              parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
            );
          };
          setDownloadProgressStr(
            `${pct.toFixed(2)}% (${formatBytes(dl)} / ${formatBytes(tot)})`,
          );
        },
        modelConfig: {
          engine: "Bergamot",
          from: "en",
          to: "fr",
          beamsize: 1,
          normalize: 1,
          temperature: 0.2,
          norepeatngramsize: 3,
          lengthpenalty: 1.2,
        },
      });

      await unloadModel({ modelId, clearStorage: false });

      setIsDownloaded(true);
      setStatus("Model downloaded successfully.");
    } catch (error: any) {
      console.error(error);
      setStatus(`Error: ${error.message}`);
    } finally {
      setIsTranslating(false);
      setDownloadProgressStr("");
    }
  };

  const handleTranslate = async () =&gt; {
    if (!inputText.trim()) {
      setStatus("Error: Please enter text to translate");
      return;
    }

    try {
      setIsTranslating(true);
      setTranslatedText("");
      setStatus("Loading model...");

      const modelId = await loadModel({
        modelSrc: BERGAMOT_EN_FR,
        modelType: "nmt",

        modelConfig: {
          engine: "Bergamot",
          from: "en",
          to: "fr",
          beamsize: 1,
          normalize: 1,
          temperature: 0.2,
          norepeatngramsize: 3,
          lengthpenalty: 1.2,
        },
      });

      setStatus(`Translating...`);

      const result = translate({
        modelId,
        text: inputText,
        modelType: "nmt",
        stream: false,
      });

      const text = await result.text;
      setTranslatedText(text);

      const stats = await result.stats;
      if (stats) {
        console.log(`▸ Processing stats:`, stats);
      }

      setStatus("Translation finished.");

      await unloadModel({ modelId, clearStorage: false });
    } catch (error: any) {
      console.error(error);
      setStatus(`Error: ${error.message}`);
    } finally {
      setIsTranslating(false);
    }
  };

  return (
    &lt;&gt;
      &lt;Stack.Screen
        options={{
          headerTitle: "Translator",
          headerStyle: { backgroundColor: "#000" },
          headerTintColor: "#fff",
        }}
      /&gt;
      &lt;ScrollView contentContainerStyle={styles.scrollContainer}&gt;
        &lt;View style={styles.card}&gt;
          &lt;View style={styles.header}&gt;
            &lt;Text style={styles.title}&gt;
              English to French Translator
            &lt;/Text&gt;
            &lt;Text style={styles.subtitle}&gt;
              Enter text to translate:
            &lt;/Text&gt;
          &lt;/View&gt;

          &lt;View style={styles.content}&gt;
            &lt;TextInput
              style={[styles.input, isTranslating &amp;&amp; styles.disabledText]}
              multiline
              placeholder="Type English text here..."
              placeholderTextColor="#888"
              value={inputText}
              onChangeText={setInputText}
              editable={!isTranslating}
            /&gt;

            &lt;Text style={styles.statusText}&gt;
              Status: {status}
              {downloadProgressStr ? `\n${downloadProgressStr}` : ""}
            &lt;/Text&gt;

            {isDownloaded === null ? (
              &lt;TouchableOpacity disabled style={[styles.button, styles.buttonDisabled]}&gt;
                &lt;Text style={styles.buttonText}&gt;
                  Loading...
                &lt;/Text&gt;
              &lt;/TouchableOpacity&gt;
            ) : isDownloaded ? (
              &lt;TouchableOpacity
                onPress={handleTranslate}
                style={[
                  styles.button,
                  (isTranslating || !inputText.trim()) &amp;&amp; styles.buttonDisabled,
                ]}
                disabled={isTranslating || !inputText.trim()}
              &gt;
                &lt;Text style={styles.buttonText}&gt;
                  {isTranslating ? "Translating..." : "Translate"}
                &lt;/Text&gt;
              &lt;/TouchableOpacity&gt;
            ) : (
              &lt;TouchableOpacity
                onPress={handleDownload}
                style={[styles.button, isTranslating &amp;&amp; styles.buttonDisabled]}
                disabled={isTranslating}
              &gt;
                &lt;Text style={styles.buttonText}&gt;
                  {isTranslating ? "Downloading..." : "Download Model"}
                &lt;/Text&gt;
              &lt;/TouchableOpacity&gt;
            )}

            &lt;View style={styles.outputContainer}&gt;
              &lt;Text style={styles.outputText}&gt;
                {translatedText || "Translation will appear here..."}
              &lt;/Text&gt;
            &lt;/View&gt;
          &lt;/View&gt;
        &lt;/View&gt;
      &lt;/ScrollView&gt;
    &lt;/&gt;
  );
}

const styles = StyleSheet.create({
  scrollContainer: {
    flexGrow: 1,
    paddingHorizontal: 16,
    paddingTop: 16,
    paddingBottom: 24,
    backgroundColor: "#f9fafb",
  },
  card: {
    backgroundColor: "#ffffff",
    maxWidth: 450,
    width: "100%",
    alignSelf: "center",
    borderRadius: 12,
    padding: 16,
  },
  header: {
    marginBottom: 16,
  },
  title: {
    textAlign: "center",
    fontSize: 24,
    fontWeight: "bold",
    color: "#111827",
  },
  subtitle: {
    textAlign: "center",
    marginTop: 4,
    fontSize: 16,
    color: "#6b7280",
  },
  content: {
    gap: 24,
  },
  input: {
    borderWidth: 1,
    borderColor: "#e5e7eb",
    backgroundColor: "#ffffff",
    color: "#111827",
    padding: 12,
    borderRadius: 8,
    minHeight: 100,
    textAlignVertical: "top",
  },
  disabledText: {
    opacity: 0.5,
  },
  statusText: {
    fontSize: 14,
    color: "#3b82f6",
    fontWeight: "bold",
    textAlign: "center",
    marginTop: 12,
    marginBottom: 12,
  },
  button: {
    width: "100%",
    height: 48,
    borderRadius: 12,
    backgroundColor: "#3b82f6",
    alignItems: "center",
    justifyContent: "center",
  },
  buttonDisabled: {
    opacity: 0.5,
  },
  buttonText: {
    fontWeight: "600",
    fontSize: 18,
    color: "#ffffff",
  },
  outputContainer: {
    marginTop: 16,
    padding: 16,
    backgroundColor: "#f3f4f6",
    borderRadius: 8,
    minHeight: 100,
  },
  outputText: {
    fontSize: 16,
    color: "#111827",
  },
});
</code></pre>
<p>Here is a translation example from the application.</p>
<p><em>Input</em> (English)</p>
<blockquote>
<p>The location I told you was near the river bank</p>
</blockquote>
<p><em>Output</em> (French)</p>
<blockquote>
<p>L'endroit où je vous ai dit était près de la rive de la rivière</p>
</blockquote>
<h3 id="heading-codebase-breakdown">Codebase Breakdown</h3>
<p>Let’s lift the hood on how this local translation implementation manages native model lifecycles and processes the streamed tokens.</p>
<h4 id="heading-1-managing-the-native-lifecycle">1. Managing the Native Lifecycle</h4>
<p>Loading neural network weights for translation is computationally expensive. When the QVAC runtime initializes a model, it must read parameters from the local disk and copy the active weights into device RAM.</p>
<p>To handle this efficiently, we check if the model is cached before attempting to load it. This is used to check if the model is downloaded. That's the meaning of cached: it means the model has been downloaded to the user's disk:</p>
<pre><code class="language-typescript">const model = await getModelInfo({ name: BERGAMOT_EN_FR.name });
setIsDownloaded(model.isCached);
</code></pre>
<p>The <code>loadModel</code> function will automatically handle downloading the model from the Hugging Face hub if it hasn't been cached locally yet. Once the file is available locally, it directly memory-maps the weights.</p>
<h4 id="heading-2-translating-the-text">2. Translating the Text</h4>
<p>Once the model is loaded, we can pass our text to the translation engine:</p>
<pre><code class="language-typescript">const result = translate({
  modelId,
  text: inputText,
  modelType: "nmt",
  stream: false,
});

const text = await result.text;
setTranslatedText(text);
</code></pre>
<p>This waits for the full translation to complete before displaying the final result to the user.</p>
<h4 id="heading-3-unloading-the-model">3. Unloading the Model</h4>
<p>After the translation is complete, we explicitly destroy the model via <code>unloadModel</code>:</p>
<pre><code class="language-typescript">await unloadModel({ modelId, clearStorage: false });
</code></pre>
<p>By unloading the model, we ensure that the device's RAM is freed up for other processes. Because the model is already downloaded and cached on the disk (and we explicitly set <code>clearStorage: false</code>), reloading the model the next time the user wants to translate something will be nearly instantaneous.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Transitioning translation from the cloud to on-device hardware offers a practical approach for mobile application developers. Running model inference locally eliminates reliance on remote internet connectivity, removes recurring API usage costs, and ensures that user text inputs never leave the physical device.</p>
<p>Integrating local translation can be highly beneficial for travel apps, secure communication tools, or educational platforms. As edge processors gain dedicated hardware acceleration cores and open-source models become even more efficient through quantization research, local-first architectures present a compelling alternative for developers prioritizing privacy, offline resilience, and predictable cost structures.</p>
<h2 id="heading-resources-and-further-reading">Resources and Further Reading</h2>
<p>To dive deeper into local Neural Machine Translation, inspect the source code, or explore advanced configurations for your mobile applications, check out the following resources:</p>
<ul>
<li><p><a href="https://docs.qvac.tether.io/ai-capabilities/translation/"><strong>QVAC Translation Docs</strong></a>: Official documentation for integrating local translation capabilities with QVAC.</p>
</li>
<li><p><a href="https://docs.qvac.tether.io/tutorials/expo/"><strong>QVAC Expo Integration Docs</strong></a>: Learn more about configuring custom local models in Expo.</p>
</li>
<li><p><a href="https://browser.mt/"><strong>Bergamot Project</strong></a>: Learn more about the underlying Neural Machine Translation engine.</p>
</li>
<li><p><a href="https://arxiv.org/abs/1706.03762"><strong>Attention Is All You Need</strong></a>: The original 2017 Google research paper that introduced the Transformer architecture.</p>
</li>
<li><p><a href="https://github.com/DjibrilM/en-fr-translator-Article-project-"><strong>Full Code Example</strong></a>: Full code example's repository.</p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Product Experimentation with Regression-Based Causal Inference: Estimating LLM Feature Impact with Python and statsmodels ]]>
                </title>
                <description>
                    <![CDATA[ A randomized A/B test is the cleanest form of product experiment available. The coin flip that splits users between the new prompt template and the control removes every possible confounder by constru ]]>
                </description>
                <link>https://www.freecodecamp.org/news/regression-models-for-causal-inference-on-ai-features/</link>
                <guid isPermaLink="false">6a57a65ae479ecc16ad3b5b5</guid>
                
                    <category>
                        <![CDATA[ product experimentation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ experimentation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ causal inference ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #Regression ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Rudrendu Paul ]]>
                </dc:creator>
                <pubDate>Wed, 15 Jul 2026 15:25:14 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/731ac81a-7bf4-45ff-9eac-49292d1484b1.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A randomized A/B test is the cleanest form of product experiment available. The coin flip that splits users between the new prompt template and the control removes every possible confounder by construction.</p>
<p>That randomization is the load-bearing wall of your experiment, and regression is how you read the result precisely: how far the treatment moved the metric, with what confidence, and whether the effect was uniform across user types.</p>
<p>If you're a data scientist running clean randomized A/B tests on AI features, the hardest question is "how much did it work, and how confident should I be?" Your team split users by a hash of their user ID, half saw the new prompt template, half saw the old one, and the experiment ran four weeks. Now someone asks how much the new template actually moved task completion rates.</p>
<p>The first instinct is to open a spreadsheet and take the difference in group means. That number is real and unbiased, and for a small team with a quick decision to make it often suffices. It leaves open, though, how confident you should be in that number, whether that confidence depends on which cluster the user was in, and whether the effect holds equally for light users and heavy users.</p>
<p>Regression handles all of that in a single model, and when the experiment is properly randomized, the coefficients carry a clean causal interpretation that the simple mean difference can't.</p>
<p>That causal interpretation is what this tutorial is about. Under random assignment, OLS gives you a causal estimate. The treatment variable and the error term are independent by construction of the randomization, so the coefficient on treatment is an unbiased estimate of the average causal effect.</p>
<p>Add covariates and the estimate stays the same but the standard error shrinks because you have absorbed variance in the outcome that comes from other sources. Cluster by workspace and you get standard errors built on the actual data structure.</p>
<p>The dataset is a synthetic SaaS product with 50,000 users split across 50 workspaces. The new prompt template was assigned randomly by user ID hash. The ground-truth causal effect baked into the data generator is an increase of 4 percentage points on task completion.</p>
<p>The code in this tutorial recovers it through five steps: a randomization check, a naïve mean difference, OLS with HC3 robust errors, cluster-robust errors, and an interaction model that detects whether the effect differs by user type.</p>
<p>The final section identifies regression's limits, because knowing when a tool fails is as important as knowing how to use it.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-regression-works-for-randomized-experiments">Why Regression Works for Randomized Experiments</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-setting-up-the-working-example">Setting Up the Working Example</a></p>
<ul>
<li><p><a href="#heading-step-1-naive-difference-in-means">Step 1: Naïve Difference in Means</a></p>
</li>
<li><p><a href="#heading-step-2-ols-with-heteroskedasticity-robust-errors-hc3">Step 2: OLS with Heteroskedasticity-robust Errors (HC3)</a></p>
</li>
<li><p><a href="#heading-step-3-cluster-robust-standard-errors">Step 3: Cluster-robust Standard Errors</a></p>
</li>
<li><p><a href="#heading-step-4-treatment-effect-heterogeneity-via-interactions">Step 4: Treatment-effect Heterogeneity via Interactions</a></p>
</li>
<li><p><a href="#heading-step-5-bootstrap-confidence-intervals">Step 5: Bootstrap Confidence Intervals</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-when-regression-alone-isnt-enough">When Regression Alone isn't Enough</a></p>
</li>
<li><p><a href="#heading-what-to-do-next">What to Do Next</a></p>
</li>
</ul>
<h2 id="heading-why-regression-works-for-randomized-experiments">Why Regression Works for Randomized Experiments</h2>
<img src="https://cdn.hashnode.com/uploads/covers/69cc82ffe4688e4edd796adb/bfd58962-9157-43e8-852c-0372394e0782.png" alt="bfd58962-9157-43e8-852c-0372394e0782" style="display:block;margin:0 auto" width="1636" height="635" loading="lazy">

<p><em>Figure 1: Under randomization (left), covariate distributions overlap almost perfectly across treatment and control arms, and OLS recovers the causal effect. Under observational data with selection bias (right), treated users have systematically higher covariate values, and OLS conflates the covariate effect with the treatment effect.</em></p>
<p>Random assignment creates one very specific condition: the treatment indicator is statistically independent of every other variable in the world, observed and unobserved. Under independence, the expected value of OLS's error term, conditional on treatment, is zero, and OLS recovers an unbiased causal estimate. The ordinary assumption of no omitted-variable bias collapses into a trivially satisfied condition once you have randomized.</p>
<p>To see why, write the simplest possible model:</p>
<pre><code class="language-plaintext">task_completed_i = alpha + beta * prompt_variant_i + epsilon_i
</code></pre>
<p>If <code>prompt_variant</code> was assigned by coin flip, then <code>E[epsilon | prompt_variant] = 0</code>. OLS will recover <code>beta</code> as the average treatment effect. Confounders such as engagement tier, workspace tenure, and historical query complexity all live inside <code>epsilon</code>, but because the coin flip removed any correlation between <code>prompt_variant</code> and <code>epsilon</code>, they pass harmlessly through the residual without touching <code>beta</code>. They simply inflate the variance of <code>epsilon</code> and therefore the variance of your estimate.</p>
<p>Adding covariates to the regression preserves the point estimate while doing something highly useful: it absorbs the variance in <code>epsilon</code> that the covariates explain. The treatment coefficient stays the same, the residual variance shrinks, and the standard error on <code>beta</code> falls. You achieve the same point estimate with a tighter confidence interval simply by including baseline variables you already have in your logs.</p>
<p>Four assumptions underpin that causal interpretation, and all four must hold for the regression coefficient to carry a causal meaning.</p>
<ol>
<li><p><strong>Random assignment</strong>: treatment is independent of potential outcomes (<code>E[ε|D] = 0</code>). Randomization delivers this by construction. If assignment is confounded, this assumption breaks and OLS measures something other than the average treatment effect.</p>
</li>
<li><p><strong>Linearity</strong>: the conditional expectation of the outcome is linear in treatment and covariates. It's a reasonable approximation for binary outcomes over a narrow covariate range.</p>
</li>
<li><p><strong>No interference / SUTVA</strong>: each user's outcome depends only on their own treatment assignment, not on which template their colleagues received. That's the stable unit treatment value assumption. When it breaks, the coefficient conflates direct effects with spillovers.</p>
</li>
<li><p><strong>No differential attrition</strong>: dropout from the experiment is roughly equal across arms, so the groups you observe at the end are still comparable, with minimal attrition and no contamination between arms.</p>
</li>
</ol>
<p>The balance check below verifies that randomization held on observables. The failure-modes section identifies which of these four assumptions each real-world problem violates.</p>
<p>When the randomization is clean, regression efficiently extracts the causal estimate. When an assumption breaks, regression describes the failure rather than the treatment effect. If the balance table reveals a systematic gap on any covariate, stop and investigate the assignment pipeline before you proceed to estimation.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Every code block in this tutorial runs end-to-end in the companion notebook at <a href="https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/09_regression"><code>09_regression/regression_demo.ipynb</code></a>.</p>
<p>You need Python 3.11 or newer and basic comfort with pandas and statistics. <code>statsmodels</code> is the one library here that might be new to you: it handles HC3 and cluster-robust standard errors in a single call, the analytical substance <code>scipy.stats</code> can't provide on its own.</p>
<p>Install the required packages:</p>
<pre><code class="language-bash">pip install numpy pandas statsmodels scipy
</code></pre>
<p>Clone the companion repo to get the synthetic dataset:</p>
<pre><code class="language-bash">git clone https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm.git
cd product-experimentation-causal-inference-genai-llm
python data/generate_data.py --seed 42 --n-users 50000 --out data/synthetic_llm_logs.csv
</code></pre>
<h2 id="heading-setting-up-the-working-example">Setting Up the Working Example</h2>
<p>The dataset simulates 50,000 users distributed across 50 workspaces. The <code>prompt_variant</code> column records which arm each user was assigned to: 1 is the new template, 0 is the control.</p>
<p>Assignment was done by hashing user ID, so it's effectively random and independent of everything else in the data.</p>
<p>The <code>task_completed</code> column is the binary outcome. The ground-truth causal effect baked into the generator is an increase of 4 percentage points.</p>
<p>Before fitting any model, verify that randomization balanced the groups on observable covariates. A properly randomized experiment produces near-equal means on every measured characteristic across arms.</p>
<pre><code class="language-python">import pandas as pd
import numpy as np

df = pd.read_csv("data/synthetic_llm_logs.csv")

print("Dataset shape:", df.shape)
print("\nPrompt variant distribution:")
print(df.prompt_variant.value_counts().to_dict())

# Randomization check: covariate means by arm
check_cols = ["query_confidence", "session_minutes", "cost_usd"]
balance_table = (
    df.groupby("prompt_variant")[check_cols]
    .mean()
    .round(4)
    .T
)
balance_table.columns = ["Control (variant=0)", "Treatment (variant=1)"]
balance_table["Difference"] = (
    balance_table["Treatment (variant=1)"]
    - balance_table["Control (variant=0)"]
)
print("\nCovariate balance check:")
print(balance_table)

# Engagement tier proportions
print("\nEngagement tier split by arm:")
print(
    df.groupby("prompt_variant")
    .engagement_tier.value_counts(normalize=True)
    .unstack()
    .round(3)
)
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">[Placeholder — run regression_demo.py on the 50k dataset to capture real numbers]
</code></pre>
<p>Here's what's happening: you load 50,000 rows and count the split between arms (approximately 25,000 in each). You then compute mean values of three continuous variables (<code>query_confidence</code>, <code>session_minutes</code>, and <code>cost_usd</code>) for the control and treatment groups separately.</p>
<p>These columns reflect behavior logged before the prompt variant was assigned, so they are pre-treatment by construction. The "Difference" column should be tiny in every row.</p>
<p>You also check that the categorical engagement tiers (heavy, medium, light) appear at similar proportions in each arm. Small imbalances are normal sampling variation, but a systematic gap on any covariate signals that the hash-based assignment failed or that the data pipeline introduced selection after randomization. If you see a large imbalance, stop and investigate the assignment pipeline before proceeding to estimation.</p>
<p>On this dataset, all differences fall below 0.01 in absolute value and engagement tier proportions match to within two percentage points across arms. The randomization held.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69cc82ffe4688e4edd796adb/95863661-169e-4de7-a699-9154ce463b92.png" alt="95863661-169e-4de7-a699-9154ce463b92" style="display:block;margin:0 auto" width="1486" height="922" loading="lazy">

<p><em>Figure 2:</em> <code>query_confidence</code> <em>density by treatment arm across 25,000 control and 25,000 treatment users. The two curves overlap almost exactly (mean difference = -0.0013), confirming that hash-based random assignment produced covariate balance. This is the real dataset diagnostic. Compare it with the schematic in Figure 1.</em></p>
<h2 id="heading-step-1-naive-difference-in-means">Step 1: Naïve Difference in Means</h2>
<p>Start with the simplest possible estimator: subtract the mean outcome in the control arm from the mean outcome in the treatment arm.</p>
<pre><code class="language-python">from scipy import stats

mean_control = df[df.prompt_variant == 0].task_completed.mean()
mean_treatment = df[df.prompt_variant == 1].task_completed.mean()

naive_effect = mean_treatment - mean_control

print(f"Control mean:    {mean_control:.4f}")
print(f"Treatment mean:  {mean_treatment:.4f}")
print(f"Naive effect:    {naive_effect:+.4f}")

# Manual two-sample t-test
n0 = (df.prompt_variant == 0).sum()
n1 = (df.prompt_variant == 1).sum()
var0 = df[df.prompt_variant == 0].task_completed.var()
var1 = df[df.prompt_variant == 1].task_completed.var()
se = np.sqrt(var0 / n0 + var1 / n1)
t_stat = naive_effect / se

p_val = 2 * stats.t.sf(abs(t_stat), df=n0 + n1 - 2)

print(f"\nSE (two-sample):  {se:.4f}")
print(f"t-statistic:      {t_stat:.3f}")
print(f"p-value:          {p_val:.4f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">[Placeholder — run regression_demo.py on the 50k dataset to capture real numbers]
</code></pre>
<p>Here's what's happening: you compute the mean task completion rate in each arm, take the difference, and calculate the standard error using the pooled variance formula for a two-sample t-test. Because the experiment was randomized, this naïve difference is a valid causal estimate.</p>
<p>The recovered estimate may sit a percentage point or two away from the baked-in +4 pp ground truth. That's normal sampling variation at this dataset size, not estimator bias. The OLS regression in the next step will reproduce this number exactly when run without covariates, and will tighten the standard error once covariates are added.</p>
<p>The naïve t-test treats every observation as independent. That's a reasonable starting assumption here, but it doesn't hold in step 3, where users in the same workspace are correlated and the naïve standard error understates the actual uncertainty.</p>
<h2 id="heading-step-2-ols-with-heteroskedasticity-robust-errors-hc3">Step 2: OLS with Heteroskedasticity-robust Errors (HC3)</h2>
<p>Ordinary least squares with a binary treatment variable regressed on a binary outcome produces the same point estimate as the difference in means when there are no covariates. Adding covariates absorbs residual variance and shrinks the standard error.</p>
<p>HC3 standard errors are the main upgrade over the naïve t-test: they're valid even when the variance of the error term shifts across observations.</p>
<p>HC3 is preferred over HC0 through HC2 for finite samples because it penalizes high-leverage observations more aggressively, giving you better confidence interval coverage when sample sizes are moderate.</p>
<pre><code class="language-python">import statsmodels.formula.api as smf

# OLS without covariates: should match naive difference
m1 = smf.ols(
    "task_completed ~ prompt_variant",
    data=df
).fit(cov_type="HC3")

print("=== OLS without covariates (HC3) ===")
print(m1.summary().tables[1])
print(f"\nCoefficient: {m1.params['prompt_variant']:+.4f}")
print(f"HC3 SE:      {m1.bse['prompt_variant']:.4f}")
print(f"p-value:     {m1.pvalues['prompt_variant']:.4f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">[Placeholder — run regression_demo.py on the 50k dataset to capture real numbers]
</code></pre>
<p>Here's what's happening: you fit OLS with HC3 robust standard errors and no covariates. The coefficient on <code>prompt_variant</code> matches the naïve difference in means to four decimal places, confirming that OLS is just the mean-difference estimator in a regression wrapper.</p>
<p>HC3 standard errors run slightly larger than classical OLS standard errors because they correct for heteroskedasticity without assuming constant variance across the outcome distribution.</p>
<p>In practice, the difference is often small on balanced experiments, but you should default to HC3 anyway. There's no cost when you don't need it and real cost when you do.</p>
<p>Now add the covariates:</p>
<pre><code class="language-python"># Define the regression formula with covariates
formula = (
    "task_completed ~ prompt_variant + query_confidence + "
    "session_minutes + C(engagement_tier)"
)

# OLS with covariates: same point estimate, smaller SE
m2 = smf.ols(formula, data=df).fit(cov_type="HC3")

print("=== OLS with covariates (HC3) ===")
print(m2.summary().tables[1])
print(f"\nCoefficient: {m2.params['prompt_variant']:+.4f}")
print(f"HC3 SE:      {m2.bse['prompt_variant']:.4f}")
print(f"p-value:     {m2.pvalues['prompt_variant']:.4f}")

# Compare the two SEs
print("\n--- SE comparison ---")
print(f"Without covariates: {m1.bse['prompt_variant']:.4f}")
print(f"With covariates:    {m2.bse['prompt_variant']:.4f}")
print(f"R-squared (with):   {m2.rsquared:.4f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">[Placeholder — run regression_demo.py on the 50k dataset to capture real numbers]
</code></pre>
<p>Here's what's happening: you add <code>query_confidence</code>, <code>session_minutes</code>, and <code>engagement_tier</code> as controls. All three are pre-treatment variables, logged before the prompt variant was applied, so including them can't introduce collider bias.</p>
<p>The coefficient on <code>prompt_variant</code> stays close to the naïve estimate because randomization guarantees those covariates are uncorrelated with treatment assignment. The point estimate stays fixed. What shrinks is the uncertainty around it.</p>
<p>R-squared rises from near-zero without covariates to a few percentage points with them, meaning the covariates account for some of the variation in task completion. The HC3 p-value on <code>prompt_variant</code> tightens as the standard error falls.</p>
<p>This is the free lunch of covariate adjustment in randomized experiments. Include any pre-treatment variable that predicts the outcome: baseline engagement, historical task completion rate, or signup cohort. Stick to variables fixed before treatment began, because anything the treatment could have changed doesn't belong here.</p>
<h2 id="heading-step-3-cluster-robust-standard-errors">Step 3: Cluster-robust Standard Errors</h2>
<p>The HC3 approach in step 2 handles heteroskedasticity but still treats every observation as independent. Users inside the same workspace share a support team, a product tier, the same IT policies, and often the same use cases, so their outcomes correlate with each other.</p>
<p>If the new prompt template happens to land well in workspace 12 and poorly in workspace 37, those outcomes are correlated within workspace regardless of treatment. Ignoring that correlation makes the standard error too small, which inflates the t-statistic and makes your results appear more significant than they are.</p>
<p>Cluster-robust standard errors fix this by treating each workspace as a single informational unit, so the variance of the treatment coefficient reflects 50 workspace-level draws rather than 50,000 independent coin flips.</p>
<pre><code class="language-python"># Naive SE (assumes independence within workspaces)
m3_naive = smf.ols(formula, data=df).fit(cov_type="HC3")

# Cluster-robust SE (accounts for within-workspace correlation)
m3_cluster = smf.ols(formula, data=df).fit(
    cov_type="cluster",
    cov_kwds={"groups": df["workspace_id"]}
)

print("=== SE comparison: HC3 vs cluster-robust ===")
print(f"Coefficient (both):      {m3_cluster.params['prompt_variant']:+.4f}")
print(f"HC3 SE:                  {m3_naive.bse['prompt_variant']:.4f}")
print(f"Cluster-robust SE:       {m3_cluster.bse['prompt_variant']:.4f}")
print(f"HC3 p-value:             {m3_naive.pvalues['prompt_variant']:.4f}")
print(f"Cluster p-value:         {m3_cluster.pvalues['prompt_variant']:.4f}")

# Check how many workspaces exist
print(f"\nNumber of clusters: {df.workspace_id.nunique()}")
print(f"Users per workspace (avg): {len(df) / df.workspace_id.nunique():.0f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">[Placeholder — run regression_demo.py on the 50k dataset to capture real numbers]
</code></pre>
<p>Here's what's happening: you fit the same covariate-adjusted OLS model twice, once with HC3 and once with cluster-robust errors grouped by <code>workspace_id</code>. The point estimate is identical in both because standard error choice doesn't affect the coefficient, only its uncertainty. On this dataset with 50 workspaces and 1,000 users per workspace, the cluster-robust standard error will be somewhat larger than the HC3 version, reflecting that your effective sample size is 50 workspace-level draws, not 50,000 individual rows.</p>
<p>A rule worth remembering: if your experiment assigns treatment at the individual level but your data has clustering structure (users in workspaces, sessions in users, weeks in products), cluster at the unit level of natural correlation. Under-clustering produces overconfident results. Over-clustering at a coarser granularity than the actual correlation structure inflates the SE and costs precision but doesn't bias the point estimate.</p>
<p>When in doubt, cluster up. At fewer than 30 clusters, cluster-robust standard errors become unreliable and you should run a permutation test instead.</p>
<h2 id="heading-step-4-treatment-effect-heterogeneity-via-interactions">Step 4: Treatment-effect Heterogeneity via Interactions</h2>
<p>The OLS coefficient in steps 2 and 3 estimates the average treatment effect across all users. Averages can hide important structure. The new prompt template might work well for heavy users and do nothing for light users, or it might produce the same lift regardless of user type. Detecting that heterogeneity means adding an interaction term between treatment and the moderating variable.</p>
<pre><code class="language-python"># Interaction model: prompt_variant x engagement_tier
interaction_formula = (
    "task_completed ~ prompt_variant * C(engagement_tier) + "
    "query_confidence + session_minutes"
)

m4 = smf.ols(interaction_formula, data=df).fit(
    cov_type="cluster",
    cov_kwds={"groups": df["workspace_id"]}
)

print("=== Interaction model (cluster-robust) ===")
print(m4.summary().tables[1])

# Extract tier-specific effects
print("\n=== Implied treatment effects by engagement tier ===")
baseline_effect = m4.params["prompt_variant"]
tiers = ["medium", "heavy"]  # 'light' is the reference category

effects = {"light": baseline_effect}
for tier in tiers:
    interaction_key = f"prompt_variant:C(engagement_tier)[T.{tier}]"
    if interaction_key in m4.params:
        effects[tier] = baseline_effect + m4.params[interaction_key]
    else:
        effects[tier] = baseline_effect

for tier, eff in effects.items():
    print(f"  {tier:8s}: {eff:+.4f}")

# Joint F-test: are the interaction terms jointly significant?
interaction_terms = [k for k in m4.params.index if "prompt_variant:C" in k]
if interaction_terms:
    f_test = m4.f_test([f"({t} = 0)" for t in interaction_terms])
    print(f"\nJoint F-test on interactions: p = {f_test.pvalue:.4f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">[Placeholder — run regression_demo.py on the 50k dataset to capture real numbers]
</code></pre>
<p>Here's what's happening: you add an interaction between <code>prompt_variant</code> and <code>C(engagement_tier)</code>. The <code>light</code> tier is the reference category, so the coefficient on <code>prompt_variant</code> is now the effect for light users specifically. Adding the interaction coefficient for <code>medium</code> or <code>heavy</code> gives you the treatment effect in each of those tiers.</p>
<p>The joint F-test on all interaction terms asks whether the effects differ across tiers beyond sampling variation. A non-significant result means the prompt template's effect is broadly consistent across engagement levels. A significant result means you would report the tier-specific effects separately and target rollout toward the tiers with the largest lift.</p>
<p>Running interaction models well requires discipline. Preregister which moderator you plan to test before looking at the data. Running ten interactions and reporting the one that's significant at p &lt; 0.05 is multiple comparisons, p-hacking masquerading as subgroup analysis.</p>
<p>If you're exploring a new dataset without preregistration, apply a Bonferroni correction or use a false-discovery-rate procedure, and describe your analysis as exploratory.</p>
<h2 id="heading-step-5-bootstrap-confidence-intervals">Step 5: Bootstrap Confidence Intervals</h2>
<p>Point estimates from OLS are efficient, but bootstrap CIs give you a check that doesn't rely on distributional assumptions. Run 500 replicates: resample users with replacement, refit the cluster-robust model, and collect the treatment coefficient each time. The 2.5th and 97.5th percentiles of that distribution are your 95% CI.</p>
<pre><code class="language-python">rng = np.random.default_rng(seed=7)
n_boot = 500
boot_coefs = []

for _ in range(n_boot):
    idx = rng.integers(0, len(df), size=len(df))
    boot_df = df.iloc[idx].reset_index(drop=True)
    boot_model = smf.ols(
        formula,
        data=boot_df
    ).fit(
        cov_type="cluster",
        cov_kwds={"groups": boot_df["workspace_id"]}
    )
    boot_coefs.append(boot_model.params["prompt_variant"])

boot_coefs = np.array(boot_coefs)
ci_low, ci_high = np.percentile(boot_coefs, [2.5, 97.5])

print(f"Bootstrap 95% CI: [{ci_low:+.4f}, {ci_high:+.4f}]")
print(f"Bootstrap mean:   {boot_coefs.mean():+.4f}")
print(f"Analytic cluster SE: {m3_cluster.bse['prompt_variant']:.4f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">[Placeholder — run regression_demo.py on the 50k dataset to capture real numbers]
</code></pre>
<p>Here's what's happening: you resample the full dataset 500 times with replacement and refit the covariate-adjusted cluster-robust model each time. The resulting distribution of treatment coefficients captures both sampling uncertainty and the cluster structure. A valid bootstrap CI covers the ground-truth effect (+4 pp) and excludes zero. The bootstrap mean should align closely with the analytic point estimate. A material gap signals that the analytic model is sensitive to specific observations.</p>
<h2 id="heading-when-regression-alone-isnt-enough">When Regression Alone Isn't Enough</h2>
<p>Regression under randomization has a clean causal story because randomization severs the link between treatment and confounders. Production LLM systems rarely run pure experiments. Each failure mode below maps to a specific assumption from the four listed earlier.</p>
<h3 id="heading-unmeasured-confounders-in-observational-data">Unmeasured Confounders in Observational Data</h3>
<p>Suppose your team never randomized the prompt template. Instead, high-confidence queries got routed to the new template by default. Now <code>prompt_variant</code> correlates strongly with <code>query_confidence</code>, which itself predicts <code>task_completed</code>.</p>
<p>This violates the random assignment assumption (<code>E[ε|D] = 0</code>): the error term is no longer independent of treatment.</p>
<p>OLS will attribute some of the confidence effect to the template and overstate the treatment effect. Adding <code>query_confidence</code> as a control fixes the bias only if you have measured and correctly specified the confounder.</p>
<p>Any unmeasured driver of both assignment and outcome passes straight through OLS into the coefficient. Measure the confounder and include it as a control, or use an instrument or discontinuity design that restores local randomization.</p>
<h3 id="heading-sutva-violations-and-spillovers">SUTVA Violations and Spillovers</h3>
<p>OLS assumes each user's outcome depends only on their own treatment assignment (SUTVA, the third identification assumption listed above).</p>
<p>In a multi-user workspace product, that assumption is fragile. If heavy users in a workspace adopt the new prompt template and start helping their teammates phrase queries differently, light users in the same workspace get an indirect treatment effect through peer influence. Your outcome now depends on the treatment assigned to a neighbor, not just yourself.</p>
<p>Cluster-robust standard errors handle the correlation, but the coefficient still conflates direct effects and spillovers. Detecting spillovers requires a two-level randomization design: randomize workspaces into treatment and control, then measure outcomes for everyone inside each workspace.</p>
<h3 id="heading-time-varying-confounders">Time-varying Confounders</h3>
<p>If the prompt template was assigned at one point in time but engagement patterns shift over the analysis window due to product updates, support incidents, or seasonal usage changes, the association between treatment and outcome can drift in ways OLS can't separate from the causal effect.</p>
<p>This violates the random assignment assumption in its time-varying form: treatment assignment is no longer independent of potential outcomes once the covariate distribution drifts post-assignment.</p>
<p>You need a panel design with period-specific controls or an instrumental variable that accounts for the time variation.</p>
<h3 id="heading-binary-outcomes-and-the-linear-probability-model">Binary Outcomes and the Linear Probability Model</h3>
<p>Task completion is 0 or 1. OLS on a binary outcome is the linear probability model, which is valid for estimating average treatment effects and easier to interpret than logistic regression in an A/B context.</p>
<p>Its mechanical weakness relates to the linearity assumption: a linear conditional expectation can produce predicted probabilities outside [0, 1] for users with extreme covariate values. This doesn't invalidate the average effect but it does make individual-level predictions unreliable. Use logistic regression when you need calibrated probability scores; use OLS when you need an interpretable average treatment effect.</p>
<h2 id="heading-what-to-do-next">What to Do Next</h2>
<p>When the experiment is clean and the four assumptions hold, these four steps give you the full picture: naïve mean difference, HC3, cluster-robust, and one preregistered interaction. Get the randomization right, run the balance table, and cluster at the natural unit of correlation. The confidence interval tightens at each step, and you walk into the rollout decision knowing exactly what precision your data supports.</p>
<p>When the experiment isn't clean, the tools change. Observational data with selection on engagement requires propensity score methods or regression adjustment on a rich covariate set. Assignment by a continuous threshold requires regression discontinuity. Non-random rollout across workspaces over time requires difference-in-differences.</p>
<p>Each of those approaches handles a specific pattern of confounding that OLS can't reach, and each maps back to which of the four identification assumptions the design violates.</p>
<p>The companion notebook for this tutorial lives at <a href="https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/09_regression">github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/09_regression</a>. Clone the repo, generate the synthetic dataset, and run <code>regression_demo.py</code> to reproduce every code block from this tutorial end to end.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ CNNs, RNNs, and Transformers Explained: A Mental Model for Key Deep Learning Concepts ]]>
                </title>
                <description>
                    <![CDATA[ Okay, pop quiz: What is a neural network? What is deep learning? Does anything come to mind? I know that feeling – yes, that thing you’re feeling now. It’s either confidence that you know what I’m ask ]]>
                </description>
                <link>https://www.freecodecamp.org/news/cnns-rnns-and-transformers-explained-a-mental-model-for-key-deep-learning-concepts/</link>
                <guid isPermaLink="false">6a5580639ffb32ef2506a817</guid>
                
                    <category>
                        <![CDATA[ Deep Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ neural networks ]]>
                    </category>
                
                    <category>
                        <![CDATA[ transformers ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Roland Sankara ]]>
                </dc:creator>
                <pubDate>Tue, 14 Jul 2026 00:18:43 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/edd06632-76da-42f9-b741-e249d22c5f29.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Okay, pop quiz: What is a neural network? What is deep learning? Does anything come to mind?</p>
<p>I know that feeling – yes, that thing you’re feeling now. It’s either confidence that you know what I’m asking, or the lack of it. Worry not, buddy: I’ve got you.</p>
<p>In this tutorial, I’ll explain all you need to know about deep learning, neural networks, and why I think you need to know about a fancy tool called Keras.</p>
<h3 id="heading-prerequisites">Prerequisites:</h3>
<p>This is a conceptual article, so you don't need any deep learning background to follow along. That's exactly what you'll be gaining here.</p>
<p>Basic Python familiarity is helpful but not required for this article. But if you'd like to get hands-on with Keras afterward, having Python 3.9+ and pip installed will make it easy to start experimenting.</p>
<h3 id="heading-table-of-contents">Table of Contents</h3>
<ol>
<li><p><a href="#heading-what-is-deep-learning">What is Deep Learning?</a></p>
</li>
<li><p><a href="#heading-so-what-are-neural-networks">So What Are Neural Networks?</a></p>
</li>
<li><p><a href="#heading-an-analogy-for-how-neural-networks-work">An Analogy for How Neural Networks Work</a></p>
</li>
<li><p><a href="#heading-what-are-cnns-rnns-and-transformers">What Are CNNs, RNNs, and Transformers?</a></p>
</li>
<li><p><a href="#heading-how-cnns-work">How CNNs Work</a></p>
</li>
<li><p><a href="#heading-how-rnns-work">How RNNs Work</a></p>
</li>
<li><p><a href="#heading-how-transformers-work">How Transformers Work</a></p>
</li>
<li><p><a href="#heading-keras-for-building-ml-models">Keras For Building ML Models</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-what-is-deep-learning"><strong>What is Deep Learning?</strong></h2>
<p>To explain Deep Learning to you, I assume that you're already familiar with what <a href="https://www.ibm.com/think/topics/artificial-intelligence">AI (Artificial Intelligence)</a> &amp; <a href="https://www.ibm.com/think/topics/machine-learning">ML (Machine Learning)</a> are all about. These terms are likely not new to your ears, especially these days.</p>
<p>But maybe just to summarise: AI is a technology that enables computers to simulate human cognitive abilities such as learning and comprehension, problem-solving, creativity, and autonomy.</p>
<p>ML is a subset of artificial intelligence. It deals with the development of algorithms that can recognize patterns and learn from training data and subsequently make accurate inferences on new data without explicitly being programmed to do so.</p>
<p><a href="https://www.ibm.com/think/topics/deep-learning">Deep Learning</a> is a subset of machine learning that's driven by multilayered neural networks whose design is inspired by the structure of the human brain.</p>
<p>Take a look at the diagram below for a visual understanding of how these concepts are layered:</p>
<img src="https://miro.medium.com/v2/resize:fit:745/0*Y--MUM5bd3C7zJaP" alt="diagram showing the relationship between AI, ML and DL" style="display:block;margin:0 auto" width="596" height="335" loading="lazy">

<p><a href="https://www.researchgate.net/">Image Source — Research Gate</a></p>
<p>According to a book I’m currently reading titled <a href="https://www.manning.com/books/deep-learning-with-python-second-edition">Deep Learning in Python</a> by <a href="https://www.manning.com/authors/francois-chollet">François Chollet</a>, the <strong>word “Deep”</strong> in Deep Learning isn’t a reference to any kind of deeper understanding achieved by this concept. Rather, it stands for this idea of successive layers of representations of data. These layers of representations are learned via models called neural networks, structured in literal layers stacked on top of each other.</p>
<p>Check out the image below for a vivid example of this layering:</p>
<img src="https://miro.medium.com/v2/resize:fit:875/1*UpmQ8gZuWahzr8B6PkRueA.jpeg" alt="Diagram illustrating a neural network" style="display:block;margin:0 auto" width="802" height="488" loading="lazy">

<p>Image Source — <a href="https://www.researchgate.net">Research Gate</a></p>
<p>An interesting assumption Chollet also clears up is that Deep Learning models (Neural Networks) aren’t models of the brain. Rather, it's just that some central parts of deep learning were inspired by our understanding of the brain, in particular the visual cortex.</p>
<p>But do you know what the visual cortex is? 😅 See the image below (the circled green part):</p>
<img src="https://miro.medium.com/v2/resize:fit:875/1*f678xW6ooQ5pamaugbXtWA.jpeg" alt="Image of the human brain, illustrating the position of the visual cortex" style="display:block;margin:0 auto" width="875" height="599" loading="lazy">

<p>The visual cortex is the part of the brain that processes what your eyes see. <a href="https://www.the-scientist.com/a-serendipitous-shadow-brought-the-brain-s-visual-pathways-to-light-73037">In the 1960s, neuroscientists Hubel and Wiesel</a> discovered something surprising while studying it: neurons deeper in the visual cortex didn't respond to whole objects directly. Instead, the earliest neurons fired only for simple things, like a line at a specific angle. Only in later stages did neurons combine those simple signals into a response for more complex shapes.</p>
<p>In other words, the brain builds up "seeing an object" in stages — simple patterns first, complexity later. That stage-by-stage structure is the loose inspiration for how CNNs stack layers of filters, which we'll get to shortly.</p>
<p>Now that you have the hang of this, let’s explore the interesting and mind-boggling concept of neural networks.</p>
<p>📌 Note: neural networks are only mind-boggling at the start because they're a new concept. Once you take some time to understand them, they'll be easier to comprehend.</p>
<h2 id="heading-so-what-are-neural-networks"><strong>So What Are Neural Networks?</strong></h2>
<p>For starters, a neural network is a concept in deep learning. The “neural” in the name is derived from the neurons of the human brain.</p>
<p>A neural network consists of <strong>connected units or nodes called artificial neurons</strong>, which loosely model the neurons in the brain.</p>
<p>Here is the serious and technical definition:</p>
<blockquote>
<p>A neural network is a machine learning model that stacks simple “neurons” in layers and learns pattern-recognizing weights and biases from data to map inputs to outputs. (<a href="https://www.ibm.com/think/topics/neural-networks"><em>Excerpt from IBM Blog</em></a> <em>)</em></p>
</blockquote>
<p>Now here's the easier and more fun definition:</p>
<p>A Neural network is just a machine for making guessing/pattern recognition/analysis mistakes smaller, one small correction at a time.</p>
<p>Neural Networks come in various architectures/types such as;</p>
<ol>
<li><p>CNNs (Convolutional Neural Networks)</p>
</li>
<li><p>RNNs (Recurrent Neural Networks)</p>
</li>
<li><p>Transformers</p>
</li>
</ol>
<p>We’ll explore these later in this article, so for now, just understand that what makes them different is simply what they choose to focus on before they guess or recognize a pattern or provide an analysis.</p>
<h2 id="heading-an-analogy-for-how-neural-networks-work"><strong>An Analogy for How Neural Networks Work</strong></h2>
<img src="https://miro.medium.com/v2/resize:fit:875/1*Qb7gfYItDPcaekEjkNG6iw.jpeg" alt="Image of a person throwing a dart blindfolded" style="display:block;margin:0 auto" width="875" height="492" loading="lazy">

<p><a href="https://www.sportbible.com/boxing/mike-tyson-darts-661968-20240405">Image Source</a></p>
<p>Imagine you’re learning to throw darts blindfolded and someone can tell you one thing after each throw. For example, <strong>“You're 6 inches too far left and 2 inches too low”.</strong> You're not told why or given a lecture on your positioning for the aim or your grip of the dart. You just get a distance &amp; direction correction.</p>
<p>So you nudge (a light shift or twist) your arm angle a little based on that correction feedback and your throw again and get a new correction. You then nudge again and again and againnnn(!) until you hit the target.</p>
<p>Over time, as you do this, your arm <strong>“learns”</strong> (underline this) – not because anyone explained dart throwing physics to you, but because <strong>every throw gave you a tiny specific correction</strong> and you kept applying corrections in the direction that shrank your possibility of missing the target.</p>
<p>That’s exactly how neural networks work.</p>
<p>Let’s now learn the technical jargon that we’d use to talk about neural networks from the analogy above.</p>
<p>The instruction or signal “Nudge your arm this much and this way” is what <a href="https://milvus.io/ai-quick-reference/what-is-the-role-of-gradients-in-training-neural-networks">we call the <strong>Gradient</strong></a>. The gradient indicates the direction and size/distance/rate to make the correction, along with the rate at which the weights and biases should be adjusted to decrease the loss function.</p>
<p>The correction detail, for example 6 Inches too far to the left, <a href="https://milvus.io/ai-quick-reference/what-is-a-loss-function-in-a-neural-network"><strong>is the loss</strong></a>. A loss function in a neural network is a mathematical tool that measures how well the model’s predictions align with the actual target values.</p>
<p>When you keep applying corrections in the direction that shrinks the miss or error, we call that <strong>Gradient Descent.</strong> This is the optimization algorithm (the step-by-step process) that a neural network uses to figure out which direction to move and how big a distance (step size) to take to reach that accurate value. In this context, <strong>descent</strong> means exactly what it means in plain English: the act of moving downward.</p>
<p>Your arm's muscle memory adjusting is the <a href="https://www.coursera.org/articles/neural-network-weights"><strong>weight update</strong></a>. <strong>Weights are numerical values</strong> that help each node within a network make decisions by determining which factors are more important than others.</p>
<p>📍 Now… Pause and let that sink in. You can re-read this analogy once again if you want to before you proceed.</p>
<h2 id="heading-what-are-cnns-rnns-and-transformers"><strong>What Are CNNs, RNNs, and Transformers?</strong></h2>
<p>I hope you’re still with me here… because you need to understand these terms, too. Remember from earlier that CNNs, RNNs, and Transformers are simply architectures or different types of neural networks. They have the same learning process, similar to the analogy of throwing darts while blindfolded: they all have the <strong>guess then measure-error/loss then nudge</strong> loop underneath.</p>
<p>The difference is what information they choose to focus on before they make a guess/prediction/give an output.</p>
<p>📌 Let me break it down for you:</p>
<ul>
<li><p><strong>CNNs</strong> (Convolutional Neural Networks) only look at the small nearby patch and analyze it, then move to the next patch. Think of it as only seeing the dart board through a small tube pointed at one spot.</p>
</li>
<li><p><strong>RNNs</strong> (Recurrent Neural Networks) only look at things in order and remember a running summary as it goes. Think of it like reading a story left to right and updating your mental summary of the plot after each sentence.</p>
</li>
<li><p>With <strong>Transformers</strong>, the neural network looks at everything all at once and figures out on the fly what matters most. Think of it like reading the whole page in one glance and deciding which words connect with each other.</p>
</li>
</ul>
<p>Let’s take a deeper look at each type of neural network.</p>
<h2 id="heading-how-cnns-work"><strong>How CNNs Work</strong></h2>
<p>This is a type of neural network built for data that has spatial structure, such as images. It works with a small grid of numbers called a filter (for example, 3x3). Those numbers are weights, which are initially just random and don’t mean anything</p>
<p>Through the guess, measure error, nudge loop, those random numbers gradually become good at reacting strongly to a specific pattern in the image, for example a vertical edge, a certain color, and so on.</p>
<p>📌 Note: No one tells the filter what to look for. It discovers that on its own through training, the same way every other weight in every network we discuss here does.</p>
<p>That filter then slides across the image a few pixels at a time, and at each position it looks at a small path and produces a single number. This is a measure of how strongly the patterns the filter has learned to detect show up there.</p>
<p>When the filter has slid across the whole image, you get a grid of numbers which really just show a map of where the detected pattern shows up across the image.</p>
<p>📌 Note: The same filter with the same numbers is reused at every single position via a technique called parameter sharing. Hence the efficiency of CNNs</p>
<p>See the below example of filters sliding over an image matrix:</p>
<img src="https://miro.medium.com/v2/resize:fit:875/0*ZVvqs5LLquoq8exD.png" alt="Illustration of how filters in CNN work" style="display:block;margin:0 auto" width="875" height="583" loading="lazy">

<p><a href="https://towardsdatascience.com/">Image Source</a></p>
<p>In real CNNs, layers of filters are stacked on top of each other, and each layer builds on the previous one's output. Early layers, working directly on the raw pixels, tend to pick up very simple things, like edges or a patch of color. Because the next layer looks at the output of the first layer rather than raw pixels, it can combine those simple edges into slightly more complex shapes, like a curve or a corner.</p>
<p>Layer by layer, this keeps compounding: shapes combine into parts (like an ear or a whisker shape), and parts combine into something the network can recognize as a whole object, like a cat.</p>
<p>That's the real payoff of stacking filter layers: none of it happens in one step, and each layer only ever has to solve a slightly harder version of the same small problem.</p>
<p>Here's an image that illustrates the whole CNN process:</p>
<img src="https://miro.medium.com/v2/resize:fit:875/0*VSkYr02_3VCWmxi4" alt="Illustration of CNN Process " style="display:block;margin:0 auto" width="875" height="583" loading="lazy">

<p><a href="https://www.teachfloor.com/blog/convolutional-neural-network">Image Source</a></p>
<p>Use cases of CNNs include:</p>
<ul>
<li><p><strong>Medical Imaging:</strong> CNNs analyze medical scans, such as chest X-rays, and assist clinicians by flagging potential abnormalities for review.</p>
</li>
<li><p><strong>Image Generation:</strong> CNNs can create new images or manipulate existing ones.</p>
</li>
<li><p><strong>Autonomous Systems:</strong> CNNs can be used in autonomous systems such as self-driving cars for lane detection, obstacle detection, and traffic sign recognition.</p>
</li>
</ul>
<p>You can <a href="https://towardsdatascience.com/using-convolutional-neural-network-for-image-classification-5997bfd0ede4/">learn more here</a>.</p>
<p>📍 Now, pause and take note of the key things that matter: filters &amp; parameter sharing.</p>
<h2 id="heading-how-rnns-work"><strong>How RNNs Work</strong></h2>
<p>RNNs handle data that's sequentially ordered, where the order itself carries meaning. Think audio data and sentences that come together to form a story.</p>
<p>Unlike CNNs, which slide over patches of an image in no particular order, RNNs need to read things one step at a time.</p>
<p>For example, a sentence is read one word at a time in sequence because what’s reviewed earlier affects how the neural network understands what comes next. This sequential flow makes it slow to review large datasets.</p>
<p>RNNs keep a running summary called a <a href="https://apxml.com/courses/rnns-and-sequence-modeling/chapter-2-rnn-fundamentals/role-of-hidden-state"><strong>hidden state</strong></a><strong>.</strong></p>
<p>📌 Note: Think of it as a small notebook where it jots down everything important that it's understood so far. At the very start, before reading anything, that notebook is essentially blank (an initial hidden state, usually all zeros).</p>
<p>Here's a simple architecture:</p>
<img src="https://miro.medium.com/v2/resize:fit:875/0*cZvjbHcipzLE_vdw.png" alt="Diagram of RNN Architecture" style="display:block;margin:0 auto" width="875" height="583" loading="lazy">

<p><a href="https://murf.ai/ai-glossary/recurrent-neural-network">Image Source</a></p>
<p>So the hidden state is never a lookup table of everything the RNN has seen. It’s a single running summary that gets overwritten at every step, carrying forward only what the network has learned is worth keeping.</p>
<p><strong>📌 Note:</strong> The downside to RNNs is that if the sequence is long, early information reviewed can fade out almost entirely, which causes the RNN to lose the context of earlier review content. This problem in RNNs is called the <a href="https://milvus.io/ai-quick-reference/what-is-the-vanishing-gradient-problem"><strong>vanishing gradient problem</strong></a>.</p>
<p>Use cases of RNNs include:</p>
<ul>
<li><p><strong>Speech Recognition:</strong> RNNs are used in speech recognition systems to process audio over time. They help models understand how sounds form words and sentences.</p>
</li>
<li><p><strong>Voice AI Systems:</strong> In voice workflows, RNNs help process sequential audio data. Combined with technologies like text-to-speech (TTS), they contribute to natural voice generation pipelines.</p>
</li>
<li><p><strong>Time-Series Prediction:</strong> In finance or weather forecasting, RNNs analyze past data to predict future outcomes using probabilistic methods.</p>
</li>
<li><p><strong>Text Generation:</strong> RNNs can generate text by predicting the next word based on previous words. This is useful in chatbots and tools powered by generative AI.</p>
</li>
</ul>
<p><a href="https://youtu.be/Gafjk7_w1i8?si=dwtNujl5ki6lc9PN">Here's a video</a> you can watch to learn more about RNNs.</p>
<p>📍 Now, pause and take note of the key things that matter: hidden state and the vanishing gradient which is a downside to RNNs.</p>
<h2 id="heading-how-transformers-work"><strong>How Transformers Work</strong></h2>
<p>In 2017, a group of researchers at Google Brain published a short but world-shaking paper: <a href="https://papers.nips.cc/paper_files/paper/2017/file/3f5ee243547dee91fbd053c1c4a845aa-Paper.pdf">“Attention Is All You Need.”</a></p>
<p>It introduced <strong>the Transformer,</strong> a new architecture for processing language that quietly changed how AI engineering is done. Since then, Transformers have become the backbone of nearly every major Large Language model, including <a href="https://zapier.com/blog/google-gemini/">Gemini</a>.</p>
<p>Here is the simplified architecture:</p>
<img src="https://miro.medium.com/v2/resize:fit:875/0*LqCw3l0LoRio8V-D.png" alt="Simplified Architecture of a Transformer" style="display:block;margin:0 auto" width="875" height="492" loading="lazy">

<p><a href="https://medium.com/@theaveragegal/transformer-architecture-simplified-3fb501d461c8">Image source</a></p>
<p>In a nutshell, transformers take in a bunch of data at the same time (unlike RNNs that take in data in a sequential order).</p>
<p>Transformers are premised on a couple key concepts. First, there's <a href="https://www.datacamp.com/blog/self-attention"><strong>self-attention</strong></a>, where every element of data has <strong>a positional encoding</strong> that helps the transformer know the ordering of the elements. Second, there's <strong>embeddings</strong> that help capture the meaning of each word and the contextual relationship between all the data elements. Embeddings make it easy for the transformer to process data faster compared to other kinds of neural networks.</p>
<p>📍 Note: the transformer architecture reduces the vanishing gradient problem that's present with the RNNs.</p>
<p>Use cases of transformers include:</p>
<ul>
<li><p><strong>NLP (Natural Language Processing) Tasks:</strong> The self-attention mechanism enhances the linguistic capabilities of machine learning models by allowing the efficient and complete analysis of an entire text.</p>
</li>
<li><p><strong>Computer Vision:</strong> Developments in image-recognition models suggest that self-attention is a crucial component to increase their robustness and generalization.</p>
</li>
</ul>
<p>You can <a href="https://youtu.be/KMHkbXzHn7s?si=x3v6xijzAaEyAnXV">learn more about Transformers from this video</a>.</p>
<p>📍 Now, pause and take note of the key things that matter: self-attention, embeddings, positional encoding, and the fact that data is ingested and processed at the same time.</p>
<h2 id="heading-keras-for-building-ml-models"><strong>Keras For Building ML Models</strong></h2>
<img src="https://miro.medium.com/v2/resize:fit:875/0*XLNut9dQlFUNUq_z.png" alt="Keras Logo" style="display:block;margin:0 auto" width="774" height="269" loading="lazy">

<p><a href="https://keras.io/keras_3/">Image Source</a></p>
<p>Now that you understand the various types of neural networks and the use cases for each, you’re probably wondering how you can start building models.</p>
<h3 id="heading-what-is-keras">What is Keras?</h3>
<p>There are many options, but the one tool that I’ve come to appreciate the most is Keras. It's been used in projects such as the Google <a href="https://blog.youtube/inside-youtube/on-youtubes-recommendation-system/">YouTube Recommendation Engine</a> and the <a href="https://waymo.com/">Waymo self-driving fleet</a>.</p>
<p>Keras is an open-source, high-level neural network API that's designed to be user-friendly, modular, and extensible. It was initially developed independently and could run on top of backends like TensorFlow, Theano, or CNTK.</p>
<p>Since 2019, it has been the official high-level API of TensorFlow (TensorFlow 2.0+), offering high-level APIs (Sequential and Functional) and built-in support for common layers, optimizers, and loss functions.</p>
<p>📌 In short, Keras allows you to quickly and easily build AI/ML models.</p>
<p>Keras provides a complete toolkit for building deep learning models. It’s never been easier to build, train, evaluate, and deploy deep learning models.</p>
<h3 id="heading-the-new-version-keras-30">The New Version — Keras 3.0</h3>
<p>A significant recent development is <a href="https://keras.io/keras_3/">Keras 3.0</a>. It’s a full rewrite that lets Keras workflows run on top of multiple backends, like JAX, TensorFlow, PyTorch, and OpenVINO (inference-only), instead of being tied to TensorFlow alone.</p>
<p>📍 Here’s what makes Keras genuinely different from just being “another way to write neural network code”:</p>
<ul>
<li><p>It doesn’t just let you build a CNN, an RNN, or a Transformer. It lets you build all three using the <strong>same pattern.</strong></p>
</li>
<li><p>The training loop wrapping them- the same guess, measure error, nudge loop we’ve talked about throughout this entire article never changes. Keras is really just one consistent way of expressing that loop, no matter which architecture you’re pointing it at.</p>
</li>
</ul>
<p>You write your model once, and you can pick the framework that suits you best. You can also switch from one to another based on your current goals without rewriting the model itself.</p>
<p>And the flexibility isn’t just theoretical. It matters for performance, too.</p>
<p>In Keras’s own benchmarks, JAX typically delivers the best training and inference performance on GPU, TPU, and CPU, though results vary from model to model.</p>
<p>📌 Being able to swap backends without touching your model code means you’re not locked into whichever framework happened to be fastest when you started the project.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>I’ll pack it in at this. I hope you now have a good understanding of CNNs, RNNs, Transformers, and where the Deep Learning framework Keras falls into all this.</p>
<p>That's the mental model: one learning process, three architectures shaped by the data they're built for, and Keras as the one API that lets you build any of them. If you take one thing from this, let it be the <strong>guess → measure error → nudge loop.</strong> it's the basis for everything else you'll ever learn about deep learning.</p>
<p>Found this helpful? You can reach out to me via <a href="mailto:roland1sankara@gmail.com">email</a> or <a href="https://www.linkedin.com/in/roland-sankara">LinkedIn</a> and let me know what stood out for you and what you expect to learn next.</p>
<p>Cheers.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Product Experimentation with Uplift Modeling: Targeting Your LLM Feature Rollout to Users Who Actually Benefit (Python Implementation) ]]>
                </title>
                <description>
                    <![CDATA[ Your LLM product experiment just came back positive, with a promising 8-percentage-point lift in task completion. You ship the feature and leadership celebrates. Three months later, the core metric ha ]]>
                </description>
                <link>https://www.freecodecamp.org/news/uplift-modeling-for-personalized-ai-rollouts-in-python/</link>
                <guid isPermaLink="false">6a4fd5184215fa285003b017</guid>
                
                    <category>
                        <![CDATA[ product experimentation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ experimentation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ causal inference ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ uplift-modeling ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Rudrendu Paul ]]>
                </dc:creator>
                <pubDate>Thu, 09 Jul 2026 17:06:32 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/134c2ea7-4a99-4150-b6c8-a91aa7074e7b.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Your LLM product experiment just came back positive, with a promising 8-percentage-point lift in task completion. You ship the feature and leadership celebrates. Three months later, the core metric has barely moved.</p>
<p>The experiment was statistically sound. It simply answered the wrong question.</p>
<p>An average treatment effect compresses the entire treatment response across your user base into a single number. That compression is useful when you're deciding whether to build a feature in the first place.</p>
<p>But once you've committed to building it, the average treatment effect is no longer the most actionable metric. Heavy users of your AI summary tool have already optimized their workflows and often find the new summaries redundant. Light users frequently lose track of context and genuinely benefit from a quick recap.</p>
<p>Rolling out the feature uniformly to everyone, simply because the average effect was positive, misses something important: the feature helps some users significantly, barely moves the needle for others, and actively disrupts a third group.</p>
<p>This is the heterogeneity problem. Standard product experiments answer a binary question about average efficacy. Uplift modeling turns that binary into a nuanced spectrum. The experimental data that produced the positive average contains hidden information about exactly which users drove that success, and you can act on it.</p>
<p>Uplift modeling estimates a conditional average treatment effect (CATE) for each user based on their specific features. You get a score you can act on immediately.</p>
<p>Users with a high predicted CATE receive the feature. Users with a CATE near zero get skipped. The result is a segmented rollout that concentrates treatment where it produces real value, keeping inference costs and user disruption proportional to actual benefit.</p>
<p>For ML engineers and product data scientists orchestrating personalized AI rollouts, this guide walks through uplift modeling from scratch using scikit-learn. We'll build this without heavy dependencies such as causalml or econml, so you can understand the underlying mechanics.</p>
<p>You'll implement two meta-learner approaches, construct a Qini curve to evaluate how well your model ranks users, and write a segmented rollout decision rule. The dataset simulates a 50,000-user SaaS product with heterogeneity baked into different engagement tiers.</p>
<p>By the end, you'll understand when to trust your estimates and how to translate a model into a practical deployment policy.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-average-treatment-effects-mislead-for-ai-personalization">Why Average Treatment Effects Mislead for AI Personalization</a></p>
</li>
<li><p><a href="#heading-what-uplift-modeling-actually-does">What Uplift Modeling Actually Does</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-setting-up-the-working-example">Setting Up the Working Example</a></p>
<ul>
<li><p><a href="#heading-step-1-t-learner-simplest-meta-learner">Step 1: T-learner (Simplest Meta-learner)</a></p>
</li>
<li><p><a href="#heading-step-2-x-learner-handles-imbalanced-treatment-arms">Step 2: X-learner (Handles Imbalanced Treatment Arms)</a></p>
</li>
<li><p><a href="#heading-step-3-the-qini-curve-and-uplift-at-k">Step 3: The Qini Curve and Iplift at K</a></p>
</li>
<li><p><a href="#heading-step-4-a-segmented-rollout-rule">Step 4: A Segmented Rollout Rule</a></p>
</li>
<li><p><a href="#heading-step-5-bootstrap-confidence-intervals">Step 5: Bootstrap Confidence Intervals</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-when-uplift-modeling-fails">When Uplift Modeling Fails</a></p>
</li>
<li><p><a href="#heading-what-to-do-next">What to Do Next</a></p>
</li>
</ul>
<h2 id="heading-why-average-treatment-effects-mislead-for-ai-personalization">Why Average Treatment Effects Mislead for AI Personalization</h2>
<p>Think about what the average treatment effect actually averages. In a typical SaaS product, heavy users overrepresent themselves in opt-in experiments because they engage with new features more frequently. Light users underrepresent themselves because they ignore toggles.</p>
<p>The average effect reflects whatever mix of users happened to participate in the experiment, and that mix will likely look nothing like the general population you face at full rollout.</p>
<p>More critically, an average treatment effect obscures the direction of the treatment effect across subgroups.</p>
<p>Consider a scenario where an AI summary feature produces a 9.6-percentage-point lift for light users, a 7.4-percentage-point lift for medium users, and only a 6.7-percentage-point lift for heavy users. That averages out to something that looks uniformly positive.</p>
<p>But the strategic call here is to concentrate the rollout on light users while monitoring heavy users to ensure their optimized workflows aren't being disrupted. Shipping uniformly ignores this spread entirely.</p>
<p>This pattern appears across all AI feature categories. Think of an AI meeting summarizer for enterprise teams. New joiners who struggle to follow long threads benefit significantly. Experienced team members who read faster than the AI writes might find the summary slows them down. A positive average justifies building the feature, but it tells you nothing about deploying it identically to every user.</p>
<p>Uplift modeling addresses this by estimating the CATE: the expected treatment effect for a specific user given their observed features. Users where the CATE is strongly positive get treatment, while low-CATE users get held back. The Qini curve, which you'll build in step 3, tells you how much value you recover by treating only the high-CATE segment and skipping the rest.</p>
<h2 id="heading-what-uplift-modeling-actually-does">What Uplift Modeling Actually Does</h2>
<p>Uplift modeling builds on top of causal inference. The fundamental quantity is the individual treatment effect, which represents the difference in potential outcomes for a specific user:</p>
<pre><code class="language-text">ITE(i) = Y_i(1) - Y_i(0)
</code></pre>
<p><code>Y_i(1)</code> is what user <code>i</code> would do with the feature. <code>Y_i(0)</code> is what user <code>i</code> would do without it. The problem is that you observe only one of these two quantities for any given user: <code>Y_i(1)</code> for treated users and <code>Y_i(0)</code> for control users, each user appearing in only one arm.</p>
<p>The CATE is the population-level analog: the expected individual treatment effect given a user's features:</p>
<pre><code class="language-text">CATE(x) = E[Y(1) - Y(0) | X = x]
</code></pre>
<p>Meta-learner approaches estimate the CATE by fitting separate outcome models on the treated and control groups, then computing the difference in their predictions. Both the T-learner and X-learner (<a href="https://arxiv.org/abs/1706.03461">Künzel et al.</a>) rest on three identification assumptions:</p>
<ol>
<li><p><strong>Unconfoundedness</strong> (conditional ignorability): treatment assignment is independent of potential outcomes given observed covariates, T ⊥ (Y(0), Y(1)) | X. In a randomized experiment, this holds automatically. In an observational opt-in study, you need a feature set rich enough to control for confounders.</p>
</li>
<li><p><strong>Overlap</strong> (positivity): every user has a nonzero probability of receiving either the treatment or the control, with 0 &lt; P(T=1|X=x) &lt; 1. When some users have a near-zero opt-in probability (as light users do in this dataset, at 12%), CATE estimates in that region have higher variance.</p>
</li>
<li><p><strong>SUTVA</strong>: each user's outcome depends only on their own treatment, independent of what other users around them do. If your users share workspaces or social graphs, this assumption may be violated (addressed in "What to do next").</p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You need:</p>
<ul>
<li><p>Python 3.11 or newer</p>
</li>
<li><p>Comfort with pandas and scikit-learn</p>
</li>
<li><p>Rough familiarity with linear regression and logistic regression</p>
</li>
</ul>
<p>Install the packages for this tutorial:</p>
<pre><code class="language-bash">pip install numpy pandas scikit-learn matplotlib scipy
</code></pre>
<p><strong>Here's what's happening:</strong> this installs the full numeric stack for the tutorial. scipy is needed for KDE smoothing of the Qini curve in the chart generator. Everything else is standard ML tooling.</p>
<p>Clone the companion repo to get the synthetic dataset:</p>
<pre><code class="language-bash">git clone https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm.git
cd product-experimentation-causal-inference-genai-llm
python data/generate_data.py --seed 42 --n-users 50000 --out data/synthetic_llm_logs.csv
</code></pre>
<p><strong>Here's what's happening:</strong> the data generator creates a reproducible dataset of 50,000 synthetic SaaS product users. Every user has an engagement tier (light, medium, heavy), a query confidence score, and an opt-in flag for the AI summary feature. The ground-truth causal effect of opting in is approximately +8 percentage points <code>task_completed</code>, baked in with per-tier variation across engagement segments. All numbers in this tutorial come from this exact dataset.</p>
<p>All code in this article runs end-to-end in the companion notebook at <a href="https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/08_uplift_modeling"><code>08_uplift_modeling/uplift_demo.ipynb</code></a>. Clone the repo and run <code>uplift_demo.py</code> to reproduce every result.</p>
<h2 id="heading-setting-up-the-working-example">Setting Up the Working Example</h2>
<p>The dataset simulates a SaaS product with an AI summary feature that users opted into via a toggle. 50,000 users, with <code>opt_in_agent_mode</code> as the treatment column and <code>task_completed</code> as the binary outcome. The engagement tier (light, medium, heavy) captures how actively each user interacts with the product.</p>
<p>Load the data and establish the baseline:</p>
<pre><code class="language-python">import pandas as pd
import numpy as np

df = pd.read_csv("data/synthetic_llm_logs.csv")
print(df.shape)
print(df[["engagement_tier", "opt_in_agent_mode", "task_completed"]].head(10))

# Opt-in rates by tier
print("\nOpt-in rate by engagement tier:")
print(df.groupby("engagement_tier").opt_in_agent_mode.mean().round(3))

# Naive ATE: treated minus control
naive_ate = (
    df[df.opt_in_agent_mode == 1].task_completed.mean()
    - df[df.opt_in_agent_mode == 0].task_completed.mean()
)
print(f"\nNaive ATE (treated - control): {naive_ate:+.4f}")
print(f"Treated users: {(df.opt_in_agent_mode == 1).sum():,}")
print(f"Control users: {(df.opt_in_agent_mode == 0).sum():,}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">(50000, 16)
  engagement_tier  opt_in_agent_mode  task_completed
0          medium                  0               0
...

Opt-in rate by engagement tier:
engagement_tier
heavy     0.647
light     0.120
medium    0.353
Name: opt_in_agent_mode, dtype: float64

Naive ATE (treated - control): +0.2106
Treated users: 13,451
Control users: 36,549
</code></pre>
<p><strong>Here's what's happening:</strong> you load 50,000 rows and immediately see a severe selection-on-engagement pattern. Heavy users opt in at 64.7%, medium at 35.3%, and light users at only 12%. The naïve ATE is +0.2106, more than double the true underlying effect.</p>
<p>That gap reflects selection bias: the treated group is skewed toward heavy users who complete more tasks regardless of the feature. The +0.21 number measures engagement level more than feature impact.</p>
<p>Now look at the naïve per-tier gaps, which hint at the heterogeneity you're about to estimate properly:</p>
<pre><code class="language-python"># Naive per-tier gap (confounded but directionally useful)
print("Naive per-tier treated vs. control completion rate:")
for tier in ["light", "medium", "heavy"]:
    sub = df[df.engagement_tier == tier]
    t_rate = sub[sub.opt_in_agent_mode == 1].task_completed.mean()
    c_rate = sub[sub.opt_in_agent_mode == 0].task_completed.mean()
    print(f"  {tier:8s}: treated={t_rate:.3f}, control={c_rate:.3f}, "
          f"diff={t_rate - c_rate:+.3f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Naive per-tier treated vs. control completion rate:
  light   : treated=0.551, control=0.455, diff=+0.096
  medium  : treated=0.745, control=0.670, diff=+0.075
  heavy   : treated=0.891, control=0.824, diff=+0.067
</code></pre>
<p><strong>Here's what's happening:</strong> even the raw confounded gaps show the ordering light &gt; medium &gt; heavy (+0.096 &gt; +0.075 &gt; +0.067). Light users show the largest within-tier gap, heavy users the smallest.</p>
<p>This is counterintuitive if you assume power users always benefit most, but it makes sense for an AI summary feature. Light users frequently lose context in long threads and genuinely benefit from a summary at the top. Heavy users have already internalized how to navigate the product and find the summary more disruptive than useful. The T-learner in the next step will sharpen these estimates by controlling for query confidence within each tier.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69cc82ffe4688e4edd796adb/911ddc46-5c79-41b1-910f-af17d426dc5f.png" alt="Figure 1, description below" style="display:block;margin:0 auto" width="1398" height="905" loading="lazy">

<p><em>Figure 1: Conceptual illustration of heterogeneous treatment effects. Control and treated distributions (dashed and solid lines) are shown for each engagement tier. The per-tier CATE (the gap between the two curves) decreases from light to heavy users. The bottom panel shows how the ATE collapses this spread into a single average, misrepresenting how the feature actually works for each segment.</em></p>
<h2 id="heading-step-1-t-learner-simplest-meta-learner">Step 1: T-learner (Simplest Meta-learner)</h2>
<p>The T-learner fits two completely separate models: one for the treated group and one for the control group. The predicted CATE for any user is the difference between the treated model's prediction and the control model's prediction for that user's features.</p>
<pre><code class="language-python">from sklearn.linear_model import LinearRegression
import pandas as pd
import numpy as np

# Build feature matrix: query_confidence + engagement_tier dummies
X_full = pd.get_dummies(
    df[["query_confidence", "engagement_tier"]],
    drop_first=False
).astype(float)

feature_cols = X_full.columns.tolist()
print("Feature columns:", feature_cols)

X_all = X_full.values
treated_mask = df.opt_in_agent_mode == 1
control_mask = ~treated_mask

X1 = X_all[treated_mask]    # features for treated users
Y1 = df[treated_mask].task_completed.values
X0 = X_all[control_mask]    # features for control users
Y0 = df[control_mask].task_completed.values

# Fit separate models on each arm
m1 = LinearRegression().fit(X1, Y1)   # outcome model for treated
m0 = LinearRegression().fit(X0, Y0)   # outcome model for control

# CATE = mu_1(x) - mu_0(x)
cate_t = m1.predict(X_all) - m0.predict(X_all)
df["cate_tlearner"] = cate_t

print(f"\nMean CATE (T-learner): {cate_t.mean():+.4f}")
print("\nMean predicted CATE by engagement tier:")
print(df.groupby("engagement_tier").cate_tlearner.mean().round(4))
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Feature columns: ['query_confidence', 'engagement_tier_heavy', 'engagement_tier_light', 'engagement_tier_medium']

Mean CATE (T-learner): +0.0847

Mean predicted CATE by engagement tier:
engagement_tier
heavy     0.0665
light     0.0954
medium    0.0744
Name: cate_tlearner, dtype: float64
</code></pre>
<p><strong>Here's what's happening:</strong> you encode engagement tier as one-hot columns and keep query confidence as a continuous feature. Two <code>LinearRegression</code> models fit separately: <code>m1</code> learns the conditional expectation of task completion among users who opted in, <code>m0</code> learns the same among users who didn't. For any user with features <code>x</code>, the predicted CATE is <code>m1(x) - m0(x)</code>.</p>
<p>The output confirms the direction from the naïve gaps but sharpens the estimates. The mean CATE across all 50,000 users is +0.0847, close to the ground truth of +0.08. The per-tier ordering is light (+0.0954) &gt; medium (+0.0744) &gt; heavy (+0.0665). The +0.2106 naive ATE was hiding a 1.4x difference between light and heavy users. That spread is your segmentation signal.</p>
<p>The T-learner has one important caveat worth naming: when one arm is much smaller than the other (here, 13,451 treated versus 36,549 control), the model trained on the smaller arm can show higher variance. Linear regression handles this reasonably well at 50,000 total users. The X-learner in the next step directly addresses the imbalance.</p>
<h2 id="heading-step-2-x-learner-handles-imbalanced-treatment-arms">Step 2: X-learner (Handles Imbalanced Treatment Arms)</h2>
<p>The X-learner improves on the T-learner by using the larger arm to help estimate the CATE in the smaller arm. It does this by computing <em>imputed treatment effects</em> for each user: counterfactual outcomes predicted by the cross-arm model, then differencing them from the observed outcome.</p>
<p>The procedure has four steps:</p>
<ol>
<li><p>Fit outcome models <code>m0</code> and <code>m1</code> on each arm (same as T-learner).</p>
</li>
<li><p>For treated users: compute <code>D1 = Y1 - m0(X1)</code>, the difference between what each treated user actually achieved and what the control model predicts they would have achieved without treatment.</p>
</li>
<li><p>For control users: compute <code>D0 = m1(X0) - Y0</code>, the difference between what the treated model predicts each control user would achieve under treatment and what they actually achieved.</p>
</li>
<li><p>Fit two tau regressors (one per arm), then combine them using the propensity score as a weight. Per (<a href="https://arxiv.org/abs/1706.03461">Künzel et al.</a>): <code>tau(x) = g(x) * tau_1(x) + (1 - g(x)) * tau_0(x)</code>, where g(x) is the propensity score. When g(x) is low (few treated users in this feature region), tau_0, estimated from the large control arm, gets more weight. When g(x) is high, tau_1 gets more weight.</p>
</li>
</ol>
<pre><code class="language-python">from sklearn.linear_model import LinearRegression, LogisticRegression

# Step 1: m0 and m1 already fitted in Step 1 above

# Step 2: imputed treatment effects for treated group
D1 = Y1 - m0.predict(X1)     # Y(1) - mu_0(X1)

# Step 3: imputed treatment effects for control group
D0 = m1.predict(X0) - Y0     # mu_1(X0) - Y(0)

# Fit tau regressors on each arm
tau1_model = LinearRegression().fit(X1, D1)  # tau for treated arm
tau0_model = LinearRegression().fit(X0, D0)  # tau for control arm

# Step 4: estimate propensity score e(x) = P(T=1 | X)
ps_model = LogisticRegression(max_iter=1000).fit(X_all, df.opt_in_agent_mode.values)
e_x = ps_model.predict_proba(X_all)[:, 1]

# Kunzel et al. (2019): tau(x) = g(x)*tau_1(x) + (1 - g(x))*tau_0(x)
tau1_all = tau1_model.predict(X_all)
tau0_all = tau0_model.predict(X_all)
cate_x = e_x * tau1_all + (1 - e_x) * tau0_all
df["cate_xlearner"] = cate_x

print(f"Mean CATE (X-learner): {cate_x.mean():+.4f}")
print("\nMean predicted CATE by engagement tier:")
print(df.groupby("engagement_tier").cate_xlearner.mean().round(4))

# Compare T-learner vs X-learner
print("\nT-learner vs X-learner per tier:")
comp = df.groupby("engagement_tier")[["cate_tlearner", "cate_xlearner"]].mean().round(4)
print(comp)
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Mean CATE (X-learner): +0.0847

Mean predicted CATE by engagement tier:
engagement_tier
heavy     0.0665
light     0.0954
medium    0.0744
Name: cate_xlearner, dtype: float64

T-learner vs X-learner per tier:
                 cate_tlearner  cate_xlearner
engagement_tier
heavy                   0.0665         0.0665
light                   0.0954         0.0954
medium                  0.0744         0.0744
</code></pre>
<p><strong>Here's what's happening:</strong> with linear outcome models and four features, the T-learner and X-learner produce identical per-tier CATEs. This agreement is expected when the outcome models are well-specified: the cross-imputation in the X-learner doesn't add information that a linear model can't already recover.</p>
<p>In production, the X-learner's advantage shows up when you use gradient boosting or causal forests as the outcome models, since tree-based models amplify arm-size imbalance in ways the X-learner's propensity-weighted combination corrects.</p>
<p>Run both estimators whenever you upgrade the base model, and prefer the one that shows better calibration on a held-out set.</p>
<h2 id="heading-step-3-the-qini-curve-and-uplift-at-k">Step 3: The Qini Curve and Uplift at K</h2>
<p>A CATE model is useful only if its ranking of users aligns with their actual treatment-response ordering. The Qini curve (<a href="https://www.research.ed.ac.uk/en/publications/using-control-groups-to-target-on-predicted-lift-building-and-ass">Radcliffe, 2007</a>) tests this by asking: if you sort users by predicted CATE (in descending order) and treat only the top k%, how much observed uplift do you actually recover?</p>
<pre><code class="language-python">import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

# Sort users by predicted CATE descending
df_sorted = df.sort_values("cate_tlearner", ascending=False).copy()
n = len(df_sorted)

# Compute observed uplift at each percentile cutoff
top_ks = np.arange(0.01, 1.01, 0.01)
qini_vals = []

for k in top_ks:
    top_n = max(1, int(k * n))
    sub = df_sorted.iloc[:top_n]
    treated_sub = sub[sub.opt_in_agent_mode == 1]
    control_sub  = sub[sub.opt_in_agent_mode == 0]
    if len(treated_sub) &gt; 0 and len(control_sub) &gt; 0:
        uplift = (treated_sub.task_completed.mean()
                  - control_sub.task_completed.mean())
    else:
        uplift = np.nan
    qini_vals.append(uplift)

# Plot
fig, ax = plt.subplots(figsize=(8, 4.5))
ax.plot(top_ks * 100, qini_vals, linewidth=2, label="T-learner Qini")
ax.axhline(naive_ate, color="gray", linestyle="--",
           label=f"Naive ATE = {naive_ate:.4f}")
ax.set_xlabel("Top-k% of users (sorted by predicted CATE)")
ax.set_ylabel("Observed uplift in top-k group")
ax.set_title("Qini curve: T-learner ranking vs. observed uplift")
ax.legend()
plt.tight_layout()
plt.savefig("qini_curve.png", dpi=140)
print("Saved qini_curve.png")

# Print values at selected percentiles
print("\nQini values at selected cutoffs:")
for target_k in [10, 20, 30, 50, 70, 100]:
    idx = target_k - 1
    print(f"  Top {target_k:3d}%: observed uplift = {qini_vals[idx]:.4f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Saved qini_curve.png

Qini values at selected cutoffs:
  Top  10%: observed uplift = 0.0895
  Top  20%: observed uplift = 0.1018
  Top  30%: observed uplift = 0.0959
  Top  50%: observed uplift = 0.0966
  Top  70%: observed uplift = 0.1454
  Top 100%: observed uplift = 0.2106
</code></pre>
<p><strong>Here's what's happening:</strong> you sort all 50,000 users by the T-learner's predicted CATE, highest first. For each percentile cutoff, you compute the raw treated-minus-control difference in task completion within that subgroup.</p>
<p>The top-10% group shows an observed uplift of +0.0895 and the top-20% group shows +0.1018, both well below the naive ATE of +0.2106, which is confounded by selection and reflects engagement level more than feature impact.</p>
<p>The Qini values here also mix the CATE signal with residual selection bias: all users in the top 54% by predicted CATE are light users (the tier with the lowest opt-in rate of 12%), so the treated-minus-control comparison within that group is still confounded by within-tier selection bias.</p>
<p>The jump in the top 70% (+0.1454) makes this confounding effect visible: as medium and heavy users enter the ranked group, the treated side suddenly includes high-completion heavy users (64.7% opt-in), while the control side remains dominated by low-completion light users. That spike is selection bias, with no genuine CATE signal behind it.</p>
<p>In observational uplift settings, the actionable region of the Qini is roughly the top 20% to 50%, where the ranking reflects the model's CATE estimates more cleanly than at higher percentiles, where propensity-score correlation with outcome levels dominates.</p>
<h2 id="heading-step-4-a-segmented-rollout-rule">Step 4: A Segmented Rollout Rule</h2>
<p>The CATE model assigns a predicted treatment effect to every user. Turn that into a deployment policy by setting a threshold: ship the feature to users whose predicted CATE exceeds some value, suppress it for everyone else.</p>
<pre><code class="language-python"># Inspect the CATE distribution first
print("CATE distribution (T-learner):")
print(pd.Series(df.cate_tlearner).describe().round(4))
print()

# Plot CATE distribution
fig, ax = plt.subplots(figsize=(8, 4))
ax.hist(df.cate_tlearner, bins=50, edgecolor="white", linewidth=0.5)
ax.axvline(0.085, color="red", linestyle="--", label="Threshold = 0.085")
ax.axvline(df.cate_tlearner.mean(), color="gray", linestyle=":",
           label=f"Mean CATE = {df.cate_tlearner.mean():.4f}")
ax.set_xlabel("Predicted CATE (T-learner)")
ax.set_ylabel("Number of users")
ax.set_title("Distribution of predicted CATEs")
ax.legend()
plt.tight_layout()
plt.savefig("cate_distribution.png", dpi=140)
print("Saved cate_distribution.png")

# Apply rollout rule
threshold = 0.085
selected = df[df.cate_tlearner &gt;= threshold].copy()
suppressed = df[df.cate_tlearner &lt; threshold].copy()

print(f"\nRollout threshold: CATE &gt;= {threshold}")
print(f"Users selected for rollout: {len(selected):,} ({100*len(selected)/len(df):.0f}%)")
print(f"Users suppressed:           {len(suppressed):,} ({100*len(suppressed)/len(df):.0f}%)")
print()
print("Tier composition of selected group:")
print((selected.groupby("engagement_tier").size() / len(selected)).round(3))
print()
print(f"Mean predicted CATE (selected):   {selected.cate_tlearner.mean():.4f}")
print(f"Mean predicted CATE (suppressed): {suppressed.cate_tlearner.mean():.4f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">CATE distribution (T-learner):
count    50000.0000
mean         0.0847
std          0.0126
min          0.0515
25%          0.0731
50%          0.0897
75%          0.0963
max          0.1021
Name: cate_tlearner, dtype: float64

Saved cate_distribution.png

Rollout threshold: CATE &gt;= 0.085
Users selected for rollout: 27,203 (54%)
Users suppressed:           22,797 (46%)

Tier composition of selected group:
engagement_tier
light    1.0
dtype: float64

Mean predicted CATE (selected):   0.0955
Mean predicted CATE (suppressed): 0.0719
</code></pre>
<p><strong>Here's what's happening:</strong> you inspect the full CATE distribution before setting a threshold. The mean CATE across all 50,000 users is +0.0847, with a standard deviation of +0.0126. Setting a threshold at +0.085 (just above the mean of +0.0847) selects 27,203 users (54%).</p>
<p>The tier composition of the selected group is 100% light users: with linear models and these features, the CATE ranges for each tier don't overlap across the threshold. Light users all have predicted CATEs between +0.0807 and +0.1021. Medium users have predicted CATEs between +0.0592 and +0.0812. The threshold at 0.085 cleanly separates the two.</p>
<p>The mean predicted CATE in the selected group (+0.0955) is 33% higher than in the suppressed group (+0.0719). That concentration is the value of the segmented rollout: you deploy the AI summary to the 54% of users who stand to benefit most, hold it back from medium and heavy users who show smaller predicted benefit, and collect outcome data on both groups to refine the threshold quarterly.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69cc82ffe4688e4edd796adb/0bfc5bc9-b9b0-42ba-9ffb-1a3eee05e797.png" alt="Figure 2, description below" style="display:block;margin:0 auto" width="1298" height="905" loading="lazy">

<p><em>Figure 2: Per-tier CATE distributions from the 50,000-user synthetic dataset. The top panel shows smooth KDE curves per engagement tier: light users (blue) cluster at the highest predicted CATEs, heavy users (green) at the lowest. The bottom panel shows mean CATE per tier with 95% bootstrap confidence intervals, alongside the naive ATE (+0.2106) as a reference line. All three tier CIs sit well below the naïve ATE, confirming that the average was confounded by selection bias.</em></p>
<p>The rollout rule maps directly to a feature flag system:</p>
<pre><code class="language-python"># Simulate the rollout decision for a single new user
def should_show_feature(query_confidence, engagement_tier, threshold=0.085):
    """Returns True if predicted CATE exceeds the rollout threshold."""
    x = pd.get_dummies(
        pd.DataFrame([{"query_confidence": query_confidence,
                        "engagement_tier": engagement_tier}]),
        drop_first=False
    ).reindex(columns=feature_cols, fill_value=0).astype(float).values
    cate = m1.predict(x)[0] - m0.predict(x)[0]
    return cate &gt;= threshold, round(cate, 4)

show, cate = should_show_feature(0.72, "heavy")
print(f"Heavy user, conf=0.72:  show feature={show}, CATE={cate}")

show, cate = should_show_feature(0.72, "light")
print(f"Light user, conf=0.72:  show feature={show}, CATE={cate}")

show, cate = should_show_feature(0.45, "medium")
print(f"Medium user, conf=0.45: show feature={show}, CATE={cate}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Heavy user, conf=0.72:  show feature=False, CATE=0.0667
Light user, conf=0.72:  show feature=True, CATE=0.0955
Medium user, conf=0.45: show feature=False, CATE=0.0681
</code></pre>
<p><strong>Here's what's happening:</strong> you wrap the CATE computation into a function that mirrors what a real feature-flag service would run at request time. A heavy user with moderate query confidence gets <code>show feature=False</code> and a CATE of +0.0667, below the 0.085 threshold. The same query confidence from a light user gets <code>show feature=True</code> and a CATE of +0.0955. A medium user with lower confidence falls below the +0.0681 threshold.</p>
<p>These outputs match the domain story: the AI summary helps users who struggle to maintain context across sessions, and engagement tier is a strong proxy for that struggle.</p>
<h2 id="heading-step-5-bootstrap-confidence-intervals">Step 5: Bootstrap Confidence Intervals</h2>
<p>The CATE estimates above are point estimates with no uncertainty quantification. Before you build rollout rules on them, you need to know how stable those estimates are across different samples of your user base.</p>
<pre><code class="language-python">def bootstrap_cate_ci(df, X_all, feature_cols, n_reps=500, seed=7):
    """Bootstrap 95% CI for mean CATE overall and per engagement tier."""
    rng = np.random.default_rng(seed)
    n = len(df)
    tier_reps = {"light": [], "medium": [], "heavy": []}
    mean_reps = []

    for _ in range(n_reps):
        idx = rng.integers(0, n, size=n)
        df_b = df.iloc[idx].reset_index(drop=True)
        X_b = X_all[idx]
        treated_b = df_b.opt_in_agent_mode == 1
        m1_b = LinearRegression().fit(X_b[treated_b], df_b[treated_b].task_completed.values)
        m0_b = LinearRegression().fit(X_b[~treated_b], df_b[~treated_b].task_completed.values)
        cate_b = m1_b.predict(X_b) - m0_b.predict(X_b)
        df_b["cate"] = cate_b
        for tier in tier_reps:
            tier_reps[tier].append(df_b[df_b.engagement_tier == tier].cate.mean())
        mean_reps.append(cate_b.mean())

    cis = {}
    for tier, vals in tier_reps.items():
        arr = np.array(vals)
        cis[tier] = (float(np.percentile(arr, 2.5)),
                     float(np.percentile(arr, 97.5)))
    arr = np.array(mean_reps)
    cis["mean"] = (float(np.percentile(arr, 2.5)),
                   float(np.percentile(arr, 97.5)))
    return cis

print("Running bootstrap (500 replicates, seed=7)...")
cis = bootstrap_cate_ci(df, X_all, feature_cols, n_reps=500, seed=7)
print(f"Mean CATE   95% CI: [{cis['mean'][0]:+.4f}, {cis['mean'][1]:+.4f}]")
print(f"Light tier  95% CI: [{cis['light'][0]:+.4f}, {cis['light'][1]:+.4f}]")
print(f"Medium tier 95% CI: [{cis['medium'][0]:+.4f}, {cis['medium'][1]:+.4f}]")
print(f"Heavy tier  95% CI: [{cis['heavy'][0]:+.4f}, {cis['heavy'][1]:+.4f}]")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Running bootstrap (500 replicates, seed=7)...
Mean CATE   95% CI: [+0.0744, +0.0951]
Light tier  95% CI: [+0.0781, +0.1125]
Medium tier 95% CI: [+0.0596, +0.0892]
Heavy tier  95% CI: [+0.0483, +0.0842]
</code></pre>
<p><strong>Here's what's happening:</strong> you resample the full 50,000-user dataset 500 times with replacement, refit the T-learner on each resample, and compute the distribution of mean CATEs across bootstrap iterations. The 2.5th and 97.5th percentiles of that distribution give a 95% confidence interval for each estimate.</p>
<p>Three things to check in these CIs. First, the overall mean CI (+0.0744, +0.0951) brackets the ground truth of +0.08, confirming that the estimator is working. Second, the light-tier CI (+0.0781, +0.1125) is wider than the heavy-tier CI (+0.0483, +0.0842), consistent with light users having the lowest opt-in rate (12%) and therefore fewer treated observations to anchor the estimate. Third, the tier CIs don't fully separate at their tails: light's lower bound (+0.0781) barely clears heavy's upper bound (+0.0842), meaning the ordering light &gt; heavy is stable but not by a wide margin.</p>
<p>For a business decision about differential rollout, that stability is enough. For a regulatory or clinical context, you'd want larger samples.</p>
<h2 id="heading-when-uplift-modeling-fails">When Uplift Modeling Fails</h2>
<p>CATE models look compelling because they produce a continuous, individualized score. Four failure modes deserve explicit attention before you deploy a CATE-based policy.</p>
<h3 id="heading-1-thin-segments-overlap-violation">1. Thin Segments (Overlap Violation)</h3>
<p>The CATE for light users is estimated from 12% of your 13,451 treated users, roughly 1,614 people. That's enough to detect a tier-level average but not enough to estimate reliable individual-level effects within the tier at fine-grained feature values.</p>
<p>When the treatment arm has sparse coverage in a region of feature space, CATE estimates there carry high variance. The model returns a smooth prediction, but the empirical support behind it may be weak.</p>
<p>Check the feature distribution of your highest-CATE users and verify that treated and control observations exist in each region before acting on the ranking.</p>
<h3 id="heading-2-extrapolation-at-the-tails-overlap-violation">2. Extrapolation at the Tails (Overlap Violation)</h3>
<p>Linear regression extrapolates smoothly outside the training range. If your model assigns a predicted CATE to a user whose feature values fall in a region with no training data for one arm, that estimate lacks empirical support.</p>
<p>The overlap assumption fails silently: the model returns a number, but P(T=1|X=x) is approximately 0 or 1 in that region, making the CATE unidentified.</p>
<p>Check propensity scores alongside CATE predictions and clip or flag estimates where the propensity falls outside [0.05, 0.95].</p>
<h3 id="heading-3-qini-noise-at-small-k">3. Qini Noise at Small k</h3>
<p>The Qini curve is noisy at very small k (top 5% or fewer). When only a few hundred users are in the evaluation group, the treated count in that group may be small enough that the observed uplift is dominated by sampling noise.</p>
<p>Base rollout decisions on the 20% to 50% Qini range, where the signal is more stable. In observational settings, high Qini values at large k (such as +0.1454 in the top 70% in this tutorial) can reflect selection bias that masks the real CATE signal. Inspect the tier composition of each top-k group before interpreting the uplift value.</p>
<h3 id="heading-4-overfitting-the-cate-model">4. Overfitting the CATE Model</h3>
<p>A <code>LinearRegression</code> trained on the treated arm here sees 13,451 observations and four features, a comfortable margin. If you replace linear regression with gradient boosting and add 30 features, you can overfit the imputed treatment effects to training noise. The CATE predictions will look sharply heterogeneous on the training set and regress toward the global mean on a held-out set. A CATE model earns its complexity when it outperforms the tier-level averages on held-out uplift. Evaluate on a held-out dataset before using it to build rollout rules.</p>
<h2 id="heading-what-to-do-next">What to Do Next</h2>
<p>The implementations above are built without external uplift libraries so you can see exactly what each step computes. For production use, <a href="https://github.com/uber/causalml"><code>causalml</code></a> and <a href="https://github.com/py-why/EconML"><code>econml</code></a> offer richer versions of both estimators: tree-based T-learners, doubly robust X-learners, and honest causal forests that split training and estimation samples to reduce overfitting. Both libraries follow the same conceptual structure you've built here.</p>
<p><code>causalml</code> includes production-grade Qini curve computation and the AUUC (area under the uplift curve) metric, which collapses the Qini curve into a single comparison number. For running uplift model comparisons in an A/B framework, AUUC is the standard leaderboard metric.</p>
<p>One structural limitation worth naming: this tutorial assumed SUTVA, meaning each user's outcome depends only on their own treatment status. In workspace-based AI products, that assumption is often wrong. Users in the same workspace share a common environment, and treating one user can affect teammates through shared outputs, changed response patterns, or altered workspace dynamics.</p>
<p>When you suspect this kind of interference, DR-learner variants that propagate within-group correlation into the CATE estimates give more realistic uncertainty bounds. Standard T-learner and X-learner treat all observations as independent, which understates uncertainty when workspace-level factors are at play.</p>
<p>The companion repo for this tutorial lives at <a href="https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/08_uplift_modeling">github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/08_uplift_modeling</a>. Clone the repo, generate the dataset with <code>--n-users 50000 --seed 42</code>, and run <code>uplift_demo.py</code> to reproduce every result in this tutorial.</p>
<p>The ATE is the number you need to decide whether to build a feature. The CATE is the number you need to decide who gets it first. A segmented rollout that focuses treatment on the 54% of users with the strongest predicted response yields more than spreading the same feature to everyone. Uniform rollout is a policy choice. Make it an informed one.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Build Your Own Healthcare AI Assistant with MedGemma, Ollama, and Open WebUI ]]>
                </title>
                <description>
                    <![CDATA[ Healthcare data is among the most sensitive data there is. Sending it to a cloud AI service is often not an option because of privacy requirements, regulatory compliance, or both. In this tutorial, yo ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-your-own-healthcare-ai-assistant-with-medgemma-ollama-and-open-webui/</link>
                <guid isPermaLink="false">6a4edb71b23ba37e305b1825</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ healthcare ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ollama ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Tutorial ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Medical Imaging ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Lakshmi Mahabaleshwara ]]>
                </dc:creator>
                <pubDate>Wed, 08 Jul 2026 23:21:21 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/c6e53c46-ca40-4f4a-87e9-a925c85963d6.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Healthcare data is among the most sensitive data there is. Sending it to a cloud AI service is often not an option because of privacy requirements, regulatory compliance, or both.</p>
<p>In this tutorial, you’ll build a healthcare AI assistant that runs entirely on your own machine using three open-source tools:</p>
<ul>
<li><p>MedGemma, Google’s open medical AI model for understanding medical text and images</p>
</li>
<li><p>Ollama, the easiest way to download and run AI models locally</p>
</li>
<li><p>Open WebUI, a ChatGPT-style web interface for interacting with local models</p>
</li>
</ul>
<p>By the end, you’ll be able to chat with a medically tuned AI model, upload medical images such as chest X-rays for analysis, and do it all locally, without sending your data to the cloud.</p>
<p><strong>Important disclaimer</strong> before we start: MedGemma is a developer model, not a medical device. Its outputs are not intended to directly inform clinical diagnosis, patient management, or treatment decisions.</p>
<p>Everything you build in this tutorial is for learning, prototyping, and research. Always consult qualified healthcare professionals for real medical questions.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><p><a href="#heading-who-is-this-tutorial-for">Who is This Tutorial For?</a></p>
</li>
<li><p><a href="#heading-what-is-medgemma">What is MedGemma?</a></p>
</li>
<li><p><a href="#heading-why-run-models-locally">Why Run Models Locally?</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-architecture-diagram">Architecture Diagram</a></p>
</li>
<li><p><a href="#heading-step-1-install-ollama">Step 1: Install Ollama</a></p>
</li>
<li><p><a href="#heading-step-2-pull-medgemma">Step 2: Pull MedGemma</a></p>
</li>
<li><p><a href="#heading-step-3-test-medgemma-from-the-terminal">Step 3: Test MedGemma from the Terminal</a></p>
</li>
<li><p><a href="#heading-step-4-install-open-webui">Step 4: Install Open WebUI</a></p>
<ul>
<li><p><a href="#heading-option-a-docker-recommended">Option A: Docker (recommended)</a></p>
</li>
<li><p><a href="#heading-option-b-pip-no-docker">Option B: pip (no Docker)</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-step-5-connect-open-webui-to-ollama">Step 5: Connect Open WebUI to Ollama</a></p>
</li>
<li><p><a href="#heading-step-6-start-chatting-with-medgemma">Step 6: Start Chatting with MedGemma</a></p>
</li>
<li><p><a href="#heading-step-7-upload-medical-images">Step 7: Upload Medical Images</a></p>
</li>
<li><p><a href="#heading-example-prompts-to-try">Example Prompts to Try</a></p>
</li>
<li><p><a href="#heading-running-larger-models">Running Larger Models</a></p>
</li>
<li><p><a href="#heading-troubleshooting-guide">Troubleshooting Guide</a></p>
<ul>
<li><p><a href="#heading-error-registryollamaailibrarymedgemmalatest-does-not-support-tools">Error: registry.ollama.ai/library/medgemma:latest does not support tools</a></p>
</li>
<li><p><a href="#heading-open-webui-shows-no-models-in-the-dropdown">Open WebUI shows no models in the dropdown</a></p>
</li>
<li><p><a href="#heading-ollama-pull-medgemma-says-model-not-found">ollama pull medgemma says model not found</a></p>
</li>
<li><p><a href="#heading-responses-are-extremely-slow">Responses are extremely slow</a></p>
</li>
<li><p><a href="#heading-image-upload-doesnt-work-or-the-model-ignores-the-image">Image upload doesn't work or the model ignores the image</a></p>
</li>
<li><p><a href="#heading-port-3000-is-already-in-use">Port 3000 is already in use</a></p>
</li>
<li><p><a href="#heading-out-of-memory-errors-when-loading-the-27b-model">"Out of memory" errors when loading the 27B model</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-who-is-this-tutorial-for"><strong>Who is This Tutorial For?</strong></h2>
<p>This tutorial is ideal if you’re:</p>
<ul>
<li><p>learning healthcare AI</p>
</li>
<li><p>building medical RAG systems</p>
</li>
<li><p>experimenting with radiology assistants</p>
</li>
<li><p>developing medical education tools</p>
</li>
<li><p>researching multimodal models</p>
</li>
</ul>
<h2 id="heading-what-is-medgemma">What is MedGemma?</h2>
<p><strong>MedGemma</strong> is a collection of open models from Google, built on the Gemma 3 architecture and specifically trained for medical text and image comprehension. Think of it as Gemma after four years of medical school and a radiology residency.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/0ea6b1a3-c9dd-4990-8fd4-404ab4069458.png" alt="Diagram showing MedGemma’s multimodal architecture, where medical images are processed by a SigLIP vision encoder and combined with a language model to understand medical text and images and generate responses." style="display:block;margin:0 auto" width="1245" height="1150" loading="lazy">

<h3 id="heading-why-medgemma">Why MedGemma?</h3>
<p>Unlike general-purpose models such as Llama or Mistral, MedGemma is designed specifically for healthcare applications.</p>
<ul>
<li><p><strong>Medical image understanding:</strong> Its multimodal models are trained on de-identified medical images, including chest X-rays, dermatology, ophthalmology, and pathology images.</p>
</li>
<li><p><strong>Medical language expertise:</strong> It has been trained on medical literature and clinical question-answer datasets, enabling it to better understand medical terminology and radiology reports.</p>
</li>
<li><p><strong>Multiple model sizes:</strong> MedGemma is available in 4B and 27B variants, both supporting text and image inputs with a 128K context window.</p>
</li>
<li><p><strong>Open weights:</strong> You can download, run, fine-tune, and build applications with the model locally under the Health AI Developer Foundation's terms of use.</p>
</li>
</ul>
<p>MedGemma is intended as a foundation model for developers building healthcare applications, medical education tools, research assistants, report summarizers, and other AI-powered medical workflows.</p>
<h2 id="heading-why-run-models-locally">Why Run Models Locally?</h2>
<p>You could call a hosted medical model through an API. So why go local? In healthcare, the case is stronger than almost anywhere else.</p>
<p>First, there's the principle of privacy by architecture. When the model runs on your machine, medical text and images never leave your device. There's no API log, no third-party data processor, no data processing agreement to negotiate.</p>
<p>For anyone working near PHI (Protected Health Information), "the data never left the laptop" is the simplest compliance story that exists.</p>
<p>Next, you have zero per-token cost. Experimentation is free once the model is downloaded. You can iterate on prompts hundreds of times without watching a billing dashboard.</p>
<p>You also get offline access. Hospitals, labs, and field clinics often have restricted or air-gapped networks. A local model works without internet after the initial download.</p>
<p>And you have full control over the setup: you choose the model version, you pin it, and it never changes underneath you. No deprecation notices, no silent behavior changes.</p>
<p>Finally, it's a great way to learn. Running models locally demystifies them. You'll develop intuition for context windows, quantization, and memory constraints that you simply don't get from calling an API.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Here's what you need before starting:</p>
<p><strong>Hardware:</strong></p>
<ul>
<li><p><strong>8 GB RAM minimum</strong> (16 GB recommended) for the MedGemma 4B model. The download is about 3.3 GB.</p>
</li>
<li><p><strong>32 GB RAM or a 24 GB+ GPU</strong> if you want to run the 27B model (a roughly 17 GB download).</p>
</li>
<li><p>Around <strong>15 GB of free disk space</strong> to be comfortable (model + Docker images + working room).</p>
</li>
<li><p>Apple Silicon Macs (M1 through M4) are excellent for this. Ollama uses Metal acceleration automatically. On Windows and Linux, an NVIDIA GPU helps a lot but isn't required. A CPU-only inference works, just slower.</p>
</li>
</ul>
<p><strong>Software:</strong></p>
<ul>
<li><p>macOS, Linux, or Windows 10/11</p>
</li>
<li><p><strong>Docker Desktop</strong> (for the recommended Open WebUI installation), or Python 3.11 if you prefer installing Open WebUI with pip</p>
</li>
<li><p>Basic comfort with the terminal</p>
</li>
</ul>
<p>That's it. No API keys, no accounts, and no GPU cloud credits.</p>
<h2 id="heading-architecture-diagram"><strong>Architecture Diagram</strong></h2>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/fa07c471-322a-4a39-bbd3-cfc885b9feec.png" alt="Architecture diagram showing Open WebUI connected to Ollama, which runs the MedGemma model locally on the user’s computer. All medical text and image processing happens on the local machine without using cloud services." style="display:block;margin:0 auto" width="2720" height="1808" loading="lazy">

<h2 id="heading-step-1-install-ollama">Step 1: Install Ollama</h2>
<p>Ollama is a lightweight runtime that handles downloading, quantizing, and serving open models through a simple CLI and a local REST API.</p>
<p><strong>On macOS:</strong></p>
<p>Download the app from <a href="https://ollama.com/download">ollama.com/download</a> and drag it to Applications, or install via Homebrew:</p>
<pre><code class="language-shell">brew install ollama
</code></pre>
<p><strong>On Linux:</strong></p>
<pre><code class="language-shell">curl -fsSL https://ollama.com/install.sh | sh
</code></pre>
<p><strong>On Windows:</strong></p>
<p>Download the native Windows installer from <a href="https://ollama.com/download">ollama.com/download</a> and run it. (Ollama now supports Windows natively, no WSL required.)</p>
<p>Once installed, verify it works:</p>
<pre><code class="language-shell">ollama --version
</code></pre>
<p>You should see a version number printed. Ollama also starts a background service that listens on <code>http://localhost:11434</code>. This is the API that Open WebUI will talk to later. You can confirm the server is up with:</p>
<pre><code class="language-shell">curl http://localhost:11434
</code></pre>
<p>which should return <code>Ollama is running</code>.</p>
<h2 id="heading-step-2-pull-medgemma">Step 2: Pull MedGemma</h2>
<p>MedGemma is available directly in the official Ollama model library, so downloading it is one command:</p>
<pre><code class="language-shell">ollama pull medgemma
</code></pre>
<p>This pulls the default 4B multimodal variant, about a 3.3 GB download.</p>
<p>If you want to be explicit about the size (useful when you later experiment with the 27B model):</p>
<pre><code class="language-shell">ollama pull medgemma:4b     # 3.3 GB — multimodal, runs on most laptops
ollama pull medgemma:27b    # 17 GB — multimodal, needs serious hardware
</code></pre>
<p>When the download finishes, confirm the model is installed:</p>
<pre><code class="language-shell">ollama list
</code></pre>
<p>You should see <code>medgemma</code> in the output along with its size.</p>
<h2 id="heading-step-3-test-medgemma-from-the-terminal">Step 3: Test MedGemma from the Terminal</h2>
<p>Before adding a UI, let's make sure the model actually works. Start an interactive session:</p>
<pre><code class="language-shell">ollama run medgemma
</code></pre>
<p>You'll get a <code>&gt;&gt;&gt;</code> prompt. Try a medical question:</p>
<pre><code class="language-plaintext">&gt;&gt;&gt; What are the classic radiographic signs of pneumonia on a chest X-ray?
</code></pre>
<p>MedGemma should respond with a structured answer covering findings like consolidation, air bronchograms, and silhouette signs — the kind of answer that shows its radiology training.</p>
<p>Try one more to see the clinical reasoning:</p>
<pre><code class="language-plaintext">&gt;&gt;&gt; Explain the difference between Type 1 and Type 2 diabetes to a first-year medical student.
</code></pre>
<p>A few useful commands inside the session:</p>
<ul>
<li><p><code>/bye</code> — exit the session</p>
</li>
<li><p><code>/clear</code> — clear the conversation context</p>
</li>
<li><p><code>/show info</code> — display model details (parameters, quantization, context length)</p>
</li>
</ul>
<p>You can also test image input directly from the terminal by passing a file path directly in the prompt:</p>
<pre><code class="language-plaintext">&gt;&gt;&gt; Describe the key findings in this image. ./chest_xray_sample.png
</code></pre>
<p>While this works, uploading images through Open WebUI is much more convenient.</p>
<h2 id="heading-step-4-install-open-webui">Step 4: Install Open WebUI</h2>
<p>Open WebUI gives you a clean, ChatGPT-style interface on top of Ollama: conversation history, model switching, image uploads, and multi-user support, all self-hosted.</p>
<h3 id="heading-option-a-docker-recommended">Option A: Docker (recommended)</h3>
<p>Start by installing <a href="https://www.docker.com/get-started">Docker</a>.</p>
<p>Make sure Docker Desktop is running, then launch Open WebUI with:</p>
<pre><code class="language-shell">docker run -d -p 3000:8080 \
  --add-host=host.docker.internal:host-gateway \
  -v open-webui:/app/backend/data \
  --name open-webui \
  --restart always \
  ghcr.io/open-webui/open-webui:main
</code></pre>
<p>Let's break down what this command does:</p>
<ul>
<li><p><code>-d</code> runs the container in the background</p>
</li>
<li><p><code>-p 3000:8080</code> maps port 3000 on your machine to the WebUI's internal port 8080</p>
</li>
<li><p><code>--add-host=host.docker.internal:host-gateway</code> lets the container reach the Ollama server running on your host machine</p>
</li>
<li><p><code>-v open-webui:/app/backend/data</code> creates a Docker volume so your chats and settings survive container restarts</p>
</li>
<li><p><code>--restart always</code> brings the UI back up automatically after reboots</p>
</li>
</ul>
<h3 id="heading-option-b-pip-no-docker">Option B: pip (no Docker)</h3>
<p>If you'd rather skip Docker, you can instead install Open WebUI as a Python package (Python 3.11 is the supported version):</p>
<pre><code class="language-shell">pip install open-webui
open-webui serve
</code></pre>
<p>This starts the interface at <code>http://localhost:8080</code> instead of port 3000.</p>
<h2 id="heading-step-5-connect-open-webui-to-ollama">Step 5: Connect Open WebUI to Ollama</h2>
<p>Open your browser and go to <code>http://localhost:3000</code> (or <code>:8080</code> if you used pip).</p>
<p>On first launch, you'll be asked to create an admin account. This account is stored <strong>locally on your machine</strong> (it's not a cloud signup).</p>
<p>In most setups, Open WebUI auto-detects Ollama at <a href="http://localhost:11434"><code>http://localhost:11434</code></a> and you're done.</p>
<p>If your models don't appear, wire up the connection manually:</p>
<ol>
<li><p>Click your profile icon and go to <strong>Admin Panel</strong> then <strong>Settings</strong> then <strong>Connections</strong>.</p>
</li>
<li><p>Under <strong>Ollama API</strong>, set the URL:</p>
<ul>
<li><p>Docker install: <code>http://host.docker.internal:11434</code></p>
</li>
<li><p>pip install: <code>http://localhost:11434</code></p>
</li>
</ul>
</li>
<li><p>Click the refresh icon to verify the connection, then save.</p>
</li>
</ol>
<p>Head back to the main chat screen, and <code>medgemma</code> should now appear in the model dropdown at the top.</p>
<p>You can check the troubleshooting section below if you face any errors.</p>
<h2 id="heading-step-6-start-chatting-with-medgemma">Step 6: Start Chatting with MedGemma</h2>
<p>Select <strong>medgemma</strong> from the model selector and start a conversation. A good first test might look like this:</p>
<pre><code class="language-plaintext">Summarize this radiology report in plain language a patient could understand:

"Impression: Mild cardiomegaly. Small right pleural effusion.
No focal consolidation. Degenerative changes of the thoracic spine."
</code></pre>
<p>You should get a clear, patient-friendly explanation of each finding. This "clinical language to plain language" translation is one of MedGemma's genuine strengths.</p>
<p>There are a few Open WebUI features worth knowing about:</p>
<ul>
<li><p><strong>System prompts:</strong> Click the model name and set a system prompt like <em>"You are a medical education assistant. Always explain your reasoning and cite the relevant physiology."</em> This shapes every response in the conversation.</p>
</li>
<li><p><strong>Conversation history:</strong> Every chat is saved locally and searchable from the sidebar.</p>
</li>
<li><p><strong>Multiple models:</strong> You can add <code>llama3.2</code>, <code>gemma3</code>, or any other Ollama model and compare their answers to the same medical question side by side. This is a great way to <em>see</em> the difference domain training makes.</p>
</li>
</ul>
<h2 id="heading-step-7-upload-medical-images">Step 7: Upload Medical Images</h2>
<p>This is where MedGemma really separates itself from general-purpose models. Because its vision encoder was pre-trained on medical imaging, it can meaningfully describe radiographs, skin lesions, fundus photos, and histopathology patches.</p>
<p>To try it:</p>
<ol>
<li><p>Start a new chat with <code>medgemma</code> selected.</p>
</li>
<li><p>Click the <strong>+</strong> (or image) icon in the message box, or simply drag and drop an image file.</p>
</li>
<li><p>Add a prompt alongside the image and hit send.</p>
</li>
</ol>
<p>For sample images you can test with (without touching any real patient data), try public teaching datasets like the NIH ChestX-ray14 dataset, MedPix, or Radiopaedia's teaching cases.</p>
<p>Example workflow with a chest X-ray:</p>
<pre><code class="language-plaintext">[Upload: chest_xray.png]

You are an expert radiology assistant. Describe this chest X-ray
systematically: technical quality, lungs, heart, mediastinum, bones,
and soft tissues. Then summarize the key findings.
</code></pre>
<p>MedGemma will typically walk through the image in the systematic order you asked for, which mirrors how radiologists are trained to read films.</p>
<p><strong>Two important caveats:</strong></p>
<ul>
<li><p>Ollama and Open WebUI work with standard image formats (PNG, JPEG). Clinical DICOM files need to be converted to PNG/JPEG first — a one-liner with Python libraries like <code>pydicom</code> + <code>Pillow</code>.</p>
</li>
<li><p>Never upload images containing patient-identifying information (names, MRNs, dates burned into the image) unless the data has been properly de-identified. Even on a local machine, good data hygiene is a habit worth building.</p>
</li>
</ul>
<h2 id="heading-example-prompts-to-try">Example Prompts to Try</h2>
<p>Here are prompts that showcase different capabilities. Use them as starting points:</p>
<p>Medical education:</p>
<pre><code class="language-plaintext">Create a comparison table of ACE inhibitors vs ARBs: mechanism, common examples, key side effects, and contraindications.
</code></pre>
<p>Clinical documentation:</p>
<pre><code class="language-plaintext">Convert these shorthand clinic notes into a structured SOAP note:"45F, 3d cough + fever 101F, no SOB, lungs clear, likely viral URI, supportive care, return if worse"
</code></pre>
<p>Report translation for patients:</p>
<pre><code class="language-plaintext">Explain this MRI impression to a worried patient in a reassuring but honest tone: "Small disc protrusion at L4-L5 without significant canal stenosis or nerve root compression."
</code></pre>
<p>Image analysis (with an uploaded dermatology photo):</p>
<pre><code class="language-plaintext">Describe this skin lesion using the ABCDE criteria
(Asymmetry, Border, Color, Diameter, Evolution cannot be assessed from a single image — note that explicitly).
</code></pre>
<p>Differential reasoning:</p>
<pre><code class="language-plaintext">A 60-year-old presents with sudden painless vision loss in one eye. List the top 5 differential diagnoses and the key distinguishing feature of each.
</code></pre>
<p>Notice a pattern: the best results come from prompts that give MedGemma a <strong>role</strong>, a <strong>structure</strong> to follow, and <strong>explicit constraints</strong>. That's true of all LLMs, but it matters even more in a domain where precision counts.</p>
<h2 id="heading-running-larger-models">Running Larger Models</h2>
<p>The 4B model is impressive for its size, but the 27B variant is noticeably stronger at complex clinical reasoning, longer differential diagnoses, and nuanced report interpretation.</p>
<p>The trade-off is hardware:</p>
<table>
<thead>
<tr>
<th>Model</th>
<th>Download</th>
<th>Realistic RAM/VRAM needed</th>
<th>Best for</th>
</tr>
</thead>
<tbody><tr>
<td><code>medgemma:4b</code></td>
<td>3.3 GB</td>
<td>8 GB+ RAM</td>
<td>Laptops, quick iteration, image Q&amp;A</td>
</tr>
<tr>
<td><code>medgemma:27b</code></td>
<td>17 GB</td>
<td>32 GB RAM or 24 GB VRAM</td>
<td>Deep reasoning, complex cases</td>
</tr>
</tbody></table>
<p>To try the 27B model:</p>
<pre><code class="language-shell">ollama pull medgemma:27b
ollama run medgemma:27b
</code></pre>
<p>Practical tips for larger models:</p>
<ul>
<li><p><strong>Watch your memory:</strong> Run <code>ollama ps</code> to see how much RAM/VRAM a loaded model is using and whether it's running on GPU, CPU, or split across both. A model that spills from GPU to CPU gets dramatically slower.</p>
</li>
<li><p><strong>On Apple Silicon</strong>, a 32 GB M-series Mac runs the 27B model comfortably.</p>
</li>
<li><p><strong>Free memory between models:</strong> Ollama keeps models loaded for a few minutes after use. Unload immediately with <code>ollama stop medgemma:27b</code> if you need the RAM back.</p>
</li>
<li><p><strong>Sanity-check the speed trade-off:</strong> If the 27B model generates at 2–3 tokens per second on your machine, the 4B model at 30+ tokens/second may be the better.</p>
</li>
</ul>
<p>You can keep both installed and switch between them in the Open WebUI dropdown — 4B for fast iteration, 27B when you need the deeper reasoning.</p>
<h2 id="heading-troubleshooting-guide">Troubleshooting Guide</h2>
<h3 id="heading-error-registryollamaailibrarymedgemmalatest-does-not-support-tools">Error: <code>registry.ollama.ai/library/medgemma:latest does not support tools</code></h3>
<p>This is the most common MedGemma-specific error, and it means Open WebUI is sending native tool/function definitions with your request. MedGemma (like base Gemma 3) doesn't support Ollama's tools API, so the request is rejected before the model even sees your message.</p>
<p>Hunt down whatever is attaching tools, in this order:</p>
<ol>
<li><p><strong>Model capabilities (most likely culprit):</strong> Go to the Admin Panel, then Settings, then Models, then medgemma, then uncheck <code>Builtin Tools</code>, <code>Web Search</code>, <code>Code Interpreter</code>, and <code>Terminal</code> under Capabilities, and make sure every item in the Builtin Tools checklist is unticked. Keep <code>Vision</code>, <code>File Upload</code>, and <code>File Context</code> checked. Newer Open WebUI versions enable builtin tools by default, so a fresh install will hit this immediately.</p>
</li>
<li><p><strong>Task model:</strong> Go to Admin Panel, then Settings, then Interface, and make sure neither the local nor external Task Model is set to medgemma. Background jobs like title and follow-up generation use tool calls — route them to <code>llama3.2</code> or similar.</p>
</li>
<li><p><strong>Function Calling mode:</strong> Set to <strong>Default</strong> (not Native) in the model's Advanced Params <em>and</em> in your user Settings, General, Advanced Parameters.</p>
</li>
<li><p><strong>Global functions/filters:</strong> Go to Admin Panel, then Functions, and disable the Global toggle on any active function, since global functions attach to every model.</p>
</li>
<li><p><strong>Per-chat toggles:</strong> In the message box, make sure web search and code interpreter toggles are off, and no Tools are attached via the + menu.</p>
</li>
</ol>
<p>Then start a <strong>new chat</strong> (old chats can carry stale settings) and test. To confirm the model itself is fine, run <code>ollama run medgemma "hello"</code> in your terminal. If that works, the issue is purely Open WebUI configuration.</p>
<h3 id="heading-open-webui-shows-no-models-in-the-dropdown">Open WebUI shows no models in the dropdown</h3>
<p>The container can't reach Ollama. Check that:</p>
<ul>
<li><p>Ollama is actually running: <code>curl</code> <code>http://localhost:11434</code> should return <code>Ollama is running</code>.</p>
</li>
<li><p>The connection URL in Admin Panel, Settings, Connections is <code>http://host.docker.internal:11434</code> (Docker) — <code>localhost</code> won't work from inside a container because it refers to the container itself.</p>
</li>
<li><p>On Linux, if <code>host.docker.internal</code> doesn't resolve, add <code>--network=host</code> to your <code>docker run</code> command instead and use <code>http://localhost:11434</code>.</p>
</li>
</ul>
<h3 id="heading-ollama-pull-medgemma-says-model-not-found"><code>ollama pull medgemma</code> says model not found</h3>
<p>Update Ollama, as MedGemma requires a recent version. Re-run the installer or, on macOS, click the menu bar icon and then Update. Then retry the pull.</p>
<h3 id="heading-responses-are-extremely-slow">Responses are extremely slow</h3>
<ul>
<li><p>Check <code>ollama ps</code> — if the model shows a large CPU percentage, it doesn't fit in your GPU/unified memory. Switch to the 4B model.</p>
</li>
<li><p>Close memory-hungry apps (browsers with 40 tabs are the usual suspect).</p>
</li>
<li><p>On first message, models take several seconds to load into memory, subsequent messages are much faster.</p>
</li>
</ul>
<h3 id="heading-image-upload-doesnt-work-or-the-model-ignores-the-image">Image upload doesn't work or the model ignores the image</h3>
<ul>
<li><p>Make sure you selected <code>medgemma</code> (multimodal) and not a text-only model in the dropdown.</p>
</li>
<li><p>Use PNG or JPEG. DICOM files must be converted first.</p>
</li>
<li><p>Very high-resolution images can cause issues — resize to something reasonable (e.g., 1024px on the long edge) before uploading.</p>
</li>
</ul>
<h3 id="heading-port-3000-is-already-in-use">Port 3000 is already in use</h3>
<p>Map a different host port: change <code>-p 3000:8080</code> to <code>-p 3001:8080</code> and access the UI at <code>http://localhost:3001</code>.</p>
<h3 id="heading-out-of-memory-errors-when-loading-the-27b-model">"Out of memory" errors when loading the 27B model</h3>
<p>Your machine doesn't have enough free RAM/VRAM. Stick with <code>medgemma:4b</code>, or free memory and try again. There is no shame in the 4B model — it punches well above its weight.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you built a complete, private healthcare AI assistant from scratch — and it took three tools and a handful of terminal commands.</p>
<p>Let's recap what you accomplished:</p>
<ul>
<li><p>Installed Ollama and pulled MedGemma, a medically-tuned multimodal model, onto your own machine</p>
</li>
<li><p>Verified the model from the terminal, then put a full chat interface on top of it with Open WebUI</p>
</li>
<li><p>Configured the model's capabilities correctly so tool-calling features don't break a model that doesn't support them</p>
</li>
<li><p>Chatted with a model that understands radiology reports, clinical terminology, and medical images — and uploaded images for analysis</p>
</li>
<li><p>Learned how to scale up to the 27B model and how to diagnose the most common errors along the way.</p>
</li>
</ul>
<p>You now have a fully private AI assistant running entirely on your own machine. From here, you can extend it with retrieval-augmented generation (RAG), integrate it with medical imaging pipelines, or connect it to de-identified clinical datasets to build more advanced healthcare AI applications.</p>
<p>Happy building!</p>
<p><strong>Further reading:</strong></p>
<ul>
<li><p><a href="https://ollama.com/library/medgemma">MedGemma on the Ollama library</a></p>
</li>
<li><p><a href="https://developers.google.com/health-ai-developer-foundations/medgemma">MedGemma model documentation (Google Health AI Developer Foundations)</a></p>
</li>
<li><p><a href="https://github.com/ollama/ollama">Ollama documentation</a></p>
</li>
<li><p><a href="https://docs.openwebui.com/">Open WebUI documentation</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Product Experimentation: Stop Early Without P-Hacking Using mSPRT and Sequential Testing in Python ]]>
                </title>
                <description>
                    <![CDATA[ Your AI product experiment reaches statistical significance on day 14 of a planned 30-day run, measuring a causal inference question: did the LLM-based feature genuinely improve outcomes? Every produc ]]>
                </description>
                <link>https://www.freecodecamp.org/news/stop-early-without-p-hacking-using-msprt-and-sequential-testing-in-python/</link>
                <guid isPermaLink="false">6a46977d0ad5b1f1520283a9</guid>
                
                    <category>
                        <![CDATA[ product experimentation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ experimentation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ causal inference ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ sequential-testing ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Rudrendu Paul ]]>
                </dc:creator>
                <pubDate>Thu, 02 Jul 2026 16:53:17 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/8df7e6a8-923c-4cbf-9e5b-56a68f5ad96e.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Your AI product experiment reaches statistical significance on day 14 of a planned 30-day run, measuring a causal inference question: did the LLM-based feature genuinely improve outcomes? Every product manager in the room wants to ship. Your statistician says to wait the full 30 days, or the p-value is invalid.</p>
<p>You wait. On day 30, the effect is still there. But you spent 16 days running a feature you already knew worked with 95% confidence, delaying the next experiment and burning opportunity cost.</p>
<p>The statistician is technically right, if you're running a classical fixed-sample test. The p-value in a standard t-test is valid only when you commit to a sample size in advance and look at the results exactly once. Look earlier and stop when p &lt; 0.05, and your false positive rate climbs toward 30%.</p>
<p>The p-value was designed for a single pre-committed look: it was built for a static experiment with a fixed endpoint. Applying it to a live stream where you can check at any point requires a different mathematical object entirely.</p>
<p>Sequential testing was designed for exactly this situation. The mixture Sequential Probability Ratio Test (mSPRT) (<a href="https://arxiv.org/abs/1512.04922">Johari et al.</a>) produces always-valid inference using a mathematical object called an e-value: you can check results every day, stop when the evidence is strong enough, and your false positive rate stays at 5%.</p>
<p>Netflix has documented the production use of always-valid sequential testing frameworks (<a href="https://netflixtechblog.com/sequential-a-b-testing-keeps-the-world-streaming-netflix-part-1-continuous-data-cba6c7ed49df">Lindon et al.</a>), and the underlying ideas trace back to Wald's 1945 work on sequential analysis and Ville's 1939 inequality.</p>
<p>This tutorial makes the connection explicit. You'll simulate the peeking problem to see the inflated error rate directly, implement a working mSPRT from scratch in Python, apply it to the shared synthetic LLM product dataset, and understand exactly when sequential testing fails.</p>
<p><strong>Companion notebook:</strong> every code block in this article runs end-to-end in <a href="https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/07_sequential_msprt/"><code>msprt_demo.ipynb</code></a> in the companion repo.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-optional-stopping-breaks-classical-tests">Why Optional Stopping Breaks Classical Tests</a></p>
</li>
<li><p><a href="#heading-what-a-sequential-test-actually-does">What a Sequential Test Actually Does</a></p>
</li>
<li><p><a href="#heading-identification-assumptions">Identification Assumptions</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-setting-up-the-working-example">Setting Up the Working Example</a></p>
<ul>
<li><p><a href="#heading-step-1-simulate-the-peeking-problem">Step 1: Simulate the peeking problem</a></p>
</li>
<li><p><a href="#heading-step-2-implement-the-msprt-e-value">Step 2: Implement the mSPRT e-value</a></p>
</li>
<li><p><a href="#heading-step-3-apply-msprt-to-the-real-dataset">Step 3: Apply mSPRT to the real dataset</a></p>
</li>
<li><p><a href="#heading-step-4-compare-power-against-a-fixed-sample-test">Step 4: Compare power against a fixed-sample test</a></p>
</li>
<li><p><a href="#heading-validate-against-ground-truth">Validate against ground truth</a></p>
</li>
<li><p><a href="#heading-step-5-bootstrap-confidence-intervals">Step 5: Bootstrap confidence intervals</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-when-msprt-fails">When mSPRT Fails</a></p>
</li>
<li><p><a href="#heading-what-to-do-next">What to Do Next</a></p>
</li>
</ul>
<h2 id="heading-why-optional-stopping-breaks-classical-tests">Why Optional Stopping Breaks Classical Tests</h2>
<p>Peeking at running p-values inflates your false positive rate toward 30%. That's the number that should give you pause, and you'll reproduce it in Step 1 below.</p>
<p>The p-value in a classical hypothesis test answers a specific question: given the null is true, what's the probability of seeing data this extreme when you run the experiment exactly as planned with the sample size you committed to upfront?</p>
<p>The "exactly as planned" clause is the problem. When you check results on day 5, day 10, day 14, and stop on day 14 because p &lt; 0.05, you haven't run the experiment you planned. You've run 14 different experiments, looked at the results of each, and stopped at the one that passed your threshold. The p-value formula doesn't know that.</p>
<p>Here's the intuition. Under the null hypothesis (no effect), your p-value bounces around randomly between 0 and 1. It doesn't stay parked at 0.5. Over a 30-day run, a null experiment will dip below 0.05 at some point with high probability. If you're watching every day and ready to stop the moment you see p &lt; 0.05, you'll almost always catch one of those dips. You'll declare a winner. But the effect isn't real.</p>
<p>Looking less often just delays the same problem. You need to look often: products move fast, and running an experiment 16 days longer than necessary costs real money, delays launches, and burns opportunity cost. You need a test statistic that stays valid regardless of when you stop.</p>
<h2 id="heading-what-a-sequential-test-actually-does">What a Sequential Test Actually Does</h2>
<p>Sequential tests are designed for optional stopping by replacing the p-value with an alternative statistic called an e-value.</p>
<p>Unlike a p-value, an e-value is nonnegative, and the process formed by e-values over time satisfies a supermartingale property under the null: conditional on the history, the expected next e-value is at most the current one.</p>
<p>This path-level supermartingale condition is what makes optional stopping safe. Having a marginal mean below 1 at each step is necessary but not sufficient: the supermartingale condition is strictly stronger, holding the bound uniformly across all stopping times.</p>
<p>Here's why. If the e-value process is a nonneg supermartingale with E[e_t] ≤ 1 under H0, then a classical result called Ville's inequality gives: the probability that the running maximum of the process ever exceeds 1/α is at most α. With α = 0.05 and stopping threshold 1/α = 20, the probability that a null e-value process ever reaches 20 is at most 5%.</p>
<p>That Type I error bound holds no matter when you stop or how many times you check. The guarantee is time-uniform: it covers all possible stopping times simultaneously.</p>
<p>A classical p-value's guarantee applies only at the pre-committed sample size. Check repeatedly and the bound dissolves. There is no time-uniform analog.</p>
<p>The mSPRT computes the e-value as a Bayes factor: the ratio of the likelihood of the observed data under the alternative to that under the null.</p>
<p>The "mixture" part means you don't specify a single effect size under H1. You average the likelihood ratio over a prior distribution on effect sizes.</p>
<p>For Bernoulli outcomes (did the task complete: yes or no), placing a Beta(1,1) prior on each arm's completion rate makes the Bayes factor tractable in closed form using the log-beta function. The math is less intimidating than it looks: the entire computation reduces to four calls to <code>betaln</code>, as Step 2 shows.</p>
<p>The practical consequence is concrete: accumulate data, compute the running e-value each day, and stop when it crosses 20. When it remains below 20 across your maximum sample size, you fail to reject the null. Check every day, every hour, or every minute. The Type I error rate holds at 5%.</p>
<h2 id="heading-identification-assumptions">Identification Assumptions</h2>
<p>mSPRT's always-valid guarantee rests on four conditions. Each can break, and the failure modes section below maps each failure mode to the condition it violates.</p>
<ol>
<li><p><strong>Nonneg supermartingale property under H0.</strong> The e-value process must satisfy E[e_{t+1} | e_1, ..., e_t] ≤ e_t under H0. For the Beta-Binomial Bayes factor used here, this holds as long as the prior is proper (Beta(1,1) qualifies) and the observations are i.i.d. within each arm.</p>
</li>
<li><p><strong>Stationarity.</strong> The data-generating process must be stationary across the experiment window. If the underlying completion rate shifts mid-experiment due to an unrelated change (a model update, a cohort shift from a marketing campaign, or a day-of-week effect), the e-value picks up noise that your experiment can't separate from the treatment effect.</p>
</li>
<li><p><strong>Independent observations within each arm.</strong> Each user's outcome must be independent of other users'. Network effects, shared workspaces, or spillover from recommendation systems can violate this.</p>
</li>
<li><p><strong>Prior specification.</strong> The Beta(1,1) prior is a modeling assumption. The mSPRT's power depends on whether the prior places reasonable mass on the true effect size. A badly misspecified prior won't break the Type I error guarantee, but it can make the e-value grow so slowly that you exhaust your sample budget without crossing the threshold.</p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Python 3.11+</p>
</li>
<li><p>pandas 2.x (<code>pip install pandas</code>)</p>
</li>
<li><p>numpy 1.26+ (<code>pip install numpy</code>)</p>
</li>
<li><p>scipy 1.12+ (<code>pip install scipy</code>)</p>
</li>
<li><p>matplotlib 3.8+ (<code>pip install matplotlib</code>)</p>
</li>
</ul>
<p>Clone the companion repo to get the synthetic dataset:</p>
<pre><code class="language-bash">git clone https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm.git
cd product-experimentation-causal-inference-genai-llm
python data/generate_data.py --seed 42 --n-users 50000 --out data/synthetic_llm_logs.csv
</code></pre>
<p><strong>Here's what's happening:</strong> this clones the repo that contains all 13 companion notebooks for this series, generates the shared 50,000-user synthetic dataset, and saves it to <code>data/synthetic_llm_logs.csv</code>. Every article in the series runs against this same CSV so the methods are directly comparable. The data generator bakes in a +5 percentage-point causal effect on task completion for wave 1 users.</p>
<h2 id="heading-setting-up-the-working-example">Setting Up the Working Example</h2>
<p>The synthetic dataset simulates a SaaS AI assistant product with 50,000 users. The <code>task_completed</code> column records whether the AI successfully completed the user's task (1) or not (0). The <code>wave</code> column assigns users to groups: wave 1 receives the new AI feature, wave 2 is the holdout control.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69cc82ffe4688e4edd796adb/422306d0-efbc-44d6-a0a1-f8415f2d5e6d.png" alt="422306d0-efbc-44d6-a0a1-f8415f2d5e6d" style="display:block;margin:0 auto" width="1486" height="824" loading="lazy">

<p><em>Figure 1: conceptual e-value trajectories. The blue path (real effect) rises and crosses the stopping threshold at the green dashed line. The purple path (weaker effect) grows but doesn't cross in 30 days. The grey path (null) meanders near 1 throughout. The red dashed line is the stopping boundary at 1/α = 20. Compare this to Figure 2 below, which shows the actual e-value trajectory on the real dataset.</em></p>
<pre><code class="language-python">import pandas as pd
import numpy as np

df = pd.read_csv("data/synthetic_llm_logs.csv")

treated = df[df["wave"] == 1]["task_completed"].values
control = df[df["wave"] == 2]["task_completed"].values

print(f"Treated: n={len(treated):,}, mean={treated.mean():.4f}")
print(f"Control: n={len(control):,}, mean={control.mean():.4f}")
print(f"Observed lift: {treated.mean() - control.mean():.4f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Treated: n=24,937, mean=0.6202
Control: n=25,063, mean=0.5718
Observed lift: 0.0485
</code></pre>
<p><strong>Here's what's happening:</strong> you load the 50,000-row dataset and split by wave. Wave 1 has 24,937 treated users with a 62.0% task completion rate. Wave 2 has 25,063 control users <em>with a 57.2% task completion rate</em>. The observed 4.85 percentage-point lift is close to the ground-truth 5pp baked into the data generator, with the small gap due to sampling noise. These arrays feed the sequential test one observation at a time, as outlined in the steps below.</p>
<h2 id="heading-step-1-simulate-the-peeking-problem">Step 1: Simulate the Peeking Problem</h2>
<p>The peeking problem is real and measurable: 30 days of daily monitoring inflates your false positive rate from 4.2% to 30.2%, confirmed by the simulation below.</p>
<p>This simulation runs 1,000 null experiments (in which the treatment has zero effect) and checks every day whether the running p-value has dropped below 0.05. The scenario uses 60 users per arm per day across a 30-day experiment: 1,800 total observations per arm, a realistic scale for a mid-sized SaaS product.</p>
<pre><code class="language-python">from scipy import stats
import numpy as np

np.random.seed(42)

N_SIMS = 1000
N_DAYS = 30
USERS_PER_ARM_PER_DAY = 60
NULL_RATE = 0.60

false_positives_peeking = 0
false_positives_single_look = 0

for _ in range(N_SIMS):
    control_outcomes = []
    treated_outcomes = []
    stopped_early = False

    for day in range(N_DAYS):
        control_outcomes.extend(np.random.binomial(1, NULL_RATE, USERS_PER_ARM_PER_DAY))
        treated_outcomes.extend(np.random.binomial(1, NULL_RATE, USERS_PER_ARM_PER_DAY))

        # The peeking problem: checking the test every single day
        if len(control_outcomes) &gt;= 10:
            _, p = stats.ttest_ind(treated_outcomes, control_outcomes)
            if p &lt; 0.05 and not stopped_early:
                false_positives_peeking += 1
                stopped_early = True

    # The fixed-sample approach: checking only once at the very end
    _, p_final = stats.ttest_ind(treated_outcomes, control_outcomes)
    if p_final &lt; 0.05:
        false_positives_single_look += 1

print(f"False positive rate (peeking daily):  {false_positives_peeking / N_SIMS:.1%}")
print(f"False positive rate (single look):    {false_positives_single_look / N_SIMS:.1%}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">False positive rate (peeking daily):  30.2%
False positive rate (single look):    4.2%
</code></pre>
<p><strong>Here's what's happening:</strong> each simulation generates null data, with both arms drawn from the same 60% completion rate, so any detected effect is pure noise. The inner loop adds 60 observations per arm per day and runs a t-test on the accumulated data for that day.</p>
<p>When the p-value falls below 0.05 for the first time, the simulation flags a false positive and stops (mimicking a team that ships when it detects significance).</p>
<p>The single-look check at day 30 is the honest fixed-sample test. One look gives 4.2% false positives, close to nominal. Daily peeking reaches 30.2%, meaning more than one in four "significant" experiments is detecting noise.</p>
<h2 id="heading-step-2-implement-the-msprt-e-value">Step 2: Implement the mSPRT e-value</h2>
<p>The mSPRT computes a Bayes factor at each time step: how much more likely are the observed data under a mixture of alternatives than under the null? For binary outcomes with a Beta(1,1) prior on each arm's completion rate, the running Bayes factor has a closed form using the log-beta function.</p>
<pre><code class="language-python">from scipy.special import betaln

def compute_evalue_running(outcomes_treated, outcomes_control,
                           alpha_prior=1.0, beta_prior=1.0):
    """
    Compute the running mSPRT e-value for two Bernoulli arms.

    Parameters
    ----------
    outcomes_treated : array-like of 0/1
    outcomes_control : array-like of 0/1
    alpha_prior, beta_prior : Beta prior hyperparameters (default: uniform)

    Returns
    -------
    e_values : np.ndarray of shape (n,), one e-value per observation
    """
    outcomes_treated = np.asarray(outcomes_treated, dtype=float)
    outcomes_control = np.asarray(outcomes_control, dtype=float)
    n = min(len(outcomes_treated), len(outcomes_control))

    cum_t = np.cumsum(outcomes_treated[:n])
    cum_c = np.cumsum(outcomes_control[:n])
    t_arr = np.arange(1, n + 1, dtype=float)

    # Alternative hypothesis: each arm has its own independent Beta prior on completion rate
    log_ml_t = (betaln(alpha_prior + cum_t, beta_prior + t_arr - cum_t)
                - betaln(alpha_prior, beta_prior))
    log_ml_c = (betaln(alpha_prior + cum_c, beta_prior + t_arr - cum_c)
                - betaln(alpha_prior, beta_prior))

    # Null hypothesis: both arms share a single pooled Beta prior on the common rate
    pooled_successes = cum_t + cum_c
    pooled_n = 2 * t_arr
    log_ml_h0 = (betaln(alpha_prior + pooled_successes,
                        beta_prior + pooled_n - pooled_successes)
                 - betaln(alpha_prior, beta_prior))

    # Log Bayes factor is the difference in log marginal likelihoods
    log_bf = log_ml_t + log_ml_c - log_ml_h0

    return np.exp(log_bf)
</code></pre>
<p><strong>Here's what's happening:</strong> the function takes two arrays of 0/1 outcomes arriving in temporal order. For each time step t, it computes the cumulative number of successes and trials for each arm.</p>
<p><code>betaln</code> gives the log of the beta function, which is the normalizing constant for the Beta-Binomial marginal likelihood. H1 integrates over independent Beta priors on each arm's rate;.H0 integrates over a single shared-rate prior.</p>
<p>The log Bayes factor is the difference. Exponentiating gives the e-value. When the treatment has a real effect, the e-value grows over time. With no effect, it bounces near 1 and is a non-negative supermartingale under H0.</p>
<p>A quick sanity check on null data confirms the expected behavior:</p>
<pre><code class="language-python">np.random.seed(0)
null_t = np.random.binomial(1, 0.60, 500)
null_c = np.random.binomial(1, 0.60, 500)
ev_null = compute_evalue_running(null_t, null_c)
print(f"E-value at end under null (should be near 1): {ev_null[-1]:.3f}")
print(f"Max e-value under null: {ev_null.max():.3f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">E-value at end under null (should be near 1): 0.078
Max e-value under null: 2.188
</code></pre>
<p><strong>Here's what's happening:</strong> under the null, the final e-value ends near 1 (0.078 here, due to sampling variation), and the maximum over 500 observations stays well below the stopping threshold of 20. By Ville's inequality, the probability that a valid null e-value process ever reaches 20 is at most 5%, consistent with a 5% Type I error rate. In this single 500-observation run, the max is 2.188, which is expected behavior.</p>
<h2 id="heading-step-3-apply-msprt-to-the-real-dataset">Step 3: Apply mSPRT to the Real Dataset</h2>
<p>Now apply the test to the synthetic data where a real treatment effect exists. You'll compute the running e-value day by day and find the first day it crosses the stopping threshold.</p>
<pre><code class="language-python">import matplotlib.pyplot as plt

np.random.seed(42)
treated_shuffled = treated.copy()
control_shuffled = control.copy()
np.random.shuffle(treated_shuffled)
np.random.shuffle(control_shuffled)

USERS_PER_ARM_PER_DAY = 60
N_DAYS_RUN = 30
n_per_arm = USERS_PER_ARM_PER_DAY * N_DAYS_RUN  # 1,800

treated_seq = treated_shuffled[:n_per_arm]
control_seq = control_shuffled[:n_per_arm]

e_values = compute_evalue_running(treated_seq, control_seq)

ALPHA = 0.05
THRESHOLD = 1 / ALPHA  # = 20

days = np.arange(1, len(e_values) + 1) / USERS_PER_ARM_PER_DAY
cross_indices = np.where(e_values &gt;= THRESHOLD)[0]
if len(cross_indices) &gt; 0:
    stopping_day = days[cross_indices[0]]
    print(f"mSPRT stopping day: {stopping_day:.1f}")
    print(f"E-value at stopping: {e_values[cross_indices[0]]:.1f}")
else:
    stopping_day = None
    print("mSPRT did not cross threshold in this window")

print(f"Final e-value on day {N_DAYS_RUN}: {e_values[-1]:.2f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">mSPRT stopping day: 25.9
E-value at stopping: 20.9
Final e-value on day 30: 75.64
</code></pre>
<p><strong>Here's what's happening:</strong> you shuffle the treatment and control arrays to simulate random daily arrival of users (real experiments don't deliver users in any particular order), then feed the first 1,800 per arm into <code>compute_evalue_running</code> one observation at a time. The e-value crosses the threshold of 20 on day 25.9, meaning you could have called the experiment 4 days early with a fully valid inference guarantee. By day 30, the e-value has climbed to 75.64, far above the threshold.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69cc82ffe4688e4edd796adb/82ae7b10-c598-4597-80e6-375fa76b209d.png" alt="82ae7b10-c598-4597-80e6-375fa76b209d" style="display:block;margin:0 auto" width="1486" height="947" loading="lazy">

<p><em>Figure 2: actual mSPRT e-value trajectory on the real 50,000-user synthetic dataset (wave 1 treatment vs. wave 2 control). The blue line is the running e-value on a log scale. The red dashed line is the stopping threshold at 1/α = 20.</em></p>
<p><em>The dotted green vertical line marks day 25.9, when the e-value first crosses the threshold. The bottom panel shows cumulative task completion rates per arm converging as data accumulates. Unlike the schematic in Figure 1, these are real data from the shared dataset, with a true 4.85 pp lift.</em></p>
<h2 id="heading-step-4-compare-power-against-a-fixed-sample-test">Step 4: Compare Power Against a Fixed-Sample Test</h2>
<p>The mSPRT carries a real cost. When the effect is active, it lets you stop earlier than the scheduled end time. When the effect is smaller than your prior expects, or when you're working with modest sample sizes, the power penalty is substantial. This simulation quantifies the trade-off honestly.</p>
<pre><code class="language-python">from scipy.stats import ttest_ind

np.random.seed(42)

N_SIMS = 1000
TRUE_EFFECT = 0.05
BASE_RATE = 0.60
N_PER_ARM = 1800          # 30 days x 60 users/arm/day
DAILY_BATCH = 60
THRESHOLD = 20

msprt_stopping_days = []
msprt_detected = 0
ttest_detected = 0

for sim in range(N_SIMS):
    t_obs = np.random.binomial(1, BASE_RATE + TRUE_EFFECT, N_PER_ARM)
    c_obs = np.random.binomial(1, BASE_RATE, N_PER_ARM)

    e_vals = compute_evalue_running(t_obs, c_obs)
    days = np.arange(1, N_PER_ARM + 1) / DAILY_BATCH
    crosses = np.where(e_vals &gt;= THRESHOLD)[0]
    if len(crosses) &gt; 0:
        msprt_detected += 1
        msprt_stopping_days.append(days[crosses[0]])
    else:
        msprt_stopping_days.append(30.0)

    _, p = ttest_ind(t_obs, c_obs)
    if p &lt; 0.05:
        ttest_detected += 1

msprt_power = msprt_detected / N_SIMS
ttest_power = ttest_detected / N_SIMS
median_stop = np.median(msprt_stopping_days)
pct_stopped_early = np.mean(np.array(msprt_stopping_days) &lt; 30.0)

print(f"mSPRT power:               {msprt_power:.1%}")
print(f"Fixed-sample t-test power: {ttest_power:.1%}")
print(f"Median mSPRT stop day:     {median_stop:.1f} / 30")
print(f"Fraction stopping early:   {pct_stopped_early:.1%}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">mSPRT power:               49.3%
Fixed-sample t-test power: 88.7%
Median mSPRT stop day:     30.0 / 30
Fraction stopping early:   49.3%
</code></pre>
<p><strong>Here's what's happening:</strong> you run 1,000 simulations with a true 5pp lift. For mSPRT, the running e-value is computed, and the first crossing of 20 is recorded.</p>
<p>For the fixed-sample test, you look once at the end of day 30. The results show a meaningful power gap: mSPRT detects the effect in 49.3% of experiments, whereas the fixed-sample test detects it in 88.7%. With a 5pp lift and 1,800 observations per arm, the mSPRT requires roughly twice as many observations to match the fixed-sample test's power.</p>
<p>That's the price of the always-valid guarantee. What you gain is the Type I error control when you check daily: a fixed-sample test peeked at daily inflates to 30.2% false positives. mSPRT stays at 5% regardless of when you stop.</p>
<p>The right choice depends on which is more expensive for your team: running experiments longer, or shipping false positives. Most teams underestimate the cost of power until they run this simulation themselves.</p>
<h2 id="heading-validate-against-ground-truth">Validate Against Ground Truth</h2>
<p>The synthetic dataset incorporates a known 5pp lift, so you can check whether mSPRT correctly identifies the effect when given more data beyond the 30-day window.</p>
<pre><code class="language-python">np.random.seed(0)
t_full = treated_shuffled
c_full = control_shuffled[:len(t_full)]

e_full = compute_evalue_running(t_full, c_full)
days_full = np.arange(1, len(e_full) + 1) / USERS_PER_ARM_PER_DAY

cross_full = np.where(e_full &gt;= THRESHOLD)[0]
if len(cross_full) &gt; 0:
    print(f"mSPRT correctly detected the effect.")
    print(f"Could have stopped on day {days_full[cross_full[0]]:.1f}")
    print(f"True effect in data: {treated.mean() - control.mean():.4f}")
    print(f"E-value at stopping point: {e_full[cross_full[0]]:.1f}")
else:
    print("mSPRT did not cross threshold with this data slice.")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">mSPRT correctly detected the effect.
Could have stopped on day 27.1
True effect in data: 0.0485
E-value at stopping point: 22.2
</code></pre>
<p><strong>Here's what's happening:</strong> running mSPRT on the full shuffled arrays (24,937 treated, 25,063 control), the e-value crosses the threshold at day 27.1. The true causal effect in the data, 4.85 pp, is close to the generator's ground truth of 5 pp and is correctly detected.</p>
<p>A fixed-sample test designed for 30 days holds you to day 30 even when the evidence has already accumulated. With 60 users per arm per day, mSPRT would have let you ship on day 27.1, saving almost 3 days on a feature that was always going to ship.</p>
<h2 id="heading-step-5-bootstrap-confidence-intervals">Step 5: Bootstrap Confidence Intervals</h2>
<p>A stopping day tells you when to call the experiment, but it doesn't tell you how large the effect is or how precisely it's estimated. Bootstrap confidence intervals give you both.</p>
<pre><code class="language-python">rng = np.random.default_rng(7)
point_est = treated.mean() - control.mean()

boot_diffs = np.array([
    rng.choice(treated, size=len(treated), replace=True).mean() -
    rng.choice(control, size=len(control), replace=True).mean()
    for _ in range(500)
])

lower = float(np.percentile(boot_diffs, 2.5))
upper = float(np.percentile(boot_diffs, 97.5))

print(f"Point estimate (treated - control): {point_est:.4f} ({point_est*100:.2f}pp)")
print(f"95% bootstrap CI: [{lower:.4f}, {upper:.4f}]  "
      f"([{lower*100:.2f}pp, {upper*100:.2f}pp])")
print(f"Ground-truth 5pp is {'inside' if lower &lt;= 0.05 &lt;= upper else 'outside'} the CI.")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Point estimate (treated - control): 0.0485 (4.85pp)
95% bootstrap CI: [0.0407, 0.0581]  ([4.07pp, 5.81pp])
Ground-truth 5pp is inside the CI.
</code></pre>
<p><strong>Here's what's happening:</strong> you resample the treated and control arrays independently with replacement 500 times, computing the difference in means each time. The 2.5th and 97.5th percentiles of the 500 differences form the confidence interval. The CI runs from 4.07pp to 5.81pp, covering the ground-truth 5pp and excluding zero, confirming the effect is real. The interval is reasonably tight given 25k users per arm, giving you both the "did it work" answer (yes) and the "how much" answer (between 4.07 and 5.81 percentage points) in a single step.</p>
<h2 id="heading-when-msprt-fails">When mSPRT Fails</h2>
<p>Sequential tests still demand experimental rigor. Four situations either break the guarantee or make the method practically useless.</p>
<h3 id="heading-badly-misspecified-prior">Badly Misspecified Prior</h3>
<p>The mSPRT assumes a Beta(1,1) prior on each arm's completion rate, a modeling choice with real consequences. This violates the prior specification assumption when your true effect is far outside the range the prior expects.</p>
<p>A uniform Beta(1,1) prior performs reasonably well for moderate effects in the 3–10 pp range at base rates around 60%. If your true effect is a 0.3pp lift, a realistic outcome for a marginal AI feature change, the e-value grows extremely slowly. You'll exhaust your sample budget before crossing the threshold.</p>
<p>Calibrate the prior against historical A/B test data from your product: fit Beta hyperparameters to the distribution of past effect sizes using maximum likelihood, and verify that the resulting prior puts meaningful mass near your minimum detectable effect.</p>
<h3 id="heading-non-stationary-outcomes">Non-Stationary Outcomes</h3>
<p>The guarantee requires the e-value process to be a non-negative supermartingale under the null, which requires the data-generating process to be stationary. If your AI model updates mid-experiment, if the user population shifts (a marketing campaign brings in a different cohort on day 12), or if there's a day-of-week effect in task difficulty, the e-value absorbs environment noise that your experiment can't separate from the treatment effect.</p>
<p>Diagnose non-stationarity by running your e-value implementation on holdout A/A experiments: if the null e-value process trends upward when it should stay near 1, your environment isn't stationary enough for the method to be reliable.</p>
<h3 id="heading-multiple-metrics-without-multiplicity-correction">Multiple Metrics Without Multiplicity Correction</h3>
<p>mSPRT controls Type I error for a single comparison. The method itself doesn't fail when you test 20 metrics, so each individual e-value remains valid. What fails is your familywise error rate: running mSPRT on 20 metrics simultaneously and stopping when any one crosses 20 inflates the probability of at least one false positive well above 5%.</p>
<p>Apply a Bonferroni correction by raising the threshold to 1/(α/m) = 400 for m=20 metrics at α=0.05, or use a Benjamini-Hochberg procedure on the final e-values when the experiment ends.</p>
<p>The multiplicity problem is identical to the one you'd face with fixed-sample tests. mSPRT doesn't make it worse, and it doesn't solve it either. This is a common misconception worth naming explicitly.</p>
<h3 id="heading-minimum-runtime-is-still-real">Minimum Runtime is Still Real</h3>
<p>Because the always-valid guarantee applies regardless of when you check, it's tempting to start monitoring immediately. Don't. The guarantee holds whenever you check, but low power means the test rarely rejects even when the effect is real.</p>
<p>The Step 4 simulation shows this directly: with 1,800 observations per arm and a 5 pp lift, mSPRT has only 49.3% power. Before starting an mSPRT-monitored experiment, compute the minimum sample size for 80% power at your expected effect size using a standard power calculator, and set that as your floor before you start monitoring. Don't check the e-value until you've reached that floor.</p>
<h2 id="heading-what-to-do-next">What to Do Next</h2>
<p>Apply mSPRT to your primary metric, with a minimum runtime floor set to the sample size required for 80% power at your expected effect size.</p>
<p>Run A/A tests on historical holdout data first: the calibration check costs you nothing and catches non-stationary environments before they corrupt a real experiment. Teams that skip the A/A test discover calibration failures during live experiments. That's an expensive way to learn about non-stationary data.</p>
<p>For the full implementation including bootstrap confidence intervals, see <a href="https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/07_sequential_msprt/"><code>07_sequential_msprt/</code></a> in the companion repo.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Product Experimentation for LLM Platforms: Switchback Designs When User Randomization Breaks Market Equilibrium in Python ]]>
                </title>
                <description>
                    <![CDATA[ Your team ships an intelligent query-routing feature for an LLM SaaS platform. The feature reads each incoming request in real time and decides whether to send it to the fast standard model or the mor ]]>
                </description>
                <link>https://www.freecodecamp.org/news/switchback-experiments-for-ai-platform-features-in-python/</link>
                <guid isPermaLink="false">6a43e83fe6f3ef85737305cb</guid>
                
                    <category>
                        <![CDATA[ product experimentation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ causal inference ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ switchback-experiments ]]>
                    </category>
                
                    <category>
                        <![CDATA[ experimentation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Rudrendu Paul ]]>
                </dc:creator>
                <pubDate>Tue, 30 Jun 2026 16:01:03 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/50802c2c-ef8c-4137-852a-eed1000e67e7.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Your team ships an intelligent query-routing feature for an LLM SaaS platform. The feature reads each incoming request in real time and decides whether to send it to the fast standard model or the more capable premium model. In offline evaluation, it raises task completion rates by six percentage points.</p>
<p>You're ready to test it in production. Then your platform engineer raises a structural problem: you can't randomize at the user level.</p>
<p>This issue is rooted in causal inference and runs deeper than a technical constraint. Every user draws from a centralized pool of premium model capacity. A standard A/B test creates an uneven playing field in this environment. When the routing AI is active for the treatment group, those users consume premium resources first, leaving the control group with degraded availability.</p>
<p>The routing AI does more than alter the treatment group's experience. It fundamentally shifts the resource environment for everyone else. You're not isolating the AI's impact. You're measuring the combined effect of the routing AI and the artificial scarcity your experimental design imposed on the control group. That's a confounded measurement, not a clean experiment.</p>
<p>Switchback experiments are the standard fix for LLM-based platforms and for any shared-resource product where user-level randomization would break the comparison. You stop randomizing users and randomize time slots instead.</p>
<p>The full platform runs with AI routing on for a 30-minute slot, then off for the next 30 minutes. You repeat the cycle, accumulate enough slots, and estimate the average treatment effect from the contrast between AI-on and AI-off slots.</p>
<p>This tutorial walks through the full switchback pipeline in Python: building the time series from session logs, diagnosing carryover contamination, estimating the direct effect with and without carryover adjustment, applying HAC standard errors for time-series data, computing bootstrap confidence intervals, and validating all estimates against a known ground truth.</p>
<p>By the end, you'll know how to run this analysis on your own LLM platform data and how to spot the four conditions that break it.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-user-level-ab-testing-fails-on-shared-llm-infrastructure">Why User-Level A/B Testing Fails on Shared LLM Infrastructure</a></p>
</li>
<li><p><a href="#heading-how-switchback-design-restores-a-clean-comparison">How Switchback Design Restores a Clean Comparison</a></p>
<ul>
<li><p><a href="#heading-identification-assumptions">Identification Assumptions</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-step-1-build-the-switchback-time-series">Step 1: Build the Switchback Time Series</a></p>
</li>
<li><p><a href="#heading-step-2-naive-estimate-ignoring-time-structure">Step 2: Naïve Estimate (Ignoring Time Structure)</a></p>
</li>
<li><p><a href="#heading-step-3-carryover-adjusted-ols-regression">Step 3: Carryover-Adjusted OLS Regression</a></p>
</li>
<li><p><a href="#heading-step-4-hac-standard-errors-for-time-series-data">Step 4: HAC Standard Errors for Time-series Data</a></p>
</li>
<li><p><a href="#heading-step-5-bootstrap-confidence-intervals">Step 5: Bootstrap Confidence Intervals</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-validating-against-the-ground-truth">Validating Against the Ground Truth</a></p>
</li>
<li><p><a href="#heading-when-switchback-fails">When Switchback Fails</a></p>
</li>
<li><p><a href="#heading-when-to-use-switchback-vs-cluster-randomization">When to Use Switchback vs. Cluster Randomization</a></p>
</li>
<li><p><a href="#heading-what-to-do-next">What to Do Next</a></p>
</li>
</ul>
<h2 id="heading-why-user-level-ab-testing-fails-on-shared-llm-infrastructure">Why User-Level A/B Testing Fails on Shared LLM Infrastructure</h2>
<p>Standard A/B testing buys you causal inference through randomization. When you flip a coin to assign each user to treatment or control, both groups share identical distributions of every confounder on average. Differences in outcomes trace back to the treatment. The logic holds when users act independently of each other.</p>
<p>Shared LLM infrastructure breaks that independence. Consider the query-routing scenario. If 50% of users are assigned to AI routing, they receive priority access to the premium model, enabling them to complete tasks faster and at higher rates. The remaining 50% operate in a degraded environment, where premium-model queues are longer because treatment-group sessions occupy capacity. Control-group users experience worse availability not because the AI routing feature fails them, but because your experiment design created artificial scarcity for them.</p>
<p>Interference is the structural problem here: the Stable Unit Treatment Value Assumption, known as SUTVA, holds that a unit's outcome depends solely on that unit's treatment assignment.</p>
<p>SUTVA fails on shared LLM infrastructure. A treated user's session claims capacity that determines whether a control user gets routed to the premium model or the degraded standard model. The control group is no longer a clean counterfactual.</p>
<p>The estimated treatment effect under user-level randomization is:</p>
<pre><code class="language-plaintext">Naive ATE = E[outcome | AI-on user] - E[outcome | AI-off user, degraded capacity]
</code></pre>
<p>The counterfactual you actually need is what AI-off users would have experienced if no users had AI routing, with no capacity degradation. You never observe that counterfactual in a 50/50 user-level split. Your estimate conflates the routing AI's direct effect with the capacity-degradation penalty, and separating them requires knowing the full capacity-utilization function, which you almost never have.</p>
<p>Other shared-resource LLM platform patterns produce the same failure: a caching layer that speeds retrieval for treated users but drains shared cache space for control users, and a fine-tuned model version that consumes GPU memory, leaving standard inference slower for the control group, or a batch-processing scheduler that prioritizes AI-routed requests and creates queuing delays for everything else. Anything touching a shared resource pool contaminates the control group.</p>
<h2 id="heading-how-switchback-design-restores-a-clean-comparison">How Switchback Design Restores a Clean Comparison</h2>
<p>Because standard randomization can poison the control group through shared resources, a switchback design changes what you randomize. You stop randomizing users. You randomize time slots.</p>
<p>The entire platform operates under a single treatment condition at any given time: AI routing is either on or off for all users.</p>
<p>The treatment indicator switches between slots on a predetermined schedule, cycling through alternating blocks across the experiment. At the end of the run, you have a time series of slots, each with a treatment indicator and an aggregate outcome, such as the mean task completion rate or the mean cost per session. You regress the outcome on the treatment indicator, and the coefficient is your average treatment effect estimate.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69cc82ffe4688e4edd796adb/64756f6a-bfac-4fd3-b014-21d6ef724df4.png" alt="64756f6a-bfac-4fd3-b014-21d6ef724df4" style="display:block;margin:0 auto" width="1636" height="734" loading="lazy">

<p><em>Figure 1: Conceptual schematic of the 3-slot switchback design. Blue regions are AI-routing-on blocks, while orange marks the first AI-off slot of each cycle where carryover from the prior on-block artificially elevates outcomes.</em><br><em>The green band shows the true 6 pp direct effect. A naïve comparison of all-on vs. all-off slots inflates the estimated effect because it can't disentangle the direct contribution from within-block carryover.</em></p>
<p>A clean comparison is restored because the platform operates under a single condition for any given slot. Every user within a slot sees the same treatment. The AI-off slots function as a reliable counterfactual for the AI-on slots, provided that demand conditions remain comparable across slots.</p>
<p>The key complication is carryover. If AI routing effects persist into a subsequent AI-off slot due to factors such as warm routing caches, in-flight sessions that began under AI routing and complete after the switch, or changed user behavior that persists across the slot boundary, then AI-off slot outcomes are artificially elevated by residual AI effects.</p>
<p>The naïve comparison conflates this inherited elevation with the direct treatment effect, biasing the estimate upward. Estimating and removing carryover is the core analytical challenge in switchback experiments: it's where most of the real work lives, and most of what this tutorial covers.</p>
<h2 id="heading-identification-assumptions">Identification Assumptions</h2>
<p>Switchback estimates have a causal interpretation only when four conditions hold.</p>
<h3 id="heading-1-zero-or-bounded-carryover-between-slots">1. Zero or bounded carryover between slots.</h3>
<p>AI routing effects from one slot don't persist far enough into later slots to bias the comparison. The carryover model in this tutorial captures first-order persistence (one lag). If effects persist for multiple periods, you need more lag terms in the regression.</p>
<h3 id="heading-2-demand-stationarity-across-the-treatment-schedule">2. Demand stationarity across the treatment schedule.</h3>
<p>AI-on and AI-off slots face similar underlying demand conditions. If Monday morning slots are always AI-on and Sunday afternoon slots are always AI-off, demand differences contaminate the treatment comparison in ways no lag correction can fix.</p>
<h3 id="heading-3-no-ramp-up-effects-at-block-boundaries">3. No ramp-up effects at block boundaries.</h3>
<p>The system reaches steady-state behavior within each slot. If the first slot of each AI-on block performs worse than subsequent slots because the routing model's cache is cold, that ramp-up period produces a downward-biased estimate of the steady-state direct effect.</p>
<h3 id="heading-4-residual-autocorrelation-is-addressed">4. Residual autocorrelation is addressed.</h3>
<p>Slot residuals may be correlated over time due to demand cycles, capacity events, and platform-level shocks spanning multiple periods. HAC standard errors or bootstrap CIs correct for this (as plain OLS standard errors aren't sufficient).</p>
<p>The "When switchback fails" section maps each failure mode to the specific assumption it violates.</p>
<p>All code in this tutorial runs end-to-end in the companion notebook at <a href="https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/06_switchback/"><code>06_switchback/switchback_demo.ipynb</code></a>.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Python 3.11+</p>
</li>
<li><p>pandas 2.x (<code>pip install pandas</code>)</p>
</li>
<li><p>numpy 1.26+ (<code>pip install numpy</code>)</p>
</li>
<li><p>statsmodels 0.14+ (<code>pip install statsmodels</code>)</p>
</li>
<li><p>matplotlib 3.8+ (<code>pip install matplotlib</code>)</p>
</li>
</ul>
<p>Clone the companion repo to get the synthetic dataset:</p>
<pre><code class="language-bash">git clone https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm
cd product-experimentation-causal-inference-genai-llm
python data/generate_data.py
</code></pre>
<p>The generate script writes <code>data/synthetic_llm_logs.csv</code>, a 50,000-row file of synthetic SaaS LLM product telemetry. Key columns are <code>user_id</code>, <code>task_completed</code> (binary outcome), <code>cost_usd</code>, and <code>session_minutes</code>.</p>
<p>After slot assignment in Step 1, each of the 48 time slots contains approximately 1,042 sessions. The dataset represents realistic LLM platform traffic: query arrival rates, model cost distributions, and session lengths are drawn from distributions calibrated to production patterns.</p>
<h2 id="heading-step-1-build-the-switchback-time-series">Step 1: Build the Switchback Time Series</h2>
<p>Switchback experiments are run with a live treatment-assignment controller that flips the routing AI on or off at the slot boundary in production.</p>
<p>For this tutorial, you construct the time series from the session log by mapping each row to a synthetic hour slot, then aggregating to the slot level.</p>
<pre><code class="language-python">import pandas as pd
import numpy as np

df = pd.read_csv("data/synthetic_llm_logs.csv")
print(f"Dataset shape: {df.shape}")
print(df[["user_id", "task_completed", "cost_usd", "session_minutes"]].head(3).round(3))

# Shuffle to eliminate row-ordering bias before slot assignment
df = df.sample(frac=1, random_state=42).reset_index(drop=True)

# Assign hour slots: 48 slots, each containing ~1,042 sessions
df['hour_slot'] = df.index % 48

# Treatment schedule: 3-slot blocks (on, on, on, off, off, off, ...)
# 3-slot blocks give the platform time to settle into each state and break
# the perfect collinearity between ai_on and its one-period lag.
ai_on_schedule = np.tile([1, 1, 1, 0, 0, 0], 8)   # 48 slots, 8 full cycles
df['ai_on'] = ai_on_schedule[df['hour_slot']]

# Aggregate to slot level: mean outcome, mean cost, treatment indicator, session count
slots = df.groupby('hour_slot').agg(
    mean_task_completed = ('task_completed', 'mean'),
    mean_cost           = ('cost_usd',       'mean'),
    ai_on               = ('ai_on',          'first'),
    n_obs               = ('user_id',         'count')
).reset_index()

print(f"\nSlot-level data: {len(slots)} slots")
print(slots[['hour_slot', 'ai_on', 'mean_task_completed', 'mean_cost', 'n_obs']].head(8).round(4))
print(f"\nAI-on slots: {slots['ai_on'].sum()},  AI-off slots: {(1 - slots['ai_on']).sum()}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Dataset shape: (50000, 16)
   user_id  task_completed  cost_usd  session_minutes
0        0               0     0.022             7.03
1        1               1     0.008             4.07
2        2               1     0.040             8.34

Slot-level data: 48 slots
   hour_slot  ai_on  mean_task_completed  mean_cost  n_obs
0          0      1               0.5950     0.0222   1042
1          1      1               0.5806     0.0223   1042
2          2      1               0.5950     0.0224   1042
3          3      0               0.6353     0.0218   1042
4          4      0               0.6017     0.0222   1042
5          5      0               0.6094     0.0218   1042
6          6      1               0.5912     0.0218   1042
7          7      1               0.5931     0.0219   1042

AI-on slots: 24,  AI-off slots: 24
</code></pre>
<p>The process begins by shuffling the dataset before slot assignment to eliminate any row-ordering artifacts from data generation. Each of the 50,000 rows is assigned to one of 48 synthetic hour slots using modulo arithmetic, and the treatment schedule alternates in 3-slot blocks, completing eight full cycles.</p>
<p>The 3-slot block structure serves two purposes: it gives the platform time to settle into each treatment state, and it breaks the perfect collinearity between the current treatment indicator and its one-period lag, which would otherwise make carryover estimation impossible under a purely alternating schedule. After aggregation, each slot contains approximately 1,042 sessions.</p>
<p>Notice that before injection, the slot-level means don't yet separate clearly by treatment. Slots 3, 4, and 5 (AI-off) show slightly higher completion rates than slots 0, 1, and 2 (AI-on) in the raw data. That's expected: before injection, the treatment assignment is arbitrary, and outcomes carry no true signal. The injection step below bakes in the ground truth.</p>
<pre><code class="language-python"># Known ground truth baked into the simulation
TRUE_EFFECT = 0.060   # AI routing raises task completion by 6 percentage points
CARRYOVER   = 0.030   # Residual routing effect persists into the following slot

# Replace slot means with synthetic balanced base rates.
# Slot noise std matches the CLT variance of aggregating ~1,042 Bernoulli sessions,
# simulating realistic slot-to-slot demand variation without treatment-group imbalance.
BASE_RATE = df['task_completed'].mean()
slot_noise_std = np.sqrt(BASE_RATE * (1 - BASE_RATE) / slots['n_obs'].iloc[0])
rng = np.random.default_rng(42)
slots['mean_task_completed'] = BASE_RATE + rng.normal(0, slot_noise_std, size=len(slots))

# Lag the treatment indicator: did the previous slot have AI routing on?
slots['ai_on_lag1'] = slots['ai_on'].shift(1).fillna(0).astype(int)

# Observed outcome = base outcome + treatment effect + carryover from prior slot
slots['mean_task_completed'] = (
    slots['mean_task_completed']
    + TRUE_EFFECT * slots['ai_on']
    + CARRYOVER   * slots['ai_on_lag1']
)

print("Post-injection slot data:")
print(slots[['hour_slot', 'ai_on', 'ai_on_lag1', 'mean_task_completed']].head(8).round(4))
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Post-injection slot data:
   hour_slot  ai_on  ai_on_lag1  mean_task_completed
0          0      1           0               0.6606
1          1      1           1               0.6701
2          2      1           1               0.6973
3          3      0           1               0.6402
4          4      0           0               0.5663
5          5      0           0               0.5761
6          6      1           0               0.6579
7          7      1           1               0.6811
</code></pre>
<p>The injection substitutes raw slot means with noise calibrated to the variance of 1,042 Bernoulli trials, producing slot-to-slot fluctuation that mirrors production demand variability without artificial treatment-group imbalance.</p>
<p>The lag of <code>ai_on</code> identifies which slots immediately follow an AI-on period. The injection formula then adds <code>TRUE_EFFECT</code> (0.060) to every AI-on slot and <code>CARRYOVER</code> (0.030) to every slot that follows an AI-on slot, regardless of its own treatment status.</p>
<p>Look at slot 3: <code>ai_on=0</code> but <code>ai_on_lag1=1</code>, so its outcome receives the +0.030 carryover boost even though AI routing is off. That's the carryover contamination a naïve model can't see.</p>
<p>The first AI-off slot of each cycle reflects a genuine off period, but its outcome is elevated by residual routing state from the previous block. A naïve comparison of all AI-on vs. all AI-off slots treats that elevated outcome as part of the AI-off baseline, distorting the true direct effect.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69cc82ffe4688e4edd796adb/a2e1458b-751e-4e64-9e76-ea269f09de5d.png" alt="a2e1458b-751e-4e64-9e76-ea269f09de5d" style="display:block;margin:0 auto" width="1918" height="719" loading="lazy">

<p><em>Figure 2: Left: the 48-slot time series from the synthetic dataset after injecting a 6 pp treatment effect and 3 pp carryover. Orange dots mark the first AI-off slot of each cycle (ai_on=0, ai_on_lag1=1), where outcomes remain elevated from the prior AI-on block.</em><br><em>Right: naïve OLS (red) overshoots the true 6 pp effect by 0.9 pp because it conflates direct and inherited carryover. The carryover-adjusted OLS (blue) recovers the true effect. Both 95% bootstrap CIs include the green dashed true-effect line.</em></p>
<h2 id="heading-step-2-naive-estimate-ignoring-time-structure">Step 2: Naive Estimate (Ignoring Time Structure)</h2>
<p>Before adding any sophistication, compute the obvious estimate: regress mean task completion on the binary AI-on indicator, ignoring the time structure entirely.</p>
<pre><code class="language-python">import statsmodels.api as sm

# Naive OLS: outcome ~ constant + ai_on
# No lag term, no time controls
X_naive = sm.add_constant(slots['ai_on'])
naive_model = sm.OLS(slots['mean_task_completed'], X_naive).fit()

naive_ate = naive_model.params['ai_on']
naive_se  = naive_model.bse['ai_on']

print("=== Naive estimate (no carryover control) ===")
print(f"  ATE estimate : {naive_ate:.4f}")
print(f"  Std error    : {naive_se:.4f}")
print(f"  95% CI       : [{naive_ate - 1.96*naive_se:.4f},  {naive_ate + 1.96*naive_se:.4f}]")
print(f"\n  True effect  : {TRUE_EFFECT}")
print(f"  Bias         : {naive_ate - TRUE_EFFECT:+.4f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">=== Naive estimate (no carryover control) ===
  ATE estimate : 0.0688
  Std error    : 0.0048
  95% CI       : [0.0595,  0.0782]

  True effect  : 0.06
  Bias         : +0.0088
</code></pre>
<p>The naïve OLS regresses mean task completion on the binary AI-on indicator alone, treating the 48 slots as 48 independent observations with no time structure. It returns an ATE of 0.0688 against a true direct effect of 0.060, a bias of +0.0088, nearly a full percentage point of artificial lift.</p>
<p>The bias stems from how carryover is distributed between the two groups. In a 3-slot-on / 3-slot-off design, slots 1 and 2 of every AI-on block receive both the direct treatment effect (+0.060) and the carryover effect (+0.030) from the previous on-slot, pushing their outcomes to base + 0.090.</p>
<p>The naïve model can't separate these two contributions: it sees a high outcome in an AI-on slot and attributes it entirely to the direct treatment. Across 24 AI-on slots, 16 receive this compound injection, pulling the group average well above the true direct effect.</p>
<p>On the AI-off side, the first off-slot of each block receives +0.030 carryover, which raises the AI-off group's baseline. That partially offsets the AI-on group inflation, but 16 slots of compound AI-on inflation outweigh 8 slots of AI-off carryover. The net result is a positive bias of roughly +0.009 percentage points.</p>
<p>A team acting on 0.0688, when the true effect is 0.060, will declare a larger effect than exists and over-prioritize the routing feature relative to other initiatives.</p>
<h2 id="heading-step-3-carryover-adjusted-ols-regression">Step 3: Carryover-Adjusted OLS Regression</h2>
<p>The fix is to add the lagged treatment indicator to the regression. The coefficient on <code>ai_on</code> then measures the direct effect of the current period's treatment, holding the prior period's treatment constant. That's the quantity you want.</p>
<pre><code class="language-python"># Carryover-adjusted OLS: outcome ~ constant + ai_on + ai_on_lag1
X_adj = sm.add_constant(slots[['ai_on', 'ai_on_lag1']])
adj_model = sm.OLS(slots['mean_task_completed'], X_adj).fit()

adj_ate      = adj_model.params['ai_on']
adj_carryover = adj_model.params['ai_on_lag1']
adj_se        = adj_model.bse['ai_on']

print("=== Carryover-adjusted estimate ===")
print(adj_model.summary().tables[1])

print(f"\n  Direct ATE estimate  : {adj_ate:.4f}  (true: {TRUE_EFFECT})")
print(f"  Carryover estimate   : {adj_carryover:.4f}  (true: {CARRYOVER})")
print(f"  Residual bias        : {adj_ate - TRUE_EFFECT:+.4f}")

# How much did we remove?
removed = naive_ate - adj_ate
print(f"\n  Bias removed vs naive: {removed:.4f}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">=== Carryover-adjusted estimate ===
==============================================================================
                 coef    std err          t      P&gt;|t|      [0.025      0.975]
------------------------------------------------------------------------------
const          0.5996      0.003    222.975      0.000       0.594       0.605
ai_on          0.0607      0.004     16.830      0.000       0.053       0.068
ai_on_lag1     0.0244      0.004      6.754      0.000       0.017       0.032
==============================================================================

  Direct ATE estimate  : 0.0607  (true: 0.06)
  Carryover estimate   : 0.0244  (true: 0.03)
  Residual bias        : +0.0007

  Bias removed vs naive: 0.0081
</code></pre>
<p>The adjusted regression includes both <code>ai_on</code> (current slot treatment) and <code>ai_on_lag1</code> (previous slot treatment) as regressors.</p>
<p>The model now decomposes the drivers of elevated outcomes in each slot: some elevation comes from the current period's AI routing, and some from the previous period's residual. The coefficient on <code>ai_on</code> isolates only the current-period direct effect.</p>
<p>The direct ATE estimate drops from 0.0688 to 0.0607, recovering the true value of 0.060 to within 0.0007, with a residual bias smaller than the standard error.</p>
<p>The carryover estimate is 0.0244, compared with a true carryover of 0.030. Some underestimation is expected: the 3-slot block structure creates slots where both <code>ai_on</code> and <code>ai_on_lag1</code> equal 1, introducing mild collinearity that slightly attenuates the carryover coefficient. Adding <code>ai_on_lag1</code> removed 0.0081 of the 0.0088 naïve bias, recovering roughly 92% of the upward distortion.</p>
<p>The two-coefficient interpretation matters for product decisions. The <code>ai_on</code> coefficient (0.0607) is the <strong>direct effect</strong>: what AI routing adds in the current slot, independent of what happened in the prior slot. The <code>ai_on_lag1</code> coefficient (0.0244) is the <strong>carryover effect</strong>: the residual impact that persists into the next slot after routing is switched off. In a real LLM platform, carryover might reflect session-level state, warm inference caches, or shifts in user behavior that span the slot boundary.</p>
<p>If <code>ai_on_lag2</code> and <code>ai_on_lag3</code> still improve model fit as measured by decreasing AIC, your slot length is shorter than the system's memory, and you need more lag terms. Add lags until AIC stops improving, and use domain knowledge to set a ceiling on plausible persistence given your platform's architecture.</p>
<h2 id="heading-step-4-hac-standard-errors-for-time-series-data">Step 4: HAC Standard Errors for Time-series Data</h2>
<p>The adjusted OLS model gives you the right point estimate. But the standard errors it reports assume residuals are uncorrelated across time.</p>
<p>Slot residuals inherit any systematic variation not captured by the treatment indicators: demand cycles, capacity events, model-version deployments, and user behavior patterns that span multiple periods. That autocorrelation makes OLS standard errors too small, which inflates your t-statistics and makes the effect look more precisely measured than it is.</p>
<p>The correction is Heteroskedasticity- and Autocorrelation-Consistent (HAC) standard errors, also called Newey-West standard errors. They correct for serial correlation in residuals using a bandwidth parameter equal to the number of lags you expect to matter.</p>
<pre><code class="language-python">from statsmodels.stats.sandwich_covariance import cov_hac
from statsmodels.stats.stattools import durbin_watson

# First check for autocorrelation in the residuals
dw_stat = durbin_watson(adj_model.resid)
print(f"Durbin-Watson statistic: {dw_stat:.4f}")
print("  DW near 2.0 = little autocorrelation in residuals.")
print("  DW &lt; 1.5 = positive serial correlation.")
print("  DW &gt; 2.5 = negative serial correlation.")
print("  Apply HAC standard errors regardless -- DW only tests AR(1) structure.")

# Apply HAC correction (Newey-West), 3 lags
hac_cov = cov_hac(adj_model, nlags=3)
hac_se  = np.sqrt(np.diag(hac_cov))

print("\n=== Standard error comparison ===")
print(f"  OLS SE on ai_on  : {adj_model.bse['ai_on']:.4f}")
print(f"  HAC SE on ai_on  : {hac_se[1]:.4f}")
print(f"  OLS t-stat       : {adj_model.tvalues['ai_on']:.2f}")
print(f"  HAC t-stat       : {adj_ate / hac_se[1]:.2f}")

# Construct HAC-based confidence interval manually
hac_ci_lower = adj_ate - 1.96 * hac_se[1]
hac_ci_upper = adj_ate + 1.96 * hac_se[1]
print(f"\n  HAC 95% CI: [{hac_ci_lower:.4f},  {hac_ci_upper:.4f}]")
print(f"  True effect {TRUE_EFFECT} inside CI: {hac_ci_lower &lt; TRUE_EFFECT &lt; hac_ci_upper}")
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">Durbin-Watson statistic: 1.9628
  DW near 2.0 = little autocorrelation in residuals.
  DW &lt; 1.5 = positive serial correlation.
  DW &gt; 2.5 = negative serial correlation.
  Apply HAC standard errors regardless -- DW only tests AR(1) structure.

=== Standard error comparison ===
  OLS SE on ai_on  : 0.0036
  HAC SE on ai_on  : 0.0037
  OLS t-stat       : 16.83
  HAC t-stat       : 16.41

  HAC 95% CI: [0.0535,  0.0680]
  True effect 0.06 inside CI: True
</code></pre>
<p>The Durbin-Watson statistic near 2.0 (1.9628) indicates very little AR(1) autocorrelation in the residuals on this synthetic dataset, so the HAC and OLS standard errors are nearly identical. The HAC 95% CI [0.0535, 0.0680] contains the true effect of 0.060, confirming the adjusted estimate is valid.</p>
<p>In production LLM platforms where demand correlates across consecutive hours (morning surges, lunchtime dips, evening peaks), positive serial correlation causes OLS standard errors to understate uncertainty. I've seen teams skip this step and report t-statistics of 20+ on effects that don't hold up.</p>
<p>HAC corrections in those settings bring those numbers down to realistic levels and occasionally flip a "significant" result to inconclusive. The flip to inconclusive is the method working correctly. Apply HAC by default in any time-series regression: it costs nothing when autocorrelation is absent, and it provides real protection when it's present.</p>
<p>The <code>nlags</code> parameter deserves deliberate choice. A reasonable default is the number of slots you'd expect your largest demand cycle to span. If your platform shows strong hour-of-day patterns and you're using 30-minute slots, set <code>nlags=4</code> or <code>nlags=6</code> to cover the two-to-three-hour neighborhood. If you use two-hour slots, <code>nlags=2</code> or <code>nlags=3</code> usually covers the relevant range.</p>
<h2 id="heading-step-5-bootstrap-confidence-intervals">Step 5: Bootstrap Confidence Intervals</h2>
<p>HAC standard errors correct for autocorrelation under the assumption that the autocorrelation structure follows a specific parametric form. Bootstrap CIs make no such assumption. They quantify estimation uncertainty by resampling slots with replacement and recomputing the estimator each time.</p>
<pre><code class="language-python">def bootstrap_ci(slots, B=500, seed=7):
    """Bootstrap CIs treating each slot as an independent observation.
  
    Each slot's ai_on_lag1 value is fixed from the original treatment schedule.
    Resampling slots with replacement while keeping their original lag values
    correctly quantifies estimation uncertainty without destroying the lag structure.
    """
    rng  = np.random.default_rng(seed)
    n    = len(slots)
    naive_ates, adj_ates, carryover_ests = [], [], []

    for _ in range(B):
        idx = rng.integers(0, n, size=n)
        s   = slots.iloc[idx]  # ai_on_lag1 stays as the original slot's value

        X_n = sm.add_constant(s['ai_on'])
        naive_ates.append(sm.OLS(s['mean_task_completed'], X_n).fit().params['ai_on'])

        X_a = sm.add_constant(s[['ai_on', 'ai_on_lag1']])
        m   = sm.OLS(s['mean_task_completed'], X_a).fit()
        adj_ates.append(m.params['ai_on'])
        carryover_ests.append(m.params['ai_on_lag1'])

    naive_ci     = np.percentile(naive_ates,     [2.5, 97.5])
    adj_ci       = np.percentile(adj_ates,       [2.5, 97.5])
    carryover_ci = np.percentile(carryover_ests, [2.5, 97.5])

    print(f"\n=== Bootstrap 95% confidence intervals (B={B}, seed={seed}) ===")
    print(f"  Naive ATE        : [{naive_ci[0]:.4f},  {naive_ci[1]:.4f}]  "
          f"(covers {TRUE_EFFECT}: {naive_ci[0] &lt; TRUE_EFFECT &lt; naive_ci[1]})")
    print(f"  Adjusted ATE     : [{adj_ci[0]:.4f},  {adj_ci[1]:.4f}]  "
          f"(covers {TRUE_EFFECT}: {adj_ci[0] &lt; TRUE_EFFECT &lt; adj_ci[1]})")
    print(f"  Carryover effect : [{carryover_ci[0]:.4f},  {carryover_ci[1]:.4f}]  "
          f"(covers {CARRYOVER}: {carryover_ci[0] &lt; CARRYOVER &lt; carryover_ci[1]})")

    return naive_ci, adj_ci, carryover_ci

naive_ci, adj_ci, carryover_ci = bootstrap_ci(slots)
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">=== Bootstrap 95% confidence intervals (B=500, seed=7) ===
  Naive ATE        : [0.0596,  0.0783]  (covers 0.06: True)
  Adjusted ATE     : [0.0541,  0.0683]  (covers 0.06: True)
  Carryover effect : [0.0175,  0.0320]  (covers 0.03: True)
</code></pre>
<p>Each bootstrap iteration resamples 48 slots with replacement, refits both the naive and adjusted OLS models, and records the key estimates. The 2.5th and 97.5th percentiles of those 500 replications give the bootstrap CIs.</p>
<p>Each slot brings its own <code>ai_on_lag1</code> value from the original treatment schedule, so the lag structure is preserved within each bootstrap draw. The resampling captures estimation uncertainty without fabricating temporal relationships that didn't exist.</p>
<p>All three 95% CIs cover their respective ground truths. The naive ATE CI [0.0596, 0.0783] covers the true effect (0.060) but is shifted upward, consistent with the +0.009 positive bias. The adjusted ATE CI [0.0541, 0.0683] is centered closer to the true effect and is narrower. The carryover CI [0.0175, 0.0320] covers the true carryover of 0.030 and excludes zero, confirming that the carryover is statistically distinguishable from no persistence.</p>
<p>The excluded-zero result matters for the product decision: if the carryover CI included zero, you couldn't rule out that all the elevated AI-off outcomes were sampling noise rather than genuine persistence.</p>
<h2 id="heading-validating-against-the-ground-truth">Validating Against the Ground Truth</h2>
<p>Pull together the three point estimates against their known ground truths:</p>
<pre><code class="language-python">print("=" * 52)
print(f"{'Estimator':&lt;30} {'Estimate':&gt;8}  {'True':&gt;6}  {'Bias':&gt;7}")
print("-" * 52)
print(f"{'Naive OLS (no lag)':&lt;30} {naive_ate:&gt;8.4f}  {TRUE_EFFECT:&gt;6.4f}  {naive_ate - TRUE_EFFECT:&gt;+7.4f}")
print(f"{'Carryover-adjusted OLS':&lt;30} {adj_ate:&gt;8.4f}  {TRUE_EFFECT:&gt;6.4f}  {adj_ate - TRUE_EFFECT:&gt;+7.4f}")
print(f"{'Carryover coefficient':&lt;30} {adj_carryover:&gt;8.4f}  {CARRYOVER:&gt;6.4f}  {adj_carryover - CARRYOVER:&gt;+7.4f}")
print("=" * 52)
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">====================================================
Estimator                      Estimate    True     Bias
----------------------------------------------------
Naive OLS (no lag)               0.0688  0.0600  +0.0088
Carryover-adjusted OLS           0.0607  0.0600  +0.0007
Carryover coefficient            0.0244  0.0300  -0.0056
====================================================
</code></pre>
<p>The comparison table shows exactly what each estimator recovers against the known ground truth.</p>
<p>The naïve OLS overshoots by 0.0088 percentage points because it can't separate the direct AI routing effect from the carryover that inflates AI-on and adjacent AI-off slots. The adjusted OLS recovers the true effect to within 0.0007, well inside the width of any reasonable confidence interval. The carryover coefficient is 0.0244, compared with a true value of 0.030.</p>
<p>That's a systematic underestimate: the collinearity between <code>ai_on</code> and <code>ai_on_lag1</code> in the 3-slot block structure produces this attenuation across all designs of this type.</p>
<p>The practical implication runs beyond this synthetic example. In a real LLM platform, carryover can be larger than the treatment effect. If the AI routing system fundamentally reshapes how the inference cluster allocates warm-cache slots across users, the next period will inherit a compute distribution shaped by AI routing, even after the routing AI is off.</p>
<p>Under those conditions, the naïve estimate could substantially overstate the effect you'd observe from a full always-on rollout, where no switching exists, and no carryover asymmetry accumulates.</p>
<p>Always estimate the carryover coefficient. If it's statistically significant and greater than 20% of your direct ATE estimate, the naïve estimate is unreliable for rollout decisions.</p>
<h2 id="heading-when-switchback-fails">When Switchback Fails</h2>
<p>Switchback solves marketplace interference under four conditions, and breaks under four others.</p>
<h3 id="heading-1-carryover-period-longer-than-the-slot-length">1. Carryover period longer than the slot length.</h3>
<p><em>Violated assumption: (1) zero or bounded carryover.</em></p>
<p>If AI routing changes how the inference cluster pre-warms caches across multi-hour periods, the carryover half-life might exceed 60 or 90 minutes. A 30-minute slot length is shorter than the system's memory, and adding a single lag term won't capture the full persistence. You'll underestimate carryover and your direct effect estimate will remain biased.</p>
<p>The diagnostic: add progressively more lags and watch whether AIC keeps improving. If <code>ai_on_lag3</code> and <code>ai_on_lag4</code> still improve fit, your slot length is too short relative to system memory. Lengthening slots and adding more lag terms trade the same resource: fewer effective observations and wider confidence intervals.</p>
<h3 id="heading-2-non-stationary-demand-confounding-slots">2. Non-stationary demand confounding slots.</h3>
<p><em>Violated assumption: (2) demand stationarity across the treatment schedule.</em></p>
<p>Weekday morning traffic surges, weekend evening spikes, and post-deployment adoption curves produce fundamentally different platform load conditions. If your treatment schedule places AI-on slots disproportionately in high-traffic windows and AI-off slots in low-traffic windows, the treatment coefficient absorbs demand differences as well as the routing AI's effect.</p>
<p>Randomizing the schedule within each day addresses this, as does including time-of-day fixed effects in the regression: a set of indicators for morning, afternoon, evening, and overnight absorbs within-day demand variation that would otherwise contaminate the treatment estimate.</p>
<h3 id="heading-3-ramp-up-effects-at-the-first-slot-of-each-on-period">3. Ramp-up effects at the first slot of each on-period.</h3>
<p><em>Violated assumption: (3) no ramp-up at block boundaries.</em></p>
<p>In a real LLM platform, the first AI-on slot often underperforms subsequent slots. The routing model's cache is cold. The demand-prediction layer hasn't observed the current day's query distribution.</p>
<p>Including the cold-start slot alongside steady-state AI-on slots averages a low-performing initialization period with a high-performing equilibrium period, and the ATE estimate understates the steady-state effect you'd observe at full rollout. Standard practice is to drop the first slot of each on-period as a burn-in window and estimate the ATE from slots 2 and 3 of each block.</p>
<h3 id="heading-4-period-autocorrelation-producing-overconfident-p-values">4. Period autocorrelation producing overconfident p-values.</h3>
<p><em>Violated assumption: (4) residual autocorrelation addressed.</em></p>
<p>The Durbin-Watson diagnostic is a first check, but it only detects AR(1) autocorrelation. Real LLM platform time series often have daily seasonality, intraday autocorrelation at specific hours, and structural breaks after model version deployments.</p>
<p>Plot the full ACF of the model residuals: spikes at lags corresponding to meaningful demand cycles signal that your <code>nlags</code> parameter in <code>cov_hac</code> needs to increase, or you should switch to bootstrap CIs that don't assume any particular autocorrelation structure.</p>
<p>Failing to correct for autocorrelation is the most common source of false positives in switchback analyses at LLM platforms.</p>
<p>Two additional design-level failure modes are worth tracking.</p>
<p>Slot lengths under 15 minutes mean the platform hasn't cleared between switches: queue depth, in-flight session count, and cache state all carry over from the prior period, amplifying contamination and making AI-off periods non-representative of steady-state operations.</p>
<p>Slot lengths longer than 4 hours reduce the number of treatment-control pairs, shrinking the effective sample size and widening confidence intervals to the point where you can't detect plausible-sized effects.</p>
<p>The practical sweet spot for most LLM platform experiments is 30 minutes to 2 hours per slot, with final calibration determined by the carryover half-life estimated from early pilot data.</p>
<h2 id="heading-when-to-use-switchback-vs-cluster-randomization">When to Use Switchback vs. Cluster Randomization</h2>
<p>Switchback and cluster randomization solve the same interference problem through different mechanisms.</p>
<p>Cluster randomization partitions users into non-overlapping segments by geographic region, tenant ID, or organizational account, and assigns segments to treatment and control simultaneously. Switchback assigns the full population to treatment and control at different times.</p>
<p>Cluster randomization works well when you have enough separable segments and between-segment spillover is negligible. For an LLM SaaS platform with enterprise tenants on dedicated compute slices, cluster randomization by tenant is feasible: one tenant's routing decisions don't exhaust capacity for another's sessions.</p>
<p>For a consumer LLM platform where all users share the same inference fleet, capacity spillover crosses any user-segment boundary you draw, and cluster randomization can't isolate it.</p>
<p>Switchback is appropriate when spillover crosses segment boundaries or when you don't have enough separable clusters to run a properly powered cluster experiment.</p>
<p>Most large platforms use both: switchback for platform-wide infrastructure changes where no clean segment boundary exists, cluster randomization for features that can be scoped to a tenant or geographic region.</p>
<p>The choice comes down to where you can plausibly break the interference. Time is a natural boundary when the system clears faster than the slot length, so the platform fully processes the effects of one condition before switching to the next. Segment identity is a natural boundary when resource pools genuinely don't overlap. Where neither boundary holds, you're in causal estimation territory: synthetic control methods, difference-in-differences with matched controls, or structural models of the interference mechanism.</p>
<h2 id="heading-what-to-do-next">What to Do Next</h2>
<p>If your switchback analysis shows a significant positive direct effect with a well-identified carryover term, the next hard question is whether the effect size justifies full rollout given the cost of the AI routing infrastructure. The premium model costs more per query than the standard model. Whether a 6 pp completion-rate lift covers that incremental inference cost depends on your product's monetization mechanics.</p>
<p>The carryover estimate shapes that decision too.</p>
<p>A large carryover coefficient means that some of the measured lift is dissipated once you switch to always-on routing, and the switching asymmetry disappears. The causal cost-benefit calculation requires the direct ATE, not the naïve estimate you'd get without the lag adjustment: revenue impact of the completion-rate gain, incremental inference cost at full traffic, and the confidence interval around each estimate before committing to an infrastructure investment.</p>
<p>If the routing AI shows heterogeneous effects across query types or user segments, the next analytical step is uplift modeling: building a model that predicts which queries benefit most from premium routing, so you route selectively and capture most of the task-completion gain at a fraction of the cost.</p>
<p>The causal identification work you've done here, including the switchback design, carryover adjustment, and HAC correction, gives you the unbiased population ATE you need as the ground-truth anchor for calibrating that uplift model.</p>
<p>The full companion code is at <a href="https://github.com/RudrenduPaul/product-experimentation-causal-inference-genai-llm/tree/main/06_switchback/"><code>06_switchback/</code></a>, including the notebook with all five steps, the figure-generation scripts, and the dataset-generation code.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Production-Safe Agent Loop: From Exit Conditions to Audit Trails ]]>
                </title>
                <description>
                    <![CDATA[ In July 2025, a Claude Code recursion loop burned between 16,000 USD and 50,000 USD in five hours. There was no crash or error, just agents doing exactly what they were told, indefinitely, because nob ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-production-safe-agent-loop-from-exit-conditions-to-audit-trails/</link>
                <guid isPermaLink="false">6a30885987482776da85bfd7</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ webdev ]]>
                    </category>
                
                    <category>
                        <![CDATA[ programing ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Daniel Nwaneri ]]>
                </dc:creator>
                <pubDate>Mon, 15 Jun 2026 23:18:49 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e135008490269cb3022acbf/0b4027d7-f2d5-42d6-bdc5-5eeec278425d.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In July 2025, a Claude Code recursion loop burned between 16,000 USD and 50,000 USD in five hours. There was no crash or error, just agents doing exactly what they were told, indefinitely, because nobody told them when to stop.</p>
<p>Four months later, a four-agent LangChain loop ran for eleven days and cost 47,000 USD. Nobody noticed until the invoice arrived. The pipeline worked correctly in testing, and the agents were doing exactly what they were told. Same pattern.</p>
<p>This tutorial is about that missing instruction.</p>
<p>You'll build five small Python primitives that catch most agent loop failures before they ship:</p>
<ul>
<li><p>A <strong>spec writer</strong> that forces you to define done before the loop starts</p>
</li>
<li><p>A <strong>circuit breaker</strong> that kills the loop when it exceeds hard limits</p>
</li>
<li><p>A <strong>ledger</strong> that records every turn in an append-only SQLite audit trail</p>
</li>
<li><p>An <strong>agent loop</strong> that ties all three together</p>
</li>
<li><p>A <strong>review surface</strong> that forces human attestation before downstream systems receive anything</p>
</li>
</ul>
<p>By the end you'll have a working repo you can drop into any agent project. The full code is at <a href="https://github.com/dannwaneri/production-safe-agent-loop">github.com/dannwaneri/production-safe-agent-loop</a>.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-why-this-keeps-happening">Why This Keeps Happening</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-phase-1-define-done-before-you-build">Phase 1: Define Done Before You Build</a></p>
</li>
<li><p><a href="#heading-phase-2-enforce-done-at-runtime">Phase 2: Enforce Done at Runtime</a></p>
</li>
<li><p><a href="#heading-phase-3-record-everything">Phase 3: Record Everything</a></p>
</li>
<li><p><a href="#heading-phase-4-the-loop-that-respects-its-boundaries">Phase 4: The Loop That Respects Its Boundaries</a></p>
</li>
<li><p><a href="#heading-phase-5-the-review-surface">Phase 5: The Review Surface</a></p>
</li>
<li><p><a href="#heading-phase-6-a-real-example-seo-audit-agent">Phase 6: A Real Example, SEO Audit Agent</a></p>
</li>
<li><p><a href="#heading-pluggable-llm-client">Pluggable LLM Client</a></p>
</li>
<li><p><a href="#heading-running-the-tests">Running the Tests</a></p>
</li>
<li><p><a href="#heading-what-youve-built">What You've Built</a></p>
</li>
<li><p><a href="#heading-next-steps">Next Steps</a></p>
</li>
</ol>
<h2 id="heading-why-this-keeps-happening">Why This Keeps Happening</h2>
<p>The math that got companies into trouble was simple. A chatbot costs roughly 0.04 USD per interaction. An orchestrated multi-agent workflow costs 1.20 USD. That's a 30x multiplier — and production benchmarks show it can reach 70x on complex tasks.</p>
<p>The problem isn't that agents are expensive. The problem is that most teams budgeted for chatbot costs and deployed agent architectures. Gartner found the token consumption gap between pilot chatbots and production agent workflows sits at 5-30x. The FinOps Foundation's 2026 State of FinOps report found 73% of enterprises say AI costs exceeded original projections.</p>
<p>The mechanism is straightforward once you see it. When an agent fails a task and retries, it doesn't start fresh. It re-reads the entire context window — every prior failed attempt — before trying again. Iteration one costs 100 tokens. Iteration two costs 200. Iteration ten costs thousands. You're paying for every failure, over and over, in milliseconds.</p>
<pre><code class="language-python"># This is the entire problem in three lines
while True:
    result = agent.run(task)
    # done when...?
</code></pre>
<p>That question mark is where the money goes.</p>
<p>The other thing making it worse: agents don't fail loudly. Traditional code hits an undefined state and crashes. An LLM hits ambiguity and tries to be helpful. It retries. It reformats the tool call. It spins up a verification agent. The verification agent finds something. A correction agent fires. Nobody defined what "correct" means. The loop looks beautiful on every dashboard you have — activity, tool calls, completion rate — while quietly burning through your budget.</p>
<p>Gartner predicts that 40% of agentic projects will be scrapped by 2027 due to economic failure. Most of that failure is preventable. Not with better models, but with exit conditions.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Python 3.10+</p>
</li>
<li><p>An Anthropic API key (or any provider — more on that later)</p>
</li>
<li><p>Basic familiarity with Python classes and SQLite</p>
</li>
</ul>
<pre><code class="language-bash">git clone https://github.com/dannwaneri/production-safe-agent-loop
cd production-safe-agent-loop
pip install -r requirements.txt
export ANTHROPIC_API_KEY=sk-...
</code></pre>
<h2 id="heading-phase-1-define-done-before-you-build">Phase 1: Define Done Before You Build</h2>
<p>The most expensive mistake in agent development isn't a bad model choice or a missing retry limit. It's starting the build before you can answer one question in one sentence:</p>
<p><strong>What does done look like?</strong></p>
<p>Most teams can't answer it. Not because they're careless, but because nothing forces them to before they open the terminal. The spec writer is that forcing function.</p>
<pre><code class="language-python"># spec_writer.py
from spec_writer import SpecWriter

spec = SpecWriter(db_path="spec.db").run()
</code></pre>
<p>When you call <code>.run()</code>, it won't return until you've answered three questions:</p>
<ol>
<li><p>What does this do?</p>
</li>
<li><p>What does this NOT do?</p>
</li>
<li><p>What does done look like in one sentence?</p>
</li>
</ol>
<p>The third question is the one that matters. It's also the hardest. "The agent audits the site" is not an answer. "The agent crawls the target URL, extracts all <code>&lt;title&gt;</code> and <code>&lt;meta description&gt;</code> tags, flags any missing or over-length, and stops" is an answer. One of those gives the circuit breaker something to enforce.</p>
<p>The spec stores to SQLite and returns a <code>SpecResult</code> dataclass with a <code>session_id</code>. That ID becomes the thread connecting your spec, your ledger rows, and your loop result. One session, traceable end to end.</p>
<pre><code class="language-python">@dataclass(frozen=True)
class SpecResult:
    what_it_does: str
    what_it_does_not: str
    done_looks_like: str
    session_id: str
</code></pre>
<p><code>frozen=True</code> matters. The spec is a commitment, not a draft. Once it's written, the loop runs against it. No mid-run revisions.</p>
<p>For testing, <code>SpecWriter</code> accepts injectable <code>input_fn</code> and <code>output_fn</code> callables. No stdin monkey-patching required. See <code>tests/test_spec_writer.py</code> for working examples — the suite uses a small <code>scripted_input</code> helper that returns answers from a generator, and writes to a per-test SQLite file via pytest's <code>tmp_path</code> fixture. SQLite's <code>:memory:</code> isn't safe here, because <code>SpecWriter</code> opens a fresh connection per method and each <code>:memory:</code> connection is its own isolated database.</p>
<h2 id="heading-phase-2-enforce-done-at-runtime">Phase 2: Enforce Done at Runtime</h2>
<p>Defining the exit condition upstream is discipline. The circuit breaker is enforcement.</p>
<pre><code class="language-python"># circuit_breaker.py
from circuit_breaker import CircuitBreaker, CircuitBreakerError

breaker = CircuitBreaker(turn_limit=5, token_limit=15000)
breaker.check(turn_count, accumulated_tokens)  # raises on breach
</code></pre>
<p>Two ceilings. Both hard.</p>
<p><code>turn_limit</code> caps how many times the loop can call the LLM. <code>token_limit</code> caps total token consumption across all turns. Either one tripping raises <code>CircuitBreakerError</code> immediately.</p>
<p>The boundary is strict: <code>turn_count == turn_limit</code> is allowed. <code>turn_count == turn_limit + 1</code> trips. No grace periods or warnings. A hard stop forces a human checkpoint.</p>
<pre><code class="language-python">from dataclasses import dataclass


@dataclass
class CircuitBreakerError(Exception):
    reason: str          # "turn_ceiling" or "token_ceiling"
    turn_count: int
    accumulated_tokens: int

    def __post_init__(self) -&gt; None:
        super().__init__(
            f"circuit breaker tripped: {self.reason} "
            f"(turn={self.turn_count}, tokens={self.accumulated_tokens})"
        )


class CircuitBreaker:
    def __init__(self, turn_limit: int = 5, token_limit: int = 15000) -&gt; None:
        self.turn_limit = turn_limit
        self.token_limit = token_limit

    def check(self, turn_count: int, accumulated_tokens: int) -&gt; None:
        if turn_count &gt; self.turn_limit:
            self._trip("turn_ceiling", turn_count, accumulated_tokens)
        if accumulated_tokens &gt; self.token_limit:
            self._trip("token_ceiling", turn_count, accumulated_tokens)

    def _trip(self, reason: str, turn_count: int, accumulated_tokens: int) -&gt; None:
        print(
            "\n=== CIRCUIT BREAKER CHECKPOINT ===\n"
            f"reason         : {reason}\n"
            f"turn_count     : {turn_count} / limit {self.turn_limit}\n"
            f"tokens_used    : {accumulated_tokens} / limit {self.token_limit}\n"
            "action         : halt loop, surface to human reviewer\n"
            "=================================="
        )
        raise CircuitBreakerError(
            reason=reason,
            turn_count=turn_count,
            accumulated_tokens=accumulated_tokens,
        )
</code></pre>
<p><code>CircuitBreakerError</code> is an exception, not a return code. That's intentional. A return code can be ignored. An uncaught exception can't. Silent breach is impossible. The human-readable checkpoint banner is printed to stdout by <code>_trip()</code> <em>before</em> the exception is raised, so even if a caller swallows the exception the operator still sees state.</p>
<p>The critical rule: call <code>.check()</code> <strong>before</strong> every LLM call, not after. Post-flight checking means you've already burned the tokens before you knew the limit was exceeded.</p>
<pre><code class="language-python"># Wrong — post-flight
result = client.messages.create(...)
breaker.check(turn_count, accumulated_tokens)  # too late

# Right — pre-flight
breaker.check(turn_count, accumulated_tokens)  # raises before any spend
result = client.messages.create(...)
</code></pre>
<p>The defaults (5 turns, 15,000 tokens) match a tight tutorial demo. Your production budget is different. Tune at instantiation:</p>
<pre><code class="language-python"># Production example — tighter token budget, more turns
breaker = CircuitBreaker(turn_limit=10, token_limit=50000)
</code></pre>
<h2 id="heading-phase-3-record-everything">Phase 3: Record Everything</h2>
<p>The circuit breaker protects your bank account. The ledger protects your understanding of what happened.</p>
<p>Most teams log for debugging — they want to know what went wrong after it went wrong. The ledger has a different purpose. It's governance. Every row is proof that the loop stayed within its boundaries, or didn't, and exactly when.</p>
<pre><code class="language-python"># ledger.py
from ledger import Ledger

ledger = Ledger(db_path="ledger.db")
ledger.write(
    session_id=spec.session_id,
    turn_count=1,
    state_origin="llm",
    input_str=task,
    token_delta=523,
    execution_time_ms=1240,
    pass_fail=True,
)
</code></pre>
<p>One row per turn. Append-only, no updates, and no deletes. The immutability is the point: a ledger you can edit isn't a ledger, it's a notebook.</p>
<p>The schema:</p>
<pre><code class="language-sql">CREATE TABLE IF NOT EXISTS ledger (
    id                 INTEGER PRIMARY KEY AUTOINCREMENT,
    session_id         TEXT    NOT NULL,
    turn_count         INTEGER NOT NULL,
    state_origin       TEXT    NOT NULL,
    input_hash         TEXT    NOT NULL,
    token_delta        INTEGER NOT NULL,
    execution_time_ms  INTEGER NOT NULL,
    pass_fail          INTEGER NOT NULL,  -- 1=pass, 0=fail
    breach_reason      TEXT,              -- NULL unless circuit breaker fired
    created_at         TEXT    NOT NULL   -- ISO 8601, UTC
);
CREATE INDEX IF NOT EXISTS idx_ledger_session ON ledger(session_id);
</code></pre>
<p>The index makes <code>get_session(session_id)</code> — the primary read path — a constant-time lookup as the ledger grows.</p>
<p>Three decisions worth explaining:</p>
<ol>
<li><p><code>input_hash</code> <strong>not</strong> <code>input_text</code><strong>.</strong> The raw input string never persists. Only its SHA-256 hash does. There are two benefits to this: identical inputs across runs are detectable, and PII never enters the audit trail.</p>
</li>
<li><p><code>pass_fail</code> <strong>as</strong> <code>INTEGER</code> <strong>not</strong> <code>BOOLEAN</code><strong>.</strong> SQLite has no boolean type. <code>1</code> and <code>0</code> are canonical. Clean Python ergonomics at the API edge, correct SQL types on disk.</p>
</li>
<li><p><code>created_at</code> <strong>as</strong> <code>datetime.now(timezone.utc).isoformat()</code><strong>.</strong> <code>datetime.utcnow()</code> was deprecated in Python 3.12. Timezone-aware timestamps avoid the footgun in any system that crosses timezones.</p>
</li>
</ol>
<p>Retrieve by session:</p>
<pre><code class="language-python">rows = ledger.get_session(spec.session_id)
for row in rows:
    print(f"Turn {row.turn_count}: {'PASS' if row.pass_fail else 'FAIL'} "
          f"| {row.token_delta} tokens | {row.execution_time_ms}ms")
</code></pre>
<h2 id="heading-phase-4-the-loop-that-respects-its-boundaries">Phase 4: The Loop That Respects Its Boundaries</h2>
<p>The agent loop wires the three primitives together. It's the only component that calls the LLM. Everything else is local.</p>
<pre><code class="language-python"># agent_loop.py
from agent_loop import AgentLoop

loop = AgentLoop(spec, breaker, ledger, client)
result = loop.run(task)
# LoopResult(success, turns, total_tokens, session_id, breach_reason)
</code></pre>
<p>The anatomy of a turn, in order:</p>
<ol>
<li><p><code>circuit_breaker.check(turn_count, accumulated_tokens)</code> — raises if either ceiling is exceeded</p>
</li>
<li><p><code>client.messages.create(...)</code> — the actual LLM call</p>
</li>
<li><p><code>ledger.write(...)</code> — one row, append-only</p>
</li>
<li><p>If <code>stop_reason == "end_turn"</code>, return. Otherwise loop.</p>
</li>
</ol>
<p>Pre-flight checking before every LLM call, with no exceptions.</p>
<pre><code class="language-python">def run(self, task: str) -&gt; LoopResult:
    session_id = self.spec.session_id
    messages: list[dict] = [{"role": "user", "content": task}]
    turn = 0
    total_tokens = 0

    try:
        while True:
            turn += 1
            self.circuit_breaker.check(turn, total_tokens)

            started = time.perf_counter()
            response = self.client.messages.create(
                model=self.model,
                max_tokens=self.max_tokens,
                system=self._system_prompt(),
                messages=messages,
            )
            elapsed_ms = int((time.perf_counter() - started) * 1000)

            turn_tokens = (
                getattr(response.usage, "input_tokens", 0)
                + getattr(response.usage, "output_tokens", 0)
            )
            total_tokens += turn_tokens

            text = self._text_from(response)
            messages.append({"role": "assistant", "content": text})

            self.ledger.write(
                session_id=session_id,
                turn_count=turn,
                state_origin="llm",
                input_str=task,
                token_delta=turn_tokens,
                execution_time_ms=elapsed_ms,
                pass_fail=True,
            )

            if getattr(response, "stop_reason", "end_turn") == "end_turn":
                return LoopResult(
                    success=True,
                    turns=turn,
                    total_tokens=total_tokens,
                    session_id=session_id,
                )

            messages.append({"role": "user", "content": "continue"})

    except CircuitBreakerError as err:
        self.ledger.write(
            session_id=session_id,
            turn_count=turn,
            state_origin="circuit_breaker",
            input_str=task,
            token_delta=0,
            execution_time_ms=0,
            pass_fail=False,
            breach_reason=err.reason,
        )
        return LoopResult(
            success=False,
            turns=turn,
            total_tokens=total_tokens,
            session_id=session_id,
            breach_reason=err.reason,
        )

def _system_prompt(self) -&gt; str:
    return (
        "You are an agent working on a tightly-scoped task.\n\n"
        f"What this does: {self.spec.what_it_does}\n"
        f"What this does NOT do: {self.spec.what_it_does_not}\n"
        f"Done looks like: {self.spec.done_looks_like}\n"
    )

@staticmethod
def _text_from(response) -&gt; str:
    content = getattr(response, "content", None)
    if not content:
        return ""
    block = content[0]
    return getattr(block, "text", "") or ""
</code></pre>
<p>A few choices worth calling out in this body:</p>
<ul>
<li><p><strong>The whole</strong> <code>while True:</code> <strong>is wrapped in one</strong> <code>try/except CircuitBreakerError</code><strong>.</strong> The check happens at the top of every turn, so a breach is caught the same way whether it fires on turn 1 or turn 6.</p>
</li>
<li><p><code>input_str=task</code> on every ledger row — the original task, not the last assistant message. The <code>input_hash</code> column then groups rows that share the same starting input across the run.</p>
</li>
<li><p><code>pass_fail=True</code> <strong>for every LLM turn that returns</strong>, <code>False</code> only on breach. The pass/fail flag tracks whether the loop <em>reached</em> the row legitimately, not whether the model's output was good. Quality scoring is a separate concern.</p>
</li>
<li><p><code>_system_prompt()</code> <strong>uses all three spec fields</strong>, not just <code>done_looks_like</code>. The model needs the negative scope (<code>what_it_does_not</code>) at least as much as the positive scope.</p>
</li>
<li><p><code>time.perf_counter()</code> <strong>not</strong> <code>time.time()</code> — monotonic, immune to wall-clock adjustments mid-run.</p>
</li>
</ul>
<p><code>LoopResult.session_id</code> is inherited from <code>spec.session_id</code>. The ledger rows tie back to the spec without a join. One session ID, one traceable run, start to finish.</p>
<h2 id="heading-phase-5-the-review-surface">Phase 5: The Review Surface</h2>
<p>The circuit breaker protects your bank account. The ledger records what happened. But neither tells you whether what happened matched what you promised.</p>
<p>That gap is where bad loops get approved. Polished output, green dashboard, missed commitment. A reviewer sees the artifact, decides it looks acceptable, and signs off. Nobody asked whether the original promise was kept.</p>
<p>The review surface closes that gap. It reads the session from SQLite, assembles the five-element frame, and forces a comparison before anything downstream receives the output.</p>
<pre><code class="language-python">from review_surface import ReviewSurface

rs = ReviewSurface(spec_db_path="spec.db", ledger_db_path="ledger.db")
print(rs.render(session_id))
</code></pre>
<p>Here's the five-element frame, in order:</p>
<ol>
<li><p><strong>Original promise</strong> — pulled from the spec table: what it does, what it doesn't do, what done looks like</p>
</li>
<li><p><strong>Acceptance criteria</strong> — the <code>done_looks_like</code> field rendered as the explicit benchmark</p>
</li>
<li><p><strong>Diff</strong> — first turn input vs final turn output, turns completed, total tokens, whether the loop breached</p>
</li>
<li><p><strong>Evidence</strong> — all ledger rows for the session: turn-by-turn pass/fail, token delta, execution time</p>
</li>
<li><p><strong>Unresolved assumptions</strong> — derived from breach rows and failed turns. Empty when clean.</p>
</li>
</ol>
<p>When the reviewer is satisfied, they attest:</p>
<pre><code class="language-python">attestation = rs.attest(
    session_id=result.session_id,
    reviewer="daniel",
    notes="Output matches spec. Approved."
)
print(attestation.frame_hash)
</code></pre>
<p><code>.attest()</code> writes to the <code>attestations</code> table in <code>ledger.db</code>. The <code>frame_hash</code> is a SHA-256 of the canonical frame data — deterministic across reviewers attesting the same session. It's the audit receipt. It proves the reviewer saw the exact frame as rendered, not a summary or a paraphrase.</p>
<p>Approval confirms the process ran. Attestation confirms the reviewer compared output to commitment. When the loop touches something regulated, those are different legal documents.</p>
<pre><code class="language-python">@dataclass(frozen=True)
class ReviewFrame:
    session_id: str
    original_promise: SpecResult
    acceptance_criteria: str
    diff: DiffResult
    evidence: tuple  # tuple[LedgerRow, ...]
    unresolved_assumptions: tuple  # tuple[str, ...]
    created_at: str
</code></pre>
<p><code>ReviewFrame</code> is frozen for the same reason <code>SpecResult</code> is — the frame is evidence, not a draft. <code>evidence</code> and <code>unresolved_assumptions</code> are tuples because lists aren't hashable and frozen dataclasses need hashable fields.</p>
<p>The full end-to-end flow with the review surface lives in <code>examples/review_example.py</code> in the repo. Run it after any completed session: it renders the five-element frame, prompts for attestation, and writes the receipt if you approve.</p>
<p>The loop runs to you. Downstream systems get nothing until someone signs.</p>
<h2 id="heading-phase-6-a-real-example-seo-audit-agent">Phase 6: A Real Example — SEO Audit Agent</h2>
<p>The pattern only makes sense against a real problem. This is the same agent architecture behind my <a href="https://github.com/dannwaneri/seo-agent">seo-agent</a> project.</p>
<p>SEO audits have a natural cadence: crawl, surface what's broken, fix, wait for reindex. Running the agent continuously doesn't change that cadence. It just burns tokens in the empty space between the moments that matter. A cron job wired to the loop is the honest architecture.</p>
<pre><code class="language-python"># examples/seo_audit_example.py
import requests
from bs4 import BeautifulSoup
import anthropic
from spec_writer import SpecWriter
from circuit_breaker import CircuitBreaker
from ledger import Ledger
from agent_loop import AgentLoop

def crawl_url(url: str) -&gt; str:
    response = requests.get(url, timeout=10)
    soup = BeautifulSoup(response.text, "html.parser")
    title = soup.find("title")
    meta_desc = soup.find("meta", attrs={"name": "description"})
    h1_tags = soup.find_all("h1")
    return (
        f"URL: {url}\n"
        f"Title: {title.text if title else 'MISSING'}\n"
        f"Meta description: "
        f"{meta_desc['content'] if meta_desc else 'MISSING'}\n"
        f"H1 count: {len(h1_tags)}\n"
        f"H1 tags: {[h.text[:50] for h in h1_tags]}"
    )

def run_seo_audit(url: str) -&gt; None:
    # Step 1: Define done before the loop starts
    spec = SpecWriter(db_path="spec.db").run()

    # Step 2: Initialise circuit breaker and ledger
    breaker = CircuitBreaker(turn_limit=5, token_limit=15000)
    ledger = Ledger(db_path="ledger.db")
    client = anthropic.Anthropic()

    # Step 3: Crawl the URL
    site_data = crawl_url(url)

    # Step 4: Run the loop
    # AgentLoop catches CircuitBreakerError internally and returns
    # LoopResult(success=False, breach_reason=...). Branch on the
    # result — do NOT wrap loop.run() in try/except CircuitBreakerError.
    loop = AgentLoop(spec, breaker, ledger, client)
    result = loop.run(
        f"Audit this page for SEO issues:\n\n{site_data}"
    )

    # Step 5: Print the ledger
    print(f"\nResult: {'SUCCESS' if result.success else 'BREACH'}")
    if not result.success:
        print(f"Breach reason: {result.breach_reason}")
    print(f"Turns: {result.turns} | Tokens: {result.total_tokens}")
    print("\nAudit trail:")
    for row in ledger.get_session(result.session_id):
        status = "PASS" if row.pass_fail else "FAIL"
        print(f"  Turn {row.turn_count}: {status} | "
              f"{row.token_delta} tokens | {row.execution_time_ms}ms")

if __name__ == "__main__":
    import sys
    run_seo_audit(sys.argv[1] if len(sys.argv) &gt; 1 else "https://example.com")
</code></pre>
<p>Run it:</p>
<pre><code class="language-bash">python examples/seo_audit_example.py https://yourdomain.com
</code></pre>
<p>The spec writer prompts you. The loop runs, the circuit breaker fires if the limits are exceeded, and the ledger records every turn. The output lands in front of you and you decide what to fix.</p>
<p>The loop runs to you, not into a void.</p>
<h2 id="heading-pluggable-llm-client">Pluggable LLM Client</h2>
<p>The loop works with any client that satisfies the <code>LLMClient</code> protocol (Anthropic by default). Bring your own via a ~20-line adapter.</p>
<pre><code class="language-python"># agent_loop.py
from typing import Protocol, runtime_checkable


@runtime_checkable
class MessagesEndpoint(Protocol):
    def create(self, *, model: str, max_tokens: int,
               system: str, messages: list) -&gt; object: ...


@runtime_checkable
class LLMClient(Protocol):
    messages: MessagesEndpoint
</code></pre>
<p><code>messages</code> is an instance attribute (not a nested class) because that's how the real Anthropic SDK exposes it — <code>anthropic.Anthropic().messages.create(...)</code>. Modeling it as a nested class would mean the real client wouldn't satisfy the Protocol. The <code>@runtime_checkable</code> decorator lets you sanity-check conformance with <code>isinstance(client, LLMClient)</code>, and the repo's test suite uses exactly that assertion against the <code>FakeClient</code> test double.</p>
<p>Here's an OpenAI adapter example (This is illustrative. A production adapter would also map streaming, tool-use, and error shapes.):</p>
<pre><code class="language-python"># openai_adapter.py — illustrative pseudocode, not production-ready.
from openai import OpenAI as _OpenAI


class _MessagesAdapter:
    def __init__(self, client):
        self._client = client

    def create(self, *, model, max_tokens, system, messages):
        completion = self._client.chat.completions.create(
            model=model,
            max_tokens=max_tokens,
            messages=[{"role": "system", "content": system}] + messages,
        )
        # Reshape OpenAI's response into the Anthropic-shaped surface
        # AgentLoop reads: response.usage.{input,output}_tokens,
        # response.content[0].text, response.stop_reason.
        return _adapt_response(completion)


class OpenAIAdapter:
    def __init__(self, api_key: str):
        self._client = _OpenAI(api_key=api_key)
        self.messages = _MessagesAdapter(self._client)  # instance attr, not a nested class
</code></pre>
<p>The adapter pattern is worth teaching explicitly. Provider APIs don't share a shape. Anthropic puts <code>system</code> at the top level. OpenAI puts it inside the messages array. An adapter shim is ~20 lines and makes the loop provider-agnostic without rewriting anything. Note that <code>self.messages</code> is assigned in <code>__init__</code> so it's a real attribute on each adapter instance, the same shape as the actual SDK.</p>
<h2 id="heading-running-the-tests">Running the Tests</h2>
<pre><code class="language-bash">python -m pytest tests/
</code></pre>
<p>With coverage:</p>
<pre><code class="language-bash">python -m coverage run --source=circuit_breaker,ledger,spec_writer,agent_loop,review_surface -m pytest tests/
python -m coverage report -m
</code></pre>
<p>80 tests, 100% coverage on all five core modules. The loop is exercised against a <code>FakeClient</code> test double defined inline in <code>tests/test_agent_loop.py</code>. It satisfies the <code>LLMClient</code> protocol via duck typing: <code>messages</code> is set to <code>self</code>, so <code>client.messages.create(...)</code> routes back to the same object and ships with scripted responses for each test scenario. Clone the repo and run <code>pytest</code> to see all 80 tests pass without touching the network or needing an API key.</p>
<p><code>circuit_breaker.py</code> has 100% coverage — no untested paths. It's the financial safety component. Every path through it is exercised.</p>
<h2 id="heading-what-youve-built">What You've Built</h2>
<p>In this tutorial, you've build five small primitives, each independently usable.</p>
<table>
<thead>
<tr>
<th>Module</th>
<th>Role</th>
<th>Lines</th>
</tr>
</thead>
<tbody><tr>
<td><code>spec_writer.py</code></td>
<td>Forces three answers before the loop runs</td>
<td>104</td>
</tr>
<tr>
<td><code>circuit_breaker.py</code></td>
<td>Hard ceilings on turns and tokens</td>
<td>41</td>
</tr>
<tr>
<td><code>ledger.py</code></td>
<td>Append-only SQLite audit trail</td>
<td>113</td>
</tr>
<tr>
<td><code>agent_loop.py</code></td>
<td>The loop that respects both</td>
<td>128</td>
</tr>
<tr>
<td><code>review_surface.py</code></td>
<td>Assembles the five-element frame, records human attestation</td>
<td>114</td>
</tr>
</tbody></table>
<p>The pattern: upstream discipline defines the boundaries. Downstream enforcement breaks the circuit. Neither trusts the model to police itself.</p>
<p>A loop that runs without an exit condition isn't autonomous. It's a billing event waiting to happen.</p>
<p>Define what done looks like before you start. That's the job, and always has been.</p>
<h2 id="heading-next-steps">Next Steps</h2>
<p>The repo is at <a href="https://github.com/dannwaneri/production-safe-agent-loop">github.com/dannwaneri/production-safe-agent-loop</a>.</p>
<p>There are three natural extensions if you want to go further:</p>
<h3 id="heading-1-graduation-to-distributed-systems">1. Graduation to Distributed Systems</h3>
<p>The SQLite ledger works for isolated sequential loops. The moment you run multiple agents against shared state, you need serializable isolation — concurrent writes to flat JSON corrupt silently. The README documents the three tipping points where a flat ledger needs to graduate.</p>
<h3 id="heading-2-cryptographic-signing">2. Cryptographic Signing</h3>
<p>For compliance-scale systems where the auditor wasn't present when the loop ran, SQLite rows aren't enough. A database admin can run an <code>UPDATE</code> query. Ed25519 signing wraps each ledger row in a receipt that proves the log wasn't altered after execution. But that's a different tutorial.</p>
<h3 id="heading-wiring-a-cron-job">Wiring a Cron Job</h3>
<p>The honest architecture for the SEO audit agent isn't 24/7 autonomous operation. It's a cron job that runs on schedule, surfaces what's broken, and stops. <code>0 3 * * 2 python examples/seo_audit_example.py https://yourdomain.com</code> is the whole thing. The loop runs to you, not into a void.</p>
<p>If you need this architecture built for your own stack (circuit breakers, audit trails, production-safe agent loops), I do freelance work. <a href="https://dannwaneri.com/ai-agents/">dannwaneri.com/ai-agents/</a></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ AI Paper Review: Chain-of-Thought Prompting Elicits Reasoning in Large Language Models ]]>
                </title>
                <description>
                    <![CDATA[ For the last few years, Large Language Models have been impressing researchers with their ability to generate text, answer questions, translate languages, and perform tasks they had never been explici ]]>
                </description>
                <link>https://www.freecodecamp.org/news/ai-paper-review-chain-of-thought-prompting-elicits-reasoning-in-large-language-models/</link>
                <guid isPermaLink="false">6a30800dc3625a1a686f75f8</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Deep Learning ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Mohammed Fahd Abrah ]]>
                </dc:creator>
                <pubDate>Mon, 15 Jun 2026 22:43:25 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/0d9c4f6a-1352-431f-af2e-c08b0e128e39.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>For the last few years, Large Language Models have been impressing researchers with their ability to generate text, answer questions, translate languages, and perform tasks they had never been explicitly trained to solve.</p>
<p>Each new generation seemed to confirm a simple belief: bigger models lead to better capabilities. Yet there was one area where progress appeared frustratingly limited. When problems required multiple steps of reasoning, language models often struggled in ways that were difficult to ignore.</p>
<p>A math word problem, a common sense question, or a symbolic puzzle could expose a surprising gap between fluent language generation and genuine problem solving. Models could frequently produce confident answers, but confidence alone wasn't enough. The challenge was whether they could reason through a problem before arriving at an answer.</p>
<p>Against this backdrop, the paper <em>Chain-of-Thought Prompting Elicits Reasoning in Large Language Models</em> introduced an idea that was both simple and unexpected. Rather than asking a model to produce an answer immediately, the authors encouraged it to work through intermediate reasoning steps first.</p>
<p>What followed was one of the most influential discoveries in modern AI research: many reasoning abilities that appeared absent in large language models weren't necessarily missing. In many cases, they simply hadn't been elicited in the right way.</p>
<p>This paper went on to reshape how researchers think about prompting, reasoning, and the capabilities of large language models. More importantly, it laid the intellectual foundation for many of the reasoning-oriented techniques and systems that emerged in the years that followed.</p>
<h2 id="heading-paper-overview">Paper Overview</h2>
<p>In this article, we'll explore the paper <em>Chain-of-Thought Prompting Elicits Reasoning in Large Language Models</em>, published by researchers at Google Research in 2022.</p>
<p>This paper introduced one of the most influential ideas in modern AI: <strong>Chain-of-Thought (CoT) Prompting</strong>. At a time when researchers were focused on scaling language models to ever-larger sizes, this study revealed that performance improvements were not always about building bigger models. Sometimes, the key was changing how we communicate with them.</p>
<p>The paper investigates a simple but powerful question: what happens if a language model is encouraged to show its reasoning process before giving an answer? Instead of responding directly, the model is guided to generate intermediate reasoning steps that lead to the final solution.</p>
<p>What makes this paper historically important is that it changed how researchers think about reasoning in large language models. The authors demonstrated that many reasoning capabilities can be unlocked through prompting alone, without additional training, fine-tuning, or architectural modifications.</p>
<p>The impact of this idea quickly extended beyond arithmetic reasoning. It influenced a new generation of research on reasoning, including Self-Consistency, Process Supervision, Verification-based methods, and the reasoning-oriented models that followed in subsequent years.</p>
<p>In many ways, this paper marked a shift from asking language models <strong>what the answer is</strong> to asking them <strong>how they arrived at the answer</strong>.</p>
<p>Here's the original paper if you'd like to explore it directly:</p>
<p><a href="https://arxiv.org/pdf/2201.11903"><strong>Chain-of-Thought Prompting Elicits Reasoning in Large Language Models</strong></a></p>
<p>And here's a quick infographic of what we'll cover throughout this review.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ce92860ff860b6de01ed93/bdf2234d-0fb2-4a44-a632-a0b3aa77fff4.png" alt="Chain-of-Thought Prompting Elicits Reasoning in Large Language Models" style="display:block;margin:0 auto" width="1414" height="2000" loading="lazy">

<h2 id="heading-table-of-contents"><strong>Table of Contents:</strong></h2>
<ul>
<li><p><a href="#heading-abstract">Abstract</a></p>
</li>
<li><p><a href="#heading-introduction">Introduction</a></p>
</li>
<li><p><a href="#heading-chain-of-thought-prompting">Chain-of-Thought Prompting</a></p>
</li>
<li><p><a href="#heading-arithmetic-reasoning">Arithmetic Reasoning</a></p>
</li>
<li><p><a href="#heading-results">Results</a></p>
</li>
<li><p><a href="#heading-ablation-study">Ablation Study</a></p>
</li>
<li><p><a href="#heading-robustness-of-chain-of-thought-prompting">Robustness of Chain-of-Thought Prompting</a></p>
</li>
<li><p><a href="#heading-common-sense-reasoning">Common Sense Reasoning</a></p>
</li>
<li><p><a href="#heading-symbolic-reasoning">Symbolic Reasoning</a></p>
</li>
<li><p><a href="#heading-discussion">Discussion</a></p>
</li>
<li><p><a href="#heading-related-work">Related Work</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To get the most out of this breakdown, it helps to already be familiar with a few foundational ideas and the evolution of large language models that led to Chain-of-Thought prompting.</p>
<p>Reading the previous reviews in this series will be especially helpful:</p>
<ul>
<li><p><a href="https://www.freecodecamp.org/news/ai-paper-review-improving-language-understanding-by-generative-pre-training-gpt-1/">AI Paper Review: Improving Language Understanding by Generative Pre-Training (GPT-1)</a></p>
</li>
<li><p><a href="https://www.freecodecamp.org/news/ai-paper-review-language-models-are-unsupervised-multitask-learners-gpt-2/">AI Paper Review: Language Models are Unsupervised Multitask Learners (GPT-2)</a></p>
</li>
<li><p><a href="https://www.freecodecamp.org/news/ai-paper-review-language-models-are-few-shot-learners-gpt-3/">AI Paper Review: Language Models are Few-Shot Learners (GPT-3)</a></p>
</li>
<li><p><a href="https://www.freecodecamp.org/news/ai-paper-review-training-language-models-to-follow-instructions-with-human-feedback-instructgpt/">AI Paper Review: Training Language Models to Follow Instructions with Human Feedback (InstructGPT)</a></p>
</li>
</ul>
<p>The GPT-3 review is particularly important because the Chain-of-Thought paper builds directly on one of GPT-3's most surprising capabilities: in-context learning. Rather than changing the model architecture or retraining the model, the authors discovered that reasoning performance could be dramatically improved simply by changing how examples were presented in the prompt.</p>
<p>It also helps to have:</p>
<ul>
<li><p>A general understanding of natural language processing (NLP) and large language models</p>
</li>
<li><p>A basic understanding of Transformer-based autoregressive models</p>
</li>
<li><p>Familiarity with prompting, few-shot learning, and in-context learning</p>
</li>
<li><p>A high-level understanding of how language models generate text token by token</p>
</li>
<li><p>General machine learning concepts such as training, inference, scaling laws, and model evaluation</p>
</li>
<li><p>Some exposure to reasoning tasks, logic problems, and mathematical word problems</p>
</li>
<li><p>A basic understanding of benchmark datasets and model performance evaluation</p>
</li>
</ul>
<p>You don't need a deep background in mathematics or machine learning research to follow this article.</p>
<p>I'll keep the explanations intuitive and practical, focusing on why Chain-of-Thought prompting became one of the most influential reasoning techniques in modern AI and how a simple prompting strategy changed the way researchers think about language model reasoning.</p>
<h2 id="heading-abstract"><strong>Abstract</strong></h2>
<p>One of the long-standing challenges for large language models has been reasoning. While these models can generate fluent text and answer a wide variety of questions, they often struggle when a task requires multiple logical steps.</p>
<p>This paper introduces a remarkably simple idea to address that limitation: instead of prompting a model with only questions and answers, you should provide examples that also include the intermediate reasoning steps leading to the solution.</p>
<p>The authors call this approach Chain-of-Thought (CoT) Prompting. By showing a model a few demonstrations of step-by-step reasoning, they find that sufficiently large language models can generate their own reasoning chains and solve complex problems more effectively. Importantly, this improvement doesn't require additional training or fine-tuning, only a different style of prompting.</p>
<p>Through experiments on arithmetic, common sense, and symbolic reasoning tasks, the paper demonstrates that chain-of-thought prompting consistently improves performance. The gains become especially pronounced at larger model scales, suggesting that reasoning abilities emerge naturally as models grow and are given the right prompting strategy.</p>
<p>The paper's most striking result comes from the GSM8K math benchmark, where PaLM 540B, using only eight chain-of-thought examples, achieved state-of-the-art performance and even surpassed a fine-tuned GPT-3 system equipped with a verifier. This finding revealed that prompting alone could unlock reasoning capabilities that standard prompting often fails to expose.</p>
<p>The figure below compares standard prompting with Chain-of-Thought (CoT) prompting using a simple arithmetic example.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ce92860ff860b6de01ed93/126da3c8-fa3f-4207-8d86-723c576d80d5.png" alt="Standard prompting vs chain of thought prompting" style="display:block;margin:0 auto" width="1853" height="835" loading="lazy">

<p>Source: <a href="https://arxiv.org/pdf/2201.11903">Chain-of-Thought Prompting Elicits Reasoning in Large Language Models</a></p>
<p>In standard prompting, the model is shown question–answer pairs and is expected to produce an answer directly, which can lead to mistakes on multi-step problems.</p>
<p>In Chain-of-Thought prompting, the examples include intermediate reasoning steps before the final answer. When faced with a new problem, the model follows a similar step-by-step process, arriving at the correct solution.</p>
<p>This paper shows that providing reasoning demonstrations can substantially improve performance on arithmetic, common sense, and symbolic reasoning tasks, particularly in large language models.</p>
<h2 id="heading-introduction"><strong>Introduction</strong></h2>
<p>By 2022, large language models had already transformed natural language processing. Models such as GPT-3 demonstrated that scaling model size could unlock impressive capabilities, from text generation to few-shot learning.</p>
<p>But there was an important limitation: larger models weren't necessarily better at reasoning. Tasks that required multi-step arithmetic, common sense inference, or symbolic manipulation remained surprisingly difficult, even for some of the largest models available.</p>
<p>The authors begin by observing two promising research directions. The first comes from prior work showing that reasoning tasks can benefit from natural language explanations or intermediate solution steps. Instead of jumping directly to an answer, a model can generate a rationale that mirrors how a human might solve the problem.</p>
<p>The second direction is few-shot prompting, where a model learns a task from a handful of examples provided in the prompt, eliminating the need for task-specific fine-tuning.</p>
<p>Still, both approaches have drawbacks. Training models on large collections of human-written rationales is expensive and time-consuming, while standard few-shot prompting often struggles on tasks that require genuine reasoning.</p>
<p>The key insight of this paper was to combine the strengths of both ideas. Rather than providing only input-output examples, the prompt includes an additional component: the reasoning process itself. Each example follows the structure of <em>input → chain of thought → output</em>.</p>
<p>This simple modification led to Chain-of-Thought Prompting. By exposing intermediate reasoning steps, the model is encouraged to break complex problems into smaller, more manageable stages before arriving at a final answer.</p>
<p>To evaluate the idea, the authors tested chain-of-thought prompting across arithmetic, common sense, and symbolic reasoning benchmarks. The results showed substantial improvements over standard prompting, with some gains being remarkably large.</p>
<h2 id="heading-chain-of-thought-prompting"><strong>Chain-of-Thought Prompting</strong></h2>
<p>At the heart of this paper is a simple observation about how humans solve difficult problems. When faced with a multi-step reasoning task, we rarely jump directly to the answer. Instead, we break the problem into smaller pieces, solve each intermediate step, and gradually work toward a conclusion. The authors argued that large language models could benefit from a similar process.</p>
<p>This idea led to Chain-of-Thought (CoT) Prompting, where examples in the prompt included not only the question and answer, but also the reasoning steps connecting them. By seeing a few demonstrations of this reasoning process, sufficiently large language models learned to generate their own chains of thought before producing a final answer.</p>
<p>The significance of this approach extends beyond improving accuracy. First, it allows complex problems to be decomposed into manageable intermediate steps, making multi-step reasoning easier to perform.</p>
<p>Second, the generated reasoning process offers a degree of interpretability, giving researchers and users a glimpse into how the model arrived at its answer. While these reasoning traces don't fully reveal the model's internal computations, they can help identify where mistakes occur.</p>
<p>Another important aspect of chain-of-thought prompting is its generality. The authors proposed it not as a solution for a single benchmark, but as a broad reasoning framework that can be applied to arithmetic problems, common sense reasoning tasks, symbolic manipulation, and potentially many other challenges that require sequential reasoning.</p>
<p>Perhaps most importantly, this capability can be elicited from existing language models through prompting alone, without additional training or architectural modifications.</p>
<p>This section establishes the paper's central claim: reasoning abilities don't necessarily require new model architectures or specialized fine-tuning. In sufficiently large language models, these capabilities can emerge when the model is guided to generate intermediate reasoning steps rather than being asked to produce an answer immediately.</p>
<h2 id="heading-arithmetic-reasoning"><strong>Arithmetic Reasoning</strong></h2>
<p>The authors begin their empirical evaluation with arithmetic reasoning, a domain that had long exposed a weakness of large language models.</p>
<p>Although solving math word problems is relatively straightforward for humans, it often requires a sequence of intermediate calculations and logical deductions.</p>
<p>Previous research had shown that even large language models struggled with these tasks, making arithmetic reasoning an ideal setting for testing whether chain-of-thought prompting could genuinely improve reasoning ability.</p>
<p>To evaluate their approach, the authors selected five established benchmarks covering a variety of math word problems. These datasets differ in style and difficulty, ranging from straightforward arithmetic questions to more complex problems that require multiple reasoning steps before arriving at a solution. Together, they provide a broad picture of how well language models handle mathematical reasoning.</p>
<p>The experiments compare two prompting strategies. The first is standard few-shot prompting, where the model is shown examples consisting only of questions and their corresponding answers. This was the dominant prompting approach at the time and serves as the baseline throughout the paper.</p>
<p>The second is chain-of-thought prompting, where each example is expanded to include the intermediate reasoning steps that connect the question to the final answer.</p>
<p>To ensure a fair comparison, the authors manually created a small set of eight reasoning demonstrations and reused them across the arithmetic benchmarks. Importantly, these examples weren't heavily optimized or engineered for specific datasets. Instead, they were intended to test whether a modest number of natural reasoning demonstrations could reliably encourage models to reason through new problems on their own.</p>
<p>The study also evaluates a diverse collection of language models, including GPT-3, LaMDA, PaLM, UL2, and Codex, spanning model sizes from hundreds of millions to hundreds of billions of parameters. This broad range allowed the authors to examine not only whether chain-of-thought prompting works, but also how its effectiveness changes as models become larger.</p>
<p>With this experimental framework in place, the paper investigated a central question: can providing a few examples of step-by-step reasoning enable large language models to solve mathematical problems that standard prompting struggles to handle?</p>
<h2 id="heading-results">Results</h2>
<p>The arithmetic reasoning experiments revealed that the success of chain-of-thought prompting depends heavily on model scale.</p>
<p>One of the clearest patterns across the benchmarks was that smaller models gained little benefit from generating reasoning steps. In some cases, their performance even deteriorated because the models produced explanations that sounded plausible but were logically flawed.</p>
<p>The advantages of chain-of-thought prompting only became apparent once the models reached very large scales, suggesting that the ability to effectively use intermediate reasoning steps is itself an emergent capability.</p>
<p>Another important observation was that the benefits of chain-of-thought prompting grew as problems became more challenging. On simpler tasks that required only a single reasoning step, standard prompting was already sufficient and the additional reasoning process provided little value.</p>
<p>But as the complexity of the problems increased, the gap between standard prompting and chain-of-thought prompting widened substantially. The GSM8K benchmark provides the strongest example of this trend, where the largest GPT and PaLM models more than doubled their performance when allowed to reason step by step.</p>
<p>Perhaps the most significant result is that chain-of-thought prompting enabled large language models to compete with, and in some cases surpass, specialized systems trained directly for these tasks.</p>
<p>Using only a handful of reasoning demonstrations, PaLM 540B established new state-of-the-art results on several arithmetic benchmarks, despite relying solely on prompting rather than task-specific fine-tuning. This outcome challenged the prevailing assumption that strong performance on reasoning tasks necessarily required dedicated training datasets and specialized models.</p>
<p>To better understand these improvements, the authors manually inspected the reasoning traces generated by the models. When the model arrived at the correct answer, the reasoning process was usually correct as well, indicating that the model was often following a coherent sequence of logical steps rather than guessing the final answer.</p>
<p>Even among incorrect predictions, many reasoning chains were largely accurate and failed only because of small mistakes such as arithmetic slips, incorrect symbol mappings, or a missing intermediate step. More serious failures tended to arise from misunderstanding the problem itself or producing incoherent reasoning.</p>
<p>The error analysis also offered an explanation for why larger models benefited more from chain-of-thought prompting. Comparing PaLM 62B with PaLM 540B showed that increasing scale reduced many of the semantic misunderstandings and incomplete reasoning patterns that appeared in smaller models.</p>
<p>In other words, larger models were not merely generating longer explanations. They were producing reasoning chains that were more logically complete and more faithful to the underlying problem.</p>
<h2 id="heading-ablation-study"><strong>Ablation Study</strong></h2>
<p>Before diving into this section, it's worth briefly explaining what an ablation study is. In machine learning research, an ablation study systematically removes or modifies parts of a method to determine which components are actually responsible for its performance. Rather than asking whether a method works, an ablation study asks why it works.</p>
<p>In this paper, the authors use ablation experiments to identify which aspects of Chain-of-Thought prompting contribute most to its reasoning improvements.</p>
<p>After demonstrating that chain-of-thought prompting improved reasoning performance, the authors turned to a more fundamental question: why does it work? Simply observing higher accuracy isn't enough. To understand the source of these gains, they designed a series of ablation experiments that isolated different aspects of the prompting strategy.</p>
<p>One possible explanation is that chain-of-thought prompting helps because it encourages the model to generate mathematical equations before producing an answer. If this were true, then the natural language reasoning itself might not be necessary.</p>
<p>To test this idea, the authors replaced the reasoning steps with equations alone. The results showed that this approach provides only limited benefits on complex benchmarks such as GSM8K. While equations can help with simpler problems, they are often insufficient for tasks that require understanding the meaning of the question before translating it into mathematical operations. This suggests that the value of chain-of-thought prompting comes from more than symbolic calculation.</p>
<p>The authors then examined another hypothesis: perhaps chain-of-thought prompting succeeds simply because it allows the model to generate more tokens and therefore spend more computation on difficult problems.</p>
<p>To isolate this factor, they created a prompt that produces additional tokens without any meaningful reasoning content. Performance remained close to the standard prompting baseline, indicating that extra computation alone doesn't explain the observed improvements. What mattered wasn't the number of intermediate tokens, but the reasoning expressed within them.</p>
<p>A third possibility was that chain-of-thought prompts merely activated relevant knowledge already stored in the model. If that were the case, the reasoning steps wouldn't need to appear before the answer.</p>
<p>The authors tested this by moving the reasoning process to after the final answer. Once again, performance largely fell back to the baseline. This result suggested that the sequence of reasoning steps plays an active role in helping the model arrive at the correct solution rather than simply serving as an explanation after the fact.</p>
<p>Taken together, these experiments strengthen the paper's central argument. The success of chain-of-thought prompting can't be explained by equation generation, additional computation, or easier access to stored knowledge alone.</p>
<p>Instead, the evidence points toward the reasoning process itself as the critical ingredient. The intermediate steps aren't merely decorative explanations. They appear to guide the model through a sequence of decisions that makes complex problem solving more effective.</p>
<h2 id="heading-robustness-of-chain-of-thought-prompting"><strong>Robustness of Chain-of-Thought Prompting</strong></h2>
<p>One of the long-standing concerns with prompting methods is their sensitivity to the examples included in the prompt. Small changes in wording, example selection, or even the order of examples can sometimes produce noticeably different results.</p>
<p>Once they established that chain-of-thought prompting improves reasoning performance, the authors investigated whether these gains were robust or whether they depended on a particular set of carefully crafted demonstrations.</p>
<p>To answer this question, the researchers asked multiple authors of the paper to independently write reasoning traces for the same examples. They also experimented with a more concise writing style and tested prompts built from entirely different sets of examples.</p>
<p>The goal was to determine whether chain-of-thought prompting was succeeding because of a specific wording choice or because the underlying reasoning structure was genuinely useful.</p>
<p>The results provided reassuring evidence that the technique isn't tied to a particular author, writing style, or collection of exemplars. While some variation in performance naturally appeared across different prompts, every version of chain-of-thought prompting consistently outperformed standard prompting by a substantial margin. Whether the reasoning steps were detailed or concise, manually written or drawn from an independent dataset, the overall pattern remained remarkably stable.</p>
<p>The authors further broadened their analysis by varying the order and number of exemplars used in the prompt. Once again, the central finding persisted: although prompt design still influenced performance to some degree, the effectiveness of chain-of-thought prompting didn't depend on a single carefully engineered prompt.</p>
<p>This robustness analysis strengthens one of the paper's most important claims that the success of chain-of-thought prompting isn't an artifact of a particular phrasing or annotation style. Instead, the benefits appear to arise from exposing the model to a reasoning process itself, suggesting that the method captures a more general principle rather than a prompt-specific trick.</p>
<h2 id="heading-common-sense-reasoning"><strong>Common Sense Reasoning</strong></h2>
<p>Up to this point, the paper focused primarily on mathematical reasoning. While the results are impressive, they leave an important question unanswered: is chain-of-thought prompting useful only for arithmetic problems, or can it improve reasoning more broadly?</p>
<p>To investigate this, the authors turned to common sense reasoning tasks. Unlike math problems, these tasks often require background knowledge about the world, an understanding of human behavior, or the ability to connect multiple pieces of information before arriving at a conclusion. In many cases, the challenge isn't performing calculations but reasoning through situations that humans find intuitive.</p>
<p>The evaluation spanned a diverse collection of benchmarks, including common sense question answering, multi-hop reasoning, date understanding, sports-related reasoning, and even tasks that involved converting natural language instructions into robot actions.</p>
<p>Despite their differences, these tasks share a common requirement: solving them often involves a sequence of intermediate inferences rather than an immediate answer.</p>
<p>The results showed that the benefits of chain-of-thought prompting extend well beyond mathematics. Across most benchmarks, models consistently performed better when encouraged to generate intermediate reasoning steps before producing a final answer.</p>
<p>The improvements became particularly noticeable for larger models, suggesting that the same pattern observed in arithmetic reasoning also applies to common sense reasoning.</p>
<p>Some of the strongest gains appeared on tasks that required multi-step inference. On StrategyQA, for example, chain-of-thought prompting enabled PaLM 540B to surpass the previous state of the art. Similarly, on the Sports Understanding benchmark, the model achieved performance that exceeded that of an unaided human sports enthusiast.</p>
<p>These results suggest that the reasoning process encouraged by chain-of-thought prompting can help models connect facts, evaluate plausibility, and navigate more complex decision-making scenarios.</p>
<p>At the same time, the improvements weren't uniform across every dataset. The gains on CommonsenseQA were relatively modest, indicating that not all reasoning tasks benefit equally from explicit reasoning traces. This serves as an early reminder that chain-of-thought prompting isn't a universal solution, even though it consistently proves valuable across a wide range of settings.</p>
<p>More broadly, this section strengthens the paper's central argument by showing that chain-of-thought prompting isn't merely a technique for solving math word problems. Its effectiveness across diverse common sense tasks suggests that the method taps into a more general reasoning capability that emerges in sufficiently large language models.</p>
<h2 id="heading-symbolic-reasoning"><strong>Symbolic Reasoning</strong></h2>
<p>The final evaluation moves away from mathematics and real-world knowledge altogether. Instead, the authors focus on symbolic reasoning tasks, where success depends on following abstract rules rather than recalling facts or performing calculations. These tasks are simple for humans, yet they provide a useful way to test whether language models can consistently apply a sequence of reasoning steps.</p>
<p>To explore this question, the authors designed two controlled tasks. The first required the model to extract and concatenate the last letters of words in a name. The second asked the model to track the state of a coin after a sequence of flips and non-flips.</p>
<p>Although these tasks may appear simple, they required the model to perform precise symbolic manipulations without relying on memorized knowledge about the world.</p>
<p>What made these experiments particularly interesting was the introduction of an out-of-distribution setting. During prompting, the model only saw examples involving short reasoning chains. At evaluation time, it was asked to solve versions of the same tasks that required more steps than any example it had previously encountered.</p>
<p>This setup allowed the authors to test not only whether the model could follow a reasoning procedure, but also whether it could extend that procedure to longer and unfamiliar cases.</p>
<p>The results revealed a familiar pattern. Large models benefitted substantially from chain-of-thought prompting, while smaller models struggled even when the required reasoning process was straightforward.</p>
<p>On the in-domain tasks, where the evaluation closely matched the examples provided in the prompt, the largest models achieved near-perfect performance when guided by chain-of-thought reasoning. This indicated that they could successfully learn and apply the underlying procedure demonstrated in the prompt.</p>
<p>The more revealing results come from the out-of-distribution evaluations. Standard prompting largely fails when the reasoning chain becomes longer than those seen in the examples. In contrast, chain-of-thought prompting enabled performance to improve as model size increased, demonstrating an ability to extend learned reasoning patterns beyond the exact situations shown during prompting.</p>
<p>Although accuracy declines compared to the in-domain setting, the models were still able to generalize in ways that standard prompting couldn't.</p>
<p>This section provided some of the strongest evidence that chain-of-thought prompting is doing more than improving benchmark performance. By helping models apply reasoning procedures to longer and previously unseen inputs, it suggests that the generated reasoning steps serve as a scaffold for systematic problem solving rather than merely a mechanism for producing better answers on familiar examples.</p>
<h2 id="heading-discussion"><strong>Discussion</strong></h2>
<p>The most important contribution of this paper wasn't a new model architecture, a new training objective, or a larger dataset. Instead, it demonstrated that a simple change in prompting could unlock capabilities that standard prompting often failed to reveal.</p>
<p>Across arithmetic, common sense, and symbolic reasoning tasks, chain-of-thought prompting consistently allowed large language models to solve problems that were previously difficult or inaccessible.</p>
<p>A recurring theme throughout the paper was the relationship between reasoning and scale. The authors repeatedly observed that chain-of-thought prompting became effective only once models reached a sufficient size. Smaller models generated fluent reasoning traces, but those traces were often logically inconsistent.</p>
<p>Larger models, in contrast, were able to use intermediate reasoning steps in a way that genuinely improved problem-solving performance.</p>
<p>This finding reinforced a broader lesson emerging from language model research at the time: some capabilities don't appear gradually, but emerge once a model crosses a certain scale threshold.</p>
<p>Perhaps the most intriguing implication was that standard prompting may significantly underestimate what large language models are capable of doing.</p>
<p>Before this work, many reasoning tasks appeared to have reached a performance ceiling. Chain-of-thought prompting revealed that the limitation wasn't always the model itself, but sometimes the way the model was being asked to solve the problem. In that sense, the paper shifted attention from building more capable models to discovering better ways of interacting with the capabilities that already exist within them.</p>
<p>At the same time, the authors were careful not to overstate their conclusions. Although chain-of-thought outputs can resemble human reasoning, the paper doesn't prove that language models reason in the same way humans do. The generated reasoning traces may reflect genuine problem-solving processes, post-hoc rationalizations, or something in between. Determining the relationship between generated reasoning and internal model computation remains an open research question.</p>
<p>The authors also acknowledged several practical limitations. Constructing high-quality reasoning demonstrations can require additional effort, particularly if the approach is extended beyond few-shot prompting.</p>
<p>Also, generating a chain of thought doesn't guarantee that the reasoning itself is correct. Models can still produce convincing but flawed reasoning paths, leading to incorrect answers.</p>
<p>Finally, the strongest benefits appear only in very large models, raising questions about computational cost and whether similar reasoning abilities can be induced in smaller systems.</p>
<p>Viewed from a historical perspective, this paper marked a turning point in research on language model reasoning. Rather than treating reasoning as something that must be explicitly trained into a model, it suggested that reasoning abilities could be elicited through the right prompting strategy.</p>
<p>Many influential ideas that followed, including self-consistency, reasoning supervision, process supervision, and the reasoning-focused models that emerged in later years, can trace part of their intellectual foundation back to the simple insight introduced here: sometimes a model performs better when it's encouraged to show its work.</p>
<h2 id="heading-related-work"><strong>Related Work</strong></h2>
<p>The ideas behind Chain-of-Thought prompting didn't emerge in isolation. Instead, the paper sits at the intersection of two research directions that had been evolving independently for several years.</p>
<p>The first direction focused on helping models solve complex problems through intermediate reasoning steps. Earlier work had already shown that tasks such as mathematical reasoning become easier when a model generates natural language rationales rather than producing an answer directly. Researchers explored methods that trained models to generate explanations, reasoning traces, or intermediate computations before arriving at a final solution.</p>
<p>Other approaches relied on formal symbolic representations, translating problems into structured equations or logical forms. Despite their differences, these efforts shared a common intuition: difficult reasoning tasks are often easier to solve when they're decomposed into smaller steps.</p>
<p>Chain-of-thought prompting inherits this intuition but introduces an important shift. Earlier methods typically required dedicated training procedures, specialized datasets, or task-specific fine-tuning.</p>
<p>In contrast, this paper demonstrated that reasoning traces could be elicited through prompting alone. Rather than teaching a model to reason through additional training, the authors showed that providing a handful of reasoning examples may be enough to unlock capabilities that already exist within sufficiently large language models.</p>
<p>The second research direction concerns prompting itself. Following the success of GPT-3 and few-shot learning, a growing body of work explored how prompts could be used to improve model performance without retraining.</p>
<p>Researchers experimented with prompt engineering, prompt tuning, and natural language instructions to better communicate tasks to language models. Most of these techniques focused on improving the input side of the interaction by changing how a task was described to the model.</p>
<p>Chain-of-thought prompting takes a different approach. Instead of modifying the instructions that precede a task, it augments the examples that follow them by exposing the reasoning process that connects inputs and outputs. This distinction may seem subtle, but it represents one of the paper's key insights: the contribution goes beyond a better prompt template. It focuses on the realization that demonstrating how to reason can be just as important as describing what task should be solved.</p>
<p>Viewed in this broader context, the paper acts as a bridge between research on reasoning traces and research on prompting. It combines the strengths of both traditions and, in doing so, lays the foundation for many later advances in language model reasoning, including self-consistency, STaR, process supervision, and the reasoning-oriented systems that followed in subsequent years.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Chain-of-Thought Prompting introduced a simple idea that changed how researchers think about reasoning in large language models. Rather than modifying model architectures or relying on additional training, the authors showed that reasoning abilities could often be unlocked by encouraging models to generate intermediate reasoning steps before producing an answer.</p>
<p>Across arithmetic, common sense, and symbolic reasoning tasks, the results demonstrated that large language models become significantly more capable when allowed to work through a problem step by step. More importantly, the paper revealed that many of these improvements emerge at larger scales, suggesting that reasoning isn't simply a product of prompting but a capability that becomes increasingly accessible as models grow more powerful.</p>
<p>What made this work particularly influential wasn't the complexity of the method, but the insight behind it. A model may possess the knowledge required to solve a problem, yet still fail to use that knowledge effectively when asked for an immediate answer. By exposing the reasoning process, Chain-of-Thought prompting showed that how a model arrives at an answer can be just as important as the answer itself.</p>
<p>This idea helped shift the focus of AI research beyond what language models know toward how they reason, plan, and solve problems. Many of the techniques that followed (including Self-Consistency, process supervision, verification-based methods, and modern reasoning-focused systems) build upon the foundation established by this paper.</p>
<p>Viewed in retrospect, Chain-of-Thought Prompting was more than a prompting technique. It marked a turning point in the study of language model reasoning, demonstrating that some capabilities aren't absent from a model but simply require the right conditions to emerge.</p>
<p>The infographic below highlights some of the most influential papers and milestones that shaped modern AI, from the introduction of GPT-1 and the scaling era of GPT-2 and GPT-3, to instruction tuning, Chain-of-Thought reasoning, Self-Consistency, process supervision, and the latest generation of reasoning-focused models. Together, these works reveal how the field evolved from teaching models to predict language toward helping them reason, verify, and solve increasingly complex problems.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ce92860ff860b6de01ed93/6d03f50e-e3d7-4370-94b7-6f5a9a5cd201.png" alt="The GPT Journey Key Papers That Shaped Modern AI" style="display:block;margin:0 auto" width="2320" height="1480" loading="lazy">

<h2 id="heading-resources">Resources</h2>
<ul>
<li><p><a href="https://github.com/MOHAMMEDFAHD/Pytorch-Collections/tree/main/GPT">Pytorch Projects for GPT series</a></p>
</li>
<li><p><a href="https://arxiv.org/abs/1706.03762">Attention Is All You Need</a></p>
</li>
<li><p><a href="https://cdn.openai.com/research-covers/language-unsupervised/language_understanding_paper.pdf">Improving Language Understanding by Generative Pre-Training (GPT-1)</a></p>
</li>
<li><p><a href="https://cdn.openai.com/better-language-models/language_models_are_unsupervised_multitask_learners.pdf">Language Models are Unsupervised Multitask Learners (GPT-2)</a></p>
</li>
<li><p><a href="https://arxiv.org/abs/2005.14165">Language Models are Few-Shot Learners (GPT-3)</a></p>
</li>
<li><p><a href="https://arxiv.org/pdf/2001.08361">Scaling Laws for Neural Language Models</a></p>
</li>
<li><p><a href="https://arxiv.org/pdf/2203.02155">Training Language Models to Follow Instructions with Human Feedback (InstructGPT)</a></p>
</li>
<li><p><a href="https://arxiv.org/pdf/2109.01652">Finetuned Language Models are Zero-Shot Learners (FLAN)</a></p>
</li>
<li><p><a href="https://arxiv.org/pdf/2201.08239">LaMDA: Language Models for Dialog Applications</a></p>
</li>
<li><p><a href="https://arxiv.org/pdf/2204.02311">PaLM: Scaling Language Modeling with Pathways</a></p>
</li>
<li><p><a href="https://arxiv.org/pdf/1705.04146">Program Induction by Rationale Generation: Learning to Solve and Explain Algebra Word Problems</a></p>
</li>
<li><p><a href="https://arxiv.org/pdf/2110.14168">Training Verifiers to Solve Math Word Problems</a></p>
</li>
<li><p><a href="https://arxiv.org/pdf/2112.00114">Show Your Work: Scratchpads for Intermediate Computation with Language Models</a></p>
</li>
<li><p><a href="https://arxiv.org/pdf/2203.11171">Self-Consistency Improves Chain of Thought Reasoning in Language Models</a></p>
</li>
<li><p><a href="https://arxiv.org/pdf/2203.14465">STaR: Bootstrapping Reasoning with Reasoning</a></p>
</li>
<li><p><a href="https://arxiv.org/pdf/2206.07682">Emergent Abilities of Large Language Models</a></p>
</li>
<li><p><a href="https://arxiv.org/pdf/2303.12712">Sparks of Artificial General Intelligence: Early Experiments with GPT-4</a></p>
</li>
<li><p><a href="https://arxiv.org/pdf/2305.20050">Let's Verify Step by Step</a></p>
</li>
<li><p><a href="https://arxiv.org/pdf/2503.19470">Learning to Reason with LLMs</a></p>
</li>
<li><p><a href="https://arxiv.org/pdf/2303.08774">GPT-4 Technical Report</a></p>
</li>
</ul>
<p><strong>Contact Me</strong></p>
<ul>
<li><p><a href="https://github.com/MOHAMMEDFAHD"><strong>Github</strong></a></p>
</li>
<li><p><a href="https://x.com/programmingoce"><strong>X</strong></a></p>
</li>
<li><p><a href="https://www.linkedin.com/in/mohammed-abrah-6435a63ba/"><strong>Linkedin</strong></a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Run Private Text-to-Speech on Your Own Hardware Using QVAC ]]>
                </title>
                <description>
                    <![CDATA[ When I was putting the final touches on QuizRope, an educational mobile app I built that uses LLMs for real-time tutoring and homework assistance, I knew the next logical step was voice. Reading text  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-run-private-text-to-speech-on-your-own-hardware-using-qvac/</link>
                <guid isPermaLink="false">6a2e0cb22e4a72670f854140</guid>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React Native ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TextToSpeech ]]>
                    </category>
                
                    <category>
                        <![CDATA[ privacy ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Jibril-M🍀 ]]>
                </dc:creator>
                <pubDate>Sun, 14 Jun 2026 02:06:42 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/3ac11484-05eb-4e59-9d35-f2bad4d1d730.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>When I was putting the final touches on <a href="https://github.com/DjibrilM/Quiz-rope-">QuizRope</a>, an educational mobile app I built that uses LLMs for real-time tutoring and homework assistance, I knew the next logical step was voice. Reading text on a screen is great, but having an AI tutor physically <em>speak</em> to you transforms the entire learning experience.</p>
<p>Naturally, my first instinct was to look at cloud providers. While services like ElevenLabs offer incredible voice quality, I quickly ran the numbers. Between the API pricing, token consumption for lengthy tutoring sessions, and the sheer volume of users I anticipated, the math got ugly very quickly. Relying on a paid API for every single sentence spoken within the app simply wasn't sustainable for an independent developer.</p>
<p>If you’re about to ask, "How far did you get with QuizRope?", well honestly, I straight-up gave up on the project back then because I couldn't find a sane, affordable solution for the TTS feature.</p>
<p>Beyond the prohibitive cost, there was the latency. Waiting for a server to process a prompt, generate the audio, and stream it back down to a mobile device completely breaks the conversational illusion. And worst of all, it meant every question a student asked would be beamed to a third-party server.</p>
<p>That frustration became the catalyst for my search to find a reliable, offline, and completely zero-cost solution.</p>
<p>In this article, we’re going to build a React Native application that performs high-fidelity Text-to-Speech (TTS) completely offline using your device's own hardware.</p>
<p>If you haven't set up your environment or need a refresher on local inference fundamentals, I highly recommend reading my previous article, <a href="https://www.freecodecamp.org/news/how-to-run-an-llm-locally-on-your-mobile-phone-with-qvac-and-expo/">How to Run a Local LLM Offline in React Native with QVAC</a>, where I cover project initialization, prebuilding, and native hardware dependencies.</p>
<p>This guide assumes you already have a project with the QVAC SDK configured and ready to run on a physical device.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-is-qvac">What is QVAC?</a></p>
</li>
<li><p><a href="#heading-the-architecture-supported-by-qvac">The Architecture Supported by QVAC</a></p>
</li>
<li><p><a href="#heading-the-inference-pipeline">The Inference Pipeline</a></p>
</li>
<li><p><a href="#heading-environment-and-dependency-config">Environment and Dependency Config</a></p>
</li>
<li><p><a href="#heading-the-audio-utility-packaging">The Audio Utility Packaging</a></p>
</li>
<li><p><a href="#heading-complete-implementation">Complete Implementation</a></p>
</li>
<li><p><a href="#heading-codebase-breakdown">Codebase Breakdown</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-resources-and-further-reading">Resources and Further Reading</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To get the most out of this article, you should have a solid foundation in modern web and mobile development:</p>
<ul>
<li><p><strong>JavaScript/TypeScript &amp; React</strong>: Familiarity with React concepts and hooks, especially <code>useState</code>, <code>useEffect</code>, and <code>useRef</code>.</p>
</li>
<li><p><strong>React Native &amp; Expo</strong>: Basic understanding of layout structures (such as <code>View</code>, <code>ScrollView</code>, <code>TextInput</code>) and styling conventions.</p>
</li>
<li><p><strong>Asynchronous JavaScript &amp; Binary Buffers</strong>: Experience with <code>async/await</code>, Promises, and basic manipulation of arrays like <code>Int16Array</code> or <code>Buffer</code>.</p>
</li>
<li><p><strong>Development Build Environment</strong>: Familiarity with running local development compilation commands, specifically <code>npx expo prebuild</code> to build native iOS and Android modules.</p>
</li>
<li><p><strong>Physical Mobile Device</strong>: Because local machine learning models leverage device-specific hardware acceleration and native optimizations, the QVAC SDK doesn't support simulator environments. You must have a physical iOS or Android testing device with Developer Mode enabled.</p>
</li>
</ul>
<h2 id="heading-what-is-qvac">What is QVAC?</h2>
<p>To help you follow along more effectively, let’s establish what QVAC is and why it exists.</p>
<p>Developed by Tether, QVAC is a local-first AI SDK designed for building cross-platform, peer-to-peer (P2P) applications and systems.</p>
<p>Many mobile applications that utilize Large Language Models (LLMs) or Text-to-Speech (TTS) engines rely on network requests to cloud-hosted APIs (such as OpenAI or ElevenLabs). While convenient, this model introduces dependencies on network connectivity, recurring API usage fees, and transmission of user data to third-party servers.</p>
<p>QVAC provides an alternative by executing AI models directly on the client device. This local-first architecture offers several practical advantages:</p>
<ul>
<li><p><strong>Local-first execution</strong>: Runs inference directly on the client hardware, eliminating the need for external APIs or active internet connections.</p>
</li>
<li><p><strong>Peer-to-peer (P2P) support</strong>: Allows distributing inference tasks across local networks, helping coordinate workloads without centralized servers.</p>
</li>
<li><p><strong>Cross-platform compatibility</strong>: Provides a single JavaScript/TypeScript interface that works consistently across different hardware and runtime environments.</p>
</li>
<li><p><strong>Unified capabilities</strong>: Exposes text generation, transcription, image generation, and speech synthesis within a single package.</p>
</li>
</ul>
<h3 id="heading-key-concepts-for-on-device-inference">Key Concepts for On-Device Inference</h3>
<p>To understand how QVAC runs on a mobile device, we must keep a few key concepts in mind:</p>
<ul>
<li><p><strong>On-Device Inference</strong>: Running model calculations locally. Rather than relying on a single engine, QVAC supports multiple specialized local inference backends depending on the task (such as <code>llama.cpp</code> for text, <code>whisper.cpp</code> for transcription, or custom diffusion backends for image generation). Under the hood, these engines memory-map quantized model weights directly into the device's RAM and run calculations using native GPU hardware acceleration.</p>
</li>
<li><p><strong>Quantization (GGUF format)</strong>: A mathematical optimization technique that compresses the model's weights (for example, from a standard 16-bit floating-point precision down to 4-bit or 8-bit integers). This makes it possible for models to fit into the memory constraints of consumer mobile hardware while keeping output quality high.</p>
</li>
<li><p><strong>KV (Key-Value) Cache</strong>: A memory area that stores calculated states of previous tokens so the model doesn't have to re-evaluate the entire context window with every word or token it generates.</p>
</li>
</ul>
<h2 id="heading-the-architecture-supported-by-qvac">The Architecture Supported by QVAC</h2>
<p>Before writing code, it's crucial to understand what's actually happening under the hood. To handle local execution without melting your device, the QVAC SDK manages the hardware binding and model lifecycle while hooking into optimized, community-maintained <a href="https://huggingface.co/blog/introduction-to-ggml"><strong>GGML</strong></a> inference backends.</p>
<p>Instead of a one-size-fits-all approach, the QVAC SDK supports two distinctly different neural architectures for speech synthesis. Depending on your application's needs — whether you want instant voice cloning or ultra-high-fidelity pre-trained voices — you'll choose between <strong>Chatterbox</strong> and <strong>Supertonic</strong>.</p>
<table>
<thead>
<tr>
<th>Feature</th>
<th>Chatterbox</th>
<th>Supertonic</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Architecture</strong></td>
<td>Transformer-based language model</td>
<td>Diffusion-based latent denoising</td>
</tr>
<tr>
<td><strong>Model Structure</strong></td>
<td>Split (T3 GGUF + S3Gen companion)</td>
<td>Single file (GGUF)</td>
</tr>
<tr>
<td><strong>Voice Method</strong></td>
<td>Zero-shot voice cloning (Reference WAV)</td>
<td>Pre-trained voice styles</td>
</tr>
<tr>
<td><strong>Sample Rate</strong></td>
<td>24,000 Hz</td>
<td>44,100 Hz</td>
</tr>
</tbody></table>
<h3 id="heading-1-the-chatterbox-engine">1. The Chatterbox Engine</h3>
<p>Chatterbox is built on a <strong>transformer-based language model</strong> architecture. It treats audio generation similarly to how an LLM predicts the next word in a sentence, but instead, it predicts discrete acoustic tokens.</p>
<p>Because of this architecture, Chatterbox excels at <strong>zero-shot voice cloning</strong>. Instead of relying purely on pre-baked voices, you can pass an optional <code>referenceAudioSrc</code> (a short WAV file of someone speaking) alongside your text. The transformer analyzes the reference audio's acoustic properties and generates a cloned voice based on those features.</p>
<h3 id="heading-2-the-supertonic-engine">2. The Supertonic Engine</h3>
<p>Supertonic takes a completely different approach, utilizing <a href="https://www.emergentmind.com/topics/latent-denoising-diffusion-models"><strong>diffusion-based latent denoising</strong></a> — the same fundamental architecture used by AI image generators like Stable Diffusion, but applied to audio.</p>
<p>It starts with pure digital noise and iteratively refines it into a 44.1 kHz high-fidelity speech waveform based on the text prompt. Supertonic uses a single, unified GGUF file rather than a split model. Instead of dynamic voice cloning, it relies on highly optimized, pre-trained voice styles (for example, <code>voice: "F1"</code> or <code>voice: "M1"</code>) baked directly into the model. This makes it incredibly efficient for generating crystal-clear, studio-quality speech when you don't need dynamic cloning capabilities.</p>
<p>For this tutorial, we'll use Supertonic. It yields fantastic results out of the box and avoids the complexity of loading multiple companion files.</p>
<h2 id="heading-the-inference-pipeline">The Inference Pipeline</h2>
<p>To visualize how we interact with these engines in our codebase, think of local TTS (Text to Speech) as running a virtual recording studio right in your phone's memory:</p>
<ol>
<li><p><strong>Hiring the actor (loading the model):</strong> We map the compressed GGUF file directly into the device's RAM or GPU VRAM.</p>
</li>
<li><p><strong>Handing over the script (text input):</strong> We pass plain text to the loaded engine.</p>
</li>
<li><p><strong>The performance (inference):</strong> The engine reads the text and mathematically predicts the sound waves. Crucially, the AI doesn't emit a finished audio file. Instead, it outputs raw digital sound waves known as PCM samples.</p>
</li>
<li><p><strong>Packaging the audio:</strong> Because a raw list of numbers can't be played by standard media players, we must manually wrap the PCM data in a standard WAV header.</p>
</li>
<li><p><strong>Closing the studio (unloading):</strong> Because speech synthesis is memory-intensive and maintains a persistent state, the model is cleared from RAM to free up resources and flush its context.</p>
</li>
</ol>
<h2 id="heading-environment-and-dependency-config">Environment and Dependency Config</h2>
<p>Before we jump into the codebase, there's a crucial dependency setup to keep in mind if your project uses the pnpm package manager.</p>
<p>Because QVAC plugins rely on transitive native peer dependencies, strict package managers like pnpm will lock these dependencies down inside hidden <code>.pnpm</code> subfolders.</p>
<p>To ensure the QVAC native bundler (<code>bare-pack</code>) can resolve your worker plugins correctly at build time, create a <code>.npmrc</code> file in the root of your project:</p>
<pre><code class="language-ini">shamefully-hoist=true
</code></pre>
<p>IMPORTANT: After creating this file, you must run a clean dependency install (<code>pnpm install</code>). This ensures a flat layout in your root <code>node_modules</code> so that all QVAC-specific helper packages are resolved properly during your local <code>npx expo prebuild</code> compilation step.</p>
<h2 id="heading-the-audio-utility-packaging">The Audio Utility Packaging</h2>
<p>Because QVAC outputs raw PCM arrays, we need to construct a valid WAV file in memory and write it to the device's storage before the native audio player can play it.</p>
<p>To achieve this, let's create a utility module inside <code>src/lib/utils.ts</code> to build the required WAV header, convert raw audio samples into a binary buffer, and write it to local storage.</p>
<pre><code class="language-typescript">import { Buffer } from "buffer";
import * as FileSystem from "expo-file-system/legacy";

/**
 * Creates a WAV header for 16-bit PCM audio
 */
export function createWavHeader(
  dataLength: number,
  sampleRate: number,
): Buffer {
  const buffer = Buffer.alloc(44);
  const channels = 1; // Mono
  const byteRate = sampleRate * channels * 2; // 16-bit audio
  const blockAlign = channels * 2;

  buffer.write("RIFF", 0);
  buffer.writeUInt32LE(36 + dataLength, 4);
  buffer.write("WAVE", 8);
  buffer.write("fmt ", 12);
  buffer.writeUInt32LE(16, 16); // Subchunk1Size
  buffer.writeUInt16LE(1, 20); // AudioFormat (PCM)
  buffer.writeUInt16LE(channels, 22);
  buffer.writeUInt32LE(sampleRate, 24);
  buffer.writeUInt32LE(byteRate, 28);
  buffer.writeUInt16LE(blockAlign, 32);
  buffer.writeUInt16LE(16, 34); // BitsPerSample
  buffer.write("data", 36);
  buffer.writeUInt32LE(dataLength, 40);

  return buffer;
}

/**
 * Converts the raw Int16Array samples from QVAC to a binary Buffer
 */
export function int16ArrayToBuffer(int16Array: Int16Array): Buffer {
  const buffer = Buffer.alloc(int16Array.length * 2);
  for (let i = 0; i &lt; int16Array.length; i++) {
    buffer.writeInt16LE(int16Array[i] ?? 0, i * 2);
  }
  return buffer;
}

/**
 * Main function to package and save the file to local mobile storage
 */
export async function saveAudioToDevice(
  audioBuffer: Int16Array,
  sampleRate: number,
): Promise&lt;string&gt; {
  try {
    const audioData = int16ArrayToBuffer(audioBuffer);
    const wavHeader = createWavHeader(audioData.length, sampleRate);
    const finalWavBuffer = Buffer.concat([wavHeader, audioData]);
    const base64Data = finalWavBuffer.toString("base64");

    const filename = `tts-speech-${Date.now()}.wav`;
    const fileUri = `\({FileSystem.documentDirectory}\){filename}`;

    await FileSystem.writeAsStringAsync(fileUri, base64Data, {
      encoding: FileSystem.EncodingType.Base64,
    });

    console.log(`✅ File saved locally at: ${fileUri}`);
    return fileUri;
  } catch (error) {
    console.error("❌ Failed to save audio file locally:", error);
    throw error;
  }
}
</code></pre>
<h2 id="heading-complete-implementation">Complete Implementation</h2>
<p>Let's bring it all together. We'll implement an interface that takes user input, manages download and loading states for the Supertonic engine, packages generated raw waves into a playable local file, and renders an interactive visual waveform player.</p>
<p>Replace your entry app file <code>src/app/index.tsx</code> with the following implementation:</p>
<pre><code class="language-tsx">import { useState, useEffect } from "react";
import {
  TextInput,
  KeyboardAvoidingView,
  Platform,
  ScrollView,
} from "react-native";
import {
  loadModel,
  unloadModel,
  textToSpeech,
  downloadAsset,
  TTS_EN_SUPERTONIC_Q8_0,
  getModelInfo,
  type ModelProgressUpdate,
} from "@qvac/sdk";
import { saveAudioToDevice } from "@/lib/utils";
import { TtsModelLoader } from "@/components/tts-model-loader";
import { AudioPlayer } from "@/components/audio-player";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";

const SUPERTONIC_SAMPLE_RATE = 44100;

// Global reference for our model ID
let globalModelId: string | null = null;

type TtsStatus =
  | { phase: "idle" }
  | { phase: "synthesizing" }
  | { phase: "done"; audioUri: string }
  | { phase: "error"; message: string };

export default function TextToVoiceScreen() {
  const [text, setText] = useState("");
  const [status, setStatus] = useState&lt;TtsStatus&gt;({ phase: "idle" });

  const [isModelLoaded, setIsModelLoaded] = useState(!!globalModelId);
  const [isDownloading, setIsDownloading] = useState(false);
  const [downloadProgress, setDownloadProgress] = useState(0);

  const isBusy = status.phase === "synthesizing";

  useEffect(() =&gt; {
    async function checkAndAutoLoad() {
      if (globalModelId) return;
      try {
        const info = await getModelInfo({ name: TTS_EN_SUPERTONIC_Q8_0.name });
        if (info.isCached) {
          setIsDownloading(true);
          setDownloadProgress(1);

          globalModelId = await loadModel({
            modelSrc: TTS_EN_SUPERTONIC_Q8_0,
            modelConfig: {
              ttsEngine: "supertonic",
              language: "en",
              voice: "F1",
              ttsSpeed: 1.05,
              ttsNumInferenceSteps: 5,
            },
          });

          setIsModelLoaded(true);
          setIsDownloading(false);
        }
      } catch (err: unknown) {
        console.warn("Failed to auto-load cached model on mount:", err);
        setIsDownloading(false);
      }
    }
    checkAndAutoLoad();
  }, []);

  const handleDownloadModel = async () =&gt; {
    if (isDownloading || isModelLoaded) return;

    try {
      setIsDownloading(true);
      setDownloadProgress(0);

      await downloadAsset({
        assetSrc: TTS_EN_SUPERTONIC_Q8_0,
        onProgress: (p: ModelProgressUpdate) =&gt; {
          setDownloadProgress(p.percentage / 100);
        },
      });

      setDownloadProgress(1);

      globalModelId = await loadModel({
        modelSrc: TTS_EN_SUPERTONIC_Q8_0,
        modelConfig: {
          ttsEngine: "supertonic",
          language: "en",
          voice: "F1",
          ttsSpeed: 1.05,
          ttsNumInferenceSteps: 5,
        },
      });

      setIsModelLoaded(true);
      setIsDownloading(false);
    } catch (err: unknown) {
      console.error("Failed to download or load model:", err);
      setIsDownloading(false);
      setStatus({
        phase: "error",
        message: err instanceof Error ? err.message : String(err),
      });
      setIsModelLoaded(false);
    }
  };

  const handleSubmit = async () =&gt; {
    if (!text.trim() || isBusy || !globalModelId) return;

    try {
      setStatus({ phase: "synthesizing" });

      // 1. Unload and reload the model to reset its state and clear the KV cache.
      if (globalModelId) {
        await unloadModel({ modelId: globalModelId });
      }
      globalModelId = await loadModel({
        modelSrc: TTS_EN_SUPERTONIC_Q8_0,
        modelConfig: {
          ttsEngine: "supertonic",
          language: "en",
          voice: "F1",
          ttsSpeed: 1.05,
          ttsNumInferenceSteps: 5,
        },
      });

      // 2. Synthesize text to raw PCM samples
      const result = textToSpeech({
        modelId: globalModelId,
        text: text.trim(),
        inputType: "text",
        stream: false,
      });

      const audioBuffer = await result.buffer;

      // 3. Package and save WAV file using our local util
      const samplesInt16 = new Int16Array(audioBuffer);
      const wavUri = await saveAudioToDevice(
        samplesInt16,
        SUPERTONIC_SAMPLE_RATE,
      );

      // 4. Show player
      setStatus({ phase: "done", audioUri: wavUri });
    } catch (err: unknown) {
      console.error("TTS error:", err);
      const msg = err instanceof Error ? err.message : String(err);
      setStatus({ phase: "error", message: msg });
    }
  };

  const buttonLabel =
    status.phase === "synthesizing" ? "Synthesizing…" : "Synthesize Speech";

  if (!isModelLoaded) {
    return (
      &lt;TtsModelLoader
        onDownload={handleDownloadModel}
        isDownloading={isDownloading}
        progress={downloadProgress}
      /&gt;
    );
  }

  return (
    &lt;KeyboardAvoidingView
      behavior={Platform.OS === "ios" ? "padding" : "height"}
      className="flex-1 bg-black"
    &gt;
      &lt;ScrollView contentContainerClassName="flex-grow p-6  justify-center"&gt;
        &lt;Card className="border border-border bg-card max-w-md w-full mx-auto"&gt;
          &lt;CardHeader&gt;
            &lt;CardTitle variant="h3" className="text-white text-center"&gt;
              Text to Voice
            &lt;/CardTitle&gt;
            &lt;CardDescription className="text-center mt-1"&gt;
              Type or paste your content to synthesize speech
            &lt;/CardDescription&gt;
          &lt;/CardHeader&gt;

          &lt;CardContent className="gap-6"&gt;
            &lt;TextInput
              className="bg-muted text-white border border-border rounded-lg p-4 h-48 text-base leading-6"
              multiline
              numberOfLines={8}
              placeholder="Type your message here..."
              placeholderTextColor="#666"
              value={text}
              onChangeText={setText}
              style={{ textAlignVertical: "top" }}
              editable={!isBusy}
            /&gt;

            {status.phase === "error" &amp;&amp; (
              &lt;Text className="text-destructive text-sm text-center"&gt;
                {status.message}
              &lt;/Text&gt;
            )}

            {status.phase === "done" &amp;&amp; &lt;AudioPlayer uri={status.audioUri} /&gt;}

            &lt;Button
              onPress={handleSubmit}
              className="w-full h-12 rounded-xl"
              disabled={!text.trim() || isBusy}
            &gt;
              &lt;Text className="font-semibold text-lg"&gt;{buttonLabel}&lt;/Text&gt;
            &lt;/Button&gt;
          &lt;/CardContent&gt;
        &lt;/Card&gt;
      &lt;/ScrollView&gt;
    &lt;/KeyboardAvoidingView&gt;
  );
}
</code></pre>
<h3 id="heading-codebase-breakdown">Codebase Breakdown</h3>
<p>Let’s lift the hood on how this local Text-to-Speech implementation manages native model lifecycles and processes raw audio arrays.</p>
<h4 id="heading-1-managing-the-native-lifecycle">1. Managing the Native Lifecycle</h4>
<p>Loading neural network weights for speech synthesis is computationally expensive. When the QVAC runtime initializes a model, it must read parameters from the local disk and copy the active weights into device RAM.</p>
<p>To handle this efficiently, we declared the reference variable outside the component scope:</p>
<pre><code class="language-typescript">let globalModelId: string | null = null;
</code></pre>
<p>If <code>globalModelId</code> were tracked inside component states, navigating away from the text-to-speech screen would clean up the state, causing the app to unnecessarily drop the reference. Storing the ID globally ensures we hold onto it across layout transitions.</p>
<h4 id="heading-2-flushing-the-kv-cache-unload-and-reload">2. Flushing the KV Cache: Unload and Reload</h4>
<p>One of the most important aspects of offline generation using GGML engines is state management:</p>
<pre><code class="language-typescript">// 1. Unload and reload the model to reset its state and clear the KV cache.
if (globalModelId) {
  await unloadModel({ modelId: globalModelId });
}

globalModelId = await loadModel({ ... });
</code></pre>
<p>WARNING about <strong>acoustic hallucinations:</strong> If you continuously synthesize sentences on a single TTS model instance without resetting it, the model's Key-Value (KV) cache fills up. It begins treating your new sentence as a continuation of the previous one, leading to heavy robotic distortion, echoing, and repeated voices.</p>
<p>By explicitly destroying the model via <code>unloadModel</code> and immediately booting a fresh instance with <code>loadModel</code>, we're forcing a pristine, empty context window. Since the model is already downloaded and memory-mapped, reloading the model directly from local flash storage is extremely fast, typically completing in a fraction of a second on modern mobile hardware to ensure a seamless user experience while guaranteeing artifact-free audio.</p>
<h4 id="heading-3-demystifying-the-wav-header-structure">3. Demystifying the WAV Header Structure</h4>
<p>Operating systems and built-in mobile media decoders are unable to parse raw, naked PCM (Pulse Code Modulation) sound waves directly. A raw PCM buffer is simply a stream of numerical coordinates representing audio wave amplitudes.</p>
<p>We resolve this by prepending-formatting our PCM buffer with a standard 44-byte RIFF/WAVE header.</p>
<p>This header acts as a passport, defining:</p>
<ul>
<li><p><strong>AudioFormat (</strong><code>1</code><strong>)</strong>: Signals uncompressed linear PCM.</p>
</li>
<li><p><strong>NumChannels (</strong><code>1</code><strong>)</strong>: Mono audio.</p>
</li>
<li><p><strong>SampleRate (</strong><code>44100</code><strong>)</strong>: The clock frequency required for Supertonic playback.</p>
</li>
<li><p><strong>BitsPerSample (</strong><code>16</code><strong>)</strong>: 16-bit word length (2 bytes per sample).</p>
</li>
</ul>
<p>Additionally, writing the file is handled via Base64 encoding to safely cross React Native's JavaScript-to-Native bridge without dropping binary data:</p>
<pre><code class="language-typescript">const base64Data = finalWavBuffer.toString("base64");
await FileSystem.writeAsStringAsync(fileUri, base64Data, {
  encoding: FileSystem.EncodingType.Base64,
});
</code></pre>
<h4 id="heading-4-visual-waveform-player">4. Visual Waveform Player</h4>
<p>Rather than using a basic headless native audio player that fires immediately in the background, we pass the local WAV file path to a custom <code>&lt;AudioPlayer&gt;</code> component powered by <code>@simform_solutions/react-native-audio-waveform</code>.</p>
<p>This module analyzes our newly written WAV file and draws a sleek, WhatsApp-inspired interactive visual waveform, giving the user full control over playback, dynamic speed adjustments (<code>1x</code>, <code>1.5x</code>, <code>2x</code>), and seeking. It's a vast UX improvement that makes the final result feel premium and polished.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Transitioning Text-to-Speech from the cloud to on-device hardware offers a practical approach for mobile application developers. Running model inference locally eliminates reliance on remote internet connectivity, removes recurring API usage costs, and ensures that user text inputs never leave the physical device.</p>
<p>Integrating local speech synthesis can be highly beneficial for interactive, educational, or conversational apps. For example, in voice-guided systems, on-device TTS allows applications to function in private or offline environments. As edge processors gain dedicated hardware acceleration cores and open-source models decrease in memory size through quantization research, local-first architectures present a compelling alternative for developers prioritizing privacy, offline resilience, and predictable cost structures.</p>
<h2 id="heading-resources-and-further-reading">Resources and Further Reading</h2>
<p>To dive deeper into local Text-to-Speech inference, inspect the source code, or explore advanced configurations for your mobile applications, check out the following resources:</p>
<ul>
<li><p><a href="https://docs.qvac.tether.io/tutorials/expo/"><strong>QVAC Expo Integration Docs</strong></a>: Learn more about configuring custom local models in Expo.</p>
</li>
<li><p><a href="https://github.com/SimformSolutionsPvtLtd/react-native-audio-waveform"><strong>react-native-audio-waveform</strong></a>: Learn more about interactive React Native audio visualizations.</p>
</li>
<li><p><a href="https://huggingface.co/models?search=gguf"><strong>GGUF Model Hub on Hugging Face</strong></a>: Browse compatible quantized open-source models.</p>
</li>
<li><p><a href="https://www.emergentmind.com/topics/latent-denoising-diffusion-models"><strong>Latent Denoising Deep Dive</strong></a>: Technical deep dive into Diffusion-based acoustic generation.</p>
</li>
<li><p><a href="https://github.com/DjibrilM/QVAC-TTS-Expo-Implementation"><strong>https://github.com/DjibrilM/QVAC-TTS-Expo-Implementation</strong></a>: Full implementation code.</p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Preprocess Medical Images for Machine Learning – A Guide Using Chest X-Rays ]]>
                </title>
                <description>
                    <![CDATA[ Working with healthcare data introduces preprocessing challenges that go beyond those you might encounter with structured data. Some familiar techniques still apply, while others look very different o ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-preprocess-medical-images-for-machine-learning/</link>
                <guid isPermaLink="false">6a21b25709761aac249473c9</guid>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ healthcare ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Medical Imaging ]]>
                    </category>
                
                    <category>
                        <![CDATA[ data-engineering ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Data Preprocessing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Lakshmi Mahabaleshwara ]]>
                </dc:creator>
                <pubDate>Thu, 04 Jun 2026 17:13:59 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/eab58d7c-f63a-41ae-a01e-52a65b0be17c.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Working with healthcare data introduces preprocessing challenges that go beyond those you might encounter with structured data. Some familiar techniques still apply, while others look very different once your data becomes medical images.</p>
<p>In this article, you’ll learn how to prepare a real-world medical imaging dataset for machine learning, from initial data validation to a complete preprocessing pipeline.</p>
<p>We’ll use the Chest X-Ray Pneumonia dataset as our running example, but the lessons apply broadly to healthcare imaging data, including ultrasound, MRI, CT, and dermatology images.</p>
<h2 id="heading-what-youll-learn-in-this-article">What You'll Learn in This Article</h2>
<p>By the end of this article, you'll know how to:</p>
<ul>
<li><p>Approach healthcare data preprocessing differently from preprocessing structured data, and recognize where standard techniques fall short</p>
</li>
<li><p>Validate a medical imaging dataset before training to catch corrupted files, mislabels, and data leakage between train and test</p>
</li>
<li><p>Apply six core preprocessing techniques for medical images</p>
</li>
<li><p>Build a complete preprocessing pipeline for chest X-rays using Python with OpenCV.</p>
</li>
</ul>
<h2 id="heading-what-well-cover"><strong>What We'll Cover:</strong></h2>
<ul>
<li><p><a href="#heading-why-preprocessing-data-matters-more-in-healthcare">Why Preprocessing Data Matters More in Healthcare</a></p>
</li>
<li><p><a href="#heading-the-dataset">The Dataset</a></p>
</li>
<li><p><a href="#heading-before-preprocessing-validate-the-dataset">Before Preprocessing: Validate the Dataset</a></p>
</li>
<li><p><a href="#heading-the-six-pillars-of-healthcare-imaging-preprocessing">The Six Pillars of Healthcare Imaging Preprocessing</a></p>
</li>
<li><p><a href="#heading-pillar-1-scaling-making-the-numbers-play-fair">Pillar 1: Scaling — Making the Numbers Play Fair</a></p>
</li>
<li><p><a href="#heading-pillar-2-normalization-centering-the-data">Pillar 2: Normalization — Centering the Data</a></p>
</li>
<li><p><a href="#heading-pillar-3-guiding-the-models-attention">Pillar 3: Guiding the Model's Attention</a></p>
</li>
<li><p><a href="#heading-pillar-4-handling-missing-data">Pillar 4: Handling Missing Data</a></p>
</li>
<li><p><a href="#heading-pillar-5-resizing-amp-resampling-fitting-everything-in-the-same-frame">Pillar 5: Resizing &amp; Resampling — Fitting Everything in the Same Frame</a></p>
</li>
<li><p><a href="#heading-pillar-6-denoising-amp-artifact-handling-cleaning-the-window">Pillar 6: Denoising &amp; Artifact Handling — Cleaning the Window</a></p>
</li>
<li><p><a href="#heading-putting-it-all-together-a-complete-pipeline">Putting it All together: A Complete Pipeline</a></p>
</li>
<li><p><a href="#heading-try-it-yourself">Try it Yourself</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-why-preprocessing-data-matters-more-in-healthcare">Why Preprocessing Data Matters More in Healthcare</h2>
<p>Imagine handing a toddler a jigsaw puzzle with missing pieces, warped edges, and pieces from three different puzzles mixed together. The toddler can't solve it, but that isn't really the toddler's fault.</p>
<p>The same thing happens when raw, messy data gets fed into a machine learning model. A bad prediction on a clinical image can mean a missed diagnosis.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/55671e0b-95ea-4f99-b507-a8742e8981d9.png" alt="Illustration showing a healthcare data preprocessing workflow. Mixed medical images with different sizes, missing labels, noisy scans, and corrupted files enter a preprocessing pipeline and emerge as clean, standardized, model-ready images ready for machine learning." style="display:block;margin:0 auto" width="1168" height="558" loading="lazy">

<p>Healthcare data tends to be messier than what most ML practitioners are used to:</p>
<ul>
<li><p>Images come from different machines, hospitals, and acquisition protocols</p>
</li>
<li><p>Labels are inconsistent, sometimes missing, sometimes wrong</p>
</li>
<li><p>Patient data is incomplete</p>
</li>
<li><p>Image sizes, contrast levels, and orientations vary across sources</p>
</li>
</ul>
<p>Poor preprocessing often leads to models that perform well on benchmark datasets but struggle to generalize to data collected from different hospitals or imaging devices.</p>
<h2 id="heading-the-dataset">The Dataset</h2>
<p>This guide uses the <strong>Chest X-Ray Pneumonia dataset</strong> by Paul Mooney on Kaggle. It's a strong choice for learning preprocessing because:</p>
<ul>
<li><p>It contains around 5,800 pediatric chest X-rays</p>
</li>
<li><p>It has two clear classes — Normal and Pneumonia</p>
</li>
<li><p>It's already organized into train, validation, and test folders</p>
</li>
<li><p>The images are recognizable without specialized medical training</p>
</li>
<li><p>It exhibits almost every preprocessing challenge worth learning</p>
</li>
</ul>
<p>The dataset is available at <a href="https://www.kaggle.com/datasets/paultimothymooney/chest-xray-pneumonia">Kaggle: Chest X-Ray Pneumonia</a>.</p>
<h3 id="heading-folder-structure">Folder Structure</h3>
<p>After downloading, the dataset is organized like this:</p>
<pre><code class="language-plaintext">chest_xray/
├── train/
│   ├── NORMAL/
│   └── PNEUMONIA/
├── val/
│   ├── NORMAL/
│   └── PNEUMONIA/
└── test/
    ├── NORMAL/
    └── PNEUMONIA/
</code></pre>
<p>Side-by-side comparison — Normal vs Pneumonia chest X-ray:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/b92e1e14-ac24-4314-afce-bc2c3ce3ea32.png" alt="Side-by-side chest X-ray images showing a normal lung scan on the left and a pneumonia scan on the right. The pneumonia image contains visible cloudy opacities compared with the clearer lung fields in the normal image." style="display:block;margin:0 auto" width="592" height="195" loading="lazy">

<p>A quick first look at one of the images:</p>
<pre><code class="language-python">import os
import numpy as np
import matplotlib.pyplot as plt
from PIL import Image
import cv2

DATA_DIR = "chest_xray"
TRAIN_DIR = os.path.join(DATA_DIR, "train")

# Peek at a sample image
sample_path = os.path.join(TRAIN_DIR, "NORMAL", os.listdir(os.path.join(TRAIN_DIR, "NORMAL"))[0])
sample_image = cv2.imread(sample_path, cv2.IMREAD_GRAYSCALE)

print(f"Image shape: {sample_image.shape}")
print(f"Pixel range: {sample_image.min()} to {sample_image.max()}")
print(f"Data type: {sample_image.dtype}")
</code></pre>
<p>The output reveals a few useful things right away: most images are large (often around 1500×2000 pixels), pixel values fall in the 0–255 range, and image sizes vary across the dataset. Each of these observations will inform a preprocessing step.</p>
<h2 id="heading-before-preprocessing-validate-the-dataset">Before Preprocessing: Validate the Dataset</h2>
<p>Before applying any transformations, it's worth checking that the data itself is intact. This step alone catches issues that would otherwise cause training to fail silently or produce misleading results.</p>
<p>A simple validation function:</p>
<pre><code class="language-python">def validate_dataset(data_dir):
    """Scan a dataset folder and flag common data quality issues."""
    corrupted = []
    too_small = []
    nearly_black = []
    total = 0
    
    for class_name in os.listdir(data_dir):
        class_path = os.path.join(data_dir, class_name)
        if not os.path.isdir(class_path):
            continue
        for fname in os.listdir(class_path):
            fpath = os.path.join(class_path, fname)
            total += 1
            try:
                img = cv2.imread(fpath, cv2.IMREAD_GRAYSCALE)
                if img is None:
                    corrupted.append(fpath)
                    continue
                if img.shape[0] &lt; 100 or img.shape[1] &lt; 100:
                    too_small.append(fpath)
                if img.mean() &lt; 5:
                    nearly_black.append(fpath)
            except Exception:
                corrupted.append(fpath)
    
    print(f"Total files scanned: {total}")
    print(f"Corrupted: {len(corrupted)}")
    print(f"Too small: {len(too_small)}")
    print(f"Nearly black: {len(nearly_black)}")
    return corrupted, too_small, nearly_black

validate_dataset(TRAIN_DIR)
</code></pre>
<p>Common issues this catches:</p>
<ul>
<li><p><strong>Corrupted files</strong> — files that won't open at all</p>
</li>
<li><p><strong>Empty or nearly-black images</strong> — failed acquisitions or saved-as-blank files</p>
</li>
<li><p><strong>Wrong dimensions</strong> — thumbnails or partial downloads mixed in</p>
</li>
<li><p><strong>Duplicate images</strong> — the same scan appearing in both train and test (this causes data leakage)</p>
</li>
<li><p><strong>Mislabeled images</strong> — a normal X-ray placed in the pneumonia folder</p>
</li>
</ul>
<p><strong>⚠️ This step is critical</strong>, One corrupted file can crash a training loop hours into a run. One duplicate between train and test can inflate accuracy scores by several percentage points without anyone noticing.</p>
<h2 id="heading-the-six-pillars-of-healthcare-imaging-preprocessing"><strong>The Six Pillars of Healthcare Imaging Preprocessing</strong></h2>
<p>Preprocessing for medical images can be organized around six core concerns. Two of them carry over directly from preprocessing structured data. Two need to be adapted because the mechanics change when the input is an image. And two are entirely new, they only exist once the data becomes pictures of human bodies.</p>
<h2 id="heading-pillar-1-scaling-making-the-numbers-play-fair">Pillar 1: Scaling — Making the Numbers Play Fair</h2>
<p>Imagine two children comparing their collections. One has 3 seashells. The other has 3,000 stickers. Asking who has more makes the answer seem obvious, but the <em>scales</em> are completely different. Comparing them meaningfully means putting both collections on the same measuring system.</p>
<p>In medical images, pixels usually range from 0 to 255 in 8-bit images, or 0 to 65,535 in some 16-bit medical DICOM images. Neural networks tend to train faster and more reliably when input values are small numbers close to zero.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/1d864b0d-992c-4637-8f43-7ca86c6fd93c.png" alt="Histogram comparison showing chest X-ray pixel values before and after scaling. The left histogram displays values in the 0–255 range, while the right histogram shows the same distribution scaled to the 0–1 range used for machine learning." style="display:block;margin:0 auto" width="1168" height="558" loading="lazy">

<p><strong>The fix:</strong> Divide every pixel by its maximum possible value, bringing everything into the 0-to-1 range.</p>
<pre><code class="language-python">image = cv2.imread(sample_path, cv2.IMREAD_GRAYSCALE)

# Scale to [0, 1]
image_scaled = image.astype(np.float32) / 255.0

print(f"Before scaling: {image.min()} to {image.max()}")
print(f"After scaling:  {image_scaled.min():.3f} to {image_scaled.max():.3f}")
</code></pre>
<p><strong>Takeaway:</strong> Pixel scaling follows the same principle as scaling any numerical feature. The values simply happen to be arranged as an image rather than a column.</p>
<h2 id="heading-pillar-2-normalization-centering-the-data">Pillar 2: Normalization — Centering the Data</h2>
<p>Imagine a teacher asks a class to rate a movie from 1 to 10. One child always gives 9s and 10s. Another spreads ratings evenly from 1 to 10. Comparing their opinions fairly requires adjusting each child's score relative to their own average.</p>
<p>In medical imaging even after scaling to 0–1, the overall brightness of images can vary. Some X-rays are taken with stronger exposure than others. Normalization shifts and rescales each image (or each channel) so the values are centered around zero with a standard deviation of one.</p>
<p><strong>The fix:</strong> Subtract the mean, divide by the standard deviation.</p>
<pre><code class="language-python"># Compute mean and std from the TRAINING set only — never from validation or test
def compute_train_stats(train_dir, sample_limit=1000):
    """Compute pixel mean and std across the training set."""
    pixel_values = []
    count = 0
    for class_name in os.listdir(train_dir):
        class_path = os.path.join(train_dir, class_name)
        for fname in os.listdir(class_path):
            if count &gt;= sample_limit:
                break
            img = cv2.imread(os.path.join(class_path, fname), cv2.IMREAD_GRAYSCALE)
            if img is not None:
                pixel_values.append(img.astype(np.float32).flatten() / 255.0)
                count += 1
    pixels = np.concatenate(pixel_values)
    return pixels.mean(), pixels.std()

train_mean, train_std = compute_train_stats(TRAIN_DIR)
image_normalized = (image_scaled - train_mean) / train_std
</code></pre>
<p><strong>⚠️</strong> Avoid this common mistake: Statistics for normalization should be computed from the training set only, never from validation or test. Including those in the calculation leaks information from the evaluation data into the model. The same statistics should then be applied to validation, test, and any new data at inference time.</p>
<p><strong>Takeaway:</strong> Centering and scaling each image around the dataset's statistics is the imaging equivalent of standardizing a feature column. The pixels are now comparable across images, regardless of how bright or dim each scan happened to be.</p>
<h2 id="heading-pillar-3-guiding-the-models-attention">Pillar 3: Guiding the Model's Attention</h2>
<p>Imagine a child walking into a crowded pet store. Instead of describing every animal in sight, a parent points to the features that matter: <em>“Look at the soft fur, the fluffy tail, and the nice small size.”</em> The child learns where to focus their attention.</p>
<p>Medical image preprocessing does something similar. It highlights the regions and features most relevant to the diagnostic task.</p>
<ul>
<li><p><strong>Region-of-interest (ROI) cropping</strong> — focus on the lung field and discard the patient's arms, machine borders, and any imprinted text</p>
</li>
<li><p><strong>Contrast enhancement</strong> — use techniques like CLAHE (Contrast Limited Adaptive Histogram Equalization) to make subtle lung textures more visible</p>
</li>
<li><p><strong>Channel selection</strong> — for images stored as RGB but containing grayscale information, convert to single-channel input to reduce noise</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/54cb1319-e794-472e-9ca4-22a063fd5092.png" alt="Three-panel illustration showing a chest X-ray before and after feature enhancement. The first panel shows the original image, the second highlights the lung region of interest, and the third shows the image after CLAHE contrast enhancement with lung textures appearing more visible." style="display:block;margin:0 auto" width="1168" height="588" loading="lazy">

<p>CLAHE applied to an X-ray:</p>
<pre><code class="language-python"># CLAHE enhances local contrast — useful for X-rays
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
image_enhanced = clahe.apply(image)

# Visualize the difference
fig, axes = plt.subplots(1, 2, figsize=(12, 6))
axes[0].imshow(image, cmap='gray')
axes[0].set_title('Original')
axes[1].imshow(image_enhanced, cmap='gray')
axes[1].set_title('After CLAHE')
plt.show()
</code></pre>
<p><strong>Takeaway:</strong> The goal of teaching the model what to look at hasn't changed. With structured data, the answer is in new columns. With images, the answer is in cropping, enhancement, and emphasizing the regions that carry diagnostic signal.</p>
<h2 id="heading-pillar-4-handling-missing-data">Pillar 4: Handling Missing Data</h2>
<p>Imagine reading a storybook with a few damaged pages. You don’t throw away the entire book, you decide whether to skip the page, infer what might be missing, or mark it for review.</p>
<p>In medical imaging, missing data can mean corrupted files, missing labels, or incomplete studies rather than empty spreadsheet cells.</p>
<p>The same three strategies — drop, impute, flag — still apply, just with different mechanics:</p>
<pre><code class="language-python"># Strategy 1: Drop — remove unreadable or empty images
def is_valid_image(path):
    try:
        img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
        if img is None:
            return False
        if img.mean() &lt; 5:           # nearly black
            return False
        if img.shape[0] &lt; 50 or img.shape[1] &lt; 50:  # too small
            return False
        return True
    except Exception:
        return False

# Strategy 2: Impute — rare for images, but possible (e.g., in painting to fill in missing patches). Generally avoided for diagnostic data.

# Strategy 3: Flag — track which patients are missing which modalities,
#   and let the model condition on availability. Common in multi-modal healthcare ML.
</code></pre>
<p><strong>Takeaway:</strong> "Missing" in imaging data is rarely just a NaN. It can be a broken file, an unlabeled scan, an absent modality, or a black corner inside an image. The same three strategies still apply.</p>
<h2 id="heading-pillar-5-resizing-amp-resampling-fitting-everything-in-the-same-frame">Pillar 5: Resizing &amp; Resampling — Fitting Everything in the Same Frame</h2>
<p>Imagine displaying children’s drawings on a classroom wall. If every drawing is a different size, they won’t fit neatly into the display. You resize them while preserving their proportions.</p>
<p>Medical images must often be resized to a common input size, but anatomical structures should retain their original shape.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/d36b6f8c-4be0-41b7-ab7c-5ca30c01b3e0.png" alt="Comparison of two chest X-ray resizing approaches. One image is stretched into a square shape, distorting the lungs, while the second preserves the original aspect ratio by adding padding around the image. The aspect-ratio-preserving approach is highlighted as the preferred method." style="display:block;margin:0 auto" width="1168" height="674" loading="lazy">

<p><strong>The fix:</strong> Resize all images to a common shape. For medical data, <em>how</em> the resizing is done matters.</p>
<pre><code class="language-python">TARGET_SIZE = (224, 224)

# Simple resize (may distort aspect ratio)
image_resized = cv2.resize(image, TARGET_SIZE)

# Better: preserve aspect ratio with padding
def resize_with_padding(image, target_size):
    h, w = image.shape[:2]
    target_h, target_w = target_size
    scale = min(target_h / h, target_w / w)
    new_h, new_w = int(h * scale), int(w * scale)
    resized = cv2.resize(image, (new_w, new_h))
    
    pad_h = target_h - new_h
    pad_w = target_w - new_w
    top, bottom = pad_h // 2, pad_h - pad_h // 2
    left, right = pad_w // 2, pad_w - pad_w // 2
    padded = cv2.copyMakeBorder(resized, top, bottom, left, right,
                                 cv2.BORDER_CONSTANT, value=0)
    return padded

image_clean_resize = resize_with_padding(image, TARGET_SIZE)
</code></pre>
<p><strong>⚠️ Why aspect ratio matters in healthcare:</strong> Squishing a chest X-ray horizontally makes the lungs look unnatural. Models trained on distorted anatomy often perform worse on real scans. Preserving aspect ratio is generally the safer choice.</p>
<p><strong>Takeaway:</strong> Models need a consistent input size, but the geometry of the anatomy needs to be preserved. Resize, but resize carefully.</p>
<h2 id="heading-pillar-6-denoising-amp-artifact-handling-cleaning-the-window">Pillar 6: Denoising &amp; Artifact Handling — Cleaning the Window</h2>
<p>Imagine looking through a window with dust and smudges on the glass. Cleaning the window makes the view clearer, but scrubbing too aggressively could scratch the glass.</p>
<p>Similarly, medical images often contain noise and acquisition artifacts that should be reduced carefully without removing clinically important details.</p>
<p>For chest X-rays, the most common issues are mild noise and burned-in text or markers. A gentle median or bilateral filter helps with the first, while cropping or masking helps with the second.</p>
<pre><code class="language-python"># Gentle denoising — careful not to blur away clinical detail
image_denoised = cv2.medianBlur(image, ksize=3)

# Bilateral filter preserves edges better than a median filter
image_bilateral = cv2.bilateralFilter(image, d=5, sigmaColor=50, sigmaSpace=50)
</code></pre>
<p><strong>⚠️ A note of caution:</strong> Aggressive denoising can erase the features a model needs to detect a disease. For diagnostic ML, gentle filtering is generally preferred. A useful rule of thumb: if a radiologist can't distinguish the cleaned image from the original, the filtering has gone too far.</p>
<p><strong>Takeaway:</strong> Imaging data carries noise that structured data doesn't have. The window can be cleaned, but never so aggressively that the view is wiped away with the smudges.</p>
<h2 id="heading-putting-it-all-together-a-complete-pipeline">Putting it All Together: A Complete Pipeline</h2>
<img src="https://cdn.hashnode.com/uploads/covers/69fd77e89f93a850a46d376f/c532949b-000c-403e-acb9-f9dec689182e.png" alt="Workflow showing a chest X-ray progressing through a healthcare imaging preprocessing pipeline. The image moves through validation, resizing, denoising, contrast enhancement, scaling, and normalization before becoming a model-ready machine learning input." style="display:block;margin:0 auto" width="828" height="255" loading="lazy">

<p>Here's how the six pillars combine into a single preprocessing function for chest X-ray images:</p>
<pre><code class="language-python">def preprocess_xray(image_path, target_size=(224, 224),
                    train_mean=0.482, train_std=0.236):
    """
    Full preprocessing pipeline for chest X-ray images.
    Applies all six pillars in order.
    """
    # Pillar 4: Validate first — skip corrupted files
    if not is_valid_image(image_path):
        return None
    
    image = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)
    
    # Pillar 5: Resize with aspect ratio preserved
    image = resize_with_padding(image, target_size)
    
    # Pillar 6: Gentle denoising
    image = cv2.medianBlur(image, 3)
    
    # Pillar 3: Enhance contrast to highlight lung texture
    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
    image = clahe.apply(image)
    
    # Pillar 1: Scale to [0, 1]
    image = image.astype(np.float32) / 255.0
    
    # Pillar 2: Normalize using training set statistics
    image = (image - train_mean) / train_std
    
    return image
</code></pre>
<h2 id="heading-try-it-yourself">Try it Yourself</h2>
<p>Every code snippet in this article is bundled into a runnable Kaggle notebook: <a href="https://www.kaggle.com/code/lakshmimahabaleshwar/chest-xray-preprocessing-kaggle">Chest X-Ray Preprocessing — Kaggle Notebook</a>. Fork it, attach the dataset, and run all the cells to see each preprocessing pillar in action on real chest X-rays.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Here's a summary of what we've discussed in this article:</p>
<table>
<thead>
<tr>
<th><strong>Pillar</strong></th>
<th><strong>Purpose</strong></th>
<th><strong>Example</strong></th>
</tr>
</thead>
<tbody><tr>
<td>Scaling</td>
<td>Standardize pixel ranges</td>
<td>0-255 → 0-1</td>
</tr>
<tr>
<td>Normalization</td>
<td>Center brightness distributions</td>
<td>z-score normalization</td>
</tr>
<tr>
<td>Attention Guidance</td>
<td>Highlight diagnostic regions</td>
<td>CLAHE</td>
</tr>
<tr>
<td>Missing Data Handling</td>
<td>Remove unusable scans</td>
<td>Corrupted files</td>
</tr>
<tr>
<td>Resizing</td>
<td>Consistent input size</td>
<td>224×224</td>
</tr>
<tr>
<td>Denoising</td>
<td>Reduce acquisition noise</td>
<td>Median filter</td>
</tr>
</tbody></table>
<p>Preprocessing for structured data is about making numbers play fair so a model can see them clearly.</p>
<p>Preprocessing for healthcare imaging is about respecting the messy reality of how medical data is captured, stored, and labeled. Some standard techniques carry over directly. Some need to be adapted. And a few preprocessing concerns only emerge once the data becomes pictures of human bodies.</p>
<p>Stepping back, whether it's a child learning to organize their toy box, or a model learning to spot pneumonia in a chest X-ray, the quality of learning depends on the quality of data preparation. Get the data right.</p>
<p>If this was useful, you can find a related conceptual primer on preprocessing more broadly here: <a href="https://lakshmimahabaleshwara.substack.com/p/data-preprocessing-for-machine-learning">Data Preprocessing for Machine Learning</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
