Ipywidgets with matplotlib

Published on: March 31, 2020

This tutorial gives a brief introduction into using ipywidgets in Jupyter Notebooks. Ipywidgets provide a set of building blocks for graphical user interfaces that are powerful, yet easy to use.

A simple use case could be adding some basic controls to a plot for interactive data exploration. On the other side of the spectrum, we can combine widgets together to build full-fledged graphical user interfaces.

Here, we first introduce the interact function, which is a convenient way to quickly create suitable widgets to control functions. Second, we look into specific widgets and stack them together to build a basic gui application.

Requirements

The notebook used for this tutorial is available on github, together with a link to a live version on binder.

To run the notebook locally, the very first requirement is a working Jupyter environment. Setting up an installation lies outside the scope of the tutorial, but can be found in the official docs. Note that for this tutorial, all libraries were installed using pip, or the pacman package manager. Anaconda currently has a matplotlib issue that gives some problems (at least on Windows 10). Therefore, if you have problems displaying plots correctly, try using pip only, or Linux. The examples were tested on Windows 10 and Arch Linux.

The versions of packages explicitly used to create the examples are:

  • Python 3.8.1
  • matplotlib 3.1.3
  • NumPy 1.18.1
  • ipywidgets 7.5.1
  • ipympl 0.4.1

To get started, we set the ipympl backend, which makes matplotlib plots interactive. We do this using a magic command, starting with %. We also import some libraries: matplotlib for plotting, NumPy to generate data, and ipywidgets for obvious reasons.

In [1]:
%matplotlib widget
import ipywidgets as widgets
import matplotlib.pyplot as plt
import numpy as np

Interact

Widgets can be created either directly or through the interact function. We explore interact first, as it is convenient for quick use. interact takes a function as its first argument, followed by the function arguments with their possible values. This creates a widget that allows to select those values, calling the callback with the current value for every selection. This may sound rather abstract at first, but an example will hopefully make it clearer.

The say_my_name function below prints the text ‘my name is {name}’. We pass say_my_name into interact, together with a list of names. This creates a dropdown filled with the names in the list. Every time we pick a name,  say_my_name is called with the currently selected name and the printed message gets updated.

In [2]:
def say_my_name(name):
    """
    Print the current widget value in short sentence
    """
    print(f'My name is {name}')
    
widgets.interact(say_my_name, name=["Jim", "Emma", "Bond"]);

You might wonder how interact decides to create a dropdown list. It turns out that this choice is based on the input options. In the example above, these were given as a list of values, resulting in a dropdown list.

We could also create a slider by passing a tuple of the form (start, stop, step) in which the values are numerical. If all values are integers, this creates an IntSlider; if floats are present (no surprise), a FloatSlider. Alternatively, we could construct a checkbox by simply passing a boolean (i.e. True or False).

The next cell shows this behavior by reusing a single function with different input options to create different kinds of widgets.

In [3]:
def say_something(x):
    """
    Print the current widget value in short sentence
    """
    print(f'Widget says: {x}')

widgets.interact(say_something, x=[0, 1, 2, 3])
widgets.interact(say_something, x=(0, 10, 1))
widgets.interact(say_something, x=(0, 10, .5))
_ = widgets.interact(say_something, x=True)

interact is not limited to single argument functions. We can pass multiple arguments to create multiple widgets, following the same rules as above. Below, we use a three function with three arguments to create three different widgets. Whenever one of the values is changed, three is called with the current values of the three widgets as its arguments.

In [4]:
def three(x, y, z):
    return (x, y, z)

_ = widgets.interact(
    three, 
    x=(0, 10, 1), 
    y=True, 
    z=['a', 'b', 'c']
)

Sometimes, not all arguments need to be linked to the widget. We can fix those using the fixed function. For example, fixing the z argument in three results in widgets for x and y only.

In [5]:
_ = widgets.interact(
    three, 
    x=(0, 10, 1), 
    y=True, 
    z=widgets.fixed('I am fixed')
)

We can also use decorator syntax to create widgets with interact.

In [6]:
@widgets.interact(x=(0, 10, 1))
def foo(x):
    """
    Print the current widget value in short sentence
    """
    print(f'Slider says: {x}')

