use crate::gibbs::Sampler;
use crate::graph::{Graph, GraphBuilder};
use crate::rng::Pcg;
use crate::tempering::TemperingResult;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Params {
pub replicas: usize,
pub epochs: usize,
pub rounds: usize,
pub swap_every: usize,
pub beta_min: f64,
pub beta_max: f64,
}
impl Default for Params {
fn default() -> Self {
Params {
replicas: 8,
epochs: 6,
rounds: 200,
swap_every: 4,
beta_min: 0.05,
beta_max: 4.0,
}
}
}
#[derive(Clone, Debug)]
pub struct Outcome {
pub best: Vec<i8>,
pub best_e: f64,
pub betas: Vec<f64>,
pub swap_rates: Vec<f64>,
pub spread: Vec<f64>,
}
pub fn adapt(g: &Graph, p: &Params, seed: u64) -> Outcome {
let r = p.replicas.max(2);
let mut betas = crate::tempering::geometric_ladder(p.beta_min, p.beta_max, r);
let mut spread = Vec::with_capacity(p.epochs.max(1));
let mut last = TemperingResult { best: vec![1; g.n], best_e: f64::INFINITY, swap_rates: vec![] };
for epoch in 0..p.epochs.max(1) {
let out = crate::tempering::parallel_tempering(
g,
&betas,
p.rounds,
p.swap_every,
seed ^ ((epoch as u64) << 17),
None,
);
let hi = out.swap_rates.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let lo = out.swap_rates.iter().cloned().fold(f64::INFINITY, f64::min);
spread.push(hi - lo);
if out.best_e < last.best_e {
last.best = out.best.clone();
last.best_e = out.best_e;
}
last.swap_rates = out.swap_rates.clone();
if epoch + 1 < p.epochs.max(1) {
betas = respace(&betas, &out.swap_rates);
}
}
Outcome { best: last.best, best_e: last.best_e, betas, swap_rates: last.swap_rates, spread }
}
pub fn respace(betas: &[f64], rates: &[f64]) -> Vec<f64> {
let r = betas.len();
if r < 3 || rates.len() + 1 != r {
return betas.to_vec();
}
const FLOOR: f64 = 0.02;
let len: Vec<f64> = rates.iter().map(|&x| 1.0 / (x.max(0.0) + FLOOR)).collect();
let total: f64 = len.iter().sum();
if !total.is_finite() || total <= 0.0 {
return betas.to_vec();
}
let lo = betas[0].ln();
let hi = betas[r - 1].ln();
let mut cum = vec![0.0; r];
for i in 0..r - 1 {
cum[i + 1] = cum[i] + len[i];
}
let mut out = vec![betas[0]; r];
out[r - 1] = betas[r - 1];
for k in 1..r - 1 {
let target = total * k as f64 / (r - 1) as f64;
let mut i = 0;
while i + 2 < r && cum[i + 1] < target {
i += 1;
}
let span = cum[i + 1] - cum[i];
let frac = if span > 0.0 { (target - cum[i]) / span } else { 0.0 };
let a = betas[i].ln();
let b = betas[i + 1].ln();
out[k] = (a + (b - a) * frac).exp();
}
for k in 1..r {
if out[k] <= out[k - 1] {
out[k] = out[k - 1] * (1.0 + 1e-9);
}
}
let _ = (lo, hi);
out
}
pub fn scaled(g: &Graph, scale: f64) -> Graph {
let mut b = GraphBuilder::new(g.n);
for i in 0..g.n {
for k in g.offset[i]..g.offset[i + 1] {
let j = g.nbr[k] as usize;
if j > i {
b.couple(i, j, g.w[k] * scale);
}
}
}
for (i, &h) in g.h.iter().enumerate() {
if h != 0.0 {
b.bias(i, h);
}
}
b.build()
}
pub fn adapt_2d(
g: &Graph,
betas: &[f64],
scales: &[f64],
rounds: usize,
swap_every: usize,
seed: u64,
) -> Result<Outcome, String> {
if betas.len() < 2 {
return Err("a ladder needs at least two betas".into());
}
if scales.is_empty() {
return Err("give at least one coupling scale; [1.0] is ordinary tempering".into());
}
if !scales.iter().any(|&s| (s - 1.0).abs() < 1e-12) {
return Err(
"`scales` must contain 1.0: the answer is about the model as given, and a grid that \
never visits it reports the best state of a DIFFERENT model"
.into(),
);
}
let (nb, ns) = (betas.len(), scales.len());
let graphs: Vec<Graph> = scales.iter().map(|&s| scaled(g, s)).collect();
let at = |bi: usize, si: usize| si * nb + bi;
let mut reps: Vec<Sampler> = Vec::with_capacity(nb * ns);
for si in 0..ns {
for bi in 0..nb {
reps.push(Sampler::new(&graphs[si], betas[bi], seed ^ ((at(bi, si) as u64) * 0x9E37)));
}
}
let mut rng = Pcg::new(seed ^ 0x2D2D, 7);
let mut attempts = vec![0u64; nb.saturating_sub(1)];
let mut accepts = vec![0u64; nb.saturating_sub(1)];
let phys = scales.iter().position(|&s| (s - 1.0).abs() < 1e-12).unwrap();
let mut best = reps[at(nb - 1, phys)].s.clone();
let mut best_e = g.energy(&best);
for round in 0..rounds {
for rep in reps.iter_mut() {
for _ in 0..swap_every.max(1) {
rep.sweep(None);
}
}
for si in 0..ns {
for bi in 0..nb {
let e = g.energy(&reps[at(bi, si)].s);
if e < best_e {
best_e = e;
best = reps[at(bi, si)].s.clone();
}
}
}
let start = round % 2;
for si in 0..ns {
for bi in (start..nb.saturating_sub(1)).step_by(2) {
let (a, b) = (at(bi, si), at(bi + 1, si));
let ea = graphs[si].energy(&reps[a].s);
let eb = graphs[si].energy(&reps[b].s);
let arg = (betas[bi + 1] - betas[bi]) * (eb - ea);
attempts[bi] += 1;
if arg >= 0.0 || rng.f64() < arg.exp() {
accepts[bi] += 1;
swap_states(&mut reps, a, b);
}
}
}
let sstart = (round / 2) % 2;
for bi in 0..nb {
for si in (sstart..ns.saturating_sub(1)).step_by(2) {
let (a, b) = (at(bi, si), at(bi, si + 1));
let (ga, gb) = (&graphs[si], &graphs[si + 1]);
let arg = betas[bi]
* (ga.energy(&reps[a].s) + gb.energy(&reps[b].s)
- ga.energy(&reps[b].s)
- gb.energy(&reps[a].s));
if arg >= 0.0 || rng.f64() < arg.exp() {
swap_states(&mut reps, a, b);
}
}
}
}
let rates: Vec<f64> = (0..nb.saturating_sub(1))
.map(|i| accepts[i] as f64 / attempts[i].max(1) as f64)
.collect();
let hi = rates.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let lo = rates.iter().cloned().fold(f64::INFINITY, f64::min);
Ok(Outcome {
best,
best_e,
betas: betas.to_vec(),
swap_rates: rates,
spread: vec![hi - lo],
})
}
fn swap_states(reps: &mut [Sampler], a: usize, b: usize) {
let (lo, hi) = if a < b { (a, b) } else { (b, a) };
let (l, r) = reps.split_at_mut(hi);
core::mem::swap(&mut l[lo].s, &mut r[0].s);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ising::lattice2d;
#[test]
fn adaptation_evens_out_a_ladder_that_started_uneven() {
let g = lattice2d(12, 1.0);
let p = Params {
replicas: 8,
epochs: 6,
rounds: 300,
swap_every: 2,
beta_min: 0.02,
beta_max: 6.0,
};
let out = adapt(&g, &p, 11);
assert_eq!(out.spread.len(), p.epochs);
assert!(
out.spread[p.epochs - 1] < out.spread[0] * 0.75,
"the spread must fall, and it went {:?}",
out.spread
);
assert!((out.betas[0] - p.beta_min).abs() < 1e-12);
assert!((out.betas[out.betas.len() - 1] - p.beta_max).abs() < 1e-12);
assert!(out.betas.windows(2).all(|w| w[1] > w[0]), "{:?}", out.betas);
}
#[test]
fn a_uniformly_dead_ladder_has_a_small_spread_and_is_not_healthy() {
let mut rng = Pcg::new(4, 0xDEAD_1ADD);
let l = 12usize;
let mut b = GraphBuilder::new(l * l);
for y in 0..l {
for x in 0..l {
let i = y * l + x;
let s = |r: &mut Pcg| if r.f64() < 0.5 { 1.0 } else { -1.0 };
b.couple(i, y * l + (x + 1) % l, s(&mut rng));
b.couple(i, ((y + 1) % l) * l + x, s(&mut rng));
}
}
let g = b.build();
let p = Params {
replicas: 4,
epochs: 5,
rounds: 200,
swap_every: 2,
beta_min: 0.02,
beta_max: 8.0,
};
let out = adapt(&g, &p, 3);
let worst = out.swap_rates.iter().cloned().fold(f64::INFINITY, f64::min);
assert!(worst < 0.02, "this ladder is meant to be dead: {:?}", out.swap_rates);
assert!(
*out.spread.last().unwrap() <= out.spread[0] + 1e-12,
"and its spread does not rise: {:?}",
out.spread
);
assert!(
*out.spread.last().unwrap() < 0.2,
"a dead ladder looks EVEN, which is why the spread must not be read alone: {:?}",
out.spread
);
}
#[test]
fn respacing_leaves_an_even_ladder_alone_and_closes_a_dead_gap() {
let even = crate::tempering::geometric_ladder(0.1, 4.0, 6);
let same = respace(&even, &[0.4; 5]);
for (a, b) in even.iter().zip(&same) {
assert!((a - b).abs() < 1e-9, "an even ladder must not move: {even:?} -> {same:?}");
}
let rates = vec![0.6, 0.6, 0.0, 0.6, 0.6];
let moved = respace(&even, &rates);
assert!(moved.windows(2).all(|w| w[1] > w[0]), "{moved:?}");
assert!((moved[0] - even[0]).abs() < 1e-12 && (moved[5] - even[5]).abs() < 1e-12);
let gap = |v: &[f64], i: usize| v[i + 1].ln() - v[i].ln();
assert!(
gap(&moved, 2) < gap(&even, 2),
"the dead gap must narrow: {:.4} was {:.4}",
gap(&moved, 2),
gap(&even, 2)
);
}
#[test]
fn a_two_rung_ladder_is_returned_unchanged() {
let two = vec![0.5, 2.0];
assert_eq!(respace(&two, &[0.3]), two);
let four = crate::tempering::geometric_ladder(0.1, 4.0, 4);
assert_eq!(respace(&four, &[0.3]), four);
}
#[test]
fn scaling_moves_couplings_and_not_fields() {
let mut b = GraphBuilder::new(3);
b.couple(0, 1, 2.0);
b.couple(1, 2, -1.0);
b.bias(0, 0.5);
b.bias(2, -1.5);
let g = b.build();
let s = scaled(&g, 0.25);
assert_eq!(s.n, g.n);
assert_eq!(s.n_edges, g.n_edges);
assert_eq!(s.h, g.h, "fields must be untouched");
let all_up = vec![1i8; 3];
assert!((g.energy(&all_up) - 0.0).abs() < 1e-12, "{}", g.energy(&all_up));
assert!((s.energy(&all_up) - 0.75).abs() < 1e-12, "{}", s.energy(&all_up));
}
#[test]
fn a_grid_that_never_reaches_the_real_model_is_refused() {
let g = lattice2d(6, 1.0);
let b = crate::tempering::geometric_ladder(0.2, 3.0, 4);
let e = adapt_2d(&g, &b, &[0.5, 0.8], 10, 2, 1).unwrap_err();
assert!(e.contains("1.0"), "{e}");
assert!(adapt_2d(&g, &b, &[], 10, 2, 1).is_err());
assert!(adapt_2d(&g, &[1.0], &[1.0], 10, 2, 1).is_err());
assert!(adapt_2d(&g, &b, &[1.0], 20, 2, 1).is_ok());
}
#[test]
fn the_2d_answer_is_about_the_model_as_given() {
let g = lattice2d(8, 1.0);
let b = crate::tempering::geometric_ladder(0.1, 3.0, 5);
let out = adapt_2d(&g, &b, &[0.4, 0.7, 1.0], 300, 2, 5).unwrap();
assert_eq!(out.best.len(), g.n);
assert!((g.energy(&out.best) - out.best_e).abs() < 1e-9, "energy must be the real one");
assert!(out.best_e < -0.8 * g.n_edges as f64, "best {} on {} edges", out.best_e, g.n_edges);
}
}