Resources

Carson Sievert - Supercharge Your Shiny (for Python) App: Unleashing Interactive Jupyter Widgets

Most Python packages that provide interactive web-based visualizations (e.g., altair, plotly, bokeh, ipyleaflet, etc.) can render in Jupyter notebooks via the ipywidgets standard. The shinywidgets package brings that ipywidgets standard to Shiny, enabling the use of 100s of Jupyter Widgets as Shiny outputs. In this talk, you'll not only learn how to render Jupyter Widgets in Shiny to get interactive output, but also how to leverage user interaction with widgets to create delightful and bespoke experiences. Talk by Carson Sievert Slides: https://talks.cpsievert.me/20240814/ GitHub Repo: https://github.com/cpsievert/talks/tree/gh-pages/20240814

Oct 31, 2024
20 min

image: thumbnail.jpg

Transcript#

This transcript was generated automatically and may contain errors.

Oh, good. So thank you all for being here, um, and sticking with me after those two amazing talks, it'll be hard to follow that up and it's quite a different topic, but I will try my best, um, and, uh, share with you how you can use, uh, Jupyter widgets to supercharge your Shiny for Python.

Um, and when I talk about Jupyter widgets, I'm generally going to be referring to any Python code that you can execute in a Jupyter notebook cell and get a web based output results.

And, uh, if you've ever written Python code before in a notebook and displayed that data, you've already used a Jupyter widget in this sense, because we're executing Python, uh, code to get a web based results. And, but that being said, you know, kind of the default, uh, rendering of a data frame is a pretty basic widget. Um, you get a limited amount of interactivity with it. Um, but they can get a whole lot more sophisticated.

One-way vs two-way widget bindings

So a great example of this is the quack package, which is a new Python package that makes it super easy to, you just give it a data frame and it creates a much more sophisticated interactive table. And this table sort of just comes with the ability to drill down into certain values of your data. So it gives you these nice graphics at the top where you can choose certain values here, I'm choosing certain values of sex and a certain range of height. And that will drill down and just to the rows that, um, match those queries.

And in addition here, notice that I also executed another code cell after interacting with the table to get the SQL attributes, uh, of this widget, which actually captures the filtered state of the data. So it's almost as if we can in Python through the Python object, access information about what the user is doing with the widget, right?

So these are two really great examples that helped me kind of frame this idea that there are two broad categories of widgets when you're working in Jupyter.

Um, the most broad category, which really encompasses all widgets is the idea of a one-way binding. Which really all I mean by that is that you have some Python code that you're executing to get a web-based results. And any interactivity that you get from that widget, it's all happening inside of JavaScript. There's no communication back to Python. Everything is just kind of baked in and predefined. And, uh, if there's any interactivity, it's likely JavaScript.

Now the two-way binding has the additional ability to send data and information back and forth between Python and JavaScript. And actually with the Jupyter widget two-way binding, they have this concept of state, both existing, both in JavaScript and Python. And, um, the framework that makes this possible, uh, will sort of automatically in some sense, detect when state changes on the front end, say in JavaScript through drilling down in our table, automatically, there will be some messages sent back to Python to update the Python object with that state. And also vice versa, you can also update, uh, Python objects to then, uh, update the front end.

So one of the nice things about working with a one-way binding, as you can imagine with the two-way binding, there's a whole lot more complexity and overhead with, you know, passing data back and forth between Python and JavaScript, if the one-way binding kind of does the job for you, that's a great option because you can do kind of better, simpler things with it. Like, you know, not even working in notebook, you could take that Python code to create like a static webpage, maybe inside of a Quarto document.

But it's also kind of fundamentally more limited in this sense. Uh, mainly, you know, because we can't access any information about what's happening on the front end with our Python code, like we can't take, you know, what the user is doing to inform, um, other things in a web application.

So really what I mean by that, like, uh, the, the thing that makes, um, uh, this interesting from the interactivity perspective is that we can kind of access the state of what's happening on the front end and link multiple views in the sense of like they're sharing the state of what's happening to that Python object.

