-
Notifications
You must be signed in to change notification settings - Fork 580
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
base: main
Are you sure you want to change the base?
Changes from 33 commits
a4a1ade
58c3663
26f8cd3
4dab155
55bfeae
f8105ea
0eb9791
66cdd0c
d75e667
e5b2e73
13848fa
bd53282
e40c6bc
75acdd2
0c87484
7cdba5b
6db9e66
f12d908
fda812e
ccfca8c
043e7de
33e1bd0
c95f41d
e9e878d
caf6bae
dd1fdcd
553ab82
a063c33
9af160d
1f849a0
da91f2f
fd8d3ea
0ee6856
01fc6ed
ab7f31f
c6e81ea
aa3961f
555a307
40ac36c
b2ed61d
daf01c7
8664ea6
9393dde
b27d89d
964446b
39a3d2b
8e823e4
3d58a30
87b6c24
7386170
401e661
d7c7028
a9e8972
408f550
038539e
8da7ce9
902ac71
f865c42
6935dca
b1f776c
d66e607
1dfd9fe
07728eb
202d21b
b76038b
3724c15
49fef05
1d8b66d
1ea2b4d
5d5374e
28e658d
6c40b0a
bb8cdd6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
||
|
|
||
|
|
@@ -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, | ||
|
|
@@ -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] | ||
|
|
@@ -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." | ||
|
|
@@ -1611,6 +1622,333 @@ def compute_FIM_full_factorial( | |
|
|
||
| return self.fim_factorial_results | ||
|
|
||
| def compute_FIM_factorial( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This function is a beast and should be broken up into helper functions. At the very least, pull the (probably overly complex) logic for generating the design points into it's own (private) function.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1 on this. This function is huge.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jsiirola Thanks for the suggestion. I agree and have refactored this in the latest update. |
||
| 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), | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should I use this tuple? I was reluctant to use 4 different arguments for the pd df setting / configuration |
||
| initialization_scheme="snake_traversal", | ||
| file_name: str = None, | ||
| ): | ||
| """Will run a simulation-based factorial exploration of the experimental input | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Change "will run" to "performs" |
||
| space (i.e., a ``grid search`` or ``parameter sweep``) to understand how the | ||
|
smondal13 marked this conversation as resolved.
Outdated
|
||
| 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`." | ||
| ) | ||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The logic here is pretty convoluted. You repeatedly test to see which "source" you are using for the design points. I would recommend refactoring.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have refactored and added private methods. |
||
| # 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: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note that at this point you don't have to test both abs and rel: they are either both None or both non-None.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have refactored the code. |
||
| 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 | ||
|
smondal13 marked this conversation as resolved.
Outdated
|
||
| 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 | ||
|
smondal13 marked this conversation as resolved.
Outdated
|
||
|
|
||
| else: | ||
| raise ValueError( | ||
| "Unexpected error in generating design values. Please check the" | ||
| " input parameters." | ||
|
smondal13 marked this conversation as resolved.
Outdated
|
||
| ) | ||
|
|
||
| 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) | ||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does it make sense to split the function here? That way, we can do extensive testing on the results of
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Great suggestion, and I implemented that split: traversal generation now lives in a dedicated helper (_materialize_factorial_points). |
||
| # 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: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bare
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added the error message inside the logger warning. |
||
| 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we still want to overhaul the plotting functions to not use strings? Or do we just want this function to be consistent with our user interface above? In other words, if we use string-based names above, I think it it okay to use string-based names here. |
||
| # TODO: Make the plotting functionalities work for >2 design features | ||
| def draw_factorial_figure( | ||
|
|
||
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 forcompute_FIM_full_factorial()There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, a depreciation warning sounds reasonable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually, I am really confused: why did you create a new method in the same file that did effectively the same thing? Does this replace the other method? Is it a superset of the other method? Do we need both?
Also, you might consider renaming this function: I don't know what a
FIM factorialis (nor how to compute one). Maybe something likecompute_FIM_from_full_factorial_sampling?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jsiirola This function is a superset of the already existing
compute_FIM_full_factorial()method. So, I am not sure if I should keep it under the same name since the "full" in the method name can be misleading. It can use a full factorial (changing all the decision variables) and a fractional factorial (changing some of the decision variables). This method basically analyzes the sensitivity.@adowling2, @slilonfe5, @snarasi2, @sscini Can we name it something like
compute_sensitivity_at_design_points()? Or similar to the one @jsiirola suggested,compute_FIM_from_factorial_sampling?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@smondal13 If sensitivity is what you are doing, I think
compute_sensitivity_at_design_pointsis better.