7.1. Interactive Widgets#

A notebook cell is a poor interface for experimentation. To try a different value you edit a number, re-run the cell, read the result, edit again, and repeat. That loop is tedious when you want to explore a range of values, and it is completely inaccessible to anyone who is not comfortable editing raw Python.

Widgets solve this. A widget is a UI control — a slider, a dropdown, a button — that lives inside the notebook and speaks directly to your Python code. Move the slider and the plot redraws. Change the dropdown and the model retrains. The reader stays in the result; the code stays out of the way.

The standard library for this in Jupyter is ipywidgets. Install it once and it works in Jupyter Notebook and JupyterLab with no extra configuration:

pip install ipywidgets

7.1.1. The Fastest Way to Start: interact#

Before learning the individual widget types, it is worth knowing the interact decorator. It reads your function signature and builds the appropriate widgets automatically. A tuple (min, max, step) becomes a slider. A list becomes a dropdown. A boolean becomes a checkbox.

Note

ipywidgets callbacks require a live Python kernel. In a static page like this one, the slider renders but moving it has no effect until you activate a live kernel. Click the rocket icon at the top of the page and select Live Code to connect. The sliders will then respond immediately.

The example below uses Plotly’s built-in JavaScript sliders instead, which work in any browser without a kernel — a good pattern for introductory demos.

import numpy as np
import plotly.graph_objects as go

x = np.linspace(0, 4 * np.pi, 500)

# Pre-compute every (frequency, amplitude) combination as a separate trace
frequencies = np.round(np.arange(0.5, 5.1, 0.5), 1)
amplitudes  = np.round(np.arange(0.5, 2.1, 0.5), 1)

traces = []
for amp in amplitudes:
    for freq in frequencies:
        y = amp * np.sin(freq * x)
        traces.append(
            go.Scatter(
                x=x, y=y,
                mode='lines',
                line=dict(width=2),
                name=f'amp={amp}, freq={freq}',
                visible=False,
            )
        )

# Default: amplitude=1.0, frequency=1.0
default_amp_idx  = list(amplitudes).index(1.0)
default_freq_idx = list(frequencies).index(1.0)
traces[default_amp_idx * len(frequencies) + default_freq_idx].visible = True

freq_steps = []
for j, freq in enumerate(frequencies):
    visible = [False] * len(traces)
    visible[default_amp_idx * len(frequencies) + j] = True
    freq_steps.append(dict(method='update',
                           args=[{'visible': visible}, {'title.text': f'Sine wave  |  frequency = {freq}'}],
                           label=str(freq)))

amp_steps = []
for i, amp in enumerate(amplitudes):
    visible = [False] * len(traces)
    visible[i * len(frequencies) + default_freq_idx] = True
    amp_steps.append(dict(method='update',
                          args=[{'visible': visible}, {'title.text': f'Sine wave  |  amplitude = {amp}'}],
                          label=str(amp)))

fig = go.Figure(data=traces)
fig.update_layout(
    title='Sine wave  |  amplitude = 1.0,  frequency = 1.0',
    xaxis_title='x',
    yaxis=dict(range=[-2.5, 2.5], title='y'),
    template='plotly_white',
    sliders=[
        dict(active=default_freq_idx, currentvalue=dict(prefix='Frequency: '),
             pad=dict(t=50, b=10), steps=freq_steps, y=0.0),
        dict(active=default_amp_idx, currentvalue=dict(prefix='Amplitude: '),
             pad=dict(t=110, b=10), steps=amp_steps, y=-0.15),
    ],
    height=480,
)
fig.show()

Two sliders, a live plot, no kernel required. For quick static demos, Plotly is the right tool. When you need a slider to drive arbitrary Python code — training a model, filtering a DataFrame, querying a database — ipywidgets with a live kernel is what you want instead.


7.1.2. Widget Families#

ipywidgets groups its controls into a small number of families. Understanding which family to reach for is more useful than memorising the full API.

7.1.2.1. Numeric Input: Sliders#

Sliders are right for any continuous or finely-stepped numeric value. IntSlider and FloatSlider share the same parameters — min, max, step, value, and description. Set continuous_update=False whenever the callback is expensive: it fires only when the user releases the handle, not on every pixel of movement.

import ipywidgets as widgets
from IPython.display import display

alpha = widgets.FloatSlider(
    value=0.01, min=0.0001, max=0.5, step=0.001,
    description='Learning rate:',
    readout_format='.4f',
    continuous_update=False,
    style={'description_width': 'initial'},
)
display(alpha)

When you need a range rather than a single value, IntRangeSlider and FloatRangeSlider return a (low, high) tuple:

year_range = widgets.IntRangeSlider(
    value=[2010, 2020], min=2000, max=2024, step=1, description='Years:',
)
display(year_range)

7.1.2.2. Categorical Selection: Dropdowns and Radio Buttons#

Dropdown is compact and fits long lists well. RadioButtons is better when the options are few and you want them all visible at once. For selecting several items simultaneously, SelectMultiple returns a tuple of chosen values:

model_selector = widgets.Dropdown(
    options=['Logistic Regression', 'Random Forest', 'Gradient Boosting', 'SVM'],
    value='Random Forest',
    description='Model:',
)
split_selector = widgets.RadioButtons(
    options=['Train', 'Validation', 'Test'],
    value='Train',
    description='Dataset:',
)
feature_selector = widgets.SelectMultiple(
    options=['age', 'income', 'credit_score', 'tenure', 'balance'],
    value=['age', 'income'],
    description='Features:',
    rows=5,
)
display(model_selector, split_selector, feature_selector)

7.1.2.3. Boolean Controls: Checkboxes and Toggles#

A Checkbox is the natural choice for any on/off flag. ToggleButton does the same thing but draws more attention to itself — useful when the toggle controls something visually significant:

normalize_cb = widgets.Checkbox(value=True, description='Normalise features', indent=False)
show_ci = widgets.ToggleButton(
    value=False, description='Show confidence interval',
    button_style='info', icon='eye',
)
display(normalize_cb, show_ci)

7.1.2.4. Text and Free-Form Input#

When you need the user to supply a string — a search term, a file path, a label — use Text for a single line or Textarea for multi-line input. Combobox combines a dropdown with a text field, useful when you want to offer suggestions but still allow custom values:

search = widgets.Text(
    placeholder='Enter a label',
    description='Search:',
    continuous_update=False,   # only fire on Enter / focus-out
)
notes = widgets.Textarea(placeholder='Notes…', description='Notes:', rows=3)
combo = widgets.Combobox(
    placeholder='Choose or type',
    options=['setosa', 'versicolor', 'virginica'],
    description='Species:',
    ensure_option=False,
)
display(search, notes, combo)

7.1.2.5. Buttons and Actions#

Sliders and dropdowns update state continuously. Sometimes you want the user to make all their selections and then trigger a computation explicitly. A Button with an on_click callback provides that control:

run_button = widgets.Button(description='Train Model', button_style='success', icon='play')
output = widgets.Output()

def on_run(b):
    with output:
        output.clear_output()
        print("Training… (replace with your real code)")

run_button.on_click(on_run)
display(run_button, output)

The Output widget is the right place to capture anything a callback prints or plots. Without it, output from a callback can appear in unexpected places or disappear entirely.

7.1.2.6. Progress Indicators#

IntProgress doubles as a visual feedback mechanism for long-running loops. Pair it with Output to give the user a sense of how far along a computation is:

import time

bar = widgets.IntProgress(min=0, max=10, description='Processing:', bar_style='info')
display(bar)

for i in range(10):
    time.sleep(0.05)   # stand-in for real work
    bar.value = i + 1

7.1.3. Reacting to Changes: observe#

Every widget property — not just value — can be watched with .observe(). The callback receives a dictionary with 'old' and 'new' keys, which is often more useful than @interact when you need fine-grained control over when and how the UI responds:

slider = widgets.IntSlider(value=5, min=0, max=20, description='k:')
label  = widgets.Label(value='Current k: 5')

def on_k_change(change):
    label.value = f'Current k: {change["new"]}'

slider.observe(on_k_change, names='value')
display(widgets.HBox([slider, label]))

This pattern is especially handy when two widgets need to stay in sync. widgets.link creates a bidirectional connection so that changing either widget updates the other — useful when you want both a slider and a text box for the same value:

slider   = widgets.FloatSlider(value=0.5, min=0, max=1, description='Alpha:')
text_box = widgets.FloatText(value=0.5, description='Alpha (text):')

widgets.link((slider, 'value'), (text_box, 'value'))
display(widgets.HBox([slider, text_box]))

7.1.4. Composing a Dashboard with Layouts#

Individual widgets are more than the sum of their parts once you arrange them. HBox places widgets side by side; VBox stacks them. Nest the two to build any structure you need:

depth_slider      = widgets.IntSlider(value=3, min=1, max=15, description='Max depth:')
estimators_slider = widgets.IntSlider(value=100, min=10, max=500, step=10, description='Estimators:')
train_button      = widgets.Button(description='Train', button_style='primary')
result_output     = widgets.Output()

controls  = widgets.VBox([depth_slider, estimators_slider, train_button])
dashboard = widgets.HBox([controls, result_output])
display(dashboard)

Controls on the left, output on the right is a natural and readable layout. The next section uses this exact pattern with a real model behind it.


7.1.5. Putting It Together: Model Parameter Exploration#

The real payoff of widgets becomes clear when you put a machine learning model inside the callback. The following example trains a Decision Tree on the Iris dataset and lets you adjust the two most important structural parameters — max_depth and min_samples_leaf — while the accuracy and tree complexity update live.

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.tree import DecisionTreeClassifier
from sklearn.model_selection import cross_val_score, train_test_split

iris = load_iris()
X, y = iris.data, iris.target
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)

depth_w    = widgets.IntSlider(value=3, min=1, max=15, step=1,
                               description='Max depth:', continuous_update=False,
                               style={'description_width': 'initial'})
