Hit callers on well profiles#
hit_calling() scores a treatment against the negative controls. On well profiles, where a
treatment has only its replicate wells, two of its methods apply, and this page runs both on two compound screens:
"mahalanobis", the default: the median distance of the treatment’s wells from the control centroid, in the controls’ covariance."mp_value": the squared Mahalanobis distance between the treatment’s centroid and the controls’, in the average of the two covariances. This is the multidimensional perturbation value (Hutz et al., J Biomol Screen 2013, doi:10.1177/1087057112469257), and unlike the default it counts the treatment’s own spread: a treatment whose replicate wells disagree is measured in a wider covariance.
Three wells are too few to estimate a treatment’s covariance on its own, but enough to add to the controls’: the
average is positive definite whenever the controls’ covariance is, so "mp_value" needs only two wells per
treatment. "hellinger" compares the two covariances themselves and needs at least twice as many rows as features
on each side, so it does not apply here; the cell and guide comparison
covers it.
import numpy as np
import pandas as pd
import scanpy as sc
from scipy.stats import binom
import mantispy as mt
METHODS = {"mahalanobis": {}, "mp_value": {"method": "mp_value"}}
def call(adata):
"""Run both methods on the PCA representation and return each one's calls at q < 0.05."""
calls = {}
for name, keywords in METHODS.items():
mt.tl.hit_calling(adata, use_rep="X_pca", n_permutations=1000, key_added=name, **keywords)
calls[name] = adata.uns["mantispy"][name].set_index("group")["is_hit"]
return pd.DataFrame(calls)
def pseudo_rate(adata, size, n_draws=100, seed=0):
"""Share of control-only pseudo-treatments of ``size`` wells that each method calls at p < 0.05, beside the share a calibrated test stays under."""
controls = adata[adata.obs["Metadata_Control_Type"].astype(str).to_numpy() == "negcon"].copy()
rng = np.random.default_rng(seed)
called = dict.fromkeys(METHODS, 0)
for draw in range(n_draws):
picked = np.zeros(controls.n_obs, dtype=bool)
picked[rng.choice(controls.n_obs, size, replace=False)] = True
controls.obs["Metadata_Perturbation"] = np.where(picked, "pseudo", "DMSO")
# The reference is selected through Metadata_Control_Type, so flip it along with Metadata_Control.
controls.obs["Metadata_Control_Type"] = np.where(picked, "treatment", "negcon")
controls.obs["Metadata_Control"] = ~picked
for name, keywords in METHODS.items():
mt.tl.hit_calling(controls, use_rep="X_pca", n_permutations=1000, seed=draw, key_added=name, **keywords)
called[name] += int(controls.uns["mantispy"][name].set_index("group").loc["pseudo", "pvalue"] < 0.05)
# A calibrated test stays at or below this rate in 95% of runs of n_draws draws.
chance = binom.ppf(0.95, n_draws, 0.05) / n_draws
return pd.DataFrame(
{"rate at p < 0.05": {name: count / n_draws for name, count in called.items()}, "95% chance limit": chance}
).rename_axis(f"{n_draws} pseudo-treatments of {size} wells")
BBBC021#
The overview’s screen and preprocessing: MCF-7 cells, a compound at one concentration is a treatment, and most treatments have three replicate wells.
bbbc = mt.ds.bbbc021()
mt.pp.normalize(bbbc, method="mad_robustize", by="Metadata_Plate", reference="negcon", keep_raw=True)
mt.pp.feature_select(bbbc, na_cutoff=0.0)
bbbc = mt.pp.subset_features(bbbc)
sc.pp.pca(bbbc, n_comps=30)
treated = bbbc.obs["Metadata_Control_Type"].astype(str).to_numpy() != "negcon"
wells = bbbc.obs.loc[treated, "Metadata_Perturbation"].astype(str).value_counts()
wells.value_counts().rename("treatments").rename_axis("wells per treatment")
wells per treatment
3 96
2 7
Name: treatments, dtype: int64
First the calibration. Control wells relabelled as a treatment of the typical size should be called at about the nominal rate.
pseudo_rate(bbbc, size=int(wells.median()))
| rate at p < 0.05 | 95% chance limit | |
|---|---|---|
| 100 pseudo-treatments of 3 wells | ||
| mahalanobis | 0.07 | 0.09 |
| mp_value | 0.02 | 0.09 |
bbbc_calls = call(bbbc).loc[wells.index]
pd.crosstab(bbbc_calls["mahalanobis"], bbbc_calls["mp_value"])
| mp_value | True |
|---|---|
| mahalanobis | |
| False | 5 |
| True | 98 |
cells = bbbc.obs.groupby("Metadata_Perturbation", observed=True)["Metadata_CellCount"].mean()
only = bbbc_calls.index[bbbc_calls["mp_value"] & ~bbbc_calls["mahalanobis"]]
pd.DataFrame({"wells": wells[only], "cells per well": cells.reindex(only).round()}).assign(
control_cells_per_well=round(bbbc.obs.loc[~treated, "Metadata_CellCount"].mean())
)
| wells | cells per well | control_cells_per_well | |
|---|---|---|---|
| bryostatin@0.3 | 2 | 1576.0 | 878 |
| PD-169316@10.0 | 2 | 1102.0 | 878 |
| PD-169316@3.0 | 2 | 1095.0 | 878 |
| alsterpaullone@3.0 | 2 | 990.0 | 878 |
| alsterpaullone@1.0 | 2 | 1302.0 | 878 |
Of 100 control-only pseudo-treatments, the default called 7 at p < 0.05 and "mp_value" called 2. Both counts are
within chance: a calibrated test stays at or below 9 of 100 in at least 95% of runs.
The two methods agree on 98 of the 103 treatments, and "mp_value" calls the other five as well, so it calls every
treatment. All five have two wells rather than three, which leaves the default calling two of the seven two-well
treatments. None of the five has lost cells: each keeps more cells per well than the controls’ 878.
JUMP-Target-2#
jump_target2() runs one plate map in eleven laboratories, and its compounds were chosen to cover
many targets rather than to be active, so some of them should not be called. There is no list of which. As a
proxy, map() with mode="activity" asks whether a compound’s replicates retrieve each other
against the controls on their own plate, and a compound it misses is not known to be inactive.
jump = mt.ds.jump_target2()
mt.pp.normalize(jump, method="mad_robustize", by="Metadata_Plate", reference="negcon")
mt.pp.feature_select(jump, na_cutoff=0.0)
jump = mt.pp.subset_features(jump)
sc.pp.pca(jump, n_comps=30)
treated = jump.obs["Metadata_Control_Type"].astype(str).to_numpy() != "negcon"
wells = jump.obs.loc[treated, "Metadata_Perturbation"].astype(str).value_counts()
print(f"{wells.size} compounds, {int(wells.median())} wells each (median), {int((~treated).sum())} control wells")
pseudo_rate(jump, size=int(wells.median()))
301 compounds, 14 wells each (median), 898 control wells
| rate at p < 0.05 | 95% chance limit | |
|---|---|---|
| 100 pseudo-treatments of 14 wells | ||
| mahalanobis | 0.05 | 0.09 |
| mp_value | 0.09 | 0.09 |
jump_calls = call(jump).loc[wells.index]
mt.tl.map(jump, mode="activity", use_rep="X_pca", key_added="activity")
active = jump.uns["mantispy"]["activity"].set_index("Metadata_Perturbation")["qvalue"].reindex(wells.index) < 0.05
pd.DataFrame(
{
name: {
"called": int(called.sum()),
"called, mAP active": int((called & active).sum()),
"called, mAP inactive": int((called & ~active).sum()),
"mAP active, not called": int((~called & active).sum()),
}
for name, called in jump_calls.items()
}
).T
| called | called, mAP active | called, mAP inactive | mAP active, not called | |
|---|---|---|---|---|
| mahalanobis | 159 | 68 | 91 | 1 |
| mp_value | 172 | 69 | 103 | 0 |
Of 100 control-only pseudo-treatments of 14 wells, the default called 5 and "mp_value" called 9. Both are within
chance, though "mp_value" sits at the 95% limit. "mp_value" calls
172 of the 301 compounds and the default 159. Of the 69 compounds mAP calls active, "mp_value" calls all 69 and the
default 68. Of the 232 that mAP does not call, "mp_value" calls 103 and the default 91.
mAP is not ground truth. A compound it misses may still have a small, real effect, so these extra calls are not known
to be false. They do show that "mp_value" returns the longer list on this screen.
Which to use#
"mahalanobis" stays the default, and "mp_value" is opt-in. Both methods stayed within chance on control-only
pseudo-treatments of both screens. On BBBC021 the treatments "mp_value" adds are the two-well ones. On JUMP-Target-2 it
calls more of the compounds mAP does not call than the default does, and nothing on this page says whether those
extra calls are right.
On single cells the picture changes. In the cell and guide comparison,
"mp_value" called nine of the twelve olfactory-receptor negatives of a CRISPR screen, where the default called one.
Whichever method you pick, relabel control wells as pseudo-treatments of your group size, as pseudo_rate does
above, to see how often it calls a group that has no effect.