Python & Data Science
Explainability Under review

LIME vs. SHAP: Choosing the Right 'Translator' for Your Black-Box Models

1. The ‘Black Box’ Problem: Why Your Model is Keeping Secrets

Imagine you’re a loan officer. Maria and her partner walk in, looking for their first mortgage. Decent jobs, some savings, a clean history. You plug their data into the company’s new machine learning model. The screen flashes red: Denied.

Last time, Mr. Owens — the lending team’s manager — asked the data science team to explain exactly why the model denied Maria’s application. The team pulled SHAP values, built a force plot, and pointed to the specific features that pushed Maria’s prediction toward “No.” But the moment Mr. Owens saw the explanation, he leaned back and asked a follow-up: “Is there a simpler, faster tool I could use when a customer is sitting right in front of me?” This article answers that question. It introduces the other major translator in the Explainable AI toolkit: LIME.

The couple asks, “Why?” You look at the screen, but all you see is a probability score. You can’t tell them if it was their debt-to-income ratio, their length of employment, or some quirk in the data.

This is the “Black Box” problem. Models like Random Forests or XGBoost find patterns humans can’t, but they can’t explain their work. A 90% accuracy score is great. It doesn’t tell you whether the model is biased against a specific zip code or just got lucky on the test set. We need a translator to turn those complex math weights into human reasons.

So let’s build a quick, uninterpretable model — something to translate.

import pandas as pd
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split

# Let's create a fake loan dataset
np.random.seed(42)
data_size = 1000
data = pd.DataFrame({
    'Income': np.random.normal(50000, 15000, data_size),
    'Credit_Score': np.random.normal(650, 50, data_size),
    'Age': np.random.randint(18, 70, data_size),
    'Loan_Amount': np.random.normal(200000, 50000, data_size)
})

# Target: 1 if approved, 0 if denied (simple logic for the sake of the example)
target = (data['Income'] * 0.004 + data['Credit_Score'] * 0.5 - data['Loan_Amount'] * 0.001 > 300).astype(int)

X_train, X_test, y_train, y_test = train_test_split(data, target, test_size=0.2, random_state=42)

# Train a Random Forest - our 'Black Box'
model = RandomForestClassifier(n_estimators=100, random_state=42)
model.fit(X_train, y_train)

# Pick the first applicant in the test set the model actually denies,
# instead of hardcoding a row and hoping it's a denial.
denied_indices = np.where(model.predict(X_test) == 0)[0]
person_id = int(denied_indices[0])
person_data = X_test.iloc[[person_id]]
prediction = model.predict(person_data)[0]
assert prediction == 0, "expected a denied applicant"
print(f"Loan Status for Person {person_id}: {'Approved' if prediction == 1 else 'Denied'}")
  • np.random.seed(42) — Sets the random number generator to a fixed starting point so the synthetic loan data comes out identical every time the notebook runs.
  • np.random.normal(50000, 15000, data_size) — Draws 1,000 samples from a normal distribution with mean 50,00050{,}000 and standard deviation 15,00015{,}000, simulating applicant incomes.
  • np.random.normal(650, 50, data_size) — Draws credit scores centred at 650 with a spread of 50 points.
  • np.random.randint(18, 70, data_size) — Generates integer ages in the half-open range [18,70)[18, 70) — the upper bound is excluded, so the oldest simulated applicant is 69.
  • data['Income'] * 0.004 + data['Credit_Score'] * 0.5 - data['Loan_Amount'] * 0.001 > 300 — Computes a weighted sum of the three driving features and thresholds it at 300 to create the approval/denial label. Higher income and credit score push toward approval; a higher loan amount pushes toward denial. This threshold sits close to the score’s median, which gives a roughly 60/40 approval split — workable class balance for both training and for finding denied examples to explain, unlike a much lower threshold that would make denials rare.
  • .astype(int) — Converts the boolean result of the comparison into 0/1 integers (0 = Denied, 1 = Approved) so the target can be used for classification.
  • train_test_split(data, target, test_size=0.2, random_state=42) — Shuffles the 1,000 applicants and reserves 20% (200 rows) for testing. random_state=42 makes the split reproducible.
  • RandomForestClassifier(n_estimators=100, random_state=42) — Creates (but does not yet train) a random forest with 100 decision trees.
  • model.fit(X_train, y_train) — Trains each tree on a bootstrap sample of the training data; features are randomly subsampled at each split.
  • np.where(model.predict(X_test) == 0)[0] — Runs the model over the whole test set and returns the positions of every row it actually predicts as denied. Picking person_id from this list — rather than a hardcoded row number — guarantees the walkthrough is explaining a real denial instead of assuming one.
  • X_test.iloc[[person_id]] — Double brackets return a single-row DataFrame (2-D), which is what scikit-learn’s predict expects. Single brackets would return a 1-D Series and trigger a shape error.
  • assert prediction == 0 — A cheap sanity check: if this ever fails, the rest of the walkthrough would be narrating the wrong outcome, so it’s better to fail loudly here than silently mislabel the story.

