22import flowpaths .stdigraph as stdigraph
33import flowpaths .abstractpathmodeldag as pathmodel
44import flowpaths .utils as utils
5+ import copy
56
67
78class 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
0 commit comments