jupyter-widgets / ipywidgets

Interactive Widgets for the Jupyter Notebook
https://ipywidgets.readthedocs.io
BSD 3-Clause "New" or "Revised" License
3.13k stars 950 forks source link

Idea: Debounce and throttle options for observing traits #663

Open maartenbreddels opened 8 years ago

maartenbreddels commented 8 years ago

Two use cases:

  1. Using interact or a slider that causes something to execute which say takes ~100 ms to complete, you don't want events to be delivered all the time, but you may want to update it say every 250 ms, when a slider is changing value.
  2. I'm using bqplot and listening to a change in the min value of one of the axes. Only when it didn't change for say 500msec, I expect the user to be done with zooming and panning and will start a heavy computation to rebuild the figure.

If this is done on the client side (js), this will also lead to less traffic/events.

I can imaging something like this

interact(f, x=10, throttle=250);

For the bqplot use case

scale_x.observe(f, "xmin", debounce=500)

See here for a visual explaination: http://benalman.com/projects/jquery-throttle-debounce-plugin/

throttle and debounced functions are also available in underscore

Having them in link and js(d)link could also be useful (say when a widget would make a ajax request)

Carreau commented 8 years ago

IIRC it's already debounced/throttle, if there are more that 5 (IIRC) query pendings.

maartenbreddels commented 8 years ago

From what I understand from the code, it will not send 10 events at once after the kernel is finished, but just 1 (as is the default now).

This kind of throttling and debouncing is different (and debounce!=throttle), it is based on elapsed time since the first event. It will also be useful in combination with multithreading, as discussed here #642 It can also be done on the python side of course, though not as elegant, and requires attaching to the ioloop or some other async method. It makes more sense to implement in JS I think, to not send useless events to the kernel.

hainm commented 8 years ago

Two use cases:

add 3rd (my) case. I have an widget will update its state while user is typing in TextArea. Some operations are quite expensive, so I just want the TextArea updated every delay time.

ef331c07_m

gnestor commented 7 years ago

According to @jasongrout:

The sliders have a continuous_update option that [if set to False] only triggers a value change when the user stops dragging a slider: https://github.com/ipython/ipywidgets/blob/master/ipywidgets/widgets/widget_int.py#L156.

Related: https://github.com/ipython/ipywidgets/issues/584

gnestor commented 7 years ago

I should also add that every widget has a msg_throttle key/property that can be set. According to the help: "msg_throttle: Maximum number of msgs the front-end can send before receiving an idle msg from the back-end." https://github.com/ipython/ipywidgets/blob/eb4dcd9956d88ae842bff7822a9c32fc038906dd/ipywidgets/widgets/widget.py#L164

maartenbreddels commented 7 years ago

I currently have an 'ok' method to deal with this:

def debounced(delay_seconds=0.5, method=False):
    def wrapped(f):
        counters = collections.defaultdict(int)

        @functools.wraps(f)
        def execute(*args, **kwargs):
            if method: # if it is a method, we want to have a counter per instance
                key = args[0]
            else:
                key = None
            counters[key] += 1
            def debounced_execute(counter=counters[key]):
                if counter == counters[key]: # only execute if the counter wasn't changed in the meantime
                    f(*args, **kwargs)
            ioloop = get_ioloop()

            def thread_safe():
                ioloop.add_timeout(time.time() + delay_seconds, debounced_execute)

            ioloop.add_callback(thread_safe)
        return execute
    return wrapped

Use it like this:

@widgets.interact
@debounced(0.5)
def f(i=1):
    print(i)

Can also be used for methods, using method=True, I don't know an automatic way of testing the difference (I doubt it can be done). Note that output does not get cleared, but you could do that manually, say:

from IPython.display import clear_output
@widgets.interact
@debounced(1)
def f(i=1, f=0.1):
    clear_output(wait=True)
    print(i,f)
konwiddak commented 5 years ago

Function or method can be auto detected.

https://docs.python.org/3/library/inspect.html#inspect.ismethod

inspect.ismethod(object) Return true if the object is a bound method written in Python.

inspect.isfunction(object) Return true if the object is a Python function, which includes functions created by a lambda expression.

gsteele13 commented 3 years ago

Hi all,

I'd like to investigate this further, particularly in the context of using the interact() function with matplotlib.

Because matplotlib is slow, this basically renders using interact with matplotlib pointless, see this video:

https://youtu.be/NFrVjdiAiOk

This is based on this tiny piece of code:

import numpy as np
import matplotlib.pyplot as plt
from ipywidgets import interact

x = np.linspace(0,1,100)

def update_plot(p=1):
    plt.plot(x, x**p)

interact(update_plot, p=(0,1,0.01))

I think that my desires for interaction in this case could be solved with say a 100 ms debouncing, and the solution above from @maartenbreddels seems like it should work. But, so far, I haven't got it to work.

And, it's an awful lot of boilerplate for what I suspect might be a common problem with interact(), and involves a bit of painful digging into the gory details of python threading (gory to me at least...)

Does anyone have a suggestion on a simple, minimalist bit of code I could inject into my 3 lines of code above to achieve the debounce?

Also, I suspect that this might be a common desire of users of the interact function, who want to quickly create an interaction with their code without needing to understand / dig into the details of the python mulithreading behind it.

Maybe it would make sense for interact() to implement the debounce itself, with a keyword argument to specify the debounce time? That would be pretty awesome, and I think a lot of people would appreciate it.

ianhi commented 3 years ago

Did the discussion here occur prior to https://ipywidgets.readthedocs.io/en/latest/examples/Widget%20Events.html?highlight=throttle#Throttling, or are these somewhat orthogonal issues?


Because matplotlib is slow, this basically renders using interact with matplotlib pointless, see this video:

Hi @gsteele13, I have some specific advice for how you can speed up interactive matplotlib without throttling or debouncing

In general using the inline backend with savefig is going to be least performant option to display an interactive maptlotlib plot. This is because when combining interact with the inline backend what happens every time the slider value updates is:

  1. plt.plot creates a new figure (expensive)
  2. the plot command runs (cheap)
  3. The figure draw command gets called (expensive)
  4. savefig gets called (expensive)
  5. Display updates

So instead I always recommend using the ipympl backend which embeds a true interactive matplotlib figure in the notebook. In contrast the inline backend is constantly creating and saving and destroying new figures. Doing that allows you to use commands like line.set_ydata so you don't need to regenerate the entire figure every time.

So for example I get what I would argue is acceptable performance without any throttling or debouncing:

%matplotlib ipympl
import numpy as np
import matplotlib.pyplot as plt
import ipywidgets as widgets

x = np.linspace(0,1,100)
fig, ax = plt.subplots()
line, = ax.plot(x, x**.5)

slider = widgets.FloatSlider(value=0.5, min=0, max=1, step=0.01)
def update_plot(change['new']):
    # you can use either change['new']
    # or slider.value. But one or the other may work better with
    # throttling and debouncing
    line.set_ydata(x**slider.value)

ax.set_ylim(0,1)
slider.observe(update_plot, names='value')
display(slider)

You can of course still take the throttle or debounce functions from the docs: https://ipywidgets.readthedocs.io/en/latest/examples/Widget%20Events.html?highlight=throttle#Throttling and apply them to update function if you want to debounce it even more.

There is however a downside here, which is that ipympl and interact don't tend to play nice together (see https://github.com/matplotlib/ipympl/issues/36). So it is better to manually create the sliders and attach them to the callback as I did above. This can be kind of a hassle and as you note:

Also, I suspect that this might be a common desire of users of the interact function, who want to quickly create an interaction with their code without needing to understand

boilerplate can indeed make it tricky to create an interaction. So I ended up making a library mpl-interactions that makes it easy to hook up widgets to matplotlib plots in the most performant ways possible. It was directly inspired by the issue you are having - the name is based on a combination of matplotlib and interact.

Using that library you should get all the benefits of not creating new figures and your code simplifies to:

%matplotlib ipympl
import mpl_interactions.ipyplot as iplt
import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(0, np.pi, 100)

def f(x, p):
    return x**p

fig, ax = plt.subplots()
controls = iplt.plot(x, f, p=(0,1,1000))

There's also this SO answer that I wrote for a related question https://stackoverflow.com/questions/65264185/slow-matplotlib-and-ipywidgets-image-refresh/65573875#65573875

gsteele13 commented 3 years ago

Thanks @ianhi! I'll definitely try it out.

I had some earlier hacks based on the notebook driver:

https://github.com/gsteele13/gary-misc-notebooks/blob/master/iPywidgets%20and%20Matplotlib%20Fast%20Interact.md

The code is not quite as transparent as my three lines of code with the inline driver (requires you to dig a bit deeper into less documented territories of mpl), but is not bad. However, notebook seems depricated, and so I recently tried the widget library. Which was OK (had big headaches actually with improper installs, but eventually got it working, but not yet fully on the cloud platform we use for teaching...).

But I was unware of the ipympl driver, I'll definitely try out your mpl_interactions library (and see if I can get in into the containers for my teaching maybe).

But say taking this out to a broader scope:

Although changing the backend to a more responsive one is of course a good fix for my matplotlib problem above (we're looking right now as well at plotly for another specific application), I could imagine that the desire for a simple workflow for debouncing interact may also be highly useful for other applications: it's a pretty generic UI interaction feature that would be highly valuable for preventing over stimulating generic backend code you want to interact with. And being able to implement it without having to build that debouncing into the higher level code your wrapping which ideally does not even know it is being embedded in a UI I think could be very useful

gsteele13 commented 3 years ago

and maybe a final little detail: when I did get the notebook driver running in a notebook on our cloud platform inside a container, it also seemed tremendously non-responsive, even though it was acceptably reponsive when running locally. I'm not sure if this is because of the specific details of the installation in our container, or because I am interacting with it remotely via a notebook running in the US from Europe...in any case, I think that if one does not have the ambition of faster than 100 ms updates, it would be good anyway from a CPU / network traffic, etc, to be able to debounce interactions you don't want

jasongrout commented 3 years ago

A couple of thoughts:

  1. Do you want throttling or debouncing? In other words, if you have a bunch of updates to a control, do you want to slow the calls to the function to be no more than a certain number per time, or do you want to wait until all of the calls are done before calling the update function once? (I think either is a valid usecase in different situations)
  2. If you are using a slider or textbox, you can set (IIRC) continuous_update to False so that updates of a control that are in-progress do not trigger changes. This happens on the js side, so messages don't even go to the python side while a control is in motion, and it only works for the slider and text controls.
  3. I like Maarten's idea to separate out the throttling/debouncing from the interact call. That would be a useful function to export from ipywidgets
  4. I wouldn't be opposed to adding an option to interact to do automatic throttling or debouncing, that would essentially wrap your function in that decorator.
maartenbreddels commented 3 years ago

I've implemented a debouce decorator in the vaex-jupyter package, example usage together with interact:

%matplotlib ipympl
import numpy as np
import matplotlib.pyplot as plt
from ipywidgets import interact, ColorPicker
import vaex.jupyter

x = np.linspace(0,1,100)
fig, ax = plt.subplots()
line, = ax.plot(x, x**.5)

@vaex.jupyter.debounced(0.2)
def update_plot(p=1, color="red", flip=False):
    y = x**p
    line.set_ydata(1-y if flip else y)
    line.set_color(color)
    fig.canvas.draw()
    fig.canvas.flush_events()

interact(update_plot, p=(0,1,0.01), color=ColorPicker(value="red"))

which should give you: image

vaex-jupyter is a bit of a heavy dependency though.

gsteele13 commented 3 years ago

Thanks @maartenbreddels, I'll definitely have a look when I get a chance! This is now some nice compact code (assuming I don't have too much trouble with vaex-jupyter)

gsteele13 commented 3 years ago

Do you want throttling or debouncing? In other words, if you have a bunch of updates to a control, do you want to slow the calls to the function to be no more than a certain number per time, or do you want to wait until all of the calls are done before calling the update function once? (I think either is a valid usecase in different situations)

Good question, I'm not 100% sure which is which, I've not really even thought about the technical definition of the terms until a few days ago :)

I think that what we would need is debounce, in this sense:

https://davidwalsh.name/javascript-debounce-function

AKA: make sure that the plot_update function is called at most once per 100 ms say, for example, regardless how many updates are registered by the slider.

If you are using a slider or textbox, you can set (IIRC) continuous_update to False so that updates of a control that are in-progress do not trigger changes. This happens on the js side, so messages don't even go to the python side while a control is in motion, and it only works for the slider and text controls.

Indeed, this works, but the interactivity is significantly reduced (equivalent to the "click" solution in my video), and so I'm looking for something a bit more

I like Maarten's idea to separate out the throttling/debouncing from the interact call. That would be a useful function to export from ipywidgets

I agree, this should work, although it requires some boilerplate / understanding from the user perspective, or perhaps the last library that @maartenbreddels mention. But in any case, both will raise the barrier for people using it, and what I like the most about ipywidgets and interact is the low barrier to use.

I wouldn't be opposed to adding an option to interact to do automatic throttling or debouncing, that would essentially wrap your function in that decorator.

I think it's a common enough feature that it would be useful to have already in ipywidgets: what I like the most myself about ipywidgets is the low barrier to simple gui programming in a notebook: with only a few lines of code, people can make already quite functional GUI interactions, particularly using interact (I like to show it already to my students in our python for physicists course!). And so for me, debounce handling in the interact function would be a natural extension of this concept of low barrier GUI interaction.

gsteele13 commented 3 years ago

Small update:

After reading ipywidgets docs page, I think I understand now better what you mean with the difference between debouncing and throttling, and from my guess, either seems fine.

I've tried copy-pasting the code over:

import numpy as np
import matplotlib.pyplot as plt
from ipywidgets import interact

import asyncio
from time import time

class Timer:
    def __init__(self, timeout, callback):
        self._timeout = timeout
        self._callback = callback

    async def _job(self):
        await asyncio.sleep(self._timeout)
        self._callback()

    def start(self):
        self._task = asyncio.ensure_future(self._job())

    def cancel(self):
        self._task.cancel()

def throttle(wait):
    """ Decorator that prevents a function from being called
        more than once every wait period. """
    def decorator(fn):
        time_of_last_call = 0
        scheduled, timer = False, None
        new_args, new_kwargs = None, None
        def throttled(*args, **kwargs):
            nonlocal new_args, new_kwargs, time_of_last_call, scheduled, timer
            def call_it():
                nonlocal new_args, new_kwargs, time_of_last_call, scheduled, timer
                time_of_last_call = time()
                fn(*new_args, **new_kwargs)
                scheduled = False
            time_since_last_call = time() - time_of_last_call
            new_args, new_kwargs = args, kwargs
            if not scheduled:
                scheduled = True
                new_wait = max(0, wait - time_since_last_call)
                timer = Timer(new_wait, call_it)
                timer.start()
        return throttled
    return decorator

x = np.linspace(0,10,100)

@throttle(0.2)
def update_plot(p=1):
    plt.plot(x, x**p)

interact(update_plot, p=(0,1,0.01))

But this results in an infinite number of plots below each other, rather than the identical lines without the throttling. Which completely baffled me...?

Why has decorating my update function with a throttle change the behaviour of the output of interact()? And how can I fix it?

(I have tried a few things, the last of which is the last code snippet from this issue, but then I get flickering, whereas with the original non-decorated interact I do not have flickering? Also baffled...)

Thanks, Gary

maartenbreddels commented 3 years ago

rather than the identical lines without the throttling. Which completely baffled me...?

My guess is that the interact wraps the call to update_plot with a context manager, that makes sure it gets output to the right output widget. However, the throttle call will be called some time later, not in the context of the context manager.

gsteele13 commented 3 years ago

OK, for anyone who is interested, I found a workaround!

The solution is to code the interactivity yourself, having the sliders / controls use a throttled plot update function. The key trick to solve the flashing was to use wait=True when clearing the output widget. Here is a minimal code segment:

import ipywidgets as widgets 
import numpy as np
import matplotlib.pyplot as plt
import asyncio
from time import time

class Timer:
    def __init__(self, timeout, callback):
        self._timeout = timeout
        self._callback = callback

    async def _job(self):
        await asyncio.sleep(self._timeout)
        self._callback()

    def start(self):
        self._task = asyncio.ensure_future(self._job())

    def cancel(self):
        self._task.cancel()

def throttle(wait):
    """ Decorator that prevents a function from being called
        more than once every wait period. """
    def decorator(fn):
        time_of_last_call = 0
        scheduled, timer = False, None
        new_args, new_kwargs = None, None
        def throttled(*args, **kwargs):
            nonlocal new_args, new_kwargs, time_of_last_call, scheduled, timer
            def call_it():
                nonlocal new_args, new_kwargs, time_of_last_call, scheduled, timer
                time_of_last_call = time()
                fn(*new_args, **new_kwargs)
                scheduled = False
            time_since_last_call = time() - time_of_last_call
            new_args, new_kwargs = args, kwargs
            if not scheduled:
                scheduled = True
                new_wait = max(0, wait - time_since_last_call)
                timer = Timer(new_wait, call_it)
                timer.start()
        return throttled
    return decorator

x = np.linspace(0,1,100)

@throttle(0.2)
def update_plot(w):
    with out:
        # Without clear_output(), figures get appended below each other inside
        # the output widget
        # Ah ha! Got it! I need wait=True!
        out.clear_output(wait=True)
        plt.plot(x, x**p_widget.value)  
        plt.show()

out = widgets.Output(layout=widgets.Layout(height='300px'))
p_widget = widgets.FloatSlider(min=0, max=2, step=0.1, value = 1)
update_plot([])
p_widget.observe(update_plot)
display(p_widget, out)

This now no longer builds up an infinite queue of matplotlib updates! The refresh is not "instant" / video rate, but that is fine since for me it is more important that there is a visual indication of the interactivity as the slider is moved, and that when it stops moving, it goes quickly to the correct parameter. And this work at least :)

gsteele13 commented 3 years ago

(This workaround aside, I still support the incorporation of throttling directly into the interact functionality, I guess it's a few lines of code)

posita commented 1 year ago

The sample decorator doesn't work for me. It basically results in funneling all output to the log (at level "info"). Try it for yourself:

Binder [source]

After opening that Binder link:

  1. Select View -> Show Log Console
  2. Set the Log Level to Info
  3. Select Run -> Restart Kernel and Run All Cells...

Screenshot from 2022-10-20 17-04-29

To "fix" the behavior, comment out the decorator, restart the kernel, and output should be displayed inline.

+1 to this being a library feature with better guidance on how/where to use it.