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
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) @param.depends("m.data") def view2(self): return str(self.m.data) 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.
- The expensive computation is only called once when
ais 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.
- Need to set it up so that
SmartModel.record_data()is called each time
ais 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
pn.Rowbut then have to add the
watch=True, on_init=Trueoptions 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
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.
- The expensive computation is only called once for each value of
- We only do the computation when we need the output, not every time a parameter is changed.
- Drop-in replacement for the uncached model.
- 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
dataparameter 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.