fix(operators): make ProjectToSimplex robust to extreme magnitudes
This commit is contained in:
+61
-4
@@ -70,23 +70,47 @@ impl Repair<Vec<f64>> for ProjectToSimplex {
|
|||||||
if n == 0 {
|
if n == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// If any |x_i| dwarfs `total` so badly that `x_i - total == x_i` in
|
||||||
|
// f64, the standard Duchi/Held-Wolfe projection loses all precision
|
||||||
|
// in τ and silently returns the all-zero vector. In that pathological
|
||||||
|
// regime the projection is effectively concentrated on argmax(x), so
|
||||||
|
// assign all mass there directly.
|
||||||
|
let max_abs = decision
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.fold(0.0_f64, |a, b| a.max(b.abs()));
|
||||||
|
if max_abs > self.total * 1e15 {
|
||||||
|
let mut argmax = 0;
|
||||||
|
for (i, &v) in decision.iter().enumerate().skip(1) {
|
||||||
|
if v > decision[argmax] {
|
||||||
|
argmax = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (i, x) in decision.iter_mut().enumerate() {
|
||||||
|
*x = if i == argmax { self.total } else { 0.0 };
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Sort copy descending.
|
// Sort copy descending.
|
||||||
let mut sorted: Vec<f64> = decision.clone();
|
let mut sorted: Vec<f64> = decision.clone();
|
||||||
sorted.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
|
sorted.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
|
||||||
// Find ρ = max{ j : sorted[j-1] - (Σ_{i<=j} sorted[i] - total) / j > 0 }.
|
// Find ρ = max{ j : sorted[j-1] - (Σ_{i<=j} sorted[i] - total) / j > 0 }.
|
||||||
|
// Mathematically the j=0 case always satisfies the condition (since
|
||||||
|
// total > 0), so we initialize from it before the loop — that guards
|
||||||
|
// against floating-point precision loss when |sorted[0]| ≫ total,
|
||||||
|
// where the subtraction `sorted[0] - tau` could otherwise round to
|
||||||
|
// zero and leave τ unset (yielding the all-zero output bug).
|
||||||
let mut cumsum = 0.0;
|
let mut cumsum = 0.0;
|
||||||
let mut rho = 0;
|
let mut tau_at_rho = sorted[0] - self.total;
|
||||||
let mut tau_at_rho = 0.0;
|
|
||||||
for (j, &val) in sorted.iter().enumerate() {
|
for (j, &val) in sorted.iter().enumerate() {
|
||||||
cumsum += val;
|
cumsum += val;
|
||||||
let tau = (cumsum - self.total) / (j as f64 + 1.0);
|
let tau = (cumsum - self.total) / (j as f64 + 1.0);
|
||||||
if val - tau > 0.0 {
|
if val - tau > 0.0 {
|
||||||
rho = j + 1;
|
|
||||||
tau_at_rho = tau;
|
tau_at_rho = tau;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = rho;
|
|
||||||
// Apply: x_i ← max(x_i - τ, 0).
|
// Apply: x_i ← max(x_i - τ, 0).
|
||||||
for x in decision.iter_mut() {
|
for x in decision.iter_mut() {
|
||||||
*x = (*x - tau_at_rho).max(0.0);
|
*x = (*x - tau_at_rho).max(0.0);
|
||||||
@@ -168,4 +192,37 @@ mod tests {
|
|||||||
fn project_non_positive_total_panics() {
|
fn project_non_positive_total_panics() {
|
||||||
let _ = ProjectToSimplex::new(0.0);
|
let _ = ProjectToSimplex::new(0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression: discovered by the `clamp_to_bounds` fuzzer. When
|
||||||
|
/// `|max(x)|` dwarfs `total` so badly that the subtraction `x - τ`
|
||||||
|
/// rounds away `total`, the standard algorithm previously returned
|
||||||
|
/// the all-zero vector. The degenerate-magnitude fallback now
|
||||||
|
/// concentrates all mass on argmax(x).
|
||||||
|
#[test]
|
||||||
|
fn project_extreme_magnitudes_concentrates_on_argmax() {
|
||||||
|
let mut r = ProjectToSimplex::new(1.0);
|
||||||
|
let mut x = vec![1e20, 5e19, -1e20];
|
||||||
|
r.repair(&mut x);
|
||||||
|
let s: f64 = x.iter().sum();
|
||||||
|
assert!(approx_eq(s, 1.0, 1e-12));
|
||||||
|
// Argmax is index 0; all mass should be there.
|
||||||
|
assert!(approx_eq(x[0], 1.0, 1e-12));
|
||||||
|
assert_eq!(x[1], 0.0);
|
||||||
|
assert_eq!(x[2], 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression: when the input is "all zeros", τ is small (0 - total),
|
||||||
|
/// the projection should distribute total evenly. This tests the
|
||||||
|
/// loop's handling of equal entries.
|
||||||
|
#[test]
|
||||||
|
fn project_all_zeros_distributes_evenly() {
|
||||||
|
let mut r = ProjectToSimplex::new(1.0);
|
||||||
|
let mut x = vec![0.0, 0.0, 0.0, 0.0];
|
||||||
|
r.repair(&mut x);
|
||||||
|
let s: f64 = x.iter().sum();
|
||||||
|
assert!(approx_eq(s, 1.0, 1e-12));
|
||||||
|
for &v in &x {
|
||||||
|
assert!(approx_eq(v, 0.25, 1e-12));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user