Skip to content

Python API

from citeqc import coupling, diagnostics, mapping, datasets, io

citeqc.coupling

Protein ~ cognate-mRNA coupling.

The measurement answers: within a homogeneous group of cells, does a marker's antibody signal track its own transcript?

Three design constraints make the number meaningful, and all three are enforced here rather than left to the caller:

  1. Stratify by donor x cell type. Differences in cell-type composition move protein and mRNA together, so an unstratified correlation mostly measures composition and is strongly inflated.
  2. Use equal-sized pseudo-groups. A mean over cells is less noisy when more cells are averaged, so groups of unequal size introduce a size-driven gradient (see diagnostics.size_confounding and diagnostics.equal_n_resample).
  3. Regress out library depth in both modalities before correlating.

Absolute values depend on how finely cell types are split, so compare ranks between datasets, not raw correlations (rank_percentile).

clr

clr(x, axis=1)

Centered log-ratio, the standard ADT normalisation (axis=1: per cell).

log_normalise

log_normalise(counts, total=None, scale=10000.0)

log1p(counts per scale), the standard RNA normalisation.

measure

measure(
    adt,
    rna,
    meta,
    pairs,
    *,
    strata=("donor", "celltype"),
    group_size=10,
    n_draw=10,
    min_groups=25,
    seed=0,
    depth_cols=("nCount_ADT", "nCount_RNA"),
    return_raw=False,
    min_obs=10,
)

Measure protein~mRNA coupling for each antibody/gene pair.

Parameters:

Name Type Description Default
adt DataFrame

Cells x antibodies, already normalised (see clr).

required
rna DataFrame

Cells x genes, already normalised (see log_normalise).

required
meta DataFrame

Cells x metadata; must contain strata and depth_cols.

required
pairs dict

{antibody: gene}, e.g. from mapping.map_antibodies.

required
strata tuple of str

Metadata columns whose combinations define homogeneous groups.

('donor', 'celltype')
group_size int

Cells averaged into each pseudo-group.

10
n_draw int

Random partitions into pseudo-groups per stratum.

10
min_groups int

Pseudo-groups a stratum must yield to be used, so a stratum needs at least group_size * min_groups cells.

25
seed int

Seed for the random partitions.

0
depth_cols tuple of str

Columns of meta holding the raw ADT and RNA library sizes.

('nCount_ADT', 'nCount_RNA')
return_raw bool

Also return every stratum x draw observation. Needed for anything that must respect the donor structure, such as a donor bootstrap, a per-cell-type breakdown, or weighting by variance.

False
min_obs int

Markers with fewer stratum x draw observations than this are left out of the summary.

10

Returns:

Name Type Description
summary DataFrame

One row per marker: median_rho (the headline statistic), q25 / q75 across strata and draws, n_obs, n_strata. Sorted by median_rho, highest first.

raw DataFrame

Only with return_raw=True, which returns (summary, raw). One row per observation: marker, gene, one column per stratum variable, draw, rho, the standard deviation of the residualised protein (sd_adt) and mRNA (sd_rna) across pseudo-groups, and the number of pseudo-groups. Observations are not independent (draws within a stratum share cells; strata within a donor share the donor), so resample donors, not rows.

Raises:

Type Description
ValueError

If required columns are missing, no cells or pairs are shared, or no marker reaches min_obs observations.

specificity

specificity(
    adt,
    rna,
    meta,
    pairs,
    *,
    genes=None,
    strata=("donor", "celltype"),
    group_size=10,
    n_draw=10,
    min_groups=25,
    seed=0,
    depth_cols=("nCount_ADT", "nCount_RNA"),
    max_gb=2.0,
)

Is a marker's coupling to its own transcript above the background of other genes?

A small correlation is hard to read on its own. This correlates every antibody with every gene in genes, using the same stratification, pseudo- grouping and depth adjustment as measure, and asks where the cognate gene falls within that antibody's row.

The background is not a null: genes that truly co-vary with the antibody (CD3E with CD8A in T cells) sit in it. Read the percentile as descriptive. With the same seed, the cognate entries equal measure's medians.

Parameters:

Name Type Description Default
adt DataFrame

Cells x antibodies, already normalised, as in measure.

required
rna DataFrame

Cells x genes, already normalised, as in measure.

required
meta DataFrame

As in measure.

required
pairs dict

As in measure.

required
genes list of str

