Published profiles and JUMP#

An image-based profiling pipeline hands you a table: one row per well or per cell, a few thousand columns of CellProfiler measurements, and some metadata columns mixed in among them. read_profiles() turns one or many of those tables into an AnnData. It splits the measurements from the metadata and annotates what each feature name encodes. It handles what differs between real datasets: four spellings of the metadata prefix, missing-value sentinels, files with different columns, and metadata that only exists in the directory name.

import mantispy as mt

A dataset#

mantispy.ds downloads whole accessions from the Cell Painting Gallery [Weisbart et al., 2024], so there is something real to read. This one is kinase inhibitors at a range of doses, eight plates of it.

adata = mt.ds.pki()
adata
AnnData object with n_obs × n_vars = 3072 × 5839
    obs: 'Metadata_plate_map_name', 'Metadata_broad_sample', 'Metadata_mg_per_ml', 'Metadata_mmoles_per_liter', 'Metadata_solvent', 'Metadata_Plate', 'Metadata_Well', 'Metadata_Site_Count', 'Metadata_Count_Cells', 'Metadata_Count_CellsIncludingEdges', 'Metadata_Count_Cytoplasm', 'Metadata_Count_Nuclei', 'Metadata_Count_NucleiIncludingEdges', 'Metadata_Object_Count', 'Metadata_Barcode', 'Metadata_Supplier', 'Metadata_Supplier_Catalog', 'Metadata_pert_type', 'Metadata_control_type', 'Metadata_CellCount', 'Metadata_SiteCount', 'Metadata_Control', 'Metadata_Compound', 'Metadata_Concentration', 'Metadata_MOA', 'Metadata_Perturbation'
    var: 'object', 'feature_group', 'feature', 'channel', 'scale', 'angle', 'gray_levels', 'radial_bin', 'params', 'is_feature'
    uns: 'mantispy'
    layers: None (.X)

Numeric columns that parse as CellProfiler features became the feature matrix, and everything else became obs. Every metadata prefix is normalized to Metadata_, and the loader adds the columns an analysis looks for: Metadata_Compound, Metadata_Concentration, Metadata_Control and Metadata_Perturbation.

adata.obs.head()
Metadata_plate_map_name Metadata_broad_sample Metadata_mg_per_ml Metadata_mmoles_per_liter Metadata_solvent Metadata_Plate Metadata_Well Metadata_Site_Count Metadata_Count_Cells Metadata_Count_CellsIncludingEdges ... Metadata_Supplier_Catalog Metadata_pert_type Metadata_control_type Metadata_CellCount Metadata_SiteCount Metadata_Control Metadata_Compound Metadata_Concentration Metadata_MOA Metadata_Perturbation
0 C-7210-01-CMP-014 NaN NaN NaN DMSO BR00122970 A01 9 1758 1890 ... DMSO control negcon 1758.0 9.0 True DMSO NaN NaN DMSO
1 C-7210-01-CMP-014 NaN NaN NaN DMSO BR00122970 A02 9 1941 2137 ... DMSO control negcon 1941.0 9.0 True DMSO NaN NaN DMSO
2 C-7210-01-CMP-014 NaN NaN NaN DMSO BR00122970 A03 9 1840 2007 ... DMSO control negcon 1840.0 9.0 True DMSO NaN NaN DMSO
3 C-7210-01-CMP-014 NaN NaN NaN DMSO BR00122970 A04 9 1869 2027 ... DMSO control negcon 1869.0 9.0 True DMSO NaN NaN DMSO
4 C-7210-01-CMP-014 NaN NaN NaN DMSO BR00122970 A05 9 2165 2369 ... DMSO control negcon 2165.0 9.0 True DMSO NaN NaN DMSO

5 rows × 26 columns

Plates of one screen can disagree on their columns when a channel failed on one of them, so the eight are read down to the features they share. That is on_column_mismatch="intersect"; the default is to raise instead, so a mismatch is never silent.

adata.obs.groupby("Metadata_Plate", observed=True).size()
Metadata_Plate
BR00122970    384
BR00122971    384
BR00122972    384
BR00122973    384
BR00122974    384
BR00122975    384
BR00122977    384
BR00122978    384
dtype: int64

What a feature name says#

CellProfiler names a feature <object>_<feature group>_<feature>[_<channel>][_<parameters>], and read_profiles() pulls that apart into var. Correlation features measure colocalization between a pair of channels, recorded as a pair such as AGP|Brightfield. AreaShape is geometry and carries none.

adata.var.head()
object feature_group feature channel scale angle gray_levels radial_bin params is_feature
Cells_AreaShape_Area Cells AreaShape Area NaN NaN NaN NaN NaN NaN True
Cells_AreaShape_BoundingBoxArea Cells AreaShape BoundingBoxArea NaN NaN NaN NaN NaN NaN True
Cells_AreaShape_Compactness Cells AreaShape Compactness NaN NaN NaN NaN NaN NaN True
Cells_AreaShape_Eccentricity Cells AreaShape Eccentricity NaN NaN NaN NaN NaN NaN True
Cells_AreaShape_EquivalentDiameter Cells AreaShape EquivalentDiameter NaN NaN NaN NaN NaN NaN True
adata.var[adata.var["feature_group"] == "Correlation"].head(3)
object feature_group feature channel scale angle gray_levels radial_bin params is_feature
Cells_Correlation_Correlation_AGP_BFHigh Cells Correlation Correlation AGP|BFHigh NaN NaN NaN NaN NaN True
Cells_Correlation_Correlation_AGP_BFLow Cells Correlation Correlation AGP|BFLow NaN NaN NaN NaN NaN True
Cells_Correlation_Correlation_AGP_Brightfield Cells Correlation Correlation AGP|Brightfield NaN NaN NaN NaN NaN True
adata.var.groupby(["object", "feature_group"], observed=True).size().sort_values(ascending=False).head(10)
object     feature_group     
Nuclei     Texture               1248
Cytoplasm  Texture               1248
Cells      Texture               1248
Nuclei     Correlation            224
Cytoplasm  Correlation            224
Cells      Correlation            224
           RadialDistribution     207
