Learned embeddings against CellProfiler#

JUMP-Lite [Muñoz et al., 2026] measured the same 1,536 wells six ways: four learned embeddings, the same architecture as one of them with untrained weights, and cp_measure, a CellProfiler-equivalent feature set. Swapping the feature block is a controlled experiment rather than a change of dataset. The images are lossy and there is one field of view per well, and the dataset page says what that bounds. Bringing your own embedding covers loading one and what var no longer tells you.

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import scanpy as sc

import mantispy as mt

classical = mt.ds.jump_lite("cp_measure")
mt.pp.normalize(classical, method="mad_robustize", by="Metadata_Plate", reference="negcon")
import plotly.io as pio

pio.renderers.default = "notebook_connected"

Preparing each block#

The embeddings are normalized per plate and not selected, for the reason on Bringing your own embedding. cp_measure is a list of measurements, with the usual pathologies: features that never vary, that duplicate each other, or that run away to enormous values. Holding the selection identical across blocks would be the unfair comparison, not the fair one. Here is what each selection step is worth on it.

PYCYTOMINER = ("variance_threshold", "correlation_threshold", "drop_na_columns", "blocklist")
# feature_select's own default: drop_degenerate, which removes only what normalize flagged, then pycytominer's.
DEFAULT = ("drop_degenerate", *PYCYTOMINER)

selection = {}
for label, operations in {
    "none": ("drop_degenerate", "drop_na_columns"),
    "pycytominer default": DEFAULT,
    "default + drop_outliers": (*DEFAULT, "drop_outliers"),
    "+ noise_removal": (*DEFAULT, "drop_outliers", "noise_removal"),
}.items():
    trial = classical.copy()
    mt.pp.feature_select(trial, operations=operations, na_cutoff=0.0)
    trial = mt.pp.subset_features(trial)
    selection[label] = {
        "features": trial.n_vars,
        "largest |value|": float(np.abs(np.asarray(trial.X)).max()) if trial.n_vars else np.nan,
    }
pd.DataFrame(selection).T
features largest |value|
none 1949.0 178622.234375
pycytominer default 540.0 178622.234375
default + drop_outliers 526.0 456.745605
+ noise_removal 0.0 NaN

Left alone, cp_measure carries values of order 1e5, which survive a robust per-plate normalization because they are not constant, merely enormous. The pycytominer default cuts the block to about a quarter of its features and leaves the largest value where it was. drop_outliers, which is in pp.OPERATIONS and not in the default set, removes only fourteen more features and brings the largest value down about four-hundred-fold. noise_removal empties the block: its cutoff is an absolute standard deviation, and on the scale mad_robustize leaves no feature falls under it.

So cp_measure gets the standard selection plus drop_outliers, and the embeddings get none.

Comparing six feature sets#

The question is not whether an embedding works, but how it compares with the alternatives on the same wells. Two readouts:

  • tl.map — mean average precision. Rank every other profile by similarity to a query and ask whether its replicates come first. The pairs are defined so that a replicate must come from a different laboratory, which is the cross-site reproducibility question, and copairs gives every compound a permutation p-value that is then BH-corrected. This is the metric the JUMP benchmarks report [Arevalo et al., 2024, Kalinin et al., 2025], and it is the only one of the two that scales: the similarity-matrix readouts build a dense n-by-n matrix.

  • metrics.known_relationships — of the compounds annotated to act on the same gene, how many end up in either tail of the similarity distribution?

Each is measured under three alignments: the principal components alone, pp.tvn, and pp.harmony, which ranked top-three in every scenario of the batch-correction benchmark [Arevalo et al., 2024] and is the last step of the JUMP recipe. pp.harmony needs the optional extra, pip install 'mantispy[harmony]'.

targets = mt.ds.jump_lite_targets()


def align(wells):
    """The three alignments every block is compared under: none, TVN and Harmony, on the same 50 components."""
    sc.pp.pca(wells, n_comps=50)
    mt.pp.tvn(wells, batch_key="Metadata_Source", use_rep="X_pca", key_added="X_tvn")
    mt.pp.harmony(wells, batch_key="Metadata_Source", use_rep="X_pca", key_added="X_harmony")
    return wells


