Escaping Absorbing States in Markov Route Generation

When a Markov route generator produces walks that all pile up in the same handful of nodes or terminate the instant they arrive somewhere, the cause is an unintended absorbing state — a node the chain can enter but never leave — hiding in the transition matrix.

Part of Markov Chain Routing Models: that stage samples routes from a state-transition matrix over a road or grid network, and this page isolates the single structural defect that most often ruins a run — states or whole regions of the state space that trap the walk, either as literal self-loops or as disconnected components with no outgoing edge to the rest of the graph.

Root Cause: Reachability Defects in the Transition Matrix

A Markov route generator draws the next node from the current node’s row of a row-stochastic transition matrix P\mathbf{P}, where PijP_{ij} is the probability of stepping from state ii to state jj and every row sums to one:

P=[P11P1NPN1PNN],jPij=1    i.\mathbf{P} = \begin{bmatrix} P_{11} & \cdots & P_{1N} \\ \vdots & \ddots & \vdots \\ P_{N1} & \cdots & P_{NN} \end{bmatrix}, \qquad \sum_{j} P_{ij} = 1 \;\; \forall i.

A state ii is absorbing when Pii=1P_{ii} = 1: once entered, the chain re-selects the same state on every subsequent draw and the route freezes. That is the obvious case. The subtler and more common defect is a disconnected component — a set of states CC for which Pij=0P_{ij} = 0 for every iC,jCi \in C, j \notin C. The chain is still perfectly row-stochastic, every row sums to one, and no single diagonal entry is one, yet a walk that wanders into CC can never return to the rest of the network. From the sampler’s point of view the whole component behaves as one giant absorbing region.

These defects rarely come from bad math; they come from how the matrix is built. A road-network matrix assembled from observed transition counts inherits the observation gaps: a cul-de-sac visited only inbound gets a row that, after normalization, points only back the way the walk came or loops on itself. A grid clipped to a study-area boundary leaves border cells whose only recorded neighbours fell outside the clip, so they normalize to a self-loop. Merging two tiles whose node ids were assigned independently can leave a bridge edge missing, splitting what should be one network into two components that never exchange walks. Unlike the sparse-count dead-ends handled in simulating pedestrian movement with first-order Markov models — which produce zero-sum rows that truncate the walk — absorbing states and disconnected components produce valid row-stochastic matrices, so a row-sum check passes them straight through. Detecting them requires reasoning about reachability on the directed graph, not about the arithmetic of any single row.

Reachability defects in a Markov route network: self-loop absorbing state and disconnected component Left panel shows the raw directed graph of route states as circles connected by arrows. A large strongly connected component of five nodes routes freely among itself. One node has a self-loop arrow back to itself and no outgoing edge elsewhere, marked as an absorbing state that freezes the walk. A separate pair of nodes forms a disconnected component with an edge only between themselves and no edge back to the main component, marked as a trap. Right panel shows the cleaned graph after strongly-connected-component analysis: only the largest strongly connected component is retained for sampling, the self-loop node is either whitelisted as a true sink or merged, and the disconnected pair is dropped. Raw transition graph After SCC analysis S self-loop Pₛₛ=1 disconnected: no edge in/out largest SCC retained sink whitelisted or merged disconnected pair dropped
A row-stochastic matrix can still trap walks: self-loops freeze them and disconnected components swallow them. Strongly-connected-component analysis exposes both so only a mutually reachable core is sampled.

Minimal Reproducer: Surface the Traps

Row-sum arithmetic will not find these defects, so build the directed reachability graph and inspect its strongly connected components. Pin the toolchain so component ordering is deterministic.

# requirements.txt — pin majors, let patch releases float
numpy==1.26.*
scipy==1.11.*
networkx==3.*
python
import numpy as np
import networkx as nx
from scipy import sparse

