Source code for networkx.algorithms.community.infomap

"""Detecting communities based on the Infomap algorithm.

Infomap identifies communities as regions in which a random walk tends to
remain; the literature calls them modules. It searches for a partition that
compresses the walk: each module has its own codebook, so node codewords can be
reused between modules, while transitions between modules require additional
codewords. The map equation measures the resulting codelength in bits per step.

:func:`infomap_communities` searches for a flat, two-level partition.
:func:`infomap_partitions` searches for a multilevel hierarchy.
:func:`~networkx.algorithms.community.quality.map_equation` scores a flat
partition under the same flow model.

This module accepts first-order ``Graph``, ``DiGraph``, ``MultiGraph``, and
``MultiDiGraph`` inputs. Specialized multilayer, higher-order or state,
metadata-aware, and bipartite flow models are not exposed. A NetworkX bipartite
graph is treated as an ordinary first-order graph.

Rosvall & Bergstrom (2008) introduce the method. Rosvall, Axelsson & Bergstrom
(2009) derive the map equation in full. Rosvall & Bergstrom (2011) extend it to
a multilevel hierarchy.

References
----------
Rosvall, M. & Bergstrom, C.T. Maps of random walks on complex networks reveal
community structure. PNAS 105, 1118-1123 (2008).
https://doi.org/10.1073/pnas.0706851105

Rosvall, M., Axelsson, D. & Bergstrom, C.T. The map equation.
Eur. Phys. J. Special Topics 178, 13-23 (2009).
https://doi.org/10.1140/epjst/e2010-01179-1

Rosvall, M. & Bergstrom, C.T. Multilevel compression of random walks on networks
reveals hierarchical organization in large integrated systems.
PLoS ONE 6(4), e18209 (2011). https://doi.org/10.1371/journal.pone.0018209
"""

from collections import Counter, defaultdict
from typing import NamedTuple

import networkx as nx
from networkx.algorithms.community.quality import (
    _check_teleportation_probability,
    _codelength,
    _flow,
    _plogp,
)
from networkx.utils import groups, py_random_state

__all__ = ["infomap_communities", "infomap_partitions"]


# ---------------------------------------------------------------------------
# Terminology
#
# The code below follows the map equation literature, which calls a community a
# *module*. The word is kept because this file mirrors the reference C++
# implementation and the `infomap` package, whose results are phrased in modules
# too. Everywhere a user looks -- the public functions here, and the rest of
# networkx -- the same thing is called a community.
#
# Two more words recur below. An *aggregated network* (Louvain's term) is the
# network in which each module of the previous level has been contracted to a
# single node; those nodes are *super-nodes*, and a module of super-nodes is a
# *super-module*.
#
# The optimizer
#
# `quality.map_equation` says how good a partition is; the optimizer below
# searches for the partition that minimizes it. The search mirrors Louvain and
# adds Infomap's refinements, in four nested layers:
#
#   1. core loop      -- repeatedly move each node into the neighboring module
#                        that most lowers the codelength (`_CoreOptimizer`),
#   2. aggregation    -- build an aggregated network in which each module of the
#                        previous level is one node, then run the core loop on
#                        that, so the new super-modules are collections of the
#                        previous level's modules (`_find_top_modules`),
#   3. tuning         -- alternate fine-tuning (re-move single nodes) and
#                        coarse-tuning (re-split modules) to escape the local
#                        optima the greedy moves get stuck in (`_partition`),
#   4. hierarchy      -- aggregate once more above the top level and recurse into
#                        the modules below it, so the result is a tree of modules
#                        rather than one flat level (`_build_hierarchy`).
#
# `infomap_communities` runs this from `num_trials` random starts and keeps the
# best.
#
# The move heuristics and loop structure follow the Infomap algorithm of Rosvall
# and Bergstrom, so the optimizer minimizes the same map equation. The search is
# greedy and stochastic: a given seed fixes one path to a local optimum, and
# different seeds explore different ones.

# Tuning constants for the search: how many sweeps each loop runs before giving
# up, and how much a move (or a whole pass) must lower the codelength to count.
_CORE_LOOP_LIMIT = 10
_AGGREGATION_CORE_LOOP_LIMIT = 20
_MIN_IMPROVEMENT = 1e-10
_MIN_RELATIVE_TUNE_IMPROVEMENT = 1e-5
_MIN_SINGLE_NODE_IMPROVEMENT = 1e-16
_NUM_RANDOM_MOVES = 5
_MAX_DEGREE_FOR_RANDOM_MOVES = 2  # only low-degree nodes get random targets


class _MoveContext(NamedTuple):
    """The destination-independent part of a node's move delta: computed once
    per node by :meth:`_CoreOptimizer._prepare_move` and reused for every
    candidate module in :meth:`_CoreOptimizer._delta_codelength`."""

    node_enter: float
    node_exit: float
    node_flow: float
    old_cross: float  # flow the node shares with its current module
    enter_flow: float  # total inter-module (index) flow, Q
    enter_flow_log: float  # _plogp(enter_flow), cached to skip it per candidate
    old_delta: float  # change in the current module's codelength as the node leaves


