Source code for boolforge.wiring_diagram.core

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from collections.abc import Sequence
import numpy as np

from .interoperability import WiringDiagramInteroperabilityMixin
from .modularity import WiringDiagramModularityMixin
from .motifs import WiringDiagramMotifMixin
from .plotting import WiringDiagramPlottingMixin

[docs] class WiringDiagram( WiringDiagramInteroperabilityMixin, WiringDiagramModularityMixin, WiringDiagramMotifMixin, WiringDiagramPlottingMixin, ): """ Directed wiring diagram for a dynamical system. A wiring diagram specifies the regulatory topology of a dynamical system by listing, for each node, the indices of its regulators (incoming edges). It does not encode dynamical update functions or dynamical rules. Nodes with zero indegree are source nodes. Whether a source node represents a constant, an identity node, or a dynamic variable is determined only after dynamical update functions are assigned. Parameters ---------- I : sequence of sequences of int Adjacency-list representation of the wiring diagram. Entry ``I[i]`` contains the indices of nodes regulating node ``i``. Regulator lists may have variable length. variables : list[str] or np.ndarray[str], optional Names of the variables corresponding to each node. Must have length ``N`` if provided. If None, default names ``['x0', 'x1', ..., 'x{N-1}']`` are assigned. weights : sequence of sequences of float, optional Interaction weights corresponding to ``I``. Entry ``weights[i][j]`` gives the weight of the interaction from regulator ``I[i][j]`` to node ``i``. Missing interactions may be encoded as ``np.nan``. Attributes ---------- I : list[np.ndarray] Regulator index arrays, one per node. variables : np.ndarray[str] Names of variables corresponding to each node. N : int Total number of nodes, including source nodes. indegrees : np.ndarray[int] Indegree of each node. outdegrees : np.ndarray[int] Outdegree of each node. weights : list[np.ndarray] or None Interaction weights associated with ``I`` if provided. Notes ----- - Node indices are zero-based. - Source nodes are identified solely by indegree == 0. - The wiring diagram encodes topology only and does not define dynamics. Examples -------- Nodes with zero indegree are interpreted as source nodes. >>> from boolforge import WiringDiagram >>> I = [ [], # node 0 has no regulators -> source node [0], # node 1 is regulated by node 0 [0, 1], # node 2 is regulated by nodes 0 and 1 ] >>> W = WiringDiagram(I) >>> W.N 3 >>> W.get_source_nodes(as_dict=True) {0: True, 1: False, 2: False} >>> W.get_source_nodes(as_dict=False) array([0]) See Also -------- boolforge.BooleanNetwork """ def __init__( self, I : Sequence[Sequence[int]], variables : list[str] | np.ndarray | None = None, weights : Sequence[Sequence[float]] | None = None, ): """ Initialize a wiring diagram. Parameters ---------- I : sequence of sequences of int Adjacency list representation of the wiring diagram. Entry ``I[i]`` contains the indices of nodes regulating node ``i``. Regulator lists may have variable length. variables : list[str] or np.ndarray[str], optional Names of the variables corresponding to each node. Must have length ``N`` if provided. If None, default names ``['x0', 'x1', ..., 'x{N-1}']`` are assigned. weights : sequence of sequences of float, optional Optional interaction weights corresponding to ``I``. Each entry ``weights[i][j]`` gives the weight of the interaction from regulator ``I[i][j]`` to node ``i``. Missing or undefined interactions may be encoded as ``np.nan``. Raises ------ TypeError If ``I`` is not a sequence of sequences of integers. If ``weights`` is provided and is not a sequence of sequences of numbers. If any entry ``weights[i]`` is not a sequence of numbers. ValueError If ``variables`` is provided and does not have length ``N``. If ``weights`` is provided and does not have length ``N``. If any entry ``weights[i]`` does not have the same length as ``I[i]``. Notes ----- Source nodes are identified automatically as nodes with zero indegree. """ if not isinstance(I, Sequence) or isinstance(I, (str, bytes)): raise TypeError("I must be a sequence of sequences of int") if variables is not None and len(I) != len(variables): raise ValueError("len(I) == len(variables) required if variable names are provided") # ---- Properties (empty initially) ----------------------------------- self._properties_exact = {} self._properties_estimated = {} self.I = [np.array(regulators,dtype=int) for regulators in I] self.N = len(I) self.indegrees = np.array([len(regulators) for regulators in self.I], dtype=int) if variables is None: variables = ['x'+str(i) for i in range(self.N)] self.variables = np.array(variables, dtype=str) self.outdegrees = self.get_outdegrees() if weights is not None: if not isinstance(weights, Sequence) or isinstance(weights, (str, bytes)): raise TypeError("weights must be None or a sequence of sequences of numbers") if len(weights) != self.N: raise ValueError("weights must have the same length as I") self.weights = [] for i, (regs, row) in enumerate(zip(self.I, weights)): if not isinstance(row, Sequence): raise TypeError(f"weights[{i}] must be a sequence of floats") if len(row) != len(regs): raise ValueError(f"weights[{i}] must have the same length as I[{i}]") self.weights.append(np.array(row, dtype=float)) else: self.weights = None def _make_property_key(self, name, context=None): if context is None: return name context = str(context).lower() return (name, context) def _set_property(self, name, value, context=None, exact=True): key = self._make_property_key(name, context) if exact: self._properties_exact[key] = value self._properties_estimated.pop(key, None) elif key not in self._properties_exact: self._properties_estimated[key] = value def _get_property(self, name, context=None): key = self._make_property_key(name, context) if key in self._properties_exact: return self._properties_exact[key], "exact" if key in self._properties_estimated: return self._properties_estimated[key], "estimated" # detect missing context argument if context is None: for dictionary in (self._properties_exact, self._properties_estimated): for k in dictionary: if isinstance(k, tuple) and k[0] == name: raise ValueError( f"Property '{name}' depends on the context. Specify context." ) return None, None
[docs] def __str__(self): return ( f"WiringDiagram(N={self.N}, " f"indegrees={self.indegrees.tolist()})" )
def __getitem__(self, index): return self.I[index]
[docs] def __repr__(self): return f"{type(self).__name__}(N={self.N})"
def __len__(self): return self.N
[docs] def get_outdegrees(self) -> np.ndarray: """ Compute the outdegree of each node. The outdegree of a node is the number of nodes it regulates, i.e., the number of outgoing edges from that node in the wiring diagram. Returns ------- np.ndarray One-dimensional array of length ``N`` containing the outdegree of each node. """ outdegrees = np.zeros(self.N, dtype=int) for regulators in self.I: for regulator in regulators: outdegrees[regulator] += 1 return outdegrees
[docs] def get_source_nodes( self, as_dict: bool = True ) -> dict[int, bool] | np.ndarray: """ Identify source nodes in the wiring diagram. A source node is a node with zero indegree. Source nodes represent inputs to the wiring diagram; whether they act as constants or identity nodes in dynamical systems depends on the associated dynamical update functions and is not determined at the wiring-diagram level. Parameters ---------- as_dict : bool, optional If True (default), return a dictionary mapping node indices to Boolean values indicating whether each node is a source node. If False, return an array of indices corresponding to source nodes. Returns ------- dict[int, bool] or np.ndarray If ``as_dict`` is True, a dictionary where keys are node indices and values indicate whether the node is a source node. If ``as_dict`` is False, a one-dimensional array containing the indices of source nodes. """ is_source = self.indegrees == 0 if as_dict: return dict(enumerate(is_source.tolist())) return np.where(is_source)[0]