diff --git a/biobalm/_sd_algorithms/expand_minimal_spaces.py b/biobalm/_sd_algorithms/expand_minimal_spaces.py index 0df78b7..32515f7 100644 --- a/biobalm/_sd_algorithms/expand_minimal_spaces.py +++ b/biobalm/_sd_algorithms/expand_minimal_spaces.py @@ -5,12 +5,17 @@ if TYPE_CHECKING: from biobalm.succession_diagram import SuccessionDiagram +import copy from biobalm.space_utils import is_subspace from biobalm.trappist_core import trappist +from biobalm.types import BooleanSpace def expand_minimal_spaces( - sd: SuccessionDiagram, node_id: int | None, size_limit: int | None = None + sd: SuccessionDiagram, + node_id: int | None, + size_limit: int | None = None, + skip_remaining: bool = False, ) -> bool: """ See `SuccessionDiagram.expand_minimal_spaces` for documentation. @@ -22,7 +27,10 @@ def expand_minimal_spaces( pn = sd.node_percolated_petri_net(node_id, compute=True) node_space = sd.node_data(node_id)["space"] - minimal_traps = trappist(network=pn, problem="min", ensure_subspace=node_space) + all_minimal_traps = trappist(network=pn, problem="min", ensure_subspace=node_space) + all_minimal_traps = [(node_space | x) for x in all_minimal_traps] + # We don't need to duplicate the actual trap spaces, just the list. + minimal_traps = copy.copy(all_minimal_traps) if sd.config["debug"]: print( @@ -33,6 +41,28 @@ def expand_minimal_spaces( stack: list[tuple[int, list[int] | None]] = [(node_id, None)] + def make_skip_node( + sd: SuccessionDiagram, node_id: int, all_minimal_traps: list[BooleanSpace] + ): + node = sd.node_data(node_id) + if node["expanded"]: + return + + skip_edges = 0 + for m_trap in all_minimal_traps: + if is_subspace(m_trap, sd.node_data(node_id)["space"]): + m_id = sd._ensure_node(node_id, m_trap) # type: ignore + m_data = sd.node_data(m_id) + m_data["expanded"] = True + assert sd.node_is_minimal(m_id) + skip_edges += 1 + + node["expanded"] = True + node["skipped"] = True + + if sd.config["debug"]: + print(f"[{node_id}] Node skipped with {skip_edges} edges.") + while len(stack) > 0: (node, successors) = stack.pop() if successors is None: @@ -51,10 +81,13 @@ def expand_minimal_spaces( # do not cover any new minimal trap space. while len(successors) > 0: if successors[-1] in seen: + # Everything in seen is expanded, so no need to skip it. successors.pop() continue if len([s for s in minimal_traps if is_subspace(s, node_space)]) == 0: - successors.pop() + skipped = successors.pop() + if skip_remaining: + make_skip_node(sd, skipped, all_minimal_traps) continue break @@ -64,6 +97,8 @@ def expand_minimal_spaces( if len(successors) == 0: if sd.node_is_minimal(node): minimal_traps.remove(sd.node_data(node)["space"]) + if sd.config["debug"]: + print(f"Remaining minimal traps: {len(minimal_traps)}.") continue # At this point, we know that `s` is not visited and it contains @@ -77,5 +112,8 @@ def expand_minimal_spaces( # Push the successor onto the stack. stack.append((s, None)) + if sd.config["debug"]: + print(f"[{s}] Expanding...") + assert len(minimal_traps) == 0 return True diff --git a/biobalm/_sd_algorithms/expand_source_blocks.py b/biobalm/_sd_algorithms/expand_source_blocks.py index 4ee440c..e0cf8a2 100644 --- a/biobalm/_sd_algorithms/expand_source_blocks.py +++ b/biobalm/_sd_algorithms/expand_source_blocks.py @@ -15,6 +15,7 @@ def expand_source_blocks( check_maa: bool = True, size_limit: int | None = None, optimize_source_nodes: bool = True, + check_maa_exact: bool = False, ) -> bool: """ Base correctness assumptions: @@ -203,9 +204,14 @@ def expand_source_blocks( # just get stuck on this node and the "partial" results wouldn't be usable. is_clean = False try: - block_sd_candidates = block_sd.node_attractor_candidates( - block_sd.root(), compute=True - ) + if check_maa_exact: + block_sd_candidates = block_sd.node_attractor_seeds( + block_sd.root(), compute=True, symbolic_fallback=True + ) + else: + block_sd_candidates = block_sd.node_attractor_candidates( + block_sd.root(), compute=True + ) is_clean = len(block_sd_candidates) == 0 except RuntimeError: is_clean = False diff --git a/biobalm/_sd_attractors/attractor_candidates.py b/biobalm/_sd_attractors/attractor_candidates.py index 7c48297..f49894c 100644 --- a/biobalm/_sd_attractors/attractor_candidates.py +++ b/biobalm/_sd_attractors/attractor_candidates.py @@ -19,6 +19,7 @@ from biodivine_aeon import Bdd, AsynchronousGraph, BddVariable from biobalm.trappist_core import compute_fixed_point_reduced_STG from biobalm.symbolic_utils import state_list_to_bdd, valuation_to_state, state_to_bdd +from biobalm.space_utils import intersect, is_subspace try: pint_available = True @@ -122,6 +123,37 @@ def compute_attractor_candidates( sd.edge_stable_motif(node_id, s, reduced=True) for s in children ] + if node_data["skipped"]: + # For skip nodes, it does not hold that the successors are the maximal subspaces. + # This means that a skip node can intersect with some other SD node and that intersection + # is not a subset of one of its children. In such case, we can use this intersection + # to further simplify the attractor detection process. + total_skip_nodes_applied = 0 + for n in sd.node_ids(): + n_data = sd.node_data(n) + if is_subspace(node_space, n_data["space"]): + # This means that (in a fully expanded SD), `node` would be a (transitive) + # successor of `n`, which means that a result for `n` is "attractors in n + # that are not in node". Hence, we can't use it to reason about `node`. + # This is not a problem if the intersection of the two nodes is non-triviall, + # because that means they have common successors (and those should be + # solved separately), but the nodes themselves do not depend on each other. + continue + if n_data["attractor_candidates"] == [] or n_data["attractor_seeds"] == []: + # This will create a lot of duplicates, but it seems to be better than + # not doing it at all. + common_subspace = intersect(node_space, n_data["space"]) + if common_subspace is not None: + reduced_subspace: BooleanSpace = { + k: v for k, v in common_subspace.items() if k not in node_space + } + child_motifs_reduced.append(reduced_subspace) + total_skip_nodes_applied += 1 + if sd.config["debug"]: + print( + f"[{node_id}] Extended child motifs with {total_skip_nodes_applied} skip-node intersections." + ) + # Indicates that this space is either minimal, or has no computed successors. # In either case, the space must contain at least one attractor. node_is_pseudo_minimal = len(child_motifs_reduced) == 0 diff --git a/biobalm/_sd_attractors/attractor_symbolic.py b/biobalm/_sd_attractors/attractor_symbolic.py index a8d1e2c..17c634f 100644 --- a/biobalm/_sd_attractors/attractor_symbolic.py +++ b/biobalm/_sd_attractors/attractor_symbolic.py @@ -14,6 +14,7 @@ Reachability, ) from biobalm.symbolic_utils import state_list_to_bdd +from biobalm.space_utils import is_subspace, intersect from biobalm.types import BooleanSpace import biodivine_aeon import copy @@ -48,16 +49,37 @@ def symbolic_attractor_fallback( s_space = sd.node_data(s)["space"] candidates = candidates.minus(sd.symbolic.mk_subspace(s_space)) - fixed_variables = list(node_space.keys()) - free_variables = [ - v for v in sd.network.variable_names() if v not in fixed_variables - ] + if node_data["skipped"]: + # This is the same method that we applied to candidate states computation. + initial_size = candidates.cardinality() + for n in sd.node_ids(): + n_data = sd.node_data(n) + if is_subspace(node_space, n_data["space"]): + continue + if n_data["attractor_candidates"] == [] or n_data["attractor_seeds"] == []: + # This will create a lot of duplicates, but it seems to be better than + # not doing it at all. + common_subspace = intersect(node_space, n_data["space"]) + if common_subspace is not None: + candidates = candidates.minus( + sd.symbolic.mk_subspace(common_subspace) + ) + if sd.config["debug"]: + print( + f"[{node_id}] Simplified symbolic fallback candidates with skip nodes from {initial_size} to {candidates.cardinality()}." + ) + + # These should cover all cycles in the network, so transition-guided reduction + # only needs to consider these variables. + internal_nfvs = sd.node_percolated_nfvs(node_id, compute=True) if sd.config["debug"]: print(f"[{node_id}] > Initial attractor candidates: {candidates}") candidates = Attractors.transition_guided_reduction( - sd.symbolic, candidates, free_variables + sd.symbolic, + candidates, + internal_nfvs, ) if not sd.node_is_minimal(node_id) and not candidates.is_empty(): diff --git a/biobalm/succession_diagram.py b/biobalm/succession_diagram.py index 0ee9505..d74f6e8 100644 --- a/biobalm/succession_diagram.py +++ b/biobalm/succession_diagram.py @@ -35,7 +35,12 @@ network_to_petrinet, restrict_petrinet_to_subspace, ) -from biobalm.space_utils import percolate_network, percolate_space, space_unique_key +from biobalm.space_utils import ( + percolate_network, + percolate_space, + space_unique_key, + is_subspace, +) from biobalm.trappist_core import trappist from biobalm.types import ( BooleanSpace, @@ -1223,6 +1228,7 @@ def expand_block( find_motif_avoidant_attractors: bool = True, size_limit: int | None = None, optimize_source_nodes: bool = True, + exact_attractor_detection: bool = False, ) -> bool: """ Expand the succession diagram using the source block method. @@ -1240,12 +1246,21 @@ def expand_block( attractor search and always produces a smaller succession diagram, but if you need to obtain a succession diagram where this does not happen (e.g. for testing), you can turn this off using `optimize_source_nodes`. + + If `exact_attractor_detection` is selected, the method will use attractor seeds instead + of attractor candidates to check for motif-avoidant attractors. This means that the attractor + detection can take much longer. In particular, it will not expand the SD if it cannot detect + all attractors, meaning it can get "stuck". However, assuming you want to run full attractor + detection anyway, this might save you some time, as you won't need to re-run the failed + candidate detection. (This is only relevant for models where the attractors are very + complex and the candidate state detection can fail with default settings). """ return expand_source_blocks( self, find_motif_avoidant_attractors, size_limit=size_limit, optimize_source_nodes=optimize_source_nodes, + check_maa_exact=exact_attractor_detection, ) def expand_bfs( @@ -1301,7 +1316,10 @@ def expand_dfs( return expand_dfs(self, node_id, dfs_stack_limit, size_limit) def expand_minimal_spaces( - self, node_id: int | None = None, size_limit: int | None = None + self, + node_id: int | None = None, + size_limit: int | None = None, + skip_ignored: bool = False, ) -> bool: """ Expands the succession diagram in a way that guarantees every minimal @@ -1324,8 +1342,16 @@ def expand_minimal_spaces( Returns `True` if the expansion procedure terminated without exceeding the size limit. + + If `skip_ignored` is set, any nodes that are not expanded by this procedure + are "skipped" with edges redirected to the corresponding minimal trap spaces + (see also `SuccessionDiagram.skip_remaining`). This is usually faster than + using `skip_remaining` directly, since the minimal trap spaces are only + computed once. However, note that if used with `size_limit`, nodes that + are not processed when `size_limit` is reached remain unprocessed + (i.e. it is not guaranteed that all nodes are either fully expanded or skipped). """ - return expand_minimal_spaces(self, node_id, size_limit) + return expand_minimal_spaces(self, node_id, size_limit, skip_ignored) def expand_attractor_seeds(self, size_limit: int | None = None) -> bool: """ @@ -1355,6 +1381,100 @@ def expand_to_target( """ return expand_to_target(self, target, size_limit) + def skip_to_minimal(self, node_id: int) -> bool: + """ + Skip the expansion of this node (see also `NodeData.skipped`) and add extra + edges that connect it directly to its minimal trap spaces. Returns `False` + if the node is already expanded. + + Note that this method is relatively inefficient when applied to multiple + nodes repeatedly, as it has to recompute the minimal trap spaces for each node. + To turn multiple nodes into skip nodes, see also `skip_remaining`. + """ + + node = self.node_data(node_id) + + if node["expanded"]: + return False + + pn = self.node_percolated_petri_net(node_id, compute=True) + minimal_traps = trappist(network=pn, problem="min") + minimal_traps = [(node["space"] | x) for x in minimal_traps] + + if len(minimal_traps) == 1 and minimal_traps[0] == node["space"]: + # This node is a minimal trap space + # and thus cannot be skipped. + node["expanded"] = True + return True + + for m_trap in minimal_traps: + m_id = self._ensure_node(node_id, m_trap) + # Also expand the minimal trap space, since we know + # it has no successors. + m_data = self.node_data(m_id) + m_data["expanded"] = True + + node["expanded"] = True + node["skipped"] = True + + if self.config["debug"]: + print(f"[{node_id}] Added {len(minimal_traps)} skip edges.") + + return True + + def skip_remaining(self) -> int: + """ + Apply `skip_to_minimal` to every node that is not expanded. + + This is faster than calling the method individually if the number of + nodes is high since we can cache the minimal trap spaces. + + Returns the number of created skip nodes. + """ + + pn = self.node_percolated_network(self.root(), compute=True) + root_space = self.node_data(self.root())["space"] + minimal_traps = trappist(network=pn, problem="min") + minimal_traps = [root_space | x for x in minimal_traps] + + if self.config["debug"]: + print(f"Skipping remaining nodes. Found {len(minimal_traps)} trap spaces.") + + trap_with_id: list[tuple[int, BooleanSpace]] = [] + for m_trap in minimal_traps: + m_id = self._ensure_node(None, m_trap) + m_data = self.node_data(m_id) + trap_with_id.append((m_id, m_trap)) + # We can expand the minimal trap spaces since + # they don't have successors. + m_data["expanded"] = True + + skipped_nodes = 0 + for node_id in self.node_ids(): + node = self.node_data(node_id) + + if node["expanded"]: + continue + + skip_edges = 0 + for m_id, m_trap in trap_with_id: + if is_subspace(m_trap, node["space"]): + self._ensure_edge(node_id, m_id, m_trap) + skip_edges += 1 + + node["skipped"] = True + node["expanded"] = True + skipped_nodes += 1 + + # At this point, all minimal traps must be expanded, + # hence we should never skip one. + assert not self.node_is_minimal(node_id) + + if self.config["debug"]: + print(f"Skipped {skipped_nodes} nodes.") + + return skipped_nodes + def _update_node_depth(self, node_id: int, parent_id: int): """ An internal method that updates the depth of a node based on a specific @@ -1464,10 +1584,7 @@ def _expand_one_node(self, node_id: int): print(f"[{node_id}] Found sub-spaces: {len(sub_spaces)}") for sub_space in sub_spaces: - child_id = self._ensure_node(node_id, sub_space) - - if self.config["debug"]: - print(f"[{node_id}] Created edge into node {child_id}.") + self._ensure_node(node_id, sub_space) # If everything else worked out, we can mark the node as expanded. node["expanded"] = True @@ -1506,6 +1623,7 @@ def _ensure_node(self, parent_id: int | None, stable_motif: BooleanSpace) -> int attractor_seeds=None, attractor_sets=None, parent_node=parent_id, + skipped=None, ) self.node_indices[key] = child_id else: @@ -1522,5 +1640,6 @@ def _ensure_edge(self, parent_id: int, child_id: int, stable_motif: BooleanSpace # TODO: It seems that there are some networks where the same child # can be reached through multiple stable motifs. Not sure how to # approach these... but this is probably good enough for now. - self.dag.add_edge(parent_id, child_id, motif=stable_motif) # type: ignore + if not self.dag.has_edge(parent_id, child_id): # type: ignore + self.dag.add_edge(parent_id, child_id, motif=stable_motif) # type: ignore self._update_node_depth(child_id, parent_id) diff --git a/biobalm/types.py b/biobalm/types.py index a610302..c941b7e 100644 --- a/biobalm/types.py +++ b/biobalm/types.py @@ -167,6 +167,26 @@ class NodeData(TypedDict): expanding its predecessors). """ + skipped: bool | None + """ + If `True`, indicates that the successors of this node are not the normal + maximal trap spaces, but rather some smaller trap spaces deeper in the + succession diagram. The only requirement is that all minimal trap spaces + that are reachable from this "skip node" are still reachable through + this modified set of successors (in the most basic form, the successors + can be the minimal trap spaces themselves). + + For skip nodes, attractor detection still works, but can over-count + motif-avoidant attractors assuming the attractor intersects multiple + skip nodes. In other words, each attractor is still found, but it is + not necessarily true that its smallest trap space is discovered. + Nevertheless, if the network has no motif-avoidant attractors, the + result is always the same regardless of how many skip nodes are used. + + A skip node is considered "expanded", since its outgoing edges are + computed. + """ + class SuccessionDiagramConfiguration(TypedDict): """ diff --git a/pyproject.toml b/pyproject.toml index e89d704..edde318 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.11" dependencies = [ - 'biodivine_aeon ==1.0.0a10', + 'biodivine_aeon >=1.0.1', 'clingo >=5.6.2', 'networkx >=2.8.8', ] diff --git a/requirements.txt b/requirements.txt index b104275..8a59fc5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -biodivine_aeon==1.0.0a10 +biodivine_aeon>=1.0.0 clingo==5.6.2 networkx==2.8.8 pypint[pint]==1.6.2 \ No newline at end of file diff --git a/tests/succession_diagram_test.py b/tests/succession_diagram_test.py index 565456a..6d372d4 100644 --- a/tests/succession_diagram_test.py +++ b/tests/succession_diagram_test.py @@ -2,6 +2,7 @@ from biodivine_aeon import AsynchronousGraph, Attractors, BooleanNetwork +import copy import biobalm import biobalm.succession_diagram from biobalm.succession_diagram import SuccessionDiagram @@ -254,6 +255,26 @@ def test_attractor_detection(network_file: str): if not fully_expanded: return + # Build partial succession diagrams. + sd_min_partial = SuccessionDiagram(bn) + sd_min_partial.expand_minimal_spaces(size_limit=10, skip_ignored=True) + sd_min_partial.skip_remaining() + sd_block_partial = SuccessionDiagram(bn) + sd_block_partial.expand_block(size_limit=10) + sd_block_partial.skip_remaining() + + # Check that the minimal trap spaces match + for node in sd_min_partial.node_ids(): + sd_id = sd.find_node(sd_min_partial.node_data(node)["space"]) + assert sd_id is not None + assert sd.node_is_minimal(sd_id) == sd_min_partial.node_is_minimal(node) + for node in sd_block_partial.node_ids(): + sd_id = sd.find_node(sd_block_partial.node_data(node)["space"]) + assert sd_id is not None + assert sd.node_is_minimal(sd_id) == sd_block_partial.node_is_minimal(node) + assert len(sd.minimal_trap_spaces()) == len(sd_min_partial.minimal_trap_spaces()) + assert len(sd.minimal_trap_spaces()) == len(sd_block_partial.minimal_trap_spaces()) + # Compute attractors in diagram nodes. # TODO: There will probably be a method that does this in one "go". nfvs_attractors: list[BooleanSpace] = [] @@ -265,12 +286,25 @@ def test_attractor_detection(network_file: str): if len(attr) > 0: nfvs_attractors += attr + min_partial_attractors: list[BooleanSpace] = [] + for i in sd_min_partial.node_ids(): + attr = sd_min_partial.node_attractor_seeds(i, compute=True) + if len(attr) > 0: + min_partial_attractors += attr + + block_partial_attractors: list[BooleanSpace] = [] + for i in sd_block_partial.node_ids(): + attr = sd_block_partial.node_attractor_seeds(i, compute=True) + if len(attr) > 0: + block_partial_attractors += attr + # Compute symbolic attractors using AEON. - symbolic_attractors = Attractors.attractors(stg, stg.mk_unit_colored_vertices()) + symbolic_attractors_all = Attractors.attractors(stg, stg.mk_unit_colored_vertices()) # Check that every "seed" returned by SuccessionDiagram appears in # some symbolic attractor, and that every symbolic attractor contains # at most one such "seed" state. + symbolic_attractors = copy.copy(symbolic_attractors_all) for seed in nfvs_attractors: symbolic_seed = stg.mk_subspace(seed) found = None @@ -284,11 +318,37 @@ def test_attractor_detection(network_file: str): symbolic_attractors.pop(found) - print("Attractors:", len(nfvs_attractors)) - # All symbolic attractors must be covered by some seed at this point. assert len(symbolic_attractors) == 0 + # Copy of the test above, but for the results from partially expanded diagrams. + + symbolic_attractors = copy.copy(symbolic_attractors_all) + for seed in min_partial_attractors: + symbolic_seed = stg.mk_subspace(seed) + found = None + + for i in range(len(symbolic_attractors)): + if symbolic_seed.is_subset(symbolic_attractors[i]): + found = i + assert found is not None + + symbolic_attractors.pop(found) + assert len(symbolic_attractors) == 0 + + symbolic_attractors = copy.copy(symbolic_attractors_all) + for seed in block_partial_attractors: + symbolic_seed = stg.mk_subspace(seed) + found = None + + for i in range(len(symbolic_attractors)): + if symbolic_seed.is_subset(symbolic_attractors[i]): + found = i + assert found is not None + + symbolic_attractors.pop(found) + assert len(symbolic_attractors) == 0 + def test_attractor_expansion(network_file: str): # This test is similar to the "test attractor detection" function above, but