2c. Interactive plotting with Bokeh


[1]:
# Colab setup ------------------
import os, sys, subprocess
if "google.colab" in sys.modules:
    cmd = "pip install --upgrade biocircuits watermark"
    process = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    stdout, stderr = process.communicate()
# ------------------------------

import numpy as np
import scipy.integrate

import biocircuits.jsplots

import bokeh.io
import bokeh.layouts
import bokeh.models
import bokeh.plotting

import colorcet

# Set to True to have fully interactive plots with Python;
# Set to False to use pre-built JavaScript-based plots
interactive_python_plots = False
notebook_url = "localhost:8888"

bokeh.io.output_notebook()
Loading BokehJS ...

In this technical appendix, we make an interactive plot of the dynamics of an autorepressive gene to a pulse in its activating signal. Prior to proceeding to making interactive plots from the circuits, we need to define some functions and variables from Chapter 2 to make the plots. They are in the (hidden) code cell below.

[2]:
def s_pulse(t, t_0, tau):
    """
    Returns s value for a pulse centered at t_0 with duration tau.
    """
    # Return 0 is tau is zero, otherwise Gaussian
    return 0 if tau == 0 else np.exp(-4 * (t - t_0) ** 2 / tau ** 2)


def neg_auto_rhs(x, t, beta0, gamma, k, n, ks, ns, s):
    """
    Right hand side for negative autoregulation motif with s dependence.
    Return dx/dt.
    """
    # Compute dx/dt
    return (
        beta0 * (s / ks) ** ns / (1 + (s / ks) ** ns) / (1 + (x / k) ** n) - gamma * x
    )


def neg_auto_rhs_s_fun(x, t, beta0, gamma, k, n, ks, ns, s_fun, s_args):
    """
    Right hand side for negative autoregulation function, with s variable.
    Returns dx/dt.

    s_fun is a function of the form s_fun(t, *s_args), so s_args is a tuple
    containing the arguments to pass to s_fun.
    """
    # Compute s
    s = s_fun(t, *s_args)

    # Correct for x possibly being numerically negative as odeint() adjusts step size
    x = np.maximum(0, x)

    # Plug in this value of s to the RHS of the negative autoregulation model
    return neg_auto_rhs(x, t, beta0, gamma, k, n, ks, ns, s)


def unreg_rhs(x, t, beta0, gamma, ks, ns, s):
    """
    Right hand side for constitutive gene expression
    modulated to only be active in the presence of s.
    Returns dx/dt.
    """
    return beta0 * (s / ks) ** ns / (1 + (s / ks) ** ns) - gamma * x


def unreg_rhs_s_fun(x, t, beta0, gamma, ks, ns, s_fun, s_args):
    """
    Right hand side for unregulated function, with s variable.
    Returns dx/dt.

    s_fun is a function of the form s_fun(t, *s_args), so s_args is a tuple
    containing the arguments to pass to s_fun.
    """
    # Compute s
    s = s_fun(t, *s_args)

    # Plug in this value of s to the RHS of the negative autoregulation model
    return unreg_rhs(x, t, beta0, gamma, ks, ns, s)

Interactive plotting with Bokeh

To make an interactive plot in Bokeh, there are three major components.

  1. The plot or plots themselves.

  2. The widgets. Widgets for parameter values are primarily sliders, which enable you to vary parameter values by clicking and dragging. We will also make use of other widgets such as toggle, radio buttons, and drop menus throughout the book.

  3. The callback function. This is a function that is executed whenever a widget changes value. Most of the time, we use it to update a ColumnDataSource of a plot. You may have more than one callback functions for different widgets and also for changes in the range of the axis of the plot due to zooming.

  4. The layout. This is the spatial arrangement of the plots and widgets.

  5. The app. Bokeh will create an application that can be embedded in a notebook or serves as its own page in a browser. To create it, you need to make a simple function that adds the layout you built to the document that Bokeh will make into an app. (This sounds a lot more complicated than it is; see the example below.)

