SupraLocal Interpretable Model Agnostic Explanations (SLIME)

In this demo we explain the SLIME algorithm and show you how to:

  • Use SymbolicModel’s SLIME functionality

  • Approximate local behavior of a black-box model

  • Make predictions with the surrogate symbolic model

SLIME is a very similar model interpretability technique to LIME (Local Interpretable Model Agnostic Explanations). Essentially, LIME is a technique that approximates any black box machine learning model with a linear model around a certain point. The surrogate model is fitted with Gaussian perturbed samples around the point.

SLIME_conceptual_pic0

SLIME extends this idea. Here, we implement the framework as presented by the SLIME paper (Fong, Motani 2025).

SLIME Algorithm: Suppose we have a black-box model \(f(x)\),

  1. We want to fit a local surrogate model \(g^{*}(x) \in \mathcal{G}\) where \(\mathcal{S}\) is the space of closed-form analytic expressions (symbolic models) to estimate the behaviour of \(f(x)\) around point \(x_0\).

  2. To choose our surrogate model, we create a dataset points near \(x_0\). This dataset, \(\mathcal{D}\), consists of the \(J-\) nearest neighbours around \(x_0\) and \(N_{\text{synthetic}}\) number of Gaussian sampled points around \(x_0\) (the default variance of the Gaussian is equal to half the variance of the set of \(J\) neighbours, hence we choose the size of the neighbourhood by setting \(J\)).

  3. We evaluate the outputs of the model on this dataset, \(\mathcal{D}\), to create the targets for our symbolic regression \(\mathcal{T}\).

  4. We choose \(g^{*}(x)\) by

\[ g^*(x) = \arg\min_{g \in \mathcal{S}} \sum_{z_i \in \text{synthetic}} \pi_x(z_i) [f(z_i)-g(z_i)]^2 + M \sum_{z_j \in \text{neighbours}} [f(z_j)-g(z_j)]^2 \]

where \(\pi_x(z_i) = \exp(\frac{-||x_0-x_i||^2}{\sigma^2})\) is the proximity kernel weighting and \(M\) is a weighting for the real neighbours.

SLIME has benefits over LIME as it does not restrict itself to linear models, yet does not have any reduction in interpretability of the surrogate model.

SymTorch’s SymbolicModel can fit a SLIME model for you. The user can specify:

  • \(J-\) nearest neighbours (J_nn = 10)

  • \(M\) (real_weighting = 1.0)

  • \(N_{\text{synthetic}}\) (num_synthetic = 0)

As well as

  • Nearest neighbour metric (nn_metric = "euclidean")

  • PySR parameters (pysr_params=None defaults to default parameters)

The model is fitted using the .distill(SLIME = True) method and predictions can be made using .predict().

SLIME_conceptual_pic

Conceptual example

Let us see an example of how SLIME can be effective at analysing black box models by using a spiral dataset.

import numpy as np
import matplotlib.pyplot as plt

np.random.seed(7)

N = 400
theta = np.sqrt(np.random.rand(N)) * 2 * np.pi

# Class A spiral
r_a = 2 * theta + np.pi
data_a = np.column_stack([np.cos(theta) * r_a, np.sin(theta) * r_a])
x_a = data_a + np.random.randn(N, 2)
y_a = np.zeros(N, dtype=int)

# Class B spiral
r_b = -2 * theta - np.pi
data_b = np.column_stack([np.cos(theta) * r_b, np.sin(theta) * r_b])
x_b = data_b + np.random.randn(N, 2)
y_b = np.ones(N, dtype=int)

# Combine
X = np.vstack([x_a, x_b])
y = np.concatenate([y_a, y_b])

# Shuffle together
idx = np.random.permutation(len(y))
X = X[idx]
y = y[idx]

The dataset

plt.scatter(X[y==0, 0], X[y==0, 1], s=8, label="Class 0")
plt.scatter(X[y==1, 0], X[y==1, 1], s=8, label="Class 1")
plt.xlabel(r"$x_1$")
plt.ylabel(r"$x_2$")
plt.legend()
plt.title('Original dataset')
plt.show()
../../_images/1790a1331018dfeee1dc0c4f22b1832daa8bf43cdefcea363dfd211cc69f13c6.png

Training the model

Set up a simple two layer MLP.

import torch
import torch.nn as nn
import torch.nn.functional as F

class SimpleMLP(nn.Module):
    def __init__(self, in_dim=2, hidden_dim=64, out_dim=2):
        super().__init__()
        self.layer1 = nn.Linear(in_dim, hidden_dim)
        self.layer2 = nn.Linear(hidden_dim, hidden_dim)
        self.output = nn.Linear(hidden_dim, out_dim)

    def forward(self, x):
        x = F.relu(self.layer1(x))
        x = F.relu(self.layer2(x))
        x = self.output(x)      
        return x
from torch.utils.data import TensorDataset, DataLoader

X_tensor = torch.from_numpy(X).float()     
y_tensor = torch.from_numpy(y).long()         

dataset = TensorDataset(X_tensor, y_tensor)
loader = DataLoader(dataset, batch_size=64, shuffle=True)

Set up training.

device = torch.device("mps")

model = SimpleMLP(in_dim=2, hidden_dim=64, out_dim=2).to(device)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

Train the model.

for epoch in range(50):
    model.train()
    running_loss = 0.0

    for xb, yb in loader:
        xb = xb.to(device)
        yb = yb.to(device)

        optimizer.zero_grad()
        logits = model(xb)
        loss = criterion(logits, yb)
        loss.backward()
        optimizer.step()

        running_loss += loss.item() * xb.size(0)

    epoch_loss = running_loss / len(dataset)
    if (epoch + 1) % 10 == 0:
        print(f"Epoch {epoch+1:02d}, loss = {epoch_loss:.4f}")
Epoch 10, loss = 0.3800
Epoch 20, loss = 0.2161
Epoch 30, loss = 0.0862
Epoch 40, loss = 0.0434
Epoch 50, loss = 0.0265

Get predictions for the data.

def model_func(inputs):
    model.eval()
    with torch.no_grad():
        # convert numpy arrays to torch tensors
        if isinstance(inputs, np.ndarray):
            inputs = torch.from_numpy(inputs).float()
        
        # ensure tensor + move to device
        inputs = inputs.to(device)

        logits = model(inputs)
        probs = torch.softmax(logits, dim=-1)
        preds = probs.argmax(dim=-1).cpu().numpy()

    return preds
preds = model_func(X_tensor.to(device))
from sklearn.metrics import accuracy_score

acc = accuracy_score(y, preds)
print(f"The model accuracy on this dataset is {acc}.")
The model accuracy on this dataset is 0.9975.

Interpreting the model outputs with SLIME

Imagining that we don’t know the true form of the data, we would want to understand how the model behaves. For complex models, it is easier to see how the model behaves around a local point of interest.

np.random.seed(7)
idx = np.random.randint(len(X))
x0 = X[idx]
plt.scatter(X[y==0, 0], X[y==0, 1], s=8, label="Class 0")
plt.scatter(X[y==1, 0], X[y==1, 1], s=8, label="Class 1")

# plot the special point on top
plt.scatter(x0[0], x0[1], color="red", s=40, label=r"$x_0$")

plt.xlabel(r"$x_1$")
plt.ylabel(r"$x_2$")
plt.title(r"Original dataset with chosen local point, $x_0$")
plt.legend()
<matplotlib.legend.Legend at 0x318175dd0>
../../_images/0ee412653bc3476843b55c82ca6e476f95ec5a6a72ed96586e93314b4db910c9.png

Let us just restrict ourself to the neighbourhood of points close to \(x_0\).

from sklearn.neighbors import NearestNeighbors

nbrs = NearestNeighbors(n_neighbors=50, metric='euclidean').fit(X)
_, indices = nbrs.kneighbors(x0.reshape(1, -1))
neigh_idx = indices[0]
# plot neighbours using the same class-specific colours
plt.scatter(X[neigh_idx][y[neigh_idx] == 0, 0],
            X[neigh_idx][y[neigh_idx] == 0, 1],
            s=20, label="Neighbours (Class 0)")

plt.scatter(X[neigh_idx][y[neigh_idx] == 1, 0],
            X[neigh_idx][y[neigh_idx] == 1, 1],
            s=20, label="Neighbours (Class 1)")

