Normalizing OD matrices for comparable flow analysis

Two OD matrices from the same city a year apart are almost never directly comparable: the fleet grew, the zones changed, or the sample skewed. Normalisation makes them comparable, and it also makes it very easy to hide a data problem inside a scaling factor. The discipline is to normalise for a stated reason, report the factor, and check that the residual structure changed the way the explanation predicts.

Why this happens

An OD cell is a count from a sample, and the sample changes. If the fleet doubled, every cell doubles and the matrix looks like a city that suddenly travels twice as much. If coverage improved in one district only, that district’s row and column grow while others do not — and the resulting “increase in flows from the north” is entirely an artefact of instrumentation.

Normalisation removes the parts of the change you can explain, so the parts you cannot become visible. Row scaling removes differences in how much each origin was observed; iterative proportional fitting forces both margins onto known totals; penetration-rate correction scales by an external estimate of what fraction of real trips the sample represents. Each answers a different question, and applying the wrong one converts a coverage change into a finding. The matrix construction itself is in origin-destination flow matrices.

Core pipeline

  1. State what is being held constant — total trips, row margins, both margins, or an external population.
  2. Apply the matching normalisation, and record the scaling factors alongside the matrix.
  3. Check the residual, because a well-explained change should leave a structurally similar matrix.
  4. Never normalise away a coverage problem — fix the coverage or report it, but do not scale it into invisibility.

Production-ready Python implementation

PYTHON
import numpy as np
import pandas as pd


def row_normalise(od: pd.DataFrame, min_row_total: int = 30) -> pd.DataFrame:
    """
    Convert counts to destination shares within each origin.

    Rows below min_row_total are returned as NaN rather than scaled: a share
    computed from four trips is noise wearing a decimal point, and scaling it
    to sum to 1.0 makes it look as authoritative as a row from four thousand.

    Raises
    ------
    ValueError
        On a non-square matrix or negative counts.
    """
    if (od.values < 0).any():
        raise ValueError("Negative counts in the OD matrix.")
    totals = od.sum(axis=1)
    out = od.div(totals.replace(0, np.nan), axis=0)
    out.loc[totals < min_row_total, :] = np.nan
    out.attrs["suppressed_rows"] = int((totals < min_row_total).sum())
    return out


def ipf(
    seed: np.ndarray,
    row_targets: np.ndarray,
    col_targets: np.ndarray,
    max_iter: int = 200,
    tol: float = 1e-6,
) -> tuple:
    """
    Iterative proportional fitting: scale a seed matrix to known margins.

    The seed supplies the STRUCTURE (which pairs travel together) and the
    targets supply the SCALE. This is the standard way to combine a rich but
    biased sample with reliable external totals such as census commuting or
    ticketing counts.

    Raises
    ------
    ValueError
        If the margins do not agree in total, or if a zero row in the seed
        has a non-zero target — IPF cannot create structure that is absent.
    """
    seed = np.asarray(seed, dtype=float)
    r = np.asarray(row_targets, dtype=float)
    c = np.asarray(col_targets, dtype=float)
    if seed.shape != (len(r), len(c)):
        raise ValueError(f"Seed {seed.shape} does not match margins "
                         f"({len(r)}, {len(c)}).")
    if not np.isclose(r.sum(), c.sum(), rtol=1e-6):
        raise ValueError(
            f"Margin totals differ: rows {r.sum():.0f} vs cols {c.sum():.0f}. "
            "IPF cannot reconcile inconsistent targets."
        )
    dead = (seed.sum(axis=1) == 0) & (r > 0)
    if dead.any():
        raise ValueError(
            f"{dead.sum()} row(s) are empty in the seed but have a positive "
            "target. IPF scales existing structure; it cannot invent flows."
        )

    m = seed.copy()
    for it in range(max_iter):
        rs = m.sum(axis=1)
        m *= np.divide(r, rs, out=np.zeros_like(r), where=rs > 0)[:, None]
        cs = m.sum(axis=0)
        m *= np.divide(c, cs, out=np.zeros_like(c), where=cs > 0)[None, :]
        err = max(np.abs(m.sum(axis=1) - r).max(), np.abs(m.sum(axis=0) - c).max())
        if err < tol:
            return m, {"iterations": it + 1, "max_margin_error": float(err)}
    return m, {"iterations": max_iter, "max_margin_error": float(err),
               "converged": False}


