Quality control#

A cell can be bad, the image it came from can be bad, or the whole well can be. This page flags each level on the 13,578 cells of jump_cells(): 24 wells of the JUMP plate BR00121438, four fields of view each, as CellProfiler measured them [Chandrasekaran et al., 2023]. The first call downloads about 1.5 GB.

The QC functions only write flags. The filter_ functions drop what was flagged, and you choose which to call.

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

import mantispy as mt

cells = mt.ds.jump_cells()
cells
AnnData object with n_obs × n_vars = 13578 × 5839
    obs: 'Metadata_ImageNumber', 'Metadata_ObjectNumber', 'Metadata_AbsPositionZ', 'Metadata_AbsTime', 'Metadata_BinningX', 'Metadata_BinningY', 'Metadata_ChannelID', 'Metadata_ChannelName', 'Metadata_Col', 'Metadata_ExposureTime', 'Metadata_FieldID', 'Metadata_ImageResolutionX', 'Metadata_ImageResolutionY', 'Metadata_ImageSizeX', 'Metadata_ImageSizeY', 'Metadata_MainEmissionWavelength', 'Metadata_MainExcitationWavelength', 'Metadata_MaxIntensity', 'Metadata_ObjectiveMagnification', 'Metadata_ObjectiveNA', 'Metadata_PlaneID', 'Metadata_Plate', 'Metadata_PositionX', 'Metadata_PositionY', 'Metadata_PositionZ', 'Metadata_Row', 'Metadata_Site', 'Metadata_Well', 'Metadata_Center_X', 'Metadata_Center_Y', 'Metadata_Source', 'Metadata_JCP2022', 'Metadata_InChIKey', 'Metadata_Perturbation', 'Metadata_Control'
    var: 'object', 'feature_group', 'feature', 'channel', 'scale', 'angle', 'gray_levels', 'radial_bin', 'params', 'is_feature', 'selected'
    uns: 'mantispy'
    layers: None (.X)
import plotly.io as pio

pio.renderers.default = "notebook_connected"

Images#

MeasureImageQuality gives focus, saturation and dynamic-range statistics per field of view, 96 fields here. image_qc flags fields that are outliers within their own plate. Pooling plates would flag every field on a uniformly dimmer plate and miss a blurred field on a bright one.

mt.pp.image_qc(cells, method="mad")
images = cells.uns["mantispy"]["image_qc"]
print(f"{int((~images['qc_image_pass']).sum())} of {len(images)} fields flagged")
images.sort_values("qc_image_score", ascending=False)[
    ["Metadata_Well", "Metadata_Site", "qc_image_score", "qc_image_pass"]
].head()
9 of 96 fields flagged
Metadata_Well Metadata_Site qc_image_score qc_image_pass
ImageNumber
20 B09 4 19.483637 False
17 B09 1 19.113390 False
61 J09 1 14.748206 False
18 B09 2 13.430829 False
14 B04 2 9.030103 False
mt.pl.image_qc(cells);
print(
    "compound in B09:",
    cells.obs.loc[cells.obs["Metadata_Well"] == "B09", "Metadata_Perturbation"].iloc[0],
    "| median Mito focus score over the plate:",
    round(float(images["ImageQuality_FocusScore_OrigMito"].median()), 4),
)
images.loc[images["Metadata_Well"] == "B09", ["Metadata_Site", "ImageQuality_FocusScore_OrigMito", "qc_image_pass"]]
compound in B09: JCP2022_098688 | median Mito focus score over the plate: 0.0044
Metadata_Site ImageQuality_FocusScore_OrigMito qc_image_pass
ImageNumber
17 1 0.043975 False
18 2 0.032221 False
19 3 0.018285 False
20 4 0.044741 False

Nine of the 96 fields are flagged, and four of them are the fields of one well, B09, whose Mito focus score is four to ten times the plate’s median. B09 received a compound, not DMSO. A focus score measures fine texture, so a compound that changes the texture of the Mito stain moves it the way a blurred image would. Look at a flagged field before dropping it: filtering these four removes the whole of one treated well.

knn = mt.pp.image_qc(cells, method="knn", copy=True).uns["mantispy"]["image_qc"]
{
    "flagged by mad": sorted(images.index[~images["qc_image_pass"]]),
    "flagged by knn": sorted(knn.index[~knn["qc_image_pass"]]),
}
{'flagged by mad': [14, 17, 18, 19, 20, 34, 49, 58, 61],
 'flagged by knn': [21, 46, 49, 51, 61]}

The two methods agree on two fields. mad flags a field where any single metric is extreme, knn one whose combination of metrics is unusual, and on real images they mostly flag different ones. filter_images drops the fields mad flagged, and their cells with them.

mt.pp.filter_images(cells)
cells.shape
(12320, 5839)

Cells#

calculate_qc_metrics writes per-cell and per-feature statistics. The most important one is qc_nan_fraction. Some missing values are normal in CellProfiler output, because some measurements are undefined for some objects, so qc_pass thresholds the fraction of missing features instead of requiring none.

mt.pp.calculate_qc_metrics(cells)
{
    "cells with some missing data": round(float((cells.obs["qc_n_nan_features"] > 0).mean()), 3),
    "cells passing QC": round(float(cells.obs["qc_pass"].mean()), 3),
}
{'cells with some missing data': 0.001, 'cells passing QC': 0.995}
mt.pp.filter_cells(cells, min_cells_per_well=20)
mt.pp.filter_features(cells, min_variance=1e-8)
cells.shape
(12260, 5685)

Dropping the flagged fields took the plate from 13,578 cells to 12,320. Real CellProfiler output has few missing values here: one cell in a thousand has any. filter_cells drops the 60 cells that failed, and filter_features the 154 features that did not vary.

Wells#

well_qc summarizes each well: how many cells passed, how much data is missing and, for control wells, how variable the controls are, which is control_cv and blank on treated wells.

mt.pp.well_qc(cells, min_cells=100)
cells.uns["mantispy"]["well_qc"].sort_values("n_cells").head()
Metadata_Plate Metadata_Well n_cells nan_fraction control_cv qc_well_pass
11 BR00121438 H19 252 0.000000 NaN True
14 BR00121438 J09 331 0.000004 NaN True
20 BR00121438 O05 377 0.000000 NaN True
2 BR00121438 B01 397 0.000006 NaN True
3 BR00121438 B04 407 0.000000 NaN True

The thinnest remaining well holds 252 cells, so every well passes a floor of 100. B09 is no longer among them: filter_images removed all four of its fields.

Outlying cells#

Outlier detection cleans the rows. A segmentation failure, a clump of debris or a dying cell gives an extreme profile that says nothing about the perturbation.

This section starts again from the cells as they were loaded.

jump = mt.ds.jump_cells()
mt.pp.outliers(jump, contamination=0.02)
mt.pl.outliers(jump, groupby="Metadata_Well");
../../_images/646c84a886d9a4d7358ae860a724322623b7b73f166b958157d61eeb81df7f33.png

outliers() writes a flag and a score and removes nothing. The histogram shows the flagged tail of the score, and the bars the fraction flagged in each well. It scores only the features var["selected"] marks, so a block of near-duplicate features cannot outvote the rest.

The default method, ecod [Li et al., 2023], needs no tuning. A cell’s score is the sum over features of how far into a tail it sits, measured by rank, so the scale of a feature does not matter. isolation_forest catches cells that are unusual in their combination of features, and mad takes the largest robust z-score of any single feature, so score_cutoff=5 applies the usual five-robust-standard-deviations rule instead of a fixed fraction.

What gets flagged#

Three measurements that are easy to picture, as percentiles within the plate:

features = ["Cells_AreaShape_Area", "Nuclei_AreaShape_Area", "Nuclei_Intensity_MeanIntensity_DNA"]
percentiles = mt.get.to_dataframe(jump, metadata=False, features=features).rank(pct=True)
percentiles.groupby(jump.obs["qc_outlier"]).median().round(2)
Cells_AreaShape_Area Nuclei_AreaShape_Area Nuclei_Intensity_MeanIntensity_DNA
qc_outlier
False 0.51 0.51 0.49
True 0.06 0.07 0.97

