{ "cells": [ { "cell_type": "markdown", "id": "c000", "metadata": {}, "source": [ "# Bringing your own embedding\n", "\n", "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](../../data_model/index.md) explains the `feature_kind` axis it relies on.\n", "\n", "A learned embedding replaces the CellProfiler block with the output of a neural network: hundreds to a couple\n", "of thousand numbers per well that mean nothing individually. To mantispy it is a matrix like any other, so\n", "normalization, hit calling, consensus and the metrics run on it unchanged. What changes is what `var` can\n", "tell you, how per-field output is aggregated, and which preprocessing steps apply.\n", "\n", "This page uses [JUMP-Lite](../../datasets/jump_lite.ipynb) {cite:p}`Munoz_2026`, where the same wells were\n", "measured by several models and by `cp_measure`, a CellProfiler-equivalent feature set.\n", "[Learned embeddings against CellProfiler](../../case_studies/learned_embeddings.ipynb) compares them." ] }, { "cell_type": "code", "execution_count": 1, "id": "c001", "metadata": { "execution": { "iopub.execute_input": "2026-10-21T01:03:30.051753Z", "iopub.status.busy": "2026-10-21T01:03:30.051365Z", "iopub.status.idle": "2026-10-21T01:03:33.615952Z", "shell.execute_reply": "2026-10-21T01:03:33.614741Z" } }, "outputs": [ { "data": { "text/plain": [ "AnnData object with n_obs × n_vars = 1536 × 384\n", " 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'\n", " var: 'object', 'feature_group', 'feature', 'channel', 'scale', 'angle', 'gray_levels', 'radial_bin', 'params', 'is_feature', 'feature_kind'\n", " uns: 'mantispy'\n", " layers: None (.X)" ] }, "execution_count": 1, "metadata": {}, "output_type": "execute_result" } ], "source": [ "import anndata as ad\n", "import numpy as np\n", "import pandas as pd\n", "\n", "import mantispy as mt\n", "\n", "adata = mt.ds.jump_lite(\"openphenom\")\n", "adata" ] }, { "cell_type": "markdown", "id": "c002", "metadata": {}, "source": [ "384 numbers per well from OpenPhenom, a Cell Painting foundation model. The metadata is ordinary JUMP\n", "metadata: source, batch, plate, well, and the compound each well received." ] }, { "cell_type": "markdown", "id": "c003", "metadata": {}, "source": [ "## What `var` no longer tells you\n", "\n", "A CellProfiler feature name parses into a compartment, a feature group and a channel. `openphenom_nahualX_17`\n", "has neither. The loader supplies the annotation columns the schema requires and leaves them empty, rather\n", "than letting the name parser loose on names with no structure in them, since that parser would read\n", "`openphenom_nahualX_17` as the `nahualX` group of an `openphenom` object, and the screen would quietly\n", "acquire feature families named after the model's own tensors." ] }, { "cell_type": "code", "execution_count": 2, "id": "c004", "metadata": { "execution": { "iopub.execute_input": "2026-10-21T01:03:33.620063Z", "iopub.status.busy": "2026-10-21T01:03:33.619857Z", "iopub.status.idle": "2026-10-21T01:03:34.049824Z", "shell.execute_reply": "2026-10-21T01:03:34.048647Z" } }, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
objectfeature_groupfeaturechannel
block
openphenomopenphenom_nahualX_0NaNNaNNaNNaN
openphenom_nahualX_1NaNNaNNaNNaN
openphenom_nahualX_10NaNNaNNaNNaN
cp_measurecell_0/max/ferretMaxFeretDiametercellferretMaxFeretDiameter0
cell_0/max/ferretMinFeretDiametercellferretMinFeretDiameter0
cell_0/max/intensityIntensity_IntegratedIntensitycellintensityIntensity_IntegratedIntensity0
\n", "
" ], "text/plain": [ " object \\\n", "block \n", "openphenom openphenom_nahualX_0 NaN \n", " openphenom_nahualX_1 NaN \n", " openphenom_nahualX_10 NaN \n", "cp_measure cell_0/max/ferretMaxFeretDiameter cell \n", " cell_0/max/ferretMinFeretDiameter cell \n", " cell_0/max/intensityIntensity_IntegratedIntensity cell \n", "\n", " feature_group \\\n", "block \n", "openphenom openphenom_nahualX_0 NaN \n", " openphenom_nahualX_1 NaN \n", " openphenom_nahualX_10 NaN \n", "cp_measure cell_0/max/ferretMaxFeretDiameter ferret \n", " cell_0/max/ferretMinFeretDiameter ferret \n", " cell_0/max/intensityIntensity_IntegratedIntensity intensity \n", "\n", " feature \\\n", "block \n", "openphenom openphenom_nahualX_0 NaN \n", " openphenom_nahualX_1 NaN \n", " openphenom_nahualX_10 NaN \n", "cp_measure cell_0/max/ferretMaxFeretDiameter MaxFeretDiameter \n", " cell_0/max/ferretMinFeretDiameter MinFeretDiameter \n", " cell_0/max/intensityIntensity_IntegratedIntensity Intensity_IntegratedIntensity \n", "\n", " channel \n", "block \n", "openphenom openphenom_nahualX_0 NaN \n", " openphenom_nahualX_1 NaN \n", " openphenom_nahualX_10 NaN \n", "cp_measure cell_0/max/ferretMaxFeretDiameter 0 \n", " cell_0/max/ferretMinFeretDiameter 0 \n", " cell_0/max/intensityIntensity_IntegratedIntensity 0 " ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
objectfeature_groupchannel
countnuniquecountnuniquecountnunique
block
openphenom000000
cp_measure255022550725505
\n", "
" ], "text/plain": [ " object feature_group channel \n", " count nunique count nunique count nunique\n", "block \n", "openphenom 0 0 0 0 0 0\n", "cp_measure 2550 2 2550 7 2550 5" ] }, "execution_count": 2, "metadata": {}, "output_type": "execute_result" } ], "source": [ "both = pd.concat({\"openphenom\": adata.var, \"cp_measure\": mt.ds.jump_lite(\"cp_measure\").var}, names=[\"block\"])\n", "display(both.groupby(level=\"block\", sort=False).head(3)[[\"object\", \"feature_group\", \"feature\", \"channel\"]])\n", "# Over every feature, not just the three above: how many are annotated, and with how many distinct values.\n", "both.groupby(level=\"block\", sort=False)[[\"object\", \"feature_group\", \"channel\"]].agg([\"count\", \"nunique\"])" ] }, { "cell_type": "markdown", "id": "c005", "metadata": {}, "source": [ "That is the contrast the rest of the page is about, in one table. `cp_measure` names a compartment, a feature\n", "group and a channel for every column; the embedding names nothing.\n", "\n", "One caveat on the channel: `cp_measure` numbers its inputs `0` to `4` rather than naming them, because the\n", "stain each index stands for lives in the acquisition metadata and not in the feature name. mantispy keeps the\n", "index rather than guessing a stain.\n", "\n", "This is not a defect of the embedding, it is the honest answer: it has no notion of a channel or a\n", "compartment. The consequence is that anything keyed on the feature annotation has nothing to work with.\n", "`pl.effect_sizes` colours its bars by feature family, `tl.feature_sets` groups features into sets, and the\n", "CellProfiler blocklist names features to drop: none of them have anything to key on.\n", "\n", "What survives is everything that treats a feature as an anonymous number: normalization, sphering, distances,\n", "hit calling, consensus, and every metric. A block of named features measured on the same wells can name the\n", "embedding's leading axes, which [Which measurements moved](../phenotypes/which_features_moved.ipynb) does." ] }, { "cell_type": "markdown", "id": "c006", "metadata": {}, "source": [ "## Bringing your own embedding\n", "\n", "`ds.jump_lite` is a convenience. An embedding you produced yourself is a matrix and a metadata frame, and\n", "`io.stamp` is what puts it on the mantispy API surface." ] }, { "cell_type": "code", "execution_count": 3, "id": "c007", "metadata": { "execution": { "iopub.execute_input": "2026-10-21T01:03:34.052878Z", "iopub.status.busy": "2026-10-21T01:03:34.052730Z", "iopub.status.idle": "2026-10-21T01:03:34.092267Z", "shell.execute_reply": "2026-10-21T01:03:34.091160Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "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\n" ] } ], "source": [ "# Pretend this came out of your own model, one row per well. Join on the index rather than slicing both by\n", "# position: ds.jump_lite sorts its blocks into one row order, but two tables from different pipelines rarely\n", "# share one.\n", "wells_i_have = adata.obs_names[:8]\n", "values = np.asarray(adata[wells_i_have].X, dtype=np.float32)\n", "metadata = adata.obs.loc[\n", " wells_i_have,\n", " [\"Metadata_Plate\", \"Metadata_Well\", \"Metadata_Perturbation\", \"Metadata_Control_Type\", \"Metadata_Control\"],\n", "]\n", "\n", "mine = ad.AnnData(values, obs=metadata.copy())\n", "mine.var_names = [f\"mymodel_{index}\" for index in range(values.shape[1])]\n", "# feature_kind=\"embedding\" declares what the columns are: the names carry no representation to infer it from.\n", "mt.io.stamp(mine, resolution=\"well\", feature_kind=\"embedding\")\n", "print(mt.io.validate(mine))" ] }, { "cell_type": "markdown", "id": "c008", "metadata": {}, "source": [ "`io.stamp` records the resolution and the representation you declare with `feature_kind`, and supplies the\n", "annotation columns empty rather than parsing the feature names, so the object validates and can be written to\n", "h5ad. You declare `feature_kind=\"embedding\"` because the parser would otherwise read `mymodel_17` as\n", "structure it does not have, and mantispy refuses to guess the representation from a name.\n", "\n", "Two things worth copying from that cell. `values` is cast to **float32**: a model's output often arrives\n", "as float64 once it has been through pandas or a parquet file, and at 100,000 wells by 1,536 dimensions that is\n", "1.2 GB against 600 MB. And the warning is\n", "*printed*, not counted: it names `Metadata_CellCount` as missing. Keep the count if your pipeline has one:\n", "on JUMP-Lite the largest axis of every trained model tracks it, as\n", "[Learned embeddings against CellProfiler](../../case_studies/learned_embeddings.ipynb) shows.\n", "\n", "### Aggregating a model's output to a well\n", "\n", "JUMP-Lite gives one field of view per well, so it never has to be done there, and your screen will. The\n", "difference matters more than it looks:\n", "\n", "- A **CellProfiler** profile is a per-cell measurement aggregated over cells and fields, and the median is the\n", " pycytominer default because per-cell features are heavy-tailed. `tl.aggregate(by=(\"Metadata_Plate\",\n", " \"Metadata_Well\"), func=\"median\")` is the call.\n", "- An **embedding** is a function of a whole image, so per-field embeddings are vectors in a learned basis. A\n", " per-coordinate median is not a point the model would ever emit. Prefer the mean, and if you take the median\n", " know that you have left the model's output manifold.\n", "\n", "`tl.aggregate`'s `min_cells` guard counts rows, so it does nothing when the rows are already fields rather\n", "than cells. Check the field count yourself." ] }, { "cell_type": "markdown", "id": "c009", "metadata": {}, "source": [ "## What changes in the recipe\n", "\n", "Normalize per plate against the negative controls, as for CellProfiler features. The embedding is the\n", "model's output on each well's images, so it carries the plate's staining and illumination just as named\n", "features do. Illumination correction happens upstream, on the images, and no normalization of the profiles\n", "substitutes for it." ] }, { "cell_type": "code", "execution_count": 4, "id": "c010", "metadata": { "execution": { "iopub.execute_input": "2026-10-21T01:03:34.095105Z", "iopub.status.busy": "2026-10-21T01:03:34.094966Z", "iopub.status.idle": "2026-10-21T01:03:34.445498Z", "shell.execute_reply": "2026-10-21T01:03:34.444375Z" } }, "outputs": [ { "data": { "text/plain": [ "{'dimensions normalize could not scale': 0}" ] }, "execution_count": 4, "metadata": {}, "output_type": "execute_result" } ], "source": [ "mt.pp.normalize(adata, method=\"mad_robustize\", by=\"Metadata_Plate\", reference=\"negcon\")\n", "{\"dimensions normalize could not scale\": int(adata.var[\"degenerate_scale\"].sum())}" ] }, { "cell_type": "markdown", "id": "c011", "metadata": {}, "source": [ "Do not run {func}`~mantispy.pp.feature_select` on an embedding. Its dimensions are a basis rather than a list\n", "of measurements, and dropping some because they correlate discards the geometry the model learned. The\n", "CellProfiler blocklist matches feature names, so it has nothing to say either.\n", "\n", "The filter that does apply is reproducibility, because it uses the replicate structure rather than a\n", "feature's own distribution. Report it rather than filtering on it blindly." ] }, { "cell_type": "code", "execution_count": 5, "id": "c012", "metadata": { "execution": { "iopub.execute_input": "2026-10-21T01:03:34.448476Z", "iopub.status.busy": "2026-10-21T01:03:34.448317Z", "iopub.status.idle": "2026-10-21T01:03:34.525743Z", "shell.execute_reply": "2026-10-21T01:03:34.524570Z" } }, "outputs": [ { "data": { "text/plain": [ "{'dimensions with ICC > 0.2': '351 of 384', 'median ICC': 0.502}" ] }, "execution_count": 5, "metadata": {}, "output_type": "execute_result" } ], "source": [ "icc = adata.copy()\n", "mt.pp.feature_reproducibility(icc, by=\"Metadata_Perturbation\", min_icc=0.2)\n", "{\n", " \"dimensions with ICC > 0.2\": f\"{int(icc.var['icc_selected'].sum())} of {icc.n_vars}\",\n", " \"median ICC\": round(float(icc.var[\"icc\"].median()), 3),\n", "}" ] }, { "cell_type": "markdown", "id": "c013", "metadata": {}, "source": [ "Two single-cell tools do not transfer to profiles that are already one row per well:\n", "\n", "- {func}`~mantispy.pp.well_qc` counts the rows of each well, so on well-level profiles every well has one row,\n", " fails `min_cells`, and the whole plate is flagged. At well resolution the equivalent is a threshold on\n", " `obs[\"Metadata_CellCount\"]`.\n", "- scanpy's `sc.pp.filter_cells` and `sc.pp.filter_genes` threshold on counts, and a morphological profile has\n", " none. `sc.pp.pca`, `sc.pp.neighbors` and `sc.tl.umap` do apply, because the object is an ordinary `AnnData`." ] }, { "cell_type": "markdown", "id": "embed-score-md", "metadata": {}, "source": [ "## Scoring the embedding\n", "\n", "The last step is the same as for any profile: ask whether each perturbation is distinguishable from the negative controls it was plated with. {func}`~mantispy.tl.map` with `mode=\"activity\"` does that on the normalized embedding, with no change for the representation." ] }, { "cell_type": "code", "execution_count": 6, "id": "embed-score-code", "metadata": { "execution": { "iopub.execute_input": "2026-10-21T01:03:34.528632Z", "iopub.status.busy": "2026-10-21T01:03:34.528308Z", "iopub.status.idle": "2026-10-21T01:03:35.144711Z", "shell.execute_reply": "2026-10-21T01:03:35.143654Z" } }, "outputs": [ { "data": { "text/plain": [ "{'perturbations': 301, 'active': 61, 'median mAP': 0.107}" ] }, "execution_count": 6, "metadata": {}, "output_type": "execute_result" } ], "source": [ "mt.tl.map(adata, mode=\"activity\", reference=\"negcon\", seed=0)\n", "activity = adata.uns[\"mantispy\"][\"map\"]\n", "{\n", " \"perturbations\": len(activity),\n", " \"active\": int(activity[\"is_significant\"].sum()),\n", " \"median mAP\": round(float(activity[\"score\"].median()), 3),\n", "}" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.13.15" } }, "nbformat": 4, "nbformat_minor": 5 }