Skip to content

Commit 6465859

Browse files
feat: Mirror Descent algorithm (#12)
* feat: implement mirror descent * ci: move formatting test to its own ci job, and only run it on latest julia version --------- Co-authored-by: BatyLeo <leo.baty67@gmail.com>
1 parent fd9ac89 commit 6465859

8 files changed

Lines changed: 444 additions & 12 deletions

File tree

.github/workflows/Format.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: Format
2+
on:
3+
push:
4+
branches:
5+
- main
6+
pull_request:
7+
workflow_dispatch:
8+
concurrency:
9+
group: ${{ github.workflow }}-${{ github.ref }}
10+
cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }}
11+
jobs:
12+
format-check:
13+
name: JuliaFormatter
14+
runs-on: ubuntu-latest
15+
timeout-minutes: 10
16+
permissions:
17+
actions: write
18+
contents: read
19+
steps:
20+
- uses: actions/checkout@v7
21+
- uses: julia-actions/setup-julia@v3
22+
with:
23+
version: '1'
24+
- uses: julia-actions/cache@v3
25+
- name: Run JuliaFormatter
26+
shell: julia --color=yes {0}
27+
run: |
28+
using Pkg
29+
Pkg.activate(; temp=true)
30+
Pkg.add(name="JuliaFormatter", version="2")
31+
using JuliaFormatter
32+
if !format(".", verbose=true, overwrite=false)
33+
@error "Code is not formatted. Run `julia -e 'using JuliaFormatter; format(\".\")'` locally."
34+
exit(1)
35+
end

Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ UnicodePlots = "b8865327-cd53-5732-bb35-84acbb429228"
1919
ValueHistories = "98cad3c8-aec3-5f06-8e41-884608649ab7"
2020

2121
[compat]
22-
DecisionFocusedLearningBenchmarks = "0.5.0, 0.6"
22+
DecisionFocusedLearningBenchmarks = "0.6.1"
2323
DocStringExtensions = "0.9.5"
2424
Flux = "0.16.9"
2525
InferOpt = "0.7.1"

src/DecisionFocusedLearningAlgorithms.jl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ include("algorithms/abstract_algorithm.jl")
2525
include("algorithms/supervised/fyl.jl")
2626
include("algorithms/supervised/anticipative_imitation.jl")
2727
include("algorithms/supervised/dagger.jl")
28+
include("algorithms/mirror_descent/mirror_descent.jl")
2829

2930
export TrainingContext
3031

@@ -41,7 +42,7 @@ export AbstractMetric,
4142

4243
export AbstractAlgorithm, AbstractImitationAlgorithm
4344
export PerturbedFenchelYoungLossImitation,
44-
DAgger, AnticipativeImitation, train_policy!, train_policy
45+
DAgger, AnticipativeImitation, train_policy!, train_policy, MirrorDescent
4546
export AbstractPolicy, DFLPolicy
4647

4748
end
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
"""
2+
$TYPEDEF
3+
4+
Mirror Descent algorithm for learning coordinated solutions.
5+
6+
This algorithm is designed for stochastic benchmarks.
7+
8+
Reference: <https://arxiv.org/abs/2505.04757>
9+
10+
# Fields
11+
$TYPEDFIELDS
12+
"""
13+
@kwdef struct MirrorDescent{A<:PerturbedFenchelYoungLossImitation} <: AbstractAlgorithm
14+
"inner imitation algorithm for supervised learning"
15+
inner_algorithm::A = PerturbedFenchelYoungLossImitation()
16+
end
17+
18+
# Helper function to augment a dataset with anticipative solutions
19+
function _augment_with_anticipative(dataset, anticipative_solver)
20+
return map(dataset) do sample
21+
y = anticipative_solver(sample.scenario; sample.context...)
22+
return DataSample(sample; y=y)
23+
end
24+
end
25+
26+
# Helper function to create a perturbed sample
27+
function _perturbed_sample(sample, model, perturbed_solver, is_minimization, κ)
28+
θ = model(sample.x)
29+
signed_θ = is_minimization ? -κ * θ : κ * θ
30+
y = perturbed_solver(signed_θ; scenario=sample.scenario, sample.context...)
31+
return DataSample(sample; y=y)
32+
end
33+
34+
# Helper function to augment a dataset with perturbed solutions
35+
function _augment_with_perturbed(dataset, model, perturbed_solver, is_minimization; κ=1.0)
36+
return map(dataset) do sample
37+
return _perturbed_sample(sample, model, perturbed_solver, is_minimization, κ)
38+
end
39+
end
40+
41+
# Helper function to augment a dataset with perturbed solutions in-place
42+
function _augment_with_perturbed!(dataset, model, perturbed_solver, is_minimization; κ=1.0)
43+
for i in eachindex(dataset)
44+
dataset[i] = _perturbed_sample(
45+
dataset[i], model, perturbed_solver, is_minimization, κ
46+
)
47+
end
48+
return dataset
49+
end
50+
51+
# Helper function to run the mirror descent loop for a given number of iterations
52+
function _mirror_descent_loop(
53+
algorithm,
54+
policy,
55+
input_dataset,
56+
perturbed_solver,
57+
is_minimization;
58+
md_iters,
59+
epochs,
60+
κ,
61+
metrics,
62+
verbose,
63+
)
64+
# Allocate the perturbed dataset once. Subsequent iterations mutate in place.
65+
dataset = _augment_with_perturbed(
66+
input_dataset, policy.statistical_model, perturbed_solver, is_minimization; κ
67+
)
68+
return map(1:md_iters) do n_it
69+
verbose && println("Mirror descent iteration $n_it / $md_iters")
70+
if n_it > 1
71+
_augment_with_perturbed!(
72+
dataset, policy.statistical_model, perturbed_solver, is_minimization; κ
73+
)
74+
end
75+
return train_policy!(algorithm.inner_algorithm, policy, dataset; epochs, metrics)
76+
end
77+
end
78+
79+
"""
80+
$TYPEDSIGNATURES
81+
82+
Train a DFLPolicy using the Mirror Descent algorithm on a provided training dataset.
83+
84+
When `imitation_start=true`, the first iteration is a pure imitation step using
85+
`anticipative_solver`; subsequent iterations are the mirror descent loop using
86+
`perturbed_anticipative_solver`.
87+
88+
# Arguments
89+
- `iterations=10`: total number of mirror descent iterations (includes the imitation step
90+
when `imitation_start=true`)
91+
- `epochs=10`: number of inner training epochs per mirror descent iteration
92+
- `κ=1.0`: scaling factor applied to `θ` before passing it to the perturbed solver
93+
- `metrics::Tuple=()`: metrics forwarded to the inner training algorithm
94+
- `verbose=false`: if true, prints progress at each iteration
95+
- `imitation_start=true`: if true, run a pure imitation step against the
96+
anticipative solver as the first iteration
97+
- `is_minimization=true`: set to false if the objective is a maximization problem
98+
"""
99+
function train_policy!(
100+
algorithm::MirrorDescent,
101+
policy::DFLPolicy,
102+
train_dataset,
103+
anticipative_solver,
104+
perturbed_anticipative_solver;
105+
epochs=10,
106+
iterations=10,
107+
κ=1.0,
108+
metrics::Tuple=(),
109+
verbose::Bool=false,
110+
imitation_start::Bool=true,
111+
is_minimization::Bool=true,
112+
)
113+
if imitation_start
114+
verbose && println("Imitation step")
115+
dataset = _augment_with_anticipative(train_dataset, anticipative_solver)
116+
h_imitation = train_policy!(
117+
algorithm.inner_algorithm, policy, dataset; epochs, metrics
118+
)
119+
md_iters = iterations - 1
120+
md_iters >= 1 || return [h_imitation]
121+
rest = _mirror_descent_loop(
122+
algorithm,
123+
policy,
124+
dataset,
125+
perturbed_anticipative_solver,
126+
is_minimization;
127+
md_iters,
128+
epochs,
129+
κ,
130+
metrics,
131+
verbose,
132+
)
133+
return pushfirst!(rest, h_imitation)
134+
end
135+
136+
# else
137+
return _mirror_descent_loop(
138+
algorithm,
139+
policy,
140+
train_dataset,
141+
perturbed_anticipative_solver,
142+
is_minimization;
143+
md_iters=iterations,
144+
epochs,
145+
κ,
146+
metrics,
147+
verbose,
148+
)
149+
end
150+
151+
"""
152+
$TYPEDSIGNATURES
153+
154+
Generate a dataset for the provided benchmark and train a DFLPolicy using the Mirror Descent algorithm.
155+
156+
This high-level wrapper builds every component (`model`, `maximizer`,
157+
`anticipative_solver`, `parametric_anticipative_solver`, `train_dataset`) from the
158+
benchmark, each exposed as an optional keyword so callers can override any of them
159+
without dropping to [`train_policy!`](@ref).
160+
161+
# Arguments
162+
- `dataset_size=30`: number of samples in the training dataset
163+
(used when `train_dataset` is not provided)
164+
- `nb_scenarios=1`: number of scenarios per instance
165+
(used when `train_dataset` is not provided)
166+
- `context_per_instance=1`: number of contexts per instance
167+
(used when `train_dataset` is not provided)
168+
- `seed=nothing`: random seed for reproducibility
169+
(used in `model` and `train_dataset` when not provided)
170+
- `model`: statistical model to wrap in the policy
171+
(defaults to `generate_statistical_model(benchmark; seed)`)
172+
- `maximizer`: combinatorial oracle to wrap in the policy
173+
(defaults to `generate_maximizer(benchmark)`)
174+
- `anticipative_solver`: oracle used in pure-imitation iterations
175+
(defaults to `generate_anticipative_solver(benchmark)`)
176+
- `parametric_anticipative_solver`: parametric oracle wrapped in `PerturbedAdditive` for
177+
mirror-descent iterations (defaults to `generate_parametric_anticipative_solver(benchmark)`)
178+
- `train_dataset`: training dataset (defaults to `generate_dataset(benchmark, dataset_size; ...)`)
179+
- `epochs=10`: number of inner training epochs per mirror descent iteration
180+
- `iterations=10`: total number of mirror descent iterations
181+
- `κ=1.0`: scaling factor applied to `θ` before passing it to the perturbed solver
182+
- `metrics::Tuple=()`: metrics forwarded to the inner training algorithm
183+
- `verbose=false`: if true, prints a banner at each iteration
184+
- `imitation_start=true`: if true, run a pure imitation step against the anticipative solver as the
185+
first iteration
186+
"""
187+
function train_policy(
188+
algorithm::MirrorDescent,
189+
benchmark::ExogenousStochasticBenchmark;
190+
dataset_size=30,
191+
nb_scenarios=1,
192+
context_per_instance=1,
193+
seed=nothing,
194+
model=generate_statistical_model(benchmark; seed=seed),
195+
maximizer=generate_maximizer(benchmark),
196+
anticipative_solver=generate_anticipative_solver(benchmark),
197+
parametric_anticipative_solver=generate_parametric_anticipative_solver(benchmark),
198+
train_dataset=generate_dataset(
199+
benchmark,
200+
dataset_size;
201+
nb_scenarios=nb_scenarios,
202+
contexts_per_instance=context_per_instance,
203+
seed=seed,
204+
),
205+
epochs=10,
206+
iterations=10,
207+
κ=1.0,
208+
metrics::Tuple=(),
209+
verbose::Bool=false,
210+
imitation_start::Bool=true,
211+
)
212+
policy = DFLPolicy(model, maximizer)
213+
214+
(; nb_samples, ε, threaded) = algorithm.inner_algorithm
215+
perturbed_anticipative_solver = PerturbedAdditive(
216+
(θ; scenario, kwargs...) -> parametric_anticipative_solver(θ, scenario; kwargs...);
217+
ε=κ * ε,
218+
nb_samples=nb_samples,
219+
seed=seed,
220+
threaded=threaded,
221+
)
222+
223+
histories_per_iteration = train_policy!(
224+
algorithm,
225+
policy,
226+
train_dataset,
227+
anticipative_solver,
228+
perturbed_anticipative_solver;
229+
epochs=epochs,
230+
iterations=iterations,
231+
κ=κ,
232+
metrics=metrics,
233+
verbose=verbose,
234+
imitation_start=imitation_start,
235+
is_minimization=is_minimization_problem(benchmark),
236+
)
237+
238+
return histories_per_iteration, policy
239+
end

test/Project.toml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ DecisionFocusedLearningBenchmarks = "2fbe496a-299b-4c81-bab5-c44dfc55cf20"
55
Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
66
InferOpt = "4846b161-c94e-4150-8dac-c7ae193c601f"
77
JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b"
8-
JuliaFormatter = "98e50ef6-434e-11e9-1051-2b60c6c9e899"
98
MLUtils = "f1d291b0-491e-4a28-83b9-f70985020b54"
9+
Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2"
1010
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
1111
ValueHistories = "98cad3c8-aec3-5f06-8e41-884608649ab7"
1212

@@ -16,9 +16,8 @@ DecisionFocusedLearningAlgorithms = {path = ".."}
1616
[compat]
1717
Aqua = "0.8"
1818
DecisionFocusedLearningAlgorithms = "0.2.0"
19-
DecisionFocusedLearningBenchmarks = "0.5"
19+
DecisionFocusedLearningBenchmarks = "0.6.1"
2020
Documenter = "1"
21-
JuliaFormatter = "2"
2221
MLUtils = "0.4"
2322
Test = "1"
2423
ValueHistories = "0.5"

test/code.jl

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
using Aqua
22
using Documenter
33
using JET
4-
using JuliaFormatter
54

65
using DecisionFocusedLearningAlgorithms
76

@@ -20,12 +19,6 @@ end
2019
)
2120
end
2221

23-
@testset "JuliaFormatter" begin
24-
@test JuliaFormatter.format(
25-
DecisionFocusedLearningAlgorithms; verbose=false, overwrite=false
26-
)
27-
end
28-
2922
@testset "Documenter" begin
3023
Documenter.doctest(DecisionFocusedLearningAlgorithms)
3124
end

0 commit comments

Comments
 (0)