Bringing your own embedding#

A learned embedding replaces the CellProfiler block with the output of a neural network: hundreds to a couple of thousand numbers per well that mean nothing individually. To mantispy it is a matrix like any other, so normalization, hit calling, consensus and the metrics run on it unchanged. What changes is what var can tell you, how per-field output is aggregated, and which preprocessing steps apply.

This page uses JUMP-Lite [Muñoz et al., 2026], where the same wells were measured by several models and by cp_measure, a CellProfiler-equivalent feature set. Learned embeddings against CellProfiler compares them.

import anndata as ad
import numpy as np
import pandas as pd

import mantispy as mt

adata = mt.ds.jump_lite("openphenom")
adata
AnnData object with n_obs × n_vars = 1536 × 384
    obs: 'Metadata_id', 'Metadata_Source', 'Metadata_Batch', 'Metadata_Plate', 'Metadata_Well', 'Metadata_Site', 'Metadata_model', 'Metadata_dataset', 'Metadata_compression', 'Metadata_CellCount', 'Metadata_JCP2022', 'Metadata_InChIKey', 'Metadata_Perturbation', 'Metadata_Control'
    var: 'object', 'feature_group', 'feature', 'channel', 'scale', 'angle', 'gray_levels', 'radial_bin', 'params', 'is_feature'
    uns: 'mantispy'
    layers: None (.X)

384 numbers per well from OpenPhenom, a Cell Painting foundation model. The metadata is ordinary JUMP metadata — source, batch, plate, well, and the compound each well received.

What var no longer tells you#

A CellProfiler feature name parses into a compartment, a feature group and a channel. openphenom_nahualX_17 has neither. The loader supplies the annotation columns the schema requires and leaves them empty, rather than letting the name parser loose on names with no structure in them — that parser would read openphenom_nahualX_17 as the nahualX group of an openphenom object, and the screen would quietly acquire feature families named after the model’s own tensors.

both = pd.concat({"openphenom": adata.var, "cp_measure": mt.ds.jump_lite("cp_measure").var}, names=["block"])
display(both.groupby(level="block", sort=False).head(3)[["object", "feature_group", "feature", "channel"]])
# Over every feature, not just the three above: how many are annotated, and with how many distinct values.
both.groupby(level="block", sort=False)[["object", "feature_group", "channel"]].agg(["count", "nunique"])
object feature_group feature channel
block
openphenom openphenom_nahualX_0 NaN NaN NaN NaN
openphenom_nahualX_1 NaN NaN NaN NaN
openphenom_nahualX_10 NaN NaN NaN NaN
cp_measure cell_0/max/ferretMaxFeretDiameter cell ferret MaxFeretDiameter 0
cell_0/max/ferretMinFeretDiameter cell ferret MinFeretDiameter 0
cell_0/max/intensityIntensity_IntegratedIntensity cell intensity Intensity_IntegratedIntensity 0
object feature_group channel
count nunique count nunique count nunique
block
openphenom 0 0 0 0 0 0
cp_measure 2550 2 2550 7 2550 5

That is the contrast the rest of the page is about, in one table. cp_measure names a compartment, a feature group and a channel for every column; the embedding names nothing.

One caveat on the channel: cp_measure numbers its inputs 0 to 4 rather than naming them, because the stain each index stands for lives in the acquisition metadata and not in the feature name. mantispy keeps the index rather than guessing a stain.

This is not a defect of the embedding, it is the honest answer: it has no notion of a channel or a compartment. The consequence is that anything keyed on the feature annotation has nothing to work with. pl.effect_sizes colours its bars by feature family, tl.feature_sets groups features into sets, and the CellProfiler blocklist names features to drop — none of them have anything to key on.

What survives is everything that treats a feature as an anonymous number: normalization, sphering, distances, hit calling, consensus, and every metric. A block of named features measured on the same wells can name the embedding’s leading axes, which Which measurements moved does.

Bringing your own embedding#

ds.jump_lite is a convenience. An embedding you produced yourself is a matrix and a metadata frame, and io.stamp is what puts it on the mantispy API surface.

# Pretend this came out of your own model, one row per well. Join on the index rather than slicing both by
# position: ds.jump_lite sorts its blocks into one row order, but two tables from different pipelines rarely
# share one.
wells_i_have = adata.obs_names[:8]
values = np.asarray(adata[wells_i_have].X, dtype=np.float32)
metadata = adata.obs.loc[wells_i_have, ["Metadata_Plate", "Metadata_Well", "Metadata_Perturbation", "Metadata_Control"]]

mine = ad.AnnData(values, obs=metadata.copy())
mine.var_names = [f"mymodel_{index}" for index in range(values.shape[1])]
mt.io.stamp(mine, resolution="well")
print(mt.io.validate(mine))
WARNING: obs has no Metadata_CellCount, so mt.tl.cytotoxicity cannot separate a hit from cell loss; aggregate single cells with mt.tl.aggregate, which writes it, or add a per-well count

io.stamp supplies the annotation columns empty rather than parsing the feature names, and records the resolution, so the object validates and can be written to h5ad. Supplying them empty is the point: the parser would read mymodel_17 as structure it does not have.

Two things worth copying from that cell. values is cast to float32 — a model’s output often arrives as float64 once it has been through pandas or a parquet file, and at 100,000 wells by 1,536 dimensions that is 1.2 GB against 600 MB. And the warning is printed, not counted: it names Metadata_CellCount as missing. Keep the count if your pipeline has one: on JUMP-Lite the largest axis of every trained model tracks it, as Learned embeddings against CellProfiler shows.

Aggregating a model’s output to a well#

JUMP-Lite gives one field of view per well, so it never has to be done there, and your screen will. The difference matters more than it looks:

  • A CellProfiler profile is a per-cell measurement aggregated over cells and fields, and the median is the pycytominer default because per-cell features are heavy-tailed. tl.aggregate(by=("Metadata_Plate", "Metadata_Well"), func="median") is the call.

  • An embedding is a function of a whole image, so per-field embeddings are vectors in a learned basis. A per-coordinate median is not a point the model would ever emit. Prefer the mean, and if you take the median know that you have left the model’s output manifold.

tl.aggregate’s min_cells guard counts rows, so it does nothing when the rows are already fields rather than cells. Check the field count yourself.

What changes in the recipe#

Normalize per plate against the negative controls, as for CellProfiler features. The embedding is the model’s output on each well’s images, so it carries the plate’s staining and illumination just as named features do. Illumination correction happens upstream, on the images, and no normalization of the profiles substitutes for it.

mt.pp.normalize(adata, method="mad_robustize", by="Metadata_Plate", reference="negcon")
{"dimensions normalize could not scale": int(adata.var["degenerate_scale"].sum())}
{'dimensions normalize could not scale': 0}

Do not run feature_select() on an embedding. Its dimensions are a basis rather than a list of measurements, and dropping some because they correlate discards the geometry the model learned. The CellProfiler blocklist matches feature names, so it has nothing to say either.

The filter that does apply is reproducibility, because it uses the replicate structure rather than a feature’s own distribution. Report it rather than filtering on it blindly.

icc = adata.copy()
mt.pp.feature_reproducibility(icc, groupby="Metadata_Perturbation", min_icc=0.2)
{
    "dimensions with ICC > 0.2": f"{int(icc.var['icc_selected'].sum())} of {icc.n_vars}",
    "median ICC": round(float(icc.var["icc"].median()), 3),
}
{'dimensions with ICC > 0.2': '351 of 384', 'median ICC': 0.502}

Two single-cell tools do not transfer to profiles that are already one row per well:

  • well_qc() counts the rows of each well, so on well-level profiles every well has one row, fails min_cells, and the whole plate is flagged. At well resolution the equivalent is a threshold on obs["Metadata_CellCount"].

  • scanpy’s sc.pp.filter_cells and sc.pp.filter_genes threshold on counts, and a morphological profile has none. sc.pp.pca, sc.pp.neighbors and sc.tl.umap do apply, because the object is an ordinary AnnData.