class _CoreOptimizer:
    """Greedy single-node optimizer for the core loop.

    Maintains per-module flow data (flow, enter, exit) and the global codelength
    terms so each single-node move is evaluated and applied with an O(degree)
    delta, rather than recomputing the whole codelength each time. Self-link
    flow never crosses a module boundary, so it is excluded from enter/exit
    (kept in node flow).
    """

    def __init__(self, flow, links, module_of, seed, directed):
        """Set up the state needed for O(degree) moves on the partition
        `module_of`: per-node flow, per-module flow data, and the running
        codelength terms."""
        self.flow = flow
        self.seed = seed
        # Directedness comes from the graph, not from flow symmetry: a symmetric
        # directed graph (reciprocal equal-weight links) has symmetric flow yet
        # must still use the directed leftover-move rule below to match Infomap.
        self.directed = directed

        # Per node: its out-/in-link flows and their totals (its own exit/enter
        # rate). Self-links never cross a boundary, so drop them here.
        self.out_links = {u: {} for u in flow}
        self.in_links = {u: {} for u in flow}
        for u, v, f in links:
            if u == v:
                continue
            out_links_u = self.out_links[u]
            if v in out_links_u:  # a parallel link, from a multigraph
                out_links_u[v] += f
            else:
                out_links_u[v] = f

            in_links_v = self.in_links[v]
            if u in in_links_v:
                in_links_v[u] += f
            else:
                in_links_v[u] = f
        self.node_enter = {u: sum(self.in_links[u].values()) for u in flow}
        self.node_exit = {u: sum(self.out_links[u].values()) for u in flow}
        self.node_log = sum(_plogp(flow[u]) for u in flow)  # constant node term

        # Per module: total flow, boundary-crossing enter/exit flow, member count.
        self.module_of = dict(module_of)
        self.module_flow = defaultdict(float)
        self.module_enter = defaultdict(float)
        self.module_exit = defaultdict(float)
        self.module_members = Counter(self.module_of.values())
        for u in flow:
            m = self.module_of[u]
            self.module_flow[m] += flow[u]
            for v, f in self.out_links[u].items():
                if self.module_of[v] != m:
                    self.module_exit[m] += f
            for v, f in self.in_links[u].items():
                if self.module_of[v] != m:
                    self.module_enter[m] += f

        # The codelength's running terms (see `codelength`), kept in sync by
        # `_apply` so the whole codelength is never recomputed from scratch.
        self.enter_flow = sum(self.module_enter.values())
        self.enter_log = sum(_plogp(v) for v in self.module_enter.values())
        self.exit_log = sum(_plogp(v) for v in self.module_exit.values())
        self.flow_log = sum(
            _plogp(self.module_exit[m] + self.module_flow[m]) for m in self.module_flow
        )

        # Each module's own codelength contribution: its usage term (``usage =
        # exit flow + visit rate``) minus its enter and exit terms. `codelength`
        # sums it over the modules and adds the index and node terms. Cached
        # because a move re-reads the destination's current value once per
        # candidate but only changes it once per applied move. An unseen (empty)
        # module is 0.
        self.module_codelength = defaultdict(float)
        for m in self.module_flow:
            enter = self.module_enter[m]
            exit = self.module_exit[m]
            usage = exit + self.module_flow[m]
            self.module_codelength[m] = _plogp(usage) - _plogp(enter) - _plogp(exit)

        # A pool of emptied module ids to reuse for "split into a new module";
        # every id in `module_of` starts with a member, so the pool starts empty.
        self._next_module = (max(self.module_of.values()) + 1) if self.module_of else 0
        self.empty_modules = []

    def codelength(self):
        """The current two-level codelength, the same quantity as
        :func:`~networkx.algorithms.community.quality.map_equation` but read off
        the running terms. ``index = plogp(Q) - enter_log``;
        ``module = -exit_log + flow_log - node_log``. The whole-network exit
        flow is 0, so it drops out of the index term."""
        return (
            _plogp(self.enter_flow)
            - self.enter_log
            - self.exit_log
            + self.flow_log
            - self.node_log
        )

    def _shared_flows(self, node):
        """``module -> [exit, enter]``: flow between `node` and each module it
        links to (out-links add to exit, in-links to enter).

        Called once per node per sweep, so it avoids a default factory and, on
        an undirected network, walks the links once: there ``in_links`` repeats
        ``out_links``, so both halves of an entry take the same value.
        """
        shared_flow = {}
        module_of = self.module_of
        if self.directed:
            for v, f in self.out_links[node].items():
                module = module_of[v]
                if module in shared_flow:
                    shared_flow[module][0] += f
                else:
                    shared_flow[module] = [f, 0.0]
            for v, f in self.in_links[node].items():
                module = module_of[v]
                if module in shared_flow:
                    shared_flow[module][1] += f
                else:
                    shared_flow[module] = [0.0, f]
            return shared_flow
        # undirected case
        for v, f in self.out_links[node].items():
            module = module_of[v]
            if module in shared_flow:
                entry = shared_flow[module]
                entry[0] += f
                entry[1] += f
            else:
                shared_flow[module] = [f, f]
        return shared_flow

    def _prepare_move(self, node, old, old_cross):
        """The part of a move's codelength delta that is fixed once `node` leaves
        its current module `old`. It does not depend on the destination, so
        compute it once here and reuse it for every candidate (see
        :meth:`_delta_codelength`). `old_cross` is the flow `node` shares with
        `old` (``exit + enter``).
        """
        node_enter = self.node_enter[node]
        node_exit = self.node_exit[node]
        node_flow = self.flow[node]
        # `old`'s codelength before and after `node` leaves it: it sheds the
        # node's enter/exit/flow, and the links they shared become crossing.
        old_before = self.module_codelength[old]
        old_exit = self.module_exit[old]
        enter = self.module_enter[old] - node_enter + old_cross
        exit = old_exit - node_exit + old_cross
        # The terms of `usage` keep this order on purpose; see _delta_codelength.
        usage = old_exit + self.module_flow[old] - node_exit - node_flow + old_cross
        old_after = _plogp(usage) - _plogp(enter) - _plogp(exit)
        # `self.enter_flow` and the module terms are stable during the scan (no
        # move is applied until a winner is chosen), so capturing them is safe.
        return _MoveContext(
            node_enter,
            node_exit,
            node_flow,
            old_cross,
            self.enter_flow,
            _plogp(self.enter_flow),
            old_after - old_before,
        )

    def _delta_codelength(self, ctx, new, new_cross):
        """Change in total codelength if the node moves into module `new`.

        Moving a node touches only two modules' codebooks plus the shared index
        codebook, so the delta is the sum of three independent changes: the
        index term, `old`'s change (fixed for this node, precomputed in `ctx` by
        :meth:`_prepare_move`), and `new`'s change computed here. `new_cross` is
        the flow the node shares with `new` (``exit + enter``); joining `new`
        makes those links internal and brings the node's own flow with it.
        """
        # `new`'s codelength before and after the node joins it.
        new_before = self.module_codelength[new]
        new_exit = self.module_exit[new]
        enter = self.module_enter[new] + ctx.node_enter - new_cross
        exit = new_exit + ctx.node_exit - new_cross
        # `usage` equals `exit + self.module_flow[new] + ctx.node_flow`, but not
        # bit for bit: floating-point addition is not associative, and the last
        # bit can decide a near-tie between two moves (see
        # _MIN_SINGLE_NODE_IMPROVEMENT). Building it from `exit` sends some
        # searches to a different local optimum, and no better one on average,
        # so the terms keep this order.
        usage = (
            new_exit + self.module_flow[new] + ctx.node_exit + ctx.node_flow - new_cross
        )
        new_after = _plogp(usage) - _plogp(enter) - _plogp(exit)
        # Index codebook: only the total switching rate Q shifts. The node's
        # shared flow with `old` was internal and becomes crossing (+old_cross);
        # with `new` it was crossing and becomes internal (-new_cross); its flow
        # to all *other* modules stays crossing and cancels.
        q = ctx.enter_flow + ctx.old_cross - new_cross
        delta_index = _plogp(q) - ctx.enter_flow_log
        return delta_index + ctx.old_delta + (new_after - new_before)

    def _apply(self, node, new, shared_flow):
        """Move `node` into module `new`, updating the module flow data and the
        running codelength terms in place."""
        module_flow = self.module_flow
        module_enter = self.module_enter
        module_exit = self.module_exit
        node_flow = self.flow[node]
        node_enter = self.node_enter[node]
        node_exit = self.node_exit[node]
        old = self.module_of[node]
        old_cross = sum(shared_flow[old]) if old in shared_flow else 0.0
        new_cross = sum(shared_flow[new]) if new in shared_flow else 0.0

        # 1. Remove the two modules' current contributions to the global terms.
        old_enter = module_enter[old]
        old_exit = module_exit[old]
        old_usage = old_exit + module_flow[old]
        new_enter = module_enter[new]
        new_exit = module_exit[new]
        new_usage = new_exit + module_flow[new]
        self.enter_flow -= old_enter + new_enter
        self.enter_log -= _plogp(old_enter) + _plogp(new_enter)
        self.exit_log -= _plogp(old_exit) + _plogp(new_exit)
        self.flow_log -= _plogp(old_usage) + _plogp(new_usage)

        # 2. Carry the node's own flow from `old` to `new`.
        module_flow[old] -= node_flow
        module_enter[old] -= node_enter
        module_exit[old] -= node_exit
        module_flow[new] += node_flow
        module_enter[new] += node_enter
        module_exit[new] += node_exit

        # 3. Fix the boundary: links to `old` now cross it, links to `new` are
        #    now internal to it.
        module_enter[old] += old_cross
        module_exit[old] += old_cross
        module_enter[new] -= new_cross
        module_exit[new] -= new_cross

        # 4. Add the two modules' new contributions back, and cache each
        #    module's codelength from the same terms.
        old_enter = module_enter[old]
        old_exit = module_exit[old]
        old_usage = old_exit + module_flow[old]
        new_enter = module_enter[new]
        new_exit = module_exit[new]
        new_usage = new_exit + module_flow[new]
        old_enter_log = _plogp(old_enter)
        old_exit_log = _plogp(old_exit)
        old_usage_log = _plogp(old_usage)
        new_enter_log = _plogp(new_enter)
        new_exit_log = _plogp(new_exit)
        new_usage_log = _plogp(new_usage)
        self.enter_flow += old_enter + new_enter
        self.enter_log += old_enter_log + new_enter_log
        self.exit_log += old_exit_log + new_exit_log
        self.flow_log += old_usage_log + new_usage_log
        self.module_codelength[old] = old_usage_log - old_enter_log - old_exit_log
        self.module_codelength[new] = new_usage_log - new_enter_log - new_exit_log

        self.module_members[old] -= 1
        self.module_members[new] += 1
        self.module_of[node] = new

    def optimize(self, first_loop, loop_limit):
        """Sweep the nodes (in random order) up to `loop_limit` times, moving
        each to the module that lowers the codelength most, until a sweep stops
        improving the codelength. Returns the resulting codelength. With
        `first_loop` (the initial all-singletons partition), nodes may join
        modules but never leave one that has grown past a single member, so
        every sweep of this run builds modules up instead of reshuffling."""
        nodes = list(self.flow)
        old_codelength = self.codelength()
        for _ in range(loop_limit):
            num_moved = 0
            self.seed.shuffle(nodes)
            for node in nodes:
                current = self.module_of[node]
                if first_loop and self.module_members[current] > 1:
                    continue

                # Candidate modules: the node's neighbors; for low-degree nodes, a
                # few random modules as well; and an empty module to split into. The
                # random and empty targets give the greedy search a way out of
                # shallow optima.
                shared_flow = self._shared_flows(node)
                candidates = set(shared_flow)
                if (
                    len(self.out_links[node]) + len(self.in_links[node])
                    <= _MAX_DEGREE_FOR_RANDOM_MOVES
                ):
                    candidates.update(
                        self.module_of[n]
                        for n in self.seed.choices(nodes, k=_NUM_RANDOM_MOVES)
                    )
                candidates.discard(current)
                empty_module = None
                if self.module_members[current] > 1:
                    empty_module = (
                        self.empty_modules[-1]
                        if self.empty_modules
                        else self._next_module
                    )
                    candidates.add(empty_module)
                if not candidates:
                    continue

                # Each `shared_flow` entry is [exit, enter]; its sum is the flow
                # that crosses the boundary between the node and that module.
                if current in shared_flow:
                    old_entry = shared_flow[current]
                    old_cross = old_entry[0] + old_entry[1]
                    strongest_exit = old_entry[0]
                else:
                    old_cross = 0.0
                    strongest_exit = 0.0
                ctx = self._prepare_move(node, current, old_cross)
                best_module, best_delta = current, 0.0
                # Track which candidate the node sends the most flow to; ties in
                # codelength are broken toward it (see below). Seed it with the
                # current module so a move wins the tie only by being more cohesive.
                strongest_module = current
                strongest_delta = 0.0
                for module in candidates:
                    if module in shared_flow:
                        entry = shared_flow[module]
                        exit_to = entry[0]
                        new_cross = entry[0] + entry[1]
                    else:
                        exit_to = 0.0
                        new_cross = 0.0
                    delta = self._delta_codelength(ctx, module, new_cross)
                    if delta < best_delta - _MIN_SINGLE_NODE_IMPROVEMENT:
                        best_delta, best_module = delta, module
                    if exit_to > strongest_exit:
                        strongest_exit = exit_to
                        strongest_module = module
                        strongest_delta = delta
                # Tie-break: when a move barely changes the codelength (within
                # _MIN_SINGLE_NODE_IMPROVEMENT of the best, even slightly worse),
                # prefer the module the node sends the most exit flow to. It is
                # free now and usually compresses better on later sweeps.
                if (
                    strongest_module != best_module
                    and strongest_delta <= best_delta + _MIN_SINGLE_NODE_IMPROVEMENT
                ):
                    best_module = strongest_module
                if best_module == current:
                    continue

                if best_module == empty_module:
                    if self.empty_modules:
                        self.empty_modules.pop()
                    else:
                        self._next_module += 1
                # Neighbors of `node` still in `current`, taken before the move
                # since `_apply` rewrites `module_of`. Used below to rescue a node
                # the move would otherwise leave stranded.
                linked_in_old = [
                    neighbor
                    for neighbor in set(self.out_links[node]) | set(self.in_links[node])
                    if self.module_of[neighbor] == current
                ]
                self._apply(node, best_module, shared_flow)
                num_moved += 1
                if self.module_members[current] == 0:
                    self.empty_modules.append(current)
                elif self.module_members[current] == 1 and len(linked_in_old) == 1:
                    leftover = linked_in_old[0]
                    # A leftover linked to the mover by a single link is about to
                    # sit alone with no internal flow, costing index codebook for
                    # nothing, so move it along too. A directed reciprocal pair (a
                    # link each way) keeps flow between the two, so leave it.
                    num_links = (
                        (leftover in self.out_links[node])
                        + (leftover in self.in_links[node])
                        if self.directed
                        else 1
                    )
                    if num_links == 1:
                        self._apply(leftover, best_module, self._shared_flows(leftover))
                        num_moved += 1
                        if self.module_members[current] == 0:
                            self.empty_modules.append(current)
            # Stop once a whole sweep no longer lowers the codelength enough.
            # Besides saving sweeps, this bounds the neutral lateral moves the
            # strongest-connected tie-break can otherwise keep making.
            new_codelength = self.codelength()
            if num_moved == 0 or new_codelength >= old_codelength - _MIN_IMPROVEMENT:
                break
            old_codelength = new_codelength
        return self.codelength()


