Skip to content

Commit a11702b

Browse files
committed
More thorough testing for lp-ball projection
About: - test projection onto lp-ball using random points on the sphere - introduce rand_distr as a test dependency - unit test of test function that generated random points - test projection when x is inside the ball
1 parent 72d6c2b commit a11702b

File tree

4 files changed

+164
-24
lines changed

4 files changed

+164
-24
lines changed

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ unit_test_utils = "0.1.4"
133133
icasadi_test = "0.0.3"
134134
# Random number generators for unit tests:
135135
rand = "0.10"
136+
rand_distr = "0.6"
136137

137138

138139
# --------------------------------------------------------------------------

src/constraints/ballp.rs

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use super::Constraint;
66
/// or a translated ball
77
/// $$B_p^{x_c, r} = \\{ x \\in \mathbb{R}^n : \Vert x - x_c \Vert_p \\leq r \\},$$
88
/// with $1 < p < \infty$.
9-
///
9+
///
1010
/// # Projection problem
1111
///
1212
/// Given a vector $x$, projection onto the ball means solving
@@ -81,10 +81,10 @@ use super::Constraint;
8181
/// # Notes
8282
///
8383
/// - The projection is with respect to the *Euclidean norm*
84-
/// - The implementation is intended for general finite $p > 1.0$. If you need
84+
/// - The implementation is intended for general finite $p > 1.0$. If you need
8585
/// to project on a $\Vert{}\cdot{}\Vert_1$-ball or an $\Vert{}\cdot{}
8686
/// \Vert_\infty$-ball, use the implementations in [`Ball1`](crate::constraints::Ball1) and [`BallInf`](crate::constraints::BallInf).
87-
/// - Do not use this struct to project on a Euclidean ball; the implementation
87+
/// - Do not use this struct to project on a Euclidean ball; the implementation
8888
/// in [`Ball2`](crate::constraints::Ball2) is more efficient
8989
/// - The quality and speed of the computation depend on the chosen numerical
9090
/// tolerance and iteration limit.
@@ -144,11 +144,17 @@ impl<'a> BallP<'a> {
144144
}
145145

