Skip to content

Graphs and clustering

find_neighbors builds both graphs Seurat builds — the directed KNN graph and the shared-nearest-neighbour graph on top of it — and find_clusters runs community detection over the SNN.

For Louvain, Louvain with multilevel refinement and smart local moving, find_clusters runs Seurat's own modularity optimiser: a translation of the C++ behind FindClusters, with the same restarts and the same random-number stream. Given the same graph it returns Seurat's partition, label for label. Leiden runs through leidenalg.

Four details here were wrong before the integration tutorial went looking, and each mattered downstream: the KNN graph is stored directed rather than symmetrized, the SNN keeps its diagonal and is computed in float64, run_umap zeroes the diagonal when handed a graph, and singletons are folded into their nearest community the way GroupSingletons does. They are noted in the docstrings because each one changes cluster assignments, not just internals.

Neighbour graphs

find_neighbors

find_neighbors(seurat, dims: Optional[Union[list[int], range]] = None, k_param: int = 20, assay: Optional[str] = None, reduction: str = 'pca', graph_name: Optional[str] = None, return_neighbor: bool = False, compute_snn: Optional[bool] = None, prune_snn: float = 1 / 15, seed: int = 42) -> None

Build KNN and SNN graphs from a low-dimensional embedding.

Mirrors R's FindNeighbors(pbmc, dims = 1:10). Stores Graph objects in seurat.graphs[f"{graph_name}_nn"] and seurat.graphs[f"{graph_name}_snn"].

Parameters:

  • dims (Optional[Union[list[int], range]], default: None ) –

    which PCs to use (0-indexed; default all available)

  • k_param (int, default: 20 ) –

    number of nearest neighbors, including the cell itself

  • reduction (str, default: 'pca' ) –

    which reduction to use ('pca' by default)

  • graph_name (Optional[str], default: None ) –

    prefix for graph names (defaults to active assay name). With return_neighbor=True it names the Neighbor outright, rather than acting as a prefix.

  • return_neighbor (bool, default: False ) –

    store the raw KNN result — indices and distances — as a Neighbor in seurat.neighbors instead of building graphs. Seurat's return.neighbor.

  • compute_snn (Optional[bool], default: None ) –

    build the SNN graph. Defaults to not return_neighbor, as in Seurat, which cannot do both.

  • prune_snn (float, default: 1 / 15 ) –

    edges with Jaccard index below this are pruned (Seurat default 1/15)

Notes

return_neighbor=True stores a Neighbor under f"{assay}.nn" — a dot, where the graphs use an underscore (RNA.nn against RNA_nn / RNA_snn). That is Seurat's naming, and the separator is the only thing distinguishing the two in a printout.

The stored indices are 0-based, where R's Indices() are 1-based. Everything else — the k columns, self first at distance 0, the ordering — is the same.