Flagged cells are small, with small nuclei and bright DNA. Rounded cells look like that: cells in division, dying cells and fragments. Two fields of well O09 were imaged as well, and jump_plate() downloads them (44 MB), so the flagged cells there can be set beside cells that were kept. DNA is red, AGP green and Mito blue, and the white line is CellProfiler’s outline of the cell.

from spatialdata import get_pyramid_levels

plate = mt.ds.jump_plate()
imaged = jump.obs[(jump.obs["Metadata_Well"] == "O09") & jump.obs["Metadata_Site"].isin([1, 2])]
examples = {
    "flagged": imaged[imaged["qc_outlier"]].head(4),
    "kept": imaged[~imaged["qc_outlier"]].sample(4, random_state=0),
}

fig, axes = plt.subplots(2, 4, figsize=(10, 5))
for row, (label, cells_shown) in zip(axes, examples.items(), strict=True):
    row[0].set_title(label, loc="left")
    for ax, (_, cell) in zip(row, cells_shown.iterrows(), strict=False):
        field = f"BR00121438_O09_s{cell['Metadata_Site']}"
        mask = np.asarray(plate[f"{field}_cells"]) == cell["Metadata_ObjectNumber"]
        ys, xs = np.nonzero(mask)
        window = np.s_[max(ys.min() - 15, 0) : ys.max() + 15, max(xs.min() - 15, 0) : xs.max() + 15]
        image = get_pyramid_levels(plate[f"{field}_image"], n=0)
        rgb = np.stack([np.asarray(image.sel(c=c))[window] for c in ("DNA", "AGP", "Mito")], axis=-1).astype(float)
        ax.imshow((rgb / np.percentile(rgb, 99.5, axis=(0, 1))).clip(0, 1))
        ax.contour(mask[window], levels=[0.5], colors="white", linewidths=0.8)
for ax in axes.flat:
    ax.set_axis_off()
plt.show()
../../_images/717d219595523108558961988fd75bc6394bb3e4712f8debacad1cd2f5ecdad2.png

The kept cells are flat and spread out. The flagged ones are rounded, out of focus and badly outlined: the first outline covers the space beside a round cell rather than the cell, the second holds little but a nucleus, and the last two cut across a column of rounded cells. Their profiles describe the segmentation more than the cell.

The methods disagree#

flags = {"ecod": jump.obs["qc_outlier"].to_numpy()}
for name, kwargs in {
    "ecod, paper": {"ecod_aggregation": "paper"},
    "isolation_forest": {"method": "isolation_forest"},
    "mad": {"method": "mad"},
}.items():
    mt.pp.outliers(jump, contamination=0.02, key_added="qc_compare", **kwargs)
    flags[name] = jump.obs["qc_compare"].to_numpy()

pd.DataFrame({a: {b: (flags[a] & flags[b]).sum() / (flags[a] | flags[b]).sum() for b in flags} for a in flags}).round(2)
ecod ecod, paper isolation_forest mad
ecod 1.00 0.79 0.81 0.21
ecod, paper 0.79 1.00 0.77 0.21
isolation_forest 0.81 0.77 1.00 0.24
mad 0.21 0.21 0.24 1.00

The table is the Jaccard index between flagged sets. ecod and isolation_forest flag largely the same cells. mad flags mostly others, because one extreme feature is enough for it. ecod_aggregation="paper" scores as Algorithm 1 of Li et al. [2023] does rather than as pyod does, and on these cells it shares most of its flags with the default.

Is it removing a phenotype?#

per_well = jump.obs.groupby("Metadata_Well", observed=True).agg(
    control=("Metadata_Control", "first"), flagged=("qc_outlier", "mean")
)
per_well.groupby("control")["flagged"].agg(["count", "min", "median", "max"]).round(3)
count min median max
control
False 16 0.007 0.021 0.030
True 8 0.011 0.022 0.031

Control and compound wells lose similar fractions, between about 0.7 % and 3 % of a well, so here the flags do not concentrate on one compound’s wells. On a plate with a strong phenotype they may not: a compound that stops cells in division fills its wells with exactly the rounded cells flagged above. Look at this table before dropping anything. If one perturbation’s wells stand out, pass by="Metadata_Well" so each well is compared only with itself, or keep the cells.

Next: Normalize, select, aggregate.