Dynamic parameter values

When developing your own Python code using Parameters, there are a variety of different programming models you can use:

  1. Parameters as fancy Python attributes: making use of Param’s semantic type checking, inheritance of default values and docstrings, etc., but not using any dynamic or event-handling features of Param. When Parameter values need to change, users change them explicitly, using their own Python code in separate methods and functions invoked from outside of Param.

  2. “Push” model: Using Param’s Dependencies and Watchers so that Param invokes user-written code to change Parameter values based on events that Param detects (typically chaining from changes in some other parameter values). A “push” model is typical for event-driven GUI applications, where a user interacts with a GUI widget to change some Parameter value, prompting Param to execute chained dependencies in response.

  3. “Pull” model: Using Dynamic parameter values (described here) where the value of each dynamic parameter is computed when the parameter is read, potentially computing it from some global state values. A “pull” model is typical for simulations with a global clock, making it easy to use value a1 at time 1, value a2 at times 2-100, value a3 for 100-, etc.

Each of these models has advantages and disadvantages that make them appropriate for different situations, so it’s important to understand all three models so that you can choose the right one(s) for your system. Here, we’ll discuss the third model, using Dynamic parameters.

param.Dynamic

A Dynamic parameter of type t is one that accepts either a value of type t, or a callable returning a value of type t. If a user passes a callable, the callable will be invoked to get the actual value when the parameter value is accessed. All of Param’s numeric parameter types are Dynamic because their base class param.Number inherits from param.Dynamic. New non-numeric types can be defined and made dynamic by inheriting from param.Dynamic, and having dynamic string and selector parameters would be a nice addition to the current dynamic numeric parameter support.

To see how it works, let’s make a Parameterized class with some numeric Parameters:

import param, random

class P(param.Parameterized):
    i = param.Integer(2)
    j = param.Integer(5)
    k = param.Integer(8)
    x = param.Number(-13.6)

P(i=6, x=9.8)
P(i=6, j=5, k=8, name='P00002', x=9.8)

Here we can set p.i and p.x to any supported numeric values, illustrating programming model 1. But we can also set them to dynamic values, for model 3:

p = P(i=lambda: random.randint(35,99), x=lambda: random.random())
p
P(i=<function <lambda> at 0x7f70c0684710>, j=5, k=8, name='P00003', x=<function <lambda> at 0x7f70c0684950>)
p.i, p.i, p.i
(75, 49, 99)
p.x, p.x, p.x
(0.8203563916709923, 0.8535254338221367, 0.6104302304809474)

As you can see, each time you access a parameter with a dynamic value, it computes a new value and returns it. If you want to inspect the current value without changing it, you can use a special method for that:

p.param.inspect_value('x'), p.param.inspect_value('x'), p.param.inspect_value('x')
(0.6104302304809474, 0.6104302304809474, 0.6104302304809474)

Of course, dynamic parameters don’t have to be random; e.g. you can set a parameter to a counter:

class Count:
    def __init__(self, start=0):
        self._count=start
    
    def __call__(self):
        self._count += 1
        return self._count
    
c = Count()

p.j = c
p.j, p.j, p.j
(1, 2, 3)

Using a gobal time variable

As you can see, dynamic parameters are very dynamic by default, changing every single time they are accessed. What if you want a “somewhat dynamic” value that changes only in certain well-defined situations? For instance, what if you are running a simulation, and you want a new dynamic value whenever time t changes, but otherwise the value should be constant so that no matter how many times the parameter is read at that time t, the result is the same? Or you are running a training or annealing or sampling or similar process that has many different iterations or runs, and you want values to change only when the iteration or run number changes, and otherwise to have the same value for a given iteration or run?

To support simulations and other applications controlled by a central counter or state value like this, Dynamic supports a time_dependent mode where new values will be generated only if param.Dynamic.time_fn has changed in value since a number was last generated:

param.Dynamic.time_dependent = True

p.i, p.i, p.i
(98, 98, 98)

time_fn is a callable object that will return the current value if called:

param.Dynamic.time_fn()
0

time_fn can be incremented using += or changed to a specific value by calling with that value:

param.Dynamic.time_fn +=1
p.i, p.i, p.i
(67, 67, 67)
param.Dynamic.time_fn +=10
p.i, p.i, p.i
(86, 86, 86)
param.Dynamic.time_fn(6)
param.Dynamic.time_fn()
6
p.i, p.i, p.i
(40, 40, 40)

The global time_fn provides a convenient way to compute values that are fixed functions of the time value:

p.k = lambda: 100+param.Dynamic.time_fn()**2
p.k
136
param.Dynamic.time_fn +=10
p.k
356
# Reset to the default, to support out of order execution of this notebook
param.Dynamic.time_dependent = False 

See help(param.Time) for detailed information about using the time_fn, including:

  • how to use time_fn as a context manager to test results at different times without disrupting the current time

  • how and why to use time types other than the integer default

  • how to set an upper limit on the time to bound a simulation run

  • how to declare the time units and time label

The time_fn is not required to be of type param.Time, but a lot of the features here do depend on that particular model of time.

If there are any Parameterized objects that should not respect the global time value or should respect a different time value, you can call obj.param.set_dynamic_time_fn() to override the time on those objects and any of their subobjects.

You can see topographica for an example of a complex simulator built on Param’s time support, including a general-purpose event-driven simulation engine capable of simulating any phenomena that can be simulated by updating at discrete times (whether on a fixed global timebase or not).

#help(param.Time)

Numbergen

As you can see above, you can pass any callable object to a Dynamic parameter, including unnamed functions (lambdas), named functions, and custom objects with a __call__ method. However, each of those approaches has limitations:

  • lambdas cannot easily be pickled for saving and restoring state (though see cloudpickle for an alternative to pickle that does support lambdas)

  • named functions don’t support internal state and need to be stored in a named module somewhere for them to be picklable, potentially resulting in a large number of one-off functions to keep track of

  • making a new object with a __call__ method is verbose and error-prone, and again needs to be stored in a formal module if it is to be picklable.

To make using Dynamic parameters more convenient, Param includes a separate module Numbergen that provides ready-to-use, picklable, composable, and interchangeable callable objects producing numeric values. Numbergen relies only on Param and the Python standard library, so it should be easy to add to any project.

Numbergen objects are designed to work seamlessly as Dynamic parameter values, providing easy access to various temporal distributions, along with tools for combining and configuring number generators without having to write custom functions or classes. Moreover, because all of these objects are Parameterized objects sharing the same usage interface (each provides a numeric value when called, regardless of how many or which parameters are required to configure that distribution), using them together with Param’s Dynamic support provides a huge amount of power over the values parameters take over time, without requiring any extra complexity in your program. Without Dynamic support and numbergen, your Parameterized classes could of course provide their own support for e.g. a normal random distribution by accepting a mean and variance, but it would then be limited to that specific random distribution, whereas Dynamic parameters can accept any current or future number generator object as configured by a user for their own purposes, neatly separating your Parameterized’s requirements (“a positive integer value”) from the user’s requirements (“let’s see what happens when the value starts at 1 and doubles every iteration”).

Numbergen objects all inherit from ng.NumberGenerator, which defines the callable interface and adds operator support as described below. Each type of object then further inherits from either TimeAware (having basic time support) or TimeDependent (TimeAware objects having values that are a strict function of time).

TimeDependent number generators

If you have a global clock, TimeDependent number generators are easy to reason about: their value is a strict function of the time value returned by their time_fn parameter. If the generator has a value v at time t, then if time is advanced by 10 units to t+10, rolled back 5 units to t+5, and rolled back 5 more units to t, the value of the generator will again be v. These generators typically calculate their values directly from the time_fn value. TimeDependent objects provide a time_dependent parameter that is always True; the only mode they support is to be dependent on the global time. TimeDependent number generators include:

  • ng.ScaledTime(factor=1.0): Simple multiplicative function of the global time value.

  • ng.ExponentialDecay(starting_value=1.0, ending_value=0.0, time_constant=10000, base=e): Returns starting_value*base^(-time_fn()/time_constant).

  • ng.BoxCar(onset=0.0, duration=None): 1.0 in the exclusive interval (onset, onset+duration); zero at all other times. Default is a step function with no offset.

  • ng.SquareWave(onset=0.0, duration=1.0, off_duration=None): Alternating between 1.0 and 0.0 starting at the onset with a frequency (duration+off_duration) with a duty cycle determined by duration:off_duration (50% by default; off_duration defaults to the initial on duration).

To demonstrate these objects, let’s write a helper function that samples the distribution at different time values:

import numbergen as ng
import pandas as pd

param.Dynamic.time_dependent = True
pd.options.display.precision=3

def timesample(ng, ts=range(0,10), time_fn = param.Dynamic.time_fn):
    ss = []
    for t in ts:
        time_fn(t)
        s = ng()
        ss += [(t,s)]
    df = pd.DataFrame(ss, columns=['t','s']).T
    return df.style.set_caption(ng.param.pprint(unknown_value=None))

