Skip to content

Add core symbolic gradient support to Pyomo.DoE#3928

Open
snarasi2 wants to merge 49 commits into
Pyomo:mainfrom
snarasi2:clean_symbolic
Open

Add core symbolic gradient support to Pyomo.DoE#3928
snarasi2 wants to merge 49 commits into
Pyomo:mainfrom
snarasi2:clean_symbolic

Conversation

@snarasi2
Copy link
Copy Markdown

@snarasi2 snarasi2 commented May 4, 2026

Fixes # .

This PR ports the core symbolic-gradient functionality from the historical pyomo-doe-symbolic work into the current pyomo.contrib.doe implementation.

Summary/Motivation:

Rather than merging the old branch directly, this change transplants the symbolic DoE pieces onto current main so that symbolic sensitivities work with the newer DoE implementation already present in Pyomo, including the current objective and GreyBox-oriented code paths.

This PR ports the core symbolic-gradient functionality from adowling2#7 onto current Pyomo main and adapts it to the current pyomo.contrib.doe implementation.

This contribution was prepared with coding assistance from OpenAI Codex. All design decisions, validation, testing, and quality-assurance responsibility remain with Shilpa Narasimhan.

Changes proposed in this PR:

  • Add GradientMethod support to DesignOfExperiments
  • Preserve the existing finite-difference workflow through the new gradient-method interface
  • Add a symbolic / PyNumero gradient path for DoE
  • Add an analytic FIM computation path for non-optimization FIM evaluation
  • Export ExperimentGradients from pyomo.contrib.doe
  • Refactor ExperimentGradients so symbolic and automatic differentiation are set up together
  • Add supporting symbolic-gradient utilities for labeled experiment models
  • Update DoE model construction so symbolic sensitivities can be used in the Jacobian-based DoE machinery
  • Add a guard preventing run_doe() from being called with GradientMethod.kaug
  • Add the polynomial example and polynomial-focused regression coverage
  • Add broader symbolic-versus-automatic gradient consistency tests
  • Add factorial-result dataframe tests
  • Add reactor regression tests against expected solutions
  • Clarify GreyBox cyipopt / MA57-HSL test behavior where relevant

Validation performed locally:

  • Black: python -m black -S -C --check --diff pyomo/contrib/doe → passed
  • Typos: typos --config .github/workflows/typos.toml pyomo/contrib/doe DOE_SYMBOLIC_PR_NOTES.md → passed
  • Focused DoE/GreyBox validation: 134 passed, 0 failed, 0 skipped
  • GreyBox cyipopt tests were run with MA57/HSL available in the local environment
  • The existing K_AUG-dependent DoE test path was validated locally after configuring the required runtime library path

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:

  1. I agree my contributions are submitted under the BSD license.
  2. I represent I am authorized to make the contributions and grant the license. If my employer has rights to intellectual property that includes these contributions, I represent that I have received permission to make contributions and grant the required license on behalf of that employer.

Shilpa Narasimhan added 30 commits April 1, 2026 14:20
Add schema, filtering, and error-path tests for factorial-result tables and DataFrame input handling.

Validation:
- 101 passed, 5 warnings, 10 subtests passed in 30.00s

Notes:
- plotting tests emit expected matplotlib warnings under the non-interactive Agg backend because draw_factorial_figure() calls plt.show().
Add a determinant-based reactor regression test, guard Codecov uploads when coverage.xml is absent, and align local formatting with Black expectations.

Validation:
- focused reactor regression subset: 2 passed, 28 deselected in 10.08s
- full DoE suite: 102 passed, 5 warnings, 10 subtests passed in 30.29s

Notes:
- plotting-related tests continue to emit expected matplotlib warnings under the non-interactive Agg backend because draw_factorial_figure() calls plt.show().
Add coverage for draw_factorial_figure guard branches covering more-than-two sensitivity variables and missing fixed design variables.

Validation:
- focused plotting-guard subset: 2 passed, 37 deselected in 1.92s
- full DoE suite: 104 passed, 5 warnings, 10 subtests passed in 31.47s

Notes:
- plotting-related tests continue to emit expected matplotlib warnings under the non-interactive Agg backend because draw_factorial_figure() calls plt.show().
# The GreyBox path here uses cyipopt rather than the standalone
# IPOPT executable, and the local failure mode we diagnosed was a
# missing MA57/HSL runtime on that cyipopt solve path.
cyipopt_skip_reason = (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a nitpick. All of the skip reasons are variations of the same thing (MA57/HSL issues). Do you really need all three different messages? Or can you just standardize to the one that's on line 315?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — standardized the MA57/HSL-related skip reasons to use the same message as line 315 in ab2f729.

Comment thread pyomo/contrib/doe/tests/test_utils.py Outdated


@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available")
@unittest.skipIf(not numpy_available, "Numpy is not available")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aren't these numpy_available checks redundant (because of lines 29-30)?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — removed the redundant NumPy skip decorators in test_utils.py because the file-level SkipTest already handles missing NumPy in f1c9a8f.

Comment thread pyomo/contrib/doe/tests/test_utils.py Outdated
self.assertEqual(jacobian.shape, expected.shape)
self.assertTrue(np.allclose(jacobian, expected, atol=1e-7, rtol=1e-7))

@unittest.skipIf(not scipy_available, "scipy is not available")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar comment - isn't this scipy_available check redundant?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — removed the redundant SciPy skip decorator in test_utils.py because the file-level SkipTest already handles missing SciPy in dcbfee6.

Copy link
Copy Markdown
Author

@snarasi2 snarasi2 May 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mrmundt, @adowling2 I also noticed the same pattern in test_greybox.py: the file has a file-level SkipTest for NumPy/SciPy, but the TestFIMExternalGreyBox class still has NumPy/SciPy skipIf decorators. Would you prefer that I keep this PR scoped to the test_utils.py comments, or should I also remove the redundant decorators within test_greybox.py and similar files for consistency?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update, one more follow-up: after @adowling2 confirmed the broader cleanup was okay, I removed the remaining redundant downstream NumPy/SciPy skip decorators in the DoE tests and added comments noting that NumPy/SciPy availability is checked by the file-level SkipTest.

Checks run:

  • No remaining downstream skipIf(not numpy_available) / skipIf(not scipy_available) decorators in pyomo/contrib/doe/tests.
  • Black passed: conda run -n base black -S -C --check pyomo/contrib/doe/tests.
  • git diff --check passed.
  • python -m compileall pyomo/contrib/doe/tests passed.
  • Full DoE test suite passed: 134 passed, 5 warnings.

Latest commit: ba6d769c6.

Comment thread pyomo/contrib/doe/doe.py
default: None --> don't save

"""
if self._gradient_method == GradientMethod.kaug:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I am misunderstanding - kaug is listed as an acceptable option in the docstring, but it's not actually?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — I checked the code path more closely. kaug is supported for FIM computation through compute_FIM(), but run_doe() explicitly rejects GradientMethod.kaug because it is not supported for DoE optimization. I clarified that scope in the gradient_method docstring in 1096e52.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once we are happy with this feature, I want to deprecated k_aug in both Pyomo.DoE and ParmEst. This will be a future PR. The new GradientMethod should provide us with sufficient infrastructure to replicate with k_aug is doing in both DoE and ParmEst. That then means one less solver to maintain.

@slilonfe5 fyi, another thing to think about for the fall Pyomo release

@snarasi2
Copy link
Copy Markdown
Author

snarasi2 commented May 22, 2026

Hi @mrmundt , I addressed the latest review comments and pushed the updates.

Summary of changes:

  • Removed the disabled Rooney-Biegler Cholesky test, since it has been disabled for a while and remains available in Git history.
  • Clarified the gradient_method docstring: kaug is supported for FIM computation through compute_FIM(), but not for DoE optimization through run_doe().
  • Updated the DoE optional dependency imports in doe.py, test_utils.py, and test_greybox.py to use attempt_import(..., defer_import=True).
  • Applied Black formatting after the import update.

Local checks:

  • black -S -C --check pyomo/contrib/doe/doe.py pyomo/contrib/doe/tests/test_greybox.py pyomo/contrib/doe/tests/test_utils.py passed.
  • git diff --check passed.
  • python -m compileall pyomo/contrib/doe/doe.py pyomo/contrib/doe/tests/test_greybox.py pyomo/contrib/doe/tests/test_utils.py passed.
  • The targeted changed-file tests pass fully: test_utils.py + test_greybox.py, 48 passed.
  • I also reran the full DoE test suite in the HSL-enabled environment. The full suite:134 passed.

One CI job failed during Windows environment setup, before the Pyomo tests ran. The failure appears to be a conda/libmamba segmentation fault while installing dependencies:

Segmentation fault "$CONDA_EXE" "$@"
Error: Process completed with exit code 139

This does not look related to the PR changes. Could this job please be rerun?

I think the PR is otherwise ready for you to re-review. Could you please re-review when you have a chance?

Comment thread pyomo/contrib/doe/tests/test_utils.py Outdated
for parameter in ("a", "b", "c", "d"):
forward_values = dict(base_values)
backward_values = dict(base_values)
forward_values[parameter] += step
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@snarasi2 I believe you wanted to implement a relative perturbation (which is what we do in ParmEst and Pyomo.DoE). I suggest you define a rel_perturbation = step * base_values[parameter] before the forward_values line. Then replace all the step with rel_perturbation.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, modified as suggested

Comment thread pyomo/contrib/doe/tests/test_utils.py Outdated
.astype(float)
)
base_values = {"a": 2.0, "b": -1.0, "c": 0.5, "d": -1.0}
step = 1e-6
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, the step is too small. I suggest 1e-3

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, modified as suggested

m.x1 = pyo.Var(bounds=(-5, 5), initialize=2.0)
m.x2 = pyo.Var(bounds=(-5, 5), initialize=3.0)

m.a = pyo.Var(bounds=(-5, 5), initialize=2)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It appears a, b, c, and d are parameters. I think these should be fixed after defining them. E.g., after you defined m.a = pyo.Var(bounds=(-5, 5), initialize=2), you should add a m.a.fix() to the new line after the definition. Similar for m.b, m.c, and m.d.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, modified as suggested

@snarasi2
Copy link
Copy Markdown
Author

@slilonfe5 @mrmundt I addressed the review comments by fixing the polynomial model parameters after declaration, updating the finite-difference check to use relative perturbations, and increasing the step size to 1e-3.

Checks passed locally:

git diff --check
Black check
compileall
Full DoE test suite: 134 passed

Latest commit: 512451e.

@slilonfe5
Copy link
Copy Markdown
Member

@mrmundt, this also looks pretty good. We will appreciate any final comments; otherwise, I think this is ready to be merged.

Comment thread DOE_SYMBOLIC_PR_NOTES.md
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file needs to be removed before this PR can be approved.

@snarasi2
Copy link
Copy Markdown
Author

@adowling2 @sscini @slilonfe5 @smondal13 Adding context here: the markdown file explaining the math was included based on earlier internal review feedback from April 8th. Since the Sandia team would prefer that the .md file not be included, should I remove it entirely from the PR, or would you prefer that context be captured elsewhere (for example, in the PR description or inline comments)?

@adowling2
Copy link
Copy Markdown
Member

adowling2 commented May 27, 2026 via email

Copy link
Copy Markdown
Member

@adowling2 adowling2 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my mind, there are two important issues to discuss as a team, possibly at the next Pyomo dev call:

  • Why are some tests outside the symbolic feature getting changed in this PR? Are these intentional changes to improve code coverage, etc., or an artifact of help from Codex? I think we have had a few small group conversations quickly about this as a team, but not with the code pulled up, just in the abstract.
  • Do we want to add RooneyBiegler tests for (almost) all of the objective options with the PyNumero derivatives? If yes, how should this be added to the testing infrastructure? Do we want to tag these as "long" or "extended" tests? Should we use parameterized tests?

def test_reactor_update_suffix_items(self):
"""Test the reactor example with updating suffix items."""
from pyomo.contrib.doe.examples.update_suffix_doe_example import main
class TestRooneyBieglerExample(unittest.TestCase):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am a little confused why this PR changes these files. I thought we already swapped out the reactor example for the consolidated RooneyBiegler example in a previous PR. My practical recommendation is to merge #3866 first, as it is really close and it is the last active Pyomo.DoE PR besides this current PR. After we merge @smondal13's PR, we can look at what this PR is changing relative to the new main.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@snarasi2 , I think the class name here is misleading

and (set(x2_vals).issuperset(set([0, 5])))
)

def test_rooney_biegler_run_doe_pynumero(self):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@blnicho @mrmundt PyNumero now provides an alternative to the finite difference approximation. That means for Pyomo.DoE, the user gets to choose:

Sensitivity calculation method: finite_difference or PyNumero

Objective: trace (Cholseky), det (Cholseky), pseudo-trace, trace (GreyBox), det (GreyBox), min_eigenvalue (GreyBox), condition_number (GreyBox), pseudo-trace (GreyBox -- I think, but I could be wrong)

This leaves 2 times 8 = 16 different reasonable configurations for Pyomo.DoE. Is there a preferred way to test all 16 of these configurations with the RooneyBiegler example in an "extended" or "long runtime" test suite? Is this something that makes the most sense for a parameterized test?

I think this PR is adding tests for the PyNumero for many of these objectives. I want to test this with RooneyBiegler, but I also appreciate if the everyday Pyomo testing suite is getting bloated.

@slilonfe5 @smondal13 @sscini Did we decide on a similar "long runtime" or "extended" test suite for ParmEst? Or is this just on the todo list?

Copy link
Copy Markdown
Contributor

@smondal13 smondal13 May 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@adowling2, the decision was for Pyomo.DoE. I think for Parmest that is on TODO list. @sscini @slilonfe5 Correct me if I am wrong

Comment thread pyomo/contrib/doe/doe.py
default: None --> don't save

"""
if self._gradient_method == GradientMethod.kaug:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once we are happy with this feature, I want to deprecated k_aug in both Pyomo.DoE and ParmEst. This will be a future PR. The new GradientMethod should provide us with sufficient infrastructure to replicate with k_aug is doing in both DoE and ParmEst. That then means one less solver to maintain.

@slilonfe5 fyi, another thing to think about for the fall Pyomo release

Comment thread pyomo/contrib/doe/doe.py
try:
kaug_no = col.index(name)
dsdp_extract.append(dsdp_array[kaug_no])
except Exception:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should avoid except Exception if possible.

Comment thread pyomo/contrib/doe/doe.py
experiment_grad = None
if self._gradient_method == GradientMethod.pynumero:
experiment_grad = ExperimentGradients(
model.scenario_blocks[0], automatic=True, symbolic=True
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a good plan. Also, this PR changes tests outside of the new symbolic/automatic differentiation capabilities. I am curious if any of these changes are also being made in @smondal13's PR.

Comment thread pyomo/contrib/doe/doe.py
import logging
import math

from pyomo.common.dependencies import (
Copy link
Copy Markdown
Contributor

@smondal13 smondal13 May 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mrmundt @adowling2 I think the imports from pyomo.common.dependencies are deferred imports. So, is it necessary to use attempt_import specifically? Is there a preferred style choice between these two? I am curious, and this will help me in the future.


doe_obj = DesignOfExperiments(**DoE_args)

synthetic_results = {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since this is being used multiple times, why don't you make this a function ?

@@ -425,43 +432,191 @@ def test_compute_FIM_seq_backward(self):
doe_obj.compute_FIM(method="sequential")

@unittest.skipIf(not pandas_available, "pandas is not available")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is pandas required for polynomial?

"""Check a stable Rooney-Biegler optimum fingerprint against expected values."""
experiment = get_rooney_biegler_experiment()
DoE_args = get_standard_args(experiment, "central", "determinant")
DoE_args["gradient_method"] = "pynumero"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we have tests for the ' sequentialmethod. So, it would make more sense to useparameterized` tests for maintainability. @adowling2 also mentioned something like that.

doe_obj.results["log10 A-opt"], -2.5554049159721415, places=4
)

def test_rooney_biegler_run_doe_pynumero_objective_matrix(self):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Ready for design review

Development

Successfully merging this pull request may close these issues.

7 participants