Skip to content

Commit 0dad82c

Browse files
ilansenDekker1lokdlok
authored
Finding diverse solution for MiniZinc instances (#87)
* Initial commit on diversity * Fix the formatting and types * Make MznAnalyse.find consistent with Driver.find * Simplify the assignment of previous solutions * Remove the requirement of numpy * Remove unused argument MznAnalyse.run * Refactor code to be in more logical places * Additional refactoring of diverse_solutions function * Change Instance.diverse_solutions to be async * Add a more programmatic interface for the mzn-analyse tool * Add solver argument for Instance.diverse_solutions * Change solution assertions in Instance.diverse_solutions to early return * Add initial diversity library file * Diversity MZN: Added description of annotations. Removed unused annotations. * Diversity MZN: more description to annotations * Diversity: Added documentation entry for diversity functionality * Move diversity.mzn file into the MiniZinc standard library * Fix typing issues --------- Co-authored-by: Jip J. Dekker <[email protected]> Co-authored-by: Kevin Leo <[email protected]>
1 parent 33f7deb commit 0dad82c

File tree

6 files changed

+488
-4
lines changed

6 files changed

+488
-4
lines changed

docs/advanced_usage.rst

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,79 @@ better solution is found in the last 3 iterations, it will stop.
159159
else:
160160
i += 1
161161
162+
Getting Diverse Solutions
163+
-------------------------
164+
165+
It is sometimes useful to find multiple solutions to a problem
166+
that exhibit some desired measure of diversity. For example, in a
167+
satisfaction problem, we may wish to have solutions that differ in
168+
the assignments to certain variables but we might not care about some
169+
others. Another important case is where we wish to find a diverse set
170+
of close-to-optimal solutions.
171+
172+
The following example demonstrates a simple optimisation problem where
173+
we wish to find a set of 5 diverse, close to optimal solutions.
174+
First, to define the diversity metric, we annotate the solve item with
175+
the :func:`diverse_pairwise(x, "hamming_distance")` annotation to indicate that
176+
we wish to find solutions that have the most differences to each other.
177+
The `diversity.mzn` library also defines the "manhattan_distance"
178+
diversity metric which computes the sum of the absolution difference
179+
between solutions.
180+
Second, to define how many solutions, and how close to optimal we wish the
181+
solutions to be, we use the :func:`diversity_incremental(5, 1.0)` annotation.
182+
This indicates that we wish to find 5 diverse solutions, and we will
183+
accept solutions that differ from the optimal by 100% (Note that this is
184+
the ratio of the optimal solution, not an optimality gap).
185+
186+
.. code-block:: minizinc
187+
188+
% AllDiffOpt.mzn
189+
include "alldifferent.mzn";
190+
include "diversity.mzn";
191+
192+
array[1..5] of var 1..5: x;
193+
constraint alldifferent(x);
194+
195+
solve :: diverse_pairwise(x, "hamming_distance")
196+
:: diversity_incremental(5, 1.0) % number of solutions, gap %
197+
minimize x[1];
198+
199+
The :func:`Instance.diverse_solutions` method will use these annotations
200+
to find the desired set of diverse solutions. If we are solving an
201+
optimisation problem and want to find "almost" optimal solutions we must
202+
first acquire the optimal solution. This solution is then passed to
203+
the :func:`diverse_solutions()` method in the :func:`reference_solution` parameter.
204+
We loop until we see a duplicate solution.
205+
206+
.. code-block:: python
207+
208+
import asyncio
209+
import minizinc
210+
211+
async def main():
212+
# Create a MiniZinc model
213+
model = minizinc.Model("AllDiffOpt.mzn")
214+
215+
# Transform Model into a instance
216+
gecode = minizinc.Solver.lookup("gecode")
217+
inst = minizinc.Instance(gecode, model)
218+
219+
# Solve the instance
220+
result = await inst.solve_async(all_solutions=False)
221+
print(result.objective)
222+
223+
# Solve the instance to obtain diverse solutions
224+
sols = []
225+
async for divsol in inst.diverse_solutions(reference_solution=result):
226+
if divsol["x"] not in sols:
227+
sols.append(divsol["x"])
228+
else:
229+
print("New diverse solution already in the pool of diverse solutions. Terminating...")
230+
break
231+
print(divsol["x"])
232+
233+
asyncio.run(main())
234+
162235
163236
Concurrent Solving
164237
------------------

src/minizinc/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,5 @@
5050
"Result",
5151
"Solver",
5252
"Status",
53+
"Diversity",
5354
]

src/minizinc/analyse.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# This Source Code Form is subject to the terms of the Mozilla Public
2+
# License, v. 2.0. If a copy of the MPL was not distributed with this
3+
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
4+
import json
5+
import os
6+
import platform
7+
import shutil
8+
import subprocess
9+
from enum import Enum, auto
10+
from pathlib import Path
11+
from typing import Any, Dict, List, Optional, Union
12+
13+
from .driver import MAC_LOCATIONS, WIN_LOCATIONS
14+
from .error import ConfigurationError, MiniZincError
15+
16+
17+
class InlineOption(Enum):
18+
DISABLED = auto()
19+
NON_LIBRARY = auto()
20+
ALL = auto()
21+
22+
23+
class MznAnalyse:
24+
"""Python interface to the mzn-analyse executable
25+
26+
This tool is used to retrieve information about or transform a MiniZinc
27+
instance. This is used, for example, to diverse solutions to the given
28+
MiniZinc instance using the given solver configuration.
29+
"""
30+
31+
_executable: Path
32+
33+
def __init__(self, executable: Path):
34+
self._executable = executable
35+
if not self._executable.exists():
36+
raise ConfigurationError(
37+
f"No MiniZinc data annotator executable was found at '{self._executable}'."
38+
)
39+
40+
@classmethod
41+
def find(
42+
cls, path: Optional[List[str]] = None, name: str = "mzn-analyse"
43+
) -> Optional["MznAnalyse"]:
44+
"""Finds the mzn-analyse executable on default or specified path.
45+
46+
The find method will look for the mzn-analyse executable to create an
47+
interface for MiniZinc Python. If no path is specified, then the paths
48+
given by the environment variables appended by default locations will be
49+
tried.
50+
51+
Args:
52+
path: List of locations to search. name: Name of the executable.
53+
54+
Returns:
55+
Optional[MznAnalyse]: Returns a MznAnalyse object when found or None.
56+
"""
57+
58+
if path is None:
59+
path = os.environ.get("PATH", "").split(os.pathsep)
60+
# Add default MiniZinc locations to the path
61+
if platform.system() == "Darwin":
62+
path.extend(MAC_LOCATIONS)
63+
elif platform.system() == "Windows":
64+
path.extend(WIN_LOCATIONS)
65+
66+
# Try to locate the MiniZinc executable
67+
executable = shutil.which(name, path=os.pathsep.join(path))
68+
if executable is not None:
69+
return cls(Path(executable))
70+
return None
71+
72+
def run(
73+
self,
74+
mzn_files: List[Path],
75+
inline_includes: InlineOption = InlineOption.DISABLED,
76+
remove_litter: bool = False,
77+
get_diversity_anns: bool = False,
78+
get_solve_anns: bool = True,
79+
output_all: bool = True,
80+
mzn_output: Optional[Path] = None,
81+
remove_anns: Optional[List[str]] = None,
82+
remove_items: Optional[List[str]] = None,
83+
) -> Dict[str, Any]:
84+
# Do not change the order of the arguments 'inline-includes', 'remove-items:output', 'remove-litter' and 'get-diversity-anns'
85+
tool_run_cmd: List[Union[str, Path]] = [
86+
str(self._executable),
87+
"json_out:-",
88+
]
89+
90+
for f in mzn_files:
91+
tool_run_cmd.append(str(f))
92+
93+
if inline_includes == InlineOption.ALL:
94+
tool_run_cmd.append("inline-all_includes")
95+
elif inline_includes == InlineOption.NON_LIBRARY:
96+
tool_run_cmd.append("inline-includes")
97+
98+
if remove_items is not None and len(remove_items) > 0:
99+
tool_run_cmd.append(f"remove-items:{','.join(remove_items)}")
100+
if remove_anns is not None and len(remove_anns) > 0:
101+
tool_run_cmd.append(f"remove-anns:{','.join(remove_anns)}")
102+
103+
if remove_litter:
104+
tool_run_cmd.append("remove-litter")
105+
if get_diversity_anns:
106+
tool_run_cmd.append("get-diversity-anns")
107+
108+
if mzn_output is not None:
109+
tool_run_cmd.append(f"out:{str(mzn_output)}")
110+
else:
111+
tool_run_cmd.append("no_out")
112+
113+
# Extract the diversity annotations.
114+
proc = subprocess.run(
115+
tool_run_cmd, stderr=subprocess.PIPE, stdout=subprocess.PIPE
116+
)
117+
if proc.returncode != 0:
118+
raise MiniZincError(message=str(proc.stderr))
119+
return json.loads(proc.stdout)

src/minizinc/driver.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,13 @@
2626
#: Default locations on MacOS where the MiniZinc packaged release would be installed
2727
MAC_LOCATIONS = [
2828
str(Path("/Applications/MiniZincIDE.app/Contents/Resources")),
29+
str(Path("/Applications/MiniZincIDE.app/Contents/Resources/bin")),
2930
str(Path("~/Applications/MiniZincIDE.app/Contents/Resources").expanduser()),
31+
str(
32+
Path(
33+
"~/Applications/MiniZincIDE.app/Contents/Resources/bin"
34+
).expanduser()
35+
),
3036
]
3137
#: Default locations on Windows where the MiniZinc packaged release would be installed
3238
WIN_LOCATIONS = [

src/minizinc/helpers.py

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import sys
22
from dataclasses import asdict, is_dataclass
33
from datetime import timedelta
4-
from typing import Any, Dict, Optional, Sequence, Union
4+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Union
55

66
import minizinc
77

@@ -102,7 +102,7 @@ def check_solution(
102102

103103
assert isinstance(solution, dict)
104104
for k, v in solution.items():
105-
if k not in ("objective", "__output_item"):
105+
if k not in ("objective", "_output_item", "_checker"):
106106
instance[k] = v
107107
check = instance.solve(time_limit=time_limit)
108108

@@ -120,3 +120,101 @@ def check_solution(
120120
minizinc.Status.OPTIMAL_SOLUTION,
121121
minizinc.Status.ALL_SOLUTIONS,
122122
]
123+
124+
125+
def _add_diversity_to_opt_model(
126+
inst: minizinc.Instance,
127+
obj_annots: Dict[str, Any],
128+
vars: List[Dict[str, Any]],
129+
sol_fix: Optional[Dict[str, Iterable]] = None,
130+
):
131+
for var in vars:
132+
# Current and previous variables
133+
varname = var["name"]
134+
varprevname = var["prev_name"]
135+
136+
# Add the 'previous solution variables'
137+
inst[varprevname] = []
138+
139+
# Fix the solution to given once
140+
if sol_fix is not None:
141+
inst.add_string(
142+
f"constraint {varname} == {list(sol_fix[varname])};\n"
143+
)
144+
145+
# Add the optimal objective.
146+
if obj_annots["sense"] != "0":
147+
obj_type = obj_annots["type"]
148+
inst.add_string(f"{obj_type}: div_orig_opt_objective :: output;\n")
149+
inst.add_string(
150+
f"constraint div_orig_opt_objective == {obj_annots['name']};\n"
151+
)
152+
if obj_annots["sense"] == "-1":
153+
inst.add_string(f"solve minimize {obj_annots['name']};\n")
154+
else:
155+
inst.add_string(f"solve maximize {obj_annots['name']};\n")
156+
else:
157+
inst.add_string("solve satisfy;\n")
158+
159+
return inst
160+
161+
162+
def _add_diversity_to_div_model(
163+
inst: minizinc.Instance,
164+
vars: List[Dict[str, Any]],
165+
obj_sense: str,
166+
gap: Union[int, float],
167+
sols: Dict[str, Any],
168+
):
169+
# Add the 'previous solution variables'
170+
for var in vars:
171+
# Current and previous variables
172+
varname = var["name"]
173+
varprevname = var["prev_name"]
174+
varprevisfloat = "float" in var["prev_type"]
175+
176+
distfun = var["distance_function"]
177+
prevsols = sols[varprevname] + [sols[varname]]
178+
prevsol = (
179+
__round_elements(prevsols, 6) if varprevisfloat else prevsols
180+
) # float values are rounded to six decimal places to avoid infeasibility due to decimal errors.
181+
182+
# Add the previous solutions to the model code.
183+
inst[varprevname] = prevsol
184+
185+
# Add the diversity distance measurement to the model code.
186+
dim = __num_dim(prevsols)
187+
dotdots = ", ".join([".." for _ in range(dim - 1)])
188+
varprevtype = "float" if "float" in var["prev_type"] else "int"
189+
inst.add_string(
190+
f"array [1..{len(prevsol)}] of var {varprevtype}: dist_{varname} :: output = [{distfun}({varname}, {varprevname}[sol,{dotdots}]) | sol in 1..{len(prevsol)}];\n"
191+
)
192+
193+
# Add the bound on the objective.
194+
if obj_sense == "-1":
195+
inst.add_string(f"constraint div_orig_objective <= {gap};\n")
196+
elif obj_sense == "1":
197+
inst.add_string(f"constraint div_orig_objective >= {gap};\n")
198+
199+
# Add new objective: maximize diversity.
200+
dist_sum = "+".join([f'sum(dist_{var["name"]})' for var in vars])
201+
inst.add_string(f"solve maximize {dist_sum};\n")
202+
203+
return inst
204+
205+
206+
def __num_dim(x: List) -> int:
207+
i = 1
208+
while isinstance(x[0], list):
209+
i += 1
210+
x = x[0]
211+
return i
212+
213+
214+
def __round_elements(x: List, p: int) -> List:
215+
for i in range(len(x)):
216+
if isinstance(x[i], list):
217+
x[i] = __round_elements(x[i], p)
218+
elif isinstance(x[i], float):
219+
x[i] = round(x[i], p)
220+
return x

0 commit comments

Comments
 (0)