Claude Skill

alterlab-rnaseq-quant

Quantifies bulk RNA-seq transcript abundance with salmon 2.x (the Rust rewrite; selective alignment or --sketch) and kallisto (v0.52.0, kb-python workflow), builds a decoy-aware gentrome index, runs quant with --gcBias -l A, then imports estimates via tximport/tximeta with a tx2g

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download alterlab-ieu-alterlab-academic-skills-skills_bioinformatics_alterlab-rnaseq-quant-e4836c0.zip · 24 KB
Part of alterlab-ieu/alterlab-academic-skills — 94 skills

Install

skills CLI npx skills add https://github.com/AlterLab-IEU/AlterLab-Academic-Skills/tree/main/skills/bioinformatics/alterlab-rnaseq-quant
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install alterlab-ieu-alterlab-academic-skills@llmmart
Git git clone https://github.com/AlterLab-IEU/AlterLab-Academic-Skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole alterlab-ieu/alterlab-academic-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

RNA-seq Quantification — salmon & kallisto Transcript Abundance

The command-line quantification entry point for bulk RNA-seq: take raw FASTQ reads plus a reference transcriptome and produce transcript-level abundance estimates (counts + TPM) with salmon (selective alignment) or kallisto (pseudoalignment via kb-python), then aggregate to the gene level with tximport/tximeta and hand off to alterlab-pydeseq2 for differential expression. It is the raw-data-to-count-matrix pipeline that the repo's Python analysis skills assume already ran.

Quick Start

Quantify these RNA-seq FASTQs with salmon and a decoy-aware index
Build a salmon gentrome index from this transcriptome + genome
Run kallisto / kb count on my paired-end reads
Turn my salmon quant.sf files into a gene-level count matrix for DESeq2

→ Build a decoy-aware index once, run salmon quant (or kb count) per sample, then run scripts/build_tx2gene.py + scripts/import_quant.py to make the tximport gene matrix and route it to alterlab-pydeseq2.


When to Use This Skill

Use this skill when the request is about getting from FASTQ to transcript or gene abundance with a lightweight quantifier:

  • "Quantify my RNA-seq with salmon / kallisto."
  • "Build a decoy-aware salmon index (gentrome + decoys.txt)."
  • "Run selective alignment with --gcBias." / "Use salmon's --sketch mode."
  • "I have quant.sf files — make me a gene-level count matrix for DESeq2."
  • "Set up tximport / tximeta with a tx2gene map."
  • "Use kb-python / kb count to pseudoalign these reads."

Does NOT Trigger — route these to the right sibling

The request is really about… Route to
Differential expression on a count matrix (DESeq2 Wald tests, FDR, volcano) alterlab-pydeseq2
Single-cell RNA-seq quantification (was salmon alevin) piscem + alevin-fry — see references/single_cell_alevin.md; downstream → alterlab-scanpy / alterlab-scvi-tools
FASTQ-to-VCF germline/somatic variant calling alterlab-nf-core-sarek
16S/ITS amplicon (microbiome) FASTQ-to-feature-table alterlab-qiime2-amplicon
Spatial transcriptomics (Visium/Xenium) neighborhood analysis alterlab-squidpy-spatial
Loading/manipulating the resulting matrix as an AnnData object alterlab-anndata
BLAST/DIAMOND sequence similarity search alterlab-blast
Quick gene/transcript ID lookups & reference fetch (Ensembl/RefSeq) alterlab-gget
Aligned BAM manipulation, coverage, read counting from alignments alterlab-pysam

This skill stops at the count/abundance matrix. It does not call DEGs, does not handle single-cell barcodes, and does not align to a genome for variant calling.


Two Critical Correctness Traps (read before quantifying)

These are the three failures most outdated RNA-seq instructions get wrong now that salmon has moved to 2.x (a from-scratch Rust rewrite; bioconda 2.7.0). All are confirmed in the upstream MIGRATION.md (see references/tool_versions.md).

  1. salmon 2.0 cannot read a C++ (pufferfish) index. The index format changed with the rewrite. Loading an old index — or pointing old salmon at a 2.x index — is detected and rejected with a clear error, so this fails loudly rather than silently. Rebuild every index with the same salmon version you quantify with. quant.sf and the bootstrap/Gibbs outputs are unchanged, so tximport, tximeta, fishpond and swish keep working on 2.x output with no changes.

  2. --validateMappings no longer does anything. Selective alignment is the default (and only) alignment mode in 2.x, so the flag parses and logs a warning. Copying it from a 2019-era tutorial is harmless but misleading; drop it. Several other C++ flags now error out: --mimicBT2, --mimicStrictBT2, --minAssignedFrags, --numBiasSamples, --alternativeInitMode.

  3. salmon alevin was REMOVED. Single-cell quantification is no longer part of salmon; salmon alevin … prints a redirect and exits. Use the piscem + alevin-fry ecosystem instead. If the user has single-cell / droplet data, route per the table above and see references/single_cell_alevin.md.

If you genuinely need the old behavior, the final C++ release (salmon 1.12.0) lives on the upstream cpp branch and is packaged separately as salmon-cpp.


Pipeline (salmon, the default path)

1. Build a decoy-aware gentrome index (once per reference)

A decoy-aware index lets salmon distinguish reads that align better to the genome than the transcriptome, reducing spurious assignments. You build a "gentrome" = transcripts FASTA concatenated with the genome FASTA, plus a decoys.txt listing the genome sequence names as decoys.

# 1. decoys.txt = the genome's sequence (chromosome) names, one per line
grep "^>" genome.fa | sed 's/^>//; s/ .*//' > decoys.txt

# 2. gentrome = transcripts FIRST, then genome (order matters)
cat transcripts.fa genome.fa > gentrome.fa

# 3. build the index (rebuild under salmon 2.x — see trap #1)
salmon index \
  -t gentrome.fa \
  -d decoys.txt \
  -i salmon_index \
  -k 31 \
  -p 8
  • -k 31 is the default k-mer; lower it only for very short reads.
  • The helper scripts/make_decoys.py writes decoys.txt and gentrome.fa for you and refuses to proceed if the genome names are absent from the transcript FASTA (a common silent mistake). See references/decoy_index.md.

2. Quantify each sample

salmon quant \
  -i salmon_index \
  -l A \
  -1 sampleA_R1.fastq.gz -2 sampleA_R2.fastq.gz \
  --gcBias \
  -p 8 \
  -o quants/sampleA
  • -l A — auto-detect library type (strandedness). Let salmon infer it unless you have a documented protocol; verify the inferred type in lib_format_counts.json.
  • Selective alignment is the default in salmon 2.x — there is no flag to turn it on (--validateMappings is accepted and ignored). --sketch opts out of it into faster alignment-free pseudoalignment; prefer the default when the quantification feeds differential expression, and reserve --sketch for very large screens where speed dominates.
  • --gcBias — corrects fragment-level GC bias; recommended for DE and cheap to enable. Add --seqBias for 5'/3' sequence-specific bias if needed.
  • --ignoreTxVersion (new in 2.x) — with -g/--geneMap, matches transcript IDs ignoring the trailing .N, the way tximport's option of the same name does. Needed for an Ensembl cDNA index against an Ensembl GTF. When transcripts fail to match the gene map, 2.x writes the offending names to aux_info/genemap_unmatched_txps.json and warns once with a count, instead of C++ salmon's one warning per transcript — check for that file after any run that used -g.
  • For single-end reads, pass -r reads.fastq.gz instead of -1/-2.

Each sample produces quants/<sample>/quant.sf (transcript-level estimates) and quants/<sample>/lib_format_counts.json (the inferred library type). See references/salmon_quant.md for the full flag map and per-sample QC checks.

3. Aggregate to gene level with tximport

Build a transcript→gene map (tx2gene) from your annotation, then summarize the per-sample quant.sf files into a gene-level matrix that pydeseq2 consumes.

# tx2gene from a GTF/GFF3 (transcript_id -> gene_id)
uv run python scripts/build_tx2gene.py annotation.gtf --out tx2gene.tsv

# import + summarize to gene level (tximport "lengthScaledTPM" counts)
uv run python scripts/import_quant.py \
  --quants quants \
  --tx2gene tx2gene.tsv \
  --out-counts gene_counts.tsv \
  --out-tpm gene_tpm.tsv

import_quant.py produces an integer-rounded gene × sample count matrix plus a gene × sample TPM matrix, the inputs alterlab-pydeseq2 expects. It implements tximport's makeCountsFromAbundance(..., "lengthScaledTPM") at the transcript level (scale each transcript's TPM by its sample-averaged effective length, then rescale each sample column back to its mapped-read library size) and sums to genes — so the counts are length-corrected and library-size-scaled, not raw summed NumReads. The canonical R route is the tximport / tximeta Bioconductor packages with countsFromAbundance = "lengthScaledTPM"; the Python helper here reproduces that computation so you can stay in uv (differing only in integer rounding and the absence of tximeta provenance). See references/tximport_handoff.md for the exact semantics, the tximeta linkedTxome metadata option, and when to prefer the R path.

4. Hand off to differential expression

Pass gene_counts.tsv (+ a sample/condition sheet) to alterlab-pydeseq2. This skill does not call DEGs — that is pydeseq2's job (size-factor normalization, dispersion, Wald tests, BH-FDR, volcano/MA plots).


Pipeline (kallisto, the pseudoalignment path)

kallisto (standalone v0.52.0) and the kb-python wrapper (kb) give a faster pseudoalignment route. kb-python drives kallisto | bustools and writes tidy outputs.

# build a kallisto index from the transcriptome
kallisto index -i kallisto_index.idx transcripts.fa

# quantify a paired-end sample
kallisto quant -i kallisto_index.idx -o quants_kallisto/sampleA \
  sampleA_R1.fastq.gz sampleA_R2.fastq.gz

# OR the kb-python workflow (bulk)
# -f1 is the cDNA FASTA kb WRITES; trailing positionals are genome FASTA THEN GTF
kb ref -i index.idx -g t2g.txt -f1 cdna.fa genome.fa annotation.gtf
kb count -i index.idx -g t2g.txt -x bulk -o quants_kb/sampleA \
  sampleA_R1.fastq.gz sampleA_R2.fastq.gz
  • kallisto outputs abundance.tsv / abundance.h5; feed these to tximport (type="kallisto") the same way as salmon's quant.sf.
  • Long reads: kb-python exposes lr-kallisto via the --long flag (and k>31 k-mers) — use it for ONT/PacBio cDNA. See references/kallisto_kb.md.
  • kallisto does not use the decoy/gentrome construction; that is salmon-specific.

Turnkey alternative — nf-core/rnaseq

For an end-to-end, provenance-tracked pipeline (trimming → alignment → quantification → QC), nf-core/rnaseq v3.26.0 runs --aligner star_salmon by default: STAR maps to the genome, projects onto the transcriptome, and Salmon does the quantification. Reach for it when the user wants a reproducible Nextflow pipeline rather than hand-run commands; this skill covers the direct-salmon/kallisto path and the tximport handoff. See references/tool_versions.md.


Offload note

Indexing and per-sample quantification are CPU/IO-heavy but fully offline. On a local workstation these are good candidates to run directly (e.g. overnight) rather than streaming large FASTQs through an API session. Build the index once; quantify samples in a loop.


Self-Check Before Reporting

  • Did you rebuild the salmon index with the same 2.x binary you quantified with (trap #1)? A C++/pufferfish index is rejected outright.
  • Is the index decoy-aware (gentrome + decoys.txt) for salmon? Confirm the genome names made it into decoys.txt.
  • Did you let -l A infer strandedness, and did you sanity-check the inferred type in lib_format_counts.json?
  • Is the data actually single-cell? If so you must NOT use this path — salmon alevin is gone; route to piscem + alevin-fry (trap #2).
  • Did you stop at the count matrix and hand DE off to alterlab-pydeseq2 rather than calling DEGs here?

References

Part of the AlterLab Academic Skills suite.

Files (alterlab-academic-skills)
  • evals
    • evals.json 6 KB
      {
        "skill": "alterlab-rnaseq-quant",
        "evals": [
          {
            "id": "salmon-decoy-quant",
            "prompt": "I have paired-end bulk RNA-seq FASTQs for 6 samples and a human transcriptome plus the genome FASTA. Can you set up salmon to quantify them properly? I want it done the accurate way.",
            "expected_output": "Invokes alterlab-rnaseq-quant on the salmon path. Builds a DECOY-AWARE gentrome index first (decoys.txt from the genome sequence names, gentrome = transcripts concatenated before genome) and runs `salmon index`, then quantifies each sample with `salmon quant -l A --gcBias` (selective alignment is the default in salmon 2.x; `--validateMappings` is accepted but ignored). Explicitly warns that a salmon 2.x index must be rebuilt because 2.0 cannot read C++/pufferfish indices, and checks the inferred library type in lib_format_counts.json. Stops at per-sample quant.sf and offers to summarize to a gene matrix.",
            "assertions": [
              {
                "type": "should_trigger",
                "value": true
              },
              {
                "type": "output_contains",
                "value": "decoy"
              },
              {
                "type": "behavior",
                "value": "Builds a decoy-aware gentrome index and runs salmon quant with --gcBias -l A, and warns that salmon 2.x cannot read a C++/pufferfish index so it must be rebuilt."
              }
            ]
          },
          {
            "id": "tximport-to-deseq2-handoff",
            "prompt": "I already ran salmon and I have a folder of quant.sf files, one per sample. I need to turn these into a gene-level count matrix I can feed into DESeq2. Can you help me aggregate them?",
            "expected_output": "Invokes alterlab-rnaseq-quant in the import/aggregation mode: builds a tx2gene map from the annotation (scripts/build_tx2gene.py) and summarizes the per-sample quant.sf files to a gene-level count matrix via tximport semantics (countsFromAbundance lengthScaledTPM), e.g. scripts/import_quant.py. Produces gene_counts.tsv and then hands the differential-expression step off to alterlab-pydeseq2 rather than calling DEGs itself.",
            "assertions": [
              {
                "type": "should_trigger",
                "value": true
              },
              {
                "type": "output_contains",
                "value": "tximport"
              },
              {
                "type": "behavior",
                "value": "Aggregates quant.sf to a gene-level matrix using a tx2gene map and a lengthScaledTPM import (tximport semantics), then routes the actual differential-expression analysis to alterlab-pydeseq2."
              }
            ]
          },
          {
            "id": "kallisto-kb-pseudoalign",
            "prompt": "I'd rather use kallisto than salmon for speed on my bulk RNA-seq. Can you show me how to build the index and run kb count on my paired-end reads?",
            "expected_output": "Invokes alterlab-rnaseq-quant on the kallisto / kb-python path: builds a kallisto index (or `kb ref`) from the transcriptome and runs `kb count -x bulk` (or `kallisto quant`) on the paired-end reads. Notes that kallisto uses a plain transcriptome index (no decoy/gentrome construction), points to lr-kallisto `--long` for long reads, and explains that abundance.tsv/.h5 feeds tximport identically to salmon before handoff to alterlab-pydeseq2.",
            "assertions": [
              {
                "type": "should_trigger",
                "value": true
              },
              {
                "type": "output_contains",
                "value": "kb count"
              },
              {
                "type": "behavior",
                "value": "Drives the kallisto/kb-python pseudoalignment workflow (kb ref / kb count -x bulk or kallisto index/quant) and notes kallisto needs no decoy construction."
              }
            ]
          },
          {
            "id": "near-miss-pydeseq2-de",
            "prompt": "I already have a gene-level count matrix (genes x samples) and a sample sheet with treated vs control. Run the differential expression analysis and give me the significant DEGs with a volcano plot.",
            "expected_output": "Does NOT invoke this skill; defers to alterlab-pydeseq2. The user already has the count matrix and wants differential expression (size-factor normalization, dispersion, Wald tests, BH-FDR, volcano plot) — that is pydeseq2's job. alterlab-rnaseq-quant only produces the abundance/count matrix from FASTQs; it does not call DEGs.",
            "assertions": [
              {
                "type": "should_not_trigger",
                "value": true
              },
              {
                "type": "output_contains",
                "value": "alterlab-pydeseq2"
              }
            ]
          },
          {
            "id": "near-miss-single-cell-alevin",
            "prompt": "I have 10x Chromium single-cell RNA-seq FASTQs with cell barcodes and UMIs. Can I quantify them with salmon alevin to get a cell-by-gene matrix?",
            "expected_output": "Does NOT invoke this skill's bulk path. Explains that `salmon alevin` was REMOVED in salmon 2.x and that single-cell quantification now uses the piscem + alevin-fry pipeline, then routes downstream single-cell analysis to alterlab-scanpy / alterlab-scvi-tools. This skill covers bulk RNA-seq only and must not force barcoded single-cell data through the bulk salmon/kallisto path.",
            "assertions": [
              {
                "type": "should_not_trigger",
                "value": true
              },
              {
                "type": "output_contains",
                "value": "alevin-fry"
              }
            ]
          },
          {
            "id": "near-miss-sarek-variants",
            "prompt": "I have whole-exome sequencing FASTQs and I need to align them and call germline SNPs and indels to get a VCF for variant annotation.",
            "expected_output": "Does NOT invoke this skill; defers to alterlab-nf-core-sarek. The user wants FASTQ-to-VCF germline variant calling (alignment, BQSR, variant calling), not transcript abundance quantification. alterlab-rnaseq-quant is for RNA-seq expression quantification with salmon/kallisto and does not align to a genome for variant calling.",
            "assertions": [
              {
                "type": "should_not_trigger",
                "value": true
              },
              {
                "type": "output_contains",
                "value": "alterlab-nf-core-sarek"
              }
            ]
          }
        ]
      }
      
  • references
    • decoy_index.md 2.3 KB
      # Decoy-aware salmon index (gentrome)
      
      ## Why decoys
      
      Selective alignment scores how well a read maps to the transcriptome. Some reads
      actually originate from intronic / intergenic genomic sequence and would
      otherwise be force-assigned to a transcript. Including the **genome as a decoy**
      lets salmon recognize "this read aligns better to the genome than to any
      transcript" and decline the spurious transcript assignment. This is the
      recommended construction for accurate bulk quantification.
      
      ## Construction
      
      A **gentrome** is the transcripts FASTA concatenated with the genome FASTA. The
      `decoys.txt` file lists the genome sequence (chromosome/scaffold) names so salmon
      knows which gentrome entries are decoys.
      
      ```bash
      # decoys = the genome's sequence names (strip '>' and any description after a space)
      grep "^>" genome.fa | sed 's/^>//; s/ .*//' > decoys.txt
      
      # gentrome: transcripts FIRST, then genome (order matters — decoys go last)
      cat transcripts.fa genome.fa > gentrome.fa
      
      salmon index -t gentrome.fa -d decoys.txt -i salmon_index -k 31 -p 8
      ```
      
      ### Gotchas
      
      - **Order matters.** Transcripts must come before the genome in the gentrome;
        `decoys.txt` names must match the genome FASTA headers exactly.
      - **Header trimming.** Many genome FASTAs have headers like
        `>1 dna:chromosome ...`; the `sed 's/ .*//'` keeps only `1`. salmon matches on
        the first whitespace-delimited token, so trim consistently.
      - **Rebuild for salmon 2.x.** The Rust rewrite uses a new index format and rejects
        C++/pufferfish indices outright — rebuild with the binary you will quantify with
        (see tool_versions.md).
      - Use a transcriptome FASTA and genome from the **same assembly/annotation
        release** (e.g. matching Ensembl/GENCODE versions). Fetch references with
        `alterlab-gget` if needed.
      
      ## Helper
      
      `scripts/make_decoys.py` automates the two derived files and adds a guard: it
      extracts genome names into `decoys.txt`, concatenates the gentrome in the correct
      order, and warns if the transcript and genome FASTAs look swapped or empty. It
      shells out nothing — pure stdlib file IO — so it runs in a bare `uv` env.
      
      ```bash
      uv run python scripts/make_decoys.py \
        --transcripts transcripts.fa \
        --genome genome.fa \
        --out-gentrome gentrome.fa \
        --out-decoys decoys.txt
      ```
      
      Then run `salmon index -t gentrome.fa -d decoys.txt -i salmon_index`.
      
    • kallisto_kb.md 2.4 KB
      # kallisto & kb-python (pseudoalignment path)
      
      Targets standalone kallisto **v0.52.0** and kb-python (the `kb` CLI). See
      ../references/tool_versions.md for the version facts.
      
      ## Plain kallisto
      
      ```bash
      # index
      kallisto index -i kallisto_index.idx transcripts.fa
      
      # paired-end quant
      kallisto quant -i kallisto_index.idx -o quants_kallisto/sampleA \
        sampleA_R1.fastq.gz sampleA_R2.fastq.gz
      
      # single-end (must supply fragment length mean/SD)
      kallisto quant -i kallisto_index.idx -o quants_kallisto/sampleA \
        --single -l 200 -s 20 sampleA.fastq.gz
      ```
      
      Outputs per sample: `abundance.tsv` (`target_id`, `length`, `eff_length`,
      `est_counts`, `tpm`), `abundance.h5`, and `run_info.json`. Feed `abundance.h5`
      (or `abundance.tsv`) to `tximport` with `type="kallisto"`.
      
      ## kb-python (`kb`) — wraps kallisto | bustools
      
      ```bash
      # build references (index + t2g) from a GENOME FASTA + GTF
      # positional order: <GENOME_FASTA> <GTF> (genome first, annotation second)
      kb ref -i index.idx -g t2g.txt -f1 cdna.fa genome.fa annotation.gtf
      
      # bulk quantification
      kb count -i index.idx -g t2g.txt -x bulk -o quants_kb/sampleA \
        sampleA_R1.fastq.gz sampleA_R2.fastq.gz
      ```
      
      - `kb ref` extracts the cDNA from the genome+GTF, builds the kallisto index, and
        writes the transcript-to-gene map (`t2g.txt`) in one step.
      - `-f1 cdna.fa` is the **output** cDNA FASTA that `kb ref` writes (not an input
        transcriptome). The two trailing positionals are the genome FASTA **then** the
        GTF — genome first, in that order.
      - `-x` is the technology string; `bulk` for bulk RNA-seq.
      - kb-python emits tidy count outputs and a run log.
      
      ## Long reads (lr-kallisto)
      
      kb-python exposes **lr-kallisto** through the `--long` flag and supports k-mer
      sizes greater than 31, for long-read cDNA (ONT/PacBio). kb-python v0.29.1
      introduced this option. Use it instead of the short-read defaults for long-read
      libraries; consult `kb count --help` in your installed version for the exact
      `--long` usage and the recommended `-k`.
      
      ## Notes
      
      - kallisto/kb does **not** use the salmon gentrome/decoy construction — that is
        salmon-specific. A plain transcriptome index is correct here.
      - The bundled kallisto inside a given kb-python build may lag the standalone
        kallisto release; check your install.
      - Downstream, kallisto output aggregates to gene level via `tximport`
        identically to salmon — see ../references/tximport_handoff.md — then hand the
        gene matrix to `alterlab-pydeseq2`.
      
    • salmon_quant.md 3 KB
      # salmon quant — flags, library type, QC
      
      Targets salmon **2.x** (the Rust rewrite; selective alignment by default). See
      tool_versions.md for the version facts (2.0 cannot read C++/pufferfish indices;
      `salmon alevin` removed; `--validateMappings` accepted but ignored).
      
      ## Mapping-based quant command
      
      ```bash
      salmon quant \
        -i salmon_index \
        -l A \
        -1 sampleA_R1.fastq.gz -2 sampleA_R2.fastq.gz \
        --gcBias \
        -p 8 \
        -o quants/sampleA
      ```
      
      | Flag | Meaning |
      |------|---------|
      | `-i` | path to the (decoy-aware, freshly built) index |
      | `-l A` | **auto-detect** library type / strandedness; verify the result afterwards |
      | `-1` / `-2` | paired-end read files (use `-r` for single-end) |
      | `--gcBias` | correct fragment-level GC bias (recommended for DE) |
      | `--seqBias` | optional: correct 5'/3' sequence-specific bias |
      | `--posBias` | optional: correct positional (5'/3' coverage) bias |
      | `--sketch` | opt **out** of selective alignment into faster alignment-free pseudoalignment (2.x) |
      | `--ignoreTxVersion` | with `-g/--geneMap`, match transcript IDs ignoring the trailing `.N` (2.x) |
      | `--numBootstraps` / `--numGibbsSamples` | inferential replicates, written in the same format fishpond/swish expect |
      | `-p` | threads |
      | `-o` | per-sample output directory |
      
      Selective alignment is the default in 2.x, so there is no flag that enables it.
      `--validateMappings` parses and warns; `--mimicBT2`, `--mimicStrictBT2`,
      `--minAssignedFrags`, `--numBiasSamples` and `--alternativeInitMode` are removed and
      now error.
      
      ## Outputs (per sample)
      
      - `quant.sf` — transcript-level table: `Name`, `Length`, `EffectiveLength`,
        `TPM`, `NumReads`. This is the file `tximport` reads.
      - `lib_format_counts.json` — the **inferred library type** and compatible
        fragment counts. Always check this when using `-l A`.
      - `logs/salmon_quant.log` and `aux_info/meta_info.json` — overall mapping rate
        and run metadata; record the mapping rate as a QC metric.
      
      ## Library type (`-l`)
      
      `-l A` lets salmon infer strandedness from the data. The inferred code (e.g.
      `ISR`, `ISF`, `IU`) appears in `lib_format_counts.json`. Only override with an
      explicit code when you have a documented, trustworthy protocol; a wrong manual
      `-l` silently biases counts. If the inferred type is inconsistent across samples
      that should share a protocol, investigate before proceeding.
      
      ## QC checks before handoff
      
      - **Mapping rate** (`aux_info/meta_info.json` → `percent_mapped`): unexpectedly
        low rates suggest a contaminating organism, wrong reference, or adapter/quality
        issues upstream.
      - **Consistent inferred library type** across replicates.
      - **Effective length** sanity: very short effective lengths flag fragment-length
        distribution problems for single-end data (set `--fldMean`/`--fldSD` if you
        must quantify SE without a distribution).
      
      ## Single-end reads
      
      ```bash
      salmon quant -i salmon_index -l A -r sampleA.fastq.gz \
        --gcBias -p 8 -o quants/sampleA
      ```
      
      For SE data salmon cannot empirically learn the fragment-length distribution;
      provide `--fldMean` and `--fldSD` if known.
      
    • single_cell_alevin.md 1.7 KB
      # Single-cell RNA-seq: `salmon alevin` is removed
      
      ## The change
      
      As of salmon **2.x**, **`salmon alevin` has been removed** (upstream release
      notes; see ../references/tool_versions.md). Single-cell / droplet RNA-seq
      quantification is no longer a salmon subcommand. Any instruction that writes
      `salmon alevin ...` is outdated and will fail.
      
      ## The replacement: piscem + alevin-fry
      
      COMBINE-lab directs former alevin users to the **piscem + alevin-fry** pipeline,
      which the release notes describe as offering improved memory efficiency and
      throughput and being actively maintained:
      
      - **piscem** — the indexing + mapping tool: https://github.com/COMBINE-lab/piscem
      - **alevin-fry** — single-cell quantification (collation, barcode/UMI
        resolution, count-matrix generation): https://github.com/COMBINE-lab/alevin-fry
      
      The typical flow is: build a piscem index of the (spliced+intron, if RNA
      velocity is desired) reference, map the single-cell reads to produce a RAD file,
      then run alevin-fry `generate-permit-list` → `collate` → `quant` to produce the
      cell × gene count matrix.
      
      ## Routing
      
      This skill (`alterlab-rnaseq-quant`) covers **bulk** RNA-seq only. For
      single-cell:
      
      1. Quantify with piscem + alevin-fry (their docs are the source of truth for the
         exact subcommand syntax — verify before running, as the CLI evolves).
      2. Take the resulting count matrix downstream to:
         - `alterlab-scanpy` — single-cell analysis (QC, clustering, UMAP).
         - `alterlab-scvi-tools` — probabilistic single-cell models.
         - `alterlab-anndata` — to load/manipulate the matrix as an AnnData object.
      
      Do not attempt to force single-cell barcoded data through the bulk salmon/kallisto
      path; the barcode/UMI structure requires the alevin-fry tooling.
      
    • tool_versions.md 4.5 KB
      # Pinned tool versions & upstream facts
      
      All version-specific claims in this skill trace to the upstream release pages
      below. Re-verify against the linked release notes before changing a pin.
      
      ## salmon — 2.x (the Rust rewrite)
      
      - Source: COMBINE-lab/salmon — https://github.com/COMBINE-lab/salmon, with the
        breaking changes catalogued in the repo's `MIGRATION.md`.
      - Version in bioconda at review time (2026-09-23): **2.7.0** (uploaded 2026-08-30).
      - **salmon 2.0 is a from-scratch Rust rewrite.** Same workflow
        (`salmon index` -> `salmon quant` -> `quant.sf`), same downstream output formats,
        single portable binary with no Boost/compiler dependency. The final C++ release is
        salmon **1.12.0**, kept on the upstream `cpp` branch and packaged as `salmon-cpp`.
      - **Index break.** 2.0 uses a new index format and *cannot read C++ (pufferfish)
        indices*; the mismatch is detected and rejected with a clear error in both
        directions. Rebuild with the binary you quantify with.
      - **Outputs are stable.** `quant.sf` is unchanged, and inferential replicates
        (`aux_info/bootstrap/...` from `--numBootstraps` and `--numGibbsSamples`) keep the
        C++ format, so tximport / tximeta / fishpond / swish work unmodified. The bias
        diagnostic dumps in `aux_info/` moved to a documented Rust format; no standard R
        package reads them.
      - **Removed subcommand:** `salmon alevin`. It prints a redirect and exits. Single-cell
        moved to the **piscem + alevin-fry** ecosystem
        (https://github.com/COMBINE-lab/piscem, https://github.com/COMBINE-lab/alevin-fry).
      - **Removed options (now error):** `--features` (index); `--mimicBT2`,
        `--mimicStrictBT2`, `--minAssignedFrags`, `--alternativeInitMode`,
        `--bootstrapReproject`, `--noGammaDraw`, `--numBiasSamples` (quant);
        `--auxTargetFile`, `--writeOrphanLinks` (quant -a).
      - **Accepted but ignored (parse + warn):** `--validateMappings` (selective alignment is
        the default), `--eqclasses`, `--noFragLengthDist`, `--noSingleFragProb`,
        `--mismatchSeedSkip`, `--disableChainingHeuristic`, `--hitFilterPolicy`,
        `--maxRecoverReadOcc`, `--filterSize` (index), and several `quant -a` options.
      - **New in 2.0:** `--sketch` (alignment-free pseudoalignment mode),
        `--sketchStrictOrphans`, `--allowDovetail` honored in sketch mode, and
        `--ignoreTxVersion` for `-g/--geneMap` matching. With `-g`, unmatched transcripts are
        still emitted as single-transcript genes (nothing is dropped), but 2.x warns once
        with a count and writes the names to `aux_info/genemap_unmatched_txps.json` instead
        of warning per transcript.
      
      ## kallisto — v0.52.0
      
      - Source: pachterlab/kallisto releases — https://github.com/pachterlab/kallisto/releases
      - Latest release at authoring time: **v0.52.0** (released 25 Feb). This release
        restores features (pseudobam, genomebam, fusion) that were missing after the
        index-structure rework; v0.51.1 and v0.51.0 precede it.
      
      ## kb-python (the `kb` CLI)
      
      - Source: pachterlab/kb_python releases — https://github.com/pachterlab/kb_python/releases
      - Latest release at authoring time: **v0.30.2** (released 19 May).
      - **lr-kallisto / `--long`.** kb-python v0.29.1 release notes: *"Added
        lr-kallisto (--long) option, and enabling k>31"* and shipped kallisto/bustools
        binaries with and without long k-mer support. That release upgraded the bundled
        kallisto to **0.51.1** and bustools to **0.44.1**. (The bundled kallisto in a
        given kb-python build may lag the standalone kallisto release above; check
        `kb info` / your install for the exact bundled version.)
      
      ## nf-core/rnaseq — v3.26.0 (turnkey alternative)
      
      - Source: nf-core/rnaseq releases — https://github.com/nf-core/rnaseq/releases
      - Latest release at authoring time: **v3.26.0** ("Chromium Cuttlefish", released
        7 May).
      - **Default route is `--aligner star_salmon`.** From the v3.26.0 usage docs:
        *"By default, the pipeline uses STAR (i.e. `--aligner star_salmon`) to map the
        raw FastQ reads to the reference genome, project the alignments onto the
        transcriptome and to perform the downstream BAM-level quantification with
        Salmon."* (https://nf-co.re/rnaseq/3.26.0/docs/usage)
      
      ## tximport / tximeta
      
      - Bioconductor packages used for aggregating transcript-level estimates to the
        gene level (`tximport`) with full transcriptome provenance (`tximeta`,
        `linkedTxome`). They are the canonical R import layer for salmon/kallisto
        output feeding DESeq2. The Python helper in this skill reproduces the
        `countsFromAbundance = "lengthScaledTPM"` computation so the workflow can stay
        in `uv`; use the R packages when you need full `tximeta` metadata.
      
    • tximport_handoff.md 3.5 KB
      # tximport / tximeta handoff to pydeseq2
      
      ## What tximport does
      
      salmon `quant.sf` and kallisto `abundance.tsv/.h5` are **transcript-level**.
      DESeq2/PyDESeq2 work at the **gene level**. `tximport` summarizes transcript
      estimates to genes using a transcript→gene map (`tx2gene`) and, importantly,
      generates an **average-transcript-length offset** so the model accounts for
      changes in transcript usage between samples.
      
      The recommended import for DESeq2 uses
      `countsFromAbundance = "lengthScaledTPM"` (or `"scaledTPM"`), which produces
      length-scaled, library-size-scaled counts that correct for between-sample
      differences in average transcript length, suitable to feed directly into the
      differential-expression model. (GC/sequence-bias correction is a separate step
      done earlier by salmon's `--gcBias`/`--seqBias`, not by tximport.)
      
      ## tx2gene map
      
      A two-column table mapping `transcript_id` → `gene_id`. Build it from the same
      GTF/GFF3 annotation used to make the transcriptome FASTA. `scripts/build_tx2gene.py`
      parses `transcript_id` and `gene_id` attributes from a GTF and writes a TSV.
      
      ```bash
      uv run python scripts/build_tx2gene.py annotation.gtf --out tx2gene.tsv
      ```
      
      Caveat: transcript IDs in the `quant.sf`/`abundance.tsv` `Name`/`target_id`
      column must match the IDs in `tx2gene` (watch for version suffixes like
      `ENST00000456328.2` vs `ENST00000456328` — strip with `--strip-version` if your
      annotation and reference disagree).
      
      ## Python aggregation helper
      
      `scripts/import_quant.py` reproduces the tximport `lengthScaledTPM` computation
      in pure stdlib (no pandas) so the workflow can stay in `uv`:
      
      ```bash
      uv run python scripts/import_quant.py \
        --quants quants \
        --tx2gene tx2gene.tsv \
        --out-counts gene_counts.tsv \
        --out-tpm gene_tpm.tsv
      ```
      
      It reads every `<sample>/quant.sf` (auto-detects kallisto `abundance.tsv` too)
      and applies tximport's `makeCountsFromAbundance(..., "lengthScaledTPM")` at the
      transcript level before summarizing to genes:
      
      1. `newCounts[tx, s] = TPM[tx, s] * mean_over_samples(effLength[tx, :])`
      2. rescale each sample column so its total equals that sample's original
         mapped-read count: `* (sum NumReads[:, s] / sum newCounts[:, s])`
      3. sum the scaled transcript counts per gene and round to integers.
      
      The result is length-corrected and library-size-scaled (per-sample column totals
      equal the salmon/kallisto library size) — **not** a plain sum of raw `NumReads`.
      The gene × TPM matrix is the per-gene sum of transcript TPMs. The only
      differences from Bioconductor `tximport` are integer rounding and the lack of
      `tximeta` provenance metadata; for exact parity or provenance use the R path
      below.
      
      ## When to use the R path instead
      
      Prefer the Bioconductor `tximport` + `tximeta` packages when you need:
      
      - Full **`tximeta` provenance** — `linkedTxome` records the exact transcriptome
        checksum, source, and release, attaching it to the imported object.
      - The officially validated `dtuScaledTPM` mode for differential transcript usage,
        or exact parity with a published DESeq2/tximport pipeline.
      
      In R the flow is `tximport(files, type="salmon", tx2gene=..., countsFromAbundance="lengthScaledTPM")`
      → `DESeqDataSetFromTximport(...)`. The Python helper here is a convenience that
      covers the common count-matrix case.
      
      ## Handoff
      
      The resulting `gene_counts.tsv` (gene × sample integer matrix) plus a sample
      metadata sheet (sample → condition) are exactly the inputs **`alterlab-pydeseq2`**
      expects. This skill stops here; pydeseq2 owns normalization, dispersion, Wald
      tests, FDR, and plots.
      
  • scripts
    • build_tx2gene.py 4.2 KB
      #!/usr/bin/env python3
      """build_tx2gene.py — make a transcript->gene (tx2gene) map from a GTF/GFF.
      
      tximport needs a two-column transcript_id -> gene_id table to summarize
      transcript-level salmon/kallisto estimates up to genes. This parses the GTF
      attribute column for transcript_id and gene_id and writes a deduplicated TSV.
      
      Pure stdlib (no pandas needed). Handles both GTF style
        gene_id "ENSG..."; transcript_id "ENST...";
      and GFF3 style
        ID=transcript:ENST...;Parent=gene:ENSG...
      attribute fields, preferring GTF; falls back to GFF3 keys when GTF keys are absent.
      
      Usage:
        uv run python build_tx2gene.py annotation.gtf --out tx2gene.tsv
        uv run python build_tx2gene.py annotation.gtf --strip-version --out tx2gene.tsv
      
      Output: a TSV with header `transcript_id\tgene_id`, one row per unique transcript.
      
      Exit codes: 0 = wrote map; 2 = bad input / no transcript records found.
      """
      
      from __future__ import annotations
      
      import argparse
      import re
      import sys
      from pathlib import Path
      
      # GTF: key "value"; GFF3: key=value within a ;-separated attribute string.
      _GTF_ATTR = re.compile(r'(\w+)\s+"([^"]*)"')
      _GFF_ATTR = re.compile(r"(\w+)=([^;]+)")
      
      
      def _strip_ver(x: str) -> str:
          """ENST00000456328.2 -> ENST00000456328 (drop a trailing .<digits>)."""
          return re.sub(r"\.\d+$", "", x)
      
      
      def parse_attrs(attr_field: str) -> dict[str, str]:
          attrs = {k: v for k, v in _GTF_ATTR.findall(attr_field)}
          if "transcript_id" not in attrs and "gene_id" not in attrs:
              # try GFF3 form
              for k, v in _GFF_ATTR.findall(attr_field):
                  attrs.setdefault(k, v)
          return attrs
      
      
      def transcript_gene(attrs: dict[str, str]) -> tuple[str, str] | None:
          tx = attrs.get("transcript_id")
          gene = attrs.get("gene_id")
          # GFF3 fallbacks
          if tx is None:
              raw = attrs.get("ID", "")
              if raw.startswith("transcript:"):
                  tx = raw.split(":", 1)[1]
          if gene is None:
              parent = attrs.get("Parent", "")
              if parent.startswith("gene:"):
                  gene = parent.split(":", 1)[1]
          if tx and gene:
              return tx, gene
          return None
      
      
      def main(argv: list[str] | None = None) -> int:
          p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
          p.add_argument("gtf", type=Path, help="GTF/GFF annotation (matching the transcriptome)")
          p.add_argument("--out", required=True, type=Path)
          p.add_argument(
              "--strip-version",
              action="store_true",
              help="drop trailing .N version suffixes from IDs (match Ensembl reference vs annotation)",
          )
          p.add_argument("--force", action="store_true", help="overwrite existing output")
          args = p.parse_args(argv)
      
          if not args.gtf.is_file():
              print(f"ERROR: annotation not found: {args.gtf}", file=sys.stderr)
              return 2
          if args.out.exists() and not args.force:
              print(f"ERROR: {args.out} exists (use --force)", file=sys.stderr)
              return 2
      
          seen: dict[str, str] = {}
          with args.gtf.open("r") as fh:
              for line in fh:
                  if not line or line.startswith("#"):
                      continue
                  cols = line.rstrip("\n").split("\t")
                  if len(cols) < 9:
                      continue
                  feature = cols[2]
                  # only feature rows that carry transcript_id (transcript/exon/etc.)
                  attrs = parse_attrs(cols[8])
                  tg = transcript_gene(attrs)
                  if tg is None:
                      continue
                  tx, gene = tg
                  if args.strip_version:
                      tx, gene = _strip_ver(tx), _strip_ver(gene)
                  seen.setdefault(tx, gene)  # first mapping wins; one row per transcript
                  _ = feature  # feature kind not needed once transcript_id is present
      
          if not seen:
              print(
                  "ERROR: no transcript_id/gene_id pairs found. Is this a valid GTF/GFF3 "
                  "with transcript_id and gene_id attributes?",
                  file=sys.stderr,
              )
              return 2
      
          with args.out.open("w") as out:
              out.write("transcript_id\tgene_id\n")
              for tx, gene in sorted(seen.items()):
                  out.write(f"{tx}\t{gene}\n")
      
          print(f"Wrote {args.out}: {len(seen)} transcript->gene rows.", file=sys.stderr)
          return 0
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
    • import_quant.py 9.3 KB
      #!/usr/bin/env python3
      """import_quant.py — aggregate salmon/kallisto transcript estimates to a gene matrix.
      
      A Python convenience that reproduces tximport's gene-level summarization so the
      RNA-seq quant workflow can stay in `uv` before handing off to alterlab-pydeseq2.
      It reads every per-sample quant file under a directory, maps transcripts to genes
      via a tx2gene TSV, and writes:
      
        * a gene x sample COUNT matrix (lengthScaledTPM, integer-rounded) for DESeq2
        * a gene x sample TPM matrix
      
      Auto-detects salmon (`<sample>/quant.sf`) and kallisto (`<sample>/abundance.tsv`)
      outputs. Pure stdlib — no pandas required, so it runs in a bare `uv run` env.
      
      SEMANTICS — countsFromAbundance="lengthScaledTPM": this implements tximport's
      `makeCountsFromAbundance(..., countsFromAbundance="lengthScaledTPM")` at the
      transcript level, then sums to genes (matching how tximport applies the scaling
      before gene summarization). For each transcript t and sample s:
      
          newCounts[t, s] = TPM[t, s] * mean_over_samples(effLength[t, :])
      
      then each sample column is rescaled so its total equals that sample's original
      mapped-read total:
      
          counts[t, s] = newCounts[t, s] * (sum_t NumReads[t, s] / sum_t newCounts[t, s])
      
      Finally counts are summed per gene and rounded. This is length-corrected and
      library-size-scaled — NOT a plain sum of raw NumReads. For full tximeta
      provenance or exact parity with a published DESeq2/tximport pipeline, use the
      Bioconductor tximport/tximeta packages (see ../references/tximport_handoff.md).
      
      Usage:
        uv run python import_quant.py --quants quants --tx2gene tx2gene.tsv \\
            --out-counts gene_counts.tsv --out-tpm gene_tpm.tsv
      
      Exit codes: 0 = wrote matrices; 2 = bad input / no quant files found.
      """
      
      from __future__ import annotations
      
      import argparse
      import sys
      from pathlib import Path
      
      # salmon quant.sf cols:   Name  Length  EffectiveLength  TPM  NumReads
      # kallisto abundance.tsv: target_id  length  eff_length  est_counts  tpm
      
      
      def load_tx2gene(path: Path, strip_version: bool) -> dict[str, str]:
          mapping: dict[str, str] = {}
          with path.open("r") as fh:
              for i, line in enumerate(fh):
                  parts = line.rstrip("\n").split("\t")
                  if len(parts) < 2:
                      continue
                  tx, gene = parts[0], parts[1]
                  if i == 0 and tx.lower() in {"transcript_id", "target_id", "name"}:
                      continue  # header
                  if strip_version:
                      tx = tx.split(".")[0]
                  mapping[tx] = gene
          return mapping
      
      
      def find_quant_files(root: Path) -> list[tuple[str, Path, str]]:
          """Return (sample_name, file_path, kind) for each per-sample subdir."""
          found: list[tuple[str, Path, str]] = []
          for sub in sorted(p for p in root.iterdir() if p.is_dir()):
              salmon = sub / "quant.sf"
              kallisto = sub / "abundance.tsv"
              if salmon.is_file():
                  found.append((sub.name, salmon, "salmon"))
              elif kallisto.is_file():
                  found.append((sub.name, kallisto, "kallisto"))
          return found
      
      
      def parse_quant(path: Path, kind: str, strip_version: bool):
          """Yield (transcript_id, eff_length, tpm, num_reads) rows."""
          with path.open("r") as fh:
              header = fh.readline().rstrip("\n").split("\t")
              idx = {name: i for i, name in enumerate(header)}
              if kind == "salmon":
                  c_id, c_eff, c_tpm, c_reads = (
                      idx["Name"], idx["EffectiveLength"], idx["TPM"], idx["NumReads"],
                  )
              else:  # kallisto
                  c_id, c_eff, c_tpm, c_reads = (
                      idx["target_id"], idx["eff_length"], idx["tpm"], idx["est_counts"],
                  )
              for line in fh:
                  cols = line.rstrip("\n").split("\t")
                  if len(cols) <= max(c_id, c_eff, c_tpm, c_reads):
                      continue
                  tx = cols[c_id].split(".")[0] if strip_version else cols[c_id]
                  try:
                      yield tx, float(cols[c_eff]), float(cols[c_tpm]), float(cols[c_reads])
                  except ValueError:
                      continue
      
      
      def main(argv: list[str] | None = None) -> int:
          p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
          p.add_argument("--quants", required=True, type=Path, help="dir of per-sample quant subdirs")
          p.add_argument("--tx2gene", required=True, type=Path)
          p.add_argument("--out-counts", required=True, type=Path)
          p.add_argument("--out-tpm", required=True, type=Path)
          p.add_argument("--strip-version", action="store_true")
          args = p.parse_args(argv)
      
          if not args.quants.is_dir():
              print(f"ERROR: --quants dir not found: {args.quants}", file=sys.stderr)
              return 2
          if not args.tx2gene.is_file():
              print(f"ERROR: --tx2gene not found: {args.tx2gene}", file=sys.stderr)
              return 2
      
          tx2gene = load_tx2gene(args.tx2gene, args.strip_version)
          if not tx2gene:
              print("ERROR: tx2gene map is empty.", file=sys.stderr)
              return 2
      
          samples = find_quant_files(args.quants)
          if not samples:
              print(
                  f"ERROR: no quant.sf or abundance.tsv under {args.quants}/*/ — "
                  "did salmon/kallisto write per-sample subdirs?",
                  file=sys.stderr,
              )
              return 2
      
          sample_names: list[str] = [s[0] for s in samples]
      
          # First pass: read every transcript row from every sample. We need the full
          # per-transcript effective-length and TPM/NumReads tables BEFORE we can apply
          # lengthScaledTPM, because the length factor is averaged over samples and the
          # rescale factor depends on per-sample totals.
          #   tx_eff[tx][sample]   = effective length
          #   tx_tpm[tx][sample]   = TPM
          #   tx_reads[tx][sample] = NumReads (raw, used only to recover library size)
          tx_eff: dict[str, dict[str, float]] = {}
          tx_tpm: dict[str, dict[str, float]] = {}
          tx_reads: dict[str, dict[str, float]] = {}
          reads_total: dict[str, float] = {s: 0.0 for s in sample_names}
          unmapped = 0
          seen_tx: set[str] = set()
      
          for sample, path, kind in samples:
              for tx, eff, tpm, reads in parse_quant(path, kind, args.strip_version):
                  reads_total[sample] += reads  # library size = sum of ALL mapped reads
                  if tx2gene.get(tx) is None:
                      if tx not in seen_tx:
                          unmapped += 1
                          seen_tx.add(tx)
                      continue
                  seen_tx.add(tx)
                  tx_eff.setdefault(tx, {})[sample] = eff
                  tx_tpm.setdefault(tx, {})[sample] = tpm
                  tx_reads.setdefault(tx, {})[sample] = reads
      
          if not tx_tpm:
              print(
                  "ERROR: no transcripts mapped to genes. Check that tx2gene IDs match "
                  "the quant file IDs (try --strip-version).",
                  file=sys.stderr,
              )
              return 2
      
          # lengthScaledTPM, transcript level (tximport::makeCountsFromAbundance):
          #   newCounts[tx, s] = TPM[tx, s] * mean_over_samples(effLength[tx, :])
          # then rescale each sample column so its total matches the sample's original
          # mapped-read count (countsSum / newSum). Effective lengths <= 0 are skipped
          # in the row mean (salmon/kallisto can emit 0 for unexpressed transcripts).
          avg_eff: dict[str, float] = {}
          for tx, per_sample in tx_eff.items():
              vals = [v for v in per_sample.values() if v > 0]
              avg_eff[tx] = (sum(vals) / len(vals)) if vals else 0.0
      
          new_counts: dict[str, dict[str, float]] = {}
          new_sum: dict[str, float] = {s: 0.0 for s in sample_names}
          for tx, tpm_row in tx_tpm.items():
              L = avg_eff.get(tx, 0.0)
              row = {}
              for s in sample_names:
                  nc = tpm_row.get(s, 0.0) * L
                  row[s] = nc
                  new_sum[s] += nc
              new_counts[tx] = row
      
          # Per-sample rescale factor countsSum/newSum (countsSum = library size).
          scale: dict[str, float] = {}
          for s in sample_names:
              scale[s] = (reads_total[s] / new_sum[s]) if new_sum[s] > 0 else 0.0
      
          # Summarize scaled transcript counts (and raw TPM) up to gene level.
          gene_counts: dict[str, dict[str, float]] = {}
          gene_tpm: dict[str, dict[str, float]] = {}
          for tx, row in new_counts.items():
              gene = tx2gene[tx]
              gc = gene_counts.setdefault(gene, {})
              gt = gene_tpm.setdefault(gene, {})
              tpm_row = tx_tpm[tx]
              for s in sample_names:
                  gc[s] = gc.get(s, 0.0) + row[s] * scale[s]
                  gt[s] = gt.get(s, 0.0) + tpm_row.get(s, 0.0)
      
          genes = sorted(gene_counts)
      
          def write_matrix(out: Path, data: dict[str, dict[str, float]], integer: bool) -> None:
              with out.open("w") as fh:
                  fh.write("gene_id\t" + "\t".join(sample_names) + "\n")
                  for g in genes:
                      row = data.get(g, {})
                      vals = []
                      for s in sample_names:
                          v = row.get(s, 0.0)
                          vals.append(str(int(round(v))) if integer else f"{v:.4f}")
                      fh.write(g + "\t" + "\t".join(vals) + "\n")
      
          write_matrix(args.out_counts, gene_counts, integer=True)
          write_matrix(args.out_tpm, gene_tpm, integer=False)
      
          print(
              f"Wrote {args.out_counts} and {args.out_tpm}: "
              f"{len(genes)} genes x {len(sample_names)} samples "
              f"({unmapped} transcript rows had no gene mapping).",
              file=sys.stderr,
          )
          print("Next: hand gene_counts.tsv + a condition sheet to alterlab-pydeseq2.", file=sys.stderr)
          return 0
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
    • make_decoys.py 4.6 KB
      #!/usr/bin/env python3
      """make_decoys.py — build a decoy-aware salmon gentrome + decoys.txt.
      
      A decoy-aware salmon index needs two derived files:
        * decoys.txt  — the genome's sequence (chromosome/scaffold) names, one per line
        * gentrome.fa — transcripts FASTA concatenated with genome FASTA (transcripts FIRST)
      
      salmon then treats the genome entries as decoys, so reads that align better to the
      genome than to any transcript are not force-assigned to a transcript.
      
      This helper is pure stdlib (streaming file IO — never loads a whole FASTA into
      memory) and adds guards against the two common silent mistakes:
        * empty / unreadable input FASTAs
        * a swapped argument order (genome passed as transcripts) — detected heuristically
          by comparing header counts (transcriptomes have far more records than genomes)
      
      Usage:
        uv run python make_decoys.py --transcripts transcripts.fa --genome genome.fa \\
            --out-gentrome gentrome.fa --out-decoys decoys.txt
      
      Then:
        salmon index -t gentrome.fa -d decoys.txt -i salmon_index -k 31 -p 8
      
      Exit codes: 0 = wrote both files; 2 = bad input/usage.
      """
      
      from __future__ import annotations
      
      import argparse
      import sys
      from pathlib import Path
      
      
      def _first_token(header_line: str) -> str:
          """'>1 dna:chromosome ...' -> '1'. Strip '>' and anything after first space."""
          name = header_line[1:].strip()
          # salmon matches on the first whitespace-delimited token
          return name.split()[0] if name else ""
      
      
      def collect_decoys(genome_path: Path) -> list[str]:
          """Stream the genome FASTA and return its sequence names (for decoys.txt)."""
          names: list[str] = []
          with genome_path.open("r") as fh:
              for line in fh:
                  if line.startswith(">"):
                      tok = _first_token(line)
                      if tok:
                          names.append(tok)
          return names
      
      
      def count_headers(path: Path) -> int:
          n = 0
          with path.open("r") as fh:
              for line in fh:
                  if line.startswith(">"):
                      n += 1
          return n
      
      
      def append_fasta(src: Path, dst) -> int:
          """Append src FASTA to an open dst file handle. Returns header count."""
          n = 0
          with src.open("r") as fh:
              for line in fh:
                  if line.startswith(">"):
                      n += 1
                  dst.write(line)
          return n
      
      
      def main(argv: list[str] | None = None) -> int:
          p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
          p.add_argument("--transcripts", required=True, type=Path, help="transcriptome FASTA")
          p.add_argument("--genome", required=True, type=Path, help="genome FASTA (decoys)")
          p.add_argument("--out-gentrome", required=True, type=Path)
          p.add_argument("--out-decoys", required=True, type=Path)
          p.add_argument("--force", action="store_true", help="overwrite existing outputs")
          args = p.parse_args(argv)
      
          for f in (args.transcripts, args.genome):
              if not f.is_file():
                  print(f"ERROR: input not found: {f}", file=sys.stderr)
                  return 2
      
          for out in (args.out_gentrome, args.out_decoys):
              if out.exists() and not args.force:
                  print(f"ERROR: {out} exists (use --force to overwrite)", file=sys.stderr)
                  return 2
      
          tx_headers = count_headers(args.transcripts)
          decoy_names = collect_decoys(args.genome)
          if tx_headers == 0:
              print(f"ERROR: no FASTA records in transcripts file {args.transcripts}", file=sys.stderr)
              return 2
          if not decoy_names:
              print(f"ERROR: no FASTA records in genome file {args.genome}", file=sys.stderr)
              return 2
      
          # Heuristic swap guard: a transcriptome normally has many more records than a
          # genome assembly. If transcripts has fewer headers than the genome, warn loudly.
          if tx_headers < len(decoy_names):
              print(
                  "WARNING: the transcripts FASTA has fewer records "
                  f"({tx_headers}) than the genome ({len(decoy_names)}). "
                  "Did you swap --transcripts and --genome? Continuing anyway.",
                  file=sys.stderr,
              )
      
          # decoys.txt
          args.out_decoys.write_text("\n".join(decoy_names) + "\n")
      
          # gentrome.fa = transcripts FIRST, then genome (decoys last)
          with args.out_gentrome.open("w") as dst:
              append_fasta(args.transcripts, dst)
              append_fasta(args.genome, dst)
      
          print(
              f"Wrote {args.out_decoys} ({len(decoy_names)} decoy names) and "
              f"{args.out_gentrome} (transcripts then genome).",
              file=sys.stderr,
          )
          print(
              "Next: salmon index -t "
              f"{args.out_gentrome} -d {args.out_decoys} -i salmon_index -k 31 -p 8",
              file=sys.stderr,
          )
          return 0
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
  • SKILL.md 14.1 KB
    ---
    name: alterlab-rnaseq-quant
    description: Quantifies bulk RNA-seq transcript abundance with salmon 2.x (the Rust rewrite; selective alignment or --sketch) and kallisto (v0.52.0, kb-python workflow), builds a decoy-aware gentrome index, runs quant with --gcBias -l A, then imports estimates via tximport/tximeta with a tx2gene map and hands differential expression to alterlab-pydeseq2. Warns that salmon 2.0 cannot read C++/pufferfish indices (rebuild every index), that --validateMappings is now accepted-but-ignored, and that 'salmon alevin' was REMOVED (single-cell now uses piscem + alevin-fry). Use when quantifying RNA-seq transcript abundance, running salmon or kallisto, building a decoy-aware index, or wiring tximport to DESeq2; for differential expression use alterlab-pydeseq2, for FASTQ-to-VCF variant calling use alterlab-nf-core-sarek. Part of the AlterLab Academic Skills suite.
    license: MIT
    allowed-tools: Read Write Edit Bash(python:*) Bash(uv:*) Bash(salmon:*) Bash(kallisto:*) Bash(kb:*)
    compatibility: "Requires the salmon and/or kallisto CLI on PATH (conda/bioconda or a container). This skill targets salmon 2.x (bioconda 2.7.0 as of 2026-09) and kallisto v0.52.0; salmon 2.x is a single portable Rust binary with no Boost/compiler dependency. The tximport/tx2gene helper runs under `uv run python` with pure stdlib (no pandas needed). No API key or account required; all work is local."
    metadata:
        skill-author: AlterLab
        version: "1.2.0"
        last_updated: "2026-09-23"
        depends_on: "alterlab-pydeseq2 (downstream differential expression)"
    ---
    
    # RNA-seq Quantification — salmon & kallisto Transcript Abundance
    
    The command-line quantification entry point for bulk RNA-seq: take raw FASTQ
    reads plus a reference transcriptome and produce transcript-level abundance
    estimates (counts + TPM) with **salmon** (selective alignment) or **kallisto**
    (pseudoalignment via kb-python), then aggregate to the gene level with
    `tximport`/`tximeta` and hand off to `alterlab-pydeseq2` for differential
    expression. It is the raw-data-to-count-matrix pipeline that the repo's Python
    analysis skills assume already ran.
    
    ## Quick Start
    
    ```
    Quantify these RNA-seq FASTQs with salmon and a decoy-aware index
    Build a salmon gentrome index from this transcriptome + genome
    Run kallisto / kb count on my paired-end reads
    Turn my salmon quant.sf files into a gene-level count matrix for DESeq2
    ```
    
    → Build a **decoy-aware** index once, run `salmon quant` (or `kb count`) per
    sample, then run `scripts/build_tx2gene.py` + `scripts/import_quant.py` to make
    the `tximport` gene matrix and route it to `alterlab-pydeseq2`.
    
    ---
    
    ## When to Use This Skill
    
    Use this skill when the request is about **getting from FASTQ to transcript or
    gene abundance** with a lightweight quantifier:
    
    - "Quantify my RNA-seq with salmon / kallisto."
    - "Build a decoy-aware salmon index (gentrome + decoys.txt)."
    - "Run selective alignment with `--gcBias`." / "Use salmon's `--sketch` mode."
    - "I have `quant.sf` files — make me a gene-level count matrix for DESeq2."
    - "Set up `tximport` / `tximeta` with a tx2gene map."
    - "Use kb-python / `kb count` to pseudoalign these reads."
    
    ### Does NOT Trigger — route these to the right sibling
    
    | The request is really about… | Route to |
    |------------------------------|----------|
    | Differential expression on a **count matrix** (DESeq2 Wald tests, FDR, volcano) | `alterlab-pydeseq2` |
    | **Single-cell** RNA-seq quantification (was `salmon alevin`) | piscem + alevin-fry — see [references/single_cell_alevin.md](references/single_cell_alevin.md); downstream → `alterlab-scanpy` / `alterlab-scvi-tools` |
    | **FASTQ-to-VCF** germline/somatic variant calling | `alterlab-nf-core-sarek` |
    | **16S/ITS amplicon** (microbiome) FASTQ-to-feature-table | `alterlab-qiime2-amplicon` |
    | **Spatial** transcriptomics (Visium/Xenium) neighborhood analysis | `alterlab-squidpy-spatial` |
    | Loading/manipulating the resulting matrix as an **AnnData** object | `alterlab-anndata` |
    | BLAST/DIAMOND **sequence similarity search** | `alterlab-blast` |
    | Quick gene/transcript **ID lookups & reference fetch** (Ensembl/RefSeq) | `alterlab-gget` |
    | Aligned **BAM** manipulation, coverage, read counting from alignments | `alterlab-pysam` |
    
    This skill stops at the **count/abundance matrix**. It does not call DEGs, does
    not handle single-cell barcodes, and does not align to a genome for variant
    calling.
    
    ---
    
    ## Two Critical Correctness Traps (read before quantifying)
    
    These are the three failures most outdated RNA-seq instructions get wrong now that
    salmon has moved to **2.x** (a from-scratch Rust rewrite; bioconda 2.7.0). All are
    confirmed in the upstream `MIGRATION.md` (see
    [references/tool_versions.md](references/tool_versions.md)).
    
    1. **salmon 2.0 cannot read a C++ (pufferfish) index.** The index format changed
       with the rewrite. Loading an old index — or pointing old salmon at a 2.x index —
       is detected and rejected with a clear error, so this fails loudly rather than
       silently. **Rebuild every index** with the same salmon version you quantify with.
       `quant.sf` and the bootstrap/Gibbs outputs are unchanged, so tximport, tximeta,
       fishpond and swish keep working on 2.x output with no changes.
    
    2. **`--validateMappings` no longer does anything.** Selective alignment is the
       default (and only) alignment mode in 2.x, so the flag parses and logs a warning.
       Copying it from a 2019-era tutorial is harmless but misleading; drop it. Several
       other C++ flags now **error out**: `--mimicBT2`, `--mimicStrictBT2`,
       `--minAssignedFrags`, `--numBiasSamples`, `--alternativeInitMode`.
    
    3. **`salmon alevin` was REMOVED.** Single-cell quantification is no longer part of
       salmon; `salmon alevin …` prints a redirect and exits. Use the
       **piscem + alevin-fry** ecosystem instead. If the user has single-cell / droplet
       data, route per the table above and see
       [references/single_cell_alevin.md](references/single_cell_alevin.md).
    
    If you genuinely need the old behavior, the final C++ release (salmon 1.12.0) lives
    on the upstream `cpp` branch and is packaged separately as `salmon-cpp`.
    
    ---
    
    ## Pipeline (salmon, the default path)
    
    ### 1. Build a decoy-aware gentrome index (once per reference)
    
    A **decoy-aware** index lets salmon distinguish reads that align better to the
    genome than the transcriptome, reducing spurious assignments. You build a
    "gentrome" = transcripts FASTA **concatenated with the genome FASTA**, plus a
    `decoys.txt` listing the genome sequence names as decoys.
    
    ```bash
    # 1. decoys.txt = the genome's sequence (chromosome) names, one per line
    grep "^>" genome.fa | sed 's/^>//; s/ .*//' > decoys.txt
    
    # 2. gentrome = transcripts FIRST, then genome (order matters)
    cat transcripts.fa genome.fa > gentrome.fa
    
    # 3. build the index (rebuild under salmon 2.x — see trap #1)
    salmon index \
      -t gentrome.fa \
      -d decoys.txt \
      -i salmon_index \
      -k 31 \
      -p 8
    ```
    
    - `-k 31` is the default k-mer; lower it only for very short reads.
    - The helper `scripts/make_decoys.py` writes `decoys.txt` and `gentrome.fa` for
      you and refuses to proceed if the genome names are absent from the transcript
      FASTA (a common silent mistake). See [references/decoy_index.md](references/decoy_index.md).
    
    ### 2. Quantify each sample
    
    ```bash
    salmon quant \
      -i salmon_index \
      -l A \
      -1 sampleA_R1.fastq.gz -2 sampleA_R2.fastq.gz \
      --gcBias \
      -p 8 \
      -o quants/sampleA
    ```
    
    - **`-l A`** — auto-detect library type (strandedness). Let salmon infer it
      unless you have a documented protocol; verify the inferred type in
      `lib_format_counts.json`.
    - **Selective alignment is the default** in salmon 2.x — there is no flag to turn
      it on (`--validateMappings` is accepted and ignored). `--sketch` opts *out* of it
      into faster alignment-free pseudoalignment; prefer the default when the
      quantification feeds differential expression, and reserve `--sketch` for very
      large screens where speed dominates.
    - **`--gcBias`** — corrects fragment-level GC bias; recommended for DE and cheap
      to enable. Add `--seqBias` for 5'/3' sequence-specific bias if needed.
    - **`--ignoreTxVersion`** (new in 2.x) — with `-g/--geneMap`, matches transcript
      IDs ignoring the trailing `.N`, the way tximport's option of the same name does.
      Needed for an Ensembl cDNA index against an Ensembl GTF. When transcripts fail to
      match the gene map, 2.x writes the offending names to
      `aux_info/genemap_unmatched_txps.json` and warns once with a count, instead of
      C++ salmon's one warning per transcript — check for that file after any run that
      used `-g`.
    - For single-end reads, pass `-r reads.fastq.gz` instead of `-1/-2`.
    
    Each sample produces `quants/<sample>/quant.sf` (transcript-level estimates) and
    `quants/<sample>/lib_format_counts.json` (the inferred library type). See
    [references/salmon_quant.md](references/salmon_quant.md) for the full flag map
    and per-sample QC checks.
    
    ### 3. Aggregate to gene level with tximport
    
    Build a transcript→gene map (`tx2gene`) from your annotation, then summarize the
    per-sample `quant.sf` files into a gene-level matrix that `pydeseq2` consumes.
    
    ```bash
    # tx2gene from a GTF/GFF3 (transcript_id -> gene_id)
    uv run python scripts/build_tx2gene.py annotation.gtf --out tx2gene.tsv
    
    # import + summarize to gene level (tximport "lengthScaledTPM" counts)
    uv run python scripts/import_quant.py \
      --quants quants \
      --tx2gene tx2gene.tsv \
      --out-counts gene_counts.tsv \
      --out-tpm gene_tpm.tsv
    ```
    
    `import_quant.py` produces an integer-rounded gene × sample count matrix plus a
    gene × sample TPM matrix, the inputs `alterlab-pydeseq2` expects. It implements
    tximport's `makeCountsFromAbundance(..., "lengthScaledTPM")` at the transcript
    level (scale each transcript's TPM by its sample-averaged effective length, then
    rescale each sample column back to its mapped-read library size) and sums to
    genes — so the counts are length-corrected and library-size-scaled, **not** raw
    summed `NumReads`. The canonical R route is the `tximport` / `tximeta`
    Bioconductor packages with `countsFromAbundance = "lengthScaledTPM"`; the Python
    helper here reproduces that computation so you can stay in `uv` (differing only
    in integer rounding and the absence of `tximeta` provenance). See
    [references/tximport_handoff.md](references/tximport_handoff.md) for the exact
    semantics, the `tximeta` linkedTxome metadata option, and when to prefer the R
    path.
    
    ### 4. Hand off to differential expression
    
    Pass `gene_counts.tsv` (+ a sample/condition sheet) to **`alterlab-pydeseq2`**.
    This skill does not call DEGs — that is pydeseq2's job (size-factor
    normalization, dispersion, Wald tests, BH-FDR, volcano/MA plots).
    
    ---
    
    ## Pipeline (kallisto, the pseudoalignment path)
    
    `kallisto` (standalone **v0.52.0**) and the **kb-python** wrapper (`kb`) give a
    faster pseudoalignment route. kb-python drives `kallisto | bustools` and writes
    tidy outputs.
    
    ```bash
    # build a kallisto index from the transcriptome
    kallisto index -i kallisto_index.idx transcripts.fa
    
    # quantify a paired-end sample
    kallisto quant -i kallisto_index.idx -o quants_kallisto/sampleA \
      sampleA_R1.fastq.gz sampleA_R2.fastq.gz
    
    # OR the kb-python workflow (bulk)
    # -f1 is the cDNA FASTA kb WRITES; trailing positionals are genome FASTA THEN GTF
    kb ref -i index.idx -g t2g.txt -f1 cdna.fa genome.fa annotation.gtf
    kb count -i index.idx -g t2g.txt -x bulk -o quants_kb/sampleA \
      sampleA_R1.fastq.gz sampleA_R2.fastq.gz
    ```
    
    - kallisto outputs `abundance.tsv` / `abundance.h5`; feed these to `tximport`
      (`type="kallisto"`) the same way as salmon's `quant.sf`.
    - **Long reads:** kb-python exposes **lr-kallisto** via the `--long` flag (and
      `k>31` k-mers) — use it for ONT/PacBio cDNA. See
      [references/kallisto_kb.md](references/kallisto_kb.md).
    - kallisto does not use the decoy/gentrome construction; that is salmon-specific.
    
    ---
    
    ## Turnkey alternative — nf-core/rnaseq
    
    For an end-to-end, provenance-tracked pipeline (trimming → alignment →
    quantification → QC), **nf-core/rnaseq v3.26.0** runs `--aligner star_salmon` by
    default: STAR maps to the genome, projects onto the transcriptome, and Salmon
    does the quantification. Reach for it when the user wants a reproducible
    Nextflow pipeline rather than hand-run commands; this skill covers the
    direct-salmon/kallisto path and the tximport handoff. See
    [references/tool_versions.md](references/tool_versions.md).
    
    ---
    
    ## Offload note
    
    Indexing and per-sample quantification are CPU/IO-heavy but fully offline. On a
    local workstation these are good candidates to run directly (e.g. overnight)
    rather than streaming large FASTQs through an API session. Build the index once;
    quantify samples in a loop.
    
    ---
    
    ## Self-Check Before Reporting
    
    - Did you **rebuild** the salmon index with the same 2.x binary you quantified with
      (trap #1)? A C++/pufferfish index is rejected outright.
    - Is the index **decoy-aware** (gentrome + `decoys.txt`) for salmon? Confirm the
      genome names made it into `decoys.txt`.
    - Did you let `-l A` infer strandedness, and did you sanity-check the inferred
      type in `lib_format_counts.json`?
    - Is the data actually **single-cell**? If so you must NOT use this path —
      `salmon alevin` is gone; route to piscem + alevin-fry (trap #2).
    - Did you stop at the **count matrix** and hand DE off to `alterlab-pydeseq2`
      rather than calling DEGs here?
    
    ---
    
    ## References
    
    - [references/tool_versions.md](references/tool_versions.md) — pinned versions
      (salmon 2.x, kallisto v0.52.0, kb-python, nf-core/rnaseq) and the upstream
      release-note facts (the 2.0 rewrite and index break, alevin removal).
    - [references/decoy_index.md](references/decoy_index.md) — decoy-aware gentrome
      index construction, gotchas, and the `make_decoys.py` helper.
    - [references/salmon_quant.md](references/salmon_quant.md) — `salmon quant` flag
      map, library-type inference, and per-sample QC.
    - [references/kallisto_kb.md](references/kallisto_kb.md) — kallisto / kb-python
      workflow, `--long` (lr-kallisto), and output handling.
    - [references/tximport_handoff.md](references/tximport_handoff.md) — tximport /
      tximeta aggregation, tx2gene, `countsFromAbundance`, and the pydeseq2 handoff.
    - [references/single_cell_alevin.md](references/single_cell_alevin.md) — why
      `salmon alevin` is removed and the piscem + alevin-fry replacement.
    
    Part of the AlterLab Academic Skills suite.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related