Skip to main content

optirs_learned/
domain_objectives.rs

1//! Meta-training objectives: the tasks a learned optimizer is trained *on*.
2//!
3//! A learned optimizer's weights can only be trained against something that can
4//! actually be optimized, so meta-training needs a task abstraction with a real
5//! loss and a real gradient. [`MetaObjective`] is that abstraction, and
6//! [`QuadraticObjective`] is the canonical instance: an anisotropic quadratic
7//! bowl whose optimum, loss and gradient are all known in closed form, which is
8//! what makes a "did meta-training help?" assertion checkable rather than a
9//! matter of opinion.
10//!
11//! Introduced alongside the [`crate::gnn_optimizer::meta_training`] entry point
12//! (finding F75: the GNN and NTM optimizers had genuinely-correct forward maths
13//! and no way whatsoever to train their weights).
14
15use scirs2_core::ndarray::Array1;
16use scirs2_core::numeric::Float;
17use std::fmt::Debug;
18
19use crate::error::{OptimError, Result};
20
21/// A differentiable task a learned optimizer can be meta-trained on.
22///
23/// Implementors must be cheap to evaluate: a meta-training run calls
24/// [`Self::loss_and_gradient`] on the order of
25/// `population × iterations × tasks × horizon` times.
26pub trait MetaObjective<T: Float + Debug + Send + Sync + 'static> {
27    /// Number of parameters the task optimizes over.
28    fn dimension(&self) -> usize;
29
30    /// The point a rollout starts from. Must have length [`Self::dimension`].
31    fn initial_parameters(&self) -> Array1<T>;
32
33    /// The loss and its gradient at `params`.
34    ///
35    /// # Errors
36    /// Returns an error if `params` has the wrong length.
37    fn loss_and_gradient(&self, params: &Array1<T>) -> Result<(T, Array1<T>)>;
38
39    /// A short human-readable name, for diagnostics.
40    fn name(&self) -> &str {
41        "meta-objective"
42    }
43}
44
45/// An anisotropic quadratic bowl: `f(x) = ½ Σᵢ cᵢ (xᵢ − x*ᵢ)²`.
46///
47/// The Hessian is `diag(c)`, the unique minimum is at `x*` with `f = 0`, and the
48/// condition number is `max(c) / min(c)` — so a family of these with different
49/// `c` vectors is a controllable benchmark for whether a learned optimizer has
50/// picked up anything about curvature.
51#[derive(Debug, Clone)]
52pub struct QuadraticObjective<T: Float + Debug + Send + Sync + 'static> {
53    curvature: Array1<T>,
54    optimum: Array1<T>,
55    start: Array1<T>,
56    label: String,
57}
58
59impl<T: Float + Debug + Send + Sync + 'static> QuadraticObjective<T> {
60    /// Build a bowl from an explicit per-coordinate curvature vector, with the
61    /// optimum at the origin and every start coordinate at `start_value`.
62    ///
63    /// # Errors
64    /// Returns [`OptimError::InvalidConfig`] for an empty or non-positive
65    /// curvature vector, or a non-finite `start_value`.
66    pub fn from_curvature(curvature: Vec<f64>, start_value: f64) -> Result<Self> {
67        if curvature.is_empty() {
68            return Err(OptimError::InvalidConfig(
69                "a quadratic objective needs at least one coordinate".to_string(),
70            ));
71        }
72        if !start_value.is_finite() {
73            return Err(OptimError::InvalidConfig(
74                "start_value must be finite".to_string(),
75            ));
76        }
77        for value in &curvature {
78            if !(value.is_finite() && *value > 0.0) {
79                return Err(OptimError::InvalidConfig(format!(
80                    "curvature entries must be finite and positive, got {value}"
81                )));
82            }
83        }
84        let dimension = curvature.len();
85        let cast =
86            |v: f64| -> T { scirs2_core::numeric::NumCast::from(v).unwrap_or_else(|| T::zero()) };
87        Ok(Self {
88            curvature: Array1::from_iter(curvature.into_iter().map(cast)),
89            optimum: Array1::zeros(dimension),
90            start: Array1::from_elem(dimension, cast(start_value)),
91            label: "quadratic".to_string(),
92        })
93    }
94
95    /// A `dimension`-dimensional bowl with uniform curvature.
96    ///
97    /// # Errors
98    /// As [`Self::from_curvature`].
99    pub fn isotropic(dimension: usize, curvature: f64, start_value: f64) -> Result<Self> {
100        Self::from_curvature(vec![curvature; dimension], start_value)
101    }
102
103    /// Move the optimum away from the origin.
104    ///
105    /// # Errors
106    /// Returns [`OptimError::InvalidConfig`] if `optimum` has the wrong length or
107    /// a non-finite entry.
108    pub fn with_optimum(mut self, optimum: Vec<f64>) -> Result<Self> {
109        if optimum.len() != self.curvature.len() {
110            return Err(OptimError::InvalidConfig(format!(
111                "optimum has {} coordinates but the objective has {}",
112                optimum.len(),
113                self.curvature.len()
114            )));
115        }
116        if optimum.iter().any(|v| !v.is_finite()) {
117            return Err(OptimError::InvalidConfig(
118                "optimum entries must be finite".to_string(),
119            ));
120        }
121        self.optimum = Array1::from_iter(
122            optimum
123                .into_iter()
124                .map(|v| scirs2_core::numeric::NumCast::from(v).unwrap_or_else(|| T::zero())),
125        );
126        Ok(self)
127    }
128
129    /// Attach a diagnostic label.
130    pub fn labelled(mut self, label: impl Into<String>) -> Self {
131        self.label = label.into();
132        self
133    }
134
135    /// `max(c) / min(c)`, the condition number of the Hessian.
136    pub fn condition_number(&self) -> f64 {
137        let mut low = f64::INFINITY;
138        let mut high = 0.0_f64;
139        for value in self.curvature.iter() {
140            let v = value.to_f64().unwrap_or(0.0);
141            low = low.min(v);
142            high = high.max(v);
143        }
144        if low > 0.0 && low.is_finite() {
145            high / low
146        } else {
147            f64::INFINITY
148        }
149    }
150}
151
152impl<T: Float + Debug + Send + Sync + 'static> MetaObjective<T> for QuadraticObjective<T> {
153    fn dimension(&self) -> usize {
154        self.curvature.len()
155    }
156
157    fn initial_parameters(&self) -> Array1<T> {
158        self.start.clone()
159    }
160
161    fn loss_and_gradient(&self, params: &Array1<T>) -> Result<(T, Array1<T>)> {
162        if params.len() != self.curvature.len() {
163            return Err(OptimError::InvalidConfig(format!(
164                "quadratic objective has {} coordinates but received {}",
165                self.curvature.len(),
166                params.len()
167            )));
168        }
169        let half = scirs2_core::numeric::NumCast::from(0.5).unwrap_or_else(|| T::one());
170        let mut loss = T::zero();
171        let mut gradient = Array1::zeros(params.len());
172        for i in 0..params.len() {
173            let offset = params[i] - self.optimum[i];
174            let scaled = self.curvature[i] * offset;
175            loss = loss + half * scaled * offset;
176            gradient[i] = scaled;
177        }
178        Ok((loss, gradient))
179    }
180
181    fn name(&self) -> &str {
182        &self.label
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn quadratic_loss_and_gradient_match_the_closed_form() {
192        let task = QuadraticObjective::<f64>::from_curvature(vec![2.0, 8.0], 0.0)
193            .expect("task")
194            .with_optimum(vec![1.0, -0.5])
195            .expect("optimum")
196            .labelled("test-bowl");
197        assert_eq!(task.dimension(), 2);
198        assert_eq!(task.name(), "test-bowl");
199        assert!((task.condition_number() - 4.0).abs() < 1e-12);
200
201        // At x = (3, 0.5): offsets (2, 1) -> loss = ½(2·4 + 8·1) = 8,
202        // gradient = (2·2, 8·1) = (4, 8).
203        let point = Array1::from_vec(vec![3.0, 0.5]);
204        let (loss, gradient) = task.loss_and_gradient(&point).expect("evaluate");
205        assert!((loss - 8.0).abs() < 1e-12, "loss {loss}");
206        assert!((gradient[0] - 4.0).abs() < 1e-12);
207        assert!((gradient[1] - 8.0).abs() < 1e-12);
208
209        // At the optimum the loss and gradient are exactly zero.
210        let (loss, gradient) = task
211            .loss_and_gradient(&Array1::from_vec(vec![1.0, -0.5]))
212            .expect("evaluate");
213        assert_eq!(loss, 0.0);
214        assert!(gradient.iter().all(|g| *g == 0.0));
215    }
216
217    /// The analytic gradient must match a central finite difference — the whole
218    /// point of a meta-objective is that its gradient is trustworthy.
219    #[test]
220    fn quadratic_gradient_matches_finite_differences() {
221        let task =
222            QuadraticObjective::<f64>::from_curvature(vec![0.7, 3.1, 11.0], 0.0).expect("task");
223        let point = Array1::from_vec(vec![0.4, -1.2, 2.5]);
224        let (_, analytic) = task.loss_and_gradient(&point).expect("analytic");
225
226        let h = 1e-6;
227        for i in 0..point.len() {
228            let mut plus = point.clone();
229            let mut minus = point.clone();
230            plus[i] += h;
231            minus[i] -= h;
232            let (loss_plus, _) = task.loss_and_gradient(&plus).expect("plus");
233            let (loss_minus, _) = task.loss_and_gradient(&minus).expect("minus");
234            let numeric = (loss_plus - loss_minus) / (2.0 * h);
235            assert!(
236                (analytic[i] - numeric).abs() < 1e-6,
237                "coordinate {i}: analytic {} vs numeric {numeric}",
238                analytic[i]
239            );
240        }
241    }
242
243    #[test]
244    fn invalid_objectives_are_rejected() {
245        assert!(QuadraticObjective::<f64>::from_curvature(vec![], 0.0).is_err());
246        assert!(QuadraticObjective::<f64>::from_curvature(vec![0.0], 0.0).is_err());
247        assert!(QuadraticObjective::<f64>::from_curvature(vec![-1.0], 0.0).is_err());
248        assert!(QuadraticObjective::<f64>::from_curvature(vec![f64::NAN], 0.0).is_err());
249        assert!(QuadraticObjective::<f64>::from_curvature(vec![1.0], f64::INFINITY).is_err());
250        assert!(QuadraticObjective::<f64>::isotropic(3, 1.0, 0.5)
251            .expect("task")
252            .with_optimum(vec![0.0, 0.0])
253            .is_err());
254        let task = QuadraticObjective::<f64>::isotropic(2, 1.0, 0.5).expect("task");
255        assert!(task
256            .loss_and_gradient(&Array1::from_vec(vec![1.0]))
257            .is_err());
258    }
259}