Skip to main content

prospicio_models/
stack.rs

1//! Model weights for blending from pointwise out-of-sample log predictive
2//! densities: stacking and pseudo-BMA (Yao, Vehtari, Simpson and Gelman,
3//! 2018, "Using stacking to average Bayesian predictive distributions").
4//!
5//! The input is, for each model, the log predictive density of each
6//! observation when that observation was held out: PSIS-LOO pointwise
7//! values for a Bayesian fit (`prospicio_bayes::elpd`), or cross-validated
8//! log densities ([`Family::log_density`](crate::Family::log_density) on
9//! each test fold) for any other model. So the weights apply to every
10//! engine, Bayesian or not. Blend the models' predictive distributions
11//! with `prospicio_prob::PredictiveDistribution::blend`.
12
13use prospicio_core::{Error, Result, StreamRng};
14use prospicio_math::linalg::{cholesky, cholesky_solve};
15
16/// Stacking weights: the `w` on the simplex that maximizes
17/// `Σ_i log Σ_k w_k exp(lpd_ik)`, the log score of the mixture of the
18/// models' predictive distributions. `lpd[k][i]` is model `k`'s held-out
19/// log density of observation `i`.
20///
21/// Unlike pseudo-BMA, stacking gives weight to a model that is worse on
22/// its own when it predicts well where the others do not, and drops
23/// models that add nothing (their weights are exactly 0). The objective
24/// is concave; it is solved by EM, then Newton's method on the models
25/// kept, checked against the optimality conditions.
26///
27/// ```
28/// use prospicio_models::stack::stacking_weights;
29///
30/// // Model 0 predicts the first half well, model 1 the second half.
31/// let a = [-0.1, -0.1, -3.0, -3.0];
32/// let b = [-3.0, -3.0, -0.1, -0.1];
33/// let w = stacking_weights(&[a.to_vec(), b.to_vec()]).unwrap();
34/// assert!((w[0] - 0.5).abs() < 1e-9 && (w[1] - 0.5).abs() < 1e-9);
35/// ```
36pub fn stacking_weights(lpd: &[Vec<f64>]) -> Result<Vec<f64>> {
37    let n = check(lpd)?;
38    let k = lpd.len();
39    // e[i * k + j] = exp(lpd_ij - max_j lpd_ij): rows scaled for range.
40    let mut e = vec![0.0; n * k];
41    for i in 0..n {
42        let m = lpd.iter().map(|l| l[i]).fold(f64::NEG_INFINITY, f64::max);
43        for j in 0..k {
44            e[i * k + j] = (lpd[j][i] - m).exp();
45        }
46    }
47    let mut w = vec![1.0 / k as f64; k];
48    // A few EM steps (monotone: w_j <- w_j mean_i e_ij / (e_i . w)) give a
49    // good start; then active-set Newton.
50    for _ in 0..50 {
51        let g = gradient(&e, &w, n, k);
52        for (wj, gj) in w.iter_mut().zip(&g) {
53            *wj *= gj / n as f64;
54        }
55    }
56    let nf = n as f64;
57    for _round in 0..100 {
58        let mut support: Vec<usize> = (0..k).filter(|&j| w[j] > 0.0).collect();
59        newton(&e, &mut w, &mut support, n, k);
60        // Optimality: the gradient is n on the support and at most n off it.
61        let g = gradient(&e, &w, n, k);
62        let on = support.iter().all(|&j| (g[j] - nf).abs() <= 1e-8 * nf);
63        let off = (0..k).all(|j| w[j] > 0.0 || g[j] <= nf * (1.0 + 1e-9));
64        if on && off {
65            return Ok(w);
66        }
67        // Bring back the best excluded model with a positive slope.
68        if !off {
69            let j = (0..k)
70                .filter(|&j| w[j] == 0.0)
71                .max_by(|&a, &b| g[a].total_cmp(&g[b]))
72                .expect("a violated model is excluded");
73            w.iter_mut().for_each(|x| *x *= 0.99);
74            w[j] = 0.01;
75        }
76    }
77    Err(Error::Data("stacking weights did not converge".into()))
78}
79
80/// Pseudo-BMA weights: `w_k ∝ exp(elpd_k)`, with `elpd_k = Σ_i lpd_ik`.
81///
82/// With `bootstrap = Some((draws, seed))` these are pseudo-BMA+ weights:
83/// each of `draws` Bayesian-bootstrap replicates reweights the
84/// observations by Dirichlet(1, ..., 1) (replicate `b` from stream `b` of
85/// `seed`), and the weights are averaged over replicates. That accounts
86/// for the uncertainty in the elpd differences, so a model that is only
87/// slightly better does not take all the weight.
88pub fn pseudo_bma_weights(lpd: &[Vec<f64>], bootstrap: Option<(usize, u64)>) -> Result<Vec<f64>> {
89    let n = check(lpd)?;
90    let k = lpd.len();
91    let Some((draws, seed)) = bootstrap else {
92        let elpd: Vec<f64> = lpd.iter().map(|l| l.iter().sum()).collect();
93        return Ok(softmax(&elpd));
94    };
95    if draws == 0 {
96        return Err(Error::InvalidParameter {
97            name: "draws",
98            value: 0.0,
99            reason: "must be positive",
100        });
101    }
102    let mut out = vec![0.0; k];
103    let mut g = vec![0.0; n];
104    for b in 0..draws {
105        let mut rng = StreamRng::new(seed, b as u64);
106        // Dirichlet(1, ..., 1) as normalized standard exponentials.
107        for x in g.iter_mut() {
108            *x = -rng.next_open01().ln();
109        }
110        let total: f64 = g.iter().sum();
111        let z: Vec<f64> = lpd
112            .iter()
113            .map(|l| n as f64 * l.iter().zip(&g).map(|(a, gi)| a * gi).sum::<f64>() / total)
114            .collect();
115        for (o, wk) in out.iter_mut().zip(softmax(&z)) {
116            *o += wk / draws as f64;
117        }
118    }
119    Ok(out)
120}
121
122fn softmax(z: &[f64]) -> Vec<f64> {
123    let m = z.iter().copied().fold(f64::NEG_INFINITY, f64::max);
124    let e: Vec<f64> = z.iter().map(|v| (v - m).exp()).collect();
125    let s: f64 = e.iter().sum();
126    e.into_iter().map(|v| v / s).collect()
127}
128
129fn check(lpd: &[Vec<f64>]) -> Result<usize> {
130    if lpd.len() < 2 {
131        return Err(Error::Data("weights need at least two models".into()));
132    }
133    let n = lpd[0].len();
134    if n == 0 || lpd.iter().any(|l| l.len() != n) {
135        return Err(Error::Data(
136            "every model needs the same, non-zero number of pointwise log densities".into(),
137        ));
138    }
139    if lpd.iter().flatten().any(|v| !v.is_finite()) {
140        return Err(Error::Data("pointwise log densities must be finite".into()));
141    }
142    Ok(n)
143}
144
145/// `∂/∂w_j Σ_i log(e_i · w) = Σ_i e_ij / (e_i · w)`.
146fn gradient(e: &[f64], w: &[f64], n: usize, k: usize) -> Vec<f64> {
147    let mut g = vec![0.0; k];
148    for row in e.chunks_exact(k).take(n) {
149        let s: f64 = row.iter().zip(w).map(|(a, b)| a * b).sum();
150        for (gj, a) in g.iter_mut().zip(row) {
151            *gj += a / s;
152        }
153    }
154    g
155}
156
157fn objective(e: &[f64], w: &[f64], k: usize) -> f64 {
158    e.chunks_exact(k)
159        .map(|row| row.iter().zip(w).map(|(a, b)| a * b).sum::<f64>().ln())
160        .sum()
161}
162
163/// Damped Newton's method on the face of the simplex spanned by
164/// `support`, eliminating its last weight. A step that would make a weight
165/// negative stops at the boundary and drops that model from the support.
166fn newton(e: &[f64], w: &mut [f64], support: &mut Vec<usize>, n: usize, k: usize) {
167    for _ in 0..500 {
168        let m = support.len();
169        if m < 2 {
170            if let [only] = support[..] {
171                w.iter_mut().for_each(|x| *x = 0.0);
172                w[only] = 1.0;
173            }
174            return;
175        }
176        let mut g = vec![0.0; m];
177        let mut h = vec![0.0; m * m];
178        for row in e.chunks_exact(k).take(n) {
179            let s: f64 = row.iter().zip(w.iter()).map(|(a, b)| a * b).sum();
180            for (a, &ja) in support.iter().enumerate() {
181                let xa = row[ja] / s;
182                g[a] += xa;
183                for (b, &jb) in support.iter().enumerate() {
184                    h[a * m + b] += xa * row[jb] / s;
185                }
186            }
187        }
188        // Reduced gradient and negated Hessian in the m - 1 free weights.
189        let r = m - 1;
190        let grad: Vec<f64> = (0..r).map(|a| g[a] - g[r]).collect();
191        if grad.iter().all(|v| v.abs() <= 1e-12 * n as f64) {
192            return;
193        }
194        let mut neg_h = vec![0.0; r * r];
195        for a in 0..r {
196            for b in 0..r {
197                neg_h[a * r + b] = h[a * m + b] - h[a * m + r] - h[r * m + b] + h[r * m + r];
198            }
199        }
200        // Levenberg damping when the models are nearly collinear.
201        let scale = (0..r)
202            .map(|a| neg_h[a * r + a])
203            .fold(0.0, f64::max)
204            .max(1e-300);
205        let mut damping = 0.0;
206        let step = loop {
207            let mut a = neg_h.clone();
208            for d in 0..r {
209                a[d * r + d] += damping;
210            }
211            if let Some(l) = cholesky(&a, r) {
212                break cholesky_solve(&l, &grad);
213            }
214            damping = if damping == 0.0 {
215                1e-12 * scale
216            } else {
217                damping * 10.0
218            };
219        };
220        let mut full = vec![0.0; m];
221        full[..r].copy_from_slice(&step);
222        full[r] = -step.iter().sum::<f64>();
223        // Largest step that keeps every weight non-negative.
224        let mut t_max = f64::INFINITY;
225        let mut blocking = None;
226        for (a, &ja) in support.iter().enumerate() {
227            if full[a] < 0.0 {
228                let t = -w[ja] / full[a];
229                if t < t_max {
230                    t_max = t;
231                    blocking = Some(a);
232                }
233            }
234        }
235        let base = objective(e, w, k);
236        let mut t = t_max.min(1.0);
237        let mut moved = false;
238        for _ in 0..60 {
239            let mut trial = w.to_vec();
240            for (a, &ja) in support.iter().enumerate() {
241                trial[ja] = (w[ja] + t * full[a]).max(0.0);
242            }
243            if objective(e, &trial, k) >= base {
244                w.copy_from_slice(&trial);
245                moved = true;
246                break;
247            }
248            t *= 0.5;
249        }
250        if !moved {
251            return;
252        }
253        if t == t_max {
254            if let Some(a) = blocking {
255                w[support[a]] = 0.0;
256                support.remove(a);
257            }
258        }
259        let s: f64 = w.iter().sum();
260        w.iter_mut().for_each(|x| *x /= s);
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn a_dominated_model_gets_no_stacking_weight() {
270        let a = vec![-1.0, -1.2, -0.8, -1.1];
271        let worse: Vec<f64> = a.iter().map(|v| v - 0.5).collect();
272        let w = stacking_weights(&[a, worse]).unwrap();
273        assert_eq!(w, vec![1.0, 0.0]);
274    }
275
276    #[test]
277    fn pseudo_bma_is_a_softmax_of_elpd_and_the_bootstrap_shrinks_it() {
278        let a = vec![-1.0, -2.0, -1.5, -1.0, -0.5, -2.5];
279        let b = vec![-1.2, -1.6, -1.4, -1.3, -0.9, -1.8];
280        let w = pseudo_bma_weights(&[a.clone(), b.clone()], None).unwrap();
281        let (ea, eb): (f64, f64) = (a.iter().sum(), b.iter().sum());
282        assert!((w[0] - 1.0 / (1.0 + (eb - ea).exp())).abs() < 1e-15);
283        let plus = pseudo_bma_weights(&[a.clone(), b.clone()], Some((4000, 1))).unwrap();
284        assert!((plus[0] + plus[1] - 1.0).abs() < 1e-12);
285        // The better model by elpd keeps more weight, but less than plain
286        // pseudo-BMA gives it.
287        assert!(plus[1] > 0.5 && plus[1] < w[1]);
288        assert_eq!(plus, pseudo_bma_weights(&[a, b], Some((4000, 1))).unwrap());
289    }
290
291    #[test]
292    fn rejects_bad_input() {
293        assert!(stacking_weights(&[vec![-1.0]]).is_err());
294        assert!(stacking_weights(&[vec![-1.0], vec![-1.0, -2.0]]).is_err());
295        assert!(stacking_weights(&[vec![f64::NAN], vec![-1.0]]).is_err());
296        assert!(pseudo_bma_weights(&[vec![-1.0], vec![-1.0]], Some((0, 1))).is_err());
297    }
298}