Parameterisation#

Panel supports the use of parameters and dependencies between parameters, expressed in a simple way by param, to encapsulate dashboards as declarative, stand-alone classes.

Parameters are Python attributes that have been extended using the param library to support types, ranges, and documentation. This is just the information you need to automatically create widgets for each parameter.

Parameters and widgets#

For this purpose, some parameterised classes with different parameters are declared first:

[1]:
import datetime as dt

import param


class BaseClass(param.Parameterized):
    x = param.Parameter(default=3.14, doc="X position")
    y = param.Parameter(default="Not editable", constant=True)
    string_value = param.String(default="str", doc="A string")
    num_int = param.Integer(50000, bounds=(-200, 100000))
    unbounded_int = param.Integer(23)
    float_with_hard_bounds = param.Number(8.2, bounds=(7.5, 10))
    float_with_soft_bounds = param.Number(
        0.5, bounds=(0, None), softbounds=(0, 2)
    )
    unbounded_float = param.Number(30.01, precedence=0)
    hidden_parameter = param.Number(2.718, precedence=-1)
    integer_range = param.Range(default=(3, 7), bounds=(0, 10))
    float_range = param.Range(default=(0, 1.57), bounds=(0, 3.145))
    dictionary = param.Dict(default={"a": 2, "b": 9})


class Example(BaseClass):
    """An example Parameterized class"""

    timestamps = []

    boolean = param.Boolean(True, doc="A sample Boolean parameter")
    color = param.Color(default="#FFFFFF")
    date = param.Date(
        dt.datetime(2017, 1, 1),
        bounds=(dt.datetime(2017, 1, 1), dt.datetime(2017, 2, 1)),
    )
    select_string = param.ObjectSelector(
        default="yellow", objects=["red", "yellow", "green"]
    )
    select_fn = param.ObjectSelector(default=list, objects=[list, set, dict])
    int_list = param.ListSelector(
        default=[3, 5], objects=[1, 3, 5, 7, 9], precedence=0.5
    )
    single_file = param.FileSelector(path="../../*/*.py*", precedence=0.5)
    multiple_files = param.MultiFileSelector(
        path="../../*/*.py?", precedence=0.5
    )
    record_timestamp = param.Action(
        lambda x: x.timestamps.append(dt.datetime.now()),
        doc="""Record timestamp.""",
        precedence=0.7,
    )


Example.num_int
[1]:
50000

As you can see, the declaration of parameters only depends on the separate param library. Parameters are a simple idea with a few properties critical to creating clean, usable code:

  • The param library is written in pure Python with no dependencies, which makes it easy to include in any code without tying it to a specific GUI or widgets library, or to Jupyter notebooks.

  • Parameter declarations focus on semantic information that is relevant to your domain. In this way, you avoid contaminating domain-specific code with anything that binds it to a specific display or interaction with it.

  • Parameters can be defined wherever they make sense in your inheritance hierarchy, and you can document them once, enter them and limit them to a certain area. All these properties are inherited from any base class. For example, all parameters work the same here, regardless of whether they were declared in BaseClass or Example. This makes it easier to provide this metadata once and prevents it from being duplicated anywhere in the code where areas or types need to be checked or documentation saved.

If you then decide to use these parameterised classes in a notebook or web server environment, you can easily display and edit the parameter values as an optional additional step with import panel:

[2]:
import panel as pn


pn.extension()

base = BaseClass()
pn.Row(Example.param, base.param)
[2]:

As you can see, Panel does not need to have knowledge of your domain-specific application, nor of the names of your parameters. It simply shows widgets for all parameters that have been defined for this object. By using Param with Panel, an almost complete separation between your domain-specific code and your display code is achieved, which considerably simplifies the maintenance of both over a longer period of time. Here even the msg behavior of the buttons was declared declaratively as an action that can be called regardless of whether it is used in a GUI or in another context.

Interaction with the above widgets is only supported in the notebook and on the bokeh server. However, you can also export static renderings of the widgets to a file or a website.

If you edit values in this way, you have to run the notebook cell by cell by default. When you get to the cell above, edit the values as you wish and execute the following cells, in which these parameter values are referred to, your interactively selected settings are used:

[3]:
Example.unbounded_int
[3]:
23
[4]:
Example.num_int
[4]:
50000

To work around this and automatically update all widgets generated from the parameter, you can pass the param object:

[5]:
pn.Row(Example.param.float_range, Example.param.num_int)
[5]:

Custom widgets#

In the previous section we saw how parameters can be automatically converted into widgets. This is possible because the Panel internally manages an assignment between parameter types and widget types. However, sometimes the standard widget doesn’t provide the most convenient user interface, and we want to give Panel an explicit hint on how a parameter should be rendered. This is possible with the widgets argument for the Param panel. With the widgets keyword we can declare an association between the parameter name and the desired widget type.

As an example we can assign a RadioButtonGroup and a DiscretePlayer to a String and a Number selector.

[6]:
class CustomExample(param.Parameterized):
    """An example Parameterized class"""

    select_string = param.Selector(objects=["red", "yellow", "green"])
    select_number = param.Selector(objects=[0, 1, 10, 100])


pn.Param(
    CustomExample.param,
    widgets={
        "select_string": pn.widgets.RadioButtonGroup,
        "select_number": pn.widgets.DiscretePlayer,
    },
)
[6]:

It is also possible to pass arguments to the widget to customise it. Instead of passing the widget, pass a dictionary with the options you want. Uses the type keyword to map the widget:

[7]:
pn.Param(
    CustomExample.param,
    widgets={
        "select_string": {
            "type": pn.widgets.RadioButtonGroup,
            "button_type": "primary",
        },
        "select_number": pn.widgets.DiscretePlayer,
    },
)
[7]:

Parameter dependencies#

Declaring parameters is usually just the beginning of a workflow. In most applications, these parameters are then linked to a computation. To express the relationship between a computation and the parameters on which it depends, the param.depends decorator for parameterized methods can be used. This decorator gives panels and other param-based libraries (e.g. HoloViews) an indication that the method should be recalculated if a parameter is changed.

As a simple example with no additional dependencies, let’s write a small class that returns an ASCII representation of a sine wave that depends on phase and frequency parameters. When we pass the .view method to a panel, the view is automatically recalculated and updated as soon as one or more of the parameters change:

[8]:
import numpy as np


class Sine(param.Parameterized):
    phase = param.Number(default=0, bounds=(0, np.pi))
    frequency = param.Number(default=1, bounds=(0.1, 2))

    @param.depends("phase", "frequency")
    def view(self):
        y = np.sin(np.linspace(0, np.pi * 3, 40) * self.frequency + self.phase)
        y = ((y - y.min()) / y.ptp()) * 20
        array = np.array(
            [list((" " * (int(round(d)) - 1) + "*").ljust(20)) for d in y]
        )
        return pn.pane.Str(
            "\n".join(["".join(r) for r in array.T]), height=325, width=500
        )


sine = Sine(name="ASCII Sine Wave")
pn.Row(sine.param, sine.view)
[8]:

The parameterised and annotated view method can return any type provided by the Pane objects. This makes it easy to link parameters and their associated widgets to a plot or other output. Parameterised classes can therefore be a very useful pattern for encapsulating part of a computational workflow with an associated visualisation and for declaratively expressing the dependencies between the parameters and the computation.

By default, a Param area (Pane) shows widgets for all parameters with a precedence value above the value pn.Param.display_threshold, so you can use precedence to automatically hide parameters. You can also explicitly choose which parameters should contain widgets in a certain area by passing an parameters argument. For example, this code outputs a phase widget, keeping sine.frequency the initial value 1:

[9]:
pn.Row(pn.panel(sine.param, parameters=["phase"]), sine.view)
[9]:

Another common pattern is linking the values of one parameter to another parameter, for example when there are dependencies between parameters. In the following example we define two parameters, one for the continent and one for the country. Since we would like the selection of valid countries to change when we change continent, let’s define a method to do this for us. To connect the two, we express the dependency using the param.depends decorator and then use watch=True to ensure that the method is executed when the continent is changed.

We also define a view method that returns an HTML iframe showing the country using Google Maps.

[10]:
class GoogleMapViewer(param.Parameterized):
    continent = param.ObjectSelector(
        default="Asia", objects=["Africa", "Asia", "Europe"]
    )

    country = param.ObjectSelector(
        default="China", objects=["China", "Thailand", "Japan"]
    )

    _countries = {
        "Africa": ["Ghana", "Togo", "South Africa", "Tanzania"],
        "Asia": ["China", "Thailand", "Japan"],
        "Europe": ["Austria", "Bulgaria", "Greece", "Portugal", "Switzerland"],
    }

    @param.depends("continent", watch=True)
    def _update_countries(self):
        countries = self._countries[self.continent]
        self.param["country"].objects = countries
        self.country = countries[0]

    @param.depends("country")
    def view(self):
        iframe = """
        <iframe width="800" height="400" src="https://maps.google.com/maps?q={country}&z=6&output=embed"
        frameborder="0" scrolling="no" marginheight="0" marginwidth="0"></iframe>
        """.format(
            country=self.country
        )
        return pn.pane.HTML(iframe, height=400)


viewer = GoogleMapViewer(name="Google Map Viewer")
pn.Row(viewer.param, viewer.view)
[10]:

Whenever the continent changes, the _update_countries method for changing the displayed country list is now executed, which in turn triggers an update of the view method.

[11]:
from bokeh.plotting import figure


class Shape(param.Parameterized):
    radius = param.Number(default=1, bounds=(0, 1))

    def __init__(self, **params):
        super(Shape, self).__init__(**params)
        self.figure = figure(x_range=(-1, 1), y_range=(-1, 1))
        self.renderer = self.figure.line(*self._get_coords())

    def _get_coords(self):
        return [], []

    def view(self):
        return self.figure


class Circle(Shape):
    n = param.Integer(default=100, precedence=-1)

    def __init__(self, **params):
        super(Circle, self).__init__(**params)

    def _get_coords(self):
        angles = np.linspace(0, 2 * np.pi, self.n + 1)
        return (self.radius * np.sin(angles), self.radius * np.cos(angles))

    @param.depends("radius", watch=True)
    def update(self):
        xs, ys = self._get_coords()
        self.renderer.data_source.data.update({"x": xs, "y": ys})


class NGon(Circle):
    n = param.Integer(default=3, bounds=(3, 10), precedence=1)

    @param.depends("radius", "n", watch=True)
    def update(self):
        xs, ys = self._get_coords()
        self.renderer.data_source.data.update({"x": xs, "y": ys})

Parameter sub-objects#

Parameterized objects often have parameter values that are Parameterized objects themselves and form a tree-like structure. With the control panel you can not only edit the parameters of the main object, but also access sub-objects. Let’s first define a hierarchy of Shape classes that will draw a bokeh plot of the selected Shape:

[12]:
from bokeh.plotting import figure


class Shape(param.Parameterized):
    radius = param.Number(default=1, bounds=(0, 1))

    def __init__(self, **params):
        super(Shape, self).__init__(**params)
        self.figure = figure(x_range=(-1, 1), y_range=(-1, 1))
        self.renderer = self.figure.line(*self._get_coords())

    def _get_coords(self):
        return [], []

    def view(self):
        return self.figure


class Circle(Shape):
    n = param.Integer(default=100, precedence=-1)

    def __init__(self, **params):
        super(Circle, self).__init__(**params)

    def _get_coords(self):
        angles = np.linspace(0, 2 * np.pi, self.n + 1)
        return (self.radius * np.sin(angles), self.radius * np.cos(angles))

    @param.depends("radius", watch=True)
    def update(self):
        xs, ys = self._get_coords()
        self.renderer.data_source.data.update({"x": xs, "y": ys})


class NGon(Circle):
    n = param.Integer(default=3, bounds=(3, 10), precedence=1)

    @param.depends("radius", "n", watch=True)
    def update(self):
        xs, ys = self._get_coords()
        self.renderer.data_source.data.update({"x": xs, "y": ys})

Now that we have multiple Shape classes we can create instances of them and create a ShapeViewer to choose between. We can also declare two methods with parameter dependencies that update the plot and the plot title. It should be noted that the param.depends decorator can not only depend on parameters on the object itself, but can also be expressed on certain parameters on the subobject, for example shape.radius or with shape.param on parameters of the subobject.

[13]:
shapes = [NGon(), Circle()]


class ShapeViewer(param.Parameterized):
    shape = param.ObjectSelector(default=shapes[0], objects=shapes)

    @param.depends("shape")
    def view(self):
        return self.shape.view()

    @param.depends("shape", "shape.radius")
    def title(self):
        return "## %s (radius=%.1f)" % (
            type(self.shape).__name__,
            self.shape.radius,
        )

    def panel(self):
        return pn.Column(self.title, self.view)

Now that we have a class with sub-objects, we can display them as usual. Three main options control how the sub-object is rendered:

  • expand: whether the sub-object is expanded during initialisation (default=False)

  • expand_button: whether there should be a button to toggle the extension; otherwise it is set to the initial expand value (default=True)

  • expand_layout: A layout type or instance to extend the plot in (default=Column)

Let’s start with the standard view, which has a toggle button to expand the sub-object:

[14]:
viewer = ShapeViewer()

pn.Row(viewer.param, viewer.panel())
[14]:

Alternatively, we can offer a completely separate expand_layout instance for a param area, which with the expand and expand_button option always remains expanded. This allows us to separate the main widgets and the sub-object’s widgets:

[15]:
viewer = ShapeViewer()

expand_layout = pn.Column()

pn.Row(
    pn.Column(
        pn.panel(
            viewer.param,
            expand_button=False,
            expand=True,
            expand_layout=expand_layout,
        ),
        "#### Subobject parameters:",
        expand_layout,
    ),
    viewer.panel(),
)
[15]: