Bring your Jupyter Notebook to life with interactive widgets | by Semi Koen | Towards Data Science

Traditionally, every time you need to modify the output of your notebook cells, you need to change the code and rerun the affected cells. This can be cumbersome, inefficient and error prone and in the case of a non-technical user it may even be impracticable. This is where the ipywidgets come into play: they can be embedded in the notebook and provide a user friendly interface to collect the user input and see the impact the changes have on the data/results, without having to interact with the code; your notebooks can be transformed from static documents to dynamic dashboards — ideal for showcasing your data story!
⚠️ Scope: There are limited resources on ipywidgets and the very few tutorials out there are either incomplete or focusing on the interact function/decorator. This is a complete tutorial of how you can take full control of the widgets to create powerful dashboards.
We will start with the basics: adding a widget and explaining how the events work and we will progressively develop a dashboard.I will guide you step by step, building on the examples as we go.
If you have ever created a graphical user interface (GUI) then you already know what a widget is. But let’s give a quick definition anyway:A widget is a GUI element, such as a button, dropdown or textbox, which resides in the browser and allows us to control the code and the data by responding to events and invoking specified handlers.
These GUI elements can be assembled and customised to create complex dashboards.
Throughout this article we will see some of them in action.
Ready? 🏁
To start using the library we need to install the ipywidgets extension. If using conda, we type this command in the terminal:
conda install -c conda-forge ipywidgets
For pip, it will be a two-step process: 1. install and 2. enable:
pip install ipywidgetsjupyter nbextension enable --py widgetsnbextension
In order to incorporate widgets in the notebook we have to import the module, as shown below:
import ipywidgets as widgets
To add a slider, we can define the minimum and maximum values, the interval size (step), a description and an initial value:
widgets.IntSlider(    min=0,    max=10,    step=1,    description='Slider:',    value=3)
The display() function renders a widget object in an input cell.First import:
from IPython.display import display
Then pass the widget as a parameter in the display() function:
slider = widgets.IntSlider()display(slider)
To read the value of a widget, we will query its value property. Similarly, we can set a widget’s value:
We can synchronise the values of two widgets by using the jslink() function.
slider = widgets.IntSlider()text = widgets.IntText()display(slider, text)widgets.jslink((slider, 'value'), (text, 'value'))
For a full list of widgets you can check out the documentation, or run the following command:
print(dir(widgets))
The widgets can respond to events, which are raised when a user interacts with them. A simple example is clicking on a button — we are expecting an action to take place.
Let’s see how this works...
Depending on its specific features, each widget exposes different events. An event handler will be executed every time the event is fired.
Event handler is a callback function in response to an event, that operates asynchronously and handles the inputs received.
Here we will create a simple button called btn. The on_click method is invoked when the button is clicked.
Our event handler, btn_eventhandler, will print a short message with the button’s caption — note that the input argument of the handler, obj, is the button object itself which allows us to access its properties.
To bind the event with the handler, we assign the latter to the button’s on_click method.
btn = widgets.Button(description='Medium')display(btn)def btn_eventhandler(obj):    print('Hello from the {} button!'.format(obj.description))btn.on_click(btn_eventhandler)
What will bring us nicely to the next section is that the output appears in the same cell as the button itself. So let’s move on to see how we can add more flexibility to our notebook!
In this section we will explore how to use widgets to control a dataframe. The sample dataset I have chosen is ‘Number of International Visitors to London’ which shows totals of London’s visitors with regards to nights, visits and spend, broken down by year, quarter, purpose, duration, mode and country.
Initially, we will get the data and load it into a dataframe:
import pandas as pdimport numpy as npurl = "https://data.london.gov.uk/download/number-international-visitors-london/b1e0f953-4c8a-4b45-95f5-e0d143d5641e/international-visitors-london-raw.csv"df_london = pd.read_csv(url)
Suppose we would like to filter the dataframe by year. We will first define a dropdown and populate it with the list of unique year values.
In order to do this, we will create a generic function, unique_sorted_values_plus_ALL, which will find the unique values, sort them and then add the ALL item at the start, so the user could remove the filter.
ALL = 'ALL'def unique_sorted_values_plus_ALL(array):    unique = array.unique().tolist()    unique.sort()    unique.insert(0, ALL)    return unique
Now we will initialise the dropdown:
dropdown_year = widgets.Dropdown(options =    unique_sorted_values_plus_ALL(df_london.year))
The dropdown widget exposes the observe method, which takes a function that will be invoked when the value of the dropdown changes. As such, we will next create the observer handler to filter the dataframe by the selected values — note that the input argument of the handler, change, contains information about the changes that took place which allows us to access the new value (change.new).If the new value is ALL we remove the filter, otherwise we apply it:
def dropdown_year_eventhandler(change):    if (change.new == ALL):        display(df_london)    else:        display(df_london[df_london.year == change.new])
We will then bind the handler to the dropdown:
dropdown_year.observe(dropdown_year_eventhandler, names='value')
So far so good, but the output of all the queries is accumulating in this very same cell; i.e. if we select a new year from the dropdown, a new dataframe will render underneath the first one, on the same cell.The desired behaviour though, is to refresh the contents of the dataframe each time.
The solution to this is to capture the cell output in a special kind of widget, namely Output, and then display it in another cell.
We will slightly tweak the code to:
create a new instance of Output
output_year = widgets.Output()
call the clear_output method within the event handler to clear the previous selection on each iteration, and capture the output of the dataframe in a with block.
def dropdown_year_eventhandler(change):    output_year.clear_output()    with output_year:        display(df_london[df_london.year == change.new])
We will then display the output in a new cell:
display(output_year)
This is how it works:
As you can see the output is rendered in a new cell and the filtering is working as expected! 👍
Continuing the previous example, let’s assume we would also like to filter by purpose too.
If we go ahead and add another dropdown, we will quickly realise the dataframe only responds to the filter by the dropdown which was recently changed. What we need to do is to link the two together so it can work on both values (i.e. year and purpose).
Let’s see how it should work:
Firstly we need a common output for both dropdowns:
output = widgets.Output()
Here are the two dropdowns:
dropdown_year = widgets.Dropdown(options =    unique_sorted_values_plus_ALL(df_london.year))dropdown_purpose = widgets.Dropdown(options = unique_sorted_values_plus_ALL(df_london.purpose))
Then we create a new function, common_filtering, that will be called by both the event handlers. This function will apply a filter on the dataframe for both year AND purpose:We are clearing the output, then we check if any of the values is ALL, in which case we consider that the respective filter is removed. When both filters are present, in the else statement, we apply the & operation in both filters. Finally we capture the output:
def common_filtering(year, purpose):    output.clear_output()        if (year == ALL) & (purpose == ALL):        common_filter = df_london    elif (year == ALL):        common_filter = df_london[df_london.purpose == purpose]    elif (purpose == ALL):        common_filter = df_london[df_london.year == year]    else:        common_filter = df_london[(df_london.year == year) &                                   (df_london.purpose == purpose)]        with output:        display(common_filter)
We amend the event handlers to call the common_filtering function and pass the change.new value as well as the current value of the other dropdown:
def dropdown_year_eventhandler(change):    common_filtering(change.new, dropdown_purpose.value)def dropdown_purpose_eventhandler(change):    common_filtering(dropdown_year.value, change.new)
We bind the handlers to the dropdowns, and that’s it!
dropdown_year.observe(dropdown_year_eventhandler, names='value')dropdown_purpose.observe(dropdown_purpose_eventhandler, names='value')
Code snippet:
Here is the demo:
We have put the basis for our dashboard so far by filtering and displaying the data of the London dataset. We will carry on by colouring the numeric values based on a user selected value.
A useful numeric widget is the BoundedFloatText; we will give it a min, max and initial value, and the incremental step.
bounded_num = widgets.BoundedFloatText(min=0, max=100000, value=5, step=1)
In order to colour the dataframe cells, we will define this function:
def colour_ge_value(value, comparison):    if value >= comparison:        return 'color: red'    else:        return 'color: black'
Now we will minimally amend the common_filtering function to:
add new num input parameter:
def common_filtering(year, purpose, num):
apply the styling by calling the colour_ge_value function for the three numeric columns:
with output:        display(common_filter                .style.applymap(                    lambda x: colour_ge_value(x, num),                    subset=['visits','spend', 'nights']))
The existing event handlers need to be adjusted to pass the bounded_num.value:
def dropdown_year_eventhandler(change):    common_filtering(change.new, dropdown_purpose.value,                     bounded_num.value)def dropdown_purpose_eventhandler(change):    common_filtering(dropdown_year.value, change.new,                      bounded_num.value)
And finally we will plug-in the event handler of the new widget:
def bounded_num_eventhandler(change):    common_filtering(dropdown_year.value, dropdown_purpose.value,                      change.new)bounded_num.observe(bounded_num_eventhandler, names='value')
Code snippet:
Here is the demo:
Next we will be adding a new graph to plot a basic univariate density of the number of visits (KDE → Kernel Density Estimation). We will use seaborn, so let’s import the libraries:
import seaborn as snsimport matplotlib.pyplot as plt
Continuing the previous use-case, we will capture the plot in a new output variable:
plot_output = widgets.Output()
We will now amend the common_filtering function to plot the new diagram:
first we clear the output:
plot_output.clear_output()
and then we call the kdeplot method of seaborn by passing the number of visits:
with plot_output:    sns.kdeplot(common_filter['visits'], shade=True)    plt.show()
Lastly, the only thing we need to do is to display the outputs in a new cell:
display(output)display(plot_output)
Code snippet:
Here is the demo:
Up until now our user interface is functional but is taking up a lot of real estate.
We will first arrange the input widgets horizontally. The HBox will add widgets to it one at a time from left-to-right:
input_widgets = widgets.HBox([dropdown_year, dropdown_purpose, bounded_num])display(input_widgets)
Next we will create a container for the output. Tab is great for this. The 1st tab will host the dataframe and the 2nd one the graph.
tab = widgets.Tab([output, plot_output])tab.set_title(0, 'Dataset Exploration')tab.set_title(1, 'KDE Plot')display(tab)
Finally we will stack the input widgets and the tab on top of each other with a VBox.
dashboard = widgets.VBox([input_widgets, tab])display(dashboard)
It feels a bit ‘jammed’, so as a last step, we will polish our dashboard by adding some space. We will define a Layout giving 50px margin between the items.
item_layout = widgets.Layout(margin='0 0 50px 0')
We will call this layout for each item:
input_widgets = widgets.HBox([dropdown_year, dropdown_purpose, bounded_num],layout=item_layout)tab = widgets.Tab([output, plot_output],layout=item_layout)
and ta da.... Our finished dashboard:
PS: For presentation purposes, in some of these demos I have used a subset of the dataset i.e.: df_london = df_london.sample(250).
There are a few third party widgets you can use too, with most popular ones being:• 2-D charting: bqplot• 3-D visualisation: pythreejs and ipyvolume• Mapping: ipyleaflet and gmaps.
You can also build your own custom widgets! For more info take a look here.
We saw a fairly wide range of widgets in action but we still have only scratched the surface here — we can build really complex and extensive GUIs using ipywidgets. I hope you all agree they deserve a place in any Data Scientist’s toolbox as they enhance our productivity and add a lot of value during data exploration.
Thanks for reading!
I regularly write about Technology & Data on Medium — if you would like to read my future posts then please ‘Follow’ me!