Skip to content

Commit 6d61e09

Browse files
authored
Merge branch 'main' into implicit_constraint
2 parents a273f6c + 3603b1e commit 6d61e09

12 files changed

Lines changed: 627 additions & 230 deletions

.github/workflows/test_branches.yml

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -320,14 +320,7 @@ jobs:
320320
python -m pip install --cache-dir cache/pip pymysql || \
321321
python -m pip install --cache-dir cache/pip pymysql
322322
if test -z "${{matrix.slim}}"; then
323-
# Disallow cplex 22.1.2.1 because it errors fatally when
324-
# computing IIS on python 3.13 and 3.14
325-
if [[ ${{matrix.python}} =~ 3.1[34] ]]; then
326-
CPLEX='cplex!=22.1.2.1'
327-
else
328-
CPLEX='cplex'
329-
fi
330-
python -m pip install --cache-dir cache/pip "$CPLEX" docplex \
323+
python -m pip install --cache-dir cache/pip cplex docplex \
331324
|| echo "WARNING: CPLEX Community Edition is not available"
332325
python -m pip install --cache-dir cache/pip gurobipy \
333326
|| echo "WARNING: Gurobi is not available"
@@ -410,14 +403,8 @@ jobs:
410403
PYVER=$(echo "py${{matrix.python}}" | sed 's/\.//g')
411404
echo "Installing for $PYVER"
412405
#
413-
# Disallow cplex 22.1.2 and 22.1.2.1, as they error fatally when
414-
# computing IIS on Python 3.13 and 3.14
415406
# Disallow cplex 12.9 (caused segfaults in tests)
416-
if [[ ${{matrix.python}} =~ 3.1[34] ]]; then
417-
CPLEX='cplex>=12.10,<22.1.2|>22.1.2.1'
418-
else
419-
CPLEX='cplex>=12.10'
420-
fi
407+
CPLEX='cplex>=12.10'
421408
# xpress.init() (xpress 9.5.1 from conda) hangs indefinitely
422409
# on GHA/Windows under Python 3.10 and 3.11. Exclude that
423410
# release on that platform.

.github/workflows/test_pr_and_main.yml

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -373,14 +373,7 @@ jobs:
373373
python -m pip install --cache-dir cache/pip pymysql || \
374374
python -m pip install --cache-dir cache/pip pymysql
375375
if test -z "${{matrix.slim}}"; then
376-
# Disallow cplex 22.1.2.1 because it errors fatally when
377-
# computing IIS on python 3.13 and 3.14
378-
if [[ ${{matrix.python}} =~ 3.1[34] ]]; then
379-
CPLEX='cplex!=22.1.2.1'
380-
else
381-
CPLEX='cplex'
382-
fi
383-
python -m pip install --cache-dir cache/pip "$CPLEX" docplex \
376+
python -m pip install --cache-dir cache/pip cplex docplex \
384377
|| echo "WARNING: CPLEX Community Edition is not available"
385378
python -m pip install --cache-dir cache/pip gurobipy \
386379
|| echo "WARNING: Gurobi is not available"
@@ -463,14 +456,8 @@ jobs:
463456
PYVER=$(echo "py${{matrix.python}}" | sed 's/\.//g')
464457
echo "Installing for $PYVER"
465458
#
466-
# Disallow cplex 22.1.2 and 22.1.2.1, as they error fatally when
467-
# computing IIS on Python 3.13 and 3.14
468459
# Disallow cplex 12.9 (caused segfaults in tests)
469-
if [[ ${{matrix.python}} =~ 3.1[34] ]]; then
470-
CPLEX='cplex>=12.10,<22.1.2|>22.1.2.1'
471-
else
472-
CPLEX='cplex>=12.10'
473-
fi
460+
CPLEX='cplex>=12.10'
474461
# xpress.init() (xpress 9.5.1 from conda) hangs indefinitely
475462
# on GHA/Windows under Python 3.10 and 3.11. Exclude that
476463
# release on that platform.

pyomo/contrib/doe/doe.py

Lines changed: 103 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,10 @@
5252

5353
import pyomo.environ as pyo
5454
from pyomo.contrib.doe.utils import (
55+
_SMALL_TOLERANCE_DEFINITENESS,
5556
check_FIM,
5657
compute_FIM_metrics,
57-
_SMALL_TOLERANCE_DEFINITENESS,
58+
regularize_fim_for_cholesky,
5859
)
5960

6061
from pyomo.opt import SolverStatus
@@ -281,7 +282,6 @@ def run_doe(self, model=None, results_file=None):
281282
results_file: string name of the file path to save the results
282283
to in the form of a .json file
283284
default: None --> don't save
284-
285285
"""
286286
# Check results file name
287287
if results_file is not None:
@@ -376,6 +376,11 @@ def run_doe(self, model=None, results_file=None):
376376
if self.objective_option == ObjectiveLib.trace:
377377
trace_val = np.trace(np.linalg.pinv(self.get_FIM()))
378378
model.obj_cons.egb_fim_block.outputs["A-opt"].set_value(trace_val)
379+
elif self.objective_option == ObjectiveLib.pseudo_trace:
380+
pseudo_trace_val = np.trace(np.array(self.get_FIM()))
381+
model.obj_cons.egb_fim_block.outputs["pseudo-A-opt"].set_value(
382+
pseudo_trace_val
383+
)
379384
elif self.objective_option == ObjectiveLib.determinant:
380385
det_val = np.linalg.det(np.array(self.get_FIM()))
381386
model.obj_cons.egb_fim_block.outputs["log-D-opt"].set_value(
@@ -389,63 +394,8 @@ def run_doe(self, model=None, results_file=None):
389394
cond_number = np.log(np.abs(np.max(eig) / np.min(eig)))
390395
model.obj_cons.egb_fim_block.outputs["ME-opt"].set_value(cond_number)
391396

392-
# If the model has L, initialize it with the solved FIM
393-
if hasattr(model, "L"):
394-
# Get the FIM values
395-
fim_vals = [
396-
pyo.value(model.fim[i, j])
397-
for i in model.parameter_names
398-
for j in model.parameter_names
399-
]
400-
fim_np = np.array(fim_vals).reshape(
401-
(len(model.parameter_names), len(model.parameter_names))
402-
)
403-
404-
# Need to compute the full FIM before
405-
# initializing the Cholesky factorization
406-
if self.only_compute_fim_lower:
407-
fim_np = fim_np + fim_np.T - np.diag(np.diag(fim_np))
408-
409-
# Check if the FIM is positive definite
410-
# If not, add jitter to the diagonal
411-
# to ensure positive definiteness
412-
min_eig = np.min(np.linalg.eigvals(fim_np))
413-
414-
if min_eig < _SMALL_TOLERANCE_DEFINITENESS:
415-
# Raise the minimum eigenvalue to at
416-
# least _SMALL_TOLERANCE_DEFINITENESS
417-
jitter = np.min(
418-
[
419-
-min_eig + _SMALL_TOLERANCE_DEFINITENESS,
420-
_SMALL_TOLERANCE_DEFINITENESS,
421-
]
422-
)
423-
else:
424-
# No jitter needed
425-
jitter = 0
426-
427-
# Add jitter to the diagonal to ensure positive definiteness
428-
L_vals_sq = np.linalg.cholesky(
429-
fim_np + jitter * np.eye(len(model.parameter_names))
430-
)
431-
for i, c in enumerate(model.parameter_names):
432-
for j, d in enumerate(model.parameter_names):
433-
model.L[c, d].value = L_vals_sq[i, j]
434-
435-
# Initialize the inverse of L if it exists
436-
if hasattr(model, "L_inv"):
437-
L_inv_vals = np.linalg.inv(L_vals_sq)
438-
439-
for i, c in enumerate(model.parameter_names):
440-
for j, d in enumerate(model.parameter_names):
441-
if i >= j:
442-
model.L_inv[c, d].value = L_inv_vals[i, j]
443-
else:
444-
model.L_inv[c, d].value = 0.0
445-
# Initialize the cov_trace if it exists
446-
if hasattr(model, "cov_trace"):
447-
initial_cov_trace = np.sum(L_inv_vals**2)
448-
model.cov_trace.value = initial_cov_trace
397+
# Keep Cholesky-related variables synchronized with current FIM values
398+
self._initialize_cholesky_from_fim(model=model)
449399

450400
if hasattr(model, "determinant"):
451401
model.determinant.value = np.linalg.det(np.array(self.get_FIM()))
@@ -537,6 +487,92 @@ def run_doe(self, model=None, results_file=None):
537487
with open(results_file, "w") as file:
538488
json.dump(self.results, file)
539489

490+
def _get_fim_numpy(self, model):
491+
"""
492+
Assemble the current FIM variable values into a NumPy array.
493+
494+
Parameters
495+
----------
496+
model: ConcreteModel
497+
DoE model containing variable ``fim``.
498+
499+
Returns
500+
-------
501+
ndarray
502+
Dense FIM array. If ``only_compute_fim_lower`` is True, the
503+
returned array is symmetrized from the lower triangle.
504+
"""
505+
fim_vals = [
506+
pyo.value(model.fim[i, j])
507+
for i in model.parameter_names
508+
for j in model.parameter_names
509+
]
510+
fim_np = np.array(fim_vals, dtype=float).reshape(
511+
(len(model.parameter_names), len(model.parameter_names))
512+
)
513+
if self.only_compute_fim_lower:
514+
fim_np = fim_np + fim_np.T - np.diag(np.diag(fim_np))
515+
return fim_np
516+
517+
def _initialize_cholesky_from_fim(self, model=None):
518+
"""
519+
Synchronize Cholesky-related variables using the current FIM.
520+
521+
Parameters
522+
----------
523+
model: ConcreteModel, optional
524+
DoE model to update. Defaults to ``self.model``.
525+
526+
Returns
527+
-------
528+
None
529+
Updates model values in place for available variables:
530+
``L``, ``L_inv``, ``fim_inv``, and ``cov_trace``.
531+
"""
532+
if model is None:
533+
model = self.model
534+
if not hasattr(model, "L"):
535+
# The model doesn't have the Cholesky variables, so we can't initialize them.
536+
# This happens if the function is called with a model using GreyBox.
537+
return
538+
539+
fim_np = self._get_fim_numpy(model)
540+
fim_pd, _ = regularize_fim_for_cholesky(fim_np)
541+
542+
L_vals = np.linalg.cholesky(fim_pd)
543+
for i, c in enumerate(model.parameter_names):
544+
for j, d in enumerate(model.parameter_names):
545+
if i >= j:
546+
model.L[c, d].value = L_vals[i, j]
547+
else:
548+
model.L[c, d].value = 0.0
549+
550+
if hasattr(model, "L_inv"):
551+
L_inv_vals = np.linalg.inv(L_vals)
552+
for i, c in enumerate(model.parameter_names):
553+
for j, d in enumerate(model.parameter_names):
554+
if i >= j:
555+
model.L_inv[c, d].value = L_inv_vals[i, j]
556+
else:
557+
model.L_inv[c, d].value = 0.0
558+
559+
if hasattr(model, "fim_inv"):
560+
# Use the pseudo-inverse here rather than the strict inverse.
561+
# The jittered matrix should be positive definite, but ``pinv``
562+
# is safer for borderline ill-conditioned cases and matches the
563+
# defensive approach already used when initializing ``fim_inv``
564+
# from user-provided starting values.
565+
fim_inv_vals = np.linalg.pinv(fim_pd)
566+
for i, c in enumerate(model.parameter_names):
567+
for j, d in enumerate(model.parameter_names):
568+
if self.only_compute_fim_lower and i < j:
569+
model.fim_inv[c, d].value = 0.0
570+
else:
571+
model.fim_inv[c, d].value = fim_inv_vals[i, j]
572+
573+
if hasattr(model, "cov_trace"):
574+
model.cov_trace.value = np.trace(fim_inv_vals)
575+
540576
# Perform multi-experiment doe (sequential, or ``greedy`` approach)
541577
def run_multi_doe_sequential(self, N_exp=1):
542578
raise NotImplementedError("Multiple experiment optimization not yet supported.")
@@ -898,6 +934,7 @@ def create_doe_model(self, model=None):
898934
self.only_compute_fim_lower
899935
and self.objective_option == ObjectiveLib.determinant
900936
and not self.Cholesky_option
937+
and not self.use_grey_box
901938
):
902939
raise ValueError(
903940
"Cannot compute determinant with explicit formula "
@@ -1372,29 +1409,14 @@ def create_objective_function(self, model=None):
13721409
model.obj_cons = pyo.Block()
13731410

13741411
# Assemble the FIM matrix. This is helpful for initialization!
1375-
fim_vals = [
1376-
model.fim[bu, un].value
1377-
for i, bu in enumerate(model.parameter_names)
1378-
for j, un in enumerate(model.parameter_names)
1379-
]
1380-
fim = np.array(fim_vals).reshape(
1381-
len(model.parameter_names), len(model.parameter_names)
1382-
)
1412+
fim = self._get_fim_numpy(model)
13831413

13841414
### Initialize the Cholesky decomposition matrix
13851415
if self.Cholesky_option and self.objective_option in (
13861416
ObjectiveLib.determinant,
13871417
ObjectiveLib.trace,
13881418
):
1389-
# Calculate the eigenvalues of the FIM matrix
1390-
eig = np.linalg.eigvals(fim)
1391-
1392-
# If the smallest eigenvalue is (practically) negative,
1393-
# add a diagonal matrix to make it positive definite
1394-
if min(eig) < small_number:
1395-
fim = fim + np.eye(len(model.parameter_names)) * (
1396-
small_number - min(eig)
1397-
)
1419+
fim, _ = regularize_fim_for_cholesky(fim)
13981420

13991421
# Compute the Cholesky decomposition of the FIM matrix
14001422
L = np.linalg.cholesky(fim)
@@ -1663,6 +1685,11 @@ def FIM_egb_cons(m, p1, p2):
16631685
model.objective = pyo.Objective(
16641686
expr=model.obj_cons.egb_fim_block.outputs["A-opt"], sense=pyo.minimize
16651687
)
1688+
elif self.objective_option == ObjectiveLib.pseudo_trace:
1689+
model.objective = pyo.Objective(
1690+
expr=model.obj_cons.egb_fim_block.outputs["pseudo-A-opt"],
1691+
sense=pyo.maximize,
1692+
)
16661693
elif self.objective_option == ObjectiveLib.determinant:
16671694
model.objective = pyo.Objective(
16681695
expr=model.obj_cons.egb_fim_block.outputs["log-D-opt"],

pyomo/contrib/doe/grey_box_utilities.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,11 @@ def __init__(self, doe_object, objective_option="determinant", logger_level=None
108108
def _get_FIM(self):
109109
# Grabs the current FIM subject
110110
# to the input values.
111-
# This function currently assumes
112-
# that we use a lower triangular
113-
# FIM.
111+
# Inputs store one triangular half
112+
# of a symmetric FIM. Reconstruct
113+
# the full symmetric matrix here,
114+
# consistent with manuscript equation S5.
115+
# https://arxiv.org/abs/2604.03354v1
114116
upt_FIM = self._input_values
115117

116118
# Create FIM in the correct way
@@ -164,6 +166,8 @@ def output_names(self):
164166

165167
if self.objective_option == ObjectiveLib.trace:
166168
obj_name = "A-opt"
169+
elif self.objective_option == ObjectiveLib.pseudo_trace:
170+
obj_name = "pseudo-A-opt"
167171
elif self.objective_option == ObjectiveLib.determinant:
168172
obj_name = "log-D-opt"
169173
elif self.objective_option == ObjectiveLib.minimum_eigenvalue:
@@ -196,6 +200,8 @@ def evaluate_outputs(self):
196200

197201
if self.objective_option == ObjectiveLib.trace:
198202
obj_value = np.trace(np.linalg.pinv(M))
203+
elif self.objective_option == ObjectiveLib.pseudo_trace:
204+
obj_value = np.trace(M)
199205
elif self.objective_option == ObjectiveLib.determinant:
200206
sign, logdet = np.linalg.slogdet(M)
201207
obj_value = logdet
@@ -231,6 +237,8 @@ def finalize_block_construction(self, pyomo_block):
231237
# objective function.
232238
if self.objective_option == ObjectiveLib.trace:
233239
pyomo_block.outputs["A-opt"] = output_value
240+
elif self.objective_option == ObjectiveLib.pseudo_trace:
241+
pyomo_block.outputs["pseudo-A-opt"] = output_value
234242
elif self.objective_option == ObjectiveLib.determinant:
235243
pyomo_block.outputs["log-D-opt"] = output_value
236244
elif self.objective_option == ObjectiveLib.minimum_eigenvalue:
@@ -268,6 +276,8 @@ def evaluate_jacobian_outputs(self):
268276
# is -inv(FIM) @ inv(FIM). Add reference to
269277
# pyomo.DoE 2.0 manuscript S.I.
270278
jac_M = -Minv @ Minv
279+
elif self.objective_option == ObjectiveLib.pseudo_trace:
280+
jac_M = np.eye(self._n_params, dtype=np.float64)
271281
elif self.objective_option == ObjectiveLib.determinant:
272282
Minv = np.linalg.pinv(M)
273283
# Derivative formula derived using tensor

pyomo/contrib/doe/tests/test_doe_build.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,6 @@
2525

2626
if scipy_available:
2727
from pyomo.contrib.doe import DesignOfExperiments
28-
from pyomo.contrib.doe.examples.reactor_example import (
29-
ReactorExperiment as FullReactorExperiment,
30-
)
3128
from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import (
3229
RooneyBieglerExperiment,
3330
)
@@ -98,10 +95,6 @@ def get_FIM_FIMPrior_Q_L(doe_obj=None):
9895
for j in model.parameter_names
9996
]
10097
sigma_inv = [1 / v for k, v in model.scenario_blocks[0].measurement_error.items()]
101-
param_vals = np.array(
102-
[[v for k, v in model.scenario_blocks[0].unknown_parameters.items()]]
103-
)
104-
10598
FIM_vals_np = np.array(FIM_vals).reshape((n_param, n_param))
10699
FIM_prior_vals_np = np.array(FIM_prior_vals).reshape((n_param, n_param))
107100

0 commit comments

Comments
 (0)