Arrow types in Catalyst JumpProblem

I have a question about the arrow types in Catalyst models when simulating jump processes. I build the model as a reaction network in Catalyst and simulate it as a JumpProblem. It’s a large model and so I’m trying to optimise the run times. I’ve noticed that using different arrow types can affect the run times (=> vs -->). I understand the difference between the two in that → automatically treats it as a mass action reaction and => just uses the user defined rate. When using the same seed, I expect my solutions to be equivalent no matter what arrows I’m using, as long as the propensities match.

See the MWE below (using a toy model), model 0 and model 3 are equivalent, but models 1 and 2 don’t match them, even though technically the reactions have the same propensities. Can anyone share any information on why this is? Can I treat my models as equivalent if their propensities are the same even though the same seed doesn’t produce the exact same outcome? Thanks in advance!

using Catalyst, JumpProcesses, Random, DataFrames, Statistics

# ============================================================================
# Model Definitions
# ============================================================================

# Model 0: All reactions use --> (mass action)
function model_arrows_0()
    rn = @reaction_network begin
        k_prod,     ∅ --> A
        k_bind,     A + B --> AB
        k_unbind,   AB --> A + B
        k_deg,      A --> ∅
        k_deg,      B --> ∅
        k_deg,      AB --> ∅
    end
    return rn
end

# Model 1: Mixed arrows - unbinding uses explicit => 
function model_arrows_1()
    rn = @reaction_network begin
        k_prod,             ∅ --> A
        k_bind,             A + B --> AB
        k_unbind * AB,      AB => A + B
        k_deg,              A --> ∅
        k_deg,              B --> ∅
        k_deg,              AB --> ∅
    end
    return rn
end

# Model 2: More explicit rates with =>
function model_arrows_2()
    rn = @reaction_network begin
        k_prod,             ∅ --> A
        k_bind * A * B,     A + B => AB
        k_unbind * AB,      AB => A + B
        k_deg,              A --> ∅
        k_deg,              B --> ∅
        k_deg,              AB --> ∅
    end
    return rn
end

# Model 3: All reactions use explicit => 
function model_arrows_3()
    rn = @reaction_network begin
        k_prod,             ∅ => A
        k_bind * A * B,     A + B => AB
        k_unbind * AB,      AB => A + B
        k_deg * A,          A => ∅
        k_deg * B,          B => ∅
        k_deg * AB,         AB => ∅
    end
    return rn
end

# ============================================================================
# Test Function
# ============================================================================

function run_stochastic_sim(model_func; seed=123, tspan=(0.0, 100.0), dt=0.5)
  
    rn = model_func()
    
    # Parameters
    pars = Dict(:k_prod => 0.5, :k_bind => 0.01, :k_unbind => 0.1, :k_deg => 0.02)
    
    # Initial conditions
    u0 = Dict(:A => 10, :B => 5, :AB => 0)
    
    # Create and solve jump problem
    jp = JumpProblem(rn, u0, tspan, pars, aggregator=Direct(), save_positions=(false, false))
    
    Random.seed!(seed)
    sol = solve(jp, SSAStepper(), saveat=dt)
    
    return sol
end

# ============================================================================
# Run All Models
# ============================================================================
sol0 = run_stochastic_sim(model_arrows_0)
sol1 = run_stochastic_sim(model_arrows_1)
sol2 = run_stochastic_sim(model_arrows_2)
sol3 = run_stochastic_sim(model_arrows_3)


println("\n" * "="^70)
println("All Models vs Model 0:")
println("="^70)

for i in 1:3
    s = [sol0, sol1, sol2, sol3][i+1]
    equal = isequal(sol0, s)
    status = equal ? "✓ EQUAL" : "✗ DIFFERENT"
    println("Model 0 vs Model $i: $status")
end

What is your measure of “equivalent”? In general, you cannot expect that the stochastic trajectories of two different solvers are the same, even if you do the same physical simulation. It looks like you are comparing only single trajectories here?

As soon as some internal operations in the solver are different for the two types of rate functions, e.g. the order of multiplications and additions, etc. you will get slightly different floating point values and over time diverging trajectories. Same if the solver does different setup work or stores the propensities in different ways, depending on which type of arrow was used (I don’t know the internals of Catalyst well enough to know where this could happen in the code).

On a statistical level, the results should agree, though. So if you plot histograms of your observables of interest at a given time, and run enough realizations of your problem, they should “look the same” if the type of reactions specified are physically the same. This is always the type of comparison you should go for when dealing with stochastic processes, in my opinion.

2 Likes

When you use => arrows you are telling Catalyst that you want it to just build a function for your given rate, ignoring substrate stoichiometry, and so with jump models you will always get a ConstantRateJump or VariableRateJump. If you want MassActionJumps you have to use the normal arrow that calculates propensities from the reaction stoichiometry and pass a constant parameter for the rate (which is strongly recommended as the default one should use).

JumpProcesses currently orders MassActionJumps before ConstantRateJumps, so your examples 2 and 3 are changing the order of the reactions when sampling compared to examples 1 and 4. Everything should be fine in a statistical sense, but the same seed will not give the same reaction path in these cases.

2 Likes

Ah got it. That makes sense thanks. When you say “constant parameter” for the rate, do you just mean something that can’t change between jumps? Or something that is truly constant across the whole simulation? So if the rates depend on other model species, I can’t use the → arrow?

Suppose you have a reaction of the form

rateexpr, A + B --> C

If rateexpr is a number like 1.0, or a function of non-time dependent parameters like

@parameter k, b
rateexpr = k * exp(b)

you should end up with a MassActionJump.

If rateexpr depends in any explicit way on t, a species (i.e. A), or a non-species unknown then it will generate a constant or variable rate jump. i.e.

@species D(t)
@variables m(t)
rateexpr = t * D     # gives VariableRateJump
rateexpr = k * D     # gives ConstantRateJump
rateexpr = m*D + k   # gives ConstantRateJump
1 Like

If rates need to explicitly depend on model species in way that can’t be handled by the reaction stoichiometry, i.e. the substrate part of the reaction: k, A + B --> ... gives a factor of A*B, you can use --> or => and put the species dependence in the rate expression, but then you will get a ConstantRateJump or VariableRatejump.

We have some documentation about this that might also be helpful at:

FAQs · Catalyst.jl

1 Like

Ok, all understood, thanks for the responses!