-
Notifications
You must be signed in to change notification settings - Fork 583
Clip and scale calculate_variable_from_constraint #3993
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,7 @@ | |
| from pyomo.common.numeric_types import native_numeric_types, native_complex_types, value | ||
| from pyomo.core.expr.calculus.derivatives import differentiate | ||
| from pyomo.core.base.constraint import Constraint | ||
| from pyomo.core.base.suffix import SuffixFinder | ||
|
|
||
| import logging | ||
|
|
||
|
|
@@ -24,6 +25,53 @@ | |
| } | ||
|
|
||
|
|
||
| def _clip_variable_to_bounds(variable, expr, eps): | ||
| """ | ||
| Function to try to clip a variable back within its bounds. | ||
| If the expression residual is less than eps, then the new | ||
| value of the variable is retained. If the value is greater | ||
| than eps, then the original, bound-violating value of | ||
| variable is retained. | ||
|
|
||
| Parameters: | ||
| ----------- | ||
| variable: :py:class:`VarData` | ||
| The variable to clip to within its bounds | ||
| expr: :py:class:`NumericExpression` | ||
| The expression to evaluate the constraint residual | ||
| eps: `float` | ||
| The tolerance to use to determine constraint feasibility. | ||
|
|
||
| Returns: | ||
| -------- | ||
| None | ||
|
|
||
| """ | ||
| # Should we check against variable domain here as well? | ||
| variable_value = value(variable) | ||
| if ( | ||
| variable.lb is not None | ||
| and variable.ub is not None | ||
| and variable.lb > variable.ub | ||
| ): | ||
| raise ValueError( | ||
| f"Variable {variable} has inconsistent bounds. " | ||
| f"It has a lower bound of {variable.lb} and an " | ||
| f"upper bound of {variable.ub}." | ||
| ) | ||
| elif variable.lb is not None and variable_value < variable.lb: | ||
| variable.set_value(variable.lb) | ||
| if abs(value(expr)) < eps: | ||
| return | ||
| # Else proceed to set the variable to its old value | ||
| elif variable.ub is not None and variable_value > variable.ub: | ||
| variable.set_value(variable.ub) | ||
| if abs(value(expr)) < eps: | ||
| return | ||
| # Else proceed to set the variable to its old value | ||
| variable.set_value(variable_value) | ||
|
|
||
|
|
||
| def calculate_variable_from_constraint( | ||
| variable, | ||
| constraint, | ||
|
|
@@ -32,6 +80,8 @@ def calculate_variable_from_constraint( | |
| linesearch=True, | ||
| alpha_min=1e-8, | ||
| diff_mode=None, | ||
| scale_problem=False, | ||
| clip_to_bounds=False, | ||
| ): | ||
| """Calculate the variable value given a specified equality constraint | ||
|
|
||
|
|
@@ -72,6 +122,16 @@ def calculate_variable_from_constraint( | |
| diff_mode: :py:enum:`pyomo.core.expr.calculus.derivatives.Modes` | ||
| The mode to use to differentiate the expression. If | ||
| unspecified, defaults to `Modes.sympy` | ||
| scale_problem: `bool` | ||
| Whether to take variable and constraint scaling into account | ||
| when calculating if the constraint residual tolerance is | ||
| satisfied or if the initial derivative is zero. | ||
| clip_to_bounds: `bool` | ||
| If terminating at a point outside a variable's bounds, set the | ||
| variable to its closest bound and see whether the constraint | ||
| residual termination condition is satisfied. This method is | ||
| applied only to constraints that are not solved by the shortcut | ||
| methods used to quickly solve linear constraints. | ||
|
|
||
| Returns: | ||
| -------- | ||
|
|
@@ -97,6 +157,16 @@ def calculate_variable_from_constraint( | |
| if lower != upper: | ||
| raise ValueError(f"Constraint '{constraint}' must be an equality constraint") | ||
|
|
||
| if scale_problem: | ||
| finder = SuffixFinder('scaling_factor', 1.0, constraint.model()) | ||
|
|
||
| sf_variable = finder.find(variable) | ||
| sf_constraint = finder.find(constraint) | ||
| eps /= sf_constraint | ||
| else: | ||
| sf_variable = 1 | ||
| sf_constraint = 1 | ||
|
|
||
| _invalid_types = set(native_complex_types) | ||
| _invalid_types.add(type(None)) | ||
|
|
||
|
|
@@ -219,7 +289,7 @@ def calculate_variable_from_constraint( | |
| else: | ||
| fp0 = value(expr_deriv) | ||
|
|
||
| if abs(value(fp0)) < 1e-12: | ||
| if abs(value(fp0) * sf_constraint / sf_variable) < 1e-12: | ||
| raise ValueError( | ||
| f"Initial value for variable '{variable}' results in a derivative " | ||
| f"value for constraint '{constraint}' that is very close to zero.\n" | ||
|
|
@@ -231,11 +301,18 @@ def calculate_variable_from_constraint( | |
| while abs(fk) > eps and iter_left: | ||
| iter_left -= 1 | ||
| if not iter_left: | ||
| raise IterationLimitError( | ||
| f"Iteration limit (%s) reached solving for variable '{variable}' " | ||
| f"using constraint '{constraint}'; remaining residual = %s" | ||
| % (iterlim, value(expr)) | ||
| ) | ||
| if scale_problem: | ||
| raise IterationLimitError( | ||
| f"Iteration limit (%s) reached solving for variable '{variable}' " | ||
| f"using constraint '{constraint}'; remaining (scaled) residual = %s" | ||
| % (iterlim, sf_constraint * value(expr)) | ||
| ) | ||
| else: | ||
| raise IterationLimitError( | ||
| f"Iteration limit (%s) reached solving for variable '{variable}' " | ||
| f"using constraint '{constraint}'; remaining residual = %s" | ||
| % (iterlim, value(expr)) | ||
| ) | ||
|
|
||
| # compute step | ||
| xk = value(variable) | ||
|
|
@@ -258,7 +335,7 @@ def calculate_variable_from_constraint( | |
| else: | ||
| fpk = value(expr_deriv) | ||
|
|
||
| if abs(fpk) < 1e-12: | ||
| if abs(fpk * sf_constraint / sf_variable) < 1e-12: | ||
| # TODO: should this raise a ValueError or a new | ||
| # DerivativeError (subclassing ArithmeticError)? | ||
| raise RuntimeError( | ||
|
|
@@ -286,7 +363,11 @@ def calculate_variable_from_constraint( | |
| if fkp1.__class__ in _invalid_types: | ||
| # We cannot perform computations on complex numbers | ||
| fkp1 = None | ||
| if fkp1 is not None and fkp1**2 < c1 * fk**2: | ||
| # Why isn't this the Armijo condition? | ||
| if ( | ||
| fkp1 is not None | ||
| and (fkp1 * sf_constraint) ** 2 < c1 * (fk * sf_constraint) ** 2 | ||
| ): | ||
| # found an alpha value with sufficient reduction | ||
| # continue to the next step | ||
| fk = fkp1 | ||
|
|
@@ -310,7 +391,9 @@ def calculate_variable_from_constraint( | |
| f"variable '{variable}' using constraint '{constraint}'; " | ||
| f"remaining residual = {residual}." | ||
| ) | ||
| # | ||
| # Re-set the variable value to trigger any warnings WRT the final | ||
| # variable state | ||
| variable.set_value(variable.value) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could just add We could also transform
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd rather have the warnings if we end up setting the variable value to something outside its bounds. I would be okay with converting it to a bound-constrained solver. For the initial version of this PR, I wanted to keep the changes minor as to not break any existing workflow. I wonder whether turning on bound clipping would cause some of the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe I guess my problem with clipping is that you're just trading some bound infeasibility for some equality infeasibility. I would want to make sure that we didn't make the constraint infeasible (by more than tolerance) after the clipping. But I'm also now realizing that, since these are single-variable problems, clipping is not so different than respecting bounds during the solve iterations.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I would love to see an example of this. I have some ideas related to bound propagation among blocks (e.g., #3573) that I would like to test on a real problem.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Right now, if clipping the variable causes the constraint to become infeasible, it reverts to the unclipped value and logs a warning.
With any sort of nonlinear solution method, there's always the risk that changing the intermediate iterations causes the problem to converge to either a different solution or a stationary point for the constraint residual. In general, though, I would guess that clipping as part of the line search would give a better chance at converging to a solution that satisfies the bound (if such a solution exists). There are two possible implementations:
I think I prefer option (2), because we don't have to worry about how to determine we've reached a bound---we just jump straight to the bound. With option (1), we could end up spending a long time taking tiny line search steps before the sufficient decrease condition is violated. Edit: Another thing to keep in mind is consistency between the three methods used in the function. The first shortcut method assumes the constraint is of the form
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Good point. I don't have a strong preference on the implementation if it is demonstrated to work, as long as this is opt-in behavior (for now). |
||
| if clip_to_bounds: | ||
| _clip_variable_to_bounds(variable, expr, eps) | ||
| else: | ||
| # Re-set the variable value to trigger any warnings WRT the final | ||
| # variable state | ||
| variable.set_value(variable.value) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this is just scaling tolerances, I'm not sure it belongs in this function. The caller can always use the scaling factor to compute a new tolerance if they want. I also don't love calling SuffixFinder within this function.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It also applies scaling factors when calculating if a problem has a derivative of effectively zero.
That's true, but that means I'm going to move the scaling here into
solve_strongly_connected_components.Is the concern that it adds too much overhead to each function call?