Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Gaussian confidence level attribute to PyROS EllipsoidalSet #3434

Merged
merged 13 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions doc/OnlineDocs/explanation/solvers/pyros.rst
Original file line number Diff line number Diff line change
Expand Up @@ -935,8 +935,8 @@ Observe that the log contains the following information:
:linenos:
==============================================================================
PyROS: The Pyomo Robust Optimization Solver, v1.3.0.
Pyomo version: 6.8.1
PyROS: The Pyomo Robust Optimization Solver, v1.3.1.
Pyomo version: 6.9.0
Commit hash: unknown
Invoked at UTC 2024-11-01T00:00:00.000000
Expand Down
7 changes: 7 additions & 0 deletions pyomo/contrib/pyros/CHANGELOG.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@
PyROS CHANGELOG
===============

-------------------------------------------------------------------------------
PyROS 1.3.1 25 Nov 2024
-------------------------------------------------------------------------------
- Add new EllipsoidalSet attribute for specifying a
confidence level in lieu of a (squared) scale factor


-------------------------------------------------------------------------------
PyROS 1.3.0 12 Aug 2024
-------------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion pyomo/contrib/pyros/pyros.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
)


__version__ = "1.3.0"
__version__ = "1.3.1"


default_pyros_solver_logger = setup_pyros_logger()
Expand Down
86 changes: 86 additions & 0 deletions pyomo/contrib/pyros/tests/test_uncertainty_sets.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
attempt_import,
numpy as np,
numpy_available,
scipy as sp,
scipy_available,
)
from pyomo.environ import SolverFactory
Expand Down Expand Up @@ -1863,6 +1864,13 @@ def test_normal_construction_and_update(self):
np.testing.assert_allclose(
scale, eset.scale, err_msg="EllipsoidalSet scale not as expected"
)
np.testing.assert_allclose(
# evaluate chisquare CDF for 2 degrees of freedom
# using simplified formula
1 - np.exp(-scale / 2),
eset.gaussian_conf_lvl,
err_msg="EllipsoidalSet Gaussian confidence level not as expected",
)

# check attributes update
new_center = [-1, -3]
Expand All @@ -1886,6 +1894,42 @@ def test_normal_construction_and_update(self):
np.testing.assert_allclose(
new_scale, eset.scale, err_msg="EllipsoidalSet scale update not as expected"
)
np.testing.assert_allclose(
# evaluate chisquare CDF for 2 degrees of freedom
# using simplified formula
1 - np.exp(-new_scale / 2),
eset.gaussian_conf_lvl,
err_msg="EllipsoidalSet Gaussian confidence level update not as expected",
)

def test_normal_construction_and_update_gaussian_conf_lvl(self):
"""
Test EllipsoidalSet constructor and setter
work normally when arguments are appropriate.
"""
init_conf_lvl = 0.95
eset = EllipsoidalSet(
center=[0, 0, 0],
shape_matrix=np.eye(3),
scale=None,
gaussian_conf_lvl=init_conf_lvl,
)

self.assertEqual(eset.gaussian_conf_lvl, init_conf_lvl)
np.testing.assert_allclose(
sp.stats.chi2.isf(q=1 - init_conf_lvl, df=eset.dim),
eset.scale,
err_msg="EllipsoidalSet scale not as expected",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: I won't have this hold up the PR, but it might be worth making this message into its own class-level var so you don't have to potentially change the error message in a bunch of places if you decide to modify the language or the error message someday.

)

new_conf_lvl = 0.99
eset.gaussian_conf_lvl = new_conf_lvl
self.assertEqual(eset.gaussian_conf_lvl, new_conf_lvl)
np.testing.assert_allclose(
sp.stats.chi2.isf(q=1 - new_conf_lvl, df=eset.dim),
eset.scale,
err_msg="EllipsoidalSet scale not as expected",
)

def test_error_on_ellipsoidal_dim_change(self):
"""
Expand Down Expand Up @@ -1926,6 +1970,48 @@ def test_error_on_neg_scale(self):
with self.assertRaisesRegex(ValueError, exc_str):
eset.scale = neg_scale

def test_error_invalid_gaussian_conf_lvl(self):
"""
Test error when attempting to initialize with Gaussian
confidence level outside range.
"""
center = [0, 0]
shape_matrix = [[1, 0], [0, 2]]
invalid_conf_lvl = 1.001

exc_str = r"Ensure the confidence level is a value in \[0, 1\)."

# error on construction
with self.assertRaisesRegex(ValueError, exc_str):
EllipsoidalSet(
center=center,
shape_matrix=shape_matrix,
scale=None,
gaussian_conf_lvl=invalid_conf_lvl,
)

# error on updating valid ellipsoid
eset = EllipsoidalSet(center, shape_matrix, scale=None, gaussian_conf_lvl=0.95)
with self.assertRaisesRegex(ValueError, exc_str):
eset.gaussian_conf_lvl = invalid_conf_lvl

# negative confidence level
eset = EllipsoidalSet(center, shape_matrix, scale=None, gaussian_conf_lvl=0.95)
with self.assertRaisesRegex(ValueError, exc_str):
eset.gaussian_conf_lvl = -0.1

def test_error_scale_gaussian_conf_lvl_construction(self):
"""
Test exception raised if neither or both of
`scale` and `gaussian_conf_lvl` are None.
"""
exc_str = r"Exactly one of `scale` and `gaussian_conf_lvl` should be None"
with self.assertRaisesRegex(ValueError, exc_str):
EllipsoidalSet([0], [[1]], scale=None, gaussian_conf_lvl=None)

with self.assertRaisesRegex(ValueError, exc_str):
EllipsoidalSet([0], [[1]], scale=1, gaussian_conf_lvl=0.95)

def test_error_on_shape_matrix_with_wrong_size(self):
"""
Test error in event EllipsoidalSet shape matrix
Expand Down
87 changes: 75 additions & 12 deletions pyomo/contrib/pyros/uncertainty_sets.py
Original file line number Diff line number Diff line change
Expand Up @@ -2374,31 +2374,37 @@ class EllipsoidalSet(UncertaintySet):
center : (N,) array-like
Center of the ellipsoid.
shape_matrix : (N, N) array-like
A positive definite matrix characterizing the shape
and orientation of the ellipsoid.
A symmetric positive definite matrix characterizing
the shape and orientation of the ellipsoid.
scale : numeric type, optional
Square of the factor by which to scale the semi-axes
of the ellipsoid (i.e. the eigenvectors of the shape
matrix). The default is `1`.
gaussian_conf_lvl : numeric type, optional
(Fractional) confidence level of the multivariate
normal distribution with mean `center` and covariance
matrix `shape_matrix`.
Exactly one of `scale` and `gaussian_conf_lvl` should be
None; otherwise, an exception is raised.

Examples
--------
3D origin-centered unit hypersphere:
A 3D origin-centered unit ball:

>>> from pyomo.contrib.pyros import EllipsoidalSet
>>> import numpy as np
>>> hypersphere = EllipsoidalSet(
>>> ball = EllipsoidalSet(
... center=[0, 0, 0],
... shape_matrix=np.eye(3),
... scale=1,
... )
>>> hypersphere.center
>>> ball.center
array([0, 0, 0])
>>> hypersphere.shape_matrix
>>> ball.shape_matrix
array([[1., 0., 0.],
[0., 1., 0.],
[0., 0., 1.]])
>>> hypersphere.scale
>>> ball.scale
1

A 2D ellipsoid with custom rotation and scaling:
Expand All @@ -2416,13 +2422,42 @@ class EllipsoidalSet(UncertaintySet):
>>> rotated_ellipsoid.scale
0.5

A 4D 95% confidence ellipsoid:

>>> conf_ellipsoid = EllipsoidalSet(
... center=np.zeros(4),
... shape_matrix=np.diag(range(1, 5)),
... scale=None,
... gaussian_conf_lvl=0.95,
... )
>>> conf_ellipsoid.center
array([0, 0, 0, 0])
>>> conf_ellipsoid.shape_matrix
array([[1, 0, 0, 0]],
[0, 2, 0, 0]],
[0, 0, 3, 0]],
[0, 0, 0. 4]])
>>> conf_ellipsoid.scale
...9.4877...
>>> conf_ellipsoid.gaussian_conf_lvl
0.95

"""

def __init__(self, center, shape_matrix, scale=1):
def __init__(self, center, shape_matrix, scale=1, gaussian_conf_lvl=None):
"""Initialize self (see class docstring)."""
self.center = center
self.shape_matrix = shape_matrix
self.scale = scale

if scale is not None and gaussian_conf_lvl is None:
self.scale = scale
elif scale is None and gaussian_conf_lvl is not None:
self.gaussian_conf_lvl = gaussian_conf_lvl
else:
raise ValueError(
"Exactly one of `scale` and `gaussian_conf_lvl` should be "
f"None (got {scale=}, {gaussian_conf_lvl=})"
)
Comment on lines +2452 to +2460
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need to change this, but would it be more clear to have this be:

if (scale is None) ^ (gaussian_conf_lvl is None):
    self.scale = scale
    self.gaussian_conf_lvl = gaussian_conf_lvl
else:
    raise ValueError(
        "Exactly one of `scale` and `gaussian_conf_lvl` should be "
        f"None (got {scale=}, {gaussian_conf_lvl=})"
    )

And then have the setters just "do nothing" if the incoming value is None?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree this would clarify the code, but are we sure we want each of those setters to leave the state unchanged if None is passed (without at least noting this behavior in the property docstrings)? Currently, each of the other EllipsoidalSet attribute setters (and more broadly, attribute setters for other concrete UncertaintySet subclasses) raises an exception if None is passed and not an acceptable value/type/shape for the attribute. I am wondering whether this new behavior for scale and gaussian_conf_lvl might not be clear to users attempting to set either attribute to None post construction.


@property
def type(self):
Expand Down Expand Up @@ -2456,7 +2491,7 @@ def center(self, val):
if val_arr.size != self.dim:
raise ValueError(
"Attempting to set attribute 'center' of "
f"AxisAlignedEllipsoidalSet of dimension {self.dim} "
f"{type(self).__name__} of dimension {self.dim} "
f"to value of dimension {val_arr.size}"
)

Expand Down Expand Up @@ -2535,7 +2570,7 @@ def shape_matrix(self, val):
if hasattr(self, "_center"):
if not all(size == self.dim for size in shape_mat_arr.shape):
raise ValueError(
f"EllipsoidalSet attribute 'shape_matrix' "
f"{type(self).__name__} attribute 'shape_matrix' "
f"must be a square matrix of size "
f"{self.dim} to match set dimension "
f"(provided matrix with shape {shape_mat_arr.shape})"
Expand All @@ -2558,12 +2593,40 @@ def scale(self, val):
validate_arg_type("scale", val, valid_num_types, "a valid numeric type", False)
if val < 0:
raise ValueError(
"EllipsoidalSet attribute "
f"{type(self).__name__} attribute "
f"'scale' must be a non-negative real "
f"(provided value {val})"
)

self._scale = val
self._gaussian_conf_lvl = sp.stats.chi2.cdf(x=val, df=self.dim)

@property
def gaussian_conf_lvl(self):
"""
numeric type : (Fractional) confidence level of the
multivariate Gaussian distribution with mean ``self.origin``
and covariance ``self.shape_matrix`` for ellipsoidal region
with square magnification factor ``self.scale``.
"""
return self._gaussian_conf_lvl

@gaussian_conf_lvl.setter
def gaussian_conf_lvl(self, val):
validate_arg_type(
"gaussian_conf_lvl", val, valid_num_types, "a valid numeric type", False
)

scale_val = sp.stats.chi2.isf(q=1 - val, df=self.dim)
if np.isnan(scale_val) or np.isinf(scale_val):
raise ValueError(
f"Squared scaling factor calculation for confidence level {val} "
f"and set dimension {self.dim} returned {scale_val}. "
"Ensure the confidence level is a value in [0, 1)."
)

self._gaussian_conf_lvl = val
self._scale = scale_val

@property
def dim(self):
Expand Down
Loading