leaf_w     = widgets.IntSlider(value=1, min=1, max=20, step=1,
                               description='Min samples leaf:', continuous_update=False,
                               style={'description_width': 'initial'})
out        = widgets.Output()

def update(_change):
    model = DecisionTreeClassifier(max_depth=depth_w.value,
                                   min_samples_leaf=leaf_w.value,
                                   random_state=42)
    cv_scores = cross_val_score(model, X_train, y_train, cv=5)
    model.fit(X_train, y_train)
    test_acc = model.score(X_test, y_test)

    with out:
        out.clear_output(wait=True)
        fig, axes = plt.subplots(1, 2, figsize=(11, 4))

        # CV accuracy distribution
        axes[0].bar(range(1, 6), cv_scores, color='#42A5F5', edgecolor='k', linewidth=0.5)
        axes[0].axhline(cv_scores.mean(), color='red', linestyle='--', linewidth=1.5,
                        label=f'Mean = {cv_scores.mean():.3f}')
        axes[0].set_ylim(0.5, 1.05)
        axes[0].set_xlabel('Fold')
        axes[0].set_ylabel('Accuracy')
        axes[0].set_title('5-Fold Cross-Validation')
        axes[0].legend()

        # Tree structure metrics
        metrics = {'Leaves': model.get_n_leaves(), 'Depth': model.get_depth(),
                   'Test acc (×10)': round(test_acc * 10, 2)}
        axes[1].bar(metrics.keys(), metrics.values(), color=['#66BB6A', '#FFA726', '#AB47BC'],
                    edgecolor='k', linewidth=0.5)
        axes[1].set_title('Tree Structure')
        for bar, val in zip(axes[1].patches, metrics.values()):
            axes[1].text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.3,
                         str(val), ha='center', va='bottom', fontsize=10)
        plt.tight_layout()
        plt.show()

depth_w.observe(update, names='value')
leaf_w.observe(update, names='value')

display(widgets.VBox([depth_w, leaf_w, out]))
update(None)

Try pushing max_depth to its maximum — the cross-validation variance grows and the leaf count explodes. That is overfitting, made visible in real time. Bring min_samples_leaf up and watch the tree constrain itself. The same tradeoff that a learning curve describes statically, widgets let the reader experience directly.

The SVM decision boundary is another classic target. Because C and gamma span orders of magnitude, a FloatLogSlider gives each decade equal physical travel — far more natural to use than a linear slider over a huge range:

from sklearn.svm import SVC
from sklearn.datasets import make_moons

X_m, y_m = make_moons(n_samples=200, noise=0.25, random_state=42)

C_w     = widgets.FloatLogSlider(value=1.0, base=10, min=-1, max=2, step=0.25,
                                 description='C:', continuous_update=False)
gamma_w = widgets.FloatLogSlider(value=0.5, base=10, min=-2, max=1, step=0.25,
                                 description='γ:', continuous_update=False)
svm_out = widgets.Output()

def update_boundary(_change):
    model = SVC(C=C_w.value, gamma=gamma_w.value, kernel='rbf')
    model.fit(X_m, y_m)

    h = 0.03
    x0_min, x0_max = X_m[:, 0].min() - 0.5, X_m[:, 0].max() + 0.5
    x1_min, x1_max = X_m[:, 1].min() - 0.5, X_m[:, 1].max() + 0.5
    xx, yy = np.meshgrid(np.arange(x0_min, x0_max, h), np.arange(x1_min, x1_max, h))
    Z = model.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)

    with svm_out:
        svm_out.clear_output(wait=True)
        fig, ax = plt.subplots(figsize=(7, 5))
        ax.contourf(xx, yy, Z, alpha=0.3, cmap='RdBu')
        ax.scatter(X_m[:, 0], X_m[:, 1], c=y_m, cmap='RdBu',
                   edgecolors='k', linewidths=0.5, s=40)
        ax.set_title(f'SVM boundary  |  C = {C_w.value:.2g},  γ = {gamma_w.value:.2g}  '
                     f'|  train acc = {model.score(X_m, y_m):.2f}')
        plt.tight_layout()
        plt.show()

C_w.observe(update_boundary, names='value')
gamma_w.observe(update_boundary, names='value')

display(widgets.VBox([C_w, gamma_w, svm_out]))
update_boundary(None)

A high C and high gamma will wrap the boundary tightly around individual training points — textbook overfitting. A low C and low gamma will over-smooth. The right combination is now something the reader can discover by feel.


7.1.6. A Note on Performance#

Widgets are synchronous. When a slider moves and calls your function, that function runs before the next frame is drawn. If it takes a second, the interface feels frozen. Two techniques help:

  • continuous_update=False — limits callbacks to the moment the user releases the control.

  • observe with explicit control — attach callbacks manually and add debounce logic if needed.

For anything that truly takes more than a second (large model training, heavy data processing), a button-triggered approach is better — the user consciously fires the expensive operation rather than causing it accidentally by dragging.

Tip

Measure first. Most visualization callbacks are fast enough that continuous_update=True is perfectly smooth. Only optimise if you actually feel lag.