diff --git a/Wrappers/Python/cil/optimisation/functions/HuberLoss.py b/Wrappers/Python/cil/optimisation/functions/HuberLoss.py new file mode 100644 index 0000000000..c9615dc593 --- /dev/null +++ b/Wrappers/Python/cil/optimisation/functions/HuberLoss.py @@ -0,0 +1,281 @@ +# Copyright 2026 United Kingdom Research and Innovation +# Copyright 2026 The University of Manchester +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Authors: +# CIL Developers, listed at: https://github.com/TomographicImaging/CIL/blob/master/NOTICE.txt +# Martin Sæbye Carøe (Technical University of Denmark, DTU Compute) + +import numpy as np +import warnings +from numbers import Number + +from cil.optimisation.functions import Function +from cil.optimisation.operators import DiagonalOperator, LinearOperator + +class HuberLoss(Function): + r""" + (Weighted) Huber loss + + For residual :math:`r = Ax - b`: + + .. math:: + \phi_\delta(r) = + \begin{cases} + 0.5 * r^2 & \text{if } |r| \leq \delta \\ + \delta * (|r| - 0.5*\delta) & \text{otherwise} + \end{cases} + + .. math:: + The (Weighted) Huber loss acts element wise on the residual :math:`r = Ax - b`:. For small values of the residual it acts like a least squares loss and for larger values it acts like an absolute error loss. The idea is that the resulting loss is differentiable and stongly convex close to the minimum while also being robust to outliers far from the minimum. A positive scalar :math:`\delta` controls the change point between the least squares and absolute error loss. + + First define a function, acting on :math:`d\in\mathbb{R}` + + .. math:: + \phi_\delta(d) = + \begin{cases} + 0.5 * r^2 & \text{if } |d| \leq \delta \\ + \delta * (|d| - 0.5*\delta) & \text{otherwise.} + \end{cases} + + This is then applied element wise to give the :code: `HuberLoss`: + + .. math:: + \mathtt{HuberLoss}_\delta(x) = c * \sum_i w_i \phi_\delta([Ax - b]_i). + +Note that :math:`c\in\mathbb{R}` is an optional scalar constant and :math:`w` is an optional weighting vector in range of the operator, :math:`A`, which defaults to a vector of 1s. + + + Parameters + ---------- + A : LinearOperator + b : Data, DataContainer + huber_delta : float + Transition point between L2 and L1 behaviour. Must be positive. + c : float, default 1.0 + Scaling constant + weight : DataContainer, optional + DataContainer with all positive elements of size of the range of operator A, default None + """ + + def __init__(self, A, b, huber_delta, c=1.0, weight=None): + super(HuberLoss, self).__init__() + + if huber_delta <= 0: + raise ValueError("huber_delta must be positive") + + self.A = A + self.b = b + self.c = c + self.huber_delta = huber_delta + + self.weight = weight + self._weight_norm = None + + if weight is not None: + if (self.weight < 0).any(): + raise ValueError("weight contains negative values") + + def __call__(self, x): + + r = self.A.direct(x) + r.subtract(self.b, out=r) + + abs_r = r.abs() + + # m = min(|r|, delta) + m = abs_r.copy() + m.minimum(self.huber_delta, out=m) + + # 0.5 * m^2 + val = m.power(2) + val.multiply(0.5, out=val) + + # delta * (|r| - m) + lin = abs_r.copy() + lin.subtract(m, out=lin) + lin.multiply(self.huber_delta, out=lin) + + val.add(lin, out=val) + + if self.weight is not None: + val.multiply(self.weight, out=val) + + return self.c * val.sum() + + + + def gradient(self, x, out=None): +def gradient(self, x, out=None): + r""" + Returns the gradient of the Huber loss. + + For the residual + + .. math:: + r = Ax - b, + + the derivative of the Huber function is + + .. math:: + \phi_\delta'(r) = + \begin{cases} + r & \text{if } |r| \leq \delta \\ + \delta \operatorname{sign}(r) & \text{otherwise}. + \end{cases} + + Therefore the gradient with respect to :math:`x` is + + .. math:: + \nabla f(x) = + cA^T\left( + w \odot + \phi_\delta'(Ax-b) + \right), + + where :math:`w` denotes the optional weights and + :math:`\odot` denotes element-wise multiplication. If no + weights are supplied, :math:`w=1`. + + Parameters + ---------- + x : DataContainer + Point at which the gradient is evaluated. + out : DataContainer, optional + Container in which to store the result. + + Returns + ------- + DataContainer + The gradient of the Huber loss evaluated at ``x``. + """ + if out is None: + out = x * 0.0 + + r = self.A.direct(x) + r.subtract(self.b, out=r) + + abs_r = r.abs() + + # m = min(|r|, delta) + m = abs_r.copy() + m.minimum(self.huber_delta, out=m) + + # grad wrt residual: sign(r) * m + grad_r = r.sign() + grad_r.multiply(m, out=grad_r) + + if self.weight is not None: + grad_r.multiply(self.weight, out=grad_r) + + self.A.adjoint(grad_r, out=out) + out.multiply(self.c, out=out) + + return out + + + + @property + def L(self): + if self._L is None: + self.calculate_Lipschitz() + return self._L + + @L.setter + def L(self, value): + warnings.warn("You should set the Lipschitz constant with calculate_Lipschitz().") + if isinstance(value, Number) and value >= 0: + self._L = value + else: + raise TypeError("The Lipschitz constant must be non-negative") + + def calculate_Lipschitz(self): + r""" +Calculate the Lipschitz constant of the gradient. + +For the Huber function + +.. math:: + + \max_r \phi_\delta''(r) = 1. + +Therefore, for + +.. math:: + + f(x) = + c \sum_i w_i\,\phi_\delta((Ax-b)_i), + +the Hessian satisfies + +.. math:: + + \nabla^2 f(x) + = + c\,A^T W D(x) A, + +where :math:`D(x)` is a diagonal operator with entries +:math:`\phi_\delta''((Ax-b)_i)`. + +It follows that a Lipschitz constant for the gradient is + +.. math:: + + L = |c|\,\|A\|^2, + +or, in the weighted case, + +.. math:: + + L = |c|\,\|W\|\,\|A\|^2, + +where :math:`W` is the diagonal operator defined by +``weight``. + +""" + try: + self._L = np.abs(self.c) * (self.A.norm() ** 2) + except AttributeError: + if self.A.is_linear(): + Anorm = LinearOperator.PowerMethod(self.A, 10)[0] + self._L = np.abs(self.c) * (Anorm * Anorm) + else: + warnings.warn( + f"{self.__class__.__name__} could not calculate Lipschitz Constant." + ) + + if self.weight is not None: + self._L *= self.weight_norm + + @property + def weight_norm(self): + if self.weight is not None: + if self._weight_norm is None: + D = DiagonalOperator(self.weight) + self._weight_norm = D.norm() + else: + self._weight_norm = 1.0 + return self._weight_norm + + def __rmul__(self, other): + if not isinstance(other, Number): + raise NotImplemented + + return HuberLoss( + A=self.A, + b=self.b, + huber_delta=self.huber_delta, + c=self.c * other, + weight=self.weight + ) diff --git a/Wrappers/Python/cil/optimisation/functions/LeastSquares.py b/Wrappers/Python/cil/optimisation/functions/LeastSquares.py index c641f87944..eaf66e3eb9 100644 --- a/Wrappers/Python/cil/optimisation/functions/LeastSquares.py +++ b/Wrappers/Python/cil/optimisation/functions/LeastSquares.py @@ -44,7 +44,7 @@ class LeastSquares(Function): Note -------- - L is the Lipshitz Constant of the gradient of :math:`F` which is :math:`2 c ||A||_2^2 = 2 c \sigma_1(A)^2`, or :math:`2 c ||W|| ||A||_2^2 = 2c||W|| \sigma_1(A)^2`, where :math:`\sigma_1(A)` is the largest singular value of :math:`A` and :math:`W=\text{diag}(weight)`. + L is the Lipschitz Constant of the gradient of :math:`F` which is :math:`2 c ||A||_2^2 = 2 c \sigma_1(A)^2`, or :math:`2 c ||W|| ||A||_2^2 = 2c||W|| \sigma_1(A)^2`, where :math:`\sigma_1(A)` is the largest singular value of :math:`A` and :math:`W=\text{diag}(weight)`. """ diff --git a/Wrappers/Python/cil/optimisation/functions/__init__.py b/Wrappers/Python/cil/optimisation/functions/__init__.py index 762c4d29ff..2326f5e37e 100644 --- a/Wrappers/Python/cil/optimisation/functions/__init__.py +++ b/Wrappers/Python/cil/optimisation/functions/__init__.py @@ -40,4 +40,5 @@ from .SVRGFunction import SVRGFunction, LSVRGFunction from .SAGFunction import SAGFunction, SAGAFunction from .AbsFunction import FunctionOfAbs +from .HuberLoss import HuberLoss diff --git a/Wrappers/Python/test/test_functions.py b/Wrappers/Python/test/test_functions.py index 4b2296b457..afcbfeaed8 100644 --- a/Wrappers/Python/test/test_functions.py +++ b/Wrappers/Python/test/test_functions.py @@ -22,7 +22,7 @@ import numpy as np from cil.framework import VectorGeometry, VectorData, BlockDataContainer, DataContainer, ImageGeometry, \ - AcquisitionGeometry + AcquisitionGeometry, ImageData from cil.framework.labels import FillType from cil.optimisation.operators import IdentityOperator, MatrixOperator, CompositionOperator, DiagonalOperator, BlockOperator from cil.optimisation.functions import Function, KullbackLeibler, ConstantFunction, TranslateFunction, soft_shrinkage @@ -32,7 +32,7 @@ L1Norm, MixedL21Norm, LeastSquares, \ SmoothMixedL21Norm, OperatorCompositionFunction,\ Rosenbrock, IndicatorBox, TotalVariation, ScaledFunction, SumFunction, SumScalarFunction, \ - WeightedL2NormSquared, MixedL11Norm, ZeroFunction, L1Sparsity, FunctionOfAbs + WeightedL2NormSquared, MixedL11Norm, ZeroFunction, L1Sparsity, FunctionOfAbs, HuberLoss from cil.optimisation.functions import BlockFunction @@ -1528,9 +1528,9 @@ def test_get_p2_with_warm_start(self): for i, x in enumerate(tv._get_p2()): np.testing.assert_allclose(x.as_array(), checkp2[i].as_array(), rtol=1e-8, atol=1e-8, err_msg="P2 not initially set to zero") test=tv.proximal(data, 1.) - print(test) + #print(test) a=np.sum(np.linalg.norm(test)) - print(np.linalg.norm(test)) + #print(np.linalg.norm(test)) for i, x in enumerate(tv._get_p2()): np.testing.assert_equal(np.any(np.not_equal(x.as_array(), checkp2[i].as_array())), True, err_msg="The stored value of p2 doesn't change after calling proximal") np.testing.assert_almost_equal(np.sum(np.linalg.norm(test)),126.3372581, err_msg="Incorrect value of the proximal", decimal=4) @@ -1614,7 +1614,7 @@ def test_rmul_with_gradient(self): twicels.gradient(x, out=y1) np.testing.assert_array_almost_equal(constant * y2.as_array(), y1.as_array()) - + # tests for OperatorCompositionFunction class TestOperatorCompositionFunctionWithWrongInterfaceFunction( @@ -1858,26 +1858,26 @@ def _test_IndicatorBox_pixelwise_call(self, accelerated): im = ig.allocate(2) ib = IndicatorBox(lower=-2 * mask, accelerated=accelerated) for val, res in zip([2, -3], [0, np.inf]): - print("test1", val, res) + #print("test1", val, res) im.fill(val) np.testing.assert_equal(ib(im), res) im = ig.allocate(2) ib = IndicatorBox(lower=-2 * mask, upper=None, accelerated=accelerated) for val, res in zip([2, -3], [0, np.inf]): - print("test1", val, res) + #print("test1", val, res) im.fill(val) np.testing.assert_equal(ib(im), res) im = ig.allocate(2) ib = IndicatorBox(upper=2 * mask, accelerated=accelerated) for val, res in zip([-1, 3], [0, np.inf]): - print("test2", val, res) + #print("test2", val, res) im.fill(val) np.testing.assert_equal(ib(im), res) ib = IndicatorBox(upper=2 * mask, lower=None, accelerated=accelerated) for val, res in zip([-1, 3], [0, np.inf]): - print("test2", val, res) + #print("test2", val, res) im.fill(val) np.testing.assert_equal(ib(im), res) @@ -1885,7 +1885,7 @@ def _test_IndicatorBox_pixelwise_call(self, accelerated): lower=-2 * mask, accelerated=accelerated) for val, res in zip([-1, 1, 3], [np.inf, np.inf, np.inf]): - print("test2", val, res) + #print("test2", val, res) im.fill(val) np.testing.assert_equal(ib(im), res) @@ -1905,7 +1905,7 @@ def _test_IndicatorBox_pixelwise_call_suppress(self, accelerated): ib = IndicatorBox(lower=-2 * mask, accelerated=accelerated) ib.set_suppress_evaluation(True) for val, res in zip([2, -3], [0, 0]): - print("test1", val, res) + #print("test1", val, res) im.fill(val) np.testing.assert_equal(ib(im), res) @@ -1913,14 +1913,14 @@ def _test_IndicatorBox_pixelwise_call_suppress(self, accelerated): ib = IndicatorBox(upper=2 * mask, accelerated=accelerated) ib.set_suppress_evaluation(True) for val, res in zip([-1, 3], [0, 0]): - print("test2", val, res) + #print("test2", val, res) im.fill(val) np.testing.assert_equal(ib(im), res) ib = IndicatorBox(lower=-2 * mask, upper=None, accelerated=accelerated) ib.set_suppress_evaluation(True) for val, res in zip([2, -3], [0, 0]): - print("test1", val, res) + #print("test1", val, res) im.fill(val) np.testing.assert_equal(ib(im), res) @@ -1928,7 +1928,7 @@ def _test_IndicatorBox_pixelwise_call_suppress(self, accelerated): ib = IndicatorBox(upper=2 * mask, lower=None, accelerated=accelerated) ib.set_suppress_evaluation(True) for val, res in zip([-1, 3], [0, 0]): - print("test2", val, res) + #print("test2", val, res) im.fill(val) np.testing.assert_equal(ib(im), res) @@ -1937,7 +1937,7 @@ def _test_IndicatorBox_pixelwise_call_suppress(self, accelerated): accelerated=accelerated) ib.set_suppress_evaluation(True) for val, res in zip([-1, 1, 3], [0, 0, 0]): - print("test2", val, res) + #print("test2", val, res) im.fill(val) np.testing.assert_equal(ib(im), res) @@ -1965,7 +1965,7 @@ def _test_IndicatorBox_pixelwise_proximal(self, accelerated): ib = IndicatorBox(upper=2 * mask, accelerated=accelerated) for val, res in zip([-1, 3], [ig.allocate(-1), 2 * mask]): # log.info("test1 %r %r", val, res) - print("test2", val, res) + #print("test2", val, res) im.fill(val) np.testing.assert_allclose( ib.proximal(im, 1).as_array(), res.as_array()) @@ -1975,7 +1975,7 @@ def _test_IndicatorBox_pixelwise_proximal(self, accelerated): lower=-2 * mask, accelerated=accelerated) for val, res in zip([-1, -3, 1], [-1 * mask, -2 * mask, 1 * mask]): - print("test3", val, res) + #print("test3", val, res) im.fill(val) np.testing.assert_allclose( ib.proximal(im, 1).as_array(), res.as_array()) @@ -2222,5 +2222,272 @@ def test_convex_conjugate_lower_semi(self): def test_convex_conjugate_not_implemented(self): self.abs_function._lower_semi = False - self.assertEqual(self.abs_function.convex_conjugate(self.data_real32), 0.) + +class TestHuberLoss(unittest.TestCase): + + def setUp(self) -> None: + ig = ImageGeometry(40, 30) + A = IdentityOperator(ig) + self.A = A + self.ig = ig + return super().setUp() + + def test_call_r_less_than_delta(self): + numpy.random.seed(1) + b = self.ig.allocate('random', seed=3) + x = self.ig.allocate('random', seed=4) + c = numpy.float32(0.3) + weight = self.ig.allocate('random', seed=5) + f1 = HuberLoss(self.A, b,10000, c, weight=weight) + f2 = HuberLoss(self.A, b,10000, c) + + # check call with weight + res1 = c/2 * (self.A.direct(x) - b).dot(weight * (self.A.direct(x) - b)) + res2 = f1(x) + numpy.testing.assert_almost_equal(res1, res2, decimal=5) + + # check call without weight + res1 = c/2 * (self.A.direct(x) - b).squared_norm() + res2 = f2(x) + numpy.testing.assert_almost_equal(res1, res2, decimal=5) + + def test_call_r_greater_than_delta(self): + huber_delta = 1e-5 + + numpy.random.seed(1) + + b = self.ig.allocate('random', seed=3) + x = self.ig.allocate('random', seed=4) + # need to allocate random but make sure Ax-b is greater than huber_delta + res = self.A.direct(x) - b + mask = np.abs(res.as_array()) < huber_delta + res.array[mask] = huber_delta + 1e-1 + b = self.A.direct(x) - res + c = numpy.float32(0.3) + + weight = self.ig.allocate('random', seed=5) + + f1 = HuberLoss(self.A, b,huber_delta, c, weight=weight) + f2 = HuberLoss(self.A, b,huber_delta, c) + + huber_delta_array = self.ig.allocate(huber_delta) + + # check call with weight + res1 = (c*huber_delta_array * weight* ((self.A.direct(x) - b).abs() - 0.5*huber_delta_array)).sum() + res2 = f1(x) + numpy.testing.assert_almost_equal(res1, res2) + + # check call without weight + res1 = (c*huber_delta_array * ((self.A.direct(x) - b).abs() - 0.5*huber_delta_array)).sum() + res2 = f2(x) + numpy.testing.assert_almost_equal(res1, res2) + + def test_call_elementwise(self): + + ig = ImageGeometry(2, 2) + A = IdentityOperator(ig) + + huber_delta = 0.5 + numpy.random.seed(1) + + x_array = np.array([[0.2, 0.9], [0.2, 0.9]], dtype=np.float32) + x = ImageData(x_array, geometry=ig) + + b_array = np.array([[0.1, 0.1], [0.1, 0.1]], dtype=np.float32) + b = ImageData(b_array, geometry=ig) + + c=2.0 + + f1 = HuberLoss(A, b,huber_delta, c) + + # check call without weight + # for element 0 it less than huber_delta, so it should be 0.5 * c * (r^2) + # for element 1 it greater than huber_delta, so it should be c *huber_delta * (abs(r) - 0.5 * huber_delta) + r = self.A.direct(x) - b + res = f1(x) + + expected_elem0 = 0.5 *c * (r.array[0][0]**2) + expected_elem1 = c * huber_delta * (np.abs(r.array[0][1]) - 0.5 * huber_delta) + + numpy.testing.assert_almost_equal(res, (expected_elem0 + expected_elem1)*2) + + weight = ImageData(np.array([[2.0, 1.0], [2.0, 1.0]], dtype=np.float32), geometry=ig) + f2 = HuberLoss(A, b,huber_delta, c, weight=weight) + res = f2(x) + + expected_elem0 = 0.5 *c * (r.array[0][0]**2) * weight.array[0][0] + expected_elem1 = c * huber_delta * (np.abs(r.array[0][1]) - 0.5 * huber_delta) * weight.array[0][1] + + numpy.testing.assert_almost_equal(res, (expected_elem0 + expected_elem1)*2) + + + def test_gradient_r_less_than_delta(self): + numpy.random.seed(1) + b = self.ig.allocate('random', seed=3) + x = self.ig.allocate('random', seed=4) + c = numpy.float64(0.3) + + weight = self.ig.allocate('random', seed=5) + + f1 = HuberLoss(self.A, b,np.inf, c, weight=weight) + f2 = HuberLoss(self.A, b,np.inf, c) + # check gradient with weight + out = self.ig.allocate(None) + res1 = f1.gradient(x) + f1.gradient(x, out=out) + res2 = c * self.A.adjoint(weight * (self.A.direct(x) - b)) + + numpy.testing.assert_array_almost_equal(res1.as_array(), + res2.as_array()) + numpy.testing.assert_array_almost_equal(out.as_array(), + res2.as_array()) + + #check gradient without weight + out = self.ig.allocate() + res1 = f2.gradient(x) + f2.gradient(x, out=out) + res2 = c * self.A.adjoint(self.A.direct(x) - b) + numpy.testing.assert_array_almost_equal(res1.as_array(), + res2.as_array()) + numpy.testing.assert_array_almost_equal(out.as_array(), + res2.as_array()) + + def test_gradient_r_greater_than_delta(self): + huber_delta = 1e-20 + + numpy.random.seed(1) + + A = IdentityOperator(self.ig) + + b = self.ig.allocate('random', seed=3) + x = self.ig.allocate('random', seed=4) + # need to allocate random but make sure Ax-b is greater than huber_delta + res = A.direct(x) - b + mask = np.abs(res.as_array()) < huber_delta + res.array[mask] = huber_delta + 1e-1 + b = A.direct(x) - res + c = numpy.float64(0.3) + + weight = self.ig.allocate('random', seed=5) + + f1 = HuberLoss(A, b,huber_delta, c, weight=weight) + f2 = HuberLoss(A, b,huber_delta, c) + # check gradient with weight + out = self.ig.allocate(None) + res1 = f1.gradient(x) + f1.gradient(x, out=out) + huber_delta_array = self.ig.allocate(huber_delta) + res2 = c * A.adjoint(huber_delta_array) + + numpy.testing.assert_array_almost_equal(res1.as_array(), + res2.as_array()) + numpy.testing.assert_array_almost_equal(out.as_array(), + res2.as_array()) + + #check gradient without weight + out = self.ig.allocate() + res1 = f2.gradient(x) + f2.gradient(x, out=out) + res2 = c * A.adjoint(huber_delta_array) + numpy.testing.assert_array_almost_equal(res1.as_array(), + res2.as_array()) + numpy.testing.assert_array_almost_equal(out.as_array(), + res2.as_array()) + + def test_rmul(self): + ig = self.ig + A = self.A + b = ig.allocate(1) + c = 1. + constant = 2. + hl = HuberLoss(A, b, huber_delta=1.0, c=c) + twice = constant * hl + + assert constant * hl.c == twice.c + + def test_rmul_with_call(self): + ig = self.ig + A = self.A + b = ig.allocate(1) + x = ig.allocate(3) + c = 1. + constant = 2. + hl = HuberLoss(A, b, huber_delta=1.0, c=c) + twice = constant * hl + np.testing.assert_almost_equal(constant * hl(x), twice(x)) + + def test_rmul_with_Lipschitz(self): + ig = self.ig + A = self.A + b = ig.allocate(1) + x = ig.allocate(3) + c = 1. + constant = 2. + hl = HuberLoss(A, b, huber_delta=1.0, c=c) + twice = constant * hl + + np.testing.assert_almost_equal(constant * hl.L, twice.L) + + def test_rmul_with_gradient(self): + ig = self.ig + A = self.A + b = ig.allocate(1) + x = ig.allocate(3) + c = 1. + constant = 2. + hl = HuberLoss(A, b, huber_delta=1.0, c=c) + twice = constant * hl + + y1 = hl.gradient(x) + y2 = twice.gradient(x) + np.testing.assert_array_almost_equal(constant * y1.as_array(), + y2.as_array()) + + hl.gradient(x, out=y2) + twice.gradient(x, out=y1) + np.testing.assert_array_almost_equal(constant * y2.as_array(), + y1.as_array()) + + def test_weights_input(self): + ig = self.ig + A = self.A + b = ig.allocate(1) + x = ig.allocate(3) + c = 1. + weights = ig.allocate('random', seed=5) + hl = HuberLoss(A, b, huber_delta=1.0, c=c, weight=weights) + + # Check that the weights are correctly set + np.testing.assert_array_almost_equal(hl.weight.as_array(), weights.as_array()) + + # do negative values in weights and check it raises an error + weights_neg = ig.allocate(-1) + with self.assertRaises(ValueError): + HuberLoss(A, b, huber_delta=1.0, c=c, weight=weights_neg) + + def test_huber_delta_input(self): + ig = self.ig + A = self.A + b = ig.allocate(1) + # Check that negative huber_delta raises an error + with self.assertRaises(ValueError): + HuberLoss(A, b, huber_delta=-1.0) + + def test_Lipschitz(self): + numpy.random.seed(1) + b = self.ig.allocate('random', seed=3) + c = numpy.float64(0.3) + + weight = self.ig.allocate('random', seed=5) + + D = DiagonalOperator(weight) + norm_weight = numpy.float64(D.norm()) + + f1 = HuberLoss(self.A, b,10000, c, weight=weight) + f2 = HuberLoss(self.A, b,10000, c) + + # check Lipschitz + numpy.testing.assert_almost_equal(f2.L, c * (self.A.norm()**2)) + numpy.testing.assert_almost_equal( + f1.L, c * norm_weight * (self.A.norm()**2)) diff --git a/docs/source/optimisation.rst b/docs/source/optimisation.rst index 0ec5e4f262..8a4ba0826c 100644 --- a/docs/source/optimisation.rst +++ b/docs/source/optimisation.rst @@ -543,6 +543,12 @@ Mixed L11 norm :members: :inherited-members: +Huber Loss +--------------- +.. autoclass:: cil.optimisation.functions.HuberLoss + :members: + :inherited-members: + Total variation ---------------