Applied Workflows10/13
Starting sandbox…
Beginner·4 graded tests

Applied - Reading VCF with pandas

Applied - Reading VCF with pandas

Core Principle: Parsing Variant Call Format (VCF) files into pandas DataFrames requires parsing metadata headers, unpacking multi-allelic records, and restructuring INFO and FORMAT fields into tidy tabular formats.

Uses: Reading and Writing Data · String Accessor · Reshaping with pivot and melt · pandas dtypes · Categorical dtype · Tidy Data

When pandas is the wrong tool

For millions of variants, or when you need region queries, use cyvcf2 or pysam: they parse in C and understand the format properly. Use pandas when the file is small enough to fit in memory and you want to join, group and plot it.

from cyvcf2 import VCF
for v in VCF("cohort.vcf.gz")("chr17:7668000-7688000"):
    v.CHROM, v.POS, v.REF, v.ALT, v.INFO.get("AF"), v.gt_types

The rest of this note assumes pandas is the right call.

Reading the fixed columns

import gzip, pandas as pd

def read_vcf(path):
    opener = gzip.open if str(path).endswith(".gz") else open
    with opener(path, "rt") as fh:
        for line in fh:
            if line.startswith("#CHROM"):
                cols = line.lstrip("#").rstrip("\n").split("\t")
                break
        else:
            raise ValueError("no #CHROM header found")

    return pd.read_csv(
        path, sep="\t", comment="#", names=cols,
        dtype={"CHROM": "category", "POS": "int32", "REF": "category",
               "ALT": "str", "FILTER": "category"},
        na_values=["."],
    )

vcf = read_vcf("cohort.vcf.gz")
vcf.info()

The header scan is necessary because the sample columns are not known in advance. comment="#" then skips all the ## meta lines when the data is read.

Untangling INFO

def parse_info(series):
    """'AF=0.3;DP=44;DB' → a DataFrame of columns."""
    parts = (series.fillna("")
                   .str.split(";")
                   .explode()
                   .str.split("=", n=1, expand=True))
    parts.columns = ["key", "value"]
    parts["value"] = parts["value"].fillna(True)      # flag fields have no '='
    parts["idx"] = parts.index
    return parts.pivot_table(index="idx", columns="key",
                             values="value", aggfunc="first")

info = parse_info(vcf["INFO"])
vcf = vcf.join(info)

# INFO values arrive as strings: cast the ones you need
vcf["AF"] = pd.to_numeric(vcf["AF"], errors="coerce").astype("float32")
vcf["DP"] = pd.to_numeric(vcf["DP"], errors="coerce").astype("Int32")

Flag fields (DB, SOMATIC) have no =, hence the fillna(True).

Faster, when you only want a few keys:

vcf["DP"] = vcf["INFO"].str.extract(r"(?:^|;)DP=(\d+)").astype("Int32")
vcf["AF"] = vcf["INFO"].str.extract(r"(?:^|;)AF=([\d.eE+-]+)").astype("float32")

The (?:^|;) anchor matters: without it, DP= also matches inside MQ_DP= or HaplotypeDP=. See String Accessor.

Untangling the sample columns

[1]python
error1ms
Traceback (most recent call last):
  File "/Users/leviosman/Developer/python-for-bio/webapp/scripts/curriculum/generate_cell_outputs.py", line 132, in run_cell_capture
    exec(compile(code, "<cell>", "exec"), namespace)
    ~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<cell>", line 15, in <module>
NameError: name 'vcf' is not defined

Melting the sample columns is what makes the data tidy: one row per (variant, sample), which is the right observational unit for per-sample analysis.

Multi-allelic sites

[2]python
error1ms
Traceback (most recent call last):
  File "/Users/leviosman/Developer/python-for-bio/webapp/scripts/curriculum/generate_cell_outputs.py", line 128, in run_cell_capture
    value = eval(compile(tail, "<cell>", "eval"), namespace)
  File "<cell>", line 1, in <module>
NameError: name 'vcf' is not defined

A multi-allelic record packs several ALT alleles into one row, and the AF, AC and AD fields then carry comma-separated values indexed to the ALT list. Most analysis code assumes one ALT per row and quietly gets it wrong.

Normalise upstream with bcftools rather than in pandas:

bcftools norm -m -any -f reference.fa input.vcf.gz -Oz -o normalized.vcf.gz

-m -any splits multi-allelics; -f reference.fa also left-aligns indels. Doing this before you read the file removes an entire class of bug. See hgvs Normalizer.

Filtering

[3]python
error1ms
Traceback (most recent call last):
  File "/Users/leviosman/Developer/python-for-bio/webapp/scripts/curriculum/generate_cell_outputs.py", line 132, in run_cell_capture
    exec(compile(code, "<cell>", "exec"), namespace)
    ~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<cell>", line 1, in <module>
NameError: name 'vcf' is not defined

The fillna(0) is critical. A variant absent from gnomAD has NA frequency; NA fails < 0.001, so without the fill you silently exclude the rarest variants: the exact opposite of the intent. See Missing Data in pandas.

Summarising

[4]python
error1ms
Traceback (most recent call last):
  File "/Users/leviosman/Developer/python-for-bio/webapp/scripts/curriculum/generate_cell_outputs.py", line 126, in run_cell_capture
    exec(compile(head, "<cell>", "exec"), namespace)
    ~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<cell>", line 3, in <module>
NameError: name 'vcf' is not defined

Ti/Tv is the single most informative VCF QC number. Whole-genome should be ~2.0–2.1, whole-exome ~2.8–3.0. Substantially lower means false positives; the metric is sensitive because sequencing errors are roughly random across substitution types while real variation is not.