Putting widgets to use

Making widgets and printing stuff is all well and good, but let’s do something slightly more useful and create a perfectly fake oscilloscope. For this, we use matplotlib to create a plot with a fixed vertical scale and a grid. Next, we generate some x values between 0 and 2pi and define a function to return the sine of x for some frequency w, amplitude amp and phase angle phi.

Finally, we define an update function that takes three arguments:  wamp and phi, corresponding with the parameters controlling our sine. This function clears all existing lines from the ax object (if any) and then plots our sine wave. Adding the interact decorator completes our beautiful interactive plot.

In [7]:
# set up plot
fig, ax = plt.subplots(figsize=(6, 4))
ax.set_ylim([-4, 4])
ax.grid(True)

# generate x values
x = np.linspace(0, 2 * np.pi, 100)


def my_sine(x, w, amp, phi):
    """
    Return a sine for x with angular frequeny w and amplitude amp.
    """
    return amp*np.sin(w * (x-phi))


@widgets.interact(w=(0, 10, 1), amp=(0, 4, .1), phi=(0, 2*np.pi+0.01, 0.01))
def update(w = 1.0, amp=1, phi=0):
    """Remove old lines from plot and plot new one"""
    [l.remove() for l in ax.lines]
    ax.plot(x, my_sine(x, w, amp, phi), color='C0')

For more information on how to use interact, check the official documentation. In the next bit, we’ll use the widgets directly and stack them together to build larger apps.

Widgets

Often, we will want to create widgets manually, for example to build larger interfaces with interconnected components. There are many widgets to choose from. The next cell shows a quick and dirty listing of all classes defined in the ipywidgets module. Not all of these are meant for everyday use (e.g. DOMWidget and CoreWidgets), but most of them are immediately useful. The official list can be found here.

In [8]:
widgets.Textarea(
    '\n'.join([w for w in dir(widgets) if not w.islower()]),
    layout=widgets.Layout(height='200px')
)

A few widget examples

Text

The Label widget shows the value text as uneditable text. If more formatting is required, you can use an HTML widget. As the name implies, this widget renders an html string. For editable text, there are the Text and Textarea widgets.

In [9]:
label = widgets.Label(
    value='A label')

html = widgets.HTML(
    value='<b>Formatted</b> <font color="red">html</font>', 
    description=''
)

text = widgets.Text(
    value='A text field', 
    description='text field'
)

textarea = widgets.Textarea(
    value='A text area for longer texts', 
    description='text area'
)

# show the three together in a VBox (vertical box container)
widgets.VBox([label, html, text, textarea])

Selections

The cell below shows a few common selection widgets, some of which we met before. The IntRangeSlider is like an IntSlider, but as the name implies, it allows the selection of an lower and upper bound of a range. RadioButtons allow the selection of single value from a list of options, similar to the dropdown list. Checkboxes are displayed a little differently with their description on the right, but still indented. The indent can be removed by passing the argument indent=False.

A personal favorite is the combobox at the end, which starts showing a list of matching possibilities as one starts typing.

In [10]:
int_slider = widgets.IntSlider(
    value=5, 
    min=0, max=10, step=1, 
    description='slider'
)

int_range_slider = widgets.IntRangeSlider(
    value=(20, 40), 
    min=0, max=100, step=2, 
    description='range slider'
)

dropdown = widgets.Dropdown(
    value='feb', 
    options=['jan', 'feb', 'mar', 'apr'], 
    description='dropdown'
)

radiobuttons = widgets.RadioButtons(
    value='feb', 
    options=['jan', 'feb', 'mar', 'apr'], 
    description='radio buttons'
)

combobox = widgets.Combobox(
    placeholder='start typing... (e.g. L or o)',
    options=['Amsterdam', 'Athens', 'Lisbon', 'London', 'Ljubljana'], 
    description='combo box'
)

checkbox = widgets.Checkbox(
    description='checkbox',
    value=True
)


# a VBox container to pack widgets vertically
widgets.VBox(
    [
        int_slider, 
        int_range_slider, 
        dropdown, 
        radiobuttons,
        checkbox,
        combobox,
    ]
)