Our model says this person is denied. But why? This is where LIME and SHAP come in. They are the two most popular translators in Explainable AI (XAI), but they speak very different languages.

2. LIME: The Friendly Neighbor Approach

Think of LIME (Local Interpretable Model-agnostic Explanations) as a surveyor inspecting one house, not the whole city.

LIME doesn’t try to understand the entire Random Forest. It zeros in on the specific person who was denied and “pokes” at their case. It generates a bunch of fake versions of that person—maybe one with $1,000 more income, or one who is 5 years older—and asks the model what it thinks of each.

By watching how the model’s answer shifts as those details change, LIME builds a simple local map around that person. That’s the “Local” part: it only cares about the ‘why’ for this one individual right now.

Let’s see what happens when we ask LIME to explain our denied applicant.

from lime import lime_tabular

# Initialize the LIME explainer
explainer = lime_tabular.LimeTabularExplainer(
    training_data=np.array(X_train),
    feature_names=X_train.columns,
    class_names=['Denied', 'Approved'],
    mode='classification'
)

# Explain the prediction for our specific person
exp = explainer.explain_instance(person_data.values[0], model.predict_proba)

# Show the results
for feature, weight in exp.as_list():
    print(f"{feature}: {weight:.4f}")
  • from lime import lime_tabular — Imports the tabular-data module from the lime library. For image data you’d use lime.lime_image; for text, lime.lime_text.
  • lime_tabular.LimeTabularExplainer(...) — Creates a LIME explainer object. Unlike SHAP’s TreeExplainer, LIME stores the training data at initialization so it can perturb it later.
  • training_data=np.array(X_train) — The background data LIME uses to understand each feature’s distribution and generate perturbed samples. Converting to np.array strips column names but preserves values.
  • feature_names=X_train.columns — Labels for each feature, so LIME can print human-readable conditions like "Credit_Score <= 618" instead of "Feature 1 <= 618".
  • class_names=['Denied', 'Approved'] — Labels for the two classes; index 0 maps to ‘Denied’ (class 0), index 1 to ‘Approved’ (class 1).
  • mode='classification' — Tells LIME the model outputs class probabilities, not a continuous regression target.
  • explainer.explain_instance(person_data.values[0], model.predict_proba) — The core LIME call: perturbs the input features around person_data.values[0], gets model predictions for each perturbation via model.predict_proba, fits a weighted local linear model, and returns the top contributing feature-condition pairs. By default this explains class 1 (Approved).
  • exp.as_list() — Returns the explanation as a list of (condition_string, weight) tuples, e.g. ('Income <= 40236.77', -0.5168). Because the explanation targets the Approved class, a negative weight means that condition pushed toward ‘Denied.’

Running this on our applicant prints something like:

Income <= 40236.77: -0.5168
165521.18 < Loan_Amount <= 198645.91: 0.1684
619.69 < Credit_Score <= 654.45: -0.0352
Age <= 31.00: -0.0053

