Reading the Result

How-to

At a glance

infomap.run returns an immutable Result: summary statistics as properties, per-node assignments through modules() and nodes(), and tabular output through to_dataframe().

What the Result holds

A Result holds more than labels. Each node carries a flow value, the share of the random walk that visits it, and the hierarchical tree records nested modules that a flat assignment vector cannot.

This chapter covers the summary statistics, the assignment dict, the per-node view with flow, and the DataFrame export. It also explains why a single run may not be enough.

Three layers of detail

Think of the Result as three concentric layers of detail.

The outermost layer is a handful of scalars: codelength, number of top modules, number of levels. They answer “did it find something?” without touching individual nodes, and are cheap to inspect, log, and compare across runs.

The middle layer is the partition itself, a mapping from node id to module id. result.modules() returns it as a plain dict, {node_id: module_id}. Anything that expects a node-colouring can consume it directly.

The inner layer is per-node flow. Each node’s flow records the stationary probability of a random walk visiting it under the best partition. High-flow nodes are influential hubs; low-flow nodes are peripheral. This layer lets you rank nodes within a module or build flow-weighted summaries.

result.to_dataframe() materialises all three layers into one tabular object you can filter, group, merge, and export.

Why more than one trial

Infomap minimises the map equation \(L(\mathsf{M})\) over many independent optimisation trials. Each trial starts from a different random partition and runs a greedy hill-climbing search. The search is stochastic and the solution landscape is degenerate (many partitions reach near-identical codelengths with different node assignments), so running several trials lowers the risk of returning a local minimum [Calatayud et al., 2019].

Infomap keeps only the partition with the lowest codelength across all trials. That partition is what the Result reports through codelength, modules(), and nodes(). The implication is practical: a single trial is fine for exploration, but results you intend to publish or act on warrant more. See Running Infomap for the full num_trials rule of thumb and a visualisation of the effect; the rest of this chapter is about reading and comparing the partition you get back.

The solution landscape and degeneracy

Even for networks with clear community structure, Infomap produces a cloud of near-optimal partitions with similar codelengths but meaningfully different node assignments [Calatayud et al., 2019]. The variance of that cloud grows as community structure weakens (higher mixing parameter \(\mu\) in LFR benchmarks). For networks with \(\mu \le 0.2\), 50 trials are usually enough for the solution landscape to be “complete”, so further trials rarely reveal a different partition. Noisier networks can need 200 to 1000 trials. If you need to characterise the degeneracy rather than just find the best partition, consider the dedicated solution-landscape tooling.

Reading a karate-club result

The examples below use the Zachary karate club graph, a 34-node network with well-known community structure.

Run Infomap

import networkx as nx
import infomap
import pandas as pd

g = nx.karate_club_graph()

result = infomap.run(g, two_level=True, seed=123, num_trials=10, silent=True)

The Result object

A Result is an immutable snapshot of one run. run() captures the scalar metrics when it returns. Node-level collections are materialised lazily on first access and then cached. The surface follows one convention:

  • Scalars are properties: result.codelength, result.num_top_modules.

  • Collections are methods, with defaults: result.modules(depth=1), result.nodes(), result.to_dataframe().

A Result from an earlier run of a reused stateful Infomap raises if you read its node data after a later run(). The eagerly captured scalar properties stay readable (the lazily computed effective_num_* properties must have been read at least once before the re-run).

Summary statistics

The most useful summary properties are available immediately:

print(f"Codelength:          {result.codelength:.4f} bits/step")
print(f"One-level baseline:  {result.one_level_codelength:.4f} bits/step")
print(f"Compression gain:    {result.relative_codelength_savings * 100:.1f}%")
print(f"Top-level modules:   {result.num_top_modules}")
print(f"Hierarchy levels:    {result.num_levels}")
Codelength:          4.0874 bits/step
One-level baseline:  4.6340 bits/step
Compression gain:    11.8%
Top-level modules:   3
Hierarchy levels:    2