def _core_loop(
    flow, links, module_of, seed, directed, first_loop=False, loop_limit=None
):
    """Run the stateful core optimizer on `module_of` (mutated in place)."""
    optimizer = _CoreOptimizer(flow, links, module_of, seed, directed)
    optimizer.optimize(
        first_loop, _CORE_LOOP_LIMIT if loop_limit is None else loop_limit
    )
    module_of.clear()
    module_of.update(optimizer.module_of)


def _collapse(flow, links, module_of):
    """Aggregate modules into super-nodes, preserving the map equation.

    Returns ``(super_flow, super_links, relabel)`` where ``relabel`` maps each
    original module id to its super-node id (``0..k-1``). A super-node's flow is
    the total flow of its module; super-links carry the summed inter-module
    flow. Intra-module flow never crosses a boundary, so self-loops are dropped.
    """
    relabel = {m: i for i, m in enumerate(sorted(set(module_of.values())))}
    super_flow = defaultdict(float)
    for node, f in flow.items():
        super_flow[relabel[module_of[node]]] += f
    super_links = defaultdict(float)
    for u, v, f in links:
        a, b = relabel[module_of[u]], relabel[module_of[v]]
        if a != b:
            super_links[a, b] += f
    links_list = [(a, b, f) for (a, b), f in super_links.items()]
    return dict(super_flow), links_list, relabel


