Integration Guide
How your code can use CompetingClocks.jl as a sampling engine.
Core Integration Pattern
CompetingClocks manages when events happen. Your framework manages what happens.
Your framework provides:
- State representation
- Event-handling logic
- A user-facing API
CompetingClocks provides:
- Event timing and scheduling
- Distribution sampling
- Variance reduction and likelihood calculation
A minimal integration loop:
function run_simulation(model, sampler, rng, end_time)
initialize_events!(model, sampler) # Your code
when, which = next(sampler)
while !isnothing(which) && when < end_time
fire!(sampler, which, when)
handle_event!(model, sampler, which, when, rng) # Your code
when, which = next(sampler)
end
endDesign Decisions
Clock Key Type
How will you identify events? CompetingClocks.jl indexes events using an immutable clock key that you provide. Examples:
(:infect, infectious, susceptible)-Tuple{Symbol,Int,Int}orTuple392-Int64:i => 37- A Pair for use withGen.jl.
A custom struct type for events works well. Using a concrete type is more performant for sampling.
Threading
This package doesn't use Tasks or thread guards because it is so common to run multiple copies of a simulation, each on its own thread. If you run multiple simulations, it can be more correct to use random number generators that work in parallel, such as those in the Random123 package or the jump!() method for the Xoshiro sampler in the Random package.
State and Dependencies
Your simulation handles all state. CompetingClocks only knows about enabled events. It can be very helpful for performance if you use information about relationships among events to limit updates to the samplers. There are a few kinds of dependency graphs that may help.
- Dependency Graph–-When one event fires, it changes state. Which other events could be enabled or disabled because of the changed state?
- Rate Dependency Graph–-When one event fires, it changes state. Which other events have transition rates that depend on that state? The event may remain enabled, but its distribution of firing times may have a different parameter value.
- Event-to-event Graph–-When one event fires, you might know directly which other events have enabling rules or transition rates that depend on that first event, without needing to ask what state changed and what events depend on the state.
How you represent a dependency graph depends on the problem.
Common Integration Patterns
Agent-based Models
The clock key will identify an action and an agent, along with either another agent or a location.
struct ClockKey
action::Symbol
who::Int
other::Int # other agent or location
endIt's also easy to use a Tuple as a ClockKey until you figure out the space of all possible keys.
Spatial Simulation
Split the sampler into groups so that each region is faster.
builder = SamplerBuilder(EventKey, Float64)
for region in spatial_grid
add_group!(builder, region.name => (key, dist) -> key.location == region.id)
endChemical Reaction Networks
Map reactions to events.
function enable_reaction!(sampler, reaction, state, params)
propensity = calculate_propensity(reaction, state, params)
enable!(sampler, reaction.id, Exponential(1/propensity))
endAdvanced Features
There is extra work in a simulation to support some of the sampler features.
Exposing likelihood calculation
The step log-likelihood and path log-likelihood can be found from the sampler at any point during a simulation. They are always calulated relative to the last event to fire!(), even if next() has been called.
builder = SamplerBuilder(KeyType, Float64;
path_likelihood=true)
sampler = SamplingContext(builder, rng)
# After simulation, the log_prob is a Float64.
log_prob = pathloglikelihood(sampler, end_time)The resulting value is the likelihood of a single trajectory of the system. The pathloglikelihood is a regular function and can be differentiated.
Variance reduction
Construct the sampler with the common_random option.
builder = SamplerBuilder(KeyType, Float64; common_random=true)
sampler = SamplingContext(builder, rng)Run the simulation a bunch of times, calling reset!() between runs to clear the sampler's memory of the previous run but save the common random numbers. Each run will pin more random draws. Then use freeze_crn! to stop collecting random draws.
for i in 1:warm_up
reset!(sampler)
results1 = run_simulation(model, sampler)
end
freeze_crn!(sampler) # Lock random drawsReplay with different parameters.
for i in 1:draw_cnt
reset!(sampler) # Do this before each run with same sampler.
results2 = run_simulation(model, sampler) # Uses same random draws
endEach simulation copy also needs a copy of the state.
If you want to run these multithreaded, you can use clone() to copy the sampler to a vector and then use copy_clocks! to initialize all the samplers in the vector with the common random numbers that were just collected.
master_seed = (0x3a97a224, 0x65ff9227)
common_samplers = Vector{typeof(sampler)}(undef, clone_cnt)
for i in 1:clone_cnt
rng = Philox4x((0, 0, 0, init_idx), master_seed)
common_samplers[init_idx] = clone(sampler, rng)
end
# Run warm-up of common random numbers here, and freeze them.
for i in 1:clone_cnt
copy_clocks!(common_samplers[i], sampler)
endThen run tasks on the clones of the frozen sampler.
Path splitting for rare events
This is a simple form of importance sampling. When a simulation gets near a space of interesting results, take the current trajectory and make multiple copies of the simulation, both the state and the samplers. Weight the results of each copy by one over the number of copies.
CompetingClocks offers a split! function that copies the state to a vector of clocks and keeps a split_weight as a record of how many times splitting was done. Take a look at the implementation of CompetingClocks.split!().
Your code might keep the sampler in the main simulation framework, in which case you would include the sampler in the cloning and copying process.
Error Handling and Debugging
If you call the sampler builder with debug=true then there will be logging.
builder = SamplerBuilder(KeyType, Float64; debug=true)
sampler = SamplingContext(builder, rng)Then run with logging on.
with_logger(ConsoleLogger(stdout, Logging.Debug)) do
observed, importance = run_epochs(100)
endThere is also a chaos-monkey option called a Petri sampler. This sampler always advances time by the same time step dt=1.0 by default, and it ignores the clock distributions. It chooses any enabled sampler at each step, with the goal of finding unusual sampling paths. This is surprisingly effective at finding logic errors in code to change state.
Performance Considerations
Type Stability–-Use a concrete type for the ClockKey for more speed.
Sampler Choice–-If there are a lot of clocks enabled at the same time, the the sampler choice can be important. Experts in traffic simulation use hierarchical samplers to split sampling by geographic location but also split fast events from slow events. There are also fine-grained choices for samplers about whether clock keys are reused.
Memory Allocation–-Instead of making a new sampler for each run, use reset!(sampler) between runs to clear out the clocks.