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)
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
Resultgoes stale if its network runs again. Readingnodes()ormodules()on aResultafter a laterrun()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. Passdepth=-1for the finest level of a multilevel result.
API pointers¶
infomap.Result.codelengthis \(L(\mathsf{M}^*)\), the map equation value for the best partition.infomap.Result.one_level_codelengthis the baseline cost with all nodes in a single module.infomap.Result.relative_codelength_savingsis the fractional gain \((L_\text{one} - L^*) / L_\text{one}\).infomap.Result.num_top_modulescounts the top-level modules.infomap.Result.num_levelsis the depth of the hierarchical tree.infomap.Result.modules()returns a{node_id: module_id}dict and acceptsdepth=kfor hierarchy slicing.infomap.Result.nodes()yields per-node views withnode_id,module_id, andflow.infomap.Result.to_dataframe()exports a table with columnsnode_id,module_id,flow,path,name.
Going deeper¶
API reference is the complete property and method reference for
infomap.Result.converge=Truetreatsnum_trialsas a cap and stops once the best codelength plateaus; pair it with a high cap on networks whose degeneracy you have not yet characterised. The solution-landscape tooling follows the solution-landscape clustering approach [Calatayud et al., 2019].The survey (§4) covers the multilevel map equation and hierarchical community detection in full [Smiljanić et al., 2026].