Skip to main content

fcmaes_core/
pgpe.rs

1//! Parameter-Exploring Policy Gradients (PGPE).
2//!
3//! PGPE searches the parameters of a sampling distribution instead of
4//! perturbing actions. This implementation combines symmetric (“mirrored”)
5//! sampling with an Adam update of the distribution center.
6//!
7//! # Reference
8//!
9//! F. Sehnke, C. Osendorfer, T. Rückstieß, A. Graves, J. Peters, and
10//! J. Schmidhuber, [“Parameter-Exploring Policy
11//! Gradients”](https://doi.org/10.1016/j.neunet.2009.12.004), *Neural
12//! Networks* 23(4), 551–559 (2010).
13//!
14//! # Example
15//!
16//! ```
17//! use fcmaes_core::{Fitness, Pgpe, PgpeParams};
18//!
19//! let fit = Fitness::bounded(6, 1, &[-3.0; 6], &[3.0; 6]);
20//! let params = PgpeParams {
21//!     max_evaluations: 1_024,
22//!     seed: 19,
23//!     ..Default::default()
24//! };
25//! let mut pgpe = Pgpe::new(fit, &[1.0; 6], &[0.4; 6], &params);
26//! let result = pgpe.optimize_batch(|population| {
27//!     population
28//!         .iter()
29//!         .map(|x| x.iter().map(|v| v * v).sum())
30//!         .collect()
31//! });
32//! assert!(result.y.is_finite());
33//! ```
34
35use nalgebra::DVector;
36
37use crate::fitness::Fitness;
38use crate::rng::Rng;
39
40/// Outcome of a PGPE run.
41#[derive(Clone, Debug)]
42pub struct PgpeResult {
43    /// Best decoded decision vector found.
44    pub x: Vec<f64>,
45    /// Objective value at [`x`](Self::x).
46    pub y: f64,
47    /// Number of objective evaluations charged to the run.
48    pub evaluations: u64,
49    /// Number of completed distribution updates.
50    pub iterations: i32,
51    /// Termination code; `1` means `stop_fitness` was reached.
52    pub stop: i32,
53}
54
55/// Tunable inputs for [`Pgpe::new`].
56#[derive(Clone, Debug)]
57pub struct PgpeParams {
58    /// Population size; the implementation rounds odd values up for mirrored pairs.
59    pub popsize: i32,
60    /// Maximum number of objective evaluations.
61    pub max_evaluations: u64,
62    /// Stop after finding an objective value strictly below this threshold.
63    pub stop_fitness: f64,
64    /// Number of updates over which the center learning rate decays.
65    pub lr_decay_steps: i32,
66    /// Use rank-normalized utilities instead of raw objective differences.
67    pub use_ranking: bool,
68    /// Initial Adam learning rate for the distribution center.
69    pub center_learning_rate: f64,
70    /// Learning rate for the coordinate-wise standard deviations.
71    pub stdev_learning_rate: f64,
72    /// Maximum relative standard-deviation change per update.
73    pub stdev_max_change: f64,
74    /// Adam first-moment decay coefficient.
75    pub b1: f64,
76    /// Adam second-moment decay coefficient.
77    pub b2: f64,
78    /// Adam numerical-stability constant.
79    pub eps: f64,
80    /// Multiplicative learning-rate decay coefficient.
81    pub decay_coef: f64,
82    /// Seed for the optimizer's independent random stream.
83    pub seed: u64,
84    /// Additional run identifier mixed into [`seed`](Self::seed).
85    pub runid: i64,
86}
87
88impl Default for PgpeParams {
89    fn default() -> Self {
90        Self {
91            popsize: 32,
92            max_evaluations: 100_000,
93            stop_fitness: f64::NEG_INFINITY,
94            lr_decay_steps: 1000,
95            use_ranking: true,
96            center_learning_rate: 0.15,
97            stdev_learning_rate: 0.1,
98            stdev_max_change: 0.2,
99            b1: 0.9,
100            b2: 0.999,
101            eps: 1e-8,
102            decay_coef: 1.0,
103            seed: 0,
104            runid: 0,
105        }
106    }
107}
108
109fn sort_index(v: &[f64]) -> Vec<usize> {
110    let mut idx: Vec<usize> = (0..v.len()).collect();
111    idx.sort_by(|&a, &b| v[a].partial_cmp(&v[b]).unwrap_or(std::cmp::Ordering::Equal));
112    idx
113}
114
115/// ADAM optimizer for the distribution center (the C++ `ADAM`).
116struct Adam {
117    x: DVector<f64>,
118    m: DVector<f64>,
119    v: DVector<f64>,
120    b1: f64,
121    b2: f64,
122    eps: f64,
123    center_lr: f64,
124    decay_coef: f64,
125}
126
127impl Adam {
128    fn new(x0: &DVector<f64>, b1: f64, b2: f64, eps: f64, center_lr: f64, decay_coef: f64) -> Self {
129        let dim = x0.len();
130        Adam {
131            x: x0.clone(),
132            m: DVector::zeros(dim),
133            v: DVector::zeros(dim),
134            b1,
135            b2,
136            eps,
137            center_lr,
138            decay_coef,
139        }
140    }
141
142    fn step_size(&self, i: i32) -> f64 {
143        self.center_lr * self.decay_coef.powi(i)
144    }
145
146    fn update(&mut self, i: i32, g: &DVector<f64>) {
147        self.m = g * (1.0 - self.b1) + &self.m * self.b1;
148        self.v = g.map(|v| v * v) * (1.0 - self.b2) + &self.v * self.b2;
149        let bc1 = 1.0 / (1.0 - self.b1.powi(i + 1));
150        let bc2 = 1.0 / (1.0 - self.b2.powi(i + 1));
151        let mhat = &self.m * bc1;
152        let vhat = &self.v * bc2;
153        let step = self.step_size(i);
154        let delta = DVector::from_iterator(
155            self.x.len(),
156            (0..self.x.len()).map(|k| step * mhat[k] / (vhat[k].sqrt() + self.eps)),
157        );
158        self.x -= delta;
159    }
160}
161
162/// Stateful PGPE optimizer with batch and ask/tell evaluation interfaces.
163pub struct Pgpe {
164    fitfun: Fitness,
165    rng: Rng,
166    dim: usize,
167    popsize: usize,
168    max_evaluations: u64,
169    stopfitness: f64,
170    lr_decay_steps: i32,
171    use_ranking: bool,
172    stdev_learning_rate: f64,
173    stdev_max_change: f64,
174
175    adam: Adam,
176    center: DVector<f64>,
177    stdev: DVector<f64>,
178    scaled_noises: Vec<DVector<f64>>, // n columns
179    pop_x: Vec<DVector<f64>>,         // decoded population (popsize)
180
181    best_x: DVector<f64>,
182    best_y: f64,
183    iterations: i32,
184    stop: i32,
185    external_evaluations: u64,
186}
187
188impl Pgpe {
189    /// Create a PGPE distribution centered on `guess`.
190    ///
191    /// `input_sigma` supplies one initial standard deviation per dimension.
192    pub fn new(mut fitfun: Fitness, guess: &[f64], input_sigma: &[f64], p: &PgpeParams) -> Self {
193        let dim = fitfun.dim();
194        fitfun.reset_evaluations();
195        let mut popsize = if p.popsize > 0 {
196            p.popsize as usize
197        } else {
198            4 * dim
199        };
200        if popsize % 2 == 1 {
201            popsize += 1;
202        }
203        let center = DVector::from_vec(fitfun.encode(guess));
204        let stdev = if input_sigma.len() == 1 {
205            DVector::from_element(dim, input_sigma[0])
206        } else {
207            DVector::from_row_slice(input_sigma)
208        };
209        // ADAM optimizes the center in encoded space. (The C++ seeded ADAM with
210        // the raw guess while `center` was encoded, so after the first tell the
211        // center jumped to raw coordinates in normalized space — a latent
212        // inconsistency; seeding ADAM with the encoded center fixes it.)
213        let adam = Adam::new(
214            &center,
215            p.b1,
216            p.b2,
217            p.eps,
218            p.center_learning_rate,
219            p.decay_coef,
220        );
221        Pgpe {
222            dim,
223            popsize,
224            max_evaluations: if p.max_evaluations > 0 {
225                p.max_evaluations
226            } else {
227                50_000
228            },
229            stopfitness: p.stop_fitness,
230            lr_decay_steps: p.lr_decay_steps.max(1),
231            use_ranking: p.use_ranking,
232            stdev_learning_rate: p.stdev_learning_rate.abs(),
233            stdev_max_change: p.stdev_max_change.abs(),
234            adam,
235            center,
236            stdev,
237            scaled_noises: vec![],
238            pop_x: vec![DVector::zeros(dim); popsize],
239            best_x: DVector::zeros(dim),
240            best_y: f64::MAX,
241            iterations: 0,
242            stop: 0,
243            external_evaluations: 0,
244            rng: Rng::new(p.seed.wrapping_add(p.runid as u64)),
245            fitfun,
246        }
247    }
248
249    /// Number of decision variables.
250    pub fn dim(&self) -> usize {
251        self.dim
252    }
253    /// Number of mirrored samples in each population.
254    pub fn popsize(&self) -> usize {
255        self.popsize
256    }
257    /// Current termination code, or zero while the optimizer can continue.
258    pub fn stop(&self) -> i32 {
259        self.stop
260    }
261
262    /// Symmetric sampling: returns `popsize` *encoded* candidates, interleaved
263    /// `[center+n0, center-n0, center+n1, center-n1, ...]`, storing the noises.
264    fn ask_encoded(&mut self) -> Vec<DVector<f64>> {
265        let n = self.popsize / 2;
266        self.scaled_noises = (0..n)
267            .map(|_| {
268                let noise =
269                    DVector::from_iterator(self.dim, (0..self.dim).map(|_| self.rng.gaussian()));
270                noise.component_mul(&self.stdev)
271            })
272            .collect();
273        let mut xs = Vec::with_capacity(self.popsize);
274        for p in 0..n {
275            xs.push(&self.center + &self.scaled_noises[p]);
276            xs.push(&self.center - &self.scaled_noises[p]);
277        }
278        xs
279    }
280
281    /// Decoded, in-bounds population (rows), stored for the reinforce update.
282    fn ask_pop_internal(&mut self) -> Vec<Vec<f64>> {
283        let xs = self.ask_encoded();
284        self.pop_x = xs
285            .iter()
286            .map(|c| {
287                let feasible = self.fitfun.closest_feasible_normed(c.as_slice());
288                DVector::from_vec(self.fitfun.decode(&feasible))
289            })
290            .collect();
291        self.pop_x.iter().map(|c| c.as_slice().to_vec()).collect()
292    }
293
294    fn process_scores(&self, ys: &[f64]) -> DVector<f64> {
295        if self.use_ranking {
296            let n = ys.len();
297            let order = sort_index(ys);
298            let mut ranks = DVector::zeros(n);
299            for (i, &idx) in order.iter().enumerate() {
300                ranks[idx] = i as f64 / n as f64 - 0.5;
301            }
302            ranks
303        } else {
304            DVector::from_row_slice(ys)
305        }
306    }
307
308    /// grad_center, grad_stdev from the REINFORCE estimator (the C++
309    /// `compute_reinforce_update`).
310    fn reinforce(&self, pop_y: &DVector<f64>) -> (DVector<f64>, DVector<f64>) {
311        let n = self.popsize / 2;
312        let mean_all = pop_y.mean();
313        let mut grad_center = DVector::zeros(self.dim);
314        let mut grad_stdev = DVector::zeros(self.dim);
315        for i in 0..self.dim {
316            let mut gc = 0.0;
317            let mut gs = 0.0;
318            for p in 0..n {
319                let fit1 = pop_y[2 * p];
320                let fit2 = pop_y[2 * p + 1];
321                let score = fit1 - fit2;
322                let avg = 0.5 * (fit1 + fit2);
323                let sn = self.scaled_noises[p][i];
324                gc += sn * score * 0.5;
325                gs += (avg - mean_all) * (sn * sn - self.stdev[i] * self.stdev[i]) / self.stdev[i];
326            }
327            grad_center[i] = gc / n as f64;
328            grad_stdev[i] = gs / n as f64;
329        }
330        (grad_center, grad_stdev)
331    }
332
333    fn update_stdev(&self, grad: &DVector<f64>) -> DVector<f64> {
334        DVector::from_iterator(
335            self.dim,
336            (0..self.dim).map(|i| {
337                let allowed = self.stdev[i].abs() * self.stdev_max_change;
338                let lo = self.stdev[i] - allowed;
339                let hi = self.stdev[i] + allowed;
340                (self.stdev[i] + self.stdev_learning_rate * grad[i]).clamp(lo, hi)
341            }),
342        )
343    }
344
345    fn tell(&mut self, ys: &[f64]) -> i32 {
346        let neg: Vec<f64> = ys.iter().map(|y| -y).collect();
347        let pop_y = self.process_scores(&neg);
348        // Track the *true* best fitness/point. (The C++ tracked `-max(process_
349        // scores(-ys))`, which under ranking is a rank value, not a fitness, so
350        // its reported best was unusable; using the raw ys is correct and keeps
351        // the result meaningful for retry/comparison.)
352        let mut best_p = 0;
353        for p in 1..self.popsize {
354            if ys[p] < ys[best_p] {
355                best_p = p;
356            }
357        }
358        if ys[best_p] < self.best_y {
359            self.best_y = ys[best_p];
360            self.best_x = self.pop_x[best_p].clone();
361            if self.best_y < self.stopfitness {
362                self.stop = 1;
363            }
364        }
365        let (grad_center, grad_stdev) = self.reinforce(&pop_y);
366        self.adam
367            .update(self.iterations / self.lr_decay_steps, &(-grad_center));
368        self.iterations += 1;
369        self.center = self.adam.x.clone();
370        self.stdev = self.update_stdev(&grad_stdev);
371        self.stop
372    }
373
374    fn make_result(&self, evaluations: u64) -> PgpeResult {
375        PgpeResult {
376            x: self.best_x.as_slice().to_vec(),
377            y: self.best_y,
378            evaluations,
379            iterations: self.iterations,
380            stop: self.stop,
381        }
382    }
383
384    /// Generational loop evaluating each population through a batch closure.
385    pub fn optimize_batch<F>(&mut self, mut eval_batch: F) -> PgpeResult
386    where
387        F: FnMut(&[Vec<f64>]) -> Vec<f64>,
388    {
389        self.iterations = 0;
390        self.fitfun.reset_evaluations();
391        while self.fitfun.evaluations() < self.max_evaluations
392            && !self.fitfun.terminate()
393            && self.stop == 0
394        {
395            let rows = self.ask_pop_internal();
396            let mut ys = eval_batch(&rows);
397            for v in ys.iter_mut() {
398                if !v.is_finite() {
399                    *v = crate::fitness::NAN_REPLACEMENT;
400                }
401            }
402            self.fitfun.incr_evaluations(self.popsize as u64);
403            self.tell(&ys);
404        }
405        self.make_result(self.fitfun.evaluations())
406    }
407
408    // ---- ask/tell interface (mirrors PgpeState::Impl) ----
409
410    /// Sample and return one decoded population.
411    pub fn ask_pop(&mut self) -> Vec<Vec<f64>> {
412        self.ask_pop_internal()
413    }
414
415    /// Update the distribution from objective values corresponding to
416    /// [`ask_pop`](Self::ask_pop), returning the current stop code.
417    pub fn tell_pop(&mut self, ys: &[f64]) -> i32 {
418        let stop = self.tell(ys);
419        self.external_evaluations += ys.len() as u64;
420        stop
421    }
422
423    /// Return the most recently sampled decoded population.
424    pub fn population(&self) -> Vec<Vec<f64>> {
425        self.pop_x.iter().map(|c| c.as_slice().to_vec()).collect()
426    }
427
428    /// Return the current best result for an ask/tell run.
429    pub fn result(&self) -> PgpeResult {
430        self.make_result(self.external_evaluations)
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    fn sphere(x: &[f64]) -> f64 {
439        x.iter().map(|v| v * v).sum()
440    }
441
442    #[test]
443    fn converges_on_sphere() {
444        // use_ranking=false so best_y is the true fitness.
445        let mut fit = Fitness::bounded(5, 1, &[-5.0; 5], &[5.0; 5]);
446        fit.set_normalize(true);
447        let params = PgpeParams {
448            popsize: 40,
449            max_evaluations: 20000,
450            use_ranking: false,
451            seed: 1,
452            ..Default::default()
453        };
454        let mut opt = Pgpe::new(fit, &[3.0; 5], &[0.5; 5], &params);
455        let r = opt.optimize_batch(|rows| rows.iter().map(|x| sphere(x)).collect());
456        assert!(r.y < 1e-2, "pgpe did not converge: {}", r.y);
457    }
458
459    #[test]
460    fn ask_tell_best_x_is_real_point() {
461        let mut fit = Fitness::bounded(4, 1, &[-5.0; 4], &[5.0; 4]);
462        fit.set_normalize(true);
463        let params = PgpeParams {
464            popsize: 32,
465            use_ranking: false,
466            seed: 2,
467            ..Default::default()
468        };
469        let mut opt = Pgpe::new(fit, &[2.0; 4], &[0.5; 4], &params);
470        for _ in 0..400 {
471            let pop = opt.ask_pop();
472            let ys: Vec<f64> = pop.iter().map(|x| sphere(x)).collect();
473            opt.tell_pop(&ys);
474        }
475        let r = opt.result();
476        // best_x must evaluate close to the reported best value.
477        assert!((sphere(&r.x) - r.y).abs() < 1e-6 || r.y < 1e-2);
478        assert!(sphere(&r.x) < 1e-1, "best_x not good: {}", sphere(&r.x));
479    }
480
481    #[test]
482    fn ranking_defaults_odd_population_getters_and_nonfinite_scores() {
483        let mut fit = Fitness::bounded(2, 1, &[-1.0; 2], &[1.0; 2]);
484        fit.set_normalize(true);
485        let params = PgpeParams {
486            popsize: 3,
487            max_evaluations: 0,
488            stop_fitness: 1.0e100,
489            lr_decay_steps: 0,
490            use_ranking: true,
491            stdev_learning_rate: -0.1,
492            stdev_max_change: -0.2,
493            seed: 14,
494            ..Default::default()
495        };
496        let mut optimizer = Pgpe::new(fit, &[0.0; 2], &[0.25], &params);
497        assert_eq!(optimizer.dim(), 2);
498        assert_eq!(optimizer.popsize(), 4);
499        assert_eq!(optimizer.stop(), 0);
500        let result = optimizer.optimize_batch(|rows| vec![f64::NAN; rows.len()]);
501        assert_eq!(result.evaluations, 4);
502        assert_eq!(result.stop, 1);
503        assert_eq!(optimizer.population().len(), 4);
504        assert_eq!(optimizer.stop(), 1);
505    }
506}