From d332230274ac2c2ee3a94d5f3a0f9b56f5eb8345 Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Sun, 23 Mar 2025 09:30:25 -0400 Subject: [PATCH 01/35] Updated parmest file --- pyomo/contrib/parmest/parmest.py | 184 +++++++++++++++++++++++++++++-- 1 file changed, 175 insertions(+), 9 deletions(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index ea9dfc00640..c860c74e890 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -231,9 +231,95 @@ def SSE(model): """ Sum of squared error between `experiment_output` model and data values """ - expr = sum((y - y_hat) ** 2 for y, y_hat in model.experiment_outputs.items()) + expr = sum((y - y_hat) ** 2 for y_hat, y in model.experiment_outputs.items()) return expr +def SSE_weighted(model): + """ + Sum of squared error between `experiment_output` model and data values + """ + expr = sum( + ((y - y_hat) / model.measurement_error[y_hat]) ** 2 + for y_hat, y in model.experiment_outputs.items() + ) + return expr + +# Calculate Jacobian matrix J using finite differences +def compute_jacobian(model): + """ + Compute the Jacobian matrix using finite differences. + """ + y_hat_list = [y_hat for y_hat, y in model.experiment_outputs.items()] + params = [k for k, v in model.unknown_parameters.items()] + param_values = [p.value for p in params] + print("Parameter values:",param_values) + n_params = len(param_values) + n_outputs = len(y_hat_list) + + J = np.zeros((n_outputs, n_params)) + + perturbation = 1E-6 # Small perturbation for finite differences + for i, param in enumerate(params): + orig_value = param_values[i] + + # Forward perturbation + param.set_value(orig_value + perturbation) + params_new = [k.value for k, v in model.unknown_parameters.items()] + print("New param", params_new) + solver = pyo.SolverFactory('ipopt') + res_plus = solver.solve(model) + y_hat_plus = [pyo.value(y_hat) for y_hat, y in model.experiment_outputs.items()] + + # Backward perturbation + param.set_value(orig_value - perturbation) + res_minus = solver.solve(model) + y_hat_minus = [pyo.value(y_hat) for y_hat, y in model.experiment_outputs.items()] + + # Restore original parameter value + param.set_value(orig_value) + # model.unknown_parameters[param] = orig_value + + # Central difference approximation for Jacobian + J[:, i] = [(y_hat_plus[w] - y_hat_minus[w]) / (2 * perturbation) for w in range(len(y_hat_plus))] + + return J + +def compute_FIM(model): + """ + Calculate the covariance matrix using the Jacobian and model measurement error. + + Parameters: + ----------- + model: ConcreteModel + Pyomo model containing experiment_outputs and measurement_error. + + Returns: + -------- + covariance_matrix: numpy.ndarray + Covariance matrix of the estimated parameters. + """ + + # Extract experiment outputs and measurement error values + y_hat_list = [y_hat for y_hat, y in model.experiment_outputs.items()] + error_list = [model.measurement_error[y_hat] for y_hat, y in model.experiment_outputs.items()] + + # Check if error list is consistent + if len(error_list) == 0 or len(y_hat_list) == 0: + raise ValueError("Experiment outputs and measurement errors cannot be empty.") + + # Create the weight matrix W (inverse of variance) + W = np.diag([1 / (err**2) for err in error_list]) + + # Calculate Jacobian matrix + J = compute_jacobian(model) + + # Calculate FIM + try: + FIM = J.T @ W @ J + except np.linalg.LinAlgError: + raise RuntimeError("Jacobian matrix is singular or ill-conditioned. Cannot calculate covariance matrix.") + + return FIM class Estimator(object): """ @@ -428,6 +514,8 @@ def _create_parmest_model(self, experiment_number): # custom functions if self.obj_function == 'SSE': second_stage_rule = SSE + elif self.obj_function == 'SSE_weighted': + second_stage_rule = SSE_weighted else: # A custom function uses model.experiment_outputs as data second_stage_rule = self.obj_function @@ -578,10 +666,49 @@ def _Q_opt( the constant cancels out. (was scaled by 1/n because it computes an expected value.) ''' - cov = 2 * sse / (n - l) * inv_red_hes - cov = pd.DataFrame( - cov, index=thetavals.keys(), columns=thetavals.keys() - ) + if self.obj_function == 'SSE': # covariance calculation for measurements in the same unit + model = self.exp_list[0].get_labeled_model() + + # get the measurement error + meas_error = [model.measurement_error[y_hat] for y_hat in model.experiment_outputs] + + # check if the user specifies the measurement error + if all(item is None for item in meas_error): + cov = 2 * sse / (n - l) * inv_red_hes + cov = pd.DataFrame( + cov, index=thetavals.keys(), columns=thetavals.keys() + ) + else: + cov = 2 * meas_error[0] * inv_red_hes # covariance matrix + cov = pd.DataFrame( + cov, index=thetavals.keys(), columns=thetavals.keys() + ) + elif self.obj_function == 'SSE_weighted': # covariance calculation for measurements in diff. units + # TODO: update formula + # covariance matrix + FIM_all = [] # FIM of all experiments + for experiment in self.exp_list: # loop through the experiments + model = experiment.get_labeled_model() + + # change the parameter values to the optimal values estimated + params = [k for k, v in model.unknown_parameters.items()] + for param in params: + param.set_value(thetavals[param.name]) + + # resolve the model + solver = pyo.SolverFactory("ipopt") + solver.solve(model) + + # compute the FIM + FIM_all.append(compute_FIM(model)) + + FIM_total = np.sum(FIM_all, axis=0) # FIM of all experiments + cov = np.linalg.inv(FIM_total) # covariance matrix + cov = pd.DataFrame( + cov, index=thetavals.keys(), columns=thetavals.keys() + ) + else: + raise NotImplementedError('Covariance calculation is only supported for SSE and SSE_weighted objectives') thetavals = pd.Series(thetavals) @@ -1726,10 +1853,49 @@ def _Q_opt( the constant cancels out. (was scaled by 1/n because it computes an expected value.) ''' - cov = 2 * sse / (n - l) * inv_red_hes - cov = pd.DataFrame( - cov, index=thetavals.keys(), columns=thetavals.keys() - ) + if self.obj_function == 'SSE': # covariance calculation for measurements in the same unit + model = self.exp_list[0].get_labeled_model() + + # get the measurement error + meas_error = [model.measurement_error[y_hat] for y_hat in model.experiment_outputs] + + # check if the user specifies the measurement error + if all(item is None for item in meas_error): + cov = 2 * sse / (n - l) * inv_red_hes + cov = pd.DataFrame( + cov, index=thetavals.keys(), columns=thetavals.keys() + ) + else: + cov = 2 * meas_error[0] * inv_red_hes # covariance matrix + cov = pd.DataFrame( + cov, index=thetavals.keys(), columns=thetavals.keys() + ) + elif self.obj_function == 'SSE_weighted': # covariance calculation for measurements in diff. units + # TODO: update formula + # covariance matrix + FIM_all = [] # FIM of all experiments + for experiment in self.exp_list: # loop through the experiments + model = experiment.get_labeled_model() + + # change the parameter values to the optimal values estimated + params = [k for k, v in model.unknown_parameters.items()] + for param in params: + param.set_value(thetavals[param.name]) + + # resolve the model + solver = pyo.SolverFactory("ipopt") + solver.solve(model) + + # compute the FIM + FIM_all.append(compute_FIM(model)) + + FIM_total = np.sum(FIM_all, axis=0) # FIM of all experiments + cov = np.linalg.inv(FIM_total) # covariance matrix + cov = pd.DataFrame( + cov, index=thetavals.keys(), columns=thetavals.keys() + ) + else: + raise NotImplementedError('Covariance calculation is only supported for SSE and SSE_weighted objectives') thetavals = pd.Series(thetavals) From e2c5eab8d4e48f58c3282ec36fef23a18a7eb67a Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Thu, 27 Mar 2025 14:23:22 -0400 Subject: [PATCH 02/35] Updated parmest.py file --- pyomo/contrib/parmest/parmest.py | 147 +++++++++++++++++++------------ 1 file changed, 89 insertions(+), 58 deletions(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index c860c74e890..53965b0b283 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -229,95 +229,108 @@ def _experiment_instance_creation_callback( def SSE(model): """ - Sum of squared error between `experiment_output` model and data values + Sum of squared error between the model prediction of measured variables and data values, + assuming Gaussian i.i.d errors. """ - expr = sum((y - y_hat) ** 2 for y_hat, y in model.experiment_outputs.items()) + expr = (1 / 2) * sum((y - y_hat) ** 2 for y_hat, y in model.experiment_outputs.items()) return expr def SSE_weighted(model): """ - Sum of squared error between `experiment_output` model and data values + Weighted sum of squared error between the model prediction of measured variables and data values, + assuming Gaussian i.i.d errors. """ - expr = sum( + expr = (1 / 2) * sum( ((y - y_hat) / model.measurement_error[y_hat]) ** 2 for y_hat, y in model.experiment_outputs.items() ) return expr -# Calculate Jacobian matrix J using finite differences +# Calculate the sensitivity of measured variables to parameters using central finite difference def compute_jacobian(model): """ - Compute the Jacobian matrix using finite differences. + Compute the Jacobian matrix using central finite difference. + + Arguments: + model: Pyomo model containing experiment_outputs and measurement_error. + + Returns: + J: Jacobian matrix """ + # get the measured variables y_hat_list = [y_hat for y_hat, y in model.experiment_outputs.items()] + + # get the parameters params = [k for k, v in model.unknown_parameters.items()] param_values = [p.value for p in params] - print("Parameter values:",param_values) + + # get the number of parameters and measured variables n_params = len(param_values) n_outputs = len(y_hat_list) + # compute the sensitivity of measured variables to the parameters (Jacobian) J = np.zeros((n_outputs, n_params)) - perturbation = 1E-6 # Small perturbation for finite differences + perturbation = 1e-6 # Small perturbation for finite differences for i, param in enumerate(params): + # store original value of the parameter orig_value = param_values[i] # Forward perturbation - param.set_value(orig_value + perturbation) - params_new = [k.value for k, v in model.unknown_parameters.items()] - print("New param", params_new) + param.fix(orig_value + perturbation) + + # solve model solver = pyo.SolverFactory('ipopt') - res_plus = solver.solve(model) + solver.solve(model) + + # forward perturbation measured variables y_hat_plus = [pyo.value(y_hat) for y_hat, y in model.experiment_outputs.items()] # Backward perturbation - param.set_value(orig_value - perturbation) - res_minus = solver.solve(model) + param.fix(orig_value - perturbation) + + # resolve model + solver.solve(model) + + # backward perturbation measured variables y_hat_minus = [pyo.value(y_hat) for y_hat, y in model.experiment_outputs.items()] # Restore original parameter value - param.set_value(orig_value) - # model.unknown_parameters[param] = orig_value + param.fix(orig_value) - # Central difference approximation for Jacobian + # Central difference approximation for the Jacobian J[:, i] = [(y_hat_plus[w] - y_hat_minus[w]) / (2 * perturbation) for w in range(len(y_hat_plus))] return J +# compute the Fisher information matrix of the estimated parameters def compute_FIM(model): """ - Calculate the covariance matrix using the Jacobian and model measurement error. + Calculate the Fisher information matrix using the Jacobian and model measurement errors. - Parameters: - ----------- - model: ConcreteModel - Pyomo model containing experiment_outputs and measurement_error. + Arguments: + model: Pyomo model containing experiment_outputs and measurement_error. Returns: - -------- - covariance_matrix: numpy.ndarray - Covariance matrix of the estimated parameters. + FIM: Fisher information matrix. """ - # Extract experiment outputs and measurement error values + # extract the measured variables and measurement errors y_hat_list = [y_hat for y_hat, y in model.experiment_outputs.items()] error_list = [model.measurement_error[y_hat] for y_hat, y in model.experiment_outputs.items()] - # Check if error list is consistent + # check if error list is consistent if len(error_list) == 0 or len(y_hat_list) == 0: raise ValueError("Experiment outputs and measurement errors cannot be empty.") - # Create the weight matrix W (inverse of variance) + # create the weight matrix W (inverse of variance) W = np.diag([1 / (err**2) for err in error_list]) - # Calculate Jacobian matrix + # calculate Jacobian matrix J = compute_jacobian(model) - # Calculate FIM - try: - FIM = J.T @ W @ J - except np.linalg.LinAlgError: - raise RuntimeError("Jacobian matrix is singular or ill-conditioned. Cannot calculate covariance matrix.") + # calculate the FIM + FIM = J.T @ W @ J return FIM @@ -667,46 +680,55 @@ def _Q_opt( expected value.) ''' if self.obj_function == 'SSE': # covariance calculation for measurements in the same unit + # get the model model = self.exp_list[0].get_labeled_model() # get the measurement error - meas_error = [model.measurement_error[y_hat] for y_hat in model.experiment_outputs] + meas_error = [model.measurement_error[y_hat] for y_hat, y in model.experiment_outputs.items()] # check if the user specifies the measurement error if all(item is None for item in meas_error): - cov = 2 * sse / (n - l) * inv_red_hes + cov = sse / (n - l) * inv_red_hes # covariance matrix cov = pd.DataFrame( cov, index=thetavals.keys(), columns=thetavals.keys() ) else: - cov = 2 * meas_error[0] * inv_red_hes # covariance matrix + cov = meas_error[0] * inv_red_hes # covariance matrix cov = pd.DataFrame( cov, index=thetavals.keys(), columns=thetavals.keys() ) elif self.obj_function == 'SSE_weighted': # covariance calculation for measurements in diff. units - # TODO: update formula - # covariance matrix - FIM_all = [] # FIM of all experiments + # Store the FIM of all the experiments + FIM_all_exp = [] for experiment in self.exp_list: # loop through the experiments - model = experiment.get_labeled_model() + # get a copy of the model + model = experiment.get_labeled_model().clone() - # change the parameter values to the optimal values estimated + # fix the parameter values to the optimal values estimated params = [k for k, v in model.unknown_parameters.items()] for param in params: - param.set_value(thetavals[param.name]) + param.fix(thetavals[param.name]) # resolve the model solver = pyo.SolverFactory("ipopt") solver.solve(model) # compute the FIM - FIM_all.append(compute_FIM(model)) + FIM_all_exp.append(compute_FIM(model)) + + # Total FIM of experiments + FIM_total = np.sum(FIM_all_exp, axis=0) - FIM_total = np.sum(FIM_all, axis=0) # FIM of all experiments - cov = np.linalg.inv(FIM_total) # covariance matrix + # covariance matrix + cov = np.linalg.inv(FIM_total) cov = pd.DataFrame( cov, index=thetavals.keys(), columns=thetavals.keys() ) + # elif self.obj_function == 'SSE_weighted': + # cov = inv_red_hes + # cov = pd.DataFrame( + # cov, index=thetavals.keys(), columns=thetavals.keys() + # ) else: raise NotImplementedError('Covariance calculation is only supported for SSE and SSE_weighted objectives') @@ -1854,46 +1876,55 @@ def _Q_opt( expected value.) ''' if self.obj_function == 'SSE': # covariance calculation for measurements in the same unit + # get the model model = self.exp_list[0].get_labeled_model() # get the measurement error - meas_error = [model.measurement_error[y_hat] for y_hat in model.experiment_outputs] + meas_error = [model.measurement_error[y_hat] for y_hat, y in model.experiment_outputs.items()] # check if the user specifies the measurement error if all(item is None for item in meas_error): - cov = 2 * sse / (n - l) * inv_red_hes + cov = sse / (n - l) * inv_red_hes # covariance matrix cov = pd.DataFrame( cov, index=thetavals.keys(), columns=thetavals.keys() ) else: - cov = 2 * meas_error[0] * inv_red_hes # covariance matrix + cov = meas_error[0] * inv_red_hes # covariance matrix cov = pd.DataFrame( cov, index=thetavals.keys(), columns=thetavals.keys() ) elif self.obj_function == 'SSE_weighted': # covariance calculation for measurements in diff. units - # TODO: update formula - # covariance matrix - FIM_all = [] # FIM of all experiments + # Store the FIM of all the experiments + FIM_all_exp = [] for experiment in self.exp_list: # loop through the experiments - model = experiment.get_labeled_model() + # get a copy of the model + model = experiment.get_labeled_model().clone() - # change the parameter values to the optimal values estimated + # fix the parameter values to the optimal values estimated params = [k for k, v in model.unknown_parameters.items()] for param in params: - param.set_value(thetavals[param.name]) + param.fix(thetavals[param.name]) # resolve the model solver = pyo.SolverFactory("ipopt") solver.solve(model) # compute the FIM - FIM_all.append(compute_FIM(model)) + FIM_all_exp.append(compute_FIM(model)) - FIM_total = np.sum(FIM_all, axis=0) # FIM of all experiments - cov = np.linalg.inv(FIM_total) # covariance matrix + # Total FIM of experiments + FIM_total = np.sum(FIM_all_exp, axis=0) + + # covariance matrix + cov = np.linalg.inv(FIM_total) cov = pd.DataFrame( cov, index=thetavals.keys(), columns=thetavals.keys() ) + # elif self.obj_function == 'SSE_weighted': + # cov = inv_red_hes + # cov = pd.DataFrame( + # cov, index=thetavals.keys(), columns=thetavals.keys() + # ) else: raise NotImplementedError('Covariance calculation is only supported for SSE and SSE_weighted objectives') From 6f832ca3ad956bc563e0931cced5c32da2509ff3 Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Thu, 17 Apr 2025 18:04:54 -0400 Subject: [PATCH 03/35] Updated parmest.py --- pyomo/contrib/parmest/parmest.py | 47 +++++++++++++++++++------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index 53965b0b283..a5a2ded7836 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -247,12 +247,13 @@ def SSE_weighted(model): return expr # Calculate the sensitivity of measured variables to parameters using central finite difference -def compute_jacobian(model): +def _compute_jacobian(model, relative_perturbation, solver_option="ipopt"): """ - Compute the Jacobian matrix using central finite difference. + Computes the Jacobian matrix using central finite difference scheme Arguments: - model: Pyomo model containing experiment_outputs and measurement_error. + model: Pyomo model containing experiment_outputs and measurement_error + relative_perturbation: value used to perturb the objectives Returns: J: Jacobian matrix @@ -271,23 +272,22 @@ def compute_jacobian(model): # compute the sensitivity of measured variables to the parameters (Jacobian) J = np.zeros((n_outputs, n_params)) - perturbation = 1e-6 # Small perturbation for finite differences for i, param in enumerate(params): # store original value of the parameter orig_value = param_values[i] # Forward perturbation - param.fix(orig_value + perturbation) + param.fix(orig_value + relative_perturbation) # solve model - solver = pyo.SolverFactory('ipopt') + solver = pyo.SolverFactory(solver_option) solver.solve(model) # forward perturbation measured variables y_hat_plus = [pyo.value(y_hat) for y_hat, y in model.experiment_outputs.items()] # Backward perturbation - param.fix(orig_value - perturbation) + param.fix(orig_value - relative_perturbation) # resolve model solver.solve(model) @@ -299,20 +299,21 @@ def compute_jacobian(model): param.fix(orig_value) # Central difference approximation for the Jacobian - J[:, i] = [(y_hat_plus[w] - y_hat_minus[w]) / (2 * perturbation) for w in range(len(y_hat_plus))] + J[:, i] = [(y_hat_plus[w] - y_hat_minus[w]) / (2 * relative_perturbation) for w in range(len(y_hat_plus))] return J # compute the Fisher information matrix of the estimated parameters -def compute_FIM(model): +def compute_FIM(model, relative_perturbation, solver_option="ipopt"): """ - Calculate the Fisher information matrix using the Jacobian and model measurement errors. + Compute the Fisher information matrix from the Jacobian matrix and measurement errors Arguments: - model: Pyomo model containing experiment_outputs and measurement_error. + model: Pyomo model containing the experiment outputs and measurement errors + relative_perturbation: value used to perturb the objectives Returns: - FIM: Fisher information matrix. + FIM: Fisher information matrix """ # extract the measured variables and measurement errors @@ -323,11 +324,19 @@ def compute_FIM(model): if len(error_list) == 0 or len(y_hat_list) == 0: raise ValueError("Experiment outputs and measurement errors cannot be empty.") + # check if the dimension of error_list is same with that of y_hat_list + if len(error_list) != len(y_hat_list): + raise ValueError("Experiment outputs and measurement errors are not the same length.") + # create the weight matrix W (inverse of variance) W = np.diag([1 / (err**2) for err in error_list]) - # calculate Jacobian matrix - J = compute_jacobian(model) + # compute the Jacobian matrix + J = _compute_jacobian(model, relative_perturbation, solver_option) + + # computing the condition number of the Jacobian matrix + cond_number_jac = np.linalg.cond(J) + print("The condition number of the Jacobian matrix is:",cond_number_jac) # calculate the FIM FIM = J.T @ W @ J @@ -681,7 +690,7 @@ def _Q_opt( ''' if self.obj_function == 'SSE': # covariance calculation for measurements in the same unit # get the model - model = self.exp_list[0].get_labeled_model() + model = self.exp_list[0].get_labeled_model().clone() # get the measurement error meas_error = [model.measurement_error[y_hat] for y_hat, y in model.experiment_outputs.items()] @@ -697,7 +706,7 @@ def _Q_opt( cov = pd.DataFrame( cov, index=thetavals.keys(), columns=thetavals.keys() ) - elif self.obj_function == 'SSE_weighted': # covariance calculation for measurements in diff. units + elif self.obj_function == 'SSE_weighted': # Store the FIM of all the experiments FIM_all_exp = [] for experiment in self.exp_list: # loop through the experiments @@ -714,7 +723,7 @@ def _Q_opt( solver.solve(model) # compute the FIM - FIM_all_exp.append(compute_FIM(model)) + FIM_all_exp.append(compute_FIM(model, relative_perturbation=1e-6)) # Total FIM of experiments FIM_total = np.sum(FIM_all_exp, axis=0) @@ -1877,7 +1886,7 @@ def _Q_opt( ''' if self.obj_function == 'SSE': # covariance calculation for measurements in the same unit # get the model - model = self.exp_list[0].get_labeled_model() + model = self.exp_list[0].get_labeled_model().clone() # get the measurement error meas_error = [model.measurement_error[y_hat] for y_hat, y in model.experiment_outputs.items()] @@ -1910,7 +1919,7 @@ def _Q_opt( solver.solve(model) # compute the FIM - FIM_all_exp.append(compute_FIM(model)) + FIM_all_exp.append(compute_FIM(model, relative_perturbation=1e-6)) # Total FIM of experiments FIM_total = np.sum(FIM_all_exp, axis=0) From 14c0f6de5df5699494fe5cc7e696f93e3339d603 Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Fri, 18 Apr 2025 13:33:24 -0400 Subject: [PATCH 04/35] Updated parmest.py --- pyomo/contrib/parmest/parmest.py | 66 +++++++++++++++++++------------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index a5a2ded7836..3324858342c 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -232,7 +232,7 @@ def SSE(model): Sum of squared error between the model prediction of measured variables and data values, assuming Gaussian i.i.d errors. """ - expr = (1 / 2) * sum((y - y_hat) ** 2 for y_hat, y in model.experiment_outputs.items()) + expr = sum((y - y_hat) ** 2 for y_hat, y in model.experiment_outputs.items()) return expr def SSE_weighted(model): @@ -688,21 +688,28 @@ def _Q_opt( the constant cancels out. (was scaled by 1/n because it computes an expected value.) ''' - if self.obj_function == 'SSE': # covariance calculation for measurements in the same unit + if self.obj_function == 'SSE': # get the model - model = self.exp_list[0].get_labeled_model().clone() - - # get the measurement error - meas_error = [model.measurement_error[y_hat] for y_hat, y in model.experiment_outputs.items()] - - # check if the user specifies the measurement error - if all(item is None for item in meas_error): - cov = sse / (n - l) * inv_red_hes # covariance matrix - cov = pd.DataFrame( - cov, index=thetavals.keys(), columns=thetavals.keys() - ) + model = self.exp_list[0].get_labeled_model() + + # covariance matrix if the user defines the measurement errors + if hasattr(model, "measurement_error"): + # get the measurement errors + meas_error = [model.measurement_error[y_hat] for y_hat, y in model.experiment_outputs.items()] + + # check if the user supplied values for the measurement errors + if all(item is None for item in meas_error): + cov = 2 * (sse / (n - l)) * inv_red_hes # covariance matrix + cov = pd.DataFrame( + cov, index=thetavals.keys(), columns=thetavals.keys() + ) + else: + cov = 2 * (meas_error[0] ** 2) * inv_red_hes # covariance matrix + cov = pd.DataFrame( + cov, index=thetavals.keys(), columns=thetavals.keys() + ) else: - cov = meas_error[0] * inv_red_hes # covariance matrix + cov = 2 * (sse / (n - l)) * inv_red_hes # covariance matrix when the measurement errors are not defined cov = pd.DataFrame( cov, index=thetavals.keys(), columns=thetavals.keys() ) @@ -1886,19 +1893,26 @@ def _Q_opt( ''' if self.obj_function == 'SSE': # covariance calculation for measurements in the same unit # get the model - model = self.exp_list[0].get_labeled_model().clone() - - # get the measurement error - meas_error = [model.measurement_error[y_hat] for y_hat, y in model.experiment_outputs.items()] - - # check if the user specifies the measurement error - if all(item is None for item in meas_error): - cov = sse / (n - l) * inv_red_hes # covariance matrix - cov = pd.DataFrame( - cov, index=thetavals.keys(), columns=thetavals.keys() - ) + model = self.exp_list[0].get_labeled_model() + + # covariance matrix if the user defines the measurement errors + if hasattr(model, "measurement_error"): + # get the measurement errors + meas_error = [model.measurement_error[y_hat] for y_hat, y in model.experiment_outputs.items()] + + # check if the user supplied values for the measurement errors + if all(item is None for item in meas_error): + cov = 2 * (sse / (n - l)) * inv_red_hes # covariance matrix + cov = pd.DataFrame( + cov, index=thetavals.keys(), columns=thetavals.keys() + ) + else: + cov = 2 * (meas_error[0] ** 2) * inv_red_hes # covariance matrix + cov = pd.DataFrame( + cov, index=thetavals.keys(), columns=thetavals.keys() + ) else: - cov = meas_error[0] * inv_red_hes # covariance matrix + cov = 2 * (sse / (n - l)) * inv_red_hes # covariance matrix when the measurement errors are not defined cov = pd.DataFrame( cov, index=thetavals.keys(), columns=thetavals.keys() ) From 0c210de7d9d37a74cb072c9545660ef900d1dfc8 Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Wed, 23 Apr 2025 09:56:40 -0400 Subject: [PATCH 05/35] Updated parmest.py file --- pyomo/contrib/parmest/parmest.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index 3324858342c..4ef30352823 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -740,11 +740,6 @@ def _Q_opt( cov = pd.DataFrame( cov, index=thetavals.keys(), columns=thetavals.keys() ) - # elif self.obj_function == 'SSE_weighted': - # cov = inv_red_hes - # cov = pd.DataFrame( - # cov, index=thetavals.keys(), columns=thetavals.keys() - # ) else: raise NotImplementedError('Covariance calculation is only supported for SSE and SSE_weighted objectives') @@ -1943,11 +1938,6 @@ def _Q_opt( cov = pd.DataFrame( cov, index=thetavals.keys(), columns=thetavals.keys() ) - # elif self.obj_function == 'SSE_weighted': - # cov = inv_red_hes - # cov = pd.DataFrame( - # cov, index=thetavals.keys(), columns=thetavals.keys() - # ) else: raise NotImplementedError('Covariance calculation is only supported for SSE and SSE_weighted objectives') From f98590fca4f9eeec8e2f46e4104c050e869d5855 Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Thu, 1 May 2025 13:40:56 -0400 Subject: [PATCH 06/35] Created test for the new capabilities --- .../contrib/parmest/tests/test_parmest_cov.py | 2590 +++++++++++++++++ 1 file changed, 2590 insertions(+) create mode 100644 pyomo/contrib/parmest/tests/test_parmest_cov.py diff --git a/pyomo/contrib/parmest/tests/test_parmest_cov.py b/pyomo/contrib/parmest/tests/test_parmest_cov.py new file mode 100644 index 00000000000..ad7785ff379 --- /dev/null +++ b/pyomo/contrib/parmest/tests/test_parmest_cov.py @@ -0,0 +1,2590 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ________________________________________________________________________ +# ___ + +import platform +import sys +import os +import subprocess +from itertools import product + +import pyomo.common.unittest as unittest +import pyomo.contrib.parmest.parmest as parmest +import pyomo.contrib.parmest.graphics as graphics +import pyomo.contrib.parmest as parmestbase +import pyomo.environ as pyo +import pyomo.dae as dae + +from pyomo.common.dependencies import numpy as np, pandas as pd, scipy, matplotlib +from pyomo.common.fileutils import this_file_dir +from pyomo.contrib.parmest.experiment import Experiment +from pyomo.contrib.pynumero.asl import AmplInterface +from pyomo.opt import SolverFactory + +is_osx = platform.mac_ver()[0] != "" +ipopt_available = SolverFactory("ipopt").available() +pynumero_ASL_available = AmplInterface.available() +testdir = this_file_dir() + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) + +# Test class for when the user wants to use the built-in Parmest SSE objective function +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestRooneyBieglerSSE(unittest.TestCase): + def setUp(self): + from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import ( + RooneyBieglerExperiment, + ) + + # Note, the data used in this test has been corrected to use + # data.loc[5,'hour'] = 7 (instead of 6) + data = pd.DataFrame( + data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], + columns=["hour", "y"], + ) + + # Create an experiment list + exp_list = [] + for i in range(data.shape[0]): + exp_list.append(RooneyBieglerExperiment(data.loc[i, :])) + + # Create an instance of the parmest estimator + pest = parmest.Estimator(exp_list, obj_function="SSE") + + solver_options = {"tol": 1e-8} + + self.data = data + self.pest = parmest.Estimator( + exp_list, obj_function="SSE", solver_options=solver_options, tee=True + ) + + def test_theta_est(self): + objval, thetavals = self.pest.theta_est() + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + @unittest.skipIf( + not graphics.imports_available, "parmest.graphics imports are unavailable" + ) + def test_bootstrap(self): + objval, thetavals = self.pest.theta_est() + + num_bootstraps = 10 + theta_est = self.pest.theta_est_bootstrap(num_bootstraps, return_samples=True) + + num_samples = theta_est["samples"].apply(len) + self.assertEqual(len(theta_est.index), 10) + self.assertTrue(num_samples.equals(pd.Series([6] * 10))) + + del theta_est["samples"] + + # apply confidence region test + CR = self.pest.confidence_region_test(theta_est, "MVN", [0.5, 0.75, 1.0]) + + self.assertTrue(set(CR.columns) >= set([0.5, 0.75, 1.0])) + self.assertEqual(CR[0.5].sum(), 5) + self.assertEqual(CR[0.75].sum(), 7) + self.assertEqual(CR[1.0].sum(), 10) # all true + + graphics.pairwise_plot(theta_est) + graphics.pairwise_plot(theta_est, thetavals) + graphics.pairwise_plot(theta_est, thetavals, 0.8, ["MVN", "KDE", "Rect"]) + + @unittest.skipIf( + not graphics.imports_available, "parmest.graphics imports are unavailable" + ) + def test_likelihood_ratio(self): + objval, thetavals = self.pest.theta_est() + + asym = np.arange(10, 30, 2) + rate = np.arange(0, 1.5, 0.25) + theta_vals = pd.DataFrame( + list(product(asym, rate)), columns=['asymptote', 'rate_constant'] + ) + obj_at_theta = self.pest.objective_at_theta(theta_vals) + + LR = self.pest.likelihood_ratio_test(obj_at_theta, objval, [0.8, 0.9, 1.0]) + + self.assertTrue(set(LR.columns) >= set([0.8, 0.9, 1.0])) + self.assertEqual(LR[0.8].sum(), 6) + self.assertEqual(LR[0.9].sum(), 10) + self.assertEqual(LR[1.0].sum(), 60) # all true + + graphics.pairwise_plot(LR, thetavals, 0.8) + + def test_leaveNout(self): + lNo_theta = self.pest.theta_est_leaveNout(1) + self.assertTrue(lNo_theta.shape == (6, 2)) + + results = self.pest.leaveNout_bootstrap_test( + 1, None, 3, "Rect", [0.5, 1.0], seed=5436 + ) + self.assertEqual(len(results), 6) # 6 lNo samples + i = 1 + samples = results[i][0] # list of N samples that are left out + lno_theta = results[i][1] + bootstrap_theta = results[i][2] + self.assertTrue(samples == [1]) # sample 1 was left out + self.assertEqual(lno_theta.shape[0], 1) # lno estimate for sample 1 + self.assertTrue(set(lno_theta.columns) >= set([0.5, 1.0])) + self.assertEqual(lno_theta[1.0].sum(), 1) # all true + self.assertEqual(bootstrap_theta.shape[0], 3) # bootstrap for sample 1 + self.assertEqual(bootstrap_theta[1.0].sum(), 3) # all true + + def test_diagnostic_mode(self): + self.pest.diagnostic_mode = True + + objval, thetavals = self.pest.theta_est() + + asym = np.arange(10, 30, 2) + rate = np.arange(0, 1.5, 0.25) + theta_vals = pd.DataFrame( + list(product(asym, rate)), columns=['asymptote', 'rate_constant'] + ) + + obj_at_theta = self.pest.objective_at_theta(theta_vals) + + self.pest.diagnostic_mode = False + + @unittest.skip("Presently having trouble with mpiexec on appveyor") + def test_parallel_parmest(self): + """use mpiexec and mpi4py""" + p = str(parmestbase.__path__) + l = p.find("'") + r = p.find("'", l + 1) + parmestpath = p[l + 1 : r] + rbpath = ( + parmestpath + + os.sep + + "examples" + + os.sep + + "rooney_biegler" + + os.sep + + "rooney_biegler_parmest.py" + ) + rbpath = os.path.abspath(rbpath) # paranoia strikes deep... + rlist = ["mpiexec", "--allow-run-as-root", "-n", "2", sys.executable, rbpath] + if sys.version_info >= (3, 5): + ret = subprocess.run(rlist) + retcode = ret.returncode + else: + retcode = subprocess.call(rlist) + self.assertEqual(retcode, 0) + + # @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") + def test_theta_est_cov(self): + objval, thetavals, cov = self.pest.theta_est(calc_cov=True, cov_n=6) + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + # Covariance matrix + self.assertAlmostEqual( + cov["asymptote"]["asymptote"], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov["asymptote"]["rate_constant"], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov["rate_constant"]["asymptote"], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov["rate_constant"]["rate_constant"], 0.04124, places=2 + ) # 0.04124 from paper + + """ Why does the covariance matrix from parmest not match the paper? Parmest is + calculating the exact reduced Hessian. The paper (Rooney and Bielger, 2001) likely + employed the first order approximation common for nonlinear regression. The paper + values were verified with Scipy, which uses the same first order approximation. + The formula used in parmest was verified against equations (7-5-15) and (7-5-16) in + "Nonlinear Parameter Estimation", Y. Bard, 1974. + """ + + def test_cov_scipy_least_squares_comparison(self): + """ + Scipy results differ in the 3rd decimal place from the paper. It is possible + the paper used an alternative finite difference approximation for the Jacobian. + """ + + def model(theta, t): + """ + Model to be fitted y = model(theta, t) + Arguments: + theta: vector of fitted parameters + t: independent variable [hours] + + Returns: + y: model predictions [need to check paper for units] + """ + asymptote = theta[0] + rate_constant = theta[1] + + return asymptote * (1 - np.exp(-rate_constant * t)) + + def residual(theta, t, y): + """ + Calculate residuals + Arguments: + theta: vector of fitted parameters + t: independent variable [hours] + y: dependent variable [?] + """ + return y - model(theta, t) + + # define data + t = self.data["hour"].to_numpy() + y = self.data["y"].to_numpy() + + # define initial guess + theta_guess = np.array([15, 0.5]) + + ## solve with optimize.least_squares + sol = scipy.optimize.least_squares( + residual, theta_guess, method="trf", args=(t, y), verbose=2 + ) + theta_hat = sol.x + + self.assertAlmostEqual( + theta_hat[0], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper + + # calculate residuals + r = residual(theta_hat, t, y) + + # calculate variance of the residuals + # -2 because there are 2 fitted parameters + sigre = np.matmul(r.T, r / (len(y) - 2)) + + # approximate covariance + # Need to divide by 2 because optimize.least_squares scaled the objective by 1/2 + cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) + + self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper + self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + + def test_cov_scipy_curve_fit_comparison(self): + """ + Scipy results differ in the 3rd decimal place from the paper. It is possible + the paper used an alternative finite difference approximation for the Jacobian. + """ + + ## solve with optimize.curve_fit + def model(t, asymptote, rate_constant): + return asymptote * (1 - np.exp(-rate_constant * t)) + + # define data + t = self.data["hour"].to_numpy() + y = self.data["y"].to_numpy() + + # define initial guess + theta_guess = np.array([15, 0.5]) + + theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess) + + self.assertAlmostEqual( + theta_hat[0], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper + + self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper + self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + +# Test class for when the user wants to use the built-in Parmest SSE_weighted objective function +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestRooneyBieglerWSSE(unittest.TestCase): + def setUp(self): + from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import ( + RooneyBieglerExperiment, + ) + + # Note, the data used in this test has been corrected to use + # data.loc[5,'hour'] = 7 (instead of 6) + data = pd.DataFrame( + data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], + columns=["hour", "y"], + ) + + # Create an experiment list + exp_list = [] + for i in range(data.shape[0]): + exp_list.append(RooneyBieglerExperiment(data.loc[i, :])) + + # Create an instance of the parmest estimator + pest = parmest.Estimator(exp_list, obj_function="SSE_weighted") + + solver_options = {"tol": 1e-8} + + self.data = data + self.pest = parmest.Estimator( + exp_list, obj_function="SSE_weighted", solver_options=solver_options, tee=True + ) + + def test_theta_est(self): + objval, thetavals = self.pest.theta_est() + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + @unittest.skipIf( + not graphics.imports_available, "parmest.graphics imports are unavailable" + ) + def test_bootstrap(self): + objval, thetavals = self.pest.theta_est() + + num_bootstraps = 10 + theta_est = self.pest.theta_est_bootstrap(num_bootstraps, return_samples=True) + + num_samples = theta_est["samples"].apply(len) + self.assertEqual(len(theta_est.index), 10) + self.assertTrue(num_samples.equals(pd.Series([6] * 10))) + + del theta_est["samples"] + + # apply confidence region test + CR = self.pest.confidence_region_test(theta_est, "MVN", [0.5, 0.75, 1.0]) + + self.assertTrue(set(CR.columns) >= set([0.5, 0.75, 1.0])) + self.assertEqual(CR[0.5].sum(), 5) + self.assertEqual(CR[0.75].sum(), 7) + self.assertEqual(CR[1.0].sum(), 10) # all true + + graphics.pairwise_plot(theta_est) + graphics.pairwise_plot(theta_est, thetavals) + graphics.pairwise_plot(theta_est, thetavals, 0.8, ["MVN", "KDE", "Rect"]) + + @unittest.skipIf( + not graphics.imports_available, "parmest.graphics imports are unavailable" + ) + def test_likelihood_ratio(self): + objval, thetavals = self.pest.theta_est() + + asym = np.arange(10, 30, 2) + rate = np.arange(0, 1.5, 0.25) + theta_vals = pd.DataFrame( + list(product(asym, rate)), columns=['asymptote', 'rate_constant'] + ) + obj_at_theta = self.pest.objective_at_theta(theta_vals) + + LR = self.pest.likelihood_ratio_test(obj_at_theta, objval, [0.8, 0.9, 1.0]) + + self.assertTrue(set(LR.columns) >= set([0.8, 0.9, 1.0])) + self.assertEqual(LR[0.8].sum(), 6) + self.assertEqual(LR[0.9].sum(), 10) + self.assertEqual(LR[1.0].sum(), 60) # all true + + graphics.pairwise_plot(LR, thetavals, 0.8) + + def test_leaveNout(self): + lNo_theta = self.pest.theta_est_leaveNout(1) + self.assertTrue(lNo_theta.shape == (6, 2)) + + results = self.pest.leaveNout_bootstrap_test( + 1, None, 3, "Rect", [0.5, 1.0], seed=5436 + ) + self.assertEqual(len(results), 6) # 6 lNo samples + i = 1 + samples = results[i][0] # list of N samples that are left out + lno_theta = results[i][1] + bootstrap_theta = results[i][2] + self.assertTrue(samples == [1]) # sample 1 was left out + self.assertEqual(lno_theta.shape[0], 1) # lno estimate for sample 1 + self.assertTrue(set(lno_theta.columns) >= set([0.5, 1.0])) + self.assertEqual(lno_theta[1.0].sum(), 1) # all true + self.assertEqual(bootstrap_theta.shape[0], 3) # bootstrap for sample 1 + self.assertEqual(bootstrap_theta[1.0].sum(), 3) # all true + + def test_diagnostic_mode(self): + self.pest.diagnostic_mode = True + + objval, thetavals = self.pest.theta_est() + + asym = np.arange(10, 30, 2) + rate = np.arange(0, 1.5, 0.25) + theta_vals = pd.DataFrame( + list(product(asym, rate)), columns=['asymptote', 'rate_constant'] + ) + + obj_at_theta = self.pest.objective_at_theta(theta_vals) + + self.pest.diagnostic_mode = False + + @unittest.skip("Presently having trouble with mpiexec on appveyor") + def test_parallel_parmest(self): + """use mpiexec and mpi4py""" + p = str(parmestbase.__path__) + l = p.find("'") + r = p.find("'", l + 1) + parmestpath = p[l + 1 : r] + rbpath = ( + parmestpath + + os.sep + + "examples" + + os.sep + + "rooney_biegler" + + os.sep + + "rooney_biegler_parmest.py" + ) + rbpath = os.path.abspath(rbpath) # paranoia strikes deep... + rlist = ["mpiexec", "--allow-run-as-root", "-n", "2", sys.executable, rbpath] + if sys.version_info >= (3, 5): + ret = subprocess.run(rlist) + retcode = ret.returncode + else: + retcode = subprocess.call(rlist) + self.assertEqual(retcode, 0) + + # @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") + def test_theta_est_cov(self): + objval, thetavals, cov = self.pest.theta_est(calc_cov=True, cov_n=6) + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + # Covariance matrix + self.assertAlmostEqual( + cov["asymptote"]["asymptote"], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov["asymptote"]["rate_constant"], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov["rate_constant"]["asymptote"], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov["rate_constant"]["rate_constant"], 0.04124, places=2 + ) # 0.04124 from paper + + """ Why does the covariance matrix from parmest not match the paper? Parmest is + calculating the exact reduced Hessian. The paper (Rooney and Bielger, 2001) likely + employed the first order approximation common for nonlinear regression. The paper + values were verified with Scipy, which uses the same first order approximation. + The formula used in parmest was verified against equations (7-5-15) and (7-5-16) in + "Nonlinear Parameter Estimation", Y. Bard, 1974. + """ + + def test_cov_scipy_least_squares_comparison(self): + """ + Scipy results differ in the 3rd decimal place from the paper. It is possible + the paper used an alternative finite difference approximation for the Jacobian. + """ + + def model(theta, t): + """ + Model to be fitted y = model(theta, t) + Arguments: + theta: vector of fitted parameters + t: independent variable [hours] + + Returns: + y: model predictions [need to check paper for units] + """ + asymptote = theta[0] + rate_constant = theta[1] + + return asymptote * (1 - np.exp(-rate_constant * t)) + + def residual(theta, t, y): + """ + Calculate residuals + Arguments: + theta: vector of fitted parameters + t: independent variable [hours] + y: dependent variable [?] + """ + return y - model(theta, t) + + # define data + t = self.data["hour"].to_numpy() + y = self.data["y"].to_numpy() + + # define initial guess + theta_guess = np.array([15, 0.5]) + + ## solve with optimize.least_squares + sol = scipy.optimize.least_squares( + residual, theta_guess, method="trf", args=(t, y), verbose=2 + ) + theta_hat = sol.x + + self.assertAlmostEqual( + theta_hat[0], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper + + # calculate residuals + r = residual(theta_hat, t, y) + + # calculate variance of the residuals + # -2 because there are 2 fitted parameters + sigre = np.matmul(r.T, r / (len(y) - 2)) + + # approximate covariance + # Need to divide by 2 because optimize.least_squares scaled the objective by 1/2 + cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) + + self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper + self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + + def test_cov_scipy_curve_fit_comparison(self): + """ + Scipy results differ in the 3rd decimal place from the paper. It is possible + the paper used an alternative finite difference approximation for the Jacobian. + """ + + ## solve with optimize.curve_fit + def model(t, asymptote, rate_constant): + return asymptote * (1 - np.exp(-rate_constant * t)) + + # define data + t = self.data["hour"].to_numpy() + y = self.data["y"].to_numpy() + + # define initial guess + theta_guess = np.array([15, 0.5]) + + theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess) + + self.assertAlmostEqual( + theta_hat[0], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper + + self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper + self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + +# # Test class for when the user supply their SSE objective function +# @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +# class TestRooneyBiegler(unittest.TestCase): +# def setUp(self): +# from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import ( +# RooneyBieglerExperiment, +# ) +# +# # Note, the data used in this test has been corrected to use +# # data.loc[5,'hour'] = 7 (instead of 6) +# data = pd.DataFrame( +# data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], +# columns=["hour", "y"], +# ) +# +# # Sum of squared error function +# def SSE(model): +# expr = ( +# model.experiment_outputs[model.y] +# - model.response_function[model.experiment_outputs[model.hour]] +# ) ** 2 +# return expr +# +# # Create an experiment list +# exp_list = [] +# for i in range(data.shape[0]): +# exp_list.append(RooneyBieglerExperiment(data.loc[i, :])) +# +# # Create an instance of the parmest estimator +# pest = parmest.Estimator(exp_list, obj_function=SSE) +# +# solver_options = {"tol": 1e-8} +# +# self.data = data +# self.pest = parmest.Estimator( +# exp_list, obj_function=SSE, solver_options=solver_options, tee=True +# ) +# +# def test_theta_est(self): +# objval, thetavals = self.pest.theta_est() +# +# self.assertAlmostEqual(objval, 4.3317112, places=2) +# self.assertAlmostEqual( +# thetavals["asymptote"], 19.1426, places=2 +# ) # 19.1426 from the paper +# self.assertAlmostEqual( +# thetavals["rate_constant"], 0.5311, places=2 +# ) # 0.5311 from the paper +# +# @unittest.skipIf( +# not graphics.imports_available, "parmest.graphics imports are unavailable" +# ) +# def test_bootstrap(self): +# objval, thetavals = self.pest.theta_est() +# +# num_bootstraps = 10 +# theta_est = self.pest.theta_est_bootstrap(num_bootstraps, return_samples=True) +# +# num_samples = theta_est["samples"].apply(len) +# self.assertEqual(len(theta_est.index), 10) +# self.assertTrue(num_samples.equals(pd.Series([6] * 10))) +# +# del theta_est["samples"] +# +# # apply confidence region test +# CR = self.pest.confidence_region_test(theta_est, "MVN", [0.5, 0.75, 1.0]) +# +# self.assertTrue(set(CR.columns) >= set([0.5, 0.75, 1.0])) +# self.assertEqual(CR[0.5].sum(), 5) +# self.assertEqual(CR[0.75].sum(), 7) +# self.assertEqual(CR[1.0].sum(), 10) # all true +# +# graphics.pairwise_plot(theta_est) +# graphics.pairwise_plot(theta_est, thetavals) +# graphics.pairwise_plot(theta_est, thetavals, 0.8, ["MVN", "KDE", "Rect"]) +# +# @unittest.skipIf( +# not graphics.imports_available, "parmest.graphics imports are unavailable" +# ) +# def test_likelihood_ratio(self): +# objval, thetavals = self.pest.theta_est() +# +# asym = np.arange(10, 30, 2) +# rate = np.arange(0, 1.5, 0.25) +# theta_vals = pd.DataFrame( +# list(product(asym, rate)), columns=['asymptote', 'rate_constant'] +# ) +# obj_at_theta = self.pest.objective_at_theta(theta_vals) +# +# LR = self.pest.likelihood_ratio_test(obj_at_theta, objval, [0.8, 0.9, 1.0]) +# +# self.assertTrue(set(LR.columns) >= set([0.8, 0.9, 1.0])) +# self.assertEqual(LR[0.8].sum(), 6) +# self.assertEqual(LR[0.9].sum(), 10) +# self.assertEqual(LR[1.0].sum(), 60) # all true +# +# graphics.pairwise_plot(LR, thetavals, 0.8) +# +# def test_leaveNout(self): +# lNo_theta = self.pest.theta_est_leaveNout(1) +# self.assertTrue(lNo_theta.shape == (6, 2)) +# +# results = self.pest.leaveNout_bootstrap_test( +# 1, None, 3, "Rect", [0.5, 1.0], seed=5436 +# ) +# self.assertEqual(len(results), 6) # 6 lNo samples +# i = 1 +# samples = results[i][0] # list of N samples that are left out +# lno_theta = results[i][1] +# bootstrap_theta = results[i][2] +# self.assertTrue(samples == [1]) # sample 1 was left out +# self.assertEqual(lno_theta.shape[0], 1) # lno estimate for sample 1 +# self.assertTrue(set(lno_theta.columns) >= set([0.5, 1.0])) +# self.assertEqual(lno_theta[1.0].sum(), 1) # all true +# self.assertEqual(bootstrap_theta.shape[0], 3) # bootstrap for sample 1 +# self.assertEqual(bootstrap_theta[1.0].sum(), 3) # all true +# +# def test_diagnostic_mode(self): +# self.pest.diagnostic_mode = True +# +# objval, thetavals = self.pest.theta_est() +# +# asym = np.arange(10, 30, 2) +# rate = np.arange(0, 1.5, 0.25) +# theta_vals = pd.DataFrame( +# list(product(asym, rate)), columns=['asymptote', 'rate_constant'] +# ) +# +# obj_at_theta = self.pest.objective_at_theta(theta_vals) +# +# self.pest.diagnostic_mode = False +# +# @unittest.skip("Presently having trouble with mpiexec on appveyor") +# def test_parallel_parmest(self): +# """use mpiexec and mpi4py""" +# p = str(parmestbase.__path__) +# l = p.find("'") +# r = p.find("'", l + 1) +# parmestpath = p[l + 1 : r] +# rbpath = ( +# parmestpath +# + os.sep +# + "examples" +# + os.sep +# + "rooney_biegler" +# + os.sep +# + "rooney_biegler_parmest.py" +# ) +# rbpath = os.path.abspath(rbpath) # paranoia strikes deep... +# rlist = ["mpiexec", "--allow-run-as-root", "-n", "2", sys.executable, rbpath] +# if sys.version_info >= (3, 5): +# ret = subprocess.run(rlist) +# retcode = ret.returncode +# else: +# retcode = subprocess.call(rlist) +# self.assertEqual(retcode, 0) +# +# # @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") +# def test_theta_est_cov(self): +# objval, thetavals, cov = self.pest.theta_est(calc_cov=True, cov_n=6) +# +# self.assertAlmostEqual(objval, 4.3317112, places=2) +# self.assertAlmostEqual( +# thetavals["asymptote"], 19.1426, places=2 +# ) # 19.1426 from the paper +# self.assertAlmostEqual( +# thetavals["rate_constant"], 0.5311, places=2 +# ) # 0.5311 from the paper +# +# # Covariance matrix +# self.assertAlmostEqual( +# cov["asymptote"]["asymptote"], 6.30579403, places=2 +# ) # 6.22864 from paper +# self.assertAlmostEqual( +# cov["asymptote"]["rate_constant"], -0.4395341, places=2 +# ) # -0.4322 from paper +# self.assertAlmostEqual( +# cov["rate_constant"]["asymptote"], -0.4395341, places=2 +# ) # -0.4322 from paper +# self.assertAlmostEqual( +# cov["rate_constant"]["rate_constant"], 0.04124, places=2 +# ) # 0.04124 from paper +# +# """ Why does the covariance matrix from parmest not match the paper? Parmest is +# calculating the exact reduced Hessian. The paper (Rooney and Bielger, 2001) likely +# employed the first order approximation common for nonlinear regression. The paper +# values were verified with Scipy, which uses the same first order approximation. +# The formula used in parmest was verified against equations (7-5-15) and (7-5-16) in +# "Nonlinear Parameter Estimation", Y. Bard, 1974. +# """ +# +# def test_cov_scipy_least_squares_comparison(self): +# """ +# Scipy results differ in the 3rd decimal place from the paper. It is possible +# the paper used an alternative finite difference approximation for the Jacobian. +# """ +# +# def model(theta, t): +# """ +# Model to be fitted y = model(theta, t) +# Arguments: +# theta: vector of fitted parameters +# t: independent variable [hours] +# +# Returns: +# y: model predictions [need to check paper for units] +# """ +# asymptote = theta[0] +# rate_constant = theta[1] +# +# return asymptote * (1 - np.exp(-rate_constant * t)) +# +# def residual(theta, t, y): +# """ +# Calculate residuals +# Arguments: +# theta: vector of fitted parameters +# t: independent variable [hours] +# y: dependent variable [?] +# """ +# return y - model(theta, t) +# +# # define data +# t = self.data["hour"].to_numpy() +# y = self.data["y"].to_numpy() +# +# # define initial guess +# theta_guess = np.array([15, 0.5]) +# +# ## solve with optimize.least_squares +# sol = scipy.optimize.least_squares( +# residual, theta_guess, method="trf", args=(t, y), verbose=2 +# ) +# theta_hat = sol.x +# +# self.assertAlmostEqual( +# theta_hat[0], 19.1426, places=2 +# ) # 19.1426 from the paper +# self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper +# +# # calculate residuals +# r = residual(theta_hat, t, y) +# +# # calculate variance of the residuals +# # -2 because there are 2 fitted parameters +# sigre = np.matmul(r.T, r / (len(y) - 2)) +# +# # approximate covariance +# # Need to divide by 2 because optimize.least_squares scaled the objective by 1/2 +# cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) +# +# self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper +# self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper +# self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper +# self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper +# +# def test_cov_scipy_curve_fit_comparison(self): +# """ +# Scipy results differ in the 3rd decimal place from the paper. It is possible +# the paper used an alternative finite difference approximation for the Jacobian. +# """ +# +# ## solve with optimize.curve_fit +# def model(t, asymptote, rate_constant): +# return asymptote * (1 - np.exp(-rate_constant * t)) +# +# # define data +# t = self.data["hour"].to_numpy() +# y = self.data["y"].to_numpy() +# +# # define initial guess +# theta_guess = np.array([15, 0.5]) +# +# theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess) +# +# self.assertAlmostEqual( +# theta_hat[0], 19.1426, places=2 +# ) # 19.1426 from the paper +# self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper +# +# self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper +# self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper +# self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper +# self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestModelVariants(unittest.TestCase): + + def setUp(self): + from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import ( + RooneyBieglerExperiment, + ) + + self.data = pd.DataFrame( + data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], + columns=["hour", "y"], + ) + + def rooney_biegler_params(data): + model = pyo.ConcreteModel() + + model.asymptote = pyo.Param(initialize=15, mutable=True) + model.rate_constant = pyo.Param(initialize=0.5, mutable=True) + + model.hour = pyo.Param(within=pyo.PositiveReals, mutable=True) + model.y = pyo.Param(within=pyo.PositiveReals, mutable=True) + + def response_rule(m, h): + expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) + return expr + + model.response_function = pyo.Expression(data.hour, rule=response_rule) + + return model + + class RooneyBieglerExperimentParams(RooneyBieglerExperiment): + + def create_model(self): + data_df = self.data.to_frame().transpose() + self.model = rooney_biegler_params(data_df) + + rooney_biegler_params_exp_list = [] + for i in range(self.data.shape[0]): + rooney_biegler_params_exp_list.append( + RooneyBieglerExperimentParams(self.data.loc[i, :]) + ) + + def rooney_biegler_indexed_params(data): + model = pyo.ConcreteModel() + + model.param_names = pyo.Set(initialize=["asymptote", "rate_constant"]) + model.theta = pyo.Param( + model.param_names, + initialize={"asymptote": 15, "rate_constant": 0.5}, + mutable=True, + ) + + model.hour = pyo.Param(within=pyo.PositiveReals, mutable=True) + model.y = pyo.Param(within=pyo.PositiveReals, mutable=True) + + def response_rule(m, h): + expr = m.theta["asymptote"] * ( + 1 - pyo.exp(-m.theta["rate_constant"] * h) + ) + return expr + + model.response_function = pyo.Expression(data.hour, rule=response_rule) + + return model + + class RooneyBieglerExperimentIndexedParams(RooneyBieglerExperiment): + + def create_model(self): + data_df = self.data.to_frame().transpose() + self.model = rooney_biegler_indexed_params(data_df) + + def label_model(self): + + m = self.model + + m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.experiment_outputs.update( + [(m.hour, self.data["hour"]), (m.y, self.data["y"])] + ) + + m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.unknown_parameters.update((k, pyo.ComponentUID(k)) for k in [m.theta]) + + rooney_biegler_indexed_params_exp_list = [] + for i in range(self.data.shape[0]): + rooney_biegler_indexed_params_exp_list.append( + RooneyBieglerExperimentIndexedParams(self.data.loc[i, :]) + ) + + def rooney_biegler_vars(data): + model = pyo.ConcreteModel() + + model.asymptote = pyo.Var(initialize=15) + model.rate_constant = pyo.Var(initialize=0.5) + model.asymptote.fixed = True # parmest will unfix theta variables + model.rate_constant.fixed = True + + model.hour = pyo.Param(within=pyo.PositiveReals, mutable=True) + model.y = pyo.Param(within=pyo.PositiveReals, mutable=True) + + def response_rule(m, h): + expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) + return expr + + model.response_function = pyo.Expression(data.hour, rule=response_rule) + + return model + + class RooneyBieglerExperimentVars(RooneyBieglerExperiment): + + def create_model(self): + data_df = self.data.to_frame().transpose() + self.model = rooney_biegler_vars(data_df) + + rooney_biegler_vars_exp_list = [] + for i in range(self.data.shape[0]): + rooney_biegler_vars_exp_list.append( + RooneyBieglerExperimentVars(self.data.loc[i, :]) + ) + + def rooney_biegler_indexed_vars(data): + model = pyo.ConcreteModel() + + model.var_names = pyo.Set(initialize=["asymptote", "rate_constant"]) + model.theta = pyo.Var( + model.var_names, initialize={"asymptote": 15, "rate_constant": 0.5} + ) + model.theta["asymptote"].fixed = ( + True # parmest will unfix theta variables, even when they are indexed + ) + model.theta["rate_constant"].fixed = True + + model.hour = pyo.Param(within=pyo.PositiveReals, mutable=True) + model.y = pyo.Param(within=pyo.PositiveReals, mutable=True) + + def response_rule(m, h): + expr = m.theta["asymptote"] * ( + 1 - pyo.exp(-m.theta["rate_constant"] * h) + ) + return expr + + model.response_function = pyo.Expression(data.hour, rule=response_rule) + + return model + + class RooneyBieglerExperimentIndexedVars(RooneyBieglerExperiment): + + def create_model(self): + data_df = self.data.to_frame().transpose() + self.model = rooney_biegler_indexed_vars(data_df) + + def label_model(self): + + m = self.model + + m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.experiment_outputs.update( + [(m.hour, self.data["hour"]), (m.y, self.data["y"])] + ) + + m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.unknown_parameters.update((k, pyo.ComponentUID(k)) for k in [m.theta]) + + rooney_biegler_indexed_vars_exp_list = [] + for i in range(self.data.shape[0]): + rooney_biegler_indexed_vars_exp_list.append( + RooneyBieglerExperimentIndexedVars(self.data.loc[i, :]) + ) + + # # Sum of squared error function + # def SSE(model): + # expr = ( + # model.experiment_outputs[model.y] + # - model.response_function[model.experiment_outputs[model.hour]] + # ) ** 2 + # return expr + # + # self.objective_function = SSE + self.objective_function = "SSE" + + theta_vals = pd.DataFrame([20, 1], index=["asymptote", "rate_constant"]).T + theta_vals_index = pd.DataFrame( + [20, 1], index=["theta['asymptote']", "theta['rate_constant']"] + ).T + + self.input = { + "param": { + "exp_list": rooney_biegler_params_exp_list, + "theta_names": ["asymptote", "rate_constant"], + "theta_vals": theta_vals, + }, + "param_index": { + "exp_list": rooney_biegler_indexed_params_exp_list, + "theta_names": ["theta"], + "theta_vals": theta_vals_index, + }, + "vars": { + "exp_list": rooney_biegler_vars_exp_list, + "theta_names": ["asymptote", "rate_constant"], + "theta_vals": theta_vals, + }, + "vars_index": { + "exp_list": rooney_biegler_indexed_vars_exp_list, + "theta_names": ["theta"], + "theta_vals": theta_vals_index, + }, + "vars_quoted_index": { + "exp_list": rooney_biegler_indexed_vars_exp_list, + "theta_names": ["theta['asymptote']", "theta['rate_constant']"], + "theta_vals": theta_vals_index, + }, + "vars_str_index": { + "exp_list": rooney_biegler_indexed_vars_exp_list, + "theta_names": ["theta[asymptote]", "theta[rate_constant]"], + "theta_vals": theta_vals_index, + }, + } + + @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") + def check_rooney_biegler_results(self, objval, cov): + + # get indices in covariance matrix + cov_cols = cov.columns.to_list() + asymptote_index = [idx for idx, s in enumerate(cov_cols) if "asymptote" in s][0] + rate_constant_index = [ + idx for idx, s in enumerate(cov_cols) if "rate_constant" in s + ][0] + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[asymptote_index, asymptote_index], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[asymptote_index, rate_constant_index], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, asymptote_index], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, rate_constant_index], 0.04193591, places=2 + ) # 0.04124 from paper + + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') + def test_parmest_basics(self): + + for model_type, parmest_input in self.input.items(): + pest = parmest.Estimator( + parmest_input["exp_list"], obj_function=self.objective_function + ) + + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + self.check_rooney_biegler_results(objval, cov) + + obj_at_theta = pest.objective_at_theta(parmest_input["theta_vals"]) + self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') + def test_parmest_basics_with_initialize_parmest_model_option(self): + + for model_type, parmest_input in self.input.items(): + pest = parmest.Estimator( + parmest_input["exp_list"], obj_function=self.objective_function + ) + + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + self.check_rooney_biegler_results(objval, cov) + + obj_at_theta = pest.objective_at_theta( + parmest_input["theta_vals"], initialize_parmest_model=True + ) + + self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') + def test_parmest_basics_with_square_problem_solve(self): + + for model_type, parmest_input in self.input.items(): + pest = parmest.Estimator( + parmest_input["exp_list"], obj_function=self.objective_function + ) + + obj_at_theta = pest.objective_at_theta( + parmest_input["theta_vals"], initialize_parmest_model=True + ) + + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + self.check_rooney_biegler_results(objval, cov) + + self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') + def test_parmest_basics_with_square_problem_solve_no_theta_vals(self): + + for model_type, parmest_input in self.input.items(): + + pest = parmest.Estimator( + parmest_input["exp_list"], obj_function=self.objective_function + ) + + obj_at_theta = pest.objective_at_theta(initialize_parmest_model=True) + + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + self.check_rooney_biegler_results(objval, cov) + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' solver is not available") +class TestReactorDesign(unittest.TestCase): + def setUp(self): + from pyomo.contrib.parmest.examples.reactor_design.reactor_design import ( + ReactorDesignExperiment, + ) + + # Data from the design + data = pd.DataFrame( + data=[ + [1.05, 10000, 3458.4, 1060.8, 1683.9, 1898.5], + [1.10, 10000, 3535.1, 1064.8, 1613.3, 1893.4], + [1.15, 10000, 3609.1, 1067.8, 1547.5, 1887.8], + [1.20, 10000, 3680.7, 1070.0, 1486.1, 1881.6], + [1.25, 10000, 3750.0, 1071.4, 1428.6, 1875.0], + [1.30, 10000, 3817.1, 1072.2, 1374.6, 1868.0], + [1.35, 10000, 3882.2, 1072.4, 1324.0, 1860.7], + [1.40, 10000, 3945.4, 1072.1, 1276.3, 1853.1], + [1.45, 10000, 4006.7, 1071.3, 1231.4, 1845.3], + [1.50, 10000, 4066.4, 1070.1, 1189.0, 1837.3], + [1.55, 10000, 4124.4, 1068.5, 1148.9, 1829.1], + [1.60, 10000, 4180.9, 1066.5, 1111.0, 1820.8], + [1.65, 10000, 4235.9, 1064.3, 1075.0, 1812.4], + [1.70, 10000, 4289.5, 1061.8, 1040.9, 1803.9], + [1.75, 10000, 4341.8, 1059.0, 1008.5, 1795.3], + [1.80, 10000, 4392.8, 1056.0, 977.7, 1786.7], + [1.85, 10000, 4442.6, 1052.8, 948.4, 1778.1], + [1.90, 10000, 4491.3, 1049.4, 920.5, 1769.4], + [1.95, 10000, 4538.8, 1045.8, 893.9, 1760.8], + ], + columns=["sv", "caf", "ca", "cb", "cc", "cd"], + ) + + # Create an experiment list + exp_list = [] + for i in range(data.shape[0]): + exp_list.append(ReactorDesignExperiment(data, i)) + + solver_options = {"max_iter": 6000} + + self.pest = parmest.Estimator( + exp_list, obj_function="SSE", solver_options=solver_options + ) + + def test_theta_est(self): + # used in data reconciliation + objval, thetavals = self.pest.theta_est() + + self.assertAlmostEqual(thetavals["k1"], 5.0 / 6.0, places=4) + self.assertAlmostEqual(thetavals["k2"], 5.0 / 3.0, places=4) + self.assertAlmostEqual(thetavals["k3"], 1.0 / 6000.0, places=7) + + def test_return_values(self): + objval, thetavals, data_rec = self.pest.theta_est( + return_values=["ca", "cb", "cc", "cd", "caf"] + ) + self.assertAlmostEqual(data_rec["cc"].loc[18], 893.84924, places=3) + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' solver is not available") +class TestReactorDesign_DAE(unittest.TestCase): + # Based on a reactor example in `Chemical Reactor Analysis and Design Fundamentals`, + # https://sites.engineering.ucsb.edu/~jbraw/chemreacfun/ + # https://sites.engineering.ucsb.edu/~jbraw/chemreacfun/fig-html/appendix/fig-A-10.html + + def setUp(self): + def ABC_model(data): + ca_meas = data["ca"] + cb_meas = data["cb"] + cc_meas = data["cc"] + + if isinstance(data, pd.DataFrame): + meas_t = data.index # time index + else: # dictionary + meas_t = list(ca_meas.keys()) # nested dictionary + + ca0 = 1.0 + cb0 = 0.0 + cc0 = 0.0 + + m = pyo.ConcreteModel() + + m.k1 = pyo.Var(initialize=0.5, bounds=(1e-4, 10)) + m.k2 = pyo.Var(initialize=3.0, bounds=(1e-4, 10)) + + m.time = dae.ContinuousSet(bounds=(0.0, 5.0), initialize=meas_t) + + # initialization and bounds + m.ca = pyo.Var(m.time, initialize=ca0, bounds=(-1e-3, ca0 + 1e-3)) + m.cb = pyo.Var(m.time, initialize=cb0, bounds=(-1e-3, ca0 + 1e-3)) + m.cc = pyo.Var(m.time, initialize=cc0, bounds=(-1e-3, ca0 + 1e-3)) + + m.dca = dae.DerivativeVar(m.ca, wrt=m.time) + m.dcb = dae.DerivativeVar(m.cb, wrt=m.time) + m.dcc = dae.DerivativeVar(m.cc, wrt=m.time) + + def _dcarate(m, t): + if t == 0: + return pyo.Constraint.Skip + else: + return m.dca[t] == -m.k1 * m.ca[t] + + m.dcarate = pyo.Constraint(m.time, rule=_dcarate) + + def _dcbrate(m, t): + if t == 0: + return pyo.Constraint.Skip + else: + return m.dcb[t] == m.k1 * m.ca[t] - m.k2 * m.cb[t] + + m.dcbrate = pyo.Constraint(m.time, rule=_dcbrate) + + def _dccrate(m, t): + if t == 0: + return pyo.Constraint.Skip + else: + return m.dcc[t] == m.k2 * m.cb[t] + + m.dccrate = pyo.Constraint(m.time, rule=_dccrate) + + def ComputeFirstStageCost_rule(m): + return 0 + + m.FirstStageCost = pyo.Expression(rule=ComputeFirstStageCost_rule) + + def ComputeSecondStageCost_rule(m): + return sum( + (m.ca[t] - ca_meas[t]) ** 2 + + (m.cb[t] - cb_meas[t]) ** 2 + + (m.cc[t] - cc_meas[t]) ** 2 + for t in meas_t + ) + + m.SecondStageCost = pyo.Expression(rule=ComputeSecondStageCost_rule) + + def total_cost_rule(model): + return model.FirstStageCost + model.SecondStageCost + + m.Total_Cost_Objective = pyo.Objective( + rule=total_cost_rule, sense=pyo.minimize + ) + + disc = pyo.TransformationFactory("dae.collocation") + disc.apply_to(m, nfe=20, ncp=2) + + return m + + class ReactorDesignExperimentDAE(Experiment): + + def __init__(self, data): + + self.data = data + self.model = None + + def create_model(self): + self.model = ABC_model(self.data) + + def label_model(self): + + m = self.model + + m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.unknown_parameters.update( + (k, pyo.ComponentUID(k)) for k in [m.k1, m.k2] + ) + + def get_labeled_model(self): + self.create_model() + self.label_model() + + return self.model + + # This example tests data formatted in 3 ways + # Each format holds 1 scenario + # 1. dataframe with time index + # 2. nested dictionary {ca: {t, val pairs}, ... } + data = [ + [0.000, 0.957, -0.031, -0.015], + [0.263, 0.557, 0.330, 0.044], + [0.526, 0.342, 0.512, 0.156], + [0.789, 0.224, 0.499, 0.310], + [1.053, 0.123, 0.428, 0.454], + [1.316, 0.079, 0.396, 0.556], + [1.579, 0.035, 0.303, 0.651], + [1.842, 0.029, 0.287, 0.658], + [2.105, 0.025, 0.221, 0.750], + [2.368, 0.017, 0.148, 0.854], + [2.632, -0.002, 0.182, 0.845], + [2.895, 0.009, 0.116, 0.893], + [3.158, -0.023, 0.079, 0.942], + [3.421, 0.006, 0.078, 0.899], + [3.684, 0.016, 0.059, 0.942], + [3.947, 0.014, 0.036, 0.991], + [4.211, -0.009, 0.014, 0.988], + [4.474, -0.030, 0.036, 0.941], + [4.737, 0.004, 0.036, 0.971], + [5.000, -0.024, 0.028, 0.985], + ] + data = pd.DataFrame(data, columns=["t", "ca", "cb", "cc"]) + data_df = data.set_index("t") + data_dict = { + "ca": {k: v for (k, v) in zip(data.t, data.ca)}, + "cb": {k: v for (k, v) in zip(data.t, data.cb)}, + "cc": {k: v for (k, v) in zip(data.t, data.cc)}, + } + + # Create an experiment list + exp_list_df = [ReactorDesignExperimentDAE(data_df)] + exp_list_dict = [ReactorDesignExperimentDAE(data_dict)] + + self.pest_df = parmest.Estimator(exp_list_df) + self.pest_dict = parmest.Estimator(exp_list_dict) + + # Estimator object with multiple scenarios + exp_list_df_multiple = [ + ReactorDesignExperimentDAE(data_df), + ReactorDesignExperimentDAE(data_df), + ] + exp_list_dict_multiple = [ + ReactorDesignExperimentDAE(data_dict), + ReactorDesignExperimentDAE(data_dict), + ] + + self.pest_df_multiple = parmest.Estimator(exp_list_df_multiple) + self.pest_dict_multiple = parmest.Estimator(exp_list_dict_multiple) + + # Create an instance of the model + self.m_df = ABC_model(data_df) + self.m_dict = ABC_model(data_dict) + + def test_dataformats(self): + obj1, theta1 = self.pest_df.theta_est() + obj2, theta2 = self.pest_dict.theta_est() + + self.assertAlmostEqual(obj1, obj2, places=6) + self.assertAlmostEqual(theta1["k1"], theta2["k1"], places=6) + self.assertAlmostEqual(theta1["k2"], theta2["k2"], places=6) + + def test_return_continuous_set(self): + """ + test if ContinuousSet elements are returned correctly from theta_est() + """ + obj1, theta1, return_vals1 = self.pest_df.theta_est(return_values=["time"]) + obj2, theta2, return_vals2 = self.pest_dict.theta_est(return_values=["time"]) + self.assertAlmostEqual(return_vals1["time"].loc[0][18], 2.368, places=3) + self.assertAlmostEqual(return_vals2["time"].loc[0][18], 2.368, places=3) + + def test_return_continuous_set_multiple_datasets(self): + """ + test if ContinuousSet elements are returned correctly from theta_est() + """ + obj1, theta1, return_vals1 = self.pest_df_multiple.theta_est( + return_values=["time"] + ) + obj2, theta2, return_vals2 = self.pest_dict_multiple.theta_est( + return_values=["time"] + ) + self.assertAlmostEqual(return_vals1["time"].loc[1][18], 2.368, places=3) + self.assertAlmostEqual(return_vals2["time"].loc[1][18], 2.368, places=3) + + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') + def test_covariance(self): + from pyomo.contrib.interior_point.inverse_reduced_hessian import ( + inv_reduced_hessian_barrier, + ) + + # Number of datapoints. + # 3 data components (ca, cb, cc), 20 timesteps, 1 scenario = 60 + # In this example, this is the number of data points in data_df, but that's + # only because the data is indexed by time and contains no additional information. + n = 60 + + # Compute covariance using parmest + obj, theta, cov = self.pest_df.theta_est(calc_cov=True, cov_n=n) + + # Compute covariance using interior_point + vars_list = [self.m_df.k1, self.m_df.k2] + solve_result, inv_red_hes = inv_reduced_hessian_barrier( + self.m_df, independent_variables=vars_list, tee=True + ) + l = len(vars_list) + cov_interior_point = 2 * obj / (n - l) * inv_red_hes + cov_interior_point = pd.DataFrame( + cov_interior_point, ["k1", "k2"], ["k1", "k2"] + ) + + cov_diff = (cov - cov_interior_point).abs().sum().sum() + + self.assertTrue(cov.loc["k1", "k1"] > 0) + self.assertTrue(cov.loc["k2", "k2"] > 0) + self.assertAlmostEqual(cov_diff, 0, places=6) + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestSquareInitialization_RooneyBiegler(unittest.TestCase): + def setUp(self): + from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler_with_constraint import ( + RooneyBieglerExperiment, + ) + + # Note, the data used in this test has been corrected to use data.loc[5,'hour'] = 7 (instead of 6) + data = pd.DataFrame( + data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], + columns=["hour", "y"], + ) + + # Sum of squared error function + def SSE(model): + expr = ( + model.experiment_outputs[model.y] + - model.response_function[model.experiment_outputs[model.hour]] + ) ** 2 + return expr + + exp_list = [] + for i in range(data.shape[0]): + exp_list.append(RooneyBieglerExperiment(data.loc[i, :])) + + solver_options = {"tol": 1e-8} + + self.data = data + self.pest = parmest.Estimator( + exp_list, obj_function=SSE, solver_options=solver_options, tee=True + ) + + def test_theta_est_with_square_initialization(self): + obj_init = self.pest.objective_at_theta(initialize_parmest_model=True) + objval, thetavals = self.pest.theta_est() + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + def test_theta_est_with_square_initialization_and_custom_init_theta(self): + theta_vals_init = pd.DataFrame( + data=[[19.0, 0.5]], columns=["asymptote", "rate_constant"] + ) + obj_init = self.pest.objective_at_theta( + theta_values=theta_vals_init, initialize_parmest_model=True + ) + objval, thetavals = self.pest.theta_est() + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + def test_theta_est_with_square_initialization_diagnostic_mode_true(self): + self.pest.diagnostic_mode = True + obj_init = self.pest.objective_at_theta(initialize_parmest_model=True) + objval, thetavals = self.pest.theta_est() + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + self.pest.diagnostic_mode = False + + +########################### +# tests for deprecated UI # +########################### + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestRooneyBieglerDeprecated(unittest.TestCase): + def setUp(self): + + def rooney_biegler_model(data): + model = pyo.ConcreteModel() + + model.asymptote = pyo.Var(initialize=15) + model.rate_constant = pyo.Var(initialize=0.5) + + def response_rule(m, h): + expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) + return expr + + model.response_function = pyo.Expression(data.hour, rule=response_rule) + + def SSE_rule(m): + return sum( + (data.y[i] - m.response_function[data.hour[i]]) ** 2 + for i in data.index + ) + + model.SSE = pyo.Objective(rule=SSE_rule, sense=pyo.minimize) + + return model + + # Note, the data used in this test has been corrected to use data.loc[5,'hour'] = 7 (instead of 6) + data = pd.DataFrame( + data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], + columns=["hour", "y"], + ) + + theta_names = ["asymptote", "rate_constant"] + + def SSE(model, data): + expr = sum( + (data.y[i] - model.response_function[data.hour[i]]) ** 2 + for i in data.index + ) + return expr + + solver_options = {"tol": 1e-8} + + self.data = data + self.pest = parmest.Estimator( + rooney_biegler_model, + data, + theta_names, + SSE, + solver_options=solver_options, + tee=True, + ) + + def test_theta_est(self): + objval, thetavals = self.pest.theta_est() + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + @unittest.skipIf( + not graphics.imports_available, "parmest.graphics imports are unavailable" + ) + def test_bootstrap(self): + objval, thetavals = self.pest.theta_est() + + num_bootstraps = 10 + theta_est = self.pest.theta_est_bootstrap(num_bootstraps, return_samples=True) + + num_samples = theta_est["samples"].apply(len) + self.assertTrue(len(theta_est.index), 10) + self.assertTrue(num_samples.equals(pd.Series([6] * 10))) + + del theta_est["samples"] + + # apply confidence region test + CR = self.pest.confidence_region_test(theta_est, "MVN", [0.5, 0.75, 1.0]) + + self.assertTrue(set(CR.columns) >= set([0.5, 0.75, 1.0])) + self.assertTrue(CR[0.5].sum() == 5) + self.assertTrue(CR[0.75].sum() == 7) + self.assertTrue(CR[1.0].sum() == 10) # all true + + graphics.pairwise_plot(theta_est) + graphics.pairwise_plot(theta_est, thetavals) + graphics.pairwise_plot(theta_est, thetavals, 0.8, ["MVN", "KDE", "Rect"]) + + @unittest.skipIf( + not graphics.imports_available, "parmest.graphics imports are unavailable" + ) + def test_likelihood_ratio(self): + objval, thetavals = self.pest.theta_est() + + asym = np.arange(10, 30, 2) + rate = np.arange(0, 1.5, 0.25) + theta_vals = pd.DataFrame( + list(product(asym, rate)), columns=self.pest._return_theta_names() + ) + + obj_at_theta = self.pest.objective_at_theta(theta_vals) + + LR = self.pest.likelihood_ratio_test(obj_at_theta, objval, [0.8, 0.9, 1.0]) + + self.assertTrue(set(LR.columns) >= set([0.8, 0.9, 1.0])) + self.assertTrue(LR[0.8].sum() == 6) + self.assertTrue(LR[0.9].sum() == 10) + self.assertTrue(LR[1.0].sum() == 60) # all true + + graphics.pairwise_plot(LR, thetavals, 0.8) + + def test_leaveNout(self): + lNo_theta = self.pest.theta_est_leaveNout(1) + self.assertTrue(lNo_theta.shape == (6, 2)) + + results = self.pest.leaveNout_bootstrap_test( + 1, None, 3, "Rect", [0.5, 1.0], seed=5436 + ) + self.assertTrue(len(results) == 6) # 6 lNo samples + i = 1 + samples = results[i][0] # list of N samples that are left out + lno_theta = results[i][1] + bootstrap_theta = results[i][2] + self.assertTrue(samples == [1]) # sample 1 was left out + self.assertTrue(lno_theta.shape[0] == 1) # lno estimate for sample 1 + self.assertTrue(set(lno_theta.columns) >= set([0.5, 1.0])) + self.assertTrue(lno_theta[1.0].sum() == 1) # all true + self.assertTrue(bootstrap_theta.shape[0] == 3) # bootstrap for sample 1 + self.assertTrue(bootstrap_theta[1.0].sum() == 3) # all true + + def test_diagnostic_mode(self): + self.pest.diagnostic_mode = True + + objval, thetavals = self.pest.theta_est() + + asym = np.arange(10, 30, 2) + rate = np.arange(0, 1.5, 0.25) + theta_vals = pd.DataFrame( + list(product(asym, rate)), columns=self.pest._return_theta_names() + ) + + obj_at_theta = self.pest.objective_at_theta(theta_vals) + + self.pest.diagnostic_mode = False + + @unittest.skip("Presently having trouble with mpiexec on appveyor") + def test_parallel_parmest(self): + """use mpiexec and mpi4py""" + p = str(parmestbase.__path__) + l = p.find("'") + r = p.find("'", l + 1) + parmestpath = p[l + 1 : r] + rbpath = ( + parmestpath + + os.sep + + "examples" + + os.sep + + "rooney_biegler" + + os.sep + + "rooney_biegler_parmest.py" + ) + rbpath = os.path.abspath(rbpath) # paranoia strikes deep... + rlist = ["mpiexec", "--allow-run-as-root", "-n", "2", sys.executable, rbpath] + if sys.version_info >= (3, 5): + ret = subprocess.run(rlist) + retcode = ret.returncode + else: + retcode = subprocess.call(rlist) + assert retcode == 0 + + @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") + def test_theta_est_cov(self): + objval, thetavals, cov = self.pest.theta_est(calc_cov=True, cov_n=6) + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + # Covariance matrix + self.assertAlmostEqual( + cov.iloc[0, 0], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[0, 1], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 0], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual(cov.iloc[1, 1], 0.04124, places=2) # 0.04124 from paper + + """ Why does the covariance matrix from parmest not match the paper? Parmest is + calculating the exact reduced Hessian. The paper (Rooney and Bielger, 2001) likely + employed the first order approximation common for nonlinear regression. The paper + values were verified with Scipy, which uses the same first order approximation. + The formula used in parmest was verified against equations (7-5-15) and (7-5-16) in + "Nonlinear Parameter Estimation", Y. Bard, 1974. + """ + + def test_cov_scipy_least_squares_comparison(self): + """ + Scipy results differ in the 3rd decimal place from the paper. It is possible + the paper used an alternative finite difference approximation for the Jacobian. + """ + + def model(theta, t): + """ + Model to be fitted y = model(theta, t) + Arguments: + theta: vector of fitted parameters + t: independent variable [hours] + + Returns: + y: model predictions [need to check paper for units] + """ + asymptote = theta[0] + rate_constant = theta[1] + + return asymptote * (1 - np.exp(-rate_constant * t)) + + def residual(theta, t, y): + """ + Calculate residuals + Arguments: + theta: vector of fitted parameters + t: independent variable [hours] + y: dependent variable [?] + """ + return y - model(theta, t) + + # define data + t = self.data["hour"].to_numpy() + y = self.data["y"].to_numpy() + + # define initial guess + theta_guess = np.array([15, 0.5]) + + ## solve with optimize.least_squares + sol = scipy.optimize.least_squares( + residual, theta_guess, method="trf", args=(t, y), verbose=2 + ) + theta_hat = sol.x + + self.assertAlmostEqual( + theta_hat[0], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper + + # calculate residuals + r = residual(theta_hat, t, y) + + # calculate variance of the residuals + # -2 because there are 2 fitted parameters + sigre = np.matmul(r.T, r / (len(y) - 2)) + + # approximate covariance + # Need to divide by 2 because optimize.least_squares scaled the objective by 1/2 + cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) + + self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper + self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + + def test_cov_scipy_curve_fit_comparison(self): + """ + Scipy results differ in the 3rd decimal place from the paper. It is possible + the paper used an alternative finite difference approximation for the Jacobian. + """ + + ## solve with optimize.curve_fit + def model(t, asymptote, rate_constant): + return asymptote * (1 - np.exp(-rate_constant * t)) + + # define data + t = self.data["hour"].to_numpy() + y = self.data["y"].to_numpy() + + # define initial guess + theta_guess = np.array([15, 0.5]) + + theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess) + + self.assertAlmostEqual( + theta_hat[0], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper + + self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper + self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestModelVariantsDeprecated(unittest.TestCase): + def setUp(self): + self.data = pd.DataFrame( + data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], + columns=["hour", "y"], + ) + + def rooney_biegler_params(data): + model = pyo.ConcreteModel() + + model.asymptote = pyo.Param(initialize=15, mutable=True) + model.rate_constant = pyo.Param(initialize=0.5, mutable=True) + + def response_rule(m, h): + expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) + return expr + + model.response_function = pyo.Expression(data.hour, rule=response_rule) + + return model + + def rooney_biegler_indexed_params(data): + model = pyo.ConcreteModel() + + model.param_names = pyo.Set(initialize=["asymptote", "rate_constant"]) + model.theta = pyo.Param( + model.param_names, + initialize={"asymptote": 15, "rate_constant": 0.5}, + mutable=True, + ) + + def response_rule(m, h): + expr = m.theta["asymptote"] * ( + 1 - pyo.exp(-m.theta["rate_constant"] * h) + ) + return expr + + model.response_function = pyo.Expression(data.hour, rule=response_rule) + + return model + + def rooney_biegler_vars(data): + model = pyo.ConcreteModel() + + model.asymptote = pyo.Var(initialize=15) + model.rate_constant = pyo.Var(initialize=0.5) + model.asymptote.fixed = True # parmest will unfix theta variables + model.rate_constant.fixed = True + + def response_rule(m, h): + expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) + return expr + + model.response_function = pyo.Expression(data.hour, rule=response_rule) + + return model + + def rooney_biegler_indexed_vars(data): + model = pyo.ConcreteModel() + + model.var_names = pyo.Set(initialize=["asymptote", "rate_constant"]) + model.theta = pyo.Var( + model.var_names, initialize={"asymptote": 15, "rate_constant": 0.5} + ) + model.theta["asymptote"].fixed = ( + True # parmest will unfix theta variables, even when they are indexed + ) + model.theta["rate_constant"].fixed = True + + def response_rule(m, h): + expr = m.theta["asymptote"] * ( + 1 - pyo.exp(-m.theta["rate_constant"] * h) + ) + return expr + + model.response_function = pyo.Expression(data.hour, rule=response_rule) + + return model + + def SSE(model, data): + expr = sum( + (data.y[i] - model.response_function[data.hour[i]]) ** 2 + for i in data.index + ) + return expr + + self.objective_function = SSE + + theta_vals = pd.DataFrame([20, 1], index=["asymptote", "rate_constant"]).T + theta_vals_index = pd.DataFrame( + [20, 1], index=["theta['asymptote']", "theta['rate_constant']"] + ).T + + self.input = { + "param": { + "model": rooney_biegler_params, + "theta_names": ["asymptote", "rate_constant"], + "theta_vals": theta_vals, + }, + "param_index": { + "model": rooney_biegler_indexed_params, + "theta_names": ["theta"], + "theta_vals": theta_vals_index, + }, + "vars": { + "model": rooney_biegler_vars, + "theta_names": ["asymptote", "rate_constant"], + "theta_vals": theta_vals, + }, + "vars_index": { + "model": rooney_biegler_indexed_vars, + "theta_names": ["theta"], + "theta_vals": theta_vals_index, + }, + "vars_quoted_index": { + "model": rooney_biegler_indexed_vars, + "theta_names": ["theta['asymptote']", "theta['rate_constant']"], + "theta_vals": theta_vals_index, + }, + "vars_str_index": { + "model": rooney_biegler_indexed_vars, + "theta_names": ["theta[asymptote]", "theta[rate_constant]"], + "theta_vals": theta_vals_index, + }, + } + + @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") + def test_parmest_basics(self): + for model_type, parmest_input in self.input.items(): + pest = parmest.Estimator( + parmest_input["model"], + self.data, + parmest_input["theta_names"], + self.objective_function, + ) + + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[0, 0], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[0, 1], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 0], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 1], 0.04193591, places=2 + ) # 0.04124 from paper + + obj_at_theta = pest.objective_at_theta(parmest_input["theta_vals"]) + self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') + def test_parmest_basics_with_initialize_parmest_model_option(self): + for model_type, parmest_input in self.input.items(): + pest = parmest.Estimator( + parmest_input["model"], + self.data, + parmest_input["theta_names"], + self.objective_function, + ) + + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[0, 0], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[0, 1], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 0], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 1], 0.04193591, places=2 + ) # 0.04124 from paper + + obj_at_theta = pest.objective_at_theta( + parmest_input["theta_vals"], initialize_parmest_model=True + ) + + self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') + def test_parmest_basics_with_square_problem_solve(self): + for model_type, parmest_input in self.input.items(): + pest = parmest.Estimator( + parmest_input["model"], + self.data, + parmest_input["theta_names"], + self.objective_function, + ) + + obj_at_theta = pest.objective_at_theta( + parmest_input["theta_vals"], initialize_parmest_model=True + ) + + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[0, 0], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[0, 1], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 0], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 1], 0.04193591, places=2 + ) # 0.04124 from paper + + self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') + def test_parmest_basics_with_square_problem_solve_no_theta_vals(self): + for model_type, parmest_input in self.input.items(): + pest = parmest.Estimator( + parmest_input["model"], + self.data, + parmest_input["theta_names"], + self.objective_function, + ) + + obj_at_theta = pest.objective_at_theta(initialize_parmest_model=True) + + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[0, 0], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[0, 1], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 0], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 1], 0.04193591, places=2 + ) # 0.04124 from paper + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' solver is not available") +class TestReactorDesignDeprecated(unittest.TestCase): + def setUp(self): + + def reactor_design_model(data): + # Create the concrete model + model = pyo.ConcreteModel() + + # Rate constants + model.k1 = pyo.Param( + initialize=5.0 / 6.0, within=pyo.PositiveReals, mutable=True + ) # min^-1 + model.k2 = pyo.Param( + initialize=5.0 / 3.0, within=pyo.PositiveReals, mutable=True + ) # min^-1 + model.k3 = pyo.Param( + initialize=1.0 / 6000.0, within=pyo.PositiveReals, mutable=True + ) # m^3/(gmol min) + + # Inlet concentration of A, gmol/m^3 + if isinstance(data, dict) or isinstance(data, pd.Series): + model.caf = pyo.Param( + initialize=float(data["caf"]), within=pyo.PositiveReals + ) + elif isinstance(data, pd.DataFrame): + model.caf = pyo.Param( + initialize=float(data.iloc[0]["caf"]), within=pyo.PositiveReals + ) + else: + raise ValueError("Unrecognized data type.") + + # Space velocity (flowrate/volume) + if isinstance(data, dict) or isinstance(data, pd.Series): + model.sv = pyo.Param( + initialize=float(data["sv"]), within=pyo.PositiveReals + ) + elif isinstance(data, pd.DataFrame): + model.sv = pyo.Param( + initialize=float(data.iloc[0]["sv"]), within=pyo.PositiveReals + ) + else: + raise ValueError("Unrecognized data type.") + + # Outlet concentration of each component + model.ca = pyo.Var(initialize=5000.0, within=pyo.PositiveReals) + model.cb = pyo.Var(initialize=2000.0, within=pyo.PositiveReals) + model.cc = pyo.Var(initialize=2000.0, within=pyo.PositiveReals) + model.cd = pyo.Var(initialize=1000.0, within=pyo.PositiveReals) + + # Objective + model.obj = pyo.Objective(expr=model.cb, sense=pyo.maximize) + + # Constraints + model.ca_bal = pyo.Constraint( + expr=( + 0 + == model.sv * model.caf + - model.sv * model.ca + - model.k1 * model.ca + - 2.0 * model.k3 * model.ca**2.0 + ) + ) + + model.cb_bal = pyo.Constraint( + expr=( + 0 + == -model.sv * model.cb + model.k1 * model.ca - model.k2 * model.cb + ) + ) + + model.cc_bal = pyo.Constraint( + expr=(0 == -model.sv * model.cc + model.k2 * model.cb) + ) + + model.cd_bal = pyo.Constraint( + expr=(0 == -model.sv * model.cd + model.k3 * model.ca**2.0) + ) + + return model + + # Data from the design + data = pd.DataFrame( + data=[ + [1.05, 10000, 3458.4, 1060.8, 1683.9, 1898.5], + [1.10, 10000, 3535.1, 1064.8, 1613.3, 1893.4], + [1.15, 10000, 3609.1, 1067.8, 1547.5, 1887.8], + [1.20, 10000, 3680.7, 1070.0, 1486.1, 1881.6], + [1.25, 10000, 3750.0, 1071.4, 1428.6, 1875.0], + [1.30, 10000, 3817.1, 1072.2, 1374.6, 1868.0], + [1.35, 10000, 3882.2, 1072.4, 1324.0, 1860.7], + [1.40, 10000, 3945.4, 1072.1, 1276.3, 1853.1], + [1.45, 10000, 4006.7, 1071.3, 1231.4, 1845.3], + [1.50, 10000, 4066.4, 1070.1, 1189.0, 1837.3], + [1.55, 10000, 4124.4, 1068.5, 1148.9, 1829.1], + [1.60, 10000, 4180.9, 1066.5, 1111.0, 1820.8], + [1.65, 10000, 4235.9, 1064.3, 1075.0, 1812.4], + [1.70, 10000, 4289.5, 1061.8, 1040.9, 1803.9], + [1.75, 10000, 4341.8, 1059.0, 1008.5, 1795.3], + [1.80, 10000, 4392.8, 1056.0, 977.7, 1786.7], + [1.85, 10000, 4442.6, 1052.8, 948.4, 1778.1], + [1.90, 10000, 4491.3, 1049.4, 920.5, 1769.4], + [1.95, 10000, 4538.8, 1045.8, 893.9, 1760.8], + ], + columns=["sv", "caf", "ca", "cb", "cc", "cd"], + ) + + theta_names = ["k1", "k2", "k3"] + + def SSE(model, data): + expr = ( + (float(data.iloc[0]["ca"]) - model.ca) ** 2 + + (float(data.iloc[0]["cb"]) - model.cb) ** 2 + + (float(data.iloc[0]["cc"]) - model.cc) ** 2 + + (float(data.iloc[0]["cd"]) - model.cd) ** 2 + ) + return expr + + solver_options = {"max_iter": 6000} + + self.pest = parmest.Estimator( + reactor_design_model, data, theta_names, SSE, solver_options=solver_options + ) + + def test_theta_est(self): + # used in data reconciliation + objval, thetavals = self.pest.theta_est() + + self.assertAlmostEqual(thetavals["k1"], 5.0 / 6.0, places=4) + self.assertAlmostEqual(thetavals["k2"], 5.0 / 3.0, places=4) + self.assertAlmostEqual(thetavals["k3"], 1.0 / 6000.0, places=7) + + def test_return_values(self): + objval, thetavals, data_rec = self.pest.theta_est( + return_values=["ca", "cb", "cc", "cd", "caf"] + ) + self.assertAlmostEqual(data_rec["cc"].loc[18], 893.84924, places=3) + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' solver is not available") +class TestReactorDesign_DAE_Deprecated(unittest.TestCase): + # Based on a reactor example in `Chemical Reactor Analysis and Design Fundamentals`, + # https://sites.engineering.ucsb.edu/~jbraw/chemreacfun/ + # https://sites.engineering.ucsb.edu/~jbraw/chemreacfun/fig-html/appendix/fig-A-10.html + + def setUp(self): + def ABC_model(data): + ca_meas = data["ca"] + cb_meas = data["cb"] + cc_meas = data["cc"] + + if isinstance(data, pd.DataFrame): + meas_t = data.index # time index + else: # dictionary + meas_t = list(ca_meas.keys()) # nested dictionary + + ca0 = 1.0 + cb0 = 0.0 + cc0 = 0.0 + + m = pyo.ConcreteModel() + + m.k1 = pyo.Var(initialize=0.5, bounds=(1e-4, 10)) + m.k2 = pyo.Var(initialize=3.0, bounds=(1e-4, 10)) + + m.time = dae.ContinuousSet(bounds=(0.0, 5.0), initialize=meas_t) + + # initialization and bounds + m.ca = pyo.Var(m.time, initialize=ca0, bounds=(-1e-3, ca0 + 1e-3)) + m.cb = pyo.Var(m.time, initialize=cb0, bounds=(-1e-3, ca0 + 1e-3)) + m.cc = pyo.Var(m.time, initialize=cc0, bounds=(-1e-3, ca0 + 1e-3)) + + m.dca = dae.DerivativeVar(m.ca, wrt=m.time) + m.dcb = dae.DerivativeVar(m.cb, wrt=m.time) + m.dcc = dae.DerivativeVar(m.cc, wrt=m.time) + + def _dcarate(m, t): + if t == 0: + return pyo.Constraint.Skip + else: + return m.dca[t] == -m.k1 * m.ca[t] + + m.dcarate = pyo.Constraint(m.time, rule=_dcarate) + + def _dcbrate(m, t): + if t == 0: + return pyo.Constraint.Skip + else: + return m.dcb[t] == m.k1 * m.ca[t] - m.k2 * m.cb[t] + + m.dcbrate = pyo.Constraint(m.time, rule=_dcbrate) + + def _dccrate(m, t): + if t == 0: + return pyo.Constraint.Skip + else: + return m.dcc[t] == m.k2 * m.cb[t] + + m.dccrate = pyo.Constraint(m.time, rule=_dccrate) + + def ComputeFirstStageCost_rule(m): + return 0 + + m.FirstStageCost = pyo.Expression(rule=ComputeFirstStageCost_rule) + + def ComputeSecondStageCost_rule(m): + return sum( + (m.ca[t] - ca_meas[t]) ** 2 + + (m.cb[t] - cb_meas[t]) ** 2 + + (m.cc[t] - cc_meas[t]) ** 2 + for t in meas_t + ) + + m.SecondStageCost = pyo.Expression(rule=ComputeSecondStageCost_rule) + + def total_cost_rule(model): + return model.FirstStageCost + model.SecondStageCost + + m.Total_Cost_Objective = pyo.Objective( + rule=total_cost_rule, sense=pyo.minimize + ) + + disc = pyo.TransformationFactory("dae.collocation") + disc.apply_to(m, nfe=20, ncp=2) + + return m + + # This example tests data formatted in 3 ways + # Each format holds 1 scenario + # 1. dataframe with time index + # 2. nested dictionary {ca: {t, val pairs}, ... } + data = [ + [0.000, 0.957, -0.031, -0.015], + [0.263, 0.557, 0.330, 0.044], + [0.526, 0.342, 0.512, 0.156], + [0.789, 0.224, 0.499, 0.310], + [1.053, 0.123, 0.428, 0.454], + [1.316, 0.079, 0.396, 0.556], + [1.579, 0.035, 0.303, 0.651], + [1.842, 0.029, 0.287, 0.658], + [2.105, 0.025, 0.221, 0.750], + [2.368, 0.017, 0.148, 0.854], + [2.632, -0.002, 0.182, 0.845], + [2.895, 0.009, 0.116, 0.893], + [3.158, -0.023, 0.079, 0.942], + [3.421, 0.006, 0.078, 0.899], + [3.684, 0.016, 0.059, 0.942], + [3.947, 0.014, 0.036, 0.991], + [4.211, -0.009, 0.014, 0.988], + [4.474, -0.030, 0.036, 0.941], + [4.737, 0.004, 0.036, 0.971], + [5.000, -0.024, 0.028, 0.985], + ] + data = pd.DataFrame(data, columns=["t", "ca", "cb", "cc"]) + data_df = data.set_index("t") + data_dict = { + "ca": {k: v for (k, v) in zip(data.t, data.ca)}, + "cb": {k: v for (k, v) in zip(data.t, data.cb)}, + "cc": {k: v for (k, v) in zip(data.t, data.cc)}, + } + + theta_names = ["k1", "k2"] + + self.pest_df = parmest.Estimator(ABC_model, [data_df], theta_names) + self.pest_dict = parmest.Estimator(ABC_model, [data_dict], theta_names) + + # Estimator object with multiple scenarios + self.pest_df_multiple = parmest.Estimator( + ABC_model, [data_df, data_df], theta_names + ) + self.pest_dict_multiple = parmest.Estimator( + ABC_model, [data_dict, data_dict], theta_names + ) + + # Create an instance of the model + self.m_df = ABC_model(data_df) + self.m_dict = ABC_model(data_dict) + + def test_dataformats(self): + obj1, theta1 = self.pest_df.theta_est() + obj2, theta2 = self.pest_dict.theta_est() + + self.assertAlmostEqual(obj1, obj2, places=6) + self.assertAlmostEqual(theta1["k1"], theta2["k1"], places=6) + self.assertAlmostEqual(theta1["k2"], theta2["k2"], places=6) + + def test_return_continuous_set(self): + """ + test if ContinuousSet elements are returned correctly from theta_est() + """ + obj1, theta1, return_vals1 = self.pest_df.theta_est(return_values=["time"]) + obj2, theta2, return_vals2 = self.pest_dict.theta_est(return_values=["time"]) + self.assertAlmostEqual(return_vals1["time"].loc[0][18], 2.368, places=3) + self.assertAlmostEqual(return_vals2["time"].loc[0][18], 2.368, places=3) + + def test_return_continuous_set_multiple_datasets(self): + """ + test if ContinuousSet elements are returned correctly from theta_est() + """ + obj1, theta1, return_vals1 = self.pest_df_multiple.theta_est( + return_values=["time"] + ) + obj2, theta2, return_vals2 = self.pest_dict_multiple.theta_est( + return_values=["time"] + ) + self.assertAlmostEqual(return_vals1["time"].loc[1][18], 2.368, places=3) + self.assertAlmostEqual(return_vals2["time"].loc[1][18], 2.368, places=3) + + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') + def test_covariance(self): + from pyomo.contrib.interior_point.inverse_reduced_hessian import ( + inv_reduced_hessian_barrier, + ) + + # Number of datapoints. + # 3 data components (ca, cb, cc), 20 timesteps, 1 scenario = 60 + # In this example, this is the number of data points in data_df, but that's + # only because the data is indexed by time and contains no additional information. + n = 60 + + # Compute covariance using parmest + obj, theta, cov = self.pest_df.theta_est(calc_cov=True, cov_n=n) + + # Compute covariance using interior_point + vars_list = [self.m_df.k1, self.m_df.k2] + solve_result, inv_red_hes = inv_reduced_hessian_barrier( + self.m_df, independent_variables=vars_list, tee=True + ) + l = len(vars_list) + cov_interior_point = 2 * obj / (n - l) * inv_red_hes + cov_interior_point = pd.DataFrame( + cov_interior_point, ["k1", "k2"], ["k1", "k2"] + ) + + cov_diff = (cov - cov_interior_point).abs().sum().sum() + + self.assertTrue(cov.loc["k1", "k1"] > 0) + self.assertTrue(cov.loc["k2", "k2"] > 0) + self.assertAlmostEqual(cov_diff, 0, places=6) + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestSquareInitialization_RooneyBiegler_Deprecated(unittest.TestCase): + def setUp(self): + + def rooney_biegler_model_with_constraint(data): + model = pyo.ConcreteModel() + + model.asymptote = pyo.Var(initialize=15) + model.rate_constant = pyo.Var(initialize=0.5) + model.response_function = pyo.Var(data.hour, initialize=0.0) + + # changed from expression to constraint + def response_rule(m, h): + return m.response_function[h] == m.asymptote * ( + 1 - pyo.exp(-m.rate_constant * h) + ) + + model.response_function_constraint = pyo.Constraint( + data.hour, rule=response_rule + ) + + def SSE_rule(m): + return sum( + (data.y[i] - m.response_function[data.hour[i]]) ** 2 + for i in data.index + ) + + model.SSE = pyo.Objective(rule=SSE_rule, sense=pyo.minimize) + + return model + + # Note, the data used in this test has been corrected to use data.loc[5,'hour'] = 7 (instead of 6) + data = pd.DataFrame( + data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], + columns=["hour", "y"], + ) + + theta_names = ["asymptote", "rate_constant"] + + def SSE(model, data): + expr = sum( + (data.y[i] - model.response_function[data.hour[i]]) ** 2 + for i in data.index + ) + return expr + + solver_options = {"tol": 1e-8} + + self.data = data + self.pest = parmest.Estimator( + rooney_biegler_model_with_constraint, + data, + theta_names, + SSE, + solver_options=solver_options, + tee=True, + ) + + def test_theta_est_with_square_initialization(self): + obj_init = self.pest.objective_at_theta(initialize_parmest_model=True) + objval, thetavals = self.pest.theta_est() + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + def test_theta_est_with_square_initialization_and_custom_init_theta(self): + theta_vals_init = pd.DataFrame( + data=[[19.0, 0.5]], columns=["asymptote", "rate_constant"] + ) + obj_init = self.pest.objective_at_theta( + theta_values=theta_vals_init, initialize_parmest_model=True + ) + objval, thetavals = self.pest.theta_est() + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + def test_theta_est_with_square_initialization_diagnostic_mode_true(self): + self.pest.diagnostic_mode = True + obj_init = self.pest.objective_at_theta(initialize_parmest_model=True) + objval, thetavals = self.pest.theta_est() + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + self.pest.diagnostic_mode = False + + +if __name__ == "__main__": + unittest.main() From 8fb0de27e42bd98ccdc5e414df464e95bc3d23b5 Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Sun, 4 May 2025 01:53:49 -0400 Subject: [PATCH 07/35] Updated parmest.py file --- pyomo/contrib/parmest/parmest.py | 501 ++++++++++++++++--------------- 1 file changed, 262 insertions(+), 239 deletions(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index 4ef30352823..d2b6d942f1e 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -230,7 +230,7 @@ def _experiment_instance_creation_callback( def SSE(model): """ Sum of squared error between the model prediction of measured variables and data values, - assuming Gaussian i.i.d errors. + assuming Gaussian i.i.d errors """ expr = sum((y - y_hat) ** 2 for y_hat, y in model.experiment_outputs.items()) return expr @@ -238,7 +238,7 @@ def SSE(model): def SSE_weighted(model): """ Weighted sum of squared error between the model prediction of measured variables and data values, - assuming Gaussian i.i.d errors. + assuming Gaussian i.i.d errors, with measurement error standard deviation defined in the annotated Pyomo model """ expr = (1 / 2) * sum( ((y - y_hat) / model.measurement_error[y_hat]) ** 2 @@ -246,14 +246,15 @@ def SSE_weighted(model): ) return expr -# Calculate the sensitivity of measured variables to parameters using central finite difference -def _compute_jacobian(model, relative_perturbation, solver_option="ipopt"): +# Compute the Jacobian matrix of measurement predictions with respect to changes in parameter values +def _compute_jacobian(model, absolute_perturbation, solver_object): """ - Computes the Jacobian matrix using central finite difference scheme + Computes the Jacobian matrix of measurement predictions with respect to changes in parameter values + using central finite difference scheme Arguments: model: Pyomo model containing experiment_outputs and measurement_error - relative_perturbation: value used to perturb the objectives + absolute_perturbation: value used to perturb the parameters Returns: J: Jacobian matrix @@ -277,17 +278,17 @@ def _compute_jacobian(model, relative_perturbation, solver_option="ipopt"): orig_value = param_values[i] # Forward perturbation - param.fix(orig_value + relative_perturbation) + param.fix(orig_value + absolute_perturbation) # solve model - solver = pyo.SolverFactory(solver_option) + solver = pyo.SolverFactory(solver_object) solver.solve(model) # forward perturbation measured variables y_hat_plus = [pyo.value(y_hat) for y_hat, y in model.experiment_outputs.items()] # Backward perturbation - param.fix(orig_value - relative_perturbation) + param.fix(orig_value - absolute_perturbation) # resolve model solver.solve(model) @@ -299,22 +300,35 @@ def _compute_jacobian(model, relative_perturbation, solver_option="ipopt"): param.fix(orig_value) # Central difference approximation for the Jacobian - J[:, i] = [(y_hat_plus[w] - y_hat_minus[w]) / (2 * relative_perturbation) for w in range(len(y_hat_plus))] + J[:, i] = [(y_hat_plus[w] - y_hat_minus[w]) / (2 * absolute_perturbation) for w in range(len(y_hat_plus))] return J # compute the Fisher information matrix of the estimated parameters -def compute_FIM(model, relative_perturbation, solver_option="ipopt"): +def compute_FIM(experiment, thetavals, absolute_perturbation, solver_object, estimated_var=None): """ Compute the Fisher information matrix from the Jacobian matrix and measurement errors Arguments: model: Pyomo model containing the experiment outputs and measurement errors - relative_perturbation: value used to perturb the objectives + absolute_perturbation: value used to perturb the objectives Returns: FIM: Fisher information matrix """ + if not isinstance(solver_object, str): + raise TypeError("Expected a string for the solver object") + + model = experiment.get_labeled_model().clone() + + # fix the parameter values to the optimal values estimated + params = [k for k, v in model.unknown_parameters.items()] + for param in params: + param.fix(thetavals[param.name]) + + # resolve the model + solver = pyo.SolverFactory(solver_object) + solver.solve(model) # extract the measured variables and measurement errors y_hat_list = [y_hat for y_hat, y in model.experiment_outputs.items()] @@ -328,11 +342,13 @@ def compute_FIM(model, relative_perturbation, solver_option="ipopt"): if len(error_list) != len(y_hat_list): raise ValueError("Experiment outputs and measurement errors are not the same length.") - # create the weight matrix W (inverse of variance) - W = np.diag([1 / (err**2) for err in error_list]) + if estimated_var is None: # user supplies the measurement errors + W = np.diag([1 / (err**2) for err in error_list]) # matrix of the inverse of measurement variance + else: # user does not supply the measurement errors + W = 1 / estimated_var # compute the Jacobian matrix - J = _compute_jacobian(model, relative_perturbation, solver_option) + J = _compute_jacobian(model, absolute_perturbation, solver_object) # computing the condition number of the Jacobian matrix cond_number_jac = np.linalg.cond(J) @@ -523,9 +539,10 @@ def _create_parmest_model(self, experiment_number): 'SecondStageCost', ] for n in reserved_names: - if model.component(n) or hasattr(model, n): + if model.component(n) is not None or hasattr(model, n): raise RuntimeError( - f"Parmest will not override the existing model component named {n}" + f"Parmest will not override the existing model component named {n}. " + f"Rerun the Estimator object before running theta_est again" ) # Deactivate any existing objective functions @@ -568,8 +585,6 @@ def _Q_opt( solver="ef_ipopt", return_values=[], bootlist=None, - calc_cov=False, - cov_n=None, ): """ Set up all thetas as first stage Vars, return resulting theta @@ -618,36 +633,26 @@ def _Q_opt( # Solve the extensive form with ipopt if solver == "ef_ipopt": - if not calc_cov: - # Do not calculate the reduced hessian - - solver = SolverFactory('ipopt') - if self.solver_options is not None: - for key in self.solver_options: - solver.options[key] = self.solver_options[key] - - solve_result = solver.solve(self.ef_instance, tee=self.tee) - # The import error will be raised when we attempt to use # inv_reduced_hessian_barrier below. # # elif not asl_available: # raise ImportError("parmest requires ASL to calculate the " # "covariance matrix with solver 'ipopt'") - else: - # parmest makes the fitted parameters stage 1 variables - ind_vars = [] - for ndname, Var, solval in ef_nonants(ef): - ind_vars.append(Var) - # calculate the reduced hessian - (solve_result, inv_red_hes) = ( - inverse_reduced_hessian.inv_reduced_hessian_barrier( - self.ef_instance, - independent_variables=ind_vars, - solver_options=self.solver_options, - tee=self.tee, - ) + + # parmest makes the fitted parameters stage 1 variables + ind_vars = [] + for ndname, Var, solval in ef_nonants(ef): + ind_vars.append(Var) + # calculate the reduced hessian + (solve_result, inv_red_hes) = ( + inverse_reduced_hessian.inv_reduced_hessian_barrier( + self.ef_instance, + independent_variables=ind_vars, + solver_options=self.solver_options, + tee=self.tee, ) + ) if self.diagnostic_mode: print( @@ -665,83 +670,9 @@ def _Q_opt( objval = pyo.value(ef.EF_Obj) - if calc_cov: - # Calculate the covariance matrix - - # Number of data points considered - n = cov_n - - # Extract number of fitted parameters - l = len(thetavals) - - # Assumption: Objective value is sum of squared errors - sse = objval - - '''Calculate covariance assuming experimental observation errors are - independent and follow a Gaussian - distribution with constant variance. - - The formula used in parmest was verified against equations (7-5-15) and - (7-5-16) in "Nonlinear Parameter Estimation", Y. Bard, 1974. - - This formula is also applicable if the objective is scaled by a constant; - the constant cancels out. (was scaled by 1/n because it computes an - expected value.) - ''' - if self.obj_function == 'SSE': - # get the model - model = self.exp_list[0].get_labeled_model() - - # covariance matrix if the user defines the measurement errors - if hasattr(model, "measurement_error"): - # get the measurement errors - meas_error = [model.measurement_error[y_hat] for y_hat, y in model.experiment_outputs.items()] - - # check if the user supplied values for the measurement errors - if all(item is None for item in meas_error): - cov = 2 * (sse / (n - l)) * inv_red_hes # covariance matrix - cov = pd.DataFrame( - cov, index=thetavals.keys(), columns=thetavals.keys() - ) - else: - cov = 2 * (meas_error[0] ** 2) * inv_red_hes # covariance matrix - cov = pd.DataFrame( - cov, index=thetavals.keys(), columns=thetavals.keys() - ) - else: - cov = 2 * (sse / (n - l)) * inv_red_hes # covariance matrix when the measurement errors are not defined - cov = pd.DataFrame( - cov, index=thetavals.keys(), columns=thetavals.keys() - ) - elif self.obj_function == 'SSE_weighted': - # Store the FIM of all the experiments - FIM_all_exp = [] - for experiment in self.exp_list: # loop through the experiments - # get a copy of the model - model = experiment.get_labeled_model().clone() - - # fix the parameter values to the optimal values estimated - params = [k for k, v in model.unknown_parameters.items()] - for param in params: - param.fix(thetavals[param.name]) - - # resolve the model - solver = pyo.SolverFactory("ipopt") - solver.solve(model) - - # compute the FIM - FIM_all_exp.append(compute_FIM(model, relative_perturbation=1e-6)) - - # Total FIM of experiments - FIM_total = np.sum(FIM_all_exp, axis=0) - - # covariance matrix - cov = np.linalg.inv(FIM_total) - cov = pd.DataFrame( - cov, index=thetavals.keys(), columns=thetavals.keys() - ) - else: - raise NotImplementedError('Covariance calculation is only supported for SSE and SSE_weighted objectives') + # add the estimated theta and objective value to the class + self.estimated_theta = thetavals + self.objective_value = objval thetavals = pd.Series(thetavals) @@ -773,19 +704,157 @@ def _Q_opt( if len(vals) > 0: var_values.append(vals) var_values = pd.DataFrame(var_values) - if calc_cov: - return objval, thetavals, var_values, cov - else: - return objval, thetavals, var_values - if calc_cov: - return objval, thetavals, cov - else: - return objval, thetavals + return objval, thetavals, var_values + + return objval, thetavals else: raise RuntimeError("Unknown solver in Q_Opt=" + solver) + def _cov_at_theta( + self, + method, + solver, + cov_n, + ): + """ + Parameter estimation using all scenarios in the data + + Parameters + ---------- + solver: string, optional + Currently only "ef_ipopt" is supported. Default is "ef_ipopt". + return_values: list, optional + List of Variable names, used to return values from the model for data reconciliation + calc_cov: boolean, optional + If True, calculate and return the covariance matrix (only for "ef_ipopt" solver). + Default is False. + cov_n: int, optional + If calc_cov=True, then the user needs to supply the number of datapoints + that are used in the objective function. + + Returns + ------- + objectiveval: float + The objective function value + thetavals: pd.Series + Estimated values for theta + variable values: pd.DataFrame + Variable values for each variable name in return_values (only for solver='ef_ipopt') + cov: pd.DataFrame + Covariance matrix of the fitted parameters (only for solver='ef_ipopt') + + """ + + # Number of data points considered + n = cov_n + + # Extract number of fitted parameters + l = len(self.estimated_theta) + + # Assumption: Objective value is sum of squared errors + sse = self.objective_value + + '''Calculate covariance assuming experimental observation errors are + independent and follow a Gaussian distribution with constant variance. + + The formula used in parmest was verified against equations (7-5-15) and + (7-5-16) in "Nonlinear Parameter Estimation", Y. Bard, 1974. + + This formula is also applicable if the objective is scaled by a constant; + the constant cancels out. (was scaled by 1/n because it computes an + expected value.) + ''' + if self.obj_function == 'SSE': + # get the model + model = self.exp_list[0].get_labeled_model() + + if hasattr(model, "measurement_error"): # user defined the measurement_error attribute + # get the measurement errors + meas_error = [model.measurement_error[y_hat] for y_hat, y in model.experiment_outputs.items()] + + if all(item is None for item in meas_error): # user does not supply the value of the errors + measurement_var = sse / (n - l) # estimate of the measurement variance + if method == "reduced_hessian": + cov = 2 * measurement_var * inv_red_hes # covariance matrix + cov = pd.DataFrame( + cov, index=self.estimated_theta.keys(), columns=self.estimated_theta.keys() + ) + elif method == "jacobian": + FIM_all_exp = [] + for experiment in self.exp_list: # loop through the experiments and compute the FIM + FIM_all_exp.append(compute_FIM(experiment, self.estimated_theta, absolute_perturbation=1e-6, + solver_object=solver, estimated_var=measurement_var)) + + # Total FIM of experiments + FIM_total = np.sum(FIM_all_exp, axis=0) + + # covariance matrix + cov = np.linalg.inv(FIM_total) + cov = pd.DataFrame( + cov, index=self.estimated_theta.keys(), columns=self.estimated_theta.keys() + ) + else: + raise NotImplementedError('Only jacobian, reduced_hessian, and kaug methods are ' + 'supported for covariance calculation') + else: # user supplies the value of the errors + if method == "reduced_hessian": + cov = 2 * (meas_error[0] ** 2) * inv_red_hes + cov = pd.DataFrame( + cov, index=self.estimated_theta.keys(), columns=self.estimated_theta.keys() + ) + elif method == "jacobian": + FIM_all_exp = [] + for experiment in self.exp_list: # loop through the experiments and compute the FIM + FIM_all_exp.append(compute_FIM(experiment, self.estimated_theta, absolute_perturbation=1e-6, + solver_object=solver)) + + # Total FIM of experiments + FIM_total = np.sum(FIM_all_exp, axis=0) + + # covariance matrix + cov = np.linalg.inv(FIM_total) + cov = pd.DataFrame( + cov, index=self.estimated_theta.keys(), columns=self.estimated_theta.keys() + ) + else: + raise NotImplementedError('Only jacobian, reduced_hessian, and kaug methods are ' + 'supported for covariance calculation') + else: # user did not define the measurement_error attribute + measurement_var = sse / (n - l) # estimate of the measurement variance + cov = 2 * measurement_var * inv_red_hes + cov = pd.DataFrame( + cov, index=self.estimated_theta.keys(), columns=self.estimated_theta.keys() + ) + elif self.obj_function == 'SSE_weighted': + if method == "jacobian": + FIM_all_exp = [] + for experiment in self.exp_list: # loop through the experiments and compute the FIM + FIM_all_exp.append(compute_FIM(experiment, self.estimated_theta, absolute_perturbation=1e-6, + solver_object=solver)) + + # Total FIM of experiments + FIM_total = np.sum(FIM_all_exp, axis=0) + + # covariance matrix + cov = np.linalg.inv(FIM_total) + cov = pd.DataFrame( + cov, index=self.estimated_theta.keys(), columns=self.estimated_theta.keys() + ) + elif method == "reduced_hessian": + cov = inv_red_hes + cov = pd.DataFrame( + cov, index=self.estimated_theta.keys(), columns=self.estimated_theta.keys() + ) + else: + raise NotImplementedError('Only jacobian, reduced_hessian, and kaug methods are supported ' + 'for covariance calculation') + else: + raise NotImplementedError('Covariance calculation is only supported for SSE and SSE_weighted objectives') + + return cov + def _Q_at_theta(self, thetavals, initialize_parmest_model=False): """ Return the objective function value with fixed theta values. @@ -1016,7 +1085,7 @@ def _get_sample_list(self, samplesize, num_samples, replacement=True): return samplelist def theta_est( - self, solver="ef_ipopt", return_values=[], calc_cov=False, cov_n=None + self, solver="ef_ipopt", return_values=[] ): """ Parameter estimation using all scenarios in the data @@ -1046,39 +1115,67 @@ def theta_est( Covariance matrix of the fitted parameters (only for solver='ef_ipopt') """ - # check if we are using deprecated parmest - if self.pest_deprecated is not None: - return self.pest_deprecated.theta_est( - solver=solver, - return_values=return_values, - calc_cov=calc_cov, - cov_n=cov_n, - ) - assert isinstance(solver, str) assert isinstance(return_values, list) - assert isinstance(calc_cov, bool) - if calc_cov: - num_unknowns = max( - [ - len(experiment.get_labeled_model().unknown_parameters) - for experiment in self.exp_list - ] - ) - assert isinstance(cov_n, int), ( - "The number of datapoints that are used in the objective function is " - "required to calculate the covariance matrix" - ) - assert ( - cov_n > num_unknowns - ), "The number of datapoints must be greater than the number of parameters to estimate" return self._Q_opt( solver=solver, return_values=return_values, - bootlist=None, - calc_cov=calc_cov, - cov_n=cov_n, + bootlist=None + ) + + def cov_est( + self, method="jacobian", solver="ipopt", cov_n=None + ): + """ + Parameter estimation using all scenarios in the data + + Parameters + ---------- + solver: string, optional + Currently only "ef_ipopt" is supported. Default is "ef_ipopt". + return_values: list, optional + List of Variable names, used to return values from the model for data reconciliation + calc_cov: boolean, optional + If True, calculate and return the covariance matrix (only for "ef_ipopt" solver). + Default is False. + cov_n: int, optional + If calc_cov=True, then the user needs to supply the number of datapoints + that are used in the objective function. + + Returns + ------- + objectiveval: float + The objective function value + thetavals: pd.Series + Estimated values for theta + variable values: pd.DataFrame + Variable values for each variable name in return_values (only for solver='ef_ipopt') + cov: pd.DataFrame + Covariance matrix of the fitted parameters (only for solver='ef_ipopt') + """ + + assert isinstance(solver, str) + + # number of unknown parameters + num_unknowns = max( + [ + len(experiment.get_labeled_model().unknown_parameters) + for experiment in self.exp_list + ] + ) + assert isinstance(cov_n, int), ( + "The number of datapoints that are used in the objective function is " + "required to calculate the covariance matrix" + ) + assert ( + cov_n > num_unknowns + ), "The number of datapoints must be greater than the number of parameters to estimate" + + return self._cov_at_theta( + method=method, + solver=solver, + cov_n=cov_n ) def theta_est_bootstrap( @@ -1761,13 +1858,13 @@ def _instance_creation_callback(self, experiment_number=None, cb_data=None): return model def _Q_opt( - self, - ThetaVals=None, - solver="ef_ipopt", - return_values=[], - bootlist=None, - calc_cov=False, - cov_n=None, + self, + ThetaVals=None, + solver="ef_ipopt", + return_values=[], + bootlist=None, + calc_cov=False, + cov_n=None, ): """ Set up all thetas as first stage Vars, return resulting theta @@ -1858,88 +1955,14 @@ def _Q_opt( for ndname, Var, solval in ef_nonants(ef): # process the name # the scenarios are blocks, so strip the scenario name - vname = Var.name[Var.name.find(".") + 1 :] + vname = Var.name[Var.name.find(".") + 1:] thetavals[vname] = solval objval = pyo.value(ef.EF_Obj) if calc_cov: - # Calculate the covariance matrix - - # Number of data points considered - n = cov_n - - # Extract number of fitted parameters - l = len(thetavals) - - # Assumption: Objective value is sum of squared errors - sse = objval - - '''Calculate covariance assuming experimental observation errors are - independent and follow a Gaussian - distribution with constant variance. - - The formula used in parmest was verified against equations (7-5-15) and - (7-5-16) in "Nonlinear Parameter Estimation", Y. Bard, 1974. - - This formula is also applicable if the objective is scaled by a constant; - the constant cancels out. (was scaled by 1/n because it computes an - expected value.) - ''' - if self.obj_function == 'SSE': # covariance calculation for measurements in the same unit - # get the model - model = self.exp_list[0].get_labeled_model() - - # covariance matrix if the user defines the measurement errors - if hasattr(model, "measurement_error"): - # get the measurement errors - meas_error = [model.measurement_error[y_hat] for y_hat, y in model.experiment_outputs.items()] - - # check if the user supplied values for the measurement errors - if all(item is None for item in meas_error): - cov = 2 * (sse / (n - l)) * inv_red_hes # covariance matrix - cov = pd.DataFrame( - cov, index=thetavals.keys(), columns=thetavals.keys() - ) - else: - cov = 2 * (meas_error[0] ** 2) * inv_red_hes # covariance matrix - cov = pd.DataFrame( - cov, index=thetavals.keys(), columns=thetavals.keys() - ) - else: - cov = 2 * (sse / (n - l)) * inv_red_hes # covariance matrix when the measurement errors are not defined - cov = pd.DataFrame( - cov, index=thetavals.keys(), columns=thetavals.keys() - ) - elif self.obj_function == 'SSE_weighted': # covariance calculation for measurements in diff. units - # Store the FIM of all the experiments - FIM_all_exp = [] - for experiment in self.exp_list: # loop through the experiments - # get a copy of the model - model = experiment.get_labeled_model().clone() - - # fix the parameter values to the optimal values estimated - params = [k for k, v in model.unknown_parameters.items()] - for param in params: - param.fix(thetavals[param.name]) - - # resolve the model - solver = pyo.SolverFactory("ipopt") - solver.solve(model) - - # compute the FIM - FIM_all_exp.append(compute_FIM(model, relative_perturbation=1e-6)) - - # Total FIM of experiments - FIM_total = np.sum(FIM_all_exp, axis=0) - - # covariance matrix - cov = np.linalg.inv(FIM_total) - cov = pd.DataFrame( - cov, index=thetavals.keys(), columns=thetavals.keys() - ) - else: - raise NotImplementedError('Covariance calculation is only supported for SSE and SSE_weighted objectives') + raise NotImplementedError('Computing the covariance is no longer supported ' + 'in the deprecated interface') thetavals = pd.Series(thetavals) From 0aaf29b79fbadb33e7607194e1a2e9fe7c34cce3 Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Wed, 7 May 2025 11:59:35 -0400 Subject: [PATCH 08/35] Updated parmest.py file --- pyomo/contrib/parmest/parmest.py | 453 ++++++++++++++++++++----------- 1 file changed, 289 insertions(+), 164 deletions(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index d2b6d942f1e..e41bd8c6c6c 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -61,6 +61,8 @@ from pyomo.opt import SolverFactory from pyomo.environ import Block, ComponentUID +from pyomo.contrib.sensitivity_toolbox.sens import get_dsdp + import pyomo.contrib.parmest.utils as utils import pyomo.contrib.parmest.graphics as graphics from pyomo.dae import ContinuousSet @@ -226,7 +228,6 @@ def _experiment_instance_creation_callback( return instance - def SSE(model): """ Sum of squared error between the model prediction of measured variables and data values, @@ -240,25 +241,48 @@ def SSE_weighted(model): Weighted sum of squared error between the model prediction of measured variables and data values, assuming Gaussian i.i.d errors, with measurement error standard deviation defined in the annotated Pyomo model """ - expr = (1 / 2) * sum( - ((y - y_hat) / model.measurement_error[y_hat]) ** 2 - for y_hat, y in model.experiment_outputs.items() - ) - return expr + if not hasattr(model, "measurement_error"): + raise AttributeError('The model must have a `measurement_error` attribute. Please define it') + + if all(model.measurement_error[y_hat] is None for y_hat in model.experiment_outputs): + raise ValueError('Measurement error values are missing. Please ensure all are supplied') + elif all(model.measurement_error[y_hat] is not None for y_hat in model.experiment_outputs): + expr = (1 / 2) * sum( + ((y - y_hat) / model.measurement_error[y_hat]) ** 2 + for y_hat, y in model.experiment_outputs.items() + ) + return expr + else: + raise ValueError('One or more values of the measurement errors have not been supplied') -# Compute the Jacobian matrix of measurement predictions with respect to changes in parameter values -def _compute_jacobian(model, absolute_perturbation, solver_object): +# Compute the Jacobian matrix of measured variables with respect to the parameters +def _compute_jacobian(experiment, thetavals, absolute_perturbation, solver, tee): """ - Computes the Jacobian matrix of measurement predictions with respect to changes in parameter values + Computes the Jacobian matrix of the measured variables with respect to the parameters using central finite difference scheme Arguments: - model: Pyomo model containing experiment_outputs and measurement_error - absolute_perturbation: value used to perturb the parameters + experiment: Estimator class object that contains the model for a particular experimental condition + thetavals: estimated parameter values + absolute_perturbation: value used to perturb the objectives + solver: A ``solver`` object specified by the user + tee: Solver option to be passed for verbose output Returns: J: Jacobian matrix """ + # grab and clone the model + model = experiment.get_labeled_model().clone() + + # fix the parameter values to the optimal values estimated + params = [k for k, v in model.unknown_parameters.items()] + for param in params: + param.fix(thetavals[param.name]) + + # resolve the model with the estimated parameters + solver = pyo.SolverFactory(solver) + solver.solve(model, tee=tee) + # get the measured variables y_hat_list = [y_hat for y_hat, y in model.experiment_outputs.items()] @@ -281,8 +305,7 @@ def _compute_jacobian(model, absolute_perturbation, solver_object): param.fix(orig_value + absolute_perturbation) # solve model - solver = pyo.SolverFactory(solver_object) - solver.solve(model) + solver.solve(model, tee=tee) # forward perturbation measured variables y_hat_plus = [pyo.value(y_hat) for y_hat, y in model.experiment_outputs.items()] @@ -291,7 +314,7 @@ def _compute_jacobian(model, absolute_perturbation, solver_object): param.fix(orig_value - absolute_perturbation) # resolve model - solver.solve(model) + solver.solve(model, tee=tee) # backward perturbation measured variables y_hat_minus = [pyo.value(y_hat) for y_hat, y in model.experiment_outputs.items()] @@ -304,58 +327,209 @@ def _compute_jacobian(model, absolute_perturbation, solver_object): return J +# Compute the covariance matrix of the estimated parameters +def compute_cov(experiment_list, method, thetavals, absolute_perturbation, solver, tee, estimated_var=None): + """ + Computes the parameter covariance matrix from the list of experimental conditions using jacobian and kaug methods + + Arguments: + experiment_list: list of Estimator class objects containing the model for different experimental conditions + method: a ``method`` object specified by the user (e.g., jacobian or kaug) + thetavals: estimated parameter values + absolute_perturbation: value used to perturb the objectives + solver: a ``solver`` object specified by the user (e.g., ipopt) + tee: Solver option to be passed for verbose output + estimated_var: estimated variance of the measurement error when the measurement error standard deviation + is not supplied by user + + Returns: + cov: covariance matrix of the estimated parameters + """ + if method == "jacobian": + # store the FIM of all experiments + FIM_all_exp = [] + for experiment in experiment_list: # loop through the experiments and compute the FIM + FIM_all_exp.append( + _jac_FIM(experiment, thetavals=thetavals, absolute_perturbation=absolute_perturbation, + solver=solver, tee=tee, estimated_var=estimated_var)) + + FIM = np.sum(FIM_all_exp, axis=0) + + # covariance matrix + cov = np.linalg.inv(FIM) + cov = pd.DataFrame( + cov, index=thetavals.keys(), columns=thetavals.keys() + ) + elif method == "kaug": + # store the FIM of all experiments + FIM_all_exp = [] + for experiment in experiment_list: # loop through the experiments and compute the FIM + FIM_all_exp.append( + _kaug_FIM(experiment, thetavals=thetavals, solver=solver, tee=tee, estimated_var=estimated_var)) + + FIM = np.sum(FIM_all_exp, axis=0) + + # covariance matrix + cov = np.linalg.inv(FIM) + cov = pd.DataFrame( + cov, index=thetavals.keys(), columns=thetavals.keys() + ) + else: + raise ValueError("The method provided, {}, must be either `jacobian` or `kaug`".format(method)) + + return cov + # compute the Fisher information matrix of the estimated parameters -def compute_FIM(experiment, thetavals, absolute_perturbation, solver_object, estimated_var=None): +def _jac_FIM(experiment, thetavals, absolute_perturbation, solver, tee, estimated_var=None): """ - Compute the Fisher information matrix from the Jacobian matrix and measurement errors + Compute the Fisher information matrix from the Jacobian matrix and + measurement errors standard deviation defined in the annotated Pyomo model Arguments: - model: Pyomo model containing the experiment outputs and measurement errors + experiment: Estimator class object that contains the model + thetavals: estimated parameter values absolute_perturbation: value used to perturb the objectives + solver: A ``solver`` object specified by the user + tee: Solver option to be passed for verbose output + estimated_var: estimated variance of the measurement error when the measurement error standard deviation + is not supplied by user Returns: - FIM: Fisher information matrix + FIM: Fisher information matrix about the parameters + """ + # compute the Jacobian matrix + J = _compute_jacobian(experiment, thetavals, absolute_perturbation, solver, tee) + + # computing the condition number of the Jacobian matrix + cond_number_jac = np.linalg.cond(J) + print("The condition number of the Jacobian matrix is:", cond_number_jac) + + # grab the model + model = experiment.get_labeled_model() + + # extract the measured variables and measurement errors + y_hat_list = [y_hat for y_hat, y in model.experiment_outputs.items()] + + # check if the model has a defined measurement_error attribute and supplied measurement error standard deviation + if hasattr(model, "measurement_error") and all(model.measurement_error[y_hat] is not None for y_hat in + model.experiment_outputs): + error_list = [model.measurement_error[y_hat] for y_hat in model.experiment_outputs] + + # compute the matrix of the inverse of the measurement variance + # the following assumes independent measurement errors + W = np.diag([1 / (err ** 2) for err in error_list]) + + # check if error list is consistent + if len(error_list) == 0 or len(y_hat_list) == 0: + raise ValueError("Experiment outputs and measurement errors cannot be empty") + + # check if the dimension of error_list is same with that of y_hat_list + if len(error_list) != len(y_hat_list): + raise ValueError("Experiment outputs and measurement errors are not the same length") + + # calculate the FIM + FIM = J.T @ W @ J + else: + FIM = (1 / estimated_var) * (J.T @ J) + + return FIM + +def _kaug_FIM(experiment, thetavals, solver, tee, estimated_var=None): """ - if not isinstance(solver_object, str): - raise TypeError("Expected a string for the solver object") + Compute the FIM using kaug, a sensitivity-based approach that uses the annotated Pyomo model optimality conditions + and user-defined measurement errors standard deviation + + Disclaimer - code adopted from the kaug function implemented in Pyomo.DoE + + Arguments: + experiment: Estimator class object that contains the model + thetavals: estimated parameter values + solver: A ``solver`` object specified by the user + tee: Solver option to be passed for verbose output + estimated_var: estimated variance of the measurement error when the measurement error standard deviation + is not supplied by user + Returns: + FIM: Fisher information matrix about the parameters + """ + # grab and clone the model model = experiment.get_labeled_model().clone() - # fix the parameter values to the optimal values estimated + # fix the parameter values to the estimated values params = [k for k, v in model.unknown_parameters.items()] for param in params: param.fix(thetavals[param.name]) - # resolve the model - solver = pyo.SolverFactory(solver_object) - solver.solve(model) + # resolve the model with the estimated parameters + solver = pyo.SolverFactory(solver) + solver.solve(model, tee=tee) - # extract the measured variables and measurement errors - y_hat_list = [y_hat for y_hat, y in model.experiment_outputs.items()] - error_list = [model.measurement_error[y_hat] for y_hat, y in model.experiment_outputs.items()] + # add zero (dummy/placeholder) objective function + if not hasattr(model, "objective"): + model.objective = pyo.Objective(expr=0, sense=pyo.minimize) - # check if error list is consistent - if len(error_list) == 0 or len(y_hat_list) == 0: - raise ValueError("Experiment outputs and measurement errors cannot be empty.") + # Fix design variables to make the problem square + for comp in model.experiment_inputs: + comp.fix() - # check if the dimension of error_list is same with that of y_hat_list - if len(error_list) != len(y_hat_list): - raise ValueError("Experiment outputs and measurement errors are not the same length.") + solver.solve(model, tee=tee) - if estimated_var is None: # user supplies the measurement errors - W = np.diag([1 / (err**2) for err in error_list]) # matrix of the inverse of measurement variance - else: # user does not supply the measurement errors - W = 1 / estimated_var + # Probe the solved model for dsdp results (sensitivities s.t. parameters) + params_dict = {k.name: v for k, v in model.unknown_parameters.items()} + params_names = list(params_dict.keys()) - # compute the Jacobian matrix - J = _compute_jacobian(model, absolute_perturbation, solver_object) + dsdp_re, col = get_dsdp(model, params_names, params_dict, tee=tee) - # computing the condition number of the Jacobian matrix - cond_number_jac = np.linalg.cond(J) - print("The condition number of the Jacobian matrix is:",cond_number_jac) + # analyze result + dsdp_array = dsdp_re.toarray().T + + # store dsdp returned + dsdp_extract = [] + + # get right lines from results + measurement_index = [] + + # loop over measurement variables and their time points + for k, v in model.experiment_outputs.items(): + name = k.name + try: + kaug_no = col.index(name) + measurement_index.append(kaug_no) + # get right line of dsdp + dsdp_extract.append(dsdp_array[kaug_no]) + except: + # k_aug does not provide value for fixed variables + logging.getLogger(__name__).debug("The variable is fixed: %s", name) + # produce the sensitivity for fixed variables + zero_sens = np.zeros(len(params_names)) + # for fixed variables, the sensitivity are a zero vector + dsdp_extract.append(zero_sens) + + # Extract and calculate sensitivity if scaled by constants or parameters. + jac = [[] for k in params_names] + + for d in range(len(dsdp_extract)): + for k, v in model.unknown_parameters.items(): + p = params_names.index(k.name) # Index of parameter in np array + sensi = dsdp_extract[d][p] + jac[p].append(sensi) + + # record kaug jacobian + kaug_jac = np.array(jac).T + + # compute FIM + # compute matrix of the inverse of the measurement variance + # The following assumes independent measurement error. + W = np.zeros((len(model.measurement_error), len(model.measurement_error))) + count = 0 + for k, v in model.measurement_error.items(): + if all(model.measurement_error[y_hat] is not None for y_hat in model.experiment_outputs): + W[count, count] = 1 / (v ** 2) + else: + W[count, count] = 1 / (estimated_var) + count += 1 - # calculate the FIM - FIM = J.T @ W @ J + FIM = kaug_jac.T @ W @ kaug_jac return FIM @@ -654,6 +828,8 @@ def _Q_opt( ) ) + self.inv_red_hes = inv_red_hes + if self.diagnostic_mode: print( ' Solver termination condition = ', @@ -719,34 +895,16 @@ def _cov_at_theta( cov_n, ): """ - Parameter estimation using all scenarios in the data - - Parameters - ---------- - solver: string, optional - Currently only "ef_ipopt" is supported. Default is "ef_ipopt". - return_values: list, optional - List of Variable names, used to return values from the model for data reconciliation - calc_cov: boolean, optional - If True, calculate and return the covariance matrix (only for "ef_ipopt" solver). - Default is False. - cov_n: int, optional - If calc_cov=True, then the user needs to supply the number of datapoints - that are used in the objective function. + Covariance matrix calculation using all scenarios in the data - Returns - ------- - objectiveval: float - The objective function value - thetavals: pd.Series - Estimated values for theta - variable values: pd.DataFrame - Variable values for each variable name in return_values (only for solver='ef_ipopt') - cov: pd.DataFrame - Covariance matrix of the fitted parameters (only for solver='ef_ipopt') + Argument: + method: str, a ``method`` object specified by the user (e.g., jacobian or kaug) + solver: str, a ``solver`` object specified by the user (e.g., ipopt) + cov_n: int, the user needs to supply the number of datapoints that are used in the objective function + Returns: + cov: pd.DataFrame, covariance matrix of the estimated parameters """ - # Number of data points considered n = cov_n @@ -766,92 +924,78 @@ def _cov_at_theta( the constant cancels out. (was scaled by 1/n because it computes an expected value.) ''' - if self.obj_function == 'SSE': - # get the model - model = self.exp_list[0].get_labeled_model() + # get the model + model = self.exp_list[0].get_labeled_model() - if hasattr(model, "measurement_error"): # user defined the measurement_error attribute + # check if the user specified SSE or SSE_weighted as objective function + if self.obj_function == 'SSE': + # check if user defined the measurement_error attribute + if hasattr(model, "measurement_error"): # get the measurement errors meas_error = [model.measurement_error[y_hat] for y_hat, y in model.experiment_outputs.items()] - if all(item is None for item in meas_error): # user does not supply the value of the errors + # check if the user supplied the values of the measurement errors + if all(item is None for item in meas_error): measurement_var = sse / (n - l) # estimate of the measurement variance if method == "reduced_hessian": - cov = 2 * measurement_var * inv_red_hes # covariance matrix + cov = 2 * measurement_var * self.inv_red_hes # covariance matrix cov = pd.DataFrame( cov, index=self.estimated_theta.keys(), columns=self.estimated_theta.keys() ) elif method == "jacobian": - FIM_all_exp = [] - for experiment in self.exp_list: # loop through the experiments and compute the FIM - FIM_all_exp.append(compute_FIM(experiment, self.estimated_theta, absolute_perturbation=1e-6, - solver_object=solver, estimated_var=measurement_var)) - - # Total FIM of experiments - FIM_total = np.sum(FIM_all_exp, axis=0) - - # covariance matrix - cov = np.linalg.inv(FIM_total) - cov = pd.DataFrame( - cov, index=self.estimated_theta.keys(), columns=self.estimated_theta.keys() - ) + cov = compute_cov(self.exp_list, method, thetavals=self.estimated_theta, solver=solver, + absolute_perturbation=1e-6, tee=self.tee, estimated_var=measurement_var) + elif method == "kaug": + cov = compute_cov(self.exp_list, method, thetavals=self.estimated_theta, solver=solver, + absolute_perturbation=1e-6, tee=self.tee, estimated_var=measurement_var) else: - raise NotImplementedError('Only jacobian, reduced_hessian, and kaug methods are ' - 'supported for covariance calculation') - else: # user supplies the value of the errors + raise NotImplementedError('Only `jacobian`, `reduced_hessian`, and `kaug` methods are ' + 'supported') + elif all(item is not None for item in meas_error): if method == "reduced_hessian": - cov = 2 * (meas_error[0] ** 2) * inv_red_hes + cov = 2 * (meas_error[0] ** 2) * self.inv_red_hes cov = pd.DataFrame( cov, index=self.estimated_theta.keys(), columns=self.estimated_theta.keys() ) elif method == "jacobian": - FIM_all_exp = [] - for experiment in self.exp_list: # loop through the experiments and compute the FIM - FIM_all_exp.append(compute_FIM(experiment, self.estimated_theta, absolute_perturbation=1e-6, - solver_object=solver)) - - # Total FIM of experiments - FIM_total = np.sum(FIM_all_exp, axis=0) + cov = compute_cov(self.exp_list, method, thetavals=self.estimated_theta, solver=solver, + absolute_perturbation=1e-6, tee=self.tee) + elif method == "kaug": + cov = compute_cov(self.exp_list, method, thetavals=self.estimated_theta, + absolute_perturbation=1e-6, solver=solver, tee=self.tee) + else: + raise NotImplementedError('Only `jacobian`, `reduced_hessian`, and `kaug` methods are ' + 'supported') + else: + raise ValueError('One or more values of the measurement errors have not been supplied') + else: + raise AttributeError('The model must have a `measurement_error` attribute. Please define it') + elif self.obj_function == 'SSE_weighted': + # check if the user defined the measurement_error attribute + if hasattr(model, "measurement_error"): + meas_error = [model.measurement_error[y_hat] for y_hat, y in model.experiment_outputs.items()] - # covariance matrix - cov = np.linalg.inv(FIM_total) + # check if the user supplied values for the measurement errors + if all(item is not None for item in meas_error): + if method == "jacobian": + cov = compute_cov(self.exp_list, method, thetavals=self.estimated_theta, + absolute_perturbation=1e-6, solver=solver, tee=self.tee) + elif method == "kaug": + cov = compute_cov(self.exp_list, method, thetavals=self.estimated_theta, + absolute_perturbation=1e-6, solver=solver, tee=self.tee) + elif method == "reduced_hessian": + cov = self.inv_red_hes cov = pd.DataFrame( cov, index=self.estimated_theta.keys(), columns=self.estimated_theta.keys() ) else: - raise NotImplementedError('Only jacobian, reduced_hessian, and kaug methods are ' - 'supported for covariance calculation') - else: # user did not define the measurement_error attribute - measurement_var = sse / (n - l) # estimate of the measurement variance - cov = 2 * measurement_var * inv_red_hes - cov = pd.DataFrame( - cov, index=self.estimated_theta.keys(), columns=self.estimated_theta.keys() - ) - elif self.obj_function == 'SSE_weighted': - if method == "jacobian": - FIM_all_exp = [] - for experiment in self.exp_list: # loop through the experiments and compute the FIM - FIM_all_exp.append(compute_FIM(experiment, self.estimated_theta, absolute_perturbation=1e-6, - solver_object=solver)) - - # Total FIM of experiments - FIM_total = np.sum(FIM_all_exp, axis=0) - - # covariance matrix - cov = np.linalg.inv(FIM_total) - cov = pd.DataFrame( - cov, index=self.estimated_theta.keys(), columns=self.estimated_theta.keys() - ) - elif method == "reduced_hessian": - cov = inv_red_hes - cov = pd.DataFrame( - cov, index=self.estimated_theta.keys(), columns=self.estimated_theta.keys() - ) + raise NotImplementedError('Only `jacobian`, `reduced_hessian`, and `kaug` methods are supported') + else: + raise ValueError('One or more values of the measurement errors have not been supplied') else: - raise NotImplementedError('Only jacobian, reduced_hessian, and kaug methods are supported ' - 'for covariance calculation') + raise AttributeError('The model must have a "measurement_error" attribute. Please define it') else: - raise NotImplementedError('Covariance calculation is only supported for SSE and SSE_weighted objectives') + raise NotImplementedError('Covariance calculation is only supported for `SSE` and `SSE_weighted` objectives') return cov @@ -1096,12 +1240,6 @@ def theta_est( Currently only "ef_ipopt" is supported. Default is "ef_ipopt". return_values: list, optional List of Variable names, used to return values from the model for data reconciliation - calc_cov: boolean, optional - If True, calculate and return the covariance matrix (only for "ef_ipopt" solver). - Default is False. - cov_n: int, optional - If calc_cov=True, then the user needs to supply the number of datapoints - that are used in the objective function. Returns ------- @@ -1111,8 +1249,6 @@ def theta_est( Estimated values for theta variable values: pd.DataFrame Variable values for each variable name in return_values (only for solver='ef_ipopt') - cov: pd.DataFrame - Covariance matrix of the fitted parameters (only for solver='ef_ipopt') """ assert isinstance(solver, str) @@ -1128,34 +1264,23 @@ def cov_est( self, method="jacobian", solver="ipopt", cov_n=None ): """ - Parameter estimation using all scenarios in the data + Covariance matrix calculation using all scenarios in the data - Parameters - ---------- - solver: string, optional - Currently only "ef_ipopt" is supported. Default is "ef_ipopt". - return_values: list, optional - List of Variable names, used to return values from the model for data reconciliation - calc_cov: boolean, optional - If True, calculate and return the covariance matrix (only for "ef_ipopt" solver). - Default is False. - cov_n: int, optional - If calc_cov=True, then the user needs to supply the number of datapoints - that are used in the objective function. + Argument: + method: str, a ``method`` object specified by the user (e.g., jacobian or kaug) + solver: str, a ``solver`` object specified by the user (e.g., ipopt) + cov_n: int, the user needs to supply the number of datapoints that are used in the objective function - Returns - ------- - objectiveval: float - The objective function value - thetavals: pd.Series - Estimated values for theta - variable values: pd.DataFrame - Variable values for each variable name in return_values (only for solver='ef_ipopt') - cov: pd.DataFrame - Covariance matrix of the fitted parameters (only for solver='ef_ipopt') + Returns: + cov: pd.DataFrame, covariance matrix of the estimated parameters """ + # check if the solver input is a string + if not isinstance(solver, str): + raise TypeError("Expected a string for the solver") - assert isinstance(solver, str) + # check if the method input is a string + if not isinstance(method, str): + raise TypeError("Expected a string for the method") # number of unknown parameters num_unknowns = max( From b3aa7b1d6eba9a5d201c8f1b90c44a307118c53f Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Sun, 11 May 2025 22:58:51 -0400 Subject: [PATCH 09/35] Updated parmest.py file --- pyomo/contrib/parmest/parmest.py | 538 +++++++++++++++++++++---------- 1 file changed, 362 insertions(+), 176 deletions(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index e41bd8c6c6c..eddbd081afc 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -37,6 +37,7 @@ import pyomo.contrib.parmest.utils.create_ef as local_ef import pyomo.contrib.parmest.utils.scenario_tree as scenario_tree +from enum import Enum import re import importlib as im import logging @@ -228,66 +229,136 @@ def _experiment_instance_creation_callback( return instance + def SSE(model): """ - Sum of squared error between the model prediction of measured variables and data values, - assuming Gaussian i.i.d errors + Returns an expression that is used to compute the sum of squared error (`SSE`) objective, + assuming Gaussian i.i.d. errors + + Argument: + model: annotated Pyomo model """ + # Check that experimental outputs exist + try: + measured_variables = [y_hat.name for y_hat, y in model.experiment_outputs.items()] + except: + raise RuntimeError( + "Experiment model does not have suffix " + '"experiment_outputs".' + ) + + # sum of squared error between the prediction and observation of the measured variables expr = sum((y - y_hat) ** 2 for y_hat, y in model.experiment_outputs.items()) return expr + def SSE_weighted(model): """ - Weighted sum of squared error between the model prediction of measured variables and data values, - assuming Gaussian i.i.d errors, with measurement error standard deviation defined in the annotated Pyomo model + Returns an expression that is used to compute the `SSE_weighted` objective, + assuming Gaussian i.i.d. errors, with measurement error standard deviation defined in the annotated Pyomo model + + Argument: + model: annotated Pyomo model """ - if not hasattr(model, "measurement_error"): - raise AttributeError('The model must have a `measurement_error` attribute. Please define it') + # Check that experimental outputs exist + try: + measured_variables = [y_hat.name for y_hat, y in model.experiment_outputs.items()] + except: + raise RuntimeError( + "Experiment model does not have suffix " + '"experiment_outputs".' + ) + + # check that measurement errors exist + try: + measured_variables_error = [k.name for k, v in model.measurement_error.items()] + except: + raise RuntimeError( + "Experiment model does not have suffix " + '"measurement_error".' + ) - if all(model.measurement_error[y_hat] is None for y_hat in model.experiment_outputs): - raise ValueError('Measurement error values are missing. Please ensure all are supplied') - elif all(model.measurement_error[y_hat] is not None for y_hat in model.experiment_outputs): + # check if all the values of the measurement error standard deviation have been supplied + if all( + model.measurement_error[y_hat] is not None for y_hat in model.experiment_outputs + ): + # calculate the weighted SSE between the prediction and observation of the measured variables expr = (1 / 2) * sum( ((y - y_hat) / model.measurement_error[y_hat]) ** 2 for y_hat, y in model.experiment_outputs.items() ) return expr else: - raise ValueError('One or more values of the measurement errors have not been supplied') + raise ValueError( + 'One or more values are missing from `measurement_error`.' + ) + + +class CovMethodLib(Enum): + finite_difference = "finite_difference" + kaug = "kaug" + reduced_hessian = "reduced_hessian" + + +class ObjectiveLib(Enum): + SSE = "SSE" + SSE_weighted = "SSE_weighted" + # Compute the Jacobian matrix of measured variables with respect to the parameters -def _compute_jacobian(experiment, thetavals, absolute_perturbation, solver, tee): +def _compute_jacobian(experiment, thetavals, step, solver, tee): """ Computes the Jacobian matrix of the measured variables with respect to the parameters using central finite difference scheme Arguments: - experiment: Estimator class object that contains the model for a particular experimental condition - thetavals: estimated parameter values - absolute_perturbation: value used to perturb the objectives - solver: A ``solver`` object specified by the user - tee: Solver option to be passed for verbose output + experiment: Estimator class object, that contains the model for a particular experimental condition + thetavals: dictionary containing the estimates of the unknown parameters + step: float or integer used to perturb the objectives + solver: string ``solver`` object specified by the user + tee: boolean solver option to be passed for verbose output Returns: J: Jacobian matrix """ # grab and clone the model - model = experiment.get_labeled_model().clone() + # check if the experiment has a ``get_labeled_model`` function + try: + model = experiment.get_labeled_model().clone() + except: + raise ValueError( + "The experiment object must have a ``get_labeled_model`` function." + ) + + # fix the value of the unknown parameters to the estimated values + # Check that unknown parameters exist + try: + params = [k for k, v in model.unknown_parameters.items()] + except: + raise RuntimeError( + "Experiment model does not have suffix " + '"unknown_parameters".' + ) - # fix the parameter values to the optimal values estimated - params = [k for k, v in model.unknown_parameters.items()] for param in params: param.fix(thetavals[param.name]) # resolve the model with the estimated parameters - solver = pyo.SolverFactory(solver) - solver.solve(model, tee=tee) + try: + solver = pyo.SolverFactory(solver) + res = solver.solve(model, tee=tee) + pyo.assert_optimal_termination(res) + except: + raise RuntimeError( + "Model from experiment did not solve appropriately. Make sure the model is well-posed." + ) # get the measured variables - y_hat_list = [y_hat for y_hat, y in model.experiment_outputs.items()] + # check that experimental outputs exist + try: + y_hat_list = [y_hat for y_hat, y in model.experiment_outputs.items()] + except: + raise RuntimeError( + "Experiment model does not have suffix " + '"experiment_outputs".' + ) - # get the parameters - params = [k for k, v in model.unknown_parameters.items()] + # get the estimated parameter values param_values = [p.value for p in params] # get the number of parameters and measured variables @@ -301,104 +372,168 @@ def _compute_jacobian(experiment, thetavals, absolute_perturbation, solver, tee) # store original value of the parameter orig_value = param_values[i] + # calculate the relative perturbation + relative_perturbation = step * orig_value + # Forward perturbation - param.fix(orig_value + absolute_perturbation) + param.fix(orig_value + relative_perturbation) # solve model - solver.solve(model, tee=tee) + try: + res = solver.solve(model, tee=tee) + pyo.assert_optimal_termination(res) + except: + raise RuntimeError( + "Model from experiment did not solve appropriately. Make sure the model is well-posed." + ) # forward perturbation measured variables y_hat_plus = [pyo.value(y_hat) for y_hat, y in model.experiment_outputs.items()] # Backward perturbation - param.fix(orig_value - absolute_perturbation) + param.fix(orig_value - relative_perturbation) # resolve model - solver.solve(model, tee=tee) + try: + res = solver.solve(model, tee=tee) + pyo.assert_optimal_termination(res) + except: + raise RuntimeError( + "Model from experiment did not solve appropriately. Make sure the model is well-posed." + ) # backward perturbation measured variables - y_hat_minus = [pyo.value(y_hat) for y_hat, y in model.experiment_outputs.items()] + y_hat_minus = [ + pyo.value(y_hat) for y_hat, y in model.experiment_outputs.items() + ] # Restore original parameter value param.fix(orig_value) # Central difference approximation for the Jacobian - J[:, i] = [(y_hat_plus[w] - y_hat_minus[w]) / (2 * absolute_perturbation) for w in range(len(y_hat_plus))] + J[:, i] = [ + (y_hat_plus[w] - y_hat_minus[w]) / (2 * relative_perturbation) + for w in range(len(y_hat_plus)) + ] return J + # Compute the covariance matrix of the estimated parameters -def compute_cov(experiment_list, method, thetavals, absolute_perturbation, solver, tee, estimated_var=None): +def compute_cov( + experiment_list, + method, + thetavals, + step, + solver, + tee, + estimated_var=None, +): """ - Computes the parameter covariance matrix from the list of experimental conditions using jacobian and kaug methods + Computes the covariance matrix of the estimated parameters using finite difference and kaug methods Arguments: experiment_list: list of Estimator class objects containing the model for different experimental conditions - method: a ``method`` object specified by the user (e.g., jacobian or kaug) - thetavals: estimated parameter values - absolute_perturbation: value used to perturb the objectives - solver: a ``solver`` object specified by the user (e.g., ipopt) - tee: Solver option to be passed for verbose output - estimated_var: estimated variance of the measurement error when the measurement error standard deviation - is not supplied by user + method: string `method` object specified by the user (e.g., kaug) + thetavals: dictionary containing the estimates of the unknown parameters + step: float or integer used to perturb the objectives + solver: string `solver` object specified by the user (e.g., ipopt) + tee: boolean solver option to be passed for verbose output + estimated_var: value of the estimated variance of the measurement error in cases where + the user does not supply the measurement error standard deviation Returns: cov: covariance matrix of the estimated parameters """ - if method == "jacobian": + # check if the supplied method is supported + try: + cov_method = CovMethodLib(method) + except: + raise ValueError(f"Invalid method: '{method}'. Choose from {[e.value for e in CovMethodLib]}.") + + if cov_method == CovMethodLib.finite_difference: # store the FIM of all experiments FIM_all_exp = [] - for experiment in experiment_list: # loop through the experiments and compute the FIM + for ( + experiment + ) in experiment_list: # loop through the experiments and compute the FIM FIM_all_exp.append( - _jac_FIM(experiment, thetavals=thetavals, absolute_perturbation=absolute_perturbation, - solver=solver, tee=tee, estimated_var=estimated_var)) + _finite_difference_FIM( + experiment, + thetavals=thetavals, + step=step, + solver=solver, + tee=tee, + estimated_var=estimated_var, + ) + ) FIM = np.sum(FIM_all_exp, axis=0) # covariance matrix - cov = np.linalg.inv(FIM) - cov = pd.DataFrame( - cov, index=thetavals.keys(), columns=thetavals.keys() - ) - elif method == "kaug": + try: + cov = np.linalg.inv(FIM) + except np.linalg.LinAlgError: + cov = np.linalg.pinv(FIM) + print("The FIM is singular. Using pseudo-inverse instead.") + cov = pd.DataFrame(cov, index=thetavals.keys(), columns=thetavals.keys()) + elif cov_method == CovMethodLib.kaug: # store the FIM of all experiments FIM_all_exp = [] - for experiment in experiment_list: # loop through the experiments and compute the FIM + for ( + experiment + ) in experiment_list: # loop through the experiments and compute the FIM FIM_all_exp.append( - _kaug_FIM(experiment, thetavals=thetavals, solver=solver, tee=tee, estimated_var=estimated_var)) + _kaug_FIM( + experiment, + thetavals=thetavals, + solver=solver, + tee=tee, + estimated_var=estimated_var, + ) + ) FIM = np.sum(FIM_all_exp, axis=0) # covariance matrix - cov = np.linalg.inv(FIM) - cov = pd.DataFrame( - cov, index=thetavals.keys(), columns=thetavals.keys() - ) + try: + cov = np.linalg.inv(FIM) + except np.linalg.LinAlgError: + cov = np.linalg.pinv(FIM) + print("The FIM is singular. Using pseudo-inverse instead.") + cov = pd.DataFrame(cov, index=thetavals.keys(), columns=thetavals.keys()) else: - raise ValueError("The method provided, {}, must be either `jacobian` or `kaug`".format(method)) + raise ValueError( + "The method provided, {}, must be either `finite_difference` or `kaug`".format( + method + ) + ) return cov -# compute the Fisher information matrix of the estimated parameters -def _jac_FIM(experiment, thetavals, absolute_perturbation, solver, tee, estimated_var=None): + +# compute the Fisher information matrix of the estimated parameters using finite difference +def _finite_difference_FIM( + experiment, thetavals, step, solver, tee, estimated_var=None +): """ - Compute the Fisher information matrix from the Jacobian matrix and + Computes the Fisher information matrix from finite difference Jacobian matrix and measurement errors standard deviation defined in the annotated Pyomo model Arguments: - experiment: Estimator class object that contains the model - thetavals: estimated parameter values - absolute_perturbation: value used to perturb the objectives - solver: A ``solver`` object specified by the user - tee: Solver option to be passed for verbose output - estimated_var: estimated variance of the measurement error when the measurement error standard deviation - is not supplied by user + experiment: Estimator class object that contains the model for a particular experimental condition + thetavals: dictionary containing the estimates of the unknown parameters + step: float or integer used to perturb the objectives + solver: string ``solver`` object specified by the user + tee: boolean solver option to be passed for verbose output + estimated_var: value of the estimated variance of the measurement error in cases where + the user does not supply the measurement error standard deviation Returns: FIM: Fisher information matrix about the parameters """ - # compute the Jacobian matrix - J = _compute_jacobian(experiment, thetavals, absolute_perturbation, solver, tee) + # compute the Jacobian matrix using finite difference + J = _compute_jacobian(experiment, thetavals, step, solver, tee) # computing the condition number of the Jacobian matrix cond_number_jac = np.linalg.cond(J) @@ -411,43 +546,52 @@ def _jac_FIM(experiment, thetavals, absolute_perturbation, solver, tee, estimate y_hat_list = [y_hat for y_hat, y in model.experiment_outputs.items()] # check if the model has a defined measurement_error attribute and supplied measurement error standard deviation - if hasattr(model, "measurement_error") and all(model.measurement_error[y_hat] is not None for y_hat in - model.experiment_outputs): - error_list = [model.measurement_error[y_hat] for y_hat in model.experiment_outputs] + if hasattr(model, "measurement_error") and all( + model.measurement_error[y_hat] is not None for y_hat in model.experiment_outputs + ): + error_list = [ + model.measurement_error[y_hat] for y_hat in model.experiment_outputs + ] # compute the matrix of the inverse of the measurement variance # the following assumes independent measurement errors - W = np.diag([1 / (err ** 2) for err in error_list]) + W = np.diag([1 / (err**2) for err in error_list]) # check if error list is consistent if len(error_list) == 0 or len(y_hat_list) == 0: - raise ValueError("Experiment outputs and measurement errors cannot be empty") + raise ValueError( + "Experiment outputs and measurement errors cannot be empty" + ) # check if the dimension of error_list is same with that of y_hat_list if len(error_list) != len(y_hat_list): - raise ValueError("Experiment outputs and measurement errors are not the same length") + raise ValueError( + "Experiment outputs and measurement errors are not the same length" + ) - # calculate the FIM + # calculate the FIM using the formula in Lilonfe et al. (2025) FIM = J.T @ W @ J else: FIM = (1 / estimated_var) * (J.T @ J) return FIM + +# compute the Fisher information matrix of the estimated parameters using kaug def _kaug_FIM(experiment, thetavals, solver, tee, estimated_var=None): """ - Compute the FIM using kaug, a sensitivity-based approach that uses the annotated Pyomo model optimality conditions + Computes the FIM using kaug, a sensitivity-based approach that uses the annotated Pyomo model optimality conditions and user-defined measurement errors standard deviation Disclaimer - code adopted from the kaug function implemented in Pyomo.DoE Arguments: - experiment: Estimator class object that contains the model + experiment: Estimator class object that contains the model for a particular experimental condition thetavals: estimated parameter values - solver: A ``solver`` object specified by the user - tee: Solver option to be passed for verbose output - estimated_var: estimated variance of the measurement error when the measurement error standard deviation - is not supplied by user + solver: string ``solver`` object specified by the user + tee: boolean solver option to be passed for verbose output + estimated_var: value of the estimated variance of the measurement error in cases where + the user does not supply the measurement error standard deviation Returns: FIM: Fisher information matrix about the parameters @@ -523,8 +667,11 @@ def _kaug_FIM(experiment, thetavals, solver, tee, estimated_var=None): W = np.zeros((len(model.measurement_error), len(model.measurement_error))) count = 0 for k, v in model.measurement_error.items(): - if all(model.measurement_error[y_hat] is not None for y_hat in model.experiment_outputs): - W[count, count] = 1 / (v ** 2) + if all( + model.measurement_error[y_hat] is not None + for y_hat in model.experiment_outputs + ): + W[count, count] = 1 / (v**2) else: W[count, count] = 1 / (estimated_var) count += 1 @@ -533,6 +680,7 @@ def _kaug_FIM(experiment, thetavals, solver, tee, estimated_var=None): return FIM + class Estimator(object): """ Parameter estimation class @@ -577,23 +725,32 @@ def __init__( assert isinstance(experiment_list, list) self.exp_list = experiment_list - # check that an experiment has experiment_outputs and unknown_parameters - model = self.exp_list[0].get_labeled_model() + # check if the experiment has a ``get_labeled_model`` function try: - outputs = [k.name for k, v in model.experiment_outputs.items()] + model = self.exp_list[0].get_labeled_model() except: - RuntimeError( - 'Experiment list model does not have suffix ' + '"experiment_outputs".' + raise ValueError( + "The experiment object must have a ``get_labeled_model`` function." + ) + + # check that experimental outputs exist + try: + measured_variables = [y_hat.name for y_hat, y in model.experiment_outputs.items()] + except: + raise RuntimeError( + "Experiment model does not have suffix " + '"experiment_outputs".' ) + + # check that unknown parameters exist try: - params = [k.name for k, v in model.unknown_parameters.items()] + unknown_params = [k.name for k, v in model.unknown_parameters.items()] except: RuntimeError( 'Experiment list model does not have suffix ' + '"unknown_parameters".' ) # populate keyword argument options - self.obj_function = obj_function + self.obj_function = ObjectiveLib(obj_function) self.tee = tee self.diagnostic_mode = diagnostic_mode self.solver_options = solver_options @@ -725,9 +882,9 @@ def _create_parmest_model(self, experiment_number): # TODO, this needs to be turned into an enum class of options that still support # custom functions - if self.obj_function == 'SSE': + if self.obj_function == ObjectiveLib.SSE: second_stage_rule = SSE - elif self.obj_function == 'SSE_weighted': + elif self.obj_function == ObjectiveLib.SSE_weighted: second_stage_rule = SSE_weighted else: # A custom function uses model.experiment_outputs as data @@ -754,11 +911,7 @@ def _instance_creation_callback(self, experiment_number=None, cb_data=None): return model def _Q_opt( - self, - ThetaVals=None, - solver="ef_ipopt", - return_values=[], - bootlist=None, + self, ThetaVals=None, solver="ef_ipopt", return_values=[], bootlist=None ): """ Set up all thetas as first stage Vars, return resulting theta @@ -888,12 +1041,7 @@ def _Q_opt( else: raise RuntimeError("Unknown solver in Q_Opt=" + solver) - def _cov_at_theta( - self, - method, - solver, - cov_n, - ): + def _cov_at_theta(self, method, solver, cov_n, step): """ Covariance matrix calculation using all scenarios in the data @@ -924,78 +1072,126 @@ def _cov_at_theta( the constant cancels out. (was scaled by 1/n because it computes an expected value.) ''' - # get the model + # check if the supplied method is supported + try: + cov_method = CovMethodLib(method) + except: + raise ValueError(f"Invalid method: '{method}'. Choose from {[e.value for e in CovMethodLib]}.") + + # get a version of the model to check if it has a `measurement_error` attribute model = self.exp_list[0].get_labeled_model() - # check if the user specified SSE or SSE_weighted as objective function - if self.obj_function == 'SSE': - # check if user defined the measurement_error attribute + # check if the user specified `SSE` or `SSE_weighted` as the objective function + if self.obj_function == ObjectiveLib.SSE: + # check if user defined the `measurement_error` attribute if hasattr(model, "measurement_error"): # get the measurement errors - meas_error = [model.measurement_error[y_hat] for y_hat, y in model.experiment_outputs.items()] + meas_error = [ + model.measurement_error[y_hat] + for y_hat, y in model.experiment_outputs.items() + ] # check if the user supplied the values of the measurement errors if all(item is None for item in meas_error): - measurement_var = sse / (n - l) # estimate of the measurement variance - if method == "reduced_hessian": - cov = 2 * measurement_var * self.inv_red_hes # covariance matrix + measurement_var = sse / ( + n - l + ) # estimate of the measurement variance + if cov_method == CovMethodLib.reduced_hessian: + cov = ( + 2 * measurement_var * self.inv_red_hes + ) # covariance matrix cov = pd.DataFrame( - cov, index=self.estimated_theta.keys(), columns=self.estimated_theta.keys() + cov, + index=self.estimated_theta.keys(), + columns=self.estimated_theta.keys(), + ) + elif cov_method == CovMethodLib.finite_difference or cov_method == CovMethodLib.kaug: + cov = compute_cov( + self.exp_list, + method, + thetavals=self.estimated_theta, + solver=solver, + step=step, + tee=self.tee, + estimated_var=measurement_var, ) - elif method == "jacobian": - cov = compute_cov(self.exp_list, method, thetavals=self.estimated_theta, solver=solver, - absolute_perturbation=1e-6, tee=self.tee, estimated_var=measurement_var) - elif method == "kaug": - cov = compute_cov(self.exp_list, method, thetavals=self.estimated_theta, solver=solver, - absolute_perturbation=1e-6, tee=self.tee, estimated_var=measurement_var) else: - raise NotImplementedError('Only `jacobian`, `reduced_hessian`, and `kaug` methods are ' - 'supported') + raise NotImplementedError( + 'Only `finite_difference`, `reduced_hessian`, and `kaug` methods are ' + 'supported.' + ) elif all(item is not None for item in meas_error): - if method == "reduced_hessian": + if cov_method == CovMethodLib.reduced_hessian: cov = 2 * (meas_error[0] ** 2) * self.inv_red_hes cov = pd.DataFrame( - cov, index=self.estimated_theta.keys(), columns=self.estimated_theta.keys() + cov, + index=self.estimated_theta.keys(), + columns=self.estimated_theta.keys(), + ) + elif cov_method == CovMethodLib.finite_difference or cov_method == CovMethodLib.kaug: + cov = compute_cov( + self.exp_list, + method, + thetavals=self.estimated_theta, + solver=solver, + step=step, + tee=self.tee, ) - elif method == "jacobian": - cov = compute_cov(self.exp_list, method, thetavals=self.estimated_theta, solver=solver, - absolute_perturbation=1e-6, tee=self.tee) - elif method == "kaug": - cov = compute_cov(self.exp_list, method, thetavals=self.estimated_theta, - absolute_perturbation=1e-6, solver=solver, tee=self.tee) else: - raise NotImplementedError('Only `jacobian`, `reduced_hessian`, and `kaug` methods are ' - 'supported') + raise NotImplementedError( + 'Only `finite_difference`, `reduced_hessian`, and `kaug` methods are ' + 'supported.' + ) else: - raise ValueError('One or more values of the measurement errors have not been supplied') + raise ValueError( + 'One or more values of the measurement errors have not been supplied.' + ) else: - raise AttributeError('The model must have a `measurement_error` attribute. Please define it') - elif self.obj_function == 'SSE_weighted': - # check if the user defined the measurement_error attribute + raise RuntimeError( + "Experiment model does not have suffix " + '"measurement_error".' + ) + elif self.obj_function == ObjectiveLib.SSE_weighted: + # check if the user defined the `measurement_error` attribute if hasattr(model, "measurement_error"): - meas_error = [model.measurement_error[y_hat] for y_hat, y in model.experiment_outputs.items()] + meas_error = [ + model.measurement_error[y_hat] + for y_hat, y in model.experiment_outputs.items() + ] # check if the user supplied values for the measurement errors if all(item is not None for item in meas_error): - if method == "jacobian": - cov = compute_cov(self.exp_list, method, thetavals=self.estimated_theta, - absolute_perturbation=1e-6, solver=solver, tee=self.tee) - elif method == "kaug": - cov = compute_cov(self.exp_list, method, thetavals=self.estimated_theta, - absolute_perturbation=1e-6, solver=solver, tee=self.tee) - elif method == "reduced_hessian": + if cov_method == CovMethodLib.finite_difference or cov_method == CovMethodLib.kaug: + cov = compute_cov( + self.exp_list, + method, + thetavals=self.estimated_theta, + step=step, + solver=solver, + tee=self.tee, + ) + elif cov_method == CovMethodLib.reduced_hessian: cov = self.inv_red_hes cov = pd.DataFrame( - cov, index=self.estimated_theta.keys(), columns=self.estimated_theta.keys() + cov, + index=self.estimated_theta.keys(), + columns=self.estimated_theta.keys(), ) else: - raise NotImplementedError('Only `jacobian`, `reduced_hessian`, and `kaug` methods are supported') + raise NotImplementedError( + 'Only `finite_difference`, `reduced_hessian`, and `kaug` methods are supported.' + ) else: - raise ValueError('One or more values of the measurement errors have not been supplied') + raise ValueError( + 'One or more values of the measurement errors have not been supplied.' + ) else: - raise AttributeError('The model must have a "measurement_error" attribute. Please define it') + raise RuntimeError( + "Experiment model does not have suffix " + '"measurement_error".' + ) else: - raise NotImplementedError('Covariance calculation is only supported for `SSE` and `SSE_weighted` objectives') + raise NotImplementedError( + 'Covariance calculation is only supported for `SSE` and `SSE_weighted` objectives.' + ) return cov @@ -1228,9 +1424,7 @@ def _get_sample_list(self, samplesize, num_samples, replacement=True): return samplelist - def theta_est( - self, solver="ef_ipopt", return_values=[] - ): + def theta_est(self, solver="ef_ipopt", return_values=[]): """ Parameter estimation using all scenarios in the data @@ -1254,15 +1448,9 @@ def theta_est( assert isinstance(solver, str) assert isinstance(return_values, list) - return self._Q_opt( - solver=solver, - return_values=return_values, - bootlist=None - ) + return self._Q_opt(solver=solver, return_values=return_values, bootlist=None) - def cov_est( - self, method="jacobian", solver="ipopt", cov_n=None - ): + def cov_est(self, method="finite_difference", solver="ipopt", cov_n=None, step=1e-3): """ Covariance matrix calculation using all scenarios in the data @@ -1276,11 +1464,11 @@ def cov_est( """ # check if the solver input is a string if not isinstance(solver, str): - raise TypeError("Expected a string for the solver") + raise TypeError("Expected a string for the solver.") # check if the method input is a string if not isinstance(method, str): - raise TypeError("Expected a string for the method") + raise TypeError("Expected a string for the method.") # number of unknown parameters num_unknowns = max( @@ -1291,17 +1479,13 @@ def cov_est( ) assert isinstance(cov_n, int), ( "The number of datapoints that are used in the objective function is " - "required to calculate the covariance matrix" + "required to calculate the covariance matrix." ) assert ( cov_n > num_unknowns - ), "The number of datapoints must be greater than the number of parameters to estimate" + ), "The number of datapoints must be greater than the number of parameters to estimate." - return self._cov_at_theta( - method=method, - solver=solver, - cov_n=cov_n - ) + return self._cov_at_theta(method=method, solver=solver, cov_n=cov_n, step=step) def theta_est_bootstrap( self, @@ -1983,13 +2167,13 @@ def _instance_creation_callback(self, experiment_number=None, cb_data=None): return model def _Q_opt( - self, - ThetaVals=None, - solver="ef_ipopt", - return_values=[], - bootlist=None, - calc_cov=False, - cov_n=None, + self, + ThetaVals=None, + solver="ef_ipopt", + return_values=[], + bootlist=None, + calc_cov=False, + cov_n=None, ): """ Set up all thetas as first stage Vars, return resulting theta @@ -2080,14 +2264,16 @@ def _Q_opt( for ndname, Var, solval in ef_nonants(ef): # process the name # the scenarios are blocks, so strip the scenario name - vname = Var.name[Var.name.find(".") + 1:] + vname = Var.name[Var.name.find(".") + 1 :] thetavals[vname] = solval objval = pyo.value(ef.EF_Obj) if calc_cov: - raise NotImplementedError('Computing the covariance is no longer supported ' - 'in the deprecated interface') + raise NotImplementedError( + 'Computing the covariance is no longer supported ' + 'in the deprecated interface' + ) thetavals = pd.Series(thetavals) From 8a190eb3415aed6be308d8c56dbfe02cafd083ef Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Tue, 13 May 2025 23:00:06 -0400 Subject: [PATCH 10/35] Updated parmest.py file --- pyomo/contrib/parmest/parmest.py | 33 ++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index eddbd081afc..fa14581029c 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -311,7 +311,7 @@ def _compute_jacobian(experiment, thetavals, step, solver, tee): Arguments: experiment: Estimator class object, that contains the model for a particular experimental condition thetavals: dictionary containing the estimates of the unknown parameters - step: float or integer used to perturb the objectives + step: float used to perturb the parameters solver: string ``solver`` object specified by the user tee: boolean solver option to be passed for verbose output @@ -434,10 +434,10 @@ def compute_cov( Arguments: experiment_list: list of Estimator class objects containing the model for different experimental conditions - method: string `method` object specified by the user (e.g., kaug) + method: string ``method`` object specified by the user (e.g., kaug) thetavals: dictionary containing the estimates of the unknown parameters - step: float or integer used to perturb the objectives - solver: string `solver` object specified by the user (e.g., ipopt) + step: float used to perturb the parameters + solver: string ``solver`` object specified by the user (e.g., ipopt) tee: boolean solver option to be passed for verbose output estimated_var: value of the estimated variance of the measurement error in cases where the user does not supply the measurement error standard deviation @@ -523,7 +523,7 @@ def _finite_difference_FIM( Arguments: experiment: Estimator class object that contains the model for a particular experimental condition thetavals: dictionary containing the estimates of the unknown parameters - step: float or integer used to perturb the objectives + step: float used to perturb the parameters solver: string ``solver`` object specified by the user tee: boolean solver option to be passed for verbose output estimated_var: value of the estimated variance of the measurement error in cases where @@ -537,7 +537,10 @@ def _finite_difference_FIM( # computing the condition number of the Jacobian matrix cond_number_jac = np.linalg.cond(J) - print("The condition number of the Jacobian matrix is:", cond_number_jac) + + # set up logging + logging.basicConfig(level=logging.INFO) + logging.info("The condition number of the Jacobian matrix is:", cond_number_jac) # grab the model model = experiment.get_labeled_model() @@ -1046,9 +1049,10 @@ def _cov_at_theta(self, method, solver, cov_n, step): Covariance matrix calculation using all scenarios in the data Argument: - method: str, a ``method`` object specified by the user (e.g., jacobian or kaug) - solver: str, a ``solver`` object specified by the user (e.g., ipopt) - cov_n: int, the user needs to supply the number of datapoints that are used in the objective function + method: string ``method`` object specified by the user (e.g., kaug) + solver: string ``solver`` object specified by the user (e.g., ipopt) + cov_n: integer, number of datapoints specified by the user which is used in the objective function + step: float used to perturb the parameters Returns: cov: pd.DataFrame, covariance matrix of the estimated parameters @@ -1455,9 +1459,10 @@ def cov_est(self, method="finite_difference", solver="ipopt", cov_n=None, step=1 Covariance matrix calculation using all scenarios in the data Argument: - method: str, a ``method`` object specified by the user (e.g., jacobian or kaug) - solver: str, a ``solver`` object specified by the user (e.g., ipopt) - cov_n: int, the user needs to supply the number of datapoints that are used in the objective function + method: string ``method`` object specified by the user (e.g., kaug) + solver: string ``solver`` object specified by the user (e.g., ipopt) + cov_n: integer, number of datapoints specified by the user which is used in the objective function + step: float used to perturb the parameters Returns: cov: pd.DataFrame, covariance matrix of the estimated parameters @@ -1470,6 +1475,10 @@ def cov_est(self, method="finite_difference", solver="ipopt", cov_n=None, step=1 if not isinstance(method, str): raise TypeError("Expected a string for the method.") + # check if the supplied number of datapoints is an integer + if not isinstance(cov_n, int): + raise TypeError("Expected an integer for " + '"cov_n".') + # number of unknown parameters num_unknowns = max( [ From 8837f6d609ebd8148304bc382ab91dd44be44f9a Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Mon, 19 May 2025 12:08:00 -0400 Subject: [PATCH 11/35] Implemented Alex's comments on the parmest.py file --- pyomo/contrib/parmest/parmest.py | 362 ++++++++++++++++--------------- 1 file changed, 191 insertions(+), 171 deletions(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index fa14581029c..e7e00bf0525 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -74,7 +74,7 @@ parmest_available = numpy_available & pandas_available & scipy_available inverse_reduced_hessian, inverse_reduced_hessian_available = attempt_import( - 'pyomo.contrib.interior_point.inverse_reduced_hessian' + "pyomo.contrib.interior_point.inverse_reduced_hessian" ) logger = logging.getLogger(__name__) @@ -119,7 +119,7 @@ def _experiment_instance_creation_callback( """ assert cb_data is not None outer_cb_data = cb_data - scen_num_str = re.compile(r'(\d+)$').search(scenario_name).group(1) + scen_num_str = re.compile(r"(\d+)$").search(scenario_name).group(1) scen_num = int(scen_num_str) basename = scenario_name[: -len(scen_num_str)] # to reconstruct name @@ -238,13 +238,8 @@ def SSE(model): Argument: model: annotated Pyomo model """ - # Check that experimental outputs exist - try: - measured_variables = [y_hat.name for y_hat, y in model.experiment_outputs.items()] - except: - raise RuntimeError( - "Experiment model does not have suffix " + '"experiment_outputs".' - ) + # check if the model has all the required suffixes + _check_model_labels_helper(model) # sum of squared error between the prediction and observation of the measured variables expr = sum((y - y_hat) ** 2 for y_hat, y in model.experiment_outputs.items()) @@ -259,17 +254,12 @@ def SSE_weighted(model): Argument: model: annotated Pyomo model """ - # Check that experimental outputs exist - try: - measured_variables = [y_hat.name for y_hat, y in model.experiment_outputs.items()] - except: - raise RuntimeError( - "Experiment model does not have suffix " + '"experiment_outputs".' - ) + # check if the model has all the required suffixes + _check_model_labels_helper(model) - # check that measurement errors exist + # Check that measurement errors exist try: - measured_variables_error = [k.name for k, v in model.measurement_error.items()] + errors = [k.name for k, v in model.measurement_error.items()] except: raise RuntimeError( "Experiment model does not have suffix " + '"measurement_error".' @@ -286,14 +276,67 @@ def SSE_weighted(model): ) return expr else: + raise ValueError("One or more values are missing from `measurement_error`.") + + +def _check_model_labels_helper(model): + """ + Checks if the annotated Pyomo model contains the necessary suffixes. + + Argument: + model: annotated Pyomo model for suffix checking + """ + # check that experimental outputs exist + try: + outputs = [k.name for k, v in model.experiment_outputs.items()] + except: + raise RuntimeError( + "Experiment model does not have suffix " + '"experiment_outputs".' + ) + + # Check that experimental inputs exist + try: + inputs = [k.name for k, v in model.experiment_inputs.items()] + except: + raise RuntimeError( + "Experiment model does not have suffix " + '"experiment_inputs".' + ) + + # Check that unknown parameters exist + try: + params = [k.name for k, v in model.unknown_parameters.items()] + except: + raise RuntimeError( + "Experiment model does not have suffix " + '"unknown_parameters".' + ) + + logger.setLevel(level=logging.INFO) + logger.info("Model has expected labels.") + + +def _get_labeled_model_helper(experiment): + """ + Checks if the Experiment class object has a ``get_labeled_model`` function + + Argument: + experiment: Estimator class object that contains the model for a particular experimental condition + + Returns: + model: Annotated Pyomo model + """ + try: + model = experiment.get_labeled_model().clone() + except: raise ValueError( - 'One or more values are missing from `measurement_error`.' + "The experiment object must have a ``get_labeled_model`` function." ) + return model + class CovMethodLib(Enum): finite_difference = "finite_difference" - kaug = "kaug" + automatic_differentiation_kaug = "automatic_differentiation_kaug" reduced_hessian = "reduced_hessian" @@ -309,33 +352,23 @@ def _compute_jacobian(experiment, thetavals, step, solver, tee): using central finite difference scheme Arguments: - experiment: Estimator class object, that contains the model for a particular experimental condition + experiment: Estimator class object that contains the model for a particular experimental condition thetavals: dictionary containing the estimates of the unknown parameters - step: float used to perturb the parameters + step: float used for relative perturbation of the parameters, e.g., step=0.02 is a 2% perturbation solver: string ``solver`` object specified by the user tee: boolean solver option to be passed for verbose output Returns: J: Jacobian matrix """ - # grab and clone the model - # check if the experiment has a ``get_labeled_model`` function - try: - model = experiment.get_labeled_model().clone() - except: - raise ValueError( - "The experiment object must have a ``get_labeled_model`` function." - ) + # grab the model + model = _get_labeled_model_helper(experiment) - # fix the value of the unknown parameters to the estimated values - # Check that unknown parameters exist - try: - params = [k for k, v in model.unknown_parameters.items()] - except: - raise RuntimeError( - "Experiment model does not have suffix " + '"unknown_parameters".' - ) + # check if the model has all the required suffixes + _check_model_labels_helper(model) + # fix the value of the unknown parameters to the estimated values + params = [k for k, v in model.unknown_parameters.items()] for param in params: param.fix(thetavals[param.name]) @@ -350,13 +383,7 @@ def _compute_jacobian(experiment, thetavals, step, solver, tee): ) # get the measured variables - # check that experimental outputs exist - try: - y_hat_list = [y_hat for y_hat, y in model.experiment_outputs.items()] - except: - raise RuntimeError( - "Experiment model does not have suffix " + '"experiment_outputs".' - ) + y_hat_list = [y_hat for y_hat, y in model.experiment_outputs.items()] # get the estimated parameter values param_values = [p.value for p in params] @@ -421,23 +448,18 @@ def _compute_jacobian(experiment, thetavals, step, solver, tee): # Compute the covariance matrix of the estimated parameters def compute_cov( - experiment_list, - method, - thetavals, - step, - solver, - tee, - estimated_var=None, + experiment_list, method, thetavals, step, solver, tee, estimated_var=None ): """ - Computes the covariance matrix of the estimated parameters using finite difference and kaug methods + Computes the covariance matrix of the estimated parameters using `finite_difference` and + `automatic_differentiation_kaug` methods Arguments: experiment_list: list of Estimator class objects containing the model for different experimental conditions - method: string ``method`` object specified by the user (e.g., kaug) + method: string ``method`` object specified by the user (e.g., `finite_difference`) thetavals: dictionary containing the estimates of the unknown parameters - step: float used to perturb the parameters - solver: string ``solver`` object specified by the user (e.g., ipopt) + step: float used for relative perturbation of the parameters, e.g., step=0.02 is a 2% perturbation + solver: string ``solver`` object specified by the user tee: boolean solver option to be passed for verbose output estimated_var: value of the estimated variance of the measurement error in cases where the user does not supply the measurement error standard deviation @@ -448,8 +470,10 @@ def compute_cov( # check if the supplied method is supported try: cov_method = CovMethodLib(method) - except: - raise ValueError(f"Invalid method: '{method}'. Choose from {[e.value for e in CovMethodLib]}.") + except ValueError: + raise ValueError( + f"Invalid method: '{method}'. Choose from {[e.value for e in CovMethodLib]}." + ) if cov_method == CovMethodLib.finite_difference: # store the FIM of all experiments @@ -477,7 +501,7 @@ def compute_cov( cov = np.linalg.pinv(FIM) print("The FIM is singular. Using pseudo-inverse instead.") cov = pd.DataFrame(cov, index=thetavals.keys(), columns=thetavals.keys()) - elif cov_method == CovMethodLib.kaug: + elif cov_method == CovMethodLib.automatic_differentiation_kaug: # store the FIM of all experiments FIM_all_exp = [] for ( @@ -504,7 +528,7 @@ def compute_cov( cov = pd.DataFrame(cov, index=thetavals.keys(), columns=thetavals.keys()) else: raise ValueError( - "The method provided, {}, must be either `finite_difference` or `kaug`".format( + "The method provided, {}, must be either `finite_difference` or `automatic_differentiation_kaug`".format( method ) ) @@ -512,7 +536,7 @@ def compute_cov( return cov -# compute the Fisher information matrix of the estimated parameters using finite difference +# compute the Fisher information matrix of the estimated parameters using `finite_difference` def _finite_difference_FIM( experiment, thetavals, step, solver, tee, estimated_var=None ): @@ -523,7 +547,7 @@ def _finite_difference_FIM( Arguments: experiment: Estimator class object that contains the model for a particular experimental condition thetavals: dictionary containing the estimates of the unknown parameters - step: float used to perturb the parameters + step: float used for relative perturbation of the parameters, e.g., step=0.02 is a 2% perturbation solver: string ``solver`` object specified by the user tee: boolean solver option to be passed for verbose output estimated_var: value of the estimated variance of the measurement error in cases where @@ -539,11 +563,10 @@ def _finite_difference_FIM( cond_number_jac = np.linalg.cond(J) # set up logging - logging.basicConfig(level=logging.INFO) - logging.info("The condition number of the Jacobian matrix is:", cond_number_jac) + logger.info(f"The condition number of the Jacobian matrix is {cond_number_jac}") # grab the model - model = experiment.get_labeled_model() + model = _get_labeled_model_helper(experiment) # extract the measured variables and measurement errors y_hat_list = [y_hat for y_hat, y in model.experiment_outputs.items()] @@ -580,11 +603,11 @@ def _finite_difference_FIM( return FIM -# compute the Fisher information matrix of the estimated parameters using kaug +# compute the Fisher information matrix of the estimated parameters using `automatic_differentiation_kaug` def _kaug_FIM(experiment, thetavals, solver, tee, estimated_var=None): """ - Computes the FIM using kaug, a sensitivity-based approach that uses the annotated Pyomo model optimality conditions - and user-defined measurement errors standard deviation + Computes the FIM using `automatic_differentiation_kaug`, a sensitivity-based approach that uses the annotated + Pyomo model optimality condition and user-defined measurement errors standard deviation Disclaimer - code adopted from the kaug function implemented in Pyomo.DoE @@ -729,28 +752,10 @@ def __init__( self.exp_list = experiment_list # check if the experiment has a ``get_labeled_model`` function - try: - model = self.exp_list[0].get_labeled_model() - except: - raise ValueError( - "The experiment object must have a ``get_labeled_model`` function." - ) + model = _get_labeled_model_helper(self.exp_list[0]) - # check that experimental outputs exist - try: - measured_variables = [y_hat.name for y_hat, y in model.experiment_outputs.items()] - except: - raise RuntimeError( - "Experiment model does not have suffix " + '"experiment_outputs".' - ) - - # check that unknown parameters exist - try: - unknown_params = [k.name for k, v in model.unknown_parameters.items()] - except: - RuntimeError( - 'Experiment list model does not have suffix ' + '"unknown_parameters".' - ) + # check if the model has all the required suffixes + _check_model_labels_helper(model) # populate keyword argument options self.obj_function = ObjectiveLib(obj_function) @@ -797,7 +802,7 @@ def _deprecated_init( "You're using the deprecated parmest interface (model_function, " "data, theta_names). This interface will be removed in a future release, " "please update to the new parmest interface using experiment lists.", - version='6.7.2', + version="6.7.2", ) self.pest_deprecated = _DeprecatedEstimator( model_function, @@ -818,7 +823,7 @@ def _return_theta_names(self): # if fitted model parameter names differ from theta_names # created when Estimator object is created - if hasattr(self, 'theta_names_updated'): + if hasattr(self, "theta_names_updated"): return self.pest_deprecated.theta_names_updated else: @@ -830,7 +835,7 @@ def _return_theta_names(self): # if fitted model parameter names differ from theta_names # created when Estimator object is created - if hasattr(self, 'theta_names_updated'): + if hasattr(self, "theta_names_updated"): return self.theta_names_updated else: @@ -868,9 +873,9 @@ def _create_parmest_model(self, experiment_number): # Check for component naming conflicts reserved_names = [ - 'Total_Cost_Objective', - 'FirstStageCost', - 'SecondStageCost', + "Total_Cost_Objective", + "FirstStageCost", + "SecondStageCost", ] for n in reserved_names: if model.component(n) is not None or hasattr(model, n): @@ -988,7 +993,7 @@ def _Q_opt( if self.diagnostic_mode: print( - ' Solver termination condition = ', + " Solver termination condition = ", str(solve_result.solver.termination_condition), ) @@ -1049,10 +1054,10 @@ def _cov_at_theta(self, method, solver, cov_n, step): Covariance matrix calculation using all scenarios in the data Argument: - method: string ``method`` object specified by the user (e.g., kaug) - solver: string ``solver`` object specified by the user (e.g., ipopt) + method: string ``method`` object specified by the user (e.g., `finite_difference`) + solver: string ``solver`` object specified by the user (e.g., `ipopt`) cov_n: integer, number of datapoints specified by the user which is used in the objective function - step: float used to perturb the parameters + step: float used for relative perturbation of the parameters, e.g., step=0.02 is a 2% perturbation Returns: cov: pd.DataFrame, covariance matrix of the estimated parameters @@ -1066,7 +1071,7 @@ def _cov_at_theta(self, method, solver, cov_n, step): # Assumption: Objective value is sum of squared errors sse = self.objective_value - '''Calculate covariance assuming experimental observation errors are + """Calculate covariance assuming experimental observation errors are independent and follow a Gaussian distribution with constant variance. The formula used in parmest was verified against equations (7-5-15) and @@ -1075,12 +1080,14 @@ def _cov_at_theta(self, method, solver, cov_n, step): This formula is also applicable if the objective is scaled by a constant; the constant cancels out. (was scaled by 1/n because it computes an expected value.) - ''' + """ # check if the supplied method is supported try: cov_method = CovMethodLib(method) - except: - raise ValueError(f"Invalid method: '{method}'. Choose from {[e.value for e in CovMethodLib]}.") + except ValueError: + raise ValueError( + f"Invalid method: '{method}'. Choose from {[e.value for e in CovMethodLib]}." + ) # get a version of the model to check if it has a `measurement_error` attribute model = self.exp_list[0].get_labeled_model() @@ -1109,7 +1116,10 @@ def _cov_at_theta(self, method, solver, cov_n, step): index=self.estimated_theta.keys(), columns=self.estimated_theta.keys(), ) - elif cov_method == CovMethodLib.finite_difference or cov_method == CovMethodLib.kaug: + elif ( + cov_method == CovMethodLib.finite_difference + or cov_method == CovMethodLib.automatic_differentiation_kaug + ): cov = compute_cov( self.exp_list, method, @@ -1121,8 +1131,8 @@ def _cov_at_theta(self, method, solver, cov_n, step): ) else: raise NotImplementedError( - 'Only `finite_difference`, `reduced_hessian`, and `kaug` methods are ' - 'supported.' + "Only `finite_difference`, `reduced_hessian`, and `automatic_differentiation_kaug` " + "methods are supported." ) elif all(item is not None for item in meas_error): if cov_method == CovMethodLib.reduced_hessian: @@ -1132,7 +1142,10 @@ def _cov_at_theta(self, method, solver, cov_n, step): index=self.estimated_theta.keys(), columns=self.estimated_theta.keys(), ) - elif cov_method == CovMethodLib.finite_difference or cov_method == CovMethodLib.kaug: + elif ( + cov_method == CovMethodLib.finite_difference + or cov_method == CovMethodLib.automatic_differentiation_kaug + ): cov = compute_cov( self.exp_list, method, @@ -1143,12 +1156,12 @@ def _cov_at_theta(self, method, solver, cov_n, step): ) else: raise NotImplementedError( - 'Only `finite_difference`, `reduced_hessian`, and `kaug` methods are ' - 'supported.' + "Only `finite_difference`, `reduced_hessian`, and `automatic_differentiation_kaug` " + "methods are supported." ) else: raise ValueError( - 'One or more values of the measurement errors have not been supplied.' + "One or more values of the measurement errors have not been supplied." ) else: raise RuntimeError( @@ -1164,7 +1177,10 @@ def _cov_at_theta(self, method, solver, cov_n, step): # check if the user supplied values for the measurement errors if all(item is not None for item in meas_error): - if cov_method == CovMethodLib.finite_difference or cov_method == CovMethodLib.kaug: + if ( + cov_method == CovMethodLib.finite_difference + or cov_method == CovMethodLib.automatic_differentiation_kaug + ): cov = compute_cov( self.exp_list, method, @@ -1182,11 +1198,12 @@ def _cov_at_theta(self, method, solver, cov_n, step): ) else: raise NotImplementedError( - 'Only `finite_difference`, `reduced_hessian`, and `kaug` methods are supported.' + "Only `finite_difference`, `reduced_hessian`, and `automatic_differentiation_kaug` " + "methods are supported." ) else: raise ValueError( - 'One or more values of the measurement errors have not been supplied.' + "One or more values of the measurement errors have not been supplied." ) else: raise RuntimeError( @@ -1194,7 +1211,7 @@ def _cov_at_theta(self, method, solver, cov_n, step): ) else: raise NotImplementedError( - 'Covariance calculation is only supported for `SSE` and `SSE_weighted` objectives.' + "Covariance calculation is only supported for `SSE` and `SSE_weighted` objectives." ) return cov @@ -1224,7 +1241,7 @@ def _Q_at_theta(self, thetavals, initialize_parmest_model=False): pyo.TerminationCondition.infeasible is the worst. """ - optimizer = pyo.SolverFactory('ipopt') + optimizer = pyo.SolverFactory("ipopt") if len(thetavals) > 0: dummy_cb = { @@ -1242,9 +1259,9 @@ def _Q_at_theta(self, thetavals, initialize_parmest_model=False): if self.diagnostic_mode: if len(thetavals) > 0: - print(' Compute objective at theta = ', str(thetavals)) + print(" Compute objective at theta = ", str(thetavals)) else: - print(' Compute objective at initial theta') + print(" Compute objective at initial theta") # start block of code to deal with models with no constraints # (ipopt will crash or complain on such problems without special care) @@ -1292,14 +1309,14 @@ def _Q_at_theta(self, thetavals, initialize_parmest_model=False): theta_init_vals.append(var_validate) except: logger.warning( - 'Unable to fix model parameter value for %s (not a Pyomo model Var)', + "Unable to fix model parameter value for %s (not a Pyomo model Var)", (theta), ) if active_constraints: if self.diagnostic_mode: - print(' Experiment = ', snum) - print(' First solve with special diagnostics wrapper') + print(" Experiment = ", snum) + print(" First solve with special diagnostics wrapper") (status_obj, solved, iters, time, regu) = ( utils.ipopt_solve_with_stats( instance, optimizer, max_iter=500, max_cpu_time=120 @@ -1317,7 +1334,7 @@ def _Q_at_theta(self, thetavals, initialize_parmest_model=False): results = optimizer.solve(instance) if self.diagnostic_mode: print( - 'standard solve solver termination condition=', + "standard solve solver termination condition=", str(results.solver.termination_condition), ) @@ -1360,7 +1377,7 @@ def _Q_at_theta(self, thetavals, initialize_parmest_model=False): totobj += objval retval = totobj / len(scenario_numbers) # -1?? - if initialize_parmest_model and not hasattr(self, 'ef_instance'): + if initialize_parmest_model and not hasattr(self, "ef_instance"): # create extensive form of the model using scenario dictionary if len(scen_dict) > 0: for scen in scen_dict.values(): @@ -1454,15 +1471,18 @@ def theta_est(self, solver="ef_ipopt", return_values=[]): return self._Q_opt(solver=solver, return_values=return_values, bootlist=None) - def cov_est(self, method="finite_difference", solver="ipopt", cov_n=None, step=1e-3): + def cov_est( + self, method="finite_difference", solver="ipopt", cov_n=None, step=1e-3 + ): """ Covariance matrix calculation using all scenarios in the data Argument: - method: string ``method`` object specified by the user (e.g., kaug) - solver: string ``solver`` object specified by the user (e.g., ipopt) + method: string ``method`` object specified by the user + options - `finite_difference`, `reduced_hessian`, and `automatic_differentiation_kaug` + solver: string ``solver`` object specified by the user (e.g., `ipopt`) cov_n: integer, number of datapoints specified by the user which is used in the objective function - step: float used to perturb the parameters + step: float used for relative perturbation of the parameters, e.g., step=0.02 is a 2% perturbation Returns: cov: pd.DataFrame, covariance matrix of the estimated parameters @@ -1559,14 +1579,14 @@ def theta_est_bootstrap( bootstrap_theta = list() for idx, sample in local_list: objval, thetavals = self._Q_opt(bootlist=list(sample)) - thetavals['samples'] = sample + thetavals["samples"] = sample bootstrap_theta.append(thetavals) global_bootstrap_theta = task_mgr.allgather_global_data(bootstrap_theta) bootstrap_theta = pd.DataFrame(global_bootstrap_theta) if not return_samples: - del bootstrap_theta['samples'] + del bootstrap_theta["samples"] return bootstrap_theta @@ -1620,14 +1640,14 @@ def theta_est_leaveNout( for idx, sample in local_list: objval, thetavals = self._Q_opt(bootlist=list(sample)) lNo_s = list(set(range(len(self.exp_list))) - set(sample)) - thetavals['lNo'] = np.sort(lNo_s) + thetavals["lNo"] = np.sort(lNo_s) lNo_theta.append(thetavals) global_bootstrap_theta = task_mgr.allgather_global_data(lNo_theta) lNo_theta = pd.DataFrame(global_bootstrap_theta) if not return_samples: - del lNo_theta['lNo'] + del lNo_theta["lNo"] return lNo_theta @@ -1684,7 +1704,7 @@ def leaveNout_bootstrap_test( assert isinstance(lNo, int) assert isinstance(lNo_samples, (type(None), int)) assert isinstance(bootstrap_samples, int) - assert distribution in ['Rect', 'MVN', 'KDE'] + assert distribution in ["Rect", "MVN", "KDE"] assert isinstance(alphas, list) assert isinstance(seed, (type(None), int)) @@ -1775,7 +1795,7 @@ def objective_at_theta(self, theta_values=None, initialize_parmest_model=False): assert len(list(theta_names)) == len(model_theta_list) - all_thetas = theta_values.to_dict('records') + all_thetas = theta_values.to_dict("records") if all_thetas: task_mgr = utils.ParallelTaskManager(len(all_thetas)) @@ -1803,7 +1823,7 @@ def objective_at_theta(self, theta_values=None, initialize_parmest_model=False): all_obj.append(list(thetvals.values()) + [obj]) global_all_obj = task_mgr.allgather_global_data(all_obj) - dfcols = list(theta_names) + ['obj'] + dfcols = list(theta_names) + ["obj"] obj_at_theta = pd.DataFrame(data=global_all_obj, columns=dfcols) return obj_at_theta @@ -1852,7 +1872,7 @@ def likelihood_ratio_test( for a in alphas: chi2_val = scipy.stats.chi2.ppf(a, 2) thresholds[a] = obj_value * ((chi2_val / (S - 2)) + 1) - LR[a] = LR['obj'] < thresholds[a] + LR[a] = LR["obj"] < thresholds[a] thresholds = pd.Series(thresholds) @@ -1902,7 +1922,7 @@ def confidence_region_test( ) assert isinstance(theta_values, pd.DataFrame) - assert distribution in ['Rect', 'MVN', 'KDE'] + assert distribution in ["Rect", "MVN", "KDE"] assert isinstance(alphas, list) assert isinstance( test_theta_values, (type(None), dict, pd.Series, pd.DataFrame) @@ -1917,7 +1937,7 @@ def confidence_region_test( test_result = test_theta_values.copy() for a in alphas: - if distribution == 'Rect': + if distribution == "Rect": lb, ub = graphics.fit_rect_dist(theta_values, a) training_results[a] = (theta_values > lb).all(axis=1) & ( theta_values < ub @@ -1929,7 +1949,7 @@ def confidence_region_test( test_theta_values < ub ).all(axis=1) - elif distribution == 'MVN': + elif distribution == "MVN": dist = graphics.fit_mvn_dist(theta_values) Z = dist.pdf(theta_values) score = scipy.stats.scoreatpercentile(Z, (1 - a) * 100) @@ -1940,7 +1960,7 @@ def confidence_region_test( Z = dist.pdf(test_theta_values) test_result[a] = Z >= score - elif distribution == 'KDE': + elif distribution == "KDE": dist = graphics.fit_kde_dist(theta_values) Z = dist.pdf(theta_values.transpose()) score = scipy.stats.scoreatpercentile(Z, (1 - a) * 100) @@ -1962,7 +1982,7 @@ def confidence_region_test( ################################ -@deprecated(version='6.7.2') +@deprecated(version="6.7.2") def group_data(data, groupby_column_name, use_mean=None): """ Group data by scenario @@ -2068,7 +2088,7 @@ def __init__( ), "The scenarios in data must be a dictionary, DataFrame or filename" if len(theta_names) == 0: - self.theta_names = ['parmest_dummy_var'] + self.theta_names = ["parmest_dummy_var"] else: self.theta_names = theta_names @@ -2086,7 +2106,7 @@ def _return_theta_names(self): Return list of fitted model parameter names """ # if fitted model parameter names differ from theta_names created when Estimator object is created - if hasattr(self, 'theta_names_updated'): + if hasattr(self, "theta_names_updated"): return self.theta_names_updated else: @@ -2101,7 +2121,7 @@ def _create_parmest_model(self, data): model = self.model_function(data) if (len(self.theta_names) == 1) and ( - self.theta_names[0] == 'parmest_dummy_var' + self.theta_names[0] == "parmest_dummy_var" ): model.parmest_dummy_var = pyo.Var(initialize=1.0) @@ -2152,7 +2172,7 @@ def TotalCost_rule(model): var_validate.unfix() self.theta_names[i] = repr(var_cuid) except: - logger.warning(theta + ' is not a variable') + logger.warning(theta + " is not a variable") self.parmest_model = model @@ -2165,12 +2185,12 @@ def _instance_creation_callback(self, experiment_number=None, cb_data=None): pass elif isinstance(exp_data, str): try: - with open(exp_data, 'r') as infile: + with open(exp_data, "r") as infile: exp_data = json.load(infile) except: - raise RuntimeError(f'Could not read {exp_data} as json') + raise RuntimeError(f"Could not read {exp_data} as json") else: - raise RuntimeError(f'Unexpected data format for cb_data={cb_data}') + raise RuntimeError(f"Unexpected data format for cb_data={cb_data}") model = self._create_parmest_model(exp_data) return model @@ -2234,7 +2254,7 @@ def _Q_opt( if not calc_cov: # Do not calculate the reduced hessian - solver = SolverFactory('ipopt') + solver = SolverFactory("ipopt") if self.solver_options is not None: for key in self.solver_options: solver.options[key] = self.solver_options[key] @@ -2264,7 +2284,7 @@ def _Q_opt( if self.diagnostic_mode: print( - ' Solver termination condition = ', + " Solver termination condition = ", str(solve_result.solver.termination_condition), ) @@ -2280,8 +2300,8 @@ def _Q_opt( if calc_cov: raise NotImplementedError( - 'Computing the covariance is no longer supported ' - 'in the deprecated interface' + "Computing the covariance is no longer supported " + "in the deprecated interface" ) thetavals = pd.Series(thetavals) @@ -2352,7 +2372,7 @@ def _Q_at_theta(self, thetavals, initialize_parmest_model=False): pyo.TerminationCondition.infeasible is the worst. """ - optimizer = pyo.SolverFactory('ipopt') + optimizer = pyo.SolverFactory("ipopt") if len(thetavals) > 0: dummy_cb = { @@ -2370,9 +2390,9 @@ def _Q_at_theta(self, thetavals, initialize_parmest_model=False): if self.diagnostic_mode: if len(thetavals) > 0: - print(' Compute objective at theta = ', str(thetavals)) + print(" Compute objective at theta = ", str(thetavals)) else: - print(' Compute objective at initial theta') + print(" Compute objective at initial theta") # start block of code to deal with models with no constraints # (ipopt will crash or complain on such problems without special care) @@ -2419,14 +2439,14 @@ def _Q_at_theta(self, thetavals, initialize_parmest_model=False): theta_init_vals.append(var_validate) except: logger.warning( - 'Unable to fix model parameter value for %s (not a Pyomo model Var)', + "Unable to fix model parameter value for %s (not a Pyomo model Var)", (theta), ) if active_constraints: if self.diagnostic_mode: - print(' Experiment = ', snum) - print(' First solve with special diagnostics wrapper') + print(" Experiment = ", snum) + print(" First solve with special diagnostics wrapper") (status_obj, solved, iters, time, regu) = ( utils.ipopt_solve_with_stats( instance, optimizer, max_iter=500, max_cpu_time=120 @@ -2444,7 +2464,7 @@ def _Q_at_theta(self, thetavals, initialize_parmest_model=False): results = optimizer.solve(instance) if self.diagnostic_mode: print( - 'standard solve solver termination condition=', + "standard solve solver termination condition=", str(results.solver.termination_condition), ) @@ -2487,7 +2507,7 @@ def _Q_at_theta(self, thetavals, initialize_parmest_model=False): totobj += objval retval = totobj / len(scenario_numbers) # -1?? - if initialize_parmest_model and not hasattr(self, 'ef_instance'): + if initialize_parmest_model and not hasattr(self, "ef_instance"): # create extensive form of the model using scenario dictionary if len(scen_dict) > 0: for scen in scen_dict.values(): @@ -2654,14 +2674,14 @@ def theta_est_bootstrap( bootstrap_theta = list() for idx, sample in local_list: objval, thetavals = self._Q_opt(bootlist=list(sample)) - thetavals['samples'] = sample + thetavals["samples"] = sample bootstrap_theta.append(thetavals) global_bootstrap_theta = task_mgr.allgather_global_data(bootstrap_theta) bootstrap_theta = pd.DataFrame(global_bootstrap_theta) if not return_samples: - del bootstrap_theta['samples'] + del bootstrap_theta["samples"] return bootstrap_theta @@ -2708,14 +2728,14 @@ def theta_est_leaveNout( for idx, sample in local_list: objval, thetavals = self._Q_opt(bootlist=list(sample)) lNo_s = list(set(range(len(self.callback_data))) - set(sample)) - thetavals['lNo'] = np.sort(lNo_s) + thetavals["lNo"] = np.sort(lNo_s) lNo_theta.append(thetavals) global_bootstrap_theta = task_mgr.allgather_global_data(lNo_theta) lNo_theta = pd.DataFrame(global_bootstrap_theta) if not return_samples: - del lNo_theta['lNo'] + del lNo_theta["lNo"] return lNo_theta @@ -2765,7 +2785,7 @@ def leaveNout_bootstrap_test( assert isinstance(lNo, int) assert isinstance(lNo_samples, (type(None), int)) assert isinstance(bootstrap_samples, int) - assert distribution in ['Rect', 'MVN', 'KDE'] + assert distribution in ["Rect", "MVN", "KDE"] assert isinstance(alphas, list) assert isinstance(seed, (type(None), int)) @@ -2822,7 +2842,7 @@ def objective_at_theta(self, theta_values=None, initialize_parmest_model=False): Objective value for each theta (infeasible solutions are omitted). """ - if len(self.theta_names) == 1 and self.theta_names[0] == 'parmest_dummy_var': + if len(self.theta_names) == 1 and self.theta_names[0] == "parmest_dummy_var": pass # skip assertion if model has no fitted parameters else: # create a local instance of the pyomo model to access model variables and parameters @@ -2877,7 +2897,7 @@ def objective_at_theta(self, theta_values=None, initialize_parmest_model=False): ) assert len(list(theta_names)) == len(model_theta_list) - all_thetas = theta_values.to_dict('records') + all_thetas = theta_values.to_dict("records") if all_thetas: task_mgr = utils.ParallelTaskManager(len(all_thetas)) @@ -2905,7 +2925,7 @@ def objective_at_theta(self, theta_values=None, initialize_parmest_model=False): all_obj.append(list(thetvals.values()) + [obj]) global_all_obj = task_mgr.allgather_global_data(all_obj) - dfcols = list(theta_names) + ['obj'] + dfcols = list(theta_names) + ["obj"] obj_at_theta = pd.DataFrame(data=global_all_obj, columns=dfcols) return obj_at_theta @@ -2947,7 +2967,7 @@ def likelihood_ratio_test( for a in alphas: chi2_val = scipy.stats.chi2.ppf(a, 2) thresholds[a] = obj_value * ((chi2_val / (S - 2)) + 1) - LR[a] = LR['obj'] < thresholds[a] + LR[a] = LR["obj"] < thresholds[a] thresholds = pd.Series(thresholds) @@ -2989,7 +3009,7 @@ def confidence_region_test( with True (inside) or False (outside) for each alpha """ assert isinstance(theta_values, pd.DataFrame) - assert distribution in ['Rect', 'MVN', 'KDE'] + assert distribution in ["Rect", "MVN", "KDE"] assert isinstance(alphas, list) assert isinstance( test_theta_values, (type(None), dict, pd.Series, pd.DataFrame) @@ -3004,7 +3024,7 @@ def confidence_region_test( test_result = test_theta_values.copy() for a in alphas: - if distribution == 'Rect': + if distribution == "Rect": lb, ub = graphics.fit_rect_dist(theta_values, a) training_results[a] = (theta_values > lb).all(axis=1) & ( theta_values < ub @@ -3016,7 +3036,7 @@ def confidence_region_test( test_theta_values < ub ).all(axis=1) - elif distribution == 'MVN': + elif distribution == "MVN": dist = graphics.fit_mvn_dist(theta_values) Z = dist.pdf(theta_values) score = scipy.stats.scoreatpercentile(Z, (1 - a) * 100) @@ -3027,7 +3047,7 @@ def confidence_region_test( Z = dist.pdf(test_theta_values) test_result[a] = Z >= score - elif distribution == 'KDE': + elif distribution == "KDE": dist = graphics.fit_kde_dist(theta_values) Z = dist.pdf(theta_values.transpose()) score = scipy.stats.scoreatpercentile(Z, (1 - a) * 100) From 8a6832906bdc5248cbb8483643828d4fdbd28ab2 Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Tue, 20 May 2025 09:27:47 -0400 Subject: [PATCH 12/35] Updated parmest.py file --- pyomo/contrib/parmest/parmest.py | 40 +++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index e7e00bf0525..d754a538c9d 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -1009,7 +1009,6 @@ def _Q_opt( # add the estimated theta and objective value to the class self.estimated_theta = thetavals - self.objective_value = objval thetavals = pd.Series(thetavals) @@ -1068,8 +1067,40 @@ def _cov_at_theta(self, method, solver, cov_n, step): # Extract number of fitted parameters l = len(self.estimated_theta) - # Assumption: Objective value is sum of squared errors - sse = self.objective_value + # calculate the sum of squared errors at the estimated parameters + sse_vals = [] + for experiment in self.exp_list: + model = _get_labeled_model_helper(experiment) + + # fix the value of the unknown parameters to the estimated values + params = [k for k, v in model.unknown_parameters.items()] + for param in params: + param.fix(self.estimated_theta[param.name]) + + # resolve the model with the estimated parameters + try: + res = pyo.SolverFactory(solver).solve(model, tee=self.tee) + pyo.assert_optimal_termination(res) + except: + raise RuntimeError( + "Model from experiment did not solve appropriately. Make sure the model is well-posed." + ) + + # choose and evaluate the objective expression + if self.obj_function == ObjectiveLib.SSE: + sse_expr = SSE(model) + elif self.obj_function == ObjectiveLib.SSE_weighted: + sse_expr = SSE_weighted(model) + else: + raise NotImplementedError( + "Covariance calculation is only supported for `SSE` and `SSE_weighted` objectives." + ) + + # evaluate numerical SSE and store it + sse_val = pyo.value(sse_expr) + sse_vals.append(sse_val) + + sse = sum(sse_vals) # total SSE """Calculate covariance assuming experimental observation errors are independent and follow a Gaussian distribution with constant variance. @@ -1089,9 +1120,6 @@ def _cov_at_theta(self, method, solver, cov_n, step): f"Invalid method: '{method}'. Choose from {[e.value for e in CovMethodLib]}." ) - # get a version of the model to check if it has a `measurement_error` attribute - model = self.exp_list[0].get_labeled_model() - # check if the user specified `SSE` or `SSE_weighted` as the objective function if self.obj_function == ObjectiveLib.SSE: # check if user defined the `measurement_error` attribute From acd444ca6de1ce6ec935718a13e177f68230642b Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Tue, 20 May 2025 12:38:20 -0400 Subject: [PATCH 13/35] Ran black on parmest.py file --- pyomo/contrib/parmest/parmest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index d754a538c9d..caf32e47b9a 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -1100,7 +1100,7 @@ def _cov_at_theta(self, method, solver, cov_n, step): sse_val = pyo.value(sse_expr) sse_vals.append(sse_val) - sse = sum(sse_vals) # total SSE + sse = sum(sse_vals) # total SSE """Calculate covariance assuming experimental observation errors are independent and follow a Gaussian distribution with constant variance. From c3d37a53e96668bee0c1165f3096cf8f0736fed2 Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Tue, 20 May 2025 12:56:27 -0400 Subject: [PATCH 14/35] Updated the test file for the new covariance methods --- .../tests/test_new_parmest_capabilities.py | 291 ++ .../contrib/parmest/tests/test_parmest_cov.py | 2590 ----------------- 2 files changed, 291 insertions(+), 2590 deletions(-) create mode 100644 pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py delete mode 100644 pyomo/contrib/parmest/tests/test_parmest_cov.py diff --git a/pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py b/pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py new file mode 100644 index 00000000000..56a5776547d --- /dev/null +++ b/pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py @@ -0,0 +1,291 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ________________________________________________________________________ +# ___ + +import platform +import sys +import os +import subprocess +from itertools import product + +import pyomo.common.unittest as unittest +import pyomo.contrib.parmest.parmest as parmest +import pyomo.contrib.parmest.graphics as graphics +import pyomo.contrib.parmest as parmestbase +import pyomo.environ as pyo +import pyomo.dae as dae + +from pyomo.common.dependencies import numpy as np, pandas as pd, scipy, matplotlib +from pyomo.common.fileutils import this_file_dir +from pyomo.contrib.parmest.experiment import Experiment +from pyomo.contrib.pynumero.asl import AmplInterface +from pyomo.opt import SolverFactory + +is_osx = platform.mac_ver()[0] != "" +ipopt_available = SolverFactory("ipopt").available() +pynumero_ASL_available = AmplInterface.available() +testdir = this_file_dir() + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) + +# Test class for the built-in Parmest `SSE_weighted` objective function +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestModelVariants(unittest.TestCase): + + def setUp(self): + self.data = pd.DataFrame( + data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], + columns=["hour", "y"], + ) + + # create the Rooney-Biegler model + def rooney_biegler_model(): + """ + Formulates the Pyomo model of the Rooney-Biegler example + + Returns: + m: Pyomo model + """ + m = pyo.ConcreteModel() + + m.asymptote = pyo.Var(within=pyo.NonNegativeReals, initialize=10) + m.rate_constant = pyo.Var(within=pyo.NonNegativeReals, initialize=0.2) + + m.hour = pyo.Var(within=pyo.PositiveReals, initialize=0.1) + m.y = pyo.Var(within=pyo.NonNegativeReals) + + @m.Constraint() + def response_rule(m): + return m.y == m.asymptote * (1 - pyo.exp(- m.rate_constant * m.hour)) + + return m + + # create the Experiment class + class RooneyBieglerExperiment(Experiment): + def __init__(self, experiment_number, hour, y): + self.y = y + self.hour = hour + self.experiment_number = experiment_number + self.model = None + + + def get_labeled_model(self): + if self.model is None: + self.create_model() + self.finalize_model() + self.label_model() + return self.model + + + def create_model(self): + m = self.model = rooney_biegler_model() + + return m + + + def finalize_model(self): + m = self.model + + # fix the input variable + m.hour.fix(self.hour) + + return m + + + def label_model(self): + m = self.model + + # add experiment inputs + m.experiment_inputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.experiment_inputs.update( + [(m.hour, self.hour)] + ) + + # add experiment outputs + m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.experiment_outputs.update( + [(m.y, self.y)] + ) + + # add unknown parameters + m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.unknown_parameters.update((k, pyo.value(k)) for k in [m.asymptote, m.rate_constant]) + + # add measurement error + m.measurement_error = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.measurement_error.update([(m.y, None)]) + + return m + + + # creat the experiments list + rooney_biegler_exp_list = [] + hour_data = self.data["hour"] + y_data = self.data["y"] + for i in range(self.data.shape[0]): + rooney_biegler_exp_list.append( + RooneyBieglerExperiment(i, hour_data[i], y_data[i]) + ) + + self.exp_list = rooney_biegler_exp_list + + self.objective_function = "SSE" # testing the new covariance calculations for the `SSE` objective + + def check_rooney_biegler_results(self, objval, cov): + """ + Checks if the results are equal to the expected values and agree with the results of Rooney-Biegler + + Argument: + objval: the objective value of the annotated Pyomo model + cov: covariance matrix of the estimated parameters + """ + + # get indices in covariance matrix + cov_cols = cov.columns.to_list() + asymptote_index = [idx for idx, s in enumerate(cov_cols) if "asymptote" in s][0] + rate_constant_index = [ + idx for idx, s in enumerate(cov_cols) if "rate_constant" in s + ][0] + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[asymptote_index, asymptote_index], 6.229612, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[asymptote_index, rate_constant_index], -0.432265, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, asymptote_index], -0.432265, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, rate_constant_index], 0.041242, places=2 + ) # 0.04124 from paper + + + def test_parmest_basics(self): + """ + Calculates the parameter estimates and covariance matrix, and compares them with the results of Rooney-Biegler + """ + pest = parmest.Estimator( + self.exp_list, obj_function=self.objective_function + ) + + objval, thetavals = pest.theta_est() + cov = pest.cov_est(cov_n=6, method="finite_difference") + + self.check_rooney_biegler_results(objval, cov) + + + def test_cov_scipy_least_squares_comparison(self): + """ + Scipy results differ in the 3rd decimal place from the paper. It is possible + the paper used an alternative finite difference approximation for the Jacobian. + """ + + def model(theta, t): + """ + Model to be fitted y = model(theta, t) + Arguments: + theta: vector of fitted parameters + t: independent variable [hours] + + Returns: + y: model predictions [need to check paper for units] + """ + asymptote = theta[0] + rate_constant = theta[1] + + return asymptote * (1 - np.exp(-rate_constant * t)) + + def residual(theta, t, y): + """ + Calculate residuals + Arguments: + theta: vector of fitted parameters + t: independent variable [hours] + y: dependent variable [?] + """ + return y - model(theta, t) + + # define data + t = self.data["hour"].to_numpy() + y = self.data["y"].to_numpy() + + # define initial guess + theta_guess = np.array([15, 0.5]) + + ## solve with optimize.least_squares + sol = scipy.optimize.least_squares( + residual, theta_guess, method="trf", args=(t, y), verbose=2 + ) + theta_hat = sol.x + + self.assertAlmostEqual( + theta_hat[0], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper + + # calculate residuals + r = residual(theta_hat, t, y) + + # calculate variance of the residuals + # -2 because there are 2 fitted parameters + sigre = np.matmul(r.T, r / (len(y) - 2)) + + # approximate covariance + # Need to divide by 2 because optimize.least_squares scaled the objective by 1/2 + cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) + + self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper + self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + + def test_cov_scipy_curve_fit_comparison(self): + """ + Scipy results differ in the 3rd decimal place from the paper. It is possible + the paper used an alternative finite difference approximation for the Jacobian. + """ + + ## solve with optimize.curve_fit + def model(t, asymptote, rate_constant): + return asymptote * (1 - np.exp(-rate_constant * t)) + + # define data + t = self.data["hour"].to_numpy() + y = self.data["y"].to_numpy() + + # define initial guess + theta_guess = np.array([15, 0.5]) + + theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess) + + self.assertAlmostEqual( + theta_hat[0], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper + + self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper + self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/contrib/parmest/tests/test_parmest_cov.py b/pyomo/contrib/parmest/tests/test_parmest_cov.py deleted file mode 100644 index ad7785ff379..00000000000 --- a/pyomo/contrib/parmest/tests/test_parmest_cov.py +++ /dev/null @@ -1,2590 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2025 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ________________________________________________________________________ -# ___ - -import platform -import sys -import os -import subprocess -from itertools import product - -import pyomo.common.unittest as unittest -import pyomo.contrib.parmest.parmest as parmest -import pyomo.contrib.parmest.graphics as graphics -import pyomo.contrib.parmest as parmestbase -import pyomo.environ as pyo -import pyomo.dae as dae - -from pyomo.common.dependencies import numpy as np, pandas as pd, scipy, matplotlib -from pyomo.common.fileutils import this_file_dir -from pyomo.contrib.parmest.experiment import Experiment -from pyomo.contrib.pynumero.asl import AmplInterface -from pyomo.opt import SolverFactory - -is_osx = platform.mac_ver()[0] != "" -ipopt_available = SolverFactory("ipopt").available() -pynumero_ASL_available = AmplInterface.available() -testdir = this_file_dir() - - -@unittest.skipIf( - not parmest.parmest_available, - "Cannot test parmest: required dependencies are missing", -) - -# Test class for when the user wants to use the built-in Parmest SSE objective function -@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") -class TestRooneyBieglerSSE(unittest.TestCase): - def setUp(self): - from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import ( - RooneyBieglerExperiment, - ) - - # Note, the data used in this test has been corrected to use - # data.loc[5,'hour'] = 7 (instead of 6) - data = pd.DataFrame( - data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], - columns=["hour", "y"], - ) - - # Create an experiment list - exp_list = [] - for i in range(data.shape[0]): - exp_list.append(RooneyBieglerExperiment(data.loc[i, :])) - - # Create an instance of the parmest estimator - pest = parmest.Estimator(exp_list, obj_function="SSE") - - solver_options = {"tol": 1e-8} - - self.data = data - self.pest = parmest.Estimator( - exp_list, obj_function="SSE", solver_options=solver_options, tee=True - ) - - def test_theta_est(self): - objval, thetavals = self.pest.theta_est() - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper - - @unittest.skipIf( - not graphics.imports_available, "parmest.graphics imports are unavailable" - ) - def test_bootstrap(self): - objval, thetavals = self.pest.theta_est() - - num_bootstraps = 10 - theta_est = self.pest.theta_est_bootstrap(num_bootstraps, return_samples=True) - - num_samples = theta_est["samples"].apply(len) - self.assertEqual(len(theta_est.index), 10) - self.assertTrue(num_samples.equals(pd.Series([6] * 10))) - - del theta_est["samples"] - - # apply confidence region test - CR = self.pest.confidence_region_test(theta_est, "MVN", [0.5, 0.75, 1.0]) - - self.assertTrue(set(CR.columns) >= set([0.5, 0.75, 1.0])) - self.assertEqual(CR[0.5].sum(), 5) - self.assertEqual(CR[0.75].sum(), 7) - self.assertEqual(CR[1.0].sum(), 10) # all true - - graphics.pairwise_plot(theta_est) - graphics.pairwise_plot(theta_est, thetavals) - graphics.pairwise_plot(theta_est, thetavals, 0.8, ["MVN", "KDE", "Rect"]) - - @unittest.skipIf( - not graphics.imports_available, "parmest.graphics imports are unavailable" - ) - def test_likelihood_ratio(self): - objval, thetavals = self.pest.theta_est() - - asym = np.arange(10, 30, 2) - rate = np.arange(0, 1.5, 0.25) - theta_vals = pd.DataFrame( - list(product(asym, rate)), columns=['asymptote', 'rate_constant'] - ) - obj_at_theta = self.pest.objective_at_theta(theta_vals) - - LR = self.pest.likelihood_ratio_test(obj_at_theta, objval, [0.8, 0.9, 1.0]) - - self.assertTrue(set(LR.columns) >= set([0.8, 0.9, 1.0])) - self.assertEqual(LR[0.8].sum(), 6) - self.assertEqual(LR[0.9].sum(), 10) - self.assertEqual(LR[1.0].sum(), 60) # all true - - graphics.pairwise_plot(LR, thetavals, 0.8) - - def test_leaveNout(self): - lNo_theta = self.pest.theta_est_leaveNout(1) - self.assertTrue(lNo_theta.shape == (6, 2)) - - results = self.pest.leaveNout_bootstrap_test( - 1, None, 3, "Rect", [0.5, 1.0], seed=5436 - ) - self.assertEqual(len(results), 6) # 6 lNo samples - i = 1 - samples = results[i][0] # list of N samples that are left out - lno_theta = results[i][1] - bootstrap_theta = results[i][2] - self.assertTrue(samples == [1]) # sample 1 was left out - self.assertEqual(lno_theta.shape[0], 1) # lno estimate for sample 1 - self.assertTrue(set(lno_theta.columns) >= set([0.5, 1.0])) - self.assertEqual(lno_theta[1.0].sum(), 1) # all true - self.assertEqual(bootstrap_theta.shape[0], 3) # bootstrap for sample 1 - self.assertEqual(bootstrap_theta[1.0].sum(), 3) # all true - - def test_diagnostic_mode(self): - self.pest.diagnostic_mode = True - - objval, thetavals = self.pest.theta_est() - - asym = np.arange(10, 30, 2) - rate = np.arange(0, 1.5, 0.25) - theta_vals = pd.DataFrame( - list(product(asym, rate)), columns=['asymptote', 'rate_constant'] - ) - - obj_at_theta = self.pest.objective_at_theta(theta_vals) - - self.pest.diagnostic_mode = False - - @unittest.skip("Presently having trouble with mpiexec on appveyor") - def test_parallel_parmest(self): - """use mpiexec and mpi4py""" - p = str(parmestbase.__path__) - l = p.find("'") - r = p.find("'", l + 1) - parmestpath = p[l + 1 : r] - rbpath = ( - parmestpath - + os.sep - + "examples" - + os.sep - + "rooney_biegler" - + os.sep - + "rooney_biegler_parmest.py" - ) - rbpath = os.path.abspath(rbpath) # paranoia strikes deep... - rlist = ["mpiexec", "--allow-run-as-root", "-n", "2", sys.executable, rbpath] - if sys.version_info >= (3, 5): - ret = subprocess.run(rlist) - retcode = ret.returncode - else: - retcode = subprocess.call(rlist) - self.assertEqual(retcode, 0) - - # @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") - def test_theta_est_cov(self): - objval, thetavals, cov = self.pest.theta_est(calc_cov=True, cov_n=6) - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper - - # Covariance matrix - self.assertAlmostEqual( - cov["asymptote"]["asymptote"], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov["asymptote"]["rate_constant"], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov["rate_constant"]["asymptote"], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov["rate_constant"]["rate_constant"], 0.04124, places=2 - ) # 0.04124 from paper - - """ Why does the covariance matrix from parmest not match the paper? Parmest is - calculating the exact reduced Hessian. The paper (Rooney and Bielger, 2001) likely - employed the first order approximation common for nonlinear regression. The paper - values were verified with Scipy, which uses the same first order approximation. - The formula used in parmest was verified against equations (7-5-15) and (7-5-16) in - "Nonlinear Parameter Estimation", Y. Bard, 1974. - """ - - def test_cov_scipy_least_squares_comparison(self): - """ - Scipy results differ in the 3rd decimal place from the paper. It is possible - the paper used an alternative finite difference approximation for the Jacobian. - """ - - def model(theta, t): - """ - Model to be fitted y = model(theta, t) - Arguments: - theta: vector of fitted parameters - t: independent variable [hours] - - Returns: - y: model predictions [need to check paper for units] - """ - asymptote = theta[0] - rate_constant = theta[1] - - return asymptote * (1 - np.exp(-rate_constant * t)) - - def residual(theta, t, y): - """ - Calculate residuals - Arguments: - theta: vector of fitted parameters - t: independent variable [hours] - y: dependent variable [?] - """ - return y - model(theta, t) - - # define data - t = self.data["hour"].to_numpy() - y = self.data["y"].to_numpy() - - # define initial guess - theta_guess = np.array([15, 0.5]) - - ## solve with optimize.least_squares - sol = scipy.optimize.least_squares( - residual, theta_guess, method="trf", args=(t, y), verbose=2 - ) - theta_hat = sol.x - - self.assertAlmostEqual( - theta_hat[0], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper - - # calculate residuals - r = residual(theta_hat, t, y) - - # calculate variance of the residuals - # -2 because there are 2 fitted parameters - sigre = np.matmul(r.T, r / (len(y) - 2)) - - # approximate covariance - # Need to divide by 2 because optimize.least_squares scaled the objective by 1/2 - cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) - - self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper - self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper - - def test_cov_scipy_curve_fit_comparison(self): - """ - Scipy results differ in the 3rd decimal place from the paper. It is possible - the paper used an alternative finite difference approximation for the Jacobian. - """ - - ## solve with optimize.curve_fit - def model(t, asymptote, rate_constant): - return asymptote * (1 - np.exp(-rate_constant * t)) - - # define data - t = self.data["hour"].to_numpy() - y = self.data["y"].to_numpy() - - # define initial guess - theta_guess = np.array([15, 0.5]) - - theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess) - - self.assertAlmostEqual( - theta_hat[0], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper - - self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper - self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper - -# Test class for when the user wants to use the built-in Parmest SSE_weighted objective function -@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") -class TestRooneyBieglerWSSE(unittest.TestCase): - def setUp(self): - from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import ( - RooneyBieglerExperiment, - ) - - # Note, the data used in this test has been corrected to use - # data.loc[5,'hour'] = 7 (instead of 6) - data = pd.DataFrame( - data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], - columns=["hour", "y"], - ) - - # Create an experiment list - exp_list = [] - for i in range(data.shape[0]): - exp_list.append(RooneyBieglerExperiment(data.loc[i, :])) - - # Create an instance of the parmest estimator - pest = parmest.Estimator(exp_list, obj_function="SSE_weighted") - - solver_options = {"tol": 1e-8} - - self.data = data - self.pest = parmest.Estimator( - exp_list, obj_function="SSE_weighted", solver_options=solver_options, tee=True - ) - - def test_theta_est(self): - objval, thetavals = self.pest.theta_est() - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper - - @unittest.skipIf( - not graphics.imports_available, "parmest.graphics imports are unavailable" - ) - def test_bootstrap(self): - objval, thetavals = self.pest.theta_est() - - num_bootstraps = 10 - theta_est = self.pest.theta_est_bootstrap(num_bootstraps, return_samples=True) - - num_samples = theta_est["samples"].apply(len) - self.assertEqual(len(theta_est.index), 10) - self.assertTrue(num_samples.equals(pd.Series([6] * 10))) - - del theta_est["samples"] - - # apply confidence region test - CR = self.pest.confidence_region_test(theta_est, "MVN", [0.5, 0.75, 1.0]) - - self.assertTrue(set(CR.columns) >= set([0.5, 0.75, 1.0])) - self.assertEqual(CR[0.5].sum(), 5) - self.assertEqual(CR[0.75].sum(), 7) - self.assertEqual(CR[1.0].sum(), 10) # all true - - graphics.pairwise_plot(theta_est) - graphics.pairwise_plot(theta_est, thetavals) - graphics.pairwise_plot(theta_est, thetavals, 0.8, ["MVN", "KDE", "Rect"]) - - @unittest.skipIf( - not graphics.imports_available, "parmest.graphics imports are unavailable" - ) - def test_likelihood_ratio(self): - objval, thetavals = self.pest.theta_est() - - asym = np.arange(10, 30, 2) - rate = np.arange(0, 1.5, 0.25) - theta_vals = pd.DataFrame( - list(product(asym, rate)), columns=['asymptote', 'rate_constant'] - ) - obj_at_theta = self.pest.objective_at_theta(theta_vals) - - LR = self.pest.likelihood_ratio_test(obj_at_theta, objval, [0.8, 0.9, 1.0]) - - self.assertTrue(set(LR.columns) >= set([0.8, 0.9, 1.0])) - self.assertEqual(LR[0.8].sum(), 6) - self.assertEqual(LR[0.9].sum(), 10) - self.assertEqual(LR[1.0].sum(), 60) # all true - - graphics.pairwise_plot(LR, thetavals, 0.8) - - def test_leaveNout(self): - lNo_theta = self.pest.theta_est_leaveNout(1) - self.assertTrue(lNo_theta.shape == (6, 2)) - - results = self.pest.leaveNout_bootstrap_test( - 1, None, 3, "Rect", [0.5, 1.0], seed=5436 - ) - self.assertEqual(len(results), 6) # 6 lNo samples - i = 1 - samples = results[i][0] # list of N samples that are left out - lno_theta = results[i][1] - bootstrap_theta = results[i][2] - self.assertTrue(samples == [1]) # sample 1 was left out - self.assertEqual(lno_theta.shape[0], 1) # lno estimate for sample 1 - self.assertTrue(set(lno_theta.columns) >= set([0.5, 1.0])) - self.assertEqual(lno_theta[1.0].sum(), 1) # all true - self.assertEqual(bootstrap_theta.shape[0], 3) # bootstrap for sample 1 - self.assertEqual(bootstrap_theta[1.0].sum(), 3) # all true - - def test_diagnostic_mode(self): - self.pest.diagnostic_mode = True - - objval, thetavals = self.pest.theta_est() - - asym = np.arange(10, 30, 2) - rate = np.arange(0, 1.5, 0.25) - theta_vals = pd.DataFrame( - list(product(asym, rate)), columns=['asymptote', 'rate_constant'] - ) - - obj_at_theta = self.pest.objective_at_theta(theta_vals) - - self.pest.diagnostic_mode = False - - @unittest.skip("Presently having trouble with mpiexec on appveyor") - def test_parallel_parmest(self): - """use mpiexec and mpi4py""" - p = str(parmestbase.__path__) - l = p.find("'") - r = p.find("'", l + 1) - parmestpath = p[l + 1 : r] - rbpath = ( - parmestpath - + os.sep - + "examples" - + os.sep - + "rooney_biegler" - + os.sep - + "rooney_biegler_parmest.py" - ) - rbpath = os.path.abspath(rbpath) # paranoia strikes deep... - rlist = ["mpiexec", "--allow-run-as-root", "-n", "2", sys.executable, rbpath] - if sys.version_info >= (3, 5): - ret = subprocess.run(rlist) - retcode = ret.returncode - else: - retcode = subprocess.call(rlist) - self.assertEqual(retcode, 0) - - # @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") - def test_theta_est_cov(self): - objval, thetavals, cov = self.pest.theta_est(calc_cov=True, cov_n=6) - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper - - # Covariance matrix - self.assertAlmostEqual( - cov["asymptote"]["asymptote"], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov["asymptote"]["rate_constant"], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov["rate_constant"]["asymptote"], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov["rate_constant"]["rate_constant"], 0.04124, places=2 - ) # 0.04124 from paper - - """ Why does the covariance matrix from parmest not match the paper? Parmest is - calculating the exact reduced Hessian. The paper (Rooney and Bielger, 2001) likely - employed the first order approximation common for nonlinear regression. The paper - values were verified with Scipy, which uses the same first order approximation. - The formula used in parmest was verified against equations (7-5-15) and (7-5-16) in - "Nonlinear Parameter Estimation", Y. Bard, 1974. - """ - - def test_cov_scipy_least_squares_comparison(self): - """ - Scipy results differ in the 3rd decimal place from the paper. It is possible - the paper used an alternative finite difference approximation for the Jacobian. - """ - - def model(theta, t): - """ - Model to be fitted y = model(theta, t) - Arguments: - theta: vector of fitted parameters - t: independent variable [hours] - - Returns: - y: model predictions [need to check paper for units] - """ - asymptote = theta[0] - rate_constant = theta[1] - - return asymptote * (1 - np.exp(-rate_constant * t)) - - def residual(theta, t, y): - """ - Calculate residuals - Arguments: - theta: vector of fitted parameters - t: independent variable [hours] - y: dependent variable [?] - """ - return y - model(theta, t) - - # define data - t = self.data["hour"].to_numpy() - y = self.data["y"].to_numpy() - - # define initial guess - theta_guess = np.array([15, 0.5]) - - ## solve with optimize.least_squares - sol = scipy.optimize.least_squares( - residual, theta_guess, method="trf", args=(t, y), verbose=2 - ) - theta_hat = sol.x - - self.assertAlmostEqual( - theta_hat[0], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper - - # calculate residuals - r = residual(theta_hat, t, y) - - # calculate variance of the residuals - # -2 because there are 2 fitted parameters - sigre = np.matmul(r.T, r / (len(y) - 2)) - - # approximate covariance - # Need to divide by 2 because optimize.least_squares scaled the objective by 1/2 - cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) - - self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper - self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper - - def test_cov_scipy_curve_fit_comparison(self): - """ - Scipy results differ in the 3rd decimal place from the paper. It is possible - the paper used an alternative finite difference approximation for the Jacobian. - """ - - ## solve with optimize.curve_fit - def model(t, asymptote, rate_constant): - return asymptote * (1 - np.exp(-rate_constant * t)) - - # define data - t = self.data["hour"].to_numpy() - y = self.data["y"].to_numpy() - - # define initial guess - theta_guess = np.array([15, 0.5]) - - theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess) - - self.assertAlmostEqual( - theta_hat[0], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper - - self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper - self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper - -# # Test class for when the user supply their SSE objective function -# @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") -# class TestRooneyBiegler(unittest.TestCase): -# def setUp(self): -# from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import ( -# RooneyBieglerExperiment, -# ) -# -# # Note, the data used in this test has been corrected to use -# # data.loc[5,'hour'] = 7 (instead of 6) -# data = pd.DataFrame( -# data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], -# columns=["hour", "y"], -# ) -# -# # Sum of squared error function -# def SSE(model): -# expr = ( -# model.experiment_outputs[model.y] -# - model.response_function[model.experiment_outputs[model.hour]] -# ) ** 2 -# return expr -# -# # Create an experiment list -# exp_list = [] -# for i in range(data.shape[0]): -# exp_list.append(RooneyBieglerExperiment(data.loc[i, :])) -# -# # Create an instance of the parmest estimator -# pest = parmest.Estimator(exp_list, obj_function=SSE) -# -# solver_options = {"tol": 1e-8} -# -# self.data = data -# self.pest = parmest.Estimator( -# exp_list, obj_function=SSE, solver_options=solver_options, tee=True -# ) -# -# def test_theta_est(self): -# objval, thetavals = self.pest.theta_est() -# -# self.assertAlmostEqual(objval, 4.3317112, places=2) -# self.assertAlmostEqual( -# thetavals["asymptote"], 19.1426, places=2 -# ) # 19.1426 from the paper -# self.assertAlmostEqual( -# thetavals["rate_constant"], 0.5311, places=2 -# ) # 0.5311 from the paper -# -# @unittest.skipIf( -# not graphics.imports_available, "parmest.graphics imports are unavailable" -# ) -# def test_bootstrap(self): -# objval, thetavals = self.pest.theta_est() -# -# num_bootstraps = 10 -# theta_est = self.pest.theta_est_bootstrap(num_bootstraps, return_samples=True) -# -# num_samples = theta_est["samples"].apply(len) -# self.assertEqual(len(theta_est.index), 10) -# self.assertTrue(num_samples.equals(pd.Series([6] * 10))) -# -# del theta_est["samples"] -# -# # apply confidence region test -# CR = self.pest.confidence_region_test(theta_est, "MVN", [0.5, 0.75, 1.0]) -# -# self.assertTrue(set(CR.columns) >= set([0.5, 0.75, 1.0])) -# self.assertEqual(CR[0.5].sum(), 5) -# self.assertEqual(CR[0.75].sum(), 7) -# self.assertEqual(CR[1.0].sum(), 10) # all true -# -# graphics.pairwise_plot(theta_est) -# graphics.pairwise_plot(theta_est, thetavals) -# graphics.pairwise_plot(theta_est, thetavals, 0.8, ["MVN", "KDE", "Rect"]) -# -# @unittest.skipIf( -# not graphics.imports_available, "parmest.graphics imports are unavailable" -# ) -# def test_likelihood_ratio(self): -# objval, thetavals = self.pest.theta_est() -# -# asym = np.arange(10, 30, 2) -# rate = np.arange(0, 1.5, 0.25) -# theta_vals = pd.DataFrame( -# list(product(asym, rate)), columns=['asymptote', 'rate_constant'] -# ) -# obj_at_theta = self.pest.objective_at_theta(theta_vals) -# -# LR = self.pest.likelihood_ratio_test(obj_at_theta, objval, [0.8, 0.9, 1.0]) -# -# self.assertTrue(set(LR.columns) >= set([0.8, 0.9, 1.0])) -# self.assertEqual(LR[0.8].sum(), 6) -# self.assertEqual(LR[0.9].sum(), 10) -# self.assertEqual(LR[1.0].sum(), 60) # all true -# -# graphics.pairwise_plot(LR, thetavals, 0.8) -# -# def test_leaveNout(self): -# lNo_theta = self.pest.theta_est_leaveNout(1) -# self.assertTrue(lNo_theta.shape == (6, 2)) -# -# results = self.pest.leaveNout_bootstrap_test( -# 1, None, 3, "Rect", [0.5, 1.0], seed=5436 -# ) -# self.assertEqual(len(results), 6) # 6 lNo samples -# i = 1 -# samples = results[i][0] # list of N samples that are left out -# lno_theta = results[i][1] -# bootstrap_theta = results[i][2] -# self.assertTrue(samples == [1]) # sample 1 was left out -# self.assertEqual(lno_theta.shape[0], 1) # lno estimate for sample 1 -# self.assertTrue(set(lno_theta.columns) >= set([0.5, 1.0])) -# self.assertEqual(lno_theta[1.0].sum(), 1) # all true -# self.assertEqual(bootstrap_theta.shape[0], 3) # bootstrap for sample 1 -# self.assertEqual(bootstrap_theta[1.0].sum(), 3) # all true -# -# def test_diagnostic_mode(self): -# self.pest.diagnostic_mode = True -# -# objval, thetavals = self.pest.theta_est() -# -# asym = np.arange(10, 30, 2) -# rate = np.arange(0, 1.5, 0.25) -# theta_vals = pd.DataFrame( -# list(product(asym, rate)), columns=['asymptote', 'rate_constant'] -# ) -# -# obj_at_theta = self.pest.objective_at_theta(theta_vals) -# -# self.pest.diagnostic_mode = False -# -# @unittest.skip("Presently having trouble with mpiexec on appveyor") -# def test_parallel_parmest(self): -# """use mpiexec and mpi4py""" -# p = str(parmestbase.__path__) -# l = p.find("'") -# r = p.find("'", l + 1) -# parmestpath = p[l + 1 : r] -# rbpath = ( -# parmestpath -# + os.sep -# + "examples" -# + os.sep -# + "rooney_biegler" -# + os.sep -# + "rooney_biegler_parmest.py" -# ) -# rbpath = os.path.abspath(rbpath) # paranoia strikes deep... -# rlist = ["mpiexec", "--allow-run-as-root", "-n", "2", sys.executable, rbpath] -# if sys.version_info >= (3, 5): -# ret = subprocess.run(rlist) -# retcode = ret.returncode -# else: -# retcode = subprocess.call(rlist) -# self.assertEqual(retcode, 0) -# -# # @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") -# def test_theta_est_cov(self): -# objval, thetavals, cov = self.pest.theta_est(calc_cov=True, cov_n=6) -# -# self.assertAlmostEqual(objval, 4.3317112, places=2) -# self.assertAlmostEqual( -# thetavals["asymptote"], 19.1426, places=2 -# ) # 19.1426 from the paper -# self.assertAlmostEqual( -# thetavals["rate_constant"], 0.5311, places=2 -# ) # 0.5311 from the paper -# -# # Covariance matrix -# self.assertAlmostEqual( -# cov["asymptote"]["asymptote"], 6.30579403, places=2 -# ) # 6.22864 from paper -# self.assertAlmostEqual( -# cov["asymptote"]["rate_constant"], -0.4395341, places=2 -# ) # -0.4322 from paper -# self.assertAlmostEqual( -# cov["rate_constant"]["asymptote"], -0.4395341, places=2 -# ) # -0.4322 from paper -# self.assertAlmostEqual( -# cov["rate_constant"]["rate_constant"], 0.04124, places=2 -# ) # 0.04124 from paper -# -# """ Why does the covariance matrix from parmest not match the paper? Parmest is -# calculating the exact reduced Hessian. The paper (Rooney and Bielger, 2001) likely -# employed the first order approximation common for nonlinear regression. The paper -# values were verified with Scipy, which uses the same first order approximation. -# The formula used in parmest was verified against equations (7-5-15) and (7-5-16) in -# "Nonlinear Parameter Estimation", Y. Bard, 1974. -# """ -# -# def test_cov_scipy_least_squares_comparison(self): -# """ -# Scipy results differ in the 3rd decimal place from the paper. It is possible -# the paper used an alternative finite difference approximation for the Jacobian. -# """ -# -# def model(theta, t): -# """ -# Model to be fitted y = model(theta, t) -# Arguments: -# theta: vector of fitted parameters -# t: independent variable [hours] -# -# Returns: -# y: model predictions [need to check paper for units] -# """ -# asymptote = theta[0] -# rate_constant = theta[1] -# -# return asymptote * (1 - np.exp(-rate_constant * t)) -# -# def residual(theta, t, y): -# """ -# Calculate residuals -# Arguments: -# theta: vector of fitted parameters -# t: independent variable [hours] -# y: dependent variable [?] -# """ -# return y - model(theta, t) -# -# # define data -# t = self.data["hour"].to_numpy() -# y = self.data["y"].to_numpy() -# -# # define initial guess -# theta_guess = np.array([15, 0.5]) -# -# ## solve with optimize.least_squares -# sol = scipy.optimize.least_squares( -# residual, theta_guess, method="trf", args=(t, y), verbose=2 -# ) -# theta_hat = sol.x -# -# self.assertAlmostEqual( -# theta_hat[0], 19.1426, places=2 -# ) # 19.1426 from the paper -# self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper -# -# # calculate residuals -# r = residual(theta_hat, t, y) -# -# # calculate variance of the residuals -# # -2 because there are 2 fitted parameters -# sigre = np.matmul(r.T, r / (len(y) - 2)) -# -# # approximate covariance -# # Need to divide by 2 because optimize.least_squares scaled the objective by 1/2 -# cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) -# -# self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper -# self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper -# self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper -# self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper -# -# def test_cov_scipy_curve_fit_comparison(self): -# """ -# Scipy results differ in the 3rd decimal place from the paper. It is possible -# the paper used an alternative finite difference approximation for the Jacobian. -# """ -# -# ## solve with optimize.curve_fit -# def model(t, asymptote, rate_constant): -# return asymptote * (1 - np.exp(-rate_constant * t)) -# -# # define data -# t = self.data["hour"].to_numpy() -# y = self.data["y"].to_numpy() -# -# # define initial guess -# theta_guess = np.array([15, 0.5]) -# -# theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess) -# -# self.assertAlmostEqual( -# theta_hat[0], 19.1426, places=2 -# ) # 19.1426 from the paper -# self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper -# -# self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper -# self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper -# self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper -# self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper - - -@unittest.skipIf( - not parmest.parmest_available, - "Cannot test parmest: required dependencies are missing", -) -@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") -class TestModelVariants(unittest.TestCase): - - def setUp(self): - from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import ( - RooneyBieglerExperiment, - ) - - self.data = pd.DataFrame( - data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], - columns=["hour", "y"], - ) - - def rooney_biegler_params(data): - model = pyo.ConcreteModel() - - model.asymptote = pyo.Param(initialize=15, mutable=True) - model.rate_constant = pyo.Param(initialize=0.5, mutable=True) - - model.hour = pyo.Param(within=pyo.PositiveReals, mutable=True) - model.y = pyo.Param(within=pyo.PositiveReals, mutable=True) - - def response_rule(m, h): - expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) - return expr - - model.response_function = pyo.Expression(data.hour, rule=response_rule) - - return model - - class RooneyBieglerExperimentParams(RooneyBieglerExperiment): - - def create_model(self): - data_df = self.data.to_frame().transpose() - self.model = rooney_biegler_params(data_df) - - rooney_biegler_params_exp_list = [] - for i in range(self.data.shape[0]): - rooney_biegler_params_exp_list.append( - RooneyBieglerExperimentParams(self.data.loc[i, :]) - ) - - def rooney_biegler_indexed_params(data): - model = pyo.ConcreteModel() - - model.param_names = pyo.Set(initialize=["asymptote", "rate_constant"]) - model.theta = pyo.Param( - model.param_names, - initialize={"asymptote": 15, "rate_constant": 0.5}, - mutable=True, - ) - - model.hour = pyo.Param(within=pyo.PositiveReals, mutable=True) - model.y = pyo.Param(within=pyo.PositiveReals, mutable=True) - - def response_rule(m, h): - expr = m.theta["asymptote"] * ( - 1 - pyo.exp(-m.theta["rate_constant"] * h) - ) - return expr - - model.response_function = pyo.Expression(data.hour, rule=response_rule) - - return model - - class RooneyBieglerExperimentIndexedParams(RooneyBieglerExperiment): - - def create_model(self): - data_df = self.data.to_frame().transpose() - self.model = rooney_biegler_indexed_params(data_df) - - def label_model(self): - - m = self.model - - m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) - m.experiment_outputs.update( - [(m.hour, self.data["hour"]), (m.y, self.data["y"])] - ) - - m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) - m.unknown_parameters.update((k, pyo.ComponentUID(k)) for k in [m.theta]) - - rooney_biegler_indexed_params_exp_list = [] - for i in range(self.data.shape[0]): - rooney_biegler_indexed_params_exp_list.append( - RooneyBieglerExperimentIndexedParams(self.data.loc[i, :]) - ) - - def rooney_biegler_vars(data): - model = pyo.ConcreteModel() - - model.asymptote = pyo.Var(initialize=15) - model.rate_constant = pyo.Var(initialize=0.5) - model.asymptote.fixed = True # parmest will unfix theta variables - model.rate_constant.fixed = True - - model.hour = pyo.Param(within=pyo.PositiveReals, mutable=True) - model.y = pyo.Param(within=pyo.PositiveReals, mutable=True) - - def response_rule(m, h): - expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) - return expr - - model.response_function = pyo.Expression(data.hour, rule=response_rule) - - return model - - class RooneyBieglerExperimentVars(RooneyBieglerExperiment): - - def create_model(self): - data_df = self.data.to_frame().transpose() - self.model = rooney_biegler_vars(data_df) - - rooney_biegler_vars_exp_list = [] - for i in range(self.data.shape[0]): - rooney_biegler_vars_exp_list.append( - RooneyBieglerExperimentVars(self.data.loc[i, :]) - ) - - def rooney_biegler_indexed_vars(data): - model = pyo.ConcreteModel() - - model.var_names = pyo.Set(initialize=["asymptote", "rate_constant"]) - model.theta = pyo.Var( - model.var_names, initialize={"asymptote": 15, "rate_constant": 0.5} - ) - model.theta["asymptote"].fixed = ( - True # parmest will unfix theta variables, even when they are indexed - ) - model.theta["rate_constant"].fixed = True - - model.hour = pyo.Param(within=pyo.PositiveReals, mutable=True) - model.y = pyo.Param(within=pyo.PositiveReals, mutable=True) - - def response_rule(m, h): - expr = m.theta["asymptote"] * ( - 1 - pyo.exp(-m.theta["rate_constant"] * h) - ) - return expr - - model.response_function = pyo.Expression(data.hour, rule=response_rule) - - return model - - class RooneyBieglerExperimentIndexedVars(RooneyBieglerExperiment): - - def create_model(self): - data_df = self.data.to_frame().transpose() - self.model = rooney_biegler_indexed_vars(data_df) - - def label_model(self): - - m = self.model - - m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) - m.experiment_outputs.update( - [(m.hour, self.data["hour"]), (m.y, self.data["y"])] - ) - - m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) - m.unknown_parameters.update((k, pyo.ComponentUID(k)) for k in [m.theta]) - - rooney_biegler_indexed_vars_exp_list = [] - for i in range(self.data.shape[0]): - rooney_biegler_indexed_vars_exp_list.append( - RooneyBieglerExperimentIndexedVars(self.data.loc[i, :]) - ) - - # # Sum of squared error function - # def SSE(model): - # expr = ( - # model.experiment_outputs[model.y] - # - model.response_function[model.experiment_outputs[model.hour]] - # ) ** 2 - # return expr - # - # self.objective_function = SSE - self.objective_function = "SSE" - - theta_vals = pd.DataFrame([20, 1], index=["asymptote", "rate_constant"]).T - theta_vals_index = pd.DataFrame( - [20, 1], index=["theta['asymptote']", "theta['rate_constant']"] - ).T - - self.input = { - "param": { - "exp_list": rooney_biegler_params_exp_list, - "theta_names": ["asymptote", "rate_constant"], - "theta_vals": theta_vals, - }, - "param_index": { - "exp_list": rooney_biegler_indexed_params_exp_list, - "theta_names": ["theta"], - "theta_vals": theta_vals_index, - }, - "vars": { - "exp_list": rooney_biegler_vars_exp_list, - "theta_names": ["asymptote", "rate_constant"], - "theta_vals": theta_vals, - }, - "vars_index": { - "exp_list": rooney_biegler_indexed_vars_exp_list, - "theta_names": ["theta"], - "theta_vals": theta_vals_index, - }, - "vars_quoted_index": { - "exp_list": rooney_biegler_indexed_vars_exp_list, - "theta_names": ["theta['asymptote']", "theta['rate_constant']"], - "theta_vals": theta_vals_index, - }, - "vars_str_index": { - "exp_list": rooney_biegler_indexed_vars_exp_list, - "theta_names": ["theta[asymptote]", "theta[rate_constant]"], - "theta_vals": theta_vals_index, - }, - } - - @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") - def check_rooney_biegler_results(self, objval, cov): - - # get indices in covariance matrix - cov_cols = cov.columns.to_list() - asymptote_index = [idx for idx, s in enumerate(cov_cols) if "asymptote" in s][0] - rate_constant_index = [ - idx for idx, s in enumerate(cov_cols) if "rate_constant" in s - ][0] - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - cov.iloc[asymptote_index, asymptote_index], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[asymptote_index, rate_constant_index], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[rate_constant_index, asymptote_index], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[rate_constant_index, rate_constant_index], 0.04193591, places=2 - ) # 0.04124 from paper - - @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') - def test_parmest_basics(self): - - for model_type, parmest_input in self.input.items(): - pest = parmest.Estimator( - parmest_input["exp_list"], obj_function=self.objective_function - ) - - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - self.check_rooney_biegler_results(objval, cov) - - obj_at_theta = pest.objective_at_theta(parmest_input["theta_vals"]) - self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) - - @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') - def test_parmest_basics_with_initialize_parmest_model_option(self): - - for model_type, parmest_input in self.input.items(): - pest = parmest.Estimator( - parmest_input["exp_list"], obj_function=self.objective_function - ) - - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - self.check_rooney_biegler_results(objval, cov) - - obj_at_theta = pest.objective_at_theta( - parmest_input["theta_vals"], initialize_parmest_model=True - ) - - self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) - - @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') - def test_parmest_basics_with_square_problem_solve(self): - - for model_type, parmest_input in self.input.items(): - pest = parmest.Estimator( - parmest_input["exp_list"], obj_function=self.objective_function - ) - - obj_at_theta = pest.objective_at_theta( - parmest_input["theta_vals"], initialize_parmest_model=True - ) - - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - self.check_rooney_biegler_results(objval, cov) - - self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) - - @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') - def test_parmest_basics_with_square_problem_solve_no_theta_vals(self): - - for model_type, parmest_input in self.input.items(): - - pest = parmest.Estimator( - parmest_input["exp_list"], obj_function=self.objective_function - ) - - obj_at_theta = pest.objective_at_theta(initialize_parmest_model=True) - - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - self.check_rooney_biegler_results(objval, cov) - - -@unittest.skipIf( - not parmest.parmest_available, - "Cannot test parmest: required dependencies are missing", -) -@unittest.skipIf(not ipopt_available, "The 'ipopt' solver is not available") -class TestReactorDesign(unittest.TestCase): - def setUp(self): - from pyomo.contrib.parmest.examples.reactor_design.reactor_design import ( - ReactorDesignExperiment, - ) - - # Data from the design - data = pd.DataFrame( - data=[ - [1.05, 10000, 3458.4, 1060.8, 1683.9, 1898.5], - [1.10, 10000, 3535.1, 1064.8, 1613.3, 1893.4], - [1.15, 10000, 3609.1, 1067.8, 1547.5, 1887.8], - [1.20, 10000, 3680.7, 1070.0, 1486.1, 1881.6], - [1.25, 10000, 3750.0, 1071.4, 1428.6, 1875.0], - [1.30, 10000, 3817.1, 1072.2, 1374.6, 1868.0], - [1.35, 10000, 3882.2, 1072.4, 1324.0, 1860.7], - [1.40, 10000, 3945.4, 1072.1, 1276.3, 1853.1], - [1.45, 10000, 4006.7, 1071.3, 1231.4, 1845.3], - [1.50, 10000, 4066.4, 1070.1, 1189.0, 1837.3], - [1.55, 10000, 4124.4, 1068.5, 1148.9, 1829.1], - [1.60, 10000, 4180.9, 1066.5, 1111.0, 1820.8], - [1.65, 10000, 4235.9, 1064.3, 1075.0, 1812.4], - [1.70, 10000, 4289.5, 1061.8, 1040.9, 1803.9], - [1.75, 10000, 4341.8, 1059.0, 1008.5, 1795.3], - [1.80, 10000, 4392.8, 1056.0, 977.7, 1786.7], - [1.85, 10000, 4442.6, 1052.8, 948.4, 1778.1], - [1.90, 10000, 4491.3, 1049.4, 920.5, 1769.4], - [1.95, 10000, 4538.8, 1045.8, 893.9, 1760.8], - ], - columns=["sv", "caf", "ca", "cb", "cc", "cd"], - ) - - # Create an experiment list - exp_list = [] - for i in range(data.shape[0]): - exp_list.append(ReactorDesignExperiment(data, i)) - - solver_options = {"max_iter": 6000} - - self.pest = parmest.Estimator( - exp_list, obj_function="SSE", solver_options=solver_options - ) - - def test_theta_est(self): - # used in data reconciliation - objval, thetavals = self.pest.theta_est() - - self.assertAlmostEqual(thetavals["k1"], 5.0 / 6.0, places=4) - self.assertAlmostEqual(thetavals["k2"], 5.0 / 3.0, places=4) - self.assertAlmostEqual(thetavals["k3"], 1.0 / 6000.0, places=7) - - def test_return_values(self): - objval, thetavals, data_rec = self.pest.theta_est( - return_values=["ca", "cb", "cc", "cd", "caf"] - ) - self.assertAlmostEqual(data_rec["cc"].loc[18], 893.84924, places=3) - - -@unittest.skipIf( - not parmest.parmest_available, - "Cannot test parmest: required dependencies are missing", -) -@unittest.skipIf(not ipopt_available, "The 'ipopt' solver is not available") -class TestReactorDesign_DAE(unittest.TestCase): - # Based on a reactor example in `Chemical Reactor Analysis and Design Fundamentals`, - # https://sites.engineering.ucsb.edu/~jbraw/chemreacfun/ - # https://sites.engineering.ucsb.edu/~jbraw/chemreacfun/fig-html/appendix/fig-A-10.html - - def setUp(self): - def ABC_model(data): - ca_meas = data["ca"] - cb_meas = data["cb"] - cc_meas = data["cc"] - - if isinstance(data, pd.DataFrame): - meas_t = data.index # time index - else: # dictionary - meas_t = list(ca_meas.keys()) # nested dictionary - - ca0 = 1.0 - cb0 = 0.0 - cc0 = 0.0 - - m = pyo.ConcreteModel() - - m.k1 = pyo.Var(initialize=0.5, bounds=(1e-4, 10)) - m.k2 = pyo.Var(initialize=3.0, bounds=(1e-4, 10)) - - m.time = dae.ContinuousSet(bounds=(0.0, 5.0), initialize=meas_t) - - # initialization and bounds - m.ca = pyo.Var(m.time, initialize=ca0, bounds=(-1e-3, ca0 + 1e-3)) - m.cb = pyo.Var(m.time, initialize=cb0, bounds=(-1e-3, ca0 + 1e-3)) - m.cc = pyo.Var(m.time, initialize=cc0, bounds=(-1e-3, ca0 + 1e-3)) - - m.dca = dae.DerivativeVar(m.ca, wrt=m.time) - m.dcb = dae.DerivativeVar(m.cb, wrt=m.time) - m.dcc = dae.DerivativeVar(m.cc, wrt=m.time) - - def _dcarate(m, t): - if t == 0: - return pyo.Constraint.Skip - else: - return m.dca[t] == -m.k1 * m.ca[t] - - m.dcarate = pyo.Constraint(m.time, rule=_dcarate) - - def _dcbrate(m, t): - if t == 0: - return pyo.Constraint.Skip - else: - return m.dcb[t] == m.k1 * m.ca[t] - m.k2 * m.cb[t] - - m.dcbrate = pyo.Constraint(m.time, rule=_dcbrate) - - def _dccrate(m, t): - if t == 0: - return pyo.Constraint.Skip - else: - return m.dcc[t] == m.k2 * m.cb[t] - - m.dccrate = pyo.Constraint(m.time, rule=_dccrate) - - def ComputeFirstStageCost_rule(m): - return 0 - - m.FirstStageCost = pyo.Expression(rule=ComputeFirstStageCost_rule) - - def ComputeSecondStageCost_rule(m): - return sum( - (m.ca[t] - ca_meas[t]) ** 2 - + (m.cb[t] - cb_meas[t]) ** 2 - + (m.cc[t] - cc_meas[t]) ** 2 - for t in meas_t - ) - - m.SecondStageCost = pyo.Expression(rule=ComputeSecondStageCost_rule) - - def total_cost_rule(model): - return model.FirstStageCost + model.SecondStageCost - - m.Total_Cost_Objective = pyo.Objective( - rule=total_cost_rule, sense=pyo.minimize - ) - - disc = pyo.TransformationFactory("dae.collocation") - disc.apply_to(m, nfe=20, ncp=2) - - return m - - class ReactorDesignExperimentDAE(Experiment): - - def __init__(self, data): - - self.data = data - self.model = None - - def create_model(self): - self.model = ABC_model(self.data) - - def label_model(self): - - m = self.model - - m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) - m.unknown_parameters.update( - (k, pyo.ComponentUID(k)) for k in [m.k1, m.k2] - ) - - def get_labeled_model(self): - self.create_model() - self.label_model() - - return self.model - - # This example tests data formatted in 3 ways - # Each format holds 1 scenario - # 1. dataframe with time index - # 2. nested dictionary {ca: {t, val pairs}, ... } - data = [ - [0.000, 0.957, -0.031, -0.015], - [0.263, 0.557, 0.330, 0.044], - [0.526, 0.342, 0.512, 0.156], - [0.789, 0.224, 0.499, 0.310], - [1.053, 0.123, 0.428, 0.454], - [1.316, 0.079, 0.396, 0.556], - [1.579, 0.035, 0.303, 0.651], - [1.842, 0.029, 0.287, 0.658], - [2.105, 0.025, 0.221, 0.750], - [2.368, 0.017, 0.148, 0.854], - [2.632, -0.002, 0.182, 0.845], - [2.895, 0.009, 0.116, 0.893], - [3.158, -0.023, 0.079, 0.942], - [3.421, 0.006, 0.078, 0.899], - [3.684, 0.016, 0.059, 0.942], - [3.947, 0.014, 0.036, 0.991], - [4.211, -0.009, 0.014, 0.988], - [4.474, -0.030, 0.036, 0.941], - [4.737, 0.004, 0.036, 0.971], - [5.000, -0.024, 0.028, 0.985], - ] - data = pd.DataFrame(data, columns=["t", "ca", "cb", "cc"]) - data_df = data.set_index("t") - data_dict = { - "ca": {k: v for (k, v) in zip(data.t, data.ca)}, - "cb": {k: v for (k, v) in zip(data.t, data.cb)}, - "cc": {k: v for (k, v) in zip(data.t, data.cc)}, - } - - # Create an experiment list - exp_list_df = [ReactorDesignExperimentDAE(data_df)] - exp_list_dict = [ReactorDesignExperimentDAE(data_dict)] - - self.pest_df = parmest.Estimator(exp_list_df) - self.pest_dict = parmest.Estimator(exp_list_dict) - - # Estimator object with multiple scenarios - exp_list_df_multiple = [ - ReactorDesignExperimentDAE(data_df), - ReactorDesignExperimentDAE(data_df), - ] - exp_list_dict_multiple = [ - ReactorDesignExperimentDAE(data_dict), - ReactorDesignExperimentDAE(data_dict), - ] - - self.pest_df_multiple = parmest.Estimator(exp_list_df_multiple) - self.pest_dict_multiple = parmest.Estimator(exp_list_dict_multiple) - - # Create an instance of the model - self.m_df = ABC_model(data_df) - self.m_dict = ABC_model(data_dict) - - def test_dataformats(self): - obj1, theta1 = self.pest_df.theta_est() - obj2, theta2 = self.pest_dict.theta_est() - - self.assertAlmostEqual(obj1, obj2, places=6) - self.assertAlmostEqual(theta1["k1"], theta2["k1"], places=6) - self.assertAlmostEqual(theta1["k2"], theta2["k2"], places=6) - - def test_return_continuous_set(self): - """ - test if ContinuousSet elements are returned correctly from theta_est() - """ - obj1, theta1, return_vals1 = self.pest_df.theta_est(return_values=["time"]) - obj2, theta2, return_vals2 = self.pest_dict.theta_est(return_values=["time"]) - self.assertAlmostEqual(return_vals1["time"].loc[0][18], 2.368, places=3) - self.assertAlmostEqual(return_vals2["time"].loc[0][18], 2.368, places=3) - - def test_return_continuous_set_multiple_datasets(self): - """ - test if ContinuousSet elements are returned correctly from theta_est() - """ - obj1, theta1, return_vals1 = self.pest_df_multiple.theta_est( - return_values=["time"] - ) - obj2, theta2, return_vals2 = self.pest_dict_multiple.theta_est( - return_values=["time"] - ) - self.assertAlmostEqual(return_vals1["time"].loc[1][18], 2.368, places=3) - self.assertAlmostEqual(return_vals2["time"].loc[1][18], 2.368, places=3) - - @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') - def test_covariance(self): - from pyomo.contrib.interior_point.inverse_reduced_hessian import ( - inv_reduced_hessian_barrier, - ) - - # Number of datapoints. - # 3 data components (ca, cb, cc), 20 timesteps, 1 scenario = 60 - # In this example, this is the number of data points in data_df, but that's - # only because the data is indexed by time and contains no additional information. - n = 60 - - # Compute covariance using parmest - obj, theta, cov = self.pest_df.theta_est(calc_cov=True, cov_n=n) - - # Compute covariance using interior_point - vars_list = [self.m_df.k1, self.m_df.k2] - solve_result, inv_red_hes = inv_reduced_hessian_barrier( - self.m_df, independent_variables=vars_list, tee=True - ) - l = len(vars_list) - cov_interior_point = 2 * obj / (n - l) * inv_red_hes - cov_interior_point = pd.DataFrame( - cov_interior_point, ["k1", "k2"], ["k1", "k2"] - ) - - cov_diff = (cov - cov_interior_point).abs().sum().sum() - - self.assertTrue(cov.loc["k1", "k1"] > 0) - self.assertTrue(cov.loc["k2", "k2"] > 0) - self.assertAlmostEqual(cov_diff, 0, places=6) - - -@unittest.skipIf( - not parmest.parmest_available, - "Cannot test parmest: required dependencies are missing", -) -@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") -class TestSquareInitialization_RooneyBiegler(unittest.TestCase): - def setUp(self): - from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler_with_constraint import ( - RooneyBieglerExperiment, - ) - - # Note, the data used in this test has been corrected to use data.loc[5,'hour'] = 7 (instead of 6) - data = pd.DataFrame( - data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], - columns=["hour", "y"], - ) - - # Sum of squared error function - def SSE(model): - expr = ( - model.experiment_outputs[model.y] - - model.response_function[model.experiment_outputs[model.hour]] - ) ** 2 - return expr - - exp_list = [] - for i in range(data.shape[0]): - exp_list.append(RooneyBieglerExperiment(data.loc[i, :])) - - solver_options = {"tol": 1e-8} - - self.data = data - self.pest = parmest.Estimator( - exp_list, obj_function=SSE, solver_options=solver_options, tee=True - ) - - def test_theta_est_with_square_initialization(self): - obj_init = self.pest.objective_at_theta(initialize_parmest_model=True) - objval, thetavals = self.pest.theta_est() - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper - - def test_theta_est_with_square_initialization_and_custom_init_theta(self): - theta_vals_init = pd.DataFrame( - data=[[19.0, 0.5]], columns=["asymptote", "rate_constant"] - ) - obj_init = self.pest.objective_at_theta( - theta_values=theta_vals_init, initialize_parmest_model=True - ) - objval, thetavals = self.pest.theta_est() - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper - - def test_theta_est_with_square_initialization_diagnostic_mode_true(self): - self.pest.diagnostic_mode = True - obj_init = self.pest.objective_at_theta(initialize_parmest_model=True) - objval, thetavals = self.pest.theta_est() - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper - - self.pest.diagnostic_mode = False - - -########################### -# tests for deprecated UI # -########################### - - -@unittest.skipIf( - not parmest.parmest_available, - "Cannot test parmest: required dependencies are missing", -) -@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") -class TestRooneyBieglerDeprecated(unittest.TestCase): - def setUp(self): - - def rooney_biegler_model(data): - model = pyo.ConcreteModel() - - model.asymptote = pyo.Var(initialize=15) - model.rate_constant = pyo.Var(initialize=0.5) - - def response_rule(m, h): - expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) - return expr - - model.response_function = pyo.Expression(data.hour, rule=response_rule) - - def SSE_rule(m): - return sum( - (data.y[i] - m.response_function[data.hour[i]]) ** 2 - for i in data.index - ) - - model.SSE = pyo.Objective(rule=SSE_rule, sense=pyo.minimize) - - return model - - # Note, the data used in this test has been corrected to use data.loc[5,'hour'] = 7 (instead of 6) - data = pd.DataFrame( - data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], - columns=["hour", "y"], - ) - - theta_names = ["asymptote", "rate_constant"] - - def SSE(model, data): - expr = sum( - (data.y[i] - model.response_function[data.hour[i]]) ** 2 - for i in data.index - ) - return expr - - solver_options = {"tol": 1e-8} - - self.data = data - self.pest = parmest.Estimator( - rooney_biegler_model, - data, - theta_names, - SSE, - solver_options=solver_options, - tee=True, - ) - - def test_theta_est(self): - objval, thetavals = self.pest.theta_est() - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper - - @unittest.skipIf( - not graphics.imports_available, "parmest.graphics imports are unavailable" - ) - def test_bootstrap(self): - objval, thetavals = self.pest.theta_est() - - num_bootstraps = 10 - theta_est = self.pest.theta_est_bootstrap(num_bootstraps, return_samples=True) - - num_samples = theta_est["samples"].apply(len) - self.assertTrue(len(theta_est.index), 10) - self.assertTrue(num_samples.equals(pd.Series([6] * 10))) - - del theta_est["samples"] - - # apply confidence region test - CR = self.pest.confidence_region_test(theta_est, "MVN", [0.5, 0.75, 1.0]) - - self.assertTrue(set(CR.columns) >= set([0.5, 0.75, 1.0])) - self.assertTrue(CR[0.5].sum() == 5) - self.assertTrue(CR[0.75].sum() == 7) - self.assertTrue(CR[1.0].sum() == 10) # all true - - graphics.pairwise_plot(theta_est) - graphics.pairwise_plot(theta_est, thetavals) - graphics.pairwise_plot(theta_est, thetavals, 0.8, ["MVN", "KDE", "Rect"]) - - @unittest.skipIf( - not graphics.imports_available, "parmest.graphics imports are unavailable" - ) - def test_likelihood_ratio(self): - objval, thetavals = self.pest.theta_est() - - asym = np.arange(10, 30, 2) - rate = np.arange(0, 1.5, 0.25) - theta_vals = pd.DataFrame( - list(product(asym, rate)), columns=self.pest._return_theta_names() - ) - - obj_at_theta = self.pest.objective_at_theta(theta_vals) - - LR = self.pest.likelihood_ratio_test(obj_at_theta, objval, [0.8, 0.9, 1.0]) - - self.assertTrue(set(LR.columns) >= set([0.8, 0.9, 1.0])) - self.assertTrue(LR[0.8].sum() == 6) - self.assertTrue(LR[0.9].sum() == 10) - self.assertTrue(LR[1.0].sum() == 60) # all true - - graphics.pairwise_plot(LR, thetavals, 0.8) - - def test_leaveNout(self): - lNo_theta = self.pest.theta_est_leaveNout(1) - self.assertTrue(lNo_theta.shape == (6, 2)) - - results = self.pest.leaveNout_bootstrap_test( - 1, None, 3, "Rect", [0.5, 1.0], seed=5436 - ) - self.assertTrue(len(results) == 6) # 6 lNo samples - i = 1 - samples = results[i][0] # list of N samples that are left out - lno_theta = results[i][1] - bootstrap_theta = results[i][2] - self.assertTrue(samples == [1]) # sample 1 was left out - self.assertTrue(lno_theta.shape[0] == 1) # lno estimate for sample 1 - self.assertTrue(set(lno_theta.columns) >= set([0.5, 1.0])) - self.assertTrue(lno_theta[1.0].sum() == 1) # all true - self.assertTrue(bootstrap_theta.shape[0] == 3) # bootstrap for sample 1 - self.assertTrue(bootstrap_theta[1.0].sum() == 3) # all true - - def test_diagnostic_mode(self): - self.pest.diagnostic_mode = True - - objval, thetavals = self.pest.theta_est() - - asym = np.arange(10, 30, 2) - rate = np.arange(0, 1.5, 0.25) - theta_vals = pd.DataFrame( - list(product(asym, rate)), columns=self.pest._return_theta_names() - ) - - obj_at_theta = self.pest.objective_at_theta(theta_vals) - - self.pest.diagnostic_mode = False - - @unittest.skip("Presently having trouble with mpiexec on appveyor") - def test_parallel_parmest(self): - """use mpiexec and mpi4py""" - p = str(parmestbase.__path__) - l = p.find("'") - r = p.find("'", l + 1) - parmestpath = p[l + 1 : r] - rbpath = ( - parmestpath - + os.sep - + "examples" - + os.sep - + "rooney_biegler" - + os.sep - + "rooney_biegler_parmest.py" - ) - rbpath = os.path.abspath(rbpath) # paranoia strikes deep... - rlist = ["mpiexec", "--allow-run-as-root", "-n", "2", sys.executable, rbpath] - if sys.version_info >= (3, 5): - ret = subprocess.run(rlist) - retcode = ret.returncode - else: - retcode = subprocess.call(rlist) - assert retcode == 0 - - @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") - def test_theta_est_cov(self): - objval, thetavals, cov = self.pest.theta_est(calc_cov=True, cov_n=6) - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper - - # Covariance matrix - self.assertAlmostEqual( - cov.iloc[0, 0], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[0, 1], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 0], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual(cov.iloc[1, 1], 0.04124, places=2) # 0.04124 from paper - - """ Why does the covariance matrix from parmest not match the paper? Parmest is - calculating the exact reduced Hessian. The paper (Rooney and Bielger, 2001) likely - employed the first order approximation common for nonlinear regression. The paper - values were verified with Scipy, which uses the same first order approximation. - The formula used in parmest was verified against equations (7-5-15) and (7-5-16) in - "Nonlinear Parameter Estimation", Y. Bard, 1974. - """ - - def test_cov_scipy_least_squares_comparison(self): - """ - Scipy results differ in the 3rd decimal place from the paper. It is possible - the paper used an alternative finite difference approximation for the Jacobian. - """ - - def model(theta, t): - """ - Model to be fitted y = model(theta, t) - Arguments: - theta: vector of fitted parameters - t: independent variable [hours] - - Returns: - y: model predictions [need to check paper for units] - """ - asymptote = theta[0] - rate_constant = theta[1] - - return asymptote * (1 - np.exp(-rate_constant * t)) - - def residual(theta, t, y): - """ - Calculate residuals - Arguments: - theta: vector of fitted parameters - t: independent variable [hours] - y: dependent variable [?] - """ - return y - model(theta, t) - - # define data - t = self.data["hour"].to_numpy() - y = self.data["y"].to_numpy() - - # define initial guess - theta_guess = np.array([15, 0.5]) - - ## solve with optimize.least_squares - sol = scipy.optimize.least_squares( - residual, theta_guess, method="trf", args=(t, y), verbose=2 - ) - theta_hat = sol.x - - self.assertAlmostEqual( - theta_hat[0], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper - - # calculate residuals - r = residual(theta_hat, t, y) - - # calculate variance of the residuals - # -2 because there are 2 fitted parameters - sigre = np.matmul(r.T, r / (len(y) - 2)) - - # approximate covariance - # Need to divide by 2 because optimize.least_squares scaled the objective by 1/2 - cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) - - self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper - self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper - - def test_cov_scipy_curve_fit_comparison(self): - """ - Scipy results differ in the 3rd decimal place from the paper. It is possible - the paper used an alternative finite difference approximation for the Jacobian. - """ - - ## solve with optimize.curve_fit - def model(t, asymptote, rate_constant): - return asymptote * (1 - np.exp(-rate_constant * t)) - - # define data - t = self.data["hour"].to_numpy() - y = self.data["y"].to_numpy() - - # define initial guess - theta_guess = np.array([15, 0.5]) - - theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess) - - self.assertAlmostEqual( - theta_hat[0], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper - - self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper - self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper - - -@unittest.skipIf( - not parmest.parmest_available, - "Cannot test parmest: required dependencies are missing", -) -@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") -class TestModelVariantsDeprecated(unittest.TestCase): - def setUp(self): - self.data = pd.DataFrame( - data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], - columns=["hour", "y"], - ) - - def rooney_biegler_params(data): - model = pyo.ConcreteModel() - - model.asymptote = pyo.Param(initialize=15, mutable=True) - model.rate_constant = pyo.Param(initialize=0.5, mutable=True) - - def response_rule(m, h): - expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) - return expr - - model.response_function = pyo.Expression(data.hour, rule=response_rule) - - return model - - def rooney_biegler_indexed_params(data): - model = pyo.ConcreteModel() - - model.param_names = pyo.Set(initialize=["asymptote", "rate_constant"]) - model.theta = pyo.Param( - model.param_names, - initialize={"asymptote": 15, "rate_constant": 0.5}, - mutable=True, - ) - - def response_rule(m, h): - expr = m.theta["asymptote"] * ( - 1 - pyo.exp(-m.theta["rate_constant"] * h) - ) - return expr - - model.response_function = pyo.Expression(data.hour, rule=response_rule) - - return model - - def rooney_biegler_vars(data): - model = pyo.ConcreteModel() - - model.asymptote = pyo.Var(initialize=15) - model.rate_constant = pyo.Var(initialize=0.5) - model.asymptote.fixed = True # parmest will unfix theta variables - model.rate_constant.fixed = True - - def response_rule(m, h): - expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) - return expr - - model.response_function = pyo.Expression(data.hour, rule=response_rule) - - return model - - def rooney_biegler_indexed_vars(data): - model = pyo.ConcreteModel() - - model.var_names = pyo.Set(initialize=["asymptote", "rate_constant"]) - model.theta = pyo.Var( - model.var_names, initialize={"asymptote": 15, "rate_constant": 0.5} - ) - model.theta["asymptote"].fixed = ( - True # parmest will unfix theta variables, even when they are indexed - ) - model.theta["rate_constant"].fixed = True - - def response_rule(m, h): - expr = m.theta["asymptote"] * ( - 1 - pyo.exp(-m.theta["rate_constant"] * h) - ) - return expr - - model.response_function = pyo.Expression(data.hour, rule=response_rule) - - return model - - def SSE(model, data): - expr = sum( - (data.y[i] - model.response_function[data.hour[i]]) ** 2 - for i in data.index - ) - return expr - - self.objective_function = SSE - - theta_vals = pd.DataFrame([20, 1], index=["asymptote", "rate_constant"]).T - theta_vals_index = pd.DataFrame( - [20, 1], index=["theta['asymptote']", "theta['rate_constant']"] - ).T - - self.input = { - "param": { - "model": rooney_biegler_params, - "theta_names": ["asymptote", "rate_constant"], - "theta_vals": theta_vals, - }, - "param_index": { - "model": rooney_biegler_indexed_params, - "theta_names": ["theta"], - "theta_vals": theta_vals_index, - }, - "vars": { - "model": rooney_biegler_vars, - "theta_names": ["asymptote", "rate_constant"], - "theta_vals": theta_vals, - }, - "vars_index": { - "model": rooney_biegler_indexed_vars, - "theta_names": ["theta"], - "theta_vals": theta_vals_index, - }, - "vars_quoted_index": { - "model": rooney_biegler_indexed_vars, - "theta_names": ["theta['asymptote']", "theta['rate_constant']"], - "theta_vals": theta_vals_index, - }, - "vars_str_index": { - "model": rooney_biegler_indexed_vars, - "theta_names": ["theta[asymptote]", "theta[rate_constant]"], - "theta_vals": theta_vals_index, - }, - } - - @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") - def test_parmest_basics(self): - for model_type, parmest_input in self.input.items(): - pest = parmest.Estimator( - parmest_input["model"], - self.data, - parmest_input["theta_names"], - self.objective_function, - ) - - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - cov.iloc[0, 0], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[0, 1], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 0], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 1], 0.04193591, places=2 - ) # 0.04124 from paper - - obj_at_theta = pest.objective_at_theta(parmest_input["theta_vals"]) - self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) - - @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') - def test_parmest_basics_with_initialize_parmest_model_option(self): - for model_type, parmest_input in self.input.items(): - pest = parmest.Estimator( - parmest_input["model"], - self.data, - parmest_input["theta_names"], - self.objective_function, - ) - - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - cov.iloc[0, 0], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[0, 1], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 0], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 1], 0.04193591, places=2 - ) # 0.04124 from paper - - obj_at_theta = pest.objective_at_theta( - parmest_input["theta_vals"], initialize_parmest_model=True - ) - - self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) - - @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') - def test_parmest_basics_with_square_problem_solve(self): - for model_type, parmest_input in self.input.items(): - pest = parmest.Estimator( - parmest_input["model"], - self.data, - parmest_input["theta_names"], - self.objective_function, - ) - - obj_at_theta = pest.objective_at_theta( - parmest_input["theta_vals"], initialize_parmest_model=True - ) - - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - cov.iloc[0, 0], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[0, 1], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 0], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 1], 0.04193591, places=2 - ) # 0.04124 from paper - - self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) - - @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') - def test_parmest_basics_with_square_problem_solve_no_theta_vals(self): - for model_type, parmest_input in self.input.items(): - pest = parmest.Estimator( - parmest_input["model"], - self.data, - parmest_input["theta_names"], - self.objective_function, - ) - - obj_at_theta = pest.objective_at_theta(initialize_parmest_model=True) - - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - cov.iloc[0, 0], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[0, 1], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 0], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 1], 0.04193591, places=2 - ) # 0.04124 from paper - - -@unittest.skipIf( - not parmest.parmest_available, - "Cannot test parmest: required dependencies are missing", -) -@unittest.skipIf(not ipopt_available, "The 'ipopt' solver is not available") -class TestReactorDesignDeprecated(unittest.TestCase): - def setUp(self): - - def reactor_design_model(data): - # Create the concrete model - model = pyo.ConcreteModel() - - # Rate constants - model.k1 = pyo.Param( - initialize=5.0 / 6.0, within=pyo.PositiveReals, mutable=True - ) # min^-1 - model.k2 = pyo.Param( - initialize=5.0 / 3.0, within=pyo.PositiveReals, mutable=True - ) # min^-1 - model.k3 = pyo.Param( - initialize=1.0 / 6000.0, within=pyo.PositiveReals, mutable=True - ) # m^3/(gmol min) - - # Inlet concentration of A, gmol/m^3 - if isinstance(data, dict) or isinstance(data, pd.Series): - model.caf = pyo.Param( - initialize=float(data["caf"]), within=pyo.PositiveReals - ) - elif isinstance(data, pd.DataFrame): - model.caf = pyo.Param( - initialize=float(data.iloc[0]["caf"]), within=pyo.PositiveReals - ) - else: - raise ValueError("Unrecognized data type.") - - # Space velocity (flowrate/volume) - if isinstance(data, dict) or isinstance(data, pd.Series): - model.sv = pyo.Param( - initialize=float(data["sv"]), within=pyo.PositiveReals - ) - elif isinstance(data, pd.DataFrame): - model.sv = pyo.Param( - initialize=float(data.iloc[0]["sv"]), within=pyo.PositiveReals - ) - else: - raise ValueError("Unrecognized data type.") - - # Outlet concentration of each component - model.ca = pyo.Var(initialize=5000.0, within=pyo.PositiveReals) - model.cb = pyo.Var(initialize=2000.0, within=pyo.PositiveReals) - model.cc = pyo.Var(initialize=2000.0, within=pyo.PositiveReals) - model.cd = pyo.Var(initialize=1000.0, within=pyo.PositiveReals) - - # Objective - model.obj = pyo.Objective(expr=model.cb, sense=pyo.maximize) - - # Constraints - model.ca_bal = pyo.Constraint( - expr=( - 0 - == model.sv * model.caf - - model.sv * model.ca - - model.k1 * model.ca - - 2.0 * model.k3 * model.ca**2.0 - ) - ) - - model.cb_bal = pyo.Constraint( - expr=( - 0 - == -model.sv * model.cb + model.k1 * model.ca - model.k2 * model.cb - ) - ) - - model.cc_bal = pyo.Constraint( - expr=(0 == -model.sv * model.cc + model.k2 * model.cb) - ) - - model.cd_bal = pyo.Constraint( - expr=(0 == -model.sv * model.cd + model.k3 * model.ca**2.0) - ) - - return model - - # Data from the design - data = pd.DataFrame( - data=[ - [1.05, 10000, 3458.4, 1060.8, 1683.9, 1898.5], - [1.10, 10000, 3535.1, 1064.8, 1613.3, 1893.4], - [1.15, 10000, 3609.1, 1067.8, 1547.5, 1887.8], - [1.20, 10000, 3680.7, 1070.0, 1486.1, 1881.6], - [1.25, 10000, 3750.0, 1071.4, 1428.6, 1875.0], - [1.30, 10000, 3817.1, 1072.2, 1374.6, 1868.0], - [1.35, 10000, 3882.2, 1072.4, 1324.0, 1860.7], - [1.40, 10000, 3945.4, 1072.1, 1276.3, 1853.1], - [1.45, 10000, 4006.7, 1071.3, 1231.4, 1845.3], - [1.50, 10000, 4066.4, 1070.1, 1189.0, 1837.3], - [1.55, 10000, 4124.4, 1068.5, 1148.9, 1829.1], - [1.60, 10000, 4180.9, 1066.5, 1111.0, 1820.8], - [1.65, 10000, 4235.9, 1064.3, 1075.0, 1812.4], - [1.70, 10000, 4289.5, 1061.8, 1040.9, 1803.9], - [1.75, 10000, 4341.8, 1059.0, 1008.5, 1795.3], - [1.80, 10000, 4392.8, 1056.0, 977.7, 1786.7], - [1.85, 10000, 4442.6, 1052.8, 948.4, 1778.1], - [1.90, 10000, 4491.3, 1049.4, 920.5, 1769.4], - [1.95, 10000, 4538.8, 1045.8, 893.9, 1760.8], - ], - columns=["sv", "caf", "ca", "cb", "cc", "cd"], - ) - - theta_names = ["k1", "k2", "k3"] - - def SSE(model, data): - expr = ( - (float(data.iloc[0]["ca"]) - model.ca) ** 2 - + (float(data.iloc[0]["cb"]) - model.cb) ** 2 - + (float(data.iloc[0]["cc"]) - model.cc) ** 2 - + (float(data.iloc[0]["cd"]) - model.cd) ** 2 - ) - return expr - - solver_options = {"max_iter": 6000} - - self.pest = parmest.Estimator( - reactor_design_model, data, theta_names, SSE, solver_options=solver_options - ) - - def test_theta_est(self): - # used in data reconciliation - objval, thetavals = self.pest.theta_est() - - self.assertAlmostEqual(thetavals["k1"], 5.0 / 6.0, places=4) - self.assertAlmostEqual(thetavals["k2"], 5.0 / 3.0, places=4) - self.assertAlmostEqual(thetavals["k3"], 1.0 / 6000.0, places=7) - - def test_return_values(self): - objval, thetavals, data_rec = self.pest.theta_est( - return_values=["ca", "cb", "cc", "cd", "caf"] - ) - self.assertAlmostEqual(data_rec["cc"].loc[18], 893.84924, places=3) - - -@unittest.skipIf( - not parmest.parmest_available, - "Cannot test parmest: required dependencies are missing", -) -@unittest.skipIf(not ipopt_available, "The 'ipopt' solver is not available") -class TestReactorDesign_DAE_Deprecated(unittest.TestCase): - # Based on a reactor example in `Chemical Reactor Analysis and Design Fundamentals`, - # https://sites.engineering.ucsb.edu/~jbraw/chemreacfun/ - # https://sites.engineering.ucsb.edu/~jbraw/chemreacfun/fig-html/appendix/fig-A-10.html - - def setUp(self): - def ABC_model(data): - ca_meas = data["ca"] - cb_meas = data["cb"] - cc_meas = data["cc"] - - if isinstance(data, pd.DataFrame): - meas_t = data.index # time index - else: # dictionary - meas_t = list(ca_meas.keys()) # nested dictionary - - ca0 = 1.0 - cb0 = 0.0 - cc0 = 0.0 - - m = pyo.ConcreteModel() - - m.k1 = pyo.Var(initialize=0.5, bounds=(1e-4, 10)) - m.k2 = pyo.Var(initialize=3.0, bounds=(1e-4, 10)) - - m.time = dae.ContinuousSet(bounds=(0.0, 5.0), initialize=meas_t) - - # initialization and bounds - m.ca = pyo.Var(m.time, initialize=ca0, bounds=(-1e-3, ca0 + 1e-3)) - m.cb = pyo.Var(m.time, initialize=cb0, bounds=(-1e-3, ca0 + 1e-3)) - m.cc = pyo.Var(m.time, initialize=cc0, bounds=(-1e-3, ca0 + 1e-3)) - - m.dca = dae.DerivativeVar(m.ca, wrt=m.time) - m.dcb = dae.DerivativeVar(m.cb, wrt=m.time) - m.dcc = dae.DerivativeVar(m.cc, wrt=m.time) - - def _dcarate(m, t): - if t == 0: - return pyo.Constraint.Skip - else: - return m.dca[t] == -m.k1 * m.ca[t] - - m.dcarate = pyo.Constraint(m.time, rule=_dcarate) - - def _dcbrate(m, t): - if t == 0: - return pyo.Constraint.Skip - else: - return m.dcb[t] == m.k1 * m.ca[t] - m.k2 * m.cb[t] - - m.dcbrate = pyo.Constraint(m.time, rule=_dcbrate) - - def _dccrate(m, t): - if t == 0: - return pyo.Constraint.Skip - else: - return m.dcc[t] == m.k2 * m.cb[t] - - m.dccrate = pyo.Constraint(m.time, rule=_dccrate) - - def ComputeFirstStageCost_rule(m): - return 0 - - m.FirstStageCost = pyo.Expression(rule=ComputeFirstStageCost_rule) - - def ComputeSecondStageCost_rule(m): - return sum( - (m.ca[t] - ca_meas[t]) ** 2 - + (m.cb[t] - cb_meas[t]) ** 2 - + (m.cc[t] - cc_meas[t]) ** 2 - for t in meas_t - ) - - m.SecondStageCost = pyo.Expression(rule=ComputeSecondStageCost_rule) - - def total_cost_rule(model): - return model.FirstStageCost + model.SecondStageCost - - m.Total_Cost_Objective = pyo.Objective( - rule=total_cost_rule, sense=pyo.minimize - ) - - disc = pyo.TransformationFactory("dae.collocation") - disc.apply_to(m, nfe=20, ncp=2) - - return m - - # This example tests data formatted in 3 ways - # Each format holds 1 scenario - # 1. dataframe with time index - # 2. nested dictionary {ca: {t, val pairs}, ... } - data = [ - [0.000, 0.957, -0.031, -0.015], - [0.263, 0.557, 0.330, 0.044], - [0.526, 0.342, 0.512, 0.156], - [0.789, 0.224, 0.499, 0.310], - [1.053, 0.123, 0.428, 0.454], - [1.316, 0.079, 0.396, 0.556], - [1.579, 0.035, 0.303, 0.651], - [1.842, 0.029, 0.287, 0.658], - [2.105, 0.025, 0.221, 0.750], - [2.368, 0.017, 0.148, 0.854], - [2.632, -0.002, 0.182, 0.845], - [2.895, 0.009, 0.116, 0.893], - [3.158, -0.023, 0.079, 0.942], - [3.421, 0.006, 0.078, 0.899], - [3.684, 0.016, 0.059, 0.942], - [3.947, 0.014, 0.036, 0.991], - [4.211, -0.009, 0.014, 0.988], - [4.474, -0.030, 0.036, 0.941], - [4.737, 0.004, 0.036, 0.971], - [5.000, -0.024, 0.028, 0.985], - ] - data = pd.DataFrame(data, columns=["t", "ca", "cb", "cc"]) - data_df = data.set_index("t") - data_dict = { - "ca": {k: v for (k, v) in zip(data.t, data.ca)}, - "cb": {k: v for (k, v) in zip(data.t, data.cb)}, - "cc": {k: v for (k, v) in zip(data.t, data.cc)}, - } - - theta_names = ["k1", "k2"] - - self.pest_df = parmest.Estimator(ABC_model, [data_df], theta_names) - self.pest_dict = parmest.Estimator(ABC_model, [data_dict], theta_names) - - # Estimator object with multiple scenarios - self.pest_df_multiple = parmest.Estimator( - ABC_model, [data_df, data_df], theta_names - ) - self.pest_dict_multiple = parmest.Estimator( - ABC_model, [data_dict, data_dict], theta_names - ) - - # Create an instance of the model - self.m_df = ABC_model(data_df) - self.m_dict = ABC_model(data_dict) - - def test_dataformats(self): - obj1, theta1 = self.pest_df.theta_est() - obj2, theta2 = self.pest_dict.theta_est() - - self.assertAlmostEqual(obj1, obj2, places=6) - self.assertAlmostEqual(theta1["k1"], theta2["k1"], places=6) - self.assertAlmostEqual(theta1["k2"], theta2["k2"], places=6) - - def test_return_continuous_set(self): - """ - test if ContinuousSet elements are returned correctly from theta_est() - """ - obj1, theta1, return_vals1 = self.pest_df.theta_est(return_values=["time"]) - obj2, theta2, return_vals2 = self.pest_dict.theta_est(return_values=["time"]) - self.assertAlmostEqual(return_vals1["time"].loc[0][18], 2.368, places=3) - self.assertAlmostEqual(return_vals2["time"].loc[0][18], 2.368, places=3) - - def test_return_continuous_set_multiple_datasets(self): - """ - test if ContinuousSet elements are returned correctly from theta_est() - """ - obj1, theta1, return_vals1 = self.pest_df_multiple.theta_est( - return_values=["time"] - ) - obj2, theta2, return_vals2 = self.pest_dict_multiple.theta_est( - return_values=["time"] - ) - self.assertAlmostEqual(return_vals1["time"].loc[1][18], 2.368, places=3) - self.assertAlmostEqual(return_vals2["time"].loc[1][18], 2.368, places=3) - - @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') - def test_covariance(self): - from pyomo.contrib.interior_point.inverse_reduced_hessian import ( - inv_reduced_hessian_barrier, - ) - - # Number of datapoints. - # 3 data components (ca, cb, cc), 20 timesteps, 1 scenario = 60 - # In this example, this is the number of data points in data_df, but that's - # only because the data is indexed by time and contains no additional information. - n = 60 - - # Compute covariance using parmest - obj, theta, cov = self.pest_df.theta_est(calc_cov=True, cov_n=n) - - # Compute covariance using interior_point - vars_list = [self.m_df.k1, self.m_df.k2] - solve_result, inv_red_hes = inv_reduced_hessian_barrier( - self.m_df, independent_variables=vars_list, tee=True - ) - l = len(vars_list) - cov_interior_point = 2 * obj / (n - l) * inv_red_hes - cov_interior_point = pd.DataFrame( - cov_interior_point, ["k1", "k2"], ["k1", "k2"] - ) - - cov_diff = (cov - cov_interior_point).abs().sum().sum() - - self.assertTrue(cov.loc["k1", "k1"] > 0) - self.assertTrue(cov.loc["k2", "k2"] > 0) - self.assertAlmostEqual(cov_diff, 0, places=6) - - -@unittest.skipIf( - not parmest.parmest_available, - "Cannot test parmest: required dependencies are missing", -) -@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") -class TestSquareInitialization_RooneyBiegler_Deprecated(unittest.TestCase): - def setUp(self): - - def rooney_biegler_model_with_constraint(data): - model = pyo.ConcreteModel() - - model.asymptote = pyo.Var(initialize=15) - model.rate_constant = pyo.Var(initialize=0.5) - model.response_function = pyo.Var(data.hour, initialize=0.0) - - # changed from expression to constraint - def response_rule(m, h): - return m.response_function[h] == m.asymptote * ( - 1 - pyo.exp(-m.rate_constant * h) - ) - - model.response_function_constraint = pyo.Constraint( - data.hour, rule=response_rule - ) - - def SSE_rule(m): - return sum( - (data.y[i] - m.response_function[data.hour[i]]) ** 2 - for i in data.index - ) - - model.SSE = pyo.Objective(rule=SSE_rule, sense=pyo.minimize) - - return model - - # Note, the data used in this test has been corrected to use data.loc[5,'hour'] = 7 (instead of 6) - data = pd.DataFrame( - data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], - columns=["hour", "y"], - ) - - theta_names = ["asymptote", "rate_constant"] - - def SSE(model, data): - expr = sum( - (data.y[i] - model.response_function[data.hour[i]]) ** 2 - for i in data.index - ) - return expr - - solver_options = {"tol": 1e-8} - - self.data = data - self.pest = parmest.Estimator( - rooney_biegler_model_with_constraint, - data, - theta_names, - SSE, - solver_options=solver_options, - tee=True, - ) - - def test_theta_est_with_square_initialization(self): - obj_init = self.pest.objective_at_theta(initialize_parmest_model=True) - objval, thetavals = self.pest.theta_est() - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper - - def test_theta_est_with_square_initialization_and_custom_init_theta(self): - theta_vals_init = pd.DataFrame( - data=[[19.0, 0.5]], columns=["asymptote", "rate_constant"] - ) - obj_init = self.pest.objective_at_theta( - theta_values=theta_vals_init, initialize_parmest_model=True - ) - objval, thetavals = self.pest.theta_est() - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper - - def test_theta_est_with_square_initialization_diagnostic_mode_true(self): - self.pest.diagnostic_mode = True - obj_init = self.pest.objective_at_theta(initialize_parmest_model=True) - objval, thetavals = self.pest.theta_est() - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper - - self.pest.diagnostic_mode = False - - -if __name__ == "__main__": - unittest.main() From f106b5cdb853fbeda1b88ac7c6df2762fb418d81 Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Tue, 20 May 2025 13:01:36 -0400 Subject: [PATCH 15/35] Ran black on test_new_parmest_capabilities.py --- .../tests/test_new_parmest_capabilities.py | 37 +++++++------------ 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py b/pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py index 56a5776547d..0ba5453c750 100644 --- a/pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py +++ b/pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py @@ -57,11 +57,11 @@ def setUp(self): # create the Rooney-Biegler model def rooney_biegler_model(): """ - Formulates the Pyomo model of the Rooney-Biegler example + Formulates the Pyomo model of the Rooney-Biegler example - Returns: - m: Pyomo model - """ + Returns: + m: Pyomo model + """ m = pyo.ConcreteModel() m.asymptote = pyo.Var(within=pyo.NonNegativeReals, initialize=10) @@ -72,7 +72,7 @@ def rooney_biegler_model(): @m.Constraint() def response_rule(m): - return m.y == m.asymptote * (1 - pyo.exp(- m.rate_constant * m.hour)) + return m.y == m.asymptote * (1 - pyo.exp(-m.rate_constant * m.hour)) return m @@ -84,7 +84,6 @@ def __init__(self, experiment_number, hour, y): self.experiment_number = experiment_number self.model = None - def get_labeled_model(self): if self.model is None: self.create_model() @@ -92,13 +91,11 @@ def get_labeled_model(self): self.label_model() return self.model - def create_model(self): m = self.model = rooney_biegler_model() return m - def finalize_model(self): m = self.model @@ -107,25 +104,22 @@ def finalize_model(self): return m - def label_model(self): m = self.model # add experiment inputs m.experiment_inputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) - m.experiment_inputs.update( - [(m.hour, self.hour)] - ) + m.experiment_inputs.update([(m.hour, self.hour)]) # add experiment outputs m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) - m.experiment_outputs.update( - [(m.y, self.y)] - ) + m.experiment_outputs.update([(m.y, self.y)]) # add unknown parameters m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) - m.unknown_parameters.update((k, pyo.value(k)) for k in [m.asymptote, m.rate_constant]) + m.unknown_parameters.update( + (k, pyo.value(k)) for k in [m.asymptote, m.rate_constant] + ) # add measurement error m.measurement_error = pyo.Suffix(direction=pyo.Suffix.LOCAL) @@ -133,7 +127,6 @@ def label_model(self): return m - # creat the experiments list rooney_biegler_exp_list = [] hour_data = self.data["hour"] @@ -145,7 +138,9 @@ def label_model(self): self.exp_list = rooney_biegler_exp_list - self.objective_function = "SSE" # testing the new covariance calculations for the `SSE` objective + self.objective_function = ( + "SSE" # testing the new covariance calculations for the `SSE` objective + ) def check_rooney_biegler_results(self, objval, cov): """ @@ -177,21 +172,17 @@ def check_rooney_biegler_results(self, objval, cov): cov.iloc[rate_constant_index, rate_constant_index], 0.041242, places=2 ) # 0.04124 from paper - def test_parmest_basics(self): """ Calculates the parameter estimates and covariance matrix, and compares them with the results of Rooney-Biegler """ - pest = parmest.Estimator( - self.exp_list, obj_function=self.objective_function - ) + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) objval, thetavals = pest.theta_est() cov = pest.cov_est(cov_n=6, method="finite_difference") self.check_rooney_biegler_results(objval, cov) - def test_cov_scipy_least_squares_comparison(self): """ Scipy results differ in the 3rd decimal place from the paper. It is possible From 5dc48f2c6b0bc5c78ffc4cc73f5578299b0bb2e8 Mon Sep 17 00:00:00 2001 From: slilonfe5 <158379837+slilonfe5@users.noreply.github.com> Date: Thu, 22 May 2025 09:25:20 -0400 Subject: [PATCH 16/35] Update pyomo/contrib/parmest/parmest.py Co-authored-by: Miranda Mundt <55767766+mrmundt@users.noreply.github.com> --- pyomo/contrib/parmest/parmest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index caf32e47b9a..6e34f276997 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -291,7 +291,7 @@ def _check_model_labels_helper(model): outputs = [k.name for k, v in model.experiment_outputs.items()] except: raise RuntimeError( - "Experiment model does not have suffix " + '"experiment_outputs".' + 'Experiment model does not have suffix "experiment_outputs".' ) # Check that experimental inputs exist From 4450a01ef22dadd3c5e04acaf00e4348f4fbc2c3 Mon Sep 17 00:00:00 2001 From: slilonfe5 <158379837+slilonfe5@users.noreply.github.com> Date: Thu, 22 May 2025 09:27:04 -0400 Subject: [PATCH 17/35] Update pyomo/contrib/parmest/parmest.py Co-authored-by: Miranda Mundt <55767766+mrmundt@users.noreply.github.com> --- pyomo/contrib/parmest/parmest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index 6e34f276997..f916c05651b 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -299,7 +299,7 @@ def _check_model_labels_helper(model): inputs = [k.name for k, v in model.experiment_inputs.items()] except: raise RuntimeError( - "Experiment model does not have suffix " + '"experiment_inputs".' + 'Experiment model does not have suffix "experiment_inputs".' ) # Check that unknown parameters exist From 55dd8360ba79002dccb799b436558831ba481970 Mon Sep 17 00:00:00 2001 From: slilonfe5 <158379837+slilonfe5@users.noreply.github.com> Date: Thu, 22 May 2025 09:27:19 -0400 Subject: [PATCH 18/35] Update pyomo/contrib/parmest/parmest.py Co-authored-by: Miranda Mundt <55767766+mrmundt@users.noreply.github.com> --- pyomo/contrib/parmest/parmest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index f916c05651b..3e3012fd96a 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -307,7 +307,7 @@ def _check_model_labels_helper(model): params = [k.name for k, v in model.unknown_parameters.items()] except: raise RuntimeError( - "Experiment model does not have suffix " + '"unknown_parameters".' + 'Experiment model does not have suffix "unknown_parameters".' ) logger.setLevel(level=logging.INFO) From 1b362cfbdf6dec5d7a71fad6ea71d695e17d7c5f Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Thu, 22 May 2025 10:13:45 -0400 Subject: [PATCH 19/35] Updated parmest.py and test_new_parmest_capabilities.py files --- pyomo/contrib/parmest/parmest.py | 6 +- .../tests/test_new_parmest_capabilities.py | 83 +++++++++++++++---- 2 files changed, 68 insertions(+), 21 deletions(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index caf32e47b9a..9bdbb00a62b 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -260,9 +260,9 @@ def SSE_weighted(model): # Check that measurement errors exist try: errors = [k.name for k, v in model.measurement_error.items()] - except: - raise RuntimeError( - "Experiment model does not have suffix " + '"measurement_error".' + except AttributeError: + raise AttributeError( + 'Experiment model does not have suffix "measurement_error". "measurement_error" is a required suffix for the `SSE_weighted` objective.' ) # check if all the values of the measurement error standard deviation have been supplied diff --git a/pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py b/pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py index 0ba5453c750..dfa5e7ebbe8 100644 --- a/pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py +++ b/pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py @@ -15,6 +15,8 @@ import os import subprocess from itertools import product +import pytest +from parameterized import parameterized, parameterized_class import pyomo.common.unittest as unittest import pyomo.contrib.parmest.parmest as parmest @@ -142,7 +144,7 @@ def label_model(self): "SSE" # testing the new covariance calculations for the `SSE` objective ) - def check_rooney_biegler_results(self, objval, cov): + def check_rooney_biegler_results(self, objval, cov, cov_method): """ Checks if the results are equal to the expected values and agree with the results of Rooney-Biegler @@ -158,30 +160,75 @@ def check_rooney_biegler_results(self, objval, cov): idx for idx, s in enumerate(cov_cols) if "rate_constant" in s ][0] - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - cov.iloc[asymptote_index, asymptote_index], 6.229612, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[asymptote_index, rate_constant_index], -0.432265, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[rate_constant_index, asymptote_index], -0.432265, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[rate_constant_index, rate_constant_index], 0.041242, places=2 - ) # 0.04124 from paper - - def test_parmest_basics(self): + if cov_method == "finite_difference" or cov_method == "automatic_differentiation_kaug": + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[asymptote_index, asymptote_index], 6.229612, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[asymptote_index, rate_constant_index], -0.432265, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, asymptote_index], -0.432265, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, rate_constant_index], 0.041242, places=2 + ) # 0.04124 from paper + else: + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[asymptote_index, asymptote_index], 36.935351, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[asymptote_index, rate_constant_index], -2.551392, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, asymptote_index], -2.551392, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, rate_constant_index], 0.243428, places=2 + ) # 0.04124 from paper + + """ Why does the covariance matrix from parmest not match the paper? Parmest is + calculating the exact reduced Hessian. The paper (Rooney and Bielger, 2001) likely + employed the first order approximation common for nonlinear regression. The paper + values were verified with Scipy, which uses the same first order approximation. + The formula used in parmest was verified against equations (7-5-15) and (7-5-16) in + "Nonlinear Parameter Estimation", Y. Bard, 1974. + """ + + # @parameterized.expand([("finite_difference"), ("automatic_differentiation_kaug"), ("reduced_hessian")]) + # def test_parmest_cov(self, cov_method): + # """ + # Calculates the parameter estimates and covariance matrix, and compares them with the results of Rooney-Biegler + # """ + # pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + # + # # estimate the parameters + # objval, thetavals = pest.theta_est() + # + # # calculate the covariance matrix + # cov = pest.cov_est(cov_n=6, method=cov_method) + # + # # check the results + # self.check_rooney_biegler_results(objval, cov, cov_method) + + + def test_parmest_cov(self): """ Calculates the parameter estimates and covariance matrix, and compares them with the results of Rooney-Biegler """ pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + # estimate the parameters objval, thetavals = pest.theta_est() - cov = pest.cov_est(cov_n=6, method="finite_difference") - self.check_rooney_biegler_results(objval, cov) + # calculate the covariance matrix using the three supported methods + cov_methods = ["finite_difference", "automatic_differentiation_kaug", "reduced_hessian"] + for method in cov_methods: + cov = pest.cov_est(cov_n=6, method=method) + + self.check_rooney_biegler_results(objval, cov, method) def test_cov_scipy_least_squares_comparison(self): """ From 49c8abed6e037fe604c5fb40fc816c989bcce3eb Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Fri, 23 May 2025 14:57:32 -0400 Subject: [PATCH 20/35] Updated parmest.py file --- pyomo/contrib/parmest/parmest.py | 275 +++++++++++++++++-------------- 1 file changed, 147 insertions(+), 128 deletions(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index 7967b37c10a..6d8ab5be845 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -214,14 +214,14 @@ def _experiment_instance_creation_callback( ] if "ThetaVals" in outer_cb_data: - thetavals = outer_cb_data["ThetaVals"] + theta_vals = outer_cb_data["ThetaVals"] # dlw august 2018: see mea code for more general theta - for name, val in thetavals.items(): + for name, val in theta_vals.items(): theta_cuid = ComponentUID(name) theta_object = theta_cuid.find_component_on(instance) if val is not None: - # print("Fixing",vstr,"at",str(thetavals[vstr])) + # print("Fixing",vstr,"at",str(theta_vals[vstr])) theta_object.fix(val) else: # print("Freeing",vstr) @@ -258,11 +258,12 @@ def SSE_weighted(model): _check_model_labels_helper(model) # Check that measurement errors exist - try: - errors = [k.name for k, v in model.measurement_error.items()] - except AttributeError: + if hasattr(model, "measurement_error"): + pass + else: raise AttributeError( - 'Experiment model does not have suffix "measurement_error". "measurement_error" is a required suffix for the `SSE_weighted` objective.' + 'Experiment model does not have suffix "measurement_error". "measurement_error" is a required suffix ' + 'for the "SSE_weighted" objective.' ) # check if all the values of the measurement error standard deviation have been supplied @@ -276,7 +277,10 @@ def SSE_weighted(model): ) return expr else: - raise ValueError("One or more values are missing from `measurement_error`.") + raise ValueError( + 'One or more values are missing from "measurement_error". All values of the ' + 'measurement errors are required for the "SSE_weighted" objective.' + ) def _check_model_labels_helper(model): @@ -287,26 +291,26 @@ def _check_model_labels_helper(model): model: annotated Pyomo model for suffix checking """ # check that experimental outputs exist - try: - outputs = [k.name for k, v in model.experiment_outputs.items()] - except: - raise RuntimeError( + if hasattr(model, "experiment_outputs"): + pass + else: + raise AttributeError( 'Experiment model does not have suffix "experiment_outputs".' ) # Check that experimental inputs exist - try: - inputs = [k.name for k, v in model.experiment_inputs.items()] - except: - raise RuntimeError( + if hasattr(model, "experiment_inputs"): + pass + else: + raise AttributeError( 'Experiment model does not have suffix "experiment_inputs".' ) # Check that unknown parameters exist - try: - params = [k.name for k, v in model.unknown_parameters.items()] - except: - raise RuntimeError( + if hasattr(model, "unknown_parameters"): + pass + else: + raise AttributeError( 'Experiment model does not have suffix "unknown_parameters".' ) @@ -316,7 +320,7 @@ def _check_model_labels_helper(model): def _get_labeled_model_helper(experiment): """ - Checks if the Experiment class object has a ``get_labeled_model`` function + Checks if the Experiment class object has a "get_labeled_model" function Argument: experiment: Estimator class object that contains the model for a particular experimental condition @@ -326,15 +330,15 @@ def _get_labeled_model_helper(experiment): """ try: model = experiment.get_labeled_model().clone() - except: - raise ValueError( - "The experiment object must have a ``get_labeled_model`` function." + except Exception as e: + raise AttributeError( + f'The experiment object must have a "get_labeled_model" function. The original error was {e}.' ) return model -class CovMethodLib(Enum): +class CovarianceMethodLib(Enum): finite_difference = "finite_difference" automatic_differentiation_kaug = "automatic_differentiation_kaug" reduced_hessian = "reduced_hessian" @@ -346,14 +350,14 @@ class ObjectiveLib(Enum): # Compute the Jacobian matrix of measured variables with respect to the parameters -def _compute_jacobian(experiment, thetavals, step, solver, tee): +def _compute_jacobian(experiment, theta_vals, step, solver, tee): """ Computes the Jacobian matrix of the measured variables with respect to the parameters using central finite difference scheme Arguments: experiment: Estimator class object that contains the model for a particular experimental condition - thetavals: dictionary containing the estimates of the unknown parameters + theta_vals: dictionary containing the estimates of the unknown parameters step: float used for relative perturbation of the parameters, e.g., step=0.02 is a 2% perturbation solver: string ``solver`` object specified by the user tee: boolean solver option to be passed for verbose output @@ -370,16 +374,17 @@ def _compute_jacobian(experiment, thetavals, step, solver, tee): # fix the value of the unknown parameters to the estimated values params = [k for k, v in model.unknown_parameters.items()] for param in params: - param.fix(thetavals[param.name]) + param.fix(theta_vals[param.name]) - # resolve the model with the estimated parameters + # re-solve the model with the estimated parameters try: solver = pyo.SolverFactory(solver) res = solver.solve(model, tee=tee) pyo.assert_optimal_termination(res) - except: + except Exception as e: raise RuntimeError( - "Model from experiment did not solve appropriately. Make sure the model is well-posed." + f"Model from experiment did not solve appropriately. Make sure the model is well-posed. " + f"The original error was {e}." ) # get the measured variables @@ -405,13 +410,14 @@ def _compute_jacobian(experiment, thetavals, step, solver, tee): # Forward perturbation param.fix(orig_value + relative_perturbation) - # solve model + # solve the model try: res = solver.solve(model, tee=tee) pyo.assert_optimal_termination(res) - except: + except Exception as e: raise RuntimeError( - "Model from experiment did not solve appropriately. Make sure the model is well-posed." + f"Model from experiment did not solve appropriately. Make sure the model is well-posed. " + f"The original error was {e}." ) # forward perturbation measured variables @@ -420,13 +426,14 @@ def _compute_jacobian(experiment, thetavals, step, solver, tee): # Backward perturbation param.fix(orig_value - relative_perturbation) - # resolve model + # re-solve the model try: res = solver.solve(model, tee=tee) pyo.assert_optimal_termination(res) - except: + except Exception as e: raise RuntimeError( - "Model from experiment did not solve appropriately. Make sure the model is well-posed." + f"Model from experiment did not solve appropriately. Make sure the model is well-posed. " + f"The original error was {e}." ) # backward perturbation measured variables @@ -434,7 +441,7 @@ def _compute_jacobian(experiment, thetavals, step, solver, tee): pyo.value(y_hat) for y_hat, y in model.experiment_outputs.items() ] - # Restore original parameter value + # Restore the original parameter value param.fix(orig_value) # Central difference approximation for the Jacobian @@ -447,8 +454,8 @@ def _compute_jacobian(experiment, thetavals, step, solver, tee): # Compute the covariance matrix of the estimated parameters -def compute_cov( - experiment_list, method, thetavals, step, solver, tee, estimated_var=None +def compute_covariance_matrix( + experiment_list, method, theta_vals, step, solver, tee, estimated_var=None ): """ Computes the covariance matrix of the estimated parameters using `finite_difference` and @@ -457,7 +464,7 @@ def compute_cov( Arguments: experiment_list: list of Estimator class objects containing the model for different experimental conditions method: string ``method`` object specified by the user (e.g., `finite_difference`) - thetavals: dictionary containing the estimates of the unknown parameters + theta_vals: dictionary containing the estimates of the unknown parameters step: float used for relative perturbation of the parameters, e.g., step=0.02 is a 2% perturbation solver: string ``solver`` object specified by the user tee: boolean solver option to be passed for verbose output @@ -469,13 +476,13 @@ def compute_cov( """ # check if the supplied method is supported try: - cov_method = CovMethodLib(method) + cov_method = CovarianceMethodLib(method) except ValueError: raise ValueError( - f"Invalid method: '{method}'. Choose from {[e.value for e in CovMethodLib]}." + f"Invalid method: '{method}'. Choose from {[e.value for e in CovarianceMethodLib]}." ) - if cov_method == CovMethodLib.finite_difference: + if cov_method == CovarianceMethodLib.finite_difference: # store the FIM of all experiments FIM_all_exp = [] for ( @@ -484,7 +491,7 @@ def compute_cov( FIM_all_exp.append( _finite_difference_FIM( experiment, - thetavals=thetavals, + theta_vals=theta_vals, step=step, solver=solver, tee=tee, @@ -500,8 +507,8 @@ def compute_cov( except np.linalg.LinAlgError: cov = np.linalg.pinv(FIM) print("The FIM is singular. Using pseudo-inverse instead.") - cov = pd.DataFrame(cov, index=thetavals.keys(), columns=thetavals.keys()) - elif cov_method == CovMethodLib.automatic_differentiation_kaug: + cov = pd.DataFrame(cov, index=theta_vals.keys(), columns=theta_vals.keys()) + elif cov_method == CovarianceMethodLib.automatic_differentiation_kaug: # store the FIM of all experiments FIM_all_exp = [] for ( @@ -510,7 +517,7 @@ def compute_cov( FIM_all_exp.append( _kaug_FIM( experiment, - thetavals=thetavals, + theta_vals=theta_vals, solver=solver, tee=tee, estimated_var=estimated_var, @@ -525,7 +532,7 @@ def compute_cov( except np.linalg.LinAlgError: cov = np.linalg.pinv(FIM) print("The FIM is singular. Using pseudo-inverse instead.") - cov = pd.DataFrame(cov, index=thetavals.keys(), columns=thetavals.keys()) + cov = pd.DataFrame(cov, index=theta_vals.keys(), columns=theta_vals.keys()) else: raise ValueError( "The method provided, {}, must be either `finite_difference` or `automatic_differentiation_kaug`".format( @@ -538,7 +545,7 @@ def compute_cov( # compute the Fisher information matrix of the estimated parameters using `finite_difference` def _finite_difference_FIM( - experiment, thetavals, step, solver, tee, estimated_var=None + experiment, theta_vals, step, solver, tee, estimated_var=None ): """ Computes the Fisher information matrix from finite difference Jacobian matrix and @@ -546,7 +553,7 @@ def _finite_difference_FIM( Arguments: experiment: Estimator class object that contains the model for a particular experimental condition - thetavals: dictionary containing the estimates of the unknown parameters + theta_vals: dictionary containing the estimates of the unknown parameters step: float used for relative perturbation of the parameters, e.g., step=0.02 is a 2% perturbation solver: string ``solver`` object specified by the user tee: boolean solver option to be passed for verbose output @@ -557,7 +564,7 @@ def _finite_difference_FIM( FIM: Fisher information matrix about the parameters """ # compute the Jacobian matrix using finite difference - J = _compute_jacobian(experiment, thetavals, step, solver, tee) + J = _compute_jacobian(experiment, theta_vals, step, solver, tee) # computing the condition number of the Jacobian matrix cond_number_jac = np.linalg.cond(J) @@ -586,13 +593,13 @@ def _finite_difference_FIM( # check if error list is consistent if len(error_list) == 0 or len(y_hat_list) == 0: raise ValueError( - "Experiment outputs and measurement errors cannot be empty" + "Experiment outputs and measurement errors cannot be empty." ) # check if the dimension of error_list is same with that of y_hat_list if len(error_list) != len(y_hat_list): raise ValueError( - "Experiment outputs and measurement errors are not the same length" + "Experiment outputs and measurement errors are not the same length." ) # calculate the FIM using the formula in Lilonfe et al. (2025) @@ -604,7 +611,7 @@ def _finite_difference_FIM( # compute the Fisher information matrix of the estimated parameters using `automatic_differentiation_kaug` -def _kaug_FIM(experiment, thetavals, solver, tee, estimated_var=None): +def _kaug_FIM(experiment, theta_vals, solver, tee, estimated_var=None): """ Computes the FIM using `automatic_differentiation_kaug`, a sensitivity-based approach that uses the annotated Pyomo model optimality condition and user-defined measurement errors standard deviation @@ -613,7 +620,7 @@ def _kaug_FIM(experiment, thetavals, solver, tee, estimated_var=None): Arguments: experiment: Estimator class object that contains the model for a particular experimental condition - thetavals: estimated parameter values + theta_vals: dictionary containing the estimates of the unknown parameters solver: string ``solver`` object specified by the user tee: boolean solver option to be passed for verbose output estimated_var: value of the estimated variance of the measurement error in cases where @@ -622,17 +629,24 @@ def _kaug_FIM(experiment, thetavals, solver, tee, estimated_var=None): Returns: FIM: Fisher information matrix about the parameters """ - # grab and clone the model - model = experiment.get_labeled_model().clone() + # grab the model + model = _get_labeled_model_helper(experiment) # fix the parameter values to the estimated values params = [k for k, v in model.unknown_parameters.items()] for param in params: - param.fix(thetavals[param.name]) + param.fix(theta_vals[param.name]) - # resolve the model with the estimated parameters - solver = pyo.SolverFactory(solver) - solver.solve(model, tee=tee) + # re-solve the model with the estimated parameters + try: + solver = pyo.SolverFactory(solver) + res = solver.solve(model, tee=tee) + pyo.assert_optimal_termination(res) + except Exception as e: + raise RuntimeError( + f"Model from experiment did not solve appropriately. Make sure the model is well-posed. " + f"The original error was {e}." + ) # add zero (dummy/placeholder) objective function if not hasattr(model, "objective"): @@ -676,7 +690,7 @@ def _kaug_FIM(experiment, thetavals, solver, tee, estimated_var=None): dsdp_extract.append(zero_sens) # Extract and calculate sensitivity if scaled by constants or parameters. - jac = [[] for k in params_names] + jac = [[] for _ in params_names] for d in range(len(dsdp_extract)): for k, v in model.unknown_parameters.items(): @@ -977,7 +991,7 @@ def _Q_opt( # parmest makes the fitted parameters stage 1 variables ind_vars = [] - for ndname, Var, solval in ef_nonants(ef): + for nd_name, Var, sol_val in ef_nonants(ef): ind_vars.append(Var) # calculate the reduced hessian (solve_result, inv_red_hes) = ( @@ -998,19 +1012,19 @@ def _Q_opt( ) # assume all first stage are thetas... - thetavals = {} - for ndname, Var, solval in ef_nonants(ef): + theta_vals = {} + for nd_name, Var, sol_val in ef_nonants(ef): # process the name # the scenarios are blocks, so strip the scenario name - vname = Var.name[Var.name.find(".") + 1 :] - thetavals[vname] = solval + var_name = Var.name[Var.name.find(".") + 1 :] + theta_vals[var_name] = sol_val - objval = pyo.value(ef.EF_Obj) + obj_val = pyo.value(ef.EF_Obj) # add the estimated theta and objective value to the class - self.estimated_theta = thetavals + self.estimated_theta = theta_vals - thetavals = pd.Series(thetavals) + theta_vals = pd.Series(theta_vals) if len(return_values) > 0: var_values = [] @@ -1041,9 +1055,9 @@ def _Q_opt( var_values.append(vals) var_values = pd.DataFrame(var_values) - return objval, thetavals, var_values + return obj_val, theta_vals, var_values - return objval, thetavals + return obj_val, theta_vals else: raise RuntimeError("Unknown solver in Q_Opt=" + solver) @@ -1077,13 +1091,14 @@ def _cov_at_theta(self, method, solver, cov_n, step): for param in params: param.fix(self.estimated_theta[param.name]) - # resolve the model with the estimated parameters + # re-solve the model with the estimated parameters try: res = pyo.SolverFactory(solver).solve(model, tee=self.tee) pyo.assert_optimal_termination(res) - except: + except Exception as e: raise RuntimeError( - "Model from experiment did not solve appropriately. Make sure the model is well-posed." + f"Model from experiment did not solve appropriately. Make sure the model is well-posed. " + f"The original error was {e}." ) # choose and evaluate the objective expression @@ -1093,7 +1108,7 @@ def _cov_at_theta(self, method, solver, cov_n, step): sse_expr = SSE_weighted(model) else: raise NotImplementedError( - "Covariance calculation is only supported for `SSE` and `SSE_weighted` objectives." + 'Covariance calculation is only supported for "SSE" and "SSE_weighted" objectives.' ) # evaluate numerical SSE and store it @@ -1114,10 +1129,10 @@ def _cov_at_theta(self, method, solver, cov_n, step): """ # check if the supplied method is supported try: - cov_method = CovMethodLib(method) + cov_method = CovarianceMethodLib(method) except ValueError: raise ValueError( - f"Invalid method: '{method}'. Choose from {[e.value for e in CovMethodLib]}." + f"Invalid method: '{method}'. Choose from {[e.value for e in CovarianceMethodLib]}." ) # check if the user specified `SSE` or `SSE_weighted` as the objective function @@ -1135,7 +1150,7 @@ def _cov_at_theta(self, method, solver, cov_n, step): measurement_var = sse / ( n - l ) # estimate of the measurement variance - if cov_method == CovMethodLib.reduced_hessian: + if cov_method == CovarianceMethodLib.reduced_hessian: cov = ( 2 * measurement_var * self.inv_red_hes ) # covariance matrix @@ -1145,13 +1160,14 @@ def _cov_at_theta(self, method, solver, cov_n, step): columns=self.estimated_theta.keys(), ) elif ( - cov_method == CovMethodLib.finite_difference - or cov_method == CovMethodLib.automatic_differentiation_kaug + cov_method == CovarianceMethodLib.finite_difference + or cov_method + == CovarianceMethodLib.automatic_differentiation_kaug ): - cov = compute_cov( + cov = compute_covariance_matrix( self.exp_list, method, - thetavals=self.estimated_theta, + theta_vals=self.estimated_theta, solver=solver, step=step, tee=self.tee, @@ -1159,11 +1175,11 @@ def _cov_at_theta(self, method, solver, cov_n, step): ) else: raise NotImplementedError( - "Only `finite_difference`, `reduced_hessian`, and `automatic_differentiation_kaug` " - "methods are supported." + 'Only "finite_difference", "reduced_hessian", and "automatic_differentiation_kaug" ' + 'methods are supported.' ) elif all(item is not None for item in meas_error): - if cov_method == CovMethodLib.reduced_hessian: + if cov_method == CovarianceMethodLib.reduced_hessian: cov = 2 * (meas_error[0] ** 2) * self.inv_red_hes cov = pd.DataFrame( cov, @@ -1171,29 +1187,30 @@ def _cov_at_theta(self, method, solver, cov_n, step): columns=self.estimated_theta.keys(), ) elif ( - cov_method == CovMethodLib.finite_difference - or cov_method == CovMethodLib.automatic_differentiation_kaug + cov_method == CovarianceMethodLib.finite_difference + or cov_method + == CovarianceMethodLib.automatic_differentiation_kaug ): - cov = compute_cov( + cov = compute_covariance_matrix( self.exp_list, method, - thetavals=self.estimated_theta, + theta_vals=self.estimated_theta, solver=solver, step=step, tee=self.tee, ) else: raise NotImplementedError( - "Only `finite_difference`, `reduced_hessian`, and `automatic_differentiation_kaug` " - "methods are supported." + 'Only "finite_difference", "reduced_hessian", and "automatic_differentiation_kaug" ' + 'methods are supported.' ) else: raise ValueError( "One or more values of the measurement errors have not been supplied." ) else: - raise RuntimeError( - "Experiment model does not have suffix " + '"measurement_error".' + raise AttributeError( + 'Experiment model does not have suffix "measurement_error".' ) elif self.obj_function == ObjectiveLib.SSE_weighted: # check if the user defined the `measurement_error` attribute @@ -1206,18 +1223,19 @@ def _cov_at_theta(self, method, solver, cov_n, step): # check if the user supplied values for the measurement errors if all(item is not None for item in meas_error): if ( - cov_method == CovMethodLib.finite_difference - or cov_method == CovMethodLib.automatic_differentiation_kaug + cov_method == CovarianceMethodLib.finite_difference + or cov_method + == CovarianceMethodLib.automatic_differentiation_kaug ): - cov = compute_cov( + cov = compute_covariance_matrix( self.exp_list, method, - thetavals=self.estimated_theta, + theta_vals=self.estimated_theta, step=step, solver=solver, tee=self.tee, ) - elif cov_method == CovMethodLib.reduced_hessian: + elif cov_method == CovarianceMethodLib.reduced_hessian: cov = self.inv_red_hes cov = pd.DataFrame( cov, @@ -1226,31 +1244,32 @@ def _cov_at_theta(self, method, solver, cov_n, step): ) else: raise NotImplementedError( - "Only `finite_difference`, `reduced_hessian`, and `automatic_differentiation_kaug` " - "methods are supported." + 'Only "finite_difference", "reduced_hessian", and "automatic_differentiation_kaug" ' + 'methods are supported.' ) else: raise ValueError( - "One or more values of the measurement errors have not been supplied." + 'One or more values of the measurement errors have not been supplied. All values of the ' + 'measurement errors are required for the "SSE_weighted" objective.' ) else: - raise RuntimeError( - "Experiment model does not have suffix " + '"measurement_error".' + raise AttributeError( + 'Experiment model does not have suffix "measurement_error".' ) else: raise NotImplementedError( - "Covariance calculation is only supported for `SSE` and `SSE_weighted` objectives." + 'Covariance calculation is only supported for "SSE" and "SSE_weighted" objectives.' ) return cov - def _Q_at_theta(self, thetavals, initialize_parmest_model=False): + def _Q_at_theta(self, theta_vals, initialize_parmest_model=False): """ Return the objective function value with fixed theta values. Parameters ---------- - thetavals: dict + theta_vals: dict A dictionary of theta values. initialize_parmest_model: boolean @@ -1261,7 +1280,7 @@ def _Q_at_theta(self, thetavals, initialize_parmest_model=False): ------- objectiveval: float The objective function value. - thetavals: dict + theta_vals: dict A dictionary of all values for theta that were input. solvertermination: Pyomo TerminationCondition Tries to return the "worst" solver status across the scenarios. @@ -1271,10 +1290,10 @@ def _Q_at_theta(self, thetavals, initialize_parmest_model=False): optimizer = pyo.SolverFactory("ipopt") - if len(thetavals) > 0: + if len(theta_vals) > 0: dummy_cb = { "callback": self._instance_creation_callback, - "ThetaVals": thetavals, + "ThetaVals": theta_vals, "theta_names": self._return_theta_names(), "cb_data": None, } @@ -1286,8 +1305,8 @@ def _Q_at_theta(self, thetavals, initialize_parmest_model=False): } if self.diagnostic_mode: - if len(thetavals) > 0: - print(" Compute objective at theta = ", str(thetavals)) + if len(theta_vals) > 0: + print(" Compute objective at theta = ", str(theta_vals)) else: print(" Compute objective at initial theta") @@ -1330,10 +1349,10 @@ def _Q_at_theta(self, thetavals, initialize_parmest_model=False): ) else: try: - if len(thetavals) == 0: + if len(theta_vals) == 0: var_validate.fix() else: - var_validate.fix(thetavals[theta]) + var_validate.fix(theta_vals[theta]) theta_init_vals.append(var_validate) except: logger.warning( @@ -1401,8 +1420,8 @@ def _Q_at_theta(self, thetavals, initialize_parmest_model=False): scen_dict[sname] = instance objobject = getattr(instance, self._second_stage_cost_exp) - objval = pyo.value(objobject) - totobj += objval + obj_val = pyo.value(objobject) + totobj += obj_val retval = totobj / len(scenario_numbers) # -1?? if initialize_parmest_model and not hasattr(self, "ef_instance"): @@ -1428,13 +1447,13 @@ def _Q_at_theta(self, thetavals, initialize_parmest_model=False): self.model_initialized = True # return initialized theta values - if len(thetavals) == 0: + if len(theta_vals) == 0: # use appropriate theta_names member theta_ref = self._return_theta_names() for i, theta in enumerate(theta_ref): - thetavals[theta] = theta_init_vals[i]() + theta_vals[theta] = theta_init_vals[i]() - return retval, thetavals, WorstStatus + return retval, theta_vals, WorstStatus def _get_sample_list(self, samplesize, num_samples, replacement=True): samplelist = list() @@ -1488,7 +1507,7 @@ def theta_est(self, solver="ef_ipopt", return_values=[]): ------- objectiveval: float The objective function value - thetavals: pd.Series + theta_vals: pd.Series Estimated values for theta variable values: pd.DataFrame Variable values for each variable name in return_values (only for solver='ef_ipopt') @@ -1606,9 +1625,9 @@ def theta_est_bootstrap( bootstrap_theta = list() for idx, sample in local_list: - objval, thetavals = self._Q_opt(bootlist=list(sample)) - thetavals["samples"] = sample - bootstrap_theta.append(thetavals) + obj_val, theta_vals = self._Q_opt(bootlist=list(sample)) + theta_vals["samples"] = sample + bootstrap_theta.append(theta_vals) global_bootstrap_theta = task_mgr.allgather_global_data(bootstrap_theta) bootstrap_theta = pd.DataFrame(global_bootstrap_theta) @@ -1666,10 +1685,10 @@ def theta_est_leaveNout( lNo_theta = list() for idx, sample in local_list: - objval, thetavals = self._Q_opt(bootlist=list(sample)) + obj_val, theta_vals = self._Q_opt(bootlist=list(sample)) lNo_s = list(set(range(len(self.exp_list))) - set(sample)) - thetavals["lNo"] = np.sort(lNo_s) - lNo_theta.append(thetavals) + theta_vals["lNo"] = np.sort(lNo_s) + lNo_theta.append(theta_vals) global_bootstrap_theta = task_mgr.allgather_global_data(lNo_theta) lNo_theta = pd.DataFrame(global_bootstrap_theta) @@ -1845,7 +1864,7 @@ def objective_at_theta(self, theta_values=None, initialize_parmest_model=False): # DLW, Aug2018: should we also store the worst solver status? else: obj, thetvals, worststatus = self._Q_at_theta( - thetavals={}, initialize_parmest_model=initialize_parmest_model + theta_vals={}, initialize_parmest_model=initialize_parmest_model ) if worststatus != pyo.TerminationCondition.infeasible: all_obj.append(list(thetvals.values()) + [obj]) From 46b535646d81c590aa569e0e23f63990f1680028 Mon Sep 17 00:00:00 2001 From: slilonfe5 <158379837+slilonfe5@users.noreply.github.com> Date: Fri, 23 May 2025 14:59:19 -0400 Subject: [PATCH 21/35] Updated pyomo/contrib/parmest/parmest.py Co-authored-by: Miranda Mundt <55767766+mrmundt@users.noreply.github.com> --- pyomo/contrib/parmest/parmest.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index 6d8ab5be845..c1f0956000a 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -535,8 +535,7 @@ def compute_covariance_matrix( cov = pd.DataFrame(cov, index=theta_vals.keys(), columns=theta_vals.keys()) else: raise ValueError( - "The method provided, {}, must be either `finite_difference` or `automatic_differentiation_kaug`".format( - method + f"The method provided, {method}, must be either `finite_difference` or `automatic_differentiation_kaug`" ) ) From a9c4148e65ead67fe0f969c8fe155e32fd37a44b Mon Sep 17 00:00:00 2001 From: slilonfe5 <158379837+slilonfe5@users.noreply.github.com> Date: Fri, 23 May 2025 15:01:38 -0400 Subject: [PATCH 22/35] Updated pyomo/contrib/parmest/parmest.py Co-authored-by: Miranda Mundt <55767766+mrmundt@users.noreply.github.com> --- pyomo/contrib/parmest/parmest.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index c1f0956000a..dc790e664a7 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -705,15 +705,17 @@ def _kaug_FIM(experiment, theta_vals, solver, tee, estimated_var=None): # The following assumes independent measurement error. W = np.zeros((len(model.measurement_error), len(model.measurement_error))) count = 0 - for k, v in model.measurement_error.items(): - if all( - model.measurement_error[y_hat] is not None - for y_hat in model.experiment_outputs - ): - W[count, count] = 1 / (v**2) - else: - W[count, count] = 1 / (estimated_var) - count += 1 +all_known_errors = all( + model.measurement_error[y_hat] is not None + for y_hat in model.experiment_outputs +) + +for k, v in model.measurement_error.items(): + if all_known_errors: + W[count, count] = 1 / (v**2) + else: + W[count, count] = 1 / estimated_var + count += 1 FIM = kaug_jac.T @ W @ kaug_jac From 927e36eb5188b55538cf6853ca017ad3a045ebd4 Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Tue, 27 May 2025 20:35:35 -0400 Subject: [PATCH 23/35] Updated parmest.py and the test file --- pyomo/contrib/parmest/parmest.py | 23 ++- .../tests/test_new_parmest_capabilities.py | 134 ++++++++++-------- 2 files changed, 81 insertions(+), 76 deletions(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index dc790e664a7..8a1cb5de0c7 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -535,8 +535,7 @@ def compute_covariance_matrix( cov = pd.DataFrame(cov, index=theta_vals.keys(), columns=theta_vals.keys()) else: raise ValueError( - f"The method provided, {method}, must be either `finite_difference` or `automatic_differentiation_kaug`" - ) + f'The method provided, {method}, must be either "finite_difference" or "automatic_differentiation_kaug".' ) return cov @@ -704,18 +703,16 @@ def _kaug_FIM(experiment, theta_vals, solver, tee, estimated_var=None): # compute matrix of the inverse of the measurement variance # The following assumes independent measurement error. W = np.zeros((len(model.measurement_error), len(model.measurement_error))) + all_known_errors = all( + model.measurement_error[y_hat] is not None for y_hat in model.experiment_outputs + ) count = 0 -all_known_errors = all( - model.measurement_error[y_hat] is not None - for y_hat in model.experiment_outputs -) - -for k, v in model.measurement_error.items(): - if all_known_errors: - W[count, count] = 1 / (v**2) - else: - W[count, count] = 1 / estimated_var - count += 1 + for k, v in model.measurement_error.items(): + if all_known_errors: + W[count, count] = 1 / (v**2) + else: + W[count, count] = 1 / estimated_var + count += 1 FIM = kaug_jac.T @ W @ kaug_jac diff --git a/pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py b/pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py index dfa5e7ebbe8..a38502312e8 100644 --- a/pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py +++ b/pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py @@ -42,14 +42,18 @@ "Cannot test parmest: required dependencies are missing", ) -# Test class for the built-in Parmest `SSE_weighted` objective function +# Test class for the built-in Parmest "SSE_weighted" objective function @unittest.skipIf( not parmest.parmest_available, "Cannot test parmest: required dependencies are missing", ) @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +@parameterized_class(("objective_function",), [("SSE",), ("SSE_weighted",)]) class TestModelVariants(unittest.TestCase): + def objective_runs(self): + self.objective_function = objective_function + def setUp(self): self.data = pd.DataFrame( data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], @@ -140,9 +144,9 @@ def label_model(self): self.exp_list = rooney_biegler_exp_list - self.objective_function = ( - "SSE" # testing the new covariance calculations for the `SSE` objective - ) + # self.objective_function = ( + # "SSE" # testing the new covariance calculations for the `SSE` objective + # ) def check_rooney_biegler_results(self, objval, cov, cov_method): """ @@ -160,45 +164,61 @@ def check_rooney_biegler_results(self, objval, cov, cov_method): idx for idx, s in enumerate(cov_cols) if "rate_constant" in s ][0] - if cov_method == "finite_difference" or cov_method == "automatic_differentiation_kaug": - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - cov.iloc[asymptote_index, asymptote_index], 6.229612, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[asymptote_index, rate_constant_index], -0.432265, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[rate_constant_index, asymptote_index], -0.432265, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[rate_constant_index, rate_constant_index], 0.041242, places=2 - ) # 0.04124 from paper + if self.objective_function == "SSE": + if ( + cov_method == "finite_difference" + or cov_method == "automatic_differentiation_kaug" + ): + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[asymptote_index, asymptote_index], 6.229612, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[asymptote_index, rate_constant_index], -0.432265, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, asymptote_index], -0.432265, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, rate_constant_index], 0.041242, places=2 + ) # 0.04124 from paper + else: + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[asymptote_index, asymptote_index], 36.935351, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[asymptote_index, rate_constant_index], -2.551392, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, asymptote_index], -2.551392, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, rate_constant_index], 0.243428, places=2 + ) # 0.04124 from paper + else: + pass + + @parameterized.expand([("finite_difference"), ("automatic_differentiation_kaug"), ("reduced_hessian")]) + def test_parmest_cov(self, cov_method): + """ + Calculates the parameter estimates and covariance matrix, and compares them with the results of Rooney-Biegler + """ + if self.objective_function == "SSE": + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + + # estimate the parameters + objval, thetavals = pest.theta_est() + + # calculate the covariance matrix + cov = pest.cov_est(cov_n=6, method=cov_method) + + # check the results + self.check_rooney_biegler_results(objval, cov, cov_method) else: - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - cov.iloc[asymptote_index, asymptote_index], 36.935351, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[asymptote_index, rate_constant_index], -2.551392, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[rate_constant_index, asymptote_index], -2.551392, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[rate_constant_index, rate_constant_index], 0.243428, places=2 - ) # 0.04124 from paper - - """ Why does the covariance matrix from parmest not match the paper? Parmest is - calculating the exact reduced Hessian. The paper (Rooney and Bielger, 2001) likely - employed the first order approximation common for nonlinear regression. The paper - values were verified with Scipy, which uses the same first order approximation. - The formula used in parmest was verified against equations (7-5-15) and (7-5-16) in - "Nonlinear Parameter Estimation", Y. Bard, 1974. - """ - - # @parameterized.expand([("finite_difference"), ("automatic_differentiation_kaug"), ("reduced_hessian")]) - # def test_parmest_cov(self, cov_method): + pass + + # def test_parmest_cov(self): # """ # Calculates the parameter estimates and covariance matrix, and compares them with the results of Rooney-Biegler # """ @@ -207,28 +227,16 @@ def check_rooney_biegler_results(self, objval, cov, cov_method): # # estimate the parameters # objval, thetavals = pest.theta_est() # - # # calculate the covariance matrix - # cov = pest.cov_est(cov_n=6, method=cov_method) + # # calculate the covariance matrix using the three supported methods + # cov_methods = [ + # "finite_difference", + # "automatic_differentiation_kaug", + # "reduced_hessian", + # ] + # for method in cov_methods: + # cov = pest.cov_est(cov_n=6, method=method) # - # # check the results - # self.check_rooney_biegler_results(objval, cov, cov_method) - - - def test_parmest_cov(self): - """ - Calculates the parameter estimates and covariance matrix, and compares them with the results of Rooney-Biegler - """ - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) - - # estimate the parameters - objval, thetavals = pest.theta_est() - - # calculate the covariance matrix using the three supported methods - cov_methods = ["finite_difference", "automatic_differentiation_kaug", "reduced_hessian"] - for method in cov_methods: - cov = pest.cov_est(cov_n=6, method=method) - - self.check_rooney_biegler_results(objval, cov, method) + # self.check_rooney_biegler_results(objval, cov, method) def test_cov_scipy_least_squares_comparison(self): """ From 78cc3d8cde2c9a351898d347a6ba361b91c1b694 Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Tue, 3 Jun 2025 09:17:25 -0400 Subject: [PATCH 24/35] Updated parmest.py and test_parmest.py files --- pyomo/contrib/parmest/parmest.py | 28 +- .../tests/test_new_parmest_capabilities.py | 352 ++- pyomo/contrib/parmest/tests/test_parmest.py | 2281 ++++------------- 3 files changed, 678 insertions(+), 1983 deletions(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index 8a1cb5de0c7..ee2bb3b1753 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -359,7 +359,7 @@ def _compute_jacobian(experiment, theta_vals, step, solver, tee): experiment: Estimator class object that contains the model for a particular experimental condition theta_vals: dictionary containing the estimates of the unknown parameters step: float used for relative perturbation of the parameters, e.g., step=0.02 is a 2% perturbation - solver: string ``solver`` object specified by the user + solver: string ``solver`` object specified by the user, e.g., 'ipopt' tee: boolean solver option to be passed for verbose output Returns: @@ -463,10 +463,10 @@ def compute_covariance_matrix( Arguments: experiment_list: list of Estimator class objects containing the model for different experimental conditions - method: string ``method`` object specified by the user (e.g., `finite_difference`) + method: string ``method`` object specified by the user, e.g., 'finite_difference' theta_vals: dictionary containing the estimates of the unknown parameters step: float used for relative perturbation of the parameters, e.g., step=0.02 is a 2% perturbation - solver: string ``solver`` object specified by the user + solver: string ``solver`` object specified by the user, e.g., 'ipopt' tee: boolean solver option to be passed for verbose output estimated_var: value of the estimated variance of the measurement error in cases where the user does not supply the measurement error standard deviation @@ -553,7 +553,7 @@ def _finite_difference_FIM( experiment: Estimator class object that contains the model for a particular experimental condition theta_vals: dictionary containing the estimates of the unknown parameters step: float used for relative perturbation of the parameters, e.g., step=0.02 is a 2% perturbation - solver: string ``solver`` object specified by the user + solver: string ``solver`` object specified by the user, e.g., 'ipopt' tee: boolean solver option to be passed for verbose output estimated_var: value of the estimated variance of the measurement error in cases where the user does not supply the measurement error standard deviation @@ -619,7 +619,7 @@ def _kaug_FIM(experiment, theta_vals, solver, tee, estimated_var=None): Arguments: experiment: Estimator class object that contains the model for a particular experimental condition theta_vals: dictionary containing the estimates of the unknown parameters - solver: string ``solver`` object specified by the user + solver: string ``solver`` object specified by the user, e.g., 'ipopt' tee: boolean solver option to be passed for verbose output estimated_var: value of the estimated variance of the measurement error in cases where the user does not supply the measurement error standard deviation @@ -770,7 +770,11 @@ def __init__( _check_model_labels_helper(model) # populate keyword argument options - self.obj_function = ObjectiveLib(obj_function) + try: + self.obj_function = ObjectiveLib(obj_function) + except ValueError: + raise ValueError(f"Invalid objective function: '{obj_function}'. " + f"Choose from {[e.value for e in ObjectiveLib]}.") self.tee = tee self.diagnostic_mode = diagnostic_mode self.solver_options = solver_options @@ -1065,8 +1069,8 @@ def _cov_at_theta(self, method, solver, cov_n, step): Covariance matrix calculation using all scenarios in the data Argument: - method: string ``method`` object specified by the user (e.g., `finite_difference`) - solver: string ``solver`` object specified by the user (e.g., `ipopt`) + method: string ``method`` object specified by the user, e.g., 'finite_difference' + solver: string ``solver`` object specified by the user, e.g., 'ipopt' cov_n: integer, number of datapoints specified by the user which is used in the objective function step: float used for relative perturbation of the parameters, e.g., step=0.02 is a 2% perturbation @@ -1524,8 +1528,8 @@ def cov_est( Argument: method: string ``method`` object specified by the user - options - `finite_difference`, `reduced_hessian`, and `automatic_differentiation_kaug` - solver: string ``solver`` object specified by the user (e.g., `ipopt`) + options - 'finite_difference', 'reduced_hessian', and 'automatic_differentiation_kaug' + solver: string ``solver`` object specified by the user, e.g., 'ipopt' cov_n: integer, number of datapoints specified by the user which is used in the objective function step: float used for relative perturbation of the parameters, e.g., step=0.02 is a 2% perturbation @@ -1534,11 +1538,11 @@ def cov_est( """ # check if the solver input is a string if not isinstance(solver, str): - raise TypeError("Expected a string for the solver.") + raise TypeError("Expected a string for the solver, e.g., 'ipopt'") # check if the method input is a string if not isinstance(method, str): - raise TypeError("Expected a string for the method.") + raise TypeError("Expected a string for the method, e.g., 'finite_difference'") # check if the supplied number of datapoints is an integer if not isinstance(cov_n, int): diff --git a/pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py b/pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py index a38502312e8..28237bb7ba5 100644 --- a/pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py +++ b/pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py @@ -10,49 +10,33 @@ # ________________________________________________________________________ # ___ -import platform -import sys -import os -import subprocess -from itertools import product import pytest from parameterized import parameterized, parameterized_class import pyomo.common.unittest as unittest import pyomo.contrib.parmest.parmest as parmest -import pyomo.contrib.parmest.graphics as graphics -import pyomo.contrib.parmest as parmestbase import pyomo.environ as pyo -import pyomo.dae as dae from pyomo.common.dependencies import numpy as np, pandas as pd, scipy, matplotlib -from pyomo.common.fileutils import this_file_dir from pyomo.contrib.parmest.experiment import Experiment -from pyomo.contrib.pynumero.asl import AmplInterface -from pyomo.opt import SolverFactory - -is_osx = platform.mac_ver()[0] != "" -ipopt_available = SolverFactory("ipopt").available() -pynumero_ASL_available = AmplInterface.available() -testdir = this_file_dir() +ipopt_available = pyo.SolverFactory("ipopt").available() -@unittest.skipIf( - not parmest.parmest_available, - "Cannot test parmest: required dependencies are missing", -) - -# Test class for the built-in Parmest "SSE_weighted" objective function +# Test class for the built-in "SSE" and "SSE_weighted" objective functions +# validated the results using the Rooney-Biegler example +# Rooney-Biegler example is the case when the measurement error is None +# we considered another case when the user supplies the value of the measurement error @unittest.skipIf( not parmest.parmest_available, "Cannot test parmest: required dependencies are missing", ) @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") -@parameterized_class(("objective_function",), [("SSE",), ("SSE_weighted",)]) -class TestModelVariants(unittest.TestCase): - def objective_runs(self): - self.objective_function = objective_function +# we use parameterized_class to test the two objective functions over the two cases of measurement error +# included a third objective function to test the error message when an incorrect objective function is supplied +@parameterized_class(("measurement_std", "objective_function"), [(None, "SSE"), (None, "SSE_weighted"), + (None, "incorrect_obj"), (0.1, "SSE"), (0.1, "SSE_weighted"), (0.1, "incorrect_obj")]) +class TestRooneyBiegler(unittest.TestCase): def setUp(self): self.data = pd.DataFrame( @@ -84,11 +68,12 @@ def response_rule(m): # create the Experiment class class RooneyBieglerExperiment(Experiment): - def __init__(self, experiment_number, hour, y): + def __init__(self, experiment_number, hour, y, measurement_error_std): self.y = y self.hour = hour self.experiment_number = experiment_number self.model = None + self.measurement_error_std = measurement_error_std def get_labeled_model(self): if self.model is None: @@ -129,32 +114,59 @@ def label_model(self): # add measurement error m.measurement_error = pyo.Suffix(direction=pyo.Suffix.LOCAL) - m.measurement_error.update([(m.y, None)]) + m.measurement_error.update([(m.y, self.measurement_error_std)]) return m - # creat the experiments list - rooney_biegler_exp_list = [] + # extract the input and output variables hour_data = self.data["hour"] y_data = self.data["y"] + + # create the experiments list + rooney_biegler_exp_list = [] for i in range(self.data.shape[0]): rooney_biegler_exp_list.append( - RooneyBieglerExperiment(i, hour_data[i], y_data[i]) + RooneyBieglerExperiment(i, hour_data[i], y_data[i], self.measurement_std) ) self.exp_list = rooney_biegler_exp_list - # self.objective_function = ( - # "SSE" # testing the new covariance calculations for the `SSE` objective - # ) - def check_rooney_biegler_results(self, objval, cov, cov_method): + def check_rooney_biegler_parameters(self, obj_val, theta_vals, obj_function, measurement_error): + """ + Checks if the objective value and parameter estimates are equal to the expected values + and agree with the results of the Rooney-Biegler paper + + Argument: + obj_val: the objective value of the annotated Pyomo model + theta_vals: dictionary of the estimated parameters + obj_function: a string of the objective function supplied by the user, e.g., 'SSE' + measurement_error: float or integer value of the measurement error standard deviation + """ + if obj_function == "SSE": + self.assertAlmostEqual(obj_val, 4.33171, places=2) + elif obj_function == "SSE_weighted" and measurement_error is not None: + self.assertAlmostEqual(obj_val, 216.58556, places=2) + + self.assertAlmostEqual( + theta_vals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + theta_vals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + + def check_rooney_biegler_covariance(self, cov, cov_method, obj_function, measurement_error): """ - Checks if the results are equal to the expected values and agree with the results of Rooney-Biegler + Checks if the covariance matrix elements are equal to the expected values + and agree with the results of the Rooney-Biegler paper Argument: - objval: the objective value of the annotated Pyomo model - cov: covariance matrix of the estimated parameters + cov: pd.DataFrame, covariance matrix of the estimated parameters + cov_method: string ``method`` object specified by the user + options - 'finite_difference', 'reduced_hessian', and 'automatic_differentiation_kaug' + obj_function: a string of the objective function supplied by the user, e.g., 'SSE' + measurement_error: float or integer value of the measurement error standard deviation """ # get indices in covariance matrix @@ -164,79 +176,144 @@ def check_rooney_biegler_results(self, objval, cov, cov_method): idx for idx, s in enumerate(cov_cols) if "rate_constant" in s ][0] - if self.objective_function == "SSE": - if ( - cov_method == "finite_difference" - or cov_method == "automatic_differentiation_kaug" - ): - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - cov.iloc[asymptote_index, asymptote_index], 6.229612, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[asymptote_index, rate_constant_index], -0.432265, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[rate_constant_index, asymptote_index], -0.432265, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[rate_constant_index, rate_constant_index], 0.041242, places=2 - ) # 0.04124 from paper - else: - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - cov.iloc[asymptote_index, asymptote_index], 36.935351, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[asymptote_index, rate_constant_index], -2.551392, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[rate_constant_index, asymptote_index], -2.551392, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[rate_constant_index, rate_constant_index], 0.243428, places=2 - ) # 0.04124 from paper + if measurement_error is None: + if obj_function == "SSE": + if ( + cov_method == "finite_difference" + or cov_method == "automatic_differentiation_kaug" + ): + self.assertAlmostEqual( + cov.iloc[asymptote_index, asymptote_index], 6.229612, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[asymptote_index, rate_constant_index], -0.432265, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, asymptote_index], -0.432265, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, rate_constant_index], 0.041242, places=2 + ) # 0.04124 from paper + else: + self.assertAlmostEqual( + cov.iloc[asymptote_index, asymptote_index], 36.935351, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[asymptote_index, rate_constant_index], -2.551392, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, asymptote_index], -2.551392, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, rate_constant_index], 0.243428, places=2 + ) # 0.04124 from paper else: - pass - - @parameterized.expand([("finite_difference"), ("automatic_differentiation_kaug"), ("reduced_hessian")]) + if obj_function == "SSE" or obj_function == "SSE_weighted": + if ( + cov_method == "finite_difference" + or cov_method == "automatic_differentiation_kaug" + ): + self.assertAlmostEqual( + cov.iloc[asymptote_index, asymptote_index], 0.009588, places=4 + ) + self.assertAlmostEqual( + cov.iloc[asymptote_index, rate_constant_index], -0.000665, places=4 + ) + self.assertAlmostEqual( + cov.iloc[rate_constant_index, asymptote_index], -0.000665, places=4 + ) + self.assertAlmostEqual( + cov.iloc[rate_constant_index, rate_constant_index], 0.000063, places=4 + ) + else: + self.assertAlmostEqual( + cov.iloc[asymptote_index, asymptote_index], 0.056845, places=4 + ) + self.assertAlmostEqual( + cov.iloc[asymptote_index, rate_constant_index], -0.003927, places=4 + ) + self.assertAlmostEqual( + cov.iloc[rate_constant_index, asymptote_index], -0.003927, places=4 + ) + self.assertAlmostEqual( + cov.iloc[rate_constant_index, rate_constant_index], 0.000375, places=4 + ) + + + # test and check the covariance calculation for all the three supported methods + # added an 'unsupported_method' to test the error message when the method supplied is not supported + @parameterized.expand([("finite_difference"), ("automatic_differentiation_kaug"), ("reduced_hessian"), + ("unsupported_method")]) def test_parmest_cov(self, cov_method): """ - Calculates the parameter estimates and covariance matrix, and compares them with the results of Rooney-Biegler - """ - if self.objective_function == "SSE": - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) - - # estimate the parameters - objval, thetavals = pest.theta_est() + Calculates the parameter estimates and covariance matrix and compares them with the results of Rooney-Biegler - # calculate the covariance matrix - cov = pest.cov_est(cov_n=6, method=cov_method) - - # check the results - self.check_rooney_biegler_results(objval, cov, cov_method) + Argument: + cov_method: string ``method`` object specified by the user + options - 'finite_difference', 'reduced_hessian', and 'automatic_differentiation_kaug' + """ + if self.measurement_std is None: + if self.objective_function == "SSE": + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + + # estimate the parameters + obj_val, theta_vals = pest.theta_est() + + # check the parameter estimation result + self.check_rooney_biegler_parameters(obj_val, theta_vals, obj_function=self.objective_function, + measurement_error=self.measurement_std) + + # calculate the covariance matrix + if cov_method in ("finite_difference", "automatic_differentiation_kaug", "reduced_hessian"): + cov = pest.cov_est(cov_n=6, method=cov_method) + + # check the covariance calculation results + self.check_rooney_biegler_covariance(cov, cov_method, obj_function=self.objective_function, + measurement_error=self.measurement_std) + else: + with pytest.raises(ValueError, + match=r"Invalid method: 'unsupported_method'\. Choose from \['finite_difference', " + "'automatic_differentiation_kaug', 'reduced_hessian'\]\."): + cov = pest.cov_est(cov_n=6, method=cov_method) + elif self.objective_function == "SSE_weighted": + with pytest.raises(ValueError, match='One or more values are missing from ' + '"measurement_error". All values of the measurement errors are ' + 'required for the "SSE_weighted" objective.'): + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + + # we expect this error when estimating the parameters + obj_val, theta_vals = pest.theta_est() + else: + with pytest.raises(ValueError, match=r"Invalid objective function: 'incorrect_obj'\. " + r"Choose from \['SSE', 'SSE_weighted'\]\."): + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) else: - pass - - # def test_parmest_cov(self): - # """ - # Calculates the parameter estimates and covariance matrix, and compares them with the results of Rooney-Biegler - # """ - # pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) - # - # # estimate the parameters - # objval, thetavals = pest.theta_est() - # - # # calculate the covariance matrix using the three supported methods - # cov_methods = [ - # "finite_difference", - # "automatic_differentiation_kaug", - # "reduced_hessian", - # ] - # for method in cov_methods: - # cov = pest.cov_est(cov_n=6, method=method) - # - # self.check_rooney_biegler_results(objval, cov, method) + if self.objective_function == "SSE" or self.objective_function == "SSE_weighted": + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + + # estimate the parameters + obj_val, theta_vals = pest.theta_est() + + # check the parameter estimation results + self.check_rooney_biegler_parameters(obj_val, theta_vals, obj_function=self.objective_function, + measurement_error=self.measurement_std) + + # calculate the covariance matrix + if cov_method in ("finite_difference", "automatic_differentiation_kaug", "reduced_hessian"): + cov = pest.cov_est(cov_n=6, method=cov_method) + + # check the covariance calculation results + self.check_rooney_biegler_covariance(cov, cov_method, obj_function=self.objective_function, + measurement_error=self.measurement_std) + else: + with pytest.raises(ValueError, + match=r"Invalid method: 'unsupported_method'\. Choose from \['finite_difference', " + "'automatic_differentiation_kaug', 'reduced_hessian'\]\."): + cov = pest.cov_est(cov_n=6, method=cov_method) + else: + with pytest.raises(ValueError, match="Invalid objective function: 'incorrect_obj'\. " + "Choose from \['SSE', 'SSE_weighted'\]\."): + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) def test_cov_scipy_least_squares_comparison(self): """ @@ -290,18 +367,28 @@ def residual(theta, t, y): # calculate residuals r = residual(theta_hat, t, y) - # calculate variance of the residuals - # -2 because there are 2 fitted parameters - sigre = np.matmul(r.T, r / (len(y) - 2)) + if self.measurement_std is None: + # calculate variance of the residuals + # -2 because there are 2 fitted parameters + sigre = np.matmul(r.T, r / (len(y) - 2)) + + # approximate covariance + cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) - # approximate covariance - # Need to divide by 2 because optimize.least_squares scaled the objective by 1/2 - cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) + self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper + self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + else: + # use the user-supplied measurement error standard deviation + sigre = self.measurement_std ** 2 - self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper - self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) + + self.assertAlmostEqual(cov[0, 0], 0.009588, places=4) + self.assertAlmostEqual(cov[0, 1], -0.000665, places=4) + self.assertAlmostEqual(cov[1, 0], -0.000665, places=4) + self.assertAlmostEqual(cov[1, 1], 0.000063, places=4) def test_cov_scipy_curve_fit_comparison(self): """ @@ -320,17 +407,32 @@ def model(t, asymptote, rate_constant): # define initial guess theta_guess = np.array([15, 0.5]) - theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess) + # estimate the parameters and covariance matrix + if self.measurement_std is None: + theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess) - self.assertAlmostEqual( - theta_hat[0], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper + self.assertAlmostEqual( + theta_hat[0], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper - self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper - self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper + self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + else: + theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess, sigma=self.measurement_std, + absolute_sigma=True) + + self.assertAlmostEqual( + theta_hat[0], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper + + self.assertAlmostEqual(cov[0, 0], 0.0095875, places=4) + self.assertAlmostEqual(cov[0, 1], -0.0006653, places=4) + self.assertAlmostEqual(cov[1, 0], -0.0006653, places=4) + self.assertAlmostEqual(cov[1, 1], 0.00006347, places=4) if __name__ == "__main__": diff --git a/pyomo/contrib/parmest/tests/test_parmest.py b/pyomo/contrib/parmest/tests/test_parmest.py index a6a549757f7..db33b07a9d5 100644 --- a/pyomo/contrib/parmest/tests/test_parmest.py +++ b/pyomo/contrib/parmest/tests/test_parmest.py @@ -9,12 +9,13 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import platform import sys import os import subprocess from itertools import product +import pytest +from parameterized import parameterized, parameterized_class import pyomo.common.unittest as unittest import pyomo.contrib.parmest.parmest as parmest import pyomo.contrib.parmest.graphics as graphics @@ -26,148 +27,450 @@ from pyomo.common.fileutils import this_file_dir from pyomo.contrib.parmest.experiment import Experiment from pyomo.contrib.pynumero.asl import AmplInterface -from pyomo.opt import SolverFactory -is_osx = platform.mac_ver()[0] != "" -ipopt_available = SolverFactory("ipopt").available() +ipopt_available = pyo.SolverFactory("ipopt").available() pynumero_ASL_available = AmplInterface.available() testdir = this_file_dir() - +# Test class for the built-in "SSE" and "SSE_weighted" objective functions +# validated the results using the Rooney-Biegler example +# Rooney-Biegler example is the case when the measurement error is None +# we considered another case when the user supplies the value of the measurement error @unittest.skipIf( not parmest.parmest_available, "Cannot test parmest: required dependencies are missing", ) @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") + +# we use parameterized_class to test the two objective functions over the two cases of measurement error +# included a third objective function to test the error message when an incorrect objective function is supplied +@parameterized_class(("measurement_std", "objective_function"), [(None, "SSE"), (None, "SSE_weighted"), + (None, "incorrect_obj"), (0.1, "SSE"), (0.1, "SSE_weighted"), (0.1, "incorrect_obj")]) class TestRooneyBiegler(unittest.TestCase): - def setUp(self): - from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import ( - RooneyBieglerExperiment, - ) - # Note, the data used in this test has been corrected to use - # data.loc[5,'hour'] = 7 (instead of 6) - data = pd.DataFrame( + def setUp(self): + self.data = pd.DataFrame( data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], columns=["hour", "y"], ) - # Sum of squared error function - def SSE(model): - expr = ( - model.experiment_outputs[model.y] - - model.response_function[model.experiment_outputs[model.hour]] - ) ** 2 - return expr + # create the Rooney-Biegler model + def rooney_biegler_model(): + """ + Formulates the Pyomo model of the Rooney-Biegler example + + Returns: + m: Pyomo model + """ + m = pyo.ConcreteModel() - # Create an experiment list - exp_list = [] - for i in range(data.shape[0]): - exp_list.append(RooneyBieglerExperiment(data.loc[i, :])) + m.asymptote = pyo.Var(within=pyo.NonNegativeReals, initialize=10) + m.rate_constant = pyo.Var(within=pyo.NonNegativeReals, initialize=0.2) - # Create an instance of the parmest estimator - pest = parmest.Estimator(exp_list, obj_function=SSE) + m.hour = pyo.Var(within=pyo.PositiveReals, initialize=0.1) + m.y = pyo.Var(within=pyo.NonNegativeReals) - solver_options = {"tol": 1e-8} + @m.Constraint() + def response_rule(m): + return m.y == m.asymptote * (1 - pyo.exp(-m.rate_constant * m.hour)) - self.data = data - self.pest = parmest.Estimator( - exp_list, obj_function=SSE, solver_options=solver_options, tee=True - ) + return m + + # create the Experiment class + class RooneyBieglerExperiment(Experiment): + def __init__(self, experiment_number, hour, y, measurement_error_std): + self.y = y + self.hour = hour + self.experiment_number = experiment_number + self.model = None + self.measurement_error_std = measurement_error_std + + def get_labeled_model(self): + if self.model is None: + self.create_model() + self.finalize_model() + self.label_model() + return self.model + + def create_model(self): + m = self.model = rooney_biegler_model() + + return m + + def finalize_model(self): + m = self.model + + # fix the input variable + m.hour.fix(self.hour) + + return m + + def label_model(self): + m = self.model + + # add experiment inputs + m.experiment_inputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.experiment_inputs.update([(m.hour, self.hour)]) + + # add experiment outputs + m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.experiment_outputs.update([(m.y, self.y)]) + + # add unknown parameters + m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.unknown_parameters.update( + (k, pyo.value(k)) for k in [m.asymptote, m.rate_constant] + ) + + # add measurement error + m.measurement_error = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.measurement_error.update([(m.y, self.measurement_error_std)]) + + return m + + # extract the input and output variables + hour_data = self.data["hour"] + y_data = self.data["y"] + + # create the experiments list + rooney_biegler_exp_list = [] + for i in range(self.data.shape[0]): + rooney_biegler_exp_list.append( + RooneyBieglerExperiment(i, hour_data[i], y_data[i], self.measurement_std) + ) + + self.exp_list = rooney_biegler_exp_list - def test_theta_est(self): - objval, thetavals = self.pest.theta_est() - self.assertAlmostEqual(objval, 4.3317112, places=2) + def check_rooney_biegler_parameters(self, obj_val, theta_vals, obj_function, measurement_error): + """ + Checks if the objective value and parameter estimates are equal to the expected values + and agree with the results of the Rooney-Biegler paper + + Argument: + obj_val: the objective value of the annotated Pyomo model + theta_vals: dictionary of the estimated parameters + obj_function: a string of the objective function supplied by the user, e.g., 'SSE' + measurement_error: float or integer value of the measurement error standard deviation + """ + if obj_function == "SSE": + self.assertAlmostEqual(obj_val, 4.33171, places=2) + elif obj_function == "SSE_weighted" and measurement_error is not None: + self.assertAlmostEqual(obj_val, 216.58556, places=2) + self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 + theta_vals["asymptote"], 19.1426, places=2 ) # 19.1426 from the paper self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 + theta_vals["rate_constant"], 0.5311, places=2 ) # 0.5311 from the paper + + def check_rooney_biegler_covariance(self, cov, cov_method, obj_function, measurement_error): + """ + Checks if the covariance matrix elements are equal to the expected values + and agree with the results of the Rooney-Biegler paper + + Argument: + cov: pd.DataFrame, covariance matrix of the estimated parameters + cov_method: string ``method`` object specified by the user + options - 'finite_difference', 'reduced_hessian', and 'automatic_differentiation_kaug' + obj_function: a string of the objective function supplied by the user, e.g., 'SSE' + measurement_error: float or integer value of the measurement error standard deviation + """ + + # get indices in covariance matrix + cov_cols = cov.columns.to_list() + asymptote_index = [idx for idx, s in enumerate(cov_cols) if "asymptote" in s][0] + rate_constant_index = [ + idx for idx, s in enumerate(cov_cols) if "rate_constant" in s + ][0] + + if measurement_error is None: + if obj_function == "SSE": + if ( + cov_method == "finite_difference" + or cov_method == "automatic_differentiation_kaug" + ): + self.assertAlmostEqual( + cov.iloc[asymptote_index, asymptote_index], 6.229612, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[asymptote_index, rate_constant_index], -0.432265, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, asymptote_index], -0.432265, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, rate_constant_index], 0.041242, places=2 + ) # 0.04124 from paper + else: + self.assertAlmostEqual( + cov.iloc[asymptote_index, asymptote_index], 36.935351, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[asymptote_index, rate_constant_index], -2.551392, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, asymptote_index], -2.551392, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, rate_constant_index], 0.243428, places=2 + ) # 0.04124 from paper + else: + if obj_function == "SSE" or obj_function == "SSE_weighted": + if ( + cov_method == "finite_difference" + or cov_method == "automatic_differentiation_kaug" + ): + self.assertAlmostEqual( + cov.iloc[asymptote_index, asymptote_index], 0.009588, places=4 + ) + self.assertAlmostEqual( + cov.iloc[asymptote_index, rate_constant_index], -0.000665, places=4 + ) + self.assertAlmostEqual( + cov.iloc[rate_constant_index, asymptote_index], -0.000665, places=4 + ) + self.assertAlmostEqual( + cov.iloc[rate_constant_index, rate_constant_index], 0.000063, places=4 + ) + else: + self.assertAlmostEqual( + cov.iloc[asymptote_index, asymptote_index], 0.056845, places=4 + ) + self.assertAlmostEqual( + cov.iloc[asymptote_index, rate_constant_index], -0.003927, places=4 + ) + self.assertAlmostEqual( + cov.iloc[rate_constant_index, asymptote_index], -0.003927, places=4 + ) + self.assertAlmostEqual( + cov.iloc[rate_constant_index, rate_constant_index], 0.000375, places=4 + ) + + + # test and check the covariance calculation for all the three supported methods + # added an 'unsupported_method' to test the error message when the method supplied is not supported + @parameterized.expand([("finite_difference"), ("automatic_differentiation_kaug"), ("reduced_hessian"), + ("unsupported_method")]) + def test_parmest_covariance(self, cov_method): + """ + Calculates the parameter estimates and covariance matrix and compares them with the results of Rooney-Biegler + + Argument: + cov_method: string ``method`` object specified by the user + options - 'finite_difference', 'reduced_hessian', and 'automatic_differentiation_kaug' + """ + if self.measurement_std is None: + if self.objective_function == "SSE": + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + + # estimate the parameters + obj_val, theta_vals = pest.theta_est() + + # check the parameter estimation result + self.check_rooney_biegler_parameters(obj_val, theta_vals, obj_function=self.objective_function, + measurement_error=self.measurement_std) + + # calculate the covariance matrix + if cov_method in ("finite_difference", "automatic_differentiation_kaug", "reduced_hessian"): + cov = pest.cov_est(cov_n=6, method=cov_method) + + # check the covariance calculation results + self.check_rooney_biegler_covariance(cov, cov_method, obj_function=self.objective_function, + measurement_error=self.measurement_std) + else: + with pytest.raises(ValueError, + match=r"Invalid method: 'unsupported_method'\. Choose from \['finite_difference', " + "'automatic_differentiation_kaug', 'reduced_hessian'\]\."): + cov = pest.cov_est(cov_n=6, method=cov_method) + elif self.objective_function == "SSE_weighted": + with pytest.raises(ValueError, match='One or more values are missing from ' + '"measurement_error". All values of the measurement errors are ' + 'required for the "SSE_weighted" objective.'): + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + + # we expect this error when estimating the parameters + obj_val, theta_vals = pest.theta_est() + else: + with pytest.raises(ValueError, match=r"Invalid objective function: 'incorrect_obj'\. " + r"Choose from \['SSE', 'SSE_weighted'\]\."): + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + else: + if self.objective_function == "SSE" or self.objective_function == "SSE_weighted": + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + + # estimate the parameters + obj_val, theta_vals = pest.theta_est() + + # check the parameter estimation results + self.check_rooney_biegler_parameters(obj_val, theta_vals, obj_function=self.objective_function, + measurement_error=self.measurement_std) + + # calculate the covariance matrix + if cov_method in ("finite_difference", "automatic_differentiation_kaug", "reduced_hessian"): + cov = pest.cov_est(cov_n=6, method=cov_method) + + # check the covariance calculation results + self.check_rooney_biegler_covariance(cov, cov_method, obj_function=self.objective_function, + measurement_error=self.measurement_std) + else: + with pytest.raises(ValueError, + match=r"Invalid method: 'unsupported_method'\. Choose from \['finite_difference', " + "'automatic_differentiation_kaug', 'reduced_hessian'\]\."): + cov = pest.cov_est(cov_n=6, method=cov_method) + else: + with pytest.raises(ValueError, match="Invalid objective function: 'incorrect_obj'\. " + "Choose from \['SSE', 'SSE_weighted'\]\."): + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + @unittest.skipIf( not graphics.imports_available, "parmest.graphics imports are unavailable" ) def test_bootstrap(self): - objval, thetavals = self.pest.theta_est() + if self.objective_function in ("SSE", "SSE_weighted"): + if self.objective_function == "SSE_weighted" and self.measurement_std is None: + with pytest.raises(ValueError, match='One or more values are missing from ' + '"measurement_error". All values of the measurement errors are ' + 'required for the "SSE_weighted" objective.'): + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + + # we expect this error when estimating the parameters + obj_val, theta_vals = pest.theta_est() + else: + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + + obj_val, theta_vals = pest.theta_est() - num_bootstraps = 10 - theta_est = self.pest.theta_est_bootstrap(num_bootstraps, return_samples=True) + num_bootstraps = 10 + theta_est = pest.theta_est_bootstrap(num_bootstraps, return_samples=True) - num_samples = theta_est["samples"].apply(len) - self.assertEqual(len(theta_est.index), 10) - self.assertTrue(num_samples.equals(pd.Series([6] * 10))) + num_samples = theta_est["samples"].apply(len) + self.assertEqual(len(theta_est.index), 10) + self.assertTrue(num_samples.equals(pd.Series([6] * 10))) - del theta_est["samples"] + del theta_est["samples"] - # apply confidence region test - CR = self.pest.confidence_region_test(theta_est, "MVN", [0.5, 0.75, 1.0]) + # apply confidence region test + CR = pest.confidence_region_test(theta_est, "MVN", [0.5, 0.75, 1.0]) - self.assertTrue(set(CR.columns) >= set([0.5, 0.75, 1.0])) - self.assertEqual(CR[0.5].sum(), 5) - self.assertEqual(CR[0.75].sum(), 7) - self.assertEqual(CR[1.0].sum(), 10) # all true + self.assertTrue(set(CR.columns) >= set([0.5, 0.75, 1.0])) + self.assertEqual(CR[0.5].sum(), 5) + self.assertEqual(CR[0.75].sum(), 7) + self.assertEqual(CR[1.0].sum(), 10) # all true - graphics.pairwise_plot(theta_est) - graphics.pairwise_plot(theta_est, thetavals) - graphics.pairwise_plot(theta_est, thetavals, 0.8, ["MVN", "KDE", "Rect"]) + graphics.pairwise_plot(theta_est) + graphics.pairwise_plot(theta_est, theta_vals) + graphics.pairwise_plot(theta_est, theta_vals, 0.8, ["MVN", "KDE", "Rect"]) + else: + with pytest.raises(ValueError, match="Invalid objective function: 'incorrect_obj'\. " + "Choose from \['SSE', 'SSE_weighted'\]\."): + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) @unittest.skipIf( not graphics.imports_available, "parmest.graphics imports are unavailable" ) def test_likelihood_ratio(self): - objval, thetavals = self.pest.theta_est() + if self.objective_function in ("SSE", "SSE_weighted"): + if self.objective_function == "SSE_weighted" and self.measurement_std is None: + with pytest.raises(ValueError, match='One or more values are missing from ' + '"measurement_error". All values of the measurement errors are ' + 'required for the "SSE_weighted" objective.'): + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + + # we expect this error when estimating the parameters + obj_val, theta_vals = pest.theta_est() + else: + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) - asym = np.arange(10, 30, 2) - rate = np.arange(0, 1.5, 0.25) - theta_vals = pd.DataFrame( - list(product(asym, rate)), columns=['asymptote', 'rate_constant'] - ) - obj_at_theta = self.pest.objective_at_theta(theta_vals) + obj_val, theta_vals = pest.theta_est() + + asym = np.arange(10, 30, 2) + rate = np.arange(0, 1.5, 0.25) + theta_vals = pd.DataFrame( + list(product(asym, rate)), columns=['asymptote', 'rate_constant'] + ) + obj_at_theta = pest.objective_at_theta(theta_vals) - LR = self.pest.likelihood_ratio_test(obj_at_theta, objval, [0.8, 0.9, 1.0]) + LR = pest.likelihood_ratio_test(obj_at_theta, obj_val, [0.8, 0.9, 1.0]) - self.assertTrue(set(LR.columns) >= set([0.8, 0.9, 1.0])) - self.assertEqual(LR[0.8].sum(), 6) - self.assertEqual(LR[0.9].sum(), 10) - self.assertEqual(LR[1.0].sum(), 60) # all true + self.assertTrue(set(LR.columns) >= set([0.8, 0.9, 1.0])) + self.assertEqual(LR[0.8].sum(), 6) + self.assertEqual(LR[0.9].sum(), 10) + self.assertEqual(LR[1.0].sum(), 60) # all true - graphics.pairwise_plot(LR, thetavals, 0.8) + graphics.pairwise_plot(LR, theta_vals, 0.8) + else: + with pytest.raises(ValueError, match="Invalid objective function: 'incorrect_obj'\. " + "Choose from \['SSE', 'SSE_weighted'\]\."): + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) def test_leaveNout(self): - lNo_theta = self.pest.theta_est_leaveNout(1) - self.assertTrue(lNo_theta.shape == (6, 2)) + if self.objective_function in ("SSE", "SSE_weighted"): + if self.objective_function == "SSE_weighted" and self.measurement_std is None: + with pytest.raises(ValueError, match='One or more values are missing from ' + '"measurement_error". All values of the measurement errors are ' + 'required for the "SSE_weighted" objective.'): + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + + # we expect this error when estimating the parameters + obj_val, theta_vals = pest.theta_est() + else: + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) - results = self.pest.leaveNout_bootstrap_test( - 1, None, 3, "Rect", [0.5, 1.0], seed=5436 - ) - self.assertEqual(len(results), 6) # 6 lNo samples - i = 1 - samples = results[i][0] # list of N samples that are left out - lno_theta = results[i][1] - bootstrap_theta = results[i][2] - self.assertTrue(samples == [1]) # sample 1 was left out - self.assertEqual(lno_theta.shape[0], 1) # lno estimate for sample 1 - self.assertTrue(set(lno_theta.columns) >= set([0.5, 1.0])) - self.assertEqual(lno_theta[1.0].sum(), 1) # all true - self.assertEqual(bootstrap_theta.shape[0], 3) # bootstrap for sample 1 - self.assertEqual(bootstrap_theta[1.0].sum(), 3) # all true + lNo_theta = pest.theta_est_leaveNout(1) + self.assertTrue(lNo_theta.shape == (6, 2)) + + results = pest.leaveNout_bootstrap_test( + 1, None, 3, "Rect", [0.5, 1.0], seed=5436 + ) + self.assertEqual(len(results), 6) # 6 lNo samples + i = 1 + samples = results[i][0] # list of N samples that are left out + lno_theta = results[i][1] + bootstrap_theta = results[i][2] + self.assertTrue(samples == [1]) # sample 1 was left out + self.assertEqual(lno_theta.shape[0], 1) # lno estimate for sample 1 + self.assertTrue(set(lno_theta.columns) >= set([0.5, 1.0])) + self.assertEqual(lno_theta[1.0].sum(), 1) # all true + self.assertEqual(bootstrap_theta.shape[0], 3) # bootstrap for sample 1 + self.assertEqual(bootstrap_theta[1.0].sum(), 3) # all true + else: + with pytest.raises(ValueError, match="Invalid objective function: 'incorrect_obj'\. " + "Choose from \['SSE', 'SSE_weighted'\]\."): + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) def test_diagnostic_mode(self): - self.pest.diagnostic_mode = True + if self.objective_function in ("SSE", "SSE_weighted"): + if self.objective_function == "SSE_weighted" and self.measurement_std is None: + with pytest.raises(ValueError, match='One or more values are missing from ' + '"measurement_error". All values of the measurement errors are ' + 'required for the "SSE_weighted" objective.'): + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + + # we expect this error when estimating the parameters + obj_val, theta_vals = pest.theta_est() + else: + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) - objval, thetavals = self.pest.theta_est() + pest.diagnostic_mode = True - asym = np.arange(10, 30, 2) - rate = np.arange(0, 1.5, 0.25) - theta_vals = pd.DataFrame( - list(product(asym, rate)), columns=['asymptote', 'rate_constant'] - ) + obj_val, theta_vals = pest.theta_est() + + asym = np.arange(10, 30, 2) + rate = np.arange(0, 1.5, 0.25) + theta_vals = pd.DataFrame( + list(product(asym, rate)), columns=['asymptote', 'rate_constant'] + ) - obj_at_theta = self.pest.objective_at_theta(theta_vals) + obj_at_theta = pest.objective_at_theta(theta_vals) - self.pest.diagnostic_mode = False + pest.diagnostic_mode = False + else: + with pytest.raises(ValueError, match="Invalid objective function: 'incorrect_obj'\. " + "Choose from \['SSE', 'SSE_weighted'\]\."): + pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) @unittest.skip("Presently having trouble with mpiexec on appveyor") def test_parallel_parmest(self): @@ -194,40 +497,6 @@ def test_parallel_parmest(self): retcode = subprocess.call(rlist) self.assertEqual(retcode, 0) - @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") - def test_theta_est_cov(self): - objval, thetavals, cov = self.pest.theta_est(calc_cov=True, cov_n=6) - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper - - # Covariance matrix - self.assertAlmostEqual( - cov["asymptote"]["asymptote"], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov["asymptote"]["rate_constant"], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov["rate_constant"]["asymptote"], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov["rate_constant"]["rate_constant"], 0.04124, places=2 - ) # 0.04124 from paper - - """ Why does the covariance matrix from parmest not match the paper? Parmest is - calculating the exact reduced Hessian. The paper (Rooney and Bielger, 2001) likely - employed the first order approximation common for nonlinear regression. The paper - values were verified with Scipy, which uses the same first order approximation. - The formula used in parmest was verified against equations (7-5-15) and (7-5-16) in - "Nonlinear Parameter Estimation", Y. Bard, 1974. - """ - def test_cov_scipy_least_squares_comparison(self): """ Scipy results differ in the 3rd decimal place from the paper. It is possible @@ -280,18 +549,28 @@ def residual(theta, t, y): # calculate residuals r = residual(theta_hat, t, y) - # calculate variance of the residuals - # -2 because there are 2 fitted parameters - sigre = np.matmul(r.T, r / (len(y) - 2)) + if self.measurement_std is None: + # calculate variance of the residuals + # -2 because there are 2 fitted parameters + sigre = np.matmul(r.T, r / (len(y) - 2)) + + # approximate covariance + cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) + + self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper + self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + else: + # use the user-supplied measurement error standard deviation + sigre = self.measurement_std ** 2 - # approximate covariance - # Need to divide by 2 because optimize.least_squares scaled the objective by 1/2 - cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) + cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) - self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper - self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + self.assertAlmostEqual(cov[0, 0], 0.009588, places=4) + self.assertAlmostEqual(cov[0, 1], -0.000665, places=4) + self.assertAlmostEqual(cov[1, 0], -0.000665, places=4) + self.assertAlmostEqual(cov[1, 1], 0.000063, places=4) def test_cov_scipy_curve_fit_comparison(self): """ @@ -310,1722 +589,32 @@ def model(t, asymptote, rate_constant): # define initial guess theta_guess = np.array([15, 0.5]) - theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess) - - self.assertAlmostEqual( - theta_hat[0], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper - - self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper - self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper - - -@unittest.skipIf( - not parmest.parmest_available, - "Cannot test parmest: required dependencies are missing", -) -@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") -class TestModelVariants(unittest.TestCase): - - def setUp(self): - from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import ( - RooneyBieglerExperiment, - ) - - self.data = pd.DataFrame( - data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], - columns=["hour", "y"], - ) - - def rooney_biegler_params(data): - model = pyo.ConcreteModel() - - model.asymptote = pyo.Param(initialize=15, mutable=True) - model.rate_constant = pyo.Param(initialize=0.5, mutable=True) - - model.hour = pyo.Param(within=pyo.PositiveReals, mutable=True) - model.y = pyo.Param(within=pyo.PositiveReals, mutable=True) - - def response_rule(m, h): - expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) - return expr - - model.response_function = pyo.Expression(data.hour, rule=response_rule) - - return model - - class RooneyBieglerExperimentParams(RooneyBieglerExperiment): - - def create_model(self): - data_df = self.data.to_frame().transpose() - self.model = rooney_biegler_params(data_df) - - rooney_biegler_params_exp_list = [] - for i in range(self.data.shape[0]): - rooney_biegler_params_exp_list.append( - RooneyBieglerExperimentParams(self.data.loc[i, :]) - ) - - def rooney_biegler_indexed_params(data): - model = pyo.ConcreteModel() - - model.param_names = pyo.Set(initialize=["asymptote", "rate_constant"]) - model.theta = pyo.Param( - model.param_names, - initialize={"asymptote": 15, "rate_constant": 0.5}, - mutable=True, - ) - - model.hour = pyo.Param(within=pyo.PositiveReals, mutable=True) - model.y = pyo.Param(within=pyo.PositiveReals, mutable=True) - - def response_rule(m, h): - expr = m.theta["asymptote"] * ( - 1 - pyo.exp(-m.theta["rate_constant"] * h) - ) - return expr - - model.response_function = pyo.Expression(data.hour, rule=response_rule) - - return model - - class RooneyBieglerExperimentIndexedParams(RooneyBieglerExperiment): - - def create_model(self): - data_df = self.data.to_frame().transpose() - self.model = rooney_biegler_indexed_params(data_df) - - def label_model(self): - - m = self.model - - m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) - m.experiment_outputs.update( - [(m.hour, self.data["hour"]), (m.y, self.data["y"])] - ) - - m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) - m.unknown_parameters.update((k, pyo.ComponentUID(k)) for k in [m.theta]) - - rooney_biegler_indexed_params_exp_list = [] - for i in range(self.data.shape[0]): - rooney_biegler_indexed_params_exp_list.append( - RooneyBieglerExperimentIndexedParams(self.data.loc[i, :]) - ) - - def rooney_biegler_vars(data): - model = pyo.ConcreteModel() - - model.asymptote = pyo.Var(initialize=15) - model.rate_constant = pyo.Var(initialize=0.5) - model.asymptote.fixed = True # parmest will unfix theta variables - model.rate_constant.fixed = True - - model.hour = pyo.Param(within=pyo.PositiveReals, mutable=True) - model.y = pyo.Param(within=pyo.PositiveReals, mutable=True) - - def response_rule(m, h): - expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) - return expr - - model.response_function = pyo.Expression(data.hour, rule=response_rule) - - return model - - class RooneyBieglerExperimentVars(RooneyBieglerExperiment): - - def create_model(self): - data_df = self.data.to_frame().transpose() - self.model = rooney_biegler_vars(data_df) - - rooney_biegler_vars_exp_list = [] - for i in range(self.data.shape[0]): - rooney_biegler_vars_exp_list.append( - RooneyBieglerExperimentVars(self.data.loc[i, :]) - ) - - def rooney_biegler_indexed_vars(data): - model = pyo.ConcreteModel() - - model.var_names = pyo.Set(initialize=["asymptote", "rate_constant"]) - model.theta = pyo.Var( - model.var_names, initialize={"asymptote": 15, "rate_constant": 0.5} - ) - model.theta["asymptote"].fixed = ( - True # parmest will unfix theta variables, even when they are indexed - ) - model.theta["rate_constant"].fixed = True - - model.hour = pyo.Param(within=pyo.PositiveReals, mutable=True) - model.y = pyo.Param(within=pyo.PositiveReals, mutable=True) - - def response_rule(m, h): - expr = m.theta["asymptote"] * ( - 1 - pyo.exp(-m.theta["rate_constant"] * h) - ) - return expr - - model.response_function = pyo.Expression(data.hour, rule=response_rule) - - return model - - class RooneyBieglerExperimentIndexedVars(RooneyBieglerExperiment): - - def create_model(self): - data_df = self.data.to_frame().transpose() - self.model = rooney_biegler_indexed_vars(data_df) - - def label_model(self): - - m = self.model - - m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) - m.experiment_outputs.update( - [(m.hour, self.data["hour"]), (m.y, self.data["y"])] - ) - - m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) - m.unknown_parameters.update((k, pyo.ComponentUID(k)) for k in [m.theta]) - - rooney_biegler_indexed_vars_exp_list = [] - for i in range(self.data.shape[0]): - rooney_biegler_indexed_vars_exp_list.append( - RooneyBieglerExperimentIndexedVars(self.data.loc[i, :]) - ) - - # Sum of squared error function - def SSE(model): - expr = ( - model.experiment_outputs[model.y] - - model.response_function[model.experiment_outputs[model.hour]] - ) ** 2 - return expr - - self.objective_function = SSE - - theta_vals = pd.DataFrame([20, 1], index=["asymptote", "rate_constant"]).T - theta_vals_index = pd.DataFrame( - [20, 1], index=["theta['asymptote']", "theta['rate_constant']"] - ).T - - self.input = { - "param": { - "exp_list": rooney_biegler_params_exp_list, - "theta_names": ["asymptote", "rate_constant"], - "theta_vals": theta_vals, - }, - "param_index": { - "exp_list": rooney_biegler_indexed_params_exp_list, - "theta_names": ["theta"], - "theta_vals": theta_vals_index, - }, - "vars": { - "exp_list": rooney_biegler_vars_exp_list, - "theta_names": ["asymptote", "rate_constant"], - "theta_vals": theta_vals, - }, - "vars_index": { - "exp_list": rooney_biegler_indexed_vars_exp_list, - "theta_names": ["theta"], - "theta_vals": theta_vals_index, - }, - "vars_quoted_index": { - "exp_list": rooney_biegler_indexed_vars_exp_list, - "theta_names": ["theta['asymptote']", "theta['rate_constant']"], - "theta_vals": theta_vals_index, - }, - "vars_str_index": { - "exp_list": rooney_biegler_indexed_vars_exp_list, - "theta_names": ["theta[asymptote]", "theta[rate_constant]"], - "theta_vals": theta_vals_index, - }, - } - - @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") - def check_rooney_biegler_results(self, objval, cov): - - # get indices in covariance matrix - cov_cols = cov.columns.to_list() - asymptote_index = [idx for idx, s in enumerate(cov_cols) if "asymptote" in s][0] - rate_constant_index = [ - idx for idx, s in enumerate(cov_cols) if "rate_constant" in s - ][0] - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - cov.iloc[asymptote_index, asymptote_index], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[asymptote_index, rate_constant_index], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[rate_constant_index, asymptote_index], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[rate_constant_index, rate_constant_index], 0.04193591, places=2 - ) # 0.04124 from paper - - @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') - def test_parmest_basics(self): - - for model_type, parmest_input in self.input.items(): - pest = parmest.Estimator( - parmest_input["exp_list"], obj_function=self.objective_function - ) - - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - self.check_rooney_biegler_results(objval, cov) - - obj_at_theta = pest.objective_at_theta(parmest_input["theta_vals"]) - self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) - - @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') - def test_parmest_basics_with_initialize_parmest_model_option(self): - - for model_type, parmest_input in self.input.items(): - pest = parmest.Estimator( - parmest_input["exp_list"], obj_function=self.objective_function - ) - - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - self.check_rooney_biegler_results(objval, cov) - - obj_at_theta = pest.objective_at_theta( - parmest_input["theta_vals"], initialize_parmest_model=True - ) - - self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) - - @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') - def test_parmest_basics_with_square_problem_solve(self): - - for model_type, parmest_input in self.input.items(): - pest = parmest.Estimator( - parmest_input["exp_list"], obj_function=self.objective_function - ) - - obj_at_theta = pest.objective_at_theta( - parmest_input["theta_vals"], initialize_parmest_model=True - ) - - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - self.check_rooney_biegler_results(objval, cov) - - self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) - - @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') - def test_parmest_basics_with_square_problem_solve_no_theta_vals(self): - - for model_type, parmest_input in self.input.items(): - - pest = parmest.Estimator( - parmest_input["exp_list"], obj_function=self.objective_function - ) - - obj_at_theta = pest.objective_at_theta(initialize_parmest_model=True) - - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - self.check_rooney_biegler_results(objval, cov) - + # estimate the parameters and covariance matrix + if self.measurement_std is None: + theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess) -@unittest.skipIf( - not parmest.parmest_available, - "Cannot test parmest: required dependencies are missing", -) -@unittest.skipIf(not ipopt_available, "The 'ipopt' solver is not available") -class TestReactorDesign(unittest.TestCase): - def setUp(self): - from pyomo.contrib.parmest.examples.reactor_design.reactor_design import ( - ReactorDesignExperiment, - ) - - # Data from the design - data = pd.DataFrame( - data=[ - [1.05, 10000, 3458.4, 1060.8, 1683.9, 1898.5], - [1.10, 10000, 3535.1, 1064.8, 1613.3, 1893.4], - [1.15, 10000, 3609.1, 1067.8, 1547.5, 1887.8], - [1.20, 10000, 3680.7, 1070.0, 1486.1, 1881.6], - [1.25, 10000, 3750.0, 1071.4, 1428.6, 1875.0], - [1.30, 10000, 3817.1, 1072.2, 1374.6, 1868.0], - [1.35, 10000, 3882.2, 1072.4, 1324.0, 1860.7], - [1.40, 10000, 3945.4, 1072.1, 1276.3, 1853.1], - [1.45, 10000, 4006.7, 1071.3, 1231.4, 1845.3], - [1.50, 10000, 4066.4, 1070.1, 1189.0, 1837.3], - [1.55, 10000, 4124.4, 1068.5, 1148.9, 1829.1], - [1.60, 10000, 4180.9, 1066.5, 1111.0, 1820.8], - [1.65, 10000, 4235.9, 1064.3, 1075.0, 1812.4], - [1.70, 10000, 4289.5, 1061.8, 1040.9, 1803.9], - [1.75, 10000, 4341.8, 1059.0, 1008.5, 1795.3], - [1.80, 10000, 4392.8, 1056.0, 977.7, 1786.7], - [1.85, 10000, 4442.6, 1052.8, 948.4, 1778.1], - [1.90, 10000, 4491.3, 1049.4, 920.5, 1769.4], - [1.95, 10000, 4538.8, 1045.8, 893.9, 1760.8], - ], - columns=["sv", "caf", "ca", "cb", "cc", "cd"], - ) - - # Create an experiment list - exp_list = [] - for i in range(data.shape[0]): - exp_list.append(ReactorDesignExperiment(data, i)) - - solver_options = {"max_iter": 6000} - - self.pest = parmest.Estimator( - exp_list, obj_function="SSE", solver_options=solver_options - ) - - def test_theta_est(self): - # used in data reconciliation - objval, thetavals = self.pest.theta_est() - - self.assertAlmostEqual(thetavals["k1"], 5.0 / 6.0, places=4) - self.assertAlmostEqual(thetavals["k2"], 5.0 / 3.0, places=4) - self.assertAlmostEqual(thetavals["k3"], 1.0 / 6000.0, places=7) + self.assertAlmostEqual( + theta_hat[0], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper + + self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper + self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + else: + theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess, sigma=self.measurement_std, + absolute_sigma=True) - def test_return_values(self): - objval, thetavals, data_rec = self.pest.theta_est( - return_values=["ca", "cb", "cc", "cd", "caf"] - ) - self.assertAlmostEqual(data_rec["cc"].loc[18], 893.84924, places=3) - - -@unittest.skipIf( - not parmest.parmest_available, - "Cannot test parmest: required dependencies are missing", -) -@unittest.skipIf(not ipopt_available, "The 'ipopt' solver is not available") -class TestReactorDesign_DAE(unittest.TestCase): - # Based on a reactor example in `Chemical Reactor Analysis and Design Fundamentals`, - # https://sites.engineering.ucsb.edu/~jbraw/chemreacfun/ - # https://sites.engineering.ucsb.edu/~jbraw/chemreacfun/fig-html/appendix/fig-A-10.html - - def setUp(self): - def ABC_model(data): - ca_meas = data["ca"] - cb_meas = data["cb"] - cc_meas = data["cc"] - - if isinstance(data, pd.DataFrame): - meas_t = data.index # time index - else: # dictionary - meas_t = list(ca_meas.keys()) # nested dictionary - - ca0 = 1.0 - cb0 = 0.0 - cc0 = 0.0 - - m = pyo.ConcreteModel() - - m.k1 = pyo.Var(initialize=0.5, bounds=(1e-4, 10)) - m.k2 = pyo.Var(initialize=3.0, bounds=(1e-4, 10)) - - m.time = dae.ContinuousSet(bounds=(0.0, 5.0), initialize=meas_t) - - # initialization and bounds - m.ca = pyo.Var(m.time, initialize=ca0, bounds=(-1e-3, ca0 + 1e-3)) - m.cb = pyo.Var(m.time, initialize=cb0, bounds=(-1e-3, ca0 + 1e-3)) - m.cc = pyo.Var(m.time, initialize=cc0, bounds=(-1e-3, ca0 + 1e-3)) - - m.dca = dae.DerivativeVar(m.ca, wrt=m.time) - m.dcb = dae.DerivativeVar(m.cb, wrt=m.time) - m.dcc = dae.DerivativeVar(m.cc, wrt=m.time) - - def _dcarate(m, t): - if t == 0: - return pyo.Constraint.Skip - else: - return m.dca[t] == -m.k1 * m.ca[t] - - m.dcarate = pyo.Constraint(m.time, rule=_dcarate) - - def _dcbrate(m, t): - if t == 0: - return pyo.Constraint.Skip - else: - return m.dcb[t] == m.k1 * m.ca[t] - m.k2 * m.cb[t] - - m.dcbrate = pyo.Constraint(m.time, rule=_dcbrate) - - def _dccrate(m, t): - if t == 0: - return pyo.Constraint.Skip - else: - return m.dcc[t] == m.k2 * m.cb[t] - - m.dccrate = pyo.Constraint(m.time, rule=_dccrate) - - def ComputeFirstStageCost_rule(m): - return 0 - - m.FirstStageCost = pyo.Expression(rule=ComputeFirstStageCost_rule) - - def ComputeSecondStageCost_rule(m): - return sum( - (m.ca[t] - ca_meas[t]) ** 2 - + (m.cb[t] - cb_meas[t]) ** 2 - + (m.cc[t] - cc_meas[t]) ** 2 - for t in meas_t - ) - - m.SecondStageCost = pyo.Expression(rule=ComputeSecondStageCost_rule) - - def total_cost_rule(model): - return model.FirstStageCost + model.SecondStageCost - - m.Total_Cost_Objective = pyo.Objective( - rule=total_cost_rule, sense=pyo.minimize - ) - - disc = pyo.TransformationFactory("dae.collocation") - disc.apply_to(m, nfe=20, ncp=2) - - return m - - class ReactorDesignExperimentDAE(Experiment): - - def __init__(self, data): - - self.data = data - self.model = None - - def create_model(self): - self.model = ABC_model(self.data) - - def label_model(self): - - m = self.model - - m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) - m.unknown_parameters.update( - (k, pyo.ComponentUID(k)) for k in [m.k1, m.k2] - ) - - def get_labeled_model(self): - self.create_model() - self.label_model() - - return self.model - - # This example tests data formatted in 3 ways - # Each format holds 1 scenario - # 1. dataframe with time index - # 2. nested dictionary {ca: {t, val pairs}, ... } - data = [ - [0.000, 0.957, -0.031, -0.015], - [0.263, 0.557, 0.330, 0.044], - [0.526, 0.342, 0.512, 0.156], - [0.789, 0.224, 0.499, 0.310], - [1.053, 0.123, 0.428, 0.454], - [1.316, 0.079, 0.396, 0.556], - [1.579, 0.035, 0.303, 0.651], - [1.842, 0.029, 0.287, 0.658], - [2.105, 0.025, 0.221, 0.750], - [2.368, 0.017, 0.148, 0.854], - [2.632, -0.002, 0.182, 0.845], - [2.895, 0.009, 0.116, 0.893], - [3.158, -0.023, 0.079, 0.942], - [3.421, 0.006, 0.078, 0.899], - [3.684, 0.016, 0.059, 0.942], - [3.947, 0.014, 0.036, 0.991], - [4.211, -0.009, 0.014, 0.988], - [4.474, -0.030, 0.036, 0.941], - [4.737, 0.004, 0.036, 0.971], - [5.000, -0.024, 0.028, 0.985], - ] - data = pd.DataFrame(data, columns=["t", "ca", "cb", "cc"]) - data_df = data.set_index("t") - data_dict = { - "ca": {k: v for (k, v) in zip(data.t, data.ca)}, - "cb": {k: v for (k, v) in zip(data.t, data.cb)}, - "cc": {k: v for (k, v) in zip(data.t, data.cc)}, - } - - # Create an experiment list - exp_list_df = [ReactorDesignExperimentDAE(data_df)] - exp_list_dict = [ReactorDesignExperimentDAE(data_dict)] - - self.pest_df = parmest.Estimator(exp_list_df) - self.pest_dict = parmest.Estimator(exp_list_dict) - - # Estimator object with multiple scenarios - exp_list_df_multiple = [ - ReactorDesignExperimentDAE(data_df), - ReactorDesignExperimentDAE(data_df), - ] - exp_list_dict_multiple = [ - ReactorDesignExperimentDAE(data_dict), - ReactorDesignExperimentDAE(data_dict), - ] - - self.pest_df_multiple = parmest.Estimator(exp_list_df_multiple) - self.pest_dict_multiple = parmest.Estimator(exp_list_dict_multiple) - - # Create an instance of the model - self.m_df = ABC_model(data_df) - self.m_dict = ABC_model(data_dict) - - def test_dataformats(self): - obj1, theta1 = self.pest_df.theta_est() - obj2, theta2 = self.pest_dict.theta_est() - - self.assertAlmostEqual(obj1, obj2, places=6) - self.assertAlmostEqual(theta1["k1"], theta2["k1"], places=6) - self.assertAlmostEqual(theta1["k2"], theta2["k2"], places=6) - - def test_return_continuous_set(self): - """ - test if ContinuousSet elements are returned correctly from theta_est() - """ - obj1, theta1, return_vals1 = self.pest_df.theta_est(return_values=["time"]) - obj2, theta2, return_vals2 = self.pest_dict.theta_est(return_values=["time"]) - self.assertAlmostEqual(return_vals1["time"].loc[0][18], 2.368, places=3) - self.assertAlmostEqual(return_vals2["time"].loc[0][18], 2.368, places=3) - - def test_return_continuous_set_multiple_datasets(self): - """ - test if ContinuousSet elements are returned correctly from theta_est() - """ - obj1, theta1, return_vals1 = self.pest_df_multiple.theta_est( - return_values=["time"] - ) - obj2, theta2, return_vals2 = self.pest_dict_multiple.theta_est( - return_values=["time"] - ) - self.assertAlmostEqual(return_vals1["time"].loc[1][18], 2.368, places=3) - self.assertAlmostEqual(return_vals2["time"].loc[1][18], 2.368, places=3) - - @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') - def test_covariance(self): - from pyomo.contrib.interior_point.inverse_reduced_hessian import ( - inv_reduced_hessian_barrier, - ) - - # Number of datapoints. - # 3 data components (ca, cb, cc), 20 timesteps, 1 scenario = 60 - # In this example, this is the number of data points in data_df, but that's - # only because the data is indexed by time and contains no additional information. - n = 60 - - # Compute covariance using parmest - obj, theta, cov = self.pest_df.theta_est(calc_cov=True, cov_n=n) - - # Compute covariance using interior_point - vars_list = [self.m_df.k1, self.m_df.k2] - solve_result, inv_red_hes = inv_reduced_hessian_barrier( - self.m_df, independent_variables=vars_list, tee=True - ) - l = len(vars_list) - cov_interior_point = 2 * obj / (n - l) * inv_red_hes - cov_interior_point = pd.DataFrame( - cov_interior_point, ["k1", "k2"], ["k1", "k2"] - ) - - cov_diff = (cov - cov_interior_point).abs().sum().sum() - - self.assertTrue(cov.loc["k1", "k1"] > 0) - self.assertTrue(cov.loc["k2", "k2"] > 0) - self.assertAlmostEqual(cov_diff, 0, places=6) - - -@unittest.skipIf( - not parmest.parmest_available, - "Cannot test parmest: required dependencies are missing", -) -@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") -class TestSquareInitialization_RooneyBiegler(unittest.TestCase): - def setUp(self): - from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler_with_constraint import ( - RooneyBieglerExperiment, - ) - - # Note, the data used in this test has been corrected to use data.loc[5,'hour'] = 7 (instead of 6) - data = pd.DataFrame( - data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], - columns=["hour", "y"], - ) - - # Sum of squared error function - def SSE(model): - expr = ( - model.experiment_outputs[model.y] - - model.response_function[model.experiment_outputs[model.hour]] - ) ** 2 - return expr - - exp_list = [] - for i in range(data.shape[0]): - exp_list.append(RooneyBieglerExperiment(data.loc[i, :])) - - solver_options = {"tol": 1e-8} - - self.data = data - self.pest = parmest.Estimator( - exp_list, obj_function=SSE, solver_options=solver_options, tee=True - ) - - def test_theta_est_with_square_initialization(self): - obj_init = self.pest.objective_at_theta(initialize_parmest_model=True) - objval, thetavals = self.pest.theta_est() - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper - - def test_theta_est_with_square_initialization_and_custom_init_theta(self): - theta_vals_init = pd.DataFrame( - data=[[19.0, 0.5]], columns=["asymptote", "rate_constant"] - ) - obj_init = self.pest.objective_at_theta( - theta_values=theta_vals_init, initialize_parmest_model=True - ) - objval, thetavals = self.pest.theta_est() - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper - - def test_theta_est_with_square_initialization_diagnostic_mode_true(self): - self.pest.diagnostic_mode = True - obj_init = self.pest.objective_at_theta(initialize_parmest_model=True) - objval, thetavals = self.pest.theta_est() - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper - - self.pest.diagnostic_mode = False - - -########################### -# tests for deprecated UI # -########################### - - -@unittest.skipIf( - not parmest.parmest_available, - "Cannot test parmest: required dependencies are missing", -) -@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") -class TestRooneyBieglerDeprecated(unittest.TestCase): - def setUp(self): - - def rooney_biegler_model(data): - model = pyo.ConcreteModel() - - model.asymptote = pyo.Var(initialize=15) - model.rate_constant = pyo.Var(initialize=0.5) - - def response_rule(m, h): - expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) - return expr - - model.response_function = pyo.Expression(data.hour, rule=response_rule) - - def SSE_rule(m): - return sum( - (data.y[i] - m.response_function[data.hour[i]]) ** 2 - for i in data.index - ) - - model.SSE = pyo.Objective(rule=SSE_rule, sense=pyo.minimize) - - return model - - # Note, the data used in this test has been corrected to use data.loc[5,'hour'] = 7 (instead of 6) - data = pd.DataFrame( - data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], - columns=["hour", "y"], - ) - - theta_names = ["asymptote", "rate_constant"] - - def SSE(model, data): - expr = sum( - (data.y[i] - model.response_function[data.hour[i]]) ** 2 - for i in data.index - ) - return expr - - solver_options = {"tol": 1e-8} - - self.data = data - self.pest = parmest.Estimator( - rooney_biegler_model, - data, - theta_names, - SSE, - solver_options=solver_options, - tee=True, - ) - - def test_theta_est(self): - objval, thetavals = self.pest.theta_est() - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper - - @unittest.skipIf( - not graphics.imports_available, "parmest.graphics imports are unavailable" - ) - def test_bootstrap(self): - objval, thetavals = self.pest.theta_est() - - num_bootstraps = 10 - theta_est = self.pest.theta_est_bootstrap(num_bootstraps, return_samples=True) - - num_samples = theta_est["samples"].apply(len) - self.assertTrue(len(theta_est.index), 10) - self.assertTrue(num_samples.equals(pd.Series([6] * 10))) - - del theta_est["samples"] - - # apply confidence region test - CR = self.pest.confidence_region_test(theta_est, "MVN", [0.5, 0.75, 1.0]) - - self.assertTrue(set(CR.columns) >= set([0.5, 0.75, 1.0])) - self.assertTrue(CR[0.5].sum() == 5) - self.assertTrue(CR[0.75].sum() == 7) - self.assertTrue(CR[1.0].sum() == 10) # all true - - graphics.pairwise_plot(theta_est) - graphics.pairwise_plot(theta_est, thetavals) - graphics.pairwise_plot(theta_est, thetavals, 0.8, ["MVN", "KDE", "Rect"]) - - @unittest.skipIf( - not graphics.imports_available, "parmest.graphics imports are unavailable" - ) - def test_likelihood_ratio(self): - objval, thetavals = self.pest.theta_est() - - asym = np.arange(10, 30, 2) - rate = np.arange(0, 1.5, 0.25) - theta_vals = pd.DataFrame( - list(product(asym, rate)), columns=self.pest._return_theta_names() - ) - - obj_at_theta = self.pest.objective_at_theta(theta_vals) - - LR = self.pest.likelihood_ratio_test(obj_at_theta, objval, [0.8, 0.9, 1.0]) - - self.assertTrue(set(LR.columns) >= set([0.8, 0.9, 1.0])) - self.assertTrue(LR[0.8].sum() == 6) - self.assertTrue(LR[0.9].sum() == 10) - self.assertTrue(LR[1.0].sum() == 60) # all true - - graphics.pairwise_plot(LR, thetavals, 0.8) - - def test_leaveNout(self): - lNo_theta = self.pest.theta_est_leaveNout(1) - self.assertTrue(lNo_theta.shape == (6, 2)) - - results = self.pest.leaveNout_bootstrap_test( - 1, None, 3, "Rect", [0.5, 1.0], seed=5436 - ) - self.assertTrue(len(results) == 6) # 6 lNo samples - i = 1 - samples = results[i][0] # list of N samples that are left out - lno_theta = results[i][1] - bootstrap_theta = results[i][2] - self.assertTrue(samples == [1]) # sample 1 was left out - self.assertTrue(lno_theta.shape[0] == 1) # lno estimate for sample 1 - self.assertTrue(set(lno_theta.columns) >= set([0.5, 1.0])) - self.assertTrue(lno_theta[1.0].sum() == 1) # all true - self.assertTrue(bootstrap_theta.shape[0] == 3) # bootstrap for sample 1 - self.assertTrue(bootstrap_theta[1.0].sum() == 3) # all true - - def test_diagnostic_mode(self): - self.pest.diagnostic_mode = True - - objval, thetavals = self.pest.theta_est() - - asym = np.arange(10, 30, 2) - rate = np.arange(0, 1.5, 0.25) - theta_vals = pd.DataFrame( - list(product(asym, rate)), columns=self.pest._return_theta_names() - ) - - obj_at_theta = self.pest.objective_at_theta(theta_vals) - - self.pest.diagnostic_mode = False - - @unittest.skip("Presently having trouble with mpiexec on appveyor") - def test_parallel_parmest(self): - """use mpiexec and mpi4py""" - p = str(parmestbase.__path__) - l = p.find("'") - r = p.find("'", l + 1) - parmestpath = p[l + 1 : r] - rbpath = ( - parmestpath - + os.sep - + "examples" - + os.sep - + "rooney_biegler" - + os.sep - + "rooney_biegler_parmest.py" - ) - rbpath = os.path.abspath(rbpath) # paranoia strikes deep... - rlist = ["mpiexec", "--allow-run-as-root", "-n", "2", sys.executable, rbpath] - if sys.version_info >= (3, 5): - ret = subprocess.run(rlist) - retcode = ret.returncode - else: - retcode = subprocess.call(rlist) - assert retcode == 0 - - @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") - def test_theta_est_cov(self): - objval, thetavals, cov = self.pest.theta_est(calc_cov=True, cov_n=6) - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper - - # Covariance matrix - self.assertAlmostEqual( - cov.iloc[0, 0], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[0, 1], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 0], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual(cov.iloc[1, 1], 0.04124, places=2) # 0.04124 from paper - - """ Why does the covariance matrix from parmest not match the paper? Parmest is - calculating the exact reduced Hessian. The paper (Rooney and Bielger, 2001) likely - employed the first order approximation common for nonlinear regression. The paper - values were verified with Scipy, which uses the same first order approximation. - The formula used in parmest was verified against equations (7-5-15) and (7-5-16) in - "Nonlinear Parameter Estimation", Y. Bard, 1974. - """ - - def test_cov_scipy_least_squares_comparison(self): - """ - Scipy results differ in the 3rd decimal place from the paper. It is possible - the paper used an alternative finite difference approximation for the Jacobian. - """ - - def model(theta, t): - """ - Model to be fitted y = model(theta, t) - Arguments: - theta: vector of fitted parameters - t: independent variable [hours] - - Returns: - y: model predictions [need to check paper for units] - """ - asymptote = theta[0] - rate_constant = theta[1] - - return asymptote * (1 - np.exp(-rate_constant * t)) - - def residual(theta, t, y): - """ - Calculate residuals - Arguments: - theta: vector of fitted parameters - t: independent variable [hours] - y: dependent variable [?] - """ - return y - model(theta, t) - - # define data - t = self.data["hour"].to_numpy() - y = self.data["y"].to_numpy() - - # define initial guess - theta_guess = np.array([15, 0.5]) - - ## solve with optimize.least_squares - sol = scipy.optimize.least_squares( - residual, theta_guess, method="trf", args=(t, y), verbose=2 - ) - theta_hat = sol.x - - self.assertAlmostEqual( - theta_hat[0], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper - - # calculate residuals - r = residual(theta_hat, t, y) - - # calculate variance of the residuals - # -2 because there are 2 fitted parameters - sigre = np.matmul(r.T, r / (len(y) - 2)) - - # approximate covariance - # Need to divide by 2 because optimize.least_squares scaled the objective by 1/2 - cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) - - self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper - self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper - - def test_cov_scipy_curve_fit_comparison(self): - """ - Scipy results differ in the 3rd decimal place from the paper. It is possible - the paper used an alternative finite difference approximation for the Jacobian. - """ - - ## solve with optimize.curve_fit - def model(t, asymptote, rate_constant): - return asymptote * (1 - np.exp(-rate_constant * t)) - - # define data - t = self.data["hour"].to_numpy() - y = self.data["y"].to_numpy() - - # define initial guess - theta_guess = np.array([15, 0.5]) - - theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess) - - self.assertAlmostEqual( - theta_hat[0], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper - - self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper - self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper - - -@unittest.skipIf( - not parmest.parmest_available, - "Cannot test parmest: required dependencies are missing", -) -@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") -class TestModelVariantsDeprecated(unittest.TestCase): - def setUp(self): - self.data = pd.DataFrame( - data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], - columns=["hour", "y"], - ) - - def rooney_biegler_params(data): - model = pyo.ConcreteModel() - - model.asymptote = pyo.Param(initialize=15, mutable=True) - model.rate_constant = pyo.Param(initialize=0.5, mutable=True) - - def response_rule(m, h): - expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) - return expr - - model.response_function = pyo.Expression(data.hour, rule=response_rule) - - return model - - def rooney_biegler_indexed_params(data): - model = pyo.ConcreteModel() - - model.param_names = pyo.Set(initialize=["asymptote", "rate_constant"]) - model.theta = pyo.Param( - model.param_names, - initialize={"asymptote": 15, "rate_constant": 0.5}, - mutable=True, - ) - - def response_rule(m, h): - expr = m.theta["asymptote"] * ( - 1 - pyo.exp(-m.theta["rate_constant"] * h) - ) - return expr - - model.response_function = pyo.Expression(data.hour, rule=response_rule) - - return model - - def rooney_biegler_vars(data): - model = pyo.ConcreteModel() - - model.asymptote = pyo.Var(initialize=15) - model.rate_constant = pyo.Var(initialize=0.5) - model.asymptote.fixed = True # parmest will unfix theta variables - model.rate_constant.fixed = True - - def response_rule(m, h): - expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) - return expr - - model.response_function = pyo.Expression(data.hour, rule=response_rule) - - return model - - def rooney_biegler_indexed_vars(data): - model = pyo.ConcreteModel() - - model.var_names = pyo.Set(initialize=["asymptote", "rate_constant"]) - model.theta = pyo.Var( - model.var_names, initialize={"asymptote": 15, "rate_constant": 0.5} - ) - model.theta["asymptote"].fixed = ( - True # parmest will unfix theta variables, even when they are indexed - ) - model.theta["rate_constant"].fixed = True - - def response_rule(m, h): - expr = m.theta["asymptote"] * ( - 1 - pyo.exp(-m.theta["rate_constant"] * h) - ) - return expr - - model.response_function = pyo.Expression(data.hour, rule=response_rule) - - return model - - def SSE(model, data): - expr = sum( - (data.y[i] - model.response_function[data.hour[i]]) ** 2 - for i in data.index - ) - return expr - - self.objective_function = SSE - - theta_vals = pd.DataFrame([20, 1], index=["asymptote", "rate_constant"]).T - theta_vals_index = pd.DataFrame( - [20, 1], index=["theta['asymptote']", "theta['rate_constant']"] - ).T - - self.input = { - "param": { - "model": rooney_biegler_params, - "theta_names": ["asymptote", "rate_constant"], - "theta_vals": theta_vals, - }, - "param_index": { - "model": rooney_biegler_indexed_params, - "theta_names": ["theta"], - "theta_vals": theta_vals_index, - }, - "vars": { - "model": rooney_biegler_vars, - "theta_names": ["asymptote", "rate_constant"], - "theta_vals": theta_vals, - }, - "vars_index": { - "model": rooney_biegler_indexed_vars, - "theta_names": ["theta"], - "theta_vals": theta_vals_index, - }, - "vars_quoted_index": { - "model": rooney_biegler_indexed_vars, - "theta_names": ["theta['asymptote']", "theta['rate_constant']"], - "theta_vals": theta_vals_index, - }, - "vars_str_index": { - "model": rooney_biegler_indexed_vars, - "theta_names": ["theta[asymptote]", "theta[rate_constant]"], - "theta_vals": theta_vals_index, - }, - } - - @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") - def test_parmest_basics(self): - for model_type, parmest_input in self.input.items(): - pest = parmest.Estimator( - parmest_input["model"], - self.data, - parmest_input["theta_names"], - self.objective_function, - ) - - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - - self.assertAlmostEqual(objval, 4.3317112, places=2) self.assertAlmostEqual( - cov.iloc[0, 0], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[0, 1], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 0], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 1], 0.04193591, places=2 - ) # 0.04124 from paper - - obj_at_theta = pest.objective_at_theta(parmest_input["theta_vals"]) - self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) - - @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') - def test_parmest_basics_with_initialize_parmest_model_option(self): - for model_type, parmest_input in self.input.items(): - pest = parmest.Estimator( - parmest_input["model"], - self.data, - parmest_input["theta_names"], - self.objective_function, - ) - - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - cov.iloc[0, 0], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[0, 1], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 0], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 1], 0.04193591, places=2 - ) # 0.04124 from paper - - obj_at_theta = pest.objective_at_theta( - parmest_input["theta_vals"], initialize_parmest_model=True - ) - - self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) - - @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') - def test_parmest_basics_with_square_problem_solve(self): - for model_type, parmest_input in self.input.items(): - pest = parmest.Estimator( - parmest_input["model"], - self.data, - parmest_input["theta_names"], - self.objective_function, - ) - - obj_at_theta = pest.objective_at_theta( - parmest_input["theta_vals"], initialize_parmest_model=True - ) - - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - cov.iloc[0, 0], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[0, 1], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 0], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 1], 0.04193591, places=2 - ) # 0.04124 from paper - - self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) - - @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') - def test_parmest_basics_with_square_problem_solve_no_theta_vals(self): - for model_type, parmest_input in self.input.items(): - pest = parmest.Estimator( - parmest_input["model"], - self.data, - parmest_input["theta_names"], - self.objective_function, - ) - - obj_at_theta = pest.objective_at_theta(initialize_parmest_model=True) - - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - cov.iloc[0, 0], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[0, 1], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 0], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 1], 0.04193591, places=2 - ) # 0.04124 from paper - - -@unittest.skipIf( - not parmest.parmest_available, - "Cannot test parmest: required dependencies are missing", -) -@unittest.skipIf(not ipopt_available, "The 'ipopt' solver is not available") -class TestReactorDesignDeprecated(unittest.TestCase): - def setUp(self): - - def reactor_design_model(data): - # Create the concrete model - model = pyo.ConcreteModel() - - # Rate constants - model.k1 = pyo.Param( - initialize=5.0 / 6.0, within=pyo.PositiveReals, mutable=True - ) # min^-1 - model.k2 = pyo.Param( - initialize=5.0 / 3.0, within=pyo.PositiveReals, mutable=True - ) # min^-1 - model.k3 = pyo.Param( - initialize=1.0 / 6000.0, within=pyo.PositiveReals, mutable=True - ) # m^3/(gmol min) - - # Inlet concentration of A, gmol/m^3 - if isinstance(data, dict) or isinstance(data, pd.Series): - model.caf = pyo.Param( - initialize=float(data["caf"]), within=pyo.PositiveReals - ) - elif isinstance(data, pd.DataFrame): - model.caf = pyo.Param( - initialize=float(data.iloc[0]["caf"]), within=pyo.PositiveReals - ) - else: - raise ValueError("Unrecognized data type.") - - # Space velocity (flowrate/volume) - if isinstance(data, dict) or isinstance(data, pd.Series): - model.sv = pyo.Param( - initialize=float(data["sv"]), within=pyo.PositiveReals - ) - elif isinstance(data, pd.DataFrame): - model.sv = pyo.Param( - initialize=float(data.iloc[0]["sv"]), within=pyo.PositiveReals - ) - else: - raise ValueError("Unrecognized data type.") - - # Outlet concentration of each component - model.ca = pyo.Var(initialize=5000.0, within=pyo.PositiveReals) - model.cb = pyo.Var(initialize=2000.0, within=pyo.PositiveReals) - model.cc = pyo.Var(initialize=2000.0, within=pyo.PositiveReals) - model.cd = pyo.Var(initialize=1000.0, within=pyo.PositiveReals) - - # Objective - model.obj = pyo.Objective(expr=model.cb, sense=pyo.maximize) - - # Constraints - model.ca_bal = pyo.Constraint( - expr=( - 0 - == model.sv * model.caf - - model.sv * model.ca - - model.k1 * model.ca - - 2.0 * model.k3 * model.ca**2.0 - ) - ) - - model.cb_bal = pyo.Constraint( - expr=( - 0 - == -model.sv * model.cb + model.k1 * model.ca - model.k2 * model.cb - ) - ) - - model.cc_bal = pyo.Constraint( - expr=(0 == -model.sv * model.cc + model.k2 * model.cb) - ) - - model.cd_bal = pyo.Constraint( - expr=(0 == -model.sv * model.cd + model.k3 * model.ca**2.0) - ) - - return model - - # Data from the design - data = pd.DataFrame( - data=[ - [1.05, 10000, 3458.4, 1060.8, 1683.9, 1898.5], - [1.10, 10000, 3535.1, 1064.8, 1613.3, 1893.4], - [1.15, 10000, 3609.1, 1067.8, 1547.5, 1887.8], - [1.20, 10000, 3680.7, 1070.0, 1486.1, 1881.6], - [1.25, 10000, 3750.0, 1071.4, 1428.6, 1875.0], - [1.30, 10000, 3817.1, 1072.2, 1374.6, 1868.0], - [1.35, 10000, 3882.2, 1072.4, 1324.0, 1860.7], - [1.40, 10000, 3945.4, 1072.1, 1276.3, 1853.1], - [1.45, 10000, 4006.7, 1071.3, 1231.4, 1845.3], - [1.50, 10000, 4066.4, 1070.1, 1189.0, 1837.3], - [1.55, 10000, 4124.4, 1068.5, 1148.9, 1829.1], - [1.60, 10000, 4180.9, 1066.5, 1111.0, 1820.8], - [1.65, 10000, 4235.9, 1064.3, 1075.0, 1812.4], - [1.70, 10000, 4289.5, 1061.8, 1040.9, 1803.9], - [1.75, 10000, 4341.8, 1059.0, 1008.5, 1795.3], - [1.80, 10000, 4392.8, 1056.0, 977.7, 1786.7], - [1.85, 10000, 4442.6, 1052.8, 948.4, 1778.1], - [1.90, 10000, 4491.3, 1049.4, 920.5, 1769.4], - [1.95, 10000, 4538.8, 1045.8, 893.9, 1760.8], - ], - columns=["sv", "caf", "ca", "cb", "cc", "cd"], - ) - - theta_names = ["k1", "k2", "k3"] - - def SSE(model, data): - expr = ( - (float(data.iloc[0]["ca"]) - model.ca) ** 2 - + (float(data.iloc[0]["cb"]) - model.cb) ** 2 - + (float(data.iloc[0]["cc"]) - model.cc) ** 2 - + (float(data.iloc[0]["cd"]) - model.cd) ** 2 - ) - return expr - - solver_options = {"max_iter": 6000} - - self.pest = parmest.Estimator( - reactor_design_model, data, theta_names, SSE, solver_options=solver_options - ) - - def test_theta_est(self): - # used in data reconciliation - objval, thetavals = self.pest.theta_est() - - self.assertAlmostEqual(thetavals["k1"], 5.0 / 6.0, places=4) - self.assertAlmostEqual(thetavals["k2"], 5.0 / 3.0, places=4) - self.assertAlmostEqual(thetavals["k3"], 1.0 / 6000.0, places=7) - - def test_return_values(self): - objval, thetavals, data_rec = self.pest.theta_est( - return_values=["ca", "cb", "cc", "cd", "caf"] - ) - self.assertAlmostEqual(data_rec["cc"].loc[18], 893.84924, places=3) - - -@unittest.skipIf( - not parmest.parmest_available, - "Cannot test parmest: required dependencies are missing", -) -@unittest.skipIf(not ipopt_available, "The 'ipopt' solver is not available") -class TestReactorDesign_DAE_Deprecated(unittest.TestCase): - # Based on a reactor example in `Chemical Reactor Analysis and Design Fundamentals`, - # https://sites.engineering.ucsb.edu/~jbraw/chemreacfun/ - # https://sites.engineering.ucsb.edu/~jbraw/chemreacfun/fig-html/appendix/fig-A-10.html - - def setUp(self): - def ABC_model(data): - ca_meas = data["ca"] - cb_meas = data["cb"] - cc_meas = data["cc"] - - if isinstance(data, pd.DataFrame): - meas_t = data.index # time index - else: # dictionary - meas_t = list(ca_meas.keys()) # nested dictionary - - ca0 = 1.0 - cb0 = 0.0 - cc0 = 0.0 - - m = pyo.ConcreteModel() - - m.k1 = pyo.Var(initialize=0.5, bounds=(1e-4, 10)) - m.k2 = pyo.Var(initialize=3.0, bounds=(1e-4, 10)) - - m.time = dae.ContinuousSet(bounds=(0.0, 5.0), initialize=meas_t) - - # initialization and bounds - m.ca = pyo.Var(m.time, initialize=ca0, bounds=(-1e-3, ca0 + 1e-3)) - m.cb = pyo.Var(m.time, initialize=cb0, bounds=(-1e-3, ca0 + 1e-3)) - m.cc = pyo.Var(m.time, initialize=cc0, bounds=(-1e-3, ca0 + 1e-3)) - - m.dca = dae.DerivativeVar(m.ca, wrt=m.time) - m.dcb = dae.DerivativeVar(m.cb, wrt=m.time) - m.dcc = dae.DerivativeVar(m.cc, wrt=m.time) - - def _dcarate(m, t): - if t == 0: - return pyo.Constraint.Skip - else: - return m.dca[t] == -m.k1 * m.ca[t] - - m.dcarate = pyo.Constraint(m.time, rule=_dcarate) - - def _dcbrate(m, t): - if t == 0: - return pyo.Constraint.Skip - else: - return m.dcb[t] == m.k1 * m.ca[t] - m.k2 * m.cb[t] - - m.dcbrate = pyo.Constraint(m.time, rule=_dcbrate) - - def _dccrate(m, t): - if t == 0: - return pyo.Constraint.Skip - else: - return m.dcc[t] == m.k2 * m.cb[t] - - m.dccrate = pyo.Constraint(m.time, rule=_dccrate) - - def ComputeFirstStageCost_rule(m): - return 0 - - m.FirstStageCost = pyo.Expression(rule=ComputeFirstStageCost_rule) - - def ComputeSecondStageCost_rule(m): - return sum( - (m.ca[t] - ca_meas[t]) ** 2 - + (m.cb[t] - cb_meas[t]) ** 2 - + (m.cc[t] - cc_meas[t]) ** 2 - for t in meas_t - ) - - m.SecondStageCost = pyo.Expression(rule=ComputeSecondStageCost_rule) - - def total_cost_rule(model): - return model.FirstStageCost + model.SecondStageCost - - m.Total_Cost_Objective = pyo.Objective( - rule=total_cost_rule, sense=pyo.minimize - ) - - disc = pyo.TransformationFactory("dae.collocation") - disc.apply_to(m, nfe=20, ncp=2) - - return m - - # This example tests data formatted in 3 ways - # Each format holds 1 scenario - # 1. dataframe with time index - # 2. nested dictionary {ca: {t, val pairs}, ... } - data = [ - [0.000, 0.957, -0.031, -0.015], - [0.263, 0.557, 0.330, 0.044], - [0.526, 0.342, 0.512, 0.156], - [0.789, 0.224, 0.499, 0.310], - [1.053, 0.123, 0.428, 0.454], - [1.316, 0.079, 0.396, 0.556], - [1.579, 0.035, 0.303, 0.651], - [1.842, 0.029, 0.287, 0.658], - [2.105, 0.025, 0.221, 0.750], - [2.368, 0.017, 0.148, 0.854], - [2.632, -0.002, 0.182, 0.845], - [2.895, 0.009, 0.116, 0.893], - [3.158, -0.023, 0.079, 0.942], - [3.421, 0.006, 0.078, 0.899], - [3.684, 0.016, 0.059, 0.942], - [3.947, 0.014, 0.036, 0.991], - [4.211, -0.009, 0.014, 0.988], - [4.474, -0.030, 0.036, 0.941], - [4.737, 0.004, 0.036, 0.971], - [5.000, -0.024, 0.028, 0.985], - ] - data = pd.DataFrame(data, columns=["t", "ca", "cb", "cc"]) - data_df = data.set_index("t") - data_dict = { - "ca": {k: v for (k, v) in zip(data.t, data.ca)}, - "cb": {k: v for (k, v) in zip(data.t, data.cb)}, - "cc": {k: v for (k, v) in zip(data.t, data.cc)}, - } - - theta_names = ["k1", "k2"] - - self.pest_df = parmest.Estimator(ABC_model, [data_df], theta_names) - self.pest_dict = parmest.Estimator(ABC_model, [data_dict], theta_names) - - # Estimator object with multiple scenarios - self.pest_df_multiple = parmest.Estimator( - ABC_model, [data_df, data_df], theta_names - ) - self.pest_dict_multiple = parmest.Estimator( - ABC_model, [data_dict, data_dict], theta_names - ) - - # Create an instance of the model - self.m_df = ABC_model(data_df) - self.m_dict = ABC_model(data_dict) - - def test_dataformats(self): - obj1, theta1 = self.pest_df.theta_est() - obj2, theta2 = self.pest_dict.theta_est() - - self.assertAlmostEqual(obj1, obj2, places=6) - self.assertAlmostEqual(theta1["k1"], theta2["k1"], places=6) - self.assertAlmostEqual(theta1["k2"], theta2["k2"], places=6) - - def test_return_continuous_set(self): - """ - test if ContinuousSet elements are returned correctly from theta_est() - """ - obj1, theta1, return_vals1 = self.pest_df.theta_est(return_values=["time"]) - obj2, theta2, return_vals2 = self.pest_dict.theta_est(return_values=["time"]) - self.assertAlmostEqual(return_vals1["time"].loc[0][18], 2.368, places=3) - self.assertAlmostEqual(return_vals2["time"].loc[0][18], 2.368, places=3) - - def test_return_continuous_set_multiple_datasets(self): - """ - test if ContinuousSet elements are returned correctly from theta_est() - """ - obj1, theta1, return_vals1 = self.pest_df_multiple.theta_est( - return_values=["time"] - ) - obj2, theta2, return_vals2 = self.pest_dict_multiple.theta_est( - return_values=["time"] - ) - self.assertAlmostEqual(return_vals1["time"].loc[1][18], 2.368, places=3) - self.assertAlmostEqual(return_vals2["time"].loc[1][18], 2.368, places=3) - - @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') - def test_covariance(self): - from pyomo.contrib.interior_point.inverse_reduced_hessian import ( - inv_reduced_hessian_barrier, - ) - - # Number of datapoints. - # 3 data components (ca, cb, cc), 20 timesteps, 1 scenario = 60 - # In this example, this is the number of data points in data_df, but that's - # only because the data is indexed by time and contains no additional information. - n = 60 - - # Compute covariance using parmest - obj, theta, cov = self.pest_df.theta_est(calc_cov=True, cov_n=n) - - # Compute covariance using interior_point - vars_list = [self.m_df.k1, self.m_df.k2] - solve_result, inv_red_hes = inv_reduced_hessian_barrier( - self.m_df, independent_variables=vars_list, tee=True - ) - l = len(vars_list) - cov_interior_point = 2 * obj / (n - l) * inv_red_hes - cov_interior_point = pd.DataFrame( - cov_interior_point, ["k1", "k2"], ["k1", "k2"] - ) - - cov_diff = (cov - cov_interior_point).abs().sum().sum() - - self.assertTrue(cov.loc["k1", "k1"] > 0) - self.assertTrue(cov.loc["k2", "k2"] > 0) - self.assertAlmostEqual(cov_diff, 0, places=6) - - -@unittest.skipIf( - not parmest.parmest_available, - "Cannot test parmest: required dependencies are missing", -) -@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") -class TestSquareInitialization_RooneyBiegler_Deprecated(unittest.TestCase): - def setUp(self): - - def rooney_biegler_model_with_constraint(data): - model = pyo.ConcreteModel() - - model.asymptote = pyo.Var(initialize=15) - model.rate_constant = pyo.Var(initialize=0.5) - model.response_function = pyo.Var(data.hour, initialize=0.0) - - # changed from expression to constraint - def response_rule(m, h): - return m.response_function[h] == m.asymptote * ( - 1 - pyo.exp(-m.rate_constant * h) - ) - - model.response_function_constraint = pyo.Constraint( - data.hour, rule=response_rule - ) - - def SSE_rule(m): - return sum( - (data.y[i] - m.response_function[data.hour[i]]) ** 2 - for i in data.index - ) - - model.SSE = pyo.Objective(rule=SSE_rule, sense=pyo.minimize) - - return model - - # Note, the data used in this test has been corrected to use data.loc[5,'hour'] = 7 (instead of 6) - data = pd.DataFrame( - data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], - columns=["hour", "y"], - ) - - theta_names = ["asymptote", "rate_constant"] - - def SSE(model, data): - expr = sum( - (data.y[i] - model.response_function[data.hour[i]]) ** 2 - for i in data.index - ) - return expr - - solver_options = {"tol": 1e-8} - - self.data = data - self.pest = parmest.Estimator( - rooney_biegler_model_with_constraint, - data, - theta_names, - SSE, - solver_options=solver_options, - tee=True, - ) - - def test_theta_est_with_square_initialization(self): - obj_init = self.pest.objective_at_theta(initialize_parmest_model=True) - objval, thetavals = self.pest.theta_est() - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper - - def test_theta_est_with_square_initialization_and_custom_init_theta(self): - theta_vals_init = pd.DataFrame( - data=[[19.0, 0.5]], columns=["asymptote", "rate_constant"] - ) - obj_init = self.pest.objective_at_theta( - theta_values=theta_vals_init, initialize_parmest_model=True - ) - objval, thetavals = self.pest.theta_est() - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper - - def test_theta_est_with_square_initialization_diagnostic_mode_true(self): - self.pest.diagnostic_mode = True - obj_init = self.pest.objective_at_theta(initialize_parmest_model=True) - objval, thetavals = self.pest.theta_est() - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper - - self.pest.diagnostic_mode = False + theta_hat[0], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper + + self.assertAlmostEqual(cov[0, 0], 0.0095875, places=4) + self.assertAlmostEqual(cov[0, 1], -0.0006653, places=4) + self.assertAlmostEqual(cov[1, 0], -0.0006653, places=4) + self.assertAlmostEqual(cov[1, 1], 0.00006347, places=4) if __name__ == "__main__": From 39824b191b16f8f238a0671328cccbd2c70ed027 Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Tue, 3 Jun 2025 09:19:00 -0400 Subject: [PATCH 25/35] Removed test_new_parmest_capabilities.py file --- .../tests/test_new_parmest_capabilities.py | 439 ------------------ 1 file changed, 439 deletions(-) delete mode 100644 pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py diff --git a/pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py b/pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py deleted file mode 100644 index 28237bb7ba5..00000000000 --- a/pyomo/contrib/parmest/tests/test_new_parmest_capabilities.py +++ /dev/null @@ -1,439 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2025 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ________________________________________________________________________ -# ___ - -import pytest -from parameterized import parameterized, parameterized_class - -import pyomo.common.unittest as unittest -import pyomo.contrib.parmest.parmest as parmest -import pyomo.environ as pyo - -from pyomo.common.dependencies import numpy as np, pandas as pd, scipy, matplotlib -from pyomo.contrib.parmest.experiment import Experiment - -ipopt_available = pyo.SolverFactory("ipopt").available() - -# Test class for the built-in "SSE" and "SSE_weighted" objective functions -# validated the results using the Rooney-Biegler example -# Rooney-Biegler example is the case when the measurement error is None -# we considered another case when the user supplies the value of the measurement error -@unittest.skipIf( - not parmest.parmest_available, - "Cannot test parmest: required dependencies are missing", -) -@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") - -# we use parameterized_class to test the two objective functions over the two cases of measurement error -# included a third objective function to test the error message when an incorrect objective function is supplied -@parameterized_class(("measurement_std", "objective_function"), [(None, "SSE"), (None, "SSE_weighted"), - (None, "incorrect_obj"), (0.1, "SSE"), (0.1, "SSE_weighted"), (0.1, "incorrect_obj")]) -class TestRooneyBiegler(unittest.TestCase): - - def setUp(self): - self.data = pd.DataFrame( - data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], - columns=["hour", "y"], - ) - - # create the Rooney-Biegler model - def rooney_biegler_model(): - """ - Formulates the Pyomo model of the Rooney-Biegler example - - Returns: - m: Pyomo model - """ - m = pyo.ConcreteModel() - - m.asymptote = pyo.Var(within=pyo.NonNegativeReals, initialize=10) - m.rate_constant = pyo.Var(within=pyo.NonNegativeReals, initialize=0.2) - - m.hour = pyo.Var(within=pyo.PositiveReals, initialize=0.1) - m.y = pyo.Var(within=pyo.NonNegativeReals) - - @m.Constraint() - def response_rule(m): - return m.y == m.asymptote * (1 - pyo.exp(-m.rate_constant * m.hour)) - - return m - - # create the Experiment class - class RooneyBieglerExperiment(Experiment): - def __init__(self, experiment_number, hour, y, measurement_error_std): - self.y = y - self.hour = hour - self.experiment_number = experiment_number - self.model = None - self.measurement_error_std = measurement_error_std - - def get_labeled_model(self): - if self.model is None: - self.create_model() - self.finalize_model() - self.label_model() - return self.model - - def create_model(self): - m = self.model = rooney_biegler_model() - - return m - - def finalize_model(self): - m = self.model - - # fix the input variable - m.hour.fix(self.hour) - - return m - - def label_model(self): - m = self.model - - # add experiment inputs - m.experiment_inputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) - m.experiment_inputs.update([(m.hour, self.hour)]) - - # add experiment outputs - m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) - m.experiment_outputs.update([(m.y, self.y)]) - - # add unknown parameters - m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) - m.unknown_parameters.update( - (k, pyo.value(k)) for k in [m.asymptote, m.rate_constant] - ) - - # add measurement error - m.measurement_error = pyo.Suffix(direction=pyo.Suffix.LOCAL) - m.measurement_error.update([(m.y, self.measurement_error_std)]) - - return m - - # extract the input and output variables - hour_data = self.data["hour"] - y_data = self.data["y"] - - # create the experiments list - rooney_biegler_exp_list = [] - for i in range(self.data.shape[0]): - rooney_biegler_exp_list.append( - RooneyBieglerExperiment(i, hour_data[i], y_data[i], self.measurement_std) - ) - - self.exp_list = rooney_biegler_exp_list - - - def check_rooney_biegler_parameters(self, obj_val, theta_vals, obj_function, measurement_error): - """ - Checks if the objective value and parameter estimates are equal to the expected values - and agree with the results of the Rooney-Biegler paper - - Argument: - obj_val: the objective value of the annotated Pyomo model - theta_vals: dictionary of the estimated parameters - obj_function: a string of the objective function supplied by the user, e.g., 'SSE' - measurement_error: float or integer value of the measurement error standard deviation - """ - if obj_function == "SSE": - self.assertAlmostEqual(obj_val, 4.33171, places=2) - elif obj_function == "SSE_weighted" and measurement_error is not None: - self.assertAlmostEqual(obj_val, 216.58556, places=2) - - self.assertAlmostEqual( - theta_vals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - theta_vals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper - - - def check_rooney_biegler_covariance(self, cov, cov_method, obj_function, measurement_error): - """ - Checks if the covariance matrix elements are equal to the expected values - and agree with the results of the Rooney-Biegler paper - - Argument: - cov: pd.DataFrame, covariance matrix of the estimated parameters - cov_method: string ``method`` object specified by the user - options - 'finite_difference', 'reduced_hessian', and 'automatic_differentiation_kaug' - obj_function: a string of the objective function supplied by the user, e.g., 'SSE' - measurement_error: float or integer value of the measurement error standard deviation - """ - - # get indices in covariance matrix - cov_cols = cov.columns.to_list() - asymptote_index = [idx for idx, s in enumerate(cov_cols) if "asymptote" in s][0] - rate_constant_index = [ - idx for idx, s in enumerate(cov_cols) if "rate_constant" in s - ][0] - - if measurement_error is None: - if obj_function == "SSE": - if ( - cov_method == "finite_difference" - or cov_method == "automatic_differentiation_kaug" - ): - self.assertAlmostEqual( - cov.iloc[asymptote_index, asymptote_index], 6.229612, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[asymptote_index, rate_constant_index], -0.432265, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[rate_constant_index, asymptote_index], -0.432265, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[rate_constant_index, rate_constant_index], 0.041242, places=2 - ) # 0.04124 from paper - else: - self.assertAlmostEqual( - cov.iloc[asymptote_index, asymptote_index], 36.935351, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[asymptote_index, rate_constant_index], -2.551392, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[rate_constant_index, asymptote_index], -2.551392, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[rate_constant_index, rate_constant_index], 0.243428, places=2 - ) # 0.04124 from paper - else: - if obj_function == "SSE" or obj_function == "SSE_weighted": - if ( - cov_method == "finite_difference" - or cov_method == "automatic_differentiation_kaug" - ): - self.assertAlmostEqual( - cov.iloc[asymptote_index, asymptote_index], 0.009588, places=4 - ) - self.assertAlmostEqual( - cov.iloc[asymptote_index, rate_constant_index], -0.000665, places=4 - ) - self.assertAlmostEqual( - cov.iloc[rate_constant_index, asymptote_index], -0.000665, places=4 - ) - self.assertAlmostEqual( - cov.iloc[rate_constant_index, rate_constant_index], 0.000063, places=4 - ) - else: - self.assertAlmostEqual( - cov.iloc[asymptote_index, asymptote_index], 0.056845, places=4 - ) - self.assertAlmostEqual( - cov.iloc[asymptote_index, rate_constant_index], -0.003927, places=4 - ) - self.assertAlmostEqual( - cov.iloc[rate_constant_index, asymptote_index], -0.003927, places=4 - ) - self.assertAlmostEqual( - cov.iloc[rate_constant_index, rate_constant_index], 0.000375, places=4 - ) - - - # test and check the covariance calculation for all the three supported methods - # added an 'unsupported_method' to test the error message when the method supplied is not supported - @parameterized.expand([("finite_difference"), ("automatic_differentiation_kaug"), ("reduced_hessian"), - ("unsupported_method")]) - def test_parmest_cov(self, cov_method): - """ - Calculates the parameter estimates and covariance matrix and compares them with the results of Rooney-Biegler - - Argument: - cov_method: string ``method`` object specified by the user - options - 'finite_difference', 'reduced_hessian', and 'automatic_differentiation_kaug' - """ - if self.measurement_std is None: - if self.objective_function == "SSE": - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) - - # estimate the parameters - obj_val, theta_vals = pest.theta_est() - - # check the parameter estimation result - self.check_rooney_biegler_parameters(obj_val, theta_vals, obj_function=self.objective_function, - measurement_error=self.measurement_std) - - # calculate the covariance matrix - if cov_method in ("finite_difference", "automatic_differentiation_kaug", "reduced_hessian"): - cov = pest.cov_est(cov_n=6, method=cov_method) - - # check the covariance calculation results - self.check_rooney_biegler_covariance(cov, cov_method, obj_function=self.objective_function, - measurement_error=self.measurement_std) - else: - with pytest.raises(ValueError, - match=r"Invalid method: 'unsupported_method'\. Choose from \['finite_difference', " - "'automatic_differentiation_kaug', 'reduced_hessian'\]\."): - cov = pest.cov_est(cov_n=6, method=cov_method) - elif self.objective_function == "SSE_weighted": - with pytest.raises(ValueError, match='One or more values are missing from ' - '"measurement_error". All values of the measurement errors are ' - 'required for the "SSE_weighted" objective.'): - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) - - # we expect this error when estimating the parameters - obj_val, theta_vals = pest.theta_est() - else: - with pytest.raises(ValueError, match=r"Invalid objective function: 'incorrect_obj'\. " - r"Choose from \['SSE', 'SSE_weighted'\]\."): - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) - else: - if self.objective_function == "SSE" or self.objective_function == "SSE_weighted": - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) - - # estimate the parameters - obj_val, theta_vals = pest.theta_est() - - # check the parameter estimation results - self.check_rooney_biegler_parameters(obj_val, theta_vals, obj_function=self.objective_function, - measurement_error=self.measurement_std) - - # calculate the covariance matrix - if cov_method in ("finite_difference", "automatic_differentiation_kaug", "reduced_hessian"): - cov = pest.cov_est(cov_n=6, method=cov_method) - - # check the covariance calculation results - self.check_rooney_biegler_covariance(cov, cov_method, obj_function=self.objective_function, - measurement_error=self.measurement_std) - else: - with pytest.raises(ValueError, - match=r"Invalid method: 'unsupported_method'\. Choose from \['finite_difference', " - "'automatic_differentiation_kaug', 'reduced_hessian'\]\."): - cov = pest.cov_est(cov_n=6, method=cov_method) - else: - with pytest.raises(ValueError, match="Invalid objective function: 'incorrect_obj'\. " - "Choose from \['SSE', 'SSE_weighted'\]\."): - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) - - def test_cov_scipy_least_squares_comparison(self): - """ - Scipy results differ in the 3rd decimal place from the paper. It is possible - the paper used an alternative finite difference approximation for the Jacobian. - """ - - def model(theta, t): - """ - Model to be fitted y = model(theta, t) - Arguments: - theta: vector of fitted parameters - t: independent variable [hours] - - Returns: - y: model predictions [need to check paper for units] - """ - asymptote = theta[0] - rate_constant = theta[1] - - return asymptote * (1 - np.exp(-rate_constant * t)) - - def residual(theta, t, y): - """ - Calculate residuals - Arguments: - theta: vector of fitted parameters - t: independent variable [hours] - y: dependent variable [?] - """ - return y - model(theta, t) - - # define data - t = self.data["hour"].to_numpy() - y = self.data["y"].to_numpy() - - # define initial guess - theta_guess = np.array([15, 0.5]) - - ## solve with optimize.least_squares - sol = scipy.optimize.least_squares( - residual, theta_guess, method="trf", args=(t, y), verbose=2 - ) - theta_hat = sol.x - - self.assertAlmostEqual( - theta_hat[0], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper - - # calculate residuals - r = residual(theta_hat, t, y) - - if self.measurement_std is None: - # calculate variance of the residuals - # -2 because there are 2 fitted parameters - sigre = np.matmul(r.T, r / (len(y) - 2)) - - # approximate covariance - cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) - - self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper - self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper - else: - # use the user-supplied measurement error standard deviation - sigre = self.measurement_std ** 2 - - cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) - - self.assertAlmostEqual(cov[0, 0], 0.009588, places=4) - self.assertAlmostEqual(cov[0, 1], -0.000665, places=4) - self.assertAlmostEqual(cov[1, 0], -0.000665, places=4) - self.assertAlmostEqual(cov[1, 1], 0.000063, places=4) - - def test_cov_scipy_curve_fit_comparison(self): - """ - Scipy results differ in the 3rd decimal place from the paper. It is possible - the paper used an alternative finite difference approximation for the Jacobian. - """ - - ## solve with optimize.curve_fit - def model(t, asymptote, rate_constant): - return asymptote * (1 - np.exp(-rate_constant * t)) - - # define data - t = self.data["hour"].to_numpy() - y = self.data["y"].to_numpy() - - # define initial guess - theta_guess = np.array([15, 0.5]) - - # estimate the parameters and covariance matrix - if self.measurement_std is None: - theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess) - - self.assertAlmostEqual( - theta_hat[0], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper - - self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper - self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper - else: - theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess, sigma=self.measurement_std, - absolute_sigma=True) - - self.assertAlmostEqual( - theta_hat[0], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper - - self.assertAlmostEqual(cov[0, 0], 0.0095875, places=4) - self.assertAlmostEqual(cov[0, 1], -0.0006653, places=4) - self.assertAlmostEqual(cov[1, 0], -0.0006653, places=4) - self.assertAlmostEqual(cov[1, 1], 0.00006347, places=4) - - -if __name__ == "__main__": - unittest.main() From 7591dc9da177427c017c03049984ac1f67f3146d Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Tue, 3 Jun 2025 09:35:38 -0400 Subject: [PATCH 26/35] Ran black on parmest.py and test_parmest.py files --- pyomo/contrib/parmest/parmest.py | 16 +- pyomo/contrib/parmest/tests/test_parmest.py | 355 ++++++++++++++------ 2 files changed, 271 insertions(+), 100 deletions(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index ee2bb3b1753..233c19bf2df 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -232,7 +232,7 @@ def _experiment_instance_creation_callback( def SSE(model): """ - Returns an expression that is used to compute the sum of squared error (`SSE`) objective, + Returns an expression that is used to compute the sum of squared error ('SSE') objective, assuming Gaussian i.i.d. errors Argument: @@ -248,7 +248,7 @@ def SSE(model): def SSE_weighted(model): """ - Returns an expression that is used to compute the `SSE_weighted` objective, + Returns an expression that is used to compute the 'SSE_weighted' objective, assuming Gaussian i.i.d. errors, with measurement error standard deviation defined in the annotated Pyomo model Argument: @@ -285,7 +285,7 @@ def SSE_weighted(model): def _check_model_labels_helper(model): """ - Checks if the annotated Pyomo model contains the necessary suffixes. + Checks if the annotated Pyomo model contains the necessary suffixes Argument: model: annotated Pyomo model for suffix checking @@ -773,8 +773,10 @@ def __init__( try: self.obj_function = ObjectiveLib(obj_function) except ValueError: - raise ValueError(f"Invalid objective function: '{obj_function}'. " - f"Choose from {[e.value for e in ObjectiveLib]}.") + raise ValueError( + f"Invalid objective function: '{obj_function}'. " + f"Choose from {[e.value for e in ObjectiveLib]}." + ) self.tee = tee self.diagnostic_mode = diagnostic_mode self.solver_options = solver_options @@ -1542,7 +1544,9 @@ def cov_est( # check if the method input is a string if not isinstance(method, str): - raise TypeError("Expected a string for the method, e.g., 'finite_difference'") + raise TypeError( + "Expected a string for the method, e.g., 'finite_difference'" + ) # check if the supplied number of datapoints is an integer if not isinstance(cov_n, int): diff --git a/pyomo/contrib/parmest/tests/test_parmest.py b/pyomo/contrib/parmest/tests/test_parmest.py index db33b07a9d5..862175828c1 100644 --- a/pyomo/contrib/parmest/tests/test_parmest.py +++ b/pyomo/contrib/parmest/tests/test_parmest.py @@ -32,6 +32,7 @@ pynumero_ASL_available = AmplInterface.available() testdir = this_file_dir() + # Test class for the built-in "SSE" and "SSE_weighted" objective functions # validated the results using the Rooney-Biegler example # Rooney-Biegler example is the case when the measurement error is None @@ -44,8 +45,17 @@ # we use parameterized_class to test the two objective functions over the two cases of measurement error # included a third objective function to test the error message when an incorrect objective function is supplied -@parameterized_class(("measurement_std", "objective_function"), [(None, "SSE"), (None, "SSE_weighted"), - (None, "incorrect_obj"), (0.1, "SSE"), (0.1, "SSE_weighted"), (0.1, "incorrect_obj")]) +@parameterized_class( + ("measurement_std", "objective_function"), + [ + (None, "SSE"), + (None, "SSE_weighted"), + (None, "incorrect_obj"), + (0.1, "SSE"), + (0.1, "SSE_weighted"), + (0.1, "incorrect_obj"), + ], +) class TestRooneyBiegler(unittest.TestCase): def setUp(self): @@ -136,13 +146,16 @@ def label_model(self): rooney_biegler_exp_list = [] for i in range(self.data.shape[0]): rooney_biegler_exp_list.append( - RooneyBieglerExperiment(i, hour_data[i], y_data[i], self.measurement_std) + RooneyBieglerExperiment( + i, hour_data[i], y_data[i], self.measurement_std + ) ) self.exp_list = rooney_biegler_exp_list - - def check_rooney_biegler_parameters(self, obj_val, theta_vals, obj_function, measurement_error): + def check_rooney_biegler_parameters( + self, obj_val, theta_vals, obj_function, measurement_error + ): """ Checks if the objective value and parameter estimates are equal to the expected values and agree with the results of the Rooney-Biegler paper @@ -165,8 +178,9 @@ def check_rooney_biegler_parameters(self, obj_val, theta_vals, obj_function, mea theta_vals["rate_constant"], 0.5311, places=2 ) # 0.5311 from the paper - - def check_rooney_biegler_covariance(self, cov, cov_method, obj_function, measurement_error): + def check_rooney_biegler_covariance( + self, cov, cov_method, obj_function, measurement_error + ): """ Checks if the covariance matrix elements are equal to the expected values and agree with the results of the Rooney-Biegler paper @@ -196,26 +210,38 @@ def check_rooney_biegler_covariance(self, cov, cov_method, obj_function, measure cov.iloc[asymptote_index, asymptote_index], 6.229612, places=2 ) # 6.22864 from paper self.assertAlmostEqual( - cov.iloc[asymptote_index, rate_constant_index], -0.432265, places=2 + cov.iloc[asymptote_index, rate_constant_index], + -0.432265, + places=2, ) # -0.4322 from paper self.assertAlmostEqual( - cov.iloc[rate_constant_index, asymptote_index], -0.432265, places=2 + cov.iloc[rate_constant_index, asymptote_index], + -0.432265, + places=2, ) # -0.4322 from paper self.assertAlmostEqual( - cov.iloc[rate_constant_index, rate_constant_index], 0.041242, places=2 + cov.iloc[rate_constant_index, rate_constant_index], + 0.041242, + places=2, ) # 0.04124 from paper else: self.assertAlmostEqual( cov.iloc[asymptote_index, asymptote_index], 36.935351, places=2 ) # 6.22864 from paper self.assertAlmostEqual( - cov.iloc[asymptote_index, rate_constant_index], -2.551392, places=2 + cov.iloc[asymptote_index, rate_constant_index], + -2.551392, + places=2, ) # -0.4322 from paper self.assertAlmostEqual( - cov.iloc[rate_constant_index, asymptote_index], -2.551392, places=2 + cov.iloc[rate_constant_index, asymptote_index], + -2.551392, + places=2, ) # -0.4322 from paper self.assertAlmostEqual( - cov.iloc[rate_constant_index, rate_constant_index], 0.243428, places=2 + cov.iloc[rate_constant_index, rate_constant_index], + 0.243428, + places=2, ) # 0.04124 from paper else: if obj_function == "SSE" or obj_function == "SSE_weighted": @@ -227,33 +253,50 @@ def check_rooney_biegler_covariance(self, cov, cov_method, obj_function, measure cov.iloc[asymptote_index, asymptote_index], 0.009588, places=4 ) self.assertAlmostEqual( - cov.iloc[asymptote_index, rate_constant_index], -0.000665, places=4 + cov.iloc[asymptote_index, rate_constant_index], + -0.000665, + places=4, ) self.assertAlmostEqual( - cov.iloc[rate_constant_index, asymptote_index], -0.000665, places=4 + cov.iloc[rate_constant_index, asymptote_index], + -0.000665, + places=4, ) self.assertAlmostEqual( - cov.iloc[rate_constant_index, rate_constant_index], 0.000063, places=4 + cov.iloc[rate_constant_index, rate_constant_index], + 0.000063, + places=4, ) else: self.assertAlmostEqual( cov.iloc[asymptote_index, asymptote_index], 0.056845, places=4 ) self.assertAlmostEqual( - cov.iloc[asymptote_index, rate_constant_index], -0.003927, places=4 + cov.iloc[asymptote_index, rate_constant_index], + -0.003927, + places=4, ) self.assertAlmostEqual( - cov.iloc[rate_constant_index, asymptote_index], -0.003927, places=4 + cov.iloc[rate_constant_index, asymptote_index], + -0.003927, + places=4, ) self.assertAlmostEqual( - cov.iloc[rate_constant_index, rate_constant_index], 0.000375, places=4 + cov.iloc[rate_constant_index, rate_constant_index], + 0.000375, + places=4, ) - # test and check the covariance calculation for all the three supported methods # added an 'unsupported_method' to test the error message when the method supplied is not supported - @parameterized.expand([("finite_difference"), ("automatic_differentiation_kaug"), ("reduced_hessian"), - ("unsupported_method")]) + @parameterized.expand( + [ + ("finite_difference"), + ("automatic_differentiation_kaug"), + ("reduced_hessian"), + ("unsupported_method"), + ] + ) def test_parmest_covariance(self, cov_method): """ Calculates the parameter estimates and covariance matrix and compares them with the results of Rooney-Biegler @@ -264,87 +307,149 @@ def test_parmest_covariance(self, cov_method): """ if self.measurement_std is None: if self.objective_function == "SSE": - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + pest = parmest.Estimator( + self.exp_list, obj_function=self.objective_function + ) # estimate the parameters obj_val, theta_vals = pest.theta_est() # check the parameter estimation result - self.check_rooney_biegler_parameters(obj_val, theta_vals, obj_function=self.objective_function, - measurement_error=self.measurement_std) + self.check_rooney_biegler_parameters( + obj_val, + theta_vals, + obj_function=self.objective_function, + measurement_error=self.measurement_std, + ) # calculate the covariance matrix - if cov_method in ("finite_difference", "automatic_differentiation_kaug", "reduced_hessian"): + if cov_method in ( + "finite_difference", + "automatic_differentiation_kaug", + "reduced_hessian", + ): cov = pest.cov_est(cov_n=6, method=cov_method) # check the covariance calculation results - self.check_rooney_biegler_covariance(cov, cov_method, obj_function=self.objective_function, - measurement_error=self.measurement_std) + self.check_rooney_biegler_covariance( + cov, + cov_method, + obj_function=self.objective_function, + measurement_error=self.measurement_std, + ) else: - with pytest.raises(ValueError, - match=r"Invalid method: 'unsupported_method'\. Choose from \['finite_difference', " - "'automatic_differentiation_kaug', 'reduced_hessian'\]\."): + with pytest.raises( + ValueError, + match=r"Invalid method: 'unsupported_method'\. Choose from \['finite_difference', " + "'automatic_differentiation_kaug', 'reduced_hessian'\]\.", + ): cov = pest.cov_est(cov_n=6, method=cov_method) elif self.objective_function == "SSE_weighted": - with pytest.raises(ValueError, match='One or more values are missing from ' - '"measurement_error". All values of the measurement errors are ' - 'required for the "SSE_weighted" objective.'): - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + with pytest.raises( + ValueError, + match='One or more values are missing from ' + '"measurement_error". All values of the measurement errors are ' + 'required for the "SSE_weighted" objective.', + ): + pest = parmest.Estimator( + self.exp_list, obj_function=self.objective_function + ) # we expect this error when estimating the parameters obj_val, theta_vals = pest.theta_est() else: - with pytest.raises(ValueError, match=r"Invalid objective function: 'incorrect_obj'\. " - r"Choose from \['SSE', 'SSE_weighted'\]\."): - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + with pytest.raises( + ValueError, + match=r"Invalid objective function: 'incorrect_obj'\. " + r"Choose from \['SSE', 'SSE_weighted'\]\.", + ): + pest = parmest.Estimator( + self.exp_list, obj_function=self.objective_function + ) else: - if self.objective_function == "SSE" or self.objective_function == "SSE_weighted": - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + if ( + self.objective_function == "SSE" + or self.objective_function == "SSE_weighted" + ): + pest = parmest.Estimator( + self.exp_list, obj_function=self.objective_function + ) # estimate the parameters obj_val, theta_vals = pest.theta_est() # check the parameter estimation results - self.check_rooney_biegler_parameters(obj_val, theta_vals, obj_function=self.objective_function, - measurement_error=self.measurement_std) + self.check_rooney_biegler_parameters( + obj_val, + theta_vals, + obj_function=self.objective_function, + measurement_error=self.measurement_std, + ) # calculate the covariance matrix - if cov_method in ("finite_difference", "automatic_differentiation_kaug", "reduced_hessian"): + if cov_method in ( + "finite_difference", + "automatic_differentiation_kaug", + "reduced_hessian", + ): cov = pest.cov_est(cov_n=6, method=cov_method) # check the covariance calculation results - self.check_rooney_biegler_covariance(cov, cov_method, obj_function=self.objective_function, - measurement_error=self.measurement_std) + self.check_rooney_biegler_covariance( + cov, + cov_method, + obj_function=self.objective_function, + measurement_error=self.measurement_std, + ) else: - with pytest.raises(ValueError, - match=r"Invalid method: 'unsupported_method'\. Choose from \['finite_difference', " - "'automatic_differentiation_kaug', 'reduced_hessian'\]\."): + with pytest.raises( + ValueError, + match=r"Invalid method: 'unsupported_method'\. Choose from \['finite_difference', " + "'automatic_differentiation_kaug', 'reduced_hessian'\]\.", + ): cov = pest.cov_est(cov_n=6, method=cov_method) else: - with pytest.raises(ValueError, match="Invalid objective function: 'incorrect_obj'\. " - "Choose from \['SSE', 'SSE_weighted'\]\."): - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + with pytest.raises( + ValueError, + match="Invalid objective function: 'incorrect_obj'\. " + "Choose from \['SSE', 'SSE_weighted'\]\.", + ): + pest = parmest.Estimator( + self.exp_list, obj_function=self.objective_function + ) @unittest.skipIf( not graphics.imports_available, "parmest.graphics imports are unavailable" ) def test_bootstrap(self): if self.objective_function in ("SSE", "SSE_weighted"): - if self.objective_function == "SSE_weighted" and self.measurement_std is None: - with pytest.raises(ValueError, match='One or more values are missing from ' - '"measurement_error". All values of the measurement errors are ' - 'required for the "SSE_weighted" objective.'): - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + if ( + self.objective_function == "SSE_weighted" + and self.measurement_std is None + ): + with pytest.raises( + ValueError, + match='One or more values are missing from ' + '"measurement_error". All values of the measurement errors are ' + 'required for the "SSE_weighted" objective.', + ): + pest = parmest.Estimator( + self.exp_list, obj_function=self.objective_function + ) # we expect this error when estimating the parameters obj_val, theta_vals = pest.theta_est() else: - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + pest = parmest.Estimator( + self.exp_list, obj_function=self.objective_function + ) obj_val, theta_vals = pest.theta_est() num_bootstraps = 10 - theta_est = pest.theta_est_bootstrap(num_bootstraps, return_samples=True) + theta_est = pest.theta_est_bootstrap( + num_bootstraps, return_samples=True + ) num_samples = theta_est["samples"].apply(len) self.assertEqual(len(theta_est.index), 10) @@ -362,27 +467,44 @@ def test_bootstrap(self): graphics.pairwise_plot(theta_est) graphics.pairwise_plot(theta_est, theta_vals) - graphics.pairwise_plot(theta_est, theta_vals, 0.8, ["MVN", "KDE", "Rect"]) + graphics.pairwise_plot( + theta_est, theta_vals, 0.8, ["MVN", "KDE", "Rect"] + ) else: - with pytest.raises(ValueError, match="Invalid objective function: 'incorrect_obj'\. " - "Choose from \['SSE', 'SSE_weighted'\]\."): - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + with pytest.raises( + ValueError, + match="Invalid objective function: 'incorrect_obj'\. " + "Choose from \['SSE', 'SSE_weighted'\]\.", + ): + pest = parmest.Estimator( + self.exp_list, obj_function=self.objective_function + ) @unittest.skipIf( not graphics.imports_available, "parmest.graphics imports are unavailable" ) def test_likelihood_ratio(self): if self.objective_function in ("SSE", "SSE_weighted"): - if self.objective_function == "SSE_weighted" and self.measurement_std is None: - with pytest.raises(ValueError, match='One or more values are missing from ' - '"measurement_error". All values of the measurement errors are ' - 'required for the "SSE_weighted" objective.'): - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + if ( + self.objective_function == "SSE_weighted" + and self.measurement_std is None + ): + with pytest.raises( + ValueError, + match='One or more values are missing from ' + '"measurement_error". All values of the measurement errors are ' + 'required for the "SSE_weighted" objective.', + ): + pest = parmest.Estimator( + self.exp_list, obj_function=self.objective_function + ) # we expect this error when estimating the parameters obj_val, theta_vals = pest.theta_est() else: - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + pest = parmest.Estimator( + self.exp_list, obj_function=self.objective_function + ) obj_val, theta_vals = pest.theta_est() @@ -402,22 +524,37 @@ def test_likelihood_ratio(self): graphics.pairwise_plot(LR, theta_vals, 0.8) else: - with pytest.raises(ValueError, match="Invalid objective function: 'incorrect_obj'\. " - "Choose from \['SSE', 'SSE_weighted'\]\."): - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + with pytest.raises( + ValueError, + match="Invalid objective function: 'incorrect_obj'\. " + "Choose from \['SSE', 'SSE_weighted'\]\.", + ): + pest = parmest.Estimator( + self.exp_list, obj_function=self.objective_function + ) def test_leaveNout(self): if self.objective_function in ("SSE", "SSE_weighted"): - if self.objective_function == "SSE_weighted" and self.measurement_std is None: - with pytest.raises(ValueError, match='One or more values are missing from ' - '"measurement_error". All values of the measurement errors are ' - 'required for the "SSE_weighted" objective.'): - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + if ( + self.objective_function == "SSE_weighted" + and self.measurement_std is None + ): + with pytest.raises( + ValueError, + match='One or more values are missing from ' + '"measurement_error". All values of the measurement errors are ' + 'required for the "SSE_weighted" objective.', + ): + pest = parmest.Estimator( + self.exp_list, obj_function=self.objective_function + ) # we expect this error when estimating the parameters obj_val, theta_vals = pest.theta_est() else: - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + pest = parmest.Estimator( + self.exp_list, obj_function=self.objective_function + ) lNo_theta = pest.theta_est_leaveNout(1) self.assertTrue(lNo_theta.shape == (6, 2)) @@ -437,22 +574,37 @@ def test_leaveNout(self): self.assertEqual(bootstrap_theta.shape[0], 3) # bootstrap for sample 1 self.assertEqual(bootstrap_theta[1.0].sum(), 3) # all true else: - with pytest.raises(ValueError, match="Invalid objective function: 'incorrect_obj'\. " - "Choose from \['SSE', 'SSE_weighted'\]\."): - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + with pytest.raises( + ValueError, + match="Invalid objective function: 'incorrect_obj'\. " + "Choose from \['SSE', 'SSE_weighted'\]\.", + ): + pest = parmest.Estimator( + self.exp_list, obj_function=self.objective_function + ) def test_diagnostic_mode(self): if self.objective_function in ("SSE", "SSE_weighted"): - if self.objective_function == "SSE_weighted" and self.measurement_std is None: - with pytest.raises(ValueError, match='One or more values are missing from ' - '"measurement_error". All values of the measurement errors are ' - 'required for the "SSE_weighted" objective.'): - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + if ( + self.objective_function == "SSE_weighted" + and self.measurement_std is None + ): + with pytest.raises( + ValueError, + match='One or more values are missing from ' + '"measurement_error". All values of the measurement errors are ' + 'required for the "SSE_weighted" objective.', + ): + pest = parmest.Estimator( + self.exp_list, obj_function=self.objective_function + ) # we expect this error when estimating the parameters obj_val, theta_vals = pest.theta_est() else: - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + pest = parmest.Estimator( + self.exp_list, obj_function=self.objective_function + ) pest.diagnostic_mode = True @@ -468,9 +620,14 @@ def test_diagnostic_mode(self): pest.diagnostic_mode = False else: - with pytest.raises(ValueError, match="Invalid objective function: 'incorrect_obj'\. " - "Choose from \['SSE', 'SSE_weighted'\]\."): - pest = parmest.Estimator(self.exp_list, obj_function=self.objective_function) + with pytest.raises( + ValueError, + match="Invalid objective function: 'incorrect_obj'\. " + "Choose from \['SSE', 'SSE_weighted'\]\.", + ): + pest = parmest.Estimator( + self.exp_list, obj_function=self.objective_function + ) @unittest.skip("Presently having trouble with mpiexec on appveyor") def test_parallel_parmest(self): @@ -563,7 +720,7 @@ def residual(theta, t, y): self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper else: # use the user-supplied measurement error standard deviation - sigre = self.measurement_std ** 2 + sigre = self.measurement_std**2 cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) @@ -596,20 +753,30 @@ def model(t, asymptote, rate_constant): self.assertAlmostEqual( theta_hat[0], 19.1426, places=2 ) # 19.1426 from the paper - self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper + self.assertAlmostEqual( + theta_hat[1], 0.5311, places=2 + ) # 0.5311 from the paper self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper else: - theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess, sigma=self.measurement_std, - absolute_sigma=True) + theta_hat, cov = scipy.optimize.curve_fit( + model, + t, + y, + p0=theta_guess, + sigma=self.measurement_std, + absolute_sigma=True, + ) self.assertAlmostEqual( theta_hat[0], 19.1426, places=2 ) # 19.1426 from the paper - self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper + self.assertAlmostEqual( + theta_hat[1], 0.5311, places=2 + ) # 0.5311 from the paper self.assertAlmostEqual(cov[0, 0], 0.0095875, places=4) self.assertAlmostEqual(cov[0, 1], -0.0006653, places=4) From f54dbc6cd97a6c6ba59acfa7b056d2717f1cf578 Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Wed, 4 Jun 2025 16:50:34 -0400 Subject: [PATCH 27/35] Added back the test for the deprecated interface --- pyomo/contrib/parmest/tests/test_parmest.py | 1016 +++++++++++++++++++ 1 file changed, 1016 insertions(+) diff --git a/pyomo/contrib/parmest/tests/test_parmest.py b/pyomo/contrib/parmest/tests/test_parmest.py index 862175828c1..45fe95a4bd0 100644 --- a/pyomo/contrib/parmest/tests/test_parmest.py +++ b/pyomo/contrib/parmest/tests/test_parmest.py @@ -784,5 +784,1021 @@ def model(t, asymptote, rate_constant): self.assertAlmostEqual(cov[1, 1], 0.00006347, places=4) +########################### +# tests for deprecated UI # +########################### + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestRooneyBieglerDeprecated(unittest.TestCase): + def setUp(self): + + def rooney_biegler_model(data): + model = pyo.ConcreteModel() + + model.asymptote = pyo.Var(initialize=15) + model.rate_constant = pyo.Var(initialize=0.5) + + def response_rule(m, h): + expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) + return expr + + model.response_function = pyo.Expression(data.hour, rule=response_rule) + + def SSE_rule(m): + return sum( + (data.y[i] - m.response_function[data.hour[i]]) ** 2 + for i in data.index + ) + + model.SSE = pyo.Objective(rule=SSE_rule, sense=pyo.minimize) + + return model + + # Note, the data used in this test has been corrected to use data.loc[5,'hour'] = 7 (instead of 6) + data = pd.DataFrame( + data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], + columns=["hour", "y"], + ) + + theta_names = ["asymptote", "rate_constant"] + + def SSE(model, data): + expr = sum( + (data.y[i] - model.response_function[data.hour[i]]) ** 2 + for i in data.index + ) + return expr + + solver_options = {"tol": 1e-8} + + self.data = data + self.pest = parmest.Estimator( + rooney_biegler_model, + data, + theta_names, + SSE, + solver_options=solver_options, + tee=True, + ) + + def test_theta_est(self): + objval, thetavals = self.pest.theta_est() + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + @unittest.skipIf( + not graphics.imports_available, "parmest.graphics imports are unavailable" + ) + def test_bootstrap(self): + objval, thetavals = self.pest.theta_est() + + num_bootstraps = 10 + theta_est = self.pest.theta_est_bootstrap(num_bootstraps, return_samples=True) + + num_samples = theta_est["samples"].apply(len) + self.assertTrue(len(theta_est.index), 10) + self.assertTrue(num_samples.equals(pd.Series([6] * 10))) + + del theta_est["samples"] + + # apply confidence region test + CR = self.pest.confidence_region_test(theta_est, "MVN", [0.5, 0.75, 1.0]) + + self.assertTrue(set(CR.columns) >= set([0.5, 0.75, 1.0])) + self.assertTrue(CR[0.5].sum() == 5) + self.assertTrue(CR[0.75].sum() == 7) + self.assertTrue(CR[1.0].sum() == 10) # all true + + graphics.pairwise_plot(theta_est) + graphics.pairwise_plot(theta_est, thetavals) + graphics.pairwise_plot(theta_est, thetavals, 0.8, ["MVN", "KDE", "Rect"]) + + @unittest.skipIf( + not graphics.imports_available, "parmest.graphics imports are unavailable" + ) + def test_likelihood_ratio(self): + objval, thetavals = self.pest.theta_est() + + asym = np.arange(10, 30, 2) + rate = np.arange(0, 1.5, 0.25) + theta_vals = pd.DataFrame( + list(product(asym, rate)), columns=self.pest._return_theta_names() + ) + + obj_at_theta = self.pest.objective_at_theta(theta_vals) + + LR = self.pest.likelihood_ratio_test(obj_at_theta, objval, [0.8, 0.9, 1.0]) + + self.assertTrue(set(LR.columns) >= set([0.8, 0.9, 1.0])) + self.assertTrue(LR[0.8].sum() == 6) + self.assertTrue(LR[0.9].sum() == 10) + self.assertTrue(LR[1.0].sum() == 60) # all true + + graphics.pairwise_plot(LR, thetavals, 0.8) + + def test_leaveNout(self): + lNo_theta = self.pest.theta_est_leaveNout(1) + self.assertTrue(lNo_theta.shape == (6, 2)) + + results = self.pest.leaveNout_bootstrap_test( + 1, None, 3, "Rect", [0.5, 1.0], seed=5436 + ) + self.assertTrue(len(results) == 6) # 6 lNo samples + i = 1 + samples = results[i][0] # list of N samples that are left out + lno_theta = results[i][1] + bootstrap_theta = results[i][2] + self.assertTrue(samples == [1]) # sample 1 was left out + self.assertTrue(lno_theta.shape[0] == 1) # lno estimate for sample 1 + self.assertTrue(set(lno_theta.columns) >= set([0.5, 1.0])) + self.assertTrue(lno_theta[1.0].sum() == 1) # all true + self.assertTrue(bootstrap_theta.shape[0] == 3) # bootstrap for sample 1 + self.assertTrue(bootstrap_theta[1.0].sum() == 3) # all true + + def test_diagnostic_mode(self): + self.pest.diagnostic_mode = True + + objval, thetavals = self.pest.theta_est() + + asym = np.arange(10, 30, 2) + rate = np.arange(0, 1.5, 0.25) + theta_vals = pd.DataFrame( + list(product(asym, rate)), columns=self.pest._return_theta_names() + ) + + obj_at_theta = self.pest.objective_at_theta(theta_vals) + + self.pest.diagnostic_mode = False + + @unittest.skip("Presently having trouble with mpiexec on appveyor") + def test_parallel_parmest(self): + """use mpiexec and mpi4py""" + p = str(parmestbase.__path__) + l = p.find("'") + r = p.find("'", l + 1) + parmestpath = p[l + 1 : r] + rbpath = ( + parmestpath + + os.sep + + "examples" + + os.sep + + "rooney_biegler" + + os.sep + + "rooney_biegler_parmest.py" + ) + rbpath = os.path.abspath(rbpath) # paranoia strikes deep... + rlist = ["mpiexec", "--allow-run-as-root", "-n", "2", sys.executable, rbpath] + if sys.version_info >= (3, 5): + ret = subprocess.run(rlist) + retcode = ret.returncode + else: + retcode = subprocess.call(rlist) + assert retcode == 0 + + @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") + def test_theta_est_cov(self): + objval, thetavals, cov = self.pest.theta_est(calc_cov=True, cov_n=6) + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + # Covariance matrix + self.assertAlmostEqual( + cov.iloc[0, 0], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[0, 1], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 0], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual(cov.iloc[1, 1], 0.04124, places=2) # 0.04124 from paper + + """ Why does the covariance matrix from parmest not match the paper? Parmest is + calculating the exact reduced Hessian. The paper (Rooney and Bielger, 2001) likely + employed the first order approximation common for nonlinear regression. The paper + values were verified with Scipy, which uses the same first order approximation. + The formula used in parmest was verified against equations (7-5-15) and (7-5-16) in + "Nonlinear Parameter Estimation", Y. Bard, 1974. + """ + + def test_cov_scipy_least_squares_comparison(self): + """ + Scipy results differ in the 3rd decimal place from the paper. It is possible + the paper used an alternative finite difference approximation for the Jacobian. + """ + + def model(theta, t): + """ + Model to be fitted y = model(theta, t) + Arguments: + theta: vector of fitted parameters + t: independent variable [hours] + + Returns: + y: model predictions [need to check paper for units] + """ + asymptote = theta[0] + rate_constant = theta[1] + + return asymptote * (1 - np.exp(-rate_constant * t)) + + def residual(theta, t, y): + """ + Calculate residuals + Arguments: + theta: vector of fitted parameters + t: independent variable [hours] + y: dependent variable [?] + """ + return y - model(theta, t) + + # define data + t = self.data["hour"].to_numpy() + y = self.data["y"].to_numpy() + + # define initial guess + theta_guess = np.array([15, 0.5]) + + ## solve with optimize.least_squares + sol = scipy.optimize.least_squares( + residual, theta_guess, method="trf", args=(t, y), verbose=2 + ) + theta_hat = sol.x + + self.assertAlmostEqual( + theta_hat[0], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper + + # calculate residuals + r = residual(theta_hat, t, y) + + # calculate variance of the residuals + # -2 because there are 2 fitted parameters + sigre = np.matmul(r.T, r / (len(y) - 2)) + + # approximate covariance + # Need to divide by 2 because optimize.least_squares scaled the objective by 1/2 + cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) + + self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper + self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + + def test_cov_scipy_curve_fit_comparison(self): + """ + Scipy results differ in the 3rd decimal place from the paper. It is possible + the paper used an alternative finite difference approximation for the Jacobian. + """ + + ## solve with optimize.curve_fit + def model(t, asymptote, rate_constant): + return asymptote * (1 - np.exp(-rate_constant * t)) + + # define data + t = self.data["hour"].to_numpy() + y = self.data["y"].to_numpy() + + # define initial guess + theta_guess = np.array([15, 0.5]) + + theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess) + + self.assertAlmostEqual( + theta_hat[0], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper + + self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper + self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestModelVariantsDeprecated(unittest.TestCase): + def setUp(self): + self.data = pd.DataFrame( + data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], + columns=["hour", "y"], + ) + + def rooney_biegler_params(data): + model = pyo.ConcreteModel() + + model.asymptote = pyo.Param(initialize=15, mutable=True) + model.rate_constant = pyo.Param(initialize=0.5, mutable=True) + + def response_rule(m, h): + expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) + return expr + + model.response_function = pyo.Expression(data.hour, rule=response_rule) + + return model + + def rooney_biegler_indexed_params(data): + model = pyo.ConcreteModel() + + model.param_names = pyo.Set(initialize=["asymptote", "rate_constant"]) + model.theta = pyo.Param( + model.param_names, + initialize={"asymptote": 15, "rate_constant": 0.5}, + mutable=True, + ) + + def response_rule(m, h): + expr = m.theta["asymptote"] * ( + 1 - pyo.exp(-m.theta["rate_constant"] * h) + ) + return expr + + model.response_function = pyo.Expression(data.hour, rule=response_rule) + + return model + + def rooney_biegler_vars(data): + model = pyo.ConcreteModel() + + model.asymptote = pyo.Var(initialize=15) + model.rate_constant = pyo.Var(initialize=0.5) + model.asymptote.fixed = True # parmest will unfix theta variables + model.rate_constant.fixed = True + + def response_rule(m, h): + expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) + return expr + + model.response_function = pyo.Expression(data.hour, rule=response_rule) + + return model + + def rooney_biegler_indexed_vars(data): + model = pyo.ConcreteModel() + + model.var_names = pyo.Set(initialize=["asymptote", "rate_constant"]) + model.theta = pyo.Var( + model.var_names, initialize={"asymptote": 15, "rate_constant": 0.5} + ) + model.theta["asymptote"].fixed = ( + True # parmest will unfix theta variables, even when they are indexed + ) + model.theta["rate_constant"].fixed = True + + def response_rule(m, h): + expr = m.theta["asymptote"] * ( + 1 - pyo.exp(-m.theta["rate_constant"] * h) + ) + return expr + + model.response_function = pyo.Expression(data.hour, rule=response_rule) + + return model + + def SSE(model, data): + expr = sum( + (data.y[i] - model.response_function[data.hour[i]]) ** 2 + for i in data.index + ) + return expr + + self.objective_function = SSE + + theta_vals = pd.DataFrame([20, 1], index=["asymptote", "rate_constant"]).T + theta_vals_index = pd.DataFrame( + [20, 1], index=["theta['asymptote']", "theta['rate_constant']"] + ).T + + self.input = { + "param": { + "model": rooney_biegler_params, + "theta_names": ["asymptote", "rate_constant"], + "theta_vals": theta_vals, + }, + "param_index": { + "model": rooney_biegler_indexed_params, + "theta_names": ["theta"], + "theta_vals": theta_vals_index, + }, + "vars": { + "model": rooney_biegler_vars, + "theta_names": ["asymptote", "rate_constant"], + "theta_vals": theta_vals, + }, + "vars_index": { + "model": rooney_biegler_indexed_vars, + "theta_names": ["theta"], + "theta_vals": theta_vals_index, + }, + "vars_quoted_index": { + "model": rooney_biegler_indexed_vars, + "theta_names": ["theta['asymptote']", "theta['rate_constant']"], + "theta_vals": theta_vals_index, + }, + "vars_str_index": { + "model": rooney_biegler_indexed_vars, + "theta_names": ["theta[asymptote]", "theta[rate_constant]"], + "theta_vals": theta_vals_index, + }, + } + + @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") + def test_parmest_basics(self): + for model_type, parmest_input in self.input.items(): + pest = parmest.Estimator( + parmest_input["model"], + self.data, + parmest_input["theta_names"], + self.objective_function, + ) + + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[0, 0], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[0, 1], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 0], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 1], 0.04193591, places=2 + ) # 0.04124 from paper + + obj_at_theta = pest.objective_at_theta(parmest_input["theta_vals"]) + self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') + def test_parmest_basics_with_initialize_parmest_model_option(self): + for model_type, parmest_input in self.input.items(): + pest = parmest.Estimator( + parmest_input["model"], + self.data, + parmest_input["theta_names"], + self.objective_function, + ) + + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[0, 0], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[0, 1], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 0], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 1], 0.04193591, places=2 + ) # 0.04124 from paper + + obj_at_theta = pest.objective_at_theta( + parmest_input["theta_vals"], initialize_parmest_model=True + ) + + self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') + def test_parmest_basics_with_square_problem_solve(self): + for model_type, parmest_input in self.input.items(): + pest = parmest.Estimator( + parmest_input["model"], + self.data, + parmest_input["theta_names"], + self.objective_function, + ) + + obj_at_theta = pest.objective_at_theta( + parmest_input["theta_vals"], initialize_parmest_model=True + ) + + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[0, 0], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[0, 1], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 0], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 1], 0.04193591, places=2 + ) # 0.04124 from paper + + self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') + def test_parmest_basics_with_square_problem_solve_no_theta_vals(self): + for model_type, parmest_input in self.input.items(): + pest = parmest.Estimator( + parmest_input["model"], + self.data, + parmest_input["theta_names"], + self.objective_function, + ) + + obj_at_theta = pest.objective_at_theta(initialize_parmest_model=True) + + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[0, 0], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[0, 1], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 0], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 1], 0.04193591, places=2 + ) # 0.04124 from paper + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' solver is not available") +class TestReactorDesignDeprecated(unittest.TestCase): + def setUp(self): + + def reactor_design_model(data): + # Create the concrete model + model = pyo.ConcreteModel() + + # Rate constants + model.k1 = pyo.Param( + initialize=5.0 / 6.0, within=pyo.PositiveReals, mutable=True + ) # min^-1 + model.k2 = pyo.Param( + initialize=5.0 / 3.0, within=pyo.PositiveReals, mutable=True + ) # min^-1 + model.k3 = pyo.Param( + initialize=1.0 / 6000.0, within=pyo.PositiveReals, mutable=True + ) # m^3/(gmol min) + + # Inlet concentration of A, gmol/m^3 + if isinstance(data, dict) or isinstance(data, pd.Series): + model.caf = pyo.Param( + initialize=float(data["caf"]), within=pyo.PositiveReals + ) + elif isinstance(data, pd.DataFrame): + model.caf = pyo.Param( + initialize=float(data.iloc[0]["caf"]), within=pyo.PositiveReals + ) + else: + raise ValueError("Unrecognized data type.") + + # Space velocity (flowrate/volume) + if isinstance(data, dict) or isinstance(data, pd.Series): + model.sv = pyo.Param( + initialize=float(data["sv"]), within=pyo.PositiveReals + ) + elif isinstance(data, pd.DataFrame): + model.sv = pyo.Param( + initialize=float(data.iloc[0]["sv"]), within=pyo.PositiveReals + ) + else: + raise ValueError("Unrecognized data type.") + + # Outlet concentration of each component + model.ca = pyo.Var(initialize=5000.0, within=pyo.PositiveReals) + model.cb = pyo.Var(initialize=2000.0, within=pyo.PositiveReals) + model.cc = pyo.Var(initialize=2000.0, within=pyo.PositiveReals) + model.cd = pyo.Var(initialize=1000.0, within=pyo.PositiveReals) + + # Objective + model.obj = pyo.Objective(expr=model.cb, sense=pyo.maximize) + + # Constraints + model.ca_bal = pyo.Constraint( + expr=( + 0 + == model.sv * model.caf + - model.sv * model.ca + - model.k1 * model.ca + - 2.0 * model.k3 * model.ca**2.0 + ) + ) + + model.cb_bal = pyo.Constraint( + expr=( + 0 + == -model.sv * model.cb + model.k1 * model.ca - model.k2 * model.cb + ) + ) + + model.cc_bal = pyo.Constraint( + expr=(0 == -model.sv * model.cc + model.k2 * model.cb) + ) + + model.cd_bal = pyo.Constraint( + expr=(0 == -model.sv * model.cd + model.k3 * model.ca**2.0) + ) + + return model + + # Data from the design + data = pd.DataFrame( + data=[ + [1.05, 10000, 3458.4, 1060.8, 1683.9, 1898.5], + [1.10, 10000, 3535.1, 1064.8, 1613.3, 1893.4], + [1.15, 10000, 3609.1, 1067.8, 1547.5, 1887.8], + [1.20, 10000, 3680.7, 1070.0, 1486.1, 1881.6], + [1.25, 10000, 3750.0, 1071.4, 1428.6, 1875.0], + [1.30, 10000, 3817.1, 1072.2, 1374.6, 1868.0], + [1.35, 10000, 3882.2, 1072.4, 1324.0, 1860.7], + [1.40, 10000, 3945.4, 1072.1, 1276.3, 1853.1], + [1.45, 10000, 4006.7, 1071.3, 1231.4, 1845.3], + [1.50, 10000, 4066.4, 1070.1, 1189.0, 1837.3], + [1.55, 10000, 4124.4, 1068.5, 1148.9, 1829.1], + [1.60, 10000, 4180.9, 1066.5, 1111.0, 1820.8], + [1.65, 10000, 4235.9, 1064.3, 1075.0, 1812.4], + [1.70, 10000, 4289.5, 1061.8, 1040.9, 1803.9], + [1.75, 10000, 4341.8, 1059.0, 1008.5, 1795.3], + [1.80, 10000, 4392.8, 1056.0, 977.7, 1786.7], + [1.85, 10000, 4442.6, 1052.8, 948.4, 1778.1], + [1.90, 10000, 4491.3, 1049.4, 920.5, 1769.4], + [1.95, 10000, 4538.8, 1045.8, 893.9, 1760.8], + ], + columns=["sv", "caf", "ca", "cb", "cc", "cd"], + ) + + theta_names = ["k1", "k2", "k3"] + + def SSE(model, data): + expr = ( + (float(data.iloc[0]["ca"]) - model.ca) ** 2 + + (float(data.iloc[0]["cb"]) - model.cb) ** 2 + + (float(data.iloc[0]["cc"]) - model.cc) ** 2 + + (float(data.iloc[0]["cd"]) - model.cd) ** 2 + ) + return expr + + solver_options = {"max_iter": 6000} + + self.pest = parmest.Estimator( + reactor_design_model, data, theta_names, SSE, solver_options=solver_options + ) + + def test_theta_est(self): + # used in data reconciliation + objval, thetavals = self.pest.theta_est() + + self.assertAlmostEqual(thetavals["k1"], 5.0 / 6.0, places=4) + self.assertAlmostEqual(thetavals["k2"], 5.0 / 3.0, places=4) + self.assertAlmostEqual(thetavals["k3"], 1.0 / 6000.0, places=7) + + def test_return_values(self): + objval, thetavals, data_rec = self.pest.theta_est( + return_values=["ca", "cb", "cc", "cd", "caf"] + ) + self.assertAlmostEqual(data_rec["cc"].loc[18], 893.84924, places=3) + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' solver is not available") +class TestReactorDesign_DAE_Deprecated(unittest.TestCase): + # Based on a reactor example in `Chemical Reactor Analysis and Design Fundamentals`, + # https://sites.engineering.ucsb.edu/~jbraw/chemreacfun/ + # https://sites.engineering.ucsb.edu/~jbraw/chemreacfun/fig-html/appendix/fig-A-10.html + + def setUp(self): + def ABC_model(data): + ca_meas = data["ca"] + cb_meas = data["cb"] + cc_meas = data["cc"] + + if isinstance(data, pd.DataFrame): + meas_t = data.index # time index + else: # dictionary + meas_t = list(ca_meas.keys()) # nested dictionary + + ca0 = 1.0 + cb0 = 0.0 + cc0 = 0.0 + + m = pyo.ConcreteModel() + + m.k1 = pyo.Var(initialize=0.5, bounds=(1e-4, 10)) + m.k2 = pyo.Var(initialize=3.0, bounds=(1e-4, 10)) + + m.time = dae.ContinuousSet(bounds=(0.0, 5.0), initialize=meas_t) + + # initialization and bounds + m.ca = pyo.Var(m.time, initialize=ca0, bounds=(-1e-3, ca0 + 1e-3)) + m.cb = pyo.Var(m.time, initialize=cb0, bounds=(-1e-3, ca0 + 1e-3)) + m.cc = pyo.Var(m.time, initialize=cc0, bounds=(-1e-3, ca0 + 1e-3)) + + m.dca = dae.DerivativeVar(m.ca, wrt=m.time) + m.dcb = dae.DerivativeVar(m.cb, wrt=m.time) + m.dcc = dae.DerivativeVar(m.cc, wrt=m.time) + + def _dcarate(m, t): + if t == 0: + return pyo.Constraint.Skip + else: + return m.dca[t] == -m.k1 * m.ca[t] + + m.dcarate = pyo.Constraint(m.time, rule=_dcarate) + + def _dcbrate(m, t): + if t == 0: + return pyo.Constraint.Skip + else: + return m.dcb[t] == m.k1 * m.ca[t] - m.k2 * m.cb[t] + + m.dcbrate = pyo.Constraint(m.time, rule=_dcbrate) + + def _dccrate(m, t): + if t == 0: + return pyo.Constraint.Skip + else: + return m.dcc[t] == m.k2 * m.cb[t] + + m.dccrate = pyo.Constraint(m.time, rule=_dccrate) + + def ComputeFirstStageCost_rule(m): + return 0 + + m.FirstStageCost = pyo.Expression(rule=ComputeFirstStageCost_rule) + + def ComputeSecondStageCost_rule(m): + return sum( + (m.ca[t] - ca_meas[t]) ** 2 + + (m.cb[t] - cb_meas[t]) ** 2 + + (m.cc[t] - cc_meas[t]) ** 2 + for t in meas_t + ) + + m.SecondStageCost = pyo.Expression(rule=ComputeSecondStageCost_rule) + + def total_cost_rule(model): + return model.FirstStageCost + model.SecondStageCost + + m.Total_Cost_Objective = pyo.Objective( + rule=total_cost_rule, sense=pyo.minimize + ) + + disc = pyo.TransformationFactory("dae.collocation") + disc.apply_to(m, nfe=20, ncp=2) + + return m + + # This example tests data formatted in 3 ways + # Each format holds 1 scenario + # 1. dataframe with time index + # 2. nested dictionary {ca: {t, val pairs}, ... } + data = [ + [0.000, 0.957, -0.031, -0.015], + [0.263, 0.557, 0.330, 0.044], + [0.526, 0.342, 0.512, 0.156], + [0.789, 0.224, 0.499, 0.310], + [1.053, 0.123, 0.428, 0.454], + [1.316, 0.079, 0.396, 0.556], + [1.579, 0.035, 0.303, 0.651], + [1.842, 0.029, 0.287, 0.658], + [2.105, 0.025, 0.221, 0.750], + [2.368, 0.017, 0.148, 0.854], + [2.632, -0.002, 0.182, 0.845], + [2.895, 0.009, 0.116, 0.893], + [3.158, -0.023, 0.079, 0.942], + [3.421, 0.006, 0.078, 0.899], + [3.684, 0.016, 0.059, 0.942], + [3.947, 0.014, 0.036, 0.991], + [4.211, -0.009, 0.014, 0.988], + [4.474, -0.030, 0.036, 0.941], + [4.737, 0.004, 0.036, 0.971], + [5.000, -0.024, 0.028, 0.985], + ] + data = pd.DataFrame(data, columns=["t", "ca", "cb", "cc"]) + data_df = data.set_index("t") + data_dict = { + "ca": {k: v for (k, v) in zip(data.t, data.ca)}, + "cb": {k: v for (k, v) in zip(data.t, data.cb)}, + "cc": {k: v for (k, v) in zip(data.t, data.cc)}, + } + + theta_names = ["k1", "k2"] + + self.pest_df = parmest.Estimator(ABC_model, [data_df], theta_names) + self.pest_dict = parmest.Estimator(ABC_model, [data_dict], theta_names) + + # Estimator object with multiple scenarios + self.pest_df_multiple = parmest.Estimator( + ABC_model, [data_df, data_df], theta_names + ) + self.pest_dict_multiple = parmest.Estimator( + ABC_model, [data_dict, data_dict], theta_names + ) + + # Create an instance of the model + self.m_df = ABC_model(data_df) + self.m_dict = ABC_model(data_dict) + + def test_dataformats(self): + obj1, theta1 = self.pest_df.theta_est() + obj2, theta2 = self.pest_dict.theta_est() + + self.assertAlmostEqual(obj1, obj2, places=6) + self.assertAlmostEqual(theta1["k1"], theta2["k1"], places=6) + self.assertAlmostEqual(theta1["k2"], theta2["k2"], places=6) + + def test_return_continuous_set(self): + """ + test if ContinuousSet elements are returned correctly from theta_est() + """ + obj1, theta1, return_vals1 = self.pest_df.theta_est(return_values=["time"]) + obj2, theta2, return_vals2 = self.pest_dict.theta_est(return_values=["time"]) + self.assertAlmostEqual(return_vals1["time"].loc[0][18], 2.368, places=3) + self.assertAlmostEqual(return_vals2["time"].loc[0][18], 2.368, places=3) + + def test_return_continuous_set_multiple_datasets(self): + """ + test if ContinuousSet elements are returned correctly from theta_est() + """ + obj1, theta1, return_vals1 = self.pest_df_multiple.theta_est( + return_values=["time"] + ) + obj2, theta2, return_vals2 = self.pest_dict_multiple.theta_est( + return_values=["time"] + ) + self.assertAlmostEqual(return_vals1["time"].loc[1][18], 2.368, places=3) + self.assertAlmostEqual(return_vals2["time"].loc[1][18], 2.368, places=3) + + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') + def test_covariance(self): + from pyomo.contrib.interior_point.inverse_reduced_hessian import ( + inv_reduced_hessian_barrier, + ) + + # Number of datapoints. + # 3 data components (ca, cb, cc), 20 timesteps, 1 scenario = 60 + # In this example, this is the number of data points in data_df, but that's + # only because the data is indexed by time and contains no additional information. + n = 60 + + # Compute covariance using parmest + obj, theta, cov = self.pest_df.theta_est(calc_cov=True, cov_n=n) + + # Compute covariance using interior_point + vars_list = [self.m_df.k1, self.m_df.k2] + solve_result, inv_red_hes = inv_reduced_hessian_barrier( + self.m_df, independent_variables=vars_list, tee=True + ) + l = len(vars_list) + cov_interior_point = 2 * obj / (n - l) * inv_red_hes + cov_interior_point = pd.DataFrame( + cov_interior_point, ["k1", "k2"], ["k1", "k2"] + ) + + cov_diff = (cov - cov_interior_point).abs().sum().sum() + + self.assertTrue(cov.loc["k1", "k1"] > 0) + self.assertTrue(cov.loc["k2", "k2"] > 0) + self.assertAlmostEqual(cov_diff, 0, places=6) + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestSquareInitialization_RooneyBiegler_Deprecated(unittest.TestCase): + def setUp(self): + + def rooney_biegler_model_with_constraint(data): + model = pyo.ConcreteModel() + + model.asymptote = pyo.Var(initialize=15) + model.rate_constant = pyo.Var(initialize=0.5) + model.response_function = pyo.Var(data.hour, initialize=0.0) + + # changed from expression to constraint + def response_rule(m, h): + return m.response_function[h] == m.asymptote * ( + 1 - pyo.exp(-m.rate_constant * h) + ) + + model.response_function_constraint = pyo.Constraint( + data.hour, rule=response_rule + ) + + def SSE_rule(m): + return sum( + (data.y[i] - m.response_function[data.hour[i]]) ** 2 + for i in data.index + ) + + model.SSE = pyo.Objective(rule=SSE_rule, sense=pyo.minimize) + + return model + + # Note, the data used in this test has been corrected to use data.loc[5,'hour'] = 7 (instead of 6) + data = pd.DataFrame( + data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], + columns=["hour", "y"], + ) + + theta_names = ["asymptote", "rate_constant"] + + def SSE(model, data): + expr = sum( + (data.y[i] - model.response_function[data.hour[i]]) ** 2 + for i in data.index + ) + return expr + + solver_options = {"tol": 1e-8} + + self.data = data + self.pest = parmest.Estimator( + rooney_biegler_model_with_constraint, + data, + theta_names, + SSE, + solver_options=solver_options, + tee=True, + ) + + def test_theta_est_with_square_initialization(self): + obj_init = self.pest.objective_at_theta(initialize_parmest_model=True) + objval, thetavals = self.pest.theta_est() + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + def test_theta_est_with_square_initialization_and_custom_init_theta(self): + theta_vals_init = pd.DataFrame( + data=[[19.0, 0.5]], columns=["asymptote", "rate_constant"] + ) + obj_init = self.pest.objective_at_theta( + theta_values=theta_vals_init, initialize_parmest_model=True + ) + objval, thetavals = self.pest.theta_est() + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + def test_theta_est_with_square_initialization_diagnostic_mode_true(self): + self.pest.diagnostic_mode = True + obj_init = self.pest.objective_at_theta(initialize_parmest_model=True) + objval, thetavals = self.pest.theta_est() + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + self.pest.diagnostic_mode = False + + if __name__ == "__main__": unittest.main() From bbcdb2d9669a85809a8c8ae680834ac4e5f8bda8 Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Sat, 7 Jun 2025 12:49:53 -0400 Subject: [PATCH 28/35] Small updates to the parmest.py file --- pyomo/contrib/parmest/parmest.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index 233c19bf2df..17ea24e3bca 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -788,7 +788,7 @@ def __init__( # We could collect the union (or intersect?) of thetas when the models are built theta_names = [] for experiment in self.exp_list: - model = experiment.get_labeled_model() + model = _get_labeled_model_helper(experiment) theta_names.extend([k.name for k, v in model.unknown_parameters.items()]) # Utilize list(dict.fromkeys(theta_names)) to preserve parameter # order compared with list(set(theta_names)), which had @@ -881,7 +881,7 @@ def _create_parmest_model(self, experiment_number): Modify the Pyomo model for parameter estimation """ - model = self.exp_list[experiment_number].get_labeled_model() + model = _get_labeled_model_helper(self.exp_list[experiment_number]) if len(model.unknown_parameters) == 0: model.parmest_dummy_var = pyo.Var(initialize=1.0) @@ -896,7 +896,7 @@ def _create_parmest_model(self, experiment_number): "SecondStageCost", ] for n in reserved_names: - if model.component(n) is not None or hasattr(model, n): + if model.component(n) or hasattr(model, n): raise RuntimeError( f"Parmest will not override the existing model component named {n}. " f"Rerun the Estimator object before running theta_est again" @@ -913,8 +913,10 @@ def _create_parmest_model(self, experiment_number): elif self.obj_function == ObjectiveLib.SSE_weighted: second_stage_rule = SSE_weighted else: - # A custom function uses model.experiment_outputs as data - second_stage_rule = self.obj_function + raise ValueError( + f"Invalid objective function: '{self.obj_function.value}'. " + f"Choose from {[e.value for e in ObjectiveLib]}." + ) model.FirstStageCost = pyo.Expression(expr=0) model.SecondStageCost = pyo.Expression(rule=second_stage_rule) @@ -1111,8 +1113,9 @@ def _cov_at_theta(self, method, solver, cov_n, step): elif self.obj_function == ObjectiveLib.SSE_weighted: sse_expr = SSE_weighted(model) else: - raise NotImplementedError( - 'Covariance calculation is only supported for "SSE" and "SSE_weighted" objectives.' + raise ValueError( + f"Invalid objective function: '{self.obj_function.value}'. " + f"Choose from {[e.value for e in ObjectiveLib]}." ) # evaluate numerical SSE and store it @@ -1139,9 +1142,9 @@ def _cov_at_theta(self, method, solver, cov_n, step): f"Invalid method: '{method}'. Choose from {[e.value for e in CovarianceMethodLib]}." ) - # check if the user specified `SSE` or `SSE_weighted` as the objective function + # check if the user specified 'SSE' or 'SSE_weighted' as the objective function if self.obj_function == ObjectiveLib.SSE: - # check if user defined the `measurement_error` attribute + # check if the user defined the 'measurement_error' attribute if hasattr(model, "measurement_error"): # get the measurement errors meas_error = [ @@ -1217,14 +1220,14 @@ def _cov_at_theta(self, method, solver, cov_n, step): 'Experiment model does not have suffix "measurement_error".' ) elif self.obj_function == ObjectiveLib.SSE_weighted: - # check if the user defined the `measurement_error` attribute + # check if the user defined the 'measurement_error' attribute if hasattr(model, "measurement_error"): meas_error = [ model.measurement_error[y_hat] for y_hat, y in model.experiment_outputs.items() ] - # check if the user supplied values for the measurement errors + # check if the user supplied the values for the measurement errors if all(item is not None for item in meas_error): if ( cov_method == CovarianceMethodLib.finite_difference @@ -1517,6 +1520,13 @@ def theta_est(self, solver="ef_ipopt", return_values=[]): Variable values for each variable name in return_values (only for solver='ef_ipopt') """ + # check if we are using deprecated parmest + if self.pest_deprecated is not None: + return self.pest_deprecated.theta_est( + solver=solver, + return_values=return_values + ) + assert isinstance(solver, str) assert isinstance(return_values, list) From bc5da0f882c48bc1ed654f9edef8f615c3d123ba Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Sat, 7 Jun 2025 12:53:35 -0400 Subject: [PATCH 29/35] Updated the test_parmest.py file --- pyomo/contrib/parmest/tests/test_parmest.py | 419 +++++++++----------- 1 file changed, 181 insertions(+), 238 deletions(-) diff --git a/pyomo/contrib/parmest/tests/test_parmest.py b/pyomo/contrib/parmest/tests/test_parmest.py index 45fe95a4bd0..f386d946bd9 100644 --- a/pyomo/contrib/parmest/tests/test_parmest.py +++ b/pyomo/contrib/parmest/tests/test_parmest.py @@ -96,10 +96,9 @@ def __init__(self, experiment_number, hour, y, measurement_error_std): self.measurement_error_std = measurement_error_std def get_labeled_model(self): - if self.model is None: - self.create_model() - self.finalize_model() - self.label_model() + self.create_model() + self.finalize_model() + self.label_model() return self.model def create_model(self): @@ -153,6 +152,20 @@ def label_model(self): self.exp_list = rooney_biegler_exp_list + if self.objective_function == "incorrect_obj": + with pytest.raises( + ValueError, + match="Invalid objective function: 'incorrect_obj'\. " + "Choose from \['SSE', 'SSE_weighted'\]\.", + ): + self.pest = parmest.Estimator( + self.exp_list, obj_function=self.objective_function, tee=True + ) + else: + self.pest = parmest.Estimator( + self.exp_list, obj_function=self.objective_function, tee=True + ) + def check_rooney_biegler_parameters( self, obj_val, theta_vals, obj_function, measurement_error ): @@ -307,12 +320,12 @@ def test_parmest_covariance(self, cov_method): """ if self.measurement_std is None: if self.objective_function == "SSE": - pest = parmest.Estimator( - self.exp_list, obj_function=self.objective_function - ) + # pest = parmest.Estimator( + # self.exp_list, obj_function=self.objective_function + # ) # estimate the parameters - obj_val, theta_vals = pest.theta_est() + obj_val, theta_vals = self.pest.theta_est() # check the parameter estimation result self.check_rooney_biegler_parameters( @@ -328,7 +341,7 @@ def test_parmest_covariance(self, cov_method): "automatic_differentiation_kaug", "reduced_hessian", ): - cov = pest.cov_est(cov_n=6, method=cov_method) + cov = self.pest.cov_est(cov_n=6, method=cov_method) # check the covariance calculation results self.check_rooney_biegler_covariance( @@ -343,7 +356,7 @@ def test_parmest_covariance(self, cov_method): match=r"Invalid method: 'unsupported_method'\. Choose from \['finite_difference', " "'automatic_differentiation_kaug', 'reduced_hessian'\]\.", ): - cov = pest.cov_est(cov_n=6, method=cov_method) + cov = self.pest.cov_est(cov_n=6, method=cov_method) elif self.objective_function == "SSE_weighted": with pytest.raises( ValueError, @@ -351,32 +364,15 @@ def test_parmest_covariance(self, cov_method): '"measurement_error". All values of the measurement errors are ' 'required for the "SSE_weighted" objective.', ): - pest = parmest.Estimator( - self.exp_list, obj_function=self.objective_function - ) - # we expect this error when estimating the parameters - obj_val, theta_vals = pest.theta_est() - else: - with pytest.raises( - ValueError, - match=r"Invalid objective function: 'incorrect_obj'\. " - r"Choose from \['SSE', 'SSE_weighted'\]\.", - ): - pest = parmest.Estimator( - self.exp_list, obj_function=self.objective_function - ) + obj_val, theta_vals = self.pest.theta_est() else: if ( self.objective_function == "SSE" or self.objective_function == "SSE_weighted" ): - pest = parmest.Estimator( - self.exp_list, obj_function=self.objective_function - ) - # estimate the parameters - obj_val, theta_vals = pest.theta_est() + obj_val, theta_vals = self.pest.theta_est() # check the parameter estimation results self.check_rooney_biegler_parameters( @@ -392,7 +388,7 @@ def test_parmest_covariance(self, cov_method): "automatic_differentiation_kaug", "reduced_hessian", ): - cov = pest.cov_est(cov_n=6, method=cov_method) + cov = self.pest.cov_est(cov_n=6, method=cov_method) # check the covariance calculation results self.check_rooney_biegler_covariance( @@ -407,16 +403,7 @@ def test_parmest_covariance(self, cov_method): match=r"Invalid method: 'unsupported_method'\. Choose from \['finite_difference', " "'automatic_differentiation_kaug', 'reduced_hessian'\]\.", ): - cov = pest.cov_est(cov_n=6, method=cov_method) - else: - with pytest.raises( - ValueError, - match="Invalid objective function: 'incorrect_obj'\. " - "Choose from \['SSE', 'SSE_weighted'\]\.", - ): - pest = parmest.Estimator( - self.exp_list, obj_function=self.objective_function - ) + cov = self.pest.cov_est(cov_n=6, method=cov_method) @unittest.skipIf( not graphics.imports_available, "parmest.graphics imports are unavailable" @@ -433,21 +420,13 @@ def test_bootstrap(self): '"measurement_error". All values of the measurement errors are ' 'required for the "SSE_weighted" objective.', ): - pest = parmest.Estimator( - self.exp_list, obj_function=self.objective_function - ) - # we expect this error when estimating the parameters - obj_val, theta_vals = pest.theta_est() + obj_val, theta_vals = self.pest.theta_est() else: - pest = parmest.Estimator( - self.exp_list, obj_function=self.objective_function - ) - - obj_val, theta_vals = pest.theta_est() + obj_val, theta_vals = self.pest.theta_est() num_bootstraps = 10 - theta_est = pest.theta_est_bootstrap( + theta_est = self.pest.theta_est_bootstrap( num_bootstraps, return_samples=True ) @@ -458,7 +437,7 @@ def test_bootstrap(self): del theta_est["samples"] # apply confidence region test - CR = pest.confidence_region_test(theta_est, "MVN", [0.5, 0.75, 1.0]) + CR = self.pest.confidence_region_test(theta_est, "MVN", [0.5, 0.75, 1.0]) self.assertTrue(set(CR.columns) >= set([0.5, 0.75, 1.0])) self.assertEqual(CR[0.5].sum(), 5) @@ -470,15 +449,6 @@ def test_bootstrap(self): graphics.pairwise_plot( theta_est, theta_vals, 0.8, ["MVN", "KDE", "Rect"] ) - else: - with pytest.raises( - ValueError, - match="Invalid objective function: 'incorrect_obj'\. " - "Choose from \['SSE', 'SSE_weighted'\]\.", - ): - pest = parmest.Estimator( - self.exp_list, obj_function=self.objective_function - ) @unittest.skipIf( not graphics.imports_available, "parmest.graphics imports are unavailable" @@ -495,43 +465,26 @@ def test_likelihood_ratio(self): '"measurement_error". All values of the measurement errors are ' 'required for the "SSE_weighted" objective.', ): - pest = parmest.Estimator( - self.exp_list, obj_function=self.objective_function - ) - # we expect this error when estimating the parameters - obj_val, theta_vals = pest.theta_est() + obj_val, theta_vals = self.pest.theta_est() else: - pest = parmest.Estimator( - self.exp_list, obj_function=self.objective_function - ) - - obj_val, theta_vals = pest.theta_est() + objval, thetavals = self.pest.theta_est() asym = np.arange(10, 30, 2) rate = np.arange(0, 1.5, 0.25) theta_vals = pd.DataFrame( list(product(asym, rate)), columns=['asymptote', 'rate_constant'] ) - obj_at_theta = pest.objective_at_theta(theta_vals) + obj_at_theta = self.pest.objective_at_theta(theta_vals) - LR = pest.likelihood_ratio_test(obj_at_theta, obj_val, [0.8, 0.9, 1.0]) + LR = self.pest.likelihood_ratio_test(obj_at_theta, objval, [0.8, 0.9, 1.0]) self.assertTrue(set(LR.columns) >= set([0.8, 0.9, 1.0])) self.assertEqual(LR[0.8].sum(), 6) self.assertEqual(LR[0.9].sum(), 10) self.assertEqual(LR[1.0].sum(), 60) # all true - graphics.pairwise_plot(LR, theta_vals, 0.8) - else: - with pytest.raises( - ValueError, - match="Invalid objective function: 'incorrect_obj'\. " - "Choose from \['SSE', 'SSE_weighted'\]\.", - ): - pest = parmest.Estimator( - self.exp_list, obj_function=self.objective_function - ) + graphics.pairwise_plot(LR, thetavals, 0.8) def test_leaveNout(self): if self.objective_function in ("SSE", "SSE_weighted"): @@ -545,21 +498,13 @@ def test_leaveNout(self): '"measurement_error". All values of the measurement errors are ' 'required for the "SSE_weighted" objective.', ): - pest = parmest.Estimator( - self.exp_list, obj_function=self.objective_function - ) - # we expect this error when estimating the parameters - obj_val, theta_vals = pest.theta_est() + obj_val, theta_vals = self.pest.theta_est() else: - pest = parmest.Estimator( - self.exp_list, obj_function=self.objective_function - ) - - lNo_theta = pest.theta_est_leaveNout(1) + lNo_theta = self.pest.theta_est_leaveNout(1) self.assertTrue(lNo_theta.shape == (6, 2)) - results = pest.leaveNout_bootstrap_test( + results = self.pest.leaveNout_bootstrap_test( 1, None, 3, "Rect", [0.5, 1.0], seed=5436 ) self.assertEqual(len(results), 6) # 6 lNo samples @@ -573,15 +518,6 @@ def test_leaveNout(self): self.assertEqual(lno_theta[1.0].sum(), 1) # all true self.assertEqual(bootstrap_theta.shape[0], 3) # bootstrap for sample 1 self.assertEqual(bootstrap_theta[1.0].sum(), 3) # all true - else: - with pytest.raises( - ValueError, - match="Invalid objective function: 'incorrect_obj'\. " - "Choose from \['SSE', 'SSE_weighted'\]\.", - ): - pest = parmest.Estimator( - self.exp_list, obj_function=self.objective_function - ) def test_diagnostic_mode(self): if self.objective_function in ("SSE", "SSE_weighted"): @@ -595,20 +531,12 @@ def test_diagnostic_mode(self): '"measurement_error". All values of the measurement errors are ' 'required for the "SSE_weighted" objective.', ): - pest = parmest.Estimator( - self.exp_list, obj_function=self.objective_function - ) - # we expect this error when estimating the parameters - obj_val, theta_vals = pest.theta_est() + obj_val, theta_vals = self.pest.theta_est() else: - pest = parmest.Estimator( - self.exp_list, obj_function=self.objective_function - ) + self.pest.diagnostic_mode = True - pest.diagnostic_mode = True - - obj_val, theta_vals = pest.theta_est() + objval, thetavals = self.pest.theta_est() asym = np.arange(10, 30, 2) rate = np.arange(0, 1.5, 0.25) @@ -616,18 +544,9 @@ def test_diagnostic_mode(self): list(product(asym, rate)), columns=['asymptote', 'rate_constant'] ) - obj_at_theta = pest.objective_at_theta(theta_vals) + obj_at_theta = self.pest.objective_at_theta(theta_vals) - pest.diagnostic_mode = False - else: - with pytest.raises( - ValueError, - match="Invalid objective function: 'incorrect_obj'\. " - "Choose from \['SSE', 'SSE_weighted'\]\.", - ): - pest = parmest.Estimator( - self.exp_list, obj_function=self.objective_function - ) + self.pest.diagnostic_mode = False @unittest.skip("Presently having trouble with mpiexec on appveyor") def test_parallel_parmest(self): @@ -968,35 +887,39 @@ def test_parallel_parmest(self): @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") def test_theta_est_cov(self): - objval, thetavals, cov = self.pest.theta_est(calc_cov=True, cov_n=6) + with pytest.raises( + TypeError, + match=r"Estimator\.theta_est\(\) got an unexpected keyword argument 'calc_cov'", + ): + objval, thetavals, cov = self.pest.theta_est(calc_cov=True, cov_n=6) - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper - # Covariance matrix - self.assertAlmostEqual( - cov.iloc[0, 0], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[0, 1], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 0], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual(cov.iloc[1, 1], 0.04124, places=2) # 0.04124 from paper - - """ Why does the covariance matrix from parmest not match the paper? Parmest is - calculating the exact reduced Hessian. The paper (Rooney and Bielger, 2001) likely - employed the first order approximation common for nonlinear regression. The paper - values were verified with Scipy, which uses the same first order approximation. - The formula used in parmest was verified against equations (7-5-15) and (7-5-16) in - "Nonlinear Parameter Estimation", Y. Bard, 1974. - """ + # Covariance matrix + self.assertAlmostEqual( + cov.iloc[0, 0], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[0, 1], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 0], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual(cov.iloc[1, 1], 0.04124, places=2) # 0.04124 from paper + + """ Why does the covariance matrix from parmest not match the paper? Parmest is + calculating the exact reduced Hessian. The paper (Rooney and Bielger, 2001) likely + employed the first order approximation common for nonlinear regression. The paper + values were verified with Scipy, which uses the same first order approximation. + The formula used in parmest was verified against equations (7-5-15) and (7-5-16) in + "Nonlinear Parameter Estimation", Y. Bard, 1974. + """ def test_cov_scipy_least_squares_comparison(self): """ @@ -1234,24 +1157,28 @@ def test_parmest_basics(self): self.objective_function, ) - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - cov.iloc[0, 0], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[0, 1], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 0], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 1], 0.04193591, places=2 - ) # 0.04124 from paper - - obj_at_theta = pest.objective_at_theta(parmest_input["theta_vals"]) - self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + with pytest.raises( + TypeError, + match=r"Estimator\.theta_est\(\) got an unexpected keyword argument 'calc_cov'", + ): + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[0, 0], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[0, 1], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 0], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 1], 0.04193591, places=2 + ) # 0.04124 from paper + + obj_at_theta = pest.objective_at_theta(parmest_input["theta_vals"]) + self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') def test_parmest_basics_with_initialize_parmest_model_option(self): @@ -1263,27 +1190,31 @@ def test_parmest_basics_with_initialize_parmest_model_option(self): self.objective_function, ) - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - cov.iloc[0, 0], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[0, 1], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 0], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 1], 0.04193591, places=2 - ) # 0.04124 from paper - - obj_at_theta = pest.objective_at_theta( - parmest_input["theta_vals"], initialize_parmest_model=True - ) + with pytest.raises( + TypeError, + match=r"Estimator\.theta_est\(\) got an unexpected keyword argument 'calc_cov'", + ): + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[0, 0], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[0, 1], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 0], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 1], 0.04193591, places=2 + ) # 0.04124 from paper + + obj_at_theta = pest.objective_at_theta( + parmest_input["theta_vals"], initialize_parmest_model=True + ) - self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') def test_parmest_basics_with_square_problem_solve(self): @@ -1299,23 +1230,27 @@ def test_parmest_basics_with_square_problem_solve(self): parmest_input["theta_vals"], initialize_parmest_model=True ) - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - cov.iloc[0, 0], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[0, 1], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 0], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 1], 0.04193591, places=2 - ) # 0.04124 from paper - - self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + with pytest.raises( + TypeError, + match=r"Estimator\.theta_est\(\) got an unexpected keyword argument 'calc_cov'", + ): + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[0, 0], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[0, 1], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 0], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 1], 0.04193591, places=2 + ) # 0.04124 from paper + + self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') def test_parmest_basics_with_square_problem_solve_no_theta_vals(self): @@ -1329,21 +1264,25 @@ def test_parmest_basics_with_square_problem_solve_no_theta_vals(self): obj_at_theta = pest.objective_at_theta(initialize_parmest_model=True) - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - cov.iloc[0, 0], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[0, 1], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 0], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 1], 0.04193591, places=2 - ) # 0.04124 from paper + with pytest.raises( + TypeError, + match=r"Estimator\.theta_est\(\) got an unexpected keyword argument 'calc_cov'", + ): + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[0, 0], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[0, 1], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 0], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 1], 0.04193591, places=2 + ) # 0.04124 from paper @unittest.skipIf( @@ -1674,24 +1613,28 @@ def test_covariance(self): n = 60 # Compute covariance using parmest - obj, theta, cov = self.pest_df.theta_est(calc_cov=True, cov_n=n) - - # Compute covariance using interior_point - vars_list = [self.m_df.k1, self.m_df.k2] - solve_result, inv_red_hes = inv_reduced_hessian_barrier( - self.m_df, independent_variables=vars_list, tee=True - ) - l = len(vars_list) - cov_interior_point = 2 * obj / (n - l) * inv_red_hes - cov_interior_point = pd.DataFrame( - cov_interior_point, ["k1", "k2"], ["k1", "k2"] - ) + with pytest.raises( + TypeError, + match=r"Estimator\.theta_est\(\) got an unexpected keyword argument 'calc_cov'", + ): + obj, theta, cov = self.pest_df.theta_est(calc_cov=True, cov_n=n) + + # Compute covariance using interior_point + vars_list = [self.m_df.k1, self.m_df.k2] + solve_result, inv_red_hes = inv_reduced_hessian_barrier( + self.m_df, independent_variables=vars_list, tee=True + ) + l = len(vars_list) + cov_interior_point = 2 * obj / (n - l) * inv_red_hes + cov_interior_point = pd.DataFrame( + cov_interior_point, ["k1", "k2"], ["k1", "k2"] + ) - cov_diff = (cov - cov_interior_point).abs().sum().sum() + cov_diff = (cov - cov_interior_point).abs().sum().sum() - self.assertTrue(cov.loc["k1", "k1"] > 0) - self.assertTrue(cov.loc["k2", "k2"] > 0) - self.assertAlmostEqual(cov_diff, 0, places=6) + self.assertTrue(cov.loc["k1", "k1"] > 0) + self.assertTrue(cov.loc["k2", "k2"] > 0) + self.assertAlmostEqual(cov_diff, 0, places=6) @unittest.skipIf( From 3130ad7c3a4127458109d331142dd2ec2b09292e Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Fri, 13 Jun 2025 00:51:42 -0400 Subject: [PATCH 30/35] Updated parmest.py and test_parmest.py files --- pyomo/contrib/parmest/parmest.py | 246 ++- pyomo/contrib/parmest/tests/test_parmest.py | 1505 ++++++++++++++----- 2 files changed, 1339 insertions(+), 412 deletions(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index 17ea24e3bca..064fa73399f 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -298,14 +298,6 @@ def _check_model_labels_helper(model): 'Experiment model does not have suffix "experiment_outputs".' ) - # Check that experimental inputs exist - if hasattr(model, "experiment_inputs"): - pass - else: - raise AttributeError( - 'Experiment model does not have suffix "experiment_inputs".' - ) - # Check that unknown parameters exist if hasattr(model, "unknown_parameters"): pass @@ -349,6 +341,11 @@ class ObjectiveLib(Enum): SSE_weighted = "SSE_weighted" +class UnsupportedArgsLib(Enum): + calc_cov = "calc_cov" + cov_n = "cov_n" + + # Compute the Jacobian matrix of measured variables with respect to the parameters def _compute_jacobian(experiment, theta_vals, step, solver, tee): """ @@ -770,13 +767,24 @@ def __init__( _check_model_labels_helper(model) # populate keyword argument options - try: - self.obj_function = ObjectiveLib(obj_function) - except ValueError: - raise ValueError( - f"Invalid objective function: '{obj_function}'. " - f"Choose from {[e.value for e in ObjectiveLib]}." + if isinstance(obj_function, str): + try: + self.obj_function = ObjectiveLib(obj_function) + except ValueError: + raise ValueError( + f"Invalid objective function: '{obj_function}'. " + f"Choose from {[e.value for e in ObjectiveLib]}." + ) + else: + deprecation_warning( + "You're using a deprecated input to the `obj_function` argument by " + "passing a custom function. This usage will be removed in a " + "future release. Please update to the new parmest interface using " + "the built-in 'SSE' and 'SSE_weighted' objectives.", + version="6.7.2", ) + self.obj_function = obj_function + self.tee = tee self.diagnostic_mode = diagnostic_mode self.solver_options = solver_options @@ -908,15 +916,19 @@ def _create_parmest_model(self, experiment_number): # TODO, this needs to be turned into an enum class of options that still support # custom functions - if self.obj_function == ObjectiveLib.SSE: - second_stage_rule = SSE - elif self.obj_function == ObjectiveLib.SSE_weighted: - second_stage_rule = SSE_weighted + if isinstance(self.obj_function, Enum): + if self.obj_function == ObjectiveLib.SSE: + second_stage_rule = SSE + elif self.obj_function == ObjectiveLib.SSE_weighted: + second_stage_rule = SSE_weighted + else: + raise ValueError( + f"Invalid objective function: '{self.obj_function.value}'. " + f"Choose from {[e.value for e in ObjectiveLib]}." + ) else: - raise ValueError( - f"Invalid objective function: '{self.obj_function.value}'. " - f"Choose from {[e.value for e in ObjectiveLib]}." - ) + # A custom function uses model.experiment_outputs as data + second_stage_rule = self.obj_function model.FirstStageCost = pyo.Expression(expr=0) model.SecondStageCost = pyo.Expression(rule=second_stage_rule) @@ -939,7 +951,7 @@ def _instance_creation_callback(self, experiment_number=None, cb_data=None): return model def _Q_opt( - self, ThetaVals=None, solver="ef_ipopt", return_values=[], bootlist=None + self, ThetaVals=None, solver="ef_ipopt", return_values=[], bootlist=None, **kwargs ): """ Set up all thetas as first stage Vars, return resulting theta @@ -988,28 +1000,71 @@ def _Q_opt( # Solve the extensive form with ipopt if solver == "ef_ipopt": - # The import error will be raised when we attempt to use - # inv_reduced_hessian_barrier below. - # - # elif not asl_available: - # raise ImportError("parmest requires ASL to calculate the " - # "covariance matrix with solver 'ipopt'") + if not kwargs: + # The import error will be raised when we attempt to use + # inv_reduced_hessian_barrier below. + # + # elif not asl_available: + # raise ImportError("parmest requires ASL to calculate the " + # "covariance matrix with solver 'ipopt'") - # parmest makes the fitted parameters stage 1 variables - ind_vars = [] - for nd_name, Var, sol_val in ef_nonants(ef): - ind_vars.append(Var) - # calculate the reduced hessian - (solve_result, inv_red_hes) = ( - inverse_reduced_hessian.inv_reduced_hessian_barrier( - self.ef_instance, - independent_variables=ind_vars, - solver_options=self.solver_options, - tee=self.tee, + # parmest makes the fitted parameters stage 1 variables + ind_vars = [] + for nd_name, Var, sol_val in ef_nonants(ef): + ind_vars.append(Var) + # calculate the reduced hessian + (solve_result, inv_red_hes) = ( + inverse_reduced_hessian.inv_reduced_hessian_barrier( + self.ef_instance, + independent_variables=ind_vars, + solver_options=self.solver_options, + tee=self.tee, + ) ) - ) - self.inv_red_hes = inv_red_hes + self.inv_red_hes = inv_red_hes + elif kwargs and all(arg.value in kwargs for arg in UnsupportedArgsLib): + deprecation_warning( + "You're using a deprecated call to the `theta_est()` function " + "with the `calc_cov` and `cov_n` arguments. This usage will be " + "removed in a future release. Please update to the new parmest " + "interface using `cov_est()` function for covariance calculation.", + version="6.7.2", + ) + + calc_cov = kwargs[UnsupportedArgsLib.calc_cov.value] + cov_n = kwargs[UnsupportedArgsLib.cov_n.value] + + if not calc_cov: + # Do not calculate the reduced hessian + + solver = SolverFactory('ipopt') + if self.solver_options is not None: + for key in self.solver_options: + solver.options[key] = self.solver_options[key] + + solve_result = solver.solve(self.ef_instance, tee=self.tee) + + # The import error will be raised when we attempt to use + # inv_reduced_hessian_barrier below. + # + # elif not asl_available: + # raise ImportError("parmest requires ASL to calculate the " + # "covariance matrix with solver 'ipopt'") + else: + # parmest makes the fitted parameters stage 1 variables + ind_vars = [] + for ndname, Var, solval in ef_nonants(ef): + ind_vars.append(Var) + # calculate the reduced hessian + (solve_result, inv_red_hes) = ( + inverse_reduced_hessian.inv_reduced_hessian_barrier( + self.ef_instance, + independent_variables=ind_vars, + solver_options=self.solver_options, + tee=self.tee, + ) + ) if self.diagnostic_mode: print( @@ -1027,9 +1082,38 @@ def _Q_opt( obj_val = pyo.value(ef.EF_Obj) - # add the estimated theta and objective value to the class + # add the estimated theta to the class self.estimated_theta = theta_vals + if kwargs and all(arg.value in kwargs for arg in UnsupportedArgsLib): + if calc_cov: + # Calculate the covariance matrix + + # Number of data points considered + n = cov_n + + # Extract number of fitted parameters + l = len(theta_vals) + + # Assumption: Objective value is sum of squared errors + sse = obj_val + + '''Calculate covariance assuming experimental observation errors are + independent and follow a Gaussian + distribution with constant variance. + + The formula used in parmest was verified against equations (7-5-15) and + (7-5-16) in "Nonlinear Parameter Estimation", Y. Bard, 1974. + + This formula is also applicable if the objective is scaled by a constant; + the constant cancels out. (was scaled by 1/n because it computes an + expected value.) + ''' + cov = 2 * sse / (n - l) * inv_red_hes + cov = pd.DataFrame( + cov, index=theta_vals.keys(), columns=theta_vals.keys() + ) + theta_vals = pd.Series(theta_vals) if len(return_values) > 0: @@ -1045,7 +1129,7 @@ def _Q_opt( for var in return_values: exp_i_var = exp_i.find_component(str(var)) if ( - exp_i_var is None + exp_i_var is None ): # we might have a block such as _mpisppy_data continue # if value to return is ContinuousSet @@ -1061,9 +1145,21 @@ def _Q_opt( var_values.append(vals) var_values = pd.DataFrame(var_values) - return obj_val, theta_vals, var_values + if not kwargs: + return obj_val, theta_vals, var_values + elif kwargs and all(arg.value in kwargs for arg in UnsupportedArgsLib): + if calc_cov: + return obj_val, theta_vals, var_values, cov + else: + return obj_val, theta_vals, var_values - return obj_val, theta_vals + if not kwargs: + return obj_val, theta_vals + elif kwargs and all(arg.value in kwargs for arg in UnsupportedArgsLib): + if calc_cov: + return obj_val, theta_vals, cov + else: + return obj_val, theta_vals else: raise RuntimeError("Unknown solver in Q_Opt=" + solver) @@ -1087,7 +1183,7 @@ def _cov_at_theta(self, method, solver, cov_n, step): # Extract number of fitted parameters l = len(self.estimated_theta) - # calculate the sum of squared errors at the estimated parameters + # calculate the sum of squared errors at the estimated parameter values sse_vals = [] for experiment in self.exp_list: model = _get_labeled_model_helper(experiment) @@ -1107,7 +1203,7 @@ def _cov_at_theta(self, method, solver, cov_n, step): f"The original error was {e}." ) - # choose and evaluate the objective expression + # choose and evaluate the sum of squared errors expression if self.obj_function == ObjectiveLib.SSE: sse_expr = SSE(model) elif self.obj_function == ObjectiveLib.SSE_weighted: @@ -1118,7 +1214,7 @@ def _cov_at_theta(self, method, solver, cov_n, step): f"Choose from {[e.value for e in ObjectiveLib]}." ) - # evaluate numerical SSE and store it + # evaluate the numerical SSE and store it sse_val = pyo.value(sse_expr) sse_vals.append(sse_val) @@ -1134,7 +1230,7 @@ def _cov_at_theta(self, method, solver, cov_n, step): the constant cancels out. (was scaled by 1/n because it computes an expected value.) """ - # check if the supplied method is supported + # check if the user-supplied covariance method is supported try: cov_method = CovarianceMethodLib(method) except ValueError: @@ -1499,7 +1595,7 @@ def _get_sample_list(self, samplesize, num_samples, replacement=True): return samplelist - def theta_est(self, solver="ef_ipopt", return_values=[]): + def theta_est(self, solver="ef_ipopt", return_values=[], **kwargs): """ Parameter estimation using all scenarios in the data @@ -1522,15 +1618,25 @@ def theta_est(self, solver="ef_ipopt", return_values=[]): # check if we are using deprecated parmest if self.pest_deprecated is not None: - return self.pest_deprecated.theta_est( - solver=solver, - return_values=return_values - ) + if not kwargs: + return self.pest_deprecated.theta_est( + solver=solver, + return_values=return_values, + ) + elif kwargs and all(arg.value in kwargs for arg in UnsupportedArgsLib): + calc_cov = kwargs[UnsupportedArgsLib.calc_cov.value] + cov_n = kwargs[UnsupportedArgsLib.cov_n.value] + return self.pest_deprecated.theta_est( + solver=solver, + return_values=return_values, + calc_cov=calc_cov, + cov_n=cov_n, + ) assert isinstance(solver, str) assert isinstance(return_values, list) - return self._Q_opt(solver=solver, return_values=return_values, bootlist=None) + return self._Q_opt(solver=solver, return_values=return_values, bootlist=None, **kwargs) def cov_est( self, method="finite_difference", solver="ipopt", cov_n=None, step=1e-3 @@ -2362,9 +2468,31 @@ def _Q_opt( objval = pyo.value(ef.EF_Obj) if calc_cov: - raise NotImplementedError( - "Computing the covariance is no longer supported " - "in the deprecated interface" + # Calculate the covariance matrix + + # Number of data points considered + n = cov_n + + # Extract number of fitted parameters + l = len(thetavals) + + # Assumption: Objective value is sum of squared errors + sse = objval + + '''Calculate covariance assuming experimental observation errors are + independent and follow a Gaussian + distribution with constant variance. + + The formula used in parmest was verified against equations (7-5-15) and + (7-5-16) in "Nonlinear Parameter Estimation", Y. Bard, 1974. + + This formula is also applicable if the objective is scaled by a constant; + the constant cancels out. (was scaled by 1/n because it computes an + expected value.) + ''' + cov = 2 * sse / (n - l) * inv_red_hes + cov = pd.DataFrame( + cov, index=thetavals.keys(), columns=thetavals.keys() ) thetavals = pd.Series(thetavals) diff --git a/pyomo/contrib/parmest/tests/test_parmest.py b/pyomo/contrib/parmest/tests/test_parmest.py index f386d946bd9..704ac68b1b5 100644 --- a/pyomo/contrib/parmest/tests/test_parmest.py +++ b/pyomo/contrib/parmest/tests/test_parmest.py @@ -56,7 +56,7 @@ (0.1, "incorrect_obj"), ], ) -class TestRooneyBiegler(unittest.TestCase): +class NewTestRooneyBiegler(unittest.TestCase): def setUp(self): self.data = pd.DataFrame( @@ -170,14 +170,16 @@ def check_rooney_biegler_parameters( self, obj_val, theta_vals, obj_function, measurement_error ): """ - Checks if the objective value and parameter estimates are equal to the expected values - and agree with the results of the Rooney-Biegler paper + Checks if the objective value and parameter estimates are equal to the expected + values and agree with the results of the Rooney-Biegler paper Argument: obj_val: the objective value of the annotated Pyomo model theta_vals: dictionary of the estimated parameters - obj_function: a string of the objective function supplied by the user, e.g., 'SSE' - measurement_error: float or integer value of the measurement error standard deviation + obj_function: a string of the objective function supplied by the user, + e.g., 'SSE' + measurement_error: float or integer value of the measurement error + standard deviation """ if obj_function == "SSE": self.assertAlmostEqual(obj_val, 4.33171, places=2) @@ -201,9 +203,12 @@ def check_rooney_biegler_covariance( Argument: cov: pd.DataFrame, covariance matrix of the estimated parameters cov_method: string ``method`` object specified by the user - options - 'finite_difference', 'reduced_hessian', and 'automatic_differentiation_kaug' - obj_function: a string of the objective function supplied by the user, e.g., 'SSE' - measurement_error: float or integer value of the measurement error standard deviation + options - 'finite_difference', 'reduced_hessian', + and 'automatic_differentiation_kaug' + obj_function: a string of the objective function supplied by the user, + e.g., 'SSE' + measurement_error: float or integer value of the measurement error + standard deviation """ # get indices in covariance matrix @@ -301,7 +306,8 @@ def check_rooney_biegler_covariance( ) # test and check the covariance calculation for all the three supported methods - # added an 'unsupported_method' to test the error message when the method supplied is not supported + # added an 'unsupported_method' to test the error message when the method supplied + # is not supported @parameterized.expand( [ ("finite_difference"), @@ -312,17 +318,16 @@ def check_rooney_biegler_covariance( ) def test_parmest_covariance(self, cov_method): """ - Calculates the parameter estimates and covariance matrix and compares them with the results of Rooney-Biegler + Calculates the parameter estimates and covariance matrix and compares them + with the results of Rooney-Biegler Argument: cov_method: string ``method`` object specified by the user - options - 'finite_difference', 'reduced_hessian', and 'automatic_differentiation_kaug' + options - 'finite_difference', 'reduced_hessian', + and 'automatic_differentiation_kaug' """ if self.measurement_std is None: if self.objective_function == "SSE": - # pest = parmest.Estimator( - # self.exp_list, obj_function=self.objective_function - # ) # estimate the parameters obj_val, theta_vals = self.pest.theta_est() @@ -405,148 +410,269 @@ def test_parmest_covariance(self, cov_method): ): cov = self.pest.cov_est(cov_n=6, method=cov_method) + def test_cov_scipy_least_squares_comparison(self): + """ + Scipy results differ in the 3rd decimal place from the paper. It is possible + the paper used an alternative finite difference approximation for the Jacobian. + """ + + def model(theta, t): + """ + Model to be fitted y = model(theta, t) + Arguments: + theta: vector of fitted parameters + t: independent variable [hours] + + Returns: + y: model predictions [need to check paper for units] + """ + asymptote = theta[0] + rate_constant = theta[1] + + return asymptote * (1 - np.exp(-rate_constant * t)) + + def residual(theta, t, y): + """ + Calculate residuals + Arguments: + theta: vector of fitted parameters + t: independent variable [hours] + y: dependent variable [?] + """ + return y - model(theta, t) + + # define data + t = self.data["hour"].to_numpy() + y = self.data["y"].to_numpy() + + # define initial guess + theta_guess = np.array([15, 0.5]) + + ## solve with optimize.least_squares + sol = scipy.optimize.least_squares( + residual, theta_guess, method="trf", args=(t, y), verbose=2 + ) + theta_hat = sol.x + + self.assertAlmostEqual( + theta_hat[0], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper + + # calculate residuals + r = residual(theta_hat, t, y) + + if self.measurement_std is None: + # calculate variance of the residuals + # -2 because there are 2 fitted parameters + sigre = np.matmul(r.T, r / (len(y) - 2)) + + # approximate covariance + cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) + + self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper + self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + else: + sigre = self.measurement_std**2 + + cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) + + self.assertAlmostEqual(cov[0, 0], 0.009588, places=4) + self.assertAlmostEqual(cov[0, 1], -0.000665, places=4) + self.assertAlmostEqual(cov[1, 0], -0.000665, places=4) + self.assertAlmostEqual(cov[1, 1], 0.000063, places=4) + + def test_cov_scipy_curve_fit_comparison(self): + """ + Scipy results differ in the 3rd decimal place from the paper. It is possible + the paper used an alternative finite difference approximation for the Jacobian. + """ + + ## solve with optimize.curve_fit + def model(t, asymptote, rate_constant): + return asymptote * (1 - np.exp(-rate_constant * t)) + + # define data + t = self.data["hour"].to_numpy() + y = self.data["y"].to_numpy() + + # define initial guess + theta_guess = np.array([15, 0.5]) + + # estimate the parameters and covariance matrix + if self.measurement_std is None: + theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess) + + self.assertAlmostEqual( + theta_hat[0], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + theta_hat[1], 0.5311, places=2 + ) # 0.5311 from the paper + + self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper + self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + else: + theta_hat, cov = scipy.optimize.curve_fit( + model, + t, + y, + p0=theta_guess, + sigma=self.measurement_std, + absolute_sigma=True, + ) + + self.assertAlmostEqual( + theta_hat[0], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + theta_hat[1], 0.5311, places=2 + ) # 0.5311 from the paper + + self.assertAlmostEqual(cov[0, 0], 0.0095875, places=4) + self.assertAlmostEqual(cov[0, 1], -0.0006653, places=4) + self.assertAlmostEqual(cov[1, 0], -0.0006653, places=4) + self.assertAlmostEqual(cov[1, 1], 0.00006347, places=4) + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestRooneyBiegler(unittest.TestCase): + def setUp(self): + from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import ( + RooneyBieglerExperiment, + ) + + # Note, the data used in this test has been corrected to use + # data.loc[5,'hour'] = 7 (instead of 6) + data = pd.DataFrame( + data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], + columns=["hour", "y"], + ) + + # Sum of squared error function + def SSE(model): + expr = ( + model.experiment_outputs[model.y] + - model.response_function[model.experiment_outputs[model.hour]] + ) ** 2 + return expr + + # Create an experiment list + exp_list = [] + for i in range(data.shape[0]): + exp_list.append(RooneyBieglerExperiment(data.loc[i, :])) + + # Create an instance of the parmest estimator + pest = parmest.Estimator(exp_list, obj_function=SSE) + + solver_options = {"tol": 1e-8} + + self.data = data + self.pest = parmest.Estimator( + exp_list, obj_function=SSE, solver_options=solver_options, tee=True + ) + + def test_theta_est(self): + objval, thetavals = self.pest.theta_est() + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + @unittest.skipIf( not graphics.imports_available, "parmest.graphics imports are unavailable" ) def test_bootstrap(self): - if self.objective_function in ("SSE", "SSE_weighted"): - if ( - self.objective_function == "SSE_weighted" - and self.measurement_std is None - ): - with pytest.raises( - ValueError, - match='One or more values are missing from ' - '"measurement_error". All values of the measurement errors are ' - 'required for the "SSE_weighted" objective.', - ): - # we expect this error when estimating the parameters - obj_val, theta_vals = self.pest.theta_est() - else: - obj_val, theta_vals = self.pest.theta_est() + objval, thetavals = self.pest.theta_est() - num_bootstraps = 10 - theta_est = self.pest.theta_est_bootstrap( - num_bootstraps, return_samples=True - ) + num_bootstraps = 10 + theta_est = self.pest.theta_est_bootstrap(num_bootstraps, return_samples=True) - num_samples = theta_est["samples"].apply(len) - self.assertEqual(len(theta_est.index), 10) - self.assertTrue(num_samples.equals(pd.Series([6] * 10))) + num_samples = theta_est["samples"].apply(len) + self.assertEqual(len(theta_est.index), 10) + self.assertTrue(num_samples.equals(pd.Series([6] * 10))) - del theta_est["samples"] + del theta_est["samples"] - # apply confidence region test - CR = self.pest.confidence_region_test(theta_est, "MVN", [0.5, 0.75, 1.0]) + # apply confidence region test + CR = self.pest.confidence_region_test(theta_est, "MVN", [0.5, 0.75, 1.0]) - self.assertTrue(set(CR.columns) >= set([0.5, 0.75, 1.0])) - self.assertEqual(CR[0.5].sum(), 5) - self.assertEqual(CR[0.75].sum(), 7) - self.assertEqual(CR[1.0].sum(), 10) # all true + self.assertTrue(set(CR.columns) >= set([0.5, 0.75, 1.0])) + self.assertEqual(CR[0.5].sum(), 5) + self.assertEqual(CR[0.75].sum(), 7) + self.assertEqual(CR[1.0].sum(), 10) # all true - graphics.pairwise_plot(theta_est) - graphics.pairwise_plot(theta_est, theta_vals) - graphics.pairwise_plot( - theta_est, theta_vals, 0.8, ["MVN", "KDE", "Rect"] - ) + graphics.pairwise_plot(theta_est) + graphics.pairwise_plot(theta_est, thetavals) + graphics.pairwise_plot(theta_est, thetavals, 0.8, ["MVN", "KDE", "Rect"]) @unittest.skipIf( not graphics.imports_available, "parmest.graphics imports are unavailable" ) def test_likelihood_ratio(self): - if self.objective_function in ("SSE", "SSE_weighted"): - if ( - self.objective_function == "SSE_weighted" - and self.measurement_std is None - ): - with pytest.raises( - ValueError, - match='One or more values are missing from ' - '"measurement_error". All values of the measurement errors are ' - 'required for the "SSE_weighted" objective.', - ): - # we expect this error when estimating the parameters - obj_val, theta_vals = self.pest.theta_est() - else: - objval, thetavals = self.pest.theta_est() + objval, thetavals = self.pest.theta_est() - asym = np.arange(10, 30, 2) - rate = np.arange(0, 1.5, 0.25) - theta_vals = pd.DataFrame( - list(product(asym, rate)), columns=['asymptote', 'rate_constant'] - ) - obj_at_theta = self.pest.objective_at_theta(theta_vals) + asym = np.arange(10, 30, 2) + rate = np.arange(0, 1.5, 0.25) + theta_vals = pd.DataFrame( + list(product(asym, rate)), columns=['asymptote', 'rate_constant'] + ) + obj_at_theta = self.pest.objective_at_theta(theta_vals) - LR = self.pest.likelihood_ratio_test(obj_at_theta, objval, [0.8, 0.9, 1.0]) + LR = self.pest.likelihood_ratio_test(obj_at_theta, objval, [0.8, 0.9, 1.0]) - self.assertTrue(set(LR.columns) >= set([0.8, 0.9, 1.0])) - self.assertEqual(LR[0.8].sum(), 6) - self.assertEqual(LR[0.9].sum(), 10) - self.assertEqual(LR[1.0].sum(), 60) # all true + self.assertTrue(set(LR.columns) >= set([0.8, 0.9, 1.0])) + self.assertEqual(LR[0.8].sum(), 6) + self.assertEqual(LR[0.9].sum(), 10) + self.assertEqual(LR[1.0].sum(), 60) # all true - graphics.pairwise_plot(LR, thetavals, 0.8) + graphics.pairwise_plot(LR, thetavals, 0.8) def test_leaveNout(self): - if self.objective_function in ("SSE", "SSE_weighted"): - if ( - self.objective_function == "SSE_weighted" - and self.measurement_std is None - ): - with pytest.raises( - ValueError, - match='One or more values are missing from ' - '"measurement_error". All values of the measurement errors are ' - 'required for the "SSE_weighted" objective.', - ): - # we expect this error when estimating the parameters - obj_val, theta_vals = self.pest.theta_est() - else: - lNo_theta = self.pest.theta_est_leaveNout(1) - self.assertTrue(lNo_theta.shape == (6, 2)) + lNo_theta = self.pest.theta_est_leaveNout(1) + self.assertTrue(lNo_theta.shape == (6, 2)) - results = self.pest.leaveNout_bootstrap_test( - 1, None, 3, "Rect", [0.5, 1.0], seed=5436 - ) - self.assertEqual(len(results), 6) # 6 lNo samples - i = 1 - samples = results[i][0] # list of N samples that are left out - lno_theta = results[i][1] - bootstrap_theta = results[i][2] - self.assertTrue(samples == [1]) # sample 1 was left out - self.assertEqual(lno_theta.shape[0], 1) # lno estimate for sample 1 - self.assertTrue(set(lno_theta.columns) >= set([0.5, 1.0])) - self.assertEqual(lno_theta[1.0].sum(), 1) # all true - self.assertEqual(bootstrap_theta.shape[0], 3) # bootstrap for sample 1 - self.assertEqual(bootstrap_theta[1.0].sum(), 3) # all true + results = self.pest.leaveNout_bootstrap_test( + 1, None, 3, "Rect", [0.5, 1.0], seed=5436 + ) + self.assertEqual(len(results), 6) # 6 lNo samples + i = 1 + samples = results[i][0] # list of N samples that are left out + lno_theta = results[i][1] + bootstrap_theta = results[i][2] + self.assertTrue(samples == [1]) # sample 1 was left out + self.assertEqual(lno_theta.shape[0], 1) # lno estimate for sample 1 + self.assertTrue(set(lno_theta.columns) >= set([0.5, 1.0])) + self.assertEqual(lno_theta[1.0].sum(), 1) # all true + self.assertEqual(bootstrap_theta.shape[0], 3) # bootstrap for sample 1 + self.assertEqual(bootstrap_theta[1.0].sum(), 3) # all true def test_diagnostic_mode(self): - if self.objective_function in ("SSE", "SSE_weighted"): - if ( - self.objective_function == "SSE_weighted" - and self.measurement_std is None - ): - with pytest.raises( - ValueError, - match='One or more values are missing from ' - '"measurement_error". All values of the measurement errors are ' - 'required for the "SSE_weighted" objective.', - ): - # we expect this error when estimating the parameters - obj_val, theta_vals = self.pest.theta_est() - else: - self.pest.diagnostic_mode = True + self.pest.diagnostic_mode = True - objval, thetavals = self.pest.theta_est() + objval, thetavals = self.pest.theta_est() - asym = np.arange(10, 30, 2) - rate = np.arange(0, 1.5, 0.25) - theta_vals = pd.DataFrame( - list(product(asym, rate)), columns=['asymptote', 'rate_constant'] - ) + asym = np.arange(10, 30, 2) + rate = np.arange(0, 1.5, 0.25) + theta_vals = pd.DataFrame( + list(product(asym, rate)), columns=['asymptote', 'rate_constant'] + ) - obj_at_theta = self.pest.objective_at_theta(theta_vals) + obj_at_theta = self.pest.objective_at_theta(theta_vals) - self.pest.diagnostic_mode = False + self.pest.diagnostic_mode = False @unittest.skip("Presently having trouble with mpiexec on appveyor") def test_parallel_parmest(self): @@ -573,6 +699,40 @@ def test_parallel_parmest(self): retcode = subprocess.call(rlist) self.assertEqual(retcode, 0) + @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") + def test_theta_est_cov(self): + objval, thetavals, cov = self.pest.theta_est(calc_cov=True, cov_n=6) + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper + + # Covariance matrix + self.assertAlmostEqual( + cov["asymptote"]["asymptote"], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov["asymptote"]["rate_constant"], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov["rate_constant"]["asymptote"], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov["rate_constant"]["rate_constant"], 0.04124, places=2 + ) # 0.04124 from paper + + """ Why does the covariance matrix from parmest not match the paper? Parmest is + calculating the exact reduced Hessian. The paper (Rooney and Bielger, 2001) likely + employed the first order approximation common for nonlinear regression. The paper + values were verified with Scipy, which uses the same first order approximation. + The formula used in parmest was verified against equations (7-5-15) and (7-5-16) in + "Nonlinear Parameter Estimation", Y. Bard, 1974. + """ + def test_cov_scipy_least_squares_comparison(self): """ Scipy results differ in the 3rd decimal place from the paper. It is possible @@ -594,113 +754,776 @@ def model(theta, t): return asymptote * (1 - np.exp(-rate_constant * t)) - def residual(theta, t, y): - """ - Calculate residuals - Arguments: - theta: vector of fitted parameters - t: independent variable [hours] - y: dependent variable [?] - """ - return y - model(theta, t) + def residual(theta, t, y): + """ + Calculate residuals + Arguments: + theta: vector of fitted parameters + t: independent variable [hours] + y: dependent variable [?] + """ + return y - model(theta, t) + + # define data + t = self.data["hour"].to_numpy() + y = self.data["y"].to_numpy() + + # define initial guess + theta_guess = np.array([15, 0.5]) + + ## solve with optimize.least_squares + sol = scipy.optimize.least_squares( + residual, theta_guess, method="trf", args=(t, y), verbose=2 + ) + theta_hat = sol.x + + self.assertAlmostEqual( + theta_hat[0], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper + + # calculate residuals + r = residual(theta_hat, t, y) + + # calculate variance of the residuals + # -2 because there are 2 fitted parameters + sigre = np.matmul(r.T, r / (len(y) - 2)) + + # approximate covariance + # Need to divide by 2 because optimize.least_squares scaled the objective by 1/2 + cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) + + self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper + self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + + def test_cov_scipy_curve_fit_comparison(self): + """ + Scipy results differ in the 3rd decimal place from the paper. It is possible + the paper used an alternative finite difference approximation for the Jacobian. + """ + + ## solve with optimize.curve_fit + def model(t, asymptote, rate_constant): + return asymptote * (1 - np.exp(-rate_constant * t)) + + # define data + t = self.data["hour"].to_numpy() + y = self.data["y"].to_numpy() + + # define initial guess + theta_guess = np.array([15, 0.5]) + + theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess) + + self.assertAlmostEqual( + theta_hat[0], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper + + self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper + self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper + self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestModelVariants(unittest.TestCase): + + def setUp(self): + from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler import ( + RooneyBieglerExperiment, + ) + + self.data = pd.DataFrame( + data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], + columns=["hour", "y"], + ) + + def rooney_biegler_params(data): + model = pyo.ConcreteModel() + + model.asymptote = pyo.Param(initialize=15, mutable=True) + model.rate_constant = pyo.Param(initialize=0.5, mutable=True) + + model.hour = pyo.Param(within=pyo.PositiveReals, mutable=True) + model.y = pyo.Param(within=pyo.PositiveReals, mutable=True) + + def response_rule(m, h): + expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) + return expr + + model.response_function = pyo.Expression(data.hour, rule=response_rule) + + return model + + class RooneyBieglerExperimentParams(RooneyBieglerExperiment): + + def create_model(self): + data_df = self.data.to_frame().transpose() + self.model = rooney_biegler_params(data_df) + + rooney_biegler_params_exp_list = [] + for i in range(self.data.shape[0]): + rooney_biegler_params_exp_list.append( + RooneyBieglerExperimentParams(self.data.loc[i, :]) + ) + + def rooney_biegler_indexed_params(data): + model = pyo.ConcreteModel() + + model.param_names = pyo.Set(initialize=["asymptote", "rate_constant"]) + model.theta = pyo.Param( + model.param_names, + initialize={"asymptote": 15, "rate_constant": 0.5}, + mutable=True, + ) + + model.hour = pyo.Param(within=pyo.PositiveReals, mutable=True) + model.y = pyo.Param(within=pyo.PositiveReals, mutable=True) + + def response_rule(m, h): + expr = m.theta["asymptote"] * ( + 1 - pyo.exp(-m.theta["rate_constant"] * h) + ) + return expr + + model.response_function = pyo.Expression(data.hour, rule=response_rule) + + return model + + class RooneyBieglerExperimentIndexedParams(RooneyBieglerExperiment): + + def create_model(self): + data_df = self.data.to_frame().transpose() + self.model = rooney_biegler_indexed_params(data_df) + + def label_model(self): + + m = self.model + + m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.experiment_outputs.update( + [(m.hour, self.data["hour"]), (m.y, self.data["y"])] + ) + + m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.unknown_parameters.update((k, pyo.ComponentUID(k)) for k in [m.theta]) + + rooney_biegler_indexed_params_exp_list = [] + for i in range(self.data.shape[0]): + rooney_biegler_indexed_params_exp_list.append( + RooneyBieglerExperimentIndexedParams(self.data.loc[i, :]) + ) + + def rooney_biegler_vars(data): + model = pyo.ConcreteModel() + + model.asymptote = pyo.Var(initialize=15) + model.rate_constant = pyo.Var(initialize=0.5) + model.asymptote.fixed = True # parmest will unfix theta variables + model.rate_constant.fixed = True + + model.hour = pyo.Param(within=pyo.PositiveReals, mutable=True) + model.y = pyo.Param(within=pyo.PositiveReals, mutable=True) + + def response_rule(m, h): + expr = m.asymptote * (1 - pyo.exp(-m.rate_constant * h)) + return expr + + model.response_function = pyo.Expression(data.hour, rule=response_rule) + + return model + + class RooneyBieglerExperimentVars(RooneyBieglerExperiment): + + def create_model(self): + data_df = self.data.to_frame().transpose() + self.model = rooney_biegler_vars(data_df) + + rooney_biegler_vars_exp_list = [] + for i in range(self.data.shape[0]): + rooney_biegler_vars_exp_list.append( + RooneyBieglerExperimentVars(self.data.loc[i, :]) + ) + + def rooney_biegler_indexed_vars(data): + model = pyo.ConcreteModel() + + model.var_names = pyo.Set(initialize=["asymptote", "rate_constant"]) + model.theta = pyo.Var( + model.var_names, initialize={"asymptote": 15, "rate_constant": 0.5} + ) + model.theta["asymptote"].fixed = ( + True # parmest will unfix theta variables, even when they are indexed + ) + model.theta["rate_constant"].fixed = True + + model.hour = pyo.Param(within=pyo.PositiveReals, mutable=True) + model.y = pyo.Param(within=pyo.PositiveReals, mutable=True) + + def response_rule(m, h): + expr = m.theta["asymptote"] * ( + 1 - pyo.exp(-m.theta["rate_constant"] * h) + ) + return expr + + model.response_function = pyo.Expression(data.hour, rule=response_rule) + + return model + + class RooneyBieglerExperimentIndexedVars(RooneyBieglerExperiment): + + def create_model(self): + data_df = self.data.to_frame().transpose() + self.model = rooney_biegler_indexed_vars(data_df) + + def label_model(self): + + m = self.model + + m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.experiment_outputs.update( + [(m.hour, self.data["hour"]), (m.y, self.data["y"])] + ) + + m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.unknown_parameters.update((k, pyo.ComponentUID(k)) for k in [m.theta]) + + rooney_biegler_indexed_vars_exp_list = [] + for i in range(self.data.shape[0]): + rooney_biegler_indexed_vars_exp_list.append( + RooneyBieglerExperimentIndexedVars(self.data.loc[i, :]) + ) + + # Sum of squared error function + def SSE(model): + expr = ( + model.experiment_outputs[model.y] + - model.response_function[model.experiment_outputs[model.hour]] + ) ** 2 + return expr + + self.objective_function = SSE + + theta_vals = pd.DataFrame([20, 1], index=["asymptote", "rate_constant"]).T + theta_vals_index = pd.DataFrame( + [20, 1], index=["theta['asymptote']", "theta['rate_constant']"] + ).T + + self.input = { + "param": { + "exp_list": rooney_biegler_params_exp_list, + "theta_names": ["asymptote", "rate_constant"], + "theta_vals": theta_vals, + }, + "param_index": { + "exp_list": rooney_biegler_indexed_params_exp_list, + "theta_names": ["theta"], + "theta_vals": theta_vals_index, + }, + "vars": { + "exp_list": rooney_biegler_vars_exp_list, + "theta_names": ["asymptote", "rate_constant"], + "theta_vals": theta_vals, + }, + "vars_index": { + "exp_list": rooney_biegler_indexed_vars_exp_list, + "theta_names": ["theta"], + "theta_vals": theta_vals_index, + }, + "vars_quoted_index": { + "exp_list": rooney_biegler_indexed_vars_exp_list, + "theta_names": ["theta['asymptote']", "theta['rate_constant']"], + "theta_vals": theta_vals_index, + }, + "vars_str_index": { + "exp_list": rooney_biegler_indexed_vars_exp_list, + "theta_names": ["theta[asymptote]", "theta[rate_constant]"], + "theta_vals": theta_vals_index, + }, + } + + @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") + def check_rooney_biegler_results(self, objval, cov): + + # get indices in covariance matrix + cov_cols = cov.columns.to_list() + asymptote_index = [idx for idx, s in enumerate(cov_cols) if "asymptote" in s][0] + rate_constant_index = [ + idx for idx, s in enumerate(cov_cols) if "rate_constant" in s + ][0] + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[asymptote_index, asymptote_index], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[asymptote_index, rate_constant_index], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, asymptote_index], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[rate_constant_index, rate_constant_index], 0.04193591, places=2 + ) # 0.04124 from paper + + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') + def test_parmest_basics(self): + + for model_type, parmest_input in self.input.items(): + pest = parmest.Estimator( + parmest_input["exp_list"], obj_function=self.objective_function + ) + + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + self.check_rooney_biegler_results(objval, cov) + + obj_at_theta = pest.objective_at_theta(parmest_input["theta_vals"]) + self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') + def test_parmest_basics_with_initialize_parmest_model_option(self): + + for model_type, parmest_input in self.input.items(): + pest = parmest.Estimator( + parmest_input["exp_list"], obj_function=self.objective_function + ) + + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + self.check_rooney_biegler_results(objval, cov) + + obj_at_theta = pest.objective_at_theta( + parmest_input["theta_vals"], initialize_parmest_model=True + ) + + self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') + def test_parmest_basics_with_square_problem_solve(self): + + for model_type, parmest_input in self.input.items(): + pest = parmest.Estimator( + parmest_input["exp_list"], obj_function=self.objective_function + ) + + obj_at_theta = pest.objective_at_theta( + parmest_input["theta_vals"], initialize_parmest_model=True + ) + + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + self.check_rooney_biegler_results(objval, cov) + + self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') + def test_parmest_basics_with_square_problem_solve_no_theta_vals(self): + + for model_type, parmest_input in self.input.items(): + + pest = parmest.Estimator( + parmest_input["exp_list"], obj_function=self.objective_function + ) + + obj_at_theta = pest.objective_at_theta(initialize_parmest_model=True) + + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + self.check_rooney_biegler_results(objval, cov) + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' solver is not available") +class TestReactorDesign(unittest.TestCase): + def setUp(self): + from pyomo.contrib.parmest.examples.reactor_design.reactor_design import ( + ReactorDesignExperiment, + ) + + # Data from the design + data = pd.DataFrame( + data=[ + [1.05, 10000, 3458.4, 1060.8, 1683.9, 1898.5], + [1.10, 10000, 3535.1, 1064.8, 1613.3, 1893.4], + [1.15, 10000, 3609.1, 1067.8, 1547.5, 1887.8], + [1.20, 10000, 3680.7, 1070.0, 1486.1, 1881.6], + [1.25, 10000, 3750.0, 1071.4, 1428.6, 1875.0], + [1.30, 10000, 3817.1, 1072.2, 1374.6, 1868.0], + [1.35, 10000, 3882.2, 1072.4, 1324.0, 1860.7], + [1.40, 10000, 3945.4, 1072.1, 1276.3, 1853.1], + [1.45, 10000, 4006.7, 1071.3, 1231.4, 1845.3], + [1.50, 10000, 4066.4, 1070.1, 1189.0, 1837.3], + [1.55, 10000, 4124.4, 1068.5, 1148.9, 1829.1], + [1.60, 10000, 4180.9, 1066.5, 1111.0, 1820.8], + [1.65, 10000, 4235.9, 1064.3, 1075.0, 1812.4], + [1.70, 10000, 4289.5, 1061.8, 1040.9, 1803.9], + [1.75, 10000, 4341.8, 1059.0, 1008.5, 1795.3], + [1.80, 10000, 4392.8, 1056.0, 977.7, 1786.7], + [1.85, 10000, 4442.6, 1052.8, 948.4, 1778.1], + [1.90, 10000, 4491.3, 1049.4, 920.5, 1769.4], + [1.95, 10000, 4538.8, 1045.8, 893.9, 1760.8], + ], + columns=["sv", "caf", "ca", "cb", "cc", "cd"], + ) + + # Create an experiment list + exp_list = [] + for i in range(data.shape[0]): + exp_list.append(ReactorDesignExperiment(data, i)) + + solver_options = {"max_iter": 6000} + + self.pest = parmest.Estimator( + exp_list, obj_function="SSE", solver_options=solver_options + ) + + def test_theta_est(self): + # used in data reconciliation + objval, thetavals = self.pest.theta_est() + + self.assertAlmostEqual(thetavals["k1"], 5.0 / 6.0, places=4) + self.assertAlmostEqual(thetavals["k2"], 5.0 / 3.0, places=4) + self.assertAlmostEqual(thetavals["k3"], 1.0 / 6000.0, places=7) + + def test_return_values(self): + objval, thetavals, data_rec = self.pest.theta_est( + return_values=["ca", "cb", "cc", "cd", "caf"] + ) + self.assertAlmostEqual(data_rec["cc"].loc[18], 893.84924, places=3) + + +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' solver is not available") +class TestReactorDesign_DAE(unittest.TestCase): + # Based on a reactor example in `Chemical Reactor Analysis and Design Fundamentals`, + # https://sites.engineering.ucsb.edu/~jbraw/chemreacfun/ + # https://sites.engineering.ucsb.edu/~jbraw/chemreacfun/fig-html/appendix/fig-A-10.html + + def setUp(self): + def ABC_model(data): + ca_meas = data["ca"] + cb_meas = data["cb"] + cc_meas = data["cc"] + + if isinstance(data, pd.DataFrame): + meas_t = data.index # time index + else: # dictionary + meas_t = list(ca_meas.keys()) # nested dictionary + + ca0 = 1.0 + cb0 = 0.0 + cc0 = 0.0 + + m = pyo.ConcreteModel() + + m.k1 = pyo.Var(initialize=0.5, bounds=(1e-4, 10)) + m.k2 = pyo.Var(initialize=3.0, bounds=(1e-4, 10)) + + m.time = dae.ContinuousSet(bounds=(0.0, 5.0), initialize=meas_t) + + # initialization and bounds + m.ca = pyo.Var(m.time, initialize=ca0, bounds=(-1e-3, ca0 + 1e-3)) + m.cb = pyo.Var(m.time, initialize=cb0, bounds=(-1e-3, ca0 + 1e-3)) + m.cc = pyo.Var(m.time, initialize=cc0, bounds=(-1e-3, ca0 + 1e-3)) + + m.dca = dae.DerivativeVar(m.ca, wrt=m.time) + m.dcb = dae.DerivativeVar(m.cb, wrt=m.time) + m.dcc = dae.DerivativeVar(m.cc, wrt=m.time) + + def _dcarate(m, t): + if t == 0: + return pyo.Constraint.Skip + else: + return m.dca[t] == -m.k1 * m.ca[t] + + m.dcarate = pyo.Constraint(m.time, rule=_dcarate) + + def _dcbrate(m, t): + if t == 0: + return pyo.Constraint.Skip + else: + return m.dcb[t] == m.k1 * m.ca[t] - m.k2 * m.cb[t] + + m.dcbrate = pyo.Constraint(m.time, rule=_dcbrate) + + def _dccrate(m, t): + if t == 0: + return pyo.Constraint.Skip + else: + return m.dcc[t] == m.k2 * m.cb[t] + + m.dccrate = pyo.Constraint(m.time, rule=_dccrate) + + def ComputeFirstStageCost_rule(m): + return 0 + + m.FirstStageCost = pyo.Expression(rule=ComputeFirstStageCost_rule) + + def ComputeSecondStageCost_rule(m): + return sum( + (m.ca[t] - ca_meas[t]) ** 2 + + (m.cb[t] - cb_meas[t]) ** 2 + + (m.cc[t] - cc_meas[t]) ** 2 + for t in meas_t + ) + + m.SecondStageCost = pyo.Expression(rule=ComputeSecondStageCost_rule) + + def total_cost_rule(model): + return model.FirstStageCost + model.SecondStageCost + + m.Total_Cost_Objective = pyo.Objective( + rule=total_cost_rule, sense=pyo.minimize + ) + + disc = pyo.TransformationFactory("dae.collocation") + disc.apply_to(m, nfe=20, ncp=2) + + return m + + class ReactorDesignExperimentDAE(Experiment): + + def __init__(self, data): + + self.data = data + self.model = None + + def create_model(self): + self.model = ABC_model(self.data) + + def label_model(self): + + m = self.model + + m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.experiment_outputs.update( + [ + (m.ca, None), + (m.cb, None), + (m.cc, None), + ] + ) + + m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) + m.unknown_parameters.update( + (k, pyo.ComponentUID(k)) for k in [m.k1, m.k2] + ) + + def get_labeled_model(self): + self.create_model() + self.label_model() + + return self.model + + # This example tests data formatted in 3 ways + # Each format holds 1 scenario + # 1. dataframe with time index + # 2. nested dictionary {ca: {t, val pairs}, ... } + data = [ + [0.000, 0.957, -0.031, -0.015], + [0.263, 0.557, 0.330, 0.044], + [0.526, 0.342, 0.512, 0.156], + [0.789, 0.224, 0.499, 0.310], + [1.053, 0.123, 0.428, 0.454], + [1.316, 0.079, 0.396, 0.556], + [1.579, 0.035, 0.303, 0.651], + [1.842, 0.029, 0.287, 0.658], + [2.105, 0.025, 0.221, 0.750], + [2.368, 0.017, 0.148, 0.854], + [2.632, -0.002, 0.182, 0.845], + [2.895, 0.009, 0.116, 0.893], + [3.158, -0.023, 0.079, 0.942], + [3.421, 0.006, 0.078, 0.899], + [3.684, 0.016, 0.059, 0.942], + [3.947, 0.014, 0.036, 0.991], + [4.211, -0.009, 0.014, 0.988], + [4.474, -0.030, 0.036, 0.941], + [4.737, 0.004, 0.036, 0.971], + [5.000, -0.024, 0.028, 0.985], + ] + data = pd.DataFrame(data, columns=["t", "ca", "cb", "cc"]) + data_df = data.set_index("t") + data_dict = { + "ca": {k: v for (k, v) in zip(data.t, data.ca)}, + "cb": {k: v for (k, v) in zip(data.t, data.cb)}, + "cc": {k: v for (k, v) in zip(data.t, data.cc)}, + } + + # Create an experiment list + exp_list_df = [ReactorDesignExperimentDAE(data_df)] + exp_list_dict = [ReactorDesignExperimentDAE(data_dict)] + + self.pest_df = parmest.Estimator(exp_list_df) + self.pest_dict = parmest.Estimator(exp_list_dict) + + # Estimator object with multiple scenarios + exp_list_df_multiple = [ + ReactorDesignExperimentDAE(data_df), + ReactorDesignExperimentDAE(data_df), + ] + exp_list_dict_multiple = [ + ReactorDesignExperimentDAE(data_dict), + ReactorDesignExperimentDAE(data_dict), + ] + + self.pest_df_multiple = parmest.Estimator(exp_list_df_multiple) + self.pest_dict_multiple = parmest.Estimator(exp_list_dict_multiple) + + # Create an instance of the model + self.m_df = ABC_model(data_df) + self.m_dict = ABC_model(data_dict) + + def test_dataformats(self): + obj1, theta1 = self.pest_df.theta_est() + obj2, theta2 = self.pest_dict.theta_est() + + self.assertAlmostEqual(obj1, obj2, places=6) + self.assertAlmostEqual(theta1["k1"], theta2["k1"], places=6) + self.assertAlmostEqual(theta1["k2"], theta2["k2"], places=6) + + def test_return_continuous_set(self): + """ + test if ContinuousSet elements are returned correctly from theta_est() + """ + obj1, theta1, return_vals1 = self.pest_df.theta_est(return_values=["time"]) + obj2, theta2, return_vals2 = self.pest_dict.theta_est(return_values=["time"]) + self.assertAlmostEqual(return_vals1["time"].loc[0][18], 2.368, places=3) + self.assertAlmostEqual(return_vals2["time"].loc[0][18], 2.368, places=3) + + def test_return_continuous_set_multiple_datasets(self): + """ + test if ContinuousSet elements are returned correctly from theta_est() + """ + obj1, theta1, return_vals1 = self.pest_df_multiple.theta_est( + return_values=["time"] + ) + obj2, theta2, return_vals2 = self.pest_dict_multiple.theta_est( + return_values=["time"] + ) + self.assertAlmostEqual(return_vals1["time"].loc[1][18], 2.368, places=3) + self.assertAlmostEqual(return_vals2["time"].loc[1][18], 2.368, places=3) + + @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') + def test_covariance(self): + from pyomo.contrib.interior_point.inverse_reduced_hessian import ( + inv_reduced_hessian_barrier, + ) - # define data - t = self.data["hour"].to_numpy() - y = self.data["y"].to_numpy() + # Number of datapoints. + # 3 data components (ca, cb, cc), 20 timesteps, 1 scenario = 60 + # In this example, this is the number of data points in data_df, but that's + # only because the data is indexed by time and contains no additional information. + n = 60 - # define initial guess - theta_guess = np.array([15, 0.5]) + # Compute covariance using parmest + obj, theta, cov = self.pest_df.theta_est(calc_cov=True, cov_n=n) - ## solve with optimize.least_squares - sol = scipy.optimize.least_squares( - residual, theta_guess, method="trf", args=(t, y), verbose=2 + # Compute covariance using interior_point + vars_list = [self.m_df.k1, self.m_df.k2] + solve_result, inv_red_hes = inv_reduced_hessian_barrier( + self.m_df, independent_variables=vars_list, tee=True + ) + l = len(vars_list) + cov_interior_point = 2 * obj / (n - l) * inv_red_hes + cov_interior_point = pd.DataFrame( + cov_interior_point, ["k1", "k2"], ["k1", "k2"] ) - theta_hat = sol.x - - self.assertAlmostEqual( - theta_hat[0], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual(theta_hat[1], 0.5311, places=2) # 0.5311 from the paper - # calculate residuals - r = residual(theta_hat, t, y) + cov_diff = (cov - cov_interior_point).abs().sum().sum() - if self.measurement_std is None: - # calculate variance of the residuals - # -2 because there are 2 fitted parameters - sigre = np.matmul(r.T, r / (len(y) - 2)) + self.assertTrue(cov.loc["k1", "k1"] > 0) + self.assertTrue(cov.loc["k2", "k2"] > 0) + self.assertAlmostEqual(cov_diff, 0, places=6) - # approximate covariance - cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) - self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper - self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper - else: - # use the user-supplied measurement error standard deviation - sigre = self.measurement_std**2 +@unittest.skipIf( + not parmest.parmest_available, + "Cannot test parmest: required dependencies are missing", +) +@unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") +class TestSquareInitialization_RooneyBiegler(unittest.TestCase): + def setUp(self): + from pyomo.contrib.parmest.examples.rooney_biegler.rooney_biegler_with_constraint import ( + RooneyBieglerExperiment, + ) - cov = sigre * np.linalg.inv(np.matmul(sol.jac.T, sol.jac)) + # Note, the data used in this test has been corrected to use data.loc[5,'hour'] = 7 (instead of 6) + data = pd.DataFrame( + data=[[1, 8.3], [2, 10.3], [3, 19.0], [4, 16.0], [5, 15.6], [7, 19.8]], + columns=["hour", "y"], + ) - self.assertAlmostEqual(cov[0, 0], 0.009588, places=4) - self.assertAlmostEqual(cov[0, 1], -0.000665, places=4) - self.assertAlmostEqual(cov[1, 0], -0.000665, places=4) - self.assertAlmostEqual(cov[1, 1], 0.000063, places=4) + # Sum of squared error function + def SSE(model): + expr = ( + model.experiment_outputs[model.y] + - model.response_function[model.experiment_outputs[model.hour]] + ) ** 2 + return expr - def test_cov_scipy_curve_fit_comparison(self): - """ - Scipy results differ in the 3rd decimal place from the paper. It is possible - the paper used an alternative finite difference approximation for the Jacobian. - """ + exp_list = [] + for i in range(data.shape[0]): + exp_list.append(RooneyBieglerExperiment(data.loc[i, :])) - ## solve with optimize.curve_fit - def model(t, asymptote, rate_constant): - return asymptote * (1 - np.exp(-rate_constant * t)) + solver_options = {"tol": 1e-8} - # define data - t = self.data["hour"].to_numpy() - y = self.data["y"].to_numpy() + self.data = data + self.pest = parmest.Estimator( + exp_list, obj_function=SSE, solver_options=solver_options, tee=True + ) - # define initial guess - theta_guess = np.array([15, 0.5]) + def test_theta_est_with_square_initialization(self): + obj_init = self.pest.objective_at_theta(initialize_parmest_model=True) + objval, thetavals = self.pest.theta_est() - # estimate the parameters and covariance matrix - if self.measurement_std is None: - theta_hat, cov = scipy.optimize.curve_fit(model, t, y, p0=theta_guess) + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper - self.assertAlmostEqual( - theta_hat[0], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - theta_hat[1], 0.5311, places=2 - ) # 0.5311 from the paper + def test_theta_est_with_square_initialization_and_custom_init_theta(self): + theta_vals_init = pd.DataFrame( + data=[[19.0, 0.5]], columns=["asymptote", "rate_constant"] + ) + obj_init = self.pest.objective_at_theta( + theta_values=theta_vals_init, initialize_parmest_model=True + ) + objval, thetavals = self.pest.theta_est() + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper - self.assertAlmostEqual(cov[0, 0], 6.22864, places=2) # 6.22864 from paper - self.assertAlmostEqual(cov[0, 1], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 0], -0.4322, places=2) # -0.4322 from paper - self.assertAlmostEqual(cov[1, 1], 0.04124, places=2) # 0.04124 from paper - else: - theta_hat, cov = scipy.optimize.curve_fit( - model, - t, - y, - p0=theta_guess, - sigma=self.measurement_std, - absolute_sigma=True, - ) + def test_theta_est_with_square_initialization_diagnostic_mode_true(self): + self.pest.diagnostic_mode = True + obj_init = self.pest.objective_at_theta(initialize_parmest_model=True) + objval, thetavals = self.pest.theta_est() - self.assertAlmostEqual( - theta_hat[0], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - theta_hat[1], 0.5311, places=2 - ) # 0.5311 from the paper + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper - self.assertAlmostEqual(cov[0, 0], 0.0095875, places=4) - self.assertAlmostEqual(cov[0, 1], -0.0006653, places=4) - self.assertAlmostEqual(cov[1, 0], -0.0006653, places=4) - self.assertAlmostEqual(cov[1, 1], 0.00006347, places=4) + self.pest.diagnostic_mode = False ########################### @@ -887,39 +1710,35 @@ def test_parallel_parmest(self): @unittest.skipIf(not pynumero_ASL_available, "pynumero_ASL is not available") def test_theta_est_cov(self): - with pytest.raises( - TypeError, - match=r"Estimator\.theta_est\(\) got an unexpected keyword argument 'calc_cov'", - ): - objval, thetavals, cov = self.pest.theta_est(calc_cov=True, cov_n=6) + objval, thetavals, cov = self.pest.theta_est(calc_cov=True, cov_n=6) - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - thetavals["asymptote"], 19.1426, places=2 - ) # 19.1426 from the paper - self.assertAlmostEqual( - thetavals["rate_constant"], 0.5311, places=2 - ) # 0.5311 from the paper + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + thetavals["asymptote"], 19.1426, places=2 + ) # 19.1426 from the paper + self.assertAlmostEqual( + thetavals["rate_constant"], 0.5311, places=2 + ) # 0.5311 from the paper - # Covariance matrix - self.assertAlmostEqual( - cov.iloc[0, 0], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[0, 1], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 0], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual(cov.iloc[1, 1], 0.04124, places=2) # 0.04124 from paper - - """ Why does the covariance matrix from parmest not match the paper? Parmest is - calculating the exact reduced Hessian. The paper (Rooney and Bielger, 2001) likely - employed the first order approximation common for nonlinear regression. The paper - values were verified with Scipy, which uses the same first order approximation. - The formula used in parmest was verified against equations (7-5-15) and (7-5-16) in - "Nonlinear Parameter Estimation", Y. Bard, 1974. - """ + # Covariance matrix + self.assertAlmostEqual( + cov.iloc[0, 0], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[0, 1], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 0], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual(cov.iloc[1, 1], 0.04124, places=2) # 0.04124 from paper + + """ Why does the covariance matrix from parmest not match the paper? Parmest is + calculating the exact reduced Hessian. The paper (Rooney and Bielger, 2001) likely + employed the first order approximation common for nonlinear regression. The paper + values were verified with Scipy, which uses the same first order approximation. + The formula used in parmest was verified against equations (7-5-15) and (7-5-16) in + "Nonlinear Parameter Estimation", Y. Bard, 1974. + """ def test_cov_scipy_least_squares_comparison(self): """ @@ -1157,28 +1976,24 @@ def test_parmest_basics(self): self.objective_function, ) - with pytest.raises( - TypeError, - match=r"Estimator\.theta_est\(\) got an unexpected keyword argument 'calc_cov'", - ): - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - cov.iloc[0, 0], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[0, 1], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 0], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 1], 0.04193591, places=2 - ) # 0.04124 from paper - - obj_at_theta = pest.objective_at_theta(parmest_input["theta_vals"]) - self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[0, 0], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[0, 1], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 0], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 1], 0.04193591, places=2 + ) # 0.04124 from paper + + obj_at_theta = pest.objective_at_theta(parmest_input["theta_vals"]) + self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') def test_parmest_basics_with_initialize_parmest_model_option(self): @@ -1190,31 +2005,27 @@ def test_parmest_basics_with_initialize_parmest_model_option(self): self.objective_function, ) - with pytest.raises( - TypeError, - match=r"Estimator\.theta_est\(\) got an unexpected keyword argument 'calc_cov'", - ): - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - cov.iloc[0, 0], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[0, 1], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 0], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 1], 0.04193591, places=2 - ) # 0.04124 from paper - - obj_at_theta = pest.objective_at_theta( - parmest_input["theta_vals"], initialize_parmest_model=True - ) + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[0, 0], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[0, 1], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 0], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 1], 0.04193591, places=2 + ) # 0.04124 from paper + + obj_at_theta = pest.objective_at_theta( + parmest_input["theta_vals"], initialize_parmest_model=True + ) - self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') def test_parmest_basics_with_square_problem_solve(self): @@ -1230,27 +2041,23 @@ def test_parmest_basics_with_square_problem_solve(self): parmest_input["theta_vals"], initialize_parmest_model=True ) - with pytest.raises( - TypeError, - match=r"Estimator\.theta_est\(\) got an unexpected keyword argument 'calc_cov'", - ): - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - cov.iloc[0, 0], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[0, 1], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 0], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 1], 0.04193591, places=2 - ) # 0.04124 from paper - - self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[0, 0], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[0, 1], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 0], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 1], 0.04193591, places=2 + ) # 0.04124 from paper + + self.assertAlmostEqual(obj_at_theta["obj"][0], 16.531953, places=2) @unittest.skipUnless(pynumero_ASL_available, 'pynumero_ASL is not available') def test_parmest_basics_with_square_problem_solve_no_theta_vals(self): @@ -1264,25 +2071,21 @@ def test_parmest_basics_with_square_problem_solve_no_theta_vals(self): obj_at_theta = pest.objective_at_theta(initialize_parmest_model=True) - with pytest.raises( - TypeError, - match=r"Estimator\.theta_est\(\) got an unexpected keyword argument 'calc_cov'", - ): - objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) - - self.assertAlmostEqual(objval, 4.3317112, places=2) - self.assertAlmostEqual( - cov.iloc[0, 0], 6.30579403, places=2 - ) # 6.22864 from paper - self.assertAlmostEqual( - cov.iloc[0, 1], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 0], -0.4395341, places=2 - ) # -0.4322 from paper - self.assertAlmostEqual( - cov.iloc[1, 1], 0.04193591, places=2 - ) # 0.04124 from paper + objval, thetavals, cov = pest.theta_est(calc_cov=True, cov_n=6) + + self.assertAlmostEqual(objval, 4.3317112, places=2) + self.assertAlmostEqual( + cov.iloc[0, 0], 6.30579403, places=2 + ) # 6.22864 from paper + self.assertAlmostEqual( + cov.iloc[0, 1], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 0], -0.4395341, places=2 + ) # -0.4322 from paper + self.assertAlmostEqual( + cov.iloc[1, 1], 0.04193591, places=2 + ) # 0.04124 from paper @unittest.skipIf( @@ -1613,28 +2416,24 @@ def test_covariance(self): n = 60 # Compute covariance using parmest - with pytest.raises( - TypeError, - match=r"Estimator\.theta_est\(\) got an unexpected keyword argument 'calc_cov'", - ): - obj, theta, cov = self.pest_df.theta_est(calc_cov=True, cov_n=n) - - # Compute covariance using interior_point - vars_list = [self.m_df.k1, self.m_df.k2] - solve_result, inv_red_hes = inv_reduced_hessian_barrier( - self.m_df, independent_variables=vars_list, tee=True - ) - l = len(vars_list) - cov_interior_point = 2 * obj / (n - l) * inv_red_hes - cov_interior_point = pd.DataFrame( - cov_interior_point, ["k1", "k2"], ["k1", "k2"] - ) + obj, theta, cov = self.pest_df.theta_est(calc_cov=True, cov_n=n) + + # Compute covariance using interior_point + vars_list = [self.m_df.k1, self.m_df.k2] + solve_result, inv_red_hes = inv_reduced_hessian_barrier( + self.m_df, independent_variables=vars_list, tee=True + ) + l = len(vars_list) + cov_interior_point = 2 * obj / (n - l) * inv_red_hes + cov_interior_point = pd.DataFrame( + cov_interior_point, ["k1", "k2"], ["k1", "k2"] + ) - cov_diff = (cov - cov_interior_point).abs().sum().sum() + cov_diff = (cov - cov_interior_point).abs().sum().sum() - self.assertTrue(cov.loc["k1", "k1"] > 0) - self.assertTrue(cov.loc["k2", "k2"] > 0) - self.assertAlmostEqual(cov_diff, 0, places=6) + self.assertTrue(cov.loc["k1", "k1"] > 0) + self.assertTrue(cov.loc["k2", "k2"] > 0) + self.assertAlmostEqual(cov_diff, 0, places=6) @unittest.skipIf( From 2346e1b7cfcd333f202c9175325b2a9bba796a98 Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Sat, 14 Jun 2025 12:28:03 -0400 Subject: [PATCH 31/35] Ran black and reduced the code line length of parmest.py and test_parmest.py files --- pyomo/contrib/parmest/parmest.py | 337 +++++++++++--------- pyomo/contrib/parmest/tests/test_parmest.py | 53 ++- 2 files changed, 214 insertions(+), 176 deletions(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index 064fa73399f..5917fc0b492 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -232,8 +232,8 @@ def _experiment_instance_creation_callback( def SSE(model): """ - Returns an expression that is used to compute the sum of squared error ('SSE') objective, - assuming Gaussian i.i.d. errors + Returns an expression that is used to compute the sum of squared errors + ('SSE') objective, assuming Gaussian i.i.d. errors Argument: model: annotated Pyomo model @@ -241,7 +241,7 @@ def SSE(model): # check if the model has all the required suffixes _check_model_labels_helper(model) - # sum of squared error between the prediction and observation of the measured variables + # SSE between the prediction and observation of the measured variables expr = sum((y - y_hat) ** 2 for y_hat, y in model.experiment_outputs.items()) return expr @@ -249,7 +249,8 @@ def SSE(model): def SSE_weighted(model): """ Returns an expression that is used to compute the 'SSE_weighted' objective, - assuming Gaussian i.i.d. errors, with measurement error standard deviation defined in the annotated Pyomo model + assuming Gaussian i.i.d. errors, with measurement error standard deviation + defined in the annotated Pyomo model Argument: model: annotated Pyomo model @@ -262,15 +263,18 @@ def SSE_weighted(model): pass else: raise AttributeError( - 'Experiment model does not have suffix "measurement_error". "measurement_error" is a required suffix ' - 'for the "SSE_weighted" objective.' + 'Experiment model does not have suffix "measurement_error". ' + '"measurement_error" is a required suffix for the "SSE_weighted" ' + 'objective.' ) - # check if all the values of the measurement error standard deviation have been supplied + # check if all the values of the measurement error standard deviation + # have been supplied if all( model.measurement_error[y_hat] is not None for y_hat in model.experiment_outputs ): - # calculate the weighted SSE between the prediction and observation of the measured variables + # calculate the weighted SSE between the prediction and observation of the + # measured variables expr = (1 / 2) * sum( ((y - y_hat) / model.measurement_error[y_hat]) ** 2 for y_hat, y in model.experiment_outputs.items() @@ -278,8 +282,8 @@ def SSE_weighted(model): return expr else: raise ValueError( - 'One or more values are missing from "measurement_error". All values of the ' - 'measurement errors are required for the "SSE_weighted" objective.' + 'One or more values are missing from "measurement_error". All values of ' + 'the measurement errors are required for the "SSE_weighted" objective.' ) @@ -315,7 +319,8 @@ def _get_labeled_model_helper(experiment): Checks if the Experiment class object has a "get_labeled_model" function Argument: - experiment: Estimator class object that contains the model for a particular experimental condition + experiment: Estimator class object that contains the model for a particular + experimental condition Returns: model: Annotated Pyomo model @@ -324,7 +329,8 @@ def _get_labeled_model_helper(experiment): model = experiment.get_labeled_model().clone() except Exception as e: raise AttributeError( - f'The experiment object must have a "get_labeled_model" function. The original error was {e}.' + f'The experiment object must have a "get_labeled_model" function. ' + f'The original error was {e}.' ) return model @@ -349,13 +355,15 @@ class UnsupportedArgsLib(Enum): # Compute the Jacobian matrix of measured variables with respect to the parameters def _compute_jacobian(experiment, theta_vals, step, solver, tee): """ - Computes the Jacobian matrix of the measured variables with respect to the parameters - using central finite difference scheme + Computes the Jacobian matrix of the measured variables with respect to the + parameters using the central finite difference scheme Arguments: - experiment: Estimator class object that contains the model for a particular experimental condition + experiment: Estimator class object that contains the model for a particular + experimental condition theta_vals: dictionary containing the estimates of the unknown parameters - step: float used for relative perturbation of the parameters, e.g., step=0.02 is a 2% perturbation + step: float used for relative perturbation of the parameters, + e.g., step=0.02 is a 2% perturbation solver: string ``solver`` object specified by the user, e.g., 'ipopt' tee: boolean solver option to be passed for verbose output @@ -380,8 +388,8 @@ def _compute_jacobian(experiment, theta_vals, step, solver, tee): pyo.assert_optimal_termination(res) except Exception as e: raise RuntimeError( - f"Model from experiment did not solve appropriately. Make sure the model is well-posed. " - f"The original error was {e}." + f"Model from experiment did not solve appropriately. Make sure the " + f"model is well-posed. The original error was {e}." ) # get the measured variables @@ -413,8 +421,8 @@ def _compute_jacobian(experiment, theta_vals, step, solver, tee): pyo.assert_optimal_termination(res) except Exception as e: raise RuntimeError( - f"Model from experiment did not solve appropriately. Make sure the model is well-posed. " - f"The original error was {e}." + f"Model from experiment did not solve appropriately. Make sure the " + f"model is well-posed. The original error was {e}." ) # forward perturbation measured variables @@ -429,8 +437,8 @@ def _compute_jacobian(experiment, theta_vals, step, solver, tee): pyo.assert_optimal_termination(res) except Exception as e: raise RuntimeError( - f"Model from experiment did not solve appropriately. Make sure the model is well-posed. " - f"The original error was {e}." + f"Model from experiment did not solve appropriately. Make sure the " + f"model is well-posed. The original error was {e}." ) # backward perturbation measured variables @@ -455,18 +463,22 @@ def compute_covariance_matrix( experiment_list, method, theta_vals, step, solver, tee, estimated_var=None ): """ - Computes the covariance matrix of the estimated parameters using `finite_difference` and - `automatic_differentiation_kaug` methods + Computes the covariance matrix of the estimated parameters using + 'finite_difference' and 'automatic_differentiation_kaug' methods Arguments: - experiment_list: list of Estimator class objects containing the model for different experimental conditions - method: string ``method`` object specified by the user, e.g., 'finite_difference' + experiment_list: list of Estimator class objects containing the model for + different experimental conditions + method: string ``method`` object specified by the user, + e.g., 'finite_difference' theta_vals: dictionary containing the estimates of the unknown parameters - step: float used for relative perturbation of the parameters, e.g., step=0.02 is a 2% perturbation + step: float used for relative perturbation of the parameters, + e.g., step=0.02 is a 2% perturbation solver: string ``solver`` object specified by the user, e.g., 'ipopt' tee: boolean solver option to be passed for verbose output - estimated_var: value of the estimated variance of the measurement error in cases where - the user does not supply the measurement error standard deviation + estimated_var: value of the estimated variance of the measurement error + in cases where the user does not supply the measurement + error standard deviation Returns: cov: covariance matrix of the estimated parameters @@ -476,7 +488,8 @@ def compute_covariance_matrix( cov_method = CovarianceMethodLib(method) except ValueError: raise ValueError( - f"Invalid method: '{method}'. Choose from {[e.value for e in CovarianceMethodLib]}." + f"Invalid method: '{method}'. " + f"Choose from {[e.value for e in CovarianceMethodLib]}." ) if cov_method == CovarianceMethodLib.finite_difference: @@ -532,28 +545,33 @@ def compute_covariance_matrix( cov = pd.DataFrame(cov, index=theta_vals.keys(), columns=theta_vals.keys()) else: raise ValueError( - f'The method provided, {method}, must be either "finite_difference" or "automatic_differentiation_kaug".' + f'The method provided, {method}, must be either "finite_difference" or ' + f'"automatic_differentiation_kaug".' ) return cov -# compute the Fisher information matrix of the estimated parameters using `finite_difference` +# compute the Fisher information matrix of the estimated parameters using +# 'finite_difference' def _finite_difference_FIM( experiment, theta_vals, step, solver, tee, estimated_var=None ): """ - Computes the Fisher information matrix from finite difference Jacobian matrix and - measurement errors standard deviation defined in the annotated Pyomo model + Computes the Fisher information matrix from 'finite_difference' Jacobian matrix + and measurement errors standard deviation defined in the annotated Pyomo model Arguments: - experiment: Estimator class object that contains the model for a particular experimental condition + experiment: Estimator class object that contains the model for a particular + experimental condition theta_vals: dictionary containing the estimates of the unknown parameters - step: float used for relative perturbation of the parameters, e.g., step=0.02 is a 2% perturbation + step: float used for relative perturbation of the parameters, + e.g., step=0.02 is a 2% perturbation solver: string ``solver`` object specified by the user, e.g., 'ipopt' tee: boolean solver option to be passed for verbose output - estimated_var: value of the estimated variance of the measurement error in cases where - the user does not supply the measurement error standard deviation + estimated_var: value of the estimated variance of the measurement error in + cases where the user does not supply the measurement error + standard deviation Returns: FIM: Fisher information matrix about the parameters @@ -573,7 +591,8 @@ def _finite_difference_FIM( # extract the measured variables and measurement errors y_hat_list = [y_hat for y_hat, y in model.experiment_outputs.items()] - # check if the model has a defined measurement_error attribute and supplied measurement error standard deviation + # check if the model has a 'measurement_error' attribute and the measurement + # error standard deviation has been supplied if hasattr(model, "measurement_error") and all( model.measurement_error[y_hat] is not None for y_hat in model.experiment_outputs ): @@ -605,21 +624,25 @@ def _finite_difference_FIM( return FIM -# compute the Fisher information matrix of the estimated parameters using `automatic_differentiation_kaug` +# compute the Fisher information matrix of the estimated parameters using +# 'automatic_differentiation_kaug' def _kaug_FIM(experiment, theta_vals, solver, tee, estimated_var=None): """ - Computes the FIM using `automatic_differentiation_kaug`, a sensitivity-based approach that uses the annotated - Pyomo model optimality condition and user-defined measurement errors standard deviation + Computes the FIM using 'automatic_differentiation_kaug', a sensitivity-based + approach that uses the annotated Pyomo model optimality condition and + user-defined measurement errors standard deviation Disclaimer - code adopted from the kaug function implemented in Pyomo.DoE Arguments: - experiment: Estimator class object that contains the model for a particular experimental condition + experiment: Estimator class object that contains the model for a particular + experimental condition theta_vals: dictionary containing the estimates of the unknown parameters solver: string ``solver`` object specified by the user, e.g., 'ipopt' tee: boolean solver option to be passed for verbose output - estimated_var: value of the estimated variance of the measurement error in cases where - the user does not supply the measurement error standard deviation + estimated_var: value of the estimated variance of the measurement error in + cases where the user does not supply the measurement error + standard deviation Returns: FIM: Fisher information matrix about the parameters @@ -639,8 +662,8 @@ def _kaug_FIM(experiment, theta_vals, solver, tee, estimated_var=None): pyo.assert_optimal_termination(res) except Exception as e: raise RuntimeError( - f"Model from experiment did not solve appropriately. Make sure the model is well-posed. " - f"The original error was {e}." + f"Model from experiment did not solve appropriately. Make sure the " + f"model is well-posed. The original error was {e}." ) # add zero (dummy/placeholder) objective function @@ -951,7 +974,12 @@ def _instance_creation_callback(self, experiment_number=None, cb_data=None): return model def _Q_opt( - self, ThetaVals=None, solver="ef_ipopt", return_values=[], bootlist=None, **kwargs + self, + ThetaVals=None, + solver="ef_ipopt", + return_values=[], + bootlist=None, + **kwargs, ): """ Set up all thetas as first stage Vars, return resulting theta @@ -1098,16 +1126,17 @@ def _Q_opt( # Assumption: Objective value is sum of squared errors sse = obj_val - '''Calculate covariance assuming experimental observation errors are - independent and follow a Gaussian - distribution with constant variance. + '''Calculate covariance assuming experimental observation errors + are independent and follow a Gaussian distribution + with constant variance. - The formula used in parmest was verified against equations (7-5-15) and - (7-5-16) in "Nonlinear Parameter Estimation", Y. Bard, 1974. + The formula used in parmest was verified against equations + (7-5-15) and (7-5-16) in "Nonlinear Parameter Estimation", + Y. Bard, 1974. - This formula is also applicable if the objective is scaled by a constant; - the constant cancels out. (was scaled by 1/n because it computes an - expected value.) + This formula is also applicable if the objective is scaled by a + constant; the constant cancels out. + (was scaled by 1/n because it computes an expected value.) ''' cov = 2 * sse / (n - l) * inv_red_hes cov = pd.DataFrame( @@ -1129,7 +1158,7 @@ def _Q_opt( for var in return_values: exp_i_var = exp_i.find_component(str(var)) if ( - exp_i_var is None + exp_i_var is None ): # we might have a block such as _mpisppy_data continue # if value to return is ContinuousSet @@ -1169,10 +1198,13 @@ def _cov_at_theta(self, method, solver, cov_n, step): Covariance matrix calculation using all scenarios in the data Argument: - method: string ``method`` object specified by the user, e.g., 'finite_difference' + method: string ``method`` object specified by the user, + e.g., 'finite_difference' solver: string ``solver`` object specified by the user, e.g., 'ipopt' - cov_n: integer, number of datapoints specified by the user which is used in the objective function - step: float used for relative perturbation of the parameters, e.g., step=0.02 is a 2% perturbation + cov_n: integer, number of datapoints specified by the user which is used + in the objective function + step: float used for relative perturbation of the parameters, + e.g., step=0.02 is a 2% perturbation Returns: cov: pd.DataFrame, covariance matrix of the estimated parameters @@ -1199,8 +1231,8 @@ def _cov_at_theta(self, method, solver, cov_n, step): pyo.assert_optimal_termination(res) except Exception as e: raise RuntimeError( - f"Model from experiment did not solve appropriately. Make sure the model is well-posed. " - f"The original error was {e}." + f"Model from experiment did not solve appropriately. Make sure the " + f"model is well-posed. The original error was {e}." ) # choose and evaluate the sum of squared errors expression @@ -1235,7 +1267,8 @@ def _cov_at_theta(self, method, solver, cov_n, step): cov_method = CovarianceMethodLib(method) except ValueError: raise ValueError( - f"Invalid method: '{method}'. Choose from {[e.value for e in CovarianceMethodLib]}." + f"Invalid method: '{method}'. Choose " + f"from {[e.value for e in CovarianceMethodLib]}." ) # check if the user specified 'SSE' or 'SSE_weighted' as the objective function @@ -1278,8 +1311,8 @@ def _cov_at_theta(self, method, solver, cov_n, step): ) else: raise NotImplementedError( - 'Only "finite_difference", "reduced_hessian", and "automatic_differentiation_kaug" ' - 'methods are supported.' + 'Only "finite_difference", "reduced_hessian", and ' + '"automatic_differentiation_kaug" methods are supported.' ) elif all(item is not None for item in meas_error): if cov_method == CovarianceMethodLib.reduced_hessian: @@ -1304,12 +1337,13 @@ def _cov_at_theta(self, method, solver, cov_n, step): ) else: raise NotImplementedError( - 'Only "finite_difference", "reduced_hessian", and "automatic_differentiation_kaug" ' - 'methods are supported.' + 'Only "finite_difference", "reduced_hessian", and ' + '"automatic_differentiation_kaug" methods are supported.' ) else: raise ValueError( - "One or more values of the measurement errors have not been supplied." + "One or more values of the measurement errors have " + "not been supplied." ) else: raise AttributeError( @@ -1347,13 +1381,14 @@ def _cov_at_theta(self, method, solver, cov_n, step): ) else: raise NotImplementedError( - 'Only "finite_difference", "reduced_hessian", and "automatic_differentiation_kaug" ' - 'methods are supported.' + 'Only "finite_difference", "reduced_hessian", and ' + '"automatic_differentiation_kaug" methods are supported.' ) else: raise ValueError( - 'One or more values of the measurement errors have not been supplied. All values of the ' - 'measurement errors are required for the "SSE_weighted" objective.' + 'One or more values of the measurement errors have not been ' + 'supplied. All values of the measurement errors are required ' + 'for the "SSE_weighted" objective.' ) else: raise AttributeError( @@ -1361,18 +1396,19 @@ def _cov_at_theta(self, method, solver, cov_n, step): ) else: raise NotImplementedError( - 'Covariance calculation is only supported for "SSE" and "SSE_weighted" objectives.' + 'Covariance calculation is only supported for "SSE" and ' + '"SSE_weighted" objectives.' ) return cov - def _Q_at_theta(self, theta_vals, initialize_parmest_model=False): + def _Q_at_theta(self, thetavals, initialize_parmest_model=False): """ Return the objective function value with fixed theta values. Parameters ---------- - theta_vals: dict + thetavals: dict A dictionary of theta values. initialize_parmest_model: boolean @@ -1383,7 +1419,7 @@ def _Q_at_theta(self, theta_vals, initialize_parmest_model=False): ------- objectiveval: float The objective function value. - theta_vals: dict + thetavals: dict A dictionary of all values for theta that were input. solvertermination: Pyomo TerminationCondition Tries to return the "worst" solver status across the scenarios. @@ -1391,12 +1427,12 @@ def _Q_at_theta(self, theta_vals, initialize_parmest_model=False): pyo.TerminationCondition.infeasible is the worst. """ - optimizer = pyo.SolverFactory("ipopt") + optimizer = pyo.SolverFactory('ipopt') - if len(theta_vals) > 0: + if len(thetavals) > 0: dummy_cb = { "callback": self._instance_creation_callback, - "ThetaVals": theta_vals, + "ThetaVals": thetavals, "theta_names": self._return_theta_names(), "cb_data": None, } @@ -1408,10 +1444,10 @@ def _Q_at_theta(self, theta_vals, initialize_parmest_model=False): } if self.diagnostic_mode: - if len(theta_vals) > 0: - print(" Compute objective at theta = ", str(theta_vals)) + if len(thetavals) > 0: + print(' Compute objective at theta = ', str(thetavals)) else: - print(" Compute objective at initial theta") + print(' Compute objective at initial theta') # start block of code to deal with models with no constraints # (ipopt will crash or complain on such problems without special care) @@ -1452,21 +1488,21 @@ def _Q_at_theta(self, theta_vals, initialize_parmest_model=False): ) else: try: - if len(theta_vals) == 0: + if len(thetavals) == 0: var_validate.fix() else: - var_validate.fix(theta_vals[theta]) + var_validate.fix(thetavals[theta]) theta_init_vals.append(var_validate) except: logger.warning( - "Unable to fix model parameter value for %s (not a Pyomo model Var)", + 'Unable to fix model parameter value for %s (not a Pyomo model Var)', (theta), ) if active_constraints: if self.diagnostic_mode: - print(" Experiment = ", snum) - print(" First solve with special diagnostics wrapper") + print(' Experiment = ', snum) + print(' First solve with special diagnostics wrapper') (status_obj, solved, iters, time, regu) = ( utils.ipopt_solve_with_stats( instance, optimizer, max_iter=500, max_cpu_time=120 @@ -1484,7 +1520,7 @@ def _Q_at_theta(self, theta_vals, initialize_parmest_model=False): results = optimizer.solve(instance) if self.diagnostic_mode: print( - "standard solve solver termination condition=", + 'standard solve solver termination condition=', str(results.solver.termination_condition), ) @@ -1523,11 +1559,11 @@ def _Q_at_theta(self, theta_vals, initialize_parmest_model=False): scen_dict[sname] = instance objobject = getattr(instance, self._second_stage_cost_exp) - obj_val = pyo.value(objobject) - totobj += obj_val + objval = pyo.value(objobject) + totobj += objval retval = totobj / len(scenario_numbers) # -1?? - if initialize_parmest_model and not hasattr(self, "ef_instance"): + if initialize_parmest_model and not hasattr(self, 'ef_instance'): # create extensive form of the model using scenario dictionary if len(scen_dict) > 0: for scen in scen_dict.values(): @@ -1550,13 +1586,13 @@ def _Q_at_theta(self, theta_vals, initialize_parmest_model=False): self.model_initialized = True # return initialized theta values - if len(theta_vals) == 0: + if len(thetavals) == 0: # use appropriate theta_names member theta_ref = self._return_theta_names() for i, theta in enumerate(theta_ref): - theta_vals[theta] = theta_init_vals[i]() + thetavals[theta] = theta_init_vals[i]() - return retval, theta_vals, WorstStatus + return retval, thetavals, WorstStatus def _get_sample_list(self, samplesize, num_samples, replacement=True): samplelist = list() @@ -1620,8 +1656,7 @@ def theta_est(self, solver="ef_ipopt", return_values=[], **kwargs): if self.pest_deprecated is not None: if not kwargs: return self.pest_deprecated.theta_est( - solver=solver, - return_values=return_values, + solver=solver, return_values=return_values ) elif kwargs and all(arg.value in kwargs for arg in UnsupportedArgsLib): calc_cov = kwargs[UnsupportedArgsLib.calc_cov.value] @@ -1636,7 +1671,9 @@ def theta_est(self, solver="ef_ipopt", return_values=[], **kwargs): assert isinstance(solver, str) assert isinstance(return_values, list) - return self._Q_opt(solver=solver, return_values=return_values, bootlist=None, **kwargs) + return self._Q_opt( + solver=solver, return_values=return_values, bootlist=None, **kwargs + ) def cov_est( self, method="finite_difference", solver="ipopt", cov_n=None, step=1e-3 @@ -1646,10 +1683,13 @@ def cov_est( Argument: method: string ``method`` object specified by the user - options - 'finite_difference', 'reduced_hessian', and 'automatic_differentiation_kaug' + options - 'finite_difference', 'reduced_hessian', + and 'automatic_differentiation_kaug' solver: string ``solver`` object specified by the user, e.g., 'ipopt' - cov_n: integer, number of datapoints specified by the user which is used in the objective function - step: float used for relative perturbation of the parameters, e.g., step=0.02 is a 2% perturbation + cov_n: integer, number of datapoints specified by the user which is used + in the objective function + step: float used for relative perturbation of the parameters, e.g., + step=0.02 is a 2% perturbation Returns: cov: pd.DataFrame, covariance matrix of the estimated parameters @@ -1747,15 +1787,15 @@ def theta_est_bootstrap( bootstrap_theta = list() for idx, sample in local_list: - obj_val, theta_vals = self._Q_opt(bootlist=list(sample)) - theta_vals["samples"] = sample - bootstrap_theta.append(theta_vals) + objval, thetavals = self._Q_opt(bootlist=list(sample)) + thetavals['samples'] = sample + bootstrap_theta.append(thetavals) global_bootstrap_theta = task_mgr.allgather_global_data(bootstrap_theta) bootstrap_theta = pd.DataFrame(global_bootstrap_theta) if not return_samples: - del bootstrap_theta["samples"] + del bootstrap_theta['samples'] return bootstrap_theta @@ -1807,16 +1847,16 @@ def theta_est_leaveNout( lNo_theta = list() for idx, sample in local_list: - obj_val, theta_vals = self._Q_opt(bootlist=list(sample)) + objval, thetavals = self._Q_opt(bootlist=list(sample)) lNo_s = list(set(range(len(self.exp_list))) - set(sample)) - theta_vals["lNo"] = np.sort(lNo_s) - lNo_theta.append(theta_vals) + thetavals['lNo'] = np.sort(lNo_s) + lNo_theta.append(thetavals) global_bootstrap_theta = task_mgr.allgather_global_data(lNo_theta) lNo_theta = pd.DataFrame(global_bootstrap_theta) if not return_samples: - del lNo_theta["lNo"] + del lNo_theta['lNo'] return lNo_theta @@ -1873,7 +1913,7 @@ def leaveNout_bootstrap_test( assert isinstance(lNo, int) assert isinstance(lNo_samples, (type(None), int)) assert isinstance(bootstrap_samples, int) - assert distribution in ["Rect", "MVN", "KDE"] + assert distribution in ['Rect', 'MVN', 'KDE'] assert isinstance(alphas, list) assert isinstance(seed, (type(None), int)) @@ -1884,7 +1924,6 @@ def leaveNout_bootstrap_test( results = [] for idx, sample in global_list: - obj, theta = self.theta_est() bootstrap_theta = self.theta_est_bootstrap(bootstrap_samples) @@ -1964,7 +2003,7 @@ def objective_at_theta(self, theta_values=None, initialize_parmest_model=False): assert len(list(theta_names)) == len(model_theta_list) - all_thetas = theta_values.to_dict("records") + all_thetas = theta_values.to_dict('records') if all_thetas: task_mgr = utils.ParallelTaskManager(len(all_thetas)) @@ -1986,13 +2025,13 @@ def objective_at_theta(self, theta_values=None, initialize_parmest_model=False): # DLW, Aug2018: should we also store the worst solver status? else: obj, thetvals, worststatus = self._Q_at_theta( - theta_vals={}, initialize_parmest_model=initialize_parmest_model + thetavals={}, initialize_parmest_model=initialize_parmest_model ) if worststatus != pyo.TerminationCondition.infeasible: all_obj.append(list(thetvals.values()) + [obj]) global_all_obj = task_mgr.allgather_global_data(all_obj) - dfcols = list(theta_names) + ["obj"] + dfcols = list(theta_names) + ['obj'] obj_at_theta = pd.DataFrame(data=global_all_obj, columns=dfcols) return obj_at_theta @@ -2041,7 +2080,7 @@ def likelihood_ratio_test( for a in alphas: chi2_val = scipy.stats.chi2.ppf(a, 2) thresholds[a] = obj_value * ((chi2_val / (S - 2)) + 1) - LR[a] = LR["obj"] < thresholds[a] + LR[a] = LR['obj'] < thresholds[a] thresholds = pd.Series(thresholds) @@ -2091,7 +2130,7 @@ def confidence_region_test( ) assert isinstance(theta_values, pd.DataFrame) - assert distribution in ["Rect", "MVN", "KDE"] + assert distribution in ['Rect', 'MVN', 'KDE'] assert isinstance(alphas, list) assert isinstance( test_theta_values, (type(None), dict, pd.Series, pd.DataFrame) @@ -2106,7 +2145,7 @@ def confidence_region_test( test_result = test_theta_values.copy() for a in alphas: - if distribution == "Rect": + if distribution == 'Rect': lb, ub = graphics.fit_rect_dist(theta_values, a) training_results[a] = (theta_values > lb).all(axis=1) & ( theta_values < ub @@ -2118,7 +2157,7 @@ def confidence_region_test( test_theta_values < ub ).all(axis=1) - elif distribution == "MVN": + elif distribution == 'MVN': dist = graphics.fit_mvn_dist(theta_values) Z = dist.pdf(theta_values) score = scipy.stats.scoreatpercentile(Z, (1 - a) * 100) @@ -2129,7 +2168,7 @@ def confidence_region_test( Z = dist.pdf(test_theta_values) test_result[a] = Z >= score - elif distribution == "KDE": + elif distribution == 'KDE': dist = graphics.fit_kde_dist(theta_values) Z = dist.pdf(theta_values.transpose()) score = scipy.stats.scoreatpercentile(Z, (1 - a) * 100) @@ -2151,7 +2190,7 @@ def confidence_region_test( ################################ -@deprecated(version="6.7.2") +@deprecated(version='6.7.2') def group_data(data, groupby_column_name, use_mean=None): """ Group data by scenario @@ -2257,7 +2296,7 @@ def __init__( ), "The scenarios in data must be a dictionary, DataFrame or filename" if len(theta_names) == 0: - self.theta_names = ["parmest_dummy_var"] + self.theta_names = ['parmest_dummy_var'] else: self.theta_names = theta_names @@ -2275,7 +2314,7 @@ def _return_theta_names(self): Return list of fitted model parameter names """ # if fitted model parameter names differ from theta_names created when Estimator object is created - if hasattr(self, "theta_names_updated"): + if hasattr(self, 'theta_names_updated'): return self.theta_names_updated else: @@ -2290,7 +2329,7 @@ def _create_parmest_model(self, data): model = self.model_function(data) if (len(self.theta_names) == 1) and ( - self.theta_names[0] == "parmest_dummy_var" + self.theta_names[0] == 'parmest_dummy_var' ): model.parmest_dummy_var = pyo.Var(initialize=1.0) @@ -2341,7 +2380,7 @@ def TotalCost_rule(model): var_validate.unfix() self.theta_names[i] = repr(var_cuid) except: - logger.warning(theta + " is not a variable") + logger.warning(theta + ' is not a variable') self.parmest_model = model @@ -2354,12 +2393,12 @@ def _instance_creation_callback(self, experiment_number=None, cb_data=None): pass elif isinstance(exp_data, str): try: - with open(exp_data, "r") as infile: + with open(exp_data, 'r') as infile: exp_data = json.load(infile) except: - raise RuntimeError(f"Could not read {exp_data} as json") + raise RuntimeError(f'Could not read {exp_data} as json') else: - raise RuntimeError(f"Unexpected data format for cb_data={cb_data}") + raise RuntimeError(f'Unexpected data format for cb_data={cb_data}') model = self._create_parmest_model(exp_data) return model @@ -2423,7 +2462,7 @@ def _Q_opt( if not calc_cov: # Do not calculate the reduced hessian - solver = SolverFactory("ipopt") + solver = SolverFactory('ipopt') if self.solver_options is not None: for key in self.solver_options: solver.options[key] = self.solver_options[key] @@ -2453,7 +2492,7 @@ def _Q_opt( if self.diagnostic_mode: print( - " Solver termination condition = ", + ' Solver termination condition = ', str(solve_result.solver.termination_condition), ) @@ -2563,7 +2602,7 @@ def _Q_at_theta(self, thetavals, initialize_parmest_model=False): pyo.TerminationCondition.infeasible is the worst. """ - optimizer = pyo.SolverFactory("ipopt") + optimizer = pyo.SolverFactory('ipopt') if len(thetavals) > 0: dummy_cb = { @@ -2581,9 +2620,9 @@ def _Q_at_theta(self, thetavals, initialize_parmest_model=False): if self.diagnostic_mode: if len(thetavals) > 0: - print(" Compute objective at theta = ", str(thetavals)) + print(' Compute objective at theta = ', str(thetavals)) else: - print(" Compute objective at initial theta") + print(' Compute objective at initial theta') # start block of code to deal with models with no constraints # (ipopt will crash or complain on such problems without special care) @@ -2630,14 +2669,14 @@ def _Q_at_theta(self, thetavals, initialize_parmest_model=False): theta_init_vals.append(var_validate) except: logger.warning( - "Unable to fix model parameter value for %s (not a Pyomo model Var)", + 'Unable to fix model parameter value for %s (not a Pyomo model Var)', (theta), ) if active_constraints: if self.diagnostic_mode: - print(" Experiment = ", snum) - print(" First solve with special diagnostics wrapper") + print(' Experiment = ', snum) + print(' First solve with special diagnostics wrapper') (status_obj, solved, iters, time, regu) = ( utils.ipopt_solve_with_stats( instance, optimizer, max_iter=500, max_cpu_time=120 @@ -2655,7 +2694,7 @@ def _Q_at_theta(self, thetavals, initialize_parmest_model=False): results = optimizer.solve(instance) if self.diagnostic_mode: print( - "standard solve solver termination condition=", + 'standard solve solver termination condition=', str(results.solver.termination_condition), ) @@ -2698,7 +2737,7 @@ def _Q_at_theta(self, thetavals, initialize_parmest_model=False): totobj += objval retval = totobj / len(scenario_numbers) # -1?? - if initialize_parmest_model and not hasattr(self, "ef_instance"): + if initialize_parmest_model and not hasattr(self, 'ef_instance'): # create extensive form of the model using scenario dictionary if len(scen_dict) > 0: for scen in scen_dict.values(): @@ -2865,14 +2904,14 @@ def theta_est_bootstrap( bootstrap_theta = list() for idx, sample in local_list: objval, thetavals = self._Q_opt(bootlist=list(sample)) - thetavals["samples"] = sample + thetavals['samples'] = sample bootstrap_theta.append(thetavals) global_bootstrap_theta = task_mgr.allgather_global_data(bootstrap_theta) bootstrap_theta = pd.DataFrame(global_bootstrap_theta) if not return_samples: - del bootstrap_theta["samples"] + del bootstrap_theta['samples'] return bootstrap_theta @@ -2919,14 +2958,14 @@ def theta_est_leaveNout( for idx, sample in local_list: objval, thetavals = self._Q_opt(bootlist=list(sample)) lNo_s = list(set(range(len(self.callback_data))) - set(sample)) - thetavals["lNo"] = np.sort(lNo_s) + thetavals['lNo'] = np.sort(lNo_s) lNo_theta.append(thetavals) global_bootstrap_theta = task_mgr.allgather_global_data(lNo_theta) lNo_theta = pd.DataFrame(global_bootstrap_theta) if not return_samples: - del lNo_theta["lNo"] + del lNo_theta['lNo'] return lNo_theta @@ -2976,7 +3015,7 @@ def leaveNout_bootstrap_test( assert isinstance(lNo, int) assert isinstance(lNo_samples, (type(None), int)) assert isinstance(bootstrap_samples, int) - assert distribution in ["Rect", "MVN", "KDE"] + assert distribution in ['Rect', 'MVN', 'KDE'] assert isinstance(alphas, list) assert isinstance(seed, (type(None), int)) @@ -3033,7 +3072,7 @@ def objective_at_theta(self, theta_values=None, initialize_parmest_model=False): Objective value for each theta (infeasible solutions are omitted). """ - if len(self.theta_names) == 1 and self.theta_names[0] == "parmest_dummy_var": + if len(self.theta_names) == 1 and self.theta_names[0] == 'parmest_dummy_var': pass # skip assertion if model has no fitted parameters else: # create a local instance of the pyomo model to access model variables and parameters @@ -3088,7 +3127,7 @@ def objective_at_theta(self, theta_values=None, initialize_parmest_model=False): ) assert len(list(theta_names)) == len(model_theta_list) - all_thetas = theta_values.to_dict("records") + all_thetas = theta_values.to_dict('records') if all_thetas: task_mgr = utils.ParallelTaskManager(len(all_thetas)) @@ -3116,7 +3155,7 @@ def objective_at_theta(self, theta_values=None, initialize_parmest_model=False): all_obj.append(list(thetvals.values()) + [obj]) global_all_obj = task_mgr.allgather_global_data(all_obj) - dfcols = list(theta_names) + ["obj"] + dfcols = list(theta_names) + ['obj'] obj_at_theta = pd.DataFrame(data=global_all_obj, columns=dfcols) return obj_at_theta @@ -3158,7 +3197,7 @@ def likelihood_ratio_test( for a in alphas: chi2_val = scipy.stats.chi2.ppf(a, 2) thresholds[a] = obj_value * ((chi2_val / (S - 2)) + 1) - LR[a] = LR["obj"] < thresholds[a] + LR[a] = LR['obj'] < thresholds[a] thresholds = pd.Series(thresholds) @@ -3200,7 +3239,7 @@ def confidence_region_test( with True (inside) or False (outside) for each alpha """ assert isinstance(theta_values, pd.DataFrame) - assert distribution in ["Rect", "MVN", "KDE"] + assert distribution in ['Rect', 'MVN', 'KDE'] assert isinstance(alphas, list) assert isinstance( test_theta_values, (type(None), dict, pd.Series, pd.DataFrame) @@ -3215,7 +3254,7 @@ def confidence_region_test( test_result = test_theta_values.copy() for a in alphas: - if distribution == "Rect": + if distribution == 'Rect': lb, ub = graphics.fit_rect_dist(theta_values, a) training_results[a] = (theta_values > lb).all(axis=1) & ( theta_values < ub @@ -3227,7 +3266,7 @@ def confidence_region_test( test_theta_values < ub ).all(axis=1) - elif distribution == "MVN": + elif distribution == 'MVN': dist = graphics.fit_mvn_dist(theta_values) Z = dist.pdf(theta_values) score = scipy.stats.scoreatpercentile(Z, (1 - a) * 100) @@ -3238,7 +3277,7 @@ def confidence_region_test( Z = dist.pdf(test_theta_values) test_result[a] = Z >= score - elif distribution == "KDE": + elif distribution == 'KDE': dist = graphics.fit_kde_dist(theta_values) Z = dist.pdf(theta_values.transpose()) score = scipy.stats.scoreatpercentile(Z, (1 - a) * 100) diff --git a/pyomo/contrib/parmest/tests/test_parmest.py b/pyomo/contrib/parmest/tests/test_parmest.py index 704ac68b1b5..6bf8c1ba81e 100644 --- a/pyomo/contrib/parmest/tests/test_parmest.py +++ b/pyomo/contrib/parmest/tests/test_parmest.py @@ -34,8 +34,8 @@ # Test class for the built-in "SSE" and "SSE_weighted" objective functions -# validated the results using the Rooney-Biegler example -# Rooney-Biegler example is the case when the measurement error is None +# validated the results using the Rooney-Biegler paper example +# Rooney-Biegler paper example is the case when the measurement error is None # we considered another case when the user supplies the value of the measurement error @unittest.skipIf( not parmest.parmest_available, @@ -43,8 +43,9 @@ ) @unittest.skipIf(not ipopt_available, "The 'ipopt' command is not available") -# we use parameterized_class to test the two objective functions over the two cases of measurement error -# included a third objective function to test the error message when an incorrect objective function is supplied +# we use parameterized_class to test the two objective functions +# over the two cases of the measurement error. Included a third objective function +# to test the error message when an incorrect objective function is supplied @parameterized_class( ("measurement_std", "objective_function"), [ @@ -154,12 +155,12 @@ def label_model(self): if self.objective_function == "incorrect_obj": with pytest.raises( - ValueError, - match="Invalid objective function: 'incorrect_obj'\. " - "Choose from \['SSE', 'SSE_weighted'\]\.", + ValueError, + match="Invalid objective function: 'incorrect_obj'\. " + "Choose from \['SSE', 'SSE_weighted'\]\.", ): self.pest = parmest.Estimator( - self.exp_list, obj_function=self.objective_function, tee=True + self.exp_list, obj_function=self.objective_function, tee=True ) else: self.pest = parmest.Estimator( @@ -170,8 +171,8 @@ def check_rooney_biegler_parameters( self, obj_val, theta_vals, obj_function, measurement_error ): """ - Checks if the objective value and parameter estimates are equal to the expected - values and agree with the results of the Rooney-Biegler paper + Checks if the objective value and parameter estimates are equal to the + expected values and agree with the results of the Rooney-Biegler paper Argument: obj_val: the objective value of the annotated Pyomo model @@ -197,8 +198,8 @@ def check_rooney_biegler_covariance( self, cov, cov_method, obj_function, measurement_error ): """ - Checks if the covariance matrix elements are equal to the expected values - and agree with the results of the Rooney-Biegler paper + Checks if the covariance matrix elements are equal to the expected + values and agree with the results of the Rooney-Biegler paper Argument: cov: pd.DataFrame, covariance matrix of the estimated parameters @@ -305,8 +306,8 @@ def check_rooney_biegler_covariance( places=4, ) - # test and check the covariance calculation for all the three supported methods - # added an 'unsupported_method' to test the error message when the method supplied + # test the covariance calculation of the three supported methods + # added a 'unsupported_method' to test the error message when the method supplied # is not supported @parameterized.expand( [ @@ -318,8 +319,8 @@ def check_rooney_biegler_covariance( ) def test_parmest_covariance(self, cov_method): """ - Calculates the parameter estimates and covariance matrix and compares them - with the results of Rooney-Biegler + Estimates the parameters and covariance matrix and compares them + with the results of the Rooney-Biegler paper Argument: cov_method: string ``method`` object specified by the user @@ -358,8 +359,10 @@ def test_parmest_covariance(self, cov_method): else: with pytest.raises( ValueError, - match=r"Invalid method: 'unsupported_method'\. Choose from \['finite_difference', " - "'automatic_differentiation_kaug', 'reduced_hessian'\]\.", + match=r"Invalid method: 'unsupported_method'\. Choose from " + r"\['finite_difference', " + r"'automatic_differentiation_kaug', " + r"'reduced_hessian'\]\.", ): cov = self.pest.cov_est(cov_n=6, method=cov_method) elif self.objective_function == "SSE_weighted": @@ -405,8 +408,10 @@ def test_parmest_covariance(self, cov_method): else: with pytest.raises( ValueError, - match=r"Invalid method: 'unsupported_method'\. Choose from \['finite_difference', " - "'automatic_differentiation_kaug', 'reduced_hessian'\]\.", + match=r"Invalid method: 'unsupported_method'\. Choose from " + r"\['finite_difference', " + r"'automatic_differentiation_kaug', " + r"'reduced_hessian'\]\.", ): cov = self.pest.cov_est(cov_n=6, method=cov_method) @@ -1306,13 +1311,7 @@ def label_model(self): m = self.model m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) - m.experiment_outputs.update( - [ - (m.ca, None), - (m.cb, None), - (m.cc, None), - ] - ) + m.experiment_outputs.update([(m.ca, None), (m.cb, None), (m.cc, None)]) m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) m.unknown_parameters.update( From 7ba18155ff7cba55c71919a371ffa1c4d6ef46da Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Mon, 16 Jun 2025 09:39:35 -0400 Subject: [PATCH 32/35] Some string formatting to parmest.py --- pyomo/contrib/parmest/parmest.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index 5917fc0b492..8eb1318d93b 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -74,7 +74,7 @@ parmest_available = numpy_available & pandas_available & scipy_available inverse_reduced_hessian, inverse_reduced_hessian_available = attempt_import( - "pyomo.contrib.interior_point.inverse_reduced_hessian" + 'pyomo.contrib.interior_point.inverse_reduced_hessian' ) logger = logging.getLogger(__name__) @@ -119,7 +119,7 @@ def _experiment_instance_creation_callback( """ assert cb_data is not None outer_cb_data = cb_data - scen_num_str = re.compile(r"(\d+)$").search(scenario_name).group(1) + scen_num_str = re.compile(r'(\d+)$').search(scenario_name).group(1) scen_num = int(scen_num_str) basename = scenario_name[: -len(scen_num_str)] # to reconstruct name @@ -214,14 +214,14 @@ def _experiment_instance_creation_callback( ] if "ThetaVals" in outer_cb_data: - theta_vals = outer_cb_data["ThetaVals"] + thetavals = outer_cb_data["ThetaVals"] # dlw august 2018: see mea code for more general theta - for name, val in theta_vals.items(): + for name, val in thetavals.items(): theta_cuid = ComponentUID(name) theta_object = theta_cuid.find_component_on(instance) if val is not None: - # print("Fixing",vstr,"at",str(theta_vals[vstr])) + # print("Fixing",vstr,"at",str(thetavals[vstr])) theta_object.fix(val) else: # print("Freeing",vstr) @@ -851,7 +851,7 @@ def _deprecated_init( "You're using the deprecated parmest interface (model_function, " "data, theta_names). This interface will be removed in a future release, " "please update to the new parmest interface using experiment lists.", - version="6.7.2", + version='6.7.2', ) self.pest_deprecated = _DeprecatedEstimator( model_function, @@ -872,7 +872,7 @@ def _return_theta_names(self): # if fitted model parameter names differ from theta_names # created when Estimator object is created - if hasattr(self, "theta_names_updated"): + if hasattr(self, 'theta_names_updated'): return self.pest_deprecated.theta_names_updated else: @@ -884,7 +884,7 @@ def _return_theta_names(self): # if fitted model parameter names differ from theta_names # created when Estimator object is created - if hasattr(self, "theta_names_updated"): + if hasattr(self, 'theta_names_updated'): return self.theta_names_updated else: @@ -922,15 +922,14 @@ def _create_parmest_model(self, experiment_number): # Check for component naming conflicts reserved_names = [ - "Total_Cost_Objective", - "FirstStageCost", - "SecondStageCost", + 'Total_Cost_Objective', + 'FirstStageCost', + 'SecondStageCost', ] for n in reserved_names: if model.component(n) or hasattr(model, n): raise RuntimeError( - f"Parmest will not override the existing model component named {n}. " - f"Rerun the Estimator object before running theta_est again" + f"Parmest will not override the existing model component named {n}" ) # Deactivate any existing objective functions @@ -1096,7 +1095,7 @@ def _Q_opt( if self.diagnostic_mode: print( - " Solver termination condition = ", + ' Solver termination condition = ', str(solve_result.solver.termination_condition), ) From 06e4e177b42fe942a721363270e89a7f446e07e4 Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Thu, 19 Jun 2025 15:08:34 -0400 Subject: [PATCH 33/35] A small bug fix in test_parmest.py file --- pyomo/contrib/parmest/tests/test_parmest.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/parmest/tests/test_parmest.py b/pyomo/contrib/parmest/tests/test_parmest.py index 6bf8c1ba81e..813e7b2ef45 100644 --- a/pyomo/contrib/parmest/tests/test_parmest.py +++ b/pyomo/contrib/parmest/tests/test_parmest.py @@ -1310,8 +1310,18 @@ def label_model(self): m = self.model + if isinstance(self.data, pd.DataFrame): + meas_time_points = self.data.index + else: # dictionary + meas_time_points = list(self.data["ca"].keys()) + m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) - m.experiment_outputs.update([(m.ca, None), (m.cb, None), (m.cc, None)]) + m.experiment_outputs.update((m.ca[t], self.data["ca"][t]) for + t in meas_time_points) + m.experiment_outputs.update((m.cb[t], self.data["cb"][t]) for + t in meas_time_points) + m.experiment_outputs.update((m.cc[t], self.data["cc"][t]) for + t in meas_time_points) m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) m.unknown_parameters.update( From e1e0392e5e219fa09156b11e24a2de9a180e5ba5 Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Thu, 19 Jun 2025 15:40:40 -0400 Subject: [PATCH 34/35] Ran black on test_parmest.py file --- pyomo/contrib/parmest/tests/test_parmest.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/pyomo/contrib/parmest/tests/test_parmest.py b/pyomo/contrib/parmest/tests/test_parmest.py index 813e7b2ef45..3df44aa8759 100644 --- a/pyomo/contrib/parmest/tests/test_parmest.py +++ b/pyomo/contrib/parmest/tests/test_parmest.py @@ -1312,16 +1312,19 @@ def label_model(self): if isinstance(self.data, pd.DataFrame): meas_time_points = self.data.index - else: # dictionary + else: # dictionary meas_time_points = list(self.data["ca"].keys()) m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) - m.experiment_outputs.update((m.ca[t], self.data["ca"][t]) for - t in meas_time_points) - m.experiment_outputs.update((m.cb[t], self.data["cb"][t]) for - t in meas_time_points) - m.experiment_outputs.update((m.cc[t], self.data["cc"][t]) for - t in meas_time_points) + m.experiment_outputs.update( + (m.ca[t], self.data["ca"][t]) for t in meas_time_points + ) + m.experiment_outputs.update( + (m.cb[t], self.data["cb"][t]) for t in meas_time_points + ) + m.experiment_outputs.update( + (m.cc[t], self.data["cc"][t]) for t in meas_time_points + ) m.unknown_parameters = pyo.Suffix(direction=pyo.Suffix.LOCAL) m.unknown_parameters.update( From 67a0cbd11c766da9e8a92140cdaf93c84b4a379c Mon Sep 17 00:00:00 2001 From: slilonfe5 Date: Tue, 24 Jun 2025 13:50:41 -0400 Subject: [PATCH 35/35] Small formatting changes to parmest.py and test_parmest.py --- pyomo/contrib/parmest/parmest.py | 37 ++++++++++++--------- pyomo/contrib/parmest/tests/test_parmest.py | 5 +-- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/pyomo/contrib/parmest/parmest.py b/pyomo/contrib/parmest/parmest.py index 8eb1318d93b..875ff6e7c42 100644 --- a/pyomo/contrib/parmest/parmest.py +++ b/pyomo/contrib/parmest/parmest.py @@ -670,10 +670,6 @@ def _kaug_FIM(experiment, theta_vals, solver, tee, estimated_var=None): if not hasattr(model, "objective"): model.objective = pyo.Objective(expr=0, sense=pyo.minimize) - # Fix design variables to make the problem square - for comp in model.experiment_inputs: - comp.fix() - solver.solve(model, tee=tee) # Probe the solved model for dsdp results (sensitivities s.t. parameters) @@ -1061,6 +1057,8 @@ def _Q_opt( calc_cov = kwargs[UnsupportedArgsLib.calc_cov.value] cov_n = kwargs[UnsupportedArgsLib.cov_n.value] + if not isinstance(calc_cov, bool): + raise TypeError("Expected a boolean for 'calc_cov' argument.") if not calc_cov: # Do not calculate the reduced hessian @@ -1081,7 +1079,7 @@ def _Q_opt( else: # parmest makes the fitted parameters stage 1 variables ind_vars = [] - for ndname, Var, solval in ef_nonants(ef): + for nd_name, Var, sol_val in ef_nonants(ef): ind_vars.append(Var) # calculate the reduced hessian (solve_result, inv_red_hes) = ( @@ -1114,7 +1112,18 @@ def _Q_opt( if kwargs and all(arg.value in kwargs for arg in UnsupportedArgsLib): if calc_cov: - # Calculate the covariance matrix + if not isinstance(cov_n, int): + raise TypeError("Expected an integer for 'cov_n' argument.") + num_unknowns = max( + [ + len(experiment.get_labeled_model().unknown_parameters) + for experiment in self.exp_list + ] + ) + assert cov_n > num_unknowns, ( + "The number of datapoints must be greater than the " + "number of parameters to estimate" + ) # Number of data points considered n = cov_n @@ -1211,7 +1220,7 @@ def _cov_at_theta(self, method, solver, cov_n, step): # Number of data points considered n = cov_n - # Extract number of fitted parameters + # Extract the number of fitted parameters l = len(self.estimated_theta) # calculate the sum of squared errors at the estimated parameter values @@ -1703,9 +1712,9 @@ def cov_est( "Expected a string for the method, e.g., 'finite_difference'" ) - # check if the supplied number of datapoints is an integer + # check if the user-supplied number of datapoints is an integer if not isinstance(cov_n, int): - raise TypeError("Expected an integer for " + '"cov_n".') + raise TypeError("Expected an integer for 'cov_n' argument.") # number of unknown parameters num_unknowns = max( @@ -1714,13 +1723,10 @@ def cov_est( for experiment in self.exp_list ] ) - assert isinstance(cov_n, int), ( - "The number of datapoints that are used in the objective function is " - "required to calculate the covariance matrix." + assert cov_n > num_unknowns, ( + "The number of datapoints must be greater than the " + "number of parameters to estimate." ) - assert ( - cov_n > num_unknowns - ), "The number of datapoints must be greater than the number of parameters to estimate." return self._cov_at_theta(method=method, solver=solver, cov_n=cov_n, step=step) @@ -1923,6 +1929,7 @@ def leaveNout_bootstrap_test( results = [] for idx, sample in global_list: + obj, theta = self.theta_est() bootstrap_theta = self.theta_est_bootstrap(bootstrap_samples) diff --git a/pyomo/contrib/parmest/tests/test_parmest.py b/pyomo/contrib/parmest/tests/test_parmest.py index 3df44aa8759..6c3c9686675 100644 --- a/pyomo/contrib/parmest/tests/test_parmest.py +++ b/pyomo/contrib/parmest/tests/test_parmest.py @@ -100,6 +100,7 @@ def get_labeled_model(self): self.create_model() self.finalize_model() self.label_model() + return self.model def create_model(self): @@ -118,10 +119,6 @@ def finalize_model(self): def label_model(self): m = self.model - # add experiment inputs - m.experiment_inputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) - m.experiment_inputs.update([(m.hour, self.hour)]) - # add experiment outputs m.experiment_outputs = pyo.Suffix(direction=pyo.Suffix.LOCAL) m.experiment_outputs.update([(m.y, self.y)])