5252
5353import pyomo .environ as pyo
5454from 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
6061from 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" ],
0 commit comments