Source code for timesmith.network.motifs

"""Network motif analysis for time series networks."""

from itertools import combinations
from typing import Dict

import numpy as np

# Optional networkx
try:
    import networkx as nx

    HAS_NETWORKX = True
except ImportError:
    HAS_NETWORKX = False
    nx = None


[docs] def directed_3node_motifs(G) -> Dict: """Count connected directed 3-node motifs using adjacency pattern codes. Args: G: Directed NetworkX graph. Returns: Dictionary mapping motif codes to counts and frequencies. """ if not HAS_NETWORKX: raise ImportError( "NetworkX is required for directed_3node_motifs. " "Install with: pip install networkx or pip install timesmith[network]" ) if not G.is_directed(): raise ValueError("Graph must be directed.") motifs = {} nodes = list(G.nodes()) for trio in combinations(nodes, 3): sub = G.subgraph(trio).copy() # Relabel to 0,1,2 for canonical form mapping = dict(zip(sub.nodes(), range(3))) sub = nx.relabel_nodes(sub, mapping) # Encode adjacency as bitstring of length 6 (excluding self-loops) - vectorized # Create adjacency matrix for 3 nodes adj = np.zeros((3, 3), dtype=int) for u, v in sub.edges(): adj[u, v] = 1 # Extract upper triangular (excluding diagonal) and flatten edges = adj[np.triu_indices(3, k=1)] code = "".join(map(str, edges)) motifs[code] = motifs.get(code, 0) + 1 total = sum(motifs.values()) return { k: {"count": v, "freq": v / total if total > 0 else 0.0} for k, v in motifs.items() }
[docs] def undirected_4node_motifs(G) -> Dict: if not HAS_NETWORKX: raise ImportError( "NetworkX is required for undirected_4node_motifs. " "Install with: pip install networkx or pip install timesmith[network]" ) """Count simple undirected 4-node motifs by isomorphism type. Args: G: Undirected NetworkX graph. Returns: Dictionary mapping motif types to counts and frequencies. """ if G.is_directed(): raise ValueError("Graph must be undirected.") motifs = { "4-clique": 0, "square": 0, "triangle-tail": 0, "4-chain": 0, "4-star": 0, "other": 0, } for quad in combinations(G.nodes(), 4): sub = G.subgraph(quad) m = sub.number_of_edges() if m == 6: motifs["4-clique"] += 1 elif m == 4 and nx.cycle_graph(4).edges() <= set(map(frozenset, sub.edges())): motifs["square"] += 1 elif m == 4 and any( len(list(sub.subgraph(c).edges())) == 3 for c in combinations(sub.nodes(), 3) ): motifs["triangle-tail"] += 1 elif m == 3 and nx.is_connected(sub): motifs["4-chain"] += 1 elif m == 3 and not nx.is_connected(sub): motifs["4-star"] += 1 else: motifs["other"] += 1 total = sum(motifs.values()) return { k: {"count": v, "freq": v / total if total > 0 else 0.0} for k, v in motifs.items() }