Bringing your own embedding#
This is the learned-embedding workflow, one of the three end-to-end paths with the compound and genetic screens: stamp the embedding, validate it, aggregate to wells, normalize, then score it. The data model explains the feature_kind axis it relies on.
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 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_Row', 'Metadata_Col', 'Metadata_CellCount', 'Metadata_JCP2022', 'Metadata_InChIKey', 'Metadata_Perturbation', 'Metadata_Perturbation_Type', 'Metadata_Control_Type', 'Metadata_Control'
var: 'object', 'feature_group', 'feature', 'channel', 'scale', 'angle', 'gray_levels', 'radial_bin', 'params', 'is_feature', 'feature_kind'
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, since 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
from_embedding() turns the two into an object every tool accepts.
# Stand-in for the output of your own model, one row per well. Take the matrix and the metadata from the same
# index rather than slicing both by position, because two tables from different pipelines rarely share a row order.
wells_i_have = adata.obs_names[:8]
values = np.asarray(adata[wells_i_have].X)
metadata = adata.obs.loc[
wells_i_have,
["Metadata_Plate", "Metadata_Well", "Metadata_Perturbation", "Metadata_Control_Type", "Metadata_Control"],
]
mine = mt.io.from_embedding(
values, metadata, resolution="well", var_names=[f"mymodel_{index}" for index in range(values.shape[1])]
)
print(mt.io.validate(mine))
{
"shape": mine.shape,
"feature_kind": mine.var["feature_kind"].unique().tolist(),
"same values": bool((mine.X == values).all()),
}
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
{'shape': (8, 384), 'feature_kind': ['embedding'], 'same values': True}
from_embedding() builds the object, names the columns embedding_0 onward unless you pass
var_names=, and stamps it with the resolution you give and with feature_kind="embedding". It keeps your
values as they are, in the dtype you pass. A model’s output often arrives as float64 after pandas or a
parquet file, which at 100,000 wells by 1,536 dimensions is 1.2 GB against 600 MB as float32, so cast it
yourself with .astype(np.float32) before the call if memory is tight.
You declare the representation with feature_kind because mantispy does not guess it from feature names. The
name parser would read mymodel_17 as structure that the name doesn’t have, so the annotation columns are
added empty instead of parsed.
The validation report prints a warning that Metadata_CellCount is missing. Keep the cell count if your
pipeline has one, because on JUMP-Lite the largest axis of every trained model tracks it, as
Learned embeddings against CellProfiler shows.
When the embedding was computed from an object you already have, pass that object as like= instead of
obs. Its metadata, its resolution and its perturbation record then carry over to the embedding.
cellprofiler_wells = adata[wells_i_have].copy()
from_same_wells = mt.io.from_embedding(values, like=cellprofiler_wells)
{
"resolution": from_same_wells.uns["mantispy"]["resolution"],
"obs columns carried over": from_same_wells.obs.columns.equals(cellprofiler_wells.obs.columns),
}
{'resolution': 'well', 'obs columns carried over': True}
Only the row counts are checked, so row i of the matrix must describe the same object as row i of obs,
or of like.obs. An embedding written out in another order than its metadata is mislabelled without an
error, which is why the cell above takes both from the same wells_i_have index.
For single cells, pass resolution="object". The metadata then needs the object identifiers
Metadata_ImageID and Metadata_ObjectNumber, and object_type= sets Metadata_ObjectType when obs has
none. The next section covers how to take such an object to wells.
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, by="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, failsmin_cells, and the whole plate is flagged. At well resolution the equivalent is a threshold onobs["Metadata_CellCount"].scanpy’s
sc.pp.filter_cellsandsc.pp.filter_genesthreshold on counts, and a morphological profile has none.sc.pp.pca,sc.pp.neighborsandsc.tl.umapdo apply, because the object is an ordinaryAnnData.
Scoring the embedding#
The last step is the same as for any profile: ask whether each perturbation is distinguishable from the negative controls it was plated with. map() with mode="activity" does that on the normalized embedding, with no change for the representation.
mt.tl.map(adata, mode="activity", reference="negcon", seed=0)
activity = adata.uns["mantispy"]["map"]
{
"perturbations": len(activity),
"active": int(activity["is_significant"].sum()),
"median mAP": round(float(activity["score"].median()), 3),
}
{'perturbations': 301, 'active': 67, 'median mAP': 0.107}