{ "cells": [ { "cell_type": "markdown", "id": "ac236928", "metadata": {}, "source": [ "# Dynamic parameter values\n", "\n", "When developing your own Python code using Parameters, there are a variety of different programming models you can use:\n", "\n", "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.\n", "2. **\"Push\" model**: Using Param's [Dependencies and Watchers](Dependencies_and_Watchers.ipynb) 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) and using [Generators](Generators.ipynb) you can periodically emit new data to generate new events. 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.\n", "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.\n", "\n", "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.\n", "\n", "## `param.Dynamic`\n", "\n", "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. \n", "\n", "To see how it works, let's make a Parameterized class with some numeric Parameters:" ] }, { "cell_type": "code", "execution_count": null, "id": "f0cd871c", "metadata": {}, "outputs": [], "source": [ "import param, random\n", "\n", "class P(param.Parameterized):\n", " i = param.Integer(2)\n", " j = param.Integer(5)\n", " k = param.Integer(8)\n", " x = param.Number(-13.6)\n", "\n", "P(i=6, x=9.8)" ] }, { "cell_type": "markdown", "id": "887024df", "metadata": {}, "source": [ "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:" ] }, { "cell_type": "code", "execution_count": null, "id": "f38b30ff", "metadata": {}, "outputs": [], "source": [ "p = P(i=lambda: random.randint(35,99), x=lambda: random.random())\n", "p" ] }, { "cell_type": "code", "execution_count": null, "id": "4f9ce80a", "metadata": {}, "outputs": [], "source": [ "p.i, p.i, p.i" ] }, { "cell_type": "code", "execution_count": null, "id": "a0c30cd6", "metadata": {}, "outputs": [], "source": [ "p.x, p.x, p.x" ] }, { "cell_type": "markdown", "id": "30a6251d", "metadata": {}, "source": [ "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:" ] }, { "cell_type": "code", "execution_count": null, "id": "527abc7e", "metadata": {}, "outputs": [], "source": [ "p.param.inspect_value('x'), p.param.inspect_value('x'), p.param.inspect_value('x')" ] }, { "cell_type": "markdown", "id": "5bcc0bd9", "metadata": {}, "source": [ "Of course, dynamic parameters don't have to be random; e.g. you can set a parameter to a counter:" ] }, { "cell_type": "code", "execution_count": null, "id": "79483f39", "metadata": {}, "outputs": [], "source": [ "class Count:\n", " def __init__(self, start=0):\n", " self._count=start\n", " \n", " def __call__(self):\n", " self._count += 1\n", " return self._count\n", " \n", "c = Count()\n", "\n", "p.j = c\n", "p.j, p.j, p.j" ] }, { "cell_type": "markdown", "id": "7970ed77", "metadata": {}, "source": [ "## Using a global time variable\n", "\n", "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? \n", "\n", "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:" ] }, { "cell_type": "code", "execution_count": null, "id": "beaf7ffe", "metadata": {}, "outputs": [], "source": [ "param.Dynamic.time_dependent = True\n", "\n", "p.i, p.i, p.i" ] }, { "cell_type": "markdown", "id": "9717bb5f", "metadata": {}, "source": [ "`time_fn` is a callable object that will return the current value if called:" ] }, { "cell_type": "code", "execution_count": null, "id": "0f5218a3", "metadata": {}, "outputs": [], "source": [ "param.Dynamic.time_fn()" ] }, { "cell_type": "markdown", "id": "a2b3b838", "metadata": {}, "source": [ "`time_fn` can be incremented using `+=` or changed to a specific value by calling with that value:" ] }, { "cell_type": "code", "execution_count": null, "id": "3cd1ccce", "metadata": {}, "outputs": [], "source": [ "param.Dynamic.time_fn +=1\n", "p.i, p.i, p.i" ] }, { "cell_type": "code", "execution_count": null, "id": "1f6177fd", "metadata": {}, "outputs": [], "source": [ "param.Dynamic.time_fn +=10\n", "p.i, p.i, p.i" ] }, { "cell_type": "code", "execution_count": null, "id": "517c199e", "metadata": {}, "outputs": [], "source": [ "param.Dynamic.time_fn(6)\n", "param.Dynamic.time_fn()" ] }, { "cell_type": "code", "execution_count": null, "id": "31fa34f2", "metadata": {}, "outputs": [], "source": [ "p.i, p.i, p.i" ] }, { "cell_type": "markdown", "id": "c17e855e", "metadata": {}, "source": [ "The global `time_fn` provides a convenient way to compute values that are fixed functions of the time value:" ] }, { "cell_type": "code", "execution_count": null, "id": "3d264393", "metadata": {}, "outputs": [], "source": [ "p.k = lambda: 100+param.Dynamic.time_fn()**2\n", "p.k" ] }, { "cell_type": "code", "execution_count": null, "id": "aa7d9fec", "metadata": {}, "outputs": [], "source": [ "param.Dynamic.time_fn +=10\n", "p.k" ] }, { "cell_type": "code", "execution_count": null, "id": "d8c83516", "metadata": {}, "outputs": [], "source": [ "# Reset to the default, to support out of order execution of this notebook\n", "param.Dynamic.time_dependent = False " ] }, { "cell_type": "markdown", "id": "73c9d5ef", "metadata": {}, "source": [ "See `help(param.Time)` for detailed information about using the `time_fn`, including:\n", "- how to use `time_fn` as a context manager to test results at different times without disrupting the current time\n", "- how and why to use time types other than the integer default\n", "- how to set an upper limit on the time to bound a simulation run\n", "- how to declare the time units and time label\n", "\n", "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.\n", "\n", "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.\n", "\n", "You can see [topographica](https://github.com/ioam/topographica) for an example of a complex simulator built on Param's time support, including a [general-purpose event-driven simulation engine](https://github.com/ioam/topographica/blob/master/topo/base/simulation.py) capable of simulating any phenomena that can be simulated by updating at discrete times (whether on a fixed global timebase or not)." ] }, { "cell_type": "code", "execution_count": null, "id": "5bbeb5cb", "metadata": {}, "outputs": [], "source": [ "#help(param.Time)" ] }, { "cell_type": "markdown", "id": "01b94b15", "metadata": {}, "source": [ "## Numbergen\n", "\n", "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:\n", "\n", "- lambdas cannot easily be pickled for saving and restoring state (though see [cloudpickle](https://github.com/cloudpipe/cloudpickle) for an alternative to pickle that does support lambdas)\n", "- 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\n", "- 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.\n", "\n", "To make using Dynamic parameters more convenient, Param includes a separate module [Numbergen](https://github.com/holoviz/param/blob/main/numbergen/__init__.py) 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. \n", "\n", "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\").\n", "\n", "Numbergen objects all inherit from `ng.NumberGenerator`, which defines the callable interface and adds [operator support](#operations-on-number-generators) 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)." ] }, { "cell_type": "markdown", "id": "2bbb8087", "metadata": {}, "source": [ "### TimeDependent number generators\n", "\n", "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:\n", "\n", "- `ng.ScaledTime(factor=1.0)`: Simple multiplicative function of the global time value.\n", "- `ng.ExponentialDecay(starting_value=1.0, ending_value=0.0, time_constant=10000, base=e)`: Returns `starting_value*base^(-time_fn()/time_constant)`.\n", "- `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.\n", "- `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).\n", "\n", "To demonstrate these objects, let's write a helper function that samples the distribution at different time values:" ] }, { "cell_type": "code", "execution_count": null, "id": "aa3c1a7a", "metadata": {}, "outputs": [], "source": [ "import numbergen as ng\n", "import pandas as pd\n", "\n", "param.Dynamic.time_dependent = True\n", "pd.options.display.precision=3\n", "\n", "def timesample(ng, ts=range(0,10), time_fn = param.Dynamic.time_fn):\n", " ss = []\n", " for t in ts:\n", " time_fn(t)\n", " s = ng()\n", " ss += [(t,s)]\n", " df = pd.DataFrame(ss, columns=['t','s']).T\n", " return df.style.set_caption(ng.param.pprint(unknown_value=None))" ] }, { "cell_type": "markdown", "id": "6150a020", "metadata": {}, "source": [ "Now we can see how each of these objects behaves as `t` changes:" ] }, { "cell_type": "code", "execution_count": null, "id": "00f6dfa2", "metadata": {}, "outputs": [], "source": [ "timesample(ng.ScaledTime(factor=2.0))" ] }, { "cell_type": "code", "execution_count": null, "id": "c5db24ba", "metadata": {}, "outputs": [], "source": [ "timesample(ng.ExponentialDecay(time_constant=2, base=4))" ] }, { "cell_type": "code", "execution_count": null, "id": "05526b6f", "metadata": {}, "outputs": [], "source": [ "timesample(ng.BoxCar(onset=0.0, duration=3))" ] }, { "cell_type": "code", "execution_count": null, "id": "3f6457ad", "metadata": {}, "outputs": [], "source": [ "timesample(ng.SquareWave(onset=0.0, duration=1.0, off_duration=2.0))" ] }, { "cell_type": "markdown", "id": "ed137586", "metadata": {}, "source": [ "### TimeAware random number generators\n", "\n", "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. \n", "\n", "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](https://github.com/holoviz/param/blob/f5208aaac55cf7717e507988e0e733cf2a46e981/numbergen/__init__.py#L201), TimeAwareRandomState, and RandomDistribution classes for more details). \n", "\n", "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](https://docs.python.org/3/library/random.html) 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.\n", "\n", "RandomDistributions (all TimeAware and supporting `time_dependent`) include:\n", "\n", "- `ng.UniformRandom(lbound=0.0, ubound=1.0)`: Uniform random float in the range [lbound, ubound).\n", "- `ng.UniformRandomOffset(mean=0, range=1.0)`: Same as UniformRandom, but returns a random float in the range [mean - range/2, mean + range/2).\n", "- `ng.UniformRandomInt(lbound=0, ubound=1000)`: Uniform random integer in the (inclusive) range [lbound, ubound].\n", "- `ng.Choice(choices=[0,1])`: Random value from a provided list of choices.\n", "- `ng.NormalRandom(mu=0.0, sigma=1.0)`: Normally distributed (Gaussian) random number with mean mu and standard deviation sigma.\n", "- `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." ] }, { "cell_type": "code", "execution_count": null, "id": "dcf436fe", "metadata": {}, "outputs": [], "source": [ "timesample(ng.UniformRandom(lbound=0.0, ubound=10.0))" ] }, { "cell_type": "code", "execution_count": null, "id": "fd657f82", "metadata": {}, "outputs": [], "source": [ "timesample(ng.UniformRandomOffset(mean=100, range=3))" ] }, { "cell_type": "code", "execution_count": null, "id": "1ea60791", "metadata": {}, "outputs": [], "source": [ "timesample(ng.UniformRandomInt(lbound=0, ubound=1000))" ] }, { "cell_type": "code", "execution_count": null, "id": "c40dfb12", "metadata": {}, "outputs": [], "source": [ "timesample(ng.Choice(choices=[3.1, -95, 7]))" ] }, { "cell_type": "code", "execution_count": null, "id": "65782665", "metadata": {}, "outputs": [], "source": [ "timesample(ng.NormalRandom(mu=50.0, sigma=5.0))" ] }, { "cell_type": "code", "execution_count": null, "id": "a6ba4d70", "metadata": {}, "outputs": [], "source": [ "timesample(ng.VonMisesRandom(mu=0, kappa=500)) # small variance around 0 (aka 2pi)" ] }, { "cell_type": "markdown", "id": "6a2e30e6", "metadata": {}, "source": [ "### Operations on number generators\n", "\n", "Numbergen also provides a couple of NumberGenerators that accept other NumberGenerator objects and filter or modify their values:\n", "\n", "- `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.\n", "- `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.\n", "\n", "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:" ] }, { "cell_type": "code", "execution_count": null, "id": "618a91fa", "metadata": {}, "outputs": [], "source": [ "timesample(-abs(ng.ScaledTime(factor=2.0)+1)//4)" ] }, { "cell_type": "code", "execution_count": null, "id": "7b4c40c3", "metadata": {}, "outputs": [], "source": [ "timesample(ng.UniformRandom()%0.2)" ] }, { "cell_type": "code", "execution_count": null, "id": "8fde2ab7", "metadata": {}, "outputs": [], "source": [ "timesample(2*ng.SquareWave()-1)" ] }, { "cell_type": "markdown", "id": "2aa0bd9d", "metadata": {}, "source": [ "## Using numbergen objects for parameter values\n", "\n", "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:" ] }, { "cell_type": "code", "execution_count": null, "id": "c69a2333", "metadata": {}, "outputs": [], "source": [ "param.Dynamic.time_dependent = False\n", "\n", "p = P(i=ng.UniformRandomInt(lbound=35, ubound=99), \n", " x=ng.UniformRandom())\n", "\n", "p.i, p.i, p.i" ] }, { "cell_type": "code", "execution_count": null, "id": "41db033b", "metadata": {}, "outputs": [], "source": [ "p.x, p.x, p.x" ] }, { "cell_type": "markdown", "id": "9d3332da", "metadata": {}, "source": [ "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](https://imagen.holoviz.org) 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." ] } ], "metadata": { "language_info": { "name": "python", "pygments_lexer": "ipython3" } }, "nbformat": 4, "nbformat_minor": 5 }