Skip to content

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 D4 was stained with a panel that lacks CD154 (its values are NaN);
  • groups.csv holds 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_rho between 0.35 and 0.63 and their cognate gene ranks first among the 25 genes. The decoupled and absent ones sit near zero.
  • mechanism is an annotation, not a result. It comes from the bundled table and marks a documented post-transcriptional mechanism. Here CD45RO carries splice_isoform yet 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_percentile is 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 with cognate_rho = 0.07. Read spec_percentile together with cognate_rho.
  • n_strata exposes the missing panel. CD154 is computed from 6 strata, not 8, because donor D4 never measured it, which is what panel_consistency reports. 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.