Have 2 Ipywidgets acting on one matplotlib plot in

2019-08-11 04:42发布

问题:

The following code simulates a machine learning, linear regression process.

It is meant to allow the user to do the regression manually and visually in a Jupyter notebook to get a better feel for the linear regression process.

The first section (x,y) of the function generates a plot to perform the regression on.

The next section (a,b) generates the line to play with, for the simulated regression.

I want to be able to change the slope slider without the scatter plot being regenerated.

Any guidance will be very helpful and welcome. :-)

import numpy as np
import ipywidgets as widgets
from ipywidgets import interactive
import matplotlib.pyplot as plt    

def scatterplt(rand=3, num_points=20, slope=1):

    x = np.linspace(3, 9, num_points)
    y = np.linspace(3, 9, num_points)

    #add randomness to scatter
    pcent_rand = rand
    pcent_decimal = pcent_rand/100
    x = [n*np.random.uniform(low=1-pcent_decimal, high=1+ pcent_decimal) for n in x]
    y = [n*np.random.uniform(low=1-pcent_decimal, high=1+ pcent_decimal) for n in y]

    #plot regression line
    a = np.linspace(0, 9, num_points)
    b = [(slope * n) for n in a]

    #format & plot the figure
    plt.figure(figsize=(9, 9), dpi=80)
    plt.ylim(ymax=max(x)+1)
    plt.xlim(xmax=max(x)+1)

    plt.scatter(x, y)

    plt.plot(a, b)

    plt.show()


#WIDGETS    

interactive_plot = interactive(scatterplt, 
                 rand = widgets.FloatSlider(
                                value=3,
                                min=0,
                                max=50,
                                step=3,
                                description='Randomness:', num_points=(10, 50, 5)
                                ),
                 num_points = widgets.IntSlider(
                                value=20,
                                min=10,
                                max=50,
                                step=5,
                                description='Number of points:'
                                ),
                 slope=widgets.FloatSlider(
                                value=1,
                                min=-1,
                                max=5,
                                step=0.1,
                                description='Slope'
                                )

                )

interactive_plot

回答1:

The interactive function does not really give you access to this level of granularity. It always runs the entire scatterplt callback. Basically, the point of interactive is to make a class of problems really easy -- once you move out of that class of problems, it's not really applicable.

You then have to fall back to the rest of the widget machinery. This can be a bit hard to understand initially, so, to minimize the jump, I'll start by explaining what interactive does under the hood.

When you call interactive(func, widget), it creates widget and binds a callback to whenever that widget changes. The callback runs func in an Output widget (docs). The Output widget captures the entire output of func. interactive then packs widget and the output widget into a VBox (a container for stacking widgets).

Back to what you want to do now. Your application has the following criteria:

  1. we need to maintain some form of internal state: the application needs to remember the x and y locations of the random variates
  2. we need different behaviour to run based on what slider was triggered.

To satisfy (1), we should probably create a class to maintain the state. To satisfy (2), we need different callbacks to run based on what slider was called.

Something like this seems to do what you need:

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

class LinRegressDisplay:

    def __init__(self, rand=3.0, num_points=20, slope=1.0):
        self.rand = rand
        self.num_points = num_points
        self.slope = slope
        self.output_widget = widgets.Output()  # will contain the plot
        self.container = widgets.VBox()  # Contains the whole app
        self.redraw_whole_plot()
        self.draw_app()

    def draw_app(self):
        """
        Draw the sliders and the output widget

        This just runs once at app startup.
        """
        self.num_points_slider = widgets.IntSlider(
            value=self.num_points,
            min=10,
            max=50,
            step=5,
            description='Number of points:'
        )
        self.num_points_slider.observe(self._on_num_points_change, ['value'])
        self.slope_slider = widgets.FloatSlider(
            value=self.slope,
            min=-1,
            max=5,
            step=0.1,
            description='Slope:'
        )
        self.slope_slider.observe(self._on_slope_change, ['value'])
        self.rand_slider = widgets.FloatSlider(
            value=self.rand,
            min=0,
            max=50,
            step=3,
            description='Randomness:', num_points=(10, 50, 5)
        )
        self.rand_slider.observe(self._on_rand_change, ['value'])
        self.container.children = [
            self.num_points_slider,
            self.slope_slider,
            self.rand_slider ,
            self.output_widget
        ]

    def _on_num_points_change(self, _):
        """
        Called whenever the number of points slider changes.

        Updates the internal state, recomputes the random x and y and redraws the plot.
        """
        self.num_points = self.num_points_slider.value
        self.redraw_whole_plot()

    def _on_slope_change(self, _):
        """
        Called whenever the slope slider changes.

        Updates the internal state, recomputes the slope and redraws the plot.
        """
        self.slope = self.slope_slider.value
        self.redraw_slope()

    def _on_rand_change(self, _):
        self.rand = self.rand_slider.value
        self.redraw_whole_plot()

    def redraw_whole_plot(self):
        """
        Recompute x and y random variates and redraw whole plot

        Called whenever the number of points or the randomness changes.
        """
        pcent_rand = self.rand
        pcent_decimal = pcent_rand/100
        self.x = [
            n*np.random.uniform(low=1-pcent_decimal, high=1+pcent_decimal) 
            for n in np.linspace(3, 9, self.num_points)
        ]
        self.y = [
            n*np.random.uniform(low=1-pcent_decimal, high=1+pcent_decimal)
            for n in np.linspace(3, 9, self.num_points)
        ]
        self.redraw_slope()

    def redraw_slope(self):
        """
        Recompute slope line and redraw whole plot

        Called whenever the slope changes.
        """
        a = np.linspace(0, 9, self.num_points)
        b = [(self.slope * n) for n in a]

        self.output_widget.clear_output(wait=True)
        with self.output_widget as f:
            plt.figure(figsize=(9, 9), dpi=80)
            plt.ylim(ymax=max(self.y)+1)
            plt.xlim(xmax=max(self.x)+1)

            plt.scatter(self.x, self.y)
            plt.plot(a, b)
            plt.show()