So if we go back to our interactive table with drill down capabilities, uh, you can imagine since we can actually access like what filtered data has been filtered in this table, we could take that data and route it to another view that maybe gives us like a higher dimensional picture of what's happening just in that filtered data.

The other big thing that the two way binding, um, gives us as a feature is this idea of mutable state. Which really means, you know, imagine you have a widget that you've just rendered and maybe you want to, um, just, uh, change a particular value of a particular attributes, which you can do is just mutate that Python objects, give it a new value for certain attributes. And just that change in states is sent to the front end. So in order to update a view, you can do it a lot more performantly by just sending only what has changed, um, from Python to JavaScript, right?

So to give you a simple, concrete example of this, imagine I had a dropdown where I could choose different cities and just update the center of a map from, um, one city to the next. If I were to try to do this by re rendering the map from scratch, every time you would get a little bit of graying out and flickering every time that map updates, but by just telling the front end that only the center of the map has changed, it's able to smoothly transition from one location to the next.

The IPyWidgets standard

So going back to our diagram here, there's actually like a formal name and standard and implementation of this two-way binding, which is provided by the IPyWidgets package. And you as a user might not actually end up importing from IPyWidgets. Uh, you may, maybe you just want to use, um, the Quack package or Plotly or Altair. Um, and all of these packages that provide this two-way binding interface are going to inherit from this widget class from IPyWidgets, because it kind of provides the foundation for passing that state back and forth between Python and JavaScript.

And as we'll see shortly, the ShinyWidgets package has a render widget decorator that allows us to render IPyWidgets in Shiny and leverage all of those nice things that come with the two-way binding.

And most of the projects that you've heard of, um, are going to provide two-way binding interfaces. So big names like Plotly, Leaflet, Bokeh, Vega, Altair, Mapbox, um, has a binding called PyDeck. All of these projects tend to provide a two-way binding interfaces.

And more than that, a lot of these big projects will also provide a one-way binding interface, which, you know, makes a whole lot of sense, uh, for the, especially from their perspective that sometimes, you know, the one-way binding does the job. Maybe you don't even want to work, uh, in a Jupyter notebook. Maybe you just want a script that, um, creates a static webpage or embedded in a Quarto document. Um, just having basically the static results with JavaScript, um, you know, requires a lot less overhead.

So, um, ShinyWidgets can actually handle both of these cases. And what it will do is it will automatically transform a one-way binding. If you give it, if you give this one-way binding to, um, ShinyWidgets, it will automatically transform the one-way binding to a two-way binding. And this makes getting started with ShinyWidgets, um, a whole lot easier because a lot of these packages like Altair, I'm just using Altair as an example here, but this is true for like Plotly and a lot of other packages. Where the one-way binding is kind of at the heart of their documentation and code examples. So if you actually look up Altair, you'll see Altair chart all over the place. So it's very convenient to just kind of dump those code examples into ShinyWidgets and it just works.

Using ShinyWidgets in code

So let's get a feel for, you know, the code that you're actually going to write in order to use IPv6. The first thing you're going to want to do is start by importing ShinyWidgets. And then you can use this render widget decorator to create a rendering function. And all you need to really make sure to do in this function is to return a widget object.

Now, if we were to take this kind of template and go back to our Quack widget that we started off with, we could start off very simple in the Shiny app by just reading in the data and making sure this rendering function returns the interactive table widget.

So we'd get a very basic result close to this. It would just be the table that you see on the top half here. But let's take that another step further and have a code chunk just below that, that will automatically update to show the SQL statement that mirrors the filtered data state. And sort of have a Shiny app that reactively updates that code cell whenever the user makes changes.

So let's go back to our simple Shiny app that just renders the table widget and let's add another rendering function here. I'm going to take the render code decorator from Shiny and I'm going to create another output essentially. And the first thing that this rendering function is going to want to do is gain a reference to the widget that has been rendered, and this can be a little bit awkward because there's scoping involved where you're inside of a different function, you're returning a widget object, so to make this a little bit more convenient, what the render widget decorator will do is actually attach a widget attribute where you can get at the return value of that function.