We refer to a plot or set of plots with widgets for interactivity as a dashboard.

Note

The excellent package Panel allows for more declarative (and hence fewer lines of code) means of dashboarding (with dashboards ultimately being rendered, if desired, with Bokeh) and we encourage you to explore it. We however choose to use base Bokeh for interactive plotting in this book because it offers greater flexibility and performance, allows for convenient JavaScript integration (which is necessary for many of the interactive plots to work in the static HTML rendering of this book), and does not require that much more effort compared to Panel. Typically, in our own work, we use base Bokeh for our dashboards.

A simple example: Varying the properties of the input signal

To demonstrate dashboard construction, we will first build a simple example that lets you interactively change the properties of the input signal’s time course, without incorporating it into the ODE model just yet.

Step 1: Make the widgets

Because we need parameter values to generate any curve on the plot, we first need to make widgets that define the parameter values. In this case, where we are simply plotting the input signal \(s\), we only need two sliders, one to for the value \(t_0\) and one for the value of \(\tau\).

[3]:
t0_slider = bokeh.models.Slider(
    title="t₀", start=0, end=10, step=0.01, value=4.0, width=150
)
tau_slider = bokeh.models.Slider(
    title="τ", start=0, end=10, step=0.01, value=2.0, width=150
)

The sliders are instantiated using bokeh.models.Slider(), with keyword arguments whose meaning should be obvious from their names. The value attribute of the slider is the present value of the slider.

Step 2: Generate the plot (but don’t show it)

Now that we have access to parameters we will generate the plot. We can pull the values of the sliders using their value attribute, e.g., tau_slider.value. When we generate the plot, we specify the data in a ColumnDataSource so that we can change the data in the plot without re-rendering it.

We cannot show the plot here, since we will show it in the app. A Bokeh plot can only be in a single document, in our case the app (which as far as Bokeh is concerned is separate from showing it in the JupyterLab cell below).

[4]:
# Set up time points
t = np.linspace(0, 10, 200)

# Build s, taking slider values are parameters.
s = s_pulse(t, t0_slider.value, tau_slider.value)

# Place the data in a ColumnDataSource
cds = bokeh.models.ColumnDataSource(dict(t=t, s=s))

# Build the plot
p = bokeh.plotting.figure(
    frame_height=200,
    frame_width=400,
    x_axis_label="time",
    y_axis_label="input signal",
    x_range=[0, 10],
    y_range=[-0.02, 1.1],
)
p.line(source=cds, x="t", y="s", line_width=2);

Step 3: Make the callbacks

Next, we specify the callback function that will be used to update the plot as the slider changes. The callback function for a slider must take three arguments, the attribute that changes, its old value, and its new one. We will not directly use these arguments (though we could), but will rather directly read the value from the slider using its value attribute. Our callback function simply updates the y data values of the ColumnDataSource.

[5]:
def callback(attr, old, new):
    cds.data["s"] = s_pulse(cds.data["t"], t0_slider.value, tau_slider.value)

Now, we need to alert Bokeh that it should trigger the callback function whenever the slider value changes.

[6]:
t0_slider.on_change("value", callback)
tau_slider.on_change("value", callback)

Step 4: Build the layout

Now that we have a slider and a plot and have linked the data source of the plot to the slider, we can lay out the dashboard. We will put the sliders in a column next to the plot, putting a spacer in between.

[7]:
layout = bokeh.layouts.row(
    p,
    bokeh.models.Spacer(width=30),
    bokeh.layouts.column(t0_slider, tau_slider),
)

Step 5: Make the app

Now we are ready to make a function to produce the app. The function needs to have call signature app(doc), where doc represents the document that Bokeh will build into an app. The purpose of this function is to add the layout we have just build to the document.

[8]:
def app(doc):
    doc.add_root(layout)

Step 6: Enjoy your interactive plot!

