Skip to main content

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}