Skip to content

Commit 8885b6e

Browse files
authored
Merge pull request #10 from jnschmid/sobol-sensitivity
Sobol sensitivity
2 parents 8675586 + 201b1e2 commit 8885b6e

File tree

5 files changed

+407
-14
lines changed

5 files changed

+407
-14
lines changed

uqpce/examples/paraboloid/paraboloid_example.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,24 @@
1515
# Setting up for UQPCE and robust design
1616
#---------------------------------------------------------------------------
1717
(
18-
var_basis, norm_sq, resampled_var_basis,
19-
aleatory_cnt, epistemic_cnt, resp_cnt, order, variables,
18+
var_basis, norm_sq, resampled_var_basis,
19+
aleatory_cnt, epistemic_cnt, resp_cnt, order, variables,
2020
sig, run_matrix
2121
) = interface.initialize(input_file, matrix_file)
2222

2323
prob = om.Problem()
2424

2525
prob.model.add_subsystem(
26-
'parab',
27-
Paraboloid(vec_size=resp_cnt),
28-
promotes_inputs=['uncerta', 'uncertb', 'desx', 'desy'],
26+
'parab',
27+
Paraboloid(vec_size=resp_cnt),
28+
promotes_inputs=['uncerta', 'uncertb', 'desx', 'desy'],
2929
promotes_outputs=['f_abxy']
3030
)
3131

3232
prob.model.add_subsystem(
3333
'func',
3434
UQPCEGroup(
35-
significance=sig, var_basis=var_basis, norm_sq=norm_sq,
35+
significance=sig, var_basis=var_basis, norm_sq=norm_sq,
3636
resampled_var_basis=resampled_var_basis, tail='upper',
3737
epistemic_cnt=epistemic_cnt, aleatory_cnt=aleatory_cnt,
3838
uncert_list=['f_abxy'], tanh_omega=5e-4

uqpce/mdao/interface.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010

1111
def initialize(input_file: str='input.yaml', matrix_file: str='run_matrix.dat', order: Union[None, int]=None):
12-
12+
1313
var_dict, settings = read_input_file(input_file)
1414
if 'plot' in settings.keys():
1515
settings.pop('plot')
@@ -34,12 +34,50 @@ def initialize(input_file: str='input.yaml', matrix_file: str='run_matrix.dat',
3434
cil, cih = pce.confidence_interval()
3535

3636
return (
37-
pce._matrix.var_basis_sys_eval, pce._matrix.norm_sq,
38-
pce._pbox.var_basis_resamp.astype(float), pce._pbox.aleat_samps,
39-
pce._pbox.epist_samps, X.shape[0], pce.order, pce.variables,
37+
pce._matrix.var_basis_sys_eval, pce._matrix.norm_sq,
38+
pce._pbox.var_basis_resamp.astype(float), pce._pbox.aleat_samps,
39+
pce._pbox.epist_samps, X.shape[0], pce.order, pce.variables,
4040
pce.significance, pce._X
4141
)
4242

43+
def initialize_dict(
44+
input_file: str='input.yaml', matrix_file: str='run_matrix.dat',
45+
order: Union[None, int]=None):
46+
47+
var_dict, settings = read_input_file(input_file)
48+
if 'plot' in settings.keys():
49+
settings.pop('plot')
50+
if 'verbose' in settings.keys():
51+
settings.pop('verbose')
52+
53+
if order is not None:
54+
settings['order'] = order
55+
56+
with warnings.catch_warnings():
57+
warnings.filterwarnings("ignore")
58+
pce = PCE(outputs=False, plot=False, verbose=False, **settings)
59+
60+
for key, value in var_dict.items():
61+
pce.add_variable(**value)
62+
63+
X = np.loadtxt(matrix_file, ndmin=2)
64+
65+
with warnings.catch_warnings():
66+
warnings.filterwarnings("ignore")
67+
pce.fit(X, np.zeros(X.shape[0]))
68+
cil, cih = pce.confidence_interval()
69+
70+
init_dict = {
71+
'var_basis': pce._matrix.var_basis_sys_eval,
72+
'norm_sq': pce._matrix.norm_sq, 'order': pce.order,
73+
'resampled_var_basis': pce._pbox.var_basis_resamp.astype(float),
74+
'aleatory_cnt': pce._pbox.aleat_samps, 'variables': pce.variables,
75+
'epistemic_cnt': pce._pbox.epist_samps, 'resp_cnt': X.shape[0],
76+
'significance': pce.significance, 'run_matrix': pce._X,
77+
'model_matrix': pce._matrix.model_matrix
78+
}
79+
return init_dict
80+
4381
def set_vals(prob: om.Problem, uncert_var_list: np.ndarray, run_matrix: np.ndarray, deterministic: bool=False):
4482
if not deterministic:
4583
for i in range(len(uncert_var_list)):

uqpce/mdao/sobolcomp.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"""OpenMDAO component for computing Sobol sensitivity indices."""
2+
import numpy as np
3+
import openmdao.api as om
4+
from uqpce.pce._helpers import calc_sobols, create_total_sobols
5+
6+
7+
class SobolComp(om.ExplicitComponent):
8+
"""
9+
Computes Sobol sensitivity indices using existing UQPCE functions.
10+
11+
This component wraps uqpce.pce._helpers.calc_sobols() and
12+
create_total_sobols() to expose Sobol indices as OpenMDAO outputs.
13+
14+
Individual Sobol indices show the variance contribution of each PCE term.
15+
Total Sobol indices show the variance contribution of each uncertain variable
16+
(including interaction effects).
17+
18+
Mathematical formulation:
19+
variance = Σ(coeff[i]² × norm_sq[i]) for i > 0
20+
sobol[i-1] = (coeff[i]² × norm_sq[i]) / variance
21+
total_sobol[j] = Σ sobol[i-1] where variable j appears in term i
22+
"""
23+
24+
def initialize(self):
25+
"""Declare options for the component."""
26+
self.options.declare(
27+
'norm_sq', types=np.ndarray, allow_none=False,
28+
desc='Norm squared for PCE terms'
29+
)
30+
self.options.declare(
31+
'model_matrix', types=np.ndarray, allow_none=False,
32+
desc='Interaction matrix for computing total Sobols. '
33+
'Shape: (n_terms, n_vars). Entry [i,j] indicates if variable j '
34+
'appears in PCE term i.'
35+
)
36+
37+
def setup(self):
38+
"""Set up inputs and outputs for the component."""
39+
norm_sq = self.options['norm_sq']
40+
model_matrix = self.options['model_matrix']
41+
42+
n_terms = len(norm_sq)
43+
n_vars = model_matrix.shape[1]
44+
n_sobols = n_terms - 1 # Exclude intercept
45+
46+
# Input: PCE coefficients from CoefficientsComp
47+
self.add_input(
48+
'matrix_coeffs', shape=(n_terms,),
49+
desc='PCE coefficients from CoefficientsComp'
50+
)
51+
52+
# Output: Individual Sobol indices (one per PCE term, excluding intercept)
53+
self.add_output(
54+
'sobols', shape=(n_sobols,),
55+
desc='Individual Sobol indices (variance contribution per PCE term)'
56+
)
57+
58+
# Output: Total Sobol indices (one per uncertain variable)
59+
self.add_output(
60+
'total_sobols', shape=(n_vars,),
61+
desc='Total Sobol indices (variance contribution per variable, including interactions)'
62+
)
63+
64+
# Declare partials with analytic derivatives
65+
# Individual Sobols depend on all coefficients (through variance in denominator)
66+
self.declare_partials('sobols', 'matrix_coeffs', method='exact')
67+
68+
# Total Sobols are linear combinations of individual Sobols
69+
# The Jacobian is sparse based on model_matrix structure
70+
# For simplicity, declare as dense (could optimize to sparse later)
71+
self.declare_partials('total_sobols', 'matrix_coeffs', method='exact')
72+
73+
def compute(self, inputs, outputs):
74+
"""
75+
Compute Sobols using existing UQPCE functions.
76+
77+
Uses:
78+
- uqpce.pce._helpers.calc_sobols() for individual Sobols
79+
- uqpce.pce._helpers.create_total_sobols() for total Sobols
80+
81+
Supports complex step by taking real part of complex inputs.
82+
"""
83+
matrix_coeffs = inputs['matrix_coeffs']
84+
norm_sq = self.options['norm_sq']
85+
model_matrix = self.options['model_matrix']
86+
87+
# Handle complex step: UQPCE functions don't support complex,
88+
# but Sobols are real-valued functions of real coefficients
89+
if np.iscomplexobj(matrix_coeffs):
90+
matrix_coeffs = matrix_coeffs.real
91+
92+
# Compute individual Sobols using existing tested UQPCE function
93+
sobols = calc_sobols(matrix_coeffs, norm_sq)
94+
outputs['sobols'] = sobols
95+
96+
# Compute total Sobols using existing tested UQPCE function
97+
var_count = model_matrix.shape[1]
98+
99+
# create_total_sobols expects sobols to be 2D: (n_terms, n_responses)
100+
# Reshape 1D sobols to 2D (n_terms, 1) for single response
101+
sobols_2d = sobols.reshape(-1, 1)
102+
total_sobols = create_total_sobols(var_count, model_matrix, sobols_2d)
103+
104+
# Flatten to 1D array for output
105+
outputs['total_sobols'] = total_sobols.flatten()
106+
107+
def compute_partials(self, inputs, partials):
108+
"""
109+
Compute analytic derivatives of Sobols with respect to coefficients.
110+
111+
For sobol[i-1] = (coeff[i]² × norm_sq[i]) / variance:
112+
113+
∂sobol[i-1]/∂coeff[i] = 2×coeff[i]×norm_sq[i]/variance
114+
- sobol[i-1]×2×coeff[i]×norm_sq[i]/variance
115+
= 2×coeff[i]×norm_sq[i]/variance × (1 - sobol[i-1])
116+
117+
∂sobol[i-1]/∂coeff[j] (j≠i) = -sobol[i-1]×2×coeff[j]×norm_sq[j]/variance
118+
= -2×coeff[j]×norm_sq[j]×sobol[i-1]/variance
119+
120+
For total_sobol[var] = Σ sobol[i] where model_matrix[i, var] != 0:
121+
122+
∂total_sobol[var]/∂coeff[j] = Σ ∂sobol[i]/∂coeff[j] for relevant i
123+
"""
124+
matrix_coeffs = inputs['matrix_coeffs']
125+
norm_sq = self.options['norm_sq']
126+
model_matrix = self.options['model_matrix']
127+
128+
n_terms = len(matrix_coeffs)
129+
n_sobols = n_terms - 1
130+
131+
# Handle complex step: take real part
132+
if np.iscomplexobj(matrix_coeffs):
133+
matrix_coeffs = matrix_coeffs.real
134+
135+
# Compute variance (denominator of Sobol formula)
136+
# variance = Σ(coeff[i]² × norm_sq[i]) for i > 0
137+
# norm_sq is 2D (n_terms, 1), extract scalar values
138+
variance = 0.0
139+
for i in range(1, n_terms):
140+
variance += matrix_coeffs[i]**2 * float(norm_sq[i])
141+
142+
# Compute individual Sobols for derivative calculation
143+
sobols = np.zeros(n_sobols)
144+
for i in range(1, n_terms):
145+
sobols[i-1] = (matrix_coeffs[i]**2 * float(norm_sq[i])) / variance
146+
147+
# Initialize Jacobian matrix for d(sobols)/d(matrix_coeffs)
148+
jac_sobols = np.zeros((n_sobols, n_terms))
149+
150+
# Derivatives of individual Sobols
151+
for i in range(1, n_terms): # For each Sobol index
152+
sobol_idx = i - 1
153+
154+
for j in range(n_terms): # For each coefficient
155+
if j == 0:
156+
# Intercept doesn't affect Sobols
157+
jac_sobols[sobol_idx, j] = 0.0
158+
elif j == i:
159+
# Derivative with respect to own coefficient
160+
# ∂sobol[i]/∂coeff[i] = 2×coeff[i]×norm_sq[i]/variance × (1 - sobol[i])
161+
jac_sobols[sobol_idx, j] = (
162+
2.0 * matrix_coeffs[i] * float(norm_sq[i]) / variance * (1.0 - sobols[sobol_idx])
163+
)
164+
else:
165+
# Derivative with respect to other coefficients (through variance)
166+
# ∂sobol[i]/∂coeff[j] = -2×coeff[j]×norm_sq[j]×sobol[i]/variance
167+
jac_sobols[sobol_idx, j] = (
168+
-2.0 * matrix_coeffs[j] * float(norm_sq[j]) * sobols[sobol_idx] / variance
169+
)
170+
171+
partials['sobols', 'matrix_coeffs'] = jac_sobols
172+
173+
# Derivatives of total Sobols
174+
# total_sobol[var] = Σ sobol[i-1] where model_matrix[i, var] != 0
175+
# ∂total_sobol[var]/∂coeff[j] = Σ ∂sobol[i-1]/∂coeff[j] for relevant i
176+
177+
n_vars = model_matrix.shape[1]
178+
jac_total = np.zeros((n_vars, n_terms))
179+
180+
for var_idx in range(n_vars): # For each variable
181+
for term_idx in range(1, n_terms): # For each PCE term (excluding intercept)
182+
if model_matrix[term_idx, var_idx] != 0:
183+
# This term contributes to this variable's total Sobol
184+
# Add the derivatives from individual Sobol
185+
jac_total[var_idx, :] += jac_sobols[term_idx-1, :]
186+
187+
partials['total_sobols', 'matrix_coeffs'] = jac_total

uqpce/mdao/uqpcegroup.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from uqpce.mdao.variancecomp import VarianceComp
99
from uqpce.mdao.cdf.cdfgroup import CDFGroup
1010
from uqpce.mdao.meanplusvarcomp import MeanPlusVarComp
11+
from uqpce.mdao.sobolcomp import SobolComp
1112

1213
class UQPCEGroup(om.Group):
1314
"""
@@ -61,9 +62,20 @@ def initialize(self):
6162
desc='Reference scale for 0 of the sample data'
6263
)
6364
self.options.declare(
64-
'sample_ref', types=(list, None, int, float), default=None,
65+
'sample_ref', types=(list, None, int, float), default=None,
6566
desc='Reference scale for 1 of the sample data'
6667
)
68+
self.options.declare(
69+
'model_matrix', types=(np.ndarray, type(None)), default=None,
70+
desc='Interaction matrix for computing Sobol indices. '
71+
'Shape: (n_terms, n_vars). Entry [i,j] indicates if variable j '
72+
'appears in PCE term i. Required if compute_sobols=True.'
73+
)
74+
self.options.declare(
75+
'compute_sobols', types=bool, default=False,
76+
desc='Whether to compute Sobol sensitivity indices. '
77+
'If True, model_matrix must be provided.'
78+
)
6779

6880
def setup(self):
6981
"""
@@ -129,11 +141,29 @@ def setup(self):
129141
)
130142

131143
self.add_subsystem(
132-
f'{resp_subsys_name}_mean_plus_var_comp', MeanPlusVarComp(),
133-
promotes_inputs=[('mean', f'{resp}:mean'),('variance', f'{resp}:variance')],
134-
promotes_outputs=[('mean_plus_var', f'{resp}:mean_plus_var')]
144+
f'{resp_subsys_name}_mean_plus_var_comp', MeanPlusVarComp(),
145+
promotes_inputs=[('mean', f'{resp}:mean'),('variance', f'{resp}:variance')],
146+
promotes_outputs=[('mean_plus_var', f'{resp}:mean_plus_var')]
135147
)
136148

149+
# Add Sobol sensitivity component if requested
150+
if self.options['compute_sobols']:
151+
model_matrix = self.options['model_matrix']
152+
if model_matrix is None:
153+
raise ValueError(
154+
'model_matrix must be provided when compute_sobols=True'
155+
)
156+
157+
self.add_subsystem(
158+
f'{resp_subsys_name}_sobol_comp',
159+
SobolComp(norm_sq=self.options['norm_sq'], model_matrix=model_matrix),
160+
promotes_inputs=[('matrix_coeffs', f'{resp}:matrix_coeffs')],
161+
promotes_outputs=[
162+
('sobols', f'{resp}:sobols'),
163+
('total_sobols', f'{resp}:total_sobols')
164+
]
165+
)
166+
137167
if tail == 'lower' or tail == 'both':
138168
self.add_subsystem(
139169
f'{resp_subsys_name}_lower_cdf_group',

0 commit comments

Comments
 (0)