And we are finally ready to see the app! When we call bokeh.io.show(), we pass the app() function as the first argument. We also need to use the notebook_url keyword argument to specify where the notebook is being hosted. This is usually "localhost:8888", but the number may change (e.g., 8889 or 8890). You can look in the navigation bar of your browser to make sure you get the right number. In this and all other chapters, we specify notebook_url in the first cell of the notebook along with the imports.

Note

In the static HTML rendering of this notebook, there is no Python instance running, so apps requiring Python will not be responsive. Also, Bokeh apps currently are not supported by Google Colab.

Therefore, in chapters, technical appendices, and appendices with Bokeh apps requiring Python, we have a variable interactive_python_plots, which is set in the first code cell along with the imports. This is set to False for static HTML rendering. Where possible, we use a function in the biocircuits.jsplots module to generate a nearly identical interactive plot using pure JavaScript callbacks. The purpose here is to still allow the reader exploration of the plot without having to have a notebook running; the goal is not to teach JavaScript. When purely JavaScript-based interactivity is not possible, we do not link the sliders to callbacks (since there is no Python engine), and disable the control widgets.

[10]:
if interactive_python_plots:
    bokeh.io.show(app, notebook_url=notebook_url)
else:
    bokeh.io.show(biocircuits.jsplots.gaussian_pulse())

Warning

Only one Bokeh app may be active in a running notebook at a time. We will momentarily build another app to look at the autorepressor circuit dynamics, and after you execute the code to build that app, the above app will no longer be active.

Serving an app

If you wanted to have a stand-alone interactive plot on its own browser tab, you can put all of the code necessary to generate it in a single .py file and then serve it from the command line. For this example, we could have a file pulse_signal.py with the following contents.

import numpy as np
import bokeh.plotting
import bokeh.models


def s_pulse(t, t_0, tau):
    """
    Returns s value for a pulse centered at t_0 with duration tau.
    """
    # Return 0 is tau is zero, otherwise Gaussian
    return 0 if tau == 0 else np.exp(-4 * (t - t_0) ** 2 / tau ** 2)


# Sliders with parameter values
t0_slider = bokeh.models.Slider(
    title="t0", start=0, end=10, step=0.01, value=4.0, width=150
)
tau_slider = bokeh.models.Slider(
    title="tau", start=0, end=10, step=0.01, value=2.0, width=150
)

# Set up time points
t = np.linspace(0, 10, 200)

# Build s, taking slider values are parameters.
s = s_pulse(t, t0_slider.value, tau_slider.value)

# Place the data in a ColumnDataSource
cds = bokeh.models.ColumnDataSource(dict(t=t, s=s))

# Build the plot
p = bokeh.plotting.figure(
    frame_height=200,
    frame_width=400,
    x_axis_label="time",
    y_axis_label="input signal",
    x_range=[0, 10],
    y_range=[0, 1.1],
)
p.line(source=cds, x="t", y="s", line_width=2)


def callback(attr, old, new):
    cds.data["s"] = s_pulse(cds.data["t"], t0_slider.value, tau_slider.value)


t0_slider.on_change("value", callback)
tau_slider.on_change("value", callback)

layout = bokeh.layouts.row(
    p, bokeh.models.Spacer(width=30), bokeh.layouts.column(t0_slider, tau_slider)
)


def app(doc):
    doc.add_root(layout)


# Build the app in the current doc
app(bokeh.plotting.curdoc())

After saving that file, you can serve it be doing the following on the command line.

bokeh serve --show s_pulse.py

An app for the negative autoregulation model

Now that we have gained some familiarity with interactive plotting via Bokeh, we will make a dashboard to allow us to interactively explore the negative autoregulation model. To do so, we will incorporate scipy’s ODE integration into the plotting function itself, and we will also demonstrate how to create a button that can toggle a categorical property (like normalization of the results).

Step 1: Build the widgets

