Skip to content

Commit e545ea8

Browse files
Fixed issue with highs solving problem when the objective was added; added support for weights superset in LeastAbsErrors
1 parent 4dae261 commit e545ea8

File tree

4 files changed

+141
-22
lines changed

4 files changed

+141
-22
lines changed

flowpaths/kleastabserrors.py

Lines changed: 87 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22
import flowpaths.stdigraph as stdigraph
33
import flowpaths.abstractpathmodeldag as pathmodel
44
import flowpaths.utils as utils
5+
import copy
56

67

78
class kLeastAbsErrors(pathmodel.AbstractPathModelDAG):
89
"""
9-
This class implements the k-LeastAbsoluteErrors, namely it looks for a decomposition of a weighted DAG into
10-
k weighted paths (specified by `num_paths`), minimizing the absolute errors on the edges. The error on an edge
10+
This class implements the k-LeastAbsoluteErrors problem, namely it looks for a decomposition of a weighted DAG into
11+
$k$ weighted paths, minimizing the absolute errors on the edges. The error on an edge
1112
is defined as the absolute value of the difference between the weight of the edge and the sum of the weights of
1213
the paths that go through it.
1314
"""
@@ -28,6 +29,7 @@ def __init__(
2829
optimization_options: dict = None,
2930
solver_options: dict = {},
3031
trusted_edges_for_safety: list = None,
32+
solution_weights_superset: list = None,
3133
):
3234
"""
3335
Initialize the Least Absolute Errors model for a given number of paths.
@@ -102,6 +104,12 @@ def __init__(
102104
If set, the model can apply the safety optimizations for these edges, so it can be significantly faster.
103105
See [optimizations documentation](solver-options-optimizations.md#2-optimizations)
104106
107+
- `solution_weights_superset: list`, optional
108+
109+
List of allowed weights for the paths. Default is `None`.
110+
If set, the model will use the solution path weights only from this set, with the property that **every weight in the superset
111+
appears at most once in the solution weight**.
112+
105113
Raises
106114
------
107115
- `ValueError`
@@ -135,12 +143,19 @@ def __init__(
135143
)
136144

137145
self.k = k
146+
self.original_k = k
147+
self.solution_weights_superset = solution_weights_superset
148+
self.optimization_options = optimization_options or {}
149+
150+
if self.solution_weights_superset is not None:
151+
self.k = len(self.solution_weights_superset)
152+
self.optimization_options["allow_empty_paths"] = True
153+
138154
self.subpath_constraints = subpath_constraints
139155
self.subpath_constraints_coverage = subpath_constraints_coverage
140156
self.subpath_constraints_coverage_length = subpath_constraints_coverage_length
141157
self.edge_length_attr = edge_length_attr
142158

143-
144159
self.pi_vars = {}
145160
self.path_weights_vars = {}
146161
self.edge_errors_vars = {}
@@ -151,8 +166,6 @@ def __init__(
151166
self.__lowerbound_k = None
152167

153168
self.solve_statistics = {}
154-
155-
self.optimization_options = optimization_options or {}
156169

157170
# If we get subpath constraints, and the coverage fraction is 1
158171
# then we know their edges must appear in the solution, so we add their edges to the trusted edges for safety
@@ -163,11 +176,10 @@ def __init__(
163176
for constraint in self.subpath_constraints:
164177
self.optimization_options["trusted_edges_for_safety"].update(constraint)
165178

166-
167179
# Call the constructor of the parent class AbstractPathModelDAG
168180
super().__init__(
169181
self.G,
170-
k,
182+
self.k,
171183
subpath_constraints=self.subpath_constraints,
172184
subpath_constraints_coverage=self.subpath_constraints_coverage,
173185
subpath_constraints_coverage_length=self.subpath_constraints_coverage_length,
@@ -183,6 +195,9 @@ def __init__(
183195
# This method is called from the current class
184196
self.__encode_leastabserrors_decomposition()
185197

198+
# This method is called from the current class
199+
self.__encode_solution_weights_superset()
200+
186201
# This method is called from the current class to add the objective function
187202
self.__encode_objective()
188203

@@ -210,7 +225,7 @@ def __encode_leastabserrors_decomposition(self):
210225

211226
self.edge_errors_vars = self.solver.add_variables(
212227
self.edge_indexes_basic,
213-
name_prefix="errorofedge",
228+
name_prefix="ee",
214229
lb=0,
215230
ub=self.w_max,
216231
var_type="integer" if self.weight_type == int else "continuous",
@@ -248,17 +263,74 @@ def __encode_leastabserrors_decomposition(self):
248263
name=f"9aa_u={u}_v={v}_i={i}",
249264
)
250265

266+
def __encode_solution_weights_superset(self):
267+
268+
if self.solution_weights_superset is not None:
269+
270+
if len(self.solution_weights_superset) != self.k:
271+
utils.logger.error(f"{__name__}: solution_weights_superset must have length {self.k}, not {len(self.solution_weights_superset)}")
272+
raise ValueError(f"solution_weights_superset must have length {self.k}, not {len(self.solution_weights_superset)}")
273+
if not self.allow_empty_paths:
274+
utils.logger.error(f"{__name__}: solution_weights_superset is not allowed when allow_empty_paths is False")
275+
raise ValueError(f"solution_weights_superset is not allowed when allow_empty_paths is False")
276+
277+
# We state that the weight of the i-th path equals the i-th entry of the solution_weights_superset
278+
for i in range(self.k):
279+
self.solver.add_constraint(
280+
self.path_weights_vars[i] == self.solution_weights_superset[i],
281+
name=f"solution_weights_superset_{i}",
282+
)
283+
284+
# We state that at most self.original_k paths can be used
285+
self.solver.add_constraint(
286+
self.solver.quicksum(
287+
self.solver.quicksum(
288+
self.edge_vars[(self.G.source, v, i)]
289+
for v in self.G.successors(self.G.source)
290+
) for i in range(self.k)
291+
) <= self.original_k,
292+
name="max_paths_original_k_paths",
293+
)
294+
251295
def __encode_objective(self):
252296

253297
self.solver.set_objective(
254298
self.solver.quicksum(
255-
self.edge_errors_vars[(u, v)]
256-
* self.edge_error_scaling.get((u, v), 1)
299+
self.edge_errors_vars[(u, v)] * self.edge_error_scaling.get((u, v), 1) if self.edge_error_scaling.get((u, v), 1) != 1 else self.edge_errors_vars[(u, v)]
257300
for (u,v) in self.edge_indexes_basic),
258301
sense="minimize"
259302
)
260303

261-
def get_solution(self):
304+
def __remove_empty_paths(self, solution):
305+
"""
306+
Removes empty paths from the solution. Empty paths are those with 0 or 1 nodes.
307+
308+
Parameters
309+
----------
310+
- `solution: dict`
311+
312+
The solution dictionary containing paths and weights.
313+
314+
Returns
315+
-------
316+
- `solution: dict`
317+
318+
The solution dictionary with empty paths removed.
319+
320+
"""
321+
solution_copy = copy.deepcopy(solution)
322+
non_empty_paths = []
323+
non_empty_weights = []
324+
for path, weight in zip(solution["paths"], solution["weights"]):
325+
if len(path) > 1:
326+
non_empty_paths.append(path)
327+
non_empty_weights.append(weight)
328+
329+
solution_copy["paths"] = non_empty_paths
330+
solution_copy["weights"] = non_empty_weights
331+
return solution_copy
332+
333+
def get_solution(self, remove_empty_paths=True):
262334
"""
263335
Retrieves the solution for the flow decomposition problem.
264336
@@ -279,7 +351,7 @@ def get_solution(self):
279351
"""
280352

281353
if self.__solution is not None:
282-
return self.__solution
354+
return self.__remove_empty_paths(self.__solution) if remove_empty_paths else self.__solution
283355

284356
self.check_is_solved()
285357

@@ -292,7 +364,7 @@ def get_solution(self):
292364
)
293365
for i in range(self.k)
294366
]
295-
self.edge_errors_sol = self.solver.get_variable_values("errorofedge", [str, str])
367+
self.edge_errors_sol = self.solver.get_variable_values("ee", [str, str])
296368
print("self.edge_errors_sol", self.edge_errors_sol)
297369
for (u,v) in self.edge_indexes_basic:
298370
self.edge_errors_sol[(u,v)] = round(self.edge_errors_sol[(u,v)]) if self.weight_type == int else float(self.edge_errors_sol[(u,v)])
@@ -303,7 +375,7 @@ def get_solution(self):
303375
"edge_errors": self.edge_errors_sol # This is a dictionary with keys (u,v) and values the error on the edge (u,v)
304376
}
305377

306-
return self.__solution
378+
return self.__remove_empty_paths(self.__solution) if remove_empty_paths else self.__solution
307379

308380
def is_valid_solution(self, tolerance=0.001):
309381
"""
@@ -350,7 +422,7 @@ def is_valid_solution(self, tolerance=0.001):
350422
):
351423
return False
352424

353-
if abs(self.get_objective_value() - self.solver.get_objective_value()) > tolerance * self.k:
425+
if abs(self.get_objective_value() - self.solver.get_objective_value()) > tolerance * self.original_k:
354426
return False
355427

356428
return True

flowpaths/utils/solverwrapper.py

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import signal
77
import math
88
import flowpaths.utils as utils
9+
import numpy as np
910

1011
class SolverWrapper:
1112
"""
@@ -72,7 +73,8 @@ def __init__(
7273
self.did_timeout = False
7374

7475
if self.external_solver == "highs":
75-
self.solver = highspy.Highs()
76+
self.solver = HighsCustom()
77+
self.solver.setMinimize() # minimization by default
7678
self.solver.setOptionValue("solver", "choose")
7779
self.solver.setOptionValue("threads", kwargs.get("threads", SolverWrapper.threads))
7880
self.solver.setOptionValue("time_limit", kwargs.get("time_limit", SolverWrapper.time_limit))
@@ -95,9 +97,11 @@ def __init__(
9597
self.env.setParam("MIPGap", self.tolerance)
9698
self.env.setParam("IntFeasTol", self.tolerance)
9799
self.env.setParam("FeasibilityTol", self.tolerance)
100+
self.env.setParam("ModelSense", 1) # minimization by default
98101

99102
self.env.start()
100103
self.solver = gurobipy.Model(env=self.env)
104+
101105
else:
102106
utils.logger.error(f"{__name__}: Unsupported solver type `{self.external_solver}`. Supported solvers are `highs` and `gurobi`.")
103107
raise ValueError(
@@ -268,10 +272,7 @@ def set_objective(self, expr, sense="minimize"):
268272
self.optimization_sense = sense
269273

270274
if self.external_solver == "highs":
271-
if sense in ["minimize", "min"]:
272-
self.solver.minimize(expr)
273-
else:
274-
self.solver.maximize(expr)
275+
self.solver.set_objective_without_solving(expr, sense=sense)
275276
elif self.external_solver == "gurobi":
276277
import gurobipy
277278

@@ -529,4 +530,48 @@ def __run_with_timeout(self, timeout, func):
529530
except Exception as e:
530531
pass
531532
finally:
532-
signal.alarm(0) # Disable alarm after execution
533+
signal.alarm(0) # Disable alarm after execution
534+
535+
536+
537+
class HighsCustom(highspy.Highs):
538+
def __init__(self):
539+
super().__init__()
540+
541+
def set_objective_without_solving(self, obj, sense: str = "minimize") -> None:
542+
"""
543+
This method is implemented is the same was as the [minimize()](https://github.com/ERGO-Code/HiGHS/blob/master/src/highspy/highs.py#L182) or
544+
[maximize()](https://github.com/ERGO-Code/HiGHS/blob/master/src/highspy/highs.py#L218) methods of the [Highs](https://github.com/ERGO-Code/HiGHS/blob/master/src/highspy/highs.py) class,
545+
only that it does not call the solve() method.
546+
547+
That is, you can use it to set the objective value, without also running the solver.
548+
549+
**`obj` cannot be a single variable, but a linear expression.**
550+
"""
551+
552+
if obj is not None:
553+
# if we have a single variable, wrap it in a linear expression
554+
# expr = highspy.highs_linear_expression(obj) if isinstance(obj, highspy.highs_var) else obj
555+
expr = obj
556+
557+
if expr.bounds is not None:
558+
raise Exception("Objective cannot be an inequality")
559+
560+
# reset objective
561+
super().changeColsCost(
562+
self.numVariables,
563+
np.arange(self.numVariables, dtype=np.int32),
564+
np.full(self.numVariables, 0, dtype=np.float64),
565+
)
566+
567+
# if we have duplicate variables, add the vals
568+
idxs, vals = expr.unique_elements()
569+
super().changeColsCost(len(idxs), idxs, vals)
570+
super().changeObjectiveOffset(expr.constant or 0.0)
571+
572+
if sense in ["minimize", "min"]:
573+
super().changeObjectiveSense(highspy.ObjSense.kMinimize)
574+
elif sense in ["maximize", "max"]:
575+
super().changeObjectiveSense(highspy.ObjSense.kMaximize)
576+
else:
577+
raise ValueError(f"Invalid objective sense: {sense}. Use 'minimize' or 'maximize'.")

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "flowpaths"
3-
version = "0.1.20"
3+
version = "0.1.21"
44
description = "A Python package to quickly decompose weighted graphs into weights paths, under various models."
55
readme = "README.md"
66
authors = [{name="Graph Algorithms and Bioinformatics Group @ University of Helsinki, and external collaborators"}]
@@ -19,6 +19,7 @@ dependencies = [
1919
"networkx",
2020
"highspy",
2121
"graphviz",
22+
"numpy",
2223
]
2324

2425
[build-system]

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
networkx>=3.4.2
22
highspy>=1.9.0
33
graphviz>=0.20.3
4+
numpy>=2.2.5

0 commit comments

Comments
 (0)