Skip to content

Conversation

sivasathyaseeelan
Copy link
Contributor

@sivasathyaseeelan sivasathyaseeelan commented Aug 9, 2025

using JumpProcesses, DiffEqBase
using Test, LinearAlgebra, Statistics
using StableRNGs, Plots
rng = StableRNG(12345)

Nsims = 10

β = 0.1 / 1000.0
ν = 0.01
influx_rate = 1.0
p = (β, ν, influx_rate)

u0 = [999.0, 10.0, 0.0]  # S, I, R
tspan = (0.0, 250.0)

prob_disc = DiscreteProblem(u0, tspan, p)

# MassActionJump formulation for SimpleExplicitTauLeaping
reactant_stoich = [[1=>1, 2=>1], [2=>1], Pair{Int,Int}[]]
net_stoich = [[1=>-1, 2=>1], [2=>-1, 3=>1], [1=>1]]
param_idxs = [1, 2, 3]
maj = MassActionJump(reactant_stoich, net_stoich; param_idxs=param_idxs)
jump_prob_maj = JumpProblem(prob_disc, PureLeaping(), maj; rng=rng)

# Solve with SimpleExplicitTauLeaping
sol = solve(EnsembleProblem(jump_prob_maj), SimpleExplicitTauLeaping(), EnsembleSerial(); trajectories=Nsims)

plot(sol)

SimpleExplicitTauLeaping Plot:

Screenshot from 2025-08-10 05-23-26

Checklist

  • Appropriate tests were added
  • Any code changes were done in a way that does not break public API
  • All documentation related to code changes were updated
  • The new code follows the
    contributor guidelines, in particular the SciML Style Guide and
    COLPRAC.
  • Any new documentation only uses public API

Additional context

Add any other context about the problem here.

@ChrisRackauckas
Copy link
Member

Rebase onto master

@isaacsas
Copy link
Member

@sivasathyaseeelan can you make the correctness test more strict. Compare against Direct, which will probably require a smaller timestep for the leaping methods, and calculate the maximum error over time (say every second) instead of just at the final time.

I’m reading through the references to refresh my memory on how the adaptive stepping works, and will try to finish that tomorrow and give you some more feedback afterwards.

Copy link
Member

@isaacsas isaacsas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sivasathyaseeelan, to speed up review I suggest splitting this into separate PRs for explicit vs. implicit. Let's get explicit merged and then we can work on implicit.

@ChrisRackauckas I think there are some design decisions to be made here for getting at the individual rate functions and stochiometry vectors. So it is just a question if you want to work on that as part of these or as a followup -- I'd defer to what you want, but I don't think these should be advertised until we get that settled and have more extensive testing.

@sivasathyaseeelan sivasathyaseeelan changed the title SimpleAdaptiveTauLeaping and SimpleImplicitTauLeaping SimpleAdaptiveTauLeaping solver Aug 12, 2025
Copy link
Member

@isaacsas isaacsas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

General comments:

  1. Please add comments citing the formulas you are using from the original paper for the leap selection.
  2. Please revise with an eye towards making this non-allocating during the solution timestepping, see the examples I've highlighted but keep in mind they are not all the cases of extraneous allocations.

@isaacsas
Copy link
Member

@sivasathyaseeelan this PR needs to be updated for the changes I just merged to master.

Copy link
Member

@isaacsas isaacsas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sivasathyaseeelan getting better. I've only reviewed the tau-selection code -- once that seems to be the right algorithm / approach I'll give you a more thorough review.

Comment on lines 112 to 157
function compute_gi(u, reaction_conditions, i)
# Compute g_i for species i to bound the relative change in propensity functions,
# as per Cao et al. (2006), Section IV, equation (27).
# g_i is determined by the highest order of reaction (HOR) where species i is a reactant:
# - HOR = 1 (first-order, e.g., S_i -> products): g_i = 1
# - HOR = 2 (second-order):
# - nu_ij = 1 (e.g., S_i + S_k -> products): g_i = 2
# - nu_ij = 2 (e.g., 2S_i -> products): g_i = 2 + 1/(x_i - 1)
# - HOR = 3 (third-order):
# - nu_ij = 1 (e.g., S_i + S_k + S_m -> products): g_i = 3
# - nu_ij = 2 (e.g., 2S_i + S_k -> products): g_i = (3/2) * (2 + 1/(x_i - 1))
# - nu_ij = 3 (e.g., 3S_i -> products): g_i = 3 + 1/(x_i - 1) + 2/(x_i - 2)
# Uses precomputed reaction_conditions to optimize checks for HOR = 2 or 3 with nu_ij >= 2.
max_hor = maximum(isempty(reaction_conditions[i]) ? 0 : [hor_j for (j, nu_ij, hor_j) in reaction_conditions[i]])
max_gi = 1
for (j, nu_ij, hor_j) in reaction_conditions[i]
if hor_j == max_hor
if hor_j == 1
max_gi = max(max_gi, 1)
elseif hor_j == 2
if nu_ij == 1
max_gi = max(max_gi, 2)
elseif nu_ij == 2
if u[i] > 1 # Ensure x_i - 1 > 0
gi = 2 + 1 / (u[i] - 1)
max_gi = max(max_gi, ceil(Int64, gi))
end
end
elseif hor_j == 3
if nu_ij == 1
max_gi = max(max_gi, 3)
elseif nu_ij == 2
if u[i] > 1 # Ensure x_i - 1 > 0
gi = 1.5 * (2 + 1 / (u[i] - 1))
max_gi = max(max_gi, ceil(Int64, gi))
end
elseif nu_ij == 3
if u[i] > 2 # Ensure x_i - 2 > 0
gi = 3 + 1 / (u[i] - 1) + 2 / (u[i] - 2)
max_gi = max(max_gi, ceil(Int64, gi))
end
end
end
end
end
return max_gi
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is getting better, but there are still some issues.

  1. Just initialize max_gi = zero(typeof(t)) and get rid of all these ceil's and conversions to integers. WIth the correction formulas g_i can be a floating point type so I think it makes sense to just give it the same type as the time variable.
  2. You are currently doing num_species * num_rxs work per timestep in computing all these g_i, when it should only be num_species work. You need to precalculate when initializing the solver hor_order, where hor_order[i] is the maximum substrate order of species i over all reactions. Then you use this to determine which g_i case you are in. For example, if HOR(i) == 2 and hor_order[i] == 1 you just set g_i = 2, but if HOR(i) == 2 and hor_order[i] == 2 you use the formula that involves u[i].

Copy link
Contributor Author

@sivasathyaseeelan sivasathyaseeelan Aug 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now compute_gi is in O(num_species) time complexity

@sivasathyaseeelan sivasathyaseeelan changed the title SimpleAdaptiveTauLeaping solver SimpleExplicitTauLeaping solver Sep 5, 2025
# HOR is the sum of stoichiometric coefficients of reactants in reaction j.
hor = zeros(Int, numjumps)
for j in 1:numjumps
order = sum(stoch for (spec_idx, stoch) in reactant_stoch[j]; init=0)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
order = sum(stoch for (spec_idx, stoch) in reactant_stoch[j]; init=0)
order = sum(stoch for (spec_idx, stoch) in reactant_stoch[j])

sum can figure this out and avoid type assumptions.

function compute_hor(reactant_stoch, numjumps)
# Compute the highest order of reaction (HOR) for each reaction j, as per Cao et al. (2006), Section IV.
# HOR is the sum of stoichiometric coefficients of reactants in reaction j.
hor = zeros(Int, numjumps)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
hor = zeros(Int, numjumps)
hor = zeros(Int, numjumps)

Instead of Int, extract the type from reactant_stoch via a parametric input to this function.

Comment on lines +83 to +85
function compute_hor(reactant_stoch, numjumps)
# Compute the highest order of reaction (HOR) for each reaction j, as per Cao et al. (2006), Section IV.
# HOR is the sum of stoichiometric coefficients of reactants in reaction j.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
function compute_hor(reactant_stoch, numjumps)
# Compute the highest order of reaction (HOR) for each reaction j, as per Cao et al. (2006), Section IV.
# HOR is the sum of stoichiometric coefficients of reactants in reaction j.
# Compute the highest order of reaction (HOR) for each reaction j, as per Cao et al. (2006), Section IV.
# HOR is the sum of stoichiometric coefficients of reactants in reaction j.
function compute_hor(reactant_stoch, numjumps)

And please update other functions accordingly. Place these kind of design comments before the function, not right inside it.

Comment on lines +102 to +103
max_hor = zeros(Int, numspecies)
max_stoich = zeros(Int, numspecies)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't assume Int, just match the eltype(hor).

Comment on lines +119 to +131
function compute_gi(u, max_hor, max_stoich, i, t)
# Compute g_i for species i to bound the relative change in propensity functions,
# as per Cao et al. (2006), Section IV, equation (27).
# g_i is determined by the highest order of reaction (HOR) and maximum stoichiometry (nu_ij) where species i is a reactant:
# - HOR = 1 (first-order, e.g., S_i -> products): g_i = 1
# - HOR = 2 (second-order):
# - nu_ij = 1 (e.g., S_i + S_k -> products): g_i = 2
# - nu_ij = 2 (e.g., 2S_i -> products): g_i = 2 + 1/(x_i - 1)
# - HOR = 3 (third-order):
# - nu_ij = 1 (e.g., S_i + S_k + S_m -> products): g_i = 3
# - nu_ij = 2 (e.g., 2S_i + S_k -> products): g_i = (3/2) * (2 + 1/(x_i - 1))
# - nu_ij = 3 (e.g., 3S_i -> products): g_i = 3 + 1/(x_i - 1) + 2/(x_i - 2)
# Uses precomputed max_hor and max_stoich to reduce work to O(num_species) per timestep.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
function compute_gi(u, max_hor, max_stoich, i, t)
# Compute g_i for species i to bound the relative change in propensity functions,
# as per Cao et al. (2006), Section IV, equation (27).
# g_i is determined by the highest order of reaction (HOR) and maximum stoichiometry (nu_ij) where species i is a reactant:
# - HOR = 1 (first-order, e.g., S_i -> products): g_i = 1
# - HOR = 2 (second-order):
# - nu_ij = 1 (e.g., S_i + S_k -> products): g_i = 2
# - nu_ij = 2 (e.g., 2S_i -> products): g_i = 2 + 1/(x_i - 1)
# - HOR = 3 (third-order):
# - nu_ij = 1 (e.g., S_i + S_k + S_m -> products): g_i = 3
# - nu_ij = 2 (e.g., 2S_i + S_k -> products): g_i = (3/2) * (2 + 1/(x_i - 1))
# - nu_ij = 3 (e.g., 3S_i -> products): g_i = 3 + 1/(x_i - 1) + 2/(x_i - 2)
# Uses precomputed max_hor and max_stoich to reduce work to O(num_species) per timestep.
# Compute g_i for species i to bound the relative change in propensity functions,
# as per Cao et al. (2006), Section IV, equation (27).
# g_i is determined by the highest order of reaction (HOR) and maximum stoichiometry (nu_ij) where species i is a reactant:
# - HOR = 1 (first-order, e.g., S_i -> products): g_i = 1
# - HOR = 2 (second-order):
# - nu_ij = 1 (e.g., S_i + S_k -> products): g_i = 2
# - nu_ij = 2 (e.g., 2S_i -> products): g_i = 2 + 1/(x_i - 1)
# - HOR = 3 (third-order):
# - nu_ij = 1 (e.g., S_i + S_k + S_m -> products): g_i = 3
# - nu_ij = 2 (e.g., 2S_i + S_k -> products): g_i = (3/2) * (2 + 1/(x_i - 1))
# - nu_ij = 3 (e.g., 3S_i -> products): g_i = 3 + 1/(x_i - 1) + 2/(x_i - 2)
# Uses precomputed max_hor and max_stoich to reduce work to O(num_species) per timestep.
function compute_gi(u, max_hor, max_stoich, i, t)

max_hor, max_stoich = precompute_reaction_conditions(reactant_stoch, hor, length(u0), numjumps)

# Set up saveat_times
saveat_times = nothing
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
saveat_times = nothing

Not needed.


save_idx = 1

while t_current < t_end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For type stability reasons, this while loop should probably be a separate function you call.

if !isempty(saveat_times) && save_idx <= length(saveat_times) && t_current + tau > saveat_times[save_idx]
tau = saveat_times[save_idx] - t_current
end
counts .= pois_rand.(rng, max.(rate_cache * tau, 0.0))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
counts .= pois_rand.(rng, max.(rate_cache * tau, 0.0))
counts .= pois_rand.(rng, max.(rate_cache * tau, zero(tau)))

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In fact, if a particular rate is <= 0 why not just set the count to zero directly and avoid the call to pois_rand for it? That seems a better approach than using max in this way.

Comment on lines +266 to +269
for i in eachindex(u_new)
u_new[i] = max(u_new[i], 0)
end
t_new = t_current + tau
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the step rejection if any component of u_new is negative make this unneeded? i.e. at this point don't you know that all the entries in u_new are non-negative?

end
end
end
u_new = u_current + du
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is allocating... Have separate pre-declatred vectors for u_new and u_current and broadcast here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants