thrust_rl/train/optimizer.rs
1//! Burn optimizer wrapper used by the PPO and DQN trainers.
2//!
3//! After phase 5 of the Burn migration (#82), Burn is the only tensor
4//! backend in the workspace; the backend-agnostic abstraction added in
5//! phase 2b (#92) has collapsed to a single Burn impl. The
6//! [`BackendOptimizer`] trait survives as a minimal interface so the
7//! trainer bodies can hold the optimizer behind a generic without naming
8//! the concrete Burn `Optimizer<M, B>` type.
9
10use anyhow::Result;
11
12/// Burn-side optimizer interface used by PPO and DQN trainers.
13///
14/// Burn's `Optimizer<M, B>` is *move-through*: every gradient step
15/// consumes the module by value and returns the updated copy. This
16/// trait exposes that single fundamental verb plus the
17/// gradient-clipping configuration knob the trainer needs.
18pub trait BackendOptimizer {
19 /// The module type the optimizer steps.
20 type Module;
21
22 /// Stage the maximum global gradient L2-norm.
23 ///
24 /// This only records the cap on the wrapper; the trainer bodies read it
25 /// back via [`BurnOptimizer::grad_clip_norm`] and apply the clip to the
26 /// gradient slice before their move-through `inner_mut().step(...)`
27 /// call (see the joint trainer's per-policy step, issue #239). The
28 /// trait-level [`Self::step_module`] fallback does not itself clip.
29 fn clip_grad_norm(&mut self, max: f64);
30
31 /// Burn-style move-through update.
32 ///
33 /// Consumes `module`, applies the optimizer's staged gradient (with
34 /// any clipping configured by [`Self::clip_grad_norm`]), and returns
35 /// the updated module.
36 fn step_module(&mut self, module: Self::Module) -> Self::Module;
37
38 /// Construction-time learning rate. Exposed for diagnostics.
39 fn learning_rate(&self) -> f64;
40}
41
42// ---------------------------------------------------------------------------
43// Burn impl
44// ---------------------------------------------------------------------------
45
46/// Burn-side optimizer wrapper.
47///
48/// Wraps a Burn `OptimizerAdaptor<O, M, B>` and exposes it through the
49/// [`BackendOptimizer`] trait. The trainer bodies hold one of these and
50/// route their gradient step through [`BackendOptimizer::step_module`].
51pub struct BurnOptimizer<B, M, O>
52where
53 B: burn::tensor::backend::AutodiffBackend,
54 M: burn::module::AutodiffModule<B>,
55 O: burn::optim::Optimizer<M, B>,
56{
57 inner: O,
58 learning_rate: f64,
59 grad_clip_norm: Option<f64>,
60 _marker: core::marker::PhantomData<(B, M)>,
61}
62
63impl<B, M, O> std::fmt::Debug for BurnOptimizer<B, M, O>
64where
65 B: burn::tensor::backend::AutodiffBackend,
66 M: burn::module::AutodiffModule<B>,
67 O: burn::optim::Optimizer<M, B>,
68{
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 f.debug_struct("BurnOptimizer")
71 .field("learning_rate", &self.learning_rate)
72 .field("grad_clip_norm", &self.grad_clip_norm)
73 .field("inner", &"burn::optim::Optimizer<...>")
74 .finish()
75 }
76}
77
78impl<B, M, O> BurnOptimizer<B, M, O>
79where
80 B: burn::tensor::backend::AutodiffBackend,
81 M: burn::module::AutodiffModule<B>,
82 O: burn::optim::Optimizer<M, B>,
83{
84 /// Wrap a Burn optimizer (typically `AdamConfig::new().init()`).
85 pub fn new(inner: O, learning_rate: f64) -> Self {
86 Self { inner, learning_rate, grad_clip_norm: None, _marker: core::marker::PhantomData }
87 }
88
89 /// Borrow the wrapped Burn optimizer.
90 pub fn inner(&self) -> &O {
91 &self.inner
92 }
93
94 /// Mutably borrow the wrapped Burn optimizer. The trainer bodies
95 /// call `inner_mut().step(lr, module, grads)` directly from their
96 /// loss closures.
97 pub fn inner_mut(&mut self) -> &mut O {
98 &mut self.inner
99 }
100
101 /// The currently-staged gradient-norm cap, if any.
102 pub fn grad_clip_norm(&self) -> Option<f64> {
103 self.grad_clip_norm
104 }
105}
106
107impl<B, M, O> BackendOptimizer for BurnOptimizer<B, M, O>
108where
109 B: burn::tensor::backend::AutodiffBackend,
110 M: burn::module::AutodiffModule<B>,
111 O: burn::optim::Optimizer<M, B>,
112{
113 type Module = M;
114
115 fn clip_grad_norm(&mut self, max: f64) {
116 self.grad_clip_norm = Some(max);
117 }
118
119 fn step_module(&mut self, module: Self::Module) -> Self::Module {
120 // Burn's `Optimizer::step` consumes the module by value and
121 // returns the updated copy. The trainer bodies use
122 // `inner_mut().step(lr, module, grads)` directly when they have
123 // gradients to apply; this trait method is the no-grad fallback
124 // used by call sites that just want a default step.
125 module
126 }
127
128 fn learning_rate(&self) -> f64 {
129 self.learning_rate
130 }
131}
132
133// ---------------------------------------------------------------------------
134// Convenience constructors
135// ---------------------------------------------------------------------------
136
137/// Helper: wrap a freshly-built Burn optimizer in a [`BurnOptimizer`].
138pub fn wrap_burn<B, M, O>(inner: O, learning_rate: f64) -> BurnOptimizer<B, M, O>
139where
140 B: burn::tensor::backend::AutodiffBackend,
141 M: burn::module::AutodiffModule<B>,
142 O: burn::optim::Optimizer<M, B>,
143{
144 BurnOptimizer::new(inner, learning_rate)
145}
146
147// ---------------------------------------------------------------------------
148// Result alias for parity with the rest of the crate's training surface.
149// ---------------------------------------------------------------------------
150
151/// Result alias used by trainer-side optimizer plumbing.
152pub type OptimResult<T> = Result<T>;
153
154// ---------------------------------------------------------------------------
155// Tests
156// ---------------------------------------------------------------------------
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 /// Construction smoke test: verify the Burn impl satisfies the trait
163 /// and the move-through step type-checks.
164 #[test]
165 fn burn_optimizer_satisfies_trait() {
166 use burn::{
167 backend::{Autodiff, NdArray},
168 optim::AdamConfig,
169 };
170
171 type B = Autodiff<NdArray<f32>>;
172
173 let device = Default::default();
174 let module = crate::policy::mlp::MlpBurnPolicy::<B>::new(2, 2, 4, &device);
175 let inner_opt = AdamConfig::new().init();
176 let mut opt: BurnOptimizer<B, crate::policy::mlp::MlpBurnPolicy<B>, _> =
177 BurnOptimizer::new(inner_opt, 1e-3);
178
179 opt.clip_grad_norm(0.5);
180 assert_eq!(opt.grad_clip_norm(), Some(0.5));
181
182 // step_module flows the module by value and (in the trait-level
183 // fallback path) hands it back unchanged. The trainer bodies
184 // perform real gradient steps via `inner_mut().step(...)`.
185 let module = opt.step_module(module);
186 let _ = module;
187 assert!((opt.learning_rate() - 1e-3).abs() < 1e-12);
188 }
189}