146146
#[inline]
147-
fn lp_norm(x: &[f64], p: f64) -> f64 {
147+
#[must_use]
148+
/// Computes the $p$-norm of a given vector
149+
///
150+
/// The $p$-norm of a vector $x\in \mathbb{R}^n$ is given by
151+
/// $$\Vert x \Vert_p = \left(\sum_{i=1}^{n} |x_i|^p\right)^{1/p},$$
152+
/// for $p > 1$.
153+
pub fn lp_norm(&self, x: &[f64]) -> f64 {
148154
x.iter()
149-
.map(|xi| xi.abs().powf(p))
155+
.map(|xi| xi.abs().powf(self.p))
150156
.sum::<f64>()
151-
.powf(1.0 / p)
157+
.powf(1.0 / self.p)
152158
}
153159

154160
#[inline]
@@ -162,7 +168,7 @@ impl<'a> BallP<'a> {
162168
let tol = self.tolerance;
163169
let max_iter = self.max_iter;
164170

165-
let current_norm = Self::lp_norm(x, p);
171+
let current_norm = self.lp_norm(x);
166172
if current_norm <= r {
167173
return;
168174
}
@@ -171,7 +177,8 @@ impl<'a> BallP<'a> {
171177
let target = r.powf(p);
172178

173179
let radius_error = |lambda: f64| -> f64 {
174-
abs_x.iter()
180+
abs_x
181+
.iter()
175182
.map(|&a| {
176183
let u = Self::solve_coordinate_newton(a, lambda, p, tol, max_iter);
177184
u.powf(p)
@@ -220,13 +227,7 @@ impl<'a> BallP<'a> {
220227
///
221228
/// The solution always belongs to [0, a], so Newton is combined with
222229
/// bracketing and a bisection fallback.
223-
fn solve_coordinate_newton(
224-
a: f64,
225-
lambda: f64,
226-
p: f64,
227-
tol: f64,
228-
max_iter: usize,
229-
) -> f64 {
230+
fn solve_coordinate_newton(a: f64, lambda: f64, p: f64, tol: f64, max_iter: usize) -> f64 {
230231
if a == 0.0 {
231232
return 0.0;
232233
}
@@ -298,4 +299,4 @@ impl<'a> Constraint for BallP<'a> {
298299
fn is_convex(&self) -> bool {
299300
true
300301
}
301-
}
302+
}

src/constraints/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
mod affine_space;
1212
mod ball1;
1313
mod ball2;
14-
mod ballp;
1514
mod ballinf;
15+
mod ballp;
1616
mod cartesian_product;
1717
mod epigraph_squared_norm;
1818
mod finite;
@@ -28,8 +28,8 @@ mod zero;
2828
pub use affine_space::AffineSpace;
2929
pub use ball1::Ball1;
3030
pub use ball2::Ball2;
31-
pub use ballp::BallP;
3231
pub use ballinf::BallInf;
32+
pub use ballp::BallP;
3333
pub use cartesian_product::CartesianProduct;
3434
pub use epigraph_squared_norm::EpigraphSquaredNorm;
3535
pub use finite::FiniteSet;

src/constraints/tests.rs

Lines changed: 144 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ use crate::matrix_operations;
22

33
use super::*;
44
use rand;
5+
use rand::RngExt;
6+
use rand_distr::{Distribution, Gamma};
57

68
#[test]
79
fn t_zero_set() {
@@ -990,16 +992,152 @@ fn t_affine_space_wrong_dimensions() {
990992
let _ = AffineSpace::new(a, b);
991993
}
992994

995+
/// Sample `n_points` random points on the l_p sphere in R^dim:
996+
///
997+
/// { x : ||x||_p = radius }
998+
///
999+
/// Assumes:
1000+
/// - `p > 0.0`
1001+
/// - `radius > 0.0`
1002+
/// - `dim > 0`
1003+
///
1004+
/// Returns a vector of points, each point being a `Vec<f64>` of length `dim`.
1005+
pub fn sample_lp_sphere(n_points: usize, dim: usize, p: f64, radius: f64) -> Vec<Vec<f64>> {
1006+
assert!(n_points > 0);
1007+
assert!(dim > 0);
1008+
assert!(p > 0.0);
1009+
assert!(radius > 0.0);
1010+
1011+
let mut rng = rand::rng();
1012+
1013+
// Y_i ~ Gamma(shape=1/p, scale=1)
1014+
let gamma = Gamma::new(1.0 / p, 1.0).unwrap();
1015+
1016+
let mut points = Vec::with_capacity(n_points);
1017+
1018+
for _ in 0..n_points {
1019+
let mut g = Vec::with_capacity(dim);
1020+
1021+
for _ in 0..dim {
1022+
let y: f64 = gamma.sample(&mut rng);
1023+
let sign = if rng.random_bool(0.5) { 1.0 } else { -1.0 };
1024+
let value = sign * y.powf(1.0 / p);
1025+
g.push(value);
1026+
}
1027+
1028+
let norm_p = g
1029+
.iter()
1030+
.map(|x: &f64| x.abs().powf(p))
1031+
.sum::<f64>()
1032+
.powf(1.0 / p);
1033+
1034+
let point: Vec<f64> = g.into_iter().map(|x| radius * x / norm_p).collect();
1035+
1036+
points.push(point);
1037+
}
1038+
1039+
points
1040+
}
9931041

9941042
#[test]
995-
fn t_ballp_at_origin() {
996-
let radius = 1.0;
997-
let mut x = [1.0, 1.0];
1043+
fn t_sample_lp_sphere_points_have_correct_norm() {
1044+
let n_points = 100_000;
1045+
let dim = 5;
1046+
let p = 3.2;
1047+
let radius = 0.9;
1048+
1049+
let samples = sample_lp_sphere(n_points, dim, p, radius);
1050+
1051+
assert_eq!(samples.len(), n_points);
1052+
1053+
for point in samples.iter() {
1054+
assert_eq!(point.len(), dim);
1055+
1056+
let norm_p = point
1057+
.iter()
1058+
.map(|xi| xi.abs().powf(p))
1059+
.sum::<f64>()
1060+
.powf(1.0 / p);
1061+
1062+
unit_test_utils::assert_nearly_equal(
1063+
radius,
1064+
norm_p,
1065+
1e-10,
1066+
1e-12,
1067+
"sample_lp_sphere produced a point with incorrect p-norm",
1068+
);
1069+
}
1070+
}
1071+
1072+
/// Check if a given vector `x_candidate_proj` is actually the projection
1073+
/// of `x` onto the p-norm ball centered at the origin with a given radius
1074+
///
1075+
/// This is based on taking `sample_points` on the sphere of the lp-ball.
1076+
fn is_norm_p_projection(
1077+
x: &[f64],
1078+
x_candidate_proj: &[f64],
1079+
p: f64,
1080+
radius: f64,
1081+
sample_points: usize,
1082+
) -> bool {
1083+
let n = x.len();
1084+
assert_eq!(n, x_candidate_proj.len());
1085+
// e = x - x_candidate_proj
1086+
let e: Vec<f64> = x
1087+
.iter()
1088+
.zip(x_candidate_proj.iter())
1089+
.map(|(xi, yi)| xi - yi)
1090+
.collect();
1091+
let samples = sample_lp_sphere(sample_points, n, p, radius);
1092+
for xi in samples.iter() {
1093+
// w = x_candidate_proj - xi
1094+
let w: Vec<f64> = x_candidate_proj
1095+
.iter()
1096+
.zip(xi.iter())
1097+
.map(|(xproj_i, xi_i)| xproj_i - xi_i)
1098+
.collect();
1099+
let inner = matrix_operations::inner_product(&w, &e);
1100+
if inner < 0. {
1101+
return false;
1102+
}
1103+
}
1104+
true
1105+
}
1106+
1107+
#[test]
1108+
fn t_ballp_at_origin_projection() {
1109+
let radius = 0.8;
1110+
let mut x = [1.0, -1.0, 6.0];
1111+
let x0 = x.clone();
9981112
let p = 3.;
999-
let tol = 1e-10;
1113+
let tol = 1e-16;
10001114
let max_iters: usize = 200;
10011115
let ball = BallP::new(None, radius, p, tol, max_iters);
10021116
ball.project(&mut x);
1003-
println!("x = {:?}", x);
1117+
let p_norm_projected = ball.lp_norm(&x);
1118+
// Test that the p-norm of x is equal to the radius
1119+
// (this is because x was not in the lp-ball, so its
1120+
// projection is on the sphere)
1121+
unit_test_utils::assert_nearly_equal(radius, p_norm_projected, 1e-10, 1e-15, "lol");
1122+
assert!(is_norm_p_projection(&x0, &x, p, radius, 10_000));
1123+
}
10041124

1005-
}
1125+
#[test]
1126+
fn t_ballp_at_origin_x_already_inside() {
1127+
let radius = 1.5;
1128+
let mut x = [0.5, -0.2, 0.1];
1129+
let x0 = x.clone();
1130+
let p = 3.;
1131+
let tol = 1e-16;
1132+
let max_iters: usize = 1200;
1133+
let ball = BallP::new(None, radius, p, tol, max_iters);
1134+
ball.project(&mut x);
1135+
assert!(ball.lp_norm(&x) <= radius);
1136+
unit_test_utils::assert_nearly_equal_array(
1137+
&x0,
1138+
&x,
1139+
1e-12,
1140+
1e-12,
1141+
"wrong projection on lp-ball",
1142+
);
1143+
}

0 commit comments

Comments
 (0)