citeqc
Measure how well each antibody in a CITE-seq experiment is predicted by its own transcript, and check your analysis for artefacts that produce confident, biologically plausible false positives.
Documentation: https://elmasnuryilmaz.github.io/citeqc/
The short version of why this exists: a third to a half of surface markers cannot be read off the transcriptome, and which ones is predictable from molecular mechanism rather than random. CD45RA is an alternative splice product of PTPRC; an antibody distinguishes CD45RA from CD45RO, total PTPRC mRNA cannot. CTLA-4 and CD40L sit in intracellular vesicles until the cell is triggered, so their surface level is set by trafficking, not transcription.
Across four public CITE-seq datasets (~410,000 cells, 118 markers, 2017–2024), CD45RA was the lowest-coupled marker in every dataset that measured it (rank 1 of 13, 1 of 113, 1 of 29).
Install
pip install citeqc
Optional .h5mu support:
pip install 'citeqc[anndata]'
Python 3.10 or newer.
Quick start
citeqc ships a small synthetic dataset (simulated, not real data) in which every antibody's behaviour is known by construction, so you can try the whole workflow in seconds:
citeqc example citeqc_example
citeqc couple --adt citeqc_example/adt.csv --rna citeqc_example/rna.csv \
--meta citeqc_example/meta.csv --normalise
citeqc diagnose --adt citeqc_example/adt.csv --meta citeqc_example/meta.csv \
--groups citeqc_example/groups.csv
The quick start walks
through the output, and
examples/quickstart.py
does the same from Python.
Measure coupling
import pandas as pd
from citeqc import coupling, mapping
adt = ... # cells x antibodies, CLR-normalised
rna = ... # cells x genes, log-normalised
meta = ... # cells x [donor, celltype, nCount_ADT, nCount_RNA]
pairs, unmapped = mapping.map_antibodies(adt.columns, rna.columns)
result = coupling.measure(adt, rna, meta, pairs)
result["pct_rank"] = coupling.rank_percentile(result)
result has one row per antibody: median_rho is the headline number, with
q25/q75 across strata and draws.
Same thing from the shell:
citeqc couple --adt adt.csv --rna rna.csv --meta meta.csv --out coupling.csv
Add --normalise if your matrices are raw counts, and --transpose if they are
features × cells.
measure(..., return_raw=True) also returns every stratum × draw observation,
which is what a donor-level bootstrap needs (resample donors, recompute the
per-marker medians, and ask how often a marker keeps its rank).
Is a low value specific to that marker?
A correlation of 0.02 is hard to read on its own: it may be a decoupled marker,
or a marker the assay cannot measure. specificity correlates every antibody
with every gene in a background set, using the same stratification, grouping and
depth adjustment, and reports where the cognate gene falls in that antibody's row.
matrix, summary = coupling.specificity(adt, rna, meta, pairs)
summary[["marker", "cognate_rho", "spec_percentile", "rank_of_cognate"]]
spec_percentile of 1.0 means the protein tracks its own transcript better than
any other gene; about 0.5 means the cognate gene is indistinguishable from the
background. The background is descriptive rather than a null (genes that truly
co-vary with the antibody sit in it), and a marker can be low on measure yet
specific here: weak coupling is not the same as no coupling. In our own data
CD45RA's transcript, PTPRC, ranked 110th of 111 genes in the CD45RA row, while
the same gene ranked first for CD45RO.
How to read the result
Absolute correlations depend on how finely you split cell types, so they are
not comparable between datasets; compare pct_rank instead. Within a
dataset, calibrate against a marker that should couple well in the cells you are
looking at (CD8/CD8A in CD8⁺ T cells, CD19/CD19 in B cells). That value is
your ceiling; markers far below it are not readable from RNA.
Three constraints the function enforces for you
- Stratification by donor × cell type. Cell-type composition moves protein and mRNA together, so an unstratified correlation largely measures composition. This is the single biggest source of inflated coupling.
- Equal-sized pseudo-groups. A mean over more cells is less noisy, so unequal group sizes create a size-driven gradient across every feature.
- Library depth regressed out in both modalities.
Check your analysis
Five checks, each built on a negative control the data already contains. They answer one question: what would this analysis report if there were no signal? A check flags a problem; it does not say where the problem comes from.
from citeqc import diagnostics as dx
dx.panel_consistency(adt, meta, by="donor") # is one panel measured everywhere?
dx.control_check(my_zscores, control_markers=["CD19", "CD20"])
dx.size_confounding(pseudobulk, sizes=n_cells_per_group)
dx.pvalue_sanity(my_pvalues)
dx.saturation_check(psi_matrix) # bounded values: PSI, fractions
Each returns a Finding with .passed, a .summary you can print, and
.detail with the numbers.
citeqc diagnose --adt adt.csv --meta meta.csv --pvalues p.csv
Exit code is non-zero if any check flags, so it drops into CI.
| Check | What goes wrong | How you can tell |
|---|---|---|
panel_consistency |
Antibody panels differ between donors; "absent" and "not measured" get confused | Donors measure different sets of antibodies; some donors unstained |
control_check |
The statistic carries a marker-independent gradient | Lineage-impossible markers (CD19 on T cells) score like genuine ones |
size_confounding |
Group means drift with group size, inflating low-expressed features | Almost every feature moves the same direction with size |
pvalue_sanity |
Constant or saturated features flatten the null | p-values depleted near zero, or a spike in the interior |
saturation_check |
Bounded features pinned at 0/1 carry no information but enter FDR | Large fraction identical across all samples |
size_confounding deserves emphasis. In our own analysis, before correction it
produced 8,881 of 11,364 genes at FDR<0.05: 8,797 "up" against 84 "down". After
resampling to a fixed number of cells per group, 2 genes survived (both
"down"). diagnostics.equal_n_resample does the resampling.
control_check and size_confounding are meant to be run in that order, and the
second one first if the first one flags. In that analysis the four control
markers scored a mean |Z| of 2.56 against 3.66 for genuine T-cell markers; equal-N
resampling took the controls to 0.39, whereas regressing out an ambient score
built from the other controls left them at 2.40. We first attributed the
gradient to ambient signal, and that attribution was wrong: an ambient
correction should be judged on control markers held out of the correction.
ambient_check remains as an alias of control_check.
What each check guards against and how it decides is explained in the diagnostics guide.
The antibody → gene table
145 entries covering common TotalSeq naming, with mechanism annotations where a documented post-transcriptional mechanism exists.
citeqc map --query cd45
citeqc map --mechanism vesicular
map_antibodies tries the raw name, a normalised form (CD3--UCHT1-TSA → CD3),
then a form with a trailing replicate index removed (CD38-1 → CD38).
Antibodies it cannot place are returned as unmapped, never guessed. Extend it
with map_antibodies(..., extra={"MyAb": "GENE"}).
Three caveats: the mapping is one-to-one, so it cannot express multi-gene or
post-translationally modified epitopes; HLA-DR is mapped to HLA-DRA alone;
and CD45RA and CD45RO both map to PTPRC, which is the point rather than a
limitation.
What it does not do
It does not normalise for you beyond the helpers in coupling, denoise ADT
(see totalVI or dsb), or correct ambient RNA (see SoupX, CellBender; note that
neither addresses the antibody signal). It measures and it checks. Run the
measurement on the normalisation you trust and compare ranks between them; the
accompanying paper does this for CLR, log-CP10K, isotype subtraction, dsb and totalVI
(under totalVI the headline marker's rank weakens, which is the kind of result this comparison is for).
Development
pip install -e '.[test,anndata]'
pytest
The tests include synthetic data where the coupled and decoupled markers are
known by construction. tests/validate_on_real_data.py (run separately; it
needs the analysis repository's processed data) checks that the package
reproduces the prototype's published results on real data, to Spearman
ρ = 1.000.
Release steps for maintainers are in the
releasing guide; changes
are listed in CHANGELOG.md.
Citing
If you use this, please cite it using
CITATION.cff,
the accompanying paper (in preparation), and the datasets you analyse.
License
MIT
Metadata
Release files for citeqc 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| citeqc-0.2.0.tar.gz | 41.5 kB | With provenance | Details for citeqc-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| citeqc-0.2.0-py3-none-any.whl | Python 3 | none | any | Details for citeqc-0.2.0-py3-none-any.whl |
Total release size: 69.1 kB
Release files / citeqc-0.2.0.tar.gz
| Download URL | citeqc-0.2.0.tar.gz |
|---|---|
| Size | 41.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
7f02999a53550aae313dcd70fab6e281af0821fda5e610cd20866011b167c3ce
|
|
BLAKE2b-256 checksum How to use checksums |
aa593f66f6f97070f08191b4c86671805ebc728b406bb498c436502cdd42f63e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logRelease files / citeqc-0.2.0-py3-none-any.whl
| Download URL | citeqc-0.2.0-py3-none-any.whl |
|---|---|
| Size | 27.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
cc3fba0d8792cb9901ec1ca6a7ad2471f3fc542f451ea71bbdd1d86ddd02809a
|
|
BLAKE2b-256 checksum How to use checksums |
57aec8524a9fbda223ee5e2a4dfb7feb63f6c8460fd9ea4bfd57300a0dec6f39
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logSecurity for citeqc 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for citeqc-0.2.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.