Skip to content

Commit 0b4dcbc

Browse files
authored
Merge pull request #618 from jeverink/RegularizedGaussian-Additional-Constraints
Regularized Gaussian - additional constraints
2 parents 61f29c7 + 6a081b5 commit 0b4dcbc

File tree

6 files changed

+300
-47
lines changed

6 files changed

+300
-47
lines changed

cuqi/experimental/mcmc/_conjugate.py

Lines changed: 78 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from cuqi.experimental.mcmc import Sampler
55
from cuqi.distribution import Posterior, Gaussian, Gamma, GMRF, ModifiedHalfNormal
66
from cuqi.implicitprior import RegularizedGaussian, RegularizedGMRF, RegularizedUnboundedUniform
7-
from cuqi.utilities import get_non_default_args, count_nonzero, count_constant_components_1D, count_constant_components_2D
7+
from cuqi.utilities import get_non_default_args, count_nonzero, count_within_bounds, count_constant_components_1D, count_constant_components_2D, piecewise_linear_1D_DoF
88
from cuqi.geometry import Continuous1D, Continuous2D, Image2D
99

1010
class Conjugate(Sampler):
@@ -17,8 +17,8 @@ class Conjugate(Sampler):
1717
- (GMRF, Gamma) where Gamma is defined on the precision parameter of the GMRF
1818
- (RegularizedGaussian, Gamma) with preset constraints only and Gamma is defined on the precision parameter of the RegularizedGaussian
1919
- (RegularizedGMRF, Gamma) with preset constraints only and Gamma is defined on the precision parameter of the RegularizedGMRF
20-
- (RegularizedGaussian, ModifiedHalfNormal) with preset constraints and regularization only
21-
- (RegularizedGMRF, ModifiedHalfNormal) with preset constraints and regularization only
20+
- (RegularizedGaussian, ModifiedHalfNormal) with most of the preset constraints and regularization
21+
- (RegularizedGMRF, ModifiedHalfNormal) with most of the preset constraints and regularization
2222
2323
Currently the Gamma and ModifiedHalfNormal distribution must be univariate.
2424
@@ -147,8 +147,8 @@ def validate_target(self):
147147
if self.target.prior.dim != 1:
148148
raise ValueError("RegularizedGaussian-Gamma conjugacy only works with univariate ModifiedHalfNormal prior")
149149

150-
if self.target.likelihood.distribution.preset["constraint"] not in ["nonnegativity"]:
151-
raise ValueError("RegularizedGaussian-Gamma conjugacy only works with implicit regularized Gaussian likelihood with nonnegativity constraints")
150+
# Raises error if preset is not supported
151+
_compute_sparsity_level(self.target)
152152

153153
key_value_pairs = _get_conjugate_parameter(self.target)
154154
if len(key_value_pairs) != 1:
@@ -166,7 +166,7 @@ def validate_target(self):
166166
def conjugate_distribution(self):
167167
# Extract variables
168168
b = self.target.likelihood.data # mu
169-
m = np.count_nonzero(b) # n
169+
m = _compute_sparsity_level(self.target)
170170
Ax = self.target.likelihood.distribution.mean # x_i
171171
L = self.target.likelihood.distribution(np.array([1])).sqrtprec # L
172172
alpha = self.target.prior.shape # alpha
@@ -183,9 +183,9 @@ def validate_target(self):
183183
if self.target.prior.dim != 1:
184184
raise ValueError("RegularizedUnboundedUniform-Gamma conjugacy only works with univariate Gamma prior")
185185

186-
if self.target.likelihood.distribution.preset["regularization"] not in ["l1", "tv"]:
187-
raise ValueError("RegularizedUnboundedUniform-Gamma conjugacy only works with implicit regularized Gaussian likelihood with l1 or tv regularization")
188-
186+
# Raises error if preset is not supported
187+
_compute_sparsity_level(self.target)
188+
189189
key_value_pairs = _get_conjugate_parameter(self.target)
190190
if len(key_value_pairs) != 1:
191191
raise ValueError(f"Multiple references to conjugate parameter {self.target.prior.name} found in likelihood. Only one occurance is supported.")
@@ -219,8 +219,8 @@ def validate_target(self):
219219
if self.target.prior.dim != 1:
220220
raise ValueError("RegularizedGaussian-ModifiedHalfNormal conjugacy only works with univariate ModifiedHalfNormal prior")
221221

222-
if self.target.likelihood.distribution.preset["regularization"] not in ["l1", "tv"]:
223-
raise ValueError("RegularizedGaussian-ModifiedHalfNormal conjugacy only works with implicit regularized Gaussian likelihood with l1 or tv regularization")
222+
# Raises error if preset is not supported
223+
_compute_sparsity_level(self.target)
224224

225225
key_value_pairs = _get_conjugate_parameter(self.target)
226226
if len(key_value_pairs) != 2:
@@ -266,23 +266,74 @@ def conjugate_distribution(self):
266266

267267

268268
def _compute_sparsity_level(target):
269-
"""Computes the sparsity level in accordance with Section 4 from [2],"""
269+
"""Computes the sparsity level in accordance with Section 4 from [2],
270+
this can be interpreted as the number of degrees of freedom, that is,
271+
the number of components n minus the dimension the of the subdifferential of the regularized.
272+
"""
270273
x = target.likelihood.data
271-
if target.likelihood.distribution.preset["constraint"] == "nonnegativity":
272-
if target.likelihood.distribution.preset["regularization"] == "l1":
273-
m = count_nonzero(x)
274-
elif target.likelihood.distribution.preset["regularization"] == "tv" and isinstance(target.likelihood.distribution.geometry, Continuous1D):
275-
m = count_constant_components_1D(x, lower = 0.0)
276-
elif target.likelihood.distribution.preset["regularization"] == "tv" and isinstance(target.likelihood.distribution.geometry, (Continuous2D, Image2D)):
277-
m = count_constant_components_2D(target.likelihood.distribution.geometry.par2fun(x), lower = 0.0)
278-
else: # No constraints, only regularization
279-
if target.likelihood.distribution.preset["regularization"] == "l1":
280-
m = count_nonzero(x)
281-
elif target.likelihood.distribution.preset["regularization"] == "tv" and isinstance(target.likelihood.distribution.geometry, Continuous1D):
282-
m = count_constant_components_1D(x)
283-
elif target.likelihood.distribution.preset["regularization"] == "tv" and isinstance(target.likelihood.distribution.geometry, (Continuous2D, Image2D)):
284-
m = count_constant_components_2D(target.likelihood.distribution.geometry.par2fun(x))
285-
return m
274+
275+
constraint = target.likelihood.distribution.preset["constraint"]
276+
regularization = target.likelihood.distribution.preset["regularization"]
277+
278+
# There is no reference for some of these conjugacy rules
279+
if constraint == "nonnegativity":
280+
if regularization in [None, "l1"]:
281+
# Number of non-zero components in x
282+
return count_nonzero(x)
283+
elif regularization == "tv" and isinstance(target.likelihood.distribution.geometry, Continuous1D):
284+
# Number of non-zero constant components in x
285+
return count_constant_components_1D(x, lower = 0.0)
286+
elif regularization == "tv" and isinstance(target.likelihood.distribution.geometry, (Continuous2D, Image2D)):
287+
# Number of non-zero constant components in x
288+
return count_constant_components_2D(target.likelihood.distribution.geometry.par2fun(x), lower = 0.0)
289+
elif constraint == "box":
290+
bounds = target.likelihood.distribution._box_bounds
291+
if regularization is None:
292+
# Number of components in x that are strictly between the lower and upper bound
293+
return count_within_bounds(x, bounds[0], bounds[1])
294+
elif regularization == "l1":
295+
# Number of components in x that are strictly between the lower and upper bound and are not zero
296+
return count_within_bounds(x, bounds[0], bounds[1], exception = 0.0)
297+
elif regularization == "tv" and isinstance(target.likelihood.distribution.geometry, Continuous1D):
298+
# Number of constant components in x between are strictly between the lower and upper bound
299+
return count_constant_components_1D(x, lower = bounds[0], upper = bounds[1])
300+
elif regularization == "tv" and isinstance(target.likelihood.distribution.geometry, (Continuous2D, Image2D)):
301+
# Number of constant components in x between are strictly between the lower and upper bound
302+
return count_constant_components_2D(target.likelihood.distribution.geometry.par2fun(x), lower = bounds[0], upper = bounds[1])
303+
elif constraint in ["increasing", "decreasing"]:
304+
if regularization is None:
305+
# Number of constant components in x
306+
return count_constant_components_1D(x)
307+
elif regularization == "l1":
308+
# Number of constant components in x that are not zero
309+
return count_constant_components_1D(x, exception = 0.0)
310+
elif regularization == "tv" and isinstance(target.likelihood.distribution.geometry, Continuous1D):
311+
# Number of constant components in x
312+
return count_constant_components_1D(x)
313+
# Increasing and decreasing cannot be done in 2D
314+
elif constraint in ["convex", "concave"]:
315+
if regularization is None:
316+
# Number of piecewise linear components in x
317+
return piecewise_linear_1D_DoF(x)
318+
elif regularization == "l1":
319+
# Number of piecewise linear components in x that are not zero
320+
return piecewise_linear_1D_DoF(x, exception_zero = True)
321+
elif regularization == "tv" and isinstance(target.likelihood.distribution.geometry, Continuous1D):
322+
# Number of piecewise linear components in x that are not flat
323+
return piecewise_linear_1D_DoF(x, exception_flat = True)
324+
# convex and concave has only been implemented in 1D
325+
elif constraint == None:
326+
if regularization == "l1":
327+
# Number of non-zero components in x
328+
return count_nonzero(x)
329+
elif regularization == "tv" and isinstance(target.likelihood.distribution.geometry, Continuous1D):
330+
# Number of non-zero constant components in x
331+
return count_constant_components_1D(x)
332+
elif regularization == "tv" and isinstance(target.likelihood.distribution.geometry, (Continuous2D, Image2D)):
333+
# Number of non-zero constant components in x
334+
return count_constant_components_2D(target.likelihood.distribution.geometry.par2fun(x))
335+
336+
raise ValueError("RegularizedGaussian preset constraint and regularization choice is currently not supported with conjugacy.")
286337

