The Story That Explains LIME
So the doctor doesn't read all 400 pages. Instead, she zooms in on just the patient in front of her. She says: "For this specific patient, three things mattered most — high blood glucose, high BMI, and age over 55. Everything else barely moved the needle."
That, in one sentence, is LIME. It does not try to explain the whole model. It explains one prediction at a time, by building a tiny, understandable model that works just around that one data point.
LIME — Local Interpretable Model-agnostic Explanations — is a technique that explains the prediction of any classifier or regressor in a faithful and interpretable way. Introduced by Ribeiro, Singh, and Guestrin in 2016, it has become one of the most widely used tools in explainable AI.
Local: Explains a single prediction, not the whole model.
Interpretable: Uses a simple model (linear) that humans can read.
Model-agnostic: Works on any black-box model — XGBoost, neural network, Random Forest, SVM — it doesn't matter.
Why Black-Box Models Need Explaining
Modern ML models are extraordinarily powerful. But that power comes from stacking thousands of non-linear decisions on top of each other — decisions that no human can trace from input to output. This creates three serious problems:
Many models with very different internal logic can produce identical predictions on a test set. Accuracy alone tells you nothing about whether a model has learned the right features for the right reasons. LIME is one tool that lets you look inside.
How LIME Works — The Core Algorithm
LIME uses a clever trick: instead of trying to understand the whole model globally, it pretends to be the model in the tiny neighbourhood around the single prediction you care about.
predict() function.Blue dots = class 0 · Red dots = class 1 · Dashed circle = LIME neighbourhood around selected point · Line = local linear surrogate fitted inside that neighbourhood
The LIME Objective Function
LIME's algorithm is expressed as a formal optimisation problem. Understanding it demystifies the "magic" and reveals exactly what trade-offs are being made.
The objective balances fidelity (how accurately g mimics f near x) against simplicity (keeping g readable). You can always make a more faithful explanation by using more features — but then it's no longer interpretable. LIME forces you to choose K features that give the best fidelity-per-feature trade-off.
LIME for Tabular Data — Step-by-Step
| Feature | Value for #4127 | LIME Weight | Direction | Interpretation |
|---|---|---|---|---|
CreditScore | 580 | −0.38 | ↓ Reject | Score below 600 threshold strongly pushed toward rejection |
DebtToIncome | 0.43 | −0.29 | ↓ Reject | Debt-to-income ratio above 0.40 is a red flag for this model |
Employment | 3 yr | +0.14 | ↑ Approve | 3 years of stable employment partially offsets other negatives |
Income | £28,000 | −0.11 | ↓ Reject | Below median income for this loan size |
Age | 34 | +0.04 | ≈ Neutral | Age has minimal influence on this prediction |
The compliance team can now say: "You were rejected primarily because your credit score (580) is below our threshold and your debt-to-income ratio (0.43) exceeds our limit. Your employment history is a positive factor. Improving your credit score by 40 points would change the outcome." — This is a real, legally defensible explanation.
LIME for Text Data
For text, LIME's perturbation is conceptually simple: it removes words one by one (or in groups) and observes how the model's prediction changes.
Green bars = words pushing toward POSITIVE prediction · Red bars = pushing toward NEGATIVE · Intensity = magnitude of influence
LIME for Image Data
Images present a unique challenge: you can't perturb individual pixels — there are too many, and a single pixel means nothing. Instead, LIME uses superpixels — coherent image regions grouped by colour and texture (via algorithms like SLIC).
Simulated superpixels · Bright = high LIME weight (supporting "Dog" class) · Dark = masked off · Watch as perturbations are generated then the explanation is revealed
Python Implementation — LIME for Tabular Data
Installation
# Install the lime library
pip install lime
Complete Example — Credit Default Prediction
import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
from sklearn.datasets import make_classification
import lime
import lime.lime_tabular
# ── 1. Simulate a credit dataset ──────────────────────────────
np.random.seed(42)
n = 3000
data = pd.DataFrame({
'age': np.random.randint(21, 70, n),
'income': np.random.normal(35000, 12000, n).clip(15000),
'debt': np.random.normal(8000, 5000, n).clip(0),
'credit_score': np.random.normal(620, 80, n).clip(300, 850),
'loan_amount': np.random.normal(15000, 6000, n).clip(1000),
'employment_yrs': np.random.randint(0, 20, n),
})
# Target: default = 1 (driven mainly by low credit score + high debt)
data['default'] = (
(data['credit_score'] < 580).astype(int) * 2 +
(data['debt'] / data['income'] > 0.4).astype(int) +
np.random.binomial(1, 0.12, n)
).clip(0, 1)
feature_names = ['age', 'income', 'debt', 'credit_score', 'loan_amount', 'employment_yrs']
X = data[feature_names].values
y = data['default'].values
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)
# ── 2. Train a black-box model ─────────────────────────────────
model = GradientBoostingClassifier(n_estimators=200, random_state=42)
model.fit(X_train, y_train)
print(f"Test Accuracy: {model.score(X_test, y_test):.4f}")
# ── 3. Create the LIME explainer ───────────────────────────────
explainer = lime.lime_tabular.LimeTabularExplainer(
training_data = X_train, # training set for distribution estimation
feature_names = feature_names, # column names
class_names = ['No Default', 'Default'],
mode = 'classification', # or 'regression'
discretize_continuous = True, # convert continuous → quartile bins for display
random_state = 42
)
# ── 4. Explain a single prediction ────────────────────────────
instance_idx = 12
instance = X_test[instance_idx]
print(f"\nBlack-box prediction: {model.predict_proba([instance])[0]}")
print(f"True label: {y_test[instance_idx]}")
exp = explainer.explain_instance(
data_row = instance,
predict_fn = model.predict_proba, # must return probability array
num_features = 6, # top-K features to show
num_samples = 5000, # N perturbed samples to generate
top_labels = 1
)
# ── 5. Show results ────────────────────────────────────────────
print("\nLIME Feature Importances (for predicted class 'Default'):")
for feat, weight in exp.as_list():
direction = "▲ Toward Default" if weight > 0 else "▽ Away from Default"
print(f" {feat:40s} {weight:+.4f} {direction}")
# Optional: open in browser (generates interactive HTML)
# exp.show_in_notebook(show_table=True)
# exp.save_to_file('/tmp/lime_explanation.html')
LIME for Regression
from sklearn.ensemble import RandomForestRegressor
from sklearn.datasets import fetch_california_housing
housing = fetch_california_housing()
X, y = housing.data, housing.target
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
rf = RandomForestRegressor(n_estimators=300, n_jobs=-1, random_state=42)
rf.fit(X_train, y_train)
# LIME for regression: mode='regression', predict_fn returns 1D array
reg_explainer = lime.lime_tabular.LimeTabularExplainer(
training_data = X_train,
feature_names = housing.feature_names,
mode = 'regression', # ← key change
discretize_continuous = True,
random_state = 42
)
instance = X_test[0]
exp_reg = reg_explainer.explain_instance(
data_row = instance,
predict_fn = rf.predict, # for regression: takes 2D array → 1D array
num_features= 6,
num_samples = 3000,
)
print(f"Predicted value: {rf.predict([instance])[0]:.3f}")
for feat, w in exp_reg.as_list():
print(f" {feat:35s} {w:+.4f}")
Python Implementation — LIME for Text
import numpy as np
from sklearn.pipeline import make_pipeline
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
import lime
import lime.lime_text
# ── 1. Train a text classifier ─────────────────────────────────
texts = [
"The food was absolutely wonderful",
"Terrible service and bland food",
"Not bad at all, pleasantly surprised",
"I would not recommend this restaurant",
"Best meal I have had in years",
"Cold food, rude staff, never again",
"Exceptional flavours and warm atmosphere",
"Disappointing experience overall",
]
labels = [1, 0, 1, 0, 1, 0, 1, 0] # 1=positive, 0=negative
pipeline = make_pipeline(
TfidfVectorizer(ngram_range=(1,2)),
LogisticRegression(max_iter=1000)
)
pipeline.fit(texts, labels)
# ── 2. Build the LIME text explainer ──────────────────────────
text_explainer = lime.lime_text.LimeTextExplainer(
class_names = ['Negative', 'Positive'],
split_expression = ' ', # tokenise by space
bow = True, # bag-of-words mode (word order not preserved)
random_state = 42
)
# ── 3. Explain a specific prediction ──────────────────────────
text_to_explain = "The food was not bad at all — I was pleasantly surprised"
print(f"Prediction: {pipeline.predict_proba([text_to_explain])[0]}")
exp_text = text_explainer.explain_instance(
text_instance = text_to_explain,
classifier_fn = pipeline.predict_proba,
num_features = 6,
num_samples = 1000,
labels = [1] # explain class 1 (Positive)
)
print("\nWord importances for 'Positive' class:")
for word, w in exp_text.as_list(label=1):
bar = "█" * int(abs(w) * 50)
sign = "+" if w > 0 else "-"
print(f" {word:18s} {sign}{bar} ({w:+.4f})")
The LIME explanation reveals that the model is assigning heavy negative weight to the word "not" and "bad" — treating them as independent signals rather than understanding "not bad" as a positive phrase. This is a known limitation of bag-of-words models. LIME just diagnosed a model deficiency that raw accuracy metrics would never catch.
LIME for Images — Using lime.lime_image
import numpy as np
from PIL import Image
import lime
import lime.lime_image
from skimage.segmentation import mark_boundaries
# ── Assumes you have a Keras/PyTorch model returning class probabilities ─
# from tensorflow.keras.applications import MobileNetV2
# model = MobileNetV2(weights='imagenet')
# Preprocess function (model-specific)
def preprocess(img_array):
# Resize, normalise, add batch dim — adjust to your model's requirements
return img_array.astype(np.float32) / 255.0
# Predict function: takes (N, H, W, 3) → (N, n_classes) probability array
def predict_fn(images):
preprocessed = np.array([preprocess(img) for img in images])
return model.predict(preprocessed) # replace 'model' with yours
# ── Build LIME image explainer ──────────────────────────────
image_explainer = lime.lime_image.LimeImageExplainer()
# Load your image as numpy array (H, W, 3), uint8
img = np.array(Image.open('dog.jpg').resize((224, 224)))
exp_img = image_explainer.explain_instance(
image = img,
classifier_fn = predict_fn,
top_labels = 3, # explain top-3 predicted classes
hide_color = 0, # colour to use when masking (0=black)
num_samples = 1000, # N perturbed images
num_features = 10, # top superpixels to highlight
segmentation_fn = None, # None = use default SLIC segmentation
)
# ── Visualise: positive superpixels for top predicted class ──
top_class = exp_img.top_labels[0]
temp_img, mask = exp_img.get_image_and_mask(
label = top_class,
positive_only = True, # show only SUPPORTING superpixels
num_features = 5, # top 5 superpixels
hide_rest = True # grey out non-explanatory regions
)
# Overlay superpixel boundaries on the image
img_with_boundaries = mark_boundaries(temp_img / 255.0, mask)
# Display: plt.imshow(img_with_boundaries); plt.axis('off'); plt.show()
Hyperparameters That Matter in LIME
| Parameter | Default | Effect | Tuning Advice |
|---|---|---|---|
num_samples | 5000 | Number of perturbed points generated around x | Lower = faster but less stable. Below 500 explanations become unreliable. 1000–5000 is the sweet spot. |
num_features | 10 | How many top features to include in explanation | 3–8 features gives human-readable explanations. More than 10 is rarely interpretable. |
kernel_width | 0.75√p | Controls the neighbourhood size σ in the proximity kernel | Crucial and often overlooked. Smaller = tighter neighbourhood, more local but higher variance. Try 0.25, 0.5, 1.0. |
discretize_continuous | True | Convert continuous features to quartile bins | True gives more human-readable output ("age between 35–50"). False gives exact thresholds. |
sample_around_instance | False | Whether to sample from N(x, σ²) vs the training distribution | True for out-of-distribution instances. False (default) is usually better for in-distribution data. |
distance_metric | 'euclidean' | How proximity is measured between x and perturbed points | Euclidean works well for most tabular data. For mixed types, consider 'cosine'. |
Run LIME on the same instance twice with different random seeds and you may get
different feature rankings. This is called explanation instability
and is inherent to the stochastic sampling process. Mitigation: increase
num_samples to 5000–10000 and average over multiple runs. If
consistency is critical, consider SHAP instead — which has exact or near-exact solutions.
Submodular Pick — Explaining the Whole Dataset
Explaining every instance individually does not scale. LIME includes a technique called SP-LIME (Submodular Pick), which selects a small set of representative instances that, together, provide maximum coverage of the model's behaviour.
from lime.lime_tabular import LimeTabularExplainer
from lime.submodular_pick import SubmodularPick
# The SP-LIME algorithm selects B instances that provide maximum coverage
sp = SubmodularPick(
explainer = explainer, # LimeTabularExplainer from earlier
data = X_test, # pool to pick from
predict_fn = model.predict_proba,
method = 'full', # 'full'=greedy cover, 'sample'=faster
num_features = 6,
num_exps_desired = 5 # B = number of representative instances
)
# sp.sp_explanations contains B LimeExplanation objects
print(f"Selected {len(sp.sp_explanations)} representative instances")
for i, exp in enumerate(sp.sp_explanations):
print(f"\n── Instance {i+1} ──────────────────────────")
for feat, w in exp.as_list():
print(f" {feat:40s} {w:+.4f}")
LIME vs SHAP — Which Should You Use?
| Property | LIME | SHAP |
|---|---|---|
| Theoretical foundation | Heuristic — local linear approximation | Axiomatic — Shapley values from game theory |
| Consistency | Stochastic — results vary per run | Deterministic (TreeSHAP) or near-deterministic |
| Speed | Fast — single prediction in seconds | Varies — TreeSHAP is fast, KernelSHAP is slow |
| Model-agnostic | Yes — any model | Yes (KernelSHAP) / Model-specific variants faster |
| Image/Text support | Native support built-in | Via DeepSHAP / GradientExplainer (less plug-and-play) |
| Global explanations | Via SP-LIME (approximate) | Native — SHAP summary plots are standard |
| Neighbourhood size | Manual kernel_width tuning needed | No neighbourhood — uses entire feature space |
| Interpretability | Very intuitive — feature weights | Equally intuitive — feature contributions |
| Best use case | Quick local explanations, text & images | Tabular data, reliable global feature importance |
Use SHAP for tabular data when you need reliable, consistent explanations and have a tree-based model — TreeSHAP is fast and exact. Use LIME when you're working with text classifiers or image classifiers, or when you need quick exploratory explanations of a model-agnostic black box. In production, run both: if LIME and SHAP agree, you can trust the explanation. If they disagree, investigate why.
Stability Analysis — Testing Your LIME Explanations
Before presenting a LIME explanation to stakeholders, always run a stability check. Good explanations should be consistent across multiple runs.
import numpy as np
from collections import defaultdict
def lime_stability_check(explainer, instance, predict_fn,
n_runs=10, num_features=5, num_samples=3000):
"""
Run LIME n_runs times and measure consistency of feature rankings.
Returns: dict of {feature: mean_weight, std_weight, rank_frequency}
"""
weight_records = defaultdict(list)
for seed in range(n_runs):
exp = explainer.explain_instance(
data_row = instance,
predict_fn = predict_fn,
num_features = num_features,
num_samples = num_samples,
)
for feat, w in exp.as_list():
weight_records[feat].append(w)
results = {}
for feat, weights in weight_records.items():
arr = np.array(weights)
results[feat] = {
'mean': np.mean(arr),
'std': np.std(arr),
'cv': np.std(arr) / np.abs(np.mean(arr)) if np.mean(arr) != 0 else np.inf,
'appearances': len(weights), # how many runs it appeared in
}
# Sort by |mean weight|
return dict(sorted(results.items(), key=lambda x: -abs(x[1]['mean'])))
stability = lime_stability_check(explainer, X_test[12], model.predict_proba,
n_runs=10, num_features=5)
print(f"{'Feature':40s} {'Mean':>8s} {'Std':>8s} {'CV':>6s} {'Appears':>8s}")
print("-"*78)
for feat, stats in stability.items():
stability_flag = "✓" if stats['cv'] < 0.30 else "⚠"
print(f"{feat:40s} {stats['mean']:+8.4f} {stats['std']:8.4f} {stats['cv']:6.2f} {stats['appearances']:>5}/{10} {stability_flag}")
Features with CV < 0.30 (coefficient of variation) and appearing in 9–10/10 runs
are stable and trustworthy. Features with CV > 0.50 are unstable — treat them as noise,
not signal. In this example, credit_score and debt/income are highly
stable (✓) — we can confidently report them. The lower-ranked features are noisy (⚠) and
should not be used in regulatory or high-stakes reports.
A Complete Real-World Workflow
Here is a production-grade LIME workflow that wraps everything together — model training, explanation, stability validation, and report generation.
class LIMEExplainabilityPipeline:
"""
A production-grade wrapper for LIME explanations.
Handles training, explaining, stability-checking, and reporting.
"""
def __init__(self, model, X_train, feature_names, class_names,
mode='classification', kernel_width=None):
self.model = model
self.feature_names = feature_names
self.class_names = class_names
self.mode = mode
self.explainer = lime.lime_tabular.LimeTabularExplainer(
training_data = X_train,
feature_names = feature_names,
class_names = class_names,
mode = mode,
kernel_width = kernel_width,
discretize_continuous = True,
random_state = 42
)
def explain(self, instance, num_features=6, num_samples=3000):
predict_fn = (self.model.predict_proba
if self.mode == 'classification'
else self.model.predict)
return self.explainer.explain_instance(
data_row = instance,
predict_fn = predict_fn,
num_features = num_features,
num_samples = num_samples,
)
def explain_with_confidence(self, instance, n_runs=10,
num_features=6, num_samples=3000):
"""Returns only features with CV < 0.3 (stable features)."""
stability = lime_stability_check(
self.explainer, instance,
self.model.predict_proba if self.mode == 'classification'
else self.model.predict,
n_runs=n_runs, num_features=num_features, num_samples=num_samples
)
stable_features = {
feat: stats
for feat, stats in stability.items()
if stats['cv'] < 0.30 and stats['appearances'] >= n_runs * 0.8
}
return stable_features
def generate_report(self, instance, stable_features, prediction):
print("="*55)
print("LIME EXPLANATION REPORT")
print("="*55)
print(f"Prediction: {prediction}")
print(f"Reliable features ({len(stable_features)} stable):\n")
for feat, stats in stable_features.items():
direction = "↑ Supports" if stats['mean'] > 0 else "↓ Opposes"
print(f" {feat:40s} {stats['mean']:+.4f} {direction}")
print("="*55)
# Usage
pipeline = LIMEExplainabilityPipeline(
model = model,
X_train = X_train,
feature_names= feature_names,
class_names = ['No Default', 'Default'],
mode = 'classification'
)
instance = X_test[12]
stable = pipeline.explain_with_confidence(instance, n_runs=8)
pred = pipeline.model.predict([instance])[0]
pipeline.generate_report(instance, stable, pred)
Golden Rules for Using LIME in Production
num_samples ≥ 1000, preferably 3000–5000.
With fewer samples the linear surrogate is fitted on insufficient data and explanations
are meaningless.
kernel_width. The default (0.75√p) is rarely optimal.
A very small kernel gives hyper-local but noisy explanations. A very large kernel
approaches a global linear approximation. Cross-validate to find the right balance.
feature_selection='lasso_path' or 'auto' to select the most
informative features before fitting the surrogate. Passing 100+ features to a linear
model with 5000 samples leads to poor explanations.