result.codelength is the map equation value \(L(\mathsf{M}^*)\) for the best partition found. result.one_level_codelength is the cost with all nodes in a single module, the natural baseline for judging how much structure Infomap found. result.relative_codelength_savings gives the fractional gap between the two; a larger number means stronger, more compressible community structure.

result.num_top_modules is the number of top-level modules. result.num_levels is the depth of the hierarchical tree; a value of 2 means one level of modules above the leaves, the standard two-level result.

Getting assignments: modules()

modules = result.modules()   # {node_id: top-level module_id}

print(f"Type:  {type(modules)}")
print(f"First 8 entries: {dict(list(modules.items())[:8])}")
print(f"Unique modules: {sorted(set(modules.values()))}")
Type:  <class 'dict'>
First 8 entries: {0: 1, 1: 1, 2: 1, 3: 1, 7: 1, 11: 1, 12: 1, 13: 1}
Unique modules: [1, 2, 3]

Module ids are positive integers with no guaranteed ordering, but the numbering is stable across calls for the same run.

For hierarchical results with more than two levels, pass depth=k to slice the tree at depth \(k\). Level 1 gives top modules; level 2 gives sub-modules, and so on down to the finest module level, result.num_levels - 1 (the leaf nodes themselves occupy the last level).

# Three clusters, each with three dense sub-communities.
g_hier = nx.Graph()
for cluster in range(3):
    for sub in range(3):
        base = cluster * 30 + sub * 10
        for i in range(base, base + 10):
            for j in range(i + 1, base + 10):
                g_hier.add_edge(i, j)
        g_hier.add_edge(base, cluster * 30 + ((sub + 1) % 3) * 10)
    g_hier.add_edge(cluster * 30, ((cluster + 1) % 3) * 30)

result_hier = infomap.run(g_hier, seed=123, num_trials=10, silent=True)

print(f"Hierarchy levels: {result_hier.num_levels}")
top_mods = result_hier.modules(depth=1)
sub_mods = result_hier.modules(depth=2)
print(f"Top-level modules (depth 1): {sorted(set(top_mods.values()))}")
print(f"Sub-modules      (depth 2): {sorted(set(sub_mods.values()))}")
Hierarchy levels: 3
Top-level modules (depth 1): [1, 2, 3]
Sub-modules      (depth 2): [1, 2, 3, 4, 5, 6, 7, 8, 9]

Iterating over nodes

When you need flow alongside module membership, iterate over result.nodes(). Each node is an immutable view; the attributes you reach for most are node_id, module_id, and flow, alongside path, name, and layer_id. See TreeNode for the full set (including state_id, state_name, depth, child_index, and modular_centrality):

print(f"{'node_id':>8}  {'module_id':>10}  {'flow':>10}")
print("-" * 35)
for node in result.nodes():
    print(f"{node.node_id:>8}  {node.module_id:>10}  {node.flow:>10.6f}")
 node_id   module_id        flow
-----------------------------------
       0           1    0.090909
       1           1    0.062771
       2           1    0.071429
       3           1    0.038961
       7           1    0.028139
      11           1    0.006494
      12           1    0.008658
      13           1    0.036797
      17           1    0.006494
      19           1    0.010823
      21           1    0.008658
       4           2    0.017316
       5           2    0.030303
       6           2    0.028139
      10           2    0.017316
      16           2    0.012987
       8           3    0.036797
       9           3    0.006494
      14           3    0.010823
      15           3    0.015152
      18           3    0.006494
      20           3    0.008658
      22           3    0.010823
      23           3    0.045455
      24           3    0.015152
      25           3    0.030303
      26           3    0.012987
      27           3    0.028139
      28           3    0.012987
      29           3    0.028139
      30           3    0.023810
      31           3    0.045455
      32           3    0.082251
      33           3    0.103896

flow is the stationary visit probability described above; see Flow and random walks for how it is computed.