A negative number means that condition pushed the model toward “Denied.” This applicant’s low income (below $40,237) is by far the biggest reason for the rejection — its weight of -0.5168 dwarfs the other three conditions combined. LIME is fast and intuitive, but there’s a catch: because it uses random “pokes,” you might get slightly different answers if you run it twice on the same person. Run explainer.explain_instance again on the same row and you’ll see it: across four consecutive calls on this applicant, Loan_Amount came back as 0.1684, 0.1439, 0.1411, and 0.1626 — never identical — and Age flipped sign outright, from -0.0053 on the first run to +0.0052 on the second. That instability is a real, demonstrable property of LIME — not just a theoretical caveat.

3. SHAP: The Fair Share Approach

SHAP (SHapley Additive exPlanations) takes a more formal approach. The method builds on “Shapley Values” — a concept from Nobel Prize-winning game theory about fairly dividing a prize among players in a group project.

Features (Income, Age, Credit Score) become players in a game. The prize is the final prediction. SHAP calculates the “average contribution” of a feature by testing it in every possible combination with other features.

Here’s the tricky part: SHAP doesn’t just poke the model. It mathematically accounts for the fact that Income might matter more when Credit Score is low than when it is high. The contributions of all features are guaranteed to add up exactly to the difference between the actual prediction and the average prediction. That’s what makes it “fair” and mathematically consistent.

import shap

# Initialize the SHAP explainer (TreeExplainer is optimized for Random Forests)
shap_explainer = shap.TreeExplainer(model)
shap_values = shap_explainer.shap_values(person_data)

# Let's look at the SHAP values for the 'Denied' class (index 0)
# We'll print the contribution of each feature
feature_names = X_test.columns
for i, name in enumerate(feature_names):
    print(f"{name}: {shap_values[0, i, 0]:.4f}")

# Verify additivity for real, instead of just eyeballing the numbers
base_value = shap_explainer.expected_value[0]
phi_denied = shap_values[0, :, 0]
proba_denied = model.predict_proba(person_data)[0][0]
assert abs(base_value + phi_denied.sum() - proba_denied) < 1e-6
print(f"base + sum(phi) = {base_value + phi_denied.sum():.4f}, model says {proba_denied:.4f}")
  • import shap — Imports the SHAP library.
  • shap.TreeExplainer(model) — Creates a SHAP explainer specialised for tree-based models (random forests, gradient boosting, XGBoost, etc.). It uses the exact TreeSHAP algorithm, which is polynomial-time and much faster than the general-purpose KernelExplainer that works on any model but uses sampling.
  • shap_explainer.shap_values(person_data) — Computes SHAP values for every feature in the single row person_data. On a current shap install this returns a single NumPy array shaped (n_rows, n_features, n_classes) — for one applicant and four features, (1, 4, 2). That’s different from older versions, which returned a list of two (n_rows, n_features) arrays — one per class. If you index it the old way (shap_values[0][0, i]), [0] silently selects row 0 of the array instead of class 0, and every downstream feature lookup is wrong.
  • shap_values[0, i, 0] — Selects row 0 (our single applicant), feature i, and class 0 (Denied) — the correct way to reach the same value on the current array shape. A positive value here means that feature pushed the prediction toward Denied (increasing the class-0 probability); negative means it pushed toward Approved.
  • enumerate(feature_names) — Pairs each feature name with its integer position so we can print name: value lines in the loop.
  • shap_explainer.expected_value — The model’s average predicted probability for each class across the background data, as an array of length 2 ([P(Denied), P(Approved)]).
  • assert abs(base_value + phi_denied.sum() - proba_denied) < 1e-6 — This is the additivity guarantee made concrete: the base rate plus every feature’s contribution must equal the model’s actual predicted probability for the Denied class, to within floating-point error. Printing a number that merely looks plausible isn’t verification — asserting it is.

Running this on our applicant gives:

Income: 0.6149
Credit_Score: 0.0312
Age: 0.0136
Loan_Amount: -0.0251
base + sum(phi) = 1.0000, model says 1.0000

Income is overwhelmingly the largest contributor toward this applicant’s denial — more than ten times the size of the next-largest feature. Credit Score adds a small further push toward denial; the applicant’s below-average loan amount is the one factor working slightly in their favor. And the additivity check passes exactly: the base rate (36.5% average denial probability) plus every feature’s contribution sums to the model’s own 100% denial probability for this applicant. Unlike LIME, SHAP gives you the same answer every time — run this code twice and you get bit-identical numbers.