287338

288339
def _get_conjugate_parameter(target):

cuqi/implicitprior/_regularizedGaussian.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
from cuqi.distribution import Distribution, Gaussian
33
from cuqi.solver import ProjectNonnegative, ProjectBox, ProximalL1
44
from cuqi.geometry import Continuous1D, Continuous2D, Image2D
5-
from cuqi.operator import FirstOrderFiniteDifference, Operator
5+
from cuqi.operator import FirstOrderFiniteDifference, SecondOrderFiniteDifference, Operator
66

77
import numpy as np
88
import scipy.sparse as sparse
9+
import scipy.optimize as spoptimize
10+
911
from copy import copy
1012

1113

@@ -60,12 +62,18 @@ class RegularizedGaussian(Distribution):
6062
min_(z in C) 0.5||x-z||_2^2.
6163
6264
constraint : string or None
63-
Preset constraints that generate the corresponding proximal parameter. Can be set to "nonnegativity" and "box". Required for use in Gibbs.
64-
For "box", the following additional parameters can be passed:
65+
Preset constraints that generate the corresponding proximal parameter. Required for use in Gibbs. For any geometry the following can be chosen:
66+
- "nonnegativity"
67+
- "box", the following additional parameters can be passed:
6568
lower_bound : array_like or None
6669
Lower bound of box, defaults to zero
6770
upper_bound : array_like
6871
Upper bound of box, defaults to one
72+
Additionally, for Continuous1D geometry the following options can be chosen:
73+
- "increasing"
74+
- "decreasing"
75+
- "convex"
76+
- "concave"
6977
7078
regularization : string or None
7179
Preset regularization that generate the corresponding proximal parameter. Can be set to "l1" or 'tv'. Required for use in Gibbs in future update.
@@ -178,6 +186,28 @@ def _parse_preset_constraint_input(self, constraint, optional_regularization_par
178186
self._proximal = lambda z, _: ProjectBox(z, _box_lower, _box_upper)
179187
self._box_bounds = (np.ones(self.dim)*_box_lower, np.ones(self.dim)*_box_upper)
180188
self._preset["constraint"] = "box"
189+
elif c_lower == "increasing":
190+
if not isinstance(self.geometry, Continuous1D):
191+
raise ValueError("Geometry not supported for " + c_lower)
192+
self._constraint_prox = lambda z, _: spoptimize.isotonic_regression(z, increasing=True).x
193+
self._preset["constraint"] = "increasing"
194+
elif c_lower == "decreasing":
195+
if not isinstance(self.geometry, Continuous1D):
196+
raise ValueError("Geometry not supported for " + c_lower)
197+
self._constraint_prox = lambda z, _: spoptimize.isotonic_regression(z, increasing=False).x
198+
self._preset["constraint"] = "decreasing"
199+
elif c_lower == "convex":
200+
if not isinstance(self.geometry, Continuous1D):
201+
raise ValueError("Geometry not supported for " + c_lower)
202+
self._constraint_prox = lambda z, _: -ProjectNonnegative(-z)
203+
self._constraint_oper = SecondOrderFiniteDifference(self.geometry.fun_shape, bc_type='neumann')
204+
self._preset["constraint"] = "convex"
205+
elif c_lower == "concave":
206+
if not isinstance(self.geometry, Continuous1D):
207+
raise ValueError("Geometry not supported for " + c_lower)
208+
self._constraint_prox = lambda z, _: ProjectNonnegative(z)
209+
self._constraint_oper = SecondOrderFiniteDifference(self.geometry.fun_shape, bc_type='neumann')
210+
self._preset["constraint"] = "concave"
181211
else:
182212
raise ValueError("Constraint not supported.")
183213

@@ -289,7 +319,7 @@ def _sample(self, N, rng=None):
289319

290320
@staticmethod
291321
def constraint_options():
292-
return ["nonnegativity", "box"]
322+
return ["nonnegativity", "box", "increasing", "decreasing", "convex", "concave"]
293323

294324
@staticmethod
295325
def regularization_options():

cuqi/utilities/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
plot_1D_density,
1515
plot_2D_density,
1616
count_nonzero,
17+
count_within_bounds,
1718
count_constant_components_1D,
18-
count_constant_components_2D
19+
count_constant_components_2D,
20+
piecewise_linear_1D_DoF
1921
)
2022

