Skip to content

Commit 926c91a

Browse files
authored
Merge pull request #3561 from eslickj/sens
Add some pynumero-based sensitivity functions
2 parents d38d9cb + 9caeed2 commit 926c91a

File tree

2 files changed

+236
-0
lines changed

2 files changed

+236
-0
lines changed
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# ___________________________________________________________________________
2+
#
3+
# Pyomo: Python Optimization Modeling Objects
4+
# Copyright (c) 2008-2025
5+
# National Technology and Engineering Solutions of Sandia, LLC
6+
# Under the terms of Contract DE-NA0003525 with National Technology and
7+
# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain
8+
# rights in this software.
9+
# This software is distributed under the 3-clause BSD License.
10+
# ___________________________________________________________________________
11+
12+
from pyomo.common.dependencies import numpy as np
13+
from pyomo.common.dependencies import scipy, attempt_import
14+
15+
import pyomo.environ as pyo
16+
17+
# Use attempt_import here due to unguarded NumPy import in this file
18+
nlp = attempt_import('pyomo.contrib.pynumero.interfaces.pyomo_nlp')[0]
19+
from pyomo.common.collections import ComponentSet, ComponentMap
20+
21+
22+
def _coo_reorder_cols(mat, remap):
23+
"""Change the order of columns in a COO matrix. The main use of this is
24+
to reorder variables in the Jacobian matrix. This changes the matrix in
25+
place.
26+
27+
Parameters
28+
----------
29+
mat: scipy.sparse.coo_matrix
30+
Reorder the columns of this matrix
31+
remap: dict
32+
dictionary where keys are old column and value is new column, if a column
33+
doesn't move, it doesn't need to be included.
34+
35+
Returns
36+
-------
37+
NoneType
38+
None
39+
"""
40+
for i in range(len(mat.data)):
41+
try:
42+
mat.col[i] = remap[mat.col[i]]
43+
except KeyError:
44+
pass # it's fine if we don't move a col in remap
45+
46+
47+
def get_dsdp_dfdp(model, theta):
48+
"""Calculate the derivatives of the state variables (s) with respect to
49+
parameters (p) (ds/dp), and the derivative of the objective function (f)
50+
with respect to p (df/dp). The number of parameters in theta should be the
51+
same as the number of degrees of freedom.
52+
53+
Parameters
54+
----------
55+
model: pyomo.environ.Block | pyomo.contrib.pynumero.interfaces.PyomoNLP
56+
Model to calculate sensitivity on. To retain the cached objects in
57+
the pynumero interface, create a PyomoNLP first and pass it to this function.
58+
theta: list
59+
A list of parameters as pyomo.environ.VarData, the number of parameters
60+
should be equal to the degrees of freedom.
61+
62+
Returns
63+
-------
64+
scipy.sparse.csc_matrix, csc_matrix, ComponentMap, ComponentMap
65+
ds/dp (ns by np), df/dp (1 by np), row map, column map.
66+
The column map maps Pyomo variables p to columns and the
67+
row map maps Pyomo variables s to rows.
68+
"""
69+
# Create a Pynumero NLP and get Jacobian
70+
if isinstance(model, nlp.PyomoNLP):
71+
m2 = model
72+
else:
73+
m2 = nlp.PyomoNLP(model)
74+
J = m2.evaluate_jacobian_eq()
75+
v_list = m2.get_pyomo_variables()
76+
# Map variables to columns in J
77+
mv_map = {id(v): i for i, v in enumerate(v_list)}
78+
s_list = list(ComponentSet(v_list) - ComponentSet(theta))
79+
ns = len(s_list)
80+
np = len(theta)
81+
col_remap = {mv_map[id(v)]: i for i, v in enumerate(s_list + theta)}
82+
_coo_reorder_cols(J, remap=col_remap)
83+
J = J.tocsc()
84+
dB = -(
85+
J
86+
@ scipy.sparse.vstack(
87+
(scipy.sparse.coo_matrix((ns, np)), scipy.sparse.identity(np))
88+
).tocsc()
89+
)
90+
# Calculate sensitivity matrix
91+
dsdp = scipy.sparse.linalg.spsolve(J[:, range(ns)], dB)
92+
# Get a map of state vars to columns
93+
s_map = {id(v): i for i, v in enumerate(s_list)}
94+
# Get the outputs we are interested in from the list of output vars
95+
column_map = ComponentMap([(v, i) for i, v in enumerate(theta)])
96+
row_map = ComponentMap([(v, i) for i, v in enumerate(s_list)])
97+
dfdx = scipy.sparse.coo_matrix(m2.evaluate_grad_objective())
98+
_coo_reorder_cols(dfdx, remap=col_remap)
99+
dfdx = dfdx.tocsc()
100+
dfdp = dfdx[0, :ns] @ dsdp + dfdx[0, ns:]
101+
# return sensitivity of the outputs to p and component maps
102+
return dsdp, dfdp, row_map, column_map
103+
104+
105+
def get_dydp(y_list, dsdp, row_map):
106+
"""Reduce the sensitivity matrix from get_dsdp_dfdp to only
107+
a specified set of state variables of interest.
108+
109+
Parameters
110+
----------
111+
y_list: list
112+
A list of state variables of interest (a subset of s)
113+
dsdp: csc_matrix
114+
A sensitivity matrix calculated by get_dsdp_dfdp
115+
row_map: ComponentMap
116+
A row map from get_dsdp_dfdp
117+
118+
Returns
119+
-------
120+
csc_matrix, ComponentMap
121+
dy/dp and a new row map with only y variables
122+
123+
"""
124+
new_row_map = ComponentMap()
125+
for i, v in enumerate(y_list):
126+
new_row_map[v] = i
127+
rows = [row_map[v] for v in y_list]
128+
dydp = dsdp[rows, :]
129+
return dydp, new_row_map
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# ___________________________________________________________________________
2+
#
3+
# Pyomo: Python Optimization Modeling Objects
4+
# Copyright (c) 2008-2025
5+
# National Technology and Engineering Solutions of Sandia, LLC
6+
# Under the terms of Contract DE-NA0003525 with National Technology and
7+
# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain
8+
# rights in this software.
9+
# This software is distributed under the 3-clause BSD License.
10+
# ___________________________________________________________________________
11+
12+
13+
import pyomo.common.unittest as unittest
14+
from pyomo.common.dependencies import numpy as np, numpy_available
15+
from pyomo.common.dependencies import scipy_available, attempt_import
16+
17+
import pyomo.environ as pyo
18+
19+
# Use attempt_import here due to unguarded NumPy import in this file
20+
nlp = attempt_import('pyomo.contrib.pynumero.interfaces.pyomo_nlp')[0]
21+
import pyomo.contrib.sensitivity_toolbox.pynumero as pnsens
22+
from pyomo.contrib.pynumero.asl import AmplInterface
23+
24+
if not scipy_available or not numpy_available:
25+
raise unittest.SkipTest("scipy or numpy is not available")
26+
27+
if not AmplInterface.available():
28+
raise unittest.SkipTest("Pynumero needs the ASL extension to run NLP tests")
29+
30+
31+
class TestSeriesData(unittest.TestCase):
32+
def test_dsdp_dfdp_pyomo(self):
33+
m = pyo.ConcreteModel()
34+
m.x1 = pyo.Var(initialize=200)
35+
m.x2 = pyo.Var(initialize=5)
36+
m.p1 = pyo.Var(initialize=10)
37+
m.p2 = pyo.Var(initialize=5)
38+
m.obj = pyo.Objective(
39+
expr=m.x1 * m.p1 + m.x2 * m.x2 * m.p2 + m.p1 * m.p2, sense=pyo.minimize
40+
)
41+
m.c1 = pyo.Constraint(expr=m.x1 == 2 * m.p1**2)
42+
m.c2 = pyo.Constraint(expr=m.x2 == m.p2)
43+
theta = [m.p1, m.p2]
44+
45+
dsdp, dfdp, rmap, cmap = pnsens.get_dsdp_dfdp(m, theta)
46+
47+
# Since x1 = p1
48+
np.testing.assert_almost_equal(dsdp[rmap[m.x1], cmap[m.p1]], 40.0)
49+
np.testing.assert_almost_equal(dsdp[rmap[m.x1], cmap[m.p2]], 0.0)
50+
np.testing.assert_almost_equal(dsdp[rmap[m.x2], cmap[m.p1]], 0.0)
51+
np.testing.assert_almost_equal(dsdp[rmap[m.x2], cmap[m.p2]], 1.0)
52+
53+
# if x1 = 2 * p1 and x2 = p2 then
54+
# df/dp1 = 6 p1**2 + p2 = 45.0
55+
# df/dp2 = 3 p2 + p1 = 85.0
56+
np.testing.assert_almost_equal(dfdp[0, cmap[m.p1]], 605.0)
57+
np.testing.assert_almost_equal(dfdp[0, cmap[m.p2]], 85.0)
58+
59+
def test_dsdp_dfdp_pyomo_nlp(self):
60+
m = pyo.ConcreteModel()
61+
m.x1 = pyo.Var(initialize=200)
62+
m.x2 = pyo.Var(initialize=5)
63+
m.p1 = pyo.Var(initialize=10)
64+
m.p2 = pyo.Var(initialize=5)
65+
m.obj = pyo.Objective(
66+
expr=m.x1 * m.p1 + m.x2 * m.x2 * m.p2 + m.p1 * m.p2, sense=pyo.minimize
67+
)
68+
m.c1 = pyo.Constraint(expr=m.x1 == 2 * m.p1**2)
69+
m.c2 = pyo.Constraint(expr=m.x2 == m.p2)
70+
theta = [m.p1, m.p2]
71+
72+
m2 = nlp.PyomoNLP(m)
73+
dsdp, dfdp, rmap, cmap = pnsens.get_dsdp_dfdp(m2, theta)
74+
75+
# Since x1 = p1
76+
assert dsdp.shape == (2, 2)
77+
np.testing.assert_almost_equal(dsdp[rmap[m.x1], cmap[m.p1]], 40.0)
78+
np.testing.assert_almost_equal(dsdp[rmap[m.x1], cmap[m.p2]], 0.0)
79+
np.testing.assert_almost_equal(dsdp[rmap[m.x2], cmap[m.p1]], 0.0)
80+
np.testing.assert_almost_equal(dsdp[rmap[m.x2], cmap[m.p2]], 1.0)
81+
82+
# if x1 = 2 * p1 and x2 = p2 then
83+
# df/dp1 = 6 p1**2 + p2 = 45.0
84+
# df/dp2 = 3 p2 + p1 = 85.0
85+
assert dfdp.shape == (1, 2)
86+
np.testing.assert_almost_equal(dfdp[0, cmap[m.p1]], 605.0)
87+
np.testing.assert_almost_equal(dfdp[0, cmap[m.p2]], 85.0)
88+
89+
def test_dydp_pyomo(self):
90+
m = pyo.ConcreteModel()
91+
m.x1 = pyo.Var(initialize=200)
92+
m.x2 = pyo.Var(initialize=5)
93+
m.p1 = pyo.Var(initialize=10)
94+
m.p2 = pyo.Var(initialize=5)
95+
m.obj = pyo.Objective(
96+
expr=m.x1 * m.p1 + m.x2 * m.x2 * m.p2 + m.p1 * m.p2, sense=pyo.minimize
97+
)
98+
m.c1 = pyo.Constraint(expr=m.x1 == 2 * m.p1**2)
99+
m.c2 = pyo.Constraint(expr=m.x2 == m.p2)
100+
theta = [m.p1, m.p2]
101+
dsdp, dfdp, rmap, cmap = pnsens.get_dsdp_dfdp(m, theta)
102+
103+
dydp, rmap = pnsens.get_dydp([m.x2], dsdp, rmap)
104+
105+
np.testing.assert_almost_equal(dydp[rmap[m.x2], cmap[m.p1]], 0.0)
106+
np.testing.assert_almost_equal(dydp[rmap[m.x2], cmap[m.p2]], 1.0)
107+
assert dydp.shape == (1, 2)

0 commit comments

Comments
 (0)