Dictionary of Applied Machine Learning · explainability

explainability — Python demo

Numerical companion to the entry explainability: it recomputes what the entry states and prints one line per check

Quantifies the (subjective) explainability of a trained hypothesis for a simulated user, via the two measures discussed in the entry: the deviation between the user-anticipated and the actual predictions on a test set, and the empirical conditional entropy of the predictions given the user's anticipations. Providing explanations (LIME-style local linear approximations) raises both measures. Self-contained (numpy/matplotlib only), fixed seed.

Run it with python3 pythondemos/explainability.py, from the repository root — it writes its data files under pythondemos/. Requires NumPy and Matplotlib only, and uses fixed seeds, so the printed numbers reproduce exactly. Download explainability.py

The script, block by block

One cell per block of the script: the code, and what that code printed when it last ran here

setup

"""
explainability.py — numerical companion to the glossary entry
'explainability'.

Purpose
-------
Quantifies the (subjective) explainability of a trained hypothesis for a
simulated user, via the two measures discussed in the entry: the deviation
between the user-anticipated and the actual predictions on a test set, and
the empirical conditional entropy of the predictions given the user's
anticipations.  Providing explanations (LIME-style local linear
approximations) raises both measures.  Self-contained (numpy/matplotlib
only), fixed seed.

Setup
-----
Trained hypothesis: Gaussian-kernel ridge regression fit to m = 30 noisy
samples of a nonlinear function on [-3, 3] — opaque to a user who reasons
in terms of linear maps.  The user is simulated as ridge-fitting a linear
map to a small set of labeled examples of the hypothesis' predictions
("mental model"), and anticipating predictions on a test set.

Blocks
------
[B-lin]    For a LINEAR trained hypothesis, the simulated user anticipates
           its test-set predictions almost exactly (mean squared deviation
           < 1e-3): a linear hypothesis is highly explainable to this user.
[B-opaque] For the kernel hypothesis, the user's anticipation deviates
           strongly (mean squared deviation > 0.1): low explainability.
[B-expl]   Explanations close the gap: given a LIME-style local linear
           approximation around each test point (fit to perturbations near
           the point), the user's anticipation error drops by a factor
           of at least 10 compared with [B-opaque].
[B-ent]    The empirical conditional entropy H(prediction | anticipation)
           (both discretized into 8 bins) is smaller with explanations
           than without: anticipations become more informative about the
           predictions.

Outputs
-------
explainability_scatter.csv : test-set points with columns yhat (prediction
                             of the kernel hypothesis), u_no (user
                             anticipation without explanations), u_expl
                             (with explanations), for the entry's pgfplots
                             figure.
explainability.png         : matplotlib preview of that figure (checking
                             only).
"""

import numpy as np
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt

report = []


def check(name, ok):
    report.append((name, bool(ok)))
    print(f"  [{'ok' if ok else 'FAIL'}] {name}")


rng = np.random.default_rng(0)

# ------------------------------------------------- trained hypotheses
m = 30
x_tr = np.sort(rng.uniform(-3.0, 3.0, m))
y_tr = np.sin(1.5 * x_tr) + 0.5 * x_tr + 0.1 * rng.standard_normal(m)

SIGMA, ALPHA = 0.6, 1e-2


def gauss_kernel(p, q):
    return np.exp(-(p[:, None] - q[None, :]) ** 2 / (2.0 * SIGMA ** 2))


beta = np.linalg.solve(gauss_kernel(x_tr, x_tr) + ALPHA * m * np.eye(m),
                       y_tr)


def h_kernel(p):
    """Opaque hypothesis: Gaussian-kernel ridge regression."""
    return gauss_kernel(np.atleast_1d(p), x_tr) @ beta


w_lin = np.polyfit(x_tr, y_tr, 1)


def h_linear(p):
    """Interpretable hypothesis: linear map (with intercept)."""
    return np.polyval(w_lin, p)


def user_anticipation(h, x_examples, x_test):
    """The simulated user ridge-fits a linear map to labeled examples of
    the hypothesis' predictions and extrapolates to the test points."""
    A = np.c_[x_examples, np.ones_like(x_examples)]
    w = np.linalg.solve(A.T @ A + 1e-6 * np.eye(2), A.T @ h(x_examples))
    return np.c_[x_test, np.ones_like(x_test)] @ w


x_ex = np.linspace(-3.0, 3.0, 6)          # examples shown to the user
x_te = np.linspace(-2.8, 2.8, 40)         # test set to anticipate

B-lin

For a LINEAR trained hypothesis, the simulated user anticipates its test-set predictions almost exactly (mean squared deviation < 1e-3): a linear hypothesis is highly explainable to this user.

dev_lin = float(np.mean(
    (user_anticipation(h_linear, x_ex, x_te) - h_linear(x_te)) ** 2))
check(f"[B-lin]    linear hypothesis anticipated (msd {dev_lin:.1e})",
      dev_lin < 1e-3)
  [ok] [B-lin]    linear hypothesis anticipated (msd 3.2e-15)

B-opaque

For the kernel hypothesis, the user's anticipation deviates strongly (mean squared deviation > 0.1): low explainability.

u_no = user_anticipation(h_kernel, x_ex, x_te)
yhat = h_kernel(x_te)
dev_no = float(np.mean((u_no - yhat) ** 2))
check(f"[B-opaque] kernel hypothesis not anticipated (msd {dev_no:.2f})",
      dev_no > 0.1)
  [ok] [B-opaque] kernel hypothesis not anticipated (msd 0.51)

B-expl

Explanations close the gap: given a LIME-style local linear approximation around each test point (fit to perturbations near the point), the user's anticipation error drops by a factor of at least 10 compared with [B-opaque].

u_expl = np.empty_like(x_te)
for i, xt in enumerate(x_te):
    x_loc = xt + 0.25 * rng.standard_normal(20)   # LIME-style perturbations
    u_expl[i] = user_anticipation(h_kernel, x_loc,
                                  np.array([xt]))[0]
dev_expl = float(np.mean((u_expl - yhat) ** 2))
check(f"[B-expl]   explanations shrink the deviation "
      f"({dev_no:.2f} -> {dev_expl:.4f})", dev_no > 10.0 * dev_expl)
  [ok] [B-expl]   explanations shrink the deviation (0.51 -> 0.0013)

B-ent

The empirical conditional entropy H(prediction | anticipation) (both discretized into 8 bins) is smaller with explanations than without: anticipations become more informative about the predictions.

def cond_entropy(target, given, bins=8):
    """Empirical conditional entropy H(target | given) in bits."""
    lo, hi = min(target.min(), given.min()), max(target.max(), given.max())
    edges = np.linspace(lo, hi + 1e-9, bins + 1)
    t = np.digitize(target, edges) - 1
    g = np.digitize(given, edges) - 1
    joint = np.zeros((bins, bins))
    for ti, gi in zip(t, g):
        joint[ti, gi] += 1
    joint /= joint.sum()
    pg = joint.sum(axis=0)
    with np.errstate(divide="ignore", invalid="ignore"):
        cond = joint / pg[None, :]
        terms = np.where(joint > 0, joint * np.log2(cond), 0.0)
    return float(-terms.sum())


H_no = cond_entropy(yhat, u_no)
H_expl = cond_entropy(yhat, u_expl)
check(f"[B-ent]    conditional entropy drops ({H_no:.2f} -> {H_expl:.2f} "
      f"bits)", H_expl < H_no)

# ---------------------------------------------------------------- CSV
with open("pythondemos/explainability_scatter.csv", "w") as f:
    f.write("yhat,u_no,u_expl\n")
    for a, b, c in zip(yhat, u_no, u_expl):
        f.write(f"{a:.4f},{b:.4f},{c:.4f}\n")

# -------------------------------------------------------------- preview
fig, ax = plt.subplots(figsize=(4.2, 4.2))
lim = [yhat.min() - 0.3, yhat.max() + 0.3]
ax.plot(lim, lim, "k--", lw=0.8)
ax.plot(yhat, u_no, "ks", mfc="none", ms=4, label="without explanations")
ax.plot(yhat, u_expl, "k.", ms=5, label="with explanations")
ax.set_xlabel("prediction $\\hat{h}(x)$")
ax.set_ylabel("user anticipation")
ax.legend(frameon=False, fontsize=8)
ax.set_aspect("equal")
fig.tight_layout()
fig.savefig("pythondemos/explainability.png", dpi=110)

n_ok = sum(ok for _, ok in report)
print(f"\n{n_ok}/{len(report)} checks pass")
print("wrote pythondemos/explainability_scatter.csv, "
      "pythondemos/explainability.png")
if n_ok != len(report):
    raise SystemExit(1)
  [ok] [B-ent]    conditional entropy drops (1.22 -> 0.09 bits)

4/4 checks pass
wrote pythondemos/explainability_scatter.csv, pythondemos/explainability.png
Preview figure produced by explainability.py
The preview figure the block B-ent writes when the script runs