Skip to content

Commit b52d9a7

Browse files
authored
feat!: Better reproducibility management of dynamic problems with SeededEnvironment (#73)
* refactor: replace MersenneTwister with Xoshiro * refactor: replace MersenneTwister with Xoshiro * feat: implement the SeededEnv wrapper, and adapt and cleanup policy evaluation and various dynamic environments * feat: user now needs to implement build_environment insteadof generate_environment * test: update tests + unit tests for SeededEnvironment * doc: update documentation * cleanup
1 parent a7d039b commit b52d9a7

30 files changed

Lines changed: 564 additions & 270 deletions

docs/src/custom_benchmarks.md

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -121,26 +121,39 @@ Dynamic benchmarks extend stochastic ones with an environment-based rollout inte
121121
### Environment generation
122122

123123
```julia
124-
# Strategy A: generate one environment at a time (default implementation of
125-
# generate_environments calls this repeatedly)
126-
generate_environment(bench::MyDynamicBenchmark, rng::AbstractRNG; kwargs...) -> AbstractEnvironment
124+
# Option A: build one bare environment (the framework wraps it in a SeededEnvironment)
125+
build_environment(bench::MyDynamicBenchmark, rng::AbstractRNG; kwargs...) -> AbstractEnvironment
127126

128-
# Strategy B: override when environments are not independent (e.g. loaded from files)
129-
generate_environments(bench::MyDynamicBenchmark, n::Int; rng, kwargs...) -> Vector{<:AbstractEnvironment}
127+
# Option B: override when environments are not independent (e.g. loaded from files)
128+
generate_environments(bench::MyDynamicBenchmark, n::Int; seed=nothing, kwargs...) -> Vector{SeededEnvironment}
130129
```
131130

131+
You implement **`build_environment`**, which returns a *bare* environment. The package then automatically wraps it in a [`SeededEnvironment`](@ref) (attaching a seed and RNG) through two public entry points:
132+
133+
```julia
134+
generate_environment(bench; seed=nothing, kwargs...) -> SeededEnvironment # one env
135+
generate_environments(bench, n; seed=nothing, kwargs...) -> Vector{SeededEnvironment} # n envs
136+
```
137+
138+
For environments that cannot be drawn independently (e.g. loaded from files), override `generate_environments` instead of implementing `build_environment`.
139+
An override must return already-wrapped `SeededEnvironment`s (use `SeededEnvironment(env; seed=...)`).
140+
Do not override `generate_environment`: it just delegates to `generate_environments`.
141+
132142
### `AbstractEnvironment` interface
133143

134-
Your environment type must implement:
144+
Your environment must be **stateless about randomness**: it does *not* store its own RNG or seed.
145+
All randomness is owned by the wrapping [`SeededEnvironment`](@ref), which passes its `rng` into every `reset!` and `step!`.
146+
Draw all stochasticity from that `rng` so that re-seeding the wrapper (via [`reset_to_initial!`](@ref)) replays an episode exactly.
135147

136148
```julia
137-
get_seed(env::MyEnv) # Return the RNG seed used at creation
138-
reset!(env::MyEnv; reset_rng::Bool, seed=get_seed(env)) # Reset to initial state
139-
observe(env::MyEnv) -> (observation, info) # Current observation
140-
step!(env::MyEnv, action) -> reward # Apply action, advance state
141-
is_terminated(env::MyEnv) -> Bool # True when episode has ended
149+
reset!(env::MyEnv, rng::AbstractRNG) # Reset to a starting state, sampling from rng
150+
observe(env::MyEnv) -> (observation, state) # Current observation and internal state
151+
step!(env::MyEnv, action, rng::AbstractRNG) -> reward # Apply action, advance state (draw from rng)
152+
is_terminated(env::MyEnv) -> Bool # True when the episode has ended
142153
```
143154

155+
Environments may ignore the `rng` argument.
156+
144157
### Baseline policies (required for `generate_dataset`)
145158

146159
```julia

docs/src/using_benchmarks.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,15 +132,25 @@ rollout and returns the resulting trajectory.
132132

133133
## Seed / RNG control
134134

135-
All `generate_dataset` and `generate_environments` calls accept either `seed`
136-
(creates an internal `MersenneTwister`) or `rng` for full control:
135+
`generate_dataset` accepts either `seed` (creates an internal `Xoshiro`) or `rng` (any `AbstractRNG`)::
137136

138137
```julia
139138
using Random
140-
rng = MersenneTwister(42)
139+
rng = Xoshiro(42)
141140
dataset = generate_dataset(bench, 50; rng=rng)
142141
```
143142

143+
`generate_environments` takes a `seed`:
144+
145+
```julia
146+
envs = generate_environments(bench, 10; seed=0)
147+
```
148+
149+
### Reproducibility in dynamic benchmarks
150+
151+
Each environment returned by `generate_environments` or `generate_environment` is a `SeededEnvironment`: a wrapper that owns the random number generator and a stored seed.
152+
It is the single source of randomness for the episode, so re-running a policy on the same environment replays the exact same trajectory. `evaluate_policy!` resets to the stored seed before each run, which is what makes evaluation reproducible (pass `seed=...` to override the stored seed for a given run).
153+
144154
---
145155

146156
## Evaluation

src/Argmax2D/Argmax2D.jl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ using ..Utils
44
using DocStringExtensions: TYPEDEF, TYPEDFIELDS, TYPEDSIGNATURES
55
using Flux: Chain, Dense
66
using LinearAlgebra: dot, norm
7-
using Random: Random, MersenneTwister, AbstractRNG
7+
using Random: Random, Xoshiro, AbstractRNG
88

99
include("polytope.jl")
1010

@@ -79,7 +79,7 @@ $TYPEDSIGNATURES
7979
Generate a statistical model for the [`Argmax2DBenchmark`](@ref).
8080
"""
8181
function Utils.generate_statistical_model(
82-
bench::Argmax2DBenchmark; seed=nothing, rng=MersenneTwister(seed)
82+
bench::Argmax2DBenchmark; seed=nothing, rng=Xoshiro(seed)
8383
)
8484
Random.seed!(rng, seed)
8585
(; nb_features) = bench

src/ContextualStochasticArgmax/ContextualStochasticArgmax.jl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ using ..Utils
44
using DocStringExtensions: TYPEDEF, TYPEDFIELDS, TYPEDSIGNATURES
55
using Flux: Dense
66
using LinearAlgebra: dot
7-
using Random: Random, AbstractRNG, MersenneTwister
7+
using Random: Random, AbstractRNG, Xoshiro
88
using Statistics: mean
99

1010
"""
@@ -37,7 +37,7 @@ end
3737
function ContextualStochasticArgmaxBenchmark(;
3838
n::Int=10, d::Int=5, noise_std::Float32=0.1f0, seed=nothing
3939
)
40-
rng = MersenneTwister(seed)
40+
rng = Xoshiro(seed)
4141
W = randn(rng, Float32, n, d)
4242
return ContextualStochasticArgmaxBenchmark(n, d, W, noise_std)
4343
end

src/DecisionFocusedLearningBenchmarks.jl

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,17 @@ using .Utils
6666
export AbstractBenchmark, AbstractStochasticBenchmark, AbstractDynamicBenchmark, DataSample
6767
export ExogenousStochasticBenchmark,
6868
EndogenousStochasticBenchmark, ExogenousDynamicBenchmark, EndogenousDynamicBenchmark
69-
export AbstractEnvironment, get_seed, is_terminated, observe, reset!, step!
69+
export AbstractEnvironment, SeededEnvironment
70+
export get_seed, is_terminated, observe, reset!, reset_to_initial!, step!
7071

7172
export Policy, evaluate_policy!
7273

7374
export generate_instance,
74-
generate_sample, generate_dataset, generate_environments, generate_environment
75+
generate_sample,
76+
generate_dataset,
77+
build_environment,
78+
generate_environment,
79+
generate_environments
7580
export generate_scenario, generate_context
7681
export generate_baseline_policies
7782
export SampleAverageApproximation

src/DynamicAssortment/DynamicAssortment.jl

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ using DocStringExtensions: TYPEDEF, TYPEDFIELDS, TYPEDSIGNATURES, SIGNATURES
66
using Distributions: Uniform, Categorical
77
using Flux: Chain, Dense
88
using LinearAlgebra: dot
9-
using Random: Random, AbstractRNG, MersenneTwister
9+
using Random: Random, AbstractRNG, Xoshiro
1010
using Statistics: mean
1111

1212
using Combinatorics: combinations
@@ -100,14 +100,11 @@ end
100100
$TYPEDSIGNATURES
101101
102102
Creates an [`Environment`](@ref) for the dynamic assortment benchmark.
103-
The instance and seed are randomly generated using the provided random number generator.
103+
The instance is randomly generated using the provided random number generator.
104104
"""
105-
function Utils.generate_environment(
106-
b::DynamicAssortmentBenchmark, rng::AbstractRNG; kwargs...
107-
)
105+
function Utils.build_environment(b::DynamicAssortmentBenchmark, rng::AbstractRNG; kwargs...)
108106
instance = Instance(b, rng)
109-
seed = rand(rng, 1:typemax(Int))
110-
return Environment(instance; seed)
107+
return Environment(instance)
111108
end
112109

113110
"""

src/DynamicAssortment/environment.jl

Lines changed: 10 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,13 @@ Environment for the dynamic assortment problem.
66
# Fields
77
$TYPEDFIELDS
88
"""
9-
@kwdef mutable struct Environment{I<:Instance,R<:AbstractRNG,S<:Union{Nothing,Int}} <:
10-
AbstractEnvironment
9+
@kwdef mutable struct Environment{I<:Instance} <: AbstractEnvironment
1110
"associated instance"
1211
instance::I
1312
"current step"
1413
step::Int
1514
"purchase history (used to update hype feature)"
1615
purchase_history::Vector{Int}
17-
"rng"
18-
rng::R
19-
"seed for RNG"
20-
seed::S
2116
"customer utility for each item"
2217
utility::Vector{Float64}
2318
"current full features"
@@ -31,25 +26,21 @@ $TYPEDSIGNATURES
3126
3227
Creates an [`Environment`](@ref) from an [`Instance`](@ref) of the dynamic assortment benchmark.
3328
"""
34-
function Environment(instance::Instance; seed=0, rng::AbstractRNG=MersenneTwister(seed))
29+
function Environment(instance::Instance)
3530
N = item_count(instance)
3631
(; prices, features, starting_hype_and_saturation) = instance
3732
full_features = vcat(
3833
reshape(prices[1:(end - 1)], 1, :), starting_hype_and_saturation, features
3934
)
4035
model = customer_choice_model(instance)
41-
env = Environment(;
36+
return Environment(;
4237
instance,
4338
step=1,
4439
purchase_history=Int[],
45-
rng=rng,
46-
seed=seed,
4740
utility=model(full_features),
4841
features=full_features,
4942
d_features=zeros(2, N),
5043
)
51-
Utils.reset!(env; reset_rng=true)
52-
return env
5344
end
5445

5546
customer_choice_model(env::Environment) = customer_choice_model(env.instance)
@@ -144,36 +135,26 @@ end
144135
"""
145136
$TYPEDSIGNATURES
146137
147-
Outputs the seed of the environment.
148-
"""
149-
Utils.get_seed(env::Environment) = env.seed
150-
151-
"""
152-
$TYPEDSIGNATURES
153-
154138
Resets the environment to the initial state:
155-
- reset the rng if `reset_rng` is true
156139
- reset the step to 1
157140
- reset the features to the initial features
158141
- reset the change in features to zero
159142
- reset the utility to the initial utility
160143
- clear the purchase history
161144
"""
162-
function Utils.reset!(env::Environment; reset_rng=false, seed=env.seed)
163-
reset_rng && Random.seed!(env.rng, seed)
164-
145+
function Utils.reset!(env::Environment, ::AbstractRNG)
165146
env.step = 1
166147

167148
(; prices, starting_hype_and_saturation, features) = env.instance
168-
features = vcat(
149+
full_features = vcat(
169150
reshape(prices[1:(end - 1)], 1, :), starting_hype_and_saturation, features
170151
)
171-
env.features .= features
152+
env.features .= full_features
172153

173154
env.d_features .= 0.0
174155

175156
model = customer_choice_model(env)
176-
env.utility .= model(features)
157+
env.utility .= model(full_features)
177158

178159
empty!(env.purchase_history)
179160
return nothing
@@ -224,12 +205,11 @@ $TYPEDSIGNATURES
224205
Performs one step in the environment given an assortment.
225206
Draw an item according to the customer choice model and updates the environment state.
226207
"""
227-
function Utils.step!(env::Environment, assortment::BitVector)
208+
function Utils.step!(env::Environment, assortment::BitVector, rng::AbstractRNG)
228209
@assert !Utils.is_terminated(env) "Environment is terminated, cannot act!"
229-
r = prices(env)
230210
probs = choice_probabilities(env, assortment)
231-
item = rand(env.rng, Categorical(probs))
232-
reward = r[item]
211+
item = rand(rng, Categorical(probs))
212+
reward = prices(env)[item]
233213
buy_item!(env, item)
234214
return reward
235215
end

src/DynamicVehicleScheduling/DynamicVehicleScheduling.jl

Lines changed: 23 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ using IterTools: partition
1313
using JSON
1414
using JuMP
1515
using Printf: @printf, @sprintf
16-
using Random: Random, AbstractRNG, MersenneTwister, seed!, randperm
16+
using Random: Random, AbstractRNG, Xoshiro, seed!, randperm
1717
using Requires: @require
1818
using Statistics: mean, quantile
1919

@@ -59,50 +59,37 @@ include("policy.jl")
5959
$TYPEDSIGNATURES
6060
6161
Generate environments for the dynamic vehicle scheduling benchmark.
62-
Reads from pre-existing DVRPTW files and creates [`DVSPEnv`](@ref) environments.
62+
Reads from pre-existing DVRPTW files and creates [`DVSPEnv`](@ref) environments
63+
wrapped in [`SeededEnvironment`](@ref) for reproducible episode replay.
6364
"""
6465
function Utils.generate_environments(
65-
b::DynamicVehicleSchedulingBenchmark,
66-
n::Int;
67-
seed=nothing,
68-
rng=MersenneTwister(seed),
69-
kwargs...,
66+
b::DynamicVehicleSchedulingBenchmark, n::Int; seed=nothing, rng=Xoshiro(seed), kwargs...
7067
)
7168
(; max_requests_per_epoch, Δ_dispatch, epoch_duration, two_dimensional_features) = b
7269
files = readdir(datadep"dvrptw"; join=true)
7370
n = min(n, length(files))
71+
gen_rng = Xoshiro(rand(rng, UInt))
72+
seed_rng = Xoshiro(rand(rng, UInt))
7473
return [
75-
generate_environment(
76-
b,
77-
Instance(
78-
read_vsp_instance(files[i]);
79-
max_requests_per_epoch,
80-
Δ_dispatch,
81-
epoch_duration,
82-
two_dimensional_features,
83-
),
84-
rng;
85-
kwargs...,
74+
Utils.SeededEnvironment(
75+
DVSPEnv(
76+
Instance(
77+
read_vsp_instance(files[i]);
78+
max_requests_per_epoch,
79+
Δ_dispatch,
80+
epoch_duration,
81+
two_dimensional_features,
82+
),
83+
gen_rng,
84+
);
85+
seed=rand(seed_rng, UInt),
8686
) for i in 1:n
8787
]
8888
end
8989

9090
"""
9191
$TYPEDSIGNATURES
9292
93-
Creates an environment from an [`Instance`](@ref) of the dynamic vehicle scheduling benchmark.
94-
The seed of the environment is randomly generated using the provided random number generator.
95-
"""
96-
function generate_environment(
97-
::DynamicVehicleSchedulingBenchmark, instance::Instance, rng::AbstractRNG; kwargs...
98-
)
99-
seed = rand(rng, 1:typemax(Int))
100-
return DVSPEnv(instance; seed)
101-
end
102-
103-
"""
104-
$TYPEDSIGNATURES
105-
10693
Returns a linear maximizer for the dynamic vehicle scheduling benchmark, of the form:
10794
θ ↦ argmax_{y} θᵀg(y) + h(y)
10895
"""
@@ -119,8 +106,11 @@ as a `Vector{DataSample}`. Set `reset_env=true` (default) to reset the environme
119106
before solving, or `reset_env=false` to plan from the current state.
120107
"""
121108
function Utils.generate_anticipative_solver(::DynamicVehicleSchedulingBenchmark)
122-
return (env; reset_env=true, kwargs...) -> begin
123-
_, trajectory = anticipative_solver(env; reset_env, kwargs...)
109+
return (env::Utils.SeededEnvironment; reset_env=true, kwargs...) -> begin
110+
# Anchor to the wrapper's initial seed so the solved scenario is reproducible,
111+
# then solve from that state (the env was already reset).
112+
reset_env && Utils.reset_to_initial!(env)
113+
_, trajectory = anticipative_solver(env.env, env.rng; reset_env=false, kwargs...)
124114
return trajectory
125115
end
126116
end

src/DynamicVehicleScheduling/anticipative_solver.jl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,16 @@ For this, it uses the current environment history, so make sure that the environ
4545
"""
4646
function anticipative_solver(
4747
env::DVSPEnv,
48+
rng::AbstractRNG,
4849
scenario=env.scenario;
4950
model_builder=highs_model,
5051
two_dimensional_features=env.instance.two_dimensional_features,
5152
reset_env=true,
5253
nb_epochs=nothing,
53-
seed=get_seed(env),
5454
verbose=false,
5555
)
5656
if reset_env
57-
reset!(env; reset_rng=true, seed)
57+
reset!(env, rng)
5858
scenario = env.scenario
5959
end
6060

0 commit comments

Comments
 (0)