1use prospicio_core::{Error, Result};
11
12use crate::distortion::Distortion;
13use crate::predictive::PredictiveDistribution;
14use crate::sampled::Empirical;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum AllocationMethod {
20 Euler,
26 Covariance,
30 Proportional,
34 Marginal,
38 Shapley,
43}
44
45#[derive(Debug, Clone, PartialEq)]
47pub struct Allocation {
48 pub method: AllocationMethod,
49 pub total: f64,
51 pub standalone: Vec<f64>,
54 pub allocated: Vec<f64>,
56}
57
58impl Allocation {
59 pub fn diversification_benefit(&self) -> f64 {
63 self.standalone.iter().sum::<f64>() - self.total
64 }
65
66 pub fn diversification(&self) -> Vec<f64> {
69 self.standalone
70 .iter()
71 .zip(&self.allocated)
72 .map(|(s, a)| s - a)
73 .collect()
74 }
75}
76
77pub const SHAPLEY_MAX_COMPONENTS: usize = 12;
79
80impl PredictiveDistribution {
81 pub fn capital(&self, d: &Distortion, method: AllocationMethod) -> Result<Allocation> {
112 let m = self.n_components();
113 let draws = self.draw_matrix();
114 let column = |j: usize| -> Vec<f64> { draws.iter().skip(j).step_by(m).copied().collect() };
115 let total = self.total().distortion(d);
116 let standalone: Vec<f64> = (0..m).map(|j| measure(d, column(j))).collect();
117 let allocated = match method {
118 AllocationMethod::Euler => self.allocate(d),
119 AllocationMethod::Covariance => {
120 let s = self.total().draws();
121 let n = s.len() as f64;
122 let mean_s = s.iter().sum::<f64>() / n;
123 let var_s = s.iter().map(|x| (x - mean_s).powi(2)).sum::<f64>() / n;
124 if var_s.is_nan() || var_s <= 0.0 {
125 return Err(invalid("draws", var_s, "the total must vary"));
126 }
127 (0..m)
128 .map(|j| {
129 let x = column(j);
130 let mean_x = x.iter().sum::<f64>() / n;
131 let cov = x
132 .iter()
133 .zip(s)
134 .map(|(a, b)| (a - mean_x) * (b - mean_s))
135 .sum::<f64>()
136 / n;
137 total * cov / var_s
138 })
139 .collect()
140 }
141 AllocationMethod::Proportional => {
142 let sum: f64 = standalone.iter().sum();
143 if sum == 0.0 || !sum.is_finite() {
144 return Err(invalid(
145 "standalone",
146 sum,
147 "must have a finite, non-zero sum",
148 ));
149 }
150 standalone.iter().map(|r| total * r / sum).collect()
151 }
152 AllocationMethod::Marginal => {
153 let s = self.total().draws();
154 (0..m)
155 .map(|j| {
156 let without: Vec<f64> = s
157 .iter()
158 .zip(column(j))
159 .map(|(total, x)| total - x)
160 .collect();
161 total - measure(d, without)
162 })
163 .collect()
164 }
165 AllocationMethod::Shapley => {
166 if m > SHAPLEY_MAX_COMPONENTS {
167 return Err(invalid(
168 "components",
169 m as f64,
170 "Shapley allocation takes at most 12 components",
171 ));
172 }
173 shapley(d, draws, m)
174 }
175 };
176 Ok(Allocation {
177 method,
178 total,
179 standalone,
180 allocated,
181 })
182 }
183}
184
185fn measure(d: &Distortion, mut draws: Vec<f64>) -> f64 {
187 draws.sort_by(f64::total_cmp);
188 d.apply_sorted(&draws)
189}
190
191fn shapley(d: &Distortion, draws: &[f64], m: usize) -> Vec<f64> {
194 let n = draws.len() / m;
195 let coalitions = 1usize << m;
196 let mut value = vec![0.0; coalitions];
197 for (mask, v) in value.iter_mut().enumerate().skip(1) {
198 let sums = (0..n)
199 .map(|i| {
200 let row = &draws[i * m..(i + 1) * m];
201 (0..m).filter(|j| mask >> j & 1 == 1).map(|j| row[j]).sum()
202 })
203 .collect();
204 *v = measure(d, sums);
205 }
206 let factorial = |k: usize| (1..=k).map(|i| i as f64).product::<f64>();
208 let weight: Vec<f64> = (0..m)
209 .map(|t| factorial(t) * factorial(m - t - 1) / factorial(m))
210 .collect();
211 (0..m)
212 .map(|j| {
213 (0..coalitions)
214 .filter(|mask| mask >> j & 1 == 0)
215 .map(|mask| {
216 let size = mask.count_ones() as usize;
217 weight[size] * (value[mask | 1 << j] - value[mask])
218 })
219 .sum()
220 })
221 .collect()
222}
223
224fn invalid(name: &'static str, value: f64, reason: &'static str) -> Error {
225 Error::InvalidParameter {
226 name,
227 value,
228 reason,
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use crate::{GaussianCopula, KeyValue, Lognormal, Provenance, copula};
236
237 fn three_lines() -> PredictiveDistribution {
238 let marginals = [
239 Lognormal::from_mean_cv(10.0, 0.3).unwrap(),
240 Lognormal::from_mean_cv(20.0, 0.8).unwrap(),
241 Lognormal::from_mean_cv(5.0, 2.0).unwrap(),
242 ];
243 let corr = [1.0, 0.5, 0.2, 0.5, 1.0, 0.4, 0.2, 0.4, 1.0];
244 let c = GaussianCopula::new(&corr, 3).unwrap();
245 copula::simulate(
246 &c,
247 &[&marginals[0], &marginals[1], &marginals[2]],
248 vec!["line".into()],
249 ["a", "b", "c"]
250 .iter()
251 .map(|&l| vec![KeyValue::from(l)])
252 .collect(),
253 50_000,
254 11,
255 Provenance::new("test"),
256 )
257 .unwrap()
258 }
259
260 fn close(a: f64, b: f64) -> bool {
261 (a - b).abs() <= 1e-9 * b.abs().max(1.0)
262 }
263
264 #[test]
265 fn full_allocations_sum_to_the_total() {
266 let pd = three_lines();
267 let d = Distortion::tvar(0.99).unwrap();
268 for method in [
269 AllocationMethod::Euler,
270 AllocationMethod::Covariance,
271 AllocationMethod::Proportional,
272 AllocationMethod::Shapley,
273 ] {
274 let a = pd.capital(&d, method).unwrap();
275 assert!(close(a.allocated.iter().sum(), a.total), "{method:?}");
276 assert!(a.diversification_benefit() > 0.0);
277 }
278 }
279
280 #[test]
281 fn euler_matches_allocate_and_marginal_falls_short() {
282 let pd = three_lines();
283 let d = Distortion::wang(0.5).unwrap();
284 let euler = pd.capital(&d, AllocationMethod::Euler).unwrap();
285 assert_eq!(euler.allocated, pd.allocate(&d));
286 let marginal = pd.capital(&d, AllocationMethod::Marginal).unwrap();
287 for (m, s) in marginal.allocated.iter().zip(&marginal.standalone) {
290 assert!(m <= s);
291 }
292 assert!(marginal.allocated.iter().sum::<f64>() < marginal.total);
293 }
294
295 #[test]
296 fn shapley_by_hand_for_two_components() {
297 let pd = three_lines();
299 let d = Distortion::tvar(0.95).unwrap();
300 let pd2 = {
301 let m = pd.n_components();
302 let draws: Vec<f64> = pd
303 .draw_matrix()
304 .chunks_exact(m)
305 .flat_map(|r| [r[0], r[1] + r[2]])
306 .collect();
307 PredictiveDistribution::from_draws(
308 vec!["line".into()],
309 vec![vec![KeyValue::from("a")], vec![KeyValue::from("bc")]],
310 draws,
311 Provenance::new("test"),
312 )
313 .unwrap()
314 };
315 let a = pd2.capital(&d, AllocationMethod::Shapley).unwrap();
316 let want = 0.5 * (a.standalone[0] + a.total - a.standalone[1]);
317 assert!(close(a.allocated[0], want));
318 }
319
320 #[test]
321 fn comonotonic_components_have_no_benefit() {
322 let x: Vec<f64> = (0..1000).map(|i| f64::from(i) * 0.37 % 11.0).collect();
324 let draws: Vec<f64> = x.iter().flat_map(|&v| [v, 2.0 * v]).collect();
325 let pd = PredictiveDistribution::from_draws(
326 vec!["line".into()],
327 vec![vec![KeyValue::from("a")], vec![KeyValue::from("b")]],
328 draws,
329 Provenance::new("test"),
330 )
331 .unwrap();
332 let d = Distortion::tvar(0.9).unwrap();
333 for method in [
334 AllocationMethod::Euler,
335 AllocationMethod::Covariance,
336 AllocationMethod::Shapley,
337 ] {
338 let a = pd.capital(&d, method).unwrap();
339 assert!(a.diversification_benefit().abs() < 1e-9);
340 assert!(close(a.allocated[0], a.standalone[0]), "{method:?}");
341 }
342 }
343
344 #[test]
345 fn rejects_degenerate_inputs() {
346 let pd = PredictiveDistribution::from_draws(
347 vec!["line".into()],
348 vec![vec![KeyValue::from("a")], vec![KeyValue::from("b")]],
349 vec![1.0, -1.0, 2.0, -2.0],
350 Provenance::new("test"),
351 )
352 .unwrap();
353 let d = Distortion::tvar(0.5).unwrap();
354 assert!(pd.capital(&d, AllocationMethod::Covariance).is_err());
355 }
356}