7.2. Model-Powered Visualization#
Widgets on their own are already useful — a slider that filters a dataset, a dropdown that switches between charts. But the real payoff comes when you place a machine learning model in the callback loop.
At that point, every widget becomes a dial on the model. Move a slider and the prediction changes. Adjust a hyperparameter and the decision boundary reshapes itself in front of you. This is what we mean by model-powered visualization: an interface that makes the model inspectable, explorable, and — crucially — usable by someone who does not want to read code.
7.2.1. Live Prediction Interfaces#
The simplest model-powered interface is a prediction form. A user adjusts input features and the model produces a prediction on every change. No buttons, no rerunning cells — the output is always live.
Note
All examples in this section use ipywidgets callbacks that require a live Python kernel. In a static Jupyter Book the sliders render but will not update predictions or redraw decision boundaries until you activate Live Code. Click the rocket icon at the top of the page and select Live Code to connect.
The following example trains a classifier on the Iris dataset and builds a slider-driven prediction display. Because the data is real and the model is trained in the same cell, the whole notebook section is self-contained.
import numpy as np
import matplotlib.pyplot as plt
import ipywidgets as widgets
from IPython.display import display
from sklearn.datasets import load_iris
from sklearn.ensemble import RandomForestClassifier
from sklearn.preprocessing import StandardScaler
# Prepare data and train model
iris = load_iris()
X, y = iris.data, iris.target
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
model = RandomForestClassifier(n_estimators=100, random_state=42)
model.fit(X_scaled, y)
# Widget definitions
sepal_len = widgets.FloatSlider(value=5.8, min=4.0, max=8.0, step=0.1,
description='Sepal length:', style={'description_width': 'initial'})
sepal_wid = widgets.FloatSlider(value=3.0, min=2.0, max=4.5, step=0.1,
description='Sepal width:', style={'description_width': 'initial'})
petal_len = widgets.FloatSlider(value=3.8, min=1.0, max=7.0, step=0.1,
description='Petal length:', style={'description_width': 'initial'})
petal_wid = widgets.FloatSlider(value=1.2, min=0.1, max=2.5, step=0.1,
description='Petal width:', style={'description_width': 'initial'})
output = widgets.Output()
def predict(_change):
sample = np.array([[sepal_len.value, sepal_wid.value,
petal_len.value, petal_wid.value]])
sample_scaled = scaler.transform(sample)
proba = model.predict_proba(sample_scaled)[0]
predicted = iris.target_names[np.argmax(proba)]
with output:
output.clear_output(wait=True)
fig, ax = plt.subplots(figsize=(5, 2.5))
bars = ax.barh(iris.target_names, proba,
color=['#4CAF50' if p == max(proba) else '#90CAF9' for p in proba])
ax.set_xlim(0, 1)
ax.set_xlabel('Probability')
ax.set_title(f'Predicted: {predicted}')
plt.tight_layout()
plt.show()
for w in [sepal_len, sepal_wid, petal_len, petal_wid]:
w.observe(predict, names='value')
controls = widgets.VBox([sepal_len, sepal_wid, petal_len, petal_wid])
display(widgets.HBox([controls, output]))
predict(None) # render the initial prediction
Notice a few design choices worth keeping in mind. The sliders use style={'description_width': 'initial'} so the labels are not truncated. The bar chart uses colour to highlight the winning class, giving the user an immediate visual read before they even look at the numbers. And clear_output(wait=True) prevents flickering by holding the old output until the new one is ready.
7.2.2. Exploring Decision Boundaries#
One of the most instructive things you can do with a model-powered visualization is watch a decision boundary change as you adjust hyperparameters. What does raising the regularisation parameter C on an SVM actually do to the boundary? Describing it in words is one thing; watching it reshape in real time is another.
from sklearn.svm import SVC
from sklearn.datasets import make_moons
import numpy as np
import matplotlib.pyplot as plt
import ipywidgets as widgets
from IPython.display import display
# Generate a non-linearly separable dataset
X_moons, y_moons = make_moons(n_samples=200, noise=0.25, random_state=42)
def plot_boundary(C=1.0, gamma=0.5):
model = SVC(C=C, gamma=gamma, kernel='rbf', probability=False)
model.fit(X_moons, y_moons)
# Build a mesh over the feature space
h = 0.02
x_min, x_max = X_moons[:, 0].min() - 0.5, X_moons[:, 0].max() + 0.5
y_min, y_max = X_moons[:, 1].min() - 0.5, X_moons[:, 1].max() + 0.5
xx, yy = np.meshgrid(np.arange(x_min, x_max, h),
np.arange(y_min, y_max, h))
Z = model.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)
fig, ax = plt.subplots(figsize=(7, 5))
ax.contourf(xx, yy, Z, alpha=0.3, cmap='RdBu')
ax.scatter(X_moons[:, 0], X_moons[:, 1], c=y_moons,
cmap='RdBu', edgecolors='k', linewidths=0.5, s=40)
ax.set_title(f'SVM decision boundary | C = {C:.1f}, γ = {gamma:.2f}')
ax.set_xlabel('Feature 1')
ax.set_ylabel('Feature 2')
plt.tight_layout()
plt.show()
widgets.interact(
plot_boundary,
C=widgets.FloatLogSlider(value=1.0, base=10, min=-1, max=2, step=0.1,
description='C:', continuous_update=False),
gamma=widgets.FloatLogSlider(value=0.5, base=10, min=-2, max=1, step=0.1,
description='γ:', continuous_update=False),
)
<function __main__.plot_boundary(C=1.0, gamma=0.5)>
This kind of visualization makes the bias–variance tradeoff tangible. A very high C and high gamma will memorise the training data — you can see the boundary wrap tightly around individual points. A low C and low gamma will smooth everything out, sometimes too aggressively. The right combination is something a reader can now find by feel, not just by reading a table of cross-validation scores.
FloatLogSlider is used here instead of a regular FloatSlider because C and gamma span orders of magnitude. A logarithmic slider gives the user the same physical travel for each order of magnitude, which is far more natural to use.
7.2.3. Tuning the Classification Threshold#
The default threshold for a binary classifier is \(0.5\) — predict positive if the model’s estimated probability exceeds that value. But in practice, the right threshold depends on the relative cost of false positives and false negatives, and it deserves the same attention as any other hyperparameter.
A slider-driven confusion matrix is one of the clearest ways to communicate that tradeoff.
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import confusion_matrix, precision_score, recall_score, f1_score
import matplotlib.pyplot as plt
import seaborn as sns
import ipywidgets as widgets
from IPython.display import display
# Train a binary classifier
X_clf, y_clf = make_classification(n_samples=1000, n_features=10,
weights=[0.75, 0.25], random_state=0)
X_tr, X_te, y_tr, y_te = train_test_split(X_clf, y_clf, test_size=0.3, random_state=0)
clf = LogisticRegression(max_iter=1000)
clf.fit(X_tr, y_tr)
y_proba = clf.predict_proba(X_te)[:, 1]
def explore_threshold(threshold=0.50):
y_pred = (y_proba >= threshold).astype(int)
cm = confusion_matrix(y_te, y_pred)
tn, fp, fn, tp = cm.ravel()
precision = precision_score(y_te, y_pred, zero_division=0)
recall = recall_score(y_te, y_pred, zero_division=0)
f1 = f1_score(y_te, y_pred, zero_division=0)
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', ax=axes[0],
xticklabels=['Pred 0', 'Pred 1'],
yticklabels=['True 0', 'True 1'])
axes[0].set_title(f'Confusion Matrix (threshold = {threshold:.2f})')
metrics = {'Precision': precision, 'Recall': recall, 'F1': f1}
bars = axes[1].bar(metrics.keys(), metrics.values(), color=['#2196F3', '#4CAF50', '#FF9800'])
axes[1].set_ylim(0, 1)
axes[1].set_title('Classification Metrics')
for bar, val in zip(bars, metrics.values()):
axes[1].text(bar.get_x() + bar.get_width() / 2, val + 0.02,
f'{val:.2f}', ha='center', va='bottom', fontsize=11)
plt.tight_layout()
plt.show()
widgets.interact(
explore_threshold,
threshold=widgets.FloatSlider(value=0.50, min=0.01, max=0.99, step=0.01,
description='Threshold:', continuous_update=False,
style={'description_width': 'initial'}),
)
<function __main__.explore_threshold(threshold=0.5)>
Drag the slider to the left and recall climbs — the model flags more positives, catching more true ones, at the cost of more false alarms. Drag it to the right and precision climbs — fewer false alarms, but more true positives are missed. The F1 score rises and falls as you sweep, and its peak is visible directly. A chart like this turns a stakeholder conversation about operational thresholds from an abstract discussion into a concrete, demonstrable trade-off.
7.2.4. What to Keep in Mind#
Model-powered visualizations are powerful precisely because they make the model’s internal behaviour visible. A few principles help keep them effective:
Keep the feedback fast. If a callback takes more than a second, the interface stops feeling live. For slow models, use continuous_update=False on all sliders and add a button to trigger training explicitly.
Show uncertainty when you have it. Probability bars, confidence intervals, and shaded regions are not noise — they are information. A prediction bar chart that shows a 51 % / 49 % split tells the viewer something very different from a 95 % / 5 % split, even though both predict the same class.
Design for the audience. An interface built for your own exploration can be complex; one you hand to a non-technical stakeholder needs to hide the machinery. The widget layout should guide the reader’s attention toward the output, not toward the controls.