Plotting

import seaborn as sns
sns.set_theme(style="ticks", context="paper")

CHROM_ORDER = [f"chr{i}" for i in range(1, 23)] + ["chrX", "chrY", "chrM"]
vcf["CHROM"] = pd.Categorical(vcf["CHROM"], categories=CHROM_ORDER, ordered=True)

fig, axes = plt.subplots(2, 2, figsize=(10, 7), layout="constrained")

sns.countplot(data=vcf, x="CHROM", ax=axes[0,0])
axes[0,0].tick_params(axis="x", rotation=90, labelsize=6)

sns.histplot(data=vcf, x="AF", bins=50, log_scale=(True, False), ax=axes[0,1])
sns.histplot(data=vcf, x="DP", bins=50, ax=axes[1,0])
sns.countplot(data=vcf, x="variant_type", ax=axes[1,1])

The ordered categorical is what stops the x-axis reading chr1, chr10, chr11, chr2. See Categorical dtype.

Handing off to hgvs

[5]python
error1ms
Traceback (most recent call last):
  File "/Users/leviosman/Developer/python-for-bio/webapp/scripts/curriculum/generate_cell_outputs.py", line 132, in run_cell_capture
    exec(compile(code, "<cell>", "exec"), namespace)
    ~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<cell>", line 4, in <module>
NameError: name 'vcf' is not defined

Only correct for SNVs: indels need proper construction, not string formatting, because VCF and HGVS represent them differently. See Applied - Annotating a Variant Table and hgvs Pitfalls.

Common mistakes

  • Ignoring multi-allelic sites.
  • NA allele frequencies failing filters, dropping the rarest variants.
  • Unanchored INFO regexes matching the wrong key.
  • Forgetting INFO/FORMAT values are strings.
  • chr1 vs 1 breaking joins.
  • Assuming pandas can handle a whole-genome VCF. Use cyvcf2.
  • Not checking Ti/Tv.
  • Alphabetical chromosome order.
  • Building HGVS strings for indels by concatenation.

See also

Applied - Genomic File Formats · Applied - Annotating a Variant Table · String Accessor · Reshaping with pivot and melt · Missing Data in pandas · Tidy Data · hgvs Normalizer

Exercise

Untangle both levels of nesting the lesson describes: the INFO column and the per-sample FORMAT columns, with multi-allelic sites split into one row per alternate allele.

Write parse_vcf(text), returning a list of output lines. Skip lines beginning with ##. The #CHROM line is the header; if there is none, print error: no header line and nothing else. Rows with a different field count from the header are malformed and must be skipped. Split each record's ALT column on commas into one row per alternate allele. INFO is semicolon-separated key=value pairs, and a flag without a value counts as present with the value 1. AF is declared Number=A, so it carries one value per alternate allele: take the entry matching the allele's index, and print NA when AF is absent or has too few entries. DP is a single value for the whole record; print NA when it is missing.

Take the genotype from the first sample column, which is the tenth field onward, by finding GT's position in that record's FORMAT string; print NA when the file has no sample columns and ./. when the sample column is missing that subfield. Print records=<n> alleles=<n> pass=<n> where the pass count is the alleles whose FILTER is exactly PASS. Then print one line per passing allele in file order holding chrom:pos:ref:alt, the depth, the allele frequency as round(value, 3) renders it -- so 0.2 stays 0.2 -- and the genotype, tab separated. If no allele passes, print no variants after the count line.

exerciseEvery submission runs independently in a fresh namespace.

Test cases · 4

#viainputexpected stdout
1stdin##fileformat=VCFv4.2 ##reference=GRCh38 #CHROM POS ID REF ALT QUAL FILTER INFO chr17 7676154 . G A 99.0 PASS DP=120;AF=0.51 chr17 43093220 rs80357906 C T 45.2 PASS DP=88;AF=0.49 chr7 55191822 . T G 88.7 PASS DP=143;AF=0.32 chr12 25245350 . C T 12.4 LowQual DP=19;AF=0.21 records=4 alleles=4 pass=3 chr17:7676154:G:A 120 0.51 NA chr17:43093220:C:T 88 0.49 NA chr7:55191822:T:G 143 0.32 NA
2stdin##fileformat=VCFv4.2 #CHROM POS ID REF ALT QUAL FILTER INFO FORMAT NA12878 chr1 100 . A G,T 50 PASS DP=30;AF=0.2,0.05;DB GT:AD:DP 0/1:20,10:30 chr1 200 . CT C 60 PASS SOMATIC DP:GT 30:1/1 chr1 300 . G A 10 LowQual DP=5;AF=0.5 GT 0/0 chr1 400 . T C 70 PASS AF=0.9 AD 10,5 records=4 alleles=5 pass=4 chr1:100:A:G 30 0.2 0/1 chr1:100:A:T 30 0.05 0/1 chr1:200:CT:C NA NA 1/1 chr1:400:T:C NA 0.9 ./.
3stdin##fileformat=VCFv4.2 #CHROM POS ID REF ALT QUAL FILTER INFO records=0 alleles=0 pass=0 no variants
4stdin##fileformat=VCFv4.2 chr1 1 . A G 1 PASS . error: no header line

Hints · 3

01Hint

pandas.read_csv over io.StringIO of the non-'##' lines gives the frame; the header still starts with '#'.

02Hint

DataFrame.explode after splitting ALT on ',' produces one row per allele, but you still need the allele index for AF.

03Hint

FORMAT.split(':').index('GT') tells you which subfield of the sample column to read.