Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Random Module

Challenges of randomness

Random sampling is a fundamental capability for most simulations. The requirement that experiments also need to be deterministic makes randomness a subtle issue.

randomness vs. determinism

A truly random number source produces numbers in a sequence that cannot be predicted even in principle. A pseudorandom number source produces numbers in a sequence according to a completely deterministic algorithm, so if you know the algorithm, you can predict the next number in the sequence exactly. However, good pseudorandom number generators can produce sequences that appear indistinguishable from a truly random sequence according to a battery of rigorous statistical tests. We sometimes use the word random when we actually mean pseudorandom. Because we want simulations to be reproducible, we want them to be deterministic. But we also want statistical randomness within the simulation. Pseudorandom number generators give us both determinism and statistical randomness.

Here are the primary ways we handle this challenge in Ixa:

  • We use pseudorandom number generators (PRNG or just RNG for short), which when provided with a seed number will produce numbers in a statistically random sequence—except that using the same seed will produce the same random sequence. To run a "new" simulation, provide a different seed.
  • The default hash function used in the Rust Standard Library for containers like std::collections::HashMap and std::collections::HashSet is nondeterministic for technical reasons that don't apply to our application. Therefore, we provide ixa::HashMap and ixa::HashSet, which use a deterministic—and much faster—hash function.
  • Each module defines its own random number sources, so the use of RNGs in one module is somewhat isolated from RNGs used in other modules. Of course, if a module interacts with other parts of Ixa, its own random behavior can "leak" and affect the determinism of other modules as well.

Illustration of distinct RNGsIllustration of a shared RNG Using distinct RNGs in distinct components helps maintain simulation reproducibility and prevents unintended interactions between independent parts of your model. When modules share RNG streams, adding a new random call in one component can shift the entire sequence of random numbers consumed by other components, making it impossible to isolate changes or compare scenarios reliably. For example, if you're comparing two disease transmission scenarios where only the infection rate differs, you want the same sequence of people to be randomly selected for potential infection in both runs - but if these scenarios share an RNG with a recovery module that makes different numbers of random calls due to varying infection counts, the transmission module will consume different parts of the random sequence, compromising the comparison. By giving each module its own RNG, you ensure that changes in one module's random behavior don't cascade through the entire simulation, enabling precise scenario comparisons and reproducible debugging.

requirements for determinism

The determinism guarantee applies to repeated execution of the same model compiled with the same version of Ixa. However, you should not expect identical results between different versions of Ixa. When you make changes to your own code, it is very easy to change the simulation behavior even if you don't intend to. Do not be surprised if you get different results if you make changes to your own code.

How to make and use a random number source

1. Define an RNG with define_rng!

Suppose we want to make a transmission manager that randomly samples a person to become infected. In transmission_manger.rs we define a random number source for our exclusive use in the transmission manager using the define_rng! macro, giving it a unique name:

define_rng!(TransmissionRng);

This macro generates all the code needed to define TransmissionRng.

2. Ensure the random module is initialized

Initialization of the random module only needs to be done one time for a given Context instance to initialize all random number sources defined anywhere in the source. It is not an error to initialize more than once, but the random module's state will be reset on the second initialization.

automatically with run_with_args()

This step is automatically done for you if you use run_with_args(), which is the recommended way to run your model. This allows you to provide a seed for the random module as a command line parameter, among other conveniences.

manually with run_with_args()

Even if you are using run_with_args(), you might still wish to programmatically initialize the Context with a hard-coded random seed, say, during development.

fn main() {
    run_with_args(|context, _, _| {
        context.init_random(42);
        Ok(())
    })
    .unwrap();
}

This re-initializes the random module with the provided seed.

manually on Context

In cases such as in unit tests, you can initialize the random module manually:

let mut context = Context::new();
// Initialize with a seed for reproducibility.
context.init_random(42);

3. Sampling random numbers using the RNG

The random module endows Context with several convenient member functions you can use to do different kinds of sampling. (The next section covers several.) The key is to always provide the name of the RNG we defined in step 1. We called it TransmissionRng in our example.

let random_integer = context.sample_range(TransmissionRng, 0..100);

Make sure the random module's context extension is imported if your IDE does not automatically add the import for you:

use ixa::prelude::*;

Some typical uses of sampling

Ixa provides several methods for generating random values through the context:

Sample from a Range

let random_person = context.sample_range(MyRng, 0..POPULATION);

Sample Boolean with Probability

let result = context.sample_bool(MyRng, 0.4);  // 40% chance of true

Sample from a Distribution

Ixa re-exports the rand crate as ixa::rand. The rand crate comes with a few common distributions. The rand_distr and the statrs crates provide many more.

let value = context.sample_distr(MyRng, Uniform::new(2.0, 10.0).unwrap());

`rand` crate version compatibility

As of this writing, the latest version of the rand crate is v0.9.2, but some popular crates in the Rust ecosystem still use rand@0.8.*, which is incompatible. Using an incompatible version in your code can result in confusing errors that end with the tell-tale line, "Note: there are multiple different versions of crate rand in the dependency graph". Always using rand that Ixa re-exports at ixa::rand will help you avoid this trap.

Custom Random Generation

For more sophisticated sampling use cases, you might need direct access to the random number generator. In this case, we use a design pattern that ensures we have safe access to the RNG: we pass a closure to the context.sample(...) method that takes the RNG as an argument. If we didn't already have context.sample_weighted(...), we could do the following:

let choices = ['a', 'b', 'c'];
let weights = [2,   1,   1];
let choice = context.sample(MyRng, |rng|{
    let dist = WeightedIndex::new(&weights).unwrap();
    choices[dist.sample(rng)]
});