-
Notifications
You must be signed in to change notification settings - Fork 558
Bugfix: IPOPT log parser and no objective case #3738
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
Changes from all commits
4d00144
8032600
edf0528
9e7c31b
0c7ffda
b9b1453
00d9705
cbc3293
a9c3bf5
b4fea1e
89e021f
393f61b
b5d8608
24ddea7
31b6143
2ab4ee8
385b872
06674ed
e615ddd
94c2bcd
4a52dfc
56e8df6
d9b15db
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 |
---|---|---|
|
@@ -62,7 +62,7 @@ | |
|
||
# Acceptable chars for the end of the alpha_pr column | ||
# in ipopt's output, per https://coin-or.github.io/Ipopt/OUTPUT.html | ||
_ALPHA_PR_CHARS = set("fFhHkKnNRwstTr") | ||
_ALPHA_PR_CHARS = set("fFhHkKnNRwSstTr") | ||
|
||
|
||
class IpoptConfig(SolverConfig): | ||
|
@@ -115,6 +115,9 @@ def get_reduced_costs( | |
self, vars_to_load: Optional[Sequence[VarData]] = None | ||
) -> Mapping[VarData, float]: | ||
self._error_check() | ||
# If the NL instance has no objectives, report zeros | ||
if not len(self._nl_info.objectives): | ||
return ComponentMap() | ||
if self._nl_info.scaling is None: | ||
scale_list = [1] * len(self._nl_info.variables) | ||
obj_scale = 1 | ||
|
@@ -294,7 +297,7 @@ def has_linear_solver(self, linear_solver: str) -> bool: | |
def _verify_ipopt_options(self, config: IpoptConfig) -> None: | ||
for key, msg in unallowed_ipopt_options.items(): | ||
if key in config.solver_options: | ||
raise ValueError(f"unallowed ipopt option '{key}': {msg}") | ||
raise ValueError(f"unallowed Ipopt option '{key}': {msg}") | ||
# Map standard Pyomo solver options to Ipopt options: standard | ||
# options override ipopt-specific options. | ||
if config.time_limit is not None: | ||
|
@@ -505,12 +508,10 @@ def solve(self, model, **kwds) -> Results: | |
for k, v in cpu_seconds.items(): | ||
results.timing_info[k] = v | ||
results.extra_info = parsed_output_data | ||
# Set iteration_log visibility to ADVANCED_OPTION because it's | ||
# a lot to print out with `display` | ||
results.extra_info.get("iteration_log")._visibility = ( | ||
ADVANCED_OPTION | ||
) | ||
except KeyError as e: | ||
iter_log = results.extra_info.get("iteration_log", None) | ||
if iter_log is not None: | ||
iter_log._visibility = ADVANCED_OPTION | ||
except Exception as e: | ||
logger.log( | ||
logging.WARNING, | ||
"The solver output data is empty or incomplete.\n" | ||
|
@@ -610,42 +611,70 @@ def _parse_ipopt_output(self, output: Union[str, io.StringIO]) -> Dict[str, Any] | |
"ls", | ||
] | ||
iterations = [] | ||
n_expected_columns = len(columns) | ||
|
||
for line in iter_table: | ||
tokens = line.strip().split() | ||
if len(tokens) != len(columns): | ||
continue | ||
# IPOPT sometimes mashes the first two column values together | ||
# (e.g., "2r-4.93e-03"). We need to split them. | ||
try: | ||
idx = tokens[0].index('-') | ||
head = tokens[0][:idx] | ||
if head and head.rstrip('r').isdigit(): | ||
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. Why is this test needed? The only way for a |
||
tokens[:1] = (head, tokens[0][idx:]) | ||
except ValueError: | ||
pass | ||
|
||
iter_data = dict(zip(columns, tokens)) | ||
extra_tokens = tokens[n_expected_columns:] | ||
|
||
# Extract restoration flag from 'iter' | ||
iter_data['restoration'] = iter_data['iter'].endswith('r') | ||
if iter_data['restoration']: | ||
iter_data['iter'] = iter_data['iter'][:-1] | ||
iter_num = iter_data.pop("iter") | ||
restoration = iter_num.endswith("r") | ||
if restoration: | ||
iter_num = iter_num[:-1] | ||
|
||
try: | ||
iter_num = int(iter_num) | ||
except ValueError: | ||
logger.warning( | ||
f"Could not parse Ipopt iteration number: {iter_num}" | ||
) | ||
|
||
iter_data["restoration"] = restoration | ||
iter_data["iter"] = iter_num | ||
|
||
# Separate alpha_pr into numeric part and optional tag | ||
iter_data['step_acceptance'] = iter_data['alpha_pr'][-1] | ||
if iter_data['step_acceptance'] in _ALPHA_PR_CHARS: | ||
# Separate alpha_pr into numeric part and optional tag (f, D, R, etc.) | ||
step_acceptance_tag = iter_data['alpha_pr'][-1] | ||
if step_acceptance_tag in _ALPHA_PR_CHARS: | ||
iter_data['step_acceptance'] = step_acceptance_tag | ||
iter_data['alpha_pr'] = iter_data['alpha_pr'][:-1] | ||
else: | ||
iter_data['step_acceptance'] = None | ||
|
||
# Capture optional IPOPT diagnostic tags if present | ||
if extra_tokens: | ||
iter_data['diagnostic_tags'] = " ".join(extra_tokens) | ||
|
||
mrmundt marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# Attempt to cast all values to float where possible | ||
for key in columns: | ||
if iter_data[key] == '-': | ||
for key in columns[1:]: | ||
val = iter_data[key] | ||
if val == '-': | ||
iter_data[key] = None | ||
else: | ||
try: | ||
iter_data[key] = float(iter_data[key]) | ||
iter_data[key] = float(val) | ||
except (ValueError, TypeError): | ||
logger.warning( | ||
"Error converting Ipopt log entry to " | ||
f"float:\n\t{sys.exc_info()[1]}\n\t{line}" | ||
) | ||
|
||
assert len(iterations) == iter_data.pop('iter'), ( | ||
f"Parsed row in the iterations table\n\t{line}\ndoes not " | ||
f"match the next expected iteration number ({len(iterations)})" | ||
) | ||
if len(iterations) != iter_num: | ||
logger.warning( | ||
f"Total number of iterations parsed {len(iterations)} " | ||
f"does not match the expected iteration number ({iter_num})." | ||
) | ||
iterations.append(iter_data) | ||
|
||
parsed_data['iteration_log'] = iterations | ||
|
@@ -674,7 +703,6 @@ def _parse_ipopt_output(self, output: Union[str, io.StringIO]) -> Dict[str, Any] | |
"complementarity_error", | ||
"overall_nlp_error", | ||
] | ||
|
||
# Filter out None values and create final fields and values. | ||
# Nones occur in old-style IPOPT output (<= 3.13) | ||
zipped = [ | ||
|
@@ -684,10 +712,8 @@ def _parse_ipopt_output(self, output: Union[str, io.StringIO]) -> Dict[str, Any] | |
) | ||
if scaled is not None and unscaled is not None | ||
] | ||
|
||
scaled = {k: float(s) for k, s, _ in zipped} | ||
unscaled = {k: float(u) for k, _, u in zipped} | ||
|
||
parsed_data.update(unscaled) | ||
parsed_data['final_scaled_results'] = scaled | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -122,6 +122,12 @@ def get_duals( | |
'Solution loader does not currently have a valid solution. Please ' | ||
'check results.termination_condition and/or results.solution_status.' | ||
) | ||
# If the NL instance has no objectives, report zeros | ||
if not self._nl_info.objectives: | ||
cons = ( | ||
cons_to_load if cons_to_load is not None else self._nl_info.constraints | ||
) | ||
return {c: 0.0 for c in cons} | ||
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. As with RC, is it more correct to return {}? 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 am happy to defer sorting out the duals / rc until the next PR (which will be a more substantive rework of the SOL parser) |
||
if len(self._nl_info.eliminated_vars) > 0: | ||
raise NotImplementedError( | ||
'For now, turn presolve off (opt.config.writer_config.linear_presolve=False) ' | ||
|
@@ -133,21 +139,20 @@ def get_duals( | |
"have happened. Report this error to the Pyomo Developers." | ||
) | ||
res = {} | ||
if self._nl_info.scaling is None: | ||
scale_list = [1] * len(self._nl_info.constraints) | ||
obj_scale = 1 | ||
else: | ||
scale_list = self._nl_info.scaling.constraints | ||
scaling = self._nl_info.scaling | ||
if scaling: | ||
_iter = zip( | ||
self._nl_info.constraints, self._sol_data.duals, scaling.constraints | ||
) | ||
obj_scale = self._nl_info.scaling.objectives[0] | ||
if cons_to_load is None: | ||
cons_to_load = set(self._nl_info.constraints) | ||
else: | ||
cons_to_load = set(cons_to_load) | ||
for con, val, scale in zip( | ||
self._nl_info.constraints, self._sol_data.duals, scale_list | ||
): | ||
if con in cons_to_load: | ||
res[con] = val * scale / obj_scale | ||
_iter = zip(self._nl_info.constraints, self._sol_data.duals) | ||
if cons_to_load is not None: | ||
_iter = filter(lambda x: x[0] in cons_to_load, _iter) | ||
if scaling: | ||
res = {con: val * scale / obj_scale for con, val, scale in _iter} | ||
else: | ||
res = {con: val for con, val in _iter} | ||
return res | ||
|
||
|
||
|
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.
I'd still to prefer to not rely on the exception for the "normal" case where the columns are properly separated. That is, look for the
-
, and if it was found, then split the tokens.