Genes forming the background. Default: the cognate genes of pairs, i.e. the other markers' genes. Pass a broader list for a wider background, but note memory grows with markers x genes x observations.

None
strata tuple of str

As in measure.

('donor', 'celltype')
group_size int

As in measure.

10
n_draw int

As in measure.

10
min_groups int

As in measure.

25
seed int

As in measure.

0
depth_cols tuple of str

As in measure.

('nCount_ADT', 'nCount_RNA')
max_gb float

Refuse to run if the stored observations would exceed this size.

2.0

Returns:

Name Type Description
matrix DataFrame

Antibodies x genes, the median correlation.

summary DataFrame

One row per antibody: cognate_rho, background_mean, background_sd, spec_percentile (share of other genes the cognate gene beats: 1.0 means the protein tracks its own transcript better than any other), z_vs_background, rank_of_cognate and n_genes.

rank_percentile

rank_percentile(df, col='median_rho')

Within-dataset percentile rank, the unit to compare across datasets.

citeqc.diagnostics

Five checks for artefacts that produce confident-looking false positives.

Each check is built around the same question: what would this analysis report if there were no signal at all? None of them needs a ground truth: they use negative controls that the data already contains.

panel_consistency   antibody panel differs between donors/batches
control_check       markers that cannot be expressed behave like real signal
size_confounding    a per-group statistic tracks group size
pvalue_sanity       the p-value distribution is not what a null looks like
saturation_check    bounded values (PSI, fractions) are pinned at 0/1

control_check diagnoses; it does not explain. When it flags, test group size (size_confounding, equal_n_resample) before reaching for an ambient-signal correction: in the data this package was developed on, resampling to equal N removed the gradient and regressing out ambient signal did not.

Finding dataclass

The result of one check.

Attributes:

Name Type Description
check str

Name of the check.

passed bool

False when the check flags a problem.

summary str

One line for humans; str(finding) prefixes it with PASS or FLAG.

detail dict

The numbers behind the decision. Keys depend on the check.

panel_consistency

panel_consistency(adt, meta, by='donor', min_detect=0.5)

Is the same antibody panel measured in every donor/batch?

Heterogeneous panels are common in multi-site CITE-seq and silently turn "marker absent" into "marker not measured". An antibody counts as measured in a group when more than min_detect of its cells have a value, so a marker that was not measured must be NaN, not 0. Returns the core panel (the markers present everywhere), which is what cross-donor analysis may use.

control_check

control_check(
    stat,
    control_markers,
    signal_markers=None,
    ratio_threshold=0.5,
)

Do markers that cannot be expressed behave like real signal?

stat is your own per-marker test statistic (Z, -log10 p, effect size...). control_markers should be absent from the cells analysed: CD19/CD20 on T cells, CD3 on monocytes. Choose markers that are truly absent: CD16, CD56 and CD161 occur on CD8 T-cell subsets and make poor controls there.

Flags when the controls reach a comparable magnitude to the real markers. That means the statistic contains a marker-independent gradient. It does not say where the gradient comes from. Group size is the first suspect (size_confounding); ambient signal is the second, and a correction for it should be judged on control markers held out of the correction.

size_confounding

size_confounding(
    values, sizes, method="spearman", skew_threshold=0.75
)

Does a per-group statistic track how many cells each group contains?

values is groups x features (clonotype pseudobulk, cluster means...); sizes the number of cells per group, in the same row order as values (a Series indexed by the same group labels is matched by label). Because a mean over more cells is less noisy, low-expressed features drift upward with group size. The signature is a pervasive one-directional association across features; real biology does not move almost every feature the same way.

equal_n_resample

equal_n_resample(cell_groups, n, n_draw=10, seed=0)

Yield n_draw dicts {group: array of exactly n cell ids}.

Groups with fewer than n cells are dropped; that exclusion is the price of removing the size artefact, and is reported by the caller.

pvalue_sanity

pvalue_sanity(
    pvalues, alpha=(0.001, 0.01, 0.05), spike_bins=20
)

Is this p-value distribution consistent with a working test?

Three regimes matter. Enriched near zero means signal. Uniform means no signal. Depleted near zero means the test is broken, commonly because many features are constant or saturated and yield p close to 1, which also shows up as a spike somewhere in the interior.

saturation_check

saturation_check(
    values,
    lo=0.02,
    hi=0.98,
    min_range=0.05,
    max_saturated=0.25,
)

What fraction of bounded features (PSI, fractions) is pinned at 0 or 1?