We will build sliders, one for each parameter. We are interested in varying the parameters \(\beta\), \(\gamma\), and \(x_0\) on a logarithmic scale, so we will set up the sliders to specify \(\log_{10} \beta\), \(\log_{10}\gamma\) and \(\log_{10} x_0\).

[11]:
log_beta0_slider = bokeh.models.Slider(
    title="log₁₀ β₀", start=-1, end=2, step=0.1, value=np.log10(100.0), width=150
)
log_gamma_slider = bokeh.models.Slider(
    title="log₁₀ γ", start=-1, end=2, step=0.1, value=np.log10(1.0), width=150
)
log_k_slider = bokeh.models.Slider(
    title="log₁₀ k", start=-1, end=2, step=0.1, value=np.log10(1.0), width=150
)
n_slider = bokeh.models.Slider(
    title="n", start=0.1, end=10, step=0.1, value=1.0, width=150
)
log_ks_slider = bokeh.models.Slider(
    title="log₁₀ kₛ", start=-2, end=2, step=0.1, value=np.log10(0.1), width=150
)
ns_slider = bokeh.models.Slider(
    title="nₛ", start=0.1, end=10, step=0.1, value=10.0, width=150
)
t0_slider = bokeh.models.Slider(
    title="t₀", start=0.01, end=10, step=0.01, value=4.0, width=150
)
tau_slider = bokeh.models.Slider(
    title="τ", start=0.01, end=10, step=0.01, value=2.0, width=150
)

We also want to be able to toggle between display of normalized versus unnormalized responses. We can make a toggle button for that.

[12]:
normalize_toggle = bokeh.models.Toggle(label='Normalize', active=True, width=50)

Finally, the legend may occasionally get in the way, so we want to toggle its visibility.

[13]:
legend_toggle = bokeh.models.Toggle(label='Legend', active=True, width=50)

Step 2: Build the plot

We will build the plot as before. For convenience, we make a function to packages the slider values into tuples to be passed into the right-hand-side functions for the ODE solver.

[14]:
def package_args():
    """Package slider values into tuples."""
    s_args = (t0_slider.value, tau_slider.value)

    args = (
        10 ** log_beta0_slider.value,
        10 ** log_gamma_slider.value,
        10 ** log_k_slider.value,
        n_slider.value,
        10 ** log_ks_slider.value,
        ns_slider.value,
        s_pulse,
        s_args,
    )

    args_unreg = (
        10 ** log_beta0_slider.value,
        10 ** log_gamma_slider.value,
        10 ** log_ks_slider.value,
        ns_slider.value,
        s_pulse,
        s_args,
    )

    return s_args, args, args_unreg

Now, we proceed to integrate the ODE to get solutions for plotting.

[15]:
# Initial condition
x0 = 0.0

# Arguments for right-hand-sides of the ODEs
s_args, args, args_unreg = package_args()

# Integrate ODE
x = scipy.integrate.odeint(neg_auto_rhs_s_fun, x0, t, args=args)
x = x.transpose()[0]
x_unreg = scipy.integrate.odeint(unreg_rhs_s_fun, x0, t, args=args_unreg)
x_unreg = x_unreg.transpose()[0]

# also calculate the input
s = s_pulse(t, *s_args)

# Normalize time courses
x /= x.max()
x_unreg /= x_unreg.max()

# set up the column data source
cds = bokeh.models.ColumnDataSource(dict(t=t, x=x, s=s, x_unreg=x_unreg))

# set up plot
p = bokeh.plotting.figure(
    frame_width=375,
    frame_height=250,
    x_axis_label="time",
    y_axis_label="normalized concentration",
    x_range=[t.min(), t.max()],
)

# Color palette
colors = colorcet.b_glasbey_category10

# Populate glyphs
p.line(source=cds, x="t", y="x", line_width=2, color=colors[1], legend_label="x neg. auto.")
p.line(source=cds, x="t", y="x_unreg", line_width=2, color=colors[2], legend_label="x unreg.")
p.line(source=cds, x="t", y="s", line_width=2, color=colors[0], legend_label="s")