def _split_by_module(flow, links, module_of):
    """Split the flow network into the sub-network induced by each module: per
    module, the visit rates of its nodes and the links with both endpoints
    inside it. One pass over `flow` and `links` serves every module; filtering
    them once per module instead costs a pass per module.

    Nodes keep their order in `flow`, the graph's iteration order. The order of
    a set of nodes depends on their hashes, which Python randomizes for strings,
    so ordering a sub-network by one would make a seeded search vary from run
    to run."""
    module_flows = defaultdict(dict)
    for node, f in flow.items():
        module_flows[module_of[node]][node] = f
    module_links = defaultdict(list)
    for link in links:
        module = module_of[link[0]]
        if module == module_of[link[1]]:
            module_links[module].append(link)
    return module_flows, module_links


def _find_top_modules(flow, links, module_of, seed, directed, first_loop=False):
    """Aggregation phase: starting from the current partition, repeatedly
    collapse modules into a super-network and run the core loop on it, accepting
    each level only while it lowers the codelength.
    Returns a (possibly coarser) base-level ``node -> module`` mapping.

    `first_loop` marks the very first partition of the full network, where the
    core loop forbids pulling a node out of a multi-member module; it only
    applies to the finest (level-0) aggregation pass.
    """
    module_of = dict(module_of)
    codelength = _codelength(flow, links, module_of)
    level = 0
    while len(set(module_of.values())) > 1:
        super_flow, super_links, relabel = _collapse(flow, links, module_of)
        super_module = {node: i for i, node in enumerate(super_flow)}
        _core_loop(
            super_flow,
            super_links,
            super_module,
            seed,
            directed,
            first_loop=(first_loop and level == 0),
            loop_limit=_CORE_LOOP_LIMIT if level == 0 else _AGGREGATION_CORE_LOOP_LIMIT,
        )
        if len(set(super_module.values())) == len(super_flow):
            break  # core loop merged nothing -> converged
        candidate = {node: super_module[relabel[module_of[node]]] for node in flow}
        new_codelength = _codelength(flow, links, candidate)
        if new_codelength > codelength - _MIN_IMPROVEMENT:
            break
        module_of, codelength = candidate, new_codelength
        level += 1
    return module_of