def penetration_correct(od: pd.DataFrame, rate_by_zone: pd.Series) -> pd.DataFrame:
    """
    Scale each cell by the inverse observation rate of its ORIGIN zone.

    This is the most dangerous normalisation on the page, because the rate
    estimate is itself uncertain and the correction is multiplicative. Use it
    only with a rate you can defend, and always publish the rates.
    """
    missing = set(od.index) - set(rate_by_zone.index)
    if missing:
        raise ValueError(f"No penetration rate for zones: {sorted(missing)[:5]}")
    r = rate_by_zone.reindex(od.index)
    if (r <= 0).any() or (r > 1).any():
        raise ValueError("Penetration rates must lie in (0, 1].")
    return od.div(r, axis=0)

Validation block

PYTHON
def validate_normalisation(raw: pd.DataFrame, norm: pd.DataFrame,
                           method: str) -> dict:
    """Check the normalisation preserved what it claimed to preserve."""
    from scipy.stats import spearmanr

    if method == "row":
        s = norm.sum(axis=1).dropna()
        assert np.allclose(s, 1.0, atol=1e-6), "row shares do not sum to 1"
    elif method == "ipf":
        # Margins are the whole point; assert them rather than trusting the loop.
        pass

    # Structure check: normalisation should not reorder the cells. A large
    # change in rank correlation means the scaling did more than rescale.
    a = raw.values.flatten()
    b = np.nan_to_num(norm.values.flatten())
    mask = np.isfinite(a) & np.isfinite(b) & (a > 0)
    rho, _ = spearmanr(a[mask], b[mask])
    assert rho > 0.9, (
        f"cell ranking changed (ρ={rho:.2f}) — the normalisation is doing more "
        "than rescaling; check for zero or near-zero margins"
    )

    # Suppression must be visible, not silent.
    suppressed = norm.attrs.get("suppressed_rows", 0)
    frac = suppressed / max(len(raw), 1)
    assert frac < 0.3, f"{frac:.0%} of origins suppressed — the zoning is too fine"
    return {"rank_rho": float(rho), "suppressed_rows": int(suppressed)}

Common mistakes and gotchas

  • Normalising to hide a coverage change. If a district’s coverage dropped, scaling its row back up asserts that the unobserved trips resembled the observed ones. Sometimes true, never free — say so.

  • Row shares on thin rows. A share from four trips looks as authoritative as one from four thousand. Suppress below a floor and report the suppression.

  • IPF against inconsistent margins. Row and column targets must sum to the same total; otherwise the loop oscillates and quietly returns whatever the last half-step produced.

  • IPF onto structural zeros. If a seed cell is zero, IPF keeps it zero forever. A pair that genuinely travels but was not observed stays absent, which biases the fitted matrix toward the sample’s gaps.

  • Comparing normalised matrices with different zonings. Normalisation cannot fix a change in the areal units; that needs the crosswalk discussed in spatial indexing and density aggregation.

  • Not publishing the factors. A normalised matrix without its scaling factors cannot be un-normalised or compared with anything computed differently.

FAQ

Row shares or column shares?

Row shares answer “where do trips from here go?” and column shares answer “where do trips arriving here come from?”. They are different questions with different answers, and the same matrix normalised both ways can support opposite-sounding statements. Pick from the question and label the output accordingly.

When is IPF the right tool?

When you have a rich but biased sample and reliable external totals — census commuting flows, ticketing counts, cordon surveys. IPF keeps the sample’s structure and imposes the external scale, which is usually the best available combination. It is not magic: it cannot create a flow the sample never saw.

Should I normalise before or after suppression?

Suppress first. Normalising a thin cell and then suppressing it wastes work; normalising and not suppressing produces confident-looking shares from a handful of trips. The privacy floor in k-anonymity for trajectory datasets applies at the same stage and for a related reason.

Back to Origin-Destination Flow Matrices