{ "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-11-09T01:27:35.504252Z", "iopub.status.busy": "2026-11-09T01:27:35.504009Z", "iopub.status.idle": "2026-11-09T01:29:56.158963Z", "shell.execute_reply": "2026-11-09T01:29:56.156468Z" } }, "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 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-11-09T01:29:56.163160Z", "iopub.status.busy": "2026-11-09T01:29:56.162685Z", "iopub.status.idle": "2026-11-09T01:30:00.722644Z", "shell.execute_reply": "2026-11-09T01:30:00.721340Z" } }, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "Updating file 'jump_lite_cp_measure.h5ad' from 'https://exampledata.scverse.org/mantispy/jump_lite/jump_lite_cp_measure.h5ad' to ''.\n" ] }, { "name": "stderr", "output_type": "stream", "text": [ "\r", " 0%| | 0.00/16.4M [00:00\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", "{func}`~mantispy.io.from_embedding` turns the two into an object every tool accepts." ] }, { "cell_type": "code", "execution_count": 3, "id": "c007", "metadata": { "execution": { "iopub.execute_input": "2026-11-09T01:30:00.725359Z", "iopub.status.busy": "2026-11-09T01:30:00.725100Z", "iopub.status.idle": "2026-11-09T01:30:00.918245Z", "shell.execute_reply": "2026-11-09T01:30:00.916929Z" } }, "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" ] }, { "data": { "text/plain": [ "{'shape': (8, 384), 'feature_kind': ['embedding'], 'same values': True}" ] }, "execution_count": 3, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Stand-in for the output of your own model, one row per well. Take the matrix and the metadata from the same\n", "# index rather than slicing both by position, because two tables from different pipelines rarely share a row order.\n", "wells_i_have = adata.obs_names[:8]\n", "values = np.asarray(adata[wells_i_have].X)\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 = mt.io.from_embedding(\n", " values, metadata, resolution=\"well\", var_names=[f\"mymodel_{index}\" for index in range(values.shape[1])]\n", ")\n", "print(mt.io.validate(mine))\n", "{\n", " \"shape\": mine.shape,\n", " \"feature_kind\": mine.var[\"feature_kind\"].unique().tolist(),\n", " \"same values\": bool((mine.X == values).all()),\n", "}" ] }, { "cell_type": "markdown", "id": "c008", "metadata": {}, "source": [ "{func}`~mantispy.io.from_embedding` builds the object, names the columns `embedding_0` onward unless you pass\n", "`var_names=`, and stamps it with the `resolution` you give and with `feature_kind=\"embedding\"`. It keeps your\n", "values as they are, in the dtype you pass. A model's output often arrives as float64 after pandas or a\n", "parquet file, which at 100,000 wells by 1,536 dimensions is 1.2 GB against 600 MB as float32, so cast it\n", "yourself with `.astype(np.float32)` before the call if memory is tight.\n", "\n", "You declare the representation with `feature_kind` because mantispy does not guess it from feature names. The\n", "name parser would read `mymodel_17` as structure that the name doesn't have, so the annotation columns are\n", "added empty instead of parsed.\n", "\n", "The validation report prints a warning that `Metadata_CellCount` is missing. Keep the cell count if your\n", "pipeline has one, because 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", "When the embedding was computed from an object you already have, pass that object as `like=` instead of\n", "`obs`. Its metadata, its resolution and its perturbation record then carry over to the embedding." ] }, { "cell_type": "code", "execution_count": 4, "id": "2b8a07b5", "metadata": { "execution": { "iopub.execute_input": "2026-11-09T01:30:00.920880Z", "iopub.status.busy": "2026-11-09T01:30:00.920452Z", "iopub.status.idle": "2026-11-09T01:30:00.959686Z", "shell.execute_reply": "2026-11-09T01:30:00.958469Z" } }, "outputs": [ { "data": { "text/plain": [ "{'resolution': 'well', 'obs columns carried over': True}" ] }, "execution_count": 4, "metadata": {}, "output_type": "execute_result" } ], "source": [ "cellprofiler_wells = adata[wells_i_have].copy()\n", "from_same_wells = mt.io.from_embedding(values, like=cellprofiler_wells)\n", "{\n", " \"resolution\": from_same_wells.uns[\"mantispy\"][\"resolution\"],\n", " \"obs columns carried over\": from_same_wells.obs.columns.equals(cellprofiler_wells.obs.columns),\n", "}" ] }, { "cell_type": "markdown", "id": "1cb42964", "metadata": {}, "source": [ "Only the row counts are checked, so row `i` of the matrix must describe the same object as row `i` of `obs`,\n", "or of `like.obs`. An embedding written out in another order than its metadata is mislabelled without an\n", "error, which is why the cell above takes both from the same `wells_i_have` index.\n", "\n", "For single cells, pass `resolution=\"object\"`. The metadata then needs the object identifiers\n", "`Metadata_ImageID` and `Metadata_ObjectNumber`, and `object_type=` sets `Metadata_ObjectType` when `obs` has\n", "none. The next section covers how to take such an object to wells.\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": 5, "id": "c010", "metadata": { "execution": { "iopub.execute_input": "2026-11-09T01:30:00.962212Z", "iopub.status.busy": "2026-11-09T01:30:00.961787Z", "iopub.status.idle": "2026-11-09T01:30:23.982255Z", "shell.execute_reply": "2026-11-09T01:30:23.980747Z" } }, "outputs": [ { "data": { "text/plain": [ "{'dimensions normalize could not scale': 0}" ] }, "execution_count": 5, "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": 6, "id": "c012", "metadata": { "execution": { "iopub.execute_input": "2026-11-09T01:30:23.985080Z", "iopub.status.busy": "2026-11-09T01:30:23.984778Z", "iopub.status.idle": "2026-11-09T01:30:24.410901Z", "shell.execute_reply": "2026-11-09T01:30:24.409614Z" } }, "outputs": [ { "data": { "text/plain": [ "{'dimensions with ICC > 0.2': '351 of 384', 'median ICC': 0.502}" ] }, "execution_count": 6, "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": 7, "id": "embed-score-code", "metadata": { "execution": { "iopub.execute_input": "2026-11-09T01:30:24.413271Z", "iopub.status.busy": "2026-11-09T01:30:24.413023Z", "iopub.status.idle": "2026-11-09T01:30:36.736668Z", "shell.execute_reply": "2026-11-09T01:30:36.735161Z" } }, "outputs": [ { "data": { "text/plain": [ "{'perturbations': 301, 'active': 67, 'median mAP': 0.107}" ] }, "execution_count": 7, "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 }