def _coarse_tune(flow, links, module_of, seed, directed):
    """Coarse-tune: split each module into sub-modules by
    optimizing the induced sub-network, then let those sub-modules regroup
    starting from the current modular structure. Returns a ``node -> module``
    mapping (the caller decides whether it improved)."""
    module_flows, module_links = _split_by_module(flow, links, module_of)

    submodule_of = {}
    offset = 0
    for module, sub_flow in module_flows.items():
        if len(sub_flow) < 2:
            for node in sub_flow:
                submodule_of[node] = offset
            offset += 1
            continue
        singletons = {node: i for i, node in enumerate(sub_flow)}
        sub_partition = _find_top_modules(
            sub_flow, module_links[module], singletons, seed, directed
        )
        relabel = {
            s: offset + i for i, s in enumerate(sorted(set(sub_partition.values())))
        }
        for node in sub_flow:
            submodule_of[node] = relabel[sub_partition[node]]
        offset += len(relabel)

    # Aggregate the sub-modules, seed each in its original module, and let them
    # regroup with one core-loop run; expanding the result is the tuned partition.
    super_flow, super_links, relabel = _collapse(flow, links, submodule_of)
    super_module = {relabel[submodule_of[node]]: module_of[node] for node in flow}
    _core_loop(super_flow, super_links, super_module, seed, directed)
    return {node: super_module[relabel[submodule_of[node]]] for node in flow}


def _fine_tune(flow, links, module_of, seed, directed):
    """Fine-tune: re-run the core loop on individual nodes,
    starting from the current modular structure. Returns a ``node -> module``
    mapping."""
    module_of = dict(module_of)
    _core_loop(flow, links, module_of, seed, directed)
    return module_of


def _partition(flow, links, seed, directed, first_loop=True):
    """The two-level partition: an initial aggregation followed by an
    alternating fine-tune / coarse-tune loop, re-aggregating after each tune,
    until neither improves the codelength (and coarse-tune has been tried).

    `first_loop` forbids pulling a node out of a multi-member module on the very
    first sweep; it holds only for the main network. Recursive partitions of
    super- and sub-networks (the index level and refinement) pass False, matching
    the C++ Infomap, which applies this guard only in the top-level search of
    the full network."""
    one_level_codelength = _codelength(flow, links, dict.fromkeys(flow, 0))

    module_of = _find_top_modules(
        flow,
        links,
        {n: i for i, n in enumerate(flow)},
        seed,
        directed,
        first_loop=first_loop,
    )
    old_codelength = codelength = _codelength(flow, links, module_of)

    do_fine_tune = True
    coarse_tuned = False
    while len(set(module_of.values())) > 1:
        if do_fine_tune:
            tuned = _fine_tune(flow, links, module_of, seed, directed)
        else:
            coarse_tuned = True
            tuned = _coarse_tune(flow, links, module_of, seed, directed)
        if _codelength(flow, links, tuned) < codelength:
            module_of = _find_top_modules(flow, links, tuned, seed, directed)
            codelength = _codelength(flow, links, module_of)

        is_improvement = (
            codelength <= old_codelength - _MIN_IMPROVEMENT
            and codelength
            < old_codelength - one_level_codelength * _MIN_RELATIVE_TUNE_IMPROVEMENT
        )
        if not is_improvement:
            if coarse_tuned:
                break
        else:
            old_codelength = codelength
        do_fine_tune = not do_fine_tune

    # Never do worse than putting everything in one module.
    if codelength > one_level_codelength:
        return dict.fromkeys(flow, 0)
    return module_of


