100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
ML Ops & Data Science in Production
55 minadvanced

Add SHAP Explainability Endpoint

What You'll Build

In this exercise you will extend the FastAPI service built in lesson 32 with a /explain endpoint. The endpoint receives the same cricket player feature payload as /predict, runs a SHAP TreeExplainer on the XGBoost model, and returns a JSON response containing the base value (expected model output), the predicted strike rate, and a sorted list of per-feature attributions with feature names and SHAP values. You will also update the Pydantic response schemas, reload the Docker container, and verify the explanation output against a known input.

Analogy🏏Cricket
🏏 Think of it like cricket: Before the IPL auction, a franchise's analytics team evaluates every player across dozens of trial matches — tracking Rohit Sharma's strike rate in power plays, Virat Kohli's average against pace, MS Dhoni's finishing rate in the death overs, Shubman Gill's consistency across pitches, and Jasprit Bumrah's economy in the middle overs. Each trial is recorded in a shared logbook so the selectors can compare and pick the best combination. MLflow is exactly that shared logbook for your ML experiments — every training run is a trial match, every metric is a scorecard entry, and the Model Registry is the final squad announcement. Keep the auction framing in mind throughout the exercise, because it fixes the discipline the steps teach: a franchise never signs a player off one good highlight reel, and you never register a model off one lucky run — you log every trial, compare them on identical conditions, and promote only with the full scorecard in front of you.

Prerequisites

This exercise builds directly on the FastAPI + Docker service from lesson 32. Ensure the application files api/main.py, api/schemas.py, Dockerfile, docker-compose.yml, and requirements.txt are in place. Install shap==0.45.0 in your virtual environment and add it to requirements.txt. The SHAP library requires the same XGBoost Booster object (the underlying native model) as the pyfunc wrapper, so we will access it via the mlflow.xgboost.load_model function rather than mlflow.pyfunc.load_model for the explainer setup.

Analogy🏏Cricket
🏏 Think of it like cricket: the kit check before a net session. Just as a batter arriving for practice needs pads, gloves, and a bat they already know how to use — but doesn't need to have faced the new bowling machine before, because today's session is exactly where they'll learn it — this exercise expects you to arrive comfortable with Python, basic scikit-learn (fit, predict, train_test_split), and pandas, while MLflow itself is taught from scratch. Just as the coach insists on a properly prepared practice pitch — a clean, dedicated strip rather than the match square — you need a clean virtual environment or Conda environment where packages can be installed freely. And just as the only outside help needed is the equipment delivery van arriving once before practice, network access is required only for the initial pip install; after that everything runs locally, whether your 'net' is a laptop, a Docker container, or a cloud notebook. The payoff: checking your kit now means the session ahead is pure skill-building, with no stoppages for missing gear.

Setup

python
# Add shap to requirements.txt and understand SHAP TreeExplainer

# requirements.txt addition
requirements_addition = "shap==0.45.0\n"

with open("requirements.txt", "a") as f:
    f.write(requirements_addition)
print("shap added to requirements.txt")

# Quick explainer primer — run outside Docker to verify shap is working
import shap
import xgboost as xgb
import numpy as np

# Minimal XGBoost model for illustration
X_demo = np.random.randn(100, 7)
y_demo = X_demo[:, 0] * 10 + X_demo[:, 2] * 5 + np.random.randn(100)
xgb_demo = xgb.XGBRegressor(n_estimators=50, max_depth=3, random_state=42)
xgb_demo.fit(X_demo, y_demo)

# TreeExplainer works directly with the XGBoost Booster
explainer_demo = shap.TreeExplainer(xgb_demo)

# SHAP values for one instance
single_instance = X_demo[[0]]
shap_values_demo = explainer_demo.shap_values(single_instance)

print(f"Base value (expected output): {explainer_demo.expected_value:.4f}")
print(f"SHAP values shape           : {np.array(shap_values_demo).shape}")
print(f"Sum of SHAP values + base   : {explainer_demo.expected_value + shap_values_demo[0].sum():.4f}")
print(f"Model direct prediction     : {xgb_demo.predict(single_instance)[0]:.4f}")
print("Sanity check: SHAP sum + base ≈ model prediction (should be True)")

Step 1: Create the SHAP Explainer Module

We separate the SHAP logic into its own module api/explainer.py to keep the route handlers clean. The explainer wraps the native XGBoost Booster (accessed via the pyfunc model's unwrap_python_model or by loading with mlflow.xgboost.load_model). It exposes a single explain method that takes a NumPy feature array and returns a structured dict containing the base value, per-feature attributions sorted by absolute SHAP magnitude, and the model prediction. The sort-by-magnitude behaviour surfaces the most influential features first, which is what front-end waterfall charts expect.

Analogy🏏Cricket
🏏 Think of it like cricket: A commentary scorecard ranks contributions by impact — 'Virat Kohli 72 off 48 balls (highest individual impact), Shubman Gill 34 off 22 (second), Rohit Sharma 28 off 18 (third).' The SHAP explanation does exactly this for model features: boundary_pct might contribute +18 strike-rate points (biggest positive driver), dot_ball_pct might subtract 12 (biggest drag), and venue_encoded might add only 1 point (noise). Sorting by absolute value puts the dominant drivers at the top, just like putting Kohli first in the scorecard.
python
# api/explainer.py
from typing import List, Dict, Any
import numpy as np
import shap
import mlflow.xgboost

FEATURE_COLS = [
    "batting_average",
    "innings_count",
    "boundary_pct",
    "dot_ball_pct",
    "opponent_bowling_quality",
    "venue_encoded",
    "is_knockout",
]

class IPLStrikeRateExplainer:
    """SHAP TreeExplainer wrapper for the IPL strike-rate XGBoost model."""

    def __init__(self, model_uri: str):
        # Load as native XGBoost model to access the Booster for SHAP
        self._xgb_model = mlflow.xgboost.load_model(model_uri)
        self._explainer = shap.TreeExplainer(self._xgb_model)
        self._base_value = float(self._explainer.expected_value)
        print(f"[explainer] SHAP TreeExplainer initialised. Base value: {self._base_value:.4f}")

    def explain(self, feature_vector: np.ndarray) -> Dict[str, Any]:
        """
        Compute SHAP attributions for a single prediction.
        Args:
            feature_vector: shape (1, n_features) NumPy array
        Returns:
            dict with base_value, predicted_strike_rate, and feature_attributions
        """
        if feature_vector.shape[0] != 1:
            raise ValueError("explain() expects a single-row feature vector.")

        # Compute SHAP values — shape (1, n_features) for regression
        ipl_shap_values = self._explainer.shap_values(feature_vector)
        shap_row = ipl_shap_values[0]  # (n_features,)

        # Reconstruct prediction from SHAP decomposition
        predicted_strike_rate = self._base_value + float(shap_row.sum())

        # Build attribution list sorted by absolute SHAP value (most impactful first)
        attributions = sorted(
            [
                {
                    "feature": FEATURE_COLS[i],
                    "shap_value": round(float(shap_row[i]), 4),
                    "abs_impact": abs(float(shap_row[i])),
                }
                for i in range(len(FEATURE_COLS))
            ],
            key=lambda x: x["abs_impact"],
            reverse=True,
        )
        # Remove helper field before returning
        for attr in attributions:
            del attr["abs_impact"]

        return {
            "base_value": round(self._base_value, 4),
            "predicted_strike_rate": round(predicted_strike_rate, 2),
            "feature_attributions": attributions,
        }

Step 2: Add the /explain Endpoint to FastAPI

We update api/main.py to import IPLStrikeRateExplainer, instantiate it in the lifespan startup handler alongside the pyfunc model, and add a POST /explain route. The route handler builds the feature vector exactly as /predict does, then delegates to the explainer's explain method. The response schema ExplanationResponse includes a nested list of FeatureAttribution objects, which FastAPI automatically serialises to JSON and documents in the /docs Swagger UI.

Analogy🏏Cricket
🏏 Think of it like cricket: The /predict endpoint is like a scoreboard showing the final total — 'Mumbai Indians 189/4'. The /explain endpoint is the full ball-by-ball breakdown showing which over contributed what — 'Powerplay: 54 runs, Overs 7-15: 82 runs, Death: 53 runs'. Both views answer the same underlying question (how many runs?) but at different levels of granularity. Analysts need both to do their job properly.
python
# Updated api/schemas.py — add explanation response models
from pydantic import BaseModel, Field
from typing import List, Optional

# (existing schemas omitted for brevity — keep CricketPlayerInput, PredictionResponse, HealthResponse)

class FeatureAttribution(BaseModel):
    """SHAP attribution for a single feature."""
    feature: str = Field(..., description="Feature name")
    shap_value: float = Field(
        ...,
        description="SHAP value: positive means feature pushed prediction up, negative means it pushed it down"
    )

class ExplanationResponse(BaseModel):
    """SHAP explanation response for a single IPL player prediction."""
    base_value: float = Field(
        ...,
        description="Expected model output (average strike rate across training data)"
    )
    predicted_strike_rate: float = Field(
        ...,
        description="Final predicted strike rate (base_value + sum of all SHAP values)"
    )
    feature_attributions: List[FeatureAttribution] = Field(
        ...,
        description="Per-feature SHAP attributions sorted by absolute impact (highest first)"
    )
    model_version: str


# Updated api/main.py — /explain route addition (patch to existing file)
explain_route_patch = '''
from api.explainer import IPLStrikeRateExplainer
from api.schemas import ExplanationResponse, FeatureAttribution

# Add to app_state dict in lifespan:
#   app_state["explainer"] = IPLStrikeRateExplainer(model_uri)

@app.post("/explain", response_model=ExplanationResponse)
async def explain(player_input: CricketPlayerInput) -> ExplanationResponse:
    if app_state.get("explainer") is None:
        raise HTTPException(status_code=503, detail="Explainer not loaded")

    feature_vector = np.array([[
        getattr(player_input, col) for col in FEATURE_COLS
    ]])

    explanation = app_state["explainer"].explain(feature_vector)

    return ExplanationResponse(
        base_value=explanation["base_value"],
        predicted_strike_rate=explanation["predicted_strike_rate"],
        feature_attributions=[
            FeatureAttribution(**attr)
            for attr in explanation["feature_attributions"]
        ],
        model_version=app_state["model_version"],
    )
'''
print(explain_route_patch)

Step 3: Test the /explain Endpoint

Testing SHAP explanations requires verifying two properties: consistency (the sum of all SHAP values plus the base value should equal the predicted strike rate, to within floating point tolerance) and sensibility (features with intuitive positive effects like high boundary_pct should have positive SHAP values, and features with intuitive negative effects like high dot_ball_pct should have negative SHAP values). The verification script below checks both automatically.

Analogy🏏Cricket
🏏 Think of it like cricket: If an analyst claims 'Rohit Sharma's boundary hitting added 18 runs to his expected total', you can sanity-check this by verifying that all contributions add up to the actual total — powerplay + middle + death = final score. If they do not, the attribution method is broken. SHAP's additive property guarantees this sum-to-prediction consistency: if the numbers do not add up, there is a bug in the implementation.
python
# test_explain.py — verify SHAP endpoint correctness
import httpx
import pytest

# Rohit Sharma — high boundary%, low dot% — expect high strike rate
rohit_ipl_features = {
    "batting_average": 31.17,
    "innings_count": 243,
    "boundary_pct": 0.60,   # high — positive driver
    "dot_ball_pct": 0.25,   # low — less negative drag
    "opponent_bowling_quality": 6.5,
    "venue_encoded": 5,
    "is_knockout": 0,
}

# MS Dhoni finisher profile — lower boundary%, higher dot%
dhoni_ipl_features = {
    "batting_average": 28.5,
    "innings_count": 210,
    "boundary_pct": 0.41,
    "dot_ball_pct": 0.44,
    "opponent_bowling_quality": 8.1,
    "venue_encoded": 2,
    "is_knockout": 1,
}

def test_explain_shap_consistency():
    """SHAP sum + base_value must equal predicted_strike_rate."""
    with httpx.Client(base_url="http://localhost:8000") as client:
        resp = client.post("/explain", json=rohit_ipl_features)
    assert resp.status_code == 200
    body = resp.json()

    base_value   = body["base_value"]
    predicted_sr = body["predicted_strike_rate"]
    shap_sum     = sum(attr["shap_value"] for attr in body["feature_attributions"])

    # Allow 0.01 tolerance for float rounding
    assert abs(base_value + shap_sum - predicted_sr) < 0.01, (
        f"SHAP consistency failed: {base_value} + {shap_sum} != {predicted_sr}"
    )
    print(f"[PASS] SHAP consistency: {base_value:.4f} + {shap_sum:.4f} = {predicted_sr:.4f}")

def test_explain_boundary_pct_positive_attribution():
    """High boundary_pct should have positive SHAP value for Rohit's input."""
    with httpx.Client(base_url="http://localhost:8000") as client:
        resp = client.post("/explain", json=rohit_ipl_features)
    body = resp.json()
    attributions = {a["feature"]: a["shap_value"] for a in body["feature_attributions"]}
    assert attributions["boundary_pct"] > 0, (
        f"Expected positive SHAP for boundary_pct, got {attributions['boundary_pct']}"
    )
    print(f"[PASS] boundary_pct SHAP = {attributions['boundary_pct']:.4f} (positive as expected)")

def test_explain_returns_all_features():
    """Response must contain SHAP values for all 7 features."""
    with httpx.Client(base_url="http://localhost:8000") as client:
        resp = client.post("/explain", json=rohit_ipl_features)
    body = resp.json()
    feature_names = [a["feature"] for a in body["feature_attributions"]]
    expected_features = [
        "batting_average", "innings_count", "boundary_pct",
        "dot_ball_pct", "opponent_bowling_quality", "venue_encoded", "is_knockout"
    ]
    for f in expected_features:
        assert f in feature_names, f"Missing feature '{f}' in SHAP response"
    print(f"[PASS] All 7 features present in SHAP response")

if __name__ == "__main__":
    test_explain_shap_consistency()
    test_explain_boundary_pct_positive_attribution()
    test_explain_returns_all_features()
    print("\nAll SHAP endpoint tests PASSED.")

Testing & Verification

Rebuild and run the Docker container with the SHAP extension, then run both the curl spot-check and the pytest suite. The expected /explain response for Rohit Sharma's high-boundary-percentage profile should show boundary_pct as the largest positive attribution and dot_ball_pct as a significant negative attribution. If the consistency check fails (SHAP sum + base ≠ predicted), verify that the pyfunc model and the XGBoost native model are loaded from the same MLflow run — mismatched models are the most common source of this inconsistency.

Analogy🏏Cricket
🏏 Think of it like cricket: the verification script is the match referee's pre-game inspection, and its value is exactly that it checks the *system*, not the players. Before any international fixture, the referee walks a fixed checklist — pitch hardness, boundary rope distance, floodlight levels, sightscreen operation — because a brilliant team on a defective ground still produces an invalid match. Your script does the referee's walk over the tracking setup: can it reach the tracking store, do the expected six runs exist, does each run carry its parameters and metrics, is exactly one model version sitting in Production? Each check is boring in isolation and decisive in combination. The deeper habit this builds is the MLOps one: never certify a pipeline by eyeballing one pretty dashboard screenshot, the way a referee never certifies a ground by glancing at it from the pavilion. Write the checklist as executable code, run it after every change, and let a green result — like the referee's signed pre-match form — be the only thing that clears the ground for play. When the same script runs in CI, every future teammate inherits the inspection for free.
python
# curl spot-check for /explain
spot_check_cmd = """
# Rebuild container with SHAP dependency
docker compose up --build -d

# Test /explain endpoint with Rohit Sharma's profile
curl -s -X POST http://localhost:8000/explain \\
  -H 'Content-Type: application/json' \\
  -d '{
    "batting_average": 31.17,
    "innings_count": 243,
    "boundary_pct": 0.60,
    "dot_ball_pct": 0.25,
    "opponent_bowling_quality": 6.5,
    "venue_encoded": 5,
    "is_knockout": 0
  }' | python3 -m json.tool

# Expected response structure:
# {
#   "base_value": 132.18,
#   "predicted_strike_rate": 154.32,
#   "feature_attributions": [
#     {"feature": "boundary_pct",  "shap_value": 17.84},
#     {"feature": "dot_ball_pct",  "shap_value": -9.12},
#     {"feature": "batting_average","shap_value": 4.67},
#     ... (remaining features sorted by |shap_value|)
#   ],
#   "model_version": "1"
# }

# Run pytest suite
pytest test_explain.py -v
"""
print(spot_check_cmd)

Warning: Do not load two separate copies of the XGBoost model at startup — one for pyfunc /predict and a separate one for SHAP /explain — without ensuring they come from the same run_id. If the Staging version changes between the two load calls (rare but possible in concurrent deployments), the SHAP base value will not match the pyfunc prediction, breaking the consistency guarantee. Load both artefacts from the same model_uri within the same lifespan startup block.

Pro Tip

SHAP's TreeExplainer for XGBoost uses the exact tree path algorithm (not the default kernel-based approximation), which means SHAP values are exact rather than approximate. This makes consistency checks (sum + base = prediction) reliable to floating-point precision. Always prefer TreeExplainer over KernelExplainer for tree-based models — it is orders of magnitude faster and mathematically exact.

  • SHAP's additive property guarantees that base_value + sum(all SHAP values) equals the model prediction — use this as your primary correctness test for the /explain endpoint.
  • Use mlflow.xgboost.load_model (not mlflow.pyfunc.load_model) to get the native XGBoost Booster object that SHAP TreeExplainer requires for exact tree-path attribution.
  • Sort feature attributions by absolute SHAP value before returning so front-end waterfall charts receive features pre-ordered from most to least impactful.
  • Load both the pyfunc model and the SHAP explainer from the same model_uri in the lifespan startup block to guarantee consistency between /predict and /explain responses.
  • SHAP TreeExplainer is the correct choice for XGBoost — it uses the exact tree-path algorithm, making it both faster than KernelExplainer and mathematically exact rather than approximate.
Lesson 34 of 35
0% complete