Quick start¶
This page runs the whole workflow on a small synthetic CITE-seq dataset that ships with citeqc. Nothing to download, and it takes a few seconds.
Simulated data
The example dataset is generated by
citeqc.datasets.synthetic. It is not
real data and supports no biological conclusion. It exists so that every
antibody's behaviour is known by construction and you can check that
citeqc recovers it.
The example dataset¶
3,150 T cells from 4 simulated donors x 2 cell types (CD4 T, CD8 T), with
15 antibodies and 25 genes as raw UMI counts. The design, also written to
design.csv:
| Design | Antibodies (cognate gene) | Built so that |
|---|---|---|
| coupled | CD3 (CD3E), CD2, CD5, CD7, CD27, CD28, CD62L (SELL), CD127 (IL7R), CD45RO (PTPRC) | the protein follows its transcript's per-cell state, plus protein-specific noise |
| decoupled | CD45RA (PTPRC), CD152 (CTLA4), CD154 (CD40LG) | the protein varies independently of its transcript |
| absent | CD19, CD20 (MS4A1) | B-cell markers on T cells: only ambient antibody background, so they are negative controls |
| isotype | IgG1 | no cognate gene |
Two artefacts are planted on purpose:
- donor
D4was stained with a panel that lacks CD154 (its values are NaN); groups.csvholds pseudobulk profiles of 120 "pseudo-clonotypes" of 5 to 300 cells drawn at random from the same cells, so no gene truly depends on group size.
The RNA table is a 25-gene subset of the transcriptome, so nCount_RNA in
meta.csv (the whole-library size) is larger than the row sums of rna.csv.
From the command line¶
Write the example files:
citeqc example citeqc_example
Measure coupling. The inputs are raw counts, so --normalise applies CLR to
the antibodies and log counts per 10,000 (scaled by nCount_RNA) to the RNA:
citeqc couple --adt citeqc_example/adt.csv --rna citeqc_example/rna.csv \
--meta citeqc_example/meta.csv --normalise --show 5
mapped 14/15 antibodies to cognate genes
unmapped: IgG1
wrote coupling.csv (14 markers)
marker gene median_rho q25 q75 n_obs n_strata pct_rank mechanism
CD45RO PTPRC 0.634118 0.536669 0.693041 80 8 1.000000 splice_isoform
CD3 CD3E 0.562182 0.469183 0.620787 80 8 0.928571
CD2 CD2 0.474126 0.392595 0.567565 80 8 0.857143
CD27 CD27 0.442038 0.352269 0.542192 80 8 0.785714
CD62L SELL 0.437058 0.378516 0.538786 80 8 0.714286
...
marker gene median_rho q25 q75 n_obs n_strata pct_rank mechanism
CD154 CD40LG 0.072873 -0.034383 0.185222 60 6 0.357143 vesicular
CD19 CD19 0.007468 -0.164522 0.121502 70 7 0.285714
CD152 CTLA4 0.006122 -0.135190 0.118422 80 8 0.214286 vesicular
CD20 MS4A1 -0.001092 -0.123478 0.107775 60 6 0.142857
CD45RA PTPRC -0.034411 -0.147020 0.071562 80 8 0.071429 splice_isoform
Run the checks. The exit code is 1 because both checks flag, which is what
lets citeqc diagnose stop a pipeline:
citeqc diagnose --adt citeqc_example/adt.csv --meta citeqc_example/meta.csv \
--groups citeqc_example/groups.csv
[FLAG] panel_consistency: 2 different panels across the remaining donors (sizes [14, 15]); core panel = 14 markers measured everywhere
[FLAG] size_confounding: 80% of features move the same way with group size; resample to a fixed number of cells per group (see equal_n_resample) and re-run
0/2 checks passed
From Python¶
examples/quickstart.py
does the same, plus the specificity analysis, the negative-control check and
the equal-N fix for the group-size artefact:
"""citeqc quick start on the bundled synthetic dataset (simulated, not real data).
Runs in a few seconds:
python examples/quickstart.py
Every antibody's behaviour is known by construction (see citeqc.datasets.DESIGN),
so you can check each result against the truth.
"""
import numpy as np
import pandas as pd
from citeqc import coupling, datasets, mapping, diagnostics as dx
pd.set_option("display.width", 120)
# 1. Data: raw counts, cells in rows. meta holds donor, celltype and library sizes.
adt, rna, meta = datasets.synthetic(seed=0)
print(f"{len(adt)} cells, {adt.shape[1]} antibodies, {rna.shape[1]} genes, "
f"{meta.donor.nunique()} donors x {meta.celltype.nunique()} cell types")
# 2. Is the same antibody panel measured in every donor? Check before anything else.
panel = dx.panel_consistency(adt, meta, by="donor")
print("\n" + str(panel))
print(" panel sizes per donor:", panel.detail["panel_sizes"])
# 3. Normalise: CLR for antibodies, log counts per 10k of the library for RNA.
adt_n = pd.DataFrame(coupling.clr(adt.values), index=adt.index, columns=adt.columns)
rna_n = coupling.log_normalise(rna, total=meta["nCount_RNA"])
# 4. Pair antibodies with their cognate genes. Unknown names are reported, never guessed.
pairs, unmapped = mapping.map_antibodies(adt_n.columns, rna_n.columns)
print(f"\nmapped {len(pairs)}/{adt.shape[1]} antibodies; unmapped: {unmapped}")
# 5. Coupling: within donor x cell type, equal-sized pseudo-groups, depth regressed out.
res = coupling.measure(adt_n, rna_n, meta, pairs)
res["pct_rank"] = coupling.rank_percentile(res)
res["mechanism"] = [mapping.mechanism_of(m) for m in res.marker]
res["design"] = [datasets.DESIGN[m][1] for m in res.marker]
print("\nprotein~mRNA coupling (design = the truth the data were simulated from):")
print(res[["marker", "gene", "median_rho", "q25", "q75", "n_strata", "pct_rank",
"mechanism", "design"]].round(3).to_string(index=False))
# 6. Specificity: does each protein track its own transcript better than other genes?
_, spec = coupling.specificity(adt_n, rna_n, meta, pairs, genes=list(rna_n.columns))
print("\nwhere the cognate gene ranks among all 25 genes:")
print(spec[["marker", "cognate_rho", "spec_percentile", "rank_of_cognate"]]
.round(3).to_string(index=False))
# 7. Negative controls: CD19 and CD20 are absent from T cells and must not "couple".
print("\n" + str(dx.control_check(res.set_index("marker").median_rho,
control_markers=["CD19", "CD20"])))
# 8. Group size: pseudo-clonotypes of 5-300 cells drawn from the same cells, so no
# gene truly depends on group size. The pseudobulk still drifts with size ...
values, sizes, members = datasets.pseudoclonotypes(rna, meta)
print("\n" + str(dx.size_confounding(values, sizes)))
# ... and averaging exactly 5 cells per group removes the drift.
cp10k = rna.div(meta["nCount_RNA"], axis=0) * 1e4
draw = next(dx.equal_n_resample(members, n=5, n_draw=1, seed=0))
equal_n = pd.DataFrame({g: np.log1p(cp10k.loc[cells].mean()) for g, cells in draw.items()}).T
print(dx.size_confounding(equal_n, sizes.loc[equal_n.index]))
Its output (the last digits can differ between NumPy versions):
3150 cells, 15 antibodies, 25 genes, 4 donors x 2 cell types
[FLAG] panel_consistency: 2 different panels across the remaining donors (sizes [14, 15]); core panel = 14 markers measured everywhere
panel sizes per donor: {'D1': 15, 'D2': 15, 'D3': 15, 'D4': 14}
mapped 14/15 antibodies; unmapped: ['IgG1']
protein~mRNA coupling (design = the truth the data were simulated from):
marker gene median_rho q25 q75 n_strata pct_rank mechanism design
CD45RO PTPRC 0.634 0.537 0.693 8 1.000 splice_isoform coupled
CD3 CD3E 0.562 0.469 0.621 8 0.929 coupled
CD2 CD2 0.474 0.393 0.568 8 0.857 coupled
CD27 CD27 0.442 0.352 0.542 8 0.786 coupled
CD62L SELL 0.437 0.379 0.539 8 0.714 coupled
CD5 CD5 0.389 0.307 0.484 8 0.643 coupled
CD28 CD28 0.353 0.216 0.438 8 0.571 coupled
CD7 CD7 0.347 0.212 0.481 8 0.500 coupled
CD127 IL7R 0.346 0.246 0.440 8 0.429 coupled
CD154 CD40LG 0.073 -0.034 0.185 6 0.357 vesicular decoupled
CD19 CD19 0.007 -0.165 0.122 7 0.286 absent
CD152 CTLA4 0.006 -0.135 0.118 8 0.214 vesicular decoupled
CD20 MS4A1 -0.001 -0.123 0.108 6 0.143 absent
CD45RA PTPRC -0.034 -0.147 0.072 8 0.071 splice_isoform decoupled
where the cognate gene ranks among all 25 genes:
marker cognate_rho spec_percentile rank_of_cognate
CD3 0.562 1.000 1
CD2 0.474 1.000 1
CD5 0.389 1.000 1
CD7 0.347 1.000 1
CD27 0.442 1.000 1
CD28 0.353 1.000 1
CD62L 0.437 1.000 1
CD127 0.346 1.000 1
CD45RO 0.634 1.000 1
CD45RA -0.034 0.333 17
CD152 0.006 0.625 10
CD154 0.073 0.958 2
CD19 0.007 0.750 7
CD20 -0.001 0.708 8
[PASS] control_check: negative controls are 0.01x the real-marker magnitude
[FLAG] size_confounding: 80% of features move the same way with group size; resample to a fixed number of cells per group (see equal_n_resample) and re-run
[PASS] size_confounding: 64% of features move the same way with group size
Reading the result¶
- Coupled and decoupled markers separate cleanly. All nine coupled
antibodies have
median_rhobetween 0.35 and 0.63 and their cognate gene ranks first among the 25 genes. The decoupled and absent ones sit near zero. mechanismis an annotation, not a result. It comes from the bundled table and marks a documented post-transcriptional mechanism. Here CD45RO carriessplice_isoformyet couples well, because the simulation made it follow PTPRC. In real data, use the annotation to interpret a low value, not to predict one.spec_percentileis only meaningful when the coupling is real. For a marker whose correlations with every gene are near zero, where the cognate gene falls among them is noise: CD154 ranks 2nd of 25 withcognate_rho= 0.07. Readspec_percentiletogether withcognate_rho.n_strataexposes the missing panel. CD154 is computed from 6 strata, not 8, because donor D4 never measured it, which is whatpanel_consistencyreports. For CD19 and CD20 some strata drop out because their RNA is almost always zero, so the correlation is undefined there.- CD45RA comes out slightly negative, not zero. That is a property of the per-cell ADT normalisation, not of the coupling measure. CLR centres each cell on the mean of all its antibodies, and that mean includes CD45RO, which follows PTPRC by construction; on this 15-antibody panel, CD45RA inherits a small negative dependence on PTPRC. Leaving CD45RO out of the CLR brings CD45RA back to about zero. Any normalisation that divides by a per-cell antibody total behaves the same way, and the effect shrinks as the panel grows.
- The size artefact is pure statistics. The pseudo-clonotypes are random samples of the same cells, yet 80% of genes drift the same way with group size, because the log of a mean over few cells is pulled down more than the log of a mean over many. Averaging exactly 5 cells per group removes it.
Next steps¶
Replace the example files with your own tables (cells in rows; see the
CLI reference) or call the Python API directly. If your
data are in a .h5mu file, io.from_mudata
returns the three tables measure expects.