def _hierarchical_codelength(flow, links, path):
    """Multilevel map equation codelength of a hierarchical partition.

    ``path[node]`` is the tuple of module ids from the top level down to the
    node's innermost module. This is the recursive generalization of the
    two-level codelength: every module runs its own index codebook over its
    children -- entering a child costs its enter rate, exiting the module
    costs its exit rate -- and the two-level map equation is the special case
    of one root codebook over leaf-filled modules. Each module pays
    ``plogp(exit + sum child_enter) - plogp(exit) - sum plogp(child_enter)``,
    where a leaf child contributes its node visit rate.
    """
    # Each module in the tree is identified by a path prefix; record the tree
    # structure (a module's child sub-modules and its directly-held leaf nodes).
    prefixes = set()
    for node_path in path.values():
        for depth in range(1, len(node_path) + 1):
            prefixes.add(node_path[:depth])
    children = defaultdict(set)
    for module in prefixes:
        if len(module) > 1:
            children[module[:-1]].add(module)
    # Leaf nodes in `path` order rather than in a set, whose order follows the
    # node hashes, so the sums below do not change from run to run.
    leaf_nodes = defaultdict(list)
    for node, node_path in path.items():
        leaf_nodes[node_path].append(node)

    # A link u->v crosses the boundary of every module that contains exactly one
    # of its endpoints: those strictly below the level where the two paths first
    # differ. It exits each such module on u's side and enters each on v's side.
    enter_flow = defaultdict(float)
    exit_flow = defaultdict(float)
    for u, v, f in links:
        path_u, path_v = path[u], path[v]
        common = 0
        for module_u, module_v in zip(path_u, path_v):
            if module_u != module_v:
                break
            common += 1
        for depth in range(common + 1, len(path_u) + 1):
            exit_flow[path_u[:depth]] += f
        for depth in range(common + 1, len(path_v) + 1):
            enter_flow[path_v[:depth]] += f

    # Root index codebook: encodes which top module the walk enters.
    top_modules = [module for module in prefixes if len(module) == 1]
    top_enter = [enter_flow[module] for module in top_modules]
    codelength = _plogp(sum(top_enter)) - sum(_plogp(rate) for rate in top_enter)
    # Every module pays its own codebook: it codes entering each child (a
    # sub-module's enter flow, or a leaf node's visit rate) and exiting itself.
    for module in prefixes:
        child_enter = []
        if module in children:
            child_enter += [enter_flow[child] for child in children[module]]
        if module in leaf_nodes:
            child_enter += [flow[node] for node in leaf_nodes[module]]
        exit = exit_flow[module]
        codelength += (
            _plogp(exit + sum(child_enter))
            - _plogp(exit)
            - sum(_plogp(rate) for rate in child_enter)
        )
    return codelength


def _find_super_modules(flow, links, module_of, seed, directed):
    """Coarsen the current top-level modules into super-modules. The index-level
    super-network uses each module's *enter flow* as its node flow, not its
    visit rate: in the hierarchy a module is coded each time the walker
    *enters* it (see :func:`_hierarchical_codelength`), so enter flows are the
    visit rates of the index level. Returns ``module_id -> super_module_id`` or
    None if trivial."""
    modules = sorted(set(module_of.values()))
    if len(modules) <= 2:
        return None
    relabel = {m: i for i, m in enumerate(modules)}
    enter = defaultdict(float)
    super_links = defaultdict(float)
    for u, v, f in links:
        module_u, module_v = module_of[u], module_of[v]
        if module_u != module_v:
            enter[relabel[module_v]] += f
            super_links[relabel[module_u], relabel[module_v]] += f
    super_flow = {i: enter[i] for i in range(len(modules))}
    super_links = [(a, b, f) for (a, b), f in super_links.items()]
    super_partition = _partition(
        super_flow, super_links, seed, directed, first_loop=False
    )
    num_super = len(set(super_partition.values()))
    if num_super == 1 or num_super == len(modules):
        return None
    return {m: super_partition[relabel[m]] for m in modules}


def _refine_hierarchy(flow, links, path, seed, directed):
    """Recursively split each innermost module into sub-modules, appending a
    deeper level whenever it lowers the hierarchical codelength."""
    module_flows, module_links = _split_by_module(flow, links, path)
    changed = False
    new_path = dict(path)
    codelength = _hierarchical_codelength(flow, links, new_path)
    for p, sub_flow in module_flows.items():
        if len(sub_flow) <= 2:
            continue
        sub = _partition(sub_flow, module_links[p], seed, directed, first_loop=False)
        k = len(set(sub.values()))
        if k == 1 or k == len(sub_flow):
            continue
        candidate = dict(new_path)
        for n in sub_flow:
            candidate[n] = p + (sub[n],)
        candidate_codelength = _hierarchical_codelength(flow, links, candidate)
        if candidate_codelength < codelength - _MIN_IMPROVEMENT:
            new_path, codelength = candidate, candidate_codelength
            changed = True
    if changed:
        new_path = _refine_hierarchy(flow, links, new_path, seed, directed)
    return new_path