Cytoplasm  RadialDistribution     207
Cells      Granularity            128
Nuclei     Granularity            128
dtype: int64

A slice of the measurements is then a query on var instead of a regex on the names:

dna_intensity = adata[:, (adata.var["channel"] == "DNA") & (adata.var["feature_group"] == "Intensity")]
dna_intensity.shape
(3072, 45)

Reading your own files#

The remaining arguments cover what has differed between datasets:

adata = mt.io.read_profiles(
    sorted(Path("profiles").glob("*/*.parquet")),
    sentinels=-999,                          # values standing for missing, replaced with NaN
    index_columns=("Metadata_Plate", "Metadata_Well"),  # joined with ":" into the observation index
    metadata_columns=("ImageNumber",),       # numeric columns that are not features
    path_columns={"Metadata_Batch": 2},      # metadata read off the file path
    on_column_mismatch="intersect",
)

path_columns maps a column name to how many directories up from the file to read the name of. That is how a batch or a plate that exists only in the directory layout gets into obs.

Pointed at a directory instead, the same call reads a CellProfiler ExportToSpreadsheet run, one row per cell with every object joined on, or the parquet parts CytoTable writes.

A first look#

These are raw measurements, most of them redundant. Default feature selection keeps a few hundred, and scaling those is enough for a first look with scanpy:

import scanpy as sc

mt.pp.feature_select(adata, na_cutoff=0.0)
adata = mt.pp.subset_features(adata)
sc.pp.scale(adata)

sc.pp.pca(adata)
sc.pp.neighbors(adata)
sc.tl.umap(adata)
sc.pl.umap(adata, color=["Metadata_Plate", "Metadata_Concentration"], ncols=2)
../../_images/1aa46aa490d02cfb799038aecdf842c05211a0f34c5dd97f7e8d240241cbf54f.png

The eight plates fall into four clusters of two, and the DMSO wells split among them. All eight share one plate map, so the clusters come from how the plates were run rather than from what they received, which a real analysis has to account for before interpreting the rest of the embedding.

JUMP#

A JUMP plate parquet has three metadata columns, source, plate and well, and nothing about what each well received. read_jump() reads the plates and joins the perturbation from the JUMP metadata repository, which is what jump_target2() does for you:

wells = mt.io.read_jump(sorted(Path("jump").glob("*.parquet")), on_column_mismatch="intersect")

That annotation names compounds. For the CRISPR plates, mt.pp.annotate_jump(wells, kind="crispr") joins the gene each well targets instead, as jump_crispr() does.

JUMP calls a participating laboratory a source, Metadata_Source. In a CellProfiler export, Metadata_Site is a field of view inside a well, a different thing.

Working from disk#

mt.io.read(path, backed="r") leaves X in the file. obs and var are loaded into memory, so metadata, QC flags and feature selection work as before, and grouped operations read one group at a time.

Backed mode is slower than working in memory, so use it only on data that does not fit.

import tempfile
from pathlib import Path

import numpy as np

path = Path(tempfile.mkdtemp()) / "pki.h5ad"
mt.io.write(mt.ds.pki(), path)

backed = mt.io.read(path, backed="r")
in_memory = mt.io.read(path)

{
    "backed": (backed.isbacked, type(backed.X).__name__),
    "in memory": (in_memory.isbacked, type(in_memory.X).__name__),
    "obs is in memory either way": type(backed.obs).__name__,
}
{'backed': (True, 'Dataset'),
 'in memory': (False, 'ndarray'),
 'obs is in memory either way': 'DataFrame'}
from_disk = mt.pp.normalize(backed, by="Metadata_Plate", reference="negcon", copy=True)
resident = mt.pp.normalize(in_memory, by="Metadata_Plate", reference="negcon", copy=True)

{
    "identical": bool(np.allclose(np.asarray(from_disk.X), np.asarray(resident.X), rtol=1e-6)),
    "largest difference": float(np.abs(np.asarray(from_disk.X) - np.asarray(resident.X)).max()),
}
{'identical': True, 'largest difference': 0.0}

The results are identical. The backed path reduces group by group while the in-memory path makes one kernel call, and the test suite asserts they agree to 1e-10.

Functions that rewrite X need copy=True here. The file is open read-only, so they raise a clear error instead of failing inside h5py:

try:
    mt.pp.normalize(backed, by="Metadata_Plate")
except ValueError as error:
    print(error)
normalize rewrites X, which a backed object holds read-only on disk. Pass copy=True to get the result in memory, or call adata.to_memory() first.