From 9a53353ea2661af36a598a91826abb81f8a0e7a3 Mon Sep 17 00:00:00 2001 From: Chaithya G R Date: Wed, 14 Apr 2021 16:41:33 +0200 Subject: [PATCH 01/46] Add support for tensorflow backend which allows for differentiability (#112) * Added support for tensorflow * Updates to get tests passing * Or --> And * Moving modopt to allow working with tensorflow * Fix issues with wos * Fix all flakes finally! * Update modopt/base/backend.py Co-authored-by: Samuel Farrens * Update modopt/base/backend.py Co-authored-by: Samuel Farrens * Minute updates to codes * Add dynamic module * Fix docu * Fix PEP Co-authored-by: chaithyagr Co-authored-by: Samuel Farrens --- modopt/base/backend.py | 108 +++++++++++++++++++++++++++------------ modopt/math/matrix.py | 16 ++---- modopt/opt/algorithms.py | 46 ++++++----------- setup.cfg | 3 +- 4 files changed, 100 insertions(+), 73 deletions(-) diff --git a/modopt/base/backend.py b/modopt/base/backend.py index 1f5a7a3a..def77151 100644 --- a/modopt/base/backend.py +++ b/modopt/base/backend.py @@ -8,35 +8,73 @@ """ -import warnings from importlib import util import numpy as np +from modopt.interface.errors import warn + try: import torch + from torch.utils.dlpack import from_dlpack as torch_from_dlpack + from torch.utils.dlpack import to_dlpack as torch_to_dlpack + except ImportError: # pragma: no cover import_torch = False else: import_torch = True # Handle the compatibility with variable -gpu_compatibility = { - 'cupy': False, - 'cupy-cudnn': False, +LIBRARIES = { + 'cupy': None, + 'tensorflow': None, + 'numpy': np, } if util.find_spec('cupy') is not None: try: import cupy as cp - gpu_compatibility['cupy'] = True + LIBRARIES['cupy'] = cp + except ImportError: + pass - if util.find_spec('cupy.cuda.cudnn') is not None: - gpu_compatibility['cupy-cudnn'] = True +if util.find_spec('tensorflow') is not None: + try: + from tensorflow.experimental import numpy as tnp + LIBRARIES['tensorflow'] = tnp except ImportError: pass +def get_backend(backend): + """Get backend. + + Returns the backend module for input specified by string + + Parameters + ---------- + backend: str + String holding the backend name. One of `tensorflow`, + `numpy` or `cupy`. + + Returns + ------- + tuple + Returns the module for carrying out calculations and the actual backend + that was reverted towards. If the right libraries are not installed, + the function warns and reverts to `numpy` backend + """ + if backend not in LIBRARIES.keys() or LIBRARIES[backend] is None: + msg = ( + '{0} backend not possible, please ensure that ' + + 'the optional libraries are installed.\n' + + 'Reverting to numpy' + ) + warn(msg.format(backend)) + backend = 'numpy' + return LIBRARIES[backend], backend + + def get_array_module(input_data): """Get Array Module. @@ -54,48 +92,47 @@ def get_array_module(input_data): The numpy or cupy module """ - if gpu_compatibility['cupy']: - return cp.get_array_module(input_data) - + if LIBRARIES['tensorflow'] is not None: + if isinstance(input_data, LIBRARIES['tensorflow'].ndarray): + return LIBRARIES['tensorflow'] + elif LIBRARIES['cupy'] is not None: + if isinstance(input_data, LIBRARIES['cupy'].ndarray): + return LIBRARIES['cupy'] return np -def move_to_device(input_data): +def change_backend(input_data, backend='cupy'): """Move data to device. - This method moves data from CPU to GPU if we have the - compatibility to do so. It returns the same data if - it is already on GPU. + This method changes the backend of an array + This can be used to copy data to GPU or to CPU Parameters ---------- input_data : numpy.ndarray or cupy.ndarray Input data array to be moved + backend: str, optional + The backend to use, one among `tensorflow`, `cupy` and + `numpy`. Default is `cupy`. Returns ------- - cupy.ndarray - The CuPy array residing on GPU + backend.ndarray + An ndarray of specified backend """ xp = get_array_module(input_data) - - if xp == cp: + txp, target_backend = get_backend(backend) + if xp == txp: return input_data - - if gpu_compatibility['cupy']: - return cp.array(input_data) - - warnings.warn('Cupy is not installed, cannot move data to GPU') - - return input_data + return txp.array(input_data) def move_to_cpu(input_data): """Move data to CPU. - This method moves data from GPU to CPU.It returns the same data if it is - already on CPU. + This method moves data from GPU to CPU. + It returns the same data if it is already on CPU. Parameters ---------- @@ -107,13 +144,20 @@ def move_to_cpu(input_data): numpy.ndarray The NumPy array residing on CPU + Raises + ------ + ValueError + if the input does not correspond to any array """ xp = get_array_module(input_data) - if xp == np: + if xp == LIBRARIES['numpy']: return input_data - - return input_data.get() + elif xp == LIBRARIES['cupy']: + return input_data.get() + elif xp == LIBRARIES['tensorflow']: + return input_data.data.numpy() + raise ValueError('Cannot identify the array type.') def convert_to_tensor(input_data): @@ -150,7 +194,7 @@ def convert_to_tensor(input_data): if xp == np: return torch.Tensor(input_data) - return torch.utils.dlpack.from_dlpack(input_data.toDlpack()).float() + return torch_from_dlpack(input_data.toDlpack()).float() def convert_to_cupy_array(input_data): @@ -182,6 +226,6 @@ def convert_to_cupy_array(input_data): ) if input_data.is_cuda: - return cp.fromDlpack(torch.utils.dlpack.to_dlpack(input_data)) + return cp.fromDlpack(torch_to_dlpack(input_data)) return input_data.detach().numpy() diff --git a/modopt/math/matrix.py b/modopt/math/matrix.py index cb54cebc..be737f52 100644 --- a/modopt/math/matrix.py +++ b/modopt/math/matrix.py @@ -12,12 +12,7 @@ import numpy as np -from modopt.base.backend import get_array_module - -try: - import cupy as cp -except ImportError: # pragma: no cover - pass +from modopt.base.backend import get_array_module, get_backend def gram_schmidt(matrix, return_opt='orthonormal'): @@ -303,7 +298,7 @@ def __init__( data_shape, data_type=float, auto_run=True, - use_gpu=False, + compute_backend='numpy', verbose=False, ): @@ -311,10 +306,9 @@ def __init__( self._data_shape = data_shape self._data_type = data_type self._verbose = verbose - if use_gpu: - self.xp = cp - else: - self.xp = np + xp, compute_backend = get_backend(compute_backend) + self.xp = xp + self.compute_backend = compute_backend if auto_run: self.get_spec_rad() diff --git a/modopt/opt/algorithms.py b/modopt/opt/algorithms.py index ccd8fe21..fea737cb 100644 --- a/modopt/opt/algorithms.py +++ b/modopt/opt/algorithms.py @@ -54,11 +54,6 @@ from modopt.opt.cost import costObj from modopt.opt.linear import Identity -try: - import cupy as cp -except ImportError: # pragma: no cover - pass - class SetUp(Observable): r"""Algorithm Set-Up. @@ -92,7 +87,7 @@ def __init__( verbose=False, progress=True, step_size=None, - use_gpu=False, + compute_backend='numpy', **dummy_kwargs, ): @@ -123,20 +118,9 @@ def __init__( ) self.add_observer('cv_metrics', observer) - # Check for GPU - if use_gpu: - if backend.gpu_compatibility['cupy']: - self.xp = cp - else: - warn( - 'CuPy is not installed, cannot run on GPU!' - + 'Running optimization on CPU.', - ) - self.xp = np - use_gpu = False - else: - self.xp = np - self.use_gpu = use_gpu + xp, compute_backend = backend.get_backend(compute_backend) + self.xp = xp + self.compute_backend = compute_backend @property def metrics(self): @@ -148,7 +132,9 @@ def metrics(self, metrics): if isinstance(metrics, type(None)): self._metrics = {} - elif not isinstance(metrics, dict): + elif isinstance(metrics, dict): + self._metrics = metrics + else: raise TypeError( 'Metrics must be a dictionary, not {0}.'.format(type(metrics)), ) @@ -184,10 +170,10 @@ def copy_data(self, input_data): Copy of input data """ - if self.use_gpu: - return backend.move_to_device(input_data) - - return self.xp.copy(input_data) + return self.xp.copy(backend.change_backend( + input_data, + self.compute_backend, + )) def _check_input_data(self, input_data): """Check input data type. @@ -205,8 +191,10 @@ def _check_input_data(self, input_data): For invalid input type """ - if not isinstance(input_data, self.xp.ndarray): - raise TypeError('Input data must be a numpy array.') + if not (isinstance(input_data, (self.xp.ndarray, np.ndarray))): + raise TypeError( + 'Input data must be a numpy array or backend array', + ) def _check_param(self, param_val): """Check algorithm parameters. @@ -779,8 +767,8 @@ def _update(self): self._z_new = self._x_new # Update old values for next iteration. - self.xp.copyto(self._x_old, self._x_new) - self.xp.copyto(self._z_old, self._z_new) + self._x_old = self.xp.copy(self._x_new) + self._z_old = self.xp.copy(self._z_new) # Update parameter values for next iteration. self._update_param() diff --git a/setup.cfg b/setup.cfg index 56a40d12..74fb3f79 100644 --- a/setup.cfg +++ b/setup.cfg @@ -26,7 +26,8 @@ per-file-ignores = #Justification: Needed for keeping package version and current API *__init__.py*: F401,F403,WPS347,WPS410,WPS412 #Todo: Rethink conditional imports - modopt/base/backend.py: WPS229, WPS420 + #Todo: How can we bypass mutable constants? + modopt/base/backend.py: WPS229, WPS420, WPS407 #Todo: Rethink conditional imports modopt/base/observable.py: WPS420,WPS604 #Todo: Check string for log formatting From 514d0f712b0f8fc00c271f1ec2f24f9a1bdeb09c Mon Sep 17 00:00:00 2001 From: Chaithya G R Date: Fri, 16 Apr 2021 11:40:05 +0200 Subject: [PATCH 02/46] Fix 115 (#116) * Fix issues * Add right tests * Fix PEP Co-authored-by: chaithyagr --- modopt/opt/algorithms.py | 4 +-- modopt/tests/test_opt.py | 64 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/modopt/opt/algorithms.py b/modopt/opt/algorithms.py index fea737cb..125ac84c 100644 --- a/modopt/opt/algorithms.py +++ b/modopt/opt/algorithms.py @@ -777,7 +777,7 @@ def _update(self): if self._cost_func: self.converge = ( self.any_convergence_flag() - or self._cost_func.get_cost(self._x_new), + or self._cost_func.get_cost(self._x_new) ) def iterate(self, max_iter=150): @@ -1536,7 +1536,7 @@ def _update(self): if self._cost_func: self.converge = ( self.any_convergence_flag() - or self._cost_func.get_cost(self._x_new), + or self._cost_func.get_cost(self._x_new) ) def iterate(self, max_iter=150): diff --git a/modopt/tests/test_opt.py b/modopt/tests/test_opt.py index b6805006..3c33c948 100644 --- a/modopt/tests/test_opt.py +++ b/modopt/tests/test_opt.py @@ -58,6 +58,17 @@ def setUp(self): reweight_inst = reweight.cwbReweight(self.data3) cost_inst = cost.costObj([grad_inst, prox_inst, prox_dual_inst]) self.setup = algorithms.SetUp() + self.max_iter = 20 + + self.fb_all_iter = algorithms.ForwardBackward( + self.data1, + grad=grad_inst, + prox=prox_inst, + cost=None, + auto_iterate=False, + beta_update=func_identity, + ) + self.fb_all_iter.iterate(self.max_iter) self.fb1 = algorithms.ForwardBackward( self.data1, @@ -110,6 +121,17 @@ def setUp(self): s_greedy=1.1, ) + self.gfb_all_iter = algorithms.GenForwardBackward( + self.data1, + grad=grad_inst, + prox_list=[prox_inst, prox_dual_inst], + cost=None, + auto_iterate=False, + gamma_update=func_identity, + beta_update=func_identity, + ) + self.gfb_all_iter.iterate(self.max_iter) + self.gfb1 = algorithms.GenForwardBackward( self.data1, grad=grad_inst, @@ -133,6 +155,20 @@ def setUp(self): step_size=2, ) + self.condat_all_iter = algorithms.Condat( + self.data1, + self.data2, + grad=grad_inst, + prox=prox_inst, + cost=None, + prox_dual=prox_dual_inst, + sigma_update=func_identity, + tau_update=func_identity, + rho_update=func_identity, + auto_iterate=False, + ) + self.condat_all_iter.iterate(self.max_iter) + self.condat1 = algorithms.Condat( self.data1, self.data2, @@ -166,6 +202,18 @@ def setUp(self): auto_iterate=False, ) + self.pogm_all_iter = algorithms.POGM( + u=self.data1, + x=self.data1, + y=self.data1, + z=self.data1, + grad=grad_inst, + prox=prox_inst, + auto_iterate=False, + cost=None, + ) + self.pogm_all_iter.iterate(self.max_iter) + self.pogm1 = algorithms.POGM( u=self.data1, x=self.data1, @@ -184,13 +232,18 @@ def tearDown(self): self.data1 = None self.data2 = None self.setup = None + self.fb_all_iter = None self.fb1 = None self.fb2 = None + self.gfb_all_iter = None self.gfb1 = None self.gfb2 = None + self.condat_all_iter = None self.condat1 = None self.condat2 = None self.condat3 = None + self.pogm1 = None + self.pogm_all_iter = None self.dummy = None def test_set_up(self): @@ -201,6 +254,17 @@ def test_set_up(self): npt.assert_raises(TypeError, self.setup._check_param_update, 1) + def test_all_iter(self): + """Test if all opt run for all iterations.""" + opts = [ + self.fb_all_iter, + self.gfb_all_iter, + self.condat_all_iter, + self.pogm_all_iter, + ] + for opt in opts: + npt.assert_equal(opt.idx, self.max_iter - 1) + def test_forward_backward(self): """Test forward_backward.""" npt.assert_array_equal( From 063a61169bb1dd09a6e36bd80d1380f8483393db Mon Sep 17 00:00:00 2001 From: Chaithya G R Date: Tue, 20 Apr 2021 10:03:25 +0200 Subject: [PATCH 03/46] Minor bug fix, remove elif (#124) Co-authored-by: chaithyagr --- modopt/base/backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modopt/base/backend.py b/modopt/base/backend.py index def77151..5fbe912f 100644 --- a/modopt/base/backend.py +++ b/modopt/base/backend.py @@ -95,7 +95,7 @@ def get_array_module(input_data): if LIBRARIES['tensorflow'] is not None: if isinstance(input_data, LIBRARIES['tensorflow'].ndarray): return LIBRARIES['tensorflow'] - elif LIBRARIES['cupy'] is not None: + if LIBRARIES['cupy'] is not None: if isinstance(input_data, LIBRARIES['cupy'].ndarray): return LIBRARIES['cupy'] return np From 1d2bd7b1a12d78f87ac1f5a166315001983a0c64 Mon Sep 17 00:00:00 2001 From: Chaithya G R Date: Wed, 21 Apr 2021 16:19:38 +0200 Subject: [PATCH 04/46] Add tests for modopt.base.backend and fix minute bug uncovered (#126) * Minor bug fix, remove elif * Add tests for backend * Fix tests * Add tests * Remove cupy * PEP fixes * Fix PEP * Fix PEP and update * Final PEP * Update setup.cfg Co-authored-by: Samuel Farrens * Update test_base.py Co-authored-by: chaithyagr Co-authored-by: Samuel Farrens --- .github/workflows/ci-build.yml | 1 + modopt/tests/test_base.py | 54 +++++++++++++++++++++++++++++++++- setup.cfg | 2 ++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 2ed4960d..3ffcb6f4 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -53,6 +53,7 @@ jobs: python -m pip install -r develop.txt python -m pip install -r docs/requirements.txt python -m pip install astropy scikit-image scikit-learn + python -m pip install tensorflow>=2.4.1 python -m pip install twine python -m pip install . diff --git a/modopt/tests/test_base.py b/modopt/tests/test_base.py index 26e1e4ea..873a4506 100644 --- a/modopt/tests/test_base.py +++ b/modopt/tests/test_base.py @@ -9,12 +9,14 @@ """ from builtins import range -from unittest import TestCase +from unittest import TestCase, skipIf import numpy as np import numpy.testing as npt from modopt.base import np_adjust, transform, types +from modopt.base.backend import (LIBRARIES, change_backend, get_array_module, + get_backend) class NPAdjustTestCase(TestCase): @@ -275,3 +277,53 @@ def test_check_npndarray(self): self.data3, dtype=np.integer, ) + + +class TestBackend(TestCase): + """Test the backend codes.""" + + def setUp(self): + """Set test parameter values.""" + self.input = np.array([10, 10]) + + @skipIf(LIBRARIES['tensorflow'] is None, 'tensorflow library not installed') + def test_tf_backend(self): + """Test tensorflow backend.""" + xp, backend = get_backend('tensorflow') + if backend != 'tensorflow' or xp != LIBRARIES['tensorflow']: + raise AssertionError('tensorflow get_backend fails!') + tf_input = change_backend(self.input, 'tensorflow') + if ( + get_array_module(LIBRARIES['tensorflow'].ones(1)) != LIBRARIES['tensorflow'] + or get_array_module(tf_input) != LIBRARIES['tensorflow'] + ): + raise AssertionError('tensorflow backend fails!') + + @skipIf(LIBRARIES['cupy'] is None, 'cupy library not installed') + def test_cp_backend(self): + """Test cupy backend.""" + xp, backend = get_backend('cupy') + if backend != 'cupy' or xp != LIBRARIES['cupy']: + raise AssertionError('cupy get_backend fails!') + cp_input = change_backend(self.input, 'cupy') + if ( + get_array_module(LIBRARIES['cupy'].ones(1)) != LIBRARIES['cupy'] + or get_array_module(cp_input) != LIBRARIES['cupy'] + ): + raise AssertionError('cupy backend fails!') + + def test_np_backend(self): + """Test numpy backend.""" + xp, backend = get_backend('numpy') + if backend != 'numpy' or xp != LIBRARIES['numpy']: + raise AssertionError('numpy get_backend fails!') + np_input = change_backend(self.input, 'numpy') + if ( + get_array_module(LIBRARIES['numpy'].ones(1)) != LIBRARIES['numpy'] + or get_array_module(np_input) != LIBRARIES['numpy'] + ): + raise AssertionError('numpy backend fails!') + + def tearDown(self): + """Tear Down of objects.""" + self.input = None diff --git a/setup.cfg b/setup.cfg index 74fb3f79..d2f544f0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -55,6 +55,8 @@ per-file-ignores = modopt/signal/wavelet.py: S404,S603 #Todo: Clean up tests modopt/tests/*.py: E731,F401,WPS301,WPS420,WPS425,WPS437,WPS604 + #Todo: Import has bad parenthesis + modopt/tests/test_base.py: WPS318,WPS319,E501,WPS301 #WPS Settings max-arguments = 25 max-attributes = 40 From 46b718a8c56b86061e53d0762080a0e2257186ad Mon Sep 17 00:00:00 2001 From: Samuel Farrens Date: Wed, 21 Apr 2021 22:22:12 +0200 Subject: [PATCH 05/46] Release cleanup (#128) * updated GPU dependencies * added logo to manifest * updated package version and release date --- MANIFEST.in | 1 + README.md | 1 + docs/source/dependencies.rst | 6 ++++++ docs/source/index.rst | 4 ++-- setup.py | 9 +++++---- 5 files changed, 15 insertions(+), 6 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index 9a2f374e..74db0634 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,3 +3,4 @@ include develop.txt include docs/requirements.txt include README.rst include LICENSE.txt +include docs/source/modopt_logo.png diff --git a/README.md b/README.md index aa72d976..0f7501f0 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ Note that none of these are required for running on a CPU. * [CuPy](https://cupy.dev/) * [Torch](https://pytorch.org/) +* [TensorFlow](https://www.tensorflow.org/) ## Citation diff --git a/docs/source/dependencies.rst b/docs/source/dependencies.rst index 782807f8..2a513158 100644 --- a/docs/source/dependencies.rst +++ b/docs/source/dependencies.rst @@ -82,6 +82,7 @@ For GPU compliance the following packages can also be installed: * |link-to-cupy| * |link-to-torch| +* |link-to-tf| .. |link-to-cupy| raw:: html @@ -93,6 +94,11 @@ For GPU compliance the following packages can also be installed: Torch +.. |link-to-tf| raw:: html + + TensorFlow + .. note:: Note that none of these are required for running on a CPU. diff --git a/docs/source/index.rst b/docs/source/index.rst index 8262d43d..238aa5b6 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -12,8 +12,8 @@ ModOpt Documentation .. include:: toc.rst :Author: Samuel Farrens `(samuel.farrens@cea.fr) `_ -:Version: 1.5.0 -:Release Date: 31/03/2021 +:Version: 1.5.1 +:Release Date: 22/04/2021 :Repository: |link-to-repo| .. |link-to-repo| raw:: html diff --git a/setup.py b/setup.py index 92040f6d..841ca1b1 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ # Set the package release version major = 1 minor = 5 -patch = 0 +patch = 1 # Set the package details name = 'modopt' @@ -29,9 +29,10 @@ os_str = 'Operating System :: {0}' classifiers = ( - [lc_str.format(license)] + [ln_str] + - [py_str.format(ver) for ver in python_versions_supported] + - [os_str.format(ops) for ops in os_platforms_supported] + [lc_str.format(license)] + + [ln_str] + + [py_str.format(ver) for ver in python_versions_supported] + + [os_str.format(ops) for ops in os_platforms_supported] ) # Source package description from README.md From 4035f514614e42f896349e4efd14755b44c4a92b Mon Sep 17 00:00:00 2001 From: Samuel Farrens Date: Fri, 26 Nov 2021 17:17:39 +0100 Subject: [PATCH 06/46] Unpin package dependencies (#189) * unpinned dependencies * updated pinned documentation dependency versions --- .pyup.yml | 3 +++ develop.txt | 16 ++++++++-------- docs/requirements.txt | 8 ++++---- docs/source/dependencies.rst | 8 ++++---- requirements.txt | 8 ++++---- setup.cfg | 2 +- 6 files changed, 24 insertions(+), 21 deletions(-) diff --git a/.pyup.yml b/.pyup.yml index d844b78b..4c53f50a 100644 --- a/.pyup.yml +++ b/.pyup.yml @@ -7,5 +7,8 @@ label_prs: update assignees: sfarrens requirements: - requirements.txt + pin: False - develop.txt + pin: False - docs/requirements.txt + pin: True diff --git a/develop.txt b/develop.txt index b88396e7..98b9233d 100644 --- a/develop.txt +++ b/develop.txt @@ -1,8 +1,8 @@ -coverage==5.5 -nose==1.3.7 -pytest==6.2.2 -pytest-cov==2.11.1 -pytest-pep8==1.0.6 -pytest-emoji==0.2.0 -pytest-flake8==1.0.7 -wemake-python-styleguide==0.15.2 +coverage>=5.5 +nose>=1.3.7 +pytest>=6.2.2 +pytest-cov>=2.11.1 +pytest-pep8>=1.0.6 +pytest-emoji>=0.2.0 +pytest-flake8>=1.0.7 +wemake-python-styleguide>=0.15.2 diff --git a/docs/requirements.txt b/docs/requirements.txt index e1c873a2..16d3ca09 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,7 +1,7 @@ jupyter==1.0.0 -nbsphinx==0.8.2 +nbsphinx==0.8.7 nbsphinx-link==1.3.0 numpydoc==1.1.0 -sphinx==3.5.2 -sphinxcontrib-bibtex==2.2.0 -sphinxawesome-theme==1.19.2 +sphinx==4.3.0 +sphinxcontrib-bibtex==2.4.1 +sphinxawesome-theme==3.2.0 diff --git a/docs/source/dependencies.rst b/docs/source/dependencies.rst index 2a513158..524361f5 100644 --- a/docs/source/dependencies.rst +++ b/docs/source/dependencies.rst @@ -12,10 +12,10 @@ Required Packages In order to use ModOpt the following packages must be installed: * |link-to-python| ``[> 3.6]`` -* |link-to-metadata| ``[==3.7.0]`` -* |link-to-numpy| ``[==1.19.5]`` -* |link-to-scipy| ``[==1.5.4]`` -* |link-to-progressbar| ``[==3.53.1]`` +* |link-to-metadata| ``[>=3.7.0]`` +* |link-to-numpy| ``[>=1.19.5]`` +* |link-to-scipy| ``[>=1.5.4]`` +* |link-to-progressbar| ``[>=3.53.1]`` .. |link-to-python| raw:: html diff --git a/requirements.txt b/requirements.txt index 3c0e6d4f..63a404ba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -importlib_metadata==3.7.0 -numpy==1.19.5 -scipy==1.5.4 -progressbar2==3.53.1 +importlib_metadata>=3.7.0 +numpy>=1.19.5 +scipy>=1.5.4 +progressbar2>=3.53.1 diff --git a/setup.cfg b/setup.cfg index d2f544f0..14784b51 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,7 +2,7 @@ test=pytest [metadata] -description-file = README.rst +description_file = README.rst [darglint] docstring_style = numpy From 353ef4d05d175c52b7db2fd4eb3c376b63d1a877 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Comby <77174042+paquiteau@users.noreply.github.com> Date: Fri, 10 Dec 2021 10:40:16 +0100 Subject: [PATCH 07/46] Add Gradient descent algorithms (#196) * Version 1.5.1 patch release (#114) * Add support for tensorflow backend which allows for differentiability (#112) * Added support for tensorflow * Updates to get tests passing * Or --> And * Moving modopt to allow working with tensorflow * Fix issues with wos * Fix all flakes finally! * Update modopt/base/backend.py Co-authored-by: Samuel Farrens * Update modopt/base/backend.py Co-authored-by: Samuel Farrens * Minute updates to codes * Add dynamic module * Fix docu * Fix PEP Co-authored-by: chaithyagr Co-authored-by: Samuel Farrens * Fix 115 (#116) * Fix issues * Add right tests * Fix PEP Co-authored-by: chaithyagr * Minor bug fix, remove elif (#124) Co-authored-by: chaithyagr * Add tests for modopt.base.backend and fix minute bug uncovered (#126) * Minor bug fix, remove elif * Add tests for backend * Fix tests * Add tests * Remove cupy * PEP fixes * Fix PEP * Fix PEP and update * Final PEP * Update setup.cfg Co-authored-by: Samuel Farrens * Update test_base.py Co-authored-by: chaithyagr Co-authored-by: Samuel Farrens * Release cleanup (#128) * updated GPU dependencies * added logo to manifest * updated package version and release date Co-authored-by: Chaithya G R Co-authored-by: chaithyagr * make algorithms a module. * add Gradient Descent Algorithms * enforce WPS compliance. * add test for gradient descent * Docstrings improvements * Add See Also and minor corrections * add idx initialisation for all algorithms. * fix merge error * fix typo Co-authored-by: Samuel Farrens Co-authored-by: Chaithya G R Co-authored-by: chaithyagr --- docs/source/refs.bib | 19 + modopt/opt/algorithms/__init__.py | 56 ++ modopt/opt/algorithms/base.py | 292 +++++++++ .../forward_backward.py} | 614 +----------------- modopt/opt/algorithms/gradient_descent.py | 428 ++++++++++++ modopt/opt/algorithms/primal_dual.py | 267 ++++++++ modopt/tests/test_algorithms.py | 470 ++++++++++++++ setup.cfg | 10 +- 8 files changed, 1568 insertions(+), 588 deletions(-) create mode 100644 modopt/opt/algorithms/__init__.py create mode 100644 modopt/opt/algorithms/base.py rename modopt/opt/{algorithms.py => algorithms/forward_backward.py} (64%) create mode 100644 modopt/opt/algorithms/gradient_descent.py create mode 100644 modopt/opt/algorithms/primal_dual.py create mode 100644 modopt/tests/test_algorithms.py diff --git a/docs/source/refs.bib b/docs/source/refs.bib index cac8159e..62ef87f0 100644 --- a/docs/source/refs.bib +++ b/docs/source/refs.bib @@ -175,3 +175,22 @@ @article{zou2005 journal = {Journal of the Royal Statistical Society Series B}, doi = {10.1111/j.1467-9868.2005.00527.x} } + +@article{ruder2017, + title = {An overview of gradient descent optimization algorithms}, + url = {http://arxiv.org/abs/1609.04747}, + journaltitle = {{arXiv}:1609.04747 [cs]}, + author = {Ruder, Sebastian}, + urldate = {2021-12-09}, + date = {2017-06-15}, + langid = {english}, + eprinttype = {arxiv}, + eprint = {1609.04747}, +} + +@article{defazio2014, + title = {{SAGA}: A Fast Incremental Gradient Method With Support for Non-Strongly Convex Composite Objectives}, + pages = {15}, + author = {Defazio, Aaron and Bach, Francis and Lacoste-Julien, Simon}, + langid = {english}, +} diff --git a/modopt/opt/algorithms/__init__.py b/modopt/opt/algorithms/__init__.py new file mode 100644 index 00000000..aa645e47 --- /dev/null +++ b/modopt/opt/algorithms/__init__.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +r"""OPTIMISATION ALGOTITHMS. + +This module contains class implementations of various optimisation algoritms. + +:Authors: Samuel Farrens , + Zaccharie Ramzi + +:Notes: + +Input classes must have the following properties: + + * **Gradient Operators** + + Must have the following methods: + + * ``get_grad()`` - calculate the gradient + + Must have the following variables: + + * ``grad`` - the gradient + + * **Linear Operators** + + Must have the following methods: + + * ``op()`` - operator + * ``adj_op()`` - adjoint operator + + * **Proximity Operators** + + Must have the following methods: + + * ``op()`` - operator + +The following notation is used to implement the algorithms: + + * x_old is used in place of :math:`x_{n}`. + * x_new is used in place of :math:`x_{n+1}`. + * x_prox is used in place of :math:`\tilde{x}_{n+1}`. + * x_temp is used for intermediate operations. + +""" + +from modopt.opt.algorithms.base import SetUp +from modopt.opt.algorithms.forward_backward import (FISTA, POGM, + ForwardBackward, + GenForwardBackward) +from modopt.opt.algorithms.gradient_descent import (AdaGenericGradOpt, + ADAMGradOpt, + GenericGradOpt, + MomentumGradOpt, + RMSpropGradOpt, + SAGAOptGradOpt, + VanillaGenericGradOpt) +from modopt.opt.algorithms.primal_dual import Condat diff --git a/modopt/opt/algorithms/base.py b/modopt/opt/algorithms/base.py new file mode 100644 index 00000000..9191642e --- /dev/null +++ b/modopt/opt/algorithms/base.py @@ -0,0 +1,292 @@ +# -*- coding: utf-8 -*- +"""Base SetUp for optimisation algorithms.""" + +from inspect import getmro + +import numpy as np +from progressbar import ProgressBar + +from modopt.base import backend +from modopt.base.observable import MetricObserver, Observable +from modopt.interface.errors import warn + + +class SetUp(Observable): + r"""Algorithm Set-Up. + + This class contains methods for checking the set-up of an optimisation + algotithm and produces warnings if they do not comply. + + Parameters + ---------- + metric_call_period : int, optional + Metric call period (default is ``5``) + metrics : dict, optional + Metrics to be used (default is ``\{\}``) + verbose : bool, optional + Option for verbose output (default is ``False``) + progress : bool, optional + Option to display progress bar (default is ``True``) + step_size : int, optional + Generic step size parameter to override default algorithm + parameter name (`e.g.` `step_size` will override the value set for + `beta_param` in `ForwardBackward`) + use_gpu : bool, optional + Option to use available GPU + + See Also + -------- + modopt.base.observable.MetricObserver : + Definition of Metrics. + """ + + def __init__( + self, + metric_call_period=5, + metrics=None, + verbose=False, + progress=True, + step_size=None, + compute_backend='numpy', + **dummy_kwargs, + ): + self.idx = 0 + self.converge = False + self.verbose = verbose + self.progress = progress + self.metrics = metrics + self.step_size = step_size + self._op_parents = ( + 'GradParent', + 'ProximityParent', + 'LinearParent', + 'costObj', + ) + + self.metric_call_period = metric_call_period + + # Declaration of observers for metrics + super().__init__(['cv_metrics']) + + for name, dic in self.metrics.items(): + observer = MetricObserver( + name, + dic['metric'], + dic['mapping'], + dic['cst_kwargs'], + dic['early_stopping'], + ) + self.add_observer('cv_metrics', observer) + + xp, compute_backend = backend.get_backend(compute_backend) + self.xp = xp + self.compute_backend = compute_backend + + @property + def metrics(self): + """Metrics.""" + return self._metrics + + @metrics.setter + def metrics(self, metrics): + + if isinstance(metrics, type(None)): + self._metrics = {} + elif isinstance(metrics, dict): + self._metrics = metrics + else: + raise TypeError( + 'Metrics must be a dictionary, not {0}.'.format(type(metrics)), + ) + + def any_convergence_flag(self): + """Check convergence flag. + + Return if any matrices values matched the convergence criteria. + + Returns + ------- + bool + True if any convergence criteria met + + """ + return any( + obs.converge_flag for obs in self._observers['cv_metrics'] + ) + + def copy_data(self, input_data): + """Copy Data. + + Set directive for copying data. + + Parameters + ---------- + input_data : numpy.ndarray + Input data + + Returns + ------- + numpy.ndarray + Copy of input data + + """ + return self.xp.copy(backend.change_backend( + input_data, + self.compute_backend, + )) + + def _check_input_data(self, input_data): + """Check input data type. + + This method checks if the input data is a numpy array + + Parameters + ---------- + input_data : numpy.ndarray + Input data array + + Raises + ------ + TypeError + For invalid input type + + """ + if not (isinstance(input_data, (self.xp.ndarray, np.ndarray))): + raise TypeError( + 'Input data must be a numpy array or backend array', + ) + + def _check_param(self, param_val): + """Check algorithm parameters. + + This method checks if the specified algorithm parameters are floats + + Parameters + ---------- + param_val : float + Parameter value + + Raises + ------ + TypeError + For invalid input type + + """ + if not isinstance(param_val, float): + raise TypeError('Algorithm parameter must be a float value.') + + def _check_param_update(self, param_update): + """Check algorithm parameter update methods. + + This method checks if the specified algorithm parameters are floats + + Parameters + ---------- + param_update : function + Callable function + + Raises + ------ + TypeError + For invalid input type + + """ + param_conditions = ( + not isinstance(param_update, type(None)) + and not callable(param_update) + ) + + if param_conditions: + raise TypeError( + 'Algorithm parameter update must be a callabale function.', + ) + + def _check_operator(self, operator): + """Check set-Up. + + This method checks algorithm operator against the expected parent + classes + + Parameters + ---------- + operator : str + Algorithm operator to check + + """ + if not isinstance(operator, type(None)): + tree = [op_obj.__name__ for op_obj in getmro(operator.__class__)] + + if not any(parent in tree for parent in self._op_parents): + message = '{0} does not inherit an operator parent.' + warn(message.format(str(operator.__class__))) + + def _compute_metrics(self): + """Compute metrics during iteration. + + This method create the args necessary for metrics computation, then + call the observers to compute metrics + + """ + kwargs = self.get_notify_observers_kwargs() + self.notify_observers('cv_metrics', **kwargs) + + def _iterations(self, max_iter, progbar=None): + """Iterate method. + + Iterate the update step of the given algorithm. + + Parameters + ---------- + max_iter : int + Maximum number of iterations + progbar : progressbar.ProgressBar + Progress bar (default is ``None``) + + """ + for idx in range(max_iter): + self.idx = idx + self._update() + + # Calling metrics every metric_call_period cycle + # Also calculate at the end (max_iter or at convergence) + # We do not call metrics if metrics is empty or metric call + # period is None + if self.metrics and self.metric_call_period is not None: + + metric_conditions = ( + self.idx % self.metric_call_period == 0 + or self.idx == (max_iter - 1) + or self.converge, + ) + + if metric_conditions: + self._compute_metrics() + + if self.converge: + if self.verbose: + print(' - Converged!') + break + + if not isinstance(progbar, type(None)): + progbar.update(idx) + + def _run_alg(self, max_iter): + """Run algorithm. + + Run the update step of a given algorithm up to the maximum number of + iterations. + + Parameters + ---------- + max_iter : int + Maximum number of iterations + + """ + if self.progress: + with ProgressBar( + redirect_stdout=True, + max_value=max_iter, + ) as progbar: + self._iterations(max_iter, progbar=progbar) + else: + self._iterations(max_iter) diff --git a/modopt/opt/algorithms.py b/modopt/opt/algorithms/forward_backward.py similarity index 64% rename from modopt/opt/algorithms.py rename to modopt/opt/algorithms/forward_backward.py index 125ac84c..5c6fb404 100644 --- a/modopt/opt/algorithms.py +++ b/modopt/opt/algorithms/forward_backward.py @@ -1,337 +1,14 @@ # -*- coding: utf-8 -*- - -r"""OPTIMISATION ALGOTITHMS. - -This module contains class implementations of various optimisation algoritms. - -:Authors: Samuel Farrens , - Zaccharie Ramzi - -:Notes: - -Input classes must have the following properties: - - * **Gradient Operators** - - Must have the following methods: - - * ``get_grad()`` - calculate the gradient - - Must have the following variables: - - * ``grad`` - the gradient - - * **Linear Operators** - - Must have the following methods: - - * ``op()`` - operator - * ``adj_op()`` - adjoint operator - - * **Proximity Operators** - - Must have the following methods: - - * ``op()`` - operator - -The following notation is used to implement the algorithms: - - * x_old is used in place of :math:`x_{n}`. - * x_new is used in place of :math:`x_{n+1}`. - * x_prox is used in place of :math:`\tilde{x}_{n+1}`. - * x_temp is used for intermediate operations. - -""" - -from inspect import getmro +"""Forward-Backward Algorithms.""" import numpy as np -from progressbar import ProgressBar from modopt.base import backend -from modopt.base.observable import MetricObserver, Observable -from modopt.interface.errors import warn +from modopt.opt.algorithms.base import SetUp from modopt.opt.cost import costObj from modopt.opt.linear import Identity -class SetUp(Observable): - r"""Algorithm Set-Up. - - This class contains methods for checking the set-up of an optimisation - algotithm and produces warnings if they do not comply. - - Parameters - ---------- - metric_call_period : int, optional - Metric call period (default is ``5``) - metrics : dict, optional - Metrics to be used (default is ``\{\}``) - verbose : bool, optional - Option for verbose output (default is ``False``) - progress : bool, optional - Option to display progress bar (default is ``True``) - step_size : int, optional - Generic step size parameter to override default algorithm - parameter name (`e.g.` `step_size` will override the value set for - `beta_param` in `ForwardBackward`) - use_gpu : bool, optional - Option to use available GPU - - """ - - def __init__( - self, - metric_call_period=5, - metrics=None, - verbose=False, - progress=True, - step_size=None, - compute_backend='numpy', - **dummy_kwargs, - ): - - self.converge = False - self.verbose = verbose - self.progress = progress - self.metrics = metrics - self.step_size = step_size - self._op_parents = ( - 'GradParent', - 'ProximityParent', - 'LinearParent', - 'costObj', - ) - - self.metric_call_period = metric_call_period - - # Declaration of observers for metrics - super().__init__(['cv_metrics']) - - for name, dic in self.metrics.items(): - observer = MetricObserver( - name, - dic['metric'], - dic['mapping'], - dic['cst_kwargs'], - dic['early_stopping'], - ) - self.add_observer('cv_metrics', observer) - - xp, compute_backend = backend.get_backend(compute_backend) - self.xp = xp - self.compute_backend = compute_backend - - @property - def metrics(self): - """Metrics.""" - return self._metrics - - @metrics.setter - def metrics(self, metrics): - - if isinstance(metrics, type(None)): - self._metrics = {} - elif isinstance(metrics, dict): - self._metrics = metrics - else: - raise TypeError( - 'Metrics must be a dictionary, not {0}.'.format(type(metrics)), - ) - - def any_convergence_flag(self): - """Check convergence flag. - - Return if any matrices values matched the convergence criteria. - - Returns - ------- - bool - True if any convergence criteria met - - """ - return any( - obs.converge_flag for obs in self._observers['cv_metrics'] - ) - - def copy_data(self, input_data): - """Copy Data. - - Set directive for copying data. - - Parameters - ---------- - input_data : numpy.ndarray - Input data - - Returns - ------- - numpy.ndarray - Copy of input data - - """ - return self.xp.copy(backend.change_backend( - input_data, - self.compute_backend, - )) - - def _check_input_data(self, input_data): - """Check input data type. - - This method checks if the input data is a numpy array - - Parameters - ---------- - input_data : numpy.ndarray - Input data array - - Raises - ------ - TypeError - For invalid input type - - """ - if not (isinstance(input_data, (self.xp.ndarray, np.ndarray))): - raise TypeError( - 'Input data must be a numpy array or backend array', - ) - - def _check_param(self, param_val): - """Check algorithm parameters. - - This method checks if the specified algorithm parameters are floats - - Parameters - ---------- - param_val : float - Parameter value - - Raises - ------ - TypeError - For invalid input type - - """ - if not isinstance(param_val, float): - raise TypeError('Algorithm parameter must be a float value.') - - def _check_param_update(self, param_update): - """Check algorithm parameter update methods. - - This method checks if the specified algorithm parameters are floats - - Parameters - ---------- - param_update : function - Callable function - - Raises - ------ - TypeError - For invalid input type - - """ - param_conditions = ( - not isinstance(param_update, type(None)) - and not callable(param_update) - ) - - if param_conditions: - raise TypeError( - 'Algorithm parameter update must be a callabale function.', - ) - - def _check_operator(self, operator): - """Check set-Up. - - This method checks algorithm operator against the expected parent - classes - - Parameters - ---------- - operator : str - Algorithm operator to check - - """ - if not isinstance(operator, type(None)): - tree = [op_obj.__name__ for op_obj in getmro(operator.__class__)] - - if not any(parent in tree for parent in self._op_parents): - message = '{0} does not inherit an operator parent.' - warn(message.format(str(operator.__class__))) - - def _compute_metrics(self): - """Compute metrics during iteration. - - This method create the args necessary for metrics computation, then - call the observers to compute metrics - - """ - kwargs = self.get_notify_observers_kwargs() - self.notify_observers('cv_metrics', **kwargs) - - def _iterations(self, max_iter, progbar=None): - """Iterate method. - - Iterate the update step of the given algorithm. - - Parameters - ---------- - max_iter : int - Maximum number of iterations - progbar : progressbar.ProgressBar - Progress bar (default is ``None``) - - """ - for idx in range(max_iter): - self.idx = idx - self._update() - - # Calling metrics every metric_call_period cycle - # Also calculate at the end (max_iter or at convergence) - # We do not call metrics if metrics is empty or metric call - # period is None - if self.metrics and self.metric_call_period is not None: - - metric_conditions = ( - self.idx % self.metric_call_period == 0 - or self.idx == (max_iter - 1) - or self.converge, - ) - - if metric_conditions: - self._compute_metrics() - - if self.converge: - if self.verbose: - print(' - Converged!') - break - - if not isinstance(progbar, type(None)): - progbar.update(idx) - - def _run_alg(self, max_iter): - """Run algorithm. - - Run the update step of a given algorithm up to the maximum number of - iterations. - - Parameters - ---------- - max_iter : int - Maximum number of iterations - - """ - if self.progress: - with ProgressBar( - redirect_stdout=True, - max_value=max_iter, - ) as progbar: - self._iterations(max_iter, progbar=progbar) - else: - self._iterations(max_iter) - - class FISTA(object): """FISTA. @@ -645,6 +322,13 @@ class ForwardBackward(SetUp): The `beta_param` can also be set using the keyword `step_size`, which will override the value of `beta_param`. + The following state variable are available for metrics measurememts at + each iteration : + + * `x_new` : new estimate of x + * `z_new` : new estimate of z (adjoint representation of x). + * `idx` : index of the iteration. + See Also -------- FISTA : complementary class @@ -865,6 +549,13 @@ class GenForwardBackward(SetUp): The `gamma_param` can also be set using the keyword `step_size`, which will override the value of `gamma_param`. + The following state variable are available for metrics measurememts at + each iteration : + + * `x_new` : new estimate of x + * `z_new` : new estimate of z (adjoint representation of x). + * `idx` : index of the iteration. + See Also -------- SetUp : parent class @@ -1100,267 +791,6 @@ def retrieve_outputs(self): self.metrics = metrics -class Condat(SetUp): - """Condat optimisation. - - This class implements algorithm 3.1 from :cite:`condat2013` - - Parameters - ---------- - x : numpy.ndarray - Initial guess for the primal variable - y : numpy.ndarray - Initial guess for the dual variable - grad : class instance - Gradient operator class - prox : class instance - Proximity primal operator class - prox_dual : class instance - Proximity dual operator class - linear : class instance, optional - Linear operator class (default is ``None``) - cost : class or str, optional - Cost function class (default is 'auto'); Use 'auto' to automatically - generate a costObj instance - reweight : class instance, optional - Reweighting class - rho : float, optional - Relaxation parameter (default is ``0.5``) - sigma : float, optional - Proximal dual parameter (default is ``1.0``) - tau : float, optional - Proximal primal paramater (default is ``1.0``) - rho_update : function, optional - Relaxation parameter update method (default is ``None``) - sigma_update : function, optional - Proximal dual parameter update method (default is ``None``) - tau_update : function, optional - Proximal primal parameter update method (default is ``None``) - auto_iterate : bool, optional - Option to automatically begin iterations upon initialisation (default - is ``True``) - max_iter : int, optional - Maximum number of iterations (default is ``150``) - n_rewightings : int, optional - Number of reweightings to perform (default is ``1``) - - Notes - ----- - The `tau_param` can also be set using the keyword `step_size`, which will - override the value of `tau_param`. - - See Also - -------- - SetUp : parent class - - """ - - def __init__( - self, - x, - y, - grad, - prox, - prox_dual, - linear=None, - cost='auto', - reweight=None, - rho=0.5, - sigma=1.0, - tau=1.0, - rho_update=None, - sigma_update=None, - tau_update=None, - auto_iterate=True, - max_iter=150, - n_rewightings=1, - metric_call_period=5, - metrics=None, - **kwargs, - ): - - # Set default algorithm properties - super().__init__( - metric_call_period=metric_call_period, - metrics=metrics, - **kwargs, - ) - - # Set the initial variable values - for input_data in (x, y): - self._check_input_data(input_data) - - self._x_old = self.xp.copy(x) - self._y_old = self.xp.copy(y) - - # Set the algorithm operators - for operator in (grad, prox, prox_dual, linear, cost): - self._check_operator(operator) - - self._grad = grad - self._prox = prox - self._prox_dual = prox_dual - self._reweight = reweight - if isinstance(linear, type(None)): - self._linear = Identity() - else: - self._linear = linear - if cost == 'auto': - self._cost_func = costObj([ - self._grad, - self._prox, - self._prox_dual, - ]) - else: - self._cost_func = cost - - # Set the algorithm parameters - for param_val in (rho, sigma, tau): - self._check_param(param_val) - - self._rho = rho - self._sigma = sigma - self._tau = self.step_size or tau - - # Set the algorithm parameter update methods - for param_update in (rho_update, sigma_update, tau_update): - self._check_param_update(param_update) - - self._rho_update = rho_update - self._sigma_update = sigma_update - self._tau_update = tau_update - - # Automatically run the algorithm - if auto_iterate: - self.iterate(max_iter=max_iter, n_rewightings=n_rewightings) - - def _update_param(self): - """Update parameters. - - This method updates the values of the algorthm parameters with the - methods provided - - """ - # Update relaxation parameter. - if not isinstance(self._rho_update, type(None)): - self._rho = self._rho_update(self._rho) - - # Update proximal dual parameter. - if not isinstance(self._sigma_update, type(None)): - self._sigma = self._sigma_update(self._sigma) - - # Update proximal primal parameter. - if not isinstance(self._tau_update, type(None)): - self._tau = self._tau_update(self._tau) - - def _update(self): - """Update. - - This method updates the current reconstruction - - Notes - ----- - Implements equation 9 (algorithm 3.1) from :cite:`condat2013` - - - primal proximity operator set up for positivity constraint - - """ - # Step 1 from eq.9. - self._grad.get_grad(self._x_old) - - x_prox = self._prox.op( - self._x_old - self._tau * self._grad.grad - self._tau - * self._linear.adj_op(self._y_old), - ) - - # Step 2 from eq.9. - y_temp = ( - self._y_old + self._sigma - * self._linear.op(2 * x_prox - self._x_old) - ) - - y_prox = ( - y_temp - self._sigma - * self._prox_dual.op( - y_temp / self._sigma, - extra_factor=(1.0 / self._sigma), - ) - ) - - # Step 3 from eq.9. - self._x_new = self._rho * x_prox + (1 - self._rho) * self._x_old - self._y_new = self._rho * y_prox + (1 - self._rho) * self._y_old - - del x_prox, y_prox, y_temp - - # Update old values for next iteration. - self.xp.copyto(self._x_old, self._x_new) - self.xp.copyto(self._y_old, self._y_new) - - # Update parameter values for next iteration. - self._update_param() - - # Test cost function for convergence. - if self._cost_func: - self.converge = ( - self.any_convergence_flag() - or self._cost_func.get_cost(self._x_new, self._y_new) - ) - - def iterate(self, max_iter=150, n_rewightings=1): - """Iterate. - - This method calls update until either convergence criteria is met or - the maximum number of iterations is reached - - Parameters - ---------- - max_iter : int, optional - Maximum number of iterations (default is ``150``) - n_rewightings : int, optional - Number of reweightings to perform (default is ``1``) - - """ - self._run_alg(max_iter) - - if not isinstance(self._reweight, type(None)): - for _ in range(n_rewightings): - self._reweight.reweight(self._linear.op(self._x_new)) - self._run_alg(max_iter) - - # retrieve metrics results - self.retrieve_outputs() - # rename outputs as attributes - self.x_final = self._x_new - self.y_final = self._y_new - - def get_notify_observers_kwargs(self): - """Notify observers. - - Return the mapping between the metrics call and the iterated - variables. - - Returns - ------- - notify_observers_kwargs : dict, - The mapping between the iterated variables - - """ - return {'x_new': self._x_new, 'y_new': self._y_new, 'idx': self.idx} - - def retrieve_outputs(self): - """Retrieve outputs. - - Declare the outputs of the algorithms as attributes: x_final, - y_final, metrics. - - """ - metrics = {} - for obs in self._observers['cv_metrics']: - metrics[obs.name] = obs.retrieve_metrics() - self.metrics = metrics - - class POGM(SetUp): """Proximal Optimised Gradient Method. @@ -1399,6 +829,18 @@ class POGM(SetUp): The `beta_param` can also be set using the keyword `step_size`, which will override the value of `beta_param`. + The following state variable are available for metrics measurememts at + each iterations: + + * `u_new` : new estimate of u + * `x_new` : new estimate of x + * `y_new` : new estimate of y + * `z_new` : new estimate of z + * `xi`: xi variable + * `t` : new estimate of t + * `sigma`: sigma variable + * `idx` : index of the iteration. + See Also -------- SetUp : parent class diff --git a/modopt/opt/algorithms/gradient_descent.py b/modopt/opt/algorithms/gradient_descent.py new file mode 100644 index 00000000..8d526674 --- /dev/null +++ b/modopt/opt/algorithms/gradient_descent.py @@ -0,0 +1,428 @@ +# -*- coding: utf-8 -*- +"""Gradient Descent Algorithms.""" + +import numpy as np + +from modopt.opt.algorithms.base import SetUp +from modopt.opt.cost import costObj + + +class GenericGradOpt(SetUp): + r"""Generic Gradient descent operator. + + Performs the descent algorithm in the direction m_k at speed s_k. + + + Parameters + ---------- + x: ndarray + Initial value + grad: Instance of GradBase + Gradient operator + prox: Instance of ProximalOperator + Proximal operator, + linear: Instance of OperatorBase + Linear operator (the image domain should be sparse) + cost: Instance of costObj + Cost Operator + eta: float, default 1.0 + Descent step + eta_update: callable, default None + If not None, used to update eta at each step. + epsilon: float, default 1e-6 + Numerical stability constant for the gradient. + epoch_size, int, default 1 + Size of epoch for the descent. + metric_call_period: int, default 5 + The period of iteration on which metrics will be computed. + metrics: dict, default None + If not None, specify which metrics to use. + + Notes + ----- + The Gradient descent step is defined as: + + .. math:: x_{k+1} = x_k - \frac{\eta}{\sqrt{s_k + \epsilon}} m_k + + where: + + * :math:`m_k` is the gradient direction + * :math:`\eta` is the gradient descent step + * :math:`s_k` is the gradient "speed" + + + At each Epoch, an optional Proximal step can be performed. + + The following state variable are available for metrics measurememts: + + * `x_new` : new estimate of the iterations + * `dir_grad` : direction of the gradient descent step + * `speed_grad` : speed for the gradient descent step + * `idx` : index of the iteration being reconstructed. + + See Also + -------- + modopt.opt.algorithms.base.SetUp : parent class + + """ + + def __init__( + self, + x, + grad, + prox, + cost, + eta=1.0, + eta_update=None, + epsilon=1e-6, + epoch_size=1, + metric_call_period=5, + metrics=None, + **kwargs, + ): + # Set the initial variable values + if metrics is None: + metrics = {} + # Set default algorithm properties + super().__init__( + metric_call_period=metric_call_period, + metrics=metrics, + **kwargs, + ) + self.iter = 0 + self._check_input_data(x) + self._x_old = np.copy(x) + self._x_new = np.copy(x) + self._speed_grad = np.zeros(x.shape, dtype=float) + self._dir_grad = np.zeros_like(x) + # Set the algorithm operators + for operator in (grad, prox, cost): + self._check_operator(operator) + self._grad = grad + self._prox = prox + if cost == 'auto': + self._cost_func = costObj([self._grad, self._prox]) + else: + self._cost_func = cost + # Set the algorithm parameters + for param_val in (eta, epsilon): + self._check_param(param_val) + self._eta = eta + self._eps = epsilon + + # Set the algorithm parameter update methods + self._check_param_update(eta_update) + self._eta_update = eta_update + self.idx = 0 + self.epoch_size = epoch_size + + def iterate(self, max_iter=150): + """Iterate. + + This method calls update until either convergence criteria is met or + the maximum number of iterations is reached. + + Parameters + ---------- + max_iter : int, optional + Maximum number of iterations (default is ``150``) + + """ + self._run_alg(max_iter) + + # retrieve metrics results + self.retrieve_outputs() + + self.x_final = self._x_new + + def _update(self): + self._grad.get_grad(self._x_old) + self._update_grad_dir(self._grad.grad) + self._update_grad_speed(self._grad.grad) + step = self._eta / (np.sqrt(self._speed_grad) + self._eps) + self._x_new = self._x_old - step * self._dir_grad + if self.idx % self.epoch_size == 0: + self.reset() + self._update_reg(step) + self._x_old = self._x_new.copy() + if self._eta_update is not None: + self._eta = self._eta_update(self._eta, self.idx) + # Test cost function for convergence. + if self._cost_func: + self.converge = ( + self.any_convergence_flag() + or self._cost_func.get_cost(self._x_new) + ) + + def _update_grad_dir(self, grad): + """Update the gradient descent direction. + + Parameters + ---------- + grad: ndarray + The gradien direction + """ + self._dir_grad = grad + + def _update_grad_speed(self, grad): + """Update the gradient descent speed. + + Parameters + ---------- + grad: ndarray + The gradien direction + + """ + pass + + def _update_reg(self, factor): + """Regularisation step. + + Parameters + ---------- + factor: float or array_like + extra factor for the proximal step. + """ + self._x_new = self._prox.op(self._x_new, extra_factor=factor) + + def get_notify_observers_kwargs(self): + """Notify observers. + + Return the mapping between the metrics call and the iterated + variables. + + Returns + ------- + notify_observers_kwargs : dict, + The mapping between the iterated variables + + """ + return { + 'x_new': self._x_new, + 'dir_grad': self._dir_grad, + 'speed_grad': self._speed_grad, + 'idx': self.idx, + } + + def retrieve_outputs(self): + """Retrieve outputs. + + Declare the outputs of the algorithms as attributes: x_final, + y_final, metrics. + + """ + metrics = {} + for obs in self._observers['cv_metrics']: + metrics[obs.name] = obs.retrieve_metrics() + self.metrics = metrics + + def reset(self): + """Reset internal state of the algorithm.""" + pass + + +class VanillaGenericGradOpt(GenericGradOpt): + """Vanilla Descent Algorithm. + + Fixed step size and no numerical precision threshold. + + See Also + -------- + GenericGradOpt : parent class + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # no scale factor + self._speed_grad = 1.0 + self._eps = 0 + + +class AdaGenericGradOpt(GenericGradOpt): + r"""Generic Grad descent Algorithm with ADA acceleration scheme. + + Notes + ----- + For AdaGrad (Section 4.2 of :cite:`ruder2017`) the gradient is + preconditioned using a speed update: + + .. math:: s_k = \sum_{i=0}^k g_k * g_k + + + See Also + -------- + GenericGradOpt : parent class + """ + + def _update_grad_speed(self, grad): + """Ada Acceleration Scheme. + + Parameters + ---------- + grad: ndarray + The new gradient for updating the speed. + + """ + self._speed_grad += abs(grad) ** 2 + + +class RMSpropGradOpt(GenericGradOpt): + r"""RMSprop Gradient descent algorithm. + + Parameters + ---------- + gamma: float, default 0.5 + Update weight for the speed of descent. + + Raises + ------ + ValueError + If gamma is outside ]0,1[ + + Notes + ----- + The gradient speed of RMSProp (Section 4.5 of :cite:`ruder2017`) is + defined as : + + .. math:: s_k = \gamma s_{k-1} + (1-\gamma) * |\nabla f|^2 + + See Also + -------- + GenericGradOpt : parent class + """ + + def __init__(self, *args, gamma=0.5, **kwargs): + super().__init__(*args, **kwargs) + if gamma < 0 or gamma > 1: + raise ValueError('gamma is outside of range [0,1]') + self._check_param(gamma) + self._gamma = gamma + + def _update_grad_speed(self, grad): + """Rmsprop update speed.""" + self._speed_grad = ( + self._gamma * self._speed_grad + (1 - self._gamma) * abs(grad) ** 2 + ) + + +class MomentumGradOpt(GenericGradOpt): + r"""Momentum (Heavy-ball) descent algorithm. + + Parameters + ---------- + beta: float, default 0.9 + update weight for the momentum. + + Notes + ----- + The Momentum (Section 4.1 of :cite:`ruder2017` update is defined as: + + .. math:: m_k = \beta * m_{k-1} + \nabla f(x_k) + + See Also + -------- + GenericGradOpt : parent class + """ + + def __init__(self, *args, beta=0.9, **kwargs): + super().__init__(*args, **kwargs) + self._check_param(beta) + self._beta = beta + # no scale factor + self._speed_grad = 1.0 + self._eps = 0 + + def _update_grad_dir(self, grad): + """Momentum gradient direction update.""" + self._dir_grad = self._beta * self._dir_grad + grad + + def reset(self): + """Reset the gradient direction.""" + self._dir_grad = np.zeros_like(self._x_new) + + +class ADAMGradOpt(GenericGradOpt): + r"""ADAM optimizer. + + Parameters + ---------- + gamma: float + update weight for the direction in ]0,1[ + beta: float + update weight for the speed in ]0,1[ + + Raises + ------ + ValueError + If gamma or beta is outside ]0,1[ + + Notes + ----- + The ADAM optimizer (Section 4.6 of :cite:`ruder2017` is defined as: + + .. math:: + m_{k+1} = \frac{1}{1-\beta^k}(\beta*m_{k}+(1-\beta)*|\nabla f_k|^2) + .. math:: + s_{k+1} = \frac{1}{1-\gamma^k}(\gamma*s_k+(1-\gamma)*\nabla f_k) + + See Also + -------- + GenericGradOpt : parent class + """ + + def __init__(self, *args, gamma=0.9, beta=0.9, **kwargs): + super().__init__(*args, **kwargs) + self._check_param(gamma) + self._check_param(beta) + if gamma < 0 or gamma >= 1: + raise ValueError('gamma is outside of range [0,1]') + if beta < 0 or beta >= 1: + raise ValueError('beta is outside of range [0,1]') + self._gamma = gamma + self._beta = beta + self._beta_pow = 1 + self._gamma_pow = 1 + + def _update_grad_dir(self, grad): + """ADAM Update of gradient direction.""" + self._beta_pow *= self._beta + + self._dir_grad = (1.0 / (1.0 - self._beta_pow)) * ( + self._beta * self._dir_grad + (1 - self._beta) * grad + ) + + def _update_grad_speed(self, grad): + """ADAM Updatae of gradient speed.""" + self._gamma_pow *= self._gamma + self._speed_grad = (1.0 / (1.0 - self._gamma_pow)) * ( + self._gamma * self._speed_grad + (1 - self._gamma) * abs(grad) ** 2 + ) + + +class SAGAOptGradOpt(GenericGradOpt): + """SAGA optimizer. + + Implements equation (7) of :cite:`defazio2014` + + Notes + ----- + The stochastic part is not handled here, and should be implemented by + changing the obs_data between each call to the _update function. + + See Also + -------- + GenericGradOpt : parent class + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._grad_memory = np.zeros( + (self.epoch_size, *self._x_old.shape), + dtype=self._x_old.dtype, + ) + + def _update_grad_dir(self, grad): + """SAGA Update gradient direction.""" + cycle = self.idx % self.epoch_size + self._dir_grad = self._dir_grad - self._grad_memory[cycle] + grad + self._grad_memory[cycle] = grad diff --git a/modopt/opt/algorithms/primal_dual.py b/modopt/opt/algorithms/primal_dual.py new file mode 100644 index 00000000..8ba0630f --- /dev/null +++ b/modopt/opt/algorithms/primal_dual.py @@ -0,0 +1,267 @@ +# -*- coding: utf-8 -*- +"""Primal-Dual Algorithms.""" + +from modopt.opt.algorithms.base import SetUp +from modopt.opt.cost import costObj +from modopt.opt.linear import Identity + + +class Condat(SetUp): + """Condat optimisation. + + This class implements algorithm 3.1 from :cite:`condat2013` + + Parameters + ---------- + x : numpy.ndarray + Initial guess for the primal variable + y : numpy.ndarray + Initial guess for the dual variable + grad : class instance + Gradient operator class + prox : class instance + Proximity primal operator class + prox_dual : class instance + Proximity dual operator class + linear : class instance, optional + Linear operator class (default is ``None``) + cost : class or str, optional + Cost function class (default is 'auto'); Use 'auto' to automatically + generate a costObj instance + reweight : class instance, optional + Reweighting class + rho : float, optional + Relaxation parameter (default is ``0.5``) + sigma : float, optional + Proximal dual parameter (default is ``1.0``) + tau : float, optional + Proximal primal paramater (default is ``1.0``) + rho_update : function, optional + Relaxation parameter update method (default is ``None``) + sigma_update : function, optional + Proximal dual parameter update method (default is ``None``) + tau_update : function, optional + Proximal primal parameter update method (default is ``None``) + auto_iterate : bool, optional + Option to automatically begin iterations upon initialisation (default + is ``True``) + max_iter : int, optional + Maximum number of iterations (default is ``150``) + n_rewightings : int, optional + Number of reweightings to perform (default is ``1``) + + Notes + ----- + The `tau_param` can also be set using the keyword `step_size`, which will + override the value of `tau_param`. + + See Also + -------- + SetUp : parent class + + """ + + def __init__( + self, + x, + y, + grad, + prox, + prox_dual, + linear=None, + cost='auto', + reweight=None, + rho=0.5, + sigma=1.0, + tau=1.0, + rho_update=None, + sigma_update=None, + tau_update=None, + auto_iterate=True, + max_iter=150, + n_rewightings=1, + metric_call_period=5, + metrics=None, + **kwargs, + ): + + # Set default algorithm properties + super().__init__( + metric_call_period=metric_call_period, + metrics=metrics, + **kwargs, + ) + + # Set the initial variable values + for input_data in (x, y): + self._check_input_data(input_data) + + self._x_old = self.xp.copy(x) + self._y_old = self.xp.copy(y) + + # Set the algorithm operators + for operator in (grad, prox, prox_dual, linear, cost): + self._check_operator(operator) + + self._grad = grad + self._prox = prox + self._prox_dual = prox_dual + self._reweight = reweight + if isinstance(linear, type(None)): + self._linear = Identity() + else: + self._linear = linear + if cost == 'auto': + self._cost_func = costObj([ + self._grad, + self._prox, + self._prox_dual, + ]) + else: + self._cost_func = cost + + # Set the algorithm parameters + for param_val in (rho, sigma, tau): + self._check_param(param_val) + + self._rho = rho + self._sigma = sigma + self._tau = self.step_size or tau + + # Set the algorithm parameter update methods + for param_update in (rho_update, sigma_update, tau_update): + self._check_param_update(param_update) + + self._rho_update = rho_update + self._sigma_update = sigma_update + self._tau_update = tau_update + + # Automatically run the algorithm + if auto_iterate: + self.iterate(max_iter=max_iter, n_rewightings=n_rewightings) + + def _update_param(self): + """Update parameters. + + This method updates the values of the algorthm parameters with the + methods provided + + """ + # Update relaxation parameter. + if not isinstance(self._rho_update, type(None)): + self._rho = self._rho_update(self._rho) + + # Update proximal dual parameter. + if not isinstance(self._sigma_update, type(None)): + self._sigma = self._sigma_update(self._sigma) + + # Update proximal primal parameter. + if not isinstance(self._tau_update, type(None)): + self._tau = self._tau_update(self._tau) + + def _update(self): + """Update. + + This method updates the current reconstruction + + Notes + ----- + Implements equation 9 (algorithm 3.1) from :cite:`condat2013` + + - primal proximity operator set up for positivity constraint + + """ + # Step 1 from eq.9. + self._grad.get_grad(self._x_old) + + x_prox = self._prox.op( + self._x_old - self._tau * self._grad.grad - self._tau + * self._linear.adj_op(self._y_old), + ) + + # Step 2 from eq.9. + y_temp = ( + self._y_old + self._sigma + * self._linear.op(2 * x_prox - self._x_old) + ) + + y_prox = ( + y_temp - self._sigma + * self._prox_dual.op( + y_temp / self._sigma, + extra_factor=(1.0 / self._sigma), + ) + ) + + # Step 3 from eq.9. + self._x_new = self._rho * x_prox + (1 - self._rho) * self._x_old + self._y_new = self._rho * y_prox + (1 - self._rho) * self._y_old + + del x_prox, y_prox, y_temp + + # Update old values for next iteration. + self.xp.copyto(self._x_old, self._x_new) + self.xp.copyto(self._y_old, self._y_new) + + # Update parameter values for next iteration. + self._update_param() + + # Test cost function for convergence. + if self._cost_func: + self.converge = ( + self.any_convergence_flag() + or self._cost_func.get_cost(self._x_new, self._y_new) + ) + + def iterate(self, max_iter=150, n_rewightings=1): + """Iterate. + + This method calls update until either convergence criteria is met or + the maximum number of iterations is reached + + Parameters + ---------- + max_iter : int, optional + Maximum number of iterations (default is ``150``) + n_rewightings : int, optional + Number of reweightings to perform (default is ``1``) + + """ + self._run_alg(max_iter) + + if not isinstance(self._reweight, type(None)): + for _ in range(n_rewightings): + self._reweight.reweight(self._linear.op(self._x_new)) + self._run_alg(max_iter) + + # retrieve metrics results + self.retrieve_outputs() + # rename outputs as attributes + self.x_final = self._x_new + self.y_final = self._y_new + + def get_notify_observers_kwargs(self): + """Notify observers. + + Return the mapping between the metrics call and the iterated + variables. + + Returns + ------- + notify_observers_kwargs : dict, + The mapping between the iterated variables + + """ + return {'x_new': self._x_new, 'y_new': self._y_new, 'idx': self.idx} + + def retrieve_outputs(self): + """Retrieve outputs. + + Declare the outputs of the algorithms as attributes: x_final, + y_final, metrics. + + """ + metrics = {} + for obs in self._observers['cv_metrics']: + metrics[obs.name] = obs.retrieve_metrics() + self.metrics = metrics diff --git a/modopt/tests/test_algorithms.py b/modopt/tests/test_algorithms.py new file mode 100644 index 00000000..7ff96a8b --- /dev/null +++ b/modopt/tests/test_algorithms.py @@ -0,0 +1,470 @@ +# -*- coding: utf-8 -*- + +"""UNIT TESTS FOR OPT.ALGORITHMS. + +This module contains unit tests for the modopt.opt.algorithms module. + +:Author: Samuel Farrens + +""" + +from unittest import TestCase + +import numpy as np +import numpy.testing as npt + +from modopt.opt import algorithms, cost, gradient, linear, proximity, reweight + +# Basic functions to be used as operators or as dummy functions +func_identity = lambda x_val: x_val +func_double = lambda x_val: x_val * 2 +func_sq = lambda x_val: x_val ** 2 +func_cube = lambda x_val: x_val ** 3 + + +class Dummy(object): + """Dummy class for tests.""" + + pass + + +class AlgorithmTestCase(TestCase): + """Test case for algorithms module.""" + + def setUp(self): + """Set test parameter values.""" + self.data1 = np.arange(9).reshape(3, 3).astype(float) + self.data2 = self.data1 + np.random.randn(*self.data1.shape) * 1e-6 + self.data3 = np.arange(9).reshape(3, 3).astype(float) + 1 + + grad_inst = gradient.GradBasic( + self.data1, + func_identity, + func_identity, + ) + + prox_inst = proximity.Positivity() + prox_dual_inst = proximity.IdentityProx() + linear_inst = linear.Identity() + reweight_inst = reweight.cwbReweight(self.data3) + cost_inst = cost.costObj([grad_inst, prox_inst, prox_dual_inst]) + self.setup = algorithms.SetUp() + self.max_iter = 20 + + self.fb_all_iter = algorithms.ForwardBackward( + self.data1, + grad=grad_inst, + prox=prox_inst, + cost=None, + auto_iterate=False, + beta_update=func_identity, + ) + self.fb_all_iter.iterate(self.max_iter) + + self.fb1 = algorithms.ForwardBackward( + self.data1, + grad=grad_inst, + prox=prox_inst, + beta_update=func_identity, + ) + + self.fb2 = algorithms.ForwardBackward( + self.data1, + grad=grad_inst, + prox=prox_inst, + cost=cost_inst, + lambda_update=None, + ) + + self.fb3 = algorithms.ForwardBackward( + self.data1, + grad=grad_inst, + prox=prox_inst, + beta_update=func_identity, + a_cd=3, + ) + + self.fb4 = algorithms.ForwardBackward( + self.data1, + grad=grad_inst, + prox=prox_inst, + beta_update=func_identity, + r_lazy=3, + p_lazy=0.7, + q_lazy=0.7, + ) + + self.fb5 = algorithms.ForwardBackward( + self.data1, + grad=grad_inst, + prox=prox_inst, + restart_strategy='adaptive', + xi_restart=0.9, + ) + + self.fb6 = algorithms.ForwardBackward( + self.data1, + grad=grad_inst, + prox=prox_inst, + restart_strategy='greedy', + xi_restart=0.9, + min_beta=1.0, + s_greedy=1.1, + ) + + self.gfb_all_iter = algorithms.GenForwardBackward( + self.data1, + grad=grad_inst, + prox_list=[prox_inst, prox_dual_inst], + cost=None, + auto_iterate=False, + gamma_update=func_identity, + beta_update=func_identity, + ) + self.gfb_all_iter.iterate(self.max_iter) + + self.gfb1 = algorithms.GenForwardBackward( + self.data1, + grad=grad_inst, + prox_list=[prox_inst, prox_dual_inst], + gamma_update=func_identity, + lambda_update=func_identity, + ) + + self.gfb2 = algorithms.GenForwardBackward( + self.data1, + grad=grad_inst, + prox_list=[prox_inst, prox_dual_inst], + cost=cost_inst, + ) + + self.gfb3 = algorithms.GenForwardBackward( + self.data1, + grad=grad_inst, + prox_list=[prox_inst, prox_dual_inst], + cost=cost_inst, + step_size=2, + ) + + self.condat_all_iter = algorithms.Condat( + self.data1, + self.data2, + grad=grad_inst, + prox=prox_inst, + cost=None, + prox_dual=prox_dual_inst, + sigma_update=func_identity, + tau_update=func_identity, + rho_update=func_identity, + auto_iterate=False, + ) + self.condat_all_iter.iterate(self.max_iter) + + self.condat1 = algorithms.Condat( + self.data1, + self.data2, + grad=grad_inst, + prox=prox_inst, + prox_dual=prox_dual_inst, + sigma_update=func_identity, + tau_update=func_identity, + rho_update=func_identity, + ) + + self.condat2 = algorithms.Condat( + self.data1, + self.data2, + grad=grad_inst, + prox=prox_inst, + prox_dual=prox_dual_inst, + linear=linear_inst, + cost=cost_inst, + reweight=reweight_inst, + ) + + self.condat3 = algorithms.Condat( + self.data1, + self.data2, + grad=grad_inst, + prox=prox_inst, + prox_dual=prox_dual_inst, + linear=Dummy(), + cost=cost_inst, + auto_iterate=False, + ) + + self.pogm_all_iter = algorithms.POGM( + u=self.data1, + x=self.data1, + y=self.data1, + z=self.data1, + grad=grad_inst, + prox=prox_inst, + auto_iterate=False, + cost=None, + ) + self.pogm_all_iter.iterate(self.max_iter) + + self.pogm1 = algorithms.POGM( + u=self.data1, + x=self.data1, + y=self.data1, + z=self.data1, + grad=grad_inst, + prox=prox_inst, + ) + + self.vanilla_grad = algorithms.VanillaGenericGradOpt( + self.data1, + grad=grad_inst, + prox=prox_inst, + cost=cost_inst, + ) + self.ada_grad = algorithms.AdaGenericGradOpt( + self.data1, + grad=grad_inst, + prox=prox_inst, + cost=cost_inst, + ) + self.adam_grad = algorithms.ADAMGradOpt( + self.data1, + grad=grad_inst, + prox=prox_inst, + cost=cost_inst, + ) + self.momentum_grad = algorithms.MomentumGradOpt( + self.data1, + grad=grad_inst, + prox=prox_inst, + cost=cost_inst, + ) + self.rms_grad = algorithms.RMSpropGradOpt( + self.data1, + grad=grad_inst, + prox=prox_inst, + cost=cost_inst, + ) + self.saga_grad = algorithms.SAGAOptGradOpt( + self.data1, + grad=grad_inst, + prox=prox_inst, + cost=cost_inst, + ) + + self.dummy = Dummy() + self.dummy.cost = func_identity + self.setup._check_operator(self.dummy.cost) + + def tearDown(self): + """Unset test parameter values.""" + self.data1 = None + self.data2 = None + self.setup = None + self.fb_all_iter = None + self.fb1 = None + self.fb2 = None + self.gfb_all_iter = None + self.gfb1 = None + self.gfb2 = None + self.condat_all_iter = None + self.condat1 = None + self.condat2 = None + self.condat3 = None + self.pogm1 = None + self.pogm_all_iter = None + self.dummy = None + + def test_set_up(self): + """Test set_up.""" + npt.assert_raises(TypeError, self.setup._check_input_data, 1) + + npt.assert_raises(TypeError, self.setup._check_param, 1) + + npt.assert_raises(TypeError, self.setup._check_param_update, 1) + + def test_all_iter(self): + """Test if all opt run for all iterations.""" + opts = [ + self.fb_all_iter, + self.gfb_all_iter, + self.condat_all_iter, + self.pogm_all_iter, + ] + for opt in opts: + npt.assert_equal(opt.idx, self.max_iter - 1) + + def test_forward_backward(self): + """Test forward_backward.""" + npt.assert_array_equal( + self.fb1.x_final, + self.data1, + err_msg='Incorrect ForwardBackward result.', + ) + + npt.assert_array_equal( + self.fb2.x_final, + self.data1, + err_msg='Incorrect ForwardBackward result.', + ) + + npt.assert_array_equal( + self.fb3.x_final, + self.data1, + err_msg='Incorrect ForwardBackward result.', + ) + + npt.assert_array_equal( + self.fb4.x_final, + self.data1, + err_msg='Incorrect ForwardBackward result.', + ) + + npt.assert_array_equal( + self.fb5.x_final, + self.data1, + err_msg='Incorrect ForwardBackward result.', + ) + + npt.assert_array_equal( + self.fb6.x_final, + self.data1, + err_msg='Incorrect ForwardBackward result.', + ) + + def test_gen_forward_backward(self): + """Test gen_forward_backward.""" + npt.assert_array_equal( + self.gfb1.x_final, + self.data1, + err_msg='Incorrect GenForwardBackward result.', + ) + + npt.assert_array_equal( + self.gfb2.x_final, + self.data1, + err_msg='Incorrect GenForwardBackward result.', + ) + + npt.assert_array_equal( + self.gfb3.x_final, + self.data1, + err_msg='Incorrect GenForwardBackward result.', + ) + + npt.assert_equal( + self.gfb3.step_size, + 2, + err_msg='Incorrect step size.', + ) + + npt.assert_raises( + TypeError, + algorithms.GenForwardBackward, + self.data1, + self.dummy, + [self.dummy], + weights=1, + ) + + npt.assert_raises( + ValueError, + algorithms.GenForwardBackward, + self.data1, + self.dummy, + [self.dummy], + weights=[1], + ) + + npt.assert_raises( + ValueError, + algorithms.GenForwardBackward, + self.data1, + self.dummy, + [self.dummy], + weights=[0.5, 0.5], + ) + + npt.assert_raises( + ValueError, + algorithms.GenForwardBackward, + self.data1, + self.dummy, + [self.dummy], + weights=[0.5], + ) + + def test_condat(self): + """Test gen_condat.""" + npt.assert_almost_equal( + self.condat1.x_final, + self.data1, + err_msg='Incorrect Condat result.', + ) + + npt.assert_almost_equal( + self.condat2.x_final, + self.data1, + err_msg='Incorrect Condat result.', + ) + + def test_pogm(self): + """Test pogm.""" + npt.assert_almost_equal( + self.pogm1.x_final, + self.data1, + err_msg='Incorrect POGM result.', + ) + + def test_ada_grad(self): + """Test ADA Gradient Descent.""" + self.ada_grad.iterate() + npt.assert_almost_equal( + self.ada_grad.x_final, + self.data1, + err_msg='Incorrect ADAGrad results.', + ) + + def test_adam_grad(self): + """Test ADAM Gradient Descent.""" + self.adam_grad.iterate() + npt.assert_almost_equal( + self.adam_grad.x_final, + self.data1, + err_msg='Incorrect ADAMGrad results.', + ) + + def test_momemtum_grad(self): + """Test Momemtum Gradient Descent.""" + self.momentum_grad.iterate() + npt.assert_almost_equal( + self.momentum_grad.x_final, + self.data1, + err_msg='Incorrect MomentumGrad results.', + ) + + def test_rmsprop_grad(self): + """Test RMSProp Gradient Descent.""" + self.rms_grad.iterate() + npt.assert_almost_equal( + self.rms_grad.x_final, + self.data1, + err_msg='Incorrect RMSPropGrad results.', + ) + + def test_saga_grad(self): + """Test SAGA Descent.""" + self.saga_grad.iterate() + npt.assert_almost_equal( + self.saga_grad.x_final, + self.data1, + err_msg='Incorrect SAGA Grad results.', + ) + + def test_vanilla_grad(self): + """Test Vanilla Gradient Descent.""" + self.vanilla_grad.iterate() + npt.assert_almost_equal( + self.vanilla_grad.x_final, + self.data1, + err_msg='Incorrect VanillaGrad results.', + ) diff --git a/setup.cfg b/setup.cfg index 14784b51..0619a295 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,8 +36,14 @@ per-file-ignores = modopt/math/convolve.py: WPS301,WPS420 #Todo: Rethink conditional imports modopt/math/matrix.py: WPS420 - #Todo: Check need for del statement, - modopt/opt/algorithms.py: WPS111,WPS420 + #Todo: import has bad parenthesis + modopt/opt/algorithms/__init__.py: F401,F403,WPS318, WPS319, WPS412, WPS410 + #Todo: x is a too short name. + modopt/opt/algorithms/forward_backward.py: WPS111 + #Todo: Check need for del statement + modopt/opt/algorithms/primal_dual.py: WPS111, WPS420 + #multiline parameters bug with tuples + modopt/opt/algorithms/gradient_descent.py: WPS111, WPS420, WPS317 #Todo: Consider changing costObj name modopt/opt/cost.py: N801 #Todo: From 643411f8d864c348e84534067751dce06dc3e015 Mon Sep 17 00:00:00 2001 From: Samuel Farrens Date: Fri, 17 Dec 2021 17:47:29 +0100 Subject: [PATCH 08/46] Release cleanup (#198) * started clean up for next release * update progress * further clean up * additional clean up * cleaned up link to logo --- MANIFEST.in | 1 - README.md | 2 +- develop.txt | 1 + docs/requirements.txt | 5 +- docs/source/about.rst | 13 +- docs/source/conf.py | 35 ++- docs/source/contributing.rst | 13 +- docs/source/cosmostat_logo.jpg | Bin 169466 -> 116127 bytes docs/source/dependencies.rst | 2 +- docs/source/index.rst | 6 +- docs/source/neurospin_logo.png | Bin 62657 -> 97699 bytes docs/source/refs.bib | 51 ++-- docs/source/z_ref.rst | 1 - modopt/base/backend.py | 28 +-- modopt/base/np_adjust.py | 5 +- modopt/base/observable.py | 6 +- modopt/base/transform.py | 12 +- modopt/base/types.py | 4 +- modopt/base/wrappers.py | 6 +- modopt/math/convolve.py | 8 +- modopt/math/matrix.py | 20 +- modopt/math/stats.py | 18 +- modopt/opt/algorithms/__init__.py | 39 ++-- modopt/opt/algorithms/base.py | 17 +- modopt/opt/algorithms/forward_backward.py | 270 ++++++++++++---------- modopt/opt/algorithms/gradient_descent.py | 142 +++++++----- modopt/opt/algorithms/primal_dual.py | 70 +++--- modopt/opt/cost.py | 35 ++- modopt/opt/gradient.py | 59 +++-- modopt/opt/linear.py | 13 +- modopt/opt/proximity.py | 159 +++++++------ modopt/opt/reweight.py | 9 +- modopt/signal/noise.py | 18 +- modopt/signal/positivity.py | 4 +- modopt/signal/svd.py | 8 +- modopt/signal/validation.py | 4 +- modopt/signal/wavelet.py | 29 +-- setup.cfg | 4 +- setup.py | 4 +- 39 files changed, 654 insertions(+), 467 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index 74db0634..9a2f374e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,4 +3,3 @@ include develop.txt include docs/requirements.txt include README.rst include LICENSE.txt -include docs/source/modopt_logo.png diff --git a/README.md b/README.md index 0f7501f0..acb316ad 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ModOpt - + | Usage | Development | Release | | ----- | ----------- | ------- | diff --git a/develop.txt b/develop.txt index 98b9233d..3f809fc2 100644 --- a/develop.txt +++ b/develop.txt @@ -1,4 +1,5 @@ coverage>=5.5 +flake8<4 nose>=1.3.7 pytest>=6.2.2 pytest-cov>=2.11.1 diff --git a/docs/requirements.txt b/docs/requirements.txt index 16d3ca09..9b196b1f 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,7 +1,8 @@ jupyter==1.0.0 +myst-parser==0.16.0 nbsphinx==0.8.7 nbsphinx-link==1.3.0 numpydoc==1.1.0 -sphinx==4.3.0 +sphinx==4.3.1 sphinxcontrib-bibtex==2.4.1 -sphinxawesome-theme==3.2.0 +sphinxawesome-theme==3.2.1 diff --git a/docs/source/about.rst b/docs/source/about.rst index 0d412b5d..00b2dbe2 100644 --- a/docs/source/about.rst +++ b/docs/source/about.rst @@ -15,6 +15,8 @@ Contributors You can find a |link-to-contributors|. +|CS_LOGO| |NS_LOGO| + .. |link-to-cosmic| raw:: html COSMIC @@ -38,11 +40,12 @@ You can find a |link-to-contributors|. list of ModOpt contributors here - -.. image:: cosmostat_logo.jpg - :width: 300 +.. |CS_LOGO| image:: cosmostat_logo.jpg + :width: 45% :alt: CosmoStat Logo + :target: http://www.cosmostat.org/ -.. image:: neurospin_logo.png - :width: 300 +.. |NS_LOGO| image:: neurospin_logo.png + :width: 45% :alt: NeuroSpin Logo + :target: https://joliot.cea.fr/drf/joliot/en/Pages/research_entities/NeuroSpin.aspx diff --git a/docs/source/conf.py b/docs/source/conf.py index 95aaba20..fb954f6d 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -41,6 +41,7 @@ 'sphinx.ext.viewcode', 'sphinxawesome_theme', 'sphinxcontrib.bibtex', + 'myst_parser', 'nbsphinx', 'nbsphinx_link', 'numpydoc', @@ -95,12 +96,25 @@ html_theme_options = { "nav_include_hidden": True, "show_nav": True, - "show_breadcrumbs": False, - "breadcrumbs_separator": "/" + "show_breadcrumbs": True, + "breadcrumbs_separator": "/", + "show_prev_next": True, + "show_scrolltop": True, + } html_collapsible_definitions = True - - +html_awesome_headerlinks = True +html_logo = 'modopt_logo.jpg' +html_permalinks_icon = ( + '' + '' +) # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". html_title = '{0} v{1}'.format(project, version) @@ -216,12 +230,25 @@ def add_notebooks(nb_path='../../notebooks'): 'python': ('http://docs.python.org/3', None), 'numpy': ('https://numpy.org/doc/stable/', None), 'scipy': ('https://docs.scipy.org/doc/scipy/reference', None), + 'progressbar': ('https://progressbar-2.readthedocs.io/en/latest/', None), 'matplotlib': ('https://matplotlib.org', None), 'astropy': ('http://docs.astropy.org/en/latest/', None), 'cupy': ('https://docs-cupy.chainer.org/en/stable/', None), 'torch': ('https://pytorch.org/docs/stable/', None), + 'sklearn': ( + 'http://scikit-learn.org/stable', + (None, './_intersphinx/sklearn-objects.inv') + ), + 'tensorflow': ( + 'https://www.tensorflow.org/api_docs/python', + ( + 'https://github.com/GPflow/tensorflow-intersphinx/' + + 'raw/master/tf2_py_objects.inv') + ) + } # -- BibTeX Setting ---------------------------------------------- bibtex_bibfiles = ['refs.bib', 'my_ref.bib'] +bibtex_default_style = 'alpha' diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst index f983d030..d2bac8e2 100644 --- a/docs/source/contributing.rst +++ b/docs/source/contributing.rst @@ -1,9 +1,16 @@ Contributing ============ -Read our `Contribution Guidelines `_ +Read our |link-to-contrib|. for details on how to contribute to the development of this package. -All contributors are kindly asked to adhere to the -`Code of Conduct `_ +All contributors are kindly asked to adhere to the |link-to-conduct| at all times to ensure a safe and inclusive environment for everyone. + +.. |link-to-contrib| raw:: html + + Contribution Guidelines + +.. |link-to-conduct| raw:: html + + Code of Conduct diff --git a/docs/source/cosmostat_logo.jpg b/docs/source/cosmostat_logo.jpg index 4945c4684b87ba27a90190c52fa95dbeede8cd9c..8bf52ad557b90b7587c2805a71a185d12274791f 100644 GIT binary patch literal 116127 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 Oj`%|_Co;{|1 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 literal 169466 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

xVv^hpABxb;PoMC0Ekk-^_M#g7TQQP4${wbB>J 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

vh$y*V^W!*o%_N zD~XK+3Pgr%fCFL>cCczY&*9Rh_X)PA4cnc_p_*-NGJ{bLMV5QYw8(g3GQk^8Hk&dI zB8wZGeCAqyPc}&1?HKcbRb#yVhS_O@c_mQ=>^{qZ=H< zKFWT_a&uANPqhj;9Qd-0oR>*rD^z9>wDy`(j!ceQ`eE?=ac)(;`5;BoBHdL&Wx&@@ zP+h_Y!nU>R{&H;j7sIHYMyvYi`J&w{>K^>=$A>W82zTZ}rXOws1hbl@I=E$~GEC^h zWvgj@un}K`*#j2hA6{yQsw!g~xgXGd9cKVt4Gvp=p`uafOzD!(Mk+=S5ea7YTLSS!Jp&S~-9? z+Pk=JDJ;>|cFYn*UL3Ry{-1fwWz)MBdX&F~J?XIQU7tNmF|x zOmY0Bn{E3{5uW$$qfyDc+bK%vm0==wEotGLj}g^9fja9Q-3p{rIm?S#oyl zRZhOem802h$x*Gdt2=Kx4phTHHZ*qZABWo4{mZ|KpKzZ!^-p1{-{E^t53LN1aaoz{ zpGGN_YC`pJy|{Qxx`%$mq3Sinl(%#2#%6@1y10kVL;cid`!k!EE08s~5%+3#k`ux8 z+y2QDXCvXEctN(BzW;M(m6L9bGMKday!xnOc#V1$O6D2A^L9WxDKWa%Vsg?h%rSK` z!#eghRdAj#JDT>4(hOEGVfq9mYebLMFML9ng!F?$bU-F|E68@ghXfvD906fcrO!Lw z>BL01Gj_p5umw!&TfS2+4?m0)vx;8Jf`^Mm;gj`$T$a=2j4uty*^FgF*Y7!UJKP83k?6 z4XT1pzMoP*Za9&x^o-?re|ClIXm(?AR13Yj;~YwL3d=0zJJjtx&W^#Gocam${)d9* zzinqL43fK(J5yJE4OO>zRvQgs`JWiV=NQ<0CoZ@-;j}o8mZ8)R2<7lEP}d$lgqZb0 zOEM}E{RMVtf7xCxt7PdGakjf9PPgSu(8j}|t~=WXJ3#IZpkus?9N237iCqv;V&Poj z(oJf#tK+D5Tfn*Iy=c0qkei3KbV4bYz@;g$oB$l0wNRy@3~y4-P;tRfgIF`c{lhqb zz40u9cXa7Sx}{(&!YZm1^6=rZUvv<2C5-^?tB$)^zVOmvuRgsj&Ae;FYgrJ(Aiuyy1@`7&L3XbJ$R}d}!ulH$8u&LP z7l6D0Af*3*=yRyv9BFd>Z=$bk--9{o zg0RQe(>J@@YmDD})-n3Sr%`OL7kcT=GKqt!6zN z(lmnPjfb_PvP{g_CT53=mvMm(34G!a&nK_-X>J-`EA~Nw#XX?)Al_N9!YWEy8u04* zV9TRSLy_A{ydhTe6fVIb(H(Ph`)lTAv!^%0fSd8)U8y--6pzmlm5jm@Ux`Yt;&B4p zGW|r&T5laIV5pE>nzvML$_%~4VBJ0@FAstN$5opJX`nqs6H(3GCn%X}8wSY?e2s!MTR@^Fa_ZRy$ zaCW(11F0jX*@6U%+rZ+wWiOXTDzZgk&CIvguae_@2Kfma0kg)3bu#@qnp@I?SEv&k z-kKdA6;D}RRnK3n5kf6k!C*_7k8OT6UABi$H~j%Zp)GR&R5Z!VePdMSU;>{Xx{y~HzFt7G+t-+ z3dhA_%va`2c|shj&`B&i-kF-Olus2Sa)63J2hgM8g9J+fBX@A-8FlTaHg|WKlV$c<~7BrFn8W68UxQtcurB@&O|HEWU znQ*6Po2HcZ(?x7^{KKREzo^u$UKHc_vdYx4=>)Be;rZF4CwmtuM~!l12*u2IrN%e> z)Z`#TS)?cEZ0n0NwtLZzM|g;w=Hg_E>{#v zvXK-p41j{82f+l#SYmv)BQa~KcF~Xlb|U+p=*%9Z;-=J-@a^&qej^HQDsTUs3%*jB+_eqCyW_d;5!pAk@?dL@hURG746cPvjx67m(c4Rth56{t%BmP*yfyRmErm(qO9^FqCfC3H`xnK}(sNqp zr;Jd|$M@#}DP1j#raa>)4S%ucqk{|3S`j(~4lbKPoil<`CS2;}#fHD13Z{carT+4t zkeaz{-gay-+oRsMf0jSS2U$=9uhvZe#cB;tuuY3ny;{-N1qjV_&eyhZ@ib2zh6yj) zyf$7A-OYz*Bwuq(9j(=Ei0WzKw53Cyc^KtqKiz>WV_cJI{!!BMkxhUk@0Y;yDFe)H z{efMzn{5ioepzTvT!gqk1QJeB!-l->gW*9qnDWHU!g_I)-O~pYTxMqH%80Cp#iY9b zFtf(+oM$BaklVLyET{lH-0$D+x2j_$`*3LkX*Hd8c%HsY;2&#PY#i~wQ8Ui8K2!N( z0{*$)^Nkm~<<1_=5>)_xT;d*&v{+`Da2AyP`HAoFeAM$^@dJCmTB>Yx(21nOj9}jK zl4gBII@ZRzz~2aya)zX&tjoU`**{`#<$jEbh(_7xw;`-YhpUJVDq6QYjY<<4OKBA6 zmKuMb#qw{JDlUsnwjA24MI|aHpFjNLxI47iJ|NSf8{=6Oau`>eFoZAHCw$8f@&Zdd z8h~jJH@zM~frNw?%EQ$H`yHYzak#CYAiD8lJ;D(}?TaO~NmJA+4^^Q_Cf zgazi)uI0k7$OZxo4NaQ4>2Dy;Xo}c7HlMH2lRm$C?cpiUPc~ZPuiC$rcKws6Yfa$3 zUng=YSH4zogD?fqFG|hLO_i%`PY2Azbf>eAi;z}+9uoUQe57fQX<7aAMBU-iuCkU5 zd;+={s~l`>%uRU`D>M|hi&3TNm#!aRT0Sm_dS-cLNQkb94!p4x~{UFv@ zKodc}u|+YX35-&&9i$>B5H=l{hW(}&C)h-PNi!M_3DeX$eBpi(ib%NW&-3xC z-i$2*x6ByzzF-@FD4Fj8&h_SL*S_fUJgP(9N%1y`3BDz-gSM3tDi5mJg*1V>g=OCR zqXnDVF5&noHvYdguda3JMl)7L!i1`ozc5uw28?gvdge0X+hd@)Rttd%4-b*7-{yN1 zN6BOMQtlKj@XT4pD{PQZWK5SkAj8isX4K)bWQt##Qadl#c+a)FWTGE`Q40< zJKHlzk0I;ldC+Ma=YL5{-gF_rTQe7>yp;1XvccA)s~Nl2P9l*8=WL+rBHZWignjZ! zMDmWw@>&cSzoGU#yw%oxytR2n%(TTYwd`1~)sAd!E@xXdh9!nlhjY`5S$=9?n%jTF zNokyX9aGNH-Pv8nTdJNdd&bt#mDdb!u)gt@HnTKG0!gDPgDfw4+^_Rn94Dx2&L%UV z$wWtL3*vqG8jn)caM@nT>oe~O$)8;n2Fiy4UkGbW4UCeQow|<)JuS;x77h7`7@pmL zQm&Pi@~B3PDDaNIw(Xix+7qNn@88n$ybxMy#*y4+6Io&25Zhsb4e~Ft3SZoti(HFk znnUq98Oc_31bM2;sF{1&GBOcHwz60tIsoJ+jD<^NFUA5#U&g@k(&)D%T4_7IcmMtZ zfXECgv4Y7SFN!|-D-73Hy#2agsxsE=ImQ?5jFe03GIBzVP*eKg;P2YVH>^1)3%cvN&*2|fHn3koPUsqZ3PcTfNOj@}Y+bVrWw=s^US2QD1 z_o^!ezb_6`Gq!rpF>cemxSo@JZ*u3Rg6ndgH}~WNt_cx0bP`{%>lV6VIru~PkdpbD zNe3cuwR3Jq&Zv1Z->(=-QX!1fZ@)W8s8IzwOWHfdSwcduJUtZFy$KSCD}PKnJAfWg zA{SX=EDG{HI)|GVWV)`A$gGKu=TM#4&ZwdN<8^z5eLSIW)&5T)H@{)O|MJBv9b@F* z7vjgue;b+B|M-tioytpDvGN>IADbNYVP?<`%nog&NH2#x)j^EsBSkd=td`Xpb^7_XCKiAdPQPW z#E&EadgQFWP#>(P@IB884~ao)*yzb22AMQ39bRs2m3-q_RW|=;tKP0p7`XnXZ$xeo zS>qjRkuYzr_fJ;Bi0ts}Wu5`9UW2=_>JB5k%^R_7_N_5qir`w@;%>|3(g9CsLA)@P zE4VY#>~npd^80^F|NqbPGc^8y1FFqu*3C=lvR@L+iJy>YmzSydUfy*_RU@s@4R=hF zsRdV`X{Tk0SN-8c7X-7_P!Ru>Yss?8uK#fRn*f)HB&pY>QV1+|I@l79=`C30tt@vr z)T`^GQ}1w_wSCs(rwa9V)@ChjmRk?j)=e!DKqp=DN!=^{A3URx=DmOt<1Zs-y&}+i z9u@`0B4?ZFi)%Q@xV*7u!bHWy*~`Y3>uk&5j|gk(g_Qv zf45(4lfP)SSb6)D(39iiEv^a3dRT493A4@iM8Th{tKV@RR7qeg>fmmYMWKq;h3|cIx4aR53n?^6J zhf111FroS1E(-sbkvOW=?thfcy_+~%b=|=Us~(j7BJ)8qT6EL5lVd&L?$&S{`9>*` zh;1=ccC%)R!N5w1-4j$Z4Zo@?yDzm#-!$8V_xT*{=Y`eUL8QHs^caT1qjnP%S`YGN z_NJDFxMvp5MXzYyh-xOc@pFgglLal25jPMs`%*{H;}(SG$cm~%TlpMV5~u9rFIBIH z!?~~=>1qWv&L@+8hCyL!8*4QEb>yDMLvLG(tsBiq^{`H{140@dT zGH!{%?{|?$MhPI=I4Mz34ft!rY zhtQlC# zE3g{5(SWWL&EgAXPGz*u>yW35gMAY$`Xgws2uWAz+lD>$Y!jkwX08Y>&$U8#KOF77 z16!E5@MT|Q^dTjBxN@4^xrrVqs(KdWxZTax8$9GKVMI?osc6ebbL3@*rh5yMJiYh>z@;!Uj}}LSya5Qd8-;u`U#ahoVB@NPj#fOo6afrUF-JO~ zPKB*M+yC_Z>rV=nQRn9YPNiGGJ5SdG{dM6n)3)ga za_LvkC-t+O#svBApA@f5t$aPZ+aXY5k$Rw>T1pN@!G9G@%8aQRQjX#gVk9}reBs0W?7O95h2rQVtN z1yGM2@J{$B*JSKM7lDRiS!uIYQ>E12UExcBhRf#;cR zi6K*GBHC{HcwH8gCOA)_s0FneAzhQ1PEl<&hmH^Zb#(D8`_sSfp02Ryzblzm+38(^ zKG@}6ry!9u@AP?RDfm8!goBY2WQ13ku^i)E`~-UNc_N4PyXfM2N?eVEEQyYfILG(T?4_?FXN#*Ax98tJ@S3eN03vg^aH}=#1SK<1L(2B$(G} z=iAL7d{@P)7`w%?(ZfgyTEXLaZ``8vQf(`(OF&_i#BDo*RVE!Pf5V zjTDTcf1e^?=PgSd6)eYHfR4xVD#uo+zi#5PYw_0S5k~iWz(f;4p`e&kk}zzUS^0% zSB{^FWw`=$6M9)~#Lx5kUjrTEC-l4?;fyaw&O7*;{}1NgG@Q-!{r@-9X-%tYv_mX?AP7}U5TT0Lcd_qFh^@c7GvCkg zozIj1gWvIg@XG@ZTxagXeIGf`>pGX$+ZUwtq95u2tPiA={j(ldCdxmUk9n*raCR*7 z!!}v8{>@CfR*vA6u9oGZ33Oo5)i}uypljbB89RNd$Kuk-kxwzQb#o)oz799#e=$nh zsyJncpS1L>U8?Gao?{fMovSL_q`=Ma4F78W$Dx53y5-W8w<*_t(|F?raG@cBd6f8D zO;&(Yg7Ba1^ijf!lGCfQay3sV|4ZJrhJuNiiS#ze+4rhR9Xt2+|!Ys zJCrsMkF%IAw+(95P2>{5wL^!me!5*AxV&+aG`9@g%}--E(E)so7e`sN|KPK4EaB*W zrw3X!l#y;v2#}|*n&;c-f0kkz7|DPYLIU!h@n`2B*6d&T)B4dEp<8wnI=P9Hj|8&t+fA^(B-)96xN+=E^60yZgJM_q{f<7Wu*#)k zQ^ZTxEJTLPG`_J6gVIlDLIBt4qmr<~(P9IN75ZbAed9rfK2*>hLW&yqFO_75b~xEx z>fe@mbp?NdvdLYSK!u_|VEp#6lOG`}$3lYcbv~*z1q%i7WG;$qm)~0K4yE^U#>Cv1 z8~z2!L!fB#sGv?dpq_7m|ETS>-~ z4Jz)X^qp=vxe+5h>8c!t8<@(IqhZA>!1mxqKf*mDXKtldtE~O>^_wtJXz^?EP%3)& zq|L(?vo4v5$PWf|X1}ql=C|yaU}6ZOHuHtVS_VSeTsKcl_~%hcCwayVLpjs28z^+c zOtI)l+bDW{=(XaZ8x_Ac#4l?_qpt8~1;IHVQ=vk*7vixMK3OFOcLyw?gH7#?`s`nu zywuq91-T)%-H#d4_m_(=S)5HSNFm=Zq7ludDkaKn^$kcoO_Zr(U@~(iKrQU#=C-wT zm8vol1rlkDA%R8j>ED5(LVf-Cco9l_?+51N0N;&ywi%x)OL9LyJr2NYHKqy2Wb|Hr8JPj|DEjAp{{H#{6f*FD z`TM@ZLDtt(&HJX`Sl;pMG>);(jM^OZUYE~7Hg=mTgy&C1^xVCln;*P`>Y98q#8I)$ z1D6pooqqI?mJE%G4WoK+YL0>N&bHNHxx)>xfNxxz0uC-X2)@ty6wtk8T|f)?`a)2GtIjvRNa7 zf39eyZ!&CLR|dfOkJtuw-loa`tt>4{-ucDL*VybLf_~240BcrsS8k!!L<(QU2k~63 zFzjm{o&)1SkPF9$P1_lCA{*l1IUt9qtqUR4xW9|(#M!U+YJ6=2R{__!Hg@BdTS8%U zMLXV+6egMS`}s>RRleWqAE3bGv-7&%0kMnpDF~ld)8@=nG--oPq+i|GCS*c{M%~!l z$Y!4E+67$B}6Q|KPW#`GC&aa*WDpkzBgh+U)E z-=-79$ul`jc~p^T9V#hzx9na~@eDFu_^zz^VQT%hDaS-M4o8?;_DQ>Om&B>a*H0z8 zq6hCl!gUO9``7FO=#`b0|EbwPsN3?xgAj+c7CDP$<5Mbgf}Tm|{~$G5>!C1o6y#fg*BI?YNKjX@oBo1!^M<5ZAI(Lh@n@{W-m z&G1B_hW)s#hpQUfDwg|Ty1%$$*lW2@c5Pqqs})lvs$kbx9Bu{lO=u!yiF)4Co41v5yufelRY zPlq?AwQC=s2eFB>R`EU1%amVUlq?=}llft^kTS6KCO;^S-=I-ZbQ^qE!AdOkIx>Mm zQ_NFLSC#ea;`@T(-NPoQBV4eU)#|JuQ>TAWY;4 zJ#i1^%v?_FN;%khUN&-0h_XB%@qA<> zZMhQ=^W%cRrmSj$A1xQG+(m?18=JQ+7)m`IkC04dq_i;d4kjw#!X{SuJK<1RVXwMB z=UKmdeSfmB@NAv{Fb7L};Q#?ee9$XO=0(s^%(S4Dv}3mWE!~s|+ZhL+pFL*e%qv7~ zDRogXu?(eh&_H*!Pdw+GZV)6|hb`bx4`3-TTvBQdT53;^((9^s_|ui$2fEF4os-r6 ztk2~Yh^`>Z>aXGp6jPp#J#F9@l!^fSO5dAj4k4woe_Sx?q51X>e zbZ8h6m(q%6pQB>8Nu1Jdd&?1bWem_24;)p70TAqdw4n?BfzO8jaao?TnsHD^x$MK) zW6fJrbfScj>H|400YCFHNB2CHwg891)s>MLkXuH*cg4ow^d)5bPyIxQtdGC^Mwv5U^>jSsIATyBGubcW?ORNp$ha~3BO@pPY zz7ew7xCL;doc=brF)7-f;v@>3HKf+Tt&Sjl%gq-}JmdoRx@)U1(lrkkvRGNS*tuy& z8R_90VI7v$I{3C)*AVM2WN&GQ4X5LJ$=#b4hGf~>yNRF3@&Zif7ZU@Tc5^xFA?}>5 zS%IKmjm#7@9fyNRF@72*6&DfSV~od}B2ua74`7ZV8 z94Uw78s(kr-D%KP>(2a;JoR>F+nlT?((0(9Y+q6r3?0Y%t9sZ&jgikPE)H?7;oLn( z@P=12RW`DtxMJ^5G>0P$0YIVRNpuWctn+Dmzw|6Zkh*Eg4RMT{monB{lfp&OIOFfI|SfmtM)^Y@@K*Vy+lsiIZJ&t z$9=Mfgv68=RNz(dBUJ&e6rwP)#~C^qD?#Mc;WRO_uJ}?Xe8UWJfVY)Qrr#N z2q){4U%Km{uBs+qs@Q~oM%<`6<)2W9McRh*vyH*9%4PE+0)hm;kCsey)I&h6Wl_qME1t7(?VR zvbH;?=dp(Q$Kv>Ll?%V9xap|4H`u#s9-t9(@(b{Sb-U5%72G1i76CKmgK=Ei;OF7n z&dDr$!p|rOKcFU z`PbC5MZ;+o8d2kTyFdXNDfhiI|6B5~L6t#OXw@|`*5Wgx<0@juP|4IQk=PYrUs^`+ z%#=+ibyap~KKqu&(2_~D?0g(>bv{2LYmr7OmcF74XjjIph54FbQ&g2#!OHEJ3z}Ai z6;DRbSy+zw**t){d>*zU6TX>Aq{drp%8!bS6<2|1I zQZ;&X=%COUItO1@Pve!*bGD?6*=T)rDNUZm-gdsJu4JXl!^BZWn69mNgkF5_b(cQ1d1^z`Y{ zRI7Z=1q7~LJR7y8>wj$wy1u5xMxd|QQXa5dkIRe=3cAy(6fWt}Fm0_*zv?+w-A*>M z8dnpwE=aL`W?Zu-k-l72VEFNjUldo$X>mleSm}IQZ254r-S+QYtv5shVTAY$v2QGu zK>8+tbU^otnV#Cn8-NI}M|A#Zk9eu|?z=Bx59&LzoT8qo@wvaAaQ~~vN?2=`Z4~L_ zwp9S9^xGou{=KYMSy8I;sL!9V+$=k@ndk!}cIN=lGUDQUjOq1-cUn8{i85%JyeXbWH=j+FL5) zDcWuVA2YR<3MJ3TO?J&r;Y-aY)%rNVB1u!*)^+AamsB-<#GEG zcz%dF^w?bB+X>e64#nSECIqcT3bqW7$EEa4_lyAbZ^YvGvY;U;|#EfB1#yEY++{#oEL!4JWZ6E&Y zK`F-C{qy^%U}97AwOIL6jUtX;m?WooRer!pt$zHKHuTlqK5o0Gq>WR>V0g^Wh@%w2f69lev@ zp6hEDJbe6D^hGZ}=jVU^5R!X`!$gQ&_hNT_rrT;B05$C-g>X2Hs+(VI7^tuU#wSK_ zFXsdp99%XImkjFV2MZA7R(?L3$TLZx#$eumnnFqms_4?rSk(Hfvg&uEJKUdtVHurrY4r@Ddy*+vrLE^b`K-cbq{=JD z-4D6$EW=)mqjKxIw$CoL4y5EeJ3q&{DHpxYnZehht)2Te2sxALidF2Iu*n*{^+**^ zeDz1JyZ?`sTZi*MR!(?x3}3%NVGU*-oix6q73zE%rKNt%p8`N)k_2~r8H$IBTh*s* z*H6t<4G(__$~?Hx+qjvkldWoTcqs^Vz}g%6{U>2X@<-i8=jPldqVIMMv-*hlg!cg8 zf1iExjpb&-;vXZvQ{U&QKHDEYjr^ngYa`2{-09_ozK<_M^9~&tXIdI^$G>iad4uZO zYE@1Owy?u%cF@6P7mVxX@I4!>W`3Eu4a}k~ho=10T%^pbSDSjM{lI9Wx$IJ}+J2>M z=y;{i&tH`ts;$3@Qyv9f)Q6VN`KMxBwz^#g=8{&irkY$`p;F*3GEpa{#9@q)FdCFT z>Mplto6G5$aZ3JcK&*X6QEf;@KL}AHXKqDdoAfj&%tm7Y9;}tR#bUzLC{uCD%7~ff1H&njSSrls{6^?6N@g^&UqEy5qX6P30TAE2HL!& zhX(lY-GSYW;R4(isEx=(`;qCU<0{%J`2~r~^u?MdX|%mW%hX9DNB>h9m{3nsNZh?K z@b!UI*8%JOJi|-Vc(Z_Ql#~0NeJ07N;sYEN#>2~Rra`hyGrhYW0GP=3HmL(9rXrwJ zw}ETlO=kbsgzEU+WP&XYQIze7cC;j1e^mbYPZpN5tc^C%dS7~OouF@(jD<8MRKm;J zz1ju&A;)u&821cgC|>sAYg>QpkytfVd&e{<_BpHYpIxnBQprOo{ZBJO!nAH-%-zhOglhwYl2r-j+ zT5909Ootg;+54Es!PZz`{-2cBTsb#9MQ|gnwfQdZ^%&wsl zv1SLak&Vj_VXk&;wXMz}L)Y=pn+b3;UfupMJxMFiKv>gbn#~k?PU_KZ8?Fx)z(2|I z!!T|!`~;Mpd-8D|lI?v^pHNWP&QmQ+z$MR~j@WSk``Nkh&${(CJpdcu>l=$HK5>xs zbjv+L-RmWe|J3Ie@y5&0kC9*=Ai@t>-*Xeb`^wmRXP65^dMzS?8mV?{@^`MsnGu z4nc3!1pl>dyMAWNtTr6Tb~;}bm;Bk{`{>H*i>XF^)BC_VM z&LWIc-dfFDNG0}{@hGVCL(lE}Bz(Tk$DJaP81J4<=`G4R+kb%RyHaLf&EwwymKkNR zioWew)QA8CI0@w&k>?xV)Zv}6V|C8M-e&r1Yu1T9ktm#RI;EFWi|wBO#^dY)m#2cl z*Au7fwZ~qq>DdxPSDDnh(NQ9l?H*j^BLn`E|B>Al?Vey1&IB{{I_GTR!A@yJZ@KL` z#GCfbZXw3)(_HL0DJX1xa&6t7@Tx!?V*%HWQai!LOf1xO(yGx1A`*YSJ$2r=*Yo8Y z%Y&nGx^LrbZWt~5`6*Ika2x>Y?2o{J(NKW7yzvRib`aoyG^-v>-5Va9(mi{uNetiS zNIUvkv8&q`u}ZYtIQVZ%oqz8l!X_QEBhG9>n(I<0e`O|+q;&O8RM#mefPoZE^%#;{ zoW+HI1w<>t^XGv=#L=ec^HYJbhy(J!FDB??Pr=XZ$wKJfVAdZ8K0~#V>sJm!OA>c9 zs^r)!=<-7cf=VS%ld#HC^53i0;~MC_TSgtvLizSZeGe{L8bf$qj8|cGRL;UbSU+)A ztX{H+aS6rDS+9KR-;qS)P0*ms(6+wM>qkgoVLqVtM(|P_`3;k`7n^;N)Jez(lR!OI zdRHvac+euq7v5&^d4oTNUy!dkp=Axutq?D{pzTzsCkMkl8Q1{92i~;wFQd^Ut@~bn z$+1}vSuT-v2gU&5jX+Mw(t#i@fe}&BXXiwWG#x-qXW%CfZ9Q2zGPeGT15T{)2k$ zZ!Au9{cV$xK>=)V?O>kdv-fS)+F&k{C%;K@Hsb4a&>CD3OV4-8(;ix#*Jx8&sDR}$ z#qUZJtv3Qo?m*u8!^>R9Z71X0n3}gsb1yBZ=j$qC_2bi^+p;2@g$ zJ{|pNmiyWP`5oXV{(iBl^s37x9@NQZvhu>tt&wf!Yk5>v^Snaen9 zUGyD%z`7w4Xr{>4$jY~zP<_B5k{-_QJEGK8F70#wV{xe~VJuc!HsHNw-D?2zBW(i1 zgzu9;j?t5YL8L;n+H@IH*Pa#Lt5%Yb!2n(XnSN`O1788wJELz8(GBu#*aU5=pR23# z)_B$5;b|w^gqbt5E{=5Qn)6FFa{@iRmr_;*HHqWTLQjTIVWy0#*+c1}Iez6Jj2;p9 zBJsBYKxvD;tHwL>E%&lA6(WPnb!{7(G(#h-@a2>iG!g=|=c%S5!Y%8TX60TqS!7l4 zr>Jo0M<^zhrG;-y-$i;dy#TD-$5At2lsG)cK-m&>sLiE9*55m~KB&i#x1z_V^caZi z2MR7nDS9(bxOhGcd*N(UWMcu|SgOrbV@0opx030@l656HXLcW(4)3cXmf(jIi_R(= zFP~aECcADET!#*yn&fP+Xz?&YCGRizKyVgsk)GB$Yno5;eJZlqWH$sG-P{U1&xsC{ zw|A?|31qj+-8~pXf*#~lm{BbaB(=4YAfHbg28YG%BmL^HTeV!W&V+i-T+AXA#uU-L z5`2+vsqEfXFjl;(fu!R;ld*FL!|&xK;2nBb98rW$2n9Gy z#M#mXWwX$awqpAFcYO6tW%svyu@1m`S&y06f7WYxMEtWJ=9&3l>lY_tbL?(0P|Jcq zI}IEVQW)(~gw~lvT<{2%BZq)8!E;>~y*WiWzfe3e=2LvN{#Aqox!G*Yxw-((&T$BMMR=1aNJIMWLRAa99bWTrj{9XpM2=n z*SE2pM$Xq5=N5k$b0s-#GjC2At*@wZ9U$-mEvDmEw8lg)p2?r&_DjmnxS=seHul+E zuI{huvZpUi-8%A-pV$hCf9of<9~c*VY%q*;5`lwl2e)PL;hVSjwrgA1GthO6a-xRh zdU3_a5XC7Vrso|OFJ%uwRJYG{bY@kI?DP8CM^_JH0^CLw-@+8=0V>7LOEaES*j?De zI}1%nG9cR~uRZs~^W(|TzDHO=_5A+&A5+=<(6J!(sfw&21%hnHn+{XkY_G7(!(m`2 z=WH&?M1yQT;FtlOm>x;+$vo|NJEp25+FvD@sVZ$juTZ~0gTQvbSUt~DFqyYS#4aw= z5HY=1CJ5{yV{nr-${Q8?l35^ORZ!-|BtFiS$!Mol9;JSKj=lOmDOjQ33v6gr>1Fad zXRpY4BYn0{cvpV9XKJjPX%8qz0DMVj-F#jC$fK*a838#bHNVG2TEG?%yY@YWH1% zHK|RGyl*Vivf&y!?g{rJzmTJKU%)2m{{MBP6aMm|a`KZE;_o}T`2WMhD0OHIG8~EY zBqE|8txZ1PjS%lzd)8SK9w&+h5gpqgy!XD2WA2UO94ZI(&Jw@`PR_8lM>CfO;So~; zDb8}uVIYkEy6L0*bE=8~5WE|2xsqph6^2$?F_O)F6DyakA@VF;3h|6B>L8S1D&D3A z$1kN;C|}QiTM-rYtf0>Pe9E&&>8?30*0bqh9lcjtI>J0Y2`DzFmWRmxHcN#YH>MY} z3lw0L6kBCRQ1f=bG$~ND+RMla%;Y?Yn96y$0yE9@YM-7hua|X-T#z0ClC+HQQGk1fNQpn=_Kgjiyt{B|b;U?V>T0Q-sYhg ziG89O&e=8_1aA6eIeBBJrf}bR0^%QbN>Pz{%si8lGA#Mawn`={&h0=M_CtvC6eDu@nD z7&c>OfM8}CiqyYz=XTafI}A=t%~sb<;+IY~8&;`*v57==KSj{W&|NweSB^Uy!1{lZ zX`XrTewdu;kD1yX+b@W@*+j#n%ve{e^Tx>asG@=s( zY_oy5f#TZ;6w9sDCmKoJf7T@nr8)Gii5=m{?Iz;6IeHb7nt`F&1XG=rgz z#svU^lCe1#UbT$O0|IISz(}p3{F-He{h)*uvBU(R?D7C3CI#)b{~v!XTs;xa-}NN> zX(uvr$E}>D`yr{}46i_bivkQ62Fv~KPORPe1;?U>8^6w#{5ak4 zm=xau3l>^A%3f7ADwff#n5&53gqw!B1 zF!gypV5yJq-^ru=`DeT2lZ(#+4pe1M!NUI)XE;xO)_ib$_Q~Xdajr?iqWTDUN*G{h ze$PLJ^$4YXV|kx&De@e>v2%8Qa@Bp;eb)DwTkkke_up6RJz>?ncYJ2C@%wvZL(X1~ zeZzGHaL01vOX5qJK)5~(3LEoN>&a*L;verhIh&V#5bs zt=;~cvtk|NDIr*!GUw$8m7g=`LkaN+&Z2B^NwdE&tH1D>M{>)@+V57z!vE@Y{m|`F zR=WoV$1-efQBNB73MpcJRMk(5t$ZO_MYM{hE9kwhWB*nG{}wfP+9jpF%~~JP>xmJz zdjc|nQJNvVQiDY+0kwt{>GET~4_+oEi?r&dE94T7*@zpn5x+K%eezGqe%1FI^J3ig zV9_+R_S)Lq2b^O*u6s#Xx}37g`9NCIdwd=4l1wHe){=g}TPLlG7Ra{YEP##5=W@8WU}&sp^wp83=-H!`hQ(J{iMm=5T#|XiixNj-G?*EPP>D*U zsw!E`LllHrKRJ5>%;zt$TI2o4t9A@M>WP(p(b|hl2evl^y9=Y%t1ms5)x%tpO@Smb z4OW)dJ4Ep61jCmm(M+i>1WY7F8V25g_Cu01S|npa$5JrGV|QF8#DK^3k>5X2^2_g)A|v5|F&|JUK>UOy4QU zTo!(q(OfNat}dh4T*m#nk{si8(aIG%C?z8ijLFd^g}(~(>I)UzT#MFWrW%$sGnThIH|!_$=lFWztqp8&#dbc-PVs=e52~2WA)x!(2Ft!PXZw7z!*wKQBhI2mMm1R#C3~!eC{5DUp0zkCm!25>qRlWj%>F; zRSXbK`Y*YBv3~9*nlX=-l;pAAZ8&i{QBC@FP))Bf?jT2ZPWeDq^vsynZ+daF(a6p3< zAm@f=T^~R-_O{7vK^b4#hLo{ykxL6zE%%?s(!o_|)k)LTCpkqmGH5M`@cwnT6hg)6 ztTBH_Z(CW%k)+MWQDfFNC!#T z;+#|{XJdC-7Fw0A+}Q;n7FI-bY;4hoHW0yf&vW0y`Z-7iel*WXu9 zCmC(YCHYFxNM0|*QV=nmB>_@)iDq1(d@JL*KY`a3U#Og_`Zm2E5(8{f|F)@lY;Ls0 zk>V>I=UDYelCeR{>p^uCuDvL>D4J`xc6X=uL{ro2vwwq*R*-uNW3k9vfZjOx7eE`q%u+j+Ne&Bjlz{a!-UhJ8oV&Z%G+-AqX(+| z_(i!sAN+n*IFPkHIBS#DpSsL_wp@dxB8TGA+L5vo`xo(Ka?_t}_R^_kZnBj}BR!n0F8kQ?}068o}<#=Zh z$n2NhavSLgeqp#=D#?O3w#~BaO@~W4w@9JED?WdnR!pjlb(o(O#`fnirWiaC>nVxO zyT0U7ZoR6a!GOqx=E!ly6}n$f>Tr>6Hi^=Cr3P^tr=+avs7ZlvKly|uX}8mqxqz$m zo_3IaSF3Im*!SmFGs&jZyEYv$IFO}TfBCya$r`l}f9^q1y{zqB2-BIuIDOTMsOMc6 zr~X@;(dAcBiKlB%;WdB!Cm8U+^qh+>ka(?CbSnZM3gAN$4R<}a@8?ua+ z7>p@eRF0-PDKWaeJVc#+8aIVuw$c7JLMEwyJP+B^(H)jQY-LXgeCjoVGrnz{y8TpA zN5#nQYKIoKq+E`W9SH|GYsjZYp zvpznwC7RntfjuZGw6cU49;Y%cX_q89+UyYenPDutVq`v2J{IaaA>kY*H{qClLjvBW zdcMJFk;47`Uy=MpJOd$13OY#)o4j^#5nVMQ1~&}(ya_hl1 z5t`ob#;nE@ilswKzOlqTzp=2Xh|jqDC>z{9MmxJhwDMG}Yo8}|$9(C+rPq?iCOyLJ z#?O@=2aW$xgKHEvx85`Pz4ebPK^K2onNPYV0b+PlA3-!xZtovYP^s|oEAP_n6hjv@ zzpfT$)p5*Y$rl1r&Y~24lONVL^=T=<@cD{(mft_{@V1SxY7Ols-gcLF&f(=1L|fS| zN*f9wEN?}RO?ufEj|5A&)HI{yW$-~aY8j+5=dU3I9EFAHAn0{UjCk@WFe6DF-Qfhc zal*DQesmTZxQ!p%`c(g7wCV_cdrlzWF@fJ|f?wH4Je9R#dR#R9ob!2@b)L5e~frkjYo~24{xp~cD;~)L6kO=-pj#lckpKn zV>;smaM0xU#pmj;S$}>9ASIf6M8YnMAKrRglsjTg2adoB{Eb5{ebp$Hj5Tl=pQBEr z5^ib3>Y0kRD{8=DR{)r+$_p359Q}koG_bAeo(f*RAMh1{6rk9aw)k3Uj6WDOh|SDe z&*AV=bvG#N)rSm;r}nN7R0VUiE2S+I@C(SN5?qT0tZC!gvwE?~S?k%HYf*bIJ;Sep zPF}<$eYW;0OYcxRAwy@^Fpd*X9TJp_d~9T zHDk{FbTJ=8k@xGfz%cCo5?vnbHCt|d#A%lw`t~cBVBW=H*s2{e6W|hSJ&|o@W@fr9 znU|NhYq@__@Kr-}PNkCOCJn>b7-Jvv;i+>FW~uU|k5rbWHNa`|T!&-Bn`sBMpNoAD zLKCHjyBwZ%l|A>uZhn3ZwLARBn2QZcg6g;6b>Bd zgK8o*__r+a5Z$-I?5AS+2m94r3wzH`;Wgwfs~$uhufwEu?m~0CE|>JdWH}f7*Eg@X zMSf=~PhGBzv=;s?lGyWWMCcLx#^@J2dSpA0ZW*mU4LEp)(!C3L`*Gs{IUJ$0^=tkNam1{`QEOEkUy3epnLahJIK}Fl-`n9DWBKu3xR5S(wnCUJq`GQiKi&wm z8l|cN<=*%Vi1YT1fgs)O@+o|EY~u_Vfm`*Zdp8ODDo3{X3cjPS8T)dtu$(EP)a1V9 z(1=iDYR*=9t<=mz79vrNyqZ02@$Zc|Mt8#6b^TD__tKJXvk{e^{-zTArSs>>~u2)3=t#k;GJGF4lMeAeCpwk?z03$T5` zx2@U)`LuWkxD6Y_zD*L)uG>e8;7!k%g#Y1ZL&_{)pYhX}7nUk@SIC0HN)*{&l1Dq~ zmXayDZan3Myw+*Q)sBQ*{Y8`(=@p?K1@^$SGL!MY5Bqr8r zjDDPpDv(U1x^DVz=G(0$aQuPi%p^Xkwivt`DYX zj#8ViaC0?4=UWfT-PylXExh=6LU{507=BLbZNy%z**6vgpE+H_b)Vz68@Y3rv5oDf zs;B3e;O&s=$fOIn$Y00k2fXyij)}=rL*XyY^}4-!L`XaM=E(}jB6Tc!@q{{=l)ZEO zT=z|m?=kx}es0I-8;cmn9Di2h87(|h6X>pKpSPXT>48_b?cguW!L)Jvyn4cVDK#@| zD}&DrlO_8KOiE^>a;SAd?ljTo1kW!93mu|k@y?te5a-2LE#Wo69LA`cHhTBD`Ovq^ zFz!@agyH5!tzp%CIZxkeUAL6 zA>9o{`i;k}lHPNjKay!qEN&fEfr2_?+$k3eed^ zd3^UsXGAymCD#RbJI4aL>LVkU-PT%1OR7mLJc0{P*}I_XwnWc24pFLYRa>cIm{srJ z>D$&g(V0*wp`!Xh#QsTgZ1ss;5`iu9hY=<1TsFcKRxaJ?ehSC+bGPfQ@}1K+V^tum z6*=M%=`Wt`0yq8;FLdRf8vR9E=P#JaZ*u|? zQvUOq?T51{zoQ%yqQ^33TfwGSV=btGrqDOLpUd8AZzN?BGnIU2?(l3^P2-!=urhL} zyVwd6)a;YNP}Mf)x!d9PVTCYqWKA(m*jFaS)FfGDUumiU49>YLJnrs(28BFx=J=(RuJ zxti$~EK}~j9mK`$73HoP5td@IO48zF_}IV)t~6;JJi9tO6u|Vx=w=S@42re8t4LQF zs;4z7?4`_`O8GzsaYlX3RPFRJ`qEVPJ*bz>an)chb&dBZX)YjB2qK!*1xXL31m8kc zE3Ys8Vv1)k6N=2=;fCyeVH{M=Q22#(@*Rj)&MnGuymMZ zJ74l7zP-9ut|j5uV@M4&HF#3IJNy{r19^^ zR;yO~Chwm;l~5zknEbM8IE|lw5gW-pqS_i}6L?Af)VpSXP1xe-%RF)8jiN)YJh`&JX>rW2T*~*}4K@*;}J3fnx!3JB^)_@!1Qr1`i9OCBCt!+eb5Y zs!np-ufC^qEGQ=GOjK=DFwb1NHZ(;(%W|fyXD>JsUGXwvECjQIC#2rp^=AIK6+9{- z5#bl~W3MBSP8J>Ew^W1S z24)8PU-TvXFkwPGxAx%yVu1OL#iFf1fZKB?7s6IMHJxI2vA>G=C@KV6TVK(B9L^|P z$B7}42DQ8z&B7-e48!Pk-}Bsk=nlglx@^~kpF1g}41AekWLcUUDF#xb?&hc5wc-_r z7uy-Af6-od?(NmZ`tMH)yIhM{DD_*j{qQ#M)MfBy*cDDxRY^2Y<&0C3(I{@!TwkF4 zc4>#y_`MnV?!Ptygpe^O&MOP^4HxDwjS<K8 z@a05ek6;8sY_cU1lIc(ftvvWgV#ei^f~M`NxiOqq{84p6dAlQ4DDd$hGC$VEzZPPNoAJ` zm9olyG-@lY;r{bVjwI~B%<~){Zs~|_sV?8X^ywJUVhbn|D1*4fBsPU3te7^Bi#IdR2eB=)JExXBoJM*P zt#s~&nIxEwncsXeFk$1!V|+tG{^BjcdvF)Owgo-0`$)D`&x&>myXni}gKU`HdDuUX zO(b=Y^$?@^>F7aUq{hhkcS6OHzdFt~&^^tsL`3ZQJ_1`qb*jjkf{&_>uD>PN&c}>Xf(z<478|2{JBw^X3MMHEp zdjNlf+DYRr?14*CM)AOs8jG$VT)AMESd%LqZ zv(@xRmFd(|w-w*&+m>72t2emmU2mblc6%(fFs3+sYdg6nxk|`v$kXOP#aBM+fs|u_ zm^Jo>ojOCIp< zw3-@s%Ziy91vI-b;B?>w(^ww(>MB^$%JLZt`{r)@8ag3ZkP$T9l-^}e1h{0A*Lz^> z=W6dhB9Lou=rFd3Ux+@+PG9lqP0+rFMx0%tRF8N>IA$oWN*m#aaGX9WnRb-y{1Rbo z*R_`DT7BU)9(ikb%_JLF=vwU7%!RQM$dTX>zZ02WVk51Lg**WS4@^DkL68i1^vr}# zyG8h_kg1cKp=(L(yrFDG39YQu9onM^!Z=C|9Xqdx))G3I88m!0Rez}aYfWCG^8S_9 ztdPWyM?+dUZCyyJ4ObV^+uZ@@{J5Kz6NIs<2X76LX@-H&`W~Y=167f0QV1iuN?j7SKPrFNcKX=97qY;v`1b^O@Jd=j@t+6|J z2_WFFy!>7(46(6rEo0mNt=lg0K6TGz+nD9{@{IN`Ocw|z1G_ahRy0>8$NkO%=QiB} zdowt0ZW(5s53Vs9L__Jau-GRW-svY93jCagQVW_ZLwB-_Fcd5)w2(tSRGK&A&DTlS z?vnM}s;+7(v`O)LhIQHN>*o^Pescp>qvpN#MI#+G#>-ZQmjJKm$~u7s=>_jfH3_PO z!OHIK67GlOkAu?ba3`Oo;3o-`6alnS5LWDyHP|r6QH3Qi*D?dYX>ty!dpS!=XxYq` zi)@n98_NtK)IBkxn|mLYE9*0Ss-`X85Nh0Efbu%Fx2m zjx}KS^(Qp)R6h9&>Y{jS;?qA9_y44o53jY^_U9+)d&eqX|54dDYg*hnQH9y=Wqp-? zNk6)E!_r+@^>h2Yg87JlRs_YxWE7^vcx)+TlD3fG0J90;Y&vMrq&%8JaSK*CsD1st z7k10Hwu-04=U3Z~r|$kvwOewgq@U-$?kBrPjV0#R0Xjw@R*O}+HG#e+uAy#^2@edJ zQ(qA*UY9`dx4fHFZpxZDhnWh+>tDlT(u7{fbS&1Oi;76UFOt1&Mo6*HF-9IxB?6T~ z6EOg^0KgYSQ`l68_HKwC|Mk}olpdccpE&Jbo70#(4;eaZu)o2)_2O}?0X-yxcPr|^agPt%9v_{zEZ5%EfJi?b-^ zWzURr5(}>+w{Cl0PGGLYCtd2D#9Z;TMX1$u^B9#iFY!cg&-moGoEt^bIhO-Sv6f`F zYG%JJ&&2>AmUAR=I za?MO^9W?JSE|gj*Ze5D6IpsCCRn^@9+bTe>w;Ou!l-T(OAd1zsTEgl(QaEZzwW!_*H)_nn${Qd=Ft z5$Hwx%Ae3lur88HF*7WST?2K$0pYTF_VfFF_t~|8DaE;SNIS$lG=j66cK&^twh&}y z-FaW($5;+-S=q|! zM(3KN`@CTD_z~xP9jfcbtf9aZph_7}645&42T#_Tesd1a$HCq- zs}G+d!*N;@g=F;A!x;{*VE1`>{sfwN9kk(6VI5{#n`?t3CHhW68M&J!{&xekfh zFPlW?8QC28291a$k)wVVgf8Oc#&10jii1L8H|qQdIcsUbb)LQUL^AZEfgTms`=S|Y zTxg>fN$G<_bRG_Z@U5&YgJkja4%$);)&^ANcHH(6yTCZ>2E~r-XEKAN9SI;yW~^?O z|FM8LYO^%KCEwdV>YiXhIz-{oUojR;#|?pF9$5^Boy@yUyGxI@D(bEOWD-WR`s7sX z`mQ@3=*Nv6csPyFT36pR++J#jt_a|TwP>Ubm_ogYEBC6*YG@_gI`B^>cDh52wPF>T z{gg1ASn&MdJ}uA9p)j4Mg49`t(v|M%>9H)C=0_xsGZGbUua3--P-hIOnT?75N9WJO z@XhiPM^2^ZNjoQJQOV%_^0X~6q2cX;T7^ys_u|gcfG~b3Nx(616kazy)jFYHml@hV z=pY}??fb+ux)^e27AQetyG`b= z?kTI}izv@Ul;*td3o|=yp;U>7W_(iZ<@b)v{$zS9d9Gfg;<|}mC|eT1@bo6CFC*K% zSF)0t36}57zAmWgPaKH#r8Q_bvc<%)<_y7H7N8B+D)#|vLu-l^K|CIe9{fF6tfH70 zJ2Ka29` z_^$Gfhc&g-X_&k@eg7e9v-SIFw4{d%{OPp0`Ax!nvH}($V8-8Zlpt&DDg>ev@WoCf z7rUWzMxDlxa{COdkAPebZ6H2pQSggjm z3T=O5Kq0=WRZw2Cr@NkBcR5Ki`Mre0o_-cRpafdSZKo00i!i2jhIa|Mz1tI|2z@l# z&`5rSBEQ}wRFZ9LrX)ID0{kF(<#SYf(HyrM(PVq8vGFTH2HrH($a6KYAdKUSL9vh+ z@T>3|4Bz)VihP?q`i<^EuR0#-1y1d@kE;PT*KZW#i=A1^pwwUJX}T{!QOgA|g*6bT zAlpD!Z=8MRr~sA?uCH!CH(BR20KaA(bB|KEOIJa|%NHl6RU!|BniywPC z1tEeH6!X&26itiI8(h5}{?$=z`oY-Lb$=%Grl(suzpL*W>Lu-+Sx5g4bXfAh$S zVoDGH z_PoJHMh3S%6KD8;V!AGuoyOJ=(WJXd;0(uh5hJ01ug5{B%({pks#4KsdgDn+oQSHb z^YsH6uJWf(v8OF1;Df$*dhFsdY%JVMA6F<~Hf|-Gcph8#A!MeI5$n9q8XPKi!6UNQRKMw}baXH%u#U~ZN3*i&=&*sDpP#JlDLk5N@I{lv6< zl-SPNlMu87H`Xwe6scB1aJFkO4!%2U|zCR})Vg4D*% zJZb#Nf6lqZ?KrvV@tH`wQ(5I)6?jK$vuvV3sSR(A6;rzk&JU6$(n%Y(xIg+FeS@aN z?V2W%!)%!Y8?^b|g%A3vSOFa_3}mX4^*F$$U7%b`3^2w4KRu?HqCqNb9Ow(gKbi1S zYsB6IWrG>_g?-w-XX$*fDX0<|Qt~I$&vB;H50XpPPDUz5)wHP=)ma!$mn440WXoHm zn66=+-?Kx+ri;D&-eTQ{$2uT#k9h<0J$4JOVa@rC#dX-MaDb%EPmx@8dD$FEldAN6 zvw4KY{ITetOws^&?lbor2f@AnTK@m~sXxx7Z*_Ldbn*b=)B1&^;FfDr(Dv0u*ICIPHqVd%L9 z=yQVMv+T9&8@p!`A@)ZSQ_}j=$#K^JCuu{n!JdcW*Y<8>03tRhy_k(q7QI@@{#DZ9 zzh4nRtGx9mlSRtH`|-vTV)Ie|bW}m$(P>__2VI!Iom7oeFX7moY*UY_400Rm+B^o_ z&eV8b9Tq#coqfVkfBK*uf0OCr=SFbe@wcP*XT1Rzc1Opl3RCZ<#m}{Y8r)>#Zgz7^ z@$r+>0>#i4zi#>g@)z^HkRv@0{s?l)+yQ6Glw^h3F#Zt8;z9aG3Xb^v=;u3SAx01dub@8)42D(?RCNdV?t z(0}8yw5z3ml^GMSjw@Wz7>SZ z%&pr6z>{Q52e>^rJGtA`hK%?k#&0o#3S8oEnraIG5C1t83t!Kbe*5;hu6-i+AOE^{ z_BpT)jvX__OY^_^x>Vx$=sS!;iu50i_Z73$!e4T}GnNrw6Ib&hNNO_D{ppy@&{Jd4 zi|`KA5cNbG)LlPW78nIrrI}vQSC3JEv7NC$&x+|<;YQU#l;1~-c;YJ;(vzXxF=O}@ zZ>qdQEa@=Ed#G=I7n51%_$_O7chucKe9eL#&g$_Z_^3Rz)jIp?C0kbBwfikZRhOBB zFxPZ*#%r8|!W^>KP%)(kjj61^IMt`-Hy05?__BoOkms1r7CkkgJ^18Emzl1J(K8-F z{c_)FML64u67*&wRs^InWh;v=ncv=8vxNP6%AWFuH*aP~%|p7`&xUtix!Tr#h`e+t z6<<3m2SAD$S;mFgucA8_Y{vsg>qVZ8YfO%F47LD7Rgl}XV#*AtL2<&(UNzgG!Q&MM zcDPl>d@<+~$zB{$(D;o(IJldL#`nT$YcNeAm{Ar~49d#9d#5 zEHBv3-mz+skcifPVyE^xbO&<9h&WnU)%|*0HHyz5EAWIjR9#PaBWTi?K~)F{4CZUN zSUvmg~f6 z7f`Fjc?L;#j=+vss!5Sg+rM*yqWbA2jkjV zxfc;T$u;HqTlopq1jyNGs0w9}cMf#I}LFj3s=wt*z2wDYGa z|H+HajdSyS0i(>G+n) zP4Z8X14erbhMwrh9++t*@x?)9#DBVPxxHN`IR%=xuN($Ua-WjTW-Z~)?=+T+g+ay> zuAk}RK~H|UeCO|*|Nj@n{-+9JOw0kBiWTP*b=5-|xp3|lrBJc8{YjP7g0cGzDw{P< zgBs(CzN$D*dFDK0lH(_QIXSk*RL9;Ues$5;B^;DjHN~;XZ|Po4=JCaDjrp5G`mI{E z<-|Xx`vAN>+|qKVu`-U9hL>z-u`=JpnT)cg`cy#XIFR{)*(Ap`PT~*P$Sx5 zN78~N5>ZEYN8o|#5PA%vzH3Yz_Gt7)*I+T%o(6;B(LFSYNSTZ(h3o|m?|9QXw)^fm z`1-~>B=9=>=k15GQ+XFnn=I|xYLiWok4AGvDlwlj3TqFf>wcLSIBsYt%zP|a<2j*- z^VYhV;pmfb`RZ2fd5Gu>b@o$MGg`qA=*rwzS23bMGbl(&xog2y!gj|w?;u#kTR2kG zvy?y4(>Fmg#6E^Gp=JYOL5Xe$v4`k8zbP*#vRTB>SvBx-!ZhMRID6@InQesU&04;bQeduEoRTTXNvGg?@&h>OL*qVd0!Ttx( zKaj68=0E*onzxl7uI_1?X#Eba83+{1m4NJIdz@}GRi$wEoXT`%8p6jVuDy@1V8%Q* zpVtf&X|gSw#nZ4QQ$2~pBETQK64?+>RJ=0F%CFfCU?}8Zyom<_*-!h;S0?n;~aLG?#V-G$7_?$9bs+L#lJA zgqpr3hOuh(wu%HmAVcJ4B#0vpCTYF6q>H+#A0G2+CgSWiWHs?{d@I;Vt| zCAZgQS=SVL&6NT2wGtB_FBzU_%8i1so(12DHj$U#=G|uHQYDgjdbuUzvi5Ovven&2 z*6j{1=8=GOR^I!ellW2);#X<_M z)W~uz23p)|*hKkvk}{$wj>Okf&0ihaz|7ZPv&x*)FkH+H9WCrQMxLa{9(D~6u(;T7 ztRGq`6t|E9U*NCg0&b^OGwY+-oD-vNJcd09CzqTm-F(H|h->(}VU;VT;{===GOS!c z(!x(JwX?9K^+1fwm5UYwxnCIPe;}82C?0zRI`6D`zR4oK{^t8|>?4I3$;j z>{o^yi}W5|v-PTvu>tV^Rm>ajQ!V>cSlX?!MatccRX^yS@I1f^#4;ED;uLhRZS0GN zCe8vz`o-yO^d=-enqp)G@zu|g>&*W9mPOfG@voL_UD#06KV5r$?~lNO)3=D&6N`~^ z^;NB`z(0cGYU&Q6FVowt9*z%l%=`B8*PBVlT2Mz`7*!X~7z1NmsQ$M8N%~itmw%7P z^wn_9_<)~xGP<&1=)nQ-X#Y<39y!t#9hXoyk&~*+rPql>Mh=orL)4Pn1=II;OE`bd zL_)2ij{A>~ca0^T{Pn8p-5TXQ58lsfi%C>qb z3J6+7EsO2HU${5cNw>W=Kv;b4i#SE%f9qsfKh$5q<6Oz{K#`@KU{S#dV0D6~db>Gt z1#)YDCfU{KSIPz%f8T>0CGuJTn2B{?s(_mMhbc=gKzmB2kzooo#eMe5OChhX;{yf6 z#L-tE4?lcoVPXPK>8qu0F6>TFCg5I2N_?81jBsy=GfQPztRdZ^%e zxc-w7DTqywG&JFrD6_oP)*U|`V3;g9vStYxq0?;mTA*1rz;%BJbKl0u=*{K#*b z+@nn|l+RS#MGOCADnWj`F{^GQks{Pzxb8bO=Rf!R{A;I=MKl7g7hxYasZti zRC{JQgeY2aU_XqXyrwQ_1y`fr_X1ummGz!vd&nK}}O4x{d zAPBDtK_e2wmsh%6yMu7wN3qfgEe}B=RwYY&P{+3vA_)&*H??(CUd*zwkT|+oDS?GG zUhld-EGDA=nN>bZS#ArwU2=c?57@f=GvfbAdr@B9@_lgQb&>Mx(pb6im=1@4zF;!M z2fDu47=~?AV&EUX$e(1}$1Hhm#AEnPbj*sjIPP*cd^vKlw2j_=?v>A~U$IbvH*Ojn zak`$xAwT7wW!CM-Vq|aVdd?flCJ zy8~FzfE8Ic3o5?}c{i!^m%)=!p9R^)Tov)F?r@OtIg48zo57zo*W#R`%I*DytciD zkGhBz>hZR~28sz3vP@M3d&KWECu-4_70yY-nXOl{`-xqh{4Y*#eB5ag!=Ha&a0Us# zGckN9Cxi3QmcKQdF_Kieqn1T@jc{b2_azgU5tmZ|Z!yx|*0*I_$pD_wQ@;OaNQ0}W zrOl!`c&*m$mm{7&SWxko-SbO|ox+lk*QRuJAtlAU{ID$FuE{P>hS9bU!-Jsad@Dh@ zYwvBul7bT5J1$2=W!11Q@dxSP#lpl>zJuY>-L5J1$z(-=2C)I9vkY4B!S~4dpq+XA ziol+i#xNLqM=x1b@Ca@lTz9HdwN@V&1D!e^gMF_uppi0aYaiWS^R^gqZxAvR{2EMq zw!b_NXu}}?aJSftTsZS&Y|;5-Y z9Sbrkm>uu0nChS4+}g?GR_)4CY5Q^_%<`&Rs^!gnts@uboLHFfIncYzEMBRY$11KV zsJ+ElO-gspy4es}esamdYfLZZyLk#)u~S?9n)fSNUaO+4mv}%k!Xie%F7MVqYrsu! z>H+2;_$*0|^TV8hZA~@3RBlZB#;-eTl3S<27w@y7d)%*IO<7$}x4s@hde!8BkFV+T z&Kv~=X7r7R7mfPy;uT`0pUp#+?+(Le?A~i7+w&59)hVe%%r#?6bL$#FM<;-V(VQ|7S)GKgzJzCmbudP?G{B8 zh!|SzFvG3sYzxlu&8;|&q8PASTVUuk;=)aErur7GwG}U5;$jgG7T(C$3Y_%#0?>Ic zbS^H^xpbt_{7)w0)Z_R3A2#!l2U61tnhrA77!7ujx%NX&CATV4joIe!_& zHNYea;9b)fle!`>DnY_i;VxP2O{NCSo6_M&Vw5%w#!Di3?Sd5ba^=c;+NUAdIF>m3 zH8f30+v&5c@Ak@YvYPa!b5(;&RNWyit$GfQe^r9;G_%IB-fQBh0KfrcSH!QdTf*=( zCE37%MqKz4jXgfU8Ptnd4*G$}3b)k|vD96iqiilaEv|2K_po?A$Kx|XR6)2 zwHI~dKA&yDW~WR=xCG&qAtxU;4?ZH^w3+)Yl|N1x4-U`fY^Uw@JNR|)>ToqweJ?II zbbj03g%Wq&{Ss*ry?&ODGMmI?HN)S253Zb(sPm!T>D-uV?u(h540Ju>t-AX#9-* z^=OEyw6&{bK=5^&?&t<{FVi^(PtcCA(%x)5T~hmmyfvMU=%tb>r#nl- zSjU5%2+AljZk208d%Dk!v&5+}EWdBuqRUCD z`AH0KpmdTDLD|T9Q(gtqbe*a;erRa3QTGQRH0kRYWhHXcv$r@GQpf8zY(>hvB!HDb zg|skx52RE|_bnD?z()kXX8IJ48+;@Tg5v|n}!Y~Z$z^3(W)rFvH6yqnTB z8?au$CstD>^kzv8NnKXE(GRaVv(X!n?YDjH>BpwHuu;3Lcp-5=E4SOw@lHK1@%Tzv zIJa;Z+YyQMe&!gtwY^7ti2F zJ8wN1IkHDqKJ1CD+TcR2uKglW3Q+>DypRUY>3KN^a>ZT^0=eJjqbeX^2o`_nemcoH!>0Szs0+`0Sduo3CUXF5)&p-30%8GO|801lbn z;;>dNhHy_-yFlKHM`oKb&L%#CHTbG&(6zpAnY=adUpjBwC=aFH=8#gDGc45-s+y4?(#VZj zE7Rk0*rkG4EJm7574z)kdPb>bhcnKN2!mf#`tah5@qwhx)yA^OO+n)mX2YLEZ1soB zX!md*hu{1f#LxOZk5`EdPjXD_C{1YXmV#PPC8vmji)kH^i}9N_k)t`VbGbRJbEJ=? zZLH66_Pfb)S(CX1og~xcR|>OP+f_zOHx4z^ru>GT@2zy|3C5w9o-JvDQyy*2sC9CU2#w?i z`ufDJh@HK4mY@y5gF_&o{q1p8m5;Wv`_G0HkcwJ+fEq zclkS==z;F175>g=0wJ#Oya1AdnnkAcd3@-@6DswYbs13xdgs4g5b)uCAwZgx;G6uD zX|j~RH|!KFKH9gy+Z-Bs|6FyWHQ?-JBWhFT6ik?NZ-c-A*5vlwjaEH1#r2Oi9YO*s z5ucbO3XZY-BbR&lH@!RfQ4Ld_m3!<*wma%4l>k2_^PErEaFAm`wC-H6(l)OCwQgVD zL6@cfsTQn}zq+S^?v~qD5ciFV=}JQgQMzNdD()cZ{jPP#PdAT`PBRX@>608Ss`ZgK z%%m5e#x!!_PmQQQM*#x+_Wtd39DgfwYHqwqLVYXz|60g=Ep8t`$cn3u!;5a*-Rie1 z(QG62n&EqBPA*9{JwbH};C{mAZCNYmi~5?9koBW-IgP0}M+uTPh|OVFQYzR65zzgn z5MCEM$*$()!n5?SJ_YMAZcQ${=Y0oX72ML$C4x^BHFWk#5wb%pj&zKA*V|HDw^ntU zIr$_v4V|chh2p+8<_Y?17lX@ch}p^2CGOL)KFcWb!J2at1J!asezz{xAhk+_?u*XA z;@A$v3G4AgN$G`>I`=V|63-PME(8-UG)=ASF9Yn#? zxU#?R{e`_z5CC9Z0wl9QrLF*cRX&hN_VNMrH?oY3C)Bat>GJ^aK_=CX!7|YuEseG*BKaj1m()(*z(Nfan(B2drsgVqycB_|8T}x#NBgj60Yd z&#VB5Oy{dLDHiQ>lUIw^%q7c5*7CoX1-wv8=VMG}=plkiv}$-C8}(>FYl$q(C}4d* z!OcX9hl=o=beie^-V2n6B54fasd{Q-Z9_ zku>5__C?6f!L@!TR@c^}?Dfe|r@49n?J;}adZkt)XwVL8XZw>8uDsAyTkx0n8<!h;;F~xQUBvSgS_z10=Q?Fi6zN~kZl+@$*q#)u)#|*)YI$&3&HUGe>SXNvZ8LRoEwcfg`K7eeY z#Svd!1#8(%cM9RqLy~9jdCz*!!V}2iNed1TnHnWIfp$A=>MX%V!}M8OjS{Qv7l0Gn zWl<{a*RLSqH&Egv==0gDnhK_4=4^%bValp35wbhO2hNw!=K@;ip6J+cbQCLY6yn0q*7K@>+Waox4>sRZXwi`sHb z|NgbnEczG&s@rJ)7^-xf!+u_Fd&kD;#9@xV_j<$Y8^!G%2cc)D{pX-p5oi0VN4#D) zwiZ9q`>y!5-Kr8>1?MweOKhm)SaA#9ly)H!cnjS0wLUz5fa!@#2s?Uca0T_ryw zT&r7ks6Y)~PjZ5}bMS@>1y6mJzp`i#mIHv}r1&2w$NnR=_k})6o38wr&p*8K%G1e= zI)40IP}F>^Hm$`xNnm#$D)MZ42*YA5&)DeUl)AhaeD$-;W>)`NT6IY2i_!&cNBNld z)^THWl%|)qP;wc=@mc+uMdu($$=6QIFBxXl+OE`oeKFubm@qdQSr+68PsVPtjUrqF#K!1zC$tHKL4<9UV+*`6G8qMi+ITX`fbbW>^2_G*$;-(556oj@I3 zeXUZKldA-_%m!PxpT@!vX2(8|++2~UWy=gjCty zS|6PM;&l|9|H9sKgL6|n-ng*V>Vjzf>KKyiK*+Lt#9EpkV_2)78QCeUz2-|dAWIos zModGmRgau#`Ov+@d=3oIeCvpC8UnxhG)*~rBA)JaWb5CxUeewGr@CMMcQl`(G35tJ zTXkoMBCmT3Uj|5u1qL#&a8!jg!Kw{Q?O zUD|o$3+(+QFoX(mF*tXepJBOGCiloB(GRso}RF!GOMmiLcULNY`aLpR}L1QSg`kx=2sXag9r>AgU^hb|4vk6}|RImqiImMTWoXG;Mk1X_DhAUQqR^ z(<=5h4JCybxPiyLl;gH(dQTy)vdh+-rO3p5dr9}bT~6zCl2d%yZhzktNwOA>PJxvx z$g6;RHPlcXHxkDHoxaek2Cy)D<;0xumj2S#gw}PG!LUh@@!Lp>t$c zC~mHBhjbTNlB{E+NZl4APMch<)q;r3!x_A!Fv0?jMw)0EFK=2*9J|jDR~RMQ?jyzJ z&c1aDGxm95!-o_DQ`C-a25bVK7+3c*Q+^_}FHy(q*JX(`%%#e90H{-#R0^ zmSjce(WQS$7Kz67-ti)o(O9?_9he@bsu>-mnT=w|@RxbC|*-nWo>E*1gZHr&mj6 z_^bF_PuDga2UWR4eyg`;baqZ>^8Qf)`KsP=@Y~txv>M1sPcQIQP;B+d!EW&3Qu@8U z&+kJc|NZw%)L)IN-thee(XsaV;^B_|D%PATt$lyDA>vhlF2xo#!YL%Dv6VY2Z)9jg z(XGPh8vv?iVrF)&cGx0&yoLYd$?L^;P7R`FTe-2Cnkow7qg6iE$DWmCL0^`&x<#7 zaRv>(md0NEuT`Y)J=t|QbnzzAXKpLF`JK0g28(dS{=MAC9ZdEe(@ho)(igNBB7gEokTg(Z*$(xZ+(pk7~G zQ}jV#DLzrPa@pKkx%-Wik{m1z zooBw@O_}N?l+IOV3f*sTbXEBJA}Oewf-cIkj<>-YQt1!Z{P!X^PE^Xr1}l4?^p}vV z*H>#9%C1e{qZdu{ToyzjWK|1vB!w!xI((LWQ!F5K-(0zTM2!vl@o4wMkt+Z&T}He4 zFd9Ut9{6m=wc^;=!n@ECg5sfmw#-j3+wtG&diO?a(;O1-8`D4ixVOBuA)mmR=l62=NgLR`|0L1@zVy$(9?bG7Ro|PI) zX2X+*VT4yye5Ck!l#ZW5)p>ckXWg-yx0>L%Ue1oz+geC8P5e$zQ`(ShCD-(cLNoZ& z`d9@(Sk)9FaRXzUKg(YX)t-J4d->hM##}|Ie&_6c{BB>yoSBRYDJJ%F2wd$foE~EA zMu;usfk8UfJbx=Av}xFJ{i_p|X}#h3(9~^1mE@s_r&z2wcDb|f3x%O}M)d=hLa_64=WFy_@#f;f>oAkFpL z2+?XGEgr)?JMZ#!e98W7#IwhHZ0wPxTa{LwwNM)K3nfdU*0*Wd+Md#3$;AT91q+II zvXl0_M|6C?yE)b_D{|HHoonSPSrQ6{wH!*A;xA0z>NmWGg?%DlLjvl+8s1h>QCa%; z(>XEvk_Z+(o7Mn_F#mf94725arr~*ZZmbv5MEzhq7v- zIZ3gxW(CNuPQe7lT-5}bWk7He80;3s#V+|5hj;$-VXqy6jGv10^5FHjlM=LrJTwqO z_5;94ij7T4bVfxxhW-a&soQ>LirpFUE)RL#Uux-_JJQo_EiM@YE=>8|-+9naF`%i@OU9Fyoh$U5C z!P-4d7pDZz&X>Y}6S{vc*lz;(rQ2#lKGVIG?_OU<+jekNZxOK`bLaUkqeYiHPBI$( z%R+g4oApVL0nwS6lh${K3)n12VeTjt+XG<1RgSsd`EUBg#lIF`CZ_9Hb(8aX{z=#m(iXisBF<0mgJwU6=v39i!p{?z#8TI{Ntb|>rIzPU2d#6X#@Q8SWYEiTO@Cp6@A zcr#>kDWm54Rky15ely+5>z0$QZC&`B z)nnbhQ@X+yMs2BcB;eNKkjuxLqw=OzHZ5krF(7qpM;_nlHU%~@mjH!C8lG<-8BKIf z#EHA(&D=8eDxpe@%EPI+(hUz2sQiR#)D#4Sj1#uxNuEH0H4~asmg5;NM_4VwAO9NL zJ#MhNa2g)yLU(uCmwvk*w2ybooXdn#PtZeSZlfbUg+g6RO1dL^%z7N2NDb)iHGoX# z=$+*=t=%hduBUvrf5#6K#4X=#p7lOSwrKHunMh#2+q8BiK@5|95&6_Q#}T^?wSG0p zipFHU-qJTvJU6T}8J0GCKjsMH7D=1uc_$ux+?1HEVtO%X6{+#gpoL|JwD2{HcO0qo-F09AG8LB$Ueh04+(n~nMfXc2K{V9Ql(Z>cFxdC< zer{ce^-={&aPtPICc&|q`*YSGz2;D^QPch-KV%TNTTV9y()G!MYw^<0sc57Jz^L*8 zoA}iPZqfg}VO>w3elUPen17TJ>?b0wB#-ss$`$~J^EUBlSK@Nn2VNRQ&qwdLK6+g0X2rKX?;| z>EjB$?GMqJ?@AiftM{&!QoJiB&tf;K=t>_#K}cMsf!bxWbUYqSVjZtBZlBD(YrIp} z>dVCRX-n5&E{#@I#Noz+|5UZDY7SqevF{5dmOPvt3{b*mT~<%%5D*ih_*N@iT8NMS zLHOOSFzRmgdG_hx)P>EH51>m6aqnM3+~2>TO&ytv4XmCFv$}#1m8V8R@2|4IlKq(i zNC+fxHLTtxSZD4;`-j%3iB6P|*JZBaTT5Y5K2aT z4XfPB)GE=ca76MfvbX2F`Ffp@#Z=3|GyJ!nOXIg{r*tXVUk*Ku<`Y)sLz_z|2S(Qi z1&)O4qJ#hVdvfuDmMjCby<&d+52)4rGveRF{=aPgWoS4uK70CcQ+>TT-F0LoigLhb zHG8_>6004s!@bD*oVKsjmqI(aPs(zCLD~Zo$JLkdF$d`SehZS%3>{)8Xmff9PQ~u;T^VLSPW$j}WrY4!VuU zYbrU*RYWF6cALwaW3)@V3ra}NBVKZ(#tYEt#bg-U0<7F_$qS7pbR7eQ1D_B_?ZR}> zB!Bou#>v$^^@=YI5c>vna2ovVKrc4h*-WM|8~+vc|o_wl!w{cPCS67iT~W%S7P{dKjG8tv}eH zyxlguT22X~%d@HUy6$TnPz8ky0~^%K8m?qd{1lV+>Fl3-bz!vX1@^e|^%T}-K|E=( z*ek2r9&b8n$x;hw`;MmJq5^kY_Mv0*K+X`@=L*AG{Uw`JEq{ONgiUq8IT0hM583ae z#m2rC2?JhFUnMJw16ILAKs(qVJofb~{59gbCeOAZ_YCTnRO+jAPK%NRQTsqpg0>(J zkYW@Qlh#!6e##u-h_k!D^v-|!+V=Cd?rZ&B6o+^{?tz{JKv-B?{;w3ID0ltV%D+~c zC!W|6>KVB9M=FfRgZfgG*5P%!)b}a}FVc~?!lRwuIz5?yO)y0H?!~qa%oc6yf^LBQ zP-6L%gPymW)#ZZO@=>-Oo$UTK!}glEkE%hvbt*L2p(|C~03My?C4T=;rtNrr^Lc|# zgw_1khwifNb6i{p6{32C)cVDzC;K}O$=cQ2H_h{tO&Mkro4TYO`XS1-N`Uc{)+cg$ z&pJGu2n*5bs@dvs{Z+4E{`}YdU8UjjwVSg)NUyp`U|747@~O}Zo}-j~us^r5WL@Uf zi7gR&bH4Z2_cuDzQB__3i@-Hmym5HIUfnGGBDcnfQ>fU_jD|X);Uc+%QkGd-sOA#p zvo=0c6;-OH&+v6^jUT67oY*!&x zu68Xz+O?t0c8p`J*S_s5-b@%Rsp!?(0M}AMI8k+4DP4|q45IgH0^ND4s$Bo>4r{in zx`KsAm9S9T$VId;J`zi5pc%q=a<}+4Pu6kSOA2rkO!P#hvEpRl^vW&^i=*w}22d6kz+|q=eWQ6w4i2tj(rFf1Y`!9Hd&!$me=hIFI{T0Nlnlqe3j69U`k||H zUcp&g&{FO=XN&=me{t`Nf0fA}lnOs_qEP z%=FeW2J7^_LfNKrKetH*wyBW`qwY^X_`vV5NnaS{<15yFm~G|#8FMa-gmi*Dls4~0 z?7+O+04;kxinC(xx5yAwSYJmWArG^&2hi0l?lsi?m3-ZD%W0d|PovLki?GTc$s z86xA^Z_HfSRdAPf&Mxluwl(x|Z?ZItjheuEaY{31A5-&o1&-#tQ-O;jvE6D(Q=3r! z*!&E?;4hx~S{mgi&+s4hjO8?~&2#b%7-e3UXys4i=;O|8{oCy0>x1*y$#3WXK8oA1 zuM3U8e)SXyq^o}vCWOpX#_gE5G$vlxJ>~OI_W}SiI{_`B3FpsGdADr6K!1H)Gx;Iq z+wt4D$sv5NfPp*H2N8>Ma&*3dT=*gT))$N80D&vUiGHd5+G5VSSotb%Tc+B zJwS(CfNg((U%54Xae{8VrA|7$ngpZCX(!5Cwp!lbu9KL zDMa&z>v~n3h;$N8OOyq&(Eh!REp&h40!hRy&bD|}z1=H;=h<<=Wr0^;KTRH_4?vUv z2=swh^=}dX4oUvesP|tWhHl+N)Na58)`;h8;MJ zCR)=S*g@uZ_ec&FgLV!J%XYt0q>JVNel6?z0MgOl(L?UrdeaC0qpMg8EdFQ+D9EjC z;N51cP#T*Z`cb3D0(gn)oICCFb^hIcqi;X83Fqy2CG)l2Iu){La8<1M(vDBwQIiE}>f08|RZQR*g-&5NO#C6V8t6cTSCVS-G-3 zoH?!+F53;i2%Qt5l_e0T;&>jt9XlrswGh;43k+Wnutt`S-{x%CoBv7J8 zb3YwTtrmYvH+NszP;FlD$LArv)Qq1h#@Iu1L*%N(HH=#^ zkav9~uT}qQ?>`L!s=}s95$+{P_yCOPlOZLgp(sA;=R0_`JlBiz84s~kK@U`|goMzi zqQmiVR`1ZWDf3=D6x-u|u&Bqp>fNunC9ewZv`haW{`!ST7WT+Pa&d7yE>3{upy6Ze z&kd6M>Y!K$ytHEV#lCTWOY)_rPOE5K^$^LxHU=W_C_N(^+}k!;G1ANhI!w>C1VO6mwTiR->r0&g^xVSRt}2!!5n?Tmz_9IBi??3{}X!crEH* zZ;s*Hk7mys?Ade2OeI9%^*`+KX!;U=F#a1%y$FuW~W=^G(zNu;JaCflw^djCjDVPbcZ75^-81?X|a zn(+BuVPirDLzfyN zhc9gm6QJ~s;cD;BRV%S_*Ur^Yz>+N9LS@yD&}CmjG|Gm#pFFijvBlB`aW= zP-&oMzKvYz2xyjoT)jY7%$AI%2@qz2Vz%v!v~ahR4u;$pf+0pI&iZ)B>Zpb6a!T^M z591FEM+yg(Xpz1)I$RIWf2@A4j^O)1oH2q_+x1smX*Dr|i`oTUdAdCvgtT6SB0K$^ zONt&A=I?s>FR4NA)lfda{CvX5riNcLzEg8ktjeZzVJMa^E=Ei$CB&5Nuh+F>0y`ee zsNleU+#yo@Mng|-|Js;|6ZZ$1_1e{Hm#BmI&)p^%n!R^B+(6u4;BC9XH?td9oL*JN zqN}%~Bz#tJ1HT1Rv>Z?ouo0l1ZFkCX!aoF=pO))?G839VKbs}l+hU&_vjBE*jepSf zO4N-{IU3&$DG3G{+s(Z4vGRN));&TMC$%vz3(*f>7tbKYFb_tyuJ#{Isay-NC@heP z&dZK*_-Y+ZdM3S{)oK!YRPUHxt|e?QZQS}G+ua0Ki6P&{*oD5Ozv!{geovB!he;t$ zdPp#fJG|)^hh~stOoTfR3^aa}zqk_Kj(zcay>WwD^-Wh!KJ;VD^?rtv-vJj@pP!q9 zr&h%Te$Md#j&`}S;Fh&%GbJIQT~GaLGlA_8 zYgQdtUN;${A{1olerL8qX1N{{B_J%!U2Qm^VnfKvx|x2S@B5^<5s#AHIEZ)6W4KRx zgGi|kNvxYp(+5D$z5*ZzWn{T9!|fcw&j!b&uXzOPU6mm^BAj+?$vWXta+&&#*4Xol z55GXlC3nyuG0LoGb2Ggn$g*EM{aZ!JJ@Y?cRcDsL&Qjo`3feA-Vn^7SY&pU|>sh#J z7i80F8(_YJsoIj2SH`;tJEe~(^9iJZ9v3C4AjQi&xSr8SGRTb%(NA!%<-!EG)Ay;~ z%JdaNV(pyQaz@ympsPQmy}3oP%DYxe4&2hK&)Xg_$cV1=cHnLYUTA%CpUO@FO40z` z8n)UmKfTlis#;lK-tF^AI9<1?`{}bI*U|(^iZbzJ@5YvKOxr16^;rmk{)YxO5fhn`%L{ygW=(@_$lAfXc> zn$a|0x6w11vlYcG9*djV8f1DrC-3VV_O80Q2tvR0HKG6d`$W#EuWk}TXWha+`n&n1 zQ!Aoq{+Sl9zB0=9seYsFm(l1nuLfJHb2Ov&&&B7s8}dHLlEwwBBS4T!pBx=fX25-x zyWgu+pMKS{l=g^~E2h=-n~SGaaYCz>AnxgNpLk02+bSVXUl%?hP$;-ldzb5>AT1K) zeCyup>J_slNB3E|tKnJ#&?=Ci`u036uyai2;*61^vsE5Mn1t2w*?XkDdzO!uBk6og z`L*&fpbOyc=64nP3n2y|ifMtg8JP<+MjFmm(-7fSP>&6uw4w2>2r%`t3OccWV{LZy zGtkgg3_936-T2aM<)qa$UqK}m=cFs-#6I51z{bLbXsb9j-(vxm_7$$}F2 zuV?|SRhi?2L$BROm57CwZmu@17es+QIA9Hp5>QVN4L=p&GkN%vM9(>anNLYZ#l z<0VdAtl>vL(o;CD)nR0Fya1)@zcuretw37ys`Q@%94CP z{2jc0g_W-XF2Ffo8zHxXsqj4CIVo>dH;|@urc%Da4!>FsLYtxiTjD^~U>>bufue8* zEmh8Bns=NJaWKjFpwj6XLc$1js& z5G$YQkA^5nL57w(a39d6=A%^xZ(oW#y%DTxeB+enJjd8IEqvj6*Y~uYXe`Sa@ngJ9 z2WvHA7ZSPoiOlt2dy}d6e0=JGRs!7D9$_gnTNjJq^4uFKM~(u$aiO;w)=j$O4g@W5 zr~gb45@BHz4@c>3zu06w$lPF^#a83Ih$l%FhY82ab%&Lu@K|1T~ zK0^1XY|MAlGRf@{CVL2JS-hMhq1}6J1v^?oUKUP2SwPZf4uVm~qQS#s&Bx|6j-5@5L*4b^^Y(pJ>)vH73LPX; zbFc&Xn5|>2@Mcd+Wo*hr>I>4;r>GG<9`D5dbo{y0Q%pM^sW0fXr{ud9qM>i}RFy#@ z_dmy2t6`QcV(aRsG7~QQX;@u@JBJ%R9(kt6Q&DT!*}iedR3+NKKd$^tl}`MqKJPnP zDZ>egz0XKE8FX%BCX$xGKl;5Iwi-o&V;A9kO0s_Yr6FY`38pO^SeEsm?9t^`3yD^5 zz$k{F?9x}hM(FmZAn}%X%+z}DZ^H4Fv3DgpF7#*8( zw*L9uio6W#rlKCvK=nHG*7Ax`B`w}$Y@moXQbVoWkk&$O4X4PZqqh)VXNTrK>f^3@ zk%jV{V}x3Bt&Axz-3OzDQ93jkm(b}E{KyWJ&%3KF?+%EXD!9n?xfSxG@2Jv`F&!mJ zKwE8K^;!bUTb4OY#9cjvsTIK08Fgh5#>j4t)TO0#$qPhxKWVIWXarHp2&&@G(U?S| zN;1J$_g0l|QMJvYtm_30&V&Aa>s|wK@F`8 z{D>T3upSJl;^ukHxN2N-zj9;$Bns$E!|i>Tr8OR2$Kjm=*duf`6ye=nJIL>$F_kGZ=6CDx`cW)$0WaR<2rf<-`RI{Hd^73|(WHVML4}3Oj&r4!G|K3bX{|ch zo0k|JDGb1*#&EX|`2k}gxcHGwF7H-cDPy zRwiHQcLCFcL_p&h@NYQuYv4FofN!YM1;#0C)7cjnW5vC}eW?5YV+p4j{z{FH)fMr_ zOn>9Ajv2AjhK6Nbkf{%!;)Dwvz~iO%<;X8TqT=qO%=&_0A+~nULc8GZW~I9_D{mu3 zwiT8sYF;a|@3v{?8aJLD^mWpYeoK_SV$FMM)R8u@uK0nY!A7`8@o#A*t6aa zBLezGBO(0wjbdjvFdYBcyhuVU~=^A z**uNEZk4R;D0$Bj(r?NYW8NB_e1SD}RZA>!$>;RQ%b}_|Pc=1seaf9MEP2XP?`uVk zN6w#~592Dx;tIjk=7EyWnlJHl#p2hP@mm{d(#U7H zV$?l2Z|BG*%lsrBb4)NP_M&+I2&3%PTC5&)#(7lfT}_OR>#`;#TaKJajES;Qwu ze&gK=>-uQ>xPW|f!e;g6cxO(W{k^@>R0aQRM2@uUA!I|W`S2@7mqXS#`u{`^Ir*-J zsTL}RNZ$2eBt)m3FoAC~FYW5=CDH|AcF*~(9MB2f%riih8QjiyF)_pmY2pPIEO)%-_!3y3Gr zxE-e1X9f~9d+;)4mp5h8L}po40K95!T@>^7X5n-~GD*#~t1jNpc z{U^&T=-u%cbi9K3`+N0IhckX+Bh=9y#I>glJiM1DKC(bnj!Rd!&7UWXBq)Xl?%?M6 z$38i{6`}>in>Es0)OiFO4pa8lksh38gNJ170%tSbfMW$7T>!D+PMt?$x?L|~nvh4w$Au`!D8e$O={iC-7+`l}DCwy2*mUkOMrXT+ve5J<@ zG)Koh#L>0aqPZnXgd{%K1o^i|HP{h0NZaS|g()c12%w}ZJ1XYdQvp(3`eRK#x7Ol& zn{%2=Yt-aL8NePMY>2-5`pxZjILmKc!Q_NZT18uEP!p_Dy1Zh;b-UR2m~g@=v@1u0 zFuT4#BXp@jK(BpX?#hkfIG>o>t$WC4=@B;Xs>77;d}*$5Q;tK~_uVX4j8Amw@Fc*3 zZ3EnSG!CN=QZ2L1*6oS^qN~Q^6KOj{C!w0QQn3()Vr?Aa_ zs+XdwSafELF%_rwc?j)32$f9ps&M?aXj#)!3TsR3qN5z=WiOA1ZU<&OGK`uk^cLWG z7R)a>O=OK#wA5rntv9Elp20@#-{ze9vT#{db%+0a)I@d*Uwj;yruHudj1lK2_u7=0 zzhWl#q+~3=ECgGs2JGC*^5-&TkBi9-7ONr1NH#t`< zW9*<`XRbK*Ab$Eaqw6%oP|fqS<4oIU#$g`h^sGi zw@feZ3O2WO(Ae8)0fQHsMpGYlG(<#MC+G~WuN|3F`2j&++jmR1_OWY5%EUD@`{+Pj zl~Sk77JMh17ch?p#0)6*fL}0?tJI@VM`!muxloY;6vcpQkM#_ogfk$tWd;;;%uF&L zL<5$!Kor1#iH?qr0b032jg238NPxiBf7iu-RkDFnwvNuaj*gbt+OfbF7B%A|1+|1A zC|~r>5TzO?>%0Sa%kfU%R9jBz#63K|*W;3Ne@xOkZef0*BYAN9fcpw>bm`6|CqI>C zU>C6gy+e^+VZ&>3GI%V6iJ2Yq0lsRUl;m#VYCiDB!okDd@^;*pgCVpw)w*Rn2$vSG z<8K=u_~pQaAv$9?R)Y;McS+e8lCuA-25fFNHAa-`nKya-YwN3dBQW96%oKiWUfu7mT5xPNnyfgT- z23_jjUFOpo$&cM|-iELx@?Y65=6Vil@j>5w7nrJB(I7(zs&yrPqe(tBw?uF+F&hdB zPxubV;QzIdH;ng6&>h;5lJWiKjK@@)bh!j#a_l<6De*QZbH0w{@T4|6&v#Ng%#T%% z+ywy*2oQgh&NY)Gwi?ucY}(ZdlUAo?qX*k+vb7*CuYTnBL9DvBw!dhEImk9jlw~TQb z=p3A+E@2b7$3cl4Bd-c~r<;cYqWQ?bm3dss#OO;}c*6OGG>IH50ozwhC9=4iI4W}{ zw_pdP1}|BJXLSGZJ|avVLH}c9&8Vo*Nu%TN4F83dyf?eHRriJt*}=yufQb8o52P1p zET1|xymWq9Ag!Z!{IL&dZ8!8>R)0uLOpuT3s8c8v2faSOG#6$PkqsX6@&C!|`sY3;zQWq)Q{FY;SUcvq;uDScY}UZq zs6y4ACiu}$fz+3Eu>$k5OEwu-nU~RewJ}bcxw!;YnMY3<$6IO^6T!=3H~dpxyl6Ak zlN=sZZ&0!$mW;~@AucLHU7_ZAWBnc~{l?kOW)@;P^Dja`wfy73Q7N3^j5sxR7*56I zYkymX3QnlHB;Z);2~ToYt_sCGFQrzwGfQ<({4b_Vt>M_-n-oq21M~mKtKo91JF~Q6 zEKf1yi{kykMU2weO)(vZlh@n`KGd|yEO(ztHIoUu>i2TSgzd#u^@?||5-N9E<1FL_ zz6r5!+FNJ1IswuQ_~MfG)g$}!Q_{gq8HiDk+zGPJfS;O4UJD?ceM8%R$H zhG1|a&X&~hp#;^VkrgF-;Q%$+qu$fsK88(~7WbUq3ySx*3H*%8OL#&o_=Si<82eiCsLI>ln_NFiE;a@!Cl*jh2cG!1GtU(i4)XqS$J1 zwYlN~`(a{9Zqv@Y7IR|hgrj~~>;9|V09u4t=-SpU!aCk1ry$+rvO%NBV9Z7E)!d4K4gs^~mUtvW^2- z%D8Tj>!u=iZusmwf>%*-zxw^!8R>m^+Ds$fz=^I_yFzex)?2XVbnYuNd}%hf{|`W6 z;1{6oA3&YL?LMc+Usza9U*7t$#b7uX#|O2nub@up*-jBXnj{Mq{JOsot3cXRkpP?= zQbkM~l{i}Ej1u+K`NN`CKa?s8@vx|ngy#&gyBq*ptiy66qc1u^Y#%4DqY%j7h1lWqeFye5_(%GKVJg3xB~gn@3J#x{ zT|;PSz%MZ1N%iLwFYu@w;_l8BoA0*IZX@U3HGxJd!-}BQHM@GP{ehY4&~g~`y7jK| zXz#=^47MWJzN_>%O6`=*V66n0xIs8_fr-dI*x7Mi8~B&+D$BZSWCO1$)~r8xGj6*+ z9E*A?j#ss4`JRxsM9XPUqRRpL*h!;{eyGAaXoy={SBf6NhqJlfzi|E1o8tBC7ZKRM zdMnXV>gQRxtvK@)7Uai`T{8GrqnI|7lgnO#cM zD(IJEt$Yj9ax)a%@L@Ppw3+bMT+C@xArLeKg4=f|?3@qfF81UZvk?^qWxGjbng#L_ zIoq45ZX|p4mR;jfbGOiuDsI~JBR0<1?+WK2f9_g9Z3H6IoA0Tlbhuc?S$^tRz5*Jf zfP^Sgmj)bsu2#1uGCH%8dsnn_MCc@!A=lJ|z*tQ&>mR|J)=3`Fjo^L5a09U*_wGE$ zlw>)Tj-BB$W1nvVU_pmBjWckCgm}j#k5s?VBQdc}bBaQJ5c=u*RcbkKU1$J*}Q<=XCgBxlnx*saDgaKC816)1q*c@6GYVFBU z{o6D%`{oD%wg+b}KJlq#YP>(|Vc;6H9f~7UB!5T{SNxISpqR(KiW(>ETRdQfb#MFe z^$*3*Yn^+QcS_wzaVYSF9x3|kdpeQiJ{;cN zs^*RTG=EDyfEySK`j3G|9! zqH?&ky8LmXmZDlu{aA*8lp(NLxlc|HTUyGO9<=^|fA{yv{C1I4qt$OU#yaes!K8kF z#IkL`{C$^i2ah>K^u?)evoHnxgH|uGPsi7|R~N~&E@o;4mpSm_E719QzNX(k1B3jr zdyJ{x&h!|M>asoHyqz)*=q{uxv95cy<~S`}P@Z?%z%_>DdFM<8MqS%NF|DUGdx9L+ zRa+cS`^ixXvRPREpGo#VCfO`3S{L+qd)h9R<17oD@>0aF4z!5)c;ErWIU)QK9BgcC zn)`_fzalKa8|#_aIv~3QHwHFm(F3x}en2C!B1}T&#)3Zx_xxaN=~XG7V=@|*I-zu#9wLm9*V!kFEN9;BGNn$@6r5;`3@Q~Gs5B>-2QvH zji&yOM^4_;O7xn%rzwXev-eVL%^`_#EA=xwc zo=?O*h=4ATtg58=q}Puf4g4fft0T!q_{hSPT0j;#2g;)vc!UF9>?MbZLoyIrp;ShO z49DX84Jtq}NbkQu%}Nf7EJ0xg!|_g6Y=qt0Nz(X>$3D3+J?G5m*{yIJhLtC6s3y|3 zFr^Y3N+3Rxa!*qpgq74XA{UipkvOGH)YPJ{5uj^)nmd<+r_-T?FrwMChO*_M`P}sN zBGmuTxfk|zfmZPnD@NS-YOe0+o8o=7OSdF2eVK5zbTiOT4Cz#tq;aA~OMZEFiu8;g zcD^y&t;N)i%H zw{3rrC!prRh>sfM6OO`jnzqV`&XMFS(^^pjC0ldux|?&WjZ7miW&Vwn^}?>>QWviT zfyhzW9<_%2$5pu=R;RRy+|34@8eFh#`>a2UXN>xzR=afrQK6~CmL*CWee@TOCsC3`WVSPtbZH75WkTtoK=|7olo|KZFDGQWqw!rJ;QkM4=x;a z8%--|0(=!`GkXPX!K=wZXa|vcluW%pR3p+1b*08d&1FlcF&q2UVG0%?<^A zQ|jPr=gSfP;$vpwV0RGhK^0&9&T6}Ygqe{dnj~OTQ3L58Sqb?(q|o0zA*{21rpgri zU12wPS(%`zYL)6aX?I-UF;5Lmk!RKHh#O1?AV1qgNt(a&6dJ?u_?xcl_Dxdi7}7?2+U2Gdbou`GCLbuC*( zF#U?a#qaGcdXgs14Cll)o*w?T%Rsv2Z@OKu5y!Op6CkqyF)B#eXPu zg-VYGr`RsHVhX4u-95dsx6sR}o!L_5PBOY~9h}JSmpTF7{+UgF*=c_Z25 z@6y#&)Vt5FFQwVY4jPC%$yivmeD5%1hEXX>y6Dox=#shYk`@UR*nH{;w*-7oW4DZ4 z>Of-T_tWQOnOBD743nKv6UC;j?Xw5PBGr)A*>bQQ4-(*UN2C1g2Y29VsopeU+$b`6 zyxliEh!5j9;s#sxc?vaZi_)+x~A&>RW_2C+xn~Ou-U(b;GNoph4^P&eQ&7w5&=Oa3G;z{v{ zG-OGW*F0mBs1FbxCXJ?7S*MwLT*jhXi+EXP4N}-hy*Rc_4%t<@ zFbHaS+Sc32xDN04c}-5B@x(JEA1S0hojRL7QoomHVOde&Y0}r?I%O88zVTW9db8``^M*SkQXZQi^KbO6l z+*L-Ht20K;SiwW@4rKal&TK~-`RZZRI{7BV`1M0G$G=$A6p8HQl8OqTb#b&~6GP@s z+^S^$zB9dp$AuzB44&>I4ijqhc4Ji5Tb!hF=C?OgCw{VwNv^g?y+h_9C{4;D|9iV7 zJ_4~WG^a*pU8Py;!t+Q+t#b$kUemp*9ujD(qsTX5)2oI2Y(Ho4>dAq<1}1*GyVA>G z>6Ch>tG>$DuG;`JG*5ae{IUA{-ZXtRehA=nry;VA%@3VdwXVv&kyO!Qw$rt$v#J42 zfCBa#2$jsJo$czdoYu?Fn_EuZy1~L1k3iKOfis17rYR0tVdz@DJfBwX7}Ai6+UBdW zOVL{`O#V3ma>i^6;EU$-P$qFe0H}bGc{jDklHU2OU%Sn6J9Y8uhL+%ZP~i-Z%e2&{ z|1>7Os z0h-@s2^1dBp~}u)W`aTP5~(=59YWAEDSt(C&d$JHjxk%tSmnQn4-H2MBjdn^MX&u~ zpS*aK@g$03Im+D~|X#SkR;+A=?LpEGo(V z(^zlcZ8vh?Vi&{=WC)OwiUoeS5!ipQW{@RDW|_fQnT=)2PnHNn@ZqQBafV-APDi&& z_!WY~MpX4rmK4^l?p5|J?25(4V!KwD@czSH!TsBtK@1z9(@Tb(F_Q6<2X-AXC$fNdSyAZwAe1v{BUF$x-_aVwBhcS6r=Z~07Mj~#& zgP67m8Fj{bC~HM?&0{?X{Z4^LpA9aF+#c+JrvIXR{MJI52K(_y(@hxBLRWXf=7XT;4{?vTB7`z(b2X^&*Bx;9}3|x~;geAiEK}+0#K94elc@Ei;vt zbNUraS3I}gJC*CX1xA>Ln~zqTJFmQn-f~0oVho8eL66_zZHVia|DNPnin&F1Do; z9VcMQ2&K@Do$VI~EF08(!0YqU!@u`l90rT8fb{=F*ynk2me(KO+0DrBWPHaV*5fTl z_j|FA*%Nn_!`Gi2-&%~wfW177Cp7HFoLuj)QEh*q%%v{z1UQSJ%X~>Er`S4wUE6QQ zKGggbojnSu`h4&6A$E}wdpG$m`0|bX`!n9c*3s0o7GJ}-qaUH+7Ba~02QzB<;p1GK z`zjO{1;U{{kC>D`dXd+uDEl>^kh?C zd(V~hnSZNBbBou0J`oe_5tk#GmN7tJb$Su6OB8oZlV-DW&eGUImf8^#VR)GGu&Lu- ziC7Bfkz&LUdVQc_oM9HV zABe96kPX8rCt-vrjS%aWTon%9J~M`@;XKZlI`m>p;qY}ZCeNx>2l66Kyf*!Dh>B=_lg2`oJu`^l0u(xcXZ2se#1M`dFG$^gyPS6v$Npb%tX(=|36mK|cI!rCH<)b) zeTNDNRH@iOqY&sx7kwvo$H$doU?1Atl-lOz*K77Ep$VKJmRhHD_YhwmoL=c`IlN(A zw(SD@e$W0=iJX$`+qnBnoi09ZIOf9{!}~66u71im!f8lfVFFfkDK5?1Cg}_yh&nxg zmpS8Cby+C>@<y^JA z^7EOSnEE_|?sFhzR$=q~U>~KXb?}HrRvlRn5il@%C*!RJu@P%Is;m+KmT|2IuMxo& zIlNH0q2n9};ZpXasjfr6q0`pNhcr{a+rI|S^xL8$7z^Ns^!E+%BD3wTj$wUH+w*g; zWMpogbulk5hXiNq4oSMVC)QB}s(#>7u?yyQ7fSNX9~hbEWk&Vz(rw>h{JO8tAL}mK zjP*vnc>8wvv8y+kZ-Qtu(`+O;xQhypOc$MtCgTd1Mc`pA1g-LchJ%iY(~qWns1Dn= zr&D?(J%@u4nGi4}7v>kq<>Xtnbi0`0s1J|w<8_c@>sXZ z()`hgzx1xnSfpu5?bYCiZ(K`+usTRWZ50dBC+G*4{W)IEdLgg%a+>mLuWPs-ZcRV z)8xQ0e8X(%U?Lib%$4O&a43F{12F(@WWWN+Cy+zkX#AVdPZkVNhc8(@e0#Pjew(qC z(ry~D?Y$oO_EXMq$d$zDY)GT64-UCjH{yh&xf=*Npr*k-l~E+ zqPxLp3USC-xQT6q$7v3%hf1-C>7-K%cdkr~eVo6=SL2KO-YX6-uW)|0e>i}Qi*qsa z7!564P1wjaFm(;Bu z9kpuNOjvRF1IUr9`#%^IObN=5ca|ktR9$v0m#b1j(Go36+uX8sw0F@?w!1Pfg-UV- zl!X_c&b!5wAq!Up;?Ww8p2gS2&XH$5nEa*MI(2V)H!l7#GnX-!%!U@~m}#3WzJ_X< zXtr)k#+0jZYd(<;xl}F^XoX*%(ZAg9pV6~;3L}4c%N%Q7l9$|2B4(zT16ft83gfwP zczwK`OOaOZx!D%qz6j5X(A(kv!+`5&V& zGs14!51|%TqaGu1>RpcciCQ+Bvde%FE2^h$%e3jNLs@v4Ou;GkVI0|SUd+)^B>olT^!> z@iY(7XUnY!G^ir{yu}_3l=r9TIAunuA6#NYX5UEP$+Mlg2l#$-U8OJfUYZTFI&f;1 z9XDB5FT_1!98qM4o3FkI~R~eor5)ylgrp3{lB+O`;aJ<=& z%f|7{hh>Lz4^G0$yR_47$GT%vZuPXr`BjN?afMzj?)!19djz3)oU+0;hGn?P`Kb+8 zWjiGj{36c13vi&hRA3fW&A%<$Lyw1*7*98_HM>_Swc%ygFI?|t=i3=T*6)V=sL~6d zKlt#usR!Yo6e`Vb*(rJKd{n^`t|jx`?}PBO?Sv-XA;%wk%7R`Uv-hQ!c?vw?PIIL7 zjj8opyy=t~qrhU{EUiWH7dtsto)5iJq(Cn>lxU=Ympi?+)RrpEn1c*PljCz0#f;AE zMH6}$5Xx7N-3R0t4m!NerVkxXu4Sq47T;Y#eHmjYS1c&jiLqyd(&{^Frj#XAa>5IZ6g2V~18y&eK;Qj`|$ zv6$YDe$~?Q6?-%vW$CoKYQLt&_m6%h<~i)8mYs~J9=V7St6@K@OuHx!*j~Zqk={do z5(v$cgHradJ7aVnZxp|EnYRRqe!5+E1zjm=pR+@SqO&1fj@tGlab2aYpDe7{C@$XK z1;KHd=M;iN>Ot`gv4ZoRN7YO@|0W>{EyMuO(YxLo(}PZv#!?J2m;_hktXUOC5ihqnAAqb`NUn-J_wN!v*b0 zXvAMqsKeTalIO;v1g41>3SGUF)$LkdV z{x~Y}COJ1sG+i_T+AX(^e8OSm|YG))1@6 zt~?Tzn#2YRx=5{x>+}I?DVjyN)8H!K+Bfb!aTMo%%ehgxz+#|)Yys*iBL~$PEcquL zWVZKpw4Ed>M=jdu&M4RQ^X8aY{ZZ0kNzp{lHP7HYW>l3QW_g(TNwhOyN>|j*sy$uy ze_f|Bbq@YInDbF_;r!)On5cffwfdFr?DDf5E=m-#qOc7_t77I-#U=QK0LbOmi8t@4 z(U+ZSs!u-G;s*GSx4fmN9(j-ms3{Itq;WV0vWxLK{>G}O+NDR!p3Kk{{ihxs^83Qp zR2@#Ih+9)GANEq;R`UPrhi^J7@5JQS3E3i?eot7{J*mXRMBJF0a#(A!v)@3U7Gup9r8t&xj)Z zu-MYYW8#%?*X5L!u6%fy;{4^;%1a_8<83qVSmBawL0e7@7 zNU-1jbvi680-y2S{=f@z6`qKJ7~9UObo5{#rX@i2dhTuZ{E;@$SE~d5^|M3?X-X)J z>oFVc7`vNy%Cvf}Y;wnr z_7|Yk+YI|1VH4y|o*xHNRq-w&$v?#Q8|r`^sDZ$TV*}-w-XKO^gVsFfrYVt!GGD&Y zH;LGaVHS>cwclNSQygRU&&m8BWIl`#3nK`tX3SnDqq|(;wAYpq)Bg$L5-n;+^OJCR ziWW#xZ&+#mSj;ks{Q((SD!j}c2)Klyq1RG{C5Nph{vUJjALgKV9_|=#e)QMi=H7Ma z)50NFZe?U+RR;m9$ca|wgTcJAdc=4%gaeA%(`;t!vtWTHl}xfjSRF@t$5h6M5<$&3 z&0ue*YsILoxk41=N7V;MI(}vGq!#6u&yxz+cEv?dq;bb~ zq^JJx_tB56TKi#o1BKxEzL%a@v};RTyK^~ZB2=XK?oqqG#Om%KuE6HP?IaMVnV+J4 z;`V$$EKpPI(31DXK9~D=%HjBdf~Lda?E}PI1LoKahX`T)wAtoK{>xTi^ynuG@!ogl z4AkKZKl{cj#Ogyt(~DMtPv%)nA0YW!apxz?zck19Z!RK`k~w3yne-}$FVgHQ$3VvZ z<9|ls9bAsS*{;;#voEa32%@MzN;k%k7M|rLz3m+ts8@iA2h#k#iu>HOXnk{mgL2k) zZPd&>V@+(j%KK>rjrI3+guLySuV^c}hU2-Ho+lw2F4vj>{p2Us<<6ogT>~!1`~4fDkDOJt$QO zMM5V5W-N4q0qJc7q$DIE!O%%WfzXi>Aav;+LP>xCp5^Sj_p^Wd{Bh57&b`mM_da+1 zv68^|vsRL|vexJODX;e-1!mvE=y6=1A+J>48f)?tIpyuc<6TD1m`x=|7e4SwBcy=O zp6fXTwIE;91ZL|hqcE$LEAFGbm=Nir%klf6mrI}GjLil$6jyCiXx4=5=eF8@>TTdw z4_Km~{dJ6w$2ajBvyvmb@y;VlskYXe=8Xua{|3ZHM=mTfkF1W;kHzJwE~L4&FqPx`S{27KrD1PcQzutn z&So__JfrK-WK&mH{bo~unD8?)@G8m7n?b%g_}lA)CI`B8*QB?m)cz3tm#W>Q{U5gy zN5!ukKM&acBg{lwzwR5?#(WgBQgzdHcouJaA%Ee*|Ss!z5J=6+=bkg#S1%=Ni$p=YrE2szCzN&dlY2NF9V^B_j7?= z4kzn9fR>?(FL6@1wQ4~Mx!mcd<#p?`vhdgOt|r+iNAj9iCIs3H$W*BB>gviQ5TD3v zKu@S)7fNYw8drnkQD@}!z;Jy(zSLr+GrLfng`cgK_1FX;WHE#ZPVE~eADS=-3rff% z)vYU9iQGwzuFnkd57!;H2Ik*|W9b+a%nhrNMd#&J-v#lMe&ebWo~VQlAd=?8$1CYb zM)<9XZT?Q{Q!T`sem@S8j!Q|+0tGRhxDOsxrOr=vVPMe@6D7G{hi1k^&d+lq<|&W9 z;$GX!>L;@5Q_pLl9BI4e7g1gf5h?<_XzY{jDH%u&zmyH7+kf+37P6xy1S&YEwGr

uU4!YtT$%Or{7NeGAjb}qh7rHnI7$L!N6=3aIh}_tsbBHnNmc3{eA0Rb z5!yuR$SgZ}htgN>7LaVgTH)g4qKGGpMDyj6oHy@7@*_qEk|$#xOt_hfiO9e<6N8%%QM$M$mDW$7~r zrz7O-KP_e&x96Q1bRP>GswKD0zuYUut4pOVGZs;B-KZ9pJI} zfnL7&3%&-JX-R^In9Ts-y*+r~A?p9&0qCa(_r{{5fxiHVYHn_xd{OZiqN2f~qL-O~ zjZz5!x{i*{;pWx^{@2L;+p^1s&Ro=Z1gCwQu%Pb_87OoA6!D26H$|NYsNRO5%0PE!O6JR0~_RD z2w#p3LtAIm1( z&5p=4|J?%=P8FS4q+Zl~$qfIBx1U6&sj->hny^R`EGk&!27XG1SD<*45-rfD`zG@gn?Gi{kU@Y^l*f^ zFrxgPbwb!x@p|^u!tX$>hH09w&Mjg8L*K0+nkQq(x04D;;z@-o1mz`My_YJOcQ^+J z(nq5}zoA*wTb=Cg2b|DRO3&)HLTQ?N znVoKpq!zEuatHvj@YXAxDs?0Dine}9er&AuZ^!KW`bUV?B@Q~mlTt9=uo?sx;Tous z(>^{kf!w;TRMZHsz)s!Xn#`HlqUfBVgz2?)vi)J!y5RPN~hr4W?7eKWFE@m;q4ia@SoPwE0g{<&% zkt18OGvhBiXPrT`btz%xxYdUg?!f{g^5@Yp$}c0wT0EF6^stJgMoeANjP!Sl*CSgd zGhI=Dg_%>;jW_{a)^!JjkcWWCN3VvCsRY7IuX-t?9Qv^Gn%6%cQr)k+atk$g^&{|` z@AA37v0Szapm0^-hbunKvtcsYe>7Ad;XLz1Sbf9cd4RqEzr@KI^W$Bu5QU6*k)7Vi#PvrH8J@(?UZISD;4n7 z6O4gR5Y4AnGBY0*cuIGH{jEJ#^$_}Yk(a^tb_=qD{@wOQYMt8R4hOX8!dH3r}aaP@YY&SzMDgeol(XGovd)OpWk+3oz>G()SU?27a1(lYe?too;=B>+(HUP zf$6Me+2*y(iBoPZ8eQ`=PgP{NM_m2dmQI)X4QN-uc12^@Mr^R~VF--5b2JXkBZ}D^ zQ%B*&dB^5Id)i|+AuWU?dw#rVN9_&~KSq7^@L*?Uw!pAM-r@eCl_oI?rp4X3O26izCg}&6HH3?uasUr>fJu66rE2i)3ro$=sl*^hR2#* ziprX|fflyF_4EB7DhL1CV8)c8;`tMz?w;NYu~*EU-_ebL;_V-gY6+>L05U>ga{J-S zvNmzDzvnRV;CK>pirR9MB+0wOk$b_rxmD|?)wLK4v@lWn{Wj&+rc$UCa}b^5fTyvf zh8OE^u*dHL``r|)_0uaLu+JwHwx(Ra0+;Yr@U5+}z z;*m_oPRV-DWz1XO+Vd?jbBn9WOF#XxPC)P#Gb2$aCw6iAau%S_Sztz+A7U<(7*z9= zhvS(-{l;a^Q`&?J%o{tY3(^tp5moDk*<0#hY;^&!)GHhz2hAk6QiPp-%mpWQwl^*a z<5=}7-hmApFcJOdGpjE&+WIGJKCs$mU!!!FC!Y;U0*WhqMatBrt_fE)o1{&0%?P_8 zKI~ivYU|dlV=>jw-K)RjjBp&C(pv^TLIExy2y-Xms7P*uaCE9E$m?*HrWV&ycM)rc z1A7KQ=>P_Ht06U;R)q<31Bub>t0sr`uq3^5*rg!VsT&(?2TB?3_7-S z3J}IKZ{~eGofIfPUJut+d2X_bPgr+KRrT#_!2oj)k`?Jfte#}?%S;R%bP0TSS1%E$ zuWJTeI{>7mqti>vrv98Ie*A*ke`@A{C&+g1+&Dm;{&1Y5Bl5+k&gY}v_BXEA1v{Gu z1^R%Eu^rq~5BQ+pwZ5^qPZW=~iVF=3e^iHXj8a6|*%_m&pA z>Sfgf|3nx+>26|7G~U8PYA%%{mJp4dUXWDcX$JpxL>s0BZar)rZPXyC>ys0#uhDe* zrRe;z`!1~tw}xXWvhV^^O|a?N&y7f-PN6QJ0afas!IM672^*q4KEfwVK8?1u8OFIr z*4H5CW}Sl@{7%}yxGQ1%zpj}51)X%43Vqk}fzql`GOO7XETj>ws8(Nah_dC)^x?a#{Yq?y@O`6g*9nPrIIz=Yesec7jnw< zB>tgh(*4po#t>baohvBY~PRLcHF$krQvp?9#xxS^K(J0dA_Lp9r=d`1Ss*Hx zdxzn7&I_UO6O%Iu7@fO=0OOMeU|-0oXHLANZ&u@ctUo&jj0c@e#KwMl^4w}UI9^tO zPw%p2K|-@5b91O5k=9QTa00^T&c4Kw!F*AF@eQ7s<|n>yZ&z!&>YR&gwjd-nPy60k zaU!OV@QQgVxj%kqqUzCKYI9jk45h3T?;$EGdQDz+HBg89UalOEUas8LMBF*ea19~I zX^UF}_?BiUPCX!lHW}P25 zo_%C}Bh%d(LQ*Yo0(bkRaC57d?)zl|!E7L+tpvofO@T_A9N;k&n9rGDuS>DvH=s_z?x%2aEvG7x6Az`muQL)AuxgP$Rm{rFv zje>`H_>Hjm4d-l_#$mqN(WVwTP`TlNwj6- zr#x6E2N7TU9UhJY>F>h16+o-jYnB9Ab5>A01Ceu}hBj5MkJouy_5kcsbBe+0NQEW} znWWW?n3w~D!D2V9U2?5&W*XegbQh5(wX+SRN#Cn=48-Vm`@-KxfWhW{r!c`Ezq=48 z#Gh}R*`hwTiz4{yoPH*ta}zGx?=JaO$ryB18Fa&~qu0}pxY{e_B!6Wu#ib^6;7$Mg z6ZXo=C5 zYkn|@P2nF{#_s%p1U*co@Ao3~DsVyE<4vL8xFp!(;FPVA)VLq@Q)i&utp*~QQjd_a z2MD&P6L85p`wD&ZsCgJ{Bj0uBwsR^35wa%0OG|uTy>;5W#~Y|{c4B%yAif^FX|X(% zPq5lzM4LverD(mnz>E=`uXLJ>{K-vUqj~Z^OW+%q;fv0eweLaue_c-84>SFmNh=md zl)aE}MIc<}maFw2M8uL~ftd$BCYKv;J+SzxI30-L)e7fo_0Di)Hgx{dNbCH%6>@|~2#n8MQ_Yro)|ORKvaXJA z{ClnY)90krhBIoJwS)a09&fsog6D|>S1LZDSe=aAuKoEu&xTH>oXRY-inJFdu7y}; z>0C~6c$;U>xP3KD|0#g4CPWtQ-@2W_xoC+pxaOQJkF$1MSrf?NxXg`=UFaV3Yad!1 zZJklFYoG#cJU4QO&-r8g7~BddO9pHS9&dD!hktdczwS78;^(aSylgscca57rw@WfBv2 z|J>QDGi<-Tg_$uq>9c1m0(U%o(E~yg5{?pQZiY%uwz24=@JW-s~aGO$(gNp%77!A4#_Y9 z>9TF@gbA(L8W{)_liTe!Lq4;UeH=5yRx9+;WrybJFK~9y|I{=BRQlCtZ0#T)kVxN! zBlGPj&K)g6b{uKrv&uo;oKL;^2vD3T^-po46{kr=XRlg-BMk@@++PUXVIKYd zjf<|M8c}g~8y#rB=lYGy5{YBSYQw|ZfjNiB@=B^U^HdLeN$kKnq69VfjcfSJ{&<6C z#6z83P`AkH7v!vcTKH8eeNrlHIx#zsME$IPP&=1ce|P)n;Nfwq!?IXS1nbVfyg?B) zK9|WzpWUGL$>4$o@m0e2g3B7Gjo)GjR+F~J9s3&i}b=cnf3fL7P#HqNRY2`;Lr8(6=0fN-fdYGY?s$*nDCOu`VR?BNM}3 zeZkWM)VC9x$FO0z0zro5)Oy^}Y0;lr&2aOS0 zV@`Wmx0dmE{6-mQ`)i9xY%0=+ak}Bn!v`VUzjIj|?b_&+T}am%TX>_R71ogKwJ5Vi zosD370f|$!LrufHLb-PHphJB?raSmcN}m44+e+qMJR@AkHzRC%R5!s$1tf0X6`sQuX5tJ~CssgS zSG4p-NbEY-4>zvmaRGn+FmR%k+I^nQnyWakg~0Uq)s>+tdHVb>f2vz#}Dc$ukgcyYaOyu!n{jO)aCK!!4W0o1f0jx^{> zAI*r!JP$dv_0i$oiaIsBF+ci-(#z^8SLr_2_KmBHaP6qTi~2<2lqq`Xxmo>t;Eeiz zYqsWtU_+_b^tj9IZ(KI|A3&v9;%C@Itdr&kmX&qmScO&~XK;ZSuw{GU>VhXR!7oo4 zbn3jNkmfps7~j$t$LjI1=?>jut!xTTUJcUut6+|holtMxs_bAorg#J`Zf0(UdS@=qF8Bb{4W^toJrnuI$q<@rT3-fs4y1`TiKiZ~kIpbHG0kGvL zjWlO{9hw017;>wfT{$s97`w6g2>-fgI5y|^_Ld2)vDp~PuSvzc^|{696r3(NX+kSa zvQ2acW(%p)8oKtFRQur8Z*PDj10C9R(ztv42;nh__%I@G^3ro%T>S{9n>*l(@W?ix zg$Pvddz(8*+LVmfg&P5QQm3QU&8H=uOMTcDpCzp3K`-lxLPz zkaXM~67P(ry0^JjH5jZEeXhDe^&CPl99!sU%C4#X(+z`Mo?{6ngx1Ljg$cVQlcZvY z3Ny;KKfhm5b}QST+BA6jXppv<_-rWHK!|_C=S4m)=IHvK{@(j$0g*c{OJyPW$E|g| z1Or9uwQGI5apr@_&EV@_ieS!0!gTy*lDg8wh9TQ(19y@sMv^(%L|&_k?9m zgt~^oP~BKh`~7MWT96bH*>kCL!DoS*p~GJv#jKMxi9gtX;~IQ^j29H= zwEHGDLk+Ig4JpoA#~lVPQlF?eDuh{U8@<)x&rBOmD2&&E5R$ren7Gogs1X!p>+h2= z!(QMzeA(m(N;MN6L0s=(pQG7#dMWR%ob2eHS&K5C9RI7w^UCR(Fj*N=q!F~I5oixp zi$~TNj!N^GS8Q5Z=;AcoQg8Tchg9qYZCJk2fVOT6XLMg(;dhln+ZWFVtkn!>S2}V3 ztZ$Y0v=nh6E$)<&cvA6=Jw;ywn}{dm{^528wAt#guV5)}T<+4Q(8MG9zJr`HF>&0| zrtNE`F3lNkHS9fQoq>a0O-kf}>@N3IT{cr`lp|W1Gae>s#g=UwQj07D1WiBkg=kpL z#W#&mbOP< uS`gp%539P9pa^&r=~8fqHbvN4odYpcnhD9uknE}{=vk`-Og67x>= zRqTB151$FK-S{bIBS)^>p-;nxlQns~69eD>sG)tR@`U;)TcqI=RQox!n|l6=+>}hm zh9`1QJ0-8^=Pb4W;Q5Fx7_IW{pav=PY@nv%+V-D;VSC<<5kIW!AYLwI}{dCt>C@xCE=JkJXygcz+iS9ezDK4&)lM!Ggyvd{n4yaD2`N?Ju z*5&Wp&AZE4DhaJiWhUxc90-}Sj0{45P?~II>j3|>Od=N*)XJsUjoY`+>auxlP$pC& z{nz820Nvw9`!VBu`5RVubeEn~mP4e1U!_=~jmk%zd^71Gyo)$ipdTBnM>!8gZ36hO&st4z zKULVveVt^@-pavJpwwpiBG^$^w%IqKc?CRcx$9F=Q4YoN@2Q1qH1dXu?%rcmIL^ha zjpo82YsNBJkh1puRcoTgxHKTjg7;50{;tf@4fe~%U>Z!Q?nRlMeZxfQN@=T9gW_Bu zDAI2X$g+1x1IjQ4&WIGd?=g|(_bPL#)^kZAvq|nn=&i0h1plNCX_RH#^&~Cu1{#oF z{Jt!|zvJ>hFsk?!Q&_R(`*HlYC3;TM9*shwIOI;Ufv5zh|7{zFX-0uIFAp#RyfysLS!@Iw+7OEO&-1ZwoAPa2mI{l z`bw>_aRXbQ@6d~4n?qb)G+m?t;03Y#%&YluZ3)NkTs~d4DizY?3==J|re9l`bL0%H zo?CNEhxxI+$22;BGh~!`k0=Wt!`fDR-*{Q%2Z}r|>~ZOh9T9-2&;C-9u#7&YQ^`i? zz;9e_&;9sLGxKiT7jw??7+|Ju;c{Y2?pr6DEVsh@!=@O_cz zi5r*wW>77*I{hoN0gb?9zW(9KhRhWM&OCv&X&A-nELTt zspp--mW5p}!a3A1xpQ9KJ9i|~>DM%z-__*zaw(d<#ym4Ypi5dtdk0r-0_7QoU8_^9(lnqRuWv#*GyNytAw&l&#wb?TkRh{1b9z|sskWXh zr_YYp(n7v=uye7BNnuioXl(9amWLik880q#lHU_vlfJK6%D+Uo9(ccH)NGjgex&N} zxx(!RDF}>|Ion5|SOh9`_Kp-L!u>%nhn<0J&_nzFcPo?%q3MY-ht=R>_FtCgSHgbQ zdWe98>r2R;?dl#867iA>qD;)zwSKwr(6o=zqHh=_azBYEjRR}Fk9q$Y`Li%;>FJtB zb+0{#EbR7W(0v?N=h3V?4|fSTWt8Dl;USf>?Idv48nf7X2wi}%jG64^n1S6KsNT^9 zh42n!#4P*n`6?!xX$chQy~WMIEu@XiSlUPKr4#SHPnG%6tIR%&<&vYFJpjHBz+_dI z3{Yx+=0X5CBbVM7^Nb6YcYY^`$OD3+dONUT7RLWOKtxkWJy`C=%Yp9*k^gS_q3WnS z@XUmGh??px)(mjt{C!JS+sNUkit5JLM#sm_~p;IeTdZO|c~XC~vuG*;(Du>JCzBN1nx2V_}1Ux%6Y>jxXQiN7N<5Tl#i0Xm)?2#Pz;8XMqS6u7RI*-@ z=o0J*uxh0{hDz5IFye7TKoT%1_tzsj+w%Aio2sgHM(_!;@nxz&;6_ue!^Nd99KIgZ z0P_*fPtl_ucGg;}K+9#YQmF3Eo4VF2QYzlg4=E&>FDkH7vc#YII@XoXWQdu0tV99( zs~#-2Nw+p_RU!s+2{msj=AgNQA=lC7r*~xaE`?Ba8?hXXN`*A<_{R@QYuXbmjCqyp=3g z;zHyyg4r=8NIBo`&(JZn^xJZa`ZT|N*&I{tQ}NCTp$`_s(?%aHV#yQd(dzH^knxTI z@SUSq2U16cC(j=lhXr!dH<&fuxBG2je`+{Y?@JoAgibtV1%}_H(Zm|wj^U~yk@1Ys z%KpN*S4FMz+>+#E<()UAzGfX2HnU z0Uj;!_{pE(hVunIde&pdCAqs);*KT9s}fs>-fA0>6IVPI!CVz6}AHhRG5FjeW#y&K7H~ zs-C$W^d@K&@u$) zUEH`HJh$@mHE1->(iU#azlRIDwo0?^W=1rChQD#0Iev9=kfXLLPR4)UQCPSm zaf+u-UU=4`d~D{!8H7o)f;o1nh7VZVyP?=5<^A6iz-G$wnim4%VLS!q#jD3aIMnNI zb&;OVKj+jxU+#abJ~g!aHjU9ni?x}&!J|qji?1dYGtr{{iLXgr_0MX?9dC`_fq&e~ zWY2N)b|H8hLJN||4OB?zu8 z<~sXw_Qq%g=nieAqtYz_cchYt6MoPRzq)b#&Py@8(YDH(_gQNa)`7ZTDtYtTDI{@U? z+y8!1qF|&W=tleK0SVmVj?|GoZNN`|$VeZrtUo(E*dd;y;}cI;5Qcx5 zK@GK?#0DT%+m(=nwL?=L)@X$K6Uj%Kc%)ZG?5yr`^v=rqJBuC%JuQv6uNl0khvT23 zTN-RA`r2A6Fu&m;|1E4fgCCQ-ESAiAOaPS$0BnYq3y_*C>oR)eBfj;8H4F~~P6{)G!#ZBPg@?(&A82$Sw@8Og+eg8$g<3lS7Y}AI`X@oXb#yVhkU+Gu zi>v{6p)n{uau-{$$bBj|Y*u?*`7m;$C6x&FtI>kb2hI`y z*=%orRb&3(sAV#_KBC`g!DPK-g_6ML11+JqF+s51NnK8C@lGqitrs7dIb8bM(e}TbP{o74aTU?JbH>w#!UWc3^ZLaWEY^osC~<6F&{DFo#=x!(Kt;jvSj^;p z^|Mjdx9V4(oT8IDM}()ygA{D}8e$4%w+8zhkHuP#Z?Y(du~Lo@~EgTTurk!Kci znKjtzqo8xxEtjLvZt;_u2h@9-%0E(WI=8+Tu$o2oH%libUB|&6Yz2#hS9X4D)MQUs zDhJ)belGC7F^@p2?s~N!kyyAgeL6nYcx28_ z;Fsayg&H+sEd8Gpr;`Hw;jUgjMp<BKH*7xMVt)hPg}u9nm+1<2qsb?@V0 z2!fLR3>@n!-so64R6a!f2?sGa`(*_-vq&*#LxzNx(ELa6lIT}WVpe*T;p z`K%DYQW{-0?6{t==5Vv_H6V&yPe{IW?FJ28+dW5MP3aDh1cGnr&8;5`v^^-R+7a+^Z5$ zb9HD9MZX_94+cfM(o?62Lg)Aw%V42o`+2|jaj&!-IoVSt;=Owa(>_%>DcrLwu2bcV zb9q*iUJGSrE?JkI>{7DzZ$6dp2k|o?V)@w9QZ_lJ_Vf->JM(3i9?iS7&^&%vCgI}j zZJR)u>TH)z)+rxiS|(OJGf-xUSvMj1mPB-g9pMwhZk6W z`#SSLnOF3EN#v2%qyBU~gm%FAs?&L)G0b(*fyw6sL1u4|PK^?Q%KnI+;Vj>YmbWC2`YHly9 zI;#xsEi3Qf*W&4glD-&sYp!dJdj60_B>Z-318^tS&}$GQLT*oFYNc9y*mF+u{&ipr z=YJlNp0bUr$SR>pqnl{72_TrXG?GI8{_KQ+-q@YkQZ6M73Na#U^KsDA3Z*P(Iq zw11;*PDvTfb8kUW95?A#E5$g6q(6lCNRoU0f_9gn%ztHHZMsu4`daN&9FB9@nsHmP z==9iPY-eB(%1N6Go*bIp`Nd1Qo1_NBUd(G!9U-Ks*3J`?LUg- zQHi0R!A>;KF(LEsl4$mSEhfGgmwKZuafKp-0P~IA;!bIveID{Cl~2yFMMEZv>c{t% zHk$BdxfIYqXE~sdCaA?R;Tr8pNZyKOqY>ZLXDe9cuJ&nprn1V z;>rkC)0^Gf1q~jfDIrZ;OUxs#CDz4I5xhxQB|(((Tz2@qlvHVm#5ge4=D&{sOT$ri5)y4Edd{_)_tY#MAXYysNmYsNV>9K~OQWm?u{H}Ht?P~NZX&1LRZg=EHK zq58o5v@}T-5yz7CnA18DLsm^Se)&eZOq-aP%us(;8fIQ4s7$I%ACeFMA*zsMQ@){S z`sN;6GGE+0+qdXt=A7&w{nab>j#T*?Pu0$IDjcO$b?OCDYnijOJl^q;N5Hbj(PcS$ zI3JEFHYrw{(K?~hp8c|j*ibn@mJf1q1 znxP1NkzZ?Zw|iOy%T%rab(}Wr)Fa2@VoQ~1jhCc}=*sqqk6`T&gHt!AF^Mk$2a`)7nv~ZZ+%dZm_I* z;qny|o&Mbf5!} z^eE?F@N@YVNV~p~sd;%W93$ddM-dCW}dE%@f%EnSA9z zv)--$bXj`a%5Ec4Xd*m4hI-mWLu_l_>Va}893gEly`?Zzj8W+uzK$3D9 z#|~1ab|rBb`(1M)qC$4*8diji&gO2b5}eey+DFkbZ=0+Uw-#(65KFB$mWDbvnA@3ZKLJ8W1#tgla z44o`XL-6D4{v4FgIwhhZfPd<1C5XT+sxGamqlDwH9K^Z$lX|?KKEY3F9F_u(<>ITC zQ%A;};-SrIC38*pgP>fgLe-6tZdRp-t^%sI@TC#u2xS$inZby9&d$ZuOR#k4xq61VOzHGWE$CtrJS@0KF&Xr%D@bVyFGX7@56k(I~1evloJ zqp7wp)^wyDM#%k4@ga} z7S-tnI#>ORf~pkBHV``N#6Kv{2Na}ogK|1&l{F`vo^}{AwlY@n_Z5>e(E2BCwz|l4 zh0c~m-Xz_!y%9486xAvL=xBBBrPOx2t~q!}S$sm;wX|0Ic4!KkbhFLxnyXP@p{Hqq zQvxB^Qc|fT;Q=&eN5XYXnWyOF-SEgk=BQ zws}78xwUJDY=7ih4L^d~C@+VaKj)46t@aiip`r}^d)JRw+9ENdDjqiPq0|i_R}r`M zdt2k^u?^~ylH>P#yK%8cfBFjc>y@Eces5(g;8^fYe7DhvySiQh7+jKC+TI=48(P6q zmmIm@uTcMm6+pXa*z6=TSGB@BhM}{{t3rn*YnFP!rWc#t1!j6ft61unvv+8OJ6c)3 zACibx9U)%F%|Vjnuriqm7C%1JA0SB(ZS?kbptvK{E5^k?eHy8Ma^1hM!sVN?Zm?6m z&_;aTCR_PuzBuF(%kRZqJQe!UQzA7K9A$UsR$!17r( z>L4CjNe03K!>t95H4YAZmj2RQ}iHhv72^#4%Wq z@y^z8mEEh0E%kL{!axVW*LeiAbzvx={^u-OIp>nKgCH)>d0?xABxZ$KFSBrPjjd$w zDD=s|?6X}15ooQr*80y?ZbVWI9(LpNQ(ykU9FYoL^eS4cYlZQXWxf(PY&n0Q8`2h@ zY*V(U&47oj$TwhXx1eol1!Cwf&-6~H#>ujZ)>yXEVke$|TB?^H1Js@%ORz3J2F7MB zXDX$O!aIF@n4`(ccFk7>Q84G`X=1gEOYQ^98iJn7^D1s=G#$*waxBc6oja344S{3~ z7uWv<@mU zsa+>a1WPxH8g=rEQ(DUfR(iUOhpHoD_rwK!ucBwYDhHQ{chBb47U2pky^vwH!~ixK zJ}L{BmvO%SZYcSGi`fA%dF@x38%YqX9+)9%fRR5@NuKWBI;G6f;OyGcNI z&lRL9ElnRHYUN(+MnzbiH-98|mAS!f`XQmN-ZM}+drV%3{a|SKmKi^IO@HVUMPDNo z^=zm(j@i9PIWvgTwK6>9-2lQib-%35KDx+UXE2-8{D?eFRtE< zbo01UWsoyAeu+N!BG@I)qInDfr4iR4K)1U|k;hs`{Aed~sd>yxQ^AV1CJ?B6cGgte zYR=x{WnLNsNR|NkM+4!8MAEFE-H?(wMp7;&OT%a8^0v5!uMa=yY^pp*Z!+Pf+9gn` zgryCwW=s#*CN6ZzuKg?zB+|Qm zfg^*W*6gf7;QP z{Ynj~!!zHw%Ai&pj+SQP^3j2@Ep4I{L~bKm%9xX(c(W>1wt!=ca_1LqRk=nefF8Ck z&>Wb9KO8g`M5}c@K-keO9BdW}MT(na16Ky8LY`UYe%KtMUXa*+&~KJ&mEDSKK1b2Z zEh7!0TUnKQ+A30^YM7kIn>IU))ozc$YcHbx`fV zzlRo}0hJ0&jv$cG1q=pOKZSlbxL&OJ#rTqn=&Y>SKL#297 z)h}NS*B-ADI`t%O*zH3?@sk0?=ONG@r?f%R;3nf%JxL5Y=@yvWUx4OS4^H`WRH;5S zgX}pH?yBRGMpr)DU6m5oKF`riJtOAE7pf!GD?E7vgExtdF70o^;zx1L9~ua*{T1>Y z>6YU1d2-nYd%;PUq(3{vDY41=0?V`~ZBiwk zdmGYnH?DXMu)4yM+H(Ww`#%auc&9uBhn8Jw$U5QLga`TV>*aGwtW^3$-rDC(uK^Li z4dZ$8->U(Y(u&S#&5l3E@^hrY1io6L({^>tJi2XNonsm{AT6_WqTRcj>GQj<%+lfD z&bg#Y+H21w@vJ>+O|#A?$-%gBD?lPdE2a>RK(#9Ijo<6PzI&1N=>K^M>VIxV{J1fF zr-pZICo_+uCe}uC_ItR^T1i)$Mwtb!zOQV!?{Z_zte}FEO7oAREccw$|^XP=x(1$%`(Zlo!$1lDOcI^9`p32R#4GmqhmizW= ztQh1fEHnfNdF|SIt!gAFtVoaKMmP zh$~`<5gvG-!_uQUz{`948>5qo>6pw#%hcH!wA%4>?M07^f5hsR&+pCnk_P=D(tGxV zb2m#(`|@`lJUVRP<-_FrgNM!~6NN2X)b&>w36^g7*bV2V8rV5>sCdmhF1(^d5C4se zT@{?m2M!IV+idKAXHWeo6JpsXHqa3XaT%|ZUeNe$(7(-<^;eln8Zjl~gy~j~lT=eG z8|le=g}$GoLYjzvh=;9 z5A;AfGs_f;YiSXFlM!75{MV|U+4hJ`K7#DOU&}NxwU;s%sDjlbcpubWis@I9n0N0h z$H#~})@V|8g(oP>SBk}A+NW>qY1>|X(+tRaqo5=0Ge|J}gDQZCHcb%|9fWrVQS)ljMHg*>qo!;Ae`rn78s)pFJ02l`bUa)ma!DJv0+A*3epSSGKMGOj7%% z9?E0aFFV0Uce%6|)F;2PF5hufL^$F?AB!D1TmYL`*#zEPTh6=u)LWxHquXv)NDOS) zo&X#An%N*Y7!-jY&@YbtdLX&`U>OI$={8h%Zz1O;qhaKg6@Q-`=(VRB(&d8q3rUWl zyfr!1?SVsq!?BBIDHQyT>m(1yBX3{5!kXcF39UXxTVL}*@Ut$ z+A%j6d#@yS8Yj-7EJP)ykRw)fIL`(E1xxDAj{ykp9sZrRHIm^~%?K{~yfG;XbaQ-6 z*!b|htDAf$(u`_SXDibD#wHRH!qDVDR3vvy9Y;rJf!?#;f8w`k)>MCs5WXjyqac7> z$w8#>^5X6M%i?}&v+T@!BxXXanK6Rk8PgM9ZqWH}}r-o=m-K5PV$fPS-DtcUvAkt=+5XU0w#0KA$ z*wX$gG@L#ct|K4%`d|e5K2RmZbaP%|Kw4BwaCj`|(TUcuPUb0rLs1IIHA*StWheck z$8nD$+Q23jdoIQ?CA||_(x6`19Gy2q=LYf2C*-cwVj^DTN`2$H7vazQXyg8FHm7dk z=XoXJA#%Nr$HBSE;llBx`rRtLTbmhL=*0Z-omnP*_rFp1-ce0uZQs8$jyj5E6cMB< zRjC5f+c?q$0g)0q(v<*#gdWPM14s)30@A_=(h?v92q8c~MF=e*B_VW$&=Wd=(B^l% z@8^E+x!-rae?8A1?~k>BEZEO z(4rFd%2e4)1s{O3Y;o63q*>QuV z9@s)jv`a+F{?8O3e^JCGb!rw=%D}vc?inhRHXkcpe&*(rOlWEIKH_dVNdD_Y@x7Wr)_V*(BS zPWeS1bQDejK9?}a$ji!&XPQB-*}tw=gTfuQOViQEX9UX1^cUo6Ufgwx-FnJ@kEH45 zC_JR(<0soz#DdK^ksQfm-lZ+RNyHJe$PKk{Oszq^IO>2}DA;4u5k)C07zGkQaJ%;i z9S?M#{NsN9KYm=S>$gYtKqvDvrVvtnpc1on03rn8`Z7#xi1q9QocY?;^VRie|MADvcVdxpsj`k!bv-7oVmfYF zb@hrg8*AnITka!U={=lJmNAIj6|Jc&IP0>OKQ%c*Ucz6EQE_LbmK+j;S-Zt!ZkKN# z@DodI`oG<@q0wB@o%+nAWnQ9M)FBnSp+*#&7b0&5EewDC-#uI!J?c2OvXp_9O=46A;frM?mmIOY!m-wqGM&r9EamKX_y>fWHjvlFBt@o zogET;^1AT;hZE(H6$>C$F{3{q+E<|0rL1B&bj54Dp(qS{{+VdCIh~i5cbpO2lhYC^ zKdjJ*FsdP%+pZ_&NiUCRl`Ze=?+2W|FH`6rt?DKzjSK8uZ6_UA4q$~utyF%NXGgjY zFDK3?WIT)|26I6R@#9X(M01B%i*7qFXTjM51_@zK$w6r?*i7=X@t-On*&BedMQQ2> zT84SV00jf%@D>9^W8dK`aPDit0#t7f~m+k*<8CXL53 z(>o1^gMh6sB=27*_?CI%)r&G9V{z_X?oy??y8G!O&7Jc&0lAj=!~5{)HQ|&S>bdVf z_*WHRncP*td8q7T(8j~9n-I~BtsGcEZ)Ru2WzJ>zB!#O|FD=?wP#M6qDuDumymT(L zH&P)wem$wznxO14hdiRW`DJ(fAST&&)v<4QBHy;ZEx@TZ zuYbITu2(hD5+0KeI{RR1xiY(`d#slGg>b~#9tziG?&^w$Y|ks?Cnth}!s~;N-Y{#2 zbsA;PO=;VyI_!#jshK^9#zq8IE;(SlO}2iXaHv8k+m**S&>PAH`xO+Z+D|D~0}X{| z${?4fzsYW@O+T(RHEQUs-^iE_3OP`zsZ4fzEv@6zUVNRuLpgk}z5BtvRfwf;LYlml zL%XEnR2=X4Ov^k0=8#jn@}-jrfb37KiRAu76X^D0SY9cjR6cO|lW=ATT59P-K~k)& z%OCaas18nF-PPzBqf-i;VvT(|{dmp*PmgBWg+4Dv;CGGBmoE-4mk8wL(#a)WP-h9p2kK4BuOJuTV9%1dk*$M)}Up z;YHB1ewUy;E;XncF-|Jwlk2TtDS;!(XlyMy?=$5)LZTl>6J z)u27ooK-U^Kgqk^p8*tU1j`F-aUdBy?jOSQ<(u}^Z^zf$X+h0iR{|hH z8wmBGMs~%QFE~wov}s!-E^$7m<#v@*8FH;5m@mLH*&<1`4WT;tM~`cY;M4HfWTTLv z6)h8EC(;wCheUHU)i8h=(tunF)YchZS+u*BS~!&ISzf%%OD(nbSfSeO&+}#qp5;af z??hM*M1I~iF56fww*U1@VC0oNw33O`BPvd^R<23L7sg?H=_fSha(EZlUFQI;q1*iH zb0oQ&96PT723Bs%L?~zrgSre^5!*a9Csc1LfB1D}{`dX7h?J1OPE3<8&1UkXOj}H+ zj@&pte(KD>Uh3dt46Q1*`}IUxfkDcODuc8^NiE+Z#WecwRLNl>S1n$oa6Lbi%>YNjPG#9$`Bs zvkKQHlK4`1T(=j4A67hS)k$xEG^!c`Cf`$T&-5APLyjFTu2AP;T6x}`*_@h`#gQ?L z>Sone4}n>b6O^=jW1+%_Frl4Q^R!N>VJl>mvu32slDyIKF+IVd?_P~#EmNi&fpgXK zN==|Ds}qce`0#xox=d}F0(;f`!`!ZB^XNpqBB;;Xa^0uf!5y56H*EnK^iCrl<_|}f zAF|w6T)of{l>kSgr#tGcR`X{6O=ffY+x|`DNjbvYeBazgPCd-L^o6JOtcXKKV^B?_ zvgaYxLl(y>-5O1=j7L^(;_N!U$?e%`>FAsnn2ma*Y5^n=G61iXJNTzUe?-w+R`%MC zQuxoz$1pL6y9+{%jvPlh{3qw5n5Bn%3p$OCXO6OS!J`K6ygZK*VD)khQSzhgq3~&c zURK-lk(+as1u3w%0^zUp{4b8HS5+6Ju`slhF{kV%E|Fx)o3)K^yItNJ&_wt5im z{#i2uo>ZJQ^m#x@m;>kLgmMhEy*LP_8|CGfd$2D7;)Y(N3b)`=*1yEXfcVZo#iT}W zCcGr#*@bv@tV1QtGcf&RIC=fV$@27Oz`s}sumoO{>TAsxc)v2a>NR(B;?itWMvJyr zf+q~WHQif~>~w!`GV69^zfOOaVw4h_b2jP;wdm)!W$phn>Gcmjsu}#+wn@4UmAmF6 z3-{;GDD)jW9Wa3J7x6#HY<)cf^-E6J8|XK(<=!?^2y!lryE?7JA9CaA0W#(-Nab?V za_+UFH!#j#)@=Ply?H7Ums-Q|4HVjkR&y5wh9(qHDD(S;bpF{t?0)}`59 z#%js6Np+QGis6JCb*u3do%(>uR=Xm?GF-nP9VD&-9)SUYbDMmJH?W3r)h6%Z<*6z| z8I96}(0qf#bkevPCsc5&eY{U?FS#EPe3W7^wgC-0999+p$wAJ(MIlmC35?jI+n1|0 zOY;F2cs=#?5MLb~QWJvmj%k^X7_B2FDx!U^3eB%r$p79+Yo|ZagYzsXXaqSWhv;sp z6dqm49Jj{39a8pGom|bKB9|b0O~a$4tgMdJylGs+ ze6vsJ#u(*_yAD5HgD>dsY~nE)vO?pR_-$vLrW>+SFrRQW4Hp!72;JQY1*d7yC2^Q* z>3s8VOC3Ky2N zN1my^vd$#Jz_K2&%S>;tS}dQ56CB4y=&zv|jeb$BpQ_YDmvy#eeY>!`(Dt>oGmuH@tXXI4GCV6N?|m0 z(>K74lA-S?e3M>&d<##HK5dpl_*1NHf}Iq{s=nDrQs|W+_YYljM6~Z-10AHXtUq35 zhkgy5406IJr6a)+CG}%A;7t|WH_1+n`bMOlA>|Hiv9R&JE)lO<(2FQkYHxCgfk^7@u3iUwtUq4dkKd#26U!s$VSB<^7uP^@5BaJkx68R@Ba)a8 zwW@@|TJ97}_p-!+&ayYuc~fcHo<(|y;GHoSxI!b+wf(0Wtokiz!$=@Djyw#`0DZNs zaQHHsNJ1%C>MhbOP2hJnl6u|$-3|Wn3U(|xb2OX7me8LuRr+W@il5RO-QY!_|0p4; z+Y^!~ol8_!jfg|{8}NZ%COKaHzFi$8(?D5St*dAI0_QXO(YVztQH0Y?t$sCZBDm@I zcB(g;=?)FHwY&6mThBMVnoWH$A8q(GCM9(_37nxOo?ph`V^J_m;Z^EB^`ttFau@%- zJzmrSH>8gyyhXVDn0F&AW3ziTDM6gDY6-ToNLy-*ZEIzQvocU_B|ScHeWy&$3n|Z0 z7;N|<>JKlnL*-XamIWVSSe4@;9KjnE54iHYI0KFWdZXA? znX2rGx$x<;W6HKSz&5lBp93Xy_F4M@bz=|GlQWQsdvRZ7ug(hcebh=Hn4P@OiRlVf zO}pAX=Mp!*Pz#B#^fpiqQPavzlhEDWDi1*N&H8v<3h<2Uu*p)e%kjh`+O&s64AXDE zPLKm z20ne2lqHv$1D7)`z-O4eR6Lv3`@Pihf4pGwjJ+HN1Qi;T8qR(Dl{Dc&1iiS?5zMZT zhHNRN1sslY@O2v|LqFFVbg_HshV+dniVK+a?zI%dHolKJqgrV)11978SeG>PE{ z4xic-eE&vY`3x5Os^!}SQEy(^r*BVOgCBupPk63PrpWJ}dh>2Q$@x@(yZZd;-q)D4 zlAWIH>)4$tYIVzwC+593yL(U`@GokiSa}B;n3n#zds*e_>ZP=>7e{8k1_^<;)*G^? zudu<)h4boCRnZ83V(1_xU5z_J6DrH)Ku?Dc`w1I@?<`8C0&Nf!l|yAdCKug6*)`u zU1WM24VYa;f34BO%=}A%LIU(UyY=cNSO4#qUL9aW*%<$x`|-FqB0KM$2~KGhPF0fi zZ>zaAP<^PO;Y39fP@NqLD)t28GqzL>zI`!^r|PIia&Vd|^lB&(J~^e3AtmOcBJ&lu zYSB@zEUdTQT%q_?*$RY9cBR$gRziHWG#%nSP_6ZPc8&X48=syEP?%xozKHVl>3{wf z_G5xPQDjg5x7*e1sVcA?B_}fOtasZ`duMtfrZEBcZ0GK*`|RpUeQ(UuE9bpLUGFr% z)T@9P9&@H9_$UWiby9Jj1uh=}V)_PDbP>H;SaCDe@99~gKTkYd`!ghf$($X|dUcz= zDf6xzMt<0Of`OLq>~sc)*#&Q3dk+CfK%ASwmpE*ztPB*Su7p{IqaDSy)A)y{_}APJ zV5f!*rBz!@>WuVQizeNZIrr?K&7l|+aK-*#!S#K=d;a~k zUBfdG?3714tUPC{eodVoBa zhrMd?`4|~ml3dfVCY4Iwl3$!}>YgtwwOqG6K|Ly}@$1D0tuzLwj9;5VEjpm<{3W=& zD2|R8_lqkT6q$p?>msR%$LG|{)q^Zzre+0|yQ7bLQUW7V5<$mDtBtAwwij205knvAKj}XD1fLQboY7pw9pue9bFx* z^(K1f`d=qTb^kh%{R64RGupV|OHs^UCtd(yrn#3x$H~oHdkWOn?Q7cEI>>IF{p90y zoq%wqeaCiDD844@DoCGYIGlAUEk_B)kI+rKUQ=^lip!$C3MfKax3tu$WH!AR16U(Bpy6x{rf=arjN zH$26bBI`R$;a^8-TWgysG@eR!D5HcpONB>{<=t$1lZU~yH~y&QKoXfBUe9zO>7yzF zO>2!jVmi?y=flnim28w{t{$j z`vqUIG8a$NG~r4n`gDEG*I^3$b;34$%fS;{>0*VB8>tS>a1HdHmA`vS%3?ZH$$KQR z5WaJ~f84dNtGj!d^Wedk>zW5~+y8Zc{eYV63~3wFc^XRZ3;|YDd;t!FAp0OC##8_v zA5p9e&RelD)&8KDHC2lnA1Z7?ss|V44O=bG7p`j!y;0pwxyc#_7=a%My{yz>+%vIm zGa)^zNC!QATDP5R;D*gNs4z-m;Qre)2sN5KSZuv#F}XAOto%S91tb$ha2`xawr>7d zD?rRH3uw8a{+7gJ=BSYHb8&%FiSLc@|=08kYB;#e`Jz?MoC33txkA0#@uGy}K z?#iHC(W=WCO|s`qK5Im43Cy)54WbZ7ikMN#bNBzA235=In;vz62s z?IfC4S%Fj4s2b11N^cK%Zv?LnL$|>gF#Ei{z?$8ff{w%mWi*#yzQ9O#ix&4svq3$8 ztoZxdYN;7}|0Ha0dIzRu9kL|i(0X>6-&WrfUD&y4AiF^iKq>1cN0ulW^g;M8cm*w& zr41%qRW2jXO)DFHVuAa^JRqNx@`6uG1X+`FX#R`HbMb3`wvTl?xc+rwK_4jTt!k~S zlsqVlKf;+E=HC;Z0PPnyFQsMHvYVO^;)CCODucQwKt+E1xzg46QNlWvSU)^Nv?Z5vRVy7@qwuzXgSHaDWr&xXipUeK91Li>)% zIu$c61qW$Nht&@5DV0sjwSc@#U|6Rc?NVZW&SR`>i_*GSs?m9Ctt0iF&3muVpd0-P zm=t@{UCvT7!4_+3vevX>Ydw+k*finni?7k9s~ra(L42A*WBunB*?De-K?1m(U>%of zsYWgX-TBRn)QQ#A1$m}{aMKMSadsNsBHr$ur`108+vg)^UFt^g6YC1wpE}wglI@U0 zD}~)bs)-dhsRI)7c}pn?!n7McUP#$J`r`L2&Fss+pD6#wtGkJDe4lTze;8zh4Ey}Qz`4YKN2kTAHYUnCm%z&o!uq!w@9?w?WScW~kdPrsXL5jN*Qt1G_48Sp-DF3Q)-!WE z01a|`3WV+96o1*Jv4oy9a})R!oQTAM(v+ju)e|A^vB5I+4&ZGeezO_6h^&jp9m zc0qdHUpkd>#V`kKy-jmXBR2mdE4z0RKgR$~DK&8`1z{Oj`{Q#QDm&*ZpgLW)La9E6 z4G~G`Z|fT6r&1Iou{8#Bsg&od+;zyO%vw3umaR$}0WI@KB4?*6kM#7=CKjj|mho&A zrkVSXUI(>(4SCEV=!yD=Y=H6XTjlAR-~V=bh3ro~@ACy>$;66p(D7^0a-Qlod4e1Q zFuAu~6Iqy=azuyQH*7F7TOCfDbwjq^yYw#M^BincGBiR^@n&`p-OIxvN{Y*<&p<4^ zx1bJVnyfls>m9Pa4(yM}F$7mN1YY+{RVID@(v;rim{Dy(nnm>o?+~UB48fKa0EQG4 zxDMt;l{O(d=nA_i;GrZtrvUQsxr*IeuAL}PtI2r%3Ji{WVb)a1%e8gD47 z-_7YQo?TM2np{Vl0e?e!!^O4-`67y4w=;|=GE z*MsfW0J^Zfm8?{{?OQ_PbpB~qN4nc|K=W6?1&3!<qaY_e>c_bX<67QJ;w!;8 z#>AZpdHOA_wS*}Nofxd!zHC7)Q=T2+KG?1qRLk@5%`X$BlGr7#w>)#;RCT$Z=u>JGHte@C z)mc~x9!IX_R#v>p$CX^`*k?^P6Z_3pB;&O^7F3u(e#TX0xDb!9v@}ipz}89L@pJqV zj8vJtEyINPwwok%8t`K4!G!Lva`!T0QE1J1r1+t{ZKW`-i+@ADJ0X4`e31BCq~bxs z_7*)a_GFq;b1XceCL0_OfLC^rXO#|y@Y|PGGkonwW|MbzDk*g?TMlhwa36gZVO=I+ zs_*lgxV1~zV^79HFyYmtKVD(%6eH%aK$8Rv$4_yla!ku+~t!pr%?y%jrey9Fu4@6ZZ`7`>s89i&(Yo zs=nHR>@E?9ghi?$(A(9?ib{V_zW%ENp415*2W*1XcLTa;2oYc!kfPZ9vSH36#s|-5 zt^X~k!XaJZY`qNWNk--8<;F=B;uYg8j|4gOdS$7(h=5kv^@5)^@d12&8i>6(lO8`l zvE7c^`lL&Kif8pUm2WF*h&$=}>HH8=u^_BX&K61OE$d*vhw(5{+w%-MBZlf94>pY3 zb}|S(DFDLF1^TGnGBBQRGpsj#;I_+#zZCIsit@{3>~rg zm*yt)(dXg7P>E z7)9e@!AGVl753uub=lVZ`d7e8E6$!7r4*1=dz+L9NQz+}XxjYcYxNN#%S3X0+ z7vJI&u_=d*{amA9CJJk@Y20SZC9=6=@jknJn=R=z8>{RJhsg7+^7)^hN05x=lPeO@fF3XXe9O;t3m71DS(_lc2t5Nh@@|vKRrWxf+xeQjWv02MB@zoHrU{7F^oV@Nl3H~W zIX`cKFj|CffD1~+Xzksgl@SczJo59F7PjsDosN+Q(u19JASPi2MF~qhUebQql6Wxv zi|=04PE+Hsk2gG1GO5O2=ORPtD3era-#r`&=g(@H@i5co&~m=+v9rO0zP;WJ1surC z@3~5Ljw^?ohI{(v!LYM%Ymec4ht5g8)$9@%&T_Khf6LMd94M1S6Vbz{K?cEEY zvD3dP6-X^0RZ9>*J@~rKU>Lg&!UDrqa;j@wYTv97CqjzoGn*dr77j53#yCbGpd95{ zHd>~PU;n@hhVMV{Io!;&0NjTRoJ5pm2 z(n4}CDwirMHnn7}s~hwvie#h;+lN*!-F2s&_~B>g3cVw%yh+yVr`G()M~Tt4aCL`ov@OX~ z2cZd}xQ3+B&5%H`=-+Zjgv09ZK`lL^Ye8H}FMZ<2` z^9#c6m0KemE|YJ@1s>m}%ZLc^Jw7H0oMXmbC~|tv)q1|~qtUf??_AEY33s0enV>)5 z67Q$XuBUyXLtM604*=s%*@hIWJts2fTVl9ow8c|WSm=*pnHW)=)X+>QYO07y4W~DCq%LTvU=1BBaAR1-FeDJ=~ zFTcSZIN~cOEcsLdu=Cz_*UTG7hKl5r4?Nm7v)t)-_^(^%(vaN(;h(WaDz2;wmN&OZ z#g$VW79_WI%>s{cRj|}bJs3~~hbu!ZsDXf*yigRXU>GoruuA}l54=DE%6DGzcVKbp z&SeLnSPmFx0CXSl*KlEBi3C(RIQo9Hp6tDGHE!cIp{8WP!Rn^yoJrV@yD?94fyS$vBwQlp4)yK0l1nY0SXW=?G<}aZs8+tEATq{> zkY8}J&tV8N`q8`bD2JUdWJab0UN=TbNKRz;gNxsT}; zTep{(G-Y!3*d5IUa`;Z(wQ|fTCVL=|F$r+ieARN+9$*QK7KK3H1m0EIVC7KZ{G_x) zvRiZOY17)tiMjwCz2854Khv=Vpi736oWn2mNlf()18x$rZ-XV=e&J*^@u!}bWB&0n ziOkuS`@Xm48jr#qV&cV$qj=_VaaheXgR2|HT46;N!#YDv7Hbw(<;af??@D1M1&ExI zBK%l_Rl67VnJ9J0Wa2Sew1%OXIR{@JPfqJk&H)jVGS$RlO}N*LYsevuCQ;Qo$J zhd4#cEpsR5F_l_xf0+pQ%7p(zxa5e*r;$ce`!OELY2FH*yGm*Do4Ut~W<-=^=61BPi=pC^Q2utt-L>Y6bl}?o*2EsTg zz3yiDt_d-0KN=dGpGASD8nQ;xm_g}W8S#lj-$EafgR;1@OV*CrPPV+1<&i+Bl!asO zT{lji|LQyc_;vl|9~uBol#b7f=JPkW?mJQ3NJnik5V z{tRZ7BqzQeh5mwSd)=*60d|2cQ;t4vk^S0_l34;N%ujN2mtKpLeE8em{JCE(qjS(F z^c%@BzLJpKW|NU@PZ&={wL=f=sPB!I*Q4l5?zL{n_Jebz;7RVy_)bN)dO^4DM`;xt zX2)BxUjp#1U_Up1s+gPgYB0kH>(By>ZdV$K(G!k$GriWcpoZ}h|Gb*QStDom(Hb8P zOopP>$IkvKSRQ@DX%=YSnzEl->h!0O$f_UU6nhvjazT0L6k7lmw|0$VHnTqH%^N?- zP|FX%QC9D0h;n@9etY8NkGl{0c%sJO(D~JfzLv(vGf|4;cth~$mUNhAhsb45NBB>} z;8~CjP5D%%zBv0WBD2lhVc1#0)hTRF6Y}$V>#fZxtMy_h?E#id&MmJMIx&qjTbh0$ z!CR>pWb(GBg3HZJ(cC?|3>81~b|ow9#}uvrAlJ5Ylv_Vzd(uJ)iBJn?`%V=bfoA0w zUsYPAi>2A1vFdpAobEDm2@w|k`ss$jwaqi4?v7VLLrR)c;i_{hbNKL#>&{7}LXn)R ztp5-Uf2{V};D6Kma}VH~nxn-QC);5K@Ap|Dh5 z%NZxCn_L=Af&Gx>Ngw6!fQZ25GCFN6?YV1gr4%NJ&>(Lmv{J5x+N!0lnc|xgQ!_ER zd}^o1X0UrxTK6PAtH$# z-f7O%r*|hRHI0;}QJNG`!oodT@eaFzx~8azmkWZai@*Osd$x~v_-RUHd!>C-Y2P=_ zWmPUMM9?rZ%uV0Pv-0;^A%5?-OI$qdc3MFk1og>?<;otlDTvfSWkY{q!DT$+K=ECp zeMRcLT0zjLimh1ct8tSLGZLN|m_Z;Rp!L*WCpMtu#c+0p#f^oaMY{fI?%+`JkTEkB zIpFz|r`a3jy!m0a;IMD(jQn6NsLd*b=zS>k*9liMKUpHExZR1!by<3L$+4rWIJnj9 zY3+}S65SfsA>I)w-pXzMdrP)x7gXyMF6z=>C$JKDZfAwUzGE2edMdYEKJ+#-n<7^aXcN9`?aCB1ubfR^i#TM zE5LHvc|MzhiimEXD1Y22ykrSTG7 zRVSelk?qas@6C}tX+xX;)%;C%>VGs(+dS?vd{HI(W5(f$ldE7pafr!i5VO^;Rawf7 z0(+aE{==K9vP^kGv%o(4q17Ijxw&|0cm-4ivuARD`D<;M30NhLww^ghny8~wINK>f^mTIFHnTjo$L zFUHc~qw6(-PEFjvXAdZkbiJZle65&f37?3uR5Ibpc4pOm0Z*wwMh9?2L+Rf}Bv_A_ zh0LtnJ4cSDm@-sAj(`gwdTLv0W|RHHC!k z(VI|7)H_&|#B|wa!S$yO$tf(8%0}-{t@tM~$H9+rX{3*?$7+uA;Rg;L3lE%ht*pQ^ zmR453EjF994$PU#e+d&3VC32d4OiP=`N(lpbla|L_A54;cOk6cpk86?JqGa7s5i{u z3V)ZEROZfk^ZzyX-|u(&|Mg1{Hx^WLmpQv?z(hHrwKN{F=_x?b2Ip6#&NI$zDa{Qn z3$z2m6i9+M`;4u5N>9+;G61|90dVZR+gK$q8Wp9ZL5p&aE}RT07An~usUbMTdGeph z>z?1_oP!cfvJ8sq+*h4z%9a+=Z(?OlXCsT!Q8)}(9fdJPm54c={1#{I`gsCeK8E%7 zqG7cd4xoiJ&>nF`sUIR7!+F3@GPLzIb2BkRFg%15%wwk0D z8L8nEYXH}|CPnvD!@zS|ZUNO!5oXKs@vHLXM`!vVy>iMIy1M>wq+J?fb_$$o{%D6ejE<_? z##m#5^ULxtB;1{)*v0Z{&1ddfw7Gj_ zhzCucqlN$g(;!>T%}z#CM#jf)rGZBdDYoDTBfVAY;Uei4Dp73*Cex*vQ_iut36IhD ztSWq&P0usuMybP1t!K-l=dBe$CKU6j!81EslE>pk>5oIAJ{=eVih1W)rC!G+K!Gcz z^(^TCib1Vs-h}g}vj3?7$}zvyZv$LxB|^$3A#?xN577$IZaOw$5}Wa__aA*0Qc8K& zYP*}#WZY`}7Cw(=N~~ya?SkP;0Ra38U!udV?2XRo#SfmGH)D9MToOGtR}eC~ltZeU zt~ek~7Ec$G$e=MZYUIKyb$4*~m=E$WddvPTkV2Uad4Hz2fYwhHd~jIkuKnikZ@^?*V-j6A~$_^Pi_K zqU$K>$c0|eEq;IL-j)q3cc&oqpss1VfsHzN0O*g#ruH4^IsMB^`*vUDihHv-AqROf zF#flbxo_F0&;4BSKx=vi>1N%`BKGS+)jjmAh2aVGknxZE3asm|>NEW|R;Oz^5-3Kt zeGevt%PD1loe0?@rhXWMsyQT-)u7o#ks5i$Qwnrav);aM~j-FpVhYL859EJ)^*g8Vwr8zGkv{Ay6%f!gytaukUF1 zl>2h(Sd*gt_T>w+Gp1t=CtrwIZ=Ct%srrSi56eSy>vlUl^->u~I&`Gdhb4l=xH zuykj=_m2EYxeXDj7#EtHFr_@}t7DeGp?n>dGD#avnD#G`2+uI9Q^0^#)R*Ux4$>G377by>_DEkR`~FbLFb3{`bc}* z$?EHl7SIuYd%w#3vi<8su-9j|E!uhVb*`$kOwgqa3`F2{*{%|oTaF6WiW4OyrS|+I zV=r<4xF_~ALzTZW#IoB(@$GV$kG>I_!nK2$g|xZmcduEOS{ss-!r^g!J+AU<4BuYm z5N-t7RpCx!%YClQVtbKfqGNZ$qO3XLD#hm$Fv}jnk)4p=wrXe*rqO(`3;FG{wKfq+ zvX!sfJ<6@S+#n!mVI#*IKRqS;M*w{c$Ap1O8+2Qx_s{P;Zcp-~fm&i0abix_q@ zzWvZI*yJTevCW|&ETR0aCYXzNKCm(&ZpyG#ufo~@b2I!Q>t<(2#hm!J&^ks2UZkWT zCD=FXap6>&vYCvpU#7`^zBo!GcktZhgAvStK02@mw!O^cgW^n>gBZCyHV9>xv6{M-8kY+13=cM8G|aK!C~zfQ!C4+$Mo=XJX0 z?U#Pol3*$B#HB^w%Dz;5hU_*5p+?TG4z3N({x^rl1;xQuP~zaoqpn(s=-7}8%r8Cs zlqJ7r9I#9kP7||&AT#l~)IuU>{(z%E0fciu&>C%J9;S?4{6_O^3d2`!l#uGBp$azM zjKi1)V|^78#exjtc=M6ut>lz2Z^m>BF-@QV{+1ypmjFBQE91Vj0T}#B2dhP$&FuF` zE8_1%A-(sS_DXDAF^yf3ML_w4X})*Nr3?V(uxcx|C4OU#Y7x3aKnl4FzvQ%8lN zXKHx=_5~rtosE_Ki$zsSG#iS*j*9(f^Uv04x+_%p`Qr0B=jwK#=;Bmo z5sKA!0*Q*T(d-dl^7_Ea*y(pw&l(IVz|af1WN_K~fd$6?+I1tID6VJAbD$o{76ZaR z6bh;R7oDK%-v!YDW@+QYhpJBBRPQ6yym6|I&U`QkwalsC#RWdKTkAX~g1C_7 z#7fENo-6WeZ0aR~+8=84M7H>+XwDXPz__n1ImDcLnty>$L-1m8MZJQGS|8k>O;wPd z66#wZSb5W8K)u9BihO~{`Un@4d212sCA2)A_ekCitqtL_>(GD6Uj<9&%LKc1&uVt> zEaT>rL8#-dB!Wn+YEjmb!Tt2m1M5lQ7fK25+07>4>yqc8gg!q-St==`BP{QKwZ8@z zT3!fu@Pek%116k18)Kf?58N4BxU;6{IungFXf({Pb60jiJJ7>by6ZDuQA_m;cdWit zj1*mR8MWVZC@u60XDCR-Dspu3S-ueo$zKI^5LU9A7EuV@#agsEZ~kR&RMk5VEx7}| zPW#B5F+_h%{L)`1zN!+ZT_W}6vim^tP*zR-fR(^dDb5;T9P4hZl#ZGiBfmgZguJWX z)wu|VNAI1M3#B=mAhy)H=VhI!)-#etje)H0VO`T#NUf+#|2b#~N&J1#5YqTtXNE2O z|6|aAjh61?Ti#mv-gk|Z$gGjq-41-?2W5TkuD2hs80|fb2$aPoj{J3kK;~=K8tS(n z0AXz;5}pM`{no_qx20W))bFi1J5+ekwW@-*T>0aES+B}P&3^@tp6*uy!^I~##FAaJU;8#$HBWWf z-KNbM)Mz|(OAVI{RE8zOpL`YM&e6z&v^RFNX2ewa*e>0`=8g)3X%(r}4EU+MD!*$+ z?UpcGmrAHy0-sKMeiiGl6Q61gV9BAipplwt963=-^qOL>s7Y!e23nn?M+~ zD5vKFI5pzYq=nbRO|NQz&+MpyWawt5ejaI2uBUjS(@R?*mh#c^8>h4T8ZQEQmU{}JkVIzwXWobCG7QcY;~|e zL(Xmobqv_(4BRCz7wE>f{^#shWcKfUgZJ6rCZ>dv|L&|=VeQBLE5RqnL*#+=fZ9eT zv70MoRk6RVW|^v}nU&TvGt+(Vr=8iz37@)I1pT%^R*);$zMIY{C1&t-H2BoeBYWU5NXYJ@RX4moe zvCG-w9syUT#+Z|3Jpm+96WisM(>0-oX&9o_{oDXjsk3h$sCmU9uH2Qi7;IuD+FK=P zKknFsyY8n-kFP=&eFaOTbV(jiEX57eUWS=0{bOGE{Rd#L@j4oWA4*SGnVe2j&Ie}} zj}p#6MnfVK>>Ntpl$t(V9m#G&_H;cix<$wU>)edeL)^8}mp3nK`N_^tFhw8>!sj>8 zxKN!JBM$SY)xu3ZjhdMIl!8o}LNIicd!%X%MY|Ey1F@FpGEZJC`1Q+$tRVy3B*v|u zsTN^6e^}p>+r6-v^3tF1)1}UNh*@gmqKnTtnrl$5tTj%P7x_~pV=6lzNb(l^i1U=! zDB2uXhDcR=2%Y{0{aGlrce+3%?8}F-<$nsW+(i~2X8yOeO~CZu7f}&re_O5f$Nx9y zKF}uzFd#b!abWw+@zU=N$a=d1!GI2(o5@tLmqOZn4c4m5a}$2*JptI%>+xMl?dh!q zieOxeqMnO1%YRmf{J4QA{TvZ_`nUT0;vu}j8GzrKt@(#P;Lh38mkBYVK5*#?5fLs# zN$8^_zNmyG?v2~b2wZZUO;(nA4OSx*f@zlzQK6{Y6rIp!fV1?n? zc8h;|;VY2Vma{9gv@~iK%qn=Z%83_LqYK0uG0J7>b+XG%+c;r2w-@gxp1+@_?%JZP z+|+@JE%eRh7F|g8WDq(BuFgzsfM0Cw)s!poyB_yxY;E)!5XKxA3mv2CIhnMA@G+xw z%t`w}W%aQd?e81!M1QYato{$p0}A8x7Fi0*A-B-bInQqB8%yO=N0D31f%uOda+-TC z;KnmW?}thMTC zt4t}f6a^})f`E!i2oS04h(K9FfUqbDTYw-TC?cYc6CIR9rlJtz0i{quh3p7WgVyWe@A_xi+mL(ph zL;gLa-S8d1S<+IEj!={&c}shmiU{}Y3zmHJVKxX~;`J5acr=(43zd3}oCKW2(@~~lDqZYeG(SF&VTVV=y%9dF5xL+e^4{_jEzC4gi&;)+Zb;!m zI~fgCS(Ki_TdRY;$h(8!PF{+N9^~d67XLD$T)q19eP5lqSs_?hZju$H8h;XraN+biyzG=iRk9C2 zE`Zu0%Czq-D~QcVZdH-Q{!+-t^*!jXv{`6C+Msa190{Brk#yly$ll89o?`8nZmdA z9;VptOKC61N)Ou1rN6Fe)|<0DImi88OMOOt*1&tIf#cYObO+1h(kP6eG_YHi&15qS zFr?h#mgqb=JIHO77GLR!BopLFsX*FEuJGv0(7yf`W8r8-F32Kk`8V85)n4SdUlk{} zk9ri0!|Q?<Tf<=bRC3Z)jgpRZmNv0z5p%@OoD%uZUrB&k--Ty@dy0~*u!R54tCC-M@!wj|d}W1xzLr-;K-^Fx;- zTY{D>)Oz&6d*=x*LBuX4i<(87_iYTb+W93zgsCUUib44uGcBktG#2Xl&gbup%U*}y|ni1pQT_8N~Mcz?SA_hi~3ir zL(MqzgsYQ_Q+Z}X!h(h#<~X_>?5{5=+io4)`W7Y_&)F-Y zoeYs6VAaL=g3|EeY{qqzl4gVPxBi1sPu?AKpEGAq-6o}x5+nr*9-xe;@aVkB;agrg zW&q{tVjS71_Y$z_MDLqS)^cxedx+kJ`?o>{fj56X!0=->BJiT{AOc95?&a^Swz2(? zfLmBc@L;rARM-qN!!R=c%=scP@KSwvhgCYCwN_=;?3=02N-(c>Je`0=T~fzUX>ArK zp6@$Iw5~q;K{YJV2lwL}M$tRUVIIVwF>jN)|ka{{q(i2gcu=vR*Y;fWy zvX)!FQm=1YwX41tAvW+60?Wuj^lnr~uNLItZLy-hwc!l_M3Q9>VU6N&Xrs!CsYa!P zcOY7xeQ~>vp)%3NzO9G{x1qBr&_HvY86&=;(9I%X9M&`>wGUeE++#yK_jD^|;UbEX zQ~~xNWx_~IznQ+|Q#6nfwki7P!EUw-=E|pc(3UNS%VJ8H;#XLqLGxMVLtZSuF}8jC zn79cLsqXG)WG3IHBEtbWdsmxqd7+|n~3F#d;Ab}F&aO#WYenA zx2N6M)@e*#ll|IzsJAimIuRVe0u19XJ;Ya&&abJJNdKa?FN%O!xfpOM;3I8p|80^$zbHN z0p`xKPOQ8Fd$PE%6=!53+R){LnO3Zry7`7sl%SJB6&Z|B-GhkmQ(ks;UU$FqP0hO8 zGMIGN9bCH>oME=C(tgLZz+*yfr*T_Tm#73E8q&tfBPVw{rqQF0fK>&kvyNz8i1eHt z+UmADxTbUk;nMu2(`PTCtLn_nB@&*-ri|c)(dO#KXT$z$0u_T=&PqSVmKzs9y_7RYcN)#a5ZQF#Tpn#ZNkM%#k;5aj7Ok z6-b8uN*}V{p5Aws9%wL;f9RaaHLpq?E!j)K9RPd+Z| zzKzL?!J`55@lI%I!c_d=1I^-mqtg{i-Zh2Xf^Q0mu|EAV7msVsX_@$t=o$4pT;;!m zh^4Jneb{V<+CDOBRQ>$xVaS}h^v5duMf>6irNuN$dB8H|1F{YIns%-GC-xW-LH&u& z<4_c5uwfG>8(ih%pg4q(-zIE$wi*fw1CxNSCi|I$(7=rRaqR5z>yy>bk<-Do-jxa3 zJ~(H!##6Rl8^eHDH9i?*1P~4Da93J?d?6-?>@^66>ZeAeJyN3n{%wujRrO}3tzeaX26?cSc{zh!P*@cW0MZtm_5SjL{<+#p(Hgcd|iJNk@jWcglPLCgNf1*`P z%ATR3`oB7A{H&$0)6xKa|7hV}A2?Ki@gB?JZ)lu@QSg186P~tH9EH=}Pmwg;EnC0% zkNn@JU~|@_Rj|pHVod*tn&_S5!nMoN2D*5d^=l(NVt#e|-I%NnL|l=eyP|TmL5bJD2IxkpKVy diff --git a/docs/source/dependencies.rst b/docs/source/dependencies.rst index 524361f5..024b1fe5 100644 --- a/docs/source/dependencies.rst +++ b/docs/source/dependencies.rst @@ -11,7 +11,7 @@ Required Packages In order to use ModOpt the following packages must be installed: -* |link-to-python| ``[> 3.6]`` +* |link-to-python| ``[>= 3.6]`` * |link-to-metadata| ``[>=3.7.0]`` * |link-to-numpy| ``[>=1.19.5]`` * |link-to-scipy| ``[>=1.5.4]`` diff --git a/docs/source/index.rst b/docs/source/index.rst index 238aa5b6..0eb6878f 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -7,13 +7,15 @@ ModOpt Documentation ====================== .. image:: modopt_logo.png + :width: 100% + :alt: ModOpt logo .. Include table of contents .. include:: toc.rst :Author: Samuel Farrens `(samuel.farrens@cea.fr) `_ -:Version: 1.5.1 -:Release Date: 22/04/2021 +:Version: 1.6.0 +:Release Date: 17/12/2021 :Repository: |link-to-repo| .. |link-to-repo| raw:: html diff --git a/docs/source/neurospin_logo.png b/docs/source/neurospin_logo.png index 669deb0d8cc5db48ba0197ffcbd28c32307daa76..4efb9cab5b93ad0ffdb7445d9ab2ec1e176e6ae9 100644 GIT binary patch literal 97699 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 literal 62657 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 @@ -13,7 +13,7 @@ def add_args_kwargs(func): - """Add Args and Kwargs. + """Add args and kwargs. This wrapper adds support for additional arguments and keyword arguments to any callable function. @@ -25,7 +25,7 @@ def add_args_kwargs(func): Returns ------- - function + callable wrapper """ diff --git a/modopt/math/convolve.py b/modopt/math/convolve.py index 7074e44a..a4322ff2 100644 --- a/modopt/math/convolve.py +++ b/modopt/math/convolve.py @@ -34,7 +34,7 @@ def convolve(input_data, kernel, method='scipy'): """Convolve data with kernel. This method convolves the input data with a given kernel using FFT and - is the default convolution used for all routines + is the default convolution used for all routines. Parameters ---------- @@ -43,7 +43,7 @@ def convolve(input_data, kernel, method='scipy'): kernel : numpy.ndarray Input kernel array, normally a 2D kernel method : {'scipy', 'astropy'}, optional - Convolution method (default is 'scipy') + Convolution method (default is ``'scipy'``) Returns ------- @@ -106,7 +106,7 @@ def convolve_stack(input_data, kernel, rot_kernel=False, method='scipy'): """Convolve stack of data with stack of kernels. This method convolves the input data with a given kernel using FFT and - is the default convolution used for all routines + is the default convolution used for all routines. Parameters ---------- @@ -117,7 +117,7 @@ def convolve_stack(input_data, kernel, rot_kernel=False, method='scipy'): rot_kernel : bool Option to rotate kernels by 180 degrees (default is ``False``) method : {'astropy', 'scipy'}, optional - Convolution method (default is 'scipy') + Convolution method (default is ``'scipy'``) Returns ------- diff --git a/modopt/math/matrix.py b/modopt/math/matrix.py index be737f52..939cf41f 100644 --- a/modopt/math/matrix.py +++ b/modopt/math/matrix.py @@ -16,7 +16,7 @@ def gram_schmidt(matrix, return_opt='orthonormal'): - """Gram-Schmit. + r"""Gram-Schmit. This method orthonormalizes the row vectors of the input matrix. @@ -25,12 +25,14 @@ def gram_schmidt(matrix, return_opt='orthonormal'): matrix : numpy.ndarray Input matrix array return_opt : {'orthonormal', 'orthogonal', 'both'} - Option to return u, e or both, (default is 'orthonormal') + Option to return :math:`\mathbf{u}`, :math:`\mathbf{e}` or both + (default is ``'orthonormal'``) Returns ------- tuple or numpy.ndarray - Orthogonal vectors, u, and/or orthonormal vectors, e + Orthogonal vectors, :math:`\mathbf{u}`, and/or orthonormal vectors, + :math:`\mathbf{e}` Raises ------ @@ -124,7 +126,8 @@ def nuclear_norm(input_data): def project(u_vec, v_vec): r"""Project vector. - This method projects vector v onto vector u. + This method projects vector :math:`\mathbf{v}` onto vector + :math:`\mathbf{u}`. Parameters ---------- @@ -259,11 +262,11 @@ class PowerMethod(object): """Power method class. This method performs implements power method to calculate the spectral - radius of the input data + radius of the input data. Parameters ---------- - operator : function + operator : callable Operator function data_shape : tuple Shape of the data array @@ -313,9 +316,10 @@ def __init__( self.get_spec_rad() def _set_initial_x(self): - """Set initial value of x. + """Set initial value of :math:`x`. - This method sets the initial value of x to an arrray of random values + This method sets the initial value of :math:`x` to an arrray of random + values. Returns ------- diff --git a/modopt/math/stats.py b/modopt/math/stats.py index 858e0ade..3ac818a7 100644 --- a/modopt/math/stats.py +++ b/modopt/math/stats.py @@ -21,7 +21,7 @@ def gaussian_kernel(data_shape, sigma, norm='max'): """Gaussian kernel. - This method produces a Gaussian kerenal of a specified size and dispersion + This method produces a Gaussian kerenal of a specified size and dispersion. Parameters ---------- @@ -30,8 +30,8 @@ def gaussian_kernel(data_shape, sigma, norm='max'): sigma : float Standard deviation of the kernel norm : {'max', 'sum', 'none'}, optional - Normalisation of the kerenl (options are 'max', 'sum' or 'none', - default is 'max') + Normalisation of the kerenl (options are ``'max'``, ``'sum'`` or + ``'none'``, default is ``'max'``) Returns ------- @@ -150,8 +150,8 @@ def mse(data1, data2): def psnr(data1, data2, method='starck', max_pix=255): r"""Peak Signal-to-Noise Ratio. - This method calculates the Peak Signal-to-Noise Ratio between an two data - sets + This method calculates the Peak Signal-to-Noise Ratio between two data + sets. Parameters ---------- @@ -160,7 +160,7 @@ def psnr(data1, data2, method='starck', max_pix=255): data2 : numpy.ndarray Second data set method : {'starck', 'wiki'}, optional - PSNR implementation (default is 'starck') + PSNR implementation (default is ``'starck'``) max_pix : int, optional Maximum number of pixels (default is ``255``) @@ -187,11 +187,11 @@ def psnr(data1, data2, method='starck', max_pix=255): Notes ----- - 'starck': + ``'starck'``: Implements eq.3.7 from :cite:`starck2010` - 'wiki': + ``'wiki'``: Implements PSNR equation on https://en.wikipedia.org/wiki/Peak_signal-to-noise_ratio @@ -235,7 +235,7 @@ def psnr_stack(data1, data2, metric=np.mean, method='starck'): The desired metric to be applied to the PSNR values (default is ``numpy.mean``) method : {'starck', 'wiki'}, optional - PSNR implementation (default is 'starck') + PSNR implementation (default is ``'starck'``) Returns ------- diff --git a/modopt/opt/algorithms/__init__.py b/modopt/opt/algorithms/__init__.py index aa645e47..e0ac2572 100644 --- a/modopt/opt/algorithms/__init__.py +++ b/modopt/opt/algorithms/__init__.py @@ -3,42 +3,45 @@ This module contains class implementations of various optimisation algoritms. -:Authors: Samuel Farrens , - Zaccharie Ramzi +:Authors: + +* Samuel Farrens , +* Zaccharie Ramzi , +* Pierre-Antoine Comby :Notes: Input classes must have the following properties: - * **Gradient Operators** +* **Gradient Operators** - Must have the following methods: +Must have the following methods: - * ``get_grad()`` - calculate the gradient + * ``get_grad()`` - calculate the gradient - Must have the following variables: +Must have the following variables: - * ``grad`` - the gradient + * ``grad`` - the gradient - * **Linear Operators** +* **Linear Operators** - Must have the following methods: +Must have the following methods: - * ``op()`` - operator - * ``adj_op()`` - adjoint operator + * ``op()`` - operator + * ``adj_op()`` - adjoint operator - * **Proximity Operators** +* **Proximity Operators** - Must have the following methods: +Must have the following methods: - * ``op()`` - operator + * ``op()`` - operator The following notation is used to implement the algorithms: - * x_old is used in place of :math:`x_{n}`. - * x_new is used in place of :math:`x_{n+1}`. - * x_prox is used in place of :math:`\tilde{x}_{n+1}`. - * x_temp is used for intermediate operations. + * ``x_old`` is used in place of :math:`x_{n}`. + * ``x_new`` is used in place of :math:`x_{n+1}`. + * ``x_prox`` is used in place of :math:`\tilde{x}_{n+1}`. + * ``x_temp`` is used for intermediate operations. """ diff --git a/modopt/opt/algorithms/base.py b/modopt/opt/algorithms/base.py index 9191642e..85c36306 100644 --- a/modopt/opt/algorithms/base.py +++ b/modopt/opt/algorithms/base.py @@ -36,8 +36,9 @@ class SetUp(Observable): See Also -------- - modopt.base.observable.MetricObserver : - Definition of Metrics. + modopt.base.observable.Observable : parent class + modopt.base.observable.MetricObserver : definition of metrics + """ def __init__( @@ -84,7 +85,7 @@ def __init__( @property def metrics(self): - """Metrics.""" + """Set metrics dictionary.""" return self._metrics @metrics.setter @@ -102,7 +103,7 @@ def metrics(self, metrics): def any_convergence_flag(self): """Check convergence flag. - Return if any matrices values matched the convergence criteria. + Retur True if any matrix values matched the convergence criteria. Returns ------- @@ -182,7 +183,7 @@ def _check_param_update(self, param_update): Parameters ---------- - param_update : function + param_update : callable Callable function Raises @@ -239,7 +240,7 @@ def _iterations(self, max_iter, progbar=None): ---------- max_iter : int Maximum number of iterations - progbar : progressbar.ProgressBar + progbar : progressbar.bar.ProgressBar Progress bar (default is ``None``) """ @@ -281,6 +282,10 @@ def _run_alg(self, max_iter): max_iter : int Maximum number of iterations + See Also + -------- + progressbar.bar.ProgressBar + """ if self.progress: with ProgressBar( diff --git a/modopt/opt/algorithms/forward_backward.py b/modopt/opt/algorithms/forward_backward.py index 5c6fb404..e18f66c3 100644 --- a/modopt/opt/algorithms/forward_backward.py +++ b/modopt/opt/algorithms/forward_backward.py @@ -10,44 +10,44 @@ class FISTA(object): - """FISTA. + r"""FISTA. This class is inherited by optimisation classes to speed up convergence The parameters for the modified FISTA are as described in :cite:`liang2018` - (p, q, r)_lazy or in :cite:`chambolle2015` (a_cd). + :math:`(p, q, r)`-lazy or in :cite:`chambolle2015` (a_cd). The restarting strategies are those described in :cite:`liang2018`, algorithms 4-5. Parameters ---------- restart_strategy: str or None - name of the restarting strategy. If None, there is no restarting. - (Default is ``None``) + Name of the restarting strategy, if ``None``, there is no restarting + (default is ``None``) min_beta: float or None - the minimum beta when using the greedy restarting strategy. - (Default is ``None``) + The minimum :math:`\beta` value when using the greedy restarting + strategy (default is ``None``) s_greedy: float or None - parameter for the safeguard comparison in the greedy restarting - strategy. It has to be > 1. - (Default is ``None``) + Parameter for the safeguard comparison in the greedy restarting + strategy, it must be > 1 + (default is ``None``) xi_restart: float or None - mutlitplicative parameter for the update of beta in the greedy + Mutlitplicative parameter for the update of beta in the greedy restarting strategy and for the update of r_lazy in the adaptive - restarting strategies. It has to be > 1. - (Default is None) + restarting strategies, it must be > 1 + (default is ``None``) a_cd: float or None - parameter for the update of lambda in Chambolle-Dossal mode. If None - the mode of the algorithm is the regular FISTA, else the mode is - Chambolle-Dossal. It has to be > 2. + Parameter for the update of lambda in Chambolle-Dossal mode, if + ``None`` the mode of the algorithm is the regular FISTA, else the mode + is Chambolle-Dossal, it must be > 2 p_lazy: float - parameter for the update of lambda in Fista-Mod. It has to be in - ]0, 1]. + Parameter for the update of lambda in Fista-Mod, it must satisfy + :math:`p \in ]0, 1]` q_lazy: float - parameter for the update of lambda in Fista-Mod. It has to be in - ]0, (2-p)**2]. + Parameter for the update of lambda in Fista-Mod, it must satisfy + :math:`q \in ]0, (2-p)^2]` r_lazy: float - parameter for the update of lambda in Fista-Mod. It has to be in - ]0, 4]. + Parameter for the update of lambda in Fista-Mod, it must satisfy + :math:`r \in ]0, 4]` """ @@ -118,7 +118,7 @@ def _check_restart_params( s_greedy, xi_restart, ): - """Check restarting parameters. + r"""Check restarting parameters. This method checks that the restarting parameters are set and satisfy the correct assumptions. It also checks that the current mode is @@ -127,25 +127,23 @@ def _check_restart_params( Parameters ---------- restart_strategy: str or None - name of the restarting strategy. If None, there is no restarting. - (Default is ``None``) + Name of the restarting strategy, if ``None``, there is no + restarting (default is ``None``) min_beta: float or None - the minimum beta when using the greedy restarting strategy. - (Default is ``None``) + The minimum :math:`\beta` value when using the greedy restarting + strategy (default is ``None``) s_greedy: float or None - parameter for the safeguard comparison in the greedy restarting - strategy. It has to be > 1. - (Default is ``None``) + Parameter for the safeguard comparison in the greedy restarting + strategy, it must be > 1 (default is ``None``) xi_restart: float or None - mutlitplicative parameter for the update of beta in the greedy + Mutlitplicative parameter for the update of beta in the greedy restarting strategy and for the update of r_lazy in the adaptive - restarting strategies. It has to be > 1. - (Default is ``None``) + restarting strategies, it must be > 1 (default is ``None``) Returns ------- bool - True + ``True`` Raises ------ @@ -177,21 +175,21 @@ def _check_restart_params( return True def is_restart(self, z_old, x_new, x_old): - """Check whether the algorithm needs to restart. + r"""Check whether the algorithm needs to restart. This method implements the checks necessary to tell whether the algorithm needs to restart depending on the restarting strategy. It also updates the FISTA parameters according to the restarting - strategy (namely beta and r). + strategy (namely :math:`\beta` and :math:`r`). Parameters ---------- z_old: numpy.ndarray - Corresponds to y_n in :cite:`liang2018`. + Corresponds to :math:`y_n` in :cite:`liang2018`. x_new: numpy.ndarray - Corresponds to x_{n+1} in :cite:`liang2018`. + Corresponds to :math:`x_{n+1}`` in :cite:`liang2018`. x_old: numpy.ndarray - Corresponds to x_n in :cite:`liang2018`. + Corresponds to :math:`x_n` in :cite:`liang2018`. Returns ------- @@ -200,8 +198,8 @@ def is_restart(self, z_old, x_new, x_old): Notes ----- - Implements restarting and safeguarding steps in alg 4-5 o - :cite:`liang2018` + Implements restarting and safeguarding steps in algorithms 4-5 of + :cite:`liang2018`. """ xp = backend.get_array_module(x_new) @@ -227,20 +225,20 @@ def is_restart(self, z_old, x_new, x_old): return criterion def update_beta(self, beta): - """Update beta. + r"""Update :math:`\beta`. - This method updates beta only in the case of safeguarding (should only - be done in the greedy restarting strategy). + This method updates :math:`\beta` only in the case of safeguarding + (should only be done in the greedy restarting strategy). Parameters ---------- beta: float - The beta parameter + The :math:`\beta` parameter Returns ------- float - The new value for the beta parameter + The new value for the :math:`\beta` parameter """ if self._safeguard: @@ -250,25 +248,25 @@ def update_beta(self, beta): return beta def update_lambda(self, *args, **kwargs): - """Update lambda. + r"""Update :math:`\lambda`. - This method updates the value of lambda + This method updates the value of :math:`\lambda`. Parameters ---------- - args : interable + *args : tuple Positional arguments - kwargs : dict + **kwargs : dict Keyword arguments Returns ------- float - Current lambda value + Current :math:`\lambda` value Notes ----- - Implements steps 3 and 4 from algoritm 10.7 in :cite:`bauschke2009` + Implements steps 3 and 4 from algoritm 10.7 in :cite:`bauschke2009`. """ if self.restart_strategy == 'greedy': @@ -289,29 +287,30 @@ def update_lambda(self, *args, **kwargs): class ForwardBackward(SetUp): - """Forward-Backward optimisation. + r"""Forward-Backward optimisation. This class implements standard forward-backward optimisation with an the - option to use the FISTA speed-up + option to use the FISTA speed-up. Parameters ---------- x : numpy.ndarray Initial guess for the primal variable - grad : class - Gradient operator class - prox : class - Proximity operator class - cost : class or str, optional - Cost function class (default is 'auto'); Use 'auto' to automatically - generate a costObj instance + grad + Gradient operator class instance + prox + Proximity operator class instance + cost : class instance or str, optional + Cost function class instance (default is ``'auto'``); Use ``'auto'`` to + automatically generate a ``costObj`` instance beta_param : float, optional - Initial value of the beta parameter (default is ``1.0``) + Initial value of the beta parameter, :math:`\beta` (default is ``1.0``) lambda_param : float, optional - Initial value of the lambda parameter (default is ```1.0``) - beta_update : function, optional + Initial value of the lambda parameter, :math:`\lambda` + (default is ```1.0``) + beta_update : callable, optional Beta parameter update method (default is ``None``) - lambda_update : function or str, optional + lambda_update : callable or str, optional Lambda parameter update method (default is 'fista') auto_iterate : bool, optional Option to automatically begin iterations upon initialisation (default @@ -319,20 +318,24 @@ class ForwardBackward(SetUp): Notes ----- - The `beta_param` can also be set using the keyword `step_size`, which will - override the value of `beta_param`. + The ``beta_param`` can also be set using the keyword ``step_size``, which + will override the value of ``beta_param``. The following state variable are available for metrics measurememts at each iteration : - * `x_new` : new estimate of x - * `z_new` : new estimate of z (adjoint representation of x). - * `idx` : index of the iteration. + * ``'x_new'`` : new estimate of :math:`x` + * ``'z_new'`` : new estimate of :math:`z` (adjoint representation of + :math:`x`). + * ``'idx'`` : index of the iteration. See Also -------- FISTA : complementary class - SetUp : parent class + modopt.opt.algorithms.base.SetUp : parent class + modopt.opt.cost.costObj : cost object class + modopt.opt.gradient : gradient operator classes + modopt.opt.proximity : proximity operator classes """ @@ -415,7 +418,7 @@ def _update_param(self): """Update parameters. This method updates the values of the algorthm parameters with the - methods provided + methods provided. """ # Update the gamma parameter. @@ -429,11 +432,11 @@ def _update_param(self): def _update(self): """Update. - This method updates the current reconstruction + This method updates the current reconstruction. Notes ----- - Implements algorithm 10.7 (or 10.5) from :cite:`bauschke2009` + Implements algorithm 10.7 (or 10.5) from :cite:`bauschke2009`. """ # Step 1 from alg.10.7. @@ -467,8 +470,8 @@ def _update(self): def iterate(self, max_iter=150): """Iterate. - This method calls update until either convergence criteria is met or - the maximum number of iterations is reached + This method calls update until either the convergence criteria is met + or the maximum number of iterations is reached. Parameters ---------- @@ -504,8 +507,8 @@ def get_notify_observers_kwargs(self): def retrieve_outputs(self): """Retireve outputs. - Declare the outputs of the algorithms as attributes: x_final, - y_final, metrics. + Declare the outputs of the algorithms as attributes: ``x_final``, + ``y_final``, ``metrics``. """ metrics = {} @@ -515,28 +518,30 @@ def retrieve_outputs(self): class GenForwardBackward(SetUp): - """Generalized Forward-Backward Algorithm. + r"""Generalized Forward-Backward Algorithm. - This class implements algorithm 1 from :cite:`raguet2011` + This class implements algorithm 1 from :cite:`raguet2011`. Parameters ---------- x : list, tuple or numpy.ndarray Initial guess for the primal variable - grad : class instance + grad Gradient operator class prox_list : list List of proximity operator class instances - cost : class or str, optional - Cost function class (default is 'auto'); Use 'auto' to automatically - generate a costObj instance + cost : class instance or str, optional + Cost function class instance (default is ``'auto'``); Use ``'auto'`` to + automatically generate a ``costObj`` instance gamma_param : float, optional - Initial value of the gamma parameter (default is ``1.0``) + Initial value of the gamma parameter, :math:`\gamma` + (default is ``1.0``) lambda_param : float, optional - Initial value of the lambda parameter (default is ``1.0``) - gamma_update : function, optional + Initial value of the lambda parameter, :math:`\lambda` + (default is ``1.0``) + gamma_update : callable, optional Gamma parameter update method (default is ``None``) - lambda_update : function, optional + lambda_update : callable, optional Lambda parameter parameter update method (default is ``None``) weights : list, tuple or numpy.ndarray, optional Proximity operator weights (default is ``None``) @@ -546,19 +551,23 @@ class GenForwardBackward(SetUp): Notes ----- - The `gamma_param` can also be set using the keyword `step_size`, which will - override the value of `gamma_param`. + The ``gamma_param`` can also be set using the keyword ``step_size``, which + will override the value of ``gamma_param``. The following state variable are available for metrics measurememts at each iteration : - * `x_new` : new estimate of x - * `z_new` : new estimate of z (adjoint representation of x). - * `idx` : index of the iteration. + * ``'x_new'`` : new estimate of :math:`x` + * ``'z_new'`` : new estimate of :math:`z` (adjoint representation of + :math:`x`). + * ``'idx'`` : index of the iteration. See Also -------- - SetUp : parent class + modopt.opt.algorithms.base.SetUp : parent class + modopt.opt.cost.costObj : cost object class + modopt.opt.gradient : gradient operator classes + modopt.opt.proximity : proximity operator classes """ @@ -642,7 +651,7 @@ def __init__( def _set_weights(self, weights): """Set weights. - This method sets weights on each of the proximty operators provided + This method sets weights on each of the proximty operators provided. Parameters ---------- @@ -690,7 +699,7 @@ def _update_param(self): """Update parameters. This method updates the values of the algorthm parameters with the - methods provided + methods provided. """ # Update the gamma parameter. @@ -704,11 +713,11 @@ def _update_param(self): def _update(self): """Update. - This method updates the current reconstruction + This method updates the current reconstruction. Notes ----- - Implements algorithm 1 from :cite:`raguet2011` + Implements algorithm 1 from :cite:`raguet2011`. """ # Calculate gradient for current iteration. @@ -781,8 +790,8 @@ def get_notify_observers_kwargs(self): def retrieve_outputs(self): """Retrieve outputs. - Declare the outputs of the algorithms as attributes: x_final, - y_final, metrics. + Declare the outputs of the algorithms as attributes: ``x_final``, + ``y_final``, ``metrics``. """ metrics = {} @@ -792,58 +801,63 @@ def retrieve_outputs(self): class POGM(SetUp): - """Proximal Optimised Gradient Method. + r"""Proximal Optimised Gradient Method. - This class implements algorithm 3 from :cite:`kim2017` + This class implements algorithm 3 from :cite:`kim2017`. Parameters ---------- u : numpy.ndarray - Initial guess for the u variable + Initial guess for the :math:`u` variable x : numpy.ndarray - Initial guess for the x variable (primal) + Initial guess for the :math:`x` variable (primal) y : numpy.ndarray - Initial guess for the y variable + Initial guess for the :math:`y` variable z : numpy.ndarray - Initial guess for the z variable - grad : class + Initial guess for the :math:`z` variable + grad Gradient operator class - prox : class + prox Proximity operator class - cost : class or str, optional - Cost function class (default is 'auto'); Use 'auto' to automatically - generate a costObj instance + cost : class instance or str, optional + Cost function class instance (default is ``'auto'``); Use ``'auto'`` to + automatically generate a ``costObj`` instance linear : class instance, optional - Linear operator class (default is ``None``) + Linear operator class instance (default is ``None``) beta_param : float, optional - Initial value of the beta parameter (default is ``1.0``). + Initial value of the beta parameter, :math:`\beta` (default is ``1.0``) This corresponds to (1 / L) in :cite:`kim2017` sigma_bar : float, optional - Value of the shrinking parameter sigma bar (default is ``1.0``) + Value of the shrinking parameter, :math:`\bar{\sigma}` + (default is ``1.0``) auto_iterate : bool, optional Option to automatically begin iterations upon initialisation (default is ``True``) Notes ----- - The `beta_param` can also be set using the keyword `step_size`, which will - override the value of `beta_param`. + The ``beta_param`` can also be set using the keyword ``step_size``, which + will override the value of ``beta_param``. The following state variable are available for metrics measurememts at each iterations: - * `u_new` : new estimate of u - * `x_new` : new estimate of x - * `y_new` : new estimate of y - * `z_new` : new estimate of z - * `xi`: xi variable - * `t` : new estimate of t - * `sigma`: sigma variable - * `idx` : index of the iteration. + * ``'u_new'`` : new estimate of :math:`u` + * ``'x_new'`` : new estimate of :math:`x` + * ``'y_new'`` : new estimate of :math:`y` + * ``'z_new'`` : new estimate of :math:`z` + * ``'xi'``: :math:`\xi` variable + * ``'t'`` : new estimate of :math:`t` + * ``'sigma'``: :math:`\sigma` variable + * ``'idx'`` : index of the iteration. See Also -------- - SetUp : parent class + modopt.opt.algorithms.base.SetUp : parent class + modopt.opt.cost.costObj : cost object class + modopt.opt.gradient : gradient operator classes + modopt.opt.proximity : proximity operator classes + modopt.opt.linear : linear operator classes """ @@ -919,11 +933,11 @@ def __init__( def _update(self): """Update. - This method updates the current reconstruction + This method updates the current reconstruction. Notes ----- - Implements algorithm 3 from :cite:`kim2017` + Implements algorithm 3 from :cite:`kim2017`. """ # Step 4 from alg. 3 @@ -1026,8 +1040,8 @@ def get_notify_observers_kwargs(self): def retrieve_outputs(self): """Retrieve outputs. - Declare the outputs of the algorithms as attributes: x_final, - y_final, metrics. + Declare the outputs of the algorithms as attributes: ``x_final``, + ``y_final``, ``metrics``. """ metrics = {} diff --git a/modopt/opt/algorithms/gradient_descent.py b/modopt/opt/algorithms/gradient_descent.py index 8d526674..f3fe4b10 100644 --- a/modopt/opt/algorithms/gradient_descent.py +++ b/modopt/opt/algorithms/gradient_descent.py @@ -10,33 +10,36 @@ class GenericGradOpt(SetUp): r"""Generic Gradient descent operator. - Performs the descent algorithm in the direction m_k at speed s_k. + Performs the descent algorithm in the direction :math:`m_k` at speed + :math:`s_k`. Parameters ---------- - x: ndarray + x: numpy.ndarray Initial value - grad: Instance of GradBase - Gradient operator - prox: Instance of ProximalOperator - Proximal operator, - linear: Instance of OperatorBase - Linear operator (the image domain should be sparse) - cost: Instance of costObj - Cost Operator - eta: float, default 1.0 - Descent step - eta_update: callable, default None - If not None, used to update eta at each step. - epsilon: float, default 1e-6 - Numerical stability constant for the gradient. - epoch_size, int, default 1 - Size of epoch for the descent. - metric_call_period: int, default 5 - The period of iteration on which metrics will be computed. - metrics: dict, default None - If not None, specify which metrics to use. + grad + Gradient operator class instance + prox + Proximity operator class instance + cost : class instance or str, optional + Cost function class instance (default is ``'auto'``); Use ``'auto'`` to + automatically generate a ``costObj`` instance + eta: float + Descent step, :math:`\eta` (default is ``1.0``) + eta_update: callable + If not ``None``, used to update :math:`\eta` at each step + (default is ``None``) + epsilon: float + Numerical stability constant for the gradient, :math:`\epsilon` + (default is ``1e-6``) + epoch_size: int + Size of epoch for the descent (default is ``1``) + metric_call_period: int + The period of iteration on which metrics will be computed + (default is ``5``) + metrics: dict + If not None, specify which metrics to use (default is ``None``) Notes ----- @@ -50,19 +53,19 @@ class GenericGradOpt(SetUp): * :math:`\eta` is the gradient descent step * :math:`s_k` is the gradient "speed" - At each Epoch, an optional Proximal step can be performed. The following state variable are available for metrics measurememts: - * `x_new` : new estimate of the iterations - * `dir_grad` : direction of the gradient descent step - * `speed_grad` : speed for the gradient descent step - * `idx` : index of the iteration being reconstructed. + * ``'x_new'`` : new estimate of the iterations + * ``'dir_grad'`` : direction of the gradient descent step + * ``'speed_grad'`` : speed for the gradient descent step + * ``'idx'`` : index of the iteration being reconstructed. See Also -------- modopt.opt.algorithms.base.SetUp : parent class + modopt.opt.cost.costObj : cost object class """ @@ -136,6 +139,11 @@ def iterate(self, max_iter=150): self.x_final = self._x_new def _update(self): + """Update. + + This method updates the current reconstruction. + + """ self._grad.get_grad(self._x_old) self._update_grad_dir(self._grad.grad) self._update_grad_speed(self._grad.grad) @@ -159,8 +167,9 @@ def _update_grad_dir(self, grad): Parameters ---------- - grad: ndarray - The gradien direction + grad: numpy.ndarray + The gradient direction + """ self._dir_grad = grad @@ -169,8 +178,8 @@ def _update_grad_speed(self, grad): Parameters ---------- - grad: ndarray - The gradien direction + grad: numpy.ndarray + The gradient direction """ pass @@ -180,8 +189,9 @@ def _update_reg(self, factor): Parameters ---------- - factor: float or array_like - extra factor for the proximal step. + factor: float or numpy.ndarray + Extra factor for the proximal step + """ self._x_new = self._prox.op(self._x_new, extra_factor=factor) @@ -193,7 +203,7 @@ def get_notify_observers_kwargs(self): Returns ------- - notify_observers_kwargs : dict, + dict The mapping between the iterated variables """ @@ -229,6 +239,7 @@ class VanillaGenericGradOpt(GenericGradOpt): See Also -------- GenericGradOpt : parent class + """ def __init__(self, *args, **kwargs): @@ -252,6 +263,7 @@ class AdaGenericGradOpt(GenericGradOpt): See Also -------- GenericGradOpt : parent class + """ def _update_grad_speed(self, grad): @@ -259,8 +271,8 @@ def _update_grad_speed(self, grad): Parameters ---------- - grad: ndarray - The new gradient for updating the speed. + grad: numpy.ndarray + The new gradient for updating the speed """ self._speed_grad += abs(grad) ** 2 @@ -271,24 +283,26 @@ class RMSpropGradOpt(GenericGradOpt): Parameters ---------- - gamma: float, default 0.5 - Update weight for the speed of descent. + gamma: float + Update weight for the speed of descent, :math:`\gamma` + (default is ``0.5``) Raises ------ ValueError - If gamma is outside ]0,1[ + If :math:`\gamma` is outside :math:`]0,1[` Notes ----- The gradient speed of RMSProp (Section 4.5 of :cite:`ruder2017`) is - defined as : + defined as: .. math:: s_k = \gamma s_{k-1} + (1-\gamma) * |\nabla f|^2 See Also -------- GenericGradOpt : parent class + """ def __init__(self, *args, gamma=0.5, **kwargs): @@ -299,7 +313,14 @@ def __init__(self, *args, gamma=0.5, **kwargs): self._gamma = gamma def _update_grad_speed(self, grad): - """Rmsprop update speed.""" + """Rmsprop update speed. + + Parameters + ---------- + grad: numpy.ndarray + The new gradient for updating the speed + + """ self._speed_grad = ( self._gamma * self._speed_grad + (1 - self._gamma) * abs(grad) ** 2 ) @@ -310,18 +331,19 @@ class MomentumGradOpt(GenericGradOpt): Parameters ---------- - beta: float, default 0.9 - update weight for the momentum. + beta: float + update weight for the momentum, :math:`\beta` (default is ``0.9``) Notes ----- - The Momentum (Section 4.1 of :cite:`ruder2017` update is defined as: + The Momentum (Section 4.1 of :cite:`ruder2017`) update is defined as: .. math:: m_k = \beta * m_{k-1} + \nabla f(x_k) See Also -------- GenericGradOpt : parent class + """ def __init__(self, *args, beta=0.9, **kwargs): @@ -333,7 +355,14 @@ def __init__(self, *args, beta=0.9, **kwargs): self._eps = 0 def _update_grad_dir(self, grad): - """Momentum gradient direction update.""" + """Momentum gradient direction update. + + Parameters + ---------- + grad: numpy.ndarray + The new gradient for updating the speed + + """ self._dir_grad = self._beta * self._dir_grad + grad def reset(self): @@ -347,18 +376,18 @@ class ADAMGradOpt(GenericGradOpt): Parameters ---------- gamma: float - update weight for the direction in ]0,1[ + Update weight, :math:`\gamma`, for the direction in :math:`]0,1[` beta: float - update weight for the speed in ]0,1[ + Update weight, :math:`\beta`, for the speed in :math:`]0,1[` Raises ------ ValueError - If gamma or beta is outside ]0,1[ + If gamma or beta is outside :math:`]0,1[` Notes ----- - The ADAM optimizer (Section 4.6 of :cite:`ruder2017` is defined as: + The ADAM optimizer (Section 4.6 of :cite:`ruder2017`) is defined as: .. math:: m_{k+1} = \frac{1}{1-\beta^k}(\beta*m_{k}+(1-\beta)*|\nabla f_k|^2) @@ -368,6 +397,7 @@ class ADAMGradOpt(GenericGradOpt): See Also -------- GenericGradOpt : parent class + """ def __init__(self, *args, gamma=0.9, beta=0.9, **kwargs): @@ -402,16 +432,17 @@ def _update_grad_speed(self, grad): class SAGAOptGradOpt(GenericGradOpt): """SAGA optimizer. - Implements equation (7) of :cite:`defazio2014` + Implements equation 7 of :cite:`defazio2014`. Notes ----- The stochastic part is not handled here, and should be implemented by - changing the obs_data between each call to the _update function. + changing the ``obs_data`` between each call to the ``_update`` function. See Also -------- GenericGradOpt : parent class + """ def __init__(self, *args, **kwargs): @@ -422,7 +453,14 @@ def __init__(self, *args, **kwargs): ) def _update_grad_dir(self, grad): - """SAGA Update gradient direction.""" + """SAGA Update gradient direction. + + Parameters + ---------- + grad: numpy.ndarray + The new gradient for updating the speed + + """ cycle = self.idx % self.epoch_size self._dir_grad = self._dir_grad - self._grad_memory[cycle] + grad self._grad_memory[cycle] = grad diff --git a/modopt/opt/algorithms/primal_dual.py b/modopt/opt/algorithms/primal_dual.py index 8ba0630f..c8566969 100644 --- a/modopt/opt/algorithms/primal_dual.py +++ b/modopt/opt/algorithms/primal_dual.py @@ -7,9 +7,9 @@ class Condat(SetUp): - """Condat optimisation. + r"""Condat optimisation. - This class implements algorithm 3.1 from :cite:`condat2013` + This class implements algorithm 3.1 from :cite:`condat2013`. Parameters ---------- @@ -17,30 +17,30 @@ class Condat(SetUp): Initial guess for the primal variable y : numpy.ndarray Initial guess for the dual variable - grad : class instance - Gradient operator class - prox : class instance - Proximity primal operator class - prox_dual : class instance - Proximity dual operator class + grad + Gradient operator class instance + prox + Proximity primal operator class instance + prox_dual + Proximity dual operator class instance linear : class instance, optional - Linear operator class (default is ``None``) - cost : class or str, optional - Cost function class (default is 'auto'); Use 'auto' to automatically - generate a costObj instance + Linear operator class instance (default is ``None``) + cost : class instance or str, optional + Cost function class instance (default is ``'auto'``); Use ``'auto'`` to + automatically generate a ``costObj`` instance reweight : class instance, optional - Reweighting class + Reweighting class instance rho : float, optional - Relaxation parameter (default is ``0.5``) + Relaxation parameter, :math:`\rho` (default is ``0.5``) sigma : float, optional - Proximal dual parameter (default is ``1.0``) + Proximal dual parameter, :math:`\sigma` (default is ``1.0``) tau : float, optional - Proximal primal paramater (default is ``1.0``) - rho_update : function, optional + Proximal primal paramater, :math:`\tau` (default is ``1.0``) + rho_update : callable, optional Relaxation parameter update method (default is ``None``) - sigma_update : function, optional + sigma_update : callable, optional Proximal dual parameter update method (default is ``None``) - tau_update : function, optional + tau_update : callable, optional Proximal primal parameter update method (default is ``None``) auto_iterate : bool, optional Option to automatically begin iterations upon initialisation (default @@ -52,12 +52,24 @@ class Condat(SetUp): Notes ----- - The `tau_param` can also be set using the keyword `step_size`, which will - override the value of `tau_param`. + The ``tau_param`` can also be set using the keyword `step_size`, which will + override the value of ``tau_param``. + + The following state variable are available for metrics measurememts at + each iteration : + + * ``'x_new'`` : new estimate of :math:`x` (primal variable) + * ``'y_new'`` : new estimate of :math:`y` (dual variable) + * ``'idx'`` : index of the iteration. See Also -------- - SetUp : parent class + modopt.opt.algorithms.base.SetUp : parent class + modopt.opt.cost.costObj : cost object class + modopt.opt.gradient : gradient operator classes + modopt.opt.proximity : proximity operator classes + modopt.opt.linear : linear operator classes + modopt.opt.reweight : reweighting classes """ @@ -144,7 +156,7 @@ def _update_param(self): """Update parameters. This method updates the values of the algorthm parameters with the - methods provided + methods provided. """ # Update relaxation parameter. @@ -162,13 +174,13 @@ def _update_param(self): def _update(self): """Update. - This method updates the current reconstruction + This method updates the current reconstruction. Notes ----- - Implements equation 9 (algorithm 3.1) from :cite:`condat2013` + Implements equation 9 (algorithm 3.1) from :cite:`condat2013`. - - primal proximity operator set up for positivity constraint + - Primal proximity operator set up for positivity constraint. """ # Step 1 from eq.9. @@ -217,7 +229,7 @@ def iterate(self, max_iter=150, n_rewightings=1): """Iterate. This method calls update until either convergence criteria is met or - the maximum number of iterations is reached + the maximum number of iterations is reached. Parameters ---------- @@ -257,8 +269,8 @@ def get_notify_observers_kwargs(self): def retrieve_outputs(self): """Retrieve outputs. - Declare the outputs of the algorithms as attributes: x_final, - y_final, metrics. + Declare the outputs of the algorithms as attributes: ``x_final``, + ``y_final``, ``metrics``. """ metrics = {} diff --git a/modopt/opt/cost.py b/modopt/opt/cost.py index e5690831..3cdfcc50 100644 --- a/modopt/opt/cost.py +++ b/modopt/opt/cost.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """COST FUNCTIONS. This module contains classes of different cost functions for optimization. @@ -18,20 +16,20 @@ class costObj(object): """Generic cost function object. - This class updates the cost according to the input cost functio class and - tests for convergence + This class updates the cost according to the input operator classes and + tests for convergence. Parameters ---------- - costFunc : class - Class for calculating the cost + opertors : list, tuple or numpy.ndarray + List of operators classes containing ``cost`` method initial_cost : float, optional Initial value of the cost (default is ``1e6``) tolerance : float, optional Tolerance threshold for convergence (default is ``1e-4``) cost_interval : int, optional Iteration interval to calculate cost (default is ``1``). - If `cost_interval` is ``None`` the cost is never calculated, + If ``cost_interval`` is ``None`` the cost is never calculated, thereby saving on computation time. test_range : int, optional Number of cost values to be used in test (default is ``4``) @@ -42,7 +40,7 @@ class costObj(object): Notes ----- - The costFunc class must contain a method called `calc_cost()`. + The costFunc class must contain a method called ``cost``. Examples -------- @@ -96,16 +94,16 @@ def __init__( self._verbose = verbose def _check_operators(self): - """Check Operators. + """Check operators. - This method checks if the input operators have a `cost` method + This method checks if the input operators have a ``cost`` method. Raises ------ TypeError For invalid operators type ValueError - For operators without `cost` method + For operators without ``cost`` method """ if not isinstance(self._operators, (list, tuple, np.ndarray)): @@ -123,7 +121,8 @@ def _check_cost(self): """Check cost function. This method tests the cost function for convergence in the specified - interval of iterations using the last n (test_range) cost values + interval of iterations using the last :math:`n` (``test_range``) cost + values. Returns ------- @@ -175,15 +174,15 @@ def _calc_cost(self, *args, **kwargs): Parameters ---------- - args : interable + *args : tuple Positional arguments - kwargs : dict + **kwargs : dict Keyword arguments Returns ------- float - Cost + Cost value """ return np.sum([op.cost(*args, **kwargs) for op in self._operators]) @@ -195,9 +194,9 @@ def get_cost(self, *args, **kwargs): Parameters ---------- - args : interable + *args : tuple Positional arguments - kwargs : dict + **kwargs : dict Keyword arguments Returns @@ -238,7 +237,7 @@ def get_cost(self, *args, **kwargs): def plot_cost(self): # pragma: no cover """Plot the cost function. - This method plots the cost function as function of iteration number + This method plots the cost function as function of iteration number. """ plotCost(self._cost_list, self._plot_output) diff --git a/modopt/opt/gradient.py b/modopt/opt/gradient.py index b949a7eb..56004838 100644 --- a/modopt/opt/gradient.py +++ b/modopt/opt/gradient.py @@ -18,19 +18,19 @@ class GradParent(object): """Gradient Parent Class. This class defines the basic methods that will be inherited by specific - gradient classes + gradient classes. Parameters ---------- input_data : numpy.ndarray The observed data - op : function + op : callable The operator - trans_op : function + trans_op : callable The transpose operator - get_grad : function, optional + get_grad : callable, optional Method for calculating the gradient (default is ``None``) - cost: function, optional + cost: callable, optional Method for calculating the cost (default is ``None``) data_type : type, optional Expected data type of the input data (default is ``None``) @@ -82,7 +82,16 @@ def __init__( @property def obs_data(self): - """Observed Data.""" + r"""Observed Data. + + The observed data :math:`\mathbf{y}`. + + Returns + ------- + numpy.ndarray + The observed data + + """ return self._obs_data @obs_data.setter @@ -101,7 +110,16 @@ def obs_data(self, input_data): @property def op(self): - """Operator.""" + r"""Operator. + + The operator :math:`\mathbf{H}`. + + Returns + ------- + callable + The operator function + + """ return self._op @op.setter @@ -111,7 +129,16 @@ def op(self, operator): @property def trans_op(self): - """Transpose operator.""" + r"""Transpose operator. + + The transpose operator :math:`\mathbf{H}^T`. + + Returns + ------- + callable + The transpose operator function + + """ return self._trans_op @trans_op.setter @@ -155,7 +182,7 @@ def trans_op_op(self, input_data): r"""Transpose Operation of the Operator. This method calculates the action of the transpose operator on - the action of the operator on the data + the action of the operator on the data. Parameters ---------- @@ -174,6 +201,8 @@ def trans_op_op(self, input_data): .. math:: \mathbf{H}^T(\mathbf{H}\mathbf{x}) + where :math:`\mathbf{x}` is the ``input_data``. + """ return self.trans_op(self.op(input_data)) @@ -186,9 +215,9 @@ class GradBasic(GradParent): Parameters ---------- - args : interable + *args : tuple Positional arguments - kwargs : dict + **kwargs : dict Keyword arguments Examples @@ -218,7 +247,7 @@ def __init__(self, *args, **kwargs): def _get_grad_method(self, input_data): r"""Get the gradient. - This method calculates the gradient step from the input data + This method calculates the gradient step from the input data. Parameters ---------- @@ -239,13 +268,13 @@ def _cost_method(self, *args, **kwargs): """Calculate gradient component of the cost. This method returns the l2 norm error of the difference between the - original data and the data obtained after optimisation + original data and the data obtained after optimisation. Parameters ---------- - args : interable + *args : tuple Positional arguments - kwargs : dict + **kwargs : dict Keyword arguments Returns diff --git a/modopt/opt/linear.py b/modopt/opt/linear.py index e21ffac9..d8679998 100644 --- a/modopt/opt/linear.py +++ b/modopt/opt/linear.py @@ -21,9 +21,9 @@ class LinearParent(object): Parameters ---------- - op : function + op : callable Callable function that implements the linear operation - adj_op : function + adj_op : callable Callable function that implements the linear adjoint operation Examples @@ -91,11 +91,12 @@ class WaveletConvolve(LinearParent): filters: numpy.ndarray Array of wavelet filter coefficients method : str, optional - Convolution method (default is 'scipy') + Convolution method (default is ``'scipy'``) See Also -------- LinearParent : parent class + modopt.signal.wavelet.filter_convolve_stack : wavelet filter convolution """ @@ -168,8 +169,8 @@ def _check_type(self, input_val): Parameters ---------- - input_val : list, tuple or numpy.ndarray - Any input type + input_val : any + Any input object Returns ------- @@ -234,7 +235,7 @@ def _check_inputs(self, operators, weights): raise ValueError('Operators must contain "adj_op" method.') operator.op = check_callable(operator.op) - operator.cost = check_callable(operator.adj_op) + operator.adj_op = check_callable(operator.adj_op) if not isinstance(weights, type(None)): weights = self._check_type(weights) diff --git a/modopt/opt/proximity.py b/modopt/opt/proximity.py index 474d7395..e0f28e96 100644 --- a/modopt/opt/proximity.py +++ b/modopt/opt/proximity.py @@ -2,9 +2,12 @@ """PROXIMITY OPERATORS. -This module contains classes of proximity operators for optimisation +This module contains classes of proximity operators for optimisation. -:Author: Samuel Farrens +:Authors: + +* Samuel Farrens , +* Loubna El Gueddari """ @@ -35,9 +38,9 @@ class ProximityParent(object): Parameters ---------- - op : function + op : callable Callable function that implements the proximity operation - cost : function + cost : callable Callable function that implements the proximity contribution to the cost @@ -68,7 +71,7 @@ def cost(self): Returns ------- float - Cost + Cost contribution value """ return self._cost @@ -108,6 +111,7 @@ class Positivity(ProximityParent): See Also -------- ProximityParent : parent class + modopt.signal.positivity.positive : positivity operator """ @@ -119,14 +123,14 @@ def __init__(self): def _cost_method(self, *args, **kwargs): """Calculate positivity component of the cost. - This method returns 0 as the posivituty does not contribute to the + This method returns ``0`` as the posivituty does not contribute to the cost. Parameters ---------- - args : interable + *args : tuple Positional arguments - kwargs : dict + **kwargs : dict Keyword arguments Returns @@ -142,9 +146,9 @@ def _cost_method(self, *args, **kwargs): class SparseThreshold(ProximityParent): - """Threshold Proximity Operator. + """Sparse Threshold Proximity Operator. - This class defines the threshold proximity operator. + This class defines the sparse thresholding proximity operator. Parameters ---------- @@ -158,6 +162,7 @@ class SparseThreshold(ProximityParent): See Also -------- ProximityParent : parent class + modopt.signal.noise.thresh : thresholding function """ @@ -199,9 +204,9 @@ def _cost_method(self, *args, **kwargs): Parameters ---------- - args : interable + *args : tuple Positional arguments - kwargs : dict + **kwargs : dict Keyword arguments Returns @@ -252,6 +257,9 @@ class LowRankMatrix(ProximityParent): See Also -------- ProximityParent : parent class + modopt.signal.svd.svd_thresh : SVD thresholding function + modopt.signal.svd.svd_thresh_coef : SVD coefficient thresholding function + modopt.math.matrix.nuclear_norm : nuclear norm implementation """ @@ -318,9 +326,9 @@ def _cost_method(self, *args, **kwargs): Parameters ---------- - args : interable + *args : tuple Positional arguments - kwargs : dict + **kwargs : dict Keyword arguments Returns @@ -394,9 +402,9 @@ def _cost_method(self, *args, **kwargs): Parameters ---------- - args : interable + *args : tuple Positional arguments - kwargs : dict + **kwargs : dict Keyword arguments Returns @@ -528,9 +536,9 @@ def _cost_method(self, *args, **kwargs): Parameters ---------- - args : interable + *args : tuple Positional arguments - kwargs : dict + **kwargs : dict Keyword arguments Returns @@ -576,6 +584,7 @@ class OrderedWeightedL1Norm(ProximityParent): See Also -------- ProximityParent : parent class + sklearn.isotonic.isotonic_regression : isotonic regression implementation """ @@ -652,9 +661,9 @@ def _cost_method(self, *args, **kwargs): Parameters ---------- - args : interable + *args : tuple Positional arguments - kwargs : dict + **kwargs : dict Keyword arguments Returns @@ -736,9 +745,9 @@ def _cost_method(self, *args, **kwargs): Parameters ---------- - args : interable + *args : tuple Positional arguments - kwargs : dict + **kwargs : dict Keyword arguments Returns @@ -782,6 +791,7 @@ class ElasticNet(ProximityParent): See Also -------- ProximityParent : parent class + modopt.signal.noise.thresh : thresholding function """ @@ -823,9 +833,9 @@ def _cost_method(self, *args, **kwargs): Parameters ---------- - args : interable + *args : tuple Positional arguments - kwargs : dict + **kwargs : dict Keyword arguments Returns @@ -848,7 +858,7 @@ def _cost_method(self, *args, **kwargs): class KSupportNorm(ProximityParent): """K-support Norm Proximity Operator. - This class defines the squarred K-support norm proximity operator + This class defines the squarred :math:`k`-support norm proximity operator described in :cite:`mcdonald2014`. Parameters @@ -856,18 +866,20 @@ class KSupportNorm(ProximityParent): thresh : float Threshold value k_value : int - Hyper-parameter of the k-support norm, equivalent to the cardinality - value for the overlapping group lasso. k should included in - {1, ..., dim(input_vector)} + Hyper-parameter of the :math:`k`-support norm, equivalent to the + cardinality value for the overlapping group lasso. :math:`k` should be + included in {1, ..., dim(input_vector)}. Notes ----- - The k-support norm can be seen as an extension to the group-LASSO with - overlaps with groups of cardianlity at most equal to k. - When k = 1 the norm is equivalent to the L1-norm. - When k = dimension of the input vector than the norm is equivalent to the - L2-norm. - The dual of this norm correspond to the sum of the k biggest input entries. + The :math:`k`-support norm can be seen as an extension to the group-LASSO + with overlaps with groups of cardianlity at most equal to :math:`k`. + When :math:`k = 1` the norm is equivalent to the L1-norm. + When :math:`k` = dimension of the input vector than the norm is equivalent + to the L2-norm. + + The dual of this norm corresponds to the sum of the k biggest input + entries. Examples -------- @@ -897,7 +909,7 @@ def __init__(self, beta, k_value): @property def k_value(self): - """K value.""" + """Get the :math:`k` value.""" return self._k_value @k_value.setter @@ -929,7 +941,7 @@ def _compute_theta(self, input_data, alpha, extra_factor=1.0): input_data: numpy.ndarray Input data alpha: float - Parameter choosen such that sum(theta_i) = k + Parameter choosen such that :math:`\sum\theta_i = k` extra_factor: float Potential extra factor comming from the optimization process (default is ``1.0``) @@ -937,7 +949,8 @@ def _compute_theta(self, input_data, alpha, extra_factor=1.0): Returns ------- theta: numpy.ndarray - Same size as w and each component is equal to theta_i + Same size as :math:`w` and each component is equal to + :math:`theta_i` """ alpha_input = np.dot( @@ -952,26 +965,26 @@ def _compute_theta(self, input_data, alpha, extra_factor=1.0): return theta def _interpolate(self, alpha0, alpha1, sum0, sum1): - """Linear interpolation of alpha. + r"""Linear interpolation of alpha (:math:`\alpha`). - This method estimats alpha* such that sum(theta(alpha*))=k via a linear - interpolation. + This method estimats :math:`\alpha^*` such that + :math:`\sum\theta(\alpha^*)=k` via a linear interpolation. Parameters ----------- alpha0: float - A value for wich sum(theta(alpha0)) <= k + A value for wich :math:`\sum\theta(\alpha^0) \leq k` alpha1: float - A value for which sum(theta(alpha1)) <= k + A value for which :math:`\sum\theta(\alpha^1) \leq k` sum0: float - Value of sum(theta(alpha0)) + Value of :math:`\sum\theta(\alpha^0)` sum1: float - Value of sum(theta(alpha0)) + Value of :math:`\sum\theta(\alpha^1)` Returns ------- float - An interpolation for which sum(theta(alpha_star)) = k + An interpolation for which :math:`\sum\theta(\alpha^*) = k` """ if sum0 == self._k_value: @@ -986,16 +999,16 @@ def _interpolate(self, alpha0, alpha1, sum0, sum1): return (self._k_value - b_val) / slope def _binary_search(self, input_data, alpha, extra_factor=1.0): - """Binary search method. + r"""Binary search method. - This method finds the coordinate of alpha (i) such that - sum(theta(alpha[i])) =< k and sum(theta(alpha[i+1])) >= k via binary - search method + This method finds the coordinate of :math:`\alpha^i` such that + :math:`\sum\theta(\alpha^i) =< k` and + :math:`\sum\theta(\alpha^{i+1}) >= k` via a binary search method. Parameters ---------- input_data: numpy.ndarray - absolute value of the input data + Absolute value of the input data alpha: numpy.ndarray Array same size as the input data extra_factor: float @@ -1010,11 +1023,12 @@ def _binary_search(self, input_data, alpha, extra_factor=1.0): Returns ------- tuple - The index where: sum(theta(alpha[index])) <= k and - sum(theta(alpha[index+1])) >= k, The alpha value for which - sum(theta(alpha[index])) <= k, The alpha value for which - sum(theta(alpha[index+1])) >= k, Value of sum(theta(alpha[index])), - Value of sum(theta(alpha[index + 1])) + The index where: :math:`\sum\theta(\alpha^i) <= k` and + :math:`\sum\theta(\alpha^{i+1}) >= k`, The alpha value for which + :math:`\sum\theta(\alpha^i) <= k`, The alpha value for which + :math:`\sum\theta(\alpha^{i+1}) >= k`, Value of + :math:`\sum\theta(\alpha^i)`, + Value of :math:`\sum\theta(\alpha^{i + 1})` """ first_idx = 0 @@ -1199,11 +1213,9 @@ def _op_method(self, input_data, extra_factor=1.0): return rslt.reshape(data_shape) def _find_q(self, sorted_data): - """Find q index value. + r"""Find :math:`q`. - This method finds the value of q such that: - - sorted_data[q] >= sum(sorted_data[q+1:]) / (k - q)>= sorted_data[q+1] + Find the :math:`q` index value. Parameters ---------- @@ -1213,8 +1225,17 @@ def _find_q(self, sorted_data): Returns ------- int - index such that sorted_data[q] >= sum(sorted_data[q+1:]) / - (k - q)>= sorted_data[q+1] + The :math:`q` index value + + Notes + ----- + This method finds the value of :math:`q` such that: + + .. math:: + + |w_q| \geq \frac{\sum_{j=q+1}^d |w_j|}{k - q} \geq |w_{q+1}| + + where :math:`w` is the input ``sorted_data`` and :math:`k \leq d`. """ first_idx = 0 @@ -1258,21 +1279,23 @@ def _find_q(self, sorted_data): return q_val def _cost_method(self, *args, **kwargs): - """Calculate OWL component of the cost. + """Calculate :math:`k`-support component of the cost. - This method returns the ordered weighted l1 norm of the data. + This method returns the :math:`k`-support contribution to the total + cost. Parameters ---------- - args : interable + *args : tuple Positional arguments - kwargs : dict + **kwargs : dict Keyword arguments Returns ------- float - OWL cost component + The :math:`k`-support cost component + """ data_abs = np.abs(args[0].flatten()) ix = np.argsort(data_abs)[::-1] @@ -1280,7 +1303,7 @@ def _cost_method(self, *args, **kwargs): q_val = self._find_q(data_abs) cost_val = ( ( - np.sum(data_abs[:q_val]**2) * 0.5 + np.sum(data_abs[:q_val] ** 2) * 0.5 + np.sum(data_abs[q_val:]) ** 2 / (self._k_value - q_val) ) * self.beta @@ -1359,7 +1382,7 @@ def _op_method(self, input_data, extra_factor=1.0): ) def _cost_method(self, input_data): - """Cost function. + """Calculate the group LASSO component of the cost. This method calculate the cost function of the proximable part. diff --git a/modopt/opt/reweight.py b/modopt/opt/reweight.py index 444b65d5..8c4f2449 100644 --- a/modopt/opt/reweight.py +++ b/modopt/opt/reweight.py @@ -2,7 +2,7 @@ """REWEIGHTING CLASSES. -This module contains classes for reweighting optimisation implementations +This module contains classes for reweighting optimisation implementations. :Author: Samuel Farrens @@ -17,7 +17,7 @@ class cwbReweight(object): """Candes, Wakin and Boyd reweighting class. This class implements the reweighting scheme described in - :cite:`candes2007` + :cite:`candes2007`. Parameters ---------- @@ -56,7 +56,7 @@ def reweight(self, input_data): r"""Reweight. This method implements the reweighting from section 4 in - :cite:`candes2007` + :cite:`candes2007`. Parameters ---------- @@ -76,6 +76,9 @@ def reweight(self, input_data): w = w \left( \frac{1}{1 + \frac{|x^w|}{n \sigma}} \right) + where :math:`w` are the weights, :math:`x` is the ``input_data`` and + :math:`n` is the ``thresh_factor``. + """ if self.verbose: print(' - Reweighting: {0}'.format(self._rw_num)) diff --git a/modopt/signal/noise.py b/modopt/signal/noise.py index f67cc066..a59d5553 100644 --- a/modopt/signal/noise.py +++ b/modopt/signal/noise.py @@ -18,17 +18,17 @@ def add_noise(input_data, sigma=1.0, noise_type='gauss'): """Add noise to data. - This method adds Gaussian or Poisson noise to the input data + This method adds Gaussian or Poisson noise to the input data. Parameters ---------- input_data : numpy.ndarray, list or tuple Input data array sigma : float or list, optional - Standard deviation of the noise to be added ('gauss' only, default is - ``1.0``) + Standard deviation of the noise to be added (``'gauss'`` only, + default is ``1.0``) noise_type : {'gauss', 'poisson'} - Type of noise to be added (default is 'gauss') + Type of noise to be added (default is ``'gauss'``) Returns ------- @@ -38,9 +38,9 @@ def add_noise(input_data, sigma=1.0, noise_type='gauss'): Raises ------ ValueError - If `noise_type` is not 'gauss' or 'poisson' + If ``noise_type`` is not ``'gauss'`` or ``'poisson'`` ValueError - If number of `sigma` values does not match the first dimension of the + If number of ``sigma`` values does not match the first dimension of the input data Examples @@ -99,7 +99,7 @@ def add_noise(input_data, sigma=1.0, noise_type='gauss'): def thresh(input_data, threshold, threshold_type='hard'): r"""Threshold data. - This method perfoms hard or soft thresholding on the input data + This method perfoms hard or soft thresholding on the input data. Parameters ---------- @@ -108,7 +108,7 @@ def thresh(input_data, threshold, threshold_type='hard'): threshold : float or numpy.ndarray Threshold level(s) threshold_type : {'hard', 'soft'} - Type of noise to be added (default is 'hard') + Type of noise to be added (default is ``'hard'``) Returns ------- @@ -118,7 +118,7 @@ def thresh(input_data, threshold, threshold_type='hard'): Raises ------ ValueError - If `threshold_type` is not 'hard' or 'soft' + If ``threshold_type`` is not ``'hard'`` or ``'soft'`` Notes ----- diff --git a/modopt/signal/positivity.py b/modopt/signal/positivity.py index 7b79d0ee..e4ec098d 100644 --- a/modopt/signal/positivity.py +++ b/modopt/signal/positivity.py @@ -3,7 +3,7 @@ """POSITIVITY. This module contains a function that retains only positive coefficients in -an array +an array. :Author: Samuel Farrens @@ -60,7 +60,7 @@ def positive(input_data, ragged=False): """Positivity operator. This method preserves only the positive coefficients of the input data, all - negative coefficients are set to zero + negative coefficients are set to zero. Parameters ---------- diff --git a/modopt/signal/svd.py b/modopt/signal/svd.py index de3fa453..41241b33 100644 --- a/modopt/signal/svd.py +++ b/modopt/signal/svd.py @@ -122,10 +122,10 @@ def svd_thresh(input_data, threshold=None, n_pc=None, thresh_type='hard'): threshold : float or numpy.ndarray, optional Threshold value(s) (default is ``None``) n_pc : int or str, optional - Number of principal components, specify an integer value or 'all' + Number of principal components, specify an integer value or ``'all'`` (default is ``None``) thresh_type : {'hard', 'soft'}, optional - Type of thresholding (default is 'hard') + Type of thresholding (default is ``'hard'``) Returns ------- @@ -203,7 +203,7 @@ def svd_thresh(input_data, threshold=None, n_pc=None, thresh_type='hard'): def svd_thresh_coef(input_data, operator, threshold, thresh_type='hard'): """Threshold the singular values coefficients. - This method thresholds the input data using singular value decomposition + This method thresholds the input data using singular value decomposition. Parameters ---------- @@ -214,7 +214,7 @@ def svd_thresh_coef(input_data, operator, threshold, thresh_type='hard'): threshold : float or numpy.ndarray Threshold value(s) thresh_type : {'hard', 'soft'} - Type of noise to be added (default is 'hard') + Type of noise to be added (default is ``'hard'``) Returns ------- diff --git a/modopt/signal/validation.py b/modopt/signal/validation.py index f39a2eb3..422a987b 100644 --- a/modopt/signal/validation.py +++ b/modopt/signal/validation.py @@ -24,9 +24,9 @@ def transpose_test( Parameters ---------- - operator : function + operator : callable Operator function - operator_t : function + operator_t : callable Transpose operator function x_shape : tuple Shape of operator input data diff --git a/modopt/signal/wavelet.py b/modopt/signal/wavelet.py index 0e509cbd..bc4ffc70 100644 --- a/modopt/signal/wavelet.py +++ b/modopt/signal/wavelet.py @@ -2,14 +2,15 @@ """WAVELET MODULE. -This module contains methods for performing wavelet transformations using iSAP +This module contains methods for performing wavelet transformations using +Spars2D. :Author: Samuel Farrens Notes ----- This module serves as a wrapper for the wavelet transformation code -`mr_transform`, which is part of the Sparse2D package. This executable +``mr_transform``, which is part of the Sparse2D package. This executable should be installed and built before using these methods. Sparse2D Repository: https://github.com/CosmoStat/Sparse2D @@ -73,18 +74,18 @@ def call_mr_transform( path='./', remove_files=True, ): # pragma: no cover - """Call mr_transform. + """Call ``mr_transform``. - This method calls the iSAP module mr_transform + This method calls the Sparse2D module ``mr_transform``. Parameters ---------- input_data : numpy.ndarray Input data, 2D array opt : list or str, optional - Options to be passed to mr_transform (default is '') + Options to be passed to mr_transform (default is ``''``) path : str, optional - Path for output files (default is './') + Path for output files (default is ``'./'``) remove_files : bool, optional Option to remove output files (default is ``True``) @@ -98,9 +99,9 @@ def call_mr_transform( ImportError If the Astropy package is not found ValueError - If the input data is not a 2D numpy array + If the input data is not a 2D Numpy array RuntimeError - For exception encountered in call to mr_transform + For exception encountered in call to ``mr_transform`` Examples -------- @@ -206,7 +207,7 @@ def get_mr_filters( coarse=False, trim=False, ): # pragma: no cover - """Get mr_transform filters. + """Get ``mr_transform`` filters. This method obtains wavelet filters by calling mr_transform. @@ -215,7 +216,7 @@ def get_mr_filters( data_shape : tuple 2D data shape opt : list, optional - List of additonal mr_transform options (default is '') + List of additonal mr_transform options (default is ``''``) coarse : bool, optional Option to keep coarse scale (default is ``False``) trim: bool, optional @@ -267,9 +268,9 @@ def filter_convolve(input_data, filters, filter_rot=False, method='scipy'): filters : numpy.ndarray Wavelet filters, 3D array filter_rot : bool, optional - Option to rotate wavelet filters (default is `False`) + Option to rotate wavelet filters (default is ``False``) method : {'astropy', 'scipy'}, optional - Convolution method (default is 'scipy') + Convolution method (default is ``'scipy'``) Returns ------- @@ -327,7 +328,7 @@ def filter_convolve_stack( ): """Filter convolve. - This method convolves the a stack of input images with the wavelet filters + This method convolves the a stack of input images with the wavelet filters. Parameters ---------- @@ -338,7 +339,7 @@ def filter_convolve_stack( filter_rot : bool, optional Option to rotate wavelet filters (default is ``False``) method : {'astropy', 'scipy'}, optional - Convolution method (default is 'scipy') + Convolution method (default is ``'scipy'``) Returns ------- diff --git a/setup.cfg b/setup.cfg index 0619a295..eada1b8c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -12,6 +12,8 @@ strictness = short ignore = D107, #Justification: Don't need docstring for __init__ in numpydoc style RST304, #Justification: Need to use :cite: role for citations + RST210, #Justification: RST210, RST213 Inconsistent with numpydoc + RST213, # documentation for handling *args and **kwargs W503, #Justification: Have to choose one multiline operator format WPS202, #Todo: Rethink module size, possibly split large modules WPS337, #Todo: Consider simplifying multiline conditions. @@ -45,7 +47,7 @@ per-file-ignores = #multiline parameters bug with tuples modopt/opt/algorithms/gradient_descent.py: WPS111, WPS420, WPS317 #Todo: Consider changing costObj name - modopt/opt/cost.py: N801 + modopt/opt/cost.py: N801, #Todo: # - Rethink subscript slice assignment # - Reduce complexity of KSupportNorm diff --git a/setup.py b/setup.py index 841ca1b1..59b03d2c 100644 --- a/setup.py +++ b/setup.py @@ -6,8 +6,8 @@ # Set the package release version major = 1 -minor = 5 -patch = 1 +minor = 6 +patch = 0 # Set the package details name = 'modopt' From bc72be449ddf53e089fba13e473949ae55d729f5 Mon Sep 17 00:00:00 2001 From: Samuel Farrens Date: Fri, 17 Dec 2021 18:04:57 +0100 Subject: [PATCH 09/46] fixed index.rst --- docs/source/index.rst | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/source/index.rst b/docs/source/index.rst index 82732b72..0eb6878f 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -14,13 +14,8 @@ ModOpt Documentation .. include:: toc.rst :Author: Samuel Farrens `(samuel.farrens@cea.fr) `_ -<<<<<<< HEAD :Version: 1.6.0 :Release Date: 17/12/2021 -======= -:Version: 1.5.1 -:Release Date: 22/04/2021 ->>>>>>> af01498f614552da2ddf600329490db34459e08e :Repository: |link-to-repo| .. |link-to-repo| raw:: html From e1bd5d7040773fdfbd33f28a568fa1ec4e221932 Mon Sep 17 00:00:00 2001 From: Samuel Farrens Date: Fri, 17 Dec 2021 18:34:56 +0100 Subject: [PATCH 10/46] fixed conflict --- .pyup.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.pyup.yml b/.pyup.yml index dfd10780..8fdac7ff 100644 --- a/.pyup.yml +++ b/.pyup.yml @@ -6,17 +6,9 @@ update: all label_prs: update assignees: sfarrens requirements: -<<<<<<< HEAD - - requirements.txt - pin: False - - develop.txt - pin: False - - docs/requirements.txt -======= - requirements.txt: pin: False - develop.txt: pin: False - docs/requirements.txt: ->>>>>>> 64d6f856708630ddec13ba8ae476bb084db1ddf3 pin: True From 65e24f11e8cb1e416e79a76796e2d83e4a087d09 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Comby <77174042+paquiteau@users.noreply.github.com> Date: Mon, 7 Feb 2022 09:24:42 +0100 Subject: [PATCH 11/46] Fast Singular Value Thresholding (#209) * add SingularValueThreshold This Method provides 10x faster SVT estimation than the LowRankMatrix Operator. * linting * add test for fast computation. * flake8 compliance * Ignore DAR000 Error. * Update modopt/signal/svd.py tuples in docstring Co-authored-by: Samuel Farrens * Update modopt/signal/svd.py typo Co-authored-by: Samuel Farrens * Update modopt/opt/proximity.py typo Co-authored-by: Samuel Farrens * update docstring * fix isort * Update modopt/signal/svd.py Co-authored-by: Samuel Farrens * Update modopt/signal/svd.py Co-authored-by: Samuel Farrens * run isort Co-authored-by: Samuel Farrens --- modopt/opt/proximity.py | 30 +++++++++++++++++--- modopt/signal/svd.py | 59 ++++++++++++++++++++++++++++++++++++++++ modopt/tests/test_opt.py | 12 ++++++++ setup.cfg | 3 +- 4 files changed, 99 insertions(+), 5 deletions(-) diff --git a/modopt/opt/proximity.py b/modopt/opt/proximity.py index e0f28e96..f8f368ef 100644 --- a/modopt/opt/proximity.py +++ b/modopt/opt/proximity.py @@ -28,7 +28,7 @@ from modopt.math.matrix import nuclear_norm from modopt.signal.noise import thresh from modopt.signal.positivity import positive -from modopt.signal.svd import svd_thresh, svd_thresh_coef +from modopt.signal.svd import svd_thresh, svd_thresh_coef, svd_thresh_coef_fast class ProximityParent(object): @@ -237,6 +237,9 @@ class LowRankMatrix(ProximityParent): lowr_type : {'standard', 'ngole'} Low-rank implementation (options are 'standard' or 'ngole', default is 'standard') + initial_rank: int, optional + Initial guess of the rank of future input_data. + If provided this will save computation time. operator : class Operator class ('ngole' only) @@ -268,6 +271,7 @@ def __init__( threshold, thresh_type='soft', lowr_type='standard', + initial_rank=None, operator=None, ): @@ -277,8 +281,9 @@ def __init__( self.operator = operator self.op = self._op_method self.cost = self._cost_method + self.rank = initial_rank - def _op_method(self, input_data, extra_factor=1.0): + def _op_method(self, input_data, extra_factor=1.0, rank=None): """Operator. This method returns the input data after the singular values have been @@ -290,22 +295,37 @@ def _op_method(self, input_data, extra_factor=1.0): Input data array extra_factor : float Additional multiplication factor (default is ``1.0``) + rank: int, optional + Estimation of the rank to save computation time in standard mode, + if not set an internal estimation is used. Returns ------- numpy.ndarray SVD thresholded data + Raises + ------ + ValueError + if lowr_type is not in ``{'standard', 'ngole'}`` """ # Update threshold with extra factor. threshold = self.thresh * extra_factor - - if self.lowr_type == 'standard': + if self.lowr_type == 'standard' and self.rank is None and rank is None: data_matrix = svd_thresh( cube2matrix(input_data), threshold, thresh_type=self.thresh_type, ) + elif self.lowr_type == 'standard': + data_matrix, update_rank = svd_thresh_coef_fast( + cube2matrix(input_data), + threshold, + n_vals=rank or self.rank, + extra_vals=5, + thresh_type=self.thresh_type, + ) + self.rank = update_rank # save for future use elif self.lowr_type == 'ngole': data_matrix = svd_thresh_coef( @@ -314,6 +334,8 @@ def _op_method(self, input_data, extra_factor=1.0): threshold, thresh_type=self.thresh_type, ) + else: + raise ValueError('lowr_type should be standard or ngole') # Return updated data. return matrix2cube(data_matrix, input_data.shape[1:]) diff --git a/modopt/signal/svd.py b/modopt/signal/svd.py index 41241b33..6dcb9eda 100644 --- a/modopt/signal/svd.py +++ b/modopt/signal/svd.py @@ -10,6 +10,7 @@ import numpy as np from scipy.linalg import svd +from scipy.sparse.linalg import svds from modopt.base.transform import matrix2cube from modopt.interface.errors import warn @@ -200,6 +201,64 @@ def svd_thresh(input_data, threshold=None, n_pc=None, thresh_type='hard'): return np.dot(u_vec, np.dot(s_new, v_vec)) +def svd_thresh_coef_fast( + input_data, + threshold, + n_vals=-1, + extra_vals=5, + thresh_type='hard', +): + """Threshold the singular values coefficients. + + This method thresholds the input data by using singular value + decomposition, but only computing the the greastest ``n_vals`` + values. + + Parameters + ---------- + input_data : numpy.ndarray + Input data array, 2D matrix + Operator class instance + threshold : float or numpy.ndarray + Threshold value(s) + n_vals: int, optional + Number of singular values to compute. + If None, compute all singular values. + extra_vals: int, optional + If the number of values computed is not enough to perform thresholding, + recompute by using ``n_vals + extra_vals`` (default is ``5``) + thresh_type : {'hard', 'soft'} + Type of noise to be added (default is ``'hard'``) + + Returns + ------- + tuple + The thresholded data (numpy.ndarray) and the estimated rank after + thresholding (int) + """ + if n_vals == -1: + n_vals = min(input_data.shape) - 1 + ok = False + while not ok: + (u_vec, s_values, v_vec) = svds(input_data, k=n_vals) + ok = (s_values[0] <= threshold or n_vals == min(input_data.shape) - 1) + n_vals = min(n_vals + extra_vals, *input_data.shape) + + s_values = thresh( + s_values, + threshold, + threshold_type=thresh_type, + ) + rank = np.count_nonzero(s_values) + return ( + np.dot( + u_vec[:, -rank:] * s_values[-rank:], + v_vec[-rank:, :], + ), + rank, + ) + + def svd_thresh_coef(input_data, operator, threshold, thresh_type='hard'): """Threshold the singular values coefficients. diff --git a/modopt/tests/test_opt.py b/modopt/tests/test_opt.py index 3c33c948..d5547783 100644 --- a/modopt/tests/test_opt.py +++ b/modopt/tests/test_opt.py @@ -675,6 +675,11 @@ def setUp(self): weights, ) self.lowrank = proximity.LowRankMatrix(10.0, thresh_type='hard') + self.lowrank_rank = proximity.LowRankMatrix( + 10.0, + initial_rank=1, + thresh_type='hard', + ) self.lowrank_ngole = proximity.LowRankMatrix( 10.0, lowr_type='ngole', @@ -763,6 +768,8 @@ def tearDown(self): self.positivity = None self.sparsethresh = None self.lowrank = None + self.lowrank_rank = None + self.lowrank_ngole = None self.combo = None self.data1 = None self.data2 = None @@ -841,6 +848,11 @@ def test_low_rank_matrix(self): err_msg='Incorrect low rank operation: standard', ) + npt.assert_almost_equal( + self.lowrank_rank.op(self.data3), + self.data4, + err_msg='Incorrect low rank operation: standard with rank', + ) npt.assert_almost_equal( self.lowrank_ngole.op(self.data3), self.data5, diff --git a/setup.cfg b/setup.cfg index eada1b8c..cabd35a0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -58,7 +58,8 @@ per-file-ignores = #Justification: Needed to import matplotlib.pyplot modopt/plot/cost_plot.py: N802,WPS301 #Todo: Investigate possible bug in find_n_pc function - modopt/signal/svd.py: WPS345 + #Todo: Investigate darglint error + modopt/signal/svd.py: WPS345, DAR000 #Todo: Check security of using system executable call modopt/signal/wavelet.py: S404,S603 #Todo: Clean up tests From b55f5a65ea8f7b55d02b6fcff1a1722672331a47 Mon Sep 17 00:00:00 2001 From: Samuel Farrens Date: Mon, 21 Mar 2022 10:25:06 +0100 Subject: [PATCH 12/46] added writeable input data array feature for benchopt (#213) --- modopt/opt/gradient.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modopt/opt/gradient.py b/modopt/opt/gradient.py index 56004838..caa8fa9d 100644 --- a/modopt/opt/gradient.py +++ b/modopt/opt/gradient.py @@ -34,6 +34,8 @@ class GradParent(object): Method for calculating the cost (default is ``None``) data_type : type, optional Expected data type of the input data (default is ``None``) + input_data_writeable: bool, optional + Option to make the observed data writeable (default is ``False``) verbose : bool, optional Option for verbose output (default is ``True``) @@ -66,10 +68,12 @@ def __init__( get_grad=None, cost=None, data_type=None, + input_data_writeable=False, verbose=True, ): self.verbose = verbose + self._input_data_writeable = input_data_writeable self._grad_data_type = data_type self.obs_data = input_data self.op = op @@ -102,7 +106,7 @@ def obs_data(self, input_data): check_npndarray( input_data, dtype=self._grad_data_type, - writeable=False, + writeable=self._input_data_writeable, verbose=self.verbose, ) From a04bb297ce0eb5b32649099371a00a9e767a463c Mon Sep 17 00:00:00 2001 From: Samuel Farrens Date: Fri, 8 Apr 2022 17:43:36 +0200 Subject: [PATCH 13/46] removed flake8 limit --- develop.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/develop.txt b/develop.txt index 3f809fc2..8beef0ff 100644 --- a/develop.txt +++ b/develop.txt @@ -1,5 +1,5 @@ coverage>=5.5 -flake8<4 +flake8>=4 nose>=1.3.7 pytest>=6.2.2 pytest-cov>=2.11.1 From 7632e072cf553bdaea7f81290a8f2298eed72a69 Mon Sep 17 00:00:00 2001 From: Samuel Farrens Date: Fri, 8 Apr 2022 18:07:13 +0200 Subject: [PATCH 14/46] updated patch version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 59b03d2c..c93dd020 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ # Set the package release version major = 1 minor = 6 -patch = 0 +patch = 1 # Set the package details name = 'modopt' From ad4c496f94793b3a872f96502cefcf0a1c396cfa Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Comby <77174042+paquiteau@users.noreply.github.com> Date: Mon, 2 May 2022 15:12:39 +0200 Subject: [PATCH 15/46] [lint] pydocstyle compliance. (#228) * [lint] pydocstyle compliance. * use pytest-pydocstyle --- develop.txt | 1 + modopt/opt/linear.py | 1 - modopt/opt/proximity.py | 2 +- setup.cfg | 4 ++++ 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/develop.txt b/develop.txt index 8beef0ff..7397e15c 100644 --- a/develop.txt +++ b/develop.txt @@ -7,3 +7,4 @@ pytest-pep8>=1.0.6 pytest-emoji>=0.2.0 pytest-flake8>=1.0.7 wemake-python-styleguide>=0.15.2 +pytest-pydocstyle>=2.2.0 diff --git a/modopt/opt/linear.py b/modopt/opt/linear.py index d8679998..3807253b 100644 --- a/modopt/opt/linear.py +++ b/modopt/opt/linear.py @@ -150,7 +150,6 @@ class LinearCombo(LinearParent): See Also -------- LinearParent : parent class - """ def __init__(self, operators, weights=None): diff --git a/modopt/opt/proximity.py b/modopt/opt/proximity.py index f8f368ef..e8492367 100644 --- a/modopt/opt/proximity.py +++ b/modopt/opt/proximity.py @@ -993,7 +993,7 @@ def _interpolate(self, alpha0, alpha1, sum0, sum1): :math:`\sum\theta(\alpha^*)=k` via a linear interpolation. Parameters - ----------- + ---------- alpha0: float A value for wich :math:`\sum\theta(\alpha^0) \leq k` alpha1: float diff --git a/setup.cfg b/setup.cfg index cabd35a0..87496ced 100644 --- a/setup.cfg +++ b/setup.cfg @@ -89,3 +89,7 @@ addopts = --cov-report=term --cov-report=xml --junitxml=pytest.xml + --pydocstyle + +[pydocstyle] +convention=numpy From 5dd600184b209bfe9d3779c8c00053daed31d4cd Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Comby <77174042+paquiteau@users.noreply.github.com> Date: Thu, 9 Jun 2022 13:59:42 +0200 Subject: [PATCH 16/46] Power method: fix #211 (#212) * Correct the norm update for Power Method x_new should be divided by its norm, not by x_old_norm. * fix test value We are testing for eigen value of Identity. It should be one. * fix WPS350 * fix test value for unconverged case Co-authored-by: Samuel Farrens --- modopt/math/matrix.py | 17 +++++++++++------ modopt/tests/test_math.py | 8 ++++---- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/modopt/math/matrix.py b/modopt/math/matrix.py index 939cf41f..8361531d 100644 --- a/modopt/math/matrix.py +++ b/modopt/math/matrix.py @@ -285,9 +285,9 @@ class PowerMethod(object): >>> np.random.seed(1) >>> pm = PowerMethod(lambda x: x.dot(x.T), (3, 3)) >>> np.around(pm.spec_rad, 6) - 0.904292 + 1.0 >>> np.around(pm.inv_spec_rad, 6) - 1.105837 + 1.0 Notes ----- @@ -348,17 +348,21 @@ def get_spec_rad(self, tolerance=1e-6, max_iter=20, extra_factor=1.0): # Set (or reset) values of x. x_old = self._set_initial_x() + xp = get_array_module(x_old) + x_old_norm = xp.linalg.norm(x_old) + + x_old /= x_old_norm + # Iterate until the L2 norm of x converges. for i_elem in range(max_iter): - xp = get_array_module(x_old) - x_old_norm = xp.linalg.norm(x_old) - - x_new = self._operator(x_old) / x_old_norm + x_new = self._operator(x_old) x_new_norm = xp.linalg.norm(x_new) + x_new /= x_new_norm + if (xp.abs(x_new_norm - x_old_norm) < tolerance): message = ( ' - Power Method converged after {0} iterations!' @@ -374,6 +378,7 @@ def get_spec_rad(self, tolerance=1e-6, max_iter=20, extra_factor=1.0): print(message.format(max_iter)) xp.copyto(x_old, x_new) + x_old_norm = x_new_norm self.spec_rad = x_new_norm * extra_factor self.inv_spec_rad = 1.0 / self.spec_rad diff --git a/modopt/tests/test_math.py b/modopt/tests/test_math.py index 99908e02..ba175ae6 100644 --- a/modopt/tests/test_math.py +++ b/modopt/tests/test_math.py @@ -234,13 +234,13 @@ def test_powermethod_converged(self): """Test PowerMethod converged.""" npt.assert_almost_equal( self.pmInstance1.spec_rad, - 0.90429242629600837, + 1.0, err_msg='Incorrect spectral radius: converged', ) npt.assert_almost_equal( self.pmInstance1.inv_spec_rad, - 1.1058369736612865, + 1.0, err_msg='Incorrect inverse spectral radius: converged', ) @@ -248,13 +248,13 @@ def test_powermethod_unconverged(self): """Test PowerMethod unconverged.""" npt.assert_almost_equal( self.pmInstance2.spec_rad, - 0.92048833577059219, + 0.8675467477372257, err_msg='Incorrect spectral radius: unconverged', ) npt.assert_almost_equal( self.pmInstance2.inv_spec_rad, - 1.0863798715741946, + 1.152675636913221, err_msg='Incorrect inverse spectral radius: unconverged', ) From 75456f14c08484ee71310623276b9e1ca45b8ec3 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Comby <77174042+paquiteau@users.noreply.github.com> Date: Thu, 9 Jun 2022 15:46:36 +0200 Subject: [PATCH 17/46] Switch from progressbar to tqdm (#231) * switch from progressbar to tqdm. The progress bar can be provided externally for nested usage. * exposes the progress bar argument. * Child classes better have to implement these. (my linter was complaining) * update docs for progress bar using tqdm. * fix WPS errors * drop progressbar requirement, add tqdm. * [lint] disable warning for non implemented function. * simplify progbar check and argument passthrough --- modopt/opt/algorithms/base.py | 47 +++++++++++++++-------- modopt/opt/algorithms/forward_backward.py | 21 +++++----- modopt/opt/algorithms/primal_dual.py | 11 ++++-- requirements.txt | 2 +- 4 files changed, 51 insertions(+), 30 deletions(-) diff --git a/modopt/opt/algorithms/base.py b/modopt/opt/algorithms/base.py index 85c36306..b236013b 100644 --- a/modopt/opt/algorithms/base.py +++ b/modopt/opt/algorithms/base.py @@ -4,7 +4,7 @@ from inspect import getmro import numpy as np -from progressbar import ProgressBar +from tqdm.auto import tqdm from modopt.base import backend from modopt.base.observable import MetricObserver, Observable @@ -15,7 +15,7 @@ class SetUp(Observable): r"""Algorithm Set-Up. This class contains methods for checking the set-up of an optimisation - algotithm and produces warnings if they do not comply. + algorithm and produces warnings if they do not comply. Parameters ---------- @@ -38,7 +38,6 @@ class SetUp(Observable): -------- modopt.base.observable.Observable : parent class modopt.base.observable.MetricObserver : definition of metrics - """ def __init__( @@ -240,9 +239,8 @@ def _iterations(self, max_iter, progbar=None): ---------- max_iter : int Maximum number of iterations - progbar : progressbar.bar.ProgressBar - Progress bar (default is ``None``) - + progbar: tqdm.tqdm + Progress bar handle (default is ``None``) """ for idx in range(max_iter): self.idx = idx @@ -268,10 +266,10 @@ def _iterations(self, max_iter, progbar=None): print(' - Converged!') break - if not isinstance(progbar, type(None)): - progbar.update(idx) + if progbar: + progbar.update() - def _run_alg(self, max_iter): + def _run_alg(self, max_iter, progbar=None): """Run algorithm. Run the update step of a given algorithm up to the maximum number of @@ -281,17 +279,34 @@ def _run_alg(self, max_iter): ---------- max_iter : int Maximum number of iterations + progbar: tqdm.tqdm + Progress bar handle (default is ``None``) See Also -------- - progressbar.bar.ProgressBar + tqdm.tqdm """ - if self.progress: - with ProgressBar( - redirect_stdout=True, - max_value=max_iter, - ) as progbar: - self._iterations(max_iter, progbar=progbar) + if self.progress and progbar is None: + with tqdm(total=max_iter) as pb: + self._iterations(max_iter, progbar=pb) + elif progbar: + self._iterations(max_iter, progbar=progbar) else: self._iterations(max_iter) + + def _update(self): + raise NotImplementedError + + def get_notify_observers_kwargs(self): + """Notify Observers. + + Return the mapping between the metrics call and the iterated + variables. + + Raises + ------ + NotImplementedError + This method should be overriden by subclasses. + """ + raise NotImplementedError diff --git a/modopt/opt/algorithms/forward_backward.py b/modopt/opt/algorithms/forward_backward.py index e18f66c3..6923a6af 100644 --- a/modopt/opt/algorithms/forward_backward.py +++ b/modopt/opt/algorithms/forward_backward.py @@ -467,7 +467,7 @@ def _update(self): or self._cost_func.get_cost(self._x_new) ) - def iterate(self, max_iter=150): + def iterate(self, max_iter=150, progbar=None): """Iterate. This method calls update until either the convergence criteria is met @@ -477,9 +477,10 @@ def iterate(self, max_iter=150): ---------- max_iter : int, optional Maximum number of iterations (default is ``150``) - + progbar: tqdm.tqdm + Progress bar handle (default is ``None``) """ - self._run_alg(max_iter) + self._run_alg(max_iter, progbar) # retrieve metrics results self.retrieve_outputs() @@ -750,7 +751,7 @@ def _update(self): if self._cost_func: self.converge = self._cost_func.get_cost(self._x_new) - def iterate(self, max_iter=150): + def iterate(self, max_iter=150, progbar=None): """Iterate. This method calls update until either convergence criteria is met or @@ -760,9 +761,10 @@ def iterate(self, max_iter=150): ---------- max_iter : int, optional Maximum number of iterations (default is ``150``) - + progbar: tqdm.tqdm + Progress bar handle (default is ``None``) """ - self._run_alg(max_iter) + self._run_alg(max_iter, progbar) # retrieve metrics results self.retrieve_outputs() @@ -995,7 +997,7 @@ def _update(self): or self._cost_func.get_cost(self._x_new) ) - def iterate(self, max_iter=150): + def iterate(self, max_iter=150, progbar=None): """Iterate. This method calls update until either convergence criteria is met or @@ -1005,9 +1007,10 @@ def iterate(self, max_iter=150): ---------- max_iter : int, optional Maximum number of iterations (default is ``150``) - + progbar: tqdm.tqdm + Progress bar handle (default is ``None``) """ - self._run_alg(max_iter) + self._run_alg(max_iter, progbar) # retrieve metrics results self.retrieve_outputs() diff --git a/modopt/opt/algorithms/primal_dual.py b/modopt/opt/algorithms/primal_dual.py index c8566969..d5bdd431 100644 --- a/modopt/opt/algorithms/primal_dual.py +++ b/modopt/opt/algorithms/primal_dual.py @@ -225,7 +225,7 @@ def _update(self): or self._cost_func.get_cost(self._x_new, self._y_new) ) - def iterate(self, max_iter=150, n_rewightings=1): + def iterate(self, max_iter=150, n_rewightings=1, progbar=None): """Iterate. This method calls update until either convergence criteria is met or @@ -237,14 +237,17 @@ def iterate(self, max_iter=150, n_rewightings=1): Maximum number of iterations (default is ``150``) n_rewightings : int, optional Number of reweightings to perform (default is ``1``) - + progbar: tqdm.tqdm + Progress bar handle (default is ``None``) """ - self._run_alg(max_iter) + self._run_alg(max_iter, progbar) if not isinstance(self._reweight, type(None)): for _ in range(n_rewightings): self._reweight.reweight(self._linear.op(self._x_new)) - self._run_alg(max_iter) + if progbar: + progbar.reset(total=max_iter) + self._run_alg(max_iter, progbar) # retrieve metrics results self.retrieve_outputs() diff --git a/requirements.txt b/requirements.txt index 63a404ba..1f44de13 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ importlib_metadata>=3.7.0 numpy>=1.19.5 scipy>=1.5.4 -progressbar2>=3.53.1 +tqdm>=4.64.0 From 059090770e4cbd2c06bb89bbdc61a17a3e6effaf Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Comby <77174042+paquiteau@users.noreply.github.com> Date: Fri, 17 Jun 2022 09:50:43 +0200 Subject: [PATCH 18/46] Update README for tqdm dependency (#240) Remote progressbar, use tqdm. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index acb316ad..91bebfc2 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ installed: * [importlib_metadata](https://importlib-metadata.readthedocs.io/en/latest/) [==3.7.0] * [Numpy](http://www.numpy.org/) [==1.19.5] * [Scipy](http://www.scipy.org/) [==1.5.4] -* [Progressbar 2](https://progressbar-2.readthedocs.io/) [==3.53.1] +* [tqdm]([https://progressbar-2.readthedocs.io/](https://tqdm.github.io/) [>=4.64.0] ### Optional Packages From 96f361d24716c0e6aeb51122be5c83dbf3975c9d Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Comby <77174042+paquiteau@users.noreply.github.com> Date: Fri, 17 Jun 2022 15:36:24 +0200 Subject: [PATCH 19/46] add small help for the metric argument. (#241) * add small help for the metric argument. * RST validation * use single quote * use double backticks. Co-authored-by: Samuel Farrens --- modopt/opt/algorithms/base.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/modopt/opt/algorithms/base.py b/modopt/opt/algorithms/base.py index b236013b..c5a4b101 100644 --- a/modopt/opt/algorithms/base.py +++ b/modopt/opt/algorithms/base.py @@ -12,7 +12,7 @@ class SetUp(Observable): - r"""Algorithm Set-Up. + """Algorithm Set-Up. This class contains methods for checking the set-up of an optimisation algorithm and produces warnings if they do not comply. @@ -22,7 +22,7 @@ class SetUp(Observable): metric_call_period : int, optional Metric call period (default is ``5``) metrics : dict, optional - Metrics to be used (default is ``\{\}``) + Metrics to be used (default is ``None``) verbose : bool, optional Option for verbose output (default is ``False``) progress : bool, optional @@ -34,6 +34,28 @@ class SetUp(Observable): use_gpu : bool, optional Option to use available GPU + Notes + ----- + If provided, the ``metrics`` argument should be a nested dictionary of the + following form:: + + metrics = { + 'metric_name': { + 'metric': callable, + 'mapping': {'x_new': 'test'}, + 'cst_kwargs': {'ref': ref_image}, + 'early_stopping': False, + } + } + + Where ``callable`` is a function with arguments being for instance + ``test`` and ``ref``. The mapping of the argument uses the same keys as the + output of ``get_notify_observer_kwargs``, ``cst_kwargs`` defines constant + arguments that will always be passed to the metric call. + If ``early_stopping`` is True, the metric will be used to check for + convergence of the algorithm, in that case it is recommended to have + ``metric_call_period = 1`` + See Also -------- modopt.base.observable.Observable : parent class From 11c260dcb6c8fcf742f51c0a1faa2e21f757abb7 Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Tue, 15 Nov 2022 15:24:33 +0100 Subject: [PATCH 20/46] add implementation for admm and fast admm. Based on Goldstein2014 --- modopt/opt/algorithms/admm.py | 291 ++++++++++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 modopt/opt/algorithms/admm.py diff --git a/modopt/opt/algorithms/admm.py b/modopt/opt/algorithms/admm.py new file mode 100644 index 00000000..d45c1d38 --- /dev/null +++ b/modopt/opt/algorithms/admm.py @@ -0,0 +1,291 @@ +"""ADMM Algorithms.""" +import numpy as np + +from modopt.opt.algorithms.base import SetUp +from modopt.opt.cost import costObj + + +class ADMM(SetUp): + r"""Fast ADMM Optimisation Algorihm. + + This class implement the ADMM algorithm (Algorithm 1 from :cite:`Goldstein2014`) + + Parameters + ---------- + A : OperatorBase + Linear operator for u + B : OperatorBase + Linear operator for v + b : array_like + Constraint vector + optimizers: 2-tuple of functions + Solvers for the u and v update, takes init_value and obs_value as argument. + and returns an estimate for: + .. math:: u_{k+1} = \argmin H(u) + \frac{\tau}{2}\|A u - y\|^2 + .. math:: v_{k+1} = \argmin G(v) + \frac{\tau}{2}\|Bv - y \|^2 + cost_funcs = 2-tuple of function + Compute the values of H and G + rho : float , optional + regularisation coupling variable default is ``1.0`` + eta : float, optional + Restart threshold, default is ``0.999`` + + Notes + ----- + The algorithm solve the problem: + + .. math:: u, v = \arg\min H(u) + G(v) + \frac{\tau}{2} \|Au + Bv - b \|_2^2 + + with the following augmented lagrangian: + + .. math :: \mathcal{L}_{\tau}(u,v, \lambda) = H(u) + G(v) + +\langle\lambda |Au + Bv -b \rangle + \frac\tau2 \| Au + Bv -b \|^{2} + + To allow easy iterative solving, the change of variable :math:`\mu=\lambda/\tau` + is used. Hence, the lagrangian of interest is: + + .. math :: \tilde{\mathcal{L}}_{\tau}(u,v, \mu) = H(u) + G(v) + + \frac\tau2 \left(\|\mu + Au +Bv - b\|^2 - \|\mu\|^2\right) + + See Also + -------- + SetUp: parent class + """ + + def __init__( + self, + u, + v, + mu, + A, + B, + b, + optimizers, + tau=1, + max_iter2=5, + cost_func=None, + **kwargs + ): + super().__init__(**kwargs) + self.A = A + self.B = B + self.b = b + self._opti_H = optimizers[0] + self._opti_G = optimizers[1] + self._tau = tau + + self._cost_func = costObj() + # patching to get the full cost + self._cost_func._calc_cost = lambda u, v: ( + cost_func[0](u) + + cost_func[1](v) + + self.xp.linalg.norm(A.op(u) + B.op(v) - b) + ) + + # init iteration variables. + self._u_old = self.xp.copy(u) + self._u_new = self.xp.copy(u) + self._v_old = self.xp.copy(v) + self._v_new = self.xp.copy(v) + self._mu_new = self.xp.copy(mu) + self._mu_old = self.xp.copy(mu) + + def _update(self): + self._u_new = self._opti_H( + init=self._u_old, + obs=self.B.op(self._v_old) + self._u_old - self.b, + ) + uA_new = self.A.op(self._u_new) + self._v_new = self.solver2( + init=self._v_old, + obs=uA_new + self._u_old - self.c, + ) + + self._mu_new = self._mu_old + (uA_new + self.B.op(self._v_new) - self.b) + + # update cycle + self._u_old = self.xp.copy(self._u_new) + self._v_old = self.xp.copy(self._v_new) + self._mu_old = self.xp.copy(self._mu_new) + + # Test cost function for convergence. + if self._cost_func: + self.converge = self.any_convergence_flag() or self._cost_func.get_cost( + self._u_new, self.v_new + ) + + def iterate(self, max_iter=150): + """Iterate. + + This method calls update until either convergence criteria is met or + the maximum number of iterations is reached. + + Parameters + ---------- + max_iter : int, optional + Maximum number of iterations (default is ``150``) + """ + self._run_alg(max_iter) + + # retrieve metrics results + self.retrieve_outputs() + # rename outputs as attributes + self.u_final = self._u_new + self.v_final = self._v_new + + def get_notify_observers_kwargs(self): + """Notify observers. + + Return the mapping between the metrics call and the iterated + variables. + + Returns + ------- + dict + The mapping between the iterated variables + """ + return { + "u_new": self._u_new, + "v_new": self._v_new, + "idx": self.idx, + } + + def retrieve_outputs(self): + """Retrieve outputs. + + Declare the outputs of the algorithms as attributes: x_final, + y_final, metrics. + """ + metrics = {} + for obs in self._observers["cv_metrics"]: + metrics[obs.name] = obs.retrieve_metrics() + self.metrics = metrics + + +class FastADMM(ADMM): + r"""Fast ADMM Optimisation Algorihm. + + This class implement the fast ADMM algorithm (Algorithm 8 from :cite:`Goldstein2014`) + + Parameters + ---------- + A : OperatorBase + Linear operator for u + B : OperatorBase + Linear operator for v + b : array_like + Constraint vector + solver1 : function + Solver for the x update, takes init_value and obs_value as argument. + ie, return an estimate for: + .. math:: u_{k+1} = \argmin H(u) + \frac{\tau}{2}\|A u - y\|^2 + solver2 : function + Solver for the z update, takes init_value and obs_value as argument. + ie return an estimate for: + .. math:: v_{k+1} = \argmin G(v) + \frac{\tau}{2}\|Bv - y \|^2 + rho : float , optional + regularisation coupling variable default is ``1.0`` + eta : float, optional + Restart threshold, default is ``0.999`` + + Notes + ----- + The algorithm solve the problem: + + .. math:: u, v = \arg\min H(u) + G(v) + \frac{\tau}{2} \|Au + Bv - b \|_2^2 + + with the following augmented lagrangian: + + .. math :: \mathcal{L}_{\tau}(u,v, \lambda) = H(u) + G(v) + +\langle\lambda |Au + Bv -b \rangle + \frac\tau2 \| Au + Bv -b \|^{2} + + To allow easy iterative solving, the change of variable :math:`\mu=\lambda/\tau` + is used. Hence, the lagrangian of interest is: + + .. math :: \tilde{\mathcal{L}}_{\tau}(u,v, \mu) = H(u) + G(v) + + \frac\tau2 \left(\|\mu + Au +Bv - b\|^2 - \|\mu\|^2\right) + + See Also + -------- + SetUp: parent class + """ + + def __init__( + self, + u, + v, + mu, + A, + B, + b, + opti_H, + opti_G, + alpha=1, + eta=0.999, + tau=1, + opti_H_kwargs=None, + opti_G_kwargs=None, + cost=None, + **kwargs + ): + super().__init__( + u=u, + v=b, + mu=mu, + A=A, + B=B, + b=b, + opti_H=opti_H, + opti_G=opti_G, + opti_H_kwargs=opti_H_kwargs, + opti_G_kwargs=opti_G, + cost=None, + **kwargs + ) + self._c_old = np.inf + self._c_new = 0.0 + self._eta = eta + self._alpha_old = alpha + self._alpha_new = alpha + self._v_hat = self.xp.copy(self._v_new) + self._mu_hat = self.xp.copy(self._mu_new) + + def _update(self): + # Classical ADMM steps + self._u_new = self._opti_H( + init=self._u_old, + obs=self.B.op(self._v_hat) + self._u_old - self.b, + ) + uA_new = self.A.op(self._u_new) + self._v_new = self.solver2( + init=self._v_hat, + obs=uA_new + self._u_hat - self.c, + ) + + self._mu_new = self._mu_hat + (uA_new + self.B.op(self._v_new) - self.b) + + # restarting condition + self._c_new = self.xp.linalg.norm(self._mu_new - self._mu_hat) + self._c_new += self._tau * self.xp.linalg.norm( + self.B.op(self._v_new - self._v_hat) + ) + if self._c_new < self._eta * self._c_old: + self._alpha_new = 1 + np.sqrt(1 + 4 * self._alpha_old**2) + update_factor = (self._alpha_new - 1) / self._alpha_old + self._v_hat = self._v_new + (self._v_new - self._v_old) * update_factor + self._mu_hat = self._mu_new + (self._mu_new - self._mu_old) * update_factor + else: + # reboot to old iteration + self._alpha_new = 1 + self._v_hat = self._v_old + self._mu_hat = self._mu_old + self._c_new = self._c_old / self._eta + + self.xp.copyto(self._u_old, self._u_new) + self.xp.copyto(self._v_old, self._v_new) + self.xp.copyto(self._mu_old, self._mu_new) + # Test cost function for convergence. + if self._cost_func: + self.converge = self.any_convergence_flag() or self._cost_func.get_cost( + self._u_new + ) From 3e0bef3217646bab2a486f3444e42ee73e8af496 Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Tue, 15 Nov 2022 15:31:21 +0100 Subject: [PATCH 21/46] add Goldstein ref. --- docs/source/refs.bib | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/source/refs.bib b/docs/source/refs.bib index d8365e71..7782ca52 100644 --- a/docs/source/refs.bib +++ b/docs/source/refs.bib @@ -207,3 +207,15 @@ @article{zou2005 journal = {Journal of the Royal Statistical Society Series B}, doi = {10.1111/j.1467-9868.2005.00527.x} } + +@article{Goldstein2014, + author={Goldstein, Tom and O’Donoghue, Brendan and Setzer, Simon and Baraniuk, Richard}, + year={2014}, + month={Jan}, + pages={1588–1623}, + title={Fast Alternating Direction Optimization Methods}, + journal={SIAM Journal on Imaging Sciences}, + volume={7}, + ISSN={1936-4954}, + doi={10/gdwr49}, +} From 83459ada2082e9f2f4ba7e3a9943aeb349b88a66 Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Tue, 15 Nov 2022 16:08:45 +0100 Subject: [PATCH 22/46] WPS compliance. --- modopt/opt/algorithms/admm.py | 56 +++++++++++++++++------------------ 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/modopt/opt/algorithms/admm.py b/modopt/opt/algorithms/admm.py index d45c1d38..1830a846 100644 --- a/modopt/opt/algorithms/admm.py +++ b/modopt/opt/algorithms/admm.py @@ -8,7 +8,8 @@ class ADMM(SetUp): r"""Fast ADMM Optimisation Algorihm. - This class implement the ADMM algorithm (Algorithm 1 from :cite:`Goldstein2014`) + This class implement the ADMM algorithm + (Algorithm 1 from :cite:`Goldstein2014`) Parameters ---------- @@ -19,8 +20,8 @@ class ADMM(SetUp): b : array_like Constraint vector optimizers: 2-tuple of functions - Solvers for the u and v update, takes init_value and obs_value as argument. - and returns an estimate for: + Solvers for the u and v update, takes init_value and obs_value as + argument. each element returns an estimate for: .. math:: u_{k+1} = \argmin H(u) + \frac{\tau}{2}\|A u - y\|^2 .. math:: v_{k+1} = \argmin G(v) + \frac{\tau}{2}\|Bv - y \|^2 cost_funcs = 2-tuple of function @@ -34,15 +35,15 @@ class ADMM(SetUp): ----- The algorithm solve the problem: - .. math:: u, v = \arg\min H(u) + G(v) + \frac{\tau}{2} \|Au + Bv - b \|_2^2 + .. math:: u, v = \arg\min H(u) + G(v) + \frac\tau2 \|Au + Bv - b \|_2^2 with the following augmented lagrangian: .. math :: \mathcal{L}_{\tau}(u,v, \lambda) = H(u) + G(v) - +\langle\lambda |Au + Bv -b \rangle + \frac\tau2 \| Au + Bv -b \|^{2} + +\langle\lambda |Au + Bv -b \rangle + \frac\tau2 \| Au + Bv -b \|^2 - To allow easy iterative solving, the change of variable :math:`\mu=\lambda/\tau` - is used. Hence, the lagrangian of interest is: + To allow easy iterative solving, the change of variable + :math:`\mu=\lambda/\tau` is used. Hence, the lagrangian of interest is: .. math :: \tilde{\mathcal{L}}_{\tau}(u,v, \mu) = H(u) + G(v) + \frac\tau2 \left(\|\mu + Au +Bv - b\|^2 - \|\mu\|^2\right) @@ -95,13 +96,13 @@ def _update(self): init=self._u_old, obs=self.B.op(self._v_old) + self._u_old - self.b, ) - uA_new = self.A.op(self._u_new) + tmp = self.A.op(self._u_new) self._v_new = self.solver2( init=self._v_old, - obs=uA_new + self._u_old - self.c, + obs=tmp + self._u_old - self.c, ) - self._mu_new = self._mu_old + (uA_new + self.B.op(self._v_new) - self.b) + self._mu_new = self._mu_old + (tmp + self.B.op(self._v_new) - self.b) # update cycle self._u_old = self.xp.copy(self._u_new) @@ -110,9 +111,8 @@ def _update(self): # Test cost function for convergence. if self._cost_func: - self.converge = self.any_convergence_flag() or self._cost_func.get_cost( - self._u_new, self.v_new - ) + self.converge = self.any_convergence_flag() + self.converge |= self._cost_func.get_cost(self._u_new, self.v_new) def iterate(self, max_iter=150): """Iterate. @@ -165,7 +165,8 @@ def retrieve_outputs(self): class FastADMM(ADMM): r"""Fast ADMM Optimisation Algorihm. - This class implement the fast ADMM algorithm (Algorithm 8 from :cite:`Goldstein2014`) + This class implement the fast ADMM algorithm + (Algorithm 8 from :cite:`Goldstein2014`) Parameters ---------- @@ -192,15 +193,15 @@ class FastADMM(ADMM): ----- The algorithm solve the problem: - .. math:: u, v = \arg\min H(u) + G(v) + \frac{\tau}{2} \|Au + Bv - b \|_2^2 + .. math:: u, v = \arg\min H(u) + G(v) + \frac\tau2 \|Au + Bv - b \|_2^2 with the following augmented lagrangian: - .. math :: \mathcal{L}_{\tau}(u,v, \lambda) = H(u) + G(v) - +\langle\lambda |Au + Bv -b \rangle + \frac\tau2 \| Au + Bv -b \|^{2} + .. math:: \mathcal{L}_{\tau}(u,v, \lambda) = H(u) + G(v) + +\langle\lambda |Au + Bv -b \rangle + \frac\tau2 \| Au + Bv -b \|^2 - To allow easy iterative solving, the change of variable :math:`\mu=\lambda/\tau` - is used. Hence, the lagrangian of interest is: + To allow easy iterative solving, the change of variable + :math:`\mu=\lambda/\tau` is used. Hence, the lagrangian of interest is: .. math :: \tilde{\mathcal{L}}_{\tau}(u,v, \mu) = H(u) + G(v) + \frac\tau2 \left(\|\mu + Au +Bv - b\|^2 - \|\mu\|^2\right) @@ -256,13 +257,13 @@ def _update(self): init=self._u_old, obs=self.B.op(self._v_hat) + self._u_old - self.b, ) - uA_new = self.A.op(self._u_new) + tmp = self.A.op(self._u_new) self._v_new = self.solver2( init=self._v_hat, - obs=uA_new + self._u_hat - self.c, + obs=tmp + self._u_hat - self.c, ) - self._mu_new = self._mu_hat + (uA_new + self.B.op(self._v_new) - self.b) + self._mu_new = self._mu_hat + (tmp + self.B.op(self._v_new) - self.b) # restarting condition self._c_new = self.xp.linalg.norm(self._mu_new - self._mu_hat) @@ -271,9 +272,9 @@ def _update(self): ) if self._c_new < self._eta * self._c_old: self._alpha_new = 1 + np.sqrt(1 + 4 * self._alpha_old**2) - update_factor = (self._alpha_new - 1) / self._alpha_old - self._v_hat = self._v_new + (self._v_new - self._v_old) * update_factor - self._mu_hat = self._mu_new + (self._mu_new - self._mu_old) * update_factor + beta = (self._alpha_new - 1) / self._alpha_old + self._v_hat = self._v_new + (self._v_new - self._v_old) * beta + self._mu_hat = self._mu_new + (self._mu_new - self._mu_old) * beta else: # reboot to old iteration self._alpha_new = 1 @@ -286,6 +287,5 @@ def _update(self): self.xp.copyto(self._mu_old, self._mu_new) # Test cost function for convergence. if self._cost_func: - self.converge = self.any_convergence_flag() or self._cost_func.get_cost( - self._u_new - ) + self.converge = self.any_convergence_flag() + self.convergd |= self._cost_func.get_cost(self._u_new, self._v_new) From 23776d921027a4dd397197a8a98e163719529189 Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Tue, 15 Nov 2022 17:12:10 +0100 Subject: [PATCH 23/46] Abstract class for cost function. --- modopt/opt/cost.py | 152 +++++++++++++++++++++++++++++++++------------ 1 file changed, 114 insertions(+), 38 deletions(-) diff --git a/modopt/opt/cost.py b/modopt/opt/cost.py index 3cdfcc50..a90c87ae 100644 --- a/modopt/opt/cost.py +++ b/modopt/opt/cost.py @@ -6,6 +6,8 @@ """ +import abc + import numpy as np from modopt.base.backend import get_array_module @@ -13,8 +15,8 @@ from modopt.plot.cost_plot import plotCost -class costObj(object): - """Generic cost function object. +class AbstractcostObj(abc.ABC): + """Abstract cost function object. This class updates the cost according to the input operator classes and tests for convergence. @@ -40,7 +42,8 @@ class costObj(object): Notes ----- - The costFunc class must contain a method called ``cost``. + All child classes should implement a ``_calc_cost`` method (returning + a float) or a ``get_cost`` for more complex behavior on convergence test. Examples -------- @@ -71,7 +74,6 @@ class costObj(object): def __init__( self, - operators, initial_cost=1e6, tolerance=1e-4, cost_interval=1, @@ -80,9 +82,6 @@ def __init__( plot_output=None, ): - self._operators = operators - if not isinstance(operators, type(None)): - self._check_operators() self.cost = initial_cost self._cost_list = [] self._cost_interval = cost_interval @@ -93,30 +92,6 @@ def __init__( self._plot_output = plot_output self._verbose = verbose - def _check_operators(self): - """Check operators. - - This method checks if the input operators have a ``cost`` method. - - Raises - ------ - TypeError - For invalid operators type - ValueError - For operators without ``cost`` method - - """ - if not isinstance(self._operators, (list, tuple, np.ndarray)): - message = ( - 'Input operators must be provided as a list, not {0}' - ) - raise TypeError(message.format(type(self._operators))) - - for op in self._operators: - if not hasattr(op, 'cost'): - raise ValueError('Operators must contain "cost" method.') - op.cost = check_callable(op.cost) - def _check_cost(self): """Check cost function. @@ -167,6 +142,7 @@ def _check_cost(self): return False + @abc.abstractmethod def _calc_cost(self, *args, **kwargs): """Calculate the cost. @@ -178,14 +154,7 @@ def _calc_cost(self, *args, **kwargs): Positional arguments **kwargs : dict Keyword arguments - - Returns - ------- - float - Cost value - """ - return np.sum([op.cost(*args, **kwargs) for op in self._operators]) def get_cost(self, *args, **kwargs): """Get cost function. @@ -241,3 +210,110 @@ def plot_cost(self): # pragma: no cover """ plotCost(self._cost_list, self._plot_output) + + +class costObj(AbstractcostObj): + """Abstract cost function object. + + This class updates the cost according to the input operator classes and + tests for convergence. + + Parameters + ---------- + opertors : list, tuple or numpy.ndarray + List of operators classes containing ``cost`` method + initial_cost : float, optional + Initial value of the cost (default is ``1e6``) + tolerance : float, optional + Tolerance threshold for convergence (default is ``1e-4``) + cost_interval : int, optional + Iteration interval to calculate cost (default is ``1``). + If ``cost_interval`` is ``None`` the cost is never calculated, + thereby saving on computation time. + test_range : int, optional + Number of cost values to be used in test (default is ``4``) + verbose : bool, optional + Option for verbose output (default is ``True``) + plot_output : str, optional + Output file name for cost function plot + + Examples + -------- + >>> from modopt.opt.cost import * + >>> class dummy(object): + ... def cost(self, x): + ... return x ** 2 + ... + ... + >>> inst = costObj([dummy(), dummy()]) + >>> inst.get_cost(2) + - ITERATION: 1 + - COST: 8 + + False + >>> inst.get_cost(2) + - ITERATION: 2 + - COST: 8 + + False + >>> inst.get_cost(2) + - ITERATION: 3 + - COST: 8 + + False + """ + + def __init__( + self, + operators, + **kwargs, + ): + super().__init__(**kwargs) + + self._operators = operators + if not isinstance(operators, type(None)): + self._check_operators() + + def _check_operators(self): + """Check operators. + + This method checks if the input operators have a ``cost`` method. + + Raises + ------ + TypeError + For invalid operators type + ValueError + For operators without ``cost`` method + + """ + if not isinstance(self._operators, (list, tuple, np.ndarray)): + message = ( + 'Input operators must be provided as a list, not {0}' + ) + raise TypeError(message.format(type(self._operators))) + + for op in self._operators: + if not hasattr(op, 'cost'): + raise ValueError('Operators must contain "cost" method.') + op.cost = check_callable(op.cost) + + def _calc_cost(self, *args, **kwargs): + """Calculate the cost. + + This method calculates the cost from each of the input operators. + + Parameters + ---------- + *args : tuple + Positional arguments + **kwargs : dict + Keyword arguments + + Returns + ------- + float + Cost value + + """ + return np.sum([op.cost(*args, **kwargs) for op in self._operators]) From 38fefba4c2aed7f7dc4ba2d1ada2eb2f8b51f4f9 Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Tue, 15 Nov 2022 17:25:26 +0100 Subject: [PATCH 24/46] add custom cost operator for admm. --- modopt/opt/algorithms/admm.py | 72 ++++++++++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 10 deletions(-) diff --git a/modopt/opt/algorithms/admm.py b/modopt/opt/algorithms/admm.py index 1830a846..7fef879b 100644 --- a/modopt/opt/algorithms/admm.py +++ b/modopt/opt/algorithms/admm.py @@ -1,8 +1,66 @@ """ADMM Algorithms.""" import numpy as np +from modopt.base.backend import get_array_module from modopt.opt.algorithms.base import SetUp -from modopt.opt.cost import costObj +from modopt.opt.cost import AbstractcostObj + + +class ADMMcostObj(AbstractcostObj): + r"""Cost Object for the ADMM problem class. + + Compute :math:`f(u)+g(v) + \tau \| Au +Bv - b\|^2` + + Parameters + ---------- + cost_funcs: 2-tuples of callable + f and g function. + A : OperatorBase + First Operator + B : OperatorBase + Second Operator + b : array_like + Observed data + **kwargs : dict + Extra parameters for cost operator configuration + + + See Also + -------- + AbstractcostObj: parent class + """ + + def __init__(self, cost_funcs, A, B, b, tau, **kwargs): + super().__init__(*kwargs) + self.cost_funcs = cost_funcs + self.A = A + self.B = B + self.b = b + self.tau = tau + + def _calc_cost(self, u, v): + """Calculate the cost. + + This method calculates the cost from each of the input operators. + + Parameters + ---------- + u: array_like + First primal variable of ADMM + v: array_like + Second primal variable of ADMM + + Returns + ------- + float + Cost value + + """ + xp = get_array_module(u) + cost = self.cost_func[0](u) + cost += self.cost_func[1](v) + cost += self.tau * xp.linalg.norm(self.A.op(u) + self.B.op(v) - self.b) + return cost class ADMM(SetUp): @@ -64,8 +122,8 @@ def __init__( optimizers, tau=1, max_iter2=5, - cost_func=None, - **kwargs + cost_funcs=None, + **kwargs, ): super().__init__(**kwargs) self.A = A @@ -75,13 +133,7 @@ def __init__( self._opti_G = optimizers[1] self._tau = tau - self._cost_func = costObj() - # patching to get the full cost - self._cost_func._calc_cost = lambda u, v: ( - cost_func[0](u) - + cost_func[1](v) - + self.xp.linalg.norm(A.op(u) + B.op(v) - b) - ) + self._cost_func = ADMMcostObj(cost_funcs, A, B, b, tau) # init iteration variables. self._u_old = self.xp.copy(u) From 3d710444e189a703dcfebf4bfc219bf08472f865 Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Tue, 15 Nov 2022 17:25:46 +0100 Subject: [PATCH 25/46] fix WPS compliance. --- modopt/opt/algorithms/admm.py | 16 ++++++++-------- setup.cfg | 2 ++ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/modopt/opt/algorithms/admm.py b/modopt/opt/algorithms/admm.py index 7fef879b..5925c9ae 100644 --- a/modopt/opt/algorithms/admm.py +++ b/modopt/opt/algorithms/admm.py @@ -197,9 +197,9 @@ def get_notify_observers_kwargs(self): The mapping between the iterated variables """ return { - "u_new": self._u_new, - "v_new": self._v_new, - "idx": self.idx, + 'u_new': self._u_new, + 'v_new': self._v_new, + 'idx': self.idx, } def retrieve_outputs(self): @@ -209,7 +209,7 @@ def retrieve_outputs(self): y_final, metrics. """ metrics = {} - for obs in self._observers["cv_metrics"]: + for obs in self._observers['cv_metrics']: metrics[obs.name] = obs.retrieve_metrics() self.metrics = metrics @@ -279,7 +279,7 @@ def __init__( opti_H_kwargs=None, opti_G_kwargs=None, cost=None, - **kwargs + **kwargs, ): super().__init__( u=u, @@ -293,10 +293,10 @@ def __init__( opti_H_kwargs=opti_H_kwargs, opti_G_kwargs=opti_G, cost=None, - **kwargs + **kwargs, ) self._c_old = np.inf - self._c_new = 0.0 + self._c_new = 0 self._eta = eta self._alpha_old = alpha self._alpha_new = alpha @@ -320,7 +320,7 @@ def _update(self): # restarting condition self._c_new = self.xp.linalg.norm(self._mu_new - self._mu_hat) self._c_new += self._tau * self.xp.linalg.norm( - self.B.op(self._v_new - self._v_hat) + self.B.op(self._v_new - self._v_hat), ) if self._c_new < self._eta * self._c_old: self._alpha_new = 1 + np.sqrt(1 + 4 * self._alpha_old**2) diff --git a/setup.cfg b/setup.cfg index 87496ced..e5065be8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -42,6 +42,8 @@ per-file-ignores = modopt/opt/algorithms/__init__.py: F401,F403,WPS318, WPS319, WPS412, WPS410 #Todo: x is a too short name. modopt/opt/algorithms/forward_backward.py: WPS111 + #Todo: u,v , A is a too short name. + modopt/opt/algorithms/admm.py: WPS111, N803 #Todo: Check need for del statement modopt/opt/algorithms/primal_dual.py: WPS111, WPS420 #multiline parameters bug with tuples From efcf30168d1b7d9c80734b0db7d308c005693f94 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Comby <77174042+paquiteau@users.noreply.github.com> Date: Mon, 2 Jan 2023 14:54:53 +0100 Subject: [PATCH 26/46] Ci update (#268) * update python version support. * use string for CI. * remove flake8 and wemake-python-styleguide This anticipates the change to black formatting. * remove wps checks * apparently conda does not support 3.11 for now * remove all linting testing. * fix np.int warning/error * fix dtype error * fix precision for doctest * added black and isort support * Update python version in README * add 3.7 for test back * don't test 3.10 twice --- .github/workflows/ci-build.yml | 13 ++----------- README.md | 4 ++-- develop.txt | 8 +++----- modopt/signal/filter.py | 8 ++++---- modopt/signal/positivity.py | 2 +- modopt/signal/svd.py | 4 ++-- setup.cfg | 1 - setup.py | 2 +- 8 files changed, 15 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 3ffcb6f4..2279abef 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -16,21 +16,12 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest] - python-version: [3.8] + python-version: ["3.10"] steps: - name: Checkout uses: actions/checkout@v2 - - name: Report WPS Errors - uses: wemake-services/wemake-python-styleguide@0.14.1 - continue-on-error: true - with: - reporter: 'github-pr-review' - path: './modopt' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Set up Conda with Python ${{ matrix.python-version }} uses: conda-incubator/setup-miniconda@v2 with: @@ -98,7 +89,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest] - python-version: [3.6, 3.7, 3.9] + python-version: ["3.7", "3.8", "3.9"] steps: - name: Checkout diff --git a/README.md b/README.md index 91bebfc2..223d0b73 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,11 @@ All packages required by ModOpt should be installed automatically. Optional pack In order to run the code in this repository the following packages must be installed: -* [Python](https://www.python.org/) [> 3.6] +* [Python](https://www.python.org/) [> 3.7] * [importlib_metadata](https://importlib-metadata.readthedocs.io/en/latest/) [==3.7.0] * [Numpy](http://www.numpy.org/) [==1.19.5] * [Scipy](http://www.scipy.org/) [==1.5.4] -* [tqdm]([https://progressbar-2.readthedocs.io/](https://tqdm.github.io/) [>=4.64.0] +* [tqdm](https://tqdm.github.io/) [>=4.64.0] ### Optional Packages diff --git a/develop.txt b/develop.txt index 7397e15c..25857153 100644 --- a/develop.txt +++ b/develop.txt @@ -1,10 +1,8 @@ coverage>=5.5 -flake8>=4 -nose>=1.3.7 pytest>=6.2.2 pytest-cov>=2.11.1 -pytest-pep8>=1.0.6 pytest-emoji>=0.2.0 -pytest-flake8>=1.0.7 -wemake-python-styleguide>=0.15.2 pytest-pydocstyle>=2.2.0 +black +isort +pytest-black diff --git a/modopt/signal/filter.py b/modopt/signal/filter.py index 8e24768c..84dd8160 100644 --- a/modopt/signal/filter.py +++ b/modopt/signal/filter.py @@ -73,8 +73,8 @@ def mex_hat(data_point, sigma): Examples -------- >>> from modopt.signal.filter import mex_hat - >>> mex_hat(2, 1) - -0.3521390522571337 + >>> round(mex_hat(2, 1), 15) + -0.352139052257134 """ data_point = check_float(data_point) @@ -108,8 +108,8 @@ def mex_hat_dir(data_gauss, data_mex, sigma): Examples -------- >>> from modopt.signal.filter import mex_hat_dir - >>> mex_hat_dir(1, 2, 1) - 0.17606952612856686 + >>> round(mex_hat_dir(1, 2, 1), 16) + 0.1760695261285668 """ data_gauss = check_float(data_gauss) diff --git a/modopt/signal/positivity.py b/modopt/signal/positivity.py index e4ec098d..c19ba62c 100644 --- a/modopt/signal/positivity.py +++ b/modopt/signal/positivity.py @@ -48,7 +48,7 @@ def pos_recursive(input_data): """ if input_data.dtype == 'O': - res = np.array([pos_recursive(elem) for elem in input_data]) + res = np.array([pos_recursive(elem) for elem in input_data], dtype="object") else: res = pos_thresh(input_data) diff --git a/modopt/signal/svd.py b/modopt/signal/svd.py index 6dcb9eda..f3d40a51 100644 --- a/modopt/signal/svd.py +++ b/modopt/signal/svd.py @@ -57,7 +57,7 @@ def find_n_pc(u_vec, factor=0.5): ) # Get the shape of the array - array_shape = np.repeat(np.int(np.sqrt(u_vec.shape[0])), 2) + array_shape = np.repeat(int(np.sqrt(u_vec.shape[0])), 2) # Find the auto correlation of the left singular vector. u_auto = [ @@ -299,7 +299,7 @@ def svd_thresh_coef(input_data, operator, threshold, thresh_type='hard'): a_matrix = np.dot(s_values, v_vec) # Get the shape of the array - array_shape = np.repeat(np.int(np.sqrt(u_vec.shape[0])), 2) + array_shape = np.repeat(int(np.sqrt(u_vec.shape[0])), 2) # Compute threshold matrix. ti = np.array([ diff --git a/setup.cfg b/setup.cfg index 87496ced..afe46bbc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -84,7 +84,6 @@ testpaths = addopts = --verbose --emoji - --flake8 --cov=modopt --cov-report=term --cov-report=xml diff --git a/setup.py b/setup.py index c93dd020..c95e5984 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,7 @@ license = 'MIT' # Set the package classifiers -python_versions_supported = ['3.6', '3.7', '3.8', '3.9'] +python_versions_supported = ['3.7', '3.8', '3.9', '3.10', '3.11'] os_platforms_supported = ['Unix', 'MacOS'] lc_str = 'License :: OSI Approved :: {0} License' From eeb8d174ad7d7777ebc2b049d710972c50ece4f7 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Comby <77174042+paquiteau@users.noreply.github.com> Date: Tue, 3 Jan 2023 15:08:12 +0100 Subject: [PATCH 27/46] Test rewrite (#266) * add MatrixOperator. * move base test to pytest. * [fixme] remove flake8 and emoji config. * rewrite test_math module using pytest. * use fail/skipparam helper function. * generalize usage of failparam * refactor test_signal. * refactor test_signal, the end. * lint * fix missing parameter. * add dummy object test helper. * rewrite test for cost and gradients. * show missing lines in coverage reports * rewrite of proximity operators testing. * add fail low rank method. * add cases for algorithms test * add algorithm test. * add pytest-cases and pytest-xdists support. * add support for testing metrics. * improve base module coverage. * test for wrong mask in metric module. * add docstring. * update email adress and authors field. * 100% coverage for transform module. * move linear operator to class * update docstring. * paramet(e)rization. * update docstring. * improve test_helper module. * raises should be specified for each failparam call. * encapsulate module's test in classes. * skip test if sklearn is not installed. * pin pydocstyle --- .github/workflows/ci-build.yml | 2 +- develop.txt | 4 + modopt/opt/linear.py | 18 + modopt/tests/test_algorithms.py | 641 ++++-------- modopt/tests/test_base.py | 435 +++----- modopt/tests/test_helpers/__init__.py | 1 + modopt/tests/test_helpers/utils.py | 23 + modopt/tests/test_math.py | 671 +++++------- modopt/tests/test_opt.py | 1376 ++++++++----------------- modopt/tests/test_signal.py | 561 +++++----- setup.cfg | 5 +- 11 files changed, 1322 insertions(+), 2415 deletions(-) create mode 100644 modopt/tests/test_helpers/__init__.py create mode 100644 modopt/tests/test_helpers/utils.py diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 2279abef..cdfff840 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -52,7 +52,7 @@ jobs: shell: bash -l {0} run: | export PATH=/usr/share/miniconda/bin:$PATH - python setup.py test + pytest -n 2 - name: Save Test Results if: always() diff --git a/develop.txt b/develop.txt index 25857153..6ff665eb 100644 --- a/develop.txt +++ b/develop.txt @@ -1,7 +1,11 @@ coverage>=5.5 pytest>=6.2.2 +pytest-raises>=0.10 +pytest-cases>= 3.6 +pytest-xdist>= 3.0.1 pytest-cov>=2.11.1 pytest-emoji>=0.2.0 +pydocstyle==6.1.1 pytest-pydocstyle>=2.2.0 black isort diff --git a/modopt/opt/linear.py b/modopt/opt/linear.py index 3807253b..83241625 100644 --- a/modopt/opt/linear.py +++ b/modopt/opt/linear.py @@ -11,6 +11,7 @@ import numpy as np from modopt.base.types import check_callable, check_float +from modopt.base.backend import get_array_module from modopt.signal.wavelet import filter_convolve_stack @@ -80,6 +81,23 @@ def __init__(self): self.adj_op = self.op +class MatrixOperator(LinearParent): + """ + Matrix Operator class. + + This class transforms an array into a suitable linear operator. + """ + + def __init__(self, array): + self.op = lambda x: array @ x + xp = get_array_module(array) + + if xp.any(xp.iscomplex(array)): + self.adj_op = lambda x: array.T.conjugate() @ x + else: + self.adj_op = lambda x: array.T @ x + + class WaveletConvolve(LinearParent): """Wavelet Convolution Class. diff --git a/modopt/tests/test_algorithms.py b/modopt/tests/test_algorithms.py index 7ff96a8b..73091acd 100644 --- a/modopt/tests/test_algorithms.py +++ b/modopt/tests/test_algorithms.py @@ -1,470 +1,249 @@ # -*- coding: utf-8 -*- -"""UNIT TESTS FOR OPT.ALGORITHMS. +"""UNIT TESTS FOR Algorithms. -This module contains unit tests for the modopt.opt.algorithms module. - -:Author: Samuel Farrens +This module contains unit tests for the modopt.opt module. +:Authors: + Samuel Farrens + Pierre-Antoine Comby """ -from unittest import TestCase - import numpy as np import numpy.testing as npt - +import pytest from modopt.opt import algorithms, cost, gradient, linear, proximity, reweight - -# Basic functions to be used as operators or as dummy functions -func_identity = lambda x_val: x_val -func_double = lambda x_val: x_val * 2 -func_sq = lambda x_val: x_val ** 2 -func_cube = lambda x_val: x_val ** 3 - - -class Dummy(object): - """Dummy class for tests.""" - - pass - - -class AlgorithmTestCase(TestCase): - """Test case for algorithms module.""" - - def setUp(self): - """Set test parameter values.""" - self.data1 = np.arange(9).reshape(3, 3).astype(float) - self.data2 = self.data1 + np.random.randn(*self.data1.shape) * 1e-6 - self.data3 = np.arange(9).reshape(3, 3).astype(float) + 1 - - grad_inst = gradient.GradBasic( - self.data1, - func_identity, - func_identity, - ) - +from pytest_cases import ( + case, + fixture, + fixture_ref, + lazy_value, + parametrize, + parametrize_with_cases, +) + +from test_helpers import Dummy + +SKLEARN_AVAILABLE = True +try: + import sklearn +except ImportError: + SKLEARN_AVAILABLE = False + + +@fixture +def idty(): + """Identity function.""" + return lambda x: x + + +@fixture +def reweight_op(): + """Reweight operator.""" + data3 = np.arange(9).reshape(3, 3).astype(float) + 1 + return reweight.cwbReweight(data3) + + +def build_kwargs(kwargs, use_metrics): + """Build the kwargs for each algorithm, replacing placeholders by true values. + + This function has to be call for each test, as direct parameterization somehow + is not working with pytest-xdist and pytest-cases. + It also adds dummy metric measurement to validate the metric api. + """ + update_value = { + "idty": lambda x: x, + "lin_idty": linear.Identity(), + "reweight_op": reweight.cwbReweight( + np.arange(9).reshape(3, 3).astype(float) + 1 + ), + } + new_kwargs = dict() + print(kwargs) + # update the value of the dict is possible. + for key in kwargs: + new_kwargs[key] = update_value.get(kwargs[key], kwargs[key]) + + if use_metrics: + new_kwargs["linear"] = linear.Identity() + new_kwargs["metrics"] = { + "diff": { + "metric": lambda test, ref: np.sum(test - ref), + "mapping": {"x_new": "test"}, + "cst_kwargs": {"ref": np.arange(9).reshape((3, 3))}, + "early_stopping": False, + } + } + + return new_kwargs + + +@parametrize(use_metrics=[True, False]) +class AlgoCases: + """Cases for algorithms.""" + + data1 = np.arange(9).reshape(3, 3).astype(float) + data2 = data1 + np.random.randn(*data1.shape) * 1e-6 + max_iter = 20 + + @parametrize( + kwargs=[ + {"beta_update": "idty", "auto_iterate": False, "cost": None}, + {"beta_update": "idty"}, + {"cost": None, "lambda_update": None}, + {"beta_update": "idty", "a_cd": 3}, + {"beta_update": "idty", "r_lazy": 3, "p_lazy": 0.7, "q_lazy": 0.7}, + {"restart_strategy": "adaptive", "xi_restart": 0.9}, + { + "restart_strategy": "greedy", + "xi_restart": 0.9, + "min_beta": 1.0, + "s_greedy": 1.1, + }, + ] + ) + def case_forward_backward(self, kwargs, idty, use_metrics): + """Forward Backward case.""" + update_kwargs = build_kwargs(kwargs, use_metrics) + algo = algorithms.ForwardBackward( + self.data1, + grad=gradient.GradBasic(self.data1, idty, idty), + prox=proximity.Positivity(), + **update_kwargs, + ) + if update_kwargs.get("auto_iterate", None) is False: + algo.iterate(self.max_iter) + return algo, update_kwargs + + @parametrize( + kwargs=[ + { + "cost": None, + "auto_iterate": False, + "gamma_update": "idty", + "beta_update": "idty", + }, + {"gamma_update": "idty", "lambda_update": "idty"}, + {"cost": True}, + {"cost": True, "step_size": 2}, + ] + ) + def case_gen_forward_backward(self, kwargs, use_metrics, idty): + """General FB setup.""" + update_kwargs = build_kwargs(kwargs, use_metrics) + grad_inst = gradient.GradBasic(self.data1, idty, idty) prox_inst = proximity.Positivity() prox_dual_inst = proximity.IdentityProx() - linear_inst = linear.Identity() - reweight_inst = reweight.cwbReweight(self.data3) - cost_inst = cost.costObj([grad_inst, prox_inst, prox_dual_inst]) - self.setup = algorithms.SetUp() - self.max_iter = 20 - - self.fb_all_iter = algorithms.ForwardBackward( - self.data1, - grad=grad_inst, - prox=prox_inst, - cost=None, - auto_iterate=False, - beta_update=func_identity, - ) - self.fb_all_iter.iterate(self.max_iter) - - self.fb1 = algorithms.ForwardBackward( - self.data1, - grad=grad_inst, - prox=prox_inst, - beta_update=func_identity, - ) - - self.fb2 = algorithms.ForwardBackward( - self.data1, - grad=grad_inst, - prox=prox_inst, - cost=cost_inst, - lambda_update=None, - ) - - self.fb3 = algorithms.ForwardBackward( - self.data1, - grad=grad_inst, - prox=prox_inst, - beta_update=func_identity, - a_cd=3, - ) - - self.fb4 = algorithms.ForwardBackward( - self.data1, - grad=grad_inst, - prox=prox_inst, - beta_update=func_identity, - r_lazy=3, - p_lazy=0.7, - q_lazy=0.7, - ) - - self.fb5 = algorithms.ForwardBackward( - self.data1, - grad=grad_inst, - prox=prox_inst, - restart_strategy='adaptive', - xi_restart=0.9, - ) - - self.fb6 = algorithms.ForwardBackward( - self.data1, - grad=grad_inst, - prox=prox_inst, - restart_strategy='greedy', - xi_restart=0.9, - min_beta=1.0, - s_greedy=1.1, - ) - - self.gfb_all_iter = algorithms.GenForwardBackward( - self.data1, - grad=grad_inst, - prox_list=[prox_inst, prox_dual_inst], - cost=None, - auto_iterate=False, - gamma_update=func_identity, - beta_update=func_identity, - ) - self.gfb_all_iter.iterate(self.max_iter) - - self.gfb1 = algorithms.GenForwardBackward( - self.data1, - grad=grad_inst, - prox_list=[prox_inst, prox_dual_inst], - gamma_update=func_identity, - lambda_update=func_identity, - ) - - self.gfb2 = algorithms.GenForwardBackward( - self.data1, - grad=grad_inst, - prox_list=[prox_inst, prox_dual_inst], - cost=cost_inst, - ) - - self.gfb3 = algorithms.GenForwardBackward( + if update_kwargs.get("cost", None) is True: + update_kwargs["cost"] = cost.costObj([grad_inst, prox_inst, prox_dual_inst]) + algo = algorithms.GenForwardBackward( self.data1, grad=grad_inst, prox_list=[prox_inst, prox_dual_inst], - cost=cost_inst, - step_size=2, - ) - - self.condat_all_iter = algorithms.Condat( - self.data1, - self.data2, - grad=grad_inst, - prox=prox_inst, - cost=None, - prox_dual=prox_dual_inst, - sigma_update=func_identity, - tau_update=func_identity, - rho_update=func_identity, - auto_iterate=False, - ) - self.condat_all_iter.iterate(self.max_iter) - - self.condat1 = algorithms.Condat( - self.data1, - self.data2, - grad=grad_inst, - prox=prox_inst, - prox_dual=prox_dual_inst, - sigma_update=func_identity, - tau_update=func_identity, - rho_update=func_identity, - ) - - self.condat2 = algorithms.Condat( - self.data1, - self.data2, - grad=grad_inst, - prox=prox_inst, - prox_dual=prox_dual_inst, - linear=linear_inst, - cost=cost_inst, - reweight=reweight_inst, - ) + **update_kwargs, + ) + if update_kwargs.get("auto_iterate", None) is False: + algo.iterate(self.max_iter) + return algo, update_kwargs + + @parametrize( + kwargs=[ + { + "sigma_dual": "idty", + "tau_update": "idty", + "rho_update": "idty", + "auto_iterate": False, + }, + { + "sigma_dual": "idty", + "tau_update": "idty", + "rho_update": "idty", + }, + { + "linear": "lin_idty", + "cost": True, + "reweight": "reweight_op", + }, + ] + ) + def case_condat(self, kwargs, use_metrics, idty): + """Condat Vu Algorithm setup.""" + update_kwargs = build_kwargs(kwargs, use_metrics) + grad_inst = gradient.GradBasic(self.data1, idty, idty) + prox_inst = proximity.Positivity() + prox_dual_inst = proximity.IdentityProx() + if update_kwargs.get("cost", None) is True: + update_kwargs["cost"] = cost.costObj([grad_inst, prox_inst, prox_dual_inst]) - self.condat3 = algorithms.Condat( + algo = algorithms.Condat( self.data1, self.data2, grad=grad_inst, prox=prox_inst, prox_dual=prox_dual_inst, - linear=Dummy(), - cost=cost_inst, - auto_iterate=False, + **update_kwargs, ) + if update_kwargs.get("auto_iterate", None) is False: + algo.iterate(self.max_iter) + return algo, update_kwargs - self.pogm_all_iter = algorithms.POGM( + @parametrize(kwargs=[{"auto_iterate": False, "cost": None}, {}]) + def case_pogm(self, kwargs, use_metrics, idty): + """POGM setup.""" + update_kwargs = build_kwargs(kwargs, use_metrics) + grad_inst = gradient.GradBasic(self.data1, idty, idty) + prox_inst = proximity.Positivity() + algo = algorithms.POGM( u=self.data1, x=self.data1, y=self.data1, z=self.data1, grad=grad_inst, prox=prox_inst, - auto_iterate=False, - cost=None, + **update_kwargs, ) - self.pogm_all_iter.iterate(self.max_iter) - self.pogm1 = algorithms.POGM( - u=self.data1, - x=self.data1, - y=self.data1, - z=self.data1, - grad=grad_inst, - prox=prox_inst, - ) + if update_kwargs.get("auto_iterate", None) is False: + algo.iterate(self.max_iter) + return algo, update_kwargs - self.vanilla_grad = algorithms.VanillaGenericGradOpt( - self.data1, - grad=grad_inst, - prox=prox_inst, - cost=cost_inst, - ) - self.ada_grad = algorithms.AdaGenericGradOpt( - self.data1, - grad=grad_inst, - prox=prox_inst, - cost=cost_inst, - ) - self.adam_grad = algorithms.ADAMGradOpt( - self.data1, - grad=grad_inst, - prox=prox_inst, - cost=cost_inst, - ) - self.momentum_grad = algorithms.MomentumGradOpt( - self.data1, - grad=grad_inst, - prox=prox_inst, - cost=cost_inst, - ) - self.rms_grad = algorithms.RMSpropGradOpt( - self.data1, - grad=grad_inst, - prox=prox_inst, - cost=cost_inst, - ) - self.saga_grad = algorithms.SAGAOptGradOpt( + @parametrize( + GradDescent=[ + algorithms.VanillaGenericGradOpt, + algorithms.AdaGenericGradOpt, + algorithms.ADAMGradOpt, + algorithms.MomentumGradOpt, + algorithms.RMSpropGradOpt, + algorithms.SAGAOptGradOpt, + ] + ) + def case_grad(self, GradDescent, use_metrics, idty): + """Gradient Descent algorithm test.""" + update_kwargs = build_kwargs({}, use_metrics) + grad_inst = gradient.GradBasic(self.data1, idty, idty) + prox_inst = proximity.Positivity() + cost_inst = cost.costObj([grad_inst, prox_inst]) + + algo = GradDescent( self.data1, grad=grad_inst, prox=prox_inst, cost=cost_inst, + **update_kwargs, ) + algo.iterate() + return algo, update_kwargs - self.dummy = Dummy() - self.dummy.cost = func_identity - self.setup._check_operator(self.dummy.cost) - - def tearDown(self): - """Unset test parameter values.""" - self.data1 = None - self.data2 = None - self.setup = None - self.fb_all_iter = None - self.fb1 = None - self.fb2 = None - self.gfb_all_iter = None - self.gfb1 = None - self.gfb2 = None - self.condat_all_iter = None - self.condat1 = None - self.condat2 = None - self.condat3 = None - self.pogm1 = None - self.pogm_all_iter = None - self.dummy = None - - def test_set_up(self): - """Test set_up.""" - npt.assert_raises(TypeError, self.setup._check_input_data, 1) - - npt.assert_raises(TypeError, self.setup._check_param, 1) - - npt.assert_raises(TypeError, self.setup._check_param_update, 1) - - def test_all_iter(self): - """Test if all opt run for all iterations.""" - opts = [ - self.fb_all_iter, - self.gfb_all_iter, - self.condat_all_iter, - self.pogm_all_iter, - ] - for opt in opts: - npt.assert_equal(opt.idx, self.max_iter - 1) - - def test_forward_backward(self): - """Test forward_backward.""" - npt.assert_array_equal( - self.fb1.x_final, - self.data1, - err_msg='Incorrect ForwardBackward result.', - ) - - npt.assert_array_equal( - self.fb2.x_final, - self.data1, - err_msg='Incorrect ForwardBackward result.', - ) - - npt.assert_array_equal( - self.fb3.x_final, - self.data1, - err_msg='Incorrect ForwardBackward result.', - ) - - npt.assert_array_equal( - self.fb4.x_final, - self.data1, - err_msg='Incorrect ForwardBackward result.', - ) - - npt.assert_array_equal( - self.fb5.x_final, - self.data1, - err_msg='Incorrect ForwardBackward result.', - ) - - npt.assert_array_equal( - self.fb6.x_final, - self.data1, - err_msg='Incorrect ForwardBackward result.', - ) - - def test_gen_forward_backward(self): - """Test gen_forward_backward.""" - npt.assert_array_equal( - self.gfb1.x_final, - self.data1, - err_msg='Incorrect GenForwardBackward result.', - ) - - npt.assert_array_equal( - self.gfb2.x_final, - self.data1, - err_msg='Incorrect GenForwardBackward result.', - ) - - npt.assert_array_equal( - self.gfb3.x_final, - self.data1, - err_msg='Incorrect GenForwardBackward result.', - ) - - npt.assert_equal( - self.gfb3.step_size, - 2, - err_msg='Incorrect step size.', - ) - npt.assert_raises( - TypeError, - algorithms.GenForwardBackward, - self.data1, - self.dummy, - [self.dummy], - weights=1, - ) +@parametrize_with_cases("algo, kwargs", cases=AlgoCases) +def test_algo(algo, kwargs): + """Test algorithms.""" + if kwargs.get("auto_iterate") is False: + # algo already run + npt.assert_almost_equal(algo.idx, AlgoCases.max_iter - 1) + else: + npt.assert_almost_equal(algo.x_final, AlgoCases.data1) - npt.assert_raises( - ValueError, - algorithms.GenForwardBackward, - self.data1, - self.dummy, - [self.dummy], - weights=[1], - ) - - npt.assert_raises( - ValueError, - algorithms.GenForwardBackward, - self.data1, - self.dummy, - [self.dummy], - weights=[0.5, 0.5], - ) - - npt.assert_raises( - ValueError, - algorithms.GenForwardBackward, - self.data1, - self.dummy, - [self.dummy], - weights=[0.5], - ) - - def test_condat(self): - """Test gen_condat.""" - npt.assert_almost_equal( - self.condat1.x_final, - self.data1, - err_msg='Incorrect Condat result.', - ) - - npt.assert_almost_equal( - self.condat2.x_final, - self.data1, - err_msg='Incorrect Condat result.', - ) - - def test_pogm(self): - """Test pogm.""" - npt.assert_almost_equal( - self.pogm1.x_final, - self.data1, - err_msg='Incorrect POGM result.', - ) - - def test_ada_grad(self): - """Test ADA Gradient Descent.""" - self.ada_grad.iterate() - npt.assert_almost_equal( - self.ada_grad.x_final, - self.data1, - err_msg='Incorrect ADAGrad results.', - ) - - def test_adam_grad(self): - """Test ADAM Gradient Descent.""" - self.adam_grad.iterate() - npt.assert_almost_equal( - self.adam_grad.x_final, - self.data1, - err_msg='Incorrect ADAMGrad results.', - ) - - def test_momemtum_grad(self): - """Test Momemtum Gradient Descent.""" - self.momentum_grad.iterate() - npt.assert_almost_equal( - self.momentum_grad.x_final, - self.data1, - err_msg='Incorrect MomentumGrad results.', - ) - - def test_rmsprop_grad(self): - """Test RMSProp Gradient Descent.""" - self.rms_grad.iterate() - npt.assert_almost_equal( - self.rms_grad.x_final, - self.data1, - err_msg='Incorrect RMSPropGrad results.', - ) - - def test_saga_grad(self): - """Test SAGA Descent.""" - self.saga_grad.iterate() - npt.assert_almost_equal( - self.saga_grad.x_final, - self.data1, - err_msg='Incorrect SAGA Grad results.', - ) - - def test_vanilla_grad(self): - """Test Vanilla Gradient Descent.""" - self.vanilla_grad.iterate() - npt.assert_almost_equal( - self.vanilla_grad.x_final, - self.data1, - err_msg='Incorrect VanillaGrad results.', - ) + if kwargs.get("metrics"): + print(algo.metrics) + npt.assert_almost_equal(algo.metrics["diff"]["values"][-1], 0, 3) diff --git a/modopt/tests/test_base.py b/modopt/tests/test_base.py index 873a4506..e32ff94b 100644 --- a/modopt/tests/test_base.py +++ b/modopt/tests/test_base.py @@ -1,192 +1,139 @@ -# -*- coding: utf-8 -*- - -"""UNIT TESTS FOR BASE. - -This module contains unit tests for the modopt.base module. - -:Author: Samuel Farrens - """ +Test for base module. -from builtins import range -from unittest import TestCase, skipIf - +:Authors: + Samuel Farrens + Pierre-Antoine Comby +""" import numpy as np import numpy.testing as npt +import pytest +from test_helpers import failparam, skipparam -from modopt.base import np_adjust, transform, types -from modopt.base.backend import (LIBRARIES, change_backend, get_array_module, - get_backend) +from modopt.base import backend, np_adjust, transform, types +from modopt.base.backend import LIBRARIES -class NPAdjustTestCase(TestCase): - """Test case for np_adjust module.""" +class TestNpAdjust: + """Test for npadjust.""" - def setUp(self): - """Set test parameter values.""" - self.data1 = np.arange(9).reshape((3, 3)) - self.data2 = np.arange(18).reshape((2, 3, 3)) - self.data3 = np.array([ + array33 = np.arange(9).reshape((3, 3)) + array233 = np.arange(18).reshape((2, 3, 3)) + arraypad = np.array( + [ [0, 0, 0, 0, 0], [0, 0, 1, 2, 0], [0, 3, 4, 5, 0], [0, 6, 7, 8, 0], [0, 0, 0, 0, 0], - ]) - - def tearDown(self): - """Unset test parameter values.""" - self.data1 = None - self.data2 = None - self.data3 = None + ] + ) def test_rotate(self): """Test rotate.""" npt.assert_array_equal( - np_adjust.rotate(self.data1), - np.array([[8, 7, 6], [5, 4, 3], [2, 1, 0]]), - err_msg='Incorrect rotation', + np_adjust.rotate(self.array33), + np.rot90(np.rot90(self.array33)), + err_msg="Incorrect rotation.", ) def test_rotate_stack(self): """Test rotate_stack.""" npt.assert_array_equal( - np_adjust.rotate_stack(self.data2), - np.array([ - [[8, 7, 6], [5, 4, 3], [2, 1, 0]], - [[17, 16, 15], [14, 13, 12], [11, 10, 9]], - ]), - err_msg='Incorrect stack rotation', + np_adjust.rotate_stack(self.array233), + np.rot90(self.array233, k=2, axes=(1, 2)), + err_msg="Incorrect stack rotation.", ) - def test_pad2d(self): + @pytest.mark.parametrize( + "padding", + [ + 1, + [1, 1], + np.array([1, 1]), + failparam("1", raises=ValueError), + ], + ) + def test_pad2d(self, padding): """Test pad2d.""" - npt.assert_array_equal( - np_adjust.pad2d(self.data1, (1, 1)), - self.data3, - err_msg='Incorrect padding', - ) - - npt.assert_array_equal( - np_adjust.pad2d(self.data1, 1), - self.data3, - err_msg='Incorrect padding', - ) - - npt.assert_array_equal( - np_adjust.pad2d(self.data1, np.array([1, 1])), - self.data3, - err_msg='Incorrect padding', - ) - - npt.assert_raises(ValueError, np_adjust.pad2d, self.data1, '1') + npt.assert_equal(np_adjust.pad2d(self.array33, padding), self.arraypad) def test_fancy_transpose(self): - """Test fancy_transpose.""" + """Test fancy transpose.""" npt.assert_array_equal( - np_adjust.fancy_transpose(self.data2), - np.array([ - [[0, 3, 6], [9, 12, 15]], - [[1, 4, 7], [10, 13, 16]], - [[2, 5, 8], [11, 14, 17]], - ]), - err_msg='Incorrect fancy transpose', + np_adjust.fancy_transpose(self.array233), + np.array( + [ + [[0, 3, 6], [9, 12, 15]], + [[1, 4, 7], [10, 13, 16]], + [[2, 5, 8], [11, 14, 17]], + ] + ), + err_msg="Incorrect fancy transpose", ) def test_ftr(self): """Test ftr.""" npt.assert_array_equal( - np_adjust.ftr(self.data2), - np.array([ - [[0, 3, 6], [9, 12, 15]], - [[1, 4, 7], [10, 13, 16]], - [[2, 5, 8], [11, 14, 17]], - ]), - err_msg='Incorrect fancy transpose: ftr', + np_adjust.ftr(self.array233), + np.array( + [ + [[0, 3, 6], [9, 12, 15]], + [[1, 4, 7], [10, 13, 16]], + [[2, 5, 8], [11, 14, 17]], + ] + ), + err_msg="Incorrect fancy transpose: ftr", ) def test_ftl(self): - """Test ftl.""" - npt.assert_array_equal( - np_adjust.ftl(self.data2), - np.array([ - [[0, 9], [1, 10], [2, 11]], - [[3, 12], [4, 13], [5, 14]], - [[6, 15], [7, 16], [8, 17]], - ]), - err_msg='Incorrect fancy transpose: ftl', - ) - - -class TransformTestCase(TestCase): - """Test case for transform module.""" - - def setUp(self): - """Set test parameter values.""" - self.cube = np.arange(16).reshape((4, 2, 2)) - self.map = np.array( - [[0, 1, 4, 5], [2, 3, 6, 7], [8, 9, 12, 13], [10, 11, 14, 15]], - ) - self.matrix = np.array( - [[0, 4, 8, 12], [1, 5, 9, 13], [2, 6, 10, 14], [3, 7, 11, 15]], - ) - self.layout = (2, 2) - - def tearDown(self): - """Unset test parameter values.""" - self.cube = None - self.map = None - self.layout = None - - def test_cube2map(self): + """Test fancy transpose left.""" + npt.assert_array_equal( + np_adjust.ftl(self.array233), + np.array( + [ + [[0, 9], [1, 10], [2, 11]], + [[3, 12], [4, 13], [5, 14]], + [[6, 15], [7, 16], [8, 17]], + ] + ), + err_msg="Incorrect fancy transpose: ftl", + ) + + +class TestTransforms: + """Test for the transform module.""" + + cube = np.arange(16).reshape((4, 2, 2)) + map = np.array([[0, 1, 4, 5], [2, 3, 6, 7], [8, 9, 12, 13], [10, 11, 14, 15]]) + matrix = np.array([[0, 4, 8, 12], [1, 5, 9, 13], [2, 6, 10, 14], [3, 7, 11, 15]]) + layout = (2, 2) + fail_layout = (3, 3) + + @pytest.mark.parametrize( + ("func", "indata", "layout", "outdata"), + [ + (transform.cube2map, cube, layout, map), + failparam(transform.cube2map, np.eye(2), layout, map, raises=ValueError), + (transform.map2cube, map, layout, cube), + (transform.map2matrix, map, layout, matrix), + (transform.matrix2map, matrix, matrix.shape, map), + ], + ) + def test_map(self, func, indata, layout, outdata): """Test cube2map.""" npt.assert_array_equal( - transform.cube2map(self.cube, self.layout), - self.map, - err_msg='Incorrect transformation: cube2map', - ) - - npt.assert_raises( - ValueError, - transform.cube2map, - self.map, - self.layout, - ) - - npt.assert_raises(ValueError, transform.cube2map, self.cube, (3, 3)) - - def test_map2cube(self): - """Test map2cube.""" - npt.assert_array_equal( - transform.map2cube(self.map, self.layout), - self.cube, - err_msg='Incorrect transformation: map2cube', - ) - - npt.assert_raises(ValueError, transform.map2cube, self.map, (3, 3)) - - def test_map2matrix(self): - """Test map2matrix.""" - npt.assert_array_equal( - transform.map2matrix(self.map, self.layout), - self.matrix, - err_msg='Incorrect transformation: map2matrix', - ) - - def test_matrix2map(self): - """Test matrix2map.""" - npt.assert_array_equal( - transform.matrix2map(self.matrix, self.map.shape), - self.map, - err_msg='Incorrect transformation: matrix2map', + func(indata, layout), + outdata, ) + if func.__name__ != "map2matrix": + npt.assert_raises(ValueError, func, indata, self.fail_layout) def test_cube2matrix(self): """Test cube2matrix.""" npt.assert_array_equal( transform.cube2matrix(self.cube), self.matrix, - err_msg='Incorrect transformation: cube2matrix', ) def test_matrix2cube(self): @@ -194,136 +141,78 @@ def test_matrix2cube(self): npt.assert_array_equal( transform.matrix2cube(self.matrix, self.cube[0].shape), self.cube, - err_msg='Incorrect transformation: matrix2cube', - ) - - -class TypesTestCase(TestCase): - """Test case for types module.""" - - def setUp(self): - """Set test parameter values.""" - self.data1 = list(range(5)) - self.data2 = np.arange(5) - self.data3 = np.arange(5).astype(float) - - def tearDown(self): - """Unset test parameter values.""" - self.data1 = None - self.data2 = None - self.data3 = None - - def test_check_float(self): - """Test check_float.""" - npt.assert_array_equal( - types.check_float(1.0), - 1.0, - err_msg='Float check failed', - ) - - npt.assert_array_equal( - types.check_float(1), - 1.0, - err_msg='Float check failed', - ) - - npt.assert_array_equal( - types.check_float(self.data1), - self.data3, - err_msg='Float check failed', - ) - - npt.assert_array_equal( - types.check_float(self.data2), - self.data3, - err_msg='Float check failed', - ) - - npt.assert_raises(TypeError, types.check_float, '1') - - def test_check_int(self): - """Test check_int.""" - npt.assert_array_equal( - types.check_int(1), - 1, - err_msg='Float check failed', - ) - - npt.assert_array_equal( - types.check_int(1.0), - 1, - err_msg='Float check failed', - ) - - npt.assert_array_equal( - types.check_int(self.data1), - self.data2, - err_msg='Float check failed', - ) - - npt.assert_array_equal( - types.check_int(self.data3), - self.data2, - err_msg='Int check failed', - ) - - npt.assert_raises(TypeError, types.check_int, '1') - - def test_check_npndarray(self): + err_msg="Incorrect transformation: matrix2cube", + ) + + +class TestType: + """Test for type module.""" + + data_list = list(range(5)) + data_int = np.arange(5) + data_flt = np.arange(5).astype(float) + + @pytest.mark.parametrize( + ("data", "checked"), + [ + (1.0, 1.0), + (1, 1.0), + (data_list, data_flt), + (data_int, data_flt), + failparam("1.0", 1.0, raises=TypeError), + ], + ) + def test_check_float(self, data, checked): + """Test check float.""" + npt.assert_array_equal(types.check_float(data), checked) + + @pytest.mark.parametrize( + ("data", "checked"), + [ + (1.0, 1), + (1, 1), + (data_list, data_int), + (data_flt, data_int), + failparam("1", None, raises=TypeError), + ], + ) + def test_check_int(self, data, checked): + """Test check int.""" + npt.assert_array_equal(types.check_int(data), checked) + + @pytest.mark.parametrize( + ("data", "dtype"), [(data_flt, np.integer), (data_int, np.floating)] + ) + def test_check_npndarray(self, data, dtype): """Test check_npndarray.""" npt.assert_raises( TypeError, types.check_npndarray, - self.data3, - dtype=np.integer, - ) - - -class TestBackend(TestCase): - """Test the backend codes.""" - - def setUp(self): - """Set test parameter values.""" - self.input = np.array([10, 10]) - - @skipIf(LIBRARIES['tensorflow'] is None, 'tensorflow library not installed') - def test_tf_backend(self): - """Test tensorflow backend.""" - xp, backend = get_backend('tensorflow') - if backend != 'tensorflow' or xp != LIBRARIES['tensorflow']: - raise AssertionError('tensorflow get_backend fails!') - tf_input = change_backend(self.input, 'tensorflow') - if ( - get_array_module(LIBRARIES['tensorflow'].ones(1)) != LIBRARIES['tensorflow'] - or get_array_module(tf_input) != LIBRARIES['tensorflow'] - ): - raise AssertionError('tensorflow backend fails!') - - @skipIf(LIBRARIES['cupy'] is None, 'cupy library not installed') - def test_cp_backend(self): - """Test cupy backend.""" - xp, backend = get_backend('cupy') - if backend != 'cupy' or xp != LIBRARIES['cupy']: - raise AssertionError('cupy get_backend fails!') - cp_input = change_backend(self.input, 'cupy') - if ( - get_array_module(LIBRARIES['cupy'].ones(1)) != LIBRARIES['cupy'] - or get_array_module(cp_input) != LIBRARIES['cupy'] - ): - raise AssertionError('cupy backend fails!') - - def test_np_backend(self): - """Test numpy backend.""" - xp, backend = get_backend('numpy') - if backend != 'numpy' or xp != LIBRARIES['numpy']: - raise AssertionError('numpy get_backend fails!') - np_input = change_backend(self.input, 'numpy') - if ( - get_array_module(LIBRARIES['numpy'].ones(1)) != LIBRARIES['numpy'] - or get_array_module(np_input) != LIBRARIES['numpy'] - ): - raise AssertionError('numpy backend fails!') - - def tearDown(self): - """Tear Down of objects.""" - self.input = None + data, + dtype=dtype, + ) + + def test_check_callable(self): + """Test callable.""" + npt.assert_raises(TypeError, types.check_callable, 1) + + +@pytest.mark.parametrize( + "backend_name", + [ + skipparam(name, cond=LIBRARIES[name] is None, reason=f"{name} not installed") + for name in LIBRARIES + ], +) +def test_tf_backend(backend_name): + """Test Modopt computational backends.""" + xp, checked_backend_name = backend.get_backend(backend_name) + if checked_backend_name != backend_name or xp != LIBRARIES[backend_name]: + raise AssertionError(f"{backend_name} get_backend fails!") + xp_input = backend.change_backend(np.array([10, 10]), backend_name) + if ( + backend.get_array_module(LIBRARIES[backend_name].ones(1)) + != backend.LIBRARIES[backend_name] + or backend.get_array_module(xp_input) != LIBRARIES[backend_name] + ): + raise AssertionError(f"{backend_name} backend fails!") diff --git a/modopt/tests/test_helpers/__init__.py b/modopt/tests/test_helpers/__init__.py new file mode 100644 index 00000000..3886b877 --- /dev/null +++ b/modopt/tests/test_helpers/__init__.py @@ -0,0 +1 @@ +from .utils import failparam, skipparam, Dummy diff --git a/modopt/tests/test_helpers/utils.py b/modopt/tests/test_helpers/utils.py new file mode 100644 index 00000000..d8227640 --- /dev/null +++ b/modopt/tests/test_helpers/utils.py @@ -0,0 +1,23 @@ +""" +Some helper functions for the test parametrization. +They should be used inside ``@pytest.mark.parametrize`` call. + +:Author: Pierre-Antoine Comby +""" +import pytest + + +def failparam(*args, raises=None): + """Return a pytest parameterization that should raise an error.""" + if not issubclass(raises, Exception): + raise ValueError("raises should be an expected Exception.") + return pytest.param(*args, marks=pytest.mark.raises(exception=raises)) + + +def skipparam(*args, cond=True, reason=""): + """Return a pytest parameterization that should be skip if cond is valid.""" + return pytest.param(*args, marks=pytest.mark.skipif(cond, reason=reason)) + + +class Dummy: + pass diff --git a/modopt/tests/test_math.py b/modopt/tests/test_math.py index ba175ae6..e44011c9 100644 --- a/modopt/tests/test_math.py +++ b/modopt/tests/test_math.py @@ -1,215 +1,181 @@ -# -*- coding: utf-8 -*- - """UNIT TESTS FOR MATH. This module contains unit tests for the modopt.math module. -:Author: Samuel Farrens - +:Authors: + Samuel Farrens + Pierre-Antoine Comby """ - -from unittest import TestCase, skipIf, skipUnless +import pytest +from test_helpers import failparam, skipparam import numpy as np import numpy.testing as npt + from modopt.math import convolve, matrix, metrics, stats try: import astropy except ImportError: # pragma: no cover - import_astropy = False + ASTROPY_AVAILABLE = False else: # pragma: no cover - import_astropy = True + ASTROPY_AVAILABLE = True try: from skimage.metrics import structural_similarity as compare_ssim except ImportError: # pragma: no cover - import_skimage = False + SKIMAGE_AVAILABLE = False else: - import_skimage = True - - -class ConvolveTestCase(TestCase): - """Test case for convolve module.""" - - def setUp(self): - """Set test parameter values.""" - self.data1 = np.arange(18).reshape(2, 3, 3) - self.data2 = self.data1 + 1 - - def tearDown(self): - """Unset test parameter values.""" - self.data1 = None - self.data2 = None - - @skipUnless(import_astropy, 'Astropy not installed.') # pragma: no cover - def test_convolve_astropy(self): - """Test convolve using astropy.""" - npt.assert_allclose( - convolve.convolve(self.data1[0], self.data2[0], method='astropy'), - np.array([ - [210.0, 201.0, 210.0], - [129.0, 120.0, 129.0], - [210.0, 201.0, 210.0], - ]), - err_msg='Incorrect convolution: astropy', - ) - - npt.assert_raises( - ValueError, - convolve.convolve, - self.data1[0], - self.data2, - ) - - npt.assert_raises( - ValueError, - convolve.convolve, - self.data1[0], - self.data2[0], - method='bla', - ) - - def test_convolve_scipy(self): - """Test convolve using scipy.""" - npt.assert_allclose( - convolve.convolve(self.data1[0], self.data2[0], method='scipy'), - np.array([ + SKIMAGE_AVAILABLE = True + + +class TestConvolve: + """Test convolve functions.""" + + array233 = np.arange(18).reshape((2, 3, 3)) + array233_1 = array233 + 1 + result_astropy = np.array( + [ + [210.0, 201.0, 210.0], + [129.0, 120.0, 129.0], + [210.0, 201.0, 210.0], + ] + ) + result_scipy = np.array( + [ + [ [14.0, 35.0, 38.0], [57.0, 120.0, 111.0], [110.0, 197.0, 158.0], - ]), - err_msg='Incorrect convolution: scipy', - ) - - def test_convolve_stack(self): - """Test convolve_stack.""" + ], + [ + [518.0, 845.0, 614.0], + [975.0, 1578.0, 1137.0], + [830.0, 1331.0, 950.0], + ], + ] + ) + + result_rot_kernel = np.array( + [ + [ + [66.0, 115.0, 82.0], + [153.0, 240.0, 159.0], + [90.0, 133.0, 82.0], + ], + [ + [714.0, 1087.0, 730.0], + [1125.0, 1698.0, 1131.0], + [738.0, 1105.0, 730.0], + ], + ] + ) + + @pytest.mark.parametrize( + ("input_data", "kernel", "method", "result"), + [ + skipparam( + array233[0], + array233_1[0], + "astropy", + result_astropy, + cond=not ASTROPY_AVAILABLE, + reason="astropy not available", + ), + failparam( + array233[0], array233_1, "astropy", result_astropy, raises=ValueError + ), + failparam( + array233[0], array233_1[0], "fail!", result_astropy, raises=ValueError + ), + (array233[0], array233_1[0], "scipy", result_scipy[0]), + ], + ) + def test_convolve(self, input_data, kernel, method, result): + """Test convolve function.""" + npt.assert_allclose(convolve.convolve(input_data, kernel, method), result) + + @pytest.mark.parametrize( + ("result", "rot_kernel"), + [ + (result_scipy, False), + (result_rot_kernel, True), + ], + ) + def test_convolve_stack(self, result, rot_kernel): + """Test convolve stack function.""" npt.assert_allclose( - convolve.convolve_stack(self.data1, self.data2), - np.array([ - [ - [14.0, 35.0, 38.0], - [57.0, 120.0, 111.0], - [110.0, 197.0, 158.0], - ], - [ - [518.0, 845.0, 614.0], - [975.0, 1578.0, 1137.0], - [830.0, 1331.0, 950.0], - ], - ]), - err_msg='Incorrect convolution: stack', + convolve.convolve_stack( + self.array233, self.array233_1, rot_kernel=rot_kernel + ), + result, ) - def test_convolve_stack_rot(self): - """Test convolve_stack rotated.""" - npt.assert_allclose( - convolve.convolve_stack(self.data1, self.data2, rot_kernel=True), - np.array([ - [ - [66.0, 115.0, 82.0], - [153.0, 240.0, 159.0], - [90.0, 133.0, 82.0], - ], - [ - [714.0, 1087.0, 730.0], - [1125.0, 1698.0, 1131.0], - [738.0, 1105.0, 730.0], - ], - ]), - err_msg='Incorrect convolution: stack rot', - ) +class TestMatrix: + """Test matrix module.""" -class MatrixTestCase(TestCase): - """Test case for matrix module.""" - - def setUp(self): - """Set test parameter values.""" - self.data1 = np.arange(9).reshape(3, 3) - self.data2 = np.arange(3) - self.data3 = np.arange(6).reshape(2, 3) - np.random.seed(1) - self.pmInstance1 = matrix.PowerMethod( - lambda x_val: x_val.dot(x_val.T), - self.data1.shape, - verbose=True, - ) - np.random.seed(1) - self.pmInstance2 = matrix.PowerMethod( - lambda x_val: x_val.dot(x_val.T), - self.data1.shape, - auto_run=False, - verbose=True, - ) - self.pmInstance2.get_spec_rad(max_iter=1) - self.gram_schmidt_out = ( - np.array([ + array3 = np.arange(3) + array33 = np.arange(9).reshape((3, 3)) + array23 = np.arange(6).reshape((2, 3)) + gram_schmidt_out = ( + np.array( + [ [0, 1.0, 2.0], [3.0, 1.2, -6e-1], [-1.77635684e-15, 0, 0], - ]), - np.array([ + ] + ), + np.array( + [ [0, 0.4472136, 0.89442719], [0.91287093, 0.36514837, -0.18257419], [-1.0, 0, 0], - ]), - ) - - def tearDown(self): - """Unset test parameter values.""" - self.data1 = None - self.data2 = None - self.data3 = None - self.pmInstance1 = None - self.pmInstance2 = None - self.gram_schmidt_out = None - - def test_gram_schmidt_orthonormal(self): - """Test gram_schmidt with orthonormal output.""" - npt.assert_allclose( - matrix.gram_schmidt(self.data1), - self.gram_schmidt_out[1], - err_msg='Incorrect Gram-Schmidt: orthonormal', - ) + ] + ), + ) - npt.assert_raises( - ValueError, - matrix.gram_schmidt, - self.data1, - return_opt='bla', - ) - - def test_gram_schmidt_orthogonal(self): - """Test gram_schmidt with orthogonal output.""" - npt.assert_allclose( - matrix.gram_schmidt(self.data1, return_opt='orthogonal'), - self.gram_schmidt_out[0], - err_msg='Incorrect Gram-Schmidt: orthogonal', + @pytest.fixture + def pm_instance(self, request): + """Power Method instance.""" + np.random.seed(1) + pm = matrix.PowerMethod( + lambda x_val: x_val.dot(x_val.T), + self.array33.shape, + auto_run=request.param, + verbose=True, ) - - def test_gram_schmidt_both(self): - """Test gram_schmidt with both outputs.""" + if not request.param: + pm.get_spec_rad(max_iter=1) + return pm + + @pytest.mark.parametrize( + ("return_opt", "output"), + [ + ("orthonormal", gram_schmidt_out[1]), + ("orthogonal", gram_schmidt_out[0]), + ("both", gram_schmidt_out), + failparam("fail!", gram_schmidt_out, raises=ValueError), + ], + ) + def test_gram_schmidt(self, return_opt, output): + """Test gram schmidt.""" npt.assert_allclose( - matrix.gram_schmidt(self.data1, return_opt='both'), - self.gram_schmidt_out, - err_msg='Incorrect Gram-Schmidt: both', + matrix.gram_schmidt(self.array33, return_opt=return_opt), output ) def test_nuclear_norm(self): - """Test nuclear_norm.""" + """Test nuclear norm.""" npt.assert_almost_equal( - matrix.nuclear_norm(self.data1), + matrix.nuclear_norm(self.array33), 15.49193338482967, - err_msg='Incorrect nuclear norm', ) def test_project(self): """Test project.""" npt.assert_array_equal( - matrix.project(self.data2, self.data2 + 3), + matrix.project(self.array3, self.array3 + 3), np.array([0, 2.8, 5.6]), - err_msg='Incorrect projection', ) def test_rot_matrix(self): @@ -217,280 +183,159 @@ def test_rot_matrix(self): npt.assert_allclose( matrix.rot_matrix(np.pi / 6), np.array([[0.8660254, -0.5], [0.5, 0.8660254]]), - err_msg='Incorrect rotation matrix', ) def test_rotate(self): """Test rotate.""" npt.assert_array_equal( - matrix.rotate(self.data1, np.pi / 2), + matrix.rotate(self.array33, np.pi / 2), np.array([[2, 5, 8], [1, 4, 7], [0, 3, 6]]), - err_msg='Incorrect rotation', - ) - - npt.assert_raises(ValueError, matrix.rotate, self.data3, np.pi / 2) - - def test_powermethod_converged(self): - """Test PowerMethod converged.""" - npt.assert_almost_equal( - self.pmInstance1.spec_rad, - 1.0, - err_msg='Incorrect spectral radius: converged', ) - npt.assert_almost_equal( - self.pmInstance1.inv_spec_rad, - 1.0, - err_msg='Incorrect inverse spectral radius: converged', - ) - - def test_powermethod_unconverged(self): - """Test PowerMethod unconverged.""" - npt.assert_almost_equal( - self.pmInstance2.spec_rad, - 0.8675467477372257, - err_msg='Incorrect spectral radius: unconverged', - ) - - npt.assert_almost_equal( - self.pmInstance2.inv_spec_rad, - 1.152675636913221, - err_msg='Incorrect inverse spectral radius: unconverged', - ) - - -class MetricsTestCase(TestCase): - """Test case for metrics module.""" - - def setUp(self): - """Set test parameter values.""" - self.data1 = np.arange(49).reshape(7, 7) - self.mask = np.ones(self.data1.shape) - self.ssim_res = 0.8963363560519094 - self.ssim_mask_res = 0.805154442543846 - self.snr_res = 10.134554256920536 - self.psnr_res = 14.860761791850397 - self.mse_res = 0.03265305507330247 - self.nrmse_res = 0.31136678840022625 - - def tearDown(self): - """Unset test parameter values.""" - self.data1 = None - self.mask = None - self.ssim_res = None - self.ssim_mask_res = None - self.psnr_res = None - self.mse_res = None - self.nrmse_res = None - - @skipIf(import_skimage, 'skimage is installed.') # pragma: no cover - def test_ssim_skimage_error(self): - """Test ssim skimage error.""" - npt.assert_raises(ImportError, metrics.ssim, self.data1, self.data1) - - @skipUnless(import_skimage, 'skimage not installed.') # pragma: no cover - def test_ssim(self): + npt.assert_raises(ValueError, matrix.rotate, self.array23, np.pi / 2) + + @pytest.mark.parametrize( + ("pm_instance", "value"), + [(True, 1.0), (False, 0.8675467477372257)], + indirect=["pm_instance"], + ) + def test_power_method(self, pm_instance, value): + """Test power method.""" + npt.assert_almost_equal(pm_instance.spec_rad, value) + npt.assert_almost_equal(pm_instance.inv_spec_rad, 1 / value) + + +class TestMetrics: + """Test metrics module.""" + + data1 = np.arange(49).reshape(7, 7) + mask = np.ones(data1.shape) + ssim_res = 0.8963363560519094 + ssim_mask_res = 0.805154442543846 + snr_res = 10.134554256920536 + psnr_res = 14.860761791850397 + mse_res = 0.03265305507330247 + nrmse_res = 0.31136678840022625 + + @pytest.mark.skipif(not SKIMAGE_AVAILABLE, reason="skimage not installed") + @pytest.mark.parametrize( + ("data1", "data2", "result", "mask"), + [ + (data1, data1**2, ssim_res, None), + (data1, data1**2, ssim_mask_res, mask), + failparam(data1, data1, None, 1, raises=ValueError), + ], + ) + def test_ssim(self, data1, data2, result, mask): """Test ssim.""" - npt.assert_almost_equal( - metrics.ssim(self.data1, self.data1 ** 2), - self.ssim_res, - err_msg='Incorrect SSIM result', - ) + npt.assert_almost_equal(metrics.ssim(data1, data2, mask=mask), result) - npt.assert_almost_equal( - metrics.ssim(self.data1, self.data1 ** 2, mask=self.mask), - self.ssim_mask_res, - err_msg='Incorrect SSIM result', - ) - - npt.assert_raises( - ValueError, - metrics.ssim, - self.data1, - self.data1, - mask=1, - ) + @pytest.mark.skipif(SKIMAGE_AVAILABLE, reason="skimage installed") + def test_ssim_fail(self): + """Test ssim.""" + npt.assert_raises(ImportError, metrics.ssim, self.data1, self.data1) - def test_snr(self): + @pytest.mark.parametrize( + ("metric", "data", "result", "mask"), + [ + (metrics.snr, data1, snr_res, None), + (metrics.snr, data1, snr_res, mask), + (metrics.psnr, data1, psnr_res, None), + (metrics.psnr, data1, psnr_res, mask), + (metrics.mse, data1, mse_res, None), + (metrics.mse, data1, mse_res, mask), + (metrics.nrmse, data1, nrmse_res, None), + (metrics.nrmse, data1, nrmse_res, mask), + failparam(metrics.snr, data1, snr_res, "maskfail", raises=ValueError), + ], + ) + def test_metric(self, metric, data, result, mask): """Test snr.""" - npt.assert_almost_equal( - metrics.snr(self.data1, self.data1 ** 2), - self.snr_res, - err_msg='Incorrect SNR result', - ) - - npt.assert_almost_equal( - metrics.snr(self.data1, self.data1 ** 2, mask=self.mask), - self.snr_res, - err_msg='Incorrect SNR result', - ) - - def test_psnr(self): - """Test psnr.""" - npt.assert_almost_equal( - metrics.psnr(self.data1, self.data1 ** 2), - self.psnr_res, - err_msg='Incorrect PSNR result', - ) - - npt.assert_almost_equal( - metrics.psnr(self.data1, self.data1 ** 2, mask=self.mask), - self.psnr_res, - err_msg='Incorrect PSNR result', - ) - - def test_mse(self): - """Test mse.""" - npt.assert_almost_equal( - metrics.mse(self.data1, self.data1 ** 2), - self.mse_res, - err_msg='Incorrect MSE result', - ) - - npt.assert_almost_equal( - metrics.mse(self.data1, self.data1 ** 2, mask=self.mask), - self.mse_res, - err_msg='Incorrect MSE result', - ) - - def test_nrmse(self): - """Test nrmse.""" - npt.assert_almost_equal( - metrics.nrmse(self.data1, self.data1 ** 2), - self.nrmse_res, - err_msg='Incorrect NRMSE result', - ) - - npt.assert_almost_equal( - metrics.nrmse(self.data1, self.data1 ** 2, mask=self.mask), - self.nrmse_res, - err_msg='Incorrect NRMSE result', - ) - - -class StatsTestCase(TestCase): - """Test case for stats module.""" - - def setUp(self): - """Set test parameter values.""" - self.data1 = np.arange(9).reshape(3, 3) - self.data2 = np.arange(18).reshape(2, 3, 3) - - def tearDown(self): - """Unset test parameter values.""" - self.data1 = None - - @skipIf(import_astropy, 'Astropy is installed.') # pragma: no cover - def test_gaussian_kernel_astropy_error(self): - """Test gaussian_kernel astropy error.""" - npt.assert_raises( - ImportError, - stats.gaussian_kernel, - self.data1.shape, - 1, - ) - - @skipUnless(import_astropy, 'Astropy not installed.') # pragma: no cover - def test_gaussian_kernel_max(self): - """Test gaussian_kernel with max norm.""" + npt.assert_almost_equal(metric(data, data**2, mask=mask), result) + + +class TestStats: + """Test stats module.""" + + array33 = np.arange(9).reshape(3, 3) + array233 = np.arange(18).reshape(2, 3, 3) + + @pytest.mark.skipif(not ASTROPY_AVAILABLE, reason="astropy not installed") + @pytest.mark.parametrize( + ("norm", "result"), + [ + ( + "max", + np.array( + [ + [0.36787944, 0.60653066, 0.36787944], + [0.60653066, 1.0, 0.60653066], + [0.36787944, 0.60653066, 0.36787944], + ] + ), + ), + ( + "sum", + np.array( + [ + [0.07511361, 0.1238414, 0.07511361], + [0.1238414, 0.20417996, 0.1238414], + [0.07511361, 0.1238414, 0.07511361], + ] + ), + ), + ( + "none", + np.array( + [ + [0.05854983, 0.09653235, 0.05854983], + [0.09653235, 0.15915494, 0.09653235], + [0.05854983, 0.09653235, 0.05854983], + ] + ), + ), + failparam("fail", None, raises=ValueError), + ], + ) + def test_gaussian_kernel(self, norm, result): + """Test Gaussian kernel.""" npt.assert_allclose( - stats.gaussian_kernel(self.data1.shape, 1), - np.array([ - [0.36787944, 0.60653066, 0.36787944], - [0.60653066, 1.0, 0.60653066], - [0.36787944, 0.60653066, 0.36787944], - ]), - err_msg='Incorrect gaussian kernel: max norm', + stats.gaussian_kernel(self.array33.shape, 1, norm=norm), result ) - npt.assert_raises( - ValueError, - stats.gaussian_kernel, - self.data1.shape, - 1, - norm='bla', - ) - - @skipUnless(import_astropy, 'Astropy not installed.') # pragma: no cover - def test_gaussian_kernel_sum(self): - """Test gaussian_kernel with sum norm.""" - npt.assert_allclose( - stats.gaussian_kernel(self.data1.shape, 1, norm='sum'), - np.array([ - [0.07511361, 0.1238414, 0.07511361], - [0.1238414, 0.20417996, 0.1238414], - [0.07511361, 0.1238414, 0.07511361], - ]), - err_msg='Incorrect gaussian kernel: sum norm', - ) - - @skipUnless(import_astropy, 'Astropy not installed.') # pragma: no cover - def test_gaussian_kernel_none(self): - """Test gaussian_kernel with no norm.""" - npt.assert_allclose( - stats.gaussian_kernel(self.data1.shape, 1, norm='none'), - np.array([ - [0.05854983, 0.09653235, 0.05854983], - [0.09653235, 0.15915494, 0.09653235], - [0.05854983, 0.09653235, 0.05854983], - ]), - err_msg='Incorrect gaussian kernel: sum norm', - ) + @pytest.mark.skipif(ASTROPY_AVAILABLE, reason="astropy installed") + def test_import_astropy(self): + """Test missing astropy.""" + npt.assert_raises(ImportError, stats.gaussian_kernel, self.array33.shape, 1) def test_mad(self): """Test mad.""" - npt.assert_equal( - stats.mad(self.data1), - 2.0, - err_msg='Incorrect median absolute deviation', - ) - - def test_mse(self): - """Test mse.""" - npt.assert_equal( - stats.mse(self.data1, self.data1 + 2), - 4.0, - err_msg='Incorrect mean squared error', - ) + npt.assert_equal(stats.mad(self.array33), 2.0) - def test_psnr_starck(self): - """Test psnr.""" + def test_sigma_mad(self): + """Test sigma_mad.""" npt.assert_almost_equal( - stats.psnr(self.data1, self.data1 + 2), - 12.041199826559248, - err_msg='Incorrect PSNR: starck', - ) - - npt.assert_raises( - ValueError, - stats.psnr, - self.data1, - self.data1, - method='bla', + stats.sigma_mad(self.array33), + 2.9651999999999998, ) - def test_psnr_wiki(self): - """Test psnr wiki method.""" - npt.assert_almost_equal( - stats.psnr(self.data1, self.data1 + 2, method='wiki'), - 42.110203695399477, - err_msg='Incorrect PSNR: wiki', - ) + @pytest.mark.parametrize( + ("data1", "data2", "method", "result"), + [ + (array33, array33 + 2, "starck", 12.041199826559248), + failparam(array33, array33, "fail", 0, raises=ValueError), + (array33, array33 + 2, "wiki", 42.110203695399477), + ], + ) + def test_psnr(self, data1, data2, method, result): + """Test PSNR.""" + npt.assert_almost_equal(stats.psnr(data1, data2, method=method), result) def test_psnr_stack(self): """Test psnr stack.""" npt.assert_almost_equal( - stats.psnr_stack(self.data2, self.data2 + 2), + stats.psnr_stack(self.array233, self.array233 + 2), 12.041199826559248, - err_msg='Incorrect PSNR stack', ) - npt.assert_raises(ValueError, stats.psnr_stack, self.data1, self.data1) - - def test_sigma_mad(self): - """Test sigma_mad.""" - npt.assert_almost_equal( - stats.sigma_mad(self.data1), - 2.9651999999999998, - err_msg='Incorrect sigma from MAD', - ) + npt.assert_raises(ValueError, stats.psnr_stack, self.array33, self.array33) diff --git a/modopt/tests/test_opt.py b/modopt/tests/test_opt.py index d5547783..0e45ffb8 100644 --- a/modopt/tests/test_opt.py +++ b/modopt/tests/test_opt.py @@ -1,718 +1,275 @@ -# -*- coding: utf-8 -*- - """UNIT TESTS FOR OPT. -This module contains unit tests for the modopt.opt module. - -:Author: Samuel Farrens +This module contains tests for the modopt.opt module. +:Authors: + Samuel Farrens + Pierre-Antoine Comby """ -from builtins import zip -from unittest import TestCase, skipIf, skipUnless - import numpy as np import numpy.testing as npt +import pytest +from pytest_cases import parametrize, parametrize_with_cases, case, fixture, fixture_ref + +from modopt.opt import cost, gradient, linear, proximity, reweight -from modopt.opt import algorithms, cost, gradient, linear, proximity, reweight +from test_helpers import Dummy +SKLEARN_AVAILABLE = True try: import sklearn -except ImportError: # pragma: no cover - import_sklearn = False -else: - import_sklearn = True +except ImportError: + SKLEARN_AVAILABLE = False # Basic functions to be used as operators or as dummy functions func_identity = lambda x_val: x_val func_double = lambda x_val: x_val * 2 -func_sq = lambda x_val: x_val ** 2 -func_cube = lambda x_val: x_val ** 3 - - -class Dummy(object): - """Dummy class for tests.""" - - pass - - -class AlgorithmTestCase(TestCase): - """Test case for algorithms module.""" - - def setUp(self): - """Set test parameter values.""" - self.data1 = np.arange(9).reshape(3, 3).astype(float) - self.data2 = self.data1 + np.random.randn(*self.data1.shape) * 1e-6 - self.data3 = np.arange(9).reshape(3, 3).astype(float) + 1 - - grad_inst = gradient.GradBasic( - self.data1, - func_identity, - func_identity, - ) - - prox_inst = proximity.Positivity() - prox_dual_inst = proximity.IdentityProx() - linear_inst = linear.Identity() - reweight_inst = reweight.cwbReweight(self.data3) - cost_inst = cost.costObj([grad_inst, prox_inst, prox_dual_inst]) - self.setup = algorithms.SetUp() - self.max_iter = 20 - - self.fb_all_iter = algorithms.ForwardBackward( - self.data1, - grad=grad_inst, - prox=prox_inst, - cost=None, - auto_iterate=False, - beta_update=func_identity, - ) - self.fb_all_iter.iterate(self.max_iter) - - self.fb1 = algorithms.ForwardBackward( - self.data1, - grad=grad_inst, - prox=prox_inst, - beta_update=func_identity, - ) - - self.fb2 = algorithms.ForwardBackward( - self.data1, - grad=grad_inst, - prox=prox_inst, - cost=cost_inst, - lambda_update=None, - ) - - self.fb3 = algorithms.ForwardBackward( - self.data1, - grad=grad_inst, - prox=prox_inst, - beta_update=func_identity, - a_cd=3, - ) - - self.fb4 = algorithms.ForwardBackward( - self.data1, - grad=grad_inst, - prox=prox_inst, - beta_update=func_identity, - r_lazy=3, - p_lazy=0.7, - q_lazy=0.7, - ) - - self.fb5 = algorithms.ForwardBackward( - self.data1, - grad=grad_inst, - prox=prox_inst, - restart_strategy='adaptive', - xi_restart=0.9, - ) - - self.fb6 = algorithms.ForwardBackward( - self.data1, - grad=grad_inst, - prox=prox_inst, - restart_strategy='greedy', - xi_restart=0.9, - min_beta=1.0, - s_greedy=1.1, - ) - - self.gfb_all_iter = algorithms.GenForwardBackward( - self.data1, - grad=grad_inst, - prox_list=[prox_inst, prox_dual_inst], - cost=None, - auto_iterate=False, - gamma_update=func_identity, - beta_update=func_identity, - ) - self.gfb_all_iter.iterate(self.max_iter) - - self.gfb1 = algorithms.GenForwardBackward( - self.data1, - grad=grad_inst, - prox_list=[prox_inst, prox_dual_inst], - gamma_update=func_identity, - lambda_update=func_identity, - ) - - self.gfb2 = algorithms.GenForwardBackward( - self.data1, - grad=grad_inst, - prox_list=[prox_inst, prox_dual_inst], - cost=cost_inst, - ) - - self.gfb3 = algorithms.GenForwardBackward( - self.data1, - grad=grad_inst, - prox_list=[prox_inst, prox_dual_inst], - cost=cost_inst, - step_size=2, - ) - - self.condat_all_iter = algorithms.Condat( - self.data1, - self.data2, - grad=grad_inst, - prox=prox_inst, - cost=None, - prox_dual=prox_dual_inst, - sigma_update=func_identity, - tau_update=func_identity, - rho_update=func_identity, - auto_iterate=False, - ) - self.condat_all_iter.iterate(self.max_iter) - - self.condat1 = algorithms.Condat( - self.data1, - self.data2, - grad=grad_inst, - prox=prox_inst, - prox_dual=prox_dual_inst, - sigma_update=func_identity, - tau_update=func_identity, - rho_update=func_identity, - ) - - self.condat2 = algorithms.Condat( - self.data1, - self.data2, - grad=grad_inst, - prox=prox_inst, - prox_dual=prox_dual_inst, - linear=linear_inst, - cost=cost_inst, - reweight=reweight_inst, - ) - - self.condat3 = algorithms.Condat( - self.data1, - self.data2, - grad=grad_inst, - prox=prox_inst, - prox_dual=prox_dual_inst, - linear=Dummy(), - cost=cost_inst, - auto_iterate=False, - ) - - self.pogm_all_iter = algorithms.POGM( - u=self.data1, - x=self.data1, - y=self.data1, - z=self.data1, - grad=grad_inst, - prox=prox_inst, - auto_iterate=False, - cost=None, - ) - self.pogm_all_iter.iterate(self.max_iter) - - self.pogm1 = algorithms.POGM( - u=self.data1, - x=self.data1, - y=self.data1, - z=self.data1, - grad=grad_inst, - prox=prox_inst, - ) - - self.dummy = Dummy() - self.dummy.cost = func_identity - self.setup._check_operator(self.dummy.cost) - - def tearDown(self): - """Unset test parameter values.""" - self.data1 = None - self.data2 = None - self.setup = None - self.fb_all_iter = None - self.fb1 = None - self.fb2 = None - self.gfb_all_iter = None - self.gfb1 = None - self.gfb2 = None - self.condat_all_iter = None - self.condat1 = None - self.condat2 = None - self.condat3 = None - self.pogm1 = None - self.pogm_all_iter = None - self.dummy = None - - def test_set_up(self): - """Test set_up.""" - npt.assert_raises(TypeError, self.setup._check_input_data, 1) - - npt.assert_raises(TypeError, self.setup._check_param, 1) - - npt.assert_raises(TypeError, self.setup._check_param_update, 1) - - def test_all_iter(self): - """Test if all opt run for all iterations.""" - opts = [ - self.fb_all_iter, - self.gfb_all_iter, - self.condat_all_iter, - self.pogm_all_iter, - ] - for opt in opts: - npt.assert_equal(opt.idx, self.max_iter - 1) - - def test_forward_backward(self): - """Test forward_backward.""" - npt.assert_array_equal( - self.fb1.x_final, - self.data1, - err_msg='Incorrect ForwardBackward result.', - ) - - npt.assert_array_equal( - self.fb2.x_final, - self.data1, - err_msg='Incorrect ForwardBackward result.', - ) - - npt.assert_array_equal( - self.fb3.x_final, - self.data1, - err_msg='Incorrect ForwardBackward result.', - ) - - npt.assert_array_equal( - self.fb4.x_final, - self.data1, - err_msg='Incorrect ForwardBackward result.', - ) - - npt.assert_array_equal( - self.fb5.x_final, - self.data1, - err_msg='Incorrect ForwardBackward result.', - ) - - npt.assert_array_equal( - self.fb6.x_final, - self.data1, - err_msg='Incorrect ForwardBackward result.', - ) - - def test_gen_forward_backward(self): - """Test gen_forward_backward.""" - npt.assert_array_equal( - self.gfb1.x_final, - self.data1, - err_msg='Incorrect GenForwardBackward result.', - ) - - npt.assert_array_equal( - self.gfb2.x_final, - self.data1, - err_msg='Incorrect GenForwardBackward result.', - ) - - npt.assert_array_equal( - self.gfb3.x_final, - self.data1, - err_msg='Incorrect GenForwardBackward result.', - ) - - npt.assert_equal( - self.gfb3.step_size, - 2, - err_msg='Incorrect step size.', - ) - - npt.assert_raises( - TypeError, - algorithms.GenForwardBackward, - self.data1, - self.dummy, - [self.dummy], - weights=1, - ) - - npt.assert_raises( - ValueError, - algorithms.GenForwardBackward, - self.data1, - self.dummy, - [self.dummy], - weights=[1], - ) - - npt.assert_raises( - ValueError, - algorithms.GenForwardBackward, - self.data1, - self.dummy, - [self.dummy], - weights=[0.5, 0.5], - ) - - npt.assert_raises( - ValueError, - algorithms.GenForwardBackward, - self.data1, - self.dummy, - [self.dummy], - weights=[0.5], - ) - - def test_condat(self): - """Test gen_condat.""" - npt.assert_almost_equal( - self.condat1.x_final, - self.data1, - err_msg='Incorrect Condat result.', - ) - - npt.assert_almost_equal( - self.condat2.x_final, - self.data1, - err_msg='Incorrect Condat result.', - ) +func_sq = lambda x_val: x_val**2 +func_cube = lambda x_val: x_val**3 + + +@case(tags="cost") +@parametrize( + ("cost_interval", "n_calls", "converged"), + [(1, 1, False), (1, 2, True), (2, 5, False), (None, 6, False)], +) +def case_cost_op(cost_interval, n_calls, converged): + """Case function for costs.""" + dummy_inst1 = Dummy() + dummy_inst1.cost = func_sq + dummy_inst2 = Dummy() + dummy_inst2.cost = func_cube + + cost_obj = cost.costObj([dummy_inst1, dummy_inst2], cost_interval=cost_interval) + + for _ in range(n_calls + 1): + cost_obj.get_cost(2) + return cost_obj, converged + + +@parametrize_with_cases("cost_obj, converged", cases=".", has_tag="cost") +def test_costs(cost_obj, converged): + """Test cost.""" + npt.assert_equal(cost_obj.get_cost(2), converged) + if cost_obj._cost_interval: + npt.assert_equal(cost_obj.cost, 12) + + +def test_raise_cost(): + """Test error raising for cost.""" + npt.assert_raises(TypeError, cost.costObj, 1) + npt.assert_raises(ValueError, cost.costObj, [Dummy(), Dummy()]) + + +@case(tags="grad") +@parametrize(call=("op", "trans_op", "trans_op_op")) +def case_grad_parent(call): + """Case for gradient parent.""" + input_data = np.arange(9).reshape(3, 3) + callables = { + "op": func_sq, + "trans_op": func_cube, + "get_grad": func_identity, + "cost": lambda input_val: 1.0, + } + + grad_op = gradient.GradParent( + input_data, + **callables, + data_type=np.floating, + ) + if call != "trans_op_op": + result = callables[call](input_data) + else: + result = callables["trans_op"](callables["op"](input_data)) + + grad_call = getattr(grad_op, call)(input_data) + return grad_call, result + + +@parametrize_with_cases("grad_values, result", cases=".", has_tag="grad") +def test_grad_op(grad_values, result): + """Test Gradient operator.""" + npt.assert_equal(grad_values, result) + + +@pytest.fixture +def grad_basic(): + """Case for GradBasic.""" + input_data = np.arange(9).reshape(3, 3) + grad_op = gradient.GradBasic( + input_data, + func_sq, + func_cube, + verbose=True, + ) + grad_op.get_grad(input_data) + return grad_op + + +def test_grad_basic(grad_basic): + """Test grad basic.""" + npt.assert_array_equal( + grad_basic.grad, + np.array( + [ + [0, 0, 8.0], + [2.16000000e2, 1.72800000e3, 8.0e3], + [2.70000000e4, 7.40880000e4, 1.75616000e5], + ] + ), + err_msg="Incorrect gradient.", + ) - def test_pogm(self): - """Test pogm.""" - npt.assert_almost_equal( - self.pogm1.x_final, - self.data1, - err_msg='Incorrect POGM result.', - ) +def test_grad_basic_cost(grad_basic): + """Test grad_basic cost.""" + npt.assert_almost_equal(grad_basic.cost(np.arange(9).reshape(3, 3)), 3192.0) -class CostTestCase(TestCase): - """Test case for cost module.""" - def setUp(self): - """Set test parameter values.""" - dummy_inst1 = Dummy() - dummy_inst1.cost = func_sq - dummy_inst2 = Dummy() - dummy_inst2.cost = func_cube +def test_grad_op_raises(): + """Test raise error.""" + npt.assert_raises( + TypeError, + gradient.GradParent, + 1, + func_sq, + func_cube, + ) - self.inst1 = cost.costObj([dummy_inst1, dummy_inst2]) - self.inst2 = cost.costObj([dummy_inst1, dummy_inst2], cost_interval=2) - # Test that by default cost of False if interval is None - self.inst_none = cost.costObj( - [dummy_inst1, dummy_inst2], - cost_interval=None, - ) - for _ in range(2): - self.inst1.get_cost(2) - for _ in range(6): - self.inst2.get_cost(2) - self.inst_none.get_cost(2) - self.dummy = Dummy() - - def tearDown(self): - """Unset test parameter values.""" - self.inst = None - - def test_cost_object(self): - """Test cost_object.""" - npt.assert_equal( - self.inst1.get_cost(2), - False, - err_msg='Incorrect cost test result.', - ) - npt.assert_equal( - self.inst1.get_cost(2), - True, - err_msg='Incorrect cost test result.', - ) - npt.assert_equal( - self.inst_none.get_cost(2), - False, - err_msg='Incorrect cost test result.', - ) - npt.assert_equal(self.inst1.cost, 12, err_msg='Incorrect cost value.') +############# +# LINEAR OP # +############# - npt.assert_equal(self.inst2.cost, 12, err_msg='Incorrect cost value.') - npt.assert_raises(TypeError, cost.costObj, 1) +class LinearCases: + """Linear operator cases.""" - npt.assert_raises(ValueError, cost.costObj, [self.dummy, self.dummy]) + def case_linear_identity(self): + """Case linear operator identity.""" + linop = linear.Identity() + data_op, data_adj_op, res_op, res_adj_op = 1, 1, 1, 1 -class GradientTestCase(TestCase): - """Test case for gradient module.""" + return linop, data_op, data_adj_op, res_op, res_adj_op - def setUp(self): - """Set test parameter values.""" - self.data1 = np.arange(9).reshape(3, 3).astype(float) - self.gp = gradient.GradParent( - self.data1, - func_sq, - func_cube, - func_identity, - lambda input_val: 1.0, - data_type=np.floating, - ) - self.gp.grad = self.gp.get_grad(self.data1) - self.gb = gradient.GradBasic( - self.data1, - func_sq, - func_cube, - ) - self.gb.get_grad(self.data1) - - def tearDown(self): - """Unset test parameter values.""" - self.data1 = None - self.gp = None - self.gb = None - - def test_grad_parent_operators(self): - """Test GradParent.""" - npt.assert_array_equal( - self.gp.op(self.data1), - np.array([[0, 1.0, 4.0], [9.0, 16.0, 25.0], [36.0, 49.0, 64.0]]), - err_msg='Incorrect gradient operation.', - ) - - npt.assert_array_equal( - self.gp.trans_op(self.data1), - np.array( - [[0, 1.0, 8.0], [27.0, 64.0, 125.0], [216.0, 343.0, 512.0]], - ), - err_msg='Incorrect gradient transpose operation.', + def case_linear_wavelet(self): + """Case linear operator wavelet.""" + linop = linear.WaveletConvolve( + filters=np.arange(8).reshape(2, 2, 2).astype(float) ) + data_op = np.arange(4).reshape(1, 2, 2).astype(float) + data_adj_op = np.arange(8).reshape(1, 2, 2, 2).astype(float) + res_op = np.array([[[[0, 0], [0, 4.0]], [[0, 4.0], [8.0, 28.0]]]]) + res_adj_op = np.array([[[28.0, 62.0], [68.0, 140.0]]]) - npt.assert_array_equal( - self.gp.trans_op_op(self.data1), - np.array([ - [0, 1.0, 6.40000000e1], - [7.29000000e2, 4.09600000e3, 1.56250000e4], - [4.66560000e4, 1.17649000e5, 2.62144000e5], - ]), - err_msg='Incorrect gradient transpose operation operation.', - ) - - npt.assert_equal( - self.gp.cost(self.data1), - 1.0, - err_msg='Incorrect cost.', - ) + return linop, data_op, data_adj_op, res_op, res_adj_op - npt.assert_raises( - TypeError, - gradient.GradParent, - 1, + @parametrize(weights=[[1.0, 1.0], None]) + def case_linear_combo(self, weights): + """Case linear operator combo with weights.""" + parent = linear.LinearParent( func_sq, func_cube, ) + linop = linear.LinearCombo([parent, parent], weights) - def test_grad_basic_gradient(self): - """Test GradBasic.""" - npt.assert_array_equal( - self.gb.grad, - np.array([ - [0, 0, 8.0], - [2.16000000e2, 1.72800000e3, 8.0e3], - [2.70000000e4, 7.40880000e4, 1.75616000e5], - ]), - err_msg='Incorrect gradient.', + data_op, data_adj_op, res_op, res_adj_op = ( + 2, + np.array([2, 2]), + np.array([4, 4]), + 8.0 * (2 if weights else 1), ) + return linop, data_op, data_adj_op, res_op, res_adj_op -class LinearTestCase(TestCase): - """Test case for linear module.""" + @parametrize(factor=[1, 1 + 1j]) + def case_linear_matrix(self, factor): + """Case linear operator from matrix.""" + linop = linear.MatrixOperator(np.eye(5) * factor) + data_op = np.arange(5) + data_adj_op = np.arange(5) + res_op = np.arange(5) * factor + res_adj_op = np.arange(5) * np.conjugate(factor) - def setUp(self): - """Set test parameter values.""" - self.parent = linear.LinearParent( - func_sq, - func_cube, - ) - self.ident = linear.Identity() - filters = np.arange(8).reshape(2, 2, 2).astype(float) - self.wave = linear.WaveletConvolve(filters) - self.combo = linear.LinearCombo([self.parent, self.parent]) - self.combo_weight = linear.LinearCombo( - [self.parent, self.parent], - [1.0, 1.0], - ) - self.data1 = np.arange(18).reshape(2, 3, 3).astype(float) - self.data2 = np.arange(4).reshape(1, 2, 2).astype(float) - self.data3 = np.arange(8).reshape(1, 2, 2, 2).astype(float) - self.data4 = np.array([[[[0, 0], [0, 4.0]], [[0, 4.0], [8.0, 28.0]]]]) - self.data5 = np.array([[[28.0, 62.0], [68.0, 140.0]]]) - self.dummy = Dummy() - - def tearDown(self): - """Unset test parameter values.""" - self.parent = None - self.ident = None - self.combo = None - self.combo_weight = None - self.data1 = None - self.data2 = None - self.data3 = None - self.data4 = None - self.data5 = None - self.dummy = None - - def test_linear_parent(self): - """Test LinearParent.""" - npt.assert_equal( - self.parent.op(2), - 4, - err_msg='Incorrect linear parent operation.', - ) + return linop, data_op, data_adj_op, res_op, res_adj_op - npt.assert_equal( - self.parent.adj_op(2), - 8, - err_msg='Incorrect linear parent adjoint operation.', - ) - npt.assert_raises(TypeError, linear.LinearParent, 0, 0) +@fixture +@parametrize_with_cases( + "linop, data_op, data_adj_op, res_op, res_adj_op", cases=LinearCases +) +def lin_adj_op(linop, data_op, data_adj_op, res_op, res_adj_op): + """Get adj_op relative data.""" + return linop.adj_op, data_adj_op, res_adj_op - def test_identity(self): - """Test Identity.""" - npt.assert_equal( - self.ident.op(1.0), - 1.0, - err_msg='Incorrect identity operation.', - ) - npt.assert_equal( - self.ident.adj_op(1.0), - 1.0, - err_msg='Incorrect identity adjoint operation.', - ) +@fixture +@parametrize_with_cases( + "linop, data_op, data_adj_op, res_op, res_adj_op", cases=LinearCases +) +def lin_op(linop, data_op, data_adj_op, res_op, res_adj_op): + """Get op relative data.""" + return linop.op, data_op, res_op - def test_wavelet_convolve(self): - """Test WaveletConvolve.""" - npt.assert_almost_equal( - self.wave.op(self.data2), - self.data4, - err_msg='Incorrect wavelet convolution operation.', - ) - npt.assert_almost_equal( - self.wave.adj_op(self.data3), - self.data5, - err_msg='Incorrect wavelet convolution adjoint operation.', - ) +@parametrize( + ("action", "data", "result"), [fixture_ref(lin_op), fixture_ref(lin_adj_op)] +) +def test_linear_operator(action, data, result): + """Test linear operator.""" + npt.assert_almost_equal(action(data), result) - def test_linear_combo(self): - """Test LinearCombo.""" - npt.assert_equal( - self.combo.op(2), - np.array([4, 4]).astype(object), - err_msg='Incorrect combined linear operation', - ) - npt.assert_equal( - self.combo.adj_op([2, 2]), - 8.0, - err_msg='Incorrect combined linear adjoint operation', - ) +dummy_with_op = Dummy() +dummy_with_op.op = lambda x: x - npt.assert_raises(TypeError, linear.LinearCombo, self.parent) - npt.assert_raises(ValueError, linear.LinearCombo, []) +@pytest.mark.parametrize( + ("args", "error"), + [ + ([linear.LinearParent(func_sq, func_cube)], TypeError), + ([[]], ValueError), + ([[Dummy()]], ValueError), + ([[dummy_with_op]], ValueError), + ([[]], ValueError), + ([[linear.LinearParent(func_sq, func_cube)] * 2, [1.0]], ValueError), + ([[linear.LinearParent(func_sq, func_cube)] * 2, ["1", "1"]], TypeError), + ], +) +def test_linear_combo_errors(args, error): + """Test linear combo_errors.""" + npt.assert_raises(error, linear.LinearCombo, *args) - npt.assert_raises(ValueError, linear.LinearCombo, [self.dummy]) - self.dummy.op = func_identity +############# +# Proximity # +############# - npt.assert_raises(ValueError, linear.LinearCombo, [self.dummy]) - def test_linear_combo_weight(self): - """Test LinearCombo with weight .""" - npt.assert_equal( - self.combo_weight.op(2), - np.array([4, 4]).astype(object), - err_msg='Incorrect combined linear operation', - ) - - npt.assert_equal( - self.combo_weight.adj_op([2, 2]), - 16.0, - err_msg='Incorrect combined linear adjoint operation', - ) +class ProxCases: + """Class containing all proximal operator cases. - npt.assert_raises( - ValueError, - linear.LinearCombo, - [self.parent, self.parent], - [1.0], - ) - - npt.assert_raises( - TypeError, - linear.LinearCombo, - [self.parent, self.parent], - ['1', '1'], - ) + Each case should return 4 parameters: + 1. The proximal operator + 2. test input data + 3. Expected result data + 4. Expected cost value. + """ + weights = np.ones(9).reshape(3, 3).astype(float) * 3 + array33 = np.arange(9).reshape(3, 3).astype(float) + array33_st = np.array([[-0, -0, -0], [0, 1.0, 2.0], [3.0, 4.0, 5.0]]) + array33_st2 = array33_st * -1 -class ProximityTestCase(TestCase): - """Test case for proximity module.""" + array33_support = np.asarray([[0, 0, 0], [0, 1.0, 1.25], [1.5, 1.75, 2.0]]) - def setUp(self): - """Set test parameter values.""" - self.parent = proximity.ProximityParent( - func_sq, - func_double, - ) - self.identity = proximity.IdentityProx() - self.positivity = proximity.Positivity() - weights = np.ones(9).reshape(3, 3).astype(float) * 3 - self.sparsethresh = proximity.SparseThreshold( - linear.Identity(), - weights, - ) - self.lowrank = proximity.LowRankMatrix(10.0, thresh_type='hard') - self.lowrank_rank = proximity.LowRankMatrix( - 10.0, - initial_rank=1, - thresh_type='hard', - ) - self.lowrank_ngole = proximity.LowRankMatrix( - 10.0, - lowr_type='ngole', - operator=func_double, - ) - self.linear_comp = proximity.LinearCompositionProx( - linear_op=linear.Identity(), - prox_op=self.sparsethresh, - ) - self.combo = proximity.ProximityCombo([self.identity, self.positivity]) - if import_sklearn: - self.owl = proximity.OrderedWeightedL1Norm(weights.flatten()) - self.ridge = proximity.Ridge(linear.Identity(), weights) - self.elasticnet_alpha0 = proximity.ElasticNet( - linear.Identity(), - alpha=0, - beta=weights, - ) - self.elasticnet_beta0 = proximity.ElasticNet( - linear.Identity(), - alpha=weights, - beta=0, - ) - self.one_support = proximity.KSupportNorm(beta=0.2, k_value=1) - self.five_support_norm = proximity.KSupportNorm(beta=3, k_value=5) - self.d_support = proximity.KSupportNorm(beta=3.0 * 2, k_value=19) - self.group_lasso = proximity.GroupLASSO( - weights=np.tile(weights, (4, 1, 1)), - ) - self.data1 = np.arange(9).reshape(3, 3).astype(float) - self.data2 = np.array([[-0, -0, -0], [0, 1.0, 2.0], [3.0, 4.0, 5.0]]) - self.data3 = np.arange(18).reshape(2, 3, 3).astype(float) - self.data4 = np.array([ + array233 = np.arange(18).reshape(2, 3, 3).astype(float) + array233_2 = np.array( + [ [ [2.73843189, 3.14594066, 3.55344943], [3.9609582, 4.36846698, 4.77597575], @@ -723,349 +280,230 @@ def setUp(self): [11.67394789, 12.87497954, 14.07601119], [15.27704284, 16.47807449, 17.67910614], ], - ]) - self.data5 = np.array([ + ] + ) + array233_3 = np.array( + [ [[0, 0, 0], [0, 0, 0], [0, 0, 0]], [ [4.00795282, 4.60438026, 5.2008077], [5.79723515, 6.39366259, 6.99009003], [7.58651747, 8.18294492, 8.77937236], ], - ]) - self.data6 = self.data3 * -1 - self.data7 = self.combo.op(self.data6) - self.data8 = np.empty(2, dtype=np.ndarray) - self.data8[0] = np.array( - [[-0, -1.0, -2.0], [-3.0, -4.0, -5.0], [-6.0, -7.0, -8.0]], - ) - self.data8[1] = np.array( - [[-0, -0, -0], [-0, -0, -0], [-0, -0, -0]], - ) - self.data9 = self.data1 * (1 + 1j) - self.data10 = self.data9 / (2 * 3 + 1) - self.data11 = np.asarray( - [[0, 0, 0], [0, 1.0, 1.25], [1.5, 1.75, 2.0]], - ) - self.random_data = 3 * np.random.random( - self.group_lasso.weights[0].shape, - ) - self.random_data_tile = np.tile( - self.random_data, - (self.group_lasso.weights.shape[0], 1, 1), - ) - self.gl_result_data = 2 * self.random_data_tile - 3 - self.gl_result_data = np.array( - (self.gl_result_data * (self.gl_result_data > 0).astype('int')) - / 2, - ) - - self.dummy = Dummy() - - def tearDown(self): - """Unset test parameter values.""" - self.parent = None - self.identity = None - self.positivity = None - self.sparsethresh = None - self.lowrank = None - self.lowrank_rank = None - self.lowrank_ngole = None - self.combo = None - self.data1 = None - self.data2 = None - self.data3 = None - self.data4 = None - self.data5 = None - self.data6 = None - self.data7 = None - self.data8 = None - self.dummy = None - self.random_data = None - self.random_data_tile = None - self.gl_result_data = None - - def test_proximity_parent(self): - """Test ProximityParent.""" - npt.assert_equal( - self.parent.op(3), + ] + ) + + def case_prox_parent(self): + """Case prox parent.""" + return ( + proximity.ProximityParent( + func_sq, + func_double, + ), + 3, 9, - err_msg='Inccoret proximity parent operation.', - ) - - npt.assert_equal( - self.parent.cost(3), 6, - err_msg='Incorrect proximity parent cost.', - ) - - def test_identity(self): - """Test IdentityProx.""" - npt.assert_equal( - self.identity.op(3), - 3, - err_msg='Incorrect proximity identity operation.', - ) - - npt.assert_equal( - self.identity.cost(3), - 0, - err_msg='Incorrect proximity identity cost.', - ) - - def test_positivity(self): - """Test Positivity.""" - npt.assert_equal( - self.positivity.op(-3), - 0, - err_msg='Incorrect proximity positivity operation.', - ) - - npt.assert_equal( - self.positivity.cost(-3, verbose=True), - 0, - err_msg='Incorrect proximity positivity cost.', ) - def test_sparse_threshold(self): - """Test SparseThreshold.""" - npt.assert_array_equal( - self.sparsethresh.op(self.data1), - self.data2, - err_msg='Incorrect sparse threshold operation.', - ) - - npt.assert_equal( - self.sparsethresh.cost(self.data1, verbose=True), - 108.0, - err_msg='Incorrect sparse threshold cost.', - ) - - def test_low_rank_matrix(self): - """Test LowRankMatrix.""" - npt.assert_almost_equal( - self.lowrank.op(self.data3), - self.data4, - err_msg='Incorrect low rank operation: standard', - ) - - npt.assert_almost_equal( - self.lowrank_rank.op(self.data3), - self.data4, - err_msg='Incorrect low rank operation: standard with rank', - ) - npt.assert_almost_equal( - self.lowrank_ngole.op(self.data3), - self.data5, - err_msg='Incorrect low rank operation: ngole', - ) - - npt.assert_almost_equal( - self.lowrank.cost(self.data3, verbose=True), - 469.39132942464983, - err_msg='Incorrect low rank cost.', - ) - - def test_linear_comp_prox(self): - """Test LinearCompositionProx.""" - npt.assert_array_equal( - self.linear_comp.op(self.data1), - self.data2, - err_msg='Incorrect sparse threshold operation.', - ) - - npt.assert_equal( - self.linear_comp.cost(self.data1, verbose=True), - 108.0, - err_msg='Incorrect sparse threshold cost.', + def case_prox_identity(self): + """Case prox identity.""" + return proximity.IdentityProx(), 3, 3, 0 + + def case_prox_positivity(self): + """Case prox positivity.""" + return proximity.Positivity(), -3, 0, 0 + + def case_prox_sparsethresh(self): + """Case prox sparsethreshosld.""" + return ( + proximity.SparseThreshold(linear.Identity(), weights=self.weights), + self.array33, + self.array33_st, + 108, + ) + + @parametrize( + "lowr_type, initial_rank, operator, result, cost", + [ + ("standard", None, None, array233_2, 469.3913294246498), + ("standard", 1, None, array233_2, 469.3913294246498), + ("ngole", None, func_double, array233_3, 469.3913294246498), + ], + ) + def case_prox_lowrank(self, lowr_type, initial_rank, operator, result, cost): + """Case prox lowrank.""" + return ( + proximity.LowRankMatrix( + 10, + lowr_type=lowr_type, + initial_rank=initial_rank, + operator=operator, + thresh_type="hard" if lowr_type == "standard" else "soft", + ), + self.array233, + result, + cost, ) - def test_proximity_combo(self): - """Test ProximityCombo.""" - for data7, data8 in zip(self.data7, self.data8): - npt.assert_array_equal( - data7, - data8, - err_msg='Incorrect combined operation', + def case_prox_linear_comp(self): + """Case prox linear comp.""" + return ( + proximity.LinearCompositionProx( + linear_op=linear.Identity(), prox_op=self.case_prox_sparsethresh()[0] + ), + self.array33, + self.array33_st, + 108, + ) + + def case_prox_ridge(self): + """Case prox ridge.""" + return ( + proximity.Ridge(linear.Identity(), self.weights), + self.array33 * (1 + 1j), + self.array33 * (1 + 1j) / 7, + 1224, + ) + + @parametrize("alpha, beta", [(0, weights), (weights, 0)]) + def case_prox_elasticnet(self, alpha, beta): + """Case prox elastic net.""" + if np.all(alpha == 0): + data = self.case_prox_sparsethresh()[1:] + else: + data = self.case_prox_ridge()[1:] + return (proximity.ElasticNet(linear.Identity(), alpha, beta), *data) + + @parametrize( + "beta, k_value, data, result, cost", + [ + (0.2, 1, array33.flatten(), array33_st.flatten(), 259.2), + (3, 5, array33.flatten(), array33_support.flatten(), 684.0), + ( + 6.0, + 9, + array33.flatten() * (1 + 1j), + array33.flatten() * (1 + 1j) / 7, + 1224, + ), + ], + ) + def case_prox_Ksupport(self, beta, k_value, data, result, cost): + """Case prox K-support norm.""" + return (proximity.KSupportNorm(beta=beta, k_value=k_value), data, result, cost) + + @parametrize(use_weights=[True, False]) + def case_prox_grouplasso(self, use_weights): + """Case GroupLasso proximity.""" + if use_weights: + weights = np.tile(self.weights, (4, 1, 1)) + else: + weights = np.tile(np.zeros((3, 3)), (4, 1, 1)) + + random_data = 3 * np.random.random(weights[0].shape) + random_data_tile = np.tile(random_data, (weights.shape[0], 1, 1)) + if use_weights: + gl_result_data = 2 * random_data_tile - 3 + gl_result_data = ( + np.array(gl_result_data * (gl_result_data > 0).astype("int")) / 2 ) - - npt.assert_equal( - self.combo.cost(self.data6), - 0, - err_msg='Incorrect combined cost.', - ) - - npt.assert_raises(TypeError, proximity.ProximityCombo, 1) - - npt.assert_raises(ValueError, proximity.ProximityCombo, []) - - npt.assert_raises(ValueError, proximity.ProximityCombo, [self.dummy]) - - self.dummy.op = func_identity - - npt.assert_raises(ValueError, proximity.ProximityCombo, [self.dummy]) - - @skipIf(import_sklearn, 'sklearn is installed.') # pragma: no cover - def test_owl_sklearn_error(self): - """Test OrderedWeightedL1Norm with Scikit-Learn.""" - npt.assert_raises(ImportError, proximity.OrderedWeightedL1Norm, 1) - - @skipUnless(import_sklearn, 'sklearn not installed.') # pragma: no cover - def test_sparse_owl(self): - """Test OrderedWeightedL1Norm.""" - npt.assert_array_equal( - self.owl.op(self.data1.flatten()), - self.data2.flatten(), - err_msg='Incorrect sparse threshold operation.', - ) - - npt.assert_equal( - self.owl.cost(self.data1.flatten(), verbose=True), + cost = np.sum(random_data_tile) * 6 + else: + gl_result_data = random_data_tile + cost = 0 + return ( + proximity.GroupLASSO( + weights=weights, + ), + random_data_tile, + gl_result_data, + cost, + ) + + @pytest.mark.skipif(not SKLEARN_AVAILABLE, reason="sklearn not available.") + def case_prox_owl(self): + """Case prox for Ordered Weighted L1 Norm.""" + return ( + proximity.OrderedWeightedL1Norm(self.weights.flatten()), + self.array33.flatten(), + self.array33_st.flatten(), 108.0, - err_msg='Incorrect sparse threshold cost.', ) - npt.assert_raises( - ValueError, - proximity.OrderedWeightedL1Norm, - np.arange(10), - ) - def test_ridge(self): - """Test Ridge.""" - npt.assert_array_equal( - self.ridge.op(self.data9), - self.data10, - err_msg='Incorect shrinkage operation.', - ) +@parametrize_with_cases("operator, input_data, op_result, cost_result", cases=ProxCases) +def test_prox_op(operator, input_data, op_result, cost_result): + """Test proximity operator op.""" + npt.assert_almost_equal(operator.op(input_data), op_result) - npt.assert_equal( - self.ridge.cost(self.data9, verbose=True), - 408.0 * 3.0, - err_msg='Incorect shrinkage cost.', - ) - def test_elastic_net_alpha0(self): - """Test ElasticNet.""" - npt.assert_array_equal( - self.elasticnet_alpha0.op(self.data1), - self.data2, - err_msg='Incorect sparse threshold operation ElasticNet class.', - ) +@parametrize_with_cases("operator, input_data, op_result, cost_result", cases=ProxCases) +def test_prox_cost(operator, input_data, op_result, cost_result): + """Test proximity operator cost.""" + npt.assert_almost_equal(operator.cost(input_data, verbose=True), cost_result) - npt.assert_equal( - self.elasticnet_alpha0.cost(self.data1), - 108.0, - err_msg='Incorect shrinkage cost in ElasticNet class.', - ) - def test_elastic_net_beta0(self): - """Test ElasticNet with beta=0.""" - npt.assert_array_equal( - self.elasticnet_beta0.op(self.data9), - self.data10, - err_msg='Incorect ridge operation ElasticNet class.', - ) +@parametrize( + "arg, error", + [ + (1, TypeError), + ([], ValueError), + ([Dummy()], ValueError), + ([dummy_with_op], ValueError), + ], +) +def test_error_prox_combo(arg, error): + """Test errors for proximity combo.""" + npt.assert_raises(error, proximity.ProximityCombo, arg) - npt.assert_equal( - self.elasticnet_beta0.cost(self.data9, verbose=True), - 408.0 * 3.0, - err_msg='Incorect shrinkage cost in ElasticNet class.', - ) - def test_one_support_norm(self): - """Test KSupportNorm with k=1.""" - npt.assert_allclose( - self.one_support.op(self.data1.flatten()), - self.data2.flatten(), - err_msg='Incorect sparse threshold operation for 1-support norm', - rtol=1e-6, - ) - - npt.assert_equal( - self.one_support.cost(self.data1.flatten(), verbose=True), - 259.2, - err_msg='Incorect sparse threshold cost.', - ) +@pytest.mark.skipif(SKLEARN_AVAILABLE, reason="sklearn is installed") +def test_fail_sklearn(): + """Test fail OWL with sklearn.""" + npt.assert_raises(ImportError, proximity.OrderedWeightedL1Norm, 1) - npt.assert_raises(ValueError, proximity.KSupportNorm, 0, 0) - def test_five_support_norm(self): - """Test KSupportNorm with k=5.""" - npt.assert_allclose( - self.five_support_norm.op(self.data1.flatten()), - self.data11.flatten(), - err_msg='Incorect sparse Ksupport norm operation', - rtol=1e-6, - ) +@pytest.mark.skipif(not SKLEARN_AVAILABLE, reason="sklearn is not installed.") +def test_fail_owl(): + """Test errors for Ordered Weighted L1 Norm.""" + npt.assert_raises( + ValueError, + proximity.OrderedWeightedL1Norm, + np.arange(10), + ) - npt.assert_equal( - self.five_support_norm.cost(self.data1.flatten(), verbose=True), - 684.0, - err_msg='Incorrect 5-support norm cost.', - ) + npt.assert_raises( + ValueError, + proximity.OrderedWeightedL1Norm, + -np.arange(10), + ) - npt.assert_raises(ValueError, proximity.KSupportNorm, 0, 0) - def test_d_support_norm(self): - """Test KSupportNorm with k=19.""" - npt.assert_allclose( - self.d_support.op(self.data9.flatten()), - self.data10.flatten(), - err_msg='Incorect shrinkage operation for d-support norm', - rtol=1e-6, - ) +def test_fail_lowrank(): + """Test fail for lowrank.""" + prox_op = proximity.LowRankMatrix(10, lowr_type="fail") + npt.assert_raises(ValueError, prox_op.op, 0) - npt.assert_almost_equal( - self.d_support.cost(self.data9.flatten(), verbose=True), - 408.0 * 3.0, - err_msg='Incorrect shrinkage cost for d-support norm.', - ) - npt.assert_raises(ValueError, proximity.KSupportNorm, 0, 0) +def test_fail_Ksupport_norm(): + """Test fail for K-support norm.""" + npt.assert_raises(ValueError, proximity.KSupportNorm, 0, 0) - def test_group_lasso(self): - """Test GroupLASSO.""" - npt.assert_allclose( - self.group_lasso.op(self.random_data_tile), - self.gl_result_data, - ) - npt.assert_equal( - self.group_lasso.cost(self.random_data_tile), - np.sum(6 * self.random_data_tile), - ) - # Check that for 0 weights operator doesnt change result - self.group_lasso.weights = np.zeros_like(self.group_lasso.weights) - npt.assert_equal( - self.group_lasso.op(self.random_data_tile), - self.random_data_tile, - ) - npt.assert_equal(self.group_lasso.cost(self.random_data_tile), 0) +def test_reweight(): + """Test for reweight module.""" + data1 = np.arange(9).reshape(3, 3).astype(float) + 1 + data2 = np.array( + [[0.5, 1.0, 1.5], [2.0, 2.5, 3.0], [3.5, 4.0, 4.5]], + ) -class ReweightTestCase(TestCase): - """Test case for reweight module.""" + rw = reweight.cwbReweight(data1) + rw.reweight(data1) - def setUp(self): - """Set test parameter values.""" - self.data1 = np.arange(9).reshape(3, 3).astype(float) + 1 - self.data2 = np.array( - [[0.5, 1.0, 1.5], [2.0, 2.5, 3.0], [3.5, 4.0, 4.5]], - ) - self.rw = reweight.cwbReweight(self.data1) - self.rw.reweight(self.data1) - - def tearDown(self): - """Unset test parameter values.""" - self.data1 = None - self.data2 = None - self.rw = None - - def test_cwbreweight(self): - """Test cwbReweight.""" - npt.assert_array_equal( - self.rw.weights, - self.data2, - err_msg='Incorrect CWB re-weighting.', - ) + npt.assert_array_equal( + rw.weights, + data2, + err_msg="Incorrect CWB re-weighting.", + ) - npt.assert_raises(ValueError, self.rw.reweight, self.data1[0]) + npt.assert_raises(ValueError, rw.reweight, data1[0]) diff --git a/modopt/tests/test_signal.py b/modopt/tests/test_signal.py index 7490b98c..202e541b 100644 --- a/modopt/tests/test_signal.py +++ b/modopt/tests/test_signal.py @@ -1,322 +1,240 @@ -# -*- coding: utf-8 -*- - """UNIT TESTS FOR SIGNAL. This module contains unit tests for the modopt.signal module. -:Author: Samuel Farrens - +:Authors: + Samuel Farrens + Pierre-Antoine Comby """ -from unittest import TestCase - import numpy as np import numpy.testing as npt +import pytest +from test_helpers import failparam from modopt.signal import filter, noise, positivity, svd, validation, wavelet -class FilterTestCase(TestCase): - """Test case for filter module.""" - - def test_guassian_filter(self): - """Test guassian_filter.""" - npt.assert_almost_equal( - filter.gaussian_filter(1, 1), - 0.24197072451914337, - err_msg='Incorrect Gaussian filter', - ) +class TestFilter: + """Test filter module""" + @pytest.mark.parametrize( + ("norm", "result"), [(True, 0.24197072451914337), (False, 0.60653065971263342)] + ) + def test_gaussian_filter(self, norm, result): + """Test gaussian filter.""" + npt.assert_almost_equal(filter.gaussian_filter(1, 1, norm=norm), result) - npt.assert_almost_equal( - filter.gaussian_filter(1, 1, norm=False), - 0.60653065971263342, - err_msg='Incorrect Gaussian filter', - ) def test_mex_hat(self): - """Test mex_hat.""" + """Test mexican hat filter.""" npt.assert_almost_equal( filter.mex_hat(2, 1), -0.35213905225713371, - err_msg='Incorrect Mexican hat filter', ) + def test_mex_hat_dir(self): - """Test mex_hat_dir.""" + """Test directional mexican hat filter.""" npt.assert_almost_equal( filter.mex_hat_dir(1, 2, 1), 0.17606952612856686, - err_msg='Incorrect directional Mexican hat filter', ) -class NoiseTestCase(TestCase): - """Test case for noise module.""" +class TestNoise: + """Test noise module.""" - def setUp(self): - """Set test parameter values.""" - self.data1 = np.arange(9).reshape(3, 3).astype(float) - self.data2 = np.array( - [[0, 2.0, 2.0], [4.0, 5.0, 10], [11.0, 15.0, 18.0]], - ) - self.data3 = np.array([ + data1 = np.arange(9).reshape(3, 3).astype(float) + data2 = np.array( + [[0, 2.0, 2.0], [4.0, 5.0, 10], [11.0, 15.0, 18.0]], + ) + data3 = np.array( + [ [1.62434536, 0.38824359, 1.47182825], [1.92703138, 4.86540763, 2.6984613], [7.74481176, 6.2387931, 8.3190391], - ]) - self.data4 = np.array([[0, 0, 0], [0, 0, 5.0], [6.0, 7.0, 8.0]]) - self.data5 = np.array( - [[0, 0, 0], [0, 0, 0], [1.0, 2.0, 3.0]], - ) - - def tearDown(self): - """Unset test parameter values.""" - self.data1 = None - self.data2 = None - self.data3 = None - self.data4 = None - self.data5 = None - - def test_add_noise_poisson(self): - """Test add_noise with Poisson noise.""" - np.random.seed(1) - npt.assert_array_equal( - noise.add_noise(self.data1, noise_type='poisson'), - self.data2, - err_msg='Incorrect noise: Poisson', - ) - - npt.assert_raises( - ValueError, - noise.add_noise, - self.data1, - noise_type='bla', - ) - - npt.assert_raises(ValueError, noise.add_noise, self.data1, (1, 1)) - - def test_add_noise_gaussian(self): - """Test add_noise with Gaussian noise.""" - np.random.seed(1) - npt.assert_almost_equal( - noise.add_noise(self.data1), - self.data3, - err_msg='Incorrect noise: Gaussian', - ) - + ] + ) + data4 = np.array([[0, 0, 0], [0, 0, 5.0], [6.0, 7.0, 8.0]]) + data5 = np.array( + [[0, 0, 0], [0, 0, 0], [1.0, 2.0, 3.0]], + ) + + @pytest.mark.parametrize( + ("data", "noise_type", "sigma", "data_noise"), + [ + (data1, "poisson", 1, data2), + (data1, "gauss", 1, data3), + (data1, "gauss", (1, 1, 1), data3), + failparam(data1, "fail", 1, data1, raises=ValueError), + ], + ) + def test_add_noise(self, data, noise_type, sigma, data_noise): + """Test add_noise.""" np.random.seed(1) npt.assert_almost_equal( - noise.add_noise(self.data1, sigma=(1, 1, 1)), - self.data3, - err_msg='Incorrect noise: Gaussian', - ) - - def test_thresh_hard(self): - """Test thresh with hard threshold.""" - npt.assert_array_equal( - noise.thresh(self.data1, 5), - self.data4, - err_msg='Incorrect threshold: hard', - ) - - npt.assert_raises( - ValueError, - noise.thresh, - self.data1, - 5, - threshold_type='bla', + noise.add_noise(data, sigma=sigma, noise_type=noise_type), data_noise ) - def test_thresh_soft(self): - """Test thresh with soft threshold.""" + @pytest.mark.parametrize( + ("threshold_type", "result"), + [("hard", data4), ("soft", data5), failparam("fail", None, raises=ValueError)], + ) + def test_thresh(self, threshold_type, result): + """Test threshold.""" npt.assert_array_equal( - noise.thresh(self.data1, 5, threshold_type='soft'), - self.data5, - err_msg='Incorrect threshold: soft', + noise.thresh(self.data1, 5, threshold_type=threshold_type), result ) - -class PositivityTestCase(TestCase): - """Test case for positivity module.""" - - def setUp(self): - """Set test parameter values.""" - self.data1 = np.arange(9).reshape(3, 3) - 5 - self.data2 = np.array([[0, 0, 0], [0, 0, 0], [1, 2, 3]]) - self.data3 = np.array( - [np.arange(5) - 3, np.arange(4) - 2], - dtype=object, - ) - self.data4 = np.array( - [np.array([0, 0, 0, 0, 1]), np.array([0, 0, 0, 1])], +class TestPositivity: + """Test positivity module.""" + data1 = np.arange(9).reshape(3, 3).astype(float) + data4 = np.array([[0, 0, 0], [0, 0, 5.0], [6.0, 7.0, 8.0]]) + data5 = np.array( + [[0, 0, 0], [0, 0, 0], [1.0, 2.0, 3.0]], + ) + @pytest.mark.parametrize( + ("value", "expected"), + [ + (-1.0, -float(0)), + (-1, 0), + (data1 - 5, data5), + ( + np.array([np.arange(3) - 1, np.arange(2) - 1], dtype=object), + np.array([np.array([0, 0, 1]), np.array([0, 0])], dtype=object), + ), + failparam("-1", None, raises=TypeError), + ], + ) + def test_positive(self, value, expected): + """Test positive.""" + if isinstance(value, np.ndarray) and value.dtype == "O": + for v, e in zip(positivity.positive(value), expected): + npt.assert_array_equal(v, e) + else: + npt.assert_array_equal(positivity.positive(value), expected) + + +class TestSVD: + """Test for svd module.""" + + @pytest.fixture + def data(self): + """Initialize test data.""" + data1 = np.arange(18).reshape(9, 2).astype(float) + data2 = np.arange(32).reshape(16, 2).astype(float) + data3 = np.array( + [ + np.array( + [ + [-0.01744594, -0.61438865], + [-0.08435304, -0.50397984], + [-0.15126014, -0.39357102], + [-0.21816724, -0.28316221], + [-0.28507434, -0.17275339], + [-0.35198144, -0.06234457], + [-0.41888854, 0.04806424], + [-0.48579564, 0.15847306], + [-0.55270274, 0.26888188], + ] + ), + np.array([42.23492742, 1.10041151]), + np.array( + [ + [-0.67608034, -0.73682791], + [0.73682791, -0.67608034], + ] + ), + ], dtype=object, ) - self.pos_dtype_obj = positivity.positive(self.data3) - self.err = 'Incorrect positivity' - - def tearDown(self): - """Unset test parameter values.""" - self.data1 = None - self.data2 = None - - def test_positivity(self): - """Test positivity.""" - npt.assert_equal(positivity.positive(-1), 0, err_msg=self.err) - - npt.assert_equal( - positivity.positive(-1.0), - -float(0), - err_msg=self.err, + data4 = np.array( + [ + [-1.05426832e-16, 1.0], + [2.0, 3.0], + [4.0, 5.0], + [6.0, 7.0], + [8.0, 9.0], + [1.0e1, 1.1e1], + [1.2e1, 1.3e1], + [1.4e1, 1.5e1], + [1.6e1, 1.7e1], + ] ) - npt.assert_equal( - positivity.positive(self.data1), - self.data2, - err_msg=self.err, + data5 = np.array( + [ + [0.49815487, 0.54291537], + [2.40863386, 2.62505584], + [4.31911286, 4.70719631], + [6.22959185, 6.78933678], + [8.14007085, 8.87147725], + [10.05054985, 10.95361772], + [11.96102884, 13.03575819], + [13.87150784, 15.11789866], + [15.78198684, 17.20003913], + ] ) + return (data1, data2, data3, data4, data5) - for expected, output in zip(self.data4, self.pos_dtype_obj): - print(expected, output) - npt.assert_array_equal(expected, output, err_msg=self.err) + @pytest.fixture + def svd0(self, data): + """Compute SVD of first data sample.""" + return svd.calculate_svd(data[0]) - npt.assert_raises(TypeError, positivity.positive, '-1') - - -class SVDTestCase(TestCase): - """Test case for svd module.""" - - def setUp(self): - """Set test parameter values.""" - self.data1 = np.arange(18).reshape(9, 2).astype(float) - self.data2 = np.arange(32).reshape(16, 2).astype(float) - self.data3 = np.array( - [ - np.array([ - [-0.01744594, -0.61438865], - [-0.08435304, -0.50397984], - [-0.15126014, -0.39357102], - [-0.21816724, -0.28316221], - [-0.28507434, -0.17275339], - [-0.35198144, -0.06234457], - [-0.41888854, 0.04806424], - [-0.48579564, 0.15847306], - [-0.55270274, 0.26888188], - ]), - np.array([42.23492742, 1.10041151]), - np.array([ - [-0.67608034, -0.73682791], - [0.73682791, -0.67608034], - ]), - ], - dtype=object, - ) - self.data4 = np.array([ - [-1.05426832e-16, 1.0], - [2.0, 3.0], - [4.0, 5.0], - [6.0, 7.0], - [8.0, 9.0], - [1.0e1, 1.1e1], - [1.2e1, 1.3e1], - [1.4e1, 1.5e1], - [1.6e1, 1.7e1], - ]) - self.data5 = np.array([ - [0.49815487, 0.54291537], - [2.40863386, 2.62505584], - [4.31911286, 4.70719631], - [6.22959185, 6.78933678], - [8.14007085, 8.87147725], - [10.05054985, 10.95361772], - [11.96102884, 13.03575819], - [13.87150784, 15.11789866], - [15.78198684, 17.20003913], - ]) - self.svd = svd.calculate_svd(self.data1) - - def tearDown(self): - """Unset test parameter values.""" - self.data1 = None - self.data2 = None - self.data3 = None - self.data4 = None - self.svd = None - - def test_find_n_pc(self): - """Test find_n_pc.""" + def test_find_n_pc(self, data): + """Test find number of principal component.""" npt.assert_equal( - svd.find_n_pc(svd.svd(self.data2)[0]), + svd.find_n_pc(svd.svd(data[1])[0]), 2, - err_msg='Incorrect number of principal components.', + err_msg="Incorrect number of principal components.", ) + def test_n_pc_fail_non_square(self): + """Test find_n_pc.""" npt.assert_raises(ValueError, svd.find_n_pc, np.arange(3)) - def test_calculate_svd(self): + def test_calculate_svd(self, data, svd0): """Test calculate_svd.""" + errors = [] + for i, name in enumerate("USV"): + try: + npt.assert_almost_equal(svd0[i], data[2][i]) + except AssertionError: + errors.append(name) + if errors: + raise AssertionError("Incorrect SVD calculation for: " + ", ".join(errors)) + + @pytest.mark.parametrize( + ("n_pc", "idx_res"), + [(None, 3), (1, 4), ("all", 0), failparam("fail", 1, raises=ValueError)], + ) + def test_svd_thresh(self, data, n_pc, idx_res): + """Test svd_tresh.""" npt.assert_almost_equal( - self.svd[0], - np.array(self.data3)[0], - err_msg='Incorrect SVD calculation: U', - ) - - npt.assert_almost_equal( - self.svd[1], - np.array(self.data3)[1], - err_msg='Incorrect SVD calculation: S', - ) - - npt.assert_almost_equal( - self.svd[2], - np.array(self.data3)[2], - err_msg='Incorrect SVD calculation: V', - ) - - def test_svd_thresh(self): - """Test svd_thresh.""" - npt.assert_almost_equal( - svd.svd_thresh(self.data1), - self.data4, - err_msg='Incorrect SVD tresholding', - ) - - npt.assert_almost_equal( - svd.svd_thresh(self.data1, n_pc=1), - self.data5, - err_msg='Incorrect SVD tresholding', - ) - - npt.assert_almost_equal( - svd.svd_thresh(self.data1, n_pc='all'), - self.data1, - err_msg='Incorrect SVD tresholding', + svd.svd_thresh(data[0], n_pc=n_pc), + data[idx_res], ) + def test_svd_tresh_invalid_type(self): + """Test svd_tresh failure.""" npt.assert_raises(TypeError, svd.svd_thresh, 1) - npt.assert_raises(ValueError, svd.svd_thresh, self.data1, n_pc='bla') - - def test_svd_thresh_coef(self): - """Test svd_thresh_coef.""" + @pytest.mark.parametrize("operator", [lambda x: x, failparam(0, raises=TypeError)]) + def test_svd_thresh_coef(self, data, operator): + """Test svd_tresh_coef.""" npt.assert_almost_equal( - svd.svd_thresh_coef(self.data1, lambda x_val: x_val, 0), - self.data1, - err_msg='Incorrect SVD coefficient tresholding', + svd.svd_thresh_coef(data[0], operator, 0), + data[0], + err_msg="Incorrect SVD coefficient tresholding", ) - npt.assert_raises(TypeError, svd.svd_thresh_coef, self.data1, 0, 0) - + # TODO test_svd_thresh_coef_fast -class ValidationTestCase(TestCase): - """Test case for validation module.""" +class TestValidation: + """Test validation Module.""" - def setUp(self): - """Set test parameter values.""" - self.data1 = np.arange(9).reshape(3, 3).astype(float) - - def tearDown(self): - """Unset test parameter values.""" - self.data1 = None + array33 = np.arange(9).reshape(3, 3) def test_transpose_test(self): """Test transpose_test.""" @@ -325,90 +243,81 @@ def test_transpose_test(self): validation.transpose_test( lambda x_val, y_val: x_val.dot(y_val), lambda x_val, y_val: x_val.dot(y_val.T), - self.data1.shape, - x_args=self.data1, + self.array33.shape, + x_args=self.array33, ), None, ) - npt.assert_raises( - TypeError, - validation.transpose_test, - 0, - 0, - self.data1.shape, - x_args=self.data1, - ) - -class WaveletTestCase(TestCase): - """Test case for wavelet module.""" +class TestWavelet: + """Test Wavelet Module.""" - def setUp(self): + @pytest.fixture + def data(self): """Set test parameter values.""" - self.data1 = np.arange(9).reshape(3, 3).astype(float) - self.data2 = np.arange(36).reshape(4, 3, 3).astype(float) - self.data3 = np.array([ - [ - [6.0, 20, 26.0], - [36.0, 84.0, 84.0], - [90, 164.0, 134.0], - ], + data1 = np.arange(9).reshape(3, 3).astype(float) + data2 = np.arange(36).reshape(4, 3, 3).astype(float) + data3 = np.array( [ - [78.0, 155.0, 134.0], - [225.0, 408.0, 327.0], - [270, 461.0, 350], - ], + [ + [6.0, 20, 26.0], + [36.0, 84.0, 84.0], + [90, 164.0, 134.0], + ], + [ + [78.0, 155.0, 134.0], + [225.0, 408.0, 327.0], + [270, 461.0, 350], + ], + [ + [150, 290, 242.0], + [414.0, 732.0, 570], + [450, 758.0, 566.0], + ], + [ + [222.0, 425.0, 350], + [603.0, 1056.0, 813.0], + [630, 1055.0, 782.0], + ], + ] + ) + + data4 = np.array( [ - [150, 290, 242.0], - [414.0, 732.0, 570], - [450, 758.0, 566.0], - ], + [6496.0, 9796.0, 6544.0], + [9924.0, 14910, 9924.0], + [6544.0, 9796.0, 6496.0], + ] + ) + + data5 = np.array( [ - [222.0, 425.0, 350], - [603.0, 1056.0, 813.0], - [630, 1055.0, 782.0], - ], - ]) - - self.data4 = np.array([ - [6496.0, 9796.0, 6544.0], - [9924.0, 14910, 9924.0], - [6544.0, 9796.0, 6496.0], - ]) - - self.data5 = np.array([ - [[0, 1.0, 4.0], [3.0, 10, 13.0], [6.0, 19.0, 22.0]], - [[3.0, 10, 13.0], [24.0, 46.0, 40], [45.0, 82.0, 67.0]], - [[6.0, 19.0, 22.0], [45.0, 82.0, 67.0], [84.0, 145.0, 112.0]], - ]) - - def tearDown(self): - """Unset test parameter values.""" - self.data1 = None - self.data2 = None - self.data3 = None - self.data4 = None - self.data5 = None - - def test_filter_convolve(self): - """Test filter_convolve.""" - npt.assert_almost_equal( - wavelet.filter_convolve(self.data1, self.data2), - self.data3, - err_msg='Inccorect filter comvolution.', + [[0, 1.0, 4.0], [3.0, 10, 13.0], [6.0, 19.0, 22.0]], + [[3.0, 10, 13.0], [24.0, 46.0, 40], [45.0, 82.0, 67.0]], + [[6.0, 19.0, 22.0], [45.0, 82.0, 67.0], [84.0, 145.0, 112.0]], + ] ) + return (data1, data2, data3, data4, data5) + @pytest.mark.parametrize( + ("idx_data", "idx_filter", "idx_res", "filter_rot"), + [(0, 1, 2, False), (1, 1, 3, True)], + ) + def test_filter_convolve(self, data, idx_data, idx_filter, idx_res, filter_rot): + """Test filter_convolve.""" npt.assert_almost_equal( - wavelet.filter_convolve(self.data2, self.data2, filter_rot=True), - self.data4, - err_msg='Inccorect filter comvolution.', + wavelet.filter_convolve( + data[idx_data], data[idx_filter], filter_rot=filter_rot + ), + data[idx_res], + err_msg="Inccorect filter comvolution.", ) - def test_filter_convolve_stack(self): + def test_filter_convolve_stack(self, data): """Test filter_convolve_stack.""" npt.assert_almost_equal( - wavelet.filter_convolve_stack(self.data1, self.data1), - self.data5, - err_msg='Inccorect filter stack comvolution.', + wavelet.filter_convolve_stack(data[0], data[0]), + data[4], + err_msg="Inccorect filter stack comvolution.", ) diff --git a/setup.cfg b/setup.cfg index afe46bbc..8d8e821b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -79,16 +79,17 @@ max-string-usages = 20 max-raises = 5 [tool:pytest] +norecursedirs=tests/test_helpers testpaths = modopt addopts = --verbose - --emoji --cov=modopt - --cov-report=term + --cov-report=term-missing --cov-report=xml --junitxml=pytest.xml --pydocstyle [pydocstyle] convention=numpy +add-ignore=D107 From 71af711d50ea21923d725d9e0b75fe9f7b9f81ac Mon Sep 17 00:00:00 2001 From: Samuel Farrens Date: Tue, 14 Mar 2023 17:22:03 +0100 Subject: [PATCH 28/46] removed unnormalised Gaussian kernel option and corresponding test --- modopt/math/stats.py | 40 +++++++++++++++++---------------------- modopt/tests/test_math.py | 10 ---------- 2 files changed, 17 insertions(+), 33 deletions(-) diff --git a/modopt/math/stats.py b/modopt/math/stats.py index 3ac818a7..6bbbdef1 100644 --- a/modopt/math/stats.py +++ b/modopt/math/stats.py @@ -18,7 +18,7 @@ import_astropy = True -def gaussian_kernel(data_shape, sigma, norm='max'): +def gaussian_kernel(data_shape, sigma, norm="max"): """Gaussian kernel. This method produces a Gaussian kerenal of a specified size and dispersion. @@ -60,22 +60,19 @@ def gaussian_kernel(data_shape, sigma, norm='max'): """ if not import_astropy: # pragma: no cover - raise ImportError('Astropy package not found.') + raise ImportError("Astropy package not found.") - if norm not in {'max', 'sum', 'none'}: + if norm not in {"max", "sum"}: raise ValueError('Invalid norm, options are "max", "sum" or "none".') kernel = np.array( Gaussian2DKernel(sigma, x_size=data_shape[1], y_size=data_shape[0]), ) - if norm == 'max': + if norm == "max": return kernel / np.max(kernel) - elif norm == 'sum': - return kernel / np.sum(kernel) - - elif norm == 'none': + else: return kernel @@ -147,7 +144,7 @@ def mse(data1, data2): return np.mean((data1 - data2) ** 2) -def psnr(data1, data2, method='starck', max_pix=255): +def psnr(data1, data2, method="starck", max_pix=255): r"""Peak Signal-to-Noise Ratio. This method calculates the Peak Signal-to-Noise Ratio between two data @@ -202,23 +199,21 @@ def psnr(data1, data2, method='starck', max_pix=255): 10\log_{10}(\mathrm{MSE})) """ - if method == 'starck': - return ( - 20 * np.log10( - (data1.shape[0] * np.abs(np.max(data1) - np.min(data1))) - / np.linalg.norm(data1 - data2), - ) + if method == "starck": + return 20 * np.log10( + (data1.shape[0] * np.abs(np.max(data1) - np.min(data1))) + / np.linalg.norm(data1 - data2), ) - elif method == 'wiki': - return (20 * np.log10(max_pix) - 10 * np.log10(mse(data1, data2))) + elif method == "wiki": + return 20 * np.log10(max_pix) - 10 * np.log10(mse(data1, data2)) raise ValueError( 'Invalid PSNR method. Options are "starck" and "wiki"', ) -def psnr_stack(data1, data2, metric=np.mean, method='starck'): +def psnr_stack(data1, data2, metric=np.mean, method="starck"): """Peak Signa-to-Noise for stack of images. This method calculates the PSNRs for two stacks of 2D arrays. @@ -261,12 +256,11 @@ def psnr_stack(data1, data2, metric=np.mean, method='starck'): """ if data1.ndim != 3 or data2.ndim != 3: - raise ValueError('Input data must be a 3D np.ndarray') + raise ValueError("Input data must be a 3D np.ndarray") - return metric([ - psnr(i_elem, j_elem, method=method) - for i_elem, j_elem in zip(data1, data2) - ]) + return metric( + [psnr(i_elem, j_elem, method=method) for i_elem, j_elem in zip(data1, data2)] + ) def sigma_mad(input_data): diff --git a/modopt/tests/test_math.py b/modopt/tests/test_math.py index e44011c9..ea177b15 100644 --- a/modopt/tests/test_math.py +++ b/modopt/tests/test_math.py @@ -284,16 +284,6 @@ class TestStats: ] ), ), - ( - "none", - np.array( - [ - [0.05854983, 0.09653235, 0.05854983], - [0.09653235, 0.15915494, 0.09653235], - [0.05854983, 0.09653235, 0.05854983], - ] - ), - ), failparam("fail", None, raises=ValueError), ], ) From 8046d103e16979012b979b0fbac430d930b40a74 Mon Sep 17 00:00:00 2001 From: Samuel Farrens Date: Tue, 14 Mar 2023 18:20:55 +0100 Subject: [PATCH 29/46] Restrict scikit-image version for testing --- .github/workflows/ci-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index cdfff840..3bf673ea 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -43,7 +43,7 @@ jobs: python -m pip install --upgrade pip python -m pip install -r develop.txt python -m pip install -r docs/requirements.txt - python -m pip install astropy scikit-image scikit-learn + python -m pip install astropy "scikit-image<0.20" scikit-learn python -m pip install tensorflow>=2.4.1 python -m pip install twine python -m pip install . From f8e5926efde17ec56cc4d5369709d05bcf000c6e Mon Sep 17 00:00:00 2001 From: Samuel Farrens Date: Tue, 14 Mar 2023 18:26:11 +0100 Subject: [PATCH 30/46] added fix for basic test suite --- .github/workflows/ci-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 3bf673ea..b24bea79 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -108,7 +108,7 @@ jobs: python --version python -m pip install --upgrade pip python -m pip install -r develop.txt - python -m pip install astropy scikit-image scikit-learn + python -m pip install astropy "scikit-image<0.20" scikit-learn python -m pip install . - name: Run Tests From 0137a3858b4463e1e7a2408e505da33367d22bfa Mon Sep 17 00:00:00 2001 From: Samuel Farrens Date: Wed, 15 Mar 2023 11:44:31 +0100 Subject: [PATCH 31/46] set behaviour for different astropy versions --- modopt/math/stats.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/modopt/math/stats.py b/modopt/math/stats.py index 6bbbdef1..09871ca3 100644 --- a/modopt/math/stats.py +++ b/modopt/math/stats.py @@ -11,6 +11,8 @@ import numpy as np try: + from packaging import version + from astropy import __version__ as astropy_version from astropy.convolution import Gaussian2DKernel except ImportError: # pragma: no cover import_astropy = False @@ -72,6 +74,9 @@ def gaussian_kernel(data_shape, sigma, norm="max"): if norm == "max": return kernel / np.max(kernel) + elif version.parse(astropy_version) < version.parse("5.2"): + return kernel / np.sum(kernel) + else: return kernel From a961c3cf9e9b8ff4c91489aa840937afb5c27adc Mon Sep 17 00:00:00 2001 From: Samuel Farrens Date: Wed, 15 Mar 2023 13:51:11 +0100 Subject: [PATCH 32/46] updated docstring for gaussian_kernel --- modopt/math/stats.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/modopt/math/stats.py b/modopt/math/stats.py index 09871ca3..59bf6759 100644 --- a/modopt/math/stats.py +++ b/modopt/math/stats.py @@ -31,9 +31,8 @@ def gaussian_kernel(data_shape, sigma, norm="max"): Desiered shape of the kernel sigma : float Standard deviation of the kernel - norm : {'max', 'sum', 'none'}, optional - Normalisation of the kerenl (options are ``'max'``, ``'sum'`` or - ``'none'``, default is ``'max'``) + norm : {'max', 'sum'}, optional + Normalisation of the kerenl (options are ``'max'`` or ``'sum'``, default is ``'max'``) Returns ------- From 2d7da27fa9d6cbd78a434029ae9b0f905fe72868 Mon Sep 17 00:00:00 2001 From: Pierre-Antoine Comby Date: Wed, 15 Mar 2023 18:08:09 +0100 Subject: [PATCH 33/46] Use example scripts as tests. (#277) * Initialize the example module. * do not export the assert statements. * add matplotlib as requirement. * add support for sphinx-gallery * Update modopt/examples/README.rst Co-authored-by: Samuel Farrens * Update modopt/examples/__init__.py Co-authored-by: Samuel Farrens * Update modopt/examples/conftest.py Co-authored-by: Samuel Farrens * Update modopt/examples/example_lasso_forward_backward.py Co-authored-by: Samuel Farrens * Update modopt/examples/example_lasso_forward_backward.py Co-authored-by: Samuel Farrens * ignore auto_example folder * doc formatting. * add pogm and basic comparison. * fix: add matplotlib for the plotting in examples scripts. * fix: add matplotlib for basic ci too. * ci: run pytest with xdist for faster testing --------- Co-authored-by: Samuel Farrens --- .github/workflows/cd-build.yml | 4 +- .github/workflows/ci-build.yml | 6 +- .gitignore | 1 + docs/requirements.txt | 1 + docs/source/conf.py | 13 ++ docs/source/toc.rst | 1 + modopt/examples/README.rst | 5 + modopt/examples/__init__.py | 10 ++ modopt/examples/conftest.py | 46 ++++++ .../example_lasso_forward_backward.py | 153 ++++++++++++++++++ modopt/opt/algorithms/forward_backward.py | 4 +- 11 files changed, 236 insertions(+), 8 deletions(-) create mode 100644 modopt/examples/README.rst create mode 100644 modopt/examples/__init__.py create mode 100644 modopt/examples/conftest.py create mode 100644 modopt/examples/example_lasso_forward_backward.py diff --git a/.github/workflows/cd-build.yml b/.github/workflows/cd-build.yml index 1e49f8bc..fca9feb1 100644 --- a/.github/workflows/cd-build.yml +++ b/.github/workflows/cd-build.yml @@ -62,9 +62,7 @@ jobs: - name: Set up Conda with Python 3.8 uses: conda-incubator/setup-miniconda@v2 with: - auto-update-conda: true - python-version: 3.8 - auto-activate-base: false + python-version: "3.8" - name: Install dependencies shell: bash -l {0} diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index b24bea79..c4ba28a0 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -43,7 +43,7 @@ jobs: python -m pip install --upgrade pip python -m pip install -r develop.txt python -m pip install -r docs/requirements.txt - python -m pip install astropy "scikit-image<0.20" scikit-learn + python -m pip install astropy "scikit-image<0.20" scikit-learn matplotlib python -m pip install tensorflow>=2.4.1 python -m pip install twine python -m pip install . @@ -108,11 +108,11 @@ jobs: python --version python -m pip install --upgrade pip python -m pip install -r develop.txt - python -m pip install astropy "scikit-image<0.20" scikit-learn + python -m pip install astropy "scikit-image<0.20" scikit-learn matplotlib python -m pip install . - name: Run Tests shell: bash -l {0} run: | export PATH=/usr/share/miniconda/bin:$PATH - python setup.py test + pytest -n 2 diff --git a/.gitignore b/.gitignore index 06dff8db..f9eaaa68 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,7 @@ instance/ docs/_build/ docs/source/fortuna.* docs/source/scripts.* +docs/source/auto_examples/ docs/source/*.nblink # PyBuilder diff --git a/docs/requirements.txt b/docs/requirements.txt index 4d2a14fb..c9e29c88 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -6,3 +6,4 @@ numpydoc==1.1.0 sphinx==4.3.1 sphinxcontrib-bibtex==2.4.1 sphinxawesome-theme==3.2.1 +sphinx-gallery==0.11.1 diff --git a/docs/source/conf.py b/docs/source/conf.py index fb954f6d..46564b9f 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -45,6 +45,7 @@ 'nbsphinx', 'nbsphinx_link', 'numpydoc', + "sphinx_gallery.gen_gallery" ] # Include module names for objects @@ -145,6 +146,18 @@ # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. html_show_copyright = True + + +# -- Options for Sphinx Gallery ---------------------------------------------- + +sphinx_gallery_conf = { + "examples_dirs": ["../../modopt/examples/"], + "filename_pattern": "/example_", + "ignore_pattern": r"/(__init__|conftest)\.py", +} + + + # -- Options for nbshpinx output ------------------------------------------ diff --git a/docs/source/toc.rst b/docs/source/toc.rst index 84a6af87..ef5753f5 100644 --- a/docs/source/toc.rst +++ b/docs/source/toc.rst @@ -25,6 +25,7 @@ plugin_example notebooks + auto_examples/index .. toctree:: :hidden: diff --git a/modopt/examples/README.rst b/modopt/examples/README.rst new file mode 100644 index 00000000..e6ffbe27 --- /dev/null +++ b/modopt/examples/README.rst @@ -0,0 +1,5 @@ +======== +Examples +======== + +This is a collection of Python scripts demonstrating the use of ModOpt. diff --git a/modopt/examples/__init__.py b/modopt/examples/__init__.py new file mode 100644 index 00000000..d7e77357 --- /dev/null +++ b/modopt/examples/__init__.py @@ -0,0 +1,10 @@ +"""EXAMPLES. + +This module contains documented examples that demonstrate the usage of various +ModOpt tools. + +These examples also serve as integration tests for various methods. + +:Author: Pierre-Antoine Comby + +""" diff --git a/modopt/examples/conftest.py b/modopt/examples/conftest.py new file mode 100644 index 00000000..73358679 --- /dev/null +++ b/modopt/examples/conftest.py @@ -0,0 +1,46 @@ +"""TEST CONFIGURATION. + +This module contains methods for configuring the testing of the example +scripts. + +:Author: Pierre-Antoine Comby + +Notes +----- +Based on: +https://stackoverflow.com/questions/56807698/how-to-run-script-as-pytest-test + +""" +from pathlib import Path +import runpy +import pytest + +def pytest_collect_file(path, parent): + """Pytest hook. + + Create a collector for the given path, or None if not relevant. + The new node needs to have the specified parent as parent. + """ + p = Path(path) + if p.suffix == '.py' and 'example' in p.name: + return Script.from_parent(parent, path=p, name=p.name) + + +class Script(pytest.File): + """Script files collected by pytest.""" + + def collect(self): + """Collect the script as its own item.""" + yield ScriptItem.from_parent(self, name=self.name) + +class ScriptItem(pytest.Item): + """Item script collected by pytest.""" + + def runtest(self): + """Run the script as a test.""" + runpy.run_path(str(self.path)) + + def repr_failure(self, excinfo): + """Return only the error traceback of the script.""" + excinfo.traceback = excinfo.traceback.cut(path=self.path) + return super().repr_failure(excinfo) diff --git a/modopt/examples/example_lasso_forward_backward.py b/modopt/examples/example_lasso_forward_backward.py new file mode 100644 index 00000000..7f820000 --- /dev/null +++ b/modopt/examples/example_lasso_forward_backward.py @@ -0,0 +1,153 @@ +# noqa: D205 +""" +Solving the LASSO Problem with the Forward Backward Algorithm. +============================================================== + +This an example to show how to solve an example LASSO Problem +using the Forward-Backward Algorithm. + +In this example we are going to use: + - Modopt Operators (Linear, Gradient, Proximal) + - Modopt implementation of solvers + - Modopt Metric API. +TODO: add reference to LASSO paper. +""" + +import numpy as np +import matplotlib.pyplot as plt + +from modopt.opt.algorithms import ForwardBackward, POGM +from modopt.opt.cost import costObj +from modopt.opt.linear import LinearParent, Identity +from modopt.opt.gradient import GradBasic +from modopt.opt.proximity import SparseThreshold +from modopt.math.matrix import PowerMethod +from modopt.math.stats import mse + +# %% +# Here we create a instance of the LASSO Problem + +BETA_TRUE = np.array( + [3.0, 1.5, 0, 0, 2, 0, 0, 0] +) # 8 original values from lLASSO Paper +DIM = len(BETA_TRUE) + + +rng = np.random.default_rng() +sigma_noise = 1 +obs = 20 +# create a measurement matrix with decaying covariance matrix. +cov = 0.4 ** abs((np.arange(DIM) * np.ones((DIM, DIM))).T - np.arange(DIM)) +x = rng.multivariate_normal(np.zeros(DIM), cov, obs) + +y = x @ BETA_TRUE +y_noise = y + (sigma_noise * np.random.standard_normal(obs)) + + +# %% +# Next we create Operators for solving the problem. + +# MatrixOperator could also work here. +lin_op = LinearParent(lambda b: x @ b, lambda bb: x.T @ bb) +grad_op = GradBasic(y_noise, op=lin_op.op, trans_op=lin_op.adj_op) + +prox_op = SparseThreshold(Identity(), 1, thresh_type="soft") + +# %% +# In order to get the best convergence rate, we first determine the Lipschitz constant of the gradient Operator +# + +calc_lips = PowerMethod(grad_op.trans_op_op, 8, data_type="float32", auto_run=True) +lip = calc_lips.spec_rad +print("lipschitz constant:", lip) + +# %% +# Solving using FISTA algorithm +# ----------------------------- +# +# TODO: Add description/Reference of FISTA. + +cost_op_fista = costObj([grad_op, prox_op], verbose=False) + +fb_fista = ForwardBackward( + np.zeros(8), + beta_param=1 / lip, + grad=grad_op, + prox=prox_op, + cost=cost_op_fista, + metric_call_period=1, + auto_iterate=False, # Just to give us the pleasure of doing things by ourself. +) + +fb_fista.iterate() + +# %% +# After the run we can have a look at the results + +print(fb_fista.x_final) +mse_fista = mse(fb_fista.x_final, BETA_TRUE) +plt.stem(fb_fista.x_final, label="estimation", linefmt="C0-") +plt.stem(BETA_TRUE, label="reference", linefmt="C1-") +plt.legend() +plt.title(f"FISTA Estimation MSE={mse_fista:.4f}") + +# sphinx_gallery_start_ignore +assert mse(fb_fista.x_final, BETA_TRUE) < 1 +# sphinx_gallery_end_ignore + + +# %% +# Solving Using the POGM Algorithm +# -------------------------------- +# +# TODO: Add description/Reference to POGM. + + +cost_op_pogm = costObj([grad_op, prox_op], verbose=False) + +fb_pogm = POGM( + np.zeros(8), + np.zeros(8), + np.zeros(8), + np.zeros(8), + beta_param=1 / lip, + grad=grad_op, + prox=prox_op, + cost=cost_op_pogm, + metric_call_period=1, + auto_iterate=False, # Just to give us the pleasure of doing things by ourself. +) + +fb_pogm.iterate() + +# %% +# After the run we can have a look at the results + +print(fb_pogm.x_final) +mse_pogm = mse(fb_pogm.x_final, BETA_TRUE) + +plt.stem(fb_pogm.x_final, label="estimation", linefmt="C0-") +plt.stem(BETA_TRUE, label="reference", linefmt="C1-") +plt.legend() +plt.title(f"FISTA Estimation MSE={mse_pogm:.4f}") +# +# sphinx_gallery_start_ignore +assert mse(fb_pogm.x_final, BETA_TRUE) < 1 + +# %% +# Comparing the Two algorithms +# ---------------------------- + +plt.figure() +plt.semilogy(cost_op_fista._cost_list, label="FISTA convergence") +plt.semilogy(cost_op_pogm._cost_list, label="POGM convergence") +plt.xlabel("iterations") +plt.ylabel("Cost Function") +plt.legend() +plt.show() + + +# %% +# We can see that the two algorithm converges quickly, and POGM requires less iterations. +# However the POGM iterations are more costly, so a proper benchmark with time measurement is needed. +# Check the benchopt benchmark for more details. diff --git a/modopt/opt/algorithms/forward_backward.py b/modopt/opt/algorithms/forward_backward.py index 6923a6af..83f45e8b 100644 --- a/modopt/opt/algorithms/forward_backward.py +++ b/modopt/opt/algorithms/forward_backward.py @@ -817,9 +817,9 @@ class POGM(SetUp): Initial guess for the :math:`y` variable z : numpy.ndarray Initial guess for the :math:`z` variable - grad + grad : GradBasic Gradient operator class - prox + prox : ProximalParent Proximity operator class cost : class instance or str, optional Cost function class instance (default is ``'auto'``); Use ``'auto'`` to From 6d698bb3687677dec38e8de85a5f617bcdb90bf1 Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Wed, 19 Apr 2023 08:41:08 -0500 Subject: [PATCH 34/46] fix: specify data_range for ssim. Refs: #290 --- modopt/math/metrics.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modopt/math/metrics.py b/modopt/math/metrics.py index 1b870e23..21952624 100644 --- a/modopt/math/metrics.py +++ b/modopt/math/metrics.py @@ -23,7 +23,7 @@ def min_max_normalize(img): """Min-Max Normalize. - Centre and normalize a given array. + Normalize a given array in the [0,1] range. Parameters ---------- @@ -33,7 +33,7 @@ def min_max_normalize(img): Returns ------- numpy.ndarray - Centred and normalized array + normalized array """ min_img = img.min() @@ -126,7 +126,7 @@ def ssim(test, ref, mask=None): test, ref, mask = _preprocess_input(test, ref, mask) test = move_to_cpu(test) - assim, ssim_value = compare_ssim(test, ref, full=True) + assim, ssim_value = compare_ssim(test, ref, full=True, data_range=1.0) if mask is None: return assim From 53a6bdde917deb495bcf9eee61b1fbfb2428657d Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Thu, 11 May 2023 11:15:04 +0200 Subject: [PATCH 35/46] typos. --- modopt/opt/algorithms/__init__.py | 2 +- modopt/opt/algorithms/admm.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/modopt/opt/algorithms/__init__.py b/modopt/opt/algorithms/__init__.py index e0ac2572..a4dc4146 100644 --- a/modopt/opt/algorithms/__init__.py +++ b/modopt/opt/algorithms/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -r"""OPTIMISATION ALGOTITHMS. +r"""OPTIMISATION ALGORITHMS. This module contains class implementations of various optimisation algoritms. diff --git a/modopt/opt/algorithms/admm.py b/modopt/opt/algorithms/admm.py index 5925c9ae..b1163e1b 100644 --- a/modopt/opt/algorithms/admm.py +++ b/modopt/opt/algorithms/admm.py @@ -66,8 +66,7 @@ def _calc_cost(self, u, v): class ADMM(SetUp): r"""Fast ADMM Optimisation Algorihm. - This class implement the ADMM algorithm - (Algorithm 1 from :cite:`Goldstein2014`) + This class implement the ADMM algorithm described in :cite:`Goldstein2014` (Algorithm 1). Parameters ---------- @@ -149,7 +148,7 @@ def _update(self): obs=self.B.op(self._v_old) + self._u_old - self.b, ) tmp = self.A.op(self._u_new) - self._v_new = self.solver2( + self._v_new = self._opti_G( init=self._v_old, obs=tmp + self._u_old - self.c, ) From 8dfa8a35387bbad209e60defa9e1b4cf4570f7de Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Thu, 11 May 2023 11:51:23 +0200 Subject: [PATCH 36/46] feat(test): add test for admm. --- modopt/opt/algorithms/__init__.py | 1 + modopt/opt/algorithms/admm.py | 37 ++++++++++++++++++------------- modopt/tests/test_algorithms.py | 34 ++++++++++++++++++++++++++-- 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/modopt/opt/algorithms/__init__.py b/modopt/opt/algorithms/__init__.py index a4dc4146..d4e7082b 100644 --- a/modopt/opt/algorithms/__init__.py +++ b/modopt/opt/algorithms/__init__.py @@ -57,3 +57,4 @@ SAGAOptGradOpt, VanillaGenericGradOpt) from modopt.opt.algorithms.primal_dual import Condat +from modopt.opt.algorithms.admm import ADMM, FastADMM diff --git a/modopt/opt/algorithms/admm.py b/modopt/opt/algorithms/admm.py index b1163e1b..6d7a59aa 100644 --- a/modopt/opt/algorithms/admm.py +++ b/modopt/opt/algorithms/admm.py @@ -38,7 +38,7 @@ def __init__(self, cost_funcs, A, B, b, tau, **kwargs): self.b = b self.tau = tau - def _calc_cost(self, u, v): + def _calc_cost(self, u, v, **kwargs): """Calculate the cost. This method calculates the cost from each of the input operators. @@ -57,8 +57,8 @@ def _calc_cost(self, u, v): """ xp = get_array_module(u) - cost = self.cost_func[0](u) - cost += self.cost_func[1](v) + cost = self.cost_funcs[0](u) + cost += self.cost_funcs[1](v) cost += self.tau * xp.linalg.norm(self.A.op(u) + self.B.op(v) - self.b) return cost @@ -70,6 +70,12 @@ class ADMM(SetUp): Parameters ---------- + u: array_like + First primal variable of ADMM + v: array_like + Second primal variable of ADMM + mu: array_like + Lagrangian multiplier. A : OperatorBase Linear operator for u B : OperatorBase @@ -83,10 +89,9 @@ class ADMM(SetUp): .. math:: v_{k+1} = \argmin G(v) + \frac{\tau}{2}\|Bv - y \|^2 cost_funcs = 2-tuple of function Compute the values of H and G - rho : float , optional - regularisation coupling variable default is ``1.0`` - eta : float, optional - Restart threshold, default is ``0.999`` + tau: float, default=1 + Coupling parameter for ADMM. + max_iter2: int Notes ----- @@ -120,7 +125,6 @@ def __init__( b, optimizers, tau=1, - max_iter2=5, cost_funcs=None, **kwargs, ): @@ -131,8 +135,10 @@ def __init__( self._opti_H = optimizers[0] self._opti_G = optimizers[1] self._tau = tau - - self._cost_func = ADMMcostObj(cost_funcs, A, B, b, tau) + if cost_funcs is not None: + self._cost_func = ADMMcostObj(cost_funcs, A, B, b, tau) + else: + self._cost_func = None # init iteration variables. self._u_old = self.xp.copy(u) @@ -150,7 +156,7 @@ def _update(self): tmp = self.A.op(self._u_new) self._v_new = self._opti_G( init=self._v_old, - obs=tmp + self._u_old - self.c, + obs=tmp + self._u_old - self.b, ) self._mu_new = self._mu_old + (tmp + self.B.op(self._v_new) - self.b) @@ -163,7 +169,7 @@ def _update(self): # Test cost function for convergence. if self._cost_func: self.converge = self.any_convergence_flag() - self.converge |= self._cost_func.get_cost(self._u_new, self.v_new) + self.converge |= self._cost_func.get_cost(self._u_new, self._v_new) def iterate(self, max_iter=150): """Iterate. @@ -182,6 +188,7 @@ def iterate(self, max_iter=150): self.retrieve_outputs() # rename outputs as attributes self.u_final = self._u_new + self.x_final = self.u_final # for backward compatibility self.v_final = self._v_new def get_notify_observers_kwargs(self): @@ -196,7 +203,7 @@ def get_notify_observers_kwargs(self): The mapping between the iterated variables """ return { - 'u_new': self._u_new, + 'x_new': self._u_new, 'v_new': self._v_new, 'idx': self.idx, } @@ -309,9 +316,9 @@ def _update(self): obs=self.B.op(self._v_hat) + self._u_old - self.b, ) tmp = self.A.op(self._u_new) - self._v_new = self.solver2( + self._v_new = self._opti_G( init=self._v_hat, - obs=tmp + self._u_hat - self.c, + obs=tmp + self._u_hat - self.b, ) self._mu_new = self._mu_hat + (tmp + self.B.op(self._v_new) - self.b) diff --git a/modopt/tests/test_algorithms.py b/modopt/tests/test_algorithms.py index 73091acd..0cb1670e 100644 --- a/modopt/tests/test_algorithms.py +++ b/modopt/tests/test_algorithms.py @@ -80,7 +80,15 @@ def build_kwargs(kwargs, use_metrics): @parametrize(use_metrics=[True, False]) class AlgoCases: - """Cases for algorithms.""" + """Cases for algorithms. + + Most of the test solves the trivial problem + + .. math:: + \\min_x \\frac{1}{2} \\| y - x \\|_2^2 \\quad\\text{s.t.} x \\geq 0 + + More complex and concrete usecases are shown in examples. + """ data1 = np.arange(9).reshape(3, 3).astype(float) data2 = data1 + np.random.randn(*data1.shape) * 1e-6 @@ -103,7 +111,8 @@ class AlgoCases: ] ) def case_forward_backward(self, kwargs, idty, use_metrics): - """Forward Backward case.""" + """Forward Backward case. + """ update_kwargs = build_kwargs(kwargs, use_metrics) algo = algorithms.ForwardBackward( self.data1, @@ -234,6 +243,27 @@ def case_grad(self, GradDescent, use_metrics, idty): algo.iterate() return algo, update_kwargs + def case_admm(self, use_metrics, idty): + """ADMM setup.""" + def optim1(init, obs): + return obs + + def optim2(init, obs): + return obs + + update_kwargs = build_kwargs({}, use_metrics) + algo = algorithms.ADMM( + u=self.data1, + v=self.data1, + mu=np.zeros_like(self.data1), + A=linear.Identity(), + B=linear.Identity(), + b=self.data1, + optimizers=(optim1, optim2), + **update_kwargs, + ) + algo.iterate() + return algo, update_kwargs @parametrize_with_cases("algo, kwargs", cases=AlgoCases) def test_algo(algo, kwargs): From 6d4e7804b4ecba7fa74008a7dfa900d866af236b Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Thu, 11 May 2023 12:08:23 +0200 Subject: [PATCH 37/46] feat(admm): improve doc. --- modopt/opt/algorithms/admm.py | 75 +++++++++++++++-------------------- 1 file changed, 31 insertions(+), 44 deletions(-) diff --git a/modopt/opt/algorithms/admm.py b/modopt/opt/algorithms/admm.py index 6d7a59aa..f8281093 100644 --- a/modopt/opt/algorithms/admm.py +++ b/modopt/opt/algorithms/admm.py @@ -3,10 +3,10 @@ from modopt.base.backend import get_array_module from modopt.opt.algorithms.base import SetUp -from modopt.opt.cost import AbstractcostObj +from modopt.opt.cost import CostParent -class ADMMcostObj(AbstractcostObj): +class ADMMcostObj(CostParent): r"""Cost Object for the ADMM problem class. Compute :math:`f(u)+g(v) + \tau \| Au +Bv - b\|^2` @@ -27,7 +27,7 @@ class ADMMcostObj(AbstractcostObj): See Also -------- - AbstractcostObj: parent class + CostParent: parent class """ def __init__(self, cost_funcs, A, B, b, tau, **kwargs): @@ -71,18 +71,18 @@ class ADMM(SetUp): Parameters ---------- u: array_like - First primal variable of ADMM + Initial value for first primal variable of ADMM v: array_like - Second primal variable of ADMM + Initial value for second primal variable of ADMM mu: array_like - Lagrangian multiplier. + Initial value for lagrangian multiplier. A : OperatorBase Linear operator for u B : OperatorBase Linear operator for v b : array_like Constraint vector - optimizers: 2-tuple of functions + optimizers: 2-tuple of callable. Solvers for the u and v update, takes init_value and obs_value as argument. each element returns an estimate for: .. math:: u_{k+1} = \argmin H(u) + \frac{\tau}{2}\|A u - y\|^2 @@ -91,7 +91,6 @@ class ADMM(SetUp): Compute the values of H and G tau: float, default=1 Coupling parameter for ADMM. - max_iter2: int Notes ----- @@ -228,45 +227,39 @@ class FastADMM(ADMM): Parameters ---------- + u: array_like + Initial value for first primal variable of ADMM + v: array_like + Initial value for second primal variable of ADMM + mu: array_like + Initial value for lagrangian multiplier. A : OperatorBase Linear operator for u B : OperatorBase Linear operator for v b : array_like Constraint vector - solver1 : function - Solver for the x update, takes init_value and obs_value as argument. - ie, return an estimate for: + optimizers: 2-tuple of callable. + Solvers for the u and v update, takes init_value and obs_value as + argument. each element returns an estimate for: .. math:: u_{k+1} = \argmin H(u) + \frac{\tau}{2}\|A u - y\|^2 - solver2 : function - Solver for the z update, takes init_value and obs_value as argument. - ie return an estimate for: .. math:: v_{k+1} = \argmin G(v) + \frac{\tau}{2}\|Bv - y \|^2 - rho : float , optional - regularisation coupling variable default is ``1.0`` - eta : float, optional - Restart threshold, default is ``0.999`` + cost_funcs = 2-tuple of function + Compute the values of H and G + tau: float, default=1 + Coupling parameter for ADMM. + eta: float, default=0.999 + Convergence parameter for ADMM. + alpha: float, default=1. + Initial value for the FISTA-like acceleration parameter. Notes ----- - The algorithm solve the problem: - - .. math:: u, v = \arg\min H(u) + G(v) + \frac\tau2 \|Au + Bv - b \|_2^2 - - with the following augmented lagrangian: - - .. math:: \mathcal{L}_{\tau}(u,v, \lambda) = H(u) + G(v) - +\langle\lambda |Au + Bv -b \rangle + \frac\tau2 \| Au + Bv -b \|^2 - - To allow easy iterative solving, the change of variable - :math:`\mu=\lambda/\tau` is used. Hence, the lagrangian of interest is: - - .. math :: \tilde{\mathcal{L}}_{\tau}(u,v, \mu) = H(u) + G(v) - + \frac\tau2 \left(\|\mu + Au +Bv - b\|^2 - \|\mu\|^2\right) + This is an accelerated version of the ADMM algorithm. The convergence hypothesis are stronger than for the ADMM algorithm. See Also -------- - SetUp: parent class + ADMM: parent class """ def __init__( @@ -277,14 +270,11 @@ def __init__( A, B, b, - opti_H, - opti_G, + optimizers, + cost_funcs=None, alpha=1, eta=0.999, tau=1, - opti_H_kwargs=None, - opti_G_kwargs=None, - cost=None, **kwargs, ): super().__init__( @@ -294,11 +284,8 @@ def __init__( A=A, B=B, b=b, - opti_H=opti_H, - opti_G=opti_G, - opti_H_kwargs=opti_H_kwargs, - opti_G_kwargs=opti_G, - cost=None, + optimizers=optimizers, + cost_funcs=cost_funcs, **kwargs, ) self._c_old = np.inf @@ -318,7 +305,7 @@ def _update(self): tmp = self.A.op(self._u_new) self._v_new = self._opti_G( init=self._v_hat, - obs=tmp + self._u_hat - self.b, + obs=tmp + self._u_old - self.b, ) self._mu_new = self._mu_hat + (tmp + self.B.op(self._v_new) - self.b) From 843e65b31abb76e889aefbcd32a563936040faa1 Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Thu, 11 May 2023 12:08:39 +0200 Subject: [PATCH 38/46] refactor: rename abstract cost to CostParent. --- modopt/opt/cost.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modopt/opt/cost.py b/modopt/opt/cost.py index a90c87ae..688a3959 100644 --- a/modopt/opt/cost.py +++ b/modopt/opt/cost.py @@ -15,7 +15,7 @@ from modopt.plot.cost_plot import plotCost -class AbstractcostObj(abc.ABC): +class CostParent(abc.ABC): """Abstract cost function object. This class updates the cost according to the input operator classes and @@ -212,7 +212,7 @@ def plot_cost(self): # pragma: no cover plotCost(self._cost_list, self._plot_output) -class costObj(AbstractcostObj): +class costObj(CostParent): """Abstract cost function object. This class updates the cost according to the input operator classes and From 676ecc0c8d6706c136f7ba92442aa06a252df785 Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Thu, 11 May 2023 12:09:02 +0200 Subject: [PATCH 39/46] feat: add test for fast admm. --- modopt/tests/test_algorithms.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modopt/tests/test_algorithms.py b/modopt/tests/test_algorithms.py index 0cb1670e..5671b8e3 100644 --- a/modopt/tests/test_algorithms.py +++ b/modopt/tests/test_algorithms.py @@ -242,8 +242,8 @@ def case_grad(self, GradDescent, use_metrics, idty): ) algo.iterate() return algo, update_kwargs - - def case_admm(self, use_metrics, idty): + @parametrize(admm=[algorithms.ADMM,algorithms.FastADMM]) + def case_admm(self, admm, use_metrics, idty): """ADMM setup.""" def optim1(init, obs): return obs @@ -252,7 +252,7 @@ def optim2(init, obs): return obs update_kwargs = build_kwargs({}, use_metrics) - algo = algorithms.ADMM( + algo = admm( u=self.data1, v=self.data1, mu=np.zeros_like(self.data1), From 9af7fc40d6394461f70cff747fbe8555a73d5d6d Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Thu, 11 May 2023 12:20:22 +0200 Subject: [PATCH 40/46] feat(admm): improve docstrings. --- modopt/opt/algorithms/admm.py | 54 ++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/modopt/opt/algorithms/admm.py b/modopt/opt/algorithms/admm.py index f8281093..0524ae47 100644 --- a/modopt/opt/algorithms/admm.py +++ b/modopt/opt/algorithms/admm.py @@ -9,7 +9,6 @@ class ADMMcostObj(CostParent): r"""Cost Object for the ADMM problem class. - Compute :math:`f(u)+g(v) + \tau \| Au +Bv - b\|^2` Parameters ---------- @@ -19,11 +18,14 @@ class ADMMcostObj(CostParent): First Operator B : OperatorBase Second Operator - b : array_like + b : numpy.ndarray Observed data **kwargs : dict Extra parameters for cost operator configuration + Notes + ----- + Compute :math:`f(u)+g(v) + \tau \| Au +Bv - b\|^2` See Also -------- @@ -45,9 +47,9 @@ def _calc_cost(self, u, v, **kwargs): Parameters ---------- - u: array_like + u: numpy.ndarray First primal variable of ADMM - v: array_like + v: numpy.ndarray Second primal variable of ADMM Returns @@ -70,25 +72,25 @@ class ADMM(SetUp): Parameters ---------- - u: array_like + u: numpy.ndarray Initial value for first primal variable of ADMM - v: array_like + v: numpy.ndarray Initial value for second primal variable of ADMM - mu: array_like + mu: numpy.ndarray Initial value for lagrangian multiplier. - A : OperatorBase + A : modopt.opt.linear.LinearOperator Linear operator for u - B : OperatorBase + B: modopt.opt.linear.LinearOperator Linear operator for v - b : array_like + b : numpy.ndarray Constraint vector - optimizers: 2-tuple of callable. - Solvers for the u and v update, takes init_value and obs_value as - argument. each element returns an estimate for: + optimizers: tuple + 2-tuple of callable, that are the optimizers for the u and v. + Each callable should access an init and obs argument and returns an estimate for: .. math:: u_{k+1} = \argmin H(u) + \frac{\tau}{2}\|A u - y\|^2 .. math:: v_{k+1} = \argmin G(v) + \frac{\tau}{2}\|Bv - y \|^2 - cost_funcs = 2-tuple of function - Compute the values of H and G + cost_funcs: tuple + 2-tuple of callable, that compute values of H and G. tau: float, default=1 Coupling parameter for ADMM. @@ -227,25 +229,25 @@ class FastADMM(ADMM): Parameters ---------- - u: array_like + u: numpy.ndarray Initial value for first primal variable of ADMM - v: array_like + v: numpy.ndarray Initial value for second primal variable of ADMM - mu: array_like + mu: numpy.ndarray Initial value for lagrangian multiplier. - A : OperatorBase + A : modopt.opt.linear.LinearOperator Linear operator for u - B : OperatorBase + B: modopt.opt.linear.LinearOperator Linear operator for v - b : array_like + b : numpy.ndarray Constraint vector - optimizers: 2-tuple of callable. - Solvers for the u and v update, takes init_value and obs_value as - argument. each element returns an estimate for: + optimizers: tuple + 2-tuple of callable, that are the optimizers for the u and v. + Each callable should access an init and obs argument and returns an estimate for: .. math:: u_{k+1} = \argmin H(u) + \frac{\tau}{2}\|A u - y\|^2 .. math:: v_{k+1} = \argmin G(v) + \frac{\tau}{2}\|Bv - y \|^2 - cost_funcs = 2-tuple of function - Compute the values of H and G + cost_funcs: tuple + 2-tuple of callable, that compute values of H and G. tau: float, default=1 Coupling parameter for ADMM. eta: float, default=0.999 From fd0a4f9a2a1e4c851d3bcdebda622c4d486bdfa7 Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Thu, 11 May 2023 13:32:47 +0200 Subject: [PATCH 41/46] style: remove extra line.c --- modopt/opt/algorithms/admm.py | 1 - 1 file changed, 1 deletion(-) diff --git a/modopt/opt/algorithms/admm.py b/modopt/opt/algorithms/admm.py index 0524ae47..b881b770 100644 --- a/modopt/opt/algorithms/admm.py +++ b/modopt/opt/algorithms/admm.py @@ -9,7 +9,6 @@ class ADMMcostObj(CostParent): r"""Cost Object for the ADMM problem class. - Parameters ---------- cost_funcs: 2-tuples of callable From 0e03c9007c9abd936397b64b702e1c7885f32189 Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Wed, 26 Jul 2023 10:03:24 +0200 Subject: [PATCH 42/46] feat: make POGM more memory efficient. --- modopt/opt/algorithms/forward_backward.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/modopt/opt/algorithms/forward_backward.py b/modopt/opt/algorithms/forward_backward.py index 83f45e8b..702799c6 100644 --- a/modopt/opt/algorithms/forward_backward.py +++ b/modopt/opt/algorithms/forward_backward.py @@ -944,7 +944,9 @@ def _update(self): """ # Step 4 from alg. 3 self._grad.get_grad(self._x_old) - self._u_new = self._x_old - self._beta * self._grad.grad + #self._u_new = self._x_old - self._beta * self._grad.grad + self._u_new = -self._beta * self._grad.grad + self._u_new += self._x_old # Step 5 from alg. 3 self._t_new = 0.5 * (1 + self.xp.sqrt(1 + 4 * self._t_old ** 2)) @@ -966,10 +968,15 @@ def _update(self): # Restarting and gamma-Decreasing # Step 9 from alg. 3 - self._g_new = self._grad.grad - (self._x_new - self._z) / self._xi + #self._g_new = self._grad.grad - (self._x_new - self._z) / self._xi + self._g_new = (self._z - self._x_new) + self._g_new /= self._xi + self._g_new += self._grad.grad # Step 10 from alg 3. - self._y_new = self._x_old - self._beta * self._g_new + #self._y_new = self._x_old - self._beta * self._g_new + self._y_new = - self._beta * self._g_new + self._y_new += self._x_old # Step 11 from alg. 3 restart_crit = ( From 7c94edeeefaa81d63b10c2a228b7fab3a7141d9e Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Wed, 26 Jul 2023 10:03:45 +0200 Subject: [PATCH 43/46] feat: add a dummy cost for the identity operator. --- modopt/opt/linear.py | 1 + 1 file changed, 1 insertion(+) diff --git a/modopt/opt/linear.py b/modopt/opt/linear.py index 83241625..1fd146fb 100644 --- a/modopt/opt/linear.py +++ b/modopt/opt/linear.py @@ -79,6 +79,7 @@ def __init__(self): self.op = lambda input_data: input_data self.adj_op = self.op + self.cost= lambda *args, **kwargs: 0 class MatrixOperator(LinearParent): From 869af9a3ad4e4c0df77f184896833f2bd7eb76e1 Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Wed, 15 Nov 2023 11:15:35 +0100 Subject: [PATCH 44/46] feat: create a linear operator module, add wavelet transform. --- modopt/opt/linear/__init__.py | 21 +++ modopt/opt/{linear.py => linear/base.py} | 50 +----- modopt/opt/linear/wavelet.py | 216 +++++++++++++++++++++++ 3 files changed, 239 insertions(+), 48 deletions(-) create mode 100644 modopt/opt/linear/__init__.py rename modopt/opt/{linear.py => linear/base.py} (85%) create mode 100644 modopt/opt/linear/wavelet.py diff --git a/modopt/opt/linear/__init__.py b/modopt/opt/linear/__init__.py new file mode 100644 index 00000000..d5c0d21f --- /dev/null +++ b/modopt/opt/linear/__init__.py @@ -0,0 +1,21 @@ +"""LINEAR OPERATORS. + +This module contains linear operator classes. + +:Author: Samuel Farrens +:Author: Pierre-Antoine Comby +""" + +from .base import LinearParent, Identity, MatrixOperator, LinearCombo + +from .wavelet import WaveletConvolve, WaveletTransform + + +__all__ = [ + "LinearParent", + "Identity", + "MatrixOperator", + "LinearCombo", + "WaveletConvolve", + "WaveletTransform", +] diff --git a/modopt/opt/linear.py b/modopt/opt/linear/base.py similarity index 85% rename from modopt/opt/linear.py rename to modopt/opt/linear/base.py index 1fd146fb..e347970d 100644 --- a/modopt/opt/linear.py +++ b/modopt/opt/linear/base.py @@ -1,19 +1,9 @@ -# -*- coding: utf-8 -*- - -"""LINEAR OPERATORS. - -This module contains linear operator classes. - -:Author: Samuel Farrens - -""" +"""Base classes for linear operators.""" import numpy as np -from modopt.base.types import check_callable, check_float +from modopt.base.types import check_callable from modopt.base.backend import get_array_module -from modopt.signal.wavelet import filter_convolve_stack - class LinearParent(object): """Linear Operator Parent Class. @@ -99,42 +89,6 @@ def __init__(self, array): self.adj_op = lambda x: array.T @ x -class WaveletConvolve(LinearParent): - """Wavelet Convolution Class. - - This class defines the wavelet transform operators via convolution with - predefined filters. - - Parameters - ---------- - filters: numpy.ndarray - Array of wavelet filter coefficients - method : str, optional - Convolution method (default is ``'scipy'``) - - See Also - -------- - LinearParent : parent class - modopt.signal.wavelet.filter_convolve_stack : wavelet filter convolution - - """ - - def __init__(self, filters, method='scipy'): - - self._filters = check_float(filters) - self.op = lambda input_data: filter_convolve_stack( - input_data, - self._filters, - method=method, - ) - self.adj_op = lambda input_data: filter_convolve_stack( - input_data, - self._filters, - filter_rot=True, - method=method, - ) - - class LinearCombo(LinearParent): """Linear Combination Class. diff --git a/modopt/opt/linear/wavelet.py b/modopt/opt/linear/wavelet.py new file mode 100644 index 00000000..6e22a2b0 --- /dev/null +++ b/modopt/opt/linear/wavelet.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Wavelet operator, using either scipy filter or pywavelet.""" +import warnings + +import numpy as np + +from modopt.base.types import check_float +from modopt.signal.wavelet import filter_convolve_stack + +from .base import LinearParent + +pywt_available = True +try: + import pywt + from joblib import Parallel, cpu_count, delayed +except ImportError: + pywt_available = False + + +class WaveletConvolve(LinearParent): + """Wavelet Convolution Class. + + This class defines the wavelet transform operators via convolution with + predefined filters. + + Parameters + ---------- + filters: numpy.ndarray + Array of wavelet filter coefficients + method : str, optional + Convolution method (default is ``'scipy'``) + + See Also + -------- + LinearParent : parent class + modopt.signal.wavelet.filter_convolve_stack : wavelet filter convolution + + """ + + def __init__(self, filters, method='scipy'): + + self._filters = check_float(filters) + self.op = lambda input_data: filter_convolve_stack( + input_data, + self._filters, + method=method, + ) + self.adj_op = lambda input_data: filter_convolve_stack( + input_data, + self._filters, + filter_rot=True, + method=method, + ) + + + +class WaveletTransform(LinearParent): + """ + 2D and 3D wavelet transform class. + + This is a light wrapper around PyWavelet, with multicoil support. + + Parameters + ---------- + wavelet_name: str + the wavelet name to be used during the decomposition. + shape: tuple[int,...] + Shape of the input data. The shape should be a tuple of length 2 or 3. + It should not contains coils or batch dimension. + nb_scales: int, default 4 + the number of scales in the decomposition. + n_batchs: int, default 1 + the number of channel/ batch dimension + n_jobs: int, default 1 + the number of cores to use for multichannel. + backend: str, default "threading" + the backend to use for parallel multichannel linear operation. + verbose: int, default 0 + the verbosity level. + + Attributes + ---------- + nb_scale: int + number of scale decomposed in wavelet space. + n_jobs: int + number of jobs for parallel computation + n_batchs: int + number of coils use f + backend: str + Backend use for parallel computation + verbose: int + Verbosity level + """ + + def __init__( + self, + wavelet_name, + shape, + level=4, + n_batch=1, + n_jobs=1, + decimated=True, + backend="threading", + mode="symmetric", + ): + if not pywt_available: + raise ImportError( + "PyWavelet and/or joblib are not available. Please install it to use WaveletTransform." + ) + if wavelet_name not in pywt.wavelist(kind="all"): + raise ValueError( + "Invalid wavelet name. Availables are ``pywt.waveletlist(kind='all')``" + ) + + self.wavelet = wavelet_name + if isinstance(shape, int): + shape = (shape,) + self.shape = shape + self.n_jobs = n_jobs + self.mode = mode + self.level = level + if not decimated: + raise NotImplementedError( + "Undecimated Wavelet Transform is not implemented yet." + ) + ca, *cds = pywt.wavedecn_shapes( + self.shape, wavelet=self.wavelet, mode=self.mode, level=self.level + ) + self.coeffs_shape = [ca] + [s for cd in cds for s in cd.values()] + + if len(shape) > 1: + self.dwt = pywt.wavedecn + self.idwt = pywt.waverecn + self._pywt_fun = "wavedecn" + else: + self.dwt = pywt.wavedec + self.idwt = pywt.waverec + self._pywt_fun = "wavedec" + + self.n_batch = n_batch + if self.n_batch == 1 and self.n_jobs != 1: + warnings.warn("Making n_jobs = 1 for WaveletTransform as n_batchs = 1") + self.n_jobs = 1 + self.backend = backend + n_proc = self.n_jobs + if n_proc < 0: + n_proc = cpu_count() + self.n_jobs + 1 + + def op(self, data): + """Define the wavelet operator. + + This method returns the input data convolved with the wavelet filter. + + Parameters + ---------- + data: ndarray or Image + input 2D data array. + + Returns + ------- + coeffs: ndarray + the wavelet coefficients. + """ + if self.n_batch > 1: + coeffs, self.coeffs_slices, self.raw_coeffs_shape = zip( + *Parallel( + n_jobs=self.n_jobs, backend=self.backend, verbose=self.verbose + )(delayed(self._op)(data[i]) for i in np.arange(self.n_batch)) + ) + coeffs = np.asarray(coeffs) + else: + coeffs, self.coeffs_slices, self.raw_coeffs_shape = self._op(data) + return coeffs + + def _op(self, data): + """Single coil wavelet transform.""" + return pywt.ravel_coeffs( + self.dwt(data, mode=self.mode, level=self.level, wavelet=self.wavelet) + ) + + def adj_op(self, coeffs): + """Define the wavelet adjoint operator. + + This method returns the reconstructed image. + + Parameters + ---------- + coeffs: ndarray + the wavelet coefficients. + + Returns + ------- + data: ndarray + the reconstructed data. + """ + if self.n_batch > 1: + images = Parallel( + n_jobs=self.n_jobs, backend=self.backend, verbose=self.verbose + )( + delayed(self._adj_op)(coeffs[i], self.coeffs_shape[i]) + for i in np.arange(self.n_batch) + ) + images = np.asarray(images) + else: + images = self._adj_op(coeffs) + return images + + def _adj_op(self, coeffs): + """Single coil inverse wavelet transform.""" + return self.idwt( + pywt.unravel_coeffs( + coeffs, self.coeffs_slices, self.raw_coeffs_shape, self._pywt_fun + ), + wavelet=self.wavelet, + mode=self.mode, + ) From 906ddc47f3e6a2e421772b51a70a88f8bf4c5bd5 Mon Sep 17 00:00:00 2001 From: Pierre-antoine Comby Date: Wed, 15 Nov 2023 11:41:34 +0100 Subject: [PATCH 45/46] feat: add test case for wavelet transform. --- modopt/tests/test_opt.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/modopt/tests/test_opt.py b/modopt/tests/test_opt.py index 0e45ffb8..dace6d18 100644 --- a/modopt/tests/test_opt.py +++ b/modopt/tests/test_opt.py @@ -22,6 +22,12 @@ except ImportError: SKLEARN_AVAILABLE = False +PYWT_AVAILABLE = True +try: + import pywt + import joblib +except ImportError: + PYWT_AVAILABLE = False # Basic functions to be used as operators or as dummy functions func_identity = lambda x_val: x_val @@ -156,7 +162,7 @@ def case_linear_identity(self): return linop, data_op, data_adj_op, res_op, res_adj_op - def case_linear_wavelet(self): + def case_linear_wavelet_convolve(self): """Case linear operator wavelet.""" linop = linear.WaveletConvolve( filters=np.arange(8).reshape(2, 2, 2).astype(float) @@ -168,6 +174,19 @@ def case_linear_wavelet(self): return linop, data_op, data_adj_op, res_op, res_adj_op + @pytest.mark.skipif(not PYWT_AVAILABLE, reason="PyWavelet not available.") + def case_linear_wavelet_transform(self): + linop = linear.WaveletTransform( + wavelet_name="haar", + shape=(8, 8), + level=2, + ) + data_op = np.arange(64).reshape(8, 8).astype(float) + res_op, slices, shapes = pywt.ravel_coeffs(pywt.wavedecn(data_op, "haar", level=2)) + data_adj_op = linop.op(data_op) + res_adj_op = pywt.waverecn(pywt.unravel_coeffs(data_adj_op, slices, shapes, "wavedecn"), "haar") + return linop, data_op, data_adj_op, res_op, res_adj_op + @parametrize(weights=[[1.0, 1.0], None]) def case_linear_combo(self, weights): """Case linear operator combo with weights.""" From 13c15346d5741e3fcdf4cb1645755ce044935b8b Mon Sep 17 00:00:00 2001 From: Chaithya G R Date: Fri, 19 Jan 2024 13:03:35 +0100 Subject: [PATCH 46/46] Update setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c95e5984..e6a8a9e6 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ # Set the package release version major = 1 -minor = 6 +minor = 7 patch = 1 # Set the package details