How to avoid calling an expensive computation multiple times for different views on the same data

Suppose I have an expensive computation that generates data I then want to view. Naively, I might think to delay the computation until I want to generate the view:

import param
import panel as pn
pn.extension()

class Model(param.Parameterized):
    a = param.Number(1)

    @param.depends("a")
    def expensive_computation(self, a=None):
        print("expensive!")
        a = self.a if a is None else a
        return a * 2, a**2

class Viewer(param.Parameterized):
    m = param.ClassSelector(class_=Model, default=Model())

    @param.depends("m.expensive_computation")
    def view(self):
        result1, result2 = self.m.expensive_computation()
        return str(result1), str(result2)

v = Viewer()
pn.Row(pn.Param(v.param, expand=True), v.view)

The catch is that there are multiple ways I might want to view the result of the expensive computation - for example, in this case there are two outputs and I might want to view them separately. My initial approach was to try to index into the .view() method, which failed for obvious reasons.

The only option I can think of is to write new methods that provide the views I want, like this:

class ExpensiveViewer(Viewer):
    @param.depends("m.expensive_computation")
    def view1(self):
        result1, _ = self.m.expensive_computation()
        return str(result1)

    @param.depends("m.expensive_computation")
    def view2(self):
        _, result2 = self.m.expensive_computation()
        return str(result2)

ev = ExpensiveViewer()
pn.Row(pn.Param(ev.param, expand=True), ev.view1, ev.view2)

Great, now I have three ways I can view the output of my computation. However, every time I change the value of a, Model.expensive_computation() gets called once per active view - in the above panel, ā€œexpensive!ā€ gets printed twice per widget click.

I have thought of 2.5 ways to get around this, but right now they all feel like hacks and have their own downsides. This must be a common scenario people who use the Param/Panel framework face, so whatā€™s your solution?

Iā€™ve put my attempts at it below for critique.

Push-based with data parameter
class SmartModel(Model):
    data = param.Parameter(precedence=-1)

    @param.depends("expensive_computation")
    def record_data(self):
        self.data = self.expensive_computation()

class SmartViewer(Viewer):
    m = param.ClassSelector(class_=SmartModel, default=SmartModel())

    @param.depends("m.data")
    def view1(self):
        return str(self.m.data[0])

    @param.depends("m.data")
    def view2(self):
        return str(self.m.data[1])

sv = SmartViewer()
pn.Row(pn.Param(sv.param, expand=True), sv.m.record_data, sv.view1, sv.view2)

The idea is that the output of the expensive computation gets saved to a parameter, and then the view methods can work with that rather than calling the expensive computation directly.

Upsides:

  • The expensive computation is only called once when a is altered, no matter how many times the data parameter is used.
  • Arguably, it is better that the view methods are written to use a parameter of the class rather than being tied to the output of a particular function. Now they can be used with any data that is indexable.

Downsides:

  • Need to set it up so that SmartModel.record_data() is called each time a is altered. The simplest way is probably to include it in the pn.Row() call like above, although that does create an empty pane and seems like a hack. If I have a lot of different data outputs that need recording, the thickness of the empty panes will add up.
  • You can omit .record_data from pn.Row but then have to add the watch=True, on_init=True options to @param.depends() in the class definition. This is not ideal, because now if the class is used in another context itā€™s potentially doing the expensive computation a lot of times unnecessarily - for example, if I want to view the output of an entirely different function of a.

Maybe thereā€™s some straightforward way to turn on those options after class definition?

Cache the expensive computation
from functools import lru_cache

class CachedModel(Model):
    cache_size = param.Integer(default=None, constant=True, precedence=-1)

    def __init__(self, **params):
        super().__init__(**params)
        self.cached_computation = lru_cache(maxsize=self.cache_size, typed=False)(
            super().expensive_computation
        )

    @param.depends("a")
    def expensive_computation(self, a=None):
        a = self.a if a is None else a
        return self.cached_computation(a=a)