# highlight x0
plt.scatter(x0[0], x0[1],
            s=60, color="red", label=r"$x_0$")

plt.xlabel(r"$x_1$")
plt.ylabel(r"$x_2$")
plt.legend()
plt.title(r"Local point $x_0$ and the 50 nearest neighbours")
plt.show()
../../_images/ead25c17e69fda3923cd021060957f15730de2d8ed8a56e1d34762e73aff54ff.png

We can now add synthetic points to further explore the model classifications. We sample our synthetic points from the region around \(x_0\) with the half the variance as the \(J-\) nearest neighbours.

var = np.var(X[neigh_idx])
synthetic_samples = np.random.normal(loc=x0, scale=np.sqrt(var)/2, size=(10_000, len(x0)))

Put our synthetic samples and our real samples into the model to see how they are classified.

real_inputs = X[neigh_idx]

real_preds = model_func(torch.from_numpy(real_inputs).float().to(device))
synthetic_preds = model_func(torch.from_numpy(synthetic_samples).float().to(device))
real_preds.shape
(50,)
plt.scatter(synthetic_samples[synthetic_preds == 0, 0],
            synthetic_samples[synthetic_preds == 0, 1],
            s=50, facecolor="blue", label="Synthetic samples (class 0)")

plt.scatter(synthetic_samples[synthetic_preds == 1, 0],
            synthetic_samples[synthetic_preds == 1, 1],
            s=50, facecolor="orange", label="Synthetic samples (class 1)")

plt.scatter(real_inputs[real_preds == 0, 0],
            real_inputs[real_preds == 0, 1],
            s=50, facecolor="blue", edgecolor="black",
            linewidth=1.5, label="Real neighbours (class 0)"

)

# class 1 neighbours
plt.scatter(real_inputs[real_preds == 1, 0],
            real_inputs[real_preds == 1, 1],
            s=50, facecolor="orange", edgecolor="black",
            linewidth=1.5, label="Real neighbours (class 1)"
)


# 4 — highlight x0
plt.scatter(x0[0], x0[1],
            s=80, color="red", edgecolor="black", linewidth=1.2,
            label=r"$x_0$")


plt.legend()
plt.xlabel(r"$x_1$")
plt.ylabel(r"$x_2$")
plt.title(r"Real and synthetic points in neighbourhood of $x_0$")
Text(0.5, 1.0, 'Real and synthetic points in neighbourhood of $x_0$')
../../_images/98f8e878250af7cd887149fb96459d46788e9caba6df0934f5597eebd3aa2d5c.png

This is how the model classfies the points in this neighbourhood. Now we can approximate the behaviour of this model using a symbolic expression.

from symtorch import SymbolicModel
Detected IPython. Loading juliacall extension. See https://juliapy.github.io/PythonCall.jl/stable/compat/#IPython

Set up the SLIME model.

slime_model = SymbolicModel(model_func, block_name = "func")
slime_params = {"x": x0,
                "J_nn": 50,
                "num_synthetic": 10_000,
                "nn_metric": "euclidean"
                }

pysr_params={
    'niterations': 1000,
    'binary_operators': ['+', '*', '-', '/', 'cond'],
    'unary_operators': ['sin', 'cos', 'exp', 'log', 'sqrt'],
    'complexity_of_operators': {'sin': 3, 'cos': 3, 'exp': 3, 'log': 3, 'sqrt': 2, 'cond': 2}, 
    'constraints':{'exp': 2, 'log':2, 'sin':2, 'cos':2},
    'verbosity': 0
}
slime_model.distill(X, SLIME= True, slime_params= slime_params, sr_params = pysr_params)
🔍 SLIME mode: Using 10050 points (50 real + 10000 synthetic)
   Point of interest: [8.23135402 3.41537682]
🛠️ Running SR on output dimension 0 of 0
/Users/liz/PhD/SymTorch_project/symtorch_venv/lib/python3.11/site-packages/pysr/sr.py:2811: UserWarning: Note: it looks like you are running in Jupyter. The progress bar will be turned off.
  warnings.warn(
/Users/liz/PhD/SymTorch_project/symtorch_venv/lib/python3.11/site-packages/pysr/sr.py:2265: UserWarning: Note: you are running with more than 10,000 datapoints. You should consider turning on batching (https://ai.damtp.cam.ac.uk/pysr/options/#batching). You should also reconsider if you need that many datapoints. Unless you have a large amount of noise (in which case you should smooth your dataset first), generally < 10,000 datapoints is enough to find a functional form with symbolic regression. More datapoints will lower the search speed.
  warnings.warn(
💡Best equation for output 0 found to be cond((((cos(x1) * 0.04937385) + 0.5404889) * x0) + -2.906935, 1.0).
❤️ SR on func complete.
{0: PySRRegressor.equations_ = [
 	   pick     score                                           equation  \
 	0        0.000000                                          0.9994247   
 	1        0.013140                     (-0.77621204 / x0) + 1.0939363   
 	2        9.639104                         cond(x0 + -5.8403883, 1.0)   
 	3        0.000001    cond(x0 + -5.84091, 0.99998724) - -1.2700881e-5   
 	4        0.063348   cond(((x1 * 0.011053928) + x0) + -5.849988, 1.0)   
 	5        0.061500  cond((((x1 * 0.04135067) + x0) * 1.0558497) + ...   
 	6        0.000024  cond(((x0 * 1.0541468) + (x1 * 0.045976285)) +...   
 	7  >>>>       inf  cond((((cos(x1) * 0.04937385) + 0.5404889) * x...   
 	
 	           loss  complexity  
 	0  5.769923e-04           1  
 	1  5.474488e-04           5  
 	2  3.565610e-08           6  
 	3  3.565600e-08           8  
 	4  3.141298e-08          10  
 	5  2.777738e-08          12  
 	6  2.777605e-08          14  
 	7  0.000000e+00          15  
 ]}

This is the equation that the symbolic regression found:

slime_model.show_symbolic_expression(SLIME=True)
➡️ Slime symbolic expressions for output dimension 0:
   complexity          loss  \
0           1  5.769923e-04   
1           5  5.474488e-04   
2           6  3.565610e-08   
3           8  3.565600e-08   
4          10  3.141298e-08   
5          12  2.777738e-08   
6          14  2.777605e-08   
7          15  0.000000e+00   

                                            equation     score  \
0                                          0.9994247  0.000000   
1                     (-0.77621204 / x0) + 1.0939363  0.013140   
2                         cond(x0 + -5.8403883, 1.0)  9.639104   
3    cond(x0 + -5.84091, 0.99998724) - -1.2700881e-5  0.000001   
4   cond(((x1 * 0.011053928) + x0) + -5.849988, 1.0)  0.063348   
5  cond((((x1 * 0.04135067) + x0) * 1.0558497) + ...  0.061500   
6  cond(((x0 * 1.0541468) + (x1 * 0.045976285)) +...  0.000024   
7  cond((((cos(x1) * 0.04937385) + 0.5404889) * x...       inf   

                                        sympy_format  \
0                                  0.999424700000000   
1                          1.0939363 - 0.77621204/x0   
2  Piecewise((1.0, x0 - 5.8403883 > 0), (0.0, True))   
3  Piecewise((0.99998724, x0 - 5.84091 > 0), (0.0...   
4  Piecewise((1.0, x0 + x1*0.011053928 - 5.849988...   
5  Piecewise((1.0, (x0 + x1*0.04135067)*1.0558497...   
6  Piecewise((0.9999528, x0*1.0541468 + x1*0.0459...   
7  Piecewise((1.0, x0*(cos(x1)*0.04937385 + 0.540...   

                                       lambda_format  
0                 PySRFunction(X=>0.999424700000000)  
1         PySRFunction(X=>1.0939363 - 0.77621204/x0)  
2  PySRFunction(X=>Piecewise((1.0, x0 - 5.8403883...  
3  PySRFunction(X=>Piecewise((0.99998724, x0 - 5....  
4  PySRFunction(X=>Piecewise((1.0, x0 + x1*0.0110...  
5  PySRFunction(X=>Piecewise((1.0, (x0 + x1*0.041...  
6  PySRFunction(X=>Piecewise((0.9999528, x0*1.054...  
7  PySRFunction(X=>Piecewise((1.0, x0*(cos(x1)*0....  
🏆 Best: cond((((cos(x1) * 0.04937385) + 0.5404889) * x0) + -2.906935, 1.0) (loss: 0.000000e+00)

which is some sort of conditional decision boundary.

Now let’s see if our SLIME model has approximated the local behaviour well…

slime_predictions=slime_model(synthetic_samples)
slime_labels = (slime_predictions >= 0.5).astype(int)
plt.scatter(
    synthetic_samples[slime_labels == 0, 0],
    synthetic_samples[slime_labels == 0, 1],
    s=50, facecolor="blue", label="Synthetic samples (class 0)"
)

plt.scatter(
    synthetic_samples[slime_labels == 1, 0],
    synthetic_samples[slime_labels == 1, 1],
    s=50, facecolor="orange", label="Synthetic samples (class 1)"
)

plt.legend()
plt.xlabel(r"$x_1$")
plt.ylabel(r"$x_2$")
plt.title(r"SLIME surrogate model predictions of synthetic data in $x_0$ neighbourhood")
Text(0.5, 1.0, 'SLIME surrogate model predictions of synthetic data in $x_0$ neighbourhood')
../../_images/95dffcc1f735fd98bd6619558b3555e6b4918c1562dcc8d6f8b79db7fac7474a.png
slime_model.switch_to_symbolic(SLIME=True)
✅ Successfully switched func to SLIME symbolic equations for all 1 dimensions:
   Dimension 0: cond((((cos(x1) * 0.04937385) + 0.5404889) * x0) + -2.906935, 1.0)
   Variables: ['x0', 'x1']
🎯 All 1 output dimensions now using SLIME symbolic equations.
func = slime_model.get_symbolic_function(SLIME=True)

It has done a pretty good job!

Let’s visualise the SLIME decision boundary on the true dataset.

# make a grid covering data
x0_min, x0_max = X[:,0].min() - 1, X[:,0].max() + 1
x1_min, x1_max = X[:,1].min() - 1, X[:,1].max() + 1

xx, yy = np.meshgrid(
    np.linspace(x0_min, x0_max, 600),
    np.linspace(x1_min, x1_max, 600)
)

# evaluate model
grid = np.column_stack([xx.ravel(), yy.ravel()])   # shape (600*600, 2)
Z = slime_model(grid)

Z = np.asarray(Z).reshape(-1)   # ensure 1D
Z = Z.reshape(xx.shape) 

# plot ONLY the boundary line
plt.contour(xx, yy, Z, levels=[0.5], colors='black', linewidths=2)

#plot the original data
plt.scatter(synthetic_samples[synthetic_preds == 0, 0],
            synthetic_samples[synthetic_preds == 0, 1],
            s=50, facecolor="blue", label="Synthetic samples (class 0)")

plt.scatter(synthetic_samples[synthetic_preds == 1, 0],
            synthetic_samples[synthetic_preds == 1, 1],
            s=50, facecolor="orange", label="Synthetic samples (class 1)")

plt.scatter(real_inputs[real_preds == 0, 0],
            real_inputs[real_preds == 0, 1],
            s=50, facecolor="blue", edgecolor="black",
            linewidth=1.5, label="Real neighbours (class 0)")
plt.scatter(real_inputs[real_preds == 1, 0],
            real_inputs[real_preds == 1, 1],
            s=50, facecolor="orange", edgecolor="black",
            linewidth=1.5, label="Real neighbours (class 1)")
plt.scatter(x0[0], x0[1],
            s=80, color="red", edgecolor="black", linewidth=1.2,
            label=r"$x_0$")

plt.xlim(2,15)
plt.ylim(-2,10)

plt.legend()
plt.xlabel(r"$x_1$")
plt.ylabel(r"$x_2$")

plt.title("Decision Boundary of Local Symbolic Surrogate Model")
Text(0.5, 1.0, 'Decision Boundary of Local Symbolic Surrogate Model')
../../_images/f9cce44f3941db863fb1677f9e095c67d5ca48aab1bff0e1ee0b5147b300c90d.png