app = LinRegressDisplay()
app.container  # actually display the widget

As a final note, the animation remains a bit jarring when you move the sliders. For better interactivity, I suggest looking at bqplot. In particular, Chakri Cherukuri has a great example of linear regression that is somewhat similar to what you are trying to do.



回答2:

Instead of using interactive/interact you can also use interact_manual (see the docs for more info). What you get is a button that lets you manually run the function once you are happy.

You need these two lines

from ipywidgets import interactive, interact_manual
interactive_plot = interact_manual(scatterplt,
...

The first time you run it, you should see this:

After you click the button, it will show you the full output:



回答3:

Part of the problem is that it is difficult to modify individual elements in a Matplotlib figure i.e. it is much easier to redraw the whole plot from scratch. Redrawing the whole figure will not be super quick or smooth. So instead, I am showing you an example of how to do it in BQplot (as suggested by Pascal Bugnion). Its not Matplotlib as I guess you probably wanted but it does demonstrate a method of separating the slope and randomness instructions and calculations from each individual slider whilst still using the standard interactive widgets.

import bqplot as bq
import numpy as np
import ipywidgets as widgets


def calcSlope(num_points, slope):
    a = np.linspace(0, 9, num_points)
    b = a * slope

    line1.x = a
    line1.y = b


def calcXY(num_points, randNum):
    x = np.linspace(3, 9, num_points)
    y = x

    #add randomness to scatter
    x = np.random.uniform(low=1-randNum/100, high=1+ randNum/100, size=(len(x))) * x
    y = np.random.uniform(low=1-randNum/100, high=1+ randNum/100, size=(len(y))) * y

    #format & plot the figure
    x_sc.min = x.min()
    x_sc.max = x.max() + 1

    scat.x = x
    scat.y = y        



def rand_int(rand):
    calcXY(num_i.children[0].value, rand)

def num_points_int(num_points):
    calcXY(num_points, rand_i.children[0].value)
    calcSlope(num_points, slope_i.children[0].value)

def slope_int(slope):
    calcSlope(num_i.children[0].value, slope)



rand_i = widgets.interactive(rand_int, 
                 rand = widgets.FloatSlider(
                                value=3,
                                min=0,
                                max=50,
                                step=3,
                                description='Randomness:', num_points=(10, 50, 5)
                                )
                              )


num_i = widgets.interactive(num_points_int, 
                 num_points = widgets.IntSlider(
                                value=20,
                                min=10,
                                max=50,
                                step=5,
                                description='Number of points:'
                                )
                              )


slope_i = widgets.interactive(slope_int, 
                 slope=widgets.FloatSlider(
                                value=1,
                                min=-1,
                                max=5,
                                step=0.1,
                                description='Slope'
                                )
                              )


# Create the initial bqplot figure
x_sc = bq.LinearScale()
ax_x = bq.Axis(label='X', scale=x_sc, grid_lines='solid', tick_format='0f')
ax_y = bq.Axis(label='Y', scale=x_sc, orientation='vertical', tick_format='0.2f')

line1 = bq.Lines( scales={'x': x_sc, 'y': x_sc} , colors=['blue'],display_legend = False, labels=['y1'],stroke_width = 1.0)
scat = bq.Scatter(scales={'x': x_sc, 'y': x_sc} , colors=['red'],display_legend = False, labels=['y1'],stroke_width = 1.0)


calcSlope(num_i.children[0].value, slope_i.children[0].value)
calcXY(num_i.children[0].value, rand_i.children[0].value)

m_fig = dict(left=100, top=50, bottom=50, right=100)
fig = bq.Figure(axes=[ax_x, ax_y], marks=[line1,scat], fig_margin=m_fig, animation_duration = 1000)

widgets.VBox([rand_i,num_i,slope_i,fig])