Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

pyomo.doe adding more verbose output for sensitivity analysis #3525

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Binary file added example_reactor_compute_FIM_A_opt.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added example_reactor_compute_FIM_D_opt.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added example_reactor_compute_FIM_E_opt.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added example_reactor_compute_FIM_ME_opt.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
46 changes: 35 additions & 11 deletions pyomo/contrib/doe/doe.py
Original file line number Diff line number Diff line change
Expand Up @@ -1389,7 +1389,7 @@ def update_FIM_prior(self, model=None, FIM=None):

self.logger.info("FIM prior has been updated.")

# ToDo: Add an update function for the parameter values? --> closed loop parameter estimation?
# TODO: Add an update function for the parameter values? --> closed loop parameter estimation?
# Or leave this to the user?????
def update_unknown_parameter_values(self, model=None, param_vals=None):
raise NotImplementedError(
Expand Down Expand Up @@ -1448,15 +1448,19 @@ def compute_FIM_full_factorial(
"Design ranges keys must be a subset of experimental design names."
)

# ToDo: Add more objective types? i.e., modified-E; G-opt; V-opt; etc?
# ToDo: Also, make this a result object, or more user friendly.
# TODO: Add more objective types? i.e., modified-E; G-opt; V-opt; etc?
# TODO: Also, make this a result object, or more user friendly.
fim_factorial_results = {k.name: [] for k, v in model.experiment_inputs.items()}
fim_factorial_results.update(
{
"log10 D-opt": [],
"log10 A-opt": [],
"log10 E-opt": [],
"log10 ME-opt": [],
"eigval_min": [],
"eigval_max": [],
"det_FIM": [],
"trace_FIM": [],
"solve_time": [],
}
)
Expand Down Expand Up @@ -1517,21 +1521,37 @@ def compute_FIM_full_factorial(
time_set.append(iter_t)

FIM = self._computed_FIM


'''
# Alex said to make this a function
# This should be a static (?) function
# Making it a function allows us to perform error tests
# on the FIM
def compute_metrics(FIM):

# Compute D, A, E, ME metrics
# Perform error checks
# Return D, A, E, ME

'''
# Compute and record metrics on FIM
D_opt = np.log10(np.linalg.det(FIM))
A_opt = np.log10(np.trace(FIM))
E_vals, E_vecs = np.linalg.eig(FIM) # Grab eigenvalues
det_FIM = np.linalg.det(FIM) # Determinant of FIM
D_opt = np.log10(det_FIM)
trace_FIM = np.trace(FIM) # Trace of FIM
A_opt = np.log10(trace_FIM)
E_vals, E_vecs =np.linalg.eig(FIM) # Grab eigenvalues and eigenvectors

E_ind = np.argmin(E_vals.real) # Grab index of minima to check imaginary
IMG_THERESHOLD = 1e-6 # Threshold for imaginary component
# Warn the user if there is a ``large`` imaginary component (should not be)
if abs(E_vals.imag[E_ind]) > 1e-8:
if abs(E_vals.imag[E_ind]) > IMG_THERESHOLD:
self.logger.warning(
"Eigenvalue has imaginary component greater than 1e-6, contact developers if this issue persists."
f"Eigenvalue has imaginary component greater than {IMG_THERESHOLD}, contact developers if this issue persists."
)

# If the real value is less than or equal to zero, set the E_opt value to nan
if E_vals.real[E_ind] <= 0:
E_opt = np.nan
E_opt = np.nan
else:
E_opt = np.log10(E_vals.real[E_ind])

Expand All @@ -1545,8 +1565,12 @@ def compute_FIM_full_factorial(
fim_factorial_results["log10 A-opt"].append(A_opt)
fim_factorial_results["log10 E-opt"].append(E_opt)
fim_factorial_results["log10 ME-opt"].append(ME_opt)
fim_factorial_results["eigval_min"].append(E_vals[0])
fim_factorial_results["eigval_max"].append(E_vals.max())
fim_factorial_results["det_FIM"].append(det_FIM)
fim_factorial_results["trace_FIM"].append(trace_FIM)
fim_factorial_results["solve_time"].append(time_set[-1])

self.fim_factorial_results = fim_factorial_results

return self.fim_factorial_results
Expand Down
90 changes: 90 additions & 0 deletions pyomo/contrib/doe/tests/test_doe_FIM_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from pyomo.common.dependencies import (
numpy as np,
pandas as pd
)
import pytest

def compute_FIM_metrics(FIM):
# Compute and record metrics on FIM
det_FIM = np.linalg.det(FIM) # Determinant of FIM
D_opt = np.log10(det_FIM)
trace_FIM = np.trace(FIM) # Trace of FIM
A_opt = np.log10(trace_FIM)
E_vals, E_vecs =np.linalg.eig(FIM) # Grab eigenvalues and eigenvectors

E_ind = np.argmin(E_vals.real) # Grab index of minima to check imaginary
IMG_THERESHOLD = 1e-6 # Threshold for imaginary component
# Warn the user if there is a ``large`` imaginary component (should not be)
if abs(E_vals.imag[E_ind]) > IMG_THERESHOLD:
print(
f"Eigenvalue has imaginary component greater than {IMG_THERESHOLD}, contact developers if this issue persists."
)

# If the real value is less than or equal to zero, set the E_opt value to nan
if E_vals.real[E_ind] <= 0:
E_opt = np.nan
else:
E_opt = np.log10(E_vals.real[E_ind])

ME_opt = np.log10(np.linalg.cond(FIM))

return {
"det_FIM": det_FIM,
"trace_FIM": trace_FIM,
"E_vals": E_vals,
"D_opt": D_opt,
"A_opt": A_opt,
"E_opt": E_opt,
"ME_opt": ME_opt
}

def test_FIM_metrics():
# Create a sample Fisher Information Matrix (FIM)
FIM = np.array([[4, 2], [2, 3]])

# Call the function to compute metrics
results = compute_FIM_metrics(FIM)

# Use known values for assertions
det_expected = np.linalg.det(FIM)
D_opt_expected = np.log10(det_expected)

trace_expected = np.trace(FIM)
A_opt_expected = np.log10(trace_expected)

E_vals_expected, _ = np.linalg.eig(FIM)
min_eigval = np.min(E_vals_expected.real)

cond_expected = np.linalg.cond(FIM)

assert np.isclose(results['det_FIM'], det_expected)
assert np.isclose(results['trace_FIM'], trace_expected)
assert np.allclose(results['E_vals'], E_vals_expected)
assert np.isclose(results['D_opt'], D_opt_expected)
assert np.isclose(results['A_opt'], A_opt_expected)
if min_eigval.real > 0:
assert np.isclose(results['E_opt'], np.log10(min_eigval))
else:
assert np.isnan(results['E_opt'])

assert np.isclose(results['ME_opt'], np.log10(cond_expected))


def test_FIM_metrics_warning_printed(capfd):
# Create a matrix with an imaginary component large enough to trigger the warning
FIM = np.array([
[9, -2],
[9, 3]
])

# Call the function
compute_FIM_metrics(FIM)

# Capture stdout and stderr
out, err = capfd.readouterr()

# Correct expected message
expected_message = "Eigenvalue has imaginary component greater than 1e-06, contact developers if this issue persists."

# Ensure expected message is in the output
assert expected_message in out