Skip to content

Commit d3c4a4b

Browse files
Better modeling of given weights in the acyclic models
1 parent f3a4a75 commit d3c4a4b

File tree

7 files changed

+317
-121
lines changed

7 files changed

+317
-121
lines changed

.github/copilot-instructions.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
## AI coding agent guide for this repo (flowpaths)
2+
3+
Purpose: Python package to decompose weighted digraphs into weighted paths/walks via (M)ILP. Default solver is HiGHS (highspy); Gurobi (gurobipy) is optional.
4+
5+
Architecture (what to know first)
6+
- Public API: `flowpaths/__init__.py` re-exports solvers (`MinFlowDecomp`, `kMinPathError`, `kLeastAbsErrors`, `*Cycles`, path-/set-cover, etc.).
7+
- Two model bases: `AbstractPathModelDAG` (acyclic, s–t paths; uses `stDAG`) and `AbstractWalkModelDiGraph` (general digraphs, walks).
8+
- ILP bridge: `utils/solverwrapper.py` unifies HiGHS/Gurobi (vars, constraints, binary×continuous, objective, status, timeouts).
9+
- Node-weighted graphs: `nodeexpandeddigraph.py` handles `flow_attr_origin="node"`, expands/condenses paths; supports `additional_starts/ends`.
10+
- Safety/optimizations live under `flowpaths/utils/*` and are toggled via `optimization_options` in concrete solvers.
11+
12+
How solutions are built (DAG models)
13+
- Create k edge-binary vars x(u,v,i) constrained to be s–t paths; add weights/constraints per objective. Output is `{'paths'|'walks', 'weights'}`; node-origin paths are condensed back.
14+
- `MinFlowDecomp` minimizes number of paths; uses width lower bound, minimal generating set, and subgraph scanning; may accept a greedy solution if it matches a lower bound.
15+
16+
Project-specific conventions
17+
- Options are dicts:
18+
- `solver_options`: {threads, time_limit, presolve, log_to_console, external_solver: "highs"|"gurobi"}.
19+
- `optimization_options`: {optimize_with_safe_paths|safe_sequences|safe_zero_edges, use_min_gen_set_lowerbound, use_subgraph_scanning_lowerbound, ...}.
20+
- `weight_type` is int or float; choose deliberately (affects feasibility/integrality). `flow_attr_origin` is "edge" (default) or "node"; only node-mode allows `additional_starts/ends`.
21+
- `elements_to_ignore`: edges (tuples) in edge-mode; node names (strings) in node-mode. `subpath_constraints` support coverage by fraction or length (`length_attr`).
22+
23+
Example (from README/tests)
24+
```python
25+
import flowpaths as fp, networkx as nx
26+
G = nx.DiGraph(); G.add_edge('s','a', flow=2); G.add_edge('a','t', flow=2)
27+
m = fp.MinFlowDecomp(G, flow_attr='flow'); m.solve(); sol = m.get_solution()
28+
```
29+
30+
Developer workflows
31+
- Setup: `pip install -e ".[dev]"`; optional `pip install gurobipy` and set `GRB_LICENSE_FILE`.
32+
- Tests (pytest.ini pins discovery to `tests/`): `pytest -vv -ra --durations=10`; targeted: `pytest -k "min_flow_decomp"`.
33+
- Examples/CLI: `python examples/min_flow_decomp.py`; `python -m flowpaths`.
34+
- Docs: `mkdocs serve` (sources in `docs/`, nav in `mkdocs.yml`).
35+
36+
Pitfalls/checks
37+
- Path models require a DAG; flows must be non-negative and conserve at non s/t nodes. If `is_solved()` is False, check `solver.get_model_status()`; `kTimeLimit` → raise `time_limit`.
38+
39+
Pointers
40+
- Overview/examples: `README.md`, `examples/*.py`.
41+
- Options/optimizations: `docs/solver-options-optimizations.md`; internals: `abstract-path-model.md`, `stdag.md`.

examples/mfd_cycles_mingenset.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def test(filename: str):
2424
"optimize_with_safe_sequences": True,
2525
"optimize_with_safety_as_subset_constraints": False,
2626
"use_min_gen_set_lowerbound": False,
27-
"optimize_with_given_weights": False,
27+
"optimize_with_guessed_weights": False,
2828
},
2929
solver_options={"external_solver": "highs"},
3030
)

flowpaths/kflowdecomp.py

Lines changed: 85 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def __init__(
2424
subpath_constraints_coverage_length: float = None,
2525
length_attr: str = None,
2626
elements_to_ignore: list = [],
27+
solution_weights_superset: list = None,
2728
optimization_options: dict = {},
2829
solver_options: dict = {},
2930
):
@@ -87,6 +88,12 @@ def __init__(
8788
8889
List of edges (or nodes, if `flow_attr_origin` is `"node"`) to ignore when adding constrains on flow explanation by the weighted paths. Default is an empty list. See [ignoring edges documentation](ignoring-edges.md)
8990
91+
- `solution_weights_superset: list`, optional
92+
93+
List of allowed weights for the paths. Default is `None`.
94+
If set, the model will use the solution path weights only from this set, with the property that **every weight in this list
95+
appears at most once in the solution weight**. That is, if you want to have more paths with the same weight, add it more times to `solution_weights_superset`.
96+
9097
- `optimization_options : dict`, optional
9198
9299
Dictionary with the optimization options. Default is `None`. See [optimization options documentation](solver-options-optimizations.md).
@@ -164,7 +171,11 @@ def __init__(
164171
)
165172
)
166173

174+
if k <= 0 or not isinstance(k, int):
175+
utils.logger.error(f"{__name__}: k must be a positive integer, not {k}")
176+
raise ValueError(f"k must be a positive integer, not {k}")
167177
self.k = k
178+
self.original_k = self.k
168179

169180
self.subpath_constraints_coverage = subpath_constraints_coverage
170181
self.subpath_constraints_coverage_length = subpath_constraints_coverage_length
@@ -206,6 +217,16 @@ def __init__(
206217

207218
self.optimization_options["trusted_edges_for_safety"] = self.G.get_non_zero_flow_edges(flow_attr=self.flow_attr, edges_to_ignore=self.edges_to_ignore)
208219

220+
self.solution_weights_superset = solution_weights_superset
221+
222+
if self.solution_weights_superset is not None:
223+
self.k = len(self.solution_weights_superset)
224+
self.optimization_options["allow_empty_paths"] = True
225+
self.optimization_options["optimize_with_safe_paths"] = False
226+
self.optimization_options["optimize_with_flow_safe_paths"] = False
227+
self.optimization_options["optimize_with_safe_sequences"] = False
228+
self.optimization_options["optimize_with_safe_zero_edges"] = False
229+
209230
# Call the constructor of the parent class AbstractPathModelDAG
210231
super().__init__(
211232
G=self.G,
@@ -227,10 +248,10 @@ def __init__(
227248
self.create_solver_and_paths()
228249

229250
# This method is called from the current class to encode the flow decomposition
230-
self._encode_flow_decomposition()
231-
232-
# The given weights optimization
233-
self._encode_given_weights()
251+
if self.solution_weights_superset is None:
252+
self._encode_flow_decomposition()
253+
else:
254+
self._encode_flow_decomposition_with_given_weights()
234255

235256
utils.logger.info(f"{__name__}: initialized with graph id = {utils.fpid(G)}, k = {self.k}")
236257

@@ -280,15 +301,16 @@ def _encode_flow_decomposition(self):
280301

281302
self.solver.add_constraint(
282303
self.solver.quicksum(self.pi_vars[(u, v, i)] for i in range(self.k)) == f_u_v,
283-
name=f"10d_u={u}_v={v}_i={i}",
304+
name=f"10d_u={u}_v={v}",
284305
)
285306

286-
def _encode_given_weights(self):
287-
288-
weights = self.optimization_options.get("given_weights", None)
289-
if weights is None:
290-
return
307+
def _encode_flow_decomposition_with_given_weights(self):
291308

309+
# If already solved, no need to encode further
310+
if self.is_solved():
311+
return
312+
313+
# Some checks
292314
if self.optimization_options.get("optimize_with_safe_paths", False):
293315
utils.logger.error(f"{__name__}: Cannot optimize with both given weights and safe paths")
294316
raise ValueError("Cannot optimize with both given weights and safe paths")
@@ -302,18 +324,38 @@ def _encode_given_weights(self):
302324
utils.logger.error(f"{__name__}: Cannot optimize with both given weights and flow safe paths")
303325
raise ValueError("Cannot optimize with both given weights and flow safe paths")
304326

305-
if len(weights) > self.k:
306-
utils.logger.error(f"Length of given weights ({len(weights)}) is greater than k ({self.k})")
307-
raise ValueError(f"Length of given weights ({len(weights)}) is greater than k ({self.k})")
327+
if len(self.solution_weights_superset) != self.k:
328+
utils.logger.error(f"Length of given weights ({len(self.solution_weights_superset)}) is different from k ({self.k})")
329+
raise ValueError(f"Length of given weights ({len(self.solution_weights_superset)}) is different from k ({self.k})")
330+
331+
# We encode that for each edge (u,v), the sum of the weights of the paths going through the edge is equal to the flow value of the edge.
332+
for u, v, data in self.G.edges(data=True):
333+
if (u, v) in self.edges_to_ignore:
334+
continue
335+
f_u_v = data[self.flow_attr]
308336

309-
for i, weight in enumerate(weights):
310337
self.solver.add_constraint(
311-
self.path_weights_vars[i] == weight,
312-
name=f"given_weight_{i}",
338+
self.solver.quicksum(self.solution_weights_superset[i] * self.edge_vars[(u, v, i)] for i in range(self.k)) == f_u_v,
339+
name=f"10d_u={u}_v={v}",
313340
)
314341

342+
# We state that at most self.original_k paths can be used
343+
self.solver.add_constraint(
344+
self.solver.quicksum(
345+
self.solver.quicksum(
346+
self.edge_vars[(self.G.source, v, i)]
347+
for v in self.G.successors(self.G.source)
348+
) for i in range(self.k)
349+
) <= self.original_k,
350+
name="max_paths_original_k_paths",
351+
)
352+
315353
self.solver.set_objective(
316-
self.solver.quicksum(self.edge_vars[(u, v, i)] for u, v in self.G.edges() for i in range(self.k)),
354+
self.solver.quicksum(
355+
self.edge_vars[(self.G.source, v, i)]
356+
for v in self.G.successors(self.G.source)
357+
for i in range(self.k)
358+
),
317359
sense="minimize",
318360
)
319361

@@ -428,32 +470,36 @@ def get_solution(self, remove_empty_paths=False):
428470
- `exception` If model is not solved.
429471
"""
430472

431-
if self._solution is None:
473+
if self._solution is not None:
474+
return self._remove_empty_paths(self._solution) if remove_empty_paths else self._solution
432475

433-
self.check_is_solved()
476+
self.check_is_solved()
477+
478+
if self.solution_weights_superset is None:
434479
weights_sol_dict = self.solver.get_values(self.path_weights_vars)
435-
self.path_weights_sol = [
436-
(
437-
round(weights_sol_dict[i])
438-
if self.weight_type == int
439-
else float(weights_sol_dict[i])
440-
)
441-
for i in range(self.k)
442-
]
480+
else:
481+
weights_sol_dict = {i: self.solution_weights_superset[i] for i in range(self.k)}
443482

444-
if self.flow_attr_origin == "edge":
445-
self._solution = {
446-
"paths": self.get_solution_paths(),
447-
"weights": self.path_weights_sol,
448-
}
449-
elif self.flow_attr_origin == "node":
450-
self._solution = {
451-
"_paths_internal": self.get_solution_paths(),
452-
"paths": self.G_internal.get_condensed_paths(self.get_solution_paths()),
453-
"weights": self.path_weights_sol,
454-
}
483+
self.path_weights_sol = [
484+
(
485+
round(weights_sol_dict[i])
486+
if self.weight_type == int
487+
else float(weights_sol_dict[i])
488+
)
489+
for i in range(self.k)
490+
]
455491

456-
return self._remove_empty_paths(self._solution) if remove_empty_paths else self._solution
492+
if self.flow_attr_origin == "edge":
493+
self._solution = {
494+
"paths": self.get_solution_paths(),
495+
"weights": self.path_weights_sol,
496+
}
497+
elif self.flow_attr_origin == "node":
498+
self._solution = {
499+
"_paths_internal": self.get_solution_paths(),
500+
"paths": self.G_internal.get_condensed_paths(self.get_solution_paths()),
501+
"weights": self.path_weights_sol,
502+
}
457503

458504
def is_valid_solution(self, tolerance=0.001):
459505
"""

flowpaths/kleastabserrors.py

Lines changed: 56 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,8 @@ def __init__(
108108
- `solution_weights_superset: list`, optional
109109
110110
List of allowed weights for the paths. Default is `None`.
111-
If set, the model will use the solution path weights only from this set, with the property that **every weight in the superset
112-
appears at most once in the solution weight**.
111+
If set, the model will use the solution path weights only from this set, with the property that **every weight in this list
112+
appears at most once in the solution weight**. That is, if you want to have more paths with the same weight, add it more times to `solution_weights_superset`.
113113
114114
- `optimization_options: dict`, optional
115115
@@ -260,10 +260,10 @@ def __init__(
260260
self.create_solver_and_paths()
261261

262262
# This method is called from the current class
263-
self._encode_leastabserrors_decomposition()
264-
265-
# This method is called from the current class
266-
self._encode_solution_weights_superset()
263+
if self.solution_weights_superset is not None:
264+
self._encode_leastabserrors_decomposition_with_given_weights()
265+
else:
266+
self._encode_leastabserrors_decomposition()
267267

268268
# This method is called from the current class to add the objective function
269269
self._encode_objective()
@@ -330,38 +330,56 @@ def _encode_leastabserrors_decomposition(self):
330330
name=f"9ab_u={u}_v={v}_i={i}",
331331
)
332332

333-
def _encode_solution_weights_superset(self):
333+
def _encode_leastabserrors_decomposition_with_given_weights(self):
334334

335-
if self.solution_weights_superset is not None:
335+
# Some checks on the solution_weights_superset
336+
if len(self.solution_weights_superset) != self.k:
337+
utils.logger.error(f"{__name__}: solution_weights_superset must have length {self.k}, not {len(self.solution_weights_superset)}")
338+
raise ValueError(f"solution_weights_superset must have length {self.k}, not {len(self.solution_weights_superset)}")
339+
if not self.allow_empty_paths:
340+
utils.logger.error(f"{__name__}: solution_weights_superset is not allowed when allow_empty_paths is False")
341+
raise ValueError(f"solution_weights_superset is not allowed when allow_empty_paths is False")
336342

337-
if len(self.solution_weights_superset) != self.k:
338-
utils.logger.error(f"{__name__}: solution_weights_superset must have length {self.k}, not {len(self.solution_weights_superset)}")
339-
raise ValueError(f"solution_weights_superset must have length {self.k}, not {len(self.solution_weights_superset)}")
340-
if not self.allow_empty_paths:
341-
utils.logger.error(f"{__name__}: solution_weights_superset is not allowed when allow_empty_paths is False")
342-
raise ValueError(f"solution_weights_superset is not allowed when allow_empty_paths is False")
343-
344-
# We state that the weight of the i-th path equals the i-th entry of the solution_weights_superset
345-
for i in range(self.k):
346-
if self.solution_weights_superset[i] > self.w_max:
347-
utils.logger.error(f"{__name__}: solution_weights_superset[{i}] must be less than or equal to {self.w_max}, not {self.solution_weights_superset[i]}")
348-
raise ValueError(f"solution_weights_superset[{i}] must be less than or equal to {self.w_max}, not {self.solution_weights_superset[i]}")
349-
self.solver.add_constraint(
350-
self.path_weights_vars[i] == self.solution_weights_superset[i],
351-
name=f"solution_weights_superset_{i}",
352-
)
343+
self.edge_indexes_basic = [(u,v) for (u,v) in self.G.edges() if (u,v) not in self.edges_to_ignore]
344+
345+
self.edge_errors_vars = self.solver.add_variables(
346+
self.edge_indexes_basic,
347+
name_prefix="ee",
348+
lb=0,
349+
ub=self.w_max,
350+
var_type="integer" if self.weight_type == int else "continuous",
351+
)
353352

354-
# We state that at most self.original_k paths can be used
355-
self.solver.add_constraint(
356-
self.solver.quicksum(
357-
self.solver.quicksum(
358-
self.edge_vars[(self.G.source, v, i)]
359-
for v in self.G.successors(self.G.source)
360-
) for i in range(self.k)
361-
) <= self.original_k,
362-
name="max_paths_original_k_paths",
353+
for u, v, data in self.G.edges(data=True):
354+
if (u, v) in self.edges_to_ignore:
355+
continue
356+
357+
f_u_v = data[self.flow_attr]
358+
359+
# Encoding the error on the edge (u, v) as the difference between
360+
# the flow value of the edge and the sum of the weights of the paths that go through it (pi variables)
361+
# If we minimize the sum of edge_errors_vars, then we are minimizing the sum of the absolute errors.
362+
self.solver.add_constraint(
363+
f_u_v - self.solver.quicksum(self.solution_weights_superset[i] * self.edge_vars[(u, v, i)] for i in range(self.k)) <= self.edge_errors_vars[(u, v)],
364+
name=f"9aa_u={u}_v={v}",
363365
)
364366

367+
self.solver.add_constraint(
368+
-f_u_v + self.solver.quicksum(self.solution_weights_superset[i] * self.edge_vars[(u, v, i)] for i in range(self.k)) <= self.edge_errors_vars[(u, v)],
369+
name=f"9ab_u={u}_v={v}",
370+
)
371+
372+
# We state that at most self.original_k paths can be used
373+
self.solver.add_constraint(
374+
self.solver.quicksum(
375+
self.solver.quicksum(
376+
self.edge_vars[(self.G.source, v, i)]
377+
for v in self.G.successors(self.G.source)
378+
) for i in range(self.k)
379+
) <= self.original_k,
380+
name="max_paths_original_k_paths",
381+
)
382+
365383
def _encode_objective(self):
366384

367385
self.solver.set_objective(
@@ -425,7 +443,11 @@ def get_solution(self, remove_empty_paths=True):
425443

426444
self.check_is_solved()
427445

428-
weights_sol_dict = self.solver.get_values(self.path_weights_vars)
446+
if self.solution_weights_superset is None:
447+
weights_sol_dict = self.solver.get_values(self.path_weights_vars)
448+
else:
449+
weights_sol_dict = {i: self.solution_weights_superset[i] for i in range(self.k)}
450+
429451
self.path_weights_sol = [
430452
(
431453
round(weights_sol_dict[i])

0 commit comments

Comments
 (0)