From fa7898c55df0b3e0b35342183fd923f982435fe9 Mon Sep 17 00:00:00 2001 From: mika <211269698+mikamikasuki@users.noreply.github.com> Date: Fri, 2 Oct 2026 06:13:09 -0700 Subject: [PATCH 1/2] fix: preserve feasible inputs in L1 ball projection --- pyproximal/projection/L1.py | 9 ++++++++- pytests/test_projection.py | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/pyproximal/projection/L1.py b/pyproximal/projection/L1.py index e6991fd..6b3672c 100644 --- a/pyproximal/projection/L1.py +++ b/pyproximal/projection/L1.py @@ -30,7 +30,12 @@ class L1BallProj: .. math:: - P_{L1_{r}} (\mathbf{x}) = sign(\mathbf{x}) P_{\operatorname{Simplex}(r)}(\mathbf{x}) + P_{L1_{r}} (\mathbf{x}) = + \begin{cases} + \mathbf{x}, & \|\mathbf{x}\|_1 \leq r \\ + sign(\mathbf{x}) P_{\operatorname{Simplex}(r)}(|\mathbf{x}|), + & \text{otherwise} + \end{cases} Note that this is the proximal operator of the corresponding indicator function :math:`\mathcal{I}_{L1_{r}}`. @@ -49,6 +54,8 @@ def __init__( self.simplex = SimplexProj(n, radius, maxiter, xtol) def __call__(self, x: NDArray) -> NDArray: + if np.sum(np.abs(x)) <= self.radius: + return x if np.iscomplexobj(x): return np.exp(1j * np.angle(x)) * self.simplex(np.abs(x)) else: diff --git a/pytests/test_projection.py b/pytests/test_projection.py index 2473d8a..b48d96a 100644 --- a/pytests/test_projection.py +++ b/pytests/test_projection.py @@ -1,8 +1,9 @@ import numpy as np import pytest -from numpy.testing import assert_array_almost_equal +from numpy.testing import assert_array_almost_equal, assert_array_equal from pylops.basicoperators import Identity +from pyproximal.projection import L1BallProj from pyproximal.proximal import ( AffineSet, Box, @@ -157,6 +158,38 @@ def test_L1Ball(par): assert moreau(l1, x, tau) +@pytest.mark.parametrize("dtype", ["float32", "float64", "complex64", "complex128"]) +@pytest.mark.parametrize("values", [[0.125, -0.25], [1.0, 0.0], [0.0, 0.0]]) +def test_L1Ball_feasible_inputs(dtype, values): + """The projection fixes every point already in the ball.""" + x = np.array(values, dtype=dtype) + original = x.copy() + projection = L1BallProj(x.size, radius=1.0) + ball = L1Ball(x.size, radius=1.0) + + assert_array_equal(projection(x), original) + assert_array_equal(ball.prox(x, 0.5), original) + assert_array_equal(ball.prox(x, 2.0), original) + assert_array_equal(x, original) + + +@pytest.mark.parametrize("dtype", ["complex64", "complex128"]) +def test_L1Ball_complex_interior(dtype): + x = np.array([0.125 + 0.125j, 0.25 - 0.125j], dtype=dtype) + ball = L1Ball(x.size, radius=1.0) + assert_array_equal(ball.prox(x, 1.0), x) + + +@pytest.mark.parametrize("dtype", ["float32", "float64", "complex64", "complex128"]) +@pytest.mark.parametrize("tau", [0.5, 2.0]) +def test_L1Ball_dual_inside_scaled_ball(dtype, tau): + """The conjugate prox is zero when x/tau lies inside the ball.""" + x = np.array([0.125, -0.25], dtype=dtype) + ball = L1Ball(x.size, radius=1.0) + assert_array_equal(ball.proxdual(x, tau), np.zeros_like(x)) + assert moreau(ball, x, tau) + + @pytest.mark.parametrize("par", [(par1), (par2)]) def test_NuclBall(par): """Nuclear Ball projection and proximal/dual proximal of related indicator""" From 9f48cf60ddb99f8cace0b61b9917589cebfde207 Mon Sep 17 00:00:00 2001 From: mika <211269698+mikamikasuki@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:48:01 -0700 Subject: [PATCH 2/2] test: clarify feasible L1 projection cases --- pytests/test_projection.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pytests/test_projection.py b/pytests/test_projection.py index b48d96a..3838289 100644 --- a/pytests/test_projection.py +++ b/pytests/test_projection.py @@ -161,7 +161,7 @@ def test_L1Ball(par): @pytest.mark.parametrize("dtype", ["float32", "float64", "complex64", "complex128"]) @pytest.mark.parametrize("values", [[0.125, -0.25], [1.0, 0.0], [0.0, 0.0]]) def test_L1Ball_feasible_inputs(dtype, values): - """The projection fixes every point already in the ball.""" + """Check that the projection returns the input as already in the ball.""" x = np.array(values, dtype=dtype) original = x.copy() projection = L1BallProj(x.size, radius=1.0) @@ -175,6 +175,7 @@ def test_L1Ball_feasible_inputs(dtype, values): @pytest.mark.parametrize("dtype", ["complex64", "complex128"]) def test_L1Ball_complex_interior(dtype): + """Check that complex inputs already in the ball are returned unchanged.""" x = np.array([0.125 + 0.125j, 0.25 - 0.125j], dtype=dtype) ball = L1Ball(x.size, radius=1.0) assert_array_equal(ball.prox(x, 1.0), x)