holoviz / panel

Panel: The powerful data exploration & web app framework for Python
https://panel.holoviz.org
BSD 3-Clause "New" or "Revised" License
4.42k stars 484 forks source link

Extend pn.panel to support functions returning multiple objects #2967

Open MarcSkovMadsen opened 2 years ago

MarcSkovMadsen commented 2 years ago

Context

pn.panel is pretty amazing in guessing the right pane to wrap an object into.

But I've started to realize that there are use cases it does not support and where users then need to write a lot more code to handle then really necessary.

In the end I would like the user to end up with an easy to understand pipeline of transformations and code.

Scenario: Multiple Outputs

Example

image

Here pn.panel cannot correctly split the output into two panes. But it can find a pane for each individual result.

The use case here is really that the user has the model function.

import panel as pn

pn.extension(sizing_mode="stretch_width")

def model(value):
    return {"data": [value]}, f"https://audio.qurancdn.com/wbw/001_001_00{value}.mp3"

selection = pn.widgets.Select(value=1, options=[1,2,3,4])
imodel = pn.bind(model, value=selection)

pn.Column(
    selection,
    pn.panel(imodel),
).servable()

Current Solution

This is the minimum solution I've been able to implement if my requirement is that I only want to rerun the function once when the selection changes.

image

import panel as pn

pn.extension(sizing_mode="stretch_width")

def model(value):
    return {"data": [value]}, f"https://audio.qurancdn.com/wbw/001_001_00{value}.mp3"

selection = pn.widgets.Select(value=1, options=[1,2,3,4])
json = pn.pane.JSON()
audio = pn.pane.Audio()

def update(value):
    json.object, audio.object = model(value)

imodel = pn.bind(update, value=selection, watch=True)

pn.Column(
    selection,
    json,
    audio
).servable()

Solution: Full Blown

import panel as pn

pn.extension(sizing_mode="stretch_width")

def model(value):
    return {"data": [value]}, f"https://audio.qurancdn.com/wbw/001_001_00{value}.mp3"

selection = pn.widgets.Select(value=1, options=[1,2,3,4])
imodel = pn.bind(model, value=selection)
outputs=pn.output(imodel, outputs=[pn.pane.JSON, pn.pane.Audio])

pn.Column(*outputs).servable()

here .output could also be called .panels, .pipe etc. It might also just be an extension of the existing pn.panel?

Maybe the object argument should not be there but the user should just provide *args to the .output function?

Please note

Solution: Simple

Just support an outputs Integer in pn.panel.

import panel as pn

pn.extension(sizing_mode="stretch_width")

def model(value):
    return {"data": [value]}, f"https://audio.qurancdn.com/wbw/001_001_00{value}.mp3"

selection = pn.widgets.Select(value=1, options=[1,2,3,4])
imodel = pn.bind(model, value=selection)
outputs=pn.panel(imodel, outputs=2)

pn.Column(
    *outputs
).servable()

Additional Context

What gradio does is to combine the apis. So the user would just write

gradio.interface(model, inputs=[selection], outputs=[pn.pane.JSON, pn.pane.Audio])

From that they would get a layout. With functionality to screenshot and flag their model. A built in REST API for their model. And interactive documentation for the REST API.

MarcSkovMadsen commented 2 years ago

Scenario: Alternative Output

Often you don't want to output the pane chosen by pn.panel

Example

image

import panel as pn

pn.extension(sizing_mode="stretch_width")

def model(value):
    return f"https://audio.qurancdn.com/wbw/001_001_00{value}.mp3"

selection = pn.widgets.Select(value=1, options=[1,2,3,4])
imodel = pn.bind(model, value=selection)
output=pn.panel(imodel)

pn.Column(output).servable()

Output to Str

If I for some reason want to output to pn.pane.Str I have to

import panel as pn

pn.extension(sizing_mode="stretch_width")

def model(value):
    return f"https://audio.qurancdn.com/wbw/001_001_00{value}.mp3"

selection = pn.widgets.Select(value=1, options=[1,2,3,4])
output = pn.pane.Str()
def update(value):
    output.object=model(value)
update(selection.value)
pn.bind(update, value=selection, watch=True)

pn.Column(output).servable()

image

As I see it, this is a convoluted way of thinking and writing code, that makes Panel slower and more difficult to use than it has to be.

Solution

import panel as pn

pn.extension(sizing_mode="stretch_width")

def model(value):
    return f"https://audio.qurancdn.com/wbw/001_001_00{value}.mp3"

selection = pn.widgets.Select(value=1, options=[1,2,3,4])
imodel=pn.bind(model, value=selection, watch=True)
outputs=pn.outputs(imodel, outputs=[pn.pane.Str])
pn.Column(outputs).servable()

This is much simpler and much more in line with building a pipeline starting with inputs, applying the model and piping to outputs. Together with hvplot.interactive this is easy to understand for your brain.

Requirements

Same as for the Alternative Output scenario plus

MarcSkovMadsen commented 2 years ago

Other Solutions

Maybe the above solutions can be further simplified and made more powerful and general. Feel free to suggest.

philippjfr commented 2 years ago

If I for some reason want to output to pn.pane.Str I have to

Just one question to figure out if it's me or you that is missing something but what's wrong with:

def model(value):
    return pn.pane.Str(f"https://audio.qurancdn.com/wbw/001_001_00{value}.mp3")

in this scenario?

philippjfr commented 2 years ago

Note that I would be very open to finding some API for a user to declare the types of a function like this because in addition to being helpful for a user it would allow optimizing the implementation significantly.

MarcSkovMadsen commented 2 years ago

If I for some reason want to output to pn.pane.Str I have to

Just one question to figure out if it's me or you that is missing something but what's wrong with:

def model(value):
    return pn.pane.Str(f"https://audio.qurancdn.com/wbw/001_001_00{value}.mp3")

in this scenario?

I see the following issues

def model_wrapper(value):
    return pn.pane.Str(model(value))

and that is a bit convoluted

MarcSkovMadsen commented 2 years ago

The more I think about it, the more I think it makes sense to provide a pipe:

model, inputs, outputs = pn.pipe(model, inputs=[selection], outputs=[pn.pane.JSON, pn.pane.Audio])

where the inputs and outputs can be either classes or instances. The model returned is that same as returned from pn.bind.

MarcSkovMadsen commented 2 years ago

Or maybe you should call it .interactive similarly to hvplot.interactive.

So make you DataFrames interactive with hvplot.interactive and your models interactive with panel.interactive 😄

model, inputs, outputs = pn.interactive(model, inputs=[selection], outputs=[pn.pane.JSON, pn.pane.Audio])
MarcSkovMadsen commented 2 years ago

Proof of concept

The below is a proof of concept implementation of pipe and interactive. I'm pretty happy about it 😄

panel serve

https://user-images.githubusercontent.com/42288570/144375937-cd51b45e-0d6a-4247-9a07-09b06e2244c4.mp4

Pytest

$ pytest 'tests\shared\test_pipe.py'
================================================= test session starts =================================================
platform win32 -- Python 3.8.4, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: C:\repos\private\panel-ai, configfile: pytest.ini
plugins: anyio-3.3.4, cov-3.0.0, mock-3.6.1
collected 6 items

tests\shared\test_pipe.py ......                                                                                 [100%]

================================================== 6 passed in 1.30s ==================================================

Code

from typing import Tuple

import panel as pn

def _clean(output):
    if callable(output):
        return output()
    return output

def pipe(function, *outputs):
    if not hasattr(function, "__wrapped__") or not hasattr(function, "_dinfo"):
        raise ValueError("Function has not been bound. Please apply panel.bind before using the pipe")
    inputs = tuple(function._dinfo["kw"].values())

    def _get_results() -> Tuple:
        args = tuple(getattr(input.owner, input.name) for input in inputs)
        kwargs = dict(zip(function._dinfo["kw"].keys(), args))
        results = function(**kwargs)
        if not isinstance(results, (list, tuple)):
            results = (results,)
        return tuple(results)

    def _set_result(results: tuple, outputs: tuple):
        for index, output in enumerate(outputs):
            output.object = results[index]

    results = _get_results()
    if not outputs:
        outputs = tuple(pn.panel(result) for result in results)
    else:
        outputs = tuple(_clean(output) for output in outputs)
        _set_result(results, outputs)

    def _handle_change(*events):
        results = _get_results()
        _set_result(results, outputs)

    for input in inputs:
        input.owner.param.watch(_handle_change, parameter_names=[input.name])
    if len(outputs)==1:
        return outputs[0]
    return outputs

def interactive(function, inputs, outputs=None):
    if isinstance(inputs, dict):
        inputs = {key: _clean(input) for key, input in inputs.items()}
        ifunction = pn.bind(function, **inputs)
    elif isinstance(inputs, list):
        inputs = [_clean(input) for input in inputs]
        ifunction = pn.bind(function, *inputs)
    else:
        inputs = _clean(inputs)
        ifunction = pn.bind(function, inputs)

    if isinstance(outputs, list):
        outputs = [_clean(output) for output in outputs]
    elif outputs:
        outputs = [_clean(outputs)]

    if outputs:
        outputs = pipe(ifunction, *outputs)
    else:
        outputs = pipe(ifunction)

    return inputs, outputs

def test_pipe():
    # Given
    def model(value1, value2):
        return value1, value2

    input1=pn.widgets.TextInput()
    input2=pn.widgets.TextInput()

    imodel = pn.bind(model, value1=input1, value2=input2)
    # When
    output1, output2 = pipe(imodel, pn.pane.Str, pn.pane.Str)
    # Then
    assert isinstance(output1, pn.pane.Str)
    assert isinstance(output2, pn.pane.Str)

    # When
    input1.value = "Hello"
    assert output1.object == input1.value
    input2.value = "World"
    assert output2.object == input2.value

def test_pipe_can_replace_pn_panel():
    # Given
    def model(value1):
        return value1

    input1=pn.widgets.TextInput()

    imodel = pn.bind(model, value1=input1)
    # When
    output1 = pipe(imodel)
    # Then
    assert isinstance(output1, pn.pane.Markdown)

    # When
    input1.value = "Hello"
    assert output1.object == input1.value

def test_pipe_can_infer_outputs():
    # Given
    def model(value1, value2):
        return value1, value2

    input1=pn.widgets.TextInput()
    input2=pn.widgets.TextInput()

    imodel = pn.bind(model, value1=input1, value2=input2)
    # When
    output1, output2 = pipe(imodel)
    # Then
    assert isinstance(output1, pn.pane.Markdown)
    assert isinstance(output2, pn.pane.Markdown)

    # When
    input1.value = "Hello"
    assert output1.object == input1.value
    input2.value = "World"
    assert output2.object == input2.value

def test_interactive():
    # Given
    def model(value1, value2):
        return value1, value2

    input1_org=pn.widgets.TextInput
    input2_org=pn.widgets.TextInput()
    output1_org=pn.pane.Str
    output2_org=pn.pane.Str(name="Output 2")
    # When
    inputs, outputs = interactive(model, inputs=[input1_org, input2_org], outputs=[output1_org, output2_org])
    # Then
    input1, input2 = inputs
    assert isinstance(input1, pn.widgets.TextInput)
    assert input2==input2_org

    output1, output2 = outputs
    assert isinstance(output1, pn.pane.Str)
    assert output2==output2_org

    input1.value="Hello"
    output1.object==input1.value

    input2.value="World"
    output2.object==input1.value

def test_multi_output():
    def model(value):
        return {"data": [value]}, f"https://audio.qurancdn.com/wbw/001_001_00{value}.mp3"
    inputs, outputs = interactive(
        model,
        inputs=[
            pn.widgets.Select(value=1, options=[1,2,3,4])
        ],
    )
    assert outputs[0].object
    assert outputs[1].object
    return pn.Row(pn.Column(*inputs), pn.Column(*outputs))

def test_alternative_output():
    def model(value):
        return f"https://audio.qurancdn.com/wbw/001_001_00{value}.mp3"

    select = pn.widgets.Select(value=1, options=[1,2,3,4])
    inputs, outputs = interactive(
        model,
        inputs=select,
        outputs=pn.pane.Str,
    )
    assert inputs == select
    assert outputs[0].object
    assert outputs[0].object
    return pn.Row(pn.Column(inputs), pn.Column(outputs))

if __name__.startswith("bokeh"):
    pn.extension(sizing_mode="stretch_width")
    pn.Column(
        "# Test Alternative Output",
        test_alternative_output(),
        "# Test Multi Output",
        test_multi_output(),
    ).servable()