Skip to content

MuData builder

cau mudata-builder assembles a sequenced channel's two count matrices into one object. The four steps before it describe a library on paper; IGVF export ends at guide_metadata.tsv. This is where the design meets the data.

In Claude Code the cau-mudata-builder skill teaches Claude to run it and read the output (Agents and skills).

Quick start

cau mudata-builder run \
    --rna counts_unfiltered/adata.h5ad \
    --guide counts_unfiltered_modified/adata.h5ad \
    --guide-metadata results/igvf/guide_metadata.tsv \
    --store gencode.v43.annotation.parquet \
    --measurement-set IGVFDS8842PXCC \
    --gtf-accession IGVFFI9561BASO \
    --outdir results/

--channel NAME=VALUE is repeatable and says what the channel is: replicate, day, chip, batch. It joins every cell's obs and is kept in uns['channel']. --guide-annotation and --element-mapping carry those tables whole into mod['guide'].uns, since neither is one row per guide.

Nothing is written outside --outdir.

What it assembles

Where What
mod['gene'].X the gene counts, untouched, with the kb layers beside them
mod['gene'].var symbol, gene_type, gene_chr, gene_start, gene_end
mod['guide'].X the guide counts, untouched
mod['guide'].var every column of guide_metadata.tsv
obs barcode, measurement_set, and any --covariates given
obsm['gene'], obsm['guide'] which modality holds each cell
uns['reference'] the GTF and genome: name, md5 and accession

The layout follows the IGVF CRISPR pipeline's own object, so the two can be compared field by field.

It assembles, and nothing else

No per-cell or per-feature statistics, no QC, no filtering, no doublet calls, no guide assignment, no pairs to test. Every one of those is a judgement, and a step that makes judgements while assembling hides them. They belong to the steps after this one, where they can be seen and argued with.

Cells are the union

A barcode seen in one modality and not the other is kept, and obsm records which modality holds it. On one real channel that is 1,022,982 barcodes against 207,274 seen by both: an intersection would discard four fifths of the object before anyone had looked at it. Filtering comes later, once somebody has decided what to filter on.

Every gene type is annotated

The counts come from a transcriptome built on the whole GTF, so two thirds of the features are not protein-coding:

protein_coding           20,066
lncRNA                   18,874
processed_pseudogene     10,150
unprocessed_pseudogene    2,608
misc_RNA                  2,221
snRNA                     1,910
...                      62,757 in total

Annotating only the coding genes would leave 42,691 features nameless, so the store is read with every gene type it holds, not only the ones guides are annotated against.

Two traps

PAR genes appear twice. 47 features carry a _PAR_Y suffix and have a chrX twin with identical sequence, so kallisto cannot tell their reads apart and counts them as two features. Both rows are carried through untouched. Anything downstream that sums or de-duplicates them is making a choice; this step does not.

The ids carry GENCODE versions. kb indexes genes as ENSG00000290825.1 while the store drops the version, and the two are joined on the store's own rule: strip a trailing .N, leave _PAR_Y alone. A rule that split on the first dot instead would hand the chrY copy the chrX gene's coordinates, and nothing would complain.

One GENCODE, checked

Genes and guides must be annotated against the same release, or the two halves of the object describe different genomes. The run reads the record beside guide_metadata.tsv and refuses a store other than the one that wrote it. Where no record is found it says so rather than assuming.

uns['reference'] then carries the GTF and the genome by name, checksum and accession, so the object answers "which GENCODE, which genome" on its own. A path is a claim; a checksum is evidence. Accessions come from --gtf-accession and --genome-accession -- the CRISPR pipeline's inputs carry the IGVF portal ids -- and fall back to the file's name, so the field is never empty and never a guess. Only the name is stored, never the path: the object is shared, and a path carries the filesystem it was built on.