Skip to content

Pyomo. DoE: Sensitivity initialization #3639

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

Open
wants to merge 33 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a4a1ade
added zigzag in utils and compute fim in doe.py
smondal13 Jun 16, 2025
58c3663
Merge branch 'Pyomo:main' into sensitivity-initialization
smondal13 Jun 16, 2025
26f8cd3
The compute_FIM_factorial() using zigzag pattern is now working as ex…
smondal13 Jun 17, 2025
4dab155
Merge branch 'sensitivity-initialization' of github.com:smondal13/pyo…
smondal13 Jun 17, 2025
55bfeae
Added utils.py as utils_updated.py from adding_eigen_values branch to…
smondal13 Jun 17, 2025
f8105ea
this version of compute_FIM_factorial() runs fine. The argument `desi…
smondal13 Jun 19, 2025
0eb9791
Changed documentation and improved the error message
smondal13 Jun 23, 2025
66cdd0c
Merge branch 'Pyomo:main' into sensitivity-initialization
smondal13 Jun 23, 2025
d75e667
updated comments
smondal13 Jun 23, 2025
e5b2e73
Added argument checking for generate zigzag pattern.
smondal13 Jun 24, 2025
13848fa
changed the name of the pattern generator
smondal13 Jun 25, 2025
bd53282
Merge branch 'adding_eigen_values' into sensitivity-initialization
smondal13 Jun 25, 2025
e40c6bc
Merge branch 'Pyomo:main' into sensitivity-initialization
smondal13 Jun 25, 2025
75acdd2
Merge branch 'sensitivity-initialization' of github.com:smondal13/pyo…
smondal13 Jun 25, 2025
0c87484
Changed the name of the grid sampling function and deleted the utils_…
smondal13 Jun 27, 2025
222b396
Add utility to utils.py
sscini Jun 27, 2025
81a595f
Ran black to update formatting
sscini Jun 27, 2025
7cdba5b
Merge remote-tracking branch 'sscini/add-update-model-utility' into s…
smondal13 Jun 27, 2025
6db9e66
added rel_change and abs_change for change in design
smondal13 Jun 30, 2025
f12d908
added update_suffix function in compute the sensitivity
smondal13 Jul 1, 2025
fda812e
working code. added design vals and also changed the settings of pd d…
smondal13 Jul 1, 2025
ccfca8c
Merge branch 'Pyomo:main' into sensitivity-initialization
smondal13 Jul 1, 2025
043e7de
changed some minor comments and docstrings
smondal13 Jul 1, 2025
33e1bd0
updated with sscini's `utils.py`.
sscini Jul 1, 2025
c95f41d
Merge branch 'main' into sensitivity-initialization
smondal13 Jul 7, 2025
e9e878d
Merge remote-tracking branch 'origin/main' into sensitivity-initializ…
smondal13 Jul 8, 2025
caf6bae
Merge branch 'sensitivity-initialization' of github.com:smondal13/pyo…
smondal13 Jul 8, 2025
dd1fdcd
Merge branch 'Pyomo:main' into sensitivity-initialization
smondal13 Jul 8, 2025
553ab82
added new func and changed test files
smondal13 Jul 9, 2025
a063c33
changed if statement to ternary operator and minor changes in return …
smondal13 Jul 9, 2025
9af160d
Revert "changed if statement to ternary operator and minor changes in…
smondal13 Jul 9, 2025
1f849a0
Reapply "changed if statement to ternary operator and minor changes i…
smondal13 Jul 9, 2025
da91f2f
deleted test_solve
smondal13 Jul 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
342 changes: 340 additions & 2 deletions pyomo/contrib/doe/doe.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,14 @@

import pyomo.environ as pyo
from pyomo.contrib.doe.utils import (
check_FIM,
check_matrix,
compute_FIM_metrics,
_SMALL_TOLERANCE_DEFINITENESS,
snake_traversal_grid_sampling,
update_model_from_suffix,
)


from pyomo.opt import SolverStatus


Expand All @@ -67,6 +70,11 @@ class FiniteDifferenceStep(Enum):
backward = "backward"


class SensitivityInitialization(Enum):
snake_traversal = "snake_traversal"
nested_for_loop = "nested_for_loop"


class DesignOfExperiments:
def __init__(
self,
Expand Down Expand Up @@ -621,6 +629,9 @@ def _sequential_FIM(self, model=None):
if self.scale_nominal_param_value:
scale_factor *= v

# TODO: scale by response values (i.e., measurement values)
# if self.scale_response_values:
# scale_factor /= measurement_vals_np[:, col_1].mean()
# Calculate the column of the sensitivity matrix
self.seq_jac[:, i] = (
measurement_vals_np[:, col_1] - measurement_vals_np[:, col_2]
Expand Down Expand Up @@ -1376,7 +1387,7 @@ def check_model_FIM(self, model=None, FIM=None):
)

# Check FIM is positive definite and symmetric
check_FIM(FIM)
check_matrix(FIM)

self.logger.info(
"FIM provided matches expected dimensions from model and is approximately positive (semi) definite."
Expand Down Expand Up @@ -1611,6 +1622,333 @@ def compute_FIM_full_factorial(

return self.fim_factorial_results

def compute_FIM_factorial(
Copy link
Member

Choose a reason for hiding this comment

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

Was this function already in Pyomo.DoE somewhere else?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No, I created this method. I do not see the same name anywhere else.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@adowling2, we have compute_FIM_full_factorial(). I have not changed that method, rather I have added a new method. Maybe we can show a deprecation warning for compute_FIM_full_factorial()

self,
model=None,
design_vals: list = None,
abs_change: list = None,
rel_change: list = None,
n_designs: int = 5,
method="sequential",
df_settings=(True, None, None, 500),
initialization_scheme="snake_traversal",
file_name: str = None,
):
"""Will run a simulation-based factorial exploration of the experimental input
space (i.e., a ``grid search`` or ``parameter sweep``) to understand how the
FIM metrics change as a function of the experimental design space. This method
can be used for both full factorial and fractional factorial designs.

Parameters
----------
model : DoE model, optional
The model to perform the full factorial exploration on. Default: None
design_vals : list, optional
A list of design values to use for the full factorial exploration. If
provided, will use this value to generate the design values and ignore
`abs_change`, `rel_change`, and `n_designs`. Default: None
Default: None
abs_change : list, optional
Absolute change in the design variable values. Default: None.
If provided, will use this value to generate the design values.
If `abs_change` is provided, but `rel_change` is not provided, `rel_change`
will be set to zero.
Formula to calculate the design values:
change_in_value = lower_bound * rel_change + abs_change`
design_value += design_value + change_in_value
rel_change : list, optional
Relative change in the design variable values. Default: None.
If provided, will use this value to generate the design values.
If `rel_change` is provided, but `abs_change` is not provided, `abs_change`
will be set to zero.
n_designs : int, optional
Number of designs to generate for each design variable. Default: 5.
If `abs_change` and/or `rel_change` are provided, this value will be ignored.
method : str, optional
string to specify which method should be used. options are ``kaug`` and
``sequential`. Default: "sequential"
df_settings : tuple, optional
A tuple containing the settings for set_option() method of the pandas
DataFrame. Default: (True, None, None, 500)
- first element: whether to return a pandas DataFrame (True/False)
- second element: number of max_columns for the DataFrame. Default: None,
i.e., no limit on the number of columns.
- third element: number of max_rows for the DataFrame. Default: None,
i.e., no limit on the number of rows.
- fourth element: display width for the DataFrame. Default: 500.
initialization_scheme : str, optional
Which scheme to use for initializing the design variables.
Options are ``"snake_traversal"`` and ``"nested_for_loop"``.
Default: "snake_traversal"
file_name : str, optional
if provided, will save the results to a json file. Default: None

Returns
-------
factorial_results: dict
A dictionary containing the results of the factorial design with the
following keys:
- keys of model's experiment_inputs
- "log10(D-opt)": list of D-optimality values
- "log10(A-opt)": list of A-optimality values
- "log10(E-opt)": list of E-optimality values
- "log10(ME-opt)": list of ME-optimality values
- "eigval_min": list of minimum eigenvalues
- "eigval_max": list of maximum eigenvalues
- "det_FIM": list of determinants of the FIM
- "trace_FIM": list of traces of the FIM
- "solve_time": list of solve times
- "total_points": total number of points in the factorial design
- "success_count": number of successful runs
- "failure_count": number of failed runs
- "FIM_all": list of all FIMs computed for each point in the factorial
"""

# Start timer
sp_timer = TicTocTimer()
sp_timer.tic(msg=None)
self.logger.info("Beginning Factorial Design.")

# Make new model for factorial design
self.factorial_model = self.experiment.get_labeled_model(
**self.get_labeled_model_args
).clone()
model = self.factorial_model

design_keys = [k for k in model.experiment_inputs.keys()]

# check if design_values, abs_change and rel_change are provided and have the
# same length as design_keys
# Design values are of higher priority than abs_change and rel_change.
if design_vals is not None:
if len(design_vals) != len(design_keys):
raise ValueError(
"`design_values` must have the same length of "
f"`{len(design_keys)}` as `design_keys`."
)
design_values = design_vals

else:
if abs_change:
if len(abs_change) != len(design_keys):
raise ValueError(
"`abs_change` must have the same length of "
f"`{len(design_keys)}` as `design_keys`."
)

if rel_change:
if len(rel_change) != len(design_keys):
raise ValueError(
"`rel_change` must have the same length of "
f"`{len(design_keys)}` as `design_keys`."
)

# if either abs_change or rel_change is not provided, set it to list of
# zeros
if abs_change or rel_change:
if not abs_change:
abs_change = [0] * len(design_keys)
elif not rel_change:
rel_change = [0] * len(design_keys)

design_values = []
# loop over design keys and generate design values
for i, comp in enumerate(design_keys):
lb = comp.lb
ub = comp.ub
# Check if the component has finite lower and upper bounds
if lb is None or ub is None:
raise ValueError(
f"{comp.name} does not have a lower or upper bound."
)

if abs_change is None and rel_change is None:
des_val = np.linspace(lb, ub, n_designs)

# if abs_change and/or rel_change is provided, generate design values
# using the formula:
# change_in_value = lower_bound * rel_change + abs_change
elif abs_change or rel_change:
des_val = []
del_val = comp.lb * rel_change[i] + abs_change[i]
if del_val == 0:
raise ValueError(
f"Design variable {comp.name} has no change in value - "
"check abs_change and rel_change values."
)
val = lb
while val <= ub:
des_val.append(val)
val += del_val

else:
raise ValueError(
"Unexpected error in generating design values. Please check the"
" input parameters."
)

design_values.append(des_val)

# generate the factorial points based on the initialization scheme
try:
scheme_enum = SensitivityInitialization(initialization_scheme)
except ValueError:
self.logger.warning(
f"Initialization scheme '{initialization_scheme}' is not recognized. "
"Using `snake_traversal` as the default initialization scheme."
)
scheme_enum = SensitivityInitialization.snake_traversal

if scheme_enum == SensitivityInitialization.snake_traversal:
factorial_points = snake_traversal_grid_sampling(*design_values)
elif scheme_enum == SensitivityInitialization.nested_for_loop:
factorial_points = product(*design_values)

# TODO: Add more initialization schemes

factorial_points_list = list(factorial_points)

factorial_results = {k.name: [] for k in model.experiment_inputs.keys()}
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": [],
}
)

success_count = 0
failure_count = 0
total_points = len(factorial_points_list)

# save the FIM for each point in the factorial design
self.n_parameters = len(model.unknown_parameters)
FIM_all = np.zeros((total_points, self.n_parameters, self.n_parameters))

time_set = []
curr_point = 1 # Initial current point
for design_point in factorial_points_list:
# kept (commented out) the following code to check later whether it will
# be considerably faster for complex models.
# In a simple model, this code took 15.9s to compute 125 points in JN.
# For the same condition, `update_model_from_suffix` took 16.5s in JN
# Fix design variables at fixed experimental design point
# for i in range(len(design_point)):
# design_keys[i].fix(design_point[i])

update_model_from_suffix(model, "experiment_inputs", design_point)

# Timing and logging objects
self.logger.info(f"=======Iteration Number: {curr_point} =======")
iter_timer = TicTocTimer()
iter_timer.tic(msg=None)

try:
curr_point = success_count + failure_count + 1
self.logger.info(f"This is run {curr_point} out of {total_points}.")
self.compute_FIM(model=model, method=method)
success_count += 1
# iteration time
iter_t = iter_timer.toc(msg=None)
time_set.append(iter_t)

# More logging
self.logger.info(
f"The code has run for {round(sum(time_set), 2)} seconds."
)
self.logger.info(
"Estimated remaining time: %s seconds",
round(
sum(time_set) / (curr_point) * (total_points - curr_point + 1),
2,
),
)
except:
self.logger.warning(
":::::::::::Warning: Cannot converge this run.::::::::::::"
)
failure_count += 1
self.logger.warning("failed count:", failure_count)

self._computed_FIM = np.zeros(self.prior_FIM.shape)

iter_t = iter_timer.toc(msg=None)
time_set.append(iter_t)

FIM = self._computed_FIM

# Save FIM for the current design point
FIM_all[curr_point - 1, :, :] = FIM

# Compute and record metrics on FIM
det_FIM, trace_FIM, E_vals, E_vecs, D_opt, A_opt, E_opt, ME_opt = (
compute_FIM_metrics(FIM)
)

for k in model.experiment_inputs.keys():
factorial_results[k.name].append(pyo.value(k))

factorial_results["log10(D-opt)"].append(D_opt)
factorial_results["log10(A-opt)"].append(A_opt)
factorial_results["log10(E-opt)"].append(E_opt)
factorial_results["log10(ME-opt)"].append(ME_opt)
factorial_results["eigval_min"].append(np.min(E_vals))
factorial_results["eigval_max"].append(np.max(E_vals))
factorial_results["det_FIM"].append(det_FIM)
factorial_results["trace_FIM"].append(trace_FIM)
factorial_results["solve_time"].append(time_set[-1])

factorial_results.update(
{
"total_points": total_points,
"success_count": success_count,
"failure_count": failure_count,
"FIM_all": FIM_all.tolist(), # Save all FIMs
}
)
self.factorial_results = factorial_results

# Set pandas DataFrame options
if df_settings[0]:
with pd.option_context(
"display.max_columns",
df_settings[1],
"display.max_rows",
df_settings[2],
"display.width",
df_settings[3],
):
exclude_keys = {
"total_points",
"success_count",
"failure_count",
"FIM_all",
}
dict_for_df = {
k: v for k, v in factorial_results.items() if k not in exclude_keys
}
res_df = pd.DataFrame(dict_for_df)
print("\n\n=========Factorial results===========")
print("Total points:", total_points)
print("Success count:", success_count)
print("Failure count:", failure_count)
print("\n")
print(res_df)

# Save the results to a json file based on the file_name provided
if file_name is not None:
with open(file_name + ".json", "w") as f:
json.dump(self.factorial_results, f, indent=4)
self.logger.info(f"Results saved to {file_name}.json.")

return self.factorial_results

# TODO: Overhaul plotting functions to not use strings
# TODO: Make the plotting functionalities work for >2 design features
def draw_factorial_figure(
Expand Down
4 changes: 2 additions & 2 deletions pyomo/contrib/doe/tests/test_doe_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ def test_reactor_check_bad_prior_negative_eigenvalue(self):

with self.assertRaisesRegex(
ValueError,
r"FIM provided is not positive definite. It has one or more negative eigenvalue\(s\) less than -{:.1e}".format(
r"Matrix provided is not positive definite. It has one or more negative eigenvalue\(s\) less than -{:.1e}".format(
_SMALL_TOLERANCE_DEFINITENESS
),
):
Expand All @@ -208,7 +208,7 @@ def test_reactor_check_bad_prior_not_symmetric(self):

with self.assertRaisesRegex(
ValueError,
"FIM provided is not symmetric using absolute tolerance {}".format(
"Matrix provided is not symmetric using absolute tolerance {}".format(
_SMALL_TOLERANCE_SYMMETRY
),
):
Expand Down
Loading
Loading