def prepared(model):
    """Load one feature set and align it three ways.

    The CellProfiler block gets the standard selection; the embeddings get none, for the reason above.
    """
    wells = mt.ds.jump_lite(model)
    mt.pp.normalize(wells, method="mad_robustize", by="Metadata_Plate", reference="negcon")
    operations = (*DEFAULT, "drop_outliers") if model == "cp_measure" else ("drop_degenerate", "drop_na_columns")
    mt.pp.feature_select(wells, operations=operations, na_cutoff=0.0)
    return align(mt.pp.subset_features(wells))


blocks = {model: prepared(model) for model in mt.ds.JUMP_LITE_MODELS}
{model: wells.n_vars for model, wells in blocks.items()}
{'openphenom': 384,
 'dinov2': 384,
 'dinov2_random': 384,
 'subcell': 1536,
 'morphem': 1920,
 'cp_measure': 526}
CROSS_LAB = {
    "pos_sameby": ["Metadata_Perturbation"],
    "pos_diffby": ["Metadata_Source"],  # a replicate pair has to cross laboratories
    "neg_diffby": ["Metadata_Perturbation"],
}
scored_profiles = {}


def scored(name, wells, rep):
    """Cross-laboratory mAP on the treated wells, and target recall on their consensus against its own null."""
    treated = wells[~wells.obs["Metadata_Control"].to_numpy()].copy()  # still carries obsm[rep]; use_rep reduces it
    mt.tl.map(
        treated, use_rep=rep, **CROSS_LAB, null_size=10_000
    )  # the default; the note under the table says why not fewer
    per_compound = treated.uns["mantispy"]["map"]
    scored_profiles[name, rep] = treated  # kept so the per-compound panels below need not score again

    consensus = mt.tl.consensus(treated, use_rep=rep, method="median", min_replicates=1)
    # Chance is measured, not assumed to be 10%; the section on chance below says why.
    recall = mt.metrics.known_relationships(consensus, targets, n_permutations=100, seed=0).iloc[0]
    return {
        "mAP": float(per_compound["mean_average_precision"].mean()),
        "significant": int(per_compound["below_corrected_p"].sum()),
        "target recall": float(recall["value"]),
        "recall null": float(recall["null"]),
        "recall p": float(recall["p_value"]),
        "source variance": float(mt.metrics.pc_regression(wells, key="Metadata_Source", use_rep=rep)["value"].iloc[0]),
    }


comparison = pd.DataFrame(
    [
        {"model": model, "alignment": rep, **scored(model, wells, rep)}
        for model, wells in blocks.items()
        for rep in ("X_pca", "X_tvn", "X_harmony")
    ]
)
comparison.pivot(index="model", columns="alignment", values=["mAP", "significant"]).round(3)
mAP significant
alignment X_harmony X_pca X_tvn X_harmony X_pca X_tvn
model
cp_measure 0.074 0.050 0.026 106.0 32.0 4.0
dinov2 0.068 0.031 0.029 70.0 5.0 5.0
dinov2_random 0.010 0.009 0.010 0.0 0.0 0.0
morphem 0.086 0.027 0.035 92.0 3.0 6.0
openphenom 0.037 0.016 0.018 4.0 0.0 3.0
subcell 0.074 0.027 0.022 73.0 2.0 2.0

significant counts the compounds whose mAP survives a BH correction over the 301 tested, which is the number a screener cares about: how many compounds would you have called. It needs a large permutation null. copairs cannot return a p-value below one over the number of draws plus one, and tl.map warns when the null is too small for the correction to call anything, so keep the default of 10,000 draws.

Four things to read out of the comparison table.

The untrained model behaves like one. dinov2_random is the same architecture with random weights, and it is the control for the whole comparison. Its mAP sits at the floor under every alignment, because random projections of an image do not reproduce across laboratories, and no correction manufactures structure out of noise. Every other block beats it on mAP under every alignment, which is the least a pipeline has to show.

Uncorrected, the classical block reproduces and the embeddings barely do. Look at the X_pca column of significant: cp_measure calls 32 of the 301 compounds across laboratories with no batch correction at all, and the learned embeddings call between none and five. After pp.harmony four of the blocks call 70 to 106; openphenom calls four. The embeddings need the correction to reproduce across sites much more than the named measurements do.

Alignment matters as much as the choice of model. pp.harmony lifts the mAP of every block but the untrained one, by 1.5 to 3.2 times. Under Harmony, the choice of block then moves it again by more than a factor of two, and the number of compounds called, among the five real blocks, from four to 106. pp.tvn does little for retrieval: no block calls more than six compounds under it. It adds a few on MorphEm and OpenPhenom, and on cp_measure it cuts the 32 uncorrected calls to four. Neither the model nor the correction is the small decision.

The CellProfiler-equivalent set leads without a correction. cp_measure tops the uncorrected column and calls the most compounds under Harmony — though MorphEm’s mean mAP under Harmony is higher, SubCell’s is level with it and DINOv2’s slightly below, and under TVN cp_measure falls behind MorphEm and DINOv2.

Each of those compounds had to be told apart from other compounds, across laboratories. The JUMP-Lite benchmark [Muñoz et al., 2026] also scores an easier question, phenotypic activity: do a compound’s replicates stand apart from the negative controls on the query’s own plate? tl.map computes it with mode="activity", which keeps the controls in and retrieves against the ones plated beside each query.

def activity(wells, rep):
    """Phenotypic activity: do a compound's replicates retrieve each other against the controls on their plate?"""
    table = mt.tl.map(wells, mode="activity", use_rep=rep, copy=True).uns["mantispy"]["map"]
    return {
        "activity mAP": float(table["mean_average_precision"].mean()),
        "active": int(table["below_corrected_p"].sum()),
    }


activities = pd.DataFrame(
    [
        {"model": model, "alignment": rep, **activity(wells, rep)}
        for model, wells in blocks.items()
        for rep in ("X_pca", "X_tvn", "X_harmony")
    ]
)
activities.pivot(index="model", columns="alignment", values=["activity mAP", "active"]).round(3)
activity mAP active
alignment X_harmony X_pca X_tvn X_harmony X_pca X_tvn
model
cp_measure 0.333 0.301 0.302 97.0 88.0 78.0
dinov2 0.317 0.275 0.327 92.0 78.0 99.0
dinov2_random 0.128 0.114 0.157 2.0 2.0 2.0
morphem 0.318 0.269 0.345 98.0 81.0 106.0
openphenom 0.293 0.251 0.267 88.0 70.0 83.0
subcell 0.306 0.261 0.290 89.0 77.0 84.0

On activity the blocks are close. cp_measure leads without a correction and on mean mAP under Harmony; MorphEm leads under TVN and is level with it on the count under Harmony; DINOv2 is near both. Under TVN every trained embedding calls more compounds active than cp_measure does. OpenPhenom has the lowest mean mAP of the five real blocks under every alignment, and the untrained model calls two compounds under every alignment.

For nearly every compound, every replicate sits at another laboratory, so both readouts ask a query to find replicates run elsewhere; what changes is what they have to outrank. Without a batch correction the learned embeddings tell a compound from DMSO nearly as well as the named measurements do, 70 to 81 compounds active against 88 for cp_measure, and have much more trouble telling one compound from another, where they call five at most against its 32. Harmony narrows that: MorphEm, SubCell and DINOv2 then reach a cross-laboratory mAP of 0.086, 0.074 and 0.068 against cp_measure’s 0.074, and call 70 to 92 compounds against its 106. That is what you would see if these embeddings carry more of the laboratory than cp_measure does, and their uncorrected source variance further down (about 0.03 to 0.04, against 0.02) points the same way. OpenPhenom is the exception: it calls four even after Harmony.

The embeddings are used out of the box, without fine-tuning to Cell Painting data, on lossy-compressed JUMP images with one fixed preprocessing. Treat the ranking as a property of this setup, not of the models.

How much of the Harmony column is the seed#

pp.harmony clusters the wells before it corrects them, and the clustering starts from a random draw, so the seed moves the result, and so does the order of the rows, which the draw is taken over. The tables above are one run each. Here is every block under five seeds.

spread = []
for model, wells in blocks.items():
    for seed in range(5):
        mt.pp.harmony(wells, batch_key="Metadata_Source", use_rep="X_pca", key_added="X_harmony_seed", seed=seed)
        treated = wells[~wells.obs["Metadata_Control"].to_numpy()].copy()
        mt.tl.map(treated, use_rep="X_harmony_seed", **CROSS_LAB)
        spread.append(
            {
                "model": model,
                "cross-laboratory mAP": float(treated.uns["mantispy"]["map"]["mean_average_precision"].mean()),
                "cross-laboratory calls": int(treated.uns["mantispy"]["map"]["below_corrected_p"].sum()),
                **activity(wells, "X_harmony_seed"),
            }
        )
pd.DataFrame(spread).groupby("model").agg(["min", "max"]).round(3)
cross-laboratory mAP cross-laboratory calls activity mAP active
min max min max min max min max
model
cp_measure 0.073 0.079 95 106 0.331 0.340 97 101
dinov2 0.068 0.076 68 76 0.317 0.322 92 94
dinov2_random 0.010 0.011 0 0 0.127 0.131 1 2
morphem 0.085 0.087 91 94 0.318 0.320 97 98
openphenom 0.037 0.040 2 7 0.293 0.295 88 90
subcell 0.074 0.077 69 73 0.302 0.307 87 91

Five seeds move cp_measure between 95 and 106 compounds called across laboratories, MorphEm between 91 and 94, DINOv2 and SubCell between 68 and 76, and OpenPhenom between two and seven; the untrained model calls none under any of them. The three tiers of calls survive every seed: cp_measure and MorphEm above DINOv2 and SubCell, OpenPhenom far below. So does the order inside the first tier: cp_measure calls more compounds than MorphEm under every seed, and MorphEm has the higher cross-laboratory mAP under every seed. What does not survive is a gap of a few compounds, or of a few thousandths of mAP, between two blocks in one run: DINOv2 and SubCell overlap on both, and on the activity count so do cp_measure and MorphEm, at 97 to 101 compounds active against 97 to 98. Read the Harmony column as a range, and compare two pipelines under Harmony over several seeds rather than one.

One preprocessing choice moves the CellProfiler result#

JUMP-Lite’s CellProfiler preparation includes a rank-based inverse normal transform [Muñoz et al., 2026], which this page leaves out; pp.rank_int computes it. It makes every feature’s distribution the same shape, which can matter a great deal for a block of measurements on wildly different scales. Here is cp_measure both ways. The correlation filter then runs on the transformed values, so a somewhat different set of features survives, and the table says how many.

def with_rank_int():
    """cp_measure prepared in the benchmark's order: the rank transform before the correlation filter."""
    wells = classical.copy()
    mt.pp.feature_select(
        wells, operations=("drop_degenerate", "drop_na_columns", "variance_threshold", "drop_outliers"), na_cutoff=0.0
    )
    wells = mt.pp.subset_features(wells)
    mt.pp.rank_int(wells, by="Metadata_Plate", stochastic=False)
    mt.pp.feature_select(wells, operations=("correlation_threshold", "blocklist"))
    return align(mt.pp.subset_features(wells))


ranked = with_rank_int()
recipes = pd.concat(
    [
        comparison.merge(activities)
        .query("model == 'cp_measure' and alignment != 'X_tvn'")
        .assign(recipe="as above", features=blocks["cp_measure"].n_vars),
        pd.DataFrame(
            [
                {"alignment": rep, **scored("cp_measure, with rank INT", ranked, rep), **activity(ranked, rep)}
                for rep in ("X_pca", "X_harmony")
            ]
        ).assign(recipe="with rank INT", features=ranked.n_vars),
    ]
)
recipes.set_index(["recipe", "alignment"])[
    ["features", "significant", "target recall", "recall null", "recall p", "activity mAP", "active"]
].rename(columns={"significant": "cross-laboratory calls"}).round(3)
features cross-laboratory calls target recall recall null recall p activity mAP active
recipe alignment
as above X_pca 526 32 0.089 0.051 0.01 0.301 88
X_harmony 526 106 0.079 0.052 0.01 0.333 97
with rank INT X_pca 603 19 0.164 0.112 0.01 0.283 84
X_harmony 603 50 0.144 0.115 0.01 0.299 80

The transform cuts cp_measure’s cross-laboratory calls from 32 to 19 uncorrected and from 106 to 50 under Harmony. Its raw annotation recall roughly doubles, but so does its null, so the gain over chance is modest: the margin grows from 0.038 to 0.052 uncorrected and from 0.027 to 0.029 under Harmony. It is the trap the section on chance below warns about: a raw recall that looks better mostly because its null rose with it. Its effect on activity depends on the alignment: small uncorrected, and under Harmony enough to drop cp_measure from first to fourth of the five real blocks on activity mAP, behind MorphEm, DINOv2 and SubCell, and from level with MorphEm at the top of the count to last. Under Harmony it also leaves cp_measure calling fewer compounds across laboratories than MorphEm, DINOv2 or SubCell. So where the CellProfiler block ranks against the embeddings here depends on a preprocessing step, not only on the features. The tables above keep this page’s recipe; this one is the reason not to read their cp_measure row as the CellProfiler result.

What is chance, really?#

metrics.known_relationships counts annotated pairs falling in either tail of the similarity distribution, and a fixed tail width invites the assumption that a map with no information scores 10%. Here it does not. The tails hold 10% of all pairs, so pairs drawn at random would land there 10% of the time. The annotated pairs are not drawn at random: RefChemDB links some compounds to hundreds of genes and most to a few, so a handful of compounds make up many of the pairs, and chance moves with where each representation puts those few. So the chance level has to be measured. n_permutations shuffles which compound each annotation row names, keeping every gene’s number of compounds and every compound’s number of genes, and reports the mean recall over the shuffles as null and the share of shuffles that recall at least as much as p_value.

fig, ax = plt.subplots(figsize=(9, 4.5))
order = list(blocks)
width = 0.26
for offset, alignment in zip((-width, 0, width), ("X_pca", "X_tvn", "X_harmony"), strict=True):
    block = comparison[comparison["alignment"] == alignment].set_index("model").loc[order]
    x = np.arange(len(order)) + offset
    ax.bar(x, block["target recall"], width * 0.9, label=alignment)
    # the shuffled-annotation level for this exact representation
    ax.hlines(block["recall null"], x - width * 0.45, x + width * 0.45, color="black", lw=1.6)
ax.plot([], [], color="black", lw=1.6, label="shuffled annotation")
ax.axhline(0.10, color="crimson", ls=":", lw=1, label="the 10% people assume")
ax.set_xticks(np.arange(len(order)), order, rotation=20, ha="right", fontsize=8)
ax.set_ylabel("recall of annotated pairs")
ax.legend(fontsize=7)
fig.tight_layout()
plt.show()

comparison.pivot(index="model", columns="alignment", values=["target recall", "recall null", "recall p"]).round(3)
../../_images/904ce07e15f62625da650299e5001fc37dbc022abe83e710e65e38c9f22dec5c.png
target recall recall null recall p
alignment X_harmony X_pca X_tvn X_harmony X_pca X_tvn X_harmony X_pca X_tvn
model
cp_measure 0.079 0.089 0.176 0.052 0.051 0.127 0.010 0.010 0.010
dinov2 0.055 0.052 0.104 0.055 0.049 0.089 0.545 0.307 0.059
dinov2_random 0.097 0.114 0.087 0.104 0.109 0.101 0.782 0.248 0.980
morphem 0.060 0.069 0.136 0.063 0.066 0.109 0.663 0.356 0.010
openphenom 0.069 0.057 0.127 0.081 0.066 0.091 0.941 0.851 0.010
subcell 0.055 0.042 0.154 0.061 0.057 0.120 0.802 0.990 0.010

The black bars are chance for that representation, and they are not a constant 10%: for the trained embeddings and cp_measure they run from 5 to 8% without TVN and 9 to 13% with it, and for the untrained model they sit at 10 to 11% under every alignment. Read every recall against the bar sitting on it, not against the red line.

Once you do, the readout says something clean, and something different from what the raw numbers suggest. pp.tvn lifts recall clearly above its own null on three of the four trained embeddings and on cp_measure, and on DINOv2 only marginally (p = 0.06, five of the 100 shuffles scoring as high). Under either of the other two alignments no embedding clears its own null — but cp_measure clears it under all three. And the untrained model never clears it: its apparently high raw recall, the highest in the unaligned column, is entirely a property of its null, which is high because a near-random projection spreads pairs into the tails. That is exactly the trap a fixed baseline sets: in the unaligned and Harmony columns it would have made the untrained control look like the best model on the page.

The shuffled annotation is what exposed the fixed baseline; the untrained model is where it would have done the most damage. Keeping one in the comparison is the cheapest check that a pipeline measures anything.

The laboratories#

The same metrics.pc_regression, now on the source.

# pl.metrics groups by "metric" and draws one bar per "representation", which here is one group per
# feature set and one bar per alignment.
mt.pl.metrics(
    comparison.rename(columns={"model": "metric", "alignment": "representation", "source variance": "value"}).assign(
        better="lower"
    )[["metric", "representation", "value", "better"]]
)
comparison.pivot(index="model", columns="alignment", values="source variance").round(3)
alignment X_harmony X_pca X_tvn
model
cp_measure 0.008 0.021 0.022
dinov2 0.006 0.030 0.045
dinov2_random 0.002 0.019 0.010
morphem 0.007 0.044 0.042
openphenom 0.004 0.035 0.036
subcell 0.007 0.034 0.027

Harmony takes the laboratory down to a fraction of a percent on every feature set. TVN does not consistently reduce it: it is unchanged on three blocks, up by half on DINOv2, and down on SubCell and the untrained model. That is consistent with what the two do — Harmony mixes the batches in every local neighbourhood, while TVN aligns each batch’s control covariance to the pooled one — and a plausible reason the two readouts disagree.

Read this together with the tables above, not instead of them. TVN leaves the laboratory in place and is still the only alignment under which the embeddings’ annotation recall clears its own null; Harmony removes it and is the only one under which most blocks call many compounds. A correction that removes the batch may also have taken biology with it, and one that leaves it may still help, so neither readout alone tells you which happened. Which alignment to use is a decision about what the screen is for, not a default.

What the leading components are#

pc_regression, per component, on the cell count and on the laboratory.

fig, axes = plt.subplots(2, 3, figsize=(14, 6.5), sharey=True)
for ax, (model, wells) in zip(axes.ravel(), blocks.items(), strict=True):
    mt.pl.batch_variance(wells, keys=["Metadata_CellCount", "Metadata_Source"], use_rep="X_pca", n_comps=10, ax=ax)
    ax.set_title(model, fontsize=10)
fig.tight_layout()
plt.show()

variance = pd.concat(
    {
        model: mt.metrics.batch_variance_explained(
            wells, keys=["Metadata_CellCount", "Metadata_Source"], use_rep="X_pca"
        ).set_index("key")["value"]
        for model, wells in blocks.items()
    },
    axis=1,
).T.rename(columns={"Metadata_CellCount": "cell count", "Metadata_Source": "source"})
variance["first component, r with count"] = {
    model: float(np.corrcoef(wells.obsm["X_pca"][:, 0], wells.obs["Metadata_CellCount"])[0, 1])
    for model, wells in blocks.items()
}
variance.round(3)
../../_images/85de19c9746a75882ef5ac354042f91baabf786142cb51aaa5e28163dfadc973.png
key cell count source first component, r with count
openphenom 0.323 0.035 -0.696
dinov2 0.376 0.030 0.744
dinov2_random 0.015 0.019 -0.116
subcell 0.216 0.034 0.702
morphem 0.301 0.044 0.736
cp_measure 0.060 0.021 -0.176

On every trained embedding the first component correlates with the cell count at about 0.70 to 0.74 in magnitude, against 0.18 for cp_measure and 0.12 for the untrained model, and over the fifty components the count accounts for between six and twelve and a half times as much of the variance as the laboratory does. The largest axis of these models tracks how full the well is. The untrained model does not do it, so the trained models learned it; cp_measure, a per-cell measurement summarised over the well, does it far less.

The untreated control wells differ 1.7-fold in cell count between sites, as the dataset page shows. If a good part of what separates the laboratories is how dense their wells are, a representation whose largest axis tracks density entangles the batch with the biology on that axis, which would be one reason the embeddings need the correction more than cp_measure does. This page does not test that.

How much of CellProfiler each embedding carries#

A learned embedding has no var to read a hit off: a compound that stands out is an ID, not “high texture in the mitochondrial channel”. So the question the comparison has skirted is what a black-box embedding actually holds of the named measurements it is being scored against. metrics.variance_carried answers it directly. It cross-fits a ridge from the embedding to each cp_measure feature and reports the out-of-fold R^2, averaged over each feature family: near one means the embedding linearly reconstructs that family, near zero means it does not carry it. The two blocks are the same 1,536 wells, so the metric aligns them on obs_names and needs nothing else. classical is the block to recover — normalized but not selected, so it still names every feature and its var["feature_group"].

carried = pd.DataFrame(
    {
        # blocks[model] holds the embedding in obsm["X_pca"]; classical.X holds the named cp_measure block.
        model: mt.metrics.variance_carried(
            blocks[model], classical, use_rep="X_pca", groupby="feature_group"
        ).set_index("feature_group")["variance_carried"]
        for model in blocks
        if model != "cp_measure"  # recovering cp_measure from itself is not the question
    }
).T
carried = carried[carried.mean().sort_values(ascending=False).index]  # feature families, best-carried first

fig, ax = plt.subplots(figsize=(9, 3.5))
im = ax.imshow(carried.to_numpy(), cmap="viridis", vmin=0, vmax=1, aspect="auto")
ax.set_xticks(np.arange(carried.shape[1]), carried.columns, rotation=30, ha="right", fontsize=8)
ax.set_yticks(np.arange(carried.shape[0]), carried.index, fontsize=8)
for row in range(carried.shape[0]):
    for col in range(carried.shape[1]):
        value = carried.iat[row, col]
        # white on the dark low end, black on the bright high end, so every cell stays readable
        ax.text(
            col, row, f"{value:.2f}", ha="center", va="center", fontsize=7, color="white" if value < 0.5 else "black"
        )
fig.colorbar(im, ax=ax, label="out-of-fold $R^2$")
fig.tight_layout()
plt.show()

carried.round(3)
../../_images/52cace22e6c8c874aba679527ec7f4a84f90a272d83103d9929438aeadd6f53d.png
feature_group ferret texture radial_distribution intensity zernike radial_zernikes sizeshape
openphenom 0.619 0.583 0.480 0.440 0.362 0.265 0.239
dinov2 0.688 0.513 0.475 0.441 0.446 0.302 0.330
dinov2_random 0.163 0.221 0.169 0.121 0.166 0.101 0.017
subcell 0.692 0.597 0.489 0.454 0.442 0.302 0.292
morphem 0.725 0.579 0.504 0.509 0.447 0.326 0.318

The trained embeddings carry the ferret and texture families of cp_measure well — an image model reconstructs how large each object is and how patterned each channel is almost for free — and the zernike and shape families less, since those turn on the exact segmentation the embedding never performed. dinov2_random carries little of any family: the untrained control sits well below the trained models everywhere, which is the point. A learned embedding stops being a black box once you can say what of the classical block it linearly rebuilds; read each row against the control, not on its own.

Is the hit a hit, or did the cells die?#

This is the question a screener asks first, and on an embedding it is sharper than usual. Call hits, then ask tl.cytotoxicity whether the compounds furthest from the controls are the ones that lost cells.

toxicity = []
for model, wells in blocks.items():
    scratch = wells.copy()
    mt.tl.hit_calling(scratch, use_rep="X_pca", n_permutations=200, seed=0)
    mt.tl.cytotoxicity(scratch, site_key=None)  # one field per well, so there is no field count to divide by
    table = scratch.uns["mantispy"]["cytotoxicity"]
    toxicity.append(
        {
            "model": model,
            "rho(distance, viability)": float(table[["distance", "viability"]].corr(method="spearman").iloc[0, 1]),
            "suspect": int(table["suspect"].sum()),
            "groups": len(table),
        }
    )
    if model == "openphenom":
        mt.pl.cytotoxicity(scratch)
pd.DataFrame(toxicity).set_index("model").round(3)
rho(distance, viability) suspect groups
model
openphenom -0.661 64 302
dinov2 -0.705 64 302
dinov2_random -0.538 64 302
subcell -0.736 64 302
morphem -0.698 64 302
cp_measure -0.700 64 302

The suspect count is identical on every block, the untrained one included, so here it follows viability alone. The rank correlation between distance and viability is negative on every block, from −0.54 for the untrained model to −0.74 for SubCell, and cp_measure sits at −0.70, inside the range of the trained embeddings. Cell loss moves a profile away from the controls whichever feature block measures it, because a compound that kills cells has changed the well. The top of this screen’s hit list is partly a cytotoxicity ranking whichever block you use.

Should the cell count be removed?#