Now we can see how each of these objects behaves as t changes:

timesample(ng.ScaledTime(factor=2.0))
ScaledTime(factor=2.0)
  0 1 2 3 4 5 6 7 8 9
t 0.000 1.000 2.000 3.000 4.000 5.000 6.000 7.000 8.000 9.000
s 0.000 2.000 4.000 6.000 8.000 10.000 12.000 14.000 16.000 18.000
timesample(ng.ExponentialDecay(time_constant=2, base=4))
ExponentialDecay(base=4, time_constant=2)
  0 1 2 3 4 5 6 7 8 9
t 0.000 1.000 2.000 3.000 4.000 5.000 6.000 7.000 8.000 9.000
s 1.000 0.500 0.250 0.125 0.062 0.031 0.016 0.008 0.004 0.002
timesample(ng.BoxCar(onset=0.0, duration=3))
BoxCar(duration=3)
  0 1 2 3 4 5 6 7 8 9
t 0.000 1.000 2.000 3.000 4.000 5.000 6.000 7.000 8.000 9.000
s 0.000 1.000 1.000 1.000 0.000 0.000 0.000 0.000 0.000 0.000
timesample(ng.SquareWave(onset=0.0, duration=1.0, off_duration=2.0))
SquareWave(off_duration=2.0)
  0 1 2 3 4 5 6 7 8 9
t 0.000 1.000 2.000 3.000 4.000 5.000 6.000 7.000 8.000 9.000
s 1.000 0.000 0.000 1.000 0.000 0.000 1.000 0.000 0.000 1.000

TimeAware random number generators

A TimeAware object also has access to a time_fn and has a time_dependent parameter, but either sets time_dependent=False (indicating that values are never a strict function of time) or allows either True or False (switching into and out of a time dependent mode). All current TimeAware NumberGenerator objects are random number generators that support both possible values of time_dependent. For time_dependent=False (the default), they return a new value on each call, while for time_dependent=True, they return pseudorandom values that follow the indicated distribution but are also a strict function of the time, in that the same number will be returned for a given time value even if time skips ahead or backwards.

These random values are thus very tightly controlled to allow reproducible, repeatable results, with values determined by both a seed value (to choose the overall set of random values) and by the current time. Effectively, when time_dependent=True, these numbers provide a random value seeded by the generator’s name parameter, the global param.random_seed, the seed parameter of the NumberGenerator, and the NumberGenerator’s current time_fn() value. The resulting generated values should be the same for a given object and a given time_fn value, even across platforms and machine-word sizes (see the Hash, TimeAwareRandomState, and RandomDistribution classes for more details).

For best results, you should provide an explicit unique name to any such generator and preserve that name over time, so that results will be reproducible across program runs. By default, the underlying random numbers are generated using Python’s random module (which see for details of the number generation), but you can substitute an instance of numpy.random.RandomState or similar compatible object for self.random_generator for higher performance or to generate time-dependent array values.

RandomDistributions (all TimeAware and supporting time_dependent) include:

  • ng.UniformRandom(lbound=0.0, ubound=1.0): Uniform random float in the range [lbound, ubound).

  • ng.UniformRandomOffset(mean=0, range=1.0): Same as UniformRandom, but returns a random float in the range [mean - range/2, mean + range/2).

  • ng.UniformRandomInt(lbound=0, ubound=1000): Uniform random integer in the (inclusive) range [lbound, ubound].

  • ng.Choice(choices=[0,1]): Random value from a provided list of choices.

  • ng.NormalRandom(mu=0.0, sigma=1.0): Normally distributed (Gaussian) random number with mean mu and standard deviation sigma.

  • ng.VonMisesRandom(mu=0,kappa=1): Circularly normal distributed random number centered around mu with inverse variance kappa; for kappa=0 the result is uniformly distributed from 0 to 2*pi, and for narrow kappa it approaches the normal distribution with variance 1/kappa.

timesample(ng.UniformRandom(lbound=0.0, ubound=10.0))
UniformRandom(ubound=10.0)
  0 1 2 3 4 5 6 7 8 9
t 0.000 1.000 2.000 3.000 4.000 5.000 6.000 7.000 8.000 9.000
s 6.314 7.954 0.460 5.018 1.834 7.075 1.994 6.264 1.550 1.511
timesample(ng.UniformRandomOffset(mean=100, range=3))
UniformRandomOffset(mean=100, range=3)
  0 1 2 3 4 5 6 7 8 9
