Pyomo.DoE: fix trace/Cholesky initialization consistency#3867
Pyomo.DoE: fix trace/Cholesky initialization consistency#3867adowling2 wants to merge 26 commits into
Conversation
|
@smondal13 @sscini @slilonfe5 @snarasi2 @djlaky I found a small bug in how we initialize the Cholesky factorization when the FIM at the initial point needs "inertia correction". This PR fixes the initialization inconsistency. This PR also proposes an interface to allow for more advanced solver options when diagnosing initialization issues for the Pyomo.DoE (dynamic) optimization formulation. Edit: This is a work in progress right now. I am not 100% happy with the proposed changes. |
|
@sscini @blnicho @mrmundt @smondal13 @slilonfe5 This is ready for review. This PR is now narrowly targeted at fixing a Pyomo.DoE FIM initialization issue. |
|
Test failures appear to be unrelated to this PR: GAMS solvers (Windows) and NEOS solver (all) |
These failures are global. The Pyomo team mentioned that they are working on resolving the issues. |
| model.fim_inv[c, d].value = fim_inv_vals[i, j] | ||
|
|
||
| if hasattr(model, "cov_trace"): | ||
| fim_inv_np = np.linalg.inv(fim_pd) |
There was a problem hiding this comment.
Since you are making the fim PD by adding the jitter, I believe the fim_pd will probably be non-singular. But if it is singular for some edge cases, then isn't it better to use the pseudo-inverse here just to be safe?
There was a problem hiding this comment.
I like this suggestion. I am going to switch to pinv for most of the initialization code.
|
|
||
|
|
||
| @unittest.skipIf( | ||
| not (numpy_available and scipy_available), |
There was a problem hiding this comment.
I think scipy is not required for the following tests. So, it may unnecessarily skip tests. Only greybox and multi-experiment initialization with LHS require scipy as far as I can remember.
…/pyomo into fix-doe-initialization
| solver = _SolverData() | ||
|
|
||
|
|
||
| class _MutatingRecordingSolver: |
There was a problem hiding this comment.
Are there any tests in any of the other files that exercise initialization using a real solver? (I do see changes in other tests in other files, but it's unclear to me if any of them are actually exercising the initialization code.)
If not, can you please add a test that uses a real solver / real result as well?
| if ( | ||
| hasattr(model, "dummy_obj") | ||
| and model.dummy_obj.active | ||
| and hasattr(model, "fim") | ||
| and len(list(model.parameter_names)) == 2 | ||
| ): |
There was a problem hiding this comment.
You know the attributes of the model being tested with this dummy solver so is this if-statement really necessary?
| return _FakeResult() | ||
|
|
||
|
|
||
| class _TwoParamExperiment: |
There was a problem hiding this comment.
Should this inherit from the Experiment class?
| jitter = doe_obj._compute_cholesky_jitter(min_eig) | ||
| self.assertAlmostEqual( | ||
| jitter, _SMALL_TOLERANCE_DEFINITENESS - min_eig, places=14 | ||
| ) |
There was a problem hiding this comment.
I know this is a simple method but in general we don't want to repeat the calculation implementation in the test and instead check against a hard-coded value. Checking up to 14 places also seems excessive. I would also recommend adding a test verifying that the return value is non-negative which is the intention of this test mentioned in the docstring.
| Finite-difference mode passed to DoE (for example, ``"central"``). | ||
| obj_used : str | ||
| Objective option name (for example, ``"trace"`` or ``"determinant"``). |
There was a problem hiding this comment.
| Finite-difference mode passed to DoE (for example, ``"central"``). | |
| obj_used : str | |
| Objective option name (for example, ``"trace"`` or ``"determinant"``). | |
| Finite-difference mode passed to DoE (for example, "central"). | |
| obj_used : str | |
| Objective option name (for example, "trace" or "determinant"). |
I would recommend removing the extra formatting around the quotations
| # Test that we can properly | ||
| # set the inputs for the | ||
| # Grey Box object | ||
| def test_set_inputs(self): | ||
| """Verify packed upper-triangular inputs reconstruct the full FIM.""" |
There was a problem hiding this comment.
This docstring doesn't really match the comment above the method describing the intended purpose. Maybe the comment should just be used as the docstring instead?
| def test_D_opt_greybox_build_with_triangular_fim(self): | ||
| """Determinant grey-box build accepts triangular FIM storage.""" | ||
| objective_option = "determinant" | ||
| doe_obj, grey_box_object = make_greybox_and_doe_objects( | ||
| objective_option=objective_option | ||
| ) | ||
|
|
||
| self.assertTrue(doe_obj.only_compute_fim_lower) | ||
| # Grey-box objective construction should not depend on the Cholesky | ||
| # build path. This fixture may still carry the shared default flag, so | ||
| # we do not treat it as a grey-box invariant here. | ||
| self.assertTrue(doe_obj.use_grey_box) | ||
|
|
||
| doe_obj.create_grey_box_objective_function() | ||
|
|
||
| self.assertTrue(hasattr(doe_obj.model.obj_cons, "egb_fim_block")) | ||
| self.assertIn("log-D-opt", list(grey_box_object.output_names())) | ||
| self.assertIsInstance(doe_obj.model.objective, pyo.Objective) | ||
| self.assertEqual(doe_obj.model.objective.sense, pyo.maximize) |
There was a problem hiding this comment.
I don't really understand the need for this test. It's checking for certain attributes and types but not checking any values or calculations. Aren't there separate unit tests verifying that objective function is being constructed with the right type and sense? Could these assertions be added to the previous test, test_D_opt_greybox_build?
The comment about "Grey-box objective construction not depending on the Cholesky build path" seems like it's trying to highlight something subtle in the implementation but I'm not following the subtlety based on the surrounding assertions.
| def _compute_cholesky_jitter(self, min_eig): | ||
| """ | ||
| Compute diagonal regularization for Cholesky initialization. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| min_eig: float | ||
| Minimum eigenvalue of the current FIM estimate. | ||
|
|
||
| Returns | ||
| ------- | ||
| float | ||
| Nonnegative diagonal shift needed so the minimum eigenvalue | ||
| is at least ``_SMALL_TOLERANCE_DEFINITENESS``. | ||
| """ | ||
| return max(0.0, _SMALL_TOLERANCE_DEFINITENESS - float(min_eig)) |
There was a problem hiding this comment.
Is there a reason to have this as a method on the DesignOfExperiments class instead of a standalone utility function? It isn't using anything from the class instance.
Also, it's a 1-line method that is only being used once on L555. Why did you even abstract this out as a separate method?
| if not hasattr(model, "L"): | ||
| return |
There was a problem hiding this comment.
Could you add a comment explaining why this is a valid check to skip the rest of this method?
| jitter = self._compute_cholesky_jitter(min_eig) | ||
| fim_pd = fim_np + jitter * np.eye(len(model.parameter_names)) | ||
|
|
||
| L_vals = np.linalg.cholesky(fim_pd) | ||
| for i, c in enumerate(model.parameter_names): | ||
| for j, d in enumerate(model.parameter_names): | ||
| if i >= j: | ||
| model.L[c, d].value = L_vals[i, j] | ||
| else: | ||
| model.L[c, d].value = 0.0 | ||
|
|
||
| if hasattr(model, "L_inv"): | ||
| L_inv_vals = np.linalg.inv(L_vals) | ||
| for i, c in enumerate(model.parameter_names): | ||
| for j, d in enumerate(model.parameter_names): | ||
| if i >= j: | ||
| model.L_inv[c, d].value = L_inv_vals[i, j] | ||
| else: | ||
| model.L_inv[c, d].value = 0.0 |
There was a problem hiding this comment.
This implementation is slightly different from the implementation where these values are originally calculated on L1443-1465. Is there a reason for the difference? Can these be combined to avoid duplication?
| # Inputs store one triangular half | ||
| # of a symmetric FIM. Reconstruct | ||
| # the full symmetric matrix here, | ||
| # consistent with manuscript S5. |
Fixes # .
Summary/Motivation:
This PR
adds advanced debugging/inspection controls forfixes a trace-objective initialization inconsistency that can produce large residuals in Cholesky-related constraints at the start of the final NLP solve.pyomo.contrib.doe.DesignOfExperiments.run_doe()while preserving default behavior for typical usersThe motivating workflow showed:
inability to usemax_iter=0for final-model probing because scenario solves reused the same solver options,fim,L,fim_inv,L_inv, andcov_trace.Changes proposed in this PR:
Added an advancedrun_config(ConfigBlock/dict) argument torun_doe(model=None, results_file=None, run_config=None):scenario_solver_optionsfinal_solver_optionsfinal_solveinspection.enabledinspection.top_constraintsImplemented scoped solver option application:scenario-generation and square-initialization solves can use different options from the final optimization solve.Added optional structured residual reporting utility:reports top violated active constraints with fields:constraint_namebodylower_boundupper_boundviolationconstraint_typeAdded assemble-and-inspect path (final_solve=False) to inspect assembled NLP state without running final optimization._initialize_cholesky_from_fim()to re-synchronizeL,L_inv,fim_inv, andcov_tracefrom current FIM immediately before final solve / inspection.Updated docstrings and added advanced usage documentation + example path inreactor_example.py.test_doe_initialization.pyfor jitter behavior and FIM synchronization.Backward compatibility:
Default usage (run_doe()with norun_config) remains unchanged.Advanced functionality is available only whenrun_configis provided.Testing:
I ran:
pytest -q pyomo/contrib/doe/tests/test_doe_debug.pyResult:8 passedpytest -q pyomo/contrib/doe/tests/test_doe_solve.py -k "rooney_biegler_fd_central_solve"Result:1 passed, 17 deselectedpython3 -m unittest -q pyomo.contrib.doe.tests.test_doe_initialization3 skippedin the current interpreter because SciPy/numpy are unavailable therepython3 -m py_compile pyomo/contrib/doe/doe.py pyomo/contrib/doe/tests/test_doe_initialization.pyNew/updated tests cover:
Outstanding Tasks (before removing WIP):
Finalize proposed interface for advanced initialization diagnosticsDecide the best way to document the proposed interface. Are examples sufficient or should we build out the Pyomo documentation?Run black@adowling2 carefully reviews proposed changesWhy the strikethought?
The original version of this PR included a new interface to
run_doe()that makes debugging the initialization and, more broadly, solver failures easier. We decided that the newrun_doe()interface added too much complexity and exposed some inconsistencies in other parts of the Pyomo solver interface. (This led to a real-time Codex experiment documented in #3871.) To expedite this current PR, I decided to remove these contributions and strike through the above parts of the PR text. These contributions are still available in the git history. Thus, this current PR only contributes a targeted patch to the Pyomo FIM initialization.Legal Acknowledgement
By contributing to this software project, I have read the contribution guide and agree to the following terms and conditions for my contribution: