Back to Claude Scientific Skills

Correct Pysam Workflow Patterns

skills/pysam/references/common_workflows.md

2.55.011.7 KB
Original Source

Correct Pysam Workflow Patterns

These patterns target pysam 0.24.0 and make filtering and coordinate semantics explicit. Adapt thresholds to the assay rather than treating them as universal defaults.

Preflight an Analysis

Before combining files, verify:

  1. Reference assembly and contig naming agree (chr1 versus 1).
  2. Numeric coordinates use 0-based, half-open intervals.
  3. Alignment and variant inputs are sorted as expected.
  4. Random-access inputs have valid indexes.
  5. CRAM has the exact reference FASTA available.
  6. Read-group/sample metadata identifies the intended samples.
  7. Duplicate, secondary, supplementary, QC-fail, and quality policies are stated.

Use the bundled inspectors:

bash
python scripts/inspect_hts.py sample.bam
python scripts/inspect_hts.py cohort.vcf.gz
python scripts/inspect_hts.py reference.fa

Streaming Alignment QC

The bundled script scans in file order and does not require an index:

bash
python scripts/alignment_qc.py sample.bam --output sample.qc.json
python scripts/alignment_qc.py sample.cram \
  --reference reference.fa \
  --max-records 100000

The report counts alignment records, not unique templates or fragments. Secondary and supplementary records are reported separately. Use --max-records for a sampling pass; omit it for a complete scan.

For index-level counts without reading every record:

python
import pysam

with pysam.AlignmentFile("sample.bam", "rb", require_index=True) as bam:
    for stats in bam.get_index_statistics():
        print(stats.contig, stats.mapped, stats.unmapped, stats.total)

Index statistics are fast but cannot replace custom record-level QC.

Zero-Aware Coverage

pileup() omits positions with no columns. Use count_coverage() when zeros must be represented:

python
import pysam


def base_depth(
    bam: pysam.AlignmentFile,
    contig: str,
    start: int,
    stop: int,
    *,
    min_base_quality: int = 20,
) -> list[int]:
    a, c, g, t = bam.count_coverage(
        contig,
        start,
        stop,
        quality_threshold=min_base_quality,
        read_callback="all",
    )
    return [sum(counts) for counts in zip(a, c, g, t)]


with pysam.AlignmentFile("sample.bam", "rb") as bam:
    depth = base_depth(bam, "chr1", 1_000, 2_000)

read_callback="all" excludes unmapped, secondary, QC-fail, and duplicate records, but not supplementary records. Use a custom callback when supplementary or low-MAPQ reads must also be excluded:

python
def usable_read(read: pysam.AlignedSegment) -> bool:
    return (
        not read.is_unmapped
        and not read.is_secondary
        and not read.is_supplementary
        and not read.is_qcfail
        and not read.is_duplicate
        and read.mapping_quality >= 20
    )


a, c, g, t = bam.count_coverage(
    "chr1",
    1_000,
    2_000,
    quality_threshold=20,
    read_callback=usable_read,
)

Only A/C/G/T query bases contribute.

Convert low-depth bases into intervals

python
def below_threshold_intervals(
    depths: list[int],
    start: int,
    threshold: int,
):
    interval_start = None

    for offset, value in enumerate(depths):
        position = start + offset
        if value < threshold and interval_start is None:
            interval_start = position
        elif value >= threshold and interval_start is not None:
            yield interval_start, position
            interval_start = None

    if interval_start is not None:
        yield interval_start, start + len(depths)

The yielded intervals remain 0-based, half-open and correctly include regions with no aligned columns.

Exact Pileup at a Position

Use explicit pileup options and retain the iterator:

python
def aligned_depth_at(
    bam: pysam.AlignmentFile,
    fasta: pysam.FastaFile,
    contig: str,
    position: int,
) -> int:
    iterator = bam.pileup(
        contig,
        position,
        position + 1,
        truncate=True,
        stepper="samtools",
        fastafile=fasta,
        min_mapping_quality=20,
        min_base_quality=20,
        max_depth=100_000,
        ignore_overlaps=True,
        ignore_orphans=True,
    )
    for column in iterator:
        if column.reference_pos == position:
            return column.get_num_aligned()
    return 0

This definition is not identical to VCF INFO/DP from a caller. Name custom annotations so their provenance and filters remain clear.

SNP Base Support

This helper is deliberately limited to single-nucleotide REF/ALT alleles:

python
from collections import Counter


def snp_base_counts(
    bam: pysam.AlignmentFile,
    fasta: pysam.FastaFile,
    record: pysam.VariantRecord,
) -> Counter[str]:
    if (
        len(record.ref) != 1
        or not record.alts
        or any(len(alt) != 1 for alt in record.alts)
    ):
        raise ValueError("snp_base_counts only supports simple SNP records")

    counts: Counter[str] = Counter()
    iterator = bam.pileup(
        record.contig,
        record.start,
        record.start + 1,
        truncate=True,
        stepper="samtools",
        fastafile=fasta,
        min_mapping_quality=20,
        min_base_quality=20,
        max_depth=100_000,
        ignore_overlaps=True,
        ignore_orphans=True,
    )

    for column in iterator:
        for pileup_read in column.pileups:
            if pileup_read.is_del or pileup_read.is_refskip:
                continue
            query_position = pileup_read.query_position
            if query_position is None:
                continue
            base = pileup_read.alignment.query_sequence[query_position]
            counts[base.upper()] += 1
    return counts

For indels, inspect PileupRead.indel, CIGAR, and normalized alleles or use a dedicated variant caller. This SNP method must not be generalized to symbolic or breakend alleles.

Annotate a VCF with BAM-Derived Depth

Copy the header, declare a new field, translate copied records, and use record.start directly:

python
import pysam


def annotate_depth(
    input_vcf: str,
    input_bam: str,
    reference_fasta: str,
    output_vcf: str,
) -> None:
    with pysam.FastaFile(reference_fasta) as fasta, pysam.AlignmentFile(
        input_bam,
        "rb",
        reference_filename=reference_fasta,
    ) as bam, pysam.VariantFile(input_vcf) as source:
        header = source.header.copy()
        if "BAM_BASE_DP" in header.info:
            raise ValueError("BAM_BASE_DP already exists in input header")
        header.info.add(
            "BAM_BASE_DP",
            number=1,
            type="Integer",
            description=(
                "Aligned base depth at POS; MAPQ>=20, baseQ>=20, "
                "samtools pileup filters, paired overlaps collapsed"
            ),
        )

        with pysam.VariantFile(output_vcf, "w", header=header) as output:
            for source_record in source:
                record = source_record.copy()
                record.translate(header)
                record.info["BAM_BASE_DP"] = aligned_depth_at(
                    bam,
                    fasta,
                    record.contig,
                    record.start,
                )
                output.write(record)

For CRAM input, reference_filename is essential. For a large unindexed VCF, this pattern can issue many BAM seeks; process by contig or sorted windows to improve locality.

Validate VCF REF Alleles Against FASTA

python
def reference_matches(
    record: pysam.VariantRecord,
    fasta: pysam.FastaFile,
) -> bool:
    observed = fasta.fetch(
        record.contig,
        record.start,
        record.start + len(record.ref),
    )
    return observed.upper() == record.ref.upper()


with pysam.FastaFile("reference.fa") as fasta, pysam.VariantFile(
    "variants.vcf.gz"
) as variants:
    mismatches = [
        (record.contig, record.pos, record.ref)
        for record in variants
        if not reference_matches(record, fasta)
    ]

Do not "fix" mismatches automatically. Investigate assembly version, contig aliases, left normalization, and strand/representation errors.

Filter an Alignment File

Use the bundled script for common primary-read filtering:

bash
python scripts/alignment_qc.py input.bam --max-records 10000
python scripts/filter_alignments.py input.bam filtered.bam \
  --min-mapq 20 --exclude-duplicates --exclude-supplementary --index

If implementing custom filtering, preserve the source header and stream records:

python
with pysam.AlignmentFile("input.bam", "rb") as source, pysam.AlignmentFile(
    "filtered.bam", "wb", template=source
) as output:
    for read in source.fetch(until_eof=True):
        if usable_read(read):
            output.write(read)

Filtering preserves input order; it does not sort. Index only coordinate-sorted output. If the header's sort-order declaration is wrong, fix the workflow rather than trusting it.

Extract Strand-Aware BED Sequences

python
IUPAC_COMPLEMENT = str.maketrans(
    "ACGTRYMKBDHVNacgtrymkbdhvn",
    "TGCAYRKMVHDBNtgcayrkmvhdbn",
)


with pysam.TabixFile(
    "genes.bed.gz", parser=pysam.asBed()
) as genes, pysam.FastaFile("reference.fa") as fasta, open(
    "genes.fa", "x", encoding="utf-8"
) as output:
    for gene in genes.fetch():
        sequence = fasta.fetch(gene.contig, gene.start, gene.end)
        if gene.strand == "-":
            sequence = sequence.translate(IUPAC_COMPLEMENT)[::-1]
        output.write(f">{gene.name}\n{sequence}\n")

BED parser coordinates are already 0-based. Sanitize or encode record names if they will be consumed by strict downstream FASTA parsers.

Count RNA Splice Junctions

find_introns() counts N CIGAR operations:

python
with pysam.AlignmentFile("rna.bam", "rb") as bam:
    primary_reads = (
        read
        for read in bam.fetch("chr1")
        if not read.is_secondary
        and not read.is_supplementary
        and not read.is_duplicate
        and read.mapping_quality >= 20
    )
    junction_counts = bam.find_introns(primary_reads)

Keys are (start, stop) 0-based splice intervals. Filter strand and library orientation according to the assay.

Bulk Operations via Wrapped Tools

For sort/index and normalization, mature command implementations are usually preferable to Python record loops:

python
import pysam.samtools
import pysam.bcftools

pysam.samtools.sort(
    "-@", "4",
    "-o", "sorted.bam",
    "input.bam",
    catch_stdout=False,
)
pysam.samtools.index(
    "-@", "4",
    "sorted.bam",
    catch_stdout=False,
)

pysam.bcftools.norm(
    "-f", "reference.fa",
    "-m", "-any",
    "-Oz",
    "-o", "normalized.vcf.gz",
    "input.vcf.gz",
    catch_stdout=False,
)
pysam.bcftools.index(
    "--csi",
    "normalized.vcf.gz",
    catch_stdout=False,
)

Pass each argument as its own string. Do not split or evaluate an untrusted shell command. Use catch_stdout=False when -o writes large or binary data.

Do Not Hand-Roll Complex VCF Merges

Combining records by (contig, pos, ref, alts) is insufficient because inputs can differ in:

  • allele normalization and multiallelic decomposition
  • contig order and metadata
  • INFO/FORMAT Number and Type definitions
  • FILTER definitions
  • sample names and ploidy
  • duplicate positions and phasing

Normalize and validate inputs, then use bcftools merge for samples or bcftools concat for disjoint genomic partitions as appropriate.

Output Validation

Alignment output:

python
pysam.samtools.quickcheck("-v", "filtered.bam")

Variant output:

python
with pysam.VariantFile("annotated.vcf.gz") as variants:
    assert "BAM_BASE_DP" in variants.header.info

Then index final sorted output and fetch known intervals at contig starts, interval boundaries, and high-coordinate regions. Format validity does not prove biological validity; compare counts and selected records to an independent tool when results matter.