From 93f17cf346e10a9a57d4a4962d7392d03dce555d Mon Sep 17 00:00:00 2001 From: Benjamin Gallusser Date: Mon, 7 Aug 2023 18:18:44 +0200 Subject: [PATCH] Add disappear cost --- motile/costs/__init__.py | 2 + motile/costs/disappear.py | 29 ++++++++++++ motile/variables/__init__.py | 2 + motile/variables/node_disappear.py | 72 ++++++++++++++++++++++++++++++ tests/test_api.py | 5 ++- 5 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 motile/costs/disappear.py create mode 100644 motile/variables/node_disappear.py diff --git a/motile/costs/__init__.py b/motile/costs/__init__.py index 8dca953..49abea1 100644 --- a/motile/costs/__init__.py +++ b/motile/costs/__init__.py @@ -1,5 +1,6 @@ from .appear import Appear from .costs import Costs +from .disappear import Disappear from .edge_distance import EdgeDistance from .edge_selection import EdgeSelection from .features import Features @@ -11,6 +12,7 @@ __all__ = [ "Appear", "Costs", + "Disappear", "EdgeDistance", "EdgeSelection", "Features", diff --git a/motile/costs/disappear.py b/motile/costs/disappear.py new file mode 100644 index 0000000..99d0ddc --- /dev/null +++ b/motile/costs/disappear.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ..variables import NodeDisappear +from .costs import Costs +from .weight import Weight + +if TYPE_CHECKING: + from motile.solver import Solver + + +class Disappear(Costs): + """Costs for :class:`motile.variables.NodeDisappear` variables. + + Args: + + constant (float): + A constant cost for each node that ends a track. + """ + + def __init__(self, constant: float) -> None: + self.constant = Weight(constant) + + def apply(self, solver: Solver) -> None: + disappear_indicators = solver.get_variables(NodeDisappear) + + for index in disappear_indicators.values(): + solver.add_variable_cost(index, 1.0, self.constant) diff --git a/motile/variables/__init__.py b/motile/variables/__init__.py index 0711ca0..49cc1c2 100644 --- a/motile/variables/__init__.py +++ b/motile/variables/__init__.py @@ -1,5 +1,6 @@ from .edge_selected import EdgeSelected from .node_appear import NodeAppear +from .node_disappear import NodeDisappear from .node_selected import NodeSelected from .node_split import NodeSplit from .variable import Variable @@ -7,6 +8,7 @@ __all__ = [ "EdgeSelected", "NodeAppear", + "NodeDisappear", "NodeSelected", "NodeSplit", "Variable", diff --git a/motile/variables/node_disappear.py b/motile/variables/node_disappear.py new file mode 100644 index 0000000..f499377 --- /dev/null +++ b/motile/variables/node_disappear.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Collection, Iterable + +from .edge_selected import EdgeSelected +from .node_selected import NodeSelected +from .variable import Variable + +if TYPE_CHECKING: + import ilpy + + from motile._types import NodeId + from motile.solver import Solver + + +class NodeDisappear(Variable["NodeId"]): + r"""A binary variable for each node that indicates whether the node is the + end of a track (i.e., the node is selected and has no selected outgoing + edges). + + This variable is coupled to the node and edge selection variables through + the following linear constraints: + + .. math:: + |\\text{out_edges}(v)|\cdot x_v - &\sum_{e \in \\text{out_edges}(v)} x_e + - d_v &\leq&\;\; |\\text{out_edges}(v)| - 1 + + |\\text{out_edges}(v)|\cdot x_v - &\sum_{e \in \\text{out_edges}(v)} x_e + - d_v\cdot |\\text{out_edges}(v)| &\geq&\;\; 0 + + where :math:`x_v` and :math:`x_e` are selection indicators for node + :math:`v` and edge :math:`e`, and :math:`d_v` is the disappear indicator for + node :math:`v`. + """ + + @staticmethod + def instantiate(solver: Solver) -> Collection[NodeId]: + return solver.graph.nodes + + @staticmethod + def instantiate_constraints(solver: Solver) -> Iterable[ilpy.Expression]: + node_indicators = solver.get_variables(NodeSelected) + edge_indicators = solver.get_variables(EdgeSelected) + disappear_indicators = solver.get_variables(NodeDisappear) + + for node in solver.graph.nodes: + next_edges = solver.graph.next_edges[node] + selected = node_indicators[node] + disappear = disappear_indicators[node] + + if not next_edges: + # special case: no outgoing edges, disappear indicator is equal to + # selection indicator + yield selected == disappear + continue + + # Ensure that the following holds: + # + # disappear = 1 <=> sum(next_selected) = 0 and selected + # disappear = 0 <=> sum(next_selected) > 0 or not selected + # + # Two linear constraints are needed for that: + # + # let s = num_next * selected - sum(next_selected) + # (1) s - disappear <= num_next - 1 + # (2) s - disappear * num_next >= 0 + + num_next = len(next_edges) + s = num_next * selected - sum(edge_indicators[e] for e in next_edges) + + yield s - disappear <= num_next - 1 + yield s - disappear >= 0 diff --git a/tests/test_api.py b/tests/test_api.py index c1a5cea..e059157 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2,7 +2,7 @@ import motile from motile.constraints import MaxChildren, MaxParents -from motile.costs import Appear, EdgeSelection, NodeSelection, Split +from motile.costs import Appear, Disappear, EdgeSelection, NodeSelection, Split from motile.data import ( arlo_graph, arlo_graph_nx, @@ -40,7 +40,8 @@ def test_solver(self): solver = motile.Solver(graph) solver.add_costs(NodeSelection(weight=-1.0, attribute="score", constant=-100.0)) solver.add_costs(EdgeSelection(weight=1.0, attribute="prediction_distance")) - solver.add_costs(Appear(constant=200.0)) + solver.add_costs(Appear(constant=100.0)) + solver.add_costs(Disappear(constant=100.0)) solver.add_costs(Split(constant=100.0)) solver.add_constraints(MaxParents(1)) solver.add_constraints(MaxChildren(2))