Saturated features carry no information but do enter multiple-testing correction and flatten the p-value distribution. Returns the index of the features worth testing.

citeqc.mapping

Antibody -> cognate gene lookup.

load_map

load_map()

The bundled antibody->gene table (a copy; edit freely).

normalise

normalise(name)

Strip common CITE-seq panel decorations from an antibody name.

map_antibodies

map_antibodies(antibodies, genes=None, extra=None)

Map antibody names to cognate gene symbols.

Tries the raw name, then a normalised form, then the normalised form with a trailing replicate index removed ('CD38-1' -> 'CD38').

Returns (pairs, unmapped) where pairs is {antibody: gene}. If genes is given, pairs are restricted to genes actually present.

mechanism_of

mechanism_of(antibody)

Documented post-transcriptional mechanism for an antibody, or ''.

citeqc.io

Readers. CSV/TSV always, parquet with pyarrow, AnnData/MuData with the anndata extra.

read_matrix

read_matrix(path, transpose=False)

Read a table whose first column is the row index (cell barcodes or group names).

Tab-separated for .tsv/.txt, comma-separated otherwise; gzip, bz2, xz, zip and zstd compression are detected from the extension. .parquet files need pyarrow (or fastparquet). Pass transpose=True if the file is features x cells.

from_mudata

from_mudata(
    path,
    adt_key="prot",
    rna_key="rna",
    strata=("donor", "celltype"),
)

Read (adt, rna, meta) from a .h5mu file. Requires the anndata extra.

adt and rna are the .X matrices of the two modalities as cells x features DataFrames, and meta is the global .obs, which must contain the strata columns. Cells missing from either modality are dropped from all three tables.

If .obs has no nCount_ADT / nCount_RNA column, they are computed as the per-cell sum of each modality's .X. That is only a library size if .X holds raw counts: if your .X is already normalised, add the raw totals to .obs before calling this.

citeqc.datasets

A small synthetic CITE-seq dataset with a known answer, for trying citeqc offline.

Nothing here is real data. The counts are simulated, so that every antibody's behaviour is known by construction and you can check that citeqc recovers it. Use it to learn the workflow, not to draw biological conclusions.

Design (see DESIGN):

  • 4 donors x 2 T-cell types (CD4 T, CD8 T), 320-479 cells per stratum.
  • 15 antibodies and 25 genes, as raw UMI counts. The RNA table is a subset of the transcriptome: meta.nCount_RNA is the whole-library size, larger than the row sums of rna.
  • Within each stratum, every gene has a per-cell latent state. Coupled antibodies are driven by the state of their cognate gene plus protein-specific noise; decoupled antibodies (CD45RA, CD152, CD154) vary independently of their transcript; the B-cell markers CD19 and CD20 are absent from T cells and carry only ambient antibody background, so they serve as negative controls; IgG1 is an isotype control with no cognate gene.
  • Donor D4 was stained with a panel that lacks CD154 (NaN in adt), so diagnostics.panel_consistency has something to find.
  • BG01-BG12 are background genes with no antibody, for coupling.specificity.

synthetic

synthetic(seed=0)

Simulate the example dataset.

The same seed gives the same tables on the same NumPy version.

Returns:

Name Type Description
adt DataFrame

Cells x 15 antibodies, raw counts (float, NaN where not measured).

rna DataFrame

Cells x 25 genes, raw counts.

meta DataFrame

Cells x [donor, celltype, nCount_ADT, nCount_RNA].

pseudoclonotypes

pseudoclonotypes(
    rna,
    meta,
    n_groups=120,
    min_size=5,
    max_size=300,
    seed=0,
)

Groups of unequal size drawn from the same cells: a null for group-size effects.

Each group is a random sample of cells (sizes log-uniform between min_size and max_size), so no gene truly depends on group size. The value per group is a pseudobulk, log1p of the mean counts per 10,000 (meta.nCount_RNA as library size). Any association with size that diagnostics.size_confounding finds is therefore the artefact.

Returns:

Name Type Description
values DataFrame

Groups x genes.

sizes Series

Cells per group.

members dict

{group: array of cell ids}, the input equal_n_resample takes.

write_example

write_example(directory, seed=0)

Write the example dataset as CSV files for the command line.

Files: adt.csv, rna.csv, meta.csv (cells in rows), groups.csv (pseudo-clonotype pseudobulk plus an n_cells column, for citeqc diagnose --groups) and design.csv (the ground truth). Returns {name: path}.