def _build_hierarchy(flow, links, seed, directed):
    """Build a multilevel partition: two-level modules, coarsened by super-
    levels above and refined by sub-levels below, accepting each change only
    when it lowers the hierarchical codelength. Returns ``node -> path tuple``
    (top level first)."""
    top = _partition(flow, links, seed, directed)
    path = {node: (top[node],) for node in flow}
    codelength = _hierarchical_codelength(flow, links, path)
    while True:  # coarsen: prepend super-levels
        coarsest = {node: path[node][0] for node in flow}
        super_of = _find_super_modules(flow, links, coarsest, seed, directed)
        if super_of is None:
            break
        candidate = {node: (super_of[coarsest[node]],) + path[node] for node in flow}
        candidate_codelength = _hierarchical_codelength(flow, links, candidate)
        if candidate_codelength < codelength - _MIN_IMPROVEMENT:
            path, codelength = candidate, candidate_codelength
        else:
            break
    # refine: append sub-levels
    return _refine_hierarchy(flow, links, path, seed, directed)


def _check_num_trials(num_trials):
    """Validate the ``num_trials`` argument shared by the public functions."""
    if not isinstance(num_trials, int) or num_trials < 1:
        raise ValueError("num_trials must be a positive integer")


[docs] @py_random_state("seed") @nx._dispatchable(edge_attrs="weight") def infomap_communities( G, *, weight="weight", seed=None, num_trials=1, teleportation_probability=0.15 ): r"""Find communities in `G` with the two-level Infomap heuristic. Infomap searches for a partition that compresses the flow of a random walk on `G`. The flow consists of the walk's node-visit and link-transition rates. The map equation literature calls a community a module. For a two-level partition :math:`\mathsf{M}`, the objective is the map equation [1]_ [2]_: .. math:: L(\mathsf{M}) = q_\curvearrowright H(\mathcal{Q}) + \sum_i p^i_\circlearrowright H(\mathcal{P}^i) The first term codes transitions between modules. The second codes node visits and module exits inside each module. Lower codelength means that the partition compresses the flow better. See :func:`~networkx.algorithms.community.quality.map_equation` for the full definition. This function runs `num_trials` randomized searches and returns the lowest-codelength partition found. :func:`infomap_partitions` optimizes the multilevel map equation separately [3]_, so this result is not guaranteed to match any level of its hierarchy. Modularity, which :any:`louvain_communities` and :any:`leiden_communities` optimize, compares link weights against a null model rather than following flow, and can select a different partition on the same graph. The search moves nodes between neighboring modules, aggregates modules, and applies Infomap's fine- and coarse-tuning passes. Directed graphs use the unrecorded teleportation-to-links flow model described by :func:`~networkx.algorithms.community.quality.map_equation` and require SciPy. Parameters ---------- G : Graph, DiGraph, MultiGraph, or MultiDiGraph An undirected or directed first-order graph. Parallel edges are summed. weight : string or None, optional (default="weight") The name of an edge attribute holding the numerical weight. Higher weights represent stronger flow and make an edge more likely to be traversed; they should not represent distances or costs. Weights must be finite and non-negative. If None, or if an edge does not have the attribute, that edge has weight 1. seed : integer, random_state, or None (default) Indicator of random number generation state. See :ref:`Randomness<randomness>`. num_trials : int, optional (default=1) Number of randomized searches. The partition with the lowest codelength is returned. Runtime is approximately proportional to `num_trials`. Must be a positive integer. teleportation_probability : float, optional (default=0.15) Teleportation probability for the directed-flow random walk, as in :func:`~networkx.algorithms.community.quality.map_equation`. Ignored for undirected graphs. Values must lie in the interval [0, 1]. Returns ------- list of sets of nodes The lowest-codelength two-level partition found across `num_trials` searches. The sets are disjoint and together contain every node of `G`. Their order is arbitrary. Raises ------ ValueError If `num_trials` is not a positive integer, if any edge weight is negative or not finite, if the weights are too large to sum as floats, or if a directed graph's `teleportation_probability` is outside the interval [0, 1]. PowerIterationFailedConvergence If PageRank fails to converge when computing directed flow. Examples -------- >>> G = nx.barbell_graph(5, 0) >>> communities = nx.community.infomap_communities(G, seed=42, num_trials=10) >>> sorted(map(sorted, communities)) [[0, 1, 2, 3, 4], [5, 6, 7, 8, 9]] Notes ----- The search is stochastic. With unchanged graph iteration order, a fixed `seed` reproduces its random choices. `num_trials` increases the search coverage without guaranteeing a global optimum. Self-loops contribute to node visit rates and within-module flow, but never to boundary-crossing flow. They can therefore affect codelengths and the returned partition. A one-community result is valid. It means that none of the partitions found compresses the flow better than the one-level baseline under the selected flow model. References ---------- .. [1] Rosvall, M. & Bergstrom, C.T. Maps of random walks on complex networks reveal community structure. PNAS 105, 1118-1123 (2008). https://doi.org/10.1073/pnas.0706851105 .. [2] Rosvall, M., Axelsson, D. & Bergstrom, C.T. The map equation. Eur. Phys. J. Special Topics 178, 13-23 (2009). https://doi.org/10.1140/epjst/e2010-01179-1 .. [3] Rosvall, M. & Bergstrom, C.T. Multilevel compression of random walks on networks reveals hierarchical organization in large integrated systems. PLoS ONE 6(4), e18209 (2011). https://doi.org/10.1371/journal.pone.0018209 See Also -------- infomap_partitions :func:`~networkx.algorithms.community.quality.map_equation` :any:`louvain_communities` :any:`leiden_communities` """ _check_num_trials(num_trials) _check_teleportation_probability(G, teleportation_probability) if nx.is_empty(G): # no edges, so no flow to compress: every node is alone return [{u} for u in G] # The flow depends only on the graph, so compute it once and reuse it across # every restart and codelength evaluation. flow, link_flows = _flow(G, weight, teleportation_probability) module_of = _best_two_level(flow, link_flows, seed, num_trials, G.is_directed()) return list(groups(module_of).values())
def _best_two_level(flow, links, seed, num_trials, directed): """Return the lowest-codelength two-level partition over `num_trials` restarts, as a ``node -> module`` mapping.""" return min( (_partition(flow, links, seed, directed) for _ in range(num_trials)), key=lambda module_of: _codelength(flow, links, module_of), ) def _best_hierarchy(flow, links, seed, num_trials, directed): """Return the lowest-codelength multilevel partition over `num_trials` restarts, as a ``node -> path`` mapping (top module first).""" return min( (_build_hierarchy(flow, links, seed, directed) for _ in range(num_trials)), key=lambda path: _hierarchical_codelength(flow, links, path), )
[docs] @py_random_state("seed") @nx._dispatchable(edge_attrs="weight") def infomap_partitions( G, *, weight="weight", seed=None, num_trials=1, teleportation_probability=0.15 ): r"""Return an iterator over the nested partitions of a multilevel Infomap hierarchy. Infomap searches for a multilevel partition with the multilevel map equation [1]_, which gives every internal module its own index codebook rather than keeping a single one at the top. The hierarchy is computed when this function is called. The returned iterator exposes one flat partition per level, coarsest first. The first partition contains the top-level modules. Each later partition refines the previous one, and the last contains the leaf modules. A branch without deeper submodules remains unchanged at later levels. This hierarchy is optimized separately from the two-level partition returned by :func:`infomap_communities`. Unlike :any:`louvain_partitions` and :any:`leiden_partitions`, which run from finer partitions to coarser ones, this function follows the Infomap hierarchy from its top modules down to its leaf modules. Directed graphs use the unrecorded teleportation-to-links flow model described by :func:`~networkx.algorithms.community.quality.map_equation` and require SciPy. Parameters ---------- G : Graph, DiGraph, MultiGraph, or MultiDiGraph An undirected or directed first-order graph. Parallel edges are summed. weight : string or None, optional (default="weight") The name of an edge attribute holding the numerical weight. Higher weights represent stronger flow and make an edge more likely to be traversed; they should not represent distances or costs. Weights must be finite and non-negative. If None, or if an edge does not have the attribute, that edge has weight 1. seed : integer, random_state, or None (default) Indicator of random number generation state. See :ref:`Randomness<randomness>`. num_trials : int, optional (default=1) Number of randomized searches. The hierarchy with the lowest codelength is returned. Runtime is approximately proportional to `num_trials`. Must be a positive integer. teleportation_probability : float, optional (default=0.15) Teleportation probability for the directed-flow random walk, as in :func:`~networkx.algorithms.community.quality.map_equation`. Ignored for undirected graphs. Values must lie in the interval [0, 1]. Returns ------- iterator of list of sets of nodes An iterator over the partitions of `G`, coarsest first. The sets in each partition are disjoint, cover every node, and have arbitrary order. Raises ------ ValueError If `num_trials` is not a positive integer, if any edge weight is negative or not finite, if the weights are too large to sum as floats, or if a directed graph's `teleportation_probability` is outside the interval [0, 1]. PowerIterationFailedConvergence If PageRank fails to converge when computing directed flow. Examples -------- >>> G = nx.ring_of_cliques(12, 6) >>> levels = list(nx.community.infomap_partitions(G, seed=0, num_trials=10)) >>> [len(level) for level in levels] [3, 12] Notes ----- With unchanged graph iteration order, a fixed `seed` reproduces the stochastic search. Increasing `num_trials` explores more candidate hierarchies but does not guarantee a global optimum. Self-loops contribute to node visit rates and within-module flow, but never to boundary-crossing flow. They can therefore affect codelengths and the returned hierarchy. A single level of one community is valid. It means that none of the hierarchies found compresses the flow better than the one-level baseline under the selected flow model. :func:`~networkx.algorithms.community.quality.map_equation` scores a flat partition under the two-level objective. It does not score the complete hierarchy returned here. References ---------- .. [1] Rosvall, M. & Bergstrom, C.T. Multilevel compression of random walks on networks reveals hierarchical organization in large integrated systems. PLoS ONE 6(4), e18209 (2011). https://doi.org/10.1371/journal.pone.0018209 See Also -------- infomap_communities :func:`~networkx.algorithms.community.quality.map_equation` :any:`louvain_partitions` """ # Validate the arguments eagerly so a bad value raises on call, not # mid-iteration. The hierarchy search runs here too; only splitting the # result into per-level partitions is deferred to `_expand_levels`. _check_num_trials(num_trials) _check_teleportation_probability(G, teleportation_probability) if nx.is_empty(G): # one level of singletons, as louvain_partitions gives return iter([[{u} for u in G]]) flow, link_flows = _flow(G, weight, teleportation_probability) path = _best_hierarchy(flow, link_flows, seed, num_trials, G.is_directed()) return _expand_levels(path)
def _expand_levels(path): """Yield the partition at each hierarchy level of a ``node -> path`` mapping, coarsest level first; each is a list of node sets covering all of `G`.""" depth = max(len(p) for p in path.values()) for level in range(1, depth + 1): yield list(groups({n: q[:level] for n, q in path.items()}).values())