And if you recall to like the original notebook example that I showed at the beginning, we were executing a code cell that said essentially like w.sql to get at the SQL statement, and we can do this in a reactive sense where if we use this reactive read function to essentially read the SQL attribute, we're doing this in a reactive way that tells Shiny to say, hey, whenever this attribute changes, please invalidate the reactive context and essentially, you know, re-render the code block when SQL changes.

So now we have this Shiny app that we're looking for, where now the user can come in and drill down and the SQL will update to reflect the changes in state.

All right, let's take this one step further and go back to that original linked views app that I showed that has this ability to actually show you like a multidimensional picture. Here, I'm choosing a bin scatterplot of height versus weight and have that automatically update to just be showing what data that we've chosen in our tape. All right, so instead of a render code block, we're going to go back to render widgets and this time return an Altair chart instead of returning the SQL verbatim, and the start of the implementation of that render widget is very similar in the sense that we take a reactive dependency on the SQL statements behind the table to make sure that this like widget re-renders when that changes. And then feed in the filtered data to an Altair chart API and create the bin scatterplot.

Why ShinyWidgets works: the architecture

All right, and this point, I would just kind of like to take a step back and have us take a moment to appreciate that IPy widgets were designed for notebooks, not for Shiny. And the fact that we can even create something like Shiny widgets and, you know, leverage over a decade's worth of work in the Python community inside of Shiny for Python like this is really a matter of technical coincidence, and like there was no guarantee when we started Shiny for Python, like we were kind of scratching our heads, how are we going to, you know, allow people to use these rich interactive outputs in a way that they're kind of familiar with already?

And it's kind of a miracle almost in some sense that there was two kind of parallel worlds evolving over the last decade.

And it's kind of a miracle almost in some sense that there was two kind of parallel worlds evolving over the last decade. IPy widgets kind of came out around the same time as Shiny, like 2012 or something like that. And there's been over a decade of a lot of development and work in building upon this standard.

So to give you a little bit of a picture of how we're able to kind of hook into the IPy widgets architecture, let's actually kind of take a kind of a simplified view of the architecture that's necessary to power IPy widgets. And here I have this diagram where on the left-hand side, you know, we have the Python end and on the right-hand side, we have the front end in JavaScript. And whenever you create and display a new Jupyter widget in Python, what happens is the widget object sends some data and some information to what the IPy widgets developers call a com layer, which stands for communication layer, that will kind of orchestrate a message passing in a way that will power all of the widgets necessary in a notebook.

So it kind of receives some data from the widget, passes that along to a front end where there's kind of a similar communication layer. And then that communication layer will then kind of know how to get to the necessary JavaScript libraries and take that data and actually render it as a interactive view.

So once we're rendered and the user kind of maybe comes in and interacts with the widget and introduces some changes to state, that widget view will know how to send data to the com layer to say, Hey, I have some state that has changed. Com layer passes that back all the way back to the Python object on the back end.

And one of the interesting bits here is, you know, the widget implementations like Plotly, Leaflet, Altair, they're all going to be kind of responsible for this part of the stuff that happens that's very close to the actual widget object and the JavaScript view. But kind of more of the backend architecture of the com layer, this is all provided by IPyWidgets and is just kind of generically handles all different widget implementations.

And it turns out these IPyWidget developers were very thoughtful about how they actually design the software for these com layers, where they've designed them to be abstract and pluggable by other environments so that you can actually kind of, they've been thoughtful about somebody like me coming in and wanting to use these widgets in another environment. And it just so happens, like that's not necessarily good enough to, you know, leverage widgets in a framework like Shiny, but it just so happens that Shiny works through message passing in a very similar way that allows us to create something like Shiny widgets and have it be interoperable with Shiny.

So in summary, widgets are either one-way or two-way bindings. The two-way bindings are called IPyWidgets and are much more capable. And, you know, you're able to achieve a lot more with interactivity and performance with these widgets. And thanks to their open standard and the thoughtfulness of the IPyWidget developers, we can leverage IPyWidgets inside of Shiny for Python.

And thank you for listening.