Source code in truecell/neighbors.py
def find_neighbors(
    seurat,
    dims: Optional[Union[list[int], range]] = None,
    k_param: int = 20,
    assay: Optional[str] = None,
    reduction: str = "pca",
    graph_name: Optional[str] = None,
    return_neighbor: bool = False,
    compute_snn: Optional[bool] = None,
    prune_snn: float = 1 / 15,
    seed: int = 42,
) -> None:
    """Build KNN and SNN graphs from a low-dimensional embedding.

    Mirrors R's ``FindNeighbors(pbmc, dims = 1:10)``. Stores ``Graph`` objects in
    ``seurat.graphs[f"{graph_name}_nn"]`` and ``seurat.graphs[f"{graph_name}_snn"]``.

    Parameters
    ----------
    dims        : which PCs to use (0-indexed; default all available)
    k_param     : number of nearest neighbors, **including the cell itself**
    reduction   : which reduction to use ('pca' by default)
    graph_name  : prefix for graph names (defaults to active assay name). With
                  ``return_neighbor=True`` it names the ``Neighbor`` outright,
                  rather than acting as a prefix.
    return_neighbor : store the raw KNN result — indices and distances — as a
                  ``Neighbor`` in ``seurat.neighbors`` instead of building
                  graphs. Seurat's ``return.neighbor``.
    compute_snn : build the SNN graph. Defaults to ``not return_neighbor``,
                  as in Seurat, which cannot do both.
    prune_snn   : edges with Jaccard index below this are pruned (Seurat default 1/15)

    Notes
    -----
    ``return_neighbor=True`` stores a ``Neighbor`` under ``f"{assay}.nn"`` — a
    **dot**, where the graphs use an underscore (``RNA.nn`` against ``RNA_nn`` /
    ``RNA_snn``). That is Seurat's naming, and the separator is the only thing
    distinguishing the two in a printout.

    The stored indices are **0-based**, where R's ``Indices()`` are 1-based.
    Everything else — the k columns, self first at distance 0, the ordering — is
    the same.
    """
    assay_name = assay or seurat.active_assay
    if compute_snn is None:
        compute_snn = not return_neighbor
    elif compute_snn and return_neighbor:
        # R warns and computes no SNN rather than refusing the call.
        warnings.warn(
            "The SNN graph is not computed if return_neighbor is True.",
            stacklevel=2,
        )
        compute_snn = False

    # Get embeddings
    if reduction not in seurat.reductions:
        raise KeyError(f"Reduction '{reduction}' not found. Run run_pca() first.")
    dr = seurat.reductions[reduction]
    embeddings = dr.cell_embeddings  # (cells × dims)

    if dims is None:
        emb = embeddings
    else:
        dims_list = list(dims)
        emb = embeddings[:, dims_list]

    cells = seurat.cell_names()
    n_cells = len(cells)

    # Build KNN
    nn_idx, nn_dist = _build_knn(emb, k_param, seed)

    if return_neighbor:
        # The distances have always been computed here and thrown away; this is
        # the branch that keeps them. Seurat stores no graph at all in this mode.
        seurat.neighbors[graph_name or f"{assay_name}.nn"] = Neighbor(
            nn_idx=nn_idx,
            nn_dist=nn_dist,
            cell_names=cells,
            alg_info={"k_param": k_param, "reduction": reduction,
                      "assay": assay_name},
        )
    else:
        prefix = graph_name or assay_name
        seurat.graphs[f"{prefix}_nn"] = Graph(
            matrix=_knn_to_sparse(nn_idx, n_cells),
            cell_names=cells, assay_used=assay_name,
        )
        if compute_snn:
            seurat.graphs[f"{prefix}_snn"] = Graph(
                matrix=_build_snn(nn_idx, n_cells, k_param, prune_snn),
                cell_names=cells, assay_used=assay_name,
            )

    log_truecell_command(
        seurat, "FindNeighbors", assay=assay_name, reduction=reduction,
        params={"k_param": k_param, "prune_snn": prune_snn,
                "return_neighbor": return_neighbor, "compute_snn": compute_snn,
                "dims": list(dims) if dims is not None else None},
    )

find_multi_modal_neighbors

find_multi_modal_neighbors(seurat, reduction_list: Sequence[str] = ('pca', 'apca'), dims_list: Optional[Sequence[Sequence[int]]] = None, k_nn: int = 20, l2_norm: bool = True, knn_graph_name: str = 'wknn', snn_graph_name: str = 'wsnn', knn_range: int = 200, prune_snn: float = 1 / 15, sd_scale: float = 1.0, cross_constant: Optional[float] = None, smooth: bool = False, seed: int = 42) -> None

Compute weighted-nearest-neighbour graphs across modalities.

Mirrors R's FindMultiModalNeighbors(obj, reduction.list = list("pca","apca"), dims.list = list(1:30, 1:18)). Stores:

  • seurat.graphs[knn_graph_name] — joint KNN graph
  • seurat.graphs[snn_graph_name] — joint SNN graph (feed to find_clusters(graph_name=...) or run_umap(graph=...))
  • seurat.meta_data["<modality>.weight"] — per-cell modality weights

Parameters:

  • reduction_list (Sequence[str], default: ('pca', 'apca') ) –

    reductions to combine, one per modality

  • dims_list (Optional[Sequence[Sequence[int]]], default: None ) –

    dims (0-indexed) to use from each reduction; default all

  • k_nn (int, default: 20 ) –

    number of joint neighbours to keep per cell

  • l2_norm (bool, default: True ) –

    L2-normalise each embedding first (R's l2.norm)

  • knn_range (int, default: 200 ) –

    candidate neighbours each modality nominates before the joint re-ranking (R's knn.range)

  • prune_snn (float, default: 1 / 15 ) –

    Jaccard prune threshold for the joint SNN graph

  • sd_scale (float, default: 1.0 ) –

    scaling on the per-cell kernel bandwidth (R's sd.scale)

  • cross_constant (Optional[float], default: None ) –

    denominator guard in the modality score; default 1e-4

  • smooth (bool, default: False ) –

    average each cell's modality score over its neighbours

Notes

The weights are stored under each reduction's assay_used name, so an RNA "pca" and an ADT "apca" produce RNA.weight and ADT.weight — the same columns Seurat writes, and readable by the plotting functions as if they were features.

Source code in truecell/multimodal.py
def find_multi_modal_neighbors(
    seurat,
    reduction_list: Sequence[str] = ("pca", "apca"),
    dims_list: Optional[Sequence[Sequence[int]]] = None,
    k_nn: int = 20,
    l2_norm: bool = True,
    knn_graph_name: str = "wknn",
    snn_graph_name: str = "wsnn",
    knn_range: int = 200,
    prune_snn: float = 1 / 15,
    sd_scale: float = 1.0,
    cross_constant: Optional[float] = None,
    smooth: bool = False,
    seed: int = 42,
) -> None:
    """Compute weighted-nearest-neighbour graphs across modalities.

    Mirrors R's ``FindMultiModalNeighbors(obj, reduction.list =
    list("pca","apca"), dims.list = list(1:30, 1:18))``. Stores:

    * ``seurat.graphs[knn_graph_name]`` — joint KNN graph
    * ``seurat.graphs[snn_graph_name]`` — joint SNN graph (feed to
      ``find_clusters(graph_name=...)`` or ``run_umap(graph=...)``)
    * ``seurat.meta_data["<modality>.weight"]`` — per-cell modality weights

    Parameters
    ----------
    reduction_list : reductions to combine, one per modality
    dims_list      : dims (0-indexed) to use from each reduction; default all
    k_nn           : number of joint neighbours to keep per cell
    l2_norm        : L2-normalise each embedding first (R's ``l2.norm``)
    knn_range      : candidate neighbours each modality nominates before the
                     joint re-ranking (R's ``knn.range``)
    prune_snn      : Jaccard prune threshold for the joint SNN graph
    sd_scale       : scaling on the per-cell kernel bandwidth (R's ``sd.scale``)
    cross_constant : denominator guard in the modality score; default 1e-4
    smooth         : average each cell's modality score over its neighbours

    Notes
    -----
    The weights are stored under each reduction's ``assay_used`` name, so an RNA
    ``"pca"`` and an ADT ``"apca"`` produce ``RNA.weight`` and ``ADT.weight`` —
    the same columns Seurat writes, and readable by the plotting functions as if
    they were features.
    """
    if len(reduction_list) < 2:
        raise ValueError("find_multi_modal_neighbors needs at least 2 reductions.")

    for r in reduction_list:
        if r not in seurat.reductions:
            raise KeyError(
                f"Reduction '{r}' not found. Compute it before WNN "
                "(e.g. run_pca for RNA, run_pca(reduction_name='apca') for ADT)."
            )

    cells = seurat.cell_names()
    n_cells = len(cells)
    cross_constant = _CROSS_CONSTANT if cross_constant is None else cross_constant

    if k_nn >= n_cells:
        raise ValueError(
            f"k_nn ({k_nn}) must be smaller than the number of cells ({n_cells})."
        )

    # Per-modality embeddings (optionally dim-subset, then L2-normalised).
    embs: list[np.ndarray] = []
    for m, r in enumerate(reduction_list):
        e = seurat.reductions[r].cell_embeddings
        if dims_list is not None and dims_list[m] is not None:
            e = e[:, list(dims_list[m])]
        e = np.asarray(e, dtype=float)
        embs.append(_l2_norm(e) if l2_norm else e)

    # Stage 1 — per-cell modality weights, plus the bandwidths and nearest
    # distances stage 2 reuses (R threads these through ModalityWeights@params).
    weights, sigmas, nearest_dist = _modality_weights(
        embs, k_nn, sd_scale, cross_constant, smooth, seed,
    )

    # Stage 2 — joint neighbour search, then graphs off that single ranking.
    select_nn = _multi_modal_nn(
        embs, weights, sigmas, nearest_dist, k_nn, knn_range, seed,
    )

    wknn = _knn_union_graph(select_nn, n_cells)
    wsnn = _build_snn(select_nn, n_cells, select_nn.shape[1], prune_snn).tocsc()

    assay_name = seurat.active_assay
    seurat.graphs[knn_graph_name] = Graph(matrix=wknn, cell_names=cells, assay_used=assay_name)
    seurat.graphs[snn_graph_name] = Graph(matrix=wsnn, cell_names=cells, assay_used=assay_name)

    # Store per-cell modality weights in meta_data, named by each modality's assay.
    for m, r in enumerate(reduction_list):
        assay_used = seurat.reductions[r].assay_used or r
        seurat.meta_data[f"{assay_used}.weight"] = weights[:, m]

Community detection

find_clusters

find_clusters(seurat, resolution: float | Sequence[float] = 0.8, algorithm: int = 1, graph_name: str | None = None, random_seed: int = 0, n_iter: int = 10, group_singletons: bool = True, cluster_name: str | Sequence[str] | None = None, modularity_fxn: int = 1, n_start: int = 10, optimizer: str = 'seurat', n_iterations: int | None = None) -> None

Cluster the cells on a neighbour graph, as Seurat's FindClusters does.

Mirrors R's FindClusters(pbmc), including its vector form FindClusters(pbmc, resolution = c(0.4, 0.8, 1.2)).

Algorithms 1–3 run Seurat's own modularity optimiser, translated from the C++ behind RunModularityClusteringCpp. It makes n_start random starts from one random stream, iterates each up to n_iter times, and keeps the partition with the highest modularity. Given the same graph and settings, truecell returns Seurat's partition label for label. Algorithm 4 is Leiden, run by leidenalg.

Each resolution is written to its own metadata column, named {graph_name}_res.{resolution} as Seurat names it. seurat_clusters and the active identities are set from the last resolution in the sequence — last as given, not largest, which is what Seurat does.

Parameters:

  • resolution (float | Sequence[float], default: 0.8 ) –

    higher values give more / finer clusters. A sequence runs each in turn, as R's resolution = c(...) does.

  • algorithm (int, default: 1 ) –

    1 = Louvain, 2 = Louvain with multilevel refinement, 3 = smart local moving (SLM), 4 = Leiden.

  • graph_name (str | None, default: None ) –

    SNN graph to use (defaults to '{assay}_snn')

  • random_seed (int, default: 0 ) –

    seeds the optimiser. Leiden needs a seed above 0, so there a seed of 0 or below becomes 1, with a warning, as in Seurat.

  • n_iter (int, default: 10 ) –

    iterations per random start. Louvain stops early once an iteration changes nothing; SLM runs all of them. For Leiden it is leidenalg's iteration count, and a negative value runs until the partition is stable.

  • group_singletons (bool, default: True ) –

    absorb size-1 clusters into their best-connected neighbour, as Seurat's GroupSingletons does. With False they are all pooled into one "singleton" cluster instead — again matching Seurat.

  • cluster_name (str | Sequence[str] | None, default: None ) –

    override the generated column name(s); one name, or one per resolution. Seurat's cluster.name. seurat_clusters is still written either way.

  • modularity_fxn (int, default: 1 ) –

    1 = standard modularity; 2 = Seurat's alternative, in which every cell weighs 1 and resolution must be at most 1.

  • n_start (int, default: 10 ) –

    random starts; the partition with the highest modularity wins.

  • optimizer (str, default: 'seurat' ) –

    "seurat" runs Seurat's optimiser for algorithms 1–3. "igraph" runs one pass of igraph's community_multilevel for algorithm 1 or 2, which is how truecell clustered up to 1.2; it reads neither n_start nor n_iter.

  • n_iterations (int | None, default: None ) –

    deprecated name for n_iter, accepted for one more release.

Notes

Seurat's partition can depend on the processor. Built for arm64, its C++ fuses a multiply-add in the rule that decides whether a cell moves, and that flips moves whose gain is within one rounding of zero. Such near-ties need edge weights that tie exactly, such as small fractions; they did not occur on PBMC 3k's shared nearest-neighbour graph. truecell uses plain IEEE arithmetic, which is Seurat as built for x86_64.

Each resolution is clustered from the same seed rather than from a running RNG stream, so a partition does not depend on which resolutions preceded it or on the order they were given in. Verified against Seurat 5.5.1, where resolution 0.8 gives the same partition alone, in c(0.4, 0.8, 1.2), and in c(1.2, 0.8, 0.4).

Source code in truecell/clustering.py
def find_clusters(
    seurat,
    resolution: float | Sequence[float] = 0.8,
    algorithm: int = 1,
    graph_name: str | None = None,
    random_seed: int = 0,
    n_iter: int = 10,
    group_singletons: bool = True,
    cluster_name: str | Sequence[str] | None = None,
    modularity_fxn: int = 1,
    n_start: int = 10,
    optimizer: str = "seurat",
    n_iterations: int | None = None,
) -> None:
    """Cluster the cells on a neighbour graph, as Seurat's ``FindClusters`` does.

    Mirrors R's ``FindClusters(pbmc)``, including its vector form
    ``FindClusters(pbmc, resolution = c(0.4, 0.8, 1.2))``.

    Algorithms 1–3 run Seurat's own modularity optimiser, translated from the C++
    behind ``RunModularityClusteringCpp``. It makes ``n_start`` random starts from
    one random stream, iterates each up to ``n_iter`` times, and keeps the
    partition with the highest modularity. Given the same graph and settings,
    truecell returns Seurat's partition label for label. Algorithm 4 is Leiden,
    run by leidenalg.

    Each resolution is written to its own metadata column, named
    ``{graph_name}_res.{resolution}`` as Seurat names it. ``seurat_clusters`` and
    the active identities are set from the **last** resolution in the sequence —
    last as given, not largest, which is what Seurat does.

    Parameters
    ----------
    resolution   : higher values give more / finer clusters. A sequence runs each
                   in turn, as R's ``resolution = c(...)`` does.
    algorithm    : 1 = Louvain, 2 = Louvain with multilevel refinement,
                   3 = smart local moving (SLM), 4 = Leiden.
    graph_name   : SNN graph to use (defaults to '{assay}_snn')
    random_seed  : seeds the optimiser. Leiden needs a seed above 0, so there a
                   seed of 0 or below becomes 1, with a warning, as in Seurat.
    n_iter       : iterations per random start. Louvain stops early once an
                   iteration changes nothing; SLM runs all of them. For Leiden it
                   is leidenalg's iteration count, and a negative value runs until
                   the partition is stable.
    group_singletons : absorb size-1 clusters into their best-connected
                   neighbour, as Seurat's ``GroupSingletons`` does. With
                   ``False`` they are all pooled into one ``"singleton"``
                   cluster instead — again matching Seurat.
    cluster_name : override the generated column name(s); one name, or one per
                   resolution. Seurat's ``cluster.name``. ``seurat_clusters`` is
                   still written either way.
    modularity_fxn : 1 = standard modularity; 2 = Seurat's alternative, in which
                   every cell weighs 1 and ``resolution`` must be at most 1.
    n_start      : random starts; the partition with the highest modularity wins.
    optimizer    : ``"seurat"`` runs Seurat's optimiser for algorithms 1–3.
                   ``"igraph"`` runs one pass of igraph's ``community_multilevel``
                   for algorithm 1 or 2, which is how truecell clustered up to
                   1.2; it reads neither ``n_start`` nor ``n_iter``.
    n_iterations : deprecated name for ``n_iter``, accepted for one more release.

    Notes
    -----
    Seurat's partition can depend on the processor. Built for arm64, its C++
    fuses a multiply-add in the rule that decides whether a cell moves, and that
    flips moves whose gain is within one rounding of zero. Such near-ties need
    edge weights that tie exactly, such as small fractions; they did not occur on
    PBMC 3k's shared nearest-neighbour graph. truecell uses plain IEEE arithmetic,
    which is Seurat as built for x86_64.

    Each resolution is clustered from the same seed rather than from a running
    RNG stream, so a partition does not depend on which resolutions preceded it
    or on the order they were given in. Verified against Seurat 5.5.1, where
    resolution 0.8 gives the same partition alone, in ``c(0.4, 0.8, 1.2)``, and
    in ``c(1.2, 0.8, 0.4)``.
    """
    if n_iterations is not None:
        warnings.warn(
            "`n_iterations` is deprecated and will be removed in the next release; "
            "use `n_iter`, which is Seurat's name for it.",
            FutureWarning,
            stacklevel=2,
        )
        n_iter = n_iterations

    assay_name = seurat.active_assay
    if graph_name is None:
        graph_name = f"{assay_name}_snn"
        if graph_name not in seurat.graphs:
            # Try knn graph
            graph_name = f"{assay_name}_nn"

    if graph_name not in seurat.graphs:
        raise KeyError(
            f"Graph '{graph_name}' not found. Run find_neighbors() first."
        )

    graph = seurat.graphs[graph_name]
    mat = graph._matrix  # scipy sparse (cells × cells)

    # Every argument is checked before the first resolution is clustered, so a
    # bad one raises before any clustering runs; a failure inside a later
    # resolution cannot leave earlier columns behind either, because nothing is
    # written until every resolution has succeeded (below).
    if algorithm not in (1, 2, 3, 4):
        raise ValueError(
            f"Unknown algorithm {algorithm!r}. Use 1 (Louvain), 2 (Louvain with "
            "multilevel refinement), 3 (SLM) or 4 (Leiden)."
        )
    if optimizer not in ("seurat", "igraph"):
        raise ValueError(f"Unknown optimizer {optimizer!r}. Use 'seurat' or 'igraph'.")
    seurat_optimizer = algorithm != 4 and optimizer == "seurat"
    if algorithm == 3 and optimizer == "igraph":
        raise ValueError("igraph has no smart local moving algorithm; algorithm=3 "
                         "needs optimizer='seurat'.")
    if algorithm in (1, 2) and optimizer == "igraph" and modularity_fxn != 1:
        raise ValueError("igraph optimises the standard modularity only; "
                         "modularity_fxn=2 needs optimizer='seurat'.")
    if seurat_optimizer:
        if modularity_fxn not in (1, 2):
            raise ValueError(f"`modularity_fxn` must be 1 (standard) or 2 "
                             f"(alternative); got {modularity_fxn!r}.")
        if n_start < 1:
            raise ValueError(f"`n_start` must be at least 1; got {n_start!r}.")
        if n_iter < 1:
            raise ValueError(f"`n_iter` must be at least 1; got {n_iter!r}.")

    # `np.number` is here for the numpy scalars a caller gets out of an array;
    # np.float64 subclasses float but np.float32 does not, and iterating one
    # raises rather than falling through to the sequence branch.
    if isinstance(resolution, (int, float, np.number)):
        resolutions = [float(resolution)]
    else:
        resolutions = [float(r) for r in resolution]
    if not resolutions:
        raise ValueError("`resolution` is empty; give at least one value.")
    if seurat_optimizer and modularity_fxn == 2 and max(resolutions) > 1.0:
        raise ValueError("The alternative modularity (modularity_fxn=2) needs "
                         f"resolution <= 1; got {max(resolutions)}.")

    if cluster_name is None:
        names = [f"{graph_name}_res.{_res_label(r)}" for r in resolutions]
    else:
        names = [cluster_name] if isinstance(cluster_name, str) else list(cluster_name)
        if len(names) != len(resolutions):
            raise ValueError(
                f"`cluster_name` has {len(names)} name(s) for "
                f"{len(resolutions)} resolution(s); give one per resolution."
            )

    leiden_seed = random_seed
    if algorithm == 4 and random_seed <= 0:
        warnings.warn(
            "`random_seed` must be greater than 0 for Leiden clustering; using 1, "
            "as Seurat does.",
            UserWarning,
            stacklevel=2,
        )
        leiden_seed = 1

    # The graph is converted once, not once per resolution.
    if seurat_optimizer:
        try:
            from ._modularity import build_network, run_modularity_clustering
        except ImportError as exc:  # numba is in the [analysis] extra
            raise ImportError(
                "find_clusters needs numba to run Seurat's modularity optimiser; "
                "install it with `pip install truecell[analysis]`."
            ) from exc
        network = build_network(mat, modularity_fxn)
    else:
        igraph_graph = _sparse_to_igraph(mat)

    columns = []
    for res, name in zip(resolutions, names):
        if seurat_optimizer:
            labels = run_modularity_clustering(network, res, algorithm, n_start, n_iter,
                                               random_seed, modularity_fxn)
        elif algorithm == 4:
            labels = _leiden_clustering(igraph_graph, res, leiden_seed, n_iter)
        else:
            labels = _louvain_clustering(igraph_graph, res, random_seed)

        str_labels = _group_singletons(
            np.asarray([str(c) for c in labels]), mat, group_singletons
        )
        present = sorted(set(str_labels),
                         key=lambda s: (not s.isdigit(), s.isdigit() and int(s), s))
        columns.append((name, pd.Categorical(str_labels, categories=present)))

    # Seurat builds every column before it assigns any, so a resolution that fails
    # part way leaves the object as it was.
    for name, series in columns:
        seurat.meta_data[name] = series
    # The last resolution given, not the largest — Seurat takes the last column
    # of its results frame, so `resolution = c(1.2, 0.8, 0.4)` leaves the object
    # sitting on 0.4.
    cluster_series = columns[-1][1]
    seurat.meta_data["seurat_clusters"] = cluster_series
    seurat.idents = cluster_series