As before, making and displaying widgets is great, but putting them to work is awesome. Below, we show an example of an application similar to the one above: a sine and a slider (just the one this time). This example shows how we can use the observe method to connect a function to a widget trait. Traits are special properties that come from a parent class called HasTraits. To look into this in further detail, check out the traitlets library.

The value property of a widget is such a trait, meaning we can use observe to connect a callback function, which will get called every time value changes. The next cell shows an example, where the frequency of a sine is connected to a slider. Note the continuous_update option when creating the IntSlider. This option, also available in other widgets, makes sure the callback is only called when making changes is done (e.g. mousebutton release, enter), and not on every value traversed along the way.

In [11]:
x = np.linspace(0, 2 * np.pi, 100)

fig, ax = plt.subplots()
line, = ax.plot(x, np.sin(x))
ax.grid(True)

def update(change):
    line.set_ydata(np.sin(change.new * x))
    fig.canvas.draw()
    
int_slider = widgets.IntSlider(
    value=1, 
    min=0, max=10, step=1,
    description='$\omega$',
    continuous_update=False
)
int_slider.observe(update, 'value')
int_slider

When the value of the slider changes, the callback function is called with a single argument, change. The next cell shows an example for a slider with a callback that only prints its input argument. As you can see, change is a dictionary-like object with several items:

'name': the name of the trait  
'old': the previous value of the trait  
'new': the current value of the trait  
'owner': the widget owning the trait (here, int_slider)
In [12]:
def show_change(change):
    display(change)

int_slider = widgets.IntSlider(value=7, min=0, max=10)
int_slider.observe(show_change, 'value')
int_slider.value = 6

Linking

Widgets can also be linked together using the link function. This function takes two tuples of the form (widget, trait) and links the given traits of the given widgets. In the example below, value on the first slider is connected to min on the second. In this way, when we change the value of the first, the minimum value of the second is updated correspondingly.

There are also a dlink and jslink function doing a similar thing. dlink works in one direction only, i.e. updating slider 1 would update slider 2, but not the other way around. jslink only works in the front-end, in JavaScript, and does not need a live ipykernel to work (see more in these docs).

Below, the two sliders are initialised with the same min and max values. However, after linking them together, updating the value of the first to 5 automatically updates min for the second as well.

In [13]:
sl1 = widgets.IntSlider(description='slider 1', min=0, max=10)
sl2 = widgets.IntSlider(description='slider 2', min=0, max=10)

link = widgets.link(
    (sl1, 'value'), 
    (sl2, 'min')
)

sl1.value = 5
widgets.VBox([sl1, sl2])

Links can be removed using the unlink method on the link object link.unlink().

Layout widgets

There are various ways to organise widgets in an interface. We can use boxes, tabs, accordion, or a templated layout. Here, we will only look at boxes. To see the other options, please check here and here.

First, we create some buttons to play with.

In [14]:
b1 = widgets.Button(description='button 1')
b2 = widgets.Button(description='button 2')
b3 = widgets.Button(description='button 3')

Using HBox and VBox widgets, we can easily present our buttons in a row or column layout.

In [15]:
widgets.HBox([b1, b2, b3])
In [16]:
widgets.VBox([b1, b2, b3])

These boxes can also be nested to create more complicated layouts. Below, we create two VBoxes. One containing buttons and another containing a dropdown and some radiobuttons. Then we put the VBoxes themselves into an HBox to lay them out next to one another.

In [17]:
def make_boxes():
    vbox1 = widgets.VBox([widgets.Label('Left'), b1, b2])
    vbox2 = widgets.VBox([widgets.Label('Right'), dropdown, radiobuttons])
    return vbox1, vbox2

vbox1, vbox2 = make_boxes()

widgets.HBox([vbox1, vbox2])

To make the left and right boxes more visible, we add some layout through the Layout widget. Note that the syntax for setting layout parameters resembles css. For our box layout, we add a solid, 1px thick red border. In addition, we add some space in the form of a margin (spacing to other widgets) and padding (spacing between border and widgets inside). Instead of having a margin-top, margin-left and so on, the margin and padding are given as a single string with the values in the order of top, right, bottom & left.