2123
from ._get_python_variable_name import _get_python_variable_name

cuqi/utilities/_utilities.py

Lines changed: 93 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -360,8 +360,29 @@ def count_nonzero(x, threshold = 1e-6):
360360
Theshold for considering a value as nonzero.
361361
"""
362362
return np.sum([np.abs(v) >= threshold for v in x])
363+
364+
365+
def count_within_bounds(x, lower_bounds, upper_bounds, threshold = 1e-6, exception = np.nan):
366+
""" Returns the number of values in an array whose value lies between the provided lower and upper bounds.
367+
368+
Parameters
369+
----------
370+
x : `np.ndarray`
371+
Array to count elements of.
372+
373+
lower_bounds : `np.ndarray`
374+
Lower bound on values to disregard when counting.
375+
376+
upper_bounds : `np.ndarray`
377+
Upper bound on values to disregard.
378+
379+
threshold : float
380+
Theshold for considering a value as nonzero.
381+
"""
382+
return np.sum([l + threshold <= v and v <= u - threshold and not (exception - threshold <= v and v <= exception + threshold) for v, l, u in zip(x, lower_bounds, upper_bounds)])
383+
363384

364-
def count_constant_components_1D(x, threshold = 1e-2, lower = -np.inf, upper = np.inf):
385+
def count_constant_components_1D(x, threshold = 1e-2, lower = -np.inf, upper = np.inf, exception = np.nan):
365386
""" Returns the number of piecewise constant components in a one-dimensional array
366387
367388
Parameters
@@ -372,29 +393,40 @@ def count_constant_components_1D(x, threshold = 1e-2, lower = -np.inf, upper = n
372393
threshold : float
373394
Strict theshold on when the difference of neighbouring values is considered zero.
374395
375-
lower : float
396+
lower : float or numpy.ndarray
376397
Piecewise constant components below this value are not counted.
377398
378-
upper : float
399+
upper : float or numpy.ndarray
379400
Piecewise constant components above this value are not counted.
380401
"""
381402

403+
if not isinstance(lower, np.ndarray):
404+
lower = lower*np.ones_like(x)
405+
406+
if not isinstance(upper, np.ndarray):
407+
upper = upper*np.ones_like(x)
408+
382409
counter = 0
383-
if x[0] > lower and x[0] < upper:
410+
index = 0
411+
if (x[index] > lower[index] and
412+
x[index] < upper[index] and
413+
not (exception - threshold <= x[index] and x[index] <= exception + threshold)):
384414
counter += 1
385415

386416
x_previous = x[0]
387417

388418
for x_current in x[1:]:
419+
index += 1
389420
if (abs(x_previous - x_current) >= threshold and
390-
x_current > lower and
391-
x_current < upper):
421+
x_current > lower[index] and
422+
x_current < upper[index] and
423+
not (exception - threshold <= x_current and x_current <= exception + threshold)):
392424
counter += 1
393425

394426
x_previous = x_current
395427

396428
return counter
397-
429+
398430
def count_constant_components_2D(x, threshold = 1e-2, lower = -np.inf, upper = np.inf):
399431
""" Returns the number of piecewise constant components in a two-dimensional array
400432
@@ -406,12 +438,19 @@ def count_constant_components_2D(x, threshold = 1e-2, lower = -np.inf, upper = n
406438
threshold : float
407439
Strict theshold on when the difference of neighbouring values is considered zero.
408440
409-
lower : float
441+
lower : float or numpy.ndarray
410442
Piecewise constant components below this value are not counted.
411443
412-
upper : float
444+
upper : float or numpy.ndarray
413445
Piecewise constant components above this value are not counted.
414446
"""
447+
448+
if not isinstance(lower, np.ndarray):
449+
lower = lower*np.ones_like(x)
450+
451+
if not isinstance(upper, np.ndarray):
452+
upper = upper*np.ones_like(x)
453+
415454
filled = np.zeros_like(x, dtype = int)
416455
counter = 0
417456

@@ -438,8 +477,51 @@ def process(i, j):
438477
for i in range(x.shape[0]):
439478
for j in range(x.shape[1]):
440479
if filled[i,j] == 0:
441-
if x[i,j] > lower and x[i,j] < upper:
480+
if x[i,j] > lower[i,j] and x[i,j] < upper[i,j]:
442481
counter += 1
443482
process(i, j)
444483
return counter
445-
484+
485+
486+
487+
def piecewise_linear_1D_DoF(x, threshold = 1e-5, exception_zero = False, exception_flat = False):
488+
""" Returns the degrees of freedom of a piecewise linear signal.
489+
Assuming linear interpolation, this corresponds to the number of non-differentiable points, including end-points.
490+
491+
Parameters
492+
----------
493+
x : `np.ndarray`
494+
1D Array to compute degrees of freedom of.
495+
496+
threshold : float
497+
Strict theshold on when values are considered zero.
498+
499+
exception_zero : Boolean
500+
Whether a zero piecewise linear components should be considered.
501+
502+
exception_flat : Boolean
503+
Whether a flat piecewise linear components should be considered.
504+
"""
505+
506+
differences = x[1:] - x[:-1]
507+
double_differences = differences[1:] - differences[:-1]
508+
509+
joints = [True] + [np.abs(d) >= threshold for d in double_differences] + [True]
510+
511+
if not exception_zero and not exception_flat:
512+
return np.sum(joints)
513+
elif exception_zero:
514+
return np.sum(joints and [np.abs(v) < threshold for v in x])
515+
elif exception_flat:
516+
prev_joint = None
517+
counter = 0
518+
for i in range(len(joints)):
519+
if joints[i]:
520+
counter += 1
521+
if prev_joint is None:
522+
prev_joint = i
523+
else:
524+
if np.abs(x[i] - x[prev_joint]) < threshold:
525+
counter -= 1
526+
prev_joint = i
527+
return counter

0 commit comments

Comments
 (0)