The reflex is to take the count out: drop the wells under a floor, or regress the count out of the profiles. Both are measured here on OpenPhenom, against keeping every well.

def remedy(treatment):
    """The recipe `prepared` ran, with a cell-count floor or with the count regressed out."""
    if treatment == "none":
        return blocks["openphenom"]  # already built above; rebuilding it is how the two quietly diverge
    wells = mt.ds.jump_lite("openphenom")
    if treatment == "floor":
        wells = wells[wells.obs["Metadata_CellCount"].to_numpy(dtype=float) >= 50].copy()
    mt.pp.normalize(wells, method="mad_robustize", by="Metadata_Plate", reference="negcon")
    # The same step `prepared` runs. It drops nothing from this embedding, and leaving it out is how the
    # floor and regress arms would end up compared against a "none" arm built a different way.
    mt.pp.feature_select(wells, operations=("drop_degenerate", "drop_na_columns"), na_cutoff=0.0)
    wells = mt.pp.subset_features(wells)
    if treatment == "regress":
        mt.pp.regress_out(wells, keys=("Metadata_CellCount",), by="Metadata_Plate")
    return align(wells)


remedies = []
for treatment in ("none", "floor", "regress"):
    trial = remedy(treatment)
    left = float(mt.metrics.pc_regression(trial, key="Metadata_CellCount", use_rep="X_pca")["value"].iloc[0])
    for rep in ("X_pca", "X_tvn", "X_harmony"):
        result = scored(treatment, trial, rep)
        remedies.append(
            {
                "treatment": treatment,
                "alignment": rep,
                "wells": trial.n_obs,
                "cell count variance": left,
                "mAP": result["mAP"],
                "significant": result["significant"],
                "target recall": result["target recall"],
                "recall null": result["recall null"],
                "recall p": result["recall p"],
            }
        )
pd.DataFrame(remedies).set_index(["treatment", "alignment"]).round(3)
wells cell count variance mAP significant target recall recall null recall p
treatment alignment
none X_pca 1536 0.323 0.016 0 0.057 0.066 0.851
X_tvn 1536 0.323 0.018 3 0.127 0.091 0.010
X_harmony 1536 0.323 0.037 4 0.069 0.081 0.941
floor X_pca 1397 0.250 0.016 0 0.048 0.073 1.000
X_tvn 1397 0.250 0.020 3 0.152 0.111 0.010
X_harmony 1397 0.250 0.034 17 0.048 0.061 0.990
regress X_pca 1536 0.064 0.010 1 0.127 0.093 0.010
X_tvn 1536 0.064 0.018 3 0.161 0.114 0.010
X_harmony 1536 0.064 0.022 0 0.109 0.084 0.010

Each remedy buys something the other does not. Regressing the count out takes its share of the variance from 0.32 to 0.06 and lifts annotation recall above its own null under every alignment, where keeping every well clears it only under TVN. It costs retrieval: under Harmony the compounds called drop from four to none. The floor discards 139 wells, raises the calls under Harmony from four to seventeen at a small cost in mAP, and leaves recall below its null there. Under TVN neither costs anything this table scores.

In a compound screen the count is partly a treatment effect, since the compound reduced it, so regressing it out removes part of the phenotype and not only a nuisance. The JUMP recipe regresses the cell count out of the ORF and CRISPR arms and not out of compounds [Chandrasekaran et al., 2023]. This page keeps every well and does not regress; report the count beside the readouts instead.

Summary#

  • On telling a compound from DMSO the blocks were close; on telling compounds apart across laboratories without a correction the embeddings called at most five compounds to cp_measure’s 32. Harmony narrowed that for every trained embedding but OpenPhenom.

  • Read Harmony as a range over seeds, and read cp_measure’s row as one recipe: the rank transform halves its calls under Harmony.

  • Measure chance with known_relationships(n_permutations=...), and keep an untrained model in the comparison. Against a fixed 10% it would have looked like the best model without TVN.

  • The trained embeddings’ largest axis tracks confluence, and distance from the controls tracks cell loss on every block.

  • The three alignments disagree and none dominates: Harmony lets the embeddings’ compounds reproduce across sites, TVN lets their annotation recall clear its null.

All of it is for off-the-shelf embeddings on lossy JUMP images and one CellProfiler recipe.