Skip to content

Integration and reference mapping

Two different jobs that share machinery. Integration removes a batch effect between datasets you intend to analyse together. Reference mapping leaves the reference untouched and projects a query into it, carrying labels across.

integrate_layers is the v5 dispatcher — method="harmony" | "cca" | "rpca" — and runs integrate_embeddings, the embedding-space algorithm, as Seurat v5 does. The v4 pair (find_integration_anchors + integrate_data, which corrects expression rather than embeddings) is still available directly and is still what you want if you are reproducing a v4 analysis. These were the same function once, which was a bug: the v5 name ran the v4 algorithm.

Both anchor paths are compared against Seurat's own anchors, not just against the clustering they produce, in Anchor internals.

Batch correction

integrate_layers

integrate_layers(seurat, method: str = 'harmony', orig_reduction: str = 'pca', new_reduction: Optional[str] = None, group_by: Optional[Union[str, list[str]]] = None, assay: Optional[str] = None, **kwargs) -> None

Integrate layers/batches (Seurat v5 IntegrateLayers dispatch API).

Mirrors IntegrateLayers(obj, method = HarmonyIntegration, orig.reduction = "pca"). A thin dispatcher over the individual integration routines.

Parameters:

  • method (str, default: 'harmony' ) –

    'harmony', 'cca', or 'rpca'.

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

    reduction to integrate (default 'pca'). Every method corrects this reduction and writes a new one of the same shape — the anchor methods included, which is what makes them interchangeable with Harmony here.

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

    storage key for the integrated reduction (defaults to '{method}')

  • group_by (Optional[Union[str, list[str]]], default: None ) –

    batch column identifying the layers/batches to integrate (required for every method)

Source code in truecell/integration.py
def integrate_layers(
    seurat,
    method: str = "harmony",
    orig_reduction: str = "pca",
    new_reduction: Optional[str] = None,
    group_by: Optional[Union[str, list[str]]] = None,
    assay: Optional[str] = None,
    **kwargs,
) -> None:
    """Integrate layers/batches (Seurat v5 ``IntegrateLayers`` dispatch API).

    Mirrors ``IntegrateLayers(obj, method = HarmonyIntegration,
    orig.reduction = "pca")``. A thin dispatcher over the individual
    integration routines.

    Parameters
    ----------
    method         : 'harmony', 'cca', or 'rpca'.
    orig_reduction : reduction to integrate (default 'pca'). Every method
                     corrects this reduction and writes a new one of the same
                     shape — the anchor methods included, which is what makes
                     them interchangeable with Harmony here.
    new_reduction  : storage key for the integrated reduction
                     (defaults to '{method}')
    group_by       : batch column identifying the layers/batches to integrate
                     (required for every method)
    """
    method = method.lower()
    new_reduction = new_reduction or method

    if method in ("harmony", "harmonyintegration"):
        if group_by is None:
            raise ValueError("method='harmony' requires group_by (batch column).")
        run_harmony(
            seurat,
            group_by=group_by,
            reduction=orig_reduction,
            reduction_name=new_reduction,
            assay=assay,
            **kwargs,
        )
    elif method in ("cca", "rpca", "ccaintegration", "rpcaintegration"):
        if group_by is None:
            raise ValueError(
                f"method={method!r} requires group_by (batch column)."
            )
        reduction = "rpca" if method.startswith("rpca") else "cca"
        _integrate_anchor_reduction(
            seurat,
            group_by=group_by,
            reduction=reduction,
            new_reduction=new_reduction,
            orig_reduction=orig_reduction,
            assay=assay,
            **kwargs,
        )
    else:
        raise ValueError(
            f"Unknown integration method {method!r}. "
            "Supported: 'harmony', 'cca', 'rpca'."
        )

run_harmony

run_harmony(seurat, group_by: Union[str, list[str]], reduction: str = 'pca', dims: Optional[Union[list[int], range]] = None, reduction_name: str = 'harmony', reduction_key: str = 'harmony_', theta: Optional[Union[float, list[float]]] = None, lambda_: Optional[Union[float, list[float]]] = None, sigma: float = 0.1, nclust: Optional[int] = None, max_iter_harmony: int = 10, assay: Optional[str] = None, seed: int = 0) -> None

Run Harmony batch correction on an existing reduction.

Mirrors R's RunHarmony(obj, group.by.vars = "batch"). Takes the cell embeddings of reduction (PCA by default), removes batch effects with harmonypy, and stores the corrected embeddings as a new DimReduc under reduction_name — same shape as the input, so it can be passed straight to find_neighbors(reduction="harmony") / run_umap(reduction="harmony").

Parameters:

  • group_by (Union[str, list[str]]) –

    metadata column(s) identifying the batch(es) to correct

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

    source reduction to correct (default 'pca')

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

    which dimensions of reduction to use (0-indexed; default all available)

  • reduction_name (str, default: 'harmony' ) –

    storage key for the corrected reduction

  • theta (Optional[Union[float, list[float]]], default: None ) –

    diversity clustering penalty (harmonypy default when None)

  • lambda_ (Optional[Union[float, list[float]]], default: None ) –

    ridge regression penalty (harmonypy default when None)

  • sigma (float, default: 0.1 ) –

    soft-clustering width

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

    number of Harmony clusters (harmonypy default when None)

  • max_iter_harmony (int, default: 10 ) –

    maximum Harmony iterations

  • seed (int, default: 0 ) –

    random seed for reproducibility

Source code in truecell/integration.py
def run_harmony(
    seurat,
    group_by: Union[str, list[str]],
    reduction: str = "pca",
    dims: Optional[Union[list[int], range]] = None,
    reduction_name: str = "harmony",
    reduction_key: str = "harmony_",
    theta: Optional[Union[float, list[float]]] = None,
    lambda_: Optional[Union[float, list[float]]] = None,
    sigma: float = 0.1,
    nclust: Optional[int] = None,
    max_iter_harmony: int = 10,
    assay: Optional[str] = None,
    seed: int = 0,
) -> None:
    """Run Harmony batch correction on an existing reduction.

    Mirrors R's ``RunHarmony(obj, group.by.vars = "batch")``. Takes the cell
    embeddings of ``reduction`` (PCA by default), removes batch effects with
    ``harmonypy``, and stores the corrected embeddings as a new DimReduc under
    ``reduction_name`` — same shape as the input, so it can be passed straight
    to ``find_neighbors(reduction="harmony")`` / ``run_umap(reduction="harmony")``.

    Parameters
    ----------
    group_by         : metadata column(s) identifying the batch(es) to correct
    reduction        : source reduction to correct (default 'pca')
    dims             : which dimensions of ``reduction`` to use (0-indexed;
                       default all available)
    reduction_name   : storage key for the corrected reduction
    theta            : diversity clustering penalty (harmonypy default when None)
    lambda_          : ridge regression penalty (harmonypy default when None)
    sigma            : soft-clustering width
    nclust           : number of Harmony clusters (harmonypy default when None)
    max_iter_harmony : maximum Harmony iterations
    seed             : random seed for reproducibility
    """
    try:
        import harmonypy
    except ImportError as exc:  # pragma: no cover - exercised only without the dep
        raise ImportError(
            "run_harmony requires 'harmonypy'. Install it with "
            "`pip install truecell[integration]` or `pip install harmonypy`."
        ) from exc

    assay_name = assay or seurat.active_assay

    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 not None:
        embeddings = embeddings[:, list(dims)]

    group_vars = [group_by] if isinstance(group_by, str) else list(group_by)
    missing = [g for g in group_vars if g not in seurat.meta_data.columns]
    if missing:
        raise KeyError(
            f"group_by column(s) {missing} not found in meta_data."
        )
    meta = seurat.meta_data[group_vars]

    n_cells = embeddings.shape[0]

    np.random.seed(seed)
    harmony_obj = harmonypy.run_harmony(
        embeddings,
        meta,
        group_vars,
        theta=theta,
        lamb=lambda_,
        sigma=sigma,
        nclust=nclust,
        max_iter_harmony=max_iter_harmony,
        random_state=seed,
    )
    # harmonypy stores corrected embeddings as (dims × cells); orient robustly
    # to (cells × dims) by matching the known cell count.
    corrected = np.asarray(harmony_obj.Z_corr)
    if corrected.shape[0] != n_cells and corrected.shape[1] == n_cells:
        corrected = corrected.T

    cells = seurat.cell_names()
    dim_names = [f"{reduction_key}{i + 1}" for i in range(corrected.shape[1])]

    seurat.reductions[reduction_name] = DimReduc(
        cell_embeddings=corrected,
        assay_used=assay_name,
        key=reduction_key,
        cell_names=cells,
        feature_names=dim_names,
    )

Anchors, directly

find_integration_anchors

find_integration_anchors(objects: list, anchor_features: Optional[list[str]] = None, reduction: str = 'cca', dims: int = 30, k_anchor: int = 5, k_filter: int = 200, k_score: int = 30, reference: int = 0, layer: str = 'scale.data', seed: int = 42) -> IntegrationAnchors

Find anchors linking each dataset to the reference (Seurat's FindIntegrationAnchors).

Mirrors FindIntegrationAnchors(object.list, reduction = "cca"). Anchors are mutual nearest neighbours in a shared CCA (or reciprocal-PCA) space, scored by neighbourhood consistency and filtered against the original expression space.

Parameters:

  • objects (list) –

    list of Truecell objects (each normalized + with variable features / scaled data). objects[reference] is treated as the reference every other dataset is anchored to.

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

    features to integrate on (default: variable features shared across all objects).

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

    "cca" or "rpca".

  • dims (int, default: 30 ) –

    number of shared dimensions to use.

  • k_anchor (int, default: 5 ) –

    neighbours for the mutual-nearest-neighbour search.

  • k_filter (int, default: 200 ) –

    neighbourhood size for the feature-space anchor filter (set to 0 or None to skip filtering).

  • k_score (int, default: 30 ) –

    neighbourhood size for anchor scoring.

  • reference (int, default: 0 ) –

    index of the reference dataset in objects.

  • layer (str, default: 'scale.data' ) –

    layer to draw the shared-space expression from.

  • seed (int, default: 42 ) –

    random seed for the PCA/neighbour steps.

Returns:

Source code in truecell/anchors.py
def find_integration_anchors(
    objects: list,
    anchor_features: Optional[list[str]] = None,
    reduction: str = "cca",
    dims: int = 30,
    k_anchor: int = 5,
    k_filter: int = 200,
    k_score: int = 30,
    reference: int = 0,
    layer: str = "scale.data",
    seed: int = 42,
) -> IntegrationAnchors:
    """Find anchors linking each dataset to the reference (Seurat's ``FindIntegrationAnchors``).

    Mirrors ``FindIntegrationAnchors(object.list, reduction = "cca")``. Anchors
    are mutual nearest neighbours in a shared CCA (or reciprocal-PCA) space,
    scored by neighbourhood consistency and filtered against the original
    expression space.

    Parameters
    ----------
    objects         : list of Truecell objects (each normalized + with variable
                      features / scaled data). ``objects[reference]`` is treated
                      as the reference every other dataset is anchored to.
    anchor_features : features to integrate on (default: variable features
                      shared across all objects).
    reduction       : ``"cca"`` or ``"rpca"``.
    dims            : number of shared dimensions to use.
    k_anchor        : neighbours for the mutual-nearest-neighbour search.
    k_filter        : neighbourhood size for the feature-space anchor filter
                      (set to 0 or None to skip filtering).
    k_score         : neighbourhood size for anchor scoring.
    reference       : index of the reference dataset in ``objects``.
    layer           : layer to draw the shared-space expression from.
    seed            : random seed for the PCA/neighbour steps.

    Returns
    -------
    IntegrationAnchors
    """
    reduction = reduction.lower()
    if reduction not in REDUCTIONS:
        raise ValueError(
            f"Unknown reduction {reduction!r}. Supported: {REDUCTIONS}."
        )
    if len(objects) < 2:
        raise ValueError("find_integration_anchors needs at least two objects.")
    if not 0 <= reference < len(objects):
        raise IndexError(f"reference index {reference} out of range.")

    features = _integration_features(objects, anchor_features, layer)

    # Both reductions start from the per-object scale.data. CCA standardizes it
    # per cell inside _cca; reciprocal PCA runs straight on it (Seurat's
    # per-object RunPCA never column-normalizes for pca-based reductions).
    ref_raw = _anchor_feature_matrix(objects[reference], features, layer)

    rows = []
    weight_embeddings: dict[int, np.ndarray] = {}
    used_dims = min(dims, ref_raw.shape[1] - 1, ref_raw.shape[0])

    for d in range(len(objects)):
        if d == reference:
            continue
        query_raw = _anchor_feature_matrix(objects[d], features, layer)
        used = min(used_dims, query_raw.shape[1] - 1)

        if reduction == "cca":
            # CheckFeatures: RunCCA drops anchor features that are constant in
            # either object before standardizing, so the CCA and its loadings
            # live on this subset.
            keep = _check_features([ref_raw, query_raw])
            emb_ref, emb_query, loadings = _cca(ref_raw[keep], query_raw[keep], used)
            nbrs = _neighbor_sets(emb_ref, emb_query, max(k_anchor, k_score))
            pairs = _mutual_nn_from(nbrs, k_anchor)
            filter_feats = [features[keep[t]] for t in _top_dim_features(loadings)]
            weight_emb = emb_query
        else:  # rpca — reciprocal PCA projections (Seurat's ReciprocalProject)
            # The reciprocal spaces below are only as good as these loadings,
            # and reciprocal PCA standardizes each dimension before the
            # neighbour search (see _pca_loadings), so the trailing PCs have to
            # be right, not just the leading ones. Feeding this the exact SVD
            # loadings takes RPCA anchor agreement with Seurat from 45% to full.
            load_ref = _pca_loadings(ref_raw, used, seed=seed)
            load_query = _pca_loadings(query_raw, used, seed=seed)
            ref_in_ref = ref_raw.T @ load_ref        # ref cells, ref PCA
            query_in_ref = query_raw.T @ load_ref     # query projected into ref PCA
            ref_in_query = ref_raw.T @ load_query     # ref projected into query PCA
            query_in_query = query_raw.T @ load_query  # query cells, query PCA
            # Each object in its own unnormalized PCA — Seurat's plain "pca"
            # reduction, which is what the within-dataset neighbour tables use.
            own_ref, own_query = ref_in_ref, query_in_query

            # Seurat forms each reciprocal space as the stacked [ref; query] and,
            # with l2.norm=TRUE (its default), standardizes every dimension by its
            # SD over the whole stack before L2-normalizing each cell. Skipping
            # that let PC1's dominant variance swamp the neighbour search, so the
            # mutual pairs were wrong — RPCA under-integrated ifnb 4x (batch-mix
            # 0.22 vs Seurat's 0.91). Normalize each space, then split it back
            # into its ref/query halves for the reciprocal MNN.
            n_ref_cells = ref_in_ref.shape[0]
            ref_space = _standardize_and_l2(np.vstack([ref_in_ref, query_in_ref]))
            query_space = _standardize_and_l2(np.vstack([ref_in_query, query_in_query]))
            ref_in_ref, query_in_ref = ref_space[:n_ref_cells], ref_space[n_ref_cells:]
            ref_in_query, query_in_query = query_space[:n_ref_cells], query_space[n_ref_cells:]

            # Reciprocal search: find B(query)-neighbours of A(ref) in the query's
            # PCA space, and A-neighbours of B in the ref's space. The four
            # neighbour tables ScoreAnchors needs are asymmetric here — the
            # within-dataset ones come from each object's *own* PCA (Seurat's
            # nn.reduction stays "pca" on the rpca branch, so it never sees a
            # reciprocal space), the across-dataset ones from the reciprocal
            # projections. Getting the index spaces the wrong way round silently
            # mismatches them and, when n_query > n_ref, runs off the end.
            k_neighbor = max(k_anchor, k_score)
            nbrs = {
                "aa": _nearest(own_ref, own_ref, min(k_neighbor + 1, own_ref.shape[0])),
                "bb": _nearest(own_query, own_query,
                               min(k_neighbor + 1, own_query.shape[0])),
                "ab": _nearest(ref_in_query, query_in_query, k_neighbor),
                "ba": _nearest(query_in_ref, ref_in_ref, k_neighbor),
            }
            pairs = _mutual_nn_from(nbrs, k_anchor)
            filter_feats = None
            weight_emb = query_in_ref

        # FindAnchors filters BEFORE it scores, and the score is rescaled
        # against the 1st/90th percentiles of whatever set it is handed. Scoring
        # first sets those percentiles from anchors that are about to be thrown
        # away, which shifts every surviving score — same ranking, wrong values.
        #
        # Seurat forces k.filter <- NA for reciprocal-PCA (every pca-based
        # nn.reduction), skipping the expression-space filter entirely: the
        # reciprocal projection is itself an expression-space check, and
        # filtering on the shared anchor features here drops good anchors and
        # leaves RPCA under-integrating. Only CCA keeps the filter.
        if k_filter and filter_feats:
            pairs = _filter_anchors(
                pairs,
                _data_matrix(objects[reference], filter_feats),
                _data_matrix(objects[d], filter_feats),
                k_filter,
            )
        n_ref = ref_raw.shape[1]
        scores = _score_anchors(pairs, nbrs, n_ref, k_score)

        weight_embeddings[d] = weight_emb
        for (i, j), s in zip(pairs, scores):
            rows.append((reference, int(i), d, int(j), float(s)))

    anchors = pd.DataFrame(
        rows, columns=["dataset1", "cell1", "dataset2", "cell2", "score"]
    )
    return IntegrationAnchors(
        anchors=anchors,
        objects=objects,
        reference=reference,
        reduction=reduction,
        anchor_features=features,
        dims=used_dims,
        weight_embeddings=weight_embeddings,
    )

integrate_embeddings

integrate_embeddings(anchors: IntegrationAnchors, reduction, new_reduction: str = 'integrated_dr', dims_to_integrate: Optional[list[int]] = None, k_weight: int = 100, sd_weight: float = 1.0) -> DimReduc

Batch-correct an existing reduction (Seurat's IntegrateEmbeddings).

The v5 counterpart to integrate_data, and a genuinely different algorithm rather than a wrapper over it. IntegrateData corrects expression and leaves you to re-scale and re-run PCA; IntegrateEmbeddings corrects the embedding itself, so the output lives in the input reduction's basis and keeps its loadings.

Seurat implements it by transposing the embedding into a fake assay whose "features" are the dimensions (drtointegrate-1 …) and pushing that through the very same anchor machinery, which is why this shares _anchor_weights with integrate_data. The one substantive difference is the weight space: RunIntegration's dims = NULL branch hands FindWeights the drtointegrate matrix itself, so neighbours are measured in the uncorrected embedding — not in the fresh per-pair PCA that the expression path builds.

Parameters:

  • anchors (IntegrationAnchors) –
  • reduction

    a DimReduc covering every cell in anchors.objects — the reduction to correct.

  • new_reduction (str, default: 'integrated_dr' ) –

    key for the returned reduction.

  • dims_to_integrate (Optional[list[int]], default: None ) –

    which dimensions to correct (0-indexed; default all).

  • k_weight (int, default: 100 ) –

    anchors used to weight each query cell's correction.

  • sd_weight (float, default: 1.0 ) –

    bandwidth of the anchor kernel; enters as (2/sd)².

Returns:

  • DimReduc

    The corrected embedding, cells in merged order (reference first).

Source code in truecell/anchors.py
def integrate_embeddings(
    anchors: IntegrationAnchors,
    reduction,
    new_reduction: str = "integrated_dr",
    dims_to_integrate: Optional[list[int]] = None,
    k_weight: int = 100,
    sd_weight: float = 1.0,
) -> DimReduc:
    """Batch-correct an existing reduction (Seurat's ``IntegrateEmbeddings``).

    The v5 counterpart to :func:`integrate_data`, and a genuinely different
    algorithm rather than a wrapper over it. ``IntegrateData`` corrects
    *expression* and leaves you to re-scale and re-run PCA; ``IntegrateEmbeddings``
    corrects the **embedding itself**, so the output lives in the input
    reduction's basis and keeps its loadings.

    Seurat implements it by transposing the embedding into a fake assay whose
    "features" are the dimensions (``drtointegrate-1 …``) and pushing that
    through the very same anchor machinery, which is why this shares
    :func:`_anchor_weights` with :func:`integrate_data`. The one substantive
    difference is the weight space: ``RunIntegration``'s ``dims = NULL`` branch
    hands ``FindWeights`` the ``drtointegrate`` matrix itself, so neighbours are
    measured in the *uncorrected embedding* — not in the fresh per-pair PCA
    that the expression path builds.

    Parameters
    ----------
    anchors           : an :class:`IntegrationAnchors` from
                        :func:`find_integration_anchors`.
    reduction         : a :class:`DimReduc` covering every cell in
                        ``anchors.objects`` — the reduction to correct.
    new_reduction     : key for the returned reduction.
    dims_to_integrate : which dimensions to correct (0-indexed; default all).
    k_weight          : anchors used to weight each query cell's correction.
    sd_weight         : bandwidth of the anchor kernel; enters as ``(2/sd)²``.

    Returns
    -------
    DimReduc
        The corrected embedding, cells in merged order (reference first).
    """
    objects = anchors.objects
    ref = anchors.reference

    emb = np.asarray(reduction.cell_embeddings)
    pos = {c: i for i, c in enumerate(reduction.cells())}
    dims = list(range(emb.shape[1])) if dims_to_integrate is None \
        else list(dims_to_integrate)

    missing = [c for o in objects for c in o.cell_names() if c not in pos]
    if missing:
        raise ValueError(
            f"{len(missing)} cell(s) in the anchor objects are absent from "
            f"reduction {reduction.key!r} (first: {missing[0]!r}). "
            "IntegrateEmbeddings needs a reduction spanning every dataset."
        )

    def block(d):
        """The dataset's embedding as dims × cells — Seurat's drtointegrate."""
        cells = objects[d].cell_names()
        return emb[[pos[c] for c in cells]][:, dims].T

    order = [ref] + [d for d in range(len(objects)) if d != ref]
    ref_block = block(ref)
    blocks = {ref: ref_block}

    for d in order[1:]:
        query_block = block(d)
        pair = anchors.anchors[anchors.anchors["dataset2"] == d]
        if len(pair) == 0:
            blocks[d] = query_block
            continue
        i_idx = pair["cell1"].to_numpy()
        j_idx = pair["cell2"].to_numpy()
        bv = ref_block[:, i_idx] - query_block[:, j_idx]  # dims × n_anchor
        weights = _anchor_weights(
            query_block.T, j_idx, pair["score"].to_numpy(), k_weight, sd_weight
        )
        blocks[d] = query_block + bv @ weights

    corrected = np.hstack([blocks[d] for d in order]).T  # cells × dims
    cells = [c for d in order for c in objects[d].cell_names()]
    loadings = None
    if reduction.feature_loadings is not None and len(reduction.feature_loadings):
        loadings = np.asarray(reduction.feature_loadings)[:, dims]
    key = f"{new_reduction.replace('_', '').replace('.', '')}_"
    return DimReduc(
        cell_embeddings=corrected,
        feature_loadings=loadings,
        assay_used=reduction.assay_used,
        key=key,
        cell_names=cells,
        feature_names=(reduction.features() if loadings is not None else None),
    )

integrate_data

integrate_data(anchors: IntegrationAnchors, new_assay: str = 'integrated', k_weight: int = 100, sd_weight: float = 1.0, add_cell_ids: Optional[list[str]] = None, seed: int = 42) -> 'object'

Batch-correct query datasets onto the reference (Seurat's IntegrateData).

Mirrors IntegrateData(anchors). For every query dataset, each cell is corrected by a distance-weighted sum of anchor correction vectors (expr_ref − expr_query); the reference is left unchanged. The corrected expression of the anchor features is stored as the data layer of a new "integrated" assay on a merged object, which becomes the active assay.

Downstream: scale_data + run_pca on the integrated assay yields an embedding that clusters by cell type rather than by batch.

Parameters:

  • anchors (IntegrationAnchors) –
  • new_assay (str, default: 'integrated' ) –

    name for the corrected assay (default "integrated").

  • k_weight (int, default: 100 ) –

    anchors used to weight each query cell's correction. Counts anchors, not anchor cells — see _anchor_weights.

  • sd_weight (float, default: 1.0 ) –

    bandwidth of the anchor kernel; enters as (2/sd_weight)².

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

    optional per-object prefixes for the merged cell names.

  • seed (int, default: 42 ) –

    random seed for the per-pair weight PCA.

Returns:

  • Truecell

    A merged object carrying the new_assay assay (active) alongside the original assay.

Source code in truecell/anchors.py
def integrate_data(
    anchors: IntegrationAnchors,
    new_assay: str = "integrated",
    k_weight: int = 100,
    sd_weight: float = 1.0,
    add_cell_ids: Optional[list[str]] = None,
    seed: int = 42,
) -> "object":
    """Batch-correct query datasets onto the reference (Seurat's ``IntegrateData``).

    Mirrors ``IntegrateData(anchors)``. For every query dataset, each cell is
    corrected by a distance-weighted sum of anchor correction vectors
    (``expr_ref − expr_query``); the reference is left unchanged. The corrected
    expression of the anchor features is stored as the ``data`` layer of a new
    ``"integrated"`` assay on a merged object, which becomes the active assay.

    Downstream: ``scale_data`` + ``run_pca`` on the integrated assay yields an
    embedding that clusters by cell type rather than by batch.

    Parameters
    ----------
    anchors      : an :class:`IntegrationAnchors` from
                   :func:`find_integration_anchors`.
    new_assay    : name for the corrected assay (default ``"integrated"``).
    k_weight     : anchors used to weight each query cell's correction. Counts
                   anchors, not anchor cells — see :func:`_anchor_weights`.
    sd_weight    : bandwidth of the anchor kernel; enters as ``(2/sd_weight)²``.
    add_cell_ids : optional per-object prefixes for the merged cell names.
    seed         : random seed for the per-pair weight PCA.

    Returns
    -------
    Truecell
        A merged object carrying the ``new_assay`` assay (active) alongside the
        original assay.
    """
    from .assay import Assay

    objects = anchors.objects
    ref = anchors.reference
    features = anchors.anchor_features

    # Merge order: reference first, then the remaining datasets in list order.
    order = [ref] + [d for d in range(len(objects)) if d != ref]
    ref_obj = objects[ref]
    others = [objects[d] for d in order[1:]]

    if add_cell_ids is not None:
        ordered_ids = [add_cell_ids[d] for d in order]
    else:
        ordered_ids = None

    merged = ref_obj.merge(others, add_cell_ids=ordered_ids)

    ref_data = _data_matrix(ref_obj, features)  # features × cells_ref (unchanged)
    corrected_blocks = [ref_data]

    for d in order[1:]:
        query_data = _data_matrix(objects[d], features)  # features × cells_q
        pair = anchors.anchors[anchors.anchors["dataset2"] == d]

        if len(pair) == 0:
            # No anchors to this dataset — leave it uncorrected.
            corrected_blocks.append(query_data)
            continue

        i_idx = pair["cell1"].to_numpy()
        j_idx = pair["cell2"].to_numpy()
        anchor_scores = pair["score"].to_numpy()

        # Correction vectors in feature space: reference minus query at anchors.
        bv = ref_data[:, i_idx] - query_data[:, j_idx]  # features × n_anchor

        # The weights live in a PCA of *this pair*, not in the anchor space.
        # RunIntegration merges the reference and query, re-scales on the anchor
        # features and runs a fresh PCA, and FindWeights searches there. Reusing
        # the CCA embedding instead measures distances in a space built to make
        # the batches overlap, which is not the same neighbourhood.
        query_emb = _pair_weight_embedding(
            ref_obj, objects[d], features, anchors.dims, seed
        )
        weights = _anchor_weights(
            query_emb, j_idx, anchor_scores, k_weight, sd_weight
        )
        correction = bv @ weights           # features × cells_q
        corrected_blocks.append(query_data + correction)

    integrated = np.hstack(corrected_blocks)  # features × total_cells
    cell_names = merged.cell_names()

    integrated_assay = Assay(
        data=integrated,
        feature_names=list(features),
        cell_names=list(cell_names),
        var_features=list(features),
        key=f"{new_assay.lower()}_",
    )
    merged.assays[new_assay] = integrated_assay
    merged.active_assay = new_assay
    return merged

IntegrationAnchors

IntegrationAnchors(anchors: DataFrame, objects: list, reference: int, reduction: str, anchor_features: list[str], dims: int, weight_embeddings: dict[int, ndarray])

Anchors linking a reference dataset to one or more query datasets.

Slots
  • anchors — DataFrame with columns dataset1, cell1, dataset2, cell2, score. dataset1 is always the reference; the cell columns hold within-dataset 0-based row indices.
  • objects — the list of Truecell objects passed to find_integration_anchors (order preserved).
  • reference — index into objects of the reference dataset.
  • reduction"cca" or "rpca" — how the shared space was built.
  • anchor_features — the features the anchors (and correction) run on.
  • dims — number of shared dimensions used.
  • weight_embeddings{query_index: (n_query_cells × dims) array} — each query dataset's cells in the shared anchor space. Kept for inspection; integrate_data does not weight with it, because Seurat weights in a fresh PCA of the merged pair instead.
Source code in truecell/anchors.py
def __init__(
    self,
    anchors: pd.DataFrame,
    objects: list,
    reference: int,
    reduction: str,
    anchor_features: list[str],
    dims: int,
    weight_embeddings: dict[int, np.ndarray],
) -> None:
    self.anchors = anchors
    self.objects = objects
    self.reference = reference
    self.reduction = reduction
    self.anchor_features = list(anchor_features)
    self.dims = dims
    self.weight_embeddings = weight_embeddings

Reference mapping

find_transfer_anchors

find_transfer_anchors(reference, query, anchor_features: Optional[list[str]] = None, reduction: str = 'pcaproject', dims: int = 30, k_anchor: int = 5, k_filter: int = 200, k_score: int = 30, layer: str = 'scale.data', seed: int = 42) -> TransferAnchors

Find transfer anchors from a reference to a query (Seurat's FindTransferAnchors).

Mirrors FindTransferAnchors(reference, query, reduction = "pcaproject"). Anchors are mutual nearest neighbours between the reference and the query in a shared space — by default the reference's own PCA, into which the query is projected — scored by neighbourhood consistency and filtered against the original expression space.

Parameters:

  • reference

    annotated reference Truecell object (normalized + with variable features / scaled data).

  • query

    query Truecell object to annotate (same preprocessing).

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

    features to anchor on (default: variable features shared by reference and query).

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

    "pcaproject" (project the query into the reference's PCA; the default and most robust for annotation) or "cca" (a jointly-learned space, for harder cross-modality/species cases). The reference PCA is computed on the shared anchor features, so the reference need not already carry a pca reduction.

  • dims (int, default: 30 ) –

    number of shared dimensions to use.

  • k_anchor (int, default: 5 ) –

    neighbours for the mutual-nearest-neighbour search.

  • k_filter (int, default: 200 ) –

    neighbourhood size for the feature-space anchor filter (set to 0 or None to skip filtering).

  • k_score (int, default: 30 ) –

    neighbourhood size for anchor scoring.

  • layer (str, default: 'scale.data' ) –

    layer to draw the shared-space expression from.

  • seed (int, default: 42 ) –

    random seed for the PCA/neighbour steps.

Returns:

Source code in truecell/transfer.py
def find_transfer_anchors(
    reference,
    query,
    anchor_features: Optional[list[str]] = None,
    reduction: str = "pcaproject",
    dims: int = 30,
    k_anchor: int = 5,
    k_filter: int = 200,
    k_score: int = 30,
    layer: str = "scale.data",
    seed: int = 42,
) -> TransferAnchors:
    """Find transfer anchors from a reference to a query (Seurat's ``FindTransferAnchors``).

    Mirrors ``FindTransferAnchors(reference, query, reduction = "pcaproject")``.
    Anchors are mutual nearest neighbours between the reference and the query in
    a shared space — by default the reference's own PCA, into which the query is
    projected — scored by neighbourhood consistency and filtered against the
    original expression space.

    Parameters
    ----------
    reference       : annotated reference Truecell object (normalized + with
                      variable features / scaled data).
    query           : query Truecell object to annotate (same preprocessing).
    anchor_features : features to anchor on (default: variable features shared
                      by reference and query).
    reduction       : ``"pcaproject"`` (project the query into the reference's
                      PCA; the default and most robust for annotation) or
                      ``"cca"`` (a jointly-learned space, for harder
                      cross-modality/species cases). The reference PCA is
                      computed on the shared anchor features, so the reference
                      need not already carry a ``pca`` reduction.
    dims            : number of shared dimensions to use.
    k_anchor        : neighbours for the mutual-nearest-neighbour search.
    k_filter        : neighbourhood size for the feature-space anchor filter
                      (set to 0 or None to skip filtering).
    k_score         : neighbourhood size for anchor scoring.
    layer           : layer to draw the shared-space expression from.
    seed            : random seed for the PCA/neighbour steps.

    Returns
    -------
    TransferAnchors
    """
    reduction = reduction.lower()
    if reduction not in REDUCTIONS:
        raise ValueError(
            f"Unknown reduction {reduction!r}. Supported: {REDUCTIONS}."
        )

    features = _integration_features([reference, query], anchor_features)

    ref_scaled = _l2_normalize_cols(
        _anchor_feature_matrix(reference, features, layer)
    )
    query_scaled = _l2_normalize_cols(
        _anchor_feature_matrix(query, features, layer)
    )
    if ref_scaled.shape[0] != query_scaled.shape[0]:
        raise ValueError(
            "Reference and query disagree on the number of anchor features "
            f"({ref_scaled.shape[0]} vs {query_scaled.shape[0]}); ensure the "
            "anchor features are scaled in both objects."
        )

    used = min(
        dims,
        ref_scaled.shape[1] - 1,
        query_scaled.shape[1] - 1,
        ref_scaled.shape[0],
    )

    if reduction == "pcaproject":
        # Project the query through the reference's PCA loadings — both datasets
        # end up in the reference's principal-component space.
        load_ref = _pca_loadings(ref_scaled, used, seed=seed)
        ref_emb = _l2_normalize_rows(ref_scaled.T @ load_ref)
        query_emb = _l2_normalize_rows(query_scaled.T @ load_ref)
    else:  # cca — a jointly-learned shared space
        ref_emb, query_emb, _loadings = _cca(ref_scaled, query_scaled, used)

    # FindTransferAnchors goes through the same FindAnchors internals as
    # FindIntegrationAnchors, so it gets the same four-way neighbour tables.
    nbrs = _neighbor_sets(ref_emb, query_emb, max(k_anchor, k_score))
    pairs = _mutual_nn_from(nbrs, k_anchor)
    n_ref = ref_emb.shape[0]
    # Filter first: the score is rescaled against the percentiles of the set it
    # is handed, so scoring before filtering shifts every surviving score.
    if k_filter:
        pairs = _filter_anchors(pairs, ref_scaled, query_scaled, k_filter)
    scores = _score_anchors(pairs, nbrs, n_ref, k_score)

    rows = [(int(i), int(j), float(s)) for (i, j), s in zip(pairs, scores)]
    anchors = pd.DataFrame(rows, columns=["cell1", "cell2", "score"])

    return TransferAnchors(
        anchors=anchors,
        reference=reference,
        query=query,
        reduction=reduction,
        anchor_features=features,
        dims=used,
        query_embedding=query_emb,
    )

transfer_data

transfer_data(anchors: TransferAnchors, refdata: Union[str, ndarray, list, Series], k_weight: int = 50, sd_weight: float = 1.0, refdata_features: Optional[list[str]] = None) -> DataFrame

Transfer labels or expression from reference to query (Seurat's TransferData).

Mirrors TransferData(anchorset, refdata = "celltype"). Each query cell gets a weight over the anchors — a distance-weighted, anchor-score-scaled Gaussian kernel in the shared space (the same weighting IntegrateData uses) — and the reference information is carried across those weights.

refdata selects the mode:

  • classification — a metadata column name (str) or a 1-D array of per-reference-cell labels. Returns a DataFrame indexed by query cell with predicted.id, one prediction.score.<class> column per reference class (each query cell's rows sum to 1), and prediction.score.max.
  • imputation — a 2-D features × reference-cells matrix. Returns a DataFrame of predicted query expression (features × query cells); name the rows with refdata_features.

Parameters:

  • anchors (TransferAnchors) –
  • refdata (Union[str, ndarray, list, Series]) –

    reference labels (str column / 1-D array) or a 2-D features × reference-cells matrix to impute.

  • k_weight (int, default: 50 ) –

    anchors used to weight each query cell.

  • sd_weight (float, default: 1.0 ) –

    bandwidth multiplier for the Gaussian anchor kernel.

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

    row names for the imputation output (2-D refdata).

Returns:

  • DataFrame
Source code in truecell/transfer.py
def transfer_data(
    anchors: TransferAnchors,
    refdata: Union[str, np.ndarray, list, pd.Series],
    k_weight: int = 50,
    sd_weight: float = 1.0,
    refdata_features: Optional[list[str]] = None,
) -> pd.DataFrame:
    """Transfer labels or expression from reference to query (Seurat's ``TransferData``).

    Mirrors ``TransferData(anchorset, refdata = "celltype")``. Each query cell
    gets a weight over the anchors — a distance-weighted, anchor-score-scaled
    Gaussian kernel in the shared space (the same weighting ``IntegrateData``
    uses) — and the reference information is carried across those weights.

    ``refdata`` selects the mode:

    * **classification** — a metadata column name (``str``) or a 1-D array of
      per-reference-cell labels. Returns a DataFrame indexed by query cell with
      ``predicted.id``, one ``prediction.score.<class>`` column per reference
      class (each query cell's rows sum to 1), and ``prediction.score.max``.
    * **imputation** — a 2-D ``features × reference-cells`` matrix. Returns a
      DataFrame of predicted query expression (features × query cells); name the
      rows with ``refdata_features``.

    Parameters
    ----------
    anchors          : a :class:`TransferAnchors` from
                       :func:`find_transfer_anchors`.
    refdata          : reference labels (str column / 1-D array) or a 2-D
                       ``features × reference-cells`` matrix to impute.
    k_weight         : anchors used to weight each query cell.
    sd_weight        : bandwidth multiplier for the Gaussian anchor kernel.
    refdata_features : row names for the imputation output (2-D ``refdata``).

    Returns
    -------
    pandas.DataFrame
    """
    anchor_df = anchors.anchors
    if len(anchor_df) == 0:
        raise ValueError(
            "No anchors between reference and query; cannot transfer. Try a "
            "larger k_anchor or k_filter=0 in find_transfer_anchors."
        )

    query_cells = anchors.query.cell_names()
    ref_cell1 = anchor_df["cell1"].to_numpy()
    query_cell2 = anchor_df["cell2"].to_numpy()
    anchor_scores = anchor_df["score"].to_numpy()

    weights = _anchor_weight_matrix(
        anchors.query_embedding, query_cell2, anchor_scores, k_weight, sd_weight
    )  # (n_query × n_anchor)

    classification = isinstance(refdata, str) or np.ndim(refdata) == 1
    if classification:
        return _transfer_labels(
            anchors.reference, refdata, ref_cell1, weights, query_cells
        )
    return _transfer_expression(
        refdata, refdata_features, ref_cell1, weights, query_cells
    )

TransferAnchors

TransferAnchors(anchors: DataFrame, reference, query, reduction: str, anchor_features: list[str], dims: int, query_embedding: ndarray)

Anchors linking a fixed reference to a query, for label/data transfer.

Slots
  • anchors — DataFrame with columns cell1, cell2, score. cell1 is a within-reference 0-based cell index, cell2 a within-query one; the reference is never moved.
  • reference — the reference Truecell object (annotated atlas).
  • query — the query Truecell object (to be annotated).
  • reduction"pcaproject" or "cca" — how the shared space was built.
  • anchor_features — the features the anchors run on.
  • dims — number of shared dimensions used.
  • query_embedding(n_query_cells × dims) — the query cells in the shared space, used by transfer_data to weight anchors.
Source code in truecell/transfer.py
def __init__(
    self,
    anchors: pd.DataFrame,
    reference,
    query,
    reduction: str,
    anchor_features: list[str],
    dims: int,
    query_embedding: np.ndarray,
) -> None:
    self.anchors = anchors
    self.reference = reference
    self.query = query
    self.reduction = reduction
    self.anchor_features = list(anchor_features)
    self.dims = dims
    self.query_embedding = query_embedding

map_query

map_query(anchors: TransferAnchors, refdata: Optional[Union[str, ndarray, list, Series]] = None, reference_reduction: str = 'pca', reduction_model: str = 'umap', reduction_name: str = 'ref.umap', reduction_key: str = 'refUMAP_', k_weight: int = 50, sd_weight: float = 1.0, refdata_features: Optional[list[str]] = None, layer: str = 'scale.data') -> Optional[DataFrame]

Annotate and place a query in a reference (Seurat's MapQuery).

Mirrors MapQuery(anchorset, query, reference, refdata = "celltype"). The single call that turns transfer anchors into a mapped query:

  1. transfer_data carries refdata across the anchors, and — for a categorical label — writes predicted.id / prediction.score.* straight onto query.meta_data.
  2. project_umap projects the query into the reference's UMAP, stored as query.reductions[reduction_name].

Both steps mutate the query object (the anchors' query) in place.

Parameters:

  • anchors (TransferAnchors) –
  • refdata (Optional[Union[str, ndarray, list, Series]], default: None ) –

    reference labels to transfer (metadata column name or a per-reference-cell array) or a 2-D features × reference-cells matrix to impute. Pass None to skip transfer and only project the UMAP.

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

    reference reduction whose loadings project the query into the UMAP's input space (default "pca").

  • reduction_model (str, default: 'umap' ) –

    reference reduction holding the fitted UMAP model.

  • reduction_name (str, default: 'ref.umap' ) –

    storage key for the projection in query.reductions.

  • reduction_key (str, default: 'refUMAP_' ) –

    prefix for the projected dimension names.

  • k_weight (int, default: 50 ) –

    anchor-weighting knobs passed to transfer_data.

  • sd_weight (int, default: 50 ) –

    anchor-weighting knobs passed to transfer_data.

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

    row names for imputation output (2-D refdata).

  • layer (str, default: 'scale.data' ) –

    layer to draw the query's expression from.

Returns:

  • DataFrame or None

    The transferred predictions (classification) or imputed expression (imputation); None when refdata is not given.

Source code in truecell/mapping.py
def map_query(
    anchors: TransferAnchors,
    refdata: Optional[Union[str, np.ndarray, list, pd.Series]] = None,
    reference_reduction: str = "pca",
    reduction_model: str = "umap",
    reduction_name: str = "ref.umap",
    reduction_key: str = "refUMAP_",
    k_weight: int = 50,
    sd_weight: float = 1.0,
    refdata_features: Optional[list[str]] = None,
    layer: str = "scale.data",
) -> Optional[pd.DataFrame]:
    """Annotate and place a query in a reference (Seurat's ``MapQuery``).

    Mirrors ``MapQuery(anchorset, query, reference, refdata = "celltype")``. The
    single call that turns transfer anchors into a mapped query:

    1. :func:`~truecell.transfer_data` carries ``refdata`` across the anchors, and
       — for a categorical label — writes ``predicted.id`` /
       ``prediction.score.*`` straight onto ``query.meta_data``.
    2. :func:`project_umap` projects the query into the reference's UMAP, stored
       as ``query.reductions[reduction_name]``.

    Both steps mutate the query object (the anchors' ``query``) in place.

    Parameters
    ----------
    anchors             : a :class:`~truecell.TransferAnchors` from
                          :func:`~truecell.find_transfer_anchors`.
    refdata             : reference labels to transfer (metadata column name or a
                          per-reference-cell array) or a 2-D
                          ``features × reference-cells`` matrix to impute. Pass
                          ``None`` to skip transfer and only project the UMAP.
    reference_reduction : reference reduction whose loadings project the query
                          into the UMAP's input space (default ``"pca"``).
    reduction_model     : reference reduction holding the fitted UMAP model.
    reduction_name      : storage key for the projection in ``query.reductions``.
    reduction_key       : prefix for the projected dimension names.
    k_weight, sd_weight : anchor-weighting knobs passed to
                          :func:`~truecell.transfer_data`.
    refdata_features    : row names for imputation output (2-D ``refdata``).
    layer               : layer to draw the query's expression from.

    Returns
    -------
    pandas.DataFrame or None
        The transferred predictions (classification) or imputed expression
        (imputation); ``None`` when ``refdata`` is not given.
    """
    query = anchors.query

    predictions: Optional[pd.DataFrame] = None
    if refdata is not None:
        predictions = transfer_data(
            anchors,
            refdata,
            k_weight=k_weight,
            sd_weight=sd_weight,
            refdata_features=refdata_features,
        )
        classification = isinstance(refdata, str) or np.ndim(refdata) == 1
        if classification:
            # Write predicted.id / prediction.score.* onto the query metadata,
            # as Seurat's MapQuery does. Imputation output is returned, not stored.
            for col in predictions.columns:
                query.meta_data[col] = (
                    predictions[col].reindex(query.meta_data.index).to_numpy()
                )

    project_umap(
        query,
        anchors.reference,
        reduction=reference_reduction,
        umap_reduction=reduction_model,
        reduction_name=reduction_name,
        reduction_key=reduction_key,
        layer=layer,
    )
    return predictions

project_umap

project_umap(query, reference, reduction: str = 'pca', umap_reduction: str = 'umap', dims: Optional[Union[list[int], range]] = None, reduction_name: str = 'ref.umap', reduction_key: str = 'refUMAP_', layer: str = 'scale.data') -> DimReduc

Project a query into a reference's UMAP (Seurat's ProjectUMAP).

Mirrors ProjectUMAP(query, reference, reduction.model = "umap"). The query is first projected into the reference's PCA (through the reference's loadings), then run through the reference's fitted UMAP model in transform-only mode — so the query cells land in the reference's existing embedding rather than in a fresh, unrelated one. Stores the result as query.reductions[reduction_name] and returns it.

The reference must already carry both a PCA reduction (reduction, for the loadings) and a UMAP reduction (umap_reduction) fitted with a returnable model — i.e. run_umap(reference), which stashes the umap-learn model in reduction.misc["umap_model"].

Parameters:

  • query

    query Truecell object (normalized + scaled on the reference's PCA features).

  • reference

    reference Truecell object carrying the fitted PCA + UMAP.

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

    reference reduction whose loadings project the query (default "pca").

  • umap_reduction (str, default: 'umap' ) –

    reference reduction holding the fitted UMAP model (default "umap").

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

    which PCA dimensions to feed the UMAP model (0-indexed). Defaults to the dimensions the model was trained on.

  • reduction_name (str, default: 'ref.umap' ) –

    storage key for the projection in query.reductions.

  • reduction_key (str, default: 'refUMAP_' ) –

    prefix for the projected dimension names.

  • layer (str, default: 'scale.data' ) –

    layer to draw the query's expression from.

Returns:

  • DimReduc

    The query's cells in the reference UMAP; also stored on query.

Source code in truecell/mapping.py
def project_umap(
    query,
    reference,
    reduction: str = "pca",
    umap_reduction: str = "umap",
    dims: Optional[Union[list[int], range]] = None,
    reduction_name: str = "ref.umap",
    reduction_key: str = "refUMAP_",
    layer: str = "scale.data",
) -> DimReduc:
    """Project a query into a reference's UMAP (Seurat's ``ProjectUMAP``).

    Mirrors ``ProjectUMAP(query, reference, reduction.model = "umap")``. The
    query is first projected into the reference's PCA (through the reference's
    loadings), then run through the reference's *fitted* UMAP model in
    transform-only mode — so the query cells land in the reference's existing
    embedding rather than in a fresh, unrelated one. Stores the result as
    ``query.reductions[reduction_name]`` and returns it.

    The reference must already carry both a PCA reduction (``reduction``, for the
    loadings) and a UMAP reduction (``umap_reduction``) fitted with a returnable
    model — i.e. ``run_umap(reference)``, which stashes the ``umap-learn`` model
    in ``reduction.misc["umap_model"]``.

    Parameters
    ----------
    query          : query Truecell object (normalized + scaled on the reference's
                     PCA features).
    reference      : reference Truecell object carrying the fitted PCA + UMAP.
    reduction      : reference reduction whose loadings project the query
                     (default ``"pca"``).
    umap_reduction : reference reduction holding the fitted UMAP model (default
                     ``"umap"``).
    dims           : which PCA dimensions to feed the UMAP model (0-indexed).
                     Defaults to the dimensions the model was trained on.
    reduction_name : storage key for the projection in ``query.reductions``.
    reduction_key  : prefix for the projected dimension names.
    layer          : layer to draw the query's expression from.

    Returns
    -------
    DimReduc
        The query's cells in the reference UMAP; also stored on ``query``.
    """
    if reduction not in reference.reductions:
        raise KeyError(
            f"Reference reduction {reduction!r} not found; run run_pca(reference) "
            "first."
        )
    if umap_reduction not in reference.reductions:
        raise KeyError(
            f"Reference reduction {umap_reduction!r} not found; run "
            "run_umap(reference) first."
        )

    umap_dr = reference.reductions[umap_reduction]
    model = umap_dr.misc.get("umap_model")
    if model is None:
        raise ValueError(
            f"Reference reduction {umap_reduction!r} has no fitted UMAP model in "
            "misc['umap_model']; run_umap(reference) must be run from a reduction "
            "(not a graph) so the model is stored for transform-only projection."
        )

    query_pca = _project_into_reference_pca(query, reference, reduction, layer)
    query_pca = _select_model_dims(query_pca, model, dims)

    umap_coords = np.asarray(model.transform(query_pca))

    dim_names = [f"{reduction_key}{i + 1}" for i in range(umap_coords.shape[1])]
    projected = DimReduc(
        cell_embeddings=umap_coords,
        cell_names=query.cell_names(),
        feature_names=dim_names,
        assay_used=query.active_assay,
        key=reduction_key,
        misc={
            "projected_from": umap_reduction,
            "reference_reduction": reduction,
            "query_pca": query_pca,
        },
    )
    query.reductions[reduction_name] = projected
    return projected