8000 Let there be names in LP files by senhalil · Pull Request #2811 · cvxpy/cvxpy · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Let there be names in LP files #2811

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions cvxpy/reductions/solvers/conic_solvers/highs_conif.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
limitations under the License.
"""

from re import compile

import numpy as np

import cvxpy.interface as intf
Expand All @@ -26,6 +28,48 @@
)
from cvxpy.utilities.citations import CITATION_DICT

PYTHON_LIST_SLICE_PATTERN = compile(r", \d+:\d+")
VALID_COLUMN_NAME_PATTERN = compile(
r"^(?!st$|bounds$|min$|max$|bin$|binary$|gen$|semi$|end$)[a-df-zA-DF-Z\"!#$%&/}{,;?@_‘’'`|~]{1}[a-zA-Z0-9\"!#$%&/}{,;?@_‘’'`|~.=()<>[\]]{,254}$"
)
INVALID_COLUMN_NAME_MESSAGE_TEMPLATE = (
"Invalid column name: {name}"
"\nA column name must:"
"\n- not be equal to one of the keywords: st, bounds, min, max, bin, binary, gen, semi or end"
"\n- not begin with a number, the letter e or E or any of the following characters: .=()<>[]"
"\n- be alphanumeric (a-z, A-Z, 0-9) or one of these symbols: \"!#$%&/}}{{,;?@_‘’'`|~.=()<>[]"
"\n- be no longer than 255 characters."
)


def validate_column_name(name: str) -> bool:
"""Check if the name is a valid column name."""
if not VALID_COLUMN_NAME_PATTERN.match(name):
raise ValueError(INVALID_COLUMN_NAME_MESSAGE_TEMPLATE.format(name=name))


def strip_column_name_of_python_list_slice_notation(name: str) -> str:
"""Strip python list slice notation -- i.e., the part after the comma in [0, 0:#]
- space and colon characters are not allowed in column names and
- 0:# part is not needed in X[0, 0:#] because we label X[0][0] ... X[0][#] individually
"""
return PYTHON_LIST_SLICE_PATTERN.sub("", name)


def collect_column_names(variable, column_names):
"""Recursively collect variable names."""
if variable.ndim == 0: # scalar
column_names.append(variable.name())
elif variable.ndim == 1: # simple array
var_name_prefix = strip_column_name_of_python_list_slice_notation(variable.name())
column_names.extend([f"{var_name_prefix}[{v}]" for v in range(variable.size)])
else: # multi-dimensional array
for var in variable:
collect_column_names(var, column_names) # recursive call
# Checking the validity of only the last inserted name is sufficient because all var
# names are derived from the same var name prefix and the last one is the longest
validate_column_name(column_names[-1])


def unpack_highs_options_inplace(solver_opts) -> None:
# Users can pass options inside a nested dict -- e.g. to circumvent a name clash
Expand Down Expand Up @@ -220,10 +264,26 @@ def solve_via_data(
for key, value in solver_opts.items():
setattr(options, key, value)

if options.write_model_file:
# TODO: Names can be collected upstream more systematically
# (or in the parent class) to be used by all solvers.
column_names = []
for variable in data[s.PARAM_PROB].variables:
# NOTE: variable.variable_of_provenance() is a bit of a hack
# to make sure that auto generated vars are named correctly -- nonneg=True etc.
variable = variable.variable_of_provenance() or variable
collect_column_names(variable, column_names)
lp.col_names_ = column_names

solver = hp.Highs()
solver.passOptions(options)
solver.passModel(model)

if options.write_model_file:
# TODO: This part can be removed once the following HiGS PR is released:
# https://github.com/ERGO-Code/HiGHS/pull/2274
solver.writeModel(options.write_model_file)

if warm_start and solver_cache is not None and self.name() in solver_cache:
old_solver, old_data, old_result = solver_cache[self.name()]
old_status = self.STATUS_MAP.get(old_result["model_status"], s.SOLVER_ERROR)
Expand Down
30 changes: 21 additions & 9 deletions cvxpy/reductions/solvers/qp_solvers/highs_qpif.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,14 @@
import cvxpy.settings as s
from cvxpy.error import SolverError
from cvxpy.reductions.solution import Solution, failure_solution
from cvxpy.reductions.solvers.conic_solvers.highs_conif import ( # importing to avoid duplication
collect_column_names,
unpack_highs_options_inplace,
)
from cvxpy.reductions.solvers.qp_solvers.qp_solver import QpSolver
from cvxpy.utilities.citations import CITATION_DICT


def unpack_highs_options_inplace(solver_opts) -> None:
# Users can pass options inside a nested dict -- e.g. to circumvent a name clash
highs_options = solver_opts.pop("highs_options", dict())

# merge via update(dict(...)) is needed to avoid silently over-writing options
solver_opts.update(dict(**solver_opts, **highs_options))


class HIGHS(QpSolver):
"""QP interface for the HiGHS solver"""

Expand Down Expand Up @@ -193,10 +189,26 @@ def solve_via_data(
for key, value in solver_opts.items():
setattr(options, key, value)

if options.write_model_file:
# TODO: Names can be collected upstream more systematically
# (or in the parent class) to be used by all solvers.
column_names = []
for variable in data[s.PARAM_PROB].variables:
# NOTE: variable.variable_of_provenance() is a bit of a hack
# to make sure that auto generated vars are named correctly -- nonneg=True etc.
variable = variable.variable_of_provenance() or variable
collect_column_names(variable, column_names)
lp.col_names_ = column_names

solver = hp.Highs()
solver.passOptions(options)
solver.passModel(model)

if options.write_model_file:
# TODO: This part can be removed once the following HiGS PR is released:
# https://github.com/ERGO-Code/HiGHS/pull/2274
solver.writeModel(options.write_model_file)

if warm_start and solver_cache is not None and self.name() in solver_cache:
old_solver, old_data, old_result = solver_cache[self.name()]
old_status = self.STATUS_MAP.get(old_result["model_status"], s.SOLVER_ERROR)
Expand Down Expand Up @@ -229,4 +241,4 @@ def cite(self, data):
data : dict
Data generated via an apply call.
"""
return CITATION_DICT["HIGHS"]
return CITATION_DICT["HIGHS"]
7 changes: 7 additions & 0 deletions cvxpy/tests/solver_test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1225,6 +1225,13 @@ def test_mi_lp_5(solver, places: int = 4, **kwargs) -> SolverTestHelper:
sth.verify_primal_values(places)
return sth

@staticmethod
def test_mi_lp_6(solver, places: int = 4, **kwargs) -> SolverTestHelper:
sth = mi_lp_6()
sth.solve(solver, **kwargs)
sth.verify_objective(places)
sth.verify_primal_values(places)
return sth

class StandardTestQPs:

Expand Down
98 changes: 98 additions & 0 deletions cvxpy/tests/test_conic_solvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

import math
import re
import string
import tempfile
import unittest

import numpy as np
Expand Down Expand Up @@ -2151,6 +2153,7 @@ class TestHIGHS:
StandardTestLPs.test_mi_lp_3,
StandardTestLPs.test_mi_lp_4,
StandardTestLPs.test_mi_lp_5,
StandardTestLPs.test_mi_lp_6,
],
)
def test_highs_solving(self, problem) -> None:
Expand All @@ -2176,6 +2179,101 @@ def test_highs_warm_start(self, problem, confirmation_string, capfd) -> None:
captured = capfd.readouterr()
assert re.search(confirmation_string, captured.out) is not None

def test_highs_validate_column_name(self) -> None:
"""Test that HiGHS column name check is working correctly.

For more information about the rules, see:
cvxpy.reductions.solvers.conic_solvers.highs_conif.INVALID_COLUMN_NAME_MESSAGE_TEMPLATE
"""
from cvxpy.reductions.solvers.conic_solvers.highs_conif import validate_column_name

must_not_be_a_keyword = set(
["st", "bounds", "min", "max", "bin", "binary", "gen", "semi", "end"]
)
must_not_begin_with = set(string.digits + "eE.=()<>[]")
may_contain = set(string.ascii_letters + string.digits + "\"!#$%&/}{,;?@_‘’'`|~.=()<>[]")
must_not_contain = set(string.printable) - set(may_contain)
may_begin_with = (set(may_contain) - set(must_not_begin_with)).union(
set(must_not_be_a_keyword) - set(["end"])
)

# Happy path: valid names
valid_names = (
["a" * 255]
+ [single_char_name for single_char_name in may_begin_with - must_not_be_a_keyword]
+ [f"{beginning}{contains}" for beginning, contains in zip(may_begin_with, may_contain)]
)
for name in valid_names:
validate_column_name(name)

# Unhappy path: invalid names
invalid_names = (
["a" * 256]
+ [keyword for keyword in must_not 9E88 _be_a_keyword]
+ [f"{beginning}_with" for beginning in must_not_begin_with]
+ [f"a_{containing}_name" for containing in must_not_contain]
)
for name in invalid_names:
with pytest.raises(ValueError):
validate_column_name(name)