t 0.000 1.000 2.000 3.000 4.000 5.000 6.000 7.000 8.000 9.000
s 100.545 101.471 98.627 99.979 99.926 100.699 100.194 98.701 101.023 99.169
timesample(ng.UniformRandomInt(lbound=0, ubound=1000))
UniformRandomInt()
  0 1 2 3 4 5 6 7 8 9
t 0 1 2 3 4 5 6 7 8 9
s 925 502 260 381 488 69 931 477 404 78
timesample(ng.Choice(choices=[3.1, -95, 7]))
Choice(choices=[3.1,-95,7])
  0 1 2 3 4 5 6 7 8 9
t 0.000 1.000 2.000 3.000 4.000 5.000 6.000 7.000 8.000 9.000
s 7.000 7.000 7.000 3.100 -95.000 7.000 7.000 3.100 -95.000 3.100
timesample(ng.NormalRandom(mu=50.0, sigma=5.0))
NormalRandom(mu=50.0, sigma=5.0)
  0 1 2 3 4 5 6 7 8 9
t 0.000 1.000 2.000 3.000 4.000 5.000 6.000 7.000 8.000 9.000
s 55.574 51.586 49.092 49.942 44.840 53.835 44.367 45.696 53.185 44.992
timesample(ng.VonMisesRandom(mu=0, kappa=500)) # small variance around 0 (aka 2pi)
VonMisesRandom(kappa=500)
  0 1 2 3 4 5 6 7 8 9
t 0.000 1.000 2.000 3.000 4.000 5.000 6.000 7.000 8.000 9.000
s 0.024 6.279 0.065 6.244 0.047 0.043 0.023 6.242 0.025 6.265

Operations on number generators

Numbergen also provides a couple of NumberGenerators that accept other NumberGenerator objects and filter or modify their values:

  • ng.TimeSampledFn(period=1.0, offset=1.0, fn=None): Discretizes the given time-dependent NumberGenerator to give discrete values held constant over the given period, changing a continuous function of time into a series of discrete steps starting at the indicated offset and changing at the indicated period.

  • ng.BoundedNumber(generator=None, bounds=(None,None)): Wrapper around another number generator (any callable returning a number) that silently crops the result to the given bounds.

It also provides a set of unary (- + abs()) and binary (+ - * % ** / //) mathematical operators that make it simple to adapt the output for usage in practice without having to define one-off functions. For instance:

timesample(-abs(ng.ScaledTime(factor=2.0)+1)//4)
-(abs(ScaledTime(factor=2.0)+1))//4
  0 1 2 3 4 5 6 7 8 9
t 0.000 1.000 2.000 3.000 4.000 5.000 6.000 7.000 8.000 9.000
s -1.000 -1.000 -2.000 -2.000 -3.000 -3.000 -4.000 -4.000 -5.000 -5.000
timesample(ng.UniformRandom()%0.2)
UniformRandom()%0.2
  0 1 2 3 4 5 6 7 8 9
t 0.000 1.000 2.000 3.000 4.000 5.000 6.000 7.000 8.000 9.000
s 0.149 0.085 0.138 0.022 0.008 0.170 0.128 0.066 0.103 0.141
timesample(2*ng.SquareWave()-1)
2*SquareWave(off_duration=1.0)-1
  0 1 2 3 4 5 6 7 8 9
t 0.000 1.000 2.000 3.000 4.000 5.000 6.000 7.000 8.000 9.000
s 1.000 -1.000 1.000 -1.000 1.000 -1.000 1.000 -1.000 1.000 -1.000

Using numbergen objects for parameter values

Any of the above objects can be supplied for any param.Number Parameter type. For instance, instead of the lambdas in the first examples in this guide, you can use Numbergen objects:

param.Dynamic.time_dependent = False

p = P(i=ng.UniformRandomInt(lbound=35, ubound=99), 
      x=ng.UniformRandom())

p.i, p.i, p.i
(65, 97, 59)
p.x, p.x, p.x
(0.2674913726330872, 0.03850663551325728, 0.03357346129181993)

Notice that the decision to use a particular distribution is up to the user of class P, not the author of P. The author of P just needs to know that i will be an integer and that x will be a float (with bounds if specified); the user is then free to set those values to be static or any type of dynamic value as needed. Using Param with this “pull” model thus provides users with easy ways to control how parameters change their value over time (for some model of time), without additional work by the Parameterized class author. You can see extensive examples of this approach at the imagen website, which shows how Numbergen objects can be used to create flexible streams of generated image objects without needing any special support for such streams in the Parameterized objects in that library.