LIME and SHAP both produce per-feature numbers for a single prediction, but they come from very different mathematics. Here is the side-by-side.

LIME explains a prediction f(x)f(x) by fitting a simple linear model gg in a neighbourhood around xx, weighted by a proximity kernel πx\pi_x:

ξ(x)=argmingG  zZπx(z)   ⁣(f(z),g(z))+Ω(g),g(z)=w0+i=1dwizi\xi(x) = \arg\min_{g \in G} \; \sum_{z \in \mathcal{Z}} \pi_x(z)\;\ell\!\big(f(z),\, g(z)\big) + \Omega(g), \qquad g(z) = w_0 + \sum_{i=1}^{d} w_i\, z_i

The LIME “weights” wiw_i are the coefficients of this local linear model — they are an approximation of the black box near xx, not an exact decomposition.

SHAP instead computes the Shapley value ϕi\phi_i for each feature, guaranteeing exact additivity:

f(x)=E[f(x)]  +  i=1dϕif(x) = \mathbb{E}[f(x)] \;+\; \sum_{i=1}^{d} \phi_i

The Shapley value itself is defined as a weighted average over all possible feature coalitions (see the formula in the previous article for the full expression). The key difference: SHAP’s ϕi\phi_i provably sum to f(x)E[f(x)]f(x) - \mathbb{E}[f(x)]; LIME’s wiw_i are a local fit with no such guarantee.

Plain EnglishStatistical symbolPython equivalent
The instance being explainedxxperson_data.values[0]
Perturbed samples near xx (LIME)zZz \in \mathcal{Z}Generated internally by explainer.explain_instance
Proximity kernel weight for sample zz (LIME)πx(z)\pi_x(z)Computed internally via kernel_width (default 0.75)
LIME local linear weight for feature iiwiw_iweight in for feature, weight in exp.as_list()
SHAP (Shapley) value for feature ii, class 0ϕi\phi_ishap_values[0, i, 0]
SHAP baseline (average prediction)E[f(x)]\mathbb{E}[f(x)]shap_explainer.expected_value[0]
The black-box modelffmodel.predict / model.predict_proba

Why this matters in practice: SHAP’s additivity means you can verify the explanation: base_value + sum(shap_values) always equals the model’s prediction. LIME’s weights are a local approximation — they tell you the direction and relative magnitude of each feature’s influence, but they don’t sum up to anything provable about the model’s actual output.

4. What Do You Actually Get From Each?

Both tools translate the same black box, but they buy you different things.

LIME buys you speed. It is incredibly fast. If you have a massive dataset with millions of rows and you need a quick explanation in a dashboard that loads in milliseconds, LIME fits that constraint well. The trade-off: because it fits a local linear approximation rather than computing an exact decomposition, it carries no additivity guarantee, and the “instability” isn’t hypothetical — the demo above shows the same row producing different weights (and even a sign flip) across consecutive runs, especially where the model’s logic is very wiggly in that specific spot.

SHAP buys you consistency. It is mathematically grounded, and its additivity guarantee is one you can actually check by re-running the assertion above. If you are in a regulated industry like banking or healthcare, where you might have to prove to a government auditor exactly why a decision was made, that reproducibility matters. But there’s a price. SHAP is computationally more expensive than LIME — for very complex models, calculating SHAP values can take minutes or even hours, whereas LIME takes seconds.

FeatureLIME WeightSHAP Value
SpeedLightning FastSlow (usually)
ConsistencyCan vary between runsAlways the same
Math FoundationLocal Linear RegressionGame Theory
Best ForQuick debuggingCompliance & Trust

5. Which One Should You Use?

Here is your decision matrix for your next project:

Use LIME when:

  • You are working with images or text (LIME handles these very naturally).
  • You need explanations to generate in real-time for a high-traffic app.
  • You just need a “good enough” idea of what’s happening for debugging.