cm = CachedModel(cache_size=None)
ev2 = ExpensiveViewer(m=cm)
pn.Row(pn.Param(ev2.param, expand=True), ev2.view1, ev2.view2)

We cache the output of the function call, so that calling it again with the same set of parameters is cheap.

Upsides:

  • The expensive computation is only called once for each value of a (up to cache_size values of a).
  • We only do the computation when we need the output, not every time a parameter is changed.
  • Drop-in replacement for the uncached model.

Downsides:

  • Have to rewrite all the wrapped functions to take all the parameters they depend on as optional arguments - and these parameters must be hashable. No lists, dicts, or numpy arrays for you!
Hypothetical pull-based solution with Dynamic parameters

It seems like there should be a way to combine the above two solutions with param.Dynamic. If the data parameter was dynamic, then I wouldnā€™t need to have a watcher update it when the parameters change, it would just return different outputs when called in different parameter states. To minimise the number of computations, I could set it to be time dependent and set a watcher to increment the time when the parameters are changed, which is cheap. The view methods could be written to use any data that is the right shape, and the parameters wouldnā€™t have to be hashable.

Iā€™ve found this difficult to implement for these reasons:

Related threads:

What about this?

import param
import panel as pn
pn.extension()

class Model(param.Parameterized):
    a = param.Number(1)
    result = param.NumericTuple(length = 2)

    @param.depends("a", watch = True)
    def expensive_computation(self, a=None):
        print("expensive!")
        a = self.a if a is None else a
        self.result = (a * 2, a**2)

class Viewer(param.Parameterized):
    m = param.ClassSelector(class_=Model, default=Model())

    @param.depends("m.result")
    def view(self):
        result1, result2 = self.m.result
        return str(result1), str(result2)


class ExpensiveViewer(Viewer):
    @param.depends("m.result")
    def view1(self):
        result1, _ = self.m.result
        return str(result1)

    @param.depends("m.result")
    def view2(self):
        _, result2 = self.m.result
        return str(result2)

ev = ExpensiveViewer()
pn.Row(pn.Param(ev.param, expand=True), ev.view1, ev.view2)

Thanks for the suggestion! I think your solution is similar to my first attempt (ā€˜Push-based with data parameterā€™), but with watch=True turned on so we donā€™t need the hack of supplying Model.expensive_computation to pn.Row().

It has the downside of being difficult to scale to a model that has more parameters and computations. If I have multiple functions of a and turn watch=True on for all of them, then they all get computed every time even if I only want to view the result of one of the computations. (This is actually the problem I am having in my use case - there are a lot of things I might like to compute from a given parameter set, but usually only a subset I want to view in a given session.)

A solution to this might be to use the lower-level watchers interface documented here to explicitly set watchers on the functions I care about in a session, although it looks like thereā€™s no shortcut for reusing the parameter dependencies Iā€™ve already declared in the class definition. Or maybe I can delve into the Panel code to figure out how it sets a watcher for a method given only its Parameterized annotations.

Please note Panel provides built in caching via

  • pn.state.cache. A simply dictionary you can use for caching across user sessions.
  • pn.cache. A function that you can use to annotate your functions. It requires your function arguments are hashable.
import param
import panel as pn
import time

pn.extension()

@pn.cache
def expensive_calculation(value):
    time.sleep(1)
    return 2*value

class Model(param.Parameterized):
    data = param.Parameter(1)

    def expensive_update(self, value):
        self.data = expensive_calculation(value)
        

class View1(pn.viewable.Viewer):
    model = param.ClassSelector(class_=Model, default=Model())

    def __panel__(self):
        return self.view

    @param.depends("model.data")
    def view(self):
        return str(self.model.data)

model = Model()

def update():
    model.expensive_update(model.data)

pn.state.add_periodic_callback(update, period=1000, count=100)

view1 = View1(model=model)
view2 = View1(model=model)

pn.Column(
    view1, view2
).servable()
1 Like