@pytest.mark.parametrize(
"variables",
[
[
cp.Variable(name="var_with_no_shape"),
cp.Variable(name="var_with_shape_1", shape=1),
cp.Variable(name="var_with_shape_2_by_2_by_2", shape=[2, 2, 2]),
cp.Variable(name="nonneg_var_with_no_shape", nonneg=True),
cp.Variable(name="nonneg_var_with_shape_1", nonneg=True, shape=1),
cp.Variable(name="nonneg_var_with_shape_2_by_2", nonneg=True, shape=[2, 2]),
cp.Variable(name="boolean_variable_to_test_conif", boolean=True),
],
[
cp.Variable(name="var_with_no_shape"),
cp.Variable(name="var_with_shape_1", shape=1),
cp.Variable(name="var_with_shape_2_by_2_by_2", shape=[2, 2, 2]),
cp.Variable(name="nonneg_var_with_no_shape", nonneg=True),
cp.Variable(name="nonneg_var_with_shape_1", nonneg=True, shape=1),
cp.Variable(name="nonneg_var_with_shape_2_by_2", nonneg=True, shape=[2, 2]),
cp.Variable(name="no_boolean_variable_to_test_qpif", boolean=False),
],
],
)
def test_highs_written_model_contains_variable_names(self, variables, capfd) -> None:
"""Test that HiGHS actually receives and writes out the variable names.

Args:
variables: List of cvxpy variables to be used in the test.
capfd: Captures stdout to search for confirmation_string.
"""
prob = cp.Problem(cp.Minimize(cp.sum(cp.sum(variables))), [cp.sum(cp.sum(variables)) >= 1])

with tempfile.NamedTemporaryFile(mode="w+", suffix=".lp") as model_file:
prob.solve(cp.HIGHS, verbose=True, write_model_file=model_file.name)

captured = capfd.readouterr().out
# Check that the model is written to the file
assert re.search(rf"\nWriting the model to {model_file.name}\n", captured), (
f"Expected model file to be written to {model_file.name}."
)

# Check that the model contains the variable names as expected.
model = model_file.read()
found_variables = re.sub(
" <= 1| free| ", "", re.search(r"\nbounds\n([\w\W]*?)\n(bin|end)\n", model)[1]
).split("\n")

for expected_var in variables:
actual_var_count = 0
for actual_var in found_variables:
if actual_var.startswith(expected_var.name()):
actual_var_count += 1
expected_var_count = expected_var.size
assert expected_var_count == actual_var_count, (
f"Expected variable {expected_var.name()} to appear "
f"{expected_var.size} times in the model bounds section."
)

def test_highs_nonstandard_name(self) -> None:
"""Test HiGHS solver with non-capitalized solver name."""
Expand Down
Loading
0