Use SHAP when:

  • You are using tree-based models like XGBoost, LightGBM, or CatBoost (the TreeExplainer makes SHAP much faster for these).
  • You are in a regulated field where accuracy and “fairness” are legal requirements.
  • You want to see how features interact with each other globally across the whole dataset.

In the end, both tools turn scary “black boxes” into transparent “glass boxes.” They allow us to stop guessing and start explaining, making our AI not just smart, but accountable.

Recap:

  • LIME pokes the model locally to see what happens; it’s an approximation with no additivity guarantee, not something that “lies” — it just doesn’t promise to add up.
  • SHAP uses game theory to distribute credit fairly, and the additivity check is something you can verify, not just trust.
  • LIME is for speed; SHAP is for consistency.
  • Both are essential tools in a modern data scientist’s kit.

Mr. Owens studies both explanations for the applicant’s denial — LIME’s quick weights and SHAP’s exact contributions — and nods slowly. He taps the Income column on the screen: “I can see why the model said no — income was the single biggest factor pulling this application toward denial, more than the credit score or the loan amount. Both tools agree on that, and SHAP tells me exactly how much.”

Check Your Understanding

Questions below move from simple recall up to open-ended design, roughly following Bloom’s Taxonomy.

Remember What does it mean that SHAP values are guaranteed to “add up exactly to the difference between the actual prediction and the average prediction,” and does LIME make that same guarantee?

Understand In your own words, explain why LIME can give slightly different answers if you run it twice on the same person, using the article’s “random pokes” explanation.

Apply Using the article’s decision matrix, which tool would you pick for a real-time fraud-scoring dashboard that needs an explanation to render in under 100ms for every transaction?

Analyze The article says SHAP “mathematically accounts for the fact that Income might matter more when Credit Score is low than when it is high,” while LIME builds a “simple, local map” via a local linear approximation. Walk through why a locally linear approximation (LIME) would struggle to capture this kind of feature interaction, even in the small neighborhood around one specific applicant.

Evaluate The article recommends SHAP for regulated industries “where you might have to prove to a government auditor exactly why a decision was made.” Critique this as a compliance strategy: SHAP explains what the model weighted heavily — does that automatically prove the decision itself was fair, or could a model be consistently and mathematically explainable while still being biased?

Create Design an explainability strategy for a new use case: a hospital readmission-risk model used both for (a) a real-time nurse dashboard flagging high-risk patients at discharge, and (b) a quarterly compliance report reviewed by regulators. Would you use LIME, SHAP, or both, and for which of the two use cases — justify using the article’s speed-vs-consistency tradeoff.



References & Further reading

  • Ribeiro, M. T., Singh, S., & Guestrin, C. (2016). “‘Why Should I Trust You?’: Explaining the Predictions of Any Classifier.” Proceedings of the 22nd ACM SIGKDD International Conference on Knowledge Discovery and Data Mining (KDD ‘16). https://arxiv.org/abs/1602.04938 — The foundational paper introducing LIME, including the local linear approximation objective and the perturbation-based sampling strategy.
  • Lundberg, S. M., & Lee, S.-I. (2017). “A Unified Approach to Interpreting Model Predictions.” Advances in Neural Information Processing Systems 30 (NeurIPS 2017). https://arxiv.org/abs/1705.07874 — The foundational paper introducing SHAP and the TreeSHAP algorithm, useful for the comparison in this article.
  • lime library documentation. https://github.com/marcotcr/lime — Full API reference and tutorials for tabular, image, and text explanations.
  • shap library documentation. https://shap.readthedocs.io — Full API reference, including TreeExplainer, KernelExplainer, and plotting utilities.
  • Kaggle: “Home Credit Default Risk” competition. https://www.kaggle.com/c/home-credit-default-risk — A real-world loan-default dataset where both LIME and SHAP explanations are widely demonstrated in top-scoring notebooks; an excellent sandbox for comparing the two tools on this article’s exact scenario.

Apply What You Learned is for Supporter and Insider subscribers.

Subscribe to unlock the exercises on this post.

See plans

Looking for something else?

Search every article by title, summary or topic.