def diagnose(P: sparse.csr_matrix, whitelist: set[int] | None = None) -> dict:
    """Find unintended absorbing states and disconnected components in a route matrix."""
    whitelist = whitelist or set()
    n = P.shape[0]
    diag = P.diagonal()
    self_loops = set(np.flatnonzero(diag >= 1.0 - 1e-9)) - whitelist  # P_ii == 1
    # Build directed graph from non-zero off-diagonal entries.
    coo = P.tocoo()
    g = nx.DiGraph()
    g.add_nodes_from(range(n))
    for i, j, v in zip(coo.row, coo.col, coo.data):
        if i != j and v > 0:
            g.add_edge(int(i), int(j))
    sccs = list(nx.strongly_connected_components(g))
    largest = max(sccs, key=len)
    trapped = n - len(largest)                       # nodes outside the main core
    return {"unintended_self_loops": len(self_loops),
            "num_sccs": len(sccs),
            "largest_scc": len(largest),
            "nodes_outside_core": trapped}

# A matrix merged from two tiles with a missing bridge edge:
# {'unintended_self_loops': 3, 'num_sccs': 47, 'largest_scc': 812, 'nodes_outside_core': 191}

Any unintended_self_loops, or a largest_scc that is much smaller than NN, means walks will truncate or pool in traps. A healthy routing matrix has one dominant strongly connected component covering nearly every node.

Fix: Restrict to the Largest Component and Whitelist True Sinks

The remedy has two parts: keep only the mutually reachable core so no walk can enter a trap, and distinguish genuine destinations (which should absorb) from accidental sinks (which must be repaired). Never “fix” a self-loop by silently redistributing its probability — that manufactures transitions the data never observed.

Step 1 — Restrict to the largest strongly connected component

A strongly connected component is a maximal set of nodes where every node can reach every other. Restricting the matrix to it guarantees the chain is irreducible: from any state, any other state is reachable, so no walk can be trapped.

python
import numpy as np
import networkx as nx
from scipy import sparse

def restrict_to_core(P: sparse.csr_matrix, node_ids: list) -> tuple[sparse.csr_matrix, list]:
    """Keep only the largest SCC, then renormalize rows to stay stochastic."""
    coo = P.tocoo()
    g = nx.DiGraph()
    g.add_nodes_from(range(P.shape[0]))
    for i, j, v in zip(coo.row, coo.col, coo.data):
        if i != j and v > 0:
            g.add_edge(int(i), int(j))
    core = max(nx.strongly_connected_components(g), key=len)
    keep = sorted(core)                              # fixed order -> reproducible index
    sub = P[keep][:, keep].tocsr()
    row_sums = np.asarray(sub.sum(axis=1)).ravel()
    inv = np.divide(1.0, row_sums, out=np.zeros_like(row_sums), where=row_sums > 0)
    sub = sparse.diags(inv) @ sub                    # renormalize surviving rows to sum to 1
    return sub.tocsr(), [node_ids[i] for i in keep]

Step 2 — Whitelist true sinks, absorb the walk gracefully

Some absorbing states are correct: a depot, a parking terminus, a trip end. Those belong on an explicit whitelist so the sampler stops there rather than looping, and the diagnostic ignores them. Everything not whitelisted that still looks absorbing is a defect to repair upstream.

python
def resolve_sinks(P, node_ids, true_sinks: set, sampler_stop: set) -> set:
    """Return the index set the sampler should treat as legitimate terminals.

    true_sinks: real-world destinations (depots, trip ends) -> STOP here.
    Everything else with no meaningful out-degree is a build defect, not a sink.
    """
    id_to_idx = {nid: i for i, nid in enumerate(node_ids)}
    stop = {id_to_idx[s] for s in true_sinks if s in id_to_idx}
    sampler_stop.update(stop)
    return sampler_stop  # sampler breaks on these; all other states remain transient

With the matrix restricted to its core and true sinks whitelisted, the sampler from the parent routing stage can run unbounded without ever freezing: every non-terminal state has a path back into the network, and every terminal state is one you intended. Attach dwell times and trip ends downstream in the temporal synchronization for moving objects stage rather than encoding them as chain structure.

Verification Step: Gate Irreducibility

Wire irreducibility and sink-legitimacy as CI assertions so a re-merged network cannot regress into traps. Score the retained coverage the same way the realism metrics evaluation stage scores synthetic against source, so a silent mass deletion of nodes fails loudly.

python
import numpy as np
import networkx as nx

def test_route_matrix_is_escapable(P, node_ids, whitelist_idx, min_core_frac=0.95):
    # 1. Exactly one dominant strongly connected component covering most nodes.
    coo = P.tocoo()
    g = nx.DiGraph(); g.add_nodes_from(range(P.shape[0]))
    g.add_edges_from((int(i), int(j)) for i, j, v in zip(coo.row, coo.col, coo.data)
                     if i != j and v > 0)
    core = max(nx.strongly_connected_components(g), key=len)
    assert len(core) / P.shape[0] >= min_core_frac, "network fragmented into traps"

    # 2. No unintended absorbing states outside the whitelist.
    diag = P.diagonal()
    leaked = set(np.flatnonzero(diag >= 1.0 - 1e-9)) - set(whitelist_idx)
    assert not leaked, f"unmodeled absorbing states: {sorted(leaked)}"

    # 3. Rows still sum to one after restriction/renormalization.
    row_sums = np.asarray(P.sum(axis=1)).ravel()
    assert np.abs(1.0 - row_sums[row_sums > 0]).max() < 1e-9, "renormalization drift"

The coverage assertion is the load-bearing one. Restricting to the largest component always yields an irreducible matrix, so the risk after the fix is the opposite failure — throwing away most of the network because a missing bridge edge split it — and only a coverage floor catches that.

Edge Cases & Gotchas

A legitimate destination looks exactly like a defect. A depot where every trip ends has genuinely no meaningful outgoing transition, so the diagnostic flags it identically to an accidental sink. The only way to tell them apart is domain knowledge encoded as the whitelist; never auto-repair flagged self-loops, because doing so injects transitions out of a real terminus that the source data never contained and corrupts the trip-end distribution.

Weak vs. strong connectivity across one-way networks. On a road graph with one-way streets, a region can be weakly connected (reachable ignoring direction) yet not strongly connected (no directed path back). Using weakly connected components hides one-way traps: a walk drives into a one-way pocket and cannot legally return. Always use strongly connected components for directed route matrices; weak connectivity is only safe on an undirected walkable grid.

Renormalization after edge deletion shifts probabilities. Dropping the edges that led into removed components leaves the surviving rows summing to less than one, and renormalizing redistributes that mass across the remaining neighbours — subtly changing the transition preferences you calibrated. Log the total probability mass removed per row; if any row lost a large fraction, the node sat on the boundary of a dropped component and its behaviour after renormalization no longer reflects the source data.

Frequently Asked Questions

How is an absorbing state different from a zero-sum dead-end row?
A zero-sum row sums to zero, so inverse-transform sampling has no valid next state and the walk truncates immediately with what is effectively an error. An absorbing state has a valid row that sums to one but points only back to itself, so the walk continues forever selecting the same node. A row-sum check catches the first and passes the second, which is why absorbing states need reachability analysis on the directed graph.
Why use strongly connected components instead of weakly connected ones?
Weak connectivity ignores edge direction, so it treats a one-way pocket as connected even though a walk that enters it can never legally drive back out. Strong connectivity requires a directed path in both directions between every pair of nodes in the component, which is exactly the guarantee a route sampler needs. On a directed transition matrix, only the largest strongly connected component is safe to sample without traps.
Can I just add a small self-transition or restart probability to every node?
Adding a uniform restart probability, as in PageRank teleportation, does make the chain irreducible and technically removes traps, but it injects transitions the source data never observed and flattens the real route structure toward uniform. It is a modeling choice, not a bug fix. Prefer restricting to the reachable core, which preserves observed transition preferences exactly and only removes nodes that were genuinely unreachable.
My largest component dropped from 5000 nodes to 900 after the fix. What happened?
A sharp drop means the network was fragmented into many components, almost always from a build-time defect: node ids assigned independently per tile, a missing bridge edge between merged regions, or a clip that severed connections. The fix is upstream in the matrix assembly, not in the sampler. Log the component-size distribution and inspect the boundary between the two largest components to find the edge that should exist but does not.