Skip to content

Commit 7a8f930

Browse files
Make rand_prototype dense for a sparse noise_rate_prototype (#3763)
rand_prototype was built as `false .* noise_rate_prototype[1, :]`, so a sparse g handed the noise process a sparse Wiener increment. Sparsity in g says which states a channel drives, not which channels are idle, so every column still draws an increment and dW fills in on the first step. Two things go wrong from there. W2Ito1 sizes dZ dense no matter what dW is and stores both under one cache field type, so a sparse dW leaves W2Ito1Cache with no applicable constructor and the solve throws a MethodError. RKMilGeneral derives a long dZ from the prototype through `similar`, and sparse and dense arrays of that length take different randn! methods, so a sparse g quietly gave a different path than a dense g for the same seed. Fold the sparse noise_rate_prototype case into the existing issparse(u) branch, which already builds a dense increment and keeps the array type of u. Keeping the saved noise history dense also makes it cheaper: 400 states over 200 channels, EM at dt=1e-3 to t=10, goes from 136.71 MiB to 33.25 MiB and from 58 ms to 25 ms.
1 parent 4589cec commit 7a8f930

3 files changed

Lines changed: 107 additions & 1 deletion

File tree

lib/StochasticDiffEq/test/runtests.jl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@ const is_APPVEYOR = Sys.iswindows() && haskey(ENV, "APPVEYOR")
132132
@time @safetestset "Non-diagonal EulerHeun sparse alloc" begin
133133
include("nondiag_noise_eulerheun_test.jl")
134134
end
135+
@time @safetestset "Sparse noise_rate_prototype Tests" begin
136+
include("sparse_noise_tests.jl")
137+
end
135138
@time @safetestset "No Index Tests" begin
136139
include("noindex_tests.jl")
137140
end
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
using StochasticDiffEq, SparseArrays, Test, Random
2+
3+
# Four states driven by two independent Wiener processes. g is structurally sparse:
4+
# channel 1 drives state 1, channel 2 drives state 3, states 2 and 4 are pure drift.
5+
6+
function f_sparse!(du, u, p, t)
7+
return du .= -u
8+
end
9+
10+
function g_sparse!(du, u, p, t)
11+
du[1, 1] = 0.1 * u[1]
12+
return du[3, 2] = 0.1 * u[3]
13+
end
14+
15+
u0 = ones(4)
16+
tspan = (0.0, 1.0)
17+
18+
noise_prototype = spzeros(4, 2)
19+
noise_prototype[1, 1] = 1.0
20+
noise_prototype[3, 2] = 1.0
21+
22+
prob = SDEProblem(f_sparse!, g_sparse!, u0, tspan, noise_rate_prototype = noise_prototype)
23+
# The same problem with a dense g prototype: the reference the sparse run has to match.
24+
prob_dense = SDEProblem(
25+
f_sparse!, g_sparse!, u0, tspan, noise_rate_prototype = Matrix(noise_prototype)
26+
)
27+
28+
@testset "Sparse noise_rate_prototype — dW is dense" begin
29+
integrator = init(prob, EM(), dt = 0.01)
30+
@test !issparse(integrator.W.dW)
31+
@test integrator.W.dW isa Vector{Float64}
32+
end
33+
34+
@testset "Sparse noise_rate_prototype — caches with a dense dZ" begin
35+
# W2Ito1 sizes dZ densely no matter what dW is and stores both under one cache
36+
# field type, so a sparse dW leaves the cache constructor unresolvable.
37+
@test solve(prob, W2Ito1(), dt = 0.01, adaptive = false).retcode == ReturnCode.Success
38+
end
39+
40+
@testset "Sparse noise_rate_prototype — same path as a dense prototype" begin
41+
# RKMilGeneral draws a long dZ for the Lévy area terms. Sparse and dense arrays of
42+
# that length take different randn! methods, so the same seed gives a different
43+
# realisation unless the prototype is dense.
44+
Random.seed!(42)
45+
sol_sparse = solve(prob, RKMilGeneral(p = 10), dt = 0.01, adaptive = false)
46+
Random.seed!(42)
47+
sol_dense = solve(prob_dense, RKMilGeneral(p = 10), dt = 0.01, adaptive = false)
48+
@test sol_sparse.u[end] == sol_dense.u[end]
49+
end
50+
51+
@testset "Sparse noise_rate_prototype — EM IIP" begin
52+
Random.seed!(42)
53+
sol = solve(prob, EM(), dt = 0.01)
54+
@test sol.retcode == ReturnCode.Success
55+
@test length(sol) > 1
56+
# Dimensions 2 and 4 have no noise: pure drift exp(-t)
57+
@test sol.u[end][2] exp(-1.0) rtol = 0.02
58+
@test sol.u[end][4] exp(-1.0) rtol = 0.02
59+
# Dimensions 1 and 3 receive noise: path must deviate from pure drift
60+
@test !isapprox(sol.u[end][1], exp(-1.0), rtol = 0.005)
61+
@test !isapprox(sol.u[end][3], exp(-1.0), rtol = 0.005)
62+
end
63+
64+
@testset "Sparse noise_rate_prototype — EulerHeun IIP" begin
65+
Random.seed!(42)
66+
sol = solve(prob, EulerHeun(), dt = 0.01)
67+
@test sol.retcode == ReturnCode.Success
68+
@test length(sol) > 1
69+
@test sol.u[end][2] exp(-1.0) rtol = 0.02
70+
@test sol.u[end][4] exp(-1.0) rtol = 0.02
71+
@test !isapprox(sol.u[end][1], exp(-1.0), rtol = 0.005)
72+
@test !isapprox(sol.u[end][3], exp(-1.0), rtol = 0.005)
73+
end
74+
75+
function f_sparse_oop(u, p, t)
76+
return -u
77+
end
78+
79+
function g_sparse_oop(u, p, t)
80+
du = spzeros(4, 2)
81+
du[1, 1] = 0.1 * u[1]
82+
du[3, 2] = 0.1 * u[3]
83+
return du
84+
end
85+
86+
prob_oop = SDEProblem(f_sparse_oop, g_sparse_oop, u0, tspan, noise_rate_prototype = noise_prototype)
87+
88+
@testset "Sparse noise_rate_prototype — EM OOP" begin
89+
Random.seed!(42)
90+
sol = solve(prob_oop, EM(), dt = 0.01)
91+
@test sol.retcode == ReturnCode.Success
92+
@test sol.u[end][2] exp(-1.0) rtol = 0.02
93+
@test sol.u[end][4] exp(-1.0) rtol = 0.02
94+
@test !isapprox(sol.u[end][1], exp(-1.0), rtol = 0.005)
95+
@test !isapprox(sol.u[end][3], exp(-1.0), rtol = 0.005)
96+
end

lib/StochasticDiffEqCore/src/solve.jl

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,14 @@ function _sde_init(
400400
rand_prototype = (u .- u) ./ sqrt(oneunit(t))
401401
end
402402
elseif prob isa SciMLBase.AbstractSDEProblem
403-
if issparse(u)
403+
if issparse(u) || issparse(noise_rate_prototype)
404+
# Sparsity in g records which states a channel drives, not which
405+
# channels are idle: every column still draws an increment, so dW has
406+
# no structural zeros. A sparse dW also breaks caches that keep it and
407+
# an unconditionally dense dZ in one field type, and it sends randn!
408+
# through the generic AbstractArray loop, which consumes the RNG
409+
# differently from the Array method and so changes the path drawn
410+
# from a given seed.
404411
rand_prototype = adapt(
405412
SciMLBase.parameterless_type(u), zeros(randElType, size(noise_rate_prototype, 2))
406413
)

0 commit comments

Comments
 (0)