2. From cells to profiles#

A screen produces millions of cells, and most analyses work on a few thousand well profiles. This page covers the steps in between: quality control at three levels, normalization against each plate’s controls, and aggregation.

The data is a synthetic plate with known ground truth, generated locally, so each result on this page can be checked against it.

import numpy as np

import mantispy as mt

adata = mt.ds.synthetic_plate(
    n_plates=2,
    n_wells=96,
    n_cells=60,
    n_features=80,
    n_images_per_well=2,
    n_bad_images=6,
    effect_size=2.0,
    nan_fraction=0.01,
    seed=0,
)
adata
AnnData object with n_obs × n_vars = 11520 × 80
    obs: 'Metadata_Plate', 'Metadata_Well', 'Metadata_Row', 'Metadata_Col', 'Metadata_Perturbation', 'Metadata_Control', 'Metadata_Batch', 'Metadata_ImageNumber', 'Metadata_CellCount'
    var: 'object', 'feature_group', 'feature', 'channel', 'scale', 'angle', 'gray_levels', 'radial_bin', 'params', 'is_feature'
    uns: 'mantispy'
    layers: None (.X)

The generator records everything it injected, and the checks below compare against that record.

truth = adata.uns["mantispy"]["truth"]
{"bad images": len(truth["bad_images"]), "perturbations affected": len(truth["affected_features"])}
{'bad images': 6, 'perturbations affected': 9}

Quality control at three levels#

A cell can be bad, the image it came from can be bad, or the whole well can be. Each level has its own function. None of them delete anything. They write flags, and you decide what to drop.

Images#

MeasureImageQuality gives focus, saturation and dynamic-range statistics per field of view. 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(adata, method="mad")
result = adata.uns["mantispy"]["image_qc"]
flagged = set(result.index[~result["qc_image_pass"]])
{"flagged": sorted(flagged), "injected": sorted(truth["bad_images"])}
{'flagged': [108, 178, 200, 263, 356, 376],
 'injected': [108, 178, 200, 263, 356, 376]}

It flagged the injected images and no others. method="knn" is the alternative. Instead of asking whether any single metric is extreme, it asks whether a field’s combination of metrics is far from its neighbors, which catches images that are unusual overall without any one metric standing out.

mt.pp.filter_images(adata)
adata.shape
(11340, 80)

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 Zernike and radial-distribution features are undefined for objects below a certain size. qc_pass therefore thresholds the fraction of missing features instead of requiring none, which would fail almost every real cell.

mt.pp.calculate_qc_metrics(adata)
{
    "cells with some missing data": float((adata.obs["qc_n_nan_features"] > 0).mean()),
    "cells passing QC": float(adata.obs["qc_pass"].mean()),
}
{'cells with some missing data': 0.5429453262786597,
 'cells passing QC': 0.9999118165784833}
mt.pp.filter_cells(adata, min_cells_per_well=20)
mt.pp.filter_features(adata, min_variance=1e-8)
adata.shape
(11339, 80)

One cell fails here: qc_pass is false for one cell of 11340, so filter_cells drops it and the plate reads 11339 rows. No feature is constant, so filter_features keeps all 80 columns. On a plate where cells or features fail in numbers, the filters remove much more.

Wells#

well_qc summarizes each well: how many cells passed, how much data is missing and, for control wells, how variable the controls are. High control variability is an early sign that a well went wrong.

control_cv is a coefficient of variation, so it is only interpretable on positive raw intensities. These synthetic features are centered near zero, which makes the ratio unbounded, and that is why it reads 11 below instead of something like 0.1. Only read it on raw CellProfiler intensities, before normalization.

mt.pp.well_qc(adata, min_cells=20)
adata.uns["mantispy"]["well_qc"].head()
Metadata_Plate Metadata_Well n_cells nan_fraction control_cv qc_well_pass
0 Plate01 A01 60 0.008750 11.381138 True
1 Plate01 A02 60 0.010625 NaN True
2 Plate01 A03 60 0.010000 NaN True
3 Plate01 A04 60 0.006875 NaN True
4 Plate01 A05 60 0.008958 NaN True

Normalization#

Plates differ in imaging day, reagent lot and microscope state. The standard fix is to express every cell relative to the negative controls on its own plate, which is what reference="negcon" together with by="Metadata_Plate" does.

The default method, mad_robustize, subtracts the control median and divides by a robust spread, so a few extreme cells do not set the scale.

mt.pp.normalize(adata, method="mad_robustize", by="Metadata_Plate", reference="negcon", keep_raw=True)

controls = adata[adata.obs["Metadata_Control"]]
np.round(np.nanmedian(np.asarray(controls.X), axis=0)[:5], 6)
array([-0.,  0.,  0., -0.,  0.], dtype=float32)

The controls now sit at zero on every plate by construction. keep_raw=True kept the original values in layers["raw"] for the before-and-after plot. It is off by default because the extra layer doubles memory and the original is already on disk.

import matplotlib.pyplot as plt

mt.pl.feature_distributions(adata, features=list(adata.var_names[:3]))
plt.show()
../_images/bb8b6d659010349e6bcf39ca5f4e45a33fe7a688c529dee4cf5c7fd67a631918.png

Aggregating to wells#

Each well’s profile is the median across its cells. The median is used instead of the mean so that a few extreme cells do not move the profile.

wells = mt.tl.aggregate(adata, by=("Metadata_Plate", "Metadata_Well"), func="median")
wells
AnnData object with n_obs × n_vars = 192 × 80
    obs: 'Metadata_Plate', 'Metadata_Well', 'Metadata_CellCount', 'Metadata_Row', 'Metadata_Col', 'Metadata_Perturbation', 'Metadata_Control', 'Metadata_Batch'
    var: 'object', 'feature_group', 'feature', 'channel', 'scale', 'angle', 'gray_levels', 'radial_bin', 'params', 'is_feature', 'qc_n_nan', 'qc_variance', 'qc_n_unique', 'degenerate_scale'
    uns: 'mantispy'
    layers: None (.X)

The result records its provenance and keeps every metadata column that is constant within a well. Columns that vary within a well, such as a per-cell measurement, are dropped because they have no single value per well.

wells.uns["mantispy"]["aggregated_from"]
{'by': ['Metadata_Plate', 'Metadata_Well'],
 'func': 'median',
 'n_obs': 11339,
 'min_cells': 10}

Metadata_CellCount is added as well. Cell count is a common confounder, and mt.pp.regress_out can remove its influence later.

Looking at the plate#

Plate layout artifacts are easiest to see on a plate map.

mt.pl.plate(wells, color=wells.var_names[0])
plt.show()
../_images/8d672320885fb5bd29d00e3e7c6be4d4c1d22cea6222ede87de2cc0db132ac87.png

pl.plate draws any feature or obs column, so it also maps per-well flags, for example mt.pl.plate(wells, color="qc_well_pass").

Handing off#

get.to_dataframe returns the flat, metadata-first table that pycytominer [Serrano et al., 2025], cytominer-eval and most published scripts expect.

frame = mt.get.to_dataframe(wells)
frame.iloc[:3, :6]
Metadata_Plate Metadata_Well Metadata_CellCount Metadata_Row Metadata_Col Metadata_Perturbation
0 Plate01 A01 60 0 0 DMSO
1 Plate01 A02 60 0 1 pert00
2 Plate01 A03 60 0 2 pert01

Next: 3. Trustworthy profiles, on reducing the features to the informative ones.