Skip to content

Commit ae863b6

Browse files
authored
Merge pull request #3532 from adowling2/pyomo-doe-fix-initialization
Pyomo.DoE: Corrected initialization when using only lower diagonal of FIM
2 parents 0b58294 + 0fa9c2b commit ae863b6

File tree

3 files changed

+140
-2
lines changed

3 files changed

+140
-2
lines changed

pyomo/contrib/doe/doe.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,19 @@
4949

5050
from pyomo.opt import SolverStatus
5151

52+
# This small and positive tolerance is used when checking
53+
# if the prior is negative definite or approximately
54+
# indefinite. It is defined as a tolerance here to ensure
55+
# consistency between the code below and the tests. The
56+
# user should not need to adjust it.
57+
_SMALL_TOLERANCE_DEFINITENESS = 1e-6
58+
59+
# This small and positive tolerance is used to check
60+
# the FIM is approximately symmetric. It is defined as
61+
# a tolerance here to ensure consistency between the code
62+
# below and the tests. The user should not need to adjust it.
63+
_SMALL_TOLERANCE_SYMMETRY = 1e-6
64+
5265

5366
class ObjectiveLib(Enum):
5467
determinant = "determinant"
@@ -309,7 +322,31 @@ def run_doe(self, model=None, results_file=None):
309322
(len(model.parameter_names), len(model.parameter_names))
310323
)
311324

312-
L_vals_sq = np.linalg.cholesky(fim_np)
325+
# Need to compute the full FIM before initializing the Cholesky factorization
326+
if self.only_compute_fim_lower:
327+
fim_np = fim_np + fim_np.T - np.diag(np.diag(fim_np))
328+
329+
# Check if the FIM is positive definite
330+
# If not, add jitter to the diagonal
331+
# to ensure positive definiteness
332+
min_eig = np.min(np.linalg.eigvals(fim_np))
333+
334+
if min_eig < _SMALL_TOLERANCE_DEFINITENESS:
335+
# Raise the minimum eigenvalue to at least _SMALL_TOLERANCE_DEFINITENESS
336+
jitter = np.min(
337+
[
338+
-min_eig + _SMALL_TOLERANCE_DEFINITENESS,
339+
_SMALL_TOLERANCE_DEFINITENESS,
340+
]
341+
)
342+
else:
343+
# No jitter needed
344+
jitter = 0
345+
346+
# Add jitter to the diagonal to ensure positive definiteness
347+
L_vals_sq = np.linalg.cholesky(
348+
fim_np + jitter * np.eye(len(model.parameter_names))
349+
)
313350
for i, c in enumerate(model.parameter_names):
314351
for j, d in enumerate(model.parameter_names):
315352
model.L[c, d].value = L_vals_sq[i, j]
@@ -1338,7 +1375,28 @@ def check_model_FIM(self, model=None, FIM=None):
13381375
)
13391376
)
13401377

1341-
self.logger.info("FIM provided matches expected dimensions from model.")
1378+
# Compute the eigenvalues of the FIM
1379+
evals = np.linalg.eigvals(FIM)
1380+
1381+
# Check if the FIM is positive definite
1382+
if np.min(evals) < -_SMALL_TOLERANCE_DEFINITENESS:
1383+
raise ValueError(
1384+
"FIM provided is not positive definite. It has one or more negative eigenvalue(s) less than -{:.1e}".format(
1385+
_SMALL_TOLERANCE_DEFINITENESS
1386+
)
1387+
)
1388+
1389+
# Check if the FIM is symmetric
1390+
if not np.allclose(FIM, FIM.T, atol=_SMALL_TOLERANCE_SYMMETRY):
1391+
raise ValueError(
1392+
"FIM provided is not symmetric using absolute tolerance {}".format(
1393+
_SMALL_TOLERANCE_SYMMETRY
1394+
)
1395+
)
1396+
1397+
self.logger.info(
1398+
"FIM provided matches expected dimensions from model and is approximately positive (semi) definite."
1399+
)
13421400

13431401
# Check the jacobian shape against what is expected from the model.
13441402
def check_model_jac(self, jac=None):

pyomo/contrib/doe/tests/test_doe_errors.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,55 @@ def test_reactor_check_bad_prior_size(self):
165165
):
166166
doe_obj.create_doe_model()
167167

168+
def test_reactor_check_bad_prior_negative_eigenvalue(self):
169+
from pyomo.contrib.doe.doe import _SMALL_TOLERANCE_DEFINITENESS
170+
171+
fd_method = "central"
172+
obj_used = "trace"
173+
flag_val = 0 # Value for faulty model build mode - 0: full model
174+
175+
prior_FIM = -np.ones((4, 4))
176+
177+
experiment = FullReactorExperiment(data_ex, 10, 3)
178+
179+
DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val)
180+
DoE_args['prior_FIM'] = prior_FIM
181+
182+
doe_obj = DesignOfExperiments(**DoE_args)
183+
184+
with self.assertRaisesRegex(
185+
ValueError,
186+
r"FIM provided is not positive definite. It has one or more negative eigenvalue\(s\) less than -{:.1e}".format(
187+
_SMALL_TOLERANCE_DEFINITENESS
188+
),
189+
):
190+
doe_obj.create_doe_model()
191+
192+
def test_reactor_check_bad_prior_not_symmetric(self):
193+
from pyomo.contrib.doe.doe import _SMALL_TOLERANCE_SYMMETRY
194+
195+
fd_method = "central"
196+
obj_used = "trace"
197+
flag_val = 0 # Value for faulty model build mode - 0: full model
198+
199+
prior_FIM = np.zeros((4, 4))
200+
prior_FIM[0, 1] = 1e-3
201+
202+
experiment = FullReactorExperiment(data_ex, 10, 3)
203+
204+
DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val)
205+
DoE_args['prior_FIM'] = prior_FIM
206+
207+
doe_obj = DesignOfExperiments(**DoE_args)
208+
209+
with self.assertRaisesRegex(
210+
ValueError,
211+
"FIM provided is not symmetric using absolute tolerance {}".format(
212+
_SMALL_TOLERANCE_SYMMETRY
213+
),
214+
):
215+
doe_obj.create_doe_model()
216+
168217
def test_reactor_check_bad_jacobian_init_size(self):
169218
fd_method = "central"
170219
obj_used = "trace"

pyomo/contrib/doe/tests/test_doe_solve.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,37 @@ def test_reactor_obj_cholesky_solve(self):
230230
# Make sure FIM and Q.T @ sigma_inv @ Q are close (alternate definition of FIM)
231231
self.assertTrue(np.all(np.isclose(FIM, Q.T @ sigma_inv @ Q)))
232232

233+
def test_reactor_obj_cholesky_solve_bad_prior(self):
234+
235+
from pyomo.contrib.doe.doe import _SMALL_TOLERANCE_DEFINITENESS
236+
237+
fd_method = "central"
238+
obj_used = "determinant"
239+
240+
experiment = FullReactorExperiment(data_ex, 10, 3)
241+
242+
DoE_args = get_standard_args(experiment, fd_method, obj_used)
243+
244+
# Specify a prior that is slightly negative definite
245+
# Because it is less than the tolerance, it should be adjusted to be positive definite
246+
# No error should be thrown
247+
DoE_args['prior_FIM'] = -(_SMALL_TOLERANCE_DEFINITENESS / 100) * np.eye(4)
248+
249+
doe_obj = DesignOfExperiments(**DoE_args)
250+
251+
doe_obj.run_doe()
252+
253+
self.assertEqual(doe_obj.results["Solver Status"], "ok")
254+
255+
# assert that Q, F, and L are the same.
256+
FIM, Q, L, sigma_inv = get_FIM_Q_L(doe_obj=doe_obj)
257+
258+
# Since Cholesky is used, there is comparison for FIM and L.T @ L
259+
self.assertTrue(np.all(np.isclose(FIM, L @ L.T)))
260+
261+
# Make sure FIM and Q.T @ sigma_inv @ Q are close (alternate definition of FIM)
262+
self.assertTrue(np.all(np.isclose(FIM, Q.T @ sigma_inv @ Q)))
263+
233264
# This test ensure that compute FIM runs without error using the
234265
# `sequential` option with central finite differences
235266
def test_compute_FIM_seq_centr(self):

0 commit comments

Comments
 (0)