diff --git a/biobalm/_sd_algorithms/expand_minimal_spaces.py b/biobalm/_sd_algorithms/expand_minimal_spaces.py index 0df78b7..c61e0c2 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,9 @@ 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) + # 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 +40,27 @@ 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 + 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 +79,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 +95,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 +110,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..ed70dd8 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 holds 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..93e7d69 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) 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..966bb95 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_remaining: bool = False, ) -> bool: """ Expands the succession diagram in a way that guarantees every minimal @@ -1324,8 +1342,14 @@ def expand_minimal_spaces( Returns `True` if the expansion procedure terminated without exceeding the size limit. + + If `skip_remaining` 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. """ - return expand_minimal_spaces(self, node_id, size_limit) + return expand_minimal_spaces(self, node_id, size_limit, skip_remaining) def expand_attractor_seeds(self, size_limit: int | None = None) -> bool: """ @@ -1355,6 +1379,97 @@ 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", ensure_subspace=node["space"] + ) + + 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) + minimal_traps = trappist(network=pn, problem="min") + + 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 + + if self.config["debug"]: + print( + f"[{node_id}] Added {skipped_nodes} skip edges into minimal trap spaces." + ) + + 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 +1579,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 +1618,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 +1635,8 @@ 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 + if self.config["debug"]: + print(f"[{parent_id}] Created edge into node {child_id}.") self._update_node_depth(child_id, parent_id) diff --git a/biobalm/types.py b/biobalm/types.py index a610302..58b2d26 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): """