Converting to a pandas DataFrame

result.to_dataframe() is the most convenient entry point for downstream analysis. Pass the columns you want; valid names include node_id, module_id, flow, path, and name.

df = result.to_dataframe(columns=["node_id", "module_id", "flow", "path"])
print(f"Shape: {df.shape}")
df.head(8)
Shape: (34, 4)
node_id module_id flow path
0 0 1 0.090909 (1, 1)
1 1 1 0.062771 (1, 2)
2 2 1 0.071429 (1, 3)
3 3 1 0.038961 (1, 4)
4 7 1 0.028139 (1, 5)
5 11 1 0.006494 (1, 6)
6 12 1 0.008658 (1, 7)
7 13 1 0.036797 (1, 8)

The path column encodes position in the hierarchical tree as a tuple. For a two-level result each path is (top_module_id, position_in_module). For deeper hierarchies the tuple grows accordingly, and its first element is the top-level module.

With the DataFrame in hand, per-module summaries are a single groupby:

module_summary = (
    df.groupby("module_id", as_index=False)
    .agg(
        nodes=("node_id", "count"),
        total_flow=("flow", "sum"),
    )
    .sort_values("total_flow", ascending=False)
    .reset_index(drop=True)
)
module_summary
module_id nodes total_flow
0 3 18 0.523810
1 1 11 0.370130
2 2 5 0.106061

total_flow is the fraction of the random walk that flows through each module. Modules with high total flow are the dominant structural features; modules with low flow are peripheral communities that the walker rarely visits.

Visualising the partition

draw_partition is a small helper that ships with these docs (in _ext/docs_viz.py), not part of the infomap package; see Visualising and exporting for the full treatment of plotting and export.

import matplotlib.pyplot as plt
from myst_nb import glue

from docs_viz import draw_partition

flow = dict(zip(df["node_id"], df["flow"]))
fig = draw_partition(g, modules, flow=flow)
glue("fig-results-and-iteration", fig, display=False)
plt.close(fig)
../_images/55db52aa2f2260261ec9c1220d6e589afad0503e69554828dfaf6c4f629207ac.png

Fig. 7 The example network coloured by the modules read off the result. The same assignment feeds the DataFrame and the per-node iteration above; marker area scales with each node’s flow.

The layout respects the network’s edge structure: tightly connected nodes appear close together, and the colour boundaries show where the random walk crosses between modules.

Comparing trials and inspecting degeneracy

Infomap runs num_trials independent searches and keeps only the best partition. You can inspect how consistent the result is by running several single-trial searches from different seeds and comparing their codelengths:

codelengths = [
    infomap.run(g, two_level=True, seed=seed, num_trials=1, silent=True).codelength
    for seed in range(1, 11)
]

print("Codelengths across 10 single-trial runs:")
print(f"  min:    {min(codelengths):.6f}")
print(f"  max:    {max(codelengths):.6f}")
print(f"  range:  {max(codelengths) - min(codelengths):.6f}")
print(f"Best (10-trial run): {result.codelength:.6f}")
Codelengths across 10 single-trial runs:
  min:    4.087423
  max:    4.087423
  range:  0.000000
Best (10-trial run): 4.087423

A tight spread means most trials recover the same strong community structure. A wide spread, or a best-of-10 value below the single-trial minimum, signals a degenerate solution landscape where more trials are warranted. The karate club’s community structure is pronounced enough that every trial finds the same codelength; large noisy networks spread wider.

Pitfalls

  • A Result goes stale if its network runs again. Reading nodes() or modules() on a Result after a later run() on the same network raises; finish reading a result before you re-run. The eagerly captured scalar properties stay readable.

  • Module ids carry no order or meaning. They are stable within one run but arbitrary across runs; compare partitions with a metric, not id equality.

  • modules() returns the top level by default. Pass depth=-1 for the finest level of a multilevel result.

API pointers

Going deeper