Parametrisierung#

Panel unterstützt die Verwendung von Parametern und Abhängigkeiten zwischen Parametern, die von param in einfacher Weise ausgedrückt werden, um Dashboards als deklarative, eigenständige Klassen zu kapseln.

Parameter sind Python-Attribute, die mithilfe der param-Bibliothek erweitert wurden, um Typen, Bereiche und Dokumentation zu unterstützen. Dabei handelt es sich lediglich um die Informationen, die ihr zum automatischen Erstellen von Widgets für jeden Parameter benötigt.

Parameter und Widgets#

Hierfür werden zuerst einige parametrisierte Klassen mit verschiedenen Parametern deklariert:

[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

Wie ihr seht, hängt die Deklaration von Parametern nur von der separaten param-Bibliothek ab. Parameter sind eine einfache Idee mit einigen Eigenschaften, die für die Erstellung von sauberem, verwendbarem Code entscheidend sind:

  • Die param-Bibliothek ist in reinem Python ohne Abhängigkeiten geschrieben, wodurch es einfach ist, sie in jeden Code einzubinden, ohne sie an eine bestimmte GUI- oder Widgets-Bibliothek oder an Jupyter-Notebooks zu binden.

  • Parameterdeklarationen konzentrieren sich auf semantische Informationen, die für eure Domäne relevant sind. So vermeidet ihr, dass domänenspezifischer Code durch irgendetwas verunreinigt wird, das ihn an eine bestimmte Art der Anzeige oder Interaktion mit ihm bindet.

  • Parameter können überall dort definiert werden, wo sie in eurer Vererbungshierarchie sinnvoll sind, und ihr könnt sie einmal dokumentieren, eingeben und auf einen bestimmten Bereich beschränken. Dabei werden alle diese Eigenschaften von einer beliebigen Basisklasse geerbt. Beispielsweise funktionieren hier alle Parameter gleich, unabhängig davon, ob sie in BaseClass oder Example deklariert wurden. Dies erleichtert die einmalige Bereitstellung dieser Metadaten und verhindert, dass sie überall im Code dupliziert werden, wo Bereiche oder Typen überprüft oder Dokumentationen gespeichert werden müssen.

Wenn ihr euch dann für die Verwendung dieser parametrisierten Klassen in einer Notebook- oder Webserver-Umgebung entscheidet, könnt ihr mit import panel die Parameter-Werte als optionalen zusätzlichen Schritt einfach anzeigen und bearbeiten:

[2]:
import panel as pn


pn.extension()

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

Wie ihr seht, muss Panel nicht über Kenntnisse eurer domänenspezifischen Anwendung verfügen, auch nicht über die Namen eurer Parameter. Es werden einfach Widgets für alle Parameter angezeigt, die für dieses Objekt definiert wurden. Durch die Verwendung von Param mit Panel wird somit eine nahezu vollständige Trennung zwischen eurem domänenspezifischen Code und eurem Display-Code erreicht, wodurch die Wartung beider über einen längeren Zeitraum erheblich vereinfacht wird. Hier wurde sogar das msg-Behavior der Schaltflächen deklarativ festgelegt als eine Aktion, die unabhängig davon, ob sie in einer GUI oder in einem anderen Kontext verwendet wird, aufgerufen werden kann.

Die Interaktion mit den oben genannten Widgets wird nur im Notebook und auf dem Bokeh-Server unterstützt. Ihr könnt jedoch auch statische Renderings der Widgets in eine Datei oder eine Webseite exportieren.

Wenn ihr Werte auf diese Weise bearbeitet, müsst ihr das Notebook standardmäßig Zelle für Zelle ausführen. Wenn ihr zu der obigen Zelle gelangt, bearbeitet ihr die Werte nach euren Wünschen und führt die nachfolgenden Zellen aus, in denen auf diese Parameterwerte verwiesen wird, werden eure interaktiv ausgewählte Einstellungen verwendet:

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

Um dies zu umgehen und automatisch alle Widgets zu aktualisieren, die aus dem Parameter generiert wurden, könnt ihr das param-Objekt übergeben:

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

Benutzerdefinierte Widgets#

Im vorherigen Abschnitt haben wir gesehen, wie Parameter automatisch in Widgets umgewandelt werden können. Dies ist möglich, da Panel intern eine Zuordnung zwischen Parameter- und Widget-Typen verwaltet. Manchmal bietet das Standard-Widget jedoch nicht die bequemste Benutzeroberfläche, und wir möchten Panel einen expliziten Hinweis geben, wie ein Parameter gerendert werden soll. Dies ist mit dem widgets-Argument für das Param-Panel möglich. Mit dem widgets-Keyword können wir eine Zuordnung zwischen dem Parameter-Namen und dem gewünschten Widget-Typ deklarieren.

Als Beispiel können wir einer RadioButtonGroup und einem DiscretePlayer einen String- und einen Number-Selector zuordnen.

[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]:

Es ist auch möglich, Argumente an das Widget zu übergeben, um es anzupassen. Anstatt das Widget zu übergeben, übergebt ein Wörterbuch mit den gewünschten Optionen. Verwendet das type-Schlüsselwort, um das Widget zuzuordnen:

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

Parameter-Abhängigkeiten#

Das Deklarieren von Parametern ist normalerweise nur der Anfang eines Workflows. In den meisten Anwendungen sind diese Parameter dann an eine Berechnung gebunden. Um die Beziehung zwischen einer Berechnung und den Parametern, von denen sie abhängt, auszudrücken, kann der param.depends-Dekorator für parametrisierte Methoden verwendet werden. Dieser Dekorator gibt Panel und anderen param-basierten Bibliotheken (z.B. HoloViews) einen Hinweis, dass die Methode bei einer Änderung eines Parameters neu bewertet werden sollte.

Als einfaches Beispiel ohne zusätzliche Abhängigkeiten schreiben wir eine kleine Klasse, die eine ASCII-Darstellung einer Sinuswelle zurückgibt, die von phase und frequency-Parametern abhängt. Wenn wir die .view-Methode an ein Panel übergeben wird die Ansicht automatisch neu berechnet und aktualisiert, sobald sich einer oder mehrere der Parameter ändern:

[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]:

Die parametrisierte und mit Anmerkungen versehene view-Methode kann einen beliebigen Typ zurückgeben, der vom Pane-Objects bereitgestellt wird. Auf diese Weise können Parameter und die zugehörigen Widgets auf einfache Weise mit einem Plot oder einer anderen Ausgabe verknüpft werden. Parametrisierte Klassen können daher ein sehr nützliches Muster sein, um einen Teil eines Rechenworkflows mit einer zugehörigen Visualisierung zu kapseln und die Abhängigkeiten zwischen den Parametern und der Berechnung deklarativ auszudrücken.

Standardmäßig zeigt ein Param-Bereich (Pane) Widgets für alle Parameter mit einem precedence-Wert über dem Wert pn.Param.display_threshold an, sodass ihr precedence verwenden könnt um automatisch Parameter auszublenden. Ihr könnt auch explizit auswählen, welche Parameter Widgets in einem bestimmten Beriech enthalten sollen, indem ihr ein parameters-Argument übergebt. Dieser Code gibt beispielsweise ein phase-Widget aus wobei sine.frequency den Anfangswert 1 beibehält:

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

Ein weiteres gängiges Muster ist das Verknüpfen der Werte eines Parameters mit einem anderen Parameter, z.B. wenn Abhängigkeiten zwischen Parametern bestehen. Im folgenden Beispiel definieren wir zwei Parameter, einen für den Kontinent und einen für das Land. Da wir möchten, dass sich die Auswahl der gültigen Länder ändert, wenn wir den Kontinent wechseln, definieren wir eine Methode, um dies für uns zu tun. Um die beiden zu verbinden, drücken wir die Abhängigkeit mithilfe des param.depends-Dekorators aus und stellen dann mit watch=True sicher, dass die Methode ausgeführt wird, wenn der Kontinent geändert wird.

Wir definieren auch eine view-Methode, die einen HTML-Iframe zurückgibt, der das Land mithilfe von Google Maps anzeigt.

[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]:

Immer wenn sich der Kontinent ändert, wird nun die _update_countries-Methode zum Ändern der angezeigten Länderliste ausgeführt, was wiederum eine Aktualisierung der view-Methode auslöst.

[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-Unterobjekte#

Parameterized-Objekte haben oft Parameter-Werte, die selbst Parameterized-Objekte sind und eine baumartige Struktur bilden. Mit dem Bedienfeld könnt ihr nicht nur die Parameter des Hauptobjekts bearbeiten, sondern auch auf Unterobjekt durchgreifen. Definieren wir zunächst eine Hierarchie von Shape-Klassen deklarieren, die einen Bokeh-Plot des ausgewählten Shape zeichnen:

[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})

Jetzt, da wir mehrere Shape-Klassen haben, können wir Instanzen davon erstellen und einen ShapeViewer erstellen, um zwischen ihnen auszuwählen. Wir können auch zwei Methoden mit Parameter-Abhängigkeiten deklarieren, die den Plot und den Plot-Titel aktualisieren. Hierbei ist zu beachten, dass der param.depends-Dekorator nicht nur von Parametern am Objekt selbst abhängen kann, sondern auch von bestimmten Parametern am Unterobjekt, z.B. shape.radius oder von Parametern des Unterobjekts mit shape.param ausgedrückt werden kann.

[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)

Nachdem wir eine Klasse mit Unterobjekten haben, können wir sie wie gewohnt anzeigen. Drei Hauptoptionen steuern, wie das Unterobjekt gerendert wird:

  • expand: ob das Unterobjekt bei der Initialisierung erweitert wird (default=False)

  • expand_button: ob eine Schaltfläche zum Umschalten der Erweiterung vorhanden sein soll; ansonsten ist es auf den initialen expand-Wert festgelegt (default=True)

  • expand_layout: Ein Layout-Typ oder eine Instanz zum Erweitern des Plots in (default=Column)

Beginnen wir mit der Standardansicht, die eine Umschaltfläche zum Erweitern des Unterobjekts bietet:

[14]:
viewer = ShapeViewer()

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

Alternativ können wir eine völlig getrennte expand_layout-Instanz für einen Param-Bereich bieten, die mit expand und expand_button-Option immer ausgeklappt bleibt. Dies ermöglicht uns, die Haupt-Widgets und die Widgets des Unterobjekts getrennt anzuordnen:

[14]:
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(),
)
[14]: