Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
b4f927f
ADD the qc module: figures that publish their own numbers
emattei Oct 15, 2026
0a659b6
UPDATE mad_outlier to a sigma-scaled MAD on log10
emattei Oct 15, 2026
fff86b6
FIX the skill's account of why the MAD needs cells first
emattei Oct 15, 2026
91f35d5
ADD metric_distributions, one histogram per QC metric
emattei Oct 15, 2026
f593581
FIX the log bins starting at 1 rather than at the data
emattei Oct 15, 2026
1f4e163
UPDATE the distribution colours: blue kept, grey removed
emattei Oct 15, 2026
5bd9434
FIX cells on the outermost value going uncounted
emattei Oct 15, 2026
fc363fb
FIX the flagged layer painting over the kept cells
emattei Oct 15, 2026
50aaef6
REWRITE metric_distributions to split at each metric's threshold
emattei Oct 15, 2026
cb1b6a2
ADD metric_scatter, two QC metrics against each other
emattei Oct 15, 2026
9440132
UPDATE the scatter to drop its legend when nothing is removed
emattei Oct 15, 2026
578fca6
ADD extra gene classes to cell_fractions
emattei Oct 15, 2026
c13d850
ADD panel_expression and mean_upm
emattei Oct 15, 2026
e1dedf6
FIX the panel caption reading "a inf-fold difference"
emattei Oct 15, 2026
b02b4b7
ADD a unit to metric_scatter so it serves a per-gene comparison
emattei Oct 15, 2026
da685e5
FIX a zero vanishing from a log-axis scatter without saying so
emattei Oct 15, 2026
bc56065
UPDATE the scatter's points to follow how many there are
emattei Oct 15, 2026
b965e5a
FIX the adaptive point size the previous commit only tested
emattei Oct 15, 2026
be7f41a
ADD groups to metric_scatter, colouring by label not by threshold
emattei Oct 15, 2026
a046717
FIX group colours depending on the order the rows arrived in
emattei Oct 15, 2026
5201bb1
UPDATE grouped points to draw solid over a see-through background
emattei Oct 15, 2026
92ebe74
ADD a thin dark outline to the labelled points
emattei Oct 15, 2026
05720ce
UPDATE the distribution panels to say what their lines cut
emattei Oct 15, 2026
d22e79b
FIX the line labels, which sat outside the band and were cut off
emattei Oct 15, 2026
a76b231
FIX the caption, which counted cells and called them bars
emattei Oct 15, 2026
c1dc6a4
FIX the log tick labels, which overlapped on a narrow span
emattei Oct 15, 2026
27d4dc8
ADD the filtering step and the filtered object it writes
emattei Oct 15, 2026
f5b8b03
ADD band, to measure a band for only some of the metrics
emattei Oct 15, 2026
029f3e2
UPDATE the panels to draw the rules the file is cut on
emattei Oct 15, 2026
6809bb5
ADD the guide counts and the guides a channel never saw
emattei Oct 15, 2026
506a3f5
MERGE main into the QC figures branch
emattei Nov 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,123 changes: 1,123 additions & 0 deletions pixi.lock

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ dependencies = [
# inside functions, like pysam, so `cau --help` loads neither.
"anndata>=0.10",
"mudata>=0.3",
# QC figures. Imported inside functions, so a run that draws nothing
# never loads it.
"matplotlib>=3.8",
# QC metrics. scanpy names them as the IGVF pipeline does, so ours are
# directly comparable with the pipeline's own.
"scanpy>=1.10",
]

[project.scripts]
Expand Down
9 changes: 9 additions & 0 deletions src/crispr_analysis_utils/cli/mudata_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ def add_arguments(parser: argparse.ArgumentParser) -> None:
"channel, joined to every cell's obs and kept in uns."
),
)
run_parser.add_argument(
"--readout-genes",
help=(
"The panel a targeted assay reads out, as a table with a column "
"of ENSEMBL ids. Adds a boolean readout column to gene.var; "
"without it the column is absent."
),
)
run_parser.add_argument(
"--covariates",
help=(
Expand Down Expand Up @@ -115,6 +123,7 @@ def run(args: argparse.Namespace) -> int:
genome_accession=args.genome_accession,
covariates=args.covariates,
metadata=_channel(args.channel),
readout_genes=args.readout_genes,
guide_annotation=args.guide_annotation,
element_mapping=args.element_mapping,
)
Expand Down
68 changes: 65 additions & 3 deletions src/crispr_analysis_utils/mudata_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,40 @@ def read_counts(path: str | os.PathLike[str]):
raise BuildError(f"{path} is not a readable h5ad: {error}") from error


def gene_var(store, ids: pd.Index) -> pd.DataFrame:
def read_readout_genes(path: str | os.PathLike[str]) -> pd.Series:
"""The genes a targeted assay reads out, as unversioned ENSEMBL ids.

A TAP-seq panel measures a few hundred genes on purpose; every other
feature in the matrix is off-panel and near-empty. Which is which is not
in the counts, so it comes from the panel's own table: the first column
holding ENSEMBL ids is taken as the gene.

Returns
-------
pandas.Series
How many rows of the panel name each gene, indexed by unversioned
ENSEMBL id. Usually one; a gene measured through two amplicons has
two, and its counts are a sum of both.
"""
path = Path(path)
if not path.is_file():
raise BuildError(f"The readout gene table {path} does not exist.")
frame = pd.read_csv(path, sep="\t" if path.suffix == ".tsv" else ",")
ensembl = [
column
for column in frame.columns
if frame[column].astype(str).str.startswith("ENSG").any()
]
if not ensembl:
raise BuildError(
f"{path} has no column of ENSEMBL gene ids, so it cannot say "
"which genes are read out."
)
genes = _unversioned(frame[ensembl[0]].astype("string"))
return genes.value_counts()


def gene_var(store, ids: pd.Index, readout: pd.Series | None = None) -> pd.DataFrame:
"""The annotation of every gene in the matrix, in the matrix's own order.

Parameters
Expand All @@ -101,6 +134,10 @@ def gene_var(store, ids: pd.Index) -> pd.DataFrame:
only the ones guides are annotated against.
ids
The matrix's gene ids, versioned or not.
readout
The genes the assay reads out, as `read_readout_genes` returns them.
Adds ``readout`` and ``readout_primers``; without it both are
absent, since a whole-transcriptome run reads out everything.

Returns
-------
Expand All @@ -125,8 +162,15 @@ def gene_var(store, ids: pd.Index) -> pd.DataFrame:
# kb indexes genes as the GTF names them, with the version; the store
# strips it. The rule has to be the store's own, or a PAR_Y gene, whose
# id ends in neither, comes back annotated with nothing.
out = annotation.reindex(_unversioned(pd.Series(ids, dtype="string")))
bare = _unversioned(pd.Series(ids, dtype="string"))
out = annotation.reindex(bare)
out.index = pd.Index(ids, name="gene_id")
if readout is not None:
counts = readout.reindex(bare).fillna(0).astype(int)
out["readout"] = (counts > 0).to_numpy()
# Above one, the panel measures the gene through two amplicons and
# the counts are their sum, as n_oligos means on the guide side.
out["readout_primers"] = counts.to_numpy()
return out


Expand Down Expand Up @@ -403,6 +447,7 @@ def run(
guide_annotation: str | os.PathLike[str] | None = None,
element_mapping: str | os.PathLike[str] | None = None,
metadata: dict[str, str] | None = None,
readout_genes: str | os.PathLike[str] | None = None,
) -> RunResult:
"""Assemble one channel's matrices into a MuData, and write it.

Expand Down Expand Up @@ -506,6 +551,13 @@ def run(
state["inputs"][name] = str(path)
state["inputs"][f"{name}_md5"] = file_md5(path)

readout = None
if readout_genes is not None:
readout = read_readout_genes(readout_genes)
state["inputs"]["readout_genes"] = str(readout_genes)
state["inputs"]["readout_genes_md5"] = file_md5(readout_genes)
logger.info("%d genes are read out by the assay", len(readout))

reference = reference_block(
loaded,
gtf_accession=gtf_accession,
Expand All @@ -517,7 +569,7 @@ def run(
data = build(
rna_counts,
guide_counts,
gene_table=gene_var(loaded, rna_counts.var_names),
gene_table=gene_var(loaded, rna_counts.var_names, readout=readout),
guide_table=guide_var(guide_metadata, guide_counts.var_names),
measurement_set=measurement_set,
reference=reference,
Expand All @@ -539,6 +591,16 @@ def run(
"genes": int(data["gene"].n_vars),
"guides": int(data["guide"].n_vars),
"genes_unannotated": int(data["gene"].var["symbol"].isna().sum()),
"genes_readout": (
int(data["gene"].var["readout"].sum())
if "readout" in data["gene"].var
else None
),
"genes_multi_primer": (
int((data["gene"].var["readout_primers"] > 1).sum())
if "readout_primers" in data["gene"].var
else None
),
"guides_unannotated": int(data["guide"].var["spacer"].isna().sum()),
"uns_tables": {k: int(len(v)) for k, v in tables.items()},
}
Expand Down
Loading
Loading