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 timea
is altered. The simplest way is probably to include it in thepn.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
frompn.Row
but then have to add thewatch=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 ofa
.
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 tocache_size
values ofa
). - 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:
- Dynamic parameters donāt trigger watch events: Interaction between dynamic parameters and watching Ā· Issue #318 Ā· holoviz/param Ā· GitHub. I could get around this by setting the view methods to depend on all the parameters that the dynamic
data
parameter varies with, but itās tedious, easy to get wrong, and ties me back into a particular implementation of the callable that supplies the data. - It doesnāt seem to be possible to make an individual Dynamic parameter time-dependent: Can I make an individual Dynamic parameter time-dependent? This would cause difficulties if Iām using a Dynamic parameter I want to not be time-dependent, like a random number generator.
- A workaround to the above would have been to set global time dependence and then assign different time functions to increment on a separate basis. Unfortunately, set_dynamic_time_fn() does not set dynamic time_fn Ā· Issue #668 Ā· holoviz/param Ā· GitHub and Iām not sure how to work around that.
Related threads: