Skip to content

Commit dc1bfc4

Browse files
committed
Merge branch 'main' into add-multiexperiment
2 parents 8edc6fa + 7fa8324 commit dc1bfc4

8 files changed

Lines changed: 649 additions & 142 deletions

File tree

pyomo/contrib/doe/doe.py

Lines changed: 131 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,10 @@
4848

4949
import pyomo.environ as pyo
5050
from pyomo.contrib.doe.utils import (
51+
_SMALL_TOLERANCE_DEFINITENESS,
5152
check_FIM,
5253
compute_FIM_metrics,
53-
_SMALL_TOLERANCE_DEFINITENESS,
54+
regularize_fim_for_cholesky,
5455
)
5556
from pyomo.contrib.parmest.utils.model_utils import update_model_from_suffix
5657

@@ -380,7 +381,6 @@ def run_doe(self, model=None, results_file=None):
380381
results_file: string name of the file path to save the results
381382
to in the form of a .json file
382383
default: None --> don't save
383-
384384
"""
385385
# Check results file name
386386
if results_file is not None:
@@ -461,11 +461,31 @@ def run_doe(self, model=None, results_file=None):
461461
model.obj_cons.activate()
462462

463463
if self.use_grey_box:
464-
self._initialize_grey_box_block(
465-
model.obj_cons.egb_fim_block,
466-
np.asarray(self.get_FIM(model=model), dtype=np.float64),
467-
model.parameter_names,
468-
)
464+
# Initialize grey box inputs to be fim values currently
465+
for i in model.parameter_names:
466+
for j in model.parameter_names:
467+
if list(model.parameter_names).index(i) >= list(
468+
model.parameter_names
469+
).index(j):
470+
model.obj_cons.egb_fim_block.inputs[(j, i)].set_value(
471+
pyo.value(model.fim[(i, j)])
472+
)
473+
# Set objective value
474+
if self.objective_option == ObjectiveLib.trace:
475+
trace_val = np.trace(np.linalg.pinv(self.get_FIM()))
476+
model.obj_cons.egb_fim_block.outputs["A-opt"].set_value(trace_val)
477+
elif self.objective_option == ObjectiveLib.determinant:
478+
det_val = np.linalg.det(np.array(self.get_FIM()))
479+
model.obj_cons.egb_fim_block.outputs["log-D-opt"].set_value(
480+
np.log(det_val)
481+
)
482+
elif self.objective_option == ObjectiveLib.minimum_eigenvalue:
483+
eig, _ = np.linalg.eig(np.array(self.get_FIM()))
484+
model.obj_cons.egb_fim_block.outputs["E-opt"].set_value(np.min(eig))
485+
elif self.objective_option == ObjectiveLib.condition_number:
486+
eig, _ = np.linalg.eig(np.array(self.get_FIM()))
487+
cond_number = np.log(np.abs(np.max(eig) / np.min(eig)))
488+
model.obj_cons.egb_fim_block.outputs["ME-opt"].set_value(cond_number)
469489

470490
# If the model has L, initialize it with the solved FIM
471491
if hasattr(model, "L"):
@@ -487,7 +507,7 @@ def run_doe(self, model=None, results_file=None):
487507
# Check if the FIM is positive definite
488508
# If not, add jitter to the diagonal
489509
# to ensure positive definiteness
490-
min_eig = np.min(np.real(np.linalg.eigvals(fim_np)))
510+
min_eig = np.min(np.linalg.eigvals(fim_np))
491511

492512
if min_eig < _SMALL_TOLERANCE_DEFINITENESS:
493513
# Raise the minimum eigenvalue to at
@@ -619,6 +639,92 @@ def run_doe(self, model=None, results_file=None):
619639
with open(results_file, "w") as file:
620640
json.dump(self.results, file)
621641

642+
def _get_fim_numpy(self, model):
643+
"""
644+
Assemble the current FIM variable values into a NumPy array.
645+
646+
Parameters
647+
----------
648+
model: ConcreteModel
649+
DoE model containing variable ``fim``.
650+
651+
Returns
652+
-------
653+
ndarray
654+
Dense FIM array. If ``only_compute_fim_lower`` is True, the
655+
returned array is symmetrized from the lower triangle.
656+
"""
657+
fim_vals = [
658+
pyo.value(model.fim[i, j])
659+
for i in model.parameter_names
660+
for j in model.parameter_names
661+
]
662+
fim_np = np.array(fim_vals, dtype=float).reshape(
663+
(len(model.parameter_names), len(model.parameter_names))
664+
)
665+
if self.only_compute_fim_lower:
666+
fim_np = fim_np + fim_np.T - np.diag(np.diag(fim_np))
667+
return fim_np
668+
669+
def _initialize_cholesky_from_fim(self, model=None):
670+
"""
671+
Synchronize Cholesky-related variables using the current FIM.
672+
673+
Parameters
674+
----------
675+
model: ConcreteModel, optional
676+
DoE model to update. Defaults to ``self.model``.
677+
678+
Returns
679+
-------
680+
None
681+
Updates model values in place for available variables:
682+
``L``, ``L_inv``, ``fim_inv``, and ``cov_trace``.
683+
"""
684+
if model is None:
685+
model = self.model
686+
if not hasattr(model, "L"):
687+
# The model doesn't have the Cholesky variables, so we can't initialize them.
688+
# This happens if the function is called with a model using GreyBox.
689+
return
690+
691+
fim_np = self._get_fim_numpy(model)
692+
fim_pd, _ = regularize_fim_for_cholesky(fim_np)
693+
694+
L_vals = np.linalg.cholesky(fim_pd)
695+
for i, c in enumerate(model.parameter_names):
696+
for j, d in enumerate(model.parameter_names):
697+
if i >= j:
698+
model.L[c, d].value = L_vals[i, j]
699+
else:
700+
model.L[c, d].value = 0.0
701+
702+
if hasattr(model, "L_inv"):
703+
L_inv_vals = np.linalg.inv(L_vals)
704+
for i, c in enumerate(model.parameter_names):
705+
for j, d in enumerate(model.parameter_names):
706+
if i >= j:
707+
model.L_inv[c, d].value = L_inv_vals[i, j]
708+
else:
709+
model.L_inv[c, d].value = 0.0
710+
711+
if hasattr(model, "fim_inv"):
712+
# Use the pseudo-inverse here rather than the strict inverse.
713+
# The jittered matrix should be positive definite, but ``pinv``
714+
# is safer for borderline ill-conditioned cases and matches the
715+
# defensive approach already used when initializing ``fim_inv``
716+
# from user-provided starting values.
717+
fim_inv_vals = np.linalg.pinv(fim_pd)
718+
for i, c in enumerate(model.parameter_names):
719+
for j, d in enumerate(model.parameter_names):
720+
if self.only_compute_fim_lower and i < j:
721+
model.fim_inv[c, d].value = 0.0
722+
else:
723+
model.fim_inv[c, d].value = fim_inv_vals[i, j]
724+
725+
if hasattr(model, "cov_trace"):
726+
model.cov_trace.value = np.trace(fim_inv_vals)
727+
622728
def optimize_experiments(
623729
self,
624730
results_file=None,
@@ -2297,6 +2403,7 @@ def create_doe_model(
22972403
self.only_compute_fim_lower
22982404
and self.objective_option == ObjectiveLib.determinant
22992405
and not self.Cholesky_option
2406+
and not self.use_grey_box
23002407
):
23012408
raise ValueError(
23022409
"Cannot compute determinant with explicit formula "
@@ -2811,30 +2918,14 @@ def create_objective_function(self, model=None):
28112918
model.obj_cons = pyo.Block()
28122919

28132920
# Assemble the FIM matrix. This is helpful for initialization!
2814-
# collect current FIM values in row-major order
2815-
fim_vals = [
2816-
model.fim[bu, un].value
2817-
for bu in model.parameter_names
2818-
for un in model.parameter_names
2819-
]
2820-
fim = np.array(fim_vals).reshape(
2821-
len(model.parameter_names), len(model.parameter_names)
2822-
)
2921+
fim = self._get_fim_numpy(model)
28232922

28242923
### Initialize the Cholesky decomposition matrix
28252924
if self.Cholesky_option and self.objective_option in (
28262925
ObjectiveLib.determinant,
28272926
ObjectiveLib.trace,
28282927
):
2829-
# Calculate the eigenvalues of the FIM matrix
2830-
eig = np.linalg.eigvals(fim)
2831-
2832-
# If the smallest eigenvalue is (practically) negative,
2833-
# add a diagonal matrix to make it positive definite
2834-
if min(eig) < small_number:
2835-
fim = fim + np.eye(len(model.parameter_names)) * (
2836-
small_number - min(eig)
2837-
)
2928+
fim, _ = regularize_fim_for_cholesky(fim)
28382929

28392930
# Compute the Cholesky decomposition of the FIM matrix
28402931
L = np.linalg.cholesky(fim)
@@ -3466,6 +3557,20 @@ def FIM_egb_cons(m, p1, p2):
34663557
)
34673558

34683559
if build_objective:
3560+
if self.objective_option == ObjectiveLib.trace:
3561+
output_name = "A-opt"
3562+
elif self.objective_option == ObjectiveLib.pseudo_trace:
3563+
output_name = "pseudo-A-opt"
3564+
elif self.objective_option == ObjectiveLib.determinant:
3565+
output_name = "log-D-opt"
3566+
elif self.objective_option == ObjectiveLib.minimum_eigenvalue:
3567+
output_name = "E-opt"
3568+
elif self.objective_option == ObjectiveLib.condition_number:
3569+
output_name = "ME-opt"
3570+
else:
3571+
# Error path is intentionally deferred to the external model.
3572+
output_name = "A-opt"
3573+
34693574
model.objective = pyo.Objective(
34703575
expr=model.obj_cons.egb_fim_block.outputs[output_name],
34713576
sense=(

pyomo/contrib/doe/grey_box_utilities.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,11 @@ def __init__(
139139
def _get_FIM(self):
140140
# Grabs the current FIM subject
141141
# to the input values.
142-
# This function currently assumes
143-
# that we use a lower triangular
144-
# FIM.
142+
# Inputs store one triangular half
143+
# of a symmetric FIM. Reconstruct
144+
# the full symmetric matrix here,
145+
# consistent with manuscript equation S5.
146+
# https://arxiv.org/abs/2604.03354v1
145147
upt_FIM = self._input_values
146148

147149
# Create FIM in the correct way
@@ -195,6 +197,8 @@ def output_names(self):
195197

196198
if self.objective_option == ObjectiveLib.trace:
197199
obj_name = "A-opt"
200+
elif self.objective_option == ObjectiveLib.pseudo_trace:
201+
obj_name = "pseudo-A-opt"
198202
elif self.objective_option == ObjectiveLib.determinant:
199203
obj_name = "log-D-opt"
200204
elif self.objective_option == ObjectiveLib.minimum_eigenvalue:
@@ -227,6 +231,8 @@ def evaluate_outputs(self):
227231

228232
if self.objective_option == ObjectiveLib.trace:
229233
obj_value = np.trace(np.linalg.pinv(M))
234+
elif self.objective_option == ObjectiveLib.pseudo_trace:
235+
obj_value = np.trace(M)
230236
elif self.objective_option == ObjectiveLib.determinant:
231237
sign, logdet = np.linalg.slogdet(M)
232238
obj_value = logdet
@@ -262,6 +268,8 @@ def finalize_block_construction(self, pyomo_block):
262268
# objective function.
263269
if self.objective_option == ObjectiveLib.trace:
264270
pyomo_block.outputs["A-opt"] = output_value
271+
elif self.objective_option == ObjectiveLib.pseudo_trace:
272+
pyomo_block.outputs["pseudo-A-opt"] = output_value
265273
elif self.objective_option == ObjectiveLib.determinant:
266274
pyomo_block.outputs["log-D-opt"] = output_value
267275
elif self.objective_option == ObjectiveLib.minimum_eigenvalue:
@@ -299,6 +307,8 @@ def evaluate_jacobian_outputs(self):
299307
# is -inv(FIM) @ inv(FIM). Add reference to
300308
# pyomo.DoE 2.0 manuscript S.I.
301309
jac_M = -Minv @ Minv
310+
elif self.objective_option == ObjectiveLib.pseudo_trace:
311+
jac_M = np.eye(self._n_params, dtype=np.float64)
302312
elif self.objective_option == ObjectiveLib.determinant:
303313
Minv = np.linalg.pinv(M)
304314
# Derivative formula derived using tensor

pyomo/contrib/doe/tests/test_doe_errors.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,6 @@
4646
RooneyBieglerMultiExperiment,
4747
RooneyBieglerMultiInputExperimentFlag,
4848
)
49-
from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import (
50-
RooneyBieglerExperiment,
51-
)
5249

5350
from pyomo.contrib.doe.examples.rooney_biegler_doe_example import run_rooney_biegler_doe
5451
import pyomo.environ as pyo
@@ -122,7 +119,7 @@ def test_experiment_none_error(self):
122119
# Experiment provided as None
123120
DoE_args = get_standard_args(None, fd_method, obj_used, flag_val)
124121

125-
doe_obj = DesignOfExperiments(**DoE_args)
122+
DesignOfExperiments(**DoE_args)
126123

127124
def test_experiment_empty_list_error(self):
128125
with self.assertRaisesRegex(
@@ -147,7 +144,7 @@ def test_reactor_check_no_get_labeled_model(self):
147144
):
148145
DoE_args = get_standard_args(experiment, fd_method, obj_used, flag_val)
149146

150-
doe_obj = DesignOfExperiments(**DoE_args)
147+
DesignOfExperiments(**DoE_args)
151148

152149
def test_reactor_check_no_experiment_outputs(self):
153150
fd_method = "central"
@@ -1270,6 +1267,25 @@ def test_lhs_missing_bounds_error_message(self):
12701267
init_method="latin_hypercube_sampling", init_n_samples=2
12711268
)
12721269

1270+
def test_invalid_determinant_without_cholesky(self):
1271+
fd_method = "central"
1272+
obj_used = "determinant"
1273+
1274+
experiment = get_rooney_biegler_experiment_flag()
1275+
1276+
DoE_args = get_standard_args(experiment, fd_method, obj_used, flag=None)
1277+
DoE_args["_Cholesky_option"] = False
1278+
1279+
doe_obj = DesignOfExperiments(**DoE_args)
1280+
1281+
# The explicit determinant formulation needs the full FIM, so we keep
1282+
# this regression test focused on the early validation guard.
1283+
with self.assertRaisesRegex(
1284+
ValueError,
1285+
"Cannot compute determinant with explicit formula if only_compute_fim_lower is True.",
1286+
):
1287+
doe_obj.create_doe_model()
1288+
12731289

12741290
if __name__ == "__main__":
12751291
unittest.main()

0 commit comments

Comments
 (0)