In [18]:
box_layout = widgets.Layout(
        border='solid 1px red',
        margin='0px 10px 10px 0px',
        padding='5px 5px 5px 5px')

vbox1, vbox2 = make_boxes()

vbox1.layout = box_layout
vbox2.layout = box_layout

widgets.HBox([vbox1, vbox2])

This code can have some suprising behavior. The Layout object is mutable and two boxes share a single instance. Hence, making changes to the layout of box1 will also be reflected in box2. For example, see what happens when we change the width and colour of vbox1.

In [19]:
box_layout = widgets.Layout(
        border='solid 1px red',
        margin='0px 10px 10px 0px',
        padding='5px 5px 5px 5px')

vbox1, vbox2 = make_boxes()

vbox1.layout = box_layout
vbox2.layout = box_layout

# change vbox1 only?
vbox1.layout.width = '400px'
vbox1.layout.border = 'solid 2px green'

widgets.HBox([vbox1, vbox2])

A simple workaround is to put the layout in a function that returns a freshly created instance, so that every widget gets its very own layout object. Then, when making changes to vbox1, vbox2 will not change.

In [20]:
def make_box_layout():
     return widgets.Layout(
        border='solid 1px red',
        margin='0px 10px 10px 0px',
        padding='5px 5px 5px 5px'
     )
    
vbox1, vbox2 = make_boxes()

vbox1.layout = make_box_layout()
vbox2.layout = make_box_layout()

# really change vbox1 only
vbox1.layout.width = '400px'
vbox1.layout.border = 'solid 2px green'

widgets.HBox([vbox1, vbox2])

As mentioned at the start of this section, there are other options to design more advanced applications. An interesting alternative is the AppLayout widget, which facilitates building a classic application layout using a column layout sandwiched between a header and footer. Head over to the offical docs for some examples.

Putting widgets to use

In the next bit, we put it all together and build a simple application. We will create a matplotlib figure again, but this time inside an Output widget. Output can take all kinds of input and display the notebook. By creating the figure inside the output context, it will not be drawn until the output widget is used. We can also display the same figure in multiple places, which is sometimes useful in larger applications.

In [21]:
output = widgets.Output()

# create some x data
x = np.linspace(0, 2 * np.pi, 100)

# default line color
initial_color = '#FF00DD'

with output:
    fig, ax = plt.subplots(constrained_layout=True, figsize=(6, 4))
    
# move the toolbar to the bottom
fig.canvas.toolbar_position = 'bottom'
ax.grid(True)    
line, = ax.plot(x, np.sin(x), initial_color)

No figure is shown yet, until we use the output widget:

In [22]:
output

Note that we used the constrained_layout when creating the figure. This is similar to the figure’s tight_layout method, and makes space for the axis labels. However, constrained_layout is more convenient in combination with the widget matplotlib backend, as it can be applied before the figure is rendered. With tight_layout, we would first have to show the figure and then call the method to make everything fit. It is an experimental feature though, so use with care: ‘constrained_layout‘.

Next, we create control widgets with their callback functions and connect them. After getting the callbacks in place, we set the default values for the labels through their corresponding widgets.

In [23]:
# create some control elements
int_slider = widgets.IntSlider(value=1, min=0, max=10, step=1, description='freq')
color_picker = widgets.ColorPicker(value=initial_color, description='pick a color')
text_xlabel = widgets.Text(value='', description='xlabel', continuous_update=False)
text_ylabel = widgets.Text(value='', description='ylabel', continuous_update=False)

# callback functions
def update(change):
    """redraw line (update plot)"""
    line.set_ydata(np.sin(change.new * x))
    fig.canvas.draw()
    
def line_color(change):
    """set line color"""
    line.set_color(change.new)
    
def update_xlabel(change):
    ax.set_xlabel(change.new)
    
def update_ylabel(change):
    ax.set_ylabel(change.new)

# connect callbacks and traits
int_slider.observe(update, 'value')
color_picker.observe(line_color, 'value')
text_xlabel.observe(update_xlabel, 'value')
text_ylabel.observe(update_ylabel, 'value')

text_xlabel.value = 'x'
text_ylabel.value = 'y'

Finally, we box everything up and display everything together.

In [24]:
controls = widgets.VBox([int_slider, color_picker, text_xlabel, text_ylabel])
widgets.HBox([controls, output])

Packing components in a class

To create more high level components, we can also subclass a container and build up our gui from within. Containers have a children property to which we can assign a list of widgets that should be displayed. Although we can assign a list, this is turned into a tuple and cannot be modified afterwards. To remove or add a widget at runtime, the children tuple can be turned back into a list, followed by an insert or deletion and finalised by reassigning to the children property. Since it can be easy to make mistakes when going by index, we tend to add a placeholder box in which we only place the ‘dynamic’ widget.

The example below packs the entire oscilliscope ‘dashboard’ in a single component by subclassing VBox. All the required widgets are defined in the Sines class and added as its children. The callbacks are defined as instance methods. It may not be a masterpiece in object oriented programming, but hopefully it shows the idea of constructing larger reusable components. Note that we need to call super().__init__() from __init__ to properly initialise the parent class.

In [25]:
def make_box_layout():
     return widgets.Layout(
        border='solid 1px black',
        margin='0px 10px 10px 0px',
        padding='5px 5px 5px 5px'
     )

class Sines(widgets.HBox):
    
    def __init__(self):
        super().__init__()
        output = widgets.Output()

        self.x = np.linspace(0, 2 * np.pi, 100)
        initial_color = '#FF00DD'

        with output:
            self.fig, self.ax = plt.subplots(constrained_layout=True, figsize=(5, 3.5))
        self.line, = self.ax.plot(self.x, np.sin(self.x), initial_color)
        
        self.fig.canvas.toolbar_position = 'bottom'
        self.ax.grid(True)

        # define widgets
        int_slider = widgets.IntSlider(
            value=1, 
            min=0, 
            max=10, 
            step=1, 
            description='freq'
        )
        color_picker = widgets.ColorPicker(
            value=initial_color, 
            description='pick a color'
        )
        text_xlabel = widgets.Text(
            value='', 
            description='xlabel', 
            continuous_update=False
        )
        text_ylabel = widgets.Text(
            value='', 
            description='ylabel', 
            continuous_update=False
        )

        controls = widgets.VBox([
            int_slider, 
            color_picker, 
            text_xlabel, 
            text_ylabel
        ])
        controls.layout = make_box_layout()
        
        out_box = widgets.Box([output])
        output.layout = make_box_layout()

        # observe stuff
        int_slider.observe(self.update, 'value')
        color_picker.observe(self.line_color, 'value')
        text_xlabel.observe(self.update_xlabel, 'value')
        text_ylabel.observe(self.update_ylabel, 'value')
        
        text_xlabel.value = 'x'
        text_ylabel.value = 'y'
        

        # add to children
        self.children = [controls, output]
    
    def update(self, change):
        """Draw line in plot"""
        self.line.set_ydata(np.sin(change.new * self.x))
        self.fig.canvas.draw()

    def line_color(self, change):
        self.line.set_color(change.new)

    def update_xlabel(self, change):
        self.ax.set_xlabel(change.new)

    def update_ylabel(self, change):
        self.ax.set_ylabel(change.new)
        
        
Sines()

There is a lot more to ipywidgets than was presented here. A good first start are the official ipywidgets and traitlets docs. There is a lot of active development, so it is always interesting to check for updates. There is also a lot of ongoing work on ipympl, so staying up to date is a good idea when using it.

Two other projects that we would like to mention are Voila and ipyvuetify. The Voila project makes it possible to present a notebook as an interactive web application with a live kernel. Ipyvuetify provides a great set of widgets based on vuetify (example in binder).

It seems like you're really digging this article.

Subscribe to our newsletter and stay up to date.




    guy digging a hole

    Author

    Patrick Steegstra

    From an academic standpoint, Patrick Steegstra’s resume is quite impressive. Patrick has a PhD in Chemistry and has held positions at the University of Gothenburg (Sweden) a ...