# Place the legend
p.legend.location = "top_left"

Step 3: Build the callbacks

We can now build our callback. This callback will be a bit more complicated. For each new slider value, we need to re-integrate the dynamical equations. We also need to check to see if the normalization toggle button is clicked and appropriately scale the data and change the y-axis label. We also will need to recalculate the result if the range of the time axis changes, so we need to read the time points off of the x_range property of the plot.

[16]:
def neg_auto_callback(attr, old, new):
    # Set up time values, keeping minimum at zero
    t = np.linspace(0, p.x_range.end, 2000)

    # Package slider values
    s_args, args, args_unreg = package_args()

    # Integrate ODES
    x = scipy.integrate.odeint(neg_auto_rhs_s_fun, x0, t, args=args)
    x = x.transpose()[0]
    x_unreg = scipy.integrate.odeint(unreg_rhs_s_fun, x0, t, args=args_unreg)
    x_unreg = x_unreg.transpose()[0]

    # Also calculate the input
    s = s_pulse(t, *s_args)

    # Normalize if desired
    if normalize_toggle.active:
        if x.max() > 0:
            x /= x.max()
        if x_unreg.max() > 0:
            x_unreg /= x_unreg.max()
        p.yaxis.axis_label = "normalized concentration"
    else:
        p.yaxis.axis_label = "concentration"

    # Show or hide legend
    if legend_toggle.active:
        p.legend.visible = True
    else:
        p.legend.visible = False

    # Update data source
    cds.data = dict(t=t, x=x, s=s, x_unreg=x_unreg)

Now we link the callback to the sliders, and also to the normalization toggle and the range of the time axis.

[17]:
for slider in [
    log_beta0_slider,
    log_gamma_slider,
    log_k_slider,
    n_slider,
    log_ks_slider,
    ns_slider,
    t0_slider,
    tau_slider,
]:
    slider.on_change("value", neg_auto_callback)

normalize_toggle.on_change("active", neg_auto_callback)
legend_toggle.on_change("active", neg_auto_callback)
p.x_range.on_change("end", neg_auto_callback)

Step 4: The layout

We can now lay things out. I will put the sliders and normalization toggle in a column next to the plot.

[18]:
layout = bokeh.layouts.row(
    p,
    bokeh.layouts.Spacer(width=30),
    bokeh.layouts.column(
        log_beta0_slider,
        log_gamma_slider,
        log_k_slider,
        n_slider,
        legend_toggle,
    ),
    bokeh.layouts.column(
        log_ks_slider,
        ns_slider,
        t0_slider,
        tau_slider,
        normalize_toggle,
    )
)

Step 5: The app

And now for the app! (Note again that the app in the HTML rendering of this notebook will be using JavaScript. To use the Python-based app, be sure interactive_python_plots is True in the top cell of this notebook.)

[20]:
def app(doc):
    doc.add_root(layout)

if interactive_python_plots:
    bokeh.io.show(app, notebook_url=notebook_url)
else:
    bokeh.io.show(biocircuits.jsplots.autorepressor_response_to_pulse())

By moving around the sliders, we can see that increasing the strength of the repression (by decreasing \(k\)) accentuates the speed-up provided by negative autoregulation. Furthermore, we see that increasing the cooperativitity of the repressor (by increasing \(n\)) makes the initial rise in \(x\) “sharper”. What other properties can you find through interacting with this plot?

Computing environment

[21]:
%load_ext watermark
%watermark -v -p numpy,scipy,bokeh,colorcet,biocircuits,jupyterlab
Python implementation: CPython
Python version       : 3.10.10
IPython version      : 8.10.0

numpy      : 1.23.5
scipy      : 1.10.0
bokeh      : 3.1.0
colorcet   : 3.0.1
biocircuits: 0.1.8
jupyterlab : 3.5.3